mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-02 21:58:06 +00:00
79b86ddcd4
* fix(security): harden auth and outbound targets * fix(security): prevent login lockout and honor trusted schemes
249 lines
12 KiB
TypeScript
249 lines
12 KiB
TypeScript
/**
|
|
* Classification of native-git failures into the public GitSourceErrorCode
|
|
* contract.
|
|
*
|
|
* Deliberately class-free: this module returns plain {code, message} data and
|
|
* never imports GitSourceService, so the dependency graph stays one-way
|
|
* (service -> git/*) and the classifier is unit-testable in isolation. The
|
|
* service wraps the returned pair in its own GitSourceError.
|
|
*
|
|
* Two behaviors are contractual and pinned by tests; do not change them:
|
|
* 1. Authentication failure WITH a supplied token reports AUTH_FAILED,
|
|
* which the HTTP layer maps to 400, never 401, because the frontend's
|
|
* global logout trips on any API-level 401.
|
|
* 2. A 401/403-shaped refusal WITHOUT a token reports REPO_NOT_FOUND with
|
|
* a private-repo hint, mirroring GitHub's masking of private repos.
|
|
*/
|
|
|
|
export type TransportFacingCode =
|
|
| 'REPO_NOT_FOUND'
|
|
| 'AUTH_FAILED'
|
|
| 'SSH_HOST_KEY_FAILED'
|
|
| 'REF_NOT_FOUND'
|
|
| 'UNSUPPORTED_REF'
|
|
| 'NETWORK_TIMEOUT'
|
|
| 'GIT_ERROR';
|
|
|
|
/** Structured failure raised by the native transport; classified below. */
|
|
export type TransportFailureReason =
|
|
| 'invalid-url'
|
|
| 'unsafe-target'
|
|
| 'target-unresolved'
|
|
| 'ssh-auth-required'
|
|
| 'invalid-ref'
|
|
| 'git-missing'
|
|
| 'git-old'
|
|
| 'ref-not-found'
|
|
| 'unsupported-ref'
|
|
| 'tip-changed'
|
|
| 'size'
|
|
| 'timeout'
|
|
| 'redirect-scope'
|
|
| 'exit';
|
|
|
|
interface TransportFailureBase {
|
|
/** Branded discriminant so isTransportFailure cannot false-positive on foreign errors. */
|
|
readonly transportFailure: true;
|
|
host: string;
|
|
hasToken: boolean;
|
|
}
|
|
|
|
/**
|
|
* Discriminated on `reason`: each variant carries exactly the payload its
|
|
* classifier branch needs (e.g. `size` must always know `maxBytes`, so the
|
|
* operator-facing breach message can never render "0 B").
|
|
*/
|
|
export type TransportFailure = TransportFailureBase & (
|
|
| { reason: 'invalid-url' }
|
|
| { reason: 'unsafe-target' }
|
|
| { reason: 'target-unresolved' }
|
|
| { reason: 'ssh-auth-required' }
|
|
| { reason: 'invalid-ref' }
|
|
| { reason: 'git-missing'; stderr?: string }
|
|
| { reason: 'git-old'; stderr?: string }
|
|
| { reason: 'ref-not-found' }
|
|
| { reason: 'unsupported-ref' }
|
|
| { reason: 'tip-changed' }
|
|
| { reason: 'size'; maxBytes: number }
|
|
| { reason: 'timeout' }
|
|
| { reason: 'redirect-scope' }
|
|
| { reason: 'exit'; stderr?: string; exitCode?: number; /** Full child argv, attached for debug diagnostics only. */ argv?: string[] }
|
|
);
|
|
|
|
/**
|
|
* Defensive redaction mirroring GitSourceService.scrubCredentials. Kept local
|
|
* instead of imported to preserve the one-way dependency direction; git never
|
|
* receives credentials via URL, so this only guards against operators pasting
|
|
* user:pass@ URLs that servers echo back in error text.
|
|
*/
|
|
function redactCredentials(text: string): string {
|
|
return text
|
|
.replace(/https?:\/\/[^/\s:@]+:[^/\s@]+@/gi, 'https://***:***@')
|
|
.replace(/(authorization[:=]\s*)[^\s,;]+/gi, '$1***')
|
|
.replace(/(token[:=]\s*)[^\s,;]+/gi, '$1***')
|
|
.replace(/(password[:=]\s*)[^\s,;]+/gi, '$1***');
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`;
|
|
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
return `${bytes} B`;
|
|
}
|
|
|
|
function hostQualifier(host: string): string {
|
|
return host && host !== 'unknown' ? ` ${host}` : ' the repository host';
|
|
}
|
|
|
|
/** Shared hint for refusals where a private repo and a missing one are indistinguishable. */
|
|
const PRIVATE_REPO_HINT = 'Repository not found, or it is private. Add a Personal Access Token if the repo is private.';
|
|
|
|
/** Last few stderr lines, scrubbed, for the generic GIT_ERROR fallback. */
|
|
function stderrTail(stderr: string | undefined): string {
|
|
if (!stderr) return '';
|
|
const lines = redactCredentials(stderr).trim().split(/\r?\n/).filter(Boolean);
|
|
return lines.slice(-3).join(' ').slice(0, 400);
|
|
}
|
|
|
|
export function classifyGitFailure(
|
|
failure: TransportFailure,
|
|
): { code: TransportFacingCode; message: string } {
|
|
const dest = hostQualifier(failure.host);
|
|
|
|
// Structured outcomes decided by the transport itself, before any
|
|
// stderr guessing.
|
|
switch (failure.reason) {
|
|
case 'invalid-url':
|
|
return { code: 'GIT_ERROR', message: 'Unsupported repository URL. Use https:// or SSH (git@host:org/repo.git or ssh://) without embedded credentials.' };
|
|
case 'unsafe-target':
|
|
return { code: 'GIT_ERROR', message: 'Repository host is not allowed.' };
|
|
case 'target-unresolved':
|
|
return { code: 'NETWORK_TIMEOUT', message: `Could not resolve${dest}. Check the repository URL and your network or DNS.` };
|
|
case 'ssh-auth-required':
|
|
return { code: 'GIT_ERROR', message: 'SSH repository URLs require a deploy key.' };
|
|
case 'invalid-ref':
|
|
return { code: 'GIT_ERROR', message: 'Unsupported ref name. Use a branch name, a tag name, or a full commit SHA as the remote reports it.' };
|
|
case 'git-missing':
|
|
return { code: 'GIT_ERROR', message: failure.stderr || 'The git command was not found on PATH.' };
|
|
case 'git-old':
|
|
return { code: 'GIT_ERROR', message: failure.stderr || 'The installed git client is too old.' };
|
|
case 'ref-not-found':
|
|
return { code: 'REF_NOT_FOUND', message: 'The configured branch, tag, or commit was not found in the repository.' };
|
|
case 'unsupported-ref':
|
|
return { code: 'UNSUPPORTED_REF', message: 'The configured commit is not reachable on this repository host. Use a branch or tag, or a commit the host advertises.' };
|
|
case 'tip-changed':
|
|
return { code: 'GIT_ERROR', message: 'Repository tip changed during fetch; retry the pull.' };
|
|
case 'size':
|
|
return {
|
|
code: 'GIT_ERROR',
|
|
message: `Repository exceeds the maximum clone size of ${formatBytes(failure.maxBytes)}.`,
|
|
};
|
|
case 'timeout':
|
|
return { code: 'NETWORK_TIMEOUT', message: `Timed out reaching${dest}.` };
|
|
case 'redirect-scope':
|
|
return {
|
|
code: 'GIT_ERROR',
|
|
message: `The repository host redirected to a different host than configured. Credentials were not sent to the redirect target. Use the final repository URL directly, or contact the server operator.`,
|
|
};
|
|
default:
|
|
break;
|
|
}
|
|
|
|
const raw = redactCredentials((failure.stderr ?? '').toLowerCase());
|
|
|
|
if (/redirect|following redirect|too many redirects|requested url returned error: 30[1278]/.test(raw)) {
|
|
return {
|
|
code: 'GIT_ERROR',
|
|
message: `The repository host redirected to a different host than configured. Credentials were not sent to the redirect target. Use the final repository URL directly, or contact the server operator.`,
|
|
};
|
|
}
|
|
|
|
// Auth-shaped refusals. Native git phrases these two ways: with a token
|
|
// it gets "Authentication failed for '<url>'"; without one it cannot even
|
|
// answer and reports the disabled terminal prompt.
|
|
if (/could not read username|could not read password/.test(raw)) {
|
|
// Prompting was suppressed, so the host refused the credentials it
|
|
// was given (possibly none): mask like GitHub hides private repos.
|
|
return {
|
|
code: 'REPO_NOT_FOUND',
|
|
message: PRIVATE_REPO_HINT,
|
|
};
|
|
}
|
|
if (/host key verification failed|remotely changed the ssh host key|no matching host key found|offending key for ip|host key mismatch/.test(raw)) {
|
|
return {
|
|
code: 'SSH_HOST_KEY_FAILED',
|
|
message: 'SSH host key verification failed. The server key changed or is not trusted. Review the fingerprint and update host trust if you intend to accept the new key.',
|
|
};
|
|
}
|
|
if (/permission denied \(publickey|publickey denied|no supported authentication methods/.test(raw)) {
|
|
return failure.hasToken
|
|
? { code: 'AUTH_FAILED', message: 'Repository authentication failed. Check your deploy key or token.' }
|
|
: {
|
|
code: 'REPO_NOT_FOUND',
|
|
message: PRIVATE_REPO_HINT,
|
|
};
|
|
}
|
|
if (/authentication failed|\b40[13]\b/.test(raw)) {
|
|
return failure.hasToken
|
|
? { code: 'AUTH_FAILED', message: 'Repository authentication failed. Check your token.' }
|
|
: {
|
|
code: 'REPO_NOT_FOUND',
|
|
message: PRIVATE_REPO_HINT,
|
|
};
|
|
}
|
|
if (/remote branch .+ not found in upstream|branch not found/.test(raw)) {
|
|
return { code: 'REF_NOT_FOUND', message: 'The configured branch, tag, or commit was not found in the repository.' };
|
|
}
|
|
// A host that refuses to serve an unadvertised object (SHA fetch without
|
|
// allowAnySHA1InWant/allowReachableSHA1InWant) still exits non-zero, but
|
|
// the failure is about server capability, not the SHA existing. Hosts word
|
|
// the refusal differently (GitHub vs GitLab/Gitea), so match stable phrases
|
|
// rather than one vendor's full sentence.
|
|
if (/unadvertised object|not our ref/.test(raw)) {
|
|
return { code: 'UNSUPPORTED_REF', message: 'The configured commit is not reachable on this repository host. Use a branch or tag, or a commit the host advertises.' };
|
|
}
|
|
if (/repository[\s\S]*\bnot found\b|not found in upstream/.test(raw)) {
|
|
return {
|
|
code: 'REPO_NOT_FOUND',
|
|
message: failure.hasToken
|
|
? 'Repository not found. Verify the URL and that your token has read access to this repo.'
|
|
: PRIVATE_REPO_HINT,
|
|
};
|
|
}
|
|
|
|
// TLS failures before generic network wording, so certificate problems do
|
|
// not read as connectivity problems.
|
|
if (/certificate has expired|certificate is not yet valid/.test(raw)) {
|
|
return { code: 'GIT_ERROR', message: `TLS certificate error reaching${dest}. The host certificate is expired or not yet valid.` };
|
|
}
|
|
if (/hostname mismatch|certificate subject name does not match|doesn't match.*altnames|subject alternative name|no alternative certificate subject name/.test(raw)) {
|
|
return { code: 'GIT_ERROR', message: `TLS certificate error reaching${dest}. The certificate hostname does not match the repository URL.` };
|
|
}
|
|
if (/ssl certificate problem|server certificate verification failed|unable to get local issuer certificate|self[- ]signed certificate|unknown ca|certificate signed by unknown authority/.test(raw)) {
|
|
return { code: 'GIT_ERROR', message: `TLS certificate error reaching${dest}. The host certificate could not be verified. If this server uses a private CA, upload the CA certificate on the git source.` };
|
|
}
|
|
|
|
// Network family.
|
|
if (/could not resolve host|name or service not known|temporary failure in name resolution/.test(raw)) {
|
|
return { code: 'NETWORK_TIMEOUT', message: `Could not resolve${dest}. Check the repository URL and your network or DNS.` };
|
|
}
|
|
if (/connection refused|could not connect to server/.test(raw)) {
|
|
return { code: 'NETWORK_TIMEOUT', message: `Connection refused by${dest}.` };
|
|
}
|
|
if (/connection timed out|operation timed out|connection was reset|remote end hung up|connection reset by peer/.test(raw)) {
|
|
return { code: 'NETWORK_TIMEOUT', message: `Connection to${dest} failed. Retry; if it persists, check the host or your network.` };
|
|
}
|
|
|
|
const tail = stderrTail(failure.stderr);
|
|
return { code: 'GIT_ERROR', message: tail ? `Git fetch failed: ${tail}` : 'Git fetch failed.' };
|
|
}
|
|
|
|
/**
|
|
* Structural type guard. The branded `transportFailure` discriminant makes
|
|
* false positives impossible: arbitrary errors that happen to carry
|
|
* reason/host fields are not mistaken for transport failures at the service
|
|
* boundary.
|
|
*/
|
|
export function isTransportFailure(e: unknown): e is TransportFailure {
|
|
return typeof e === 'object' && e !== null && (e as { transportFailure?: unknown }).transportFailure === true;
|
|
}
|