blob: f67914153c8d337448b3c2ae33ded49a5cca7ba7 [file]
import { createHash } from 'node:crypto';
import { lstat, open, readFile, realpath } from 'node:fs/promises';
import { isAbsolute, join, normalize, relative } from 'node:path';
import type { VerifiedGitRuntimeInput } from '@maka/storage/managed-workspace-owner';
const MANIFEST_KEYS = [
'arch',
'distributionReady',
'executableRelativePath',
'executableSha256',
'gitVersion',
'platform',
'protocol',
'provider',
'schemaVersion',
'sourceArchiveSha256',
] as const;
const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/u;
const VERSION_PATTERN = /^[1-9][0-9]*\.[0-9]+\.[0-9]+(?:[-.][A-Za-z0-9]+)*$/u;
export type BundledGitRuntimeErrorCode =
| 'bundled_git_unavailable'
| 'bundled_git_manifest_invalid'
| 'bundled_git_platform_mismatch'
| 'bundled_git_integrity_mismatch';
export class BundledGitRuntimeError extends Error {
constructor(
readonly code: BundledGitRuntimeErrorCode,
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = 'BundledGitRuntimeError';
}
}
export interface ResolveBundledGitRuntimeInput {
readonly resourcesRoot: string;
readonly platform?: NodeJS.Platform;
readonly arch?: string;
readonly manifestPath?: string;
}
export async function resolveBundledGitRuntime(
input: ResolveBundledGitRuntimeInput,
): Promise<VerifiedGitRuntimeInput> {
const platform = input.platform ?? process.platform;
const arch = input.arch ?? process.arch;
try {
const resourcesRoot = normalize(await realpath(input.resourcesRoot));
const manifestPath = normalize(
await realpath(input.manifestPath ?? join(resourcesRoot, 'bundled-git.json')),
);
assertWithinRoot(resourcesRoot, manifestPath, 'Bundled Git manifest');
const manifestInfo = await lstat(manifestPath);
if (!manifestInfo.isFile() || manifestInfo.isSymbolicLink()) {
throw invalidManifest('Bundled Git manifest must be a regular non-symlink file');
}
const manifest = decodeManifest(JSON.parse(await readFile(manifestPath, 'utf8')));
if (manifest.platform !== platform || manifest.arch !== arch) {
throw new BundledGitRuntimeError(
'bundled_git_platform_mismatch',
`Bundled Git targets ${manifest.platform}-${manifest.arch}, not ${platform}-${arch}`,
);
}
const executablePath = normalize(
join(resourcesRoot, ...manifest.executableRelativePath.split('/')),
);
assertWithinRoot(resourcesRoot, executablePath, 'Bundled Git executable');
const executableInfo = await lstat(executablePath);
if (!executableInfo.isFile() || executableInfo.isSymbolicLink()) {
throw new BundledGitRuntimeError(
'bundled_git_unavailable',
'Bundled Git executable must be a regular non-symlink file',
);
}
const canonicalExecutable = normalize(await realpath(executablePath));
assertWithinRoot(resourcesRoot, canonicalExecutable, 'Bundled Git executable');
const actualSha256 = await sha256File(canonicalExecutable);
if (actualSha256 !== manifest.executableSha256) {
throw new BundledGitRuntimeError(
'bundled_git_integrity_mismatch',
`Bundled Git executable digest mismatch: ${canonicalExecutable}`,
);
}
return {
executablePath: canonicalExecutable,
expectedSha256: manifest.executableSha256,
runtimeIdentitySha256: bundledGitRuntimeIdentity(manifest),
distribution: {
kind: 'dugite_native_v1',
rootPath: normalize(await realpath(join(resourcesRoot, 'git'))),
},
};
} catch (error) {
if (error instanceof BundledGitRuntimeError) throw error;
throw new BundledGitRuntimeError(
'bundled_git_unavailable',
'Bundled Git runtime is unavailable',
{ cause: error },
);
}
}
function bundledGitRuntimeIdentity(manifest: BundledGitManifestV1): `sha256:${string}` {
const identity = JSON.stringify({
protocol: 'maka_git_runtime_identity_v1',
manifestProtocol: manifest.protocol,
provider: manifest.provider,
gitVersion: manifest.gitVersion,
platform: manifest.platform,
arch: manifest.arch,
executableRelativePath: manifest.executableRelativePath,
executableSha256: manifest.executableSha256,
sourceArchiveSha256: manifest.sourceArchiveSha256,
});
return `sha256:${createHash('sha256').update(identity).digest('hex')}`;
}
interface BundledGitManifestV1 {
readonly schemaVersion: 1;
readonly protocol: 'maka_bundled_git_runtime_v1';
readonly provider: 'desktop/dugite-native';
readonly gitVersion: string;
readonly platform: NodeJS.Platform;
readonly arch: string;
readonly executableRelativePath: string;
readonly executableSha256: `sha256:${string}`;
readonly sourceArchiveSha256: `sha256:${string}`;
readonly distributionReady: true;
}
function decodeManifest(input: unknown): BundledGitManifestV1 {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw invalidManifest('Bundled Git manifest must be an object');
}
const value = input as Record<string, unknown>;
if (
Object.keys(value).sort().join('\0') !== [...MANIFEST_KEYS].sort().join('\0') ||
value.schemaVersion !== 1 ||
value.protocol !== 'maka_bundled_git_runtime_v1' ||
value.provider !== 'desktop/dugite-native' ||
typeof value.gitVersion !== 'string' ||
!VERSION_PATTERN.test(value.gitVersion) ||
(value.platform !== 'win32' && value.platform !== 'darwin' && value.platform !== 'linux') ||
typeof value.arch !== 'string' ||
!/^[a-z0-9_]+$/u.test(value.arch) ||
typeof value.executableRelativePath !== 'string' ||
!isSafeRelativePath(value.executableRelativePath) ||
typeof value.executableSha256 !== 'string' ||
!SHA256_PATTERN.test(value.executableSha256) ||
typeof value.sourceArchiveSha256 !== 'string' ||
!SHA256_PATTERN.test(value.sourceArchiveSha256) ||
value.distributionReady !== true
) {
throw invalidManifest('Bundled Git manifest is invalid');
}
return value as unknown as BundledGitManifestV1;
}
function isSafeRelativePath(value: string): boolean {
if (!value || isAbsolute(value) || value.includes('\\')) return false;
const segments = value.split('/');
return segments.every((segment) => segment.length > 0 && segment !== '.' && segment !== '..');
}
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): BundledGitRuntimeError {
return new BundledGitRuntimeError('bundled_git_manifest_invalid', message);
}
async function sha256File(path: string): Promise<`sha256:${string}`> {
const hash = createHash('sha256');
const file = await open(path, 'r');
try {
const buffer = Buffer.allocUnsafe(64 * 1024);
let position = 0;
while (true) {
const { bytesRead } = await file.read(buffer, 0, buffer.length, position);
if (bytesRead === 0) break;
hash.update(buffer.subarray(0, bytesRead));
position += bytesRead;
}
return `sha256:${hash.digest('hex')}`;
} finally {
await file.close();
}
}