mirror of
https://github.com/apache/maka.git
synced 2026-10-28 08:54:38 +08:00
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:
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,并对受支持文本
|
||||
|
||||
@@ -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'` |
|
||||
|
||||
Generated
+29
-9
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
+1166
-13
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+3
@@ -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,
|
||||
|
||||
+324
-5
@@ -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);
|
||||
}
|
||||
+250
-7
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user