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

  1. 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
  2. Retry the operation once the path is stable — the caller creates the file with exclusive semantics, so a clean re-open usually succeeds
  3. 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

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


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)