Hmbown/CodeWhale · error · Error

file is not a regular single-link file

Error message

file is not a regular single-link file

What it means

readBoundedFile performs defensive reads of key/envelope files: it first lstat()s the path and requires a regular file with exactly one hard link, throwing this error otherwise. This prevents following symlinks, FIFOs, device nodes, or hardlinked files that could be swapped or point elsewhere.

Solutions

  1. Replace the symlink with a real copy of the file (cp -L) and point at the copy
  2. Check with `ls -l` and `stat` for link count and type; remove extra hard links
  3. Ensure the file is created as a regular file, not a FIFO or socket
  4. If symlinking is intentional for your workflow, read the target yourself and pass the bytes/env var instead

Example fix

// before
CODEWHALE_FACTS_SIGNING_KEY_FILE=~/.secrets/key.pem   # symlink
// after
cp -L ~/.secrets/key.pem ./key.pem && stat -c '%h %F' ./key.pem
CODEWHALE_FACTS_SIGNING_KEY_FILE=./key.pem
Defensive patterns

Strategy: validation

Validate before calling

import { lstatSync, constants as fsC } from 'node:fs';
const st = lstatSync(path);
if (!st.isFile() || st.nlink !== 1) throw new Error(`${path} must be a regular single-link file`);

Type guard

const isRegularSingleLink = (path) => { try { const s = lstatSync(path); return s.isFile() && s.nlink === 1; } catch { return false; } };

Try / catch

try { pem = readBoundedFile(keyFile, 16 * 1024); } catch (e) { if (e.message === 'file is not a regular single-link file') { console.error(`Replace symlink/FIFO/hardlink at ${keyFile} with a real file`); process.exit(2); } throw e; }

Prevention

When it happens

Trigger: Calling readBoundedFile(path) where the path is a symlink (lstat isFile() false), a FIFO/socket/device, or a regular file with nlink > 1 (hard-linked).

Common situations: Pointing CODEWHALE_FACTS_SIGNING_KEY_FILE at a symlink into a secrets manager or dotfiles symlink farm; a temp file hard-linked by a backup tool; the key file being created by a pipe.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15). Data as JSON: /api/errors/74cfc6242d57a029. Report an issue: GitHub.

Appendix: source

Thrown at web/scripts/facts-publish.mjs:344

function parseArgs(argv) {
  const positional = [];
  const flags = {};
  for (let i = 0; i < argv.length; i += 1) {
    const arg = argv[i];
    if (arg.startsWith("--")) {
      const key = arg.slice(2);
      const next = argv[i + 1];
      if (next === undefined || next.startsWith("--")) flags[key] = true;
      else { flags[key] = next; i += 1; }
    } else positional.push(arg);
  }
  return { positional, flags };
}

/** Bounded, regular, single-link file reads; no symlink or FIFO following. */
export function readBoundedFile(path, maxBytes = MAX_ENVELOPE_BYTES) {
  const before = lstatSync(path);
  if (!before.isFile() || before.nlink !== 1) throw new Error("file is not a regular single-link file");
  const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
  try {
    const stat = fstatSync(fd);
    if (!stat.isFile() || stat.nlink !== 1 || stat.size > maxBytes || stat.ino !== before.ino || stat.dev !== before.dev) throw new Error("file is not a bounded regular single-link file");
    const bytes = Buffer.alloc(maxBytes + 1);
    let size = 0;
    while (size <= maxBytes) {
      const count = readSync(fd, bytes, size, maxBytes + 1 - size, null);
      if (!count) break;
      size += count;
    }
    if (size > maxBytes) throw new Error("file exceeds size limit");
    return bytes.subarray(0, size);
  } finally { closeSync(fd); }
}

function loadPrivateKeyFromEnv() {
  refuseUnderCi();

View on GitHub (pinned to 433685b202)