diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 4b14d88..7a70bb7 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -1,5 +1,6 @@ #include "client_send.h" #include "client_validation.h" +#include "charset.h" #include "chmod.h" #include "compression.h" #include "config.h" @@ -590,6 +591,11 @@ static const OptionEntry OPTION_TABLE[] = { * path; main() reads it (after the destination form is known) and derives * the wire credentials. Never crosses the wire. */ {"--password-file", NULL, OPT_STRING, offsetof(Config, password_file)}, + /* --iconv (protocol 2.16.0): convert file-NAME charsets at the wire + * boundary. The CONVERT_SPEC (LOCAL[,REMOTE]) is validated for real iconv + * charsets at startup (client_validation.c) and the full spec rides the + * config frame so the receiver derives the wire charset symmetrically. */ + {"--iconv", NULL, OPT_STRING, offsetof(Config, iconv_spec)}, {"--delete-before", NULL, OPT_FLAG, offsetof(Config, delete_before)}, {"--delete-during", "--del", OPT_FLAG, offsetof(Config, delete_during)}, {"--delete-delay", NULL, OPT_FLAG, offsetof(Config, delete_delay)}, @@ -1593,6 +1599,17 @@ int main(int argc, char* argv[]) { goto cleanup; } + /* --iconv: install the sender-side local->wire conversion before any path is + scanned or serialized (the scanner and the chunk/data path read windows are + all driven from this process, so one global initialization covers every + send site). */ + if (!charset_wire_init_sender(config->iconv_spec)) { + log_message(LOG_LEVEL_ERROR, + "--iconv has an invalid CONVERT_SPEC or an unsupported charset name"); + exit_code = 1; + goto cleanup; + } + /* Apply the requested --outbuf style now that the mode is parsed. */ apply_output_buffering(config); @@ -1614,6 +1631,7 @@ int main(int argc, char* argv[]) { } cleanup: + charset_wire_free(); if (config) { config_delete(config); } diff --git a/src/client/client_send.c b/src/client/client_send.c index e909810..bc86762 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -1,6 +1,7 @@ #include "client_send.h" #include "array_list.h" #include "change_list.h" +#include "charset.h" #include "chunk.h" #include "compression.h" #include "config.h" @@ -810,21 +811,21 @@ static int send_delete_manifest(int fd, ArrayList* manifest, ArrayList* protecte if (!send_int(fd, keep_count)) return -1; for (int i = 0; i < keep_count; i++) { - if (!send_str(fd, (char*)manifest->items[i])) + if (!send_wire_str(fd, (char*)manifest->items[i])) return -1; } int protected_count = protected_prefixes ? protected_prefixes->size : 0; if (!send_int(fd, protected_count)) return -1; for (int i = 0; i < protected_count; i++) { - if (!send_str(fd, (char*)protected_prefixes->items[i])) + if (!send_wire_str(fd, (char*)protected_prefixes->items[i])) return -1; } int missing_count = missing_args ? missing_args->size : 0; if (!send_int(fd, missing_count)) return -1; for (int i = 0; i < missing_count; i++) { - if (!send_str(fd, (char*)missing_args->items[i])) + if (!send_wire_str(fd, (char*)missing_args->items[i])) return -1; } return 0; @@ -899,7 +900,7 @@ static int incremental_check(Client* client, File* file, const Config* config, *resume_offset = 0; if (!send_status(client->file_descriptor, STATUS_CHECK)) return -1; - if (!send_str(client->file_descriptor, file_wire_path(file))) + if (!send_wire_str(client->file_descriptor, file_wire_path(file))) return -1; unsigned long long fsize = file->data->size; long long mtime = file->metadata ? file->metadata->mtime_sec : 0; @@ -1119,7 +1120,7 @@ static bool send_directory_entry(Client* client, File* file) { return false; if (!send_status(client->file_descriptor, STATUS_MKDIR)) return false; - return send_str(client->file_descriptor, file_wire_path(file)); + return send_wire_str(client->file_descriptor, file_wire_path(file)); } /* Transmit one symlink entry: a STATUS_SYMLINK frame carrying the destination @@ -1130,8 +1131,8 @@ static bool send_symlink_entry(const Client* client, File* file, const Config* c if (!file || !file_wire_path(file) || !file->symlink_target) return false; int fd = client->file_descriptor; - if (!send_status(fd, STATUS_SYMLINK) || !send_str(fd, file_wire_path(file)) || - !send_str(fd, file->symlink_target)) + if (!send_status(fd, STATUS_SYMLINK) || !send_wire_str(fd, file_wire_path(file)) || + !send_wire_str(fd, file->symlink_target)) return false; return !config->use_metadata || metadata_send(fd, file->metadata); } @@ -1311,9 +1312,9 @@ static int send_chunk_with_removal(Client* client, Chunk* chunk, Config* config, wire path so the receiver links this entry to that installed file. */ if (f->link_group != 0 && !f->link_first && f->hardlink_target != NULL) { if (!send_status(client->file_descriptor, STATUS_HARDLINK) || - !send_str(client->file_descriptor, file_wire_path(f)) || + !send_wire_str(client->file_descriptor, file_wire_path(f)) || !send_int(client->file_descriptor, f->link_group) || - !send_str(client->file_descriptor, f->hardlink_target)) + !send_wire_str(client->file_descriptor, f->hardlink_target)) return -1; change_emit_file_sent(config, f); continue; diff --git a/src/client/client_validation.c b/src/client/client_validation.c index c0bc08a..d42da2e 100644 --- a/src/client/client_validation.c +++ b/src/client/client_validation.c @@ -1,4 +1,5 @@ #include "client_validation.h" +#include "charset.h" #include "delay_updates.h" #include "log.h" #include "usage.h" @@ -123,5 +124,13 @@ bool validate_config(const Config* config) { "timing; at most one may be given and each implies --delete"); return false; } + /* --iconv: reject a malformed CONVERT_SPEC or an unsupported charset name at + startup (a probe iconv_open is attempted), so a typo'd charset never fails + the run mid-transfer with per-file errors. */ + if (!charset_spec_valid(config->iconv_spec)) { + log_message(LOG_LEVEL_ERROR, + "--iconv requires LOCAL[,REMOTE] charset names supported by iconv"); + return false; + } return true; } diff --git a/src/client/usage.c b/src/client/usage.c index 24e9878..f2159c3 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -35,6 +35,12 @@ void print_usage(void) { printf(" --progress Show transfer progress\n"); printf(" -P Partial mode with progress (retention incomplete)\n"); printf(" -8, --8-bit-output Leave high-bit characters unescaped in output\n"); + printf(" --iconv=LOCAL[,REMOTE] Convert file-NAME charsets at the wire boundary:\n"); + printf(" LOCAL is the charset of our file names, REMOTE is the\n"); + printf(" remote side's charset (defaults to LOCAL). Names are\n"); + printf(" converted before transmission and back on receipt; a\n"); + printf(" name that cannot be represented in the target charset\n"); + printf(" fails that transfer cleanly (rsync-compatible)\n"); printf(" --delete Delete files on receiver not in source\n"); printf(" (default timing: delete only after the whole\n"); printf(" transfer has succeeded)\n"); diff --git a/src/server/receiver.c b/src/server/receiver.c index 63da749..df1dc0f 100644 --- a/src/server/receiver.c +++ b/src/server/receiver.c @@ -1,5 +1,6 @@ #include "receiver.h" +#include "charset.h" #include "chunk.h" #include "config.h" #include "delay_updates.h" @@ -79,7 +80,7 @@ static bool receiver_process_batch(Config* config, int file_descriptor) { count > MAX_MANIFEST_ENTRIES) return false; for (int i = 0; i < count; i++) { - char* check_path = receive_str(file_descriptor); + char* check_path = receive_wire_str(file_descriptor); if (!check_path) return false; unsigned long long check_size; diff --git a/src/server/server.c b/src/server/server.c index 17fce6b..f59d39e 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -1,4 +1,5 @@ #include "config.h" +#include "charset.h" #include "credentials.h" #include "daemon_conf.h" #include "delay_updates.h" @@ -31,6 +32,10 @@ static bool allow_delete; static bool trust_sender; static bool allow_unauthenticated; static const char* required_client_cn; +/* --iconv CONVERT_SPEC the server was itself started with (borrowed argv + * pointer). Its LOCAL half may override the local charset the client assumed; + * see charset_wire_init_receiver. */ +static const char* server_iconv_spec; /* Non-NULL exactly when the listener runs in --daemon mode. Loaded once in * main before any accept-loop fork, then shared read-only by every forked @@ -318,6 +323,20 @@ void handler(int file_descriptor) { return; } config->use_delete = config->use_delete && allow_delete; + /* --iconv (protocol 2.16.0): install the receiver-side wire->local conversion + now that the client's full CONVERT_SPEC has been received and validated, + before any received file name is decoded. The server's own --iconv (if + any) may override the local charset; a spec the client is known to have + validated cannot fail here unless the server's override names an + unsupported charset. */ + if (config->iconv_spec && !charset_wire_init_receiver(config->iconv_spec, server_iconv_spec)) { + log_message(LOG_LEVEL_ERROR, + "--iconv: unsupported charset conversion requested (LOCAL[,REMOTE])"); + config_delete(config); + close(file_descriptor); + protocol_session_unbind(); + return; + } /* --delete-missing-args deletes destination mirrors receiver-side, so it is deletion and stays gated by the same --allow-delete server policy. When the server policy is off the flag is inert (the missing entries are still @@ -485,6 +504,7 @@ void handler(int file_descriptor) { } protocol_session_unbind(); identity_clear_active(); + charset_wire_free(); close(file_descriptor); } @@ -535,6 +555,11 @@ static void print_server_usage(void) { printf(" -6, --ipv6 Bind an IPv6 socket\n"); printf(" --allow-delete Permit manifest deletion\n"); printf(" --trust-sender Trust the remote sender's file list\n"); + printf(" --iconv=LOCAL[,REMOTE] Declare this server's LOCAL charset for file-name\n"); + printf(" conversion: received names are translated to this\n"); + printf(" charset (the wire charset still comes from the\n"); + printf(" client's CONVERT_SPEC). A name that cannot be\n"); + printf(" represented fails the run cleanly\n"); printf(" --allow-unauthenticated Allow plaintext/anonymous network clients\n"); printf(" -v, --verbose Enable debug logging\n"); printf(" --help Show this help\n"); @@ -621,6 +646,7 @@ int main(int argc, char* argv[]) { allow_delete = opts.allow_delete; trust_sender = opts.trust_sender; allow_unauthenticated = opts.allow_unauthenticated; + server_iconv_spec = opts.iconv_spec; signal(SIGINT, cleanup); signal(SIGTERM, cleanup); diff --git a/src/server/server_cli.c b/src/server/server_cli.c index 67b434a..e0168c8 100644 --- a/src/server/server_cli.c +++ b/src/server/server_cli.c @@ -1,4 +1,5 @@ #include "server_cli.h" +#include "charset.h" #include "utils.h" #include #include @@ -143,6 +144,12 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err, opts->trust_sender = true; } else if (arg_is(argv[i], "--allow-unauthenticated")) { opts->allow_unauthenticated = true; + } else if (arg_is(argv[i], "--iconv")) { + if (i + 1 >= argc) { + set_error(err, err_size, "missing argument for --iconv"); + return -1; + } + opts->iconv_spec = argv[++i]; } else if (arg_is(argv[i], "-p")) { if (i + 1 >= argc) { set_error(err, err_size, "missing argument for -p"); @@ -180,6 +187,15 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err, inline_value = argv[++i]; } opts->early_input_file = inline_value; + } else if (arg_has_value(argv[i], "--iconv", &inline_value)) { + if (!inline_value) { + if (i + 1 >= argc) { + set_error(err, err_size, "missing argument for --iconv"); + return -1; + } + inline_value = argv[++i]; + } + opts->iconv_spec = inline_value; } else if (arg_has_value(argv[i], "--dparam", &inline_value)) { if (!inline_value) { if (i + 1 >= argc) { @@ -227,6 +243,12 @@ int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err, "--daemon"); return -1; } + /* --iconv: reject a malformed CONVERT_SPEC or an unsupported charset name at + startup (a probe iconv_open is attempted). */ + if (opts->iconv_spec != NULL && !charset_spec_valid(opts->iconv_spec)) { + set_error(err, err_size, "--iconv requires LOCAL[,REMOTE] charset names supported by iconv"); + return -1; + } return 0; } diff --git a/src/server/server_cli.h b/src/server/server_cli.h index c268bcc..4bb2351 100644 --- a/src/server/server_cli.h +++ b/src/server/server_cli.h @@ -33,6 +33,12 @@ typedef struct ServerCliOptions { bool allow_delete; /* --allow-delete */ bool trust_sender; /* --trust-sender */ bool allow_unauthenticated; /* --allow-unauthenticated */ + /* --iconv=CONVERT_SPEC: the server's own LOCAL charset declaration. The + * client's full spec rides the wire config frame anyway; when the server is + * started with its own --iconv, its LOCAL half overrides the local charset + * the client assumed so the server converts received names to ITS charset. + * Borrowed pointer into argv (never owns heap). */ + const char* iconv_spec; /* --iconv value, or NULL */ } ServerCliOptions; /* Parse argc/argv into *opts. Zero-initialize *opts before calling (or use diff --git a/src/shared/charset.c b/src/shared/charset.c new file mode 100644 index 0000000..981e4b5 --- /dev/null +++ b/src/shared/charset.c @@ -0,0 +1,275 @@ +#include "charset.h" +#include "log.h" +#include "protocol.h" +#include "utils.h" +#include +#include +#include +#include + +typedef struct { + iconv_t cd; +} CharsetConversion; + +static CharsetConversion* g_wire_conv; + +/* Grow *buf to double capacity, freeing it on failure. realloc preserves the + * already-written prefix, so the caller only tracks its write offset. */ +static bool grow_charset_buffer(char** buf, size_t* cap) { + size_t new_cap = *cap * 2; + if (new_cap <= *cap) { + free(*buf); + *buf = NULL; + return false; + } + char* grown = realloc(*buf, new_cap); + if (!grown) { + free(*buf); + *buf = NULL; + return false; + } + *buf = grown; + *cap = new_cap; + return true; +} + +int charset_spec_parse(const char* spec, char** local_out, char** remote_out) { + if (local_out) + *local_out = NULL; + if (remote_out) + *remote_out = NULL; + if (!spec || spec[0] == '\0') + return -1; + char* dup = str_dup(spec); + if (!dup) + return -1; + char* comma = strchr(dup, ','); + if (comma) { + if (comma == dup || comma[1] == '\0') { + free(dup); + return -1; + } + *comma = '\0'; + *local_out = str_dup(dup); + *remote_out = str_dup(comma + 1); + free(dup); + } else { + *local_out = str_dup(dup); + *remote_out = str_dup(dup); + free(dup); + } + if (!*local_out || !*remote_out) { + free(*local_out); + free(*remote_out); + *local_out = NULL; + *remote_out = NULL; + return -1; + } + return 0; +} + +void* charset_conversion_open(const char* from_charset, const char* to_charset) { + if (!from_charset || !to_charset) + return NULL; + iconv_t cd = iconv_open(to_charset, from_charset); + if (cd == (iconv_t)-1) + return NULL; + CharsetConversion* conv = malloc(sizeof(CharsetConversion)); + if (!conv) { + iconv_close(cd); + return NULL; + } + conv->cd = cd; + return conv; +} + +void charset_conversion_close(void* conversion) { + if (!conversion) + return; + CharsetConversion* conv = (CharsetConversion*)conversion; + iconv_close(conv->cd); + free(conv); +} + +bool charset_pair_valid(const char* local, const char* remote) { + if (!local || !remote) + return false; + void* conv = charset_conversion_open(local, remote); + if (!conv) + return false; + charset_conversion_close(conv); + return true; +} + +bool charset_spec_valid(const char* spec) { + if (!spec) + return true; + char* local; + char* remote; + if (charset_spec_parse(spec, &local, &remote) != 0) + return false; + bool ok = charset_pair_valid(local, remote); + free(local); + free(remote); + return ok; +} + +char* charset_convert(const void* conversion, const char* in, int* err_out) { + if (!conversion || !in) + return NULL; + const CharsetConversion* conv = (const CharsetConversion*)conversion; + size_t in_len = strlen(in); + size_t cap = in_len + 16; + char* out = malloc(cap); + if (!out) + return NULL; + size_t in_left = in_len; + char* in_ptr = (char*)in; + size_t out_used = 0; + + while (in_left > 0) { + char* out_ptr = out + out_used; + size_t out_left = cap - out_used; + if (iconv(conv->cd, &in_ptr, &in_left, &out_ptr, &out_left) == (size_t)-1) { + if (errno != E2BIG) { + if (err_out) + *err_out = errno; + free(out); + return NULL; + } + if (!grow_charset_buffer(&out, &cap)) + return NULL; + continue; + } + out_used = (size_t)(out_ptr - out); + } + + /* Flush any pending shift state (a no-op for the stateless single-byte and + UTF charsets this feature targets, but keeps the descriptor clean). */ + for (;;) { + char* out_ptr = out + out_used; + size_t out_left = cap - out_used; + if (iconv(conv->cd, NULL, NULL, &out_ptr, &out_left) == (size_t)-1) { + if (errno != E2BIG) { + if (err_out) + *err_out = errno; + free(out); + return NULL; + } + if (!grow_charset_buffer(&out, &cap)) + return NULL; + continue; + } + out_used = (size_t)(out_ptr - out); + break; + } + + out[out_used] = '\0'; + return out; +} + +bool charset_wire_init_sender(const char* spec) { + charset_wire_free(); + if (!spec) + return true; + char* local; + char* remote; + if (charset_spec_parse(spec, &local, &remote) != 0) + return false; + void* conv = charset_conversion_open(local, remote); + free(local); + free(remote); + if (!conv) + return false; + g_wire_conv = (CharsetConversion*)conv; + return true; +} + +bool charset_wire_init_receiver(const char* spec, const char* server_spec) { + charset_wire_free(); + if (!spec) + return true; + char* local; + char* remote; + if (charset_spec_parse(spec, &local, &remote) != 0) + return false; + /* The wire charset is the client spec's REMOTE half; the local charset is + * the client spec's LOCAL half unless the server was itself started with + * --iconv naming a different local charset (the server halves above never + * travel, so the server's own flag is the only way its local charset can + * differ from what the client assumed). */ + const char* wire = remote; + const char* target_local = local; + char* server_local = NULL; + char* server_remote = NULL; + if (server_spec) { + if (charset_spec_parse(server_spec, &server_local, &server_remote) != 0) { + free(local); + free(remote); + return false; + } + target_local = server_local; + } + void* conv = charset_conversion_open(wire, target_local); + free(server_local); + free(server_remote); + free(local); + free(remote); + if (!conv) + return false; + g_wire_conv = (CharsetConversion*)conv; + return true; +} + +void charset_wire_free(void) { + if (g_wire_conv) { + charset_conversion_close(g_wire_conv); + g_wire_conv = NULL; + } +} + +bool charset_wire_active(void) { + return g_wire_conv != NULL; +} + +char* charset_wire_apply(const char* path) { + if (!g_wire_conv) + return str_dup(path); + return charset_convert(g_wire_conv, path, NULL); +} + +static void charset_convert_failure_log(const char* path) { + char* escaped = output_escape(path, false); + log_message(LOG_LEVEL_ERROR, "--iconv: cannot convert file name '%s' to the target charset", + escaped ? escaped : ""); + free(escaped); +} + +bool send_wire_str(int file_descriptor, const char* local_path) { + if (!g_wire_conv) + return send_str(file_descriptor, local_path); + char* wire = charset_wire_apply(local_path); + if (!wire) { + charset_convert_failure_log(local_path); + return false; + } + bool ok = send_str(file_descriptor, wire); + free(wire); + return ok; +} + +char* receive_wire_str(int file_descriptor) { + char* raw = receive_str(file_descriptor); + if (!raw) + return NULL; + if (!g_wire_conv) + return raw; + char* local = charset_convert(g_wire_conv, raw, NULL); + if (!local) { + charset_convert_failure_log(raw); + free(raw); + return NULL; + } + free(raw); + return local; +} \ No newline at end of file diff --git a/src/shared/charset.h b/src/shared/charset.h new file mode 100644 index 0000000..545fdb6 --- /dev/null +++ b/src/shared/charset.h @@ -0,0 +1,70 @@ +#ifndef CHARSET_H +#define CHARSET_H + +#include +#include + +/* --iconv=CONVERT_SPEC file-name charset conversion (rsync compatibility). + * + * CONVERT_SPEC is "LOCAL[,REMOTE]": LOCAL is the charset of our own file + * names, REMOTE is the charset of the remote side's file names and defaults + * to LOCAL when the comma half is omitted. The sender converts every local + * path from LOCAL to REMOTE before it goes on the wire; the receiver converts + * every received path back from REMOTE to LOCAL. A NULL/disabled spec means + * identity with zero overhead (the common path never consults iconv). + * + * All helpers are friendly to the strict cold path: the wire conversion state + * is process-global (one direction per process -- a client only sends, a + * server only receives) and is initialized once, before any path is + * serialized, so conversion compiles to a single non-NULL check when disabled. + */ + +/* Parse CONVERT_SPEC into malloc'd LOCAL and REMOTE charset names (caller + * frees both). REMOTE is a separate copy of LOCAL when no comma is present. + * Returns 0 on success, -1 on a malformed spec (empty halves / missing value / + * allocation failure); nothing is allocated on the -1 path. */ +int charset_spec_parse(const char* spec, char** local_out, char** remote_out); + +/* True when a CONVERT_SPEC is well-formed AND every charset name opens in a + * probe iconv_open (so a typo'd name is rejected at startup, not mid-run). + * NULL (iconv disabled) is always valid. */ +bool charset_spec_valid(const char* spec); + +/* Probe a local->remote conversion pair without keeping the descriptor. */ +bool charset_pair_valid(const char* local, const char* remote); + +/* One-shot conversion of a NUL-terminated input to a malloc'd NUL-terminated + * result, or NULL on failure. On failure *err_out (when non-NULL) receives + * the iconv errno (EILSEQ/EINVAL = the input is not representable in the + * target charset). The caller must free the result. */ +char* charset_convert(const void* conversion, const char* in, int* err_out); + +/* Open a conversion descriptor for direction from_charset -> to_charset. + * Returns NULL (errno = EINVAL) when a charset name is unsupported. Freed + * with charset_conversion_close. */ +void* charset_conversion_open(const char* from_charset, const char* to_charset); +void charset_conversion_close(void* conversion); + +/* Process-wide wire conversion. charset_wire_init_sender (client side) opens + * LOCAL->REMOTE; charset_wire_init_receiver (server side) opens + * wire(REMOTE)->server-local. server_spec is the server's own --iconv, whose + * LOCAL half may override the local charset the client assumed; NULL reuses + * the client spec's LOCAL half. Both return false on an unsupported spec. + * The state is freed with charset_wire_free. */ +bool charset_wire_init_sender(const char* spec); +bool charset_wire_init_receiver(const char* spec, const char* server_spec); +void charset_wire_free(void); +bool charset_wire_active(void); + +/* Convert a path across the wire in the process direction. Returns a malloc'd + * string, or NULL when the name cannot be represented in the target charset. */ +char* charset_wire_apply(const char* path); + +/* Convenience wire string I/O: encode+send_str / receive_str+decode. Both + * return false/NULL (logging a clear --iconv error) on conversion failure, so + * an unconvertible path FAILS the transfer cleanly instead of silently sending + * a mangled name. */ +bool send_wire_str(int file_descriptor, const char* local_path); +char* receive_wire_str(int file_descriptor); + +#endif \ No newline at end of file diff --git a/src/shared/chunk.c b/src/shared/chunk.c index 5b0069e..c5baeb4 100644 --- a/src/shared/chunk.c +++ b/src/shared/chunk.c @@ -6,6 +6,7 @@ #include #include "array_list.h" +#include "charset.h" #include "chunk.h" #include "compression.h" #include "data.h" @@ -63,9 +64,22 @@ void chunk_destroy(void* item) { free(chunk); } +/* --iconv: a chunk blob carries wire-charset path/target bytes. Encode the + * sender-side path (a no-op copy when iconv is disabled) so the blob is in the + * same charset as every other wire string. */ +static char* chunk_encode_wire(const char* path) { + if (!charset_wire_active()) + return str_dup(path); + return charset_wire_apply(path); +} + static unsigned long long per_file_serialize_size(File* file, bool use_metadata) { unsigned long long size = sizeof(size_t); - size_t path_len = strlen(file_wire_path(file)); + char* wire_path = chunk_encode_wire(file_wire_path(file)); + if (!wire_path) + return 0; + size_t path_len = strlen(wire_path); + free(wire_path); unsigned long long metadata_size = use_metadata ? sizeof(int) + (file->metadata ? FILE_METADATA_WIRE_SIZE : 0) : 0; if ((unsigned long long)path_len > ULLONG_MAX - size) @@ -94,7 +108,11 @@ static unsigned long long per_file_serialize_size(File* file, bool use_metadata) size += file->data->size; /* Symlink entries append the target string (length-prefixed). */ if (file->is_symlink) { - size_t target_len = file->symlink_target ? strlen(file->symlink_target) : 0; + char* wire_target = chunk_encode_wire(file->symlink_target ? file->symlink_target : ""); + if (!wire_target) + return 0; + size_t target_len = strlen(wire_target); + free(wire_target); if (sizeof(size_t) > ULLONG_MAX - size) return 0; size += sizeof(size_t); @@ -128,12 +146,17 @@ Data* chunk_serialize(Chunk* chunk, bool use_metadata) { char* data_pointer = data->data; for (int i = 0; i < chunk->element_count; i++) { File* file = chunk->items[i]; - const char* wire_path = file_wire_path(file); + char* wire_path = chunk_encode_wire(file_wire_path(file)); + if (wire_path == NULL) { + data_destroy(data); + return NULL; + } size_t path_len = strlen(wire_path); memcpy(data_pointer, &path_len, sizeof(size_t)); data_pointer += sizeof(size_t); memcpy(data_pointer, wire_path, path_len); data_pointer += path_len; + free(wire_path); int entry_type = file->is_dir ? 1 : (file->is_symlink ? 2 : (file->is_special ? 3 : 0)); memcpy(data_pointer, &entry_type, sizeof(int)); @@ -159,12 +182,18 @@ Data* chunk_serialize(Chunk* chunk, bool use_metadata) { data_pointer += file_data_size; if (file->is_symlink) { - size_t target_len = file->symlink_target ? strlen(file->symlink_target) : 0; + char* wire_target = chunk_encode_wire(file->symlink_target ? file->symlink_target : ""); + if (wire_target == NULL) { + data_destroy(data); + return NULL; + } + size_t target_len = strlen(wire_target); memcpy(data_pointer, &target_len, sizeof(size_t)); data_pointer += sizeof(size_t); if (target_len > 0) - memcpy(data_pointer, file->symlink_target, target_len); + memcpy(data_pointer, wire_target, target_len); data_pointer += target_len; + free(wire_target); } } return data; @@ -222,6 +251,22 @@ Chunk* chunk_deserialize(Data* data, bool use_metadata) { data_pointer += path_len; remaining_size -= path_len; + /* --iconv: the blob holds the wire charset; translate it to the receiver's + local charset before validation and creation so the destination gets the + local name. A name that cannot be decoded fails the file cleanly. */ + if (charset_wire_active()) { + char* local_path = charset_wire_apply(path); + free(path); + if (local_path == NULL) { + log_message(LOG_LEVEL_ERROR, + "--iconv: received chunk file name cannot be converted to the local charset"); + array_list_delete(files); + return NULL; + } + path = local_path; + path_len = strlen(path); + } + if (path_len == 0 || has_path_traversal(path)) { free(path); array_list_delete(files); @@ -392,6 +437,21 @@ Chunk* chunk_deserialize(Data* data, bool use_metadata) { array_list_delete(files); return NULL; } + /* The symlink target also rides the wire charset; decode it to the local + charset like the path (a target is a path). */ + if (charset_wire_active()) { + char* local_target = charset_wire_apply(target); + free(target); + if (local_target == NULL) { + log_message(LOG_LEVEL_ERROR, + "--iconv: received chunk symlink target cannot be converted to the local " + "charset"); + file_destroy(file); + array_list_delete(files); + return NULL; + } + target = local_target; + } file->symlink_target = target; data_pointer += target_len; remaining_size -= target_len; diff --git a/src/shared/config.c b/src/shared/config.c index 0b8c2d3..2c0b98f 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -1,4 +1,5 @@ #include "config.h" +#include "charset.h" #include "chmod.h" #include "credentials.h" #include "daemon_conf.h" @@ -42,6 +43,7 @@ static void config_set_defaults(Config* config) { config->auth_user = NULL; config->auth_password_hash = NULL; config->password_file = NULL; + config->iconv_spec = NULL; config->fastsync_server_path = NULL; config->exclude_patterns = NULL; config->exclude_count = 0; @@ -242,7 +244,13 @@ static bool validate_received_config(const Config* config) { config->max_delete >= -1 && config->skip_compress_count >= 0 && config->skip_compress_count <= 10000 && config->max_alloc > 0 && (!config->chmod_spec || !*config->chmod_spec || - chmod_apply(0, config->chmod_spec, &(mode_t){0})); + chmod_apply(0, config->chmod_spec, &(mode_t){0})) && + /* The received --iconv CONVERT_SPEC is untrusted input that drives + the receiver's path decoding: reject a malformed spec or an + unsupported charset name so the run is refused up front instead of + every received file name failing mid-transfer. A NULL spec (iconv + disabled) is always accepted. */ + (!config->iconv_spec || charset_spec_valid(config->iconv_spec)); } Config* config_create(void) { @@ -619,6 +627,7 @@ void config_delete(Config* config) { free(config->auth_user); free(config->auth_password_hash); free(config->password_file); + free(config->iconv_spec); free(config->fastsync_server_path); for (int i = 0; i < config->exclude_count; i++) free(config->exclude_patterns[i]); @@ -1144,6 +1153,29 @@ static bool receive_daemon_auth(int fd, Config* c) { return true; } +/* --iconv CONVERT_SPEC (protocol 2.16.0). Trailing string on the config frame, + * sent after the Wave A/B daemon-auth block and before the ack, so the + * receiver knows the wire charset before the first file name arrives. The full + * spec travels (LOCAL,REMOTE) and each end derives its own LOCAL and the wire + * (REMOTE) charset symmetrically; an unset spec is serialized as "" and + * canonicalized back to NULL on receive. */ +static bool send_iconv_spec(int fd, const Config* c) { + return send_str(fd, c->iconv_spec ? c->iconv_spec : ""); +} + +static bool receive_iconv_spec(int fd, Config* c) { + char* spec = receive_str(fd); + if (!spec) + return false; + if (*spec == '\0') { + free(spec); + c->iconv_spec = NULL; + return true; + } + c->iconv_spec = spec; + return true; +} + bool config_send(int file_descriptor, const Config* config) { protocol_session_set_max_alloc(NULL, config->max_alloc); if (!send_core_fields(file_descriptor, config) || !send_delta_fields(file_descriptor, config) || @@ -1156,7 +1188,8 @@ bool config_send(int file_descriptor, const Config* config) { !send_metadata_times_options(file_descriptor, config) || !send_symlink_trust_options(file_descriptor, config) || !send_phase4_xattr_options(file_descriptor, config) || - !send_daemon_module(file_descriptor, config) || !send_daemon_auth(file_descriptor, config)) + !send_daemon_module(file_descriptor, config) || !send_daemon_auth(file_descriptor, config) || + !send_iconv_spec(file_descriptor, config)) return false; Status status; if (!receive_status(file_descriptor, &status)) @@ -1198,7 +1231,7 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val !receive_symlink_trust_options(file_descriptor, config) || !receive_phase4_xattr_options(file_descriptor, config) || !receive_daemon_module(file_descriptor, config) || - !receive_daemon_auth(file_descriptor, config)) + !receive_daemon_auth(file_descriptor, config) || !receive_iconv_spec(file_descriptor, config)) goto error; if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 && strcmp(config->compress_choice, "none") != 0) { diff --git a/src/shared/config.h b/src/shared/config.h index 2be5acb..e7e01a2 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -110,6 +110,17 @@ typedef struct Config { * populate auth_user/auth_password_hash before connecting). */ char* password_file; char* fastsync_server_path; + /* --iconv=CONVERT_SPEC (protocol 2.16.0, rsync compatibility): convert the + * charset of FILE NAMES at the wire boundary. CONVERT_SPEC is + * "LOCAL[,REMOTE]": LOCAL is the charset of our own file names, REMOTE is + * the remote side's charset and defaults to LOCAL. The sender converts + * every path LOCAL->REMOTE before transmitting it; the receiver converts + * every received path back REMOTE->LOCAL before creating/writing it. The + * FULL SPEC crosses the wire as a trailing config-frame string so each end + * derives its own LOCAL and the wire (REMOTE) charset symmetrically. NULL + * (or "") means no conversion: identity with zero overhead. See charset.c + * and the PROTOCOL_VERSION note below. */ + char* iconv_spec; char** exclude_patterns; int exclude_count; char** include_patterns; @@ -493,8 +504,21 @@ typedef struct Config { * reads that frame right after the ack (client_send.c) -- symmetric * server->client in every build, so the strict same-version handshake keeps the * two peers in lockstep and nothing can desynchronize. The --stdio SSH path - * sends/reads no MOTD at all. */ -#define PROTOCOL_VERSION "2.15.0" + * sends/reads no MOTD at all. + * + * --iconv Wave (P6): 2.15.0 -> 2.16.0. + * + * WHY the bump, grounded in the wire: the --iconv feature adds a serialized + * field to the binary config frame. The client sends the full CONVERT_SPEC + * (Config->iconv_spec) as a new trailing string AFTER the Wave A/B daemon-auth + * block (in config_send/config_receive), so the receiver knows the wire charset + * (the REMOTE half) before the first file name arrives. Any config-frame + * layout change must bump the protocol version: a peer that does not parse the + * new trailing bytes would desynchronize on the frame boundary, and the strict + * same-version handshake (config_receive rejects a mismatched version before + * parsing anything else) is what keeps a 2.16 client and a 2.15 server from + * ever reaching that state. */ +#define PROTOCOL_VERSION "2.16.0" #define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024) /* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */ #define MAX_BASIS_DIRS 64 diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index 24c8233..95ee6aa 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -10,6 +10,7 @@ #include #include "array_list.h" +#include "charset.h" #include "chmod.h" #include "compression.h" #include "config.h" @@ -1555,7 +1556,7 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) { return NULL; } *skipped = false; - char* check_path = receive_str(fd); + char* check_path = receive_wire_str(fd); if (check_path == NULL) { return NULL; } @@ -2082,7 +2083,7 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) { } File* file_receive(const Config* config, int file_descriptor) { - char* path = receive_str(file_descriptor); + char* path = receive_wire_str(file_descriptor); if (path == NULL) return NULL; if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) { @@ -2142,7 +2143,7 @@ File* file_receive(const Config* config, int file_descriptor) { 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); + char* path = receive_wire_str(file_descriptor); if (path == NULL) return NULL; if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) { @@ -2169,7 +2170,7 @@ File* file_receive_directory(int file_descriptor) { member. All paths are validated like every other received path (non-empty, relative, no traversal). */ File* file_receive_hardlink(int file_descriptor) { - char* path = receive_str(file_descriptor); + char* path = receive_wire_str(file_descriptor); if (path == NULL) return NULL; if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) { @@ -2186,7 +2187,7 @@ File* file_receive_hardlink(int file_descriptor) { free(path); return NULL; } - char* target = receive_str(file_descriptor); + char* target = receive_wire_str(file_descriptor); if (!target) { free(path); return NULL; @@ -2219,7 +2220,7 @@ File* file_receive_hardlink(int file_descriptor) { routed through the regular store_file sink, which creates the link beneath the receive root (unmungeing the target first). */ File* file_receive_symlink(int file_descriptor, const Config* config) { - char* path = receive_str(file_descriptor); + char* path = receive_wire_str(file_descriptor); if (path == NULL) return NULL; if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) { @@ -2231,7 +2232,7 @@ File* file_receive_symlink(int file_descriptor, const Config* config) { send_status(file_descriptor, STATUS_ERROR); return NULL; } - char* target = receive_str(file_descriptor); + char* target = receive_wire_str(file_descriptor); if (!target) { free(path); return NULL; @@ -2274,7 +2275,7 @@ File* file_receive_symlink(int file_descriptor, const Config* config) { * confined). rdev is validated here (non-negative, range-checked) so a bogus * value cannot drive a dangerous node on the receiver. */ File* file_receive_special(int file_descriptor) { - char* path = receive_str(file_descriptor); + char* path = receive_wire_str(file_descriptor); if (path == NULL) return NULL; if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) { @@ -2354,7 +2355,7 @@ static bool receive_manifest_section(int fd, ArrayList* list, size_t* manifest_b return false; } for (int i = 0; i < count; i++) { - char* s = receive_str(fd); + char* s = receive_wire_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 || diff --git a/src/shared/file_send.c b/src/shared/file_send.c index 8da018a..e7bcffb 100644 --- a/src/shared/file_send.c +++ b/src/shared/file_send.c @@ -10,6 +10,7 @@ #include #include +#include "charset.h" #include "compression.h" #include "data.h" #include "file.h" @@ -27,7 +28,7 @@ bool file_send_special(const File* file, int file_descriptor, bool use_metadata) return false; if (!send_status(file_descriptor, STATUS_SPECIAL)) return false; - if (!send_str(file_descriptor, file_wire_path(file))) + if (!send_wire_str(file_descriptor, file_wire_path(file))) return false; if (use_metadata && !metadata_send(file_descriptor, file->metadata)) return false; @@ -61,7 +62,7 @@ bool file_send_single_calls_with_skip(File* file, int file_descriptor, bool use_ } data_to_send = compressed_data; } - if (send_path && !send_str(file_descriptor, file_wire_path(file))) { + if (send_path && !send_wire_str(file_descriptor, file_wire_path(file))) { data_destroy(compressed_data); return false; } @@ -97,7 +98,7 @@ bool file_send_sendfile_with_skip(File* file, int file_descriptor, bool use_meta send_path, skip_suffixes, skip_count, compression_threads, send_xattrs); - if (send_path && !send_str(file_descriptor, file_wire_path(file))) + if (send_path && !send_wire_str(file_descriptor, file_wire_path(file))) return false; if (use_metadata && !metadata_send(file_descriptor, file->metadata)) return false;