diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml new file mode 100644 index 0000000000..d3f8ac570f --- /dev/null +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -0,0 +1,57 @@ +# 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/**' + push: + branches: + - main + paths: + - '.github/workflows/gitoxide-helper-admission.yml' + - 'native/gitoxide-helper/**' + +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@v4 + - 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 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..49bc1045e5 --- /dev/null +++ b/docs/architecture/gitoxide-helper-artifact-authority-v1.zh-CN.md @@ -0,0 +1,115 @@ + + +# 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 当作自证信任根。 + +## 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 放进由正式安装器保护的只读目录; +- 它尚未拥有 spawn,因此不声称消除了“最后一次 path 校验完成后、未来 spawn 开始前”的替换窗口。 + +下一切片在接入 spawn 前,必须由正式 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; +3. repository observation 再转换为 T1 前的 opaque admission capability。 + +在第 1 项完成以前,不接 Desktop/CLI,也不恢复旧 Git CLI adapter。 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..9af0a37afa --- /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 与未知格式稳定 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 | +| 配置边界 | `gix::open::Options::isolated()` + `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 adapter 必须先 +验证 helper binary/release identity、绑定 invocation input,并把 observation 转换为 owner-issued +opaque capability;不能让 caller 直接提交裸 OID 或 object format。 + +## 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/native/gitoxide-helper/Cargo.lock b/native/gitoxide-helper/Cargo.lock new file mode 100644 index 0000000000..b37203abb7 --- /dev/null +++ b/native/gitoxide-helper/Cargo.lock @@ -0,0 +1,1431 @@ +# 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 = "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 = [ + "gix", + "serde", + "serde_json", +] + +[[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..3c1bc18359 --- /dev/null +++ b/native/gitoxide-helper/Cargo.toml @@ -0,0 +1,33 @@ +# 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] +gix = { version = "=0.86.0", default-features = false, features = ["sha1", "sha256"] } +serde = { version = "1", features = ["derive"] } +serde_json = "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..33d99bb6de --- /dev/null +++ b/native/gitoxide-helper/src/main.rs @@ -0,0 +1,156 @@ +/* + * 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::{ + io::{self, Read}, + path::PathBuf, + process::ExitCode, +}; + +use serde::{Deserialize, Serialize}; + +const PROTOCOL_VERSION: u8 = 1; +const MAX_REQUEST_BYTES: u64 = 64 * 1024; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct InspectRepositoryRequest { + protocol_version: u8, + operation: String, + repository_path: PathBuf, +} + +#[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")] + HelperError { + protocol_version: u8, + reason: &'a str, + }, +} + +fn main() -> ExitCode { + match run() { + Ok(code) => code, + Err(reason) => { + write_response(&Response::HelperError { + protocol_version: PROTOCOL_VERSION, + reason, + }); + ExitCode::from(1) + } + } +} + +fn run() -> Result { + let request = read_request()?; + if request.protocol_version != PROTOCOL_VERSION { + return Err("unsupported_protocol_version"); + } + if request.operation != "inspect_repository" { + return Err("unsupported_operation"); + } + + let repository = match gix::open::Options::isolated() + .strict_config(true) + .open(request.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" => + { + let object_format = error + .value + .as_ref() + .map(|value| String::from_utf8_lossy(value.as_slice()).into_owned()) + .unwrap_or_else(|| "unknown".to_owned()); + return Ok(reject_unsupported_object_format(object_format)); + } + Err(gix::open::Error::Config(gix::config::Error::UnsupportedObjectFormat { name })) => { + let object_format = String::from_utf8_lossy(name.as_slice()).into_owned(); + return Ok(reject_unsupported_object_format(object_format)); + } + Err(_) => return Err("repository_open_failed"), + }; + + match repository.object_hash() { + gix::hash::Kind::Sha1 => { + let head = repository + .head_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 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..a5064a8153 --- /dev/null +++ b/native/gitoxide-helper/tests/repository_admission.rs @@ -0,0 +1,237 @@ +/* + * 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::{Command, Output, Stdio}, + time::{SystemTime, UNIX_EPOCH}, +}; + +const HELPER: &str = env!("CARGO_BIN_EXE_maka-gitoxide-helper"); + +#[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_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": "sha512", + "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); +} + +fn invoke_helper(repository_path: &Path) -> Output { + 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(); + let request = serde_json::json!({ + "protocolVersion": 1, + "operation": "inspect_repository", + "repositoryPath": repository_path, + }); + child + .stdin + .take() + .unwrap() + .write_all(serde_json::to_string(&request).unwrap().as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() +} + +struct RepositoryFixture { + root: PathBuf, +} + +impl RepositoryFixture { + fn sha1_with_commit() -> Self { + let fixture = Self::init("sha1"); + fs::write(fixture.root.join("hello.txt"), b"hello from sha1\n").unwrap(); + fixture.git(["add", "hello.txt"]); + fixture.git([ + "-c", + "user.name=Maka Test", + "-c", + "user.email=maka@example.invalid", + "commit", + "-m", + "fixture", + ]); + 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 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() + )); + fs::create_dir_all(&root).unwrap(); + 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() + } +} + +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/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..882da884b0 --- /dev/null +++ b/packages/runtime-host/src/server/gitoxide-helper-artifact-authority-internal.ts @@ -0,0 +1,269 @@ +/* + * 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 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, + }); +} + +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/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',