Merge feat/p2-temp-dir: implement --temp-dir

Receiver writes temps into a confined scratch dir and atomically renames
into place; EXDEV aborts; inplace/partial bypass; thread-safe temp names.
Uses existing wire field; no protocol bump. Reviewed (c-review APPROVE
WITH NITS, all fixed); PR #262.
This commit is contained in:
2026-09-06 12:52:11 +02:00
10 changed files with 363 additions and 38 deletions
+1
View File
@@ -405,6 +405,7 @@ static const OptionEntry OPTION_TABLE[] = {
{"--ca", NULL, OPT_STRING, offsetof(Config, tls_ca)},
{"--backup-dir", NULL, OPT_STRING, offsetof(Config, backup_dir)},
{"--fastsync-server-path", NULL, OPT_STRING, offsetof(Config, fastsync_server_path)},
{"--temp-dir", NULL, OPT_STRING, offsetof(Config, temp_dir)},
{"--partial-dir", NULL, OPT_STRING, offsetof(Config, partial_dir)},
{"--suffix", NULL, OPT_STRING, offsetof(Config, suffix)},
{"--compress-choice", "--zc", OPT_STRING, offsetof(Config, compress_choice)},
+1
View File
@@ -88,6 +88,7 @@ void print_usage(void) {
printf(" --stderr=MODE Route logging to stderr: errors or all\n");
printf(" --partial Keep partial files on interrupted transfer\n");
printf(" --partial-dir <dir> Directory for partial files\n");
printf(" --temp-dir <dir> Scratch dir for temp files before atomic install\n");
printf(" --fastsync-server-path <path>\n");
printf(" Path to fastsync-server on remote (default: fastsync-server)\n");
printf(
+127 -19
View File
@@ -2,6 +2,7 @@
#include <fcntl.h>
#include <libgen.h>
#include <limits.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -30,6 +31,17 @@ static bool write_all(int fd, const void* data, unsigned long long size) {
return true;
}
/* Process-wide counter for scratch temp names. A --temp-dir scratch directory
is flat: different destinations that share a basename must never race onto
the same temp name. Deriving the trailing number from a global atomic
sequence keeps every temp name unique across the whole scratch directory
even when several threads write concurrently, so the O_EXCL creation loop
below almost never needs a retry. */
static unsigned long long next_temp_sequence(void) {
static atomic_ullong sequence;
return atomic_fetch_add_explicit(&sequence, 1, memory_order_relaxed);
}
bool file_checksum(File* file, uint64_t* checksum) {
if (!file || !checksum || !file->data)
return false;
@@ -326,10 +338,36 @@ bool file_rename_secure(const char* old_path, const char* new_path) {
return ok;
}
/* Open the configured --temp-dir scratch directory, creating it (and any
missing path components) on demand. scratch_path is expected to already be
confined below the authorized root by the caller; file_open_secure_parent
re-checks that confinement and rejects `..` components, so a scratch
directory can never be created or opened outside the destination root.
Returns an O_DIRECTORY|O_NOFOLLOW fd, or -1 on error. */
static int file_open_scratch_dir(const char* scratch_path) {
if (!scratch_path)
return -1;
char* leaf = NULL;
int parent_fd = file_open_secure_parent(scratch_path, &leaf, true);
if (parent_fd < 0)
return -1;
int fd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
if (fd < 0 && errno == ENOENT) {
/* A scratch directory holds transient working copies only; keep it
private (0700) so other users cannot race on temp names inside it. */
if (mkdirat(parent_fd, leaf, 0700) == 0 || errno == EEXIST)
fd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
}
close(parent_fd);
free(leaf);
return fd;
}
static bool file_to_disk_secure_impl(const char* path, const void* data,
unsigned long long data_size, bool inplace, bool sparse,
const FileMetadata* metadata, bool preserve_executability,
bool update, bool no_replace, bool use_fsync) {
bool update, bool no_replace, bool use_fsync,
const char* temp_dir) {
char* leaf = NULL;
int dirfd = file_open_secure_parent(path, &leaf, true);
if (dirfd < 0)
@@ -337,6 +375,8 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
int fd = -1;
bool ok = false;
if (inplace) {
/* --inplace writes directly into the destination; a scratch --temp-dir
does not apply and must never redirect these writes. */
fd = openat(dirfd, leaf, O_WRONLY | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0644);
if (fd >= 0) {
struct stat destination_stat;
@@ -372,7 +412,8 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
}
}
} else {
char tmp[NAME_MAX];
/* The --update newer-destination check runs first so a skipped file never
creates an empty scratch directory behind it. */
if (update && metadata) {
/* This check protects the normal atomic path as far as possible. A
concurrent replacement can still occur before the final rename. */
@@ -384,11 +425,59 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
return true;
}
}
for (unsigned int i = 0; i < 100 && !ok; ++i) {
snprintf(tmp, sizeof(tmp), ".%s.tmp.%ld.%u", leaf, (long)getpid(), i);
fd = openat(dirfd, tmp, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600);
/* Scratch directory for the temporary working copy. When NULL the temp
file is created in the destination directory, exactly as historically. */
int scratch_dirfd = -1;
if (temp_dir) {
scratch_dirfd = file_open_scratch_dir(temp_dir);
if (scratch_dirfd < 0) {
int saved_errno = errno;
log_message(LOG_LEVEL_ERROR, "could not open --temp-dir scratch directory '%s': %s",
temp_dir, strerror(saved_errno));
close(dirfd);
free(leaf);
return false;
}
}
/* Temp names can exceed NAME_MAX for basenames near the limit (leaf plus
the ".tmp.<pid>.<n>" decoration); heap-size the buffer instead of
truncating into a fixed array, which would silently collide in a flat
scratch directory. The sizing sentinel is the widest value of each
format. */
int tmp_size;
if (scratch_dirfd >= 0)
tmp_size = snprintf(NULL, 0, ".%s.tmp.%ld.%llu", leaf, (long)getpid(), ULLONG_MAX);
else
tmp_size = snprintf(NULL, 0, ".%s.tmp.%ld.%u", leaf, (long)getpid(), 999U);
if (tmp_size < 0) {
if (scratch_dirfd >= 0)
close(scratch_dirfd);
close(dirfd);
free(leaf);
return false;
}
char* tmp = malloc((size_t)tmp_size + 1);
if (!tmp) {
if (scratch_dirfd >= 0)
close(scratch_dirfd);
close(dirfd);
free(leaf);
return false;
}
for (unsigned int i = 0; i < 100; ++i) {
/* The temp name is created inside the scratch directory (when one is
configured) and, on success, atomically renamed into the destination
directory. In a shared scratch directory the atomic sequence number
keeps the name unique even for destinations with a common basename. */
if (scratch_dirfd >= 0)
snprintf(tmp, (size_t)tmp_size + 1, ".%s.tmp.%ld.%llu", leaf, (long)getpid(),
next_temp_sequence());
else
snprintf(tmp, (size_t)tmp_size + 1, ".%s.tmp.%ld.%u", leaf, (long)getpid(), i);
fd = openat(scratch_dirfd >= 0 ? scratch_dirfd : dirfd, tmp,
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600);
if (fd < 0)
continue;
continue; /* EEXIST (or a transient open error): try a fresh name. */
if (sparse && data_size > 0)
ok = ftruncate(fd, (off_t)data_size) == 0;
if (ok || (!sparse || data_size == 0))
@@ -404,19 +493,37 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
if (no_replace) {
/* The probe and commit cannot be one operation. A concurrent
creator may win; EEXIST is then the requested skip. */
if (linkat(dirfd, tmp, dirfd, leaf, 0) == 0 || errno == EEXIST) {
if (unlinkat(dirfd, tmp, 0) != 0 && errno != ENOENT)
if (linkat(scratch_dirfd >= 0 ? scratch_dirfd : dirfd, tmp, dirfd, leaf, 0) == 0 ||
errno == EEXIST) {
if (unlinkat(scratch_dirfd >= 0 ? scratch_dirfd : dirfd, tmp, 0) != 0 &&
errno != ENOENT)
ok = false;
} else {
if (scratch_dirfd >= 0 && errno == EXDEV)
log_message(LOG_LEVEL_ERROR,
"temp dir is on a different filesystem than the destination; cannot "
"link file into place (EXDEV); no fallback copy is attempted");
ok = false;
}
} else if (renameat(dirfd, tmp, dirfd, leaf) != 0) {
} else if (renameat(scratch_dirfd >= 0 ? scratch_dirfd : dirfd, tmp, dirfd, leaf) != 0) {
if (scratch_dirfd >= 0 && errno == EXDEV)
log_message(LOG_LEVEL_ERROR,
"temp dir is on a different filesystem than the destination; cannot "
"atomically install file (EXDEV); no fallback copy is attempted");
ok = false;
}
}
if (!ok)
unlinkat(dirfd, tmp, 0);
unlinkat(scratch_dirfd >= 0 ? scratch_dirfd : dirfd, tmp, 0);
/* Once the temp fd was created the outcome is permanent: a write,
metadata, fsync, close, linkat or renameat failure will not be fixed
by retrying under a fresh name, so stop here. Only the open-failure
path above retries a new name. */
break;
}
free(tmp);
if (scratch_dirfd >= 0)
close(scratch_dirfd);
}
if (fd >= 0)
close(fd);
@@ -427,36 +534,37 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
bool file_to_disk_secure(const char* path, const void* data, unsigned long long data_size,
bool inplace, bool sparse, const FileMetadata* metadata,
bool preserve_executability) {
bool preserve_executability, const char* temp_dir) {
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, metadata,
preserve_executability, false, false, false);
preserve_executability, false, false, false, temp_dir);
}
bool file_to_disk_secure_update(const char* path, const void* data, unsigned long long data_size,
bool inplace, bool sparse, const FileMetadata* metadata,
bool preserve_executability) {
bool preserve_executability, const char* temp_dir) {
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, metadata,
preserve_executability, true, false, false);
preserve_executability, true, false, false, temp_dir);
}
bool file_to_disk_secure_with_fsync(const char* path, const void* data,
unsigned long long data_size, bool inplace, bool sparse,
const FileMetadata* metadata, bool preserve_executability,
bool use_fsync) {
bool use_fsync, const char* temp_dir) {
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, metadata,
preserve_executability, false, false, use_fsync);
preserve_executability, false, false, use_fsync, temp_dir);
}
bool file_to_disk_secure_no_replace(const char* path, const void* data,
unsigned long long data_size, bool sparse,
const FileMetadata* metadata, bool preserve_executability) {
const FileMetadata* metadata, bool preserve_executability,
const char* temp_dir) {
return file_to_disk_secure_impl(path, data, data_size, false, sparse, metadata,
preserve_executability, false, true, false);
preserve_executability, false, true, false, temp_dir);
}
bool file_write_to_disk(const char* path, const void* data, unsigned long long data_size,
bool inplace, bool sparse) {
if (!path || (!data && data_size != 0) || has_path_traversal(path))
return false;
return file_to_disk_secure(path, data, data_size, inplace, sparse, NULL, false);
return file_to_disk_secure(path, data, data_size, inplace, sparse, NULL, false, NULL);
}
+15 -4
View File
@@ -31,21 +31,32 @@ bool file_destination_is_newer_secure(const char* path, const FileMetadata* meta
int file_open_secure_parent(const char* path, char** leaf_out, bool create_dirs);
bool file_ensure_directory_secure(const char* path);
bool file_rename_secure(const char* old_path, const char* new_path);
/* The file_to_disk_secure* variants write a temporary copy in the destination
directory and atomically rename it over `path`. temp_dir is an absolute,
root-confined scratch directory (already validated by the caller): when it
is non-NULL the temporary copy is instead created there (with a name unique
across the whole scratch directory) and atomically renamed into the
destination directory once fully written and fsynced. A rename across
filesystems (EXDEV) fails the write with an error; the file is never
silently copied into place. Pass NULL for the historical same-directory
behavior. --inplace writes never use temp_dir. */
bool file_to_disk_secure(const char* path, const void* data, unsigned long long data_size,
bool inplace, bool sparse, const FileMetadata* metadata,
bool preserve_executability);
bool preserve_executability, const char* temp_dir);
bool file_to_disk_secure_with_fsync(const char* path, const void* data,
unsigned long long data_size, bool inplace, bool sparse,
const FileMetadata* metadata, bool preserve_executability,
bool use_fsync);
bool use_fsync, const char* temp_dir);
/* With update enabled, an existing newer destination is left untouched. The
check is descriptor-based for inplace writes; atomic replacement still has
an unavoidable final rename race without filesystem locking. */
bool file_to_disk_secure_update(const char* path, const void* data, unsigned long long data_size,
bool inplace, bool sparse, const FileMetadata* metadata,
bool preserve_executability);
bool preserve_executability, const char* temp_dir);
bool file_to_disk_secure_no_replace(const char* path, const void* data,
unsigned long long data_size, bool sparse,
const FileMetadata* metadata, bool preserve_executability);
const FileMetadata* metadata, bool preserve_executability,
const char* temp_dir);
#endif
+38 -11
View File
@@ -37,6 +37,7 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
const char* backup_suffix = (config && config->suffix) ? config->suffix : "~";
const char* backup_dir = (config && config->backup_dir) ? config->backup_dir : NULL;
const char* partial_dir = (config && config->partial_dir) ? config->partial_dir : NULL;
const char* temp_dir = (config && config->temp_dir) ? config->temp_dir : NULL;
bool use_partial_root = partial_dir && config && config->partial;
char *confined_backup = NULL, *confined_partial = NULL, *disk_path = NULL;
char* destination_path = NULL;
@@ -52,9 +53,13 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
}
/* These options arrive from the client. They are names below the server
root, never independent filesystem roots. */
root, never independent filesystem roots. --temp-dir is confined exactly
like --backup-dir/--partial-dir: an absolute or `..`-escaping scratch
directory is rejected outright so nothing is ever created outside the
authorized destination root. */
if ((backup_dir && (backup_dir[0] == '/' || has_path_traversal(backup_dir))) ||
(partial_dir && (partial_dir[0] == '/' || has_path_traversal(partial_dir))))
(partial_dir && (partial_dir[0] == '/' || has_path_traversal(partial_dir))) ||
(temp_dir && (temp_dir[0] == '/' || has_path_traversal(temp_dir))))
return FILE_SAVE_ERROR;
if (backup_dir && !(confined_backup = path_cat(root_directory, backup_dir)))
return FILE_SAVE_ERROR;
@@ -151,15 +156,37 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
goto fail;
metadata = &adjusted_metadata;
}
bool ok = config && config->ignore_existing
? file_to_disk_secure_no_replace(disk_path, file->data->data, file->data->size,
sparse, metadata, preserve_executability)
: config && config->update
? file_to_disk_secure_update(disk_path, file->data->data, file->data->size, inplace,
sparse, metadata, preserve_executability)
: file_to_disk_secure_with_fsync(disk_path, file->data->data, file->data->size,
inplace, sparse, metadata, preserve_executability,
config && config->use_fsync);
/* A configured --temp-dir sends the temporary working copy to a scratch
directory resolved below the receive root; the engine then atomically
renames the completed file into the final destination directory. The
partial-dir flow already keeps its working copy in a separate directory
and --inplace writes directly, so neither diverts through the scratch
dir (matching rsync, where --inplace/--partial-dir supersede --temp-dir). */
char* confined_temp = NULL;
bool use_temp_dir = temp_dir != NULL && !inplace && !use_partial_root;
if (use_temp_dir) {
confined_temp = path_cat(root_directory, temp_dir);
if (!confined_temp)
goto fail;
/* A user-supplied trailing slash would leave the scratch path ending in
"/", which has no final component to create/open. Normalize it away. */
size_t temp_len = strlen(confined_temp);
while (temp_len > 1 && confined_temp[temp_len - 1] == '/')
confined_temp[--temp_len] = '\0';
}
bool ok =
config && config->ignore_existing
? file_to_disk_secure_no_replace(disk_path, file->data->data, file->data->size, sparse,
metadata, preserve_executability, confined_temp)
: config && config->update
? file_to_disk_secure_update(disk_path, file->data->data, file->data->size, inplace,
sparse, metadata, preserve_executability, confined_temp)
: file_to_disk_secure_with_fsync(disk_path, file->data->data, file->data->size, inplace,
sparse, metadata, preserve_executability,
config && config->use_fsync, confined_temp);
free(confined_temp);
confined_temp = NULL;
if (!ok)
goto fail;