文件预览

feed.mjs

查看 hermes-attestation-guardian 技能包中的文件内容。

文件内容

lib/feed.mjs

import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { parseAffectedSpecifier, parseVersionSpec } from "./semver.mjs";

const PINNED_FEED_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAS7nijfMcUoOBCj4yOXJX+GYGv2pFl2Yaha1P4v5Cm6A=
-----END PUBLIC KEY-----
`;

const DEFAULT_REMOTE_FEED_URL = "https://clawsec.prompt.security/advisories/feed.json";
const STATE_FILE_BASENAME = "feed-verification-state.json";
const CACHED_FEED_BASENAME = "feed.json";

function isObject(value) {
  return value && typeof value === "object" && !Array.isArray(value);
}

function toBool(value, fallback = false) {
  if (value === undefined || value === null) return fallback;
  if (typeof value === "boolean") return value;
  const norm = String(value).trim().toLowerCase();
  if (["1", "true", "yes", "on", "enabled"].includes(norm)) return true;
  if (["0", "false", "no", "off", "disabled"].includes(norm)) return false;
  return fallback;
}

function readJsonFileMaybe(filePath) {
  if (!filePath || !fs.existsSync(filePath)) return null;
  return JSON.parse(fs.readFileSync(filePath, "utf8"));
}

function detectHermesConfig(hermesHome) {
  const candidates = [path.join(hermesHome, "config.json"), path.join(hermesHome, "gateway", "config.json")];
  for (const candidate of candidates) {
    try {
      const parsed = readJsonFileMaybe(candidate);
      if (parsed && typeof parsed === "object") {
        return parsed;
      }
    } catch {
      // Ignore malformed local config here; feed verification should remain independently operable.
    }
  }
  return {};
}

function configValue(config, key) {
  const fromRoot = config?.advisory_feed?.[key];
  if (fromRoot !== undefined && fromRoot !== null) return fromRoot;
  const fromSecurity = config?.security?.advisory_feed?.[key];
  if (fromSecurity !== undefined && fromSecurity !== null) return fromSecurity;
  return undefined;
}

function readEnv(name) {
  const proc = globalThis?.process;
  const envBag = proc && typeof proc === "object" ? proc["env"] : undefined;
  return envBag ? envBag[name] : undefined;
}

function envOrConfigString(name, config, configKey, fallback) {
  const envValue = readEnv(name);
  if (typeof envValue === "string" && envValue.trim()) {
    return envValue.trim();
  }
  const cfgValue = configValue(config, configKey);
  if (typeof cfgValue === "string" && cfgValue.trim()) {
    return cfgValue.trim();
  }
  return fallback;
}

function envOrConfigBool(name, config, configKey, fallback) {
  const envValue = readEnv(name);
  if (typeof envValue === "string") {
    return toBool(envValue, fallback);
  }
  const cfgValue = configValue(config, configKey);
  if (cfgValue !== undefined) {
    return toBool(cfgValue, fallback);
  }
  return fallback;
}

function resolveUserPath(rawPath, fallback, hermesHome) {
  const picked = String(rawPath || fallback || "").trim();
  if (!picked) return "";
  if (picked === "~") return os.homedir();
  if (picked.startsWith("~/")) return path.join(os.homedir(), picked.slice(2));
  if (picked.startsWith("$HERMES_HOME/")) return path.join(hermesHome, picked.slice("$HERMES_HOME/".length));
  return path.resolve(picked);
}

function isPathInside(childPath, parentPath) {
  const child = path.resolve(childPath);
  const parent = path.resolve(parentPath);
  const rel = path.relative(parent, child);
  return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}

function nearestExistingAncestorWithinRoot(targetPath, rootPath) {
  const root = path.resolve(rootPath);
  let candidate = path.resolve(targetPath);

  while (isPathInside(candidate, root)) {
    if (fs.existsSync(candidate)) {
      return candidate;
    }
    const parent = path.dirname(candidate);
    if (parent === candidate) {
      break;
    }
    candidate = parent;
  }

  return null;
}

function nearestExistingAncestor(inputPath) {
  let candidate = path.resolve(inputPath);
  while (!fs.existsSync(candidate)) {
    const parent = path.dirname(candidate);
    if (parent === candidate) {
      return candidate;
    }
    candidate = parent;
  }
  return candidate;
}

function safeRealpath(inputPath) {
  return fs.realpathSync.native ? fs.realpathSync.native(inputPath) : fs.realpathSync(inputPath);
}

function realpathWithMissingTail(inputPath) {
  const resolved = path.resolve(inputPath);
  const ancestor = nearestExistingAncestor(resolved);
  const ancestorReal = safeRealpath(ancestor);
  const rel = path.relative(ancestor, resolved);
  return rel ? path.join(ancestorReal, rel) : ancestorReal;
}

function confineToHermesHome(candidatePath, hermesHome, label) {
  const root = path.resolve(hermesHome);
  const resolved = path.resolve(String(candidatePath || ""));

  if (!isPathInside(resolved, root)) {
    throw new Error(`${label} must stay under ${root}`);
  }

  const rootReal = realpathWithMissingTail(root);
  const nearestAncestor = nearestExistingAncestorWithinRoot(resolved, root);
  if (nearestAncestor) {
    const nearestAncestorReal = safeRealpath(nearestAncestor);
    if (!isPathInside(nearestAncestorReal, rootReal)) {
      throw new Error(`${label} must stay under ${rootReal}`);
    }
  }

  if (fs.existsSync(resolved) && fs.lstatSync(resolved).isSymbolicLink()) {
    throw new Error(`${label} must not be a symlink: ${resolved}`);
  }

  return resolved;
}

function sha256Hex(content) {
  return crypto.createHash("sha256").update(content).digest("hex");
}

function decodeSignature(signatureRaw) {
  const trimmed = String(signatureRaw || "").trim();
  if (!trimmed) return null;

  let encoded = trimmed;
  if (trimmed.startsWith("{")) {
    try {
      const parsed = JSON.parse(trimmed);
      if (isObject(parsed) && typeof parsed.signature === "string") {
        encoded = parsed.signature;
      }
    } catch {
      return null;
    }
  }

  const normalized = encoded.replace(/\s+/g, "");
  if (!normalized) return null;

  try {
    return Buffer.from(normalized, "base64");
  } catch {
    return null;
  }
}

export function verifySignedPayload(payloadRaw, signatureRaw, publicKeyPem) {
  const signature = decodeSignature(signatureRaw);
  if (!signature) return false;

  const keyPem = String(publicKeyPem || "").trim();
  if (!keyPem) return false;

  try {
    const publicKey = crypto.createPublicKey(keyPem);
    return crypto.verify(null, Buffer.from(payloadRaw, "utf8"), publicKey, signature);
  } catch {
    return false;
  }
}

function extractSha256(value) {
  if (typeof value === "string") {
    const normalized = value.trim().toLowerCase();
    return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
  }
  if (isObject(value) && typeof value.sha256 === "string") {
    const normalized = value.sha256.trim().toLowerCase();
    return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null;
  }
  return null;
}

function parseChecksumsManifest(manifestRaw) {
  let parsed;
  try {
    parsed = JSON.parse(manifestRaw);
  } catch {
    throw new Error("checksum manifest is not valid JSON");
  }

  if (!isObject(parsed)) {
    throw new Error("checksum manifest must be an object");
  }

  const algorithm = String(parsed.algorithm || "sha256").trim().toLowerCase();
  if (algorithm !== "sha256") {
    throw new Error(`unsupported checksum algorithm: ${algorithm || "(empty)"}`);
  }

  if (!isObject(parsed.files)) {
    throw new Error("checksum manifest missing files object");
  }

  const files = {};
  for (const [name, value] of Object.entries(parsed.files)) {
    const key = String(name || "").trim();
    if (!key) continue;
    const digest = extractSha256(value);
    if (!digest) {
      throw new Error(`invalid checksum digest for ${key}`);
    }
    files[key] = digest;
  }

  if (Object.keys(files).length === 0) {
    throw new Error("checksum manifest has no usable digest entries");
  }

  return { files };
}

function normalizeChecksumEntryName(entryName) {
  return String(entryName || "")
    .trim()
    .replace(/\\/g, "/")
    .replace(/^(?:\.\/)+/, "")
    .replace(/^\/+/, "");
}

function resolveChecksumManifestEntry(files, entryName) {
  const normalizedEntry = normalizeChecksumEntryName(entryName);
  if (!normalizedEntry) return null;

  const candidates = [
    normalizedEntry,
    path.posix.basename(normalizedEntry),
    `advisories/${path.posix.basename(normalizedEntry)}`,
  ].filter((candidate, index, all) => candidate && all.indexOf(candidate) === index);

  for (const candidate of candidates) {
    if (Object.prototype.hasOwnProperty.call(files, candidate)) {
      return { key: candidate, digest: files[candidate] };
    }
  }

  const basename = path.posix.basename(normalizedEntry);
  if (!basename) return null;

  const matches = Object.entries(files).filter(([key]) => path.posix.basename(normalizeChecksumEntryName(key)) === basename);
  if (matches.length > 1) {
    throw new Error(`checksum manifest entry is ambiguous for ${entryName}`);
  }
  if (matches.length === 1) {
    const [key, digest] = matches[0];
    return { key, digest };
  }

  return null;
}

function verifyChecksumEntry(manifest, entryName, contentRaw) {
  const resolved = resolveChecksumManifestEntry(manifest.files, entryName);
  if (!resolved) {
    throw new Error(`checksum manifest missing required entry: ${entryName}`);
  }
  const actual = sha256Hex(contentRaw);
  if (actual !== resolved.digest) {
    throw new Error(`checksum mismatch for ${entryName} (manifest key: ${resolved.key})`);
  }
  return resolved;
}

function safeBasename(urlOrPath, fallback) {
  try {
    const parsed = new URL(urlOrPath);
    const parts = parsed.pathname.split("/").filter(Boolean);
    return parts.length > 0 ? parts[parts.length - 1] : fallback;
  } catch {
    const normalized = String(urlOrPath || "").trim();
    const base = path.basename(normalized);
    return base || fallback;
  }
}

async function fetchTextRequired(url) {
  const controller = new globalThis.AbortController();
  const timeout = globalThis.setTimeout(() => controller.abort(), 10000);
  try {
    const response = await globalThis.fetch(url, {
      method: "GET",
      signal: controller.signal,
      headers: { accept: "application/json,text/plain;q=0.9,*/*;q=0.8" },
    });
    if (!response.ok) {
      throw new Error(`failed to fetch ${url} (http ${response.status})`);
    }
    return await response.text();
  } catch (error) {
    throw new Error(`failed to fetch ${url}: ${error?.message || String(error)}`);
  } finally {
    globalThis.clearTimeout(timeout);
  }
}

async function fetchTextOptional(url) {
  const controller = new globalThis.AbortController();
  const timeout = globalThis.setTimeout(() => controller.abort(), 10000);
  try {
    const response = await globalThis.fetch(url, {
      method: "GET",
      signal: controller.signal,
      headers: { accept: "application/json,text/plain;q=0.9,*/*;q=0.8" },
    });
    if (!response.ok) {
      if (response.status === 404) return null;
      throw new Error(`failed to fetch ${url} (http ${response.status})`);
    }
    return await response.text();
  } catch (error) {
    if (String(error?.name || "") === "AbortError") {
      throw new Error(`failed to fetch ${url}: request timed out`);
    }
    throw new Error(`failed to fetch ${url}: ${error?.message || String(error)}`);
  } finally {
    globalThis.clearTimeout(timeout);
  }
}

export function isValidFeedPayload(raw) {
  if (!isObject(raw)) return false;
  if (typeof raw.version !== "string" || !raw.version.trim()) return false;
  if (!Array.isArray(raw.advisories)) return false;

  for (const advisory of raw.advisories) {
    if (!isObject(advisory)) return false;
    if (typeof advisory.id !== "string" || !advisory.id.trim()) return false;
    if (typeof advisory.severity !== "string" || !advisory.severity.trim()) return false;
    if (!Array.isArray(advisory.affected)) return false;
    for (const entry of advisory.affected) {
      if (typeof entry !== "string" || !entry.trim()) return false;
      const parsed = parseAffectedSpecifier(entry);
      if (!parsed || !parsed.name) return false;
      if (!parseVersionSpec(parsed.versionSpec).supported) return false;
    }
  }

  return true;
}

export function detectHermesHome() {
  const envHome = String(readEnv("HERMES_HOME") || "").trim();
  return envHome || path.join(os.homedir(), ".hermes");
}

export function advisorySecurityRoot(hermesHome = detectHermesHome()) {
  return path.join(path.resolve(hermesHome), "security", "advisories");
}

export function defaultFeedStatePath(hermesHome = detectHermesHome()) {
  return path.join(advisorySecurityRoot(hermesHome), STATE_FILE_BASENAME);
}

export function defaultCachedFeedPath(hermesHome = detectHermesHome()) {
  return path.join(advisorySecurityRoot(hermesHome), CACHED_FEED_BASENAME);
}

export function defaultChecksumsUrl(feedUrl) {
  try {
    return new URL("checksums.json", feedUrl).toString();
  } catch {
    const fallbackBase = String(feedUrl || "").replace(/\/?[^/]*$/, "");
    return `${fallbackBase}/checksums.json`;
  }
}

export function resolveFeedConfig(overrides = {}) {
  const hermesHome = detectHermesHome();
  const config = detectHermesConfig(hermesHome);
  const advisoryRoot = advisorySecurityRoot(hermesHome);

  const cachedFeedPath = confineToHermesHome(
    resolveUserPath(
      overrides.cachedFeedPath
        ?? envOrConfigString("HERMES_ADVISORY_CACHED_FEED", config, "cached_feed_path", path.join(advisoryRoot, CACHED_FEED_BASENAME)),
      path.join(advisoryRoot, CACHED_FEED_BASENAME),
      hermesHome,
    ),
    hermesHome,
    "cached feed path",
  );

  const feedUrl = String(
    overrides.feedUrl
      ?? envOrConfigString("HERMES_ADVISORY_FEED_URL", config, "url", DEFAULT_REMOTE_FEED_URL),
  ).trim();

  const signatureUrl = String(
    overrides.signatureUrl
      ?? envOrConfigString("HERMES_ADVISORY_FEED_SIG_URL", config, "signature_url", `${feedUrl}.sig`),
  ).trim();

  const checksumsUrl = String(
    overrides.checksumsUrl
      ?? envOrConfigString("HERMES_ADVISORY_FEED_CHECKSUMS_URL", config, "checksums_url", defaultChecksumsUrl(feedUrl)),
  ).trim();

  const checksumsSignatureUrl = String(
    overrides.checksumsSignatureUrl
      ?? envOrConfigString("HERMES_ADVISORY_FEED_CHECKSUMS_SIG_URL", config, "checksums_signature_url", `${checksumsUrl}.sig`),
  ).trim();

  const source = String(
    overrides.source
      ?? envOrConfigString("HERMES_ADVISORY_FEED_SOURCE", config, "source", "auto"),
  ).trim().toLowerCase();

  const allowUnsigned = overrides.allowUnsigned ?? envOrConfigBool("HERMES_ADVISORY_ALLOW_UNSIGNED_FEED", config, "allow_unsigned", false);
  const verifyChecksumManifest = overrides.verifyChecksumManifest
    ?? envOrConfigBool("HERMES_ADVISORY_VERIFY_CHECKSUM_MANIFEST", config, "verify_checksum_manifest", true);

  const localFeedPath = resolveUserPath(
    overrides.localFeedPath
      ?? envOrConfigString("HERMES_LOCAL_ADVISORY_FEED", config, "local_path", cachedFeedPath),
    cachedFeedPath,
    hermesHome,
  );
  const localSignaturePath = resolveUserPath(
    overrides.localSignaturePath
      ?? envOrConfigString("HERMES_LOCAL_ADVISORY_FEED_SIG", config, "local_signature_path", `${localFeedPath}.sig`),
    `${localFeedPath}.sig`,
    hermesHome,
  );
  const localChecksumsPath = resolveUserPath(
    overrides.localChecksumsPath
      ?? envOrConfigString(
        "HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS",
        config,
        "local_checksums_path",
        path.join(path.dirname(localFeedPath), "checksums.json"),
      ),
    path.join(path.dirname(localFeedPath), "checksums.json"),
    hermesHome,
  );
  const localChecksumsSignaturePath = resolveUserPath(
    overrides.localChecksumsSignaturePath
      ?? envOrConfigString("HERMES_LOCAL_ADVISORY_FEED_CHECKSUMS_SIG", config, "local_checksums_signature_path", `${localChecksumsPath}.sig`),
    `${localChecksumsPath}.sig`,
    hermesHome,
  );

  const publicKeyPathRaw = overrides.publicKeyPath
    ?? envOrConfigString("HERMES_ADVISORY_FEED_PUBLIC_KEY", config, "public_key_path", "");
  const publicKeyPath = publicKeyPathRaw ? resolveUserPath(publicKeyPathRaw, "", hermesHome) : "";

  const statePath = confineToHermesHome(
    resolveUserPath(
      overrides.statePath
        ?? envOrConfigString("HERMES_ADVISORY_FEED_STATE_PATH", config, "state_path", path.join(advisoryRoot, STATE_FILE_BASENAME)),
      path.join(advisoryRoot, STATE_FILE_BASENAME),
      hermesHome,
    ),
    hermesHome,
    "advisory state path",
  );

  return {
    hermesHome,
    advisoryRoot,
    source: ["remote", "local", "auto"].includes(source) ? source : "auto",
    feedUrl,
    signatureUrl,
    checksumsUrl,
    checksumsSignatureUrl,
    localFeedPath,
    localSignaturePath,
    localChecksumsPath,
    localChecksumsSignaturePath,
    publicKeyPath,
    publicKeyPem: overrides.publicKeyPem || "",
    allowUnsigned: allowUnsigned === true,
    verifyChecksumManifest: verifyChecksumManifest !== false,
    statePath,
    cachedFeedPath,
  };
}

function readPublicKeyPem(config) {
  if (config.allowUnsigned) return "";
  if (config.publicKeyPem && config.publicKeyPem.trim()) {
    return config.publicKeyPem;
  }
  if (config.publicKeyPath) {
    if (!fs.existsSync(config.publicKeyPath)) {
      throw new Error(`pinned feed public key not found: ${config.publicKeyPath}`);
    }
    return fs.readFileSync(config.publicKeyPath, "utf8");
  }
  return PINNED_FEED_PUBLIC_KEY_PEM;
}

export function loadFeedVerificationState(statePath = defaultFeedStatePath()) {
  if (!fs.existsSync(statePath)) return null;
  try {
    const parsed = JSON.parse(fs.readFileSync(statePath, "utf8"));
    if (!isObject(parsed)) return null;
    return parsed;
  } catch {
    return null;
  }
}

export function getFeedVerificationStatus({ statePath = defaultFeedStatePath() } = {}) {
  const state = loadFeedVerificationState(statePath);
  const status = String(state?.status || "").trim().toLowerCase();
  if (["verified", "unverified"].includes(status)) {
    return {
      status,
      available: true,
      checked_at: state.checked_at || null,
      state_path: statePath,
      source: state.source || null,
    };
  }

  return {
    status: "unknown",
    available: false,
    checked_at: null,
    state_path: statePath,
    source: null,
  };
}

function writeTextAtomic(filePath, content, writeOptions = {}) {
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  const tempPath = path.join(
    path.dirname(filePath),
    `${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${crypto.randomUUID()}`,
  );
  let renamed = false;
  try {
    fs.writeFileSync(tempPath, content, { encoding: "utf8", ...writeOptions });
    fs.renameSync(tempPath, filePath);
    renamed = true;
  } finally {
    if (!renamed && fs.existsSync(tempPath)) {
      try {
        fs.unlinkSync(tempPath);
      } catch {
        // Best-effort cleanup for interrupted atomic writes.
      }
    }
  }
}

function writeJsonAtomic(filePath, value) {
  writeTextAtomic(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
}

function parseAndValidateFeed(feedRaw, sourceLabel) {
  let payload;
  try {
    payload = JSON.parse(feedRaw);
  } catch (error) {
    throw new Error(`invalid advisory feed JSON (${sourceLabel}): ${error?.message || String(error)}`);
  }

  if (!isValidFeedPayload(payload)) {
    throw new Error(`invalid advisory feed format (${sourceLabel})`);
  }

  return payload;
}

function assertSignedPayload(payloadRaw, signatureRaw, keyPem, failureMessage) {
  if (!verifySignedPayload(payloadRaw, signatureRaw, keyPem)) {
    throw new Error(failureMessage);
  }
}

function assertCompleteChecksumManifestArtifacts(hasManifest, hasManifestSignature) {
  if (!hasManifest || !hasManifestSignature) {
    throw new Error("checksum manifest artifacts are required when checksum verification is enabled");
  }
}

function verifyChecksumManifestBundle({
  checksumsRaw,
  checksumsSignatureRaw,
  keyPem,
  checksumsLocation,
  feedEntry,
  signatureEntry,
  feedRaw,
  signatureRaw,
}) {
  assertSignedPayload(
    checksumsRaw,
    checksumsSignatureRaw,
    keyPem,
    `checksum manifest signature verification failed: ${checksumsLocation}`,
  );

  const manifest = parseChecksumsManifest(checksumsRaw);
  verifyChecksumEntry(manifest, feedEntry, feedRaw);
  verifyChecksumEntry(manifest, signatureEntry, signatureRaw);
}

function verifySignedFeedArtifacts({
  feedRaw,
  signatureRaw,
  keyPem,
  signatureFailureMessage,
  verifyChecksumManifest,
  checksumsRaw,
  checksumsSignatureRaw,
  checksumsLocation,
  feedEntry,
  signatureEntry,
}) {
  assertSignedPayload(feedRaw, signatureRaw, keyPem, signatureFailureMessage);

  if (!verifyChecksumManifest) {
    return false;
  }

  const hasChecksums = checksumsRaw !== null;
  const hasChecksumsSignature = checksumsSignatureRaw !== null;
  assertCompleteChecksumManifestArtifacts(hasChecksums, hasChecksumsSignature);

  verifyChecksumManifestBundle({
    checksumsRaw,
    checksumsSignatureRaw,
    keyPem,
    checksumsLocation,
    feedEntry,
    signatureEntry,
    feedRaw,
    signatureRaw,
  });
  return true;
}

export async function loadLocalFeed(config) {
  const feedRaw = fs.readFileSync(config.localFeedPath, "utf8");
  const keyPem = readPublicKeyPem(config);
  const result = {
    source: "local",
    location: config.localFeedPath,
    checksums_verified: false,
    unsigned_bypass: config.allowUnsigned,
  };

  if (!config.allowUnsigned) {
    if (!fs.existsSync(config.localSignaturePath)) {
      throw new Error(`missing local feed signature: ${config.localSignaturePath}`);
    }

    const signatureRaw = fs.readFileSync(config.localSignaturePath, "utf8");
    const hasChecksums = config.verifyChecksumManifest && fs.existsSync(config.localChecksumsPath);
    const hasChecksumsSignature = config.verifyChecksumManifest && fs.existsSync(config.localChecksumsSignaturePath);
    const checksumsRaw = hasChecksums ? fs.readFileSync(config.localChecksumsPath, "utf8") : null;
    const checksumsSignatureRaw = hasChecksumsSignature ? fs.readFileSync(config.localChecksumsSignaturePath, "utf8") : null;
    result.checksums_verified = verifySignedFeedArtifacts({
      feedRaw,
      signatureRaw,
      keyPem,
      signatureFailureMessage: `local feed signature verification failed: ${config.localFeedPath}`,
      verifyChecksumManifest: config.verifyChecksumManifest,
      checksumsRaw,
      checksumsSignatureRaw,
      checksumsLocation: config.localChecksumsPath,
      feedEntry: path.basename(config.localFeedPath),
      signatureEntry: path.basename(config.localSignaturePath),
    });
  }

  const payload = parseAndValidateFeed(feedRaw, config.localFeedPath);
  return {
    payload,
    feedRaw,
    verification: result,
  };
}

export async function loadRemoteFeed(config) {
  const feedRaw = await fetchTextRequired(config.feedUrl);
  const keyPem = readPublicKeyPem(config);
  const result = {
    source: "remote",
    location: config.feedUrl,
    checksums_verified: false,
    unsigned_bypass: config.allowUnsigned,
  };

  if (!config.allowUnsigned) {
    const signatureRaw = await fetchTextRequired(config.signatureUrl);
    const checksumsRaw = config.verifyChecksumManifest ? await fetchTextOptional(config.checksumsUrl) : null;
    const checksumsSignatureRaw = config.verifyChecksumManifest ? await fetchTextOptional(config.checksumsSignatureUrl) : null;
    const feedEntry = safeBasename(config.feedUrl, "feed.json");
    result.checksums_verified = verifySignedFeedArtifacts({
      feedRaw,
      signatureRaw,
      keyPem,
      signatureFailureMessage: `remote feed signature verification failed: ${config.feedUrl}`,
      verifyChecksumManifest: config.verifyChecksumManifest,
      checksumsRaw,
      checksumsSignatureRaw,
      checksumsLocation: config.checksumsUrl,
      feedEntry,
      signatureEntry: safeBasename(config.signatureUrl, `${feedEntry}.sig`),
    });
  }

  const payload = parseAndValidateFeed(feedRaw, config.feedUrl);
  return {
    payload,
    feedRaw,
    verification: result,
  };
}

function buildState({ status, source, config, verification = {}, payload = null, error = null }) {
  return {
    schema_version: "1",
    checked_at: new Date().toISOString(),
    status,
    source,
    allow_unsigned_bypass: config.allowUnsigned,
    verify_checksum_manifest: config.verifyChecksumManifest,
    advisory_count: Array.isArray(payload?.advisories) ? payload.advisories.length : 0,
    feed_version: payload?.version || null,
    feed_updated: payload?.updated || null,
    cached_feed_path: config.cachedFeedPath,
    ...verification,
    error: error ? String(error) : null,
  };
}

export async function refreshAdvisoryFeed(overrides = {}) {
  const config = resolveFeedConfig(overrides);
  const attemptedErrors = [];

  const tryLoadRemote = async () => {
    const loaded = await loadRemoteFeed(config);
    return { ...loaded, source: "remote" };
  };

  const tryLoadLocal = async () => {
    const loaded = await loadLocalFeed(config);
    return { ...loaded, source: "local" };
  };

  let loaded = null;

  if (config.source === "remote") {
    loaded = await tryLoadRemote();
  } else if (config.source === "local") {
    loaded = await tryLoadLocal();
  } else {
    try {
      loaded = await tryLoadRemote();
    } catch (error) {
      attemptedErrors.push(`remote: ${error?.message || String(error)}`);
      loaded = await tryLoadLocal();
    }
  }

  try {
    writeTextAtomic(config.cachedFeedPath, `${loaded.feedRaw.trimEnd()}\n`);

    const state = buildState({
      status: config.allowUnsigned ? "unverified" : "verified",
      source: loaded.source,
      config,
      verification: loaded.verification,
      payload: loaded.payload,
      error: attemptedErrors.length > 0 ? attemptedErrors.join(" | ") : null,
    });
    writeJsonAtomic(config.statePath, state);

    return {
      status: state.status,
      source: loaded.source,
      statePath: config.statePath,
      cachedFeedPath: config.cachedFeedPath,
      advisoryCount: state.advisory_count,
      feedVersion: state.feed_version,
      attemptedErrors,
    };
  } catch (error) {
    const state = buildState({
      status: "unverified",
      source: loaded?.source || config.source,
      config,
      verification: loaded?.verification,
      payload: loaded?.payload,
      error: error?.message || String(error),
    });
    writeJsonAtomic(config.statePath, state);
    throw error;
  }
}

export function recordUnverifiedFeedState(error, overrides = {}) {
  const config = resolveFeedConfig(overrides);
  const state = buildState({
    status: "unverified",
    source: config.source,
    config,
    verification: {},
    payload: null,
    error,
  });
  writeJsonAtomic(config.statePath, state);
  return state;
}