diff --git a/.github/workflows/gitoxide-helper-admission.yml b/.github/workflows/gitoxide-helper-admission.yml index deb4861724..a41fa6a7ec 100644 --- a/.github/workflows/gitoxide-helper-admission.yml +++ b/.github/workflows/gitoxide-helper-admission.yml @@ -24,6 +24,11 @@ on: - '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/packaged-gitoxide-helper-internal.ts' + - 'packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts' + - 'scripts/prepare-gitoxide-helper*' + - 'scripts/generate-gitoxide-cargo-notices.mjs' + - 'apps/desktop/electron-builder.config.mjs' - 'docs/architecture/gitoxide-*.md' push: branches: @@ -33,6 +38,11 @@ on: - '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/packaged-gitoxide-helper-internal.ts' + - 'packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts' + - 'scripts/prepare-gitoxide-helper*' + - 'scripts/generate-gitoxide-cargo-notices.mjs' + - 'apps/desktop/electron-builder.config.mjs' - 'docs/architecture/gitoxide-*.md' permissions: @@ -65,8 +75,15 @@ jobs: - name: Test the short-lived Gitoxide helper working-directory: native/gitoxide-helper run: cargo test --locked + - name: Build the release helper + run: npm run build:gitoxide-helper - name: Install JavaScript dependencies without packaging hooks run: npm ci --ignore-scripts + - name: Prepare and license the packaged helper + run: >- + npm run prepare:gitoxide-helper && + npm run generate:gitoxide-cargo-notices && + node --test scripts/prepare-gitoxide-helper.test.mjs - name: Build the helper invocation owner run: >- npm --workspace @maka/core run build && diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 321216a77a..7f439ce9b9 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -66,6 +66,18 @@ export default { 'dist/renderer/computer-use-overlay/**', ], extraResources: [ + { + from: '.generated/gitoxide-helper/gitoxide', + to: 'gitoxide', + }, + { + from: '.generated/gitoxide-helper/gitoxide-helper.json', + to: 'gitoxide-helper.json', + }, + { + from: '.generated/gitoxide-helper/THIRD_PARTY_NOTICES.txt', + to: 'licenses/gitoxide-helper/THIRD_PARTY_NOTICES.txt', + }, { from: 'bundled-tools.json', to: 'bundled-tools.json', diff --git a/docs/architecture/gitoxide-packaged-helper-authority-v1.zh-CN.md b/docs/architecture/gitoxide-packaged-helper-authority-v1.zh-CN.md new file mode 100644 index 0000000000..d400e79d9f --- /dev/null +++ b/docs/architecture/gitoxide-packaged-helper-authority-v1.zh-CN.md @@ -0,0 +1,80 @@ +--- +title: Gitoxide packaged helper authority v1 +status: Draft +milestone: M1.3 +--- + + +# Gitoxide packaged helper authority v1 + +## 1. 主要不变量 + +本切片只证明一件事: + +> 只有当前 Maka 发布流程构建、清单绑定并随应用资源一起交付的 exact Gitoxide helper,才能被转换成 Runtime Host 内部的调用 capability;普通 caller 不能用裸路径、PATH 发现或自报摘要获得执行权。 + +它不负责 source import、candidate、projection 或 Desktop managed task。这些能力消费本切片签发的 opaque capability,不能重新接受 executable path。 + +## 2. Owner 与权限边界 + +- release build owner:使用锁定的 `native/gitoxide-helper/Cargo.lock` 构建 release binary; +- preparation owner:复制到 fresh `.generated/gitoxide-helper`,计算 bytes/SHA-256,并写 `maka_gitoxide_helper_release_v1`; +- legal owner:从同一个 Cargo.lock graph 生成随包交付的 crate license/notice; +- packaged-resource owner:Electron 只携带 helper、manifest 和 notice; +- Runtime Host release owner:从平台应用已经授予的 `resourcesRoot` 读取严格 manifest,签发 release claim; +- invocation owner:每次调用前重新验证 canonical path、file identity、bytes 和 digest。 + +manifest 是发布资源的完整性声明,不是独立密码学签名。v1 的外层 trust root 是操作系统认可的应用发布/签名边界;同一用户权限下能同时改写已安装应用和 manifest 的攻击者不在本切片单独抵抗的威胁模型内。后续产品接线只能传递 Desktop 已持有的 packaged-resource authority,不能把公开 CLI 路径参数当成 authority。 + +## 3. 原子性与失败状态 + +preparation 先写 fresh helper 目录,再用临时 manifest rename 发布声明。生成失败时整个 `.generated` 输出不是发布输入,打包必须停止。 + +Runtime admission 只有两种结果: + +- exact manifest 与 artifact 匹配:签发 owner-bound invocation capability; +- manifest、平台、路径、类型、大小或摘要任一不匹配:fail closed,不发现 system Git,也不尝试旧 bundled Git。 + +它没有 T1,也不写 durable state;rollback 是丢弃 `.generated/gitoxide-helper` 并重新构建。 + +## 4. 平台能力矩阵 + +| 平台 | 构建/资源 | 运行时校验 | 当前证据 | +| --- | --- | --- | --- | +| Linux x64 | CI release helper | non-symlink regular file、identity、bytes、SHA-256 | Gitoxide workflow | +| macOS arm64 | release helper 随 app 签名 | 同上;外层 trust root 为已签名 app | Gitoxide workflow;正式 notarized artifact 仍由 release lane 验证 | +| Windows x64 | release helper 随安装包 | 拒绝 symlink/junction path,校验 identity、bytes、SHA-256 | Gitoxide workflow | + +开发态不会从 PATH、system Git 或任意 `resourcesPath` 自动启用 managed Git。没有经过明确测试 authority 注入时,Gitoxide managed profile 必须报告 unavailable。 + +## 5. 许可证与包体 + +Gitoxide helper 是单个短生命周期 Rust binary,不携带 Rust 工具链。Cargo notices 从 exact lock graph 在发布时生成并放入 `licenses/gitoxide-helper/THIRD_PARTY_NOTICES.txt`。普通 TypeScript 开发和非 Gitoxide 测试不需要安装 Rust;只有修改 helper、运行其三平台 CI 或构建正式安装包时需要锁定 Rust toolchain。 + +## 6. 后续产品接线 + +下一切片必须由同一个 Runtime Host composition 生命周期持有: + +1. packaged-resource authority; +2. Gitoxide invocation/admission/import/projection capability; +3. bundled npm capability 与 dependency storage authority; +4. 专用 managed task consumer。 + +Host handshake 必须声明 exact managed profile。CLI 启动的无 packaged-resource Host 不能被 Desktop 静默复用为支持该 profile 的 Host;不匹配只能显式拒绝或安全替换,禁止 fallback。 diff --git a/package.json b/package.json index 42ce3e8613..637cc35989 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,9 @@ "test:product-release": "node --test scripts/product-release.test.mjs scripts/product-release-artifacts.test.mjs scripts/product-release-authority.test.mjs", "generate:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs", "check:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs --check", + "build:gitoxide-helper": "cargo +1.98.0 build --manifest-path native/gitoxide-helper/Cargo.toml --release --locked", + "prepare:gitoxide-helper": "node scripts/prepare-gitoxide-helper.mjs", + "generate:gitoxide-cargo-notices": "node scripts/generate-gitoxide-cargo-notices.mjs", "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/product-release.test.mjs scripts/product-release-artifacts.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", diff --git a/packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts b/packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts new file mode 100644 index 0000000000..f80fa62b36 --- /dev/null +++ b/packages/runtime-host/src/__tests__/packaged-gitoxide-helper.test.ts @@ -0,0 +1,134 @@ +/* + * 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 { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { verifyGitoxideHelperArtifactForInvocationInternal } from '../server/gitoxide-helper-artifact-authority-internal.js'; +import { + PackagedGitoxideHelperError, + resolvePackagedGitoxideHelperInternal, +} from '../server/packaged-gitoxide-helper-internal.js'; + +test('turns an exact packaged helper manifest into an owner-bound invocation capability', async () => { + const fixture = await createFixture(); + try { + const invocationOwnerToken = {}; + const capability = await withPackagedResourcesRoot(fixture.root, () => + resolvePackagedGitoxideHelperInternal({ invocationOwnerToken }), + ); + const verified = await verifyGitoxideHelperArtifactForInvocationInternal( + invocationOwnerToken, + capability, + ); + assert.equal(verified.executablePath, fixture.executablePath); + assert.equal(verified.protocolVersion, 1); + } finally { + await fixture.cleanup(); + } +}); + +test('fails closed when the manifest and packaged helper no longer agree', async () => { + const fixture = await createFixture(); + try { + await writeFile(fixture.executablePath, 'tampered'); + await assert.rejects( + withPackagedResourcesRoot(fixture.root, () => + resolvePackagedGitoxideHelperInternal({ invocationOwnerToken: {} }), + ), + (error: unknown) => + error instanceof PackagedGitoxideHelperError && + error.code === 'packaged_gitoxide_helper_integrity_mismatch', + ); + } finally { + await fixture.cleanup(); + } +}); + +test('rejects an unknown or self-declared manifest shape', async () => { + const fixture = await createFixture(); + try { + await writeFile( + join(fixture.root, 'gitoxide-helper.json'), + JSON.stringify({ schemaVersion: 999, executableRelativePath: 'gitoxide/helper' }), + ); + await assert.rejects( + withPackagedResourcesRoot(fixture.root, () => + resolvePackagedGitoxideHelperInternal({ invocationOwnerToken: {} }), + ), + (error: unknown) => + error instanceof PackagedGitoxideHelperError && + error.code === 'packaged_gitoxide_helper_manifest_invalid', + ); + } finally { + await fixture.cleanup(); + } +}); + +async function withPackagedResourcesRoot(root: string, run: () => Promise): Promise { + const descriptor = Object.getOwnPropertyDescriptor(process, 'resourcesPath'); + Object.defineProperty(process, 'resourcesPath', { + configurable: true, + value: root, + }); + try { + return await run(); + } finally { + if (descriptor) Object.defineProperty(process, 'resourcesPath', descriptor); + else delete (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + } +} + +async function createFixture(): Promise<{ + root: string; + executablePath: string; + cleanup(): Promise; +}> { + const root = await mkdtemp(join(tmpdir(), 'maka-packaged-gitoxide-')); + const runtimeRoot = join(root, 'gitoxide'); + await mkdir(runtimeRoot, { recursive: true }); + const executableName = + process.platform === 'win32' ? 'maka-gitoxide-helper.exe' : 'maka-gitoxide-helper'; + const executablePath = join(runtimeRoot, executableName); + const bytes = Buffer.from('packaged-helper'); + await writeFile(executablePath, bytes, { mode: 0o755 }); + await writeFile( + join(root, 'gitoxide-helper.json'), + `${JSON.stringify({ + schemaVersion: 1, + protocol: 'maka_gitoxide_helper_release_v1', + provider: 'maka/gitoxide-helper', + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + executableRelativePath: `gitoxide/${executableName}`, + bytes: bytes.byteLength, + sha256: `sha256:${createHash('sha256').update(bytes).digest('hex')}`, + distributionReady: true, + })}\n`, + ); + return { + root, + executablePath, + cleanup: () => rm(root, { recursive: true, force: true }), + }; +} diff --git a/packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts b/packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts new file mode 100644 index 0000000000..027c57358d --- /dev/null +++ b/packages/runtime-host/src/server/packaged-gitoxide-helper-internal.ts @@ -0,0 +1,195 @@ +/* + * 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 { lstat, readFile, realpath } from 'node:fs/promises'; +import { isAbsolute, join, normalize, relative } from 'node:path'; +import { + admitGitoxideHelperArtifactInternal, + GitoxideHelperArtifactAuthorityError, + issueGitoxideHelperReleaseArtifactClaimInternal, + type GitoxideHelperInvocationCapability, +} from './gitoxide-helper-artifact-authority-internal.js'; + +const MANIFEST_KEYS = [ + 'arch', + 'bytes', + 'distributionReady', + 'executableRelativePath', + 'platform', + 'protocol', + 'protocolVersion', + 'provider', + 'schemaVersion', + 'sha256', +] as const; +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u; +const MAX_MANIFEST_BYTES = 64 * 1024; +const MAX_HELPER_BYTES = 256 * 1024 * 1024; +const packagedReleaseOwnerToken = Object.freeze({ + kind: 'packaged_gitoxide_release_owner_v1' as const, +}); + +export type PackagedGitoxideHelperErrorCode = + | 'packaged_gitoxide_helper_unavailable' + | 'packaged_gitoxide_helper_manifest_invalid' + | 'packaged_gitoxide_helper_platform_mismatch' + | 'packaged_gitoxide_helper_integrity_mismatch'; + +export class PackagedGitoxideHelperError extends Error { + constructor( + readonly code: PackagedGitoxideHelperErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'PackagedGitoxideHelperError'; + } +} + +export async function resolvePackagedGitoxideHelperInternal(input: { + readonly invocationOwnerToken: object; +}): Promise { + try { + const resourcesRoot = normalize(await realpath(requirePackagedProcessResourcesRoot())); + const manifestPath = normalize(await realpath(join(resourcesRoot, 'gitoxide-helper.json'))); + assertWithinRoot(resourcesRoot, manifestPath, 'Gitoxide helper manifest'); + const manifestInfo = await lstat(manifestPath); + if ( + !manifestInfo.isFile() || + manifestInfo.isSymbolicLink() || + manifestInfo.size > MAX_MANIFEST_BYTES + ) { + throw invalidManifest('Gitoxide helper manifest must be a bounded regular file'); + } + const manifest = decodeManifest(parseManifest(await readFile(manifestPath, 'utf8'))); + if (manifest.platform !== process.platform || manifest.arch !== process.arch) { + throw new PackagedGitoxideHelperError( + 'packaged_gitoxide_helper_platform_mismatch', + `Packaged Gitoxide helper targets ${manifest.platform}/${manifest.arch}, not ${process.platform}/${process.arch}`, + ); + } + const executablePath = normalize( + await realpath(join(resourcesRoot, ...manifest.executableRelativePath.split('/'))), + ); + assertWithinRoot(resourcesRoot, executablePath, 'Gitoxide helper executable'); + const claim = issueGitoxideHelperReleaseArtifactClaimInternal(packagedReleaseOwnerToken, { + executablePath, + expectedSha256: manifest.sha256, + expectedBytes: manifest.bytes, + platform: manifest.platform, + arch: manifest.arch, + protocolVersion: manifest.protocolVersion, + }); + return await admitGitoxideHelperArtifactInternal({ + releaseOwnerToken: packagedReleaseOwnerToken, + invocationOwnerToken: input.invocationOwnerToken, + claim, + }); + } catch (error) { + if (error instanceof PackagedGitoxideHelperError) throw error; + if (error instanceof GitoxideHelperArtifactAuthorityError) { + throw new PackagedGitoxideHelperError( + error.code === 'gitoxide_helper_artifact_identity_mismatch' + ? 'packaged_gitoxide_helper_integrity_mismatch' + : 'packaged_gitoxide_helper_unavailable', + 'Packaged Gitoxide helper failed release admission', + { cause: error }, + ); + } + throw new PackagedGitoxideHelperError( + 'packaged_gitoxide_helper_unavailable', + 'Packaged Gitoxide helper is unavailable', + { cause: error }, + ); + } +} + +function requirePackagedProcessResourcesRoot(): string { + const resourcesPath = (process as NodeJS.Process & { readonly resourcesPath?: unknown }) + .resourcesPath; + if (typeof resourcesPath !== 'string' || resourcesPath.length === 0 || !isAbsolute(resourcesPath)) { + throw new PackagedGitoxideHelperError( + 'packaged_gitoxide_helper_unavailable', + 'Packaged process resources root is unavailable', + ); + } + return resourcesPath; +} + +interface PackagedGitoxideHelperManifestV1 { + readonly schemaVersion: 1; + readonly protocol: 'maka_gitoxide_helper_release_v1'; + readonly provider: 'maka/gitoxide-helper'; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly protocolVersion: 1; + readonly executableRelativePath: string; + readonly bytes: number; + readonly sha256: `sha256:${string}`; + readonly distributionReady: true; +} + +function decodeManifest(input: unknown): PackagedGitoxideHelperManifestV1 { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw invalidManifest('Gitoxide helper manifest must be an object'); + } + const value = input as Record; + const expectedExecutable = + value.platform === 'win32' + ? 'gitoxide/maka-gitoxide-helper.exe' + : 'gitoxide/maka-gitoxide-helper'; + if ( + Object.keys(value).sort().join('\0') !== [...MANIFEST_KEYS].sort().join('\0') || + value.schemaVersion !== 1 || + value.protocol !== 'maka_gitoxide_helper_release_v1' || + value.provider !== 'maka/gitoxide-helper' || + (value.platform !== 'win32' && value.platform !== 'darwin' && value.platform !== 'linux') || + typeof value.arch !== 'string' || + !/^[a-z0-9_]+$/u.test(value.arch) || + value.protocolVersion !== 1 || + value.executableRelativePath !== expectedExecutable || + !Number.isSafeInteger(value.bytes) || + (value.bytes as number) < 1 || + (value.bytes as number) > MAX_HELPER_BYTES || + typeof value.sha256 !== 'string' || + !SHA256_PATTERN.test(value.sha256) || + value.distributionReady !== true + ) { + throw invalidManifest('Gitoxide helper manifest is invalid'); + } + return value as unknown as PackagedGitoxideHelperManifestV1; +} + +function parseManifest(value: string): unknown { + try { + return JSON.parse(value); + } catch (error) { + throw invalidManifest(`Gitoxide helper manifest is not valid JSON: ${String(error)}`); + } +} + +function assertWithinRoot(root: string, target: string, label: string): void { + const rel = relative(root, target); + if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) return; + throw invalidManifest(`${label} escapes the packaged resources root`); +} + +function invalidManifest(message: string): PackagedGitoxideHelperError { + return new PackagedGitoxideHelperError('packaged_gitoxide_helper_manifest_invalid', message); +} diff --git a/scripts/generate-gitoxide-cargo-notices.mjs b/scripts/generate-gitoxide-cargo-notices.mjs new file mode 100644 index 0000000000..c838a953eb --- /dev/null +++ b/scripts/generate-gitoxide-cargo-notices.mjs @@ -0,0 +1,111 @@ +/* + * 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 { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const manifestPath = join(repoRoot, 'native', 'gitoxide-helper', 'Cargo.toml'); +const lockPath = join(repoRoot, 'native', 'gitoxide-helper', 'Cargo.lock'); +const apacheLicensePath = join(repoRoot, 'LICENSE'); +const outputPath = join( + repoRoot, + 'apps', + 'desktop', + '.generated', + 'gitoxide-helper', + 'THIRD_PARTY_NOTICES.txt', +); + +const metadata = JSON.parse( + execFileSync( + process.env.CARGO ?? 'cargo', + ['metadata', '--manifest-path', manifestPath, '--locked', '--format-version', '1'], + { cwd: repoRoot, encoding: 'utf8' }, + ), +); +const packages = metadata.packages + .filter((pkg) => pkg.name !== 'maka-gitoxide-helper') + .sort((left, right) => + Buffer.compare( + Buffer.from(`${left.name}@${left.version}`, 'utf8'), + Buffer.from(`${right.name}@${right.version}`, 'utf8'), + ), + ); +const sections = packages.map((pkg) => { + if (!pkg.license) throw new Error(`${pkg.name}@${pkg.version}: missing SPDX license metadata`); + const directory = dirname(pkg.manifest_path); + const licenseFiles = readdirSync(directory, { withFileTypes: true }) + .filter( + (entry) => entry.isFile() && /^(licen[cs]e|copying|notice)(?:[._-].*)?$/iu.test(entry.name), + ) + .map((entry) => entry.name) + .sort(); + const selectedLicense = selectPackagedLicense(pkg.license, directory, licenseFiles); + if (!selectedLicense) { + throw new Error(`${pkg.name}@${pkg.version}: packaged crate has no license or notice text`); + } + const source = pkg.repository ?? pkg.homepage ?? pkg.source ?? 'unknown'; + const heading = `${pkg.name} ${pkg.version}`; + return [ + heading, + '-'.repeat(heading.length), + `SPDX license: ${pkg.license}`, + ...(selectedLicense.note ? [selectedLicense.note] : []), + `Source: ${source}`, + ...selectedLicense.files.flatMap(({ name, path }) => [ + '', + `--- ${name} ---`, + readFileSync(path, 'utf8').replace(/\r\n?/gu, '\n').trimEnd(), + ]), + ].join('\n'); +}); + +function selectPackagedLicense(spdxLicense, directory, licenseFiles) { + if (licenseFiles.length > 0) { + return { + files: licenseFiles.map((name) => ({ name, path: join(directory, name) })), + }; + } + if (/(^|\s|\()Apache-2\.0($|\s|\))/u.test(spdxLicense)) { + return { + note: 'Selected license: Apache-2.0 (the crate archive contains no license text)', + files: [{ name: 'LICENSE-APACHE-2.0', path: apacheLicensePath }], + }; + } + return undefined; +} +const lockDigest = createHash('sha256').update(readFileSync(lockPath)).digest('hex'); +const output = `Maka Gitoxide helper Cargo dependency notices +================================================ + +Generated by scripts/generate-gitoxide-cargo-notices.mjs from the exact +Cargo.lock dependency graph. Do not edit this file by hand. + +Manifest: ${relative(repoRoot, manifestPath).replaceAll('\\', '/')} +Cargo.lock SHA-256: ${lockDigest} + +${sections.join('\n\n')} +`; +mkdirSync(dirname(outputPath), { recursive: true }); +writeFileSync(outputPath, output); +console.log(`[gitoxide-cargo-notices] wrote ${outputPath}`); diff --git a/scripts/package-macos-arm64.mjs b/scripts/package-macos-arm64.mjs index 34779367bc..c9128d9d52 100644 --- a/scripts/package-macos-arm64.mjs +++ b/scripts/package-macos-arm64.mjs @@ -91,6 +91,9 @@ export async function packageMacosArm64({ await run('npm', ['run', 'clean']); await run('npm', ['run', 'build']); + await run('npm', ['run', 'build:gitoxide-helper']); + await run('npm', ['run', 'prepare:gitoxide-helper']); + await run('npm', ['run', 'generate:gitoxide-cargo-notices']); await run('npm', ['run', 'check:release']); await remove(releaseDirectory, { recursive: true, force: true }); await run('npm', ['--workspace', '@maka/desktop', 'run', 'package:macos-arm64']); diff --git a/scripts/package-windows-x64.mjs b/scripts/package-windows-x64.mjs index 46cb20441d..cce7adcabb 100644 --- a/scripts/package-windows-x64.mjs +++ b/scripts/package-windows-x64.mjs @@ -107,6 +107,9 @@ export async function packageWindowsX64({ await run('npm', ['run', 'check:windows-cargo-notices']); await mkdir(sandboxResourceDirectory, { recursive: true }); await copyFile(sandboxBinaryPath, sandboxResourcePath); + await run('npm', ['run', 'build:gitoxide-helper']); + await run('npm', ['run', 'prepare:gitoxide-helper']); + await run('npm', ['run', 'generate:gitoxide-cargo-notices']); await run('npm', ['run', 'check:release']); await remove(releaseDirectory, { recursive: true, force: true }); await run('npm', ['--workspace', '@maka/desktop', 'run', 'package:windows-x64']); diff --git a/scripts/prepare-gitoxide-helper.mjs b/scripts/prepare-gitoxide-helper.mjs new file mode 100644 index 0000000000..7a193220fb --- /dev/null +++ b/scripts/prepare-gitoxide-helper.mjs @@ -0,0 +1,114 @@ +/* + * 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 { createReadStream } from 'node:fs'; +import { chmod, copyFile, lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const MAX_HELPER_BYTES = 256 * 1024 * 1024; + +export async function prepareGitoxideHelper({ sourceExecutablePath, outputRoot, platform, arch }) { + if ( + typeof sourceExecutablePath !== 'string' || + typeof outputRoot !== 'string' || + !['win32', 'darwin', 'linux'].includes(platform) || + typeof arch !== 'string' || + !/^[a-z0-9_]+$/u.test(arch) + ) { + throw new Error('Gitoxide helper preparation input is invalid'); + } + const sourceInfo = await lstat(sourceExecutablePath); + if ( + !sourceInfo.isFile() || + sourceInfo.isSymbolicLink() || + sourceInfo.size < 1 || + sourceInfo.size > MAX_HELPER_BYTES + ) { + throw new Error('Gitoxide helper build output must be a bounded regular file'); + } + + const executableName = platform === 'win32' ? 'maka-gitoxide-helper.exe' : 'maka-gitoxide-helper'; + const runtimeRoot = join(outputRoot, 'gitoxide'); + const executablePath = join(runtimeRoot, executableName); + const manifestPath = join(outputRoot, 'gitoxide-helper.json'); + const manifestTempPath = `${manifestPath}.tmp`; + await rm(runtimeRoot, { recursive: true, force: true }); + await rm(manifestTempPath, { force: true }); + await mkdir(runtimeRoot, { recursive: true }); + await copyFile(sourceExecutablePath, executablePath); + if (platform !== 'win32') await chmod(executablePath, 0o755); + + const copiedInfo = await lstat(executablePath); + if (!copiedInfo.isFile() || copiedInfo.isSymbolicLink() || copiedInfo.size !== sourceInfo.size) { + throw new Error('Prepared Gitoxide helper does not match its build output'); + } + const sha256 = await sha256File(executablePath); + const manifest = { + schemaVersion: 1, + protocol: 'maka_gitoxide_helper_release_v1', + provider: 'maka/gitoxide-helper', + platform, + arch, + protocolVersion: 1, + executableRelativePath: `gitoxide/${executableName}`, + bytes: copiedInfo.size, + sha256, + distributionReady: true, + }; + await mkdir(outputRoot, { recursive: true }); + await writeFile(manifestTempPath, `${JSON.stringify(manifest, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); + await rename(manifestTempPath, manifestPath); + return { executablePath, manifestPath, sha256 }; +} + +async function sha256File(path) { + const digest = createHash('sha256'); + for await (const chunk of createReadStream(path)) digest.update(chunk); + return `sha256:${digest.digest('hex')}`; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const platform = process.platform; + const executableName = platform === 'win32' ? 'maka-gitoxide-helper.exe' : 'maka-gitoxide-helper'; + const sourceExecutablePath = join( + repoRoot, + 'native', + 'gitoxide-helper', + 'target', + 'release', + executableName, + ); + const outputRoot = join(repoRoot, 'apps', 'desktop', '.generated', 'gitoxide-helper'); + const result = await prepareGitoxideHelper({ + sourceExecutablePath, + outputRoot, + platform, + arch: process.arch, + }); + const manifest = JSON.parse(await readFile(result.manifestPath, 'utf8')); + console.log( + `[gitoxide-helper] prepared ${manifest.executableRelativePath} (${manifest.bytes} bytes, ${manifest.sha256})`, + ); +} diff --git a/scripts/prepare-gitoxide-helper.test.mjs b/scripts/prepare-gitoxide-helper.test.mjs new file mode 100644 index 0000000000..b9bc6396ce --- /dev/null +++ b/scripts/prepare-gitoxide-helper.test.mjs @@ -0,0 +1,78 @@ +/* + * 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 { readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { mkdtemp } from 'node:fs/promises'; + +import { prepareGitoxideHelper } from './prepare-gitoxide-helper.mjs'; + +test('prepares one exact helper artifact and a strict release manifest', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-gitoxide-package-')); + try { + const source = join(root, process.platform === 'win32' ? 'helper.exe' : 'helper'); + const outputRoot = join(root, 'resources'); + await writeFile(source, 'exact-helper-bytes'); + + const result = await prepareGitoxideHelper({ + sourceExecutablePath: source, + outputRoot, + platform: process.platform, + arch: process.arch, + }); + + assert.equal(await readFile(result.executablePath, 'utf8'), 'exact-helper-bytes'); + assert.deepEqual(JSON.parse(await readFile(result.manifestPath, 'utf8')), { + schemaVersion: 1, + protocol: 'maka_gitoxide_helper_release_v1', + provider: 'maka/gitoxide-helper', + platform: process.platform, + arch: process.arch, + protocolVersion: 1, + executableRelativePath: + process.platform === 'win32' + ? 'gitoxide/maka-gitoxide-helper.exe' + : 'gitoxide/maka-gitoxide-helper', + bytes: 18, + sha256: result.sha256, + distributionReady: true, + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('refuses to prepare a missing helper artifact', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-gitoxide-package-missing-')); + try { + await assert.rejects( + prepareGitoxideHelper({ + sourceExecutablePath: join(root, 'missing'), + outputRoot: join(root, 'resources'), + platform: process.platform, + arch: process.arch, + }), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index e2f16e814e..0ec8f0bb16 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -181,7 +181,9 @@ test('Desktop packaging does not distribute the retired bundled Git runtime', () false, ); assert.equal( - resources.some(({ to }) => to === 'git' || to.startsWith('licenses/git')), + resources.some( + ({ to }) => to === 'git' || to === 'licenses/git' || to.startsWith('licenses/git/'), + ), false, ); assert.equal( @@ -194,6 +196,30 @@ test('Desktop packaging does not distribute the retired bundled Git runtime', () ); }); +test('Desktop packaging carries the Gitoxide helper, release manifest, and Cargo notices', () => { + const resources = desktopBuilderConfig.extraResources.map(({ from, to }) => ({ from, to })); + assert.deepEqual( + resources.filter(({ to }) => + [ + 'gitoxide', + 'gitoxide-helper.json', + 'licenses/gitoxide-helper/THIRD_PARTY_NOTICES.txt', + ].includes(to), + ), + [ + { from: '.generated/gitoxide-helper/gitoxide', to: 'gitoxide' }, + { + from: '.generated/gitoxide-helper/gitoxide-helper.json', + to: 'gitoxide-helper.json', + }, + { + from: '.generated/gitoxide-helper/THIRD_PARTY_NOTICES.txt', + to: 'licenses/gitoxide-helper/THIRD_PARTY_NOTICES.txt', + }, + ], + ); +}); + test('a successful Windows upgrade invalidates stale backup authority before best-effort cleanup', async () => { const source = await readFile( join(repoRoot, 'apps', 'desktop', 'build', 'installer.nsh'), diff --git a/scripts/verify-packaged-app.mjs b/scripts/verify-packaged-app.mjs index 43c51b85c0..9ca0e0eb06 100644 --- a/scripts/verify-packaged-app.mjs +++ b/scripts/verify-packaged-app.mjs @@ -1022,6 +1022,7 @@ export async function assertPackagedResources( // artifacts that were correct when they shipped. The canonical icon itself // is `requireCanonicalIcon` above, not this. requireAppIconCatalog = true, + requireGitoxideHelper = true, } = {}, ) { if (bundledGitContract !== 'forbidden' && bundledGitContract !== 'legacy-required') { @@ -1031,6 +1032,16 @@ export async function assertPackagedResources( const required = [ 'app.asar', 'bundled-tools.json', + ...(requireGitoxideHelper + ? [ + 'gitoxide-helper.json', + join( + 'gitoxide', + process.platform === 'win32' ? 'maka-gitoxide-helper.exe' : 'maka-gitoxide-helper', + ), + join('licenses', 'gitoxide-helper', 'THIRD_PARTY_NOTICES.txt'), + ] + : []), ...(requiresLegacyBundledGit ? [ 'bundled-git.json', diff --git a/scripts/verify-packaged-app.test.mjs b/scripts/verify-packaged-app.test.mjs index 1f7596d19d..f44a8972f8 100644 --- a/scripts/verify-packaged-app.test.mjs +++ b/scripts/verify-packaged-app.test.mjs @@ -58,6 +58,7 @@ test('legacy packaged resources require the historical bundled Git contract', as requireWindowsSandbox: false, bundledGitContract: 'legacy-required', requireCanonicalIcon: false, + requireGitoxideHelper: false, }); for (const path of [ diff --git a/scripts/verify-windows-x64.mjs b/scripts/verify-windows-x64.mjs index 6b2bf36265..3bf419eb3d 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -131,6 +131,7 @@ export async function verifyPackagedWindowsApp( bundledGitContract: requiresCurrentContract ? 'forbidden' : 'legacy-required', requireCanonicalIcon: requiresCurrentContract, requireAppIconCatalog: requiresCurrentContract, + requireGitoxideHelper: requiresCurrentContract, }); if (requiresCurrentContract) await assertPackagedDependencyClosure(resources); else await requirePath(join(resources, 'git', 'cmd', 'git.exe'));