local.FS.Write writes directly to the destination path:
// fs/local/localfs.go (main)
dst, err := os.OpenFile(fullPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, filePerm)
if err != nil { ... }
n, err := io.Copy(dst, src)
with filePerm = 0644. Three defects follow from those two lines.
1. A failed write destroys the previous file
O_TRUNC empties the target before the first byte of new content is written. If io.Copy fails, the context is canceled, the source reader errors, or the process dies mid-write, name is left truncated or partially written — and the complete file that was there is gone. There is no window in which a reader sees the old contents; it sees a corrupt file.
This matters more than usual here: OCFL's whole premise is that content files are immutable once written and that an inventory can be trusted to describe what is on disk. A half-written inventory.json is a broken object root, and the failure is silent.
2. Overwriting discards the target's mode
filePerm is hardcoded. Writing to an existing file with mode 0600 yields 0644 — a private file becomes world-readable with no diagnostic.
3. Cancellation is not observed
io.Copy(dst, src) never consults ctx. A canceled context is noticed only if the source reader independently returns an error, so a long write to a slow source keeps running after its caller has given up.
Plan
Step 0 — what is already in place
#162 landed as PR #170, as fs/internal/imptest (not internal/testutil). Two subtests there are written out in full and skipped waiting on this issue:
fs/internal/imptest/writefs.go:90 t.Skip("local Write is not atomic; see #163") // source error leaves previous content intact
fs/internal/imptest/writefs.go:108 t.Skip("local Write is not atomic; see #163") // source error on a new file leaves nothing behind
Deleting those two lines is the acceptance test for this issue. fs/internal/imptest/imptest.go:62 lists this as one of three outstanding skips and should lose its mention of #163 too. fs/local/imptest_test.go already runs the suite against the local backend, so no wiring is needed.
Step 1 — temp file, copy, rename
Restructure FS.Write into the sequence the reference implementation uses (e7d6ca3):
createTempFile(parent, fullPath) — O_CREATE|O_EXCL|O_WRONLY at mode 0666 (umask applies, matching os.Create), retrying on the practically-impossible name collision. O_EXCL means it can never clobber an existing file.
- Preserve the target's mode.
os.Lstat(fullPath); only when info.Mode().IsRegular(), tmp.Chmod(info.Mode().Perm()) — chmod is not umask-masked, so the mode is copied exactly. Track this with an explicit havePerm bool, not a mode != 0 sentinel, or a target legitimately at 0000 silently gets the default.
Write it this way the first time. The reference reached it by landing os.Stat (b85ed2d) and fixing it in fea16d5: os.Stat reads the referent's mode, and a symlink's own mode is 0777 on POSIX, so either value on hand is wrong for the regular file being created. IsRegular() rejects both, plus directories and devices, in one condition.
- Copy through a context-aware reader.
tmp.Sync(), then tmp.Close(). Close must precede the rename — Windows cannot move a file that is still open.
- Rename over the target (Step 3).
- Best-effort fsync of the containing directory.
A defer closes and removes the temp file on every path before the rename commits, so a failed or canceled write leaves no litter.
Side effect worth recording: this also closes item 1 of #164. O_CREATE|O_TRUNC on fullPath is what follows a symlink at name and writes through to its referent; once Write never opens fullPath, the rename replaces the link entry and the referent is untouched. #164's remaining scope is RemoveAll via os.Root, plus the regression tests that pin both halves.
Step 2 — the context-aware reader must hide io.WriterTo
type ctxReader struct {
ctx context.Context
src io.Reader
}
func (r ctxReader) Read(p []byte) (int, error) {
if err := r.ctx.Err(); err != nil {
return 0, err
}
return r.src.Read(p)
}
Exposing only Read is the point. If the wrapper forwarded the underlying reader's io.WriterTo (as strings.Reader, bytes.Reader and *os.File all have), io.Copy and os.File.ReadFrom would take a fast path that never calls Read and never checks the context. The reference used a hand-rolled copyWithContext chunk loop instead; either shape works, but the wrapper composes with io.Copy's buffer reuse and is less code. Whichever ships, a test must pass a *strings.Reader specifically — that is the type whose fast path defeats a naive wrapper.
Also: when the copy fails and ctx.Err() != nil, report the context error rather than the underlying read error, so callers can match context.Canceled / context.DeadlineExceeded.
Step 3 — Windows: use robustio, and drop the MoveFileEx helper
The premise behind rename_windows.go (3a1ad19) is false, and the helper should not be ported. Its doc comment says "os.Rename cannot replace an existing destination file on Windows". Verified against the go1.25 toolchain in this environment:
os/file_windows.go:221 e := windows.Rename(fixLongPath(oldname), fixLongPath(newname))
internal/syscall/windows/syscall_windows.go:364 return MoveFileEx(from, to, MOVEFILE_REPLACE_EXISTING)
os.Rename on Windows is MoveFileEx(..., MOVEFILE_REPLACE_EXISTING). 2041a91's commit message already noticed this ("os.Rename there already maps to MoveFileEx(MOVEFILE_REPLACE_EXISTING)") but kept the helper anyway. So the helper is strictly worse than the standard library on two counts:
- Its first strategy duplicates
os.Rename minus fixLongPath, which os.rename applies and the helper drops. Long OCFL content paths past MAX_PATH are exactly the case Step 4's NAME_MAX truncation exists for, and the helper regresses them.
- Its
os.Remove + os.Rename fallback is a real downgrade of the guarantee this issue exists to establish: a failure between the two leaves no file at the target. It trades away atomicity to work around a problem that does not exist.
The Windows failure this code actually needs to survive is different: after tmp.Close(), a virus scanner or the Search Indexer can hold a transient handle to the just-closed temp file, and the rename fails with ERROR_SHARING_VIOLATION or ERROR_ACCESS_DENIED. That is what robustio is for — a bounded retry that keeps the atomic replace instead of degrading it:
// fs/local/localfs.go — no build tags, no platform files
import "github.com/rogpeppe/go-internal/robustio"
if err := robustio.Rename(tmpPath, fullPath); err != nil { ... }
robustio.Rename wraps os.Rename and retries ERROR_ACCESS_DENIED / ERROR_FILE_NOT_FOUND / ERROR_SHARING_VIOLATION with randomized backoff up to ~2s. On !windows && !darwin (robustio_other.go) it is a plain passthrough to os.Rename, so Linux behavior is bit-for-bit unchanged.
Note that golang.org/x/tools/internal/robustio and cmd/go/internal/robustio are both internal/ and cannot be imported. github.com/rogpeppe/go-internal/robustio is the same code, published at the top level of that module. Cost, measured against this repo's go.mod:
go.mod +1 line: github.com/rogpeppe/go-internal v1.16.0
go.sum +2 lines
Zero transitive modules — go mod tidy pulls in nothing else; robustio's only non-stdlib import is a sibling internal/syscall/windows package inside the same module. BSD-3 (Go Authors), compatible with this repo's MIT. It also means golang.org/x/sys stays an indirect dependency, where 3a1ad19 promoted it to direct, and it deletes ~110 lines of platform code plus its two test files from this repo before they are ever written here.
Two honest caveats:
robustio retries on darwin too, on ENOENT, for up to ~2s. Here the rename source is a temp file this function just created and closed, so ENOENT means a genuine bug or an external cleanup; the cost is a delay before an error that was going to be returned regardless.
robustio.retry sleeps without consulting a context. After the trouble Step 2 takes to make the copy cancellable, the final rename can still block ~2s past cancellation. This is acceptable: the copy is already complete at that point and the write is one syscall from committing — abandoning it there is worse than finishing it. Worth a one-line comment saying so.
If a new module dependency is unwanted, the fallback is to vendor the ~50 relevant lines as fs/local/internal/robustio with BSD-3 attribution retained. ERROR_SHARING_VIOLATION is not in stdlib syscall; define it as syscall.Errno(32) or take it from x/sys/windows. Recommendation: take the dependency — three lines of manifest against a maintained upstream is the better trade.
Step 4 — temp name must cut at a UTF-8 rune boundary
Carry 0ac1238 over. The name is "." + base + ".tmp-" + <16 hex>, so base must be capped at 255 - 22 = 233 bytes to stay inside NAME_MAX. Slicing raw bytes at 233 can split a multibyte rune, producing invalid UTF-8 that strict filesystems reject with a confusing PathError. Back the limit off to the nearest rune start with a utf8.RuneStart scan. Names long enough to hit this are legal per fs.ValidPath and ordinary in OCFL content paths.
Step 5 — document the guarantee where callers see it
WriteFS.Write in fs/fs.go has no doc comment at all today (fs/fs.go:50), while Remove and RemoveAll both carry their contract. Add one: implementations should make the replacement atomic where the backend allows; a failed write must leave the previous contents intact and must not create a partial file; s3 gets this from object-upload semantics, local from temp-file-and-rename.
local.FS.Write documents the concrete sequence, the cleanup guarantee, mode preservation and the symlink-entry replacement. It should not repeat 2041a91's "not atomic on Windows" disclaimer — with Step 3 there is no degraded path to disclaim.
- README: the
Write guarantee is not mentioned anywhere in it today. Add it where the local backend is introduced.
Step 6 — tests and CI
Flip the two imptest skips (Step 0), then add to fs/local:
| test |
pins |
| new-file mode |
0666 &^ umask, not a hardcoded 0644 |
| overwrite preserves mode |
write 0600, overwrite, still 0600 |
target at mode 0000 |
preserved — the case a mode != 0 sentinel loses |
| symlinked target |
replacement is a regular file and is not 0777; referent untouched (!windows) |
| mid-copy cancel |
old file intact, no partial, no temp left in the directory |
context.DeadlineExceeded |
surfaces as itself, not as a read error |
*strings.Reader source cancel |
the io.WriterTo fast path does not bypass the ctx check |
| reader error |
temp file removed, previous content intact |
| large payload |
multi-chunk copy across the buffer boundary |
| concurrent writers to one name |
every observed read is one complete version, never a mix |
tempFileName |
short / sub-limit multibyte / long ASCII / >255-byte multibyte all land ≤255 bytes, valid UTF-8, creatable |
CI runs ubuntu-latest only (.github/workflows/go.yml:10), so nothing here exercises Windows. Choosing robustio over a hand-written rename_windows.go largely removes the exposure — there is no untested platform-specific code left in this repo. Still add GOOS=windows go vet ./... as a cross-compile check; a windows-latest matrix entry would be better and is cheap.
Resolved open questions
Is the directory fsync worth its cost? Keep it. It is what makes the rename itself survive a machine crash, and the durability of a committed inventory is the whole point. Add a BenchmarkWriteMany in the PR so the per-file cost is on the record, and defer any opt-out knob until someone has a number to argue from — if it ever arrives it should be an option on NewFS, never a silent drop of durability.
The Windows fallback is not atomic — accept, document, or fail loudly? None of the three. Delete it (Step 3). robustio.Rename either completes the atomic replace or returns an error; there is no third state to document.
Should Write succeed when the target is not a regular file? Symlink: yes, replacing the link entry — that is #164's requirement and Step 1 satisfies it. Directory: let the rename fail and wrap the error in a PathError. Do not add an Lstat pre-check to reject it; that is a TOCTOU race that buys only a nicer error message.
Smaller ones for review: Write currently returns bytes-copied alongside a non-nil error even though nothing reached name — returning 0 on any pre-rename failure reads truer and could be pinned in imptest. And a crash between create and rename leaves a .<base>.tmp-<hex> file that an OCFL validator will flag as unexpected content; accept and document it (a crash mid-write leaves a broken object regardless) rather than filtering temp names out of DirEntries, which would hide real filesystem state from a validator.
Reference
Retained branch fs-fixes — one working implementation, not a specification. Note that 3a1ad19 and the Windows half of 2041a91 are superseded by Step 3, and b85ed2d/fea16d5 are a detour Step 1 skips.
git show e7d6ca3 # fs/local: atomic Write via temp file and rename
git show 0ac1238 # fs/local: cut tempFileName at UTF-8 rune boundary
git show 3a1ad19 # fs/local: add Windows rename-overwrite helper using MoveFileEx (superseded)
git show 2041a91 # fs/local: wire Windows rename helper into Write (partly superseded)
git show fea16d5 # fs/local: don't stamp a symlink's 0777 onto the replacement file
git show 9417c97 # fs: document atomic write guarantee on WriteFS and local Write
git show 70238b9 # tests: large payload, cancel cleanup, reader concurrency, temp lifecycle
git show 20331de # tests: ctx-cancel, deadline-exceeded, traversal rejection
Depends on #162 (landed, PR #170). Overlaps #164 (same function) — land this one first; Step 1 shrinks #164 to its RemoveAll half plus regression tests.
local.FS.Writewrites directly to the destination path:with
filePerm = 0644. Three defects follow from those two lines.1. A failed write destroys the previous file
O_TRUNCempties the target before the first byte of new content is written. Ifio.Copyfails, the context is canceled, the source reader errors, or the process dies mid-write,nameis left truncated or partially written — and the complete file that was there is gone. There is no window in which a reader sees the old contents; it sees a corrupt file.This matters more than usual here: OCFL's whole premise is that content files are immutable once written and that an inventory can be trusted to describe what is on disk. A half-written
inventory.jsonis a broken object root, and the failure is silent.2. Overwriting discards the target's mode
filePermis hardcoded. Writing to an existing file with mode0600yields0644— a private file becomes world-readable with no diagnostic.3. Cancellation is not observed
io.Copy(dst, src)never consultsctx. A canceled context is noticed only if the source reader independently returns an error, so a long write to a slow source keeps running after its caller has given up.Plan
Step 0 — what is already in place
#162 landed as PR #170, as
fs/internal/imptest(notinternal/testutil). Two subtests there are written out in full and skipped waiting on this issue:Deleting those two lines is the acceptance test for this issue.
fs/internal/imptest/imptest.go:62lists this as one of three outstanding skips and should lose its mention of #163 too.fs/local/imptest_test.goalready runs the suite against the local backend, so no wiring is needed.Step 1 — temp file, copy, rename
Restructure
FS.Writeinto the sequence the reference implementation uses (e7d6ca3):createTempFile(parent, fullPath)—O_CREATE|O_EXCL|O_WRONLYat mode0666(umask applies, matchingos.Create), retrying on the practically-impossible name collision.O_EXCLmeans it can never clobber an existing file.os.Lstat(fullPath); only wheninfo.Mode().IsRegular(),tmp.Chmod(info.Mode().Perm())—chmodis not umask-masked, so the mode is copied exactly. Track this with an explicithavePerm bool, not amode != 0sentinel, or a target legitimately at0000silently gets the default.Write it this way the first time. The reference reached it by landing
os.Stat(b85ed2d) and fixing it infea16d5:os.Statreads the referent's mode, and a symlink's own mode is0777on POSIX, so either value on hand is wrong for the regular file being created.IsRegular()rejects both, plus directories and devices, in one condition.tmp.Sync(), thentmp.Close(). Close must precede the rename — Windows cannot move a file that is still open.A
defercloses and removes the temp file on every path before the rename commits, so a failed or canceled write leaves no litter.Side effect worth recording: this also closes item 1 of #164.
O_CREATE|O_TRUNConfullPathis what follows a symlink atnameand writes through to its referent; onceWritenever opensfullPath, the rename replaces the link entry and the referent is untouched. #164's remaining scope isRemoveAllviaos.Root, plus the regression tests that pin both halves.Step 2 — the context-aware reader must hide
io.WriterToExposing only
Readis the point. If the wrapper forwarded the underlying reader'sio.WriterTo(asstrings.Reader,bytes.Readerand*os.Fileall have),io.Copyandos.File.ReadFromwould take a fast path that never callsReadand never checks the context. The reference used a hand-rolledcopyWithContextchunk loop instead; either shape works, but the wrapper composes withio.Copy's buffer reuse and is less code. Whichever ships, a test must pass a*strings.Readerspecifically — that is the type whose fast path defeats a naive wrapper.Also: when the copy fails and
ctx.Err() != nil, report the context error rather than the underlying read error, so callers can matchcontext.Canceled/context.DeadlineExceeded.Step 3 — Windows: use
robustio, and drop theMoveFileExhelperThe premise behind
rename_windows.go(3a1ad19) is false, and the helper should not be ported. Its doc comment says "os.Rename cannot replace an existing destination file on Windows". Verified against the go1.25 toolchain in this environment:os.Renameon Windows isMoveFileEx(..., MOVEFILE_REPLACE_EXISTING).2041a91's commit message already noticed this ("os.Rename there already maps to MoveFileEx(MOVEFILE_REPLACE_EXISTING)") but kept the helper anyway. So the helper is strictly worse than the standard library on two counts:os.RenameminusfixLongPath, whichos.renameapplies and the helper drops. Long OCFL content paths pastMAX_PATHare exactly the case Step 4'sNAME_MAXtruncation exists for, and the helper regresses them.os.Remove+os.Renamefallback is a real downgrade of the guarantee this issue exists to establish: a failure between the two leaves no file at the target. It trades away atomicity to work around a problem that does not exist.The Windows failure this code actually needs to survive is different: after
tmp.Close(), a virus scanner or the Search Indexer can hold a transient handle to the just-closed temp file, and the rename fails withERROR_SHARING_VIOLATIONorERROR_ACCESS_DENIED. That is whatrobustiois for — a bounded retry that keeps the atomic replace instead of degrading it:robustio.Renamewrapsos.Renameand retriesERROR_ACCESS_DENIED/ERROR_FILE_NOT_FOUND/ERROR_SHARING_VIOLATIONwith randomized backoff up to ~2s. On!windows && !darwin(robustio_other.go) it is a plain passthrough toos.Rename, so Linux behavior is bit-for-bit unchanged.Note that
golang.org/x/tools/internal/robustioandcmd/go/internal/robustioare bothinternal/and cannot be imported.github.com/rogpeppe/go-internal/robustiois the same code, published at the top level of that module. Cost, measured against this repo'sgo.mod:Zero transitive modules —
go mod tidypulls in nothing else;robustio's only non-stdlib import is a siblinginternal/syscall/windowspackage inside the same module. BSD-3 (Go Authors), compatible with this repo's MIT. It also meansgolang.org/x/sysstays an indirect dependency, where3a1ad19promoted it to direct, and it deletes ~110 lines of platform code plus its two test files from this repo before they are ever written here.Two honest caveats:
robustioretries on darwin too, onENOENT, for up to ~2s. Here the rename source is a temp file this function just created and closed, soENOENTmeans a genuine bug or an external cleanup; the cost is a delay before an error that was going to be returned regardless.robustio.retrysleeps without consulting a context. After the trouble Step 2 takes to make the copy cancellable, the final rename can still block ~2s past cancellation. This is acceptable: the copy is already complete at that point and the write is one syscall from committing — abandoning it there is worse than finishing it. Worth a one-line comment saying so.If a new module dependency is unwanted, the fallback is to vendor the ~50 relevant lines as
fs/local/internal/robustiowith BSD-3 attribution retained.ERROR_SHARING_VIOLATIONis not in stdlibsyscall; define it assyscall.Errno(32)or take it fromx/sys/windows. Recommendation: take the dependency — three lines of manifest against a maintained upstream is the better trade.Step 4 — temp name must cut at a UTF-8 rune boundary
Carry
0ac1238over. The name is"." + base + ".tmp-" + <16 hex>, sobasemust be capped at255 - 22 = 233bytes to stay insideNAME_MAX. Slicing raw bytes at 233 can split a multibyte rune, producing invalid UTF-8 that strict filesystems reject with a confusingPathError. Back the limit off to the nearest rune start with autf8.RuneStartscan. Names long enough to hit this are legal perfs.ValidPathand ordinary in OCFL content paths.Step 5 — document the guarantee where callers see it
WriteFS.Writeinfs/fs.gohas no doc comment at all today (fs/fs.go:50), whileRemoveandRemoveAllboth carry their contract. Add one: implementations should make the replacement atomic where the backend allows; a failed write must leave the previous contents intact and must not create a partial file; s3 gets this from object-upload semantics, local from temp-file-and-rename.local.FS.Writedocuments the concrete sequence, the cleanup guarantee, mode preservation and the symlink-entry replacement. It should not repeat2041a91's "not atomic on Windows" disclaimer — with Step 3 there is no degraded path to disclaim.Writeguarantee is not mentioned anywhere in it today. Add it where the local backend is introduced.Step 6 — tests and CI
Flip the two
imptestskips (Step 0), then add tofs/local:0666 &^ umask, not a hardcoded06440600, overwrite, still06000000mode != 0sentinel loses0777; referent untouched (!windows)context.DeadlineExceeded*strings.Readersource cancelio.WriterTofast path does not bypass the ctx checktempFileNameCI runs
ubuntu-latestonly (.github/workflows/go.yml:10), so nothing here exercises Windows. Choosingrobustioover a hand-writtenrename_windows.golargely removes the exposure — there is no untested platform-specific code left in this repo. Still addGOOS=windows go vet ./...as a cross-compile check; awindows-latestmatrix entry would be better and is cheap.Resolved open questions
Is the directory fsync worth its cost? Keep it. It is what makes the rename itself survive a machine crash, and the durability of a committed inventory is the whole point. Add a
BenchmarkWriteManyin the PR so the per-file cost is on the record, and defer any opt-out knob until someone has a number to argue from — if it ever arrives it should be an option onNewFS, never a silent drop of durability.The Windows fallback is not atomic — accept, document, or fail loudly? None of the three. Delete it (Step 3).
robustio.Renameeither completes the atomic replace or returns an error; there is no third state to document.Should
Writesucceed when the target is not a regular file? Symlink: yes, replacing the link entry — that is #164's requirement and Step 1 satisfies it. Directory: let the rename fail and wrap the error in aPathError. Do not add anLstatpre-check to reject it; that is a TOCTOU race that buys only a nicer error message.Smaller ones for review:
Writecurrently returns bytes-copied alongside a non-nil error even though nothing reachedname— returning0on any pre-rename failure reads truer and could be pinned inimptest. And a crash between create and rename leaves a.<base>.tmp-<hex>file that an OCFL validator will flag as unexpected content; accept and document it (a crash mid-write leaves a broken object regardless) rather than filtering temp names out ofDirEntries, which would hide real filesystem state from a validator.Reference
Retained branch
fs-fixes— one working implementation, not a specification. Note that3a1ad19and the Windows half of2041a91are superseded by Step 3, andb85ed2d/fea16d5are a detour Step 1 skips.Depends on #162 (landed, PR #170). Overlaps #164 (same function) — land this one first; Step 1 shrinks #164 to its
RemoveAllhalf plus regression tests.