diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml new file mode 100644 index 0000000000..c6aa4ed353 --- /dev/null +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -0,0 +1,89 @@ +# 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. + +name: Gitoxide helper admission + +on: + pull_request: + paths: + - '.github/workflows/gitoxide-helper-admission.yml' + - 'native/gitoxide-helper/**' + - 'packages/runtime-host/src/server/gitoxide-helper-*.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-helper-*.test.ts' + - 'packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts' + - 'docs/architecture/gitoxide-*.md' + push: + branches: + - main + paths: + - '.github/workflows/gitoxide-helper-admission.yml' + - 'native/gitoxide-helper/**' + - 'packages/runtime-host/src/server/gitoxide-helper-*.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-helper-*.test.ts' + - 'packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts' + - 'packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts' + - 'docs/architecture/gitoxide-*.md' + +permissions: + contents: read + +concurrency: + group: gitoxide-helper-admission-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.1 + cache: npm + - name: Check Rust formatting + working-directory: native/gitoxide-helper + run: cargo fmt --check + - name: Test the short-lived Gitoxide helper + working-directory: native/gitoxide-helper + run: cargo test --locked + - name: Install JavaScript dependencies without packaging hooks + run: npm ci --ignore-scripts + - name: Build the helper invocation owner + run: >- + npm --workspace @maka/core run build && + npm --workspace @maka/storage run build && + npm --workspace @maka/runtime run build && + npm --workspace @maka/runtime-host run build + - name: Test the real helper invocation contract + env: + MAKA_GITOXIDE_HELPER_PATH: ${{ github.workspace }}/native/gitoxide-helper/target/debug/maka-gitoxide-helper${{ runner.os == 'Windows' && '.exe' || '' }} + run: >- + node --test + packages/runtime-host/dist/__tests__/gitoxide-helper-artifact-authority-internal.test.js + packages/runtime-host/dist/__tests__/gitoxide-helper-invocation-internal.test.js + packages/runtime-host/dist/__tests__/gitoxide-repository-admission-authority-internal.test.js diff --git a/.gitignore b/.gitignore index 0f6f738912..664f0303c1 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ docs/assets/ apps/desktop/tests/real-window-smoke/ deepseek.key +# Built only by the dedicated Gitoxide helper lane; normal workspace tests do not use Cargo. +/native/gitoxide-helper/target/ + # Generated Computer Use executor binary; provenance metadata stays tracked. apps/desktop/resources/bin/ # Rebuilt from experiments/windows-sandbox by scripts/package-windows-x64.mjs. diff --git a/docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md b/docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md new file mode 100644 index 0000000000..2bc092bdc2 --- /dev/null +++ b/docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md @@ -0,0 +1,118 @@ + + +# Gitoxide helper artifact authority v1 + +状态:stacked 验证切片;尚无正式 release issuer、Desktop/CLI/Runtime Host 生产消费者,必须保持 +Draft。 + +## 1. 主要不变量 + +本切片只证明: + +> 普通 caller 不能用自报的 executable path 或 SHA-256 获得 Gitoxide helper 调用资格;只有内部 +> release owner 签发、与 owner token 绑定的 artifact claim,在 exact platform、architecture、 +> protocol、size 与 SHA-256 校验通过后,才能转换为另一个指定 owner 可消费的 opaque invocation +> capability。artifact 在 admission 后变化时,调用前重验必须 fail closed。 + +它不证明平台签名、安装目录保护、helper spawn、repository observation、T1 admission、managed +workspace 或 crash recovery。 + +## 2. Owner 与 API 权限 + +```text +未来的 packaged-release owner + └─ issueGitoxideHelperReleaseArtifactClaimInternal(ownerToken, exact artifact identity) + ↓ opaque release claim +artifact authority + └─ exact file/platform/protocol verification + ↓ opaque invocation capability +未来的 invocation owner + └─ verifyGitoxideHelperArtifactForInvocationInternal(ownerToken, capability) +``` + +- claim 与 capability 的状态存放在模块私有 `WeakMap` 中;对象表面不包含 path、digest 或 size。 +- claim 必须由相同的 release owner token 消费;capability 必须由签发时指定的 invocation owner token + 消费。 +- 相关 internal API 不从 `@maka/runtime-host/server` 导出。 +- 旧的 caller-provided `{ executablePath, expectedSha256 }` 不能成为这条链的 authority。 + +当前没有 production release owner。`issueGitoxideHelperReleaseArtifactClaimInternal()` 只是未来受信 +packaging owner 的接缝,不是签名信任根;在该 owner 落地前,本切片不能转 Ready。 + +## 3. 校验边界 + +一次 artifact 校验包含: + +1. 输入 claim 的 protocol/platform/architecture/size/digest 形状检查; +2. 拒绝 claimed path 任意组件中的 symlink 或 Windows junction; +3. 打开 canonical regular file,并限制 helper artifact 最大为 256 MiB; +4. 在同一 handle 上进行 64 KiB 有界缓冲的 SHA-256 流式读取; +5. 比较读取前后 handle identity/size/timestamps; +6. 比较读取后 path identity 与已打开 handle; +7. 比较 exact byte count 与 digest。 + +admission 与每次 invocation resolve 都执行这套校验。它可以识别校验之前或校验期间的替换,不会把 +相邻 manifest 当作自证信任根;但校验完成后必须关闭 handle,而 Node 只能按 path spawn,所以这里不把 +“刚验证的 bytes”表述成“实际执行的 bytes”。 + +## 4. 原子性、失败状态与回滚 + +| 项目 | v1 合同 | +| --- | --- | +| owner | Runtime Host 内部 artifact authority | +| 原子性边界 | 单个打开 file handle 的一次 identity + streaming digest observation | +| durable state | 无;claim/capability 仅存在于进程内 | +| 非法/伪造 claim | `gitoxide_helper_release_claim_invalid` | +| 平台或架构不匹配 | `gitoxide_helper_release_claim_unsupported` | +| path/symlink/读取失败 | `gitoxide_helper_artifact_invalid` | +| size/digest/identity 漂移 | `gitoxide_helper_artifact_identity_mismatch` | +| 错误 owner/伪造 capability | `gitoxide_helper_invocation_capability_invalid` | +| rollback | 只读校验,无副作用,无需回滚 | + +## 5. 明确不承诺的威胁模型 + +本切片没有声称抵抗拥有同一 OS 用户文件写权限的主动攻击者。特别是: + +- 它尚未验证 macOS code signature、Windows Authenticode 或 Linux 发布清单的受信签名; +- 它尚未把 helper 放进由正式安装器保护的只读目录; +- invocation 已接入 path-based spawn,但不能消除“最后一次 handle 校验完成后、exec 开始前”的替换 + 窗口;重复 rehash 只能缩小窗口,不能形成 executable CAS,因此本 Draft 明确保留该限制。 + +正式生产接入前,必须由 packaged-release owner 提供信任根,并明确三平台安装目录与签名能力。不能 +通过给本 API 再传一个裸 expected digest 来绕过这一门槛。 + +## 6. 平台能力矩阵 + +| 平台 | 当前持续验证 | 尚未承诺 | +| --- | --- | --- | +| Linux | regular-file identity、digest、symlink path rejection | package signature、protected install root、spawn identity | +| macOS | 同 Linux | code-sign verification、notarized artifact binding、spawn identity | +| Windows | regular-file identity、digest、junction path rejection | Authenticode binding、ACL-protected install root、spawn identity | + +## 7. 后续切片 + +后续只能按下面顺序推进: + +1. 发布/安装 owner 把受信 helper identity 绑定到 signed product artifact; +2. 短生命周期 invocation owner 消费 opaque capability 并运行 strict helper protocol;该 stacked + Draft 的合同见 `gitoxide-helper-invocation-owner-v1.zh-CN.md`; +3. repository observation 再转换为 T1 前的 opaque admission capability。 + +在第 1 项完成以前,不接 Desktop/CLI,也不恢复旧 Git CLI adapter。 diff --git a/docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md b/docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md new file mode 100644 index 0000000000..1af0eb7956 --- /dev/null +++ b/docs/architecture/gitoxide-helper-invocation-owner-v1.zh-CN.md @@ -0,0 +1,102 @@ + + +# Gitoxide short-lived invocation owner v1 + +状态:stacked Draft;真实 Rust helper 的三平台 contract 进入 CI,但仍无正式 release issuer、 +Desktop/CLI/managed-workspace 生产消费者。 + +## 1. 主要不变量 + +本切片只证明: + +> Runtime Host 只能通过 owner-bound opaque artifact capability 启动一个刚完成 artifact observation 的 +> Gitoxide helper path; +> invocation 使用固定 strict JSON request、最小环境、有界 stdin/stdout/stderr、固定超时与取消边界; +> exit 0/1/2 必须分别匹配 inspected/operational failure/policy rejection 的 exact response shape,任意 +> 不一致均 fail closed。 + +它不签发 repository admission capability,不写 SQLite/T1,不创建 Git artifact,也不接 Desktop/CLI。 + +## 2. Owner 与调用链 + +```text +opaque GitoxideHelperInvocationCapability + ↓ invocation owner token 验证 + artifact bytes 重验 +fixed argv [] / minimal env / no shell + ↓ 64 KiB strict JSON request +one short-lived Rust helper + ↓ bounded stdout/stderr + exact exit/response decoder +typed observation | typed policy rejection | stable error +``` + +caller 不能提供 executable path、argv、environment、protocol version、timeout 或 output limit。可变 +业务输入只包括 operation 所需的 absolute repository path、显式 policy 与 AbortSignal;repository path +在 spawn 前 canonicalize。 + +## 3. 原子性、失败状态与回滚 + +| 项目 | v1 合同 | +| --- | --- | +| owner | 单次 Runtime Host invocation owner | +| 原子性边界 | artifact revalidation 后启动的一个 helper process 与其 exact response | +| 成功 | exit 0 + exact SHA-1 `repository_inspected` | +| policy rejection | exit 2 + exact `unsupported_object_format` | +| repository/helper failure | exit 1 + allowlisted stable helper reason | +| timeout | inspect 为 5 秒、source import 为 10 分钟;到期后 force-kill process tree,`gitoxide_helper_invocation_timed_out` | +| cancellation | preflight 或运行中 fail closed,`gitoxide_helper_invocation_aborted` | +| resource failure | repository open 前的本地 metadata 总量 1 MiB、Gitoxide object allocation 64 MiB、stdout 64 KiB、stderr 16 KiB;超限 fail closed 或 force-kill | +| malformed protocol | exit code、JSON shape、OID 或字段不一致均拒绝 | +| rollback | inspect 无 durable side effect;import 只允许 fresh destination,但 partial artifact cleanup 留给未来 storage owner | + +Rust helper v1 不启动 descendants;Runtime 仍使用共享 process-tree terminator 处理 timeout、abort 和 +output overflow,不允许常驻或 detached helper。 + +## 4. 配置与数据边界 + +- argv 固定为空,禁止 caller 注入 helper option; +- `shell: false`,不会经过 shell parsing; +- child `PATH` 为空,只保留 Windows loader 与临时目录所需的最少环境变量; +- Rust 侧在 `gix::open()` 前先有界解析 `.git`/`commondir` 路径文件并统计实际会读取的 + `config`/`config.worktree`,总量超过 1 MiB 时返回 + `repository_metadata_limit_exceeded`;随后使用 `gix::open::Options::isolated()`、 + `lossy_config(true)` 与 `strict_config(true)`; +- request 最大 64 KiB;stdout 最大 64 KiB;stderr 最大 16 KiB; +- SHA-1 OID 必须是 40 位小写十六进制;SHA-256/未知格式只返回 rejection,禁止 fallback。 + +## 5. 平台证据 + +同一个 workflow 使用 release toolchain 的 Node 24.18.1,在 Linux、macOS、Windows 上: + +1. 编译并测试 Rust helper; +2. 构建 Runtime Host; +3. 通过真实 helper executable 验证 SHA-1 success、SHA-256/未知格式 rejection、unborn SHA-1 + failure、checksum mismatch 和 portable managed-tree policy。 + +该证据只覆盖 helper 协议、进程终止与 fresh-only import,不包含平台安装签名或恶意同用户替换。 +当前实现会在 spawn 前完成 bytes/identity observation,但 Node 的 path-based spawn 不能把已打开并验证的 +handle 直接作为 executable,因此 observation 与 exec 之间仍有 TOCTOU。正式 packaged-release owner +必须依赖平台签名和受保护安装目录;本 Draft 不声称抵抗拥有同用户写权限的攻击者。 + +## 6. 下一切片 + +同一 consolidated PR 的后续 authority layer 已把 exact repository observation 转换成 owner-bound +opaque admission capability,并用它驱动 fresh-only source import;合同见 +`gitoxide-repository-admission-capability-v1.zh-CN.md`。仍不在本 PR 实现 durable T1、worktree +projection、candidate、recovery owner 或 ref CAS。 diff --git a/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md b/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md new file mode 100644 index 0000000000..ecb38a15ec --- /dev/null +++ b/docs/architecture/gitoxide-repository-admission-capability-v1.zh-CN.md @@ -0,0 +1,122 @@ + + +# Gitoxide repository admission capability v1 + +状态:Gitoxide repository admission / source-import consolidated Draft;source import data plane 作为同一 +PR 内的独立 authority layer 消费该 capability。 + +## 1. 主要不变量 + +本切片只证明: + +> caller 不能用裸 repository path、object format、commit OID 或 tree OID 自证 source identity。 +> 只有 owner-bound helper capability 的一次真实、严格 SHA-1 observation,才能签发指定 admission +> owner 可解析的 opaque capability;SHA-256/未知格式只返回 policy rejection,不产生 capability。 + +## 2. Owner 与事实流 + +```text +helper invocation owner + └─ exact repository_inspected response + ↓ +repository admission authority + ├─ canonical repository path + ├─ protocol/object format + ├─ exact HEAD commit OID + └─ exact HEAD tree OID + ↓ private WeakMap +opaque GitoxideRepositoryAdmissionCapability + ↓ only the designated import owner may resolve +immutable admission state + ↓ exact verified commit/tree graph +fresh SHA-1 bare baseline repository +``` + +认证元数据与可返回 observation state 分开存储;解析 capability 不会泄漏 owner token。相关 API 不从 +`@maka/runtime-host/server` 导出。 + +## 3. 原子性、失败状态与回滚 + +| 项目 | v1 合同 | +| --- | --- | +| observation owner | short-lived invocation owner | +| capability owner | repository admission authority | +| admission 原子性边界 | 一次 canonical path observation + 一次 exact helper response + 进程内 capability 签发 | +| import 原子性边界 | 完整 preflight 后用 `create_dir()` 领取 fresh destination;baseline ref 只允许 `MustNotExist` | +| accepted | SHA-1 exact commit/tree,签发 opaque capability | +| policy rejected | SHA-256/未知 format,返回 rejection,不签发 capability | +| helper/路径失败 | 沿用 invocation owner 的稳定 fail-closed error | +| forged/wrong-owner capability | `gitoxide_repository_admission_capability_invalid` | +| durable state | admission capability 仅在进程内;import 会创建 bare repository、objects 与 baseline ref | +| rollback | observation 无副作用;import 失败留下的 claimed destination 由后续 recovery owner 处理,本层不删除或接管 | + +source import 的原子边界是“先完成 source/ref/policy/object graph preflight,再以 `create_dir()` 原子领取 +此前不存在的 destination”。destination 一旦存在就拒绝接管;本层不修复、不删除 partial 或 foreign +artifact。每个 commit/tree/blob 都在解析或递归前重新计算 SHA-1 并与 claimed OID 比较,validate 与 +copy 共用同一个 bounded verified walker。 + +## 4. Freshness 与未来 T1 + +capability 表示一次明确线性化点上的 immutable Git commit/tree snapshot,不承诺 source branch 在随后 +保持不变。未来 T1 owner 应把 exact commit/tree 写入 durable admission,并从该 immutable commit +导入 source;不得在 T1 后重新解释“当前 HEAD”,也不得 fallback 到 caller 提供的 OID。 + +如果产品需要“必须采用用户按下执行按钮那一刻的最新 HEAD”,该策略必须在未来 T1 owner 内重新 +观察并比较;不能让本 capability 变成可变 branch lease。 + +## 5. 当前完成度 + +到本切片为止,Gitoxide 验证栈已具备: + +1. Rust helper 的 isolated SHA-1 observation / SHA-256 rejection; +2. exact helper artifact → opaque invocation capability; +3. bounded short-lived process owner 与 strict response decoder; +4. exact repository observation → opaque admission capability; +5. Linux、macOS、Windows 的真实 helper contract workflow。 +6. exact commit/tree/blob checksum verification、atomic destination claim 与 deterministic zero-parent + baseline publication。 + +`managedTreePolicyVersion: 1` 是 **portable lexical materialization policy v1**,而不只是 Git object +import policy。它只证明 Git tree 的词法身份满足同一套保守规则,不宣称已证明某个真实 Windows volume +上的 path-length、8.3 alias、ACL 或大小写能力。 + +v1 只接受 canonical Git tree ordering 以及 raw mode token `40000`、`100644`、`100755`;mode 的等价 +八进制别名和未排序 tree 都在 destination claim 前拒绝。路径统一拒绝 Windows reserved/control +characters、device names(含 extension 与小写 superscript 形式)、trailing dot/space、`.git`、 +`.gitattributes` 及其大小写或尾部别名。collision key 的版本化算法固定为: + +```text +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 +version;真实 projection 还必须由独立的 `FilesystemMaterializationProfileV1` 在 fresh destination 上证明 +目标 filesystem 的 case/alias/path-length 能力并执行 create/post-observation,不能把本词法检查冒充为 +真实文件系统准入。 + +仍未完成、也没有伪装完成: + +- signed packaged-release trust root 与受保护安装路径; +- Desktop/CLI 消费者; +- T1 durable admission、projection、candidate 与 ref CAS; +- state-root-bound destination capability、partial artifact receipt/quarantine/recovery owner。 + +因此这些 PR 可以作为 Gitoxide backend 的验证栈审查,但在正式 release owner 和生产消费者接入前 +继续保持 Draft。 diff --git a/docs/architecture/gitoxide-short-lived-helper-admission-v1.zh-CN.md b/docs/architecture/gitoxide-short-lived-helper-admission-v1.zh-CN.md new file mode 100644 index 0000000000..2c365891b9 --- /dev/null +++ b/docs/architecture/gitoxide-short-lived-helper-admission-v1.zh-CN.md @@ -0,0 +1,102 @@ + + +# Gitoxide short-lived helper:repository admission v1 + +状态:验证切片;尚无 Desktop、CLI、Runtime Host 或 M2 生产消费者,只能保持 Draft。 + +## 1. 主要不变量 + +本切片只证明: + +> 在选择 managed-workspace durable mode 或写入 T1 以前,Git backend owner 可以通过一个 +> 短生命周期、隔离配置的 Gitoxide helper 观察 repository object format 和 exact HEAD identity; +> 只有 SHA-1 repository 返回 observation;SHA-256 返回显式 policy rejection,未知或无法由 Gitoxide +> 打开的 object format 稳定 fail closed,且不得调用或回退到系统 Git。 + +它不证明 source import、clone、fetch、worktree、candidate、projection、ref CAS、Write/Edit 或 +resume。现有 dormant `GitWorkspaceService` 也没有切换到该 helper。 + +## 2. 为什么是 helper,不是常驻 broker + +`maka-gitoxide-helper` 每次启动只执行以下协议: + +```text +stdin: 一个最大 64 KiB 的 strict JSON request + ↓ +Gitoxide isolated repository observation + ↓ +stdout: 一个 JSON response + ↓ +process exit +``` + +进程不监听 socket、不复用 repository handle、不保存 caller identity,也不拥有跨请求锁或可恢复 +状态。因此它不是新的常驻 authority;durable ownership 仍必须由未来的 Storage/Runtime owner +通过 SQLite、artifact receipt 与 scoped capability 建立。 + +## 3. Owner、原子边界与失败状态 + +| 项目 | v1 合同 | +| --- | --- | +| operation owner | 单次 `maka-gitoxide-helper` 子进程 | +| 输入 | `inspect_repository` strict JSON,最大 64 KiB | +| 配置边界 | open 前 1 MiB repository metadata budget;`gix::open::Options::isolated()` + `lossy_config(true)` + `strict_config(true)` | +| 成功 | exit 0;SHA-1 + exact HEAD commit/tree OID | +| policy rejection | exit 2;`unsupported_object_format` | +| operational failure | exit 1;稳定 `helper_error.reason` | +| 原子性边界 | 单个 repository handle 的一次只读 observation;无跨介质事务 | +| rollback | 只读操作,不需要回滚 | + +当前 response 中的 observation 不是不可伪造的进程外 capability。Node/Runtime admission adapter 会把 +helper artifact digest、managed-tree policy version 与 repository observation 一起绑定进 owner-issued +opaque capability;不能让后续 caller 重新提交裸 OID、object format、helper identity 或 policy。 + +## 4. SHA-256 策略 + +Cargo 编译 `sha256` feature 只用于识别并给出稳定拒绝,不代表 Maka 已支持 SHA-256 repository。 +v1 的 `supportedObjectFormats` 固定为 `["sha1"]`。未来支持必须显式升级 backend capability 与 +协议测试,禁止静默 fallback。 + +## 5. 测试与工具链 + +- 普通 `npm test`、TypeScript workspace 测试和最终用户运行不要求 Rust 工具链。 +- 修改 helper 时运行 `npm run test:gitoxide-helper`。 +- `Cargo.lock` 是 source/build identity 的一部分并进入版本控制。 +- 三平台独立 CI 构建同一源码并运行协议测试。 +- 测试使用 Git CLI 预先构造真实 fixture;启动 helper 后清空 `PATH` 并注入恶意 Git config 环境。 + 如果 helper 尝试使用系统 Git 或 caller config,测试会失败。 + +## 6. 平台能力矩阵 + +| 平台 | 当前验证目标 | 尚未承诺 | +| --- | --- | --- | +| Linux | SHA-1 inspect;SHA-256 reject;无 system-Git fallback | packaging、sandbox、crash recovery | +| macOS | 同 Linux | signing、notarization、production packaging | +| Windows | 同 Linux | Authenticode、job owner、production packaging | + +只有三个 CI lane 都建立证据后,才能把“当前验证目标”升级为持续平台承诺。 + +## 7. 下一切片 + +后续 stacked Draft 先建立 helper artifact claim → opaque invocation capability 的内部边界,并明确 +正式 packaged-release trust root 尚未接入;详见 +`gitoxide-helper-artifact-authority-v1.zh-CN.md`。再后续才把一次 repository observation 转换成 +T1 前可消费的 opaque admission capability。source import、fresh projection 与 candidate ref CAS +继续分别验证,不能在 admission PR 中顺手恢复旧 Git CLI adapter。 diff --git a/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md b/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md new file mode 100644 index 0000000000..18cdb81747 --- /dev/null +++ b/docs/architecture/gitoxide-source-import-data-plane-v1.zh-CN.md @@ -0,0 +1,90 @@ + + +# Gitoxide source import data plane v1 + +状态:堆叠在 repository admission capability 之后的 API-only Draft;没有 Desktop/CLI 消费者。 + +## 1. 主要不变量 + +本切片只证明: + +> source import 只能消费 owner-bound repository admission capability 中冻结的 exact SHA-1 HEAD、 +> helper artifact identity 与 managed-tree policy;helper 只把该 commit 的 reachable tree/blob 导入 +> 此前不存在的 fresh bare repository,并以确定性零父 baseline commit 发布 `refs/maka/*`。caller 不能 +> 重新提交 source path、HEAD、tree identity、helper identity 或 tree policy。 + +本 Draft 尚未接入 state-root lease,因此不能证明 destination 属于 Maka。正式消费者必须在调用 helper +以前由 Storage owner 签发 destination capability;在此之前,API 只接受 fresh path,并拒绝接管或修复 +任何已有 repository。 + +## 2. Owner 与原子性边界 + +- repository admission authority 拥有 source path、commit 与 tree identity; +- invocation owner 在每次调用前重新验证 helper artifact; +- short-lived helper 先用原子 `create_dir` 独占领取 fresh destination,再拥有 object copy 与 baseline ref + publication;任何已存在的叶子路径(包括空目录)都稳定拒绝; +- fresh destination 整体是 artifact 边界,不尝试跨 source/destination/SQLite 伪造事务; +- helper 不拥有 destination recovery/cleanup 权限。失败后的 partial artifact 只能由未来持有 storage-root + identity 与 durable receipt 的 owner 处理。 + +fresh ownership 的线性化点是原子叶子目录创建;import 成功的线性化点是该独占 destination 内 +`refs/maka/*` 以 `MustNotExist` 从不存在发布到 baseline commit。ref 发布前的 objects 不具有 canonical +意义;完整 response 返回前,destination 不能被上层接受。已存在 ref 没有“内容相同即成功”的旁路。 + +## 3. 失败与回滚 + +| 状态 | 处理 | +| --- | --- | +| source HEAD 与 admission 不一致 | 创建 destination 前失败 | +| baseline ref 不满足 Gitoxide authoritative ref grammar | `invalid_baseline_ref`,创建 destination 前失败 | +| destination 已存在(包括 source 自身、foreign bare repo、partial import) | `import_destination_not_fresh`,不读取、修复或删除原内容 | +| destination parent 含 symlink/junction/reparse alias | `import_destination_parent_untrusted`,创建前拒绝 | +| path/type/quota/object copy 失败 | destination 可能是 untrusted partial artifact;helper 不自动清理或重试 | +| helper 进程中断或响应丢失 | 不推断成功;未来 storage owner 必须先验证/隔离 partial artifact,再签发新的 fresh destination | +| SHA-256/未知 object format | policy reject;不 fallback 到系统 Git | + +v1 不复制 source commit/history,不创建 alternates,不执行 hook/filter/submodule/LFS,也不接入 T1/T2。 + +## 4. 平台与资源边界 + +- commit object 最多 1 MiB;单个 tree object 最多 8 MiB;全部 reachable tree object 总计最多 64 MiB; +- 单文件最多 64 MiB;总计最多 2 GiB;最多 200,000 个普通文件; +- 只接受 canonical Git tree ordering,以及 raw mode token `40000`、`100644`、`100755`; +- 拒绝 symlink、submodule、`.git`、`.gitattributes`、非 UTF-8,以及 + `Unicode 17.0 NFC → Unicode 16.0 Default Full Case Folding(Non-Turkic)→ Unicode 17.0 NFC` + collision;原路径与 folded key + 分别有单路径和累计 byte budget; +- repository inspection deadline 为 5 秒;source import deadline 为 10 分钟。2 GiB/200,000 files 是输入 + 上限,不是十分钟内一定成功的 SLA;超时后 fail closed; +- commit/tree/blob 在完整 decode 前先读取 object header 并执行对应预算;isolated Gitoxide open 另固定 + `gitoxide.objects.allocLimit=64 MiB`,避免 policy counter 生效前发生无界单次 object allocation; +- Linux/macOS/Windows 运行同一 locked Cargo suite;当前只证明 fresh-only fail-closed,不承诺 import + process-crash 自动恢复或断电恢复; +- Windows 保留 Git tree 中的 executable bit,不把它映射成 ACL 权威。 + +上述规则只是 portable lexical materialization policy v1。它不证明 Windows path-length、8.3 alias 或 +目标 volume 的大小写行为;这些能力必须由未来 projection owner 的 +`FilesystemMaterializationProfileV1` 在 fresh destination 上独立验证。 + +## 5. 后续依赖 + +下一切片是 Gitoxide candidate/ref CAS。M2.1 与 M2.3 可以并行从最新 main 重建;M2.2/M2.4 必须等 +candidate/ref authority 完成后再重建。M1.3 production composition 只能消费本切片签发的 baseline +artifact,不能恢复旧 Git CLI adapter 或 PATH discovery。 diff --git a/docs/architecture/runtime-workspace-version-authority-v1.zh-CN.md b/docs/architecture/runtime-workspace-version-authority-v1.zh-CN.md index 7836432f20..43ffc7734d 100644 --- a/docs/architecture/runtime-workspace-version-authority-v1.zh-CN.md +++ b/docs/architecture/runtime-workspace-version-authority-v1.zh-CN.md @@ -330,8 +330,20 @@ SQLite read transaction/snapshot;否则并发 writer 可能让读者拼接两 只证明:Maka 能用一个显式注入且经过校验的 Git runtime 创建并独占 private internal repository/worktree lifecycle;外部 drift 被检测后 quarantine。ASF Desktop 不再提供该 runtime,后续实现将 -验证 Apache-2.0/MIT 的 gitoxide backend。需要先拍板 ignored dependencies/scratch、identity marker、fixed -Git config、symlink/LFS/submodule/case/filemode 平台政策。 +验证 Apache-2.0/MIT 的 gitoxide backend。旧 Git-CLI-shaped service 仅作为历史/测试实现,不能成为 +新生产 backend 的 identity owner。 + +当前 Gitoxide 验证栈已拆成三个窄 Draft:isolated short-lived Rust helper、exact helper artifact → +opaque invocation capability、bounded invocation → opaque repository admission capability。分别见: + +- [`gitoxide-short-lived-helper-admission-v1.zh-CN.md`](./gitoxide-short-lived-helper-admission-v1.zh-CN.md) +- [`gitoxide-helper-artifact-authority-v1.zh-CN.md`](./gitoxide-helper-artifact-authority-v1.zh-CN.md) +- [`gitoxide-helper-invocation-owner-v1.zh-CN.md`](./gitoxide-helper-invocation-owner-v1.zh-CN.md) +- [`gitoxide-repository-admission-capability-v1.zh-CN.md`](./gitoxide-repository-admission-capability-v1.zh-CN.md) + +这组 Draft 尚未建立 signed packaged-release trust root,也没有 Desktop/CLI/T1 消费者,因此不能据此 +恢复 managed mode。后续生产接线仍需先拍板 ignored dependencies/scratch、identity marker、 +symlink/LFS/submodule/case/filemode 平台政策。 ### Slice 3:Baseline Open Bundle(实现中) diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index 61635cf998..9723427f6d 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -15,11 +15,11 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t | Classification | Count | |---|---:| -| windows-backend-gap | 20 | +| windows-backend-gap | 23 | | portable-candidate | 8 | | platform-contract | 35 | -Total Windows-excluded declarations: **63** +Total Windows-excluded declarations: **66** ## Inventory @@ -36,6 +36,9 @@ Total Windows-excluded declarations: **63** | windows-backend-gap | `packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts` recovers a durable onboarding intent instead of rolling back a partial publication | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/control-endpoint.test.ts` runtime host control endpoint | `process.platform === 'win32'` | | windows-backend-gap | `packages/runtime-host/src/__tests__/execution-inspect-uds.test.ts` a live Host serves Interactive inspection over its real endpoint while retaining exclusive ownership | `process.platform === 'win32' ? 'Windows execution Host startup lifecycle' : false` | +| 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__/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'` | diff --git a/native/gitoxide-helper/Cargo.lock b/native/gitoxide-helper/Cargo.lock new file mode 100644 index 0000000000..5e27951dca --- /dev/null +++ b/native/gitoxide-helper/Cargo.lock @@ -0,0 +1,1442 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "bisync" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5020822f6d6f23196ccaf55e228db36f9de1cf788052b37992e17cbc96ec41a7" +dependencies = [ + "bisync_macros", +] + +[[package]] +name = "bisync_macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d21f40d350a700f6aa107e45fb26448cf489d34794b2ba4522181dc9f1173af6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "caseless" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" +dependencies = [ + "unicode-normalization", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "gix" +version = "0.86.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb3790fd8981cba7949f1ba924ef865d902df731627bc5998d14164063892fce" +dependencies = [ + "gix-actor", + "gix-commitgraph", + "gix-config", + "gix-date", + "gix-diff", + "gix-discover", + "gix-error", + "gix-features", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-lock", + "gix-object", + "gix-odb", + "gix-pack", + "gix-path", + "gix-protocol", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree-stream", + "gix-zlib", + "nonempty", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-actor" +version = "0.41.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f9308ad6fd35b2a865cbe4117ac61b2be59e4a9ef1621c7a9794f7c8e52c5b" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-attributes" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31c593692ebdc1e38858d9a2b56f6a594c501e24a38971fe6685571f5a07be0" +dependencies = [ + "bstr", + "gix-features", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "gix-chunk" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a871e5cab12ba568845714473505deefffb3c04eb47f4708ce344cd459c1cc" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4363accdf6ef7ba861871d2d521ab7418a04aaaed919fadb022af71d379b12" +dependencies = [ + "bstr", + "gix-path", + "gix-quote", + "gix-trace", + "shell-words", +] + +[[package]] +name = "gix-commitgraph" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2cd7f054ae2727223fe46dd39c012f066b12f532962d336d29ee193261787da" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-config" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "103d11bef95c467577ecfa8b7b86a22e65af3507b2c9bfa3809a4afbae7df301" +dependencies = [ + "bstr", + "gix-config-value", + "gix-features", + "gix-glob", + "gix-path", + "gix-ref", + "gix-sec", + "gix-utils", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "gix-config-value" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6af5321bfd3711a279d6b244d58532ba1cfabf9eb6374791f19929d8970082" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-path", + "libc", + "thiserror", +] + +[[package]] +name = "gix-date" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e47b9e8cdc688296609b706428de570f88b1e0eed7156dde7b4a89d26fa4567" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", +] + +[[package]] +name = "gix-diff" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee7d89a3c507491cdfc57a1d1e0e300214720b4f7709ebc253e422f99822bfc" +dependencies = [ + "bstr", + "gix-hash", + "gix-object", + "thiserror", +] + +[[package]] +name = "gix-discover" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f517766fa1101dfe2606c1a19a8ffa699099030995a9194445446dfe261bdf" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", + "thiserror", +] + +[[package]] +name = "gix-error" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9292309fd944e71b2a3c96d3c03a6feb8852db646febdde7cbb9f79cb5f329" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-features" +version = "0.49.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39c0e59d9d253dcccc38c3a46b91bfb9b46bd63eed54fe1a719e12194884d52a" +dependencies = [ + "bytes", + "crc32fast", + "crossbeam-channel", + "gix-path", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "parking_lot", + "prodash", + "walkdir", +] + +[[package]] +name = "gix-filter" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e7b5dbf524d97e839f642930c76d7f011c0791e7d11d8148989ac5af7c76aa8" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-fs" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcfa9fd253f25350a3b21b3dd74034a446098e373c6123d4cee3519894f12ef" +dependencies = [ + "bstr", + "gix-features", + "gix-path", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-glob" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b417cf515fd8c91468b578071f76d6cba716f8a1eccd853906bff4908b2c1413" +dependencies = [ + "bitflags 2.13.1", + "bstr", + "gix-features", + "gix-path", +] + +[[package]] +name = "gix-hash" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf125eae66b7d6e4395511a06c0d43a3c34eac96c8641fb98b22078faee65b8" +dependencies = [ + "faster-hex", + "gix-features", + "sha1-checked", + "sha2", + "thiserror", +] + +[[package]] +name = "gix-hashtable" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78fccd6fea3bcf0b39c076bae60ae49b08daaf538b950202101a981f9d3c01d3" +dependencies = [ + "gix-hash", + "hashbrown 0.17.1", + "parking_lot", +] + +[[package]] +name = "gix-lock" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c69157820343bf1c6e4b88b9808e920900de02e18aaf5862b30ada43814848" +dependencies = [ + "gix-tempfile", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-object" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48c235e7f886eb819fc878af75be889333dd3c38bee02ed7af48ae2cf596c4" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-odb" +version = "0.83.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dd494ffb5037e62b8220109e894d2861ff2150a2cacbfccdba57ae1ebab2b96" +dependencies = [ + "arc-swap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "gix-zlib", + "memmap2", + "parking_lot", + "tempfile", + "thiserror", +] + +[[package]] +name = "gix-pack" +version = "0.73.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d5446127b269706e85998065267ddd2ccc3550179da6780b22fe496175ccb20" +dependencies = [ + "clru", + "gix-chunk", + "gix-error", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-path", + "gix-zlib", + "memmap2", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-packetline" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3766025c72319c4accdd854a18e6f0dd176c8eb0f3bc8a60a7765be2b50cabf2" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror", +] + +[[package]] +name = "gix-path" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b075e730586bba7341304d6fc1b4efc1d10cf64532622521c0e07f30e661046" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror", +] + +[[package]] +name = "gix-protocol" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dede40e89c1e90f548415f50636bb051f6d9c60f68b8b710bc07825722d19588" +dependencies = [ + "bisync", + "bstr", + "gix-date", + "gix-features", + "gix-hash", + "gix-ref", + "gix-shallow", + "gix-transport", + "gix-utils", + "nonempty", + "thiserror", +] + +[[package]] +name = "gix-quote" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-ref" +version = "0.66.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb0c90a8f6202ceaaa22996cbf837c943ccb2d8af9ff3490f0758305e6b7883" +dependencies = [ + "gix-actor", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror", +] + +[[package]] +name = "gix-refspec" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7406282cc0259b51f6aee299ca3d31279a020530363152a2e6c96e8a7f7bbc83" +dependencies = [ + "bstr", + "gix-error", + "gix-glob", + "gix-hash", + "gix-revision", + "gix-validate", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-revision" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e55e09d4a1ecf2beecc8c09cafcad37979e805b31f588b0e957e191df5783681" +dependencies = [ + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-object", + "gix-revwalk", + "nonempty", +] + +[[package]] +name = "gix-revwalk" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c113c0a53294dc6280ffc06cbcc4f50f820397e97d6a00b429a44b8db26e29" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-sec" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4fe6c152c1d50aea36f299825702cd37e303307832fec1d0fdd5844e47ce2f" +dependencies = [ + "bitflags 2.13.1", + "gix-path", + "libc", + "windows-sys", +] + +[[package]] +name = "gix-shallow" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ecc9f4b40537043e4bbd7d3d1760e74fb8e7b07a546166b558acaa73ad97f4a" +dependencies = [ + "bstr", + "gix-hash", + "gix-lock", + "nonempty", + "thiserror", +] + +[[package]] +name = "gix-tempfile" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b675b920bd5a61d17ad542772f03ec34c60feb8ff683e1560c03ae967363731e" +dependencies = [ + "gix-fs", + "libc", + "parking_lot", + "tempfile", +] + +[[package]] +name = "gix-trace" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be3eb81d9dc914335923e50d52829c551feefd6a72d176c4130c546b67a60814" + +[[package]] +name = "gix-transport" +version = "0.58.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f36d045b840f8aeee1a527e677eab1fbebfbbe94bf2e708fa81d0b4b742d5fc" +dependencies = [ + "bstr", + "gix-command", + "gix-features", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-sec", + "gix-url", + "thiserror", +] + +[[package]] +name = "gix-traverse" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008c5cd879e46e86b5c2469e633611978b18775d53d05668d691bc13088bd409" +dependencies = [ + "bitflags 2.13.1", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-url" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31bdfc93aa880cda3272718a5879ce3aa7723fa13514320dd6608151607afe72" +dependencies = [ + "bstr", + "gix-path", + "gix-utils", + "percent-encoding", + "thiserror", +] + +[[package]] +name = "gix-utils" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da1c46491b49458a446cc76f0085860f8164c2290742e0aa8c653ce67240a97" +dependencies = [ + "bstr", + "fastrand", + "getrandom", + "unicode-normalization", +] + +[[package]] +name = "gix-validate" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dae8780f63ed8a803b8bdabbd7aa5f5c5d74592c8b50eed875c1bb4f6545a6a" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-worktree-stream" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b088c8724e7be120c4798dd86925cf05332c9d356a463542578600c50c7a549" +dependencies = [ + "gix-attributes", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-traverse", + "parking_lot", +] + +[[package]] +name = "gix-zlib" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e8813f5579b3075ff9c90f7c59cd2b62b4ebb639361f0911648b22d7446cc7c" +dependencies = [ + "thiserror", + "zlib-rs", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "maka-gitoxide-helper" +version = "0.0.0" +dependencies = [ + "caseless", + "gix", + "serde", + "serde_json", + "unicode-normalization", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prodash" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest 0.10.7", + "sha1", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/native/gitoxide-helper/Cargo.toml b/native/gitoxide-helper/Cargo.toml new file mode 100644 index 0000000000..0db69f5f78 --- /dev/null +++ b/native/gitoxide-helper/Cargo.toml @@ -0,0 +1,35 @@ +# 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. + +[package] +name = "maka-gitoxide-helper" +version = "0.0.0" +edition = "2024" +license = "Apache-2.0" +rust-version = "1.98" +publish = false + +[[bin]] +name = "maka-gitoxide-helper" +path = "src/main.rs" + +[dependencies] +caseless = "=0.2.2" +gix = { version = "=0.86.0", default-features = false, features = ["sha1", "sha256"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +unicode-normalization = "0.1" diff --git a/native/gitoxide-helper/rust-toolchain.toml b/native/gitoxide-helper/rust-toolchain.toml new file mode 100644 index 0000000000..bfeff488e4 --- /dev/null +++ b/native/gitoxide-helper/rust-toolchain.toml @@ -0,0 +1,21 @@ +# 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. + +[toolchain] +channel = "1.98.0" +components = ["rustfmt"] +profile = "minimal" diff --git a/native/gitoxide-helper/src/main.rs b/native/gitoxide-helper/src/main.rs new file mode 100644 index 0000000000..b93a2b197e --- /dev/null +++ b/native/gitoxide-helper/src/main.rs @@ -0,0 +1,1069 @@ +/* + * 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. + */ + +use std::{ + collections::HashSet, + fs, + io::{self, Read}, + path::{Path, PathBuf}, + process::ExitCode, +}; + +use caseless::Caseless; +use serde::{Deserialize, Serialize}; +use unicode_normalization::UnicodeNormalization; + +const PROTOCOL_VERSION: u8 = 1; +const MANAGED_TREE_POLICY_VERSION: u8 = 1; +const MAX_REQUEST_BYTES: u64 = 64 * 1024; +const MAX_REPOSITORY_METADATA_BYTES: u64 = 1024 * 1024; +const MAX_IMPORT_FILE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_IMPORT_BYTES: u64 = 2 * 1024 * 1024 * 1024; +const MAX_IMPORT_FILES: u64 = 200_000; +const MAX_COMMIT_OBJECT_BYTES: u64 = 1024 * 1024; +const MAX_SINGLE_TREE_OBJECT_BYTES: u64 = 8 * 1024 * 1024; +const MAX_TOTAL_TREE_OBJECT_BYTES: u64 = 64 * 1024 * 1024; +const MAX_GITOXIDE_OBJECT_ALLOCATION_BYTES: &str = "gitoxide.objects.allocLimit=67108864"; +const MANAGED_TREE_POLICY_V1: ManagedTreePolicy = ManagedTreePolicy { + max_depth: 64, + max_tree_visits: 250_000, + max_entries: 400_000, + max_total_path_bytes: 256 * 1024 * 1024, + max_total_folded_path_bytes: 256 * 1024 * 1024, + max_component_bytes: 255, + max_relative_path_bytes: 4096, + max_folded_relative_path_bytes: 4096, + max_files: MAX_IMPORT_FILES, + max_file_bytes: MAX_IMPORT_FILE_BYTES, + max_bytes: MAX_IMPORT_BYTES, + max_commit_object_bytes: MAX_COMMIT_OBJECT_BYTES, + max_single_tree_object_bytes: MAX_SINGLE_TREE_OBJECT_BYTES, + max_total_tree_object_bytes: MAX_TOTAL_TREE_OBJECT_BYTES, +}; +const HELPER_ERROR_REASONS_V1: &[&str] = &[ + "internal_error_reason_invalid", + "request_read_failed", + "request_too_large", + "invalid_request", + "unsupported_protocol_version", + "repository_metadata_limit_exceeded", + "repository_open_failed", + "head_commit_unavailable", + "head_commit_identity_mismatch", + "head_tree_unavailable", + "commit_object_limit_exceeded", + "baseline_commit_write_failed", + "baseline_publish_failed", + "baseline_ref_outside_maka_namespace", + "invalid_baseline_ref", + "import_destination_create_failed", + "import_destination_not_fresh", + "import_destination_object_format_mismatch", + "import_destination_parent_untrusted", + "import_destination_unreadable", + "import_hooks_cleanup_failed", + "invalid_source_head_commit_oid", + "source_blob_copy_failed", + "source_blob_identity_mismatch", + "source_blob_invalid", + "source_blob_unavailable", + "source_byte_limit_exceeded", + "source_file_limit_exceeded", + "source_folded_path_byte_limit_exceeded", + "source_folded_path_length_exceeded", + "source_head_commit_mismatch", + "source_head_commit_identity_mismatch", + "source_head_commit_unavailable", + "source_head_tree_unavailable", + "source_path_collision", + "source_path_byte_limit_exceeded", + "source_path_length_exceeded", + "source_tree_copy_failed", + "source_tree_depth_exceeded", + "source_tree_entry_limit_exceeded", + "source_tree_identity_mismatch", + "source_tree_invalid", + "source_tree_object_byte_limit_exceeded", + "source_tree_object_limit_exceeded", + "source_tree_noncanonical_mode", + "source_tree_not_sorted", + "source_tree_observation_mismatch", + "source_tree_unavailable", + "source_tree_visit_limit_exceeded", + "unsupported_source_entry_kind", + "unsupported_source_path", + "unsupported_object_format", + "unsupported_managed_tree_policy", +]; + +#[derive(Deserialize)] +#[serde( + deny_unknown_fields, + tag = "operation", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +enum Request { + InspectRepository { + protocol_version: u8, + repository_path: PathBuf, + }, + ImportSourceHead { + protocol_version: u8, + source_repository_path: PathBuf, + expected_source_head_commit_oid: String, + destination_repository_path: PathBuf, + baseline_ref: String, + managed_tree_policy_version: u8, + }, +} + +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum Response<'a> { + #[serde(rename_all = "camelCase")] + RepositoryInspected { + protocol_version: u8, + object_format: &'static str, + head_commit_oid: String, + head_tree_oid: String, + }, + #[serde(rename_all = "camelCase")] + RepositoryRejected { + protocol_version: u8, + reason: &'static str, + object_format: String, + supported_object_formats: [&'static str; 1], + }, + #[serde(rename_all = "camelCase")] + SourceImported { + protocol_version: u8, + object_format: &'static str, + source_head_commit_oid: String, + source_tree_oid: String, + baseline_commit_oid: String, + baseline_tree_oid: String, + baseline_ref: String, + managed_tree_policy_version: u8, + files_imported: u64, + bytes_imported: u64, + }, + #[serde(rename_all = "camelCase")] + HelperError { + protocol_version: u8, + reason: &'a str, + }, +} + +fn main() -> ExitCode { + match run() { + Ok(code) => code, + Err(reason) => { + let reason = if HELPER_ERROR_REASONS_V1.contains(&reason) { + reason + } else { + "internal_error_reason_invalid" + }; + write_response(&Response::HelperError { + protocol_version: PROTOCOL_VERSION, + reason, + }); + ExitCode::from(1) + } + } +} + +fn run() -> Result { + let request = read_request()?; + match request { + Request::InspectRepository { + protocol_version, + repository_path, + } => { + assert_protocol_version(protocol_version)?; + inspect_repository(repository_path) + } + Request::ImportSourceHead { + protocol_version, + source_repository_path, + expected_source_head_commit_oid, + destination_repository_path, + baseline_ref, + managed_tree_policy_version, + } => { + assert_protocol_version(protocol_version)?; + import_source_head( + source_repository_path, + expected_source_head_commit_oid, + destination_repository_path, + baseline_ref, + managed_tree_policy_version, + ) + } + } +} + +fn assert_protocol_version(protocol_version: u8) -> Result<(), &'static str> { + if protocol_version != PROTOCOL_VERSION { + return Err("unsupported_protocol_version"); + } + Ok(()) +} + +fn inspect_repository(repository_path: PathBuf) -> Result { + assert_repository_metadata_budget(&repository_path)?; + let repository = match managed_open_options().open(repository_path) { + Ok(repository) => repository.to_thread_local(), + Err(gix::open::Error::Config(gix::config::Error::ConfigTypedString(error))) + if error.key.as_slice() == b"extensions.objectFormat" => + { + return Ok(reject_unsupported_object_format("unknown".to_owned())); + } + Err(gix::open::Error::Config(gix::config::Error::UnsupportedObjectFormat { .. })) => { + return Ok(reject_unsupported_object_format("unknown".to_owned())); + } + Err(_) => return Err("repository_open_failed"), + }; + + match repository.object_hash() { + gix::hash::Kind::Sha1 => { + let head_id = repository + .head_id() + .map_err(|_| "head_commit_unavailable")? + .detach(); + let head = load_verified_object( + &repository, + head_id, + gix::objs::Kind::Commit, + MAX_COMMIT_OBJECT_BYTES, + "head_commit_unavailable", + "head_commit_unavailable", + "commit_object_limit_exceeded", + "head_commit_identity_mismatch", + )? + .try_into_commit() + .map_err(|_| "head_commit_unavailable")?; + let head_commit_oid = head.id().detach().to_string(); + let head_tree_oid = head + .tree_id() + .map_err(|_| "head_tree_unavailable")? + .detach() + .to_string(); + write_response(&Response::RepositoryInspected { + protocol_version: PROTOCOL_VERSION, + object_format: "sha1", + head_commit_oid, + head_tree_oid, + }); + Ok(ExitCode::SUCCESS) + } + gix::hash::Kind::Sha256 => Ok(reject_unsupported_object_format("sha256".to_owned())), + _ => Ok(reject_unsupported_object_format("unknown".to_owned())), + } +} + +fn open_repository(repository_path: PathBuf) -> Result { + assert_repository_metadata_budget(&repository_path)?; + Ok(managed_open_options() + .open(repository_path) + .map_err(|_| "repository_open_failed")? + .to_thread_local()) +} + +fn managed_open_options() -> gix::open::Options { + gix::open::Options::isolated() + .lossy_config(true) + .strict_config(true) + .config_overrides([MAX_GITOXIDE_OBJECT_ALLOCATION_BYTES]) +} + +fn assert_repository_metadata_budget(repository_path: &Path) -> Result<(), &'static str> { + let mut observed_bytes = 0_u64; + let dot_git_path = repository_path.join(".git"); + let git_dir = match fs::metadata(&dot_git_path) { + Ok(metadata) if metadata.is_dir() => dot_git_path, + Ok(metadata) if metadata.is_file() => { + add_repository_metadata_bytes(metadata.len(), &mut observed_bytes)?; + gix::discover::path::from_gitdir_file(&dot_git_path) + .map_err(|_| "repository_open_failed")? + } + Ok(_) => return Err("repository_open_failed"), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + match fs::metadata(repository_path) { + Ok(metadata) if metadata.is_file() => { + add_repository_metadata_bytes(metadata.len(), &mut observed_bytes)?; + gix::discover::path::from_gitdir_file(repository_path) + .map_err(|_| "repository_open_failed")? + } + Ok(metadata) if metadata.is_dir() => repository_path.to_path_buf(), + Ok(_) => return Err("repository_open_failed"), + Err(_) => return Err("repository_open_failed"), + } + } + Err(_) => return Err("repository_open_failed"), + }; + + let common_dir_path_file = git_dir.join("commondir"); + let common_dir = match fs::metadata(&common_dir_path_file) { + Ok(metadata) if metadata.is_file() => { + add_repository_metadata_bytes(metadata.len(), &mut observed_bytes)?; + let relative_or_absolute = gix::discover::path::from_plain_file(&common_dir_path_file) + .ok_or("repository_open_failed")? + .map_err(|_| "repository_open_failed")?; + git_dir.join(relative_or_absolute) + } + Ok(_) => return Err("repository_open_failed"), + Err(error) if error.kind() == io::ErrorKind::NotFound => git_dir.clone(), + Err(_) => return Err("repository_open_failed"), + }; + + add_repository_metadata_file(&common_dir.join("config"), &mut observed_bytes)?; + add_repository_metadata_file(&git_dir.join("config.worktree"), &mut observed_bytes)?; + Ok(()) +} + +fn add_repository_metadata_file(path: &Path, observed_bytes: &mut u64) -> Result<(), &'static str> { + match fs::metadata(path) { + Ok(metadata) if metadata.is_file() => { + add_repository_metadata_bytes(metadata.len(), observed_bytes) + } + Ok(_) => Err("repository_open_failed"), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(_) => Err("repository_open_failed"), + } +} + +fn add_repository_metadata_bytes(bytes: u64, observed_bytes: &mut u64) -> Result<(), &'static str> { + *observed_bytes = observed_bytes + .checked_add(bytes) + .ok_or("repository_metadata_limit_exceeded")?; + if *observed_bytes > MAX_REPOSITORY_METADATA_BYTES { + return Err("repository_metadata_limit_exceeded"); + } + Ok(()) +} + +fn import_source_head( + source_repository_path: PathBuf, + expected_source_head_commit_oid: String, + destination_repository_path: PathBuf, + baseline_ref: String, + managed_tree_policy_version: u8, +) -> Result { + use gix::bstr::ByteSlice; + + if !baseline_ref.starts_with("refs/maka/") { + return Err("baseline_ref_outside_maka_namespace"); + } + gix::refs::FullName::try_from(baseline_ref.as_str()).map_err(|_| "invalid_baseline_ref")?; + if managed_tree_policy_version != MANAGED_TREE_POLICY_VERSION { + return Err("unsupported_managed_tree_policy"); + } + let source = open_repository(source_repository_path)?; + if source.object_hash() != gix::hash::Kind::Sha1 { + return Err("unsupported_object_format"); + } + let expected_source_head = + gix::hash::ObjectId::from_hex(expected_source_head_commit_oid.as_bytes()) + .map_err(|_| "invalid_source_head_commit_oid")?; + if expected_source_head.kind() != gix::hash::Kind::Sha1 { + return Err("invalid_source_head_commit_oid"); + } + let source_head_id = source + .head_id() + .map_err(|_| "source_head_commit_unavailable")?; + if source_head_id.detach() != expected_source_head { + return Err("source_head_commit_mismatch"); + } + let source_head = load_verified_object( + &source, + expected_source_head, + gix::objs::Kind::Commit, + MANAGED_TREE_POLICY_V1.max_commit_object_bytes, + "source_head_commit_unavailable", + "source_head_commit_unavailable", + "commit_object_limit_exceeded", + "source_head_commit_identity_mismatch", + )? + .try_into_commit() + .map_err(|_| "source_head_commit_unavailable")?; + let source_tree = source_head + .tree_id() + .map_err(|_| "source_head_tree_unavailable")? + .detach(); + + let mut stats = ManagedTreeStats::default(); + walk_verified_source_tree( + &source, + None, + source_tree, + "", + 0, + MANAGED_TREE_POLICY_V1, + &mut stats, + )?; + let expected_files = stats.files; + let expected_bytes = stats.bytes; + drop(stats); + + assert_import_destination_parent(&destination_repository_path)?; + let destination = claim_fresh_import_destination(&destination_repository_path)?; + if destination.object_hash() != gix::hash::Kind::Sha1 { + return Err("import_destination_object_format_mismatch"); + } + + fs::remove_dir_all(destination_repository_path.join("hooks")) + .map_err(|_| "import_hooks_cleanup_failed")?; + fs::create_dir(destination_repository_path.join("hooks")) + .map_err(|_| "import_hooks_cleanup_failed")?; + + let mut copy_stats = ManagedTreeStats::default(); + walk_verified_source_tree( + &source, + Some(&destination), + source_tree, + "", + 0, + MANAGED_TREE_POLICY_V1, + &mut copy_stats, + )?; + if copy_stats.files != expected_files || copy_stats.bytes != expected_bytes { + return Err("source_tree_observation_mismatch"); + } + + let signature = gix::actor::SignatureRef { + name: b"Maka Workspace Service".as_bstr(), + email: b"workspace@maka.invalid".as_bstr(), + time: "946684800 +0000", + }; + let baseline_commit = destination + .new_commit_as( + signature, + signature, + "maka managed workspace baseline v1", + source_tree, + std::iter::empty::(), + ) + .map_err(|_| "baseline_commit_write_failed")? + .id() + .detach(); + destination + .reference( + baseline_ref.as_str(), + baseline_commit, + gix::refs::transaction::PreviousValue::MustNotExist, + "maka managed workspace baseline", + ) + .map_err(|_| "baseline_publish_failed")?; + + write_response(&Response::SourceImported { + protocol_version: PROTOCOL_VERSION, + object_format: "sha1", + source_head_commit_oid: expected_source_head.to_string(), + source_tree_oid: source_tree.to_string(), + baseline_commit_oid: baseline_commit.to_string(), + baseline_tree_oid: source_tree.to_string(), + baseline_ref, + managed_tree_policy_version: MANAGED_TREE_POLICY_VERSION, + files_imported: copy_stats.files, + bytes_imported: copy_stats.bytes, + }); + Ok(ExitCode::SUCCESS) +} + +fn claim_fresh_import_destination(path: &Path) -> Result { + match fs::create_dir(path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + return Err("import_destination_not_fresh"); + } + Err(_) => return Err("import_destination_create_failed"), + } + gix::ThreadSafeRepository::init_opts( + path, + gix::create::Kind::Bare, + gix::create::Options { + destination_must_be_empty: Some(true), + object_hash: Some(gix::hash::Kind::Sha1), + ..Default::default() + }, + managed_open_options(), + ) + .map_err(|_| "import_destination_create_failed") + .map(|repository| repository.to_thread_local()) +} + +fn load_verified_object<'repo>( + repository: &'repo gix::Repository, + object_id: gix::hash::ObjectId, + expected_kind: gix::objs::Kind, + max_bytes: u64, + unavailable_reason: &'static str, + invalid_reason: &'static str, + limit_reason: &'static str, + identity_reason: &'static str, +) -> Result, &'static str> { + let header = repository + .find_header(object_id) + .map_err(|_| unavailable_reason)?; + if header.kind() != expected_kind { + return Err(invalid_reason); + } + if header.size() > max_bytes { + return Err(limit_reason); + } + let object = repository + .find_object(object_id) + .map_err(|_| unavailable_reason)?; + if object.kind != expected_kind { + return Err(invalid_reason); + } + if object.data.len() as u64 > max_bytes { + return Err(limit_reason); + } + gix::objs::Data::new(&object.data, object.kind, object.id.kind()) + .verify_checksum(object_id.as_ref()) + .map_err(|_| identity_reason)?; + Ok(object) +} + +fn assert_import_destination_parent(destination: &Path) -> Result<(), &'static str> { + if !destination.is_absolute() { + return Err("import_destination_parent_untrusted"); + } + let parent = destination + .parent() + .ok_or("import_destination_parent_untrusted")?; + let mut ancestors = parent.ancestors().collect::>(); + ancestors.reverse(); + for ancestor in ancestors { + if ancestor.as_os_str().is_empty() { + continue; + } + let metadata = + fs::symlink_metadata(ancestor).map_err(|_| "import_destination_parent_untrusted")?; + if metadata.file_type().is_symlink() || is_windows_reparse_point(&metadata) { + return Err("import_destination_parent_untrusted"); + } + } + Ok(()) +} + +#[cfg(windows)] +fn is_windows_reparse_point(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn is_windows_reparse_point(_metadata: &fs::Metadata) -> bool { + false +} + +fn walk_verified_source_tree( + source: &gix::Repository, + destination: Option<&gix::Repository>, + tree_oid: gix::hash::ObjectId, + prefix: &str, + depth: u64, + policy: ManagedTreePolicy, + stats: &mut ManagedTreeStats, +) -> Result<(), &'static str> { + let tree = load_verified_object( + source, + tree_oid, + gix::objs::Kind::Tree, + policy.max_single_tree_object_bytes, + "source_tree_unavailable", + "source_tree_invalid", + "source_tree_object_limit_exceeded", + "source_tree_identity_mismatch", + )? + .try_into_tree() + .map_err(|_| "source_tree_invalid")?; + stats.enter_tree(depth, tree.data.len() as u64, policy)?; + assert_canonical_tree_modes(&tree.data)?; + let mut previous_entry: Option<(Vec, bool)> = None; + for entry in tree.iter() { + let entry = entry.map_err(|_| "source_tree_invalid")?; + let component = + std::str::from_utf8(entry.filename()).map_err(|_| "unsupported_source_path")?; + if !is_supported_source_component(component) + || component.len() as u64 > policy.max_component_bytes + { + return Err("unsupported_source_path"); + } + let is_tree = matches!(entry.mode().kind(), gix::objs::tree::EntryKind::Tree); + if previous_entry.as_ref().is_some_and(|(name, tree)| { + compare_git_tree_entry_names(name, *tree, entry.filename(), is_tree) + != std::cmp::Ordering::Less + }) { + return Err("source_tree_not_sorted"); + } + previous_entry = Some((entry.filename().to_vec(), is_tree)); + let relative_path = if prefix.is_empty() { + component.to_owned() + } else { + format!("{prefix}/{component}") + }; + stats.observe_entry(&relative_path, policy)?; + match entry.mode().kind() { + gix::objs::tree::EntryKind::Tree => { + walk_verified_source_tree( + source, + destination, + entry.object_id(), + &relative_path, + depth.checked_add(1).ok_or("source_tree_depth_exceeded")?, + policy, + stats, + )?; + } + gix::objs::tree::EntryKind::Blob | gix::objs::tree::EntryKind::BlobExecutable => { + let blob_oid = entry.object_id(); + let blob = load_verified_object( + source, + blob_oid, + gix::objs::Kind::Blob, + policy.max_file_bytes, + "source_blob_unavailable", + "source_blob_invalid", + "source_file_limit_exceeded", + "source_blob_identity_mismatch", + )? + .try_into_blob() + .map_err(|_| "source_blob_invalid")?; + stats.observe_blob(blob.data.len() as u64, policy)?; + if let Some(destination) = destination { + let copied_blob = destination + .write_blob(&blob.data) + .map_err(|_| "source_blob_copy_failed")? + .detach(); + if copied_blob != blob_oid { + return Err("source_blob_identity_mismatch"); + } + } + } + _ => return Err("unsupported_source_entry_kind"), + } + } + if let Some(destination) = destination { + let copied_tree = destination + .write_object(tree.decode().map_err(|_| "source_tree_invalid")?) + .map_err(|_| "source_tree_copy_failed")? + .detach(); + if copied_tree != tree_oid { + return Err("source_tree_identity_mismatch"); + } + } + Ok(()) +} + +fn compare_git_tree_entry_names( + left_name: &[u8], + left_is_tree: bool, + right_name: &[u8], + right_is_tree: bool, +) -> std::cmp::Ordering { + let common = left_name.len().min(right_name.len()); + match left_name[..common].cmp(&right_name[..common]) { + std::cmp::Ordering::Equal => { + let left_suffix = + left_name + .get(common) + .copied() + .unwrap_or(if left_is_tree { b'/' } else { 0 }); + let right_suffix = + right_name + .get(common) + .copied() + .unwrap_or(if right_is_tree { b'/' } else { 0 }); + left_suffix.cmp(&right_suffix) + } + ordering => ordering, + } +} + +fn assert_canonical_tree_modes(mut data: &[u8]) -> Result<(), &'static str> { + const SHA1_OID_BYTES: usize = 20; + + while !data.is_empty() { + let mode_end = data + .iter() + .position(|byte| *byte == b' ') + .ok_or("source_tree_invalid")?; + let mode = &data[..mode_end]; + if mode != b"40000" && mode != b"100644" && mode != b"100755" { + return Err("source_tree_noncanonical_mode"); + } + data = &data[mode_end + 1..]; + let filename_end = data + .iter() + .position(|byte| *byte == 0) + .ok_or("source_tree_invalid")?; + data = &data[filename_end + 1..]; + if data.len() < SHA1_OID_BYTES { + return Err("source_tree_invalid"); + } + data = &data[SHA1_OID_BYTES..]; + } + Ok(()) +} + +fn is_supported_source_component(component: &str) -> bool { + !component.is_empty() + && component != "." + && component != ".." + && !component.contains('/') + && !component.contains('\\') + && !component.contains('\0') + && !component + .chars() + .any(|character| character <= '\u{001f}' || "<>:\"|?*".contains(character)) + && !component.ends_with('.') + && !component.ends_with(' ') + && !is_windows_reserved_device_name(component) + && !component.eq_ignore_ascii_case(".git") + && !component.eq_ignore_ascii_case(".gitattributes") +} + +fn is_windows_reserved_device_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or_default(); + let folded = stem.to_ascii_uppercase(); + matches!( + folded.as_str(), + "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" + ) || folded + .strip_prefix("COM") + .is_some_and(|suffix| matches!(suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")) + || folded.strip_prefix("LPT").is_some_and(|suffix| { + matches!(suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") + }) + || matches!( + folded.as_str(), + "COM¹" | "COM²" | "COM³" | "LPT¹" | "LPT²" | "LPT³" + ) +} + +#[derive(Clone, Copy)] +struct ManagedTreePolicy { + max_depth: u64, + max_tree_visits: u64, + max_entries: u64, + max_total_path_bytes: u64, + max_total_folded_path_bytes: u64, + max_component_bytes: u64, + max_relative_path_bytes: u64, + max_folded_relative_path_bytes: u64, + max_files: u64, + max_file_bytes: u64, + max_bytes: u64, + max_commit_object_bytes: u64, + max_single_tree_object_bytes: u64, + max_total_tree_object_bytes: u64, +} + +#[derive(Default)] +struct ManagedTreeStats { + tree_visits: u64, + entries: u64, + total_path_bytes: u64, + total_folded_path_bytes: u64, + files: u64, + bytes: u64, + tree_object_bytes: u64, + folded_paths: HashSet, +} + +impl ManagedTreeStats { + fn enter_tree( + &mut self, + depth: u64, + object_bytes: u64, + policy: ManagedTreePolicy, + ) -> Result<(), &'static str> { + if depth > policy.max_depth { + return Err("source_tree_depth_exceeded"); + } + self.tree_visits = self + .tree_visits + .checked_add(1) + .filter(|visits| *visits <= policy.max_tree_visits) + .ok_or("source_tree_visit_limit_exceeded")?; + self.tree_object_bytes = self + .tree_object_bytes + .checked_add(object_bytes) + .filter(|bytes| *bytes <= policy.max_total_tree_object_bytes) + .ok_or("source_tree_object_byte_limit_exceeded")?; + Ok(()) + } + + fn observe_entry( + &mut self, + relative_path: &str, + policy: ManagedTreePolicy, + ) -> Result<(), &'static str> { + let path_bytes = relative_path.len() as u64; + if path_bytes > policy.max_relative_path_bytes { + return Err("source_path_length_exceeded"); + } + self.entries = self + .entries + .checked_add(1) + .filter(|entries| *entries <= policy.max_entries) + .ok_or("source_tree_entry_limit_exceeded")?; + self.total_path_bytes = self + .total_path_bytes + .checked_add(path_bytes) + .filter(|bytes| *bytes <= policy.max_total_path_bytes) + .ok_or("source_path_byte_limit_exceeded")?; + let folded_path: String = relative_path.nfc().default_case_fold().nfc().collect(); + let folded_path_bytes = folded_path.len() as u64; + if folded_path_bytes > policy.max_folded_relative_path_bytes { + return Err("source_folded_path_length_exceeded"); + } + self.total_folded_path_bytes = self + .total_folded_path_bytes + .checked_add(folded_path_bytes) + .filter(|bytes| *bytes <= policy.max_total_folded_path_bytes) + .ok_or("source_folded_path_byte_limit_exceeded")?; + if !self.folded_paths.insert(folded_path) { + return Err("source_path_collision"); + } + Ok(()) + } + + fn observe_blob(&mut self, size: u64, policy: ManagedTreePolicy) -> Result<(), &'static str> { + if size > policy.max_file_bytes { + return Err("source_file_limit_exceeded"); + } + self.files = self + .files + .checked_add(1) + .filter(|files| *files <= policy.max_files) + .ok_or("source_file_limit_exceeded")?; + self.bytes = self + .bytes + .checked_add(size) + .filter(|bytes| *bytes <= policy.max_bytes) + .ok_or("source_byte_limit_exceeded")?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tiny_policy() -> ManagedTreePolicy { + ManagedTreePolicy { + max_depth: 1, + max_tree_visits: 2, + max_entries: 2, + max_total_path_bytes: 5, + max_total_folded_path_bytes: 5, + max_component_bytes: 3, + max_relative_path_bytes: 4, + max_folded_relative_path_bytes: 4, + max_files: 1, + max_file_bytes: 3, + max_bytes: 3, + max_commit_object_bytes: 3, + max_single_tree_object_bytes: 3, + max_total_tree_object_bytes: 5, + } + } + + #[test] + fn managed_tree_budget_bounds_depth_visits_and_entries() { + let policy = tiny_policy(); + let mut stats = ManagedTreeStats::default(); + assert_eq!(stats.enter_tree(0, 2, policy), Ok(())); + assert_eq!(stats.enter_tree(1, 3, policy), Ok(())); + assert_eq!( + stats.enter_tree(1, 0, policy), + Err("source_tree_visit_limit_exceeded") + ); + + let mut stats = ManagedTreeStats::default(); + assert_eq!( + stats.enter_tree(2, 0, policy), + Err("source_tree_depth_exceeded") + ); + assert_eq!(stats.observe_entry("a", policy), Ok(())); + assert_eq!(stats.observe_entry("bb", policy), Ok(())); + assert_eq!( + stats.observe_entry("c", policy), + Err("source_tree_entry_limit_exceeded") + ); + } + + #[test] + fn managed_tree_budget_bounds_single_and_total_tree_object_bytes() { + let policy = tiny_policy(); + let mut stats = ManagedTreeStats::default(); + assert_eq!(stats.enter_tree(0, 3, policy), Ok(())); + assert_eq!( + stats.enter_tree(1, 3, policy), + Err("source_tree_object_byte_limit_exceeded") + ); + } + + #[test] + fn managed_tree_budget_bounds_paths_and_blob_bytes() { + let policy = tiny_policy(); + let mut stats = ManagedTreeStats::default(); + assert_eq!( + stats.observe_entry("abcde", policy), + Err("source_path_length_exceeded") + ); + assert_eq!(stats.observe_entry("abc", policy), Ok(())); + assert_eq!( + stats.observe_entry("def", policy), + Err("source_path_byte_limit_exceeded") + ); + + let mut stats = ManagedTreeStats::default(); + assert_eq!( + stats.observe_blob(4, policy), + Err("source_file_limit_exceeded") + ); + assert_eq!(stats.observe_blob(3, policy), Ok(())); + assert_eq!( + stats.observe_blob(1, policy), + Err("source_file_limit_exceeded") + ); + } + + #[test] + fn managed_tree_policy_rejects_non_portable_components() { + for component in [ + "CON", + "NUL.txt", + "COM1.log", + "LPT9", + "a:b", + "control\u{001f}", + "trailing.", + "trailing ", + ".git.", + ".git ", + "com¹", + "lpt².txt", + ] { + assert!( + !is_supported_source_component(component), + "component must be rejected by portable policy: {component:?}" + ); + } + } + + #[test] + fn managed_tree_policy_uses_full_unicode_casefold_after_nfc() { + let policy = MANAGED_TREE_POLICY_V1; + for (first, second) in [ + ("Σ.txt", "ς.txt"), + ("STRASSE.txt", "Straße.txt"), + ("é.txt", "e\u{301}.txt"), + ] { + let mut stats = ManagedTreeStats::default(); + assert_eq!(stats.observe_entry(first, policy), Ok(())); + assert_eq!( + stats.observe_entry(second, policy), + Err("source_path_collision"), + "paths must collide under the versioned fold key: {first:?}, {second:?}" + ); + } + } + + #[test] + fn managed_tree_policy_bounds_folded_keys_independently() { + let policy = ManagedTreePolicy { + max_relative_path_bytes: 2, + max_folded_relative_path_bytes: 2, + ..MANAGED_TREE_POLICY_V1 + }; + let mut stats = ManagedTreeStats::default(); + assert_eq!( + stats.observe_entry("İ", policy), + Err("source_folded_path_length_exceeded") + ); + + let policy = ManagedTreePolicy { + max_total_path_bytes: 3, + max_total_folded_path_bytes: 3, + ..MANAGED_TREE_POLICY_V1 + }; + let mut stats = ManagedTreeStats::default(); + assert_eq!(stats.observe_entry("İ", policy), Ok(())); + assert_eq!( + stats.observe_entry("A", policy), + Err("source_folded_path_byte_limit_exceeded") + ); + } + + #[test] + fn canonical_tree_order_uses_gits_directory_suffix_rule() { + assert_eq!( + compare_git_tree_entry_names(b"foo", false, b"foo.bar", false), + std::cmp::Ordering::Less + ); + assert_eq!( + compare_git_tree_entry_names(b"foo.bar", false, b"foo", true), + std::cmp::Ordering::Less + ); + assert_eq!( + compare_git_tree_entry_names(b"foo", true, b"foo0", false), + std::cmp::Ordering::Less + ); + assert_eq!( + compare_git_tree_entry_names(b"same", false, b"same", false), + std::cmp::Ordering::Equal + ); + } +} + +fn reject_unsupported_object_format(object_format: String) -> ExitCode { + write_response(&Response::RepositoryRejected { + protocol_version: PROTOCOL_VERSION, + reason: "unsupported_object_format", + object_format, + supported_object_formats: ["sha1"], + }); + ExitCode::from(2) +} + +fn read_request() -> Result { + let mut bytes = Vec::new(); + io::stdin() + .take(MAX_REQUEST_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| "request_read_failed")?; + if bytes.len() as u64 > MAX_REQUEST_BYTES { + return Err("request_too_large"); + } + serde_json::from_slice(&bytes).map_err(|_| "invalid_request") +} + +fn write_response(response: &Response<'_>) { + let encoded = serde_json::to_string(response).expect("closed response shape must serialize"); + println!("{encoded}"); +} diff --git a/native/gitoxide-helper/tests/repository_admission.rs b/native/gitoxide-helper/tests/repository_admission.rs new file mode 100644 index 0000000000..fcfae495d0 --- /dev/null +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -0,0 +1,970 @@ +/* + * 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. + */ + +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, + process::{Child, Command, Output, Stdio}, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; + +const HELPER: &str = env!("CARGO_BIN_EXE_maka-gitoxide-helper"); +const MAX_REPOSITORY_METADATA_BYTES: usize = 1024 * 1024; +static FIXTURE_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +#[test] +fn inspects_a_sha1_repository_without_invoking_system_git() { + let fixture = RepositoryFixture::sha1_with_commit(); + let expected_commit = fixture.git_output(["rev-parse", "HEAD"]); + let expected_tree = fixture.git_output(["rev-parse", "HEAD^{tree}"]); + + let output = invoke_helper(&fixture.root); + + assert!( + output.status.success(), + "helper failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let response: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + response, + serde_json::json!({ + "protocolVersion": 1, + "kind": "repository_inspected", + "objectFormat": "sha1", + "headCommitOid": expected_commit, + "headTreeOid": expected_tree, + }) + ); +} + +#[test] +fn rejects_oversized_repository_metadata_before_opening_or_importing() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("oversized-metadata.git"); + let config_path = fixture.root.join(".git").join("config"); + let mut config = fs::OpenOptions::new() + .append(true) + .open(config_path) + .unwrap(); + config.write_all(b"\n#").unwrap(); + config + .write_all(&vec![b'x'; MAX_REPOSITORY_METADATA_BYTES]) + .unwrap(); + drop(config); + + let inspection = invoke_helper(&fixture.root); + assert_helper_error(&inspection, "repository_metadata_limit_exceeded"); + + let import = invoke_import(&fixture.root, &source_head, &destination); + assert_helper_error(&import, "repository_metadata_limit_exceeded"); + assert!(!destination.exists()); +} + +#[test] +fn rejects_sha256_before_returning_repository_identity() { + let fixture = RepositoryFixture::sha256_unborn(); + + let output = invoke_helper(&fixture.root); + + assert_eq!(output.status.code(), Some(2)); + let response: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + response, + serde_json::json!({ + "protocolVersion": 1, + "kind": "repository_rejected", + "reason": "unsupported_object_format", + "objectFormat": "sha256", + "supportedObjectFormats": ["sha1"], + }) + ); +} + +#[test] +fn rejects_an_unknown_object_format_during_repository_open() { + let fixture = RepositoryFixture::unknown_object_format(); + + let output = invoke_helper(&fixture.root); + + assert_eq!(output.status.code(), Some(2)); + let response: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + response, + serde_json::json!({ + "protocolVersion": 1, + "kind": "repository_rejected", + "reason": "unsupported_object_format", + "objectFormat": "unknown", + "supportedObjectFormats": ["sha1"], + }) + ); +} + +#[test] +fn observes_raw_head_identity_instead_of_replacement_ref_semantics() { + let (fixture, expected_commit, expected_tree) = RepositoryFixture::sha1_with_replacement_ref(); + + let output = invoke_helper(&fixture.root); + + assert!(output.status.success()); + let response: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(response["headCommitOid"], expected_commit); + assert_eq!(response["headTreeOid"], expected_tree); +} + +#[test] +fn rejects_a_head_commit_whose_storage_key_does_not_match_its_bytes() { + let (fixture, claimed_head) = RepositoryFixture::sha1_with_mismatched_head_storage(); + + let inspection = invoke_helper(&fixture.root); + + assert_helper_error(&inspection, "head_commit_identity_mismatch"); + + let destination = fixture.root.join("mismatched-head.git"); + let import = invoke_import(&fixture.root, &claimed_head, &destination); + + assert_helper_error(&import, "source_head_commit_identity_mismatch"); + assert!(!destination.exists()); +} + +#[test] +fn rejects_a_source_tree_whose_storage_key_does_not_match_its_bytes() { + let (fixture, claimed_head) = RepositoryFixture::sha1_with_mismatched_tree_storage(); + let destination = fixture.root.join("mismatched-tree.git"); + + let import = invoke_import(&fixture.root, &claimed_head, &destination); + + assert_helper_error(&import, "source_tree_identity_mismatch"); + assert!(!destination.exists()); +} + +#[test] +fn rejects_a_source_blob_whose_storage_key_does_not_match_its_bytes() { + let (fixture, claimed_head) = RepositoryFixture::sha1_with_mismatched_blob_storage(); + let destination = fixture.root.join("mismatched-blob.git"); + + let import = invoke_import(&fixture.root, &claimed_head, &destination); + + assert_helper_error(&import, "source_blob_identity_mismatch"); + assert!(!destination.exists()); +} + +#[test] +fn rejects_noncanonical_tree_entry_modes_before_claiming_the_destination() { + for mode in ["100600", "100664", "100700", "100777", "040000", "0100644"] { + let (fixture, source_head) = RepositoryFixture::sha1_with_raw_tree(&[RawTreeEntry { + mode, + name: "entry", + object_kind: if mode.ends_with("40000") { + RawObjectKind::Tree + } else { + RawObjectKind::Blob + }, + }]); + let destination = fixture.root.join(format!("noncanonical-{mode}.git")); + + let import = invoke_import(&fixture.root, &source_head, &destination); + + assert_helper_error(&import, "source_tree_noncanonical_mode"); + assert!(!destination.exists()); + } +} + +#[test] +fn rejects_unsorted_tree_entries_before_claiming_the_destination() { + let (fixture, source_head) = RepositoryFixture::sha1_with_raw_tree(&[ + RawTreeEntry { + mode: "100644", + name: "z-last", + object_kind: RawObjectKind::Blob, + }, + RawTreeEntry { + mode: "100644", + name: "a-first", + object_kind: RawObjectKind::Blob, + }, + ]); + let destination = fixture.root.join("unsorted-tree.git"); + + let import = invoke_import(&fixture.root, &source_head, &destination); + + assert_helper_error(&import, "source_tree_not_sorted"); + assert!(!destination.exists()); +} + +#[test] +fn rejects_full_unicode_casefold_collisions_before_claiming_the_destination() { + let (fixture, source_head) = RepositoryFixture::sha1_with_raw_tree(&[ + RawTreeEntry { + mode: "100644", + name: "STRASSE.txt", + object_kind: RawObjectKind::Blob, + }, + RawTreeEntry { + mode: "100644", + name: "Straße.txt", + object_kind: RawObjectKind::Blob, + }, + ]); + let destination = fixture.root.join("unicode-casefold-collision.git"); + + let import = invoke_import(&fixture.root, &source_head, &destination); + + assert_helper_error(&import, "source_path_collision"); + assert!(!destination.exists()); +} + +#[test] +fn rejects_a_nonportable_raw_git_path_before_claiming_the_destination() { + let (fixture, source_head) = RepositoryFixture::sha1_with_raw_tree(&[RawTreeEntry { + mode: "100644", + name: "com¹.txt", + object_kind: RawObjectKind::Blob, + }]); + let destination = fixture.root.join("nonportable-path.git"); + + let import = invoke_import(&fixture.root, &source_head, &destination); + + assert_helper_error(&import, "unsupported_source_path"); + assert!(!destination.exists()); +} + +#[test] +fn imports_an_exact_source_head_into_a_fresh_managed_repository() { + let fixture = RepositoryFixture::sha1_with_commit(); + fs::create_dir_all(fixture.root.join("docs")).unwrap(); + fs::write(fixture.root.join("docs/guide.txt"), b"nested guide\n").unwrap(); + fixture.git(["add", "docs/guide.txt"]); + fixture.git([ + "-c", + "user.name=Maka Test", + "-c", + "user.email=maka@example.invalid", + "commit", + "-m", + "source import fixture", + ]); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let source_tree = fixture.git_output(["rev-parse", "HEAD^{tree}"]); + let destination = fixture.root.join("managed.git"); + + let output = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/baseline", + "managedTreePolicyVersion": 1, + })); + + assert!( + output.status.success(), + "helper failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let response: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(response["kind"], "source_imported"); + assert_eq!(response["sourceHeadCommitOid"], source_head); + assert_eq!(response["sourceTreeOid"], source_tree); + assert_eq!(response["baselineTreeOid"], source_tree); + assert_eq!(response["managedTreePolicyVersion"], 1); + assert_eq!(response["filesImported"], 2); + assert_eq!(response["bytesImported"], 29); + let baseline_commit = response["baselineCommitOid"].as_str().unwrap(); + assert_ne!(baseline_commit, source_head); + assert_eq!( + git_bare_output(&destination, ["rev-parse", "refs/maka/baseline"]), + baseline_commit + ); + assert_eq!( + git_bare_output( + &destination, + ["rev-parse", &format!("{baseline_commit}^{{tree}}")] + ), + source_tree + ); + assert!(!git_bare_succeeds( + &destination, + ["cat-file", "-e", source_head.as_str()] + )); + assert!(!destination.join("objects/info/alternates").exists()); + + let retry = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/baseline", + "managedTreePolicyVersion": 1, + })); + assert_helper_error(&retry, "import_destination_not_fresh"); +} + +#[test] +fn rejects_a_foreign_bare_destination_without_modifying_it() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("foreign.git"); + let initialized = Command::new("git") + .args(["init", "--bare"]) + .arg(&destination) + .output() + .unwrap(); + assert!(initialized.status.success()); + let sentinel = destination.join("hooks/foreign-owner"); + fs::write(&sentinel, b"preserve me\n").unwrap(); + + let output = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/accepted", + "managedTreePolicyVersion": 1, + })); + + assert_helper_error(&output, "import_destination_not_fresh"); + assert_eq!(fs::read(&sentinel).unwrap(), b"preserve me\n"); + assert!(!git_bare_succeeds( + &destination, + ["show-ref", "--verify", "refs/maka/accepted"] + )); +} + +#[test] +fn rejects_a_foreign_non_bare_destination_without_modifying_it() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("foreign-worktree"); + fs::create_dir(&destination).unwrap(); + let sentinel = destination.join("user-data.txt"); + fs::write(&sentinel, b"preserve non-bare content\n").unwrap(); + + let output = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/accepted", + "managedTreePolicyVersion": 1, + })); + + assert_helper_error(&output, "import_destination_not_fresh"); + assert_eq!(fs::read(&sentinel).unwrap(), b"preserve non-bare content\n"); +} + +#[test] +fn rejects_the_source_repository_as_its_own_destination_without_modifying_it() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let sentinel = fixture.root.join("hooks/user-data"); + fs::create_dir_all(sentinel.parent().unwrap()).unwrap(); + fs::write(&sentinel, b"preserve source bytes\n").unwrap(); + + let output = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": fixture.root, + "baselineRef": "refs/maka/baseline", + "managedTreePolicyVersion": 1, + })); + + assert_helper_error(&output, "import_destination_not_fresh"); + assert_eq!(fs::read(&sentinel).unwrap(), b"preserve source bytes\n"); +} + +#[test] +fn rejects_an_unknown_managed_tree_policy_before_creating_the_destination() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("unsupported-policy.git"); + + let output = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/baseline", + "managedTreePolicyVersion": 2, + })); + + assert_helper_error(&output, "unsupported_managed_tree_policy"); + assert!(!destination.exists()); +} + +#[test] +fn rejects_an_invalid_baseline_ref_before_creating_the_destination() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("invalid-ref.git"); + + let output = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/a..b", + "managedTreePolicyVersion": 1, + })); + + assert_helper_error(&output, "invalid_baseline_ref"); + assert!(!destination.exists()); +} + +#[test] +fn exactly_one_process_claims_a_fresh_import_destination() { + let first_source = RepositoryFixture::sha1_with_commit(); + let second_source = RepositoryFixture::sha1_with_commit_content(b"second source\n"); + let first_head = first_source.git_output(["rev-parse", "HEAD"]); + let second_head = second_source.git_output(["rev-parse", "HEAD"]); + let first_blob = first_source.git_output(["rev-parse", "HEAD:hello.txt"]); + let second_blob = second_source.git_output(["rev-parse", "HEAD:hello.txt"]); + let destination = first_source.root.join("contended.git"); + + let first_request = serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": first_source.root, + "expectedSourceHeadCommitOid": first_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/baseline", + "managedTreePolicyVersion": 1, + }); + let second_request = serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": second_source.root, + "expectedSourceHeadCommitOid": second_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/baseline", + "managedTreePolicyVersion": 1, + }); + + let first = spawn_request(first_request); + let second = spawn_request(second_request); + let first_output = first.wait_with_output().unwrap(); + let second_output = second.wait_with_output().unwrap(); + let outputs = [first_output, second_output]; + assert_eq!( + outputs + .iter() + .filter(|output| output.status.success()) + .count(), + 1 + ); + assert_eq!( + outputs + .iter() + .filter(|output| { + serde_json::from_slice::(&output.stdout) + .is_ok_and(|response| response["reason"] == "import_destination_not_fresh") + }) + .count(), + 1 + ); + + let imported = outputs + .iter() + .find(|output| output.status.success()) + .and_then(|output| serde_json::from_slice::(&output.stdout).ok()) + .unwrap(); + let winner_is_first = imported["sourceHeadCommitOid"] == first_head; + let winner_blob = if winner_is_first { + first_blob.as_str() + } else { + second_blob.as_str() + }; + let loser_blob = if winner_is_first { + second_blob.as_str() + } else { + first_blob.as_str() + }; + assert!(git_bare_succeeds( + &destination, + ["cat-file", "-e", winner_blob] + )); + assert!(!git_bare_succeeds( + &destination, + ["cat-file", "-e", loser_blob] + )); +} + +#[test] +fn rejects_an_oversized_commit_before_creating_the_destination() { + let fixture = RepositoryFixture::sha1_with_oversized_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("oversized-commit.git"); + + let output = invoke_import(&fixture.root, &source_head, &destination); + + assert_helper_error(&output, "commit_object_limit_exceeded"); + assert!(!destination.exists()); +} + +#[test] +fn rejects_an_oversized_tree_before_creating_the_destination() { + let fixture = RepositoryFixture::sha1_with_oversized_tree(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let destination = fixture.root.join("oversized-tree.git"); + + let output = invoke_import(&fixture.root, &source_head, &destination); + + assert_helper_error(&output, "source_tree_object_limit_exceeded"); + assert!(!destination.exists()); +} + +#[test] +fn rejects_a_destination_below_an_aliased_parent() { + let fixture = RepositoryFixture::sha1_with_commit(); + let source_head = fixture.git_output(["rev-parse", "HEAD"]); + let owned_parent = fixture.root.join("owned-parent"); + let aliased_parent = fixture.root.join("aliased-parent"); + fs::create_dir(&owned_parent).unwrap(); + create_directory_alias(&owned_parent, &aliased_parent); + let destination = aliased_parent.join("managed.git"); + + let output = invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": fixture.root, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/baseline", + "managedTreePolicyVersion": 1, + })); + + assert_helper_error(&output, "import_destination_parent_untrusted"); + assert!(!owned_parent.join("managed.git").exists()); +} + +fn invoke_helper(repository_path: &Path) -> Output { + invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "inspect_repository", + "repositoryPath": repository_path, + })) +} + +fn invoke_request(request: serde_json::Value) -> Output { + spawn_request(request).wait_with_output().unwrap() +} + +fn spawn_request(request: serde_json::Value) -> Child { + let mut child = Command::new(HELPER) + .env("PATH", "") + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "extensions.objectFormat") + .env("GIT_CONFIG_VALUE_0", "sha256") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(serde_json::to_string(&request).unwrap().as_bytes()) + .unwrap(); + child +} + +fn invoke_import(source: &Path, source_head: &str, destination: &Path) -> Output { + invoke_request(serde_json::json!({ + "protocolVersion": 1, + "operation": "import_source_head", + "sourceRepositoryPath": source, + "expectedSourceHeadCommitOid": source_head, + "destinationRepositoryPath": destination, + "baselineRef": "refs/maka/baseline", + "managedTreePolicyVersion": 1, + })) +} + +fn assert_helper_error(output: &Output, expected_reason: &str) { + assert_eq!(output.status.code(), Some(1)); + let response: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(response["kind"], "helper_error"); + assert_eq!(response["reason"], expected_reason); +} + +#[cfg(unix)] +fn create_directory_alias(target: &Path, alias: &Path) { + std::os::unix::fs::symlink(target, alias).unwrap(); +} + +#[cfg(windows)] +fn create_directory_alias(target: &Path, alias: &Path) { + let output = Command::new("cmd") + .args(["/d", "/s", "/c", "mklink", "/J"]) + .arg(alias) + .arg(target) + .output() + .unwrap(); + assert!( + output.status.success(), + "junction fixture failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn git_bare_output(repository: &Path, args: [&str; N]) -> String { + let output = Command::new("git") + .arg("--git-dir") + .arg(repository) + .args(args) + .output() + .unwrap(); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +fn git_bare_succeeds(repository: &Path, args: [&str; N]) -> bool { + Command::new("git") + .arg("--git-dir") + .arg(repository) + .args(args) + .status() + .unwrap() + .success() +} + +struct RepositoryFixture { + root: PathBuf, +} + +#[derive(Clone, Copy)] +enum RawObjectKind { + Blob, + Tree, +} + +struct RawTreeEntry<'a> { + mode: &'a str, + name: &'a str, + object_kind: RawObjectKind, +} + +impl RepositoryFixture { + fn sha1_with_commit() -> Self { + Self::sha1_with_commit_content(b"hello from sha1\n") + } + + fn sha1_with_commit_content(content: &[u8]) -> Self { + let fixture = Self::init("sha1"); + fs::write(fixture.root.join("hello.txt"), content).unwrap(); + fixture.git(["add", "hello.txt"]); + fixture.git([ + "-c", + "user.name=Maka Test", + "-c", + "user.email=maka@example.invalid", + "commit", + "-m", + "fixture", + ]); + fixture + } + + fn sha1_with_raw_tree(entries: &[RawTreeEntry<'_>]) -> (Self, String) { + let fixture = Self::init("sha1"); + let blob_oid = fixture.git_input_output( + ["hash-object", "-t", "blob", "-w", "--stdin"], + b"raw tree blob\n", + ); + let tree_oid = + fixture.git_input_output(["hash-object", "-t", "tree", "-w", "--stdin"], &[]); + let mut raw_tree = Vec::new(); + for entry in entries { + raw_tree.extend_from_slice(entry.mode.as_bytes()); + raw_tree.push(b' '); + raw_tree.extend_from_slice(entry.name.as_bytes()); + raw_tree.push(0); + let oid = match entry.object_kind { + RawObjectKind::Blob => &blob_oid, + RawObjectKind::Tree => &tree_oid, + }; + raw_tree.extend_from_slice(&decode_hex_oid(oid)); + } + let root_tree_oid = fixture.git_input_output( + ["hash-object", "--literally", "-t", "tree", "-w", "--stdin"], + &raw_tree, + ); + let commit = format!( + "tree {root_tree_oid}\nauthor Maka Test 946684800 +0000\ncommitter Maka Test 946684800 +0000\n\nraw tree fixture\n" + ); + let source_head = fixture.git_input_output( + ["hash-object", "-t", "commit", "-w", "--stdin"], + commit.as_bytes(), + ); + fixture.git(["update-ref", "HEAD", &source_head]); + (fixture, source_head) + } + + fn sha1_with_oversized_commit() -> Self { + let fixture = Self::init("sha1"); + let tree = fixture.git_input_output(["hash-object", "-t", "tree", "-w", "--stdin"], &[]); + let mut commit = format!( + "tree {tree}\nauthor Maka Test 946684800 +0000\ncommitter Maka Test 946684800 +0000\n\n" + ) + .into_bytes(); + commit.resize(1024 * 1024 + 1, b'x'); + let commit_oid = + fixture.git_input_output(["hash-object", "-t", "commit", "-w", "--stdin"], &commit); + fixture.git(["update-ref", "HEAD", &commit_oid]); + fixture + } + + fn sha1_with_oversized_tree() -> Self { + let fixture = Self::init("sha1"); + let invalid_tree = vec![0_u8; 8 * 1024 * 1024 + 1]; + let tree_oid = fixture.git_input_output( + ["hash-object", "--literally", "-t", "tree", "-w", "--stdin"], + &invalid_tree, + ); + let commit = format!( + "tree {tree_oid}\nauthor Maka Test 946684800 +0000\ncommitter Maka Test 946684800 +0000\n\noversized tree\n" + ); + let commit_oid = fixture.git_input_output( + ["hash-object", "-t", "commit", "-w", "--stdin"], + commit.as_bytes(), + ); + fixture.git(["update-ref", "HEAD", &commit_oid]); + fixture + } + + fn sha256_unborn() -> Self { + Self::init("sha256") + } + + fn unknown_object_format() -> Self { + let fixture = Self::init("sha1"); + fixture.git(["config", "core.repositoryFormatVersion", "1"]); + fixture.git(["config", "extensions.objectFormat", "sha512"]); + fixture + } + + fn sha1_with_replacement_ref() -> (Self, String, String) { + let fixture = Self::sha1_with_commit(); + let raw_commit = fixture.git_output(["rev-parse", "HEAD"]); + let raw_tree = fixture.git_output(["rev-parse", "HEAD^{tree}"]); + + fs::write(fixture.root.join("hello.txt"), b"replacement content\n").unwrap(); + fixture.git(["add", "hello.txt"]); + fixture.git([ + "-c", + "user.name=Maka Test", + "-c", + "user.email=maka@example.invalid", + "commit", + "-m", + "replacement", + ]); + let replacement_commit = fixture.git_output(["rev-parse", "HEAD"]); + fixture.git(["replace", &raw_commit, &replacement_commit]); + fixture.git(["checkout", "--detach", &raw_commit]); + + (fixture, raw_commit, raw_tree) + } + + fn sha1_with_mismatched_head_storage() -> (Self, String) { + let fixture = Self::sha1_with_commit_content(b"claimed content\n"); + let claimed_head = fixture.git_output(["rev-parse", "HEAD"]); + + fs::write(fixture.root.join("hello.txt"), b"replacement content\n").unwrap(); + fixture.git(["add", "hello.txt"]); + fixture.git([ + "-c", + "user.name=Maka Test", + "-c", + "user.email=maka@example.invalid", + "commit", + "-m", + "replacement object bytes", + ]); + let replacement_head = fixture.git_output(["rev-parse", "HEAD"]); + fixture.git(["update-ref", "HEAD", &claimed_head]); + + fixture.replace_loose_object(&claimed_head, &replacement_head); + (fixture, claimed_head) + } + + fn sha1_with_mismatched_tree_storage() -> (Self, String) { + let fixture = Self::sha1_with_commit_content(b"claimed tree content\n"); + let claimed_head = fixture.git_output(["rev-parse", "HEAD"]); + let claimed_tree = fixture.git_output(["rev-parse", "HEAD^{tree}"]); + + fs::write( + fixture.root.join("hello.txt"), + b"replacement tree content\n", + ) + .unwrap(); + fixture.git(["add", "hello.txt"]); + fixture.git([ + "-c", + "user.name=Maka Test", + "-c", + "user.email=maka@example.invalid", + "commit", + "-m", + "replacement tree bytes", + ]); + let replacement_tree = fixture.git_output(["rev-parse", "HEAD^{tree}"]); + fixture.git(["update-ref", "HEAD", &claimed_head]); + + fixture.replace_loose_object(&claimed_tree, &replacement_tree); + (fixture, claimed_head) + } + + fn sha1_with_mismatched_blob_storage() -> (Self, String) { + let fixture = Self::sha1_with_commit_content(b"claimed blob content\n"); + let claimed_head = fixture.git_output(["rev-parse", "HEAD"]); + let claimed_blob = fixture.git_output(["rev-parse", "HEAD:hello.txt"]); + + fs::write( + fixture.root.join("hello.txt"), + b"replacement blob content\n", + ) + .unwrap(); + fixture.git(["add", "hello.txt"]); + let replacement_blob = fixture.git_output(["rev-parse", ":hello.txt"]); + fixture.git(["reset", "--hard", &claimed_head]); + + fixture.replace_loose_object(&claimed_blob, &replacement_blob); + (fixture, claimed_head) + } + + fn replace_loose_object(&self, target_oid: &str, replacement_oid: &str) { + let target = self.loose_object_path(target_oid); + fs::remove_file(&target).unwrap(); + fs::copy(self.loose_object_path(replacement_oid), target).unwrap(); + } + + fn loose_object_path(&self, oid: &str) -> PathBuf { + self.root + .join(".git/objects") + .join(&oid[..2]) + .join(&oid[2..]) + } + + fn init(object_format: &str) -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "maka-gitoxide-helper-admission-{}-{nonce}-{}", + std::process::id(), + FIXTURE_SEQUENCE.fetch_add(1, Ordering::Relaxed), + )); + fs::create_dir_all(&root).unwrap(); + let root = canonicalize_fixture_root(root); + let fixture = Self { root }; + fixture.git([ + "init", + "--quiet", + &format!("--object-format={object_format}"), + ]); + fixture + } + + fn git(&self, args: [&str; N]) { + let output = Command::new("git") + .arg("-C") + .arg(&self.root) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "git fixture command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn git_output(&self, args: [&str; N]) -> String { + let output = Command::new("git") + .arg("-C") + .arg(&self.root) + .args(args) + .output() + .unwrap(); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap().trim().to_owned() + } + + fn git_input_output(&self, args: [&str; N], input: &[u8]) -> String { + let mut child = Command::new("git") + .arg("-C") + .arg(&self.root) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(input).unwrap(); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "git fixture command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_owned() + } +} + +fn decode_hex_oid(oid: &str) -> Vec { + oid.as_bytes() + .chunks_exact(2) + .map(|pair| { + let pair = std::str::from_utf8(pair).unwrap(); + u8::from_str_radix(pair, 16).unwrap() + }) + .collect() +} + +#[cfg(unix)] +fn canonicalize_fixture_root(root: PathBuf) -> PathBuf { + fs::canonicalize(root).unwrap() +} + +#[cfg(windows)] +fn canonicalize_fixture_root(root: PathBuf) -> PathBuf { + root +} + +impl Drop for RepositoryFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} diff --git a/package.json b/package.json index 7c5c6c330e..42ce3e8613 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "test": "npm run build:test && node scripts/run-workspace-tests-parallel.mjs --concurrency=3", "test:dist": "node scripts/run-workspace-tests-parallel.mjs --concurrency=3", "test:dist:serial": "node scripts/run-workspace-tests-parallel.mjs --serial", + "test:gitoxide-helper": "cargo +1.98.0 test --locked --manifest-path native/gitoxide-helper/Cargo.toml", "dev": "npm --workspace @maka/desktop run dev:hmr --", "dev:full": "npm run build && npm --workspace @maka/desktop run start", "cli:dev": "node packages/cli/dist/dev-cli.js", diff --git a/packages/runtime-host/src/__tests__/gitoxide-helper-artifact-authority-internal.test.ts b/packages/runtime-host/src/__tests__/gitoxide-helper-artifact-authority-internal.test.ts new file mode 100644 index 0000000000..e7038911c0 --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-helper-artifact-authority-internal.test.ts @@ -0,0 +1,164 @@ +/* + * 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. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + admitGitoxideHelperArtifactInternal, + GitoxideHelperArtifactAuthorityError, + issueGitoxideHelperReleaseArtifactClaimInternal, + type GitoxideHelperReleaseArtifactClaim, + verifyGitoxideHelperArtifactForInvocationInternal, +} from '../server/gitoxide-helper-artifact-authority-internal.js'; + +test('rejects a caller-forged Gitoxide helper release claim', async () => { + const forgedClaim = Object.freeze({ + kind: 'gitoxide_helper_release_artifact_claim_v1', + }) as GitoxideHelperReleaseArtifactClaim; + + await assert.rejects( + admitGitoxideHelperArtifactInternal({ + releaseOwnerToken: {}, + invocationOwnerToken: {}, + claim: forgedClaim, + }), + (error) => + error instanceof GitoxideHelperArtifactAuthorityError && + error.code === 'gitoxide_helper_release_claim_invalid', + ); +}); + +test('rejects a release claim reached through a symbolic link or junction', async (t) => { + const directory = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-helper-artifact-'))); + t.after(() => rm(directory, { recursive: true, force: true })); + const targetDirectory = join(directory, 'target'); + const claimedDirectory = join(directory, 'claimed'); + const targetPath = join(targetDirectory, 'helper'); + const claimedPath = join(claimedDirectory, 'helper'); + const bytes = Buffer.from('trusted helper bytes'); + await mkdir(targetDirectory); + await writeFile(targetPath, bytes); + try { + await symlink( + targetDirectory, + claimedDirectory, + process.platform === 'win32' ? 'junction' : 'dir', + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EPERM') { + t.skip('This Windows host cannot create symbolic links'); + return; + } + throw error; + } + + const releaseOwnerToken = {}; + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath: claimedPath, + expectedSha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + expectedBytes: bytes.length, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + }); + + await assert.rejects( + admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken: {}, + claim, + }), + (error) => + error instanceof GitoxideHelperArtifactAuthorityError && + error.code === 'gitoxide_helper_artifact_invalid', + ); +}); + +test('keeps an admitted helper artifact opaque and bound to its invocation owner', async (t) => { + const directory = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-helper-artifact-'))); + t.after(() => rm(directory, { recursive: true, force: true })); + const executablePath = join(directory, 'helper'); + const bytes = Buffer.from('trusted helper bytes'); + await writeFile(executablePath, bytes); + const releaseOwnerToken = {}; + const invocationOwnerToken = {}; + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath, + expectedSha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + expectedBytes: bytes.length, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + }); + + const capability = await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, + }); + + assert.deepEqual(capability, { kind: 'gitoxide_helper_invocation_capability_v1' }); + await assert.rejects( + verifyGitoxideHelperArtifactForInvocationInternal({}, capability), + (error) => + error instanceof GitoxideHelperArtifactAuthorityError && + error.code === 'gitoxide_helper_invocation_capability_invalid', + ); + assert.equal( + (await verifyGitoxideHelperArtifactForInvocationInternal(invocationOwnerToken, capability)) + .executablePath, + executablePath, + ); +}); + +test('rejects a helper artifact changed after admission', async (t) => { + const directory = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-helper-artifact-'))); + t.after(() => rm(directory, { recursive: true, force: true })); + const executablePath = join(directory, 'helper'); + const bytes = Buffer.from('trusted helper bytes'); + await writeFile(executablePath, bytes); + const releaseOwnerToken = {}; + const invocationOwnerToken = {}; + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath, + expectedSha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + expectedBytes: bytes.length, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + }); + const capability = await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, + }); + + await writeFile(executablePath, Buffer.alloc(bytes.length, 0x78)); + + await assert.rejects( + verifyGitoxideHelperArtifactForInvocationInternal(invocationOwnerToken, capability), + (error) => + error instanceof GitoxideHelperArtifactAuthorityError && + error.code === 'gitoxide_helper_artifact_identity_mismatch', + ); +}); diff --git a/packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts b/packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts new file mode 100644 index 0000000000..c623122fa8 --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-helper-invocation-internal.test.ts @@ -0,0 +1,410 @@ +/* + * 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. + */ + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { chmod, mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test, { type TestContext } from 'node:test'; +import { + admitGitoxideHelperArtifactInternal, + type GitoxideHelperInvocationCapability, + issueGitoxideHelperReleaseArtifactClaimInternal, +} from '../server/gitoxide-helper-artifact-authority-internal.js'; +import { + GITOXIDE_HELPER_ERROR_REASONS_V1, + GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL, + GitoxideHelperInvocationError, + importSourceHeadWithGitoxideHelperInternal, + inspectRepositoryWithGitoxideHelperInternal, +} from '../server/gitoxide-helper-invocation-internal.js'; + +interface AdmittedHelper { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; +} + +let admittedHelperPromise: Promise | undefined; + +test('uses a bounded import deadline distinct from repository inspection', () => { + assert.deepEqual(GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL, { + inspectRepositoryMs: 5_000, + importSourceHeadMs: 10 * 60_000, + }); +}); + +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 })); + const markerPath = join(root, 'processes.txt'); + const publication = new Promise((resolve, reject) => { + setTimeout(() => { + void writeFile(markerPath, '123 456').then(resolve, reject); + }, 1_000); + }); + + assert.deepEqual(await waitForProcessMarker(markerPath, 2_000), [123, 456]); + await publication; +}); + +test('applies the import deadline and terminates the helper process tree', { + skip: process.platform === 'win32', +}, async (t) => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-timeout-'))); + t.after(() => rm(root, { recursive: true, force: true })); + const markerPath = join(root, 'processes.txt'); + const helperPath = join(root, 'hanging-helper'); + await writeFile( + helperPath, + `#!/bin/sh\n/bin/sleep 600 &\nprintf '%s %s' "$$" "$!" > '${escapeSingleQuotedShell(markerPath)}'\nwait\n`, + ); + await chmod(helperPath, 0o755); + const helper = await admitHelperPath(helperPath); + const repositoryPath = await createRepository(t, 'sha1'); + await writeFile(join(repositoryPath, 'hello.txt'), 'timeout fixture\n'); + git(repositoryPath, ['add', 'hello.txt']); + git(repositoryPath, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ]); + const expectedSourceHeadCommitOid = git(repositoryPath, ['rev-parse', 'HEAD']); + + t.mock.timers.enable({ apis: ['setTimeout'] }); + let settled = false; + const operation = importSourceHeadWithGitoxideHelperInternal({ + ...helper, + sourceRepositoryPath: repositoryPath, + expectedSourceHeadCommitOid, + destinationRepositoryPath: join(root, 'destination.git'), + baselineRef: 'refs/maka/baseline', + managedTreePolicyVersion: 1, + }); + void operation.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + const [helperPid, descendantPid] = await waitForProcessMarker(markerPath); + t.after(() => { + for (const pid of [descendantPid, helperPid]) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // The timeout path already reaped the process tree. + } + } + }); + + t.mock.timers.tick(GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL.inspectRepositoryMs); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(settled, false, 'import must not inherit the inspection deadline'); + + t.mock.timers.tick( + GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL.importSourceHeadMs - + GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL.inspectRepositoryMs, + ); + await assert.rejects( + operation, + (error) => + error instanceof GitoxideHelperInvocationError && + error.code === 'gitoxide_helper_invocation_timed_out', + ); + assert.equal(isProcessAlive(helperPid), false); + assert.equal(isProcessAlive(descendantPid), false); +}); + +test('rejects an import response that does not match the requested baseline ref', { + skip: process.platform === 'win32', +}, async (t) => { + await assertMismatchedImportResponseRejected(t, { + baselineRef: 'refs/maka/not-the-requested-ref', + }); +}); + +test('rejects an import response that does not match the requested source HEAD', { + skip: process.platform === 'win32', +}, async (t) => { + await assertMismatchedImportResponseRejected(t, { + sourceHeadCommitOid: 'b'.repeat(40), + }); +}); + +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), + 'utf8', + ); + const contract = /const HELPER_ERROR_REASONS_V1: &\[&str\] = &\[(?[\s\S]*?)\];/.exec( + rustSource, + ); + assert.ok(contract?.groups?.reasons, 'Rust helper error contract is missing'); + const rustReasons = [...contract.groups.reasons.matchAll(/"([a-z0-9_]+)"/g)].map( + (match) => match[1], + ); + assert.deepEqual(rustReasons, [...GITOXIDE_HELPER_ERROR_REASONS_V1]); +}); + +test('observes exact SHA-1 HEAD identity through the admitted helper capability', 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 invocation owner\n'); + git(repositoryPath, ['add', 'hello.txt']); + git(repositoryPath, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ]); + const expectedCommit = git(repositoryPath, ['rev-parse', 'HEAD']); + const expectedTree = git(repositoryPath, ['rev-parse', 'HEAD^{tree}']); + + assert.deepEqual( + await inspectRepositoryWithGitoxideHelperInternal({ + ...helper, + repositoryPath, + }), + { + kind: 'repository_inspected', + protocolVersion: 1, + objectFormat: 'sha1', + headCommitOid: expectedCommit, + headTreeOid: expectedTree, + }, + ); +}); + +test('returns SHA-256 as a policy rejection from the admitted helper', 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, 'sha256'); + + assert.deepEqual( + await inspectRepositoryWithGitoxideHelperInternal({ ...helper, repositoryPath }), + { + kind: 'repository_rejected', + protocolVersion: 1, + reason: 'unsupported_object_format', + objectFormat: 'sha256', + supportedObjectFormats: ['sha1'], + }, + ); +}); + +test('normalizes an unknown object format before crossing the helper protocol', 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'); + git(repositoryPath, ['config', 'core.repositoryFormatVersion', '1']); + git(repositoryPath, ['config', 'extensions.objectFormat', 'SHA512']); + + assert.deepEqual( + await inspectRepositoryWithGitoxideHelperInternal({ ...helper, repositoryPath }), + { + kind: 'repository_rejected', + protocolVersion: 1, + reason: 'unsupported_object_format', + objectFormat: 'unknown', + supportedObjectFormats: ['sha1'], + }, + ); +}); + +test('reports an unborn SHA-1 repository as a stable helper operation failure', 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 assert.rejects( + inspectRepositoryWithGitoxideHelperInternal({ ...helper, repositoryPath }), + (error) => + error instanceof GitoxideHelperInvocationError && + error.code === 'gitoxide_helper_operation_failed' && + error.helperReason === 'head_commit_unavailable', + ); +}); + +async function admittedHelper(): Promise { + if (admittedHelperPromise) return admittedHelperPromise; + admittedHelperPromise = (async () => { + const configuredHelperPath = process.env.MAKA_GITOXIDE_HELPER_PATH; + if (!configuredHelperPath) return undefined; + return admitHelperPath(configuredHelperPath); + })(); + return admittedHelperPromise; +} + +async function assertMismatchedImportResponseRejected( + t: TestContext, + override: { + readonly sourceHeadCommitOid?: string; + readonly baselineRef?: string; + }, +): Promise { + const root = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-correlation-'))); + t.after(() => rm(root, { recursive: true, force: true })); + const repositoryPath = await createRepository(t, 'sha1'); + await writeFile(join(repositoryPath, 'hello.txt'), 'response correlation fixture\n'); + git(repositoryPath, ['add', 'hello.txt']); + git(repositoryPath, [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=maka@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture', + ]); + const expectedSourceHeadCommitOid = git(repositoryPath, ['rev-parse', 'HEAD']); + const sourceTreeOid = git(repositoryPath, ['rev-parse', 'HEAD^{tree}']); + const helperPath = join(root, 'mismatched-response-helper'); + const response = JSON.stringify({ + protocolVersion: 1, + kind: 'source_imported', + objectFormat: 'sha1', + sourceHeadCommitOid: expectedSourceHeadCommitOid, + sourceTreeOid, + baselineCommitOid: 'a'.repeat(40), + baselineTreeOid: sourceTreeOid, + baselineRef: 'refs/maka/expected-ref', + managedTreePolicyVersion: 1, + filesImported: 1, + bytesImported: 29, + ...override, + }); + await writeFile(helperPath, `#!/bin/sh\nprintf '%s\\n' '${response}'\n`); + await chmod(helperPath, 0o755); + const helper = await admitHelperPath(helperPath); + + await assert.rejects( + importSourceHeadWithGitoxideHelperInternal({ + ...helper, + sourceRepositoryPath: repositoryPath, + expectedSourceHeadCommitOid, + destinationRepositoryPath: join(root, 'destination.git'), + baselineRef: 'refs/maka/expected-ref', + managedTreePolicyVersion: 1, + }), + (error) => + error instanceof GitoxideHelperInvocationError && + error.code === 'gitoxide_helper_invocation_protocol_invalid', + ); +} + +async function admitHelperPath(configuredHelperPath: string): Promise { + const helperPath = await realpath(configuredHelperPath); + const helperBytes = await readFile(helperPath); + const helperInfo = await stat(helperPath); + const releaseOwnerToken = {}; + const invocationOwnerToken = {}; + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(releaseOwnerToken, { + executablePath: helperPath, + expectedSha256: `sha256:${createHash('sha256').update(helperBytes).digest('hex')}`, + expectedBytes: helperInfo.size, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + }); + const capability = await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, + }); + return { invocationOwnerToken, capability }; +} + +async function waitForProcessMarker( + path: string, + timeoutMs = 10_000, +): Promise { + const deadline = performance.now() + timeoutMs; + while (performance.now() < deadline) { + try { + const [helperPid, descendantPid, ...extra] = (await readFile(path, 'utf8')) + .trim() + .split(' ') + .map(Number); + if ( + extra.length === 0 && + helperPid !== undefined && + descendantPid !== undefined && + Number.isSafeInteger(helperPid) && + Number.isSafeInteger(descendantPid) + ) { + return [helperPid, descendantPid]; + } + } catch { + // The helper has not published its process identity yet. + } + await new Promise((resolve) => setImmediate(resolve)); + } + throw new Error('Hanging helper did not publish its process identity'); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function escapeSingleQuotedShell(value: string): string { + return value.replaceAll("'", "'\\''"); +} + +async function createRepository(t: TestContext, objectFormat: 'sha1' | 'sha256') { + const repositoryPath = await realpath(await mkdtemp(join(tmpdir(), 'maka-gitoxide-invocation-'))); + t.after(() => rm(repositoryPath, { recursive: true, force: true })); + git(repositoryPath, ['init', '--quiet', `--object-format=${objectFormat}`]); + return repositoryPath; +} + +function git(cwd: string, args: readonly string[]): string { + return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim(); +} diff --git a/packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts b/packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts new file mode 100644 index 0000000000..cf41e8d9bd --- /dev/null +++ b/packages/runtime-host/src/__tests__/gitoxide-repository-admission-authority-internal.test.ts @@ -0,0 +1,292 @@ +/* + * 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. + */ + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test, { type TestContext } from 'node:test'; +import { + admitGitoxideHelperArtifactInternal, + type GitoxideHelperInvocationCapability, + issueGitoxideHelperReleaseArtifactClaimInternal, +} from '../server/gitoxide-helper-artifact-authority-internal.js'; +import { + admitGitoxideRepositoryInternal, + GitoxideRepositoryAdmissionAuthorityError, + importAdmittedGitoxideRepositoryInternal, + requireGitoxideRepositoryAdmissionInternal, +} from '../server/gitoxide-repository-admission-authority-internal.js'; + +interface AdmittedHelper { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly helperArtifactSha256: `sha256:${string}`; +} + +let admittedHelperPromise: Promise | undefined; + +test('issues an opaque owner-bound admission capability from the exact helper observation', 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 admission 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 expectedCommit = git(repositoryPath, ['rev-parse', 'HEAD']); + const expectedTree = git(repositoryPath, ['rev-parse', 'HEAD^{tree}']); + const admissionOwnerToken = {}; + + const result = await admitGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryPath, + }); + + assert.equal(result.kind, 'accepted'); + if (result.kind !== 'accepted') return; + assert.deepEqual(result.capability, { kind: 'gitoxide_repository_admission_capability_v1' }); + assert.throws( + () => requireGitoxideRepositoryAdmissionInternal({}, result.capability), + (error) => + error instanceof GitoxideRepositoryAdmissionAuthorityError && + error.code === 'gitoxide_repository_admission_capability_invalid', + ); + assert.deepEqual( + requireGitoxideRepositoryAdmissionInternal(admissionOwnerToken, result.capability), + { + protocolVersion: 1, + repositoryPath, + objectFormat: 'sha1', + headCommitOid: expectedCommit, + headTreeOid: expectedTree, + helperArtifactSha256: helper.helperArtifactSha256, + managedTreePolicyVersion: 1, + }, + ); +}); + +test('returns a policy rejection without issuing an admission capability', 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, 'sha256'); + + assert.deepEqual( + await admitGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken: {}, + repositoryPath, + }), + { + kind: 'repository_rejected', + protocolVersion: 1, + reason: 'unsupported_object_format', + objectFormat: 'sha256', + supportedObjectFormats: ['sha1'], + }, + ); +}); + +test('imports only the exact repository identity bound to the admission capability', 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 source import 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 expectedCommit = git(repositoryPath, ['rev-parse', 'HEAD']); + const expectedTree = git(repositoryPath, ['rev-parse', 'HEAD^{tree}']); + const admissionOwnerToken = {}; + const admitted = await admitGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryPath, + }); + assert.equal(admitted.kind, 'accepted'); + if (admitted.kind !== 'accepted') return; + const destinationRepositoryPath = join(repositoryPath, 'managed.git'); + + const imported = await importAdmittedGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken, + repositoryCapability: admitted.capability, + 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']), + imported.baselineCommitOid, + ); + await assert.rejects( + importAdmittedGitoxideRepositoryInternal({ + ...helper, + admissionOwnerToken: {}, + repositoryCapability: admitted.capability, + destinationRepositoryPath: join(repositoryPath, 'forged.git'), + baselineRef: 'refs/maka/forged', + }), + (error) => + error instanceof GitoxideRepositoryAdmissionAuthorityError && + error.code === 'gitoxide_repository_admission_capability_invalid', + ); +}); + +test('rejects import through a different helper artifact than repository admission', 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'), 'bind helper identity\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 foreignRoot = await realpath(await mkdtemp(join(tmpdir(), 'maka-foreign-helper-'))); + t.after(() => rm(foreignRoot, { recursive: true, force: true })); + const foreignExecutable = join(foreignRoot, 'foreign-helper.bin'); + const foreignBytes = Buffer.from('not the admitted helper'); + await writeFile(foreignExecutable, foreignBytes); + const foreignReleaseOwnerToken = {}; + const foreignInvocationOwnerToken = {}; + const foreignClaim = issueGitoxideHelperReleaseArtifactClaimInternal(foreignReleaseOwnerToken, { + executablePath: foreignExecutable, + expectedSha256: `sha256:${createHash('sha256').update(foreignBytes).digest('hex')}`, + expectedBytes: foreignBytes.length, + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + }); + const foreignCapability = await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken: foreignReleaseOwnerToken, + invocationOwnerToken: foreignInvocationOwnerToken, + claim: foreignClaim, + }); + + await assert.rejects( + importAdmittedGitoxideRepositoryInternal({ + invocationOwnerToken: foreignInvocationOwnerToken, + helperCapability: foreignCapability, + admissionOwnerToken, + repositoryCapability: admitted.capability, + destinationRepositoryPath: join(repositoryPath, 'foreign-import.git'), + baselineRef: 'refs/maka/baseline', + }), + (error) => + error instanceof GitoxideRepositoryAdmissionAuthorityError && + error.code === 'gitoxide_repository_admission_capability_invalid', + ); +}); + +async function admittedHelper(): Promise { + if (admittedHelperPromise) return admittedHelperPromise; + admittedHelperPromise = (async () => { + 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, + }); + const helperCapability = await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken, + invocationOwnerToken, + claim, + }); + return { invocationOwnerToken, helperCapability, helperArtifactSha256 }; + })(); + return admittedHelperPromise; +} + +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 })); + git(repositoryPath, ['init', '--quiet', `--object-format=${objectFormat}`]); + return repositoryPath; +} + +function git(cwd: string, args: readonly string[]): string { + return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim(); +} + +function gitBare(repositoryPath: string, args: readonly string[]): string { + return execFileSync('git', [`--git-dir=${repositoryPath}`, ...args], { + encoding: 'utf8', + }).trim(); +} diff --git a/packages/runtime-host/src/server/gitoxide-helper-artifact-authority-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-artifact-authority-internal.ts new file mode 100644 index 0000000000..656d3dd537 --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-helper-artifact-authority-internal.ts @@ -0,0 +1,293 @@ +/* + * 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. + */ + +import { createHash } from 'node:crypto'; +import { lstat, open, realpath } from 'node:fs/promises'; +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 interface GitoxideHelperReleaseArtifactClaim { + readonly kind: 'gitoxide_helper_release_artifact_claim_v1'; +} + +export interface GitoxideHelperInvocationCapability { + readonly kind: 'gitoxide_helper_invocation_capability_v1'; +} + +export interface GitoxideHelperReleaseArtifactStateInternal { + readonly executablePath: string; + readonly expectedSha256: `sha256:${string}`; + readonly expectedBytes: number; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly protocolVersion: 1; +} + +export interface VerifiedGitoxideHelperArtifactInternal { + readonly executablePath: string; + readonly protocolVersion: 1; +} + +export interface GitoxideHelperArtifactIdentityInternal { + readonly sha256: `sha256:${string}`; + readonly bytes: number; + readonly protocolVersion: 1; +} + +export type GitoxideHelperArtifactAuthorityErrorCode = + | 'gitoxide_helper_release_claim_invalid' + | 'gitoxide_helper_release_claim_unsupported' + | 'gitoxide_helper_artifact_invalid' + | 'gitoxide_helper_artifact_identity_mismatch' + | 'gitoxide_helper_invocation_capability_invalid'; + +export class GitoxideHelperArtifactAuthorityError extends Error { + constructor( + readonly code: GitoxideHelperArtifactAuthorityErrorCode, + message: string, + ) { + super(message); + this.name = 'GitoxideHelperArtifactAuthorityError'; + } +} + +interface ReleaseClaimRecord extends GitoxideHelperReleaseArtifactStateInternal { + readonly releaseOwnerToken: object; +} + +interface InvocationCapabilityRecord { + readonly invocationOwnerToken: object; + readonly claim: ReleaseClaimRecord; + readonly canonicalExecutablePath: string; +} + +const releaseClaims = new WeakMap(); +const invocationCapabilities = new WeakMap(); + +/** + * Internal seam for the future packaged-release owner. This function is not + * exported from @maka/runtime-host/server and does not establish the platform + * signing trust root by itself. + */ +export function issueGitoxideHelperReleaseArtifactClaimInternal( + releaseOwnerToken: object, + state: GitoxideHelperReleaseArtifactStateInternal, +): GitoxideHelperReleaseArtifactClaim { + assertReleaseArtifactState(state); + const claim = Object.freeze({ + kind: 'gitoxide_helper_release_artifact_claim_v1' as const, + }); + releaseClaims.set(claim, Object.freeze({ ...state, releaseOwnerToken })); + return claim; +} + +export async function admitGitoxideHelperArtifactInternal(input: { + readonly releaseOwnerToken: object; + readonly invocationOwnerToken: object; + readonly claim: GitoxideHelperReleaseArtifactClaim; +}): Promise { + const claim = releaseClaims.get(input.claim); + if (!claim || claim.releaseOwnerToken !== input.releaseOwnerToken) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_release_claim_invalid', + 'Gitoxide helper release artifact claim is invalid for this release owner', + ); + } + if (claim.platform !== process.platform || claim.arch !== process.arch) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_release_claim_unsupported', + `Gitoxide helper release artifact does not support ${process.platform}/${process.arch}`, + ); + } + + const canonicalExecutablePath = await verifyArtifact(claim); + const capability = Object.freeze({ + kind: 'gitoxide_helper_invocation_capability_v1' as const, + }); + invocationCapabilities.set(capability, { + invocationOwnerToken: input.invocationOwnerToken, + claim, + canonicalExecutablePath, + }); + return capability; +} + +export async function verifyGitoxideHelperArtifactForInvocationInternal( + invocationOwnerToken: object, + capability: GitoxideHelperInvocationCapability, +): Promise { + 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 canonicalExecutablePath = await verifyArtifact(record.claim); + if (canonicalExecutablePath !== record.canonicalExecutablePath) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_artifact_identity_mismatch', + 'Gitoxide helper canonical executable path changed after admission', + ); + } + return Object.freeze({ + executablePath: canonicalExecutablePath, + protocolVersion: record.claim.protocolVersion, + }); +} + +export function requireGitoxideHelperArtifactIdentityInternal( + invocationOwnerToken: object, + capability: GitoxideHelperInvocationCapability, +): GitoxideHelperArtifactIdentityInternal { + 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', + ); + } + return Object.freeze({ + sha256: record.claim.expectedSha256, + bytes: record.claim.expectedBytes, + protocolVersion: record.claim.protocolVersion, + }); +} + +function assertReleaseArtifactState(state: GitoxideHelperReleaseArtifactStateInternal): void { + if ( + typeof state.executablePath !== 'string' || + state.executablePath.length === 0 || + !isAbsolute(state.executablePath) || + !SHA256_PATTERN.test(state.expectedSha256) || + !Number.isSafeInteger(state.expectedBytes) || + state.expectedBytes < 1 || + state.expectedBytes > MAX_HELPER_ARTIFACT_BYTES || + typeof state.platform !== 'string' || + state.platform.length === 0 || + typeof state.arch !== 'string' || + state.arch.length === 0 || + state.protocolVersion !== 1 + ) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_release_claim_invalid', + 'Gitoxide helper release artifact state is invalid', + ); + } +} + +async function verifyArtifact(claim: ReleaseClaimRecord): Promise { + let canonicalExecutablePath: string; + let handle; + try { + await assertNoSymbolicLinkComponents(claim.executablePath); + canonicalExecutablePath = await realpath(claim.executablePath); + handle = await open(canonicalExecutablePath, 'r'); + const initialInfo = await handle.stat({ bigint: true }); + if (!initialInfo.isFile() || initialInfo.size !== BigInt(claim.expectedBytes)) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_artifact_identity_mismatch', + 'Gitoxide helper artifact size or file type does not match its release claim', + ); + } + + const digest = createHash('sha256'); + const buffer = Buffer.allocUnsafe(HASH_BUFFER_BYTES); + let position = 0; + while (position < claim.expectedBytes) { + const length = Math.min(buffer.length, claim.expectedBytes - position); + const { bytesRead } = await handle.read(buffer, 0, length, position); + if (bytesRead === 0) break; + digest.update(buffer.subarray(0, bytesRead)); + position += bytesRead; + } + if (position !== claim.expectedBytes) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_artifact_identity_mismatch', + 'Gitoxide helper artifact changed while its identity was verified', + ); + } + const finalHandleInfo = await handle.stat({ bigint: true }); + const finalPathInfo = await lstat(canonicalExecutablePath, { bigint: true }); + if ( + !sameFileSnapshot(initialInfo, finalHandleInfo) || + !sameFileIdentity(finalHandleInfo, finalPathInfo) + ) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_artifact_identity_mismatch', + 'Gitoxide helper artifact changed while its identity was verified', + ); + } + const actualSha256 = `sha256:${digest.digest('hex')}`; + if (actualSha256 !== claim.expectedSha256) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_artifact_identity_mismatch', + 'Gitoxide helper artifact digest does not match its release claim', + ); + } + } catch (error) { + if (error instanceof GitoxideHelperArtifactAuthorityError) throw error; + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_artifact_invalid', + `Gitoxide helper artifact could not be verified: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + await handle?.close().catch(() => undefined); + } + return canonicalExecutablePath; +} + +function sameFileIdentity( + left: Awaited>, + right: Awaited>, +): boolean { + return left.dev === right.dev && left.ino === right.ino && left.size === right.size; +} + +function sameFileSnapshot( + left: Awaited>, + right: Awaited>, +): boolean { + return ( + sameFileIdentity(left, right) && + left.mtimeMs === right.mtimeMs && + left.ctimeMs === right.ctimeMs + ); +} + +async function assertNoSymbolicLinkComponents(path: string): Promise { + const absolutePath = resolve(path); + const root = parse(absolutePath).root; + const segments = relative(root, absolutePath).split(sep).filter(Boolean); + let cursor = root; + for (const segment of segments) { + cursor = join(cursor, segment); + const info = await lstat(cursor); + if (info.isSymbolicLink()) { + throw new GitoxideHelperArtifactAuthorityError( + 'gitoxide_helper_artifact_invalid', + 'Gitoxide helper artifact path must not traverse a symbolic link or junction', + ); + } + } +} diff --git a/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts new file mode 100644 index 0000000000..a056e06246 --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-helper-invocation-internal.ts @@ -0,0 +1,601 @@ +/* + * 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. + */ + +import { spawn } from 'node:child_process'; +import { realpath } from 'node:fs/promises'; +import { dirname, isAbsolute } from 'node:path'; +import { terminateChildProcessTree } from '@maka/runtime/process-tree-terminator'; +import { + type GitoxideHelperInvocationCapability, + verifyGitoxideHelperArtifactForInvocationInternal, +} from './gitoxide-helper-artifact-authority-internal.js'; + +const MAX_REQUEST_BYTES = 64 * 1024; +const MAX_STDOUT_BYTES = 64 * 1024; +const MAX_STDERR_BYTES = 16 * 1024; +export const GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL = Object.freeze({ + inspectRepositoryMs: 5_000, + importSourceHeadMs: 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}$/; +export const GITOXIDE_HELPER_ERROR_REASONS_V1 = Object.freeze([ + 'internal_error_reason_invalid', + 'request_read_failed', + 'request_too_large', + 'invalid_request', + 'unsupported_protocol_version', + 'repository_metadata_limit_exceeded', + 'repository_open_failed', + 'head_commit_unavailable', + 'head_commit_identity_mismatch', + 'head_tree_unavailable', + 'commit_object_limit_exceeded', + 'baseline_commit_write_failed', + 'baseline_publish_failed', + 'baseline_ref_outside_maka_namespace', + 'invalid_baseline_ref', + 'import_destination_create_failed', + 'import_destination_not_fresh', + 'import_destination_object_format_mismatch', + 'import_destination_parent_untrusted', + 'import_destination_unreadable', + 'import_hooks_cleanup_failed', + 'invalid_source_head_commit_oid', + 'source_blob_copy_failed', + 'source_blob_identity_mismatch', + 'source_blob_invalid', + 'source_blob_unavailable', + 'source_byte_limit_exceeded', + 'source_file_limit_exceeded', + 'source_folded_path_byte_limit_exceeded', + 'source_folded_path_length_exceeded', + 'source_head_commit_mismatch', + 'source_head_commit_identity_mismatch', + 'source_head_commit_unavailable', + 'source_head_tree_unavailable', + 'source_path_collision', + 'source_path_byte_limit_exceeded', + 'source_path_length_exceeded', + 'source_tree_copy_failed', + 'source_tree_depth_exceeded', + 'source_tree_entry_limit_exceeded', + 'source_tree_identity_mismatch', + 'source_tree_invalid', + 'source_tree_object_byte_limit_exceeded', + 'source_tree_object_limit_exceeded', + 'source_tree_noncanonical_mode', + 'source_tree_not_sorted', + 'source_tree_observation_mismatch', + 'source_tree_unavailable', + 'source_tree_visit_limit_exceeded', + 'unsupported_source_entry_kind', + 'unsupported_source_path', + 'unsupported_object_format', + 'unsupported_managed_tree_policy', +] as const); +const HELPER_ERROR_REASONS = new Set(GITOXIDE_HELPER_ERROR_REASONS_V1); + +export interface GitoxideRepositoryObservationV1 { + readonly kind: 'repository_inspected'; + readonly protocolVersion: 1; + readonly objectFormat: 'sha1'; + readonly headCommitOid: string; + readonly headTreeOid: string; +} + +export interface GitoxideRepositoryRejectionV1 { + readonly kind: 'repository_rejected'; + readonly protocolVersion: 1; + readonly reason: 'unsupported_object_format'; + readonly objectFormat: 'sha256' | 'unknown'; + readonly supportedObjectFormats: readonly ['sha1']; +} + +export type GitoxideRepositoryInspectionResultV1 = + | GitoxideRepositoryObservationV1 + | GitoxideRepositoryRejectionV1; + +export interface GitoxideSourceImportObservationV1 { + readonly kind: 'source_imported'; + readonly protocolVersion: 1; + readonly objectFormat: 'sha1'; + readonly sourceHeadCommitOid: string; + readonly sourceTreeOid: string; + readonly baselineCommitOid: string; + readonly baselineTreeOid: string; + readonly baselineRef: string; + readonly managedTreePolicyVersion: 1; + readonly filesImported: number; + readonly bytesImported: number; +} + +export type GitoxideHelperInvocationErrorCode = + | 'gitoxide_helper_invocation_invalid' + | 'gitoxide_helper_invocation_spawn_failed' + | 'gitoxide_helper_invocation_timed_out' + | 'gitoxide_helper_invocation_aborted' + | 'gitoxide_helper_invocation_output_too_large' + | 'gitoxide_helper_invocation_protocol_invalid' + | 'gitoxide_helper_operation_failed'; + +export class GitoxideHelperInvocationError extends Error { + constructor( + readonly code: GitoxideHelperInvocationErrorCode, + message: string, + readonly helperReason?: string, + ) { + super(message); + this.name = 'GitoxideHelperInvocationError'; + } +} + +export async function inspectRepositoryWithGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; + readonly repositoryPath: string; + readonly abortSignal?: AbortSignal; +}): Promise { + throwIfAborted(input.abortSignal); + if (!isAbsolute(input.repositoryPath)) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + 'Gitoxide repository path must be absolute', + ); + } + const [artifact, repositoryPath] = await Promise.all([ + verifyGitoxideHelperArtifactForInvocationInternal(input.invocationOwnerToken, input.capability), + realpath(input.repositoryPath).catch((error) => { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + `Gitoxide repository path could not be resolved: ${error instanceof Error ? error.message : String(error)}`, + ); + }), + ]); + throwIfAborted(input.abortSignal); + + const request = Buffer.from( + JSON.stringify({ + protocolVersion: artifact.protocolVersion, + operation: 'inspect_repository', + repositoryPath, + }), + ); + 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, + timeoutMs: GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL.inspectRepositoryMs, + }); + return decodeOutcome(outcome); +} + +export async function importSourceHeadWithGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; + readonly capability: GitoxideHelperInvocationCapability; + readonly sourceRepositoryPath: string; + readonly expectedSourceHeadCommitOid: string; + readonly destinationRepositoryPath: string; + readonly baselineRef: string; + readonly managedTreePolicyVersion: 1; + readonly abortSignal?: AbortSignal; +}): Promise { + throwIfAborted(input.abortSignal); + if ( + !isAbsolute(input.sourceRepositoryPath) || + !isAbsolute(input.destinationRepositoryPath) || + !SHA1_OID_PATTERN.test(input.expectedSourceHeadCommitOid) || + !MAKA_REF_PATTERN.test(input.baselineRef) + ) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + 'Gitoxide source import request is invalid', + ); + } + const [artifact, sourceRepositoryPath] = await Promise.all([ + verifyGitoxideHelperArtifactForInvocationInternal(input.invocationOwnerToken, input.capability), + realpath(input.sourceRepositoryPath).catch((error) => { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_invalid', + `Gitoxide source repository path could not be resolved: ${error instanceof Error ? error.message : String(error)}`, + ); + }), + ]); + throwIfAborted(input.abortSignal); + const request = Buffer.from( + JSON.stringify({ + protocolVersion: artifact.protocolVersion, + operation: 'import_source_head', + sourceRepositoryPath, + expectedSourceHeadCommitOid: input.expectedSourceHeadCommitOid, + destinationRepositoryPath: input.destinationRepositoryPath, + baselineRef: input.baselineRef, + 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, + timeoutMs: GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL.importSourceHeadMs, + }); + return decodeSourceImportOutcome(outcome, { + expectedSourceHeadCommitOid: input.expectedSourceHeadCommitOid, + baselineRef: input.baselineRef, + managedTreePolicyVersion: input.managedTreePolicyVersion, + }); +} + +interface HelperProcessOutcome { + readonly exitCode: number | null; + readonly signal: NodeJS.Signals | null; + readonly stdout: Buffer; + readonly stderr: Buffer; +} + +function invokeHelper(input: { + readonly executablePath: string; + readonly request: Buffer; + readonly abortSignal?: AbortSignal; + readonly timeoutMs: number; +}): Promise { + return new Promise((resolve, reject) => { + let child: ReturnType; + try { + child = spawn(input.executablePath, [], { + cwd: dirname(input.executablePath), + env: helperEnvironment(), + shell: false, + windowsHide: true, + detached: process.platform !== 'win32', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (error) { + reject( + new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_spawn_failed', + `Gitoxide helper could not be started: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + return; + } + + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let settled = false; + let termination: + | 'gitoxide_helper_invocation_timed_out' + | 'gitoxide_helper_invocation_aborted' + | 'gitoxide_helper_invocation_output_too_large' + | undefined; + let processFailure: GitoxideHelperInvocationError | undefined; + const timeout = setTimeout( + () => terminate('gitoxide_helper_invocation_timed_out'), + input.timeoutMs, + ); + const abort = () => terminate('gitoxide_helper_invocation_aborted'); + input.abortSignal?.addEventListener('abort', abort, { once: true }); + if (input.abortSignal?.aborted) abort(); + + child.stdout!.on('data', (chunk: Buffer) => { + if (settled) return; + stdoutBytes += chunk.length; + if (stdoutBytes > MAX_STDOUT_BYTES) { + terminate('gitoxide_helper_invocation_output_too_large'); + return; + } + stdout.push(chunk); + }); + child.stderr!.on('data', (chunk: Buffer) => { + if (settled) return; + stderrBytes += chunk.length; + if (stderrBytes > MAX_STDERR_BYTES) { + terminate('gitoxide_helper_invocation_output_too_large'); + return; + } + stderr.push(chunk); + }); + child.once('error', (error) => { + finishReject( + new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_spawn_failed', + `Gitoxide helper process failed: ${error.message}`, + ), + ); + }); + child.once('close', (exitCode, signal) => { + if (processFailure) { + finishReject(processFailure); + return; + } + if (termination) { + finishReject( + new GitoxideHelperInvocationError(termination, terminationMessage(termination)), + ); + return; + } + finishResolve({ + exitCode, + signal, + stdout: Buffer.concat(stdout, stdoutBytes), + stderr: Buffer.concat(stderr, stderrBytes), + }); + }); + child.stdin!.on('error', (error) => { + if (settled || processFailure) return; + processFailure = new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_spawn_failed', + `Gitoxide helper request could not be written: ${error.message}`, + ); + void terminateChildProcessTree(child, 'SIGKILL'); + }); + child.stdin!.end(input.request); + + function terminate(reason: NonNullable): void { + if (settled || termination) return; + termination = reason; + void terminateChildProcessTree(child, 'SIGKILL'); + } + + function finishResolve(outcome: HelperProcessOutcome): void { + if (settled) return; + settled = true; + cleanup(); + resolve(outcome); + } + + function finishReject(error: GitoxideHelperInvocationError): void { + if (settled) return; + settled = true; + cleanup(); + reject(error); + } + + function cleanup(): void { + clearTimeout(timeout); + input.abortSignal?.removeEventListener('abort', abort); + } + }); +} + +function decodeOutcome(outcome: HelperProcessOutcome): GitoxideRepositoryInspectionResultV1 { + 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 && isRepositoryObservation(value)) return Object.freeze(value); + if (outcome.exitCode === 2 && isRepositoryRejection(value)) { + return Object.freeze({ ...value, supportedObjectFormats: Object.freeze(['sha1'] as const) }); + } + if (outcome.exitCode === 1 && isHelperError(value)) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_operation_failed', + `Gitoxide helper could not inspect the repository: ${value.reason}`, + value.reason, + ); + } + const stderr = outcome.stderr.toString('utf8').trim(); + throw protocolInvalid( + `Gitoxide helper exit code and response disagree${stderr ? `: ${stderr}` : ''}`, + ); +} + +function decodeSourceImportOutcome( + outcome: HelperProcessOutcome, + expected: { + readonly expectedSourceHeadCommitOid: string; + readonly baselineRef: string; + readonly managedTreePolicyVersion: 1; + }, +): GitoxideSourceImportObservationV1 { + 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 && isSourceImportObservation(value, expected)) { + return Object.freeze(value); + } + if (outcome.exitCode === 1 && isHelperError(value)) { + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_operation_failed', + `Gitoxide helper could not import the source repository: ${value.reason}`, + value.reason, + ); + } + const stderr = outcome.stderr.toString('utf8').trim(); + throw protocolInvalid( + `Gitoxide helper exit code and response disagree${stderr ? `: ${stderr}` : ''}`, + ); +} + +function isSourceImportObservation( + value: unknown, + expected: { + readonly expectedSourceHeadCommitOid: string; + readonly baselineRef: string; + readonly managedTreePolicyVersion: 1; + }, +): value is GitoxideSourceImportObservationV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'objectFormat', + 'sourceHeadCommitOid', + 'sourceTreeOid', + 'baselineCommitOid', + 'baselineTreeOid', + 'baselineRef', + 'managedTreePolicyVersion', + 'filesImported', + 'bytesImported', + ]) && + value.protocolVersion === 1 && + value.kind === 'source_imported' && + value.objectFormat === 'sha1' && + typeof value.sourceHeadCommitOid === 'string' && + SHA1_OID_PATTERN.test(value.sourceHeadCommitOid) && + value.sourceHeadCommitOid === expected.expectedSourceHeadCommitOid && + typeof value.sourceTreeOid === 'string' && + SHA1_OID_PATTERN.test(value.sourceTreeOid) && + typeof value.baselineCommitOid === 'string' && + SHA1_OID_PATTERN.test(value.baselineCommitOid) && + typeof value.baselineTreeOid === 'string' && + SHA1_OID_PATTERN.test(value.baselineTreeOid) && + value.baselineTreeOid === value.sourceTreeOid && + typeof value.baselineRef === 'string' && + MAKA_REF_PATTERN.test(value.baselineRef) && + value.baselineRef === expected.baselineRef && + value.managedTreePolicyVersion === expected.managedTreePolicyVersion && + Number.isSafeInteger(value.filesImported) && + (value.filesImported as number) >= 0 && + Number.isSafeInteger(value.bytesImported) && + (value.bytesImported as number) >= 0 + ); +} + +function isRepositoryObservation(value: unknown): value is GitoxideRepositoryObservationV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'objectFormat', + 'headCommitOid', + 'headTreeOid', + ]) && + value.protocolVersion === 1 && + value.kind === 'repository_inspected' && + value.objectFormat === 'sha1' && + typeof value.headCommitOid === 'string' && + SHA1_OID_PATTERN.test(value.headCommitOid) && + typeof value.headTreeOid === 'string' && + SHA1_OID_PATTERN.test(value.headTreeOid) + ); +} + +function isRepositoryRejection(value: unknown): value is GitoxideRepositoryRejectionV1 { + return ( + hasExactKeys(value, [ + 'protocolVersion', + 'kind', + 'reason', + 'objectFormat', + 'supportedObjectFormats', + ]) && + value.protocolVersion === 1 && + value.kind === 'repository_rejected' && + value.reason === 'unsupported_object_format' && + (value.objectFormat === 'sha256' || value.objectFormat === 'unknown') && + Array.isArray(value.supportedObjectFormats) && + value.supportedObjectFormats.length === 1 && + value.supportedObjectFormats[0] === 'sha1' + ); +} + +function isHelperError(value: unknown): value is { + readonly protocolVersion: 1; + readonly kind: 'helper_error'; + readonly reason: string; +} { + return ( + hasExactKeys(value, ['protocolVersion', 'kind', 'reason']) && + value.protocolVersion === 1 && + value.kind === 'helper_error' && + typeof value.reason === 'string' && + HELPER_ERROR_REASONS.has(value.reason) + ); +} + +function hasExactKeys( + value: unknown, + expectedKeys: readonly string[], +): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const keys = Object.keys(value).sort(); + const expected = [...expectedKeys].sort(); + return keys.length === expected.length && keys.every((key, index) => key === expected[index]); +} + +function helperEnvironment(): NodeJS.ProcessEnv { + return { + PATH: '', + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + ...(process.env.WINDIR ? { WINDIR: process.env.WINDIR } : {}), + ...(process.env.TMP ? { TMP: process.env.TMP } : {}), + ...(process.env.TEMP ? { TEMP: process.env.TEMP } : {}), + ...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}), + }; +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (!signal?.aborted) return; + throw new GitoxideHelperInvocationError( + 'gitoxide_helper_invocation_aborted', + 'Gitoxide helper invocation was aborted', + ); +} + +function terminationMessage( + code: + | 'gitoxide_helper_invocation_timed_out' + | 'gitoxide_helper_invocation_aborted' + | 'gitoxide_helper_invocation_output_too_large', +): string { + if (code === 'gitoxide_helper_invocation_timed_out') + return 'Gitoxide helper invocation timed out'; + if (code === 'gitoxide_helper_invocation_aborted') + return 'Gitoxide helper invocation was aborted'; + return 'Gitoxide helper output exceeded its byte limit'; +} + +function protocolInvalid(message: string): GitoxideHelperInvocationError { + return new GitoxideHelperInvocationError('gitoxide_helper_invocation_protocol_invalid', message); +} diff --git a/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts new file mode 100644 index 0000000000..14945ddf9e --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-repository-admission-authority-internal.ts @@ -0,0 +1,183 @@ +/* + * 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. + */ + +import { realpath } from 'node:fs/promises'; +import { + type GitoxideHelperArtifactIdentityInternal, + type GitoxideHelperInvocationCapability, + requireGitoxideHelperArtifactIdentityInternal, +} from './gitoxide-helper-artifact-authority-internal.js'; +import { + importSourceHeadWithGitoxideHelperInternal, + inspectRepositoryWithGitoxideHelperInternal, + type GitoxideSourceImportObservationV1, + type GitoxideRepositoryRejectionV1, +} from './gitoxide-helper-invocation-internal.js'; + +export interface GitoxideRepositoryAdmissionCapability { + readonly kind: 'gitoxide_repository_admission_capability_v1'; +} + +export interface GitoxideRepositoryAdmissionStateInternal { + readonly protocolVersion: 1; + readonly repositoryPath: string; + readonly objectFormat: 'sha1'; + readonly headCommitOid: string; + readonly headTreeOid: string; + readonly helperArtifactSha256: `sha256:${string}`; + readonly managedTreePolicyVersion: 1; +} + +const MANAGED_TREE_POLICY_VERSION = 1 as const; + +export type GitoxideRepositoryAdmissionResultV1 = + | { + readonly kind: 'accepted'; + readonly capability: GitoxideRepositoryAdmissionCapability; + } + | GitoxideRepositoryRejectionV1; + +export class GitoxideRepositoryAdmissionAuthorityError extends Error { + constructor(readonly code: 'gitoxide_repository_admission_capability_invalid') { + super('Gitoxide repository admission capability is invalid'); + this.name = 'GitoxideRepositoryAdmissionAuthorityError'; + } +} + +interface AdmissionCapabilityRecord { + readonly admissionOwnerToken: object; + readonly helperArtifactIdentity: GitoxideHelperArtifactIdentityInternal; + readonly state: GitoxideRepositoryAdmissionStateInternal; +} + +const admissions = new WeakMap(); + +export async function admitGitoxideRepositoryInternal(input: { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly admissionOwnerToken: object; + readonly repositoryPath: string; + readonly abortSignal?: AbortSignal; +}): Promise { + const repositoryPath = await realpath(input.repositoryPath); + const helperArtifactIdentity = requireGitoxideHelperArtifactIdentityInternal( + input.invocationOwnerToken, + input.helperCapability, + ); + const observation = await inspectRepositoryWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + repositoryPath, + abortSignal: input.abortSignal, + }); + if (observation.kind === 'repository_rejected') return observation; + + const capability = Object.freeze({ + kind: 'gitoxide_repository_admission_capability_v1' as const, + }); + admissions.set( + capability, + Object.freeze({ + admissionOwnerToken: input.admissionOwnerToken, + helperArtifactIdentity, + state: Object.freeze({ + protocolVersion: observation.protocolVersion, + repositoryPath, + objectFormat: observation.objectFormat, + headCommitOid: observation.headCommitOid, + headTreeOid: observation.headTreeOid, + helperArtifactSha256: helperArtifactIdentity.sha256, + managedTreePolicyVersion: MANAGED_TREE_POLICY_VERSION, + }), + }), + ); + return Object.freeze({ kind: 'accepted' as const, capability }); +} + +export function requireGitoxideRepositoryAdmissionInternal( + admissionOwnerToken: object, + capability: GitoxideRepositoryAdmissionCapability, +): GitoxideRepositoryAdmissionStateInternal { + return requireAdmissionRecord(admissionOwnerToken, capability).state; +} + +export async function importAdmittedGitoxideRepositoryInternal(input: { + readonly invocationOwnerToken: object; + readonly helperCapability: GitoxideHelperInvocationCapability; + readonly admissionOwnerToken: object; + readonly repositoryCapability: GitoxideRepositoryAdmissionCapability; + readonly destinationRepositoryPath: string; + readonly baselineRef: string; + readonly abortSignal?: AbortSignal; +}): Promise { + const admission = requireAdmissionRecord(input.admissionOwnerToken, input.repositoryCapability); + const source = admission.state; + const helperArtifactIdentity = requireGitoxideHelperArtifactIdentityInternal( + input.invocationOwnerToken, + input.helperCapability, + ); + if (!sameHelperArtifactIdentity(helperArtifactIdentity, admission.helperArtifactIdentity)) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } + const result = await importSourceHeadWithGitoxideHelperInternal({ + invocationOwnerToken: input.invocationOwnerToken, + capability: input.helperCapability, + sourceRepositoryPath: source.repositoryPath, + expectedSourceHeadCommitOid: source.headCommitOid, + destinationRepositoryPath: input.destinationRepositoryPath, + baselineRef: input.baselineRef, + managedTreePolicyVersion: source.managedTreePolicyVersion, + abortSignal: input.abortSignal, + }); + if ( + result.sourceHeadCommitOid !== source.headCommitOid || + result.sourceTreeOid !== source.headTreeOid + ) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } + return result; +} + +function requireAdmissionRecord( + admissionOwnerToken: object, + capability: GitoxideRepositoryAdmissionCapability, +): AdmissionCapabilityRecord { + const record = admissions.get(capability); + if (!record || record.admissionOwnerToken !== admissionOwnerToken) { + throw new GitoxideRepositoryAdmissionAuthorityError( + 'gitoxide_repository_admission_capability_invalid', + ); + } + return record; +} + +function sameHelperArtifactIdentity( + left: GitoxideHelperArtifactIdentityInternal, + right: GitoxideHelperArtifactIdentityInternal, +): boolean { + return ( + left.sha256 === right.sha256 && + left.bytes === right.bytes && + left.protocolVersion === right.protocolVersion + ); +} diff --git a/scripts/asf-license-headers.mjs b/scripts/asf-license-headers.mjs index 5ce7e6b81d..7ccbc0dbbf 100644 --- a/scripts/asf-license-headers.mjs +++ b/scripts/asf-license-headers.mjs @@ -219,6 +219,7 @@ export const exclusionRules = [ 'docs/astryx-surface-file-inventory.md', 'docs/astryx-surface-file-inventory.paths', 'docs/windows-test-inventory.md', + 'native/gitoxide-helper/Cargo.lock', 'packages/core/src/model-metadata.generated.ts', 'packages/runtime/src/bundled-skill-catalog.generated.ts', 'packages/runtime/src/telemetry/model-pricing.generated.ts', diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 5324bebe6d..6520aece84 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -341,6 +341,7 @@ test('pull request triggers stay on an explicit allowlist', () => { 'ci.yml', 'copilot-auto-review.yml', 'dependency-audit.yml', + 'gitoxide-helper-admission.yml', 'release-windows-check.yml', 'windows-sandbox-w0.yml', ]); @@ -363,6 +364,19 @@ test('the packaged Windows gate owns Runtime Host candidate election changes', ( assert.match(workflow, /'packages\/runtime-host\/src\/client\/launcher\.ts'/u); }); +test('the Gitoxide gate owns repository admission changes', () => { + const workflow = readWorkflow('gitoxide-helper-admission.yml'); + + assert.match( + workflow, + /'packages\/runtime-host\/src\/server\/gitoxide-repository-admission-authority-internal\.ts'/u, + ); + assert.match( + workflow, + /'packages\/runtime-host\/src\/__tests__\/gitoxide-repository-admission-authority-internal\.test\.ts'/u, + ); +}); + test('specialized platform workflows stay reachable without pull requests', () => { const cli = readWorkflow('cli-package-validation.yml'); const baseline = readWorkflow('windows-baseline.yml');