feat(runtime-host): add Gitoxide candidate and accepted-tree read data plane (#3857)

* feat(runtime-host): add Gitoxide successor authority

* feat(runtime-host): project accepted Gitoxide trees

* feat(runtime-host): read accepted Gitoxide tree files

* refactor(runtime-host): separate Gitoxide candidates from accepted state

* fix(runtime-host): bind Gitoxide candidate outcomes

* chore(test): refresh Windows skip inventory

* fix(runtime-host): require direct Gitoxide receipt refs

* fix(runtime-host): close Gitoxide operation evidence gaps

* test(gitoxide): corrupt loose object portably

* test(gitoxide): tamper retry blob portably

* fix(gitoxide): verify exact candidate retry tree
This commit is contained in:
zhiiw authored and GitHub committed 2026-08-30 20:02:50 +08:00
1 parent 296b05a260
commit d07ff87c7a
14 files changed
+3667 -72

No files matched your search

@@ -0,0 +1,117 @@
<!--
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 candidate and accepted-tree read data plane v1
状态:建立在 Gitoxide repository admission/source import 之上的 enabling infrastructure。
最小因果主线是:
```text
accepted A
-> 基于 A 生成 immutable staged candidate B
-> 用 operation-specific ref + MustNotExist CAS 发布 B
-> 后续 SQLite acceptance owner 决定是否 promote A -> B
```
本切片只拥有 stage,不拥有 promote;任何 candidate 路径都不得推进 `refs/maka/accepted` 或签发新的
accepted capability。
## 1. 主要不变量
本切片只证明:
> owner-bound accepted-repository capability 只能读取被冻结的 exact accepted SHA-1 commit/tree,或
> 从该 exact base 生成 operation-bound immutable candidate proof。candidate 不能推进 accepted ref,
> 也不能签发新的 accepted capability。
SQLite accepted head 仍由后续 mutation authority 拥有。本切片不提供 filesystem projection;projection
必须由 storage-root owner 在后续 PR 中以独立 destination capability 和 crash protocol 实现。
## 2. Owner、原子边界与能力
- import authority 在任何 destination side effect 前验证 helper 的 operation feature attestation;导入成功
后只在固定 `refs/maka/accepted` namespace 捕获 helper capability、managed repository path、accepted
commit、tree 与 policy v3,并签发
opaque accepted-repository capability;
- candidate helper 同时绑定 exact base commit/tree、accepted ref、operation-specific candidate ref、path
与 result bytes;完整 verified walk 后,tree editor 仍只通过 checksum-verifying finder 加载 owned tree,
禁止重新信任未验证 ODB bytes;
- published 与 no-change 都创建包含 request digest 的 deterministic candidate receipt commit,并以同一个
operation-specific ref 作为线性化点;两种成功都返回 owner-bound candidate-outcome capability;
- accepted ref 与 candidate ref 都必须是直接指向 exact commit object 的 direct ref;symbolic ref 与
annotated tag 不得通过 peel 后的语义相等冒充 receipt identity;
- candidate outcome 私下绑定 exact accepted capability identity、repository/ref、helper artifact、policy、
request digest、result SHA-256 与 Git identities;验证时必须同时出示原 accepted capability;
- direct read 沿 accepted tree 的 exact path 加载并重验 tree/blob object identity,只返回最多 8 MiB 的
UTF-8 内容;它不读取 projection filesystem;
- 每个公开入口都复用 admission 时捕获的 helper capability,并在短生命周期 helper 启动前重新验证
artifact identity,同时要求 release claim 明确 attest 本次实际调用的 operation;
- candidate fresh self-check 与 exact retry 都必须实际加载 result blob,在 64 MiB 上限内验证 kind 与
checksum,不能只相信 candidate tree 中记录的 OID edge;
- exact retry 从 verified base tree、继承的目标 mode、canonical path 与 result blob 纯计算唯一 expected
candidate tree OID,不写入新 object;receipt tree 必须与它完全相等。由此目标之外的额外变化、目标 mode
flip 和伪造 no-change receipt 都无法获得 outcome capability。
## 3. 失败与回滚
| 状态 | 处理 |
| --- | --- |
| accepted ref 在初次检查时不等于 capability base | 写 object 前返回 `base_commit_mismatch`,candidate ref 不存在 |
| accepted/candidate ref 是 symbolic ref 或指向 tag/非 commit object | 稳定返回 direct-ref/target-invalid 错误 |
| base commit/tree identity 不一致 | 写 object 前 fail closed |
| candidate 违反 policy v3 | candidate ref CAS 前 fail closed |
| candidate ref 已存在且 request digest 不同 | 写 object 前返回 `candidate_request_conflict`,不覆盖已有 candidate |
| existing receipt 的 tree 不是 exact base 单路径转换结果 | 返回 `candidate_ref_target_invalid`,不签发 capability |
| result 与 base tree 相同 | 写入 same-tree receipt commit 并 CAS operation ref,返回 `candidate_no_change` outcome capability |
| 同一 operation 后续给出不同 path/content/disposition | deterministic commit 不同,稳定 conflict |
| direct read 路径、类型、大小、UTF-8 或 object identity 不合法 | fail closed,不读取 filesystem |
| helper 超时、中断或输出不匹配 | 不签发新 capability;由调用者重试 exact operation 或 park |
candidate outcome 的线性化点是 operation-specific direct candidate ref 的 `MustNotExist` CAS。任何 object
write 前先检查既有 direct receipt:request digest 相同则从 verified base 纯计算 expected successor tree,
要求 receipt tree 与之精确一致,再重验 result blob 后 exact retry;request 不同则稳定 conflict。CAS 竞争失败
后再次重读 direct ref:相同 deterministic commit 视为 exact retry,不同 commit 视为 conflict;无法证明
publication 状态时返回 `candidate_publication_indeterminate`。
accepted ref 在本切片中只读,并与 `refs/maka/candidates/<operation-hash>` namespace 不重叠。第二次 accepted
ref 检查与 candidate CAS 不是一个跨 ref transaction:二者之间的 accepted drift 可能留下绑定旧 base 的
stale candidate evidence,但不能推进 accepted truth。后续 SQLite acceptance 必须重新验证 canonical head 并
拒绝 stale candidate。direct read 是 observation,不发布持久事实。
## 4. 平台与资源边界
- Linux/macOS/Windows 使用同一 Gitoxide protocol;本切片不写工作区 filesystem;
- policy v3 的 path、Unicode fold、tree/blob、`.gitattributes` 和 object identity 规则只由 Rust helper
判定;TypeScript 只负责 transport byte bound,避免复制一份会漂移的 path policy;
- candidate 和 direct read 的绝对 deadline 为 10 分钟;
- candidate content 通过 bounded Base64 field 传输,64 MiB 业务上限与 JSON escaping 无关;
- direct read 单文件最多 8 MiB,helper stdout 以 JSON worst-case escaping 设置固定上限。
## 5. 后续依赖
后续 mutation lifecycle 把 accepted capability 与 SQLite T1 reservation 绑定;SQLite 原子接受 candidate
后,单独的 promotion/reconcile owner 才能更新 accepted-ref projection 并签发新 accepted capability。
Desktop/CLI 消费者不得绕过 capability 直接传 repository path、helper path 或 accepted commit。
当前 accepted/candidate capability 是进程内 opaque authority。Git candidate ref/receipt commit 跨进程耐久,
但 Host crash 后重新签发 capability 需要后续 durable workspace receipt owner:它必须重开 exact repository、
持有 storage-root lease,重验 direct accepted/candidate ref、receipt commit checksum、request digest、base
parent、candidate tree 与 path/result blob,再允许 SQLite 接受。本切片的 exact retry 承诺覆盖同一进程及
helper process response loss;不把尚未接线的 cross-Host reopen 或 candidate GC 宣称为已完成能力。
@@ -93,7 +93,7 @@ capability 表示一次明确线性化点上的 immutable Git commit/tree snapsh
6. exact commit/tree/blob checksum verification、atomic destination claim 与 deterministic zero-parent
baseline publication。
`managedTreePolicyVersion: 2` 是当前唯一的 **portable lexical materialization policy**,而不只是 Git
`managedTreePolicyVersion: 3` 是当前唯一的 **portable lexical materialization policy**,而不只是 Git
object import policy。未发布的 policy v1 已删除;helper、Host capability 和 import response 都拒绝把
version 1 重新解释成当前语义。v2 只证明 Git tree 的词法身份和受支持 attributes 满足同一套保守规则,
不宣称已证明某个真实 Windows volume 上的 path-length、8.3 alias、ACL 或大小写能力。
@@ -119,7 +119,7 @@ CRLF 或 tab;literal path 不接受任何 whitespace/control。外部 `filter`
Unicode 17.0 NFC → Unicode 16.0 Default Full Case Folding(Non-Turkic)→ Unicode 17.0 NFC
```
原路径和 folded key 各自执行单路径与累计 byte budget。后续 candidate、tree read 必须消费 policy 2;
原路径和 folded key 各自执行单路径与累计 byte budget。后续 candidate、tree read 必须消费 policy 3;
真实 projection 还必须由独立的 `FilesystemMaterializationProfileV1` 在 fresh destination 上证明
目标 filesystem 的 case/alias/path-length 能力并执行 create/post-observation,不能把本词法检查冒充为
真实文件系统准入。该 projection/candidate owner 还必须实现与 `text=auto eol=lf` 对称的确定性 LF
@@ -95,7 +95,7 @@ hook/filter/submodule/LFS,也不接入 T1/T2。
process-crash 自动恢复或断电恢复;
- Windows 保留 Git tree 中的 executable bit,不把它映射成 ACL 权威。
上述规则是 portable lexical materialization policy v2。它不证明 Windows path-length、8.3 alias 或
上述规则是 portable lexical materialization policy v3。它不证明 Windows path-length、8.3 alias 或
目标 volume 的大小写行为;这些能力必须由未来 projection owner 的
`FilesystemMaterializationProfileV1` 在 fresh destination 上独立验证。v2 import 只验证 attributes
语义并复制 immutable objects;真正 materialize 和 candidate 写回必须消费同一 policy,并对受支持文本
+4 -2
View File
@@ -15,11 +15,11 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t
| Classification | Count |
|---|---:|
| windows-backend-gap | 25 |
| windows-backend-gap | 27 |
| portable-candidate | 10 |
| platform-contract | 31 |
Total Windows-excluded declarations: **66**
Total Windows-excluded declarations: **68**
## Inventory
@@ -40,6 +40,8 @@ Total Windows-excluded declarations: **66**
| windows-backend-gap | `packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts` applies the import deadline and terminates the helper process tree | `process.platform === 'win32'` |
| windows-backend-gap | `packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts` rejects an import response that does not match the requested baseline ref | `process.platform === 'win32'` |
| windows-backend-gap | `packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts` rejects an import response that does not match the requested source HEAD | `process.platform === 'win32'` |
| windows-backend-gap | `packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts` rejects a candidate response whose blob identity does not match the requested bytes | `process.platform === 'win32'` |
| windows-backend-gap | `packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts` rejects a direct-read response whose blob identity does not match its content | `process.platform === 'win32'` |
| windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` an automatic failed liveness check is connection-fatal and Client close stays local | `process.platform === 'win32'` |
| windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` bounded election does not launch a Candidate after handshake exhausts the deadline | `process.platform === 'win32'` |
| windows-backend-gap | `packages/runtime-host/src/__tests__/host-kernel.test.ts` a non-reading Client overload is isolated to its connection | `process.platform === 'win32'` |
+29 -9
View File
@@ -17,6 +17,12 @@ dependencies = [
"rustversion",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bisync"
version = "0.3.0"
@@ -549,14 +555,14 @@ dependencies = [
[[package]]
name = "gix-hash"
version = "0.26.1"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf125eae66b7d6e4395511a06c0d43a3c34eac96c8641fb98b22078faee65b8"
checksum = "380b9c423a54f0821064b954b5a5ab25c660812f6a91da03c3f1cb4b10762aa5"
dependencies = [
"faster-hex",
"gix-features",
"sha1-checked",
"sha2",
"sha2 0.11.0",
"thiserror",
]
@@ -645,9 +651,9 @@ dependencies = [
[[package]]
name = "gix-packetline"
version = "0.22.0"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3766025c72319c4accdd854a18e6f0dd176c8eb0f3bc8a60a7765be2b50cabf2"
checksum = "792bd92bea390087e6223b18d4d023decc36467de31e6b066b69b94beea1b9e2"
dependencies = [
"bstr",
"faster-hex",
@@ -1039,10 +1045,13 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
name = "maka-gitoxide-helper"
version = "0.0.0"
dependencies = [
"base64",
"caseless",
"gix",
"serde",
"serde_json",
"sha2 0.10.9",
"tempfile",
"unicode-normalization",
]
@@ -1226,7 +1235,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
@@ -1263,6 +1272,17 @@ dependencies = [
"sha1",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
@@ -1305,9 +1325,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.3"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
@@ -1344,7 +1364,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
"syn 3.0.4",
]
[[package]]
+6 -1
View File
@@ -28,8 +28,13 @@ name = "maka-gitoxide-helper"
path = "src/main.rs"
[dependencies]
base64 = "0.22"
caseless = "=0.2.2"
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"
sha2 = "0.10"
unicode-normalization = "0.1"
[dev-dependencies]
tempfile = "3"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -80,6 +80,7 @@ test('rejects a release claim reached through a symbolic link or junction', asyn
platform: process.platform,
arch: process.arch,
protocolVersion: 1,
supportedOperations: ['inspect_repository', 'import_source_head'],
});
await assert.rejects(
@@ -109,6 +110,7 @@ test('keeps an admitted helper artifact opaque and bound to its invocation owner
platform: process.platform,
arch: process.arch,
protocolVersion: 1,
supportedOperations: ['inspect_repository', 'import_source_head'],
});
const capability = await admitGitoxideHelperArtifactInternal({
@@ -146,6 +148,7 @@ test('rejects a helper artifact changed after admission', async (t) => {
platform: process.platform,
arch: process.arch,
protocolVersion: 1,
supportedOperations: ['inspect_repository', 'import_source_head'],
});
const capability = await admitGitoxideHelperArtifactInternal({
releaseOwnerToken,
@@ -26,15 +26,18 @@ import { join } from 'node:path';
import test, { type TestContext } from 'node:test';
import {
admitGitoxideHelperArtifactInternal,
GitoxideHelperArtifactAuthorityError,
type GitoxideHelperInvocationCapability,
issueGitoxideHelperReleaseArtifactClaimInternal,
} from '../server/gitoxide-helper-artifact-authority-internal.js';
import {
createCandidateWithGitoxideHelperInternal,
GITOXIDE_HELPER_ERROR_REASONS_V1,
GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL,
GitoxideHelperInvocationError,
importSourceHeadWithGitoxideHelperInternal,
inspectRepositoryWithGitoxideHelperInternal,
readTreeFileWithGitoxideHelperInternal,
runGitoxideOperationWithinDeadlineInternal,
} from '../server/gitoxide-helper-invocation-internal.js';
@@ -45,10 +48,12 @@ interface AdmittedHelper {
let admittedHelperPromise: Promise<AdmittedHelper | undefined> | undefined;
test('uses a bounded import deadline distinct from repository inspection', () => {
test('uses bounded mutation/import deadlines distinct from repository inspection', () => {
assert.deepEqual(GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL, {
inspectRepositoryMs: 5_000,
importSourceHeadMs: 10 * 60_000,
createCandidateMs: 10 * 60_000,
acceptedTreeReadMs: 10 * 60_000,
});
});
@@ -66,6 +71,103 @@ test('bounds preflight work with the same absolute operation deadline', async ()
assert.ok(performance.now() - startedAt < 1_000);
});
test('rejects repository inspection when the helper did not attest that operation', async (t) => {
const helper = await admitHelperPath(process.execPath, ['import_source_head']);
const repositoryPath = await createRepository(t, 'sha1');
await assert.rejects(
inspectRepositoryWithGitoxideHelperInternal({ ...helper, repositoryPath }),
(error) =>
error instanceof GitoxideHelperArtifactAuthorityError &&
error.code === 'gitoxide_helper_release_claim_unsupported',
);
});
test('rejects source import when the helper did not attest that operation', async (t) => {
const helper = await admitHelperPath(process.execPath, [
'inspect_repository',
'create_candidate',
'read_tree_file',
]);
const sourceRepositoryPath = await createRepository(t, 'sha1');
await writeFile(join(sourceRepositoryPath, 'source.txt'), 'source\n');
git(sourceRepositoryPath, ['add', 'source.txt']);
git(sourceRepositoryPath, [
'-c',
'user.name=Maka Test',
'-c',
'user.email=maka@example.invalid',
'commit',
'--quiet',
'-m',
'fixture',
]);
const destinationRepositoryPath = join(sourceRepositoryPath, 'must-not-exist.git');
await assert.rejects(
importSourceHeadWithGitoxideHelperInternal({
...helper,
sourceRepositoryPath,
expectedSourceHeadCommitOid: git(sourceRepositoryPath, ['rev-parse', 'HEAD']),
destinationRepositoryPath,
baselineRef: 'refs/maka/accepted',
managedTreePolicyVersion: 3,
}),
(error) =>
error instanceof GitoxideHelperArtifactAuthorityError &&
error.code === 'gitoxide_helper_release_claim_unsupported',
);
await assert.rejects(stat(destinationRepositoryPath), { code: 'ENOENT' });
});
test('rejects candidate creation when the helper did not attest that operation', async (t) => {
const helper = await admitHelperPath(process.execPath, [
'inspect_repository',
'import_source_head',
'read_tree_file',
]);
const repositoryPath = await createRepository(t, 'sha1');
await assert.rejects(
createCandidateWithGitoxideHelperInternal({
...helper,
repositoryPath,
acceptedRef: 'refs/maka/accepted',
expectedBaseCommitOid: 'a'.repeat(40),
expectedBaseTreeOid: 'b'.repeat(40),
candidateRef: `refs/maka/candidates/${'c'.repeat(64)}`,
path: 'result.txt',
content: 'result\n',
managedTreePolicyVersion: 3,
}),
(error) =>
error instanceof GitoxideHelperArtifactAuthorityError &&
error.code === 'gitoxide_helper_release_claim_unsupported',
);
});
test('rejects accepted-tree reads when the helper did not attest that operation', async (t) => {
const helper = await admitHelperPath(process.execPath, [
'inspect_repository',
'import_source_head',
'create_candidate',
]);
const repositoryPath = await createRepository(t, 'sha1');
await assert.rejects(
readTreeFileWithGitoxideHelperInternal({
...helper,
repositoryPath,
acceptedCommitOid: 'a'.repeat(40),
path: 'result.txt',
managedTreePolicyVersion: 3,
}),
(error) =>
error instanceof GitoxideHelperArtifactAuthorityError &&
error.code === 'gitoxide_helper_release_claim_unsupported',
);
});
test('waits for helper process identity by elapsed time instead of scheduler turns', async (t) => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-marker-')));
t.after(() => rm(root, { recursive: true, force: true }));
@@ -116,7 +218,7 @@ test('applies the import deadline and terminates the helper process tree', {
expectedSourceHeadCommitOid,
destinationRepositoryPath: join(root, 'destination.git'),
baselineRef: 'refs/maka/baseline',
managedTreePolicyVersion: 2,
managedTreePolicyVersion: 3,
});
void operation.then(
() => {
@@ -204,6 +306,102 @@ test('rejects an import response that does not match the requested source HEAD',
});
});
test('rejects a candidate response whose blob identity does not match the requested bytes', {
skip: process.platform === 'win32',
}, async (t) => {
const root = await realpath(
await mkdtemp(join(tmpdir(), 'maka-gitoxide-candidate-correlation-')),
);
t.after(() => rm(root, { recursive: true, force: true }));
const repositoryPath = await createRepository(t, 'sha1');
const acceptedRef = 'refs/maka/accepted';
const candidateRef = `refs/maka/candidates/${'1'.repeat(64)}`;
const expectedBaseCommitOid = 'a'.repeat(40);
const expectedBaseTreeOid = 'b'.repeat(40);
const helperPath = join(root, 'mismatched-candidate-helper');
const response = JSON.stringify({
protocolVersion: 1,
kind: 'candidate_published',
objectFormat: 'sha1',
baseCommitOid: expectedBaseCommitOid,
baseTreeOid: expectedBaseTreeOid,
candidateCommitOid: 'c'.repeat(40),
candidateTreeOid: 'd'.repeat(40),
resultBlobOid: 'e'.repeat(40),
requestDigestSha256: candidateRequestDigestForTest({
acceptedRef,
expectedBaseCommitOid,
expectedBaseTreeOid,
candidateRef,
path: 'result.txt',
content: Buffer.from('expected bytes\n'),
}),
acceptedRef,
candidateRef,
path: 'result.txt',
managedTreePolicyVersion: 3,
});
await writeFile(helperPath, `#!/bin/sh\n/bin/cat >/dev/null\nprintf '%s\\n' '${response}'\n`);
await chmod(helperPath, 0o755);
const helper = await admitHelperPath(helperPath, ['create_candidate']);
await assert.rejects(
createCandidateWithGitoxideHelperInternal({
...helper,
repositoryPath,
acceptedRef,
expectedBaseCommitOid,
expectedBaseTreeOid,
candidateRef,
path: 'result.txt',
content: 'expected bytes\n',
managedTreePolicyVersion: 3,
}),
(error) =>
error instanceof GitoxideHelperInvocationError &&
error.code === 'gitoxide_helper_invocation_protocol_invalid',
);
});
test('rejects a direct-read response whose blob identity does not match its content', {
skip: process.platform === 'win32',
}, async (t) => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-read-correlation-')));
t.after(() => rm(root, { recursive: true, force: true }));
const repositoryPath = await createRepository(t, 'sha1');
const acceptedCommitOid = 'a'.repeat(40);
const content = 'returned bytes\n';
const helperPath = join(root, 'mismatched-read-helper');
const response = JSON.stringify({
protocolVersion: 1,
kind: 'tree_file_read',
objectFormat: 'sha1',
acceptedCommitOid,
acceptedTreeOid: 'b'.repeat(40),
blobOid: 'c'.repeat(40),
path: 'result.txt',
content,
bytesRead: Buffer.byteLength(content),
managedTreePolicyVersion: 3,
});
await writeFile(helperPath, `#!/bin/sh\n/bin/cat >/dev/null\nprintf '%s\\n' '${response}'\n`);
await chmod(helperPath, 0o755);
const helper = await admitHelperPath(helperPath, ['read_tree_file']);
await assert.rejects(
readTreeFileWithGitoxideHelperInternal({
...helper,
repositoryPath,
acceptedCommitOid,
path: 'result.txt',
managedTreePolicyVersion: 3,
}),
(error) =>
error instanceof GitoxideHelperInvocationError &&
error.code === 'gitoxide_helper_invocation_protocol_invalid',
);
});
test('keeps the Rust and TypeScript helper error protocol exhaustive', async () => {
const rustSource = await readFile(
new URL('../../../../native/gitoxide-helper/src/main.rs', import.meta.url),
@@ -359,7 +557,7 @@ async function assertMismatchedImportResponseRejected(
baselineCommitOid: 'a'.repeat(40),
baselineTreeOid: sourceTreeOid,
baselineRef: 'refs/maka/expected-ref',
managedTreePolicyVersion: 2,
managedTreePolicyVersion: 3,
filesImported: 1,
bytesImported: 29,
...override,
@@ -375,7 +573,7 @@ async function assertMismatchedImportResponseRejected(
expectedSourceHeadCommitOid,
destinationRepositoryPath: join(root, 'destination.git'),
baselineRef: 'refs/maka/expected-ref',
managedTreePolicyVersion: 2,
managedTreePolicyVersion: 3,
}),
(error) =>
error instanceof GitoxideHelperInvocationError &&
@@ -383,7 +581,15 @@ async function assertMismatchedImportResponseRejected(
);
}
async function admitHelperPath(configuredHelperPath: string): Promise<AdmittedHelper> {
async function admitHelperPath(
configuredHelperPath: string,
supportedOperations: readonly (
| 'inspect_repository'
| 'import_source_head'
| 'create_candidate'
| 'read_tree_file'
)[] = ['inspect_repository', 'import_source_head'],
): Promise<AdmittedHelper> {
const helperPath = await realpath(configuredHelperPath);
const helperBytes = await readFile(helperPath);
const helperInfo = await stat(helperPath);
@@ -396,6 +602,7 @@ async function admitHelperPath(configuredHelperPath: string): Promise<AdmittedHe
platform: process.platform,
arch: process.arch,
protocolVersion: 1,
supportedOperations,
});
const capability = await admitGitoxideHelperArtifactInternal({
releaseOwnerToken,
@@ -405,6 +612,30 @@ async function admitHelperPath(configuredHelperPath: string): Promise<AdmittedHe
return { invocationOwnerToken, capability };
}
function candidateRequestDigestForTest(input: {
readonly acceptedRef: string;
readonly expectedBaseCommitOid: string;
readonly expectedBaseTreeOid: string;
readonly candidateRef: string;
readonly path: string;
readonly content: Buffer;
}): string {
const hash = createHash('sha256').update('maka.gitoxide.candidate-request.v1\0');
for (const field of [
Buffer.from(input.acceptedRef),
Buffer.from(input.expectedBaseCommitOid),
Buffer.from(input.expectedBaseTreeOid),
Buffer.from(input.candidateRef),
Buffer.from(input.path),
input.content,
]) {
const length = Buffer.alloc(8);
length.writeBigUInt64BE(BigInt(field.length));
hash.update(length).update(field);
}
return hash.digest('hex');
}
async function waitForProcessMarker(
path: string,
timeoutMs = 10_000,
@@ -26,13 +26,18 @@ import { join } from 'node:path';
import test, { type TestContext } from 'node:test';
import {
admitGitoxideHelperArtifactInternal,
GitoxideHelperArtifactAuthorityError,
type GitoxideHelperOperationInternal,
type GitoxideHelperInvocationCapability,
issueGitoxideHelperReleaseArtifactClaimInternal,
} from '../server/gitoxide-helper-artifact-authority-internal.js';
import {
admitGitoxideRepositoryInternal,
createGitoxideCandidateInternal,
GitoxideRepositoryAdmissionAuthorityError,
importAdmittedGitoxideRepositoryInternal,
readGitoxideTreeFileInternal,
requireGitoxideCandidateOutcomeForAcceptedRepositoryInternal,
requireGitoxideRepositoryAdmissionInternal,
} from '../server/gitoxide-repository-admission-authority-internal.js';
import { GitoxideHelperInvocationError } from '../server/gitoxide-helper-invocation-internal.js';
@@ -110,7 +115,7 @@ test('issues an opaque owner-bound admission capability from the exact helper ob
headCommitOid: expectedCommit,
headTreeOid: expectedTree,
helperArtifactSha256: helper.helperArtifactSha256,
managedTreePolicyVersion: 2,
managedTreePolicyVersion: 3,
},
);
});
@@ -169,27 +174,28 @@ test('imports only the exact repository identity bound to the admission capabili
assert.equal(admitted.kind, 'accepted');
if (admitted.kind !== 'accepted') return;
const destinationRepositoryPath = join(repositoryPath, 'managed.git');
const acceptedRepositoryOwnerToken = {};
const imported = await importAdmittedGitoxideRepositoryInternal({
admissionOwnerToken,
repositoryCapability: admitted.capability,
acceptedRepositoryOwnerToken,
destinationRepositoryPath,
baselineRef: 'refs/maka/baseline',
});
assert.equal(imported.sourceHeadCommitOid, expectedCommit);
assert.equal(imported.sourceTreeOid, expectedTree);
assert.equal(imported.baselineTreeOid, expectedTree);
assert.equal(
gitBare(destinationRepositoryPath, ['rev-parse', 'refs/maka/baseline']),
gitBare(destinationRepositoryPath, ['rev-parse', 'refs/maka/accepted']),
imported.baselineCommitOid,
);
await assert.rejects(
importAdmittedGitoxideRepositoryInternal({
admissionOwnerToken: {},
repositoryCapability: admitted.capability,
acceptedRepositoryOwnerToken,
destinationRepositoryPath: join(repositoryPath, 'forged.git'),
baselineRef: 'refs/maka/forged',
}),
(error) =>
error instanceof GitoxideRepositoryAdmissionAuthorityError &&
@@ -197,6 +203,284 @@ test('imports only the exact repository identity bound to the admission capabili
);
});
test('rejects a helper without candidate/read attestation before claiming the import destination', async (t) => {
const helper = await admittedHelperWithOperations(['inspect_repository', 'import_source_head']);
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'), 'feature attestation\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 admitted = await admitGitoxideRepositoryInternal({
...helper,
admissionOwnerToken,
repositoryPath,
});
assert.equal(admitted.kind, 'accepted');
if (admitted.kind !== 'accepted') return;
const destinationRepositoryPath = join(repositoryPath, 'must-not-be-claimed.git');
await assert.rejects(
importAdmittedGitoxideRepositoryInternal({
admissionOwnerToken,
repositoryCapability: admitted.capability,
acceptedRepositoryOwnerToken: {},
destinationRepositoryPath,
}),
(error) =>
error instanceof GitoxideHelperArtifactAuthorityError &&
error.code === 'gitoxide_helper_release_claim_unsupported',
);
await assert.rejects(stat(destinationRepositoryPath), { code: 'ENOENT' });
});
test('publishes an operation-bound candidate without advancing accepted authority', 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 acceptedRepositoryOwnerToken = {};
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({
admissionOwnerToken,
repositoryCapability: admitted.capability,
acceptedRepositoryOwnerToken,
destinationRepositoryPath,
});
const candidateOwnerToken = {};
const candidate = await createGitoxideCandidateInternal({
acceptedRepositoryOwnerToken,
acceptedRepositoryCapability: imported.acceptedRepositoryCapability,
candidateOwnerToken,
operationId: 'operation-1',
path: 'docs/result.txt',
content: 'candidate result\n',
});
assert.equal(candidate.kind, 'candidate_published');
if (candidate.kind !== 'candidate_published') return;
assert.equal(candidate.baseCommitOid, imported.baselineCommitOid);
assert.equal(candidate.baseTreeOid, imported.baselineTreeOid);
assert.equal(
candidate.candidateRef,
`refs/maka/candidates/${createHash('sha256').update('operation-1').digest('hex')}`,
);
assert.equal(
gitBare(destinationRepositoryPath, ['rev-parse', 'refs/maka/accepted']),
imported.baselineCommitOid,
);
assert.equal(
gitBare(destinationRepositoryPath, ['rev-parse', candidate.candidateRef]),
candidate.candidateCommitOid,
);
assert.deepEqual(candidate.candidateOutcomeCapability, {
kind: 'gitoxide_candidate_outcome_capability_v1',
});
const candidateProof = requireGitoxideCandidateOutcomeForAcceptedRepositoryInternal({
acceptedRepositoryOwnerToken,
acceptedRepositoryCapability: imported.acceptedRepositoryCapability,
candidateOwnerToken,
candidateOutcomeCapability: candidate.candidateOutcomeCapability,
});
assert.equal(candidateProof.operationId, 'operation-1');
assert.equal(candidateProof.acceptedRef, 'refs/maka/accepted');
assert.equal(candidateProof.objectFormat, 'sha1');
assert.equal(candidateProof.managedTreePolicyVersion, 3);
assert.match(candidateProof.helperArtifactSha256, /^sha256:[0-9a-f]{64}$/);
assert.match(candidateProof.requestDigestSha256, /^[0-9a-f]{64}$/);
assert.equal(
candidateProof.resultContentSha256,
`sha256:${createHash('sha256').update('candidate result\n').digest('hex')}`,
);
assert.throws(
() =>
requireGitoxideCandidateOutcomeForAcceptedRepositoryInternal({
acceptedRepositoryOwnerToken,
acceptedRepositoryCapability: imported.acceptedRepositoryCapability,
candidateOwnerToken: {},
candidateOutcomeCapability: candidate.candidateOutcomeCapability,
}),
GitoxideRepositoryAdmissionAuthorityError,
);
const secondAcceptedRepositoryOwnerToken = {};
const sameContentOtherRepository = await importAdmittedGitoxideRepositoryInternal({
admissionOwnerToken,
repositoryCapability: admitted.capability,
acceptedRepositoryOwnerToken: secondAcceptedRepositoryOwnerToken,
destinationRepositoryPath: join(repositoryPath, 'managed-other.git'),
});
assert.throws(
() =>
requireGitoxideCandidateOutcomeForAcceptedRepositoryInternal({
acceptedRepositoryOwnerToken: secondAcceptedRepositoryOwnerToken,
acceptedRepositoryCapability: sameContentOtherRepository.acceptedRepositoryCapability,
candidateOwnerToken,
candidateOutcomeCapability: candidate.candidateOutcomeCapability,
}),
GitoxideRepositoryAdmissionAuthorityError,
);
const acceptedRead = await readGitoxideTreeFileInternal({
acceptedRepositoryOwnerToken,
acceptedRepositoryCapability: imported.acceptedRepositoryCapability,
path: 'hello.txt',
});
assert.equal(acceptedRead.content, 'hello from candidate authority\n');
const noChangeOperationId = 'operation-no-change';
const noChange = await createGitoxideCandidateInternal({
acceptedRepositoryOwnerToken,
acceptedRepositoryCapability: imported.acceptedRepositoryCapability,
candidateOwnerToken,
operationId: noChangeOperationId,
path: 'hello.txt',
content: 'hello from candidate authority\n',
});
assert.equal(noChange.kind, 'candidate_no_change');
assert.equal(
gitBare(destinationRepositoryPath, ['rev-parse', noChange.candidateRef]),
noChange.candidateCommitOid,
);
assert.equal(noChange.candidateTreeOid, imported.baselineTreeOid);
assert.equal(
requireGitoxideCandidateOutcomeForAcceptedRepositoryInternal({
acceptedRepositoryOwnerToken,
acceptedRepositoryCapability: imported.acceptedRepositoryCapability,
candidateOwnerToken,
candidateOutcomeCapability: noChange.candidateOutcomeCapability,
}).disposition,
'no_change',
);
const exactRetry = await createGitoxideCandidateInternal({
acceptedRepositoryOwnerToken,
acceptedRepositoryCapability: imported.acceptedRepositoryCapability,
candidateOwnerToken,
operationId: 'operation-1',
path: 'docs/result.txt',
content: 'candidate result\n',
});
assert.equal(exactRetry.kind, 'candidate_published');
if (exactRetry.kind !== 'candidate_published') return;
assert.equal(exactRetry.candidateCommitOid, candidate.candidateCommitOid);
assert.equal(exactRetry.candidateTreeOid, candidate.candidateTreeOid);
const second = await createGitoxideCandidateInternal({
acceptedRepositoryOwnerToken,
acceptedRepositoryCapability: imported.acceptedRepositoryCapability,
candidateOwnerToken,
operationId: 'operation-2',
path: 'docs/next.txt',
content: 'next candidate\n',
});
assert.equal(second.kind, 'candidate_published');
if (second.kind !== 'candidate_published') return;
assert.equal(second.baseCommitOid, imported.baselineCommitOid);
assert.notEqual(second.candidateRef, candidate.candidateRef);
await assert.rejects(
createGitoxideCandidateInternal({
acceptedRepositoryOwnerToken: {},
acceptedRepositoryCapability: imported.acceptedRepositoryCapability,
candidateOwnerToken,
operationId: 'forged-operation',
path: 'forged.txt',
content: 'forged\n',
}),
(error) =>
error instanceof GitoxideRepositoryAdmissionAuthorityError &&
error.code === 'gitoxide_repository_admission_capability_invalid',
);
});
test('reads an immutable accepted-tree file without consulting 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 acceptedRepositoryOwnerToken = {};
const admitted = await admitGitoxideRepositoryInternal({
...helper,
admissionOwnerToken,
repositoryPath,
});
assert.equal(admitted.kind, 'accepted');
if (admitted.kind !== 'accepted') return;
const imported = await importAdmittedGitoxideRepositoryInternal({
admissionOwnerToken,
repositoryCapability: admitted.capability,
acceptedRepositoryOwnerToken,
destinationRepositoryPath: join(repositoryPath, 'managed-tree-read.git'),
});
const result = await readGitoxideTreeFileInternal({
acceptedRepositoryOwnerToken,
acceptedRepositoryCapability: imported.acceptedRepositoryCapability,
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({
acceptedRepositoryOwnerToken: {},
acceptedRepositoryCapability: imported.acceptedRepositoryCapability,
path: 'package.json',
}),
GitoxideRepositoryAdmissionAuthorityError,
);
});
test('reuses the exact helper capability captured by repository admission', async (t) => {
const helper = await admittedHelper();
if (!helper) {
@@ -228,8 +512,8 @@ test('reuses the exact helper capability captured by repository admission', asyn
const imported = await importAdmittedGitoxideRepositoryInternal({
admissionOwnerToken,
repositoryCapability: admitted.capability,
acceptedRepositoryOwnerToken: {},
destinationRepositoryPath: join(repositoryPath, 'captured-helper-import.git'),
baselineRef: 'refs/maka/baseline',
});
assert.equal(imported.sourceHeadCommitOid, git(repositoryPath, ['rev-parse', 'HEAD']));
});
@@ -253,6 +537,12 @@ async function admittedHelper(): Promise<AdmittedHelper | undefined> {
platform: process.platform,
arch: process.arch,
protocolVersion: 1,
supportedOperations: [
'inspect_repository',
'import_source_head',
'create_candidate',
'read_tree_file',
],
});
const helperCapability = await admitGitoxideHelperArtifactInternal({
releaseOwnerToken,
@@ -264,6 +554,35 @@ async function admittedHelper(): Promise<AdmittedHelper | undefined> {
return admittedHelperPromise;
}
async function admittedHelperWithOperations(
supportedOperations: readonly GitoxideHelperOperationInternal[],
): Promise<AdmittedHelper | undefined> {
const configuredHelperPath = process.env.MAKA_GITOXIDE_HELPER_PATH;
if (!configuredHelperPath) return undefined;
const helperPath = await realpath(configuredHelperPath);
const helperBytes = await readFile(helperPath);
const helperInfo = await stat(helperPath);
const helperArtifactSha256 =
`sha256:${createHash('sha256').update(helperBytes).digest('hex')}` as const;
const releaseOwnerToken = {};
const invocationOwnerToken = {};
const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, {
executablePath: helperPath,
expectedSha256: helperArtifactSha256,
expectedBytes: helperInfo.size,
platform: process.platform,
arch: process.arch,
protocolVersion: 1,
supportedOperations,
});
const helperCapability = await admitGitoxideHelperArtifactInternal({
releaseOwnerToken,
invocationOwnerToken,
claim,
});
return { invocationOwnerToken, helperCapability, helperArtifactSha256 };
}
async function createRepository(t: TestContext, objectFormat: 'sha1' | 'sha256') {
const repositoryPath = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-admission-')));
t.after(() => rm(repositoryPath, { recursive: true, force: true }));
@@ -24,6 +24,13 @@ import { isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/;
const HASH_BUFFER_BYTES = 64 * 1024;
const MAX_HELPER_ARTIFACT_BYTES = 256 * 1024 * 1024;
export const GITOXIDE_HELPER_OPERATIONS_INTERNAL = Object.freeze([
'inspect_repository',
'import_source_head',
'create_candidate',
'read_tree_file',
] as const);
export type GitoxideHelperOperationInternal = (typeof GITOXIDE_HELPER_OPERATIONS_INTERNAL)[number];
export interface GitoxideHelperReleaseArtifactClaim {
readonly kind: 'gitoxide_helper_release_artifact_claim_v1';
@@ -40,17 +47,20 @@ export interface GitoxideHelperReleaseArtifactStateInternal {
readonly platform: NodeJS.Platform;
readonly arch: string;
readonly protocolVersion: 1;
readonly supportedOperations: readonly GitoxideHelperOperationInternal[];
}
export interface VerifiedGitoxideHelperArtifactInternal {
readonly executablePath: string;
readonly protocolVersion: 1;
readonly supportedOperations: readonly GitoxideHelperOperationInternal[];
}
export interface GitoxideHelperArtifactIdentityInternal {
readonly sha256: `sha256:${string}`;
readonly bytes: number;
readonly protocolVersion: 1;
readonly supportedOperations: readonly GitoxideHelperOperationInternal[];
}
export type GitoxideHelperArtifactAuthorityErrorCode =
@@ -96,7 +106,14 @@ export function issueGitoxideHelperReleaseArtifactClaimInternal(
const claim = Object.freeze({
kind: 'gitoxide_helper_release_artifact_claim_v1' as const,
});
releaseClaims.set(claim, Object.freeze({ ...state, releaseOwnerToken }));
releaseClaims.set(
claim,
Object.freeze({
...state,
supportedOperations: Object.freeze([...state.supportedOperations]),
releaseOwnerToken,
}),
);
return claim;
}
@@ -153,6 +170,7 @@ export async function verifyGitoxideHelperArtifactForInvocationInternal(
return Object.freeze({
executablePath: canonicalExecutablePath,
protocolVersion: record.claim.protocolVersion,
supportedOperations: record.claim.supportedOperations,
});
}
@@ -171,9 +189,31 @@ export function requireGitoxideHelperArtifactIdentityInternal(
sha256: record.claim.expectedSha256,
bytes: record.claim.expectedBytes,
protocolVersion: record.claim.protocolVersion,
supportedOperations: record.claim.supportedOperations,
});
}
export function requireGitoxideHelperOperationsInternal(
invocationOwnerToken: object,
capability: GitoxideHelperInvocationCapability,
requiredOperations: readonly GitoxideHelperOperationInternal[],
): void {
const record = invocationCapabilities.get(capability);
if (!record || record.invocationOwnerToken !== invocationOwnerToken) {
throw new GitoxideHelperArtifactAuthorityError(
'gitoxide_helper_invocation_capability_invalid',
'Gitoxide helper invocation capability is invalid for this owner',
);
}
const supported = new Set(record.claim.supportedOperations);
if (requiredOperations.some((operation) => !supported.has(operation))) {
throw new GitoxideHelperArtifactAuthorityError(
'gitoxide_helper_release_claim_unsupported',
'Gitoxide helper release artifact does not attest the required operations',
);
}
}
function assertReleaseArtifactState(state: GitoxideHelperReleaseArtifactStateInternal): void {
if (
typeof state.executablePath !== 'string' ||
@@ -187,7 +227,8 @@ function assertReleaseArtifactState(state: GitoxideHelperReleaseArtifactStateInt
state.platform.length === 0 ||
typeof state.arch !== 'string' ||
state.arch.length === 0 ||
state.protocolVersion !== 1
state.protocolVersion !== 1 ||
!isExactSupportedOperations(state.supportedOperations)
) {
throw new GitoxideHelperArtifactAuthorityError(
'gitoxide_helper_release_claim_invalid',
@@ -196,6 +237,17 @@ function assertReleaseArtifactState(state: GitoxideHelperReleaseArtifactStateInt
}
}
function isExactSupportedOperations(
operations: readonly GitoxideHelperOperationInternal[],
): boolean {
return (
Array.isArray(operations) &&
operations.length > 0 &&
new Set(operations).size === operations.length &&
operations.every((operation) => GITOXIDE_HELPER_OPERATIONS_INTERNAL.includes(operation))
);
}
async function verifyArtifact(claim: ReleaseClaimRecord): Promise<string> {
let canonicalExecutablePath: string;
let handle;
@@ -18,20 +18,27 @@
*/
import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { realpath } from 'node:fs/promises';
import { dirname, isAbsolute } from 'node:path';
import { manageChildProcessLifecycle } from '@maka/runtime/child-process-lifecycle';
import {
type GitoxideHelperInvocationCapability,
requireGitoxideHelperOperationsInternal,
verifyGitoxideHelperArtifactForInvocationInternal,
} from './gitoxide-helper-artifact-authority-internal.js';
const MAX_REQUEST_BYTES = 64 * 1024;
const MAX_STDOUT_BYTES = 64 * 1024;
const MAX_SUCCESSOR_CONTENT_BYTES = 64 * 1024 * 1024;
const MAX_TREE_FILE_BYTES = 8 * 1024 * 1024;
const MAX_ENCODED_CONTENT_BYTES = Math.ceil(MAX_SUCCESSOR_CONTENT_BYTES / 3) * 4;
const MAX_REQUEST_BYTES = MAX_ENCODED_CONTENT_BYTES + 64 * 1024;
const MAX_STDOUT_BYTES = MAX_TREE_FILE_BYTES * 6 + 64 * 1024;
const MAX_STDERR_BYTES = 16 * 1024;
export const GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL = Object.freeze({
inspectRepositoryMs: 5_000,
importSourceHeadMs: 10 * 60_000,
createCandidateMs: 10 * 60_000,
acceptedTreeReadMs: 10 * 60_000,
});
const SHA1_OID_PATTERN = /^[0-9a-f]{40}$/;
const MAKA_REF_PATTERN = /^refs\/maka\/[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/;
@@ -51,7 +58,16 @@ export const GITOXIDE_HELPER_ERROR_REASONS_V1 = Object.freeze([
'baseline_commit_write_failed',
'baseline_publish_failed',
'baseline_ref_outside_maka_namespace',
'base_commit_unavailable',
'base_commit_identity_mismatch',
'base_path_lookup_failed',
'base_tree_unavailable',
'base_tree_identity_mismatch',
'blob_write_failed',
'commit_write_failed',
'invalid_baseline_ref',
'invalid_base_commit_oid',
'invalid_successor_path',
'import_destination_create_failed',
'import_destination_not_fresh',
'import_destination_object_format_mismatch',
@@ -85,6 +101,29 @@ export const GITOXIDE_HELPER_ERROR_REASONS_V1 = Object.freeze([
'source_tree_observation_mismatch',
'source_tree_unavailable',
'source_tree_visit_limit_exceeded',
'successor_content_limit_exceeded',
'successor_commit_identity_mismatch',
'accepted_ref_not_direct',
'accepted_ref_target_invalid',
'candidate_ref_not_direct',
'candidate_ref_target_invalid',
'candidate_request_conflict',
'candidate_publication_indeterminate',
'target_ref_outside_maka_namespace',
'target_ref_unavailable',
'tree_edit_failed',
'tree_write_failed',
'unsupported_base_path_kind',
'accepted_commit_unavailable',
'accepted_tree_unavailable',
'invalid_accepted_commit_oid',
'invalid_tree_file_path',
'tree_file_identity_mismatch',
'tree_file_invalid',
'tree_file_lookup_failed',
'tree_file_not_utf8',
'tree_file_size_limit_exceeded',
'tree_file_unavailable',
'unsupported_source_entry_kind',
'unsupported_source_attributes',
'unsupported_source_path',
@@ -122,11 +161,73 @@ export interface GitoxideSourceImportObservationV1 {
readonly baselineCommitOid: string;
readonly baselineTreeOid: string;
readonly baselineRef: string;
readonly managedTreePolicyVersion: 2;
readonly managedTreePolicyVersion: 3;
readonly filesImported: number;
readonly bytesImported: number;
}
export interface GitoxideCandidatePublishedV1 {
readonly kind: 'candidate_published';
readonly protocolVersion: 1;
readonly objectFormat: 'sha1';
readonly baseCommitOid: string;
readonly baseTreeOid: string;
readonly candidateCommitOid: string;
readonly candidateTreeOid: string;
readonly resultBlobOid: string;
readonly requestDigestSha256: string;
readonly acceptedRef: string;
readonly candidateRef: string;
readonly path: string;
readonly managedTreePolicyVersion: 3;
}
export interface GitoxideCandidateNoChangeV1 {
readonly kind: 'candidate_no_change';
readonly protocolVersion: 1;
readonly objectFormat: 'sha1';
readonly baseCommitOid: string;
readonly baseTreeOid: string;
readonly resultBlobOid: string;
readonly candidateCommitOid: string;
readonly candidateTreeOid: string;
readonly requestDigestSha256: string;
readonly acceptedRef: string;
readonly candidateRef: string;
readonly path: string;
readonly managedTreePolicyVersion: 3;
}
export interface GitoxideCandidateRejectedV1 {
readonly kind: 'candidate_rejected';
readonly protocolVersion: 1;
readonly reason: 'base_commit_mismatch';
readonly objectFormat: 'sha1';
readonly expectedBaseCommitOid: string;
readonly actualBaseCommitOid: string;
readonly acceptedRef: string;
readonly candidateRef: string;
readonly managedTreePolicyVersion: 3;
}
export type GitoxideCandidateResultV1 =
| GitoxideCandidatePublishedV1
| GitoxideCandidateNoChangeV1
| GitoxideCandidateRejectedV1;
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;
readonly managedTreePolicyVersion: 3;
}
export type GitoxideHelperInvocationErrorCode =
| 'gitoxide_helper_invocation_invalid'
| 'gitoxide_helper_invocation_spawn_failed'
@@ -177,6 +278,9 @@ export async function inspectCanonicalRepositoryWithGitoxideHelperInternal(input
deadlineAt: input.deadlineAt,
abortSignal: input.abortSignal,
operation: async () => {
requireGitoxideHelperOperationsInternal(input.invocationOwnerToken, input.capability, [
'inspect_repository',
]);
if (!isAbsolute(input.repositoryPath)) {
throw new GitoxideHelperInvocationError(
'gitoxide_helper_invocation_invalid',
@@ -280,7 +384,7 @@ export async function importSourceHeadWithGitoxideHelperInternal(input: {
readonly expectedSourceHeadCommitOid: string;
readonly destinationRepositoryPath: string;
readonly baselineRef: string;
readonly managedTreePolicyVersion: 2;
readonly managedTreePolicyVersion: 3;
readonly abortSignal?: AbortSignal;
}): Promise<GitoxideSourceImportObservationV1> {
const deadlineAt =
@@ -289,6 +393,9 @@ export async function importSourceHeadWithGitoxideHelperInternal(input: {
deadlineAt,
abortSignal: input.abortSignal,
operation: async () => {
requireGitoxideHelperOperationsInternal(input.invocationOwnerToken, input.capability, [
'import_source_head',
]);
if (
!isAbsolute(input.sourceRepositoryPath) ||
!isAbsolute(input.destinationRepositoryPath) ||
@@ -346,6 +453,181 @@ export async function importSourceHeadWithGitoxideHelperInternal(input: {
});
}
export async function createCandidateWithGitoxideHelperInternal(input: {
readonly invocationOwnerToken: object;
readonly capability: GitoxideHelperInvocationCapability;
readonly repositoryPath: string;
readonly acceptedRef: string;
readonly expectedBaseCommitOid: string;
readonly expectedBaseTreeOid: string;
readonly candidateRef: string;
readonly path: string;
readonly content: string;
readonly managedTreePolicyVersion: 3;
readonly abortSignal?: AbortSignal;
}): Promise<GitoxideCandidateResultV1> {
const deadlineAt =
performance.now() + GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL.createCandidateMs;
const { artifact, repositoryPath } = await runGitoxideOperationWithinDeadlineInternal({
deadlineAt,
abortSignal: input.abortSignal,
operation: async () => {
requireGitoxideHelperOperationsInternal(input.invocationOwnerToken, input.capability, [
'create_candidate',
]);
if (
!isAbsolute(input.repositoryPath) ||
!SHA1_OID_PATTERN.test(input.expectedBaseCommitOid) ||
!SHA1_OID_PATTERN.test(input.expectedBaseTreeOid) ||
!MAKA_REF_PATTERN.test(input.acceptedRef) ||
!/^refs\/maka\/candidates\/[0-9a-f]{64}$/.test(input.candidateRef) ||
!isBoundedPathTransport(input.path) ||
Buffer.byteLength(input.content, 'utf8') > MAX_SUCCESSOR_CONTENT_BYTES ||
input.managedTreePolicyVersion !== 3
) {
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)}`,
);
}),
]);
return { artifact, repositoryPath };
},
});
const request = Buffer.from(
JSON.stringify({
protocolVersion: artifact.protocolVersion,
operation: 'create_candidate',
repositoryPath,
acceptedRef: input.acceptedRef,
expectedBaseCommitOid: input.expectedBaseCommitOid,
expectedBaseTreeOid: input.expectedBaseTreeOid,
candidateRef: input.candidateRef,
path: input.path,
contentBase64: Buffer.from(input.content, 'utf8').toString('base64'),
managedTreePolicyVersion: input.managedTreePolicyVersion,
}),
);
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,
deadlineAt,
});
const requestDigestSha256 = candidateRequestDigestSha256({
acceptedRef: input.acceptedRef,
expectedBaseCommitOid: input.expectedBaseCommitOid,
expectedBaseTreeOid: input.expectedBaseTreeOid,
candidateRef: input.candidateRef,
path: input.path,
content: Buffer.from(input.content, 'utf8'),
});
return decodeCandidateOutcome(outcome, {
acceptedRef: input.acceptedRef,
expectedBaseCommitOid: input.expectedBaseCommitOid,
expectedBaseTreeOid: input.expectedBaseTreeOid,
candidateRef: input.candidateRef,
path: input.path,
resultBlobOid: gitBlobOid(Buffer.from(input.content, 'utf8')),
requestDigestSha256,
managedTreePolicyVersion: input.managedTreePolicyVersion,
});
}
export async function readTreeFileWithGitoxideHelperInternal(input: {
readonly invocationOwnerToken: object;
readonly capability: GitoxideHelperInvocationCapability;
readonly repositoryPath: string;
readonly acceptedCommitOid: string;
readonly path: string;
readonly managedTreePolicyVersion: 3;
readonly abortSignal?: AbortSignal;
}): Promise<GitoxideTreeFileReadV1> {
const deadlineAt =
performance.now() + GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL.acceptedTreeReadMs;
if (!isBoundedPathTransport(input.path)) {
throw invocationInvalid('Gitoxide tree file path is invalid');
}
const prepared = await prepareAcceptedTreeInvocation({ ...input, deadlineAt });
const request = Buffer.from(
JSON.stringify({
protocolVersion: prepared.artifact.protocolVersion,
operation: 'read_tree_file',
repositoryPath: prepared.repositoryPath,
acceptedCommitOid: input.acceptedCommitOid,
path: input.path,
managedTreePolicyVersion: input.managedTreePolicyVersion,
}),
);
if (request.length > MAX_REQUEST_BYTES) throw invocationInvalid('Gitoxide request is too large');
const outcome = await invokeHelper({
executablePath: prepared.artifact.executablePath,
request,
abortSignal: input.abortSignal,
deadlineAt,
});
return decodeTreeFileOutcome(outcome, input);
}
async function prepareAcceptedTreeInvocation(input: {
readonly invocationOwnerToken: object;
readonly capability: GitoxideHelperInvocationCapability;
readonly repositoryPath: string;
readonly acceptedCommitOid: string;
readonly managedTreePolicyVersion: 3;
readonly deadlineAt: number;
readonly abortSignal?: AbortSignal;
}): Promise<{
readonly artifact: Awaited<ReturnType<typeof verifyGitoxideHelperArtifactForInvocationInternal>>;
readonly repositoryPath: string;
}> {
return runGitoxideOperationWithinDeadlineInternal({
deadlineAt: input.deadlineAt,
abortSignal: input.abortSignal,
operation: async () => {
requireGitoxideHelperOperationsInternal(input.invocationOwnerToken, input.capability, [
'read_tree_file',
]);
if (
!isAbsolute(input.repositoryPath) ||
!SHA1_OID_PATTERN.test(input.acceptedCommitOid) ||
input.managedTreePolicyVersion !== 3
) {
throw invocationInvalid('Gitoxide accepted-tree request is invalid');
}
const [artifact, repositoryPath] = await Promise.all([
verifyGitoxideHelperArtifactForInvocationInternal(
input.invocationOwnerToken,
input.capability,
),
realpath(input.repositoryPath).catch((error) => {
throw invocationInvalid(
`Gitoxide managed repository path could not be resolved: ${error instanceof Error ? error.message : String(error)}`,
);
}),
]);
return { artifact, repositoryPath };
},
});
}
/*
* The absolute operation deadline starts at the public entry point. Once it
* expires, the shared lifecycle owner force-kills the process tree and applies
@@ -560,7 +842,7 @@ function decodeSourceImportOutcome(
expected: {
readonly expectedSourceHeadCommitOid: string;
readonly baselineRef: string;
readonly managedTreePolicyVersion: 2;
readonly managedTreePolicyVersion: 3;
},
): GitoxideSourceImportObservationV1 {
if (outcome.signal !== null) {
@@ -591,12 +873,299 @@ function decodeSourceImportOutcome(
);
}
function decodeCandidateOutcome(
outcome: HelperProcessOutcome,
expected: {
readonly acceptedRef: string;
readonly expectedBaseCommitOid: string;
readonly expectedBaseTreeOid: string;
readonly candidateRef: string;
readonly path: string;
readonly resultBlobOid: string;
readonly requestDigestSha256: string;
readonly managedTreePolicyVersion: 3;
},
): GitoxideCandidateResultV1 {
if (outcome.signal !== null) {
throw new GitoxideHelperInvocationError(
'gitoxide_helper_invocation_protocol_invalid',
`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 &&
(isCandidatePublished(value, expected) || isCandidateNoChange(value, expected))
) {
return Object.freeze(value);
}
if (outcome.exitCode === 3 && isCandidateRejected(value, expected)) {
return Object.freeze(value);
}
if (outcome.exitCode === 1 && isHelperError(value)) {
throw new GitoxideHelperInvocationError(
'gitoxide_helper_operation_failed',
`Gitoxide helper could not create 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 isCandidatePublished(
value: unknown,
expected: {
readonly acceptedRef: string;
readonly expectedBaseCommitOid: string;
readonly expectedBaseTreeOid: string;
readonly candidateRef: string;
readonly path: string;
readonly resultBlobOid: string;
readonly requestDigestSha256: string;
readonly managedTreePolicyVersion: 3;
},
): value is GitoxideCandidatePublishedV1 {
return (
hasExactKeys(value, [
'protocolVersion',
'kind',
'objectFormat',
'baseCommitOid',
'baseTreeOid',
'candidateCommitOid',
'candidateTreeOid',
'resultBlobOid',
'requestDigestSha256',
'acceptedRef',
'candidateRef',
'path',
'managedTreePolicyVersion',
]) &&
value.protocolVersion === 1 &&
value.kind === 'candidate_published' &&
value.objectFormat === 'sha1' &&
value.baseCommitOid === expected.expectedBaseCommitOid &&
value.baseTreeOid === expected.expectedBaseTreeOid &&
typeof value.candidateCommitOid === 'string' &&
SHA1_OID_PATTERN.test(value.candidateCommitOid) &&
typeof value.candidateTreeOid === 'string' &&
SHA1_OID_PATTERN.test(value.candidateTreeOid) &&
typeof value.resultBlobOid === 'string' &&
value.resultBlobOid === expected.resultBlobOid &&
value.requestDigestSha256 === expected.requestDigestSha256 &&
value.acceptedRef === expected.acceptedRef &&
value.candidateRef === expected.candidateRef &&
value.path === expected.path &&
value.managedTreePolicyVersion === expected.managedTreePolicyVersion
);
}
function isCandidateNoChange(
value: unknown,
expected: {
readonly acceptedRef: string;
readonly expectedBaseCommitOid: string;
readonly expectedBaseTreeOid: string;
readonly path: string;
readonly resultBlobOid: string;
readonly candidateRef: string;
readonly requestDigestSha256: string;
readonly managedTreePolicyVersion: 3;
},
): value is GitoxideCandidateNoChangeV1 {
return (
hasExactKeys(value, [
'protocolVersion',
'kind',
'objectFormat',
'baseCommitOid',
'baseTreeOid',
'resultBlobOid',
'candidateCommitOid',
'candidateTreeOid',
'requestDigestSha256',
'acceptedRef',
'candidateRef',
'path',
'managedTreePolicyVersion',
]) &&
value.protocolVersion === 1 &&
value.kind === 'candidate_no_change' &&
value.objectFormat === 'sha1' &&
value.baseCommitOid === expected.expectedBaseCommitOid &&
value.baseTreeOid === expected.expectedBaseTreeOid &&
value.resultBlobOid === expected.resultBlobOid &&
isSha1(value.candidateCommitOid) &&
value.candidateTreeOid === expected.expectedBaseTreeOid &&
value.requestDigestSha256 === expected.requestDigestSha256 &&
value.acceptedRef === expected.acceptedRef &&
value.candidateRef === expected.candidateRef &&
value.path === expected.path &&
value.managedTreePolicyVersion === expected.managedTreePolicyVersion
);
}
function isCandidateRejected(
value: unknown,
expected: {
readonly acceptedRef: string;
readonly expectedBaseCommitOid: string;
readonly candidateRef: string;
readonly managedTreePolicyVersion: 3;
},
): value is GitoxideCandidateRejectedV1 {
return (
hasExactKeys(value, [
'protocolVersion',
'kind',
'reason',
'objectFormat',
'expectedBaseCommitOid',
'actualBaseCommitOid',
'acceptedRef',
'candidateRef',
'managedTreePolicyVersion',
]) &&
value.protocolVersion === 1 &&
value.kind === 'candidate_rejected' &&
value.reason === 'base_commit_mismatch' &&
value.objectFormat === 'sha1' &&
value.expectedBaseCommitOid === expected.expectedBaseCommitOid &&
typeof value.actualBaseCommitOid === 'string' &&
SHA1_OID_PATTERN.test(value.actualBaseCommitOid) &&
value.acceptedRef === expected.acceptedRef &&
value.candidateRef === expected.candidateRef &&
value.managedTreePolicyVersion === expected.managedTreePolicyVersion
);
}
function decodeTreeFileOutcome(
outcome: HelperProcessOutcome,
expected: {
readonly acceptedCommitOid: string;
readonly path: string;
readonly managedTreePolicyVersion: 3;
},
): GitoxideTreeFileReadV1 {
const value = parseHelperOutcome(outcome);
if (
outcome.exitCode === 0 &&
isTreeFileRead(value) &&
value.acceptedCommitOid === expected.acceptedCommitOid &&
value.path === expected.path &&
value.blobOid === gitBlobOid(Buffer.from(value.content, 'utf8')) &&
value.managedTreePolicyVersion === expected.managedTreePolicyVersion
) {
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 is invalid');
}
function gitBlobOid(content: Buffer): string {
return createHash('sha1')
.update(`blob ${content.length}\0`, 'utf8')
.update(content)
.digest('hex');
}
function candidateRequestDigestSha256(input: {
readonly acceptedRef: string;
readonly expectedBaseCommitOid: string;
readonly expectedBaseTreeOid: string;
readonly candidateRef: string;
readonly path: string;
readonly content: Buffer;
}): string {
const hash = createHash('sha256').update('maka.gitoxide.candidate-request.v1\0', 'utf8');
for (const field of [
Buffer.from(input.acceptedRef, 'utf8'),
Buffer.from(input.expectedBaseCommitOid, 'utf8'),
Buffer.from(input.expectedBaseTreeOid, 'utf8'),
Buffer.from(input.candidateRef, 'utf8'),
Buffer.from(input.path, 'utf8'),
input.content,
]) {
const length = Buffer.allocUnsafe(8);
length.writeBigUInt64BE(BigInt(field.length));
hash.update(length).update(field);
}
return hash.digest('hex');
}
function parseHelperOutcome(outcome: HelperProcessOutcome): unknown {
if (outcome.signal !== null) {
throw protocolInvalid(`Gitoxide helper exited from signal ${outcome.signal}`);
}
try {
return JSON.parse(outcome.stdout.toString('utf8'));
} catch {
throw protocolInvalid('Gitoxide helper stdout is not one JSON response');
}
}
function operationFailed(operation: string, reason: string): GitoxideHelperInvocationError {
return new GitoxideHelperInvocationError(
'gitoxide_helper_operation_failed',
`Gitoxide helper could not ${operation}: ${reason}`,
reason,
);
}
function isTreeFileRead(value: unknown): value is GitoxideTreeFileReadV1 {
return (
hasExactKeys(value, [
'protocolVersion',
'kind',
'objectFormat',
'acceptedCommitOid',
'acceptedTreeOid',
'blobOid',
'path',
'content',
'bytesRead',
'managedTreePolicyVersion',
]) &&
value.protocolVersion === 1 &&
value.kind === 'tree_file_read' &&
value.objectFormat === 'sha1' &&
isSha1(value.acceptedCommitOid) &&
isSha1(value.acceptedTreeOid) &&
isSha1(value.blobOid) &&
typeof value.path === 'string' &&
isBoundedPathTransport(value.path) &&
typeof value.content === 'string' &&
isNonNegativeSafeInteger(value.bytesRead) &&
value.bytesRead <= MAX_TREE_FILE_BYTES &&
Buffer.byteLength(value.content, 'utf8') === value.bytesRead &&
value.managedTreePolicyVersion === 3
);
}
function isSha1(value: unknown): value is string {
return typeof value === 'string' && SHA1_OID_PATTERN.test(value);
}
function isNonNegativeSafeInteger(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
function isSourceImportObservation(
value: unknown,
expected: {
readonly expectedSourceHeadCommitOid: string;
readonly baselineRef: string;
readonly managedTreePolicyVersion: 2;
readonly managedTreePolicyVersion: 3;
},
): value is GitoxideSourceImportObservationV1 {
return (
@@ -689,6 +1258,10 @@ function isHelperError(value: unknown): value is {
);
}
function isBoundedPathTransport(path: string): boolean {
return path.length > 0 && !path.includes('\0') && Buffer.byteLength(path, 'utf8') <= 4096;
}
function hasExactKeys(
value: unknown,
expectedKeys: readonly string[],
@@ -734,3 +1307,7 @@ function terminationMessage(
function protocolInvalid(message: string): GitoxideHelperInvocationError {
return new GitoxideHelperInvocationError('gitoxide_helper_invocation_protocol_invalid', message);
}
function invocationInvalid(message: string): GitoxideHelperInvocationError {
return new GitoxideHelperInvocationError('gitoxide_helper_invocation_invalid', message);
}
@@ -17,15 +17,22 @@
* under the License.
*/
import { createHash } from 'node:crypto';
import {
type GitoxideHelperInvocationCapability,
requireGitoxideHelperArtifactIdentityInternal,
requireGitoxideHelperOperationsInternal,
} from './gitoxide-helper-artifact-authority-internal.js';
import {
createCandidateWithGitoxideHelperInternal,
GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL,
importSourceHeadWithGitoxideHelperInternal,
inspectCanonicalRepositoryWithGitoxideHelperInternal,
readTreeFileWithGitoxideHelperInternal,
type GitoxideCandidateNoChangeV1,
type GitoxideCandidatePublishedV1,
type GitoxideSourceImportObservationV1,
type GitoxideTreeFileReadV1,
type GitoxideRepositoryRejectionV1,
} from './gitoxide-helper-invocation-internal.js';
@@ -33,6 +40,14 @@ export interface GitoxideRepositoryAdmissionCapability {
readonly kind: 'gitoxide_repository_admission_capability_v1';
}
export interface GitoxideAcceptedRepositoryCapability {
readonly kind: 'gitoxide_accepted_repository_capability_v1';
}
export interface GitoxideCandidateOutcomeCapability {
readonly kind: 'gitoxide_candidate_outcome_capability_v1';
}
export interface GitoxideRepositoryAdmissionStateInternal {
readonly protocolVersion: 1;
readonly repositoryPath: string;
@@ -40,10 +55,11 @@ export interface GitoxideRepositoryAdmissionStateInternal {
readonly headCommitOid: string;
readonly headTreeOid: string;
readonly helperArtifactSha256: `sha256:${string}`;
readonly managedTreePolicyVersion: 2;
readonly managedTreePolicyVersion: 3;
}
const MANAGED_TREE_POLICY_VERSION = 2 as const;
const MANAGED_TREE_POLICY_VERSION = 3 as const;
const ACCEPTED_REPOSITORY_REF = 'refs/maka/accepted' as const;
export type GitoxideRepositoryAdmissionResultV1 =
| {
@@ -53,8 +69,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 does not match the accepted commit'
: 'Gitoxide repository admission capability is invalid',
);
this.name = 'GitoxideRepositoryAdmissionAuthorityError';
}
}
@@ -66,7 +90,41 @@ interface AdmissionCapabilityRecord {
readonly state: GitoxideRepositoryAdmissionStateInternal;
}
interface AcceptedRepositoryCapabilityRecord {
readonly acceptedRepositoryOwnerToken: object;
readonly invocationOwnerToken: object;
readonly helperCapability: GitoxideHelperInvocationCapability;
readonly repositoryPath: string;
readonly acceptedRef: string;
readonly acceptedCommitOid: string;
readonly acceptedTreeOid: string;
readonly managedTreePolicyVersion: 3;
}
interface CandidateOutcomeCapabilityRecord {
readonly candidateOwnerToken: object;
readonly acceptedRepositoryCapability: GitoxideAcceptedRepositoryCapability;
readonly repositoryPath: string;
readonly acceptedRef: string;
readonly objectFormat: 'sha1';
readonly managedTreePolicyVersion: 3;
readonly helperArtifactSha256: `sha256:${string}`;
readonly disposition: 'published' | 'no_change';
readonly operationId: string;
readonly requestDigestSha256: string;
readonly resultContentSha256: `sha256:${string}`;
readonly baseCommitOid: string;
readonly baseTreeOid: string;
readonly candidateCommitOid: string;
readonly candidateTreeOid: string;
readonly candidateRef: string;
readonly resultBlobOid: string;
readonly path: string;
}
const admissions = new WeakMap<object, AdmissionCapabilityRecord>();
const acceptedRepositories = new WeakMap<object, AcceptedRepositoryCapabilityRecord>();
const candidateOutcomes = new WeakMap<object, CandidateOutcomeCapabilityRecord>();
export async function admitGitoxideRepositoryInternal(input: {
readonly invocationOwnerToken: object;
@@ -124,19 +182,28 @@ export function requireGitoxideRepositoryAdmissionInternal(
export async function importAdmittedGitoxideRepositoryInternal(input: {
readonly admissionOwnerToken: object;
readonly repositoryCapability: GitoxideRepositoryAdmissionCapability;
readonly acceptedRepositoryOwnerToken: object;
readonly destinationRepositoryPath: string;
readonly baselineRef: string;
readonly abortSignal?: AbortSignal;
}): Promise<GitoxideSourceImportObservationV1> {
}): Promise<
GitoxideSourceImportObservationV1 & {
readonly acceptedRepositoryCapability: GitoxideAcceptedRepositoryCapability;
}
> {
const admission = requireAdmissionRecord(input.admissionOwnerToken, input.repositoryCapability);
const source = admission.state;
requireGitoxideHelperOperationsInternal(
admission.invocationOwnerToken,
admission.helperCapability,
['create_candidate', 'read_tree_file'],
);
const result = await importSourceHeadWithGitoxideHelperInternal({
invocationOwnerToken: admission.invocationOwnerToken,
capability: admission.helperCapability,
sourceRepositoryPath: source.repositoryPath,
expectedSourceHeadCommitOid: source.headCommitOid,
destinationRepositoryPath: input.destinationRepositoryPath,
baselineRef: input.baselineRef,
baselineRef: ACCEPTED_REPOSITORY_REF,
managedTreePolicyVersion: source.managedTreePolicyVersion,
abortSignal: input.abortSignal,
});
@@ -148,9 +215,185 @@ export async function importAdmittedGitoxideRepositoryInternal(input: {
'gitoxide_repository_admission_capability_invalid',
);
}
const acceptedRepositoryCapability = issueAcceptedRepositoryCapability({
acceptedRepositoryOwnerToken: input.acceptedRepositoryOwnerToken,
invocationOwnerToken: admission.invocationOwnerToken,
helperCapability: admission.helperCapability,
repositoryPath: input.destinationRepositoryPath,
acceptedRef: result.baselineRef,
acceptedCommitOid: result.baselineCommitOid,
acceptedTreeOid: result.baselineTreeOid,
managedTreePolicyVersion: result.managedTreePolicyVersion,
});
return Object.freeze({ ...result, acceptedRepositoryCapability });
}
export async function createGitoxideCandidateInternal(input: {
readonly acceptedRepositoryOwnerToken: object;
readonly acceptedRepositoryCapability: GitoxideAcceptedRepositoryCapability;
readonly candidateOwnerToken: object;
readonly operationId: string;
readonly path: string;
readonly content: string;
readonly abortSignal?: AbortSignal;
}): Promise<
| (GitoxideCandidatePublishedV1 & {
readonly candidateOutcomeCapability: GitoxideCandidateOutcomeCapability;
})
| (GitoxideCandidateNoChangeV1 & {
readonly candidateOutcomeCapability: GitoxideCandidateOutcomeCapability;
})
> {
const managed = requireAcceptedRepositoryRecord(
input.acceptedRepositoryOwnerToken,
input.acceptedRepositoryCapability,
);
if (
typeof input.operationId !== 'string' ||
input.operationId.length === 0 ||
Buffer.byteLength(input.operationId, 'utf8') > 1024
) {
throw new GitoxideRepositoryAdmissionAuthorityError(
'gitoxide_repository_admission_capability_invalid',
);
}
const candidateRef = `refs/maka/candidates/${createHash('sha256').update(input.operationId).digest('hex')}`;
const result = await createCandidateWithGitoxideHelperInternal({
invocationOwnerToken: managed.invocationOwnerToken,
capability: managed.helperCapability,
repositoryPath: managed.repositoryPath,
acceptedRef: managed.acceptedRef,
expectedBaseCommitOid: managed.acceptedCommitOid,
expectedBaseTreeOid: managed.acceptedTreeOid,
candidateRef,
path: input.path,
content: input.content,
managedTreePolicyVersion: managed.managedTreePolicyVersion,
abortSignal: input.abortSignal,
});
if (result.kind === 'candidate_rejected') {
throw new GitoxideRepositoryAdmissionAuthorityError(
'gitoxide_managed_repository_base_mismatch',
);
}
const helperArtifactIdentity = requireGitoxideHelperArtifactIdentityInternal(
managed.invocationOwnerToken,
managed.helperCapability,
);
const candidateOutcomeCapability = Object.freeze({
kind: 'gitoxide_candidate_outcome_capability_v1' as const,
});
candidateOutcomes.set(
candidateOutcomeCapability,
Object.freeze({
candidateOwnerToken: input.candidateOwnerToken,
acceptedRepositoryCapability: input.acceptedRepositoryCapability,
repositoryPath: managed.repositoryPath,
acceptedRef: managed.acceptedRef,
objectFormat: 'sha1' as const,
managedTreePolicyVersion: managed.managedTreePolicyVersion,
helperArtifactSha256: helperArtifactIdentity.sha256,
disposition:
result.kind === 'candidate_published' ? ('published' as const) : ('no_change' as const),
operationId: input.operationId,
requestDigestSha256: result.requestDigestSha256,
resultContentSha256:
`sha256:${createHash('sha256').update(input.content, 'utf8').digest('hex')}` as const,
baseCommitOid: result.baseCommitOid,
baseTreeOid: result.baseTreeOid,
candidateCommitOid: result.candidateCommitOid,
candidateTreeOid: result.candidateTreeOid,
candidateRef: result.candidateRef,
resultBlobOid: result.resultBlobOid,
path: result.path,
}),
);
return Object.freeze({ ...result, candidateOutcomeCapability });
}
export async function readGitoxideTreeFileInternal(input: {
readonly acceptedRepositoryOwnerToken: object;
readonly acceptedRepositoryCapability: GitoxideAcceptedRepositoryCapability;
readonly path: string;
readonly abortSignal?: AbortSignal;
}): Promise<GitoxideTreeFileReadV1> {
const managed = requireAcceptedRepositoryRecord(
input.acceptedRepositoryOwnerToken,
input.acceptedRepositoryCapability,
);
const result = await readTreeFileWithGitoxideHelperInternal({
invocationOwnerToken: managed.invocationOwnerToken,
capability: managed.helperCapability,
repositoryPath: managed.repositoryPath,
acceptedCommitOid: managed.acceptedCommitOid,
path: input.path,
managedTreePolicyVersion: managed.managedTreePolicyVersion,
abortSignal: input.abortSignal,
});
if (
result.acceptedCommitOid !== managed.acceptedCommitOid ||
result.acceptedTreeOid !== managed.acceptedTreeOid
) {
throw new GitoxideRepositoryAdmissionAuthorityError(
'gitoxide_repository_admission_capability_invalid',
);
}
return result;
}
export function requireGitoxideCandidateOutcomeForAcceptedRepositoryInternal(input: {
readonly acceptedRepositoryOwnerToken: object;
readonly acceptedRepositoryCapability: GitoxideAcceptedRepositoryCapability;
readonly candidateOwnerToken: object;
readonly candidateOutcomeCapability: GitoxideCandidateOutcomeCapability;
}): Readonly<
Omit<CandidateOutcomeCapabilityRecord, 'candidateOwnerToken' | 'acceptedRepositoryCapability'>
> {
requireAcceptedRepositoryRecord(
input.acceptedRepositoryOwnerToken,
input.acceptedRepositoryCapability,
);
const record = candidateOutcomes.get(input.candidateOutcomeCapability);
if (
!record ||
record.candidateOwnerToken !== input.candidateOwnerToken ||
record.acceptedRepositoryCapability !== input.acceptedRepositoryCapability
) {
throw new GitoxideRepositoryAdmissionAuthorityError(
'gitoxide_repository_admission_capability_invalid',
);
}
const {
candidateOwnerToken: _candidateOwner,
acceptedRepositoryCapability: _acceptedCapability,
...proof
} = record;
return Object.freeze(proof);
}
function issueAcceptedRepositoryCapability(
record: AcceptedRepositoryCapabilityRecord,
): GitoxideAcceptedRepositoryCapability {
const capability = Object.freeze({
kind: 'gitoxide_accepted_repository_capability_v1' as const,
});
acceptedRepositories.set(capability, Object.freeze(record));
return capability;
}
function requireAcceptedRepositoryRecord(
ownerToken: object,
capability: GitoxideAcceptedRepositoryCapability,
): AcceptedRepositoryCapabilityRecord {
const record = acceptedRepositories.get(capability);
if (!record || record.acceptedRepositoryOwnerToken !== ownerToken) {
throw new GitoxideRepositoryAdmissionAuthorityError(
'gitoxide_repository_admission_capability_invalid',
);
}
return record;
}
function requireAdmissionRecord(
admissionOwnerToken: object,
capability: GitoxideRepositoryAdmissionCapability,