Skip to content

bug(review): binaryDigest reuses a stale digest after a same-size in-place rewrite within one kernel clock tick #3230

Open

Description

@danielgap

Before submitting

  • I searched open and closed issues and did not find a report of this problem.
  • I reviewed this report and removed credentials, tokens, private paths, hostnames, and other sensitive data.

Problem

binaryDigest() in lib/gentle-ai-binary.ts reuses a cached sha256 while the file's identity dev:ino:size:mtimeNs:ctimeNs is unchanged. The comment above it assumes that "any rewrite changes ctime". That does not hold on kernels with coarse inode timestamps: Linux advances inode mtime/ctime in jiffies (4 ms with CONFIG_HZ=250), so a same-size, in-place rewrite within one tick keeps the whole identity, and binaryDigest() returns the digest of the previous content.

Visible effect: tests/native-review-refusal-identity.test.ts fails deterministically on such hosts (both the runtime and typescript flavors, on Node 22 and Node 24), while CI passes. The probe rewrites the executable in place with # identity A and # refusing B, which have the same length, within one tick. binaryDigest() returns digest(A) for B, the refusal cache in lib/native-review-cli.ts (around lines 510-532) stores the wrong digest, and the last call re-sends --non-blocking-findings instead of reusing the cached refusal.

The same cached digest also backs the runtime integrity check of the gentle-ai binary against its manifest (lib/gentle-ai-binary.ts, around line 425) and the capability cache in lib/native-review-cli.ts (around lines 2527-2535). There the practical impact is low, because it needs an in-place, same-size swap within one clock tick of a previous digest, but the documented guarantee does not hold.

Steps to reproduce

  1. Use a Linux host with coarse kernel timer granularity (for example CONFIG_HZ=250).
  2. On main (b1af8a493), run:
    HOME=$(mktemp -d) node --experimental-strip-types --test tests/native-review-refusal-identity.test.ts
    It fails on every run with the flags assertion below.
  3. Minimal proof that the identity collides: in a loop, write #!/bin/sh\n# identity A\nexit 0\n to a file, take lstatSync(f, {bigint: true}), overwrite it in place with #!/bin/sh\n# refusing B\nexit 0\n, and compare dev:ino:size:mtimeNs:ctimeNs. On the reporting host the identity was identical in 193/200 iterations on ext4 and in 195/200 on tmpfs, so the cause is clock granularity, not the filesystem.

Expected and actual behavior

Expected: a rewritten binary is always hashed again, as the comment states, so the refusal cache, the capability cache and the integrity check never act on a stale digest, and the test passes regardless of kernel timer granularity.
Actual: on coarse-clock hosts, a same-size in-place rewrite within one tick reuses the previous digest, and native-review-refusal-identity fails deterministically.

Why CI passes (inferred, not verified on the runner): the GitHub-hosted runner kernel likely has finer timestamp granularity, so the two writes land in different ticks.

Suggested fix (the same idea as git's "racy index" rule): do not cache a digest when the file's ctime/mtime is within a timestamp-granularity margin of the moment the digest was computed, so a file changed "just now" is always hashed again. Giving A and B different lengths in the probe would make the test pass but hide the production assumption, so the fix belongs in binaryDigest(), with a regression test that pins the same-tick, same-size rewrite.

Happy to send the fix if this is approved.

gentle-pi version

main @ b1af8a4

Pi version

1.5.2

Operating system

Linux

Relevant logs or error output (optional)

not ok - S1/S3 old refusal cannot overwrite newer valid refusal
  Expected values to be strictly deep-equal:
  + actual - expected
    [ true, true, false, false,
  +   true,
  -   false
    ]

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions