From 5f716838745dae83c90c17f43ba28b36ab7fbc28 Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 23:54:20 +0800 Subject: [PATCH 1/3] feat(git): publish exact-base successors --- ...oxide-source-import-data-plane-v1.zh-CN.md | 3 + ...e-successor-ref-cas-data-plane-v1.zh-CN.md | 59 ++++++ native/gitoxide-helper/src/main.rs | 179 +++++++++++++++- .../tests/repository_admission.rs | 113 ++++++++++ ...itory-admission-authority-internal.test.ts | 88 ++++++++ .../gitoxide-helper-invocation-internal.ts | 198 +++++++++++++++++- ...repository-admission-authority-internal.ts | 117 ++++++++++- 7 files changed, 751 insertions(+), 6 deletions(-) create mode 100644 docs/architecture/gitoxide-successor-ref-cas-data-plane-v1.zh-CN.md diff --git a/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md b/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md index c58eee81d4..176ee4a09a 100644 --- a/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md +++ b/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md @@ -29,6 +29,9 @@ > 只把该 commit 的 reachable tree/blob 导入此前不存在的 Maka-owned bare repository,并以确定性零父 > baseline commit 发布 `refs/maka/*`。caller 不能重新提交 source path、HEAD 或 tree identity。 +后续的 exact-base successor/ref CAS 由 +`gitoxide-successor-ref-cas-data-plane-v1.zh-CN.md` 单独证明;本切片不创建 projection,也不推进 SQLite canonical head。 + ## 2. Owner 与原子性边界 - repository admission authority 拥有 source path、commit 与 tree identity; diff --git a/docs/architecture/gitoxide-successor-ref-cas-data-plane-v1.zh-CN.md b/docs/architecture/gitoxide-successor-ref-cas-data-plane-v1.zh-CN.md new file mode 100644 index 0000000000..da606c4d54 --- /dev/null +++ b/docs/architecture/gitoxide-successor-ref-cas-data-plane-v1.zh-CN.md @@ -0,0 +1,59 @@ + + +# Gitoxide successor/ref CAS 数据面 v1 + +状态:API-only Draft。该切片不接 Desktop/CLI,不实现 projection,也不宣称 Write/Edit 已经恢复闭环。 + +## 主要不变量 + +一个 owner-bound managed-repository capability 只能从它绑定的 exact base commit 构造确定性的单路径 successor;`refs/maka/*` 只有在当前值仍等于 exact base 时才可通过 CAS 前进。调用者不能重新提交 repository path、base commit 或 target ref。 + +## Owner 与原子性边界 + +- source-import authority 在成功导入后签发 opaque managed-repository capability,内部绑定 Maka-owned bare repository、accepted ref、base commit 与 base tree; +- 短生命周期 Gitoxide helper 只接受 SHA-1 repository、canonical UTF-8 `/` 路径和不超过 64 MiB 的文本内容;SHA-256 仍在 admission 阶段 fail closed; +- helper 从 immutable base tree 写入 blob、tree 与确定性单父 commit;这些对象在 ref 发布前都不是 accepted truth; +- 唯一线性化点是 `PreviousValue::MustExistAndMatch(base)` 的 ref transaction;CAS 失败不会移动 accepted ref; +- 若响应丢失,而 ref 已等于本次请求确定性计算出的 successor,精确重试返回相同 response,不会再生成一代 successor; +- 成功结果签发下一代 capability,旧 capability 只可用于同一请求的精确重试,不能基于过期 base 发布另一项修改。 + +## 失败状态与回滚 + +- ref 已由其他 successor 前进:返回 `base_commit_mismatch`,不覆盖当前 ref; +- helper/config/object/path/content 不满足协议:fail closed,不调用 system Git,不从 `PATH` fallback; +- CAS 前进程退出:新对象可能成为不可达对象,accepted ref 不变,可由后续 GC 回收; +- CAS 后响应丢失:相同请求通过确定性 successor identity 收敛; +- SQLite accepted-head、candidate receipt、projection 与 quarantine 不属于本切片,分别由重建后的 M2.1、M2.2/M2.4 和后续 projection owner 承担。 + +## 平台能力矩阵 + +| 平台 | v1 承诺 | +| --- | --- | +| Linux | 短生命周期 helper、exact-base CAS、精确重试;由三平台 workflow 验证 | +| macOS | 同 Linux;不依赖系统 Git 作为生产数据面 | +| Windows | 同 Linux;路径协议统一使用 canonical `/`,反斜杠输入在 helper 前拒绝 | + +这里不承诺对同一用户恶意替换 Maka 私有 storage root 的安全隔离;storage-root ownership 与进程级锁由产品 composition 切片负责。 + +## 后续依赖 + +1. Gitoxide fresh projection materialization/observation; +2. M1.3 product composition 消费 admission/import/candidate/projection capabilities; +3. 数据面完成后,从最新 `main` 重建 M2.2 candidate durable owner 与 M2.4 Write/Edit 生产闭环。 diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index d8a68a0965..efb666393f 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -29,7 +29,7 @@ use serde::{Deserialize, Serialize}; use unicode_normalization::UnicodeNormalization; const PROTOCOL_VERSION: u8 = 1; -const MAX_REQUEST_BYTES: u64 = 64 * 1024; +const MAX_REQUEST_BYTES: u64 = MAX_IMPORT_FILE_BYTES + 64 * 1024; const MAX_IMPORT_FILE_BYTES: u64 = 64 * 1024 * 1024; const MAX_IMPORT_BYTES: u64 = 2 * 1024 * 1024 * 1024; const MAX_IMPORT_FILES: u64 = 200_000; @@ -64,6 +64,14 @@ enum Request { destination_repository_path: PathBuf, baseline_ref: String, }, + CreateSuccessor { + protocol_version: u8, + repository_path: PathBuf, + expected_base_commit_oid: String, + target_ref: String, + path: String, + content: String, + }, } #[derive(Serialize)] @@ -96,6 +104,26 @@ enum Response<'a> { bytes_imported: u64, }, #[serde(rename_all = "camelCase")] + SuccessorPublished { + protocol_version: u8, + object_format: &'static str, + base_commit_oid: String, + successor_commit_oid: String, + successor_tree_oid: String, + result_blob_oid: String, + target_ref: String, + path: String, + }, + #[serde(rename_all = "camelCase")] + SuccessorRejected { + protocol_version: u8, + reason: &'static str, + object_format: &'static str, + expected_base_commit_oid: String, + actual_base_commit_oid: String, + target_ref: String, + }, + #[serde(rename_all = "camelCase")] HelperError { protocol_version: u8, reason: &'a str, @@ -140,6 +168,23 @@ fn run() -> Result { baseline_ref, ) } + Request::CreateSuccessor { + protocol_version, + repository_path, + expected_base_commit_oid, + target_ref, + path, + content, + } => { + assert_protocol_version(protocol_version)?; + create_successor( + repository_path, + expected_base_commit_oid, + target_ref, + path, + content, + ) + } } } @@ -395,6 +440,138 @@ fn copy_source_tree( Ok(()) } +fn create_successor( + repository_path: PathBuf, + expected_base_commit_oid: String, + target_ref: String, + path: String, + content: String, +) -> Result { + use gix::bstr::ByteSlice; + + if !target_ref.starts_with("refs/maka/") { + return Err("target_ref_outside_maka_namespace"); + } + if !is_canonical_successor_path(&path) { + return Err("invalid_successor_path"); + } + if content.len() as u64 > MAX_IMPORT_FILE_BYTES { + return Err("successor_content_limit_exceeded"); + } + + let repository = open_repository(repository_path)?; + if repository.object_hash() != gix::hash::Kind::Sha1 { + return Err("unsupported_object_format"); + } + let expected_base = gix::hash::ObjectId::from_hex(expected_base_commit_oid.as_bytes()) + .map_err(|_| "invalid_base_commit_oid")?; + if expected_base.kind() != gix::hash::Kind::Sha1 { + return Err("invalid_base_commit_oid"); + } + let base_tree = repository + .find_commit(expected_base) + .map_err(|_| "base_commit_unavailable")? + .tree_id() + .map_err(|_| "base_tree_unavailable")? + .detach(); + let result_blob = repository + .write_blob(content.as_bytes()) + .map_err(|_| "blob_write_failed")? + .detach(); + let entry_kind = match repository + .find_tree(base_tree) + .map_err(|_| "base_tree_unavailable")? + .lookup_entry_by_path(path.as_str()) + .map_err(|_| "base_path_lookup_failed")? + .map(|entry| entry.mode().kind()) + { + Some(gix::objs::tree::EntryKind::BlobExecutable) => { + gix::objs::tree::EntryKind::BlobExecutable + } + Some(gix::objs::tree::EntryKind::Blob) | None => gix::objs::tree::EntryKind::Blob, + Some(_) => return Err("unsupported_base_path_kind"), + }; + let mut editor = repository + .edit_tree(base_tree) + .map_err(|_| "tree_edit_failed")?; + editor + .upsert(path.as_str(), entry_kind, result_blob) + .map_err(|_| "tree_edit_failed")?; + let successor_tree = editor.write().map_err(|_| "tree_write_failed")?.detach(); + let signature = gix::actor::SignatureRef { + name: b"Maka Workspace Service".as_bstr(), + email: b"workspace@maka.invalid".as_bstr(), + time: "946684800 +0000", + }; + let successor_commit = repository + .new_commit_as( + signature, + signature, + "maka managed workspace successor v1", + successor_tree, + [expected_base], + ) + .map_err(|_| "commit_write_failed")? + .id() + .detach(); + + let current = repository + .find_reference(target_ref.as_str()) + .map_err(|_| "target_ref_unavailable")? + .into_fully_peeled_id() + .map_err(|_| "target_ref_unavailable")? + .detach(); + if current != expected_base && current != successor_commit { + write_response(&Response::SuccessorRejected { + protocol_version: PROTOCOL_VERSION, + reason: "base_commit_mismatch", + object_format: "sha1", + expected_base_commit_oid: expected_base.to_string(), + actual_base_commit_oid: current.to_string(), + target_ref, + }); + return Ok(ExitCode::from(3)); + } + if current == expected_base { + repository + .reference( + target_ref.as_str(), + successor_commit, + gix::refs::transaction::PreviousValue::MustExistAndMatch( + gix::refs::Target::Object(expected_base), + ), + "maka managed workspace successor", + ) + .map_err(|_| "successor_publish_failed")?; + } + + write_response(&Response::SuccessorPublished { + protocol_version: PROTOCOL_VERSION, + object_format: "sha1", + base_commit_oid: expected_base.to_string(), + successor_commit_oid: successor_commit.to_string(), + successor_tree_oid: successor_tree.to_string(), + result_blob_oid: result_blob.to_string(), + target_ref, + path, + }); + Ok(ExitCode::SUCCESS) +} + +fn is_canonical_successor_path(path: &str) -> bool { + path.len() <= 4096 + && !path.is_empty() + && !path.starts_with('/') + && !path.contains('\\') + && !path.contains('\0') + && path.split('/').all(|component| { + !component.is_empty() + && component != "." + && component != ".." + && !component.eq_ignore_ascii_case(".git") + }) +} + fn is_supported_source_component(component: &str) -> bool { !component.is_empty() && component != "." diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index 9733d298af..354a11d1e5 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -211,6 +211,108 @@ fn repairs_an_initialized_import_destination_without_a_published_baseline() { ); } +#[test] +fn publishes_and_exactly_retries_a_successor_from_the_current_ref() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("managed.git"); + let imported = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/accepted", + })); + assert!(imported.status.success()); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let baseline = imported["baselineCommitOid"].as_str().unwrap(); + let request = serde_json::json!({ + "protocolVersion": 1, + "operation": "create_successor", + "repositoryPath": destination, + "expectedBaseCommitOid": baseline, + "targetRef": "refs/maka/accepted", + "path": "docs/hello.txt", + "content": "successor content\n", + }); + + let first = invoke_request(request.clone()); + assert!(first.status.success()); + let first: serde_json::Value = serde_json::from_slice(&first.stdout).unwrap(); + assert_eq!(first["kind"], "successor_published"); + assert_eq!(first["baseCommitOid"], baseline); + assert_eq!( + git_bare_output(&destination, ["rev-parse", "refs/maka/accepted"]), + first["successorCommitOid"].as_str().unwrap() + ); + assert_eq!( + git_bare_bytes( + &destination, + [ + "show", + &format!( + "{}:docs/hello.txt", + first["successorCommitOid"].as_str().unwrap() + ) + ] + ), + b"successor content\n" + ); + + let retry = invoke_request(request); + assert!(retry.status.success()); + let retry: serde_json::Value = serde_json::from_slice(&retry.stdout).unwrap(); + assert_eq!(retry, first); +} + +#[test] +fn rejects_a_successor_when_the_target_ref_no_longer_matches_the_base() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("managed.git"); + let imported = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/accepted", + })); + assert!(imported.status.success()); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let baseline = imported["baselineCommitOid"].as_str().unwrap(); + let advanced = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "create_successor", + "repositoryPath": destination, + "expectedBaseCommitOid": baseline, + "targetRef": "refs/maka/accepted", + "path": "advanced.txt", + "content": "advanced\n", + })); + assert!(advanced.status.success()); + let advanced: serde_json::Value = serde_json::from_slice(&advanced.stdout).unwrap(); + + let rejected = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "create_successor", + "repositoryPath": destination, + "expectedBaseCommitOid": baseline, + "targetRef": "refs/maka/accepted", + "path": "should-not-exist.txt", + "content": "must not publish\n", + })); + assert_eq!(rejected.status.code(), Some(3)); + let rejected: serde_json::Value = serde_json::from_slice(&rejected.stdout).unwrap(); + assert_eq!(rejected["kind"], "successor_rejected"); + assert_eq!(rejected["reason"], "base_commit_mismatch"); + assert_eq!( + rejected["actualBaseCommitOid"], + advanced["successorCommitOid"] + ); +} + fn invoke_helper(repository_path: &Path) -> Output { invoke_request(serde_json::json!({ "protocolVersion": 1, @@ -260,6 +362,17 @@ fn git_bare_succeeds(repository: &Path, args: [&str; N]) -> bool .success() } +fn git_bare_bytes(repository: &Path, args: [&str; N]) -> Vec { + let output = Command::new("git") + .arg("--git-dir") + .arg(repository) + .args(args) + .output() + .unwrap(); + assert!(output.status.success()); + output.stdout +} + struct RepositoryFixture { root: PathBuf, } diff --git a/packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts b/packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts index 70843bb65d..69b8fa98a7 100644 --- a/packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts +++ b/packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts @@ -31,6 +31,7 @@ import { } from '../server/gitoxide-helper-artifact-authority-internal.js'; import { admitGitoxideRepositoryInternal, + createGitoxideSuccessorInternal, GitoxideRepositoryAdmissionAuthorityError, importAdmittedGitoxideRepositoryInternal, requireGitoxideRepositoryAdmissionInternal, @@ -139,6 +140,7 @@ test('imports only the exact repository identity bound to the admission capabili const expectedCommit = git(repositoryPath, ['rev-parse', 'HEAD']); const expectedTree = git(repositoryPath, ['rev-parse', 'HEAD^{tree}']); const admissionOwnerToken = {}; + const managedRepositoryOwnerToken = {}; const admitted = await admitGitoxideRepositoryInternal({ ...helper, admissionOwnerToken, @@ -152,6 +154,7 @@ test('imports only the exact repository identity bound to the admission capabili ...helper, admissionOwnerToken, repositoryCapability: admitted.capability, + managedRepositoryOwnerToken, destinationRepositoryPath, baselineRef: 'refs/maka/baseline', }); @@ -168,6 +171,7 @@ test('imports only the exact repository identity bound to the admission capabili ...helper, admissionOwnerToken: {}, repositoryCapability: admitted.capability, + managedRepositoryOwnerToken, destinationRepositoryPath: join(repositoryPath, 'forged.git'), baselineRef: 'refs/maka/forged', }), @@ -177,6 +181,90 @@ test('imports only the exact repository identity bound to the admission capabili ); }); +test('binds successor publication to the imported repository capability and exact base', async (t) => { + const helper = await admittedHelper(); + if (!helper) { + t.skip('MAKA_GITOXIDE_HELPER_PATH is required for the real helper contract test'); + return; + } + const repositoryPath = await createRepository(t, 'sha1'); + await writeFile(join(repositoryPath, 'hello.txt'), 'hello from candidate authority\n'); + git(repositoryPath, ['add', 'hello.txt']); + git(repositoryPath, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ]); + const admissionOwnerToken = {}; + const managedRepositoryOwnerToken = {}; + const admitted = await admitGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryPath, + }); + assert.equal(admitted.kind, 'accepted'); + if (admitted.kind !== 'accepted') return; + const destinationRepositoryPath = join(repositoryPath, 'managed.git'); + const imported = await importAdmittedGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryCapability: admitted.capability, + managedRepositoryOwnerToken, + destinationRepositoryPath, + baselineRef: 'refs/maka/accepted', + }); + + const successor = await createGitoxideSuccessorInternal({ + ...helper, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + path: 'docs/result.txt', + content: 'candidate result\n', + }); + + assert.equal(successor.baseCommitOid, imported.baselineCommitOid); + assert.equal(successor.targetRef, 'refs/maka/accepted'); + assert.equal( + gitBare(destinationRepositoryPath, ['rev-parse', 'refs/maka/accepted']), + successor.successorCommitOid, + ); + const exactRetry = await createGitoxideSuccessorInternal({ + ...helper, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + path: 'docs/result.txt', + content: 'candidate result\n', + }); + assert.equal(exactRetry.successorCommitOid, successor.successorCommitOid); + assert.equal(exactRetry.successorTreeOid, successor.successorTreeOid); + + const next = await createGitoxideSuccessorInternal({ + ...helper, + managedRepositoryOwnerToken, + managedRepositoryCapability: successor.managedRepositoryCapability, + path: 'docs/next.txt', + content: 'next candidate\n', + }); + assert.equal(next.baseCommitOid, successor.successorCommitOid); + await assert.rejects( + createGitoxideSuccessorInternal({ + ...helper, + managedRepositoryOwnerToken: {}, + managedRepositoryCapability: imported.managedRepositoryCapability, + path: 'forged.txt', + content: 'forged\n', + }), + (error) => + error instanceof GitoxideRepositoryAdmissionAuthorityError && + error.code === 'gitoxide_repository_admission_capability_invalid', + ); +}); + async function admittedHelper(): Promise { if (admittedHelperPromise) return admittedHelperPromise; admittedHelperPromise = (async () => { diff --git a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts index 416c9ef773..b9b6b6aa19 100644 --- a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -26,7 +26,8 @@ import { verifyGitoxideHelperArtifactForInvocationInternal, } from './gitoxide-helper-artifact-authority-internal.js'; -const MAX_REQUEST_BYTES = 64 * 1024; +const MAX_SUCCESSOR_CONTENT_BYTES = 64 * 1024 * 1024; +const MAX_REQUEST_BYTES = MAX_SUCCESSOR_CONTENT_BYTES + 64 * 1024; const MAX_STDOUT_BYTES = 64 * 1024; const MAX_STDERR_BYTES = 16 * 1024; const INVOCATION_TIMEOUT_MS = 5_000; @@ -45,12 +46,19 @@ const HELPER_ERROR_REASONS = new Set([ 'baseline_commit_write_failed', 'baseline_publish_failed', 'baseline_ref_outside_maka_namespace', + 'base_commit_unavailable', + 'base_path_lookup_failed', + 'base_tree_unavailable', + 'blob_write_failed', + 'commit_write_failed', 'import_destination_create_failed', 'import_destination_not_fresh', 'import_destination_object_format_mismatch', 'import_destination_unreadable', 'import_hooks_cleanup_failed', 'invalid_source_head_commit_oid', + 'invalid_base_commit_oid', + 'invalid_successor_path', 'source_blob_copy_failed', 'source_blob_identity_mismatch', 'source_blob_invalid', @@ -70,6 +78,13 @@ const HELPER_ERROR_REASONS = new Set([ 'source_tree_invalid', 'source_tree_unavailable', 'source_tree_visit_limit_exceeded', + 'successor_content_limit_exceeded', + 'successor_publish_failed', + 'target_ref_outside_maka_namespace', + 'target_ref_unavailable', + 'tree_edit_failed', + 'tree_write_failed', + 'unsupported_base_path_kind', 'unsupported_source_entry_kind', 'unsupported_source_path', ]); @@ -107,6 +122,30 @@ export interface GitoxideSourceImportObservationV1 { readonly bytesImported: number; } +export interface GitoxideSuccessorPublishedV1 { + readonly kind: 'successor_published'; + readonly protocolVersion: 1; + readonly objectFormat: 'sha1'; + readonly baseCommitOid: string; + readonly successorCommitOid: string; + readonly successorTreeOid: string; + readonly resultBlobOid: string; + readonly targetRef: string; + readonly path: string; +} + +export interface GitoxideSuccessorRejectedV1 { + readonly kind: 'successor_rejected'; + readonly protocolVersion: 1; + readonly reason: 'base_commit_mismatch'; + readonly objectFormat: 'sha1'; + readonly expectedBaseCommitOid: string; + readonly actualBaseCommitOid: string; + readonly targetRef: string; +} + +export type GitoxideSuccessorResultV1 = GitoxideSuccessorPublishedV1 | GitoxideSuccessorRejectedV1; + export type GitoxideHelperInvocationErrorCode = | 'gitoxide_helper_invocation_invalid' | 'gitoxide_helper_invocation_spawn_failed' @@ -228,6 +267,64 @@ export async function importSourceHeadWithGitoxideHelperInternal(input: { return decodeSourceImportOutcome(outcome); } +export async function createSuccessorWithGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; + readonly repositoryPath: string; + readonly expectedBaseCommitOid: string; + readonly targetRef: string; + readonly path: string; + readonly content: string; + readonly abortSignal?: AbortSignal; +}): Promise { + throwIfAborted(input.abortSignal); + if ( + !isAbsolute(input.repositoryPath) || + !SHA1_OID_PATTERN.test(input.expectedBaseCommitOid) || + !MAKA_REF_PATTERN.test(input.targetRef) || + !isCanonicalSuccessorPath(input.path) || + Buffer.byteLength(input.content) > MAX_SUCCESSOR_CONTENT_BYTES + ) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + 'Gitoxide successor request is invalid', + ); + } + const [artifact, repositoryPath] = await Promise.all([ + verifyGitoxideHelperArtifactForInvocationInternal(input.invocationOwnerToken, input.capability), + realpath(input.repositoryPath).catch((error) => { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + `Gitoxide managed repository path could not be resolved: ${error instanceof Error ? error.message : String(error)}`, + ); + }), + ]); + throwIfAborted(input.abortSignal); + const request = Buffer.from( + JSON.stringify({ + protocolVersion: artifact.protocolVersion, + operation: 'create_successor', + repositoryPath, + expectedBaseCommitOid: input.expectedBaseCommitOid, + targetRef: input.targetRef, + path: input.path, + content: input.content, + }), + ); + if (request.length > MAX_REQUEST_BYTES) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + 'Gitoxide helper request exceeds its byte limit', + ); + } + const outcome = await invokeHelper({ + executablePath: artifact.executablePath, + request, + abortSignal: input.abortSignal, + }); + return decodeSuccessorOutcome(outcome); +} + interface HelperProcessOutcome { readonly exitCode: number | null; readonly signal: NodeJS.Signals | null; @@ -421,6 +518,86 @@ function decodeSourceImportOutcome( ); } +function decodeSuccessorOutcome(outcome: HelperProcessOutcome): GitoxideSuccessorResultV1 { + if (outcome.signal !== null) { + throw protocolInvalid(`Gitoxide helper exited from signal ${outcome.signal}`); + } + let value: unknown; + try { + value = JSON.parse(outcome.stdout.toString('utf8')); + } catch { + throw protocolInvalid('Gitoxide helper stdout is not one JSON response'); + } + if (outcome.exitCode === 0 && isSuccessorPublished(value)) return Object.freeze(value); + if (outcome.exitCode === 3 && isSuccessorRejected(value)) return Object.freeze(value); + if (outcome.exitCode === 1 && isHelperError(value)) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_operation_failed', + `Gitoxide helper could not publish the successor: ${value.reason}`, + value.reason, + ); + } + const stderr = outcome.stderr.toString('utf8').trim(); + throw protocolInvalid( + `Gitoxide helper exit code and response disagree${stderr ? `: ${stderr}` : ''}`, + ); +} + +function isSuccessorPublished(value: unknown): value is GitoxideSuccessorPublishedV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'objectFormat', + 'baseCommitOid', + 'successorCommitOid', + 'successorTreeOid', + 'resultBlobOid', + 'targetRef', + 'path', + ]) && + value.protocolVersion === 1 && + value.kind === 'successor_published' && + value.objectFormat === 'sha1' && + typeof value.baseCommitOid === 'string' && + SHA1_OID_PATTERN.test(value.baseCommitOid) && + typeof value.successorCommitOid === 'string' && + SHA1_OID_PATTERN.test(value.successorCommitOid) && + typeof value.successorTreeOid === 'string' && + SHA1_OID_PATTERN.test(value.successorTreeOid) && + typeof value.resultBlobOid === 'string' && + SHA1_OID_PATTERN.test(value.resultBlobOid) && + typeof value.targetRef === 'string' && + MAKA_REF_PATTERN.test(value.targetRef) && + typeof value.path === 'string' && + isCanonicalSuccessorPath(value.path) + ); +} + +function isSuccessorRejected(value: unknown): value is GitoxideSuccessorRejectedV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'reason', + 'objectFormat', + 'expectedBaseCommitOid', + 'actualBaseCommitOid', + 'targetRef', + ]) && + value.protocolVersion === 1 && + value.kind === 'successor_rejected' && + value.reason === 'base_commit_mismatch' && + value.objectFormat === 'sha1' && + typeof value.expectedBaseCommitOid === 'string' && + SHA1_OID_PATTERN.test(value.expectedBaseCommitOid) && + typeof value.actualBaseCommitOid === 'string' && + SHA1_OID_PATTERN.test(value.actualBaseCommitOid) && + typeof value.targetRef === 'string' && + MAKA_REF_PATTERN.test(value.targetRef) + ); +} + function isSourceImportObservation(value: unknown): value is GitoxideSourceImportObservationV1 { return ( hasExactKeys(value, [ @@ -519,6 +696,25 @@ function hasExactKeys( return keys.length === expected.length && keys.every((key, index) => key === expected[index]); } +function isCanonicalSuccessorPath(path: string): boolean { + return ( + path.length > 0 && + path.length <= 4096 && + !path.startsWith('/') && + !path.includes('\\') && + !path.includes('\0') && + path + .split('/') + .every( + (component) => + component.length > 0 && + component !== '.' && + component !== '..' && + component.toLowerCase() !== '.git', + ) + ); +} + function helperEnvironment(): NodeJS.ProcessEnv { return { PATH: '', diff --git a/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts index 115128ed20..f57bb1c536 100644 --- a/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts @@ -22,6 +22,8 @@ import type { GitoxideHelperInvocationCapability } from './gitoxide-helper-artif import { importSourceHeadWithGitoxideHelperInternal, inspectRepositoryWithGitoxideHelperInternal, + createSuccessorWithGitoxideHelperInternal, + type GitoxideSuccessorPublishedV1, type GitoxideSourceImportObservationV1, type GitoxideRepositoryRejectionV1, } from './gitoxide-helper-invocation-internal.js'; @@ -30,6 +32,18 @@ export interface GitoxideRepositoryAdmissionCapability { readonly kind: 'gitoxide_repository_admission_capability_v1'; } +export interface GitoxideManagedRepositoryCapability { + readonly kind: 'gitoxide_managed_repository_capability_v1'; +} + +export interface GitoxideManagedRepositoryImportResultV1 extends GitoxideSourceImportObservationV1 { + readonly managedRepositoryCapability: GitoxideManagedRepositoryCapability; +} + +export interface GitoxideManagedRepositorySuccessorResultV1 extends GitoxideSuccessorPublishedV1 { + readonly managedRepositoryCapability: GitoxideManagedRepositoryCapability; +} + export interface GitoxideRepositoryAdmissionStateInternal { readonly protocolVersion: 1; readonly repositoryPath: string; @@ -46,8 +60,16 @@ export type GitoxideRepositoryAdmissionResultV1 = | GitoxideRepositoryRejectionV1; export class GitoxideRepositoryAdmissionAuthorityError extends Error { - constructor(readonly code: 'gitoxide_repository_admission_capability_invalid') { - super('Gitoxide repository admission capability is invalid'); + constructor( + readonly code: + | 'gitoxide_repository_admission_capability_invalid' + | 'gitoxide_managed_repository_base_mismatch', + ) { + super( + code === 'gitoxide_managed_repository_base_mismatch' + ? 'Gitoxide managed repository base no longer matches' + : 'Gitoxide repository admission capability is invalid', + ); this.name = 'GitoxideRepositoryAdmissionAuthorityError'; } } @@ -59,6 +81,16 @@ interface AdmissionCapabilityRecord { const admissions = new WeakMap(); +interface ManagedRepositoryCapabilityRecord { + readonly managedRepositoryOwnerToken: object; + readonly repositoryPath: string; + readonly acceptedRef: string; + readonly acceptedCommitOid: string; + readonly acceptedTreeOid: string; +} + +const managedRepositories = new WeakMap(); + export async function admitGitoxideRepositoryInternal(input: { readonly invocationOwnerToken: object; readonly helperCapability: GitoxideHelperInvocationCapability; @@ -112,10 +144,11 @@ export async function importAdmittedGitoxideRepositoryInternal(input: { readonly helperCapability: GitoxideHelperInvocationCapability; readonly admissionOwnerToken: object; readonly repositoryCapability: GitoxideRepositoryAdmissionCapability; + readonly managedRepositoryOwnerToken: object; readonly destinationRepositoryPath: string; readonly baselineRef: string; readonly abortSignal?: AbortSignal; -}): Promise { +}): Promise { const source = requireGitoxideRepositoryAdmissionInternal( input.admissionOwnerToken, input.repositoryCapability, @@ -137,5 +170,81 @@ export async function importAdmittedGitoxideRepositoryInternal(input: { 'gitoxide_repository_admission_capability_invalid', ); } - return result; + const managedRepositoryCapability = issueManagedRepositoryCapability({ + managedRepositoryOwnerToken: input.managedRepositoryOwnerToken, + repositoryPath: input.destinationRepositoryPath, + acceptedRef: result.baselineRef, + acceptedCommitOid: result.baselineCommitOid, + acceptedTreeOid: result.baselineTreeOid, + }); + return Object.freeze({ ...result, managedRepositoryCapability }); +} + +export async function createGitoxideSuccessorInternal(input: { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly managedRepositoryOwnerToken: object; + readonly managedRepositoryCapability: GitoxideManagedRepositoryCapability; + readonly path: string; + readonly content: string; + readonly abortSignal?: AbortSignal; +}): Promise { + const managed = requireManagedRepositoryCapability( + input.managedRepositoryOwnerToken, + input.managedRepositoryCapability, + ); + const result = await createSuccessorWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + repositoryPath: managed.repositoryPath, + expectedBaseCommitOid: managed.acceptedCommitOid, + targetRef: managed.acceptedRef, + path: input.path, + content: input.content, + abortSignal: input.abortSignal, + }); + if (result.kind === 'successor_rejected') { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_managed_repository_base_mismatch', + ); + } + if ( + result.baseCommitOid !== managed.acceptedCommitOid || + result.targetRef !== managed.acceptedRef + ) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } + const managedRepositoryCapability = issueManagedRepositoryCapability({ + managedRepositoryOwnerToken: input.managedRepositoryOwnerToken, + repositoryPath: managed.repositoryPath, + acceptedRef: managed.acceptedRef, + acceptedCommitOid: result.successorCommitOid, + acceptedTreeOid: result.successorTreeOid, + }); + return Object.freeze({ ...result, managedRepositoryCapability }); +} + +function issueManagedRepositoryCapability( + record: ManagedRepositoryCapabilityRecord, +): GitoxideManagedRepositoryCapability { + const capability = Object.freeze({ + kind: 'gitoxide_managed_repository_capability_v1' as const, + }); + managedRepositories.set(capability, Object.freeze({ ...record })); + return capability; +} + +function requireManagedRepositoryCapability( + ownerToken: object, + capability: GitoxideManagedRepositoryCapability, +): ManagedRepositoryCapabilityRecord { + const record = managedRepositories.get(capability); + if (!record || record.managedRepositoryOwnerToken !== ownerToken) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } + return record; } From 2fe11947046d0c1101772f259183e349675c356d Mon Sep 17 00:00:00 2001 From: zhiiw Date: Sun, 23 Aug 2026 23:56:52 +0800 Subject: [PATCH 2/3] build(git): enable tree editing --- native/gitoxide-helper/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/native/gitoxide-helper/Cargo.toml b/native/gitoxide-helper/Cargo.toml index 66a0f19dc0..4e0affcca9 100644 --- a/native/gitoxide-helper/Cargo.toml +++ b/native/gitoxide-helper/Cargo.toml @@ -28,7 +28,7 @@ name = "maka-gitoxide-helper" path = "src/main.rs" [dependencies] -gix = { version = "=0.86.0", default-features = false, features = ["sha1", "sha256"] } +gix = { version = "=0.86.0", default-features = false, features = ["sha1", "sha256", "tree-editor"] } serde = { version = "1", features = ["derive"] } serde_json = "1" unicode-normalization = "0.1" From faacbecc08814dc143c6d80b361310b4a8eef9ed Mon Sep 17 00:00:00 2001 From: zhiiw Date: Mon, 24 Aug 2026 11:09:06 +0800 Subject: [PATCH 3/3] fix(git): validate complete successor trees --- native/gitoxide-helper/src/main.rs | 60 +++++++++++++++++++ .../tests/repository_admission.rs | 41 +++++++++++++ 2 files changed, 101 insertions(+) diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index efb666393f..ce910cdce7 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -498,6 +498,7 @@ fn create_successor( .upsert(path.as_str(), entry_kind, result_blob) .map_err(|_| "tree_edit_failed")?; let successor_tree = editor.write().map_err(|_| "tree_write_failed")?.detach(); + validate_managed_tree(&repository, successor_tree, MANAGED_TREE_POLICY_V1)?; let signature = gix::actor::SignatureRef { name: b"Maka Workspace Service".as_bstr(), email: b"workspace@maka.invalid".as_bstr(), @@ -558,6 +559,65 @@ fn create_successor( Ok(ExitCode::SUCCESS) } +fn validate_managed_tree( + repository: &gix::Repository, + tree_oid: gix::hash::ObjectId, + policy: ManagedTreePolicy, +) -> Result { + let mut stats = ManagedTreeStats::default(); + validate_managed_tree_inner(repository, tree_oid, "", 0, policy, &mut stats)?; + Ok(stats) +} + +fn validate_managed_tree_inner( + repository: &gix::Repository, + tree_oid: gix::hash::ObjectId, + prefix: &str, + depth: u64, + policy: ManagedTreePolicy, + stats: &mut ManagedTreeStats, +) -> Result<(), &'static str> { + stats.enter_tree(depth, policy)?; + let tree = repository + .find_tree(tree_oid) + .map_err(|_| "source_tree_unavailable")?; + for entry in tree.iter() { + let entry = entry.map_err(|_| "source_tree_invalid")?; + let component = + std::str::from_utf8(entry.filename()).map_err(|_| "unsupported_source_path")?; + if !is_supported_source_component(component) + || component.len() as u64 > policy.max_component_bytes + { + return Err("unsupported_source_path"); + } + let relative_path = if prefix.is_empty() { + component.to_owned() + } else { + format!("{prefix}/{component}") + }; + stats.observe_entry(&relative_path, policy)?; + match entry.mode().kind() { + gix::objs::tree::EntryKind::Tree => validate_managed_tree_inner( + repository, + entry.object_id(), + &relative_path, + depth.checked_add(1).ok_or("source_tree_depth_exceeded")?, + policy, + stats, + )?, + gix::objs::tree::EntryKind::Blob | gix::objs::tree::EntryKind::BlobExecutable => { + let header = entry.id().header().map_err(|_| "source_blob_unavailable")?; + if header.kind() != gix::objs::Kind::Blob { + return Err("source_blob_invalid"); + } + stats.observe_blob(header.size(), policy)?; + } + _ => return Err("unsupported_source_entry_kind"), + } + } + Ok(()) +} + fn is_canonical_successor_path(path: &str) -> bool { path.len() <= 4096 && !path.is_empty() diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index 354a11d1e5..38722da6ad 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -313,6 +313,47 @@ fn rejects_a_successor_when_the_target_ref_no_longer_matches_the_base() { ); } +#[test] +fn rejects_a_successor_tree_outside_the_managed_tree_policy_before_ref_cas() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("managed.git"); + let imported = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/accepted", + })); + assert!(imported.status.success()); + let imported: serde_json::Value = serde_json::from_slice(&imported.stdout).unwrap(); + let baseline = imported["baselineCommitOid"].as_str().unwrap(); + let overdeep_path = (0..65) + .map(|index| format!("d{index}")) + .chain(std::iter::once("file.txt".to_owned())) + .collect::>() + .join("/"); + + let rejected = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "create_successor", + "repositoryPath": destination, + "expectedBaseCommitOid": baseline, + "targetRef": "refs/maka/accepted", + "path": overdeep_path, + "content": "must not publish\n", + })); + + assert_eq!(rejected.status.code(), Some(1)); + let rejected: serde_json::Value = serde_json::from_slice(&rejected.stdout).unwrap(); + assert_eq!(rejected["reason"], "source_tree_depth_exceeded"); + assert_eq!( + git_bare_output(&destination, ["rev-parse", "refs/maka/accepted"]), + baseline + ); +} + fn invoke_helper(repository_path: &Path) -> Output { invoke_request(serde_json::json!({ "protocolVersion": 1,