Crash-safe atomic file replacement for Linux and OpenBSD.
libchevron implements the complete correct write-to-temp-then-rename sequence, handling all known failure modes so callers do not have to. It is the C equivalent of Go's renameio.
Writing a file with open / write / close is not crash-safe. If the
process crashes or the system loses power mid-write, the file is left partially
written. The correct pattern is well-known: write to a temporary file, fsync
it, rename it atomically over the target, then fsync the parent directory. In
practice most project-internal implementations of this pattern get at least one
step wrong. The failures are invisible during development and surface only under
power loss in production.
libchevron encodes all seven failure modes correctly in one auditable place:
| Failure mode | How libchevron handles it |
|---|---|
| Missing parent directory fsync | Performed unconditionally under CHEVRON_FULL |
| Wrong temporary file location | Temp created in the same directory as the target |
| Incorrect permission handling | fchown then fchmod on every path; setuid/setgid preserved |
| Not retrying fsync on EINTR | All fsyncs use an EINTR-retry loop |
| Not checking write return values | Write loop checks every return; retries short writes |
| Not checking close return values | close on the temp fd is fatal before renameat |
| Predictable temporary file naming | mkostemp with O_CLOEXEC; O_TMPFILE on Linux (unnamed inode) |
- Single-shot and streaming interfaces over a single unified internal implementation - one place to audit, one place to fix
O_TMPFILEon Linux kernel 3.11+ - the temporary inode has no directory entry during the write phase, eliminating the symlink attack window; runtime detection with fallback tomkostempon older kernels- Permission and ownership preservation - existing target's mode, uid, and
gid are read and applied to the replacement file;
fchownbeforefchmodso setuid/setgid bits survive - Three durability levels - full fsync of file and directory, file-only fsync, or rename-only
- Precise error reporting - every failure returns a specific
chevron_err_tandchevron_op_tidentifying the class of failure and the exact operation that failed - Zero heap allocation - handle is stack-allocatable; no
mallocorfreeanywhere in the library - Zero dependencies - libc and POSIX only
- Dirfd-anchored operations - all commit-critical namespace operations anchored to the opened parent directory fd; namespace races are closed
| Platform | Path used |
|---|---|
| Linux kernel 3.11+ | O_TMPFILE + linkat + renameat |
| Linux kernel < 3.11 | mkostemp + renameat (runtime fallback) |
| OpenBSD | mkostemp + renameat |
| Requirement | Notes |
|---|---|
| C11 compiler | Clang (primary) or GCC |
| POSIX.1-2008 | -D_POSIX_C_SOURCE=200809L |
| GNU make or BSD make | GNU make ≥ 3.82 on Linux; BSD make on OpenBSD |
ar |
For building the static library |
clang |
Required for make test-tsan; GCC works for all other targets |
Optional tools used by the build system:
| Tool | Target |
|---|---|
valgrind |
make valgrind |
clang-tidy |
make lint |
cppcheck |
make lint |
clang-format |
make format |
# Build the optimised static library
make release
# Install to /usr/local (requires write permission)
make install
# Install to a custom prefix
make install PREFIX=/opt/localThis installs:
$(PREFIX)/lib/libchevron.a$(PREFIX)/include/chevron.h
To build against the installed library:
cc -std=c11 -I/usr/local/include myprogram.c -L/usr/local/lib -lchevron -o myprogramWrite a buffer to a file atomically in one call:
#include <chevron.h>
#include <stdio.h>
int main(void)
{
const char *config = "[service]\nport=8080\n";
chevron_error_t err;
if (chevron_write("/etc/myapp/config.ini",
config, strlen(config),
CHEVRON_FULL,
CHEVRON_MODE_DEFAULT,
&err) != 0) {
fprintf(stderr, "write failed: err=%d op=%d errno=%d\n",
err.err, err.op, err.errno_value);
return 1;
}
return 0;
}CHEVRON_MODE_DEFAULT applies mode 0600 to new files and preserves the
existing mode when replacing an existing file.
Write data incrementally when the full content is not available up front:
#include <chevron.h>
#include <stdio.h>
int write_certificate(const char *path, const char *pem_chain, size_t len)
{
chevron_handle_t h = CHEVRON_HANDLE_INIT;
chevron_error_t err;
/* Open: validates arguments, opens parent dir, creates temp file */
if (chevron_open(&h, path, CHEVRON_FULL, CHEVRON_MODE_DEFAULT, &err) != 0)
goto fail;
/* Write: may be called multiple times */
if (chevron_write_chunk(&h, pem_chain, len, &err) != 0)
goto abort; /* handle already cleaned up on write failure */
/* Commit: fchown, fchmod, fsync, close, renameat, dir fsync */
if (chevron_commit(&h, &err) != 0)
goto fail; /* handle is inactive after commit regardless */
return 0;
abort:
/* Safe to call after write failure - no-op because handle is inactive */
chevron_abort(&h);
fail:
fprintf(stderr, "certificate write failed: err=%d op=%d errno=%d\n",
err.err, err.op, err.errno_value);
return -1;
}Handle lifecycle rules:
- Always initialise with
CHEVRON_HANDLE_INIT- never zero-initialise (_fd == 0is a valid live file descriptor) - After
chevron_commitorchevron_abort, reinitialise withCHEVRON_HANDLE_INITbefore reuse - After a
chevron_write_chunkfailure, the handle is already inactive; callingchevron_abortis safe and is a no-op chevron_aborton an inactive handle is always a no-op
| Level | File fsync | Directory fsync | Guarantee |
|---|---|---|---|
CHEVRON_FULL |
Yes | Yes | Data and directory entry survive crash |
CHEVRON_FILE |
Yes | No | Data survives crash; directory entry durability depends on a higher-level mechanism |
CHEVRON_NONE |
No | No | Atomic replacement only; not durable across crash |
Choose CHEVRON_FULL when you need the strongest guarantee. Choose
CHEVRON_FILE when directory entry durability is handled elsewhere (for
example a journal or a higher-level fsync). Choose CHEVRON_NONE when
atomicity is required but durability is not (for example in-memory-backed
tmpfs, or test environments).
Every function returns 0 on success and -1 on failure. The optional
chevron_error_t *err argument (may be NULL) provides detail:
typedef struct {
chevron_err_t err; /* class of failure */
int errno_value; /* errno at point of failure */
chevron_op_t op; /* specific operation that failed */
} chevron_error_t;chevron_err_t values: CHEVRON_ERR_NONE, CHEVRON_ERR_INVALID,
CHEVRON_ERR_OPEN, CHEVRON_ERR_WRITE, CHEVRON_ERR_FSYNC,
CHEVRON_ERR_CLOSE, CHEVRON_ERR_RENAME, CHEVRON_ERR_PERMISSION.
chevron_op_t values identify the exact step: CHEVRON_OP_OPEN_DIR,
CHEVRON_OP_OPEN_TMP, CHEVRON_OP_STAT_TARGET, CHEVRON_OP_WRITE,
CHEVRON_OP_FCHOWN, CHEVRON_OP_FCHMOD, CHEVRON_OP_FSYNC_FILE,
CHEVRON_OP_CLOSE_TMP, CHEVRON_OP_RENAME, CHEVRON_OP_FSYNC_DIR.
ACLs and extended attributes. Only mode, uid, and gid are preserved. POSIX ACLs, SELinux security contexts, and other extended attributes are silently discarded. Callers that depend on these must restore them after the call.
Orphaned temporary files. If the process crashes after temp creation but
before renameat, the temp file remains as an orphan in the parent directory.
The library cannot clean up what it did not create in the current invocation.
linkat EOPNOTSUPP. On Linux, if the O_TMPFILE open probe succeeds but
linkat subsequently fails with EOPNOTSUPP (filesystem does not support
AT_EMPTY_PATH), the library returns a hard failure. The mid-sequence fallback
to mkostemp is not attempted because the unnamed inode cannot be unlinked by
normal means.
Post-rename directory fsync failure. Under CHEVRON_FULL, if renameat
succeeds but the subsequent directory fsync fails, the function returns -1.
The new file may already be visible at the target path. What failed is
directory entry durability, not the atomic replacement itself.
TOCTOU on permission preservation. fstatat reads the target's mode and
ownership; fchown/fchmod then applies those values. A concurrent process
can change the target's permissions in this window. This race cannot be
eliminated without directory locking that is out of scope.
Silent ownership change on EPERM. When fchown fails with EPERM the
replacement file is owned by the process's effective uid/gid, not the original
owner. This is intentional: EPERM is non-fatal so the write still succeeds.
Directory fsync on FUSE and overlayfs. fsync on a directory fd may
return EINVAL on these filesystems. Under CHEVRON_FULL this is a hard
failure. Use CHEVRON_FILE or CHEVRON_NONE on these filesystems.
No multi-writer coordination. Concurrent writes to the same target path from multiple processes or threads are not serialised by the library. The caller is responsible for coordination.
No network filesystem guarantees. NFS, CIFS, and similar have different fsync and rename semantics. libchevron documents local POSIX filesystem behaviour only.
Relative paths resolved at open time. Relative paths are resolved against
the process cwd during chevron_open. After open, all operations are
dirfd-anchored and unaffected by chdir. A concurrent chdir during
chevron_open can cause the open to resolve to the wrong directory. Use
absolute paths in multithreaded programs.
Concurrent calls using separate handles are safe. There is no global state. A single handle must not be used concurrently from multiple threads.
Relative paths are resolved against the process cwd during chevron_open. A
concurrent chdir during chevron_open can cause the open to resolve to the
wrong directory. Use absolute paths in multithreaded programs.
ISC License. See LICENSE.