fix(client): harden CLI args, log escaping, and local artifact opens

- parse_ull_arg() rejects a leading '-'/'+' (strtoull would silently wrap
  -1 to ULLONG_MAX) and --chunk-size/--delta-max enforce their upper bounds.
- Escape local untrusted paths before logging (client_send, scanner,
  --filter rule, pattern-file reads) with output_escape(..., 8-bit mode).
- Read --exclude-from/--include-from through the bounded line reader.
- Open --log-file with O_NOFOLLOW|O_CLOEXEC, mode 0600, via open+fdopen;
  create --write-batch with O_NOFOLLOW|O_CLOEXEC, mode 0600.
- Reject --dry-run together with --write-batch (dry-run must not write the
  batch file), alongside the existing --read-batch/--only-write-batch rules.

Tests: signed/oversized numeric rejection, over-long pattern file, dry-run +
write-batch unit and integration coverage.
This commit is contained in:
2026-09-14 16:39:19 +02:00
parent dfa2a42028
commit 10c4ffebdf
6 changed files with 169 additions and 25 deletions
+13 -1
View File
@@ -117,4 +117,16 @@ def test_batch_modes_conflict():
cmd = _run(["--source-dir", SOURCE_DIR, "--dest-dir", DEST1] + flags)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
assert result.returncode != 0, \
f"expected conflict failure for {flags}: {result.stderr}"
f"expected conflict failure for {flags}: {result.stderr}"
@pytest.mark.ci
def test_dry_run_rejects_write_batch():
"""--dry-run must not emit a batch file (it must not mutate anything)."""
if os.path.exists(BATCH_FILE):
os.unlink(BATCH_FILE)
cmd = _run(["--source-dir", SOURCE_DIR, "--dest-dir", DEST1,
"--dry-run", "--write-batch", BATCH_FILE])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
assert result.returncode != 0, result.stderr
assert not os.path.exists(BATCH_FILE), "dry-run must not create a batch file"
+55
View File
@@ -3275,6 +3275,58 @@ static void test_parse_args_block_size() {
config_delete(cfg);
}
/* An over-long --exclude-from/--include-from line is rejected at parse time
* rather than being read without a bound. */
static void test_parse_args_pattern_file_oversized_rejected() {
const char* list_path = "cli_pattern_oversized.txt";
size_t len = UTILS_MAX_LINE_LEN + 4096;
char* big = malloc(len);
EXPECT_NOT_NULL(big);
memset(big, 'a', len);
write_file_bytes(list_path, big, len);
free(big);
Config* cfg = config_create();
int positional_args[2];
int positional_count = 0;
char* argv[] = {"fastsync", "--exclude-from", (char*)list_path, "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1);
config_delete(cfg);
remove(list_path);
}
/* A leading '-'/'+' must be rejected for every unsigned numeric option so
* strtoull can never silently wrap (e.g. -1 -> ULLONG_MAX). */
static void test_parse_args_unsigned_options_reject_sign() {
static const char* const opts[] = {"--chunk-size", "--bwlimit", "--delta-max"};
for (size_t i = 0; i < sizeof(opts) / sizeof(opts[0]); i++) {
Config* cfg = config_create();
int positional_args[2];
int positional_count = 0;
char* argv[] = {"fastsync", (char*)opts[i], "-1", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1);
config_delete(cfg);
}
/* An over-cap --chunk-size is rejected at parse time (max 64 MiB). */
Config* cfg = config_create();
int positional_args[2];
int positional_count = 0;
char* big_argv[] = {"fastsync", "--chunk-size", "67108865", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, big_argv, positional_args, &positional_count), -1);
config_delete(cfg);
}
/* --dry-run must not emit a batch file, so it is rejected alongside
* --read-batch/--only-write-batch. */
static void test_validate_config_dry_run_rejects_write_batch() {
Config* cfg = valid_client_config();
cfg->dry_run = true;
cfg->write_batch = str_dup("batch.dat");
EXPECT_FALSE(validate_config(cfg));
config_delete(cfg);
}
void test_client_cli() {
test_validate_config_required_paths();
test_parse_args_numeric_ids();
@@ -3432,4 +3484,7 @@ void test_client_cli() {
test_parse_args_remote_option_short_M();
test_parse_args_no_motd();
test_parse_args_password_file();
test_parse_args_pattern_file_oversized_rejected();
test_parse_args_unsigned_options_reject_sign();
test_validate_config_dry_run_rejects_write_batch();
}