/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: URL parsing helpers throw into Effect and Promise adapters */export interface GitUrlPolicy { readonly allowPrivateHosts?: boolean;} const TOKEN_QUERY_KEYS = /(?:token|access[_-]?token|auth|authorization|key|secret|password|credential)/i; export const redactUrl = (input: string): string => { try { const url = new URL(input); url.username = ""; url.password = ""; url.search = ""; url.hash = ""; return url.toString(); } catch { return input.replace(/\/\/[^/@\s]+@/g, "//").replace(/[?#].*$/g, ""); }}; export const hasTokenLikeQuery = (url: URL): boolean => { for (const key of url.searchParams.keys()) { if (TOKEN_QUERY_KEYS.test(key)) return true; } return false;}; const normalizeHost = (host: string): string => host.replace(/^\[|\]$/g, "").toLowerCase(); const parseDecimalIpv4 = (host: string): string | null => { if (!/^[0-9]+$/.test(host)) return null; const value = Number(host); if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) return null; return [ Math.floor(value / 16777216) & 255, Math.floor(value / 65536) & 255, Math.floor(value / 256) & 255, value & 255, ].join(".");}; const parseSegmentedIpv4 = (host: string): string | null => { const parts = host.split("."); if (parts.length < 2 || parts.length > 4) return null; const nums = parts.map((part) => { if (/^0x[0-9a-f]+$/i.test(part)) return Number.parseInt(part.slice(2), 16); if (/^0[0-7]+$/.test(part)) return Number.parseInt(part.slice(1), 8); if (/^[0-9]+$/.test(part)) return Number.parseInt(part, 10); return Number.NaN; }); if (nums.some((part) => !Number.isInteger(part) || part < 0)) return null; const maxTail = [0, 0xffffff, 0xffff, 0xff][parts.length - 1] ?? 0xff; if (nums.slice(0, -1).some((part) => part > 255) || nums[nums.length - 1]! > maxTail) { return null; } let value = 0; for (let index = 0; index < nums.length - 1; index += 1) { value = value * 256 + nums[index]!; } const tailBytes = 5 - nums.length; value = value * 256 ** tailBytes + nums[nums.length - 1]!; return [ Math.floor(value / 16777216) & 255, Math.floor(value / 65536) & 255, Math.floor(value / 256) & 255, value & 255, ].join(".");}; const canonicalIpv4 = (host: string): string | null => { const lower = host.toLowerCase(); if (/^[0-9]+$/.test(lower)) return parseDecimalIpv4(lower); if (/^(?:0x[0-9a-f]+|0[0-7]+|[0-9]+)(?:\.(?:0x[0-9a-f]+|0[0-7]+|[0-9]+)){1,3}$/i.test(lower)) { return parseSegmentedIpv4(lower); } if (/^[0-9]{1,3}(?:\.[0-9]{1,3}){3}$/.test(lower)) return lower; return null;}; const ipv4FromMappedIpv6 = (host: string): string | null => { const normalized = normalizeHost(host); if (!normalized.startsWith("::ffff:")) return null; const tail = normalized.slice("::ffff:".length); if (tail.includes(".")) return canonicalIpv4(tail); if (tail.includes(":")) { const parts = tail.split(":"); if (parts.length !== 2 || parts.some((part) => !/^[0-9a-f]{1,4}$/i.test(part))) return null; const value = Number.parseInt(parts[0]!, 16) * 65536 + Number.parseInt(parts[1]!, 16); return [ Math.floor(value / 16777216) & 255, Math.floor(value / 65536) & 255, Math.floor(value / 256) & 255, value & 255, ].join("."); } const compact = tail.replace(/:/g, ""); if (!/^[0-9a-f]{8}$/i.test(compact)) return null; const value = Number.parseInt(compact, 16); return [ Math.floor(value / 16777216) & 255, Math.floor(value / 65536) & 255, Math.floor(value / 256) & 255, value & 255, ].join(".");}; const isPrivateIpv4 = (host: string): boolean => { const canonical = canonicalIpv4(host); if (!canonical) return false; const nums = canonical.split(".").map((part) => Number(part)); const [a, b] = nums; return ( a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || a === 0 );}; export const isPrivateGitHost = (host: string): boolean => { const normalized = normalizeHost(host); const mapped = ipv4FromMappedIpv6(normalized); return ( normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "::1" || normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd") || isPrivateIpv4(mapped ?? normalized) );}; export const validateGitFetchUrl = (input: string, policy: GitUrlPolicy = {}): URL => { const url = new URL(input); const privateHost = isPrivateGitHost(url.hostname); if ( url.protocol !== "https:" && !(url.protocol === "http:" && policy.allowPrivateHosts === true && privateHost) ) { throw new Error("git source URL must use https"); } if (url.username || url.password) throw new Error("git source URL must not include embedded credentials"); if (hasTokenLikeQuery(url)) throw new Error("git source URL must not include token query parameters"); if (policy.allowPrivateHosts !== true && privateHost) { throw new Error("git source URL host is not allowed"); } return url;};
/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: URL parsing helpers throw into Effect and Promise adapters */
export interface GitUrlPolicy {
readonly allowPrivateHosts?: boolean;
}
const TOKEN_QUERY_KEYS =
/(?:token|access[_-]?token|auth|authorization|key|secret|password|credential)/i;
export const redactUrl = (input: string): string => {
try {
const url = new URL(input);
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
return url.toString();
} catch {
return input.replace(/\/\/[^/@\s]+@/g, "//").replace(/[?#].*$/g, "");
}
};
export const hasTokenLikeQuery = (url: URL): boolean => {
for (const key of url.searchParams.keys()) {
if (TOKEN_QUERY_KEYS.test(key)) return true;
}
return false;
};
const normalizeHost = (host: string): string => host.replace(/^\[|\]$/g, "").toLowerCase();
const parseDecimalIpv4 = (host: string): string | null => {
if (!/^[0-9]+$/.test(host)) return null;
const value = Number(host);
if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) return null;
return [
Math.floor(value / 16777216) & 255,
Math.floor(value / 65536) & 255,
Math.floor(value / 256) & 255,
value & 255,
].join(".");
};
const parseSegmentedIpv4 = (host: string): string | null => {
const parts = host.split(".");
if (parts.length < 2 || parts.length > 4) return null;
const nums = parts.map((part) => {
if (/^0x[0-9a-f]+$/i.test(part)) return Number.parseInt(part.slice(2), 16);
if (/^0[0-7]+$/.test(part)) return Number.parseInt(part.slice(1), 8);
if (/^[0-9]+$/.test(part)) return Number.parseInt(part, 10);
return Number.NaN;
});
if (nums.some((part) => !Number.isInteger(part) || part < 0)) return null;
const maxTail = [0, 0xffffff, 0xffff, 0xff][parts.length - 1] ?? 0xff;
if (nums.slice(0, -1).some((part) => part > 255) || nums[nums.length - 1]! > maxTail) {
return null;
}
let value = 0;
for (let index = 0; index < nums.length - 1; index += 1) {
value = value * 256 + nums[index]!;
}
const tailBytes = 5 - nums.length;
value = value * 256 ** tailBytes + nums[nums.length - 1]!;
return [
Math.floor(value / 16777216) & 255,
Math.floor(value / 65536) & 255,
Math.floor(value / 256) & 255,
value & 255,
].join(".");
};
const canonicalIpv4 = (host: string): string | null => {
const lower = host.toLowerCase();
if (/^[0-9]+$/.test(lower)) return parseDecimalIpv4(lower);
if (/^(?:0x[0-9a-f]+|0[0-7]+|[0-9]+)(?:\.(?:0x[0-9a-f]+|0[0-7]+|[0-9]+)){1,3}$/i.test(lower)) {
return parseSegmentedIpv4(lower);
}
if (/^[0-9]{1,3}(?:\.[0-9]{1,3}){3}$/.test(lower)) return lower;
return null;
};
const ipv4FromMappedIpv6 = (host: string): string | null => {
const normalized = normalizeHost(host);
if (!normalized.startsWith("::ffff:")) return null;
const tail = normalized.slice("::ffff:".length);
if (tail.includes(".")) return canonicalIpv4(tail);
if (tail.includes(":")) {
const parts = tail.split(":");
if (parts.length !== 2 || parts.some((part) => !/^[0-9a-f]{1,4}$/i.test(part))) return null;
const value = Number.parseInt(parts[0]!, 16) * 65536 + Number.parseInt(parts[1]!, 16);
return [
Math.floor(value / 16777216) & 255,
Math.floor(value / 65536) & 255,
Math.floor(value / 256) & 255,
value & 255,
].join(".");
}
const compact = tail.replace(/:/g, "");
if (!/^[0-9a-f]{8}$/i.test(compact)) return null;
const value = Number.parseInt(compact, 16);
return [
Math.floor(value / 16777216) & 255,
Math.floor(value / 65536) & 255,
Math.floor(value / 256) & 255,
value & 255,
].join(".");
};
const isPrivateIpv4 = (host: string): boolean => {
const canonical = canonicalIpv4(host);
if (!canonical) return false;
const nums = canonical.split(".").map((part) => Number(part));
const [a, b] = nums;
return (
a === 10 ||
a === 127 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254) ||
a === 0
);
};
export const isPrivateGitHost = (host: string): boolean => {
const normalized = normalizeHost(host);
const mapped = ipv4FromMappedIpv6(normalized);
return (
normalized === "localhost" ||
normalized.endsWith(".localhost") ||
normalized === "::1" ||
normalized.startsWith("fe80:") ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
isPrivateIpv4(mapped ?? normalized)
);
};
export const validateGitFetchUrl = (input: string, policy: GitUrlPolicy = {}): URL => {
const url = new URL(input);
const privateHost = isPrivateGitHost(url.hostname);
if (
url.protocol !== "https:" &&
!(url.protocol === "http:" && policy.allowPrivateHosts === true && privateHost)
) {
throw new Error("git source URL must use https");
}
if (url.username || url.password)
throw new Error("git source URL must not include embedded credentials");
if (hasTokenLikeQuery(url))
throw new Error("git source URL must not include token query parameters");
if (policy.allowPrivateHosts !== true && privateHost) {
throw new Error("git source URL host is not allowed");
}
return url;
};