Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/architecture/gitoxide-tree-file-read-data-plane-v1.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->

# 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 重建。
106 changes: 101 additions & 5 deletions native/gitoxide-helper/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -247,6 +265,15 @@ fn run() -> Result<ExitCode, &'static str> {
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)
}
}
}

Expand Down Expand Up @@ -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<ExitCode, &'static str> {
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,
Expand Down Expand Up @@ -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 {
Expand Down
66 changes: 66 additions & 0 deletions native/gitoxide-helper/tests/repository_admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<serde_json::Value>(&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::<serde_json::Value>(&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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
importAdmittedGitoxideRepositoryInternal,
materializeGitoxideProjectionInternal,
observeGitoxideProjectionInternal,
readGitoxideTreeFileInternal,
requireGitoxideRepositoryAdmissionInternal,
} from '../server/gitoxide-repository-admission-authority-internal.js';

Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading