| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| import { spawn } from 'node:child_process'; |
| import { realpath } from 'node:fs/promises'; |
| import { dirname, isAbsolute } from 'node:path'; |
| import { manageChildProcessLifecycle } from '@maka/runtime/child-process-lifecycle'; |
| import { |
| type GitoxideHelperInvocationCapability, |
| verifyGitoxideHelperArtifactForInvocationInternal, |
| } from './gitoxide-helper-artifact-authority-internal.js'; |
| |
| const MAX_REQUEST_BYTES = 64 * 1024; |
| const MAX_STDOUT_BYTES = 64 * 1024; |
| const MAX_STDERR_BYTES = 16 * 1024; |
| export const GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL = Object.freeze({ |
| inspectRepositoryMs: 5_000, |
| importSourceHeadMs: 10 * 60_000, |
| }); |
| const SHA1_OID_PATTERN = /^[0-9a-f]{40}$/; |
| const MAKA_REF_PATTERN = /^refs\/maka\/[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/; |
| export const GITOXIDE_HELPER_ERROR_REASONS_V1 = Object.freeze([ |
| 'internal_error_reason_invalid', |
| 'request_read_failed', |
| 'request_too_large', |
| 'invalid_request', |
| 'unsupported_protocol_version', |
| 'repository_metadata_limit_exceeded', |
| 'repository_alternates_unsupported', |
| 'repository_open_failed', |
| 'head_commit_unavailable', |
| 'head_commit_identity_mismatch', |
| 'head_tree_unavailable', |
| 'commit_object_limit_exceeded', |
| 'baseline_commit_write_failed', |
| 'baseline_publish_failed', |
| 'baseline_ref_outside_maka_namespace', |
| 'invalid_baseline_ref', |
| 'import_destination_create_failed', |
| 'import_destination_not_fresh', |
| 'import_destination_object_format_mismatch', |
| 'import_destination_parent_untrusted', |
| 'invalid_source_head_commit_oid', |
| 'source_blob_copy_failed', |
| 'source_blob_identity_mismatch', |
| 'source_blob_invalid', |
| 'source_blob_unavailable', |
| 'source_attributes_limit_exceeded', |
| 'source_byte_limit_exceeded', |
| 'source_file_limit_exceeded', |
| 'source_folded_path_byte_limit_exceeded', |
| 'source_folded_path_length_exceeded', |
| 'source_head_commit_mismatch', |
| 'source_head_commit_identity_mismatch', |
| 'source_head_commit_unavailable', |
| 'source_head_tree_unavailable', |
| 'source_path_collision', |
| 'source_path_byte_limit_exceeded', |
| 'source_path_length_exceeded', |
| 'source_tree_copy_failed', |
| 'source_tree_depth_exceeded', |
| 'source_tree_entry_limit_exceeded', |
| 'source_tree_identity_mismatch', |
| 'source_tree_invalid', |
| 'source_tree_object_byte_limit_exceeded', |
| 'source_tree_object_limit_exceeded', |
| 'source_tree_noncanonical_mode', |
| 'source_tree_not_sorted', |
| 'source_tree_observation_mismatch', |
| 'source_tree_unavailable', |
| 'source_tree_visit_limit_exceeded', |
| 'unsupported_source_entry_kind', |
| 'unsupported_source_attributes', |
| 'unsupported_source_path', |
| 'unsupported_object_format', |
| 'unsupported_managed_tree_policy', |
| ] as const); |
| const HELPER_ERROR_REASONS = new Set<string>(GITOXIDE_HELPER_ERROR_REASONS_V1); |
| |
| export interface GitoxideRepositoryObservationV1 { |
| readonly kind: 'repository_inspected'; |
| readonly protocolVersion: 1; |
| readonly objectFormat: 'sha1'; |
| readonly headCommitOid: string; |
| readonly headTreeOid: string; |
| } |
| |
| export interface GitoxideRepositoryRejectionV1 { |
| readonly kind: 'repository_rejected'; |
| readonly protocolVersion: 1; |
| readonly reason: 'unsupported_object_format'; |
| readonly objectFormat: 'sha256' | 'unknown'; |
| readonly supportedObjectFormats: readonly ['sha1']; |
| } |
| |
| export type GitoxideRepositoryInspectionResultV1 = |
| | GitoxideRepositoryObservationV1 |
| | GitoxideRepositoryRejectionV1; |
| |
| export interface GitoxideSourceImportObservationV1 { |
| readonly kind: 'source_imported'; |
| readonly protocolVersion: 1; |
| readonly objectFormat: 'sha1'; |
| readonly sourceHeadCommitOid: string; |
| readonly sourceTreeOid: string; |
| readonly baselineCommitOid: string; |
| readonly baselineTreeOid: string; |
| readonly baselineRef: string; |
| readonly managedTreePolicyVersion: 2; |
| readonly filesImported: number; |
| readonly bytesImported: number; |
| } |
| |
| export type GitoxideHelperInvocationErrorCode = |
| | 'gitoxide_helper_invocation_invalid' |
| | 'gitoxide_helper_invocation_spawn_failed' |
| | 'gitoxide_helper_invocation_timed_out' |
| | 'gitoxide_helper_invocation_aborted' |
| | 'gitoxide_helper_invocation_output_too_large' |
| | 'gitoxide_helper_invocation_protocol_invalid' |
| | 'gitoxide_helper_operation_failed'; |
| |
| export class GitoxideHelperInvocationError extends Error { |
| constructor( |
| readonly code: GitoxideHelperInvocationErrorCode, |
| message: string, |
| readonly helperReason?: string, |
| ) { |
| super(message); |
| this.name = 'GitoxideHelperInvocationError'; |
| } |
| } |
| |
| export async function inspectRepositoryWithGitoxideHelperInternal(input: { |
| readonly invocationOwnerToken: object; |
| readonly capability: GitoxideHelperInvocationCapability; |
| readonly repositoryPath: string; |
| readonly abortSignal?: AbortSignal; |
| }): Promise<GitoxideRepositoryInspectionResultV1> { |
| const deadlineAt = |
| performance.now() + GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL.inspectRepositoryMs; |
| return ( |
| await inspectCanonicalRepositoryWithGitoxideHelperInternal({ |
| ...input, |
| deadlineAt, |
| }) |
| ).observation; |
| } |
| |
| export async function inspectCanonicalRepositoryWithGitoxideHelperInternal(input: { |
| readonly invocationOwnerToken: object; |
| readonly capability: GitoxideHelperInvocationCapability; |
| readonly repositoryPath: string; |
| readonly deadlineAt: number; |
| readonly abortSignal?: AbortSignal; |
| }): Promise<{ |
| readonly repositoryPath: string; |
| readonly observation: GitoxideRepositoryInspectionResultV1; |
| }> { |
| const { artifact, repositoryPath } = await runGitoxideOperationWithinDeadlineInternal({ |
| deadlineAt: input.deadlineAt, |
| abortSignal: input.abortSignal, |
| operation: async () => { |
| if (!isAbsolute(input.repositoryPath)) { |
| throw new GitoxideHelperInvocationError( |
| 'gitoxide_helper_invocation_invalid', |
| 'Gitoxide repository path must be absolute', |
| ); |
| } |
| const [artifact, repositoryPath] = await Promise.all([ |
| verifyGitoxideHelperArtifactForInvocationInternal( |
| input.invocationOwnerToken, |
| input.capability, |
| ), |
| realpath(input.repositoryPath).catch((error) => { |
| throw new GitoxideHelperInvocationError( |
| 'gitoxide_helper_invocation_invalid', |
| `Gitoxide repository path could not be resolved: ${error instanceof Error ? error.message : String(error)}`, |
| ); |
| }), |
| ]); |
| return { artifact, repositoryPath }; |
| }, |
| }); |
| |
| const request = Buffer.from( |
| JSON.stringify({ |
| protocolVersion: artifact.protocolVersion, |
| operation: 'inspect_repository', |
| repositoryPath, |
| }), |
| ); |
| if (request.length > MAX_REQUEST_BYTES) { |
| throw new GitoxideHelperInvocationError( |
| 'gitoxide_helper_invocation_invalid', |
| 'Gitoxide helper request exceeds its byte limit', |
| ); |
| } |
| |
| const outcome = await invokeHelper({ |
| executablePath: artifact.executablePath, |
| request, |
| abortSignal: input.abortSignal, |
| deadlineAt: input.deadlineAt, |
| }); |
| return Object.freeze({ repositoryPath, observation: decodeOutcome(outcome) }); |
| } |
| |
| export async function runGitoxideOperationWithinDeadlineInternal<T>(input: { |
| readonly deadlineAt: number; |
| readonly operation: () => Promise<T>; |
| readonly abortSignal?: AbortSignal; |
| }): Promise<T> { |
| throwIfAborted(input.abortSignal); |
| const remainingMs = input.deadlineAt - performance.now(); |
| if (remainingMs <= 0) throw invocationTimedOut(); |
| |
| return new Promise<T>((resolve, reject) => { |
| let settled = false; |
| const timeout = setTimeout(() => finishReject(invocationTimedOut()), remainingMs); |
| const abort = () => |
| finishReject( |
| new GitoxideHelperInvocationError( |
| 'gitoxide_helper_invocation_aborted', |
| terminationMessage('gitoxide_helper_invocation_aborted'), |
| ), |
| ); |
| input.abortSignal?.addEventListener('abort', abort, { once: true }); |
| |
| void Promise.resolve().then(input.operation).then(finishResolve, finishReject); |
| |
| function finishResolve(value: T): void { |
| if (settled) return; |
| settled = true; |
| cleanup(); |
| resolve(value); |
| } |
| |
| function finishReject(error: unknown): void { |
| if (settled) return; |
| settled = true; |
| cleanup(); |
| reject(error); |
| } |
| |
| function cleanup(): void { |
| clearTimeout(timeout); |
| input.abortSignal?.removeEventListener('abort', abort); |
| } |
| }); |
| } |
| |
| function invocationTimedOut(): GitoxideHelperInvocationError { |
| return new GitoxideHelperInvocationError( |
| 'gitoxide_helper_invocation_timed_out', |
| terminationMessage('gitoxide_helper_invocation_timed_out'), |
| ); |
| } |
| |
| export async function importSourceHeadWithGitoxideHelperInternal(input: { |
| readonly invocationOwnerToken: object; |
| readonly capability: GitoxideHelperInvocationCapability; |
| readonly sourceRepositoryPath: string; |
| readonly expectedSourceHeadCommitOid: string; |
| readonly destinationRepositoryPath: string; |
| readonly baselineRef: string; |
| readonly managedTreePolicyVersion: 2; |
| readonly abortSignal?: AbortSignal; |
| }): Promise<GitoxideSourceImportObservationV1> { |
| const deadlineAt = |
| performance.now() + GITOXIDE_HELPER_OPERATION_TIMEOUTS_INTERNAL.importSourceHeadMs; |
| const { artifact, sourceRepositoryPath } = await runGitoxideOperationWithinDeadlineInternal({ |
| deadlineAt, |
| abortSignal: input.abortSignal, |
| operation: async () => { |
| if ( |
| !isAbsolute(input.sourceRepositoryPath) || |
| !isAbsolute(input.destinationRepositoryPath) || |
| !SHA1_OID_PATTERN.test(input.expectedSourceHeadCommitOid) || |
| !MAKA_REF_PATTERN.test(input.baselineRef) |
| ) { |
| throw new GitoxideHelperInvocationError( |
| 'gitoxide_helper_invocation_invalid', |
| 'Gitoxide source import request is invalid', |
| ); |
| } |
| const [artifact, sourceRepositoryPath] = await Promise.all([ |
| verifyGitoxideHelperArtifactForInvocationInternal( |
| input.invocationOwnerToken, |
| input.capability, |
| ), |
| realpath(input.sourceRepositoryPath).catch((error) => { |
| throw new GitoxideHelperInvocationError( |
| 'gitoxide_helper_invocation_invalid', |
| `Gitoxide source repository path could not be resolved: ${error instanceof Error ? error.message : String(error)}`, |
| ); |
| }), |
| ]); |
| return { artifact, sourceRepositoryPath }; |
| }, |
| }); |
| |
| const request = Buffer.from( |
| JSON.stringify({ |
| protocolVersion: artifact.protocolVersion, |
| operation: 'import_source_head', |
| sourceRepositoryPath, |
| expectedSourceHeadCommitOid: input.expectedSourceHeadCommitOid, |
| destinationRepositoryPath: input.destinationRepositoryPath, |
| baselineRef: input.baselineRef, |
| managedTreePolicyVersion: input.managedTreePolicyVersion, |
| }), |
| ); |
| if (request.length > MAX_REQUEST_BYTES) { |
| throw new GitoxideHelperInvocationError( |
| 'gitoxide_helper_invocation_invalid', |
| 'Gitoxide helper request exceeds its byte limit', |
| ); |
| } |
| const outcome = await invokeHelper({ |
| executablePath: artifact.executablePath, |
| request, |
| abortSignal: input.abortSignal, |
| deadlineAt, |
| }); |
| return decodeSourceImportOutcome(outcome, { |
| expectedSourceHeadCommitOid: input.expectedSourceHeadCommitOid, |
| baselineRef: input.baselineRef, |
| managedTreePolicyVersion: input.managedTreePolicyVersion, |
| }); |
| } |
| |
| /* |
| * The absolute operation deadline starts at the public entry point. Once it |
| * expires, the shared lifecycle owner force-kills the process tree and applies |
| * bounded exit-acknowledgement and output-drain deadlines before this promise |
| * settles. |
| */ |
| function invokeHelper(input: { |
| readonly executablePath: string; |
| readonly request: Buffer; |
| readonly abortSignal?: AbortSignal; |
| readonly deadlineAt: number; |
| }): Promise<HelperProcessOutcome> { |
| if (input.abortSignal?.aborted) { |
| return Promise.reject( |
| new GitoxideHelperInvocationError( |
| 'gitoxide_helper_invocation_aborted', |
| terminationMessage('gitoxide_helper_invocation_aborted'), |
| ), |
| ); |
| } |
| const remainingMs = input.deadlineAt - performance.now(); |
| if (remainingMs <= 0) return Promise.reject(invocationTimedOut()); |
| |
| return new Promise((resolve, reject) => { |
| let child: ReturnType<typeof spawn>; |
| try { |
| child = spawn(input.executablePath, [], { |
| cwd: dirname(input.executablePath), |
| env: helperEnvironment(), |
| shell: false, |
| windowsHide: true, |
| detached: process.platform !== 'win32', |
| stdio: ['pipe', 'pipe', 'pipe'], |
| }); |
| } catch (error) { |
| throw new GitoxideHelperInvocationError( |
| 'gitoxide_helper_invocation_spawn_failed', |
| `Gitoxide helper could not be started: ${error instanceof Error ? error.message : String(error)}`, |
| ); |
| return; |
| } |
| |
| const stdout: Buffer[] = []; |
| const stderr: Buffer[] = []; |
| let stdoutBytes = 0; |
| let stderrBytes = 0; |
| let settled = false; |
| let termination: |
| | 'gitoxide_helper_invocation_timed_out' |
| | 'gitoxide_helper_invocation_aborted' |
| | 'gitoxide_helper_invocation_output_too_large' |
| | undefined; |
| let processFailure: GitoxideHelperInvocationError | undefined; |
| const lifecycle = manageChildProcessLifecycle( |
| child, |
| [ |
| { key: 'stdout', stream: child.stdout! }, |
| { key: 'stderr', stream: child.stderr! }, |
| ], |
| { |
| killGraceMs: 0, |
| ioDrainTimeoutMs: 2_000, |
| exitAcknowledgementMs: 2_000, |
| }, |
| ); |
| const timeout = setTimeout( |
| () => terminate('gitoxide_helper_invocation_timed_out'), |
| remainingMs, |
| ); |
| const abort = () => terminate('gitoxide_helper_invocation_aborted'); |
| input.abortSignal?.addEventListener('abort', abort, { once: true }); |
| if (input.abortSignal?.aborted) abort(); |
| |
| child.stdout!.on('data', (chunk: Buffer) => { |
| if (settled) return; |
| stdoutBytes += chunk.length; |
| if (stdoutBytes > MAX_STDOUT_BYTES) { |
| terminate('gitoxide_helper_invocation_output_too_large'); |
| return; |
| } |
| stdout.push(chunk); |
| }); |
| child.stderr!.on('data', (chunk: Buffer) => { |
| if (settled) return; |
| stderrBytes += chunk.length; |
| if (stderrBytes > MAX_STDERR_BYTES) { |
| terminate('gitoxide_helper_invocation_output_too_large'); |
| return; |
| } |
| stderr.push(chunk); |
| }); |
| void lifecycle.completion.then( |
| (outcome) => { |
| if (processFailure) { |
| finishReject(processFailure); |
| return; |
| } |
| if (termination) { |
| finishReject( |
| new GitoxideHelperInvocationError(termination, terminationMessage(termination)), |
| ); |
| return; |
| } |
| if (!outcome.ioDrained) { |
| finishReject( |
| new GitoxideHelperInvocationError( |
| 'gitoxide_helper_invocation_protocol_invalid', |
| 'Gitoxide helper output did not drain before its lifecycle deadline', |
| ), |
| ); |
| return; |
| } |
| finishResolve({ |
| exitCode: outcome.exitCode, |
| signal: outcome.signal, |
| stdout: Buffer.concat(stdout, stdoutBytes), |
| stderr: Buffer.concat(stderr, stderrBytes), |
| }); |
| }, |
| (error: unknown) => { |
| if (termination) { |
| finishReject( |
| new GitoxideHelperInvocationError(termination, terminationMessage(termination)), |
| ); |
| return; |
| } |
| finishReject( |
| new GitoxideHelperInvocationError( |
| 'gitoxide_helper_invocation_spawn_failed', |
| `Gitoxide helper process failed: ${error instanceof Error ? error.message : String(error)}`, |
| ), |
| ); |
| }, |
| ); |
| child.stdin!.on('error', (error) => { |
| if (settled || processFailure) return; |
| processFailure = new GitoxideHelperInvocationError( |
| 'gitoxide_helper_invocation_spawn_failed', |
| `Gitoxide helper request could not be written: ${error.message}`, |
| ); |
| lifecycle.forceKill(); |
| }); |
| child.stdin!.end(input.request); |
| |
| function terminate(reason: NonNullable<typeof termination>): void { |
| if (settled || termination) return; |
| termination = reason; |
| lifecycle.forceKill(); |
| } |
| |
| function finishResolve(outcome: HelperProcessOutcome): void { |
| if (settled) return; |
| settled = true; |
| cleanup(); |
| resolve(outcome); |
| } |
| |
| function finishReject(error: GitoxideHelperInvocationError): void { |
| if (settled) return; |
| settled = true; |
| cleanup(); |
| reject(error); |
| } |
| |
| function cleanup(): void { |
| clearTimeout(timeout); |
| input.abortSignal?.removeEventListener('abort', abort); |
| } |
| }); |
| } |
| |
| interface HelperProcessOutcome { |
| readonly exitCode: number | null; |
| readonly signal: NodeJS.Signals | null; |
| readonly stdout: Buffer; |
| readonly stderr: Buffer; |
| } |
| |
| function decodeOutcome(outcome: HelperProcessOutcome): GitoxideRepositoryInspectionResultV1 { |
| if (outcome.signal !== null) { |
| throw new GitoxideHelperInvocationError( |
| 'gitoxide_helper_invocation_protocol_invalid', |
| `Gitoxide helper exited from signal ${outcome.signal}`, |
| ); |
| } |
| let value: unknown; |
| try { |
| value = JSON.parse(outcome.stdout.toString('utf8')); |
| } catch { |
| throw protocolInvalid('Gitoxide helper stdout is not one JSON response'); |
| } |
| |
| if (outcome.exitCode === 0 && isRepositoryObservation(value)) return Object.freeze(value); |
| if (outcome.exitCode === 2 && isRepositoryRejection(value)) { |
| return Object.freeze({ ...value, supportedObjectFormats: Object.freeze(['sha1'] as const) }); |
| } |
| if (outcome.exitCode === 1 && isHelperError(value)) { |
| throw new GitoxideHelperInvocationError( |
| 'gitoxide_helper_operation_failed', |
| `Gitoxide helper could not inspect the repository: ${value.reason}`, |
| value.reason, |
| ); |
| } |
| const stderr = outcome.stderr.toString('utf8').trim(); |
| throw protocolInvalid( |
| `Gitoxide helper exit code and response disagree${stderr ? `: ${stderr}` : ''}`, |
| ); |
| } |
| |
| function decodeSourceImportOutcome( |
| outcome: HelperProcessOutcome, |
| expected: { |
| readonly expectedSourceHeadCommitOid: string; |
| readonly baselineRef: string; |
| readonly managedTreePolicyVersion: 2; |
| }, |
| ): GitoxideSourceImportObservationV1 { |
| if (outcome.signal !== null) { |
| throw new GitoxideHelperInvocationError( |
| 'gitoxide_helper_invocation_protocol_invalid', |
| `Gitoxide helper exited from signal ${outcome.signal}`, |
| ); |
| } |
| let value: unknown; |
| try { |
| value = JSON.parse(outcome.stdout.toString('utf8')); |
| } catch { |
| throw protocolInvalid('Gitoxide helper stdout is not one JSON response'); |
| } |
| if (outcome.exitCode === 0 && isSourceImportObservation(value, expected)) { |
| return Object.freeze(value); |
| } |
| if (outcome.exitCode === 1 && isHelperError(value)) { |
| throw new GitoxideHelperInvocationError( |
| 'gitoxide_helper_operation_failed', |
| `Gitoxide helper could not import the source repository: ${value.reason}`, |
| value.reason, |
| ); |
| } |
| const stderr = outcome.stderr.toString('utf8').trim(); |
| throw protocolInvalid( |
| `Gitoxide helper exit code and response disagree${stderr ? `: ${stderr}` : ''}`, |
| ); |
| } |
| |
| function isSourceImportObservation( |
| value: unknown, |
| expected: { |
| readonly expectedSourceHeadCommitOid: string; |
| readonly baselineRef: string; |
| readonly managedTreePolicyVersion: 2; |
| }, |
| ): value is GitoxideSourceImportObservationV1 { |
| return ( |
| hasExactKeys(value, [ |
| 'protocolVersion', |
| 'kind', |
| 'objectFormat', |
| 'sourceHeadCommitOid', |
| 'sourceTreeOid', |
| 'baselineCommitOid', |
| 'baselineTreeOid', |
| 'baselineRef', |
| 'managedTreePolicyVersion', |
| 'filesImported', |
| 'bytesImported', |
| ]) && |
| value.protocolVersion === 1 && |
| value.kind === 'source_imported' && |
| value.objectFormat === 'sha1' && |
| typeof value.sourceHeadCommitOid === 'string' && |
| SHA1_OID_PATTERN.test(value.sourceHeadCommitOid) && |
| value.sourceHeadCommitOid === expected.expectedSourceHeadCommitOid && |
| typeof value.sourceTreeOid === 'string' && |
| SHA1_OID_PATTERN.test(value.sourceTreeOid) && |
| typeof value.baselineCommitOid === 'string' && |
| SHA1_OID_PATTERN.test(value.baselineCommitOid) && |
| typeof value.baselineTreeOid === 'string' && |
| SHA1_OID_PATTERN.test(value.baselineTreeOid) && |
| value.baselineTreeOid === value.sourceTreeOid && |
| typeof value.baselineRef === 'string' && |
| MAKA_REF_PATTERN.test(value.baselineRef) && |
| value.baselineRef === expected.baselineRef && |
| value.managedTreePolicyVersion === expected.managedTreePolicyVersion && |
| Number.isSafeInteger(value.filesImported) && |
| (value.filesImported as number) >= 0 && |
| Number.isSafeInteger(value.bytesImported) && |
| (value.bytesImported as number) >= 0 |
| ); |
| } |
| |
| function isRepositoryObservation(value: unknown): value is GitoxideRepositoryObservationV1 { |
| return ( |
| hasExactKeys(value, [ |
| 'protocolVersion', |
| 'kind', |
| 'objectFormat', |
| 'headCommitOid', |
| 'headTreeOid', |
| ]) && |
| value.protocolVersion === 1 && |
| value.kind === 'repository_inspected' && |
| value.objectFormat === 'sha1' && |
| typeof value.headCommitOid === 'string' && |
| SHA1_OID_PATTERN.test(value.headCommitOid) && |
| typeof value.headTreeOid === 'string' && |
| SHA1_OID_PATTERN.test(value.headTreeOid) |
| ); |
| } |
| |
| function isRepositoryRejection(value: unknown): value is GitoxideRepositoryRejectionV1 { |
| return ( |
| hasExactKeys(value, [ |
| 'protocolVersion', |
| 'kind', |
| 'reason', |
| 'objectFormat', |
| 'supportedObjectFormats', |
| ]) && |
| value.protocolVersion === 1 && |
| value.kind === 'repository_rejected' && |
| value.reason === 'unsupported_object_format' && |
| (value.objectFormat === 'sha256' || value.objectFormat === 'unknown') && |
| Array.isArray(value.supportedObjectFormats) && |
| value.supportedObjectFormats.length === 1 && |
| value.supportedObjectFormats[0] === 'sha1' |
| ); |
| } |
| |
| function isHelperError(value: unknown): value is { |
| readonly protocolVersion: 1; |
| readonly kind: 'helper_error'; |
| readonly reason: string; |
| } { |
| return ( |
| hasExactKeys(value, ['protocolVersion', 'kind', 'reason']) && |
| value.protocolVersion === 1 && |
| value.kind === 'helper_error' && |
| typeof value.reason === 'string' && |
| HELPER_ERROR_REASONS.has(value.reason) |
| ); |
| } |
| |
| function hasExactKeys( |
| value: unknown, |
| expectedKeys: readonly string[], |
| ): value is Record<string, unknown> { |
| if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; |
| const keys = Object.keys(value).sort(); |
| const expected = [...expectedKeys].sort(); |
| return keys.length === expected.length && keys.every((key, index) => key === expected[index]); |
| } |
| |
| function helperEnvironment(): NodeJS.ProcessEnv { |
| return { |
| PATH: '', |
| ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), |
| ...(process.env.WINDIR ? { WINDIR: process.env.WINDIR } : {}), |
| ...(process.env.TMP ? { TMP: process.env.TMP } : {}), |
| ...(process.env.TEMP ? { TEMP: process.env.TEMP } : {}), |
| ...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}), |
| }; |
| } |
| |
| function throwIfAborted(signal: AbortSignal | undefined): void { |
| if (!signal?.aborted) return; |
| throw new GitoxideHelperInvocationError( |
| 'gitoxide_helper_invocation_aborted', |
| 'Gitoxide helper invocation was aborted', |
| ); |
| } |
| |
| function terminationMessage( |
| code: |
| | 'gitoxide_helper_invocation_timed_out' |
| | 'gitoxide_helper_invocation_aborted' |
| | 'gitoxide_helper_invocation_output_too_large', |
| ): string { |
| if (code === 'gitoxide_helper_invocation_timed_out') |
| return 'Gitoxide helper invocation timed out'; |
| if (code === 'gitoxide_helper_invocation_aborted') |
| return 'Gitoxide helper invocation was aborted'; |
| return 'Gitoxide helper output exceeded its byte limit'; |
| } |
| |
| function protocolInvalid(message: string): GitoxideHelperInvocationError { |
| return new GitoxideHelperInvocationError('gitoxide_helper_invocation_protocol_invalid', message); |
| } |