CI / lint (pull_request) Successful in 26s
CI / sanitizers (address) (pull_request) Failing after 41s
CI / sanitizers (undefined) (pull_request) Successful in 40s
CI / fuzz-build (pull_request) Successful in 18s
CI / coverage (pull_request) Successful in 35s
CI / valgrind (pull_request) Failing after 37s
CI / build-and-test (pull_request) Failing after 2m35s
RSYNC_COMPAT Phase-2 row M: path-list construction and destination directory creation while preserving traversal safety. - -R/--relative with --files-from: transmit each listed entry under its bare relative destination path (no source-root mirror). Files keep an absolute local read path plus a separate wire/dest path (File.send_path); manifest, incremental quick-check and change output follow the wire path, so --delete and --remove-source-files stay consistent. -R without --files-from is unchanged (full mirror). - --no-implied-dirs: client-only, only meaningful with -R + --files-from. A listed file whose parent dir is not itself (or via an ancestor) explicitly listed cannot be placed; the run fails up front with a clear error. No effect otherwise. - --dirs/-d + --old-dirs/--old-d aliases: -d <dir> transmits the source root as an explicit empty directory entry (STATUS_MKDIR frame); with --files-from listed dirs are created empty and listed files transferred, never descending. Works single-threaded, -m (sequential scanner in the -m scan thread) and chunk-serialization (per-file type marker). Directory entries appear in the delete manifest. - --mkpath: new wire bool; server creates the destination root (and missing leading components under its authorized root) at connection start. A missing destination root is now rejected by default. - Protocol bumped to 2.7.0 (mkpath wire field + STATUS_MKDIR + chunk type marker). All receive paths funnel through file_save_to_disk_full which creates directories via the secure confined mkdir engine; dir entries are excluded from --remove-source-files outcome acknowledgements on both ends. - Unit coverage: CLI parse (relative/dirs aliases/mkpath/no-implied-dirs), config round-trip (relative + mkpath), scanner --dirs non-recursion and -R send_path (sequential + parallel), receiver dir-entry save. - Integration coverage: TestRelativeFilesFrom, TestNoImpliedDirs, TestDirs, TestMkpath (single and -m). - RSYNC_COMPAT: 4 rows move to Implemented (Summary 67/3/5/1/71 = 147).
855 lines
28 KiB
C
855 lines
28 KiB
C
#include <errno.h>
|
|
#include <fcntl.h>
|
|
#include <libgen.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
|
|
#include "array_list.h"
|
|
#include "chmod.h"
|
|
#include "compression.h"
|
|
#include "config.h"
|
|
#include "data.h"
|
|
#include "delay_updates.h"
|
|
#include "delta.h"
|
|
#include "file.h"
|
|
#include "log.h"
|
|
#include "metadata.h"
|
|
#include "protocol.h"
|
|
#include "utils.h"
|
|
|
|
#define MAX_SERVER_DELETE_COUNT 100000U
|
|
#define MAX_FILE_DATA_SIZE MAX_RECEIVE_WHOLE_FILE_SIZE
|
|
|
|
bool file_save_to_disk(const char* root_directory, const File* file, const Config* config) {
|
|
return file_save_to_disk_full(root_directory, file, config) != FILE_SAVE_ERROR;
|
|
}
|
|
|
|
/* --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;
|
|
bool preserve_executability = config->use_executability;
|
|
|
|
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. */
|
|
bool ok =
|
|
file_to_disk_secure_with_fsync(staged_path, file->data->data, file->data->size, false, sparse,
|
|
metadata, preserve_executability, config->use_fsync, 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;
|
|
}
|
|
|
|
FileSaveResult file_save_to_disk_full(const char* root_directory, const File* file,
|
|
const Config* config) {
|
|
/* Backups are incompatible with ignore-existing: moving the entry first
|
|
would make a concurrent no-replace commit overwrite its old name. */
|
|
bool backup_enabled = config && config->backup && !config->ignore_existing;
|
|
bool inplace = config && config->inplace;
|
|
bool sparse = config && config->preserve_sparse;
|
|
bool preserve_executability = config && config->use_executability;
|
|
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;
|
|
char *backup_path = NULL, *parent_copy = NULL;
|
|
|
|
if (!file || !file->path || !file->data || (file->data->size != 0 && !file->data->data) ||
|
|
has_path_traversal(file->path) ||
|
|
(backup_enabled &&
|
|
(!backup_suffix || backup_suffix[0] == '\0' || strchr(backup_suffix, '/') != NULL ||
|
|
strcmp(backup_suffix, ".") == 0 || strcmp(backup_suffix, "..") == 0))) {
|
|
log_message(LOG_LEVEL_ERROR, "Invalid file or path received");
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
|
|
/* 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) {
|
|
if (file->path[0] == '\0' || has_path_traversal(file->path)) {
|
|
log_message(LOG_LEVEL_ERROR, "Invalid directory path received");
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
char* dir_path = path_cat(root_directory, file->path);
|
|
if (!dir_path)
|
|
return FILE_SAVE_ERROR;
|
|
bool ok = file_ensure_directory_secure(dir_path);
|
|
free(dir_path);
|
|
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
|
|
}
|
|
|
|
/* These options arrive from the client. They are names below the server
|
|
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))) ||
|
|
(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;
|
|
if (partial_dir && !(confined_partial = path_cat(root_directory, partial_dir))) {
|
|
free(confined_backup);
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
|
|
const char* actual_root = use_partial_root ? confined_partial : root_directory;
|
|
destination_path = path_cat(root_directory, file->path);
|
|
disk_path = path_cat(actual_root, file->path);
|
|
if (destination_path == NULL || disk_path == NULL) {
|
|
free(confined_backup);
|
|
free(confined_partial);
|
|
free(destination_path);
|
|
free(disk_path);
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
|
|
/* --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 result =
|
|
file_stage_delayed_update(root_directory, destination_path, file, (Config*)config);
|
|
free(confined_backup);
|
|
free(confined_partial);
|
|
free(destination_path);
|
|
free(disk_path);
|
|
return result;
|
|
}
|
|
|
|
/* --existing checks the final destination, not a temporary partial path. */
|
|
if (config && config->existing && !file_path_exists_secure(destination_path)) {
|
|
free(confined_backup);
|
|
free(confined_partial);
|
|
free(destination_path);
|
|
free(disk_path);
|
|
return FILE_SAVE_SKIPPED;
|
|
}
|
|
|
|
/* --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(destination_path);
|
|
if (exists) {
|
|
free(confined_backup);
|
|
free(confined_partial);
|
|
free(destination_path);
|
|
free(disk_path);
|
|
return FILE_SAVE_SKIPPED;
|
|
}
|
|
}
|
|
|
|
/* --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 = use_partial_root ? destination_path : disk_path;
|
|
if (config && config->update && file_destination_is_newer_secure(update_target, file->metadata)) {
|
|
free(confined_backup);
|
|
free(confined_partial);
|
|
free(destination_path);
|
|
free(disk_path);
|
|
return FILE_SAVE_SKIPPED;
|
|
}
|
|
|
|
if (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 = use_partial_root ? destination_path : disk_path;
|
|
struct stat backup_stat;
|
|
if (file_stat_secure(replace_target, &backup_stat)) {
|
|
if (backup_dir) {
|
|
backup_path = path_cat(confined_backup, file->path);
|
|
} else {
|
|
size_t path_len = strlen(replace_target);
|
|
size_t suffix_len = strlen(backup_suffix);
|
|
if (path_len > SIZE_MAX - suffix_len - 1)
|
|
goto fail;
|
|
backup_path = malloc(path_len + suffix_len + 1);
|
|
if (backup_path) {
|
|
memcpy(backup_path, replace_target, path_len);
|
|
memcpy(backup_path + path_len, backup_suffix, suffix_len + 1);
|
|
}
|
|
}
|
|
if (!backup_path)
|
|
goto fail;
|
|
parent_copy = str_dup(backup_path);
|
|
if (!parent_copy || !file_ensure_directory_secure(dirname(parent_copy)))
|
|
goto fail;
|
|
free(parent_copy);
|
|
parent_copy = NULL;
|
|
if (!file_rename_secure(replace_target, backup_path))
|
|
goto fail;
|
|
free(backup_path);
|
|
backup_path = NULL;
|
|
}
|
|
}
|
|
|
|
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 fail;
|
|
metadata = &adjusted_metadata;
|
|
}
|
|
|
|
/* 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;
|
|
|
|
/* --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 (use_partial_root) {
|
|
if (!file_rename_secure(disk_path, destination_path))
|
|
goto fail;
|
|
}
|
|
|
|
free(parent_copy);
|
|
free(backup_path);
|
|
free(confined_backup);
|
|
free(confined_partial);
|
|
free(destination_path);
|
|
free(disk_path);
|
|
return FILE_SAVE_WRITTEN;
|
|
|
|
fail:
|
|
free(parent_copy);
|
|
free(backup_path);
|
|
free(confined_backup);
|
|
free(confined_partial);
|
|
free(destination_path);
|
|
free(disk_path);
|
|
return FILE_SAVE_ERROR;
|
|
}
|
|
|
|
static File* receive_delta_file(int fd, const Config* config, const char* check_path,
|
|
void* old_data, unsigned long long old_size, bool* failed) {
|
|
if (!old_data) {
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
|
|
DeltaSignature* sig = delta_signature_create(old_data, old_size, config->delta_block_size);
|
|
if (!sig) {
|
|
free(old_data);
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
|
|
Data* sig_data = delta_signature_serialize(sig);
|
|
if (!sig_data) {
|
|
delta_signature_destroy(sig);
|
|
free(old_data);
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
|
|
bool sig_sent = send_status(fd, STATUS_DELTA_SIGNATURE) && send_data(fd, sig_data);
|
|
data_destroy(sig_data);
|
|
|
|
if (!sig_sent) {
|
|
delta_signature_destroy(sig);
|
|
free(old_data);
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
|
|
Status resp;
|
|
if (!receive_status(fd, &resp)) {
|
|
delta_signature_destroy(sig);
|
|
free(old_data);
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
|
|
if (resp == STATUS_DELTA_DATA) {
|
|
Data* delta_data = receive_data_limited(fd, MAX_RECEIVE_WHOLE_FILE_SIZE);
|
|
if (!delta_data) {
|
|
delta_signature_destroy(sig);
|
|
free(old_data);
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
|
|
Data* raw_delta = delta_data;
|
|
if (config->use_compression &&
|
|
!compression_should_skip_with_suffixes(
|
|
check_path, config->skip_compress_suffixes,
|
|
config->skip_compress_set ? config->skip_compress_count : -1)) {
|
|
raw_delta = data_decompress_limited(delta_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
|
|
data_destroy(delta_data);
|
|
if (!raw_delta) {
|
|
free(old_data);
|
|
delta_signature_destroy(sig);
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
}
|
|
|
|
Delta* delta = delta_deserialize(raw_delta);
|
|
data_destroy(raw_delta);
|
|
if (!delta) {
|
|
free(old_data);
|
|
delta_signature_destroy(sig);
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
|
|
uint64_t new_size = delta->new_file_size;
|
|
if (new_size > MAX_RECEIVE_WHOLE_FILE_SIZE || new_size > SIZE_MAX) {
|
|
delta_destroy(delta);
|
|
free(old_data);
|
|
delta_signature_destroy(sig);
|
|
send_status(fd, STATUS_ERROR);
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
void* new_data = delta_apply(old_data, old_size, delta, config->delta_block_size);
|
|
delta_destroy(delta);
|
|
|
|
if (!new_data) {
|
|
free(old_data);
|
|
delta_signature_destroy(sig);
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
|
|
File* file = file_create(check_path);
|
|
if (!file) {
|
|
free(new_data);
|
|
free(old_data);
|
|
delta_signature_destroy(sig);
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
|
|
if (config->use_metadata) {
|
|
int meta_ok = 1;
|
|
file->metadata = metadata_receive(fd, &meta_ok);
|
|
if (!meta_ok) {
|
|
file_destroy(file);
|
|
free(new_data);
|
|
free(old_data);
|
|
delta_signature_destroy(sig);
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
}
|
|
|
|
Data* replacement = data_create(new_data, (size_t)new_size);
|
|
if (replacement == NULL) {
|
|
file_destroy(file);
|
|
free(old_data);
|
|
delta_signature_destroy(sig);
|
|
send_status(fd, STATUS_ERROR);
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
data_destroy(file->data);
|
|
file->data = replacement;
|
|
|
|
free(old_data);
|
|
delta_signature_destroy(sig);
|
|
return file;
|
|
}
|
|
|
|
if (resp == STATUS_NEXT) {
|
|
delta_signature_destroy(sig);
|
|
free(old_data);
|
|
|
|
File* file = file_create(check_path);
|
|
if (!file) {
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
|
|
if (config->use_metadata) {
|
|
int meta_ok = 1;
|
|
file->metadata = metadata_receive(fd, &meta_ok);
|
|
if (!meta_ok) {
|
|
file_destroy(file);
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
}
|
|
|
|
Data* file_data = receive_data_limited(fd, MAX_RECEIVE_WHOLE_FILE_SIZE);
|
|
if (file_data == NULL) {
|
|
file_destroy(file);
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
|
|
if (config->use_compression &&
|
|
!compression_should_skip_with_suffixes(
|
|
file->path, config->skip_compress_suffixes,
|
|
config->skip_compress_set ? config->skip_compress_count : -1)) {
|
|
Data* uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
|
|
data_destroy(file_data);
|
|
if (uncompressed == NULL) {
|
|
file_destroy(file);
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
if (uncompressed->size > MAX_FILE_DATA_SIZE) {
|
|
data_destroy(uncompressed);
|
|
file_destroy(file);
|
|
send_status(fd, STATUS_ERROR);
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
file_data = uncompressed;
|
|
}
|
|
|
|
data_destroy(file->data);
|
|
file->data = file_data;
|
|
return file;
|
|
}
|
|
|
|
delta_signature_destroy(sig);
|
|
free(old_data);
|
|
send_status(fd, STATUS_ERROR);
|
|
*failed = true;
|
|
return NULL;
|
|
}
|
|
|
|
File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
|
|
if (!config || !skipped) {
|
|
send_status(fd, STATUS_ERROR);
|
|
return NULL;
|
|
}
|
|
*skipped = false;
|
|
char* check_path = receive_str(fd);
|
|
if (check_path == NULL) {
|
|
return NULL;
|
|
}
|
|
|
|
unsigned long long check_size;
|
|
long long check_mtime;
|
|
long long check_mtime_nsec;
|
|
uint64_t check_checksum = 0;
|
|
if (!receive_n_data(fd, &check_size, sizeof(check_size)) ||
|
|
!receive_n_data(fd, &check_mtime, sizeof(check_mtime))) {
|
|
free(check_path);
|
|
return NULL;
|
|
}
|
|
if (!receive_n_data(fd, &check_mtime_nsec, sizeof(check_mtime_nsec)) || check_mtime_nsec < 0 ||
|
|
check_mtime_nsec >= 1000000000LL) {
|
|
free(check_path);
|
|
send_status(fd, STATUS_ERROR);
|
|
return NULL;
|
|
}
|
|
if (config->checksum && !receive_n_data(fd, &check_checksum, sizeof(check_checksum))) {
|
|
free(check_path);
|
|
return NULL;
|
|
}
|
|
|
|
if (check_size > MAX_RECEIVE_WHOLE_FILE_SIZE) {
|
|
free(check_path);
|
|
send_status(fd, STATUS_ERROR);
|
|
return NULL;
|
|
}
|
|
|
|
if (has_path_traversal(check_path)) {
|
|
char* escaped_path = output_escape(check_path, log_get_8_bit_output());
|
|
log_message(LOG_LEVEL_ERROR, "Path traversal detected: %s",
|
|
escaped_path ? escaped_path : "<allocation failed>");
|
|
free(escaped_path);
|
|
free(check_path);
|
|
return NULL;
|
|
}
|
|
|
|
char* full_path = path_cat(config->receive_root_directory, check_path);
|
|
if (!full_path) {
|
|
free(check_path);
|
|
send_status(fd, STATUS_ERROR);
|
|
return NULL;
|
|
}
|
|
|
|
/* Open the existing destination entry (if any) once and keep the descriptor
|
|
until the quick-check below decides whether the old contents are needed. */
|
|
struct stat st;
|
|
bool has_old_file = false;
|
|
int old_fd = -1;
|
|
char* leaf = NULL;
|
|
int parent_fd = file_open_secure_parent(full_path, &leaf, false);
|
|
if (parent_fd >= 0) {
|
|
old_fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
|
|
free(leaf);
|
|
close(parent_fd);
|
|
has_old_file = old_fd >= 0 && fstat(old_fd, &st) == 0 && S_ISREG(st.st_mode);
|
|
}
|
|
if (!has_old_file && old_fd >= 0) {
|
|
close(old_fd);
|
|
old_fd = -1;
|
|
}
|
|
unsigned long long old_size = has_old_file ? (unsigned long long)st.st_size : 0;
|
|
|
|
/* Decide from metadata alone whether the receiver already holds the file
|
|
the sender is offering. The old contents are only read into memory when
|
|
a checksum comparison or a delta transfer actually requires them. */
|
|
bool size_equal = has_old_file && old_size == check_size;
|
|
bool match_by_metadata = false;
|
|
if (size_equal && !config->ignore_times && !config->size_only) {
|
|
long long old_mtime_nsec = 0;
|
|
#ifdef __linux__
|
|
old_mtime_nsec = st.st_mtim.tv_nsec;
|
|
#endif
|
|
match_by_metadata = metadata_mtime_matches(st.st_mtime, old_mtime_nsec, (time_t)check_mtime,
|
|
(long)check_mtime_nsec, config->modify_window);
|
|
}
|
|
|
|
bool try_delta = config->use_delta && !config->whole_file && has_old_file &&
|
|
delta_should_attempt(old_size, check_size, config->delta_max_file_size);
|
|
bool checksum_needs_read = size_equal && !config->ignore_times && config->checksum;
|
|
bool need_old_data = checksum_needs_read || try_delta;
|
|
|
|
void* old_data = NULL;
|
|
if (need_old_data && has_old_file && old_size > 0 && old_size <= MAX_RECEIVE_WHOLE_FILE_SIZE &&
|
|
old_size <= SIZE_MAX) {
|
|
old_data = protocol_alloc((size_t)old_size);
|
|
if (old_data) {
|
|
size_t got = 0;
|
|
while (got < (size_t)old_size) {
|
|
ssize_t n = read(old_fd, (char*)old_data + got, (size_t)old_size - got);
|
|
if (n <= 0) {
|
|
free(old_data);
|
|
old_data = NULL;
|
|
break;
|
|
}
|
|
got += (size_t)n;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Quick-skip decision. If no content comparison is required this is final
|
|
and the old file was never read; if the read failed the file is not
|
|
skipped and the transfer proceeds with the full new contents. */
|
|
bool match = false;
|
|
if (checksum_needs_read) {
|
|
if (old_size == 0)
|
|
match = delta_xxhash64("", 0) == check_checksum;
|
|
else
|
|
match = old_data != NULL && delta_xxhash64(old_data, (size_t)old_size) == check_checksum;
|
|
} else if (size_equal && !config->ignore_times) {
|
|
match = config->size_only || match_by_metadata;
|
|
}
|
|
|
|
if (match) {
|
|
free(old_data);
|
|
if (!send_status(fd, STATUS_OK)) {
|
|
close(old_fd);
|
|
free(full_path);
|
|
free(check_path);
|
|
return NULL;
|
|
}
|
|
close(old_fd);
|
|
free(full_path);
|
|
free(check_path);
|
|
*skipped = true;
|
|
return NULL;
|
|
}
|
|
|
|
if (try_delta && old_data != NULL) {
|
|
bool delta_failed = false;
|
|
File* delta_file =
|
|
receive_delta_file(fd, config, check_path, old_data, old_size, &delta_failed);
|
|
old_data = NULL; /* receive_delta_file consumes the snapshot on every path */
|
|
if (delta_file) {
|
|
close(old_fd);
|
|
free(full_path);
|
|
free(check_path);
|
|
return delta_file;
|
|
}
|
|
if (delta_failed) {
|
|
close(old_fd);
|
|
free(full_path);
|
|
free(check_path);
|
|
return NULL;
|
|
}
|
|
}
|
|
free(old_data);
|
|
old_data = NULL;
|
|
|
|
if (!send_status(fd, STATUS_NEXT)) {
|
|
close(old_fd);
|
|
free(full_path);
|
|
free(check_path);
|
|
return NULL;
|
|
}
|
|
close(old_fd);
|
|
|
|
File* file = file_create(check_path);
|
|
free(check_path);
|
|
free(full_path);
|
|
if (file == NULL) {
|
|
return NULL;
|
|
}
|
|
|
|
if (config->use_metadata) {
|
|
int meta_ok = 1;
|
|
file->metadata = metadata_receive(fd, &meta_ok);
|
|
if (!meta_ok) {
|
|
file_destroy(file);
|
|
return NULL;
|
|
}
|
|
}
|
|
|
|
Data* file_data = receive_data_limited(fd, MAX_RECEIVE_WHOLE_FILE_SIZE);
|
|
if (file_data == NULL) {
|
|
file_destroy(file);
|
|
return NULL;
|
|
}
|
|
|
|
if (config->use_compression &&
|
|
!compression_should_skip_with_suffixes(file->path, config->skip_compress_suffixes,
|
|
config->skip_compress_set ? config->skip_compress_count
|
|
: -1)) {
|
|
Data* uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
|
|
data_destroy(file_data);
|
|
if (uncompressed == NULL) {
|
|
file_destroy(file);
|
|
return NULL;
|
|
}
|
|
if (uncompressed->size > MAX_FILE_DATA_SIZE) {
|
|
data_destroy(uncompressed);
|
|
file_destroy(file);
|
|
return NULL;
|
|
}
|
|
file_data = uncompressed;
|
|
}
|
|
|
|
data_destroy(file->data);
|
|
file->data = file_data;
|
|
return file;
|
|
}
|
|
|
|
File* file_receive(const Config* config, int file_descriptor) {
|
|
char* path = receive_str(file_descriptor);
|
|
if (path == NULL)
|
|
return NULL;
|
|
if (path[0] == '\0' || has_path_traversal(path)) {
|
|
char* escaped_path = output_escape(path, log_get_8_bit_output());
|
|
log_message(LOG_LEVEL_ERROR, "Invalid received file path: %s",
|
|
escaped_path ? escaped_path : "<allocation failed>");
|
|
free(escaped_path);
|
|
free(path);
|
|
return NULL;
|
|
}
|
|
File* file = file_create(path);
|
|
free(path);
|
|
if (file == NULL)
|
|
return NULL;
|
|
if (config->use_metadata) {
|
|
int meta_ok = 1;
|
|
file->metadata = metadata_receive(file_descriptor, &meta_ok);
|
|
if (!meta_ok) {
|
|
file_destroy(file);
|
|
return NULL;
|
|
}
|
|
}
|
|
Data* file_data = receive_data_limited(file_descriptor, MAX_RECEIVE_WHOLE_FILE_SIZE);
|
|
if (file_data == NULL) {
|
|
file_destroy(file);
|
|
return NULL;
|
|
}
|
|
if (config->use_compression &&
|
|
!compression_should_skip_with_suffixes(file->path, config->skip_compress_suffixes,
|
|
config->skip_compress_set ? config->skip_compress_count
|
|
: -1)) {
|
|
Data* file_data_uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
|
|
data_destroy(file_data);
|
|
if (file_data_uncompressed == NULL) {
|
|
file_destroy(file);
|
|
return NULL;
|
|
}
|
|
if (file_data_uncompressed->size > MAX_FILE_DATA_SIZE) {
|
|
data_destroy(file_data_uncompressed);
|
|
file_destroy(file);
|
|
return NULL;
|
|
}
|
|
file_data = file_data_uncompressed;
|
|
}
|
|
data_destroy(file->data);
|
|
file->data = file_data;
|
|
return file;
|
|
}
|
|
|
|
/* Receive an explicit directory entry (--dirs): a STATUS_MKDIR frame carries
|
|
only the destination path; the entry carries no payload. The same path
|
|
validation as a regular file applies (non-empty, relative-or-mirrored, no
|
|
traversal), and the created File is routed through the regular store_file
|
|
sink so single-threaded and -m receivers handle directories identically. */
|
|
File* file_receive_directory(int file_descriptor) {
|
|
char* path = receive_str(file_descriptor);
|
|
if (path == NULL)
|
|
return NULL;
|
|
if (path[0] == '\0' || has_path_traversal(path)) {
|
|
char* escaped_path = output_escape(path, log_get_8_bit_output());
|
|
log_message(LOG_LEVEL_ERROR, "Invalid received directory path: %s",
|
|
escaped_path ? escaped_path : "<allocation failed>");
|
|
free(escaped_path);
|
|
free(path);
|
|
return NULL;
|
|
}
|
|
File* file = file_create(path);
|
|
free(path);
|
|
if (file == NULL)
|
|
return NULL;
|
|
file->is_dir = true;
|
|
return file;
|
|
}
|
|
|
|
int receive_manifest(int fd, const Config* config, int* next_status) {
|
|
if (!config) {
|
|
send_status(fd, STATUS_ERROR);
|
|
return -1;
|
|
}
|
|
int received_status = STATUS_ERROR;
|
|
int* status_out = next_status ? next_status : &received_status;
|
|
int count;
|
|
if (!receive_int(fd, &count)) {
|
|
send_status(fd, STATUS_ERROR);
|
|
return -1;
|
|
}
|
|
if (count < 0 || count > MAX_MANIFEST_ENTRIES) {
|
|
send_status(fd, STATUS_ERROR);
|
|
return -1;
|
|
}
|
|
ArrayList* manifest = array_list_create(free);
|
|
if (!manifest) {
|
|
send_status(fd, STATUS_ERROR);
|
|
return -1;
|
|
}
|
|
size_t manifest_bytes = 0;
|
|
for (int i = 0; i < count; i++) {
|
|
char* s = receive_str(fd);
|
|
size_t entry_size = s ? strlen(s) : 0;
|
|
if (!s || s[0] == '\0' || s[0] == '/' || has_path_traversal(s) ||
|
|
entry_size > MAX_MANIFEST_BYTES - manifest_bytes ||
|
|
(manifest_bytes += entry_size) > MAX_MANIFEST_BYTES || !array_list_add(manifest, s)) {
|
|
free(s);
|
|
array_list_delete(manifest);
|
|
send_status(fd, STATUS_ERROR);
|
|
return -1;
|
|
}
|
|
}
|
|
if (!receive_status(fd, status_out)) {
|
|
array_list_delete(manifest);
|
|
send_status(fd, STATUS_ERROR);
|
|
return -1;
|
|
}
|
|
/* Deletion is a commit operation: never perform it until the sender has
|
|
completed the manifest frame successfully. */
|
|
if (*status_out != STATUS_FINISHED || !config->use_delete) {
|
|
array_list_delete(manifest);
|
|
if (*status_out != STATUS_FINISHED)
|
|
send_status(fd, STATUS_ERROR);
|
|
return *status_out == STATUS_FINISHED ? 0 : -1;
|
|
}
|
|
fprintf(stderr, "Deleting files not in manifest...\n");
|
|
/* With --delay-updates the staged (not yet published) files live directly
|
|
under the receive root in the staging directory; the delete walker must
|
|
not treat them as extras or it would remove every staged file before it
|
|
can be published. */
|
|
const char* skip_staging = config->delay_updates ? DELAY_UPDATES_STAGING_DIR : NULL;
|
|
bool deletion_ok = delete_extras_limited(config->receive_root_directory, manifest,
|
|
MAX_SERVER_DELETE_COUNT, skip_staging);
|
|
array_list_delete(manifest);
|
|
if (!deletion_ok)
|
|
send_status(fd, STATUS_ERROR);
|
|
return deletion_ok ? 0 : -1;
|
|
}
|