diff --git a/docs/architecture/gitoxide-tree-file-read-data-plane-v1.zh-CN.md b/docs/architecture/gitoxide-tree-file-read-data-plane-v1.zh-CN.md new file mode 100644 index 0000000000..ed39351d2c --- /dev/null +++ b/docs/architecture/gitoxide-tree-file-read-data-plane-v1.zh-CN.md @@ -0,0 +1,47 @@ + + +# Gitoxide accepted-tree file read 数据面 v1 + +状态:M1.3 product composition 前置 Draft。 + +## 主要不变量 + +> dependency manifest 与 lockfile 必须直接读取自 owner-bound managed repository 的 exact accepted commit/tree;不得从可变 projection、attached checkout 或 caller 路径读取后再冒充 immutable 输入。 + +## Owner 与边界 + +- Gitoxide managed-repository capability 冻结 repository path、accepted ref、commit 和 tree; +- caller 只能提交 canonical UTF-8 `/` path;不能提交 commit、tree 或 repository path; +- short-lived helper 从 exact commit tree 查找 regular blob,拒绝 tree、symlink、缺失路径、非 UTF-8 和超过 8 MiB 的文件; +- response 同时返回 commit、tree、blob OID、path、content 与 byte count;Runtime Host 对完整 envelope 严格校验,并再次与 capability identity 比较。 + +该操作只读 Git object database,不物化文件,不写 durable state,也没有 T1。失败时 fail closed;没有 projection fallback。 + +## 为什么不是“物化后 read + 再观察” + +projection 是执行视图,不是 accepted truth。即使读取前后各做一次 drift observation,外部写入仍能发生在最后一次观察后,或者 manifest/lockfile 两次读取之间。直接从 immutable tree 读取把线性化点放回 Git object identity,也让 dependency environment identity 真正绑定 accepted source bytes。 + +## 平台与资源上限 + +Linux、macOS、Windows 使用同一 helper 协议与 8 MiB/file 上限。helper stdout owner 同步提供有限上界;超大、非 UTF-8 或非普通 blob 全部拒绝。三平台真实 Rust helper test 由 Gitoxide workflow 执行。 + +## 后续 + +M1.3 composition 只允许用本 capability 读取 `package.json` 与 `package-lock.json`,随后计算 dependency environment identity。M2.2/M2.4 仍等待 product composition 完成后从最新 main 重建。 diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs index 772e5de976..14bb0e3750 100644 --- a/native/gitoxide-helper/src/main.rs +++ b/native/gitoxide-helper/src/main.rs @@ -44,6 +44,7 @@ const MANAGED_TREE_POLICY_V1: ManagedTreePolicy = ManagedTreePolicy { max_file_bytes: MAX_IMPORT_FILE_BYTES, max_bytes: MAX_IMPORT_BYTES, }; +const MAX_TREE_FILE_BYTES: u64 = 8 * 1024 * 1024; #[derive(Deserialize)] #[serde( @@ -84,6 +85,12 @@ enum Request { accepted_commit_oid: String, projection_path: PathBuf, }, + ReadTreeFile { + protocol_version: u8, + repository_path: PathBuf, + accepted_commit_oid: String, + path: String, + }, } #[derive(Serialize)] @@ -168,6 +175,17 @@ enum Response<'a> { projection_path: PathBuf, }, #[serde(rename_all = "camelCase")] + TreeFileRead { + protocol_version: u8, + object_format: &'static str, + accepted_commit_oid: String, + accepted_tree_oid: String, + blob_oid: String, + path: String, + content: String, + bytes_read: u64, + }, + #[serde(rename_all = "camelCase")] HelperError { protocol_version: u8, reason: &'a str, @@ -247,6 +265,15 @@ fn run() -> Result { assert_protocol_version(protocol_version)?; observe_projection(repository_path, accepted_commit_oid, projection_path) } + Request::ReadTreeFile { + protocol_version, + repository_path, + accepted_commit_oid, + path, + } => { + assert_protocol_version(protocol_version)?; + read_tree_file(repository_path, accepted_commit_oid, path) + } } } @@ -681,19 +708,78 @@ fn validate_managed_tree_inner( } fn is_canonical_successor_path(path: &str) -> bool { - path.len() <= 4096 + path.len() as u64 <= MANAGED_TREE_POLICY_V1.max_relative_path_bytes && !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") + component.len() as u64 <= MANAGED_TREE_POLICY_V1.max_component_bytes + && is_supported_source_component(component) }) } +fn read_tree_file( + repository_path: PathBuf, + accepted_commit_oid: String, + path: String, +) -> Result { + if !is_canonical_successor_path(&path) { + return Err("invalid_tree_file_path"); + } + let repository = open_repository(repository_path)?; + let (accepted_commit, accepted_tree) = + accepted_commit_identity(&repository, &accepted_commit_oid)?; + let entry = repository + .find_tree(accepted_tree) + .map_err(|_| "accepted_tree_unavailable")? + .lookup_entry_by_path(path.as_str()) + .map_err(|_| "tree_file_lookup_failed")? + .ok_or("tree_file_unavailable")?; + if !matches!( + entry.mode().kind(), + gix::objs::tree::EntryKind::Blob | gix::objs::tree::EntryKind::BlobExecutable + ) { + return Err("tree_file_invalid"); + } + let header = entry.id().header().map_err(|_| "tree_file_unavailable")?; + if header.kind() != gix::objs::Kind::Blob + || header.size() > MAX_TREE_FILE_BYTES.min(MANAGED_TREE_POLICY_V1.max_file_bytes) + { + return Err("tree_file_size_limit_exceeded"); + } + let blob_oid = entry.object_id(); + let blob = entry + .object() + .map_err(|_| "tree_file_unavailable")? + .try_into_blob() + .map_err(|_| "tree_file_invalid")?; + let bytes_read = blob.data.len() as u64; + if bytes_read != header.size() { + return Err("tree_file_identity_mismatch"); + } + let actual_blob_oid = + gix::objs::compute_hash(gix::hash::Kind::Sha1, gix::objs::Kind::Blob, &blob.data) + .map_err(|_| "tree_file_identity_mismatch")?; + if actual_blob_oid != blob_oid { + return Err("tree_file_identity_mismatch"); + } + let content = std::str::from_utf8(&blob.data) + .map_err(|_| "tree_file_not_utf8")? + .to_owned(); + write_response(&Response::TreeFileRead { + protocol_version: PROTOCOL_VERSION, + object_format: "sha1", + accepted_commit_oid: accepted_commit.to_string(), + accepted_tree_oid: accepted_tree.to_string(), + blob_oid: blob_oid.to_string(), + path, + content, + bytes_read, + }); + Ok(ExitCode::SUCCESS) +} + #[derive(Default)] struct ProjectionStats { files: u64, @@ -1362,6 +1448,16 @@ mod tests { Err("source_file_limit_exceeded") ); } + + #[test] + fn direct_tree_paths_share_the_managed_tree_policy() { + assert!(!is_canonical_successor_path(".gitattributes")); + assert!(!is_canonical_successor_path(&format!( + "{}.txt", + "a".repeat(MANAGED_TREE_POLICY_V1.max_component_bytes as usize) + ))); + assert!(is_canonical_successor_path("docs/guide.txt")); + } } fn reject_unsupported_object_format(object_format: String) -> ExitCode { diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs index ab433b79e5..51927f5d8f 100644 --- a/native/gitoxide-helper/tests/repository_admission.rs +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -266,6 +266,72 @@ fn publishes_and_exactly_retries_a_successor_from_the_current_ref() { assert_eq!(retry, first); } +#[test] +fn reads_one_exact_utf8_file_from_the_accepted_tree() { + let fixture = RepositoryFixture::sha1_with_commit(); + fs::create_dir_all(fixture.root.join("config")).unwrap(); + fs::write( + fixture.root.join("config/package-lock.json"), + b"{\"lockfileVersion\":3}\n", + ) + .unwrap(); + fixture.git(["add", "config/package-lock.json"]); + fixture.git([ + "-c", + "user.name=Maka Test", + "-c", + "user.email=maka@example.invalid", + "commit", + "-m", + "tree file fixture", + ]); + let accepted_commit = fixture.git_output(["rev-parse", "HEAD"]); + let accepted_tree = fixture.git_output(["rev-parse", "HEAD^{tree}"]); + let expected_blob = fixture.git_output(["rev-parse", "HEAD:config/package-lock.json"]); + + let output = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "read_tree_file", + "repositoryPath": fixture.root, + "acceptedCommitOid": accepted_commit, + "path": "config/package-lock.json", + })); + + assert!(output.status.success()); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap(), + serde_json::json!({ + "protocolVersion": 1, + "kind": "tree_file_read", + "objectFormat": "sha1", + "acceptedCommitOid": accepted_commit, + "acceptedTreeOid": accepted_tree, + "blobOid": expected_blob, + "path": "config/package-lock.json", + "content": "{\"lockfileVersion\":3}\n", + "bytesRead": 22, + }) + ); +} + +#[test] +fn refuses_to_read_a_tree_file_from_the_wrong_commit_identity() { + let fixture = RepositoryFixture::sha1_with_commit(); + let output = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "read_tree_file", + "repositoryPath": fixture.root, + "acceptedCommitOid": "0000000000000000000000000000000000000000", + "path": "hello.txt", + })); + + assert_eq!(output.status.code(), Some(1)); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap()["reason"], + "accepted_commit_unavailable" + ); +} + #[test] fn rejects_a_successor_when_the_target_ref_no_longer_matches_the_base() { let fixture = RepositoryFixture::sha1_with_commit(); 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 1f85f9eb6b..b47e3f1824 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 @@ -36,6 +36,7 @@ import { importAdmittedGitoxideRepositoryInternal, materializeGitoxideProjectionInternal, observeGitoxideProjectionInternal, + readGitoxideTreeFileInternal, requireGitoxideRepositoryAdmissionInternal, } from '../server/gitoxide-repository-admission-authority-internal.js'; @@ -267,6 +268,64 @@ test('binds successor publication to the imported repository capability and exac ); }); +test('reads dependency inputs from the immutable imported tree, not the projection filesystem', 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, 'package.json'), '{"name":"fixture","private":true}\n'); + git(repositoryPath, ['add', 'package.json']); + 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 imported = await importAdmittedGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryCapability: admitted.capability, + managedRepositoryOwnerToken, + destinationRepositoryPath: join(repositoryPath, 'managed.git'), + baselineRef: 'refs/maka/accepted', + }); + + const result = await readGitoxideTreeFileInternal({ + ...helper, + managedRepositoryOwnerToken, + managedRepositoryCapability: imported.managedRepositoryCapability, + path: 'package.json', + }); + + assert.equal(result.content, '{"name":"fixture","private":true}\n'); + assert.equal(result.acceptedCommitOid, imported.baselineCommitOid); + assert.equal(result.acceptedTreeOid, imported.baselineTreeOid); + await assert.rejects( + readGitoxideTreeFileInternal({ + ...helper, + managedRepositoryOwnerToken: {}, + managedRepositoryCapability: imported.managedRepositoryCapability, + path: 'package.json', + }), + GitoxideRepositoryAdmissionAuthorityError, + ); +}); + test('materializes and observes only the commit bound to the projection capability', async (t) => { const helper = await admittedHelper(); if (!helper) { 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 523218555b..8a56872fa4 100644 --- a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -27,8 +27,9 @@ import { } from './gitoxide-helper-artifact-authority-internal.js'; const MAX_SUCCESSOR_CONTENT_BYTES = 64 * 1024 * 1024; +const MAX_TREE_FILE_BYTES = 8 * 1024 * 1024; const MAX_REQUEST_BYTES = MAX_SUCCESSOR_CONTENT_BYTES + 64 * 1024; -const MAX_STDOUT_BYTES = 64 * 1024; +const MAX_STDOUT_BYTES = MAX_TREE_FILE_BYTES * 6 + 64 * 1024; const MAX_STDERR_BYTES = 16 * 1024; const INVOCATION_TIMEOUT_MS = 5_000; const PROJECTION_TIMEOUT_MS = 10 * 60_000; @@ -83,6 +84,13 @@ const HELPER_ERROR_REASONS = new Set([ 'source_tree_unavailable', 'source_tree_visit_limit_exceeded', 'projection_blob_invalid', + 'invalid_tree_file_path', + 'tree_file_lookup_failed', + 'tree_file_unavailable', + 'tree_file_invalid', + 'tree_file_size_limit_exceeded', + 'tree_file_identity_mismatch', + 'tree_file_not_utf8', 'projection_blob_unavailable', 'projection_byte_limit_exceeded', 'projection_destination_create_failed', @@ -207,6 +215,18 @@ export type GitoxideProjectionObservationV1 = | GitoxideProjectionObservedV1 | GitoxideProjectionDriftedV1; +export interface GitoxideTreeFileReadV1 { + readonly kind: 'tree_file_read'; + readonly protocolVersion: 1; + readonly objectFormat: 'sha1'; + readonly acceptedCommitOid: string; + readonly acceptedTreeOid: string; + readonly blobOid: string; + readonly path: string; + readonly content: string; + readonly bytesRead: number; +} + export type GitoxideHelperInvocationErrorCode = | 'gitoxide_helper_invocation_invalid' | 'gitoxide_helper_invocation_spawn_failed' @@ -438,6 +458,36 @@ export async function observeProjectionWithGitoxideHelperInternal(input: { return decodeProjectionObservationOutcome(outcome); } +export async function readTreeFileWithGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; + readonly repositoryPath: string; + readonly acceptedCommitOid: string; + readonly path: string; + readonly abortSignal?: AbortSignal; +}): Promise { + const prepared = await prepareProjectionInvocation(input); + if (!isCanonicalSuccessorPath(input.path)) { + throw invocationInvalid('Gitoxide tree file path is invalid'); + } + const outcome = await invokeHelper({ + executablePath: prepared.executablePath, + request: encodeRequest({ + protocolVersion: prepared.protocolVersion, + operation: 'read_tree_file', + repositoryPath: prepared.repositoryPath, + acceptedCommitOid: input.acceptedCommitOid, + path: input.path, + }), + abortSignal: input.abortSignal, + }); + const value = decodeTreeFileOutcome(outcome); + if (value.acceptedCommitOid !== input.acceptedCommitOid || value.path !== input.path) { + throw protocolInvalid('Gitoxide tree file response is invalid'); + } + return value; +} + async function prepareProjectionInvocation(input: { readonly invocationOwnerToken: object; readonly capability: GitoxideHelperInvocationCapability; @@ -726,6 +776,15 @@ function decodeProjectionObservationOutcome( throw protocolInvalid('Gitoxide projection observation response disagrees with its exit code'); } +function decodeTreeFileOutcome(outcome: HelperProcessOutcome): GitoxideTreeFileReadV1 { + const value = parseHelperOutcome(outcome); + if (outcome.exitCode === 0 && isTreeFileRead(value)) return Object.freeze(value); + if (outcome.exitCode === 1 && isHelperError(value)) { + throw operationFailed('read the accepted tree file', value.reason); + } + throw protocolInvalid('Gitoxide tree file response disagrees with its exit code'); +} + function parseHelperOutcome(outcome: HelperProcessOutcome): unknown { if (outcome.signal !== null) throw protocolInvalid(`Gitoxide helper exited from signal ${outcome.signal}`); @@ -768,6 +827,34 @@ function isProjectionMaterialized(value: unknown): value is GitoxideProjectionMa ); } +function isTreeFileRead(value: unknown): value is GitoxideTreeFileReadV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'objectFormat', + 'acceptedCommitOid', + 'acceptedTreeOid', + 'blobOid', + 'path', + 'content', + 'bytesRead', + ]) && + value.protocolVersion === 1 && + value.kind === 'tree_file_read' && + value.objectFormat === 'sha1' && + isSha1(value.acceptedCommitOid) && + isSha1(value.acceptedTreeOid) && + isSha1(value.blobOid) && + typeof value.path === 'string' && + isCanonicalSuccessorPath(value.path) && + typeof value.content === 'string' && + isNonNegativeSafeInteger(value.bytesRead) && + value.bytesRead <= MAX_TREE_FILE_BYTES && + Buffer.byteLength(value.content, 'utf8') === value.bytesRead + ); +} + function isProjectionObserved(value: unknown): value is GitoxideProjectionObservedV1 { return ( hasExactKeys(value, [ 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 6501dffa4c..9826284b77 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 @@ -25,10 +25,12 @@ import { createSuccessorWithGitoxideHelperInternal, materializeProjectionWithGitoxideHelperInternal, observeProjectionWithGitoxideHelperInternal, + readTreeFileWithGitoxideHelperInternal, type GitoxideProjectionMaterializedV1, type GitoxideProjectionObservationV1, type GitoxideSuccessorPublishedV1, type GitoxideSourceImportObservationV1, + type GitoxideTreeFileReadV1, type GitoxideRepositoryRejectionV1, } from './gitoxide-helper-invocation-internal.js'; @@ -327,6 +329,37 @@ export async function observeGitoxideProjectionInternal(input: { return result; } +export async function readGitoxideTreeFileInternal(input: { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly managedRepositoryOwnerToken: object; + readonly managedRepositoryCapability: GitoxideManagedRepositoryCapability; + readonly path: string; + readonly abortSignal?: AbortSignal; +}): Promise { + const managed = requireManagedRepositoryCapability( + input.managedRepositoryOwnerToken, + input.managedRepositoryCapability, + ); + const result = await readTreeFileWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + repositoryPath: managed.repositoryPath, + acceptedCommitOid: managed.acceptedCommitOid, + path: input.path, + abortSignal: input.abortSignal, + }); + if ( + result.acceptedCommitOid !== managed.acceptedCommitOid || + result.acceptedTreeOid !== managed.acceptedTreeOid + ) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } + return result; +} + function issueManagedRepositoryCapability( record: ManagedRepositoryCapabilityRecord, ): GitoxideManagedRepositoryCapability {