Repository navigation
A failed VEX write leaves a truncated OpenVEX document at the output path #1144
Description
[agent] Filed by the scheduled architecture audit routine (CLI and core). Register: #560 (comment).
Kind: bug. Source: new finding (Part 7.3, atomic writes); register C77.
Problem
The OpenVEX document is the one file socket-patch writes without its shared stage + rename writer. Standalone vex --output and the embedded --vex of apply, vendor and scan all write it through generate_vex, which calls tokio::fs::write on the path:
Some(path) => {
if let Err(e) = tokio::fs::write(path, format!("{serialized}\n")).await {
return Err(fail(common, "write_failed", ...).await);tokio::fs::write truncates the destination first and then writes in place. If the write fails partway, the file holds a prefix of the new document.
Two pieces of code assume this can't happen:
- The failure contract.
generate_vex_from_manifest_pathpromises that "a run that ends in error leaves NO OpenVEX document at the output path".- The cleanup,
remove_stale_vex_doc, deletes only a file that parses as JSON with an openvex@context. - A truncated document doesn't parse, so the cleanup keeps it.
- The cleanup,
- The durability doc.
utils/durability.rsstates that "every file socket-patch writes goes through the stage + rename writer insuper::fs". The CLI already has a public entry to that writer,atomic_write_bytes_preserving_mode, which hosted redirects use for user-owned lockfiles.
Reproduction (debug build at 823810a, env -i, run twice with identical results):
- Mount an 8 KiB tmpfs.
- Write a valid 1,372-byte document from a 2-patch manifest:
vex --no-verify --product pkg:npm/app@1.0.0 --output <tmpfs>/out.vex.json --json. - Fill the tmpfs, grow the manifest to 60 patches, and run the same command again.
The second run exits with write_failed ("No space left on device (os error 28)"). It leaves out.vex.json at 4,096 bytes:
- the file starts
{"@context": "https://openvex.dev/ns/v0.2.0", "@id": "urn:uuid:…"; - it stops mid-statement at
"justification": "inline_mitigations_already_exist",; - it is not valid JSON;
- the envelope reports no
vex_stale_doc_removedwarning.
The reverse case also exists. If the stage + rename writer were used, a failed write would leave the old document intact, and the existing stale-document cleanup would then remove it as the contract requires. With the current writer, both the old document and the clean failure state are lost.
Symptoms and impact
- No existing issue covers this; fix(vex): honest machine diagnostics, stale-doc removal, out-of-sync disclosure; offline proxy-notice suppression #208 added the stale-document removal that this bypasses.
- Impact:
- A pipeline that reuses one
--vexpath after a disk-full or quota failure finds a partial OpenVEX file. Its header looks like an attestation, but its body is cut off mid-statement. That breaks the contract the code documents, and any tool that tolerates trailing garbage, or reads only the header, is misled. - Readers that run while a successful run is writing can also see a partial document.
- A pipeline that reuses one
- Size: small. This is the last writer outside
utils::fsin the CLI; the remaining non-test write sites in core are the self-update stage and swap,apply.lockand the blob-fetch stage files, which have their own staging.
Proposed change
- Write the document with
socket_patch_core::utils::fs::atomic_write_bytes_preserving_mode. A user's 0600 or 0640 VEX file keeps its mode. - Delete the direct
tokio::fs::writecall. - Keep the
write_failedcode and the message that names the path. - Behavior note for review: the shared writer replaces a symlink at the destination with a regular file instead of writing through it. For a user-chosen output path that is a small change. If maintainers want write-through, resolve the link once before writing and say so in the contract's
--vexrow. - Inside an open vendored group commit, a write under the project root is captured as a commit point. Check that embedded
vendor --vexstill writes the document after the commit, and in the same run.
Size and scope
- Files:
crates/socket-patch-cli/src/commands/vex.rs(about −3/+3 lines), plus one regression test incrates/socket-patch-cli/tests/e2e_embedded_vex.rsor thevexsuite. - Out of scope: the exit code of
vexruntime failures (Decide: should vex exit 1 on runtime failures, like every other command, instead of 2 #1047, closed), and other writers in core.
Acceptance criteria
-
grep -n "tokio::fs::write" crates/socket-patch-cli/src/commands/vex.rsfinds nothing outside#[cfg(test)]. - Regression test: a failed write leaves either no file or the previous valid document, which the stale-document cleanup then removes; never a partial one.
- Make the write fail without a full disk, for example by making the destination's directory read-only after a successful first run, or by using a failpoint in the stage writer.
- Regression test: rewriting an existing 0600 VEX file keeps mode 0600 (Unix).
-
apply_vex_write_failure_names_path,apply_vex_failure_preserves_non_openvex_fileand thee2e_vex*/e2e_embedded_vexsuites stay green. - Embedded
vendor --vexwith an output path inside the project still writes the document (group-commit capture).
Dependencies
- Blocked by nothing.
- Touches the same file as the open campaign PRs on
vex.rsexit codes (Make every --json top-level error a {code, message} object (#704) #1027). The change is three lines, so a rebase is trivial.
Activity
audit-core: run 2026-10-08T15:40Z, C77 (#1144), backlog consolidation
mikolalysenko commentedon Oct 8, 2026
[agent] Triaged: priority:p3 (cross-cutting VEX output, no ecosystem). Not a duplicate; no open PR covers it (#1027 touches
vex.rsfor error shapes only). Confirmed onmain(823810a):generate_vexincrates/socket-patch-cli/src/commands/vex.rsstill writes the document withtokio::fs::write, which truncates in place, andremove_stale_vex_doconly removes a file that parses as OpenVEX.Generated by Claude Code
mikolalysenko commentedon Oct 9, 2026
[agent] Claiming this issue for the architecture refactor routine (highest leverage: the last CLI writer outside the shared stage + rename writer in
utils::fs, it closes a bug, and its only file,commands/vex.rs, is not changed by any open PR). Branch: arch-refactor/1144-vex-atomic-write. Claim-ID: 2026-10-09T10:56:00Z-bf016cGenerated by Claude Code
mikolalysenko commentedon Oct 9, 2026
[agent] Draft PR: #1262.
Generated by Claude Code
Write the VEX document by stage + rename
refactor: run 2026-10-09T10:56Z, PR #1262 (#1144)
mikolalysenko commentedon Oct 9, 2026
v5 triage: P3, not a release blocker. Partial-output protection after a failed VEX write is P3 hardening. The small pending PR may land, but v5 is not blocked on crash/I/O-failure scenarios.
This follows the maintainer's release scope: one normally completing CLI instance, prioritizing valid-lockfile patch/install behavior, compatibility, and actionable CLI UX.