Compare commits
28
Commits
v2.19.0
...
1acb66628d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1acb66628d | ||
|
|
ba1c7a369f | ||
|
|
d28489d83c | ||
|
|
312ed05170 | ||
|
|
fecbe2c90c | ||
|
|
c8f5d80fcb | ||
|
|
8147ff7b50 | ||
|
|
b7fbb56289 | ||
|
|
08063b6d73 | ||
|
|
ea2f76cd7a | ||
|
|
76eeba1773 | ||
|
|
b72ab298ab | ||
|
|
446a714ef8 | ||
|
|
a90e234eb3 | ||
|
|
f8252cf3e7 | ||
|
|
4557924972 | ||
|
|
59ce174d22 | ||
|
|
2a8941ee5c | ||
|
|
37037a6ee7 | ||
|
|
061e9ad43f | ||
|
|
f928879755 | ||
|
|
84b7cb0de3 | ||
|
|
921472b8b3 | ||
|
|
082ac2645d | ||
|
|
eefbd1e849 | ||
|
|
1fd462cca8 | ||
|
|
4ac37c4d8a | ||
|
|
08af945bd6 |
No files matched your search
@@ -12,7 +12,7 @@ jobs:
|
||||
container: gitea.tap-tap.win/taptap/fastsync-ci:v10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: clang-format check
|
||||
run: find src/ tests/ -name '*.c' -o -name '*.h' | xargs clang-format --dry-run --Werror
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
needs: lint
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Configure
|
||||
run: cmake -B build -S . -DSTRICT_WARNINGS=ON
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
sanitizer: [address, undefined]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Configure
|
||||
run: cmake -B build-${{ matrix.sanitizer }} -S . -DSANITIZER=${{ matrix.sanitizer }}
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Configure (clang + fuzz)
|
||||
run: CC=clang CXX=clang++ cmake -B build-fuzz -S . -DENABLE_FUZZ=ON
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Configure
|
||||
run: cmake -B build -S . -DENABLE_COVERAGE=ON
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Configure
|
||||
run: cmake -B build -S . -DSTRICT_WARNINGS=ON
|
||||
|
||||
+41
-1
@@ -38,11 +38,24 @@ if(ENABLE_COVERAGE)
|
||||
add_link_options(--coverage)
|
||||
endif()
|
||||
|
||||
# --- Build hardening option ---
|
||||
# Production hardening is applied to the shipping server/client binaries only,
|
||||
# and only when no sanitizer or coverage instrumentation is active: sanitizers
|
||||
# carry their own instrumentation, and _FORTIFY_SOURCE requires an optimising
|
||||
# build (never the -O0 used for coverage).
|
||||
option(ENABLE_HARDENING "Enable compiler/linker hardening for production targets" ON)
|
||||
set(HARDENING_ACTIVE OFF)
|
||||
if(ENABLE_HARDENING AND SANITIZER STREQUAL "none" AND NOT ENABLE_COVERAGE)
|
||||
set(HARDENING_ACTIVE ON)
|
||||
endif()
|
||||
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
xxhash
|
||||
GIT_REPOSITORY https://github.com/Cyan4973/xxHash
|
||||
GIT_TAG v0.8.3
|
||||
# v0.8.3 is a lightweight tag pointing at this exact commit (no ^{} peel
|
||||
# entry); pin the commit SHA instead of the mutable tag.
|
||||
GIT_TAG e626a72bc2321cd320e953a0ccf1584cad60f363 # v0.8.3
|
||||
SOURCE_SUBDIR cmake_unofficial
|
||||
)
|
||||
FetchContent_MakeAvailable(xxhash)
|
||||
@@ -73,6 +86,33 @@ add_executable(client ${CLIENT_SRCS} ${SHARED_SRCS} ${FILE_STORE_SRCS} ${SERVER_
|
||||
target_include_directories(client PRIVATE src/shared src/server src/client)
|
||||
target_link_libraries(client PRIVATE Threads::Threads ${ZSTD_LIBRARY} OpenSSL::SSL OpenSSL::Crypto xxhash)
|
||||
|
||||
# --- Production hardening ---
|
||||
# Each compile flag is probed so a compiler/architecture that lacks it still
|
||||
# configures cleanly. _FORTIFY_SOURCE is guarded separately because it only
|
||||
# works in an optimising build. xxHash is a static archive built by
|
||||
# FetchContent, so it must be position-independent for the -pie link.
|
||||
if(HARDENING_ACTIVE)
|
||||
set_target_properties(xxhash PROPERTIES POSITION_INDEPENDENT_CODE ON)
|
||||
include(CheckCCompilerFlag)
|
||||
foreach(flag -fstack-protector-strong -fstack-clash-protection -fPIE)
|
||||
string(MAKE_C_IDENTIFIER "HARDEN_${flag}" _harden_var)
|
||||
check_c_compiler_flag("${flag}" ${_harden_var})
|
||||
endforeach()
|
||||
check_c_compiler_flag("-D_FORTIFY_SOURCE=2" HARDEN_FORTIFY_SOURCE)
|
||||
foreach(target server client)
|
||||
foreach(flag -fstack-protector-strong -fstack-clash-protection -fPIE)
|
||||
string(MAKE_C_IDENTIFIER "HARDEN_${flag}" _harden_var)
|
||||
if(${_harden_var})
|
||||
target_compile_options(${target} PRIVATE ${flag})
|
||||
endif()
|
||||
endforeach()
|
||||
if(HARDEN_FORTIFY_SOURCE)
|
||||
target_compile_options(${target} PRIVATE -D_FORTIFY_SOURCE=2)
|
||||
endif()
|
||||
target_link_options(${target} PRIVATE -pie -Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack)
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
# --- Testing ---
|
||||
enable_testing()
|
||||
|
||||
|
||||
+740
-375
File diff suppressed because it is too large.
Load diff
@@ -338,9 +338,10 @@ static bool basis_oversize_preflight(const Config* config) {
|
||||
return false;
|
||||
DirectoryScanner* scanner =
|
||||
directory_scanner_create_with_options(config->send_directory, &prepared.options);
|
||||
if (!scanner) {
|
||||
prepared_scanner_destroy(&prepared);
|
||||
if (!scanner)
|
||||
return false;
|
||||
}
|
||||
bool ok = true;
|
||||
Chunk* chunk;
|
||||
while ((chunk = directory_scanner_next(scanner)) != NULL) {
|
||||
@@ -364,7 +365,10 @@ static bool basis_oversize_preflight(const Config* config) {
|
||||
}
|
||||
if (directory_scanner_failed(scanner) || directory_scanner_had_io_error(scanner))
|
||||
ok = false;
|
||||
/* The scanner borrows prepared.options' base_filters/hardlinks pointers, so
|
||||
prepared must outlive the scanner. */
|
||||
directory_scanner_destroy(scanner);
|
||||
prepared_scanner_destroy(&prepared);
|
||||
return ok;
|
||||
}
|
||||
|
||||
@@ -1886,6 +1890,8 @@ int send_files(Config* config) {
|
||||
if (config->transport == TRANSPORT_TCP)
|
||||
log_message(LOG_LEVEL_ERROR, "could not connect to server%s",
|
||||
config->use_tls ? " via TLS" : "");
|
||||
if (missing_args)
|
||||
array_list_delete(missing_args);
|
||||
return 1;
|
||||
}
|
||||
ProtocolSession session;
|
||||
|
||||
@@ -587,12 +587,16 @@ void directory_scanner_destroy(DirectoryScanner* scanner) {
|
||||
|
||||
static Chunk* chunk_data_to_chunk(ArrayList* chunk_data) {
|
||||
void** chunk_items = array_list_to_array(chunk_data);
|
||||
if (!chunk_items)
|
||||
if (!chunk_items) {
|
||||
array_list_delete(chunk_data);
|
||||
return NULL;
|
||||
}
|
||||
Chunk* chunk = chunk_create((File**)chunk_items, chunk_data->size);
|
||||
free(chunk_items);
|
||||
if (!chunk)
|
||||
if (!chunk) {
|
||||
array_list_delete(chunk_data);
|
||||
return NULL;
|
||||
}
|
||||
chunk_data->item_destroyer = NULL;
|
||||
array_list_delete(chunk_data);
|
||||
return chunk;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+233
-189
@@ -23,6 +23,7 @@
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <openssl/x509.h>
|
||||
|
||||
@@ -251,6 +252,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 +474,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) {
|
||||
@@ -445,12 +503,16 @@ void handler(int file_descriptor) {
|
||||
gate_ctx.ssl = ssl;
|
||||
gate_ctx.fd = file_descriptor;
|
||||
gate_ctx.super_mode_override = -1;
|
||||
Config* config = config_receive_with_validate(file_descriptor, server_module_gate, &gate_ctx);
|
||||
/* All teardown state starts empty so the single `done` epilogue is safe to
|
||||
* reach from any error path (including before the config frame arrives). */
|
||||
Config* config = NULL;
|
||||
PipelineContextReceiver* context = NULL;
|
||||
char* joined_destination = NULL;
|
||||
bool charset_ready = false;
|
||||
config = config_receive_with_validate(file_descriptor, server_module_gate, &gate_ctx);
|
||||
if (config == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to receive config");
|
||||
close(file_descriptor);
|
||||
protocol_session_unbind();
|
||||
return;
|
||||
goto done;
|
||||
}
|
||||
/* Apply the super-mode veto the gate decided on (operator --no-super, or a
|
||||
* daemon module without the `client owner = yes` opt-in) exactly once, so
|
||||
@@ -458,27 +520,19 @@ 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");
|
||||
config_delete(config);
|
||||
close(file_descriptor);
|
||||
protocol_session_unbind();
|
||||
return;
|
||||
goto done;
|
||||
}
|
||||
if (!allow_unauthenticated && ssl == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "Rejected unauthenticated plaintext connection");
|
||||
config_delete(config);
|
||||
close(file_descriptor);
|
||||
protocol_session_unbind();
|
||||
return;
|
||||
goto done;
|
||||
}
|
||||
if (ssl && required_client_cn && !tls_client_identity_allowed(ssl)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Rejected TLS client with unauthorized identity");
|
||||
config_delete(config);
|
||||
close(file_descriptor);
|
||||
return;
|
||||
goto done;
|
||||
}
|
||||
/* Daemon mode: the module's root is the authorized root (installed by
|
||||
server_module_gate), and the client's destination is a MODULE-RELATIVE
|
||||
@@ -488,13 +542,9 @@ void handler(int file_descriptor) {
|
||||
if (g_daemon_conf && config->receive_root_directory && config->receive_root_directory[0] == '/') {
|
||||
log_message(LOG_LEVEL_ERROR, "Rejected absolute daemon destination (must be relative to the "
|
||||
"selected module root)");
|
||||
config_delete(config);
|
||||
close(file_descriptor);
|
||||
protocol_session_unbind();
|
||||
return;
|
||||
goto done;
|
||||
}
|
||||
char* destination = config->receive_root_directory;
|
||||
char* joined_destination = NULL;
|
||||
if (destination && destination[0] != '/')
|
||||
joined_destination = path_cat(authorized_root, destination);
|
||||
if (joined_destination)
|
||||
@@ -503,19 +553,16 @@ void handler(int file_descriptor) {
|
||||
!path_is_within(authorized_root, destination)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Rejected destination outside authorized root");
|
||||
free(joined_destination);
|
||||
config_delete(config);
|
||||
close(file_descriptor);
|
||||
return;
|
||||
joined_destination = NULL;
|
||||
goto done;
|
||||
}
|
||||
if (joined_destination) {
|
||||
free(config->receive_root_directory);
|
||||
config->receive_root_directory = joined_destination;
|
||||
joined_destination = NULL;
|
||||
}
|
||||
if (!config->receive_root_directory) {
|
||||
config_delete(config);
|
||||
close(file_descriptor);
|
||||
protocol_session_unbind();
|
||||
return;
|
||||
goto done;
|
||||
}
|
||||
config->use_delete = config->use_delete && allow_delete;
|
||||
/* --iconv (protocol 2.16.0): install the receiver-side wire->local conversion
|
||||
@@ -524,13 +571,13 @@ void handler(int file_descriptor) {
|
||||
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)) {
|
||||
if (config->iconv_spec) {
|
||||
if (!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;
|
||||
goto done;
|
||||
}
|
||||
charset_ready = true;
|
||||
}
|
||||
/* --delete-missing-args deletes destination mirrors receiver-side, so it is
|
||||
deletion and stays gated by the same --allow-delete server policy. When
|
||||
@@ -545,10 +592,7 @@ void handler(int file_descriptor) {
|
||||
log_message(LOG_LEVEL_ERROR, "destination root is not available: %s",
|
||||
escaped_root ? escaped_root : "<allocation failed>");
|
||||
free(escaped_root);
|
||||
config_delete(config);
|
||||
close(file_descriptor);
|
||||
protocol_session_unbind();
|
||||
return;
|
||||
goto done;
|
||||
}
|
||||
/* A --delay-updates transfer stages under a private 0700 directory inside
|
||||
the receive root. Create it up front (wiping leftovers of any previously
|
||||
@@ -557,11 +601,7 @@ void handler(int file_descriptor) {
|
||||
config->delay_context = delay_updates_context_create(config->receive_root_directory);
|
||||
if (!config->delay_context || !delay_updates_prepare(config->delay_context)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to initialize --delay-updates staging area");
|
||||
delay_updates_cleanup(config->delay_context);
|
||||
config_delete(config);
|
||||
close(file_descriptor);
|
||||
protocol_session_unbind();
|
||||
return;
|
||||
goto done;
|
||||
}
|
||||
}
|
||||
/* Preserve the negotiated identity policy for the fd-relative ownership
|
||||
@@ -571,10 +611,7 @@ void handler(int file_descriptor) {
|
||||
rather than silently applying the wrong ownership policy. */
|
||||
if (!identity_set_active(config)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to activate identity policy");
|
||||
config_delete(config);
|
||||
close(file_descriptor);
|
||||
protocol_session_unbind();
|
||||
return;
|
||||
goto done;
|
||||
}
|
||||
/* Persist the negotiated --keep-dirlinks policy once, here at config-accept,
|
||||
before any multithreaded receiver/writer threads are spawned, so the
|
||||
@@ -605,38 +642,25 @@ void handler(int file_descriptor) {
|
||||
if (!motd_send(file_descriptor, motd ? motd : "")) {
|
||||
free(motd);
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to send daemon MOTD");
|
||||
config_delete(config);
|
||||
close(file_descriptor);
|
||||
protocol_session_unbind();
|
||||
identity_clear_active();
|
||||
return;
|
||||
goto done;
|
||||
}
|
||||
free(motd);
|
||||
}
|
||||
if (config->use_multithreading) {
|
||||
Queue* q = queue_create(100, file_destroy);
|
||||
if (q == NULL) {
|
||||
config_delete(config);
|
||||
close(file_descriptor);
|
||||
protocol_session_unbind();
|
||||
identity_clear_active();
|
||||
return;
|
||||
}
|
||||
PipelineContextReceiver* context =
|
||||
pipeline_context_receiver_create(config, q, file_descriptor, ssl);
|
||||
if (q == NULL)
|
||||
goto done;
|
||||
context = pipeline_context_receiver_create(config, q, file_descriptor, ssl);
|
||||
if (context == NULL) {
|
||||
queue_destroy(q);
|
||||
config_delete(config);
|
||||
close(file_descriptor);
|
||||
protocol_session_unbind();
|
||||
identity_clear_active();
|
||||
return;
|
||||
goto done;
|
||||
}
|
||||
protocol_session_set_max_alloc(&context->session, config->max_alloc);
|
||||
atomic_store(&context->session.total_allocated_bytes,
|
||||
atomic_load(&session.total_allocated_bytes));
|
||||
pipeline_context_receiver_set_queue_byte_limit(context, RECEIVER_QUEUE_MAX_BYTES);
|
||||
thrd_t receiver, writer;
|
||||
thrd_t receiver = {0};
|
||||
thrd_t writer = {0};
|
||||
bool receiver_created = thrd_create(&receiver, receive_thread, context) == thrd_success;
|
||||
bool writer_created = false;
|
||||
if (receiver_created)
|
||||
@@ -649,17 +673,19 @@ void handler(int file_descriptor) {
|
||||
cnd_broadcast(&context->condition_not_full);
|
||||
cnd_broadcast(&context->condition_not_empty);
|
||||
mtx_unlock(&context->mutex);
|
||||
close(file_descriptor);
|
||||
/* Unblock a worker parked in socket I/O without closing the fd (the
|
||||
* child owns the single close). shutdown() only affects sockets; for
|
||||
* the --stdio pipe the receiver's per-message poll timeout still
|
||||
* bounds the join, so do nothing there rather than close a descriptor
|
||||
* another thread may still be using. */
|
||||
struct stat fd_stat;
|
||||
if (fstat(file_descriptor, &fd_stat) == 0 && S_ISSOCK(fd_stat.st_mode))
|
||||
shutdown(file_descriptor, SHUT_RDWR);
|
||||
thrd_join(receiver, NULL);
|
||||
} else {
|
||||
close(file_descriptor);
|
||||
}
|
||||
if (writer_created)
|
||||
thrd_join(writer, NULL);
|
||||
pipeline_context_receiver_destroy(context);
|
||||
protocol_session_unbind();
|
||||
identity_clear_active();
|
||||
return;
|
||||
goto done;
|
||||
}
|
||||
int receiver_result;
|
||||
int writer_result;
|
||||
@@ -703,21 +729,36 @@ void handler(int file_descriptor) {
|
||||
} else {
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
}
|
||||
if (!transfer_ok) {
|
||||
if (!transfer_ok)
|
||||
log_message(LOG_LEVEL_ERROR, "Transfer failed");
|
||||
if (config->delay_updates && config->delay_context)
|
||||
delay_updates_cleanup(config->delay_context);
|
||||
}
|
||||
pipeline_context_receiver_destroy(context);
|
||||
} else {
|
||||
if (receiver_receive_files(config, file_descriptor) != 0)
|
||||
log_message(LOG_LEVEL_ERROR, "Transfer failed");
|
||||
config_delete(config);
|
||||
}
|
||||
protocol_session_unbind();
|
||||
identity_clear_active();
|
||||
|
||||
done:
|
||||
/* Single cleanup epilogue: every error path jumps here, so the iconv
|
||||
* receiver conversion is released, the identity snapshot cleared, the
|
||||
* protocol session unbound and the config freed exactly once. The
|
||||
* connection fd is deliberately NOT closed here -- the child functions own
|
||||
* its single close (plain_child_fn / tls_child_fn), and the --stdio call
|
||||
* site must leave stdin/stdout open. */
|
||||
if (charset_ready)
|
||||
charset_wire_free();
|
||||
close(file_descriptor);
|
||||
/* The delay-updates staging tree is released by config_delete (which the
|
||||
branch below always reaches), so it is cleaned exactly once. */
|
||||
identity_clear_active();
|
||||
protocol_session_unbind();
|
||||
if (context != NULL) {
|
||||
/* context owns both the config and the queue it was created with. */
|
||||
pipeline_context_receiver_destroy(context);
|
||||
context = NULL;
|
||||
config = NULL;
|
||||
} else {
|
||||
config_delete(config);
|
||||
config = NULL;
|
||||
}
|
||||
free(joined_destination);
|
||||
}
|
||||
|
||||
#ifndef FASTSYNC_SERVER_AS_LIB
|
||||
@@ -912,6 +953,9 @@ int main(int argc, char* argv[]) {
|
||||
return 1;
|
||||
}
|
||||
io_set_fds(STDIN_FILENO, STDOUT_FILENO);
|
||||
/* handler() does not own the stdio fds: it never closes its descriptor
|
||||
* argument, so STDIN/STDOUT stay open for this (single-shot) SSH session
|
||||
* and are released by process exit. */
|
||||
handler(STDIN_FILENO);
|
||||
release_authorization();
|
||||
server_cli_options_free(&opts);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "log.h"
|
||||
#include "array_list.h"
|
||||
#include "protocol.h"
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -39,6 +40,8 @@ void array_list_delete(ArrayList* array_list) {
|
||||
static bool array_list_extend(ArrayList* array_list) {
|
||||
if (array_list == NULL)
|
||||
return false;
|
||||
if (array_list->capacity > INT_MAX / 2)
|
||||
return false;
|
||||
int new_capacity = array_list->capacity * 2;
|
||||
if (new_capacity == 0)
|
||||
new_capacity = INITIAL_ARRAY_SIZE;
|
||||
|
||||
+85
-36
@@ -202,6 +202,51 @@ static bool receive_wire_bool(int fd, bool* value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Cumulative budget for the strings retained by one received Config (see
|
||||
* MAX_CONFIG_STRING_BYTES). Config strings are received once per connection
|
||||
* before authentication and live for its whole lifetime, so the charge is never
|
||||
* released. */
|
||||
typedef struct {
|
||||
unsigned long long used;
|
||||
} ConfigStringBudget;
|
||||
|
||||
/* Charge `bytes` (the retained allocation: string body plus NUL) against the
|
||||
* aggregate config-string budget. Returns false when the ceiling would be
|
||||
* exceeded, letting the caller reject the frame with a clear error instead of
|
||||
* retaining unbounded pre-auth memory. */
|
||||
static bool config_string_budget_charge(ConfigStringBudget* budget, size_t bytes) {
|
||||
if ((unsigned long long)bytes > MAX_CONFIG_STRING_BYTES ||
|
||||
budget->used > MAX_CONFIG_STRING_BYTES - (unsigned long long)bytes) {
|
||||
log_message(LOG_LEVEL_ERROR, "Config string budget exceeded (%llu + %zu > %llu bytes)",
|
||||
budget->used, bytes, (unsigned long long)MAX_CONFIG_STRING_BYTES);
|
||||
return false;
|
||||
}
|
||||
budget->used += (unsigned long long)bytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
static char* config_receive_str(int fd, ConfigStringBudget* budget) {
|
||||
char* value = receive_str(fd);
|
||||
if (!value)
|
||||
return NULL;
|
||||
if (!config_string_budget_charge(budget, strlen(value) + 1)) {
|
||||
free(value);
|
||||
return NULL;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static char* config_receive_str_redacted(int fd, ConfigStringBudget* budget) {
|
||||
char* value = receive_str_redacted(fd);
|
||||
if (!value)
|
||||
return NULL;
|
||||
if (!config_string_budget_charge(budget, strlen(value) + 1)) {
|
||||
free(value);
|
||||
return NULL;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static bool validate_received_config(const Config* config) {
|
||||
return valid_wire_bool(config->save_to_disk) && valid_wire_bool(config->use_multithreading) &&
|
||||
valid_wire_bool(config->use_chunk_serialization) &&
|
||||
@@ -257,7 +302,7 @@ static bool validate_received_config(const Config* config) {
|
||||
config->delta_block_size <= DELTA_BLOCK_SIZE_MAX &&
|
||||
config->delta_max_file_size <= DELTA_MAX_FILE_SIZE && config->modify_window >= 0 &&
|
||||
config->max_delete >= -1 && config->skip_compress_count >= 0 &&
|
||||
config->skip_compress_count <= 10000 && config->max_alloc > 0 &&
|
||||
config->skip_compress_count <= MAX_SKIP_COMPRESS_SUFFIXES && config->max_alloc > 0 &&
|
||||
(!config->chmod_spec || !*config->chmod_spec ||
|
||||
chmod_apply(0, config->chmod_spec, &(mode_t){0})) &&
|
||||
/* The received --iconv CONVERT_SPEC is untrusted input that drives
|
||||
@@ -648,6 +693,9 @@ void config_delete(Config* config) {
|
||||
if (config == NULL)
|
||||
return;
|
||||
if (config->log_file) {
|
||||
/* The logging subsystem borrows this FILE*; detach it before closing so a
|
||||
* concurrent log call can never touch the freed handle. */
|
||||
log_set_file(NULL);
|
||||
fclose(config->log_file);
|
||||
config->log_file = NULL;
|
||||
}
|
||||
@@ -819,7 +867,7 @@ static bool send_checksum_options(int fd, const Config* c) {
|
||||
send_n_data(fd, &c->checksum_seed, sizeof(c->checksum_seed));
|
||||
}
|
||||
|
||||
static bool receive_core_fields(int fd, Config* c) {
|
||||
static bool receive_core_fields(int fd, Config* c, ConfigStringBudget* budget) {
|
||||
int value;
|
||||
if (!receive_wire_bool(fd, &c->eight_bit_output))
|
||||
return false;
|
||||
@@ -829,8 +877,8 @@ static bool receive_core_fields(int fd, Config* c) {
|
||||
if (c->max_alloc > MAX_SERVER_ALLOC)
|
||||
c->max_alloc = MAX_SERVER_ALLOC;
|
||||
protocol_session_set_max_alloc(NULL, c->max_alloc);
|
||||
c->send_directory = receive_str(fd);
|
||||
c->receive_root_directory = receive_str(fd);
|
||||
c->send_directory = config_receive_str(fd, budget);
|
||||
c->receive_root_directory = config_receive_str(fd, budget);
|
||||
if (!c->send_directory || !c->receive_root_directory)
|
||||
return false;
|
||||
if (!receive_wire_bool(fd, &c->save_to_disk) || !receive_wire_bool(fd, &c->use_multithreading) ||
|
||||
@@ -863,10 +911,10 @@ static bool receive_delta_fields(int fd, Config* c) {
|
||||
receive_n_data(fd, &c->delta_max_file_size, sizeof(unsigned long long));
|
||||
}
|
||||
|
||||
static bool receive_file_options(int fd, Config* c) {
|
||||
static bool receive_file_options(int fd, Config* c, ConfigStringBudget* budget) {
|
||||
if (!receive_wire_bool(fd, &c->backup))
|
||||
return false;
|
||||
char* backup_dir = receive_str(fd);
|
||||
char* backup_dir = config_receive_str(fd, budget);
|
||||
if (!backup_dir)
|
||||
return false;
|
||||
if (*backup_dir != '\0') {
|
||||
@@ -920,8 +968,8 @@ static bool receive_selection_options(int fd, Config* c) {
|
||||
return receive_wire_bool(fd, &c->delete_delay);
|
||||
}
|
||||
|
||||
static bool receive_resume_options(int fd, Config* c) {
|
||||
char* temp_dir = receive_str(fd);
|
||||
static bool receive_resume_options(int fd, Config* c, ConfigStringBudget* budget) {
|
||||
char* temp_dir = config_receive_str(fd, budget);
|
||||
if (!temp_dir)
|
||||
return false;
|
||||
if (*temp_dir != '\0') {
|
||||
@@ -935,7 +983,7 @@ static bool receive_resume_options(int fd, Config* c) {
|
||||
string for "unset". Canonicalize the empty wire value back to NULL so
|
||||
receivers observe exactly what the client configured (plain --backup, for
|
||||
example, must not look like --backup-dir ""). */
|
||||
char* partial_dir = receive_str(fd);
|
||||
char* partial_dir = config_receive_str(fd, budget);
|
||||
if (!partial_dir)
|
||||
return false;
|
||||
if (*partial_dir != '\0') {
|
||||
@@ -943,7 +991,7 @@ static bool receive_resume_options(int fd, Config* c) {
|
||||
} else {
|
||||
free(partial_dir);
|
||||
}
|
||||
char* suffix = receive_str(fd);
|
||||
char* suffix = config_receive_str(fd, budget);
|
||||
if (!suffix)
|
||||
return false;
|
||||
if (*suffix != '\0') {
|
||||
@@ -957,20 +1005,20 @@ static bool receive_resume_options(int fd, Config* c) {
|
||||
return false;
|
||||
if (!receive_n_data(fd, &c->modify_window, sizeof(c->modify_window)))
|
||||
return false;
|
||||
c->compress_choice = receive_str(fd);
|
||||
c->compress_choice = config_receive_str(fd, budget);
|
||||
if (!c->compress_choice)
|
||||
return false;
|
||||
c->chmod_spec = receive_str(fd);
|
||||
c->chmod_spec = config_receive_str(fd, budget);
|
||||
if (!c->chmod_spec || !receive_wire_bool(fd, &c->skip_compress_set) ||
|
||||
!receive_int(fd, &c->skip_compress_count) || c->skip_compress_count < 0 ||
|
||||
c->skip_compress_count > 10000)
|
||||
c->skip_compress_count > MAX_SKIP_COMPRESS_SUFFIXES)
|
||||
return false;
|
||||
if (c->skip_compress_count > 0) {
|
||||
c->skip_compress_suffixes = calloc((size_t)c->skip_compress_count, sizeof(char*));
|
||||
if (!c->skip_compress_suffixes)
|
||||
return false;
|
||||
for (int i = 0; i < c->skip_compress_count; i++) {
|
||||
c->skip_compress_suffixes[i] = receive_str(fd);
|
||||
c->skip_compress_suffixes[i] = config_receive_str(fd, budget);
|
||||
if (!c->skip_compress_suffixes[i])
|
||||
return false;
|
||||
}
|
||||
@@ -978,7 +1026,7 @@ static bool receive_resume_options(int fd, Config* c) {
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool receive_basis_options(int fd, Config* c) {
|
||||
static bool receive_basis_options(int fd, Config* c, ConfigStringBudget* budget) {
|
||||
int count;
|
||||
if (!receive_int(fd, &count))
|
||||
return false;
|
||||
@@ -988,7 +1036,7 @@ static bool receive_basis_options(int fd, Config* c) {
|
||||
int type;
|
||||
if (!receive_int(fd, &type) || type <= BASIS_DEST_NONE || type > BASIS_DEST_LINK)
|
||||
return false;
|
||||
char* path = receive_str(fd);
|
||||
char* path = config_receive_str(fd, budget);
|
||||
if (!path)
|
||||
return false;
|
||||
/* config_basis_append validates and canonicalizes the path; a rejected
|
||||
@@ -1119,8 +1167,8 @@ static bool send_daemon_module(int fd, const Config* c) {
|
||||
return send_str(fd, c->module ? c->module : "");
|
||||
}
|
||||
|
||||
static bool receive_daemon_module(int fd, Config* c) {
|
||||
char* module = receive_str(fd);
|
||||
static bool receive_daemon_module(int fd, Config* c, ConfigStringBudget* budget) {
|
||||
char* module = config_receive_str(fd, budget);
|
||||
if (!module)
|
||||
return false;
|
||||
/* Guard against a hostile client flooding the log with an over-long module
|
||||
@@ -1155,14 +1203,14 @@ static bool send_daemon_auth(int fd, const Config* c) {
|
||||
return send_str_redacted(fd, c->auth_user);
|
||||
}
|
||||
|
||||
static bool receive_daemon_auth(int fd, Config* c) {
|
||||
static bool receive_daemon_auth(int fd, Config* c, ConfigStringBudget* budget) {
|
||||
int present;
|
||||
if (!receive_int(fd, &present) || !valid_wire_bool(present))
|
||||
return false;
|
||||
if (!present)
|
||||
return true;
|
||||
/* Redacted receive: never log the incoming username body. */
|
||||
char* user = receive_str_redacted(fd);
|
||||
char* user = config_receive_str_redacted(fd, budget);
|
||||
if (!user)
|
||||
return false;
|
||||
if (!credentials_username_valid(user)) {
|
||||
@@ -1272,8 +1320,8 @@ 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);
|
||||
static bool receive_iconv_spec(int fd, Config* c, ConfigStringBudget* budget) {
|
||||
char* spec = config_receive_str(fd, budget);
|
||||
if (!spec)
|
||||
return false;
|
||||
if (*spec == '\0') {
|
||||
@@ -1293,14 +1341,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;
|
||||
}
|
||||
|
||||
@@ -1376,47 +1424,48 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
|
||||
Config* config = config_create();
|
||||
if (!config)
|
||||
return NULL;
|
||||
ConfigStringBudget budget = {0};
|
||||
free(config->version);
|
||||
config->version = receive_str(file_descriptor);
|
||||
config->version = config_receive_str(file_descriptor, &budget);
|
||||
if (!config->version)
|
||||
goto error;
|
||||
if (strcmp(config->version, PROTOCOL_VERSION) != 0) {
|
||||
char* escaped_version = output_escape(config->version, false);
|
||||
fprintf(stderr, "Protocol version mismatch: client=%s, server=%s\n",
|
||||
log_message(LOG_LEVEL_ERROR, "Protocol version mismatch: client=%s, server=%s",
|
||||
escaped_version ? escaped_version : "<allocation failed>", PROTOCOL_VERSION);
|
||||
free(escaped_version);
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
goto error;
|
||||
}
|
||||
if (!receive_core_fields(file_descriptor, config) ||
|
||||
if (!receive_core_fields(file_descriptor, config, &budget) ||
|
||||
!receive_delta_fields(file_descriptor, config) ||
|
||||
!receive_file_options(file_descriptor, config) ||
|
||||
!receive_file_options(file_descriptor, config, &budget) ||
|
||||
!receive_selection_options(file_descriptor, config) ||
|
||||
!receive_resume_options(file_descriptor, config) ||
|
||||
!receive_basis_options(file_descriptor, config) ||
|
||||
!receive_resume_options(file_descriptor, config, &budget) ||
|
||||
!receive_basis_options(file_descriptor, config, &budget) ||
|
||||
!receive_fuzzy_option(file_descriptor, config) ||
|
||||
!receive_checksum_options(file_descriptor, config) ||
|
||||
!receive_identity_options(file_descriptor, config) ||
|
||||
!receive_metadata_times_options(file_descriptor, config) ||
|
||||
!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_iconv_spec(file_descriptor, config) ||
|
||||
!receive_daemon_module(file_descriptor, config, &budget) ||
|
||||
!receive_daemon_auth(file_descriptor, config, &budget) ||
|
||||
!receive_iconv_spec(file_descriptor, config, &budget) ||
|
||||
!receive_privilege_options(file_descriptor, config) ||
|
||||
!receive_copy_as_options(file_descriptor, config))
|
||||
goto error;
|
||||
if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 &&
|
||||
strcmp(config->compress_choice, "none") != 0) {
|
||||
char* escaped_choice = output_escape(config->compress_choice, config->eight_bit_output);
|
||||
fprintf(stderr, "Unsupported compression choice: %s\n",
|
||||
log_message(LOG_LEVEL_ERROR, "Unsupported compression choice: %s",
|
||||
escaped_choice ? escaped_choice : "<allocation failed>");
|
||||
free(escaped_choice);
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
goto error;
|
||||
}
|
||||
if (!validate_received_config(config)) {
|
||||
fprintf(stderr, "Invalid configuration received from client\n");
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid configuration received from client");
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
goto error;
|
||||
}
|
||||
@@ -1430,7 +1479,7 @@ Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc val
|
||||
* the CONFIG_VALIDATE_ALREADY_TERMINATED sentinel, so no second status is
|
||||
* written. */
|
||||
if (rejection != CONFIG_VALIDATE_ALREADY_TERMINATED) {
|
||||
fprintf(stderr, "%s\n", rejection);
|
||||
log_message(LOG_LEVEL_ERROR, "%s", rejection);
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
}
|
||||
goto error;
|
||||
|
||||
+26
-10
@@ -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.
|
||||
@@ -636,6 +643,24 @@ typedef struct Config {
|
||||
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
|
||||
#define MAX_BASIS_DIRS 64
|
||||
|
||||
/* Upper bound on the number of --skip-compress suffixes accepted from the wire.
|
||||
* Each suffix is an independent wire string (up to MAX_STRING_SIZE = 64 KiB), so
|
||||
* without this a hostile pre-auth client could otherwise retain
|
||||
* skip_count * MAX_STRING_SIZE bytes on the server before authentication; 256
|
||||
* covers any realistic suffix list while keeping the worst case small. */
|
||||
#define MAX_SKIP_COMPRESS_SUFFIXES 256
|
||||
|
||||
/* Aggregate ceiling on the bytes retained by ALL strings in one received config
|
||||
* frame (version, send/receive roots, backup/temp/partial/suffix, compression
|
||||
* choice, chmod spec, skip-compress suffixes, basis paths, module, auth user,
|
||||
* iconv spec, ...). The config frame is parsed BEFORE authentication and every
|
||||
* one of these strings lives for the whole connection, so this cumulative
|
||||
* (never released) budget bounds the pre-auth memory a single connection can
|
||||
* pin. MAX_SKIP_COMPRESS_SUFFIXES / MAX_BASIS_DIRS bound the individual
|
||||
* repeatable counts; this budget bounds their product and any single oversized
|
||||
* field. */
|
||||
#define MAX_CONFIG_STRING_BYTES (1ULL * 1024 * 1024)
|
||||
|
||||
/* Identity-mapping sentinels and bounds (see identity.h for semantics).
|
||||
* IDENTITY_MATCH_ANY is a usermap/groupmap FROM '*' (matches any id);
|
||||
* IDENTITY_CURRENT is a chown / map TO '*' (resolve to the receiver's current
|
||||
@@ -644,15 +669,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);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "daemon_conf.h"
|
||||
#include "credentials.h"
|
||||
#include "utils.h"
|
||||
#include <ctype.h>
|
||||
#include <errno.h>
|
||||
@@ -192,6 +193,12 @@ static bool apply_module_key(DaemonModule* module, char* key, char* value, char*
|
||||
const char* user = trim_ws(token);
|
||||
if (*user == '\0')
|
||||
continue;
|
||||
if (!credentials_username_valid(user)) {
|
||||
set_error(err, err_size, "module '%s': invalid 'auth users' entry '%s'", module->name,
|
||||
user);
|
||||
free(list);
|
||||
return false;
|
||||
}
|
||||
char** grown =
|
||||
realloc(module->auth_users, (size_t)(module->auth_user_count + 1) * sizeof(char*));
|
||||
if (!grown) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "log.h"
|
||||
#include "utils.h"
|
||||
#include <errno.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -51,7 +52,8 @@ static int normalize_entry(const char* raw, size_t len, bool strip_line_endings,
|
||||
if (len == 0)
|
||||
return 0;
|
||||
if (raw[0] == '/') {
|
||||
snprintf(err, err_size, "absolute path entries are not allowed: '%.*s'", (int)len, raw);
|
||||
int print_len = len > (size_t)INT_MAX ? INT_MAX : (int)len;
|
||||
snprintf(err, err_size, "absolute path entries are not allowed: '%.*s'", print_len, raw);
|
||||
return -1;
|
||||
}
|
||||
/* Reject NUL bytes inside a token defensively (NUL-delimited mode splits on
|
||||
|
||||
@@ -1696,9 +1696,9 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (has_path_traversal(check_path)) {
|
||||
if (check_path[0] == '\0' || has_path_traversal(check_path)) {
|
||||
char* escaped_path = output_escape(check_path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_ERROR, "Path traversal detected: %s",
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid received check path: %s",
|
||||
escaped_path ? escaped_path : "<allocation failed>");
|
||||
free(escaped_path);
|
||||
free(check_path);
|
||||
@@ -1832,6 +1832,7 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
|
||||
existing/ignore-existing/update/backup/delay-updates policy. */
|
||||
File* materialized = file_create(check_path);
|
||||
if (materialized && basis.content) {
|
||||
data_destroy(materialized->data);
|
||||
materialized->data = basis.content;
|
||||
basis.content = NULL;
|
||||
materialized->metadata = file_metadata_create(NULL, &basis.st, false, false);
|
||||
@@ -2095,6 +2096,7 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
|
||||
file->metadata = meta;
|
||||
file->xattrs = append_xattrs;
|
||||
append_xattrs = NULL;
|
||||
data_destroy(file->data);
|
||||
file->data = data_create(full, full_size);
|
||||
if (!file->data) { /* data_create already freed full on failure */
|
||||
file_destroy(file);
|
||||
@@ -2236,6 +2238,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;
|
||||
@@ -2243,6 +2249,7 @@ void dir_time_list_init(DirTimeList* list) {
|
||||
list->entries = NULL;
|
||||
list->count = 0;
|
||||
list->capacity = 0;
|
||||
list->bytes = 0;
|
||||
}
|
||||
|
||||
void dir_time_list_free(DirTimeList* list) {
|
||||
@@ -2256,11 +2263,23 @@ void dir_time_list_free(DirTimeList* list) {
|
||||
list->entries = NULL;
|
||||
list->count = 0;
|
||||
list->capacity = 0;
|
||||
list->bytes = 0;
|
||||
}
|
||||
|
||||
bool dir_time_list_add(DirTimeList* list, const char* wire_path, const FileMetadata* metadata) {
|
||||
if (!list || !wire_path || !metadata)
|
||||
return true; /* nothing to remember; never a hard error */
|
||||
/* Cumulative, not per-frame: the sender may stream a tree across unbounded
|
||||
STATUS_DIR_TIMES frames, so bound the TOTAL retained here. Reject before
|
||||
touching the list, leaving it exactly as it was (the caller fails the
|
||||
transfer, which becomes a clean protocol error). */
|
||||
size_t path_len = strlen(wire_path);
|
||||
/* Charge the whole per-entry cost (path copy + pointer slot + metadata
|
||||
struct), not just the path, so the array growth is bounded by the same
|
||||
cumulative budget. */
|
||||
size_t entry_cost = path_len + sizeof(FileMetadata) + sizeof(char*);
|
||||
if (list->count >= MAX_DIR_TIME_ENTRIES || entry_cost > MAX_DIR_TIME_BYTES - list->bytes)
|
||||
return false;
|
||||
if (list->count == list->capacity) {
|
||||
size_t new_capacity = list->capacity == 0 ? 16 : list->capacity * 2;
|
||||
if (new_capacity < list->capacity)
|
||||
@@ -2287,6 +2306,7 @@ bool dir_time_list_add(DirTimeList* list, const char* wire_path, const FileMetad
|
||||
list->paths[list->count] = copy;
|
||||
list->entries[list->count] = *metadata;
|
||||
list->count++;
|
||||
list->bytes += entry_cost;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
|
||||
/* Server-side file receive/save path. */
|
||||
|
||||
/* Cumulative caps for the deferred directory-time accumulator. The sender may
|
||||
* legitimately split a large tree across repeated STATUS_DIR_TIMES frames, so a
|
||||
* per-frame bound is not enough: the receiver must bound the TOTAL it retains
|
||||
* against a hostile sender. Mirror the delete-manifest limits
|
||||
* (MAX_MANIFEST_ENTRIES / MAX_MANIFEST_BYTES): the entry count bounds the
|
||||
* metadata array and the byte budget bounds the concatenated path strings. */
|
||||
#define MAX_DIR_TIME_ENTRIES (1024 * 1024)
|
||||
#define MAX_DIR_TIME_BYTES (16ULL * 1024 * 1024)
|
||||
|
||||
File* file_receive(const Config* config, int file_descriptor);
|
||||
File* file_receive_directory(int file_descriptor, const Config* config);
|
||||
File* file_receive_dir_time(int file_descriptor, const Config* config);
|
||||
@@ -28,12 +37,20 @@ typedef struct {
|
||||
FileMetadata* entries; /* owned, parallel to paths */
|
||||
size_t count;
|
||||
size_t capacity;
|
||||
size_t bytes; /* cumulative strlen of every retained path */
|
||||
} 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
|
||||
* allocation failure (the caller fails the transfer). */
|
||||
* allocation failure OR when the cumulative entry/byte caps would be exceeded
|
||||
* (the caller fails the transfer). */
|
||||
bool dir_time_list_add(DirTimeList* list, const char* wire_path, const FileMetadata* metadata);
|
||||
/* Apply every accumulated directory's mtime (and atime when captured) beneath
|
||||
* `root_directory`, confined fd-relative. Best-effort per entry: an absent
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+72
-26
@@ -3,7 +3,9 @@
|
||||
#include <stdbool.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <threads.h>
|
||||
#include <time.h>
|
||||
|
||||
static const char* log_level_strings[] = {"DEBUG", "INFO", "WARN", "ERROR"};
|
||||
@@ -15,6 +17,18 @@ static FILE* log_fp = NULL;
|
||||
static _Thread_local bool eight_bit_output;
|
||||
static LogStderrMode stderr_mode = LOG_STDERR_ERRORS;
|
||||
|
||||
/* Serializes access to log_fp and makes each emitted line atomic: the
|
||||
* timestamp prefix, formatted body, and trailing newline are written as one
|
||||
* critical section so concurrent threads cannot interleave partial lines.
|
||||
* Initialized lazily (matching the protocol.c bw_mutex idiom) because logging
|
||||
* can happen before main() installs any synchronization. */
|
||||
static mtx_t log_mutex;
|
||||
static once_flag log_mutex_once = ONCE_FLAG_INIT;
|
||||
|
||||
static void log_mutex_init(void) {
|
||||
mtx_init(&log_mutex, mtx_plain);
|
||||
}
|
||||
|
||||
void set_log_level(LogLevel level) {
|
||||
current_log_level = level;
|
||||
}
|
||||
@@ -41,7 +55,10 @@ uint32_t get_log_info_flags(void) {
|
||||
}
|
||||
|
||||
void log_set_file(FILE* fp) {
|
||||
call_once(&log_mutex_once, log_mutex_init);
|
||||
mtx_lock(&log_mutex);
|
||||
log_fp = fp;
|
||||
mtx_unlock(&log_mutex);
|
||||
}
|
||||
|
||||
void log_set_8_bit_output(bool enabled) {
|
||||
@@ -60,13 +77,48 @@ LogStderrMode log_get_stderr_mode(void) {
|
||||
return stderr_mode;
|
||||
}
|
||||
|
||||
static inline void write_message(FILE* dest_io, LogLevel log_level, struct tm t, const char* format,
|
||||
/* Format one complete log line (timestamp prefix + body + newline) into a
|
||||
* freshly allocated buffer. This is pure CPU/malloc work and must happen
|
||||
* OUTSIDE the log mutex: the mutex only guards the log_fp pointer, so a
|
||||
* stalled stderr/stdout pipe cannot block every logging thread. Returns NULL
|
||||
* on allocation/formatting failure. */
|
||||
static char* format_log_line(LogLevel log_level, const struct tm* t, const char* format,
|
||||
va_list args) {
|
||||
fprintf(dest_io, "%04d-%02d-%02d %02d:%02d:%02d [%s]: ", t.tm_year + 1900, t.tm_mon + 1,
|
||||
t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, log_level_strings[log_level]);
|
||||
char prefix[64];
|
||||
int prefix_len = snprintf(
|
||||
prefix, sizeof(prefix), "%04d-%02d-%02d %02d:%02d:%02d [%s]: ", t->tm_year + 1900,
|
||||
t->tm_mon + 1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, log_level_strings[log_level]);
|
||||
if (prefix_len < 0 || prefix_len >= (int)sizeof(prefix))
|
||||
return NULL;
|
||||
va_list copy;
|
||||
va_copy(copy, args);
|
||||
int body_len = vsnprintf(NULL, 0, format, copy);
|
||||
va_end(copy);
|
||||
if (body_len < 0)
|
||||
return NULL;
|
||||
size_t total = (size_t)prefix_len + (size_t)body_len;
|
||||
char* line = malloc(total + 2); /* body bytes + '\n' + NUL */
|
||||
if (!line)
|
||||
return NULL;
|
||||
memcpy(line, prefix, (size_t)prefix_len);
|
||||
vsnprintf(line + prefix_len, (size_t)body_len + 1, format, args);
|
||||
line[total] = '\n';
|
||||
line[total + 1] = '\0';
|
||||
return line;
|
||||
}
|
||||
|
||||
vfprintf(dest_io, format, args);
|
||||
fprintf(dest_io, "\n");
|
||||
/* Write an already-formatted line to the console and, if configured, the log
|
||||
* file. Only the log_fp pointer is read under the mutex (so log_set_file /
|
||||
* config_delete cannot free it while it is in use); the single console fputs
|
||||
* runs unlocked but is internally atomic per stdio stream. */
|
||||
static void emit_log_line(FILE* console, const char* line) {
|
||||
fputs(line, console);
|
||||
call_once(&log_mutex_once, log_mutex_init);
|
||||
mtx_lock(&log_mutex);
|
||||
FILE* file = log_fp;
|
||||
if (file)
|
||||
fputs(line, file);
|
||||
mtx_unlock(&log_mutex);
|
||||
}
|
||||
|
||||
void log_message(LogLevel log_level, const char* format, ...) {
|
||||
@@ -86,14 +138,12 @@ void log_message(LogLevel log_level, const char* format, ...) {
|
||||
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
write_message(dest_io, log_level, t, format, args);
|
||||
char* line = format_log_line(log_level, &t, format, args);
|
||||
va_end(args);
|
||||
|
||||
if (log_fp) {
|
||||
va_start(args, format);
|
||||
write_message(log_fp, log_level, t, format, args);
|
||||
va_end(args);
|
||||
}
|
||||
if (!line)
|
||||
return;
|
||||
emit_log_line(dest_io, line);
|
||||
free(line);
|
||||
}
|
||||
|
||||
void log_debug_message(LogDebugFlag flag, const char* format, ...) {
|
||||
@@ -107,14 +157,12 @@ void log_debug_message(LogDebugFlag flag, const char* format, ...) {
|
||||
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
write_message(stdout, LOG_LEVEL_DEBUG, t, format, args);
|
||||
char* line = format_log_line(LOG_LEVEL_DEBUG, &t, format, args);
|
||||
va_end(args);
|
||||
|
||||
if (log_fp) {
|
||||
va_start(args, format);
|
||||
write_message(log_fp, LOG_LEVEL_DEBUG, t, format, args);
|
||||
va_end(args);
|
||||
}
|
||||
if (!line)
|
||||
return;
|
||||
emit_log_line(stdout, line);
|
||||
free(line);
|
||||
}
|
||||
|
||||
void log_info_message(LogInfoFlag flag, const char* format, ...) {
|
||||
@@ -129,14 +177,12 @@ void log_info_message(LogInfoFlag flag, const char* format, ...) {
|
||||
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
write_message(stdout, LOG_LEVEL_INFO, t, format, args);
|
||||
char* line = format_log_line(LOG_LEVEL_INFO, &t, format, args);
|
||||
va_end(args);
|
||||
|
||||
if (log_fp) {
|
||||
va_start(args, format);
|
||||
write_message(log_fp, LOG_LEVEL_INFO, t, format, args);
|
||||
va_end(args);
|
||||
}
|
||||
if (!line)
|
||||
return;
|
||||
emit_log_line(stdout, line);
|
||||
free(line);
|
||||
}
|
||||
|
||||
void log_perror(const char* context) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -128,7 +128,7 @@ char* ssh_build_remote_command(const char* server_path, bool old_args, char* con
|
||||
q++;
|
||||
len++;
|
||||
}
|
||||
if (len > SIZE_MAX - q * 3 || len + q * 3 + 3 > SIZE_MAX - command_len)
|
||||
if (q > (SIZE_MAX - len) / 3 || len + q * 3 + 3 > SIZE_MAX - command_len)
|
||||
return NULL;
|
||||
command_len += len + q * 3 + 3;
|
||||
}
|
||||
|
||||
@@ -152,9 +152,18 @@ static void accept_loop(Server* server, void (*child_fn)(int, void*), void* chil
|
||||
log_message(LOG_LEVEL_INFO, "%s", log_fmt);
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
/* Connection children must not run the parent's global cleanup(): it
|
||||
* frees state (credentials / daemon conf) that the child's worker
|
||||
* threads may still be reading and closes fd numbers the child could
|
||||
* already have reused. Reset the inherited handlers so a signal
|
||||
* terminates the child directly; SIGCHLD is reset too since a child
|
||||
* must never reap the parent's children. This runs before the child
|
||||
* spawns any thread, so it cannot race one. */
|
||||
signal(SIGINT, SIG_DFL);
|
||||
signal(SIGTERM, SIG_DFL);
|
||||
signal(SIGCHLD, SIG_DFL);
|
||||
close(server->file_descriptor);
|
||||
child_fn(fd, child_ctx);
|
||||
close(fd);
|
||||
_exit(0);
|
||||
} else if (pid > 0) {
|
||||
g_active_connections++;
|
||||
@@ -169,6 +178,9 @@ struct plain_ctx {
|
||||
|
||||
static void plain_child_fn(int fd, void* ctx) {
|
||||
((struct plain_ctx*)ctx)->handler(fd);
|
||||
/* handler() never closes the connection fd; the child owns its single
|
||||
* close here after the handler has fully torn down. */
|
||||
close(fd);
|
||||
}
|
||||
|
||||
bool server_listen(Server* server, void (*handler)(int file_descriptor)) {
|
||||
|
||||
@@ -65,6 +65,18 @@ static SSL_CTX* create_ssl_ctx(bool is_server, const char* cert, const char* key
|
||||
SSL_CTX_free(ctx);
|
||||
return NULL;
|
||||
}
|
||||
/* TLS 1.3 ciphersuites are configured separately from the TLS 1.2 and below
|
||||
* cipher list above. Pin the three AEAD suites OpenSSL offers, dropping
|
||||
* TLS_AES_128_CCM_SHA256 and the CCM_8 variant, and fail closed if the
|
||||
* library rejects the policy. SSL_CTX_set_ciphersuites needs OpenSSL 1.1.1;
|
||||
* earlier versions have no TLS 1.3, so the call is compile-guarded. */
|
||||
#if OPENSSL_VERSION_NUMBER >= 0x10101000L
|
||||
if (SSL_CTX_set_ciphersuites(
|
||||
ctx, "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256") != 1) {
|
||||
SSL_CTX_free(ctx);
|
||||
return NULL;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (cert && key) {
|
||||
struct stat key_stat;
|
||||
@@ -180,13 +192,18 @@ static void tls_child_fn(int fd, void* arg) {
|
||||
SSL* ssl = wrap_fd_with_ssl(fd, ctx->ssl_ctx, true, NULL);
|
||||
if (!ssl) {
|
||||
io_set_ssl(NULL);
|
||||
close(fd);
|
||||
return;
|
||||
}
|
||||
io_set_ssl(ssl);
|
||||
ctx->handler(fd);
|
||||
/* Shut the TLS layer down before releasing the fd: handler() no longer
|
||||
* closes it, so SSL_shutdown still has a valid socket. The child owns the
|
||||
* single fd close, performed last. */
|
||||
SSL_shutdown(ssl);
|
||||
SSL_free(ssl);
|
||||
io_set_ssl(NULL);
|
||||
close(fd);
|
||||
}
|
||||
|
||||
bool server_listen_tls(Server* server, void (*handler)(int file_descriptor)) {
|
||||
|
||||
+19
-1
@@ -1,6 +1,7 @@
|
||||
#include "test_array_list.h"
|
||||
#include "array_list.h"
|
||||
#include "test_utils.h"
|
||||
#include <limits.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
static int destroyer_calls = 0;
|
||||
@@ -9,7 +10,7 @@ static void test_destroyer(void* item) {
|
||||
free(item);
|
||||
}
|
||||
|
||||
void test_array_list() {
|
||||
static void test_array_list_basic() {
|
||||
ArrayList* list = array_list_create(free);
|
||||
EXPECT_NOT_NULL(list);
|
||||
EXPECT_EQ_INT(list->size, 0);
|
||||
@@ -54,3 +55,20 @@ void test_array_list() {
|
||||
array_list_delete(list);
|
||||
EXPECT_EQ_INT(destroyer_calls, 106);
|
||||
}
|
||||
|
||||
/* A capacity that would overflow `capacity * 2` must be refused instead of
|
||||
* wrapping into signed-overflow UB; array_list_add surfaces the failure. */
|
||||
static void test_array_list_extend_overflow_guard() {
|
||||
ArrayList* list = array_list_create(NULL);
|
||||
EXPECT_NOT_NULL(list);
|
||||
list->capacity = INT_MAX / 2 + 1;
|
||||
list->size = list->capacity;
|
||||
EXPECT_FALSE(array_list_add(list, NULL));
|
||||
list->size = 0;
|
||||
array_list_delete(list);
|
||||
}
|
||||
|
||||
void test_array_list() {
|
||||
test_array_list_basic();
|
||||
test_array_list_extend_overflow_guard();
|
||||
}
|
||||
+86
-1
@@ -6,6 +6,7 @@
|
||||
#include "queue.h"
|
||||
#include "test_utils.h"
|
||||
#include "utils.h"
|
||||
#include <signal.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/socket.h>
|
||||
#include <string.h>
|
||||
@@ -1672,7 +1673,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);
|
||||
@@ -1846,6 +1847,89 @@ static void test_config_receive_rejects_copy_as_without_metadata() {
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
/* Like roundtrip_config_ok, but the parent is the RECEIVER so the frame can be
|
||||
rejected MID-way, before the sender finishes writing it. The sender child
|
||||
ignores SIGPIPE so the receiver closing early cannot kill it; the parent
|
||||
waits for the child to exit after observing the rejection. */
|
||||
static bool roundtrip_config_rejected(const Config* send_cfg) {
|
||||
int p[2];
|
||||
if (socketpair(AF_UNIX, SOCK_STREAM, 0, p) != 0)
|
||||
return false;
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
(void)signal(SIGPIPE, SIG_IGN);
|
||||
close(p[0]);
|
||||
io_set_fds(p[1], p[1]);
|
||||
config_send(p[1], send_cfg);
|
||||
close(p[1]);
|
||||
_exit(0);
|
||||
}
|
||||
close(p[1]);
|
||||
io_set_fds(p[0], p[0]);
|
||||
Config* recv = config_receive(p[0]);
|
||||
bool rejected = recv == NULL;
|
||||
config_delete(recv);
|
||||
close(p[0]);
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
return rejected;
|
||||
}
|
||||
|
||||
/* Build a Config with `count` --skip-compress suffixes, each `suffix_len` bytes
|
||||
long, for the pre-auth config-string budget tests. */
|
||||
static Config* make_skip_compress_config(int count, size_t suffix_len) {
|
||||
Config* c = config_create();
|
||||
if (!c)
|
||||
return NULL;
|
||||
c->send_directory = str_dup("/src");
|
||||
c->receive_root_directory = str_dup("/dst");
|
||||
c->skip_compress_set = true;
|
||||
c->skip_compress_count = count;
|
||||
c->skip_compress_suffixes = calloc((size_t)count, sizeof(char*));
|
||||
if (!c->skip_compress_suffixes) {
|
||||
config_delete(c);
|
||||
return NULL;
|
||||
}
|
||||
char* suffix = malloc(suffix_len + 1);
|
||||
if (!suffix) {
|
||||
config_delete(c);
|
||||
return NULL;
|
||||
}
|
||||
memset(suffix, 'x', suffix_len);
|
||||
suffix[suffix_len] = '\0';
|
||||
for (int i = 0; i < count; i++)
|
||||
c->skip_compress_suffixes[i] = str_dup(suffix);
|
||||
free(suffix);
|
||||
return c;
|
||||
}
|
||||
|
||||
/* Pre-auth memory bound: one connection must not retain unbounded config
|
||||
strings. An over-limit --skip-compress count is refused, and even an
|
||||
in-range count cannot exceed the aggregate per-connection string budget. */
|
||||
static void test_config_receive_rejects_oversized_string_budget() {
|
||||
if (is_running_under_valgrind())
|
||||
return;
|
||||
|
||||
/* Exactly MAX_SKIP_COMPRESS_SUFFIXES tiny suffixes are accepted. */
|
||||
Config* ok = make_skip_compress_config(MAX_SKIP_COMPRESS_SUFFIXES, 1);
|
||||
EXPECT_NOT_NULL(ok);
|
||||
EXPECT_TRUE(roundtrip_config_ok(ok));
|
||||
config_delete(ok);
|
||||
|
||||
/* One suffix over the count cap is rejected before any suffix is read. */
|
||||
Config* over_count = make_skip_compress_config(MAX_SKIP_COMPRESS_SUFFIXES + 1, 1);
|
||||
EXPECT_NOT_NULL(over_count);
|
||||
EXPECT_TRUE(roundtrip_config_rejected(over_count));
|
||||
config_delete(over_count);
|
||||
|
||||
/* In-range count, but the strings together exceed MAX_CONFIG_STRING_BYTES
|
||||
(64 suffixes * ~64 KiB > 1 MiB), so the aggregate budget rejects it. */
|
||||
Config* over_bytes = make_skip_compress_config(64, MAX_STRING_SIZE - 1);
|
||||
EXPECT_NOT_NULL(over_bytes);
|
||||
EXPECT_TRUE(roundtrip_config_rejected(over_bytes));
|
||||
config_delete(over_bytes);
|
||||
}
|
||||
|
||||
/* identity_copy_as_refused() is the pure, pre-snapshot refusal predicate: a
|
||||
--copy-as is refused when the receiver is not root OR the effective super
|
||||
mode is OFF (an operator veto), and never when --copy-as is unset. */
|
||||
@@ -2009,6 +2093,7 @@ void test_config() {
|
||||
test_config_copy_as_wire_roundtrip();
|
||||
test_config_receive_rejects_negative_copy_as();
|
||||
test_config_receive_rejects_copy_as_without_metadata();
|
||||
test_config_receive_rejects_oversized_string_budget();
|
||||
test_config_receive_with_validate_rejects();
|
||||
}
|
||||
test_identity_copy_as_refused();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "test_daemon_conf.h"
|
||||
#include "credentials.h"
|
||||
#include "daemon_conf.h"
|
||||
#include "test_utils.h"
|
||||
#include <stdio.h>
|
||||
@@ -320,6 +321,51 @@ static void test_daemon_conf_dparam_override() {
|
||||
daemon_conf_free(conf);
|
||||
}
|
||||
|
||||
/* Each `auth users` entry is validated with the same username rule as the
|
||||
* credential store, so invisible whitespace/control characters can never make
|
||||
* an exact strcmp match ambiguous. */
|
||||
static void test_daemon_conf_auth_users_validated() {
|
||||
char* path;
|
||||
char err[256];
|
||||
const DaemonConf* conf;
|
||||
|
||||
EXPECT_EQ_INT(write_conf("[m]\npath = /x\nauth users = alice, bad user\n", &path), 0);
|
||||
conf = daemon_conf_load(path, err, sizeof(err));
|
||||
free(path);
|
||||
EXPECT_NULL(conf);
|
||||
EXPECT_TRUE(strstr(err, "invalid 'auth users' entry") != NULL);
|
||||
|
||||
EXPECT_EQ_INT(write_conf("[m]\npath = /x\nauth users = good\tbad\n", &path), 0);
|
||||
conf = daemon_conf_load(path, err, sizeof(err));
|
||||
free(path);
|
||||
EXPECT_NULL(conf);
|
||||
EXPECT_TRUE(strstr(err, "invalid 'auth users' entry") != NULL);
|
||||
|
||||
/* An over-long name exceeds CREDENTIAL_MAX_USER_LEN and is rejected. */
|
||||
{
|
||||
char body[CREDENTIAL_MAX_USER_LEN + 128];
|
||||
int n = snprintf(body, sizeof(body), "[m]\npath = /x\nauth users = ");
|
||||
memset(body + n, 'a', CREDENTIAL_MAX_USER_LEN + 1);
|
||||
body[n + CREDENTIAL_MAX_USER_LEN + 1] = '\n';
|
||||
body[n + CREDENTIAL_MAX_USER_LEN + 2] = '\0';
|
||||
EXPECT_EQ_INT(write_conf(body, &path), 0);
|
||||
conf = daemon_conf_load(path, err, sizeof(err));
|
||||
free(path);
|
||||
EXPECT_NULL(conf);
|
||||
EXPECT_TRUE(strstr(err, "invalid 'auth users' entry") != NULL);
|
||||
}
|
||||
|
||||
/* Empty entries between commas are skipped, not treated as invalid. */
|
||||
EXPECT_EQ_INT(write_conf("[m]\npath = /x\nauth users = alice,, bob\n", &path), 0);
|
||||
DaemonConf* ok_conf = daemon_conf_load(path, err, sizeof(err));
|
||||
free(path);
|
||||
EXPECT_NOT_NULL(ok_conf);
|
||||
EXPECT_EQ_INT(ok_conf->modules[0].auth_user_count, 2);
|
||||
EXPECT_EQ_STR(ok_conf->modules[0].auth_users[0], "alice");
|
||||
EXPECT_EQ_STR(ok_conf->modules[0].auth_users[1], "bob");
|
||||
daemon_conf_free(ok_conf);
|
||||
}
|
||||
|
||||
static void test_daemon_module_name_valid() {
|
||||
EXPECT_TRUE(daemon_module_name_valid("backup"));
|
||||
EXPECT_TRUE(daemon_module_name_valid("Backup_2"));
|
||||
@@ -351,5 +397,6 @@ void test_daemon_conf() {
|
||||
test_daemon_conf_missing_file_rejected();
|
||||
test_daemon_conf_find_module();
|
||||
test_daemon_conf_dparam_override();
|
||||
test_daemon_conf_auth_users_validated();
|
||||
test_daemon_module_name_valid();
|
||||
}
|
||||
@@ -1370,6 +1370,76 @@ static void test_dir_time_list() {
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
/* A hostile sender can stream unbounded STATUS_DIR_TIMES frames; the
|
||||
* accumulator must bound the CUMULATIVE path bytes (not just one frame) and
|
||||
* reject the add that would cross the cap, leaving the list untouched. */
|
||||
static void test_dir_time_list_cap() {
|
||||
DirTimeList list;
|
||||
dir_time_list_init(&list);
|
||||
EXPECT_EQ_INT((int)list.bytes, 0);
|
||||
FileMetadata metadata = {.mtime_sec = 1, .mtime_nsec = 0};
|
||||
|
||||
size_t path_len = MAX_STRING_SIZE - 1;
|
||||
char* path = malloc(path_len + 1);
|
||||
EXPECT_NOT_NULL(path);
|
||||
memset(path, 'a', path_len);
|
||||
path[path_len] = '\0';
|
||||
|
||||
bool rejected = false;
|
||||
for (size_t i = 0; i < MAX_DIR_TIME_ENTRIES + 1 && !rejected; i++) {
|
||||
size_t before_count = list.count;
|
||||
size_t before_bytes = list.bytes;
|
||||
if (!dir_time_list_add(&list, path, &metadata)) {
|
||||
rejected = true;
|
||||
/* The rejected add must not have partially mutated the list. */
|
||||
EXPECT_TRUE(list.count == before_count);
|
||||
EXPECT_TRUE(list.bytes == before_bytes);
|
||||
} else {
|
||||
EXPECT_TRUE(list.count == before_count + 1);
|
||||
EXPECT_TRUE(list.bytes == before_bytes + path_len + sizeof(FileMetadata) + sizeof(char*));
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(rejected);
|
||||
EXPECT_TRUE(list.count <= MAX_DIR_TIME_ENTRIES);
|
||||
EXPECT_TRUE(list.bytes <= MAX_DIR_TIME_BYTES);
|
||||
|
||||
/* The retained entries are still intact and freeable after the rejection. */
|
||||
EXPECT_TRUE(list.count > 0);
|
||||
EXPECT_TRUE(strcmp(list.paths[0], path) == 0);
|
||||
dir_time_list_free(&list);
|
||||
EXPECT_EQ_INT((int)list.bytes, 0);
|
||||
free(path);
|
||||
}
|
||||
|
||||
/* receive_incremental_check must reject an empty check_path; every other
|
||||
* receive path rejects path[0]=='\0'. Feed the check header (empty wire path
|
||||
* + size/mtime/nsec) and assert the check is refused without being skipped. */
|
||||
static void test_receive_incremental_check_empty_path() {
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
cfg->checksum = false;
|
||||
|
||||
int p[2];
|
||||
EXPECT_EQ_INT(pipe(p), 0);
|
||||
size_t wire_len = 0;
|
||||
unsigned long long check_size = 0;
|
||||
long long check_mtime = 0;
|
||||
long long check_mtime_nsec = 0;
|
||||
EXPECT_TRUE(send_n_data(p[1], &wire_len, sizeof(wire_len)));
|
||||
EXPECT_TRUE(send_n_data(p[1], &check_size, sizeof(check_size)));
|
||||
EXPECT_TRUE(send_n_data(p[1], &check_mtime, sizeof(check_mtime)));
|
||||
EXPECT_TRUE(send_n_data(p[1], &check_mtime_nsec, sizeof(check_mtime_nsec)));
|
||||
|
||||
bool skipped = true;
|
||||
const File* file = receive_incremental_check(p[0], cfg, &skipped);
|
||||
EXPECT_NULL(file);
|
||||
EXPECT_FALSE(skipped);
|
||||
|
||||
close(p[0]);
|
||||
close(p[1]);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* -K/--keep-dirlinks secure open: with an authorized root, a destination path
|
||||
* component that is a symlink to an IN-ROOT directory is used as that directory
|
||||
* (its referent is opened through a relative O_NOFOLLOW walk from the root fd,
|
||||
@@ -1531,6 +1601,8 @@ void test_file() {
|
||||
}
|
||||
test_file_metadata_create();
|
||||
test_dir_time_list();
|
||||
test_dir_time_list_cap();
|
||||
test_receive_incremental_check_empty_path();
|
||||
test_keep_dirlinks_secure_open();
|
||||
test_inplace_overwrite_clears_special_mode_bits();
|
||||
test_inplace_overwrite_metadata_strips_special_bits();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#include "test_log.h"
|
||||
#include "log.h"
|
||||
#include "test_utils.h"
|
||||
#include <fcntl.h>
|
||||
#include <string.h>
|
||||
#include <threads.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* Test default log level: WARNING and ERROR should print, DEBUG and INFO should not.
|
||||
@@ -147,6 +149,118 @@ static void test_log_debug_enabled_matches_gate() {
|
||||
set_log_debug_flags(LOG_DEBUG_ALL);
|
||||
}
|
||||
|
||||
#define LOG_CONCURRENCY_THREADS 8
|
||||
#define LOG_CONCURRENCY_LINES 250
|
||||
|
||||
typedef struct {
|
||||
int id;
|
||||
} LogConcurrencyArg;
|
||||
|
||||
static int log_concurrency_worker(void* context) {
|
||||
LogConcurrencyArg* arg = context;
|
||||
for (int i = 0; i < LOG_CONCURRENCY_LINES; i++) {
|
||||
log_message(LOG_LEVEL_WARNING, "worker %d line %d", arg->id, i);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int count_substring(const char* haystack, const char* needle) {
|
||||
int count = 0;
|
||||
size_t needle_length = strlen(needle);
|
||||
const char* cursor = haystack;
|
||||
while ((cursor = strstr(cursor, needle)) != NULL) {
|
||||
count++;
|
||||
cursor += needle_length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/* Concurrent log_message() calls from many threads must never interleave a
|
||||
* single line: every emitted line has exactly one timestamp prefix and one
|
||||
* body. Before write_message() was serialized, the three separate fprintf
|
||||
* calls (prefix, body, newline) let lines tear. */
|
||||
static void test_log_concurrent_no_torn_lines(void) {
|
||||
FILE* fp = tmpfile();
|
||||
EXPECT_NOT_NULL(fp);
|
||||
|
||||
/* Mute the console mirror so the workers don't flood the test output. */
|
||||
fflush(stdout);
|
||||
fflush(stderr);
|
||||
int saved_stdout = dup(STDOUT_FILENO);
|
||||
int saved_stderr = dup(STDERR_FILENO);
|
||||
int null_fd = open("/dev/null", O_WRONLY);
|
||||
EXPECT_TRUE(saved_stdout >= 0);
|
||||
EXPECT_TRUE(saved_stderr >= 0);
|
||||
EXPECT_TRUE(null_fd >= 0);
|
||||
EXPECT_TRUE(dup2(null_fd, STDOUT_FILENO) >= 0);
|
||||
EXPECT_TRUE(dup2(null_fd, STDERR_FILENO) >= 0);
|
||||
close(null_fd);
|
||||
|
||||
set_log_level(LOG_LEVEL_WARNING);
|
||||
log_set_stderr_mode(LOG_STDERR_ERRORS);
|
||||
log_set_file(fp);
|
||||
|
||||
thrd_t threads[LOG_CONCURRENCY_THREADS];
|
||||
LogConcurrencyArg args[LOG_CONCURRENCY_THREADS];
|
||||
int created = 0;
|
||||
for (int i = 0; i < LOG_CONCURRENCY_THREADS; i++) {
|
||||
args[i].id = i;
|
||||
if (thrd_create(&threads[i], log_concurrency_worker, &args[i]) != thrd_success)
|
||||
break;
|
||||
created++;
|
||||
}
|
||||
for (int i = 0; i < created; i++) {
|
||||
thrd_join(threads[i], NULL);
|
||||
}
|
||||
|
||||
log_set_file(NULL);
|
||||
fflush(fp);
|
||||
|
||||
fflush(stdout);
|
||||
fflush(stderr);
|
||||
dup2(saved_stdout, STDOUT_FILENO);
|
||||
dup2(saved_stderr, STDERR_FILENO);
|
||||
close(saved_stdout);
|
||||
close(saved_stderr);
|
||||
|
||||
rewind(fp);
|
||||
char line[512];
|
||||
int total_lines = 0;
|
||||
int malformed_lines = 0;
|
||||
bool saw_missing_newline = false;
|
||||
while (fgets(line, sizeof(line), fp) != NULL) {
|
||||
size_t length = strlen(line);
|
||||
if (length == 0 || line[length - 1] != '\n')
|
||||
saw_missing_newline = true;
|
||||
if (strncmp(line, "20", 2) != 0 || count_substring(line, "[WARN]: worker ") != 1)
|
||||
malformed_lines++;
|
||||
total_lines++;
|
||||
}
|
||||
fclose(fp);
|
||||
|
||||
EXPECT_EQ_INT(created, LOG_CONCURRENCY_THREADS);
|
||||
EXPECT_FALSE(saw_missing_newline);
|
||||
EXPECT_EQ_INT(malformed_lines, 0);
|
||||
EXPECT_EQ_INT(total_lines, LOG_CONCURRENCY_THREADS * LOG_CONCURRENCY_LINES);
|
||||
log_set_stderr_mode(LOG_STDERR_ERRORS);
|
||||
}
|
||||
|
||||
/* Detaching the logger from a FILE* before it is closed must leave the logging
|
||||
* subsystem safe: later calls must not touch the freed handle. */
|
||||
static void test_log_set_file_null_before_fclose(void) {
|
||||
FILE* fp = tmpfile();
|
||||
EXPECT_NOT_NULL(fp);
|
||||
|
||||
set_log_level(LOG_LEVEL_ERROR);
|
||||
log_set_file(fp);
|
||||
log_message(LOG_LEVEL_ERROR, "line before detach");
|
||||
log_set_file(NULL);
|
||||
fclose(fp);
|
||||
|
||||
log_message(LOG_LEVEL_ERROR, "line after close");
|
||||
EXPECT_TRUE(true);
|
||||
}
|
||||
|
||||
void test_log() {
|
||||
test_log_message_debug();
|
||||
test_log_message_info();
|
||||
@@ -159,4 +273,6 @@ void test_log() {
|
||||
test_log_stderr_mode_all();
|
||||
test_log_message_formats();
|
||||
test_log_debug_enabled_matches_gate();
|
||||
test_log_concurrent_no_torn_lines();
|
||||
test_log_set_file_null_before_fclose();
|
||||
}
|
||||
@@ -1317,6 +1317,58 @@ static void test_scanner_captures_directory_times() {
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
/* Ownership guard for chunk_data_to_chunk(): a returned Chunk owns its File
|
||||
* objects, so destroying the chunk must free them exactly once and the scanner
|
||||
* must never free them again. chunk_size = 1 forces the mid-directory
|
||||
* conversion branch (chunk_data_size > chunk_size) for every file, and the
|
||||
* chunk is destroyed immediately, catching a double free / use-after-free under
|
||||
* ASan if ownership transfer regressed.
|
||||
*
|
||||
* The failure path (array_list_to_array() or chunk_create() returning NULL) is
|
||||
* not reachable from a unit test: both allocate through protocol_alloc(), and
|
||||
* each allocation they perform is no larger than the array_list allocations
|
||||
* that already succeeded while building the list (array_list_to_array() copies
|
||||
* exactly `size` pointers, which never exceeds the capacity just grown, and
|
||||
* sizeof(Chunk) is far below the initial 100-entry item array). Binding a
|
||||
* small --max-alloc session therefore always fails *before* this function, not
|
||||
* inside it, so fault injection cannot isolate these paths. */
|
||||
static void test_scanner_chunk_ownership() {
|
||||
const char* dir = "test_scan_ownership";
|
||||
const char* file1 = "test_scan_ownership/a.txt";
|
||||
const char* file2 = "test_scan_ownership/b.txt";
|
||||
const char* file3 = "test_scan_ownership/c.txt";
|
||||
|
||||
EXPECT_EQ_INT(mkdir(dir, 0755), 0);
|
||||
create_test_file(file1, "aaaa");
|
||||
create_test_file(file2, "bbbb");
|
||||
create_test_file(file3, "cccc");
|
||||
|
||||
ScannerOptions options = {0};
|
||||
options.chunk_size = 1;
|
||||
DirectoryScanner* scanner = directory_scanner_create_with_options(dir, &options);
|
||||
EXPECT_NOT_NULL(scanner);
|
||||
|
||||
int chunks = 0;
|
||||
int files = 0;
|
||||
Chunk* chunk;
|
||||
while ((chunk = directory_scanner_next(scanner)) != NULL) {
|
||||
chunks++;
|
||||
files += chunk->element_count;
|
||||
EXPECT_EQ_INT(chunk->element_count, 1);
|
||||
chunk_destroy(chunk);
|
||||
EXPECT_FALSE(directory_scanner_failed(scanner));
|
||||
}
|
||||
EXPECT_EQ_INT(files, 3);
|
||||
EXPECT_EQ_INT(chunks, 3);
|
||||
EXPECT_FALSE(directory_scanner_failed(scanner));
|
||||
|
||||
directory_scanner_destroy(scanner);
|
||||
unlink(file1);
|
||||
unlink(file2);
|
||||
unlink(file3);
|
||||
rmdir(dir);
|
||||
}
|
||||
|
||||
void test_scanner() {
|
||||
test_scanner_single_file();
|
||||
test_scanner_multiple_files();
|
||||
@@ -1353,4 +1405,5 @@ void test_scanner() {
|
||||
test_dirs_files_from();
|
||||
test_files_from_relative_send_path();
|
||||
test_scanner_captures_directory_times();
|
||||
test_scanner_chunk_ownership();
|
||||
}
|
||||
Reference in new issue
Block a user