feat(cli): accept --inc-recursive no-op and in-root absolute --temp-dir

This commit is contained in:
2026-09-22 21:58:54 +02:00
parent d780a4625e
commit f96f1764af
8 changed files with 257 additions and 46 deletions
+84 -29
View File
@@ -2,6 +2,7 @@
#include <ctype.h>
#include <dirent.h>
#include <fcntl.h>
#include <limits.h>
#include <libgen.h>
#include <stdio.h>
#include <stdlib.h>
@@ -198,6 +199,56 @@ static FileSaveResult hardlink_sibling_absent_first(const char* destination_path
return FILE_SAVE_ERROR;
}
/* Resolve a user-supplied --temp-dir against the receive `root`.
*
* A relative, traversal-free name is joined below the root (the historical
* behavior). An absolute path is canonicalized with realpath(3) and accepted
* only when it lies inside the canonicalized receive root; this is the parity
* win over rejecting every absolute path, without weakening the confinement
* invariant: an absolute path that escapes the root (including one reached
* through a symlinked component) is still refused. A `..` component in a
* relative name is likewise refused. The root itself is treated as an
* absolute path free of `..`; its realpath() resolves any symlinks so the
* prefix comparison is against one canonical form.
*
* Logs a clear error on rejection (the scratch dir must stay confined) and
* returns a newly allocated scratch path, or NULL on rejection/allocation
* failure. */
static char* file_save_resolve_temp_dir(const char* root, const char* temp_dir) {
if (temp_dir[0] != '/') {
if (has_path_traversal(temp_dir)) {
log_message(
LOG_LEVEL_ERROR,
"receiver rejected --temp-dir '%s': a '..' component would escape the receive root",
temp_dir);
return NULL;
}
return path_cat(root, temp_dir);
}
char canonical_temp[PATH_MAX];
char canonical_root[PATH_MAX];
if (!realpath(temp_dir, canonical_temp)) {
log_message(LOG_LEVEL_ERROR,
"receiver rejected --temp-dir '%s': could not resolve the absolute path (%s)",
temp_dir, strerror(errno));
return NULL;
}
if (!realpath(root, canonical_root)) {
log_message(LOG_LEVEL_ERROR,
"receiver rejected --temp-dir '%s': could not resolve the receive root (%s)",
temp_dir, strerror(errno));
return NULL;
}
if (strcmp(canonical_root, "/") != 0 && !path_is_within_root(canonical_root, canonical_temp)) {
log_message(LOG_LEVEL_ERROR,
"receiver rejected --temp-dir '%s': an absolute temp dir must be inside the "
"receive root '%s'",
temp_dir, canonical_root);
return NULL;
}
return str_dup(canonical_temp);
}
/* Install a --hard-links/-H sibling: the destination entry is atomically
replaced (temp + rename) with a hard link to the group's first member. The
first member is guaranteed already installed at `hardlink_target` under the
@@ -303,17 +354,13 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
free(destination_path);
return absent_result;
}
/* Resolve a relative --temp-dir under the destination root, exactly as the
* primary save path does; an absolute or `..`-escaping value is rejected. */
/* Resolve the --temp-dir under the destination root, exactly as the primary
* save path does: a relative dir joins below the root, an absolute dir is
* accepted only when it canonicalizes inside the root, and any escaping value
* is rejected. */
char* resolved_temp = NULL;
if (cfg->temp_dir) {
if (cfg->temp_dir[0] == '/' || has_path_traversal(cfg->temp_dir)) {
free(content);
free(first_disk);
free(destination_path);
return FILE_SAVE_ERROR;
}
resolved_temp = path_cat(root_directory, cfg->temp_dir);
resolved_temp = file_save_resolve_temp_dir(root_directory, cfg->temp_dir);
if (!resolved_temp) {
free(content);
free(first_disk);
@@ -896,17 +943,17 @@ static bool file_save_try_special_dispatch(const FileSavePlan* plan, bool* creat
and disk paths. Returns false on an invalid/escaping option or an
allocation failure (the caller routes to the cleanup epilogue). */
static bool file_save_resolve_paths(FileSavePlan* plan) {
/* These options arrive from the client. --backup-dir, --partial-dir and
--temp-dir are names below the server root, never independent filesystem
roots: an absolute or `..`-escaping value is rejected outright (rsync's
daemon confines temp-dir to the module the same way). A relative temp dir
is resolved under the receive root below; if that resolution still lands on
a different filesystem than the destination the install falls back to a
non-atomic copy (see file_to_disk_secure_impl), never an abort. */
/* These options arrive from the client. --backup-dir and --partial-dir are
names below the server root, never independent filesystem roots: an
absolute or `..`-escaping value is rejected outright. --temp-dir is
resolved by file_save_resolve_temp_dir below: a relative name joins below
the root, an absolute name is accepted only when it canonicalizes inside
the root, and any escaping value is rejected. If the resolved scratch dir
still lands on a different filesystem than the destination the install
falls back to a non-atomic copy (see file_to_disk_secure_impl), never an
abort. */
if ((plan->backup_dir && (plan->backup_dir[0] == '/' || has_path_traversal(plan->backup_dir))) ||
(plan->partial_dir &&
(plan->partial_dir[0] == '/' || has_path_traversal(plan->partial_dir))) ||
(plan->temp_dir && (plan->temp_dir[0] == '/' || has_path_traversal(plan->temp_dir))))
(plan->partial_dir && (plan->partial_dir[0] == '/' || has_path_traversal(plan->partial_dir))))
return false;
if (plan->backup_dir &&
!(plan->confined_backup = path_cat(plan->root_directory, plan->backup_dir)))
@@ -914,6 +961,9 @@ static bool file_save_resolve_paths(FileSavePlan* plan) {
if (plan->partial_dir &&
!(plan->confined_partial = path_cat(plan->root_directory, plan->partial_dir)))
return false;
if (plan->temp_dir &&
!(plan->confined_temp = file_save_resolve_temp_dir(plan->root_directory, plan->temp_dir)))
return false;
const char* actual_root = plan->use_partial_root ? plan->confined_partial : plan->root_directory;
plan->destination_path = path_cat(plan->root_directory, plan->file->path);
@@ -1146,17 +1196,22 @@ FileSaveResult file_save_to_disk_full_ex(const char* root_directory, const File*
/* A configured --temp-dir sends the temporary working copy to a scratch
directory; the engine then atomically renames the completed file into the
final destination directory. A relative temp dir is resolved under the
receive root and must already exist (an absolute or `..`-escaping value was
rejected above); the engine falls back to a non-atomic copy on EXDEV. 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). */
bool use_temp_dir = plan.temp_dir != NULL && !plan.inplace && !plan.use_partial_root;
final destination directory. The scratch path was confined to the receive
root (and canonicalized) in file_save_resolve_paths and must already exist;
the engine falls back to a non-atomic copy on EXDEV. 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). */
/* --inplace and --partial-dir supersede --temp-dir in rsync, so the scratch
dir is not used on those paths. The value was still validated/confined by
file_save_resolve_paths; drop the resolved path so it is never handed to the
install engine. */
if (plan.confined_temp && (plan.inplace || plan.use_partial_root)) {
free(plan.confined_temp);
plan.confined_temp = NULL;
}
bool use_temp_dir = plan.confined_temp != NULL;
if (use_temp_dir) {
plan.confined_temp = path_cat(root_directory, plan.temp_dir);
if (!plan.confined_temp)
goto out;
/* 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(plan.confined_temp);