{"version":3,"file":"fixtures-remote.cjs","names":["BlockList"],"sources":["../src/fixtures-remote.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\nimport { mkdirSync, writeFileSync, statSync } from \"node:fs\";\nimport { lookup as dnsLookup } from \"node:dns/promises\";\nimport { BlockList, isIP } from \"node:net\";\nimport { homedir } from \"node:os\";\nimport { join, resolve as pathResolve } from \"node:path\";\nimport type { Logger } from \"./logger.js\";\n\nexport const REMOTE_FETCH_TIMEOUT_MS = 10_000;\nexport const REMOTE_MAX_BYTES = 50 * 1024 * 1024; // 50 MB\n\n/**\n * Private / reserved address ranges blocked by default to prevent SSRF.\n *\n * The list covers RFC1918 / CGNAT / loopback / link-local / cloud-metadata /\n * ULA / multicast / unspecified — any destination that could let an attacker\n * pivot a fetch into the local network or cloud control plane via a hostile\n * `--fixtures` URL.  Set `AIMOCK_ALLOW_PRIVATE_URLS=1` to opt out (required\n * for local dev / tests that target 127.0.0.1).\n */\nconst PRIVATE_V4_RANGES: Array<[string, number]> = [\n  [\"0.0.0.0\", 8], // \"this network\"\n  [\"10.0.0.0\", 8], // RFC1918\n  [\"100.64.0.0\", 10], // CGNAT\n  [\"127.0.0.0\", 8], // loopback\n  [\"169.254.0.0\", 16], // link-local / cloud metadata\n  [\"172.16.0.0\", 12], // RFC1918\n  [\"192.0.0.0\", 24], // IETF protocol assignments\n  [\"192.0.2.0\", 24], // TEST-NET-1\n  [\"192.88.99.0\", 24], // 6to4 relay anycast (deprecated)\n  [\"192.168.0.0\", 16], // RFC1918\n  [\"198.18.0.0\", 15], // benchmarking\n  [\"198.51.100.0\", 24], // TEST-NET-2\n  [\"203.0.113.0\", 24], // TEST-NET-3\n  [\"224.0.0.0\", 4], // multicast\n  [\"240.0.0.0\", 4], // reserved\n  [\"255.255.255.255\", 32], // broadcast\n];\n\nconst PRIVATE_V6_RANGES: Array<[string, number]> = [\n  [\"::\", 128], // unspecified\n  [\"::1\", 128], // loopback\n  [\"fc00::\", 7], // ULA\n  [\"fe80::\", 10], // link-local\n];\n\nfunction buildBlockList(): BlockList {\n  const bl = new BlockList();\n  for (const [addr, prefix] of PRIVATE_V4_RANGES) bl.addSubnet(addr, prefix, \"ipv4\");\n  for (const [addr, prefix] of PRIVATE_V6_RANGES) bl.addSubnet(addr, prefix, \"ipv6\");\n  return bl;\n}\n\nconst PRIVATE_BLOCKLIST: BlockList = buildBlockList();\n\n/**\n * Returns true if `address` is a literal IP (v4 or v6) that falls in any\n * blocked range (loopback, RFC1918, CGNAT, link-local, cloud-metadata,\n * ULA, multicast, unspecified, reserved).  Returns false for public IPs\n * and for non-literal hostnames.\n */\nexport function isPrivateAddress(address: string): boolean {\n  const family = isIP(address);\n  if (family === 0) return false; // not a literal IP\n  // BlockList.check's \"ipv6\" bucket does not match v4-mapped ::ffff:a.b.c.d\n  // automatically — unwrap to the underlying v4 address and recurse.\n  if (family === 6) {\n    const lower = address.toLowerCase();\n    const mapped = lower.match(/^::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/);\n    if (mapped) return isPrivateAddress(mapped[1]);\n  }\n  return PRIVATE_BLOCKLIST.check(address, family === 4 ? \"ipv4\" : \"ipv6\");\n}\n\nfunction privateUrlsAllowed(): boolean {\n  const v = process.env.AIMOCK_ALLOW_PRIVATE_URLS;\n  return v === \"1\" || v === \"true\";\n}\n\n/**\n * Throws if `hostname` resolves to (or literally is) a private / reserved\n * address, unless `AIMOCK_ALLOW_PRIVATE_URLS=1` is set.  If the hostname is\n * not a literal IP, all resolved addresses are checked — any blocked\n * address in the set rejects the host.\n */\nexport async function assertAllowedHost(hostname: string): Promise<void> {\n  if (privateUrlsAllowed()) return;\n\n  if (isIP(hostname) !== 0) {\n    if (isPrivateAddress(hostname)) {\n      throw new Error(\n        `Refusing to fetch from private address ${hostname}: not allowed by default (set AIMOCK_ALLOW_PRIVATE_URLS=1 to override)`,\n      );\n    }\n    return;\n  }\n\n  let addresses: Array<{ address: string; family: number }>;\n  try {\n    addresses = await dnsLookup(hostname, { all: true });\n  } catch (err) {\n    // DNS failure is not an SSRF signal — let the fetch itself surface the\n    // resolution error with its own (more detailed) message.\n    void err;\n    return;\n  }\n  for (const a of addresses) {\n    if (isPrivateAddress(a.address)) {\n      throw new Error(\n        `Refusing to fetch from ${hostname}: resolves to private address ${a.address} (set AIMOCK_ALLOW_PRIVATE_URLS=1 to override)`,\n      );\n    }\n  }\n}\n\nexport interface RemoteResolveOptions {\n  validateOnLoad: boolean;\n  logger: Logger;\n  /** Override fetch implementation (tests). */\n  fetchImpl?: typeof fetch;\n  /** Override cache root (tests). */\n  cacheRoot?: string;\n  /** Override timeout (tests). */\n  timeoutMs?: number;\n  /** Override max response size (tests). */\n  maxBytes?: number;\n}\n\nexport interface ResolvedLocalFixture {\n  /** Original value as passed on the CLI (for logging). */\n  source: string;\n  /** Filesystem path — downstream code treats this identically to a --fixtures path. */\n  path: string;\n}\n\n/**\n * Returns true if `value` looks like a URL (has a scheme followed by ://).\n * Path inputs like ./fixtures or /tmp/x never start with a scheme.\n */\nexport function looksLikeUrl(value: string): boolean {\n  return /^[a-zA-Z][a-zA-Z0-9+.-]*:\\/\\//.test(value);\n}\n\n/**\n * Returns the default on-disk cache root for fetched fixtures.\n * Honors $XDG_CACHE_HOME when set, otherwise falls back to ~/.cache.\n */\nexport function defaultCacheRoot(): string {\n  const xdg = process.env.XDG_CACHE_HOME;\n  const base = xdg && xdg.length > 0 ? xdg : join(homedir(), \".cache\");\n  return join(base, \"aimock\", \"fixtures\");\n}\n\nfunction sha256Hex(input: string): string {\n  return createHash(\"sha256\").update(input).digest(\"hex\");\n}\n\n/**\n * Resolve a single --fixtures value to a local filesystem path.\n *\n * Behavior:\n * - Filesystem path → return as-is.\n * - https://, http:// URL → fetch JSON (once) to the on-disk cache; return the cached path.\n *   On fetch failure, fall back to a pre-existing cached copy if present (warn + continue).\n *   If --validate-on-load is set and no cache is usable, throws.\n * - Any other scheme (file://, ftp://, ...) → throws.\n */\nexport async function resolveFixturesValue(\n  value: string,\n  opts: RemoteResolveOptions,\n): Promise<ResolvedLocalFixture> {\n  if (!looksLikeUrl(value)) {\n    return { source: value, path: pathResolve(value) };\n  }\n\n  const lower = value.toLowerCase();\n  if (!lower.startsWith(\"https://\") && !lower.startsWith(\"http://\")) {\n    // Extract the scheme for a clearer error\n    const match = value.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\\/\\//);\n    const scheme = match ? match[1] : \"unknown\";\n    throw new Error(\n      `Unsupported --fixtures URL scheme \"${scheme}\" in ${value} (only https:// and http:// are supported)`,\n    );\n  }\n\n  return await resolveHttpFixture(value, opts);\n}\n\nasync function resolveHttpFixture(\n  url: string,\n  opts: RemoteResolveOptions,\n): Promise<ResolvedLocalFixture> {\n  const fetchImpl = opts.fetchImpl ?? fetch;\n  const cacheRoot = opts.cacheRoot ?? defaultCacheRoot();\n  const timeoutMs = opts.timeoutMs ?? REMOTE_FETCH_TIMEOUT_MS;\n  const maxBytes = opts.maxBytes ?? REMOTE_MAX_BYTES;\n\n  const digest = sha256Hex(url);\n  const cacheDir = join(cacheRoot, digest);\n  const cacheFile = join(cacheDir, \"fixtures.json\");\n\n  try {\n    // SSRF defense: reject private / reserved destinations before any network\n    // I/O, unless explicitly opted in via AIMOCK_ALLOW_PRIVATE_URLS=1.\n    const parsed = new URL(url);\n    await assertAllowedHost(parsed.hostname);\n\n    const body = await fetchWithLimits(url, fetchImpl, timeoutMs, maxBytes);\n    // Parse to verify it is valid JSON before caching — fail loud if not.\n    JSON.parse(body);\n    mkdirSync(cacheDir, { recursive: true });\n    writeFileSync(cacheFile, body, \"utf-8\");\n    opts.logger.info(`Fetched ${url} (${body.length} bytes) → cached at ${cacheFile}`);\n    return { source: url, path: cacheFile };\n  } catch (err) {\n    const msg = err instanceof Error ? err.message : String(err);\n    const cacheExists = cacheFileExists(cacheFile);\n    if (cacheExists) {\n      opts.logger.warn(\n        `upstream fetch failed for ${url} (${msg}); using cached copy at ${cacheFile}`,\n      );\n      return { source: url, path: cacheFile };\n    }\n    if (opts.validateOnLoad) {\n      throw new Error(`Failed to fetch ${url} and no cached copy available: ${msg}`);\n    }\n    opts.logger.warn(\n      `upstream fetch failed for ${url} (${msg}); no cached copy available — skipping`,\n    );\n    // Signal \"no path\" by returning a sentinel with empty path — callers detect and skip.\n    return { source: url, path: \"\" };\n  }\n}\n\nfunction cacheFileExists(file: string): boolean {\n  try {\n    return statSync(file).isFile();\n  } catch {\n    return false;\n  }\n}\n\nasync function fetchWithLimits(\n  url: string,\n  fetchImpl: typeof fetch,\n  timeoutMs: number,\n  maxBytes: number,\n): Promise<string> {\n  const controller = new AbortController();\n  const timer = setTimeout(() => controller.abort(new Error(\"timeout\")), timeoutMs);\n  try {\n    // Redirects are disabled: following a 3xx into a different scheme or host\n    // would bypass the scheme check and SSRF denylist.  Upstream services\n    // should serve the final URL directly (e.g. GitHub raw content URLs).\n    const res = await fetchImpl(url, {\n      signal: controller.signal,\n      redirect: \"manual\",\n    });\n    if (res.status >= 300 && res.status < 400) {\n      const location = res.headers.get(\"location\") ?? \"<none>\";\n      throw new Error(\n        `redirect not allowed: upstream returned ${res.status} → ${location} (configure the upstream to serve the final URL directly; redirects are disabled to prevent scheme-bypass)`,\n      );\n    }\n    if (!res.ok) {\n      throw new Error(`HTTP ${res.status} ${res.statusText}`);\n    }\n    // Early reject on over-large Content-Length when the server reports it.\n    const len = res.headers.get(\"content-length\");\n    if (len) {\n      const n = Number(len);\n      if (Number.isFinite(n) && n > maxBytes) {\n        throw new Error(`response too large: content-length ${n} exceeds limit ${maxBytes} bytes`);\n      }\n    }\n\n    // Stream and enforce the limit incrementally in case Content-Length is absent/lying.\n    if (!res.body) {\n      const text = await res.text();\n      if (Buffer.byteLength(text, \"utf-8\") > maxBytes) {\n        throw new Error(`response too large: body exceeds limit ${maxBytes} bytes`);\n      }\n      return text;\n    }\n\n    const reader = res.body.getReader();\n    const chunks: Uint8Array[] = [];\n    let total = 0;\n    for (;;) {\n      const { value, done } = await reader.read();\n      if (done) break;\n      if (value) {\n        total += value.byteLength;\n        if (total > maxBytes) {\n          try {\n            await reader.cancel();\n          } catch {\n            // ignore cancel errors\n          }\n          throw new Error(`response too large: body exceeds limit ${maxBytes} bytes`);\n        }\n        chunks.push(value);\n      }\n    }\n    return Buffer.concat(chunks).toString(\"utf-8\");\n  } catch (err) {\n    if (err instanceof Error && (err.name === \"AbortError\" || err.message === \"timeout\")) {\n      throw new Error(`fetch timed out after ${timeoutMs}ms`);\n    }\n    throw err;\n  } finally {\n    clearTimeout(timer);\n  }\n}\n"],"mappings":"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"}