Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20a26e2f5a |
@@ -1,10 +0,0 @@
|
||||
BasedOnStyle: LLVM
|
||||
IndentWidth: 2
|
||||
ColumnLimit: 100
|
||||
PointerAlignment: Left
|
||||
AllowShortFunctionsOnASingleLine: None
|
||||
SortIncludes: false
|
||||
AllowShortIfStatementsOnASingleLine: false
|
||||
AllowShortLoopsOnASingleLine: false
|
||||
BinPackArguments: true
|
||||
BinPackParameters: true
|
||||
+39
-103
@@ -1,137 +1,73 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
container: gitea.tap-tap.win/taptap/fastsync-ci:v10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: clang-format check
|
||||
run: find src/ tests/ -name '*.c' -o -name '*.h' | xargs clang-format --dry-run --Werror
|
||||
|
||||
- name: cppcheck
|
||||
run: cppcheck --enable=warning,style,performance,portability --suppress=missingIncludeSystem --error-exitcode=1 --inline-suppr src/ tests/
|
||||
|
||||
# Fast PR gate: build + unit tests + a representative subset of integration
|
||||
# tests (marked `ci`), parallelized with pytest-xdist. Only the full coverage
|
||||
# jobs below (sanitizers/fuzz/coverage/valgrind and the FULL integration
|
||||
# suite) run on merge to dev/main, so PR CI stays well under ~3 minutes.
|
||||
build-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
container: gitea.tap-tap.win/taptap/fastsync-ci:v10
|
||||
needs: lint
|
||||
container: gitea.tap-tap.win/taptap/fastsync-ci:v6
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure
|
||||
run: cmake -B build -S . -DSTRICT_WARNINGS=ON
|
||||
run: cmake -B build -S .
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build -j$(nproc)
|
||||
|
||||
- name: Unit Tests
|
||||
run: ctest --test-dir build --output-on-failure -j$(nproc)
|
||||
run: ./build/tests
|
||||
|
||||
- name: Integration Tests (PR smoke subset)
|
||||
if: github.event_name == 'pull_request'
|
||||
run: python3 -m pytest tests/integration/ -n 4 --dist=load -m ci --durations=25 --tb=short -q
|
||||
- name: Integration Tests
|
||||
run: python3 -m pytest tests/ -v --tb=short
|
||||
|
||||
- name: Integration Tests (full suite)
|
||||
if: github.event_name == 'push'
|
||||
run: python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv" --durations=25 --tb=short -q
|
||||
|
||||
sanitizers:
|
||||
sanitizer:
|
||||
runs-on: ubuntu-latest
|
||||
container: gitea.tap-tap.win/taptap/fastsync-ci:v10
|
||||
needs: lint
|
||||
if: github.event_name == 'push'
|
||||
strategy:
|
||||
matrix:
|
||||
sanitizer: [address, undefined]
|
||||
container: gitea.tap-tap.win/taptap/fastsync-ci:v6
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure
|
||||
run: cmake -B build-${{ matrix.sanitizer }} -S . -DSANITIZER=${{ matrix.sanitizer }}
|
||||
- name: Configure with ASan + UBSan
|
||||
run: >
|
||||
cmake -B build -S .
|
||||
-DCMAKE_C_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g"
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined"
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build-${{ matrix.sanitizer }} -j$(nproc)
|
||||
run: cmake --build build -j$(nproc)
|
||||
|
||||
- name: Unit Tests
|
||||
run: ctest --test-dir build-${{ matrix.sanitizer }} --output-on-failure
|
||||
run: ./build/tests
|
||||
|
||||
fuzz-build:
|
||||
- name: Integration Tests
|
||||
run: python3 -m pytest tests/ -v --tb=short
|
||||
|
||||
clang-tidy:
|
||||
runs-on: ubuntu-latest
|
||||
container: gitea.tap-tap.win/taptap/fastsync-ci:v10
|
||||
needs: lint
|
||||
if: github.event_name == 'push'
|
||||
container: gitea.tap-tap.win/taptap/fastsync-ci:v6
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure (clang + fuzz)
|
||||
run: CC=clang CXX=clang++ cmake -B build-fuzz -S . -DENABLE_FUZZ=ON
|
||||
|
||||
- name: Build fuzz targets
|
||||
run: cmake --build build-fuzz -j$(nproc)
|
||||
|
||||
- name: Smoke fuzz targets
|
||||
- name: Install clang-tidy
|
||||
run: |
|
||||
for target in build-fuzz/fuzz_*; do
|
||||
[ -x "$target" ] || continue
|
||||
timeout 10s "$target" -runs=100 -max_total_time=5
|
||||
done
|
||||
apt-get update && apt-get install -y clang-tidy 2>/dev/null || \
|
||||
echo "::warning title=clang-tidy-skip::clang-tidy not available in container, skipping static analysis"
|
||||
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
container: gitea.tap-tap.win/taptap/fastsync-ci:v10
|
||||
needs: lint
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- name: Configure (generate compile_commands.json)
|
||||
run: cmake -B build -S .
|
||||
|
||||
- name: Configure
|
||||
run: cmake -B build -S . -DENABLE_COVERAGE=ON
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build -j$(nproc)
|
||||
|
||||
- name: Unit Tests
|
||||
run: ctest --test-dir build --output-on-failure
|
||||
|
||||
- name: Coverage Report
|
||||
- name: Run clang-tidy
|
||||
run: |
|
||||
lcov --capture --directory build --output-file coverage.info --branch-coverage --ignore-errors negative
|
||||
lcov --remove coverage.info '/usr/*' '*/tests/*' '*/_deps/*' --output-file coverage.info --branch-coverage --ignore-errors unused,negative
|
||||
lcov --list coverage.info
|
||||
|
||||
valgrind:
|
||||
runs-on: ubuntu-latest
|
||||
container: gitea.tap-tap.win/taptap/fastsync-ci:v10
|
||||
needs: lint
|
||||
if: github.event_name == 'push'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Configure
|
||||
run: cmake -B build -S . -DSTRICT_WARNINGS=ON
|
||||
|
||||
- name: Build
|
||||
run: cmake --build build -j$(nproc)
|
||||
|
||||
- name: Valgrind Memcheck
|
||||
run: valgrind --leak-check=full --show-leak-kinds=definite --error-exitcode=1 ./build/tests
|
||||
env:
|
||||
FASTSYNC_UNDER_VALGRIND: "1"
|
||||
if ! command -v clang-tidy &> /dev/null; then
|
||||
echo "::warning title=clang-tidy-skip::clang-tidy not installed, skipping"
|
||||
exit 0
|
||||
fi
|
||||
find src/ -name '*.c' | xargs clang-tidy -p build \
|
||||
--checks='-*,bugprone-*,clang-analyzer-*,misc-*,-misc-no-recursion' \
|
||||
2>&1 | tee clang-tidy-output.txt
|
||||
if grep -q -E " error:| warning:" clang-tidy-output.txt; then
|
||||
echo "clang-tidy found issues — review the output above"
|
||||
fi
|
||||
|
||||
-10
@@ -2,13 +2,3 @@ build
|
||||
data_copied
|
||||
test_data/
|
||||
__pycache__/
|
||||
build-asan
|
||||
coverage.info
|
||||
build-*/
|
||||
build2/
|
||||
build3/
|
||||
build_docker2/
|
||||
|
||||
# Test/run artifacts
|
||||
root/
|
||||
test_partial_install_tmp/
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# LSAN suppressions for FastSync
|
||||
# Add suppression entries here for known pre-existing leaks that cannot be
|
||||
# fixed immediately. Remove entries as leaks are fixed.
|
||||
#
|
||||
# Example format:
|
||||
# leak:function_name
|
||||
@@ -9,8 +9,6 @@ You are a system architect for the FastSync project — a high-performance file
|
||||
|
||||
Make high-level design decisions. Evaluate trade-offs, plan module interactions, design data flow, and ensure architectural coherence across the codebase.
|
||||
|
||||
> **Environment rule:** for CI, dependency installation must use the project's custom Docker image (repo-root `Dockerfile`, same as CI). For local development, use `nix-shell` (see `README.md`). See `AGENTS.md`.
|
||||
|
||||
## Project Architecture
|
||||
|
||||
### Module Map
|
||||
@@ -112,24 +110,3 @@ When proposing architecture changes:
|
||||
- Hardcoded constants that should be configurable
|
||||
- Missing error propagation (silent failures)
|
||||
- Thread safety violations when adding new shared state
|
||||
|
||||
## CI & Task Execution
|
||||
|
||||
**Always wait for CI to finish after every push.** Never report a task as complete or move on until CI has passed on the PR branch.
|
||||
|
||||
After every push:
|
||||
1. Use `tea actions runs list` to get the latest run ID for the branch.
|
||||
2. Poll its status until it leaves the "running" state (use a loop with sleep + sufficient timeout, e.g., 600000ms).
|
||||
3. Once completed, inspect the logs with `tea actions runs log <ID>` for every job.
|
||||
4. If any job failed, fix the issue, push again, and repeat from step 1.
|
||||
5. Only report done when ALL CI jobs pass.
|
||||
|
||||
Do not wait for the user to tell you CI failed — check proactively. The user should never have to inform you of a CI failure you could have caught yourself.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
Never push directly to `dev` or `main`. All changes must be developed on a feature branch and merged via a pull request targeting `dev`. Create a branch (`git checkout -b <branch-name>`), push it, and open the PR with `tea pr create --repo TapTap/FastSync --base dev --head <branch-name>`. Wait for CI to pass before merging.
|
||||
|
||||
## Dependency Installation
|
||||
|
||||
**CI rule:** never add `apt-get install` / `pip install` steps to CI workflows — use the custom Docker image instead. **Host rule:** for local development, use `nix-shell` (see `README.md`) which provides zstd, OpenSSL, CMake, and gcc. See `AGENTS.md` for details.
|
||||
|
||||
@@ -85,15 +85,3 @@ For each issue found, report:
|
||||
4. **Description** — what's wrong and how to fix it
|
||||
|
||||
If the code is clean, say so explicitly. Be concise — don't pad with fluff.
|
||||
|
||||
## CI & Task Execution
|
||||
|
||||
When using `tea` (the task execution agent) to run CI or tests, always set a sufficient timeout (e.g., 600000ms) to allow the workflow to finish. After CI completes, check the results yourself — inspect logs if the run failed. Never assume success.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
Never push directly to `dev` or `main`. All changes must be developed on a feature branch and merged via a pull request targeting `dev`. Create a branch (`git checkout -b <branch-name>`), push it, and open the PR with `tea pr create --repo TapTap/FastSync --base dev --head <branch-name>`. Wait for CI to pass before merging.
|
||||
|
||||
## Dependency Installation
|
||||
|
||||
**CI rule:** never add `apt-get install` / `pip install` steps to CI workflows — use the custom Docker image instead. **Host rule:** for local development, use `nix-shell` (see `README.md`) which provides zstd, OpenSSL, CMake, and gcc. See `AGENTS.md` for details.
|
||||
|
||||
@@ -21,32 +21,18 @@ set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
|
||||
add_compile_options(-Wall -g -O3)
|
||||
# add_compile_options(-Wall -g -O1 -fsanitize=address)
|
||||
# add_link_options(-fsanitize=address)
|
||||
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(xxhash GIT_REPOSITORY https://github.com/Cyan4973/xxHash GIT_TAG v0.8.3 SOURCE_SUBDIR cmake_unofficial)
|
||||
FetchContent_Declare(
|
||||
xxhash
|
||||
GIT_REPOSITORY https://github.com/Cyan4973/xxHash
|
||||
GIT_TAG v0.8.3
|
||||
SOURCE_SUBDIR cmake_unofficial
|
||||
)
|
||||
FetchContent_MakeAvailable(xxhash)
|
||||
|
||||
# Sanitizer option
|
||||
set(SANITIZER "none" CACHE STRING "Sanitizer to enable (address, thread, undefined, none)")
|
||||
set_property(CACHE SANITIZER PROPERTY STRINGS address thread undefined none)
|
||||
if(SANITIZER STREQUAL "address")
|
||||
add_compile_options(-fsanitize=address -fno-omit-frame-pointer -g)
|
||||
add_link_options(-fsanitize=address)
|
||||
elseif(SANITIZER STREQUAL "thread")
|
||||
add_compile_options(-fsanitize=thread -fno-omit-frame-pointer -g)
|
||||
add_link_options(-fsanitize=thread)
|
||||
elseif(SANITIZER STREQUAL "undefined")
|
||||
add_compile_options(-fsanitize=undefined -fno-omit-frame-pointer -g)
|
||||
add_link_options(-fsanitize=undefined)
|
||||
elseif(NOT SANITIZER STREQUAL "none")
|
||||
message(FATAL_ERROR "Unknown sanitizer: ${SANITIZER}. Supported values: address, thread, undefined, none")
|
||||
endif()
|
||||
|
||||
option(STRICT_WARNINGS "Enable strict warnings" OFF)
|
||||
if(STRICT_WARNINGS)
|
||||
add_compile_options(-Wextra -Wpedantic -Werror)
|
||||
endif()
|
||||
|
||||
set(THREADS_PREFER_PTHREAD_FLAG ON)
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
@@ -57,11 +43,13 @@ endif()
|
||||
|
||||
find_package(OpenSSL REQUIRED)
|
||||
|
||||
# Source file collection
|
||||
file(GLOB SHARED_SRCS "src/shared/*.c")
|
||||
file(GLOB SERVER_SRCS "src/server/*.c")
|
||||
file(GLOB CLIENT_SRCS "src/client/*.c")
|
||||
file(GLOB TEST_SRCS "tests/*.c")
|
||||
|
||||
# Targets
|
||||
add_executable(server ${SERVER_SRCS} ${SHARED_SRCS})
|
||||
target_include_directories(server PRIVATE src/shared src/server src/client)
|
||||
target_link_libraries(server PRIVATE Threads::Threads ${ZSTD_LIBRARY} OpenSSL::SSL OpenSSL::Crypto xxhash)
|
||||
@@ -87,7 +75,7 @@ tests/integration/ — Python pytest integration tests
|
||||
### Dependencies
|
||||
- **zstd** — found via `find_library(ZSTD_LIBRARY zstd)`
|
||||
- **OpenSSL** — found via `find_package(OpenSSL REQUIRED)` (TLS 1.2+ transport)
|
||||
- **xxHash** — fetched via `FetchContent` from the upstream repository (delta transfer hashing, v0.8.3)
|
||||
- **xxHash** — fetched via `FetchContent` from GitHub (delta transfer hashing)
|
||||
- **pthreads** — found via `find_package(Threads REQUIRED)`
|
||||
- **C11 standard** — required
|
||||
- **CMake 3.22+** — minimum version
|
||||
@@ -95,11 +83,10 @@ tests/integration/ — Python pytest integration tests
|
||||
## Conventions
|
||||
|
||||
- Use `file(GLOB ...)` for source collection (existing pattern).
|
||||
- All targets link `Threads::Threads`, `${ZSTD_LIBRARY}`, `OpenSSL::SSL`, `OpenSSL::Crypto`, and `xxhash`.
|
||||
- All targets link `Threads::Threads` and `${ZSTD_LIBRARY}`.
|
||||
- Include directories: `src/shared`, `src/server`, `src/client`, `tests` (for test target).
|
||||
- Sanitizer support: pass `-DSANITIZER=address`, `-DSANITIZER=thread`, or `-DSANITIZER=undefined` to cmake (live option in CMakeLists.txt).
|
||||
- Sanitizer support is commented out but present (`-fsanitize=address`).
|
||||
- Build with `cmake -B build -S . && cmake --build build -j$(nproc)`.
|
||||
- For CI, dependencies are provided by the project's custom Docker image (repo-root `Dockerfile`, same image CI uses). For local development, use `nix-shell`. Never add `apt-get install` / `pip install` to CI workflows. See `AGENTS.md`.
|
||||
|
||||
## When Making Changes
|
||||
|
||||
@@ -108,23 +95,40 @@ tests/integration/ — Python pytest integration tests
|
||||
3. Add new dependencies with `find_package` or `find_library`.
|
||||
4. When adding a new executable target, follow the pattern of existing targets.
|
||||
5. When adding a new library (static/shared), use `add_library` and follow the project's naming.
|
||||
6. For sanitizer builds, pass `-DSANITIZER=address`, `-DSANITIZER=thread`, or `-DSANITIZER=undefined` to cmake (matching CI's matrix strategy).
|
||||
6. For sanitizer builds, use the commented-out `-fsanitize=address` lines as reference.
|
||||
7. Always verify the build compiles after changes.
|
||||
|
||||
## Sanitizer Configurations
|
||||
|
||||
Use the project's built-in `-DSANITIZER=` option (matching the CI matrix):
|
||||
### AddressSanitizer (memory errors)
|
||||
```bash
|
||||
cmake -B build -S . -DSANITIZER=address # AddressSanitizer (memory errors)
|
||||
cmake --build build -j$(nproc)
|
||||
|
||||
cmake -B build -S . -DSANITIZER=thread # ThreadSanitizer (race conditions)
|
||||
cmake -B build -S . \
|
||||
-DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address"
|
||||
cmake --build build -j$(nproc)
|
||||
```
|
||||
|
||||
UndefinedBehaviorSanitizer uses the same built-in option:
|
||||
### ThreadSanitizer (race conditions)
|
||||
```bash
|
||||
cmake -B build -S . -DSANITIZER=undefined
|
||||
cmake -B build -S . \
|
||||
-DCMAKE_C_FLAGS="-fsanitize=thread -g" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=thread"
|
||||
cmake --build build -j$(nproc)
|
||||
```
|
||||
|
||||
### UndefinedBehaviorSanitizer
|
||||
```bash
|
||||
cmake -B build -S . \
|
||||
-DCMAKE_C_FLAGS="-fsanitize=undefined -fno-omit-frame-pointer -g" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=undefined"
|
||||
cmake --build build -j$(nproc)
|
||||
```
|
||||
|
||||
### Combined Sanitizers
|
||||
```bash
|
||||
cmake -B build -S . \
|
||||
-DCMAKE_C_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined"
|
||||
cmake --build build -j$(nproc)
|
||||
```
|
||||
|
||||
@@ -160,36 +164,36 @@ cmake -B build -S . -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
||||
```bash
|
||||
cmake -B build -S .
|
||||
cmake --build build -j$(nproc)
|
||||
./build/server -p 8080 --allow-unauthenticated
|
||||
./build/server
|
||||
./build/client
|
||||
./build/tests
|
||||
```
|
||||
|
||||
## Sanitizer Integration
|
||||
## When Adding Sanitizer Support to CMakeLists.txt
|
||||
|
||||
The project uses a single `SANITIZER` cache variable in `CMakeLists.txt`:
|
||||
Use CMake options for cleaner integration:
|
||||
```cmake
|
||||
set(SANITIZER "none" CACHE STRING "Sanitizer to enable (address, thread, none)")
|
||||
set_property(CACHE SANITIZER PROPERTY STRINGS address thread none)
|
||||
```
|
||||
Supported values: `address`, `thread`, `none`. Unknown values trigger `FATAL_ERROR`.
|
||||
option(ENABLE_ASAN "Enable AddressSanitizer" OFF)
|
||||
option(ENABLE_TSAN "Enable ThreadSanitizer" OFF)
|
||||
option(ENABLE_UBSAN "Enable UndefinedBehaviorSanitizer" OFF)
|
||||
|
||||
Build with:
|
||||
if(ENABLE_ASAN)
|
||||
add_compile_options(-fsanitize=address -fno-omit-frame-pointer)
|
||||
add_link_options(-fsanitize=address)
|
||||
endif()
|
||||
|
||||
if(ENABLE_TSAN)
|
||||
add_compile_options(-fsanitize=thread)
|
||||
add_link_options(-fsanitize=thread)
|
||||
endif()
|
||||
|
||||
if(ENABLE_UBSAN)
|
||||
add_compile_options(-fsanitize=undefined)
|
||||
add_link_options(-fsanitize=undefined)
|
||||
endif()
|
||||
```
|
||||
|
||||
Then build with:
|
||||
```bash
|
||||
cmake -B build -S . -DSANITIZER=address
|
||||
cmake --build build -j$(nproc)
|
||||
cmake -B build -S . -DENABLE_ASAN=ON
|
||||
```
|
||||
|
||||
To add support for a new sanitizer (e.g., UBSan), add an `elseif(SANITIZER STREQUAL "undefined")` block following the existing `address`/`thread` pattern.
|
||||
|
||||
## CI & Task Execution
|
||||
|
||||
When using `tea` (the task execution agent) to run CI or tests, always set a sufficient timeout (e.g., 600000ms) to allow the workflow to finish. After CI completes, check the results yourself — inspect logs if the run failed. Never assume success.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
Never push directly to `dev` or `main`. All changes must be developed on a feature branch and merged via a pull request targeting `dev`. Create a branch (`git checkout -b <branch-name>`), push it, and open the PR with `tea pr create --repo TapTap/FastSync --base dev --head <branch-name>`. Wait for CI to pass before merging.
|
||||
|
||||
## Dependency Installation
|
||||
|
||||
**CI rule:** never add `apt-get install` / `pip install` steps to CI workflows — use the custom Docker image instead. **Host rule:** for local development, use `nix-shell` (see `README.md`) which provides zstd, OpenSSL, CMake, and gcc. See `AGENTS.md` for details.
|
||||
|
||||
@@ -27,7 +27,7 @@ FastSync is a file synchronization tool (like rsync, but faster). It transfers f
|
||||
cmake -B build -S . && cmake --build build -j$(nproc)
|
||||
|
||||
# Server (TCP mode)
|
||||
./build/server -p 8080 --allow-unauthenticated
|
||||
./build/server
|
||||
|
||||
# Client (TCP mode)
|
||||
./build/client --source-dir /path/to/send --dest-dir /path/to/receive --save-to-disk
|
||||
@@ -37,13 +37,13 @@ cmake -B build -S . && cmake --build build -j$(nproc)
|
||||
|
||||
# Run tests
|
||||
./build/tests # unit tests
|
||||
python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv" # integration tests
|
||||
python3 test.py # integration tests
|
||||
```
|
||||
|
||||
## Code Walkthrough
|
||||
|
||||
### Client Entry Point (`src/client/client_cli.c`)
|
||||
- Parses CLI arguments using a custom option-table parser (`OPTION_TABLE` in `src/client/client_cli.c`); there is no `getopt*` usage
|
||||
- Parses CLI arguments using `getopt_long`
|
||||
- Creates `Config` struct with all options
|
||||
- Detects SSH destinations (contains `:`)
|
||||
- Calls into `client_send.c` for the actual transfer
|
||||
@@ -109,7 +109,7 @@ Collection of files for batch transfer. Serialized with file count, then per-fil
|
||||
zstd streaming compression via `ZSTD_compressStream2`/`ZSTD_decompressStream`. Compression happens per-chunk in the sender stage. Level 1-22 (default 5). Streaming means memory usage stays bounded regardless of file size.
|
||||
|
||||
### "How does sendfile() work?"
|
||||
On Linux, `sendfile()` copies data directly from kernel file buffer to socket, bypassing userspace. ~2x faster for large files. Enabled with `--sendfile` (long form only). Only works with TCP (not SSH, not compression).
|
||||
On Linux, `sendfile()` copies data directly from kernel file buffer to socket, bypassing userspace. ~2x faster for large files. Enabled with `-f` flag. Only works with TCP (not SSH, not compression).
|
||||
|
||||
### "How does incremental sync work?"
|
||||
Client sends file metadata (path, size, mtime) to server. Server checks if destination file has same size+mtime. If match, server responds `STATUS_OK` (skip). If mismatch, server responds `STATUS_NEXT` (send).
|
||||
@@ -131,15 +131,3 @@ When explaining code:
|
||||
3. **Highlight non-obvious parts** — why this design, not that
|
||||
4. **Reference the source** — `file:line` for key functions
|
||||
5. **Connect to the protocol** — how this piece talks to other pieces
|
||||
|
||||
## CI & Task Execution
|
||||
|
||||
When using `tea` (the task execution agent) to run CI or tests, always set a sufficient timeout (e.g., 600000ms) to allow the workflow to finish. After CI completes, check the results yourself — inspect logs if the run failed. Never assume success.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
Never push directly to `dev` or `main`. All changes must be developed on a feature branch and merged via a pull request targeting `dev`. Create a branch (`git checkout -b <branch-name>`), push it, and open the PR with `tea pr create --repo TapTap/FastSync --base dev --head <branch-name>`. Wait for CI to pass before merging.
|
||||
|
||||
## Dependency Installation
|
||||
|
||||
**CI rule:** never add `apt-get install` / `pip install` steps to CI workflows — use the custom Docker image instead. **Host rule:** for local development, use `nix-shell` (see `README.md`) which provides zstd, OpenSSL, CMake, and gcc. See `AGENTS.md` for details.
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
---
|
||||
description: Scans the FastSync codebase for code quality issues — god functions, duplication, cyclomatic complexity, error handling gaps, naming/style violations.
|
||||
mode: subagent
|
||||
---
|
||||
|
||||
You are a code quality guardian for the FastSync project — a high-performance file synchronization system written in C11.
|
||||
|
||||
## Your Role
|
||||
|
||||
Scan the codebase for code quality improvements. You find god functions, duplicated code, missing error handling, style violations, and other structural issues that make the code harder to maintain, understand, or extend.
|
||||
|
||||
> **Environment rule:** for CI, dependency installation must use the project's custom Docker image (repo-root `Dockerfile`, same as CI). For local development, use `nix-shell` (see `README.md`). See `AGENTS.md`.
|
||||
|
||||
## Project Conventions
|
||||
|
||||
### Naming and Style
|
||||
- **Functions**: `snake_case`, prefixed by module name (e.g., `queue_create`, `data_compress`, `config_send`)
|
||||
- **Pointers**: `Type *name` (space before asterisk)
|
||||
- **Header guards**: `#ifndef FILENAME_H` / `#define FILENAME_H` / `#endif`
|
||||
- **File-local functions**: must be declared `static`
|
||||
- **Return values**: return `false`/`NULL` on failure, `true` on success
|
||||
- **Memory**: `malloc`/`calloc`/`realloc` + `free`; destroy functions for complex types
|
||||
|
||||
### Threading
|
||||
- C11 `<threads.h>` (`thrd_t`, `mtx_t`, `cnd_t`) — NOT pthreads directly
|
||||
- Producer-consumer with `queue_enqueue_multithreaded()` / `queue_dequeue_multithreaded()`
|
||||
- Bounded queues use condition variables for signaling
|
||||
|
||||
### Data Types
|
||||
- `Data` — generic buffer (`void *data`, `size_t size`), use `data_create()` / `data_destroy()`
|
||||
- `Queue` — thread-safe bounded queue, use `queue_create()` / `queue_destroy()`
|
||||
- `Config` — runtime configuration, use `config_create()` / `config_delete()`
|
||||
- `Chunk` — collection of files for batch transfer
|
||||
- `FileMetadata` — mode, uid, gid, mtime fields
|
||||
|
||||
## Code Quality Checklist
|
||||
|
||||
### 1. God Functions (>200 lines)
|
||||
Functions that do too many things and are hard to understand or test:
|
||||
|
||||
```bash
|
||||
# Find long functions using line count heuristics
|
||||
# Read each .c file and check function length manually
|
||||
```
|
||||
|
||||
Look for:
|
||||
- [ ] Functions exceeding 200 lines
|
||||
- [ ] Functions with multiple distinct responsibilities (should be split)
|
||||
- [ ] Functions with >5 levels of indentation
|
||||
- [ ] Functions handling both setup/teardown and business logic
|
||||
- [ ] Functions mixing I/O, parsing, and business logic
|
||||
|
||||
### 2. Deeply Nested Conditionals (Cyclomatic Complexity)
|
||||
- [ ] If-else chains deeper than 4 levels
|
||||
```c
|
||||
if (a) {
|
||||
if (b) {
|
||||
if (c) {
|
||||
if (d) {
|
||||
// too deep
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] Switch statements with many cases that could be replaced by lookup tables
|
||||
- [ ] Complex ternary expressions nested inside other expressions
|
||||
- [ ] Loop inside conditional inside loop (deep nesting)
|
||||
- [ ] Functions with many `if-return` early exits that obscure flow
|
||||
|
||||
### 3. Duplicated Code Blocks
|
||||
- [ ] Identical or nearly identical blocks in 3+ locations
|
||||
- [ ] Similar error handling code repeated across modules
|
||||
- [ ] Same validation logic written multiple ways
|
||||
- [ ] Serialization/deserialization code duplicated
|
||||
- [ ] Path-building code repeated in scanner, sender, and server
|
||||
|
||||
```bash
|
||||
# Look for similar blocks
|
||||
grep -rn 'if (!send_n_data' src/ --include="*.c"
|
||||
grep -rn 'if (!receive_n_data' src/ --include="*.c"
|
||||
grep -rn 'snprintf.*path' src/ --include="*.c"
|
||||
```
|
||||
|
||||
### 4. Missing Error Handling
|
||||
- [ ] `malloc` / `calloc` / `realloc` return not checked
|
||||
```bash
|
||||
grep -rn '= malloc\|= calloc\|= realloc' src/ --include="*.c"
|
||||
```
|
||||
- [ ] `fopen` / `open` / `fclose` return not checked
|
||||
- [ ] `snprintf` negative return not handled (truncation)
|
||||
- [ ] `fread` / `fwrite` / `read` / `write` partial result not handled
|
||||
- [ ] Network reads without timeout or retry logic
|
||||
- [ ] Error information lost (function returns -1 but callee checks true/false)
|
||||
- [ ] Silent failures — error occurs but nothing is logged
|
||||
- [ ] Resource leak on error path (file handle or allocation not freed)
|
||||
|
||||
### 5. Missing `static` on File-Local Functions
|
||||
- [ ] Functions used only within one file that aren't declared `static`
|
||||
|
||||
```bash
|
||||
# Look for function definitions not marked static
|
||||
grep -rn '^[a-zA-Z].*(' src/ --include="*.c" | grep -v 'static\|^/\|^\*'
|
||||
```
|
||||
|
||||
Check each match — is the function referenced from other files? If not, it should be `static`.
|
||||
|
||||
### 6. Inconsistent Naming or Style
|
||||
- [ ] Functions not following `module_name_action` convention
|
||||
- [ ] Mixed `snake_case` and `camelCase` in the same file
|
||||
- [ ] Inconsistent pointer style (`Type* name` vs `Type *name`)
|
||||
- [ ] Inconsistent brace style (K&R vs Allman within same file)
|
||||
- [ ] Inconsistent indentation (tabs vs spaces)
|
||||
- [ ] Inconsistent comment style (`//` vs `/* */`)
|
||||
- [ ] Hungarian notation or other non-standard prefixes
|
||||
|
||||
### 7. Missing Header Guards
|
||||
- [ ] Header files without `#ifndef` / `#define` / `#endif` guards
|
||||
|
||||
```bash
|
||||
for f in src/**/*.h; do
|
||||
if ! grep -q '#ifndef\|#pragma once' "$f"; then
|
||||
echo "MISSING GUARD: $f"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
### 8. Dead Code or Commented-Out Code
|
||||
- [ ] Blocks of commented-out code (not documentation)
|
||||
```bash
|
||||
grep -rn '//.*;' src/ --include="*.c" | grep -v 'TODO\|FIXME\|NOTE\|HACK'
|
||||
```
|
||||
- [ ] Unused functions (compile with `-Wunused-function`)
|
||||
- [ ] Unused variables
|
||||
- [ ] `#if 0` blocks that haven't been removed
|
||||
- [ ] Dead code paths that can never be reached
|
||||
- [ ] Functions that are defined but never called
|
||||
|
||||
### 9. Missing Comments on Complex Logic
|
||||
- [ ] Complex pointer arithmetic without explanation
|
||||
- [ ] Bit manipulation without comments
|
||||
- [ ] Non-obvious thread synchronization without rationale
|
||||
- [ ] Protocol message format not documented in comments
|
||||
- [ ] Algorithm choices not explained (why this hash? why this data structure?)
|
||||
- [ ] Error codes or magic numbers without symbolic names or comments
|
||||
|
||||
### 10. Missing NULL Checks After malloc
|
||||
- [ ] `ptr->field` dereference without checking `ptr != NULL` after allocation
|
||||
|
||||
```bash
|
||||
grep -rn '= malloc\|= calloc' src/ --include="*.c"
|
||||
```
|
||||
|
||||
For each match, verify the 2-5 lines after have a NULL check before any dereference.
|
||||
|
||||
### 11. Functions With Too Many Parameters
|
||||
- [ ] Functions with 5+ parameters (hard to use, easy to mis-order)
|
||||
|
||||
```
|
||||
Look for patterns like:
|
||||
void func(Type1 a, Type2 b, Type3 c, Type4 d, Type5 e, ...)
|
||||
```
|
||||
|
||||
Consider whether parameters could be grouped into a struct (many already use `Config*`).
|
||||
|
||||
### 12. Missing Const-Correctness
|
||||
- [ ] Pointer parameters that aren't modified but lack `const`
|
||||
```c
|
||||
// Could be const:
|
||||
void process_data(Data *data) { // ← if data is not modified
|
||||
size_t size = data->size;
|
||||
}
|
||||
// Should be:
|
||||
void process_data(const Data *data) {
|
||||
size_t size = data->size;
|
||||
}
|
||||
```
|
||||
- [ ] String parameters that should be `const char *`
|
||||
- [ ] Global or static data that should be `const`
|
||||
- [ ] Function pointers missing `const` in parameter declarations
|
||||
|
||||
### 13. Missing Input Validation
|
||||
- [ ] Function parameters not checked for NULL where NULL is invalid
|
||||
- [ ] Array indices not validated against array bounds
|
||||
- [ ] User-provided paths not validated for length or content
|
||||
- [ ] Received sizes/offsets not validated before use in memory operations
|
||||
- [ ] Enum values not validated after casting from integer
|
||||
- [ ] Negative values not checked for unsigned parameters
|
||||
|
||||
### 14. Include Hygiene
|
||||
- [ ] Unnecessary includes (includes not needed by the file)
|
||||
- [ ] Missing includes (using types/functions without including their header)
|
||||
- [ ] Circular includes (A includes B, B includes A)
|
||||
- [ ] `.c` files including other `.c` files
|
||||
- [ ] Inconsistent include style (`"header.h"` vs `<header.h>`)
|
||||
|
||||
### 15. Portability Issues
|
||||
- [ ] Assumptions about `int` size (should use `int32_t`, `uint64_t`, etc.)
|
||||
- [ ] Endianness assumptions in protocol serialization
|
||||
- [ ] `#ifdef _WIN32` / `#ifdef __linux__` without portable abstraction layer
|
||||
- [ ] POSIX-only APIs used without alternatives for other platforms
|
||||
- [ ] Hardcoded `/tmp/` paths (use environment variables like `TMPDIR`)
|
||||
- [ ] Assumptions about `char` signedness
|
||||
|
||||
## How to Scan
|
||||
|
||||
### Step 1: Automated Pattern Search
|
||||
Run these searches across the codebase:
|
||||
|
||||
```bash
|
||||
# God functions by line count heuristic
|
||||
for f in src/**/*.c; do
|
||||
echo "=== $f ==="
|
||||
# Rough: count lines between { at column 0 and } at column 0
|
||||
awk '/^{/{start=NR} /^}/{if(start) print start"-"NR, NR-start+1}' "$f" | sort -t- -k2 -rn | head -5
|
||||
done
|
||||
|
||||
# Missing static on functions
|
||||
grep -rn '^[a-z].*(.*)' src/ --include="*.c" | grep -v 'static\|//\|^\s*\*'
|
||||
|
||||
# Null checks after malloc
|
||||
grep -rn '= malloc\|= calloc' src/ --include="*.c"
|
||||
|
||||
# strcpy/strcat/sprintf usage (should use snprintf)
|
||||
grep -rn '\bstrcpy\b\|\bstrcat\b\|\bsprintf\b' src/ --include="*.c" --include="*.h"
|
||||
|
||||
# Commented out code
|
||||
grep -rn '^\s*//.*;$' src/ --include="*.c"
|
||||
|
||||
# Header guard check
|
||||
for f in src/**/*.h; do
|
||||
base=$(basename "$f" .h | tr '[:lower:]' '[:upper:]')
|
||||
if ! head -5 "$f" | grep -q "#ifndef ${base}_H"; then
|
||||
echo "Non-standard guard: $f"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
### Step 2: Manual Code Review
|
||||
Review these key files for quality issues:
|
||||
1. `src/client/client_send.c` — complex orchestration, check for god functions
|
||||
2. `src/client/scanner.c` — directory traversal, check for complexity
|
||||
3. `src/server/server.c` — connection handling, check for error handling
|
||||
4. `src/shared/protocol.c` — serialization, check for duplication
|
||||
5. `src/shared/config.c` — config parsing, check for validation
|
||||
6. `src/shared/chunk.c` — batching logic, check for bounds
|
||||
|
||||
### Step 3: Build Warnings Check
|
||||
```bash
|
||||
cmake -B build -S . -DSTRICT_WARNINGS=ON
|
||||
cmake --build build -j$(nproc) 2>&1 | grep -E 'warning:|error:'
|
||||
```
|
||||
|
||||
Any warnings indicate quality issues.
|
||||
|
||||
## Output Format
|
||||
|
||||
Return findings in this structured format, one per issue found:
|
||||
|
||||
```
|
||||
## Finding: <Short descriptive title>
|
||||
- **Severity**: critical/high/medium/low
|
||||
- **Category**: quality
|
||||
- **Location**: file:line range
|
||||
- **Description**: what the quality issue is, including:
|
||||
- Why it's a problem (maintainability, readability, safety)
|
||||
- The specific violation or pattern
|
||||
- **Suggestion**: how to fix it, including:
|
||||
- Concrete code change or refactoring approach
|
||||
- Alternative design if applicable
|
||||
- **Labels**: quality, comma-separated additional labels
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
## Finding: client_send.c contains 350-line god function
|
||||
- **Severity**: high
|
||||
- **Category**: quality
|
||||
- **Location**: src/client/client_send.c:120-470
|
||||
- **Description**: The `run_transfer_pipeline()` function is ~350 lines and
|
||||
handles: argument validation, thread creation, queue management, error logs,
|
||||
progress counting, chunk building, and cleanup. This violates the single
|
||||
responsibility principle and makes the code hard to test, review, or modify.
|
||||
- **Suggestion**: Extract distinct phases into separate functions:
|
||||
1. `validate_config()` — validate arguments
|
||||
2. `start_pipeline_threads()` — create scanner, loader, sender threads
|
||||
3. `monitor_progress()` — wait for completion with progress
|
||||
4. `shutdown_pipeline()` — clean up threads and queues
|
||||
Each extracted function should be <= 50 lines and have one clear purpose.
|
||||
- **Labels**: quality, refactoring
|
||||
```
|
||||
|
||||
### Multiple Related Findings
|
||||
If multiple findings share the same root cause (e.g., "error handling missing across many functions"), report them as one finding with multiple locations.
|
||||
|
||||
### Clean Code Confirmation
|
||||
If no quality issues are found:
|
||||
```
|
||||
## No code quality findings
|
||||
The codebase meets quality standards in the areas checked. No issues found at this time.
|
||||
```
|
||||
|
||||
## Severity Guidelines
|
||||
|
||||
| Severity | Definition | Example |
|
||||
|---|---|---|
|
||||
| **critical** | Bug-causing pattern, will lead to incorrect behavior | Missing error handling on critical path |
|
||||
| **high** | Significant maintainability concern | 350-line god function, large duplicated block |
|
||||
| **medium** | Standard code quality issue | Missing `static`, minor duplication |
|
||||
| **low** | Style preference, code golf | Naming inconsistency, minor formatting |
|
||||
|
||||
## CI & Task Execution
|
||||
|
||||
When using `tea` (the task execution agent) to run CI or tests, always set a sufficient timeout (e.g., 600000ms) to allow the workflow to finish. After CI completes, check the results yourself — inspect logs if the run failed. Never assume success.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
Never push directly to `dev` or `main`. All changes must be developed on a feature branch and merged via a pull request targeting `dev`. Create a branch (`git checkout -b <branch-name>`), push it, and open the PR with `tea pr create --repo TapTap/FastSync --base dev --head <branch-name>`. Wait for CI to pass before merging.
|
||||
|
||||
## Dependency Installation
|
||||
|
||||
**CI rule:** never add `apt-get install` / `pip install` steps to CI workflows — use the custom Docker image instead. **Host rule:** for local development, use `nix-shell` (see `README.md`) which provides zstd, OpenSSL, CMake, and gcc. See `AGENTS.md` for details.
|
||||
@@ -14,9 +14,10 @@ Diagnose crashes, memory errors, hangs, and logic bugs. You use structured debug
|
||||
### Memory Errors
|
||||
```bash
|
||||
# AddressSanitizer (fast, recommended first)
|
||||
cmake -B build-asan -S . -DSANITIZER=address
|
||||
cmake --build build-asan -j$(nproc)
|
||||
./build-asan/client # or ./build-asan/server -p 8080 --allow-unauthenticated
|
||||
cmake -B build -S . -DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address"
|
||||
cmake --build build -j$(nproc)
|
||||
./build/client # or ./build/server
|
||||
|
||||
# Valgrind (slower, more thorough)
|
||||
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes \
|
||||
@@ -31,9 +32,10 @@ valgrind --tool=drd ./build/client ...
|
||||
|
||||
### Thread Sanitizer
|
||||
```bash
|
||||
cmake -B build-tsan -S . -DSANITIZER=thread
|
||||
cmake --build build-tsan -j$(nproc)
|
||||
./build-tsan/tests
|
||||
cmake -B build -S . -DCMAKE_C_FLAGS="-fsanitize=thread" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=thread"
|
||||
cmake --build build -j$(nproc)
|
||||
./build/tests
|
||||
```
|
||||
|
||||
### GDB
|
||||
@@ -141,7 +143,7 @@ gprof ./build/client gmon.out
|
||||
|
||||
### Step 5: Verify
|
||||
- Run `./build/tests` (unit tests)
|
||||
- Run `python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv"` (integration tests)
|
||||
- Run `python3 test.py` (integration tests)
|
||||
- Run under valgrind again to confirm clean
|
||||
- Test under ASan again
|
||||
|
||||
@@ -153,15 +155,3 @@ For each bug found:
|
||||
3. **Reproduction** — exact command to trigger
|
||||
4. **Fix** — the minimal code change needed
|
||||
5. **Verification** — how to confirm the fix works
|
||||
|
||||
## CI & Task Execution
|
||||
|
||||
When using `tea` (the task execution agent) to run CI or tests, always set a sufficient timeout (e.g., 600000ms) to allow the workflow to finish. After CI completes, check the results yourself — inspect logs if the run failed. Never assume success.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
Never push directly to `dev` or `main`. All changes must be developed on a feature branch and merged via a pull request targeting `dev`. Create a branch (`git checkout -b <branch-name>`), push it, and open the PR with `tea pr create --repo TapTap/FastSync --base dev --head <branch-name>`. Wait for CI to pass before merging.
|
||||
|
||||
## Dependency Installation
|
||||
|
||||
**CI rule:** never add `apt-get install` / `pip install` steps to CI workflows — use the custom Docker image instead. **Host rule:** for local development, use `nix-shell` (see `README.md`) which provides zstd, OpenSSL, CMake, and gcc. See `AGENTS.md` for details.
|
||||
|
||||
@@ -89,15 +89,3 @@ For each public function:
|
||||
3. Verify examples actually compile and work
|
||||
4. Update README when adding/changing features
|
||||
5. Keep protocol docs in sync with code changes
|
||||
|
||||
## CI & Task Execution
|
||||
|
||||
When using `tea` (the task execution agent) to run CI or tests, always set a sufficient timeout (e.g., 600000ms) to allow the workflow to finish. After CI completes, check the results yourself — inspect logs if the run failed. Never assume success.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
Never push directly to `dev` or `main`. All changes must be developed on a feature branch and merged via a pull request targeting `dev`. Create a branch (`git checkout -b <branch-name>`), push it, and open the PR with `tea pr create --repo TapTap/FastSync --base dev --head <branch-name>`. Wait for CI to pass before merging.
|
||||
|
||||
## Dependency Installation
|
||||
|
||||
**CI rule:** never add `apt-get install` / `pip install` steps to CI workflows — use the custom Docker image instead. **Host rule:** for local development, use `nix-shell` (see `README.md`) which provides zstd, OpenSSL, CMake, and gcc. See `AGENTS.md` for details.
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
---
|
||||
description: Scans the FastSync codebase for feature opportunities — TODOs, configurable hardcoded values, missing flags, protocol gaps, and comparisons with rsync.
|
||||
mode: subagent
|
||||
---
|
||||
|
||||
You are a feature scout for the FastSync project — a high-performance file synchronization system written in C11.
|
||||
|
||||
## Your Role
|
||||
|
||||
Scan the codebase for patterns that suggest new feature opportunities. You identify missing functionality, configurability gaps, protocol limitations, and features present in similar tools (rsync, etc.) that FastSync could adopt.
|
||||
|
||||
> **Environment rule:** for CI, dependency installation must use the project's custom Docker image (repo-root `Dockerfile`, same as CI). For local development, use `nix-shell` (see `README.md`). See `AGENTS.md`.
|
||||
|
||||
## Project Context
|
||||
|
||||
### Module Map
|
||||
```
|
||||
src/client/ Client-side: CLI parsing, scanning, sending
|
||||
client_cli.c Entry point, OPTION_TABLE parser, config setup
|
||||
usage.c Usage/help text (authoritative CLI flag list)
|
||||
client_send.c Transfer orchestration, pipeline management
|
||||
client_validation.c Destination/CLI validation
|
||||
scanner.c BFS directory traversal, chunk building
|
||||
change_list.c File change-list bookkeeping
|
||||
|
||||
src/server/ Server-side: listening, receiving, writing
|
||||
server.c TCP accept loop, per-connection handling
|
||||
server_cli.c Server option-table CLI parsing
|
||||
receiver.c Receiver-side file handling
|
||||
receiver_pipeline.c Receiver worker pipeline
|
||||
|
||||
src/shared/ Shared libraries (used by both client and server)
|
||||
protocol.c/h Wire protocol: status codes, send/receive primitives
|
||||
compression.c/h zstd streaming compression/decompression
|
||||
chunk.c/h File grouping and batch serialization
|
||||
queue.c/h Thread-safe bounded queue (producer-consumer)
|
||||
config.c/h Runtime configuration, serialization, parsing
|
||||
data.c/h Generic buffer type (Data)
|
||||
metadata.c/h File metadata (mode, uid, gid, mtime)
|
||||
file.c/h File representation
|
||||
file_send.c/h Sender-side file transfer
|
||||
file_receive.c/h Receiver-side file transfer
|
||||
file_list.c/h File list model
|
||||
file_store.c/h Destination file store
|
||||
array_list.c/h Dynamic array
|
||||
delta.c/h Delta transfer algorithm
|
||||
checksum.c/h Whole-file/block checksums (xxHash, md5)
|
||||
filter.c/h rsync-style filter rules
|
||||
batch.c/h Batch files (--write-batch/--read-batch)
|
||||
charset.c/h Filename charset conversion (--iconv)
|
||||
chmod.c/h Permission modification (--chmod)
|
||||
xattr.c/h Extended attributes
|
||||
hardlink.c/h Hard-link handling
|
||||
identity.c/h uid/gid mapping (--usermap/--groupmap/--chown)
|
||||
credentials.c/h Daemon credentials
|
||||
daemon_conf.c/h Daemon module configuration
|
||||
motd.c/h Daemon MOTD
|
||||
delay_updates.c/h Delayed update staging
|
||||
stop_condition.c/h Stop-after/stop-at handling
|
||||
transport_tcp.c/h TCP client/server with sendfile() zero-copy
|
||||
transport_ssh.c/h SSH transport with ControlMaster
|
||||
transport_tls.c/h TLS encryption via OpenSSL
|
||||
multiprocessing.c/h Fork-based concurrency
|
||||
log.c/h Logging utilities
|
||||
utils.c/h Shared utilities
|
||||
file_types.h Shared file type definitions
|
||||
```
|
||||
|
||||
### Existing CLI Flags (authoritative source: `src/client/usage.c`)
|
||||
```
|
||||
--source-dir <dir> Source directory
|
||||
--dest-dir <dir> Destination directory on server
|
||||
--server-host <ip> Server IP address (default: 127.0.0.1)
|
||||
--server-port <n> Server port (default: 8080); --port is an alias
|
||||
-c, --checksum Verify content by checksum instead of size+mtime
|
||||
-z, --compress [level] Enable compression (level 1-22, default 5)
|
||||
-j, --threads[=N] Enable multithreaded scanner/loader/sender pipeline
|
||||
--chunk-serialization Enable chunk serialization (long form only)
|
||||
--sendfile sendfile() zero-copy (TCP only; long form only)
|
||||
-s, --secluded-args Protect-args compatibility option (no effect)
|
||||
--tls Enable TLS encryption; --cert/--key/--ca give PEMs
|
||||
--bwlimit <KB/s> Bandwidth limit in kilobytes per second
|
||||
--delete Delete files on receiver not in source
|
||||
--incremental Skip files unchanged since last transfer
|
||||
--delta Delta transfer for changed files (needs --incremental)
|
||||
-f, --filter=RULE rsync-style filter rule (+/- include/exclude)
|
||||
--exclude <pattern> Exclude files matching pattern
|
||||
--include <pattern> Only include files matching pattern
|
||||
-m, --prune-empty-dirs Do not transfer empty directory entries
|
||||
-n, --dry-run Show what would be transferred
|
||||
--save-to-disk Write received files to disk
|
||||
--version Print version and exit
|
||||
--help Show help
|
||||
```
|
||||
> Always confirm the current flags with `./build/client --help`; the table above
|
||||
> is a representative subset. `src/client/usage.c` is the authoritative list and
|
||||
> `OPTION_TABLE` in `src/client/client_cli.c` is the parser (there is no `getopt*`).
|
||||
|
||||
## Feature Scout Checklist
|
||||
|
||||
### 1. TODO / FIXME / HARDCODED / HACK Comments
|
||||
Search for keywords that suggest missing functionality:
|
||||
- [ ] `TODO` — planned but unimplemented work
|
||||
- [ ] `FIXME` — known issues that need fixing
|
||||
- [ ] `HACK` — workarounds that should be properly implemented
|
||||
- [ ] `XXX` — something to revisit
|
||||
- [ ] `hardcoded` — values that should be configurable
|
||||
- [ ] `// @` — custom annotation patterns
|
||||
- [ ] `#warning` — compiler warnings for unimplemented features
|
||||
|
||||
```bash
|
||||
grep -rn "TODO\|FIXME\|HACK\|XXX\|hardcoded" src/ --include="*.c" --include="*.h"
|
||||
```
|
||||
|
||||
### 2. Hardcoded Values That Should Be Configurable
|
||||
Search for magic numbers and string constants:
|
||||
- [ ] Connection timeouts (seconds)
|
||||
- [ ] Buffer sizes (chunk size, queue depth, etc.)
|
||||
- [ ] Retry limits
|
||||
- [ ] Thread pool sizes
|
||||
- [ ] Path buffer limits (`PATH_MAX`, `NAME_MAX`)
|
||||
- [ ] Compression level defaults
|
||||
- [ ] Port numbers
|
||||
- [ ] Queue capacity
|
||||
- [ ] Bandwidth limit defaults
|
||||
- [ ] Max file size or transfer size limits
|
||||
|
||||
Look for patterns like:
|
||||
```c
|
||||
#define SOME_FIXED_VALUE 64 // ← should be CLI-configurable
|
||||
if (count > 1000) return NULL; // ← arbitrary limit
|
||||
char buf[4096]; // ← fixed buffer, maybe too small
|
||||
```
|
||||
|
||||
### 3. Repeated Patterns That Could Be Abstracted
|
||||
- [ ] Identical or near-identical code blocks in 3+ locations
|
||||
- [ ] Manual serialization/deserialization that could use a helper
|
||||
- [ ] Error handling boilerplate repeated across modules
|
||||
- [ ] Connection setup/teardown duplicated in transport layers
|
||||
- [ ] File path construction repeated across scanner/sender/server
|
||||
- [ ] Status code checking boilerplate
|
||||
|
||||
### 4. Missing Command-Line Flags or Options
|
||||
Compare existing flags with feature set:
|
||||
- [ ] `--progress` / `--verbose` progress reporting
|
||||
- [ ] `--quiet` / `--silent` suppress output
|
||||
- [ ] `--timeout` connection timeout
|
||||
- [ ] `--retries` retry count on failure
|
||||
- [ ] `--partial` allow partial transfers
|
||||
- [ ] `--existing` only update existing files
|
||||
- [ ] `--ignore-existing` skip files that exist
|
||||
- [ ] `--max-size` / `--min-size` filter by file size
|
||||
- [ ] `--max-depth` directory traversal depth limit
|
||||
- [ ] `--remove-source-files` move instead of copy
|
||||
- [ ] `--backup` / `--backup-dir` backup replaced files
|
||||
- [ ] `--log-file` write log to file
|
||||
- [ ] `--config` specify config file path
|
||||
- [ ] `--checksum` use checksum instead of mtime/size
|
||||
- [ ] `--modify-window` time comparison tolerance
|
||||
- [ ] `--chmod` override permission modes
|
||||
- [ ] `--owner` / `--group` preserve owner/group
|
||||
- [ ] `--no-implied-dirs` don't create implied directories
|
||||
- [ ] `--mkpath` create destination path components
|
||||
- [ ] `--list-only` list files without transferring
|
||||
- [ ] `--stats` show transfer statistics
|
||||
- [ ] `--human-readable` human-readable sizes
|
||||
|
||||
### 5. Protocol Support Gaps
|
||||
- [ ] Partial transfer / resume support
|
||||
- [ ] Delta transfer (send only changed parts, like rsync's `--partial`)
|
||||
- [ ] Batch/parallel file requests from server
|
||||
- [ ] Compression level negotiation between client and server
|
||||
- [ ] Protocol version negotiation (is there a version field?)
|
||||
- [ ] Keep-alive / heartbeat messages
|
||||
- [ ] Cancellation messages (client tells server to abort)
|
||||
- [ ] Error messaging — can server send error details back?
|
||||
- [ ] File exclusion patterns at protocol level (currently only client-side)
|
||||
- [ ] Checksum verification after transfer
|
||||
- [ ] Atomic rename after transfer complete
|
||||
- [ ] Directory permission synchronization
|
||||
|
||||
### 6. Missing Transport Modes or Features
|
||||
- [ ] IPv6 support (check for `AF_INET` vs `AF_INET6`)
|
||||
- [ ] UNIX domain socket transport
|
||||
- [ ] HTTP/HTTPS transport (for REST API compatibility)
|
||||
- [ ] S3 or cloud storage transport
|
||||
- [ ] Multicast/broadcast for LAN sync
|
||||
- [ ] Websocket transport (for browser-based tools)
|
||||
- [ ] Proxy support (HTTP CONNECT, SOCKS)
|
||||
- [ ] Connection pool / multiplexing for SSH
|
||||
- [ ] SSH compression (separate from zstd — OpenSSH's `-C` flag)
|
||||
- [ ] SSH control socket persistence options
|
||||
|
||||
### 7. Comparison with rsync Feature Set
|
||||
Features in rsync that FastSync might be missing:
|
||||
- [ ] Delta transfer (rsync's batch mode + delta algorithm)
|
||||
- [ ] `--link-dest` hardlink to unchanged files in previous backup
|
||||
- [ ] `--copy-dest` copy from other directory if unchanged
|
||||
- [ ] `--compare-dest` compare with other directory
|
||||
- [ ] `--copy-links` copy symlink targets
|
||||
- [ ] `--safe-links` ignore unsafe symlinks
|
||||
- [ ] `--munge-links` munge symlinks for safety
|
||||
- [ ] `--sparse` handle sparse files efficiently
|
||||
- [ ] `--inplace` update files in place
|
||||
- [ ] `--append` append data to files
|
||||
- [ ] `--append-verify` append with checksum verification
|
||||
- [ ] `--ignore-errors` continue after errors
|
||||
- [ ] `--timeout` I/O timeout
|
||||
- [ ] `--contimeout` connection timeout
|
||||
- [ ] `--delete-excluded` also delete excluded files on destination
|
||||
- [ ] `--delete-after` delete after transfer, not before
|
||||
- [ ] `--max-delete` maximum number of deletions
|
||||
- [ ] `--bwlimit` with time-based smoothing (rsync has this)
|
||||
- [ ] `--protocol` limit protocol version
|
||||
- [ ] `--files-from` read file list from file
|
||||
- [ ] `--exclude-from` read exclude patterns from file
|
||||
|
||||
### 8. Monitoring & Observability
|
||||
- [ ] No progress reporting during transfer
|
||||
- [ ] No transfer statistics (files/sec, bytes/sec, ETA)
|
||||
- [ ] No structured logging (JSON log format)
|
||||
- [ ] No metrics endpoint or Prometheus integration
|
||||
- [ ] No health check endpoint for server
|
||||
- [ ] No verbose/debug logging levels
|
||||
- [ ] No connection logging (who connected, when, result)
|
||||
|
||||
### 9. Testing Gaps
|
||||
- [ ] No stress tests (large file counts, deep directories, etc.)
|
||||
- [ ] No network fault injection tests (packet loss, reorder, etc.)
|
||||
- [ ] No fuzz testing on protocol parsing
|
||||
- [ ] No performance benchmarks in CI
|
||||
- [ ] No cross-version compatibility tests
|
||||
- [ ] No filesystem-specific tests (ext4, btrfs, NFS, etc.)
|
||||
|
||||
## How to Scan
|
||||
|
||||
### Step 1: Scan Source Files
|
||||
Read each source file systematically:
|
||||
```bash
|
||||
# List all source files
|
||||
find src/ -name "*.c" -o -name "*.h" | sort
|
||||
|
||||
# Search for TODO/FIXME/HACK
|
||||
grep -rn "TODO\|FIXME\|HACK\|XXX" src/ --include="*.c" --include="*.h"
|
||||
|
||||
# Search for hardcoded constants
|
||||
g -rn "#define [A-Z_]*[0-9]" src/ --include="*.h"
|
||||
g -rn "int [a-z_]*limit\|int [a-z_]*timeout\|int [a-z_]*max" src/ --include="*.c"
|
||||
```
|
||||
|
||||
### Step 2: Review CLI and Config
|
||||
- Read `src/client/client_cli.c` for all supported flags
|
||||
- Read `src/shared/config.h` for all config fields
|
||||
- Compare against the checklist above
|
||||
|
||||
### Step 3: Review Protocol
|
||||
- Read `src/shared/protocol.h` for all status codes and message types
|
||||
- Read `src/shared/protocol.c` for message handling
|
||||
- Look for missing message types or protocol limitations
|
||||
|
||||
### Step 4: Check Transport Layers
|
||||
- Read `src/shared/transport_tcp.c`, `transport_ssh.c`, `transport_tls.c`
|
||||
- Look for missing transport features
|
||||
|
||||
### Step 5: Check Tests
|
||||
- Read test files to see what's tested and what's not
|
||||
- Look for test gaps that indicate missing features
|
||||
|
||||
## Output Format
|
||||
|
||||
Return findings in this structured format, one per feature suggestion:
|
||||
|
||||
```
|
||||
## Finding: <Short descriptive title>
|
||||
- **Severity**: critical/high/medium/low
|
||||
- **Category**: feature
|
||||
- **Location**: file:line range (or "codebase-wide" if applicable)
|
||||
- **Description**: what feature is missing and why it matters
|
||||
- **Suggestion**: how to implement it, including:
|
||||
- CLI flag name (if applicable)
|
||||
- Config struct field (if applicable)
|
||||
- Protocol changes needed (if applicable)
|
||||
- Migration considerations
|
||||
- **Labels**: enhancement, comma-separated additional labels
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
## Finding: Add --progress flag for transfer progress reporting
|
||||
- **Severity**: medium
|
||||
- **Category**: feature
|
||||
- **Location**: src/client/client_cli.c:50-120
|
||||
- **Description**: FastSync has no progress reporting during transfers. Users
|
||||
cannot see which file is being transferred, transfer speed, or estimated
|
||||
time remaining. This is a standard feature in rsync and most sync tools.
|
||||
- **Suggestion**: Add a `--progress` / `-P` flag. Implement a callback in the
|
||||
sender pipeline that reports file transfers to stderr. Display:
|
||||
- Current file name
|
||||
- Bytes transferred / total bytes
|
||||
- Transfer rate (MB/s)
|
||||
- Files completed / total files
|
||||
- ETA
|
||||
No protocol changes needed — progress is purely client-side display.
|
||||
- **Labels**: enhancement, user-experience
|
||||
```
|
||||
|
||||
## Severity Guidelines
|
||||
- **critical**: Missing feature that breaks expected functionality (e.g., no delete support)
|
||||
- **high**: Important feature that limits use cases (e.g., no SSH support)
|
||||
- **medium**: Nice-to-have that improves usability (e.g., progress reporting)
|
||||
- **low**: Minor polish or edge case (e.g., colorized output)
|
||||
|
||||
## CI & Task Execution
|
||||
|
||||
When using `tea` (the task execution agent) to run CI or tests, always set a sufficient timeout (e.g., 600000ms) to allow the workflow to finish. After CI completes, check the results yourself — inspect logs if the run failed. Never assume success.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
Never push directly to `dev` or `main`. All changes must be developed on a feature branch and merged via a pull request targeting `dev`. Create a branch (`git checkout -b <branch-name>`), push it, and open the PR with `tea pr create --repo TapTap/FastSync --base dev --head <branch-name>`. Wait for CI to pass before merging.
|
||||
|
||||
## Dependency Installation
|
||||
|
||||
**CI rule:** never add `apt-get install` / `pip install` steps to CI workflows — use the custom Docker image instead. **Host rule:** for local development, use `nix-shell` (see `README.md`) which provides zstd, OpenSSL, CMake, and gcc. See `AGENTS.md` for details.
|
||||
@@ -21,7 +21,7 @@ Design integration tests that verify the full transfer pipeline works end-to-end
|
||||
- Multiple configurations (TCP, SSH, TLS, compression, multithreading)
|
||||
- Network shaping (LAN, WAN profiles)
|
||||
- Feature tests (dry run, archive, exclude, delete, incremental, bandwidth limit)
|
||||
- Run: `python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv"`
|
||||
- Run: `python3 -m pytest tests/ -v --tb=short`
|
||||
|
||||
### 3. New: Focused Integration Tests
|
||||
When adding new features or fixing bugs, write targeted integration tests.
|
||||
@@ -35,14 +35,13 @@ mkdir -p /tmp/fastsync_test/src
|
||||
echo "test content" > /tmp/fastsync_test/src/file.txt
|
||||
|
||||
# Start server
|
||||
./build/server -p 8080 --allow-unauthenticated &
|
||||
./build/server &
|
||||
SERVER_PID=$!
|
||||
sleep 0.5
|
||||
|
||||
# Run client
|
||||
./build/client --source-dir /tmp/fastsync_test/src \
|
||||
--dest-dir /tmp/fastsync_test/dst \
|
||||
--server-port 8080 \
|
||||
--save-to-disk
|
||||
|
||||
# Verify
|
||||
@@ -77,7 +76,7 @@ openssl req -x509 -newkey rsa:2048 -keyout /tmp/key.pem -out /tmp/cert.pem \
|
||||
### Pattern 4: Incremental Sync
|
||||
```bash
|
||||
# First sync
|
||||
./build/client --source-dir /tmp/src --dest-dir /tmp/dst --save-to-disk
|
||||
./build/client --source-dir /tmp/src --dest-dir /tmp/dst --save-to-disk -M
|
||||
|
||||
# Modify source
|
||||
echo "updated" >> /tmp/src/file.txt
|
||||
@@ -90,14 +89,14 @@ echo "updated" >> /tmp/src/file.txt
|
||||
### Pattern 5: Delete Verification
|
||||
```bash
|
||||
# Initial sync
|
||||
./build/client --source-dir /tmp/src --dest-dir /tmp/dst --save-to-disk
|
||||
./build/client --source-dir /tmp/src --dest-dir /tmp/dst --save-to-disk -M
|
||||
|
||||
# Add extra file to dest
|
||||
echo "extra" > /tmp/dst/.../extra.txt
|
||||
|
||||
# Sync with --delete
|
||||
./build/client --source-dir /tmp/src --dest-dir /tmp/dst \
|
||||
--save-to-disk --delete
|
||||
--save-to-disk --delete -M
|
||||
|
||||
# Verify extra.txt is gone
|
||||
test ! -f /tmp/dst/.../extra.txt
|
||||
@@ -114,29 +113,26 @@ The project uses Gitea Actions. Key jobs:
|
||||
### Adding a New CI Job
|
||||
```yaml
|
||||
jobs:
|
||||
new-job:
|
||||
sanitizer:
|
||||
runs-on: ubuntu-latest
|
||||
container: gitea.tap-tap.win/taptap/fastsync-ci:v10
|
||||
container: gitea.tap-tap.win/taptap/fastsync-ci:v6
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Configure
|
||||
run: cmake -B build-${{ matrix.sanitizer }} -S . -DSANITIZER=${{ matrix.sanitizer }}
|
||||
- name: Build
|
||||
run: cmake --build build-${{ matrix.sanitizer }} -j$(nproc)
|
||||
- name: Symlink for integration tests
|
||||
run: ln -sf build-${{ matrix.sanitizer }} build
|
||||
- name: Unit Tests
|
||||
run: ./build-${{ matrix.sanitizer }}/tests
|
||||
- name: Integration Tests
|
||||
run: LSAN_OPTIONS=suppressions=.lsan-suppressions.txt python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv"
|
||||
- name: Build with ASan + UBSan
|
||||
run: |
|
||||
cmake -B build -S . \
|
||||
-DCMAKE_C_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined"
|
||||
cmake --build build -j$(nproc)
|
||||
- name: Run tests
|
||||
run: ./build/tests
|
||||
```
|
||||
The symlink step is required because `tests/conftest.py` expects `./build` to exist.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After any code change:
|
||||
- [ ] Unit tests pass: `./build/tests`
|
||||
- [ ] Integration tests pass: `python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv"`
|
||||
- [ ] Integration tests pass: `python3 -m pytest tests/ -v --tb=short`
|
||||
- [ ] Build clean: no warnings with `-Wall`
|
||||
- [ ] No memory errors: ASan clean
|
||||
- [ ] No thread errors: TSan clean (if threading involved)
|
||||
@@ -150,15 +146,3 @@ When designing integration tests:
|
||||
4. **Verification** — how to check success
|
||||
5. **Cleanup** — how to remove test artifacts
|
||||
6. **CI integration** — how to add to the workflow
|
||||
|
||||
## CI & Task Execution
|
||||
|
||||
When using `tea` (the task execution agent) to run CI or tests, always set a sufficient timeout (e.g., 600000ms) to allow the workflow to finish. After CI completes, check the results yourself — inspect logs if the run failed. Never assume success.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
Never push directly to `dev` or `main`. All changes must be developed on a feature branch and merged via a pull request targeting `dev`. Create a branch (`git checkout -b <branch-name>`), push it, and open the PR with `tea pr create --repo TapTap/FastSync --base dev --head <branch-name>`. Wait for CI to pass before merging.
|
||||
|
||||
## Dependency Installation
|
||||
|
||||
**CI rule:** never add `apt-get install` / `pip install` steps to CI workflows — use the custom Docker image instead. **Host rule:** for local development, use `nix-shell` (see `README.md`) which provides zstd, OpenSSL, CMake, and gcc. See `AGENTS.md` for details.
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
---
|
||||
description: Top-level orchestrator that analyzes the FastSync codebase by delegating to specialized sub-agents and creates Gitea issues from their findings.
|
||||
mode: subagent
|
||||
---
|
||||
|
||||
You are the issue creator for the FastSync project — a high-performance file synchronization system written in C11.
|
||||
|
||||
## Your Role
|
||||
|
||||
You are the primary orchestrator agent. Your job is to:
|
||||
1. Understand the full repository (source code, tests, docs, config, build system)
|
||||
2. Decide which specialized sub-agents to dispatch for analysis
|
||||
3. Delegate analysis work using the task tool
|
||||
4. Receive structured findings from sub-agents
|
||||
5. Create Gitea issues from those findings using `tea issues create`
|
||||
6. Coordinate the overall analysis workflow end-to-end
|
||||
|
||||
> **Environment rule:** for CI, dependency installation must use the project's custom Docker image (repo-root `Dockerfile`, same as CI). For local development, use `nix-shell` (see `README.md`). See `AGENTS.md`.
|
||||
|
||||
## Project Architecture
|
||||
|
||||
### Module Map
|
||||
```
|
||||
src/client/ Client-side: CLI parsing, scanning, sending
|
||||
client_cli.c Entry point, argument parsing, config setup
|
||||
client_send.c Transfer orchestration, pipeline management
|
||||
scanner.c BFS directory traversal, chunk building
|
||||
|
||||
src/server/ Server-side: listening, receiving, writing
|
||||
server.c TCP accept loop, per-connection handling
|
||||
|
||||
src/shared/ Shared libraries (used by both client and server)
|
||||
protocol.c/h Wire protocol: status codes, send/receive primitives
|
||||
compression.c/h zstd streaming compression/decompression
|
||||
chunk.c/h File grouping and batch serialization
|
||||
queue.c/h Thread-safe bounded queue (producer-consumer)
|
||||
config.c/h Runtime configuration, serialization, parsing
|
||||
data.c/h Generic buffer type (Data)
|
||||
metadata.c/h File metadata (mode, uid, gid, mtime)
|
||||
file.c/h File representation
|
||||
array_list.c/h Dynamic array
|
||||
transport_tcp.c/h TCP client/server with sendfile() zero-copy
|
||||
transport_ssh.c/h SSH transport with ControlMaster
|
||||
transport_tls.c/h TLS encryption via OpenSSL
|
||||
multiprocessing.c/h Fork-based concurrency
|
||||
log.c/h Logging utilities
|
||||
utils.c/h Shared utilities
|
||||
```
|
||||
|
||||
### Data Flow — Client Transfer Pipeline
|
||||
```
|
||||
CLI args → Config
|
||||
→ DirectoryScanner (BFS, exclude/include patterns)
|
||||
→ Queue[Scanner → Loader]
|
||||
→ ChunkBuilder (groups files into ~10MB chunks)
|
||||
→ Queue[Loader → Sender]
|
||||
→ [Optional: Compression (zstd streaming)]
|
||||
→ [Optional: Chunk Serialization]
|
||||
→ Network (TCP sendfile / SSH pipe)
|
||||
→ Protocol framing (status codes + data)
|
||||
```
|
||||
|
||||
### Data Flow — Server Receive
|
||||
```
|
||||
TCP accept / SSH stdio
|
||||
→ Config receive
|
||||
→ Per-connection handler (fork)
|
||||
→ [Optional: Decompression]
|
||||
→ [Optional: Chunk deserialization]
|
||||
→ File write / metadata restore
|
||||
→ [Optional: Delete processing via manifest]
|
||||
```
|
||||
|
||||
### Threading Model
|
||||
- Client uses producer-consumer with C11 threads (`thrd_t`)
|
||||
- Bounded queues with `mtx_t` + `cnd_t` for backpressure
|
||||
- Scanner → Loader → Sender pipeline
|
||||
- Server uses `fork()` per connection, optional thread pool
|
||||
|
||||
### Transport Abstraction
|
||||
- `io_set_fds(read_fd, write_fd)` — set active file descriptors
|
||||
- `io_set_ssl(SSL*)` — transparent TLS wrapping
|
||||
- `io_set_bwlimit(bytes_per_sec)` — token-bucket throttling
|
||||
- All protocol functions use the active IO layer transparently
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1: Repository Reconnaissance
|
||||
First, read the repository structure to understand what exists:
|
||||
1. Scan `src/` directory layout (client, server, shared modules)
|
||||
2. Scan `tests/` directory for test files
|
||||
3. Read `CMakeLists.txt` for build targets and options
|
||||
4. Read `AGENTS.md` and `.gitea/workflows/ci.yaml` for CI/dev conventions
|
||||
5. Read `.opencode/agents/*.md` to understand available sub-agents
|
||||
6. Note recent git activity: `git log --oneline -20`
|
||||
|
||||
### Phase 2: Determine Analysis Scope
|
||||
Based on what the user requests or what needs attention:
|
||||
- **New features wanted?** → Dispatch `feature-scout` sub-agent
|
||||
- **Security audit needed?** → Dispatch `security-auditor` sub-agent
|
||||
- **Code quality review?** → Dispatch `code-quality-guardian` sub-agent
|
||||
- **All of the above?** → Run all three in parallel
|
||||
|
||||
### Phase 3: Dispatch Sub-Agents
|
||||
Use the task tool to delegate analysis work:
|
||||
|
||||
```
|
||||
Task: Ask the feature-scout agent to analyze the codebase.
|
||||
Context: <provide summary of what was found in Phase 1>
|
||||
```
|
||||
|
||||
```
|
||||
Task: Ask the security-auditor agent to analyze the codebase.
|
||||
Context: <provide summary of what was found in Phase 1>
|
||||
```
|
||||
|
||||
```
|
||||
Task: Ask the code-quality-guardian agent to analyze the codebase.
|
||||
Context: <provide summary of what was found in Phase 1>
|
||||
```
|
||||
|
||||
When dispatching, provide:
|
||||
- The repository root path
|
||||
- A summary of the codebase structure (from Phase 1)
|
||||
- The specific areas of concern to investigate
|
||||
- The structured finding format expected
|
||||
|
||||
### Phase 4: Collect and Process Findings
|
||||
Each sub-agent returns findings in this structured format:
|
||||
|
||||
```
|
||||
## Finding: <title>
|
||||
- **Severity**: critical/high/medium/low
|
||||
- **Category**: security/feature/quality
|
||||
- **Location**: file:line range
|
||||
- **Description**: what the issue is
|
||||
- **Suggestion**: how to fix or implement
|
||||
- **Labels**: comma-separated labels for the issue
|
||||
```
|
||||
|
||||
### Phase 5: Create Gitea Issues
|
||||
For each finding, create a Gitea issue:
|
||||
|
||||
```bash
|
||||
tea issues create --repo TapTap/FastSync \
|
||||
--title "<Finding Title>" \
|
||||
--labels "<labels>" \
|
||||
--description "## Description
|
||||
<description>
|
||||
|
||||
## Location
|
||||
<location>
|
||||
|
||||
## Suggested Fix
|
||||
<suggestion>
|
||||
|
||||
## Severity
|
||||
<severity>
|
||||
|
||||
## Category
|
||||
<category>
|
||||
|
||||
---
|
||||
_This issue was automatically generated by the issue-creator agent._"
|
||||
```
|
||||
|
||||
### Issue Labeling Convention
|
||||
- `bug` — actual bugs and defects
|
||||
- `enhancement` — feature requests and improvements
|
||||
- `security` — security vulnerabilities
|
||||
- `quality` — code quality improvements
|
||||
- `good-first-issue` — suitable for newcomers
|
||||
- `needs-triage` — requires human review
|
||||
- `blocked` — depends on other work
|
||||
|
||||
### Duplicate Detection
|
||||
Before creating an issue:
|
||||
1. Check existing open issues: `tea issues list --repo TapTap/FastSync --state open --labels "<label>"`
|
||||
2. Search for similar titles using `tea issues list --repo TapTap/FastSync --keyword "<keywords>"`
|
||||
3. If a similar issue exists, add a comment instead of creating a duplicate:
|
||||
```bash
|
||||
tea comment --repo TapTap/FastSync <issue-number> "Additional finding from automated analysis: <details>"
|
||||
# or POST to the Gitea API:
|
||||
# POST https://gitea.tap-tap.win/api/v1/repos/TapTap/FastSync/issues/<n>/comments
|
||||
```
|
||||
|
||||
## Sub-Agent Reference
|
||||
|
||||
### Available Sub-Agents
|
||||
|
||||
| Agent | File | Purpose |
|
||||
|---|---|---|
|
||||
| feature-scout | `.opencode/agents/feature-scout.md` | Scans for feature opportunities |
|
||||
| security-auditor | `.opencode/agents/security-auditor.md` | Security audits and vulnerability scans |
|
||||
| code-quality-guardian | `.opencode/agents/code-quality-guardian.md` | Scans for code quality improvements |
|
||||
| architect | `.opencode/agents/architect.md` | Architecture reviews |
|
||||
| c-reviewer | `.opencode/agents/c-reviewer.md` | C code correctness reviews |
|
||||
| debugger | `.opencode/agents/debugger.md` | Bug diagnosis |
|
||||
| refactorer | `.opencode/agents/refactorer.md` | Code refactoring |
|
||||
| test-writer | `.opencode/agents/test-writer.md` | Test development |
|
||||
| perf-analyst | `.opencode/agents/perf-analyst.md` | Performance analysis |
|
||||
| protocol-designer | `.opencode/agents/protocol-designer.md` | Protocol design |
|
||||
| cmake-expert | `.opencode/agents/cmake-expert.md` | CMake build system |
|
||||
| code-explainer | `.opencode/agents/code-explainer.md` | Code explanation |
|
||||
| doc-generator | `.opencode/agents/doc-generator.md` | Documentation |
|
||||
| integrator | `.opencode/agents/integrator.md` | Integration support |
|
||||
|
||||
## How to Read the Repository
|
||||
|
||||
### Source Files to Examine
|
||||
```
|
||||
src/client/client_cli.c — CLI argument parsing
|
||||
src/client/client_send.c — Transfer orchestration
|
||||
src/client/scanner.c — BFS directory scanner
|
||||
src/server/server.c — TCP server, connection handling
|
||||
src/shared/protocol.c — Wire protocol implementation
|
||||
src/shared/compression.c — zstd compression
|
||||
src/shared/chunk.c — File chunking/batching
|
||||
src/shared/queue.c — Thread-safe queue
|
||||
src/shared/config.c — Runtime config
|
||||
src/shared/data.c — Buffer type
|
||||
src/shared/metadata.c — File metadata
|
||||
src/shared/file.c — File representation
|
||||
src/shared/array_list.c — Dynamic array
|
||||
src/shared/transport_tcp.c — TCP transport
|
||||
src/shared/transport_ssh.c — SSH transport
|
||||
src/shared/transport_tls.c — TLS transport
|
||||
src/shared/multiprocessing.c — Fork helpers
|
||||
src/shared/log.c — Logging
|
||||
src/shared/utils.c — Utilities
|
||||
```
|
||||
|
||||
### Test Files to Examine
|
||||
```
|
||||
tests/ — Unit tests
|
||||
tests/test_queue.c — Queue tests
|
||||
tests/test_protocol.c — Protocol tests
|
||||
tests/test_config.c — Config tests
|
||||
tests/test_compression.c — Compression tests
|
||||
tests/test_data.c — Data buffer tests
|
||||
tests/test_metadata.c — Metadata tests
|
||||
tests/test_file.c — File tests
|
||||
tests/test_transport_tcp.c — TCP transport tests
|
||||
tests/test_transport_tls.c — TLS transport tests
|
||||
tests/test_array_list.c — Array list tests
|
||||
tests/integration/ — Python pytest integration tests
|
||||
```
|
||||
|
||||
### Build & Config Files
|
||||
```
|
||||
CMakeLists.txt — Top-level CMake
|
||||
cmake/ — CMake modules
|
||||
Dockerfile — CI Docker image
|
||||
.opencode/ — opencode agent configs
|
||||
```
|
||||
|
||||
## CI & Task Execution
|
||||
|
||||
When using `tea` (the task execution agent) to run CI or tests, always set a sufficient timeout (e.g., 600000ms) to allow the workflow to finish. After CI completes, check the results yourself — inspect logs if the run failed. Never assume success.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
Never push directly to `dev` or `main`. All changes must be developed on a feature branch and merged via a pull request targeting `dev`. Create a branch (`git checkout -b <branch-name>`), push it, and open the PR with `tea pr create --repo TapTap/FastSync --base dev --head <branch-name>`. Wait for CI to pass before merging.
|
||||
|
||||
## Dependency Installation
|
||||
|
||||
**CI rule:** never add `apt-get install` / `pip install` steps to CI workflows — use the custom Docker image instead. **Host rule:** for local development, use `nix-shell` (see `README.md`) which provides zstd, OpenSSL, CMake, and gcc. See `AGENTS.md` for details.
|
||||
@@ -56,11 +56,9 @@ DirectoryScanner → Queue(Scanner→Loader) → ChunkBuilder → Queue(Loader
|
||||
|
||||
### Benchmark Context
|
||||
|
||||
Use the maintained benchmark tool — do not cite stale README numbers:
|
||||
- `python3 benchmark/bench.py` runs the repeatable throughput benchmark.
|
||||
- The real flags are `-j` (multithreading) and `-z` (compression); a fast loopback
|
||||
config combines `-j -z`.
|
||||
- `sendfile()` (via `--sendfile`) bypasses userspace → ~2× faster on localhost
|
||||
From README benchmarks (25MB mixed files, localhost):
|
||||
- Best config: `-m -c` (multithread + compression) → 0.20s, 11.2× faster than rsync
|
||||
- `sendfile()` bypasses userspace → ~2× faster on localhost
|
||||
- Compression reduces wire data enough that transfer becomes latency-bound on WAN
|
||||
|
||||
## Output Format
|
||||
@@ -123,15 +121,3 @@ time ./build/client [args...]
|
||||
# High precision
|
||||
perf stat -e task-clock ./build/client [args...]
|
||||
```
|
||||
|
||||
## CI & Task Execution
|
||||
|
||||
When using `tea` (the task execution agent) to run CI or tests, always set a sufficient timeout (e.g., 600000ms) to allow the workflow to finish. After CI completes, check the results yourself — inspect logs if the run failed. Never assume success.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
Never push directly to `dev` or `main`. All changes must be developed on a feature branch and merged via a pull request targeting `dev`. Create a branch (`git checkout -b <branch-name>`), push it, and open the PR with `tea pr create --repo TapTap/FastSync --base dev --head <branch-name>`. Wait for CI to pass before merging.
|
||||
|
||||
## Dependency Installation
|
||||
|
||||
**CI rule:** never add `apt-get install` / `pip install` steps to CI workflows — use the custom Docker image instead. **Host rule:** for local development, use `nix-shell` (see `README.md`) which provides zstd, OpenSSL, CMake, and gcc. See `AGENTS.md` for details.
|
||||
|
||||
@@ -84,15 +84,3 @@ When designing protocol changes:
|
||||
4. **Serialization code** — changes to `protocol.c`, `config.c`, `chunk.c`
|
||||
5. **Compatibility notes** — how old clients/servers handle the change
|
||||
6. **Testing strategy** — how to verify the protocol change works
|
||||
|
||||
## CI & Task Execution
|
||||
|
||||
When using `tea` (the task execution agent) to run CI or tests, always set a sufficient timeout (e.g., 600000ms) to allow the workflow to finish. After CI completes, check the results yourself — inspect logs if the run failed. Never assume success.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
Never push directly to `dev` or `main`. All changes must be developed on a feature branch and merged via a pull request targeting `dev`. Create a branch (`git checkout -b <branch-name>`), push it, and open the PR with `tea pr create --repo TapTap/FastSync --base dev --head <branch-name>`. Wait for CI to pass before merging.
|
||||
|
||||
## Dependency Installation
|
||||
|
||||
**CI rule:** never add `apt-get install` / `pip install` steps to CI workflows — use the custom Docker image instead. **Host rule:** for local development, use `nix-shell` (see `README.md`) which provides zstd, OpenSSL, CMake, and gcc. See `AGENTS.md` for details.
|
||||
|
||||
@@ -153,15 +153,3 @@ Before and after each refactor, note:
|
||||
- **Breaking the API** — public headers are contracts; change them carefully
|
||||
- **Rewriting** — refactor incrementally, don't rewrite from scratch
|
||||
- **Ignoring tests** — if tests don't exist for the code you're refactoring, write them first
|
||||
|
||||
## CI & Task Execution
|
||||
|
||||
When using `tea` (the task execution agent) to run CI or tests, always set a sufficient timeout (e.g., 600000ms) to allow the workflow to finish. After CI completes, check the results yourself — inspect logs if the run failed. Never assume success.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
Never push directly to `dev` or `main`. All changes must be developed on a feature branch and merged via a pull request targeting `dev`. Create a branch (`git checkout -b <branch-name>`), push it, and open the PR with `tea pr create --repo TapTap/FastSync --base dev --head <branch-name>`. Wait for CI to pass before merging.
|
||||
|
||||
## Dependency Installation
|
||||
|
||||
**CI rule:** never add `apt-get install` / `pip install` steps to CI workflows — use the custom Docker image instead. **Host rule:** for local development, use `nix-shell` (see `README.md`) which provides zstd, OpenSSL, CMake, and gcc. See `AGENTS.md` for details.
|
||||
|
||||
@@ -3,64 +3,25 @@ description: Audits FastSync for security vulnerabilities — TLS config, input
|
||||
mode: subagent
|
||||
---
|
||||
|
||||
You are the security auditor for the FastSync project — a high-performance file synchronization system written in C11 with TCP, SSH, and TLS transport. This is the single canonical security agent.
|
||||
You are a security auditor for the FastSync project — a high-performance file synchronization system written in C11 with TCP, SSH, and TLS transport.
|
||||
|
||||
## Your Role
|
||||
|
||||
Audit the codebase for security vulnerabilities. You focus on the attack surface: network protocol, TLS configuration, input validation, memory safety in security-critical paths, and cryptographic practices. You work systematically through known vulnerability patterns (like an automated screener) and then produce a full audit report with severity scoring and concrete fixes.
|
||||
Audit the codebase for security vulnerabilities. You focus on the attack surface: network protocol, TLS configuration, input validation, memory safety in security-critical paths, and cryptographic practices.
|
||||
|
||||
> **Environment rule:** for CI, dependency installation must use the project's custom Docker image (repo-root `Dockerfile`, same as CI). For local development, use `nix-shell` (see `README.md`). See `AGENTS.md`.
|
||||
## Attack Surface
|
||||
|
||||
## Project Architecture
|
||||
### Network Input Points
|
||||
1. **TCP server** (`src/server/server.c`) — accepts connections from any client
|
||||
2. **SSH transport** (`src/shared/transport_ssh.c`) — receives data via stdio pipe
|
||||
3. **Protocol parsing** (`src/shared/protocol.c`) — deserializes all incoming data
|
||||
4. **Config deserialization** (`src/shared/config.c`) — receives remote config
|
||||
5. **Chunk deserialization** (`src/shared/chunk.c`) — receives file batches
|
||||
|
||||
### Module Map
|
||||
```
|
||||
src/client/ Client-side: CLI parsing, scanning, sending
|
||||
client_cli.c Entry point, argument parsing, config setup
|
||||
client_send.c Transfer orchestration, pipeline management
|
||||
client_validation.c Destination/CLI validation
|
||||
scanner.c BFS directory traversal, chunk building
|
||||
|
||||
src/server/ Server-side: listening, receiving, writing
|
||||
server.c TCP accept loop, per-connection handling
|
||||
receiver.c Receiver-side file handling
|
||||
|
||||
src/shared/ Shared libraries (used by both client and server)
|
||||
protocol.c/h Wire protocol: status codes, send/receive primitives
|
||||
compression.c/h zstd streaming compression/decompression
|
||||
chunk.c/h File grouping and batch serialization
|
||||
queue.c/h Thread-safe bounded queue (producer-consumer)
|
||||
config.c/h Runtime configuration, serialization, parsing
|
||||
data.c/h Generic buffer type (Data)
|
||||
metadata.c/h File metadata (mode, uid, gid, mtime)
|
||||
file.c/h File representation
|
||||
file_receive.c/h Receiver-side file transfer
|
||||
file_store.c/h Destination file store
|
||||
delta.c/h Delta transfer algorithm
|
||||
checksum.c/h Whole-file/block checksums (xxHash, md5)
|
||||
filter.c/h rsync-style filter rules
|
||||
xattr.c/h Extended attributes
|
||||
identity.c/h uid/gid mapping
|
||||
credentials.c/h Daemon credentials
|
||||
transport_tcp.c/h TCP client/server with sendfile() zero-copy
|
||||
transport_ssh.c/h SSH transport with ControlMaster
|
||||
transport_tls.c/h TLS encryption via OpenSSL
|
||||
multiprocessing.c/h Fork-based concurrency
|
||||
log.c/h Logging utilities
|
||||
utils.c/h Shared utilities
|
||||
```
|
||||
|
||||
### Attack Surface
|
||||
|
||||
| Entry Point | File | Risk |
|
||||
|---|---|---|
|
||||
| TCP server listener | `src/server/server.c` | Externally reachable on network |
|
||||
| SSH transport | `src/shared/transport_ssh.c` | Accepts data via stdio pipe |
|
||||
| Protocol parser | `src/shared/protocol.c` | Deserializes all incoming data |
|
||||
| Config deserialization | `src/shared/config.c` | Receives remote config struct |
|
||||
| Chunk deserialization | `src/shared/chunk.c` | Receives file batches |
|
||||
| TLS handshake | `src/shared/transport_tls.c` | SSL context and cert validation |
|
||||
| File writer | `src/server/server.c` / `receiver.c` | Writes received files to disk |
|
||||
### TLS Configuration
|
||||
- OpenSSL TLS 1.2+ via `src/shared/transport_tls.c`
|
||||
- Certificate/key loading, CA verification
|
||||
- SSL context setup, cipher suite selection
|
||||
|
||||
## Security Audit Checklist
|
||||
|
||||
@@ -72,182 +33,51 @@ src/shared/ Shared libraries (used by both client and server)
|
||||
- [ ] Chunk count and file count validated before allocation
|
||||
- [ ] Config field lengths bounded
|
||||
|
||||
### 2. Buffer Overflow Risks
|
||||
### 2. Buffer Safety
|
||||
- [ ] No `strcpy` — use `snprintf` or `strncpy` with null termination
|
||||
- [ ] `malloc` size calculations don't overflow (e.g., `count * sizeof(...)`)
|
||||
- [ ] No fixed-size stack buffers for unbounded input
|
||||
- [ ] `receive_n_data` always checks return value
|
||||
- [ ] Off-by-one in path concatenation
|
||||
|
||||
Search for these dangerous patterns in all `.c` and `.h` files:
|
||||
### 3. Memory Safety in Error Paths
|
||||
- [ ] All error paths free allocated resources
|
||||
- [ ] No use-after-free on error paths
|
||||
- [ ] No double-free on error paths
|
||||
- [ ] Partial reads handled (don't use incomplete data)
|
||||
|
||||
- [ ] **Fixed-size stack buffers** used for unbounded or network-provided data
|
||||
```c
|
||||
char path[PATH_MAX]; // OK if PATH_MAX is used, bad if size is arbitrary
|
||||
char buf[1024]; // SUSPICIOUS — what limits the input to 1024?
|
||||
char line[4096]; // SUSPICIOUS — what limits the line length?
|
||||
```
|
||||
- [ ] **`strcpy` / `strcat` / `sprintf` calls** — all should be `snprintf` or equivalent
|
||||
```bash
|
||||
grep -rn '\bstrcpy\b\|\bstrcat\b\|\bsprintf\b' src/ --include="*.c" --include="*.h"
|
||||
```
|
||||
- [ ] **Unbounded `sprintf` to fixed buffer**
|
||||
```c
|
||||
char buf[256];
|
||||
sprintf(buf, "%s/%s", dir, filename); // DANGER — no size limit
|
||||
```
|
||||
- [ ] **Off-by-one in string operations** — `strlen` usage without `+ 1` for null terminator
|
||||
- [ ] **`scanf` / `fscanf` / `sscanf` with `%s` and no width limit**
|
||||
```c
|
||||
sscanf(input, "%s", buffer); // DANGER — no width limit on %s
|
||||
```
|
||||
- [ ] **`memcpy` / `memmove` with unchecked size from network data**
|
||||
### 4. TLS/SSL Security
|
||||
- [ ] TLS 1.2 minimum enforced (no SSLv3, TLS 1.0, TLS 1.1)
|
||||
- [ ] Certificate verification enabled when CA provided
|
||||
- [ ] Certificate verification disabled only with explicit warning
|
||||
- [ ] Private key file permissions checked
|
||||
- [ ] No hardcoded certificates or keys
|
||||
- [ ] Cipher suites restricted to strong algorithms
|
||||
- [ ] SSL error codes checked after `SSL_read`/`SSL_write`
|
||||
|
||||
### 3. Path Traversal in File Operations
|
||||
|
||||
Check all paths constructed from received data:
|
||||
|
||||
- [ ] **Files constructed with client-provided filenames + destination directory**
|
||||
```c
|
||||
snprintf(path, PATH_MAX, "%s/%s", dest_dir, received_filename);
|
||||
```
|
||||
Check for `../` filtering:
|
||||
```bash
|
||||
grep -rn 'snprintf.*%s.*%s.*path\|snprintf.*dest_dir\|snprintf.*base_dir' src/ --include="*.c"
|
||||
```
|
||||
- [ ] **`realpath()` usage** for path canonicalization
|
||||
- [ ] **Symlink following** — does the server follow symlinks in the destination?
|
||||
- [ ] **Null byte injection** — received filenames with embedded `\0`
|
||||
|
||||
### 4. Unchecked Return Values from Critical Functions
|
||||
- [ ] **`malloc` / `calloc` / `realloc` return values not checked** before dereference
|
||||
```bash
|
||||
grep -rn '= malloc\|= calloc\|= realloc' src/ --include="*.c"
|
||||
```
|
||||
For each match, verify NULL check exists before use.
|
||||
- [ ] **`send_n_data` / `receive_n_data` return values** not checked
|
||||
- [ ] **`SSL_read` / `SSL_write`** error codes not checked
|
||||
- [ ] **`write()` / `read()` syscall** return values not checked (short writes/reads)
|
||||
- [ ] **`fopen()` / `open()`** return values not checked
|
||||
- [ ] **`snprintf` / `vsnprintf`** negative return not handled
|
||||
|
||||
### 5. TLS / SSL Security
|
||||
- [ ] **TLS version not restricted** — server allows SSLv3, TLS 1.0, or TLS 1.1
|
||||
```c
|
||||
SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION); // REQUIRED
|
||||
```
|
||||
- [ ] **Certificate verification disabled** without explicit `--ca`/warning
|
||||
- [ ] **`SSL_CTX_set_verify` not called** — default is no verification
|
||||
- [ ] **Weak cipher suites allowed** — need to call `SSL_CTX_set_cipher_list()`
|
||||
- [ ] **Private key file permissions** not checked before loading
|
||||
- [ ] **Hostname verification** not performed on server certificate
|
||||
- [ ] **Session renegotiation** not limited (DoS vector)
|
||||
- [ ] **TLS certificate/key paths from untrusted input** — can client specify arbitrary paths?
|
||||
- [ ] **No hardcoded certificates or keys**
|
||||
- [ ] **SSL error codes checked after `SSL_read`/`SSL_write`**
|
||||
|
||||
### 6. Memory Safety Issues
|
||||
- [ ] **Use-after-free** — object freed but pointer still used later
|
||||
- [ ] **Double-free** — `free()` called twice on same pointer
|
||||
- [ ] **Memory leaks** on error paths — allocated but not freed before return
|
||||
- [ ] **Integer overflow** in allocation size computation
|
||||
```c
|
||||
// DANGER: count * sizeof(Type) can overflow
|
||||
void *arr = malloc(count * sizeof(Element));
|
||||
|
||||
// SAFE:
|
||||
if (count > SIZE_MAX / sizeof(Element)) return NULL;
|
||||
void *arr = malloc(count * sizeof(Element));
|
||||
```
|
||||
- [ ] **`realloc` return value** not saved to temporary pointer (leak on failure)
|
||||
```c
|
||||
// BAD: leaks original pointer on failure
|
||||
buf = realloc(buf, new_size);
|
||||
|
||||
// GOOD:
|
||||
void *tmp = realloc(buf, new_size);
|
||||
if (!tmp) { free(buf); return NULL; }
|
||||
buf = tmp;
|
||||
```
|
||||
- [ ] **All error paths free allocated resources** (no leaks / UAF / double-free)
|
||||
- [ ] **Partial reads handled** (don't use incomplete data)
|
||||
|
||||
### 7. Integer Overflow in Allocation
|
||||
|
||||
Check all size calculations:
|
||||
|
||||
- [ ] Allocations where count comes from network data (chunk count, file count, etc.)
|
||||
- [ ] Allocations where size is multiplied by count
|
||||
```bash
|
||||
grep -rn 'malloc.*\*.*sizeof\|calloc(.*sizeof' src/ --include="*.c"
|
||||
```
|
||||
- [ ] Loop counters that could wrap (unsigned underflow)
|
||||
- [ ] Signed integer overflow in size checks
|
||||
|
||||
### 8. Format String Vulnerabilities
|
||||
- [ ] User-controlled data passed as format string
|
||||
```c
|
||||
printf(user_input); // VULNERABLE
|
||||
fprintf(stderr, user_input); // VULNERABLE
|
||||
syslog(LOG_INFO, user_input); // VULNERABLE
|
||||
|
||||
printf("%s", user_input); // SAFE
|
||||
```
|
||||
```bash
|
||||
grep -rn 'printf(\|fprintf(\|syslog(\|snprintf(' src/ --include="*.c" | grep -v '"[^"]*%'
|
||||
```
|
||||
|
||||
### 9. Authentication & Authorization
|
||||
### 5. Authentication & Authorization
|
||||
- [ ] SSH transport relies on SSH authentication (not custom auth)
|
||||
- [ ] No password/credential storage in plaintext
|
||||
- [ ] Server doesn't trust client-supplied paths blindly
|
||||
- [ ] Destination directory validated before writing
|
||||
|
||||
### 10. TOCTOU Race Conditions
|
||||
- [ ] File existence check followed by open (Time-of-check to Time-of-use)
|
||||
```c
|
||||
if (access(path, F_OK) == 0) { // CHECK
|
||||
fd = open(path, O_RDWR); // USE — file could have changed
|
||||
}
|
||||
```
|
||||
- [ ] `stat()` followed by `open()` with different permissions
|
||||
- [ ] Temporary file creation with predictable names
|
||||
### 6. Denial of Service
|
||||
- [ ] Bounded memory allocation (can't OOM server with huge chunk)
|
||||
- [ ] Timeout on connections (no indefinite blocking)
|
||||
- [ ] Maximum connection limit or rate limiting
|
||||
- [ ] Malformed protocol messages handled gracefully (no crash)
|
||||
|
||||
### 11. Insecure Temporary File Usage
|
||||
- [ ] `mktemp` / `tmpnam` — use `mkstemp` instead
|
||||
- [ ] Temporary files created in world-writable directories
|
||||
- [ ] Temporary files not cleaned up on error paths
|
||||
- [ ] Predictable temp file names (race + symlink attack)
|
||||
### 7. Cryptographic Practices
|
||||
- [ ] No custom crypto — uses OpenSSL only
|
||||
- [ ] No hardcoded keys, IVs, or salts
|
||||
- [ ] Random data from `/dev/urandom` or OpenSSL `RAND_bytes`
|
||||
|
||||
### 12. Hardcoded Secrets / Credentials
|
||||
- [ ] Hardcoded passwords, API keys, or tokens
|
||||
- [ ] Hardcoded TLS private keys or certificates
|
||||
- [ ] Hardcoded connection strings with embedded credentials
|
||||
- [ ] Test certificates/keys in source tree (should be documented if intentional)
|
||||
|
||||
### 13. Denial of Service Vectors
|
||||
- [ ] **Unbounded memory allocation** — can client request huge allocation that OOMs server?
|
||||
- Check `chunk.c` for chunk count limits
|
||||
- Check `protocol.c` for message size limits
|
||||
- Check `config.c` for config field size limits
|
||||
- [ ] **No connection limits** — server doesn't cap concurrent connections
|
||||
- [ ] **No timeouts** — connections can hang indefinitely
|
||||
- [ ] **Recursive parsing** — could cause stack overflow with crafted input
|
||||
- [ ] **Repeated slow reads** — slow loris style attack
|
||||
- [ ] **Fork bomb** — server forks per connection without limit
|
||||
|
||||
### 14. Information Disclosure
|
||||
- [ ] Server sends detailed error messages to client (path disclosure, version info)
|
||||
- [ ] Debug logging enabled in production
|
||||
- [ ] Stack traces leaked to users
|
||||
- [ ] Timing side channels in authentication or comparison
|
||||
|
||||
### 15. File System Security
|
||||
### 8. File System Security
|
||||
- [ ] Received file permissions validated (no SUID/SGID injection)
|
||||
- [ ] Symlink attack prevention (don't follow symlinks in destination)
|
||||
- [ ] Race conditions in file creation (TOCTOU)
|
||||
- [ ] Temporary file security (if any)
|
||||
|
||||
### 16. Cryptographic Practices
|
||||
- [ ] No custom crypto — uses OpenSSL only
|
||||
- [ ] No hardcoded keys, IVs, or salts
|
||||
- [ ] Random data from `/dev/urandom` or OpenSSL `RAND_bytes`
|
||||
|
||||
## Common Vulnerability Patterns
|
||||
|
||||
### Format String Bugs
|
||||
@@ -288,59 +118,9 @@ receive_n_data(fd, buffer, expected_size);
|
||||
if (!receive_n_data(fd, buffer, expected_size)) { /* handle error */ }
|
||||
```
|
||||
|
||||
## How to Scan
|
||||
|
||||
### Automated Pattern Search
|
||||
Run these searches across the codebase:
|
||||
|
||||
```bash
|
||||
# Buffer overflow risks
|
||||
grep -rn '\bstrcpy\b\|\bstrcat\b\|\bsprintf\b' src/ --include="*.c"
|
||||
|
||||
# Fixed size stack buffers
|
||||
grep -rn 'char [a-z_]*\[[0-9]*\];' src/ --include="*.c" --include="*.h"
|
||||
|
||||
# Format string risks
|
||||
grep -rn 'printf(\|fprintf(\|syslog(' src/ --include="*.c" | grep -v '"[^"]*%'
|
||||
|
||||
# Malloc without null check pattern
|
||||
grep -rn '= malloc\|= calloc\|= realloc' src/ --include="*.c"
|
||||
|
||||
# Integer overflow in allocation
|
||||
grep -rn 'malloc.*\*\|calloc.*<' src/ --include="*.c"
|
||||
|
||||
# Path construction
|
||||
grep -rn 'snprintf.*path\|snprintf.*dir' src/ --include="*.c"
|
||||
```
|
||||
|
||||
### Manual Code Review
|
||||
After automated scanning, manually review high-risk files:
|
||||
1. `src/shared/protocol.c` — all receive paths
|
||||
2. `src/shared/config.c` — deserialization logic
|
||||
3. `src/shared/chunk.c` — chunk parsing
|
||||
4. `src/shared/transport_tls.c` — TLS configuration
|
||||
5. `src/server/server.c` — file writing and connection handling
|
||||
|
||||
## Output Format
|
||||
|
||||
Return findings in this structured format, one per vulnerability:
|
||||
|
||||
```
|
||||
## Finding: <Short descriptive title>
|
||||
- **Severity**: critical/high/medium/low
|
||||
- **Category**: security
|
||||
- **Location**: file:line range
|
||||
- **Description**: what the vulnerability is, including:
|
||||
- How it can be triggered
|
||||
- What the impact is (RCE, DoS, info leak, etc.)
|
||||
- Whether it requires authentication
|
||||
- **Suggestion**: how to fix it, including concrete code changes
|
||||
- **Labels**: security, comma-separated additional labels
|
||||
```
|
||||
|
||||
### Detailed Finding Fields
|
||||
|
||||
For each vulnerability found, also be prepared to report:
|
||||
For each vulnerability found:
|
||||
1. **Location** — file:line
|
||||
2. **Severity** — critical / high / medium / low / informational
|
||||
3. **Category** — input-validation / buffer / memory / tls / auth / dos / crypto / fs
|
||||
@@ -349,31 +129,6 @@ For each vulnerability found, also be prepared to report:
|
||||
6. **Fix** — concrete code change
|
||||
7. **CVSS estimate** — rough severity score if exploitable
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
## Finding: Unchecked malloc in chunk deserialization allows OOM
|
||||
- **Severity**: high
|
||||
- **Category**: security
|
||||
- **Location**: src/shared/chunk.c:45-50
|
||||
- **Description**: `chunk_deserialize()` calls `malloc(count * sizeof(File))`
|
||||
where `count` comes directly from the network. An attacker can send a crafted
|
||||
chunk header with an extremely large count (e.g., UINT32_MAX), causing malloc
|
||||
to either fail (crash if unchecked) or allocate enormous memory (OOM).
|
||||
No authentication needed — the attack works on the initial connection.
|
||||
- **Suggestion**: Add bounds checking before allocation:
|
||||
```c
|
||||
if (count > MAX_CHUNK_FILES || count > SIZE_MAX / sizeof(File)) {
|
||||
log_error("Invalid chunk file count: %u", count);
|
||||
return NULL;
|
||||
}
|
||||
```
|
||||
Define `MAX_CHUNK_FILES` as a reasonable limit (e.g., 100000).
|
||||
- **Labels**: security, dos
|
||||
```
|
||||
|
||||
### Audit Summary
|
||||
|
||||
Also provide a summary:
|
||||
```
|
||||
=== SECURITY AUDIT SUMMARY ===
|
||||
@@ -384,32 +139,3 @@ Medium: <count>
|
||||
Low: <count>
|
||||
Informational: <count>
|
||||
```
|
||||
|
||||
### No Findings
|
||||
If no security issues are found, return:
|
||||
```
|
||||
## No security findings
|
||||
The codebase appears clean in the areas checked. No vulnerabilities found at this time.
|
||||
```
|
||||
|
||||
## Severity Guidelines
|
||||
|
||||
| Severity | Definition | Example |
|
||||
|---|---|---|
|
||||
| **critical** | Remote code execution, unauthenticated compromise | Buffer overflow on network input |
|
||||
| **high** | Significant impact but requires specific conditions | DoS via unbounded allocation, path traversal |
|
||||
| **medium** | Limited impact, requires auth or other conditions | TOCTOU race in file operations |
|
||||
| **low** | Minor issues, defense in depth | Missing null check that's unlikely to trigger |
|
||||
| **informational** | Not exploitable but violates best practice | Hardcoded value that could be configurable |
|
||||
|
||||
## CI & Task Execution
|
||||
|
||||
When using `tea` (the task execution agent) to run CI or tests, always set a sufficient timeout (e.g., 600000ms) to allow the workflow to finish. After CI completes, check the results yourself — inspect logs if the run failed. Never assume success.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
Never push directly to `dev` or `main`. All changes must be developed on a feature branch and merged via a pull request targeting `dev`. Create a branch (`git checkout -b <branch-name>`), push it, and open the PR with `tea pr create --repo TapTap/FastSync --base dev --head <branch-name>`. Wait for CI to pass before merging.
|
||||
|
||||
## Dependency Installation
|
||||
|
||||
**CI rule:** never add `apt-get install` / `pip install` steps to CI workflows — use the custom Docker image instead. **Host rule:** for local development, use `nix-shell` (see `README.md`) which provides zstd, OpenSSL, CMake, and gcc. See `AGENTS.md` for details.
|
||||
|
||||
@@ -138,9 +138,11 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
|
||||
|
||||
Build for fuzzing:
|
||||
```bash
|
||||
CC=clang CXX=clang++ cmake -B build-fuzz -S . -DENABLE_FUZZ=ON
|
||||
cmake -B build-fuzz -S . \
|
||||
-DCMAKE_C_FLAGS="-fsanitize=fuzzer,address,undefined -g" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=fuzzer,address,undefined"
|
||||
cmake --build build-fuzz -j$(nproc)
|
||||
./build-fuzz/fuzz_chunk_deserialize corpus/ -max_len=1048576
|
||||
./build-fuzz/tests/fuzz_chunk_deserialize corpus/ -max_len=1048576
|
||||
```
|
||||
|
||||
### AFL++ Harness
|
||||
@@ -171,7 +173,7 @@ When writing integration tests (Python-based), follow the patterns in `tests/int
|
||||
- `test_tls.py` — TLS transport tests
|
||||
- `test_features.py` — feature-specific tests (delete, exclude, incremental, etc.)
|
||||
|
||||
Use `tests/conftest.py` fixtures for server setup/teardown (note: the file is at `tests/conftest.py`, not `tests/integration/conftest.py`).
|
||||
Use `conftest.py` fixtures for server setup/teardown.
|
||||
|
||||
### Minimal Integration Test
|
||||
```python
|
||||
@@ -207,15 +209,3 @@ When asked to write tests, produce:
|
||||
3. The runner.c modification needed
|
||||
4. Verify with a build and test run
|
||||
5. Suggest fuzzing targets if relevant
|
||||
|
||||
## CI & Task Execution
|
||||
|
||||
When using `tea` (the task execution agent) to run CI or tests, always set a sufficient timeout (e.g., 600000ms) to allow the workflow to finish. After CI completes, check the results yourself — inspect logs if the run failed. Never assume success.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
Never push directly to `dev` or `main`. All changes must be developed on a feature branch and merged via a pull request targeting `dev`. Create a branch (`git checkout -b <branch-name>`), push it, and open the PR with `tea pr create --repo TapTap/FastSync --base dev --head <branch-name>`. Wait for CI to pass before merging.
|
||||
|
||||
## Dependency Installation
|
||||
|
||||
**CI rule:** never add `apt-get install` / `pip install` steps to CI workflows — use the custom Docker image instead. **Host rule:** for local development, use `nix-shell` (see `README.md`) which provides zstd, OpenSSL, CMake, and gcc. See `AGENTS.md` for details.
|
||||
|
||||
@@ -38,62 +38,50 @@ dd if=/dev/urandom of=/tmp/fastsync_bench/src/large.bin bs=1M count=10 2>/dev/nu
|
||||
Test each configuration 3 times, record median:
|
||||
|
||||
```bash
|
||||
# Real FastSync flags: -z=compression, -j=multithreading,
|
||||
# --chunk-serialization, --sendfile (long form only). The old rsync-style
|
||||
# spellings -c/-m/-s/-f are NOT the same options (-c=--checksum,
|
||||
# -m=--prune-empty-dirs, -s=--secluded-args, -f=--filter) and must not be used.
|
||||
CONFIGS=(
|
||||
"Standard|"
|
||||
"Compression|-z"
|
||||
"Multithreading|-j"
|
||||
"MT+Compression|-j -z"
|
||||
"Chunk Serialization|-j -z --chunk-serialization"
|
||||
"Sendfile|--sendfile"
|
||||
"Compression|-c"
|
||||
"Multithreading|-m"
|
||||
"MT+Compression|-m -c"
|
||||
"Chunk Serialization|-s"
|
||||
"MT+Compression+Chunk|-m -c -s"
|
||||
"Sendfile|-f"
|
||||
)
|
||||
|
||||
PORT=18080
|
||||
for config in "${CONFIGS[@]}"; do
|
||||
IFS='|' read -r name flags <<< "$config"
|
||||
echo "=== $name ==="
|
||||
for run in 1 2 3; do
|
||||
rm -rf /tmp/fastsync_bench/dst
|
||||
mkdir -p /tmp/fastsync_bench/dst
|
||||
|
||||
./build/server -p "$PORT" --allow-unauthenticated &
|
||||
|
||||
./build/server &
|
||||
SERVER_PID=$!
|
||||
sleep 0.5
|
||||
|
||||
|
||||
START=$(date +%s%N)
|
||||
./build/client --source-dir /tmp/fastsync_bench/src \
|
||||
--dest-dir /tmp/fastsync_bench/dst \
|
||||
--server-port "$PORT" \
|
||||
--save-to-disk $flags
|
||||
END=$(date +%s%N)
|
||||
|
||||
|
||||
ELAPSED=$(( (END - START) / 1000000 ))
|
||||
echo " Run $run: ${ELAPSED}ms"
|
||||
|
||||
|
||||
kill $SERVER_PID 2>/dev/null
|
||||
wait $SERVER_PID 2>/dev/null
|
||||
done
|
||||
done
|
||||
```
|
||||
|
||||
### Step 4: Full Benchmark Tool (Preferred)
|
||||
|
||||
The maintained benchmark tool is `benchmark/bench.py`. It handles building,
|
||||
data generation, network shaping (LAN/WAN profiles or custom `--delay`/`--jitter`/
|
||||
`--throughput`/`--loss`), rsync comparison, and JSON/table reporting:
|
||||
### Step 4: Full Integration Benchmark (Optional)
|
||||
|
||||
For comprehensive benchmarking with network shaping:
|
||||
```bash
|
||||
python3 benchmark/bench.py --help
|
||||
python3 benchmark/bench.py --runs 5 --profiles unlimited
|
||||
python3 benchmark/bench.py --size-mb 100 --random-ratio 0.5 --output json
|
||||
python3 benchmark/bench.py --delay 50ms --jitter 10ms --throughput 100mbit
|
||||
python3 test.py --full
|
||||
```
|
||||
|
||||
Network shaping needs root (`tc`/`netem` on `lo`). SSH and TLS coverage lives in
|
||||
the pytest integration suite, not the benchmark tool.
|
||||
This tests LAN/WAN profiles, SSH, TLS, and compares against rsync.
|
||||
|
||||
### Step 5: Report Results
|
||||
|
||||
@@ -104,14 +92,13 @@ Platform: <OS, CPU, network>
|
||||
|
||||
Configuration | Run 1 | Run 2 | Run 3 | Median
|
||||
-----------------------|---------|---------|---------|--------
|
||||
Standard | 0.12s | 0.11s | 0.12s | 0.12s
|
||||
Compression (-z) | 0.09s | 0.08s | 0.09s | 0.09s
|
||||
Multithreading (-j) | 0.07s | 0.07s | 0.08s | 0.07s
|
||||
MT+Compression (-j -z) | 0.05s | 0.05s | 0.06s | 0.05s
|
||||
Chunk Serialization (--chunk-serialization) | 0.05s | 0.04s | 0.05s | 0.05s
|
||||
Sendfile (--sendfile) | 0.04s | 0.04s | 0.04s | 0.04s
|
||||
Standard | 0.12s | 0.11s | 0.12s | 0.12s
|
||||
Compression (-c) | 0.09s | 0.08s | 0.09s | 0.09s
|
||||
Multithreading (-m) | 0.07s | 0.07s | 0.08s | 0.07s
|
||||
MT+Compression (-m -c) | 0.05s | 0.05s | 0.06s | 0.05s
|
||||
Sendfile (-f) | 0.04s | 0.04s | 0.04s | 0.04s
|
||||
|
||||
Best configuration: Sendfile (--sendfile)
|
||||
Best configuration: MT+Compression (-m -c)
|
||||
Throughput: <X> MB/s
|
||||
```
|
||||
|
||||
|
||||
@@ -32,19 +32,23 @@ Try to reproduce the issue with the exact command the user provides.
|
||||
|
||||
**Memory errors (first priority):**
|
||||
```bash
|
||||
rm -rf build-asan
|
||||
cmake -B build-asan -S . -DSANITIZER=address
|
||||
cmake --build build-asan -j$(nproc)
|
||||
./build-asan/tests
|
||||
rm -rf build
|
||||
cmake -B build -S . \
|
||||
-DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address"
|
||||
cmake --build build -j$(nproc)
|
||||
./build/tests
|
||||
# or run the failing command
|
||||
```
|
||||
|
||||
**Thread errors:**
|
||||
```bash
|
||||
rm -rf build-tsan
|
||||
cmake -B build-tsan -S . -DSANITIZER=thread
|
||||
cmake --build build-tsan -j$(nproc)
|
||||
./build-tsan/tests
|
||||
rm -rf build
|
||||
cmake -B build -S . \
|
||||
-DCMAKE_C_FLAGS="-fsanitize=thread -g" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=thread"
|
||||
cmake --build build -j$(nproc)
|
||||
./build/tests
|
||||
```
|
||||
|
||||
**Valgrind (if ASan doesn't find it):**
|
||||
@@ -104,12 +108,13 @@ cmake -B build -S . && cmake --build build -j$(nproc)
|
||||
./build/tests
|
||||
|
||||
# If integration test needed
|
||||
python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv"
|
||||
python3 test.py
|
||||
|
||||
# Re-run under sanitizer to confirm fix
|
||||
rm -rf build-asan
|
||||
cmake -B build-asan -S . -DSANITIZER=address
|
||||
cmake --build build-asan -j$(nproc)
|
||||
rm -rf build
|
||||
cmake -B build -S . -DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address"
|
||||
cmake --build build -j$(nproc)
|
||||
# reproduce the original failing command
|
||||
```
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ tea pr checkout <number>
|
||||
If already on a PR branch, verify with:
|
||||
```bash
|
||||
git branch --show-current
|
||||
git log dev..HEAD --oneline
|
||||
git log main..HEAD --oneline
|
||||
```
|
||||
|
||||
### Step 2: Clean build
|
||||
@@ -39,13 +39,17 @@ If the PR touches threading, memory management, or network code, also build with
|
||||
```bash
|
||||
# AddressSanitizer
|
||||
rm -rf build-asan
|
||||
cmake -B build-asan -S . -DSANITIZER=address
|
||||
cmake -B build-asan -S . \
|
||||
-DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer -g" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address"
|
||||
cmake --build build-asan -j$(nproc)
|
||||
./build-asan/tests
|
||||
|
||||
# ThreadSanitizer (if threading changes)
|
||||
rm -rf build-tsan
|
||||
cmake -B build-tsan -S . -DSANITIZER=thread
|
||||
cmake -B build-tsan -S . \
|
||||
-DCMAKE_C_FLAGS="-fsanitize=thread -g" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=thread"
|
||||
cmake --build build-tsan -j$(nproc)
|
||||
./build-tsan/tests
|
||||
```
|
||||
@@ -87,10 +91,10 @@ If tests fail:
|
||||
### Step 6: Run integration tests (optional)
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv"
|
||||
python3 test.py
|
||||
```
|
||||
|
||||
This runs the integration suite (benchmarking is `benchmark/bench.py`). It takes longer — only run if the user asks or if unit tests pass.
|
||||
This runs the integration + benchmark suite. It takes longer — only run if the user asks or if unit tests pass.
|
||||
|
||||
### Step 7: Fix and commit
|
||||
|
||||
|
||||
@@ -19,13 +19,13 @@ tea pr checkout <number>
|
||||
If already on a PR branch, verify with:
|
||||
```bash
|
||||
git branch --show-current
|
||||
git log dev..HEAD --oneline
|
||||
git log main..HEAD --oneline
|
||||
```
|
||||
|
||||
### Step 2: Get changed files
|
||||
|
||||
```bash
|
||||
git diff dev --name-only -- '*.c' '*.h'
|
||||
git diff main --name-only -- '*.c' '*.h'
|
||||
```
|
||||
|
||||
This gives the list of C source and header files changed in the PR.
|
||||
@@ -125,7 +125,7 @@ STYLE: <count>
|
||||
|
||||
If the user wants to post the review as a PR comment:
|
||||
```bash
|
||||
tea comment --repo TapTap/FastSync <number> "<review report>"
|
||||
tea pr comment <number> --comment "<review report>"
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
@@ -16,7 +16,7 @@ Ask the user or determine from context:
|
||||
- **Minor** (x.Y.0) — new features, backward compatible
|
||||
- **Patch** (x.y.Z) — bug fixes, no protocol changes
|
||||
|
||||
Current version: `PROTOCOL_VERSION "2.21.0"` in `src/shared/config.h`
|
||||
Current version: `PROTOCOL_VERSION "1.1.0"` in `src/shared/config.h`
|
||||
|
||||
### Step 2: Check Protocol Version
|
||||
|
||||
@@ -37,7 +37,7 @@ rm -rf build
|
||||
cmake -B build -S .
|
||||
cmake --build build -j$(nproc)
|
||||
./build/tests
|
||||
python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv"
|
||||
python3 test.py
|
||||
```
|
||||
|
||||
ALL tests must pass before release.
|
||||
@@ -46,10 +46,12 @@ ALL tests must pass before release.
|
||||
|
||||
```bash
|
||||
# ASan
|
||||
rm -rf build-asan
|
||||
cmake -B build-asan -S . -DSANITIZER=address
|
||||
cmake --build build-asan -j$(nproc)
|
||||
./build-asan/tests
|
||||
rm -rf build
|
||||
cmake -B build -S . \
|
||||
-DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address"
|
||||
cmake --build build -j$(nproc)
|
||||
./build/tests
|
||||
```
|
||||
|
||||
### Step 5: Update README (If Needed)
|
||||
@@ -77,23 +79,12 @@ git commit -m "Release vX.Y.Z
|
||||
git tag -a vX.Y.Z -m "Release vX.Y.Z"
|
||||
```
|
||||
|
||||
### Step 8: Push and Open dev → main PR
|
||||
|
||||
`main` is protected and only receives changes via `dev` → `main` PRs (see AGENTS.md). Never push directly to `main`.
|
||||
### Step 8: Push
|
||||
|
||||
```bash
|
||||
# Push the release commit and tag to dev
|
||||
git push origin dev
|
||||
git push origin vX.Y.Z
|
||||
|
||||
# Open the dev → main release PR for review + CI
|
||||
tea pr create --repo TapTap/FastSync --head dev --base main \
|
||||
--title "Release vX.Y.Z" \
|
||||
--description "Release vX.Y.Z"
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
Then wait for the full CI to pass and request review before the PR is merged to `main`.
|
||||
|
||||
### Step 9: Report
|
||||
|
||||
```
|
||||
|
||||
@@ -102,9 +102,9 @@ Informational: <count>
|
||||
...
|
||||
|
||||
=== VERDICT ===
|
||||
[PASS] No critical/high-severity issues found
|
||||
[PASS] No critical/high issues found
|
||||
— or —
|
||||
[FAIL] <N> critical/high-severity issues must be fixed
|
||||
[FAIL] <N> critical/high issues must be fixed
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
FastSync is a high-performance file synchronization system written in C11. It supports TCP and SSH transports, TLS encryption (OpenSSL), streaming zstd compression, multithreaded transfers, and incremental sync. The build uses CMake; CI runs on Gitea Actions (`.gitea/workflows/ci.yaml`).
|
||||
|
||||
## Dependency installation
|
||||
|
||||
**CI rule:** never add `apt-get install` / `pip install` steps to CI workflows — use the custom Docker image instead. The image is built from the repo-root `Dockerfile` and is the same image CI uses: `gitea.tap-tap.win/taptap/fastsync-ci:v10`. It contains the full toolchain: gcc/g++, CMake, libzstd-dev, libssl-dev, make, git, cppcheck, clang-format, python3 + pytest + pytest-xdist, openssh-client, and Node.js.
|
||||
|
||||
**Host rule:** for local development, use `nix-shell` (see `README.md`) which provides zstd, OpenSSL, CMake, and gcc. The Docker image can also be used locally for CI parity.
|
||||
|
||||
```bash
|
||||
# Use the prebuilt CI image directly (faster, guaranteed CI parity)
|
||||
docker pull gitea.tap-tap.win/taptap/fastsync-ci:v10
|
||||
docker tag gitea.tap-tap.win/taptap/fastsync-ci:v10 fastsync-ci:local
|
||||
|
||||
# Or build the image from the repo-root Dockerfile
|
||||
# (Note: the prebuilt :v10 image reflects the previous Dockerfile state;
|
||||
# rebuild from source to pick up any newly added packages like lcov/valgrind.)
|
||||
docker build -t fastsync-ci:local .
|
||||
|
||||
# Build, run unit tests, and run integration tests inside the container
|
||||
docker run --rm -v "$PWD:/workspace" -w /workspace fastsync-ci:local \
|
||||
sh -c 'cmake -B build -S . && cmake --build build -j$(nproc) && ./build/tests && python3 -m pytest tests/integration/ -n 4 --dist=load'
|
||||
|
||||
# Avoid root-owned build/ artifacts by matching your host UID/GID
|
||||
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/workspace" \
|
||||
-w /workspace fastsync-ci:local \
|
||||
sh -c 'cmake -B build -S . && cmake --build build -j$(nproc) && ./build/tests && python3 -m pytest tests/integration/ -n 4 --dist=load'
|
||||
```
|
||||
|
||||
> **Note:** The first `cmake configure` (`cmake -B build -S .`) fetches xxHash via `FetchContent` — network access is required. Subsequent reconfigures reuse the cached source.
|
||||
|
||||
If a dependency is missing from the CI image, add it to the `Dockerfile` (and rebuild) rather than adding an install step to the CI workflow.
|
||||
|
||||
## CI Conventions
|
||||
|
||||
When configuring for CI parity, use:
|
||||
```bash
|
||||
cmake -B build -S . -DSTRICT_WARNINGS=ON # -Wextra -Wpedantic -Werror
|
||||
cmake -B build -S . -DSANITIZER=address # AddressSanitizer (ASan)
|
||||
cmake -B build -S . -DSANITIZER=thread # ThreadSanitizer (TSan)
|
||||
```
|
||||
|
||||
The CI workflow (`.gitea/workflows/ci.yaml`) runs lint (clang-format, cppcheck), then a **fast PR gate** — build + unit + a representative subset of integration tests marked `@pytest.mark.ci`, parallelized with pytest-xdist (`-n 4 --dist=load`). The full coverage jobs (full integration suite as `-m "not setpriv"`, sanitizer, fuzz, coverage, valgrind) run **only on push to `dev`/`main`**; pull requests skip them to keep PR CI under ~3 minutes. The two `setpriv` privilege tests are excluded from CI via a marker because their result depends on the runner/container uid and host mount permissions.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cmake -B build -S . && cmake --build build -j$(nproc)
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
./build/tests # unit tests
|
||||
python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv" # full integration suite (CI excludes env-dependent privilege tests)
|
||||
python3 -m pytest tests/integration/ -n 4 --dist=load -m ci # PR-gate subset only
|
||||
```
|
||||
|
||||
## CI Workflow — Waiting for Results
|
||||
|
||||
When running the CI workflow via `tea` (the task execution agent), always set a sufficient timeout (e.g., 600000ms) to allow CI to finish. After CI completes, check the results yourself — do not assume success. Monitor CI status via the Gitea API (see below) or `tea actions`, then inspect logs on failure.
|
||||
|
||||
## CI Troubleshooting
|
||||
|
||||
### If lint (clang-format) fails
|
||||
Run clang-format in the CI Docker image to match the exact CI version:
|
||||
```bash
|
||||
docker run --rm -v "$PWD:/workspace" -w /workspace gitea.tap-tap.win/taptap/fastsync-ci:v10 \
|
||||
sh -c 'find src/ tests/ -name "*.c" -o -name "*.h" | xargs clang-format -i'
|
||||
```
|
||||
|
||||
### If cppcheck fails
|
||||
Fix reported issues locally, then verify with:
|
||||
```bash
|
||||
docker run --rm -v "$PWD:/workspace" -w /workspace gitea.tap-tap.win/taptap/fastsync-ci:v10 \
|
||||
sh -c 'cppcheck --enable=warning,style,performance,portability --suppress=missingIncludeSystem --error-exitcode=1 --inline-suppr src/ tests/'
|
||||
```
|
||||
|
||||
### If integration tests fail
|
||||
Run locally before pushing:
|
||||
```bash
|
||||
python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv"
|
||||
```
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
Two main branches: `dev` (integration) and `main` (stable releases).
|
||||
|
||||
### Rules
|
||||
- **All PRs target `dev`** — never target `main` directly
|
||||
- **`dev` is the default branch** in Gitea repo settings
|
||||
- **`main` is protected** — only merged from `dev` via PR with 2 approvals + full CI pass
|
||||
- **Feature/bug branches** branch from `dev`, PR back to `dev`
|
||||
- **`dev` → `main` merges** happen on-demand or weekly, requiring full CI + review
|
||||
|
||||
```bash
|
||||
# Start a new feature
|
||||
git checkout dev && git pull
|
||||
git checkout -b feat/my-feature
|
||||
# ... work, commit, push
|
||||
git push -u origin feat/my-feature
|
||||
# Create PR targeting dev
|
||||
```
|
||||
|
||||
### Creating the `dev` branch (one-time setup)
|
||||
```bash
|
||||
git checkout main && git pull
|
||||
git checkout -b dev
|
||||
git push origin dev
|
||||
# Then in Gitea: Settings → Repository → Default Branch → dev
|
||||
```
|
||||
|
||||
### Branch protection (Gitea repo settings)
|
||||
**For `dev`:**
|
||||
- ✅ Require PR for merging
|
||||
- ✅ Require 1 approval
|
||||
- ✅ Require status checks (all CI jobs must pass)
|
||||
- ✅ Delete branch after merge
|
||||
|
||||
**For `main`:**
|
||||
- ✅ Require PR from `dev` only
|
||||
- ✅ Require CI
|
||||
- ✅ Require 2 approvals
|
||||
- ✅ No direct pushes
|
||||
|
||||
## Automated Agent Workflows
|
||||
|
||||
All agents run locally via the opencode CLI. There is no CI-based agent automation — agents are invoked on-demand by the developer or by this assistant.
|
||||
|
||||
### One-command batch workflow
|
||||
|
||||
For fixing a set of issues and creating one integration PR:
|
||||
|
||||
```bash
|
||||
# 1. Run each subagent on its category
|
||||
opencode run --agent security-auditor "Fix all open security issues"
|
||||
opencode run --agent debugger "Fix all open bugs"
|
||||
opencode run --agent test-writer "Add missing test coverage"
|
||||
|
||||
# 2. The assistant handles: merging branches, fixing CI failures,
|
||||
# pushing, creating the integration PR, waiting for CI, iterating.
|
||||
# The developer only reviews the final PR.
|
||||
```
|
||||
|
||||
### Issue triage loop
|
||||
When you want to fix a batch of issues autonomously:
|
||||
|
||||
1. Tell the assistant: *"Fix all open issues and create one big PR"*
|
||||
2. The assistant delegates to subagents in parallel
|
||||
3. Merges their branches, handles CI failures iteratively
|
||||
4. Pushes and opens the final PR
|
||||
5. You review the PR once CI passes — no intermediate check-ins
|
||||
|
||||
### Scheduling
|
||||
For periodic maintenance (security audits, code quality scans), run:
|
||||
|
||||
```bash
|
||||
opencode run --agent security-auditor "Audit the codebase for vulnerabilities"
|
||||
opencode run --agent code-quality-guardian "Scan for code quality issues"
|
||||
```
|
||||
|
||||
This can be cron'd locally if desired (e.g., `crontab -e` with `opencode run`).
|
||||
|
||||
## Is opencode a good option?
|
||||
|
||||
**Yes, for FastSync's needs.** The hybrid model works well:
|
||||
- opencode's 16 specialized agents handle deep code analysis, fixes, tests, and reviews
|
||||
- The assistant orchestrates subagents, merges branches, and iterates on CI
|
||||
- You only review the final output
|
||||
|
||||
The key limitation: opencode is session-based, not a persistent daemon. But for the "fix all issues, one PR" workflow, this is fine — the assistant runs the full pipeline in one shot. Persistent webhook-driven automation isn't available for Gitea, but the one-shot batch approach is simpler and gives you full control over what gets merged.
|
||||
|
||||
### Recommendations for this project
|
||||
- **Do** use the batch pattern: delegate to subagents, let the assistant merge + iterate CI, review once
|
||||
- **Don't** try to run opencode in Gitea Actions — the CI container doesn't have your LLM keys or the interactive context agents need
|
||||
- **If** you want fully hands-off periodic scans, set up a local cron job or systemd timer that runs `opencode run` and posts results to Gitea via API
|
||||
|
||||
## Gitea API & tea CLI
|
||||
|
||||
### Check CI status via API
|
||||
```bash
|
||||
TOKEN="<token>"
|
||||
curl -s -H "Authorization: token $TOKEN" \
|
||||
"https://gitea.tap-tap.win/api/v1/repos/TapTap/FastSync/actions/runs?limit=5" \
|
||||
| python3 -c "
|
||||
import json,sys; d=json.load(sys.stdin)
|
||||
for r in d.get('workflow_runs',[]):
|
||||
path = r.get('path','')
|
||||
prn = path.split('@')[1].replace('refs/pull/','').replace('/head','') if '@' in path else ''
|
||||
print(f'PR #{prn}: sha={r[\"head_sha\"][:8]} {r[\"status\"]} {r.get(\"conclusion\",\"\")}')
|
||||
"
|
||||
```
|
||||
|
||||
### Post review comments
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"body":"MARKDOWN_REVIEW_BODY"}' \
|
||||
"https://gitea.tap-tap.win/api/v1/repos/TapTap/FastSync/issues/<PR_NUMBER>/comments"
|
||||
```
|
||||
|
||||
### Use tea for PR operations
|
||||
```bash
|
||||
tea pr list --repo TapTap/FastSync
|
||||
tea pr close <number> --repo TapTap/FastSync
|
||||
```
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Per-thread SSL context**: `io_ssl` is stored per-thread (`static __thread SSL* io_ssl`). Each thread that performs protocol I/O must call `io_set_ssl()` to install its own SSL object before using `send_*` / `receive_*` primitives. The main thread's SSL context is not automatically inherited by worker threads.
|
||||
- **SSL WANT_READ/WANT_WRITE retry**: Always retry on `SSL_ERROR_WANT_READ` and `SSL_ERROR_WANT_WRITE` in `send_n_data`/`receive_n_data`. Removing these breaks TLS multithreaded transfers.
|
||||
- **clang-format version**: The CI image uses clang-format 18. Always format inside the CI Docker container for exact match.
|
||||
- **Merge order matters**: Merge the most comprehensive branch first, then smaller ones, to minimize conflicts when creating a combined branch.
|
||||
-199
@@ -1,199 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to FastSync are documented here. Versions match
|
||||
`PROTOCOL_VERSION` (printed by `fastsync --version`); the client and server must
|
||||
run the same version because the handshake is strict.
|
||||
|
||||
## [2.21.0] - 2026-09-14
|
||||
|
||||
### Added
|
||||
|
||||
- Optional server→client rejection detail (protocol 2.21.0). A rejected
|
||||
operation may now carry a bounded human-readable reason via
|
||||
`STATUS_ERROR_DETAIL` instead of a bare `STATUS_ERROR`, so the client can
|
||||
report *why* the server refused (daemon module gate, config validation,
|
||||
receiver-side path/node validation). `receive_status()` transparently maps the
|
||||
new status back to `STATUS_ERROR` for every existing call site and captures
|
||||
the reason into a thread-local buffer exposed by `protocol_last_error()`. The
|
||||
detail body is always consumed, so the stream cannot desynchronize, and
|
||||
messages are sliced to `MAX_ERROR_DETAIL_BYTES` (4096) on send.
|
||||
- **Server-contacting `--dry-run` (protocol 2.21.0).** `--dry-run` now performs
|
||||
a real handshake with a remote/daemon receiver and reports exactly what WOULD
|
||||
change based on receiver state (existing destination files, mtimes, checksums,
|
||||
basis dirs). The wire config carries the dry-run intent (`Config.dry_run`) and
|
||||
the receiver answers each per-file check with `STATUS_DRY_RUN_TRANSFER` (would
|
||||
transfer) or `STATUS_OK` (already up to date); the sender prints the
|
||||
would-transfer set and its trailer without sending any file data. The receiver
|
||||
performs the normal read-only incremental decision but mutates nothing: no temp
|
||||
files, writes, renames, deletes, metadata/xattr/chown, or directory creation.
|
||||
A plain local destination (no explicit `--server-port`/remote) keeps the
|
||||
original client-side dry-run. Would-delete reporting for `--delete*` is
|
||||
deferred to a follow-up; dry-run never deletes.
|
||||
- Daemon `max connections per host` (per-source-IP concurrent cap, default 0 =
|
||||
unlimited), `auth lockout threshold` (default 10; 0 disables) and
|
||||
`auth lockout duration` (default 300 s) config keys.
|
||||
- `fastsync-server --allow-super` opt-in for a privileged standalone TCP server;
|
||||
without it a root standalone receiver forces super-user activities off (device
|
||||
nodes, `--write-devices`, ownership). The `--stdio` SSH argv is client-composed,
|
||||
so super activities always stay off there.
|
||||
|
||||
### Changed
|
||||
|
||||
- Config wire fields are now declared once in an X-macro table
|
||||
(`CONFIG_WIRE_FIELDS` in `src/shared/config.h`) that generates the struct
|
||||
members, defaults, and the send/receive sequence, removing the manual
|
||||
six-site field sync. Wire bytes and `PROTOCOL_VERSION` are unchanged.
|
||||
- `receive_incremental_check()` (the per-file `STATUS_CHECK` fast path) is split
|
||||
into small static helpers with a short linear orchestrator. Pure refactor: the
|
||||
wire byte stream and all cleanup are unchanged.
|
||||
- `authorized_root` state has a single owner (`utils.c`) with read accessors; the
|
||||
duplicated statics in `file.c` and the server were removed.
|
||||
- `Data` records its owning `ProtocolSession` so its memory charge is returned to
|
||||
the session that reserved it, regardless of the destroying thread.
|
||||
- The receiver pipeline moved out of `shared` into `server/receiver_pipeline.[ch]`;
|
||||
the build now uses explicit `fastsync_shared` / `fastsync_client_core` /
|
||||
`fastsync_server_core` targets instead of a GLOB, and the client no longer links
|
||||
server code.
|
||||
- The benchmark tool generates the requested random/compressible data mix
|
||||
accurately, verifies each transfer before recording it, computes correct
|
||||
percentiles, adds a MB/s column, handles `tc`/netem without requiring `sudo`
|
||||
when already root, builds into a dedicated `build-bench/` directory, and adds a
|
||||
`--warm` incremental-transfer mode.
|
||||
- The `nix-shell` dev environment provides the full toolchain (clang-format,
|
||||
cppcheck, pytest-xdist, OpenSSH, rsync, iproute2, valgrind, lcov) and no longer
|
||||
builds on entry.
|
||||
|
||||
### Security
|
||||
|
||||
- Enforce the daemon's per-module `max connections` cap (0 = unlimited) and add
|
||||
the shared per-source `max connections per host` cap plus a cross-process
|
||||
`auth lockout`. Because the listener forks one child per connection, the
|
||||
counters live in an anonymous shared mapping created before the accept loop and
|
||||
reclaimed by the parent's `SIGCHLD` handler, so the per-module, per-source and
|
||||
auth-failure state is shared across every child (including after `SIGKILL`). The
|
||||
per-source table has a bounded lifetime (expired/idle entries are reclaimed,
|
||||
with a rate-limited warning when genuinely full), and the occupancy counters are
|
||||
re-derived from the shared slot table on every child exit. Trusted loopback
|
||||
peers are exempt (they share one address); clients behind a shared NAT/proxy
|
||||
share a single per-host budget and lockout, which is documented.
|
||||
- Hardening from a full security audit:
|
||||
- Fail a truncated zstd frame instead of spinning forever (remote DoS).
|
||||
- Open receiver destination/basis/hard-link entries `O_NONBLOCK` so a
|
||||
client-planted FIFO cannot block a worker indefinitely.
|
||||
- Require a regular file before `--inplace` writes, closing a FIFO-hang and a
|
||||
raw-device write that bypassed the `--write-devices` gate.
|
||||
- Reject SSH destinations whose user/host begins with `-` and insert `--` before
|
||||
the host token, closing `-o ProxyCommand=…` argument injection (RCE).
|
||||
- Gate client `--force` recursive removal behind the server `--allow-delete`
|
||||
policy.
|
||||
- Reject empty `hosts allow`/`hosts deny`/`auth users` values instead of
|
||||
silently meaning "unrestricted".
|
||||
- Restrict TLS 1.2 to AEAD suites and set server cipher preference; load the
|
||||
private key TOCTOU-safely from an `O_NOFOLLOW` fd; verify IP literals against
|
||||
IP SANs; guard client-cert CN truncation.
|
||||
- Make `--dry-run` content-blind: it neither reads destination files nor
|
||||
hashes basis files, removing a 1-bit content oracle against `read only`
|
||||
modules.
|
||||
- Bound glob matching (iterative DP, no exponential backtracking) and bound
|
||||
line reads for filter/`--files-from`/pattern files.
|
||||
- Gate `system.posix_acl_*` xattrs on `--acls` and charge decompression/chunk
|
||||
allocations against the per-connection memory budget.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pre-auth NULL dereference in `config_delete()` when an over-long
|
||||
`basis_count` (and the analogous count fields) was received and then failed
|
||||
validation; received counts are now validated before being published.
|
||||
- Leaked inherited `Data` in the forked compression-truncation unit test
|
||||
(valgrind definite leak).
|
||||
- `receive_status()` no longer loses a captured rejection reason when owed
|
||||
keepalives are drained.
|
||||
|
||||
## [2.20.0] - 2026-09-13
|
||||
|
||||
### Security
|
||||
|
||||
- Cap cumulative `DirTimeList` growth and bound pre-auth config-string memory
|
||||
(remote memory-exhaustion DoS).
|
||||
- Daemon host access control (`hosts allow`/`hosts deny`, IPv4/IPv6/CIDR),
|
||||
configurable global `max connections`, connection audit logging, and a
|
||||
bounded `auth failure delay` throttle. IPv4-mapped peers are normalized and
|
||||
invalid patterns are rejected at parse time (no silent fail-open).
|
||||
- Honor `--timeout` for protocol I/O and bound idle/session time to defeat
|
||||
keepalive slowloris; child-safe signal handling in the forked daemon.
|
||||
- Compiler/linker hardening (`_FORTIFY_SOURCE`, stack protector, PIE, RELRO)
|
||||
and pinned build dependencies.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Use-after-free in the basis-dir oversize preflight.
|
||||
- Placeholder `Data` leaks, `missing_args` leak, scanner chunk leak.
|
||||
- Thread-safe logging; single fd owner and cleanup epilogue in the server
|
||||
handler.
|
||||
|
||||
### Performance
|
||||
|
||||
- Metadata now crosses the wire as one packed frame (protocol 2.20.0).
|
||||
- Delete keep-set and `--files-from` lookups indexed (O(n*m) → O(n)).
|
||||
- Reused per-thread zstd contexts; `TCP_NODELAY` by default.
|
||||
- Byte-bounded sender queues; removed a redundant scanner `stat()`.
|
||||
|
||||
## [2.19.0] - 2026-09-12
|
||||
|
||||
### Security
|
||||
|
||||
- **Daemon authentication rewritten as SCRAM-SHA-256 challenge/response**
|
||||
(`STATUS_AUTH_CHALLENGE` → `STATUS_AUTH_RESPONSE` → `STATUS_AUTH_OK`/`STATUS_AUTH_FAILED`),
|
||||
replacing the old replayable static `SHA-256(password)` bearer credential.
|
||||
Each proof is bound to a fresh per-connection server nonce plus a client
|
||||
nonce, so a captured response can never be reused.
|
||||
- **Salted verifier store.** `--password-file`/`--early-input` now hold
|
||||
`user:$fastsync$1$pbkdf2-sha256$<iters>$<salt>$<stored_key>$<server_key>`
|
||||
(PBKDF2-HMAC-SHA256, default 600000 iterations, range 100000–10000000). The
|
||||
legacy `user:SHA256HEX` form is hard-rejected; there is no auto-upgrade.
|
||||
Generate stores offline with `fastsync-server --hash-credentials FILE
|
||||
[--iterations N]`.
|
||||
- **Username-enumeration hardening.** Unknown/off-list users are answered with a
|
||||
dummy verifier whose salt is a deterministic per-username value
|
||||
(`HMAC-SHA256(dummy_key, username)`), using the store-wide uniform iteration
|
||||
count and a constant-time full-length membership scan. The dummy key is
|
||||
persisted in an owner-only `<store>.dummykey` sidecar (atomic publish, exact
|
||||
mode 0600) so challenges are stable across restarts.
|
||||
- **Verified transport for auth-required modules.** A module with `auth users`
|
||||
accepts credentials only over verified TLS whose client certificate matches
|
||||
`--client-cn`, or — when `--allow-unauthenticated` is explicitly set —
|
||||
plaintext from a loopback peer. Remote plaintext is refused before any
|
||||
challenge. Clients must use `--tls` to send `--password-file` credentials to a
|
||||
non-loopback daemon; `--client-cn` is mandatory with `--tls`.
|
||||
- **Secret hygiene.** The plaintext password, derived keys, nonces/proofs and
|
||||
the dummy key are wiped from memory on every path and never logged.
|
||||
- Carried-over hardening: `-K` TOCTOU-safe directory walk
|
||||
(`openat(O_NOFOLLOW)` per component), always shell-quoted SSH remote path,
|
||||
TLS compression/renegotiation disabled, race-free (open-then-`fstat`)
|
||||
`--password-file`/`--early-input` checks, log-injection escaping, and lazy
|
||||
protocol debug escaping.
|
||||
|
||||
### Added
|
||||
|
||||
- `fastsync-server --hash-credentials FILE [--iterations N]` offline tool.
|
||||
- `<store>.dummykey` sidecar (auto-created, owner-only, 0600).
|
||||
- Integration tests for auth replay rejection, malformed frames, legacy-store
|
||||
refusal, and the loopback/TLS transport policy; fuzz targets for config
|
||||
receive and daemon-auth parsing.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Protocol version 2.18.0 → 2.19.0 (breaking).** The config-frame auth block
|
||||
is now `[present][username]` (digest removed) and the auth challenge/response
|
||||
frames are interleaved between the config frame and its `STATUS_OK`. A 2.19.0
|
||||
client and a 2.18.0 server (or vice versa) fail cleanly at the handshake.
|
||||
- Daemon modules declaring `auth users` require a configured credential store at
|
||||
startup (fail closed); operators regenerate stores from plaintext with
|
||||
`--hash-credentials`.
|
||||
|
||||
### Notes
|
||||
|
||||
- First tagged release. FastSync implements rsync-compatible file
|
||||
synchronization over TCP and SSH with TLS (OpenSSL), streaming zstd
|
||||
compression, multithreaded transfers, and incremental sync. See
|
||||
[RSYNC_COMPAT.md](RSYNC_COMPAT.md) for the flag-parity matrix.
|
||||
+17
-247
@@ -1,61 +1,21 @@
|
||||
cmake_minimum_required(VERSION 3.22)
|
||||
|
||||
project(FastFileTransfer VERSION 2.21.0)
|
||||
project(FastFileTransfer)
|
||||
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
|
||||
add_compile_options(-Wall -g -O3)
|
||||
# add_compile_options(-Wall -g -O1 -fsanitize=address)
|
||||
|
||||
# --- Sanitizer option ---
|
||||
set(SANITIZER "none" CACHE STRING "Sanitizer to enable (address, thread, undefined, none)")
|
||||
set_property(CACHE SANITIZER PROPERTY STRINGS address thread undefined none)
|
||||
|
||||
if(SANITIZER STREQUAL "address")
|
||||
add_compile_options(-fsanitize=address -fno-omit-frame-pointer -g)
|
||||
add_link_options(-fsanitize=address)
|
||||
elseif(SANITIZER STREQUAL "thread")
|
||||
add_compile_options(-fsanitize=thread -fno-omit-frame-pointer -g)
|
||||
add_link_options(-fsanitize=thread)
|
||||
elseif(SANITIZER STREQUAL "undefined")
|
||||
add_compile_options(-fsanitize=undefined -fno-omit-frame-pointer -g)
|
||||
add_link_options(-fsanitize=undefined)
|
||||
elseif(NOT SANITIZER STREQUAL "none")
|
||||
message(FATAL_ERROR "Unknown sanitizer: ${SANITIZER}. Supported values: address, thread, undefined, none")
|
||||
endif()
|
||||
|
||||
# --- Strict warnings option ---
|
||||
option(STRICT_WARNINGS "Enable strict warnings (Wextra, Wpedantic, Werror)" OFF)
|
||||
if(STRICT_WARNINGS)
|
||||
add_compile_options(-Wextra -Wpedantic -Werror)
|
||||
endif()
|
||||
|
||||
# --- Coverage option ---
|
||||
option(ENABLE_COVERAGE "Enable gcov coverage" OFF)
|
||||
if(ENABLE_COVERAGE)
|
||||
add_compile_options(--coverage -fprofile-arcs -ftest-coverage -O0 -g)
|
||||
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()
|
||||
# add_link_options(-fsanitize=address)
|
||||
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
xxhash
|
||||
GIT_REPOSITORY https://github.com/Cyan4973/xxHash
|
||||
# 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
|
||||
GIT_TAG v0.8.3
|
||||
SOURCE_SUBDIR cmake_unofficial
|
||||
)
|
||||
FetchContent_MakeAvailable(xxhash)
|
||||
@@ -70,210 +30,20 @@ endif()
|
||||
|
||||
find_package(OpenSSL REQUIRED)
|
||||
|
||||
# --- Explicit source lists ---
|
||||
# The shared library is self-contained: it must never depend on the client or
|
||||
# server modules. In particular, the receiver pipeline (receive_thread /
|
||||
# write_thread) lives under src/server, not here, so the client executable can
|
||||
# link the shared library without pulling in any server code.
|
||||
set(SHARED_SRCS
|
||||
src/shared/array_list.c
|
||||
src/shared/batch.c
|
||||
src/shared/charset.c
|
||||
src/shared/checksum.c
|
||||
src/shared/chmod.c
|
||||
src/shared/chunk.c
|
||||
src/shared/compression.c
|
||||
src/shared/config.c
|
||||
src/shared/credentials.c
|
||||
src/shared/daemon_conf.c
|
||||
src/shared/daemon_limits.c
|
||||
src/shared/data.c
|
||||
src/shared/delay_updates.c
|
||||
src/shared/delta.c
|
||||
src/shared/file.c
|
||||
src/shared/file_list.c
|
||||
src/shared/file_receive.c
|
||||
src/shared/file_send.c
|
||||
src/shared/file_store.c
|
||||
src/shared/filter.c
|
||||
src/shared/hardlink.c
|
||||
src/shared/identity.c
|
||||
src/shared/log.c
|
||||
src/shared/metadata.c
|
||||
src/shared/motd.c
|
||||
src/shared/multiprocessing.c
|
||||
src/shared/protocol.c
|
||||
src/shared/queue.c
|
||||
src/shared/stop_condition.c
|
||||
src/shared/transport_ssh.c
|
||||
src/shared/transport_tcp.c
|
||||
src/shared/transport_tls.c
|
||||
src/shared/utils.c
|
||||
src/shared/xattr.c
|
||||
)
|
||||
file(GLOB SHARED_SRCS "src/shared/*.c")
|
||||
file(GLOB SERVER_SRCS "src/server/*.c")
|
||||
file(GLOB CLIENT_SRCS "src/client/*.c")
|
||||
file(GLOB TEST_SRCS "tests/*.c")
|
||||
|
||||
# Server implementation (no main): the receiver read/write pipeline plus the
|
||||
# CLI parser. The server executable adds its own main (server.c).
|
||||
set(SERVER_CORE_SRCS
|
||||
src/server/receiver.c
|
||||
src/server/receiver_pipeline.c
|
||||
src/server/server_cli.c
|
||||
)
|
||||
set(SERVER_MAIN_SRCS src/server/server.c)
|
||||
add_executable(server ${SERVER_SRCS} ${SHARED_SRCS})
|
||||
target_include_directories(server PRIVATE src/shared src/server src/client)
|
||||
target_link_libraries(server PRIVATE Threads::Threads ${ZSTD_LIBRARY} OpenSSL::SSL OpenSSL::Crypto xxhash)
|
||||
|
||||
# Client implementation (no main): everything except the CLI entry point.
|
||||
set(CLIENT_CORE_SRCS
|
||||
src/client/change_list.c
|
||||
src/client/client_send.c
|
||||
src/client/client_validation.c
|
||||
src/client/scanner.c
|
||||
src/client/usage.c
|
||||
)
|
||||
set(CLIENT_MAIN_SRCS src/client/client_cli.c)
|
||||
add_executable(client ${CLIENT_SRCS} ${SHARED_SRCS})
|
||||
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)
|
||||
|
||||
# --- Library targets ---
|
||||
add_library(fastsync_shared STATIC ${SHARED_SRCS})
|
||||
target_include_directories(fastsync_shared PUBLIC src/shared)
|
||||
target_link_libraries(fastsync_shared PUBLIC Threads::Threads ${ZSTD_LIBRARY} OpenSSL::SSL
|
||||
OpenSSL::Crypto xxhash)
|
||||
add_executable(tests ${TEST_SRCS} ${SHARED_SRCS} src/client/scanner.c)
|
||||
target_include_directories(tests PRIVATE tests src/shared src/server src/client)
|
||||
target_link_libraries(tests PRIVATE Threads::Threads ${ZSTD_LIBRARY} OpenSSL::SSL OpenSSL::Crypto xxhash)
|
||||
|
||||
add_library(fastsync_client_core STATIC ${CLIENT_CORE_SRCS})
|
||||
target_include_directories(fastsync_client_core PUBLIC src/client)
|
||||
target_link_libraries(fastsync_client_core PUBLIC fastsync_shared)
|
||||
|
||||
add_library(fastsync_server_core STATIC ${SERVER_CORE_SRCS})
|
||||
target_include_directories(fastsync_server_core PUBLIC src/server)
|
||||
target_link_libraries(fastsync_server_core PUBLIC fastsync_shared)
|
||||
|
||||
# --- Main executables ---
|
||||
# The client links only the shared library and its own core; it deliberately
|
||||
# does NOT get src/server on its include path nor compile receiver.c.
|
||||
add_executable(server ${SERVER_MAIN_SRCS})
|
||||
target_link_libraries(server PRIVATE fastsync_server_core)
|
||||
|
||||
add_executable(client ${CLIENT_MAIN_SRCS})
|
||||
target_link_libraries(client PRIVATE fastsync_client_core)
|
||||
|
||||
# --- 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; the same
|
||||
# applies to the first-party static libraries linked into the -pie binaries.
|
||||
if(HARDENING_ACTIVE)
|
||||
set_target_properties(xxhash fastsync_shared fastsync_server_core fastsync_client_core
|
||||
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 fastsync_shared fastsync_server_core fastsync_client_core 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()
|
||||
endforeach()
|
||||
foreach(target server client)
|
||||
target_link_options(${target} PRIVATE -pie -Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack)
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
# --- Testing ---
|
||||
enable_testing()
|
||||
|
||||
# --- Unit tests ---
|
||||
# The monolithic test binary exercises both client and server code, so it is
|
||||
# the one place that legitimately sees both include directories and links both
|
||||
# core libraries. client_cli.c is compiled here directly (with the test build
|
||||
# define) rather than linked from fastsync_client_core so its test-only shims
|
||||
# and the absence of main() are preserved.
|
||||
set(TEST_SRCS
|
||||
tests/runner.c
|
||||
tests/test_array_list.c
|
||||
tests/test_batch.c
|
||||
tests/test_change_list.c
|
||||
tests/test_checksum.c
|
||||
tests/test_chunk.c
|
||||
tests/test_client_cli.c
|
||||
tests/test_compression.c
|
||||
tests/test_config.c
|
||||
tests/test_credentials.c
|
||||
tests/test_daemon_conf.c
|
||||
tests/test_daemon_limits.c
|
||||
tests/test_data.c
|
||||
tests/test_delay_updates.c
|
||||
tests/test_delta.c
|
||||
tests/test_file.c
|
||||
tests/test_file_list.c
|
||||
tests/test_file_sendfile.c
|
||||
tests/test_fuzz_smoke.c
|
||||
tests/test_glob.c
|
||||
tests/test_hardlink.c
|
||||
tests/test_iconv.c
|
||||
tests/test_log.c
|
||||
tests/test_metadata.c
|
||||
tests/test_motd.c
|
||||
tests/test_multiprocessing.c
|
||||
tests/test_property.c
|
||||
tests/test_protocol.c
|
||||
tests/test_protocol_error.c
|
||||
tests/test_queue.c
|
||||
tests/test_receiver_timeout.c
|
||||
tests/test_robustness.c
|
||||
tests/test_scanner.c
|
||||
tests/test_server.c
|
||||
tests/test_server_cli.c
|
||||
tests/test_shared_utils.c
|
||||
tests/test_stop.c
|
||||
tests/test_stress.c
|
||||
tests/test_transport_ssh.c
|
||||
tests/test_transport_tcp.c
|
||||
tests/test_transport_tls.c
|
||||
tests/test_xattr.c
|
||||
)
|
||||
|
||||
add_executable(tests ${TEST_SRCS} src/client/client_cli.c)
|
||||
target_include_directories(tests PRIVATE tests)
|
||||
target_compile_definitions(tests PRIVATE FASTSYNC_TEST_BUILD)
|
||||
target_link_libraries(tests PRIVATE fastsync_server_core fastsync_client_core)
|
||||
add_test(NAME unit_all COMMAND tests)
|
||||
|
||||
# --- Fuzz targets (requires clang) ---
|
||||
option(ENABLE_FUZZ "Build fuzz targets (requires clang)" OFF)
|
||||
if(ENABLE_FUZZ)
|
||||
if(NOT CMAKE_C_COMPILER_ID MATCHES "Clang")
|
||||
message(FATAL_ERROR "ENABLE_FUZZ requires Clang (compiler is ${CMAKE_C_COMPILER_ID})")
|
||||
endif()
|
||||
set(FUZZ_SRCS
|
||||
tests/fuzz/fuzz_chunk_deserialize.c
|
||||
tests/fuzz/fuzz_compress_decompress.c
|
||||
tests/fuzz/fuzz_config_receive.c
|
||||
tests/fuzz/fuzz_delta_deserialize.c
|
||||
tests/fuzz/fuzz_delta_signature_deserialize.c
|
||||
tests/fuzz/fuzz_glob_match.c
|
||||
tests/fuzz/fuzz_identity_parse.c
|
||||
tests/fuzz/fuzz_manifest.c
|
||||
tests/fuzz/fuzz_metadata_from_buf.c
|
||||
tests/fuzz/fuzz_protocol_framing.c
|
||||
tests/fuzz/fuzz_xattr_block.c
|
||||
)
|
||||
# Compile the sources under test directly so libFuzzer's coverage
|
||||
# instrumentation sees them (static libraries would be uninstrumented).
|
||||
set(FUZZ_CORE_SRCS ${SHARED_SRCS} src/server/receiver.c src/server/receiver_pipeline.c)
|
||||
foreach(FUZZ_SRC ${FUZZ_SRCS})
|
||||
get_filename_component(FUZZ_NAME ${FUZZ_SRC} NAME_WE)
|
||||
add_executable(${FUZZ_NAME} ${FUZZ_SRC} ${FUZZ_CORE_SRCS})
|
||||
target_include_directories(${FUZZ_NAME} PRIVATE tests src/shared src/server)
|
||||
target_compile_options(${FUZZ_NAME} PRIVATE -fsanitize=fuzzer,address,undefined -fno-omit-frame-pointer)
|
||||
target_link_options(${FUZZ_NAME} PRIVATE -fsanitize=fuzzer,address,undefined)
|
||||
target_link_libraries(${FUZZ_NAME} PRIVATE Threads::Threads ${ZSTD_LIBRARY} OpenSSL::SSL
|
||||
OpenSSL::Crypto xxhash)
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
+3
-4
@@ -1,9 +1,8 @@
|
||||
FROM ubuntu:24.04
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc g++ make libc6-dev cmake libzstd-dev libssl-dev git ca-certificates curl cppcheck clang-format \
|
||||
python3 python3-pip python3-venv openssl openssh-client \
|
||||
lcov valgrind clang libclang-rt-18-dev && \
|
||||
pip3 install --break-system-packages pytest pytest-xdist && \
|
||||
gcc g++ make libc6-dev cmake libzstd-dev libssl-dev git ca-certificates curl \
|
||||
python3 python3-pip python3-venv openssl openssh-client && \
|
||||
pip3 install --break-system-packages pytest && \
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||
apt-get install -y --no-install-recommends nodejs && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -1,147 +1,97 @@
|
||||
#FastSync
|
||||
# FastSync
|
||||
|
||||
FastSync is a high-performance file synchronization tool designed to become a
|
||||
drop-in replacement for common `rsync` workflows. It keeps the familiar
|
||||
source/destination model and rsync-style options while adding optional
|
||||
multithreading, streaming zstd compression, chunking, zero-copy TCP transfers,
|
||||
and native TCP/TLS transports.
|
||||
A high-performance file synchronization system with SSH and TCP transport, TLS encryption, streaming zstd compression, multithreaded transfer, incremental sync, metadata preservation, and rsync-compatible CLI flags.
|
||||
|
||||
The release version is FastSync's client/server protocol version (printed by
|
||||
`fastsync --version`); client and server must match. See
|
||||
[CHANGELOG.md](CHANGELOG.md) for the history.
|
||||
## Technical Overview
|
||||
|
||||
The compatibility target is straightforward:
|
||||
1. **Dual transport**: custom TCP client-server or SSH subprocess (rsync-style `user@host:/path`)
|
||||
2. **TLS encryption**: OpenSSL-based TLS 1.2+ for encrypted TCP connections
|
||||
3. **Chunked file transfer**: files grouped into configurable-size chunks (default ~10 MB)
|
||||
4. **Streaming zstd compression** (levels 1–22) using `ZSTD_compressStream2`
|
||||
5. **Multithreading**: producer-consumer pipeline with thread-safe queues (scanner → loader → sender)
|
||||
6. **Incremental sync**: skip files unchanged since last transfer (compares size + mtime)
|
||||
7. **Metadata preservation**: `mode`, `uid`, `gid`, `mtime` restored on disk when enabled
|
||||
8. **`sendfile()` zero-copy** on TCP (~2× faster on loopback)
|
||||
9. **SSH ControlMaster** for connection reuse across repeated invocations
|
||||
10. **Bandwidth limiting**: token-bucket throttling (`--bwlimit`)
|
||||
11. **`--delete`**: receiver removes files not present in sender manifest
|
||||
12. **`--exclude` / `--include`**: glob-pattern filename filtering
|
||||
|
||||
- Existing rsync commands should keep the same meaning.
|
||||
- FastSync-only performance options should be additive and optional.
|
||||
- A normal compatibility-mode transfer should prioritize rsync filesystem
|
||||
semantics over maximum throughput.
|
||||
## System Architecture
|
||||
|
||||
FastSync currently speaks its own protocol to `fastsync-server`. SSH mode
|
||||
starts that server remotely; it does not yet interoperate with an unmodified
|
||||
rsync client or rsync daemon. See [Compatibility Status](#compatibility-status)
|
||||
for the current boundary.
|
||||
### Client
|
||||
- Recursively scans source directories (BFS), supports exclude and include patterns
|
||||
- Groups files into chunks (configurable size)
|
||||
- Streaming zstd compression with configurable level
|
||||
- Chunk serialization (compact binary format) or per-file transfer
|
||||
- Incremental transfer: sends file metadata to server, skips unchanged files
|
||||
- Manifests all sent paths when `--delete` is active
|
||||
- Sends via TCP `sendfile()` or SSH pipe
|
||||
- Optional progress display with throughput
|
||||
- Bandwidth limiting via token-bucket algorithm
|
||||
|
||||
## Why FastSync
|
||||
### Server
|
||||
- TCP mode: listens on configurable port (default 8080); SSH mode: runs via `--stdio`
|
||||
- TLS mode: wraps TCP connections with OpenSSL
|
||||
- Receives and reassembles files
|
||||
- Decompresses (streaming zstd), deserializes, restores metadata
|
||||
- Handles incremental checks: compares size + mtime against destination files
|
||||
- Processes `STATUS_MANIFEST` for `--delete`: walks destination tree, removes extras
|
||||
- Per-connection concurrency via `fork()`
|
||||
- Thread pool for parallel processing
|
||||
|
||||
FastSync uses a producer-consumer transfer pipeline and can combine several
|
||||
optimizations for large or high-latency transfers:
|
||||
## Protocol Details
|
||||
|
||||
- Multithreaded scanning, loading, and sending.
|
||||
- Streaming zstd compression with levels 1 through 22.
|
||||
- Configurable file chunking and compact chunk serialization.
|
||||
- `sendfile()` zero-copy transfers over TCP.
|
||||
- Batched incremental checks to reduce round trips.
|
||||
- Optional block-level delta transfer for FastSync peers.
|
||||
- Bandwidth limiting, progress reporting, statistics, and backups.
|
||||
- TCP, SSH, and TLS transports.
|
||||
- Atomic temporary-file writes by default.
|
||||
### Status Codes
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `STATUS_OK` | Operation successful |
|
||||
| `STATUS_ERROR` | Error occurred |
|
||||
| `STATUS_FINISHED` | Transfer complete |
|
||||
| `STATUS_NEXT` | Ready for next file (per-file mode) |
|
||||
| `STATUS_CHUNK` | Following data is a serialized chunk |
|
||||
| `STATUS_MANIFEST` | Following data is a file manifest (for `--delete`) |
|
||||
| `STATUS_CHECK` | Incremental check: client sends file path + size + mtime, server responds with OK (skip) or NEXT (send) |
|
||||
|
||||
These optimizations are disabled or selected independently. Users can start
|
||||
with rsync-style commands and add FastSync options when they are useful.
|
||||
### Wire Format — Metadata
|
||||
|
||||
## Compatibility Status
|
||||
When `use_metadata` is enabled (`-M`), each file entry carries a 4-byte `present` flag followed by five fields (`mode`, `uid`, `gid`, `mtime_sec`, `mtime_nsec`). When disabled globally, no metadata bytes are sent — zero wire overhead.
|
||||
|
||||
FastSync is currently an rsync-compatible CLI in progress, not a complete
|
||||
replacement for every rsync feature or protocol mode.
|
||||
### Transfer Flow
|
||||
```
|
||||
Config → (STATUS_NEXT | STATUS_CHUNK | STATUS_CHECK)* → [STATUS_MANIFEST] → STATUS_FINISHED → STATUS_OK
|
||||
```
|
||||
|
||||
### Working today
|
||||
### Protocol Version
|
||||
|
||||
- Recursive directory scanning.
|
||||
- Rsync-style source and destination arguments.
|
||||
- SSH transport using `user@host:destination` paths below the remote authorized root.
|
||||
- TCP client/server transfers.
|
||||
- Dry runs, excludes, includes, size filters, backups, statistics, and
|
||||
bandwidth limiting.
|
||||
- Incremental size/mtime checks and optional xxHash64 content checks.
|
||||
- FastSync-native delta transfer for changed files.
|
||||
- Optional mode and timestamp preservation.
|
||||
- Delete manifests with server-side delete authorization.
|
||||
- Temporary-file writes with atomic rename by default.
|
||||
- Path traversal checks and destination-root confinement.
|
||||
`1.1.0` — server and client must match. Mismatch results in `STATUS_ERROR`.
|
||||
|
||||
### Not yet equivalent to rsync
|
||||
|
||||
- The FastSync wire protocol is not the rsync wire protocol.
|
||||
- SSH mode requires `fastsync-server` on the remote host.
|
||||
- Archive mode does not yet provide all of rsync's `-rlptgoD` behavior.
|
||||
- Symlink transfer is incomplete; link targets are not yet recreated in all
|
||||
modes.
|
||||
- Owner/group, ACL, xattr, and hard-link handling is incomplete or
|
||||
unavailable.
|
||||
- Device and special-file preservation is implemented with documented
|
||||
divergences: recreated device nodes require `CAP_MKNOD` on the receiver (a
|
||||
non-root receiver skips the entry), and sockets cannot be recreated (FIFOs
|
||||
are).
|
||||
- Sparse-file hole preservation (`-S`, `--sparse`) is implemented receiver-side:
|
||||
long all-zero runs are written as holes (no wire change; the full file image
|
||||
is already in memory).
|
||||
- `--partial`, `--partial-dir`, `-P`, `--append`, and `--append-verify` keep
|
||||
the write atomic (temp + rename). With `--partial`, a failed/interrupted write
|
||||
now retains the already-written temp at the destination path (best-effort) so
|
||||
a later `--append`/`--append-verify` run can resume it.
|
||||
- `--dirs` is not implemented. Its compatibility aliases `--old-dirs` and
|
||||
`--old-d` are recognized but rejected explicitly rather than silently using
|
||||
FastSync's recursive directory behavior.
|
||||
- Short-option names are now rsync-parity (Phase 7 Wave A): FastSync's former
|
||||
collisions were renamed (`-j`/`--threads`, `--preserve`, `--sendfile`,
|
||||
`--chunk-serialization`, `--timeout`, `--ssh-port`), so `-m`, `-M`, `-f`,
|
||||
`-s`, `-T`, `-p`, `-c`, `-a`, and `-z` follow rsync. See `RSYNC_COMPAT.md`.
|
||||
|
||||
The detailed flag matrix is maintained in
|
||||
[`RSYNC_COMPAT.md`](RSYNC_COMPAT.md). It distinguishes implemented,
|
||||
partial, alternate, and planned behavior.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Build
|
||||
|
||||
`compile_commands.json` is a symlink to `build/compile_commands.json` and is used by clangd/editor tooling; its target is generated by the build, so it dangles until the first build.
|
||||
## Command-Line Arguments
|
||||
|
||||
### Client
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| Positional | `<source> <dest>` — automatic SSH detection if dest contains `:` |
|
||||
| `-c, --checksum` | Verify content by checksum instead of size+mtime |
|
||||
| `-z, --compress [level]` | Enable streaming zstd compression (level 1–22, default 5) |
|
||||
| `-a, --archive` | rsync archive mode (`-rlptgoD`): links, metadata, devices and specials (not compression/multithreading) |
|
||||
| `-j, --threads[=N]` | Multithreading mode; `N` (1–256) sets the parallel scanner worker count, bare `-j`/`--threads` uses the default |
|
||||
| `-m` | rsync `--prune-empty-dirs` (short form now rsync-parity) |
|
||||
| `--chunk-serialization` | Chunk serialization (batch all files per chunk; long form only) |
|
||||
| `-s` | rsync `--secluded-args` compatibility no-op (remote SSH argv is already injection-safe) |
|
||||
| `--sendfile` | Sendfile zero-copy. Incompatible with compression / chunk serialization. TCP only. Long form only. |
|
||||
| `--preserve` | Preserve supported file metadata (mode and mtime; ownership and atime are unsupported) |
|
||||
| `-c [level]` | Compression with optional level (1–22, default 5) |
|
||||
| `-z [level]` | Alias for `-c` |
|
||||
| `-a, --archive` | Archive mode: enables `-c -m -M` (no `-s`) |
|
||||
| `-m` | Multithreading mode |
|
||||
| `-s` | Chunk serialization (batch all files per chunk) |
|
||||
| `-f, --sendfile` | Sendfile zero-copy. Incompatible with `-c` / `-s`. TCP only. |
|
||||
| `-M, --preserve` | Preserve file metadata (mode, uid, gid, mtime) |
|
||||
| `-n, --dry-run` | Scan and print what would be transferred |
|
||||
| `-p, --perms` | Preserve permission bits (part of the metadata bundle) |
|
||||
| `--ssh-port <port>` | SSH port (default: 22) |
|
||||
| `-p <port>` | SSH port (default: 22) |
|
||||
| `-v, --verbose` | Enable debug logging |
|
||||
| `-q, --quiet` | Suppress non-error output |
|
||||
| `--progress` | Show real-time transfer speed |
|
||||
| `-P` | Enables partial-transfer mode + progress output; interrupted writes retain the already-written temp for resumption |
|
||||
| `--delete` | Delete files on receiver not present in source (default timing: delete-after, i.e. only after the whole transfer succeeded) |
|
||||
| `--delete-before` | Delete extras before the transfer starts (implies `--delete`) |
|
||||
| `--delete-during`, `--del` | Delete extras once the keep-set is known, before data is applied (implies `--delete`) |
|
||||
| `--delete-delay` | Delete extras only after a successful transfer (implies `--delete`) |
|
||||
| `--delete-after` | Explicit delete-after timing (implies `--delete`) |
|
||||
| `--delete` | Delete files on receiver not present in source |
|
||||
| `--exclude <pattern>` | Exclude files matching glob pattern (repeatable) |
|
||||
| `--exclude-from <file>` | Read exclude patterns from a file (one per line) |
|
||||
| `--include <pattern>` | Only transfer files matching glob pattern (repeatable, whitelist) |
|
||||
| `--max-size <n>` | Skip files larger than n bytes |
|
||||
| `--min-size <n>` | Skip files smaller than n bytes |
|
||||
| `--max-alloc <SIZE>` | Maximum single allocation (binary units: B, K, M, G, T, P, E; default 1G) |
|
||||
| `--incremental` | Skip files unchanged since last transfer (size + mtime). Auto-enables `--preserve`. Incompatible with `--chunk-serialization`. |
|
||||
| `--existing` | Skip files not already present at the destination; update existing files normally. |
|
||||
| `--incremental` | Skip files unchanged since last transfer (size + mtime). Auto-enables `--preserve`. Incompatible with `-s`. |
|
||||
| `--bwlimit <KB/s>` | Bandwidth limit in kilobytes per second |
|
||||
| `--chunk-size <n>` | Chunk size in bytes (default: 10485760) |
|
||||
| `--timeout <sec>` | Positive I/O timeout in seconds, applied to both the socket (`SO_RCVTIMEO`/`SO_SNDTIMEO`, built-in default 30 s) and the per-message protocol poll deadline (built-in default 60 s). Omit the option to keep both built-ins; `0` is rejected. The server side keeps the built-in 60 s protocol window (the value is not sent on the wire). |
|
||||
| `--contimeout <sec>` | Connection timeout in seconds (default: 10) |
|
||||
| `--backup` | Backup existing destination files before overwriting |
|
||||
| `--backup-dir <dir>` | Target directory for backups (requires `--backup`) |
|
||||
| `--stats` | Print transfer statistics at end (bytes, files, timing) |
|
||||
| `-h, --human-readable` | Format transfer byte sizes with binary units |
|
||||
| `--max-depth <n>` | Maximum directory depth to recurse (0 = unlimited, default: 0) |
|
||||
| `--log-file <path>` | Write log messages to file instead of stderr |
|
||||
| `--source-dir <path>` | Source directory (overrides `FASTSYNC_SOURCE_DIR`) |
|
||||
| `--dest-dir <path>` | Server destination directory (overrides `FASTSYNC_DEST_DIR`) |
|
||||
| `--save-to-disk` | Write received files to disk |
|
||||
@@ -151,20 +101,6 @@ partial, alternate, and planned behavior.
|
||||
| `--cert <path>` | TLS certificate file (PEM) |
|
||||
| `--key <path>` | TLS private key file (PEM) |
|
||||
| `--ca <path>` | TLS CA certificate file for verification (PEM) |
|
||||
| `--client-cn <name>` | TLS client certificate common name; mandatory with `--tls` (a TLS connection always verifies the client CN) |
|
||||
|
||||
**Per-message vs. connection timeouts.** `--timeout` bounds each individual protocol
|
||||
send/receive (the `poll()` deadline), so a peer that stops mid-frame is dropped. It
|
||||
does not, by itself, stop a peer that keeps sending well-formed frames forever. The
|
||||
receiver therefore also enforces two wall-clock (`CLOCK_MONOTONIC`) bounds on a
|
||||
connection: a **1 hour** idle limit and a **24 hour** overall session cap. Only
|
||||
frames that move real work (not `STATUS_KEEPALIVE`/`STATUS_ABORT` and not an
|
||||
empty `STATUS_CHECK_BATCH`/`STATUS_DIR_TIMES`) refresh the idle timestamp, so a
|
||||
peer cannot hold a connection slot by emitting cheap empty frames; a peer that
|
||||
fabricates minimal non-empty frames can still occupy a slot until the 24 hour
|
||||
cap, since no bound can require actual payload without risking a legitimate
|
||||
long operation. Both are deliberately generous so a legitimate long-running
|
||||
transfer is never aborted.
|
||||
|
||||
### Server
|
||||
|
||||
@@ -176,10 +112,6 @@ transfer is never aborted.
|
||||
| `--cert <path>` | TLS certificate file (PEM) |
|
||||
| `--key <path>` | TLS private key file (PEM) |
|
||||
| `--ca <path>` | TLS CA certificate file for verification (PEM) |
|
||||
| `--destination-root <path>` | Authorized destination root (default: `.`) |
|
||||
| `--allow-delete` | Permit manifest deletion |
|
||||
| `--allow-super` | Standalone TCP listener only: keep super-user activities enabled for a **root** receiver. Without it a root standalone server forces `SUPER_MODE_OFF`, so client `--devices`/`--write-devices`/`--super` and client-chosen ownership requests are skipped/refused. **Rejected with `--stdio`** (the SSH remote argv is client-composed, so a client could otherwise pass it and defeat the secure default; operators exposing `fastsync-server --stdio` over SSH must use a forced command if the default must hold). No effect when not root. |
|
||||
| `--allow-unauthenticated` | Permit plaintext TCP clients. For an `auth users` module this opts in **loopback plaintext only**; remote auth still requires verified TLS, so the flag never permits remote plaintext auth. |
|
||||
| `-v, --verbose` | Enable debug logging |
|
||||
| `--help` | Show help |
|
||||
|
||||
@@ -190,77 +122,28 @@ transfer is never aborted.
|
||||
| `FASTSYNC_SOURCE_DIR` | — | Source directory fallback |
|
||||
| `FASTSYNC_DEST_DIR` | — | Destination directory fallback |
|
||||
| `FASTSYNC_SAVE_TO_DISK` | `false` | Disk persistence fallback |
|
||||
| `FASTSYNC_SSH_PORT` | `22` | Default SSH port |
|
||||
| `FASTSYNC_SERVER_HOST` | `127.0.0.1` | Default server host |
|
||||
| `FASTSYNC_SERVER_PORT` | `8080` | Default server port |
|
||||
| `FASTSYNC_TLS_CERT` | — | Default TLS certificate path |
|
||||
| `FASTSYNC_TLS_KEY` | — | Default TLS private key path |
|
||||
| `FASTSYNC_TLS_CA` | — | Default TLS CA certificate path |
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Data Structures
|
||||
1. **Chunk** — collection of files (~10 MB total by default)
|
||||
2. **File** — path, content (`Data`), optional `FileMetadata` pointer
|
||||
3. **FileMetadata** — `mode`, `uid`, `gid`, `mtime_sec`, `mtime_nsec`;
|
||||
uid / gid are advisory wire fields and are never applied by the receiver;
|
||||
atime is unsupported
|
||||
4. **Config** — runtime parameters (transported over wire, TLS settings excluded). Includes `timeout`, `contimeout`, `quiet`, `backup`, `backup_dir`, `stats`, `max_depth`, `log_file`.
|
||||
3. **FileMetadata** — `mode`, `uid`, `gid`, `mtime_sec`, `mtime_nsec`
|
||||
4. **Config** — runtime parameters (transported over wire, TLS settings excluded)
|
||||
5. **Queue** — thread-safe bounded queue with condition variables
|
||||
6. **DirectoryScanner** — recursive BFS traversal with exclude and include pattern support, max-depth enforcement
|
||||
6. **DirectoryScanner** — recursive BFS traversal with exclude and include pattern support
|
||||
|
||||
### Key Algorithms
|
||||
1. **File scanning** — BFS directory traversal;
|
||||
entries matched against exclude and include patterns,
|
||||
max - depth enforced 2. * *Chunking ** — files accumulated until `chunk_size` threshold,
|
||||
then flushed 3. *
|
||||
*Compression ** — streaming zstd
|
||||
via `ZSTD_compressStream2` / `ZSTD_decompressStream` 4. *
|
||||
*Network protocol ** — status -
|
||||
code - driven exchange with metadata packing,
|
||||
keep - alive,
|
||||
and abort support 5. * *Incremental check ** — client sends `STATUS_CHECK` + path + size +
|
||||
mtime and,
|
||||
with `--checksum`, XXH64 content checksum; server compares against destination. Can be batched via `STATUS_CHECK_BATCH` for reduced round-trips.
|
||||
1. **File scanning** — BFS directory traversal; entries matched against exclude and include patterns
|
||||
2. **Chunking** — files accumulated until `chunk_size` threshold, then flushed
|
||||
3. **Compression** — streaming zstd via `ZSTD_compressStream2` / `ZSTD_decompressStream`
|
||||
4. **Network protocol** — status-code-driven exchange with metadata packing
|
||||
5. **Incremental check** — client sends `STATUS_CHECK` + path + size + mtime; server compares against destination
|
||||
6. **Bandwidth limiting** — token-bucket algorithm with `nanosleep` throttling on 64 KB write chunks
|
||||
7. **Metadata restoration** — `chmod()`, `chown()`, `utimensat()` on the receiving side
|
||||
8. **`--delete`** — sender tracks all sent paths;
|
||||
receiver walks destination tree and removes unlisted files / directories 9. *
|
||||
*SSH transport *
|
||||
* — `socketpair()` + `fork()` + `execvp("ssh",
|
||||
...)` with `ControlMaster` and port support
|
||||
10. *
|
||||
*TLS transport ** — OpenSSL `SSL_CTX` with TLS
|
||||
1.2 minimum,
|
||||
mutual CA verification,
|
||||
transparent `SSL_read`/`SSL_write` via `io_set_ssl()` 11. *
|
||||
*Path traversal protection ** — `has_path_traversal()` rejects any file path
|
||||
containing `..` components,
|
||||
preventing directory escape attacks 12. *
|
||||
*Connection limiting ** — server tracks active connections and rejects
|
||||
new ones beyond `max_connections` (default 100)13. *
|
||||
*Keep
|
||||
- alive ** — idle connections receive periodic `STATUS_KEEPALIVE` to detect half
|
||||
- open TCP connections 14. * *Abort handling ** — `SIGINT` sets an abort flag; the next protocol operation sends `STATUS_ABORT` for clean server cleanup
|
||||
15. **Atomic writes** — files are written to a `.tmp` suffix then atomically renamed via `rename()`, preventing partial files
|
||||
16. **Backup** — before overwriting, existing files are moved to `--backup-dir` (or same directory with `~` suffix) preserving the original
|
||||
|
||||
## Security Features
|
||||
|
||||
### Path Traversal Protection
|
||||
All received file paths are validated by `has_path_traversal()` before any disk operation. Any path containing `..` components is rejected with `STATUS_ERROR`, preventing directory escape attacks.
|
||||
|
||||
### TLS Certificate Verification
|
||||
TLS requires `--ca` and performs mutual TLS verification (`SSL_VERIFY_PEER` with depth 4). Connections without certificate verification are rejected.
|
||||
|
||||
### Connection Limits
|
||||
The server enforces a maximum of 100 concurrent connections (configurable via `max_connections` in `Server`). When the limit is reached, new connections are immediately rejected and closed.
|
||||
|
||||
### Abort Handling
|
||||
If the client receives `SIGINT` (Ctrl+C) during a transfer, it sends `STATUS_ABORT` to the server. The server then cleans up temporary files and exits the child process, preventing incomplete files from remaining on disk.
|
||||
|
||||
### Atomic Writes
|
||||
Received files are written to a temporary path (suffixed with `.tmp`) and then atomically renamed to the final filename via `rename()`. This prevents partial or corrupted files from appearing at the destination if the transfer is interrupted.
|
||||
8. **`--delete`** — sender tracks all sent paths; receiver walks destination tree and removes unlisted files/directories
|
||||
9. **SSH transport** — `socketpair()` + `fork()` + `execvp("ssh", ...)` with `ControlMaster` and port support
|
||||
10. **TLS transport** — OpenSSL `SSL_CTX` with TLS 1.2 minimum, optional CA verification, transparent `SSL_read`/`SSL_write` via `io_set_ssl()`
|
||||
|
||||
## Build Requirements
|
||||
|
||||
@@ -286,436 +169,110 @@ nix-shell # provides zstd, openssl, cmake, gcc
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cmake -B build -S .
|
||||
cmake --build build -j$(nproc)
|
||||
cmake -B build -S . && cmake --build build -j$(nproc)
|
||||
```
|
||||
|
||||
With Nix:
|
||||
## Running
|
||||
|
||||
### Server (TCP mode)
|
||||
```bash
|
||||
nix-shell
|
||||
cmake -B build -S .
|
||||
cmake --build build -j$(nproc)
|
||||
./build/server
|
||||
```
|
||||
|
||||
### SSH transfer
|
||||
|
||||
The remote host must have `fastsync-server` available in `PATH`, or use
|
||||
`--fastsync-server-path`. SSH starts `fastsync-server --stdio` in its remote
|
||||
working directory, so use a destination below that directory unless the
|
||||
remote server is otherwise configured with a matching authorized root.
|
||||
|
||||
The remote `--stdio` server argv is composed by the client, so it must never
|
||||
be trusted to opt a root receiver into super-user activities: `--allow-super`
|
||||
is rejected with `--stdio` and super stays off on that path. Operators
|
||||
exposing `fastsync-server --stdio` over SSH must use a forced command (e.g. an
|
||||
`authorized_keys` `command=` entry) if the default must hold.
|
||||
|
||||
### Server with TLS
|
||||
```bash
|
||||
ssh user@host 'mkdir -p destination'
|
||||
./build/client /path/to/source user@host:destination
|
||||
./build/server --tls --cert server.pem --key server-key.pem
|
||||
```
|
||||
|
||||
FastSync is **push-only**: the source (first argument) is always a local
|
||||
directory and only the destination may be remote. A remote source such as
|
||||
`client user@host:src ./local` (a "pull") is intentionally not supported; see
|
||||
[RSYNC_COMPAT.md](RSYNC_COMPAT.md#direction).
|
||||
|
||||
### TCP transfer
|
||||
|
||||
Start the FastSync server:
|
||||
### Server via SSH
|
||||
Place the `fastsync-server` binary in the remote `$PATH`. The client runs `ssh user@host fastsync-server --stdio` automatically when an SSH-style destination is given.
|
||||
|
||||
### Client — SSH (rsync-style)
|
||||
```bash
|
||||
./build/server --destination-root /path/to -p 8080 --allow-unauthenticated
|
||||
./build/client /path/to/send user@host:/path/to/receive
|
||||
```
|
||||
|
||||
Then run the client:
|
||||
|
||||
### Client — TCP
|
||||
```bash
|
||||
./build/client --server-host 127.0.0.1 --server-port 8080 \
|
||||
--source-dir /path/to/source --dest-dir /path/to/destination \
|
||||
--save-to-disk
|
||||
./build/client --source-dir /path/to/send --dest-dir /path/to/receive --save-to-disk
|
||||
```
|
||||
|
||||
Plain TCP requires the explicit `--allow-unauthenticated` server option. Use TLS for
|
||||
authenticated network connections.
|
||||
|
||||
### TLS transfer
|
||||
### Client — TCP with TLS
|
||||
```bash
|
||||
./build/server --destination-root /path/to --tls --cert server.pem --key server-key.pem -p 8443
|
||||
./build/client --tls --cert client.pem --key client-key.pem --ca ca.pem \
|
||||
--server-host example.com --server-port 8443 \
|
||||
--source-dir /path/to/source --dest-dir /path/to/destination \
|
||||
--save-to-disk
|
||||
--source-dir /path/to/send --dest-dir /path/to/receive --save-to-disk
|
||||
```
|
||||
|
||||
## Common Workflows
|
||||
|
||||
These examples show the intended rsync-style workflow. Options marked as
|
||||
FastSync-native are optional performance or transport extensions.
|
||||
|
||||
### Common Options
|
||||
```bash
|
||||
#Basic synchronization
|
||||
./build/client /source/ /destination/
|
||||
# Archive mode (compression + multithreading + metadata)
|
||||
./build/client -a /path/to/send user@host:/path
|
||||
|
||||
#Archive - style synchronization(current FastSync archive behavior)
|
||||
./build/client -a /source/ user@host:destination/
|
||||
# Dry run
|
||||
./build/client -n /path/to/send /path/to/receive
|
||||
|
||||
#Preview a transfer without changing the destination
|
||||
./build/client -n /source/ /destination/
|
||||
# With progress and custom chunk size
|
||||
./build/client --progress --chunk-size 2097152 /src user@host:/dst
|
||||
|
||||
#Exclude temporary and object files
|
||||
./build/client --exclude '*.tmp' --exclude '*.o' \
|
||||
/source/ user@host:destination/
|
||||
# Exclude temporary files + delete extras on receiver
|
||||
./build/client --exclude "*.tmp" --exclude "*.o" --delete /src user@host:/dst
|
||||
|
||||
#Remove destination entries not present in the source
|
||||
./build/client --delete /source/ user@host:destination/
|
||||
# Incremental sync (skip unchanged files)
|
||||
./build/client --incremental /src user@host:/dst
|
||||
|
||||
#Skip unchanged files using size and modification time
|
||||
./build/client --incremental /source/ user@host:destination/
|
||||
# Bandwidth limit to 1 MB/s
|
||||
./build/client --bwlimit 1024 /src user@host:/dst
|
||||
|
||||
#Verify content when size and time are not sufficient
|
||||
./build/client --incremental --checksum /source/ user@host:destination/
|
||||
|
||||
#Preserve supported mode and timestamp metadata
|
||||
./build/client --preserve /source/ user@host:destination/
|
||||
|
||||
#Keep backups of overwritten destination files
|
||||
./build/client --backup --backup-dir backups \
|
||||
/source/ user@host:destination/
|
||||
# All features
|
||||
./build/client -a --progress --chunk-size 5242880 --exclude "*.log" --delete /src /dst
|
||||
```
|
||||
|
||||
## FastSync Extensions
|
||||
|
||||
FastSync-native options are intended to add performance or operational
|
||||
features without changing the meaning of ordinary compatibility options.
|
||||
|
||||
| Option | Purpose |
|
||||
|---|---|
|
||||
| `-j`, `--threads[=N]` | Enable the multithreaded scanner/loader/sender pipeline. `N` (1–256) sets the parallel scanner worker count; bare `-j`/`--threads` uses the default. |
|
||||
| `-z [level]`, `--compress [level]` | Enable streaming zstd compression, levels 1-22. |
|
||||
| `--compress-level <n>` | Set the zstd compression level. |
|
||||
| `--zc <alg>` | Alias for `--compress-choice`. FastSync supports `zstd` and `none`. |
|
||||
| `--zl <n>` | Alias for `--compress-level`. |
|
||||
| `--skip-compress <list>` | Skip compression for comma-separated suffixes; incompatible with `--chunk-serialization`. |
|
||||
| `--compress-threads <n>` | Use `n` zstd compression workers. Requires compression and a zstd build with threaded support; the setting affects sender CPU work only. |
|
||||
| `--chunk-size <bytes>` | Set the transfer chunk size. |
|
||||
| `--chunk-serialization` | Enable FastSync chunk serialization (long form only; `-s` is rsync's `--secluded-args`). |
|
||||
| `--sendfile` | Use TCP `sendfile()` zero-copy transfer. Incompatible with compression and chunk serialization. Long form only. |
|
||||
| `--delta` | Use FastSync-native block delta transfer. Requires `--incremental`. |
|
||||
| `--delta-block <bytes>` | Set the FastSync delta block size (`--block-size` is an alias). |
|
||||
| `--delta-max <bytes>` | Limit files eligible for FastSync delta transfer. |
|
||||
| `--server-host <host>` | Select the TCP server host. |
|
||||
| `--server-port <port>` | Select the TCP server port (`--port <port>` and `--port=<port>` are rsync-friendly aliases). |
|
||||
| `--tls` | Enable TLS for TCP transport. |
|
||||
| `--bwlimit <KB/s>` | Apply token-bucket bandwidth limiting. |
|
||||
| `--progress` | Show transfer progress and throughput. |
|
||||
| `--stats` | Print transfer statistics. |
|
||||
| `--timeout <seconds>` | Set the socket **and** per-message protocol I/O timeout (positive seconds). Omit to keep the built-in 30 s socket / 60 s protocol defaults. |
|
||||
| `--contimeout <seconds>` | Set connection timeout. |
|
||||
|
||||
Short-option conflicts with rsync have been resolved for the CLI namespace
|
||||
(Phase 7): `-c` is now rsync's `--checksum`, `-m` is `--prune-empty-dirs`, `-M`
|
||||
is `--remote-option`, `-f` is `--filter`, `-s` is `--secluded-args`, `-p` is
|
||||
`--perms`, and `-T` is `--temp-dir`. FastSync's own flags were renamed to
|
||||
long-form-only or new shorts: multithreading is `-j`/`--threads`, metadata
|
||||
is `--preserve`, sendfile is `--sendfile`, chunk serialization is
|
||||
`--chunk-serialization`, timeout is `--timeout`, and SSH port is `--ssh-port`.
|
||||
`-a`/`--archive` is now real rsync archive (`-rlptgoD`).
|
||||
|
||||
`--secluded-args` (and its short form `-s`) is accepted as a compatibility
|
||||
no-op. It does not change FastSync's transport or protocol behavior, because
|
||||
remote SSH argv is already built injection-safe.
|
||||
|
||||
## Client Options
|
||||
|
||||
### Selection and transfer
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `-a`, `--archive` | rsync archive mode (`-rlptgoD`): links, metadata, devices and specials. |
|
||||
| `-n`, `--dry-run` | Scan and report without writing files. |
|
||||
| `--delete` | Request removal of destination entries absent from the source. The server must allow deletion. Default timing is delete-after: extras are removed only after the whole transfer succeeded. |
|
||||
| `--delete-before` | Delete extras before the transfer starts (implies `--delete`). |
|
||||
| `--delete-during`, `--del` | Delete extras once the keep-set manifest is known, before data is applied (implies `--delete`; early mode, same engine behaviour as `--delete-before`). |
|
||||
| `--delete-delay` | Delete extras only after a successful transfer (implies `--delete`; commit mode, same behaviour as `--delete-after`). |
|
||||
| `--delete-after` | Explicit delete-after timing: delete only after the transfer succeeded (implies `--delete`). |
|
||||
| `--exclude <pattern>` | Exclude matching paths. Repeatable. |
|
||||
| `--include <pattern>` | Include matching paths. Repeatable. |
|
||||
| `--exclude-from <file>` | Read exclude patterns from a file. |
|
||||
| `--include-from <file>` | Read include patterns from a file. |
|
||||
| `--max-size <bytes>` | Skip files larger than the limit. |
|
||||
| `--min-size <bytes>` | Skip files smaller than the limit. |
|
||||
| `--max-depth <n>` | Limit recursive scanning depth;
|
||||
zero means unlimited.| | `--incremental` | Skip files matching destination size and mtime.|
|
||||
| `--checksum` | Include xxHash64 content checks in incremental comparisons.| | `--backup` |
|
||||
Back up overwritten files.| | `--backup - dir<dir>` | Store backups under a separate directory.|
|
||||
| `--suffix<suffix>` | Set the backup filename suffix.| | `--partial` |
|
||||
Select partial - transfer handling. On failed/interrupted writes the
|
||||
already-written temp file is retained (best-effort) for resumption.|
|
||||
With `--partial --partial-dir <dir>`, completed files are written under the
|
||||
partial directory and installed atomically. | | `--partial - dir<dir>` |
|
||||
Set a relative partial - transfer directory below the server destination root.
|
||||
Use with `--partial`. |
|
||||
| `--inplace` | Write directly to the destination instead of using a temporary file. |
|
||||
|
||||
### Metadata and links
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `--preserve` | Preserve supported file metadata, currently mode and modification time (long form only). |
|
||||
| `-l`, `--links` | Request symlink preservation;
|
||||
link-target transfer remains incomplete. |
|
||||
| `--copy-links` | Copy symlink referents. |
|
||||
| `--safe-links` | Skip symlinks that point outside the transfer tree. |
|
||||
| `--copy-unsafe-links` | Copy unsafe symlink referents. |
|
||||
| `-S`, `--sparse` | Sparse-file handling: receiver preserves holes (zero runs are written as holes; no wire change). |
|
||||
|
||||
### Output and logging
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `-v`, `--verbose` | Enable debug logging. |
|
||||
| `--progress` | Show live transfer progress. |
|
||||
| `--stats` | Print transfer statistics. |
|
||||
| `--log-file <path>` | Write log output to a file. |
|
||||
| `-V`, `--version` | Print the FastSync protocol version. |
|
||||
| `--help` | Print command usage. |
|
||||
|
||||
### Paths and transport
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `--ssh-port <port>` | SSH port for the SSH transport (default: 22). Note the short `-p` is now rsync's `--perms`. |
|
||||
| `--fastsync-server-path <path>` | Remote FastSync server path for SSH mode. |
|
||||
| `--source-dir <path>` | Set the source directory explicitly. |
|
||||
| `--dest-dir <path>` | Set the destination directory explicitly. |
|
||||
| `--save-to-disk` | Enable server-side disk persistence. |
|
||||
| `--server-host <host>` | TCP server address. |
|
||||
| `--server-port <port>` | TCP server port. `--port <port>` / `--port=<port>` is an alias. |
|
||||
| `--tls` | Enable TLS. Requires `--cert` and `--key`. |
|
||||
| `--cert <path>` | TLS certificate file. |
|
||||
| `--key <path>` | TLS private key file. |
|
||||
| `--ca <path>` | CA file for peer verification. |
|
||||
|
||||
## Server Options
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `--stdio` | Serve one SSH connection over standard input/output. |
|
||||
| `-p <port>` | TCP listen port. |
|
||||
| `--tls` | Enable TLS. |
|
||||
| `--cert <path>` | TLS certificate file. |
|
||||
| `--key <path>` | TLS private key file. |
|
||||
| `--ca <path>` | CA file for peer verification. |
|
||||
| `--destination-root <path>` | Confine received files to this server-side root;
|
||||
defaults to the current directory. |
|
||||
| `--allow-delete` | Permit client delete manifests. Deletion is refused by default. This also gates `--force` (which can recursively replace/remove a destination directory tree). |
|
||||
| `--allow-super` | Standalone TCP listener only: keep super-user activities enabled for a **root** receiver. Without it a root standalone server forces `SUPER_MODE_OFF`, so client `--devices`/`--write-devices`/`--super` and client-chosen ownership requests are skipped/refused. Rejected with `--stdio` (the SSH remote argv is client-composed; use a forced command if the default must hold). No effect when not root. Daemon modules opt in per module with `client owner = yes`. |
|
||||
| `-v`, `--verbose` | Enable debug logging. |
|
||||
| `--help` | Print server usage. |
|
||||
|
||||
### Daemon configuration
|
||||
|
||||
`fastsync-server --daemon --config FILE` reads a line-based module config (an
|
||||
implicit global section, then `[module]` sections). Besides `port`, `motd file`,
|
||||
and `address`, the global section accepts:
|
||||
|
||||
- `max connections = N` — global cap on concurrent connections, default 100. The
|
||||
listener enforces it; `0`, negative, and non-numeric values are parse errors.
|
||||
- `max connections per host = N` — cap on concurrent connections from a single
|
||||
source IP, default 0 (unlimited). Enforced across all forked connection
|
||||
children through a shared registry.
|
||||
- `auth failure delay = MS` — milliseconds to sleep after a failed
|
||||
authentication, default 500. `0` disables it and the value is capped at 5000,
|
||||
so online password guessing is rate-limited per connection. Successful auths
|
||||
are never delayed.
|
||||
- `auth lockout threshold = N` — number of failed authentications from one source
|
||||
IP before that source is locked out, default 10; `0` disables the lockout. The
|
||||
failure counter is shared across every connection child, so the lockout holds
|
||||
even when the next attempt is handled by a different forked child.
|
||||
- `auth lockout duration = SECONDS` — how long a locked-out source is refused
|
||||
(default 300). A locked-out client is refused before any SCRAM challenge is
|
||||
sent; a successful authentication clears the counter.
|
||||
- `hosts allow` / `hosts deny` — comma- and/or whitespace-separated host access
|
||||
patterns.
|
||||
|
||||
A `[module]` may also set `max connections` (0 = unlimited; enforced per module
|
||||
across all connection children) and its own `hosts allow`/`hosts deny`.
|
||||
|
||||
The per-host cap and the shared auth lockout identify a source by its numeric
|
||||
peer IP. **Loopback peers (127.0.0.0/8, IPv6 `::1`) are exempt**: every local
|
||||
client shares that one address, so counting or locking them out would let one
|
||||
local process deny service to all the others. The per-module and global
|
||||
`max connections` caps still apply to loopback. Because the key is the peer IP,
|
||||
`max connections per host` and `auth lockout` also cannot distinguish clients
|
||||
behind the same NAT, proxy, or reverse-proxy address — they share one budget and
|
||||
one lockout counter, so an over-aggressive lockout can affect unrelated users
|
||||
behind that address. Prefer TLS client certificates (`--client-cn`) plus
|
||||
`hosts allow`/`hosts deny` for per-client policy when clients share an address,
|
||||
and size `auth lockout threshold` accordingly.
|
||||
|
||||
The shared per-source table has a bounded lifetime: an entry with no live
|
||||
connection is reclaimed once its lockout has expired, or after it has been idle
|
||||
(300 s). If every entry is still live or locked, a new source is admitted without
|
||||
per-host accounting (fail open) and a rate-limited warning is logged; the
|
||||
per-module cap and host ACLs still apply. The occupancy counters are re-derived
|
||||
from the shared slot table after every child exit, so a child killed mid-transfer
|
||||
(or mid-registration) cannot leak a slot or an occupancy count.
|
||||
|
||||
Host patterns are `*` (match all), IPv4/IPv6 literals, or IPv4/IPv6 CIDR
|
||||
(`10.0.0.0/8`, `2001:db8::/32`). Hostnames are not resolved, so hostname globs
|
||||
are rejected at parse time rather than silently never matching. A matching
|
||||
`hosts deny` rejects; if any `hosts allow` entries exist, a peer matching none of
|
||||
them is rejected; deny takes precedence over allow. The global list is checked
|
||||
before the module list, before authentication, and the connecting peer address
|
||||
(IPv4 or IPv6) appears in the connection and authentication audit log lines.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Client
|
||||
|
||||
- Recursively scans the source tree with include, exclude, size, and depth
|
||||
filters.
|
||||
- Sends individual files or serialized chunks.
|
||||
- Performs incremental checks and optional content checksums.
|
||||
- Uses a multithreaded producer-consumer pipeline when requested.
|
||||
- Sends over TCP, TLS-wrapped TCP, or an SSH subprocess.
|
||||
- Supports progress, statistics, backups, timeouts, and bandwidth limiting.
|
||||
|
||||
### Server
|
||||
|
||||
- Runs as a TCP listener or one-shot SSH `--stdio` server.
|
||||
- Receives and reassembles files and decompresses streaming zstd data.
|
||||
- Applies supported metadata and writes files through a confined destination
|
||||
root.
|
||||
- Uses temporary files and atomic rename by default.
|
||||
- Handles delete manifests only when explicitly authorized.
|
||||
- Enforces connection, message-size, and path-safety limits.
|
||||
|
||||
## Protocol and Security
|
||||
|
||||
FastSync protocol version `2.21.0` is shared by the client and server. The
|
||||
current protocol is sender-driven and includes configuration negotiation,
|
||||
including the maximum allocation limit, incremental checks, checksums,
|
||||
manifests, keep-alives, abort handling, per-file remove-source results, and
|
||||
FastSync-native delta messages.
|
||||
Client and server versions must currently match exactly.
|
||||
|
||||
Daemon modules that declare `auth users` authenticate with a SCRAM-SHA-256-style
|
||||
challenge/response against a salted PBKDF2 verifier store: no password and no
|
||||
replayable bearer credential crosses the wire or is stored on the daemon. All
|
||||
store entries share one iteration count, and an unknown user is answered with a
|
||||
deterministic per-username dummy challenge, so probing the daemon cannot
|
||||
enumerate users. Store lines are generated with
|
||||
`fastsync-server --hash-credentials <plaintext-file>` (see `RSYNC_COMPAT.md`);
|
||||
redirect that output to an owner-only (mode 0600) file, and note that legacy
|
||||
`user:SHA256HEX` stores are rejected. FastSync also maintains an owner-only
|
||||
(mode 0600) `<store>.dummykey` sidecar next to the store: it holds the store-wide
|
||||
dummy key, is auto-created on first load, and must be preserved across daemon
|
||||
restarts so the dummy challenge for an unknown user stays stable (the key is
|
||||
never regenerated while the sidecar exists). The sidecar is secret material and
|
||||
must be protected like the credential store: keep it owner-only (mode 0600) and
|
||||
include it with the store in backups and credential rotation. If the sidecar
|
||||
cannot be created (a process-substitution/FIFO store path such as `/dev/fd/N`, a
|
||||
read-only filesystem, a missing directory, or a create, write, fsync, link, or
|
||||
fchmod failure), the daemon logs a warning and uses a transient key, so the
|
||||
cross-restart guarantee does not hold for those deployments. One residual is
|
||||
accepted: the store
|
||||
iteration count is observable pre-auth by design, since the miss path must match
|
||||
a hit.
|
||||
|
||||
An `auth users` module accepts credentials only when one of two conditions
|
||||
holds: (a) the connection is an encrypted, verified TLS connection whose client
|
||||
certificate matches the server's `--client-cn`, or (b) the connection is
|
||||
plaintext from a loopback peer **and** the operator explicitly passed
|
||||
`--allow-unauthenticated`. A remote plaintext peer is refused before any
|
||||
challenge is sent, and `--allow-unauthenticated` never permits remote plaintext
|
||||
auth: remote peers still require verified TLS regardless of the flag. Clients
|
||||
sending daemon credentials with `--password-file` to a non-loopback daemon must
|
||||
therefore use `--tls`; the client rejects a non-local plaintext credential
|
||||
destination before any network I/O. Daemon modules are a `--daemon`-only
|
||||
feature: the SSH `--stdio` path never loads a daemon config and is not an auth
|
||||
transport for them.
|
||||
|
||||
Because the loopback allowance trusts whichever peer the kernel reports as
|
||||
`127.0.0.1`, it assumes nothing relays remote connections to the daemon. A local
|
||||
TCP forwarder or a TLS-terminating proxy in front of an auth-module listener
|
||||
makes remote clients appear as loopback and bypasses the mutual-TLS identity
|
||||
check, so do not front an auth-module listener with such a relay. `--tls` always
|
||||
mandates `--client-cn`, so a TLS connection to an auth-required module always
|
||||
has its client CN verified (`--client-cn` matches the certificate's CN only, not
|
||||
a subjectAltName, which is acceptable for a private CA).
|
||||
|
||||
TLS provides encrypted TCP transport. Supplying `--ca` enables certificate
|
||||
verification; without it, traffic is encrypted but peer identity is not
|
||||
verified. Use certificate verification for deployments where authentication
|
||||
matters. The default TCP transport is not encrypted.
|
||||
|
||||
The receiver protects its destination root with path validation, `openat()`
|
||||
directory traversal, `O_NOFOLLOW`, temporary files, and atomic renames. Delete
|
||||
operations require the server's explicit `--allow-delete` policy.
|
||||
|
||||
## Compatibility Roadmap
|
||||
|
||||
The project will reach the drop-in replacement goal in stages:
|
||||
|
||||
1. Correct rsync option meanings, including short options, combined options,
|
||||
and `--option=value` syntax.
|
||||
2. Add differential tests that compare FastSync and rsync contents, metadata,
|
||||
links, deletes, filters, dry runs, and exit codes.
|
||||
3. Make `-a` implement the expected recursive, links, permissions, times,
|
||||
owner/group, and supported special-file behavior.
|
||||
4. Complete symlink, sparse-file, metadata, delete-policy, and resumable-write
|
||||
semantics.
|
||||
5. Add rsync remote-shell and daemon protocol interoperability.
|
||||
6. Keep FastSync performance options as negotiated, optional extensions.
|
||||
|
||||
The exhaustive implementation matrix and compatibility notes are in
|
||||
[`RSYNC_COMPAT.md`](RSYNC_COMPAT.md).
|
||||
|
||||
## Testing
|
||||
|
||||
Run the unit test binary:
|
||||
|
||||
```bash
|
||||
# Unit tests (7 suites)
|
||||
./build/tests
|
||||
|
||||
# Integration + benchmark suite
|
||||
python3 test.py
|
||||
```
|
||||
|
||||
Run the Python integration suite:
|
||||
The benchmark prints throughput metrics, best configuration, and speedup vs rsync.
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/integration/ -n 4 --dist=load -m "not setpriv"
|
||||
```
|
||||
## Performance Considerations
|
||||
|
||||
For stricter local validation:
|
||||
1. Chunk size (~10 MB default) balances memory and transfer efficiency
|
||||
2. Compression level trades CPU for bandwidth
|
||||
3. `sendfile()` bypasses userspace — ~2× faster on localhost for large files
|
||||
4. Multithreading scales with core count
|
||||
5. Metadata transfer adds negligible overhead (~24 bytes per file when enabled)
|
||||
6. SSH socketpair buffer set to 1 MB for improved pipe throughput
|
||||
7. SSH ControlMaster reuses connections across repeated invocations
|
||||
8. Incremental sync eliminates redundant transfers entirely
|
||||
9. Bandwidth limiting uses token-bucket with nanosleep for accurate throttling
|
||||
|
||||
```bash
|
||||
cmake -B build-strict -S . -DSTRICT_WARNINGS=ON
|
||||
cmake --build build-strict -j$(nproc)
|
||||
cmake -B build-asan -S . -DSANITIZER=address
|
||||
cmake --build build-asan -j$(nproc)
|
||||
```
|
||||
## Benchmark Results
|
||||
|
||||
The benchmark tool compares FastSync configurations with rsync under
|
||||
controlled local and network conditions:
|
||||
25 MB of mixed file sizes over `localhost` with disk I/O throttled (reads ≤ 15 MB/s, writes ≤ 10 MB/s) and network emulation via `tc netem`. Each test was run 3×; the median is reported below.
|
||||
|
||||
```bash
|
||||
python3 benchmark/bench.py --help
|
||||
```
|
||||
### LAN (1000 Mbit, 20 ms ±1 ms, 0.1% loss)
|
||||
|
||||
Benchmark results measure transfer performance only. They do not establish
|
||||
rsync protocol or filesystem-semantic compatibility.
|
||||
| Configuration | Time | vs rsync (archive) | vs rsync (compress) |
|
||||
|---|---|---|---|
|
||||
| **Best: `-m -c`** | **0.20 s** | **11.2× faster** | **3.6× faster** |
|
||||
| Compression (`-c`) | 0.31 s | 7.3× faster | 2.3× faster |
|
||||
| Standard | 1.27 s | 1.8× faster | — |
|
||||
| rsync (archive) | 2.27 s | — | — |
|
||||
| rsync (archive + compress) | 0.72 s | — | — |
|
||||
|
||||
## Performance Guidance
|
||||
### WAN (100 Mbit, 50 ms ±10 ms, 1% loss)
|
||||
|
||||
- Use `-m` for workloads with many files or enough CPU parallelism.
|
||||
- Use `-c` or `-z` when network bandwidth is more constrained than CPU.
|
||||
- Tune `--chunk-size` for file sizes, memory limits, and network latency.
|
||||
- Use `-f` for large uncompressed TCP transfers where zero-copy I/O helps.
|
||||
- Use `--incremental` to avoid retransmitting unchanged files.
|
||||
- Use `--delta` for changed files when both endpoints are FastSync peers.
|
||||
- Use `--bwlimit` when sharing a link with other traffic.
|
||||
| Configuration | Time | vs rsync (archive) | vs rsync (compress) |
|
||||
|---|---|---|---|
|
||||
| **Best: `-m -c`** | **0.39 s** | **44.8× faster** | **3.8× faster** |
|
||||
| Compression (`-c`) | 0.64 s | 27.3× faster | 2.3× faster |
|
||||
| Standard | 7.12 s | 2.4× faster | — |
|
||||
| rsync (archive) | 17.44 s | — | — |
|
||||
| rsync (archive + compress) | 1.47 s | — | — |
|
||||
|
||||
Always validate the compatibility behavior required by a deployment before
|
||||
replacing an existing rsync job.
|
||||
Compression reduces the data on the wire enough that the transfer becomes latency-bound rather than bandwidth-bound. On WAN, the best configuration runs 10.8× faster than the theoretical limit for uncompressed data, since zstd shrinks the 25 MB payload to a fraction of its original size over the wire.
|
||||
|
||||
-906
@@ -1,906 +0,0 @@
|
||||
# Rsync Feature Compatibility
|
||||
|
||||
This document maps rsync's full feature set to FastSync's current implementation status.
|
||||
|
||||
## Summary
|
||||
|
||||
| Status | Count | Description |
|
||||
|--------|-------|-------------|
|
||||
| ✅ Implemented | 143 | Feature works end-to-end |
|
||||
| 🔀 Alt Arg | 0 | Functionality exists but under different flag/semantics |
|
||||
| ⛔ Impossible/Divergence | 4 | Flag is a documented divergence or cannot be implemented on any portable filesystem call |
|
||||
| ⚠️ Partial | 0 | Flag parsed/stored but behavior incomplete |
|
||||
| 🔄 Compatibility No-op | 0 | Flag is accepted for CLI compatibility but has no effect |
|
||||
| ❌ Not Implemented | 0 | Flag not recognized or no behavior |
|
||||
| **Total** | **147** | |
|
||||
|
||||
---
|
||||
|
||||
## 1. General Options
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `-a`, `--archive` | Archive mode is -rlptgoD | ✅ Implemented | Phase 7 Wave A: real rsync archive. `-a`/`--archive` now implies `--links` + metadata (perms/times/group/owner as FastSync's broad bundle) + `--devices` + `--specials`. FastSync is always recursive, so no `-r` is needed. It no longer implies compression or multithreading (those moved to `-z`/`-j`). The short-option namespace is now rsync-parity (see the Phase 7 note) |
|
||||
| `-v`, `--verbose` | Increase verbosity | ✅ Implemented | Sets `log_level=DEBUG` |
|
||||
| `-q`, `--quiet` | Suppress non-error messages | ✅ Implemented | Suppresses client output while preserving errors |
|
||||
| `--help` | Show help | ✅ Implemented | Prints usage and exits; `-h` is not accepted |
|
||||
| `-V`, `--version` | Print version | ✅ Implemented | |
|
||||
| `--info=FLAGS` | Fine-grained info verbosity | ✅ Implemented | Supports `copy`, `misc`, `skip`, `stats`, `all`, and `none`; explicit flags override `--verbose`, and `none` suppresses info output; unsupported names are rejected |
|
||||
| `--debug=FLAGS` | Fine-grained debug verbosity | ✅ Implemented | `io`, `proto`, `pack`, and `util` are supported; `--debug=help` lists flags; other rsync categories are rejected |
|
||||
| `--stderr=MODE` | Change stderr output mode | ⛔ Impossible/Divergence | `errors` (default) and `all` are supported; `client` is rejected with a clear error (`--stderr=client is not supported`) because FastSync has no rsync client-message channel — the rejection itself is the documented behavior (Phase 7 Wave B decision). The modes that exist work; the missing rsync channel cannot be emulated without a wire change |
|
||||
| `--no-motd` | Suppress daemon MOTD | ✅ Implemented | Client-only display switch (Wave C): the daemon still sends the configured `motd file` on a `host::module/path` connection; the client reads and discards the frame without showing it. Without the flag the MOTD is printed to stdout after the config/auth handshake and escaped so control bytes cannot inject terminal sequences |
|
||||
| `--exclude=PATTERN` | Exclude files matching pattern | ✅ Implemented | Glob matching in scanner |
|
||||
| `--include=PATTERN` | Include files matching pattern | ✅ Implemented | Glob matching in scanner |
|
||||
| `-C`, `--cvs-exclude` | Auto-ignore CVS files | ✅ Implemented | Applies the well-known rsync default exclude set as exclude rules during scanning (RCS SCCS CVS CVS.adm RCSLOG cvslog.* tags TAGS .make.state .nse_depinfo *~ #* .#* ,* _$* *$ *.old *.bak *.BAK *.orig *.rej .del-* *.a *.olb *.o *.obj *.so *.exe *.Z *.elc *.ln core .svn/ .git/ .hg/ .bzr/); `.git/`-style repo dirs are pruned without descending |
|
||||
|
||||
## 2. Modifying Output
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `--stats` | Give transfer stats | ✅ Implemented | Prints file/byte counts |
|
||||
| `-h`, `--human-readable` | Human-readable numbers | ✅ Implemented | Formats transfer byte sizes using binary units |
|
||||
| `-i`, `--itemize-changes` | Per-file change summary | ✅ Implemented | Prints rsync-style `>f+++++++++` lines to stdout only for files actually sent (also under `-j`/`--threads`); unchanged files print nothing, matching single-`-i` behavior |
|
||||
| `--progress` | Show progress | ✅ Implemented | Progress callback in sender |
|
||||
| `-P` | Same as --partial --progress | ✅ Implemented | Phase 7 Wave B: `-P` parses to `--partial` + `--progress`. On a failed/interrupted write the receiver now retains the already-written temp file at the destination path (best-effort rename instead of unlink when configured), so a later `--append`/`--append-verify` run can resume it; `--partial-dir` still stages completed files under the confined partial dir and installs them atomically. The retention never runs when `--partial` is off, when no data was actually written, or under `--ignore-existing`/`--existing` (the destination is not ours to overwrite), and it only ever renames the already-written temp (never a corrupt blend; a failed rename falls back to the normal unlink). See the `-S`/`--sparse` interplay note (a retained sparse temp has full logical size) |
|
||||
| `--out-format=FORMAT` | Custom output format | ✅ Implemented | Per-transfer template on stdout; tokens `%f` `%n` `%l` `%b` `%M` `%%` (`%b` is the source length, always `== %l`; post-compression/delta wire bytes are not counted); unknown escapes preserved |
|
||||
| `--log-file=FILE` | Log to file | ✅ Implemented | `log_file` config field |
|
||||
| `--log-file-format=FMT` | Log format | ✅ Implemented | Requires `--log-file`; writes one template line per transferred file using the same token set as `--out-format` (including `%b` `==` source length) |
|
||||
| `--8-bit-output`, `-8` | Leave high-bit chars unescaped | ✅ Implemented | Applies to displayed paths and protocol debug output |
|
||||
| `--list-only` | List files instead of copying | ✅ Implemented | `ls -l`-style listing of files that would be transferred; scans the source only, contacts no server, writes nothing; also works with `-n` |
|
||||
|
||||
## 3. File Selection
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `--exclude-from=FILE` | Read exclude patterns from file | ✅ Implemented | Reads patterns from file |
|
||||
| `--include-from=FILE` | Read include patterns from file | ✅ Implemented | Reads patterns from file |
|
||||
| `--filter=RULE` | Add file-filtering rule | ✅ Implemented | Long option only: rsync's short `-f` conflicts with FastSync sendfile (see FastSync-specific list), so `-f` is not reassigned. Supported subset: `+`/`-` include/exclude, implicit-exclude patterns, `include`/`exclude` word forms, a leading `/` anchor (to the transfer root, or to a `.rsync-filter` file's directory), and a trailing `/` for dir-only rules; first match wins with a default of include inside the filter layer. Filters are an independent layer from `--exclude`/`--include` (an entry must pass both). Rejected with a clear error (no silent no-ops): `merge`/`dir-merge`/`hide`/`show`/`protect`/`risk`/`clear` words, rules that begin with `:`/`.`/`!` (merge/dir-merge/list-clear shorthands), and include/exclude modifiers other than `/` (`! C s r p x`) |
|
||||
| `--files-from=FILE` | Read source file list from file | ✅ Implemented | Entries are paths relative to the source root (leading `./` stripped, `..`/absolute entries rejected at parse time, blank lines ignored; NUL-delimited with `-0`). A listed regular file is transferred; a listed directory transfers its whole subtree (FastSync recursion is always on, unlike rsync's non-recursive default). Non-listed paths and their subtrees are pruned by the scanner. A listed entry that does not exist under the source (and an empty list) is a hard error reported before any transfer, unless `--ignore-missing-args` / `--delete-missing-args` is given (see the Safety & Security rows): those flags downgrade the listed-but-missing case to a skip and, for `--delete-missing-args`, a destination deletion; an empty list stays a hard error in every mode. Listing `.` (whole tree) and empty listed directories are fine. Scalability note: `file_list_affects` is O(list size) per scanned entry, so a very large `--files-from` list against a huge tree is quadratic; lists are typically small enough that this is acceptable, but it is the documented bound. The delete manifest still derives from what was actually sent, so `--delete` stays consistent with the subset |
|
||||
| `-0`, `--from0` | Delimit *-from files with NULs | ✅ Implemented | `--files-from` entries become NUL-delimited; the flag may appear before or after `--files-from` on the command line. NUL mode preserves entry bytes exactly (trailing CR/LF are part of the name; only newline mode trims them) |
|
||||
| `--max-size=SIZE` | Skip files larger than SIZE | ✅ Implemented | `max_size` in scanner |
|
||||
| `--min-size=SIZE` | Skip files smaller than SIZE | ✅ Implemented | `min_size` in scanner |
|
||||
| `-I`, `--ignore-times` | Don't skip files matching size+time | ✅ Implemented | `ignore_times` config field (crosses the wire). Disables the size+mtime quick-check in the `--incremental` per-file handshake and the basis-dir quick-match, forcing the file to be transferred rather than skipped as unchanged. Receiver-side policy: `match_by_metadata` (file_receive.c) is bypassed, so the receiver never replies `STATUS_OK` for a matching size+mtime. Requires `--incremental` to have the handshake to act on (rsync does its quick check by default; FastSync's `-I`/`--size-only`/`--modify-window` only take effect under `--incremental`, exactly like they take effect through the basis check) |
|
||||
| `--size-only` | Skip based on size only | ✅ Implemented | With `--incremental`, ignores mtime |
|
||||
| `-@`, `--modify-window=NUM` | Mod-time comparison accuracy | ✅ Implemented | Whole-second tolerance with nanosecond-aware comparisons |
|
||||
| `--existing` | Skip creating new files on receiver | ✅ Implemented | Existing destination files continue through normal update handling |
|
||||
| `--ignore-existing` | Skip updating existing files | ✅ Implemented | `ignore_existing` config field (crosses the wire; receiver-side policy). For a destination entry that already exists, the receiver skips the write: in the regular-file path, existing/delay-updates-staged, hardlink-sibling, and special/device handlers all return `FILE_SAVE_SKIPPED` without overwriting (passed as `no_replace` to the write engine), and `--backup` is disabled for skipped files. Note: it is applied at write time, so an existing dest whose size+mtime differ still has its data (or delta) transmitted before the write is discarded — functionally correct, bandwidth-suboptimal vs rsync, which short-circuits earlier. Like rsync, it does not apply to directories/symlinks (those return before the block). Combines with `-j`/`--threads` and `--delay-updates`. See Phase-4/— notes below |
|
||||
| `--remove-source-files` | Sender removes regular files after confirmed transfer | ✅ Implemented | |
|
||||
| `-x`, `--one-file-system` | Do not cross filesystem boundaries | ✅ Implemented | Sender scanner captures the root device and skips descending into mount-point crossings (`st_dev` differs); cross-filesystem mount-point subdirectories are dropped entirely, matching rsync |
|
||||
| `-F` | Add the default `.rsync-filter` rules | ✅ Implemented | Reads one filter rule per line from each directory's `.rsync-filter` file during traversal and applies it to that directory's subtree; the current directory's rules are evaluated before its ancestors', so deeper files override shallower ones and per-directory files override the command-line `--filter`/`-C` base by default (matching rsync's first-match-wins precedence); `.rsync-filter` files are never transferred. The rsync `-FF` behavior (also `.cvsignore`) is out of scope; unsupported rule types inside the file abort with a clear error |
|
||||
|
||||
## 4. Directory Options
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `-r`, `--recursive` | Recurse into directories | ✅ Implemented | Default behavior |
|
||||
| `-R`, `--relative` | Use relative path names | ✅ Implemented | Meaningful together with `--files-from` (FastSync's default full-tree scan always mirrors the full source argument path below the destination root, so -R does not change it). With `-R` + `--files-from` each listed entry is transmitted under its bare relative destination path: an entry `sub/x.txt` lands at `<dest>/sub/x.txt` (its leading components preserved) instead of under the `<dest>/<full source path>` mirror. Only the path sent on the wire changes; the client still reads the absolute source path, and the delete manifest derives from the sent (relative) paths so `--delete` and `--remove-source-files` stay consistent in both layouts. Works single-threaded and under `-j`/`--threads` (including chunk serialization) |
|
||||
| `--no-implied-dirs` | Don't send implied dirs with -R | ✅ Implemented | Client-side, meaningful only with `-R` + `--files-from`. rsync would normally create the ancestor directories implied by a listed file so it can be written; with `--no-implied-dirs` a listed file whose parent directory is not itself (or via an ancestor) explicitly listed cannot be placed, and FastSync fails the whole run up front with a clear error (`--no-implied-dirs: cannot place file '...': parent directory '...' is not explicitly listed`). Listing the directory (or an ancestor of it, or the whole tree `.`) permits the file. In every other mode the option has no effect. FastSync has no per-entry skip channel, so the rsync "omit the file" case is surfaced as a hard pre-transfer error |
|
||||
| `-d`, `--dirs`, `--old-dirs`, `--old-d` | Transfer dirs without recursing | ✅ Implemented | `-d <dir>` transmits an explicit directory entry for the source-root directory, so the destination mirror is created empty and nothing is descended into. With `--files-from` exactly the listed items are transferred: a listed directory is created empty (no descent) and a listed file is transferred with its content; the dest layout follows the same -R rules as plain files. A new wire frame (`STATUS_MKDIR`) carries each directory entry — the path and, when `--preserve`/`-a` (metadata mode) is negotiated, the directory's metadata; the receiver creates it with the same confined mkdir-parent semantics as regular writes, in single-threaded and `-j`/`--threads` receivers (chunk serialization carries a per-entry type marker). Directory entries appear in the delete manifest so `--delete` prunes correctly. Directory TIMES are transmitted (the `STATUS_DIR_TIMES` frame carries every traversed source directory's captured times, including `--dirs` entries) and applied by the receiver at the END of the transfer, after all children and the delete/publication phases, so a later child write cannot clobber a directory's mtime (`-O`/`--omit-dir-times` skips this application). FastSync divergences: directory modes/ownership are still not applied (only times are), and empty directories are still never created (a `STATUS_DIR_TIMES` entry is record-only), filter/`--exclude` rules are not re-applied to the listed dirs mode (there is no descent during which they would apply), and `-d` never creates the intermediate directories between the destination root and a listed file beyond the usual on-demand parent creation. Under `--delay-updates` only regular files are staged: directory entries are created immediately, so a delayed run that fails part way can leave the already-created empty directories behind (matching rsync, which also creates directories as it processes the file list and only delays regular-file data) |
|
||||
| `--mkpath` | Create missing path components | ✅ Implemented | Wire option (client → server). At connection start the server creates the client's destination root directory (and any missing leading components below its own authorized root) when `--mkpath` is set, failing the connection cleanly if it cannot. Without `--mkpath` a destination root that does not exist yet is rejected up front (rsync semantics), so the flag is the only way to transfer into a not-yet-created destination directory. Creation is confined by the same secure mkdir walk as file writes (`O_NOFOLLOW`, no `..`) |
|
||||
|
||||
## 5. Transfer Modifications
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `-u`, `--update` | Skip files newer on receiver | ✅ Implemented | `update` config field (crosses the wire; receiver-side policy, implies `-M` metadata). Before writing a regular file, the receiver checks `file_destination_is_newer_secure()` (via `stat_is_newer`, second-then-nanosecond strict `>` on the existing destination) and skips the write when the destination is newer than the source (`FILE_SAVE_SKIPPED`); equal-or-older destination (or a newer source) is transferred normally. Applied at write time on the regular-file, delay-updates-staged, hardlink-sibling, and special/device paths. Only regular destinations can be guarded (the newer-check requires `S_ISREG`), and like the other write-time policies it does not short-circuit the data transfer for a differing-size dest. `--remove-source-files` correctly respects the receiver's skip outcome so a skipped source is not removed |
|
||||
| `--inplace` | Update files in-place | ✅ Implemented | Direct write mode |
|
||||
| `--append` | Append data to shorter files | ✅ Implemented | Tail-only resume. When an existing destination file is SHORTER than the source, the receiver negotiates a resume offset with the sender and only the tail is transferred; the receiver rebuilds the full file (retained prefix + tail) and installs it through the normal atomic store path, so the result is byte-identical to the source whenever the retained prefix matches. Plain `--append` does NOT content-verify that prefix (rsync parity): a destination whose prefix differs from the source is resumed anyway, so the result (wrong prefix + correct tail) is NOT byte-identical and the file is effectively left corrupt — the documented rsync-parity risk (use `--append-verify` when the prefix cannot be trusted). Non-content attributes (permissions/ownership/mtime, via `-M`) are still applied. Requires the per-file `STATUS_CHECK` handshake, so it implies `--incremental`; it takes precedence over block delta for a growing file and falls back to delta/full when the destination is not shorter. Incompatible with `-s` (chunk serialization) and `--whole-file` (both rejected up front so the mode never silently degrades to a full transfer). Combines with `--inplace`, `--partial`/`--partial-dir`, and `--delay-updates` (the reconstructed full file flows through those paths unchanged). Divergence: rsync appends in place; FastSync reconstructs and atomically installs, so an interrupted or failed resume never leaves a half-written file at the destination (no corruption window), and `--append` is thus safe to use with the normal atomic path — not only with in-place writes |
|
||||
| `--append-verify` | Append with old-data checksum | ✅ Implemented | Like `--append`, but the retained prefix IS verified before resuming: the sender transmits the source prefix checksum and the receiver compares it to the xxHash64 of the retained destination prefix; on a match only the tail is transferred, on a MISMATCH the run falls back to a clean full transfer so the result is always a byte-identical source copy (never a corrupt prefix+tail blend). Wire/protocol: the append handshake adds `STATUS_APPEND` / `STATUS_APPEND_SIG` / `STATUS_APPEND_OK` / `STATUS_APPEND_DATA` frames and `PROTOCOL_VERSION` was bumped **2.9.0 → 2.10.0** (peers must match, and both must be 2.10.0 or the run fails the version check). Same implications/incompatibilities as `--append`; when both spellings are given `--append-verify` wins (the safer semantics). See the Phase-3 append notes below |
|
||||
| `-W`, `--whole-file` | Copy whole file (no delta) | ✅ Implemented | `whole_file` config field. Forces a full (whole-file) copy, disabling the block-level delta machinery: the sender only sends `STATUS_NEXT` + full data (client_send.c) and the receiver never requests a delta signature/reconstruction — the receiver's `try_delta = use_delta && !whole_file && ...` short-circuits. `whole_file` crosses the wire folded into `use_delta` (the wire carries `use_delta && !whole_file`), so no separate field/bump is needed. Delta is opt-in (`--delta` needs `--incremental`); `-W` additionally makes `--fuzzy` inert (no similar-file delta basis). `--append`/`--append-verify` are incompatible with `-W` and rejected up front (both sides). See the delta/append notes below |
|
||||
| `--block-size=SIZE` | Force checksum block-size | ✅ Implemented | Phase 7 Wave B: `--block-size` is an alias for `--delta-block`; both set `config->delta_block_size` (default `DELTA_BLOCK_SIZE_DEFAULT`, bounds `DELTA_BLOCK_SIZE_MIN..MAX`, out-of-range values are rejected with the default kept). The value is genuinely honored by the delta engine end-to-end: `delta_signature_create_seeded(old, size, config->delta_block_size, seed)` on the sender and receiver, `delta_apply(old, ...)` with the same size, so a non-default block size changes the block count of every signature the harnesses exchange (verified by unit + integration tests) |
|
||||
|
||||
## 6. Destination Handling
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `-n`, `--dry-run` | Trial run with no changes | ✅ Implemented | Server-contacting since protocol 2.21.0. The final routing predicate is `dry_run_targets_server()` in `src/client/client_send.c`: any target a real run would reach over the wire selects the server-contacting path — an SSH transport, a daemon `host::module` destination, an explicit `--server-host` or `--server-port`/`--port`, TLS, or a source-bind `--address` — and the client handshakes with the receiver, which runs the normal read-only per-file check and answers `STATUS_DRY_RUN_TRANSFER`/`STATUS_OK` without mutating anything. A plain local destination (none of those) keeps the original client-side manifest that never dials the default `127.0.0.1:8080`. Would-delete reporting for `--delete*` is deferred (dry-run never deletes). |
|
||||
| `-b`, `--backup` | Make backups of overwritten files | ✅ Implemented | Backup before overwrite |
|
||||
| `--backup-dir=DIR` | Backup directory hierarchy | ✅ Implemented | `backup_dir` config field |
|
||||
| `--suffix=SUFFIX` | Backup suffix (default ~) | ✅ Implemented | `suffix` config field |
|
||||
| `--delay-updates` | Put updated files in place at end | ✅ Implemented | Successfully received files are staged under a private 0700 `.fastsync-stage` dir inside the receive root and atomically renamed into their final destinations only after the whole transfer (manifest/delete handling included) succeeds, just before the success/outcome frame is sent. The delete walker deliberately skips the staging dir at the receive root, so `--delete` removes genuine extras but never the staged files (deletion runs before publication; rsync's delete-after ordering is not implemented). `--existing`/`--ignore-existing`/`--update` decide against the final destination path at stage time; `--backup` moves the old file aside at publication. Incompatible with `--inplace` and with `--backup-dir=.fastsync-stage` (the internal staging name is reserved; both are rejected). The staging dir name is fixed, so two simultaneous delayed transfers to the same destination root are serialized with an exclusive advisory lock held for the whole transfer: the second session fails cleanly instead of corrupting the first. Aborting or failing before publication installs nothing and removes the staging tree; a crash between stage and publish leaves staged leftovers that the next delayed run wipes at start (process death releases the lock). A stage→publish failure aborts the transfer (best-effort cleanup of the not-yet-published staged files; already-published files are not rolled back). Works in single-threaded and `-j`/`--threads` modes |
|
||||
| `-T`, `--temp-dir=DIR` | Create temporary files in DIR | ✅ Implemented | `--temp-dir` with the rsync short `-T` (Phase 7 Wave A; the timeout alias moved to long-only `--timeout`). Scratch dir is resolved under the receive root; temp copies use a unique name there and are atomically renamed into place. If the scratch dir and destination are on different filesystems the atomic rename fails with EXDEV and the file save fails, which aborts the whole transfer (FastSync has no per-file skip/resume on a save error; rsync's non-atomic copy fallback is deliberately not used). `--inplace` and `--partial-dir` writes bypass the scratch dir |
|
||||
|
||||
## 7. Deletion
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `--delete` | Delete extraneous files from dest | ✅ Implemented | `use_delete` config field. Deletion is always derived from the transmitted keep-set manifest of the paths the sender sent/keeps (never from unchecked input), runs through the symlink-safe walker bounded by `MAX_SERVER_DELETE_COUNT`, and skips the `.fastsync-stage` staging dir under `--delay-updates`. FastSync's default timing when no timing flag is given is **delete-after** (extras are removed only once the whole transfer succeeded) — intentionally NOT rsync's `--del`/delete-during default, to preserve FastSync's commit-style safety. By default the destination mirror of a path the source scan pruned (filter/exclude/size rules) is **protected** from deletion — matching rsync, which does not delete excluded files under `--delete`; `--delete-excluded` opts back into deleting them (see below). The bounded deletion is **all-or-nothing**: if the destination holds more extras than the effective bound (a client `--max-delete=NUM` or the 100000-entry server bound) nothing is deleted and the run fails with a distinct error instead of silently truncating |
|
||||
| `--delete-before` | Delete before transfer | ✅ Implemented | Implies `--delete`. The sender runs a full source pre-scan (paths only) and transmits the keep-set manifest BEFORE any file data; the receiver validates it, removes every destination entry not listed (all-or-nothing bounded walk, staging-dir skip, protected prefixes honored), then acks `STATUS_OK`. The sender only starts streaming after the deletion committed, or aborts if the receiver reported a deletion error. By definition the deletions already happened when a later transfer phase fails — rsync's delete-before is destructive the same way; a subsequent failure does not restore the removed files. Divergence: the keep-set is the pre-scan snapshot, so a file that appears on the source between the pre-scan and the data pass is still transferred but was not protected from deletion |
|
||||
| `--del`, `--delete-during` | Delete during transfer | ✅ Implemented | Both spellings accepted; imply `--delete`. FastSync streams the source in a single directory scan and has no per-directory generator pass, so deletions cannot be interleaved per-directory the way rsync's delete-during does. `--delete-during` therefore selects the same early engine mode as `--delete-before` (manifest transmitted before any data, extras removed and acknowledged before data is applied); observable success/failure behaviour equals `--delete-before`. That is the documented divergence from rsync, where `--del` is the default meaning of `--delete` |
|
||||
| `--delete-delay` | Find deletions during, delete after | ✅ Implemented | Implies `--delete`. Commit-mode timing: extras are removed only after the whole transfer succeeded. rsync's delete-delay records the deletion list during its scan and applies it at the end; FastSync never snapshots the destination while data flows (the keep-set is the transmitted manifest and the destination is listed only at deletion time), so `--delete-delay` is implemented as the same end-of-transfer commit as `--delete-after` with identical safety. That is the documented divergence |
|
||||
| `--delete-after` | Delete after transfer | ✅ Implemented | Implies `--delete`. The delete-after timing is also what plain `--delete` does: the keep-set manifest closes the data stream and the receiver commits the bounded deletion only after the terminal `STATUS_FINISHED` proves the whole transfer (every data frame received and stored) succeeded. A failed or aborted transfer removes nothing |
|
||||
| `--delete-excluded` | Also delete excluded files | ✅ Implemented | `delete_excluded` config field. Under `--delete` FastSync now protects (rsync's default) the destination mirror of paths the sender's source scan pruned by user-selection rules — the `--filter`/`-F`/`-C` layer, the legacy `--exclude`/`--include` layer, and `--max-size`/`--min-size`. The sender transmits those concrete pruned paths as **protected prefixes** in the delete-manifest frame (see the Phase-3 notes below); the walker never descends into or removes them. `--delete-excluded` opts back in: the sender sends an empty protected list, so the excluded destination mirrors become ordinary extras and are removed. Divergences (documented): protection is derived only from what the source scan actually pruned — a stray destination-only file that happens to match an exclude rule is not protected (FastSync never re-applies rules to the destination, keeping deletion sender-derived), and `--files-from` subset pruning stays keep-set-only (an unlisted source path is treated as absent and its mirror is deletable, matching the `--files-from` delete note below). The two are orthogonal: `--delete-excluded` removes filter-excluded mirrors; it does not make `--files-from` prune things |
|
||||
| `--max-delete=NUM` | Max files to delete | ✅ Implemented | `max_delete` config field (default -1 = no client limit; 0 = delete nothing). NUM bounds a `--delete` run with rsync's all-or-nothing semantics: the receiver rehearses the deletion first and, if the destination holds more than NUM extras, deletes NOTHING and fails the transfer with a distinct `--max-delete` error. A run at or below NUM deletes exactly the extras. NUM only applies together with `--delete` (it is inert otherwise, matching rsync). The hard server bound `MAX_SERVER_DELETE_COUNT` (100000) still caps the walk; a NUM above it never raises that cap, and exceeding the server bound is its own all-or-nothing error. Directories count toward the limit (each removed empty directory is one deletion), like rsync |
|
||||
| `--ignore-errors` | Delete even with I/O errors | ✅ Implemented | Sender-side, client-only config field. rsync suppresses `--delete` when the transfer had I/O errors; FastSync's equivalent is a source-scan I/O error (an unreadable directory, e.g. EACCES): by default the scan aborts the run so no deletion happens. With `--ignore-errors` the scan continues past the unreadable directory, the readable tree is transferred and the deletion still runs (the mirror of the unreadable directory is treated as an extra). The run still exits non-zero (the error is reported, matching rsync's error status). Divergence: without the flag FastSync aborts the whole run on the scan error, whereas rsync transfers the rest of the tree and merely skips the deletion; both leave the deletion undone |
|
||||
| `--force` | Force deletion of non-empty dirs | ✅ Implemented | `force_delete` receiver config field (crosses the wire). rsync's `--force` lets an incoming non-directory replace a destination directory; FastSync implements exactly that: when a regular file is written to a path that is currently a (possibly non-empty) destination directory, `--force` removes that directory tree first — confined to the receive root and symlink-safe (O_NOFOLLOW fd walk, symlinks removed by name, never followed) — so the atomic install can place the file. Without `--force` such a write fails and the run aborts. Divergence: `--force` acts on the immediate-install path only; under `--delay-updates` a blocking directory is not cleared (publication renames over regular files) |
|
||||
| `-m`, `--prune-empty-dirs` | Prune empty dir chains | ✅ Implemented | `-m`/`--prune-empty-dirs` (Phase 7 Wave A freed the rsync short `-m`; FastSync multithreading is now `-j`/`--threads`). FastSync's recursive transfer records directory times but never CREATES an empty directory (a `STATUS_DIR_TIMES` entry is record-only, and `--dirs` empty entries are pruned by this flag), so empty directories are inherently never transferred (which is rsync's `-m` behavior) and truly-empty destination directory chains are removed by `--delete` regardless of this flag. The flag's additional real effect is on the `--dirs` explicit directory-entry generator: a plain `-d <empty-dir>` run omits the empty source directory's entry, so nothing is created at the destination (no `STATUS_MKDIR`, no `-i`/`--out-format` change line, and an existing empty mirror becomes an extra that `--delete` prunes). Explicitly `--files-from`-listed directories always pass through (documented `--files-from` behavior). A directory that still holds an excluded-but-protected file survives, matching the `--delete-excluded` default |
|
||||
|
||||
**Deletion-timing implementation notes (Phase 3):** the delete flags above are
|
||||
real. Two new config booleans (`delete_during`, `delete_delay`) join the already
|
||||
serialized `delete_before`/`delete_after`, so the on-the-wire config layout
|
||||
changed and `PROTOCOL_VERSION` was bumped **2.7.0 → 2.8.0** (peers must match).
|
||||
The `STATUS_MANIFEST` frame is count-delimited and position-independent: the
|
||||
receiver commits the deletion either when the manifest arrives (early modes:
|
||||
`--delete-before`/`--delete-during`, which additionally acknowledge with
|
||||
`STATUS_OK` before data flows) or after the terminal `STATUS_FINISHED` proves
|
||||
the whole transfer succeeded (commit modes: plain `--delete`/`--delete-after`/
|
||||
`--delete-delay`). Timing is chosen purely from the config, so server policy
|
||||
(`--allow-delete` off) still disables deletion without deadlocking the early
|
||||
manifest ack. `--delete-delay` and `--delete-during` are each implemented as
|
||||
the closest safe approximation their engine mode allows; the divergences are
|
||||
noted in the rows above.
|
||||
|
||||
**Deletion-policy notes (Phase 3, delete-policy wave):** this wave made the
|
||||
deletion family real — `--delete-excluded`, `--max-delete`, `--ignore-errors`,
|
||||
`--force`, `--prune-empty-dirs` — and, to support them, the `STATUS_MANIFEST`
|
||||
frame now carries **two sections**: the keep-set paths followed by a list of
|
||||
**protected prefixes** (destination-relative paths the source scan pruned by
|
||||
user-selection rules, which the walker must never delete unless
|
||||
`--delete-excluded` opted out). Two config booleans were added for the wave:
|
||||
`force_delete` (crosses the wire; the receiver clears a directory that blocks an
|
||||
incoming file) and `ignore_errors` (client-only; the sender's scan continues
|
||||
past an unreadable directory). `max_delete`'s default became -1 ("no client
|
||||
limit"). These wire/layout changes bumped `PROTOCOL_VERSION` **2.8.0 → 2.9.0**
|
||||
(peers must match). All four wire additions — `force_delete`,
|
||||
`delete_excluded`, `prune_empty_dirs`, `max_delete` — round-trip unchanged and
|
||||
are validated on receive.
|
||||
|
||||
**Missing-args note (Phase 3, missing-args wave):** `--ignore-missing-args` and
|
||||
`--delete-missing-args` are implemented as described in the Safety & Security
|
||||
rows. Wire impact: the `STATUS_MANIFEST` frame now carries a **third section** —
|
||||
a list of destination-relative **exact-delete paths** (the missing entries'
|
||||
mirrors) — and the config frame gained a `delete_missing_args` boolean
|
||||
(`ignore_missing_args` stays client-only, exactly like `ignore_errors`). These
|
||||
wire/layout changes bumped `PROTOCOL_VERSION` **2.9.0 → 2.10.0** (peers must
|
||||
match). The receiver validates the third section identically to the keep-set
|
||||
(non-empty, relative, traversal-free; `MAX_MANIFEST_ENTRIES` per section, a
|
||||
single `MAX_MANIFEST_BYTES` budget shared across all three). On commit the
|
||||
receiver runs the exact-path deletions FIRST (`manifest_delete_missing_args`:
|
||||
confined per-path unlink/rmdir, deep removal only under `--force`/`--delete`,
|
||||
staging/basis protected, never blocked by the protected-prefix list) and then
|
||||
the ordinary extras walk when `--delete` is active (`manifest_delete_all`). A
|
||||
client may request the exact-path deletions without `--delete`; the server's
|
||||
`--allow-delete` policy gates them exactly like `--delete`, so an unauthorized
|
||||
server ignores the request while the missing entries are still skipped.
|
||||
|
||||
The deletion walker is now **all-or-nothing**: before any unlink it rehearses
|
||||
the deletion (an fd-relative walk identical to the delete pass, counting every
|
||||
regular file it would unlink and every directory it would remove) and refuses to
|
||||
start when the extras exceed the effective bound — a client `--max-delete=NUM`
|
||||
below the hard bound, or the hard `MAX_SERVER_DELETE_COUNT` (100000) bound
|
||||
itself. Previously the walker removed up to `MAX_SERVER_DELETE_COUNT` extras and
|
||||
then reported an error (a truncated deletion); it now removes nothing and fails
|
||||
with an error naming the bound. Directories count toward the bound. A directory
|
||||
that still holds entries the walker leaves in place (a protected excluded file,
|
||||
a kept manifest entry, a symlink) is left behind rather than failing the run —
|
||||
matching rsync's "cannot delete non-empty directory" behaviour. The
|
||||
all-or-nothing guarantee holds only while the destination is not concurrently
|
||||
modified: rehearsal and delete are two separate walks, so a concurrent change
|
||||
between them (another process adding or removing destination entries) can make
|
||||
the actual deletion diverge from the counted set.
|
||||
|
||||
Manifest size: the sender's keep-set and protected-prefix collections (streaming
|
||||
or early pre-scan) are unbounded, but the receiver rejects a manifest beyond
|
||||
`MAX_MANIFEST_ENTRIES` (1 048 576 entries, applied to EACH section — a frame can
|
||||
therefore total up to 2 097 152 entries) / `MAX_MANIFEST_BYTES` (16 MB of paths,
|
||||
counted across BOTH sections) as a hard protocol error. A heavily filtered
|
||||
source whose exclusion list grows large thus fails the run cleanly on the
|
||||
receiver (STATUS_ERROR) instead of being silently truncated. In the commit
|
||||
modes this only means the deletion is refused after the data already arrived; in
|
||||
the early modes (`--delete-before`/`--delete-during`) the manifest is the first
|
||||
frame, so an oversized keep-set or protected list aborts the whole transfer
|
||||
BEFORE any data is sent. Keep the source tree small enough for the receiver's
|
||||
manifest caps when using the early timing.
|
||||
|
||||
Early-delete ACK wait: after committing a large deletion (up to
|
||||
`MAX_SERVER_DELETE_COUNT` removals) the receiver's `STATUS_OK`/`STATUS_ERROR`
|
||||
reply can legitimately take much longer than a normal round trip, so the sender
|
||||
waits for that single ACK with an extended explicit deadline (1 hour) instead
|
||||
of the default 60 s per-message receive window. A receiver that is genuinely
|
||||
gone still aborts the wait via connection close/error; the extended bound only
|
||||
protects against aborting after the deletion already committed on the receiver.
|
||||
|
||||
Flag-conflict policy: unlike rsync's last-one-wins behaviour, every deletion
|
||||
timing flag implies `--delete`, and combining a timing flag with `--no-delete`
|
||||
(in either argument order) — or more than one timing flag — is rejected as a
|
||||
configuration error rather than silently resolved. Note the check is
|
||||
order-independent because it runs over the fully parsed config. The deletion
|
||||
POLICY flags (`--delete-excluded`, `--max-delete`, `--ignore-errors`, `--force`)
|
||||
do NOT imply `--delete`; without `--delete` they are inert (matching rsync).
|
||||
|
||||
**Append-resume notes (Phase 3, append wave):** `--append` and `--append-verify`
|
||||
are real. Both are negotiated when an existing destination file is found to be
|
||||
**shorter** than the source during the per-file `STATUS_CHECK`; the receiver
|
||||
replies with a new `STATUS_APPEND` frame carrying the resume offset (the prefix
|
||||
length it already holds) instead of `STATUS_NEXT`/`STATUS_DELTA_SIGNATURE`.
|
||||
The sender transmits ONLY the tail. For `--append-verify` it first sends the
|
||||
source's prefix xxHash64 in a `STATUS_APPEND_SIG` frame; the receiver compares
|
||||
it to the retained prefix and answers `STATUS_APPEND_OK` (transfer the tail) or
|
||||
`STATUS_NEXT` (prefix mismatch → the sender falls back to a byte-exact full
|
||||
transfer). The tail arrives in a `STATUS_APPEND_DATA` frame (compression and
|
||||
metadata still apply). The receiver then rebuilds the full file in memory
|
||||
(prefix + tail) and routes it through the existing atomic store engine, so all
|
||||
of `--inplace`, `--partial`/`--partial-dir`, `--delay-updates`, `--backup`,
|
||||
`--existing`/`--ignore-existing`/`--update` and delete-manifest behaviour is
|
||||
unchanged and the result is a byte-identical source copy (given a matching
|
||||
prefix). These new frames changed the wire, so `PROTOCOL_VERSION` was bumped
|
||||
**2.9.0 → 2.10.0** (peers must match; the pre-existing `append`/`append_verify`
|
||||
config booleans already crossed the wire). CLI: both flags imply `--incremental`
|
||||
(the handshake needs it); they are incompatible with `-s` (chunk serialization)
|
||||
and `--whole-file` (both rejected up front, never a silent full transfer); when
|
||||
both spellings are given `--append-verify` wins. The FastSync divergence from
|
||||
rsync is intentional and safer: rsync appends in place, whereas FastSync
|
||||
reconstructs the whole file and atomically installs it, so an interrupted or
|
||||
failed resume never leaves a partial/corrupt file at the destination — this is
|
||||
why plain `--append` works on the normal atomic path, not only with `--inplace`.
|
||||
|
||||
## 8. Metadata Preservation
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `-M`, `--preserve` | Preserve file metadata | ✅ Implemented | Mode, uid, gid, mtime |
|
||||
| `-p`, `--perms` | Preserve permissions | ✅ Implemented | Phase 7 Wave A: `-p`/`--perms` now preserve permission bits, folded into FastSync's broad metadata bundle (`--preserve`); the SSH port moved to `--ssh-port`. rsync-parity short form |
|
||||
| `-o`, `--owner` | Preserve owner | ✅ Implemented | Part of -M |
|
||||
| `-g`, `--group` | Preserve group | ✅ Implemented | Part of -M |
|
||||
| `-t`, `--times` | Preserve modification times | ✅ Implemented | Part of -M |
|
||||
| `-E`, `--executability` | Preserve executability | ✅ Implemented | Preserves executable permission bits (implies metadata preservation) |
|
||||
| `--chmod=CHMOD` | Affect file permissions | ✅ Implemented | Supports numeric and symbolic `ugo` `rwx` changes; retains receiver safety masking |
|
||||
| `-A`, `--acls` | Preserve ACLs | ✅ Implemented | Implemented on Linux via the POSIX-ACL xattr representation: the sender captures the `system.posix_acl_access` / `system.posix_acl_default` xattrs into the same bounded whitelisted set as `-X`, transmits them per-file, and the receiver re-applies them fd-relative. Setting an ACL the receiver is not permitted to set (non-root on a file it does not own, unsupported filesystem) is logged and skipped, never fatal. libacl is **not** required. Only the `system.posix_acl_*` namespaces plus `user.*` are ever applied; privileged namespaces are never applied (see the Phase-4 xattr/ACL notes below). Implies metadata transmission |
|
||||
| `-X`, `--xattrs` | Preserve extended attributes | ✅ Implemented | Preserves unprivileged `user.*` extended attributes (Linux `listxattr`/`getxattr` on capture, `fsetxattr` on the written destination fd). Both capture (sender) and application (receiver) are restricted to the `user.*` namespace and the two POSIX ACL xattrs, so a client can **never** force a `security.*`/`trusted.*`/privileged attribute onto the destination; the receiver independently re-validates every incoming name against this whitelist and rejects anything else. Payloads are bounded (per-name ≤255B, per-value ≤1MiB, per-file count ≤256 total bytes ≤4MiB) on both ends, and an oversized/malformed frame is a clean protocol rejection (no OOM). Applied fd-relative to the exact written file. Implies metadata transmission. Incompatible with `-s` (chunk serialization), rejected up front (see the notes); a `--link-dest`/`-H` hard-link copy fallback re-applies the attributes so they are not dropped when a link is refused |
|
||||
| `-H`, `--hard-links` | Preserve hard links | ✅ Implemented | Files on the source that share an inode (`st_dev`+`st_ino`, e.g. a `cp -al` tree) are re-created as hard links to one another on the destination, so duplicate links stay deduplicated and only the first member's data is sent (later members are transmitted as payload-less `STATUS_HARDLINK` frames). The receiver links each sibling to the first member's installed file with an atomic link + rename; on `link()` failure it falls back to a byte-identical local copy of the first member, never a partial/corrupt file. Requires the sequential scan for ordering (the first member is always emitted and installed before any sibling is linked). Works single-threaded and under `-j`/`--threads`, `--inplace`, `--delay-updates` (links staged and published by rename) and `--partial`. Crosses the wire (`preserve_hard_links` bool; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0**, peers must match). Incompatible with `-s` (chunk serialization) and `--append`/`--append-verify`, rejected up front with a distinct error. See the Phase-4 hard-links notes below |
|
||||
| `-D` | Same as --devices --specials | ✅ Implemented | Implies `--devices --specials`. `-D` was unassigned in FastSync (verified: no collision), so it is free to imply both device-node and special-file preservation. See the `--devices`/`--specials` rows and the Phase-4 devices notes below |
|
||||
| `--devices` | Preserve device files | ✅ Implemented | Recreates char/block device nodes on the destination via `mknod` instead of transferring content. Type + rdev are validated strictly (S_IFMT from the transmitted mode; major/minor range-checked, non-negative), and creation is **privilege-gated**: `mknod` needs `CAP_MKNOD`, so a non-root receiver (CI runs via setpriv as non-root) logs a warning and **skips the device entry safely** — the whole transfer never aborts just because the node could not be made. The node is created fd-relative below the receive root (`mknodat` on the confined secure parent), so it can never be placed outside the authorized root, never follows a symlink, and never replaces an existing directory. Only a char/block mode is honored. Crosses the wire (a new `STATUS_SPECIAL` frame carries the path + metadata mode + rdev; `PROTOCOL_VERSION` bumped **2.12.0 → 2.13.0**). Divergence: per-entry skip (not a hard error) when the receiver lacks `CAP_MKNOD`, documented in the Phase-4 devices notes |
|
||||
| `--specials` | Preserve special files | ⛔ Impossible/Divergence | **FIFO recreation works**: FIFOs are recreated on the destination via `mkfifo` (unprivileged, so this is a real, assertable behavior under CI). **Only socket recreation is impossible**: a socket entry can be created only by `bind(2)` on a live socket, not by any filesystem call, so a source socket is skipped with an explicit note. That one unsupported node kind is why the flag is classified Impossible/Divergence even though FIFO recreation itself works; its normal path is otherwise complete. FIFO creation is privileged-gated only in the sense of graceful skip on any permission failure. Node creation is confined below the receive root (`mkfifoat` on the secure fd-relative parent; no `..`, no symlink follow). Crosses the wire like `--devices` (the `STATUS_SPECIAL` frame; `PROTOCOL_VERSION` bumped **2.12.0 → 2.13.0**). See the Phase-4 devices notes |
|
||||
| `--copy-devices` | Copy device contents as file | ✅ Implemented | Copy a device's CONTENT into an ordinary regular file on the destination instead of recreating the node — non-privileged and safe. FastSync scans a device/FIFO as a regular file: its reported size (`st_size`, typically 0 for char devices and FIFOs) is copied, so a FIFO or a non-readable device becomes an empty (or size-bounded) regular file. The default data path is size-bounded and never blocks (it sends exactly `st_size` bytes, never an unbounded pseudo-device stream); with `--sendfile`, a non-regular source (FIFO/device) is detected from its `stat` mode and falls back to that same buffered read, so `--copy-devices --sendfile` cannot hang either. The run always succeeds and never crashes on such input. **Deliberate, safe divergence from rsync's dd-like unbounded device read.** See the Phase-4 devices notes |
|
||||
| `--write-devices` | Write to devices as files | ✅ Implemented | Write the received data directly into an **existing** device node on the destination instead of creating a regular file. Restricted and best-effort: the destination must already exist and be a char/block device (opened only under the confined receive root, with `O_NOFOLLOW` + `O_NONBLOCK`); a missing, symlinked, FIFO-with-no-reader (`ENXIO`), non-device destination, or any write failure is **skipped with a warning** rather than allowed, so a run can never clobber the system, never blocks on a special-file target, and never aborts on an unusable target. See the Phase-4 devices notes |
|
||||
| `-U`, `--atimes` | Preserve access times | ✅ Implemented | Captures the source access time (from the scanner's pre-read stat, so it is not clobbered by reading the file for transfer) and transmits it over the wire; the receiver restores it together with the mtime via `futimens`/`utimensat`. Implies metadata transmission (the times travel inside the `-M` metadata payload), but does not enable ownership application (that stays opt-in via the identity flags). Wire: new `atime` fields on the metadata frame + a `preserve_atimes` config boolean; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0** |
|
||||
| `-N`, `--crtimes` | Preserve create times | ⛔ Impossible/Divergence | Birth-times cannot be set by any portable filesystem call (`utimensat`/`futimens` only set atime/mtime), so this row is an explicit **Impossible/Divergence** (Phase 7 Wave B). Capture + transmit stays: `statx(STATX_BTIME)` on Linux records the source birth time as a wire field; the receiver logs a debug note that it cannot be applied and continues — never failing the transfer and never pretending it worked. On platforms without `statx` it parses as a documented no-op (flag accepted; nothing is captured). Implies metadata transmission. Wire: new `crtime` fields + a `preserve_crtimes` config boolean; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0** (see the Phase-4 metadata-time notes) |
|
||||
| `-O`, `--omit-dir-times` | Omit dirs from --times | ✅ Implemented | Real modifier now that FastSync preserves directory times. With metadata on, the scanner captures every traversed source directory's mtime (and atime under `-U`) and the sender transmits them in trailing `STATUS_DIR_TIMES` frame(s) **after all file data and the optional delete manifest** (chunked at the receiver's `MAX_MANIFEST_ENTRIES` per-frame cap); a dir-time entry only RECORDS metadata and never creates the directory, so empty source directories stay untransferred. The receiver defers applying them until its delete / `--delay-updates` publication phases have committed, so writing or removing a child never clobbers a parent directory's mtime (rsync applies directory times at the end for exactly this reason). When `-O` is set (the boolean crosses the wire) the receiver does not apply any of them; without `-O` an `-a`/`--preserve` transfer now restores directory times (reversing the old "never preserves dir times" divergence). Wire change: the terminal `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** |
|
||||
| `-J`, `--omit-link-times` | Omit symlinks from --times | ✅ Implemented | Real modifier now that FastSync preserves symlink times. Symlink entries already carried their metadata on `STATUS_SYMLINK`; the receiver now applies it with **no-follow primitives only** (`utimensat(..., AT_SYMLINK_NOFOLLOW)`, plus best-effort `fchmodat(..., AT_SYMLINK_NOFOLLOW)` and policy-gated `fchownat(..., AT_SYMLINK_NOFOLLOW)`), so the link itself is stamped without ever dereferencing it, confined fd-relative below the authorized receive root. A symlink has no children, so the times are applied immediately at creation. When `-J` is set (the boolean crosses the wire) the receiver skips the timestamps (mode/ownership are unaffected); without `-J` an `-a`/`-l` transfer restores symlink mtimes. Wire change alongside `-O`: the shared `STATUS_DIR_TIMES` frame; `PROTOCOL_VERSION` bumped **2.16.0 → 2.17.0** |
|
||||
| `--super` | Receiver attempts super-user activities | ✅ Implemented | Phase 7 Wave E: receiver-side **safe-subset + clear-refusal** privilege model, tri-state `super_mode` (auto/on/off). `--super` **permits** the receiver to attempt super-user activities — ownership application and char/block device-node creation — that are already confined fd-relative below the authorized receive root; `--no-super` **forbids** them even when the receiver is root; the default (`auto`) preserves the pre-existing **best-effort** behavior of *attempting* them (not only when already root: an unprivileged attempt is refused by the kernel and skipped per entry, matching FastSync's history). The server additionally accepts an operator-level `--no-super` veto that forces `OFF` for every connection it accepts (so it also refuses any client `--copy-as`/`--super`); a **privileged (root) standalone TCP listener now also defaults to `OFF`** unless the operator opts in with the new server-only `--allow-super` flag (the flag is **rejected with `--stdio`**, whose remote argv is composed by the client and must never defeat the secure default; operators exposing `fastsync-server --stdio` over SSH need a forced command if the default must hold. An unprivileged receiver is unchanged, since the kernel refuses the confined attempts anyway; the `--daemon` path keeps its per-module `client owner = yes` opt-in); the `--fake-super` owner replay and the `--write-devices` write path are gated by the same policy. **FastSync never elevates**: no `setuid`/`seteuid`/`setgid` is ever called, and `--super` never bypasses the confinement floor (`file_open_secure_parent`, `O_NOFOLLOW`, root checks) — it only permits an attempt that is already confined. `--super` does **not** imply `--numeric-ids` and never enables client-chosen ownership on its own: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. A non-root receiver given `--super` logs exactly one warning at activation and each confined attempt is then refused by the kernel and skipped per entry (never aborts); `--no-super` suppresses ownership, char/block `mknod`, `--write-devices` and the fake-super owner replay, while unprivileged FIFO creation is unaffected. Wire: one trailing `super_mode` int on the config frame (validated 0..2), sent **before** the `--copy-as` block (fixed order: super int, then copy-as presence int + ids); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Documented divergence from rsync:** rsync's `--super` runs the receiver with elevated privilege; FastSync only permits a confined attempt and never elevates |
|
||||
| `--fake-super` | Store/recover privileged attrs via xattrs | ✅ Implemented | Phase 7 Wave B: full record **and replay**. The receiver writes the source `uid:gid:mode:mtime_sec:mtime_nsec` into a reserved `user.fastsync.stat` xattr on each written file (best-effort, fd-relative, format unchanged), then immediately re-applies it via `fake_super_restore_fd`: `fchown` (only where privileged — a non-root EPERM/EACCES is skipped silently, matching FastSync's identity philosophy), `fchmod`, and `futimens`. The OWNER leg is additionally skipped unless an explicit ownership identity policy (`--numeric-ids`/`--usermap`/`--groupmap`/`--chown`/`--copy-as`) is active — `--fake-super` on its own only *records* the source owner and must not act as an un-gated chown primitive — when `--no-super` forbids super-user activities (even for root), or when an active `--copy-as` is authoritative, so the recorded source owner can never override a forced `--copy-as` owner; the xattr record is still stored/replayed for a later privileged restore and mode/mtime still apply, so unprivileged `--fake-super` keeps working. The restored mode goes through the same sanitization as the normal metadata path (group/other write bits are never granted, so a recorded 0666 restores as 0644), so fake-super replay can never grant group/other-write that plain `--preserve` would refuse. Absence or a malformed record is a silent no-op, never fatal. The recording format diverges from rsync's `user.rsync.%stat%`; no cross-tool conversion is attempted. Implies metadata transmission so the source uid/gid/mode/mtime are available. Both it and `-X`/`-A` are incompatible with `-s` (chunk serialization), rejected up front |
|
||||
| `--open-noatime` | Avoid changing access time when opening files | ✅ Implemented | Sender-side policy: the sender opens source files with `O_NOATIME` (Linux) when reading them for transfer, so the open/read does NOT bump the source's on-disk access time. Degrades safely when `O_NOATIME` is unavailable (not defined) or refused (`EPERM`, since it needs `CAP_FOWNER` or file ownership): the code falls back to a normal open, so the data always transfers — only the atime-bump is skipped. It does not itself capture/preserve atime; it only avoids modifying it. **Client-only, never crosses the wire.** Exposed as `file_open_for_read()` and applied to both the buffered data path and the sendfile path |
|
||||
| `--numeric-ids` | Do not map uid/gid by name | ✅ Implemented | Ownership is applied through FastSync's opt-in identity path (see the Phase-4 identity notes below). `--numeric-ids` is a mapping-policy modifier: when applying ownership it uses the transmitted numeric uid/gid directly, skipping the name lookup. Without an ownership-affecting option it is inert (FastSync only applies ownership when the user opts in). It does not need `-M` to be parsed, but ownership is only applied when metadata (hence the source uid/gid) is actually transmitted (see the notes) |
|
||||
| `--usermap=STRING` | Map usernames | ✅ Implemented | Opt-in ownership application. rsync subset implemented: comma-separated `FROM:TO` rules evaluated in order, first match wins; `FROM`/`TO` are group/user names (resolved on the SOURCE machine at parse time), `*` (FROM matches any id / TO = the receiving process's current euid), and an `@N` or bare `N` numeric id. Rules are carried over the wire as resolved numeric id pairs; the receiver applies a matching rule (else falls back to `--chown`, `--numeric-ids`, then a best-effort name lookup) via an fd-relative `fchown`. Malformed/unresolvable specs are rejected with a clear error, never a silent no-op. Implies metadata preservation so the source uid/gid travel. Only effective when the receiver can actually change ownership (root or membership); otherwise it warns and continues |
|
||||
| `--groupmap=STRING` | Map group names | ✅ Implemented | Same rsync subset and semantics as `--usermap` but for the group (gid) side and the group databases. See the Phase-4 identity notes |
|
||||
| `--chown=USER:GROUP` | Map owner and group | ✅ Implemented | Opt-in ownership override applied receiver-side. Forms: `USER:GROUP`, `USER` (owner only), `:GROUP` (group only); a `*` for USER/GROUP means the current/root user or group as appropriate; an `@N`/bare `N` numeric id is accepted. A `:` inside a name may be escaped as `\:`. Equivalent to a trailing `*:*` usermap+groupmap rule (so an explicit `--usermap`/`--groupmap` match wins). Malformed or unresolvable specs are clear parse errors. Implies metadata preservation. Only effective when the receiver has permission to chown; otherwise it warns and continues (rsync parity) |
|
||||
| `--copy-as=USER[:GROUP]` | Perform the copy as another user/group | ✅ Implemented | Safe-subset implementation, an explicit divergence from rsync's **real identity switching**. rsync makes the receiving process actually assume USER/GROUP (setuid/setgid); FastSync's receiver is multithreaded, so a real credential drop would be unsafe and is never attempted — FastSync never calls `setuid`/`seteuid`/`setgid`. Instead the receiver FORCES the ownership of every entry it writes to `copy_as_uid`/`copy_as_gid` through the existing confined, fd-relative identity path (the same `fchown`/`fchownat` mechanism as `--chown`/`--usermap`/`--groupmap`; symlinks use `fchownat(..., AT_SYMLINK_NOFOLLOW)`, and directories — including intermediate parents created implicitly while writing a nested file — and char/block/FIFO nodes are owned no-follow too, so a directory never keeps the receiver's owner while its children get the target owner), with `--copy-as` at the **highest priority** — it beats usermap/groupmap/`--chown`/`--numeric-ids` and the best-effort name lookup. This REQUIRES a privileged (root) receiver: an unprivileged receiver REFUSES the whole transfer up front at the config handshake (`server_module_gate`, running inside `config_receive_with_validate` before the `STATUS_OK` ack) with a clear error and no file data exchanged — never a silent wrong-ownership result. A server running with an operator `--no-super` veto also refuses it; a privileged (root) standalone TCP listener refuses it by default too and only honors it after the operator passes `--allow-super` (the flag is rejected with `--stdio`, where the client-composed remote argv could otherwise defeat the default; a forced command is required if the default must hold), and a **daemon** refuses `--copy-as`, like every other client-chosen-ownership request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/explicit `--super`), unless the selected module opts in with `client owner = yes`; without that per-module opt-in a daemon must not honor an arbitrary client-selected owner (a root standalone listener honors these for its single operator-authorized root only when started with `--allow-super`). `--fake-super` interaction: `--copy-as` is authoritative, so the recorded source owner is never replayed over the forced target owner. If the ownership apply still fails with EPERM/EACCES (capability-restricted root, root-squash, read-only mount) the failure is logged at ERROR and the **entry is reported as failed** rather than written with the wrong owner, which fails the transfer (fail-fast) so overall success is never reported with the wrong owner. USER is resolved on the client against the user database (a name, an `@N`/bare `N` numeric id, or `*` meaning the client's current euid); when `:GROUP` is present it is resolved against the group database (`*` meaning the client's egid). **Group-default rule:** when the group is omitted FastSync uses the user's primary gid (`getpwuid(uid)->pw_gid`); a numeric id with no local passwd entry has no primary gid to look up, so `gid` falls back to `uid` (documented divergence). Malformed/empty/unresolvable specs are clear parse errors, never a silent no-op. Never elevates privileges and never bypasses the confined receive root. Implies metadata preservation (the source uid/gid must be transmitted). Wire: a new trailing config-frame block **sent after** the `--super` int (presence int, then the two int32 ids, both validated `>= 0` on receive; the ids are also rejected if they do not fit int32 at CLI parse time); `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0** |
|
||||
|
||||
**Phase-4 metadata-time notes:** `-U/--atimes`, `-N/--crtimes`,
|
||||
`-O/--omit-dir-times`, `-J/--omit-link-times`, and `--open-noatime` are new.
|
||||
They change the wire: the per-file metadata frame grows `atime_valid` +
|
||||
`atime_sec` + `atime_nsec` and `crtime_valid` + `crtime_sec` + `crtime_nsec`
|
||||
(appended after the existing mode/uid/gid/mtime fields, preserving the exact
|
||||
positions of every pre-existing field), and the config frame grows four
|
||||
booleans — `preserve_atimes`, `preserve_crtimes`, `omit_dir_times`,
|
||||
`omit_link_times` — that CROSS the wire so the receiver knows what to apply /
|
||||
suppress. `--open-noatime` is **client-only** and is never serialized (it only
|
||||
governs the sender's source reads). `PROTOCOL_VERSION` was bumped **2.11.0 →
|
||||
2.12.0** (peers must match, exactly as prior phases did).
|
||||
|
||||
**Client-vs-wire split:** `-U` and `-N` affect both the sender (capture) and the
|
||||
receiver (apply), so they and their metadata fields cross the wire;
|
||||
`-O`/`-J` are receiver-side preferences and cross as config booleans;
|
||||
`--open-noatime` is purely a client/sender open flag and stays off the wire
|
||||
(mirroring the existing convention where `ignore_errors` is client-only while
|
||||
`force_delete` crosses the wire).
|
||||
|
||||
**Phase-4 xattr/ACL notes (`-X/--xattrs`, `-A/--acls`, `--fake-super`):** these
|
||||
are new in protocol 2.13.0 and add a bounded per-file xattr block to the
|
||||
per-file metadata frame (count + each `name`/`value`, sent only when xattr
|
||||
transport is enabled, i.e. with zero overhead on unaffected runs). The config
|
||||
frame carries `preserve_xattrs`, `preserve_acls` (in the existing file-options
|
||||
block) and a trailing `fake_super` boolean — all CROSS the wire so the receiver
|
||||
knows the negotiated behavior; the derived `use_xattrs` flag is recomputed on
|
||||
the receiver. `PROTOCOL_VERSION` was bumped **2.12.0 → 2.13.0** (peers must
|
||||
match, exactly as prior phases did).
|
||||
|
||||
- **Security model (both `-X` and `-A`):** only `user.*` and the
|
||||
`system.posix_acl_access` / `system.posix_acl_default` namespaces are ever
|
||||
captured (sender) or applied (receiver). `security.*` (SELinux, capabilities,
|
||||
...), `trusted.*`, and all other `system.*` attributes are never transmitted
|
||||
or applied, so a client can never compel the receiver to set a privileged
|
||||
xattr. The receiver re-validates each incoming name against this whitelist
|
||||
even though the sender already filtered, so a malicious/compromised sender's
|
||||
`security.capability` payload is rejected outright (a clean protocol error),
|
||||
never applied.
|
||||
- **Bounds / memory safety:** per-name length ≤ 255 B, per-value ≤ 1 MiB,
|
||||
per-file count ≤ 256 names, per-file name+value total ≤ 4 MiB. Both the
|
||||
sender (during capture) and the receiver (during receive) enforce these; an
|
||||
oversized or malformed frame is rejected, never a large allocation.
|
||||
- **Confined application:** xattrs are applied with `fsetxattr` on the exact
|
||||
just-written destination file fd (before the atomic rename), never on a
|
||||
caller-controlled path; this is the same confinement as mode/time restore.
|
||||
The `--link-dest` / `-H` hard-link copy fallback (a byte copy when `link()`
|
||||
is refused) also re-applies the incoming (or, for `-H`, the first member's)
|
||||
xattrs and the `--fake-super` stat, so attributes are preserved rather than
|
||||
silently dropped when the link fails.
|
||||
- **Reserved fake-super key is receiver-only:** the `user.fastsync.stat` key is
|
||||
excluded from sender capture AND from receiver application, so it can only be
|
||||
written by the receiver's own `--fake-super` handling. A source file that
|
||||
already carries such a record is never forwarded on a plain `-X` run, so it
|
||||
cannot be spoofed to mislead a later privileged restore.
|
||||
- **`-A` requires no libacl** — ACLs travel as the `system.posix_acl_*` xattrs.
|
||||
Applying an ACL is owner-privileged: `fsetxattr` failure (e.g. non-root,
|
||||
unsupported filesystem) is logged (collapsed to one line per file) and never
|
||||
fatal.
|
||||
- **`--fake-super`**: see the row above; the reserved key is `user.fastsync.stat`
|
||||
with the documented `uid:gid:mode:mtime_sec:mtime_nsec` (mode octal) format.
|
||||
It is honest but partial — there is no replay, and it does not interoperate
|
||||
with rsync's `user.rsync.%stat%`.
|
||||
- **Chunk serialization (`-s`) incompatibility:** the per-file xattr block rides
|
||||
the streaming per-file frame, which `-s` replaces with a fixed buffer format,
|
||||
so `-X` / `-A` combined with `-s` is rejected up front on both ends (mirroring
|
||||
the existing `-H` + `-s` rejection) rather than silently dropping attributes.
|
||||
|
||||
**atime capture does not clobber the source atime:** the sender records the
|
||||
access time from the **same pre-read stat the scanner already took** (inside
|
||||
`file_metadata_create`), before any file data is read for transfer. So `-U`
|
||||
alone captures the correct atime even without `--open-noatime`. `--open-noatime`
|
||||
is orthogonal: it keeps the source's on-disk atime from being bumped by the read
|
||||
that actually ships the data (only honoured where `O_NOATIME` works; it degrades
|
||||
to a normal open otherwise, so the data always transfers).
|
||||
|
||||
**crtime handling:** `-N` captures the source birth time via `statx`/`STATX_BTIME`
|
||||
(guarded `#ifdef STATX_BTIME` on Linux) and transmits it. On the receiver, **no
|
||||
portable setter exists** (`utimensat` can only set atime/mtime), so the receiver
|
||||
deliberately does **not** apply it: it logs a debug note and continues — it never
|
||||
fails the transfer and never pretends the crtime was applied. This is the
|
||||
explicit, documented unsupported-attribute handling. On platforms without
|
||||
`statx` the flag is accepted but nothing is captured (a documented no-op).
|
||||
|
||||
**omit-dir-times / omit-link-times:** `-O` and `-J` are **real modifiers** as of
|
||||
P7 Wave D (`🔄 → ✅ Implemented`). FastSync now preserves directory mtimes
|
||||
(captured by the scanner, transmitted in trailing `STATUS_DIR_TIMES` frame(s),
|
||||
applied only after all children and the delete/publication phases) and symlink
|
||||
mtime/owner/mode (no-follow `utimensat`/`fchownat`/`fchmodat` at link creation).
|
||||
`-O` makes the receiver skip the directory-time set; `-J` makes it skip the
|
||||
symlink timestamps (ownership/mode application is unaffected and stays governed
|
||||
by the identity opt-in). Both config booleans already crossed the wire. See the
|
||||
`-O`/`-J` rows and the Wave D note below.
|
||||
|
||||
**-U/-N and -M interaction:** because FastSync carries all metadata (mode, uid,
|
||||
gid, mtime, and now atime/crtime) in one bounded payload that is only sent when
|
||||
metadata transmission is on, `-U` and `-N` imply metadata transmission (the
|
||||
times travel inside that payload). They do **not** enable ownership application,
|
||||
which remains opt-in strictly through the identity flags (`--numeric-ids` /
|
||||
`--usermap` / `--groupmap` / `--chown`).
|
||||
|
||||
**Phase-4 identity notes:** `--numeric-ids`, `--usermap`, `--groupmap`, and
|
||||
`--chown` are real. They introduce a **controlled, opt-in, privilege-gated**
|
||||
ownership-application path on the receiver: plain `-M`/`--preserve` still does
|
||||
NOT apply client-supplied ownership (FastSync's deliberate conservative
|
||||
default, byte-for-byte backward compatible); ownership is only attempted once a
|
||||
client explicitly requests an ownership-affecting option. Application goes
|
||||
through an fd-relative `fchown()` in the receiver's metadata-restore path (after
|
||||
the file is fully written, before timestamps are set), so it is confined and
|
||||
symlink-safe — never a path-based `chown`. When the receiver lacks permission
|
||||
(typically non-root, e.g. the CI `nobody` user) `EPERM`/`EACCES` is logged as a
|
||||
warning and the transfer CONTINUES with exit status success, matching rsync.
|
||||
A no-op default means existing transfers are unaffected.
|
||||
|
||||
Resolution of the destination uid/gid on the receiver: a matching
|
||||
`--usermap`/`--groupmap` rule wins; else the matching `--chown` side; else, with
|
||||
`--numeric-ids`, the transmitted numeric id is used raw (no name lookup); else a
|
||||
best-effort name lookup on the receiver's own account databases (skipped when
|
||||
the transmitted id has no name present there). `--chown` enforces the receiver
|
||||
side and is validated at parse time (malformed specs are clear errors, never a
|
||||
silent no-op).
|
||||
|
||||
Wire/version: the config frame gained `numeric_ids`, `chown_uid_set`,
|
||||
`chown_uid`, `chown_gid_set`, `chown_gid`, and the `usermap`/`groupmap` tables
|
||||
(count-delimited lists of resolved int32 FROM/TO id pairs), so
|
||||
`PROTOCOL_VERSION` was bumped **2.10.0 → 2.11.0** (peers must match). All new
|
||||
fields cross `config_send`/`config_receive` with full symmetry and are validated
|
||||
on receive (bounded map sizes below `MAX_IDENTITY_MAP`, ids `>=` the `-1`
|
||||
sentinels).
|
||||
|
||||
Documented divergences from rsync: because FastSync transmits only numeric
|
||||
uid/gid (not names) on the wire, name-based values (`--usermap`/`--groupmap`
|
||||
names, `--chown` names) are resolved to numbers at CLI parse time against the
|
||||
**client (sender) machine's** account databases; this reproduces rsync's
|
||||
semantics on a shared-account source/destination and is documented for a
|
||||
genuinely different destination. The interesting named-value subset is
|
||||
supported (`*` FROM wildcard, `*` TO = current user, `@N`/bare-`N` numerics); a
|
||||
lone-`@` "use the FROM value unchanged" rsync form is not implemented. Also
|
||||
unlike rsync, plain `-M` never applies ownership and `--usermap`/`--groupmap`/
|
||||
`--chown` each imply metadata preservation so the source uid/gid actually travel
|
||||
(the flags only take effect where ownership is being preserved/applied).
|
||||
|
||||
**Phase-4 hard-links notes:** `-H`/`--hard-links` is real and introduces a
|
||||
deduplicating wire path for files whose source entries share a filesystem inode.
|
||||
On the sender, the scanner records each distinct `(st_dev, st_ino)` encounter and
|
||||
assigns it a stable, run-local link-group id (`HardLinkTable`, mutex-guarded so a
|
||||
multi-threaded scan could share one instance). The FIRST member of a group is
|
||||
transferred normally and carries the data; each later (sibling) member is
|
||||
transmitted as a payload-less `STATUS_HARDLINK` frame carrying its destination
|
||||
path, the group id, and the first member's destination-relative wire path.
|
||||
Ordering is guaranteed by forcing the sequential scanner whenever `-H` is on
|
||||
(even under `-j`/`--threads`), so the first member is always emitted — and, on the receiver's
|
||||
single write thread, installed — before any of its siblings; the receiver is
|
||||
therefore always able to link to an already-present first member, including the
|
||||
"first member already up-to-date/skipped" case (the sibling links to or copies
|
||||
the existing file). Asymmetric existence policies are handled gracefully: under
|
||||
`--existing`, if the first member's destination is absent (so it is skipped) but
|
||||
a sibling's own destination already exists, that existing sibling is left in
|
||||
place rather than the transfer aborting on the missing first member. The receiver
|
||||
installs each sibling beneath its confined root
|
||||
as an atomic hard link (temp link + rename); when `link()` fails (cross-device,
|
||||
filesystem refuses links) it falls back to a byte-identical local copy of the
|
||||
first member, never a partial/corrupt file. `--delay-updates` stages each sibling
|
||||
as a hard link to the first member's STAGED file, so publication's renames
|
||||
preserve the shared inode; `--inplace` and `--partial` are unaffected (a sibling
|
||||
is a fresh link/copy). Because a hard link shares an inode, metadata is applied
|
||||
exactly once on the first member and never re-written through the sibling (whose
|
||||
members are byte-identical by construction), so all members agree.
|
||||
|
||||
Wire/version: `PROTOCOL_VERSION` was bumped **2.11.0 → 2.12.0** (peers must
|
||||
match). The config frame already carried the `preserve_hard_links` boolean
|
||||
(round-trips through `config_send`/`config_receive`); the only new wire element
|
||||
is the `STATUS_HARDLINK` frame described above. Incompatibilities (rejected up
|
||||
front with a distinct error on the client, and re-checked on receive): `-H` with
|
||||
`-s` chunk serialization (the chunk wire has no per-file hard-link info) and `-H`
|
||||
with `--append`/`--append-verify` (a payload-less sibling cannot be tail-resumed).
|
||||
|
||||
**Phase-4 devices notes:** `--devices`, `--specials`, `-D`, `--copy-devices`,
|
||||
and `--write-devices` are new. They change the wire: the config frame grows three
|
||||
booleans — `preserve_specials`, `copy_devices`, `write_devices` — that CROSS the
|
||||
wire (`preserve_devices` already existed), and a new `STATUS_SPECIAL` frame (used
|
||||
by `--devices`/`--specials`/`-D`) carries a special/device entry: the destination
|
||||
path, the metadata frame (whose mode's S_IFMT bits carry the node kind, requiring
|
||||
the flags to imply metadata transmission), and two int32 `rdev` major/minor
|
||||
fields. The chunk-serialized wire (`-s`) grows a matching per-file special
|
||||
marker + rdev so `--devices/--specials` also work under `-s`. `PROTOCOL_VERSION`
|
||||
was bumped **2.12.0 → 2.13.0** (peers must match, exactly as prior phases did).
|
||||
|
||||
**Privilege gating (the crux):** making a device node requires `CAP_MKNOD` (root).
|
||||
CI runs the integration suite as a NON-ROOT user (via setpriv), so `mknod` fails
|
||||
with `EPERM`. The receiver treats this as a graceful, logged *skip of the entry*
|
||||
returned as a success/skip outcome — the whole transfer NEVER aborts just because
|
||||
the environment cannot create the node. `mkfifo` (FIFOs) is unprivileged, so
|
||||
`--specials` FIFO creation is a real, assertable behavior under CI; sockets cannot
|
||||
be recreated by any standard filesystem call and are skipped with an explicit
|
||||
note. The "device actually created" integration assertions are guarded to run
|
||||
only as root. User-facing expectation: point `--devices` at devices and a
|
||||
non-root receiver will faithfully skip them while transferring everything else.
|
||||
|
||||
**Confinement & validation:** a special/device node is created with
|
||||
`mknodat`/`mkfifoat` on the parent directory opened fd-relative below the receive
|
||||
root (`file_open_secure_parent`: `O_NOFOLLOW`, no `..` components, root-checked),
|
||||
so a node can never be created outside the authorized destination root and never
|
||||
through a symlinked parent. The transmitted type is derived ONLY from the
|
||||
validated S_IFMT bits of the metadata mode (char/block/FIFO honored, socket
|
||||
skipped, regular/dir rejected as an invalid special), and the transmitted rdev is
|
||||
validated both on the wire (`file_receive_special`, `chunk_deserialize`) and at
|
||||
the creation site (`file_special_rdev_valid`): a negative, oversize, or
|
||||
non-device-carrying rdev is rejected outright (receiver aborts the frame), and a
|
||||
node is never replaced over an existing directory or unrelated entry (a matching
|
||||
existing node is left in place). `--write-devices` is the deliberately restricted
|
||||
danger path: it only ever opens an existing char/block node under the confined
|
||||
root, and every failure mode (missing, non-device, write error, EPERM) is a
|
||||
warning + skip, never a system-clobbering write or an abort.
|
||||
|
||||
**Documented divergences (honest subset):**
|
||||
- A device entry the receiver cannot create (missing `CAP_MKNOD`) is *skipped*,
|
||||
not a transfer failure — rsync under the same conditions would error.
|
||||
- `--copy-devices` copies the device's *reported size* (typically 0 for char
|
||||
devices/FIFOs) into a regular file and never reads an unbounded pseudo-device;
|
||||
this is the safe, non-hanging alternative to rsync's dd-like read.
|
||||
- `--write-devices` requires the device to already exist at the destination and
|
||||
never creates it; unsupported/inaccessible targets are skipped, not written.
|
||||
- Ownership is not applied to recreated nodes (identity `fchown` needs an fd and
|
||||
would require opening the node); permissions and mtime are applied at
|
||||
creation / via `utimensat`.
|
||||
|
||||
## 9. Symlink Handling
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `-l`, `--links` | Copy symlinks as symlinks | ✅ Implemented | A symlink is transmitted as a real symlink: its target string crosses the wire (a new `STATUS_SYMLINK` frame / chunk entry type) and the receiver creates it with `symlinkat` beneath the receive root. This makes the previously-`-l`-included-but-targetless symlink handling complete. See the Phase-4 symlink-trust notes |
|
||||
| `-L`, `--copy-links` | Transform symlink to referent | ✅ Implemented | `copy_links` config field |
|
||||
| `--copy-unsafe-links` | Transform unsafe symlinks | ✅ Implemented | `copy_unsafe_links` config field |
|
||||
| `--safe-links` | Ignore symlinks outside tree | ✅ Implemented | `safe_links` config field |
|
||||
| `--munge-links` | Munge symlinks for safety | ✅ Implemented | Sender rewrites each transmitted symlink target with a `#SYMLINK/` marker; a target that could escape the receive root (absolute or containing `..`) is never transmitted (contained/skipped); the receiver strips the marker to restore the real target. See the Phase-4 symlink-trust notes |
|
||||
| `-k`, `--copy-dirlinks` | Transform symlink to dir | ✅ Implemented | A symlink whose referent is a directory is dereferenced and recursed as a real directory; a symlink to a regular file stays a symlink. Sender-side only. See the Phase-4 symlink-trust notes |
|
||||
| `-K`, `--keep-dirlinks` | Treat symlinked dir as dir | ✅ Implemented | On the receiver, an existing destination symlink-to-a-directory is used as that directory (followed) instead of being replaced; it is followed only when it resolves to a directory that stays beneath the receive root. See the Phase-4 symlink-trust notes |
|
||||
|
||||
**Phase-4 symlink-trust notes:** `-l/--links`, `-k/--copy-dirlinks`,
|
||||
`-K/--keep-dirlinks`, and `--munge-links` form the "symlink trust boundaries"
|
||||
row. Making all three new flags have an observable, security-sane effect
|
||||
required transmitting symlink targets, so FastSync's `-l/--links` is now real:
|
||||
a symlink-type entry carries its target on the wire (a new `STATUS_SYMLINK`
|
||||
frame for the per-file path, and a new entry type `2` in the `-s` chunk
|
||||
serializer) and the receiver creates it with `symlinkat` under an `O_NOFOLLOW`
|
||||
parent walk, never following the target. Wire changes: `STATUS_SYMLINK`,
|
||||
the chunk entry type `2`, a per-entry symlink-target string, and two new config
|
||||
booleans that CROSS the wire — `munge_links` and `keep_dirlinks`; `PROTOCOL_VERSION`
|
||||
was bumped **2.12.0 → 2.13.0** (peers must match, exactly as prior phases did).
|
||||
|
||||
**Per-flag semantics and divergences.**
|
||||
- **`-l/--links`** copies a symlink as a symlink: the scanner `readlink`s the
|
||||
target, the sender transmits it, and the receiver `symlinkat`s it. FastSync
|
||||
`-l` never preserved symlink targets before (the flag was documented partial
|
||||
and, in fact, tried to read the referent as file data); it now does, matching
|
||||
rsync. Divergences: because the receiver enforces the symlink containment
|
||||
predicate unconditionally, a plain `-l` sync **refuses to round-trip a
|
||||
legitimate absolute symlink target** (it is dropped, never created pointing
|
||||
outside the root — see the `--munge-links` note for the symmetric trust
|
||||
boundary); a relative in-root target is copied as-is. As of P7 Wave D FastSync
|
||||
also applies the symlink's own metadata with no-follow primitives
|
||||
(`utimensat`/`fchownat`/`fchmodat` with `AT_SYMLINK_NOFOLLOW`), so `-J` is a
|
||||
real omit switch rather than a no-op.
|
||||
- **`-k/--copy-dirlinks`** (sender): a symlink whose referent is a directory is
|
||||
dereferenced and recursed into as a real directory; a symlink to a regular
|
||||
file (or any non-directory) is kept as a symlink. This is rsync's `-k`. When
|
||||
`-L/--copy-links` or `--safe-links`/`--copy-unsafe-links` are active, their
|
||||
(dereference) semantics take precedence, so `-k` is subsumed exactly as in
|
||||
rsync.
|
||||
- **`-K/--keep-dirlinks`** (receiver, crosses the wire): when a directory is to
|
||||
be created (on-demand parent creation for a child write) and the destination
|
||||
path is already an existing symlink that resolves to a directory *within* the
|
||||
receive root, that symlinked directory is used (followed) instead of being
|
||||
replaced by a real directory; new entries are written beneath it. The follow
|
||||
is confined: it only happens where `realpath` of the symlink resolves to a
|
||||
still-within-root real directory, so a malicious link pointing outside the
|
||||
root is never followed. Scope: `-K` acts on the write path (parent/`mkdir`
|
||||
creation); the delete walker still never follows symlinks (a documented
|
||||
divergence for `--delete` over an existing symlinked dir). Without `-K` the
|
||||
destination symlink is not followed (the O_NOFOLLOW walk fails the write),
|
||||
which is the safe default.
|
||||
- **`--munge-links`** (sender security rewrite; crosses the wire so the receiver
|
||||
unmunges): every transmitted symlink target is prefixed with the marker
|
||||
`#SYMLINK/`; the receiver strips the marker (only when the negotiated
|
||||
`munge_links` policy is on — a plain `-l` run never strips the prefix, so a
|
||||
source symlink that genuinely begins with `#SYMLINK/` round-trips verbatim)
|
||||
and restores the exact real target. The trust boundary is **symmetric and
|
||||
enforced receiver-side**, independent of the sender: `file_symlink_at_secure`
|
||||
refuses any target that `file_symlink_target_contained` rejects (absolute
|
||||
`/...` or relative with a `..` component), and `file_save_to_disk_full`
|
||||
contains such an entry (skipped) rather than materializing it. A deliberate confinement trade-off: because the receiver
|
||||
enforces containment unconditionally, a plain `-l` (no `--munge-links`) sync
|
||||
*refuses to round-trip a legitimate absolute symlink target* — such target is
|
||||
dropped, never created pointing outside the root. This is a stricter subset of
|
||||
rsync: rsync stores munged targets on the RECEIVING side and depends on both
|
||||
ends running `--munge-links`; FastSync additionally enforces the containment
|
||||
predicate at the receiver regardless of what the sender transmitted. When no
|
||||
symlink is being transmitted (`-l`/`-k`/`-a` off) `--munge-links` has nothing
|
||||
to rewrite and is inert. -*K/`--keep-dirlinks` policy is installed per
|
||||
connection at config-accept (stable for the whole transfer, never racy under
|
||||
`-j`/`--threads`), and only ever follows an in-root symlink-to-directory.*
|
||||
|
||||
**Compatibility (byte-identical when all three are absent):** `-k`, `-K` and
|
||||
`--munge-links` are opt-in. Without them the scanner's link handling, the wire
|
||||
frames, and the receiver's writes are unchanged for every other option set, so a
|
||||
run that previously worked continues to behave identically. `-l/--links` itself
|
||||
now transmits targets (the prior behavior was broken/partial); its status moved
|
||||
`⚠️ Partial → ✅ Implemented`.
|
||||
|
||||
## 10. Sparse & Device
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `-S`, `--sparse` | Sparse block handling | ✅ Implemented | Phase 7 Wave B: real hole preservation with no wire change. The receiver's sparse-aware writer (`write_all_sparse`, next to `write_all` in `src/shared/file.c` and `src/shared/file_store.c`) walks the in-memory file image and emits any all-zero run ≥ 4096 bytes as a hole via `lseek(SEEK_CUR)` (the pre-size `ftruncate` guarantees the offset bookkeeping and logical size), `ftruncate(size)` after the last run pins the final size even with a hole tail. Wired into both the atomic temp+rename store and `--inplace` when `sparse` is set; the non-sparse path is byte-identical to before. **Sparse wins over `--preallocate`** (posix_fallocate is skipped when sparse is set, so the holes are not re-allocated). Interplay note: under `--partial` a retained sparse temp already has the full logical size (trailing content is holes), so `--append`'s "shorter destination" resume does not re-run; the retained file is still valid and a normal re-transfer (or `-W`/delta) repairs it — documented so the combination is never surprising |
|
||||
| `--preallocate` | Allocate dest files before writing | ✅ Implemented | The receiver preallocates the destination file's full expected space before any data is written, so a transfer that would overflow disk fails fast at allocation time (a clean error, not a half-written file) and the file is laid out contiguously, avoiding fragmentation. Crosses the wire (the config frame carries a `preallocate` boolean; `PROTOCOL_VERSION` bumped **2.10.0 → 2.11.0**, peers must match) so the sender knows the receiver will preallocate and the receiver performs it. **Allocation approach:** `posix_fallocate()` is preferred because it reserves *real* disk blocks (true fail-fast on ENOSPC), falling back to plain `ftruncate()` only when the filesystem reports the allocation is unsupported (`EOPNOTSUPP`/`ENOSYS`); `ftruncate` still extends the logical size so the intent degrades gracefully. **Fallback/error semantics:** `EOPNOTSUPP`/`ENOSYS` → clean fallback to `ftruncate` (best-effort, preallocates the logical size and never fails a transfer on filesystems that lack `posix_fallocate`); a genuine allocation failure (`ENOSPC`/`EDQUOT`/`EFBIG`/…) aborts the file/receive with a distinct `preallocate failed ... transfer aborted` error — it does **not** fall back to a normal non-preallocated write, preserving the fail-fast purpose. **Size-known requirement:** preallocation only runs when the final size is already known up front (the normal regular-file case); unknown-length data is skipped (never failed). **Orthogonality:** applies uniformly across the atomic temp+rename store path, `--inplace`, `--partial`/`--partial-dir`, `--delay-updates` (the staged temp file is preallocated before data flows) and the `--link-dest` copy fallback; it neither implies nor conflicts with `-s`, `--append`, or delta. rsync-divergence: rsync signals that `--preallocate` is ignored with `--sparse`; FastSync gives **sparse precedence** — when both are set, `posix_fallocate` is skipped so the holes the sparse writer creates are not re-allocated (the `ftruncate` presize sizing stays), matching the intent of "sparse wins". See the Phase-4 preallocate notes below |
|
||||
|
||||
**Preallocate notes (Phase 4, preallocate wave):** `--preallocate` is implemented as a real receiver-side allocation of the destination file's space before data is written. It is a plain boolean config flag that crosses the wire (serialized in the config frame's selection-options block, mirroring `--inplace`/`--append`/`--force`), so the run requires matching ends: `PROTOCOL_VERSION` was bumped **2.10.0 → 2.11.0** (peers must match or the version check fails). The allocation is performed on the exact destination fd, immediately after it is opened, before any bytes are streamed; `posix_fallocate` (and the `ftruncate` fallback) leave the fd's file offset untouched, so the subsequent data write at offset 0 is unaffected and complete. Because FastSync writes each file's byte payload in one in-memory batch, the "full expected size" is exactly the known `data_size`, which is what gets preallocated. Unknown-length/streamed payloads are skipped rather than failed. A failed allocation logs a distinct `preallocate failed` error and aborts the file (the atomic temp is unlinked, the inplace target is left untrimmed) so the run fails cleanly and never silently degrades to a non-preallocated write — preserving rsync's fail-fast intent on a full disk.
|
||||
|
||||
|
||||
## 11. Checksum & Comparison
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `--checksum` | Skip based on checksum | ✅ Implemented | With `--incremental`, compares per-file whole-file content digests to skip unchanged files. The digest algorithm is `xxh64` with seed 0 by default and is selectable via `--checksum-choice`/`--cc` (xxh64/xxhash or md5) and `--checksum-seed=NUM` (see those rows); `-c` remains compression |
|
||||
| `--checksum-choice=STR`, `--cc=STR` | Choose checksum algorithm | ✅ Implemented | Real algorithm selection for the per-file whole-file digest used by the `--incremental`/`--checksum` handshake and by the basis-dir content verification. FastSync genuinely supports `xxh64` (the default, exact xxHash64, seeded by `--checksum-seed`) and `md5` (via OpenSSL EVP); `xxhash` is accepted as rsync's spelling of xxHash64. Any other name (md4/sha1/sha256/crc32/none/…) is rejected with a clear error at parse time — never a silent no-op. `--cc` is the alias (`--cc=ALG` and space forms both parse). The algorithm id and seed cross the wire with the config frame, so the receiver hashes its on-disk old file with the SAME algorithm+seed the sender used and both agree on a match; the sender's digest and the receiver's comparison live in the per-file `STATUS_CHECK` handshake, which now carries a length-prefixed, bounded (1..16 byte) digest instead of a fixed 64-bit value, and the receiver pins the received length to the negotiated algorithm's digest length (defense-in-depth: a mismatched/malicious length only forces a safe re-transfer). Note: `md5` is a FIPS-non-approved algorithm, so under an OpenSSL build with FIPS mode enabled `--checksum-choice=md5` fails loudly rather than silently falling back. Protocol/layout: `PROTOCOL_VERSION` bumped **2.9.0 → 2.10.0** (peers must match). Defaults preserve the pre-existing behavior byte-for-byte (xxh64, seed 0). Like rsync, the choice only takes effect where a whole-file digest is actually computed (`--checksum` on, or a basis-dir flag); it does not itself enable `--checksum`. Closely-related divergence: the delta BLOCK strong checksum (§11 delta) stays xxHash32 — `--checksum-choice` selects only the whole-file digest, matching rsync where the per-block checksum is independent of the whole-file checksum choice |
|
||||
| `--compare-dest=DIR` | Compare dest files relative to DIR | ✅ Implemented | DIR is a receiver-side basis relative to the destination root (confined below it; absolute/`..`/`.` rejected, `//` collapsed and trailing `/` dropped). On the receiver's per-file check (implies `--incremental`) an exact match = same size + mtime (unless `--size-only`; `-I` disables matching) **and** equal xxHash64 of the sender's file; a match suppresses the data transfer. compare-dest never copies: it only skips a file the destination does **not** already hold (sparse destination, rsync parity), and is consulted before the normal delta/full paths. Repeatable; searched in command-line order, first match wins. Divergences: when the destination already holds a *different* version rsync deletes it but FastSync instead transfers the data (keeps the mirror complete; never deletes without `--delete`); attribute-only differences on a match are not re-applied (data is skipped so the sender never sends metadata); content is verified by xxHash64, stricter than rsync's default quick check. Sizing: FastSync's whole-file payload limit is 256 MiB on **every** transfer path (not basis-specific); rsync applies basis dirs to arbitrary sizes, so FastSync refuses a basis run whose source contains a larger file up front with a clear error before any transfer. Wire: a basis-count field is always present on the config frame (protocol 2.9.0, so clients and servers must both be 2.9.0) |
|
||||
| `--copy-dest=DIR` | Include copies of unchanged files | ✅ Implemented | Same basis rules as `--compare-dest`, but an exact match materializes a **local copy** of the DIR file into the destination (via the normal atomic temp+rename store path, so `--existing`/`--ignore-existing`/`--update`/`--backup`/`--delay-updates` all still apply) instead of transferring data. Repeatable; command-line order = priority. Content is xxHash64-verified before the copy. Divergences: a basis-hit destination keeps the basis file's own mode/uid/gid and mtime (the sender sends no metadata on a skip), so with `--size-only` its mtime can differ from the source and attribute-only differences are copied with the basis attributes rather than rsync's "copy + fix attributes". Requires `--incremental` (implied); incompatible with `-s`. Wire: protocol 2.9.0 |
|
||||
| `--link-dest=DIR` | Hardlink to files when unchanged | ✅ Implemented | Same basis rules as `--copy-dest`, but an exact match installs an atomic **hard link** to the DIR file (temp hard link + rename) so no data or disk space is used; where the link is impossible (basis on another filesystem, filesystem refuses links) it falls back cleanly to a byte-identical local copy, never a corrupt/partial file. `--delay-updates` stages the link and publishes by rename, so the final entry stays a real hard link. Repeatable (searched in command-line order, first match wins). Content is xxHash64-verified before linking. Divergences and caveats: an already up-to-date destination file is not re-linked to a basis file (only files that would otherwise be written are linked); a link keeps the basis inode's own mode/uid/gid and mtime — metadata is never written through the shared inode (that would mutate the basis file), so a later `--inplace` run that rewrites such a destination path **will mutate the basis snapshot** through the shared inode (use `--copy-dest` when the destination must stay independently writable); with `--size-only` the linked mtime can differ from the source; a `--remove-source-files` source satisfied by a basis dir is treated as skipped and therefore **retained** (never removed); basis dirs are excluded from `--delete`. Requires `--incremental` (implied); incompatible with `-s`. Wire: protocol 2.9.0 |
|
||||
| `-y`, `--fuzzy`, `--no-fuzzy` | Find similar file for basis | ✅ Implemented | `-y/--fuzzy` is a pure bandwidth optimization on the existing receiver-driven delta path: when a file must be transferred and the destination holds no usable content at the exact path (file absent, or the destination file is outside the delta engine's size bounds), the receiver searches the SAME destination directory for an existing regular file whose basename is similar to the incoming name and uses it as the delta basis, so the sender transmits only the differences instead of the whole file. The output is always byte-exact regardless of which (or whether any) basis is chosen. Decision location: the receiver performs the candidate search inside `receive_incremental_check` and sends the normal `STATUS_DELTA_SIGNATURE`; the sender never learns the basis was a different file, so no new frame type or sender logic was needed — only the config frame grew a `fuzzy` boolean, so `PROTOCOL_VERSION` was bumped **2.8.0 → 2.9.0** (peers must match). Similarity heuristic (deterministic, simpler than rsync's deliberately-fuzzy matching, and documented precisely): candidates are the target's sibling entries in its destination directory, opened `O_NOFOLLOW`/`AT_SYMLINK_NOFOLLOW` under the confined root (symlinks never followed; nothing outside the destination root is ever read or hashed); dotfiles, directories, the target's own name, and the `.fastsync-stage`/temp scratch names are excluded; like the ordinary delta path, the block signature the receiver transmits is derived from on-disk content it may not otherwise send, so a negotiated `--fuzzy` run exposes the destination's sibling files (at block granularity) to the sender as a known-plaintext oracle — the same information class as the normal delta handshake over the file being replaced; the size gate is the delta engine's own bounds (both files ≥ 16 KiB, ≤ `--delta-max`, ratio ≤ 10×) rather than rsync's ~1.5× size window; the name gate is a Levenshtein edit distance between the basenames accepted only when ≤ half the length of the longer basename; the single best candidate (smallest distance, tie-break size closest to the incoming file then lexicographically smaller basename) is read; the directory scan is capped at 4096 entries so a pathological directory cannot stall a transfer. When fuzzy applies: only to files the receiver would otherwise send whole — the destination's own file is always preferred as the delta basis when it exists and fits the delta size bounds, so fuzzy does NOT replace an existing-but-different destination basis; FastSync's 10× delta size-ratio bound means an existing destination file that is too far away in size still lets the fuzzy search run. When no similar candidate exists the transfer falls back to the normal whole-file transfer. rsync-divergence note: rsync's own matching uses a fuzzy name/size rule set; FastSync implements the closest safe deterministic approximation above. Because FastSync's delta machinery is off by default (rsync's is on), `--fuzzy` implies `--incremental` + `--delta` (unless `--whole-file`/`-W` or an explicit `--no-delta` switched delta off, in which case fuzzy is inert — matching rsync where `--whole-file` makes fuzzy irrelevant). Unlike the basis-dir options, `--fuzzy` honors an explicit `--no-incremental` (it does not force the handshake back on); an explicit `--no-incremental` also suppresses the delta implication so no invalid `--delta requires --incremental` config results. `--no-fuzzy` negates it. All surrounding semantics are untouched: a fuzzy-reconstructed file is stored as a normal file, so `--remove-source-files`, itemize/`-i`, `--stats`, `--backup`, `--delay-updates`, `--existing`/`--ignore-existing`/`--update` behave exactly as for a whole-file transfer (the fuzzy delta does not skip the file) |
|
||||
|
||||
## 12. Compression
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `-z`, `--compress` | Compress file data | ✅ Implemented | Always uses zstd (rsync supports multiple algorithms — a documented divergence, selectable via `--compress-choice`). Phase 7 Wave A: `-z` is now the compression short form; `-c` is rsync's `--checksum` |
|
||||
| `--compress-choice=STR`, `--zc=STR` | Choose compression algorithm | ✅ Implemented | FastSync supports `zstd` and `none` |
|
||||
| `--compress-level=NUM`, `--zl=NUM` | Set compression level | ✅ Implemented | 1-22, default 5 |
|
||||
| `--compress-threads=NUM` | Set compression threads | ✅ Implemented | `compression_threads` config field (client-only; does not cross the wire). Sets the number of worker threads used by the zstd compression pool to NUM (1..64; 0/garbage/oversized rejected up front). Accepted in both `--compress-threads=NUM` and two-argument `--compress-threads NUM` forms. Composes with `-z`/compression; under the `-j`/`--threads` multithreaded pipeline it parallelizes compressed chunk encoding. See test_tcp.py `-z --compress-threads=2` and test_client_cli.c |
|
||||
| `--skip-compress=LIST` | Skip compress for suffixes | ✅ Implemented | Comma-separated, case-insensitive suffix list; empty list skips none; incompatible with FastSync chunk serialization (`-s`) |
|
||||
|
||||
## 13. Connectivity
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `-e`, `--rsh=COMMAND` | Remote shell to use | ✅ Implemented | `-e`/`--rsh` (and `--rsh=COMMAND`) select the remote-shell program used to build the SSH child argv, overriding the default `ssh`. The command is whitespace-split into the leading argv words so rsync's `-e "ssh -p 2222"` works; the standard `-o` family, an optional `-p` port, `user@host` and the quoted remote command (`fastsync-server --stdio`) follow. Stored in the `rsh_command` config field. **Client-only, never crosses the wire** (it is a launch concern, not a handshake property) |
|
||||
| `--rsync-path=PROGRAM` | rsync binary on remote | ✅ Implemented | Alias for `--fastsync-server-path`: both write the `fastsync_server_path` config field used as the remote-side server program (always quoted as one remote-shell word), which CROSSES the wire as before. Kept separate from `--rsh`, which names the local connecting program |
|
||||
| `--port=PORT`, `--port PORT` | Alternate daemon port | ✅ Implemented | rsync's daemon-port flag is an alias for `--server-port`: both spellings (and `--server-port=PORT`) map to the client-side `server_port` config field. The client connects to a TCP/TLS server (incl. `host::module/path` daemon destinations) on that port, and the `fastsync-server --daemon` listener's port is taken from its config's `port` key (default 873) or overridden by `--dparam port=` / `-p` |
|
||||
| `--sockopts=OPTIONS` | Custom TCP options | ✅ Implemented | Comma-separated allowlist of `OPT=VAL` applied via `setsockopt` after `socket()` before `connect()`/`bind()`. Only `TCP_NODELAY`, `SO_KEEPALIVE`, `SO_REUSEADDR` (0/1) and `SO_RCVBUF`/`SO_SNDBUF` (byte count) are accepted; an unknown option name or a bad value is rejected up front, never silently ignored. A value is required for every option (`OPT=VAL`; a bare name is an error). Applied to the outgoing TCP and TLS client socket; absent by default. `SockOptEntry`/`sockopts` config fields. Local socket concern: never crosses the wire |
|
||||
| `--blocking-io` | Use blocking I/O for remote shell | ✅ Implemented | With `--blocking-io` the SSH-transport socketpair socket is left without `SO_RCVTIMEO`/`SO_SNDTIMEO`, so the transfer blocks naturally; by default it gets the same read/write timeout as the TCP transport (see `--timeout`). `blocking_io` config bool. **Client-only, never crosses the wire** |
|
||||
| `--outbuf=N\|L\|B` | Set output buffering | ✅ Implemented | `N` (none/unbuffered) → `_IONBF`, `L` (line) → `_IOLBF`, `B` (block, the default) → `_IOFBF` via `setvbuf` on stdout and stderr. Garbage values are rejected. `outbuf` config field (`OutbufMode`). **Client-only, never crosses the wire** |
|
||||
| `--address=ADDRESS` | Bind address for outgoing socket | ✅ Implemented | Binds the outgoing client socket to a local source address before `connect()` (resolved with the same `-4`/`-6` family hints as the destination). Local socket concern: never crosses the wire |
|
||||
| `-4`, `--ipv4` | Prefer IPv4 | ✅ Implemented | Forces `AF_INET` in the `getaddrinfo` hints for client destination/source resolution and the server bind (see the Phase 5, Wave B note). Mutually exclusive with `-6` |
|
||||
| `-6`, `--ipv6` | Prefer IPv6 | ✅ Implemented | Forces `AF_INET6` in the `getaddrinfo` hints for client destination/source resolution and the server bind. Mutually exclusive with `-4` |
|
||||
| `--remote-option=OPT`, `-M` | Send an option only to the remote side | ✅ Implemented | Each value is appended to the remote server invocation over SSH as an individually single-quote-escaped shell word in `ssh_build_remote_command()`. Values are validated (non-empty, no control characters) and shell metacharacters cannot break out of the quoting (`;`, `&`, `|`, <code>`</code>, `$`, `(`, `)`, quotes are neutralized), so a value cannot inject an arbitrary remote command and a subsequent `--` on the client line cannot be turned into one. The options never cross the binary config frame. Phase 7 Wave A: the short `-M` form is now available (as `-M OPT` and `-M=OPT`), matching rsync; metadata mode moved to long-only `--preserve` |
|
||||
|
||||
## 14. Daemon Mode
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `--daemon` | Run as rsync daemon | ✅ Implemented | Wave A: a real persistent listener. `fastsync-server --daemon --config FILE` (plus `--no-detach` to stay foreground; without it the listener detaches to the background after binding) reads a FastSync-native module config file and serves each connection confined to the requested module's `path` root (never a client-chosen root; every client-chosen-ownership/super-user request (`--numeric-ids`/`--chown`/`--usermap`/`--groupmap`/`--fake-super`/`--copy-as`/explicit `--super`) is refused unless the module opts in with `client owner = yes`, and the operator `--no-super` veto is honored). TCP/TLS via the existing `--tls` stack; plaintext still requires `--allow-unauthenticated` (same secure default as the standalone server). Client destinations use rsync's `host::module/path` form. Wire/protocol: the config frame gained a trailing daemon-module string and `PROTOCOL_VERSION` was bumped **2.14.0 → 2.15.0** (see the Daemon Mode notes below). Daemon mode is built in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding |
|
||||
| `--config=FILE` | Alternate rsyncd.conf file | ✅ Implemented | Wave A: selects the daemon config file. Default when omitted (in `--daemon` mode): `~/.config/fastsync/fastsyncd.conf` if it exists, else `/etc/fastsyncd.conf`. The grammar is FastSync-native (documented in the Daemon Mode notes below) and strictly rejects unknown keys so a typo can never silently change what a module serves; requires `--daemon` |
|
||||
| `--dparam=OVERRIDE` | Override global daemon config | ✅ Implemented | Wave A: overrides one global scalar from the command line (`--dparam port=8734` and `--dparam=KEY=VALUE` both work). Limited to the global keys the grammar defines (`port`, `motd file`, `address`, `max connections`, `max connections per host`, `auth failure delay`, `auth lockout threshold`, `auth lockout duration`, `hosts allow`, `hosts deny`); keys are case-insensitive and unknown keys/invalid values are rejected. Requires `--daemon` |
|
||||
| `--no-detach` | Don't detach from parent | ✅ Implemented | Wave A: with `--daemon`, keeps the listener in the foreground (what integration tests use). Without it the daemonizes (fork/setsid, stdio redirected to /dev/null) after the listening socket is bound. Requires `--daemon` |
|
||||
| `--password-file=FILE` | Read daemon password from file | ✅ Implemented | A7 daemon auth. Client: `--password-file` supplies `user:password` for a `host::module/path` destination (the username is taken from this file, so `user@host::module` stays rejected); the literal password is held client-side only for the SCRAM handshake and wiped at teardown. Server (`fastsync-server --daemon --password-file FILE`): the salted-PBKDF2 verifier store that modules with `auth users` are verified against. **Neither the password nor any replayable bearer value crosses the wire or is stored server-side** — the store holds a per-user salt plus derived keys, and the daemon proves the secret with a per-connection nonce challenge. The file must be private to its owner: both the client and server verify the exact inode they read (open-then-`fstat`, so the check cannot be raced) and refuse a `--password-file`/`--early-input` that is not owned by the current user or grants any group/other permission bit (mode 0600), mirroring the TLS private-key check. A process-substitution pipe (`--early-input <(vault ...)`) is still accepted when it satisfies those checks. See the Daemon Mode notes below for the file formats and the plaintext/TLS caveat |
|
||||
| `--early-input=FILE` | Use FILE for daemon early exec | ✅ Implemented | Server-only (requires `--daemon`): a second credential-store file, same new-format grammar as `--password-file`, read before the listener accepts connections (a secrets-manager / process-substitution source). Its entries layer over `--password-file`: byte-identical verifiers dedupe, a conflicting verifier for the same user is a startup error. A daemon whose modules declare `auth users` must be given at least one of the two, or it refuses to start (fail closed) |
|
||||
| `--hash-credentials=FILE`, `--iterations N` | Hash a plaintext credential file | ✅ Implemented | Server-only offline tool (A7): reads the `user:password` lines of FILE (same owner-only 0600 check) and prints one new-format store line per entry to stdout, then exits. `--iterations` sets the PBKDF2 work factor (default 600000, range 100000–10000000). Dependency-free and does not run a listener. Use its output as `--password-file` for `--daemon`. There is no auto-upgrade: a legacy store line is hard-rejected by the loader and must be regenerated |
|
||||
|
||||
**Daemon Mode notes (Wave A protocol 2.15.0; A7 auth protocol 2.19.0; MOTD no bump):** FastSync daemon mode is supported in FastSync's own protocol/config grammar, not rsync's SMB/daemon option encoding.
|
||||
|
||||
- **Config grammar** (`fastsyncd.conf`): line-based; an implicit global section first, then `[module]` sections. Keys are case-insensitive, values are trimmed and may be wrapped in one layer of double quotes (`path = "/srv/my dir"`). `#` and `;` at the start of a line (after leading whitespace) are full-line comments; inline comments and `\` continuations are not supported. Lines are bounded (4096 chars), and at most 256 `[module]` sections are accepted. Global keys: `port` (default 873), `motd file` (the daemon sends its bounded, escaped content to a client after the module gate/auth accepts, unless the client passes `--no-motd`), `address` (optional bind address), `max connections` (positive integer cap on concurrent connections, default 100; 0/negative/garbage is a parse error), `max connections per host` (concurrent-connection cap per source IP, default 0 = unlimited), `auth failure delay` (milliseconds to sleep after a failed authentication, default 500; 0 disables, capped at 5000), `auth lockout threshold` (failed authentications from one source before lockout, default 10; 0 disables), `auth lockout duration` (seconds a locked-out source is refused, default 300), `hosts allow` and `hosts deny` (comma- and/or whitespace-separated host access patterns — see the host access control note below). Module keys: `path` (required; the daemon-side authorized root for that module), `read only` (yes/no/true/false/1/0, default no), `client owner` (yes/no/true/false/1/0, default no; opts the module into client-chosen ownership — see below), `auth users` (comma list), `max connections` (optional per-module cap, 0 = unlimited; enforced across all connection children), `hosts allow`/`hosts deny` (per-module host access lists). **Unknown keys and malformed lines are parse-and-reject errors** (never silently ignored), so a typo cannot change what a module serves.
|
||||
- **Host access control (`hosts allow`/`hosts deny`):** both keys accept a comma- and/or whitespace-separated list of patterns and may appear globally and/or per module (multiple config-file lines append; a `--dparam` override replaces). Supported patterns are `*` (match all), an IPv4 or IPv6 literal (`10.0.0.1`, `2001:db8::1`), and an IPv4/IPv6 CIDR (`10.0.0.0/8`, `2001:db8::/32`). Hostname patterns are **not** supported: because the peer is always a numeric address and no reverse DNS is performed, a hostname/glob pattern would silently never match, so it is rejected at load time (fail-closed) instead of being accepted as a dead rule. An IPv4 peer on a dual-stack IPv6 listener is normalized from its `::ffff:a.b.c.d` form so IPv4 patterns match it. rsync-like semantics: a matching `hosts deny` rejects; if any `hosts allow` entries exist, a peer matching none of them is rejected; deny takes precedence over allow. The daemon enforces the global list first, then the selected module's list, **before authentication** in `server_module_gate`, with an audit log line naming the peer, the module and the outcome. The numeric peer address is obtained with `getpeername`+`inet_ntop` (`utils_fd_peer_ip`, handling both address families); when it cannot be obtained a module with any ACL fails closed (refused), while an ACL-free module continues and logs at debug. A malformed pattern (e.g. an out-of-range CIDR prefix) is a parse error at load time.
|
||||
- **Connection caps, shared registry and auth lockout:** the global `max connections` key (default 100) is plumbed into the listener (`transport_tcp.c`), which rejects a connection once the accept-loop parent's active-child count reaches it; the IPv4/IPv6 peer is logged for every accepted connection. Because the listener forks one child per connection, the per-module `max connections` cap, the global `max connections per host` cap, and the auth-failure counter live in a fixed-size registry carved from an anonymous shared mapping (`daemon_limits.c`, `mmap(MAP_SHARED|MAP_ANONYMOUS)`) created by the parent before the accept loop, so every forked child shares the same counters (C11 atomics only — never a pthread lock, which can deadlock in a forked child). The parent reserves a registry slot per accepted connection and the child records the selected module and source IP once known; the parent's `SIGCHLD` handler reclaims the slot when the child dies (including `SIGKILL`) and re-derives the per-module and per-source occupancy counts from the surviving REGISTERED slots, so a child killed mid-registration cannot leak a count. The per-source table has a bounded lifetime: an entry with no live connection is reclaimed after its lockout expires or it has been idle (300 s); if the table is genuinely full the per-source cap/lockout fails open for new sources (per-module cap and ACLs still apply) with a rate-limited warning. The per-module cap (0 = unlimited) is enforced after the module lookup and before auth; per-source identity reuses the normalized numeric peer address (`utils_fd_peer_ip`, IPv4-mapped IPv6 collapsed to IPv4), and a trusted loopback peer (127.0.0.0/8 / `::1`, `utils_fd_peer_is_local`) is exempt from the per-source cap and the auth lockout because all local clients share one address (the per-module/global caps still apply). Clients behind a shared NAT/proxy address likewise share one per-source budget and lockout counter. A failed authentication increments the shared per-source failure count and, once `auth lockout threshold` (default 10; 0 disables) is reached, the source is refused for `auth lockout duration` seconds (default 300) before any challenge is sent, even when the next attempt is handled by a different forked child; a successful authentication clears the counter. On a failed authentication the per-connection child still sleeps the global `auth failure delay` (default 500 ms, 0 disables, capped at 5000) via `nanosleep`, rate-limiting online guessing without delaying a success. A missing registry (allocation failure) degrades to the global cap and host ACLs rather than refusing to start.
|
||||
- **Module selection & confinement:** the client requests a module with an rsync-style `host::module[/path]` destination. The module name crosses the wire as a trailing string on the config frame (bumping `PROTOCOL_VERSION` 2.14.0 → 2.15.0; the bump is required because the config-frame layout changed and the strict same-version handshake is what prevents a peer from desynchronizing on the new trailing field). The daemon looks the module up in ITS OWN config and uses the module's `path` as the authorized root through the exact same `configure_authorization` confinement the standalone server applies to `--destination-root` (`file_open_secure_parent`, `has_path_traversal`, `path_is_within`); the client never supplies the root, every client-chosen-ownership/super-user request is refused unless the module declares `client owner = yes` (the daemon's per-module opt-in, see below), and the operator `--no-super` veto forces super-user activities off for every daemon connection. The client's `/path` part is relative inside the module and is rejected if absolute or if it contains `..`. Unknown modules are refused before any data moves (the run fails cleanly at the config handshake). An absolute destination and a module request against a non-daemon server are also refused.
|
||||
- **`client owner` (client-chosen-ownership opt-in):** by default a daemon module refuses every request that would let the client pick an owner or ask for super-user activities — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and an explicit `--super` — at the config handshake (before `STATUS_OK`), because a daemon has no per-module opt-in for client-chosen ownership and any anonymous client could otherwise force arbitrary owner ids inside the module root. `client owner = yes` opts a single module in, allowing those requests within that module's root (a root standalone TCP listener honors them for its single operator-authorized root only when started with `--allow-super`; the flag is rejected with `--stdio`, whose client-composed remote argv must never opt back into super mode). Without the opt-in the daemon also forces super-user **device** activity off for that connection — char/block device-node creation (`--devices`) and `--write-devices` — even under the default `AUTO` mode, so a non-opted module can never be made to `mknod` or write a raw device; those entries are skipped (not refused) so an ordinary `-a` push still succeeds without device nodes. The opt-in does **not** lift the privilege requirement: `--copy-as` still needs a root receiver, and the operator `--no-super` veto still forces super-user activities off for every connection. The daemon logs a prominent startup warning for each `client owner = yes` module so the operator's deliberate choice is visible.
|
||||
- **`read only` safe default:** every network transfer FastSync currently supports is a push that writes under the module root, so a `read only` module refuses the connection (clear server log "module is read only"; the client exits non-zero, nothing is transferred). A future pull/list operation can be opened up when it exists; the knob is already stored.
|
||||
- **Direction — remote source / pull is intentionally unsupported:** FastSync is push-only. The first positional argument is always a **local** source directory and the second is the destination; only the destination is parsed for remote syntax (`user@host:path` SSH, `host::module[/path]` daemon). A remote source such as `fastsync user@host:src ./local` is deliberately **not** implemented: rsync has no pull flag (direction is positional), so supporting a remote source is an optional feature rather than a compatibility requirement, and it would require a protocol role reversal (server as sender, client as receiver) across both transports. FastSync documents this as an intentional limitation rather than a missing rsync option. <a id="direction"></a>
|
||||
- **`auth users` (A7 SCRAM-SHA-256 authentication):** a module that declares `auth users` requires the client to present credentials. The config frame carries ONLY the username; the daemon answers an auth-required module with `STATUS_AUTH_CHALLENGE` (PBKDF2 iteration count, 16-byte salt, 32-byte server nonce), the client answers with `STATUS_AUTH_RESPONSE` (fresh 32-byte client nonce + a 32-byte ClientProof), and the daemon accepts only when the proof verifies **and** the username is **on the module's `auth users` list** and has a store entry, replying `STATUS_AUTH_OK` with a 32-byte ServerSignature the client verifies before proceeding. Verification is constant-time over fixed 32-byte keys (the compare runs even for a miss), username membership uses a constant-time full-length scan, and an unknown/off-list user still receives a challenge and runs the same math against a dummy verifier: a deterministic per-username salt (`HMAC-SHA256(store dummy key, username)`), the store-wide uniform iteration count and dummy keys. Re-probing the same unknown username therefore yields an identical salt and iteration count while a different username yields a different salt, so there is no user-enumeration or timing oracle. The daemon logs the username but **never the password, proof or keys**. A module WITHOUT `auth users` stays open (legitimate rsync configuration); credentials sent to such a module are ignored. Read-only is orthogonal: even a correctly authenticated push to a `read only` module is still refused (all FastSync network transfers write). Fail-closed policy: a daemon whose config declares `auth users` on any module refuses to start unless a credential store was given (`--password-file` and/or `--early-input`); a missing or empty store is never silently treated as "open". A failed handshake (missing credentials, unknown/off-list user, wrong proof or malformed data) yields a single generic `STATUS_AUTH_FAILED` and the daemon closes before any data moves. The dummy key is persisted in an owner-only `<store_path>.dummykey` sidecar (auto-created on first load, mode 0600) so the dummy salt stays stable across daemon restarts, closing the restart-gated enumeration channel. The sidecar is secret material and must be protected like the credential store (owner-only 0600, included with the store in backups and rotation). It must be preserved across restarts for that guarantee; if it cannot be created (a process-substitution/FIFO store path such as `/dev/fd/N`, a read-only filesystem, a missing directory, or a create/write/fsync/link/fchmod failure), the daemon logs a warning and uses a transient per-run key, so unknown-user challenges change across restarts and the cross-restart guarantee does not hold for that deployment. One residual is accepted: the store iteration count is observable pre-auth by design, since the miss path must match a hit. **Transport policy (hardening A7-3/S1):** an auth-required module accepts credentials only when either (a) the connection is an encrypted, verified TLS connection whose client certificate matches `--client-cn`, or (b) the connection is plaintext from a loopback TCP peer **and** the operator explicitly passed `--allow-unauthenticated`. A remote plaintext peer, and a loopback plaintext peer without that flag, are refused at the config gate before any challenge is sent; `--allow-unauthenticated` never permits remote plaintext auth (remote peers still require verified TLS). Daemon modules are a `--daemon`-only feature — the SSH `--stdio` path never loads a daemon config and is not an auth transport for them. Because the loopback allowance trusts whichever peer the kernel reports as `127.0.0.1`, it assumes nothing relays remote connections to the daemon: a local TCP forwarder or TLS-terminating proxy in front of an auth-module listener makes remote clients appear as loopback and bypasses the mutual-TLS identity check, so do not front an auth-module listener with such a relay.
|
||||
- **Credential store format:** server `--password-file`/`--early-input` files are line-based `user:$fastsync$1$pbkdf2-sha256$<iters>$<salt_b64>$<stored_key_b64>$<server_key_b64>`, one per line (standard base64; 16-byte salt, 32-byte keys; `iters` in `[100000, 10000000]`, default 600000). Every entry in the resulting store must agree on `iters` (a store whose entries disagree, or where a layered `--early-input` disagrees with `--password-file`, is rejected). Generate lines with `fastsync-server --hash-credentials FILE [--iterations N]`; the emitted lines are secret material, so redirect them to an owner-only (mode 0600) file (the tool warns on stderr if stdout is a group/other-accessible regular file). Blank lines and lines starting with `#`/`;` are comments; the parser is strict (a malformed line fails the whole load, so a typo can never let a different set of users in). **The legacy `user:SHA256HEX` form is hard-rejected** with an actionable "legacy" error; there is no auto-upgrade, so a replayable bearer digest can never be loaded by a 2.19.0 daemon. The client `--password-file` holds `user:password` on its first meaningful line (the literal password, used only for the handshake then burned); keep both files readable only by their owner (mode 0600). Per-username wire length is bounded (256 chars) and every decoded salt/key length is validated. Loading the store also maintains an owner-only `<store_path>.dummykey` sidecar (auto-created, mode 0600, exactly 32 bytes) holding the store-wide dummy key that shapes unknown-user challenges; persist it across daemon restarts so those challenges stay stable, and treat a sidecar with the wrong owner, a mode other than exactly 0600, the wrong size or the wrong type as a fatal load error (fail closed). If the sidecar cannot be created (e.g. a process-substitution store path such as `/dev/fd/N`, a read-only filesystem, a missing directory, or a create/write/fsync/link/fchmod failure), the daemon logs a warning and uses a transient per-run key, so the cross-restart stability guarantee does not hold there.
|
||||
- **Plaintext caveat:** an auth-required module is refused, **before any challenge is sent**, unless the connection is encrypted and verified TLS whose client certificate matches the server's `--client-cn`, or it is plaintext from a loopback TCP peer **and** the operator passed `--allow-unauthenticated`. A remote plaintext peer, and a loopback plaintext peer without that flag, never receive a challenge, and `--allow-unauthenticated` never permits remote plaintext auth (remote peers still require verified TLS). On the loopback plaintext transport that remains permitted, a local sniffer could still read the challenge and response and mount an **offline dictionary attack** against a weak password, so use `--tls` for any real deployment. `--client-cn` matches the certificate CN only (not a subjectAltName), which is acceptable for a private CA. Clients sending daemon credentials with `--password-file` to a non-loopback daemon must use `--tls`; the client rejects such a destination before any network I/O. Unlike the old challenge-less exchange there is **no replay**: the proof is bound to the fresh per-connection server nonce, so a captured `STATUS_AUTH_RESPONSE` cannot be reused on another connection (an integration test proxies the daemon and proves this). TLS client-CN (`--client-cn`) is an independent transport identity check and composes with password auth; because `--tls` already mandates `--client-cn`, a TLS auth connection always verifies the client CN, so both checks necessarily apply together on such a connection.
|
||||
- **Wire/protocol:** the config-frame auth block is now `[int present][str_redacted username]` (the old digest field is gone), and the frame stream gains the challenge/response (`STATUS_AUTH_CHALLENGE` → `STATUS_AUTH_RESPONSE` → `STATUS_AUTH_OK`/`STATUS_AUTH_FAILED`) between the config frame and the `STATUS_OK` ack. Both are wire-layout changes, so `PROTOCOL_VERSION` is bumped **2.18.0 → 2.19.0** (see the A7 note in `src/shared/config.h`); the strict same-version handshake keeps a 2.19 client and a 2.18 server from desynchronizing.
|
||||
- **Client side:** `host::module/path` selects the TCP transport and connects to `--server-port`; `host:path` stays the SSH transport; plain paths stay local TCP. The daemon username comes from `--password-file` (first `user:password` line), and `--password-file` without a `host::module/path` destination is a client error (fail fast). A `user@host::module` form is rejected with a pointer to `--password-file`. The client's plaintext password is wiped from memory (`config_burn_auth`) at transfer teardown.
|
||||
- **MOTD (Wave C):** a daemon configured with a global `motd file` sends that file's content as the first server→client string frame after the config-frame STATUS_OK ack (rsync sends the MOTD as the first thing from the server at the start of a daemon connection). Only the daemon listener path (`host::module`) gets a MOTD; the `--stdio` SSH path never sends or reads one. The server reads the file bounded to 4096 bytes and treats an absent/unreadable file as "no MOTD" (an empty frame, never an error). The exchange is server→client only and does **not** bump `PROTOCOL_VERSION`: every 2.15.0 daemon client reads the frame after the ack, so sender and receiver stay in lockstep (see the Wave C note in `src/shared/config.h`). `--no-motd` is the client-side suppression switch: the client still reads (consumes) the frame to keep the stream in sync but does not display it. The MOTD is printed to stdout with control bytes (ESC included) escaped octal-style while newlines/tabs are preserved, so a hostile server cannot inject terminal escape sequences.
|
||||
- **Merge note:** the Wave A module bump (2.15.0) and the MOTD wave did not bump the version, but the A7 auth redesign is a genuine wire-layout change and owns the 2.18.0 → 2.19.0 bump (see the A7 note in `src/shared/config.h`).
|
||||
|
||||
## 15. Safety & Security
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| Path escape detection | Ensure files stay within root | ✅ Implemented | `has_path_traversal()` + realpath |
|
||||
| Symlink-safe delete | Skip symlinks in delete walk | ✅ Implemented | `delete_extras_walk()` |
|
||||
| Protocol version check | Verify compatible versions | ✅ Implemented | `config_receive()` |
|
||||
| Max data/string/chunk sizes | Prevent OOM attacks | ✅ Implemented | Per-message limits |
|
||||
| Per-connection memory limit | 1GB per connection | ✅ Implemented | `MAX_CONNECTION_MEMORY` |
|
||||
| `--max-alloc=SIZE` | Limit a single memory allocation | ✅ Implemented | Caps the largest single allocation; binary units, default 1G |
|
||||
| `--trust-sender` | Trust remote sender's file list | ✅ Implemented | Long-form-only, receiver-local policy that never crosses the wire. The receiver skips its redundant up-front re-validation of the incoming file list (empty/`..` path rejection and the escaping-symlink-target containment), trusting the sender instead of double-checking (fewer checks, faster, potentially unsafe, matching rsync). Off by default. The low-level fd-relative confinement primitives (`file_open_secure_parent`, the O_NOFOLLOW parent walk, leaf/destination confinement) are deliberately KEPT even under `--trust-sender`, so a hostile sender still cannot write or link outside the authorized root (see Phase-5 notes below) |
|
||||
| `--old-args` | Disable modern arg protection | ✅ Implemented | SSH-only; accepted for CLI compatibility but is now a **documented no-op**: FastSync always single-quote-escapes the remote server path and each `--remote-option` value (`ssh_build_remote_command`), so a metacharacter-bearing `--rsync-path` can never be interpreted by the remote shell. The flag no longer disables that quoting (the old raw-construction behavior was an injection foot-gun and is removed); the safety-relevant behavior is identical either way |
|
||||
| `--ignore-missing-args` | Ignore missing source args | ✅ Implemented | FastSync has a single source-root argument (which always exists), so the "explicitly requested source arguments" are the `--files-from` entries and the flags only ever apply there (inert without `--files-from`, like `-R`). Without the flag a listed-but-missing entry stays a hard pre-transfer error (nothing is transferred). With it each missing entry is skipped: nothing is sent for it, it never enters the keep-set, and the run succeeds for the rest — an all-missing non-empty list succeeds transferring nothing, matching rsync. `--dirs` + `--files-from` missing entries are skipped the same way. Every skipped entry is logged and a per-run warning names the count, so the handling is never a silent no-op. Divergences: an EMPTY `--files-from` file stays a hard error in every mode (no argument was requested at all; rsync likewise reports "no source files specified"); missing-arg skipping only applies to the pre-transfer list validation, so an entry that is present at preflight and vanishes mid-transfer still fails (matching rsync, whose flag "does not affect subsequent vanished-file errors"); `--no-ignore-missing-args` is not a supported negation |
|
||||
| `--delete-missing-args` | Delete missing source args | ✅ Implemented | Implies `--ignore-missing-args` (order-independent) and additionally removes each missing entry's destination mirror receiver-side. The mirror is computed exactly like a present sibling's wire path: the bare relative entry under `-R`, otherwise the full source-mirror path below the destination root. rsync parity, verified against the man page: it does **not** imply `--delete` generally and is "independent of any other type of delete processing" — unrelated destination extras are untouched unless `--delete` is also present. Composition with `--delete` + timing: the exact-path deletions commit with the manifest, early for `--delete-before`/`--delete-during`, else only after a fully-successful transfer (delete-after/commit). A non-empty directory mirror is removed only when `--force` or `--delete` is in effect (otherwise it is left with a warning and the run continues, like rsync); an absent mirror is a no-op. `--force` is deletion authority and is therefore gated by the server `--allow-delete` policy exactly like `--delete`/`--delete-missing-args`: without it the receiver clears the flag, so a client cannot use `--force` to recursively replace or remove a destination directory tree. An explicitly listed missing arg is a user request, not an excluded file: its deletion is never blocked by the filter-exclusion protection of excluded destination mirrors (a mirror sitting inside a filter-excluded directory is still removed). Safety/policy: gated by the server `--allow-delete` policy like `--delete`; the request paths cross the wire only in the delete-manifest frame and are confined by the same receiver validation as the keep-set (non-empty, relative, traversal-free, bounded by the per-section/per-frame manifest caps); the `--delay-updates` staging directory and basis snapshots are protected exactly as in the extras walker. Divergence: the missing-args deletions are not counted toward `--max-delete` (they are explicit per-path requests, not discovered extras). See the Phase-3 wire note below for the `PROTOCOL_VERSION` bump |
|
||||
|
||||
## 16. Batch Operations
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `--write-batch=FILE` | Write batched update to file | ✅ Implemented | Phase-6 residual-batch (client-only): runs the normal live transfer AND additionally emits a self-contained single-file batch of the whole source tree. The batch is a magic/format-version header followed by length-prefixed `chunk_serialize` blobs (full file images), replayable byte-identically by `--read-batch` on another machine with no source/server. `--write-batch` drives the single-threaded transfer path (the multithreaded path consumes the config before the separate batch scan pass). See the Phase-6 batch note below |
|
||||
| `--only-write-batch=FILE` | Write batch without updating dest | ✅ Implemented | Phase-6 residual-batch: emits the self-contained batch FILE only — NO destination update, NO server connection. Requires a source (scans it and serializes the full tree to FILE). Same single-file format as `--write-batch`, so the file is re-appliable via `--read-batch=FILE DEST`. See the Phase-6 batch note below |
|
||||
| `--read-batch=FILE` | Read batched update from file | ✅ Implemented | Phase-6 residual-batch: applies a previously written batch FILE locally to the destination. NO source and NO server — positional args are the destination only. Reads the magic/version header, then length-prefixed records, `chunk_deserialize`, and applies each via the confined `file_save_to_disk_full` path (same O_NOFOLLOW / `..`-rejection / root-confinement as the network receiver, so an attacker-controlled batch cannot escape the destination root). Malformed/truncated/oversized/traversal records are rejected cleanly. See the Phase-6 batch note below |
|
||||
|
||||
## 17. Advanced
|
||||
|
||||
| Flag | Rsync Description | FastSync Status | Notes |
|
||||
|------|-------------------|-----------------|-------|
|
||||
| `--stop-after=MINS` | Stop after N minutes | ✅ Implemented | Client-only sender stop deadline (Phase 6): computing `--stop-after=MINS` (a positive minute count; 0/negative/garbage rejected) and `--stop-at=TIME` (`HH:MM`, `HH:MM:SS`, or `now+N[smhd]`; a past time stops immediately). The transfer stops ELEGANTLY at the next chunk boundary: everything already fully sent is kept and applied, the run returns 0, and --delete (late/delete-after timing) does NOT wipe the destination — when the scan is cut short the partial keep-set manifest is suppressed with a warning (the delete walk is skipped rather than acting on an incomplete keep-set, so unscanned source mirrors survive). `--delete-before`/`--delete-during` still run their complete pre-scan (which ignores the deadline). Local client-only fields: never serialized into the wire config frame, so no PROTOCOL_VERSION bump. `--stop-after` uses CLOCK_MONOTONIC; `--stop-at` uses the wall clock. Works single-threaded and under `-j`/`--threads` (multithreaded). Divergence: rsync computes `--stop-after` from the run start; FastSync likewise. When both are given, the earlier of the two deadlines wins (checked per iteration). See the Phase-6 stop notes below |
|
||||
| `--stop-at=TIME` | Stop at specified time | ✅ Implemented | Same feature as `--stop-after` (deadline transfer stop), absolute wall-clock form (`HH:MM[:SS]` or `now+N[smhd]`). See the row above and the Phase-6 stop notes |
|
||||
| `--fsync` | Fsync every written file before publication | ✅ Implemented | |
|
||||
| `--protocol=NUM` | Force older protocol version | ✅ Implemented | Forces the wire protocol version for this transfer. FastSync has exactly ONE wire format (`PROTOCOL_VERSION`, currently 2.21.0) with no downgrade/backward-compat code paths, so `--protocol=2.21.0` is accepted (it sets the version claim the client sends, which the server already requires to match exactly) and **every other value is rejected up front** with a clear error before any connection — it does not and cannot speak an older or virtual wire format. Divergence from rsync (which negotiates a range and downgrades to an integer 0..31): FastSync's honest contract is force-to-the-one-supported-value; a genuine downgrade would require a per-version compatibility layer that does not exist. Client-only; the server-side exact-match check is unchanged. `--protocol=2.20.0`/`2.19.0`/`2.18.0`/`2.18`/`2.17.0`/`2.16.0`/`2.15.0`/`216`/`31`/garbage are all rejected. See the Phase-6 protocol note below |
|
||||
| `--iconv=CONVERT_SPEC` | Charset conversion | ✅ Implemented | Charset conversion of FILE NAMES (not content) at the protocol boundary via iconv(3): `--iconv=LOCAL[,REMOTE]` — the sender converts each local filename LOCAL→REMOTE before transmitting, and the receiver converts each wire filename REMOTE→LOCAL before creating/writing. The full CONVERT_SPEC is serialized into the config frame as a new trailing string field so the peer knows the wire charset; **PROTOCOL_VERSION bumped 2.15.0 → 2.16.0**. `LOCAL[,REMOTE]` parse: single charset ⇒ LOCAL==REMOTE (identity both ways); garbage rejected up front. Validation probes BOTH directions (a spec that only opens one way is refused, as is a NUL-emitting target charset like utf-16/utf-32/ucs-2, since filenames cannot contain NUL). An unrepresentable name (EILSEQ/EINVAL) fails that path cleanly with a logged `--iconv: cannot convert file name ...` and is never written mangled/truncated. Conversion is applied at EVERY wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest, the incremental-check path, and the `-s`/`chunk_serialize` embedded blob path), on both client and server (`--iconv` is also a server/daemon option). Zero overhead when unset. See the Phase-6 iconv notes below |
|
||||
| `--checksum-seed=NUM` | Set checksum seed | ✅ Implemented | Sets the seed for FastSync's whole-file xxHash64 digest (full 64-bit seed) and for the delta path's per-block xxHash32 strong checksum (low 32 bits of the seed). An explicit seed deterministically changes every computed digest on BOTH endpoints (sender and receiver share the seed via the config frame, protocol 2.10.0), so identical runs with the same seed skip the same files and a changed seed changes the digests — the explicit-seed path that makes xxHash comparisons deterministic. `--checksum-choice=md5` has no seed and ignores it (documented). The value is a strict decimal 0..2⁶⁴-1 (blank, signed, or non-numeric values are rejected). Like rsync, a seed only matters where a digest is actually computed (`--checksum` or a basis-dir run, or a delta transfer); it does not by itself enable `--checksum`/`--delta`. Divergence from rsync: the default is seed 0, and FastSync never randomizes the seed (rsync uses a random per-transfer seed when `--checksum-seed` is unset); FastSync's unset default therefore reproduces its historical byte-for-byte behavior |
|
||||
| `--secluded-args`, `-s` | Use protocol to send args | ⛔ Impossible/Divergence | Accepted for CLI compatibility (including the rsync short `-s`, Phase 7 Wave A) but a documented **no-op / divergence**. rsync's `-s` protects arguments from shell expansion by shipping them over the protocol; FastSync never passes remote arguments through a shell expansion boundary in the first place — its SSH transport builds the remote argv as **single-quote-escaped shell words** (`ssh_build_remote_command`), so the injection/leak that `-s` guards against does not exist and there is nothing to "seclude". Implementing a true arg-send protocol would mean replacing the argv-based SSH launch with an in-band argument channel, a large redesign of the transport that buys no security here. Chunk serialization remains the long-only `--chunk-serialization`. |
|
||||
| `--no-OPTION` | Turn off implied option | ✅ Supported | Supported boolean FastSync options and archive-implied options; unsafe or value-taking options are rejected. |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Difficulty Plan
|
||||
|
||||
**Phase 5 notes (remote-option wave):** `--remote-option=OPT` (long form only) and `--trust-sender` landed here.
|
||||
- `--remote-option` is CLIENT-only and never serialized into the binary config frame. On the SSH transport the client forwards each value to the remote server by appending it to the remote command line in `ssh_build_remote_command()`, after ` --stdio`, as an individually single-quoted shell word (`'...'` with `'\''` for embedded quotes). Values are validated at CLI parse time (non-empty; no ASCII control characters) and rejected otherwise, and a non-conforming value is refused again in the command builder, so shell metacharacters (`;`, `&`, `|`, backticks, `$()`, quotes) can never break out of the quoting to inject an unrelated remote command — including after a client-side `--` separator, whose arguments are never forwarded anyway. Because the remote options affect the *remote server invocation*, not the transmitted config, the wire frame layout is unchanged, but `PROTOCOL_VERSION` was bumped **2.13.0 → 2.14.0** as the Phase-5 lockstep release marker (a 2.14 client against a 2.13 server fails the version check cleanly rather than the old server rejecting an unfamiliar forwarded argv later). Divergence: rsync's short `-M` form of `--remote-option` was intentionally NOT implemented at that time because `-M` was FastSync metadata mode; **Phase 7 Wave A later freed `-M` for `--remote-option` and moved metadata to long-only `--preserve`** (see the Sending Options table).
|
||||
- `--trust-sender` is a receiver-local policy: it never crosses the wire (the sender's value is never serialized, so a wire peer can never enable it). On the receiving process it skips the up-front re-validation of the incoming file list (empty/`..` path rejection and the escaping-symlink-target containment), trusting the sender's list instead of double-checking — fewer checks, faster, and potentially unsafe, matching rsync. It is OFF by default (`config.trust_sender`). As a deliberate safety floor, the low-level fd-relative confinement primitives are NOT disabled: `file_open_secure_parent()` (O_NOFOLLOW walk, `..` rejection, root containment) and leaf/destination confinement still hold, so even under `--trust-sender` a hostile sender cannot write or create a symlink outside the authorized root — the relaxation only removes the redundant list-layer double-checks, never the root-confinement guarantees.
|
||||
|
||||
The estimates below cover the currently unimplemented features in this document. They assume one engineer familiar with the codebase, include implementation and focused tests, and exclude production rollout time. A feature should not be marked implemented until its behavior is tested in both local and SSH/TCP paths where applicable.
|
||||
|
||||
> **Note:** This plan is a superset snapshot written while several of the listed features were still outstanding. The Summary matrix above is the authoritative record of what is already shipped (for example quiet/info/debug output, `--existing`, `--remove-source-files`, `-h`, and `--size-only` are now implemented on `dev`). Treat the phases as sequencing guidance for the work that remains unimplemented.
|
||||
|
||||
| Effort | Typical duration | Meaning |
|
||||
|--------|------------------|---------|
|
||||
| XS | 0.5-1 day | CLI alias or a local formatting/validation change |
|
||||
| S | 1-3 days | Isolated behavior with little or no protocol change |
|
||||
| M | 3-7 days | Cross-cutting client, server, or scanner behavior |
|
||||
| L | 1-3 weeks | Protocol, filesystem, privilege, or compatibility work |
|
||||
| XL | 3+ weeks | New transfer mode, daemon subsystem, or broad interoperability effort |
|
||||
|
||||
### Phase 1: Low-Risk CLI and Local Behavior
|
||||
|
||||
These are the best first changes because they require limited wire-format work and can be tested with existing transfer fixtures.
|
||||
|
||||
| Features | Effort | Implementation plan |
|
||||
|----------|--------|--------------------|
|
||||
| `--quiet`, `-q`; `--human-readable`, `-h`; `--8-bit-output`, `-8`; `--stderr=MODE`; `--info=FLAGS`; `--debug=FLAGS` | S | Extend logging and output formatting without changing transferred data. |
|
||||
| `--no-OPTION`; `--old-args`; `--secluded-args`, `-s` | M | Add option implication/negation and safely serialize or protect remote arguments. `-s` currently has FastSync-specific semantics and needs a compatibility decision. |
|
||||
| `-P`; `--del`; `--old-dirs`, `--old-d`; `--cc`; `--zc`; `--zl` | XS | Add aliases and composed behaviors after the underlying options exist. |
|
||||
| `--whole-file`, `-W`; `--ignore-times`, `-I`; `--size-only`; `--modify-window`, `-@`; `--update`, `-u` | S | Extend the existing incremental comparison decision. |
|
||||
| `--existing`; `--ignore-existing`; `--remove-source-files` | S | Add scanner/receiver eligibility checks and remove successfully synchronized source files. |
|
||||
| `--executability`, `-E`; `--chmod=CHMOD` | M | Apply permission transformations safely while preserving current metadata behavior. |
|
||||
| `--skip-compress=LIST`; `--compress-threads=NUM` | S | Make compression selection configurable and validate the thread setting against zstd behavior. |
|
||||
| `--max-alloc=SIZE`; `--fsync` | S | Reuse existing allocation limits and add an explicit durability step after file writes. |
|
||||
|
||||
### Phase 2: Filesystem Selection and Update Semantics
|
||||
|
||||
These features are moderate because they affect traversal, temporary files, manifests, or the receiver's update policy.
|
||||
|
||||
| Features | Effort | Implementation plan |
|
||||
|----------|--------|--------------------|
|
||||
| `--one-file-system`, `-x` | M | Track the source device during scanner traversal and skip mount-point crossings. |
|
||||
| `--relative`, `-R`; `--no-implied-dirs`; `--dirs`, `-d`; `--mkpath` | M | Extend path-list construction and destination directory creation while preserving traversal safety. |
|
||||
| `--temp-dir`, `-T` | M | Separate temporary-file placement from FastSync's timeout alias and define collision, permissions, and cleanup rules. |
|
||||
| `--delay-updates` | L | Stage all successful updates and publish them at completion, including crash and cancellation cleanup. |
|
||||
| `--files-from=FILE`; `--from0`, `-0`; `--filter=RULE`, `-f`; `-F`; `--cvs-exclude`, `-C` | L | Build a complete filter/parser layer and integrate it with scanner pruning, manifests, and delete behavior. `-f` conflicts with FastSync sendfile mode. |
|
||||
| `--list-only`; `--itemize-changes`, `-i`; `--out-format=FORMAT`; `--log-file-format=FMT` | M | Add a structured change-event model so output modes share one source of truth. |
|
||||
|
||||
### Phase 3: Deletion, Comparison, and Delta Compatibility
|
||||
|
||||
These features require careful interaction with manifests, incremental checks, backups, and the existing delta protocol.
|
||||
|
||||
| Features | Effort | Implementation plan |
|
||||
|----------|--------|--------------------|
|
||||
| `--delete-during`; `--delete-before`; `--delete-after`; `--delete-delay`; `--del` | L | Add deletion timing to the transfer state machine and ensure failures cannot remove files unexpectedly. |
|
||||
| `--delete-excluded`; `--max-delete=NUM`; `--ignore-errors`; `--force`; `--prune-empty-dirs`, `-m` | M | Extend delete walks with policy limits, error handling, empty-directory pruning, and the `-m` short-flag conflict. |
|
||||
| `--ignore-missing-args`; `--delete-missing-args` | M | Distinguish missing source arguments from traversal errors and apply explicit deletion policy. |
|
||||
| `--compare-dest=DIR`; `--copy-dest=DIR`; `--link-dest=DIR` | L | Add alternate basis roots and hard-link handling, including metadata and cross-filesystem failures. |
|
||||
| `--fuzzy`, `-y`; `--no-fuzzy` | L | Index candidate files and select a safe similar basis without making transfer time unbounded. |
|
||||
| `--append`; `--append-verify` | M | Negotiate file length and verify the retained prefix before resuming. |
|
||||
| `--checksum-choice=STR`, `--cc`; `--checksum-seed=NUM` | M | Negotiate checksum algorithms/seeds and preserve compatibility with existing xxHash checks. |
|
||||
|
||||
### Phase 4: Metadata, Links, and Devices
|
||||
|
||||
These features are platform-sensitive and need Linux permission, ACL, xattr, and special-file integration tests.
|
||||
|
||||
| Features | Effort | Implementation plan |
|
||||
|----------|--------|--------------------|
|
||||
| `--numeric-ids`; `--usermap=STRING`; `--groupmap=STRING`; `--chown=USER:GROUP` | L | Define identity mapping, privilege failures, and wire representation before applying ownership. |
|
||||
| `--open-noatime`; `--atimes`, `-U`; `--crtimes`, `-N`; `--omit-dir-times`, `-O`; `--omit-link-times`, `-J` | L | Extend metadata capture/apply with platform capability checks and explicit unsupported-attribute handling. |
|
||||
| `--acls`, `-A`; `--xattrs`, `-X`; `--fake-super` | XL | Add portable serialization, size limits, privilege behavior, and security tests for ACL/xattr data. |
|
||||
| `--hard-links`, `-H` | L | Preserve inode relationships across the file list and coordinate hard-link creation order. |
|
||||
| `--munge-links`; `--copy-dirlinks`, `-k`; `--keep-dirlinks`, `-K` | L | Define symlink trust boundaries and receiver-side directory/link collision behavior. |
|
||||
| `--devices`; `--specials`; `-D`; `--copy-devices`; `--write-devices` | XL | Add privileged special-file handling with strict type, path, and authorization checks. |
|
||||
| `--super`; `--copy-as=USER[:GROUP]` | XL | Requires a deliberate privilege model, identity switching, and refusal paths; do not implement by blindly elevating the process. |
|
||||
| `--preallocate` | S | Use platform allocation APIs before writes and fall back cleanly when unsupported. |
|
||||
|
||||
### Phase 5: Connectivity and Daemon Compatibility
|
||||
|
||||
These options affect process startup, authentication, sockets, and remote execution. They should follow the filesystem and protocol work rather than being added as parser-only flags.
|
||||
|
||||
| Features | Effort | Implementation plan |
|
||||
|----------|--------|--------------------|
|
||||
| `--rsh=COMMAND`, `-e`; `--rsync-path=PROGRAM`; `--blocking-io`; `--outbuf=N\|L\|B` | M | ✅ Wave A implemented (see the Connectivity table above). SSH argv construction is generalized: `-e`/`--rsh` replaces the hardcoded `ssh` program (whitespace-split, so `-e "ssh -p 2222"` works), `--rsync-path` aliases the existing `fastsync_server_path`, `--blocking-io` drops the SSH socket timeouts, and `--outbuf` maps N/L/B onto `setvbuf`. All four are client-only launch concerns and never cross the wire. |
|
||||
| `--address=ADDRESS`; `--ipv4`, `-4`; `--ipv6`, `-6`; `--sockopts=OPTIONS`; `--port=PORT` daemon semantics | M | Add explicit socket-family/bind configuration and validate it independently for TCP client and daemon modes. |
|
||||
|
||||
**Phase 5, Wave B (socket/bind) shipping note:** `--sockopts` adds a strict allowlisted `OPT=VAL` socket-option layer applied with correct per-option value types; `--address` binds the outgoing client socket to a local source address; `-4`/`-6` pin the address family via `getaddrinfo` hints on both the client connect and the server bind; and the server bind now honors `--address` plus `-4`/`-6` (falling back to the historical IPv4 `INADDR_ANY` when none are given). All of these are local socket concerns and none cross the wire config frame (only `--port` maps to `server_port`).
|
||||
| `--remote-option=OPT`, `-M`; `--trust-sender` | L | Add authenticated remote-option/config negotiation and reject unsafe sender-controlled values. `-M` conflicts with FastSync metadata mode. |
|
||||
| `--daemon`; `--config=FILE`; `--dparam=OVERRIDE`; `--no-detach`; `--password-file=FILE`; `--early-input=FILE`; `--no-motd` | XL | Implement a real daemon lifecycle, module configuration, authentication, privilege separation, and process management. |
|
||||
|
||||
**Phase 5, Wave A (rsh/ssh) shipping note:** the SSH transport no longer hardcodes `ssh`. `-e`/`--rsh=COMMAND` selects the remote-shell program (whitespace-split into the leading child argv words), `--rsync-path=PROGRAM` aliases `--fastsync-server-path`, `--blocking-io` removes the SSH-socketpair `SO_RCVTIMEO`/`SO_SNDTIMEO` timeouts (by default they now match the TCP transport so a wedged shell cannot hang forever), and `--outbuf=N|L|B` maps onto `setvbuf` (`_IONBF`/`_IOLBF`/`_IOFBF`, garbage rejected). All four are client-only launch concerns and never cross the wire.
|
||||
|
||||
**Phase 5, Wave C (remote-option/trust-sender) shipping note (PROTOCOL 2.13.0 → 2.14.0):** `--remote-option=OPT` (long form only; the short `-M` is intentionally left as FastSync metadata mode — documented divergence) appends each validated value to the remote server invocation over SSH as an individually single-quote-escaped shell word, so shell metacharacters cannot break out and a `--` can never be turned into injection; options never cross the binary config frame. `--trust-sender` is a receiver-local policy (never serialized, so a wire peer can't enable it): when requested on the server (via `--remote-option=--trust-sender`), it removes only the redundant receiver/save-layer path re-checking; the low-level floor (`file_open_secure_parent`'s `..` rejection, the O_NOFOLLOW parent walk, leaf/destination confinement) stays enforced. Off by default. The wire config-frame layout is unchanged; the bump reflects that a 2.14 sender composing remote options requires a 2.14 receiver to honor them.
|
||||
|
||||
### Phase 6: Batch, Encoding, and Protocol Interoperability
|
||||
|
||||
These are the hardest compatibility items because they require durable formats or behavior that must interoperate with rsync itself.
|
||||
|
||||
| Features | Effort | Implementation plan |
|
||||
|----------|--------|--------------------|
|
||||
| `--write-batch=FILE`; `--only-write-batch=FILE`; `--read-batch=FILE` | XL | ✅ Implemented (see the Batch Operations table and Phase-6 batch note below): a versioned self-contained single-file residual-batch format, persisted via the existing chunk codec, with replay, corruption, and partial-application safety tests |
|
||||
| `--protocol=NUM` | XL | ✅ Implemented (see the Advanced table and Phase-6 protocol note below): protocol-version forcing without weakening current validation; FastSync's single lockstep wire format means only the current `PROTOCOL_VERSION` is accepted, and everything else is rejected up-front |
|
||||
| `--iconv=CONVERT_SPEC` | L | ✅ Implemented (see the Advanced table and Phase-6 iconv notes below): filename charset conversion at the wire boundary with expansion/overflow safety and invalid-sequence test coverage |
|
||||
| `--stop-after=MINS`; `--stop-at=TIME` | M | ✅ Implemented (see the Advanced table and Phase-6 stop notes below): deadline propagation and safe early stop with --delete safety |
|
||||
| `--early-input=FILE`; `--password-file=FILE` | M | Securely read startup credentials/input with permission checks and no secret disclosure in logs. |
|
||||
|
||||
**Phase 6, Wave A (stop deadline) shipping note:** `--stop-after=MINS` and `--stop-at=TIME` are client-only sender stop deadlines. `--stop-after` takes a positive minute count (0/negative/garbage rejected); `--stop-at` takes `HH:MM`, `HH:MM:SS`, or `now+N[smhd]` (a past time stops immediately, a garbage spec is rejected at parse time). The deadline is computed once at the start of the transfer (CLOCK_MONOTONIC for `--stop-after`, wall clock via `time()` for `--stop-at`) and checked at every chunk boundary in both the single-threaded `send_files` loop and the multithreaded `send_chunks_multithreaded` path, and inside the scanner loops so a busy scan itself stops. When it fires, the transfer stops ELEGANTLY: the in-flight chunk completes, the existing completion tail runs (summary, `disconnect`), and the run returns 0 — exactly like rsync's clean early stop. Because the deadline is client-only and never crosses the wire config frame, no PROTOCOL_VERSION bump is required. The safety-critical interaction is with `--delete`: FastSync streams while scanning, so a deadline can cut the source scan short and yield a PARTIAL keep-set manifest; committing that would make the receiver delete destination mirrors of source files not yet scanned. So the sender tracks `scan_stopped_early` and, when it is true on the late/delete-after (`--delete`/`--delete-after`/`--delete-delay`) path, SUPPRESSES the keep-set manifest (logs a warning) so no deletion happens from an incomplete set — this is the safe direction (preserves data; the delete simply does not run). `--delete-before`/`--delete-during` are unaffected: their complete pre-scan runs before any data and ignores the deadline (a stop can be exceeded by that pre-scan). Under `-j`/`--threads` the stop is symmetric and the scanner thread's still-in-progress manifest appends can never race the tail because the tail does not read the manifest on the early-stop path.
|
||||
|
||||
**Phase 6, Wave B (iconv) shipping note (PROTOCOL 2.15.0 → 2.16.0):** `--iconv=LOCAL[,REMOTE]` converts file NAMES at the wire boundary (never content). The full CONVERT_SPEC is serialized into the config frame as a new trailing string field (empty→NULL canonicalized), so both ends share the same wire charset interpretation; this required the PROTOCOL bump because the frame is a strict ordered sequence and a peer that does not parse the new trailing field would desynchronize. Each end derives LOCAL (its own charset) and REMOTE (the wire charset): the sender opens LOCAL→REMOTE and converts every transmitted filename; the receiver opens REMOTE→LOCAL and converts every received filename before creating/writing. Conversion is applied at every wire-path site (regular/MKDIR/hardlink path+target/symlink path+target/SPECIAL, the delete manifest keep/protected/missing entries, the incremental-check path, and the embedded `-s`/chunk-blob path). A name it cannot convert (EILSEQ/EINVAL) is failed cleanly with a logged `--iconv: cannot convert file name ...` and is never written truncated/mangled. Validation probes both directions up front (both the sender local→remote and the receiver remote→local, and, for a server/daemon with its own `--iconv`, the client-REMOTE→server-LOCAL pair) so an unusable spec is rejected before the connection rather than mid-transfer, and NUL-emitting target charsets (utf-16/utf-32/ucs-2) are refused because filenames cannot contain NUL. Divergence documented upstream: the receiver does NOT half-swap; the wire charset always comes from the sender's REMOTE half, so a server whose local charset differs from the client's LOCAL must declare it with its own `--iconv`. Conversion is process-global and runs on a single thread per process (sender thread / receiver-loop thread), initialized before worker threads start and freed after they join.
|
||||
|
||||
**Phase 6, Wave C (protocol-version) shipping note (no PROTOCOL_VERSION change):** `--protocol=NUM` lets the client force the wire protocol version for a transfer. FastSync's protocol is a single lockstep format: the config frame is a strict ordered sequence and the server requires the client's version string to equal `PROTOCOL_VERSION` exactly (`config_receive_with_validate`, src/shared/config.c) — there are no older-format code paths and no downgrade/negotiation machinery, so a lower/higher/virtual version can never be spoken. The honest contract is therefore: `--protocol=2.21.0` (the current `PROTOCOL_VERSION`, as of the combined error-detail + server-contacting dry-run wave) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `2.20.0`, `2.19.0`, `2.18.0`, `2.18`, `2.17.0`, `2.16.0`, `2.15.0`, `3.0.0`, rsync-integer spellings like `216`/`31`, garbage, empty — is rejected up front in `validate_config()` before any connection, with a clear error that FastSync supports only its current wire protocol and cannot speak an older or virtual one. Implementation is client-only: a server-side `--protocol` is intentionally not added because the server has no negotiation (it only enforces exact match), and it could only ever be the current version. This preserves (and slightly tightens) existing validation: the client now also refuses to launch with a version it cannot actually speak, rather than only the server rejecting it later. A genuine downgrade would require a per-version compatibility layer for every frame/feature added since (append 2.10, preallocate 2.11, hardlinks 2.12, devices/specials/symlink-trust/xattr 2.13, remote-option 2.14, daemon module/auth 2.15, iconv 2.16, dir/symlink times 2.17, privilege flags --super/--copy-as 2.18, SCRAM daemon auth 2.19, packed metadata 2.20) and is intentionally out of scope — documented divergences from rsync's integer-negotiated downgrade remain.
|
||||
|
||||
**Phase-1/2 selection-and-update status correction (docs):** `-I/--ignore-times`, `--size-only`, `-@/--modify-window`, `--existing`, `--ignore-existing`, `-u/--update`, `-W/--whole-file`, and `--compress-threads` were previously listed as not-implemented in this document but are in fact fully implemented and tested on `dev`. This pass corrects the matrix to match the code. The realistic model of these is that FastSync is a *sender-driven* whole-tree copy, so the size+mtime quick-check and all three receiver-policy skips (`--existing`, `--ignore-existing`, `-u`) are evaluated against the **destination** on the receiver side, and their booleans cross the wire in the config frame. `-I`/`--size-only`/`--modify-window` modify the `--incremental` per-file `STATUS_CHECK` handshake's match predicate (`-I` disables the mtime leg and forces transfer; `--size-only` drops only the mtime leg; `--modify-window` adds tolerance to `metadata_mtime_matches`); they require `--incremental` (or a basis dir) to have a handshake to affect, mirroring how they only matter where a quick-check exists in rsync. `--existing`/`--ignore-existing`/`-u` are receiver write-time policies (skipping the write / newer-destination guard) applied across the regular-file, `--delay-updates`-staged, hardlink-sibling, and special/device paths; `-u` implies `-M` metadata and uses a second-then-nanosecond strict `>` newer check; both correctly influence `--remove-source-files` (a skipped source is not removed). `-W/--whole-file` disables block-level delta (opt-in via `--delta`), folded into the wire `use_delta` so no protocol bump was needed, and makes `--fuzzy` inert; `--append`/`--append-verify` are rejected with `-W`. `--compress-threads=NUM` (1..64, client-only, never crosses the wire) sizes the zstd compression worker pool. No code was changed by this correction; the implementation had landed in earlier merge waves (feat/ignore-times, feat/ignore-existing via the newer `file_to_disk_secure_no_replace`/`linkat EEXIST` path, feat/size-only, feat/modify-window, feat/whole-file, feat/update, compression-threads).
|
||||
|
||||
**Phase 6, Wave D (batch) shipping note (no PROTOCOL_VERSION change):** FastSync batch mode is a **client-only, self-contained "residual batch"**: a single file `MAGIC "FSTRESBATCH" + format version 1 + metadata flag`, followed by length-prefixed `chunk_serialize` blobs that store full file images (regular files, dirs, symlinks, specials). It is NOT a raw capture of the live wire, because FastSync's protocol is per-file interactive (`STATUS_CHECK`/`STATUS_DELTA_SIGNATURE`/`STATUS_APPEND` handshake), so a raw sender-stream tee is not deterministically replayable against an arbitrary destination. Storing full residuals via the existing, fuzz-tested chunk codec makes `--read-batch` replay byte-identically by construction. `--write-batch=FILE` runs the normal live transfer AND emits the batch from a separate deterministic scan pass; `--only-write-batch=FILE` emits the batch only (no destination, no server); `--read-batch=FILE DEST` applies it locally (no source, no server; DEST is the only positional arg). Because batch is a local driver concern, it never crosses the wire: no new config-frame field and no `PROTOCOL_VERSION` bump (mirroring `--stop-after`/`--protocol`/`--compress-threads`). The READ side is hardened against untrusted/attacker-controlled batch files: magic+version validated before any record, per-record length bounds checked before allocation (64 MB cap), clean-EOF-after-prefix and truncated/oversized records rejected, and every applied path goes through the same confined `file_save_to_disk_full` machinery as the network receiver (O_NOFOLLOW fd-walk, `..`-rejection, root confinement — a malicious `../` or absolute/symlink path cannot escape the destination root; this was security-reviewed and valgrind/ASan-clean). Divergences from rsync: (1) the batch carries the FULL residual (complete file images) rather than rsync's update-only delta stream — always byte-correct but larger; (2) per-file data is capped at the chunk codec's ~64 MB (`BATCH_MAX_RECORD`), so very large files may be refused by the batch writer with a clean error (never a corrupt/truncated batch); (3) hard-links and xattr/ACL blocks are not represented by `chunk_serialize`, so `-H`/`-X`/`-A` are out of scope for batch; (4) there is no companion `.sh`/`.rsync_argvs` — the batch is invoked directly (`fastsync --read-batch=FILE DEST`, `--only-write-batch=FILE SOURCE`); (5) `--write-batch` drives the single-threaded transfer path. Integration/`-M` note: metadata is captured in the batch when `-M` is used and persisted in the header so it applies consistently regardless of the reading process's own `-M`.
|
||||
|
||||
### Phase 7: CLI-Namespace Parity, Filesystem/Output Completion, and Privilege (Final)
|
||||
|
||||
These are the last compatibility items and the closing phase toward rsync flag parity. Per the project decision: every rsync flag (short **and** long) that is *possible* gets real rsync-parity behavior; anything physically impossible becomes an explicit **Impossible/Divergence** status (accepted for CLI compatibility, safely inert, with coverage tests proving that); and the two privilege flags (`--super`, `--copy-as`) adopt the deliberately-scoped **safe-subset + clear-refusal** model rather than blind elevation. The remaining `⚠️ Partial`, `🔄 Compatibility No-op`, `🔀 Alt Arg`, and `❌ Not Implemented` rows in the Summary are this phase's scope. All Wave A renames are **client-side only** (the wire config fields `use_compression`/`use_metadata`/`use_sendfile`/`use_chunk_serialization` are unchanged), so they require **no `PROTOCOL_VERSION` bump**.
|
||||
|
||||
**Wave A — CLI namespace parity (rename colliding FastSync short flags) — ✅ implemented.** This freed the short letters rsync needs and made the three `🔀 Alt Arg` rows real. `-c`→`--checksum`, `-m`→`--prune-empty-dirs`, `-M`→`--remote-option`, `-f`→`--filter`, `-s`→`--secluded-args`, `-p`→`--perms`, `-T`→`--temp-dir`, `-a`/`--archive`→real `-rlptgoD`. FastSync's own flags moved to long-form-only or new shorts: `-j`/`--threads` (multithreading), `--preserve` (metadata), `--sendfile`, `--chunk-serialization`, `--timeout`, `--ssh-port`. The server's independent little CLI keeps `-p` as its port. All client-side, no wire change, no `PROTOCOL_VERSION` bump. Unit tests 37/37, full integration 400 passed, cppcheck and clang-format clean. Known Wave-A limitation: `--no-perms`/`--no-compress`-style negation of the newly-aliased shorts is not wired into the negatable set (only the long-form `--preserve`/`--compress`/`--no-links` negations exist); `--archive --no-perms` is consequently not supported yet — a minor deviation from rsync, acceptable for Wave A.
|
||||
|
||||
| FastSync flag today | rsync wants that name | Proposed rename |
|
||||
|---------------------|----------------------|-----------------|
|
||||
| `-c` / `--compress` | `-c` = `--checksum` | compression is already aliased as `-z`/`--compress` (rsync parity!) → drop the `-c` short, keep `--compress`/`-z` |
|
||||
| `-m` / `--multithreading` | `-m` = `--prune-empty-dirs` | → `-j` / `--threads` |
|
||||
| `-M` / `--preserve` | `-M` = `--remote-option` | → `--preserve` (long-only) |
|
||||
| `-f` / `--sendfile` | `-f` = `--filter` | → `--sendfile` (long-only) |
|
||||
| `-s` / `--chunk-serialization` | `-s` = `--secluded-args`/`--protect-args` | → `--chunk-serialization` (long-only) |
|
||||
| `-p` (SSH port) | `-p` = `--perms` | → `--port` (long-only; `--server-port` already exists) |
|
||||
| `-T` / `--timeout` | `-T` = `--temp-dir` | → `--timeout` (long-only) |
|
||||
| `-a` / `--archive` (= `-c -m -M`) | `-a` = `-rlptgoD` | → becomes **real rsync `-a`** after the renames |
|
||||
|
||||
**Wave B — Output & filesystem completion (✅ implemented).** `-S`/`--sparse` (`⚠️→✅`): real hole preservation — a sparse-aware writer (`write_all_sparse`) skips all-zero runs ≥ 4096 bytes with `lseek(SEEK_CUR)` and `ftruncate`s the final size, wired into both the atomic temp+rename store and `--inplace` receiver-side with **no wire change** (the full file image is already in memory; the ftruncate presize is kept). `-P` (`⚠️→✅`): interrupted-write retention — on a save failure after data reached the temp fd, `--partial` now renames the already-written temp to the destination path (best-effort; falls through to the normal unlink on failure, never retains when `--partial` is off) so a later `--append`/`--append-verify` run can resume. `--block-size=SIZE` (`⚠️→✅`): promoted after verification — `--block-size` is now an alias for `--delta-block`, both set `config->delta_block_size`, which the delta engine already honored end-to-end (`delta_signature_create_seeded` + `delta_apply`); out-of-range values keep the default. `--fake-super` (`⚠️→✅`): added `fake_super_restore_fd` to parse and re-apply the recorded `user.fastsync.stat` record fd-relative (fchown best-effort/non-root skipped, fchmod, futimens); a save under `--fake-super` now re-applies the recorded attrs instead of only recording them, with the recording format unchanged. `--stderr=client` (`⚠️→⛔ Impossible/Divergence`): FastSync has no rsync client-message channel, and `client` is rejected at CLI parse — the rejection is the documented behavior (unit-tested). `-N`/`--crtimes` (`⚠️→⛔ Impossible/Divergence`): birth-times cannot be set by any portable fs call (`utimensat` sets only atime/mtime); capture/transmit stays, setting is impossible, the flag is accepted and safely inert. Review-hardening (post-eval): fake-super replay applies the mode through the same sanitization as the normal metadata path (group/other write bits are never granted); `--sparse` takes precedence over `--preallocate` (posix_fallocate skipped so holes survive); `--partial` retention is disabled for `--no_replace` (ignore/existing) and only marks a write-attempt after the actual write begins; `--block-size=SIZE`/`--delta-block=SIZE` inline forms are accepted.
|
||||
|
||||
**Wave C — Devices & special files (finalize statuses + tests) (✅ implemented).** The four special-file rows are finalized with coverage tests. `--devices`, `--copy-devices`, and `--write-devices` are **✅ Implemented**, each with a documented, safety-driven divergence: device-node creation is privilege-gated, so a receiver without `CAP_MKNOD` skips that entry with a warning (a per-entry skip, never a transfer failure); `--copy-devices` copies a device/FIFO's reported size into an ordinary regular file (a size-bounded safe divergence from rsync's unbounded dd-like read); `--write-devices` writes only into an existing char/block node under the confined receive root and skips every unusable target rather than clobbering or aborting. `--specials` is classified **⛔ Impossible/Divergence** for one reason only: **FIFO recreation works** (unprivileged `mkfifo`, asserted under CI), but **sockets cannot be recreated by any standard filesystem call**, so a source socket is skipped with an explicit note. Tests assert FIFO recreation, the safe socket skip, the regular-file result of `--copy-devices`, the skipped/missing and non-device `--write-devices` targets, and (root-gated) real device-node creation; a root runner additionally drops the receiver to an unprivileged user to assert the `CAP_MKNOD` skip is graceful.
|
||||
|
||||
**Wave D — Times superstructure & arg-protection no-ops (✅ implemented, `--secluded-args` ⛔).** `-O`/`--omit-dir-times` and `-J`/`--omit-link-times` are now **real modifiers** (both `🔄 → ✅ Implemented`), reversing the old "never preserves directory/symlink times" divergence:
|
||||
|
||||
- **Directory times.** The recursive scanner captures every traversed source directory's metadata (mtime, plus atime under `-U`) into a per-transfer list — two paths are covered: the sequential `DirectoryScanner` captures each opened directory (including the transfer root), and the parallel scanner captures both the root in `parallel_scanner_create_with_options` and each worker's subdirectories in `open_next_directory` (appends are guarded by a mutex shared with the sender's pipeline context). The sender transmits them in trailing `STATUS_DIR_TIMES` frames (each: int count + count × (wire path, metadata) pairs) sent **after all file data and after the optional delete manifest**, just before `STATUS_FINISHED`. A tree larger than `MAX_MANIFEST_ENTRIES` (1 048 576) directories is chunked into repeated frames, each within the receiver's per-frame bound. A dir-time entry is RECORD-ONLY (`file->dir_time_only`): `file_save_to_disk_full` returns `FILE_SAVE_SKIPPED` without creating anything, so a source directory that was empty (or pruned by `-m/--prune-empty-dirs`) is never resurrected. The receiver accumulates received directory metadata in a `DirTimeList` and applies it only at the very end — after the entire stream, after the commit-style `--delete` deletion, and after `--delay-updates` publication — because creating or removing a child bumps the parent's mtime. Application is fd-relative/walk-confined (`file_open_secure_parent` + `utimensat(..., AT_SYMLINK_NOFOLLOW)`) and best-effort per entry: an absent path (an intentionally uncreated empty dir) is skipped QUIETLY and only a real existing directory is stamped. `-O` (config boolean, already on the wire) makes the receiver skip the whole set. The single-threaded sink applies in `receiver_send_success_frame`; the `-j`/`--threads` sink accumulates in `write_thread` and server.c applies after both threads join and the deletion commits.
|
||||
- **Symlink times/owner/mode.** `STATUS_SYMLINK` already carried metadata; the receiver now applies it with no-follow primitives only: `utimensat(..., AT_SYMLINK_NOFOLLOW)`, best-effort `fchmodat(..., AT_SYMLINK_NOFOLLOW)` (honest no-op where unsupported, e.g. Linux), and policy-gated `fchownat(..., AT_SYMLINK_NOFOLLOW)` via a new `identity_apply_ownership_link` that shares the identity resolver with the fd path. `-J` suppresses only the timestamps; ownership stays governed by the identity opt-in (`--numeric-ids`/`--usermap`/`--groupmap`/`--chown`) exactly like regular files. A symlink has no children, so this is applied immediately at creation.
|
||||
- **Wire:** the shared `STATUS_DIR_TIMES` frame (and metadata on `STATUS_MKDIR` for `--dirs` entries) is a frame-sequence change, so `PROTOCOL_VERSION` was bumped **2.16.0 → 2.17.0**; every version-sensitive test (`--protocol` accepted/rejected values) was updated. The config-frame layout itself is unchanged (the omit booleans already crossed). Non-metadata and `--no-preserve` transfers send no `STATUS_DIR_TIMES` frame and no directory metadata, keeping them byte-identical.
|
||||
|
||||
`--secluded-args` (`🔄 → ⛔ Impossible/Divergence`): a true arg-send protocol would replace the argv-based SSH launch with an in-band channel, and FastSync already builds the remote SSH argv injection-safe (single-quote-escaped shell words), so there is no argument-leak to close; the already-safe behavior is documented in the row and no transport change is made.
|
||||
|
||||
**Wave E (LAST) — Privilege: `--super`/`--no-super` and `--copy-as=USER[:GROUP]` (✅ implemented).** FastSync adopts a **safe-subset + clear-refusal** privilege model: it never blind-elevates and never calls `setuid`/`seteuid`/`setgid`. All privileged operations remain fd-relative and confined below the authorized receive root.
|
||||
|
||||
`--super`/`--no-super` set a receiver-side tri-state `Config->super_mode` (`SUPER_MODE_AUTO`/`ON`/`OFF`). `privilege_super_permitted()` / `privilege_super_mode_permitted()` (src/shared/identity.c) return true for `ON` and `AUTO` (AUTO preserves FastSync's historical best-effort attempt, where the kernel refuses an unprivileged call and the caller skips it) and false only for `OFF`. The gate covers every super-user activity FastSync performs: ownership application (`identity_apply_ownership`/`_link`), char/block device-node creation (`file_save_special_to_disk`), writes into an existing device (`--write-devices`), and the `--fake-super` owner replay. Unprivileged FIFO creation is deliberately unaffected. `--super` does **not** imply `--numeric-ids`: ownership is applied only when an explicit identity policy (`--usermap`/`--groupmap`/`--chown`/`--numeric-ids`/`--copy-as`) is also given. `--no-super` suppresses those activities even for a root receiver. A non-root receiver given `--super` logs one warning at activation (`identity_set_active`); each confined attempt is then refused by the kernel and skipped, never aborting. The confinement floor is unchanged (`file_open_secure_parent`, `O_NOFOLLOW`, root/path checks). Operator control: the server CLI accepts `--no-super`, a veto that forces `OFF` for every connection, refuses any client `--copy-as`, and neutralizes an explicit `--super` (the connection is accepted but no super-user activity is attempted). A privileged (root) standalone TCP listener instead defaults to `OFF` and requires the server-only `--allow-super` opt-in to attempt any super-user activity (the flag is rejected with `--stdio`, whose client-composed remote argv must never defeat the default; use a forced command if the default must hold); a non-root server is unchanged. On a daemon, a module that has not opted in with `client owner = yes` additionally has super-user device activity forced off (see the Daemon Mode notes).
|
||||
|
||||
`--copy-as=USER[:GROUP]` is the safe subset. FastSync's receiver is multithreaded, so a real credential switch is unsafe; instead the receiver forces the ownership of **every entry it writes** — regular files, symlinks, directories (including implicitly-created parents), and special nodes — to the resolved target ids through the confined fd-relative identity path. USER is resolved on the client (name, `@N`/bare N, or `*` = client euid); when `:GROUP` is omitted the user's primary gid is used (falling back to `gid == uid` for a numeric id with no local passwd entry). It requires a privileged (root) receiver: an unprivileged receiver refuses the whole transfer at the config handshake, before `STATUS_OK`, so no data is ever written with the wrong ownership. A `--copy-as` chown failure on a capability-restricted root is logged at ERROR (never silently downgraded). `--copy-as` implies metadata (`--no-preserve` is rejected) and `--fake-super` cannot override it. Daemon policy: a `--daemon` receiver refuses **every** client-chosen-ownership / super-user request — `--numeric-ids`, `--chown`, `--usermap`/`--groupmap`, `--fake-super`, `--copy-as`, and explicit `--super` — unless the selected module opts in with `client owner = yes`; without that per-module opt-in any client could force arbitrary ownership inside the module root (a root standalone TCP listener, which serves one operator-authorized root, honors these requests only when started with `--allow-super`; the flag is rejected with `--stdio`). A `--copy-as` chown failure on a capability-restricted root marks the entry as failed rather than reporting success with the wrong owner.
|
||||
|
||||
**Wire:** two trailing config-frame blocks after the `--iconv` spec, in fixed order — `send_privilege_options`/`receive_privilege_options` (one `super_mode` int, validated `0..2`), then `send_copy_as_options`/`receive_copy_as_options` (presence int + two int32 ids, validated `>= 0`, with `copy_as_set ⇒ use_metadata`). `PROTOCOL_VERSION` bumped **2.17.0 → 2.18.0**. **Divergences from rsync:** rsync's `--super` elevates the receiver and `--copy-as` actually switches its credentials; FastSync never elevates and only permits/forwards confined attempts, and `--copy-as` forces ownership rather than switching identity.
|
||||
|
||||
**Post-Phase-7 Summary (after Waves A–E).** ✅143 / 🔀0 / ⛔4 / ⚠️0 / 🔄0 / ❌0 = 147. The 3 `🔀 Alt Arg` rows (`-a`, `-p`, `-z`) are ✅ (Wave A). All 10 prior `⚠️ Partial` rows are resolved to ✅ (`-S`, `-P`, `--block-size`, `--fake-super`, `--devices`, `--copy-devices`, `--write-devices`) or ⛔ (`--stderr=client`, `-N/--crtimes`, `--specials` for the impossible socket case). The 3 `🔄 Compatibility No-op` rows are resolved: `-O`/`-J` are now real ✅ (Wave D), `--secluded-args` is ⛔. The **Impossible/Divergence** bucket holds the 4 physically-impossible/divergent flags: `--stderr=client`, `-N/--crtimes`, `--specials` (sockets), `--secluded-args`. The last two `❌ Not Implemented` rows — `--super` and `--copy-as=USER[:GROUP]` — are now ✅ (Wave E). **No `❌ Not Implemented` rows remain.**
|
||||
|
||||
## Packed Metadata Frame (protocol 2.20.0)
|
||||
|
||||
A file's metadata used to cross the wire as up to 12 separate per-field framed
|
||||
messages (a present flag followed by mode/uid/gid/mtime/atime/crtime writes),
|
||||
which cost ~11 extra protocol frames per file on many-small-file trees. FastSync
|
||||
now sends the metadata as ONE packed frame: a single `int32` present flag
|
||||
(`0` = absent) followed, when present, by the fixed
|
||||
`FILE_METADATA_WIRE_SIZE`-byte (68-byte) field record already emitted by the
|
||||
shared `metadata_to_buf()`/`metadata_from_buf()` chunk codec. Absent metadata is
|
||||
a lone `int32` zero. The encoded field layout is unchanged (only the framing
|
||||
collapses), so chunk-serialized blobs remain byte-identical. Protocol data is an
|
||||
unframed byte stream, so the packed encoding is byte-for-byte identical to the
|
||||
old field-by-field writes; `PROTOCOL_VERSION` was bumped `2.19.0 → 2.20.0` as a
|
||||
deliberate lockstep-release marker rather than because of a
|
||||
desynchronization. The strict same-version handshake rejects any mismatch before
|
||||
a byte of the frame is parsed.
|
||||
|
||||
### Recommended Delivery Order
|
||||
|
||||
1. Resolve short-option conflicts (`-m`, `-M`, `-T`, `-f`, `-s`) and define the compatibility contract.
|
||||
2. Implement Phase 1 comparison, update, output, and alias features with unit and integration coverage.
|
||||
3. Implement Phase 2 traversal/filtering and Phase 3 deletion semantics.
|
||||
4. Implement metadata and link features that are safe on the supported platforms.
|
||||
5. Treat daemon mode, special files, batch mode, and protocol-version compatibility as separate projects.
|
||||
|
||||
The existing priority list below is a feature shortlist, not an implementation schedule; this plan supersedes it for effort and sequencing.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations: Top Features to Implement Next
|
||||
|
||||
Ranked by user demand, implementation complexity, and interoperability impact (_status reflects current `dev`_):
|
||||
|
||||
| Priority | Feature | Effort | Impact |
|
||||
|----------|---------|--------|--------|
|
||||
| 1 | `--whole-file` / `-W` | Low | High — users expect opt-out of delta — **✅ implemented** |
|
||||
| 2 | `--ignore-times` / `-I` | Low | Medium — useful for forcing re-transfer — **✅ implemented** |
|
||||
| 3 | `--size-only` | Low | Medium — common migration scenario — **✅ implemented** |
|
||||
| 4 | `--ignore-existing` | Low | Medium — common sync patterns — **✅ implemented** |
|
||||
| 5 | `--existing` | Low | Medium — common sync patterns — **✅ implemented** |
|
||||
| 6 | `--remove-source-files` | Low | High — common for moves/backup |
|
||||
| 7 | `--delete-during` | Medium | High — performance improvement |
|
||||
| 8 | `--delay-updates` | Medium | High — atomic updates |
|
||||
| 9 | `--chmod` | Low | Medium — permission flexibility |
|
||||
| 10 | `--executability` / `-E` | Low | Low — simple flag |
|
||||
| 11 | `--skip-compress` | Low | Medium — performance tuning |
|
||||
|
||||
---
|
||||
|
||||
## FastSync-Specific Features (Not in rsync)
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| `-j` / `--threads[=N]` | Multithreaded pipeline (scanner/loader/sender); `N` (1–256) sizes the parallel scanner worker pool, bare `-j`/`--threads` uses the built-in default (renamed from `-m` in Phase 7 Wave A; `-m` is now rsync `--prune-empty-dirs`) |
|
||||
| `--chunk-serialization` | Chunk serialization mode (long form only; `-s` is now rsync `--secluded-args`) |
|
||||
| `--sendfile` | Zero-copy sendfile() syscall (TCP only) (long form only; `-f` is now rsync `--filter`) |
|
||||
| `-z [level]` / `--compress` | zstd compression level (1-22) (`-c` is now rsync `--checksum`) |
|
||||
| `--chunk-size` | Configurable chunk size |
|
||||
| `--tls` | TLS encryption (mutual auth) |
|
||||
| `--fastsync-server-path` | Path to fastsync-server binary |
|
||||
| `--server-host` / `--server-port` | Direct TCP connection |
|
||||
| Incremental sync | Skip unchanged files (size+mtime) |
|
||||
| Delta transfer | Block-level delta for changed files |
|
||||
+70
-415
@@ -3,24 +3,19 @@
|
||||
|
||||
Compares FastSync configs against rsync (no compression) and rsync+zstd.
|
||||
Data is ~75% random/incompressible and ~25% structured/compressible by default,
|
||||
controllable via --random-ratio. Transfers are verified by default (source and
|
||||
destination must match) so a fast-but-broken copy is never counted.
|
||||
controllable via --random-ratio.
|
||||
|
||||
Usage:
|
||||
python3 benchmark/bench.py
|
||||
python3 benchmark/bench.py --runs 5 --profiles lan wan
|
||||
python3 benchmark/bench.py --random-ratio 0.5 --size-mb 50
|
||||
python3 benchmark/bench.py --delay 50ms --jitter 10ms --throughput 100mbit
|
||||
python3 benchmark/bench.py --warm --runs 3
|
||||
python3 benchmark/bench.py --output json
|
||||
"""
|
||||
import argparse
|
||||
import filecmp
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import statistics
|
||||
@@ -30,11 +25,8 @@ import tempfile
|
||||
import time
|
||||
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
DEFAULT_BUILD_DIR = "build-bench"
|
||||
# Populated by configure_build_dirs(); default to the dedicated bench dir so
|
||||
# importing this module never depends on the user's existing build/ tree.
|
||||
BUILD_DIR = os.path.join(PROJECT_ROOT, DEFAULT_BUILD_DIR)
|
||||
SERVER_CMD = [os.path.join(BUILD_DIR, "server"), "--allow-unauthenticated"]
|
||||
BUILD_DIR = os.path.join(PROJECT_ROOT, "build")
|
||||
SERVER_CMD = [os.path.join(BUILD_DIR, "server")]
|
||||
CLIENT_CMD = [os.path.join(BUILD_DIR, "client")]
|
||||
BENCH_DIR = os.path.join(PROJECT_ROOT, "bench_data")
|
||||
|
||||
@@ -51,12 +43,11 @@ NETWORK_PROFILES = {
|
||||
}
|
||||
|
||||
FASTSYNC_CONFIGS = [
|
||||
{"name": "fastsync", "flags": [], "tool": "fastsync"},
|
||||
{"name": "fastsync -z", "flags": ["-z"], "tool": "fastsync"},
|
||||
{"name": "fastsync -j", "flags": ["-j"], "tool": "fastsync"},
|
||||
{"name": "fastsync -j -z", "flags": ["-j", "-z"], "tool": "fastsync"},
|
||||
{"name": "fastsync -j -z --chunk-serialization", "flags": ["-j", "-z", "--chunk-serialization"], "tool": "fastsync"},
|
||||
{"name": "fastsync --sendfile", "flags": ["--sendfile"], "tool": "fastsync"},
|
||||
{"name": "fastsync", "flags": [], "tool": "fastsync"},
|
||||
{"name": "fastsync -c", "flags": ["-c"], "tool": "fastsync"},
|
||||
{"name": "fastsync -m", "flags": ["-m"], "tool": "fastsync"},
|
||||
{"name": "fastsync -m -c", "flags": ["-m", "-c"], "tool": "fastsync"},
|
||||
{"name": "fastsync -m -c -s", "flags": ["-m", "-c", "-s"], "tool": "fastsync"},
|
||||
]
|
||||
|
||||
RSYNC_CONFIGS = [
|
||||
@@ -65,7 +56,6 @@ RSYNC_CONFIGS = [
|
||||
{"name": "rsync -z --zstd", "flags": ["-z", "--zc", "zstd"],"tool": "rsync"},
|
||||
]
|
||||
|
||||
|
||||
class RsyncDaemon:
|
||||
"""Manages an rsync daemon for network-fair benchmarking."""
|
||||
|
||||
@@ -127,12 +117,6 @@ STRUCTURED_FILES = {
|
||||
"nested/another.txt": b"another nested file\n" * 50,
|
||||
}
|
||||
|
||||
# Repeated text used to synthesize genuinely compressible filler of any size.
|
||||
COMPRESSIBLE_TEXT = (
|
||||
b"FastSync benchmark payload: the quick brown fox jumps over the lazy dog. "
|
||||
b"0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz\n"
|
||||
)
|
||||
|
||||
|
||||
class Progress:
|
||||
"""Simple progress bar with ETA."""
|
||||
@@ -167,127 +151,35 @@ class Progress:
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def write_compressible(path, nbytes):
|
||||
"""Write exactly nbytes of highly compressible, repeated text content."""
|
||||
if nbytes <= 0:
|
||||
return
|
||||
block = COMPRESSIBLE_TEXT * (max(1, 8192 // len(COMPRESSIBLE_TEXT)) + 1)
|
||||
remaining = nbytes
|
||||
with open(path, "wb") as f:
|
||||
while remaining > 0:
|
||||
piece = block if remaining >= len(block) else block[:remaining]
|
||||
f.write(piece)
|
||||
remaining -= len(piece)
|
||||
|
||||
|
||||
def generate_bench_data(source_dir, size_mb=25, random_ratio=0.75):
|
||||
"""Generate test data honouring the requested random/compressible split.
|
||||
|
||||
Exactly ``random_ratio * target`` bytes are incompressible random data and
|
||||
the remainder is genuinely compressible structured/repeated content. The
|
||||
measured byte counts are returned so callers can report the real mix.
|
||||
"""
|
||||
"""Generate test data. ~random_ratio is incompressible, rest is structured."""
|
||||
if os.path.exists(source_dir):
|
||||
shutil.rmtree(source_dir)
|
||||
os.makedirs(source_dir)
|
||||
|
||||
target = size_mb * 1024 * 1024
|
||||
random_budget = int(target * random_ratio)
|
||||
compressible_budget = target - random_budget
|
||||
compressible_written = 0
|
||||
random_written = 0
|
||||
files = 0
|
||||
structured_budget = int(target * (1 - random_ratio))
|
||||
written = 0
|
||||
|
||||
# A handful of fixed, human-meaningful files (directories, small files, a
|
||||
# binary blob) as long as they fit inside the compressible budget.
|
||||
for rel_path, content in STRUCTURED_FILES.items():
|
||||
if compressible_written + len(content) > compressible_budget:
|
||||
if written >= structured_budget:
|
||||
break
|
||||
full_path = os.path.join(source_dir, rel_path)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
with open(full_path, "wb") as f:
|
||||
f.write(content)
|
||||
compressible_written += len(content)
|
||||
files += 1
|
||||
written += len(content)
|
||||
|
||||
# Fill the rest of the compressible share with generated repeated content.
|
||||
if compressible_written < compressible_budget:
|
||||
os.makedirs(os.path.join(source_dir, "compressible"), exist_ok=True)
|
||||
i = 0
|
||||
while compressible_written < compressible_budget:
|
||||
chunk = min(1024 * 1024, compressible_budget - compressible_written)
|
||||
write_compressible(os.path.join(source_dir, "compressible", f"text_{i}.dat"), chunk)
|
||||
compressible_written += chunk
|
||||
files += 1
|
||||
i += 1
|
||||
os.makedirs(os.path.join(source_dir, "bulk"), exist_ok=True)
|
||||
i = 0
|
||||
while written < target:
|
||||
chunk_size = min(5 * 1024 * 1024, target - written)
|
||||
with open(os.path.join(source_dir, f"bulk/file_{i}.dat"), "wb") as f:
|
||||
f.write(random.randbytes(chunk_size))
|
||||
written += chunk_size
|
||||
i += 1
|
||||
|
||||
# Incompressible share.
|
||||
if random_written < random_budget:
|
||||
os.makedirs(os.path.join(source_dir, "bulk"), exist_ok=True)
|
||||
i = 0
|
||||
while random_written < random_budget:
|
||||
chunk = min(5 * 1024 * 1024, random_budget - random_written)
|
||||
with open(os.path.join(source_dir, "bulk", f"file_{i}.dat"), "wb") as f:
|
||||
f.write(random.randbytes(chunk))
|
||||
random_written += chunk
|
||||
files += 1
|
||||
i += 1
|
||||
|
||||
return {
|
||||
"total_bytes": compressible_written + random_written,
|
||||
"compressible_bytes": compressible_written,
|
||||
"random_bytes": random_written,
|
||||
"files": files,
|
||||
}
|
||||
|
||||
|
||||
def list_relative_files(root):
|
||||
"""Return the set of file paths (relative to root) under a directory."""
|
||||
found = set()
|
||||
for dirpath, _dirnames, filenames in os.walk(root):
|
||||
for name in filenames:
|
||||
full = os.path.join(dirpath, name)
|
||||
found.add(os.path.relpath(full, root))
|
||||
return found
|
||||
|
||||
|
||||
def verify_transfer(source_dir, dest_dir):
|
||||
"""Recursively check dest matches source (paths, sizes, content).
|
||||
|
||||
Returns (ok, detail). Content is compared byte-for-byte, never hashed, so
|
||||
collisions are impossible. This is intentionally not part of the timing.
|
||||
"""
|
||||
if not os.path.isdir(dest_dir):
|
||||
return False, "destination directory missing"
|
||||
src_files = list_relative_files(source_dir)
|
||||
dst_files = list_relative_files(dest_dir)
|
||||
if src_files != dst_files:
|
||||
missing = src_files - dst_files
|
||||
extra = dst_files - src_files
|
||||
return False, f"path set mismatch (missing {len(missing)}, extra {len(extra)})"
|
||||
for rel in sorted(src_files):
|
||||
src = os.path.join(source_dir, rel)
|
||||
dst = os.path.join(dest_dir, rel)
|
||||
if os.path.getsize(src) != os.path.getsize(dst):
|
||||
return False, f"size mismatch: {rel}"
|
||||
if not filecmp.cmp(src, dst, shallow=False):
|
||||
return False, f"content mismatch: {rel}"
|
||||
return True, ""
|
||||
|
||||
|
||||
def percentile(values, pct):
|
||||
"""Linear-interpolation percentile (matches numpy's default method)."""
|
||||
if not values:
|
||||
return None
|
||||
ordered = sorted(values)
|
||||
if len(ordered) == 1:
|
||||
return ordered[0]
|
||||
rank = (len(ordered) - 1) * (pct / 100.0)
|
||||
low = math.floor(rank)
|
||||
high = math.ceil(rank)
|
||||
if low == high:
|
||||
return ordered[int(rank)]
|
||||
return ordered[low] + (ordered[high] - ordered[low]) * (rank - low)
|
||||
return written
|
||||
|
||||
|
||||
def find_free_port():
|
||||
@@ -315,45 +207,18 @@ def wait_proc(proc, timeout=5):
|
||||
proc.wait()
|
||||
|
||||
|
||||
def _tc_base_cmd():
|
||||
"""Return the command prefix for tc, honouring root vs sudo."""
|
||||
tc = shutil.which("tc")
|
||||
if not tc:
|
||||
raise RuntimeError(
|
||||
"tc (iproute2) not found in PATH; install iproute2 to use network profiles")
|
||||
if os.geteuid() == 0:
|
||||
return [tc]
|
||||
sudo = shutil.which("sudo")
|
||||
if sudo:
|
||||
return [sudo, tc]
|
||||
raise RuntimeError(
|
||||
"applying network limits requires root or sudo; "
|
||||
"re-run as root or install sudo")
|
||||
|
||||
|
||||
def _run_tc(args, check=True):
|
||||
return subprocess.run(_tc_base_cmd() + args, check=check, capture_output=True)
|
||||
|
||||
|
||||
def netem_apply(delay=None, jitter=None, throughput=None, loss=None):
|
||||
"""Apply tc/netem rules to loopback. Pass None to skip a parameter."""
|
||||
netem_reset()
|
||||
params = []
|
||||
cmd = ["sudo", "tc", "qdisc", "add", "dev", "lo", "root", "netem"]
|
||||
if throughput:
|
||||
params += ["rate", throughput]
|
||||
cmd += ["rate", throughput]
|
||||
if delay:
|
||||
params += ["delay", delay, jitter or "0ms"]
|
||||
cmd += ["delay", delay, jitter or "0ms"]
|
||||
if loss:
|
||||
params += ["loss", loss]
|
||||
if not params:
|
||||
return
|
||||
try:
|
||||
_run_tc(["qdisc", "add", "dev", "lo", "root", "netem"] + params)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
detail = exc.stderr.decode(errors="replace").strip() if exc.stderr else str(exc)
|
||||
raise RuntimeError(f"failed to apply network profile via tc/netem: {detail}") from exc
|
||||
except RuntimeError:
|
||||
raise
|
||||
cmd += ["loss", loss]
|
||||
if len(cmd) > 6:
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
|
||||
|
||||
def netem_apply_profile(profile_name):
|
||||
@@ -370,11 +235,7 @@ def netem_apply_profile(profile_name):
|
||||
|
||||
|
||||
def netem_reset():
|
||||
"""Best-effort removal of any loopback qdisc. Always safe to call."""
|
||||
try:
|
||||
_run_tc(["qdisc", "del", "dev", "lo", "root"], check=False)
|
||||
except Exception:
|
||||
pass
|
||||
subprocess.run("sudo tc qdisc del dev lo root".split(), capture_output=True)
|
||||
|
||||
|
||||
def run_fastsync(source_dir, dest_dir, flags, port):
|
||||
@@ -391,10 +252,8 @@ def run_fastsync(source_dir, dest_dir, flags, port):
|
||||
duration = time.monotonic() - start
|
||||
if result.returncode == 0:
|
||||
return duration
|
||||
sys.stderr.write(f" fastsync failed (exit {result.returncode}): "
|
||||
f"{result.stderr.strip()[:500]}\n")
|
||||
except subprocess.TimeoutExpired:
|
||||
sys.stderr.write(" fastsync timed out after 120s\n")
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@@ -410,10 +269,8 @@ def run_rsync(source_dir, dest_dir, flags, rsync_daemon=None):
|
||||
duration = time.monotonic() - start
|
||||
if result.returncode == 0:
|
||||
return duration
|
||||
sys.stderr.write(f" rsync failed (exit {result.returncode}): "
|
||||
f"{result.stderr.strip()[:500]}\n")
|
||||
except subprocess.TimeoutExpired:
|
||||
sys.stderr.write(" rsync timed out after 120s\n")
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@@ -425,81 +282,7 @@ def run_transfer(config, source_dir, dest_dir, port=None, rsync_daemon=None):
|
||||
return run_fastsync(source_dir, dest_dir, config["flags"], port)
|
||||
|
||||
|
||||
def apply_incremental_changes(source_dir, target_bytes):
|
||||
"""Add and modify a few files so a warm transfer has real work to do.
|
||||
|
||||
Returns a mutation record (changed byte count plus enough data to revert
|
||||
and re-apply it) so every warm run can start from a pristine source.
|
||||
"""
|
||||
modified_n = 3
|
||||
added_n = 2
|
||||
per_file = max(4096, target_bytes // (modified_n + added_n))
|
||||
modified = {}
|
||||
added = {}
|
||||
changed = 0
|
||||
|
||||
existing = sorted(list_relative_files(source_dir))
|
||||
if existing:
|
||||
step = max(1, len(existing) // modified_n)
|
||||
for rel in existing[::step][:modified_n]:
|
||||
path = os.path.join(source_dir, rel)
|
||||
original_size = os.path.getsize(path)
|
||||
with open(path, "ab") as f:
|
||||
f.write(random.randbytes(per_file))
|
||||
modified[rel] = (original_size, per_file)
|
||||
changed += per_file
|
||||
|
||||
for i in range(added_n):
|
||||
os.makedirs(os.path.join(source_dir, "incremental"), exist_ok=True)
|
||||
rel = os.path.join("incremental", f"new_{i}.dat")
|
||||
write_compressible(os.path.join(source_dir, rel), per_file)
|
||||
added[rel] = per_file
|
||||
changed += per_file
|
||||
|
||||
return {"changed": changed, "modified": modified, "added": added}
|
||||
|
||||
|
||||
def revert_incremental_changes(source_dir, mutation):
|
||||
"""Undo apply_incremental_changes so the source is pristine again."""
|
||||
if not mutation:
|
||||
return
|
||||
for rel, (original_size, _appended) in mutation["modified"].items():
|
||||
path = os.path.join(source_dir, rel)
|
||||
if os.path.exists(path):
|
||||
with open(path, "r+b") as f:
|
||||
f.truncate(original_size)
|
||||
for rel in mutation["added"]:
|
||||
path = os.path.join(source_dir, rel)
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
|
||||
|
||||
def reapply_incremental_changes(source_dir, mutation):
|
||||
"""Re-apply a mutation after an untimed pristine seed transfer."""
|
||||
if not mutation:
|
||||
return
|
||||
for rel, (_original_size, appended) in mutation["modified"].items():
|
||||
with open(os.path.join(source_dir, rel), "ab") as f:
|
||||
f.write(random.randbytes(appended))
|
||||
for rel, size in mutation["added"].items():
|
||||
write_compressible(os.path.join(source_dir, rel), size)
|
||||
|
||||
|
||||
def expected_received_root(dest_dir, source_dir, tool):
|
||||
"""Where a tool places transferred files inside dest_dir.
|
||||
|
||||
FastSync mirrors the absolute source path under dest_dir (see the
|
||||
integration suite's get_dest_received_dir); rsync copies the source tree
|
||||
contents directly into dest_dir.
|
||||
"""
|
||||
if tool == "rsync":
|
||||
return dest_dir
|
||||
return os.path.join(dest_dir, os.path.abspath(source_dir).lstrip(os.sep))
|
||||
|
||||
|
||||
def run_benchmark(source_dir, dest_dir, configs, runs, profile_name,
|
||||
measure_bytes, verify=True, warm=False, mutation=None,
|
||||
progress=None):
|
||||
def run_benchmark(source_dir, dest_dir, configs, runs, profile_name, progress=None):
|
||||
"""Run benchmark for all configs, returns list of results."""
|
||||
is_limited = profile_name != "unlimited"
|
||||
has_rsync = any(c["tool"] == "rsync" for c in configs)
|
||||
@@ -515,10 +298,7 @@ def run_benchmark(source_dir, dest_dir, configs, runs, profile_name,
|
||||
results = []
|
||||
for config in configs:
|
||||
times = []
|
||||
invalid = 0
|
||||
for run_idx in range(runs):
|
||||
if warm:
|
||||
revert_incremental_changes(source_dir, mutation)
|
||||
if os.path.exists(dest_dir):
|
||||
shutil.rmtree(dest_dir)
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
@@ -526,33 +306,15 @@ def run_benchmark(source_dir, dest_dir, configs, runs, profile_name,
|
||||
port = find_free_port()
|
||||
server = None
|
||||
try:
|
||||
if config["tool"] == "fastsync" or warm:
|
||||
if config["tool"] == "fastsync":
|
||||
server = subprocess.Popen(
|
||||
SERVER_CMD + ["-p", str(port)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
wait_for_port(port)
|
||||
|
||||
if warm:
|
||||
seed = run_transfer(config, source_dir, dest_dir, port, rsync_daemon)
|
||||
if seed is None:
|
||||
invalid += 1
|
||||
sys.stderr.write(" warm-mode seeding failed; run not counted\n")
|
||||
continue
|
||||
reapply_incremental_changes(source_dir, mutation)
|
||||
|
||||
t = run_transfer(config, source_dir, dest_dir, port, rsync_daemon)
|
||||
if t is None:
|
||||
invalid += 1
|
||||
elif verify:
|
||||
root = expected_received_root(dest_dir, source_dir, config["tool"])
|
||||
ok, detail = verify_transfer(source_dir, root)
|
||||
if ok:
|
||||
times.append(t)
|
||||
else:
|
||||
invalid += 1
|
||||
sys.stderr.write(f" verification FAILED ({detail}); run not counted\n")
|
||||
else:
|
||||
if t is not None:
|
||||
times.append(t)
|
||||
finally:
|
||||
if server:
|
||||
@@ -565,22 +327,15 @@ def run_benchmark(source_dir, dest_dir, configs, runs, profile_name,
|
||||
"config": config["name"],
|
||||
"tool": config["tool"],
|
||||
"profile": profile_name,
|
||||
"warm": warm,
|
||||
"runs": len(times),
|
||||
"invalid": invalid,
|
||||
"times": [round(t, 4) for t in times],
|
||||
}
|
||||
if times:
|
||||
p50 = percentile(times, 50)
|
||||
p95 = percentile(times, 95)
|
||||
entry["p50"] = round(p50, 4)
|
||||
entry["p95"] = round(p95, 4)
|
||||
entry["p50"] = round(statistics.median(times), 4)
|
||||
entry["p95"] = round(sorted(times)[int(len(times) * 0.95)], 4) if len(times) > 1 else entry["p50"]
|
||||
entry["min"] = round(min(times), 4)
|
||||
entry["max"] = round(max(times), 4)
|
||||
entry["stdev"] = round(statistics.stdev(times), 4) if len(times) > 1 else 0.0
|
||||
if measure_bytes:
|
||||
entry["throughput_mbps"] = round(
|
||||
(measure_bytes / (1024 * 1024)) / p50, 3)
|
||||
results.append(entry)
|
||||
return results
|
||||
finally:
|
||||
@@ -590,59 +345,44 @@ def run_benchmark(source_dir, dest_dir, configs, runs, profile_name,
|
||||
netem_reset()
|
||||
|
||||
|
||||
def print_table(results, measure_bytes, stats, warm):
|
||||
def print_table(results, total_bytes, random_ratio):
|
||||
"""Print results as a human-readable table grouped by profile."""
|
||||
profiles = {}
|
||||
for r in results:
|
||||
profiles.setdefault(r["profile"], []).append(r)
|
||||
|
||||
total = stats["total_bytes"]
|
||||
comp_pct = stats["compressible_bytes"] / total * 100 if total else 0
|
||||
rand_pct = stats["random_bytes"] / total * 100 if total else 0
|
||||
|
||||
for profile, entries in profiles.items():
|
||||
params = NETWORK_PROFILES.get(profile, {})
|
||||
print(f"\n{'=' * 95}")
|
||||
print(f"\n{'=' * 85}")
|
||||
print(f" Profile: {profile.upper()}")
|
||||
if params.get("rate"):
|
||||
print(f" Network: {params['rate']}, {params['delay']} +/- {params['jitter']}, loss {params['loss']}")
|
||||
else:
|
||||
print(f" Network: unlimited")
|
||||
print(f" Data: {total / (1024*1024):.1f} MB "
|
||||
f"({rand_pct:.0f}% random, {comp_pct:.0f}% compressible actual)")
|
||||
if warm:
|
||||
print(f" Mode: warm (incremental) — measured {measure_bytes / (1024*1024):.2f} MB "
|
||||
f"changed after an untimed full seed")
|
||||
else:
|
||||
print(" Mode: cold (full copy)")
|
||||
print(f"{'=' * 95}")
|
||||
print(f" Data: {total_bytes / (1024*1024):.1f} MB ({random_ratio*100:.0f}% random, {(1-random_ratio)*100:.0f}% compressible)")
|
||||
print(f"{'=' * 85}")
|
||||
|
||||
fs_entries = [e for e in entries if e.get("tool") == "fastsync"]
|
||||
rsync_entries = [e for e in entries if e.get("tool") == "rsync"]
|
||||
|
||||
header = (f" {'Config':<38} {'p50':>8} {'p95':>8} {'min':>8} {'max':>8} "
|
||||
f"{'stdev':>8} {'MB/s':>9} {'runs':>5} {'bad':>4}")
|
||||
rule = (f" {'-' * 38} {'-' * 8} {'-' * 8} {'-' * 8} {'-' * 8} "
|
||||
f"{'-' * 8} {'-' * 9} {'-' * 5} {'-' * 4}")
|
||||
|
||||
if fs_entries:
|
||||
print(f"\n FastSync:")
|
||||
print(header)
|
||||
print(rule)
|
||||
print(f" {'Config':<25} {'p50':>8} {'p95':>8} {'min':>8} {'max':>8} {'stdev':>8} {'runs':>5}")
|
||||
print(f" {'-' * 25} {'-' * 8} {'-' * 8} {'-' * 8} {'-' * 8} {'-' * 8} {'-' * 5}")
|
||||
for e in sorted(fs_entries, key=lambda x: x.get("p50", 999)):
|
||||
_print_entry(e)
|
||||
|
||||
if rsync_entries:
|
||||
print(f"\n rsync:")
|
||||
print(header)
|
||||
print(rule)
|
||||
print(f" {'Config':<25} {'p50':>8} {'p95':>8} {'min':>8} {'max':>8} {'stdev':>8} {'runs':>5}")
|
||||
print(f" {'-' * 25} {'-' * 8} {'-' * 8} {'-' * 8} {'-' * 8} {'-' * 8} {'-' * 5}")
|
||||
for e in sorted(rsync_entries, key=lambda x: x.get("p50", 999)):
|
||||
_print_entry(e)
|
||||
|
||||
if params.get("rate_bps") and fs_entries and rsync_entries:
|
||||
fs_best = min((e["p50"] for e in fs_entries if "p50" in e), default=None)
|
||||
rsync_best = min((e["p50"] for e in rsync_entries if "p50" in e), default=None)
|
||||
theoretical = measure_bytes / params["rate_bps"]
|
||||
theoretical = total_bytes / params["rate_bps"]
|
||||
if fs_best and rsync_best:
|
||||
print(f"\n Theoretical max (line rate): {theoretical:.4f}s")
|
||||
print(f" FastSync best: {fs_best:.4f}s ({theoretical/fs_best:.2f}x vs line rate)")
|
||||
@@ -652,43 +392,10 @@ def print_table(results, measure_bytes, stats, warm):
|
||||
|
||||
def _print_entry(e):
|
||||
if "p50" in e:
|
||||
tp = f"{e['throughput_mbps']:.2f}" if "throughput_mbps" in e else "N/A"
|
||||
print(f" {e['config']:<38} {e['p50']:>7.4f}s {e['p95']:>7.4f}s "
|
||||
f"{e['min']:>7.4f}s {e['max']:>7.4f}s {e['stdev']:>7.4f} "
|
||||
f"{tp:>9} {e['runs']:>5} {e.get('invalid', 0):>4}")
|
||||
print(f" {e['config']:<25} {e['p50']:>7.4f}s {e['p95']:>7.4f}s "
|
||||
f"{e['min']:>7.4f}s {e['max']:>7.4f}s {e['stdev']:>7.4f} {e['runs']:>5}")
|
||||
else:
|
||||
print(f" {e['config']:<38} {'N/A':>8} {'N/A':>8} {'N/A':>8} {'N/A':>8} "
|
||||
f"{'N/A':>8} {'N/A':>9} {e['runs']:>5} {e.get('invalid', 0):>4}")
|
||||
|
||||
|
||||
def configure_build_dirs(build_dir):
|
||||
"""Install the selected build directory and derived binary paths."""
|
||||
global BUILD_DIR, SERVER_CMD, CLIENT_CMD
|
||||
if not os.path.isabs(build_dir):
|
||||
build_dir = os.path.join(PROJECT_ROOT, build_dir)
|
||||
BUILD_DIR = os.path.abspath(build_dir)
|
||||
SERVER_CMD = [os.path.join(BUILD_DIR, "server"), "--allow-unauthenticated"]
|
||||
CLIENT_CMD = [os.path.join(BUILD_DIR, "client")]
|
||||
|
||||
|
||||
def build_project():
|
||||
"""Configure (Release) and build into the dedicated bench build dir."""
|
||||
if shutil.which("cmake") is None:
|
||||
sys.stderr.write("cmake not found in PATH; cannot build\n")
|
||||
sys.exit(1)
|
||||
os.makedirs(BUILD_DIR, exist_ok=True)
|
||||
configure = ["cmake", "-B", BUILD_DIR, "-S", PROJECT_ROOT,
|
||||
"-DCMAKE_BUILD_TYPE=Release"]
|
||||
result = subprocess.run(configure, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write("CMake configure failed:\n" + result.stdout + result.stderr + "\n")
|
||||
sys.exit(1)
|
||||
jobs = str(os.cpu_count() or 1)
|
||||
result = subprocess.run(["cmake", "--build", BUILD_DIR, "-j", jobs],
|
||||
capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write("Build failed:\n" + result.stdout + result.stderr + "\n")
|
||||
sys.exit(1)
|
||||
print(f" {e['config']:<25} {'N/A':>8} {'N/A':>8} {'N/A':>8} {'N/A':>8} {'N/A':>8} {e['runs']:>5}")
|
||||
|
||||
|
||||
def main():
|
||||
@@ -705,20 +412,12 @@ Custom network limits (--delay/--jitter/--throughput) override profiles.
|
||||
|
||||
Data mix:
|
||||
Default is ~75%% random/incompressible + ~25%% structured/compressible,
|
||||
reflecting typical real-world file sets. The actual mix is measured and
|
||||
reported. Transfers are verified (destination must match source) unless
|
||||
--no-verify is given.
|
||||
|
||||
Warm mode:
|
||||
--warm seeds the destination with an untimed full copy of a pristine base,
|
||||
then measures only the incremental transfer after modifying a few files.
|
||||
reflecting typical real-world file sets.
|
||||
|
||||
Examples:
|
||||
%(prog)s --profiles wan --runs 5
|
||||
%(prog)s --throughput 50mbit --delay 30ms --jitter 5ms
|
||||
%(prog)s --random-ratio 0.5 --size-mb 100
|
||||
%(prog)s --warm --runs 3 --no-rsync
|
||||
%(prog)s --dry-run --size-mb 4 --random-ratio 0.25
|
||||
""")
|
||||
parser.add_argument("--runs", type=int, default=3,
|
||||
help="Number of runs per config (default: 3)")
|
||||
@@ -726,8 +425,7 @@ Examples:
|
||||
choices=list(NETWORK_PROFILES.keys()),
|
||||
help="Predefined network profiles (default: unlimited)")
|
||||
parser.add_argument("--configs", nargs="+", default=None,
|
||||
help="Custom FastSync config flags (shell-quoted, e.g. "
|
||||
"\"-j -z --chunk-serialization\")")
|
||||
help="Custom FastSync config flags")
|
||||
parser.add_argument("--size-mb", type=int, default=25,
|
||||
help="Test data size in MB (default: 25)")
|
||||
parser.add_argument("--random-ratio", type=float, default=0.75,
|
||||
@@ -742,14 +440,6 @@ Examples:
|
||||
help="Custom packet loss (e.g. 1%%)")
|
||||
parser.add_argument("--no-rsync", action="store_true",
|
||||
help="Skip rsync comparison")
|
||||
parser.add_argument("--no-verify", action="store_true",
|
||||
help="Skip source/destination verification after each run")
|
||||
parser.add_argument("--warm", action="store_true",
|
||||
help="Incremental mode: seed dest first, measure only changes")
|
||||
parser.add_argument("--build-dir", default=DEFAULT_BUILD_DIR,
|
||||
help=f"Build directory (default: {DEFAULT_BUILD_DIR})")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Only generate data and report its composition, then exit")
|
||||
parser.add_argument("--progress", action="store_true",
|
||||
help="Show progress bar with ETA")
|
||||
parser.add_argument("--output", choices=["table", "json"], default="table",
|
||||
@@ -758,48 +448,12 @@ Examples:
|
||||
help="Don't clean up test data")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not 0.0 <= args.random_ratio <= 1.0:
|
||||
parser.error("--random-ratio must be between 0.0 and 1.0")
|
||||
if args.size_mb <= 0:
|
||||
parser.error("--size-mb must be positive")
|
||||
|
||||
configure_build_dirs(args.build_dir)
|
||||
|
||||
# Generate data
|
||||
source_dir = os.path.join(BENCH_DIR, "source")
|
||||
dest_dir = os.path.join(BENCH_DIR, "dest")
|
||||
stats = generate_bench_data(source_dir, args.size_mb, args.random_ratio)
|
||||
total_bytes = stats["total_bytes"]
|
||||
comp_pct = stats["compressible_bytes"] / total_bytes * 100 if total_bytes else 0
|
||||
rand_pct = stats["random_bytes"] / total_bytes * 100 if total_bytes else 0
|
||||
print(f"Generated {total_bytes / (1024*1024):.1f} MB in {stats['files']} files "
|
||||
f"({rand_pct:.0f}% random, {comp_pct:.0f}% compressible actual)",
|
||||
file=sys.stderr)
|
||||
|
||||
if args.dry_run:
|
||||
print(f"size_mb={args.size_mb} random_ratio={args.random_ratio:.4f} "
|
||||
f"total_bytes={stats['total_bytes']} "
|
||||
f"compressible_bytes={stats['compressible_bytes']} "
|
||||
f"random_bytes={stats['random_bytes']} files={stats['files']}")
|
||||
if not args.keep_data:
|
||||
shutil.rmtree(BENCH_DIR, ignore_errors=True)
|
||||
return
|
||||
|
||||
# Warm mode: keep a pristine base copy, then mutate the live source.
|
||||
base_dir = None
|
||||
measure_bytes = total_bytes
|
||||
mutation = None
|
||||
if args.warm:
|
||||
change_target = max(64 * 1024, min(int(total_bytes * 0.01), 4 * 1024 * 1024))
|
||||
mutation = apply_incremental_changes(source_dir, change_target)
|
||||
measure_bytes = mutation["changed"]
|
||||
revert_incremental_changes(source_dir, mutation)
|
||||
print(f"Warm mode: each run seeds a full copy, then measures "
|
||||
f"{measure_bytes / (1024*1024):.3f} MB of add/change deltas", file=sys.stderr)
|
||||
|
||||
# Build (Release: benchmarking a debug build is meaningless)
|
||||
print(f"Building (Release) into {BUILD_DIR}...", file=sys.stderr)
|
||||
build_project()
|
||||
# Build
|
||||
print("Building...")
|
||||
if os.system(f"cmake -B {BUILD_DIR} -S {PROJECT_ROOT} > /dev/null 2>&1") != 0:
|
||||
print("CMake configure failed"); sys.exit(1)
|
||||
if os.system(f"cmake --build {BUILD_DIR} -j$(nproc) > /dev/null 2>&1") != 0:
|
||||
print("Build failed"); sys.exit(1)
|
||||
|
||||
# Determine active profile for display
|
||||
has_custom_net = args.delay or args.jitter or args.throughput or args.loss
|
||||
@@ -819,10 +473,18 @@ Examples:
|
||||
else:
|
||||
profiles_to_run = args.profiles or ["unlimited"]
|
||||
|
||||
# Build config list (shlex so quoted/space-separated flags survive)
|
||||
# Generate data
|
||||
source_dir = os.path.join(BENCH_DIR, "source")
|
||||
dest_dir = os.path.join(BENCH_DIR, "dest")
|
||||
total_bytes = generate_bench_data(source_dir, args.size_mb, args.random_ratio)
|
||||
compressible_pct = (1 - args.random_ratio) * 100
|
||||
random_pct = args.random_ratio * 100
|
||||
print(f"Generated {total_bytes / (1024*1024):.1f} MB "
|
||||
f"({random_pct:.0f}% random, {compressible_pct:.0f}% compressible)")
|
||||
|
||||
# Build config list
|
||||
if args.configs:
|
||||
fastsync_configs = [{"name": c, "flags": shlex.split(c), "tool": "fastsync"}
|
||||
for c in args.configs]
|
||||
fastsync_configs = [{"name": c, "flags": c.split(), "tool": "fastsync"} for c in args.configs]
|
||||
else:
|
||||
fastsync_configs = list(FASTSYNC_CONFIGS)
|
||||
|
||||
@@ -834,21 +496,14 @@ Examples:
|
||||
total_runs = len(configs) * args.runs * len(profiles_to_run)
|
||||
progress = Progress(total_runs, "Benchmarking") if args.progress else None
|
||||
if progress:
|
||||
print(f"Running {total_runs} transfers...", file=sys.stderr)
|
||||
print(f"Running {total_runs} transfers...")
|
||||
|
||||
all_results = []
|
||||
try:
|
||||
for profile in profiles_to_run:
|
||||
results = run_benchmark(source_dir, dest_dir, configs, args.runs, profile,
|
||||
measure_bytes, verify=not args.no_verify,
|
||||
warm=args.warm, mutation=mutation,
|
||||
progress=progress)
|
||||
results = run_benchmark(source_dir, dest_dir, configs, args.runs, profile, progress)
|
||||
all_results.extend(results)
|
||||
except RuntimeError as exc:
|
||||
sys.stderr.write(f"error: {exc}\n")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
netem_reset()
|
||||
if not args.keep_data:
|
||||
shutil.rmtree(BENCH_DIR, ignore_errors=True)
|
||||
|
||||
@@ -856,7 +511,7 @@ Examples:
|
||||
if args.output == "json":
|
||||
print(json.dumps(all_results, indent=2))
|
||||
else:
|
||||
print_table(all_results, measure_bytes, stats, args.warm)
|
||||
print_table(all_results, total_bytes, args.random_ratio)
|
||||
print()
|
||||
|
||||
|
||||
|
||||
+1
-2
@@ -1,11 +1,10 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"instructions": ["AGENTS.md"],
|
||||
"permission": {
|
||||
"bash": {
|
||||
"*": "allow",
|
||||
"git push origin main": "deny",
|
||||
"git push main": "deny"
|
||||
"git push main": "ask"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
[pytest]
|
||||
; Fast integration subset run on every pull request (see .gitea/workflows/ci.yaml).
|
||||
markers =
|
||||
ci: fast, representative integration tests run on the PR CI gate
|
||||
setpriv: privilege-dependent tests (drop to an unprivileged user); excluded
|
||||
from CI because their result depends on the runner/container uid and the
|
||||
host mount permissions, but run locally as root
|
||||
daemon_detach: real double-fork backgrounding path (--daemon without
|
||||
--no-detach); slower/fragile, so it runs in the full suite but not the
|
||||
fast PR gate
|
||||
@@ -3,35 +3,11 @@
|
||||
}:
|
||||
|
||||
pkgs.mkShell {
|
||||
# Development shell for FastSync. Provides the host-side toolchain needed to
|
||||
# build, lint, unit-test, integration-test and benchmark the project.
|
||||
# It deliberately does NOT build on entry: run the CMake commands in README.md
|
||||
# (or use the CI Docker image for exact CI parity).
|
||||
nativeBuildInputs = with pkgs; [
|
||||
# build
|
||||
gcc
|
||||
cmake
|
||||
gnumake
|
||||
pkg-config
|
||||
# lint / static analysis (matches CI)
|
||||
clang-tools # clang-format
|
||||
cppcheck
|
||||
# tests
|
||||
(python3.withPackages (ps: with ps; [ pytest pytest-xdist psutil ]))
|
||||
openssh # SSH transport integration tests
|
||||
# debugging
|
||||
gdb
|
||||
valgrind
|
||||
# coverage
|
||||
lcov
|
||||
# benchmark tooling
|
||||
rsync
|
||||
iproute2 # tc/netem for network shaping
|
||||
# misc
|
||||
git
|
||||
curl
|
||||
nodejs
|
||||
nixpkgs-fmt
|
||||
docker
|
||||
tea
|
||||
];
|
||||
@@ -39,21 +15,14 @@ pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
zstd
|
||||
openssl
|
||||
(python3.withPackages (ps: with ps; [ pytest ]))
|
||||
];
|
||||
|
||||
# The CMake configure step fetches xxHash via FetchContent, which needs
|
||||
# network access; NIX_ENFORCE_PURITY must be off so the sandbox does not block.
|
||||
NIX_ENFORCE_PURITY = 0;
|
||||
|
||||
shellHook = ''
|
||||
export NIX_ENFORCE_PURITY=0
|
||||
# Make an existing build tree available on PATH, but never build here.
|
||||
if [ -d "$PWD/build" ]; then
|
||||
export PATH="$PWD/build:$PATH"
|
||||
fi
|
||||
echo "FastSync dev shell ready."
|
||||
echo " Build: cmake -B build -S . && cmake --build build -j\$(nproc)"
|
||||
echo " Unit: ./build/tests"
|
||||
echo " CI parity: docker run --rm --user \"\$(id -u):\$(id -g)\" -v \"\$PWD:/workspace\" -w /workspace gitea.tap-tap.win/taptap/fastsync-ci:v10 ..."
|
||||
cmake -B build
|
||||
export PATH="$PWD/build:$PATH"
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
#include "change_list.h"
|
||||
#include "utils.h"
|
||||
#include <limits.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
|
||||
/* Itemize code emitted for a transferred regular file.
|
||||
*
|
||||
* Layout (rsync-compatible 11-char item): `>f` marks a regular file that was
|
||||
* transferred to the remote host; the trailing nine markers are, in order,
|
||||
* c(hecksum) s(ize) t(ime) p(erms) o(wner) g(roup) u(ser/acl) a(ttrs) x(attrs).
|
||||
* Every marker is `+` (FastSync does not compare each attribute on the
|
||||
* receiving side, so a sent file is reported as fully updated). Files that
|
||||
* are already up to date print no line at all, matching rsync's single -i
|
||||
* which only itemizes changes.
|
||||
*
|
||||
* Because the scanner only yields regular-file transfer candidates, `>d`
|
||||
* (directory) lines are never produced; directories are not transferred as
|
||||
* items by FastSync. */
|
||||
#define ITEMIZE_SENT_FILE ">f+++++++++"
|
||||
|
||||
typedef struct {
|
||||
char* data;
|
||||
size_t length;
|
||||
size_t capacity;
|
||||
} StrBuf;
|
||||
|
||||
static void strbuf_free(StrBuf* buf) {
|
||||
if (buf == NULL)
|
||||
return;
|
||||
free(buf->data);
|
||||
buf->data = NULL;
|
||||
buf->length = 0;
|
||||
buf->capacity = 0;
|
||||
}
|
||||
|
||||
static bool strbuf_reserve(StrBuf* buf, size_t extra) {
|
||||
if (buf->length > SIZE_MAX - extra - 1)
|
||||
return false;
|
||||
size_t need = buf->length + extra + 1;
|
||||
if (need <= buf->capacity)
|
||||
return true;
|
||||
size_t capacity = buf->capacity > 0 ? buf->capacity : 32;
|
||||
while (capacity < need) {
|
||||
if (capacity > SIZE_MAX / 2) {
|
||||
capacity = need;
|
||||
break;
|
||||
}
|
||||
capacity *= 2;
|
||||
}
|
||||
char* grown = realloc(buf->data, capacity);
|
||||
if (!grown)
|
||||
return false;
|
||||
buf->data = grown;
|
||||
buf->capacity = capacity;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool strbuf_append_char(StrBuf* buf, char c) {
|
||||
if (!strbuf_reserve(buf, 1))
|
||||
return false;
|
||||
buf->data[buf->length++] = c;
|
||||
buf->data[buf->length] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool strbuf_append(StrBuf* buf, const char* text) {
|
||||
if (text == NULL)
|
||||
return true;
|
||||
size_t length = strlen(text);
|
||||
if (!strbuf_reserve(buf, length))
|
||||
return false;
|
||||
memcpy(buf->data + buf->length, text, length);
|
||||
buf->length += length;
|
||||
buf->data[buf->length] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool strbuf_append_ull(StrBuf* buf, unsigned long long value) {
|
||||
char digits[32];
|
||||
int written = snprintf(digits, sizeof(digits), "%llu", value);
|
||||
if (written < 0 || (size_t)written >= sizeof(digits))
|
||||
return false;
|
||||
return strbuf_append(buf, digits);
|
||||
}
|
||||
|
||||
static bool strbuf_append_longlong(StrBuf* buf, long long value) {
|
||||
char digits[32];
|
||||
int written = snprintf(digits, sizeof(digits), "%lld", value);
|
||||
if (written < 0 || (size_t)written >= sizeof(digits))
|
||||
return false;
|
||||
return strbuf_append(buf, digits);
|
||||
}
|
||||
|
||||
bool change_list_enabled(const Config* config) {
|
||||
return config != NULL && (config->itemize_changes || config->out_format != NULL ||
|
||||
(config->log_file != NULL && config->log_file_format != NULL));
|
||||
}
|
||||
|
||||
char* change_render_itemize(const ChangeEvent* event) {
|
||||
if (event == NULL || event->decision != CHANGE_SENT)
|
||||
return str_dup("");
|
||||
const char* code = event->is_directory ? ">d+++++++++" : ITEMIZE_SENT_FILE;
|
||||
StrBuf line = {0};
|
||||
bool ok = strbuf_append(&line, code) && strbuf_append(&line, " ") &&
|
||||
strbuf_append(&line, event->path != NULL ? event->path : "");
|
||||
if (!ok) {
|
||||
strbuf_free(&line);
|
||||
return NULL;
|
||||
}
|
||||
return line.data;
|
||||
}
|
||||
|
||||
static const char* leaf_name(const char* path) {
|
||||
if (path == NULL)
|
||||
return "";
|
||||
const char* slash = strrchr(path, '/');
|
||||
return slash != NULL && slash[1] != '\0' ? slash + 1 : path;
|
||||
}
|
||||
|
||||
char* change_render_format(const char* format, const ChangeEvent* event) {
|
||||
if (format == NULL)
|
||||
return NULL;
|
||||
StrBuf line = {0};
|
||||
bool ok = true;
|
||||
for (const char* p = format; *p != '\0' && ok;) {
|
||||
if (*p != '%') {
|
||||
ok = strbuf_append_char(&line, *p);
|
||||
p++;
|
||||
continue;
|
||||
}
|
||||
char token = p[1];
|
||||
if (token == '\0') {
|
||||
ok = strbuf_append_char(&line, '%');
|
||||
break;
|
||||
}
|
||||
switch (token) {
|
||||
case '%':
|
||||
ok = strbuf_append_char(&line, '%');
|
||||
break;
|
||||
case 'f':
|
||||
ok = strbuf_append(&line, event->path != NULL ? event->path : "");
|
||||
break;
|
||||
case 'n':
|
||||
ok = strbuf_append(&line, leaf_name(event->path));
|
||||
break;
|
||||
case 'l':
|
||||
ok = strbuf_append_ull(&line, event->size);
|
||||
break;
|
||||
case 'b':
|
||||
ok = strbuf_append_ull(&line, event->bytes_sent);
|
||||
break;
|
||||
case 'M':
|
||||
ok = strbuf_append_longlong(&line, (long long)event->mtime_sec);
|
||||
break;
|
||||
default:
|
||||
/* Unknown escape sequences are preserved verbatim. */
|
||||
ok = strbuf_append_char(&line, '%') && strbuf_append_char(&line, token);
|
||||
break;
|
||||
}
|
||||
p += 2;
|
||||
}
|
||||
if (!ok) {
|
||||
strbuf_free(&line);
|
||||
return NULL;
|
||||
}
|
||||
if (line.data == NULL) {
|
||||
line.data = str_dup("");
|
||||
if (!line.data)
|
||||
return NULL;
|
||||
}
|
||||
return line.data;
|
||||
}
|
||||
|
||||
/* Format a mode as an `ls -l` permission string, e.g. `-rw-r--r--`. */
|
||||
static void mode_to_ls_string(mode_t mode, char out[11]) {
|
||||
out[0] = S_ISDIR(mode) ? 'd'
|
||||
: S_ISLNK(mode) ? 'l'
|
||||
: S_ISCHR(mode) ? 'c'
|
||||
: S_ISBLK(mode) ? 'b'
|
||||
: S_ISFIFO(mode) ? 'p'
|
||||
: S_ISSOCK(mode) ? 's'
|
||||
: '-';
|
||||
mode_t bits = mode & 07777;
|
||||
out[1] = (bits & S_IRUSR) ? 'r' : '-';
|
||||
out[2] = (bits & S_IWUSR) ? 'w' : '-';
|
||||
out[3] = (bits & S_IXUSR) ? (bits & S_ISUID ? 's' : 'x') : (bits & S_ISUID ? 'S' : '-');
|
||||
out[4] = (bits & S_IRGRP) ? 'r' : '-';
|
||||
out[5] = (bits & S_IWGRP) ? 'w' : '-';
|
||||
out[6] = (bits & S_IXGRP) ? (bits & S_ISGID ? 's' : 'x') : (bits & S_ISGID ? 'S' : '-');
|
||||
out[7] = (bits & S_IROTH) ? 'r' : '-';
|
||||
out[8] = (bits & S_IWOTH) ? 'w' : '-';
|
||||
out[9] = (bits & S_IXOTH) ? (bits & S_ISVTX ? 't' : 'x') : (bits & S_ISVTX ? 'T' : '-');
|
||||
out[10] = '\0';
|
||||
}
|
||||
|
||||
char* change_render_list_line(mode_t mode, unsigned long long size, time_t mtime,
|
||||
const char* path) {
|
||||
char permission[11];
|
||||
mode_to_ls_string(mode, permission);
|
||||
char date[32];
|
||||
struct tm broken_down;
|
||||
if (localtime_r(&mtime, &broken_down) != NULL) {
|
||||
if (strftime(date, sizeof(date), "%Y/%m/%d %H:%M:%S", &broken_down) == 0)
|
||||
snprintf(date, sizeof(date), "?");
|
||||
} else {
|
||||
snprintf(date, sizeof(date), "?");
|
||||
}
|
||||
StrBuf line = {0};
|
||||
char size_field[32];
|
||||
int written = snprintf(size_field, sizeof(size_field), "%llu", size);
|
||||
if (written < 0 || (size_t)written >= sizeof(size_field)) {
|
||||
strbuf_free(&line);
|
||||
return NULL;
|
||||
}
|
||||
bool ok = strbuf_append(&line, permission) && strbuf_append_char(&line, ' ') &&
|
||||
strbuf_append(&line, size_field) && strbuf_append_char(&line, ' ') &&
|
||||
strbuf_append(&line, date) && strbuf_append_char(&line, ' ') &&
|
||||
strbuf_append(&line, path != NULL ? path : "");
|
||||
if (!ok) {
|
||||
strbuf_free(&line);
|
||||
return NULL;
|
||||
}
|
||||
return line.data;
|
||||
}
|
||||
|
||||
static void print_escaped_line(FILE* stream, const char* line, bool eight_bit_output) {
|
||||
char* escaped = output_escape(line, eight_bit_output);
|
||||
if (escaped != NULL) {
|
||||
fprintf(stream, "%s\n", escaped);
|
||||
free(escaped);
|
||||
} else {
|
||||
fprintf(stream, "%s\n", line);
|
||||
}
|
||||
fflush(stream);
|
||||
}
|
||||
|
||||
void change_emit(const Config* config, const ChangeEvent* event) {
|
||||
if (event == NULL || !change_list_enabled(config))
|
||||
return;
|
||||
if (event->decision == CHANGE_UP_TO_DATE)
|
||||
return;
|
||||
bool to_stdout = config->itemize_changes || config->out_format != NULL;
|
||||
bool to_log = config->log_file != NULL && config->log_file_format != NULL;
|
||||
if (to_stdout) {
|
||||
char* line = config->out_format != NULL ? change_render_format(config->out_format, event)
|
||||
: change_render_itemize(event);
|
||||
if (line != NULL) {
|
||||
print_escaped_line(stdout, line, config->eight_bit_output);
|
||||
free(line);
|
||||
}
|
||||
}
|
||||
if (to_log) {
|
||||
char* line = change_render_format(config->log_file_format, event);
|
||||
if (line != NULL) {
|
||||
print_escaped_line(config->log_file, line, config->eight_bit_output);
|
||||
free(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static bool format_uses_mtime(const char* format) {
|
||||
if (format == NULL)
|
||||
return false;
|
||||
/* Mirror change_render_format's tokenizer: "%%" is a literal percent (so
|
||||
* "%%M" does NOT expand %M) and unknown "%X" escapes consume both chars.
|
||||
* This keeps the optional stat() fallback below in step with the renderer. */
|
||||
for (const char* p = format; *p != '\0';) {
|
||||
if (*p != '%') {
|
||||
p++;
|
||||
continue;
|
||||
}
|
||||
char token = p[1];
|
||||
if (token == '\0')
|
||||
break;
|
||||
if (token == 'M')
|
||||
return true;
|
||||
p += 2;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void change_emit_file_sent(const Config* config, const File* file) {
|
||||
if (file == NULL || !change_list_enabled(config))
|
||||
return;
|
||||
ChangeEvent event;
|
||||
memset(&event, 0, sizeof(event));
|
||||
/* The displayed path is the one transmitted (with -R + --files-from this is
|
||||
the bare relative destination path); the metadata fallback below still
|
||||
stats the local absolute path. */
|
||||
event.path = file_wire_path(file);
|
||||
event.decision = CHANGE_SENT;
|
||||
event.is_directory = false;
|
||||
event.size = file->data != NULL ? file->data->size : 0;
|
||||
/* FastSync has no wire-byte counter yet, so %b reports the source length
|
||||
* that had to be delivered (always equal to %l); the actual bytes written
|
||||
* to the socket (compressed/delta) are not measured. */
|
||||
event.bytes_sent = event.size;
|
||||
if (file->metadata != NULL) {
|
||||
event.mtime_sec = file->metadata->mtime_sec;
|
||||
} else if (format_uses_mtime(config->out_format) || format_uses_mtime(config->log_file_format)) {
|
||||
/* Best-effort fallback for %M when no metadata was captured (no -M): the
|
||||
* path is stat()ed just to fill the field, and any failure leaves 0. */
|
||||
struct stat st;
|
||||
if (file->path != NULL && stat(file->path, &st) == 0)
|
||||
event.mtime_sec = st.st_mtime;
|
||||
}
|
||||
change_emit(config, &event);
|
||||
}
|
||||
|
||||
/* Build and emit a CHANGE_SENT event for an explicit directory entry (-d). */
|
||||
void change_emit_dir_sent(const Config* config, const File* file) {
|
||||
if (file == NULL || !change_list_enabled(config))
|
||||
return;
|
||||
ChangeEvent event;
|
||||
memset(&event, 0, sizeof(event));
|
||||
event.path = file_wire_path(file);
|
||||
event.decision = CHANGE_SENT;
|
||||
event.is_directory = true;
|
||||
event.size = 0;
|
||||
event.bytes_sent = 0;
|
||||
if (file->metadata != NULL)
|
||||
event.mtime_sec = file->metadata->mtime_sec;
|
||||
change_emit(config, &event);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
#ifndef CHANGE_LIST_H
|
||||
#define CHANGE_LIST_H
|
||||
|
||||
#include "config.h"
|
||||
#include "file_types.h"
|
||||
#include <stdbool.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
|
||||
/*
|
||||
* Shared per-file change-event / output model (rsync --itemize-changes,
|
||||
* --out-format, --log-file-format, and --list-only all render from here).
|
||||
*
|
||||
* FastSync is a push-style tool: the client sends files from the source tree
|
||||
* to a server that writes them under the destination root. Events are
|
||||
* emitted by whichever code path decides a file's fate (the single-threaded
|
||||
* send loop and the `-m` sender thread both call the same per-file sender), so
|
||||
* all change events are emitted by exactly one thread and itemize/out-format
|
||||
* lines never interleave with each other. They may still interleave with
|
||||
* legacy log messages (log.c) that share the same stdout/log-file stream.
|
||||
*/
|
||||
|
||||
typedef enum {
|
||||
CHANGE_SENT, /* file data (full or delta) was transmitted */
|
||||
CHANGE_UP_TO_DATE, /* receiver already had an identical file; skipped */
|
||||
} ChangeDecision;
|
||||
|
||||
typedef struct {
|
||||
const char* path; /* full source path */
|
||||
ChangeDecision decision;
|
||||
bool is_directory;
|
||||
unsigned long long size; /* source file length in bytes */
|
||||
/* The number of bytes reported for a sent file. FastSync has no wire-byte
|
||||
* counter, so this is always the source length (== size / %l); actual
|
||||
* post-compression/delta bytes on the wire are not counted. */
|
||||
unsigned long long bytes_sent;
|
||||
time_t mtime_sec; /* 0 when unknown */
|
||||
} ChangeEvent;
|
||||
|
||||
/* True when any output mode is active and per-file events matter. */
|
||||
bool change_list_enabled(const Config* config);
|
||||
|
||||
/* Render the rsync-style itemize line for a transferred file:
|
||||
* `>f+++++++++ <path>`
|
||||
* The 11-char code is `>f` (regular file transferred to the remote host)
|
||||
* followed by c/s/t/p/o/g/u/a/x markers that are all `+` (value will be set
|
||||
* / differs) because FastSync does not separately compare checksums, size,
|
||||
* mtime, perms, owner, group, uid, acl, or xattr on the receiving side, so a
|
||||
* sent file is reported as fully updated. Up-to-date files print no line
|
||||
* (rsync single `-i` only shows changes). Caller frees the result. */
|
||||
char* change_render_itemize(const ChangeEvent* event);
|
||||
|
||||
/* Expand an --out-format/--log-file-format template. Tokens:
|
||||
* %f full source path %b "bytes sent" == the source length (%l);
|
||||
* %n leaf (base) name actual post-compression/delta wire bytes
|
||||
* %l file length in bytes are not counted
|
||||
* %M mtime in whole seconds %% a literal percent sign
|
||||
* Unknown %X sequences are preserved verbatim. Caller frees the result. */
|
||||
char* change_render_format(const char* format, const ChangeEvent* event);
|
||||
|
||||
/* Render one --list-only long-listing entry:
|
||||
* `-rw-r--r-- 12 2026/09/06 10:00:00 <path>`
|
||||
* (ls -l style columns; mtime in the local time zone). Caller frees it. */
|
||||
char* change_render_list_line(mode_t mode, unsigned long long size, time_t mtime, const char* path);
|
||||
|
||||
/* Emit an event to every active destination:
|
||||
* stdout: --itemize-changes line, or the --out-format expansion when set;
|
||||
* log file: the --log-file-format expansion (requires --log-file).
|
||||
* CHANGE_UP_TO_DATE events produce no output. */
|
||||
void change_emit(const Config* config, const ChangeEvent* event);
|
||||
|
||||
/* Build and emit a CHANGE_SENT event for a file the client just sent. */
|
||||
void change_emit_file_sent(const Config* config, const File* file);
|
||||
|
||||
/* Build and emit a CHANGE_SENT event for an explicit directory entry (-d). */
|
||||
void change_emit_dir_sent(const Config* config, const File* file);
|
||||
|
||||
#endif
|
||||
+242
-2267
File diff suppressed because it is too large
Load Diff
+280
-2499
File diff suppressed because it is too large
Load Diff
@@ -4,27 +4,12 @@
|
||||
#include "chunk.h"
|
||||
#include "config.h"
|
||||
#include "transport_tcp.h"
|
||||
#include <signal.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
/* Set ONLY by the client's SIGINT/SIGTERM handler (async-signal-safe: the
|
||||
* handler stores 1 and does nothing else). The send loops poll it via
|
||||
* client_abort_pending() and, when set, best-effort send STATUS_ABORT so the
|
||||
* receiver can clean up before the client exits. */
|
||||
extern volatile sig_atomic_t client_abort_requested;
|
||||
bool client_abort_pending(void);
|
||||
/* Arm/disarm abort handling around the network phase. While disarmed, a
|
||||
* SIGINT/SIGTERM takes the default action (immediate termination) so local-only
|
||||
* modes are not left unresponsive. Defined in client_cli.c. */
|
||||
void client_set_abort_armed(bool armed);
|
||||
extern char *server_host;
|
||||
extern int server_port;
|
||||
|
||||
/* Both sender entry points BORROW `config` for the duration of the call; they
|
||||
* never free it, and the caller retains ownership (freeing it with
|
||||
* config_delete() once the call returns). */
|
||||
int send_files(Config* config);
|
||||
int send_files_multithreaded(Config** config);
|
||||
/* Phase 6 residual-batch (client-only). See client_send.c. */
|
||||
int write_batch_from_source(const Config* config, const char* batch_path);
|
||||
int apply_batch_to_dest(const Config* config, const char* batch_path, const char* dest_root);
|
||||
int send_chunk(Client *client, Chunk *chunk, Config *config);
|
||||
int send_files(Config *config);
|
||||
int send_files_multithreaded(Config *config);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
#include "client_validation.h"
|
||||
#include "log.h"
|
||||
#include "usage.h"
|
||||
#include "utils.h"
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
/* Validate config after parsing. Returns true if valid. */
|
||||
bool validate_config(const Config* config) {
|
||||
/* Phase 6 residual-batch modes relax the normal source+destination pair: the
|
||||
batch driver is local and needs only what it consumes. --only-write-batch
|
||||
emits a batch from the source (no destination, no server);
|
||||
--read-batch applies a batch to the destination (no source, no server);
|
||||
--write-batch runs the live transfer AND emits a batch, so it keeps the
|
||||
full pair. */
|
||||
bool write_batch = config->write_batch != NULL;
|
||||
bool only_write_batch = config->only_write_batch != NULL;
|
||||
bool read_batch = config->read_batch != NULL;
|
||||
if ((write_batch && only_write_batch) || (write_batch && read_batch) ||
|
||||
(only_write_batch && read_batch)) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--write-batch, --only-write-batch, and --read-batch are mutually exclusive");
|
||||
return false;
|
||||
}
|
||||
/* A dry-run of a local batch apply is not meaningful: --read-batch bypasses
|
||||
the client-side scan/server decision entirely, so dry-run would have no
|
||||
wire state to report (and must not be used as a mutation escape hatch).
|
||||
--only-write-batch likewise never contacts a receiver. --write-batch DOES
|
||||
run a live transfer but additionally mutates the filesystem by emitting the
|
||||
batch file, so a dry-run must not write it either. Reject all three up
|
||||
front instead of silently ignoring --dry-run. */
|
||||
if (config->dry_run && (read_batch || only_write_batch || write_batch)) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--dry-run cannot be combined with --read-batch, --only-write-batch, or "
|
||||
"--write-batch; a dry-run must not mutate anything, including batch files");
|
||||
return false;
|
||||
}
|
||||
if (read_batch) {
|
||||
if (!config->receive_root_directory) {
|
||||
log_message(LOG_LEVEL_ERROR, "--read-batch requires a destination directory");
|
||||
print_usage();
|
||||
return false;
|
||||
}
|
||||
} else if (only_write_batch) {
|
||||
if (!config->send_directory) {
|
||||
log_message(LOG_LEVEL_ERROR, "--only-write-batch requires a source directory");
|
||||
print_usage();
|
||||
return false;
|
||||
}
|
||||
} else if (!config->send_directory || !config->receive_root_directory) {
|
||||
log_message(LOG_LEVEL_ERROR, "source and destination directories are required");
|
||||
print_usage();
|
||||
return false;
|
||||
}
|
||||
if (config->compression_threads > 0 && !config->use_compression) {
|
||||
log_message(LOG_LEVEL_ERROR, "--compress-threads requires compression (-c or -z)");
|
||||
return false;
|
||||
}
|
||||
if (config->transport == TRANSPORT_SSH && config->use_sendfile) {
|
||||
log_message(LOG_LEVEL_ERROR, "-f/--sendfile is not supported with SSH transport");
|
||||
return false;
|
||||
}
|
||||
/* -4 and -6 are mutually exclusive: a socket address family cannot be both. */
|
||||
if (config->ipv4 && config->ipv6) {
|
||||
log_message(LOG_LEVEL_ERROR, "-4/--ipv4 and -6/--ipv6 are mutually exclusive");
|
||||
return false;
|
||||
}
|
||||
if (config->log_file_format && !config->log_file) {
|
||||
log_message(LOG_LEVEL_ERROR, "--log-file-format requires --log-file");
|
||||
return false;
|
||||
}
|
||||
if (config->use_tls) {
|
||||
if (!config->tls_cert || !config->tls_key || !config->tls_ca) {
|
||||
log_message(LOG_LEVEL_ERROR, "--tls requires --cert, --key, and --ca");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/* Daemon credentials (A7, protocol 2.19.0): a --password-file would send the
|
||||
username in the clear and derive a SCRAM proof a network sniffer could
|
||||
attack offline, so it is only allowed over TLS (which itself mandates a
|
||||
verified --cert/--key/--ca set above) or to a loopback destination. A
|
||||
remote plaintext daemon is refused here, before any network I/O. */
|
||||
if (config->password_file && !config->use_tls && !utils_host_is_loopback(config->server_host)) {
|
||||
log_message(LOG_LEVEL_ERROR, "sending daemon credentials to a non-local server requires --tls");
|
||||
return false;
|
||||
}
|
||||
/* Every cross-field invariant the receiver enforces lives in one shared
|
||||
predicate so the client and the server can never disagree. The client
|
||||
reports the specific reason here, before any network I/O. */
|
||||
const char* invariants_error = config_invariants_error(config);
|
||||
if (invariants_error) {
|
||||
log_message(LOG_LEVEL_ERROR, "%s", invariants_error);
|
||||
return false;
|
||||
}
|
||||
/* --protocol: FastSync has exactly one wire format, so the forced version
|
||||
must equal the current PROTOCOL_VERSION exactly. Rejected here, before any
|
||||
network I/O, rather than letting the server hit its own mismatch check. */
|
||||
if (strcmp(config->version, PROTOCOL_VERSION) != 0) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--protocol must be %s (FastSync supports only its current wire "
|
||||
"protocol version and cannot speak an older or virtual one)",
|
||||
PROTOCOL_VERSION);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
#ifndef CLIENT_VALIDATION_H
|
||||
#define CLIENT_VALIDATION_H
|
||||
|
||||
#include "config.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
bool validate_config(const Config* config);
|
||||
|
||||
#endif
|
||||
+74
-1613
File diff suppressed because it is too large
Load Diff
+8
-190
@@ -2,208 +2,26 @@
|
||||
#define SCANNER_H
|
||||
|
||||
#include "chunk.h"
|
||||
#include "file_list.h"
|
||||
#include "filter.h"
|
||||
#include "hardlink.h"
|
||||
#include "protocol.h"
|
||||
#include "queue.h"
|
||||
#include "stop_condition.h"
|
||||
#include <dirent.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdatomic.h>
|
||||
#include <sys/types.h>
|
||||
#include <threads.h>
|
||||
|
||||
/* Upper bound on the configurable parallel scanner worker count (--threads=N):
|
||||
* keeps one transfer from spawning an unbounded pool on a very large machine. */
|
||||
#define MAX_SCANNER_THREADS 256
|
||||
|
||||
typedef struct {
|
||||
Queue *directories;
|
||||
DIR *current_dir;
|
||||
char *current_path;
|
||||
bool use_metadata;
|
||||
/* Phase 4 metadata capture: -U/--atimes and -N/--crtimes tell the scanner to
|
||||
* capture the source access / birth time into each entry's FileMetadata. */
|
||||
bool preserve_atimes;
|
||||
bool preserve_crtimes;
|
||||
/* Phase 4 xattrs: when preserve_xattrs || preserve_acls is set the scanner
|
||||
* captures each regular file's whitelisted xattr set onto the File. */
|
||||
bool preserve_xattrs;
|
||||
bool preserve_acls;
|
||||
unsigned long long chunk_size;
|
||||
char** exclude_patterns;
|
||||
char **exclude_patterns;
|
||||
int exclude_count;
|
||||
char** include_patterns;
|
||||
char **include_patterns;
|
||||
int include_count;
|
||||
unsigned long long max_size;
|
||||
unsigned long long min_size;
|
||||
int max_depth;
|
||||
int num_threads;
|
||||
bool follow_symlinks;
|
||||
bool copy_links;
|
||||
bool safe_links;
|
||||
bool copy_unsafe_links;
|
||||
/* Phase 4 symlink-trust sender options: -k/--copy-dirlinks (dereference a
|
||||
* symlink to a directory as a directory, keeping symlinks-to-files as
|
||||
* symlinks) and --munge-links (rewrite each transmitted symlink target with a
|
||||
* marker; escaping targets are never transmitted). Both are client/sender
|
||||
* side only and never serialized to the wire (keep_dirlinks is the
|
||||
* receiver-side counterpart). */
|
||||
bool copy_dirlinks;
|
||||
bool munge_links;
|
||||
bool checksum;
|
||||
bool one_file_system;
|
||||
/* Phase 4 special/devices: whether device nodes (--devices) and special files
|
||||
* (--specials) are preserved via recreation, and whether --copy-devices
|
||||
* copies a device's content as an ordinary regular file. */
|
||||
bool preserve_devices;
|
||||
bool preserve_specials;
|
||||
bool copy_devices;
|
||||
/* Phase 2 (files-from / filter layer). All pointers are shared read-only
|
||||
* across scanner instances and worker threads; ownership stays with the
|
||||
* caller (client_send). */
|
||||
const FileListSet* file_list; /* --files-from allow-set, or NULL */
|
||||
const FilterRuleList* base_filters; /* command-line + -C rules, or NULL */
|
||||
bool per_dir_filters; /* -F: read .rsync-filter per directory */
|
||||
bool dirs; /* -d/--dirs: transfer dir entries, no recursion */
|
||||
bool relative; /* -R/--relative (dest rel paths, with --files-from) */
|
||||
/* --prune-empty-dirs (long only): in --dirs mode an empty source directory's
|
||||
explicit entry is omitted from the transfer file list (so nothing is
|
||||
created at the destination and it can be pruned by --delete); explicitly
|
||||
--files-from-listed directories always pass through. Recursive transfers
|
||||
never emit empty directories, so the flag has no additional effect there. */
|
||||
bool prune_empty_dirs;
|
||||
/* Delete-excluded protection sink (optional): when non-NULL the scanner
|
||||
* appends the destination-relative path of every entry it prunes because a
|
||||
* USER SELECTION rule excluded it (--filter/-C/per-dir rules, the legacy
|
||||
* --exclude/--include layer, and --max-size/--min-size). The sender turns
|
||||
* this list into the manifest's protected prefixes so `--delete` leaves the
|
||||
* destination mirror of excluded source paths alone (rsync's default), and
|
||||
* empties it when --delete-excluded opts back into deleting them. NOT
|
||||
* recorded for --files-from subset pruning (whose delete semantics stay
|
||||
* keep-set-only) or for -R/--files-from relative wire paths. When
|
||||
* `excluded_mutex` is non-NULL it is taken around every append (the parallel
|
||||
* scanner shares one list across its worker threads). */
|
||||
ArrayList* excluded_paths;
|
||||
mtx_t* excluded_mutex;
|
||||
/* --ignore-errors: an unreadable directory during the scan is recorded as an
|
||||
* I/O error and skipped instead of aborting the scan. Client-only. */
|
||||
bool ignore_io_errors;
|
||||
/* --ignore-missing-args (implied by --delete-missing-args): an explicitly
|
||||
* --files-from-listed entry that does not exist under the source is skipped
|
||||
* instead of failing (the --dirs generator is the only scanner path that
|
||||
* observes a listed-but-missing entry). */
|
||||
bool ignore_missing_args;
|
||||
/* --hard-links (-H): shared, mutable (mutex-guarded) link-group detection
|
||||
* table, NULL when -H is off. Owned by the caller (client_send), shared
|
||||
* read-only here; the parallel scanner passes it unchanged to every worker so
|
||||
* one table detects every group across all subdirectories. */
|
||||
HardLinkTable* hardlinks;
|
||||
/* Phase 6: optional sender stop deadline. When non-NULL the scanner checks
|
||||
* it at natural loop boundaries and stops emitting chunks once reached
|
||||
* (without marking the scan as failed), so a busy scan itself stops early.
|
||||
* Client-only, never serialized to the wire. */
|
||||
const StopCondition* stop_condition;
|
||||
/* P7 Wave D (protocol 2.17.0): directory-time capture sink. When
|
||||
* `capture_dir_times` is true the recursive scan appends one is_dir File
|
||||
* (with metadata, no payload) per source directory it traverses to
|
||||
* `dir_entries`, so the sender can transmit trailing STATUS_DIR_TIMES
|
||||
* frame(s) and the receiver can apply directory mtimes AFTER all children
|
||||
* are written. `dir_entries_mutex` (optional) guards the list
|
||||
* for the parallel scanner's shared worker threads; the caller owns both.
|
||||
* The --dirs generator does not use this (its directory entries carry their
|
||||
* metadata inline through STATUS_MKDIR). */
|
||||
bool capture_dir_times;
|
||||
ArrayList* dir_entries;
|
||||
mtx_t* dir_entries_mutex;
|
||||
} ScannerOptions;
|
||||
|
||||
/* Internal per-scanner filter state. FilterNode chains represent the ordered
|
||||
* per-directory .rsync-filter rules that apply below a directory. */
|
||||
typedef struct FilterNode FilterNode;
|
||||
|
||||
typedef struct {
|
||||
/* Scan inputs, copied once at create time. Everything that is also a
|
||||
ScannerOptions field lives here (with the normalized chunk_size); only
|
||||
scanner-owned bookkeeping stays as direct members below. */
|
||||
ScannerOptions options;
|
||||
Queue* directories;
|
||||
DIR* current_dir;
|
||||
char* current_path;
|
||||
int current_depth;
|
||||
dev_t root_dev;
|
||||
bool failed;
|
||||
/* Phase 2 (files-from / filter layer). */
|
||||
char* root_path; /* transfer root (fs path) for rel computation */
|
||||
char* current_rel; /* rel path of the open directory ("" == root) */
|
||||
bool at_seed_dir; /* next open is the seed directory */
|
||||
FilterNode* seed_node; /* inherited context of the seed dir, or NULL */
|
||||
FilterNode* current_node; /* filter context of the open directory */
|
||||
ArrayList* filter_nodes; /* owned FilterNode arena (may be NULL) */
|
||||
/* --dirs / -R state for the directory-entry generator (options.dirs replaces
|
||||
the recursive scan). */
|
||||
bool relative_mode; /* file_list && relative: send bare relative wire paths */
|
||||
bool dirs_root_emitted;
|
||||
int list_index;
|
||||
ArrayList* dirs_batch; /* owned when non-NULL */
|
||||
unsigned long long dirs_batch_size;
|
||||
/* A directory could not be opened (I/O error, e.g. EACCES). With
|
||||
--ignore-errors the scan continues past it and the caller decides what to
|
||||
do; `failed` is reserved for fatal errors that always abort the scan. */
|
||||
bool io_error;
|
||||
} DirectoryScanner;
|
||||
|
||||
typedef struct {
|
||||
Queue* result_queue;
|
||||
mtx_t result_mutex;
|
||||
cnd_t result_not_empty;
|
||||
cnd_t result_not_full;
|
||||
int num_threads;
|
||||
int expected_threads;
|
||||
int created_threads;
|
||||
thrd_t* threads;
|
||||
bool done;
|
||||
bool failed;
|
||||
/* A worker skipped an unreadable directory under --ignore-errors (non-fatal). */
|
||||
bool io_error;
|
||||
atomic_bool cancelled;
|
||||
int completed;
|
||||
Chunk* initial_chunk;
|
||||
ProtocolSession* allocation_session;
|
||||
FilterNode* root_filter_node; /* root .rsync-filter context (owned by ps) */
|
||||
} ParallelScanner;
|
||||
|
||||
DirectoryScanner* directory_scanner_create(const char* root_directory, bool use_metadata,
|
||||
unsigned long long chunk_size, char** exclude_patterns,
|
||||
int exclude_count, char** include_patterns,
|
||||
int include_count, unsigned long long max_size,
|
||||
unsigned long long min_size, int max_depth,
|
||||
bool follow_symlinks, bool copy_links, bool safe_links,
|
||||
bool copy_unsafe_links, bool checksum);
|
||||
DirectoryScanner* directory_scanner_create_with_options(const char* root_directory,
|
||||
const ScannerOptions* options);
|
||||
Chunk* directory_scanner_next(DirectoryScanner* scanner);
|
||||
bool directory_scanner_failed(const DirectoryScanner* scanner);
|
||||
void directory_scanner_destroy(DirectoryScanner* scanner);
|
||||
|
||||
/* --one-file-system (-x) decision: a directory entry may be descended into
|
||||
* only when the option is disabled or the entry lives on the same device as
|
||||
* the transfer root. Exposed so tests can exercise the rule directly. */
|
||||
bool scanner_same_filesystem(bool one_file_system, dev_t root_device, dev_t entry_device);
|
||||
|
||||
/* Relative path of an on-disk path below `root` ("" == the root itself, NULL
|
||||
* when `fs_path` is not under `root`). Handles trailing slashes and a root of
|
||||
* "/". Exposed so tests can exercise the mapping directly. */
|
||||
char* scanner_path_relative(const char* root, const char* fs_path);
|
||||
|
||||
ParallelScanner* parallel_scanner_create_with_options(const char* root_directory,
|
||||
const ScannerOptions* options,
|
||||
ProtocolSession* allocation_session);
|
||||
Chunk* parallel_scanner_next(ParallelScanner* scanner);
|
||||
bool parallel_scanner_failed(const ParallelScanner* scanner);
|
||||
bool parallel_scanner_had_io_error(const ParallelScanner* scanner);
|
||||
void parallel_scanner_destroy(ParallelScanner* scanner);
|
||||
|
||||
/* True when a directory could not be opened during the scan (an I/O error,
|
||||
recorded even when --ignore-errors keeps the scan going past it). */
|
||||
bool directory_scanner_had_io_error(const DirectoryScanner* scanner);
|
||||
DirectoryScanner *directory_scanner_create(char *root_directory, bool use_metadata, unsigned long long chunk_size, char **exclude_patterns, int exclude_count, char **include_patterns, int include_count, unsigned long long max_size, unsigned long long min_size);
|
||||
Chunk *directory_scanner_next(DirectoryScanner *scanner);
|
||||
void directory_scanner_destroy(DirectoryScanner *scanner);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
#include "usage.h"
|
||||
#include <stdio.h>
|
||||
#include <delta.h>
|
||||
#include <chunk.h>
|
||||
#include "scanner.h"
|
||||
|
||||
void print_usage(void) {
|
||||
printf("Usage:\n");
|
||||
printf(" fastsync [options] <source> <destination>\n");
|
||||
printf(" fastsync [options] --source-dir <src> --dest-dir <dst>\n");
|
||||
printf("\n");
|
||||
printf("Destination formats:\n");
|
||||
printf(" user@host:/path SSH transport (rsync-style)\n");
|
||||
printf(" host:/path SSH transport (current user)\n");
|
||||
printf(" host::module/path Daemon TCP transport (fastsync-server --daemon);\n");
|
||||
printf(" module names a server-side module, path is relative\n");
|
||||
printf(" within it (connect with --server-port)\n");
|
||||
printf(" /local/path TCP transport (requires server on localhost:8080)\n");
|
||||
printf("\n");
|
||||
printf("Options:\n");
|
||||
printf(" -c, --checksum Verify content by checksum instead of size+mtime\n");
|
||||
printf(" -z, --compress [level] Enable compression (level 1-22, default 5)\n");
|
||||
printf(" -a, --archive rsync archive mode (-rlptgoD): links, metadata,\n");
|
||||
printf(" devices and specials (not compression/multithreading)\n");
|
||||
printf(" -n, --dry-run Show what would be transferred\n");
|
||||
printf(" --remove-source-files Remove regular source files after successful transfer\n");
|
||||
printf(" -p, --perms Preserve permission bits (part of the metadata bundle)\n");
|
||||
printf(" --ssh-port <port> SSH port (default: 22)\n");
|
||||
printf(" -e, --rsh <command> Remote shell to launch on the client for the SSH\n");
|
||||
printf(" transport (default: ssh). The command may include\n");
|
||||
printf(" arguments, e.g. -e \"ssh -p 2222\"\n");
|
||||
printf(" --rsync-path <path> Alias for --fastsync-server-path (path to the\n");
|
||||
printf(" fastsync server binary on the remote side)\n");
|
||||
printf(" --blocking-io Leave the SSH transport socket without read/write\n");
|
||||
printf(" timeouts so it blocks naturally\n");
|
||||
printf(" --outbuf=MODE stdout/stderr buffering: N (none/unbuffered),\n");
|
||||
printf(" L (line-buffered), or B (block-buffered, default)\n");
|
||||
printf(" --progress Show transfer progress\n");
|
||||
printf(" -P Partial mode with progress (retention incomplete)\n");
|
||||
printf(" -8, --8-bit-output Leave high-bit characters unescaped in output\n");
|
||||
printf(" --iconv=LOCAL[,REMOTE] Convert file-NAME charsets at the wire boundary:\n");
|
||||
printf(" LOCAL is the charset of our file names, REMOTE is the\n");
|
||||
printf(" remote side's charset (defaults to LOCAL). Names are\n");
|
||||
printf(" converted before transmission and back on receipt; a\n");
|
||||
printf(" name that cannot be represented in the target charset\n");
|
||||
printf(" fails that transfer cleanly (rsync-compatible)\n");
|
||||
printf(" --protocol=NUM Force the wire protocol version (must equal the current\n");
|
||||
printf(" PROTOCOL_VERSION; FastSync cannot speak older/virtual\n");
|
||||
printf(" wire formats)\n");
|
||||
printf(" --write-batch=FILE Run the normal live transfer AND also emit a\n");
|
||||
printf(" self-contained batch file of the whole source tree\n");
|
||||
printf(" (implies the single-threaded transfer path)\n");
|
||||
printf(" --only-write-batch=FILE\n");
|
||||
printf(" Emit the batch file only (no destination, no server)\n");
|
||||
printf(" --read-batch=FILE Apply the batch file to the destination (no source, no\n");
|
||||
printf(" server); takes only the destination as an argument\n");
|
||||
printf(" --delete Delete files on receiver not in source\n");
|
||||
printf(" (default timing: delete only after the whole\n");
|
||||
printf(" transfer has succeeded)\n");
|
||||
printf(" --delete-before Delete extras before the transfer starts\n");
|
||||
printf(" (implies --delete)\n");
|
||||
printf(" --delete-during Delete extras once the keep-set manifest is known,\n");
|
||||
printf(" before the data is applied (implies --delete)\n");
|
||||
printf(" --del Alias for --delete-during\n");
|
||||
printf(" --delete-delay Delete extras only after a successful transfer\n");
|
||||
printf(" (implies --delete)\n");
|
||||
printf(" --delete-after Delete only after the whole transfer succeeded\n");
|
||||
printf(" (the default --delete timing; implies --delete)\n");
|
||||
printf(" --delete-excluded Also delete destination files that were excluded on\n");
|
||||
printf(" the source (default protects them, matching rsync)\n");
|
||||
printf(" --max-delete=NUM Never delete more than NUM destination entries per run;\n");
|
||||
printf(" if the extras would exceed NUM, nothing is deleted and\n");
|
||||
printf(" the run fails with a clear error (implies --delete only\n");
|
||||
printf(" when used with it)\n");
|
||||
printf(" --ignore-errors Continue (and still delete) when a source directory is\n");
|
||||
printf(" unreadable during the scan, instead of aborting with no\n");
|
||||
printf(" deletion\n");
|
||||
printf(" --force A file may replace a destination directory by removing\n");
|
||||
printf(" that (non-empty) directory first\n");
|
||||
printf(" --ignore-missing-args A --files-from entry that does not exist under the\n");
|
||||
printf(" source is silently skipped instead of failing the run\n");
|
||||
printf(" --delete-missing-args Implies --ignore-missing-args; also deletes each missing\n");
|
||||
printf(" entry's destination mirror receiver-side. Independent of\n");
|
||||
printf(" --delete (it does not imply --delete; a non-empty directory\n");
|
||||
printf(" mirror is removed only with --force or --delete)\n");
|
||||
printf(" -m, --prune-empty-dirs Do not transfer empty directory entries (--dirs mode);\n");
|
||||
printf(" recursive transfers never send empty dirs\n");
|
||||
printf(" Note: each timing flag implies --delete. Combining a timing flag with\n");
|
||||
printf(" --no-delete (in either order) is rejected as a config error.\n");
|
||||
printf(" --ignore-existing Skip files that already exist on receiver\n");
|
||||
printf(" --delay-updates Put updated files into place only at the end of transfer\n");
|
||||
printf(" --dirs, -d, --old-dirs, --old-d Transfer the named directory entries without\n");
|
||||
printf(" recursing into their contents (-d <dir> mirrors the source\n");
|
||||
printf(" directory empty; with --files-from listed dirs are created\n");
|
||||
printf(" empty and listed files are transferred)\n");
|
||||
printf(" -R, --relative With --files-from, preserve each listed entry's relative path\n");
|
||||
printf(" below the destination root instead of mirroring the full\n");
|
||||
printf(" source path (no effect without --files-from)\n");
|
||||
printf(" --no-implied-dirs With -R --files-from, refuse to place a listed file whose\n");
|
||||
printf(" parent directory is not itself listed\n");
|
||||
printf(" --mkpath Create the destination root directory on the server when it\n");
|
||||
printf(" does not exist yet\n");
|
||||
printf(" --exclude <pattern> Exclude files matching pattern\n");
|
||||
printf(" --include <pattern> Only include files matching pattern\n");
|
||||
printf(" --exclude-from <file> Read exclude patterns from file\n");
|
||||
printf(" --include-from <file> Read include patterns from file\n");
|
||||
printf(" --files-from <file> Read the source file list from FILE (paths relative to the "
|
||||
"source root)\n");
|
||||
printf(" -0, --from0 Entries in --files-from are NUL-delimited\n");
|
||||
printf(" -f, --filter=RULE rsync-style filter rule (+/- include/exclude; repeatable;\n");
|
||||
printf(" both --filter=RULE and the -f RULE / -f=RULE short forms work)\n");
|
||||
printf(" -C, --cvs-exclude Auto-ignore common CVS/SCM files (.git/, .svn/, *.o, *~, ...)\n");
|
||||
printf(" -F Apply per-directory .rsync-filter files during the scan\n");
|
||||
printf(" --max-size <n> Skip files larger than n bytes\n");
|
||||
printf(" --min-size <n> Skip files smaller than n bytes\n");
|
||||
printf(" --max-alloc <SIZE> Maximum single allocation (default: 1G)\n");
|
||||
printf(" --incremental Skip files unchanged since last transfer\n");
|
||||
printf(" --size-only Skip incremental files matching in size, ignoring mtime\n");
|
||||
printf(" -I, --ignore-times Transfer files even when size and mtime match\n");
|
||||
printf(" -@, --modify-window <sec> Modification time tolerance\n");
|
||||
printf(" -u, --update Skip files newer than the source on receiver\n");
|
||||
printf(" --existing Skip files not already present at destination\n");
|
||||
printf(" --compare-dest <dir> Treat DIR (relative to destination root) as an extra\n");
|
||||
printf(" comparison basis: unchanged files are not transferred\n");
|
||||
printf(" (requires --incremental, which is implied)\n");
|
||||
printf(" --copy-dest <dir> Like --compare-dest, but copies the unchanged file from DIR\n");
|
||||
printf(" into the destination instead of transferring its data\n");
|
||||
printf(" --link-dest <dir> Like --copy-dest, but hard-links the unchanged file from DIR\n");
|
||||
printf(" into the destination (repeatable; earlier DIRs win)\n");
|
||||
printf(" --checksum-choice, --cc <alg> Whole-file checksum algorithm for --incremental/\n");
|
||||
printf(" --checksum compares (xxh64/xxhash or md5; default xxh64 with\n");
|
||||
printf(" seed 0). The seed comes from --checksum-seed\n");
|
||||
printf(" --checksum-seed <num> Seed for the whole-file xxHash64 digest (and the delta\n");
|
||||
printf(" block strong hash, low 32 bits); md5 ignores the seed. The\n");
|
||||
printf(" digest algorithm and seed must match on sender and receiver\n");
|
||||
printf(" --delta Delta transfer for changed files (requires --incremental)\n");
|
||||
printf(" -W, --whole-file Transfer changed files without delta processing\n");
|
||||
printf(" -y, --fuzzy Use a similar-named file already in the destination\n");
|
||||
printf(" directory as the delta basis when the destination has no\n");
|
||||
printf(" usable file at the exact path (saves bandwidth; implies\n");
|
||||
printf(" --incremental and --delta; inert with --whole-file,\n");
|
||||
printf(" --no-delta, or --no-incremental)\n");
|
||||
printf(" --no-fuzzy Disable --fuzzy\n");
|
||||
printf(" --delta-block <n>, --block-size <n>\n");
|
||||
printf(" Delta block size in bytes (default: %d)\n", DELTA_BLOCK_SIZE_DEFAULT);
|
||||
printf(" --delta-max <n> Max file size for delta transfer (default: %llu)\n",
|
||||
DELTA_MAX_FILE_SIZE);
|
||||
printf(" -j, --threads[=N] Enable the multithreaded scanner/loader/sender\n");
|
||||
printf(" pipeline; N (1-%d) sets the parallel scanner worker\n",
|
||||
MAX_SCANNER_THREADS);
|
||||
printf(" count (bare -j/--threads uses the default)\n");
|
||||
printf(" --chunk-serialization Enable chunk serialization (long form only)\n");
|
||||
printf(" -s, --secluded-args Protect-args compatibility option (no effect; remote\n");
|
||||
printf(" SSH argv is already built injection-safe)\n");
|
||||
printf(" --sendfile Enable sendfile zero-copy (TCP only; long form only)\n");
|
||||
printf(" --compress-choice <alg> Compression algorithm (default: zstd)\n");
|
||||
printf(" --zc <alg> Alias for --compress-choice\n");
|
||||
printf(" -v, --verbose Enable debug logging\n");
|
||||
printf(" -q, --quiet Suppress non-error output\n");
|
||||
printf(" --debug=FLAGS Fine-grained debug logging (use --debug=help for flags)\n");
|
||||
printf(" --info=FLAGS Fine-grained info: copy,misc,skip,stats,all,none\n");
|
||||
printf(" none suppresses info even with --verbose\n");
|
||||
printf(" --preserve Preserve file metadata (long form only)\n");
|
||||
printf(" -E, --executability Preserve executable permission bits\n");
|
||||
printf(" -X, --xattrs Preserve user extended attributes (user.* only;\n");
|
||||
printf(" privileged security.*/trusted.* namespaces are\n");
|
||||
printf(" never captured or applied)\n");
|
||||
printf(" -A, --acls Preserve POSIX ACLs (the system.posix_acl_* xattrs;\n");
|
||||
printf(" setting an ACL the receiver is not permitted to\n");
|
||||
printf(" set is warned and skipped, never fatal)\n");
|
||||
printf(" --fake-super Store the source uid/gid/mode/mtime in a reserved\n");
|
||||
printf(" user.fastsync.stat xattr on each written file and\n");
|
||||
printf(" re-apply it (fd-relative) on a privileged run; the\n");
|
||||
printf(" recording format diverges from rsync's user.rsync.%%stat%%\n");
|
||||
printf(" --super Permit the receiver to attempt super-user activities\n");
|
||||
printf(" (char/block device-node creation, --write-devices)\n");
|
||||
printf(" within the confined receive root. Never elevates\n");
|
||||
printf(" privileges and never bypasses confinement; ownership\n");
|
||||
printf(" is still applied only with an explicit identity flag\n");
|
||||
printf(" (--numeric-ids/--chown/--usermap/--groupmap/--copy-as)\n");
|
||||
printf(" --no-super Forbid those super-user activities even when the\n");
|
||||
printf(" receiver is running as root\n");
|
||||
printf(" --chmod <changes> Modify transferred permissions (rsync syntax)\n");
|
||||
printf(" --numeric-ids Do not map uid/gid by name: use the source numeric\n");
|
||||
printf(" ids directly when applying ownership\n");
|
||||
printf(" --usermap=MAP Map usernames when applying ownership: comma-separated\n");
|
||||
printf(" FROM:TO rules, first match wins. FROM/TO are names\n");
|
||||
printf(" (resolved on the source machine), * (match any /\n");
|
||||
printf(" current user), or @N numeric ids. e.g. *:nobody\n");
|
||||
printf(" --groupmap=MAP Map group names when applying ownership (same syntax)\n");
|
||||
printf(" --chown=USER:GROUP Override the ownership of transferred files. Forms:\n");
|
||||
printf(" USER:GROUP, USER (owner only), :GROUP (group only); a\n");
|
||||
printf(" value of * means the current/root user as appropriate.\n");
|
||||
printf(" Names resolve on the source machine; @N for numerics.\n");
|
||||
printf(" (Metadata is enabled with --preserve; -M now means\n");
|
||||
printf(" rsync's --remote-option.)\n");
|
||||
printf(" --copy-as=USER[:GROUP] Force every written entry (files, dirs, symlinks\n");
|
||||
printf(" and special nodes) to USER[:GROUP], resolved on the\n");
|
||||
printf(" source machine like --chown. Requires a privileged\n");
|
||||
printf(" (root) receiver and implies --preserve; an\n");
|
||||
printf(" unprivileged receiver refuses the transfer. Never\n");
|
||||
printf(" switches process credentials (safe-subset; see\n");
|
||||
printf(" RSYNC_COMPAT.md). A daemon refuses it.\n");
|
||||
printf(" --chunk-size <n> Chunk size in bytes (default: %d)\n", DEFAULT_CHUNK_SIZE);
|
||||
printf(" --source-dir <path> Source directory\n");
|
||||
printf(" --dest-dir <path> Destination directory\n");
|
||||
printf(" --save-to-disk Write received files to disk\n");
|
||||
printf(" --server-host <ip> Server IP address (default: 127.0.0.1)\n");
|
||||
printf(" --server-port <n> Server port (default: 8080)\n");
|
||||
printf(" --port <n> Alias for --server-port\n");
|
||||
printf(" --password-file <f> Authenticate a host::module/path daemon destination.\n");
|
||||
printf(" The file's first user:password line supplies the\n");
|
||||
printf(" username and password (only a SHA-256 digest of the\n");
|
||||
printf(" password is sent; keep the file mode 0600)\n");
|
||||
printf(" --no-motd Suppress display of the daemon's MOTD (the server\n");
|
||||
printf(" still sends it; the client just does not show it)\n");
|
||||
printf(" --bwlimit <KB/s> Bandwidth limit in kilobytes per second\n");
|
||||
printf(" --tls Enable TLS encryption\n");
|
||||
printf(" --cert <path> TLS certificate file (PEM)\n");
|
||||
printf(" --key <path> TLS private key file (PEM)\n");
|
||||
printf(" --ca <path> TLS CA certificate file (PEM)\n");
|
||||
printf(" --timeout <sec> I/O timeout in seconds (default: 30; long form only)\n");
|
||||
printf(" --contimeout <sec> Connection timeout in seconds (default: 10)\n");
|
||||
printf(" --stop-after=MINS Stop the transfer after MINS minutes (a positive\n");
|
||||
printf(" integer); whatever was already transferred is kept\n");
|
||||
printf(" --stop-at=TIME Stop at an absolute time: HH:MM, HH:MM:SS, or\n");
|
||||
printf(" now+N[smhd] (a time already in the past stops the\n");
|
||||
printf(" transfer immediately; client-only). An early stop\n");
|
||||
printf(" skips the late --delete keep-set so it cannot delete\n");
|
||||
printf(" source mirrors that were not yet scanned\n");
|
||||
printf(" --address <ip> Bind the outgoing client socket to this source address\n");
|
||||
printf(" -4, --ipv4 Force IPv4 for destination resolution\n");
|
||||
printf(" -6, --ipv6 Force IPv6 for destination resolution\n");
|
||||
printf(" --sockopts=OPTS Comma-separated OPT=VAL socket options applied before connect:\n");
|
||||
printf(" TCP_NODELAY, SO_KEEPALIVE, SO_RCVBUF, SO_SNDBUF, SO_REUSEADDR\n");
|
||||
printf(" --backup Backup existing files before overwriting\n");
|
||||
printf(" --backup-dir <dir> Directory for backups (requires --backup)\n");
|
||||
printf(" --suffix <str> Backup suffix (default: ~)\n");
|
||||
printf(" --stats Print transfer statistics at end\n");
|
||||
printf(" -i, --itemize-changes Print an rsync-style per-file change line\n");
|
||||
printf(" --out-format=FORMAT Output format for changed files (%%f %%n %%l %%b %%M %%%%)\n");
|
||||
printf(" --list-only List source files instead of transferring\n");
|
||||
printf(" --log-file-format=FORMAT Per-file log line format (needs --log-file)\n");
|
||||
printf(" -h, --human-readable Print byte sizes in human-readable form\n");
|
||||
printf(" --max-depth <n> Maximum directory depth (0=unlimited)\n");
|
||||
printf(" -x, --one-file-system Do not cross filesystem boundaries\n");
|
||||
printf(" --log-file <path> Write log messages to file\n");
|
||||
printf(" --stderr=MODE Route logging to stderr: errors or all\n");
|
||||
printf(" --partial Keep partial files on interrupted transfer\n");
|
||||
printf(" --partial-dir <dir> Directory for partial files\n");
|
||||
printf(" -T, --temp-dir <dir> Scratch dir for temp files before atomic install\n");
|
||||
printf(" --fastsync-server-path <path>\n");
|
||||
printf(" Path to fastsync-server on remote (default: fastsync-server)\n");
|
||||
printf(" --old-args Accepted for rsync CLI compatibility; no effect (the\n");
|
||||
printf(" remote server path is always safely quoted now)\n");
|
||||
printf(" -M, --remote-option=OPT Append OPT to the REMOTE server invocation over SSH\n");
|
||||
printf(" (repeatable; each value is single-quote-escaped on the remote\n");
|
||||
printf(" command line; empty values and values with control characters\n");
|
||||
printf(" are rejected; -M OPT, -M=OPT and --remote-option=OPT work)\n");
|
||||
printf(" --trust-sender Trust the remote sender's file list: the receiver skips its\n");
|
||||
printf(" own up-front path-traversal/containment re-validation of the\n");
|
||||
printf(" incoming file list (fewer checks, faster, potentially unsafe).\n");
|
||||
printf(" Local receiver policy: never sent to the peer, off by default\n");
|
||||
printf(" -l, --links Copy symlinks as symlinks\n");
|
||||
printf(" --copy-links Transform symlinks into referent files\n");
|
||||
printf(" --safe-links Skip symlinks that point outside transfer tree\n");
|
||||
printf(" --copy-unsafe-links Only transform unsafe symlinks into referent files\n");
|
||||
printf(" -k, --copy-dirlinks Transform symlinks to directories into real dirs\n");
|
||||
printf(" -K, --keep-dirlinks Keep an existing symlink-to-dir as that dir\n");
|
||||
printf(" --munge-links Munge symlink targets on the wire (sender)\n");
|
||||
printf(" -H, --hard-links Preserve hard-link relationships across the transfer\n");
|
||||
printf(" -S, --sparse Handle sparse files efficiently\n");
|
||||
printf(
|
||||
" -D Preserve device and special files (implies --devices --specials)\n");
|
||||
printf(
|
||||
" --devices Recreate device nodes on the destination (privileged; skipped when\n");
|
||||
printf(" the receiver lacks CAP_MKNOD)\n");
|
||||
printf(" --specials Recreate special files (FIFOs) on the destination (sockets "
|
||||
"skipped)\n");
|
||||
printf(" --copy-devices Copy a source device's content as a regular file instead\n");
|
||||
printf(" --write-devices Write received data into an existing destination device node\n");
|
||||
printf(" --inplace Update files in-place (no temp+rename)\n");
|
||||
printf(
|
||||
" --preallocate Allocate destination file space up front (fail-fast on full disk)\n");
|
||||
printf(" --append Resume a shorter destination by appending only its tail\n");
|
||||
printf(" (prefix is not verified; requires --incremental)\n");
|
||||
printf(" --append-verify Like --append, but verifies the retained prefix checksum\n");
|
||||
printf(" before appending (falls back to a full transfer on mismatch)\n");
|
||||
printf(" --fsync Fsync every written file before publication\n");
|
||||
printf(" --compress-level <n> Compression level (default: 5)\n");
|
||||
printf(" --zl <n> Alias for --compress-level\n");
|
||||
printf(" --skip-compress=LIST Skip compression for comma-separated suffixes\n");
|
||||
printf(" --compress-threads <n> Compression worker threads (requires zstd threaded support)\n");
|
||||
printf(" --no-OPTION Disable a supported boolean option\n");
|
||||
printf(" --help Show this help\n");
|
||||
printf(" -V, --version Show version\n");
|
||||
}
|
||||
|
||||
void print_debug_usage(void) {
|
||||
printf("Supported debug flags: IO,PROTO,PACK,UTIL,ALL,NONE\n");
|
||||
printf("Flags may be comma-separated, for example: --debug=io,proto\n");
|
||||
printf("Other rsync debug flags are unsupported and rejected.\n");
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
#ifndef USAGE_H
|
||||
#define USAGE_H
|
||||
|
||||
void print_usage(void);
|
||||
void print_debug_usage(void);
|
||||
|
||||
#endif
|
||||
@@ -1,531 +0,0 @@
|
||||
#include "receiver.h"
|
||||
|
||||
#include "charset.h"
|
||||
#include "chunk.h"
|
||||
#include "config.h"
|
||||
#include "delay_updates.h"
|
||||
#include "file.h"
|
||||
#include "file_receive.h"
|
||||
#include "log.h"
|
||||
#include "metadata.h"
|
||||
#include "protocol.h"
|
||||
#include "utils.h"
|
||||
#include <stdlib.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
|
||||
bool receiver_outcomes_append(ReceiverOutcomes* outcomes, unsigned char code) {
|
||||
if (!outcomes)
|
||||
return false;
|
||||
if (outcomes->count == outcomes->capacity) {
|
||||
size_t new_capacity = outcomes->capacity == 0 ? 64 : outcomes->capacity * 2;
|
||||
if (new_capacity < outcomes->capacity)
|
||||
return false;
|
||||
unsigned char* grown = realloc(outcomes->entries, new_capacity);
|
||||
if (!grown)
|
||||
return false;
|
||||
outcomes->entries = grown;
|
||||
outcomes->capacity = new_capacity;
|
||||
}
|
||||
outcomes->entries[outcomes->count++] = code;
|
||||
return true;
|
||||
}
|
||||
|
||||
void receiver_outcomes_destroy(ReceiverOutcomes* outcomes) {
|
||||
if (!outcomes)
|
||||
return;
|
||||
free(outcomes->entries);
|
||||
outcomes->entries = NULL;
|
||||
outcomes->count = 0;
|
||||
outcomes->capacity = 0;
|
||||
}
|
||||
|
||||
/* End-of-transfer success frame. When --remove-source-files was negotiated
|
||||
each processed data file is acknowledged first (STATUS_NEXT = written,
|
||||
STATUS_OK = skipped) so the sender never removes a source the receiver did
|
||||
not actually store. The frame always ends with a plain STATUS_OK. */
|
||||
bool receiver_send_final_success(int fd, const Config* config, const ReceiverOutcomes* outcomes) {
|
||||
if (!config->remove_source_files)
|
||||
return send_status(fd, STATUS_OK);
|
||||
size_t count = outcomes ? outcomes->count : 0;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
Status per_file = outcomes->entries[i] == FILE_SAVE_WRITTEN ? STATUS_NEXT : STATUS_OK;
|
||||
if (!send_status(fd, per_file))
|
||||
return false;
|
||||
}
|
||||
return send_status(fd, STATUS_OK);
|
||||
}
|
||||
|
||||
static bool receiver_process_chunk(Chunk* chunk, const ReceiverSink* sink) {
|
||||
if (!chunk || !sink || !sink->store_file)
|
||||
return false;
|
||||
for (int i = 0; i < chunk->element_count; i++) {
|
||||
File* file = chunk->items[i];
|
||||
if (!file) {
|
||||
chunk_destroy(chunk);
|
||||
return false;
|
||||
}
|
||||
chunk->items[i] = NULL;
|
||||
if (!sink->store_file(file, sink->context)) {
|
||||
chunk_destroy(chunk);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
chunk_destroy(chunk);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* P7 Wave D: read one STATUS_DIR_TIMES frame (a count followed by that many
|
||||
* (path, metadata) directory entries) and route every entry through the regular
|
||||
* store_file sink. A dir-time entry is RECORD-ONLY (file->dir_time_only): the
|
||||
* sink accumulates its metadata for end-of-transfer application but creates
|
||||
* nothing, so an empty/pruned source directory is never resurrected. A large
|
||||
* tree arrives as repeated frames, each bounded by MAX_MANIFEST_ENTRIES; a
|
||||
* malformed count or entry is a hard error. */
|
||||
static bool receiver_process_dir_times(int fd, const Config* config, const ReceiverSink* sink) {
|
||||
int count;
|
||||
if (!receive_int(fd, &count) || count < 0 || count > MAX_MANIFEST_ENTRIES)
|
||||
return false;
|
||||
for (int i = 0; i < count; i++) {
|
||||
File* dir = file_receive_dir_time(fd, config);
|
||||
if (!dir || !sink->store_file(dir, sink->context))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool receiver_process_batch(Config* config, int file_descriptor) {
|
||||
int count;
|
||||
if (config->checksum || !receive_int(file_descriptor, &count) || count < 0 ||
|
||||
count > MAX_MANIFEST_ENTRIES)
|
||||
return false;
|
||||
for (int i = 0; i < count; i++) {
|
||||
char* check_path = receive_wire_str(file_descriptor);
|
||||
if (!check_path)
|
||||
return false;
|
||||
unsigned long long check_size;
|
||||
long long check_mtime;
|
||||
long long check_mtime_nsec;
|
||||
if (!receive_n_data(file_descriptor, &check_size, sizeof(check_size)) ||
|
||||
!receive_n_data(file_descriptor, &check_mtime, sizeof(check_mtime)) ||
|
||||
!receive_n_data(file_descriptor, &check_mtime_nsec, sizeof(check_mtime_nsec)) ||
|
||||
check_mtime_nsec < 0 || check_mtime_nsec >= 1000000000LL) {
|
||||
free(check_path);
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
return false;
|
||||
}
|
||||
/* --trust-sender: accept a ``..``/absolute check path (a trusted sender's
|
||||
odd-but-legit entry) and defer containment to the secure stat below;
|
||||
an empty path is still always rejected. */
|
||||
if (check_path[0] == '\0' ||
|
||||
(!file_get_trust_sender() && !utils_valid_batch_path(check_path))) {
|
||||
free(check_path);
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
return false;
|
||||
}
|
||||
if (check_size > MAX_RECEIVE_WHOLE_FILE_SIZE) {
|
||||
free(check_path);
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
return false;
|
||||
}
|
||||
char* full_path = path_cat(config->receive_root_directory, check_path);
|
||||
if (!full_path) {
|
||||
free(check_path);
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
return false;
|
||||
}
|
||||
struct stat st;
|
||||
bool has_old = file_stat_secure(full_path, &st);
|
||||
long long old_mtime_nsec = 0;
|
||||
if (has_old) {
|
||||
#ifdef __linux__
|
||||
old_mtime_nsec = st.st_mtim.tv_nsec;
|
||||
#endif
|
||||
}
|
||||
bool match = !config->ignore_times && has_old && (unsigned long long)st.st_size == check_size &&
|
||||
metadata_mtime_matches(st.st_mtime, old_mtime_nsec, (time_t)check_mtime,
|
||||
(long)check_mtime_nsec, config->modify_window);
|
||||
bool sent = send_status(file_descriptor, match ? STATUS_OK : STATUS_NEXT);
|
||||
free(full_path);
|
||||
free(check_path);
|
||||
if (!sent)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---- Anti-slowloris connection bounds ----
|
||||
* A legitimate transfer either streams data frames continuously or, when it
|
||||
* must pause, sends STATUS_KEEPALIVE so the peer sees the connection is alive.
|
||||
* An attacker can therefore squat on a connection slot indefinitely by sending
|
||||
* only keepalives under the per-message timeout. Two CLOCK_MONOTONIC bounds
|
||||
* defeat that without ever punishing a real transfer:
|
||||
*
|
||||
* MAX_SESSION_IDLE_SEC (1 h): the longest a stream may make no forward
|
||||
* progress. Data/status frames count as progress and refresh the timer;
|
||||
* keepalives do not. One hour is far longer than any real pause between
|
||||
* data frames, yet small enough to reap a slowloris well before the 24 h
|
||||
* session cap.
|
||||
*
|
||||
* MAX_SESSION_WALL_SEC (24 h): an absolute ceiling on one connection's
|
||||
* lifetime as defense-in-depth against a trickle of progress frames that
|
||||
* resets the idle timer just below its limit. Larger than any plausible
|
||||
* single transfer while still bounding resource occupancy.
|
||||
*
|
||||
* Both are wall-clock deltas, so the per-message poll timeout (60 s by default,
|
||||
* or --timeout) can never fool them, and both the single-threaded and the -m
|
||||
* receiver paths (receiver_process_pending) share the same logic. */
|
||||
#define MAX_SESSION_IDLE_SEC 3600u
|
||||
#define MAX_SESSION_WALL_SEC 86400u
|
||||
|
||||
static unsigned int g_max_session_idle_sec = MAX_SESSION_IDLE_SEC;
|
||||
static unsigned int g_max_session_wall_sec = MAX_SESSION_WALL_SEC;
|
||||
|
||||
void receiver_set_time_limits(unsigned int idle_sec, unsigned int wall_sec) {
|
||||
g_max_session_idle_sec = idle_sec;
|
||||
g_max_session_wall_sec = wall_sec;
|
||||
}
|
||||
|
||||
void receiver_reset_time_limits(void) {
|
||||
g_max_session_idle_sec = MAX_SESSION_IDLE_SEC;
|
||||
g_max_session_wall_sec = MAX_SESSION_WALL_SEC;
|
||||
}
|
||||
|
||||
bool receiver_time_limit_exceeded(const struct timespec* session_start,
|
||||
const struct timespec* last_progress,
|
||||
const struct timespec* now) {
|
||||
if (!session_start || !last_progress || !now)
|
||||
return false;
|
||||
if (now->tv_sec - session_start->tv_sec >= (time_t)g_max_session_wall_sec)
|
||||
return true;
|
||||
if (now->tv_sec - last_progress->tv_sec >= (time_t)g_max_session_idle_sec)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/* A frame proves forward progress only when it cannot be fabricated for free.
|
||||
* KEEPALIVE/ABORT are pure liveness, and CHECK_BATCH/DIR_TIMES may carry zero
|
||||
* entries, so a peer must not be able to hold a connection slot forever by
|
||||
* merely emitting empty frames. */
|
||||
static bool status_counts_as_progress(Status status) {
|
||||
switch (status) {
|
||||
case STATUS_KEEPALIVE:
|
||||
case STATUS_ABORT:
|
||||
case STATUS_CHECK_BATCH:
|
||||
case STATUS_DIR_TIMES:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/* Refresh the progress timestamp for a forward-moving frame and enforce the
|
||||
* bounds above. Returns false when the connection must be dropped; the
|
||||
* terminal STATUS_ERROR is sent only when the sink owns error reporting (the
|
||||
* -m sink sets send_error=false so the main thread emits exactly one). */
|
||||
static bool receiver_note_status(const struct timespec* session_start,
|
||||
struct timespec* last_progress, Status status, int file_descriptor,
|
||||
const ReceiverSink* sink) {
|
||||
struct timespec now;
|
||||
if (clock_gettime(CLOCK_MONOTONIC, &now) != 0)
|
||||
now = *last_progress;
|
||||
if (status_counts_as_progress(status))
|
||||
*last_progress = now;
|
||||
if (!receiver_time_limit_exceeded(session_start, last_progress, &now))
|
||||
return true;
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"Receive session exceeded its time bound (idle %us / total %us); aborting connection",
|
||||
g_max_session_idle_sec, g_max_session_wall_sec);
|
||||
if (!sink || sink->send_error)
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
return false;
|
||||
}
|
||||
|
||||
int receiver_process(Config* config, int file_descriptor, const ReceiverSink* sink) {
|
||||
return receiver_process_pending(config, file_descriptor, sink, NULL);
|
||||
}
|
||||
|
||||
/* Runs the whole receive loop. The delete manifest may legitimately arrive
|
||||
either FIRST (--delete-before / --delete-during: the sender transmits the
|
||||
validated keep-set before any file data) or LAST (plain --delete /
|
||||
--delete-after / --delete-delay: the manifest closes the data stream). In
|
||||
the early modes the receiver deletes as soon as the manifest has been read
|
||||
and acknowledges with STATUS_OK so the sender only starts streaming once the
|
||||
deletion has committed (or failed); in the late modes the manifest is held
|
||||
and the deletion is committed only after the terminal STATUS_FINISHED proves
|
||||
the whole transfer succeeded. See receiver_process_pending() for how the -m
|
||||
receiver defers that commit until its disk writer has drained. */
|
||||
int receiver_process_pending(Config* config, int file_descriptor, const ReceiverSink* sink,
|
||||
DeleteManifest** pending_manifest) {
|
||||
Status status;
|
||||
if (!receive_status(file_descriptor, &status))
|
||||
return -1;
|
||||
/* Wall-clock (=CLOCK_MONOTONIC) anti-slowloris bookkeeping. session_start is
|
||||
* fixed for the whole connection; last_progress is refreshed by every frame
|
||||
* that is not a keepalive/abort. */
|
||||
struct timespec session_start;
|
||||
struct timespec last_progress;
|
||||
clock_gettime(CLOCK_MONOTONIC, &session_start);
|
||||
last_progress = session_start;
|
||||
if (!receiver_note_status(&session_start, &last_progress, status, file_descriptor, sink))
|
||||
return -1;
|
||||
bool early_delete = config_delete_timing_early(config);
|
||||
/* Parked keep-set for the late/commit timing. Every exit path below frees it
|
||||
exactly once; the only exception is the successful FINISHED handoff, which
|
||||
transfers ownership to *pending_manifest (used by the -m receiver). */
|
||||
DeleteManifest* deferred_manifest = NULL;
|
||||
while (status == STATUS_NEXT || status == STATUS_CHUNK || status == STATUS_CHECK ||
|
||||
status == STATUS_KEEPALIVE || status == STATUS_ABORT || status == STATUS_CHECK_BATCH ||
|
||||
status == STATUS_MKDIR || status == STATUS_MANIFEST || status == STATUS_HARDLINK ||
|
||||
status == STATUS_SYMLINK || status == STATUS_SPECIAL || status == STATUS_DIR_TIMES) {
|
||||
if (status == STATUS_KEEPALIVE) {
|
||||
if (!send_status(file_descriptor, STATUS_KEEPALIVE))
|
||||
goto fail;
|
||||
goto next_status;
|
||||
}
|
||||
if (status == STATUS_ABORT) {
|
||||
log_message(LOG_LEVEL_INFO, "Received abort from client, cleaning up");
|
||||
goto fail;
|
||||
}
|
||||
if (status == STATUS_CHECK) {
|
||||
bool skipped = false;
|
||||
bool would_transfer = false;
|
||||
File* file = receive_incremental_check_ex(file_descriptor, config, &skipped, &would_transfer);
|
||||
if (config->dry_run) {
|
||||
/* Server-contacting --dry-run: the reply has already been sent
|
||||
(STATUS_OK = up to date, STATUS_DRY_RUN_TRANSFER = would transfer) and
|
||||
nothing may be stored. Both flags false means a genuine protocol
|
||||
error (STATUS_ERROR already sent or sent by receive_error below). */
|
||||
if (!skipped && !would_transfer)
|
||||
goto receive_error;
|
||||
} else if (!skipped && (!file || !sink->store_file(file, sink->context))) {
|
||||
goto receive_error;
|
||||
}
|
||||
} else if (status == STATUS_CHUNK) {
|
||||
Chunk* chunk = receive_chunk_data(file_descriptor, config);
|
||||
if (!chunk || !receiver_process_chunk(chunk, sink))
|
||||
goto receive_error;
|
||||
} else if (status == STATUS_CHECK_BATCH) {
|
||||
if (!receiver_process_batch(config, file_descriptor))
|
||||
goto fail;
|
||||
goto next_status;
|
||||
} else if (status == STATUS_MKDIR) {
|
||||
File* dir = file_receive_directory(file_descriptor, config);
|
||||
if (!dir || !sink->store_file(dir, sink->context))
|
||||
goto receive_error;
|
||||
} else if (status == STATUS_DIR_TIMES) {
|
||||
if (!receiver_process_dir_times(file_descriptor, config, sink))
|
||||
goto receive_error;
|
||||
} else if (status == STATUS_HARDLINK) {
|
||||
File* file = file_receive_hardlink(file_descriptor);
|
||||
if (!file || !sink->store_file(file, sink->context))
|
||||
goto receive_error;
|
||||
} else if (status == STATUS_SYMLINK) {
|
||||
File* sym = file_receive_symlink(file_descriptor, config);
|
||||
if (!sym || !sink->store_file(sym, sink->context))
|
||||
goto receive_error;
|
||||
} else if (status == STATUS_SPECIAL) {
|
||||
File* file = file_receive_special(file_descriptor);
|
||||
if (!file || !sink->store_file(file, sink->context))
|
||||
goto receive_error;
|
||||
} else if (status == STATUS_MANIFEST) {
|
||||
DeleteManifest* manifest = receive_manifest_entries(file_descriptor);
|
||||
if (!manifest)
|
||||
goto fail; /* receive_manifest_entries already sent STATUS_ERROR */
|
||||
if (config->dry_run) {
|
||||
/* Server-contacting --dry-run mutates nothing, so a keep-set manifest
|
||||
is consumed and discarded. The early-delete mode still needs its ACK
|
||||
so a sender blocked on the delete handshake is not left hanging. */
|
||||
delete_manifest_free(manifest);
|
||||
if (early_delete && !send_status(file_descriptor, STATUS_OK))
|
||||
goto fail;
|
||||
goto next_status;
|
||||
}
|
||||
if (early_delete) {
|
||||
/* --delete-before / --delete-during: the manifest is authoritative the
|
||||
moment it arrives, before any file data. Delete now and acknowledge
|
||||
so the sender only starts streaming once the deletion committed (or
|
||||
failed). This is the rsync delete-before/delete-during window: a
|
||||
later transfer failure does not restore these deletions. */
|
||||
bool deletion_ok = (config->use_delete || config->delete_missing_args)
|
||||
? manifest_delete_all(config, manifest)
|
||||
: true;
|
||||
delete_manifest_free(manifest);
|
||||
if (!deletion_ok) {
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
goto fail;
|
||||
}
|
||||
if (!send_status(file_descriptor, STATUS_OK))
|
||||
goto fail;
|
||||
} else if (config->use_delete || config->delete_missing_args) {
|
||||
/* Plain --delete / --delete-after / --delete-delay and the
|
||||
--delete-missing-args exact-path deletions: hold the manifest and
|
||||
commit it only after STATUS_FINISHED. */
|
||||
if (deferred_manifest) {
|
||||
log_message(LOG_LEVEL_ERROR, "Received a second delete manifest");
|
||||
delete_manifest_free(deferred_manifest);
|
||||
deferred_manifest = NULL;
|
||||
delete_manifest_free(manifest);
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
goto fail;
|
||||
}
|
||||
deferred_manifest = manifest;
|
||||
} else {
|
||||
delete_manifest_free(manifest);
|
||||
}
|
||||
goto next_status;
|
||||
} else {
|
||||
File* file = file_receive(config, file_descriptor);
|
||||
if (!file) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to receive file");
|
||||
goto receive_error;
|
||||
}
|
||||
if (!sink->store_file(file, sink->context))
|
||||
goto receive_error;
|
||||
}
|
||||
next_status:
|
||||
if (!receive_status(file_descriptor, &status))
|
||||
goto receive_error;
|
||||
if (!receiver_note_status(&session_start, &last_progress, status, file_descriptor, sink))
|
||||
goto fail;
|
||||
}
|
||||
if (status != STATUS_FINISHED) {
|
||||
log_message(LOG_LEVEL_ERROR, "Did not receive FINISHED Status");
|
||||
goto receive_error;
|
||||
}
|
||||
/* Commit-style (late) deletion: every data frame has been received and the
|
||||
sender proved the whole tree with STATUS_FINISHED. The single-threaded
|
||||
receiver stores files synchronously, so everything is on disk here and the
|
||||
deletion can be committed before the --delay-updates publication in
|
||||
send_success (the walker skips the staging dir, so staged files are never
|
||||
treated as extras). The -m receiver passes `pending_manifest` because its
|
||||
disk writer may still be draining; the caller commits after the writer has
|
||||
joined so no extra file is removed unless the transfer is known to have
|
||||
succeeded. */
|
||||
if (deferred_manifest) {
|
||||
if (pending_manifest) {
|
||||
*pending_manifest = deferred_manifest;
|
||||
deferred_manifest = NULL;
|
||||
} else {
|
||||
bool deletion_ok = manifest_delete_all(config, deferred_manifest);
|
||||
delete_manifest_free(deferred_manifest);
|
||||
deferred_manifest = NULL;
|
||||
if (!deletion_ok) {
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
goto fail;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sink->send_success) {
|
||||
if (sink->send_success_frame) {
|
||||
if (!sink->send_success_frame(file_descriptor, sink->context))
|
||||
goto fail;
|
||||
} else if (!send_status(file_descriptor, STATUS_OK)) {
|
||||
goto fail;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
|
||||
fail:
|
||||
/* Failure exits that must not (or already did) report a STATUS_ERROR. The
|
||||
parked keep-set is dropped: never commit a deletion for a failed stream. */
|
||||
if (deferred_manifest) {
|
||||
delete_manifest_free(deferred_manifest);
|
||||
deferred_manifest = NULL;
|
||||
}
|
||||
return -1;
|
||||
|
||||
receive_error:
|
||||
if (deferred_manifest) {
|
||||
delete_manifest_free(deferred_manifest);
|
||||
deferred_manifest = NULL;
|
||||
}
|
||||
if (sink->send_error)
|
||||
send_status(file_descriptor, STATUS_ERROR);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* ---- Single-threaded sink (used by receiver_receive_files) ---- */
|
||||
|
||||
typedef struct {
|
||||
Config* config;
|
||||
ReceiverOutcomes outcomes;
|
||||
/* P7 Wave D: directory metadata accumulated during the stream, applied only
|
||||
after the whole transfer (and its delete/publication phases) has run so a
|
||||
child write never clobbers a directory mtime. */
|
||||
DirTimeList dir_times;
|
||||
} ReceiverSaveContext;
|
||||
|
||||
static bool receiver_save_file(File* file, void* context_pointer) {
|
||||
ReceiverSaveContext* context = context_pointer;
|
||||
FileSaveResult result = FILE_SAVE_ERROR;
|
||||
if (context->config->dry_run) {
|
||||
/* Defense in depth: a dry-run receiver mutates nothing even if a data
|
||||
frame reaches the sink (the sender is not supposed to send one). */
|
||||
result = FILE_SAVE_SKIPPED;
|
||||
} else if (!context->config->save_to_disk) {
|
||||
/* Nothing is stored; report the file as not-written so a
|
||||
--remove-source-files sender keeps its source. */
|
||||
result = FILE_SAVE_SKIPPED;
|
||||
} else {
|
||||
result = file_save_to_disk_full(context->config->receive_root_directory, file, context->config);
|
||||
}
|
||||
/* A directory's times are deferred, never applied inline: collect the
|
||||
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 &&
|
||||
dir_times_should_capture(context->config) &&
|
||||
!dir_time_list_add(&context->dir_times, file->path, file->metadata)) {
|
||||
file_destroy(file);
|
||||
return false;
|
||||
}
|
||||
/* A dry-run receiver mutates nothing AND records no per-file outcomes: a
|
||||
hostile dry-run client that streamed data frames anyway must not be able to
|
||||
grow `outcomes` without bound (receiver_outcomes_append reallocs uncharged)
|
||||
or force a per-frame ack. */
|
||||
if (!context->config->dry_run && result != FILE_SAVE_ERROR &&
|
||||
context->config->remove_source_files && !file->is_dir && !file->is_special && !file->skip &&
|
||||
!receiver_outcomes_append(&context->outcomes, (unsigned char)result)) {
|
||||
file_destroy(file);
|
||||
return false;
|
||||
}
|
||||
file_destroy(file);
|
||||
return result != FILE_SAVE_ERROR;
|
||||
}
|
||||
|
||||
static bool receiver_send_success_frame(int fd, void* context_pointer) {
|
||||
ReceiverSaveContext* context = context_pointer;
|
||||
/* Server-contacting --dry-run: nothing was staged or written, so there is
|
||||
nothing to publish and no directory times to stamp. */
|
||||
if (context->config->dry_run)
|
||||
return receiver_send_final_success(fd, context->config, &context->outcomes);
|
||||
/* --delay-updates: the whole protocol stream (including manifest/delete
|
||||
handling, which ran inside receiver_process) has succeeded and every
|
||||
staged file was fully written. Publish them atomically now, before the
|
||||
success/outcome frame tells a --remove-source-files sender it may delete
|
||||
its sources. */
|
||||
if (context->config->delay_updates && context->config->delay_context) {
|
||||
if (!delay_updates_publish(context->config->delay_context, context->config)) {
|
||||
send_status(fd, STATUS_ERROR);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/* P7 Wave D: every child is now written and the delete / --delay-updates
|
||||
phases have committed, so it is finally safe to stamp directory times.
|
||||
This runs after the deferred deletion because receiver_process commits it
|
||||
before calling this success frame. */
|
||||
dir_time_list_apply(&context->dir_times, context->config->receive_root_directory);
|
||||
return receiver_send_final_success(fd, context->config, &context->outcomes);
|
||||
}
|
||||
|
||||
int receiver_receive_files(Config* config, int file_descriptor) {
|
||||
ReceiverSaveContext context = {.config = config, .outcomes = {0}};
|
||||
dir_time_list_init(&context.dir_times);
|
||||
ReceiverSink sink = {receiver_save_file, &context, true, true, receiver_send_success_frame};
|
||||
int ret = receiver_process(config, file_descriptor, &sink);
|
||||
if (ret != 0 && config->delay_updates && config->delay_context)
|
||||
delay_updates_cleanup(config->delay_context);
|
||||
receiver_outcomes_destroy(&context.outcomes);
|
||||
dir_time_list_free(&context.dir_times);
|
||||
return ret;
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
#ifndef RECEIVER_H
|
||||
#define RECEIVER_H
|
||||
|
||||
#include "config.h"
|
||||
#include "file.h"
|
||||
#include "file_receive.h"
|
||||
#include <stdbool.h>
|
||||
#include <time.h>
|
||||
|
||||
typedef bool (*ReceiverFileSink)(File* file, void* context);
|
||||
|
||||
/* Ordered per-file save outcomes for one connection. One entry is appended
|
||||
for every data-bearing file the receiver processes (in the order the files
|
||||
were sent) so the sender of a --remove-source-files transfer can be told
|
||||
which sources were actually written versus skipped on the receiver. */
|
||||
typedef struct {
|
||||
unsigned char* entries; /* FILE_SAVE_WRITTEN or FILE_SAVE_SKIPPED */
|
||||
size_t count;
|
||||
size_t capacity;
|
||||
} ReceiverOutcomes;
|
||||
|
||||
typedef bool (*ReceiverSuccessFrame)(int fd, void* context);
|
||||
|
||||
typedef struct {
|
||||
ReceiverFileSink store_file;
|
||||
void* context;
|
||||
bool send_error;
|
||||
bool send_success;
|
||||
/* Emits the end-of-transfer success frame. When the sender requested
|
||||
--remove-source-files this includes one per-file status per processed
|
||||
data file followed by the final STATUS_OK; otherwise just STATUS_OK. */
|
||||
ReceiverSuccessFrame send_success_frame;
|
||||
} ReceiverSink;
|
||||
|
||||
bool receiver_outcomes_append(ReceiverOutcomes* outcomes, unsigned char code);
|
||||
void receiver_outcomes_destroy(ReceiverOutcomes* outcomes);
|
||||
bool receiver_send_final_success(int fd, const Config* config, const ReceiverOutcomes* outcomes);
|
||||
|
||||
int receiver_process(Config* config, int file_descriptor, const ReceiverSink* sink);
|
||||
/* receiver_process with an escape hatch for the commit-style (late) deletion:
|
||||
when `pending_manifest` is non-NULL the receiver does NOT delete at
|
||||
STATUS_FINISHED itself; instead it stores the owned keep-set manifest there
|
||||
(leaving *pending_manifest untouched on early modes/errors) so the caller can
|
||||
commit the deletion only after its disk writer has fully drained. Pass NULL
|
||||
to keep the default behaviour (delete before the success frame). */
|
||||
int receiver_process_pending(Config* config, int file_descriptor, const ReceiverSink* sink,
|
||||
DeleteManifest** pending_manifest);
|
||||
int receiver_receive_files(Config* config, int file_descriptor);
|
||||
|
||||
/* ---- Connection time bounds (anti-slowloris) ----
|
||||
* receiver_process_pending() aborts a connection that makes no forward progress
|
||||
* (only STATUS_KEEPALIVE/STATUS_ABORT frames) beyond a wall-clock idle limit,
|
||||
* and enforces a hard cap on the whole session. Both are CLOCK_MONOTONIC
|
||||
* deltas, independent of the per-message poll deadline, so a 60 s (or
|
||||
* --timeout) receive window can never reset them. Defaults are deliberately
|
||||
* generous (see MAX_SESSION_IDLE_SEC / MAX_SESSION_WALL_SEC in receiver.c). */
|
||||
|
||||
/* Test seam: override the idle/session wall-clock limits (0 = abort on the
|
||||
* next status). Always restore with receiver_reset_time_limits(). */
|
||||
void receiver_set_time_limits(unsigned int idle_sec, unsigned int wall_sec);
|
||||
void receiver_reset_time_limits(void);
|
||||
/* Pure predicate over explicit monotonic timestamps, exposed so the bound is
|
||||
* unit-testable without sleeping. True when either the idle or the overall
|
||||
* session limit has elapsed. */
|
||||
bool receiver_time_limit_exceeded(const struct timespec* session_start,
|
||||
const struct timespec* last_progress, const struct timespec* now);
|
||||
|
||||
#endif
|
||||
@@ -1,258 +0,0 @@
|
||||
#include "receiver_pipeline.h"
|
||||
|
||||
#include "log.h"
|
||||
#include "protocol.h"
|
||||
#include "queue.h"
|
||||
#include "utils.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <threads.h>
|
||||
|
||||
PipelineContextReceiver* pipeline_context_receiver_create(Config* config, Queue* queue,
|
||||
int file_descriptor, SSL* ssl) {
|
||||
PipelineContextReceiver* context = malloc(sizeof(PipelineContextReceiver));
|
||||
if (context == NULL)
|
||||
return NULL;
|
||||
context->config = config;
|
||||
context->queue = queue;
|
||||
context->file_descriptor = file_descriptor;
|
||||
context->ssl = ssl;
|
||||
context->outcomes.entries = NULL;
|
||||
context->outcomes.count = 0;
|
||||
context->outcomes.capacity = 0;
|
||||
dir_time_list_init(&context->dir_times);
|
||||
protocol_session_init(&context->session, file_descriptor, file_descriptor);
|
||||
protocol_session_set_ssl(&context->session, ssl);
|
||||
context->receiver_done = false;
|
||||
context->queued_bytes = 0;
|
||||
context->max_queue_bytes = 0;
|
||||
context->deferred_manifest = NULL;
|
||||
atomic_init(&context->cancelled, false);
|
||||
int init = 0;
|
||||
if (mtx_init(&context->mutex, mtx_plain) != thrd_success)
|
||||
goto fail;
|
||||
init++;
|
||||
if (cnd_init(&context->condition_not_full) != thrd_success)
|
||||
goto fail;
|
||||
init++;
|
||||
if (cnd_init(&context->condition_not_empty) != thrd_success)
|
||||
goto fail;
|
||||
// cppcheck-suppress unreadVariable
|
||||
init++;
|
||||
return context;
|
||||
|
||||
fail:
|
||||
log_perror("Error initializing synchronization objects");
|
||||
if (init >= 3)
|
||||
cnd_destroy(&context->condition_not_empty);
|
||||
if (init >= 2)
|
||||
cnd_destroy(&context->condition_not_full);
|
||||
if (init >= 1)
|
||||
mtx_destroy(&context->mutex);
|
||||
free(context);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void pipeline_context_receiver_destroy(PipelineContextReceiver* context) {
|
||||
config_delete(context->config);
|
||||
if (context->deferred_manifest)
|
||||
delete_manifest_free(context->deferred_manifest);
|
||||
queue_destroy(context->queue);
|
||||
receiver_outcomes_destroy(&context->outcomes);
|
||||
dir_time_list_free(&context->dir_times);
|
||||
mtx_destroy(&context->mutex);
|
||||
cnd_destroy(&context->condition_not_full);
|
||||
cnd_destroy(&context->condition_not_empty);
|
||||
free(context);
|
||||
}
|
||||
|
||||
void pipeline_context_receiver_set_queue_byte_limit(PipelineContextReceiver* context,
|
||||
size_t max_bytes) {
|
||||
if (context == NULL)
|
||||
return;
|
||||
mtx_lock(&context->mutex);
|
||||
context->max_queue_bytes = max_bytes;
|
||||
context->queued_bytes = 0;
|
||||
cnd_broadcast(&context->condition_not_full);
|
||||
mtx_unlock(&context->mutex);
|
||||
}
|
||||
|
||||
void pipeline_context_receiver_note_bytes_released(PipelineContextReceiver* context,
|
||||
size_t released_bytes) {
|
||||
if (context == NULL || context->max_queue_bytes == 0 || released_bytes == 0)
|
||||
return;
|
||||
mtx_lock(&context->mutex);
|
||||
if (released_bytes >= context->queued_bytes)
|
||||
context->queued_bytes = 0;
|
||||
else
|
||||
context->queued_bytes -= released_bytes;
|
||||
cnd_signal(&context->condition_not_full);
|
||||
mtx_unlock(&context->mutex);
|
||||
}
|
||||
|
||||
bool pipeline_context_receiver_enqueue_file(PipelineContextReceiver* context, File* file) {
|
||||
if (context == NULL || file == NULL)
|
||||
return false;
|
||||
size_t file_bytes = file->data ? file->data->size : 0;
|
||||
mtx_lock(&context->mutex);
|
||||
while (!atomic_load(&context->cancelled)) {
|
||||
bool blocked_by_count = queue_is_full(context->queue);
|
||||
bool blocked_by_budget = false;
|
||||
if (context->max_queue_bytes > 0) {
|
||||
size_t budget = context->max_queue_bytes;
|
||||
size_t used = context->queued_bytes;
|
||||
if (used >= budget) {
|
||||
blocked_by_budget = true;
|
||||
} else if (file_bytes > budget - used) {
|
||||
/* A single payload larger than the whole budget (not possible with
|
||||
the per-file receive cap) is only admitted to an empty pipeline so
|
||||
the wait can never deadlock. */
|
||||
blocked_by_budget = used != 0;
|
||||
}
|
||||
}
|
||||
if (!blocked_by_count && !blocked_by_budget)
|
||||
break;
|
||||
cnd_wait(&context->condition_not_full, &context->mutex);
|
||||
}
|
||||
if (atomic_load(&context->cancelled)) {
|
||||
mtx_unlock(&context->mutex);
|
||||
file_destroy(file);
|
||||
return false;
|
||||
}
|
||||
if (!queue_enqueue(context->queue, file)) {
|
||||
mtx_unlock(&context->mutex);
|
||||
file_destroy(file);
|
||||
return false;
|
||||
}
|
||||
context->queued_bytes += file_bytes;
|
||||
cnd_signal(&context->condition_not_empty);
|
||||
mtx_unlock(&context->mutex);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool receiver_enqueue_file(File* file, void* context_pointer) {
|
||||
PipelineContextReceiver* context = (PipelineContextReceiver*)context_pointer;
|
||||
return pipeline_context_receiver_enqueue_file(context, file);
|
||||
}
|
||||
|
||||
static void receiver_thread_fail(PipelineContextReceiver* context) {
|
||||
mtx_lock(&context->mutex);
|
||||
atomic_store(&context->cancelled, true);
|
||||
context->receiver_done = true;
|
||||
cnd_broadcast(&context->condition_not_empty);
|
||||
cnd_broadcast(&context->condition_not_full);
|
||||
mtx_unlock(&context->mutex);
|
||||
}
|
||||
|
||||
int receive_thread(void* pipeline_context) {
|
||||
PipelineContextReceiver* context = (PipelineContextReceiver*)pipeline_context;
|
||||
protocol_session_bind(&context->session);
|
||||
mtx_lock(&context->mutex);
|
||||
int file_descriptor = context->file_descriptor;
|
||||
const Config* config = context->config;
|
||||
mtx_unlock(&context->mutex);
|
||||
|
||||
ReceiverSink sink = {receiver_enqueue_file, context, false, false, NULL};
|
||||
if (receiver_process_pending((Config*)config, file_descriptor, &sink,
|
||||
&context->deferred_manifest) != 0) {
|
||||
receiver_thread_fail(context);
|
||||
protocol_session_unbind();
|
||||
return thrd_error;
|
||||
}
|
||||
mtx_lock(&context->mutex);
|
||||
context->receiver_done = true;
|
||||
cnd_signal(&context->condition_not_empty);
|
||||
mtx_unlock(&context->mutex);
|
||||
protocol_session_unbind();
|
||||
return thrd_success;
|
||||
}
|
||||
|
||||
int write_thread(void* pipeline_context) {
|
||||
PipelineContextReceiver* context = (PipelineContextReceiver*)pipeline_context;
|
||||
protocol_session_bind(&context->session);
|
||||
mtx_lock(&context->mutex);
|
||||
bool save_to_disk = context->config->save_to_disk;
|
||||
char* root_directory = str_dup(context->config->receive_root_directory);
|
||||
mtx_unlock(&context->mutex);
|
||||
if (save_to_disk && !root_directory) {
|
||||
mtx_lock(&context->mutex);
|
||||
atomic_store(&context->cancelled, true);
|
||||
context->receiver_done = true;
|
||||
cnd_broadcast(&context->condition_not_full);
|
||||
cnd_broadcast(&context->condition_not_empty);
|
||||
mtx_unlock(&context->mutex);
|
||||
protocol_session_unbind();
|
||||
return thrd_error;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
File* file =
|
||||
queue_dequeue_multithreaded(context->queue, &context->mutex, &context->condition_not_empty,
|
||||
&context->condition_not_full, &context->receiver_done);
|
||||
if (file == NULL) {
|
||||
free(root_directory);
|
||||
protocol_session_unbind();
|
||||
return thrd_success;
|
||||
}
|
||||
size_t file_bytes = file->data ? file->data->size : 0;
|
||||
FileSaveResult result = FILE_SAVE_SKIPPED;
|
||||
/* Server-contacting --dry-run: never write. The receiver thread does not
|
||||
enqueue anything on the dry-run path, but this keeps the writer thread
|
||||
provably mutation-free if a data frame ever reached it. */
|
||||
bool dry_run = context->config->dry_run;
|
||||
if (save_to_disk && !dry_run) {
|
||||
result = file_save_to_disk_full(root_directory, file, context->config);
|
||||
if (result == FILE_SAVE_ERROR) {
|
||||
file_destroy(file);
|
||||
pipeline_context_receiver_note_bytes_released(context, file_bytes);
|
||||
mtx_lock(&context->mutex);
|
||||
atomic_store(&context->cancelled, true);
|
||||
context->receiver_done = true;
|
||||
cnd_broadcast(&context->condition_not_full);
|
||||
cnd_broadcast(&context->condition_not_empty);
|
||||
mtx_unlock(&context->mutex);
|
||||
free(root_directory);
|
||||
protocol_session_unbind();
|
||||
return thrd_error;
|
||||
}
|
||||
}
|
||||
/* P7 Wave D: a directory's times are never applied inline (a later child
|
||||
write would clobber them); accumulate the metadata here and let the
|
||||
caller apply it once every writer has drained. */
|
||||
if (!dry_run && result != FILE_SAVE_ERROR && file->is_dir && file->metadata &&
|
||||
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);
|
||||
mtx_lock(&context->mutex);
|
||||
atomic_store(&context->cancelled, true);
|
||||
context->receiver_done = true;
|
||||
cnd_broadcast(&context->condition_not_full);
|
||||
cnd_broadcast(&context->condition_not_empty);
|
||||
mtx_unlock(&context->mutex);
|
||||
free(root_directory);
|
||||
protocol_session_unbind();
|
||||
return thrd_error;
|
||||
}
|
||||
/* Record the per-file outcome so a --remove-source-files sender learns
|
||||
which sources were actually written versus skipped on the receiver.
|
||||
Explicit directory entries and recreated device/special nodes have no
|
||||
source and are never acknowledged (mirrors receiver.c). */
|
||||
if (!dry_run && context->config->remove_source_files && !file->is_dir && !file->is_special &&
|
||||
!file->skip && !receiver_outcomes_append(&context->outcomes, (unsigned char)result)) {
|
||||
file_destroy(file);
|
||||
pipeline_context_receiver_note_bytes_released(context, file_bytes);
|
||||
mtx_lock(&context->mutex);
|
||||
atomic_store(&context->cancelled, true);
|
||||
context->receiver_done = true;
|
||||
cnd_broadcast(&context->condition_not_full);
|
||||
cnd_broadcast(&context->condition_not_empty);
|
||||
mtx_unlock(&context->mutex);
|
||||
free(root_directory);
|
||||
protocol_session_unbind();
|
||||
return thrd_error;
|
||||
}
|
||||
file_destroy(file);
|
||||
pipeline_context_receiver_note_bytes_released(context, file_bytes);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
#ifndef RECEIVER_PIPELINE_H
|
||||
#define RECEIVER_PIPELINE_H
|
||||
|
||||
#include <stdatomic.h>
|
||||
#include <stdbool.h>
|
||||
#include <threads.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "file.h"
|
||||
#include "file_receive.h"
|
||||
#include "protocol.h"
|
||||
#include "queue.h"
|
||||
#include "receiver.h"
|
||||
#include <openssl/ssl.h>
|
||||
|
||||
typedef struct PipelineContextReceiver {
|
||||
Queue* queue;
|
||||
Config* config;
|
||||
int file_descriptor;
|
||||
SSL* ssl;
|
||||
ProtocolSession session;
|
||||
ReceiverOutcomes outcomes;
|
||||
mtx_t mutex;
|
||||
cnd_t condition_not_full;
|
||||
cnd_t condition_not_empty;
|
||||
bool receiver_done;
|
||||
atomic_bool cancelled;
|
||||
/* Aggregate payload bytes that have been received but not yet released by
|
||||
the disk writer (queued or in the writer's hand). Guarded by `mutex`.
|
||||
When `max_queue_bytes` is non-zero the receiver blocks before enqueuing
|
||||
once this total would exceed it, so decompressed/copied file payloads
|
||||
buffered ahead of a slow disk writer respect the per-connection memory
|
||||
budget instead of growing without bound. */
|
||||
size_t queued_bytes;
|
||||
size_t max_queue_bytes;
|
||||
/* Keep-set manifest for the commit-style (late) deletion
|
||||
(--delete/--delete-after/--delete-delay). receive_thread parses the whole
|
||||
protocol stream but hands the manifest here instead of deleting while the
|
||||
disk writer may still be draining; the caller (server.c) commits the
|
||||
deletion after both threads have joined, so no extra is removed unless the
|
||||
transfer truly succeeded. NULL in the early delete modes (which delete at
|
||||
the manifest). */
|
||||
DeleteManifest* deferred_manifest;
|
||||
/* P7 Wave D: directory metadata collected by write_thread from received
|
||||
directory entries. Only write_thread mutates it (before it joins); the
|
||||
caller (server.c) applies it after the delete/delay-updates phase. */
|
||||
DirTimeList dir_times;
|
||||
} PipelineContextReceiver;
|
||||
|
||||
PipelineContextReceiver* pipeline_context_receiver_create(Config* config, Queue* queue_receiver,
|
||||
int file_descriptor, SSL* ssl);
|
||||
void pipeline_context_receiver_destroy(PipelineContextReceiver* context);
|
||||
/* Bound the bytes buffered ahead of the disk writer (see max_queue_bytes). */
|
||||
void pipeline_context_receiver_set_queue_byte_limit(PipelineContextReceiver* context,
|
||||
size_t max_bytes);
|
||||
/* Blocking enqueue used by the receive pipeline sink. Blocks while the queue
|
||||
is full by element count or when adding `file` would push queued_bytes over
|
||||
the configured byte limit; waits until the disk writer releases bytes.
|
||||
Takes ownership of `file` on success and destroys it on failure/cancel. */
|
||||
bool pipeline_context_receiver_enqueue_file(PipelineContextReceiver* context, File* file);
|
||||
/* Account for `released_bytes` of payload memory that has been freed by the
|
||||
disk writer, unblocking a receiver that is waiting on the byte limit. */
|
||||
void pipeline_context_receiver_note_bytes_released(PipelineContextReceiver* context,
|
||||
size_t released_bytes);
|
||||
int receive_thread(void* pipeline_context);
|
||||
int write_thread(void* pipeline_context);
|
||||
|
||||
#endif
|
||||
+134
-1318
File diff suppressed because it is too large
Load Diff
@@ -1,305 +0,0 @@
|
||||
#include "server_cli.h"
|
||||
#include "charset.h"
|
||||
#include "credentials.h"
|
||||
#include "utils.h"
|
||||
#include <limits.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
static void set_error(char* err, size_t err_size, const char* fmt, ...) {
|
||||
if (!err || err_size == 0)
|
||||
return;
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
vsnprintf(err, err_size, fmt, args);
|
||||
va_end(args);
|
||||
}
|
||||
|
||||
void server_cli_options_default(ServerCliOptions* opts) {
|
||||
if (!opts)
|
||||
return;
|
||||
memset(opts, 0, sizeof(*opts));
|
||||
opts->destination_root = ".";
|
||||
opts->port = 8080;
|
||||
opts->bind_family = AF_UNSPEC;
|
||||
}
|
||||
|
||||
static bool arg_is(const char* arg, const char* name) {
|
||||
return strcmp(arg, name) == 0;
|
||||
}
|
||||
|
||||
/* Match "--opt" against "--opt=value" / separate-value forms; on the "=" form
|
||||
* *value receives the inline value. Returns true when the argument is the
|
||||
* named option in either form. */
|
||||
static bool arg_has_value(const char* arg, const char* name, const char** value) {
|
||||
if (strcmp(arg, name) == 0)
|
||||
return true; /* separate form; caller takes the next argv slot */
|
||||
size_t name_len = strlen(name);
|
||||
if (strncmp(arg, name, name_len) == 0 && arg[name_len] == '=') {
|
||||
*value = arg + name_len + 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static int parse_port_arg(const char* value, int* port, char* err, size_t err_size) {
|
||||
char* end;
|
||||
long p = strtol(value, &end, 10);
|
||||
if (*end != '\0' || p <= 0 || p > 65535) {
|
||||
char* escaped = output_escape(value, false);
|
||||
set_error(err, err_size, "invalid port '%s' (must be 1-65535)",
|
||||
escaped ? escaped : "<allocation failed>");
|
||||
free(escaped);
|
||||
return -1;
|
||||
}
|
||||
*port = (int)p;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err, size_t err_size) {
|
||||
if (err && err_size)
|
||||
err[0] = '\0';
|
||||
server_cli_options_default(opts);
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
const char* inline_value = NULL;
|
||||
if (arg_is(argv[i], "--help")) {
|
||||
opts->show_help = true;
|
||||
return 1;
|
||||
} else if (arg_is(argv[i], "--stdio")) {
|
||||
opts->stdio_mode = true;
|
||||
} else if (arg_is(argv[i], "--daemon")) {
|
||||
opts->daemon_mode = true;
|
||||
} else if (arg_is(argv[i], "--no-detach")) {
|
||||
opts->no_detach = true;
|
||||
} else if (arg_is(argv[i], "-v") || arg_is(argv[i], "--verbose")) {
|
||||
opts->verbose = true;
|
||||
} else if (arg_is(argv[i], "--tls")) {
|
||||
opts->use_tls = true;
|
||||
} else if (arg_is(argv[i], "--cert")) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --cert");
|
||||
return -1;
|
||||
}
|
||||
opts->tls_cert = argv[++i];
|
||||
} else if (arg_is(argv[i], "--key")) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --key");
|
||||
return -1;
|
||||
}
|
||||
opts->tls_key = argv[++i];
|
||||
} else if (arg_is(argv[i], "--ca")) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --ca");
|
||||
return -1;
|
||||
}
|
||||
opts->tls_ca = argv[++i];
|
||||
} else if (arg_is(argv[i], "--client-cn")) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --client-cn");
|
||||
return -1;
|
||||
}
|
||||
opts->client_cn = argv[++i];
|
||||
} else if (arg_is(argv[i], "--destination-root")) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --destination-root");
|
||||
return -1;
|
||||
}
|
||||
opts->destination_root = argv[++i];
|
||||
opts->destination_root_set = true;
|
||||
} else if (arg_has_value(argv[i], "--password-file", &inline_value)) {
|
||||
if (!inline_value) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --password-file");
|
||||
return -1;
|
||||
}
|
||||
inline_value = argv[++i];
|
||||
}
|
||||
opts->password_file = inline_value;
|
||||
} else if (arg_has_value(argv[i], "--early-input", &inline_value)) {
|
||||
if (!inline_value) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --early-input");
|
||||
return -1;
|
||||
}
|
||||
inline_value = argv[++i];
|
||||
}
|
||||
opts->early_input_file = inline_value;
|
||||
} else if (arg_has_value(argv[i], "--hash-credentials", &inline_value)) {
|
||||
if (!inline_value) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --hash-credentials");
|
||||
return -1;
|
||||
}
|
||||
inline_value = argv[++i];
|
||||
}
|
||||
opts->hash_credentials_file = inline_value;
|
||||
} else if (arg_has_value(argv[i], "--iterations", &inline_value)) {
|
||||
if (!inline_value) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --iterations");
|
||||
return -1;
|
||||
}
|
||||
inline_value = argv[++i];
|
||||
}
|
||||
char* end = NULL;
|
||||
long n = strtol(inline_value, &end, 10);
|
||||
if (!end || *end != '\0' || n < (long)CREDENTIAL_MIN_ITERS ||
|
||||
n > (long)CREDENTIAL_MAX_ITERS) {
|
||||
set_error(err, err_size, "--iterations must be in [%u,%u], got '%s'", CREDENTIAL_MIN_ITERS,
|
||||
CREDENTIAL_MAX_ITERS, inline_value);
|
||||
return -1;
|
||||
}
|
||||
opts->hash_iterations = (uint32_t)n;
|
||||
opts->hash_iterations_set = true;
|
||||
} else if (arg_is(argv[i], "--address")) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --address");
|
||||
return -1;
|
||||
}
|
||||
opts->bind_address = argv[++i];
|
||||
} else if (arg_is(argv[i], "-4") || arg_is(argv[i], "--ipv4")) {
|
||||
if (opts->bind_family == AF_INET6) {
|
||||
set_error(err, err_size, "--ipv4 and --ipv6 are mutually exclusive");
|
||||
return -1;
|
||||
}
|
||||
opts->bind_family = AF_INET;
|
||||
} else if (arg_is(argv[i], "-6") || arg_is(argv[i], "--ipv6")) {
|
||||
if (opts->bind_family == AF_INET) {
|
||||
set_error(err, err_size, "--ipv4 and --ipv6 are mutually exclusive");
|
||||
return -1;
|
||||
}
|
||||
opts->bind_family = AF_INET6;
|
||||
} else if (arg_is(argv[i], "--allow-delete")) {
|
||||
opts->allow_delete = true;
|
||||
} else if (arg_is(argv[i], "--trust-sender")) {
|
||||
opts->trust_sender = true;
|
||||
} else if (arg_is(argv[i], "--no-super")) {
|
||||
opts->no_super = true;
|
||||
} else if (arg_is(argv[i], "--allow-super")) {
|
||||
opts->allow_super = true;
|
||||
} else if (arg_is(argv[i], "--allow-unauthenticated")) {
|
||||
opts->allow_unauthenticated = true;
|
||||
} else if (arg_has_value(argv[i], "--iconv", &inline_value)) {
|
||||
if (!inline_value) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --iconv");
|
||||
return -1;
|
||||
}
|
||||
inline_value = argv[++i];
|
||||
}
|
||||
opts->iconv_spec = inline_value;
|
||||
} else if (arg_is(argv[i], "-p")) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for -p");
|
||||
return -1;
|
||||
}
|
||||
opts->port_set = true;
|
||||
if (parse_port_arg(argv[++i], &opts->port, err, err_size) != 0)
|
||||
return -1;
|
||||
} else {
|
||||
if (arg_has_value(argv[i], "--config", &inline_value)) {
|
||||
if (!inline_value) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --config");
|
||||
return -1;
|
||||
}
|
||||
inline_value = argv[++i];
|
||||
}
|
||||
opts->config_path = inline_value;
|
||||
} else if (arg_has_value(argv[i], "--dparam", &inline_value)) {
|
||||
if (!inline_value) {
|
||||
if (i + 1 >= argc) {
|
||||
set_error(err, err_size, "missing argument for --dparam");
|
||||
return -1;
|
||||
}
|
||||
inline_value = argv[++i];
|
||||
}
|
||||
const char** grown =
|
||||
realloc((char**)opts->dparams, (size_t)(opts->dparam_count + 1) * sizeof(const char*));
|
||||
if (!grown) {
|
||||
set_error(err, err_size, "out of memory parsing --dparam");
|
||||
return -1;
|
||||
}
|
||||
opts->dparams = grown;
|
||||
opts->dparams[opts->dparam_count++] = inline_value;
|
||||
} else if (argv[i][0] == '-') {
|
||||
char* escaped = output_escape(argv[i], false);
|
||||
set_error(err, err_size, "unknown option: %s", escaped ? escaped : "<allocation failed>");
|
||||
free(escaped);
|
||||
return -1;
|
||||
} else {
|
||||
set_error(err, err_size, "unexpected argument '%s'", argv[i]);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Cross-mode validation. */
|
||||
if (opts->stdio_mode && opts->daemon_mode) {
|
||||
set_error(err, err_size, "--stdio and --daemon are mutually exclusive");
|
||||
return -1;
|
||||
}
|
||||
if (opts->daemon_mode && opts->destination_root_set) {
|
||||
set_error(err, err_size,
|
||||
"--destination-root cannot be combined with --daemon (module paths "
|
||||
"replace it)");
|
||||
return -1;
|
||||
}
|
||||
if (!opts->daemon_mode &&
|
||||
(opts->config_path != NULL || opts->dparam_count > 0 || opts->no_detach ||
|
||||
opts->password_file != NULL || opts->early_input_file != NULL)) {
|
||||
set_error(err, err_size,
|
||||
"--config, --dparam, --no-detach, --password-file, and --early-input require "
|
||||
"--daemon");
|
||||
return -1;
|
||||
}
|
||||
if (opts->hash_credentials_file != NULL && (opts->daemon_mode || opts->stdio_mode)) {
|
||||
set_error(err, err_size, "--hash-credentials cannot be combined with --daemon or --stdio");
|
||||
return -1;
|
||||
}
|
||||
if (opts->allow_super && opts->no_super) {
|
||||
set_error(err, err_size, "--allow-super and --no-super are mutually exclusive");
|
||||
return -1;
|
||||
}
|
||||
if (opts->allow_super && opts->daemon_mode) {
|
||||
set_error(err, err_size,
|
||||
"--allow-super is for a locally-launched standalone TCP server; daemon modules opt "
|
||||
"in per module with 'client owner = yes'");
|
||||
return -1;
|
||||
}
|
||||
/* --stdio is the SSH transport: the remote server argv is composed by the
|
||||
* CLIENT (directly and via --remote-option), so a client could otherwise pass
|
||||
* --allow-super to a root --stdio receiver and defeat the C3 secure default.
|
||||
* Never honor it there; the super mode stays forced OFF. An operator who
|
||||
* must keep the historical permissive behavior over SSH has to launch the
|
||||
* receiver through a forced command, not via client-composed argv. */
|
||||
if (opts->allow_super && opts->stdio_mode) {
|
||||
set_error(err, err_size,
|
||||
"--allow-super is not accepted with --stdio (the remote argv is client-composed; "
|
||||
"use a forced command if the default must hold)");
|
||||
return -1;
|
||||
}
|
||||
if (opts->hash_iterations_set && opts->hash_credentials_file == NULL) {
|
||||
set_error(err, err_size, "--iterations requires --hash-credentials");
|
||||
return -1;
|
||||
}
|
||||
/* --iconv: reject a malformed CONVERT_SPEC or an unsupported charset name at
|
||||
startup (a probe iconv_open is attempted). */
|
||||
if (opts->iconv_spec != NULL && !charset_spec_valid(opts->iconv_spec)) {
|
||||
set_error(err, err_size, "--iconv requires LOCAL[,REMOTE] charset names supported by iconv");
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void server_cli_options_free(ServerCliOptions* opts) {
|
||||
if (!opts)
|
||||
return;
|
||||
free((char**)opts->dparams);
|
||||
opts->dparams = NULL;
|
||||
opts->dparam_count = 0;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
#ifndef SERVER_CLI_H
|
||||
#define SERVER_CLI_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/* Parsed fastsync-server command line. All string members are borrowed
|
||||
* pointers into the original argv (valid for the life of the argv array the
|
||||
* caller passed to server_cli_parse); dparams points at the raw --dparam
|
||||
* argument strings. No member owns heap memory. */
|
||||
typedef struct ServerCliOptions {
|
||||
bool stdio_mode; /* --stdio */
|
||||
bool daemon_mode; /* --daemon */
|
||||
bool no_detach; /* --no-detach */
|
||||
bool verbose; /* -v / --verbose */
|
||||
bool show_help; /* --help */
|
||||
bool use_tls; /* --tls */
|
||||
const char* tls_cert; /* --cert */
|
||||
const char* tls_key; /* --key */
|
||||
const char* tls_ca; /* --ca */
|
||||
const char* client_cn; /* --client-cn */
|
||||
bool destination_root_set; /* an explicit --destination-root was given */
|
||||
const char* destination_root; /* --destination-root value ("." if unset) */
|
||||
bool port_set; /* an explicit -p was given */
|
||||
int port; /* -p value (default 8080 when unset) */
|
||||
const char* config_path; /* --config value, or NULL */
|
||||
const char* password_file; /* --password-file value, or NULL (daemon) */
|
||||
const char* early_input_file; /* --early-input value, or NULL (daemon) */
|
||||
/* --hash-credentials=FILE: read `user:password` lines from FILE and print
|
||||
* new-format credential-store lines to stdout, then exit. Standalone mode
|
||||
* (mutually exclusive with --daemon/--stdio). */
|
||||
const char* hash_credentials_file;
|
||||
bool hash_iterations_set; /* an explicit --iterations was given */
|
||||
uint32_t hash_iterations; /* --iterations value (default CREDENTIAL_DEFAULT_ITERS) */
|
||||
const char** dparams; /* raw --dparam override strings */
|
||||
int dparam_count;
|
||||
const char* bind_address; /* --address */
|
||||
int bind_family; /* AF_UNSPEC / AF_INET / AF_INET6 */
|
||||
bool allow_delete; /* --allow-delete */
|
||||
bool trust_sender; /* --trust-sender */
|
||||
bool allow_unauthenticated; /* --allow-unauthenticated */
|
||||
/* --no-super: operator veto forcing SUPER_MODE_OFF for every connection, so
|
||||
* the receiver never attempts super-user activities (ownership application,
|
||||
* device-node creation) even when running as root. Applies to --stdio and
|
||||
* --daemon alike; also makes the server refuse any client --copy-as. */
|
||||
bool no_super; /* --no-super */
|
||||
/* --allow-super: locally-launched standalone TCP listener opt-in that keeps
|
||||
* the historical permissive behavior for a PRIVILEGED (root) receiver.
|
||||
* Without it a root standalone server forces SUPER_MODE_OFF, so a client
|
||||
* --devices / --write-devices / --super / ownership request cannot make it
|
||||
* create device nodes, write raw devices, or apply client-chosen ownership.
|
||||
* It is rejected for --stdio: that path's remote argv is composed by the
|
||||
* client (directly and via --remote-option), so it must never opt a root
|
||||
* receiver back into super mode. Non-root receivers are unaffected (the
|
||||
* kernel refuses the confined attempts). The daemon path instead uses the
|
||||
* per-module `client owner = yes` opt-in. */
|
||||
bool allow_super; /* --allow-super */
|
||||
/* --iconv=CONVERT_SPEC: the server's own LOCAL charset declaration. The
|
||||
* client's full spec rides the wire config frame anyway; when the server is
|
||||
* started with its own --iconv, its LOCAL half overrides the local charset
|
||||
* the client assumed so the server converts received names to ITS charset.
|
||||
* Borrowed pointer into argv (never owns heap). */
|
||||
const char* iconv_spec; /* --iconv value, or NULL */
|
||||
} ServerCliOptions;
|
||||
|
||||
/* Parse argc/argv into *opts. Zero-initialize *opts before calling (or use
|
||||
* server_cli_options_default). Returns:
|
||||
* 1 -- --help was requested (opts->show_help set; caller prints usage).
|
||||
* 0 -- parsed successfully.
|
||||
* -1 -- invalid arguments (err is filled with the reason).
|
||||
*/
|
||||
void server_cli_options_default(ServerCliOptions* opts);
|
||||
int server_cli_parse(int argc, char* argv[], ServerCliOptions* opts, char* err, size_t err_size);
|
||||
/* Release the only heap the parsed options own (the dparams pointer array; the
|
||||
* strings it points at are borrowed from argv and are not freed). Safe to
|
||||
* call on a zero-initialized/defaulted struct. */
|
||||
void server_cli_options_free(ServerCliOptions* opts);
|
||||
#endif
|
||||
+15
-22
@@ -1,19 +1,16 @@
|
||||
#include "log.h"
|
||||
#include "array_list.h"
|
||||
#include "protocol.h"
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
ArrayList* array_list_create(void (*item_destroyer)(void* item)) {
|
||||
ArrayList* list = (ArrayList*)protocol_alloc(sizeof(ArrayList));
|
||||
ArrayList *array_list_create(void (*item_destroyer)(void *item)) {
|
||||
ArrayList *list = (ArrayList *)malloc(sizeof(ArrayList));
|
||||
if (list == NULL) {
|
||||
log_perror("ERROR: Could not allocate memory for array list struct");
|
||||
perror("ERROR: Could not allocate memory for array list struct");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
list->items = protocol_alloc(INITIAL_ARRAY_SIZE * sizeof(void*));
|
||||
list->items = malloc(INITIAL_ARRAY_SIZE * sizeof(void *));
|
||||
if (list->items == NULL) {
|
||||
free(list);
|
||||
return NULL;
|
||||
@@ -24,7 +21,7 @@ ArrayList* array_list_create(void (*item_destroyer)(void* item)) {
|
||||
return list;
|
||||
}
|
||||
|
||||
void array_list_delete(ArrayList* array_list) {
|
||||
void array_list_delete(ArrayList *array_list) {
|
||||
if (array_list == NULL)
|
||||
return;
|
||||
if (array_list->item_destroyer != NULL) {
|
||||
@@ -37,17 +34,14 @@ void array_list_delete(ArrayList* array_list) {
|
||||
free(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;
|
||||
bool array_list_extend(ArrayList *array_list) {
|
||||
if (array_list == NULL) return false;
|
||||
int new_capacity = array_list->capacity * 2;
|
||||
if (new_capacity == 0)
|
||||
new_capacity = INITIAL_ARRAY_SIZE;
|
||||
void* new_items = protocol_realloc(array_list->items, new_capacity * sizeof(void*));
|
||||
void *new_items = realloc(array_list->items, new_capacity * sizeof(void *));
|
||||
if (new_items == NULL) {
|
||||
log_perror("ERROR: Could not reallocate memory for array list items");
|
||||
perror("ERROR: Could not reallocate memory for array list items");
|
||||
return false;
|
||||
}
|
||||
array_list->items = new_items;
|
||||
@@ -55,9 +49,8 @@ static bool array_list_extend(ArrayList* array_list) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool array_list_add(ArrayList* array_list, void* item) {
|
||||
if (array_list == NULL)
|
||||
return false;
|
||||
bool array_list_add(ArrayList *array_list, void *item) {
|
||||
if (array_list == NULL) return false;
|
||||
if (array_list->capacity == array_list->size) {
|
||||
if (!array_list_extend(array_list))
|
||||
return false;
|
||||
@@ -67,15 +60,15 @@ bool array_list_add(ArrayList* array_list, void* item) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void** array_list_to_array(const ArrayList* array_list) {
|
||||
void **array_list_to_array(ArrayList *array_list) {
|
||||
if (array_list == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
void** array = protocol_alloc(array_list->size * sizeof(void*));
|
||||
void **array = malloc(array_list->size * sizeof(void *));
|
||||
if (array == NULL) {
|
||||
log_perror("Could not malloc space for array from array list!");
|
||||
perror("Could not malloc space for array from array list!");
|
||||
return NULL;
|
||||
}
|
||||
memcpy(array, array_list->items, array_list->size * sizeof(void*));
|
||||
memcpy(array, array_list->items, array_list->size * sizeof(void *));
|
||||
return array;
|
||||
}
|
||||
|
||||
@@ -6,15 +6,16 @@
|
||||
#define INITIAL_ARRAY_SIZE 100
|
||||
|
||||
typedef struct ArrayList {
|
||||
void** items;
|
||||
void **items;
|
||||
int size;
|
||||
int capacity;
|
||||
void (*item_destroyer)(void* item);
|
||||
void (*item_destroyer)(void *item);
|
||||
} ArrayList;
|
||||
|
||||
ArrayList* array_list_create(void (*item_destroyer)(void* item));
|
||||
void array_list_delete(ArrayList* array_list);
|
||||
bool array_list_add(ArrayList* array_list, void* item);
|
||||
void** array_list_to_array(const ArrayList* array_list);
|
||||
ArrayList *array_list_create(void (*item_destroyer)(void *item));
|
||||
void array_list_delete(ArrayList *array_list);
|
||||
bool array_list_extend(ArrayList *array_list);
|
||||
bool array_list_add(ArrayList *array_list, void *item);
|
||||
void **array_list_to_array(ArrayList *array_list);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
#include "batch.h"
|
||||
#include "data.h"
|
||||
#include "file.h"
|
||||
#include "file_receive.h"
|
||||
#include "log.h"
|
||||
#include <errno.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* Serialization metadata mode for the batch stream, captured from the config at
|
||||
* batch_write_header time. The header persists it into the file so a batch is
|
||||
* self-describing: batch_read_apply re-reads it from the file (not from the
|
||||
* reading config), so a batch written with -M is applied identically by an
|
||||
* invoking process regardless of its own -M setting. The batch driver is a
|
||||
* single sequential scan pass within one thread, so this module-level flag is
|
||||
* safe. */
|
||||
static bool batch_metadata_mode = false;
|
||||
|
||||
static bool write_all_bytes(int fd, const void* data, size_t size) {
|
||||
const unsigned char* p = (const unsigned char*)data;
|
||||
size_t done = 0;
|
||||
while (done < size) {
|
||||
ssize_t n = write(fd, p + done, size - done);
|
||||
if (n < 0 && errno == EINTR)
|
||||
continue;
|
||||
if (n <= 0)
|
||||
return false;
|
||||
done += (size_t)n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool batch_write_header(int fd, const Config* config) {
|
||||
if (fd < 0)
|
||||
return false;
|
||||
batch_metadata_mode = config != NULL && config->use_metadata;
|
||||
if (!write_all_bytes(fd, BATCH_MAGIC, BATCH_MAGIC_LEN))
|
||||
return false;
|
||||
unsigned char version = BATCH_FORMAT_VERSION;
|
||||
if (!write_all_bytes(fd, &version, 1))
|
||||
return false;
|
||||
unsigned char mode = batch_metadata_mode ? 1 : 0;
|
||||
return write_all_bytes(fd, &mode, 1);
|
||||
}
|
||||
|
||||
bool batch_write_chunk(int fd, Chunk* chunk) {
|
||||
if (fd < 0 || chunk == NULL)
|
||||
return false;
|
||||
Data* serialized = chunk_serialize(chunk, batch_metadata_mode);
|
||||
if (serialized == NULL)
|
||||
return false;
|
||||
bool ok = false;
|
||||
unsigned long long length = (unsigned long long)serialized->size;
|
||||
if (length > BATCH_MAX_RECORD) {
|
||||
log_message(LOG_LEVEL_ERROR, "batch: record size %llu exceeds the %llu-byte cap", length,
|
||||
(unsigned long long)BATCH_MAX_RECORD);
|
||||
} else if (write_all_bytes(fd, &length, sizeof(length)) &&
|
||||
(length == 0 || write_all_bytes(fd, serialized->data, (size_t)length))) {
|
||||
ok = true;
|
||||
}
|
||||
data_destroy(serialized);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* Read exactly `size` bytes. Returns true on success. On reaching EOF, sets
|
||||
* *clean_eof only when no bytes had been read yet (a clean boundary) and returns
|
||||
* that value, so a truncated record (EOF mid-read) yields false. */
|
||||
static bool read_exact(int fd, void* data, size_t size, bool* clean_eof) {
|
||||
unsigned char* p = (unsigned char*)data;
|
||||
size_t done = 0;
|
||||
while (done < size) {
|
||||
ssize_t n = read(fd, p + done, size - done);
|
||||
if (n < 0 && errno == EINTR)
|
||||
continue;
|
||||
if (n == 0) {
|
||||
if (clean_eof)
|
||||
*clean_eof = done == 0;
|
||||
return done == 0;
|
||||
}
|
||||
if (n < 0)
|
||||
return false;
|
||||
done += (size_t)n;
|
||||
}
|
||||
if (clean_eof)
|
||||
*clean_eof = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
int batch_read_apply(int fd, const Config* config, const char* dest_root) {
|
||||
if (fd < 0 || dest_root == NULL || dest_root[0] == '\0')
|
||||
return -1;
|
||||
|
||||
char magic[BATCH_MAGIC_LEN];
|
||||
bool eof = false;
|
||||
if (!read_exact(fd, magic, BATCH_MAGIC_LEN, &eof) || eof ||
|
||||
memcmp(magic, BATCH_MAGIC, BATCH_MAGIC_LEN) != 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "batch: malformed header (bad magic)");
|
||||
return -1;
|
||||
}
|
||||
unsigned char version;
|
||||
if (!read_exact(fd, &version, 1, &eof) || eof || version != BATCH_FORMAT_VERSION) {
|
||||
log_message(LOG_LEVEL_ERROR, "batch: malformed header (bad or missing format version)");
|
||||
return -1;
|
||||
}
|
||||
unsigned char mode;
|
||||
if (!read_exact(fd, &mode, 1, &eof) || eof || (mode != 0 && mode != 1)) {
|
||||
log_message(LOG_LEVEL_ERROR, "batch: malformed header (bad metadata flag)");
|
||||
return -1;
|
||||
}
|
||||
bool use_metadata = mode == 1;
|
||||
|
||||
while (1) {
|
||||
unsigned long long length;
|
||||
if (!read_exact(fd, &length, sizeof(length), &eof)) {
|
||||
log_message(LOG_LEVEL_ERROR, "batch: truncated length prefix");
|
||||
return -1;
|
||||
}
|
||||
if (eof)
|
||||
break; /* clean end of stream */
|
||||
if (length == 0 || length > BATCH_MAX_RECORD) {
|
||||
log_message(LOG_LEVEL_ERROR, "batch: rejected record length %llu (valid range 1..%llu)",
|
||||
length, (unsigned long long)BATCH_MAX_RECORD);
|
||||
return -1;
|
||||
}
|
||||
char* record = (char*)malloc((size_t)length);
|
||||
if (record == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "batch: could not allocate a %llu-byte record", length);
|
||||
return -1;
|
||||
}
|
||||
if (!read_exact(fd, record, (size_t)length, &eof) || eof) {
|
||||
log_message(LOG_LEVEL_ERROR, "batch: truncated chunk record");
|
||||
free(record);
|
||||
return -1;
|
||||
}
|
||||
Data* data = data_create(record, (size_t)length);
|
||||
if (data == NULL)
|
||||
return -1; /* data_create frees `record` on failure */
|
||||
Chunk* chunk = chunk_deserialize(data, use_metadata);
|
||||
data_destroy(data);
|
||||
if (chunk == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "batch: rejected malformed chunk record");
|
||||
return -1;
|
||||
}
|
||||
for (int i = 0; i < chunk->element_count; i++) {
|
||||
File* file = chunk->items[i];
|
||||
chunk->items[i] = NULL;
|
||||
if (file == NULL)
|
||||
continue;
|
||||
FileSaveResult result = file_save_to_disk_full(dest_root, file, config);
|
||||
file_destroy(file);
|
||||
if (result == FILE_SAVE_ERROR) {
|
||||
chunk_destroy(chunk);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
chunk_destroy(chunk);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
#ifndef BATCH_H
|
||||
#define BATCH_H
|
||||
#include "chunk.h"
|
||||
#include "config.h"
|
||||
|
||||
/* Phase 6 residual-batch codec. A residual batch is a self-contained
|
||||
* single-file record of a whole source tree: a magic+format-version header
|
||||
* followed by length-prefixed chunk blobs (each built with chunk_serialize),
|
||||
* byte-identical by construction. The batch is a client-only driver feature:
|
||||
* it never crosses the wire, so there is no PROTOCOL_VERSION bump and no server
|
||||
* change. */
|
||||
|
||||
#define BATCH_MAGIC "FSTRESBATCH"
|
||||
#define BATCH_MAGIC_LEN 11
|
||||
#define BATCH_FORMAT_VERSION 1
|
||||
/* Max size of a single length-prefixed record (a whole serialized chunk,
|
||||
* which can span several files). A single source file near the 64 MB wire
|
||||
* limit plus per-file headers can produce a record slightly over 64 MB, so a
|
||||
* large file just under the wire cap may be refused by the batch writer; this
|
||||
* is documented upstream and the failure is clean (the partial batch is
|
||||
* unlinked), never a truncated/corrupt batch. */
|
||||
#define BATCH_MAX_RECORD (64ULL * 1024 * 1024)
|
||||
|
||||
bool batch_write_header(int fd, const Config* config);
|
||||
bool batch_write_chunk(int fd, Chunk* chunk);
|
||||
int batch_read_apply(int fd, const Config* config, const char* dest_root);
|
||||
|
||||
#endif
|
||||
@@ -1,384 +0,0 @@
|
||||
#include "charset.h"
|
||||
#include "log.h"
|
||||
#include "protocol.h"
|
||||
#include "utils.h"
|
||||
#include <errno.h>
|
||||
#include <iconv.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct {
|
||||
iconv_t cd;
|
||||
} CharsetConversion;
|
||||
|
||||
/* Process-wide wire conversion descriptor (one direction per process: a client
|
||||
* only sends, a server only receives). CONCURRENCY CONTRACT: iconv_t is not
|
||||
* guaranteed thread-safe, so every conversion MUST run on a single thread at a
|
||||
* time. This holds today -- on the client the conversions run on the sender
|
||||
* thread (in the -m pipeline chunk_serialize/send happen on the sender thread
|
||||
* only), on the server on the receive-loop thread; the descriptor is
|
||||
* initialized on one thread before any transfer thread spawns and torn down
|
||||
* (charset_wire_free) only after all threads have joined. Do not add a
|
||||
* concurrent conversion path (e.g. parallel chunk serialization) without
|
||||
* guarding access with a mutex. */
|
||||
static CharsetConversion* g_wire_conv;
|
||||
|
||||
/* Grow *buf to double capacity, freeing it on failure. realloc preserves the
|
||||
* already-written prefix, so the caller only tracks its write offset. */
|
||||
static bool grow_charset_buffer(char** buf, size_t* cap) {
|
||||
size_t new_cap = *cap * 2;
|
||||
if (new_cap <= *cap) {
|
||||
free(*buf);
|
||||
*buf = NULL;
|
||||
return false;
|
||||
}
|
||||
char* grown = realloc(*buf, new_cap);
|
||||
if (!grown) {
|
||||
free(*buf);
|
||||
*buf = NULL;
|
||||
return false;
|
||||
}
|
||||
*buf = grown;
|
||||
*cap = new_cap;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Throw away any pending shift state so a subsequent conversion starts clean.
|
||||
* The flush output is discarded; for the stateless single-byte/UTF charsets
|
||||
* this feature targets it is a no-op. */
|
||||
static void charset_conversion_reset(const CharsetConversion* conv) {
|
||||
char scratch[64];
|
||||
char* sp = scratch;
|
||||
size_t sl = sizeof(scratch);
|
||||
(void)iconv(conv->cd, NULL, NULL, &sp, &sl);
|
||||
}
|
||||
|
||||
int charset_spec_parse(const char* spec, char** local_out, char** remote_out) {
|
||||
if (!local_out || !remote_out)
|
||||
return -1;
|
||||
*local_out = NULL;
|
||||
*remote_out = NULL;
|
||||
if (!spec || spec[0] == '\0')
|
||||
return -1;
|
||||
char* dup = str_dup(spec);
|
||||
if (!dup)
|
||||
return -1;
|
||||
char* comma = strchr(dup, ',');
|
||||
if (comma) {
|
||||
if (comma == dup || comma[1] == '\0') {
|
||||
free(dup);
|
||||
return -1;
|
||||
}
|
||||
*comma = '\0';
|
||||
*local_out = str_dup(dup);
|
||||
*remote_out = str_dup(comma + 1);
|
||||
free(dup);
|
||||
} else {
|
||||
*local_out = str_dup(dup);
|
||||
*remote_out = str_dup(dup);
|
||||
free(dup);
|
||||
}
|
||||
if (!*local_out || !*remote_out) {
|
||||
free(*local_out);
|
||||
free(*remote_out);
|
||||
*local_out = NULL;
|
||||
*remote_out = NULL;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void* charset_conversion_open(const char* from_charset, const char* to_charset) {
|
||||
if (!from_charset || !to_charset)
|
||||
return NULL;
|
||||
iconv_t cd = iconv_open(to_charset, from_charset);
|
||||
if (cd == (iconv_t)-1)
|
||||
return NULL;
|
||||
CharsetConversion* conv = malloc(sizeof(CharsetConversion));
|
||||
if (!conv) {
|
||||
iconv_close(cd);
|
||||
return NULL;
|
||||
}
|
||||
conv->cd = cd;
|
||||
return conv;
|
||||
}
|
||||
|
||||
void charset_conversion_close(void* conversion) {
|
||||
if (!conversion)
|
||||
return;
|
||||
CharsetConversion* conv = (CharsetConversion*)conversion;
|
||||
iconv_close(conv->cd);
|
||||
free(conv);
|
||||
}
|
||||
|
||||
/* Probe a single conversion direction: the from/to charsets both open AND a
|
||||
* representative ASCII name converts to a byte string containing no embedded
|
||||
* NUL (so a target charset like UTF-16 that emits NUL bytes for ordinary ASCII
|
||||
* names is rejected up front -- such an output would be silently truncated by
|
||||
* the C-string wire helpers). */
|
||||
static bool direction_probe_valid(const char* from, const char* to) {
|
||||
if (!from || !to)
|
||||
return false;
|
||||
void* conv = charset_conversion_open(from, to);
|
||||
if (!conv)
|
||||
return false;
|
||||
bool ok = true;
|
||||
char input = 'a';
|
||||
char* in_ptr = &input;
|
||||
size_t in_left = 1;
|
||||
char out_buf[64];
|
||||
char* out_ptr = out_buf;
|
||||
size_t out_left = sizeof(out_buf);
|
||||
if (iconv(((CharsetConversion*)conv)->cd, &in_ptr, &in_left, &out_ptr, &out_left) == (size_t)-1)
|
||||
ok = false;
|
||||
char flush_buf[64];
|
||||
char* flush_ptr = flush_buf;
|
||||
size_t flush_left = sizeof(flush_buf);
|
||||
if (ok &&
|
||||
iconv(((CharsetConversion*)conv)->cd, NULL, NULL, &flush_ptr, &flush_left) == (size_t)-1)
|
||||
ok = false;
|
||||
size_t produced = (size_t)(out_ptr - out_buf);
|
||||
if (ok && produced > 0 && memchr(out_buf, '\0', produced) != NULL)
|
||||
ok = false;
|
||||
charset_conversion_close(conv);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool charset_pair_valid(const char* local, const char* remote) {
|
||||
/* Both ends convert in opposite directions with the same two charsets, so a
|
||||
* valid spec must open (and be NUL-free) in BOTH directions: the sender
|
||||
* opens local->remote, the receiver opens remote->local. */
|
||||
return direction_probe_valid(local, remote) && direction_probe_valid(remote, local);
|
||||
}
|
||||
|
||||
bool charset_spec_valid(const char* spec) {
|
||||
if (!spec)
|
||||
return true;
|
||||
char* local;
|
||||
char* remote;
|
||||
if (charset_spec_parse(spec, &local, &remote) != 0)
|
||||
return false;
|
||||
bool ok = charset_pair_valid(local, remote);
|
||||
free(local);
|
||||
free(remote);
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool charset_spec_valid_direction(const char* from_charset, const char* to_charset) {
|
||||
return direction_probe_valid(from_charset, to_charset);
|
||||
}
|
||||
|
||||
/* The receiver's real conversion is wire(client REMOTE) -> server-local (the
|
||||
* server's own --iconv LOCAL half, or the client's LOCAL half when the server
|
||||
* has no --iconv). A dedicated pre-ack check so an impossible direction is
|
||||
* rejected before the connection instead of refusing mid-transfer. */
|
||||
bool charset_wire_receiver_spec_valid(const char* spec, const char* server_spec) {
|
||||
if (!spec)
|
||||
return true;
|
||||
char* local;
|
||||
char* remote;
|
||||
if (charset_spec_parse(spec, &local, &remote) != 0)
|
||||
return false;
|
||||
const char* wire = remote;
|
||||
const char* target_local = local;
|
||||
char* server_local = NULL;
|
||||
char* server_remote = NULL;
|
||||
if (server_spec) {
|
||||
if (charset_spec_parse(server_spec, &server_local, &server_remote) != 0) {
|
||||
free(local);
|
||||
free(remote);
|
||||
return false;
|
||||
}
|
||||
target_local = server_local;
|
||||
}
|
||||
bool ok = charset_spec_valid_direction(wire, target_local);
|
||||
free(server_local);
|
||||
free(server_remote);
|
||||
free(local);
|
||||
free(remote);
|
||||
return ok;
|
||||
}
|
||||
|
||||
char* charset_convert(const void* conversion, const char* in, int* err_out) {
|
||||
if (!conversion || !in)
|
||||
return NULL;
|
||||
const CharsetConversion* conv = (const CharsetConversion*)conversion;
|
||||
size_t in_len = strlen(in);
|
||||
size_t cap = in_len + 16;
|
||||
char* out = malloc(cap);
|
||||
if (!out)
|
||||
return NULL;
|
||||
size_t in_left = in_len;
|
||||
char* in_ptr = (char*)in;
|
||||
size_t out_used = 0;
|
||||
|
||||
while (in_left > 0) {
|
||||
char* out_ptr = out + out_used;
|
||||
size_t out_left = cap - out_used;
|
||||
if (iconv(conv->cd, &in_ptr, &in_left, &out_ptr, &out_left) == (size_t)-1) {
|
||||
if (errno != E2BIG) {
|
||||
if (err_out)
|
||||
*err_out = errno;
|
||||
charset_conversion_reset(conv);
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
/* Output exhausted but input remains. E2BIG does not roll the output
|
||||
pointer back: the bytes iconv already emitted before the failure must
|
||||
be preserved, so advance out_used before growing. */
|
||||
out_used = (size_t)(out_ptr - out);
|
||||
if (!grow_charset_buffer(&out, &cap))
|
||||
return NULL;
|
||||
continue;
|
||||
}
|
||||
out_used = (size_t)(out_ptr - out);
|
||||
}
|
||||
|
||||
/* Flush any pending shift state (a no-op for the stateless single-byte and
|
||||
UTF charsets this feature targets, but keeps the descriptor clean). */
|
||||
for (;;) {
|
||||
char* out_ptr = out + out_used;
|
||||
size_t out_left = cap - out_used;
|
||||
if (iconv(conv->cd, NULL, NULL, &out_ptr, &out_left) == (size_t)-1) {
|
||||
if (errno != E2BIG) {
|
||||
if (err_out)
|
||||
*err_out = errno;
|
||||
charset_conversion_reset(conv);
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
out_used = (size_t)(out_ptr - out);
|
||||
if (!grow_charset_buffer(&out, &cap))
|
||||
return NULL;
|
||||
continue;
|
||||
}
|
||||
out_used = (size_t)(out_ptr - out);
|
||||
break;
|
||||
}
|
||||
|
||||
/* A successful iconv call may legitimately consume the whole buffer (output
|
||||
exactly fills cap), leaving no room for the terminator: guarantee headroom
|
||||
before the final write. */
|
||||
if (out_used >= cap && !grow_charset_buffer(&out, &cap))
|
||||
return NULL;
|
||||
|
||||
/* Defense in depth: a target charset that emits embedded NUL bytes would
|
||||
truncate at the first NUL in the C-string wire helpers; fail cleanly
|
||||
(validation already rejects such charsets up front). */
|
||||
if (memchr(out, '\0', out_used) != NULL) {
|
||||
if (err_out)
|
||||
*err_out = EILSEQ;
|
||||
charset_conversion_reset(conv);
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
out[out_used] = '\0';
|
||||
return out;
|
||||
}
|
||||
|
||||
bool charset_wire_init_sender(const char* spec) {
|
||||
charset_wire_free();
|
||||
if (!spec)
|
||||
return true;
|
||||
char* local;
|
||||
char* remote;
|
||||
if (charset_spec_parse(spec, &local, &remote) != 0)
|
||||
return false;
|
||||
void* conv = charset_conversion_open(local, remote);
|
||||
free(local);
|
||||
free(remote);
|
||||
if (!conv)
|
||||
return false;
|
||||
g_wire_conv = (CharsetConversion*)conv;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool charset_wire_init_receiver(const char* spec, const char* server_spec) {
|
||||
charset_wire_free();
|
||||
if (!spec)
|
||||
return true;
|
||||
char* local;
|
||||
char* remote;
|
||||
if (charset_spec_parse(spec, &local, &remote) != 0)
|
||||
return false;
|
||||
/* The wire charset is the client spec's REMOTE half; the local charset is
|
||||
* the client spec's LOCAL half unless the server was itself started with
|
||||
* --iconv naming a different local charset (the server halves above never
|
||||
* travel, so the server's own flag is the only way its local charset can
|
||||
* differ from what the client assumed). */
|
||||
const char* wire = remote;
|
||||
const char* target_local = local;
|
||||
char* server_local = NULL;
|
||||
char* server_remote = NULL;
|
||||
if (server_spec) {
|
||||
if (charset_spec_parse(server_spec, &server_local, &server_remote) != 0) {
|
||||
free(local);
|
||||
free(remote);
|
||||
return false;
|
||||
}
|
||||
target_local = server_local;
|
||||
}
|
||||
void* conv = charset_conversion_open(wire, target_local);
|
||||
free(server_local);
|
||||
free(server_remote);
|
||||
free(local);
|
||||
free(remote);
|
||||
if (!conv)
|
||||
return false;
|
||||
g_wire_conv = (CharsetConversion*)conv;
|
||||
return true;
|
||||
}
|
||||
|
||||
void charset_wire_free(void) {
|
||||
if (g_wire_conv) {
|
||||
charset_conversion_close(g_wire_conv);
|
||||
g_wire_conv = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
bool charset_wire_active(void) {
|
||||
return g_wire_conv != NULL;
|
||||
}
|
||||
|
||||
char* charset_wire_apply(const char* path) {
|
||||
if (!g_wire_conv)
|
||||
return str_dup(path);
|
||||
return charset_convert(g_wire_conv, path, NULL);
|
||||
}
|
||||
|
||||
static void charset_convert_failure_log(const char* path) {
|
||||
char* escaped = output_escape(path, false);
|
||||
log_message(LOG_LEVEL_ERROR, "--iconv: cannot convert file name '%s' to the target charset",
|
||||
escaped ? escaped : "<unprintable>");
|
||||
free(escaped);
|
||||
}
|
||||
|
||||
bool send_wire_str(int file_descriptor, const char* local_path) {
|
||||
if (!g_wire_conv)
|
||||
return send_str(file_descriptor, local_path);
|
||||
char* wire = charset_wire_apply(local_path);
|
||||
if (!wire) {
|
||||
charset_convert_failure_log(local_path);
|
||||
return false;
|
||||
}
|
||||
bool ok = send_str(file_descriptor, wire);
|
||||
free(wire);
|
||||
return ok;
|
||||
}
|
||||
|
||||
char* receive_wire_str(int file_descriptor) {
|
||||
char* raw = receive_str(file_descriptor);
|
||||
if (!raw)
|
||||
return NULL;
|
||||
if (!g_wire_conv)
|
||||
return raw;
|
||||
char* local = charset_convert(g_wire_conv, raw, NULL);
|
||||
if (!local) {
|
||||
charset_convert_failure_log(raw);
|
||||
free(raw);
|
||||
return NULL;
|
||||
}
|
||||
free(raw);
|
||||
return local;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
#ifndef CHARSET_H
|
||||
#define CHARSET_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/* --iconv=CONVERT_SPEC file-name charset conversion (rsync compatibility).
|
||||
*
|
||||
* CONVERT_SPEC is "LOCAL[,REMOTE]": LOCAL is the charset of our own file
|
||||
* names, REMOTE is the charset of the remote side's file names and defaults
|
||||
* to LOCAL when the comma half is omitted. The sender converts every local
|
||||
* path from LOCAL to REMOTE before it goes on the wire; the receiver converts
|
||||
* every received path back from REMOTE to LOCAL. A NULL/disabled spec means
|
||||
* identity with zero overhead (the common path never consults iconv).
|
||||
*
|
||||
* All helpers are friendly to the strict cold path: the wire conversion state
|
||||
* is process-global (one direction per process -- a client only sends, a
|
||||
* server only receives) and is initialized once, before any path is
|
||||
* serialized, so conversion compiles to a single non-NULL check when disabled.
|
||||
*/
|
||||
|
||||
/* Parse CONVERT_SPEC into malloc'd LOCAL and REMOTE charset names (caller
|
||||
* frees both). REMOTE is a separate copy of LOCAL when no comma is present.
|
||||
* Returns 0 on success, -1 on a malformed spec (empty halves / missing value /
|
||||
* allocation failure); nothing is allocated on the -1 path. Both output
|
||||
* pointers are REQUIRED (non-NULL). */
|
||||
int charset_spec_parse(const char* spec, char** local_out, char** remote_out);
|
||||
|
||||
/* True when a CONVERT_SPEC is well-formed AND its charsets are usable for this
|
||||
* feature: each pair opens in a probe iconv_open in BOTH directions (a sender
|
||||
* converts local->remote, the receiver converts remote->local) and converting
|
||||
* a representative ASCII name emits no embedded NUL byte (a UTF-16-style NUL
|
||||
* emitter would be silently truncated by the C-string wire helpers). A typo'd
|
||||
* charset name is therefore rejected at startup, not mid-run. NULL (iconv
|
||||
* disabled) is always valid. */
|
||||
bool charset_spec_valid(const char* spec);
|
||||
|
||||
/* Probe a concrete from->to conversion pair without keeping the descriptor:
|
||||
* both charsets open AND a representative ASCII name converts with no embedded
|
||||
* NUL. Used for direction-specific validation (e.g. the receiver's exact
|
||||
* wire->local direction including a server-side charset override). */
|
||||
bool charset_spec_valid_direction(const char* from_charset, const char* to_charset);
|
||||
bool charset_pair_valid(const char* local, const char* remote);
|
||||
|
||||
/* One-shot conversion of a NUL-terminated input to a malloc'd NUL-terminated
|
||||
* result, or NULL on failure. On failure *err_out (when non-NULL) receives
|
||||
* the iconv errno (EILSEQ/EINVAL = the input is not representable in the
|
||||
* target charset). The caller must free the result. */
|
||||
char* charset_convert(const void* conversion, const char* in, int* err_out);
|
||||
|
||||
/* Open a conversion descriptor for direction from_charset -> to_charset.
|
||||
* Returns NULL (errno = EINVAL) when a charset name is unsupported. Freed
|
||||
* with charset_conversion_close. */
|
||||
void* charset_conversion_open(const char* from_charset, const char* to_charset);
|
||||
void charset_conversion_close(void* conversion);
|
||||
|
||||
/* Process-wide wire conversion. charset_wire_init_sender (client side) opens
|
||||
* LOCAL->REMOTE; charset_wire_init_receiver (server side) opens
|
||||
* wire(REMOTE)->server-local. server_spec is the server's own --iconv, whose
|
||||
* LOCAL half may override the local charset the client assumed; NULL reuses
|
||||
* the client spec's LOCAL half. Both return false on an unsupported spec.
|
||||
* The state is freed with charset_wire_free. */
|
||||
bool charset_wire_init_sender(const char* spec);
|
||||
bool charset_wire_init_receiver(const char* spec, const char* server_spec);
|
||||
void charset_wire_free(void);
|
||||
bool charset_wire_active(void);
|
||||
|
||||
/* Pre-ack receiver-direction sanity (see charset_wire_init_receiver): true
|
||||
* when the exact wire->server-local conversion the receiver will use (client
|
||||
* spec's REMOTE half into the server's own LOCAL half, or the client's LOCAL
|
||||
* half when the server has no --iconv) opens and produces NUL-free output. */
|
||||
bool charset_wire_receiver_spec_valid(const char* spec, const char* server_spec);
|
||||
|
||||
/* Convert a path across the wire in the process direction. Returns a malloc'd
|
||||
* string, or NULL when the name cannot be represented in the target charset. */
|
||||
char* charset_wire_apply(const char* path);
|
||||
|
||||
/* Convenience wire string I/O: encode+send_str / receive_str+decode. Both
|
||||
* return false/NULL (logging a clear --iconv error) on conversion failure, so
|
||||
* an unconvertible path FAILS the transfer cleanly instead of silently sending
|
||||
* a mangled name. */
|
||||
bool send_wire_str(int file_descriptor, const char* local_path);
|
||||
char* receive_wire_str(int file_descriptor);
|
||||
|
||||
#endif
|
||||
@@ -1,75 +0,0 @@
|
||||
#include "checksum.h"
|
||||
#include <openssl/evp.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
|
||||
/* delta.c owns the single XXH_IMPLEMENTATION that provides the xxHash symbols
|
||||
* for the whole binary; this TU only needs the declarations. */
|
||||
#include <xxhash.h>
|
||||
|
||||
bool checksum_digest(ChecksumAlgo algo, uint64_t seed, const void* data, size_t size, uint8_t* out,
|
||||
size_t out_capacity, size_t* out_len) {
|
||||
if (!out || !out_len || out_capacity < CHECKSUM_MAX_DIGEST_LEN)
|
||||
return false;
|
||||
if (data == NULL && size != 0)
|
||||
return false;
|
||||
|
||||
if (algo == CHECKSUM_ALGO_XXH64) {
|
||||
uint64_t digest = XXH64(data, size, seed);
|
||||
memcpy(out, &digest, sizeof(digest));
|
||||
*out_len = sizeof(digest);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (algo == CHECKSUM_ALGO_MD5) {
|
||||
/* md5 takes no seed; the caller's seed is deliberately ignored (documented
|
||||
* in RSYNC_COMPAT.md). OpenSSL's one-shot EVP_Digest needs a non-NULL
|
||||
* buffer even for an empty input, so map a NULL data + size==0 to an empty
|
||||
* buffer. */
|
||||
static const uint8_t empty = 0;
|
||||
const void* input = data ? data : ∅
|
||||
unsigned int digest_len = 0;
|
||||
if (EVP_Digest(input, size, out, &digest_len, EVP_md5(), NULL) != 1)
|
||||
return false;
|
||||
if (digest_len > out_capacity)
|
||||
return false;
|
||||
*out_len = digest_len;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
int checksum_algo_from_name(const char* name) {
|
||||
if (!name)
|
||||
return -1;
|
||||
if (strcasecmp(name, "xxh64") == 0 || strcasecmp(name, "xxhash") == 0)
|
||||
return (int)CHECKSUM_ALGO_XXH64;
|
||||
if (strcasecmp(name, "md5") == 0)
|
||||
return (int)CHECKSUM_ALGO_MD5;
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char* checksum_algo_name(ChecksumAlgo algo) {
|
||||
switch (algo) {
|
||||
case CHECKSUM_ALGO_XXH64:
|
||||
return "xxh64";
|
||||
case CHECKSUM_ALGO_MD5:
|
||||
return "md5";
|
||||
}
|
||||
return "<unknown>";
|
||||
}
|
||||
|
||||
bool checksum_algo_valid(int algo) {
|
||||
return algo == (int)CHECKSUM_ALGO_XXH64 || algo == (int)CHECKSUM_ALGO_MD5;
|
||||
}
|
||||
|
||||
uint8_t checksum_digest_len(ChecksumAlgo algo) {
|
||||
switch (algo) {
|
||||
case CHECKSUM_ALGO_XXH64:
|
||||
return 8;
|
||||
case CHECKSUM_ALGO_MD5:
|
||||
return 16;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
#ifndef CHECKSUM_H
|
||||
#define CHECKSUM_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/* Whole-file content-digest algorithms selectable with --checksum-choice and
|
||||
* seeded with --checksum-seed. The ids are the values actually placed on the
|
||||
* wire (config frame), so they must be kept stable and validated on receive.
|
||||
* CHECKSUM_ALGO_XXH64 == 0 is the default and is byte-for-byte what FastSync
|
||||
* computed before these options existed (xxHash64 with seed 0). */
|
||||
typedef enum { CHECKSUM_ALGO_XXH64 = 0, CHECKSUM_ALGO_MD5 = 1 } ChecksumAlgo;
|
||||
|
||||
/* md5 digest is 16 bytes, the longest supported. */
|
||||
#define CHECKSUM_MAX_DIGEST_LEN 16
|
||||
|
||||
/* Compute the whole-file digest of the first `size` bytes of `data`.
|
||||
*
|
||||
* - CHECKSUM_ALGO_XXH64: xxHash64(data, size, seed) (full 64-bit seed).
|
||||
* - CHECKSUM_ALGO_MD5: md5(data, size) via OpenSSL EVP.
|
||||
* md5 has no seed, so `seed` is ignored (documented).
|
||||
* - `size == 0` hashes the empty input (plus its seed), not a NULL input.
|
||||
*
|
||||
* Writes up to `out_capacity` bytes into `out`, storing the digest length in
|
||||
* *out_len. Returns false on NULL out* or when the digest would not fit.
|
||||
* Never writes more than CHECKSUM_MAX_DIGEST_LEN bytes. */
|
||||
bool checksum_digest(ChecksumAlgo algo, uint64_t seed, const void* data, size_t size, uint8_t* out,
|
||||
size_t out_capacity, size_t* out_len);
|
||||
|
||||
/* Resolve a --checksum-choice string (case-insensitive) to an algorithm id.
|
||||
* Accepts "xxh64" and "xxhash" (both map to CHECKSUM_ALGO_XXH64, rsync's
|
||||
* xxhash spelling) and "md5". Returns -1 for any unsupported name. */
|
||||
int checksum_algo_from_name(const char* name);
|
||||
|
||||
/* Canonical name of an algorithm (used in CLI error messages). */
|
||||
const char* checksum_algo_name(ChecksumAlgo algo);
|
||||
|
||||
/* True when `algo` is a supported id (used by config receive validation). */
|
||||
bool checksum_algo_valid(int algo);
|
||||
|
||||
/* Digest length in bytes for an algorithm (xxx64 = 8, md5 = 16). */
|
||||
uint8_t checksum_digest_len(ChecksumAlgo algo);
|
||||
|
||||
#endif /* CHECKSUM_H */
|
||||
@@ -1,90 +0,0 @@
|
||||
#include "chmod.h"
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
static bool parse_clause(mode_t* mode, const char* begin, const char* end) {
|
||||
const char* p = begin;
|
||||
unsigned who = 0;
|
||||
while (p < end && strchr("ugoa", *p)) {
|
||||
if (*p == 'a')
|
||||
who = 7;
|
||||
else
|
||||
who |= *p == 'u' ? 1U : (*p == 'g' ? 2U : 4U);
|
||||
p++;
|
||||
}
|
||||
if (who == 0)
|
||||
who = 7;
|
||||
if (p == end || (*p != '+' && *p != '-' && *p != '='))
|
||||
return false;
|
||||
char operation = *p++;
|
||||
mode_t bits = 0;
|
||||
while (p < end) {
|
||||
mode_t bit;
|
||||
switch (*p++) {
|
||||
case 'r':
|
||||
bit = 4;
|
||||
break;
|
||||
case 'w':
|
||||
bit = 2;
|
||||
break;
|
||||
case 'x':
|
||||
bit = 1;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
bits |= bit;
|
||||
}
|
||||
for (unsigned class_index = 0; class_index < 3; class_index++) {
|
||||
unsigned class_bit = 1U << class_index;
|
||||
if (!(who & class_bit))
|
||||
continue;
|
||||
mode_t shift = (mode_t)((2U - class_index) * 3U);
|
||||
mode_t mask = (mode_t)(7U << shift);
|
||||
mode_t class_bits = (mode_t)(bits << shift);
|
||||
if (operation == '+')
|
||||
*mode |= class_bits;
|
||||
else if (operation == '-')
|
||||
*mode &= ~class_bits;
|
||||
else
|
||||
*mode = (*mode & ~mask) | class_bits;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool chmod_apply(mode_t mode, const char* spec, mode_t* result) {
|
||||
if (!spec || !*spec || !result)
|
||||
return false;
|
||||
bool numeric = true;
|
||||
size_t length = strlen(spec);
|
||||
if (length > 4)
|
||||
numeric = false;
|
||||
for (size_t i = 0; i < length && numeric; i++)
|
||||
numeric = spec[i] >= '0' && spec[i] <= '7';
|
||||
if (numeric) {
|
||||
if (length == 0 || length > 4)
|
||||
return false;
|
||||
mode_t parsed = 0;
|
||||
for (size_t i = 0; i < length; i++)
|
||||
parsed = (mode_t)((parsed << 3) | (spec[i] - '0'));
|
||||
*result = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
mode_t changed = mode;
|
||||
const char* begin = spec;
|
||||
while (*begin) {
|
||||
const char* end = strchr(begin, ',');
|
||||
if (!end)
|
||||
end = begin + strlen(begin);
|
||||
if (!parse_clause(&changed, begin, end))
|
||||
return false;
|
||||
if (*end == '\0')
|
||||
break;
|
||||
begin = end + 1;
|
||||
if (!*begin)
|
||||
return false;
|
||||
}
|
||||
*result = changed;
|
||||
return true;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
#ifndef CHMOD_H
|
||||
#define CHMOD_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
/* Apply the supported rsync --chmod syntax to a permission mode. */
|
||||
bool chmod_apply(mode_t mode, const char* spec, mode_t* result);
|
||||
|
||||
#endif
|
||||
+70
-401
@@ -1,13 +1,9 @@
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <limits.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "array_list.h"
|
||||
#include "charset.h"
|
||||
#include "chunk.h"
|
||||
#include "compression.h"
|
||||
#include "data.h"
|
||||
@@ -15,60 +11,18 @@
|
||||
#include "log.h"
|
||||
#include "metadata.h"
|
||||
#include "protocol.h"
|
||||
#include "utils.h"
|
||||
|
||||
/* Maximum individual file data size within a chunk (64 MB) */
|
||||
#define MAX_FILE_DATA_SIZE (64ULL * 1024 * 1024)
|
||||
#define MAX_FILES_PER_CHUNK 65536U
|
||||
|
||||
/* Reserve `charge` against `session`'s connection budget. This mirrors the
|
||||
static protocol_reserve_memory() in protocol.c: the receive-side call sites
|
||||
only have the Data.owner pointer (a ProtocolSession*), and protocol.c is out
|
||||
of scope for this fix, so the same atomic CAS accounting is reproduced here.
|
||||
The matching release always goes through data_destroy()'s Data.owner path. */
|
||||
static bool chunk_session_reserve(ProtocolSession* session, size_t charge) {
|
||||
unsigned long long allocated = atomic_load(&session->total_allocated_bytes);
|
||||
while (true) {
|
||||
if (allocated > MAX_CONNECTION_MEMORY ||
|
||||
(unsigned long long)charge > MAX_CONNECTION_MEMORY - allocated)
|
||||
return false;
|
||||
if (atomic_compare_exchange_weak(&session->total_allocated_bytes, &allocated,
|
||||
allocated + (unsigned long long)charge))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool data_charge_session(Data* data, ProtocolSession* session, size_t charge) {
|
||||
if (!data || charge == 0 || session == NULL)
|
||||
return true;
|
||||
if (!chunk_session_reserve(session, charge))
|
||||
return false;
|
||||
data->owner = session;
|
||||
data->protocol_charge = charge;
|
||||
return true;
|
||||
}
|
||||
|
||||
Chunk* chunk_create(File** items, int element_count) {
|
||||
if (element_count < 0 || (element_count > 0 && items == NULL))
|
||||
return NULL;
|
||||
Chunk* chunk = (Chunk*)protocol_alloc(sizeof(Chunk));
|
||||
Chunk *chunk_create(File **items, int element_count) {
|
||||
Chunk *chunk = (Chunk *)malloc(sizeof(Chunk));
|
||||
if (chunk == NULL) {
|
||||
log_perror("ERROR: Could not allocate memory for chunk structure");
|
||||
perror("ERROR: Could not allocate memory for chunk structure");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (element_count == 0) {
|
||||
chunk->items = NULL;
|
||||
} else {
|
||||
if ((size_t)element_count > SIZE_MAX / sizeof(File*)) {
|
||||
free(chunk);
|
||||
return NULL;
|
||||
}
|
||||
chunk->items = (File**)protocol_alloc((size_t)element_count * sizeof(File*));
|
||||
if (chunk->items == NULL) {
|
||||
free(chunk);
|
||||
return NULL;
|
||||
}
|
||||
chunk->items = (File **)malloc(element_count * sizeof(File *));
|
||||
if (chunk->items == NULL) {
|
||||
free(chunk);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
for (int i = 0; i < element_count; i++) {
|
||||
@@ -78,11 +32,11 @@ Chunk* chunk_create(File** items, int element_count) {
|
||||
return chunk;
|
||||
}
|
||||
|
||||
void chunk_destroy(void* item) {
|
||||
void chunk_destroy(void *item) {
|
||||
if (item == NULL) {
|
||||
return;
|
||||
}
|
||||
Chunk* chunk = (Chunk*)item;
|
||||
Chunk *chunk = (Chunk *)item;
|
||||
for (int i = 0; i < chunk->element_count; ++i) {
|
||||
if (chunk->items[i] != NULL) {
|
||||
file_destroy(chunk->items[i]);
|
||||
@@ -92,112 +46,31 @@ void chunk_destroy(void* item) {
|
||||
free(chunk);
|
||||
}
|
||||
|
||||
/* --iconv: a chunk blob carries wire-charset path/target bytes. Encode the
|
||||
* sender-side path (a no-op copy when iconv is disabled) so the blob is in the
|
||||
* same charset as every other wire string. */
|
||||
static char* chunk_encode_wire(const char* path) {
|
||||
if (!charset_wire_active())
|
||||
return str_dup(path);
|
||||
return charset_wire_apply(path);
|
||||
static unsigned long long per_file_serialize_size(File *file, bool use_metadata) {
|
||||
return sizeof(size_t) + strlen(file->path) +
|
||||
(use_metadata ? sizeof(int) + (file->metadata ? FILE_METADATA_WIRE_SIZE : 0) : 0) +
|
||||
sizeof(size_t) + file->data->size;
|
||||
}
|
||||
|
||||
static unsigned long long per_file_serialize_size(File* file, bool use_metadata) {
|
||||
unsigned long long size = sizeof(size_t);
|
||||
char* wire_path = chunk_encode_wire(file_wire_path(file));
|
||||
if (!wire_path)
|
||||
return 0;
|
||||
size_t path_len = strlen(wire_path);
|
||||
free(wire_path);
|
||||
unsigned long long metadata_size =
|
||||
use_metadata ? sizeof(int) + (file->metadata ? FILE_METADATA_WIRE_SIZE : 0) : 0;
|
||||
if ((unsigned long long)path_len > ULLONG_MAX - size)
|
||||
return 0;
|
||||
size += path_len;
|
||||
if (metadata_size > ULLONG_MAX - size)
|
||||
return 0;
|
||||
size += metadata_size;
|
||||
/* Entry type marker: 0 = regular file, 1 = explicit directory entry,
|
||||
2 = symlink entry (carries its target string), 3 = special/device node
|
||||
(recreated by the receiver). */
|
||||
if (sizeof(int) > ULLONG_MAX - size)
|
||||
return 0;
|
||||
size += sizeof(int);
|
||||
/* A special node also carries its rdev major/minor. */
|
||||
if (file->is_special) {
|
||||
if (2 * sizeof(int32_t) > ULLONG_MAX - size)
|
||||
return 0;
|
||||
size += 2 * sizeof(int32_t);
|
||||
}
|
||||
if (sizeof(size_t) > ULLONG_MAX - size)
|
||||
return 0;
|
||||
size += sizeof(size_t);
|
||||
if ((unsigned long long)file->data->size > ULLONG_MAX - size)
|
||||
return 0;
|
||||
size += file->data->size;
|
||||
/* Symlink entries append the target string (length-prefixed). */
|
||||
if (file->is_symlink) {
|
||||
char* wire_target = chunk_encode_wire(file->symlink_target ? file->symlink_target : "");
|
||||
if (!wire_target)
|
||||
return 0;
|
||||
size_t target_len = strlen(wire_target);
|
||||
free(wire_target);
|
||||
if (sizeof(size_t) > ULLONG_MAX - size)
|
||||
return 0;
|
||||
size += sizeof(size_t);
|
||||
if ((unsigned long long)target_len > ULLONG_MAX - size)
|
||||
return 0;
|
||||
size += target_len;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
Data* chunk_serialize(Chunk* chunk, bool use_metadata) {
|
||||
if (!chunk || chunk->element_count < 0 || (chunk->element_count > 0 && chunk->items == NULL))
|
||||
return NULL;
|
||||
Data *chunk_serialize(Chunk *chunk, bool use_metadata) {
|
||||
unsigned long long data_size = 0;
|
||||
for (int i = 0; i < chunk->element_count; i++) {
|
||||
if (!chunk->items[i] || !chunk->items[i]->path || !chunk->items[i]->data ||
|
||||
(chunk->items[i]->data->size > 0 && !chunk->items[i]->data->data) ||
|
||||
chunk->items[i]->path[0] == '\0' || has_path_traversal(chunk->items[i]->path) ||
|
||||
(file_wire_path(chunk->items[i]))[0] == '\0')
|
||||
return NULL;
|
||||
unsigned long long file_size = per_file_serialize_size(chunk->items[i], use_metadata);
|
||||
if (file_size == 0 || file_size > ULLONG_MAX - data_size || data_size + file_size > SIZE_MAX)
|
||||
return NULL;
|
||||
data_size += file_size;
|
||||
data_size += per_file_serialize_size(chunk->items[i], use_metadata);
|
||||
}
|
||||
Data* data = data_create_empty(data_size);
|
||||
Data *data = data_create_empty(data_size);
|
||||
if (data == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "Could not allocate memory for chunk serialization");
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"Could not allocate memory for chunk serialization");
|
||||
return NULL;
|
||||
}
|
||||
char* data_pointer = data->data;
|
||||
char *data_pointer = data->data;
|
||||
for (int i = 0; i < chunk->element_count; i++) {
|
||||
File* file = chunk->items[i];
|
||||
char* wire_path = chunk_encode_wire(file_wire_path(file));
|
||||
if (wire_path == NULL) {
|
||||
data_destroy(data);
|
||||
return NULL;
|
||||
}
|
||||
size_t path_len = strlen(wire_path);
|
||||
File *file = chunk->items[i];
|
||||
size_t path_len = strlen(file->path);
|
||||
memcpy(data_pointer, &path_len, sizeof(size_t));
|
||||
data_pointer += sizeof(size_t);
|
||||
memcpy(data_pointer, wire_path, path_len);
|
||||
memcpy(data_pointer, file->path, path_len);
|
||||
data_pointer += path_len;
|
||||
free(wire_path);
|
||||
|
||||
int entry_type = file->is_dir ? 1 : (file->is_symlink ? 2 : (file->is_special ? 3 : 0));
|
||||
memcpy(data_pointer, &entry_type, sizeof(int));
|
||||
data_pointer += sizeof(int);
|
||||
|
||||
if (file->is_special) {
|
||||
int32_t special_major = file->rdev_major;
|
||||
int32_t special_minor = file->rdev_minor;
|
||||
memcpy(data_pointer, &special_major, sizeof(special_major));
|
||||
data_pointer += sizeof(special_major);
|
||||
memcpy(data_pointer, &special_minor, sizeof(special_minor));
|
||||
data_pointer += sizeof(special_minor);
|
||||
}
|
||||
|
||||
if (use_metadata)
|
||||
metadata_to_buf(&data_pointer, file->metadata);
|
||||
@@ -205,332 +78,128 @@ Data* chunk_serialize(Chunk* chunk, bool use_metadata) {
|
||||
size_t file_data_size = file->data->size;
|
||||
memcpy(data_pointer, &file_data_size, sizeof(size_t));
|
||||
data_pointer += sizeof(size_t);
|
||||
if (file_data_size > 0)
|
||||
memcpy(data_pointer, file->data->data, file_data_size);
|
||||
memcpy(data_pointer, file->data->data, file_data_size);
|
||||
data_pointer += file_data_size;
|
||||
|
||||
if (file->is_symlink) {
|
||||
char* wire_target = chunk_encode_wire(file->symlink_target ? file->symlink_target : "");
|
||||
if (wire_target == NULL) {
|
||||
data_destroy(data);
|
||||
return NULL;
|
||||
}
|
||||
size_t target_len = strlen(wire_target);
|
||||
memcpy(data_pointer, &target_len, sizeof(size_t));
|
||||
data_pointer += sizeof(size_t);
|
||||
if (target_len > 0)
|
||||
memcpy(data_pointer, wire_target, target_len);
|
||||
data_pointer += target_len;
|
||||
free(wire_target);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
Chunk* chunk_deserialize(Data* data, bool use_metadata) {
|
||||
if (!data || (!data->data && data->size != 0))
|
||||
return NULL;
|
||||
ArrayList* files = array_list_create(file_destroy);
|
||||
if (files == NULL)
|
||||
return NULL;
|
||||
char* data_pointer = data->data;
|
||||
Chunk *chunk_deserialize(Data *data, bool use_metadata) {
|
||||
ArrayList *files = array_list_create(file_destroy);
|
||||
char *data_pointer = data->data;
|
||||
size_t remaining_size = data->size;
|
||||
/* The element currently being parsed is owned by `files` only after the
|
||||
* array_list_add() at the end of the iteration; until then the error
|
||||
* epilogue destroys it directly. Keeping this one pointer nulled after the
|
||||
* hand-off makes the single cleanup path correct for every failure. */
|
||||
File* file = NULL;
|
||||
|
||||
while (remaining_size > 0) {
|
||||
if ((unsigned int)files->size >= MAX_FILES_PER_CHUNK) {
|
||||
log_message(LOG_LEVEL_ERROR, "Chunk contains too many files");
|
||||
goto error;
|
||||
}
|
||||
if (remaining_size < sizeof(size_t)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid chunk format: not enough data for path length");
|
||||
goto error;
|
||||
array_list_delete(files);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
size_t path_len;
|
||||
memcpy(&path_len, data_pointer, sizeof(size_t));
|
||||
size_t path_len = *(size_t *)data_pointer;
|
||||
data_pointer += sizeof(size_t);
|
||||
remaining_size -= sizeof(size_t);
|
||||
|
||||
if (path_len > SIZE_MAX - 1 || remaining_size < path_len) {
|
||||
if (remaining_size < path_len) {
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid chunk format: not enough data for path");
|
||||
goto error;
|
||||
array_list_delete(files);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char* path = protocol_alloc(path_len + 1);
|
||||
char *path = malloc(path_len + 1);
|
||||
if (path == NULL) {
|
||||
log_perror("Could not allocate memory for file path");
|
||||
goto error;
|
||||
perror("Could not allocate memory for file path");
|
||||
array_list_delete(files);
|
||||
return NULL;
|
||||
}
|
||||
memcpy(path, data_pointer, path_len);
|
||||
path[path_len] = '\0';
|
||||
if (memchr(path, '\0', path_len) != NULL) {
|
||||
free(path);
|
||||
goto error;
|
||||
}
|
||||
data_pointer += path_len;
|
||||
remaining_size -= path_len;
|
||||
|
||||
/* --iconv: the blob holds the wire charset; translate it to the receiver's
|
||||
local charset before validation and creation so the destination gets the
|
||||
local name. A name that cannot be decoded fails the file cleanly. */
|
||||
if (charset_wire_active()) {
|
||||
char* local_path = charset_wire_apply(path);
|
||||
free(path);
|
||||
if (local_path == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--iconv: received chunk file name cannot be converted to the local charset");
|
||||
goto error;
|
||||
}
|
||||
path = local_path;
|
||||
path_len = strlen(path);
|
||||
}
|
||||
|
||||
if (path_len == 0 || has_path_traversal(path)) {
|
||||
free(path);
|
||||
goto error;
|
||||
}
|
||||
|
||||
file = file_create(path);
|
||||
File *file = file_create(path);
|
||||
free(path);
|
||||
if (file == NULL)
|
||||
goto error;
|
||||
|
||||
if (remaining_size < sizeof(int)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid chunk format: not enough data for entry type");
|
||||
goto error;
|
||||
}
|
||||
int entry_type;
|
||||
memcpy(&entry_type, data_pointer, sizeof(int));
|
||||
if (entry_type != 0 && entry_type != 1 && entry_type != 2 && entry_type != 3) {
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid chunk format: bad entry type");
|
||||
goto error;
|
||||
}
|
||||
file->is_dir = entry_type == 1;
|
||||
file->is_symlink = entry_type == 2;
|
||||
file->is_special = entry_type == 3;
|
||||
data_pointer += sizeof(int);
|
||||
remaining_size -= sizeof(int);
|
||||
|
||||
if (file->is_special) {
|
||||
if (remaining_size < 2 * (int32_t)sizeof(int32_t)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid chunk format: not enough data for special rdev");
|
||||
goto error;
|
||||
}
|
||||
int32_t special_major, special_minor;
|
||||
memcpy(&special_major, data_pointer, sizeof(special_major));
|
||||
data_pointer += sizeof(special_major);
|
||||
memcpy(&special_minor, data_pointer, sizeof(special_minor));
|
||||
data_pointer += sizeof(special_minor);
|
||||
remaining_size -= 2 * sizeof(int32_t);
|
||||
/* Reject an out-of-range/negative rdev here as a malformed chunk (the
|
||||
same 0xffff / 0x00ffffff bounds file_special_rdev_valid uses), so a
|
||||
bogus large-but-positive rdev is refused cleanly instead of being
|
||||
deferred to the creation site where it would abort after the frame. */
|
||||
if (special_major < 0 || special_minor < 0 || special_major > 0xffff ||
|
||||
special_minor > 0x00ffffff) {
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid chunk format: out-of-range special rdev");
|
||||
goto error;
|
||||
}
|
||||
file->rdev_major = special_major;
|
||||
file->rdev_minor = special_minor;
|
||||
}
|
||||
|
||||
if (use_metadata) {
|
||||
if (remaining_size < sizeof(int)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid chunk format: not enough data for metadata");
|
||||
goto error;
|
||||
}
|
||||
/* Peek at the present flag to determine the total record size before
|
||||
decoding. metadata_from_buf() independently bounds-checks every read
|
||||
against remaining_size, so a short body can never over-read. */
|
||||
int present_flag;
|
||||
memcpy(&present_flag, data_pointer, sizeof(int));
|
||||
if ((present_flag != 0 && present_flag != 1) ||
|
||||
(present_flag == 1 && remaining_size < sizeof(int) + FILE_METADATA_WIRE_SIZE)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid chunk format: not enough data for metadata body");
|
||||
goto error;
|
||||
}
|
||||
file->metadata = metadata_from_buf((const uint8_t*)data_pointer, remaining_size);
|
||||
size_t metadata_consumed = sizeof(int);
|
||||
if (present_flag == 1) {
|
||||
if (file->metadata == NULL)
|
||||
goto error;
|
||||
metadata_consumed += FILE_METADATA_WIRE_SIZE;
|
||||
}
|
||||
data_pointer += metadata_consumed;
|
||||
remaining_size -= metadata_consumed;
|
||||
file->metadata = metadata_from_buf(&data_pointer);
|
||||
remaining_size -= sizeof(int);
|
||||
if (file->metadata)
|
||||
remaining_size -= FILE_METADATA_WIRE_SIZE;
|
||||
}
|
||||
|
||||
if (remaining_size < sizeof(size_t)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid chunk format: not enough data for data size");
|
||||
goto error;
|
||||
array_list_delete(files);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
size_t file_data_size;
|
||||
memcpy(&file_data_size, data_pointer, sizeof(size_t));
|
||||
size_t file_data_size = *(size_t *)data_pointer;
|
||||
data_pointer += sizeof(size_t);
|
||||
remaining_size -= sizeof(size_t);
|
||||
|
||||
if (remaining_size < file_data_size) {
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid chunk format: not enough data for file content");
|
||||
goto error;
|
||||
array_list_delete(files);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Reject individual file data larger than the maximum allowed size.
|
||||
if (file_data_size > MAX_FILE_DATA_SIZE) {
|
||||
log_message(LOG_LEVEL_ERROR, "File data size %zu exceeds maximum %llu", file_data_size,
|
||||
(unsigned long long)MAX_FILE_DATA_SIZE);
|
||||
goto error;
|
||||
}
|
||||
|
||||
size_t allocation_size = file_data_size > 0 ? file_data_size : 1;
|
||||
void* file_data = protocol_alloc(allocation_size);
|
||||
void *file_data = malloc(file_data_size);
|
||||
if (file_data == NULL) {
|
||||
log_perror("Could not allocate memory for file data");
|
||||
goto error;
|
||||
perror("Could not allocate memory for file data");
|
||||
array_list_delete(files);
|
||||
return NULL;
|
||||
}
|
||||
memcpy(file_data, data_pointer, file_data_size);
|
||||
Data* replacement = data_create(file_data, file_data_size);
|
||||
if (replacement == NULL)
|
||||
goto error;
|
||||
/* Charge the retained per-file copy to the connection budget (when the
|
||||
inbound chunk carries an owning session) so the queued copies are not
|
||||
held outside MAX_CONNECTION_MEMORY (B6). A NULL owner (e.g. a local
|
||||
batch apply) leaves the copy uncharged. */
|
||||
if (!data_charge_session(replacement, data->owner, allocation_size)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Per-connection memory limit exceeded for chunk file data");
|
||||
data_destroy(replacement);
|
||||
goto error;
|
||||
}
|
||||
data_destroy(file->data);
|
||||
file->data = replacement;
|
||||
file->data = data_create(file_data, file_data_size);
|
||||
data_pointer += file_data_size;
|
||||
remaining_size -= file_data_size;
|
||||
|
||||
if (file->is_symlink) {
|
||||
if (remaining_size < sizeof(size_t)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid chunk format: not enough data for symlink target");
|
||||
goto error;
|
||||
}
|
||||
size_t target_len;
|
||||
memcpy(&target_len, data_pointer, sizeof(size_t));
|
||||
data_pointer += sizeof(size_t);
|
||||
remaining_size -= sizeof(size_t);
|
||||
if (target_len == 0 || remaining_size < target_len) {
|
||||
log_message(LOG_LEVEL_ERROR, "Invalid chunk format: bad symlink target");
|
||||
goto error;
|
||||
}
|
||||
char* target = protocol_alloc(target_len + 1);
|
||||
if (!target) {
|
||||
log_perror("Could not allocate memory for symlink target");
|
||||
goto error;
|
||||
}
|
||||
memcpy(target, data_pointer, target_len);
|
||||
target[target_len] = '\0';
|
||||
if (memchr(target, '\0', target_len) != NULL) {
|
||||
free(target);
|
||||
goto error;
|
||||
}
|
||||
/* The symlink target also rides the wire charset; decode it to the local
|
||||
charset like the path (a target is a path). */
|
||||
if (charset_wire_active()) {
|
||||
char* local_target = charset_wire_apply(target);
|
||||
free(target);
|
||||
if (local_target == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--iconv: received chunk symlink target cannot be converted to the local "
|
||||
"charset");
|
||||
goto error;
|
||||
}
|
||||
target = local_target;
|
||||
}
|
||||
file->symlink_target = target;
|
||||
data_pointer += target_len;
|
||||
remaining_size -= target_len;
|
||||
}
|
||||
|
||||
if (!array_list_add(files, file))
|
||||
goto error;
|
||||
file = NULL;
|
||||
array_list_add(files, file);
|
||||
}
|
||||
|
||||
File** file_array = (File**)array_list_to_array(files);
|
||||
if (files->size > 0 && file_array == NULL)
|
||||
goto error;
|
||||
Chunk* chunk = chunk_create(file_array, files->size);
|
||||
File **file_array = (File **)array_list_to_array(files);
|
||||
Chunk *chunk = chunk_create(file_array, files->size);
|
||||
|
||||
free(file_array);
|
||||
if (chunk == NULL)
|
||||
goto error;
|
||||
files->item_destroyer = NULL;
|
||||
array_list_delete(files);
|
||||
|
||||
return chunk;
|
||||
|
||||
error:
|
||||
if (file)
|
||||
file_destroy(file);
|
||||
array_list_delete(files);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
Data* chunk_compress(Chunk* chunk, int compression_level, bool use_metadata) {
|
||||
return chunk_compress_with_threads(chunk, compression_level, use_metadata, 0);
|
||||
}
|
||||
|
||||
Data* chunk_compress_with_threads(Chunk* chunk, int compression_level, bool use_metadata,
|
||||
int compression_threads) {
|
||||
Data *chunk_compress(Chunk *chunk, int compression_level, bool use_metadata) {
|
||||
log_message(LOG_LEVEL_DEBUG, "Starting to compress chunk");
|
||||
Data* serialized = chunk_serialize(chunk, use_metadata);
|
||||
if (serialized == NULL)
|
||||
return NULL;
|
||||
Data* compressed = data_compress_with_threads(serialized, compression_level, compression_threads);
|
||||
Data *serialized = chunk_serialize(chunk, use_metadata);
|
||||
if (serialized == NULL) return NULL;
|
||||
Data *compressed = data_compress(serialized, compression_level);
|
||||
data_destroy(serialized);
|
||||
if (compressed == NULL)
|
||||
return NULL;
|
||||
log_debug_message(LOG_DEBUG_PACK, "Chunk successfully compressed");
|
||||
if (compressed == NULL) return NULL;
|
||||
log_message(LOG_LEVEL_DEBUG, "Chunk successfully compressed");
|
||||
return compressed;
|
||||
}
|
||||
|
||||
Chunk* receive_chunk_data(int fd, const Config* config) {
|
||||
Data* chunk_data = receive_data_limited(fd, MAX_CHUNK_SIZE);
|
||||
Chunk *receive_chunk_data(int fd, Config *config) {
|
||||
Data *chunk_data = receive_data(fd);
|
||||
if (chunk_data == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to receive chunk data");
|
||||
return NULL;
|
||||
}
|
||||
Data* data_to_process = chunk_data;
|
||||
Data *data_to_process = chunk_data;
|
||||
if (config->use_compression) {
|
||||
/* Preserve the inbound session across decompression so the (larger)
|
||||
decompressed chunk is charged to the same connection budget; the
|
||||
compressed buffer's own charge is released by data_destroy below. */
|
||||
ProtocolSession* owner = chunk_data->owner;
|
||||
data_to_process = data_decompress_limited(chunk_data, MAX_CHUNK_SIZE);
|
||||
data_to_process = data_decompress(chunk_data);
|
||||
data_destroy(chunk_data);
|
||||
if (data_to_process == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to decompress chunk");
|
||||
return NULL;
|
||||
}
|
||||
if (!data_charge_session(data_to_process, owner, data_to_process->size)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Per-connection memory limit exceeded for decompressed chunk");
|
||||
data_destroy(data_to_process);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
// Reject chunks larger than the maximum allowed size to prevent OOM.
|
||||
if (data_to_process->size > MAX_CHUNK_SIZE) {
|
||||
log_message(LOG_LEVEL_ERROR, "Chunk size %zu exceeds maximum %llu", data_to_process->size,
|
||||
(unsigned long long)MAX_CHUNK_SIZE);
|
||||
data_destroy(data_to_process);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
Chunk* chunk = chunk_deserialize(data_to_process, config->use_metadata);
|
||||
Chunk *chunk = chunk_deserialize(data_to_process, config->use_metadata);
|
||||
data_destroy(data_to_process);
|
||||
if (chunk == NULL)
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to deserialize chunk, skipping");
|
||||
return chunk;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
+7
-20
@@ -10,28 +10,15 @@
|
||||
#define DESIRED_CHUNK_SIZE (10 * 1024 * 1024)
|
||||
|
||||
typedef struct {
|
||||
File** items;
|
||||
File **items;
|
||||
int element_count;
|
||||
} Chunk;
|
||||
|
||||
Chunk* chunk_create(File** items, int element_count);
|
||||
void chunk_destroy(void* chunk);
|
||||
Data* chunk_serialize(Chunk* chunk, bool use_metadata);
|
||||
Chunk* chunk_deserialize(Data* data, bool use_metadata);
|
||||
Data* chunk_compress(Chunk* chunk, int compression_level, bool use_metadata);
|
||||
Data* chunk_compress_with_threads(Chunk* chunk, int compression_level, bool use_metadata,
|
||||
int compression_threads);
|
||||
Chunk* receive_chunk_data(int fd, const Config* config);
|
||||
|
||||
/* Charge `charge` retained bytes of `data` against `session`'s per-connection
|
||||
* budget (MAX_CONNECTION_MEMORY), mirroring the protocol layer's accounting, and
|
||||
* record them on `data` so data_destroy() returns the charge through the
|
||||
* Data.owner path. Returns false (leaving `data` uncharged) when the ceiling
|
||||
* would be exceeded. A NULL/zero-size charge or a NULL session is a no-op
|
||||
* success. The receive-side decompression and chunk-copy paths know the owning
|
||||
* session only through the Data.owner of the buffer they are processing, so
|
||||
* this is the entry point that lets them participate in the connection budget
|
||||
* without a session handle (B6). */
|
||||
bool data_charge_session(Data* data, ProtocolSession* session, size_t charge);
|
||||
Chunk *chunk_create(File **items, int element_count);
|
||||
void chunk_destroy(void *chunk);
|
||||
Data *chunk_serialize(Chunk *chunk, bool use_metadata);
|
||||
Chunk *chunk_deserialize(Data *data, bool use_metadata);
|
||||
Data *chunk_compress(Chunk *chunk, int compression_level, bool use_metadata);
|
||||
Chunk *receive_chunk_data(int fd, Config *config);
|
||||
|
||||
#endif
|
||||
|
||||
+46
-300
@@ -1,298 +1,72 @@
|
||||
#include "compression.h"
|
||||
#include "data.h"
|
||||
#include "log.h"
|
||||
#include "protocol.h"
|
||||
#include <limits.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <threads.h>
|
||||
#include <unistd.h>
|
||||
#include <zstd.h>
|
||||
#include "stdlib.h"
|
||||
#include "zstd.h"
|
||||
|
||||
#define INITIAL_DECOMPRESS_BUF_SIZE (1024 * 1024)
|
||||
#define MAX_DECOMPRESSED_SIZE (100ULL * 1024 * 1024) /* 100 MB hard ceiling */
|
||||
|
||||
static char* SKIP_COMPRESSION_EXTENSIONS[] = {".jpg", ".jpeg", ".png", ".gif", ".mp4", ".mkv",
|
||||
".zip", ".gz", ".xz", ".zst", NULL};
|
||||
|
||||
bool compression_should_skip_with_suffixes(const char* path, char* const* suffixes, int count) {
|
||||
if (!path)
|
||||
return false;
|
||||
const char* dot = strrchr(path, '.');
|
||||
if (!dot)
|
||||
return false;
|
||||
if (count < 0) {
|
||||
suffixes = SKIP_COMPRESSION_EXTENSIONS;
|
||||
count = 0;
|
||||
while (SKIP_COMPRESSION_EXTENSIONS[count])
|
||||
count++;
|
||||
}
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (strcasecmp(dot, suffixes[i]) == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Per-thread cache of zstd contexts plus the grow-only compression scratch
|
||||
* buffer. zstd contexts are stateful and not safe to share between threads,
|
||||
* so each thread keeps its own (see compression_get_thread_ctx). The cache is
|
||||
* stored in a C11 thread-specific storage slot whose destructor releases the
|
||||
* contexts when the thread exits; this keeps LeakSanitizer clean for the
|
||||
* short-lived sender/receiver/scanner worker threads without every worker
|
||||
* entry point having to remember to call compression_free_thread_contexts().
|
||||
* The main thread's slot is not torn down by tss at process exit, so an atexit
|
||||
* hook releases it (and compression_free_thread_contexts allows eager
|
||||
* release). */
|
||||
typedef struct {
|
||||
ZSTD_CCtx* cctx;
|
||||
ZSTD_DCtx* dctx;
|
||||
void* out_buf; /* reusable ZSTD_compressBound-sized output scratch */
|
||||
size_t out_cap; /* bytes currently allocated for out_buf */
|
||||
int level; /* compression level currently applied to cctx */
|
||||
int workers; /* nbWorkers currently applied to cctx */
|
||||
bool params_set;
|
||||
bool cached; /* false when the TSS slot could not be used: caller owns */
|
||||
} CompressionThreadCtx;
|
||||
|
||||
static once_flag compression_tls_once = ONCE_FLAG_INIT;
|
||||
static tss_t compression_tls_key;
|
||||
static bool compression_tls_ready;
|
||||
|
||||
static void compression_tls_make_key(void);
|
||||
|
||||
static void compression_ctx_free(CompressionThreadCtx* ctx) {
|
||||
if (!ctx)
|
||||
return;
|
||||
if (ctx->cctx)
|
||||
ZSTD_freeCCtx(ctx->cctx);
|
||||
if (ctx->dctx)
|
||||
ZSTD_freeDCtx(ctx->dctx);
|
||||
free(ctx->out_buf);
|
||||
free(ctx);
|
||||
}
|
||||
|
||||
static void compression_tls_destructor(void* value) {
|
||||
compression_ctx_free((CompressionThreadCtx*)value);
|
||||
}
|
||||
|
||||
void compression_free_thread_contexts(void) {
|
||||
call_once(&compression_tls_once, compression_tls_make_key);
|
||||
if (!compression_tls_ready)
|
||||
return;
|
||||
CompressionThreadCtx* ctx = (CompressionThreadCtx*)tss_get(compression_tls_key);
|
||||
if (!ctx)
|
||||
return;
|
||||
/* Clear the slot first so the thread-exit destructor cannot free it twice. */
|
||||
tss_set(compression_tls_key, NULL);
|
||||
compression_ctx_free(ctx);
|
||||
}
|
||||
|
||||
static void compression_atexit_cleanup(void) {
|
||||
compression_free_thread_contexts();
|
||||
}
|
||||
|
||||
static void compression_tls_make_key(void) {
|
||||
if (tss_create(&compression_tls_key, compression_tls_destructor) == thrd_success) {
|
||||
compression_tls_ready = true;
|
||||
atexit(compression_atexit_cleanup);
|
||||
}
|
||||
}
|
||||
|
||||
static CompressionThreadCtx* compression_get_thread_ctx(void) {
|
||||
call_once(&compression_tls_once, compression_tls_make_key);
|
||||
if (!compression_tls_ready) {
|
||||
/* Extremely unlikely: fall back to an uncached context the caller frees. */
|
||||
return (CompressionThreadCtx*)calloc(1, sizeof(CompressionThreadCtx));
|
||||
}
|
||||
CompressionThreadCtx* ctx = (CompressionThreadCtx*)tss_get(compression_tls_key);
|
||||
if (ctx)
|
||||
return ctx;
|
||||
ctx = (CompressionThreadCtx*)calloc(1, sizeof(CompressionThreadCtx));
|
||||
if (!ctx)
|
||||
return NULL;
|
||||
ctx->cached = true;
|
||||
if (tss_set(compression_tls_key, ctx) != thrd_success)
|
||||
ctx->cached = false;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/* Release an uncached context immediately; cached contexts are owned by the
|
||||
* thread's TSS slot and freed on thread exit / compression_free_thread_contexts. */
|
||||
static void compression_ctx_put(CompressionThreadCtx* ctx) {
|
||||
if (ctx && !ctx->cached)
|
||||
compression_ctx_free(ctx);
|
||||
}
|
||||
|
||||
Data* data_compress(Data* data_to_compress, int compression_level) {
|
||||
return data_compress_with_threads(data_to_compress, compression_level, 0);
|
||||
}
|
||||
|
||||
Data* data_compress_with_threads(Data* data_to_compress, int compression_level,
|
||||
int compression_threads) {
|
||||
if (!data_to_compress || (!data_to_compress->data && data_to_compress->size != 0) ||
|
||||
compression_threads < 0 || compression_threads > COMPRESSION_MAX_THREADS)
|
||||
return NULL;
|
||||
Data *data_compress(Data *data_to_compress, int compression_level) {
|
||||
log_message(LOG_LEVEL_DEBUG, "Starting to compress data");
|
||||
size_t dst_size = ZSTD_compressBound(data_to_compress->size);
|
||||
Data *compressed_data = data_create_empty(dst_size);
|
||||
if (compressed_data == NULL) return NULL;
|
||||
|
||||
CompressionThreadCtx* ctx = compression_get_thread_ctx();
|
||||
if (ctx == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to allocate ZSTD compression context");
|
||||
ZSTD_CCtx *cctx = ZSTD_createCCtx();
|
||||
if (!cctx) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to create ZSTD compression context");
|
||||
data_destroy(compressed_data);
|
||||
return NULL;
|
||||
}
|
||||
Data* compressed_data = NULL;
|
||||
|
||||
if (!ctx->cctx) {
|
||||
ctx->cctx = ZSTD_createCCtx();
|
||||
if (!ctx->cctx) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to create ZSTD compression context");
|
||||
goto cleanup;
|
||||
}
|
||||
ctx->params_set = false;
|
||||
}
|
||||
|
||||
/* Reset only the session: parameters (and any already-allocated zstd worker
|
||||
* pool) stay attached to the context, so compressing the next file does not
|
||||
* rebuild the pool. */
|
||||
ZSTD_CCtx_reset(ctx->cctx, ZSTD_reset_session_only);
|
||||
|
||||
if (!ctx->params_set || ctx->level != compression_level) {
|
||||
size_t zret = ZSTD_CCtx_setParameter(ctx->cctx, ZSTD_c_compressionLevel, compression_level);
|
||||
if (ZSTD_isError(zret)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to set compression level: %s", ZSTD_getErrorName(zret));
|
||||
goto cleanup;
|
||||
}
|
||||
ctx->level = compression_level;
|
||||
}
|
||||
|
||||
int available_threads = 0;
|
||||
if (compression_threads > 0) {
|
||||
long online_cpus = sysconf(_SC_NPROCESSORS_ONLN);
|
||||
available_threads = online_cpus > 0 && online_cpus < compression_threads ? (int)online_cpus
|
||||
: compression_threads;
|
||||
}
|
||||
if (!ctx->params_set || ctx->workers != available_threads) {
|
||||
size_t zret = ZSTD_CCtx_setParameter(ctx->cctx, ZSTD_c_nbWorkers, available_threads);
|
||||
if (ZSTD_isError(zret)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to set compression threads: %s",
|
||||
ZSTD_getErrorName(zret));
|
||||
goto cleanup;
|
||||
}
|
||||
ctx->workers = available_threads;
|
||||
}
|
||||
ctx->params_set = true;
|
||||
|
||||
if (available_threads > 0) {
|
||||
/* Streaming compression needs the source size before threaded mode can end a frame. */
|
||||
size_t zret = ZSTD_CCtx_setPledgedSrcSize(ctx->cctx, data_to_compress->size);
|
||||
if (ZSTD_isError(zret)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to set compression source size: %s",
|
||||
ZSTD_getErrorName(zret));
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx->out_cap < dst_size) {
|
||||
void* grown = protocol_realloc(ctx->out_buf, dst_size);
|
||||
if (grown == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to allocate compression buffer");
|
||||
goto cleanup;
|
||||
}
|
||||
ctx->out_buf = grown;
|
||||
ctx->out_cap = dst_size;
|
||||
}
|
||||
|
||||
ZSTD_inBuffer input = {data_to_compress->data, data_to_compress->size, 0};
|
||||
ZSTD_outBuffer output = {ctx->out_buf, dst_size, 0};
|
||||
ZSTD_outBuffer output = {compressed_data->data, dst_size, 0};
|
||||
|
||||
size_t ret;
|
||||
do {
|
||||
ret = ZSTD_compressStream2(ctx->cctx, &output, &input, ZSTD_e_end);
|
||||
ret = ZSTD_compressStream2(cctx, &output, &input, ZSTD_e_end);
|
||||
if (ZSTD_isError(ret)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Compression failed: %s", ZSTD_getErrorName(ret));
|
||||
goto cleanup;
|
||||
log_message(LOG_LEVEL_ERROR, "Compression failed: %s",
|
||||
ZSTD_getErrorName(ret));
|
||||
ZSTD_freeCCtx(cctx);
|
||||
data_destroy(compressed_data);
|
||||
return NULL;
|
||||
}
|
||||
} while (ret > 0);
|
||||
|
||||
/* Hand off an exactly-sized copy; the scratch buffer stays cached so the next
|
||||
* call does not reallocate a ZSTD_compressBound-sized block. */
|
||||
compressed_data = data_create_empty(output.pos);
|
||||
if (compressed_data == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to allocate compressed data");
|
||||
goto cleanup;
|
||||
}
|
||||
if (output.pos > 0)
|
||||
memcpy(compressed_data->data, ctx->out_buf, output.pos);
|
||||
compressed_data->size = output.pos;
|
||||
ZSTD_freeCCtx(cctx);
|
||||
|
||||
log_debug_message(LOG_DEBUG_UTIL, "Data succesfully compressed from %zu to %zu",
|
||||
data_to_compress->size, compressed_data->size);
|
||||
|
||||
cleanup:
|
||||
compression_ctx_put(ctx);
|
||||
log_message(LOG_LEVEL_DEBUG, "Data succesfully compressed from %zu to %zu",
|
||||
data_to_compress->size, compressed_data->size);
|
||||
return compressed_data;
|
||||
}
|
||||
|
||||
Data* data_decompress_limited(Data* compressed_data, size_t maximum_size) {
|
||||
if (!compressed_data || (!compressed_data->data && compressed_data->size != 0) ||
|
||||
maximum_size == 0)
|
||||
return NULL;
|
||||
log_debug_message(LOG_DEBUG_UTIL, "Start to decompress data");
|
||||
unsigned long long dst_size =
|
||||
ZSTD_getFrameContentSize(compressed_data->data, compressed_data->size);
|
||||
/* ZSTD_isError() is also true for ZSTD_CONTENTSIZE_ERROR and
|
||||
* ZSTD_CONTENTSIZE_UNKNOWN (both are encoded near (size_t)-1), so test the
|
||||
* sentinels explicitly instead of blanket-rejecting every error-ish value:
|
||||
* only CONTENTSIZE_ERROR means an unreadable header, while CONTENTSIZE_UNKNOWN
|
||||
* must reach the estimate fallback below. */
|
||||
if (dst_size == ZSTD_CONTENTSIZE_ERROR) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to get decompressed size: invalid zstd frame");
|
||||
Data *data_decompress(Data *compressed_data) {
|
||||
log_message(LOG_LEVEL_DEBUG, "Start to decompress data");
|
||||
unsigned long long dst_size = ZSTD_getFrameContentSize(
|
||||
compressed_data->data, compressed_data->size);
|
||||
if (ZSTD_isError(dst_size)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to get decompressed size: %s",
|
||||
ZSTD_getErrorName(dst_size));
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// ZSTD_CONTENTSIZE_UNKNOWN (~2^64) can cause massive allocation;
|
||||
// fall back to a conservative estimate (3x compressed size) when unknown.
|
||||
if (dst_size == ZSTD_CONTENTSIZE_UNKNOWN) {
|
||||
if (compressed_data->size > ULLONG_MAX / 3)
|
||||
return NULL;
|
||||
dst_size = compressed_data->size * 3;
|
||||
if (dst_size < INITIAL_DECOMPRESS_BUF_SIZE)
|
||||
dst_size = INITIAL_DECOMPRESS_BUF_SIZE;
|
||||
}
|
||||
unsigned long long hard_limit =
|
||||
maximum_size < MAX_DECOMPRESSED_SIZE ? maximum_size : MAX_DECOMPRESSED_SIZE;
|
||||
if (dst_size > hard_limit) {
|
||||
log_message(LOG_LEVEL_ERROR, "Declared decompressed size exceeds %llu bytes", hard_limit);
|
||||
ZSTD_DCtx *dctx = ZSTD_createDCtx();
|
||||
if (!dctx) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"Failed to create ZSTD decompression context");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
CompressionThreadCtx* ctx = compression_get_thread_ctx();
|
||||
if (ctx == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to allocate ZSTD decompression context");
|
||||
return NULL;
|
||||
}
|
||||
Data* uncompressed_data = NULL;
|
||||
|
||||
if (!ctx->dctx) {
|
||||
ctx->dctx = ZSTD_createDCtx();
|
||||
if (!ctx->dctx) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to create ZSTD decompression context");
|
||||
goto cleanup;
|
||||
}
|
||||
}
|
||||
/* Reset only the session; decompression parameters are sticky. */
|
||||
ZSTD_DCtx_reset(ctx->dctx, ZSTD_reset_session_only);
|
||||
|
||||
size_t buf_size = (dst_size > 0) ? (size_t)dst_size : INITIAL_DECOMPRESS_BUF_SIZE;
|
||||
if (buf_size > maximum_size)
|
||||
buf_size = maximum_size;
|
||||
uncompressed_data = data_create_empty(buf_size);
|
||||
size_t buf_size = (!ZSTD_isError(dst_size) && dst_size > 0)
|
||||
? (size_t)dst_size
|
||||
: INITIAL_DECOMPRESS_BUF_SIZE;
|
||||
Data *uncompressed_data = data_create_empty(buf_size);
|
||||
if (!uncompressed_data) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to allocate decompression buffer");
|
||||
goto cleanup;
|
||||
ZSTD_freeDCtx(dctx);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
ZSTD_inBuffer input = {compressed_data->data, compressed_data->size, 0};
|
||||
@@ -300,60 +74,32 @@ Data* data_decompress_limited(Data* compressed_data, size_t maximum_size) {
|
||||
|
||||
size_t ret;
|
||||
do {
|
||||
ret = ZSTD_decompressStream(ctx->dctx, &output, &input);
|
||||
ret = ZSTD_decompressStream(dctx, &output, &input);
|
||||
if (ZSTD_isError(ret)) {
|
||||
log_message(LOG_LEVEL_ERROR, "Decompression failed: %s", ZSTD_getErrorName(ret));
|
||||
log_message(LOG_LEVEL_ERROR, "Decompression failed: %s",
|
||||
ZSTD_getErrorName(ret));
|
||||
ZSTD_freeDCtx(dctx);
|
||||
data_destroy(uncompressed_data);
|
||||
uncompressed_data = NULL;
|
||||
goto cleanup;
|
||||
return NULL;
|
||||
}
|
||||
if (ret > 0 && output.pos == output.size) {
|
||||
if (buf_size >= hard_limit || buf_size > SIZE_MAX / 2) {
|
||||
log_message(LOG_LEVEL_ERROR, "Decompressed data exceeds %llu bytes",
|
||||
(unsigned long long)MAX_DECOMPRESSED_SIZE);
|
||||
data_destroy(uncompressed_data);
|
||||
uncompressed_data = NULL;
|
||||
goto cleanup;
|
||||
}
|
||||
buf_size *= 2;
|
||||
if (buf_size > hard_limit)
|
||||
buf_size = (size_t)hard_limit;
|
||||
void* new_data = protocol_realloc(uncompressed_data->data, buf_size);
|
||||
void *new_data = realloc(uncompressed_data->data, buf_size);
|
||||
if (!new_data) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to grow decompression buffer");
|
||||
ZSTD_freeDCtx(dctx);
|
||||
data_destroy(uncompressed_data);
|
||||
uncompressed_data = NULL;
|
||||
goto cleanup;
|
||||
return NULL;
|
||||
}
|
||||
uncompressed_data->data = new_data;
|
||||
output.dst = new_data;
|
||||
output.size = buf_size;
|
||||
/* Re-attempt with the larger output buffer; the truncated-frame check
|
||||
* below must not reject a complete frame that merely filled the previous
|
||||
* buffer exactly. */
|
||||
continue;
|
||||
}
|
||||
/* A positive hint with all input consumed means the frame is incomplete: a
|
||||
* truncated stream would otherwise spin here forever (ZSTD_decompressStream
|
||||
* keeps returning the same hint). Fail instead of burning CPU. */
|
||||
if (ret != 0 && input.pos == input.size) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"Truncated zstd frame: input exhausted with %zu bytes still expected", ret);
|
||||
data_destroy(uncompressed_data);
|
||||
uncompressed_data = NULL;
|
||||
goto cleanup;
|
||||
}
|
||||
} while (ret > 0);
|
||||
|
||||
uncompressed_data->size = output.pos;
|
||||
ZSTD_freeDCtx(dctx);
|
||||
|
||||
log_debug_message(LOG_DEBUG_UTIL, "Decompressed data successfully");
|
||||
|
||||
cleanup:
|
||||
compression_ctx_put(ctx);
|
||||
log_message(LOG_LEVEL_DEBUG, "Decompressed data successfully");
|
||||
return uncompressed_data;
|
||||
}
|
||||
|
||||
Data* data_decompress(Data* compressed_data) {
|
||||
return data_decompress_limited(compressed_data, MAX_DECOMPRESSED_SIZE);
|
||||
}
|
||||
|
||||
@@ -2,23 +2,8 @@
|
||||
#define COMPRESSION_H
|
||||
|
||||
#include "data.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
#define COMPRESSION_MAX_THREADS 64
|
||||
|
||||
Data* data_compress(Data* data_to_compress, int compression_level);
|
||||
Data* data_compress_with_threads(Data* data_to_compress, int compression_level,
|
||||
int compression_threads);
|
||||
Data* data_decompress(Data* compressed_data);
|
||||
Data* data_decompress_limited(Data* compressed_data, size_t maximum_size);
|
||||
bool compression_should_skip_with_suffixes(const char* path, char* const* suffixes, int count);
|
||||
|
||||
/* Release the calling thread's cached zstd contexts (compressor, decompressor
|
||||
* and scratch buffer). The cache is thread-local and is also released
|
||||
* automatically when a worker thread exits (via a C11 tss destructor) and for
|
||||
* the main thread at process exit; this explicit entry point exists so tests
|
||||
* and long-lived callers can drop the cache deterministically. Safe to call
|
||||
* when no context has been created, and idempotent. */
|
||||
void compression_free_thread_contexts(void);
|
||||
Data *data_compress(Data *data_to_compress, int compression_level);
|
||||
Data *data_decompress(Data *compressed_data);
|
||||
|
||||
#endif
|
||||
|
||||
+118
-1291
File diff suppressed because it is too large
Load Diff
+38
-912
@@ -1,935 +1,61 @@
|
||||
#ifndef CONFIG_H
|
||||
#define CONFIG_H
|
||||
|
||||
#include "array_list.h"
|
||||
#include "checksum.h"
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
|
||||
typedef enum { TRANSPORT_TCP, TRANSPORT_SSH } TransportType;
|
||||
|
||||
/* --outbuf stdout/stderr buffering style (client-only launch concern, never
|
||||
* crosses the wire). OUTBUF_BLOCK is the default, matching the stdio default
|
||||
* (fully buffered when output is not a terminal). */
|
||||
typedef enum {
|
||||
OUTBUF_BLOCK = 0, /* _IOFBF */
|
||||
OUTBUF_LINE, /* _IOLBF */
|
||||
OUTBUF_NONE /* _IONBF */
|
||||
} OutbufMode;
|
||||
|
||||
/* Receiver-side staging state for --delay-updates. Forward-declared here so
|
||||
Config can carry it; the concrete type lives in delay_updates.h. */
|
||||
typedef struct DelayUpdatesContext DelayUpdatesContext;
|
||||
|
||||
/* Alternate basis-directory modes (--compare-dest / --copy-dest /
|
||||
* --link-dest). Each flag adds one entry to the ordered Config->basis_dirs
|
||||
* list; the receiver consults entries in command-line order and stops at the
|
||||
* first exact match, mirroring rsync's basis-dir priority rules. */
|
||||
typedef enum {
|
||||
BASIS_DEST_NONE = 0,
|
||||
BASIS_DEST_COMPARE, /* compare only: never copies, never materializes */
|
||||
BASIS_DEST_COPY, /* local copy of the matched basis file */
|
||||
BASIS_DEST_LINK /* hard link to the matched basis file */
|
||||
} BasisDestType;
|
||||
|
||||
typedef struct BasisDest {
|
||||
BasisDestType type;
|
||||
char* path; /* relative to the destination root (receiver-confined) */
|
||||
} BasisDest;
|
||||
|
||||
/* One resolved FROM:TO identity-mapping rule (--usermap / --groupmap). Both
|
||||
* fields are numeric ids. IDENTITY_MATCH_ANY (-1) in `from` is rsync's '*'
|
||||
* wildcard (matches any transmitted id); IDENTITY_CURRENT (-1) in `to` makes
|
||||
* the receiver resolve the receiving process's own current euid/egid at apply
|
||||
* time. Names are resolved to numbers at parse time on the client (see
|
||||
* identity.h for the exact subset). */
|
||||
typedef struct {
|
||||
int32_t from;
|
||||
int32_t to;
|
||||
} IdentityMap;
|
||||
|
||||
/* --sockopts=OPTIONS allowlist. Only these option names are accepted; anything
|
||||
* else is rejected (never silently ignored). TCP_NODELAY, SO_KEEPALIVE and
|
||||
* SO_REUSEADDR are boolean options (value 0/1); SO_RCVBUF and SO_SNDBUF take a
|
||||
* non-negative byte count. All are applied as int-sized setsockopt values. */
|
||||
typedef enum {
|
||||
SOCKOPT_TCP_NODELAY = 0,
|
||||
SOCKOPT_SO_KEEPALIVE,
|
||||
SOCKOPT_SO_RCVBUF,
|
||||
SOCKOPT_SO_SNDBUF,
|
||||
SOCKOPT_SO_REUSEADDR,
|
||||
SOCKOPT_COUNT
|
||||
} SockOptId;
|
||||
|
||||
typedef struct {
|
||||
SockOptId id; /* allowlist index */
|
||||
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;
|
||||
|
||||
/* ===========================================================================
|
||||
* Config wire-field table (single source of truth for protocol 2.21.0).
|
||||
*
|
||||
* Every field below crosses the wire. The table is the ONLY place a
|
||||
* serialized field is named: config.h expands CONFIG_WIRE_FIELDS() to declare
|
||||
* the struct member, config_set_defaults() expands it to assign the default,
|
||||
* and config_send_wire_block()/config_receive_with_validate() expand the
|
||||
* per-segment lists to emit/consume the frame in exactly this order. Do NOT
|
||||
* reorder entries and do NOT change a field's segment/KIND without a
|
||||
* PROTOCOL_VERSION bump: the resulting byte stream is pinned by
|
||||
* test_config_wire_golden().
|
||||
*
|
||||
* Entry layout: X(MEMBER, CTYPE, DEFAULT, KIND)
|
||||
* MEMBER struct member name (public; never rename)
|
||||
* CTYPE C type of the member
|
||||
* DEFAULT default-value expression used by config_set_defaults()
|
||||
* KIND wire codec, dispatched to CONFIG_SEND_<KIND>/CONFIG_RECV_<KIND>
|
||||
* in config.c (strings receive through a ConfigStringBudget).
|
||||
*
|
||||
* Fields with genuinely custom logic keep dedicated helpers but are still
|
||||
* declared here exactly once: the protocol-version handshake (HEADER), the
|
||||
* daemon SCRAM auth username (STR_REDACTED_AUTH), the daemon module name
|
||||
* (STR_MODULE), repeated count+array blocks (BLOCK_*), --copy-as presence
|
||||
* (COPY_AS_*), and the derived --delta / use_xattrs bits (DERIVED_DELTA,
|
||||
* BOOL_XATTR_DERIVE).
|
||||
*
|
||||
* SCOPE: this table covers ONLY the serialized wire frame. The client CLI
|
||||
* option tables in client_cli.c (OPTION_TABLE / NEGATABLE_OPTIONS) are still
|
||||
* hand-maintained and are deliberately NOT generated from this table: the CLI
|
||||
* surface carries client-only fields and flag/alias/negation semantics that
|
||||
* have no wire representation. Do not assume the two are folded together.
|
||||
* =========================================================================== */
|
||||
#define CONFIG_WIRE_HEADER_FIELDS(X) X(version, char*, str_dup(PROTOCOL_VERSION), STR)
|
||||
|
||||
/* dry_run (--dry-run) is CLIENT-INTENT that now CROSSES the wire (protocol
|
||||
* 2.21.0): the receiver needs it to answer what WOULD transfer/skip without
|
||||
* touching disk. The client-only launch behavior (no server contact for a
|
||||
* local destination) is decided separately in client_send.c before the frame
|
||||
* is ever sent. */
|
||||
#define CONFIG_WIRE_CORE_FIELDS(X) \
|
||||
X(eight_bit_output, bool, false, BOOL_8BIT) \
|
||||
X(max_alloc, unsigned long long, DEFAULT_MAX_ALLOC, RAW_MAXALLOC) \
|
||||
X(send_directory, char*, NULL, STR) \
|
||||
X(receive_root_directory, char*, NULL, STR) \
|
||||
X(save_to_disk, bool, false, BOOL) \
|
||||
X(use_multithreading, bool, false, BOOL) \
|
||||
X(use_chunk_serialization, bool, false, BOOL) \
|
||||
X(use_compression, bool, false, BOOL) \
|
||||
X(use_metadata, bool, false, BOOL) \
|
||||
X(use_executability, bool, false, BOOL) \
|
||||
X(compression_level, int, 5, INT) \
|
||||
X(chunk_size, unsigned long long, DEFAULT_CHUNK_SIZE, RAW) \
|
||||
X(use_sendfile, bool, false, BOOL) \
|
||||
X(dry_run, bool, false, BOOL)
|
||||
|
||||
#define CONFIG_WIRE_DELTA_FIELDS(X) \
|
||||
X(use_delete, bool, false, BOOL) \
|
||||
X(use_incremental, bool, false, BOOL) \
|
||||
X(size_only, bool, false, BOOL) \
|
||||
X(ignore_times, bool, false, BOOL) \
|
||||
X(use_delta, bool, false, DERIVED_DELTA) \
|
||||
X(delta_block_size, uint32_t, DELTA_BLOCK_SIZE_DEFAULT, RAW) \
|
||||
X(delta_max_file_size, unsigned long long, DELTA_MAX_FILE_SIZE, RAW)
|
||||
|
||||
#define CONFIG_WIRE_FILE_OPTIONS_FIELDS(X) \
|
||||
X(backup, bool, false, BOOL) \
|
||||
X(backup_dir, char*, NULL, STR_OPT) \
|
||||
X(remove_source_files, bool, false, BOOL) \
|
||||
X(follow_symlinks, bool, false, BOOL) \
|
||||
X(copy_links, bool, false, BOOL) \
|
||||
X(safe_links, bool, false, BOOL) \
|
||||
X(copy_unsafe_links, bool, false, BOOL) \
|
||||
X(preserve_hard_links, bool, false, BOOL) \
|
||||
X(preserve_acls, bool, false, BOOL) \
|
||||
X(preserve_xattrs, bool, false, BOOL) \
|
||||
X(preserve_devices, bool, false, BOOL) \
|
||||
X(preserve_sparse, bool, false, BOOL) \
|
||||
X(preserve_specials, bool, false, BOOL) \
|
||||
X(copy_devices, bool, false, BOOL) \
|
||||
X(write_devices, bool, false, BOOL)
|
||||
|
||||
#define CONFIG_WIRE_SELECTION_FIELDS(X) \
|
||||
X(ignore_existing, bool, false, BOOL) \
|
||||
X(existing, bool, false, BOOL) \
|
||||
X(update, bool, false, BOOL) \
|
||||
X(inplace, bool, false, BOOL) \
|
||||
X(delay_updates, bool, false, BOOL) \
|
||||
X(append, bool, false, BOOL) \
|
||||
X(use_fsync, bool, false, BOOL) \
|
||||
X(append_verify, bool, false, BOOL) \
|
||||
X(delete_excluded, bool, false, BOOL) \
|
||||
X(force_delete, bool, false, BOOL) \
|
||||
X(delete_missing_args, bool, false, BOOL) \
|
||||
X(delete_after, bool, false, BOOL) \
|
||||
X(preallocate, bool, false, BOOL) \
|
||||
X(max_delete, int, -1, RAW) \
|
||||
X(relative, bool, false, BOOL) \
|
||||
X(prune_empty_dirs, bool, false, BOOL) \
|
||||
X(mkpath, bool, false, BOOL) \
|
||||
X(delete_during, bool, false, BOOL) \
|
||||
X(delete_delay, bool, false, BOOL)
|
||||
|
||||
#define CONFIG_WIRE_RESUME_FIELDS(X) \
|
||||
X(temp_dir, char*, NULL, STR_OPT) \
|
||||
X(partial, bool, false, BOOL) \
|
||||
X(partial_dir, char*, NULL, STR_OPT) \
|
||||
X(suffix, char*, NULL, STR_OPT) \
|
||||
X(delete_before, bool, false, BOOL) \
|
||||
X(checksum, bool, false, BOOL) \
|
||||
X(modify_window, int, 0, RAW) \
|
||||
X(compress_choice, char*, NULL, STR_KEEP) \
|
||||
X(chmod_spec, char*, NULL, STR_KEEP) \
|
||||
X(skip_compress_set, bool, false, BOOL) \
|
||||
X(skip_compress_count, int, 0, INT_SKIPCOUNT) \
|
||||
X(skip_compress_suffixes, char**, NULL, BLOCK_SKIP_SUFFIXES)
|
||||
|
||||
#define CONFIG_WIRE_BASIS_FIELDS(X) \
|
||||
X(basis_count, int, 0, INT_BASISCOUNT) \
|
||||
X(basis_dirs, BasisDest*, NULL, BLOCK_BASIS)
|
||||
|
||||
#define CONFIG_WIRE_FUZZY_FIELDS(X) X(fuzzy, bool, false, BOOL)
|
||||
|
||||
#define CONFIG_WIRE_CHECKSUM_FIELDS(X) \
|
||||
X(checksum_algo, int, CHECKSUM_ALGO_XXH64, INT_CHECKSUM_ALGO) \
|
||||
X(checksum_seed, uint64_t, 0, RAW)
|
||||
|
||||
#define CONFIG_WIRE_IDENTITY_FIELDS(X) \
|
||||
X(numeric_ids, bool, false, BOOL) \
|
||||
X(chown_uid_set, bool, false, BOOL) \
|
||||
X(chown_uid, int32_t, 0, INT_IDENTITY) \
|
||||
X(chown_gid_set, bool, false, BOOL) \
|
||||
X(chown_gid, int32_t, 0, INT_IDENTITY) \
|
||||
X(usermap_count, int, 0, INT_IDMAPCOUNT) \
|
||||
X(usermap, IdentityMap*, NULL, BLOCK_IDMAP) \
|
||||
X(groupmap_count, int, 0, INT_IDMAPCOUNT) \
|
||||
X(groupmap, IdentityMap*, NULL, BLOCK_IDMAP)
|
||||
|
||||
#define CONFIG_WIRE_METADATA_TIMES_FIELDS(X) \
|
||||
X(preserve_atimes, bool, false, BOOL) \
|
||||
X(preserve_crtimes, bool, false, BOOL) \
|
||||
X(omit_dir_times, bool, false, BOOL) \
|
||||
X(omit_link_times, bool, false, BOOL)
|
||||
|
||||
#define CONFIG_WIRE_SYMLINK_TRUST_FIELDS(X) \
|
||||
X(munge_links, bool, false, BOOL) \
|
||||
X(keep_dirlinks, bool, false, BOOL)
|
||||
|
||||
#define CONFIG_WIRE_XATTR_FIELDS(X) X(fake_super, bool, false, BOOL_XATTR_DERIVE)
|
||||
|
||||
#define CONFIG_WIRE_MODULE_FIELDS(X) X(module, char*, NULL, STR_MODULE)
|
||||
|
||||
#define CONFIG_WIRE_DAEMON_AUTH_FIELDS(X) X(auth_user, char*, NULL, STR_REDACTED_AUTH)
|
||||
|
||||
#define CONFIG_WIRE_ICONV_FIELDS(X) X(iconv_spec, char*, NULL, STR_OPT)
|
||||
|
||||
#define CONFIG_WIRE_PRIVILEGE_FIELDS(X) X(super_mode, SuperMode, SUPER_MODE_AUTO, SUPERMODE)
|
||||
|
||||
#define CONFIG_WIRE_COPY_AS_FIELDS(X) \
|
||||
X(copy_as_set, bool, false, COPY_AS_PRESENCE) \
|
||||
X(copy_as_uid, int32_t, 0, COPY_AS_ID) \
|
||||
X(copy_as_gid, int32_t, 0, COPY_AS_ID)
|
||||
|
||||
/* All serialized fields, in exact wire order. Concatenating the per-segment
|
||||
* lists here is what keeps the declaration order = the wire order. */
|
||||
#define CONFIG_WIRE_FIELDS(X) \
|
||||
CONFIG_WIRE_HEADER_FIELDS(X) \
|
||||
CONFIG_WIRE_CORE_FIELDS(X) \
|
||||
CONFIG_WIRE_DELTA_FIELDS(X) \
|
||||
CONFIG_WIRE_FILE_OPTIONS_FIELDS(X) \
|
||||
CONFIG_WIRE_SELECTION_FIELDS(X) \
|
||||
CONFIG_WIRE_RESUME_FIELDS(X) \
|
||||
CONFIG_WIRE_BASIS_FIELDS(X) \
|
||||
CONFIG_WIRE_FUZZY_FIELDS(X) \
|
||||
CONFIG_WIRE_CHECKSUM_FIELDS(X) \
|
||||
CONFIG_WIRE_IDENTITY_FIELDS(X) \
|
||||
CONFIG_WIRE_METADATA_TIMES_FIELDS(X) \
|
||||
CONFIG_WIRE_SYMLINK_TRUST_FIELDS(X) \
|
||||
CONFIG_WIRE_XATTR_FIELDS(X) \
|
||||
CONFIG_WIRE_MODULE_FIELDS(X) \
|
||||
CONFIG_WIRE_DAEMON_AUTH_FIELDS(X) \
|
||||
CONFIG_WIRE_ICONV_FIELDS(X) \
|
||||
CONFIG_WIRE_PRIVILEGE_FIELDS(X) \
|
||||
CONFIG_WIRE_COPY_AS_FIELDS(X)
|
||||
TRANSPORT_TCP,
|
||||
TRANSPORT_SSH
|
||||
} TransportType;
|
||||
|
||||
typedef struct Config {
|
||||
/* -j/--threads=N: number of parallel scanner worker threads for the -m
|
||||
* pipeline. 0 (the default, also set by bare -j/--threads) means "use the
|
||||
* scanner's built-in default" (4). CLIENT-ONLY: it is a local scheduling
|
||||
* concern and is NEVER serialized into the wire config frame. */
|
||||
int scanner_threads;
|
||||
bool metadata_explicitly_disabled;
|
||||
char *version;
|
||||
char *send_directory;
|
||||
char *receive_root_directory;
|
||||
bool save_to_disk;
|
||||
bool use_multithreading;
|
||||
bool use_chunk_serialization;
|
||||
bool use_compression;
|
||||
bool use_sendfile;
|
||||
bool use_metadata;
|
||||
bool show_progress;
|
||||
int compression_threads;
|
||||
bool dry_run;
|
||||
bool use_delete;
|
||||
int compression_level;
|
||||
unsigned long long chunk_size;
|
||||
int ssh_port;
|
||||
TransportType transport;
|
||||
char* ssh_destination;
|
||||
char* auth_password;
|
||||
/* Client-only path of --password-file (never crosses the wire; it is read to
|
||||
* populate auth_user/auth_password before connecting). */
|
||||
char* password_file;
|
||||
char* fastsync_server_path;
|
||||
char** exclude_patterns;
|
||||
char *ssh_destination;
|
||||
char **exclude_patterns;
|
||||
int exclude_count;
|
||||
char** include_patterns;
|
||||
char **include_patterns;
|
||||
int include_count;
|
||||
unsigned long long max_size;
|
||||
unsigned long long min_size;
|
||||
bool whole_file;
|
||||
bool use_incremental;
|
||||
bool use_delta;
|
||||
uint32_t delta_block_size;
|
||||
unsigned long long delta_max_file_size;
|
||||
bool use_tls;
|
||||
char* server_host;
|
||||
int server_port;
|
||||
/* True when --server-port/--port was explicitly given. CLIENT-ONLY (never
|
||||
* serialized): --dry-run uses it to decide whether a real server handshake
|
||||
* was requested, so a plain local destination (no explicit port) keeps the
|
||||
* existing client-side dry-run behavior instead of dialing the default
|
||||
* 127.0.0.1:8080. */
|
||||
bool server_port_set;
|
||||
/* True when --server-host was explicitly given. CLIENT-ONLY (never
|
||||
* serialized), and distinct from the "127.0.0.1" default: --dry-run uses it
|
||||
* to route an explicit remote target to the server so it reports receiver
|
||||
* state exactly like a real run, instead of silently running the client-side
|
||||
* manifest. */
|
||||
bool server_host_set;
|
||||
char* tls_cert;
|
||||
char* tls_key;
|
||||
char* tls_ca;
|
||||
/* --timeout: per-message I/O deadline in seconds. 0 (the default/unset
|
||||
* sentinel) leaves the transport's built-in 30 s socket timeout and the
|
||||
* protocol's built-in 60 s per-message deadline in place; a positive value
|
||||
* overrides both. See protocol_session_set_io_timeout. */
|
||||
int timeout;
|
||||
/* --contimeout: connect()/accept timeout, transport layer only. */
|
||||
int contimeout;
|
||||
bool quiet;
|
||||
bool stats;
|
||||
int max_depth;
|
||||
FILE* log_file;
|
||||
|
||||
/* Phase 4 symlink-trust. -k/--copy-dirlinks and --munge-links are
|
||||
* CLIENT/sender-side only (they decide how the SENDER scans and rewrites
|
||||
* symlinks; the receiver never reads them), so they never cross the wire.
|
||||
* -K/--keep-dirlinks is a RECEIVER-side policy (follow an in-root destination
|
||||
* symlink-to-directory as a directory) and CROSSES the wire along with
|
||||
* --munge-links (so the receiver knows to unmunge). */
|
||||
bool copy_dirlinks; /* client-only, sender-side (-k) */
|
||||
|
||||
// Issue #122: Output/logging options
|
||||
bool itemize_changes;
|
||||
char* out_format;
|
||||
char* log_file_format;
|
||||
int info_level;
|
||||
int debug_level;
|
||||
bool list_only;
|
||||
bool human_readable;
|
||||
|
||||
/* --ignore-errors (client-only, never serialized): a sender-side source I/O
|
||||
* error (an unreadable directory during the scan) normally aborts the run so
|
||||
* no deletion happens; with --ignore-errors the scan continues and the
|
||||
* (partial) keep-set is still transmitted so the deletion runs. */
|
||||
bool ignore_errors;
|
||||
/* --ignore-missing-args (client-only, never serialized): a --files-from
|
||||
* entry that does not exist under the source is silently skipped instead of
|
||||
* failing the run. Sender-side only: nothing is sent for it and it never
|
||||
* enters the keep-set. Implied by --delete-missing-args. */
|
||||
bool ignore_missing_args;
|
||||
|
||||
// Issue #129: Advanced file selection. These fields are CLIENT-ONLY: they are
|
||||
// never serialized to the wire (the receiver must not learn them).
|
||||
ArrayList* filters; /* --filter=RULE rule strings, in order */
|
||||
char* files_from; /* --files-from path (may be NULL) */
|
||||
void* files_from_set; /* parsed FileListSet* allow-set, or NULL */
|
||||
bool from0; /* -0/--from0: NUL-delimited *-from files */
|
||||
bool cvs_exclude; /* -C/--cvs-exclude: standard CVS ignore set */
|
||||
bool per_dir_filter; /* -F: apply per-directory .rsync-filter files */
|
||||
bool one_file_system; /* -x/--one-file-system: do not cross filesystem boundaries */
|
||||
/* --no-implied-dirs: client-only. With -R + --files-from, refuse to place a
|
||||
* listed file whose ancestor directory is not itself explicitly listed. */
|
||||
bool no_implied_dirs;
|
||||
/* -d/--dirs: client-only. Transfer the directory entries named by the
|
||||
* source argument / --files-from list without recursing into contents. */
|
||||
bool dirs;
|
||||
|
||||
/* -e/--rsh: the remote-shell program used to establish the SSH transport.
|
||||
* NULL means the default "ssh". Client-only launch concern: NEVER crosses
|
||||
* the wire (it is not meaningful to the daemon/server handshake). */
|
||||
char* rsh_command;
|
||||
/* --blocking-io: leave the SSH transport socket without
|
||||
* SO_RCVTIMEO/SO_SNDTIMEO so it blocks naturally instead of timing out.
|
||||
* Client-only launch concern: NEVER crosses the wire. */
|
||||
bool blocking_io;
|
||||
/* --outbuf mode (OutbufMode): stdout/stderr buffering. Client-only launch
|
||||
* concern: NEVER crosses the wire. */
|
||||
int outbuf;
|
||||
bool old_args;
|
||||
/* --remote-option=OPT (Phase 5, long form only): one or more extra command-line
|
||||
* options to append to the REMOTE server invocation over SSH. CLIENT-ONLY:
|
||||
* they are composed into the remote command line by ssh_build_remote_command()
|
||||
* (each valid word is shell-escaped with the same quoting boundary as the
|
||||
* server path), and are NEVER serialized into the binary config frame. They
|
||||
* do NOT cross the wire and are never parsed on the receiver process. */
|
||||
char** remote_options;
|
||||
int remote_option_count;
|
||||
|
||||
// PR #181: IPv6 and bind address
|
||||
char* address;
|
||||
bool ipv6;
|
||||
bool ipv4;
|
||||
/* --sockopts=OPTIONS (Phase 5, Wave B): strict allowlist of TCP/socket
|
||||
* options applied via setsockopt after socket() and before connect()/bind().
|
||||
* These are LOCAL socket concerns: they never cross the wire config frame.
|
||||
* .address is the outgoing/source bind address (--address). */
|
||||
SockOptEntry* sockopts;
|
||||
int sockopt_count;
|
||||
|
||||
// PR #182: Daemon/server mode
|
||||
bool daemon;
|
||||
/* --no-motd (Wave C): CLIENT-ONLY, never crosses the wire. Suppresses
|
||||
* DISPLAY of the daemon's MOTD; the daemon still sends the MOTD frame, so
|
||||
* the client reads and discards it to keep the stream in sync. rsync's
|
||||
* --no-motd is likewise a client-side display switch. Default false (the
|
||||
* MOTD is shown when a daemon offers one). */
|
||||
bool no_motd;
|
||||
|
||||
// Receiver-side runtime staging registry for --delay-updates. Never sent
|
||||
// over the wire and never set on the sender side.
|
||||
DelayUpdatesContext* delay_context;
|
||||
|
||||
/* --open-noatime: CLIENT-ONLY (never crosses the wire). The sender opens
|
||||
* source files with O_NOATIME so reading for transfer does not bump the
|
||||
* source access time. */
|
||||
bool open_noatime;
|
||||
|
||||
/* true when preserve_xattrs || preserve_acls; the sender/receiver gate the
|
||||
* xattr wire block on this single flag. */
|
||||
bool use_xattrs;
|
||||
|
||||
/* Long-form-only, receiver-local policy. rsync's --trust-sender tells the
|
||||
* receiving side to trust that the sender already produced a sane file list,
|
||||
* relaxing the receiver's own up-front re-validation of every incoming path.
|
||||
* In FastSync the receiver normally double-checks each transmitted file-list
|
||||
* entry (empty / ".." path-traversal rejection) and refuses to materialize a
|
||||
* symlink whose target could escape the receive root. When trust_sender is
|
||||
* set, those redundant list-level re-checks are SKIPPED: the receiving side
|
||||
* trusts the sender's list instead of re-validating it (fewer checks, faster,
|
||||
* potentially unsafe, matching rsync). It is a LOCAL receiver policy and is
|
||||
* NEVER serialized into the config frame (it exists only on the process that
|
||||
* actually receives the file list). Even under trust_sender the low-level
|
||||
* fd-relative confinement primitives (file_open_secure_parent, the O_NOFOLLOW
|
||||
* parent walk, leaf/destination confinement) are deliberately KEPT as a hard
|
||||
* floor, so a hostile sender still cannot write or link outside the
|
||||
* authorized root (see the phase-5 notes in RSYNC_COMPAT.md). Off by
|
||||
* default; only relaxes validation when explicitly requested. */
|
||||
bool trust_sender;
|
||||
|
||||
/* Client-only sender-side transfer stop deadlines. --stop-after=MINS stops
|
||||
* the transfer after a number of elapsed minutes (checked against
|
||||
* CLOCK_MONOTONIC so clock changes do not skew it); --stop-at=TIME stops at
|
||||
* an absolute wall-clock time (HH:MM, HH:MM:SS, or now+N[smhd]). At the
|
||||
* deadline the run stops elegantly at the next chunk/file boundary and the
|
||||
* completion tail still runs (exit 0). Both are LOCAL to the sending
|
||||
* process and are NEVER serialized into the config frame. */
|
||||
int stop_after_mins; /* --stop-after=MINS minutes; 0 when unset */
|
||||
time_t stop_at; /* --stop-at=... absolute wall-clock deadline */
|
||||
bool stop_at_set; /* true when --stop-at was given */
|
||||
|
||||
/* Client-only residual-batch paths. A residual batch is a self-contained
|
||||
* single-file record of the whole source tree (full file images using the
|
||||
* chunk codec), independent of any live server. --write-batch=FILE runs the
|
||||
* normal live transfer AND additionally emits the batch FILE;
|
||||
* --only-write-batch=FILE emits FILE only (no destination, no server);
|
||||
* --read-batch=FILE applies FILE to the destination (no source, no server).
|
||||
* All three are LOCAL to the driving process and are NEVER serialized into
|
||||
* the config frame (the batch paths bypass the transport entirely). */
|
||||
char* write_batch; /* --write-batch=FILE path, or NULL */
|
||||
char* only_write_batch; /* --only-write-batch=FILE path, or NULL */
|
||||
char* read_batch; /* --read-batch=FILE path, or NULL */
|
||||
|
||||
/* ===================================================================
|
||||
* Serialized wire fields. Their members, defaults and send/receive
|
||||
* sequence are generated from the CONFIG_WIRE_*_FIELDS table above (the
|
||||
* single source of truth); they are declared here in exact wire order.
|
||||
* The per-field notes were moved here from their original positions and
|
||||
* are listed in wire order.
|
||||
* =================================================================== */
|
||||
/* copy_links */
|
||||
// Issue #120: Symlink handling
|
||||
/* preserve_hard_links */
|
||||
// Issue #121: Extended metadata preservation
|
||||
/* preserve_specials */
|
||||
/* Phase 4 special/devices: preserve special files (FIFOs, sockets) and device
|
||||
* nodes on the destination by recreating them (mknod/mkfifo) instead of
|
||||
* transferring content. preserve_specials mirrors rsync --specials (the
|
||||
* special-file half of -D); preserve_devices mirrors --devices (the device
|
||||
* half of -D); both CROSS the wire so the receiver knows a special/device
|
||||
* entry must be recreated rather than written as a regular file. */
|
||||
/* copy_devices */
|
||||
/* --copy-devices: copy the CONTENT of a source device as an ordinary regular
|
||||
* file on the destination (rsync's non-privileged safe mode), instead of
|
||||
* recreating the device node. CROSSES the wire (receiver treats the entry as
|
||||
* a regular file, which is the default, so this is belt-and-braces). */
|
||||
/* write_devices */
|
||||
/* --write-devices: write the received data directly INTO an existing device
|
||||
* node on the destination instead of creating a regular file. Dangeroud;
|
||||
* see RSYNC_COMPAT.md for the tight gating. CROSSES the wire. */
|
||||
/* existing */
|
||||
// Issue #127: Transfer modes
|
||||
/* delete_excluded */
|
||||
/* --delete-excluded: also delete destination entries that were excluded on
|
||||
* the source. Default (off) matches rsync: excluded paths are protected from
|
||||
* deletion. Crosses the wire (the sender encodes the choice by whether it
|
||||
* transmits a protected-prefix list with the keep-set manifest). */
|
||||
/* force_delete */
|
||||
/* --force (receiver-side): a regular file may replace a destination
|
||||
* directory by removing that (possibly non-empty, symlink-safe) directory
|
||||
* tree first, instead of failing the write. Crosses the wire. */
|
||||
/* delete_missing_args */
|
||||
/* --delete-missing-args: implies --ignore-missing-args; additionally each
|
||||
* missing entry's destination mirror (computed like a present entry's wire
|
||||
* path) is deleted receiver-side. Crosses the wire and is gated by the
|
||||
* server's --allow-delete policy like --delete. rsync-parity: independent
|
||||
* of ordinary --delete processing (it does not imply --delete); a non-empty
|
||||
* directory mirror is only removed with --force or --delete in effect, and
|
||||
* the missing-args deletions are not counted toward --max-delete. */
|
||||
/* preallocate */
|
||||
/* --preallocate: allocates the destination file's full expected space up
|
||||
* front (before any data is written) so a transfer that would overflow disk
|
||||
* fails fast at allocation time and the file is laid out contiguously,
|
||||
* avoiding fragmentation. Receiver-side, crosses the wire. */
|
||||
/* max_delete */
|
||||
/* --max-delete=NUM: the receiver refuses to delete more than NUM entries per
|
||||
* run (all-or-nothing: when the extras would exceed NUM nothing is removed and
|
||||
* the transfer fails with a distinct error). -1 == no client limit (the
|
||||
* server hard bound MAX_SERVER_DELETE_COUNT still applies). */
|
||||
/* relative */
|
||||
/* -R/--relative: crosses the wire; with --files-from listed entries keep
|
||||
* their bare relative destination path (no source-root mirror prefix). */
|
||||
/* mkpath */
|
||||
/* --mkpath: crosses the wire. Tells the server to create the destination
|
||||
* root directory (and missing leading components below its authorized root)
|
||||
* at connection start instead of requiring it to already exist. */
|
||||
/* delete_during */
|
||||
/* rsync deletion-timing family (real from Phase 3). At most one of
|
||||
delete_before / delete_during / delete_delay / delete_after may be set, and
|
||||
only together with use_delete (the CLI implies --delete for each of them).
|
||||
delete_before and delete_during select the EARLY engine mode: the keep-set
|
||||
manifest is transmitted before any file data and extras are removed then,
|
||||
acknowledged, before the first data byte. delete_delay and delete_after
|
||||
select the LATE commit mode: extras are removed only after the whole
|
||||
transfer has succeeded (plain --delete keeps this mode). The exact
|
||||
semantics and the divergences from rsync are documented in RSYNC_COMPAT.md
|
||||
and in config_delete_timing_early() below. */
|
||||
/* partial_dir */
|
||||
// PR #174: Partial transfer resumption
|
||||
/* suffix */
|
||||
// PR #178: Backup versioning
|
||||
/* delete_before */
|
||||
// PR #179: Delete policies
|
||||
/* checksum */
|
||||
// PR #183: Checksum comparison
|
||||
/* compress_choice */
|
||||
// PR #184: Compression algorithm negotiation
|
||||
/* basis_dirs */
|
||||
/* Alternate basis directories, ordered by command-line appearance. Each
|
||||
* entry's type selects compare/copy/link behavior on an exact match. These
|
||||
* cross the wire so the receiver can consult them; they are interpreted
|
||||
* relative to the destination root and confined there. */
|
||||
/* fuzzy */
|
||||
/* -y/--fuzzy: when a file must be transferred and the destination holds no
|
||||
* usable file at the exact path, the receiver may reuse a SIMILAR-named
|
||||
* existing regular file in the same destination directory as the delta
|
||||
* basis so the sender transmits only the differences. Crosses the wire
|
||||
* (the receiver performs the candidate search); the CLI implies
|
||||
* --incremental + --delta because the similar-basis only matters on the
|
||||
* receiver-driven delta path. Off by default. */
|
||||
/* checksum_algo / checksum_seed */
|
||||
/* --checksum-choice / --cc and --checksum-seed. checksum_algo is the id of
|
||||
* the whole-file content-digest algorithm used by the per-file --incremental
|
||||
* handshake (sender computes it, receiver compares it to skip unchanged
|
||||
* files) and by the basis-dir content verification. checksum_seed is passed
|
||||
* to xxHash64 (and to the delta block strong hash, low 32 bits); md5 has no
|
||||
* seed so it is ignored there. Both cross the wire: the receiver MUST hash
|
||||
* the on-disk old file with the same algorithm and seed to reach a matching
|
||||
* digest. */
|
||||
/* munge_links / keep_dirlinks */
|
||||
/* Phase 4 symlink-trust: both cross the wire (the receiver unmunges symlink
|
||||
* targets and, with -K, follows an in-root destination symlink-to-directory);
|
||||
* -k/--copy-dirlinks is sender-only and is never serialized. */
|
||||
/* numeric_ids */
|
||||
/* --numeric-ids: no name lookup, use the transmitted numeric ids raw. */
|
||||
/* chown_uid_set */
|
||||
/* --chown USER (owner) override; IDENTITY_CURRENT = the receiver's euid. */
|
||||
/* chown_gid_set */
|
||||
/* --chown :GROUP (group) override; IDENTITY_CURRENT = the receiver's egid. */
|
||||
/* usermap */
|
||||
/* --usermap / --groupmap entries, in order (first match wins). */
|
||||
/* preserve_atimes */
|
||||
/* -U/--atimes: preserve source access times on the destination. */
|
||||
/* preserve_crtimes */
|
||||
/* -N/--crtimes: capture+transmit source birth time; see RSYNC_COMPAT for the
|
||||
* receiver not-applied divergence. */
|
||||
/* omit_dir_times */
|
||||
/* -O/--omit-dir-times: do not apply mtimes to directories. */
|
||||
/* omit_link_times */
|
||||
/* -J/--omit-link-times: do not apply times to symlinks. */
|
||||
/* fake_super */
|
||||
/* --fake-super: receiver-only. When set, each written file additionally gets
|
||||
* a reserved user.fastsync.stat xattr recording the source uid/gid/mode/mtime
|
||||
* so a later privileged restore could re-apply them. Crosses the wire. */
|
||||
/* module */
|
||||
/* Daemon module selection (Wave A, protocol 2.15.0). Client-composed from a
|
||||
* host::module/path destination; NULL or "" means "no module" (the ordinary
|
||||
* standalone-server path). Crosses the wire as a trailing config-frame
|
||||
* string so the daemon can look the module up in its own config and confine
|
||||
* the connection to the module's root (never a client-chosen root). */
|
||||
/* auth_user */
|
||||
/* Daemon password authentication (A7 remediation, protocol 2.19.0).
|
||||
* Client-composed from a --password-file whose first meaningful line is
|
||||
* `user:password`: the client sends ONLY the username in the config frame
|
||||
* (auth_user); the literal password is kept in auth_password CLIENT-SIDE for
|
||||
* the duration of the SCRAM challenge/response and is NEVER serialized. Both
|
||||
* are NULL when the client has no credentials to present; a module WITHOUT
|
||||
* `auth users` stays open and the server ignores any credentials that do
|
||||
* arrive (the client sends them opportunistically and the server decides). */
|
||||
/* iconv_spec */
|
||||
/* --iconv=CONVERT_SPEC (protocol 2.16.0, rsync compatibility): convert the
|
||||
* charset of FILE NAMES at the wire boundary. CONVERT_SPEC is
|
||||
* "LOCAL[,REMOTE]": LOCAL is the charset of our own file names, REMOTE is
|
||||
* the remote side's charset and defaults to LOCAL. The sender converts
|
||||
* every path LOCAL->REMOTE before transmitting it; the receiver converts
|
||||
* every received path back REMOTE->LOCAL before creating/writing it. The
|
||||
* FULL SPEC crosses the wire as a trailing config-frame string so each end
|
||||
* derives its own LOCAL and the wire (REMOTE) charset symmetrically. NULL
|
||||
* (or "") means no conversion: identity with zero overhead. See charset.c
|
||||
* and the PROTOCOL_VERSION note below. */
|
||||
/* super_mode */
|
||||
/* --super / --no-super (P7 Wave E, protocol 2.18.0): receiver-side privilege
|
||||
* policy for super-user activities confined below the authorized receive
|
||||
* root. SUPER_MODE_AUTO (default) preserves the pre-existing best-effort
|
||||
* behavior: the confined super-user operation is ALWAYS attempted and an
|
||||
* unprivileged attempt is refused by the kernel and skipped per entry.
|
||||
* SUPER_MODE_ON (--super) explicitly REQUESTS those activities (char/block
|
||||
* device-node creation, --write-devices); it does NOT imply --numeric-ids and
|
||||
* never enables ownership application on its own. SUPER_MODE_OFF
|
||||
* (--no-super) FORBIDS them even when running as root. FastSync NEVER
|
||||
* elevates privileges (no setuid/seteuid/setgid) and never bypasses the
|
||||
* fd-relative confinement (file_open_secure_parent, O_NOFOLLOW, root checks);
|
||||
* --super only permits an attempt that is already confined. Crosses the wire
|
||||
* as a trailing int so the receiver can enforce the policy. See
|
||||
* privilege_super_permitted() and identity_ownership_requested() in
|
||||
* identity.h. */
|
||||
/* copy_as_set */
|
||||
/* --copy-as=USER[:GROUP] (P7 Wave E, protocol 2.18.0). Safe-subset
|
||||
* implementation, a documented divergence from rsync's real identity switch:
|
||||
* the receiver does NOT change its process credentials (FastSync's receiver
|
||||
* is multithreaded, so a setuid/seteuid drop would be unsafe). Instead the
|
||||
* receiver FORCES the ownership of every entry it writes to copy_as_uid /
|
||||
* copy_as_gid through the existing confined, fd-relative identity path
|
||||
* (fchown/fchownat), which REQUIRES receiver privilege (root); an
|
||||
* unprivileged receiver REFUSES the whole transfer up front at the config
|
||||
* handshake (never a silent wrong-ownership result). All three fields CROSS
|
||||
* the wire as a trailing config-frame block so the receiver learns the
|
||||
* requested ids; see the PROTOCOL_VERSION note below. */
|
||||
|
||||
#define CONFIG_STRUCT_MEMBER(name, ctype, def, kind) ctype name;
|
||||
CONFIG_WIRE_FIELDS(CONFIG_STRUCT_MEMBER)
|
||||
#undef CONFIG_STRUCT_MEMBER
|
||||
char *tls_cert;
|
||||
char *tls_key;
|
||||
char *tls_ca;
|
||||
} Config;
|
||||
|
||||
/* Phase 5 (remote-option wave): 2.13.0 -> 2.14.0.
|
||||
*
|
||||
* WHY the bump, grounded in the wire: the binary config-frame layout is
|
||||
* UNCHANGED by this wave (neither --remote-option nor --trust-sender adds a
|
||||
* serialized field; see the field comments above). --remote-option is
|
||||
* forwarded to the remote server over the SSH remote-command line
|
||||
* (ssh_build_remote_command) and --trust-sender is a purely local receiver
|
||||
* policy, so there is no new frame byte to negotiate. The bump is still the
|
||||
* correct release marker for Phase 5 because the client-to-server INVOCATION
|
||||
* surface changed: a client that composes remote-options expects a server that
|
||||
* knows how to honor them, and the only safe way to express "this feature set
|
||||
* is one coordinated release" is the strict same-version handshake FastSync
|
||||
* already performs for every release. A 2.14 client against a 2.13 server
|
||||
* fails the version check cleanly up front (rather than the remote server
|
||||
* rejecting an unfamiliar forwarded argv at a confusing later point), which is
|
||||
* exactly what the lockstep convention of this project requires. */
|
||||
/* Daemon Wave A: 2.14.0 -> 2.15.0.
|
||||
*
|
||||
* WHY the bump, grounded in the wire: this wave really does add a serialized
|
||||
* field to the binary config frame. The client sends its requested daemon
|
||||
* module name (Config->module) as a new trailing string on the frame (sent
|
||||
* after the Phase-4 xattr block and before the STATUS_OK/STATUS_ERROR ack, in
|
||||
* config_send/config_receive), and the daemon reads it to select which module
|
||||
* root confines the connection. Any config-frame layout change must bump the
|
||||
* protocol version because a peer that does not parse the new trailing bytes
|
||||
* would desynchronize on the frame boundary; the strict same-version handshake
|
||||
* (config_receive rejects a mismatched version before parsing anything else)
|
||||
* is what keeps a 2.15 client and a 2.14 server from ever reaching that state.
|
||||
*
|
||||
* NOTE: daemon module-selection bump owned by Wave A (2.15.0); later daemon
|
||||
* waves (auth, motd) must not bump PROTOCOL_VERSION. Wave B (auth) added the
|
||||
* credential fields (auth_user + password digest) as further trailing
|
||||
* config-frame strings AFTER the Wave A module string, with a presence int
|
||||
* prefix. This is not a new frame version: sender and receiver of a 2.15.0
|
||||
* build always read and write the same full layout (the strict same-version
|
||||
* handshake rejects any other version before a byte of the frame is parsed),
|
||||
* so a peer can never desynchronize on the added tail. The 2.15.0 release
|
||||
* ships Wave A + Wave B together; the bump stays owned by Wave A.
|
||||
*
|
||||
* Wave C (MOTD) adds NO config-frame field and no version bump either. On the
|
||||
* daemon listener path only, the server sends one MOTD string frame AFTER the
|
||||
* config-frame STATUS_OK (server.c handler), and every 2.15.0 daemon client
|
||||
* reads that frame right after the ack (client_send.c) -- symmetric
|
||||
* server->client in every build, so the strict same-version handshake keeps the
|
||||
* two peers in lockstep and nothing can desynchronize. The --stdio SSH path
|
||||
* sends/reads no MOTD at all.
|
||||
*
|
||||
* --iconv Wave (P6): 2.15.0 -> 2.16.0.
|
||||
*
|
||||
* WHY the bump, grounded in the wire: the --iconv feature adds a serialized
|
||||
* field to the binary config frame. The client sends the full CONVERT_SPEC
|
||||
* (Config->iconv_spec) as a new trailing string AFTER the Wave A/B daemon-auth
|
||||
* block (in config_send/config_receive), so the receiver knows the wire charset
|
||||
* (the REMOTE half) before the first file name arrives. Any config-frame
|
||||
* layout change must bump the protocol version: a peer that does not parse the
|
||||
* new trailing bytes would desynchronize on the frame boundary, and the strict
|
||||
* same-version handshake (config_receive rejects a mismatched version before
|
||||
* parsing anything else) is what keeps a 2.16 client and a 2.15 server from
|
||||
* ever reaching that state.
|
||||
*
|
||||
* Times Wave (P7 Wave D): 2.16.0 -> 2.17.0.
|
||||
*
|
||||
* WHY the bump, grounded in the wire: this wave makes -O/--omit-dir-times and
|
||||
* -J/--omit-link-times REAL by adding directory and symlink time preservation.
|
||||
* The config-frame LAYOUT is unchanged (the omit flags already crossed the
|
||||
* wire), but the FRAME STREAM gains a new terminal frame: after all file data
|
||||
* and the optional delete manifest, the sender transmits STATUS_DIR_TIMES
|
||||
* frame(s) (each a count followed by (path, metadata) pairs, chunked so no
|
||||
* frame exceeds the receiver's MAX_MANIFEST_ENTRIES bound) carrying every
|
||||
* source directory's captured times, so the receiver can apply them AFTER all of a
|
||||
* directory's children have been written (writing a child bumps the parent's
|
||||
* mtime). Symlink entries already carry their metadata on the STATUS_SYMLINK
|
||||
* frame; the receiver now applies it (utimensat/lchown with
|
||||
* AT_SYMLINK_NOFOLLOW) unless -J is set. Any change to the frame sequence must
|
||||
* bump the protocol version: a 2.16 peer that does not know STATUS_DIR_TIMES
|
||||
* would desynchronize on the unknown frame, and the strict same-version
|
||||
* handshake (config_receive rejects a mismatched version before parsing
|
||||
* anything else) is what keeps a 2.17 client and a 2.16 server from ever
|
||||
* reaching that state.
|
||||
*
|
||||
* Privilege Wave (P7 Wave E): 2.17.0 -> 2.18.0.
|
||||
*
|
||||
* WHY the bump, grounded in the wire: this wave adds the receiver-side
|
||||
* privilege flags --super/--no-super and --copy-as=USER[:GROUP]. The
|
||||
* config-frame layout gains two new trailing blocks AFTER the --iconv
|
||||
* CONVERT_SPEC string, in this fixed order: (1) send_privilege_options /
|
||||
* receive_privilege_options send one int (Config->super_mode, 0..2), then
|
||||
* (2) send_copy_as_options / receive_copy_as_options send a presence int and,
|
||||
* when set, the target uid and gid (both int32). The receiver uses
|
||||
* super_mode to decide whether it may attempt super-user activities
|
||||
* (ownership application, char/block device-node creation) already confined
|
||||
* below the authorized receive root, and the copy-as ids to force the
|
||||
* ownership of every entry it writes (the safe-subset --copy-as model). The
|
||||
* receiver REQUIRES privilege for copy-as: an unprivileged receiver refuses
|
||||
* the transfer at the config handshake (server_module_gate) instead of silently
|
||||
* ignoring the flag. Any config-frame layout change must bump the protocol
|
||||
* version: a peer that does not parse the new trailing bytes would
|
||||
* desynchronize on the frame boundary, and the strict same-version handshake
|
||||
* (config_receive rejects a mismatched version before parsing anything else) is
|
||||
* what keeps a 2.18 client and a 2.17 server from ever reaching that state.
|
||||
* --super never elevates privileges; it only permits a confined attempt, and
|
||||
* --copy-as never switches process credentials (see RSYNC_COMPAT.md).
|
||||
*
|
||||
* A7 Auth Wave: 2.18.0 -> 2.19.0.
|
||||
*
|
||||
* WHY the bump, grounded in the wire: the daemon auth block on the config frame
|
||||
* loses the hard-wired password digest (it becomes `[int present][str_redacted
|
||||
* username]`), and the frame stream gains the SCRAM challenge/response
|
||||
* (STATUS_AUTH_CHALLENGE -> STATUS_AUTH_RESPONSE -> STATUS_AUTH_OK) between the
|
||||
* config frame and the STATUS_OK ack. A 2.18 peer would desynchronize on both
|
||||
* the shorter auth block and the new status frames, so the strict same-version
|
||||
* handshake (config_receive rejects a mismatched version before parsing
|
||||
* anything else) is what keeps a 2.19 client and a 2.18 server from ever
|
||||
* reaching that state. SECURITY: a 2.19 store holds a salted PBKDF2 verifier
|
||||
* and cannot verify (and refuses to load) a legacy unsalted-SHA-256 store line,
|
||||
* so an old bearer digest can never be replayed against a 2.19 daemon.
|
||||
*
|
||||
* Packed Metadata Wave: 2.19.0 -> 2.20.0.
|
||||
*
|
||||
* WHY the bump: metadata_send()/metadata_receive() no longer emit/consume the
|
||||
* metadata as up to 12 separate per-field writes. A file's metadata now
|
||||
* crosses the wire as ONE packed frame: a single int32 present flag (0 =
|
||||
* absent, 1 = present) followed, when present, by the fixed
|
||||
* FILE_METADATA_WIRE_SIZE-byte (68-byte) field record produced by
|
||||
* metadata_to_buf(). Protocol data is an unframed byte stream, so the packed
|
||||
* encoding is byte-for-byte identical to the old field-by-field writes (same
|
||||
* fields, same order, same widths); the change only removes per-field syscalls.
|
||||
* The bump is therefore a deliberate lockstep-release marker, not a
|
||||
* desynchronization fix — the strict same-version handshake still rejects a
|
||||
* mixed 2.19/2.20 deployment. The chunk codec, which already used the packed
|
||||
* metadata_to_buf()/metadata_from_buf() form, is unchanged.
|
||||
*
|
||||
* Error-Detail + Server-contacting Dry-run Wave: 2.20.0 -> 2.21.0.
|
||||
*
|
||||
* WHY the bump, grounded in the wire: this release combines two changes on the
|
||||
* same lockstep version.
|
||||
*
|
||||
* (1) Error detail: a server may now answer a rejected operation with
|
||||
* STATUS_ERROR_DETAIL followed by a bounded (<= MAX_ERROR_DETAIL_BYTES)
|
||||
* length-prefixed string instead of a bare STATUS_ERROR (see protocol.h). The
|
||||
* config-frame LAYOUT is unchanged, but the FRAME STREAM gains a new framed
|
||||
* body after a status, so a 2.20 peer that does not consume it would
|
||||
* desynchronize on the following exchange. receive_status() transparently maps
|
||||
* STATUS_ERROR_DETAIL back to STATUS_ERROR for every existing call site and
|
||||
* captures the reason into a thread-local buffer consulted via
|
||||
* protocol_last_error().
|
||||
*
|
||||
* (2) --dry-run: --dry-run now contacts the receiver and reports exactly what
|
||||
* WOULD change. The binary config frame gains one serialized bool
|
||||
* (Config->dry_run) appended to CONFIG_WIRE_CORE_FIELDS after use_sendfile, and
|
||||
* the frame stream gains one terminal status (STATUS_DRY_RUN_TRANSFER) sent in
|
||||
* reply to a per-file STATUS_CHECK when the file is not already up to date.
|
||||
* The receiver performs the normal read-only incremental decision but no
|
||||
* mutation; the sender then skips the data.
|
||||
*
|
||||
* Any config-frame layout or frame-sequence change must bump the protocol
|
||||
* version: a 2.20 peer would desynchronize on the extra trailing byte, the
|
||||
* unknown status, or the unconsumed detail body, and the strict same-version
|
||||
* handshake (config_receive rejects a mismatched version before parsing
|
||||
* anything else) is what keeps a 2.21 client and a 2.20 server from ever
|
||||
* reaching that state. */
|
||||
#define PROTOCOL_VERSION "2.21.0"
|
||||
#define PROTOCOL_VERSION "1.2.0"
|
||||
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
|
||||
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
|
||||
#define MAX_BASIS_DIRS 64
|
||||
|
||||
/* 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
|
||||
* euid/egid at apply time). */
|
||||
#define IDENTITY_MATCH_ANY (-1)
|
||||
#define IDENTITY_CURRENT (-1)
|
||||
#define MAX_IDENTITY_MAP 128
|
||||
|
||||
Config* config_create(void);
|
||||
void config_delete(Config* config);
|
||||
|
||||
/* Wipe the client-side plaintext auth password (and username) from a Config
|
||||
* before it is freed or handed off. Safe on a NULL/empty Config and idempotent
|
||||
* (it clears the pointers after burning). config_delete calls this
|
||||
* automatically; a caller that drops a Config earlier may call it explicitly. */
|
||||
void config_burn_auth(Config* config);
|
||||
|
||||
bool config_send(int file_descriptor, const Config* config);
|
||||
/* Emit the config frame BODY (every serialized field, in wire order) without
|
||||
* the trailing STATUS_OK handshake. config_send() is this plus the handshake;
|
||||
* the wire-compatibility golden test uses it to hash the exact byte stream. */
|
||||
bool config_send_wire_block(int file_descriptor, const Config* config);
|
||||
Config* config_receive(int file_descriptor);
|
||||
bool config_is_remote_dest(const char* s);
|
||||
/* Parse a single-colon host:path SSH destination (0 = not an SSH destination or
|
||||
* parsed successfully, -1 = rejected, e.g. a user@host beginning with '-'; the
|
||||
* reason is logged). */
|
||||
int config_parse_ssh_dest(Config* config);
|
||||
|
||||
/* A ConfigValidateFunc may return this sentinel to tell
|
||||
* config_receive_with_validate that the callback ALREADY sent a terminal status
|
||||
* frame (e.g. STATUS_AUTH_FAILED, then closed) and the frame must be abandoned
|
||||
* without an additional STATUS_ERROR. A normal rejection returns a message
|
||||
* string (logged, then STATUS_ERROR); NULL accepts. */
|
||||
#define CONFIG_VALIDATE_ALREADY_TERMINATED ((const char*)-1)
|
||||
|
||||
/* Server-side config-frame gate (daemon module selection, Wave A). A server
|
||||
* that needs to make an accept/reject decision about a received Config BEFORE
|
||||
* it sends the STATUS_OK ack (so a rejected connection is refused cleanly with
|
||||
* no data transferred) passes a callback here; it runs after the frame parses
|
||||
* and validates but before the STATUS_OK/STATUS_ERROR ack. Return NULL to
|
||||
* accept the connection; return a non-NULL message to reject it (the message
|
||||
* is logged server-side and STATUS_ERROR is sent in place of STATUS_OK), or the
|
||||
* CONFIG_VALIDATE_ALREADY_TERMINATED sentinel when the callback already sent
|
||||
* its own terminal status. The callback runs in the connection's own process,
|
||||
* so it may set up per-module process state (e.g. the authorized root) and
|
||||
* drive the daemon auth handshake. context is an opaque caller pointer. */
|
||||
typedef const char* (*ConfigValidateFunc)(const Config* config, void* context);
|
||||
Config* config_receive_with_validate(int file_descriptor, ConfigValidateFunc validate,
|
||||
void* context);
|
||||
|
||||
/* Daemon-destination (host::module[/path]) helpers, Wave A. config_is_remote_dest
|
||||
* recognizes the ordinary rsync-style single-colon host:path form used by the
|
||||
* SSH transport; config_is_daemon_dest recognizes the double-colon form that
|
||||
* selects a daemon module over TCP. config_parse_transport_dest is the single
|
||||
* entry point main() uses: it parses a :: destination as a daemon TCP
|
||||
* destination (host -> server_host, module -> config->module, path ->
|
||||
* receive_root_directory) and otherwise falls back to the existing SSH
|
||||
* host:path handling. */
|
||||
bool config_is_daemon_dest(const char* s);
|
||||
/* Returns 1 when the destination was daemon syntax and was parsed, 0 when it
|
||||
* is not daemon syntax (nothing changed), -1 on an invalid daemon destination
|
||||
* (a message is logged and config is left untouched). */
|
||||
int config_parse_daemon_dest(Config* config);
|
||||
/* Returns 1/0/-1 mirroring config_parse_daemon_dest when the destination is
|
||||
* daemon syntax; otherwise runs the existing SSH host:path parse and returns
|
||||
* 0. */
|
||||
int config_parse_transport_dest(Config* config);
|
||||
|
||||
/* True when the negotiated delete timing performs the extra-file deletion
|
||||
* BEFORE the transfer data (--delete-before / --delete-during). The flag is
|
||||
* a pure function of the config and is used identically on the sender (to pick
|
||||
* the manifest-first frame order) and the receiver (to delete when the early
|
||||
* manifest arrives). When false the deletion is committed only after the whole
|
||||
* transfer succeeded (--delete / --delete-after / --delete-delay). */
|
||||
bool config_delete_timing_early(const Config* config);
|
||||
/* Delete-timing sanity: with deletion enabled at most one timing flag may be
|
||||
* set (none = the default delete-after commit timing); without deletion no
|
||||
* timing flag may be set (each timing flag implies --delete). */
|
||||
bool config_has_valid_delete_timing(const Config* config);
|
||||
|
||||
/* Single source of truth for the cross-field ("combination") invariants a
|
||||
* Config must satisfy. Returns NULL when `config` is consistent, or a static,
|
||||
* human-readable error string (no trailing period) describing the FIRST
|
||||
* violation found. No I/O, no logging and no printing, so it is safe to call
|
||||
* from every trust boundary; the iconv rule does invoke charset_spec_valid
|
||||
* (which parses via str_dup/iconv_open), so it is not allocation-free. The client calls
|
||||
* it from validate_config() for up-front UX and the server calls it from
|
||||
* validate_received_config() so the receiver enforces exactly the same
|
||||
* invariants it relies on (the server is the trust boundary). */
|
||||
const char* config_invariants_error(const Config* config);
|
||||
/* True when at least one --compare-dest/--copy-dest/--link-dest was set. */
|
||||
bool config_has_basis(const Config* config);
|
||||
/* Append one basis-dir entry. Returns 0 on success, -1 on allocation failure. */
|
||||
int config_basis_append(Config* config, BasisDestType type, const char* path);
|
||||
/* Validate a client-provided basis-dir path (relative, confined, non-empty). */
|
||||
bool config_basis_path_valid(const char* path);
|
||||
|
||||
/* Parse and validate a --sockopts=OPTIONS comma-separated "OPT=VAL" list into a
|
||||
* malloc'd array of at most *out_count entries. Returns 0 on success (the
|
||||
* caller takes ownership of *out), or -1 on the first invalid option name or
|
||||
* value. Pure/static-analysis friendly: performs no socket calls, so it is
|
||||
* directly unit-testable. */
|
||||
int config_sockopts_parse(const char* spec, SockOptEntry** out, int* out_count);
|
||||
Config *config_create(char *version, char *send_directory,
|
||||
char *receive_directory, bool save_to_disk,
|
||||
bool use_multithreading, bool use_chunk_serialization,
|
||||
bool use_compression, bool use_metadata,
|
||||
int compression_level, bool use_sendfile,
|
||||
unsigned long long chunk_size);
|
||||
void config_delete(Config *config);
|
||||
bool config_send(int file_descriptor, Config *config);
|
||||
Config *config_receive(int file_descriptor);
|
||||
bool is_remote_dest(const char *s);
|
||||
void config_parse_ssh_dest(Config *config);
|
||||
|
||||
#endif
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,203 +0,0 @@
|
||||
#ifndef CREDENTIALS_H
|
||||
#define CREDENTIALS_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
/* Daemon password authentication (A7 remediation, protocol 2.19.0).
|
||||
*
|
||||
* FastSync authenticates a daemon connection with a SCRAM-SHA-256-style
|
||||
* challenge/response handshake. The daemon stores only a salted PBKDF2
|
||||
* verifier (never the password, and never a value that can be replayed as a
|
||||
* bearer credential): the client proves knowledge of the password against a
|
||||
* per-connection server nonce, and the server proves the same shared secret
|
||||
* back. See credentials.c for the exact derivation.
|
||||
*
|
||||
* Server credential store format (--password-file and --early-input): one line
|
||||
* per entry,
|
||||
* user:$fastsync$1$pbkdf2-sha256$<iters>$<salt_b64>$<stored_key_b64>$<server_key_b64>
|
||||
* with standard base64, a 16-byte salt and 32-byte keys, and iters in
|
||||
* [CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS]. Blank lines and lines whose
|
||||
* first non-space character is '#' or ';' are comments. The parser is STRICT:
|
||||
* a malformed line fails the whole load so a typo can never silently change who
|
||||
* may log in. A line holding the legacy (unsalted SHA-256 hex) secret is
|
||||
* hard-rejected with an actionable "legacy" error; there is no auto-upgrade.
|
||||
* Use `fastsync-server --hash-credentials` to generate new-format lines.
|
||||
*
|
||||
* Alongside the store, credentials_load maintains an exact-mode-0600
|
||||
* `<store_path>.dummykey` sidecar holding the store-wide random dummy key. It
|
||||
* is auto-created on first load and MUST be preserved across restarts: it makes
|
||||
* the dummy challenge for an unknown user stable for the life of the store, so
|
||||
* a daemon restart cannot be used as a username-enumeration oracle. A sidecar
|
||||
* that is not an exact-mode-0600 regular file of exactly 32 bytes fails the load
|
||||
* (fail closed); creation forces exact 0600 with fchmod (so a restrictive umask
|
||||
* cannot leave the sidecar unreadable), and only a create/write/fsync/link or
|
||||
* fchmod failure degrades to a transient per-run key with a warning. NOTE: the
|
||||
* sidecar requires EXACT 0600, whereas the store / password files only reject
|
||||
* group/other bits (a deliberate difference).
|
||||
*
|
||||
* Client --password-file format: the FIRST meaningful (non-comment, non-blank)
|
||||
* line is `user:password`, holding the literal password. The client keeps it
|
||||
* only for the duration of the handshake and wipes it at teardown; the file
|
||||
* should be mode 0600 and readable only by its owner. */
|
||||
|
||||
/* Longest accepted credential-file line (excluding the trailing newline). */
|
||||
#define CREDENTIAL_MAX_LINE 4096
|
||||
/* Upper bound on a username in a credential file and on the wire. Kept well
|
||||
* below MAX_STRING_SIZE so a wire username can never exhaust anything. */
|
||||
#define CREDENTIAL_MAX_USER_LEN 256
|
||||
/* Upper bound on a client-file password (before derivation). */
|
||||
#define CREDENTIAL_MAX_PASSWORD_LEN 1024
|
||||
|
||||
/* SCRAM-SHA-256 parameters. Salt and client nonce sizes are fixed by the
|
||||
* shared-auth-message framing; keys are always 32 bytes (SHA-256). */
|
||||
#define CREDENTIAL_SALT_LEN 16
|
||||
#define CREDENTIAL_NONCE_LEN 32
|
||||
#define CREDENTIAL_KEY_LEN 32
|
||||
#define CREDENTIAL_DEFAULT_ITERS 600000u
|
||||
#define CREDENTIAL_MIN_ITERS 100000u
|
||||
#define CREDENTIAL_MAX_ITERS 10000000u
|
||||
/* Buffer size for the full AuthMessage (prefix + three length-prefixed fields).
|
||||
* Worst case: 16 + 4 + 256 + 4 + 32 + 4 + 32. */
|
||||
#define CREDENTIAL_AUTH_MESSAGE_MAX \
|
||||
(16 + 4 + CREDENTIAL_MAX_USER_LEN + 4 + CREDENTIAL_NONCE_LEN + 4 + CREDENTIAL_NONCE_LEN)
|
||||
|
||||
typedef struct CredentialStore CredentialStore;
|
||||
|
||||
/* One resolved verifier. `found` is false for an unknown user or a user not on
|
||||
* a module's auth list; the remaining fields then hold a deterministic dummy
|
||||
* salt (HMAC of the store-wide dummy key over the username), the store-wide
|
||||
* uniform iteration count (default for an empty store) and fixed dummy keys, so
|
||||
* the server can run the same challenge/response math with no enumeration or
|
||||
* timing oracle. */
|
||||
typedef struct {
|
||||
uint8_t salt[CREDENTIAL_SALT_LEN];
|
||||
uint32_t iters;
|
||||
uint8_t stored_key[CREDENTIAL_KEY_LEN];
|
||||
uint8_t server_key[CREDENTIAL_KEY_LEN];
|
||||
bool found;
|
||||
} CredentialVerifier;
|
||||
|
||||
/* Load the daemon credential store.
|
||||
*
|
||||
* password_file and early_input_file are both NULL-or-path, matching the
|
||||
* server's --password-file and --early-input options. A file that cannot be
|
||||
* opened or that fails the strict grammar is a hard error (err filled, NULL
|
||||
* returned) -- the daemon fails CLOSED rather than serving an auth-required
|
||||
* module with a partial store. Both files may be NULL, which yields an empty
|
||||
* store (every auth-required module then refuses connections). Every entry in
|
||||
* the resulting store must agree on the iteration count; entries that disagree
|
||||
* (within one file or across the two layered sources) are rejected. When both
|
||||
* are given, the --early-input file is layered over --password-file: a duplicate
|
||||
* username whose verifier matches is deduplicated; one whose verifier differs
|
||||
* is an error (the two sources disagree), never a silent pick.
|
||||
*
|
||||
* The returned store is heap-owned; free it with credentials_free. */
|
||||
CredentialStore* credentials_load(const char* password_file, const char* early_input_file,
|
||||
char* err, size_t err_size);
|
||||
|
||||
/* Wipe every stored key/salt and free the store. */
|
||||
void credentials_free(CredentialStore* store);
|
||||
|
||||
/* True when `user` is a single bounded token free of whitespace/control bytes
|
||||
* (the rule applied to store users, client-file users and the module list). */
|
||||
bool credentials_username_valid(const char* user);
|
||||
|
||||
/* Standard base64. encode writes NUL-terminated output to out (size out_sz).
|
||||
* decode writes the raw bytes to out (capacity out_sz) and stores the length;
|
||||
* the input must be a well-formed padded base64 string. Both return false on
|
||||
* NULL arguments, a bad character/length, or insufficient output space. */
|
||||
bool credentials_b64_encode(const uint8_t* in, size_t n, char* out, size_t out_sz);
|
||||
bool credentials_b64_decode(const char* in, uint8_t* out, size_t out_sz, size_t* out_len);
|
||||
|
||||
/* Fill out[0..n) from the CSPRNG (RAND_bytes). Returns false on failure. */
|
||||
bool credentials_random_bytes(uint8_t* out, size_t n);
|
||||
|
||||
/* Resolve `user` against the store AND the module's auth-user list. The list
|
||||
* scan is a constant-time full-length comparison with no early break. On a
|
||||
* miss, *out is filled with a dummy verifier (a deterministic per-username salt
|
||||
* derived from the store's dummy key, the store-wide uniform iteration count,
|
||||
* fixed dummy keys, found=false). Returns false on invalid arguments or an
|
||||
* HMAC/crypto primitive failure. */
|
||||
bool credentials_get_verifier(const CredentialStore* store, const char* user,
|
||||
const char* const* module_users, int n, CredentialVerifier* out);
|
||||
|
||||
/* Derive the SCRAM keys from a plaintext password:
|
||||
* K = PBKDF2-HMAC-SHA256(password, salt, iters, 32)
|
||||
* ClientKey = HMAC-SHA256(K, "Client Key"); StoredKey = SHA256(ClientKey)
|
||||
* ServerKey = HMAC-SHA256(K, "Server Key")
|
||||
* Any of client_key/stored_key/server_key may be NULL when not needed.
|
||||
* `iters` must lie in [CREDENTIAL_MIN_ITERS, CREDENTIAL_MAX_ITERS]. */
|
||||
bool credentials_compute_keys(const char* password, const uint8_t salt[CREDENTIAL_SALT_LEN],
|
||||
uint32_t iters, uint8_t client_key[CREDENTIAL_KEY_LEN],
|
||||
uint8_t stored_key[CREDENTIAL_KEY_LEN],
|
||||
uint8_t server_key[CREDENTIAL_KEY_LEN]);
|
||||
|
||||
/* Serialize the shared AuthMessage:
|
||||
* "FastSync-Auth-v1" || be32(len(user)) || user
|
||||
* || be32(32) || server_nonce
|
||||
* || be32(32) || client_nonce
|
||||
* out must hold at least CREDENTIAL_AUTH_MESSAGE_MAX bytes. *out_len receives
|
||||
* the number of bytes written. */
|
||||
bool credentials_build_auth_message(const char* user, const uint8_t* snonce, const uint8_t* cnonce,
|
||||
uint8_t* out, size_t out_sz, size_t* out_len);
|
||||
|
||||
/* Client side: ClientProof = ClientKey XOR HMAC(StoredKey, AuthMessage), and
|
||||
* the expected ServerSignature = HMAC(ServerKey, AuthMessage). */
|
||||
bool credentials_client_proof(const uint8_t client_key[CREDENTIAL_KEY_LEN],
|
||||
const uint8_t stored_key[CREDENTIAL_KEY_LEN],
|
||||
const uint8_t server_key[CREDENTIAL_KEY_LEN], const uint8_t* auth_msg,
|
||||
size_t msg_len, uint8_t proof[CREDENTIAL_KEY_LEN],
|
||||
uint8_t server_sig[CREDENTIAL_KEY_LEN]);
|
||||
|
||||
/* Server side: recompute ClientSig' = HMAC(StoredKey, AuthMessage) and
|
||||
* ClientKey' = proof XOR ClientSig', then accept iff v->found AND
|
||||
* SHA256(ClientKey') equals StoredKey (constant-time over the 32-byte keys).
|
||||
* Always computes server_sig_out = HMAC(ServerKey, AuthMessage). Returns the
|
||||
* accept decision. */
|
||||
bool credentials_verify_response(const CredentialVerifier* v, const char* user,
|
||||
const uint8_t* snonce, const uint8_t* cnonce,
|
||||
const uint8_t proof[CREDENTIAL_KEY_LEN],
|
||||
uint8_t server_sig_out[CREDENTIAL_KEY_LEN]);
|
||||
|
||||
/* Derive a new-format store line for `user`/`password` and write it (without a
|
||||
* trailing newline) into out. A random 16-byte salt is used. On failure err is
|
||||
* filled. Used by --hash-credentials and by tests. */
|
||||
bool credentials_hash_store_line(const char* user, const char* password, uint32_t iters, char* out,
|
||||
size_t out_sz, char* err, size_t err_size);
|
||||
|
||||
/* Read `user:password` lines from `path` (the same no-group/other-bits check as
|
||||
* the other secret files) and write one new-format store line per entry to
|
||||
* `out`.
|
||||
* Blank/comment lines are skipped; a malformed line fails the whole run.
|
||||
* Returns 0 on success, -1 on error (err filled). Used by
|
||||
* `--hash-credentials`. */
|
||||
int credentials_hash_file(const char* path, uint32_t iters, FILE* out, char* err, size_t err_size);
|
||||
|
||||
/* Read the CLIENT-side secret file: the first meaningful line is
|
||||
* `user:password` (the literal password). *user_out and *password_out are
|
||||
* freshly allocated on success (password is plaintext -- the caller derives the
|
||||
* proof and then burns/frees it); both are NULL on error. Returns 0 on
|
||||
* success, -1 on failure (err filled: the path is named, never the credential
|
||||
* itself). Only the line's trailing CR/LF are stripped: the password's bytes
|
||||
* are otherwise preserved exactly, so a password with leading/trailing
|
||||
* whitespace (after the ':') is kept usable. The username is trimmed of
|
||||
* surrounding space/tabs. */
|
||||
int credentials_read_secret_file(const char* path, char** user_out, char** password_out, char* err,
|
||||
size_t err_size);
|
||||
|
||||
/* Constant-time equality over exactly len bytes. */
|
||||
bool credentials_secure_equal(const char* a, const char* b, size_t len);
|
||||
|
||||
/* Overwrite secret[0..len) with zeros (best-effort wipe). */
|
||||
void credentials_burn(char* secret, size_t len);
|
||||
|
||||
/* Number of entries currently in the store (tests/introspection). */
|
||||
int credentials_store_size(const CredentialStore* store);
|
||||
|
||||
/* Whether the store contains an entry for `user` (tests/introspection). */
|
||||
bool credentials_store_has(const CredentialStore* store, const char* user);
|
||||
|
||||
#endif
|
||||
@@ -1,794 +0,0 @@
|
||||
#include "daemon_conf.h"
|
||||
#include "credentials.h"
|
||||
#include "utils.h"
|
||||
#include <arpa/inet.h>
|
||||
#include <ctype.h>
|
||||
#include <errno.h>
|
||||
#include <limits.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static void set_error(char* err, size_t err_size, const char* fmt, ...) {
|
||||
if (!err || err_size == 0)
|
||||
return;
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
vsnprintf(err, err_size, fmt, args);
|
||||
va_end(args);
|
||||
}
|
||||
|
||||
/* Trim leading and trailing ASCII space/tab in place; returns the new start. */
|
||||
static char* trim_ws(char* s) {
|
||||
while (*s == ' ' || *s == '\t')
|
||||
s++;
|
||||
size_t len = strlen(s);
|
||||
while (len > 0 && (s[len - 1] == ' ' || s[len - 1] == '\t'))
|
||||
s[--len] = '\0';
|
||||
return s;
|
||||
}
|
||||
|
||||
/* Case-insensitive equality of a parsed key against a canonical key name. */
|
||||
static bool key_equals(const char* key, const char* canonical) {
|
||||
return strcasecmp(key, canonical) == 0;
|
||||
}
|
||||
|
||||
static bool parse_bool_value(const char* value, bool* out) {
|
||||
if (strcasecmp(value, "yes") == 0 || strcasecmp(value, "true") == 0 || strcmp(value, "1") == 0) {
|
||||
*out = true;
|
||||
return true;
|
||||
}
|
||||
if (strcasecmp(value, "no") == 0 || strcasecmp(value, "false") == 0 || strcmp(value, "0") == 0) {
|
||||
*out = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Parse an IPv4/IPv6 CIDR "addr/prefix" into `bytes`/`*family`. Returns false
|
||||
* for a malformed address, a missing/oversized prefix, or a prefix that does
|
||||
* not fit the address family. */
|
||||
static bool parse_cidr(const char* cidr, int* prefix_out, uint8_t* bytes, int* family_out) {
|
||||
const char* slash = strchr(cidr, '/');
|
||||
if (!slash)
|
||||
return false;
|
||||
size_t addr_len = (size_t)(slash - cidr);
|
||||
if (addr_len == 0 || addr_len >= INET6_ADDRSTRLEN)
|
||||
return false;
|
||||
char addr[INET6_ADDRSTRLEN];
|
||||
memcpy(addr, cidr, addr_len);
|
||||
addr[addr_len] = '\0';
|
||||
char* end = NULL;
|
||||
long prefix = strtol(slash + 1, &end, 10);
|
||||
if (end == slash + 1 || *end != '\0')
|
||||
return false;
|
||||
struct in_addr v4;
|
||||
struct in6_addr v6;
|
||||
if (inet_pton(AF_INET, addr, &v4) == 1) {
|
||||
if (prefix < 0 || prefix > 32)
|
||||
return false;
|
||||
memcpy(bytes, &v4, sizeof(v4));
|
||||
*prefix_out = (int)prefix;
|
||||
*family_out = AF_INET;
|
||||
return true;
|
||||
}
|
||||
if (inet_pton(AF_INET6, addr, &v6) == 1) {
|
||||
if (prefix < 0 || prefix > 128)
|
||||
return false;
|
||||
memcpy(bytes, &v6, sizeof(v6));
|
||||
*prefix_out = (int)prefix;
|
||||
*family_out = AF_INET6;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* A host pattern is valid when it is `*`, a valid IPv4/IPv6 literal, or a valid
|
||||
* CIDR. Peer addresses reaching the matcher are always numeric, so hostname
|
||||
* globs are rejected at parse time: accepting one would create a deny rule that
|
||||
* silently never matches (fail-open). */
|
||||
static bool host_pattern_valid(const char* pattern) {
|
||||
if (!pattern || *pattern == '\0')
|
||||
return false;
|
||||
if (strcmp(pattern, "*") == 0)
|
||||
return true;
|
||||
if (strchr(pattern, '/')) {
|
||||
uint8_t bytes[16];
|
||||
int prefix;
|
||||
int family;
|
||||
return parse_cidr(pattern, &prefix, bytes, &family);
|
||||
}
|
||||
struct in_addr v4;
|
||||
struct in6_addr v6;
|
||||
return inet_pton(AF_INET, pattern, &v4) == 1 || inet_pton(AF_INET6, pattern, &v6) == 1;
|
||||
}
|
||||
|
||||
/* Append every comma- and/or whitespace-separated host pattern in `value` to
|
||||
* the heap-owned list (or replace the list when `replace` is set, which --dparam
|
||||
* uses so an override can narrow access rather than only widen it). Returns
|
||||
* false (err filled) on an invalid pattern or an allocation failure. */
|
||||
static bool store_host_list(char*** list, int* count, const char* value, const char* key,
|
||||
const char* module_name, bool replace, char* err, size_t err_size) {
|
||||
if (replace) {
|
||||
for (int i = 0; i < *count; i++)
|
||||
free((*list)[i]);
|
||||
free(*list);
|
||||
*list = NULL;
|
||||
*count = 0;
|
||||
}
|
||||
char* copy = str_dup(value);
|
||||
if (!copy) {
|
||||
if (module_name)
|
||||
set_error(err, err_size, "out of memory parsing '%s' for module '%s'", key, module_name);
|
||||
else
|
||||
set_error(err, err_size, "out of memory parsing '%s'", key);
|
||||
return false;
|
||||
}
|
||||
char* save = NULL;
|
||||
int added = 0;
|
||||
for (char* token = strtok_r(copy, ", \t", &save); token; token = strtok_r(NULL, ", \t", &save)) {
|
||||
if (!host_pattern_valid(token)) {
|
||||
if (module_name)
|
||||
set_error(err, err_size, "module '%s': invalid host pattern '%s' in '%s'", module_name,
|
||||
token, key);
|
||||
else
|
||||
set_error(err, err_size, "invalid host pattern '%s' in '%s'", token, key);
|
||||
free(copy);
|
||||
return false;
|
||||
}
|
||||
char** grown = realloc(*list, (size_t)(*count + 1) * sizeof(char*));
|
||||
if (!grown) {
|
||||
if (module_name)
|
||||
set_error(err, err_size, "out of memory parsing '%s' for module '%s'", key, module_name);
|
||||
else
|
||||
set_error(err, err_size, "out of memory parsing '%s'", key);
|
||||
free(copy);
|
||||
return false;
|
||||
}
|
||||
*list = grown;
|
||||
char* dup = str_dup(token);
|
||||
if (!dup) {
|
||||
if (module_name)
|
||||
set_error(err, err_size, "out of memory parsing '%s' for module '%s'", key, module_name);
|
||||
else
|
||||
set_error(err, err_size, "out of memory parsing '%s'", key);
|
||||
free(copy);
|
||||
return false;
|
||||
}
|
||||
(*list)[(*count)++] = dup;
|
||||
added++;
|
||||
}
|
||||
free(copy);
|
||||
/* A present key with an empty (or separator-only) value would otherwise
|
||||
* install a zero-length list, i.e. no ACL at all: a strict-parse config must
|
||||
* never silently turn a restrictive directive into "allow everyone". */
|
||||
if (added == 0) {
|
||||
if (module_name)
|
||||
set_error(err, err_size, "module '%s': '%s' must list at least one host pattern", module_name,
|
||||
key);
|
||||
else
|
||||
set_error(err, err_size, "'%s' must list at least one host pattern", key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Parse a `max connections` value: a positive integer (0/negative/garbage are
|
||||
* rejected because they would silently disable the cap or admit nothing). */
|
||||
static bool store_max_connections(int* slot, const char* value, const char* module_name, char* err,
|
||||
size_t err_size) {
|
||||
char* end = NULL;
|
||||
errno = 0;
|
||||
long n = strtol(value, &end, 10);
|
||||
if (*value == '\0' || errno != 0 || *end != '\0' || n <= 0 || n > INT_MAX) {
|
||||
if (module_name)
|
||||
set_error(err, err_size,
|
||||
"module '%s': invalid 'max connections' '%s' (must be a positive "
|
||||
"integer)",
|
||||
module_name, value);
|
||||
else
|
||||
set_error(err, err_size, "invalid 'max connections' '%s' (must be a positive integer)",
|
||||
value);
|
||||
return false;
|
||||
}
|
||||
*slot = (int)n;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Parse a non-negative concurrency cap where 0 means unlimited/disabled
|
||||
* (per-module `max connections`, `max connections per host`,
|
||||
* `auth lockout threshold`). Negative/garbage/oversized values are rejected. */
|
||||
static bool store_optional_cap(int* slot, const char* value, int max_value, const char* key,
|
||||
const char* module_name, char* err, size_t err_size) {
|
||||
char* end = NULL;
|
||||
errno = 0;
|
||||
long n = strtol(value, &end, 10);
|
||||
if (*value == '\0' || errno != 0 || *end != '\0' || n < 0 || n > max_value) {
|
||||
if (module_name)
|
||||
set_error(err, err_size, "module '%s': invalid '%s' '%s' (must be 0-%d)", module_name, key,
|
||||
value, max_value);
|
||||
else
|
||||
set_error(err, err_size, "invalid '%s' '%s' (must be 0-%d)", key, value, max_value);
|
||||
return false;
|
||||
}
|
||||
*slot = (int)n;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Parse an `auth failure delay` value: 0 (disabled) through the configured cap. */
|
||||
static bool store_auth_failure_delay(int* slot, const char* value, char* err, size_t err_size) {
|
||||
char* end = NULL;
|
||||
errno = 0;
|
||||
long n = strtol(value, &end, 10);
|
||||
if (*value == '\0' || errno != 0 || *end != '\0' || n < 0 ||
|
||||
n > DAEMON_CONF_MAX_AUTH_FAILURE_DELAY_MS) {
|
||||
set_error(err, err_size, "invalid 'auth failure delay' '%s' (must be 0-%d milliseconds)", value,
|
||||
DAEMON_CONF_MAX_AUTH_FAILURE_DELAY_MS);
|
||||
return false;
|
||||
}
|
||||
*slot = (int)n;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool daemon_module_name_valid(const char* name) {
|
||||
if (!name || *name == '\0')
|
||||
return false;
|
||||
size_t len = strlen(name);
|
||||
if (len > DAEMON_MAX_MODULE_NAME)
|
||||
return false;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
unsigned char c = (unsigned char)name[i];
|
||||
bool alnum = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');
|
||||
if (!alnum && c != '.' && c != '_' && c != '-')
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
DaemonConf* daemon_conf_create(void) {
|
||||
DaemonConf* conf = calloc(1, sizeof(DaemonConf));
|
||||
if (!conf)
|
||||
return NULL;
|
||||
conf->global.port = DAEMON_CONF_DEFAULT_PORT;
|
||||
conf->global.max_connections = DAEMON_CONF_DEFAULT_MAX_CONNECTIONS;
|
||||
conf->global.auth_failure_delay_ms = DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS;
|
||||
conf->global.max_connections_per_host = DAEMON_CONF_DEFAULT_MAX_CONNECTIONS_PER_HOST;
|
||||
conf->global.auth_lockout_threshold = DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_THRESHOLD;
|
||||
conf->global.auth_lockout_duration_sec = DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_DURATION_SEC;
|
||||
return conf;
|
||||
}
|
||||
|
||||
/* Free a heap-owned pattern list of `count` entries. */
|
||||
static void free_string_list(char** list, int count) {
|
||||
for (int i = 0; i < count; i++)
|
||||
free(list[i]);
|
||||
free(list);
|
||||
}
|
||||
|
||||
void daemon_conf_free(DaemonConf* conf) {
|
||||
if (!conf)
|
||||
return;
|
||||
free(conf->global.motd_file);
|
||||
free(conf->global.address);
|
||||
free_string_list(conf->global.hosts_allow, conf->global.hosts_allow_count);
|
||||
free_string_list(conf->global.hosts_deny, conf->global.hosts_deny_count);
|
||||
for (int i = 0; i < conf->module_count; i++) {
|
||||
DaemonModule* m = &conf->modules[i];
|
||||
free(m->name);
|
||||
free(m->path);
|
||||
for (int j = 0; j < m->auth_user_count; j++)
|
||||
free(m->auth_users[j]);
|
||||
free(m->auth_users);
|
||||
free_string_list(m->hosts_allow, m->hosts_allow_count);
|
||||
free_string_list(m->hosts_deny, m->hosts_deny_count);
|
||||
}
|
||||
free(conf->modules);
|
||||
free(conf);
|
||||
}
|
||||
|
||||
const DaemonModule* daemon_conf_find_module(const DaemonConf* conf, const char* name) {
|
||||
if (!conf || !name)
|
||||
return NULL;
|
||||
for (int i = 0; i < conf->module_count; i++) {
|
||||
if (strcmp(conf->modules[i].name, name) == 0)
|
||||
return &conf->modules[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Replace *slot with a str_dup of value; returns false on allocation failure. */
|
||||
static bool store_string(char** slot, const char* value) {
|
||||
char* dup = str_dup(value);
|
||||
if (!dup)
|
||||
return false;
|
||||
free(*slot);
|
||||
*slot = dup;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool store_port(int* slot, const char* value, char* err, size_t err_size) {
|
||||
char* end;
|
||||
errno = 0;
|
||||
long p = strtol(value, &end, 10);
|
||||
if (errno != 0 || *end != '\0' || *value == '\0' || p <= 0 || p > 65535) {
|
||||
set_error(err, err_size, "invalid port '%s' (must be 1-65535)", value);
|
||||
return false;
|
||||
}
|
||||
*slot = (int)p;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Apply a global scalar key/value. Keys are case-insensitive. Returns false
|
||||
* (err filled) on an unknown key or an invalid value. */
|
||||
static bool apply_global_key(DaemonConf* conf, char* key, const char* value, bool replace_hosts,
|
||||
char* err, size_t err_size) {
|
||||
if (key_equals(key, "port"))
|
||||
return store_port(&conf->global.port, value, err, err_size);
|
||||
if (key_equals(key, "motd file")) {
|
||||
if (!store_string(&conf->global.motd_file, value)) {
|
||||
set_error(err, err_size, "out of memory parsing 'motd file'");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (key_equals(key, "address")) {
|
||||
if (!store_string(&conf->global.address, value)) {
|
||||
set_error(err, err_size, "out of memory parsing 'address'");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (key_equals(key, "max connections"))
|
||||
return store_max_connections(&conf->global.max_connections, value, NULL, err, err_size);
|
||||
if (key_equals(key, "max connections per host"))
|
||||
return store_optional_cap(&conf->global.max_connections_per_host, value,
|
||||
DAEMON_CONF_MAX_CONCURRENCY_LIMIT, "max connections per host", NULL,
|
||||
err, err_size);
|
||||
if (key_equals(key, "auth failure delay"))
|
||||
return store_auth_failure_delay(&conf->global.auth_failure_delay_ms, value, err, err_size);
|
||||
if (key_equals(key, "auth lockout threshold"))
|
||||
return store_optional_cap(&conf->global.auth_lockout_threshold, value,
|
||||
DAEMON_CONF_MAX_CONCURRENCY_LIMIT, "auth lockout threshold", NULL,
|
||||
err, err_size);
|
||||
if (key_equals(key, "auth lockout duration"))
|
||||
return store_optional_cap(&conf->global.auth_lockout_duration_sec, value,
|
||||
DAEMON_CONF_MAX_AUTH_LOCKOUT_DURATION_SEC, "auth lockout duration",
|
||||
NULL, err, err_size);
|
||||
if (key_equals(key, "hosts allow"))
|
||||
return store_host_list(&conf->global.hosts_allow, &conf->global.hosts_allow_count, value,
|
||||
"hosts allow", NULL, replace_hosts, err, err_size);
|
||||
if (key_equals(key, "hosts deny"))
|
||||
return store_host_list(&conf->global.hosts_deny, &conf->global.hosts_deny_count, value,
|
||||
"hosts deny", NULL, replace_hosts, err, err_size);
|
||||
set_error(err, err_size, "unknown global key '%s'", key);
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Apply a module key/value to the currently-open module. Returns false (err
|
||||
* filled) on an unknown module key or an invalid value. */
|
||||
static bool apply_module_key(DaemonModule* module, char* key, char* value, char* err,
|
||||
size_t err_size) {
|
||||
if (key_equals(key, "path")) {
|
||||
if (*value == '\0') {
|
||||
set_error(err, err_size, "module '%s': 'path' must not be empty", module->name);
|
||||
return false;
|
||||
}
|
||||
if (!store_string(&module->path, value)) {
|
||||
set_error(err, err_size, "out of memory parsing 'path' for module '%s'", module->name);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (key_equals(key, "read only")) {
|
||||
bool parsed;
|
||||
if (!parse_bool_value(value, &parsed)) {
|
||||
set_error(err, err_size,
|
||||
"module '%s': 'read only' must be yes/no (or true/false/1/0), got '%s'",
|
||||
module->name, value);
|
||||
return false;
|
||||
}
|
||||
module->read_only = parsed;
|
||||
return true;
|
||||
}
|
||||
if (key_equals(key, "client owner")) {
|
||||
bool parsed;
|
||||
if (!parse_bool_value(value, &parsed)) {
|
||||
set_error(err, err_size,
|
||||
"module '%s': 'client owner' must be yes/no (or true/false/1/0), got '%s'",
|
||||
module->name, value);
|
||||
return false;
|
||||
}
|
||||
module->client_owner = parsed;
|
||||
return true;
|
||||
}
|
||||
if (key_equals(key, "auth users")) {
|
||||
char* list = str_dup(value);
|
||||
if (!list) {
|
||||
set_error(err, err_size, "out of memory parsing 'auth users' for module '%s'", module->name);
|
||||
return false;
|
||||
}
|
||||
char* save = NULL;
|
||||
int added = 0;
|
||||
for (char* token = strtok_r(list, ",", &save); token; token = strtok_r(NULL, ",", &save)) {
|
||||
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) {
|
||||
free(list);
|
||||
set_error(err, err_size, "out of memory parsing 'auth users' for module '%s'",
|
||||
module->name);
|
||||
return false;
|
||||
}
|
||||
module->auth_users = grown;
|
||||
char* dup = str_dup(user);
|
||||
if (!dup) {
|
||||
free(list);
|
||||
set_error(err, err_size, "out of memory parsing 'auth users' for module '%s'",
|
||||
module->name);
|
||||
return false;
|
||||
}
|
||||
module->auth_users[module->auth_user_count++] = dup;
|
||||
added++;
|
||||
}
|
||||
free(list);
|
||||
/* An empty/separator-only value must not silently disable authentication:
|
||||
* the key's presence is an explicit request for an allow-list. */
|
||||
if (added == 0) {
|
||||
set_error(err, err_size, "module '%s': 'auth users' must list at least one user",
|
||||
module->name);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (key_equals(key, "max connections"))
|
||||
return store_optional_cap(&module->max_connections, value, DAEMON_CONF_MAX_CONCURRENCY_LIMIT,
|
||||
"max connections", module->name, err, err_size);
|
||||
if (key_equals(key, "hosts allow"))
|
||||
return store_host_list(&module->hosts_allow, &module->hosts_allow_count, value, "hosts allow",
|
||||
module->name, false, err, err_size);
|
||||
if (key_equals(key, "hosts deny"))
|
||||
return store_host_list(&module->hosts_deny, &module->hosts_deny_count, value, "hosts deny",
|
||||
module->name, false, err, err_size);
|
||||
set_error(err, err_size, "unknown key '%s' in module '%s'", key, module->name);
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool module_open_valid(const DaemonModule* module, char* err, size_t err_size) {
|
||||
if (module->path == NULL) {
|
||||
set_error(err, err_size, "module '%s' has no 'path'", module->name);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Validate a [section] header line body (text between the brackets) and set
|
||||
* *name to the module name. Returns false on a malformed header. */
|
||||
static bool parse_section_name(char* body, const char** name_out, char* err, size_t err_size) {
|
||||
char* name = trim_ws(body);
|
||||
if (!daemon_module_name_valid(name)) {
|
||||
set_error(err, err_size, "invalid module name '%s' (must be 1-%d chars of [A-Za-z0-9._-])",
|
||||
name, DAEMON_MAX_MODULE_NAME);
|
||||
return false;
|
||||
}
|
||||
*name_out = name;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Open (or switch to) a module section. Closes any previously open module
|
||||
* (validating it has a path) and appends the new one. */
|
||||
static int open_module(DaemonConf* conf, int* current_module, const char* name, char* err,
|
||||
size_t err_size) {
|
||||
if (*current_module >= 0) {
|
||||
if (!module_open_valid(&conf->modules[*current_module], err, err_size))
|
||||
return -1;
|
||||
}
|
||||
if (daemon_conf_find_module(conf, name)) {
|
||||
set_error(err, err_size, "duplicate module '%s'", name);
|
||||
return -1;
|
||||
}
|
||||
if (conf->module_count >= DAEMON_CONF_MAX_MODULES) {
|
||||
set_error(err, err_size, "too many modules (limit %d); module '%s' rejected",
|
||||
DAEMON_CONF_MAX_MODULES, name);
|
||||
return -1;
|
||||
}
|
||||
DaemonModule* grown =
|
||||
realloc(conf->modules, (size_t)(conf->module_count + 1) * sizeof(DaemonModule));
|
||||
if (!grown) {
|
||||
set_error(err, err_size, "out of memory adding module '%s'", name);
|
||||
return -1;
|
||||
}
|
||||
conf->modules = grown;
|
||||
memset(&conf->modules[conf->module_count], 0, sizeof(DaemonModule));
|
||||
conf->modules[conf->module_count].name = str_dup(name);
|
||||
if (!conf->modules[conf->module_count].name) {
|
||||
set_error(err, err_size, "out of memory adding module '%s'", name);
|
||||
return -1;
|
||||
}
|
||||
conf->module_count++;
|
||||
*current_module = conf->module_count - 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Split a "key = value" line (value pointer returned in *value, pointing into
|
||||
* line). Returns false when there is no '='. */
|
||||
static bool split_key_value(char* line, char** key, char** value) {
|
||||
char* eq = strchr(line, '=');
|
||||
if (!eq)
|
||||
return false;
|
||||
*eq = '\0';
|
||||
*key = trim_ws(line);
|
||||
*value = trim_ws(eq + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Strip one layer of surrounding double quotes from a trimmed value. A value
|
||||
* that starts with '"' but does not end with '"' is an error. */
|
||||
static bool unquote_value(char* value, char* err, size_t err_size) {
|
||||
size_t len = strlen(value);
|
||||
if (len == 0 || value[0] != '"')
|
||||
return true;
|
||||
if (len < 2 || value[len - 1] != '"') {
|
||||
set_error(err, err_size, "unterminated quoted value");
|
||||
return false;
|
||||
}
|
||||
memmove(value, value + 1, len - 2);
|
||||
value[len - 2] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
DaemonConf* daemon_conf_load(const char* path, char* err, size_t err_size) {
|
||||
if (err && err_size)
|
||||
err[0] = '\0';
|
||||
if (!path) {
|
||||
set_error(err, err_size, "no daemon config path");
|
||||
return NULL;
|
||||
}
|
||||
FILE* fp = fopen(path, "r");
|
||||
if (!fp) {
|
||||
set_error(err, err_size, "cannot open daemon config '%s': %s", path, strerror(errno));
|
||||
return NULL;
|
||||
}
|
||||
|
||||
DaemonConf* conf = daemon_conf_create();
|
||||
if (!conf) {
|
||||
fclose(fp);
|
||||
set_error(err, err_size, "out of memory allocating daemon config");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int current_module = -1;
|
||||
int line_no = 0;
|
||||
char line[DAEMON_CONF_MAX_LINE + 2];
|
||||
bool ok = true;
|
||||
|
||||
while (ok && fgets(line, sizeof(line), fp)) {
|
||||
line_no++;
|
||||
size_t len = strlen(line);
|
||||
if (len == DAEMON_CONF_MAX_LINE + 1 && line[len - 1] != '\n') {
|
||||
/* The read stopped at the buffer edge without a newline and there is
|
||||
* more file to come: the line exceeds the bound. */
|
||||
if (!feof(fp)) {
|
||||
set_error(err, err_size, "line %d exceeds the %d-byte limit", line_no,
|
||||
DAEMON_CONF_MAX_LINE);
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (len > 0 && line[len - 1] == '\n')
|
||||
line[--len] = '\0';
|
||||
if (len > 0 && line[len - 1] == '\r')
|
||||
line[--len] = '\0';
|
||||
|
||||
char* cursor = line;
|
||||
while (*cursor == ' ' || *cursor == '\t')
|
||||
cursor++;
|
||||
if (*cursor == '\0' || *cursor == '#' || *cursor == ';')
|
||||
continue; /* blank or comment line */
|
||||
|
||||
if (*cursor == '[') {
|
||||
char* close = strchr(cursor, ']');
|
||||
if (!close) {
|
||||
set_error(err, err_size, "line %d: unterminated module header", line_no);
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
*close = '\0';
|
||||
char* trailing = close + 1;
|
||||
const char* rest = trim_ws(trailing);
|
||||
if (*rest != '\0') {
|
||||
set_error(err, err_size, "line %d: unexpected text after module header", line_no);
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
const char* name = NULL;
|
||||
if (!parse_section_name(cursor + 1, &name, err, err_size)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (open_module(conf, ¤t_module, name, err, err_size) != 0) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
char* key;
|
||||
char* value;
|
||||
if (!split_key_value(cursor, &key, &value)) {
|
||||
set_error(err, err_size, "line %d: expected 'key = value'", line_no);
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (*key == '\0') {
|
||||
set_error(err, err_size, "line %d: empty key", line_no);
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (!unquote_value(value, err, err_size)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (current_module >= 0) {
|
||||
if (!apply_module_key(&conf->modules[current_module], key, value, err, err_size)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (!apply_global_key(conf, key, value, false, err, err_size)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ok && ferror(fp)) {
|
||||
set_error(err, err_size, "error reading daemon config '%s': %s", path, strerror(errno));
|
||||
ok = false;
|
||||
}
|
||||
fclose(fp);
|
||||
|
||||
if (ok && current_module >= 0 &&
|
||||
!module_open_valid(&conf->modules[current_module], err, err_size)) {
|
||||
ok = false;
|
||||
}
|
||||
if (!ok) {
|
||||
daemon_conf_free(conf);
|
||||
return NULL;
|
||||
}
|
||||
return conf;
|
||||
}
|
||||
|
||||
int daemon_conf_apply_dparam(DaemonConf* conf, const char* assignment, char* err, size_t err_size) {
|
||||
if (err && err_size)
|
||||
err[0] = '\0';
|
||||
if (!conf || !assignment || *assignment == '\0') {
|
||||
set_error(err, err_size, "--dparam requires a KEY=VALUE override");
|
||||
return -1;
|
||||
}
|
||||
char* copy = str_dup(assignment);
|
||||
if (!copy) {
|
||||
set_error(err, err_size, "out of memory parsing --dparam");
|
||||
return -1;
|
||||
}
|
||||
char* eq = strchr(copy, '=');
|
||||
if (!eq) {
|
||||
free(copy);
|
||||
set_error(err, err_size, "--dparam '%s' has no '=' (expected KEY=VALUE)", assignment);
|
||||
return -1;
|
||||
}
|
||||
*eq = '\0';
|
||||
char* key = trim_ws(copy);
|
||||
const char* value = trim_ws(eq + 1);
|
||||
if (*key == '\0') {
|
||||
free(copy);
|
||||
set_error(err, err_size, "--dparam '%s' has an empty key", assignment);
|
||||
return -1;
|
||||
}
|
||||
if (*value == '\0') {
|
||||
free(copy);
|
||||
set_error(err, err_size, "--dparam '%s' has an empty value", assignment);
|
||||
return -1;
|
||||
}
|
||||
bool ok = apply_global_key(conf, key, value, true, err, err_size);
|
||||
free(copy);
|
||||
return ok ? 0 : -1;
|
||||
}
|
||||
|
||||
/* Compare the first `prefix` bits of two 16-byte address buffers. */
|
||||
static bool bit_prefix_match(const uint8_t* a, const uint8_t* b, int prefix) {
|
||||
int whole = prefix / 8;
|
||||
if (whole > 0 && memcmp(a, b, (size_t)whole) != 0)
|
||||
return false;
|
||||
int remainder = prefix % 8;
|
||||
if (remainder == 0)
|
||||
return true;
|
||||
uint8_t mask = (uint8_t)(0xffu << (8 - remainder));
|
||||
return (a[whole] & mask) == (b[whole] & mask);
|
||||
}
|
||||
|
||||
/* Case-insensitive glob match used for hostname patterns. Falls back to the
|
||||
* shared case-sensitive matcher when an operand is too long for the stack
|
||||
* buffers. */
|
||||
static bool host_glob_match(const char* pattern, const char* str) {
|
||||
char pbuf[256];
|
||||
char sbuf[256];
|
||||
size_t plen = strlen(pattern);
|
||||
size_t slen = strlen(str);
|
||||
if (plen >= sizeof(pbuf) || slen >= sizeof(sbuf))
|
||||
return glob_match(pattern, str);
|
||||
for (size_t i = 0; i <= plen; i++)
|
||||
pbuf[i] = (char)tolower((unsigned char)pattern[i]);
|
||||
for (size_t i = 0; i <= slen; i++)
|
||||
sbuf[i] = (char)tolower((unsigned char)str[i]);
|
||||
return glob_match(pbuf, sbuf);
|
||||
}
|
||||
|
||||
bool daemon_host_pattern_match(const char* pattern, const char* peer_ip) {
|
||||
if (!pattern || *pattern == '\0' || !peer_ip || *peer_ip == '\0')
|
||||
return false;
|
||||
if (strcmp(pattern, "*") == 0)
|
||||
return true;
|
||||
if (strchr(pattern, '/')) {
|
||||
uint8_t pattern_bytes[16];
|
||||
uint8_t peer_bytes[16];
|
||||
int prefix = 0;
|
||||
int family = AF_UNSPEC;
|
||||
if (!parse_cidr(pattern, &prefix, pattern_bytes, &family))
|
||||
return false;
|
||||
if (inet_pton(family, peer_ip, peer_bytes) != 1)
|
||||
return false;
|
||||
return bit_prefix_match(pattern_bytes, peer_bytes, prefix);
|
||||
}
|
||||
struct in_addr pattern_v4;
|
||||
struct in_addr peer_v4;
|
||||
if (inet_pton(AF_INET, pattern, &pattern_v4) == 1)
|
||||
return inet_pton(AF_INET, peer_ip, &peer_v4) == 1 && pattern_v4.s_addr == peer_v4.s_addr;
|
||||
struct in6_addr pattern_v6;
|
||||
struct in6_addr peer_v6;
|
||||
if (inet_pton(AF_INET6, pattern, &pattern_v6) == 1)
|
||||
return inet_pton(AF_INET6, peer_ip, &peer_v6) == 1 &&
|
||||
memcmp(&pattern_v6, &peer_v6, sizeof(pattern_v6)) == 0;
|
||||
/* Not a literal: a hostname/glob pattern. */
|
||||
return host_glob_match(pattern, peer_ip);
|
||||
}
|
||||
|
||||
bool daemon_hosts_allowed(const char* peer_ip, char* const* allow, int allow_count,
|
||||
char* const* deny, int deny_count) {
|
||||
if (!peer_ip)
|
||||
return false;
|
||||
for (int i = 0; i < deny_count; i++) {
|
||||
if (daemon_host_pattern_match(deny[i], peer_ip))
|
||||
return false;
|
||||
}
|
||||
if (allow_count > 0) {
|
||||
for (int i = 0; i < allow_count; i++) {
|
||||
if (daemon_host_pattern_match(allow[i], peer_ip))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool daemon_hosts_restricted(char* const* allow, int allow_count, char* const* deny,
|
||||
int deny_count) {
|
||||
(void)allow;
|
||||
(void)deny;
|
||||
return allow_count > 0 || deny_count > 0;
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
#ifndef DAEMON_CONF_H
|
||||
#define DAEMON_CONF_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/* FastSync-native daemon configuration (a FastSync analog of rsyncd.conf).
|
||||
*
|
||||
* This is the config the fastsync-server --daemon listener consumes. It is
|
||||
* line-based with an implicit global section followed by zero or more
|
||||
* [module] sections. The full grammar is documented in RSYNC_COMPAT.md
|
||||
* ("Daemon Mode") and summarized below; the parser lives entirely in
|
||||
* daemon_conf.c so it can be unit tested without any socket code.
|
||||
*
|
||||
* The parser is STRICT: an unknown key, a malformed line, a value that does
|
||||
* not parse, a module without a `path`, or a line longer than
|
||||
* DAEMON_CONF_MAX_LINE all fail the whole load with a clear, line-numbered
|
||||
* error instead of being silently ignored. This keeps a typo from silently
|
||||
* changing what a module serves.
|
||||
*/
|
||||
|
||||
/* A daemon module's configured root is used exactly like the standalone
|
||||
* server's --destination-root: the daemon confines every connection that
|
||||
* selects this module to this path (file_open_secure_parent /
|
||||
* has_path_traversal / path_is_within all keep the existing confinement, just
|
||||
* per-module). There is never any client-chosen root: a module path always
|
||||
* stays confined. A daemon REFUSES every client-chosen ownership / super-user
|
||||
* request by default -- --numeric-ids, --chown, --usermap/--groupmap,
|
||||
* --fake-super, --copy-as and an explicit --super -- because there is no
|
||||
* per-module opt-in unless the operator adds one. An operator opts a single
|
||||
* module in with `client owner = yes` (DaemonModule.client_owner), which allows
|
||||
* that client to choose ownership within that module's root (the standalone/SSH
|
||||
* server honors such requests for its single operator-authorized root). The
|
||||
* operator-level --no-super veto additionally forces super-user activities off
|
||||
* for every daemon connection, even an opted-in module. See server_module_gate
|
||||
* in server.c and RSYNC_COMPAT.md.
|
||||
*
|
||||
* `auth_users` is honored by Wave B daemon authentication: a module that
|
||||
* declares auth users accepts a connection only when the presented username is
|
||||
* on this list AND verifies against the daemon's credential store
|
||||
* (--password-file / --early-input). An auth-required module with no usable
|
||||
* store refuses (fail closed) rather than falling open; see server.c. Auth is
|
||||
* never bypassed by ignoring the list. */
|
||||
typedef struct DaemonModule {
|
||||
char* name; /* module name, as the client requests it */
|
||||
char* path; /* module root (daemon-side authorized root) */
|
||||
bool read_only; /* `read only = yes/no`; default no */
|
||||
bool client_owner; /* `client owner = yes/no`; default no. Per-module opt-in
|
||||
that lets this module's clients choose ownership
|
||||
(--numeric-ids/--chown/--usermap/--groupmap/--fake-super/
|
||||
--copy-as) and request explicit --super super-user
|
||||
activities. Without it the daemon refuses all of them. */
|
||||
char** auth_users; /* `auth users = a,b`; Wave B credential list */
|
||||
int auth_user_count;
|
||||
/* `max connections = N` (optional per-module cap). 0 means unlimited. The
|
||||
* per-connection child records the selected module in the shared registry
|
||||
* (daemon_limits.c) once the config frame names it, so the cap is enforced
|
||||
* across all forked children; the parent reclaims the slot on SIGCHLD. */
|
||||
int max_connections;
|
||||
char** hosts_allow; /* `hosts allow = a,b`; host access allow patterns */
|
||||
int hosts_allow_count;
|
||||
char** hosts_deny; /* `hosts deny = a,b`; host access deny patterns */
|
||||
int hosts_deny_count;
|
||||
} DaemonModule;
|
||||
|
||||
/* Global (pre-module) scalar keys. `motd file` is parsed and stored but has
|
||||
* no wire effect yet (MOTD display is Wave C). */
|
||||
typedef struct DaemonConfGlobals {
|
||||
int port; /* `port`, default DAEMON_CONF_DEFAULT_PORT (873) */
|
||||
char* motd_file; /* `motd file`, may be NULL */
|
||||
char* address; /* `address` (optional bind address), may be NULL */
|
||||
int max_connections; /* `max connections`, default
|
||||
DAEMON_CONF_DEFAULT_MAX_CONNECTIONS (100) */
|
||||
int auth_failure_delay_ms; /* `auth failure delay`, milliseconds; default
|
||||
DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS */
|
||||
int max_connections_per_host; /* `max connections per host`, concurrent cap per
|
||||
source IP; default
|
||||
DAEMON_CONF_DEFAULT_MAX_CONNECTIONS_PER_HOST (0 =
|
||||
unlimited) */
|
||||
int auth_lockout_threshold; /* `auth lockout threshold`, failed attempts from
|
||||
one source before lockout; default
|
||||
DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_THRESHOLD (0
|
||||
disables) */
|
||||
int auth_lockout_duration_sec; /* `auth lockout duration`, seconds; default
|
||||
DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_DURATION_SEC
|
||||
(0 disables) */
|
||||
char** hosts_allow; /* `hosts allow`; global host access allow patterns */
|
||||
int hosts_allow_count;
|
||||
char** hosts_deny; /* `hosts deny`; global host access deny patterns */
|
||||
int hosts_deny_count;
|
||||
} DaemonConfGlobals;
|
||||
|
||||
typedef struct DaemonConf {
|
||||
DaemonConfGlobals global;
|
||||
DaemonModule* modules;
|
||||
int module_count;
|
||||
} DaemonConf;
|
||||
|
||||
#define DAEMON_CONF_DEFAULT_PORT 873
|
||||
/* Default global connection cap when `max connections` is absent. Matches the
|
||||
* historical hardcoded listener value. */
|
||||
#define DAEMON_CONF_DEFAULT_MAX_CONNECTIONS 100
|
||||
/* Default `auth failure delay` in milliseconds (0 disables the throttle). */
|
||||
#define DAEMON_CONF_DEFAULT_AUTH_FAILURE_DELAY_MS 500
|
||||
/* Default `max connections per host` (0 = unlimited). */
|
||||
#define DAEMON_CONF_DEFAULT_MAX_CONNECTIONS_PER_HOST 0
|
||||
/* Default cross-process auth lockout: 10 failed attempts from one source lock
|
||||
* it out for 300 s (0 disables either knob). */
|
||||
#define DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_THRESHOLD 10
|
||||
#define DAEMON_CONF_DEFAULT_AUTH_LOCKOUT_DURATION_SEC 300
|
||||
/* Upper bound on a `max connections per host` or `auth lockout threshold`
|
||||
* value, so a typo cannot size the shared registry absurdly. */
|
||||
#define DAEMON_CONF_MAX_CONCURRENCY_LIMIT 1000000
|
||||
/* Upper bound on `auth lockout duration` (7 days). */
|
||||
#define DAEMON_CONF_MAX_AUTH_LOCKOUT_DURATION_SEC 604800
|
||||
/* Largest accepted `auth failure delay`, so a typo cannot pin a connection
|
||||
* child in nanosleep for an absurd time. */
|
||||
/* Bounded well below the socket I/O timeout so a failed-auth child cannot hold
|
||||
* a connection slot for long enough to amplify connection-cap exhaustion. */
|
||||
#define DAEMON_CONF_MAX_AUTH_FAILURE_DELAY_MS 5000
|
||||
/* Upper bound on the number of [module] sections, so the shared registry's
|
||||
* per-module counter array stays fixed-size. The parser rejects the next
|
||||
* section past this bound. */
|
||||
#define DAEMON_CONF_MAX_MODULES 256
|
||||
/* Longest accepted config line (excluding the trailing newline). Longer lines
|
||||
* are rejected rather than buffered unboundedly. */
|
||||
#define DAEMON_CONF_MAX_LINE 4096
|
||||
/* Upper bound on a module name. Kept far below MAX_STRING_SIZE so a wire
|
||||
* module name can never exhaust anything by being long. */
|
||||
#define DAEMON_MAX_MODULE_NAME 200
|
||||
|
||||
/* Allocate an empty daemon config with defaulted globals (port 873, no
|
||||
* modules, no motd/address). Never fails for an allocation failure; callers
|
||||
* must still NULL-check. */
|
||||
DaemonConf* daemon_conf_create(void);
|
||||
|
||||
/* Parse `path` into a freshly allocated DaemonConf. Returns NULL on any error
|
||||
* and fills `err` (err_size bytes) with a clear, line-numbered message. The
|
||||
* returned object is heap-owned; free it with daemon_conf_free. */
|
||||
DaemonConf* daemon_conf_load(const char* path, char* err, size_t err_size);
|
||||
|
||||
void daemon_conf_free(DaemonConf* conf);
|
||||
|
||||
/* Case-sensitive exact module lookup by name. Returns the module or NULL.
|
||||
* Module names are matched exactly (rsync semantics). */
|
||||
const DaemonModule* daemon_conf_find_module(const DaemonConf* conf, const char* name);
|
||||
|
||||
/* Module-name syntax check: non-empty, at most DAEMON_MAX_MODULE_NAME chars,
|
||||
* and only [A-Za-z0-9._-]. Used by the config parser, the client's
|
||||
* host::module/path destination parser, and (implicitly) by the daemon lookup
|
||||
* (a name that fails this can never match a parsed module). */
|
||||
bool daemon_module_name_valid(const char* name);
|
||||
|
||||
/* Parse one --dparam=KEY=VALUE (or "--dparam KEY=VALUE") override string and
|
||||
* apply it to the global keys only. Keys are case-insensitive and limited to
|
||||
* the global keys defined by the grammar (port, motd file, address,
|
||||
* max connections, max connections per host, auth failure delay,
|
||||
* auth lockout threshold, auth lockout duration, hosts allow, hosts deny).
|
||||
* Returns 0 on success, -1 on error (err filled). */
|
||||
int daemon_conf_apply_dparam(DaemonConf* conf, const char* assignment, char* err, size_t err_size);
|
||||
|
||||
/* Host access-control matching (pure; no I/O). `daemon_host_pattern_match`
|
||||
* matches one configured pattern against a numeric peer IP string. Supported
|
||||
* patterns: `*` (match anything), an IPv4/IPv6 literal, an IPv4/IPv6 CIDR
|
||||
* (`10.0.0.0/8`, `2001:db8::/32`), or a glob (`*.example.com`) evaluated with
|
||||
* the same matcher as file globs; a glob only matches a peer string of the
|
||||
* same shape, so a numeric peer never matches a hostname glob. */
|
||||
bool daemon_host_pattern_match(const char* pattern, const char* peer_ip);
|
||||
|
||||
/* rsync-like combined decision over a deny list and an allow list: a matching
|
||||
* deny rejects (deny takes precedence); otherwise, when any allow entries
|
||||
* exist, a peer that matches none is rejected; with no allow entries every
|
||||
* peer not denied is accepted. An empty/unset pair returns true. */
|
||||
bool daemon_hosts_allowed(const char* peer_ip, char* const* allow, int allow_count,
|
||||
char* const* deny, int deny_count);
|
||||
|
||||
/* True when at least one allow or deny pattern is configured (i.e. an
|
||||
* unprovable peer must fail closed rather than being treated as unrestricted). */
|
||||
bool daemon_hosts_restricted(char* const* allow, int allow_count, char* const* deny,
|
||||
int deny_count);
|
||||
|
||||
#endif
|
||||
@@ -1,494 +0,0 @@
|
||||
#include "daemon_limits.h"
|
||||
#include "daemon_conf.h"
|
||||
#include "log.h"
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/mman.h>
|
||||
#include <time.h>
|
||||
|
||||
/* The two module-count bounds must agree: the daemon config parser never
|
||||
* produces more than DAEMON_CONF_MAX_MODULES modules, so the shared registry's
|
||||
* per-module counter array is sized from the same bound. */
|
||||
_Static_assert(DAEMON_LIMITS_MAX_MODULES == DAEMON_CONF_MAX_MODULES,
|
||||
"daemon_limits module bound must match daemon_conf");
|
||||
|
||||
/* Slot lifecycle states (stored in slot_state). */
|
||||
enum {
|
||||
SLOT_FREE = 0,
|
||||
SLOT_CLAIMED = 1,
|
||||
SLOT_REGISTERED = 2,
|
||||
};
|
||||
|
||||
/* The registry header lives at the base of the shared mapping; the pointer
|
||||
* fields point at the arrays carved out of the same mapping. Absolute pointers
|
||||
* remain valid in a forked child because fork() clones the address space and
|
||||
* mapping, so parent and child observe the same virtual addresses. */
|
||||
struct DaemonLimitRegistry {
|
||||
int max_slots;
|
||||
int module_count;
|
||||
int host_slots; /* power of two; 1 when no per-source tracking is needed */
|
||||
int per_host_cap;
|
||||
int lockout_threshold;
|
||||
int lockout_duration_sec;
|
||||
size_t map_size;
|
||||
_Atomic long long host_full_warn; /* last "table full" warning epoch */
|
||||
_Atomic int* slot_state;
|
||||
_Atomic int* slot_pid;
|
||||
_Atomic int* slot_module;
|
||||
_Atomic int* slot_host; /* per-source table bucket, or -1 */
|
||||
_Atomic int* module_active;
|
||||
_Atomic uint64_t* host_key; /* 0 == empty bucket */
|
||||
_Atomic int* host_active;
|
||||
_Atomic int* host_fail;
|
||||
_Atomic long long* host_until; /* epoch seconds the lockout expires */
|
||||
_Atomic long long* host_last_use; /* epoch seconds the bucket was last touched */
|
||||
};
|
||||
|
||||
static size_t round_up(size_t n, size_t align) {
|
||||
return (n + align - 1) & ~(align - 1);
|
||||
}
|
||||
|
||||
static size_t next_pow2(size_t n) {
|
||||
size_t p = 1;
|
||||
while (p < n)
|
||||
p <<= 1;
|
||||
return p;
|
||||
}
|
||||
|
||||
/* Parse a numeric IPv4/IPv6 peer string into family + raw bytes. */
|
||||
static bool parse_peer_ip(const char* peer_ip, int* family, unsigned char* bytes) {
|
||||
if (!peer_ip || *peer_ip == '\0')
|
||||
return false;
|
||||
struct in_addr v4;
|
||||
if (inet_pton(AF_INET, peer_ip, &v4) == 1) {
|
||||
memcpy(bytes, &v4, sizeof(v4));
|
||||
*family = AF_INET;
|
||||
return true;
|
||||
}
|
||||
struct in6_addr v6;
|
||||
if (inet_pton(AF_INET6, peer_ip, &v6) == 1) {
|
||||
memcpy(bytes, &v6, sizeof(v6));
|
||||
*family = AF_INET6;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
uint64_t daemon_limits_host_hash(const char* peer_ip, bool* ok) {
|
||||
if (ok)
|
||||
*ok = false;
|
||||
unsigned char bytes[16];
|
||||
int family = AF_UNSPEC;
|
||||
if (!parse_peer_ip(peer_ip, &family, bytes))
|
||||
return 0;
|
||||
uint64_t hash = 14695981039346656037ULL ^ (uint64_t)(uint32_t)family;
|
||||
size_t length = family == AF_INET ? 4 : 16;
|
||||
for (size_t i = 0; i < length; i++) {
|
||||
hash ^= bytes[i];
|
||||
hash *= 1099511628211ULL;
|
||||
}
|
||||
if (hash == 0)
|
||||
hash = 0x9e3779b97f4a7c15ULL;
|
||||
if (ok)
|
||||
*ok = true;
|
||||
return hash;
|
||||
}
|
||||
|
||||
/* True when the registry must maintain per-source buckets: either the per-host
|
||||
* cap is configured, or the auth lockout is (threshold AND duration > 0). A
|
||||
* lockout threshold without a duration is a no-op, so it must not size or intern
|
||||
* the table. create(), register() and the lockout paths all agree on this. */
|
||||
static bool registry_tracks_hosts(const DaemonLimitRegistry* registry) {
|
||||
return registry->per_host_cap > 0 ||
|
||||
(registry->lockout_threshold > 0 && registry->lockout_duration_sec > 0);
|
||||
}
|
||||
|
||||
/* Find the bucket holding `peer_ip`, or -1 when it has no entry. Finding a
|
||||
* bucket refreshes its last-use time so the eviction policy sees it as live. */
|
||||
static int host_lookup(DaemonLimitRegistry* registry, const char* peer_ip) {
|
||||
bool ok = false;
|
||||
uint64_t key = daemon_limits_host_hash(peer_ip, &ok);
|
||||
if (!ok)
|
||||
return -1;
|
||||
size_t mask = (size_t)registry->host_slots - 1;
|
||||
size_t start = (size_t)(key & mask);
|
||||
for (size_t i = 0; i < (size_t)registry->host_slots; i++) {
|
||||
size_t idx = (start + i) & mask;
|
||||
uint64_t current = atomic_load_explicit(®istry->host_key[idx], memory_order_acquire);
|
||||
if (current == key) {
|
||||
atomic_store_explicit(®istry->host_last_use[idx], (long long)time(NULL),
|
||||
memory_order_relaxed);
|
||||
return (int)idx;
|
||||
}
|
||||
if (current == 0)
|
||||
return -1; /* no tombstones: an empty bucket ends the probe chain */
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* A bucket with no live connection may be repurposed: immediately when its
|
||||
* lockout deadline has already passed (the review's "expired" case), or after an
|
||||
* idle window when it holds no pending lockout. A bucket with a future lockout
|
||||
* deadline is retained so the lockout actually lasts its configured duration. */
|
||||
static bool host_bucket_reclaimable(DaemonLimitRegistry* registry, size_t idx, long long now) {
|
||||
if (atomic_load_explicit(®istry->host_active[idx], memory_order_relaxed) != 0)
|
||||
return false;
|
||||
long long until = atomic_load_explicit(®istry->host_until[idx], memory_order_relaxed);
|
||||
if (until != 0)
|
||||
return until <= now;
|
||||
long long last_use = atomic_load_explicit(®istry->host_last_use[idx], memory_order_relaxed);
|
||||
/* A bucket whose key is published but whose last_use has not yet been stamped
|
||||
* (last_use == 0) must be treated as live: reclaiming it here would steal a
|
||||
* bucket a racing child just claimed. The claim path also stamps last_use
|
||||
* before publishing the key, so this window cannot persist. */
|
||||
return last_use != 0 && now - last_use >= DAEMON_LIMITS_HOST_EVICT_IDLE_SEC;
|
||||
}
|
||||
|
||||
/* Emit at most one "per-source table full" warning per
|
||||
* DAEMON_LIMITS_HOST_FULL_WARN_SEC across all forked children. Called from a
|
||||
* normal (non-signal) child path, so logging is safe here. */
|
||||
static void host_warn_table_full(DaemonLimitRegistry* registry, long long now) {
|
||||
long long last = atomic_load_explicit(®istry->host_full_warn, memory_order_relaxed);
|
||||
if (last != 0 && now - last < DAEMON_LIMITS_HOST_FULL_WARN_SEC)
|
||||
return;
|
||||
if (atomic_compare_exchange_strong_explicit(®istry->host_full_warn, &last, now,
|
||||
memory_order_relaxed, memory_order_relaxed)) {
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"daemon: per-source registry is full (%d slots) and no bucket can be reclaimed; "
|
||||
"'max connections per host' and the auth lockout are temporarily not enforced for "
|
||||
"new sources (the per-module cap and host ACLs still apply)",
|
||||
registry->host_slots);
|
||||
}
|
||||
}
|
||||
|
||||
/* Find or insert the bucket for `peer_ip`. Insertion is a lock-free CAS so two
|
||||
* forked children racing on the same source converge on one bucket.
|
||||
*
|
||||
* When the probe finds no empty bucket it reclaims, via a key CAS, the first
|
||||
* bucket that is reclaimable (expired lockout or idle, and no active
|
||||
* connection) and resets its counters. This bounds the table's lifetime so it
|
||||
* cannot fill permanently and stay fail-open. Returns -1 only when the address
|
||||
* is unparseable or the table is genuinely full of live/locked buckets
|
||||
* (callers fail open: the global/module caps and ACLs still apply). */
|
||||
static int host_intern(DaemonLimitRegistry* registry, const char* peer_ip) {
|
||||
bool ok = false;
|
||||
uint64_t key = daemon_limits_host_hash(peer_ip, &ok);
|
||||
if (!ok)
|
||||
return -1;
|
||||
long long now = (long long)time(NULL);
|
||||
size_t mask = (size_t)registry->host_slots - 1;
|
||||
size_t start = (size_t)(key & mask);
|
||||
/* A couple of passes bound the work: the first normally claims/seeds a bucket;
|
||||
* a lost eviction CAS retries once against the freshly observed table. */
|
||||
for (int pass = 0; pass < 2; pass++) {
|
||||
int evict = -1;
|
||||
uint64_t evict_key = 0;
|
||||
for (size_t i = 0; i < (size_t)registry->host_slots; i++) {
|
||||
size_t idx = (start + i) & mask;
|
||||
uint64_t current = atomic_load_explicit(®istry->host_key[idx], memory_order_acquire);
|
||||
if (current == key) {
|
||||
atomic_store_explicit(®istry->host_last_use[idx], now, memory_order_relaxed);
|
||||
return (int)idx;
|
||||
}
|
||||
if (current == 0) {
|
||||
/* Stamp last_use *before* publishing the key so a reclaimer racing the
|
||||
* claim can never observe a claimed bucket with last_use == 0 and
|
||||
* evict it. A pre-stamp is harmless if the CAS loses: the bucket is
|
||||
* either still empty (never inspected for reclaim) or has just been
|
||||
* taken by another source that wants a fresh timestamp anyway. */
|
||||
atomic_store_explicit(®istry->host_last_use[idx], now, memory_order_relaxed);
|
||||
uint64_t expected = 0;
|
||||
if (atomic_compare_exchange_strong_explicit(®istry->host_key[idx], &expected, key,
|
||||
memory_order_acq_rel, memory_order_acquire)) {
|
||||
return (int)idx;
|
||||
}
|
||||
if (atomic_load_explicit(®istry->host_key[idx], memory_order_acquire) == key) {
|
||||
return (int)idx;
|
||||
}
|
||||
continue; /* another child won this empty bucket; keep probing */
|
||||
}
|
||||
if (evict < 0 && host_bucket_reclaimable(registry, idx, now)) {
|
||||
evict = (int)idx;
|
||||
evict_key = current;
|
||||
}
|
||||
}
|
||||
if (evict >= 0) {
|
||||
/* Refresh the timestamp before the key changes hands so the reused bucket
|
||||
* is not seen as immediately idle by a racing reclaimer. */
|
||||
atomic_store_explicit(®istry->host_last_use[evict], now, memory_order_relaxed);
|
||||
uint64_t expected = evict_key;
|
||||
if (atomic_compare_exchange_strong_explicit(®istry->host_key[evict], &expected, key,
|
||||
memory_order_acq_rel, memory_order_acquire)) {
|
||||
/* The bucket now belongs to the new source; clear the evicted source's
|
||||
* stale lockout/failure state. */
|
||||
atomic_store_explicit(®istry->host_active[evict], 0, memory_order_relaxed);
|
||||
atomic_store_explicit(®istry->host_fail[evict], 0, memory_order_relaxed);
|
||||
atomic_store_explicit(®istry->host_until[evict], 0, memory_order_relaxed);
|
||||
/* Two children can race to intern the same brand-new key into different
|
||||
* eviction targets, leaving the table with duplicate buckets for `key`.
|
||||
* Re-scan for the first (canonical) bucket holding `key`; when it
|
||||
* precedes `evict`, drop our duplicate's occupancy and hand back the
|
||||
* canonical bucket so per-source counts are not orphaned on the
|
||||
* duplicate. The duplicate keeps its key, so no tombstone hole is
|
||||
* created and probe chains stay intact; it ages out normally. */
|
||||
for (size_t i = 0; i < (size_t)registry->host_slots; i++) {
|
||||
size_t candidate = (start + i) & mask;
|
||||
uint64_t found =
|
||||
atomic_load_explicit(®istry->host_key[candidate], memory_order_acquire);
|
||||
if (found == key) {
|
||||
if (candidate != (size_t)evict) {
|
||||
atomic_store_explicit(®istry->host_active[evict], 0, memory_order_relaxed);
|
||||
return (int)candidate;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (found == 0)
|
||||
break; /* the key is present at `evict`, so this cannot happen first */
|
||||
}
|
||||
return evict;
|
||||
}
|
||||
continue; /* lost the race; re-probe with fresh observations */
|
||||
}
|
||||
break; /* no free and no reclaimable bucket: genuinely full */
|
||||
}
|
||||
host_warn_table_full(registry, now);
|
||||
return -1;
|
||||
}
|
||||
|
||||
DaemonLimitRegistry* daemon_limits_create(int max_slots, int module_count, int per_host_cap,
|
||||
int lockout_threshold, int lockout_duration_sec) {
|
||||
if (max_slots < DAEMON_LIMITS_MIN_SLOTS)
|
||||
max_slots = DAEMON_LIMITS_MIN_SLOTS;
|
||||
if (max_slots > DAEMON_LIMITS_MAX_SLOTS)
|
||||
max_slots = DAEMON_LIMITS_MAX_SLOTS;
|
||||
if (module_count < 1)
|
||||
module_count = 1;
|
||||
if (module_count > DAEMON_LIMITS_MAX_MODULES)
|
||||
module_count = DAEMON_LIMITS_MAX_MODULES;
|
||||
if (per_host_cap < 0)
|
||||
per_host_cap = 0;
|
||||
if (lockout_threshold < 0)
|
||||
lockout_threshold = 0;
|
||||
if (lockout_duration_sec < 0)
|
||||
lockout_duration_sec = 0;
|
||||
|
||||
bool need_hosts = per_host_cap > 0 || (lockout_threshold > 0 && lockout_duration_sec > 0);
|
||||
int host_slots = 1;
|
||||
if (need_hosts) {
|
||||
size_t want = (size_t)max_slots * 4;
|
||||
if (want < 64)
|
||||
want = 64;
|
||||
if (want > DAEMON_LIMITS_MAX_HOST_SLOTS)
|
||||
want = DAEMON_LIMITS_MAX_HOST_SLOTS;
|
||||
host_slots = (int)next_pow2(want);
|
||||
}
|
||||
|
||||
size_t header = round_up(sizeof(DaemonLimitRegistry), 16);
|
||||
size_t slot_bytes =
|
||||
round_up((size_t)max_slots * sizeof(_Atomic int), 16) * 4; /* state,pid,module,host */
|
||||
size_t module_bytes = round_up((size_t)module_count * sizeof(_Atomic int), 16);
|
||||
size_t host_key_bytes = round_up((size_t)host_slots * sizeof(_Atomic uint64_t), 16);
|
||||
size_t host_int_bytes = round_up((size_t)host_slots * sizeof(_Atomic int), 16) * 2;
|
||||
size_t host_until_bytes = round_up((size_t)host_slots * sizeof(_Atomic long long), 16) * 2;
|
||||
size_t total =
|
||||
header + slot_bytes + module_bytes + host_key_bytes + host_int_bytes + host_until_bytes + 16;
|
||||
|
||||
void* map = mmap(NULL, total, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
|
||||
if (map == MAP_FAILED)
|
||||
return NULL;
|
||||
memset(map, 0, total);
|
||||
|
||||
DaemonLimitRegistry* registry = (DaemonLimitRegistry*)map;
|
||||
registry->max_slots = max_slots;
|
||||
registry->module_count = module_count;
|
||||
registry->host_slots = host_slots;
|
||||
registry->per_host_cap = per_host_cap;
|
||||
registry->lockout_threshold = lockout_threshold;
|
||||
registry->lockout_duration_sec = lockout_duration_sec;
|
||||
registry->map_size = total;
|
||||
|
||||
unsigned char* cursor = (unsigned char*)map + header;
|
||||
registry->slot_state = (atomic_int*)cursor;
|
||||
cursor += (size_t)max_slots * sizeof(_Atomic int);
|
||||
registry->slot_pid = (atomic_int*)cursor;
|
||||
cursor += (size_t)max_slots * sizeof(_Atomic int);
|
||||
registry->slot_module = (atomic_int*)cursor;
|
||||
cursor += (size_t)max_slots * sizeof(_Atomic int);
|
||||
registry->slot_host = (atomic_int*)cursor;
|
||||
cursor += (size_t)max_slots * sizeof(_Atomic int);
|
||||
registry->module_active = (atomic_int*)cursor;
|
||||
cursor += (size_t)module_count * sizeof(_Atomic int);
|
||||
cursor = (unsigned char*)round_up((size_t)(uintptr_t)cursor, 16);
|
||||
registry->host_key = (_Atomic uint64_t*)cursor;
|
||||
cursor += (size_t)host_slots * sizeof(_Atomic uint64_t);
|
||||
registry->host_active = (atomic_int*)cursor;
|
||||
cursor += (size_t)host_slots * sizeof(_Atomic int);
|
||||
registry->host_fail = (atomic_int*)cursor;
|
||||
cursor += (size_t)host_slots * sizeof(_Atomic int);
|
||||
cursor = (unsigned char*)round_up((size_t)(uintptr_t)cursor, 16);
|
||||
registry->host_until = (atomic_llong*)cursor;
|
||||
cursor += (size_t)host_slots * sizeof(_Atomic long long);
|
||||
registry->host_last_use = (atomic_llong*)cursor;
|
||||
|
||||
for (int i = 0; i < max_slots; i++) {
|
||||
atomic_store(®istry->slot_module[i], -1);
|
||||
atomic_store(®istry->slot_host[i], -1);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
void daemon_limits_destroy(DaemonLimitRegistry* registry) {
|
||||
if (!registry)
|
||||
return;
|
||||
munmap(registry, registry->map_size);
|
||||
}
|
||||
|
||||
int daemon_limits_claim_slot(DaemonLimitRegistry* registry) {
|
||||
if (!registry)
|
||||
return DAEMON_LIMITS_NO_SLOT;
|
||||
for (int i = 0; i < registry->max_slots; i++) {
|
||||
int expected = SLOT_FREE;
|
||||
if (atomic_compare_exchange_strong(®istry->slot_state[i], &expected, SLOT_CLAIMED)) {
|
||||
atomic_store(®istry->slot_pid[i], 0);
|
||||
atomic_store(®istry->slot_module[i], -1);
|
||||
atomic_store(®istry->slot_host[i], -1);
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return DAEMON_LIMITS_NO_SLOT;
|
||||
}
|
||||
|
||||
void daemon_limits_set_slot_pid(DaemonLimitRegistry* registry, int slot, long pid) {
|
||||
if (!registry || slot < 0 || slot >= registry->max_slots)
|
||||
return;
|
||||
atomic_store(®istry->slot_pid[slot], (int)pid);
|
||||
}
|
||||
|
||||
void daemon_limits_reclaim_slot(DaemonLimitRegistry* registry, int slot) {
|
||||
if (!registry || slot < 0 || slot >= registry->max_slots)
|
||||
return;
|
||||
atomic_exchange_explicit(®istry->slot_state[slot], SLOT_FREE, memory_order_acq_rel);
|
||||
atomic_store_explicit(®istry->slot_pid[slot], 0, memory_order_relaxed);
|
||||
/* The module/host occupancy arrays are derived from the slot table; do not
|
||||
* decrement here or a SIGKILL between a child's increment and its REGISTERED
|
||||
* publish would leak a count. Callers that need the derived counts call
|
||||
* daemon_limits_recompute. */
|
||||
}
|
||||
|
||||
void daemon_limits_reclaim_pid(DaemonLimitRegistry* registry, long pid) {
|
||||
if (!registry || pid <= 0)
|
||||
return;
|
||||
for (int i = 0; i < registry->max_slots; i++) {
|
||||
if (atomic_load(®istry->slot_state[i]) == SLOT_FREE)
|
||||
continue;
|
||||
if (atomic_load(®istry->slot_pid[i]) == (int)pid) {
|
||||
daemon_limits_reclaim_slot(registry, i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void daemon_limits_recompute(DaemonLimitRegistry* registry) {
|
||||
if (!registry)
|
||||
return;
|
||||
/* Zero the derived arrays, then re-derive solely from the REGISTERED slots.
|
||||
* A child that was SIGKILLed after incrementing a counter but before
|
||||
* publishing REGISTERED is not counted, and its leaked increment is erased by
|
||||
* the zeroing, so the leak cannot persist. */
|
||||
for (int m = 0; m < registry->module_count; m++)
|
||||
atomic_store_explicit(®istry->module_active[m], 0, memory_order_relaxed);
|
||||
for (int h = 0; h < registry->host_slots; h++)
|
||||
atomic_store_explicit(®istry->host_active[h], 0, memory_order_relaxed);
|
||||
for (int i = 0; i < registry->max_slots; i++) {
|
||||
if (atomic_load_explicit(®istry->slot_state[i], memory_order_acquire) != SLOT_REGISTERED)
|
||||
continue;
|
||||
int module = atomic_load_explicit(®istry->slot_module[i], memory_order_relaxed);
|
||||
if (module >= 0 && module < registry->module_count)
|
||||
atomic_fetch_add_explicit(®istry->module_active[module], 1, memory_order_relaxed);
|
||||
int host = atomic_load_explicit(®istry->slot_host[i], memory_order_relaxed);
|
||||
if (host >= 0 && host < registry->host_slots)
|
||||
atomic_fetch_add_explicit(®istry->host_active[host], 1, memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
DaemonLimitResult daemon_limits_register(DaemonLimitRegistry* registry, int slot, int module_index,
|
||||
const char* peer_ip, int module_cap) {
|
||||
if (!registry || slot < 0 || slot >= registry->max_slots)
|
||||
return DAEMON_LIMIT_UNAVAILABLE;
|
||||
if (module_index < 0 || module_index >= registry->module_count)
|
||||
return DAEMON_LIMIT_UNAVAILABLE;
|
||||
if (atomic_load_explicit(®istry->slot_state[slot], memory_order_acquire) != SLOT_CLAIMED)
|
||||
return DAEMON_LIMIT_UNAVAILABLE;
|
||||
|
||||
int host = -1;
|
||||
if (registry_tracks_hosts(registry))
|
||||
host = host_intern(registry, peer_ip);
|
||||
|
||||
int module_count = atomic_fetch_add(®istry->module_active[module_index], 1) + 1;
|
||||
if (module_cap > 0 && module_count > module_cap) {
|
||||
atomic_fetch_sub(®istry->module_active[module_index], 1);
|
||||
return DAEMON_LIMIT_MODULE_FULL;
|
||||
}
|
||||
if (host >= 0) {
|
||||
int host_count = atomic_fetch_add(®istry->host_active[host], 1) + 1;
|
||||
if (registry->per_host_cap > 0 && host_count > registry->per_host_cap) {
|
||||
atomic_fetch_sub(®istry->host_active[host], 1);
|
||||
atomic_fetch_sub(®istry->module_active[module_index], 1);
|
||||
return DAEMON_LIMIT_HOST_FULL;
|
||||
}
|
||||
}
|
||||
atomic_store(®istry->slot_module[slot], module_index);
|
||||
atomic_store(®istry->slot_host[slot], host);
|
||||
atomic_store_explicit(®istry->slot_state[slot], SLOT_REGISTERED, memory_order_release);
|
||||
return DAEMON_LIMIT_OK;
|
||||
}
|
||||
|
||||
bool daemon_limits_auth_locked(DaemonLimitRegistry* registry, const char* peer_ip,
|
||||
int* seconds_remaining) {
|
||||
if (!registry || registry->lockout_threshold <= 0 || registry->lockout_duration_sec <= 0)
|
||||
return false;
|
||||
int bucket = host_lookup(registry, peer_ip);
|
||||
if (bucket < 0)
|
||||
return false;
|
||||
long long until = atomic_load(®istry->host_until[bucket]);
|
||||
long long now = (long long)time(NULL);
|
||||
if (until > now) {
|
||||
if (seconds_remaining)
|
||||
*seconds_remaining = (int)(until - now);
|
||||
return true;
|
||||
}
|
||||
if (until != 0) {
|
||||
/* The previous lockout has expired: clear the stale counter so the source
|
||||
* gets a fresh allowance. */
|
||||
atomic_store(®istry->host_fail[bucket], 0);
|
||||
atomic_store(®istry->host_until[bucket], 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void daemon_limits_auth_record_failure(DaemonLimitRegistry* registry, const char* peer_ip) {
|
||||
if (!registry || registry->lockout_threshold <= 0 || registry->lockout_duration_sec <= 0)
|
||||
return;
|
||||
int bucket = host_intern(registry, peer_ip);
|
||||
if (bucket < 0)
|
||||
return;
|
||||
int failures = atomic_fetch_add(®istry->host_fail[bucket], 1) + 1;
|
||||
if (failures >= registry->lockout_threshold) {
|
||||
long long now = (long long)time(NULL);
|
||||
atomic_store(®istry->host_until[bucket], now + (long long)registry->lockout_duration_sec);
|
||||
}
|
||||
}
|
||||
|
||||
void daemon_limits_auth_record_success(DaemonLimitRegistry* registry, const char* peer_ip) {
|
||||
if (!registry)
|
||||
return;
|
||||
int bucket = host_lookup(registry, peer_ip);
|
||||
if (bucket < 0)
|
||||
return;
|
||||
atomic_store(®istry->host_fail[bucket], 0);
|
||||
atomic_store(®istry->host_until[bucket], 0);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
#ifndef DAEMON_LIMITS_H
|
||||
#define DAEMON_LIMITS_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
/* Cross-process daemon connection registry.
|
||||
*
|
||||
* The daemon listener forks ONE child per accepted connection, so any
|
||||
* per-module / per-source accounting must live in state shared across the
|
||||
* forked children. This module owns a fixed-size registry carved out of an
|
||||
* anonymous shared mapping (mmap(MAP_SHARED | MAP_ANONYMOUS)) created by the
|
||||
* accept-loop PARENT before it forks; every child inherits the mapping (and the
|
||||
* pointer to it) across fork().
|
||||
*
|
||||
* Rules:
|
||||
* - ONLY C11 atomics (atomic_*); never mtx_t/pthread locks, which can deadlock
|
||||
* in a forked child if another thread held them at fork time.
|
||||
* - No heap allocation after fork: the mapping is fixed-size and all access is
|
||||
* atomic load/store/CAS over preallocated arrays.
|
||||
*
|
||||
* Slot lifecycle (the parent reclaims even when a child is SIGKILLed):
|
||||
* FREE --(parent claim_slot)--> CLAIMED
|
||||
* CLAIMED --(child register)--> REGISTERED
|
||||
* any --(parent reclaim)--> FREE
|
||||
* The child records its module index and per-source bucket into the slot before
|
||||
* publishing REGISTERED; the parent's SIGCHLD handler matches the reaped pid to
|
||||
* the slot and, when REGISTERED, decrements the module/per-source counters.
|
||||
* A child killed before registering holds no counts, so reclaiming a CLAIMED
|
||||
* slot only frees the slot.
|
||||
*
|
||||
* Per-source identity is the normalized numeric peer IP (IPv4-mapped IPv6 is
|
||||
* already collapsed to IPv4 by utils_fd_peer_ip); it is interned into an
|
||||
* open-addressed, linear-probing table keyed by a 64-bit hash. The same table
|
||||
* also carries the cross-process auth-failure counter and lockout deadline.
|
||||
*
|
||||
* Per-source table lifetime: a bucket's key is never cleared back to empty (that
|
||||
* would break every later probe chain that passed through it). Instead the
|
||||
* table has a bounded-lifetime eviction policy: when no empty bucket exists, the
|
||||
* first bucket that is reclaimable -- no active connection AND (its lockout
|
||||
* deadline has passed OR it has been idle for
|
||||
* DAEMON_LIMITS_HOST_EVICT_IDLE_SEC) -- is atomically repurposed for the new
|
||||
* source via a CAS of its key, and its counters are reset. The table therefore
|
||||
* cannot fill permanently, and a full table degrades to fail-open for the
|
||||
* per-source cap/lockout of new sources (the per-module cap and host ACLs still
|
||||
* apply) instead of staying fail-open forever. A rate-limited warning is logged
|
||||
* on the fail-open path. The eviction race with a concurrent
|
||||
* registration/reclaim on the same bucket is benign: it can at worst lose one
|
||||
* source's counter (fail-open), never corrupt memory or the module caps.
|
||||
*/
|
||||
|
||||
typedef struct DaemonLimitRegistry DaemonLimitRegistry;
|
||||
|
||||
/* Result of a per-connection admission check. */
|
||||
typedef enum {
|
||||
DAEMON_LIMIT_OK = 0, /* admitted; slot is now REGISTERED */
|
||||
DAEMON_LIMIT_MODULE_FULL, /* module's `max connections` cap reached */
|
||||
DAEMON_LIMIT_HOST_FULL, /* global `max connections per host` cap reached */
|
||||
DAEMON_LIMIT_UNAVAILABLE, /* registry/slot unusable (caller fails open) */
|
||||
} DaemonLimitResult;
|
||||
|
||||
/* Bounds for registry sizing. A slot is one concurrently live child. */
|
||||
#define DAEMON_LIMITS_MIN_SLOTS 16
|
||||
#define DAEMON_LIMITS_MAX_SLOTS 65536
|
||||
#define DAEMON_LIMITS_MAX_HOST_SLOTS 65536
|
||||
#define DAEMON_LIMITS_NO_SLOT (-1)
|
||||
/* Upper bound on `module_count`, matching daemon_conf.h's DAEMON_CONF_MAX_MODULES
|
||||
* (asserted in daemon_limits.c) so a caller can never size the per-module counter
|
||||
* array larger than the config parser can produce. */
|
||||
#define DAEMON_LIMITS_MAX_MODULES 256
|
||||
|
||||
/* Per-source table lifetime: a bucket with no active connection and no pending
|
||||
* lockout is reclaimable once it has been idle this long, so a flood of distinct
|
||||
* sources cannot pin the table full forever. A bucket whose lockout deadline
|
||||
* has passed is reclaimable immediately (independent of this idle window). */
|
||||
#define DAEMON_LIMITS_HOST_EVICT_IDLE_SEC 300
|
||||
/* Minimum spacing between "per-source table is full" warnings, so a table-full
|
||||
* attack cannot flood the log. */
|
||||
#define DAEMON_LIMITS_HOST_FULL_WARN_SEC 60
|
||||
|
||||
/* Create the shared registry in the calling (parent) process. `max_slots` is
|
||||
* the number of concurrently live children to track (clamped to
|
||||
* [DAEMON_LIMITS_MIN_SLOTS, DAEMON_LIMITS_MAX_SLOTS]); `module_count` is the
|
||||
* number of daemon modules (clamped to
|
||||
* [1, DAEMON_LIMITS_MAX_MODULES]); `per_host_cap` and the lockout pair come
|
||||
* from the daemon config (0 disables). Returns NULL on failure (e.g. mmap
|
||||
* allocation); callers must degrade gracefully (global cap + ACLs still
|
||||
* apply). */
|
||||
DaemonLimitRegistry* daemon_limits_create(int max_slots, int module_count, int per_host_cap,
|
||||
int lockout_threshold, int lockout_duration_sec);
|
||||
|
||||
/* Unmap the registry. Only the creating process may call this. */
|
||||
void daemon_limits_destroy(DaemonLimitRegistry* registry);
|
||||
|
||||
/* Parent side: reserve a slot for the next fork. Returns the slot index or
|
||||
* DAEMON_LIMITS_NO_SLOT when every slot is in use. */
|
||||
int daemon_limits_claim_slot(DaemonLimitRegistry* registry);
|
||||
/* Parent side: record the forked child's pid in a claimed slot. */
|
||||
void daemon_limits_set_slot_pid(DaemonLimitRegistry* registry, int slot, long pid);
|
||||
/* Parent side: release a slot. The slot becomes FREE; the module/per-source
|
||||
* occupancy arrays are DERIVED state and are only refreshed by
|
||||
* daemon_limits_recompute, which callers must invoke afterwards when they rely
|
||||
* on the derived counts (the SIGCHLD handler batches one recompute for the whole
|
||||
* reap). Idempotent. */
|
||||
void daemon_limits_reclaim_slot(DaemonLimitRegistry* registry, int slot);
|
||||
/* Parent SIGCHLD side: release the slot owned by `pid` (no-op when not found).
|
||||
* Like reclaim_slot this does not touch the derived occupancy arrays; call
|
||||
* daemon_limits_recompute after a batch of releases. */
|
||||
void daemon_limits_reclaim_pid(DaemonLimitRegistry* registry, long pid);
|
||||
|
||||
/* Parent side (async-signal-safe; atomics only, no malloc/log): rebuild
|
||||
* module_active[] / host_active[] from scratch by scanning the REGISTERED slots.
|
||||
* The slot table is the single source of truth, so this self-heals any
|
||||
* count leaked by a child that was SIGKILLed mid-registration (it zeroes the
|
||||
* arrays and re-derives them). Bounded by max_slots + host_slots. A
|
||||
* registration racing this call can be transiently undercounted until the next
|
||||
* recompute, which can only relax a cap briefly -- never corrupt memory. */
|
||||
void daemon_limits_recompute(DaemonLimitRegistry* registry);
|
||||
|
||||
/* Child side: admit the connection for `module_index` from `peer_ip`. Always
|
||||
* tracks the module/per-source occupancy (so the parent's reclaim is
|
||||
* symmetric); when `module_cap` > 0 it additionally enforces the per-module
|
||||
* cap. A NULL/empty or non-numeric `peer_ip` skips the per-source track (the
|
||||
* callers use that to exempt a trusted loopback peer from the per-host cap; the
|
||||
* per-module cap still applies). Returns DAEMON_LIMIT_OK and publishes the
|
||||
* slot, or a refusal reason. */
|
||||
DaemonLimitResult daemon_limits_register(DaemonLimitRegistry* registry, int slot, int module_index,
|
||||
const char* peer_ip, int module_cap);
|
||||
|
||||
/* Child side: true when `peer_ip` is currently locked out after too many failed
|
||||
* authentications. `seconds_remaining` may be NULL. */
|
||||
bool daemon_limits_auth_locked(DaemonLimitRegistry* registry, const char* peer_ip,
|
||||
int* seconds_remaining);
|
||||
/* Child side: count one failed authentication for `peer_ip`; once the threshold
|
||||
* is reached the source is locked out for the configured duration. */
|
||||
void daemon_limits_auth_record_failure(DaemonLimitRegistry* registry, const char* peer_ip);
|
||||
/* Child side: clear the failure counter/lockout for a source that authenticated
|
||||
* successfully (no-op when the source has no table entry). */
|
||||
void daemon_limits_auth_record_success(DaemonLimitRegistry* registry, const char* peer_ip);
|
||||
|
||||
/* Pure helper: 64-bit FNV-1a hash of a numeric peer IP plus its family, used to
|
||||
* index the per-source table. *ok is set false (and 0 returned) for a NULL or
|
||||
* non-numeric address. Exposed for unit testing. */
|
||||
uint64_t daemon_limits_host_hash(const char* peer_ip, bool* ok);
|
||||
|
||||
#endif
|
||||
+9
-23
@@ -1,12 +1,9 @@
|
||||
#include "data.h"
|
||||
#include "log.h"
|
||||
#include "protocol.h"
|
||||
#include <stdlib.h>
|
||||
#include "stdlib.h"
|
||||
|
||||
Data* data_create_empty(size_t data_size) {
|
||||
/* malloc(0) is UB; allocate at least 1 byte but preserve requested size */
|
||||
size_t alloc_size = data_size > 0 ? data_size : 1;
|
||||
void* data = protocol_alloc(alloc_size);
|
||||
Data *data_create_empty(size_t data_size) {
|
||||
void *data = malloc(data_size);
|
||||
if (data == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "Could not allocate memory for empty data");
|
||||
return NULL;
|
||||
@@ -14,21 +11,19 @@ Data* data_create_empty(size_t data_size) {
|
||||
return data_create(data, data_size);
|
||||
}
|
||||
|
||||
Data* data_create_reserve(size_t size) {
|
||||
Data* d = protocol_alloc(sizeof(Data));
|
||||
Data *data_create_reserve(size_t size) {
|
||||
Data *d = malloc(sizeof(Data));
|
||||
if (d == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "Could not allocate memory for data");
|
||||
return NULL;
|
||||
}
|
||||
d->data = NULL;
|
||||
d->size = size;
|
||||
d->protocol_charge = 0;
|
||||
d->owner = NULL;
|
||||
return d;
|
||||
}
|
||||
|
||||
Data* data_create(void* data, size_t data_size) {
|
||||
Data* new_data = protocol_alloc(sizeof(Data));
|
||||
Data *data_create(void *data, size_t data_size) {
|
||||
Data *new_data = malloc(sizeof(Data));
|
||||
if (new_data == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "Could not allocate memory for data");
|
||||
free(data);
|
||||
@@ -36,20 +31,11 @@ Data* data_create(void* data, size_t data_size) {
|
||||
}
|
||||
new_data->data = data;
|
||||
new_data->size = data_size;
|
||||
new_data->protocol_charge = 0;
|
||||
new_data->owner = NULL;
|
||||
return new_data;
|
||||
}
|
||||
|
||||
void data_destroy(Data* data) {
|
||||
if (data == NULL)
|
||||
return;
|
||||
if (data->protocol_charge != 0) {
|
||||
if (data->owner != NULL)
|
||||
protocol_release_memory_for_session(data->owner, data->protocol_charge);
|
||||
else
|
||||
protocol_release_memory(data->protocol_charge);
|
||||
}
|
||||
void data_destroy(Data *data) {
|
||||
if (data == NULL) return;
|
||||
free(data->data);
|
||||
free(data);
|
||||
}
|
||||
|
||||
+6
-27
@@ -1,37 +1,16 @@
|
||||
#ifndef DATA_H
|
||||
#define DATA_H
|
||||
|
||||
#include <stdlib.h>
|
||||
|
||||
/* Forward declaration for the connection budget a received Data is charged
|
||||
* against; defined in protocol.h (which includes this header). */
|
||||
typedef struct ProtocolSession ProtocolSession;
|
||||
#include "stdlib.h"
|
||||
|
||||
typedef struct {
|
||||
void* data;
|
||||
void *data;
|
||||
size_t size;
|
||||
/* Non-zero only for a buffer charged to the protocol connection budget. */
|
||||
size_t protocol_charge;
|
||||
/* Session whose budget `protocol_charge` was reserved from. When non-NULL,
|
||||
* the charge is returned to this session directly, regardless of which
|
||||
* session (if any) is bound to the destroying thread. owner is not
|
||||
* guaranteed to be set whenever protocol_charge is non-zero: it is NULL for
|
||||
* uncharged Data and for Data that has no recorded owner, in which case any
|
||||
* charge falls back to the session bound at destroy time.
|
||||
*
|
||||
* Lifetime contract: a Data with a non-NULL owner must not outlive that
|
||||
* ProtocolSession -- data_destroy dereferences owner to return the charge. */
|
||||
ProtocolSession* owner;
|
||||
} Data;
|
||||
|
||||
Data* data_create_empty(size_t data_size);
|
||||
Data* data_create_reserve(size_t size);
|
||||
Data* data_create(void* data, size_t data_size);
|
||||
void data_destroy(Data* data);
|
||||
void protocol_release_memory(size_t charge);
|
||||
/* Release `charge` against `session` directly instead of the thread-local bound
|
||||
* session. Used by data_destroy to honor Data.owner; `session` must outlive
|
||||
* the Data whose charge is being returned. A NULL session is a no-op. */
|
||||
void protocol_release_memory_for_session(ProtocolSession* session, size_t charge);
|
||||
Data *data_create_empty(size_t data_size);
|
||||
Data *data_create_reserve(size_t size);
|
||||
Data *data_create(void *data, size_t data_size);
|
||||
void data_destroy(Data *data);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
#include "delay_updates.h"
|
||||
|
||||
#include "config.h"
|
||||
#include "file.h"
|
||||
#include "log.h"
|
||||
#include "utils.h"
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <libgen.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
DelayUpdatesContext* delay_updates_context_create(const char* root_directory) {
|
||||
if (!root_directory)
|
||||
return NULL;
|
||||
DelayUpdatesContext* context = calloc(1, sizeof(DelayUpdatesContext));
|
||||
if (!context)
|
||||
return NULL;
|
||||
context->root_directory = str_dup(root_directory);
|
||||
if (!context->root_directory) {
|
||||
free(context);
|
||||
return NULL;
|
||||
}
|
||||
context->staging_root = path_cat(root_directory, DELAY_UPDATES_STAGING_DIR);
|
||||
if (!context->staging_root) {
|
||||
free(context->root_directory);
|
||||
free(context);
|
||||
return NULL;
|
||||
}
|
||||
context->entries = NULL;
|
||||
context->count = 0;
|
||||
context->capacity = 0;
|
||||
context->prepared = false;
|
||||
context->lock_fd = -1;
|
||||
if (mtx_init(&context->mutex, mtx_plain) != thrd_success) {
|
||||
free(context->staging_root);
|
||||
free(context->root_directory);
|
||||
free(context);
|
||||
return NULL;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
void delay_updates_context_destroy(DelayUpdatesContext* context) {
|
||||
if (!context)
|
||||
return;
|
||||
mtx_destroy(&context->mutex);
|
||||
if (context->lock_fd >= 0)
|
||||
close(context->lock_fd);
|
||||
context->lock_fd = -1;
|
||||
free(context->staging_root);
|
||||
free(context->root_directory);
|
||||
for (size_t i = 0; i < context->count; i++) {
|
||||
free(context->entries[i].staged_path);
|
||||
free(context->entries[i].final_path);
|
||||
free(context->entries[i].file_path);
|
||||
}
|
||||
free(context->entries);
|
||||
free(context);
|
||||
}
|
||||
|
||||
bool delay_updates_staging_name_conflict(const char* dir) {
|
||||
if (!dir || !*dir)
|
||||
return false;
|
||||
size_t length = strlen(dir);
|
||||
while (length > 0 && dir[length - 1] == '/')
|
||||
length--;
|
||||
size_t reserved_length = strlen(DELAY_UPDATES_STAGING_DIR);
|
||||
if (length != reserved_length)
|
||||
return false;
|
||||
return strncmp(dir, DELAY_UPDATES_STAGING_DIR, length) == 0;
|
||||
}
|
||||
|
||||
/* Recursively delete every entry inside an open directory (never following
|
||||
symlinks). The directory itself is left in place. Mirrors the fd-relative
|
||||
walk used by the delete code so a symlink planted inside the staging tree
|
||||
can never redirect removal outside of it. */
|
||||
static bool delay_wipe_dir_fd(int dirfd) {
|
||||
int scanfd = dup(dirfd);
|
||||
if (scanfd < 0)
|
||||
return false;
|
||||
DIR* dir = fdopendir(scanfd);
|
||||
if (!dir) {
|
||||
close(scanfd);
|
||||
return false;
|
||||
}
|
||||
bool operation_ok = true;
|
||||
const struct dirent* entry;
|
||||
while ((entry = readdir(dir)) != NULL) {
|
||||
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0)
|
||||
continue;
|
||||
struct stat st;
|
||||
if (fstatat(dirfd, entry->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) {
|
||||
if (errno != ENOENT)
|
||||
operation_ok = false;
|
||||
continue;
|
||||
}
|
||||
if (S_ISDIR(st.st_mode)) {
|
||||
int childfd = openat(dirfd, entry->d_name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
bool child_removed = false;
|
||||
if (childfd >= 0) {
|
||||
child_removed = delay_wipe_dir_fd(childfd);
|
||||
close(childfd);
|
||||
} else if (errno != ENOENT) {
|
||||
operation_ok = false;
|
||||
}
|
||||
if (child_removed && unlinkat(dirfd, entry->d_name, AT_REMOVEDIR) != 0 && errno != ENOENT)
|
||||
operation_ok = false;
|
||||
} else {
|
||||
if (unlinkat(dirfd, entry->d_name, 0) != 0 && errno != ENOENT)
|
||||
operation_ok = false;
|
||||
}
|
||||
}
|
||||
closedir(dir);
|
||||
return operation_ok;
|
||||
}
|
||||
|
||||
bool delay_updates_prepare(DelayUpdatesContext* context) {
|
||||
if (!context)
|
||||
return false;
|
||||
if (context->prepared)
|
||||
return true;
|
||||
int fd = file_open_private_dir(context->staging_root);
|
||||
if (fd < 0) {
|
||||
int saved_errno = errno;
|
||||
char* escaped = output_escape(context->staging_root, false);
|
||||
log_message(LOG_LEVEL_ERROR, "could not create --delay-updates staging directory '%s': %s",
|
||||
escaped ? escaped : "<allocation failed>", strerror(saved_errno));
|
||||
free(escaped);
|
||||
return false;
|
||||
}
|
||||
/* Hold an exclusive advisory lock on the staging directory for the whole
|
||||
transfer. The staging directory name is fixed, so two simultaneous
|
||||
delayed transfers to the same destination root would otherwise share it
|
||||
and destroy each other's staged files. The lock makes the second session
|
||||
fail cleanly instead of corrupting the first. The lock is released when
|
||||
the context (and its file descriptor) is destroyed. */
|
||||
if (flock(fd, LOCK_EX | LOCK_NB) != 0) {
|
||||
int saved_errno = errno;
|
||||
close(fd);
|
||||
if (saved_errno == EWOULDBLOCK || saved_errno == EAGAIN) {
|
||||
char* escaped = output_escape(context->staging_root, false);
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"another --delay-updates transfer to '%s' is already in progress; refusing to "
|
||||
"share the staging directory",
|
||||
escaped ? escaped : "<allocation failed>");
|
||||
free(escaped);
|
||||
} else {
|
||||
log_message(LOG_LEVEL_ERROR, "could not lock --delay-updates staging directory '%s': %s",
|
||||
context->staging_root, strerror(saved_errno));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
context->lock_fd = fd;
|
||||
/* Only now, with exclusive ownership, wipe leftovers from an interrupted
|
||||
earlier transfer; this can never race with a live session. */
|
||||
bool ok = delay_wipe_dir_fd(fd);
|
||||
if (!ok) {
|
||||
log_message(LOG_LEVEL_ERROR, "could not clear stale --delay-updates staging files under '%s'",
|
||||
context->staging_root);
|
||||
close(context->lock_fd);
|
||||
context->lock_fd = -1;
|
||||
return false;
|
||||
}
|
||||
context->prepared = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool delay_updates_record(DelayUpdatesContext* context, const char* staged_path,
|
||||
const char* final_path, const char* file_path) {
|
||||
if (!context || !staged_path || !final_path || !file_path)
|
||||
return false;
|
||||
char* staged_copy = str_dup(staged_path);
|
||||
char* final_copy = str_dup(final_path);
|
||||
char* file_copy = str_dup(file_path);
|
||||
if (!staged_copy || !final_copy || !file_copy) {
|
||||
free(staged_copy);
|
||||
free(final_copy);
|
||||
free(file_copy);
|
||||
return false;
|
||||
}
|
||||
mtx_lock(&context->mutex);
|
||||
bool ok = true;
|
||||
if (context->count == context->capacity) {
|
||||
size_t new_capacity = context->capacity == 0 ? 64 : context->capacity * 2;
|
||||
if (new_capacity < context->capacity) {
|
||||
ok = false;
|
||||
} else {
|
||||
StagedFileEntry* grown = realloc(context->entries, new_capacity * sizeof(StagedFileEntry));
|
||||
if (!grown) {
|
||||
ok = false;
|
||||
} else {
|
||||
context->entries = grown;
|
||||
context->capacity = new_capacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
context->entries[context->count].staged_path = staged_copy;
|
||||
context->entries[context->count].final_path = final_copy;
|
||||
context->entries[context->count].file_path = file_copy;
|
||||
context->count++;
|
||||
}
|
||||
mtx_unlock(&context->mutex);
|
||||
if (!ok) {
|
||||
free(staged_copy);
|
||||
free(final_copy);
|
||||
free(file_copy);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* Move an existing final destination file aside before the staged replacement
|
||||
is installed. Deferred from stage time so the final destination is not
|
||||
modified until publication. Mirrors the immediate-mode backup logic. */
|
||||
static bool delay_publish_backup(const DelayUpdatesContext* context, const Config* config,
|
||||
const StagedFileEntry* entry) {
|
||||
bool backup_enabled = config && config->backup && !config->ignore_existing;
|
||||
if (!backup_enabled)
|
||||
return true;
|
||||
const char* backup_suffix = (config && config->suffix) ? config->suffix : "~";
|
||||
struct stat backup_stat;
|
||||
if (!file_stat_secure(entry->final_path, &backup_stat))
|
||||
return true; /* nothing to back up */
|
||||
|
||||
char* backup_path = NULL;
|
||||
if (config->backup_dir) {
|
||||
char* confined_backup = path_cat(context->root_directory, config->backup_dir);
|
||||
if (!confined_backup)
|
||||
return false;
|
||||
backup_path = path_cat(confined_backup, entry->file_path);
|
||||
free(confined_backup);
|
||||
} else {
|
||||
size_t path_len = strlen(entry->final_path);
|
||||
size_t suffix_len = strlen(backup_suffix);
|
||||
if (path_len > SIZE_MAX - suffix_len - 1)
|
||||
return false;
|
||||
backup_path = malloc(path_len + suffix_len + 1);
|
||||
if (backup_path) {
|
||||
memcpy(backup_path, entry->final_path, path_len);
|
||||
memcpy(backup_path + path_len, backup_suffix, suffix_len + 1);
|
||||
}
|
||||
}
|
||||
if (!backup_path)
|
||||
return false;
|
||||
char* parent_copy = str_dup(backup_path);
|
||||
if (!parent_copy || !file_ensure_directory_secure(dirname(parent_copy))) {
|
||||
free(parent_copy);
|
||||
free(backup_path);
|
||||
return false;
|
||||
}
|
||||
free(parent_copy);
|
||||
bool ok = file_rename_secure(entry->final_path, backup_path);
|
||||
free(backup_path);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static bool delay_publish_entry(DelayUpdatesContext* context, const Config* config,
|
||||
const StagedFileEntry* entry) {
|
||||
if (!delay_publish_backup(context, config, entry))
|
||||
return false;
|
||||
if (!file_rename_secure(entry->staged_path, entry->final_path)) {
|
||||
if (errno == EXDEV) {
|
||||
char* escaped = output_escape(entry->final_path, false);
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"staging directory is on a different filesystem than the destination; cannot "
|
||||
"atomically install file (EXDEV): %s",
|
||||
escaped ? escaped : "<allocation failed>");
|
||||
free(escaped);
|
||||
} else {
|
||||
char* escaped = output_escape(entry->final_path, false);
|
||||
log_message(LOG_LEVEL_ERROR, "could not install staged file '%s': %s",
|
||||
escaped ? escaped : "<allocation failed>", strerror(errno));
|
||||
free(escaped);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Remove the staging tree (contents plus the directory itself). Returns true
|
||||
when nothing is left behind (including the case where it never existed). */
|
||||
static bool delay_updates_remove_staging_tree(DelayUpdatesContext* context) {
|
||||
int fd = open(context->staging_root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (fd < 0)
|
||||
return errno == ENOENT;
|
||||
bool ok = delay_wipe_dir_fd(fd);
|
||||
if (close(fd) != 0)
|
||||
ok = false;
|
||||
if (ok && rmdir(context->staging_root) != 0 && errno != ENOENT)
|
||||
ok = false;
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool delay_updates_publish(DelayUpdatesContext* context, const Config* config) {
|
||||
if (!context)
|
||||
return false;
|
||||
mtx_lock(&context->mutex);
|
||||
bool ok = true;
|
||||
for (size_t i = 0; i < context->count; i++) {
|
||||
if (!delay_publish_entry(context, config, &context->entries[i])) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
mtx_unlock(&context->mutex);
|
||||
|
||||
/* Renaming files out of the staging tree leaves the mirrored directories
|
||||
behind, and a mid-publish failure leaves the remaining staged files.
|
||||
Remove whatever is left so a later run starts from a clean staging area
|
||||
and no staged content can linger after a failed publish. If that cleanup
|
||||
fails, tell the operator: a stale staging directory would otherwise
|
||||
silently accumulate and make the next transfer's prepare-wipe fail. */
|
||||
if (!delay_updates_remove_staging_tree(context)) {
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"could not fully remove --delay-updates staging directory '%s' after publish; a "
|
||||
"later --delay-updates transfer to this destination will try to clear it",
|
||||
context->staging_root);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
void delay_updates_cleanup(DelayUpdatesContext* context) {
|
||||
if (!context)
|
||||
return;
|
||||
/* Only a context that gained exclusive ownership may touch the shared
|
||||
staging directory. If prepare never succeeded (e.g. lock contention with
|
||||
another live session) the directory belongs to that other session and must
|
||||
be left alone. */
|
||||
if (!context->prepared)
|
||||
return;
|
||||
delay_updates_remove_staging_tree(context);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
#ifndef DELAY_UPDATES_H
|
||||
#define DELAY_UPDATES_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <threads.h>
|
||||
|
||||
/* Forward-declared in config.h; full type needed by file_save_to_disk. */
|
||||
typedef struct Config Config;
|
||||
|
||||
/* One staged file awaiting publication. */
|
||||
typedef struct {
|
||||
char* staged_path; /* full path inside the staging tree */
|
||||
char* final_path; /* full final destination path */
|
||||
char* file_path; /* the file path as received on the wire */
|
||||
} StagedFileEntry;
|
||||
|
||||
/* Receiver-side --delay-updates staging registry. All successfully written
|
||||
files land under a private staging directory inside the receive root and are
|
||||
atomically renamed into their final destination only at the very end of the
|
||||
transfer. A single receiver pipeline (see src/server/receiver_pipeline.h)
|
||||
has exactly one writer thread, but the registry is still mutex-protected so
|
||||
the same object can be safely
|
||||
shared with the publish/cleanup phase that runs after the threads join. */
|
||||
typedef struct DelayUpdatesContext {
|
||||
char* root_directory; /* receive root the staging dir lives under */
|
||||
char* staging_root; /* root_directory/<staging dir name> */
|
||||
mtx_t mutex;
|
||||
StagedFileEntry* entries;
|
||||
size_t count;
|
||||
size_t capacity;
|
||||
bool prepared; /* staging dir created, wiped, and exclusively locked */
|
||||
int lock_fd; /* advisory exclusive flock held on the staging dir, or -1 */
|
||||
} DelayUpdatesContext;
|
||||
|
||||
/* Name of the private staging subdirectory created under the receive root. */
|
||||
#define DELAY_UPDATES_STAGING_DIR ".fastsync-stage"
|
||||
|
||||
/* True when `dir` (ignoring a trailing "/") is the reserved staging directory
|
||||
name. Used to reject a --backup-dir that would collide with the internal
|
||||
staging area. */
|
||||
bool delay_updates_staging_name_conflict(const char* dir);
|
||||
|
||||
/* Create an empty staging context rooted below root_directory. Does not touch
|
||||
the filesystem yet. */
|
||||
DelayUpdatesContext* delay_updates_context_create(const char* root_directory);
|
||||
void delay_updates_context_destroy(DelayUpdatesContext* context);
|
||||
|
||||
/* Create the private 0700 staging directory (on first call) and wipe any
|
||||
leftovers from a previously interrupted delayed transfer. Idempotent. */
|
||||
bool delay_updates_prepare(DelayUpdatesContext* context);
|
||||
|
||||
/* Record a fully-written staged file for later publication. Copies all three
|
||||
paths. Returns false on allocation failure. */
|
||||
bool delay_updates_record(DelayUpdatesContext* context, const char* staged_path,
|
||||
const char* final_path, const char* file_path);
|
||||
|
||||
/* Atomically rename every staged file into its final destination. Deferred
|
||||
--backup handling runs immediately before each rename. On any failure the
|
||||
remaining staged files are removed (best effort); already-published files
|
||||
are not rolled back. Afterwards the staging tree is removed so a successful
|
||||
or failed publish leaves no staging leftovers. */
|
||||
bool delay_updates_publish(DelayUpdatesContext* context, const Config* config);
|
||||
|
||||
/* Best-effort removal of every staged file and the staging directory itself.
|
||||
Safe to call when nothing was staged or after a successful publish. */
|
||||
void delay_updates_cleanup(DelayUpdatesContext* context);
|
||||
|
||||
#endif
|
||||
+135
-377
@@ -1,8 +1,5 @@
|
||||
#include "delta.h"
|
||||
#include "log.h"
|
||||
#include "protocol.h"
|
||||
#include <stdint.h>
|
||||
#include <limits.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
@@ -10,12 +7,8 @@
|
||||
#define XXH_IMPLEMENTATION
|
||||
#include <xxhash.h>
|
||||
|
||||
/* Maximum number of blocks/instructions allowed from the wire to prevent OOM */
|
||||
#define MAX_DELTA_BLOCKS (1024U * 1024U) /* 1M signature blocks */
|
||||
#define MAX_DELTA_INSTRUCTIONS (1024U * 1024U) /* 1M delta instructions */
|
||||
|
||||
uint32_t delta_adler32(const void* data, uint32_t len) {
|
||||
const uint8_t* p = (const uint8_t*)data;
|
||||
uint32_t delta_adler32(const void *data, uint32_t len) {
|
||||
const uint8_t *p = (const uint8_t *)data;
|
||||
uint32_t s1 = 1;
|
||||
uint32_t s2 = 0;
|
||||
for (uint32_t i = 0; i < len; i++) {
|
||||
@@ -25,76 +18,51 @@ uint32_t delta_adler32(const void* data, uint32_t len) {
|
||||
return (s2 << 16) | s1;
|
||||
}
|
||||
|
||||
uint32_t delta_xxhash32(const void* data, uint32_t len) {
|
||||
uint32_t delta_xxhash32(const void *data, uint32_t len) {
|
||||
return XXH32(data, len, 0);
|
||||
}
|
||||
|
||||
uint32_t delta_xxhash32_seeded(const void* data, uint32_t len, uint32_t seed) {
|
||||
return XXH32(data, len, seed);
|
||||
}
|
||||
|
||||
uint64_t delta_xxhash64(const void* data, size_t len) {
|
||||
return XXH64(data, len, 0);
|
||||
}
|
||||
|
||||
DeltaSignature* delta_signature_create(const void* old_file_data, uint64_t old_file_size,
|
||||
uint32_t block_size) {
|
||||
return delta_signature_create_seeded(old_file_data, old_file_size, block_size, 0);
|
||||
}
|
||||
|
||||
DeltaSignature* delta_signature_create_seeded(const void* old_file_data, uint64_t old_file_size,
|
||||
uint32_t block_size, uint32_t seed) {
|
||||
DeltaSignature *delta_signature_create(const void *old_file_data,
|
||||
uint64_t old_file_size,
|
||||
uint32_t block_size) {
|
||||
if (old_file_data == NULL || old_file_size == 0 || block_size == 0)
|
||||
return NULL;
|
||||
|
||||
if (old_file_size > DELTA_MAX_FILE_SIZE || block_size > DELTA_BLOCK_SIZE_MAX ||
|
||||
old_file_size > UINT32_MAX * (uint64_t)block_size)
|
||||
return NULL;
|
||||
|
||||
uint32_t block_count = (uint32_t)((old_file_size + block_size - 1) / block_size);
|
||||
|
||||
DeltaSignature* sig = protocol_alloc(sizeof(DeltaSignature));
|
||||
if (!sig)
|
||||
return NULL;
|
||||
DeltaSignature *sig = malloc(sizeof(DeltaSignature));
|
||||
if (!sig) return NULL;
|
||||
|
||||
sig->file_size = old_file_size;
|
||||
sig->block_size = block_size;
|
||||
sig->block_count = block_count;
|
||||
if (block_count == 0) {
|
||||
free(sig);
|
||||
return NULL;
|
||||
}
|
||||
sig->blocks = protocol_alloc((size_t)block_count * sizeof(DeltaBlockSig));
|
||||
sig->blocks = malloc(block_count * sizeof(DeltaBlockSig));
|
||||
if (!sig->blocks) {
|
||||
free(sig);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const uint8_t* data = (const uint8_t*)old_file_data;
|
||||
const uint8_t *data = (const uint8_t *)old_file_data;
|
||||
for (uint32_t i = 0; i < block_count; i++) {
|
||||
uint64_t offset = (uint64_t)i * block_size;
|
||||
uint32_t len =
|
||||
(uint32_t)((old_file_size - offset < block_size) ? (old_file_size - offset) : block_size);
|
||||
uint32_t len = (uint32_t)((old_file_size - offset < block_size)
|
||||
? (old_file_size - offset)
|
||||
: block_size);
|
||||
sig->blocks[i].adler32 = delta_adler32(data + offset, len);
|
||||
sig->blocks[i].xxhash = delta_xxhash32_seeded(data + offset, len, seed);
|
||||
sig->blocks[i].xxhash = delta_xxhash32(data + offset, len);
|
||||
}
|
||||
|
||||
return sig;
|
||||
}
|
||||
|
||||
Data* delta_signature_serialize(const DeltaSignature* sig) {
|
||||
if (!sig)
|
||||
return NULL;
|
||||
Data *delta_signature_serialize(const DeltaSignature *sig) {
|
||||
if (!sig) return NULL;
|
||||
|
||||
uint64_t block_bytes = (uint64_t)sig->block_count * (sizeof(uint32_t) + sizeof(uint32_t));
|
||||
uint64_t total = sizeof(uint64_t) + sizeof(uint32_t) + sizeof(uint32_t) + block_bytes;
|
||||
if (block_bytes > UINT64_MAX - (sizeof(uint64_t) + sizeof(uint32_t) + sizeof(uint32_t)) ||
|
||||
total > SIZE_MAX)
|
||||
return NULL;
|
||||
uint64_t total = sizeof(uint64_t) + sizeof(uint32_t) + sizeof(uint32_t) +
|
||||
(uint64_t)sig->block_count * (sizeof(uint32_t) + sizeof(uint32_t));
|
||||
|
||||
uint8_t* buf = protocol_alloc((size_t)total);
|
||||
if (!buf)
|
||||
return NULL;
|
||||
uint8_t *buf = malloc((size_t)total);
|
||||
if (!buf) return NULL;
|
||||
|
||||
size_t pos = 0;
|
||||
memcpy(buf + pos, &sig->file_size, sizeof(uint64_t));
|
||||
@@ -114,16 +82,15 @@ Data* delta_signature_serialize(const DeltaSignature* sig) {
|
||||
return data_create(buf, (size_t)total);
|
||||
}
|
||||
|
||||
DeltaSignature* delta_signature_deserialize(const Data* data) {
|
||||
DeltaSignature *delta_signature_deserialize(const Data *data) {
|
||||
if (!data || data->size < sizeof(uint64_t) + sizeof(uint32_t) + sizeof(uint32_t))
|
||||
return NULL;
|
||||
|
||||
const uint8_t* buf = (const uint8_t*)data->data;
|
||||
const uint8_t *buf = (const uint8_t *)data->data;
|
||||
size_t pos = 0;
|
||||
|
||||
DeltaSignature* sig = protocol_alloc(sizeof(DeltaSignature));
|
||||
if (!sig)
|
||||
return NULL;
|
||||
DeltaSignature *sig = malloc(sizeof(DeltaSignature));
|
||||
if (!sig) return NULL;
|
||||
|
||||
memcpy(&sig->file_size, buf + pos, sizeof(uint64_t));
|
||||
pos += sizeof(uint64_t);
|
||||
@@ -132,21 +99,6 @@ DeltaSignature* delta_signature_deserialize(const Data* data) {
|
||||
memcpy(&sig->block_count, buf + pos, sizeof(uint32_t));
|
||||
pos += sizeof(uint32_t);
|
||||
|
||||
// Reject unreasonably large block counts to prevent OOM
|
||||
if (sig->block_count > MAX_DELTA_BLOCKS) {
|
||||
log_message(LOG_LEVEL_ERROR, "Delta signature block count %u exceeds maximum %u",
|
||||
sig->block_count, MAX_DELTA_BLOCKS);
|
||||
free(sig);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (sig->block_size == 0 || sig->block_size > DELTA_BLOCK_SIZE_MAX ||
|
||||
sig->file_size > DELTA_MAX_FILE_SIZE || sig->file_size == 0 ||
|
||||
(sig->file_size + sig->block_size - 1) / sig->block_size != sig->block_count) {
|
||||
free(sig);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
uint64_t expected = sizeof(uint64_t) + sizeof(uint32_t) + sizeof(uint32_t) +
|
||||
(uint64_t)sig->block_count * (sizeof(uint32_t) + sizeof(uint32_t));
|
||||
if (data->size < expected) {
|
||||
@@ -154,12 +106,7 @@ DeltaSignature* delta_signature_deserialize(const Data* data) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
uint64_t blocks_size = (uint64_t)sig->block_count * sizeof(DeltaBlockSig);
|
||||
if (blocks_size > SIZE_MAX) {
|
||||
free(sig);
|
||||
return NULL;
|
||||
}
|
||||
sig->blocks = protocol_alloc((size_t)blocks_size);
|
||||
sig->blocks = malloc(sig->block_count * sizeof(DeltaBlockSig));
|
||||
if (!sig->blocks) {
|
||||
free(sig);
|
||||
return NULL;
|
||||
@@ -175,39 +122,31 @@ DeltaSignature* delta_signature_deserialize(const Data* data) {
|
||||
return sig;
|
||||
}
|
||||
|
||||
void delta_signature_destroy(DeltaSignature* sig) {
|
||||
if (!sig)
|
||||
return;
|
||||
void delta_signature_destroy(DeltaSignature *sig) {
|
||||
if (!sig) return;
|
||||
free(sig->blocks);
|
||||
free(sig);
|
||||
}
|
||||
|
||||
static bool ensure_capacity(DeltaInstruction** instrs, uint32_t* capacity, uint32_t count) {
|
||||
if (count < *capacity)
|
||||
return true;
|
||||
if (*capacity > MAX_DELTA_INSTRUCTIONS / 2)
|
||||
return false;
|
||||
static bool ensure_capacity(DeltaInstruction **instrs, uint32_t *capacity,
|
||||
uint32_t count) {
|
||||
if (count < *capacity) return true;
|
||||
uint32_t new_cap = *capacity * 2;
|
||||
DeltaInstruction* tmp = protocol_realloc(*instrs, (size_t)new_cap * sizeof(DeltaInstruction));
|
||||
if (!tmp)
|
||||
return false;
|
||||
DeltaInstruction *tmp = realloc(*instrs, new_cap * sizeof(DeltaInstruction));
|
||||
if (!tmp) return false;
|
||||
*instrs = tmp;
|
||||
*capacity = new_cap;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool flush_literal(DeltaInstruction** instrs, uint32_t* capacity, uint32_t* count,
|
||||
const uint8_t* data, uint64_t start, uint64_t end) {
|
||||
if (start >= end)
|
||||
return true;
|
||||
if (end - start > UINT32_MAX || *count >= MAX_DELTA_INSTRUCTIONS)
|
||||
return false;
|
||||
static bool flush_literal(DeltaInstruction **instrs, uint32_t *capacity,
|
||||
uint32_t *count, const uint8_t *data,
|
||||
uint64_t start, uint64_t end) {
|
||||
if (start >= end) return true;
|
||||
uint32_t lit_len = (uint32_t)(end - start);
|
||||
if (!ensure_capacity(instrs, capacity, *count))
|
||||
return false;
|
||||
uint8_t* lit_data = protocol_alloc(lit_len);
|
||||
if (!lit_data)
|
||||
return false;
|
||||
if (!ensure_capacity(instrs, capacity, *count)) return false;
|
||||
uint8_t *lit_data = malloc(lit_len);
|
||||
if (!lit_data) return false;
|
||||
memcpy(lit_data, data + start, lit_len);
|
||||
(*instrs)[*count].type = DELTA_INSTR_LITERAL;
|
||||
(*instrs)[*count].literal.data = lit_data;
|
||||
@@ -216,165 +155,17 @@ static bool flush_literal(DeltaInstruction** instrs, uint32_t* capacity, uint32_
|
||||
return true;
|
||||
}
|
||||
|
||||
static void free_instructions(DeltaInstruction* instrs, uint32_t count) {
|
||||
if (!instrs)
|
||||
return;
|
||||
for (uint32_t i = 0; i < count; i++)
|
||||
if (instrs[i].type == DELTA_INSTR_LITERAL)
|
||||
free(instrs[i].literal.data);
|
||||
free(instrs);
|
||||
}
|
||||
|
||||
/* Sentinel meaning "no signature block" in the lookup index chains. Block
|
||||
* counts are bounded well below UINT32_MAX, so it doubles as a null link. */
|
||||
#define DELTA_NO_BLOCK UINT32_MAX
|
||||
|
||||
/* Avalanche mix for the rolling checksum so blocks do not cluster in the
|
||||
* bucket table when the weak checksum has little entropy (e.g. all-zero or
|
||||
* patterned files). */
|
||||
static uint32_t delta_adler_mix(uint32_t h) {
|
||||
h ^= h >> 16;
|
||||
h *= 0x7feb352dU;
|
||||
h ^= h >> 15;
|
||||
h *= 0x846ca68bU;
|
||||
h ^= h >> 16;
|
||||
return h;
|
||||
}
|
||||
|
||||
/* Smallest power of two >= v. v must be non-zero. */
|
||||
static uint32_t delta_next_pow2(uint32_t v) {
|
||||
v--;
|
||||
v |= v >> 1;
|
||||
v |= v >> 2;
|
||||
v |= v >> 4;
|
||||
v |= v >> 8;
|
||||
v |= v >> 16;
|
||||
return v + 1;
|
||||
}
|
||||
|
||||
/* Build a hash index over sig->blocks keyed by the (mixed) rolling checksum.
|
||||
* All blocks sharing an Adler-32 value land in the same bucket; collisions
|
||||
* are chained through a single contiguous allocation:
|
||||
*
|
||||
* [0, bucket_count) heads (first block per bucket)
|
||||
* [bucket_count, 2*bucket_count) tails (last block per bucket)
|
||||
* [2*bucket_count, ...) per-block chain links
|
||||
*
|
||||
* Blocks are inserted in ascending index order so every bucket chain is
|
||||
* ordered exactly like the historical linear scan. Returns the base pointer
|
||||
* (also the heads array) or NULL when no index could be allocated; callers
|
||||
* then fall back to the linear scan. */
|
||||
static uint32_t* delta_build_index(const DeltaSignature* sig, uint32_t bucket_count) {
|
||||
if (sig->block_count == 0 || bucket_count == 0)
|
||||
Delta *delta_compute(const void *new_file_data, uint64_t new_file_size,
|
||||
const DeltaSignature *sig, uint32_t block_size) {
|
||||
if (!new_file_data || !sig || new_file_size == 0 || block_size == 0)
|
||||
return NULL;
|
||||
|
||||
size_t entries = (size_t)2 * bucket_count + sig->block_count;
|
||||
if (entries > SIZE_MAX / sizeof(uint32_t))
|
||||
return NULL;
|
||||
|
||||
uint32_t* index = protocol_alloc(entries * sizeof(uint32_t));
|
||||
if (!index)
|
||||
return NULL;
|
||||
|
||||
uint32_t* heads = index;
|
||||
uint32_t* tails = index + bucket_count;
|
||||
uint32_t* next = index + 2 * bucket_count;
|
||||
uint32_t mask = bucket_count - 1;
|
||||
|
||||
memset(heads, 0xFF, (size_t)bucket_count * sizeof(uint32_t));
|
||||
memset(tails, 0xFF, (size_t)bucket_count * sizeof(uint32_t));
|
||||
|
||||
for (uint32_t j = 0; j < sig->block_count; j++) {
|
||||
uint32_t b = delta_adler_mix(sig->blocks[j].adler32) & mask;
|
||||
if (heads[b] == DELTA_NO_BLOCK)
|
||||
heads[b] = j;
|
||||
else
|
||||
next[tails[b]] = j;
|
||||
tails[b] = j;
|
||||
next[j] = DELTA_NO_BLOCK;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/* Locate the signature block matching the byte window at new_data[i].
|
||||
*
|
||||
* Mirrors the original per-window behaviour exactly: only a full block_size
|
||||
* window can match, candidates are accepted only when the weak (Adler-32) and
|
||||
* strong (xxHash32) checksums both agree, and the lowest block index wins so
|
||||
* the emitted op stream is byte-identical to the linear scan. When heads is
|
||||
* non-NULL the candidate set is reached through the bucket index (expected
|
||||
* O(1) per window); otherwise an exact linear scan is used. */
|
||||
static uint32_t delta_find_match(const uint8_t* window, uint32_t window_len, uint32_t adler,
|
||||
bool full_window, const DeltaSignature* sig, const uint32_t* heads,
|
||||
const uint32_t* next, uint32_t mask, uint32_t seed) {
|
||||
if (!full_window || sig->block_count == 0)
|
||||
return DELTA_NO_BLOCK;
|
||||
|
||||
if (heads) {
|
||||
uint32_t b = delta_adler_mix(adler) & mask;
|
||||
uint32_t window_xxh = 0;
|
||||
bool have_xxh = false;
|
||||
for (uint32_t j = heads[b]; j != DELTA_NO_BLOCK; j = next[j]) {
|
||||
if (sig->blocks[j].adler32 != adler)
|
||||
continue;
|
||||
if (!have_xxh) {
|
||||
window_xxh = delta_xxhash32_seeded(window, window_len, seed);
|
||||
have_xxh = true;
|
||||
}
|
||||
if (window_xxh == sig->blocks[j].xxhash)
|
||||
return j;
|
||||
}
|
||||
return DELTA_NO_BLOCK;
|
||||
}
|
||||
|
||||
/* Fallback used when the index could not be allocated. */
|
||||
for (uint32_t j = 0; j < sig->block_count; j++) {
|
||||
if (sig->blocks[j].adler32 == adler) {
|
||||
uint32_t window_xxh = delta_xxhash32_seeded(window, window_len, seed);
|
||||
if (window_xxh == sig->blocks[j].xxhash)
|
||||
return j;
|
||||
}
|
||||
}
|
||||
return DELTA_NO_BLOCK;
|
||||
}
|
||||
|
||||
Delta* delta_compute(const void* new_file_data, uint64_t new_file_size, const DeltaSignature* sig,
|
||||
uint32_t block_size) {
|
||||
return delta_compute_seeded(new_file_data, new_file_size, sig, block_size, 0);
|
||||
}
|
||||
|
||||
Delta* delta_compute_seeded(const void* new_file_data, uint64_t new_file_size,
|
||||
const DeltaSignature* sig, uint32_t block_size, uint32_t seed) {
|
||||
if (!new_file_data || !sig || !sig->blocks || new_file_size == 0 || block_size == 0 ||
|
||||
block_size > DELTA_BLOCK_SIZE_MAX || sig->block_size != block_size)
|
||||
return NULL;
|
||||
|
||||
const uint8_t* new_data = (const uint8_t*)new_file_data;
|
||||
const uint8_t *new_data = (const uint8_t *)new_file_data;
|
||||
|
||||
uint32_t capacity = 64;
|
||||
uint32_t count = 0;
|
||||
DeltaInstruction* instrs = protocol_alloc((size_t)capacity * sizeof(DeltaInstruction));
|
||||
if (!instrs)
|
||||
return NULL;
|
||||
|
||||
/* Build a one-time bucket index over the signature blocks keyed by the weak
|
||||
* checksum. This turns the per-byte-window candidate lookup from an
|
||||
* O(block_count) linear scan into an expected O(1) probe, which dominates
|
||||
* the cost for large mostly-matching files (the diff steps one byte at a
|
||||
* time through changed regions). On allocation failure the probe falls back
|
||||
* to the original linear scan, so behaviour is unchanged under memory
|
||||
* pressure. */
|
||||
uint32_t* index = NULL;
|
||||
const uint32_t* chain_next = NULL;
|
||||
uint32_t mask = 0;
|
||||
if (sig->block_count > 0) {
|
||||
uint32_t bucket_count = delta_next_pow2(sig->block_count);
|
||||
index = delta_build_index(sig, bucket_count);
|
||||
if (index) {
|
||||
chain_next = index + 2 * bucket_count;
|
||||
mask = bucket_count - 1;
|
||||
}
|
||||
}
|
||||
DeltaInstruction *instrs = malloc(capacity * sizeof(DeltaInstruction));
|
||||
if (!instrs) return NULL;
|
||||
|
||||
uint64_t literal_start = 0;
|
||||
bool has_literal = false;
|
||||
@@ -385,17 +176,20 @@ Delta* delta_compute_seeded(const void* new_file_data, uint64_t new_file_size,
|
||||
bool rolling_valid = false;
|
||||
|
||||
while (i < new_file_size) {
|
||||
uint32_t window_len =
|
||||
(uint32_t)((new_file_size - i < block_size) ? (new_file_size - i) : block_size);
|
||||
uint32_t window_len = (uint32_t)((new_file_size - i < block_size)
|
||||
? (new_file_size - i)
|
||||
: block_size);
|
||||
bool full_window = (window_len == block_size);
|
||||
|
||||
uint32_t adler;
|
||||
if (rolling_valid && full_window) {
|
||||
uint8_t old_byte = new_data[i - 1];
|
||||
uint8_t new_byte = new_data[i + block_size - 1];
|
||||
s1 = (s1 + DELTA_ADLER32_MODULUS - old_byte + new_byte) % DELTA_ADLER32_MODULUS;
|
||||
s1 = (s1 + DELTA_ADLER32_MODULUS - old_byte + new_byte) %
|
||||
DELTA_ADLER32_MODULUS;
|
||||
s2 = (s2 + DELTA_ADLER32_MODULUS -
|
||||
(uint32_t)((uint64_t)block_size * old_byte % DELTA_ADLER32_MODULUS) + s1 - 1) %
|
||||
(uint32_t)((uint64_t)block_size * old_byte % DELTA_ADLER32_MODULUS) +
|
||||
s1 - 1) %
|
||||
DELTA_ADLER32_MODULUS;
|
||||
adler = (s2 << 16) | s1;
|
||||
} else {
|
||||
@@ -410,32 +204,35 @@ Delta* delta_compute_seeded(const void* new_file_data, uint64_t new_file_size,
|
||||
}
|
||||
|
||||
bool matched = false;
|
||||
uint32_t match_block = delta_find_match(new_data + i, window_len, adler, full_window, sig,
|
||||
index, chain_next, mask, seed);
|
||||
if (match_block != DELTA_NO_BLOCK) {
|
||||
if (has_literal) {
|
||||
if (!flush_literal(&instrs, &capacity, &count, new_data, literal_start, i)) {
|
||||
free_instructions(instrs, count);
|
||||
free(index);
|
||||
return NULL;
|
||||
for (uint32_t j = 0; j < sig->block_count; j++) {
|
||||
if (adler == sig->blocks[j].adler32 && full_window) {
|
||||
uint32_t xxh = delta_xxhash32(new_data + i, window_len);
|
||||
if (xxh == sig->blocks[j].xxhash) {
|
||||
if (has_literal) {
|
||||
if (!flush_literal(&instrs, &capacity, &count, new_data,
|
||||
literal_start, i)) {
|
||||
free(instrs);
|
||||
return NULL;
|
||||
}
|
||||
has_literal = false;
|
||||
}
|
||||
|
||||
if (!ensure_capacity(&instrs, &capacity, count)) {
|
||||
free(instrs);
|
||||
return NULL;
|
||||
}
|
||||
instrs[count].type = DELTA_INSTR_BLOCK_MATCH;
|
||||
instrs[count].match.block_index = j;
|
||||
instrs[count].match.block_offset = 0;
|
||||
instrs[count].match.length = window_len;
|
||||
count++;
|
||||
|
||||
i += window_len;
|
||||
rolling_valid = false;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
has_literal = false;
|
||||
}
|
||||
|
||||
if (!ensure_capacity(&instrs, &capacity, count)) {
|
||||
free_instructions(instrs, count);
|
||||
free(index);
|
||||
return NULL;
|
||||
}
|
||||
instrs[count].type = DELTA_INSTR_BLOCK_MATCH;
|
||||
instrs[count].match.block_index = match_block;
|
||||
instrs[count].match.block_offset = 0;
|
||||
instrs[count].match.length = window_len;
|
||||
count++;
|
||||
|
||||
i += window_len;
|
||||
rolling_valid = false;
|
||||
matched = true;
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
@@ -447,18 +244,21 @@ Delta* delta_compute_seeded(const void* new_file_data, uint64_t new_file_size,
|
||||
}
|
||||
}
|
||||
|
||||
free(index);
|
||||
|
||||
if (has_literal) {
|
||||
if (!flush_literal(&instrs, &capacity, &count, new_data, literal_start, new_file_size)) {
|
||||
free_instructions(instrs, count);
|
||||
if (!flush_literal(&instrs, &capacity, &count, new_data,
|
||||
literal_start, new_file_size)) {
|
||||
free(instrs);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
Delta* delta = protocol_alloc(sizeof(Delta));
|
||||
Delta *delta = malloc(sizeof(Delta));
|
||||
if (!delta) {
|
||||
free_instructions(instrs, count);
|
||||
for (uint32_t k = 0; k < count; k++) {
|
||||
if (instrs[k].type == DELTA_INSTR_LITERAL)
|
||||
free(instrs[k].literal.data);
|
||||
}
|
||||
free(instrs);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -468,43 +268,23 @@ Delta* delta_compute_seeded(const void* new_file_data, uint64_t new_file_size,
|
||||
delta->delta_size = 0;
|
||||
|
||||
for (uint32_t k = 0; k < count; k++) {
|
||||
if (delta->delta_size == UINT64_MAX) {
|
||||
delta_destroy(delta);
|
||||
return NULL;
|
||||
}
|
||||
delta->delta_size += 1;
|
||||
if (instrs[k].type == DELTA_INSTR_BLOCK_MATCH) {
|
||||
if (delta->delta_size > UINT64_MAX - sizeof(uint32_t) * 3) {
|
||||
delta_destroy(delta);
|
||||
return NULL;
|
||||
}
|
||||
delta->delta_size += sizeof(uint32_t) * 3;
|
||||
} else {
|
||||
uint64_t extra = sizeof(uint32_t) + instrs[k].literal.length;
|
||||
if (delta->delta_size > UINT64_MAX - extra) {
|
||||
delta_destroy(delta);
|
||||
return NULL;
|
||||
}
|
||||
delta->delta_size += extra;
|
||||
delta->delta_size += sizeof(uint32_t) + instrs[k].literal.length;
|
||||
}
|
||||
}
|
||||
|
||||
return delta;
|
||||
}
|
||||
|
||||
Data* delta_serialize(const Delta* delta) {
|
||||
if (!delta)
|
||||
return NULL;
|
||||
Data *delta_serialize(const Delta *delta) {
|
||||
if (!delta) return NULL;
|
||||
|
||||
if (delta->instruction_count > 0 && !delta->instructions)
|
||||
return NULL;
|
||||
uint64_t header_size = sizeof(uint64_t) + sizeof(uint32_t);
|
||||
if (delta->delta_size > UINT64_MAX - header_size || header_size + delta->delta_size > SIZE_MAX)
|
||||
return NULL;
|
||||
uint64_t total = header_size + delta->delta_size;
|
||||
uint8_t* buf = protocol_alloc((size_t)total);
|
||||
if (!buf)
|
||||
return NULL;
|
||||
uint64_t total = sizeof(uint64_t) + sizeof(uint32_t) + delta->delta_size;
|
||||
uint8_t *buf = malloc((size_t)total);
|
||||
if (!buf) return NULL;
|
||||
|
||||
size_t pos = 0;
|
||||
memcpy(buf + pos, &delta->new_file_size, sizeof(uint64_t));
|
||||
@@ -527,7 +307,8 @@ Data* delta_serialize(const Delta* delta) {
|
||||
} else {
|
||||
memcpy(buf + pos, &delta->instructions[i].literal.length, sizeof(uint32_t));
|
||||
pos += sizeof(uint32_t);
|
||||
memcpy(buf + pos, delta->instructions[i].literal.data, delta->instructions[i].literal.length);
|
||||
memcpy(buf + pos, delta->instructions[i].literal.data,
|
||||
delta->instructions[i].literal.length);
|
||||
pos += delta->instructions[i].literal.length;
|
||||
}
|
||||
}
|
||||
@@ -535,35 +316,23 @@ Data* delta_serialize(const Delta* delta) {
|
||||
return data_create(buf, (size_t)total);
|
||||
}
|
||||
|
||||
Delta* delta_deserialize(const Data* data) {
|
||||
Delta *delta_deserialize(const Data *data) {
|
||||
if (!data || data->size < sizeof(uint64_t) + sizeof(uint32_t))
|
||||
return NULL;
|
||||
|
||||
const uint8_t* buf = (const uint8_t*)data->data;
|
||||
const uint8_t *buf = (const uint8_t *)data->data;
|
||||
size_t pos = 0;
|
||||
|
||||
Delta* delta = protocol_alloc(sizeof(Delta));
|
||||
if (!delta)
|
||||
return NULL;
|
||||
Delta *delta = malloc(sizeof(Delta));
|
||||
if (!delta) return NULL;
|
||||
|
||||
memcpy(&delta->new_file_size, buf + pos, sizeof(uint64_t));
|
||||
pos += sizeof(uint64_t);
|
||||
memcpy(&delta->instruction_count, buf + pos, sizeof(uint32_t));
|
||||
pos += sizeof(uint32_t);
|
||||
|
||||
// Reject unreasonably large instruction counts to prevent OOM
|
||||
if (delta->instruction_count > MAX_DELTA_INSTRUCTIONS) {
|
||||
log_message(LOG_LEVEL_ERROR, "Delta instruction count %u exceeds maximum %u",
|
||||
delta->instruction_count, MAX_DELTA_INSTRUCTIONS);
|
||||
free(delta);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
delta->instructions =
|
||||
delta->instruction_count == 0
|
||||
? NULL
|
||||
: protocol_alloc((size_t)delta->instruction_count * sizeof(DeltaInstruction));
|
||||
if (delta->instruction_count > 0 && !delta->instructions) {
|
||||
delta->instructions = malloc(delta->instruction_count * sizeof(DeltaInstruction));
|
||||
if (!delta->instructions) {
|
||||
free(delta);
|
||||
return NULL;
|
||||
}
|
||||
@@ -572,7 +341,11 @@ Delta* delta_deserialize(const Data* data) {
|
||||
|
||||
for (uint32_t i = 0; i < delta->instruction_count; i++) {
|
||||
if (pos >= data->size) {
|
||||
free_instructions(delta->instructions, i);
|
||||
for (uint32_t k = 0; k < i; k++) {
|
||||
if (delta->instructions[k].type == DELTA_INSTR_LITERAL)
|
||||
free(delta->instructions[k].literal.data);
|
||||
}
|
||||
free(delta->instructions);
|
||||
free(delta);
|
||||
return NULL;
|
||||
}
|
||||
@@ -584,8 +357,8 @@ Delta* delta_deserialize(const Data* data) {
|
||||
delta->delta_size += 1;
|
||||
|
||||
if (type == DELTA_OP_BLOCK_MATCH) {
|
||||
if (data->size - pos < sizeof(uint32_t) * 3) {
|
||||
free_instructions(delta->instructions, i);
|
||||
if (pos + sizeof(uint32_t) * 3 > data->size) {
|
||||
free(delta->instructions);
|
||||
free(delta);
|
||||
return NULL;
|
||||
}
|
||||
@@ -598,8 +371,8 @@ Delta* delta_deserialize(const Data* data) {
|
||||
pos += sizeof(uint32_t);
|
||||
delta->delta_size += sizeof(uint32_t) * 3;
|
||||
} else if (type == DELTA_OP_LITERAL) {
|
||||
if (data->size - pos < sizeof(uint32_t)) {
|
||||
free_instructions(delta->instructions, i);
|
||||
if (pos + sizeof(uint32_t) > data->size) {
|
||||
free(delta->instructions);
|
||||
free(delta);
|
||||
return NULL;
|
||||
}
|
||||
@@ -608,15 +381,14 @@ Delta* delta_deserialize(const Data* data) {
|
||||
pos += sizeof(uint32_t);
|
||||
|
||||
uint32_t lit_len = delta->instructions[i].literal.length;
|
||||
if (lit_len > data->size - pos) {
|
||||
free_instructions(delta->instructions, i);
|
||||
if (pos + lit_len > data->size) {
|
||||
free(delta->instructions);
|
||||
free(delta);
|
||||
return NULL;
|
||||
}
|
||||
delta->instructions[i].literal.data = protocol_alloc(lit_len ? lit_len : 1);
|
||||
delta->instructions[i].literal.data = malloc(lit_len);
|
||||
if (!delta->instructions[i].literal.data) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to allocate %u bytes for literal data", lit_len);
|
||||
free_instructions(delta->instructions, i);
|
||||
free(delta->instructions);
|
||||
free(delta);
|
||||
return NULL;
|
||||
}
|
||||
@@ -624,7 +396,11 @@ Delta* delta_deserialize(const Data* data) {
|
||||
pos += lit_len;
|
||||
delta->delta_size += sizeof(uint32_t) + lit_len;
|
||||
} else {
|
||||
free_instructions(delta->instructions, i);
|
||||
for (uint32_t k = 0; k < i; k++) {
|
||||
if (delta->instructions[k].type == DELTA_INSTR_LITERAL)
|
||||
free(delta->instructions[k].literal.data);
|
||||
}
|
||||
free(delta->instructions);
|
||||
free(delta);
|
||||
return NULL;
|
||||
}
|
||||
@@ -633,49 +409,34 @@ Delta* delta_deserialize(const Data* data) {
|
||||
return delta;
|
||||
}
|
||||
|
||||
void* delta_apply(const void* old_data, uint64_t old_size, const Delta* delta,
|
||||
void *delta_apply(const void *old_data, uint64_t old_size, const Delta *delta,
|
||||
uint32_t block_size) {
|
||||
if (!old_data || !delta || (delta->new_file_size > 0 && delta->instructions == NULL) ||
|
||||
(delta->instruction_count > 0 && block_size == 0) ||
|
||||
delta->new_file_size > DELTA_MAX_FILE_SIZE || delta->new_file_size > SIZE_MAX)
|
||||
return NULL;
|
||||
if (!old_data || !delta) return NULL;
|
||||
|
||||
void* output = protocol_alloc(delta->new_file_size ? (size_t)delta->new_file_size : 1);
|
||||
if (!output)
|
||||
return NULL;
|
||||
void *output = malloc((size_t)delta->new_file_size);
|
||||
if (!output) return NULL;
|
||||
|
||||
uint8_t* out = (uint8_t*)output;
|
||||
const uint8_t* old = (const uint8_t*)old_data;
|
||||
uint8_t *out = (uint8_t *)output;
|
||||
uint8_t *old = (uint8_t *)old_data;
|
||||
uint64_t out_pos = 0;
|
||||
|
||||
for (uint32_t i = 0; i < delta->instruction_count; i++) {
|
||||
if (delta->instructions[i].type == DELTA_INSTR_BLOCK_MATCH) {
|
||||
uint64_t src_offset = (uint64_t)delta->instructions[i].match.block_index * block_size;
|
||||
if (src_offset > UINT64_MAX - delta->instructions[i].match.block_offset) {
|
||||
free(output);
|
||||
return NULL;
|
||||
}
|
||||
uint64_t src_offset = (uint64_t)delta->instructions[i].match.block_index *
|
||||
block_size;
|
||||
src_offset += delta->instructions[i].match.block_offset;
|
||||
uint32_t len = delta->instructions[i].match.length;
|
||||
|
||||
if (src_offset > old_size || (uint64_t)len > old_size - src_offset ||
|
||||
out_pos > delta->new_file_size || (uint64_t)len > delta->new_file_size - out_pos) {
|
||||
if (src_offset + len > old_size) {
|
||||
free(output);
|
||||
return NULL;
|
||||
}
|
||||
memcpy(out + out_pos, old + src_offset, len);
|
||||
out_pos += len;
|
||||
} else if (delta->instructions[i].type == DELTA_INSTR_LITERAL) {
|
||||
} else {
|
||||
uint32_t len = delta->instructions[i].literal.length;
|
||||
if (out_pos > delta->new_file_size || (uint64_t)len > delta->new_file_size - out_pos) {
|
||||
free(output);
|
||||
return NULL;
|
||||
}
|
||||
memcpy(out + out_pos, delta->instructions[i].literal.data, len);
|
||||
out_pos += len;
|
||||
} else {
|
||||
free(output);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,9 +448,8 @@ void* delta_apply(const void* old_data, uint64_t old_size, const Delta* delta,
|
||||
return output;
|
||||
}
|
||||
|
||||
void delta_destroy(Delta* delta) {
|
||||
if (!delta)
|
||||
return;
|
||||
void delta_destroy(Delta *delta) {
|
||||
if (!delta) return;
|
||||
for (uint32_t i = 0; i < delta->instruction_count; i++) {
|
||||
if (delta->instructions[i].type == DELTA_INSTR_LITERAL)
|
||||
free(delta->instructions[i].literal.data);
|
||||
@@ -710,9 +470,8 @@ bool delta_should_attempt(uint64_t old_size, uint64_t new_size, uint64_t max_fil
|
||||
return true;
|
||||
}
|
||||
|
||||
bool delta_is_worthwhile(const Delta* delta, uint64_t new_file_size) {
|
||||
if (!delta || delta->instruction_count == 0 || new_file_size == 0)
|
||||
return false;
|
||||
bool delta_is_worthwhile(const Delta *delta, uint64_t new_file_size) {
|
||||
if (!delta || delta->instruction_count == 0) return false;
|
||||
|
||||
bool has_match = false;
|
||||
for (uint32_t i = 0; i < delta->instruction_count; i++) {
|
||||
@@ -721,8 +480,7 @@ bool delta_is_worthwhile(const Delta* delta, uint64_t new_file_size) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!has_match)
|
||||
return false;
|
||||
if (!has_match) return false;
|
||||
|
||||
double ratio = (double)delta->delta_size / (double)new_file_size;
|
||||
return ratio < DELTA_FALLBACK_RATIO;
|
||||
|
||||
+33
-40
@@ -6,17 +6,17 @@
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#define DELTA_BLOCK_SIZE_DEFAULT 8192U
|
||||
#define DELTA_BLOCK_SIZE_MIN 1024U
|
||||
#define DELTA_BLOCK_SIZE_MAX 65536U
|
||||
#define DELTA_MIN_FILE_SIZE 16384ULL
|
||||
#define DELTA_MAX_FILE_SIZE (256ULL * 1024 * 1024)
|
||||
#define DELTA_MAX_SIZE_RATIO 10.0
|
||||
#define DELTA_FALLBACK_RATIO 0.7
|
||||
#define DELTA_ADLER32_MODULUS 65521U
|
||||
#define DELTA_BLOCK_SIZE_DEFAULT 8192U
|
||||
#define DELTA_BLOCK_SIZE_MIN 1024U
|
||||
#define DELTA_BLOCK_SIZE_MAX 65536U
|
||||
#define DELTA_MIN_FILE_SIZE 16384ULL
|
||||
#define DELTA_MAX_FILE_SIZE (256ULL * 1024 * 1024)
|
||||
#define DELTA_MAX_SIZE_RATIO 10.0
|
||||
#define DELTA_FALLBACK_RATIO 0.7
|
||||
#define DELTA_ADLER32_MODULUS 65521U
|
||||
|
||||
#define DELTA_OP_BLOCK_MATCH 0x01
|
||||
#define DELTA_OP_LITERAL 0x02
|
||||
#define DELTA_OP_BLOCK_MATCH 0x01
|
||||
#define DELTA_OP_LITERAL 0x02
|
||||
|
||||
typedef struct {
|
||||
uint32_t adler32;
|
||||
@@ -27,10 +27,13 @@ typedef struct {
|
||||
uint64_t file_size;
|
||||
uint32_t block_size;
|
||||
uint32_t block_count;
|
||||
DeltaBlockSig* blocks;
|
||||
DeltaBlockSig *blocks;
|
||||
} DeltaSignature;
|
||||
|
||||
typedef enum { DELTA_INSTR_BLOCK_MATCH = 0x01, DELTA_INSTR_LITERAL = 0x02 } DeltaInstrType;
|
||||
typedef enum {
|
||||
DELTA_INSTR_BLOCK_MATCH = 0x01,
|
||||
DELTA_INSTR_LITERAL = 0x02
|
||||
} DeltaInstrType;
|
||||
|
||||
typedef struct {
|
||||
DeltaInstrType type;
|
||||
@@ -41,7 +44,7 @@ typedef struct {
|
||||
uint32_t length;
|
||||
} match;
|
||||
struct {
|
||||
uint8_t* data;
|
||||
uint8_t *data;
|
||||
uint32_t length;
|
||||
} literal;
|
||||
};
|
||||
@@ -50,39 +53,29 @@ typedef struct {
|
||||
typedef struct {
|
||||
uint64_t new_file_size;
|
||||
uint32_t instruction_count;
|
||||
DeltaInstruction* instructions;
|
||||
DeltaInstruction *instructions;
|
||||
uint64_t delta_size;
|
||||
} Delta;
|
||||
|
||||
DeltaSignature* delta_signature_create(const void* old_file_data, uint64_t old_file_size,
|
||||
uint32_t block_size);
|
||||
/* Seeded equivalent of delta_signature_create: the per-block strong (xxHash32)
|
||||
* checksum uses `seed` (the low 32 bits of --checksum-seed). Passing seed 0 is
|
||||
* identical to the unseeded function. */
|
||||
DeltaSignature* delta_signature_create_seeded(const void* old_file_data, uint64_t old_file_size,
|
||||
uint32_t block_size, uint32_t seed);
|
||||
Data* delta_signature_serialize(const DeltaSignature* sig);
|
||||
DeltaSignature* delta_signature_deserialize(const Data* data);
|
||||
void delta_signature_destroy(DeltaSignature* sig);
|
||||
DeltaSignature *delta_signature_create(const void *old_file_data,
|
||||
uint64_t old_file_size,
|
||||
uint32_t block_size);
|
||||
Data *delta_signature_serialize(const DeltaSignature *sig);
|
||||
DeltaSignature *delta_signature_deserialize(const Data *data);
|
||||
void delta_signature_destroy(DeltaSignature *sig);
|
||||
|
||||
Delta* delta_compute(const void* new_file_data, uint64_t new_file_size, const DeltaSignature* sig,
|
||||
uint32_t block_size);
|
||||
/* Seeded equivalent of delta_compute: the per-window strong (xxHash32) check
|
||||
* uses `seed` (the low 32 bits of --checksum-seed). The receiver's signature
|
||||
* must have been built with the same seed for matching. */
|
||||
Delta* delta_compute_seeded(const void* new_file_data, uint64_t new_file_size,
|
||||
const DeltaSignature* sig, uint32_t block_size, uint32_t seed);
|
||||
Data* delta_serialize(const Delta* delta);
|
||||
Delta* delta_deserialize(const Data* data);
|
||||
void* delta_apply(const void* old_data, uint64_t old_size, const Delta* delta, uint32_t block_size);
|
||||
void delta_destroy(Delta* delta);
|
||||
Delta *delta_compute(const void *new_file_data, uint64_t new_file_size,
|
||||
const DeltaSignature *sig, uint32_t block_size);
|
||||
Data *delta_serialize(const Delta *delta);
|
||||
Delta *delta_deserialize(const Data *data);
|
||||
void *delta_apply(const void *old_data, uint64_t old_size, const Delta *delta,
|
||||
uint32_t block_size);
|
||||
void delta_destroy(Delta *delta);
|
||||
|
||||
bool delta_should_attempt(uint64_t old_size, uint64_t new_size, uint64_t max_file_size);
|
||||
bool delta_is_worthwhile(const Delta* delta, uint64_t new_file_size);
|
||||
bool delta_is_worthwhile(const Delta *delta, uint64_t new_file_size);
|
||||
|
||||
uint32_t delta_adler32(const void* data, uint32_t len);
|
||||
uint32_t delta_xxhash32(const void* data, uint32_t len);
|
||||
uint32_t delta_xxhash32_seeded(const void* data, uint32_t len, uint32_t seed);
|
||||
uint64_t delta_xxhash64(const void* data, size_t len);
|
||||
uint32_t delta_adler32(const void *data, uint32_t len);
|
||||
uint32_t delta_xxhash32(const void *data, uint32_t len);
|
||||
|
||||
#endif
|
||||
|
||||
+390
-1229
File diff suppressed because it is too large
Load Diff
+27
-130
@@ -1,140 +1,37 @@
|
||||
#ifndef FILE_H
|
||||
#define FILE_H
|
||||
|
||||
#include "file_send.h"
|
||||
#include "file_receive.h"
|
||||
#include "file_types.h"
|
||||
#include "checksum.h"
|
||||
#include "config.h"
|
||||
#include "data.h"
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
/* File/FileMetadata lifecycle, local disk helpers, and secure filesystem
|
||||
primitives shared by the send/receive pipelines. */
|
||||
typedef struct {
|
||||
mode_t mode;
|
||||
uid_t uid;
|
||||
gid_t gid;
|
||||
time_t mtime_sec;
|
||||
long mtime_nsec;
|
||||
} FileMetadata;
|
||||
|
||||
File* file_create(const char* path);
|
||||
void file_destroy(void* item);
|
||||
bool file_load_data(File* file);
|
||||
/* Compute the whole-file content digest of `file` with the negotiated
|
||||
* --checksum-choice algorithm and --checksum-seed. Writes the digest into
|
||||
* `out` (capacity `out_capacity`) and its length into `*out_len`. Returns
|
||||
* false on read/allocation failure or when the digest would not fit. */
|
||||
bool file_checksum(File* file, ChecksumAlgo algo, uint64_t seed, uint8_t* out, size_t out_capacity,
|
||||
size_t* out_len);
|
||||
size_t file_content_to_buffer(File* file);
|
||||
FileMetadata* file_metadata_create(const char* path, const struct stat* stats, bool capture_atime,
|
||||
bool capture_crtime);
|
||||
void file_metadata_destroy(void* metadata);
|
||||
/* --open-noatime process-wide sender policy; see file.c. */
|
||||
void file_set_open_noatime(bool enable);
|
||||
bool file_get_open_noatime(void);
|
||||
/* Open `path` read-only for transfer, honouring --open-noatime when set. */
|
||||
int file_open_for_read(const char* path);
|
||||
bool file_write_to_disk(const char* path, const void* data, unsigned long long data_size,
|
||||
bool inplace, bool sparse);
|
||||
typedef struct {
|
||||
char *path;
|
||||
Data *data;
|
||||
FileMetadata *metadata;
|
||||
} File;
|
||||
|
||||
/* Symlink trust-boundary helpers (Phase 4, symlink wave). --munge-links
|
||||
* sender-side marker: every transmitted symlink target is prefixed with this
|
||||
* while the flag is on; the receiver strips it to restore the real target. */
|
||||
#define SYMLINK_MUNGE_PREFIX "#SYMLINK/"
|
||||
|
||||
char* file_symlink_munge(const char* target);
|
||||
/* True when a lexical target is relative and contains no ".." component, so it
|
||||
* can never escape the receive root once created beneath it. */
|
||||
bool file_symlink_target_contained(const char* target);
|
||||
/* Strip a leading SYMLINK_MUNGE_PREFIX from `target` (mutable, in place);
|
||||
* returns true when a marker was removed. */
|
||||
bool file_symlink_unmunge(char* target);
|
||||
/* Create a symlink at `path` -> `target`, confined below the authorized root
|
||||
* (O_NOFOLLOW parent walk, symlinkat; the target is never followed). Returns
|
||||
* false when a directory already occupies `path`. */
|
||||
bool file_symlink_at_secure(const char* path, const char* target);
|
||||
/* --keep-dirlinks (-K) receiver process-wide policy: allow an in-root existing
|
||||
* symlink-to-directory to be followed as a directory. */
|
||||
void file_set_keep_dirlinks(bool enable);
|
||||
|
||||
/* --trust-sender receiver process-wide policy (Phase 5). When set, the
|
||||
* receiver trusts that the sender already produced a clean file list and skips
|
||||
* its own redundant up-front re-validation of incoming paths (the empty/".."
|
||||
* rejection and the escaping-symlink-target containment). The low-level
|
||||
* fd-relative confinement primitives below are deliberately NOT disabled by
|
||||
* this flag, so a hostile sender still cannot escape the authorized root. */
|
||||
void file_set_trust_sender(bool enable);
|
||||
bool file_get_trust_sender(void);
|
||||
|
||||
/* Secure path/filesystem primitives (symlink-safe, O_NOFOLLOW, root-confined). */
|
||||
bool file_path_exists_secure(const char* path);
|
||||
bool file_stat_secure(const char* path, struct stat* st);
|
||||
bool file_destination_is_newer_secure(const char* path, const FileMetadata* metadata);
|
||||
int file_open_secure_parent(const char* path, char** leaf_out, bool create_dirs);
|
||||
bool file_ensure_directory_secure(const char* path);
|
||||
bool file_directory_exists_secure(const char* path);
|
||||
bool file_rename_secure(const char* old_path, const char* new_path);
|
||||
/* Remove the whole directory tree at `path` (confined, symlink-safe). Used by
|
||||
--force to clear a non-empty destination directory that blocks an incoming
|
||||
regular file. See the .c for the exact success semantics. */
|
||||
bool file_remove_tree_secure(const char* path);
|
||||
/* Open a private 0700 directory (creating it on demand) that must live below
|
||||
the authorized root. Used for the --temp-dir scratch directory and the
|
||||
--delay-updates staging directory. */
|
||||
int file_open_private_dir(const char* dir_path);
|
||||
|
||||
/* The file_to_disk_secure* variants write a temporary copy in the destination
|
||||
directory and atomically rename it over `path`. temp_dir is an absolute,
|
||||
root-confined scratch directory (already validated by the caller): when it
|
||||
is non-NULL the temporary copy is instead created there (with a name unique
|
||||
across the whole scratch directory) and atomically renamed into the
|
||||
destination directory once fully written and fsynced. A rename across
|
||||
filesystems (EXDEV) fails the write with an error; the file is never
|
||||
silently copied into place. Pass NULL for the historical same-directory
|
||||
behavior. --inplace writes never use temp_dir. */
|
||||
bool file_to_disk_secure(const char* path, const void* data, unsigned long long data_size,
|
||||
bool inplace, bool sparse, bool preallocate, const FileMetadata* metadata,
|
||||
bool preserve_executability, const char* temp_dir);
|
||||
bool file_to_disk_secure_with_fsync(const char* path, const void* data,
|
||||
unsigned long long data_size, bool inplace, bool sparse,
|
||||
bool preallocate, const FileMetadata* metadata,
|
||||
bool preserve_executability, bool use_fsync,
|
||||
const char* temp_dir);
|
||||
/* With update enabled, an existing newer destination is left untouched. The
|
||||
check is descriptor-based for inplace writes; atomic replacement still has
|
||||
an unavoidable final rename race without filesystem locking. */
|
||||
bool file_to_disk_secure_update(const char* path, const void* data, unsigned long long data_size,
|
||||
bool inplace, bool sparse, bool preallocate,
|
||||
const FileMetadata* metadata, bool preserve_executability,
|
||||
const char* temp_dir);
|
||||
bool file_to_disk_secure_no_replace(const char* path, const void* data,
|
||||
unsigned long long data_size, bool sparse, bool preallocate,
|
||||
const FileMetadata* metadata, bool preserve_executability,
|
||||
const char* temp_dir);
|
||||
/* Receiver write-path variant that also applies per-file xattrs (-X/-A) and the
|
||||
* --fake-super stat xattr fd-relative before the final rename. `update` /
|
||||
* `no_replace` / `use_fsync` mirror the plain wrappers above; `keep_partial`
|
||||
* enables --partial best-effort retention of a failed write's temp. */
|
||||
bool file_to_disk_secure_attrs(const char* path, const void* data, unsigned long long data_size,
|
||||
bool inplace, bool sparse, bool preallocate,
|
||||
const FileMetadata* metadata, bool preserve_executability,
|
||||
bool update, bool no_replace, bool use_fsync,
|
||||
const FileXattrList* xattrs, bool fake_super, bool keep_partial,
|
||||
const char* temp_dir);
|
||||
/* Atomic --link-dest install: replace `path` with a hard link to `basis_path`
|
||||
(via a temp name + rename); fall back to a byte-identical local copy from
|
||||
`data` when the link is impossible (EXDEV/EPERM/unsupported filesystem).
|
||||
`metadata` is applied only on the copy fallback. `preallocate` applies to
|
||||
that copy fallback only (a hard-linked file shares the basis inode and is
|
||||
never re-allocated). */
|
||||
bool file_to_disk_secure_link(const char* path, const char* basis_path, const void* data,
|
||||
unsigned long long data_size, bool preallocate,
|
||||
const FileMetadata* metadata, bool preserve_executability,
|
||||
bool use_fsync, const char* temp_dir);
|
||||
/* Like file_to_disk_secure_link, but the byte-copy fallback also applies the
|
||||
* per-file xattrs (-X/-A) and --fake-super stat xattr (fd-relative). On a
|
||||
* successful hard link no attributes are applied (the shared inode already
|
||||
* carries the basis's). */
|
||||
bool file_to_disk_secure_link_attrs(const char* path, const char* basis_path, const void* data,
|
||||
unsigned long long data_size, bool preallocate,
|
||||
const FileMetadata* metadata, bool preserve_executability,
|
||||
bool use_fsync, const FileXattrList* xattrs, bool fake_super,
|
||||
const char* temp_dir);
|
||||
File *file_create(const char *path);
|
||||
void file_destroy(void *item);
|
||||
bool file_load_data(File *file);
|
||||
File *file_receive(Config *config, int file_descriptor);
|
||||
bool file_send_single_calls(File *file, int file_descriptor, bool use_metadata, int compression_level, bool send_path);
|
||||
bool file_send_sendfile(File *file, int file_descriptor, bool use_metadata, int compression_level, bool send_path);
|
||||
size_t file_content_to_buffer(File *file);
|
||||
FileMetadata *file_metadata_create(struct stat *stats);
|
||||
void file_metadata_destroy(void *metadata);
|
||||
bool to_disk(const char *path, const void *data, unsigned long long data_size);
|
||||
bool file_save_to_disk(const char *root_directory, File *file);
|
||||
File *receive_incremental_check(int fd, Config *config, bool *skipped);
|
||||
int receive_manifest(int fd, Config *config, int *next_status);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
#include "file_list.h"
|
||||
#include "log.h"
|
||||
#include "utils.h"
|
||||
#include <errno.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct {
|
||||
char** items;
|
||||
int count;
|
||||
int capacity;
|
||||
} StringList;
|
||||
|
||||
static void string_list_destroy(StringList* list) {
|
||||
if (!list)
|
||||
return;
|
||||
for (int i = 0; i < list->count; i++)
|
||||
free(list->items[i]);
|
||||
free(list->items);
|
||||
}
|
||||
|
||||
static bool string_list_add(StringList* list, const char* text) {
|
||||
if (list->count == list->capacity) {
|
||||
if (list->capacity > INT_MAX / 2)
|
||||
return false;
|
||||
int new_cap = list->capacity > 0 ? list->capacity * 2 : 16;
|
||||
char** grown = realloc(list->items, (size_t)new_cap * sizeof(char*));
|
||||
if (!grown)
|
||||
return false;
|
||||
list->items = grown;
|
||||
list->capacity = new_cap;
|
||||
}
|
||||
list->items[list->count] = str_dup(text);
|
||||
if (!list->items[list->count])
|
||||
return false;
|
||||
list->count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Validate and normalize one entry. Returns:
|
||||
* 1 -> added to `out`
|
||||
* 0 -> blank entry, skip
|
||||
* -1 -> invalid (message set in `err`)
|
||||
* `strip_line_endings` trims a trailing CR/LF (line mode only); NUL mode keeps
|
||||
* the entry bytes verbatim so names ending in CR/LF survive. */
|
||||
static int normalize_entry(const char* raw, size_t len, bool strip_line_endings, StringList* out,
|
||||
char* err, size_t err_size) {
|
||||
if (strip_line_endings) {
|
||||
while (len > 0 && (raw[len - 1] == '\n' || raw[len - 1] == '\r'))
|
||||
len--;
|
||||
}
|
||||
if (len == 0)
|
||||
return 0;
|
||||
if (raw[0] == '/') {
|
||||
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. In NUL-delimited mode the
|
||||
* delimiter itself is the final byte and is expected; in line mode any NUL is
|
||||
* embedded garbage (strlen-based parsing would otherwise silently truncate). */
|
||||
size_t scan_len = strip_line_endings ? len : len - 1;
|
||||
if (memchr(raw, '\0', scan_len)) {
|
||||
snprintf(err, err_size, "entry contains an embedded NUL byte");
|
||||
return -1;
|
||||
}
|
||||
char* dup = malloc(len + 1);
|
||||
if (!dup) {
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return -1;
|
||||
}
|
||||
memcpy(dup, raw, len);
|
||||
dup[len] = '\0';
|
||||
|
||||
/* Rebuild the path token-by-token: skip '.' and empty segments, reject '..'. */
|
||||
size_t out_len = 0;
|
||||
for (const char* part = dup;;) {
|
||||
const char* slash = strchr(part, '/');
|
||||
size_t part_len = slash ? (size_t)(slash - part) : strlen(part);
|
||||
if (part_len == 1 && part[0] == '.') {
|
||||
/* skip "." segment */
|
||||
} else if (part_len == 2 && part[0] == '.' && part[1] == '.') {
|
||||
snprintf(err, err_size, "path traversal entry is not allowed: '%s'", dup);
|
||||
free(dup);
|
||||
return -1;
|
||||
} else if (part_len > 0) {
|
||||
if (out_len > 0)
|
||||
dup[out_len++] = '/';
|
||||
memmove(dup + out_len, part, part_len);
|
||||
out_len += part_len;
|
||||
}
|
||||
if (!slash)
|
||||
break;
|
||||
part = slash + 1;
|
||||
}
|
||||
dup[out_len] = '\0';
|
||||
|
||||
int result;
|
||||
if (out_len == 0) {
|
||||
/* "." / "./" lists the source root: the whole tree is transferred. */
|
||||
result = string_list_add(out, "") ? 1 : -1;
|
||||
if (result < 0)
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
} else {
|
||||
result = string_list_add(out, dup) ? 1 : -1;
|
||||
if (result < 0)
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
}
|
||||
free(dup);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* Build the membership index over the exact entries only. `file_list_affects`
|
||||
combines the exact/descendant lookups with a walk of the query's own ancestor
|
||||
prefixes, so no ancestor prefix is ever materialized as a copy and the index
|
||||
stays O(entry count) memory regardless of path depth. An empty entry (the
|
||||
source root) sets whole_tree and short-circuits every query. */
|
||||
static bool file_list_index_build(FileListSet* set, char* err, size_t err_size) {
|
||||
if (!path_index_build(&set->index, (const char* const*)set->entries, (size_t)set->count)) {
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < set->count; i++) {
|
||||
if (set->entries[i][0] == '\0') {
|
||||
set->whole_tree = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static FileListSet* string_list_to_set(StringList* raw, char* err, size_t err_size) {
|
||||
FileListSet* set = calloc(1, sizeof(FileListSet));
|
||||
if (!set) {
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
set->count = raw->count;
|
||||
set->entries = raw->items;
|
||||
raw->items = NULL;
|
||||
raw->count = 0;
|
||||
if (!file_list_index_build(set, err, err_size)) {
|
||||
file_list_destroy(set);
|
||||
return NULL;
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
FileListSet* file_list_load(const char* path, bool null_separated, char* err, size_t err_size) {
|
||||
if (err && err_size > 0)
|
||||
err[0] = '\0';
|
||||
if (!path || !*path) {
|
||||
snprintf(err, err_size, "no file given");
|
||||
return NULL;
|
||||
}
|
||||
FILE* fp = fopen(path, "r");
|
||||
if (!fp) {
|
||||
char* escaped = output_escape(path, false);
|
||||
snprintf(err, err_size, "could not open '%s': %s", escaped ? escaped : path, strerror(errno));
|
||||
free(escaped);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
StringList raw = {0};
|
||||
char* line = NULL;
|
||||
size_t line_cap = 0;
|
||||
bool ok = true;
|
||||
char delim = null_separated ? '\0' : '\n';
|
||||
while (ok) {
|
||||
ssize_t n = utils_getdelim_bounded(fp, &line, &line_cap, delim, UTILS_MAX_LINE_LEN);
|
||||
if (n < 0) {
|
||||
if (errno == EFBIG)
|
||||
snprintf(err, err_size, "entry in file list exceeds %d bytes", (int)UTILS_MAX_LINE_LEN);
|
||||
else
|
||||
snprintf(err, err_size, "error reading file list: %s", strerror(errno));
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (n == 0)
|
||||
break;
|
||||
int r = normalize_entry(line, (size_t)n, !null_separated, &raw, err, err_size);
|
||||
if (r < 0) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
free(line);
|
||||
fclose(fp);
|
||||
if (!ok) {
|
||||
string_list_destroy(&raw);
|
||||
return NULL;
|
||||
}
|
||||
FileListSet* set = string_list_to_set(&raw, err, err_size);
|
||||
if (!set)
|
||||
string_list_destroy(&raw);
|
||||
return set;
|
||||
}
|
||||
|
||||
void file_list_destroy(FileListSet* set) {
|
||||
if (!set)
|
||||
return;
|
||||
path_index_free(&set->index);
|
||||
for (int i = 0; i < set->count; i++)
|
||||
free(set->entries[i]);
|
||||
free(set->entries);
|
||||
free(set);
|
||||
}
|
||||
|
||||
bool file_list_affects(const FileListSet* set, const char* rel) {
|
||||
if (!set)
|
||||
return true;
|
||||
if (!rel)
|
||||
return false;
|
||||
if (set->whole_tree)
|
||||
return true; /* whole tree listed */
|
||||
/* An exact entry match means `rel` itself is listed. */
|
||||
if (path_index_contains(&set->index, rel))
|
||||
return true;
|
||||
/* Otherwise `rel` is affected when a listed entry is an ancestor directory of
|
||||
it; walk rel's own directory prefixes (which preserve path-boundary
|
||||
semantics) and test each for an exact entry. No prefixes are stored. */
|
||||
size_t len = strlen(rel);
|
||||
while (len > 0) {
|
||||
const char* slash = NULL;
|
||||
for (size_t i = len; i-- > 0;) {
|
||||
if (rel[i] == '/') {
|
||||
slash = rel + i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!slash)
|
||||
break;
|
||||
len = (size_t)(slash - rel);
|
||||
if (path_index_contains_n(&set->index, rel, len))
|
||||
return true;
|
||||
}
|
||||
/* Finally `rel` is affected when it is an ancestor directory of a listed
|
||||
entry (binary search for the first entry at or after `rel` + '/'). */
|
||||
return path_index_has_descendant(&set->index, rel);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
#ifndef FILE_LIST_H
|
||||
#define FILE_LIST_H
|
||||
|
||||
#include "utils.h"
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/* --files-from allow-set. The file lists source paths RELATIVE to the source
|
||||
* root. A listed regular file is transferred; a listed directory transfers its
|
||||
* whole subtree (FastSync's recursion is always on). Blank lines are ignored.
|
||||
*
|
||||
* Entries are normalized: leading "./" and duplicate "/" are removed, an entry
|
||||
* of "." means the whole tree, absolute entries and ".." traversal are
|
||||
* rejected at parse time. The set is immutable and shared read-only across
|
||||
* scanner worker threads.
|
||||
*
|
||||
* Membership is answered from `index`, built once at load time over the exact
|
||||
* entries only: `index.exact` matches a listed path, the sorted view detects an
|
||||
* ancestor directory of a listed entry, and `rel`'s own directory prefixes are
|
||||
* matched against the exact set while descending. No ancestor prefix is stored
|
||||
* as a separate string, so the index is O(entry count) memory however deep the
|
||||
* paths are, and each query is O(path length) comparisons. */
|
||||
typedef struct {
|
||||
char** entries; /* normalized rel paths; "" means the whole tree */
|
||||
int count;
|
||||
PathIndex index;
|
||||
bool whole_tree; /* an entry of "" lists the source root */
|
||||
} FileListSet;
|
||||
|
||||
/* Load and validate a --files-from file. When `null_separated` (-0/--from0)
|
||||
* entries are delimited by NUL instead of newlines. Returns NULL with a message
|
||||
* in `err` on open/validation failure. An empty file yields an empty set
|
||||
* (nothing is transferred). */
|
||||
FileListSet* file_list_load(const char* path, bool null_separated, char* err, size_t err_size);
|
||||
void file_list_destroy(FileListSet* set);
|
||||
|
||||
/* True when `rel` (path relative to the source root, "" == root) is a listed
|
||||
* entry, lives under a listed directory, or is an ancestor directory of a
|
||||
* listed entry. Used to prune scanning: directories are descended only when
|
||||
* this returns true, files are transferred only when it returns true. */
|
||||
bool file_list_affects(const FileListSet* set, const char* rel);
|
||||
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,121 +0,0 @@
|
||||
#ifndef FILE_RECEIVE_H
|
||||
#define FILE_RECEIVE_H
|
||||
|
||||
#include "config.h"
|
||||
#include "file_types.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
/* 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);
|
||||
File* file_receive_hardlink(int file_descriptor);
|
||||
File* file_receive_symlink(int file_descriptor, const Config* config);
|
||||
File* file_receive_special(int file_descriptor);
|
||||
bool file_special_rdev_valid(int32_t major, int32_t minor, mode_t mode);
|
||||
File* receive_incremental_check(int fd, const Config* config, bool* skipped);
|
||||
/* Extended variant used by the receiver. `would_transfer` (may be NULL) is set
|
||||
* true only on the server-contacting --dry-run path when the file is not up to
|
||||
* date: the receiver has already sent STATUS_DRY_RUN_TRANSFER and returns NULL
|
||||
* without storing anything. On that path `*skipped` is true for an up-to-date
|
||||
* (STATUS_OK) file and both flags are false for a genuine error. */
|
||||
File* receive_incremental_check_ex(int fd, const Config* config, bool* skipped,
|
||||
bool* would_transfer);
|
||||
|
||||
/* P7 Wave D directory-time accumulator. The receiver collects the metadata of
|
||||
* every directory it creates/receives (STATUS_MKDIR with metadata and/or the
|
||||
* trailing STATUS_DIR_TIMES frame(s)) and applies the times only at the END of the
|
||||
* transfer, after all children have been written and after the delete /
|
||||
* --delay-updates phases have committed (writing or removing a child bumps the
|
||||
* parent's mtime). -O/--omit-dir-times skips the application entirely. The
|
||||
* list owns deep copies of the paths and metadata; freed on every path. */
|
||||
typedef struct {
|
||||
char** paths; /* owned, destination-relative wire paths */
|
||||
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 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
|
||||
* directory (an empty/pruned source dir that was deliberately not created) or a
|
||||
* non-directory at the path is skipped QUIETLY, an unreachable one with a
|
||||
* warning, and never fatal. */
|
||||
void dir_time_list_apply(const DirTimeList* list, const char* root_directory);
|
||||
|
||||
/* A received delete-manifest frame: the keep-set (`keeps`, destination-relative
|
||||
paths the sender transferred/keeps) plus `protected`, destination-relative
|
||||
prefixes the sender asks the receiver never to delete (paths excluded on the
|
||||
source, protected at any depth). When --delete-excluded is given the sender
|
||||
transmits an empty protected list so excluded destination mirrors are treated
|
||||
as ordinary extras. With --delete-missing-args a third section (`missing`)
|
||||
carries the destination mirrors of explicitly-listed source entries that do
|
||||
not exist: each is an exact deletion request, independent of the ordinary
|
||||
extras walk (never blocked by the protected prefixes) and processed when the
|
||||
manifest is committed. */
|
||||
typedef struct DeleteManifest {
|
||||
ArrayList* keeps;
|
||||
ArrayList* protected;
|
||||
ArrayList* missing;
|
||||
} DeleteManifest;
|
||||
|
||||
void delete_manifest_free(DeleteManifest* manifest);
|
||||
/* Read a delete-manifest frame: keep count + keeps, then protected count +
|
||||
protected prefixes, then missing count + missing paths (self-delimiting; the
|
||||
leading STATUS_MANIFEST code has been consumed). Returns an owned
|
||||
DeleteManifest, or NULL after signalling STATUS_ERROR on a malformed frame. */
|
||||
DeleteManifest* receive_manifest_entries(int fd);
|
||||
/* Remove destination entries under config->receive_root_directory that are not
|
||||
in `manifest` (bounded, all-or-nothing walk; staging-dir, basis-dir and
|
||||
protected-prefix skips). `--max-delete` and `--force` are honored here. The
|
||||
caller decides WHEN to run it based on the negotiated delete timing. Returns
|
||||
false (and the transfer fails) when the deletion cannot be committed. */
|
||||
bool manifest_delete_extras(const Config* config, DeleteManifest* manifest);
|
||||
/* --delete-missing-args exact-path deletions: remove each destination mirror
|
||||
in `manifest->missing` (never blocked by the protected prefixes, staging dir
|
||||
and basis dirs excluded). A regular file/symlink is unlinked; an empty
|
||||
directory is removed; a NON-empty directory is removed recursively only when
|
||||
--delete or --force is in effect, otherwise it is left with a warning (rsync
|
||||
parity). A missing path is a no-op. Returns false only on a genuine
|
||||
confinement or I/O error (the run then fails); tolerated per-path cases are
|
||||
reported and skipped. */
|
||||
bool manifest_delete_missing_args(const Config* config, DeleteManifest* manifest);
|
||||
/* Run every deletion family the manifest carries: the --delete-missing-args
|
||||
exact-path deletions first (user requests are not blocked by exclusion
|
||||
protection), then the ordinary extras walk when --delete is active. Returns
|
||||
true when nothing to do or everything committed. */
|
||||
bool manifest_delete_all(const Config* config, DeleteManifest* manifest);
|
||||
|
||||
/* Outcome of a single file_save_to_disk operation. The receiver needs to
|
||||
distinguish "written" from "skipped" so --remove-source-files can be told
|
||||
which sources were actually stored. */
|
||||
typedef enum { FILE_SAVE_ERROR = 0, FILE_SAVE_WRITTEN = 1, FILE_SAVE_SKIPPED = 2 } FileSaveResult;
|
||||
|
||||
FileSaveResult file_save_to_disk_full(const char* root_directory, const File* file,
|
||||
const Config* config);
|
||||
bool file_save_to_disk(const char* root_directory, const File* file, const Config* config);
|
||||
|
||||
#endif
|
||||
@@ -1,181 +0,0 @@
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <poll.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/sendfile.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "charset.h"
|
||||
#include "compression.h"
|
||||
#include "data.h"
|
||||
#include "file.h"
|
||||
#include "log.h"
|
||||
#include "metadata.h"
|
||||
#include "protocol.h"
|
||||
#include "xattr.h"
|
||||
|
||||
/* Transmit a device/special node (--devices / --specials) as a STATUS_SPECIAL
|
||||
* frame: the destination path, the metadata frame (whose mode's S_IFMT bits
|
||||
* carry the node kind) and the device rdev major/minor. The receiver validates
|
||||
* the kind and rdev and recreates the node (privilege-gating the mknod). */
|
||||
bool file_send_special(const File* file, int file_descriptor, bool use_metadata) {
|
||||
if (!file || !file_wire_path(file))
|
||||
return false;
|
||||
if (!send_status(file_descriptor, STATUS_SPECIAL))
|
||||
return false;
|
||||
if (!send_wire_str(file_descriptor, file_wire_path(file)))
|
||||
return false;
|
||||
if (use_metadata && !metadata_send(file_descriptor, file->metadata))
|
||||
return false;
|
||||
int32_t major = file->rdev_major;
|
||||
int32_t minor = file->rdev_minor;
|
||||
return send_n_data(file_descriptor, &major, sizeof(major)) &&
|
||||
send_n_data(file_descriptor, &minor, sizeof(minor));
|
||||
}
|
||||
|
||||
bool file_send_single_calls(File* file, int file_descriptor, bool use_metadata,
|
||||
int compression_level, bool send_path) {
|
||||
return file_send_single_calls_with_skip(file, file_descriptor, use_metadata, compression_level,
|
||||
send_path, NULL, -1, 0, false);
|
||||
}
|
||||
|
||||
bool file_send_single_calls_with_skip(File* file, int file_descriptor, bool use_metadata,
|
||||
int compression_level, bool send_path,
|
||||
char* const* skip_suffixes, int skip_count,
|
||||
int compression_threads, bool send_xattrs) {
|
||||
if (!file || !file->path || !file->data || (file->data->size != 0 && !file->data->data))
|
||||
return false;
|
||||
const Data* data_to_send = file->data;
|
||||
Data* compressed_data = NULL;
|
||||
if (compression_level > 0 &&
|
||||
!compression_should_skip_with_suffixes(file->path, skip_suffixes, skip_count)) {
|
||||
compressed_data =
|
||||
data_compress_with_threads(file->data, compression_level, compression_threads);
|
||||
if (compressed_data == NULL) {
|
||||
log_message(LOG_LEVEL_ERROR, "Failed to compress file data");
|
||||
return false;
|
||||
}
|
||||
data_to_send = compressed_data;
|
||||
}
|
||||
if (send_path && !send_wire_str(file_descriptor, file_wire_path(file))) {
|
||||
data_destroy(compressed_data);
|
||||
return false;
|
||||
}
|
||||
if (use_metadata && !metadata_send(file_descriptor, file->metadata)) {
|
||||
data_destroy(compressed_data);
|
||||
return false;
|
||||
}
|
||||
if (send_xattrs && !xattr_send(file_descriptor, file ? file->xattrs : NULL)) {
|
||||
data_destroy(compressed_data);
|
||||
return false;
|
||||
}
|
||||
if (!send_data(file_descriptor, data_to_send)) {
|
||||
data_destroy(compressed_data);
|
||||
return false;
|
||||
}
|
||||
data_destroy(compressed_data);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool file_send_sendfile(File* file, int file_descriptor, bool use_metadata, int compression_level,
|
||||
bool send_path) {
|
||||
return file_send_sendfile_with_skip(file, file_descriptor, use_metadata, compression_level,
|
||||
send_path, NULL, -1, 0, false);
|
||||
}
|
||||
|
||||
bool file_send_sendfile_with_skip(File* file, int file_descriptor, bool use_metadata,
|
||||
int compression_level, bool send_path, char* const* skip_suffixes,
|
||||
int skip_count, int compression_threads, bool send_xattrs) {
|
||||
if (!file || !file->path || !file->data)
|
||||
return false;
|
||||
if (compression_level > 0)
|
||||
return file_send_single_calls_with_skip(file, file_descriptor, use_metadata, compression_level,
|
||||
send_path, skip_suffixes, skip_count,
|
||||
compression_threads, send_xattrs);
|
||||
|
||||
if (send_path && !send_wire_str(file_descriptor, file_wire_path(file)))
|
||||
return false;
|
||||
if (use_metadata && !metadata_send(file_descriptor, file->metadata))
|
||||
return false;
|
||||
if (send_xattrs && !xattr_send(file_descriptor, file ? file->xattrs : NULL))
|
||||
return false;
|
||||
|
||||
int fd = file_open_for_read(file->path);
|
||||
if (fd == -1) {
|
||||
log_perror("Could not open file for sendfile");
|
||||
return false;
|
||||
}
|
||||
|
||||
unsigned long long file_size = file->data->size;
|
||||
struct stat source_stat;
|
||||
if (fstat(fd, &source_stat) != 0 || !S_ISREG(source_stat.st_mode) ||
|
||||
(unsigned long long)source_stat.st_size < file_size) {
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
if (!send_n_data(file_descriptor, &file_size, sizeof(unsigned long long))) {
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
|
||||
/* sendfile cannot encrypt TLS records. Keep the framing identical but
|
||||
route encrypted transfers through the deadline-aware IO layer. */
|
||||
if (io_get_ssl() != NULL) {
|
||||
unsigned char buffer[64 * 1024];
|
||||
unsigned long long remaining = file_size;
|
||||
bool ok = true;
|
||||
while (remaining > 0) {
|
||||
size_t want = remaining > sizeof(buffer) ? sizeof(buffer) : (size_t)remaining;
|
||||
ssize_t got = read(fd, buffer, want);
|
||||
if (got <= 0 || !send_n_data(file_descriptor, buffer, (size_t)got)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
remaining -= (unsigned long long)got;
|
||||
}
|
||||
close(fd);
|
||||
return ok;
|
||||
}
|
||||
|
||||
off_t offset = 0;
|
||||
struct timespec deadline;
|
||||
clock_gettime(CLOCK_MONOTONIC, &deadline);
|
||||
deadline.tv_sec += protocol_get_io_timeout_sec();
|
||||
while ((unsigned long long)offset < file_size) {
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
long long remaining = (long long)(deadline.tv_sec - now.tv_sec) * 1000LL +
|
||||
(deadline.tv_nsec - now.tv_nsec) / 1000000LL;
|
||||
if (remaining <= 0) {
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
struct pollfd pfd = {.fd = file_descriptor, .events = POLLOUT};
|
||||
int timeout = remaining > INT_MAX ? INT_MAX : (int)remaining;
|
||||
int polled = poll(&pfd, 1, timeout);
|
||||
if (polled <= 0 || (pfd.revents & (POLLERR | POLLHUP | POLLNVAL))) {
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
ssize_t sent = sendfile(file_descriptor, fd, &offset, file_size - offset);
|
||||
if (sent == -1) {
|
||||
if (errno == EAGAIN || errno == EINTR)
|
||||
continue;
|
||||
log_perror("sendfile failed");
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
if (sent == 0) {
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
close(fd);
|
||||
return true;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
#ifndef FILE_SEND_H
|
||||
#define FILE_SEND_H
|
||||
|
||||
#include "file_types.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
/* Client-side file send path. */
|
||||
|
||||
bool file_send_special(const File* file, int file_descriptor, bool use_metadata);
|
||||
bool file_send_single_calls(File* file, int file_descriptor, bool use_metadata,
|
||||
int compression_level, bool send_path);
|
||||
bool file_send_single_calls_with_skip(File* file, int file_descriptor, bool use_metadata,
|
||||
int compression_level, bool send_path,
|
||||
char* const* skip_suffixes, int skip_count,
|
||||
int compression_threads, bool send_xattrs);
|
||||
bool file_send_sendfile(File* file, int file_descriptor, bool use_metadata, int compression_level,
|
||||
bool send_path);
|
||||
bool file_send_sendfile_with_skip(File* file, int file_descriptor, bool use_metadata,
|
||||
int compression_level, bool send_path, char* const* skip_suffixes,
|
||||
int skip_count, int compression_threads, bool send_xattrs);
|
||||
|
||||
#endif
|
||||
@@ -1,56 +0,0 @@
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "file_store.h"
|
||||
|
||||
static bool write_all(int fd, const void* data, unsigned long long size) {
|
||||
const unsigned char* p = data;
|
||||
unsigned long long done = 0;
|
||||
while (done < size) {
|
||||
ssize_t n = write(fd, p + done, (size_t)(size - done));
|
||||
if (n < 0 && errno == EINTR)
|
||||
continue;
|
||||
if (n <= 0)
|
||||
return false;
|
||||
done += (unsigned long long)n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* A run of NUL bytes at least this long is emitted as a hole (lseek) rather
|
||||
* than written, so the resulting file is genuinely sparse on the filesystem. */
|
||||
#define SPARSE_HOLE_MIN 4096U
|
||||
|
||||
/* Sparse-aware writer (--sparse/-S). Walks `data`; any all-zero run of at
|
||||
* least SPARSE_HOLE_MIN bytes is skipped with lseek(SEEK_CUR) so the block is
|
||||
* never allocated (a real hole on the destination); every other byte is written
|
||||
* normally. The file is pre-sized with ftruncate by the callers before this
|
||||
* runs, so holes are guaranteed and the offset bookkeeping stays correct
|
||||
* (each lseek advances the fd offset exactly as a write of that many bytes
|
||||
* would). After the final run, ftruncate(size) guarantees the logical size is
|
||||
* exactly `size` even when the tail was a hole. The full file image is in
|
||||
* memory, so no wire change is needed. Returns false on I/O error. */
|
||||
bool file_store_write_sparse(int fd, const unsigned char* data, unsigned long long size) {
|
||||
unsigned long long i = 0;
|
||||
while (i < size) {
|
||||
if (data[i] == 0) {
|
||||
unsigned long long run_start = i;
|
||||
while (i < size && data[i] == 0)
|
||||
i++;
|
||||
unsigned long long run_len = i - run_start;
|
||||
if (run_len >= SPARSE_HOLE_MIN) {
|
||||
if (lseek(fd, (off_t)run_len, SEEK_CUR) < 0)
|
||||
return false;
|
||||
} else if (!write_all(fd, data + run_start, run_len)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
unsigned long long run_start = i;
|
||||
while (i < size && data[i] != 0)
|
||||
i++;
|
||||
if (!write_all(fd, data + run_start, i - run_start))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return ftruncate(fd, (off_t)size) == 0;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
#ifndef FILE_STORE_H
|
||||
#define FILE_STORE_H
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
/* Sparse-aware write (--sparse/-S): every all-zero run of at least
|
||||
* SPARSE_HOLE_MIN bytes is skipped with lseek(SEEK_CUR) so it becomes a real
|
||||
* hole; every other byte is written. The caller pre-sizes the file with
|
||||
* ftruncate; this function also ftruncate()s to `size` at the end so a trailing
|
||||
* hole keeps the exact logical length. Shared by the file_store and file write
|
||||
* paths. Returns false on write/lseek/ftruncate error. */
|
||||
bool file_store_write_sparse(int fd, const unsigned char* data, unsigned long long size);
|
||||
|
||||
#endif
|
||||
@@ -1,97 +0,0 @@
|
||||
#ifndef FILE_TYPES_H
|
||||
#define FILE_TYPES_H
|
||||
|
||||
#include "data.h"
|
||||
#include "xattr.h"
|
||||
#include <stdbool.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
typedef enum { FILE_TYPE_REGULAR, FILE_TYPE_SYMLINK, FILE_TYPE_DIR } FileType;
|
||||
|
||||
typedef struct {
|
||||
mode_t mode;
|
||||
uid_t uid;
|
||||
gid_t gid;
|
||||
time_t mtime_sec;
|
||||
long mtime_nsec;
|
||||
/* Optional access time (-U/--atimes) and creation/birth time (-N/--crtimes),
|
||||
* appended for protocol 2.12.0. The SENDER sets the corresponding *_valid
|
||||
* flag only when the preserve option is active (and, for crtime, only when
|
||||
* the source platform exposed a birth time via statx STATX_BTIME). The wire
|
||||
* always carries the fields and the flags; a false flag tells the receiver to
|
||||
* ignore the value. */
|
||||
bool atime_valid;
|
||||
time_t atime_sec;
|
||||
long atime_nsec;
|
||||
bool crtime_valid;
|
||||
time_t crtime_sec;
|
||||
long crtime_nsec;
|
||||
} FileMetadata;
|
||||
|
||||
typedef struct {
|
||||
char* path;
|
||||
/* Sender-side override for the path transmitted on the wire (and used for
|
||||
* the delete manifest / change output). NULL means "use `path`". With
|
||||
* -R + --files-from this holds the entry's bare relative destination path,
|
||||
* while `path` stays the absolute local source path the client reads from.
|
||||
* Never populated on the receiver. */
|
||||
char* send_path;
|
||||
Data* data;
|
||||
FileMetadata* metadata;
|
||||
bool skip;
|
||||
/* True when this entry is an explicit directory entry (--dirs mode): the
|
||||
* receiver creates the directory instead of writing a regular file. */
|
||||
bool is_dir;
|
||||
/* Receiver-only (P7 Wave D): this is a STATUS_DIR_TIMES entry. It carries a
|
||||
* traversed source directory's metadata for DEFERRED application, but must
|
||||
* NEVER create the directory: the scanner captures every traversed directory
|
||||
* (including empty ones whose parents no child write created), so creation
|
||||
* would resurrect the empty dirs that FastSync deliberately never transfers.
|
||||
* file_save_to_disk_full short-circuits such an entry as FILE_SAVE_SKIPPED,
|
||||
* and the sink still accumulates the metadata into its DirTimeList. */
|
||||
bool dir_time_only;
|
||||
/* Receiver-only, --link-dest: when set, install the destination entry as a
|
||||
* hard link to this absolute (root-confined) path instead of writing
|
||||
* `data`. The matching code has already verified the link target's content
|
||||
* equals the incoming file, and `data` is kept as the cross-filesystem
|
||||
* fallback (a local copy) if the hard link cannot be created. */
|
||||
char* basis_link;
|
||||
/* --hard-links (-H), sender + receiver wire state. link_group is a run-local
|
||||
* id shared by every member of one source inode (0 = not part of a group).
|
||||
* The FIRST member (link_first == true) carries its data on the wire and is
|
||||
* written normally; every sibling (link_first == false) carries NO data and
|
||||
* hardlink_target holds the first member's wire path so the receiver can link
|
||||
* to (or copy from) the already-installed first member. */
|
||||
int link_group;
|
||||
bool link_first;
|
||||
char* hardlink_target;
|
||||
/* Symlink-type entry (-l/--links, or -k/--copy-dirlinks' keep-as-symlink
|
||||
* branch). When true, `symlink_target` holds the (sender-munged, if
|
||||
* --munge-links) target string that is carried on the wire; the receiver
|
||||
* creates a symlink to (an unmunged) target instead of writing regular-file
|
||||
* data. `data` is empty for a symlink entry. Sender + receiver state. */
|
||||
bool is_symlink;
|
||||
char* symlink_target;
|
||||
/* Phase 4 special/devices: when `is_special` is true this entry is a device
|
||||
* or special node to be RECREATED on the destination (mknod/mkfifo) rather
|
||||
* than written from `data`. The concrete node kind is derived from the
|
||||
* metadata mode's S_IFMT bits (receiver-validated), and rdev_major/minor
|
||||
* carry the device major/minor numbers for char/block devices. CROSSES the
|
||||
* wire (protocol 2.13.0). */
|
||||
bool is_special;
|
||||
int32_t rdev_major;
|
||||
int32_t rdev_minor;
|
||||
/* Phase-4 xattrs (-X/--xattrs, -A/--acls). Sender: captured from the source
|
||||
* file when use_xattrs is set; transmitted in the per-file metadata frame.
|
||||
* Receiver: parsed off the wire, attached here, and applied fd-relative on
|
||||
* the written file. NULL/0 == the file carries no xattrs. */
|
||||
FileXattrList* xattrs;
|
||||
} File;
|
||||
|
||||
/* The path that should be sent on the wire and used for the receiver-side
|
||||
* destination layout (see send_path). */
|
||||
static inline const char* file_wire_path(const File* file) {
|
||||
return file && file->send_path ? file->send_path : (file ? file->path : NULL);
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -1,443 +0,0 @@
|
||||
#include "filter.h"
|
||||
#include "log.h"
|
||||
#include "utils.h"
|
||||
#include <errno.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ---- Single rule parsing ---- */
|
||||
|
||||
static bool rule_text_is_unsupported_word(const char* p, size_t len) {
|
||||
static const char* const words[] = {"merge", "dir-merge", "hide", "show",
|
||||
"protect", "risk", "clear"};
|
||||
for (size_t i = 0; i < sizeof(words) / sizeof(words[0]); i++) {
|
||||
size_t wl = strlen(words[i]);
|
||||
if (len == wl && strncmp(p, words[i], wl) == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* rsync include/exclude rule modifiers we do NOT implement. A rule whose +/- is
|
||||
* immediately followed by one of these is rejected instead of being silently
|
||||
* parsed as a literal pattern. */
|
||||
static bool is_unsupported_rule_modifier(char c) {
|
||||
return c == '!' || c == 'C' || c == 's' || c == 'r' || c == 'p' || c == 'x';
|
||||
}
|
||||
|
||||
FilterRule* filter_rule_parse(const char* line, char* err, size_t err_size) {
|
||||
if (err && err_size > 0)
|
||||
err[0] = '\0';
|
||||
if (!line)
|
||||
return NULL;
|
||||
char* text = str_dup(line);
|
||||
if (!text) {
|
||||
if (err)
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
size_t len = strlen(text);
|
||||
while (len > 0 && (text[len - 1] == '\n' || text[len - 1] == '\r'))
|
||||
text[--len] = '\0';
|
||||
|
||||
const char* p = text;
|
||||
while (*p == ' ' || *p == '\t')
|
||||
p++;
|
||||
if (*p == '\0') {
|
||||
snprintf(err, err_size, "empty filter rule");
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
FilterAction action = FILTER_ACTION_EXCLUDE;
|
||||
if (*p == '+' || *p == '-') {
|
||||
action = *p == '+' ? FILTER_ACTION_INCLUDE : FILTER_ACTION_EXCLUDE;
|
||||
p++;
|
||||
/* rsync attaches rule modifiers directly to the +/- (e.g. "-s foo"). Only
|
||||
* the '/' anchor modifier is supported; anything else is a clear error
|
||||
* rather than a silently-ignored literal. */
|
||||
if (*p != ' ' && *p != '\t' && *p != '\0' && is_unsupported_rule_modifier(*p)) {
|
||||
snprintf(err, err_size,
|
||||
"filter rule modifier '%c' is not supported (only the '/' anchor after +/- "
|
||||
"is implemented; put a space between +/- and the pattern)",
|
||||
*p);
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
while (*p == ' ' || *p == '\t')
|
||||
p++;
|
||||
} else {
|
||||
/* ':' (dir-merge) and '.' (merge) are rsync filter-rule shorthands. At the
|
||||
* start of a rule they mean "merge this file", so reject them instead of
|
||||
* silently turning them into inert exclude patterns. */
|
||||
if (*p == ':' || *p == '.' || *p == '!') {
|
||||
snprintf(err, err_size,
|
||||
"filter rule starting with '%c' is not supported (merge/dir-merge/list-clear "
|
||||
"shorthands are not implemented; use +/- include/exclude rules)",
|
||||
*p);
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
const char* sp = p;
|
||||
while (*sp != '\0' && *sp != ' ' && *sp != '\t')
|
||||
sp++;
|
||||
size_t word_len = (size_t)(sp - p);
|
||||
if (rule_text_is_unsupported_word(p, word_len)) {
|
||||
snprintf(err, err_size,
|
||||
"'%.*s' filter directives are not supported (only +/- include/exclude rules "
|
||||
"with an optional '/' anchor and trailing '/' dir marker)",
|
||||
(int)word_len, p);
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
if (word_len == strlen("include") && strncmp(p, "include", word_len) == 0) {
|
||||
action = FILTER_ACTION_INCLUDE;
|
||||
p = sp;
|
||||
} else if (word_len == strlen("exclude") && strncmp(p, "exclude", word_len) == 0) {
|
||||
action = FILTER_ACTION_EXCLUDE;
|
||||
p = sp;
|
||||
}
|
||||
while (*p == ' ' || *p == '\t')
|
||||
p++;
|
||||
}
|
||||
|
||||
if (*p == '\0') {
|
||||
snprintf(err, err_size, "filter rule has no pattern");
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* A pattern beginning with '/' is anchored (either as "-/foo" or "- /foo"). */
|
||||
bool anchored = false;
|
||||
if (*p == '/') {
|
||||
anchored = true;
|
||||
p++;
|
||||
while (*p == ' ' || *p == '\t')
|
||||
p++;
|
||||
}
|
||||
if (*p == '\0') {
|
||||
snprintf(err, err_size, "filter rule has no pattern after '/' anchor");
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Pattern runs to the end of the rule; a single trailing '/' marks dir-only. */
|
||||
size_t pat_len = strlen(p);
|
||||
bool dir_only = false;
|
||||
if (pat_len > 1 && p[pat_len - 1] == '/') {
|
||||
dir_only = true;
|
||||
pat_len--;
|
||||
} else if (pat_len == 1 && p[0] == '/') {
|
||||
/* "//" anchored with nothing after: meaningless. */
|
||||
snprintf(err, err_size, "filter rule has no pattern");
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
FilterRule* rule = calloc(1, sizeof(FilterRule));
|
||||
if (!rule) {
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
rule->pattern = malloc(pat_len + 1);
|
||||
if (!rule->pattern) {
|
||||
free(rule);
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
free(text);
|
||||
return NULL;
|
||||
}
|
||||
memcpy(rule->pattern, p, pat_len);
|
||||
rule->pattern[pat_len] = '\0';
|
||||
rule->action = action;
|
||||
rule->anchored = anchored;
|
||||
rule->dir_only = dir_only;
|
||||
rule->owner = NULL;
|
||||
free(text);
|
||||
return rule;
|
||||
}
|
||||
|
||||
void filter_rule_free(FilterRule* rule) {
|
||||
if (!rule)
|
||||
return;
|
||||
free(rule->pattern);
|
||||
free(rule->owner);
|
||||
free(rule);
|
||||
}
|
||||
|
||||
/* ---- Ordered rule lists ---- */
|
||||
|
||||
FilterRuleList* filter_rule_list_create(void) {
|
||||
return calloc(1, sizeof(FilterRuleList));
|
||||
}
|
||||
|
||||
bool filter_rule_list_add(FilterRuleList* list, FilterRule* rule) {
|
||||
if (!list || !rule)
|
||||
return false;
|
||||
if (list->count == list->capacity) {
|
||||
if (list->capacity > INT_MAX / 2)
|
||||
return false;
|
||||
int new_cap = list->capacity > 0 ? list->capacity * 2 : 8;
|
||||
FilterRule** grown = realloc(list->items, (size_t)new_cap * sizeof(FilterRule*));
|
||||
if (!grown)
|
||||
return false;
|
||||
list->items = grown;
|
||||
list->capacity = new_cap;
|
||||
}
|
||||
list->items[list->count++] = rule;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool filter_rule_list_parse_append(FilterRuleList* list, const char* line, char* err,
|
||||
size_t err_size) {
|
||||
FilterRule* rule = filter_rule_parse(line, err, err_size);
|
||||
if (!rule)
|
||||
return false;
|
||||
if (!filter_rule_list_add(list, rule)) {
|
||||
filter_rule_free(rule);
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void filter_rule_list_free(FilterRuleList* list) {
|
||||
if (!list)
|
||||
return;
|
||||
for (int i = 0; i < list->count; i++)
|
||||
filter_rule_free(list->items[i]);
|
||||
free(list->items);
|
||||
free(list);
|
||||
}
|
||||
|
||||
static bool set_rule_owner(FilterRule* rule, const char* owner) {
|
||||
char* dup = str_dup(owner ? owner : "");
|
||||
if (!dup)
|
||||
return false;
|
||||
free(rule->owner);
|
||||
rule->owner = dup;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---- CVS default excludes (-C) ---- */
|
||||
|
||||
typedef struct {
|
||||
const char* pattern;
|
||||
bool dir_only;
|
||||
} CvsDefaultRule;
|
||||
|
||||
static const CvsDefaultRule CVS_DEFAULTS[] = {
|
||||
{"RCS", false}, {"SCCS", false}, {"CVS", false}, {"CVS.adm", false},
|
||||
{"RCSLOG", false}, {"cvslog.*", false}, {"tags", false}, {"TAGS", false},
|
||||
{".make.state", false}, {".nse_depinfo", false}, {"*~", false}, {"#*", false},
|
||||
{".#*", false}, {",*", false}, {"_$*", false}, {"*$", false},
|
||||
{"*.old", false}, {"*.bak", false}, {"*.BAK", false}, {"*.orig", false},
|
||||
{"*.rej", false}, {".del-*", false}, {"*.a", false}, {"*.olb", false},
|
||||
{"*.o", false}, {"*.obj", false}, {"*.so", false}, {"*.exe", false},
|
||||
{"*.Z", false}, {"*.elc", false}, {"*.ln", false}, {"core", false},
|
||||
{".svn/", true}, {".git/", true}, {".hg/", true}, {".bzr/", true},
|
||||
};
|
||||
|
||||
static bool cvs_rule_list_append(FilterRuleList* list) {
|
||||
for (size_t i = 0; i < sizeof(CVS_DEFAULTS) / sizeof(CVS_DEFAULTS[0]); i++) {
|
||||
FilterRule* rule = calloc(1, sizeof(FilterRule));
|
||||
if (!rule)
|
||||
return false;
|
||||
rule->action = FILTER_ACTION_EXCLUDE;
|
||||
rule->dir_only = CVS_DEFAULTS[i].dir_only;
|
||||
size_t plen = strlen(CVS_DEFAULTS[i].pattern);
|
||||
if (rule->dir_only && plen > 0 && CVS_DEFAULTS[i].pattern[plen - 1] == '/')
|
||||
plen--; /* keep the cleaned pattern, matching filter_rule_parse */
|
||||
rule->pattern = malloc(plen + 1);
|
||||
if (!rule->pattern) {
|
||||
free(rule);
|
||||
return false;
|
||||
}
|
||||
memcpy(rule->pattern, CVS_DEFAULTS[i].pattern, plen);
|
||||
rule->pattern[plen] = '\0';
|
||||
if (!set_rule_owner(rule, "")) {
|
||||
filter_rule_free(rule);
|
||||
return false;
|
||||
}
|
||||
if (!filter_rule_list_add(list, rule)) {
|
||||
filter_rule_free(rule);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
FilterRuleList* filter_base_build(const char* const* rule_texts, int rule_count, bool cvs_exclude,
|
||||
char* err, size_t err_size) {
|
||||
if (err && err_size > 0)
|
||||
err[0] = '\0';
|
||||
FilterRuleList* list = filter_rule_list_create();
|
||||
if (!list) {
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
for (int i = 0; i < rule_count; i++) {
|
||||
if (!rule_texts || !rule_texts[i])
|
||||
continue;
|
||||
FilterRule* rule = filter_rule_parse(rule_texts[i], err, err_size);
|
||||
if (!rule) {
|
||||
filter_rule_list_free(list);
|
||||
return NULL;
|
||||
}
|
||||
if (!set_rule_owner(rule, "")) {
|
||||
filter_rule_free(rule);
|
||||
filter_rule_list_free(list);
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
if (!filter_rule_list_add(list, rule)) {
|
||||
filter_rule_free(rule);
|
||||
filter_rule_list_free(list);
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
if (cvs_exclude && !cvs_rule_list_append(list)) {
|
||||
filter_rule_list_free(list);
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/* ---- Per-directory .rsync-filter files ---- */
|
||||
|
||||
FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bool* exists,
|
||||
char* err, size_t err_size) {
|
||||
if (err && err_size > 0)
|
||||
err[0] = '\0';
|
||||
if (exists)
|
||||
*exists = false;
|
||||
char* filter_path = path_cat(dir_path, ".rsync-filter");
|
||||
if (!filter_path) {
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
FILE* fp = fopen(filter_path, "r");
|
||||
free(filter_path);
|
||||
if (!fp) {
|
||||
if (errno == ENOENT || errno == ENOTDIR)
|
||||
return filter_rule_list_create();
|
||||
char* escaped_dir = output_escape(dir_path, log_get_8_bit_output());
|
||||
log_message(LOG_LEVEL_WARNING, "Could not read .rsync-filter in %s: %s",
|
||||
escaped_dir ? escaped_dir : "<allocation failed>", strerror(errno));
|
||||
free(escaped_dir);
|
||||
return filter_rule_list_create();
|
||||
}
|
||||
if (exists)
|
||||
*exists = true;
|
||||
FilterRuleList* list = filter_rule_list_create();
|
||||
if (!list) {
|
||||
fclose(fp);
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
return NULL;
|
||||
}
|
||||
char* line = NULL;
|
||||
size_t line_cap = 0;
|
||||
bool ok = true;
|
||||
while (true) {
|
||||
ssize_t n = utils_getdelim_bounded(fp, &line, &line_cap, '\n', UTILS_MAX_LINE_LEN);
|
||||
if (n < 0) {
|
||||
if (errno == EFBIG) {
|
||||
snprintf(err, err_size, "line in .rsync-filter exceeds %d bytes", (int)UTILS_MAX_LINE_LEN);
|
||||
} else {
|
||||
snprintf(err, err_size, "error reading .rsync-filter: %s", strerror(errno));
|
||||
}
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (n == 0)
|
||||
break;
|
||||
const char* p = line;
|
||||
while (*p == ' ' || *p == '\t')
|
||||
p++;
|
||||
if (*p == '\0' || *p == '\n' || *p == '\r' || *p == '#')
|
||||
continue;
|
||||
FilterRule* rule = filter_rule_parse(p, err, err_size);
|
||||
if (!rule) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (!set_rule_owner(rule, owner_rel)) {
|
||||
filter_rule_free(rule);
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (!filter_rule_list_add(list, rule)) {
|
||||
filter_rule_free(rule);
|
||||
snprintf(err, err_size, "memory allocation failed");
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
free(line);
|
||||
fclose(fp);
|
||||
if (!ok) {
|
||||
filter_rule_list_free(list);
|
||||
return NULL;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/* ---- Rule matching ---- */
|
||||
|
||||
/* Match a pattern that contains '/' (non-anchored) against the end of the
|
||||
* relative path, starting at any path-component boundary. */
|
||||
static bool glob_suffix_match(const char* pattern, const char* str) {
|
||||
if (glob_match(pattern, str))
|
||||
return true;
|
||||
for (const char* slash = strchr(str, '/'); slash; slash = strchr(slash + 1, '/')) {
|
||||
if (glob_match(pattern, slash + 1))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static FilterAction rule_matches(const FilterRule* rule, const char* rel_path, const char* leaf,
|
||||
bool is_dir) {
|
||||
if (!rule || !rule->pattern)
|
||||
return FILTER_ACTION_NONE;
|
||||
if (rule->dir_only && !is_dir)
|
||||
return FILTER_ACTION_NONE;
|
||||
/* A rule applies only to entries below its owner directory. */
|
||||
const char* rel2 = rel_path;
|
||||
if (rule->owner && rule->owner[0] != '\0') {
|
||||
size_t owner_len = strlen(rule->owner);
|
||||
if (strncmp(rule->owner, rel_path, owner_len) != 0)
|
||||
return FILTER_ACTION_NONE;
|
||||
if (rel_path[owner_len] != '/')
|
||||
return FILTER_ACTION_NONE;
|
||||
rel2 = rel_path + owner_len + 1;
|
||||
}
|
||||
if (rel2[0] == '\0')
|
||||
return FILTER_ACTION_NONE;
|
||||
bool matched;
|
||||
if (rule->anchored) {
|
||||
matched = glob_match(rule->pattern, rel2);
|
||||
} else if (strchr(rule->pattern, '/') != NULL) {
|
||||
matched = glob_suffix_match(rule->pattern, rel2);
|
||||
} else {
|
||||
matched = glob_match(rule->pattern, leaf);
|
||||
}
|
||||
return matched ? rule->action : FILTER_ACTION_NONE;
|
||||
}
|
||||
|
||||
FilterAction filter_rules_apply(const FilterRuleList* list, const char* rel_path, const char* leaf,
|
||||
bool is_dir) {
|
||||
if (!list)
|
||||
return FILTER_ACTION_NONE;
|
||||
for (int i = 0; i < list->count; i++) {
|
||||
FilterAction action = rule_matches(list->items[i], rel_path, leaf, is_dir);
|
||||
if (action != FILTER_ACTION_NONE)
|
||||
return action;
|
||||
}
|
||||
return FILTER_ACTION_NONE;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
#ifndef FILTER_H
|
||||
#define FILTER_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/* rsync-style filter rule engine (client-side file selection).
|
||||
*
|
||||
* Supported rule syntax (documented subset):
|
||||
* [+|-] [anchored '/' prefix] pattern [trailing '/' for dir-only]
|
||||
*
|
||||
* "+ PATTERN" include rule (first match wins)
|
||||
* "- PATTERN" exclude rule
|
||||
* "PATTERN" implicit exclude rule (rsync default)
|
||||
* "include PATTERN" / "exclude PATTERN" word forms
|
||||
* leading '/' after the +/- anchors the pattern to its owner directory
|
||||
* (the transfer root for command-line/-C rules, the directory that
|
||||
* contains a .rsync-filter file for per-directory rules)
|
||||
* a trailing '/' makes the rule match directories only
|
||||
*
|
||||
* Rejected explicitly (no silent no-ops): the rsync merge/dir-merge/list-clear
|
||||
* shorthands written as a rule that starts with ':' or '.' or '!', the
|
||||
* merge/dir-merge/hide/show/protect/risk/clear words, and every include/exclude
|
||||
* rule modifier other than '/' (! C s r p x). The pattern must be separated
|
||||
* from +/- by a space (or a single '/' anchor), exactly like rsync's
|
||||
* "-s foo"/"-p ..." modifier syntax is refused.
|
||||
*/
|
||||
|
||||
typedef enum {
|
||||
FILTER_ACTION_NONE = 0, /* no rule matched */
|
||||
FILTER_ACTION_EXCLUDE = -1,
|
||||
FILTER_ACTION_INCLUDE = 1
|
||||
} FilterAction;
|
||||
|
||||
typedef struct {
|
||||
FilterAction action;
|
||||
bool anchored; /* pattern anchored to the rule's owner directory */
|
||||
bool dir_only; /* pattern had a trailing '/': matches directories only */
|
||||
char* owner; /* owning directory rel path ("" == transfer root) */
|
||||
char* pattern; /* cleaned glob pattern (no leading '/', no trailing '/') */
|
||||
} FilterRule;
|
||||
|
||||
typedef struct {
|
||||
FilterRule** items; /* owned array of rule pointers */
|
||||
int count;
|
||||
int capacity;
|
||||
} FilterRuleList;
|
||||
|
||||
/* Parse a single filter-rule line (no trailing newline required). Returns an
|
||||
* owned rule, or NULL on unsupported/invalid syntax with a message in `err`. */
|
||||
FilterRule* filter_rule_parse(const char* line, char* err, size_t err_size);
|
||||
void filter_rule_free(FilterRule* rule);
|
||||
|
||||
FilterRuleList* filter_rule_list_create(void);
|
||||
/* Append a fully-parsed rule (takes ownership). Returns false on OOM. */
|
||||
bool filter_rule_list_add(FilterRuleList* list, FilterRule* rule);
|
||||
/* Parse `line` and append it. Returns false and fills `err` on bad syntax. */
|
||||
bool filter_rule_list_parse_append(FilterRuleList* list, const char* line, char* err,
|
||||
size_t err_size);
|
||||
void filter_rule_list_free(FilterRuleList* list);
|
||||
|
||||
/* Build the command-line filter set: `rule_texts` (--filter=RULE in the order
|
||||
* given, 0..rule_count) followed by the -C CVS default excludes when
|
||||
* cvs_exclude is true. All rules are owned by "" (the transfer root).
|
||||
* Returns NULL on unsupported rule text (message in `err`). */
|
||||
FilterRuleList* filter_base_build(const char* const* rule_texts, int rule_count, bool cvs_exclude,
|
||||
char* err, size_t err_size);
|
||||
|
||||
/* Read "<dir_path>/.rsync-filter" and return its rules, each owned by
|
||||
* `owner_rel`. A missing file yields an empty list with *exists=false; an
|
||||
* unreadable file is treated as missing. Returns NULL only on parse or
|
||||
* allocation failure (message in `err`). */
|
||||
FilterRuleList* filter_file_read(const char* dir_path, const char* owner_rel, bool* exists,
|
||||
char* err, size_t err_size);
|
||||
|
||||
/* Evaluate an entry against one ordered rule list. Returns FILTER_ACTION_NONE
|
||||
* when no rule matched, otherwise the first matching rule's action.
|
||||
* `rel_path` is the entry's path relative to the transfer root ("" == root),
|
||||
* `leaf` its final name, `is_dir` whether it is a directory. */
|
||||
FilterAction filter_rules_apply(const FilterRuleList* list, const char* rel_path, const char* leaf,
|
||||
bool is_dir);
|
||||
|
||||
#endif
|
||||
@@ -1,127 +0,0 @@
|
||||
#include "hardlink.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "log.h"
|
||||
#include "utils.h"
|
||||
|
||||
/* ---- Sender-side detection table ---- */
|
||||
|
||||
HardLinkTable* hardlink_table_create(void) {
|
||||
HardLinkTable* table = calloc(1, sizeof(HardLinkTable));
|
||||
if (!table)
|
||||
return NULL;
|
||||
if (mtx_init(&table->mutex, mtx_plain) != thrd_success) {
|
||||
free(table);
|
||||
return NULL;
|
||||
}
|
||||
table->next_gid = 1;
|
||||
return table;
|
||||
}
|
||||
|
||||
static void hardlink_item_destroy(HardLinkItem* item) {
|
||||
if (!item)
|
||||
return;
|
||||
free(item->first_path);
|
||||
item->first_path = NULL;
|
||||
}
|
||||
|
||||
void hardlink_table_destroy(HardLinkTable* table) {
|
||||
if (!table)
|
||||
return;
|
||||
for (size_t i = 0; i < table->count; i++)
|
||||
hardlink_item_destroy(&table->items[i]);
|
||||
free(table->items);
|
||||
table->items = NULL;
|
||||
table->count = 0;
|
||||
table->capacity = 0;
|
||||
mtx_destroy(&table->mutex);
|
||||
free(table);
|
||||
}
|
||||
|
||||
static HardLinkItem* hardlink_table_find_locked(HardLinkTable* table, dev_t dev, ino_t ino) {
|
||||
for (size_t i = 0; i < table->count; i++) {
|
||||
if (table->items[i].dev == dev && table->items[i].ino == ino)
|
||||
return &table->items[i];
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static bool hardlink_table_add_locked(HardLinkTable* table, dev_t dev, ino_t ino, const char* path,
|
||||
int gid, HardLinkItem** out) {
|
||||
if (table->count == table->capacity) {
|
||||
size_t new_capacity = table->capacity == 0 ? 8 : table->capacity * 2;
|
||||
if (new_capacity < table->capacity)
|
||||
return false;
|
||||
HardLinkItem* grown = realloc(table->items, new_capacity * sizeof(HardLinkItem));
|
||||
if (!grown)
|
||||
return false;
|
||||
table->items = grown;
|
||||
table->capacity = new_capacity;
|
||||
}
|
||||
HardLinkItem* item = &table->items[table->count];
|
||||
char* dup = str_dup(path);
|
||||
if (!dup)
|
||||
return false;
|
||||
memset(item, 0, sizeof(*item));
|
||||
item->dev = dev;
|
||||
item->ino = ino;
|
||||
item->gid = gid;
|
||||
item->first_path = dup;
|
||||
table->count++;
|
||||
*out = item;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool hardlink_table_assign(HardLinkTable* table, const char* wire_path, dev_t dev, ino_t ino,
|
||||
int* gid, bool* is_first, char** first_path_out) {
|
||||
if (!table || !wire_path || !gid || !is_first || !first_path_out)
|
||||
return false;
|
||||
if (mtx_lock(&table->mutex) != thrd_success)
|
||||
return false;
|
||||
bool ok = true;
|
||||
const HardLinkItem* item = hardlink_table_find_locked(table, dev, ino);
|
||||
int next_gid;
|
||||
if (item) {
|
||||
*is_first = false;
|
||||
char* dup = str_dup(item->first_path);
|
||||
if (!dup) {
|
||||
ok = false;
|
||||
} else {
|
||||
*gid = item->gid;
|
||||
*first_path_out = dup;
|
||||
}
|
||||
next_gid = -1;
|
||||
} else {
|
||||
if (table->next_gid <= 0) {
|
||||
ok = false;
|
||||
next_gid = -1;
|
||||
} else {
|
||||
next_gid = table->next_gid;
|
||||
HardLinkItem* created = NULL;
|
||||
if (!hardlink_table_add_locked(table, dev, ino, wire_path, next_gid, &created)) {
|
||||
ok = false;
|
||||
} else {
|
||||
char* dup = str_dup(wire_path);
|
||||
if (!dup) {
|
||||
hardlink_item_destroy(created);
|
||||
table->count--;
|
||||
ok = false;
|
||||
} else {
|
||||
*is_first = true;
|
||||
*gid = next_gid;
|
||||
*first_path_out = dup;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ok && next_gid > 0)
|
||||
table->next_gid++;
|
||||
mtx_unlock(&table->mutex);
|
||||
if (!ok) {
|
||||
log_message(LOG_LEVEL_ERROR, "memory allocation failed while detecting hard links");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
#ifndef HARDLINK_H
|
||||
#define HARDLINK_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <sys/types.h>
|
||||
#include <threads.h>
|
||||
|
||||
/*
|
||||
* --hard-links / -H support.
|
||||
*
|
||||
* Sender side: a HardLinkTable detects regular files on the source that share
|
||||
* an (st_dev, st_ino) identity (a `cp -al`-style hard-linked tree) and assigns
|
||||
* each distinct inode a stable, run-local link-group id. The first member
|
||||
* encountered carries the file data; every later member is marked as a sibling
|
||||
* (no data payload) that the receiver creates as a hard link to the first
|
||||
* member's destination file. Grouping is scoped by st_dev so inode reuse
|
||||
* across different filesystems is never conflated. The table is mutex-guarded
|
||||
* so the parallel (multi-threaded) scanner COULD share one instance across its
|
||||
* worker threads; the first-thread-to-call designates the data-carrying member,
|
||||
* which is safe because a hard-link group's members are byte-identical. (In
|
||||
* practice the sender forces the sequential scanner whenever -H is on; the
|
||||
* mutex guards the shared table for any path that supplies one.)
|
||||
*
|
||||
* ORDERING (why there is no receiver-side handshake): the receiver stores every
|
||||
* file - including a hard-link group's first member - through a SINGLE writer
|
||||
* thread draining a single FIFO queue driven by a single receive thread, so
|
||||
* wire order == write order and every sibling is processed AFTER its group's
|
||||
* first member. The sender additionally forces the sequential scanner with -H
|
||||
* so the first-member frame always precedes its siblings on the wire. Sibling
|
||||
* install therefore needs no present/wait registry: it hard-links to the first
|
||||
* member (or copies it) knowing that path is already installed - or that, if
|
||||
* the first member was skipped (already up to date), its destination still
|
||||
* exists. This guarantee is REQUIRED; do not introduce a concurrent
|
||||
* multi-writer receiver for -H without re-adding an ordering mechanism.
|
||||
*/
|
||||
|
||||
typedef struct HardLinkItem {
|
||||
dev_t dev;
|
||||
ino_t ino;
|
||||
int gid;
|
||||
char* first_path; /* wire path of the group's data-carrying first member */
|
||||
} HardLinkItem;
|
||||
|
||||
typedef struct HardLinkTable {
|
||||
mtx_t mutex;
|
||||
HardLinkItem* items;
|
||||
size_t count;
|
||||
size_t capacity;
|
||||
int next_gid;
|
||||
} HardLinkTable;
|
||||
|
||||
HardLinkTable* hardlink_table_create(void);
|
||||
void hardlink_table_destroy(HardLinkTable* table);
|
||||
|
||||
/* Assign a link-group id to the regular file at `wire_path` with (dev, ino).
|
||||
* On the first encounter the file becomes the group's first (data-carrying)
|
||||
* member (*is_first = true) and a fresh gid is allocated. On a later member
|
||||
* *is_first = false and *first_path_out is set to a malloc'd copy of the first
|
||||
* member's wire path (the caller stores it and owns it; on the first member
|
||||
* path the returned *first_path_out is a malloc'd copy of its own wire path).
|
||||
* Returns false on allocation failure (transfer should abort). */
|
||||
bool hardlink_table_assign(HardLinkTable* table, const char* wire_path, dev_t dev, ino_t ino,
|
||||
int* gid, bool* is_first, char** first_path_out);
|
||||
|
||||
#endif
|
||||
@@ -1,748 +0,0 @@
|
||||
#include "identity.h"
|
||||
#include "log.h"
|
||||
#include "utils.h"
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <grp.h>
|
||||
#include <limits.h>
|
||||
#include <pwd.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* The active identity snapshot lives in a per-process global. The TCP server
|
||||
* forks one child process per connection, so a connection never shares this
|
||||
* with another; within a connection the multithreaded receiver reads it without
|
||||
* mutation. This is what lets the fd-relative metadata path consult the
|
||||
* negotiated policy without threading a Config through every write helper. */
|
||||
typedef struct {
|
||||
bool numeric_ids;
|
||||
bool chown_uid_set;
|
||||
int32_t chown_uid;
|
||||
bool chown_gid_set;
|
||||
int32_t chown_gid;
|
||||
IdentityMap* usermap;
|
||||
int usermap_count;
|
||||
IdentityMap* groupmap;
|
||||
int groupmap_count;
|
||||
/* --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. */
|
||||
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;
|
||||
int32_t copy_as_uid;
|
||||
int32_t copy_as_gid;
|
||||
bool set;
|
||||
} IdentityActive;
|
||||
|
||||
static IdentityActive g_identity;
|
||||
|
||||
static void identity_active_reset(void) {
|
||||
free(g_identity.usermap);
|
||||
free(g_identity.groupmap);
|
||||
g_identity.usermap = NULL;
|
||||
g_identity.groupmap = NULL;
|
||||
g_identity.usermap_count = 0;
|
||||
g_identity.groupmap_count = 0;
|
||||
g_identity.numeric_ids = false;
|
||||
g_identity.chown_uid_set = false;
|
||||
g_identity.chown_uid = 0;
|
||||
g_identity.chown_gid_set = false;
|
||||
g_identity.chown_gid = 0;
|
||||
g_identity.super_mode = SUPER_MODE_AUTO;
|
||||
g_identity.copy_as_set = false;
|
||||
g_identity.copy_as_uid = 0;
|
||||
g_identity.copy_as_gid = 0;
|
||||
g_identity.set = false;
|
||||
}
|
||||
|
||||
void identity_clear_active(void) {
|
||||
identity_active_reset();
|
||||
}
|
||||
|
||||
bool identity_set_active(const Config* config) {
|
||||
identity_active_reset();
|
||||
if (!config)
|
||||
return true;
|
||||
g_identity.numeric_ids = config->numeric_ids;
|
||||
g_identity.chown_uid_set = config->chown_uid_set;
|
||||
g_identity.chown_uid = config->chown_uid;
|
||||
g_identity.chown_gid_set = config->chown_gid_set;
|
||||
g_identity.chown_gid = config->chown_gid;
|
||||
g_identity.super_mode = config->super_mode;
|
||||
g_identity.copy_as_set = config->copy_as_set;
|
||||
g_identity.copy_as_uid = config->copy_as_uid;
|
||||
g_identity.copy_as_gid = config->copy_as_gid;
|
||||
if (config->usermap_count > 0) {
|
||||
g_identity.usermap = calloc((size_t)config->usermap_count, sizeof(IdentityMap));
|
||||
if (!g_identity.usermap)
|
||||
goto alloc_failed;
|
||||
memcpy(g_identity.usermap, config->usermap,
|
||||
(size_t)config->usermap_count * sizeof(IdentityMap));
|
||||
g_identity.usermap_count = config->usermap_count;
|
||||
}
|
||||
if (config->groupmap_count > 0) {
|
||||
g_identity.groupmap = calloc((size_t)config->groupmap_count, sizeof(IdentityMap));
|
||||
if (!g_identity.groupmap)
|
||||
goto alloc_failed;
|
||||
memcpy(g_identity.groupmap, config->groupmap,
|
||||
(size_t)config->groupmap_count * sizeof(IdentityMap));
|
||||
g_identity.groupmap_count = config->groupmap_count;
|
||||
}
|
||||
g_identity.set = true;
|
||||
/* A root receiver would honor any client-supplied ownership request (a
|
||||
--usermap/--groupmap/--chown/--copy-as, or raw ids under --numeric-ids).
|
||||
Surface that prominently; a privileged daemon applying arbitrary client
|
||||
ownership is a deliberate, opt-in choice the operator should be aware of. */
|
||||
if (geteuid() == 0)
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"identity mapping active and running as root: client-supplied "
|
||||
"ownership (usermap/groupmap/chown/numeric-ids) will be honored; "
|
||||
"run the daemon as an unprivileged user unless intended");
|
||||
/* --super explicitly requests super-user activities, but FastSync never
|
||||
elevates privileges: when the receiver is not already root the kernel will
|
||||
refuse those confined attempts and each is skipped per entry. Warn exactly
|
||||
once at activation time (never abort) so the operator knows the flag cannot
|
||||
succeed on this host. */
|
||||
if (g_identity.super_mode == SUPER_MODE_ON && geteuid() != 0)
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"--super requested but the receiver is not privileged; super-user "
|
||||
"activities (ownership, device nodes) will be attempted but refused "
|
||||
"by the kernel and skipped per entry");
|
||||
return true;
|
||||
|
||||
alloc_failed:
|
||||
/* Never proceed with a partial (count-left-zero) map: that would silently
|
||||
apply the WRONG ownership policy. Fail closed and let the caller refuse
|
||||
the connection. */
|
||||
log_message(LOG_LEVEL_ERROR, "memory allocation failed while activating identity policy");
|
||||
identity_active_reset();
|
||||
return false;
|
||||
}
|
||||
|
||||
bool privilege_super_permitted(void) {
|
||||
return privilege_super_mode_permitted(g_identity.super_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
|
||||
* it must stay permissive or a group-only chown that a non-root receiver is
|
||||
* allowed to make would regress. */
|
||||
return mode != SUPER_MODE_OFF;
|
||||
}
|
||||
|
||||
bool identity_active_enabled(void) {
|
||||
/* numeric_ids is included: this set only gates identity_apply_ownership,
|
||||
which runs only when metadata is present (a -M/--preserve transfer). A
|
||||
standalone --numeric-ids (no ownership-affecting flag) carries no
|
||||
metadata, never reaches identity_apply_ownership, and therefore correctly
|
||||
stays inert; combined with -M it activates raw-id application. --super /
|
||||
--no-super does NOT enable ownership: it only permits or forbids the
|
||||
already-requested super-user activities, so a --super with no explicit
|
||||
identity flag must never silently apply client-chosen ownership. */
|
||||
return g_identity.set &&
|
||||
(g_identity.numeric_ids || g_identity.chown_uid_set || g_identity.chown_gid_set ||
|
||||
g_identity.usermap_count > 0 || g_identity.groupmap_count > 0 || g_identity.copy_as_set);
|
||||
}
|
||||
|
||||
bool identity_ownership_requested(const Config* config) {
|
||||
if (!config)
|
||||
return false;
|
||||
/* Every value that makes the receiver act on a client-chosen owner, plus an
|
||||
* explicit --super (super-user device-node activities). Pure config, so the
|
||||
* daemon gate can evaluate it before identity_set_active(). */
|
||||
return config->numeric_ids || config->chown_uid_set || config->chown_gid_set ||
|
||||
config->usermap_count > 0 || config->groupmap_count > 0 || config->copy_as_set ||
|
||||
config->fake_super || config->super_mode == SUPER_MODE_ON;
|
||||
}
|
||||
|
||||
bool identity_copy_as_active(void) {
|
||||
return g_identity.set && g_identity.copy_as_set;
|
||||
}
|
||||
|
||||
bool identity_copy_as_refused(const Config* config) {
|
||||
if (!config || !config->copy_as_set)
|
||||
return false;
|
||||
/* The safe-subset --copy-as needs a privileged (root) receiver, and an
|
||||
* operator/--no-super veto forbids the ownership change even for root. This
|
||||
* is deliberately a pure function of the config and the current effective uid
|
||||
* (never the active snapshot) because the server evaluates it at the
|
||||
* pre-STATUS_OK config gate, before identity_set_active() has run. */
|
||||
return geteuid() != 0 || config->super_mode == SUPER_MODE_OFF;
|
||||
}
|
||||
|
||||
bool identity_wire_valid(const Config* config) {
|
||||
if (!config)
|
||||
return false;
|
||||
if (config->usermap_count < 0 || config->usermap_count > MAX_IDENTITY_MAP ||
|
||||
config->groupmap_count < 0 || config->groupmap_count > MAX_IDENTITY_MAP)
|
||||
return false;
|
||||
if (config->chown_uid_set && config->chown_uid < IDENTITY_MATCH_ANY)
|
||||
return false;
|
||||
if (config->chown_gid_set && config->chown_gid < IDENTITY_MATCH_ANY)
|
||||
return false;
|
||||
for (int i = 0; i < config->usermap_count; i++) {
|
||||
if (config->usermap[i].from < IDENTITY_MATCH_ANY || config->usermap[i].to < IDENTITY_CURRENT)
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < config->groupmap_count; i++) {
|
||||
if (config->groupmap[i].from < IDENTITY_MATCH_ANY || config->groupmap[i].to < IDENTITY_CURRENT)
|
||||
return false;
|
||||
}
|
||||
/* Defense-in-depth: a --copy-as block must never carry a negative (sentinel)
|
||||
* id into the ownership path. receive_copy_as_options already rejects them,
|
||||
* but identity_wire_valid is the shared validation used by both the receiver
|
||||
* and unit tests, so re-assert it here. */
|
||||
if (config->copy_as_set && (config->copy_as_uid < 0 || config->copy_as_gid < 0))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---- CLI-time name/number resolution ---- */
|
||||
|
||||
/* Parse a single FROM/TO token into an int32 id. Returns 0 on success, -1 on a
|
||||
* malformed or unresolvable token. When is_group, name lookups use the group
|
||||
* database; otherwise the user database. A `*` token returns IDENTITY_MATCH_ANY
|
||||
* / IDENTITY_CURRENT (the same -1 value, disambiguated by the caller's
|
||||
* position). An `@`-prefixed or bare-decimal token is a numeric id. */
|
||||
static int identity_resolve_token(const char* token, bool is_group, int32_t* out) {
|
||||
if (!token || *token == '\0')
|
||||
return -1;
|
||||
if (strcmp(token, "*") == 0) {
|
||||
*out = IDENTITY_MATCH_ANY;
|
||||
return 0;
|
||||
}
|
||||
const char* num = (token[0] == '@') ? token + 1 : token;
|
||||
if (*num != '\0') {
|
||||
bool all_digits = true;
|
||||
for (const char* p = num; *p; p++)
|
||||
if (*p < '0' || *p > '9')
|
||||
all_digits = false;
|
||||
if (all_digits) {
|
||||
char* endptr = NULL;
|
||||
errno = 0;
|
||||
long val = strtol(num, &endptr, 10);
|
||||
if (errno == 0 && endptr && *endptr == '\0' && val >= 0 && val <= INT32_MAX) {
|
||||
*out = (int32_t)val;
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
/* A name (or a name-like numeric that failed strict numeric parse). */
|
||||
if (is_group) {
|
||||
struct group* gr = getgrnam(token);
|
||||
if (!gr)
|
||||
return -1;
|
||||
*out = (int32_t)gr->gr_gid;
|
||||
return 0;
|
||||
}
|
||||
struct passwd* pw = getpwnam(token);
|
||||
if (!pw)
|
||||
return -1;
|
||||
*out = (int32_t)pw->pw_uid;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int identity_append_rule(IdentityMap** map, int* count, int32_t from, int32_t to) {
|
||||
if (*count >= MAX_IDENTITY_MAP)
|
||||
return -1;
|
||||
IdentityMap* grown = realloc(*map, (size_t)(*count + 1) * sizeof(IdentityMap));
|
||||
if (!grown)
|
||||
return -1;
|
||||
*map = grown;
|
||||
(*map)[*count].from = from;
|
||||
(*map)[*count].to = to;
|
||||
(*count)++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int identity_parse_map(Config* config, const char* value, bool is_group) {
|
||||
if (!config || !value || *value == '\0') {
|
||||
log_message(LOG_LEVEL_ERROR, "%smap requires a value", is_group ? "--group" : "--user");
|
||||
return -1;
|
||||
}
|
||||
char* list = str_dup(value);
|
||||
if (!list)
|
||||
return -1;
|
||||
const char* optname = is_group ? "--groupmap" : "--usermap";
|
||||
char* saveptr = NULL;
|
||||
for (char* rule = strtok_r(list, ",", &saveptr); rule; rule = strtok_r(NULL, ",", &saveptr)) {
|
||||
char* colon = strchr(rule, ':');
|
||||
if (!colon || colon == rule) {
|
||||
/* Log before freeing: `rule` points into the str_dup'd list. */
|
||||
log_message(LOG_LEVEL_ERROR, "%s rules must be FROM:TO (got '%s')", optname, rule);
|
||||
free(list);
|
||||
return -1;
|
||||
}
|
||||
*colon = '\0';
|
||||
char* from_token = rule;
|
||||
char* to_token = colon + 1;
|
||||
if (*to_token == '\0') {
|
||||
free(list);
|
||||
log_message(LOG_LEVEL_ERROR, "%s rule 'FROM:' is missing the TO value (got '%s')", optname,
|
||||
value);
|
||||
return -1;
|
||||
}
|
||||
int32_t from_id, to_id;
|
||||
if (identity_resolve_token(from_token, is_group, &from_id) != 0 ||
|
||||
identity_resolve_token(to_token, is_group, &to_id) != 0) {
|
||||
free(list);
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"%s could not resolve '%s' (name must exist on the source; use "
|
||||
"@N for a numeric id)",
|
||||
optname, value);
|
||||
return -1;
|
||||
}
|
||||
if (identity_append_rule(is_group ? &config->groupmap : &config->usermap,
|
||||
is_group ? &config->groupmap_count : &config->usermap_count, from_id,
|
||||
to_id) != 0) {
|
||||
free(list);
|
||||
log_message(LOG_LEVEL_ERROR, "%s has too many rules (max %d)", optname, MAX_IDENTITY_MAP);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
free(list);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Split --chown=USER:GROUP on the first UNESCAPED colon, honoring backslash
|
||||
* escapes (a `\:` is a literal colon inside a name; a lone backslash before any
|
||||
* other character is kept verbatim). Both sides are returned as malloc'd
|
||||
* strings (the absent side is NULL). */
|
||||
static int identity_split_chown(const char* value, char** puser, char** pgroup) {
|
||||
size_t len = strlen(value);
|
||||
char* user = malloc(len + 1);
|
||||
char* group = malloc(len + 1);
|
||||
if (!user || !group) {
|
||||
free(user);
|
||||
free(group);
|
||||
return -1;
|
||||
}
|
||||
const char* p = value;
|
||||
size_t ui = 0;
|
||||
bool split_seen = false;
|
||||
size_t gi = 0;
|
||||
while (*p) {
|
||||
if (*p == '\\' && p[1] == ':') {
|
||||
/* an escaped colon: a literal ':' in the current side's name */
|
||||
if (split_seen)
|
||||
group[gi++] = ':';
|
||||
else
|
||||
user[ui++] = ':';
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
if (*p == ':') {
|
||||
split_seen = true;
|
||||
p++;
|
||||
continue;
|
||||
}
|
||||
if (split_seen)
|
||||
group[gi++] = *p;
|
||||
else
|
||||
user[ui++] = *p;
|
||||
p++;
|
||||
}
|
||||
user[ui] = '\0';
|
||||
group[gi] = '\0';
|
||||
char* u = str_dup(user);
|
||||
char* g = str_dup(group);
|
||||
free(user);
|
||||
free(group);
|
||||
if (!u || !g) {
|
||||
free(u);
|
||||
free(g);
|
||||
return -1;
|
||||
}
|
||||
*puser = u;
|
||||
*pgroup = g;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int identity_parse_chown(Config* config, const char* value) {
|
||||
if (!config || !value || *value == '\0') {
|
||||
log_message(LOG_LEVEL_ERROR, "--chown requires a value (USER:GROUP, USER, or :GROUP)");
|
||||
return -1;
|
||||
}
|
||||
/* Reject more than one UNESCAPED colon (a name or group may not contain an
|
||||
* unescaped ':' in the spec). The scan is escape-aware: a `\:` is a literal
|
||||
* colon inside a name, not a field separator. */
|
||||
int colons = 0;
|
||||
bool saw_colon = false;
|
||||
const char* p = value;
|
||||
while (*p) {
|
||||
if (*p == '\\' && p[1] == ':') {
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
if (*p == ':') {
|
||||
colons++;
|
||||
saw_colon = true;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
if (colons > 1) {
|
||||
log_message(LOG_LEVEL_ERROR, "--chown must have at most one ':' (got '%s')", value);
|
||||
return -1;
|
||||
}
|
||||
|
||||
char *user = NULL, *group = NULL;
|
||||
if (identity_split_chown(value, &user, &group) != 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "memory allocation failed for --chown");
|
||||
return -1;
|
||||
}
|
||||
int ret = 0;
|
||||
if (!saw_colon) {
|
||||
/* --chown=USER: owner only. */
|
||||
if (*user == '\0') {
|
||||
log_message(LOG_LEVEL_ERROR, "--chown requires a user or group (got '%s')", value);
|
||||
ret = -1;
|
||||
} else if (identity_resolve_token(user, false, &config->chown_uid) != 0) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--chown could not resolve user '%s' (use a name that exists "
|
||||
"on the source, '*', or @N)",
|
||||
value);
|
||||
ret = -1;
|
||||
} else {
|
||||
config->chown_uid_set = true;
|
||||
}
|
||||
} else {
|
||||
/* --chown=USER:GROUP, --chown=:GROUP, --chown=USER: */
|
||||
if (*user != '\0') {
|
||||
if (identity_resolve_token(user, false, &config->chown_uid) != 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "--chown could not resolve user '%s'", value);
|
||||
ret = -1;
|
||||
goto done;
|
||||
}
|
||||
config->chown_uid_set = true;
|
||||
}
|
||||
if (*group != '\0') {
|
||||
if (identity_resolve_token(group, true, &config->chown_gid) != 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "--chown could not resolve group '%s'", value);
|
||||
ret = -1;
|
||||
goto done;
|
||||
}
|
||||
config->chown_gid_set = true;
|
||||
}
|
||||
if (!*user && !*group) {
|
||||
log_message(LOG_LEVEL_ERROR, "--chown must set a user, a group, or both (got '%s')", value);
|
||||
ret = -1;
|
||||
}
|
||||
}
|
||||
done:
|
||||
free(user);
|
||||
free(group);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* uid_t/gid_t are unsigned and may hold a value wider than the signed int32 the
|
||||
* wire (and the identity policy) uses. Reject such an id instead of truncating
|
||||
* it to an out-of-range (possibly negative sentinel) value. */
|
||||
static bool identity_id_fits_int32(unsigned long id) {
|
||||
return id <= (unsigned long)INT32_MAX;
|
||||
}
|
||||
|
||||
/* Resolve one --copy-as id token. A '*' token means the caller's current
|
||||
* effective uid (user) or gid (group). Returns 0 on success. On failure sets
|
||||
* *overflow when a '*' id was wider than int32 so the caller can log the
|
||||
* specific message; otherwise the token was simply unresolvable. */
|
||||
static int identity_resolve_copy_as_id(const char* token, bool is_group, int32_t* out,
|
||||
bool* overflow) {
|
||||
*overflow = false;
|
||||
if (strcmp(token, "*") == 0) {
|
||||
unsigned long current = is_group ? (unsigned long)getegid() : (unsigned long)geteuid();
|
||||
if (!identity_id_fits_int32(current)) {
|
||||
*overflow = true;
|
||||
return -1;
|
||||
}
|
||||
*out = (int32_t)current;
|
||||
return 0;
|
||||
}
|
||||
return identity_resolve_token(token, is_group, out);
|
||||
}
|
||||
|
||||
int identity_parse_copy_as(Config* config, const char* value) {
|
||||
if (!config || !value || *value == '\0') {
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as requires USER[:GROUP]");
|
||||
return -1;
|
||||
}
|
||||
/* --copy-as=USER[:GROUP] is the whole grammar: at most one field separator.
|
||||
* (Unlike --chown there is no escaped-colon form; a name containing ':' is
|
||||
* simply not expressible, and the extra colon is a clear parse error.) */
|
||||
int colons = 0;
|
||||
for (const char* p = value; *p; p++)
|
||||
if (*p == ':')
|
||||
colons++;
|
||||
if (colons > 1) {
|
||||
char* escaped = output_escape(value, false);
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as must be USER[:GROUP] (got '%s')",
|
||||
escaped ? escaped : "<allocation failed>");
|
||||
free(escaped);
|
||||
return -1;
|
||||
}
|
||||
|
||||
char* spec = str_dup(value);
|
||||
if (!spec) {
|
||||
log_message(LOG_LEVEL_ERROR, "memory allocation failed for --copy-as");
|
||||
return -1;
|
||||
}
|
||||
const char* user_token = spec;
|
||||
const char* group_token = NULL;
|
||||
char* colon = strchr(spec, ':');
|
||||
if (colon) {
|
||||
*colon = '\0';
|
||||
group_token = colon + 1;
|
||||
}
|
||||
|
||||
/* The spec is untrusted user input echoed back in error paths: escape it once
|
||||
* (8-bit-safe) so a control byte cannot forge a log line. */
|
||||
char* escaped_spec = output_escape(value, false);
|
||||
const char* shown = escaped_spec ? escaped_spec : "<allocation failed>";
|
||||
int ret = -1;
|
||||
|
||||
if (*user_token == '\0') {
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as is missing the user (got '%s')", shown);
|
||||
goto done;
|
||||
}
|
||||
bool overflow = false;
|
||||
int32_t uid;
|
||||
if (identity_resolve_copy_as_id(user_token, false, &uid, &overflow) != 0) {
|
||||
if (overflow)
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as: current user id %lu exceeds INT32_MAX",
|
||||
(unsigned long)geteuid());
|
||||
else
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--copy-as could not resolve user (use a name that exists on the "
|
||||
"source, '*', or @N): %s",
|
||||
shown);
|
||||
goto done;
|
||||
}
|
||||
|
||||
int32_t gid;
|
||||
if (group_token) {
|
||||
if (*group_token == '\0') {
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as group is empty (got '%s')", shown);
|
||||
goto done;
|
||||
}
|
||||
if (identity_resolve_copy_as_id(group_token, true, &gid, &overflow) != 0) {
|
||||
if (overflow)
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as: current group id %lu exceeds INT32_MAX",
|
||||
(unsigned long)getegid());
|
||||
else
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as could not resolve group (got '%s')", shown);
|
||||
goto done;
|
||||
}
|
||||
} else {
|
||||
/* Group omitted: use the user's primary gid. A numeric id with no local
|
||||
* passwd entry has no primary gid to look up, so fall back to gid == uid
|
||||
* (the rsync-style numeric convention; documented divergence). */
|
||||
struct passwd* pw = getpwuid((uid_t)uid);
|
||||
if (pw) {
|
||||
if (!identity_id_fits_int32((unsigned long)pw->pw_gid)) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--copy-as: primary group id %lu for the requested user exceeds INT32_MAX",
|
||||
(unsigned long)pw->pw_gid);
|
||||
goto done;
|
||||
}
|
||||
gid = (int32_t)pw->pw_gid;
|
||||
} else {
|
||||
gid = uid;
|
||||
}
|
||||
}
|
||||
/* The group-default and gid==uid fallbacks must never store a negative
|
||||
* (sentinel) value; the explicit numeric path is already capped by
|
||||
* identity_resolve_token. */
|
||||
if (uid < 0 || gid < 0) {
|
||||
log_message(LOG_LEVEL_ERROR, "--copy-as resolved id does not fit in int32 (got '%s')", shown);
|
||||
goto done;
|
||||
}
|
||||
|
||||
config->copy_as_set = true;
|
||||
config->copy_as_uid = uid;
|
||||
config->copy_as_gid = gid;
|
||||
/* Ownership application needs the metadata path (the source uid/gid must be
|
||||
* transmitted); imply it exactly like --chown/--usermap/--groupmap. */
|
||||
config->use_metadata = true;
|
||||
ret = 0;
|
||||
|
||||
done:
|
||||
free(escaped_spec);
|
||||
free(spec);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* ---- Receiver-side ownership application ---- */
|
||||
|
||||
static bool identity_map_lookup(const IdentityMap* map, int count, int32_t source_id,
|
||||
int32_t* out_to) {
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (map[i].from == IDENTITY_MATCH_ANY || map[i].from == source_id) {
|
||||
*out_to = map[i].to;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Resolve the target ownership from the negotiated policy against the entry's
|
||||
* current stat. Shared by the fd (regular file) and no-follow (symlink) apply
|
||||
* paths. Returns false when no side is to be changed. */
|
||||
static bool identity_resolve_targets(const struct stat* st, int32_t source_uid, int32_t source_gid,
|
||||
uid_t* out_uid, gid_t* out_gid) {
|
||||
bool set_uid = false;
|
||||
bool set_gid = false;
|
||||
uid_t uid = 0;
|
||||
gid_t gid = 0;
|
||||
|
||||
/* --copy-as (P7 Wave E) has the highest priority: it forces BOTH the owner
|
||||
* and group of every written entry to the requested ids, beating usermap /
|
||||
* groupmap / --chown / --numeric-ids and the best-effort name lookup. Only
|
||||
* skip when the entry already carries exactly those ids. */
|
||||
if (g_identity.copy_as_set) {
|
||||
uid = (uid_t)g_identity.copy_as_uid;
|
||||
gid = (gid_t)g_identity.copy_as_gid;
|
||||
if (st->st_uid == uid && st->st_gid == gid)
|
||||
return false;
|
||||
*out_uid = uid;
|
||||
*out_gid = gid;
|
||||
return true;
|
||||
}
|
||||
|
||||
int32_t target;
|
||||
if (identity_map_lookup(g_identity.usermap, g_identity.usermap_count, source_uid, &target)) {
|
||||
uid = target == IDENTITY_CURRENT ? geteuid() : (uid_t)target;
|
||||
set_uid = true;
|
||||
} else if (g_identity.chown_uid_set) {
|
||||
uid = g_identity.chown_uid == IDENTITY_CURRENT ? geteuid() : (uid_t)g_identity.chown_uid;
|
||||
set_uid = true;
|
||||
} else if (g_identity.numeric_ids) {
|
||||
uid = (uid_t)source_uid;
|
||||
set_uid = true;
|
||||
} else {
|
||||
/* Best-effort name mapping against the receiver's own database: if the
|
||||
* transmitted (numeric) id resolves to a name present on this machine,
|
||||
* re-resolve it. On a shared-account host this is the identity operation;
|
||||
* when the id has no name here, the user side is left alone. */
|
||||
struct passwd* pw = getpwuid((uid_t)source_uid);
|
||||
if (pw) {
|
||||
const struct passwd* mapped = getpwnam(pw->pw_name);
|
||||
if (mapped) {
|
||||
uid = mapped->pw_uid;
|
||||
set_uid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (identity_map_lookup(g_identity.groupmap, g_identity.groupmap_count, source_gid, &target)) {
|
||||
gid = target == IDENTITY_CURRENT ? getegid() : (gid_t)target;
|
||||
set_gid = true;
|
||||
} else if (g_identity.chown_gid_set) {
|
||||
gid = g_identity.chown_gid == IDENTITY_CURRENT ? getegid() : (gid_t)g_identity.chown_gid;
|
||||
set_gid = true;
|
||||
} else if (g_identity.numeric_ids) {
|
||||
gid = (gid_t)source_gid;
|
||||
set_gid = true;
|
||||
} else {
|
||||
struct group* gr = getgrgid((gid_t)source_gid);
|
||||
if (gr) {
|
||||
const struct group* mapped = getgrnam(gr->gr_name);
|
||||
if (mapped) {
|
||||
gid = mapped->gr_gid;
|
||||
set_gid = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!set_uid && !set_gid)
|
||||
return false;
|
||||
/* An unset side keeps the file's current id so the other side can change. */
|
||||
if (!set_uid)
|
||||
uid = st->st_uid;
|
||||
if (!set_gid)
|
||||
gid = st->st_gid;
|
||||
/* Only change ownership when the target differs (avoid needless syscalls and
|
||||
* any chance of clearing setuid/setgid on an already-correct entry). */
|
||||
if (st->st_uid == uid && st->st_gid == gid)
|
||||
return false;
|
||||
*out_uid = uid;
|
||||
*out_gid = gid;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void identity_log_chown_failure(const char* what, uid_t uid, gid_t gid) {
|
||||
/* EPERM/EACCES are expected when the receiver is not privileged (e.g. the CI
|
||||
* `nobody` user): warn and continue, never abort the transfer. Any other
|
||||
* error (EIO/EROFS/ENOSPC/...) is a real failure and must not be silently
|
||||
* downgraded to a warning.
|
||||
*
|
||||
* --copy-as is different: the whole point of the flag is that the target
|
||||
* ownership is REQUIRED (the pre-flight gate already refused an unprivileged
|
||||
* receiver). If the chown still fails with EPERM/EACCES (a capability-
|
||||
* restricted root, root-squash, or a read-only mount) the run would be
|
||||
* silently producing the WRONG ownership, so surface it at ERROR. The
|
||||
* caller (identity_apply_ownership*) then reports the ENTRY as failed rather
|
||||
* than as written, which becomes a FILE_SAVE_ERROR and fails the transfer
|
||||
* (fail-fast) instead of reporting overall success with the wrong owner. */
|
||||
if (errno == EPERM || errno == EACCES) {
|
||||
if (identity_copy_as_active())
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"could not apply --copy-as ownership on %s (uid=%ld gid=%ld): %s; "
|
||||
"entry was written with the wrong owner",
|
||||
what, (long)uid, (long)gid, strerror(errno));
|
||||
else
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"could not apply ownership (uid=%ld gid=%ld): %s; leaving as-is", (long)uid,
|
||||
(long)gid, strerror(errno));
|
||||
} else {
|
||||
log_message(LOG_LEVEL_ERROR, "failed to apply ownership on %s (uid=%ld gid=%ld): %s", what,
|
||||
(long)uid, (long)gid, strerror(errno));
|
||||
}
|
||||
}
|
||||
|
||||
bool identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid) {
|
||||
/* Ownership application is OFF unless the client requested an identity flag.
|
||||
* This is the controlled gate: a default (or plain -M) transfer never changes
|
||||
* ownership, byte-for-byte preserving FastSync's existing behavior. --no-super
|
||||
* additionally forbids it even when the receiver is root. */
|
||||
if (!identity_active_enabled() || !privilege_super_permitted() || fd < 0)
|
||||
return true;
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) != 0)
|
||||
return !identity_copy_as_active();
|
||||
uid_t uid;
|
||||
gid_t gid;
|
||||
if (!identity_resolve_targets(&st, source_uid, source_gid, &uid, &gid))
|
||||
return true;
|
||||
if (fchown(fd, uid, gid) != 0) {
|
||||
identity_log_chown_failure("file", uid, gid);
|
||||
/* A required --copy-as ownership that did not land is a per-entry failure;
|
||||
* every other policy stays best-effort (rsync parity). */
|
||||
return !identity_copy_as_active();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
|
||||
int32_t source_gid) {
|
||||
if (!identity_active_enabled() || !privilege_super_permitted() || parent_fd < 0 || !leaf)
|
||||
return true;
|
||||
struct stat st;
|
||||
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0)
|
||||
return !identity_copy_as_active();
|
||||
uid_t uid;
|
||||
gid_t gid;
|
||||
if (!identity_resolve_targets(&st, source_uid, source_gid, &uid, &gid))
|
||||
return true;
|
||||
if (fchownat(parent_fd, leaf, uid, gid, AT_SYMLINK_NOFOLLOW) != 0) {
|
||||
identity_log_chown_failure("no-follow entry", uid, gid);
|
||||
return !identity_copy_as_active();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
#ifndef IDENTITY_H
|
||||
#define IDENTITY_H
|
||||
|
||||
#include "config.h"
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
/*
|
||||
* Identity mapping: --numeric-ids / --usermap / --groupmap / --chown / --copy-as.
|
||||
*
|
||||
* FastSync transmits uid/gid numerically (int32 on the wire) and, by design,
|
||||
* NEVER applies client-supplied ownership unless a user explicitly opts in with
|
||||
* an identity flag below. This module is the controlled, opt-in,
|
||||
* privilege-gated path for applying ownership on the receiver: the wire config
|
||||
* is snapshotted once per connection via identity_set_active() and applied
|
||||
* through an fd-relative fchown() in the receiver's metadata-restore path.
|
||||
*
|
||||
* Because only numeric ids cross the wire, name-based values are resolved to
|
||||
* numbers at CLI parse time using the CLIENT (sender) machine's databases. On
|
||||
* a shared-account source/destination this reproduces rsync's semantics; a
|
||||
* genuinely different destination database is a documented divergence (see
|
||||
* RSYNC_COMPAT.md).
|
||||
*/
|
||||
|
||||
/* Parse one --usermap= / --groupmap= value (comma-separated FROM:TO rules,
|
||||
* first match wins) into config->usermap / config->groupmap. is_group selects
|
||||
* the group tables and name databases. Returns 0 on success, -1 on a
|
||||
* malformed spec or an unresolvable name (never a silent no-op). */
|
||||
int identity_parse_map(Config* config, const char* value, bool is_group);
|
||||
|
||||
/* Parse --chown=USER:GROUP. Supports USER:GROUP, USER (owner only), :GROUP
|
||||
* (group only), '*' (current/root as appropriate) and numeric ids. Returns 0
|
||||
* on success, -1 on a malformed spec / unresolvable name. */
|
||||
int identity_parse_chown(Config* config, const char* value);
|
||||
|
||||
/* Parse --copy-as=USER[:GROUP] (P7 Wave E). USER is resolved with the same
|
||||
* user-database rules as --chown (a name, @N/bare N numeric id, or '*' meaning
|
||||
* the client's current euid); when ':GROUP' is present the group is resolved
|
||||
* with the group database ('*' meaning the client's egid). When the group is
|
||||
* omitted, the user's primary gid is used (getpwuid(uid)->pw_gid); if the
|
||||
* resolved user is a numeric id with no local passwd entry, gid falls back to
|
||||
* uid. On success sets copy_as_set/copy_as_uid/copy_as_gid and forces
|
||||
* metadata transmission (ownership application needs the metadata path).
|
||||
* Returns 0 on success, -1 on a malformed / empty / unresolvable spec (never a
|
||||
* silent no-op). */
|
||||
int identity_parse_copy_as(Config* config, const char* value);
|
||||
|
||||
/* True when a --copy-as request is active but the receiver is not permitted to
|
||||
* perform the privileged ownership application it needs. This is the up-front
|
||||
* refusal predicate: the server rejects the whole transfer at the config
|
||||
* handshake rather than silently ignoring the requested ownership. It is a
|
||||
* pure function of the config mode and the current effective uid (it does NOT
|
||||
* read the active snapshot, so it is valid at the pre-STATUS_OK gate, before
|
||||
* identity_set_active() has run). `super_mode` is the EFFECTIVE mode after any
|
||||
* server-side policy veto. */
|
||||
bool identity_copy_as_refused(const Config* config);
|
||||
|
||||
/* True when the CURRENT per-connection snapshot has a --copy-as active (i.e.
|
||||
* identity_set_active() has run against a config with copy_as_set). The
|
||||
* --fake-super owner replay consults this so a copy-as run never lets the
|
||||
* recorded source owner overwrite the forced target owner. Reads the active
|
||||
* snapshot, so call identity_set_active() first (the receiver does, before any
|
||||
* write). */
|
||||
bool identity_copy_as_active(void);
|
||||
|
||||
/* Receiver-side snapshot of the negotiated identity config. The server calls
|
||||
* identity_set_active() once per connection (before any file write) using the
|
||||
* config received over the wire; the snapshot is a deep copy so the caller may
|
||||
* free its Config immediately. identity_clear_active() releases it.
|
||||
*
|
||||
* Returns true on success. On an allocation failure while deep-copying a
|
||||
* requested usermap/groupmap it logs a LOG_LEVEL_ERROR, leaves the snapshot
|
||||
* cleared (never a partial/wrong policy) and returns false; the caller must
|
||||
* refuse the connection. */
|
||||
bool identity_set_active(const Config* config);
|
||||
void identity_clear_active(void);
|
||||
|
||||
/* True when any ownership-affecting identity option is present in the active
|
||||
* snapshot. Ownership stays OFF ("do not apply") for every transfer that
|
||||
* requests none of them, preserving FastSync's existing behavior. --super /
|
||||
* --no-super alone does NOT enable ownership; an explicit identity flag
|
||||
* (--numeric-ids / --chown / --usermap / --groupmap / --copy-as) is required. */
|
||||
bool identity_active_enabled(void);
|
||||
|
||||
/* Pure, config-only predicate: true when the client requested ANY
|
||||
* client-chosen ownership or super-user activity (--numeric-ids, --chown,
|
||||
* --usermap/--groupmap, --copy-as, --fake-super, or an explicit --super). Used
|
||||
* by the daemon module gate to decide whether a module's per-module opt-in is
|
||||
* required; it never reads the per-connection snapshot. */
|
||||
bool identity_ownership_requested(const Config* config);
|
||||
|
||||
/* Apply the negotiated ownership to an already-written file descriptor.
|
||||
* source_uid/source_gid are the transmitted numeric ids. Resolution order:
|
||||
* --copy-as (highest priority, forces both ids), then a matching
|
||||
* usermap/groupmap rule, then --chown, then --numeric-ids (raw), then a
|
||||
* best-effort name lookup on the receiver's own databases (skipped when the
|
||||
* transmitted id has no name on this system). Only calls fchown() when the
|
||||
* result differs from the current value.
|
||||
*
|
||||
* Returns false ONLY when an active --copy-as ownership application failed: its
|
||||
* forced ownership is REQUIRED, so the caller must treat the entry as failed
|
||||
* rather than reporting success with the wrong owner. For every other identity
|
||||
* policy an fchown EPERM/EACCES is logged and ignored and true is returned
|
||||
* (rsync parity: the transfer must not abort). A no-op when no identity policy
|
||||
* is active returns true. */
|
||||
bool identity_apply_ownership(int fd, int32_t source_uid, int32_t source_gid);
|
||||
|
||||
/* P7 Wave D: the no-follow (symlink) counterpart. Resolves the same
|
||||
* usermap/groupmap/chown/numeric-ids/copy-as policy but applies it with
|
||||
* fchownat(..., AT_SYMLINK_NOFOLLOW) so a symlink's own ownership is changed
|
||||
* without ever dereferencing it. A no-op unless an identity flag is active.
|
||||
* The return value follows identity_apply_ownership(): false only when an
|
||||
* active --copy-as application failed. */
|
||||
bool identity_apply_ownership_link(int parent_fd, const char* leaf, int32_t source_uid,
|
||||
int32_t source_gid);
|
||||
|
||||
/* Receiver-side wire validation of the resolved identity fields. */
|
||||
bool identity_wire_valid(const Config* config);
|
||||
|
||||
/* P7 Wave E receiver-side permission gate for super-user activities (ownership
|
||||
* application and char/block device-node creation). `privilege_super_permitted`
|
||||
* consults the per-connection snapshot (call identity_set_active() first);
|
||||
* `privilege_super_mode_permitted` is the pure mode predicate and is what
|
||||
* callers holding a Config use (the config-frame gate, file_receive). Both
|
||||
* return false only for SUPER_MODE_OFF; SUPER_MODE_ON and SUPER_MODE_AUTO (the
|
||||
* default) permit a confined attempt, matching FastSync's historical
|
||||
* 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(SuperMode mode);
|
||||
|
||||
#endif
|
||||
+8
-170
@@ -1,190 +1,28 @@
|
||||
#include "log.h"
|
||||
#include <errno.h>
|
||||
#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"};
|
||||
static const char *log_level_strings[] = {"DEBUG", "INFO", "WARN", "ERROR"};
|
||||
static LogLevel current_log_level = LOG_LEVEL_WARNING;
|
||||
static uint32_t current_debug_flags = 0;
|
||||
static uint32_t info_flags = 0;
|
||||
static bool info_flags_explicit = false;
|
||||
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;
|
||||
}
|
||||
|
||||
void set_log_debug_flags(uint32_t flags) {
|
||||
current_debug_flags = flags;
|
||||
}
|
||||
|
||||
uint32_t get_log_debug_flags(void) {
|
||||
return current_debug_flags;
|
||||
}
|
||||
|
||||
bool log_debug_enabled(LogDebugFlag flag) {
|
||||
return current_log_level <= LOG_LEVEL_DEBUG && (current_debug_flags & flag) != 0;
|
||||
}
|
||||
|
||||
void set_log_info_flags(uint32_t flags) {
|
||||
info_flags = flags;
|
||||
info_flags_explicit = true;
|
||||
}
|
||||
|
||||
uint32_t get_log_info_flags(void) {
|
||||
return info_flags;
|
||||
}
|
||||
|
||||
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) {
|
||||
eight_bit_output = enabled;
|
||||
}
|
||||
|
||||
bool log_get_8_bit_output(void) {
|
||||
return eight_bit_output;
|
||||
}
|
||||
|
||||
void log_set_stderr_mode(LogStderrMode mode) {
|
||||
stderr_mode = mode;
|
||||
}
|
||||
|
||||
LogStderrMode log_get_stderr_mode(void) {
|
||||
return stderr_mode;
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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, ...) {
|
||||
void log_message(LogLevel log_level, char *format, ...) {
|
||||
if (log_level < current_log_level)
|
||||
return;
|
||||
if (log_level < 0 || log_level >= (int)(sizeof(log_level_strings) / sizeof(log_level_strings[0])))
|
||||
return;
|
||||
time_t now = time(NULL);
|
||||
struct tm t;
|
||||
if (!localtime_r(&now, &t))
|
||||
return;
|
||||
struct tm *t = localtime(&now);
|
||||
|
||||
FILE* dest_io = stdout;
|
||||
if (stderr_mode == LOG_STDERR_ALL || log_level == LOG_LEVEL_ERROR) {
|
||||
dest_io = stderr;
|
||||
}
|
||||
fprintf(stderr, "%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]);
|
||||
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
char* line = format_log_line(log_level, &t, format, args);
|
||||
vfprintf(stderr, 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, ...) {
|
||||
if (current_log_level > LOG_LEVEL_DEBUG || !(current_debug_flags & flag))
|
||||
return;
|
||||
|
||||
time_t now = time(NULL);
|
||||
struct tm t;
|
||||
if (!localtime_r(&now, &t))
|
||||
return;
|
||||
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
char* line = format_log_line(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, ...) {
|
||||
if ((info_flags_explicit && (info_flags & flag) == 0) ||
|
||||
(!info_flags_explicit && current_log_level > LOG_LEVEL_DEBUG))
|
||||
return;
|
||||
|
||||
time_t now = time(NULL);
|
||||
struct tm t;
|
||||
if (!localtime_r(&now, &t))
|
||||
return;
|
||||
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
char* line = format_log_line(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) {
|
||||
log_message(LOG_LEVEL_ERROR, "%s: %s", context, strerror(errno));
|
||||
fprintf(stderr, "\n");
|
||||
}
|
||||
|
||||
+6
-38
@@ -1,46 +1,14 @@
|
||||
#ifndef LOG_H
|
||||
#define LOG_H
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
typedef enum { LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_WARNING, LOG_LEVEL_ERROR } LogLevel;
|
||||
typedef enum { LOG_STDERR_ERRORS, LOG_STDERR_ALL } LogStderrMode;
|
||||
|
||||
typedef enum {
|
||||
LOG_DEBUG_IO = 1u << 0,
|
||||
LOG_DEBUG_PROTO = 1u << 1,
|
||||
LOG_DEBUG_PACK = 1u << 2,
|
||||
LOG_DEBUG_UTIL = 1u << 3,
|
||||
LOG_DEBUG_ALL = (1u << 4) - 1,
|
||||
} LogDebugFlag;
|
||||
LOG_LEVEL_DEBUG,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_WARNING,
|
||||
LOG_LEVEL_ERROR
|
||||
} LogLevel;
|
||||
|
||||
typedef enum {
|
||||
LOG_INFO_COPY = 1u << 0,
|
||||
LOG_INFO_MISC = 1u << 1,
|
||||
LOG_INFO_SKIP = 1u << 2,
|
||||
LOG_INFO_STATS = 1u << 3,
|
||||
LOG_INFO_ALL = LOG_INFO_COPY | LOG_INFO_MISC | LOG_INFO_SKIP | LOG_INFO_STATS,
|
||||
} LogInfoFlag;
|
||||
|
||||
void log_message(LogLevel log_level, const char* message, ...);
|
||||
void log_perror(const char* context);
|
||||
void log_message(LogLevel log_level, char *message, ...);
|
||||
void set_log_level(LogLevel level);
|
||||
void set_log_debug_flags(uint32_t flags);
|
||||
uint32_t get_log_debug_flags(void);
|
||||
/* True when a log_debug_message() call with the same flag would actually emit:
|
||||
* the debug log level is enabled AND the flag is selected. Hot paths use this
|
||||
* to skip expensive message formatting/escaping when the line is filtered. */
|
||||
bool log_debug_enabled(LogDebugFlag flag);
|
||||
void log_debug_message(LogDebugFlag flag, const char* message, ...);
|
||||
void set_log_info_flags(uint32_t flags);
|
||||
uint32_t get_log_info_flags(void);
|
||||
void log_info_message(LogInfoFlag flag, const char* message, ...);
|
||||
void log_set_file(FILE* fp);
|
||||
void log_set_8_bit_output(bool enabled);
|
||||
bool log_get_8_bit_output(void);
|
||||
void log_set_stderr_mode(LogStderrMode mode);
|
||||
LogStderrMode log_get_stderr_mode(void);
|
||||
|
||||
#endif
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user