JuliusBrussee/caveman · error
file changed while opening
Error message
file changed while opening
What it means
On Linux, engine/ccr secures the SQLite database file by chmod'ing it to 0600 through a pinned descriptor (fchmodat with AT_EMPTY_PATH, or procfs fallback) so a swapped pathname cannot be attacked. Before doing so it re-stats the open file and requires it to still be a regular file and the same inode as when first observed; otherwise it throws 'file changed while opening' and aborts the securing step.
Solutions
- Treat this as a deliberate fail-closed security abort: check for concurrent writers/replacers of the database path and ensure only one process instance uses it
- Retry the operation once the path is stable — the caller creates the file with exclusive semantics, so a clean re-open usually succeeds
- If it reproduces without an attacker, audit for duplicate Store instances or cleanup jobs (tmp file sweepers) racing the open, and serialize them
Example fix
// before
// two goroutines open the same ccr sqlite path concurrently
// after
var openOnce sync.Once
openOnce.Do(func() { store, err = engine.OpenCCRStore(path) }) // serialize store creation per path Defensive patterns
Strategy: try-catch
Try / catch
store, err := engine.OpenCCRStore(path)
if err != nil && strings.Contains(err.Error(), "file changed while opening") {
// something replaced the path mid-open: ensure a single owner, then retry once
store, err = engine.OpenCCRStore(path)
} Prevention
- Create the store once per process/path (sync.Once or singleton)
- Exclude the data directory from sync/backup tools that rename-over files
- Never point the store at a path other processes may delete or recreate
When it happens
Trigger: Between the initial Stat of path and the pinned open, the path is replaced (symlink swap, rename-over, delete-and-recreate) or becomes a non-regular file, so os.SameFile(info, opened) returns false inside chmodSQLiteFile on Linux.
Common situations: A concurrent attacker or racing process swapping the database path; two Store instances racing on the same path where one deletes/recreates the file; tmp-once semantics where a cleanup routine removes the file mid-open; tests simulating TOCTOU attacks on the db path.
Related errors
- file changed while securing
- file changed while securing
- inspect sqlite parent ACL
- refusing non-regular file
- sqlite parent has no restrictive DACL
AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20).
Data as JSON: /api/errors/6efc51bef29af702.
Report an issue: GitHub.
Appendix: source
Thrown at engine/ccr/sqlite_file_security_linux.go:38
return unix.Fchmodat(fd, "", 0o600, unix.AT_EMPTY_PATH)
}
func chmodSQLiteFile(path string, info os.FileInfo) error {
// Closing an ordinary descriptor for the database or -shm drops ALL POSIX
// locks held by SQLite in this process. O_PATH pins the inode without opening
// it for I/O; closing this metadata-only descriptor does not release locks.
fd, err := unix.Open(path, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return err
}
file := os.NewFile(uintptr(fd), path)
defer file.Close()
opened, err := file.Stat()
if err != nil {
return err
}
if !opened.Mode().IsRegular() || !os.SameFile(info, opened) {
return fmt.Errorf("file changed while opening")
}
if err := fchmodatEmptyPath(fd); !errors.Is(err, unix.EOPNOTSUPP) && !errors.Is(err, unix.EINVAL) {
return err
}
// Kernels before fchmodat2/AT_EMPTY_PATH require procfs. This is the pinned
// descriptor's kernel-controlled link, NOT the swappable database pathname.
// chmod performs no open/close, so SQLite's locks remain intact. Fail closed
// if procfs is unavailable; never fall back to opening the database for I/O.
return unix.Chmod("/proc/self/fd/"+strconv.Itoa(fd), 0o600)
}
View on GitHub (pinned to 3ee70a1026)