1244 lines
54 KiB
C
1244 lines
54 KiB
C
#include <errno.h>
|
|
#include <ctype.h>
|
|
#include <dirent.h>
|
|
#include <fcntl.h>
|
|
#include <libgen.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/sysmacros.h>
|
|
#include <unistd.h>
|
|
|
|
#include "array_list.h"
|
|
#include "charset.h"
|
|
#include "chmod.h"
|
|
#include "chunk.h"
|
|
#include "compression.h"
|
|
#include "config.h"
|
|
#include "data.h"
|
|
#include "delay_updates.h"
|
|
#include "delta.h"
|
|
#include "file.h"
|
|
#include "file_save.h"
|
|
#include "format.h"
|
|
#include "identity.h"
|
|
#include "log.h"
|
|
#include "metadata.h"
|
|
#include "protocol.h"
|
|
#include "utils.h"
|
|
#include "xattr.h"
|
|
|
|
bool file_save_to_disk(const char* root_directory, const File* file, const Config* config) {
|
|
return file_save_to_disk_full_ex(root_directory, file, config, NULL, NULL) != FILE_SAVE_ERROR;
|
|
}
|
|
|
|
FileSaveResult file_save_to_disk_full(const char* root_directory, const File* file,
|
|
const Config* config) {
|
|
return file_save_to_disk_full_ex(root_directory, file, config, NULL, NULL);
|
|
}
|
|
|
|
/* --delay-updates receiver path: write the file into a private staging tree
|
|
below the receive root instead of its final destination, and remember it so
|
|
it can be atomically renamed into place only once the whole transfer has
|
|
succeeded. Existence/update policies (--existing/--ignore-existing/--update)
|
|
are decided against the FINAL destination path at stage time so the run
|
|
decides exactly what an immediate (non-delayed) run would decide; the staged
|
|
file is then never re-checked at publication. Backups are deferred to
|
|
publication so the final destination is untouched until the transfer ends. */
|
|
static FileSaveResult file_stage_delayed_update(const char* root_directory,
|
|
const char* destination_path, const File* file,
|
|
Config* config) {
|
|
if (!config)
|
|
return FILE_SAVE_ERROR;
|
|
bool sparse = config->preserve_sparse;
|
|
FileAttrPolicy policy = file_attr_policy_from_config(config);
|
|
|
|
if (config->existing && !file_path_exists_secure(destination_path))
|
|
return FILE_SAVE_SKIPPED;
|
|
if (config->ignore_existing && file_path_exists_secure(destination_path))
|
|
return FILE_SAVE_SKIPPED;
|
|
if (config->update && file_destination_is_newer_secure(destination_path, file->metadata))
|
|
return FILE_SAVE_SKIPPED;
|
|
|
|
FileMetadata adjusted_metadata;
|
|
const FileMetadata* metadata = file->metadata;
|
|
if (metadata && config->chmod_spec && *config->chmod_spec) {
|
|
adjusted_metadata = *metadata;
|
|
if (!chmod_apply(adjusted_metadata.mode, config->chmod_spec, &adjusted_metadata.mode))
|
|
return FILE_SAVE_ERROR;
|
|
metadata = &adjusted_metadata;
|
|
}
|
|
|
|
if (!config->delay_context) {
|
|
config->delay_context = delay_updates_context_create(root_directory);
|
|
if (!config->delay_context)
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
DelayUpdatesContext* context = config->delay_context;
|
|
if (!delay_updates_prepare(context))
|
|
return FILE_SAVE_ERROR;
|
|
|
|
char* staged_path = path_cat(context->staging_root, file->path);
|
|
if (!staged_path)
|
|
return FILE_SAVE_ERROR;
|
|
|
|
/* The staged location is brand new (stale leftovers from a prior crash were
|
|
wiped by prepare), so the plain atomic temp+rename engine installs the
|
|
complete file there. --temp-dir scratch is deliberately not layered on
|
|
top of the delay-updates staging tree. A --link-dest basis file is hard
|
|
linked into the staging tree (so publication's rename keeps the link). */
|
|
bool ok;
|
|
if (file->basis_link) {
|
|
ok = file_to_disk_secure_link(staged_path, file->basis_link, file->data->data, file->data->size,
|
|
config->preallocate, metadata, policy, config->use_fsync, NULL);
|
|
} else if (file->basis_copy) {
|
|
/* --copy-dest basis hit: stream the basis into the staging tree (bounded
|
|
buffers, so an over-limit basis still stages). */
|
|
ok = file_copy_basis_stream_attrs(staged_path, file->basis_copy, file->data->size,
|
|
config->preallocate, metadata, policy, config->update,
|
|
config->use_fsync, file->xattrs, config->fake_super, NULL);
|
|
} else {
|
|
ok =
|
|
file_to_disk_secure_attrs(staged_path, file->data->data, file->data->size, false, sparse,
|
|
config->preallocate, metadata, policy, false, false,
|
|
config->use_fsync, file->xattrs, config->fake_super, false, NULL);
|
|
}
|
|
if (!ok) {
|
|
free(staged_path);
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
|
|
if (!delay_updates_record(context, staged_path, destination_path, file->path)) {
|
|
unlink(staged_path);
|
|
free(staged_path);
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
free(staged_path);
|
|
return FILE_SAVE_WRITTEN;
|
|
}
|
|
|
|
/* Read the whole content of a confined regular file (used to fall back to a
|
|
byte-identical copy when a hard-link sibling's link() fails). Symlink-safe
|
|
(parent resolved via file_open_secure_parent + O_NOFOLLOW). A zero-length
|
|
file yields *out_size 0 and *out_buf NULL as a SUCCESS. Returns false only
|
|
on a real error/read failure, setting *source_absent to true when the reason
|
|
was that the path does not exist (ENOENT/ENOTDIR), so the caller can decide
|
|
between an abort and a graceful skip. */
|
|
static bool hardlink_read_source(const char* path, void** out_buf, unsigned long long* out_size,
|
|
bool* source_absent) {
|
|
*out_buf = NULL;
|
|
*out_size = 0;
|
|
*source_absent = false;
|
|
if (!path)
|
|
return false;
|
|
char* leaf = NULL;
|
|
int parent_fd = file_open_secure_parent(path, &leaf, false);
|
|
if (parent_fd < 0) {
|
|
*source_absent = errno == ENOENT || errno == ENOTDIR;
|
|
return false;
|
|
}
|
|
/* O_NONBLOCK is a no-op for a regular file but makes openat() fail/succeed
|
|
immediately for a client-planted FIFO instead of blocking the receive
|
|
thread forever; the post-open S_ISREG gate below is the actual type check. */
|
|
int fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
|
|
int saved_errno = errno;
|
|
free(leaf);
|
|
close(parent_fd);
|
|
if (fd < 0) {
|
|
*source_absent = saved_errno == ENOENT || saved_errno == ENOTDIR;
|
|
return false;
|
|
}
|
|
struct stat st;
|
|
if (fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
|
|
close(fd);
|
|
return false;
|
|
}
|
|
unsigned long long size = (unsigned long long)st.st_size;
|
|
if (size > MAX_RECEIVE_WHOLE_FILE_SIZE || size > SIZE_MAX) {
|
|
close(fd);
|
|
return false;
|
|
}
|
|
if (size == 0) {
|
|
close(fd);
|
|
return true;
|
|
}
|
|
void* buf = protocol_alloc((size_t)size);
|
|
if (!buf) {
|
|
close(fd);
|
|
return false;
|
|
}
|
|
size_t got = 0;
|
|
while (got < (size_t)size) {
|
|
ssize_t n = read(fd, (char*)buf + got, (size_t)size - got);
|
|
if (n <= 0) {
|
|
free(buf);
|
|
close(fd);
|
|
return false;
|
|
}
|
|
got += (size_t)n;
|
|
}
|
|
close(fd);
|
|
*out_buf = buf;
|
|
*out_size = size;
|
|
return true;
|
|
}
|
|
|
|
/* The group's first member's installed file is absent, but its destination
|
|
path was validated (a sibling is only ever processed after its group's first
|
|
member). When the sibling's OWN destination already exists it should be
|
|
left alone -- a clean skip -- rather than aborting the whole transfer (the
|
|
asymmetric --existing case: the first member was skipped because its
|
|
destination was missing, while the sibling already has one). Only when the
|
|
sibling's destination is missing too is this a genuine failure to
|
|
link/copy, which aborts. */
|
|
static FileSaveResult hardlink_sibling_absent_first(const char* destination_path) {
|
|
if (destination_path && file_path_exists_secure(destination_path))
|
|
return FILE_SAVE_SKIPPED;
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
|
|
/* 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
|
|
root because -H relies on the receiver's single-FIFO-writer pipeline (one
|
|
receive thread, one write thread, FIFO queue => wire order == write order)
|
|
plus the sender's forced sequential scan, so a sibling is always processed
|
|
after its group's first member. When link() fails (different filesystem,
|
|
filesystem refuses links) a byte-identical copy of the first member is
|
|
written instead, so the result is never partial or corrupt. With
|
|
--delay-updates the sibling is staged as a hard link to the first member's
|
|
STAGED file (publication's renames preserve the shared inode). The final
|
|
--existing/--ignore-existing/--update policies are decided against the final
|
|
destination like every normal write. */
|
|
static FileSaveResult file_save_hardlink_sibling(const char* root_directory, const File* file,
|
|
const Config* config, bool* created) {
|
|
Config* cfg = (Config*)config;
|
|
if (!root_directory || !file || !file->path || !file->hardlink_target)
|
|
return FILE_SAVE_ERROR;
|
|
char* destination_path = path_cat(root_directory, file->path);
|
|
if (!destination_path)
|
|
return FILE_SAVE_ERROR;
|
|
bool existed = file_path_exists_secure(destination_path);
|
|
|
|
if (cfg->existing && !file_path_exists_secure(destination_path)) {
|
|
free(destination_path);
|
|
return FILE_SAVE_SKIPPED;
|
|
}
|
|
if (cfg->ignore_existing && file_path_exists_secure(destination_path)) {
|
|
free(destination_path);
|
|
return FILE_SAVE_SKIPPED;
|
|
}
|
|
if (cfg->update && file_destination_is_newer_secure(destination_path, file->metadata)) {
|
|
free(destination_path);
|
|
return FILE_SAVE_SKIPPED;
|
|
}
|
|
|
|
bool preallocate = cfg && cfg->preallocate;
|
|
FileAttrPolicy policy = file_attr_policy_from_config(cfg);
|
|
bool use_fsync = cfg && cfg->use_fsync;
|
|
|
|
if (cfg->delay_updates) {
|
|
if (!cfg->delay_context) {
|
|
cfg->delay_context = delay_updates_context_create(root_directory);
|
|
if (!cfg->delay_context) {
|
|
free(destination_path);
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
}
|
|
if (!delay_updates_prepare(cfg->delay_context)) {
|
|
free(destination_path);
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
char* staged_first = path_cat(cfg->delay_context->staging_root, file->hardlink_target);
|
|
char* staged_sibling = path_cat(cfg->delay_context->staging_root, file->path);
|
|
if (!staged_first || !staged_sibling) {
|
|
free(staged_first);
|
|
free(staged_sibling);
|
|
free(destination_path);
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
void* content = NULL;
|
|
unsigned long long content_size = 0;
|
|
bool source_absent = false;
|
|
if (!hardlink_read_source(staged_first, &content, &content_size, &source_absent)) {
|
|
FileSaveResult absent_result =
|
|
source_absent ? hardlink_sibling_absent_first(destination_path) : FILE_SAVE_ERROR;
|
|
free(staged_first);
|
|
free(staged_sibling);
|
|
free(destination_path);
|
|
return absent_result;
|
|
}
|
|
FileXattrList* sibling_xattrs =
|
|
cfg->use_xattrs ? xattr_capture_path(staged_first, cfg->preserve_acls) : NULL;
|
|
bool ok = file_to_disk_secure_link_attrs(staged_sibling, staged_first, content, content_size,
|
|
preallocate, file->metadata, policy, use_fsync,
|
|
sibling_xattrs, cfg ? cfg->fake_super : false, NULL);
|
|
xattr_list_free(sibling_xattrs);
|
|
free(content);
|
|
if (ok)
|
|
ok = delay_updates_record(cfg->delay_context, staged_sibling, destination_path, file->path);
|
|
if (!ok)
|
|
unlink(staged_sibling);
|
|
free(staged_first);
|
|
free(staged_sibling);
|
|
free(destination_path);
|
|
if (ok && created && !existed)
|
|
*created = true;
|
|
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
|
|
}
|
|
|
|
char* first_disk = path_cat(root_directory, file->hardlink_target);
|
|
if (!first_disk) {
|
|
free(destination_path);
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
void* content = NULL;
|
|
unsigned long long content_size = 0;
|
|
bool source_absent = false;
|
|
if (!hardlink_read_source(first_disk, &content, &content_size, &source_absent)) {
|
|
FileSaveResult absent_result =
|
|
source_absent ? hardlink_sibling_absent_first(destination_path) : FILE_SAVE_ERROR;
|
|
free(first_disk);
|
|
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. */
|
|
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);
|
|
if (!resolved_temp) {
|
|
free(content);
|
|
free(first_disk);
|
|
free(destination_path);
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
}
|
|
FileXattrList* sibling_xattrs =
|
|
cfg->use_xattrs ? xattr_capture_path(first_disk, cfg->preserve_acls) : NULL;
|
|
bool ok = file_to_disk_secure_link_attrs(
|
|
destination_path, first_disk, content, content_size, preallocate, file->metadata, policy,
|
|
use_fsync, sibling_xattrs, cfg ? cfg->fake_super : false, resolved_temp);
|
|
xattr_list_free(sibling_xattrs);
|
|
free(resolved_temp);
|
|
free(content);
|
|
free(first_disk);
|
|
free(destination_path);
|
|
if (ok && created && !existed)
|
|
*created = true;
|
|
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
|
|
}
|
|
|
|
/* Validate a transmitted special rdev against the node kind implied by `mode`'s
|
|
* S_IFMT bits. Char/block devices require a legal major/minor pair (non-negative,
|
|
* range-checked); a non-device special (FIFO/socket) must carry an empty rdev.
|
|
* Used identically on the wire path and at the secure recreation site so a
|
|
* malicious/bogus rdev can never drive a dangerous node. */
|
|
bool file_special_rdev_valid(int32_t major, int32_t minor, mode_t mode) {
|
|
bool is_device = S_ISCHR(mode) || S_ISBLK(mode);
|
|
if (is_device)
|
|
return major >= 0 && minor >= 0 && major <= 0xffff && minor <= 0x00ffffff;
|
|
/* A non-device entry must actually be a special (FIFO/socket) and carry no
|
|
rdev; a regular/dir mode is never a valid special node. */
|
|
return (S_ISFIFO(mode) || S_ISSOCK(mode)) && major == 0 && minor == 0;
|
|
}
|
|
|
|
/* ---- Device/special node RECREATION (--devices/--specials), receiver side ----
|
|
*
|
|
* Privilege gating: making a real device node requires CAP_MKNOD (root); making
|
|
* a FIFO works unprivileged (mkfifo). A device node whose mknodat() fails with
|
|
* EPERM/EACCES is a genuine transfer error (rsync parity: rsync reports the
|
|
* mknod failure and the run exits partial, code 23). Only the unprivileged
|
|
* FIFO/socket (--specials) path keeps the best-effort skip, because those are
|
|
* normally creatable without privilege and a failure there is environmental.
|
|
* CI runs non-root, so device creation is expected to fail there; only a FIFO
|
|
* is honestly assertable unprivileged.
|
|
*
|
|
* Confinement: the parent directory is opened fd-relative below the receive
|
|
* root (file_open_secure_parent: O_NOFOLLOW, no "..", root-checked) and the
|
|
* node is created with mknodat()/mkfifoat(), so it can never be placed outside
|
|
* the confined root and never follows a symlink.
|
|
*
|
|
* rdev validation: a malicious/bogus rdev (negative, out-of-range) is rejected
|
|
* here as well as on the wire (file_receive_special / chunk_deserialize), and a
|
|
* non-device entry must carry an empty rdev.
|
|
*/
|
|
static FileSaveResult file_save_special_to_disk(const char* root_directory, const File* file,
|
|
const Config* config, bool* created) {
|
|
/* The empty-path and structural checks stay unconditional; the redundant
|
|
".." list-path re-check is skipped under --trust-sender exactly like the
|
|
receive layer (confinement is deferred to the secure parent walk below,
|
|
which is never disabled). */
|
|
if (!root_directory || !file || !file->path || file->path[0] == '\0' ||
|
|
(!file_get_trust_sender() && has_path_traversal(file->path)) || !file->metadata)
|
|
return FILE_SAVE_ERROR;
|
|
|
|
mode_t mode = file->metadata->mode;
|
|
bool is_char = S_ISCHR(mode);
|
|
bool is_blk = S_ISBLK(mode);
|
|
bool is_fifo = S_ISFIFO(mode);
|
|
bool is_sock = S_ISSOCK(mode);
|
|
if (!is_char && !is_blk && !is_fifo && !is_sock) {
|
|
log_message(LOG_LEVEL_ERROR, "Special node has no device/FIFO/socket mode");
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
if (is_char || is_blk) {
|
|
if (!config || !config->preserve_devices)
|
|
return FILE_SAVE_SKIPPED;
|
|
/* --super / --no-super (P7 Wave E): char/block device-node creation is a
|
|
super-user activity. --no-super forbids it even for a root receiver;
|
|
AUTO and --super attempt it (an unprivileged attempt is refused by the
|
|
kernel and skipped). The helper is evaluated against THIS config's mode
|
|
so the policy does not depend on a prior identity_set_active(). Pure
|
|
FIFO creation is unprivileged and deliberately NOT gated here. */
|
|
if (!privilege_super_mode_permitted(config->super_mode)) {
|
|
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
|
|
log_message(LOG_LEVEL_WARNING,
|
|
"skipping %s: super-user device-node creation is not permitted on this receiver",
|
|
escaped_path ? escaped_path : "<allocation failed>");
|
|
free(escaped_path);
|
|
return FILE_SAVE_SKIPPED;
|
|
}
|
|
} else if (is_fifo || is_sock) {
|
|
/* FIFOs and unix sockets are recreated by --specials. mknod(S_IFSOCK)
|
|
works unprivileged on Linux (the node carries no live socket), so unlike
|
|
a socket bound to a live fd it can be materialized. */
|
|
if (!config || !config->preserve_specials)
|
|
return FILE_SAVE_SKIPPED;
|
|
}
|
|
/* Defense-in-depth rdev/type validation (also done on the wire path). */
|
|
if (!file_special_rdev_valid(file->rdev_major, file->rdev_minor, mode)) {
|
|
log_message(LOG_LEVEL_ERROR, "Rejected out-of-range device rdev %d:%d", file->rdev_major,
|
|
file->rdev_minor);
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
|
|
char* destination = path_cat(root_directory, file->path);
|
|
if (!destination)
|
|
return FILE_SAVE_ERROR;
|
|
bool existed = file_path_exists_secure(destination);
|
|
char* leaf = NULL;
|
|
int parent_fd = file_open_secure_parent(destination, &leaf, true);
|
|
if (parent_fd < 0) {
|
|
free(destination);
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
|
|
/* --existing / --ignore-existing / --update decide against the node that
|
|
would be replaced, mirroring the regular-file path. */
|
|
if (config->existing && !file_path_exists_secure(destination)) {
|
|
close(parent_fd);
|
|
free(leaf);
|
|
free(destination);
|
|
return FILE_SAVE_SKIPPED;
|
|
}
|
|
if (config->ignore_existing && file_path_exists_secure(destination)) {
|
|
close(parent_fd);
|
|
free(leaf);
|
|
free(destination);
|
|
return FILE_SAVE_SKIPPED;
|
|
}
|
|
if (config->update && file_destination_is_newer_secure(destination, file->metadata)) {
|
|
close(parent_fd);
|
|
free(leaf);
|
|
free(destination);
|
|
return FILE_SAVE_SKIPPED;
|
|
}
|
|
|
|
dev_t rdev = 0;
|
|
mode_t create_mode;
|
|
if (is_char) {
|
|
create_mode = S_IFCHR;
|
|
rdev = makedev((unsigned)file->rdev_major, (unsigned)file->rdev_minor);
|
|
} else if (is_blk) {
|
|
create_mode = S_IFBLK;
|
|
rdev = makedev((unsigned)file->rdev_major, (unsigned)file->rdev_minor);
|
|
} else if (is_sock) {
|
|
create_mode = S_IFSOCK;
|
|
} else {
|
|
create_mode = S_IFIFO;
|
|
}
|
|
const char* node_kind = (is_char || is_blk) ? "device" : (is_fifo ? "FIFO" : "socket");
|
|
/* Under -p/--perms rsync copies the source's permission and special bits; a
|
|
* kernel that denies setuid/setgid/sticky reports the failure rather than
|
|
* having them masked here. Without -p the node is created like any other new
|
|
* entry: source_mode & 0777 & ~umask. When super-user activities are
|
|
* forbidden, the special bits are stripped even under -p (they are
|
|
* super-user activities just like device-node creation). */
|
|
mode_t perms = config->preserve_perms ? (mode & (mode_t)(S_ISUID | S_ISGID | S_ISVTX | 0777))
|
|
: (mode & 0777 & ~(mode_t)file_process_umask());
|
|
if (!privilege_super_mode_permitted(config->super_mode))
|
|
perms &= ~(mode_t)(S_ISUID | S_ISGID | S_ISVTX);
|
|
|
|
int rc = is_fifo ? mkfifoat(parent_fd, leaf, perms)
|
|
: mknodat(parent_fd, leaf, create_mode | perms, rdev);
|
|
if (rc != 0) {
|
|
if (errno == EEXIST) {
|
|
/* An entry already exists: only skip when it already is a matching node;
|
|
never replace an existing directory or unrelated entry with the node. */
|
|
struct stat st;
|
|
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) == 0 &&
|
|
((is_char && S_ISCHR(st.st_mode)) || (is_blk && S_ISBLK(st.st_mode)) ||
|
|
(is_fifo && S_ISFIFO(st.st_mode)) || (is_sock && S_ISSOCK(st.st_mode)))) {
|
|
close(parent_fd);
|
|
free(leaf);
|
|
free(destination);
|
|
return FILE_SAVE_SKIPPED;
|
|
}
|
|
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
|
|
log_message(LOG_LEVEL_WARNING, "refusing to replace existing entry with %s: %s (skipped)",
|
|
node_kind, escaped_path ? escaped_path : "<allocation failed>");
|
|
free(escaped_path);
|
|
} else if (errno == EPERM || errno == EACCES) {
|
|
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
|
|
const char* shown_path = escaped_path ? escaped_path : "<allocation failed>";
|
|
if (is_char || is_blk) {
|
|
/* rsync parity: a device node that cannot be created (no CAP_MKNOD, or
|
|
* super-user activities not permitted) is a genuine transfer error.
|
|
* rsync reports `mknod ".../node" failed: ...` and the run exits
|
|
* partial (23); FastSync surfaces it through the outcome aggregation
|
|
* instead of silently skipping the entry. FIFO/socket creation
|
|
* (--specials) keeps the best-effort skip path below. */
|
|
log_message(LOG_LEVEL_ERROR,
|
|
"cannot create %s %s: %s\n"
|
|
" --devices node creation needs privilege (CAP_MKNOD)",
|
|
node_kind, shown_path, strerror(errno));
|
|
free(escaped_path);
|
|
close(parent_fd);
|
|
free(leaf);
|
|
free(destination);
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
/* Missing CAP_MKNOD / parent write permission for a FIFO/socket: the
|
|
environment cannot create the node, so skip instead of failing the
|
|
whole run. */
|
|
log_message(LOG_LEVEL_WARNING,
|
|
"skipping %s: cannot create %s node (%s)\n"
|
|
" --specials node creation needs privilege (CAP_MKNOD)",
|
|
shown_path, node_kind, strerror(errno));
|
|
free(escaped_path);
|
|
} else {
|
|
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
|
|
log_message(LOG_LEVEL_WARNING, "failed to create %s %s: %s (skipped)", node_kind,
|
|
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
|
|
free(escaped_path);
|
|
}
|
|
close(parent_fd);
|
|
free(leaf);
|
|
free(destination);
|
|
return FILE_SAVE_SKIPPED;
|
|
}
|
|
|
|
/* Apply times on the fresh node (utimensat, no-follow) per the negotiated
|
|
* per-attribute policy: mtime only under -t, atime only under -U. The slot
|
|
* not requested stays UTIME_OMIT so it is left untouched. */
|
|
FileAttrPolicy policy = file_attr_policy_from_config(config);
|
|
if (policy.times || (policy.atimes && file->metadata->atime_valid)) {
|
|
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
|
|
{.tv_sec = 0, .tv_nsec = UTIME_OMIT}};
|
|
if (policy.times) {
|
|
times[1].tv_sec = file->metadata->mtime_sec;
|
|
times[1].tv_nsec = file->metadata->mtime_nsec;
|
|
}
|
|
if (policy.atimes && file->metadata->atime_valid) {
|
|
times[0].tv_sec = file->metadata->atime_sec;
|
|
times[0].tv_nsec = file->metadata->atime_nsec;
|
|
}
|
|
utimensat(parent_fd, leaf, times, AT_SYMLINK_NOFOLLOW);
|
|
}
|
|
/* P7 Wave E: apply the negotiated ownership to the node ITSELF. A FIFO is
|
|
created unprivileged, but --copy-as and explicit identity policies own
|
|
every entry (a char/block node path is already privilege-gated above). The
|
|
no-follow helper changes the node's own ownership without dereferencing it;
|
|
it is a no-op unless an identity policy is active. */
|
|
bool owner_ok = true;
|
|
if (identity_active_enabled())
|
|
owner_ok = identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid,
|
|
(int32_t)file->metadata->gid);
|
|
close(parent_fd);
|
|
free(leaf);
|
|
free(destination);
|
|
/* A failed required --copy-as ownership marks the node as failed; every other
|
|
* identity policy stays best-effort. */
|
|
if (owner_ok && created && !existed)
|
|
*created = true;
|
|
return owner_ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
|
|
}
|
|
|
|
/* --write-devices (receiver): write the received data directly into an EXISTING
|
|
* device node on the destination instead of creating a regular file. The node
|
|
* must already exist and be a char/block device (the device itself is opened and
|
|
* followed); it is confined to the receive root via file_open_secure_parent.
|
|
* Dangerous by nature, so deliberately restricted: a missing/non-device
|
|
* destination, or a write failure, is SKIPPED with a warning rather than
|
|
* allowed. On environments without device access the run still succeeds (the
|
|
* entry is skipped), never aborts. */
|
|
static FileSaveResult file_save_write_device(const char* root_directory, const File* file) {
|
|
if (!root_directory || !file || !file->path || file->path[0] == '\0' ||
|
|
(!file_get_trust_sender() && has_path_traversal(file->path)))
|
|
return FILE_SAVE_ERROR;
|
|
if (!file->data)
|
|
return FILE_SAVE_ERROR;
|
|
char* destination = path_cat(root_directory, file->path);
|
|
if (!destination)
|
|
return FILE_SAVE_ERROR;
|
|
char* leaf = NULL;
|
|
int parent_fd = file_open_secure_parent(destination, &leaf, false);
|
|
if (parent_fd < 0) {
|
|
free(destination);
|
|
return FILE_SAVE_SKIPPED;
|
|
}
|
|
/* O_NONBLOCK: a pre-existing FIFO at the target would otherwise block the
|
|
receive thread forever on open(2). With it the open only succeeds for a
|
|
readerless FIFO with O_RDWR (which the device fstat gate rejects anyway)
|
|
or fails with ENXIO/EAGAIN, both treated as a normal skip below. */
|
|
int fd = openat(parent_fd, leaf, O_WRONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
|
|
int saved_errno = errno;
|
|
free(leaf);
|
|
close(parent_fd);
|
|
if (fd < 0) {
|
|
free(destination);
|
|
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
|
|
const char* shown_path = escaped_path ? escaped_path : "<allocation failed>";
|
|
if (saved_errno == ENXIO || saved_errno == EAGAIN) {
|
|
/* A FIFO with no reader / an unreadable special: skip like every other
|
|
unusable write-devices target instead of blocking or failing. */
|
|
log_message(LOG_LEVEL_WARNING, "write-devices: %s not writable (%s); skipped", shown_path,
|
|
strerror(saved_errno));
|
|
} else {
|
|
log_message(LOG_LEVEL_WARNING, "write-devices: cannot open %s (%s); skipped", shown_path,
|
|
strerror(saved_errno));
|
|
}
|
|
free(escaped_path);
|
|
return FILE_SAVE_SKIPPED;
|
|
}
|
|
struct stat st;
|
|
if (fstat(fd, &st) != 0 || !(S_ISCHR(st.st_mode) || S_ISBLK(st.st_mode))) {
|
|
close(fd);
|
|
free(destination);
|
|
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
|
|
log_message(LOG_LEVEL_WARNING, "write-devices: %s is not a device node; skipped",
|
|
escaped_path ? escaped_path : "<allocation failed>");
|
|
free(escaped_path);
|
|
return FILE_SAVE_SKIPPED;
|
|
}
|
|
bool ok = true;
|
|
if (file->data->size > 0) {
|
|
size_t total = (size_t)file->data->size;
|
|
size_t written = 0;
|
|
while (written < total) {
|
|
ssize_t n = write(fd, (char*)file->data->data + written, total - written);
|
|
if (n <= 0) {
|
|
ok = false;
|
|
break;
|
|
}
|
|
written += (size_t)n;
|
|
}
|
|
}
|
|
close(fd);
|
|
free(destination);
|
|
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_SKIPPED;
|
|
}
|
|
|
|
/* ---- file_save_to_disk_full_ex decomposition ----
|
|
*
|
|
* The regular-file install path is split into small static helpers that share
|
|
* one FileSavePlan (the owned path-set) and route every exit through a single
|
|
* cleanup epilogue so the path-set is released exactly once. Each helper owns
|
|
* one decision: request validation, special/device dispatch, directory and
|
|
* symlink creation, path resolution, the --existing/--ignore-existing/--update/
|
|
* --force/--backup pre-write policies, and the data install. The ordering of
|
|
* every check and every protocol/write operation is unchanged.
|
|
*/
|
|
|
|
/* Owned state for one regular-file install. The path-set pointers are owned by
|
|
* the plan and freed together by file_save_plan_dispose(). */
|
|
typedef struct {
|
|
const char* root_directory;
|
|
const File* file;
|
|
const Config* config;
|
|
bool backup_enabled;
|
|
bool inplace;
|
|
bool sparse;
|
|
FileAttrPolicy policy;
|
|
const char* backup_suffix;
|
|
const char* backup_dir;
|
|
const char* partial_dir;
|
|
const char* temp_dir;
|
|
bool use_partial_root;
|
|
bool dest_existed;
|
|
char* confined_backup;
|
|
char* confined_partial;
|
|
char* disk_path;
|
|
char* destination_path;
|
|
char* backup_path;
|
|
char* parent_copy;
|
|
char* confined_temp;
|
|
} FileSavePlan;
|
|
|
|
static void file_save_plan_init(FileSavePlan* plan, const char* root_directory, const File* file,
|
|
const Config* config) {
|
|
memset(plan, 0, sizeof(*plan));
|
|
plan->root_directory = root_directory;
|
|
plan->file = file;
|
|
plan->config = config;
|
|
plan->backup_enabled = config && config->backup && !config->ignore_existing;
|
|
plan->inplace = config && config->inplace;
|
|
plan->sparse = config && config->preserve_sparse;
|
|
plan->policy = file_attr_policy_from_config(config);
|
|
plan->backup_suffix = (config && config->suffix) ? config->suffix : "~";
|
|
plan->backup_dir = (config && config->backup_dir) ? config->backup_dir : NULL;
|
|
plan->partial_dir = (config && config->partial_dir) ? config->partial_dir : NULL;
|
|
plan->temp_dir = (config && config->temp_dir) ? config->temp_dir : NULL;
|
|
plan->use_partial_root = plan->partial_dir && config && config->partial;
|
|
}
|
|
|
|
/* Single cleanup epilogue: release the whole owned path-set exactly once. */
|
|
static void file_save_plan_dispose(FileSavePlan* plan) {
|
|
free(plan->parent_copy);
|
|
free(plan->backup_path);
|
|
free(plan->confined_backup);
|
|
free(plan->confined_partial);
|
|
free(plan->destination_path);
|
|
free(plan->disk_path);
|
|
free(plan->confined_temp);
|
|
}
|
|
|
|
/* Structural validation of the received entry. */
|
|
static bool file_save_validate(const FileSavePlan* plan) {
|
|
const File* file = plan->file;
|
|
const char* backup_suffix = plan->backup_suffix;
|
|
return file && file->path && file->data &&
|
|
(file->data->size == 0 || file->data->data || file->basis_link || file->basis_copy) &&
|
|
(file_get_trust_sender() || !has_path_traversal(file->path)) &&
|
|
(!plan->backup_enabled ||
|
|
(backup_suffix && backup_suffix[0] != '\0' && strchr(backup_suffix, '/') == NULL &&
|
|
strcmp(backup_suffix, ".") != 0 && strcmp(backup_suffix, "..") != 0));
|
|
}
|
|
|
|
/* Explicit directory entry (--dirs). */
|
|
static FileSaveResult file_save_directory_to_disk(const FileSavePlan* plan, bool* created) {
|
|
const File* file = plan->file;
|
|
if (file->path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(file->path))) {
|
|
log_message(LOG_LEVEL_ERROR, "Invalid directory path received");
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
char* dir_path = path_cat(plan->root_directory, file->path);
|
|
if (!dir_path)
|
|
return FILE_SAVE_ERROR;
|
|
bool dir_existed = file_path_exists_secure(dir_path);
|
|
bool ok = file_ensure_directory_secure(dir_path);
|
|
/* One confined, no-follow descriptor drives ownership/mode/xattr/timestamp
|
|
application so none of them can follow a same-named symlink planted after
|
|
the mkdir. This mirrors the O_DIRECTORY|O_NOFOLLOW fd that
|
|
dir_metadata_list_apply() opens for the recursive path; the fd is reached
|
|
through the already-confined parent. */
|
|
char* leaf = NULL;
|
|
int parent_fd = -1;
|
|
int dir_fd = -1;
|
|
if (ok) {
|
|
parent_fd = file_open_secure_parent(dir_path, &leaf, false);
|
|
if (parent_fd >= 0)
|
|
dir_fd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
|
}
|
|
/* P7 Wave E: apply the negotiated ownership to the directory ITSELF (not
|
|
just the files inside it). --copy-as and every explicit identity policy
|
|
own every entry, so a directory must not keep the receiver's owner while
|
|
its children get the policy owner. Applied no-follow on the confined
|
|
parent fd; identity_apply_ownership_link() is itself a no-op unless an
|
|
identity policy is active. Ownership runs before the mode because a chown
|
|
clears setuid/setgid. A failed REQUIRED --copy-as ownership fails the
|
|
entry; every other policy stays best-effort. */
|
|
if (ok && file->metadata && identity_active_enabled()) {
|
|
if (parent_fd >= 0) {
|
|
if (!identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid,
|
|
(int32_t)file->metadata->gid))
|
|
ok = false;
|
|
} else if (identity_copy_as_active()) {
|
|
/* The directory exists (ok) but its required --copy-as ownership could
|
|
not be applied because the confined parent could not be opened. */
|
|
ok = false;
|
|
}
|
|
} else if (ok && identity_copy_as_active()) {
|
|
ok = false;
|
|
}
|
|
/* The final source MODE is deliberately NOT applied inline. A restrictive
|
|
source mode (for example 0555) would make the directory unwritable before
|
|
its children are created, so a non-root receiver fails each child with
|
|
EACCES. The receiver feeds every is_dir entry -- including this explicit
|
|
--dirs/STATUS_MKDIR one -- into the deferred DirTimeList, and
|
|
dir_metadata_list_apply() stamps the exact mode once the whole transfer has
|
|
finished, exactly as it does for the recursive path. Leaving the directory
|
|
at its creation mode keeps it writable for the children until then.
|
|
|
|
The xattrs below are still applied inline so a direct
|
|
file_save_to_disk_full() caller (which has no deferred pass) also gets
|
|
--dirs directory xattrs. Because the inline mode is absent, the inline
|
|
order here is ownership, then xattrs, then timestamps; the recursive path
|
|
(which DOES apply a mode) orders them times, mode, xattrs -- the difference
|
|
is intentional, and the deferred pass re-stamps mode and xattrs last.
|
|
Best-effort: a per-attribute failure is logged and skipped by
|
|
xattr_apply_fd(), never fatal. */
|
|
if (ok && plan->config && plan->config->use_xattrs && dir_fd >= 0 && file->xattrs)
|
|
xattr_apply_fd(dir_fd, file->xattrs);
|
|
/* Timestamps last so no later inline ownership/xattr change is mistaken for a
|
|
content update; the deferred pass re-stamps them after every child write.
|
|
-J/--omit-dir-times suppresses the directory mtime; --atimes/-U applies
|
|
only when the source atime is valid, exactly as the recursive path. */
|
|
if (ok && file->metadata && plan->config && plan->config->preserve_times &&
|
|
!plan->config->omit_dir_times) {
|
|
struct timespec times[2] = {
|
|
{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
|
|
{.tv_sec = file->metadata->mtime_sec, .tv_nsec = file->metadata->mtime_nsec}};
|
|
if (plan->config->preserve_atimes && file->metadata->atime_valid) {
|
|
times[0].tv_sec = file->metadata->atime_sec;
|
|
times[0].tv_nsec = file->metadata->atime_nsec;
|
|
}
|
|
if (parent_fd >= 0 && utimensat(parent_fd, leaf, times, AT_SYMLINK_NOFOLLOW) != 0) {
|
|
int saved_errno = errno;
|
|
char* escaped_path = output_escape(dir_path, log_get_8_bit_output());
|
|
log_message(LOG_LEVEL_WARNING, "Failed to set directory timestamps on %s: %s",
|
|
escaped_path ? escaped_path : "<allocation failed>", strerror(saved_errno));
|
|
free(escaped_path);
|
|
}
|
|
}
|
|
if (dir_fd >= 0)
|
|
close(dir_fd);
|
|
if (parent_fd >= 0)
|
|
close(parent_fd);
|
|
free(leaf);
|
|
free(dir_path);
|
|
if (ok && created && !dir_existed)
|
|
*created = true;
|
|
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
|
|
}
|
|
|
|
/* Symlink entry. (The process-wide --keep-dirlinks policy is set once by the
|
|
connection handler from the negotiated config, before any receiver/writer
|
|
threads start, so it is stable throughout this walk.) */
|
|
static FileSaveResult file_save_symlink_to_disk(const FileSavePlan* plan, bool* created) {
|
|
const File* file = plan->file;
|
|
const Config* config = plan->config;
|
|
if (!file->symlink_target || file->path[0] == '\0' ||
|
|
(!file_get_trust_sender() && has_path_traversal(file->path))) {
|
|
log_message(LOG_LEVEL_ERROR, "Invalid symlink entry received");
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
char* link_path = path_cat(plan->root_directory, file->path);
|
|
if (!link_path)
|
|
return FILE_SAVE_ERROR;
|
|
bool link_existed = file_path_exists_secure(link_path);
|
|
/* The link value is stored verbatim (rsync -l parity: absolute and
|
|
".."-bearing targets are preserved; the scanner's --safe-links /
|
|
--copy-unsafe-links decide which links are sent at all). --munge-links
|
|
is a RECEIVER-side rewrite: the stored target is prefixed with
|
|
/rsyncd-munged/, making the link unusable while the referenced directory
|
|
does not exist -- exactly as rsync's receiver munges. Only the link's
|
|
own placement path is confined below the receive root. */
|
|
bool munge = config && config->munge_links;
|
|
char* target = str_dup(file->symlink_target);
|
|
bool ok = target != NULL;
|
|
if (ok && munge) {
|
|
char* munged = file_symlink_munge(target);
|
|
free(target);
|
|
target = munged;
|
|
ok = target != NULL;
|
|
}
|
|
if (!ok) {
|
|
free(target);
|
|
free(link_path);
|
|
return FILE_SAVE_SKIPPED;
|
|
}
|
|
char* parent = str_dup(link_path);
|
|
if (parent) {
|
|
/* Propagate a failed --copy-as ownership of the parent directory this
|
|
creates; every other failure mode stays best-effort as before. */
|
|
ok = file_ensure_directory_secure(dirname(parent));
|
|
free(parent);
|
|
}
|
|
if (ok)
|
|
ok = file_symlink_at_secure(link_path, target);
|
|
free(target);
|
|
/* P7 Wave D: apply the symlink's own metadata with no-follow primitives
|
|
(utimensat/lchown/fchmodat AT_SYMLINK_NOFOLLOW). -J/--omit-link-times
|
|
suppresses the timestamps; ownership stays gated by the identity policy.
|
|
A symlink has no children, so this can be applied immediately. */
|
|
if (ok && config && config->use_metadata) {
|
|
FileAttrPolicy link_policy = file_attr_policy_from_config(config);
|
|
ok = file_restore_symlink_metadata(link_path, file->metadata, link_policy,
|
|
config->omit_link_times);
|
|
}
|
|
if (ok && created && !link_existed)
|
|
*created = true;
|
|
free(link_path);
|
|
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
|
|
}
|
|
|
|
/* Device/special node (--devices/--specials) and --write-devices dispatch. */
|
|
static bool file_save_try_special_dispatch(const FileSavePlan* plan, bool* created,
|
|
FileSaveResult* out) {
|
|
const File* file = plan->file;
|
|
const Config* config = plan->config;
|
|
/* Device/special node (--devices/--specials): recreate the node instead of
|
|
writing content (privilege-gated, confined, rdev-validated). */
|
|
if (file->is_special) {
|
|
*out = file_save_special_to_disk(plan->root_directory, file, config, created);
|
|
return true;
|
|
}
|
|
/* --write-devices: write straight into an existing device node. Writing
|
|
into a device is a super-user activity, so --no-super must suppress it just
|
|
like device-node creation; the default AUTO/--super attempt it (the wide
|
|
open below keeps its own confinement and best-effort skip semantics). */
|
|
if (config && config->write_devices) {
|
|
if (!privilege_super_mode_permitted(config->super_mode)) {
|
|
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
|
|
log_message(LOG_LEVEL_WARNING,
|
|
"write-devices: %s skipped: super-user activities are not permitted on this "
|
|
"receiver",
|
|
escaped_path ? escaped_path : "(null)");
|
|
free(escaped_path);
|
|
*out = FILE_SAVE_SKIPPED;
|
|
return true;
|
|
}
|
|
*out = file_save_write_device(plan->root_directory, file);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* Resolve and confine the backup/partial/temp directories and the destination
|
|
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. */
|
|
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))))
|
|
return false;
|
|
if (plan->backup_dir &&
|
|
!(plan->confined_backup = path_cat(plan->root_directory, plan->backup_dir)))
|
|
return false;
|
|
if (plan->partial_dir &&
|
|
!(plan->confined_partial = path_cat(plan->root_directory, plan->partial_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);
|
|
plan->disk_path = path_cat(actual_root, plan->file->path);
|
|
if (plan->destination_path == NULL || plan->disk_path == NULL)
|
|
return false;
|
|
/* Snapshot the final destination's existence BEFORE any backup/force/partial
|
|
step can move or remove it, so the receiver can report rsync's
|
|
`Number of created files` (protocol 2.28.0). */
|
|
plan->dest_existed = file_path_exists_secure(plan->destination_path);
|
|
return true;
|
|
}
|
|
|
|
typedef enum {
|
|
FILE_SAVE_POLICY_CONTINUE, /* proceed to the install */
|
|
FILE_SAVE_POLICY_SKIP, /* --existing/--ignore-existing/--update skip */
|
|
FILE_SAVE_POLICY_ERROR, /* --force/--backup failure */
|
|
} FileSavePolicyOutcome;
|
|
|
|
/* The immediate-install pre-write policies: --existing, --ignore-existing,
|
|
--update, --force and --backup, in that order. */
|
|
static FileSavePolicyOutcome file_save_apply_prewrite_policies(FileSavePlan* plan) {
|
|
const Config* config = plan->config;
|
|
const File* file = plan->file;
|
|
|
|
/* --existing checks the final destination, not a temporary partial path. */
|
|
if (config && config->existing && !file_path_exists_secure(plan->destination_path))
|
|
return FILE_SAVE_POLICY_SKIP;
|
|
|
|
/* --ignore-existing checks the final destination before partial files or
|
|
overwrite policies can modify it. */
|
|
if (config && config->ignore_existing) {
|
|
bool exists = file_path_exists_secure(plan->destination_path);
|
|
if (exists)
|
|
return FILE_SAVE_POLICY_SKIP;
|
|
}
|
|
|
|
/* --update is receiver-side policy: never replace a newer destination.
|
|
In partial-dir mode the entry that would be replaced is the real
|
|
destination, not the temporary partial file. The secure stat does not
|
|
require read permission on the destination. */
|
|
const char* update_target = plan->use_partial_root ? plan->destination_path : plan->disk_path;
|
|
if (config && config->update && file_destination_is_newer_secure(update_target, file->metadata))
|
|
return FILE_SAVE_POLICY_SKIP;
|
|
|
|
/* --force (rsync semantics): an incoming regular file may replace a
|
|
destination DIRECTORY by removing that (possibly non-empty, symlink-safe)
|
|
tree first, so the atomic temp+rename below can install the file. Only the
|
|
immediate-install path does this: a --delay-updates run stages into its own
|
|
tree and is unaffected here (its publication renames over regular files
|
|
only). The blocking directory is removed only after the --update /
|
|
--existing / --ignore-existing decisions above, which see it as an existing
|
|
destination entry. */
|
|
if (config && config->force_delete && !file->is_dir &&
|
|
file_directory_exists_secure(plan->destination_path)) {
|
|
if (!file_remove_tree_secure(plan->destination_path))
|
|
return FILE_SAVE_POLICY_ERROR;
|
|
}
|
|
|
|
if (plan->backup_enabled) {
|
|
/* Back up the entry that the incoming write will replace. When writing
|
|
through a partial dir the pre-existing destination file is the one to
|
|
preserve; any stale partial file is overwritten without a backup. */
|
|
const char* replace_target = plan->use_partial_root ? plan->destination_path : plan->disk_path;
|
|
struct stat backup_stat;
|
|
if (file_stat_secure(replace_target, &backup_stat)) {
|
|
if (plan->backup_dir) {
|
|
plan->backup_path = path_cat(plan->confined_backup, file->path);
|
|
} else {
|
|
size_t path_len = strlen(replace_target);
|
|
size_t suffix_len = strlen(plan->backup_suffix);
|
|
if (path_len > SIZE_MAX - suffix_len - 1)
|
|
return FILE_SAVE_POLICY_ERROR;
|
|
plan->backup_path = malloc(path_len + suffix_len + 1);
|
|
if (plan->backup_path) {
|
|
memcpy(plan->backup_path, replace_target, path_len);
|
|
memcpy(plan->backup_path + path_len, plan->backup_suffix, suffix_len + 1);
|
|
}
|
|
}
|
|
if (!plan->backup_path)
|
|
return FILE_SAVE_POLICY_ERROR;
|
|
plan->parent_copy = str_dup(plan->backup_path);
|
|
if (!plan->parent_copy || !file_ensure_directory_secure(dirname(plan->parent_copy)))
|
|
return FILE_SAVE_POLICY_ERROR;
|
|
free(plan->parent_copy);
|
|
plan->parent_copy = NULL;
|
|
if (!file_rename_secure(replace_target, plan->backup_path))
|
|
return FILE_SAVE_POLICY_ERROR;
|
|
free(plan->backup_path);
|
|
plan->backup_path = NULL;
|
|
}
|
|
}
|
|
return FILE_SAVE_POLICY_CONTINUE;
|
|
}
|
|
|
|
/* Install the file data into the destination (or staging/partial path):
|
|
--link-dest hard link, --copy-dest streamed copy, or the plain atomic
|
|
temp+rename engine with per-file xattr/--fake-super application. */
|
|
static bool file_save_install_data(FileSavePlan* plan, const FileMetadata* metadata,
|
|
unsigned* created_dirs) {
|
|
const File* file = plan->file;
|
|
const Config* config = plan->config;
|
|
char* count_floor = file_transfer_root_floor(config);
|
|
bool ok;
|
|
if (config && file->basis_link) {
|
|
ok = file_to_disk_secure_link_attrs_counted(
|
|
plan->disk_path, file->basis_link, file->data->data, file->data->size, config->preallocate,
|
|
metadata, plan->policy, config->use_fsync, file->xattrs, config->fake_super,
|
|
plan->confined_temp, created_dirs, count_floor);
|
|
} else if (config && file->basis_copy) {
|
|
/* --copy-dest: stream the basis bytes through a bounded buffer so a basis
|
|
larger than any whole-file bound still materializes. The source
|
|
metadata was transmitted with the check frame. */
|
|
ok = file_copy_basis_stream_attrs(plan->disk_path, file->basis_copy, file->data->size,
|
|
config->preallocate, metadata, plan->policy, config->update,
|
|
config->use_fsync, file->xattrs, config->fake_super,
|
|
plan->confined_temp);
|
|
} else {
|
|
/* The plain no-replace / update / with-fsync engines, plus per-file xattr
|
|
(-X/-A) and --fake-super application on the written fd. */
|
|
ok = file_to_disk_secure_attrs_counted(
|
|
plan->disk_path, file->data->data, file->data->size, plan->inplace, plan->sparse,
|
|
config && config->preallocate, metadata, plan->policy, config && config->update,
|
|
config && config->ignore_existing, config && config->use_fsync, file->xattrs,
|
|
config ? config->fake_super : false, config ? config->partial : false, plan->confined_temp,
|
|
created_dirs, count_floor);
|
|
}
|
|
free(count_floor);
|
|
return ok;
|
|
}
|
|
|
|
FileSaveResult file_save_to_disk_full_ex(const char* root_directory, const File* file,
|
|
const Config* config, bool* created,
|
|
unsigned* created_dirs) {
|
|
if (created)
|
|
*created = false;
|
|
if (created_dirs)
|
|
*created_dirs = 0;
|
|
/* Central no-mutation guard: a server-contacting --dry-run (or a local batch
|
|
apply that somehow carries dry_run) must never touch the destination, no
|
|
matter which caller reached this primitive. The per-caller guards remain,
|
|
but this is the last line of defense for every save path. Report SKIPPED
|
|
so a --remove-source-files sender correctly keeps its source. */
|
|
if (config && config->dry_run)
|
|
return FILE_SAVE_SKIPPED;
|
|
|
|
FileSavePlan plan;
|
|
file_save_plan_init(&plan, root_directory, file, config);
|
|
FileSaveResult result = FILE_SAVE_ERROR;
|
|
|
|
if (!file_save_validate(&plan)) {
|
|
log_message(LOG_LEVEL_ERROR, "Invalid file or path received");
|
|
goto out;
|
|
}
|
|
|
|
/* P7 Wave D #1: a STATUS_DIR_TIMES entry is RECORD-ONLY. The scanner
|
|
captures every traversed directory -- including empty ones whose parents
|
|
were never created by a child write and directories pruned by
|
|
-m/--prune-empty-dirs. Creating them here would resurrect empty
|
|
directories (an -a behavior change) and could abort the whole transfer on a
|
|
pre-existing regular file/symlink at the mirror path. Short-circuit before
|
|
any device/write-devices/directory branch and report it as skipped so the
|
|
sink still accumulates its metadata for the deferred DirTimeList
|
|
application, but create nothing. */
|
|
if (file->dir_time_only) {
|
|
result = FILE_SAVE_SKIPPED;
|
|
goto out;
|
|
}
|
|
|
|
FileSaveResult dispatched;
|
|
if (file_save_try_special_dispatch(&plan, created, &dispatched)) {
|
|
result = dispatched;
|
|
goto out;
|
|
}
|
|
|
|
/* Explicit directory entries (--dirs) carry an empty payload; the entry is
|
|
created as a directory under the receive root, applying the same secure
|
|
mkdir-parent semantics as regular writes. Directories are created
|
|
immediately (they are never staged by --delay-updates, matching rsync,
|
|
where directory creation is not delayed). */
|
|
if (file->is_dir) {
|
|
result = file_save_directory_to_disk(&plan, created);
|
|
goto out;
|
|
}
|
|
|
|
if (file->is_symlink) {
|
|
result = file_save_symlink_to_disk(&plan, created);
|
|
goto out;
|
|
}
|
|
|
|
/* --hard-links/-H sibling: a later member of a link group arrives with no
|
|
payload and is installed as a hard link to (or, on link() failure, a
|
|
byte-identical copy of) the group's first member. Handled entirely here,
|
|
before the normal data-write paths (which would create an empty file). */
|
|
if (file->link_group != 0 && !file->link_first && file->hardlink_target != NULL) {
|
|
result = file_save_hardlink_sibling(root_directory, file, config, created);
|
|
goto out;
|
|
}
|
|
|
|
if (!file_save_resolve_paths(&plan))
|
|
goto out;
|
|
|
|
/* --delay-updates diverts the whole write into the staging tree; the rest of
|
|
this function is the immediate-install path. */
|
|
if (config && config->delay_updates) {
|
|
FileSaveResult staged =
|
|
file_stage_delayed_update(root_directory, plan.destination_path, file, (Config*)config);
|
|
if (staged == FILE_SAVE_WRITTEN && created && !plan.dest_existed)
|
|
*created = true;
|
|
result = staged;
|
|
goto out;
|
|
}
|
|
|
|
FileSavePolicyOutcome policy_outcome = file_save_apply_prewrite_policies(&plan);
|
|
if (policy_outcome == FILE_SAVE_POLICY_SKIP) {
|
|
result = FILE_SAVE_SKIPPED;
|
|
goto out;
|
|
}
|
|
if (policy_outcome == FILE_SAVE_POLICY_ERROR)
|
|
goto out;
|
|
|
|
FileMetadata adjusted_metadata;
|
|
const FileMetadata* metadata = file->metadata;
|
|
if (metadata && config && config->chmod_spec && *config->chmod_spec) {
|
|
adjusted_metadata = *metadata;
|
|
if (!chmod_apply(adjusted_metadata.mode, config->chmod_spec, &adjusted_metadata.mode))
|
|
goto out;
|
|
metadata = &adjusted_metadata;
|
|
}
|
|
|
|
/* 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;
|
|
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);
|
|
while (temp_len > 1 && plan.confined_temp[temp_len - 1] == '/')
|
|
plan.confined_temp[--temp_len] = '\0';
|
|
}
|
|
|
|
/* A --link-dest basis hit installs an atomic hard link (with a byte-copy
|
|
fallback); --inplace and the update/no-replace write variants do not
|
|
apply to a fresh hard link, whose inode attributes already match. The
|
|
existing/ignore-existing/update/backup preamble above has already made the
|
|
policy decision. */
|
|
if (!file_save_install_data(&plan, metadata, created_dirs))
|
|
goto out;
|
|
|
|
/* --partial --partial-dir writes the complete file under the partial dir so
|
|
interrupted transfers leave a resumable copy there. Once the file is
|
|
fully written it must be atomically installed at the real destination;
|
|
otherwise completed transfers would linger under the partial dir. */
|
|
if (plan.use_partial_root) {
|
|
if (!file_rename_secure(plan.disk_path, plan.destination_path))
|
|
goto out;
|
|
}
|
|
|
|
if (created && !plan.dest_existed)
|
|
*created = true;
|
|
result = FILE_SAVE_WRITTEN;
|
|
|
|
out:
|
|
file_save_plan_dispose(&plan);
|
|
return result;
|
|
}
|
|
|
|
void receiver_stats_note_saved(ReceiverStats* stats, const File* file, bool created,
|
|
unsigned created_dirs) {
|
|
if (!stats || !file)
|
|
return;
|
|
/* A basis-dir hit (--link-dest/--copy-dest) materializes bytes the sender
|
|
* never transferred. rsync reports no literal data and no created entry for
|
|
* such a file, and does not count the parent directories it creates only to
|
|
* hold it, so exclude the whole entry from the receiver tallies. */
|
|
bool basis_sourced = file->basis_link != NULL || file->basis_copy != NULL;
|
|
if (basis_sourced)
|
|
return;
|
|
bool is_sibling = file->link_group != 0 && !file->link_first;
|
|
if (!file->is_dir && !file->is_symlink && !file->is_special && !is_sibling) {
|
|
unsigned long long literal = file->literal_bytes;
|
|
if (literal == 0 && file->matched_bytes == 0)
|
|
literal = file->data ? file->data->size : 0;
|
|
stats->literal_bytes += literal;
|
|
}
|
|
stats->created_dir += created_dirs;
|
|
if (!created)
|
|
return;
|
|
if (file->is_dir)
|
|
stats->created_dir++;
|
|
else if (file->is_symlink)
|
|
stats->created_link++;
|
|
else if (file->is_special)
|
|
stats->created_special++;
|
|
else
|
|
stats->created_reg++;
|
|
}
|