diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 8004d2d..e8d660f 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -2612,6 +2612,15 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool if (config->use_delete && !config->delete_before && !config->delete_during && !config->delete_delay && !config->delete_after) config->delete_during = true; + /* rsync parity: --partial-dir=DIR chooses where an interrupted transfer's + partial file is kept, so it implies --partial. rsync applies the + implication after option parsing, so it wins over an explicit --no-partial + regardless of the order the two options appear in (verified on rsync + 3.4.1). --inplace is the exception: the destination file is written in + place with no partial/temp staging, so the partial machinery is bypassed + and the implication is skipped to leave --inplace behavior untouched. */ + if (config->partial_dir && !config->inplace) + config->partial = true; if (config->compress_choice) { int algo = compression_algo_from_name(config->compress_choice); if (algo >= 0) { diff --git a/src/client/usage.c b/src/client/usage.c index c6f6e96..a24c2f8 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -288,7 +288,7 @@ void print_usage(void) { printf(" --log-file , --log-file= Write log messages to file\n"); printf(" --stderr=MODE Route logging to stderr: errors or all\n"); printf(" --partial Keep partial files on interrupted transfer\n"); - printf(" --partial-dir Directory for partial files\n"); + printf(" --partial-dir Directory for partial files (implies --partial)\n"); printf(" -T, --temp-dir Scratch dir for temp files before atomic install.\n"); printf(" Confined to the receive root: a relative dir resolves below\n"); printf(" it and an absolute/traversal dir is rejected. The dir must\n"); diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index d3371ab..e60d8c7 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -2275,6 +2275,36 @@ class TestPartialDir: partial = os.path.join(dest, ".partial", os.path.relpath(source_file, os.path.sep)) assert not os.path.exists(partial) + def test_partial_dir_alone_implies_partial(self, shared_server): + """--partial-dir=DIR with no --partial implies --partial, like rsync. + + rsync 3.4.1 retains the staged partial when --partial-dir is given by + itself; before the implication was added FastSync discarded it. The + transfer is made to fail deterministically by placing a non-empty + directory at the destination path, so the final partial-dir -> + destination rename fails and whatever was staged under the partial dir + stays on disk.""" + source = os.path.join(TEST_DATA_DIR, "partial_dir_implied_src") + dest = os.path.join(TEST_DATA_DIR, "partial_dir_implied_dst") + clean_dir(source) + clean_dir(dest) + source_file = os.path.join(source, "f.bin") + with open(source_file, "wb") as f: + f.write(b"partial payload") + + received = get_dest_received_dir(dest, source) + os.makedirs(os.path.join(received, "f.bin")) + with open(os.path.join(received, "f.bin", "keep"), "wb") as f: + f.write(b"keep") + + result, _ = run_client(source, dest, flags=["--partial-dir=.partial"], + port=shared_server.port) + assert result.returncode != 0, "expected the blocked install to fail" + + partial = os.path.join(dest, ".partial", os.path.relpath(source_file, os.path.sep)) + assert os.path.exists(partial), \ + "--partial-dir alone must imply --partial and retain the partial file" + class TestLargeFile: def test_transfer_100mb_file(self, shared_server): diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index b511dd5..d8eb597 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -511,6 +511,61 @@ static void test_parse_args_ignore_existing() { config_delete(cfg); } +/* --partial-dir=DIR implies --partial, matching rsync 3.4.1. rsync resolves + * this after option parsing, so the implication wins over an explicit + * --no-partial in either order. It is skipped under --inplace, where partial + * staging is bypassed and the destination is written in place. */ +static void test_parse_args_partial_dir_implies_partial() { + { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--partial-dir=.partial", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->partial); + config_delete(cfg); + } + { + /* Explicit --no-partial before --partial-dir: --partial-dir still wins. */ + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--no-partial", "--partial-dir=.partial", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->partial); + config_delete(cfg); + } + { + /* Reversed order must not change the precedence. */ + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--partial-dir=.partial", "--no-partial", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0); + EXPECT_TRUE(cfg->partial); + config_delete(cfg); + } + { + /* --inplace bypasses partial staging: the implication must not fire. */ + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--inplace", "--partial-dir=.partial", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0); + EXPECT_FALSE(cfg->partial); + config_delete(cfg); + } + { + Config* cfg = config_create(); + char* argv[] = {"fastsync", "--no-partial", "/src", "/dst"}; + int positional_args[2]; + int positional_count = 0; + EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0); + EXPECT_FALSE(cfg->partial); + config_delete(cfg); + } +} + static void test_parse_args_executability() { Config* cfg = config_create(); char* argv[] = {"fastsync", "-E", "/src", "/dst"}; @@ -4784,6 +4839,7 @@ void test_client_cli() { test_parse_args_valid_port(); test_parse_args_size_only(); test_parse_args_ignore_existing(); + test_parse_args_partial_dir_implies_partial(); test_parse_args_executability(); test_parse_args_chmod(); test_parse_args_numeric_chmod();