diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md
index 44f4baa..e2ab969 100644
--- a/RSYNC_COMPAT.md
+++ b/RSYNC_COMPAT.md
@@ -629,7 +629,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
| `--config=FILE` | Alternate rsyncd.conf file | ✅ Implemented | Wave A: selects the daemon config file. Default when omitted (in `--daemon` mode): `~/.config/fastsync/fastsyncd.conf` if it exists, else `/etc/fastsyncd.conf`. The grammar is FastSync-native (documented in the Daemon Mode notes below) and strictly rejects unknown keys so a typo can never silently change what a module serves; requires `--daemon` |
| `--dparam=OVERRIDE` | Override global daemon config | ✅ Implemented | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Limited to the global scalar keys the grammar defines (`port`, `motd file`, `address`); keys are case-insensitive and unknown keys/invalid values are rejected. Requires `--daemon` |
| `--no-detach` | Don't detach from parent | ✅ Implemented | Wave A: with `--daemon`, keeps the listener in the foreground (what integration tests use). Without it the daemonizes (fork/setsid, stdio redirected to /dev/null) after the listening socket is bound. Requires `--daemon` |
-| `--password-file=FILE` | Read daemon password from file | ✅ Implemented | Wave B daemon auth. Client: `--password-file` supplies `user:password` for a `host::module/path` destination (the username is taken from this file, so `user@host::module` stays rejected). Server (`fastsync-server --daemon --password-file FILE`): the credential store that modules with `auth users` are verified against. Only a SHA-256 digest of the password ever crosses the wire or is stored server-side; the literal password never appears in logs. The file must be private to its owner: both the client and server refuse to load a `--password-file`/`--early-input` that grants any group/other permission bit (mode 0600), mirroring the TLS private-key check. See the Daemon Mode notes below for the file formats and the plaintext/TLS caveat |
+| `--password-file=FILE` | Read daemon password from file | ✅ Implemented | Wave B daemon auth. Client: `--password-file` supplies `user:password` for a `host::module/path` destination (the username is taken from this file, so `user@host::module` stays rejected). Server (`fastsync-server --daemon --password-file FILE`): the credential store that modules with `auth users` are verified against. Only a SHA-256 digest of the password ever crosses the wire or is stored server-side; the literal password never appears in logs. The file must be private to its owner: both the client and server verify the exact inode they read (open-then-`fstat`, so the check cannot be raced) and refuse a `--password-file`/`--early-input` that is not owned by the current user or grants any group/other permission bit (mode 0600), mirroring the TLS private-key check. A process-substitution pipe (`--early-input <(vault ...)`) is still accepted when it satisfies those checks. See the Daemon Mode notes below for the file formats and the plaintext/TLS caveat |
| `--early-input=FILE` | Use FILE for daemon early exec | ✅ Implemented | Server-only (requires `--daemon`): a second credential-store file, same `user:SHA256HEX` grammar as `--password-file`, read before the listener accepts connections (a secrets-manager / process-substitution source). Its entries layer over `--password-file`: identical entries dedupe, a conflicting secret for the same user is a startup error. A daemon whose modules declare `auth users` must be given at least one of the two, or it refuses to start (fail closed) |
**Daemon Mode notes (Wave A, protocol 2.15.0; Wave B auth, Wave C MOTD, no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding.
diff --git a/src/client/usage.c b/src/client/usage.c
index 0da87ba..f63a5c3 100644
--- a/src/client/usage.c
+++ b/src/client/usage.c
@@ -246,8 +246,8 @@ void print_usage(void) {
printf(" -T, --temp-dir
Scratch dir for temp files before atomic install\n");
printf(" --fastsync-server-path \n");
printf(" Path to fastsync-server on remote (default: fastsync-server)\n");
- printf(
- " --old-args Disable safe SSH command argument quoting (legacy compatibility)\n");
+ printf(" --old-args Accepted for rsync CLI compatibility; no effect (the\n");
+ printf(" remote server path is always safely quoted now)\n");
printf(" -M, --remote-option=OPT Append OPT to the REMOTE server invocation over SSH\n");
printf(" (repeatable; each value is single-quote-escaped on the remote\n");
printf(" command line; empty values and values with control characters\n");
diff --git a/src/server/server.c b/src/server/server.c
index 9c0058b..80d367b 100644
--- a/src/server/server.c
+++ b/src/server/server.c
@@ -257,10 +257,11 @@ static const char* server_module_gate(const Config* config, void* context) {
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 effective copy (so an operator --no-super has already
- neutralized an explicit --super), exactly as before. */
- if (identity_ownership_requested(&effective)) {
+ /* 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",
diff --git a/src/shared/credentials.c b/src/shared/credentials.c
index 79d600b..fa1088f 100644
--- a/src/shared/credentials.c
+++ b/src/shared/credentials.c
@@ -2,6 +2,7 @@
#include "utils.h"
#include
#include
+#include
#include
#include
#include
@@ -9,6 +10,7 @@
#include
#include
#include
+#include
/* One store entry: a username and its password's SHA-256 hex digest. The
* plaintext password never appears here (and never on the daemon host). */
@@ -36,21 +38,44 @@ static bool is_comment_char(char c) {
return c == '#' || c == ';';
}
-/* A --password-file / --early-input carries plaintext or credential material
- * and must not be accessible to group or other, mirroring the TLS private-key
- * check in transport_tls.c. Reject any group/other permission bit (including
- * execute) with a clear error. A stat failure is left for the caller's fopen
- * to report, so a missing file keeps its existing "cannot open" message. */
-static bool secret_file_is_private(const char* path, char* err, size_t err_size) {
- struct stat st;
- if (stat(path, &st) != 0)
- return true;
- if (!S_ISREG(st.st_mode) || (st.st_mode & (S_IRWXG | S_IRWXO)) != 0) {
- set_error(err, err_size,
- "refusing to read secret file '%s': permissions must be owner-only (0600)", path);
- return false;
+/* Open a --password-file / --early-input after verifying the EXACT inode we
+ * will read: it must be owned by the effective user and grant no group/other
+ * permission bit (mode 0600), mirroring the TLS private-key check. We open by
+ * path and then fstat the resulting fd (rather than stat()ing the path first
+ * and reopening it), so the permission decision is made on the same inode that
+ * is read and cannot be raced by swapping the path between check and open.
+ * The path may be a process-substitution pipe (`<(...)` -> /dev/fd/N), so
+ * regular files and FIFOs are accepted when the ownership/mode checks pass.
+ *
+ * Returns a FILE* the caller must fclose, or NULL with `err` filled. */
+static FILE* secret_file_open(const char* path, char* err, size_t err_size) {
+ int fd = open(path, O_RDONLY | O_CLOEXEC);
+ if (fd < 0) {
+ set_error(err, err_size, "cannot open secret file '%s': %s", path, strerror(errno));
+ return NULL;
}
- return true;
+ struct stat st;
+ if (fstat(fd, &st) != 0) {
+ set_error(err, err_size, "cannot stat secret file '%s': %s", path, strerror(errno));
+ close(fd);
+ return NULL;
+ }
+ bool is_readable_kind = S_ISREG(st.st_mode) || S_ISFIFO(st.st_mode);
+ if (!is_readable_kind || st.st_uid != geteuid() || (st.st_mode & (S_IRWXG | S_IRWXO)) != 0) {
+ set_error(err, err_size,
+ "refusing to read secret file '%s': it must be owned by the current user and "
+ "owner-only (0600), not accessible to group/other",
+ path);
+ close(fd);
+ return NULL;
+ }
+ FILE* fp = fdopen(fd, "r");
+ if (!fp) {
+ set_error(err, err_size, "cannot read secret file '%s': %s", path, strerror(errno));
+ close(fd);
+ return NULL;
+ }
+ return fp;
}
/* Trim leading/trailing ASCII space and tab in place; returns the new start. */
@@ -142,14 +167,8 @@ static CredentialStore* load_store_file(const char* path, char* err, size_t err_
if (!path)
return store;
- if (!secret_file_is_private(path, err, err_size)) {
- credentials_free(store);
- return NULL;
- }
-
- FILE* fp = fopen(path, "r");
+ FILE* fp = secret_file_open(path, err, err_size);
if (!fp) {
- set_error(err, err_size, "cannot open credential file '%s': %s", path, strerror(errno));
credentials_free(store);
return NULL;
}
@@ -328,13 +347,9 @@ int credentials_read_secret_file(const char* path, char** user_out, char** passw
set_error(err, err_size, "no --password-file path");
return -1;
}
- if (!secret_file_is_private(path, err, err_size))
+ FILE* fp = secret_file_open(path, err, err_size);
+ if (!fp)
return -1;
- FILE* fp = fopen(path, "r");
- if (!fp) {
- set_error(err, err_size, "cannot open password file '%s': %s", path, strerror(errno));
- return -1;
- }
int line_no = 0;
char line[CREDENTIAL_MAX_LINE + 2];
diff --git a/src/shared/file.c b/src/shared/file.c
index 70efd9d..6220967 100644
--- a/src/shared/file.c
+++ b/src/shared/file.c
@@ -930,9 +930,12 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
int prealloc_rc = 0;
if (preallocate && !sparse && data_size > 0) {
prealloc_rc = preallocate_fd(fd, data_size);
- if (prealloc_rc != 0)
- log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted", path,
- strerror(prealloc_rc));
+ if (prealloc_rc != 0) {
+ char* escaped_path = output_escape(path, log_get_8_bit_output());
+ log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted",
+ escaped_path ? escaped_path : "", strerror(prealloc_rc));
+ free(escaped_path);
+ }
}
if (prealloc_rc == 0) {
/* posix_fallocate does not guarantee the fd's file offset is left
@@ -1037,9 +1040,12 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
int prealloc_rc = 0;
if (preallocate && !sparse && data_size > 0) {
prealloc_rc = preallocate_fd(fd, data_size);
- if (prealloc_rc != 0)
- log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted", path,
- strerror(prealloc_rc));
+ if (prealloc_rc != 0) {
+ char* escaped_path = output_escape(path, log_get_8_bit_output());
+ log_message(LOG_LEVEL_ERROR, "preallocate failed for '%s' (%s); transfer aborted",
+ escaped_path ? escaped_path : "", strerror(prealloc_rc));
+ free(escaped_path);
+ }
}
if (prealloc_rc == 0) {
lseek(fd, 0, SEEK_SET);
diff --git a/src/shared/protocol.c b/src/shared/protocol.c
index a840ca3..ee70d87 100644
--- a/src/shared/protocol.c
+++ b/src/shared/protocol.c
@@ -430,10 +430,14 @@ static bool protocol_send_str_impl(ProtocolSession* session, const char* data, b
return false;
if (!protocol_send_n_data(session, data, size))
return false;
- if (redact)
+ if (redact) {
log_debug_message(LOG_DEBUG_PROTO, "Send String: ");
- else
- log_debug_message(LOG_DEBUG_PROTO, "Send String: %s", data);
+ } else {
+ char* escaped_data = output_escape(data, log_get_8_bit_output());
+ log_debug_message(LOG_DEBUG_PROTO, "Send String: %s",
+ escaped_data ? escaped_data : "");
+ free(escaped_data);
+ }
return true;
}