affaan-m/ECC · error

Refusing to read a file that changed during validation

Error message

Refusing to read a file that changed during validation: ${filePath}

What it means

hashFileNoFollow computes a SHA-256 digest of a file while guarding against symlink and time-of-check-time-of-use (TOCTOU) attacks. Before hashing it stats the path twice and compares dev/ino/size/mtime/ctime; if the path is a symlink, not a regular file, or any attribute changed between the two stats, it refuses to read and throws this error. It exists so installers never hash a file that could be swapped mid-read.

Solutions

  1. Rerun the hashing/validation step — a transient concurrent write usually won't repeat.
  2. Close editors, pause file-sync tools, or stop concurrent installs that touch the path, then retry.
  3. If the path is a symlink, resolve it (fs.realpath) to a regular file before hashing.
  4. Ensure the file is a regular file (fs.statSync(p).isFile()) before passing it in.

Example fix

// before: hashing a possibly-swapped path directly
const { digest } = hashFileNoFollow(configPath);

// after: snapshot the file to a stable temp copy first
const stable = fs.realpathSync(configPath);
if (!fs.statSync(stable).isFile()) throw new Error('not a regular file');
const { digest } = hashFileNoFollow(stable);
Defensive patterns

Strategy: retry

Validate before calling

const st = fs.lstatSync(p);
if (st.isSymbolicLink() || !st.isFile()) throw new Error(`cannot hash ${p}: symlink or not a regular file`);

Type guard

function isHashableRegularFile(p) { const st = fs.lstatSync(p); return st.isFile() && !st.isSymbolicLink(); }

Try / catch

try {
  const { digest } = hashFileNoFollow(p);
} catch (e) {
  if (e.message.startsWith('Refusing to read a file that changed during validation')) {
    await new Promise(r => setTimeout(r, 100));
    return hashFileNoFollow(p); // retry after transient writer settles
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling hashFileNoFollow(filePath) when: (1) the file at filePath is replaced, truncated, or modified between the initial stat and the re-stat (race with another writer such as a running installer, editor, or sync tool); (2) filePath resolves to a symlink (finalPathStat.isSymbolicLink()); (3) filePath is a directory, FIFO, socket, or any non-regular file.

Common situations: A sync client (Dropbox, iCloud) rewrites the file during hashing; an editor auto-save fires mid-validation; a malicious or accidental symlink swap during a legacy migration; a concurrent ECC install running in another terminal touching the same managed file.

Understand the failure class

Background: Checksum mismatch errors: "checksum verification failed", "digest mismatch", "expected vs actual checksum" — what they mean and how to fix them — this error's family across 41 libraries.

Related errors


AI-assisted analysis of affaan-m/ECC@8321021c54 (2026-09-16). Data as JSON: /api/errors/b342332848afc380. Report an issue: GitHub.

Appendix: source

Thrown at scripts/lib/install/opencode-legacy-migration.js:123

    const before = fs.fstatSync(descriptor, { bigint: true });
    if (!before.isFile()) {
      throw new Error(`Refusing to read a non-file at ${filePath}`);
    }
    const content = fs.readFileSync(descriptor);
    const after = fs.fstatSync(descriptor, { bigint: true });
    const finalPathStat = fs.lstatSync(filePath, { bigint: true });
    const unchanged = before.dev === after.dev
      && before.ino === after.ino
      && before.size === after.size
      && before.mtimeMs === after.mtimeMs
      && before.ctimeMs === after.ctimeMs
      && after.dev === finalPathStat.dev
      && after.ino === finalPathStat.ino
      && after.size === finalPathStat.size
      && after.mtimeMs === finalPathStat.mtimeMs
      && after.ctimeMs === finalPathStat.ctimeMs;
    if (finalPathStat.isSymbolicLink() || !finalPathStat.isFile() || !unchanged) {
      throw new Error(`Refusing to read a file that changed during validation: ${filePath}`);
    }
    return {
      digest: crypto.createHash('sha256').update(content).digest('hex'),
      stat: after,
    };
  } finally {
    fs.closeSync(descriptor);
  }
}

function removeEmptyParents(startPath, legacyRoot) {
  let currentPath = path.dirname(startPath);
  while (!samePath(currentPath, legacyRoot)) {
    const safePath = assertWithinTrustedRoot(
      currentPath,
      legacyRoot,
      'clean legacy OpenCode install'
    );

View on GitHub (pinned to 8321021c54)