10 Commits
Author SHA1 Message Date
TapTap 2a8941ee5c Merge feat/ref-integration: SuperMode enum, dir-time gate dedup, parse_args/server_module_gate splits
CI / lint (push) Successful in 1m30s
CI / sanitizers (undefined) (push) Successful in 59s
CI / sanitizers (address) (push) Successful in 1m6s
CI / fuzz-build (push) Successful in 29s
CI / coverage (push) Successful in 49s
CI / valgrind (push) Successful in 2m9s
CI / build-and-test (push) Successful in 4m31s
2026-09-12 21:11:03 +02:00
TapTap 37037a6ee7 refactor(client-cli): drop unused CliParseCtx positional fields (cppcheck) 2026-09-12 21:07:14 +02:00
TapTap 061e9ad43f Merge feat/ref-modulegate: split server_module_gate into helpers 2026-09-12 20:56:43 +02:00
TapTap f928879755 Merge feat/ref-parseargs: split parse_args into focused helpers 2026-09-12 20:56:43 +02:00
TapTap 84b7cb0de3 refactor(server): split server_module_gate into ordered helper stages 2026-09-12 20:56:33 +02:00
TapTap 921472b8b3 refactor(client-cli): split parse_args into focused option handlers
Break the ~700-line parse_args god function into cohesive static helpers
grouped by concern: output controls, pre-negation, range/time options, the
OPTION_TABLE dispatcher, flag/meta handlers, IO/network options, filter and
logging options, checksum/socket options, remote/basis/identity options,
positional handling, and a final lowering step.

A file-local CliParseCtx carries the config, cursor, positional buffers and
the mutable parse flags, so each handler stays focused. The dispatcher calls
the handlers in the original recognition order and preserves the exact
return contract (0/1/negative), error messages, log levels and control flow.

Behavior preserved; no functional changes.
2026-09-12 20:55:04 +02:00
TapTap 082ac2645d Merge feat/ref-dirtime: dir-time capture gate dedup 2026-09-12 20:43:42 +02:00
TapTap eefbd1e849 Merge feat/ref-supermode: SuperMode enum 2026-09-12 20:43:42 +02:00
TapTap 1fd462cca8 refactor(config): replace SUPER_MODE_* macros with SuperMode enum
Type Config.super_mode as SuperMode (a proper C enum) instead of a bare
int.  The wire boundary still carries the mode as an int: send casts the
enum explicitly and receive reads a temporary int, validates the
AUTO..OFF range, then casts.  Emitted bytes and accepted values are
unchanged.  ModuleGateContext.super_mode_override keeps its -1 sentinel
as int with an explicit cast at the apply site.

Behavior preserved.
2026-09-12 20:43:24 +02:00
TapTap 4ac37c4d8a refactor(dir-times): extract dir_times_should_capture predicate
Deduplicate the repeated directory-time capture gate
(`config->use_metadata && !config->omit_dir_times`) used by the
sender-side (multiprocessing.c) and receiver-side (receiver.c) sinks
into a single predicate declared next to the DirTimeList machinery in
file_receive.h and defined in file_receive.c.

Behavior preserved: identical short-circuit condition and semantics,
no signature or protocol changes.
2026-09-12 20:42:47 +02:00
11 changed files with 926 additions and 502 deletions

No files matched your search

+734 -376
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -353,7 +353,7 @@ static bool receiver_save_file(File* file, void* context_pointer) {
metadata now and apply it at the end. -O/--omit-dir-times is honored by
dir_time_list_apply's caller (see receiver_send_success_frame). */
if (result != FILE_SAVE_ERROR && file->is_dir && file->metadata &&
context->config->use_metadata && !context->config->omit_dir_times &&
dir_times_should_capture(context->config) &&
!dir_time_list_add(&context->dir_times, file->path, file->metadata)) {
file_destroy(file);
return false;
+165 -108
View File
@@ -251,6 +251,155 @@ static bool configure_authorization(const char* root) {
return true;
}
/* Discriminates the outcome of the A7 auth gate so the dispatcher can map it
* back to the config_receive_with_validate contract: accepted (including
* "module needs no auth"), a config-level refusal carrying an error string, or
* a handshake that already wrote its own terminal status frame. */
typedef enum {
MODULE_AUTH_ACCEPTED = 0,
MODULE_AUTH_REFUSED,
MODULE_AUTH_TERMINATED,
} ModuleAuthResult;
/* Looks up the daemon module selected by the client's config frame and rejects
* a `read only` one (every FastSync network transfer writes; there is no
* read-only wire operation yet). Returns the module, or NULL with *error set
* to the caller-facing rejection message. */
static const DaemonModule* module_gate_lookup_module(const Config* config, const char** error) {
const DaemonModule* module = daemon_conf_find_module(g_daemon_conf, config->module);
if (module == NULL) {
char* escaped_module = output_escape(config->module, config->eight_bit_output);
log_message(LOG_LEVEL_ERROR, "unknown daemon module '%s' requested",
escaped_module ? escaped_module : "<allocation failed>");
free(escaped_module);
*error = "requested daemon module does not exist";
return NULL;
}
if (module->read_only) {
log_message(LOG_LEVEL_ERROR, "daemon module '%s' is read only; refusing write transfer",
config->module);
*error = "requested daemon module is read only";
return NULL;
}
return module;
}
/* Per-module client-chosen ownership / super-user policy (P7 Wave E hardening):
* a daemon module refuses EVERY ownership-affecting request (--numeric-ids,
* --chown, --usermap/--groupmap, --fake-super, --copy-as, explicit --super)
* unless the operator opted THIS module in with `client owner = yes`.
* Otherwise any client could force arbitrary ownership inside the module root.
* The ownership check is evaluated against the ORIGINAL config so an explicit
* --super is refused even when an operator --no-super veto already forced the
* effective copy to OFF (the veto must not silently convert a refusal into an
* accept); when no ownership flag is present, super-user DEVICE activities are
* forced off for this connection instead. Returns an error string on refusal,
* NULL on acceptance. */
static const char* module_gate_check_ownership(const Config* config, const DaemonModule* module,
ModuleGateContext* gate_ctx) {
if (module->client_owner)
return NULL;
/* Ownership: refuse the whole transfer up front (a clear failure). */
if (identity_ownership_requested(config)) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' refuses client-chosen ownership/super-user activities "
"(no `client owner = yes` opt-in); refusing",
config->module);
return "client-chosen ownership is not permitted by this daemon module";
}
/* Super-user DEVICE activities (char/block mknod and --write-devices) are
permitted under the default AUTO mode, so without this override a root
daemon would still let a non-opted module create arbitrary device nodes
and write raw devices. Force them off for this connection: those entries
are skipped (never mknod'ed) while an ordinary `-a` push still succeeds
without device nodes, matching the operator's least-privilege choice.
The operator-level --no-super veto is already folded into this. */
if (gate_ctx)
gate_ctx->super_mode_override = SUPER_MODE_OFF;
return NULL;
}
/* A7 auth gate: runs the SCRAM challenge/response for an auth-required module
* BEFORE the module root is installed and before any data moves. Returns
* MODULE_AUTH_ACCEPTED when the module needs no auth or the handshake succeeds,
* MODULE_AUTH_REFUSED with *error set on a config-level rejection, or
* MODULE_AUTH_TERMINATED when the handshake already wrote a terminal status. */
static ModuleAuthResult module_gate_authenticate(const Config* config, const DaemonModule* module,
ModuleGateContext* gate_ctx, const char** error) {
if (module->auth_user_count == 0)
return MODULE_AUTH_ACCEPTED;
/* Fail closed: no store -> refuse (server misconfiguration, STATUS_ERROR). */
if (g_credentials == NULL) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication but no credential store is "
"configured (--password-file/--early-input); refusing",
config->module);
*error = "requested daemon module requires authentication and no credential "
"store is configured";
return MODULE_AUTH_REFUSED;
}
/* Transport policy (A7-3/S1): an auth-required module only accepts
* credentials over (a) an encrypted, verified TLS connection whose client
* certificate matches --client-cn, or (b) an actual PLAINTEXT connection
* from a loopback peer that the operator explicitly opted into with
* --allow-unauthenticated. A remote plaintext peer, an un-flagged loopback
* plaintext peer, and a loopback TLS peer whose certificate does not match
* --client-cn are all refused HERE, before the challenge is sent, so an
* unverified client never receives a nonce: the loopback allowance requires
* !gate_ctx->ssl, so --tls + --allow-unauthenticated can never be used to
* bypass the client-CN check. The operator flag never permits REMOTE
* plaintext auth: remote peers still require verified TLS regardless. */
bool tls_ok = gate_ctx && gate_ctx->ssl && SSL_get_verify_result(gate_ctx->ssl) == X509_V_OK &&
tls_client_identity_allowed(gate_ctx->ssl);
bool local_ok = allow_unauthenticated && gate_ctx && !gate_ctx->ssl && gate_ctx->fd >= 0 &&
utils_fd_peer_is_local(gate_ctx->fd);
if (!tls_ok && !local_ok) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication over an encrypted, verified TLS "
"connection (or an opted-in loopback plaintext transport); refusing",
config->module);
*error = "daemon module requires authentication over an encrypted, verified TLS "
"connection";
return MODULE_AUTH_REFUSED;
}
/* Belt-and-braces: the transport policy above already guarantees a context
* with a usable socket (verified TLS implies a live SSL object and loopback
* allowance requires gate_ctx->fd >= 0), so this is unreachable today; keep
* the guard so the handshake can never be driven over an invalid fd. */
if (!gate_ctx || gate_ctx->fd < 0) {
log_message(LOG_LEVEL_ERROR, "daemon module '%s': no auth transport available", config->module);
*error = "authentication failed for the requested daemon module";
return MODULE_AUTH_REFUSED;
}
/* The handshake writes exactly one terminal status on failure and signals so
* via MODULE_AUTH_TERMINATED; the username may be logged (never the password
* or any derived proof). */
if (!server_auth_handshake(gate_ctx->fd, config, module)) {
char* escaped_user =
config->auth_user ? output_escape(config->auth_user, config->eight_bit_output) : NULL;
log_message(LOG_LEVEL_ERROR, "daemon module '%s': authentication failed for user '%s'",
config->module, escaped_user ? escaped_user : "(none)");
free(escaped_user);
return MODULE_AUTH_TERMINATED;
}
char* escaped_user = output_escape(config->auth_user, config->eight_bit_output);
log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' authenticated", config->module,
escaped_user ? escaped_user : "<allocation failed>");
free(escaped_user);
return MODULE_AUTH_ACCEPTED;
}
/* Installs the module's configured path as the connection's authorized root.
* Returns an error string when the root is unusable, NULL on success. */
static const char* module_gate_install_root(const Config* config, const DaemonModule* module) {
if (!configure_authorization(module->path)) {
log_message(LOG_LEVEL_ERROR, "daemon module '%s' path '%s' is not usable", config->module,
module->path ? module->path : "(null)");
return "requested daemon module root is not usable";
}
return NULL;
}
/* Config-frame gate (runs inside config_receive_with_validate, BEFORE the
* STATUS_OK ack, so a rejected connection is refused at the config handshake
* and no file data is ever exchanged).
@@ -324,115 +473,23 @@ static const char* server_module_gate(const Config* config, void* context) {
return "daemon connection did not select a module (expected a "
"host::module/path destination)";
const DaemonModule* module = daemon_conf_find_module(g_daemon_conf, config->module);
if (module == NULL) {
char* escaped_module = output_escape(config->module, config->eight_bit_output);
log_message(LOG_LEVEL_ERROR, "unknown daemon module '%s' requested",
escaped_module ? escaped_module : "<allocation failed>");
free(escaped_module);
return "requested daemon module does not exist";
}
if (module->read_only) {
log_message(LOG_LEVEL_ERROR, "daemon module '%s' is read only; refusing write transfer",
config->module);
return "requested daemon module is read only";
}
/* Client-chosen ownership / super-user policy (P7 Wave E hardening): a daemon
module refuses EVERY ownership-affecting request (--numeric-ids, --chown,
--usermap/--groupmap, --fake-super, --copy-as, explicit --super) unless the
operator opted THIS module in with `client owner = yes`. Otherwise any
client could force arbitrary ownership inside the module root. The
standalone/SSH server has a single operator-authorized root and keeps
honoring these. */
if (!module->client_owner) {
/* Ownership: refuse the whole transfer up front (a clear failure).
Evaluated against the ORIGINAL config so an explicit --super is refused
even when an operator --no-super veto already forced the effective copy
to OFF (the veto must not silently convert a refusal into an accept). */
if (identity_ownership_requested(config)) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' refuses client-chosen ownership/super-user activities "
"(no `client owner = yes` opt-in); refusing",
config->module);
return "client-chosen ownership is not permitted by this daemon module";
}
/* Super-user DEVICE activities (char/block mknod and --write-devices) are
permitted under the default AUTO mode, so without this override a root
daemon would still let a non-opted module create arbitrary device nodes
and write raw devices. Force them off for this connection: those entries
are skipped (never mknod'ed) while an ordinary `-a` push still succeeds
without device nodes, matching the operator's least-privilege choice.
The operator-level --no-super veto is already folded into this. */
if (gate_ctx)
gate_ctx->super_mode_override = SUPER_MODE_OFF;
}
if (module->auth_user_count > 0) {
/* Auth-required module (A7, protocol 2.19.0): run the SCRAM challenge/
* response BEFORE the module root is installed and before any data moves.
* Fail closed: no store -> refuse (server misconfiguration, STATUS_ERROR);
* a handshake that fails before the success response writes exactly one
* STATUS_AUTH_FAILED before signalling ALREADY_TERMINATED (a failure while
* writing the success signature instead just drops the broken connection).
* The username may be logged (never the password or any derived proof). */
if (g_credentials == NULL) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication but no credential store is "
"configured (--password-file/--early-input); refusing",
config->module);
return "requested daemon module requires authentication and no credential "
"store is configured";
}
/* Transport policy (A7-3/S1): an auth-required module only accepts
* credentials over (a) an encrypted, verified TLS connection whose client
* certificate matches --client-cn, or (b) an actual PLAINTEXT connection
* from a loopback peer that the operator explicitly opted into with
* --allow-unauthenticated. A remote plaintext peer, an un-flagged loopback
* plaintext peer, and a loopback TLS peer whose certificate does not match
* --client-cn are all refused HERE, before the challenge is sent, so an
* unverified client never receives a nonce: the loopback allowance requires
* !gate_ctx->ssl, so --tls + --allow-unauthenticated can never be used to
* bypass the client-CN check. The operator flag never permits REMOTE
* plaintext auth: remote peers still require verified TLS regardless. */
bool tls_ok = gate_ctx && gate_ctx->ssl && SSL_get_verify_result(gate_ctx->ssl) == X509_V_OK &&
tls_client_identity_allowed(gate_ctx->ssl);
bool local_ok = allow_unauthenticated && gate_ctx && !gate_ctx->ssl && gate_ctx->fd >= 0 &&
utils_fd_peer_is_local(gate_ctx->fd);
if (!tls_ok && !local_ok) {
log_message(LOG_LEVEL_ERROR,
"daemon module '%s' requires authentication over an encrypted, verified TLS "
"connection (or an opted-in loopback plaintext transport); refusing",
config->module);
return "daemon module requires authentication over an encrypted, verified TLS "
"connection";
}
/* Belt-and-braces: the transport policy above already guarantees a context
* with a usable socket (verified TLS implies a live SSL object and loopback
* allowance requires gate_ctx->fd >= 0), so this is unreachable today; keep
* the guard so the handshake can never be driven over an invalid fd. */
if (!gate_ctx || gate_ctx->fd < 0) {
log_message(LOG_LEVEL_ERROR, "daemon module '%s': no auth transport available",
config->module);
return "authentication failed for the requested daemon module";
}
if (!server_auth_handshake(gate_ctx->fd, config, module)) {
char* escaped_user =
config->auth_user ? output_escape(config->auth_user, config->eight_bit_output) : NULL;
log_message(LOG_LEVEL_ERROR, "daemon module '%s': authentication failed for user '%s'",
config->module, escaped_user ? escaped_user : "(none)");
free(escaped_user);
const char* error = NULL;
const DaemonModule* module = module_gate_lookup_module(config, &error);
if (!module)
return error;
error = module_gate_check_ownership(config, module, gate_ctx);
if (error)
return error;
switch (module_gate_authenticate(config, module, gate_ctx, &error)) {
case MODULE_AUTH_REFUSED:
return error;
case MODULE_AUTH_TERMINATED:
return CONFIG_VALIDATE_ALREADY_TERMINATED;
case MODULE_AUTH_ACCEPTED:
break;
}
char* escaped_user = output_escape(config->auth_user, config->eight_bit_output);
log_message(LOG_LEVEL_INFO, "daemon module '%s': user '%s' authenticated", config->module,
escaped_user ? escaped_user : "<allocation failed>");
free(escaped_user);
}
if (!configure_authorization(module->path)) {
log_message(LOG_LEVEL_ERROR, "daemon module '%s' path '%s' is not usable", config->module,
module->path ? module->path : "(null)");
return "requested daemon module root is not usable";
}
return NULL; /* accepted; authorized root is now the module's path */
/* accepted; the authorized root is now the module's path */
return module_gate_install_root(config, module);
}
void handler(int file_descriptor) {
@@ -458,7 +515,7 @@ void handler(int file_descriptor) {
* device-node creation) sees SUPER_MODE_OFF. The gate never mutated the
* received config. */
if (gate_ctx.super_mode_override != -1)
config->super_mode = gate_ctx.super_mode_override;
config->super_mode = (SuperMode)gate_ctx.super_mode_override;
protocol_set_8_bit_output(config->eight_bit_output);
if (!authorized_root) {
log_message(LOG_LEVEL_ERROR, "No server-side destination root configured");
+2 -2
View File
@@ -1293,14 +1293,14 @@ static bool receive_iconv_spec(int fd, Config* c) {
* validated to the SUPER_MODE_AUTO..SUPER_MODE_OFF range (also re-checked by
* validate_received_config). */
static bool send_privilege_options(int fd, const Config* c) {
return send_int(fd, c->super_mode);
return send_int(fd, (int)c->super_mode);
}
static bool receive_privilege_options(int fd, Config* c) {
int mode;
if (!receive_int(fd, &mode) || mode < SUPER_MODE_AUTO || mode > SUPER_MODE_OFF)
return false;
c->super_mode = mode;
c->super_mode = (SuperMode)mode;
return true;
}
+8 -10
View File
@@ -68,6 +68,13 @@ typedef struct {
int value; /* 0/1 for booleans, byte count for SO_RCVBUF/SO_SNDBUF */
} SockOptEntry;
/* --super / --no-super tri-state (Config->super_mode). AUTO (default) and ON
* both permit a confined super-user attempt (AUTO preserves FastSync's
* historical best-effort behavior; an unprivileged attempt is refused by the
* kernel and skipped per entry); OFF forbids the attempt even for root. See
* privilege_super_mode_permitted() in identity.h. */
typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF = 2 } SuperMode;
typedef struct Config {
char* version;
char* send_directory;
@@ -416,7 +423,7 @@ typedef struct Config {
* as a trailing int so the receiver can enforce the policy. See
* privilege_super_permitted() and identity_ownership_requested() in
* identity.h. */
int super_mode;
SuperMode super_mode;
// Receiver-side runtime staging registry for --delay-updates. Never sent
// over the wire and never set on the sender side.
@@ -644,15 +651,6 @@ typedef struct Config {
#define IDENTITY_CURRENT (-1)
#define MAX_IDENTITY_MAP 128
/* --super / --no-super tri-state (Config->super_mode). AUTO (default) and ON
* both permit a confined super-user attempt (AUTO preserves FastSync's
* historical best-effort behavior; an unprivileged attempt is refused by the
* kernel and skipped per entry); OFF forbids the attempt even for root. See
* privilege_super_mode_permitted() in identity.h. */
#define SUPER_MODE_AUTO 0
#define SUPER_MODE_ON 1
#define SUPER_MODE_OFF 2
Config* config_create(void);
void config_delete(Config* config);
+4
View File
@@ -2236,6 +2236,10 @@ File* file_receive(const Config* config, int file_descriptor) {
/* ---- P7 Wave D: deferred directory times ---- */
bool dir_times_should_capture(const Config* config) {
return config->use_metadata && !config->omit_dir_times;
}
void dir_time_list_init(DirTimeList* list) {
if (!list)
return;
+6
View File
@@ -30,6 +30,12 @@ typedef struct {
size_t capacity;
} DirTimeList;
/* Capture gate shared by the sender-side and receiver-side sinks: directory
* metadata is accumulated only when --times/--metadata is in effect and
* -O/--omit-dir-times does not suppress it. Kept here, next to the accumulator
* it guards, so both call sites express the same condition. */
bool dir_times_should_capture(const Config* config);
void dir_time_list_init(DirTimeList* list);
void dir_time_list_free(DirTimeList* list);
/* Deep-copy one directory's path + metadata into the list. Returns false on
+2 -2
View File
@@ -30,7 +30,7 @@ typedef struct {
/* --super / --no-super tri-state (SUPER_MODE_AUTO when unset). Snapshotted
* per connection so privilege_super_permitted() can gate super-user
* activities without a Config argument. */
int super_mode;
SuperMode super_mode;
/* --copy-as=USER[:GROUP]: snapshotted so the ownership resolver can force the
* target ids without a Config argument. */
bool copy_as_set;
@@ -128,7 +128,7 @@ bool privilege_super_permitted(void) {
return privilege_super_mode_permitted(g_identity.super_mode);
}
bool privilege_super_mode_permitted(int mode) {
bool privilege_super_mode_permitted(SuperMode mode) {
/* AUTO and ON both attempt the confined operation; OFF forbids it even for a
* root receiver. AUTO is the historical FastSync behavior (always attempt
* and let the kernel refuse an unprivileged call, which the caller skips), so
+1 -1
View File
@@ -128,6 +128,6 @@ bool identity_wire_valid(const Config* config);
* best-effort behavior where an unprivileged attempt is refused by the kernel
* and skipped. Neither EVER elevates privileges. */
bool privilege_super_permitted(void);
bool privilege_super_mode_permitted(int mode);
bool privilege_super_mode_permitted(SuperMode mode);
#endif
+2 -1
View File
@@ -6,6 +6,7 @@
#include "config.h"
#include "data.h"
#include "file.h"
#include "file_receive.h"
#include "log.h"
#include "protocol.h"
#include "queue.h"
@@ -331,7 +332,7 @@ int write_thread(void* pipeline_context) {
write would clobber them); accumulate the metadata here and let the
caller apply it once every writer has drained. */
if (result != FILE_SAVE_ERROR && file->is_dir && file->metadata &&
context->config->use_metadata && !context->config->omit_dir_times &&
dir_times_should_capture(context->config) &&
!dir_time_list_add(&context->dir_times, file->path, file->metadata)) {
file_destroy(file);
pipeline_context_receiver_note_bytes_released(context, file_bytes);
+1 -1
View File
@@ -1672,7 +1672,7 @@ static void test_config_receive_rejects_invalid_iconv_spec() {
static void test_config_super_mode_wire_roundtrip() {
if (is_running_under_valgrind())
return;
int modes[] = {SUPER_MODE_AUTO, SUPER_MODE_ON, SUPER_MODE_OFF};
SuperMode modes[] = {SUPER_MODE_AUTO, SUPER_MODE_ON, SUPER_MODE_OFF};
for (size_t i = 0; i < sizeof(modes) / sizeof(modes[0]); i++) {
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);