Repository navigation
fix(env): close the remaining raw env write refusals and publish without hard links - #468
Merged
Merged
Conversation
…out hard links On a filesystem without hard links (ExFAT, FAT) every env write failed with a raw ENOTSUP from the lock receipt's and the first project key's link(2) publish, so the key could never be created there. The exclusive publish now falls back to an O_CREAT|O_EXCL|O_NOFOLLOW empty reservation that the complete, fsynced sibling is renamed over, keeping the no-overwrite and no-follow guarantees. The other refusals that were still raw on next are now one value-free line with an E_* code and no stack: - hydrate with a symlinked (dangling or live) or non-directory .hack/.internal is E_PATH_CONFLICT; nothing is created through it - an --env overlay name over 128 characters is refused up front as E_USAGE, and ENAMETOOLONG is classified as E_PATH_CONFLICT - a project env key lock that stays held is E_LOCKED, naming the lock (and its recovery guard when that is held) and the remedy - a .hack/.gitignore directory is E_PATH_CONFLICT, as docs/env.md said Adds mutation-proven tests for these and for the previously untested lock-unavailable codes, atomic-write refusal codes and the staging EISDIR mapping. Refs HACK-1377 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs HACK-1377
This PR fixes the env write refusals that were still raw on
nextafter #402 (HACK-1330). All five were pre-existing; none are regressions. Each one now prints a single line with anE_*code and no value, stack, errno or staging path.--jsonreports the same code.Root causes and fixes
withProjectsRegistryLock) and the first project key (publishPrivateFileExclusive) withlink(2). ExFAT returns ENOTSUP (Linux FAT returns EPERM), so the key could never be created. On currentnextthe problem is wider: since fix(env): serialize env layer read-modify-write so concurrent sets keep every key #435, everyenv seton ExFAT fails at the per-layer lock, including a non-secret one. The output was a rawENOTSUP … link '<…>.owner' -> '<…>.lock'with a stack.Fix: a new
src/lib/exclusive-publish.ts. Whenlinkreports that hard links are unsupported (ENOTSUP, EOPNOTSUPP or EPERM), it falls back to an exclusive placeholder. Hack creates an emptyO_CREAT|O_EXCL|O_NOFOLLOWfile at the target, then renames the complete, fsynced sibling over it. The no-overwrite and no-follow guarantees still hold. An existing entry or symlink gets EEXIST. The rename replaces only the empty file this call created, and that is checked again just before the rename. Readers see nothing, an empty file or the complete file, never a partial one. If the rename fails, the reservation is removed. The registry lock now accepts that the rename already used up its staging name. Since the fallback keeps every guarantee, noE_UNSUPPORTED_FILESYSTEMrefusal was needed..hack/.internalsymlink reachedmkdir -pand got a raw EEXIST (E_UNEXPECTEDunder--json).Fix:
ensureDirForWritemaps EEXIST to a closednot_a_directory_entryrefusal. The hydrate lock now uses the newensureRealDirectoryForWrite. It refuses a dangling or live symlink, or a file, at.internalasE_PATH_CONFLICTbefore anything is created through it.--envname (215 characters) produced lock and staging file names over NAME_MAX, so set, unset and hydrate printed a raw ENAMETOOLONG with the staging path.Fix:
parseEnvConfigSelectioncaps overlay names at 128 characters (ENV_CONFIG_NAME_MAX_LENGTH). The longest derived name adds 68 bytes. The CLI refuses longer names up front with the fixedInvalid --env value.message (E_USAGE), and the daemon route uses the same parser. ENAMETOOLONG is also classified as a write failure (name_too_long,E_PATH_CONFLICT) for deep project paths.ProjectsRegistryLockTimeoutErrorescaped with a stack.Fix: a new
ProjectEnvKeyLockTimeoutErrorwith a new code,E_LOCKED. It names the lock and the remedy, and also the.recoveryguard when that guard is what is held.ProjectsRegistryLockTimeoutErrorgains arecoveryBusyfield. Its message text is unchanged, because fix(env): name unreclaimable layer locks and the re-include cause #453 (G69) classifies the per-layer lock by that text..hack/.gitignorethat is a directory printed a line with no code, and--jsongaveE_UNEXPECTED.Fix:
HackDirGitignoreRefusalErroris now aHackCliErrorwithE_PATH_CONFLICT, which is whatdocs/env.mdalready said. Its wording is unchanged.isProjectEnvKeyLockUnavailable; the code and message of everyAtomicWriteRefusal; behaviouralnot_regular_fileandnot_writablerefusals; and the staging EISDIR tonot_regular_filemapping.E_LOCKEDis added toHACK_ERROR_CODESand the error-code guide, and the plugin reference is regenerated.docs/env.mddocuments the overlay-name limit,E_LOCKED, the newE_PATH_CONFLICTcases and the behaviour without hard links.This PR does not touch the per-layer lock message, the unset CAS test, the doctor env-key check or the gitignore-refusal wording. #453 (G69, HACK-1386) owns those. The env-layer lock picks up the fallback and the ENAMETOOLONG mapping only through the shared helpers.
git merge-treeof this head with the current #453 head is clean.Tests
The new tests are in
tests/project-env-write-refusal-gaps.test.ts.tests/fixtures/staging-errno-preload.tsgains alinkinjection step.link(2)injected as ENOTSUP or EPERM, in and out of git: the first--secretcreates a 0600 key, a second set adopts it, the lock is released and nothing is left behind. Concurrent sets of three keys under ENOTSUP keep every key. Unit tests cover the fallback: it publishes and uses up its source, never replaces an existing file, never follows or replaces a symlink, and removes its reservation when the rename fails. A real ExFAT test is opt-in on macOS withHACK_TEST_EXFAT=1, because it attaches a disk image; the image is detached infinally..internalsymlink under all three loggers and--json; unit tests forensureDirForWriteandensureRealDirectoryForWrite.--jsonwith a 215-character name; ENAMETOOLONG injected at layer staging and at the layer lock.E_LOCKED. Tests also coverrecoveryBusypropagation and both messages..hack/.gitignoredirectory under all three loggers andhydrate --json.Mutation proof: each mutation below was applied alone, its focused tests were run, and the source was restored. All 21 were killed.
unlinks the consumed candidate / M3 failed rename keeps its reservation / M3b placeholder path replaces an existing fileensureDirForWriteraw EEXIST / M5 hydrate lock uses plainensureDirForWrite<) / M7 ENAMETOOLONG unclassifiedrecoveryBusydropped.hack/.gitignorerefusal back toE_UNEXPECTEDisProjectEnvKeyLockUnavailablenot_regular_file,not_writable,owner_not_preserved,group_not_preservednot_a_directoryEvidence
hdiutil create -fs ExFAT, detached in a trap; private HOME, HACK_HOME and DOCKER_CONFIG; keychain fallback disabled; layer file written first):origin/next(7d9c89f): rawENOTSUP … link '…hack.env.default.yaml.lock.<uuid>.owner'plus a stack, exit 1, in a plain directory, a git checkout and a linked worktree.env getround-trips the value. A git checkout runs a second--secretset and a non-secret set. In a linked worktree, the key is written to the shared location in.git. No.tmp,.owneror.lockfile is left behind.--secretsets on ExFAT, in a plain directory and in a git checkout, all exit 0, and all six values round-trip.HACK_TEST_EXFAT=1test passes (85 expects).dist/hack:EEXIST … mkdirwith a stack, andE_UNEXPECTED. After:E_PATH_CONFLICT: Could not create directory <…>/.hack/.internal: that path is a symlink or not a directory. Replace it with a regular directory, then retry.ENAMETOOLONG … open '<…>.owner'. After: the fixedInvalid --env value.line;--jsongivesE_USAGE. A 128-character name still writes its layer.Timed out waiting for project env key lockwith a stack. After:E_LOCKED: … remove <…>/.git/.hack.secret.key.lock, then retry.No key is created, and the retry succeeds once the lock is removed.E_UNEXPECTED. After:E_PATH_CONFLICT: Refused .hack/.gitignore access: ….project-env-write-refusal-gaps: 28 pass, 1 skip (the opt-in ExFAT test).project-env-write-refusals: 74/74 with--timeout 120000.--timeout 120000(projects-registry-lock,projects-registry-identity,project-env-layer-lock,project-env-key-ignore,project-env-worktree-key,private-env-file,env-selection-parity,env-key-argument-redaction-cli,focused-skills): 113/113.bun run check(includesprivacy:check): exit 0.bun run --cwd packages/cli typecheck: 0 errors.gitleaks diron thegit log -p origin/next..HEADexport finds no leaks.bun run generate:agent-pluginsandbun run docs:cli-referenceproduce no further diff.testjob at this PR head.Gaps
.hack.secret.keyor lock file. Hack refuses it and never adopts or reclaims it, so it must be removed by hand.docs/env.mdsays so. ExFAT and FAT also do not store Unix permissions, so mode 0600 does not limit the key there; this is documented as well.--envprints the same one-lineERRORon stderr as any other invalid--env, plus the existing usage help on stdout. That is the pre-existing usage-error convention, and this PR leaves it unchanged.project-env-worktree-key,project-env-key-ignoreandproject-env-write-refusalsfailed with 5-second hook timeouts. The sameproject-env-worktree-keyfile also fails on an untouchedorigin/nextexport under the same load (8 of 14). With a 120-second timeout, all of these files pass. Hosted CI is the full-suite evidence.E_LOCKEDis new. fix(env): name unreclaimable layer locks and the re-include cause #453 leaves the per-layer lock refusal without a code. If it wants one, it can reuseE_LOCKEDonce both PRs land.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need.