feat: per-attribute preservation flags -p/-t/-o/-g with --no-* negations (protocol 2.22.0)
Split FastSync's single use_metadata bundle into four independent rsync-parity attributes: preserve_perms, preserve_times, preserve_owner, preserve_group. use_metadata is now a derived transport bit (config_derived_use_metadata). CLI: real -p/--perms, -t/--times, -o/--owner, -g/--group plus --no-perms/--no-times/--no-owner/--no-group (short and long) and --no-preserve; -a is now rsync -rlptgoD; --preserve = -pt; -A implies -p; -X does not; --chmod implies -p; --usermap/--groupmap/--chown imply owner/group per side; --incremental/--delta still auto-preserve unless negated. Receiver: per-attribute FileAttrPolicy gating for files, dirs (modes applied at end of transfer), symlinks and specials; rsync -E read-bit rule; new files get source_mode & ~umask sanitized (no group/other write); per-side identity resolution; deferred directory metadata; batch dir-metadata replay; daemon modules without 'client owner = yes' no longer refuse plain -a but force super off (no ownership) with a warning. Wire: PROTOCOL_VERSION 2.21.0 -> 2.22.0 (four appended config bools, golden 653 / 95530566005420798). FileMetadata/chunk/batch framing unchanged. Docs/CHANGELOG/CMake updated to 2.22.0.
This commit is contained in:
@@ -16,7 +16,7 @@ Ask the user or determine from context:
|
|||||||
- **Minor** (x.Y.0) — new features, backward compatible
|
- **Minor** (x.Y.0) — new features, backward compatible
|
||||||
- **Patch** (x.y.Z) — bug fixes, no protocol changes
|
- **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 "2.22.0"` in `src/shared/config.h`
|
||||||
|
|
||||||
### Step 2: Check Protocol Version
|
### Step 2: Check Protocol Version
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,46 @@ All notable changes to FastSync are documented here. Versions match
|
|||||||
`PROTOCOL_VERSION` (printed by `fastsync --version`); the client and server must
|
`PROTOCOL_VERSION` (printed by `fastsync --version`); the client and server must
|
||||||
run the same version because the handshake is strict.
|
run the same version because the handshake is strict.
|
||||||
|
|
||||||
|
## [2.22.0] - 2026-09-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Per-attribute metadata preservation (protocol 2.22.0).** The former single
|
||||||
|
metadata bundle is split into four independent, rsync-compatible flags:
|
||||||
|
`-p/--perms`, `-t/--times`, `-o/--owner`, and `-g/--group`, each applied
|
||||||
|
independently on the receiver, with negations `--no-perms`/`--no-times`/
|
||||||
|
`--no-owner`/`--no-group` (short `--no-p`/`--no-t`/`--no-o`/`--no-g`) and
|
||||||
|
`--no-preserve` clearing all four. `-a/--archive` is now full rsync
|
||||||
|
`-rlptgoD` (owner and group included; their application stays
|
||||||
|
privilege-gated). `-A/--acls` and `--chmod` imply `-p`, `-X/--xattrs` does
|
||||||
|
not, `-E/--executability` sets only executability, and `-U`/`-N` do not imply
|
||||||
|
`-t`. `--incremental`/`--delta` still auto-preserve perms+times unless the
|
||||||
|
user explicitly negated them.
|
||||||
|
- Receiver applies directory modes under `-p` (at the end of the transfer,
|
||||||
|
alongside the deferred directory times) and symlink mode under `-p`; `-O`
|
||||||
|
suppresses directory times only.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `PROTOCOL_VERSION` bumped `2.21.0 → 2.22.0`: the binary config frame gains
|
||||||
|
four appended booleans (`preserve_perms`/`preserve_times`/`preserve_owner`/
|
||||||
|
`preserve_group`) after `omit_link_times`. The fixed-width `FileMetadata`
|
||||||
|
layout is unchanged; the receiver derives the metadata-frame gate
|
||||||
|
(`use_metadata`) from the four attributes.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Documented divergences from rsync: a client-supplied mode never grants
|
||||||
|
group/other write (`S_IWGRP|S_IWOTH` are stripped for files, directories,
|
||||||
|
symlinks, and specials; rsync's `-p` preserves them exactly); a brand-new file
|
||||||
|
without `-p` gets `source_mode & ~umask` (sanitized) when metadata is present,
|
||||||
|
else the historical fixed `0644`; `--chmod` implies `-p` (rsync does not);
|
||||||
|
`-o`/`-g` map by name on the receiver with a raw-numeric fallback (only
|
||||||
|
numeric ids cross the wire); and a daemon module without `client owner = yes`
|
||||||
|
does not refuse a plain `-a`/`-o`/`-g` but forces super-user activities off,
|
||||||
|
applies no ownership, and logs a warning (explicit `--chown`/`--usermap`/
|
||||||
|
`--groupmap`/`--numeric-ids`/`--copy-as`/`--super` are still refused).
|
||||||
|
|
||||||
## [2.21.0] - 2026-09-14
|
## [2.21.0] - 2026-09-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
cmake_minimum_required(VERSION 3.22)
|
cmake_minimum_required(VERSION 3.22)
|
||||||
|
|
||||||
project(FastFileTransfer VERSION 2.21.0)
|
project(FastFileTransfer VERSION 2.22.0)
|
||||||
|
|
||||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||||
set(CMAKE_C_STANDARD 11)
|
set(CMAKE_C_STANDARD 11)
|
||||||
|
|||||||
+3
-2
@@ -8,8 +8,8 @@
|
|||||||
- **Release PR #284 (`dev` -> `main`)** open, CI green (run 553).
|
- **Release PR #284 (`dev` -> `main`)** open, CI green (run 553).
|
||||||
`main` is protected: it needs review/approval to merge.
|
`main` is protected: it needs review/approval to merge.
|
||||||
https://gitea.tap-tap.win/TapTap/FastSync/pulls/284
|
https://gitea.tap-tap.win/TapTap/FastSync/pulls/284
|
||||||
- **`PROTOCOL_VERSION` = `"2.21.0"`** (`src/shared/config.h`); CMake
|
- **`PROTOCOL_VERSION` = `"2.22.0"`** (`src/shared/config.h`); CMake
|
||||||
`project(FastFileTransfer VERSION 2.21.0)`.
|
`project(FastFileTransfer VERSION 2.22.0)`.
|
||||||
- Working tree clean; no wave worktrees remain.
|
- Working tree clean; no wave worktrees remain.
|
||||||
|
|
||||||
## What landed this session
|
## What landed this session
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
4. **Tooling:** benchmark accuracy (data mix, verification, percentiles, `tc`,
|
4. **Tooling:** benchmark accuracy (data mix, verification, percentiles, `tc`,
|
||||||
`build-bench/`, `--warm` mode); `shell.nix` full toolchain and no build-on-entry;
|
`build-bench/`, `--warm` mode); `shell.nix` full toolchain and no build-on-entry;
|
||||||
docs state push-only / remote-source unsupported.
|
docs state push-only / remote-source unsupported.
|
||||||
|
5. **Preserve-attribute split (protocol 2.22.0)** landed on `feat/preserve-attr-split`: per-attribute `-p/-t/-o/-g` + `--no-*` negations, `-a` = `-rlptgoD`, and the 2.21.0 → 2.22.0 wire bump.
|
||||||
|
|
||||||
## Next steps
|
## Next steps
|
||||||
1. **Merge PR #284** (`dev` -> `main`) once reviewed (protected branch).
|
1. **Merge PR #284** (`dev` -> `main`) once reviewed (protected branch).
|
||||||
|
|||||||
@@ -65,19 +65,21 @@ replacement for every rsync feature or protocol mode.
|
|||||||
|
|
||||||
- The FastSync wire protocol is not the rsync wire protocol.
|
- The FastSync wire protocol is not the rsync wire protocol.
|
||||||
- SSH mode requires `fastsync-server` on the remote host.
|
- SSH mode requires `fastsync-server` on the remote host.
|
||||||
- Archive mode covers rsync's `-rlptD` behavior — links, permissions, times,
|
- Archive mode covers rsync's `-rlptgoD` behavior — links, permissions, times,
|
||||||
devices, and special files — and does not imply compression or multithreading
|
owner, group, devices, and special files — and does not imply compression or
|
||||||
(see [Client](#client)). Owner/group (`-o`/`-g`) are NOT implied; identity is
|
multithreading (see [Client](#client)). Ownership application is still
|
||||||
applied only through the opt-in identity flags (`--chown`/`--usermap`/
|
privilege-gated: a receiver that cannot `chown` logs a warning and skips it,
|
||||||
`--groupmap`/`--numeric-ids`/`--copy-as`).
|
and a client-supplied mode can never grant group/other write (see
|
||||||
|
[`RSYNC_COMPAT.md`](RSYNC_COMPAT.md)).
|
||||||
- Symlink transfer recreates only relative, `..`-free link targets
|
- Symlink transfer recreates only relative, `..`-free link targets
|
||||||
(`-l`/`--links`); an absolute target or any target containing a `..` component
|
(`-l`/`--links`); an absolute target or any target containing a `..` component
|
||||||
is dropped rather than created, even if it would resolve within the receive
|
is dropped rather than created, even if it would resolve within the receive
|
||||||
root. This containment check is skipped under `--trust-sender`.
|
root. This containment check is skipped under `--trust-sender`.
|
||||||
- Hard links (`-H`/`--hard-links`), extended attributes (`-X`/`--xattrs`), and
|
- Hard links (`-H`/`--hard-links`), extended attributes (`-X`/`--xattrs`), and
|
||||||
POSIX ACLs (`-A`/`--acls`) are preserved; owner/group is applied only through
|
POSIX ACLs (`-A`/`--acls`) are preserved; owner/group is applied through
|
||||||
the opt-in identity flags (`--chown`/`--usermap`/`--groupmap`/`--numeric-ids`/
|
`-o`/`-g` (or an `-a`/`--archive` transfer), through the opt-in identity flags
|
||||||
`--copy-as`) and only when the receiver has permission. See
|
(`--chown`/`--usermap`/`--groupmap`/`--numeric-ids`/`--copy-as`), and only when
|
||||||
|
the receiver has permission. See
|
||||||
[`RSYNC_COMPAT.md`](RSYNC_COMPAT.md) for the exact semantics and documented
|
[`RSYNC_COMPAT.md`](RSYNC_COMPAT.md) for the exact semantics and documented
|
||||||
divergences.
|
divergences.
|
||||||
- Device and special-file preservation is implemented with documented
|
- Device and special-file preservation is implemented with documented
|
||||||
@@ -120,7 +122,7 @@ This produces `./build/client` and `./build/server`. `compile_commands.json` is
|
|||||||
| Positional | `<source> <dest>` — automatic SSH detection if dest contains `:` |
|
| Positional | `<source> <dest>` — automatic SSH detection if dest contains `:` |
|
||||||
| `-c, --checksum` | Verify content by checksum instead of size+mtime |
|
| `-c, --checksum` | Verify content by checksum instead of size+mtime |
|
||||||
| `-z, --compress [level]` | Enable streaming zstd compression (level 1–22, default 5) |
|
| `-z, --compress [level]` | Enable streaming zstd compression (level 1–22, default 5) |
|
||||||
| `-a, --archive` | rsync archive mode (`-rlptD`): links, metadata, devices and specials; owner/group (`-o`/`-g`) are not implied and stay opt-in via the identity flags (not compression/multithreading) |
|
| `-a, --archive` | rsync archive mode (`-rlptgoD`): links, perms, times, owner, group, devices and specials; ownership application stays privilege-gated (not compression/multithreading) |
|
||||||
| `-j, --threads[=N]` | Multithreading mode; `N` (1–256) sets the parallel scanner worker count, bare `-j`/`--threads` uses the default |
|
| `-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) |
|
| `-m` | rsync `--prune-empty-dirs` (short form now rsync-parity) |
|
||||||
| `-d, --dirs` | Transfer the named directory entries without recursing into their contents; aliases `--old-dirs`/`--old-d` |
|
| `-d, --dirs` | Transfer the named directory entries without recursing into their contents; aliases `--old-dirs`/`--old-d` |
|
||||||
@@ -134,10 +136,14 @@ This produces `./build/client` and `./build/server`. `compile_commands.json` is
|
|||||||
| `-W, --whole-file` | Transfer changed files without delta processing |
|
| `-W, --whole-file` | Transfer changed files without delta processing |
|
||||||
| `-I, --ignore-times` | Transfer files even when size and mtime match |
|
| `-I, --ignore-times` | Transfer files even when size and mtime match |
|
||||||
| `--size-only` | Skip incremental files matching in size, ignoring mtime |
|
| `--size-only` | Skip incremental files matching in size, ignoring mtime |
|
||||||
| `--preserve` | Preserve file metadata (mode and mtime; add `-U`/`--atimes` for atime, or an identity flag for owner/group; `-N`/`--crtimes` captures birth time but cannot apply it) |
|
| `--preserve` | Preserve mode and mtime (`-p` + `-t`; add `-o`/`-g` for owner/group or `-U`/`--atimes` for atime; `-N`/`--crtimes` captures birth time but cannot apply it) |
|
||||||
| `-U, --atimes` | Preserve access times. Captured with the metadata payload; does not enable ownership. |
|
| `-U, --atimes` | Preserve access times. Captured with the metadata payload; does not enable ownership. |
|
||||||
| `-N, --crtimes` | Capture birth time; cannot be applied (documented divergence) |
|
| `-N, --crtimes` | Capture birth time; cannot be applied (documented divergence) |
|
||||||
| `-p, --perms` | Preserve permission bits (part of the metadata bundle) |
|
| `-p, --perms` | Preserve permission bits (a client mode never grants group/other write) |
|
||||||
|
| `-t, --times` | Preserve modification times |
|
||||||
|
| `-o, --owner` | Preserve the source owner (privilege-gated; mapped by name on the receiver with a numeric fallback) |
|
||||||
|
| `-g, --group` | Preserve the source group (privilege-gated; mapped by name on the receiver with a numeric fallback) |
|
||||||
|
| `--no-perms`, `--no-times`, `--no-owner`, `--no-group`, `--no-preserve` | Negate the per-attribute flags (short `--no-p`/`--no-t`/`--no-o`/`--no-g`; `--no-preserve` clears all four) |
|
||||||
| `-E, --executability` | Preserve executable permission bits |
|
| `-E, --executability` | Preserve executable permission bits |
|
||||||
| `-X, --xattrs` | Preserve user `user.*` extended attributes |
|
| `-X, --xattrs` | Preserve user `user.*` extended attributes |
|
||||||
| `-A, --acls` | Preserve POSIX ACLs |
|
| `-A, --acls` | Preserve POSIX ACLs |
|
||||||
@@ -494,8 +500,8 @@ is `--remote-option`, `-f` is `--filter`, `-s` is `--secluded-args`, `-p` is
|
|||||||
long-form-only or new shorts: multithreading is `-j`/`--threads`, metadata
|
long-form-only or new shorts: multithreading is `-j`/`--threads`, metadata
|
||||||
is `--preserve`, sendfile is `--sendfile`, chunk serialization is
|
is `--preserve`, sendfile is `--sendfile`, chunk serialization is
|
||||||
`--chunk-serialization`, timeout is `--timeout`, and SSH port is `--ssh-port`.
|
`--chunk-serialization`, timeout is `--timeout`, and SSH port is `--ssh-port`.
|
||||||
`-a`/`--archive` is now rsync archive `-rlptD`; owner/group (`-o`/`-g`) are not
|
`-a`/`--archive` is now rsync archive `-rlptgoD` (owner/group implied, but the
|
||||||
implied and remain opt-in via the identity flags.
|
receiver still needs privilege to apply them).
|
||||||
|
|
||||||
`--secluded-args` (and its short form `-s`) is accepted as a compatibility
|
`--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
|
no-op. It does not change FastSync's transport or protocol behavior, because
|
||||||
@@ -507,7 +513,7 @@ remote SSH argv is already built injection-safe.
|
|||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `-a`, `--archive` | rsync archive mode (`-rlptD`): links, metadata, devices and specials; owner/group (`-o`/`-g`) are not implied (opt in via the identity flags). |
|
| `-a`, `--archive` | rsync archive mode (`-rlptgoD`): links, perms, times, owner, group, devices and specials; ownership application stays privilege-gated. |
|
||||||
| `-n`, `--dry-run` | Report what would be transferred without mutating the destination. Since protocol 2.21.0 a server-routed target contacts the receiver and reports would-transfer based on receiver state; a plain local destination keeps the client-side scan. Never mutates or deletes. |
|
| `-n`, `--dry-run` | Report what would be transferred without mutating the destination. Since protocol 2.21.0 a server-routed target contacts the receiver and reports would-transfer based on receiver state; a plain local destination keeps the client-side scan. Never mutates or deletes. |
|
||||||
| `--remove-source-files` | Remove regular source files after a successful transfer. |
|
| `--remove-source-files` | Remove regular source files after a successful transfer. |
|
||||||
| `--incremental` | Skip files matching destination size and mtime. Auto-enables `--preserve`. Incompatible with `--chunk-serialization`. |
|
| `--incremental` | Skip files matching destination size and mtime. Auto-enables `--preserve`. Incompatible with `--chunk-serialization`. |
|
||||||
@@ -556,10 +562,14 @@ remote SSH argv is already built injection-safe.
|
|||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `--preserve` | Preserve mode and mtime (long form only). Add `-U`/`--atimes` for atime, or an identity flag (`--chown`/`--usermap`/`--groupmap`/`--numeric-ids`/`--copy-as`) for owner/group. |
|
| `--preserve` | Preserve mode and mtime (long form only; equivalent to `-p` + `-t`). Add `-o`/`-g` for owner/group, `-U`/`--atimes` for atime, or an identity flag (`--chown`/`--usermap`/`--groupmap`/`--numeric-ids`/`--copy-as`) for mapped ownership. |
|
||||||
| `-U`, `--atimes` | Preserve access times. Captured with the metadata payload; does not enable ownership. |
|
| `-U`, `--atimes` | Preserve access times. Captured with the metadata payload; does not enable ownership. |
|
||||||
| `-N`, `--crtimes` | Capture birth time and transmit it; it cannot be applied because no portable filesystem call can set a birth time (documented divergence). |
|
| `-N`, `--crtimes` | Capture birth time and transmit it; it cannot be applied because no portable filesystem call can set a birth time (documented divergence). |
|
||||||
| `-p`, `--perms` | Preserve permission bits (part of the metadata bundle). |
|
| `-p`, `--perms` | Preserve permission bits. One of the four per-attribute preserve flags (with `-t`/`-o`/`-g`); a client-supplied mode never grants group/other write. |
|
||||||
|
| `-t`, `--times` | Preserve modification times. Independent of the other attributes; `-O`/`--omit-dir-times` suppresses directories only. |
|
||||||
|
| `-o`, `--owner` | Preserve the source owner (uid). Mapped by name on the receiver with a raw-numeric fallback (only numeric ids cross the wire); application is privilege-gated. |
|
||||||
|
| `-g`, `--group` | Preserve the source group (gid). Same name-mapping/numeric-fallback and privilege gating as `-o`. |
|
||||||
|
| `--no-perms`, `--no-times`, `--no-owner`, `--no-group` | Negate each per-attribute flag (also `--no-p`/`--no-t`/`--no-o`/`--no-g`); `--no-preserve` clears all four. |
|
||||||
| `-E`, `--executability` | Preserve executable permission bits. |
|
| `-E`, `--executability` | Preserve executable permission bits. |
|
||||||
| `-X`, `--xattrs` | Preserve user `user.*` extended attributes. |
|
| `-X`, `--xattrs` | Preserve user `user.*` extended attributes. |
|
||||||
| `-A`, `--acls` | Preserve POSIX ACLs. |
|
| `-A`, `--acls` | Preserve POSIX ACLs. |
|
||||||
@@ -732,7 +742,7 @@ before the module list, before authentication, and the connecting peer address
|
|||||||
|
|
||||||
## Protocol and Security
|
## Protocol and Security
|
||||||
|
|
||||||
FastSync protocol version `2.21.0` is shared by the client and server. The
|
FastSync protocol version `2.22.0` is shared by the client and server. The
|
||||||
current protocol is sender-driven and includes configuration negotiation,
|
current protocol is sender-driven and includes configuration negotiation,
|
||||||
including the maximum allocation limit, incremental checks, checksums,
|
including the maximum allocation limit, incremental checks, checksums,
|
||||||
manifests, keep-alives, abort handling, per-file remove-source results, and
|
manifests, keep-alives, abort handling, per-file remove-source results, and
|
||||||
@@ -800,10 +810,10 @@ The project will reach the drop-in replacement goal in stages:
|
|||||||
and `--option=value` syntax.
|
and `--option=value` syntax.
|
||||||
2. Add differential tests that compare FastSync and rsync contents, metadata,
|
2. Add differential tests that compare FastSync and rsync contents, metadata,
|
||||||
links, deletes, filters, dry runs, and exit codes.
|
links, deletes, filters, dry runs, and exit codes.
|
||||||
3. `-a` now implements the expected recursive, links, permissions, times, and
|
3. `-a` now implements the expected recursive, links, permissions, times,
|
||||||
supported device/special-file behavior; owner/group (`-o`/`-g`) stay opt-in
|
owner/group (`-o`/`-g`), and supported device/special-file behavior (full
|
||||||
via the identity flags, and remaining work is the documented
|
rsync `-rlptgoD`); ownership application stays privilege-gated and remaining
|
||||||
device/special-file divergences.
|
work is the documented device/special-file divergences.
|
||||||
4. Symlink, sparse-file, metadata, delete-policy, and resumable-write semantics
|
4. Symlink, sparse-file, metadata, delete-policy, and resumable-write semantics
|
||||||
are implemented; remaining work is the documented edge cases.
|
are implemented; remaining work is the documented edge cases.
|
||||||
5. Add rsync remote-shell and daemon protocol interoperability.
|
5. Add rsync remote-shell and daemon protocol interoperability.
|
||||||
|
|||||||
+15
-13
@@ -20,7 +20,7 @@ This document maps rsync's full feature set to FastSync's current implementation
|
|||||||
|
|
||||||
| Flag | Rsync Description | FastSync Status | Notes |
|
| Flag | Rsync Description | FastSync Status | Notes |
|
||||||
|------|-------------------|-----------------|-------|
|
|------|-------------------|-----------------|-------|
|
||||||
| `-a`, `--archive` | Archive mode is -rlptgoD (rsync includes owner/group) | ✅ Implemented | Phase 7 Wave A: real rsync archive. `-a`/`--archive` now implies `--links` + metadata (perms/times) + `--devices` + `--specials`, i.e. **`-rlptD`**. Owner/group (`-o`/`-g`) are **NOT** implied; they require an explicit identity flag (`--numeric-ids`/`--usermap`/`--groupmap`/`--chown`/`--copy-as`). 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) |
|
| `-a`, `--archive` | Archive mode is -rlptgoD (rsync includes owner/group) | ✅ Implemented | Phase 7 Wave A: real rsync archive. `-a`/`--archive` now implies `--links` + the four per-attribute preserve flags (perms/times/owner/group) + `--devices` + `--specials`, i.e. **`-rlptgoD`**. Owner/group **are** implied, but their application stays privilege-gated exactly like rsync: a receiver that cannot `chown` logs a warning and skips it (see the preserve-attribute split note below). 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` |
|
| `-v`, `--verbose` | Increase verbosity | ✅ Implemented | Sets `log_level=DEBUG` |
|
||||||
| `-q`, `--quiet` | Suppress non-error messages | ✅ Implemented | Suppresses client output while preserving errors |
|
| `-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 |
|
| `--help` | Show help | ✅ Implemented | Prints usage and exits; `-h` is not accepted |
|
||||||
@@ -238,11 +238,11 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`.
|
|||||||
|
|
||||||
| Flag | Rsync Description | FastSync Status | Notes |
|
| Flag | Rsync Description | FastSync Status | Notes |
|
||||||
|------|-------------------|-----------------|-------|
|
|------|-------------------|-----------------|-------|
|
||||||
| `-M`, `--preserve` | Preserve file metadata | ✅ Implemented | Carries mode, uid, gid, and mtime on the wire; ownership is applied only via an explicit identity flag (`--numeric-ids`/`--usermap`/`--groupmap`/`--chown`/`--copy-as`) |
|
| `-M`, `--preserve` | Preserve file metadata | ✅ Implemented | `--preserve` means `-p` + `-t` (mode + mtime); the wire metadata also carries uid/gid for `-o`/`-g`/`-a`, and ownership is applied via `-o`/`-g`, `-a`, or an explicit identity flag (`--numeric-ids`/`--usermap`/`--groupmap`/`--chown`/`--copy-as`) |
|
||||||
| `-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 |
|
| `-p`, `--perms` | Preserve permissions | ✅ Implemented | Real per-attribute flag (protocol 2.22.0): `preserve_perms` applies the source mode independently of times/owner/group. A client-supplied mode never grants group/other write — `S_IWGRP|S_IWOTH` are always stripped (rsync's `-p` preserves them exactly). `--chmod` and `-A/--acls` also imply `-p`; `-X/--xattrs` does not. The SSH port moved to `--ssh-port`. rsync-parity short form |
|
||||||
| `-o`, `--owner` | Preserve owner | ✅ Implemented | Not parsed as a separate flag; owner application is provided by the identity flags only (`--numeric-ids`/`--usermap`/`--groupmap`/`--chown`/`--copy-as`) |
|
| `-o`, `--owner` | Preserve owner | ✅ Implemented | Real per-attribute flag (`preserve_owner`): preserve the source uid, resolved on the receiver by name against its own user database with a raw-numeric fallback (only numeric ids cross the wire). `--usermap`/`--chown=USER` imply it. Application follows the `--super`/`--no-super` policy; a non-opted daemon module applies no ownership (see the Daemon Mode notes) |
|
||||||
| `-g`, `--group` | Preserve group | ✅ Implemented | Not parsed as a separate flag; group application is provided by the identity flags only (`--numeric-ids`/`--usermap`/`--groupmap`/`--chown`/`--copy-as`) |
|
| `-g`, `--group` | Preserve group | ✅ Implemented | Real per-attribute flag (`preserve_group`): preserve the source gid, resolved by name on the receiver with a raw-numeric fallback. `--groupmap`/`--chown=:GROUP` imply it. Same privilege/super-policy gating as `-o` |
|
||||||
| `-t`, `--times` | Preserve modification times | ✅ Implemented | Not parsed separately; modification-time preservation is provided by `--preserve`/`-a` |
|
| `-t`, `--times` | Preserve modification times | ✅ Implemented | Real per-attribute flag (`preserve_times`): apply the source mtime independently of the other attributes. `-O/--omit-dir-times` suppresses directories only and `-J/--omit-link-times` suppresses symlinks only; `-U`/`-N` do not imply it. `--preserve`/`-a` imply it, and `--incremental`/`--delta` auto-enable it unless `--no-times`/`--no-preserve` |
|
||||||
| `-E`, `--executability` | Preserve executability | ✅ Implemented | Preserves executable permission bits (implies metadata preservation) |
|
| `-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 |
|
| `--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 |
|
| `-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 |
|
||||||
@@ -257,8 +257,8 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`.
|
|||||||
| `-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) |
|
| `-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** |
|
| `-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** |
|
| `-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 |
|
| `--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`) or a preserve-source request (`-o`/`-g`, or `-a`/`--archive`) 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 |
|
| `--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`) or a preserve-source request (`-o`/`-g`, or `-a`/`--archive`) 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 |
|
| `--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) |
|
| `--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 |
|
| `--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 |
|
||||||
@@ -639,7 +639,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
|
|||||||
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **`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. A plain preserve-source request (`-a`/`-o`/`-g`) is **not** refused: the module forces super-user activities off for that connection, so no ownership is applied, and it logs a warning that the requested ownership will not be applied (the transfer itself still succeeds). `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.
|
- **`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>
|
- **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.
|
- **`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.
|
||||||
@@ -680,7 +680,7 @@ now transmits targets (the prior behavior was broken/partial); its status moved
|
|||||||
| `--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-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 |
|
| `--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 | |
|
| `--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 |
|
| `--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.22.0) with no downgrade/backward-compat code paths, so `--protocol=2.22.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.21.0`/`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 |
|
| `--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 |
|
| `--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`. |
|
| `--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`. |
|
||||||
@@ -796,7 +796,7 @@ These are the hardest compatibility items because they require durable formats o
|
|||||||
|
|
||||||
**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 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 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.22.0` (the current `PROTOCOL_VERSION`, as of the preserve-attribute split wave) is accepted and stored into the client's `version` claim (which `config_send` already transmits), and every other value — `2.21.0`, `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, error-detail/dry-run 2.21, preserve-attribute split 2.22) 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-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).
|
||||||
|
|
||||||
@@ -806,7 +806,7 @@ These are the hardest compatibility items because they require durable formats o
|
|||||||
|
|
||||||
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**.
|
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 `-rlptD`. 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.
|
**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 `-rlptD`. 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 was not wired into the negatable set (only the long-form `--preserve`/`--compress`/`--no-links` negations existed), so `--archive --no-perms` was initially unsupported — a minor deviation from rsync. The preserve-attribute split wave below resolves the preservation side: `--no-perms`/`--no-times`/`--no-owner`/`--no-group` and `--no-preserve` now work, so `--archive --no-perms` is supported.
|
||||||
|
|
||||||
| FastSync flag today | rsync wants that name | Proposed rename |
|
| FastSync flag today | rsync wants that name | Proposed rename |
|
||||||
|---------------------|----------------------|-----------------|
|
|---------------------|----------------------|-----------------|
|
||||||
@@ -833,7 +833,7 @@ These are the last compatibility items and the closing phase toward rsync flag p
|
|||||||
|
|
||||||
**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.
|
**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).
|
`--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`) or a preserve-source request (`-o`/`-g`, or `-a`/`--archive`) 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.
|
`--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.
|
||||||
|
|
||||||
@@ -841,6 +841,8 @@ These are the last compatibility items and the closing phase toward rsync flag p
|
|||||||
|
|
||||||
**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.**
|
**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.**
|
||||||
|
|
||||||
|
**Preserve-attribute split (protocol 2.21.0 → 2.22.0) — ✅ implemented.** FastSync splits the former single metadata bundle into four independent, rsync-compatible per-attribute flags — `-p/--perms`, `-t/--times`, `-o/--owner`, `-g/--group` — each with a negation (`--no-perms`/`--no-times`/`--no-owner`/`--no-group`, short `--no-p`/`--no-t`/`--no-o`/`--no-g`), plus `--no-preserve` clearing all four. `-a/--archive` is now full rsync `-rlptgoD` (owner and group included, though their application stays privilege-gated), `-A/--acls` and `--chmod` imply `-p`, `-X/--xattrs` does not, `-E/--executability` sets only executability, and `-U`/`-N` do not imply `-t`. `--incremental`/`--delta` still auto-preserve perms+times unless the user explicitly negated them. Wire: the binary config frame gains four appended booleans (`preserve_perms`/`preserve_times`/`preserve_owner`/`preserve_group`) after `omit_link_times`, so `PROTOCOL_VERSION` is bumped **2.21.0 → 2.22.0**; the fixed-width `FileMetadata` layout is unchanged and the receiver gates the metadata frame on a derived `use_metadata`. Receiver behavior: each attribute is applied independently, directory modes are applied under `-p` (at the end of the transfer, alongside dir times), symlink mode under `-p`, and `-O/--omit-dir-times` suppresses directory times only. Documented divergences: (a) a client-supplied mode never grants group/other write — `S_IWGRP|S_IWOTH` are stripped for files, directories, symlinks, and specials (rsync's `-p` preserves them exactly); (b) a brand-new file without `-p` gets `source_mode & ~umask` (sanitized) when metadata is present, else the historical fixed `0644`; (c) `--chmod` implies `-p` (rsync does not); (d) `-o`/`-g` map by name on the receiver with a raw-numeric fallback (only numeric ids cross the wire); (e) a daemon module without `client owner = yes` does not refuse a plain `-a`/`-o`/`-g` — it forces super off, applies no ownership, and logs a warning, while explicit `--chown`/`--usermap`/`--groupmap`/`--numeric-ids`/`--copy-as`/`--super` are still refused.
|
||||||
|
|
||||||
## Packed Metadata Frame (protocol 2.20.0)
|
## Packed Metadata Frame (protocol 2.20.0)
|
||||||
|
|
||||||
A file's metadata used to cross the wire as up to 12 separate per-field framed
|
A file's metadata used to cross the wire as up to 12 separate per-field framed
|
||||||
|
|||||||
+104
-54
@@ -619,6 +619,11 @@ typedef struct {
|
|||||||
size_t offset; /* offsetof of the boolean target field in Config */
|
size_t offset; /* offsetof of the boolean target field in Config */
|
||||||
} NegatableOption;
|
} NegatableOption;
|
||||||
|
|
||||||
|
/* Sentinel offset for --no-preserve, the rsync drop-in negation of the whole
|
||||||
|
* preservation bundle: it clears all four per-attribute flags and records the
|
||||||
|
* explicit metadata opt-out instead of clearing a single Config field. */
|
||||||
|
#define NEGATABLE_PRESERVE_BUNDLE ((size_t) - 1)
|
||||||
|
|
||||||
/* Options that map directly onto a Config field with no side effects.
|
/* Options that map directly onto a Config field with no side effects.
|
||||||
*
|
*
|
||||||
* NOTE: these CLI tables are intentionally NOT generated from the wire-field
|
* NOTE: these CLI tables are intentionally NOT generated from the wire-field
|
||||||
@@ -796,7 +801,11 @@ static const NegatableOption NEGATABLE_OPTIONS[] = {
|
|||||||
{"compress", NULL, offsetof(Config, use_compression)},
|
{"compress", NULL, offsetof(Config, use_compression)},
|
||||||
{"compress", "z", offsetof(Config, use_compression)},
|
{"compress", "z", offsetof(Config, use_compression)},
|
||||||
{"multithreading", "j", offsetof(Config, use_multithreading)},
|
{"multithreading", "j", offsetof(Config, use_multithreading)},
|
||||||
{"preserve", NULL, offsetof(Config, use_metadata)},
|
{"preserve", NULL, NEGATABLE_PRESERVE_BUNDLE},
|
||||||
|
{"perms", "p", offsetof(Config, preserve_perms)},
|
||||||
|
{"times", "t", offsetof(Config, preserve_times)},
|
||||||
|
{"owner", "o", offsetof(Config, preserve_owner)},
|
||||||
|
{"group", "g", offsetof(Config, preserve_group)},
|
||||||
{"sendfile", NULL, offsetof(Config, use_sendfile)},
|
{"sendfile", NULL, offsetof(Config, use_sendfile)},
|
||||||
{"chunk-serialization", NULL, offsetof(Config, use_chunk_serialization)},
|
{"chunk-serialization", NULL, offsetof(Config, use_chunk_serialization)},
|
||||||
{"xattrs", "X", offsetof(Config, preserve_xattrs)},
|
{"xattrs", "X", offsetof(Config, preserve_xattrs)},
|
||||||
@@ -856,9 +865,27 @@ static int apply_negation(Config* config, const char* arg) {
|
|||||||
fprintf(stderr, "Cannot negate unsupported or unsafe option: %s\n", arg);
|
fprintf(stderr, "Cannot negate unsupported or unsafe option: %s\n", arg);
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
*(bool*)((char*)config + entry->offset) = false;
|
if (entry->offset == NEGATABLE_PRESERVE_BUNDLE) {
|
||||||
if (entry->offset == offsetof(Config, use_metadata))
|
config->preserve_perms = false;
|
||||||
|
config->preserve_times = false;
|
||||||
|
config->preserve_owner = false;
|
||||||
|
config->preserve_group = false;
|
||||||
config->metadata_explicitly_disabled = true;
|
config->metadata_explicitly_disabled = true;
|
||||||
|
/* --no-preserve is an explicit opt-out of the whole bundle: record it so
|
||||||
|
* the --incremental/--delta auto-preserve in cli_finalize_config does not
|
||||||
|
* silently re-enable perms/times. */
|
||||||
|
config->preserve_perms_explicit_off = true;
|
||||||
|
config->preserve_times_explicit_off = true;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
*(bool*)((char*)config + entry->offset) = false;
|
||||||
|
/* Track an explicit per-attribute negation so --incremental/--delta can
|
||||||
|
* auto-preserve the OTHER attribute without undoing this one. A later
|
||||||
|
* -p/-t sets the attribute directly; this flag only gates the implication. */
|
||||||
|
if (entry->offset == offsetof(Config, preserve_perms))
|
||||||
|
config->preserve_perms_explicit_off = true;
|
||||||
|
else if (entry->offset == offsetof(Config, preserve_times))
|
||||||
|
config->preserve_times_explicit_off = true;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,8 +896,6 @@ static int apply_table_option(Config* config, const OptionEntry* entry, const ch
|
|||||||
switch (entry->kind) {
|
switch (entry->kind) {
|
||||||
case OPT_FLAG:
|
case OPT_FLAG:
|
||||||
*(bool*)field = true;
|
*(bool*)field = true;
|
||||||
if (entry->offset == offsetof(Config, update))
|
|
||||||
config->use_metadata = true;
|
|
||||||
return 0;
|
return 0;
|
||||||
case OPT_NOOP:
|
case OPT_NOOP:
|
||||||
return 0;
|
return 0;
|
||||||
@@ -1115,7 +1140,7 @@ static bool cli_handle_table_option(CliParseCtx* ctx) {
|
|||||||
ctx->exit_code = -1;
|
ctx->exit_code = -1;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
config->use_metadata = true;
|
config->preserve_perms = true;
|
||||||
}
|
}
|
||||||
/* Remember that --server-host was explicitly given (the field itself
|
/* Remember that --server-host was explicitly given (the field itself
|
||||||
defaults to 127.0.0.1, so a value check cannot distinguish it). Used
|
defaults to 127.0.0.1, so a value check cannot distinguish it). Used
|
||||||
@@ -1142,21 +1167,15 @@ static bool cli_handle_table_option(CliParseCtx* ctx) {
|
|||||||
config. */
|
config. */
|
||||||
if (entry->offset == offsetof(Config, delete_missing_args))
|
if (entry->offset == offsetof(Config, delete_missing_args))
|
||||||
config->ignore_missing_args = true;
|
config->ignore_missing_args = true;
|
||||||
/* -U/--atimes and -N/--crtimes carry their times inside the metadata
|
/* -A/--acls implies permission preservation as well as the xattr channel;
|
||||||
payload, which is only transmitted when use_metadata is set, so either
|
-X/--xattrs preserves only the extended attributes. Either sets the
|
||||||
one implies metadata transmission. This is FastSync's broad -M bundle
|
derived xattr transport bit so the sender emits the per-file xattr block. */
|
||||||
(mode/mtime travel too); it does NOT enable ownership application,
|
|
||||||
which stays opt-in via the identity flags. */
|
|
||||||
if (entry->offset == offsetof(Config, preserve_atimes) ||
|
|
||||||
entry->offset == offsetof(Config, preserve_crtimes))
|
|
||||||
config->use_metadata = true;
|
|
||||||
if (entry->offset == offsetof(Config, preserve_xattrs) ||
|
if (entry->offset == offsetof(Config, preserve_xattrs) ||
|
||||||
entry->offset == offsetof(Config, preserve_acls)) {
|
entry->offset == offsetof(Config, preserve_acls)) {
|
||||||
config->use_metadata = true;
|
|
||||||
config->use_xattrs = config->preserve_acls || config->preserve_xattrs;
|
config->use_xattrs = config->preserve_acls || config->preserve_xattrs;
|
||||||
|
if (entry->offset == offsetof(Config, preserve_acls))
|
||||||
|
config->preserve_perms = true;
|
||||||
}
|
}
|
||||||
if (entry->offset == offsetof(Config, fake_super))
|
|
||||||
config->use_metadata = true;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,7 +1197,7 @@ static bool cli_handle_inline_chmod(CliParseCtx* ctx) {
|
|||||||
ctx->exit_code = -1;
|
ctx->exit_code = -1;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
config->use_metadata = true;
|
config->preserve_perms = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1206,29 +1225,44 @@ static bool cli_handle_meta_flags(CliParseCtx* ctx) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (opt_is(arg, "-a", "--archive")) {
|
if (opt_is(arg, "-a", "--archive")) {
|
||||||
/* FastSync archive mode (-rlptD). FastSync is always recursive and always
|
/* FastSync archive mode (-rlptgoD). FastSync is always recursive and
|
||||||
* preserves hard-link/other transfer semantics per its own flags, so -a
|
* always preserves hard-link/other transfer semantics per its own flags, so
|
||||||
* implies links, metadata (perms/times), devices and specials. Owner/group
|
* -a implies links plus the four per-attribute preservation flags
|
||||||
* are NOT implied; they require an explicit identity flag
|
* (perms/times/owner/group), devices and specials. Compression and
|
||||||
* (--numeric-ids/--usermap/--groupmap/--chown/--copy-as). Compression and
|
* multithreading are NOT implied (they are no longer part of archive
|
||||||
* multithreading are NOT implied either (they are no longer part of
|
* mode). */
|
||||||
* archive mode). */
|
|
||||||
config->follow_symlinks = true;
|
config->follow_symlinks = true;
|
||||||
config->use_metadata = true;
|
config->preserve_perms = true;
|
||||||
|
config->preserve_times = true;
|
||||||
|
config->preserve_owner = true;
|
||||||
|
config->preserve_group = true;
|
||||||
config->preserve_devices = true;
|
config->preserve_devices = true;
|
||||||
config->preserve_specials = true;
|
config->preserve_specials = true;
|
||||||
log_info_message(LOG_INFO_MISC,
|
log_info_message(LOG_INFO_MISC,
|
||||||
"Enabled archive mode (-rlptD: links, metadata, devices, specials; "
|
"Enabled archive mode (-rlptgoD: links, perms, times, owner, group, "
|
||||||
"owner/group opt-in)");
|
"devices, specials)");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (opt_is(arg, "-p", "--perms")) {
|
if (opt_is(arg, "-p", "--perms")) {
|
||||||
/* rsync -p/--perms: preserve permission bits. Folded into FastSync's
|
config->preserve_perms = true;
|
||||||
* broad metadata bundle (mode/mtime travel together). */
|
|
||||||
config->use_metadata = true;
|
|
||||||
log_info_message(LOG_INFO_MISC, "Enabled permission preservation");
|
log_info_message(LOG_INFO_MISC, "Enabled permission preservation");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (opt_is(arg, "-t", "--times")) {
|
||||||
|
config->preserve_times = true;
|
||||||
|
log_info_message(LOG_INFO_MISC, "Enabled time preservation");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (opt_is(arg, "-o", "--owner")) {
|
||||||
|
config->preserve_owner = true;
|
||||||
|
log_info_message(LOG_INFO_MISC, "Enabled owner preservation");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (opt_is(arg, "-g", "--group")) {
|
||||||
|
config->preserve_group = true;
|
||||||
|
log_info_message(LOG_INFO_MISC, "Enabled group preservation");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1357,12 +1391,12 @@ static bool cli_handle_transfer_flags(CliParseCtx* ctx) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (opt_is(arg, "--preserve", NULL)) {
|
if (opt_is(arg, "--preserve", NULL)) {
|
||||||
config->use_metadata = true;
|
config->preserve_perms = true;
|
||||||
|
config->preserve_times = true;
|
||||||
log_info_message(LOG_INFO_MISC, "Enabled metadata preservation");
|
log_info_message(LOG_INFO_MISC, "Enabled metadata preservation");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (opt_is(arg, "-E", "--executability")) {
|
if (opt_is(arg, "-E", "--executability")) {
|
||||||
config->use_metadata = true;
|
|
||||||
config->use_executability = true;
|
config->use_executability = true;
|
||||||
log_info_message(LOG_INFO_MISC, "Enabled executable permission preservation");
|
log_info_message(LOG_INFO_MISC, "Enabled executable permission preservation");
|
||||||
return true;
|
return true;
|
||||||
@@ -1806,7 +1840,7 @@ static bool cli_handle_remote_basis_options(CliParseCtx* ctx) {
|
|||||||
ctx->exit_code = -1;
|
ctx->exit_code = -1;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
config->use_metadata = true;
|
config->preserve_owner = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (opt_is(arg, "--usermap", NULL)) {
|
if (opt_is(arg, "--usermap", NULL)) {
|
||||||
@@ -1819,7 +1853,7 @@ static bool cli_handle_remote_basis_options(CliParseCtx* ctx) {
|
|||||||
ctx->exit_code = -1;
|
ctx->exit_code = -1;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
config->use_metadata = true;
|
config->preserve_owner = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (strncmp(arg, "--groupmap=", 11) == 0) {
|
if (strncmp(arg, "--groupmap=", 11) == 0) {
|
||||||
@@ -1827,7 +1861,7 @@ static bool cli_handle_remote_basis_options(CliParseCtx* ctx) {
|
|||||||
ctx->exit_code = -1;
|
ctx->exit_code = -1;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
config->use_metadata = true;
|
config->preserve_group = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (opt_is(arg, "--groupmap", NULL)) {
|
if (opt_is(arg, "--groupmap", NULL)) {
|
||||||
@@ -1840,7 +1874,7 @@ static bool cli_handle_remote_basis_options(CliParseCtx* ctx) {
|
|||||||
ctx->exit_code = -1;
|
ctx->exit_code = -1;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
config->use_metadata = true;
|
config->preserve_group = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (strncmp(arg, "--chown=", 8) == 0) {
|
if (strncmp(arg, "--chown=", 8) == 0) {
|
||||||
@@ -1848,7 +1882,10 @@ static bool cli_handle_remote_basis_options(CliParseCtx* ctx) {
|
|||||||
ctx->exit_code = -1;
|
ctx->exit_code = -1;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
config->use_metadata = true;
|
if (config->chown_uid_set)
|
||||||
|
config->preserve_owner = true;
|
||||||
|
if (config->chown_gid_set)
|
||||||
|
config->preserve_group = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (opt_is(arg, "--chown", NULL)) {
|
if (opt_is(arg, "--chown", NULL)) {
|
||||||
@@ -1861,7 +1898,10 @@ static bool cli_handle_remote_basis_options(CliParseCtx* ctx) {
|
|||||||
ctx->exit_code = -1;
|
ctx->exit_code = -1;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
config->use_metadata = true;
|
if (config->chown_uid_set)
|
||||||
|
config->preserve_owner = true;
|
||||||
|
if (config->chown_gid_set)
|
||||||
|
config->preserve_group = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (strncmp(arg, "--copy-as=", 10) == 0) {
|
if (strncmp(arg, "--copy-as=", 10) == 0) {
|
||||||
@@ -1921,19 +1961,10 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool
|
|||||||
config->files_from_set = set;
|
config->files_from_set = set;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Device/special preservation recreates a node from its metadata mode (whose
|
|
||||||
S_IFMT bits carry the node kind), so --devices/--specials/-D imply metadata
|
|
||||||
transmission. --copy-devices/--write-devices treat the entry as data but a
|
|
||||||
mtime/mode-preserving transfer still benefits from metadata, so all four
|
|
||||||
imply it (FastSync's broad -M bundle; ownership stays opt-in). */
|
|
||||||
if (config->preserve_devices || config->preserve_specials || config->copy_devices ||
|
|
||||||
config->write_devices)
|
|
||||||
config->use_metadata = true;
|
|
||||||
|
|
||||||
/* The "unchanged" decision for --compare-dest/--copy-dest/--link-dest must
|
/* The "unchanged" decision for --compare-dest/--copy-dest/--link-dest must
|
||||||
* be made on the receiver against the basis directories, which requires the
|
* be made on the receiver against the basis directories, which requires the
|
||||||
* per-file STATUS_CHECK handshake: basis-dir options therefore imply
|
* per-file STATUS_CHECK handshake: basis-dir options therefore imply
|
||||||
* --incremental (and, via the block below, metadata) on the sender. */
|
* --incremental (and, via the derived bit below, metadata) on the sender. */
|
||||||
if (config_has_basis(config))
|
if (config_has_basis(config))
|
||||||
config->use_incremental = true;
|
config->use_incremental = true;
|
||||||
|
|
||||||
@@ -1966,12 +1997,27 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool
|
|||||||
config->use_incremental = true;
|
config->use_incremental = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Incremental and delta transfers need metadata unless the user disabled it. */
|
/* --incremental/--delta historically auto-enabled the metadata path, which
|
||||||
if ((config->use_incremental || config->use_delta) && !config->use_metadata &&
|
* applied mode+mtime (README: "--incremental Auto-enables --preserve").
|
||||||
!config->metadata_explicitly_disabled) {
|
* Restore that behavior by turning on the two attributes unless the user
|
||||||
log_message(LOG_LEVEL_INFO, "Enabling metadata preservation for incremental/delta transfer");
|
* explicitly negated them (--no-perms/--no-times/--no-preserve). This runs
|
||||||
config->use_metadata = true;
|
* BEFORE the derived use_metadata bit so the transport frame is still sent
|
||||||
|
* for the incremental/delta handshake even when both attributes were negated
|
||||||
|
* via --no-preserve (metadata_explicitly_disabled handles that opt-out). */
|
||||||
|
if ((config->use_incremental || config->use_delta) && !config->metadata_explicitly_disabled) {
|
||||||
|
if (!config->preserve_perms_explicit_off)
|
||||||
|
config->preserve_perms = true;
|
||||||
|
if (!config->preserve_times_explicit_off)
|
||||||
|
config->preserve_times = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Derive the transport bit from the FINAL parsed flags. Every
|
||||||
|
* preservation/ownership option that needs the metadata frame (per-attribute
|
||||||
|
* perms/times/owner/group, atimes/crtimes, executability, xattrs/acls,
|
||||||
|
* fake-super, devices/specials, chmod, identity maps/chown/copy-as, and the
|
||||||
|
* incremental/delta handshake unless --no-preserve explicitly disabled it) is
|
||||||
|
* centralized in config_derived_use_metadata(). */
|
||||||
|
config->use_metadata = config_derived_use_metadata(config);
|
||||||
/* Recompute the derived xattr flag from the FINAL preserve flags (after any
|
/* Recompute the derived xattr flag from the FINAL preserve flags (after any
|
||||||
* --no-xattrs/--no-acls negation) so the sender's wire gate always matches
|
* --no-xattrs/--no-acls negation) so the sender's wire gate always matches
|
||||||
* the flags the receiver will recompute from the received config. */
|
* the flags the receiver will recompute from the received config. */
|
||||||
@@ -2121,6 +2167,10 @@ static int load_daemon_credentials(Config* config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int main(int argc, char* argv[]) {
|
int main(int argc, char* argv[]) {
|
||||||
|
/* Capture the process umask now, while still single-threaded: the cached
|
||||||
|
* value is what file_mode_base() uses, and reading it later would race with
|
||||||
|
* receiver threads creating files. */
|
||||||
|
file_umask_capture();
|
||||||
/* The server may close a connection mid-stream (e.g. when it rejects an
|
/* The server may close a connection mid-stream (e.g. when it rejects an
|
||||||
oversized delta). Ignore SIGPIPE so that a broken TCP connection
|
oversized delta). Ignore SIGPIPE so that a broken TCP connection
|
||||||
surfaces as a clean write error instead of killing the client. */
|
surfaces as a clean write error instead of killing the client. */
|
||||||
|
|||||||
@@ -176,9 +176,10 @@ static bool prepare_scanner(const Config* config, int num_threads, PreparedScann
|
|||||||
options->excluded_paths = NULL;
|
options->excluded_paths = NULL;
|
||||||
options->excluded_mutex = NULL;
|
options->excluded_mutex = NULL;
|
||||||
options->hardlinks = NULL;
|
options->hardlinks = NULL;
|
||||||
/* P7 Wave D: capture source directory times whenever metadata rides the
|
/* P7 Wave D: capture source directory metadata when a directory attribute is
|
||||||
wire. Whether they are APPLIED is decided receiver-side (-O skips). */
|
requested (-p for modes, -t for times unless -O omits them). Whether they
|
||||||
options->capture_dir_times = config->use_metadata;
|
are APPLIED is decided receiver-side. */
|
||||||
|
options->capture_dir_times = dir_metadata_should_capture(config);
|
||||||
options->dir_entries = NULL;
|
options->dir_entries = NULL;
|
||||||
options->dir_entries_mutex = NULL;
|
options->dir_entries_mutex = NULL;
|
||||||
if (config->preserve_hard_links) {
|
if (config->preserve_hard_links) {
|
||||||
@@ -1444,7 +1445,8 @@ static bool send_directory_entry(const Client* client, File* file, const Config*
|
|||||||
stays within the receiver's bound, and a frame that would exceed it is never
|
stays within the receiver's bound, and a frame that would exceed it is never
|
||||||
emitted. */
|
emitted. */
|
||||||
static bool send_dir_times(const Client* client, const Config* config, ArrayList* dir_entries) {
|
static bool send_dir_times(const Client* client, const Config* config, ArrayList* dir_entries) {
|
||||||
if (!client || !config || !config->use_metadata || !dir_entries || dir_entries->size == 0)
|
if (!client || !config || !dir_metadata_should_capture(config) || !dir_entries ||
|
||||||
|
dir_entries->size == 0)
|
||||||
return true;
|
return true;
|
||||||
int fd = client->file_descriptor;
|
int fd = client->file_descriptor;
|
||||||
int index = 0;
|
int index = 0;
|
||||||
@@ -2250,7 +2252,7 @@ int send_files(Config* config) {
|
|||||||
receive_daemon_motd(client, config);
|
receive_daemon_motd(client, config);
|
||||||
if (!prepare_scanner(config, 0, &prepared))
|
if (!prepare_scanner(config, 0, &prepared))
|
||||||
goto send_fail;
|
goto send_fail;
|
||||||
if (config->use_metadata) {
|
if (dir_metadata_should_capture(config)) {
|
||||||
dir_entries = array_list_create(file_destroy);
|
dir_entries = array_list_create(file_destroy);
|
||||||
if (!dir_entries)
|
if (!dir_entries)
|
||||||
goto send_fail;
|
goto send_fail;
|
||||||
|
|||||||
+19
-10
@@ -20,12 +20,15 @@ void print_usage(void) {
|
|||||||
printf("Options:\n");
|
printf("Options:\n");
|
||||||
printf(" -c, --checksum Verify content by checksum instead of size+mtime\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(" -z, --compress [level] Enable compression (level 1-22, default 5)\n");
|
||||||
printf(" -a, --archive rsync archive mode (-rlptD): links, perms, times,\n");
|
printf(" -a, --archive rsync archive mode (-rlptgoD): links, perms, times,\n");
|
||||||
printf(" devices and specials; owner/group are not implied;\n");
|
printf(" owner, group, devices and specials; not\n");
|
||||||
printf(" not compression/multithreading\n");
|
printf(" compression/multithreading\n");
|
||||||
printf(" -n, --dry-run Show what would be transferred\n");
|
printf(" -n, --dry-run Show what would be transferred\n");
|
||||||
printf(" --remove-source-files Remove regular source files after successful transfer\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(" -p, --perms Preserve permission bits\n");
|
||||||
|
printf(" -t, --times Preserve modification times\n");
|
||||||
|
printf(" -o, --owner Preserve owner (uid)\n");
|
||||||
|
printf(" -g, --group Preserve group (gid)\n");
|
||||||
printf(" --ssh-port <port> SSH port (default: 22)\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(" -e, --rsh <command> Remote shell to launch on the client for the SSH\n");
|
||||||
printf(" transport (default: ssh). The command may include\n");
|
printf(" transport (default: ssh). The command may include\n");
|
||||||
@@ -161,7 +164,12 @@ void print_usage(void) {
|
|||||||
printf(" --debug=FLAGS Fine-grained debug logging (use --debug=help for flags)\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(" --info=FLAGS Fine-grained info: copy,misc,skip,stats,all,none\n");
|
||||||
printf(" none suppresses info even with --verbose\n");
|
printf(" none suppresses info even with --verbose\n");
|
||||||
printf(" --preserve Preserve file metadata (long form only)\n");
|
printf(" --preserve Preserve permissions and times (= -pt; long form only)\n");
|
||||||
|
printf(" --no-perms Negate -p/--perms\n");
|
||||||
|
printf(" --no-times Negate -t/--times\n");
|
||||||
|
printf(" --no-owner Negate -o/--owner\n");
|
||||||
|
printf(" --no-group Negate -g/--group\n");
|
||||||
|
printf(" --no-preserve Disable metadata preservation (negates --preserve)\n");
|
||||||
printf(" -E, --executability Preserve executable permission bits\n");
|
printf(" -E, --executability Preserve executable permission bits\n");
|
||||||
printf(" -U, --atimes Preserve access times\n");
|
printf(" -U, --atimes Preserve access times\n");
|
||||||
printf(" -N, --crtimes Capture birth time; cannot be applied (documented\n");
|
printf(" -N, --crtimes Capture birth time; cannot be applied (documented\n");
|
||||||
@@ -180,8 +188,9 @@ void print_usage(void) {
|
|||||||
printf(" (char/block device-node creation, --write-devices)\n");
|
printf(" (char/block device-node creation, --write-devices)\n");
|
||||||
printf(" within the confined receive root. Never elevates\n");
|
printf(" within the confined receive root. Never elevates\n");
|
||||||
printf(" privileges and never bypasses confinement; ownership\n");
|
printf(" privileges and never bypasses confinement; ownership\n");
|
||||||
printf(" is still applied only with an explicit identity flag\n");
|
printf(" is still applied only with -o/--owner, -g/--group, or an\n");
|
||||||
printf(" (--numeric-ids/--chown/--usermap/--groupmap/--copy-as)\n");
|
printf(" explicit identity flag (--numeric-ids/--chown/--usermap/\n");
|
||||||
|
printf(" --groupmap/--copy-as)\n");
|
||||||
printf(" --no-super Forbid those super-user activities even when the\n");
|
printf(" --no-super Forbid those super-user activities even when the\n");
|
||||||
printf(" receiver is running as root\n");
|
printf(" receiver is running as root\n");
|
||||||
printf(" --chmod <changes> Modify transferred permissions (rsync syntax)\n");
|
printf(" --chmod <changes> Modify transferred permissions (rsync syntax)\n");
|
||||||
@@ -196,12 +205,12 @@ void print_usage(void) {
|
|||||||
printf(" USER:GROUP, USER (owner only), :GROUP (group only); a\n");
|
printf(" USER:GROUP, USER (owner only), :GROUP (group only); a\n");
|
||||||
printf(" value of * means the current/root user as appropriate.\n");
|
printf(" value of * means the current/root user as appropriate.\n");
|
||||||
printf(" Names resolve on the source machine; @N for numerics.\n");
|
printf(" Names resolve on the source machine; @N for numerics.\n");
|
||||||
printf(" (Metadata is enabled with --preserve; -M now means\n");
|
printf(" (Implies owner/group metadata; -M now means rsync's\n");
|
||||||
printf(" rsync's --remote-option.)\n");
|
printf(" --remote-option.)\n");
|
||||||
printf(" --copy-as=USER[:GROUP] Force every written entry (files, dirs, symlinks\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(" and special nodes) to USER[:GROUP], resolved on the\n");
|
||||||
printf(" source machine like --chown. Requires a privileged\n");
|
printf(" source machine like --chown. Requires a privileged\n");
|
||||||
printf(" (root) receiver and implies --preserve; an\n");
|
printf(" (root) receiver and implies owner/group metadata; an\n");
|
||||||
printf(" unprivileged receiver refuses the transfer. Never\n");
|
printf(" unprivileged receiver refuses the transfer. Never\n");
|
||||||
printf(" switches process credentials (safe-subset; see\n");
|
printf(" switches process credentials (safe-subset; see\n");
|
||||||
printf(" RSYNC_COMPAT.md). A daemon refuses it.\n");
|
printf(" RSYNC_COMPAT.md). A daemon refuses it.\n");
|
||||||
|
|||||||
@@ -470,11 +470,12 @@ static bool receiver_save_file(File* file, void* context_pointer) {
|
|||||||
} else {
|
} else {
|
||||||
result = file_save_to_disk_full(context->config->receive_root_directory, file, context->config);
|
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
|
/* A directory's metadata is deferred, never applied inline: collect it now
|
||||||
metadata now and apply it at the end. -O/--omit-dir-times is honored by
|
and apply it at the end. -O/--omit-dir-times and --preserve_perms/-times
|
||||||
dir_time_list_apply's caller (see receiver_send_success_frame). */
|
are honored by dir_metadata_list_apply's caller (see
|
||||||
|
receiver_send_success_frame). */
|
||||||
if (result != FILE_SAVE_ERROR && file->is_dir && file->metadata &&
|
if (result != FILE_SAVE_ERROR && file->is_dir && file->metadata &&
|
||||||
dir_times_should_capture(context->config) &&
|
dir_metadata_should_capture(context->config) &&
|
||||||
!dir_time_list_add(&context->dir_times, file->path, file->metadata)) {
|
!dir_time_list_add(&context->dir_times, file->path, file->metadata)) {
|
||||||
file_destroy(file);
|
file_destroy(file);
|
||||||
return false;
|
return false;
|
||||||
@@ -514,7 +515,8 @@ static bool receiver_send_success_frame(int fd, void* context_pointer) {
|
|||||||
phases have committed, so it is finally safe to stamp directory times.
|
phases have committed, so it is finally safe to stamp directory times.
|
||||||
This runs after the deferred deletion because receiver_process commits it
|
This runs after the deferred deletion because receiver_process commits it
|
||||||
before calling this success frame. */
|
before calling this success frame. */
|
||||||
dir_time_list_apply(&context->dir_times, context->config->receive_root_directory);
|
dir_metadata_list_apply(&context->dir_times, context->config->receive_root_directory,
|
||||||
|
context->config);
|
||||||
return receiver_send_final_success(fd, context->config, &context->outcomes);
|
return receiver_send_final_success(fd, context->config, &context->outcomes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ int write_thread(void* pipeline_context) {
|
|||||||
write would clobber them); accumulate the metadata here and let the
|
write would clobber them); accumulate the metadata here and let the
|
||||||
caller apply it once every writer has drained. */
|
caller apply it once every writer has drained. */
|
||||||
if (!dry_run && result != FILE_SAVE_ERROR && file->is_dir && file->metadata &&
|
if (!dry_run && result != FILE_SAVE_ERROR && file->is_dir && file->metadata &&
|
||||||
dir_times_should_capture(context->config) &&
|
dir_metadata_should_capture(context->config) &&
|
||||||
!dir_time_list_add(&context->dir_times, file->path, file->metadata)) {
|
!dir_time_list_add(&context->dir_times, file->path, file->metadata)) {
|
||||||
file_destroy(file);
|
file_destroy(file);
|
||||||
pipeline_context_receiver_note_bytes_released(context, file_bytes);
|
pipeline_context_receiver_note_bytes_released(context, file_bytes);
|
||||||
|
|||||||
+23
-3
@@ -389,8 +389,12 @@ static const char* module_gate_check_ownership(const Config* config, const Daemo
|
|||||||
ModuleGateContext* gate_ctx) {
|
ModuleGateContext* gate_ctx) {
|
||||||
if (module->client_owner)
|
if (module->client_owner)
|
||||||
return NULL;
|
return NULL;
|
||||||
/* Ownership: refuse the whole transfer up front (a clear failure). */
|
/* Ownership: refuse the whole transfer up front (a clear failure) for a
|
||||||
if (identity_ownership_requested(config)) {
|
* client-CHOSEN owner/group request. A plain -o/-g/-a preserve-source
|
||||||
|
* request is deliberately not in this narrow set: it falls through to the
|
||||||
|
* super-mode override below, which forces all ownership activity off for this
|
||||||
|
* connection so no chown happens (the transfer itself still succeeds). */
|
||||||
|
if (identity_explicit_ownership_requested(config)) {
|
||||||
log_message(LOG_LEVEL_ERROR,
|
log_message(LOG_LEVEL_ERROR,
|
||||||
"daemon module '%s' refuses client-chosen ownership/super-user activities "
|
"daemon module '%s' refuses client-chosen ownership/super-user activities "
|
||||||
"(no `client owner = yes` opt-in); refusing",
|
"(no `client owner = yes` opt-in); refusing",
|
||||||
@@ -737,6 +741,14 @@ void handler(int file_descriptor) {
|
|||||||
* received config. */
|
* received config. */
|
||||||
if (gate_ctx.super_mode_override != -1)
|
if (gate_ctx.super_mode_override != -1)
|
||||||
config->super_mode = (SuperMode)gate_ctx.super_mode_override;
|
config->super_mode = (SuperMode)gate_ctx.super_mode_override;
|
||||||
|
/* If the client requested ownership but the effective super mode forbids it
|
||||||
|
* (operator --no-super, a privileged standalone receiver's secure default, or
|
||||||
|
* a daemon module without `client owner = yes`), say so ONCE per connection so
|
||||||
|
* a successful -a/-o/-g transfer is not mistaken for preserved ownership. */
|
||||||
|
if (config->super_mode == SUPER_MODE_OFF && identity_ownership_requested(config))
|
||||||
|
log_message(LOG_LEVEL_WARNING,
|
||||||
|
"requested ownership will NOT be applied: super-user activities are disabled "
|
||||||
|
"for this connection (operator veto, or module without `client owner = yes`)");
|
||||||
protocol_set_8_bit_output(config->eight_bit_output);
|
protocol_set_8_bit_output(config->eight_bit_output);
|
||||||
/* Server-side per-message protocol deadline for every frame from here on.
|
/* Server-side per-message protocol deadline for every frame from here on.
|
||||||
* `timeout` is not serialized, so this is the server's own config (the server
|
* `timeout` is not serialized, so this is the server's own config (the server
|
||||||
@@ -959,7 +971,7 @@ void handler(int file_descriptor) {
|
|||||||
to stamp directory times; a directory's mtime must not be clobbered by
|
to stamp directory times; a directory's mtime must not be clobbered by
|
||||||
its children or by an extra removal. */
|
its children or by an extra removal. */
|
||||||
if (transfer_ok)
|
if (transfer_ok)
|
||||||
dir_time_list_apply(&context->dir_times, config->receive_root_directory);
|
dir_metadata_list_apply(&context->dir_times, config->receive_root_directory, config);
|
||||||
}
|
}
|
||||||
if (transfer_ok) {
|
if (transfer_ok) {
|
||||||
if (!receiver_send_final_success(file_descriptor, config, &context->outcomes))
|
if (!receiver_send_final_success(file_descriptor, config, &context->outcomes))
|
||||||
@@ -1127,10 +1139,18 @@ static bool daemonize(void) {
|
|||||||
if (chdir("/") != 0)
|
if (chdir("/") != 0)
|
||||||
log_message(LOG_LEVEL_WARNING, "daemon: chdir to / failed: %s", strerror(errno));
|
log_message(LOG_LEVEL_WARNING, "daemon: chdir to / failed: %s", strerror(errno));
|
||||||
umask(0);
|
umask(0);
|
||||||
|
/* Refresh the cached umask: main() captured the launch umask before this
|
||||||
|
* (single-threaded) umask(0), and file_mode_base() must see the daemon's
|
||||||
|
* actual umask. */
|
||||||
|
file_umask_capture();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
int main(int argc, char* argv[]) {
|
int main(int argc, char* argv[]) {
|
||||||
|
/* Capture the process umask now, while still single-threaded: the cached
|
||||||
|
* value is what file_mode_base() uses, and reading it later would race with
|
||||||
|
* receiver threads creating files. */
|
||||||
|
file_umask_capture();
|
||||||
ServerCliOptions opts;
|
ServerCliOptions opts;
|
||||||
char cli_err[512];
|
char cli_err[512];
|
||||||
int parse_result = server_cli_parse(argc, argv, &opts, cli_err, sizeof(cli_err));
|
int parse_result = server_cli_parse(argc, argv, &opts, cli_err, sizeof(cli_err));
|
||||||
|
|||||||
+55
-20
@@ -2,6 +2,7 @@
|
|||||||
#include "data.h"
|
#include "data.h"
|
||||||
#include "file.h"
|
#include "file.h"
|
||||||
#include "file_receive.h"
|
#include "file_receive.h"
|
||||||
|
#include "identity.h"
|
||||||
#include "log.h"
|
#include "log.h"
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
@@ -10,11 +11,15 @@
|
|||||||
|
|
||||||
/* Serialization metadata mode for the batch stream, captured from the config at
|
/* 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
|
* 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
|
* self-describing about whether per-entry metadata was CAPTURED in the stream:
|
||||||
* reading config), so a batch written with -M is applied identically by an
|
* batch_read_apply re-reads it from the file (not from the reading config) to
|
||||||
* invoking process regardless of its own -M setting. The batch driver is a
|
* decode the chunk records correctly. Which attributes are actually APPLIED,
|
||||||
* single sequential scan pass within one thread, so this module-level flag is
|
* however, comes from the INVOKING process's per-attribute config (the
|
||||||
* safe. */
|
* FileAttrPolicy and the dir-metadata gate), so a batch written with -M is NOT
|
||||||
|
* automatically applied identically by an invoking process with a different
|
||||||
|
* -p/-t/-o/-g: --read-batch must be invoked with the same -p/-t/-o/-g as the
|
||||||
|
* write side (rsync requires the same options). 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 batch_metadata_mode = false;
|
||||||
|
|
||||||
static bool write_all_bytes(int fd, const void* data, size_t size) {
|
static bool write_all_bytes(int fd, const void* data, size_t size) {
|
||||||
@@ -91,22 +96,37 @@ int batch_read_apply(int fd, const Config* config, const char* dest_root) {
|
|||||||
if (fd < 0 || dest_root == NULL || dest_root[0] == '\0')
|
if (fd < 0 || dest_root == NULL || dest_root[0] == '\0')
|
||||||
return -1;
|
return -1;
|
||||||
|
|
||||||
|
/* Directory metadata is deferred to the end of the apply (a child write would
|
||||||
|
* otherwise clobber its parent's mtime/mode). The batch header's single
|
||||||
|
* metadata bit only says whether metadata is present in the stream; which
|
||||||
|
* attributes are APPLIED comes from the invoking process's config, so
|
||||||
|
* --read-batch must be invoked with the same -p/-t/-o/-g as the write side
|
||||||
|
* (rsync requires the same options). The identity snapshot is activated so
|
||||||
|
* -o/-g and the explicit ownership flags can apply. */
|
||||||
|
DirTimeList dir_times;
|
||||||
|
dir_time_list_init(&dir_times);
|
||||||
|
int result = -1;
|
||||||
|
if (!identity_set_active(config)) {
|
||||||
|
log_message(LOG_LEVEL_ERROR, "batch: could not activate the identity policy");
|
||||||
|
goto done;
|
||||||
|
}
|
||||||
|
|
||||||
char magic[BATCH_MAGIC_LEN];
|
char magic[BATCH_MAGIC_LEN];
|
||||||
bool eof = false;
|
bool eof = false;
|
||||||
if (!read_exact(fd, magic, BATCH_MAGIC_LEN, &eof) || eof ||
|
if (!read_exact(fd, magic, BATCH_MAGIC_LEN, &eof) || eof ||
|
||||||
memcmp(magic, BATCH_MAGIC, BATCH_MAGIC_LEN) != 0) {
|
memcmp(magic, BATCH_MAGIC, BATCH_MAGIC_LEN) != 0) {
|
||||||
log_message(LOG_LEVEL_ERROR, "batch: malformed header (bad magic)");
|
log_message(LOG_LEVEL_ERROR, "batch: malformed header (bad magic)");
|
||||||
return -1;
|
goto done;
|
||||||
}
|
}
|
||||||
unsigned char version;
|
unsigned char version;
|
||||||
if (!read_exact(fd, &version, 1, &eof) || eof || version != BATCH_FORMAT_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)");
|
log_message(LOG_LEVEL_ERROR, "batch: malformed header (bad or missing format version)");
|
||||||
return -1;
|
goto done;
|
||||||
}
|
}
|
||||||
unsigned char mode;
|
unsigned char mode;
|
||||||
if (!read_exact(fd, &mode, 1, &eof) || eof || (mode != 0 && mode != 1)) {
|
if (!read_exact(fd, &mode, 1, &eof) || eof || (mode != 0 && mode != 1)) {
|
||||||
log_message(LOG_LEVEL_ERROR, "batch: malformed header (bad metadata flag)");
|
log_message(LOG_LEVEL_ERROR, "batch: malformed header (bad metadata flag)");
|
||||||
return -1;
|
goto done;
|
||||||
}
|
}
|
||||||
bool use_metadata = mode == 1;
|
bool use_metadata = mode == 1;
|
||||||
|
|
||||||
@@ -114,47 +134,62 @@ int batch_read_apply(int fd, const Config* config, const char* dest_root) {
|
|||||||
unsigned long long length;
|
unsigned long long length;
|
||||||
if (!read_exact(fd, &length, sizeof(length), &eof)) {
|
if (!read_exact(fd, &length, sizeof(length), &eof)) {
|
||||||
log_message(LOG_LEVEL_ERROR, "batch: truncated length prefix");
|
log_message(LOG_LEVEL_ERROR, "batch: truncated length prefix");
|
||||||
return -1;
|
goto done;
|
||||||
}
|
}
|
||||||
if (eof)
|
if (eof)
|
||||||
break; /* clean end of stream */
|
break; /* clean end of stream */
|
||||||
if (length == 0 || length > BATCH_MAX_RECORD) {
|
if (length == 0 || length > BATCH_MAX_RECORD) {
|
||||||
log_message(LOG_LEVEL_ERROR, "batch: rejected record length %llu (valid range 1..%llu)",
|
log_message(LOG_LEVEL_ERROR, "batch: rejected record length %llu (valid range 1..%llu)",
|
||||||
length, (unsigned long long)BATCH_MAX_RECORD);
|
length, (unsigned long long)BATCH_MAX_RECORD);
|
||||||
return -1;
|
goto done;
|
||||||
}
|
}
|
||||||
char* record = (char*)malloc((size_t)length);
|
char* record = (char*)malloc((size_t)length);
|
||||||
if (record == NULL) {
|
if (record == NULL) {
|
||||||
log_message(LOG_LEVEL_ERROR, "batch: could not allocate a %llu-byte record", length);
|
log_message(LOG_LEVEL_ERROR, "batch: could not allocate a %llu-byte record", length);
|
||||||
return -1;
|
goto done;
|
||||||
}
|
}
|
||||||
if (!read_exact(fd, record, (size_t)length, &eof) || eof) {
|
if (!read_exact(fd, record, (size_t)length, &eof) || eof) {
|
||||||
log_message(LOG_LEVEL_ERROR, "batch: truncated chunk record");
|
log_message(LOG_LEVEL_ERROR, "batch: truncated chunk record");
|
||||||
free(record);
|
free(record);
|
||||||
return -1;
|
goto done;
|
||||||
}
|
}
|
||||||
Data* data = data_create(record, (size_t)length);
|
Data* data = data_create(record, (size_t)length);
|
||||||
if (data == NULL)
|
if (data == NULL)
|
||||||
return -1; /* data_create frees `record` on failure */
|
goto done; /* data_create frees `record` on failure */
|
||||||
Chunk* chunk = chunk_deserialize(data, use_metadata);
|
Chunk* chunk = chunk_deserialize(data, use_metadata);
|
||||||
data_destroy(data);
|
data_destroy(data);
|
||||||
if (chunk == NULL) {
|
if (chunk == NULL) {
|
||||||
log_message(LOG_LEVEL_ERROR, "batch: rejected malformed chunk record");
|
log_message(LOG_LEVEL_ERROR, "batch: rejected malformed chunk record");
|
||||||
return -1;
|
goto done;
|
||||||
}
|
}
|
||||||
for (int i = 0; i < chunk->element_count; i++) {
|
for (int i = 0; i < chunk->element_count; i++) {
|
||||||
File* file = chunk->items[i];
|
File* file = chunk->items[i];
|
||||||
chunk->items[i] = NULL;
|
chunk->items[i] = NULL;
|
||||||
if (file == NULL)
|
if (file == NULL)
|
||||||
continue;
|
continue;
|
||||||
FileSaveResult result = file_save_to_disk_full(dest_root, file, config);
|
FileSaveResult save = file_save_to_disk_full(dest_root, file, config);
|
||||||
file_destroy(file);
|
/* Accumulate directory metadata (when it applies) before the File is
|
||||||
if (result == FILE_SAVE_ERROR) {
|
* destroyed; applied once the whole stream has been consumed. */
|
||||||
|
if (save != FILE_SAVE_ERROR && file->is_dir && file->metadata &&
|
||||||
|
dir_metadata_should_capture(config) &&
|
||||||
|
!dir_time_list_add(&dir_times, file->path, file->metadata)) {
|
||||||
|
file_destroy(file);
|
||||||
chunk_destroy(chunk);
|
chunk_destroy(chunk);
|
||||||
return -1;
|
goto done;
|
||||||
|
}
|
||||||
|
file_destroy(file);
|
||||||
|
if (save == FILE_SAVE_ERROR) {
|
||||||
|
chunk_destroy(chunk);
|
||||||
|
goto done;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
chunk_destroy(chunk);
|
chunk_destroy(chunk);
|
||||||
}
|
}
|
||||||
return 0;
|
dir_metadata_list_apply(&dir_times, dest_root, config);
|
||||||
}
|
result = 0;
|
||||||
|
|
||||||
|
done:
|
||||||
|
identity_clear_active();
|
||||||
|
dir_time_list_free(&dir_times);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
+25
-1
@@ -20,6 +20,8 @@
|
|||||||
static void config_set_defaults(Config* config) {
|
static void config_set_defaults(Config* config) {
|
||||||
config->scanner_threads = 0;
|
config->scanner_threads = 0;
|
||||||
config->metadata_explicitly_disabled = false;
|
config->metadata_explicitly_disabled = false;
|
||||||
|
config->preserve_perms_explicit_off = false;
|
||||||
|
config->preserve_times_explicit_off = false;
|
||||||
config->show_progress = false;
|
config->show_progress = false;
|
||||||
config->compression_threads = 0;
|
config->compression_threads = 0;
|
||||||
config->ssh_port = 22;
|
config->ssh_port = 22;
|
||||||
@@ -194,7 +196,9 @@ static bool validate_received_config(const Config* config) {
|
|||||||
valid_wire_bool(config->dry_run) && checksum_algo_valid(config->checksum_algo) &&
|
valid_wire_bool(config->dry_run) && checksum_algo_valid(config->checksum_algo) &&
|
||||||
identity_wire_valid(config) && valid_wire_bool(config->preserve_atimes) &&
|
identity_wire_valid(config) && valid_wire_bool(config->preserve_atimes) &&
|
||||||
valid_wire_bool(config->preserve_crtimes) && valid_wire_bool(config->omit_dir_times) &&
|
valid_wire_bool(config->preserve_crtimes) && valid_wire_bool(config->omit_dir_times) &&
|
||||||
valid_wire_bool(config->omit_link_times) && valid_wire_bool(config->munge_links) &&
|
valid_wire_bool(config->omit_link_times) && valid_wire_bool(config->preserve_perms) &&
|
||||||
|
valid_wire_bool(config->preserve_times) && valid_wire_bool(config->preserve_owner) &&
|
||||||
|
valid_wire_bool(config->preserve_group) && valid_wire_bool(config->munge_links) &&
|
||||||
valid_wire_bool(config->keep_dirlinks) && valid_wire_bool(config->fake_super) &&
|
valid_wire_bool(config->keep_dirlinks) && valid_wire_bool(config->fake_super) &&
|
||||||
(!config->copy_as_set || (config->copy_as_uid >= 0 && config->copy_as_gid >= 0)) &&
|
(!config->copy_as_set || (config->copy_as_uid >= 0 && config->copy_as_gid >= 0)) &&
|
||||||
(!config->use_compression ||
|
(!config->use_compression ||
|
||||||
@@ -292,11 +296,31 @@ const char* config_invariants_error(const Config* config) {
|
|||||||
"timing; at most one may be given and each implies --delete";
|
"timing; at most one may be given and each implies --delete";
|
||||||
if (config->iconv_spec && !charset_spec_valid(config->iconv_spec))
|
if (config->iconv_spec && !charset_spec_valid(config->iconv_spec))
|
||||||
return "--iconv requires LOCAL[,REMOTE] charset names supported by iconv";
|
return "--iconv requires LOCAL[,REMOTE] charset names supported by iconv";
|
||||||
|
if ((config->preserve_perms || config->preserve_times || config->preserve_owner ||
|
||||||
|
config->preserve_group || config->preserve_atimes || config->preserve_crtimes ||
|
||||||
|
config->use_executability) &&
|
||||||
|
!config->use_metadata)
|
||||||
|
return "a preservation attribute requires metadata transmission";
|
||||||
if (config->copy_as_set && !config->use_metadata)
|
if (config->copy_as_set && !config->use_metadata)
|
||||||
return "--copy-as requires metadata preservation and cannot be combined with --no-preserve";
|
return "--copy-as requires metadata preservation and cannot be combined with --no-preserve";
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool config_derived_use_metadata(const Config* config) {
|
||||||
|
if (!config)
|
||||||
|
return false;
|
||||||
|
if (config->preserve_perms || config->preserve_times || config->preserve_owner ||
|
||||||
|
config->preserve_group || config->preserve_atimes || config->preserve_crtimes ||
|
||||||
|
config->use_executability || config->preserve_xattrs || config->preserve_acls ||
|
||||||
|
config->fake_super || config->preserve_devices || config->preserve_specials ||
|
||||||
|
config->copy_devices || config->write_devices ||
|
||||||
|
(config->chmod_spec && config->chmod_spec[0]) || config->copy_as_set ||
|
||||||
|
config->chown_uid_set || config->chown_gid_set || config->usermap_count > 0 ||
|
||||||
|
config->groupmap_count > 0 || config->update)
|
||||||
|
return true;
|
||||||
|
return (config->use_incremental || config->use_delta) && !config->metadata_explicitly_disabled;
|
||||||
|
}
|
||||||
|
|
||||||
bool config_has_basis(const Config* config) {
|
bool config_has_basis(const Config* config) {
|
||||||
return config && config->basis_count > 0;
|
return config && config->basis_count > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-5
@@ -76,7 +76,7 @@ typedef struct {
|
|||||||
typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF = 2 } SuperMode;
|
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).
|
* Config wire-field table (single source of truth for protocol 2.22.0).
|
||||||
*
|
*
|
||||||
* Every field below crosses the wire. The table is the ONLY place a
|
* 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
|
* serialized field is named: config.h expands CONFIG_WIRE_FIELDS() to declare
|
||||||
@@ -216,7 +216,11 @@ typedef enum SuperMode { SUPER_MODE_AUTO = 0, SUPER_MODE_ON = 1, SUPER_MODE_OFF
|
|||||||
X(preserve_atimes, bool, false, BOOL) \
|
X(preserve_atimes, bool, false, BOOL) \
|
||||||
X(preserve_crtimes, bool, false, BOOL) \
|
X(preserve_crtimes, bool, false, BOOL) \
|
||||||
X(omit_dir_times, bool, false, BOOL) \
|
X(omit_dir_times, bool, false, BOOL) \
|
||||||
X(omit_link_times, bool, false, BOOL)
|
X(omit_link_times, bool, false, BOOL) \
|
||||||
|
X(preserve_perms, bool, false, BOOL) \
|
||||||
|
X(preserve_times, bool, false, BOOL) \
|
||||||
|
X(preserve_owner, bool, false, BOOL) \
|
||||||
|
X(preserve_group, bool, false, BOOL)
|
||||||
|
|
||||||
#define CONFIG_WIRE_SYMLINK_TRUST_FIELDS(X) \
|
#define CONFIG_WIRE_SYMLINK_TRUST_FIELDS(X) \
|
||||||
X(munge_links, bool, false, BOOL) \
|
X(munge_links, bool, false, BOOL) \
|
||||||
@@ -266,6 +270,15 @@ typedef struct Config {
|
|||||||
* concern and is NEVER serialized into the wire config frame. */
|
* concern and is NEVER serialized into the wire config frame. */
|
||||||
int scanner_threads;
|
int scanner_threads;
|
||||||
bool metadata_explicitly_disabled;
|
bool metadata_explicitly_disabled;
|
||||||
|
/* CLIENT-ONLY (never serialized; not in CONFIG_WIRE_FIELDS). Set when the
|
||||||
|
* user explicitly turned an attribute off with --no-perms / --no-times (long
|
||||||
|
* or short form). --incremental/--delta historically auto-enabled mode and
|
||||||
|
* mtime preservation; these flags let cli_finalize_config restore that
|
||||||
|
* behavior while still honoring the explicit per-attribute negation. A
|
||||||
|
* later -p/-t re-enables the attribute directly, so the flag only prevents
|
||||||
|
* the incremental/delta implication, never a POSITIVE request. */
|
||||||
|
bool preserve_perms_explicit_off;
|
||||||
|
bool preserve_times_explicit_off;
|
||||||
bool show_progress;
|
bool show_progress;
|
||||||
int compression_threads;
|
int compression_threads;
|
||||||
int ssh_port;
|
int ssh_port;
|
||||||
@@ -579,6 +592,22 @@ typedef struct Config {
|
|||||||
/* -O/--omit-dir-times: do not apply mtimes to directories. */
|
/* -O/--omit-dir-times: do not apply mtimes to directories. */
|
||||||
/* omit_link_times */
|
/* omit_link_times */
|
||||||
/* -J/--omit-link-times: do not apply times to symlinks. */
|
/* -J/--omit-link-times: do not apply times to symlinks. */
|
||||||
|
/* preserve_perms */
|
||||||
|
/* -p/--perms: preserve the source permission bits (mode). One of the four
|
||||||
|
* per-attribute preservation flags split out of the former single
|
||||||
|
* use_metadata bundle; --chmod and -A/--acls also imply it. */
|
||||||
|
/* preserve_times */
|
||||||
|
/* -t/--times: preserve source modification times. Split out of the former
|
||||||
|
* use_metadata bundle; --preserve and -a/--archive imply it. */
|
||||||
|
/* preserve_owner */
|
||||||
|
/* -o/--owner: preserve the source owner (uid). Split out of the former
|
||||||
|
* use_metadata bundle; --usermap/--chown (and, when a uid is requested,
|
||||||
|
* --copy-as) imply it. Owner application still requires receiver privilege
|
||||||
|
* and is gated separately by the identity flags. */
|
||||||
|
/* preserve_group */
|
||||||
|
/* -g/--group: preserve the source group (gid). Split out of the former
|
||||||
|
* use_metadata bundle; --groupmap/--chown (and, when a gid is requested,
|
||||||
|
* --copy-as) imply it. */
|
||||||
/* fake_super */
|
/* fake_super */
|
||||||
/* --fake-super: receiver-only. When set, each written file additionally gets
|
/* --fake-super: receiver-only. When set, each written file additionally gets
|
||||||
* a reserved user.fastsync.stat xattr recording the source uid/gid/mode/mtime
|
* a reserved user.fastsync.stat xattr recording the source uid/gid/mode/mtime
|
||||||
@@ -623,7 +652,7 @@ typedef struct Config {
|
|||||||
* fd-relative confinement (file_open_secure_parent, O_NOFOLLOW, root checks);
|
* fd-relative confinement (file_open_secure_parent, O_NOFOLLOW, root checks);
|
||||||
* --super only permits an attempt that is already confined. Crosses the wire
|
* --super only permits an attempt that is already confined. Crosses the wire
|
||||||
* as a trailing int so the receiver can enforce the policy. See
|
* as a trailing int so the receiver can enforce the policy. See
|
||||||
* privilege_super_permitted() and identity_ownership_requested() in
|
* privilege_super_permitted() and identity_explicit_ownership_requested() in
|
||||||
* identity.h. */
|
* identity.h. */
|
||||||
/* copy_as_set */
|
/* copy_as_set */
|
||||||
/* --copy-as=USER[:GROUP] (P7 Wave E, protocol 2.18.0). Safe-subset
|
/* --copy-as=USER[:GROUP] (P7 Wave E, protocol 2.18.0). Safe-subset
|
||||||
@@ -803,8 +832,25 @@ typedef struct Config {
|
|||||||
* unknown status, or the unconsumed detail body, and the strict same-version
|
* unknown status, or the unconsumed detail body, and the strict same-version
|
||||||
* handshake (config_receive rejects a mismatched version before parsing
|
* 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
|
* anything else) is what keeps a 2.21 client and a 2.20 server from ever
|
||||||
* reaching that state. */
|
* reaching that state.
|
||||||
#define PROTOCOL_VERSION "2.21.0"
|
*
|
||||||
|
* Preserve-Attribute Split Wave: 2.21.0 -> 2.22.0.
|
||||||
|
*
|
||||||
|
* WHY the bump, grounded in the wire: this wave splits the former single
|
||||||
|
* use_metadata bundle into four independent rsync-compatible preservation
|
||||||
|
* attributes (preserve_perms / preserve_times / preserve_owner /
|
||||||
|
* preserve_group) so -p/-t/-o/-g (and their --no-* negations) become real
|
||||||
|
* drop-in flags. The binary config frame gains four serialized bools appended
|
||||||
|
* to CONFIG_WIRE_METADATA_TIMES_FIELDS after omit_link_times, in this fixed
|
||||||
|
* order: preserve_perms, preserve_times, preserve_owner, preserve_group. 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.22 client and a 2.21
|
||||||
|
* server from ever reaching that state. The fixed-width FileMetadata layout is
|
||||||
|
* UNCHANGED: the receiver still gates attribute application on use_metadata,
|
||||||
|
* which is now DERIVED from these attributes by config_derived_use_metadata(). */
|
||||||
|
#define PROTOCOL_VERSION "2.22.0"
|
||||||
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
|
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
|
||||||
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
|
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
|
||||||
#define MAX_BASIS_DIRS 64
|
#define MAX_BASIS_DIRS 64
|
||||||
@@ -918,6 +964,14 @@ bool config_has_valid_delete_timing(const Config* config);
|
|||||||
* validate_received_config() so the receiver enforces exactly the same
|
* validate_received_config() so the receiver enforces exactly the same
|
||||||
* invariants it relies on (the server is the trust boundary). */
|
* invariants it relies on (the server is the trust boundary). */
|
||||||
const char* config_invariants_error(const Config* config);
|
const char* config_invariants_error(const Config* config);
|
||||||
|
/* Single source of truth for the DERIVED transport bit (use_metadata): true
|
||||||
|
* when any configured preservation/ownership option requires the metadata
|
||||||
|
* frame to travel. Returns false when no such option is set (a bare run).
|
||||||
|
* This is a pure predicate over the config; the client lowers it into
|
||||||
|
* Config->use_metadata at the end of parsing so every implication (devices,
|
||||||
|
* executability, identity maps, incremental/delta, ...) is centralized here
|
||||||
|
* rather than scattered as direct writes. */
|
||||||
|
bool config_derived_use_metadata(const Config* config);
|
||||||
/* True when at least one --compare-dest/--copy-dest/--link-dest was set. */
|
/* True when at least one --compare-dest/--copy-dest/--link-dest was set. */
|
||||||
bool config_has_basis(const Config* config);
|
bool config_has_basis(const Config* config);
|
||||||
/* Append one basis-dir entry. Returns 0 on success, -1 on allocation failure. */
|
/* Append one basis-dir entry. Returns 0 on success, -1 on allocation failure. */
|
||||||
|
|||||||
+145
-55
@@ -6,6 +6,7 @@
|
|||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
#include <libgen.h>
|
#include <libgen.h>
|
||||||
#include <limits.h>
|
#include <limits.h>
|
||||||
|
#include <pthread.h>
|
||||||
#include <stdatomic.h>
|
#include <stdatomic.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
@@ -72,6 +73,63 @@ static unsigned long long next_temp_sequence(void) {
|
|||||||
return atomic_fetch_add_explicit(&sequence, 1, memory_order_relaxed);
|
return atomic_fetch_add_explicit(&sequence, 1, memory_order_relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Process-wide umask, captured exactly once. Reading the umask requires a
|
||||||
|
* get+set round trip (umask(0); umask(old)); doing that per write would be racy
|
||||||
|
* in the multithreaded receiver, so the value is captured at process startup by
|
||||||
|
* file_umask_capture() (called at the top of main(), before any threads exist).
|
||||||
|
* The pthread_once fallback keeps a caller that never called the capture (e.g. a
|
||||||
|
* unit test) correct. */
|
||||||
|
static unsigned g_process_umask;
|
||||||
|
static atomic_bool g_process_umask_captured;
|
||||||
|
static pthread_once_t g_process_umask_once = PTHREAD_ONCE_INIT;
|
||||||
|
|
||||||
|
static void file_capture_umask_now(void) {
|
||||||
|
mode_t mask = umask(0);
|
||||||
|
umask(mask);
|
||||||
|
g_process_umask = (unsigned)mask;
|
||||||
|
atomic_store_explicit(&g_process_umask_captured, true, memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void file_capture_umask_once(void) {
|
||||||
|
if (atomic_load_explicit(&g_process_umask_captured, memory_order_acquire))
|
||||||
|
return;
|
||||||
|
file_capture_umask_now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Re-captures the umask. Must only be called while the process is still
|
||||||
|
* single-threaded (startup, or the daemon's post-fork setup after umask(0)),
|
||||||
|
* so a later re-capture can refresh the cached value before any receiver
|
||||||
|
* thread exists. */
|
||||||
|
void file_umask_capture(void) {
|
||||||
|
file_capture_umask_now();
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned file_process_umask(void) {
|
||||||
|
if (!atomic_load_explicit(&g_process_umask_captured, memory_order_acquire))
|
||||||
|
pthread_once(&g_process_umask_once, file_capture_umask_once);
|
||||||
|
return g_process_umask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base mode applied when the policy does not take the source mode wholesale
|
||||||
|
* (i.e. --perms is off). A pre-existing destination keeps its own mode; a
|
||||||
|
* brand-new file is created like rsync: source_mode & 0777 & ~umask, with
|
||||||
|
* S_IWGRP|S_IWOTH always cleared so a client mode can never grant group/other
|
||||||
|
* write (the daemon runs with umask(0)). Only when no metadata is available at
|
||||||
|
* all does the historical fixed 0644 default apply. The -E rule (and no-op for
|
||||||
|
* a plain -t) is layered on top of this base. */
|
||||||
|
static mode_t file_mode_base(const FileMetadata* metadata, bool existing_known,
|
||||||
|
mode_t existing_mode) {
|
||||||
|
if (existing_known)
|
||||||
|
return existing_mode;
|
||||||
|
if (metadata)
|
||||||
|
/* A brand-new file follows rsync's source_mode & ~umask base, but a
|
||||||
|
* client-supplied source mode must never grant group/other write (the
|
||||||
|
* daemon runs with umask(0), so an unmasked 0666 would otherwise create a
|
||||||
|
* world-writable file). S_IWGRP|S_IWOTH are always cleared. */
|
||||||
|
return metadata->mode & 0777 & ~(mode_t)file_process_umask() & ~(S_IWGRP | S_IWOTH);
|
||||||
|
return S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH;
|
||||||
|
}
|
||||||
|
|
||||||
bool file_checksum(File* file, ChecksumAlgo algo, uint64_t seed, uint8_t* out, size_t out_capacity,
|
bool file_checksum(File* file, ChecksumAlgo algo, uint64_t seed, uint8_t* out, size_t out_capacity,
|
||||||
size_t* out_len) {
|
size_t* out_len) {
|
||||||
if (!file || !out || !out_len || !file->data)
|
if (!file || !out || !out_len || !file->data)
|
||||||
@@ -861,7 +919,7 @@ int file_open_private_dir(const char* dir_path) {
|
|||||||
* destination file) and best-effort: a per-attribute or privilege failure is
|
* destination file) and best-effort: a per-attribute or privilege failure is
|
||||||
* logged and skipped, never fatal. */
|
* logged and skipped, never fatal. */
|
||||||
static void restore_extra_fd(int fd, const FileMetadata* metadata, const FileXattrList* xattrs,
|
static void restore_extra_fd(int fd, const FileMetadata* metadata, const FileXattrList* xattrs,
|
||||||
bool fake_super) {
|
bool fake_super, FileAttrPolicy policy) {
|
||||||
xattr_apply_fd(fd, xattrs);
|
xattr_apply_fd(fd, xattrs);
|
||||||
if (fake_super && metadata) {
|
if (fake_super && metadata) {
|
||||||
fake_super_store_fd(fd, (uint32_t)metadata->uid, (uint32_t)metadata->gid,
|
fake_super_store_fd(fd, (uint32_t)metadata->uid, (uint32_t)metadata->gid,
|
||||||
@@ -869,15 +927,16 @@ static void restore_extra_fd(int fd, const FileMetadata* metadata, const FileXat
|
|||||||
/* Replay: re-apply the recorded uid/gid/mode/mtime fd-relative so a save
|
/* Replay: re-apply the recorded uid/gid/mode/mtime fd-relative so a save
|
||||||
under --fake-super restores the attrs (when privileged) instead of only
|
under --fake-super restores the attrs (when privileged) instead of only
|
||||||
recording them. Best-effort; fake_super_restore_fd silently skips a
|
recording them. Best-effort; fake_super_restore_fd silently skips a
|
||||||
non-root fchown EPERM/EACCES and never fatal. */
|
non-root fchown EPERM/EACCES and never fatal. The replayed mode/mtime
|
||||||
fake_super_restore_fd(fd);
|
honor the per-attribute policy so fake-super cannot bypass the split. */
|
||||||
|
fake_super_restore_fd(fd, policy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool file_to_disk_secure_impl(const char* path, const void* data,
|
static bool file_to_disk_secure_impl(const char* path, const void* data,
|
||||||
unsigned long long data_size, bool inplace, bool sparse,
|
unsigned long long data_size, bool inplace, bool sparse,
|
||||||
bool preallocate, const FileMetadata* metadata,
|
bool preallocate, const FileMetadata* metadata,
|
||||||
bool preserve_executability, bool update, bool no_replace,
|
FileAttrPolicy policy, bool update, bool no_replace,
|
||||||
bool use_fsync, const char* temp_dir,
|
bool use_fsync, const char* temp_dir,
|
||||||
const FileXattrList* xattrs, bool fake_super,
|
const FileXattrList* xattrs, bool fake_super,
|
||||||
bool keep_partial) {
|
bool keep_partial) {
|
||||||
@@ -887,6 +946,13 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
|
|||||||
return false;
|
return false;
|
||||||
int fd = -1;
|
int fd = -1;
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
|
/* The base mode applied when --perms is off (neither the source mode nor an
|
||||||
|
* exec-only change is taken wholesale): a pre-existing destination keeps its
|
||||||
|
* own mode (special bits dropped), while a brand-new file uses
|
||||||
|
* source&~umask when metadata is available (see file_mode_base) or 0644 when
|
||||||
|
* there is none. Captured from the destination probe before the write. */
|
||||||
|
mode_t existing_mode = S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH;
|
||||||
|
bool existing_mode_known = false;
|
||||||
if (inplace) {
|
if (inplace) {
|
||||||
/* --inplace writes directly into the destination; a scratch --temp-dir
|
/* --inplace writes directly into the destination; a scratch --temp-dir
|
||||||
does not apply and must never redirect these writes. */
|
does not apply and must never redirect these writes. */
|
||||||
@@ -897,10 +963,16 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
|
|||||||
super-mode gate (a client-controlled device write). fstatat with
|
super-mode gate (a client-controlled device write). fstatat with
|
||||||
AT_SYMLINK_NOFOLLOW does not follow a symlink and does not block. */
|
AT_SYMLINK_NOFOLLOW does not follow a symlink and does not block. */
|
||||||
struct stat pre_stat;
|
struct stat pre_stat;
|
||||||
if (fstatat(dirfd, leaf, &pre_stat, AT_SYMLINK_NOFOLLOW) == 0 && !S_ISREG(pre_stat.st_mode)) {
|
if (fstatat(dirfd, leaf, &pre_stat, AT_SYMLINK_NOFOLLOW) == 0) {
|
||||||
close(dirfd);
|
if (!S_ISREG(pre_stat.st_mode)) {
|
||||||
free(leaf);
|
close(dirfd);
|
||||||
return false;
|
free(leaf);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/* Capture the old destination mode before the overwrite so a no--p/-E
|
||||||
|
* write can restore it (the write itself may clear setuid/setgid). */
|
||||||
|
existing_mode = pre_stat.st_mode & 0777;
|
||||||
|
existing_mode_known = true;
|
||||||
}
|
}
|
||||||
/* O_NONBLOCK: a no-op for a regular file, but a raced-in FIFO cannot block
|
/* O_NONBLOCK: a no-op for a regular file, but a raced-in FIFO cannot block
|
||||||
the open before the post-open S_ISREG re-check rejects it. */
|
the open before the post-open S_ISREG re-check rejects it. */
|
||||||
@@ -953,15 +1025,25 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
|
|||||||
/* Normalize the mode: apply the metadata-derived safe mode when the
|
/* Normalize the mode: apply the metadata-derived safe mode when the
|
||||||
sender supplied metadata (setuid/setgid/sticky are never honored);
|
sender supplied metadata (setuid/setgid/sticky are never honored);
|
||||||
otherwise fall back to a safe default so dangerous bits on an
|
otherwise fall back to a safe default so dangerous bits on an
|
||||||
existing destination cannot survive an overwrite. */
|
existing destination cannot survive an overwrite. When the policy
|
||||||
|
requests neither -p nor -E the source mode is deliberately ignored
|
||||||
|
and the pre-existing destination mode (or 0644 for a new file) is
|
||||||
|
restored instead. The exec-bits-only -E change is likewise applied
|
||||||
|
on top of that destination-derived base, not the scratch file's
|
||||||
|
0600. */
|
||||||
if (ok) {
|
if (ok) {
|
||||||
if (metadata)
|
if (metadata) {
|
||||||
ok = file_restore_metadata_fd(fd, metadata, preserve_executability);
|
if (!policy.perms &&
|
||||||
else if (fchmod(fd, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) != 0)
|
fchmod(fd, file_mode_base(metadata, existing_mode_known, existing_mode)) != 0)
|
||||||
|
ok = false;
|
||||||
|
if (ok)
|
||||||
|
ok = file_restore_metadata_fd(fd, metadata, policy);
|
||||||
|
} else if (fchmod(fd, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) != 0) {
|
||||||
ok = false;
|
ok = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (ok)
|
if (ok)
|
||||||
restore_extra_fd(fd, metadata, xattrs, fake_super);
|
restore_extra_fd(fd, metadata, xattrs, fake_super, policy);
|
||||||
if (ok && use_fsync)
|
if (ok && use_fsync)
|
||||||
ok = fsync(fd) == 0;
|
ok = fsync(fd) == 0;
|
||||||
}
|
}
|
||||||
@@ -974,16 +1056,21 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
|
|||||||
install failure (partial data may exist, --partial may retain it) from a
|
install failure (partial data may exist, --partial may retain it) from a
|
||||||
pre-write validation failure (nothing to retain). */
|
pre-write validation failure (nothing to retain). */
|
||||||
bool write_attempted = false;
|
bool write_attempted = false;
|
||||||
if (update && metadata) {
|
/* Probe the destination ONCE up front: it both drives the --update check
|
||||||
/* This check protects the normal atomic path as far as possible. A
|
and records the pre-existing mode the no--p/-E fallback preserves. */
|
||||||
concurrent replacement can still occur before the final rename. */
|
struct stat destination_stat;
|
||||||
struct stat destination_stat;
|
bool destination_is_regular =
|
||||||
if (fstatat(dirfd, leaf, &destination_stat, AT_SYMLINK_NOFOLLOW) == 0 &&
|
fstatat(dirfd, leaf, &destination_stat, AT_SYMLINK_NOFOLLOW) == 0 &&
|
||||||
S_ISREG(destination_stat.st_mode) && stat_is_newer(&destination_stat, metadata)) {
|
S_ISREG(destination_stat.st_mode);
|
||||||
close(dirfd);
|
if (destination_is_regular) {
|
||||||
free(leaf);
|
existing_mode = destination_stat.st_mode & 0777;
|
||||||
return true;
|
existing_mode_known = true;
|
||||||
}
|
}
|
||||||
|
if (update && metadata && destination_is_regular &&
|
||||||
|
stat_is_newer(&destination_stat, metadata)) {
|
||||||
|
close(dirfd);
|
||||||
|
free(leaf);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
/* Scratch directory for the temporary working copy. When NULL the temp
|
/* Scratch directory for the temporary working copy. When NULL the temp
|
||||||
file is created in the destination directory, exactly as historically. */
|
file is created in the destination directory, exactly as historically. */
|
||||||
@@ -1061,10 +1148,19 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
|
|||||||
? file_store_write_sparse(fd, (const unsigned char*)data, data_size)
|
? file_store_write_sparse(fd, (const unsigned char*)data, data_size)
|
||||||
: write_all(fd, data, data_size);
|
: write_all(fd, data, data_size);
|
||||||
}
|
}
|
||||||
if (ok && metadata)
|
if (ok) {
|
||||||
ok = file_restore_metadata_fd(fd, metadata, preserve_executability);
|
if (metadata) {
|
||||||
|
if (!policy.perms &&
|
||||||
|
fchmod(fd, file_mode_base(metadata, existing_mode_known, existing_mode)) != 0)
|
||||||
|
ok = false;
|
||||||
|
if (ok)
|
||||||
|
ok = file_restore_metadata_fd(fd, metadata, policy);
|
||||||
|
} else if (fchmod(fd, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) != 0) {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (ok)
|
if (ok)
|
||||||
restore_extra_fd(fd, metadata, xattrs, fake_super);
|
restore_extra_fd(fd, metadata, xattrs, fake_super, policy);
|
||||||
if (ok && use_fsync)
|
if (ok && use_fsync)
|
||||||
ok = fsync(fd) == 0;
|
ok = fsync(fd) == 0;
|
||||||
}
|
}
|
||||||
@@ -1129,38 +1225,33 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
|
|||||||
|
|
||||||
bool file_to_disk_secure(const char* path, const void* data, unsigned long long data_size,
|
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 inplace, bool sparse, bool preallocate, const FileMetadata* metadata,
|
||||||
bool preserve_executability, const char* temp_dir) {
|
FileAttrPolicy policy, const char* temp_dir) {
|
||||||
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
|
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
|
||||||
preserve_executability, false, false, false, temp_dir, NULL,
|
policy, false, false, false, temp_dir, NULL, false, false);
|
||||||
false, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool file_to_disk_secure_update(const char* path, const void* data, unsigned long long data_size,
|
bool file_to_disk_secure_update(const char* path, const void* data, unsigned long long data_size,
|
||||||
bool inplace, bool sparse, bool preallocate,
|
bool inplace, bool sparse, bool preallocate,
|
||||||
const FileMetadata* metadata, bool preserve_executability,
|
const FileMetadata* metadata, FileAttrPolicy policy,
|
||||||
const char* temp_dir) {
|
const char* temp_dir) {
|
||||||
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
|
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
|
||||||
preserve_executability, true, false, false, temp_dir, NULL, false,
|
policy, true, false, false, temp_dir, NULL, false, false);
|
||||||
false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool file_to_disk_secure_with_fsync(const char* path, const void* data,
|
bool file_to_disk_secure_with_fsync(const char* path, const void* data,
|
||||||
unsigned long long data_size, bool inplace, bool sparse,
|
unsigned long long data_size, bool inplace, bool sparse,
|
||||||
bool preallocate, const FileMetadata* metadata,
|
bool preallocate, const FileMetadata* metadata,
|
||||||
bool preserve_executability, bool use_fsync,
|
FileAttrPolicy policy, bool use_fsync, const char* temp_dir) {
|
||||||
const char* temp_dir) {
|
|
||||||
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
|
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
|
||||||
preserve_executability, false, false, use_fsync, temp_dir, NULL,
|
policy, false, false, use_fsync, temp_dir, NULL, false, false);
|
||||||
false, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool file_to_disk_secure_no_replace(const char* path, const void* data,
|
bool file_to_disk_secure_no_replace(const char* path, const void* data,
|
||||||
unsigned long long data_size, bool sparse, bool preallocate,
|
unsigned long long data_size, bool sparse, bool preallocate,
|
||||||
const FileMetadata* metadata, bool preserve_executability,
|
const FileMetadata* metadata, FileAttrPolicy policy,
|
||||||
const char* temp_dir) {
|
const char* temp_dir) {
|
||||||
return file_to_disk_secure_impl(path, data, data_size, false, sparse, preallocate, metadata,
|
return file_to_disk_secure_impl(path, data, data_size, false, sparse, preallocate, metadata,
|
||||||
preserve_executability, false, true, false, temp_dir, NULL, false,
|
policy, false, true, false, temp_dir, NULL, false, false);
|
||||||
false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Receiver write-path variant that also applies the per-file xattrs (-X/-A)
|
/* Receiver write-path variant that also applies the per-file xattrs (-X/-A)
|
||||||
@@ -1170,13 +1261,12 @@ bool file_to_disk_secure_no_replace(const char* path, const void* data,
|
|||||||
* failed write's temp. See file_to_disk_secure_impl for the semantics. */
|
* failed write's temp. See file_to_disk_secure_impl for the semantics. */
|
||||||
bool file_to_disk_secure_attrs(const char* path, const void* data, unsigned long long data_size,
|
bool file_to_disk_secure_attrs(const char* path, const void* data, unsigned long long data_size,
|
||||||
bool inplace, bool sparse, bool preallocate,
|
bool inplace, bool sparse, bool preallocate,
|
||||||
const FileMetadata* metadata, bool preserve_executability,
|
const FileMetadata* metadata, FileAttrPolicy policy, bool update,
|
||||||
bool update, bool no_replace, bool use_fsync,
|
bool no_replace, bool use_fsync, const FileXattrList* xattrs,
|
||||||
const FileXattrList* xattrs, bool fake_super, bool keep_partial,
|
bool fake_super, bool keep_partial, const char* temp_dir) {
|
||||||
const char* temp_dir) {
|
|
||||||
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
|
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
|
||||||
preserve_executability, update, no_replace, use_fsync, temp_dir,
|
policy, update, no_replace, use_fsync, temp_dir, xattrs,
|
||||||
xattrs, fake_super, keep_partial);
|
fake_super, keep_partial);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Atomic --link-dest install. The destination is replaced (via a temporary
|
/* Atomic --link-dest install. The destination is replaced (via a temporary
|
||||||
@@ -1197,7 +1287,7 @@ bool file_to_disk_secure_attrs(const char* path, const void* data, unsigned long
|
|||||||
static bool file_to_disk_secure_link_impl(const char* path, const char* basis_path,
|
static bool file_to_disk_secure_link_impl(const char* path, const char* basis_path,
|
||||||
const void* data, unsigned long long data_size,
|
const void* data, unsigned long long data_size,
|
||||||
bool preallocate, const FileMetadata* metadata,
|
bool preallocate, const FileMetadata* metadata,
|
||||||
bool preserve_executability, bool use_fsync,
|
FileAttrPolicy policy, bool use_fsync,
|
||||||
const FileXattrList* xattrs, bool fake_super,
|
const FileXattrList* xattrs, bool fake_super,
|
||||||
const char* temp_dir) {
|
const char* temp_dir) {
|
||||||
if (!path || !basis_path)
|
if (!path || !basis_path)
|
||||||
@@ -1286,8 +1376,8 @@ static bool file_to_disk_secure_link_impl(const char* path, const char* basis_pa
|
|||||||
/* The basis file could not be linked in (missing, cross-device, refused
|
/* The basis file could not be linked in (missing, cross-device, refused
|
||||||
by the filesystem). Write a byte-identical local copy instead. */
|
by the filesystem). Write a byte-identical local copy instead. */
|
||||||
return file_to_disk_secure_attrs(path, data, data_size, false, false, preallocate, metadata,
|
return file_to_disk_secure_attrs(path, data, data_size, false, false, preallocate, metadata,
|
||||||
preserve_executability, false, false, use_fsync, xattrs,
|
policy, false, false, use_fsync, xattrs, fake_super, false,
|
||||||
fake_super, false, temp_dir);
|
temp_dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scratch_dirfd >= 0)
|
if (scratch_dirfd >= 0)
|
||||||
@@ -1299,25 +1389,25 @@ static bool file_to_disk_secure_link_impl(const char* path, const char* basis_pa
|
|||||||
|
|
||||||
bool file_to_disk_secure_link(const char* path, const char* basis_path, const void* data,
|
bool file_to_disk_secure_link(const char* path, const char* basis_path, const void* data,
|
||||||
unsigned long long data_size, bool preallocate,
|
unsigned long long data_size, bool preallocate,
|
||||||
const FileMetadata* metadata, bool preserve_executability,
|
const FileMetadata* metadata, FileAttrPolicy policy, bool use_fsync,
|
||||||
bool use_fsync, const char* temp_dir) {
|
const char* temp_dir) {
|
||||||
return file_to_disk_secure_link_impl(path, basis_path, data, data_size, preallocate, metadata,
|
return file_to_disk_secure_link_impl(path, basis_path, data, data_size, preallocate, metadata,
|
||||||
preserve_executability, use_fsync, NULL, false, temp_dir);
|
policy, use_fsync, NULL, false, temp_dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool file_to_disk_secure_link_attrs(const char* path, const char* basis_path, const void* data,
|
bool file_to_disk_secure_link_attrs(const char* path, const char* basis_path, const void* data,
|
||||||
unsigned long long data_size, bool preallocate,
|
unsigned long long data_size, bool preallocate,
|
||||||
const FileMetadata* metadata, bool preserve_executability,
|
const FileMetadata* metadata, FileAttrPolicy policy,
|
||||||
bool use_fsync, const FileXattrList* xattrs, bool fake_super,
|
bool use_fsync, const FileXattrList* xattrs, bool fake_super,
|
||||||
const char* temp_dir) {
|
const char* temp_dir) {
|
||||||
return file_to_disk_secure_link_impl(path, basis_path, data, data_size, preallocate, metadata,
|
return file_to_disk_secure_link_impl(path, basis_path, data, data_size, preallocate, metadata,
|
||||||
preserve_executability, use_fsync, xattrs, fake_super,
|
policy, use_fsync, xattrs, fake_super, temp_dir);
|
||||||
temp_dir);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool file_write_to_disk(const char* path, const void* data, unsigned long long data_size,
|
bool file_write_to_disk(const char* path, const void* data, unsigned long long data_size,
|
||||||
bool inplace, bool sparse) {
|
bool inplace, bool sparse) {
|
||||||
if (!path || (!data && data_size != 0) || has_path_traversal(path))
|
if (!path || (!data && data_size != 0) || has_path_traversal(path))
|
||||||
return false;
|
return false;
|
||||||
return file_to_disk_secure(path, data, data_size, inplace, sparse, false, NULL, false, NULL);
|
FileAttrPolicy policy = {false, false, false, false};
|
||||||
|
return file_to_disk_secure(path, data, data_size, inplace, sparse, false, NULL, policy, NULL);
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-12
@@ -28,6 +28,17 @@ void file_metadata_destroy(void* metadata);
|
|||||||
/* --open-noatime process-wide sender policy; see file.c. */
|
/* --open-noatime process-wide sender policy; see file.c. */
|
||||||
void file_set_open_noatime(bool enable);
|
void file_set_open_noatime(bool enable);
|
||||||
bool file_get_open_noatime(void);
|
bool file_get_open_noatime(void);
|
||||||
|
/* Capture the process umask ONCE, before any threads are created. Call this at
|
||||||
|
* the very top of main() in both entry points so the cached value is read while
|
||||||
|
* the process is still single-threaded: reading the umask needs a get+set round
|
||||||
|
* trip (umask(0); umask(old)), which would race against receiver threads
|
||||||
|
* creating files if it happened during the first write. Idempotent and safe to
|
||||||
|
* call more than once. */
|
||||||
|
void file_umask_capture(void);
|
||||||
|
/* Process-wide umask, captured once (thread-safe). Used to derive the mode of
|
||||||
|
* a brand-new destination like rsync: source_mode & 0777 & ~umask. Falls back
|
||||||
|
* to file_umask_capture() (behind pthread_once) if capture was never called. */
|
||||||
|
unsigned file_process_umask(void);
|
||||||
/* Open `path` read-only for transfer, honouring --open-noatime when set. */
|
/* Open `path` read-only for transfer, honouring --open-noatime when set. */
|
||||||
int file_open_for_read(const char* path);
|
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 file_write_to_disk(const char* path, const void* data, unsigned long long data_size,
|
||||||
@@ -90,22 +101,21 @@ int file_open_private_dir(const char* dir_path);
|
|||||||
behavior. --inplace writes never use temp_dir. */
|
behavior. --inplace writes never use temp_dir. */
|
||||||
bool file_to_disk_secure(const char* path, const void* data, unsigned long long data_size,
|
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 inplace, bool sparse, bool preallocate, const FileMetadata* metadata,
|
||||||
bool preserve_executability, const char* temp_dir);
|
FileAttrPolicy policy, const char* temp_dir);
|
||||||
bool file_to_disk_secure_with_fsync(const char* path, const void* data,
|
bool file_to_disk_secure_with_fsync(const char* path, const void* data,
|
||||||
unsigned long long data_size, bool inplace, bool sparse,
|
unsigned long long data_size, bool inplace, bool sparse,
|
||||||
bool preallocate, const FileMetadata* metadata,
|
bool preallocate, const FileMetadata* metadata,
|
||||||
bool preserve_executability, bool use_fsync,
|
FileAttrPolicy policy, bool use_fsync, const char* temp_dir);
|
||||||
const char* temp_dir);
|
|
||||||
/* With update enabled, an existing newer destination is left untouched. The
|
/* With update enabled, an existing newer destination is left untouched. The
|
||||||
check is descriptor-based for inplace writes; atomic replacement still has
|
check is descriptor-based for inplace writes; atomic replacement still has
|
||||||
an unavoidable final rename race without filesystem locking. */
|
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 file_to_disk_secure_update(const char* path, const void* data, unsigned long long data_size,
|
||||||
bool inplace, bool sparse, bool preallocate,
|
bool inplace, bool sparse, bool preallocate,
|
||||||
const FileMetadata* metadata, bool preserve_executability,
|
const FileMetadata* metadata, FileAttrPolicy policy,
|
||||||
const char* temp_dir);
|
const char* temp_dir);
|
||||||
bool file_to_disk_secure_no_replace(const char* path, const void* data,
|
bool file_to_disk_secure_no_replace(const char* path, const void* data,
|
||||||
unsigned long long data_size, bool sparse, bool preallocate,
|
unsigned long long data_size, bool sparse, bool preallocate,
|
||||||
const FileMetadata* metadata, bool preserve_executability,
|
const FileMetadata* metadata, FileAttrPolicy policy,
|
||||||
const char* temp_dir);
|
const char* temp_dir);
|
||||||
/* Receiver write-path variant that also applies per-file xattrs (-X/-A) and the
|
/* 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` /
|
* --fake-super stat xattr fd-relative before the final rename. `update` /
|
||||||
@@ -113,10 +123,9 @@ bool file_to_disk_secure_no_replace(const char* path, const void* data,
|
|||||||
* enables --partial best-effort retention of a failed write's temp. */
|
* 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 file_to_disk_secure_attrs(const char* path, const void* data, unsigned long long data_size,
|
||||||
bool inplace, bool sparse, bool preallocate,
|
bool inplace, bool sparse, bool preallocate,
|
||||||
const FileMetadata* metadata, bool preserve_executability,
|
const FileMetadata* metadata, FileAttrPolicy policy, bool update,
|
||||||
bool update, bool no_replace, bool use_fsync,
|
bool no_replace, bool use_fsync, const FileXattrList* xattrs,
|
||||||
const FileXattrList* xattrs, bool fake_super, bool keep_partial,
|
bool fake_super, bool keep_partial, const char* temp_dir);
|
||||||
const char* temp_dir);
|
|
||||||
/* Atomic --link-dest install: replace `path` with a hard link to `basis_path`
|
/* 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
|
(via a temp name + rename); fall back to a byte-identical local copy from
|
||||||
`data` when the link is impossible (EXDEV/EPERM/unsupported filesystem).
|
`data` when the link is impossible (EXDEV/EPERM/unsupported filesystem).
|
||||||
@@ -125,15 +134,15 @@ bool file_to_disk_secure_attrs(const char* path, const void* data, unsigned long
|
|||||||
never re-allocated). */
|
never re-allocated). */
|
||||||
bool file_to_disk_secure_link(const char* path, const char* basis_path, const void* data,
|
bool file_to_disk_secure_link(const char* path, const char* basis_path, const void* data,
|
||||||
unsigned long long data_size, bool preallocate,
|
unsigned long long data_size, bool preallocate,
|
||||||
const FileMetadata* metadata, bool preserve_executability,
|
const FileMetadata* metadata, FileAttrPolicy policy, bool use_fsync,
|
||||||
bool use_fsync, const char* temp_dir);
|
const char* temp_dir);
|
||||||
/* Like file_to_disk_secure_link, but the byte-copy fallback also applies the
|
/* 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
|
* 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
|
* successful hard link no attributes are applied (the shared inode already
|
||||||
* carries the basis's). */
|
* carries the basis's). */
|
||||||
bool file_to_disk_secure_link_attrs(const char* path, const char* basis_path, const void* data,
|
bool file_to_disk_secure_link_attrs(const char* path, const char* basis_path, const void* data,
|
||||||
unsigned long long data_size, bool preallocate,
|
unsigned long long data_size, bool preallocate,
|
||||||
const FileMetadata* metadata, bool preserve_executability,
|
const FileMetadata* metadata, FileAttrPolicy policy,
|
||||||
bool use_fsync, const FileXattrList* xattrs, bool fake_super,
|
bool use_fsync, const FileXattrList* xattrs, bool fake_super,
|
||||||
const char* temp_dir);
|
const char* temp_dir);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#ifndef FILE_ATTR_H
|
||||||
|
#define FILE_ATTR_H
|
||||||
|
|
||||||
|
#include "config.h"
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Per-attribute receiver policy for applying a transmitted FileMetadata. This
|
||||||
|
* is the split-out replacement for the former single use_metadata bundle: each
|
||||||
|
* flag is applied independently, matching rsync's -p/-t/-o/-g/-E/-U semantics.
|
||||||
|
* `use_metadata` remains the transport/presence gate (whether the metadata frame
|
||||||
|
* travelled at all); this struct decides which attributes are ACTUALLY applied.
|
||||||
|
*
|
||||||
|
* It lives in its own header (rather than metadata.h) because xattr.h's
|
||||||
|
* fake_super_restore_fd() takes one and metadata.h <-> file_types.h form an
|
||||||
|
* include cycle that must not be entered from xattr.h.
|
||||||
|
*
|
||||||
|
* The mode leg is: perms wins over executability; an exec-bits-only change is
|
||||||
|
* made only when perms is off; when neither is set the receiver deliberately
|
||||||
|
* sets no source mode. file.c then substitutes the pre-existing destination
|
||||||
|
* mode for a brand-new destination with metadata it uses the sanitized
|
||||||
|
* source-mode-&-umask base (S_IWGRP|S_IWOTH cleared), and the fixed 0644
|
||||||
|
* default only when no metadata is available at all, so a no--p overwrite
|
||||||
|
* does not lose the destination's perms.
|
||||||
|
*/
|
||||||
|
typedef struct FileAttrPolicy {
|
||||||
|
bool perms; /* config->preserve_perms: apply the source mode bits */
|
||||||
|
bool times; /* config->preserve_times: apply the source mtime */
|
||||||
|
bool atimes; /* config->preserve_atimes (-U): apply the source atime */
|
||||||
|
bool executability; /* config->use_executability (-E): exec-bits-only mode */
|
||||||
|
} FileAttrPolicy;
|
||||||
|
|
||||||
|
/* Build the per-attribute policy from a connection's Config. A NULL config
|
||||||
|
* yields the all-off policy (no attribute application). */
|
||||||
|
FileAttrPolicy file_attr_policy_from_config(const Config* config);
|
||||||
|
|
||||||
|
#endif
|
||||||
+112
-47
@@ -52,7 +52,7 @@ static FileSaveResult file_stage_delayed_update(const char* root_directory,
|
|||||||
if (!config)
|
if (!config)
|
||||||
return FILE_SAVE_ERROR;
|
return FILE_SAVE_ERROR;
|
||||||
bool sparse = config->preserve_sparse;
|
bool sparse = config->preserve_sparse;
|
||||||
bool preserve_executability = config->use_executability;
|
FileAttrPolicy policy = file_attr_policy_from_config(config);
|
||||||
|
|
||||||
if (config->existing && !file_path_exists_secure(destination_path))
|
if (config->existing && !file_path_exists_secure(destination_path))
|
||||||
return FILE_SAVE_SKIPPED;
|
return FILE_SAVE_SKIPPED;
|
||||||
@@ -91,13 +91,12 @@ static FileSaveResult file_stage_delayed_update(const char* root_directory,
|
|||||||
bool ok;
|
bool ok;
|
||||||
if (file->basis_link) {
|
if (file->basis_link) {
|
||||||
ok = file_to_disk_secure_link(staged_path, file->basis_link, file->data->data, file->data->size,
|
ok = file_to_disk_secure_link(staged_path, file->basis_link, file->data->data, file->data->size,
|
||||||
config->preallocate, metadata, preserve_executability,
|
config->preallocate, metadata, policy, config->use_fsync, NULL);
|
||||||
config->use_fsync, NULL);
|
|
||||||
} else {
|
} else {
|
||||||
ok = file_to_disk_secure_attrs(staged_path, file->data->data, file->data->size, false, sparse,
|
ok =
|
||||||
config->preallocate, metadata, preserve_executability, false,
|
file_to_disk_secure_attrs(staged_path, file->data->data, file->data->size, false, sparse,
|
||||||
false, config->use_fsync, file->xattrs, config->fake_super,
|
config->preallocate, metadata, policy, false, false,
|
||||||
false, NULL);
|
config->use_fsync, file->xattrs, config->fake_super, false, NULL);
|
||||||
}
|
}
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
free(staged_path);
|
free(staged_path);
|
||||||
@@ -229,7 +228,7 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool preallocate = cfg && cfg->preallocate;
|
bool preallocate = cfg && cfg->preallocate;
|
||||||
bool preserve_executability = cfg && cfg->use_executability;
|
FileAttrPolicy policy = file_attr_policy_from_config(cfg);
|
||||||
bool use_fsync = cfg && cfg->use_fsync;
|
bool use_fsync = cfg && cfg->use_fsync;
|
||||||
|
|
||||||
if (cfg->delay_updates) {
|
if (cfg->delay_updates) {
|
||||||
@@ -265,9 +264,9 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
|
|||||||
}
|
}
|
||||||
FileXattrList* sibling_xattrs =
|
FileXattrList* sibling_xattrs =
|
||||||
cfg->use_xattrs ? xattr_capture_path(staged_first, cfg->preserve_acls) : NULL;
|
cfg->use_xattrs ? xattr_capture_path(staged_first, cfg->preserve_acls) : NULL;
|
||||||
bool ok = file_to_disk_secure_link_attrs(
|
bool ok = file_to_disk_secure_link_attrs(staged_sibling, staged_first, content, content_size,
|
||||||
staged_sibling, staged_first, content, content_size, preallocate, file->metadata,
|
preallocate, file->metadata, policy, use_fsync,
|
||||||
preserve_executability, use_fsync, sibling_xattrs, cfg ? cfg->fake_super : false, NULL);
|
sibling_xattrs, cfg ? cfg->fake_super : false, NULL);
|
||||||
xattr_list_free(sibling_xattrs);
|
xattr_list_free(sibling_xattrs);
|
||||||
free(content);
|
free(content);
|
||||||
if (ok)
|
if (ok)
|
||||||
@@ -298,9 +297,9 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
|
|||||||
const char* temp_dir = (cfg && cfg->temp_dir) ? cfg->temp_dir : NULL;
|
const char* temp_dir = (cfg && cfg->temp_dir) ? cfg->temp_dir : NULL;
|
||||||
FileXattrList* sibling_xattrs =
|
FileXattrList* sibling_xattrs =
|
||||||
cfg->use_xattrs ? xattr_capture_path(first_disk, cfg->preserve_acls) : NULL;
|
cfg->use_xattrs ? xattr_capture_path(first_disk, cfg->preserve_acls) : NULL;
|
||||||
bool ok = file_to_disk_secure_link_attrs(
|
bool ok = file_to_disk_secure_link_attrs(destination_path, first_disk, content, content_size,
|
||||||
destination_path, first_disk, content, content_size, preallocate, file->metadata,
|
preallocate, file->metadata, policy, use_fsync,
|
||||||
preserve_executability, use_fsync, sibling_xattrs, cfg ? cfg->fake_super : false, temp_dir);
|
sibling_xattrs, cfg ? cfg->fake_super : false, temp_dir);
|
||||||
xattr_list_free(sibling_xattrs);
|
xattr_list_free(sibling_xattrs);
|
||||||
free(content);
|
free(content);
|
||||||
free(first_disk);
|
free(first_disk);
|
||||||
@@ -437,7 +436,10 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
|
|||||||
} else {
|
} else {
|
||||||
create_mode = S_IFIFO;
|
create_mode = S_IFIFO;
|
||||||
}
|
}
|
||||||
mode_t perms = mode & 0777;
|
/* The creation permission bits come from the source only under -p/--perms;
|
||||||
|
* otherwise a safe default (0644, group/other write never granted) keeps an
|
||||||
|
* unprivileged no--p run from materializing a world-writable node. */
|
||||||
|
mode_t perms = config->preserve_perms ? (mode & 0777 & ~(S_IWGRP | S_IWOTH)) : 0644;
|
||||||
|
|
||||||
int rc = is_fifo ? mkfifoat(parent_fd, leaf, perms)
|
int rc = is_fifo ? mkfifoat(parent_fd, leaf, perms)
|
||||||
: mknodat(parent_fd, leaf, create_mode | perms, rdev);
|
: mknodat(parent_fd, leaf, create_mode | perms, rdev);
|
||||||
@@ -481,11 +483,23 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
|
|||||||
return FILE_SAVE_SKIPPED;
|
return FILE_SAVE_SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Apply mtime on the fresh node (utimensat, no-follow). */
|
/* Apply times on the fresh node (utimensat, no-follow) per the negotiated
|
||||||
struct timespec times[2] = {
|
* per-attribute policy: mtime only under -t, atime only under -U. The slot
|
||||||
{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
|
* not requested stays UTIME_OMIT so it is left untouched. */
|
||||||
{.tv_sec = file->metadata->mtime_sec, .tv_nsec = file->metadata->mtime_nsec}};
|
FileAttrPolicy policy = file_attr_policy_from_config(config);
|
||||||
utimensat(parent_fd, leaf, times, AT_SYMLINK_NOFOLLOW);
|
if (policy.times || (policy.atimes && file->metadata->atime_valid)) {
|
||||||
|
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
|
||||||
|
{.tv_sec = 0, .tv_nsec = UTIME_OMIT}};
|
||||||
|
if (policy.times) {
|
||||||
|
times[1].tv_sec = file->metadata->mtime_sec;
|
||||||
|
times[1].tv_nsec = file->metadata->mtime_nsec;
|
||||||
|
}
|
||||||
|
if (policy.atimes && file->metadata->atime_valid) {
|
||||||
|
times[0].tv_sec = file->metadata->atime_sec;
|
||||||
|
times[0].tv_nsec = file->metadata->atime_nsec;
|
||||||
|
}
|
||||||
|
utimensat(parent_fd, leaf, times, AT_SYMLINK_NOFOLLOW);
|
||||||
|
}
|
||||||
/* P7 Wave E: apply the negotiated ownership to the node ITSELF. A FIFO is
|
/* P7 Wave E: apply the negotiated ownership to the node ITSELF. A FIFO is
|
||||||
created unprivileged, but --copy-as and explicit identity policies own
|
created unprivileged, but --copy-as and explicit identity policies own
|
||||||
every entry (a char/block node path is already privilege-gated above). The
|
every entry (a char/block node path is already privilege-gated above). The
|
||||||
@@ -592,7 +606,7 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
|
|||||||
bool backup_enabled = config && config->backup && !config->ignore_existing;
|
bool backup_enabled = config && config->backup && !config->ignore_existing;
|
||||||
bool inplace = config && config->inplace;
|
bool inplace = config && config->inplace;
|
||||||
bool sparse = config && config->preserve_sparse;
|
bool sparse = config && config->preserve_sparse;
|
||||||
bool preserve_executability = config && config->use_executability;
|
FileAttrPolicy policy = file_attr_policy_from_config(config);
|
||||||
const char* backup_suffix = (config && config->suffix) ? config->suffix : "~";
|
const char* backup_suffix = (config && config->suffix) ? config->suffix : "~";
|
||||||
const char* backup_dir = (config && config->backup_dir) ? config->backup_dir : NULL;
|
const char* backup_dir = (config && config->backup_dir) ? config->backup_dir : NULL;
|
||||||
const char* partial_dir = (config && config->partial_dir) ? config->partial_dir : NULL;
|
const char* partial_dir = (config && config->partial_dir) ? config->partial_dir : NULL;
|
||||||
@@ -734,8 +748,11 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
|
|||||||
(utimensat/lchown/fchmodat AT_SYMLINK_NOFOLLOW). -J/--omit-link-times
|
(utimensat/lchown/fchmodat AT_SYMLINK_NOFOLLOW). -J/--omit-link-times
|
||||||
suppresses the timestamps; ownership stays gated by the identity policy.
|
suppresses the timestamps; ownership stays gated by the identity policy.
|
||||||
A symlink has no children, so this can be applied immediately. */
|
A symlink has no children, so this can be applied immediately. */
|
||||||
if (ok && config && config->use_metadata)
|
if (ok && config && config->use_metadata) {
|
||||||
ok = file_restore_symlink_metadata(link_path, file->metadata, config->omit_link_times);
|
FileAttrPolicy link_policy = file_attr_policy_from_config(config);
|
||||||
|
ok = file_restore_symlink_metadata(link_path, file->metadata, link_policy,
|
||||||
|
config->omit_link_times);
|
||||||
|
}
|
||||||
free(link_path);
|
free(link_path);
|
||||||
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
|
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
|
||||||
}
|
}
|
||||||
@@ -904,16 +921,15 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
|
|||||||
policy decision. */
|
policy decision. */
|
||||||
bool ok;
|
bool ok;
|
||||||
if (config && file->basis_link) {
|
if (config && file->basis_link) {
|
||||||
ok = file_to_disk_secure_link_attrs(disk_path, file->basis_link, file->data->data,
|
ok = file_to_disk_secure_link_attrs(
|
||||||
file->data->size, config->preallocate, metadata,
|
disk_path, file->basis_link, file->data->data, file->data->size, config->preallocate,
|
||||||
preserve_executability, config->use_fsync, file->xattrs,
|
metadata, policy, config->use_fsync, file->xattrs, config->fake_super, confined_temp);
|
||||||
config->fake_super, confined_temp);
|
|
||||||
} else {
|
} else {
|
||||||
/* The plain no-replace / update / with-fsync engines, plus per-file xattr
|
/* The plain no-replace / update / with-fsync engines, plus per-file xattr
|
||||||
(-X/-A) and --fake-super application on the written fd. */
|
(-X/-A) and --fake-super application on the written fd. */
|
||||||
ok = file_to_disk_secure_attrs(
|
ok = file_to_disk_secure_attrs(
|
||||||
disk_path, file->data->data, file->data->size, inplace, sparse,
|
disk_path, file->data->data, file->data->size, inplace, sparse,
|
||||||
config && config->preallocate, metadata, preserve_executability, config && config->update,
|
config && config->preallocate, metadata, policy, config && config->update,
|
||||||
config && config->ignore_existing, config && config->use_fsync, file->xattrs,
|
config && config->ignore_existing, config && config->use_fsync, file->xattrs,
|
||||||
config ? config->fake_super : false, config ? config->partial : false, confined_temp);
|
config ? config->fake_super : false, config ? config->partial : false, confined_temp);
|
||||||
}
|
}
|
||||||
@@ -2401,10 +2417,15 @@ File* file_receive(const Config* config, int file_descriptor) {
|
|||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- P7 Wave D: deferred directory times ---- */
|
/* ---- P7 Wave D: deferred directory metadata ---- */
|
||||||
|
|
||||||
bool dir_times_should_capture(const Config* config) {
|
bool dir_metadata_should_capture(const Config* config) {
|
||||||
return config->use_metadata && !config->omit_dir_times;
|
/* Directory metadata is captured when a directory attribute is actually
|
||||||
|
* requested: -p/--perms (directory modes) or -t/--times (directory mtimes,
|
||||||
|
* unless -O/--omit-dir-times suppresses them). --atimes/-U alone does not
|
||||||
|
* pull directory metadata (matching the original dir-time bundle). */
|
||||||
|
return config && config->use_metadata &&
|
||||||
|
(config->preserve_perms || (config->preserve_times && !config->omit_dir_times));
|
||||||
}
|
}
|
||||||
|
|
||||||
void dir_time_list_init(DirTimeList* list) {
|
void dir_time_list_init(DirTimeList* list) {
|
||||||
@@ -2475,8 +2496,13 @@ bool dir_time_list_add(DirTimeList* list, const char* wire_path, const FileMetad
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void dir_time_list_apply(const DirTimeList* list, const char* root_directory) {
|
void dir_metadata_list_apply(const DirTimeList* list, const char* root_directory,
|
||||||
if (!list || !root_directory)
|
const Config* config) {
|
||||||
|
if (!list || !root_directory || !config)
|
||||||
|
return;
|
||||||
|
bool apply_times = config->preserve_times && !config->omit_dir_times;
|
||||||
|
bool apply_mode = config->preserve_perms;
|
||||||
|
if (!apply_times && !apply_mode)
|
||||||
return;
|
return;
|
||||||
for (size_t i = 0; i < list->count; i++) {
|
for (size_t i = 0; i < list->count; i++) {
|
||||||
char* dir_path = path_cat(root_directory, list->paths[i]);
|
char* dir_path = path_cat(root_directory, list->paths[i]);
|
||||||
@@ -2484,7 +2510,8 @@ void dir_time_list_apply(const DirTimeList* list, const char* root_directory) {
|
|||||||
continue;
|
continue;
|
||||||
char* leaf = NULL;
|
char* leaf = NULL;
|
||||||
/* The parent walk is fd-relative and O_NOFOLLOW, so a symlink planted in a
|
/* The parent walk is fd-relative and O_NOFOLLOW, so a symlink planted in a
|
||||||
parent component can never redirect the utimensat outside the root. */
|
parent component can never redirect the utimensat/chmod outside the
|
||||||
|
root. */
|
||||||
int parent_fd = file_open_secure_parent(dir_path, &leaf, false);
|
int parent_fd = file_open_secure_parent(dir_path, &leaf, false);
|
||||||
if (parent_fd < 0) {
|
if (parent_fd < 0) {
|
||||||
free(dir_path);
|
free(dir_path);
|
||||||
@@ -2493,8 +2520,8 @@ void dir_time_list_apply(const DirTimeList* list, const char* root_directory) {
|
|||||||
/* A dir-time entry only records metadata: the directory is (deliberately)
|
/* A dir-time entry only records metadata: the directory is (deliberately)
|
||||||
not created from it, so an empty source directory (or one pruned by
|
not created from it, so an empty source directory (or one pruned by
|
||||||
-m/--prune-empty-dirs) may well not exist here. Skip absent paths
|
-m/--prune-empty-dirs) may well not exist here. Skip absent paths
|
||||||
QUIETLY rather than warning for every one, and apply the times only to a
|
QUIETLY rather than warning for every one, and apply the metadata only to
|
||||||
real directory that does exist. AT_SYMLINK_NOFOLLOW keeps a same-named
|
a real directory that does exist. AT_SYMLINK_NOFOLLOW keeps a same-named
|
||||||
symlink from being followed; a pre-existing regular file/symlink is not a
|
symlink from being followed; a pre-existing regular file/symlink is not a
|
||||||
directory, so it is left completely untouched. */
|
directory, so it is left completely untouched. */
|
||||||
struct stat st;
|
struct stat st;
|
||||||
@@ -2504,18 +2531,56 @@ void dir_time_list_apply(const DirTimeList* list, const char* root_directory) {
|
|||||||
free(dir_path);
|
free(dir_path);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
struct timespec times[2] = {
|
if (apply_times) {
|
||||||
{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
|
struct timespec times[2] = {
|
||||||
{.tv_sec = list->entries[i].mtime_sec, .tv_nsec = list->entries[i].mtime_nsec}};
|
{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
|
||||||
if (list->entries[i].atime_valid) {
|
{.tv_sec = list->entries[i].mtime_sec, .tv_nsec = list->entries[i].mtime_nsec}};
|
||||||
times[0].tv_sec = list->entries[i].atime_sec;
|
if (config->preserve_atimes && list->entries[i].atime_valid) {
|
||||||
times[0].tv_nsec = list->entries[i].atime_nsec;
|
times[0].tv_sec = list->entries[i].atime_sec;
|
||||||
|
times[0].tv_nsec = list->entries[i].atime_nsec;
|
||||||
|
}
|
||||||
|
if (utimensat(parent_fd, leaf, times, AT_SYMLINK_NOFOLLOW) != 0) {
|
||||||
|
char* escaped_path = output_escape(dir_path, log_get_8_bit_output());
|
||||||
|
log_message(LOG_LEVEL_WARNING, "Failed to set directory timestamps on %s: %s",
|
||||||
|
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
|
||||||
|
free(escaped_path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (utimensat(parent_fd, leaf, times, AT_SYMLINK_NOFOLLOW) != 0) {
|
if (apply_mode) {
|
||||||
char* escaped_path = output_escape(dir_path, log_get_8_bit_output());
|
mode_t dir_mode = list->entries[i].mode;
|
||||||
log_message(LOG_LEVEL_WARNING, "Failed to set directory timestamps on %s: %s",
|
bool mode_ready = true;
|
||||||
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
|
if (config->chmod_spec && *config->chmod_spec &&
|
||||||
free(escaped_path);
|
!chmod_apply(dir_mode, config->chmod_spec, &dir_mode)) {
|
||||||
|
char* escaped_path = output_escape(dir_path, log_get_8_bit_output());
|
||||||
|
log_message(LOG_LEVEL_WARNING, "Failed to apply --chmod to directory %s",
|
||||||
|
escaped_path ? escaped_path : "<allocation failed>");
|
||||||
|
free(escaped_path);
|
||||||
|
mode_ready = false;
|
||||||
|
}
|
||||||
|
if (mode_ready) {
|
||||||
|
/* Route the directory mode through the SAME sanitization as the
|
||||||
|
* regular-file policy: a client-supplied mode never grants group/other
|
||||||
|
* write. Open the directory with O_DIRECTORY|O_NOFOLLOW (never
|
||||||
|
* following a same-named symlink) and fchmod the fd, avoiding the
|
||||||
|
* fchmodat(..., 0) TOCTOU/symlink-follow hole. */
|
||||||
|
mode_t safe_mode =
|
||||||
|
(dir_mode & 0777 & ~(S_IWGRP | S_IWOTH)) | (dir_mode & (S_ISGID | S_ISVTX));
|
||||||
|
int dir_fd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||||
|
if (dir_fd < 0) {
|
||||||
|
char* escaped_path = output_escape(dir_path, log_get_8_bit_output());
|
||||||
|
log_message(LOG_LEVEL_WARNING, "Failed to open directory %s to set its mode: %s",
|
||||||
|
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
|
||||||
|
free(escaped_path);
|
||||||
|
} else {
|
||||||
|
if (fchmod(dir_fd, safe_mode) != 0) {
|
||||||
|
char* escaped_path = output_escape(dir_path, log_get_8_bit_output());
|
||||||
|
log_message(LOG_LEVEL_WARNING, "Failed to set directory mode on %s: %s",
|
||||||
|
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
|
||||||
|
free(escaped_path);
|
||||||
|
}
|
||||||
|
close(dir_fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
close(parent_fd);
|
close(parent_fd);
|
||||||
free(leaf);
|
free(leaf);
|
||||||
|
|||||||
+15
-10
@@ -48,10 +48,12 @@ typedef struct {
|
|||||||
} DirTimeList;
|
} DirTimeList;
|
||||||
|
|
||||||
/* Capture gate shared by the sender-side and receiver-side sinks: directory
|
/* Capture gate shared by the sender-side and receiver-side sinks: directory
|
||||||
* metadata is accumulated only when --times/--metadata is in effect and
|
* metadata is accumulated only when a directory attribute is requested
|
||||||
* -O/--omit-dir-times does not suppress it. Kept here, next to the accumulator
|
* (-p/--perms for directory modes, or -t/--times for directory mtimes with
|
||||||
* it guards, so both call sites express the same condition. */
|
* -O/--omit-dir-times not suppressing them) and metadata rides the wire. Kept
|
||||||
bool dir_times_should_capture(const Config* config);
|
* here, next to the accumulator it guards, so both call sites express the same
|
||||||
|
* condition. */
|
||||||
|
bool dir_metadata_should_capture(const Config* config);
|
||||||
|
|
||||||
void dir_time_list_init(DirTimeList* list);
|
void dir_time_list_init(DirTimeList* list);
|
||||||
void dir_time_list_free(DirTimeList* list);
|
void dir_time_list_free(DirTimeList* list);
|
||||||
@@ -59,12 +61,15 @@ void dir_time_list_free(DirTimeList* list);
|
|||||||
* allocation failure OR when the cumulative entry/byte caps would be exceeded
|
* allocation failure OR when the cumulative entry/byte caps would be exceeded
|
||||||
* (the caller fails the transfer). */
|
* (the caller fails the transfer). */
|
||||||
bool dir_time_list_add(DirTimeList* list, const char* wire_path, const FileMetadata* metadata);
|
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
|
/* Apply every accumulated directory's metadata beneath `root_directory`,
|
||||||
* `root_directory`, confined fd-relative. Best-effort per entry: an absent
|
* confined fd-relative. Times (mtime, plus atime when -U captured one) are
|
||||||
* directory (an empty/pruned source dir that was deliberately not created) or a
|
* applied only when config->preserve_times && !config->omit_dir_times; the mode
|
||||||
* non-directory at the path is skipped QUIETLY, an unreachable one with a
|
* (through --chmod when configured) is applied only when config->preserve_perms.
|
||||||
* warning, and never fatal. */
|
* Best-effort per entry: an absent directory (an empty/pruned source dir that
|
||||||
void dir_time_list_apply(const DirTimeList* list, const char* root_directory);
|
* 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_metadata_list_apply(const DirTimeList* list, const char* root_directory,
|
||||||
|
const Config* config);
|
||||||
|
|
||||||
/* A received delete-manifest frame: the keep-set (`keeps`, destination-relative
|
/* A received delete-manifest frame: the keep-set (`keeps`, destination-relative
|
||||||
paths the sender transferred/keeps) plus `protected`, destination-relative
|
paths the sender transferred/keeps) plus `protected`, destination-relative
|
||||||
|
|||||||
+126
-76
@@ -36,6 +36,13 @@ typedef struct {
|
|||||||
bool copy_as_set;
|
bool copy_as_set;
|
||||||
int32_t copy_as_uid;
|
int32_t copy_as_uid;
|
||||||
int32_t copy_as_gid;
|
int32_t copy_as_gid;
|
||||||
|
/* -o/--owner and -g/--group: preserve the source owner/group through the
|
||||||
|
* normal name/identity resolution path. Split out of the former
|
||||||
|
* use_metadata bundle; unlike --numeric-ids/--chown/--usermap/--groupmap/-a
|
||||||
|
* these are a preserve-source request, not an arbitrary client-chosen owner,
|
||||||
|
* so they are tracked separately from the explicit ownership gate. */
|
||||||
|
bool preserve_owner;
|
||||||
|
bool preserve_group;
|
||||||
bool set;
|
bool set;
|
||||||
} IdentityActive;
|
} IdentityActive;
|
||||||
|
|
||||||
@@ -57,6 +64,8 @@ static void identity_active_reset(void) {
|
|||||||
g_identity.copy_as_set = false;
|
g_identity.copy_as_set = false;
|
||||||
g_identity.copy_as_uid = 0;
|
g_identity.copy_as_uid = 0;
|
||||||
g_identity.copy_as_gid = 0;
|
g_identity.copy_as_gid = 0;
|
||||||
|
g_identity.preserve_owner = false;
|
||||||
|
g_identity.preserve_group = false;
|
||||||
g_identity.set = false;
|
g_identity.set = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +86,8 @@ bool identity_set_active(const Config* config) {
|
|||||||
g_identity.copy_as_set = config->copy_as_set;
|
g_identity.copy_as_set = config->copy_as_set;
|
||||||
g_identity.copy_as_uid = config->copy_as_uid;
|
g_identity.copy_as_uid = config->copy_as_uid;
|
||||||
g_identity.copy_as_gid = config->copy_as_gid;
|
g_identity.copy_as_gid = config->copy_as_gid;
|
||||||
|
g_identity.preserve_owner = config->preserve_owner;
|
||||||
|
g_identity.preserve_group = config->preserve_group;
|
||||||
if (config->usermap_count > 0) {
|
if (config->usermap_count > 0) {
|
||||||
g_identity.usermap = calloc((size_t)config->usermap_count, sizeof(IdentityMap));
|
g_identity.usermap = calloc((size_t)config->usermap_count, sizeof(IdentityMap));
|
||||||
if (!g_identity.usermap)
|
if (!g_identity.usermap)
|
||||||
@@ -95,14 +106,22 @@ bool identity_set_active(const Config* config) {
|
|||||||
}
|
}
|
||||||
g_identity.set = true;
|
g_identity.set = true;
|
||||||
/* A root receiver would honor any client-supplied ownership request (a
|
/* A root receiver would honor any client-supplied ownership request (a
|
||||||
--usermap/--groupmap/--chown/--copy-as, or raw ids under --numeric-ids).
|
--usermap/--groupmap/--chown/--copy-as, or raw ids under --numeric-ids)
|
||||||
Surface that prominently; a privileged daemon applying arbitrary client
|
ONLY when super-user activities are permitted. --no-super (or a daemon
|
||||||
ownership is a deliberate, opt-in choice the operator should be aware of. */
|
veto that forced SUPER_MODE_OFF) forbids the chown even for root, so do
|
||||||
if (geteuid() == 0)
|
not claim the ownership will be honored in that case. */
|
||||||
log_message(LOG_LEVEL_WARNING,
|
if (geteuid() == 0) {
|
||||||
"identity mapping active and running as root: client-supplied "
|
if (privilege_super_mode_permitted(g_identity.super_mode))
|
||||||
"ownership (usermap/groupmap/chown/numeric-ids) will be honored; "
|
log_message(LOG_LEVEL_WARNING,
|
||||||
"run the daemon as an unprivileged user unless intended");
|
"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");
|
||||||
|
else
|
||||||
|
log_message(LOG_LEVEL_WARNING,
|
||||||
|
"identity mapping active and running as root, but super-user activities are "
|
||||||
|
"disabled (--no-super): requested ownership will NOT be applied; run the "
|
||||||
|
"daemon as an unprivileged user unless intended");
|
||||||
|
}
|
||||||
/* --super explicitly requests super-user activities, but FastSync never
|
/* --super explicitly requests super-user activities, but FastSync never
|
||||||
elevates privileges: when the receiver is not already root the kernel will
|
elevates privileges: when the receiver is not already root the kernel will
|
||||||
refuse those confined attempts and each is skipped per entry. Warn exactly
|
refuse those confined attempts and each is skipped per entry. Warn exactly
|
||||||
@@ -148,15 +167,47 @@ bool identity_active_enabled(void) {
|
|||||||
identity flag must never silently apply client-chosen ownership. */
|
identity flag must never silently apply client-chosen ownership. */
|
||||||
return g_identity.set &&
|
return g_identity.set &&
|
||||||
(g_identity.numeric_ids || g_identity.chown_uid_set || g_identity.chown_gid_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);
|
g_identity.usermap_count > 0 || g_identity.groupmap_count > 0 || g_identity.copy_as_set ||
|
||||||
|
g_identity.preserve_owner || g_identity.preserve_group);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool identity_owner_requested(void) {
|
||||||
|
return g_identity.set &&
|
||||||
|
(g_identity.copy_as_set || g_identity.chown_uid_set || g_identity.numeric_ids ||
|
||||||
|
g_identity.preserve_owner || g_identity.usermap_count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool identity_group_requested(void) {
|
||||||
|
return g_identity.set &&
|
||||||
|
(g_identity.copy_as_set || g_identity.chown_gid_set || g_identity.numeric_ids ||
|
||||||
|
g_identity.preserve_group || g_identity.groupmap_count > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool identity_ownership_requested(const Config* config) {
|
bool identity_ownership_requested(const Config* config) {
|
||||||
if (!config)
|
if (!config)
|
||||||
return false;
|
return false;
|
||||||
/* Every value that makes the receiver act on a client-chosen owner, plus an
|
/* General-awareness predicate: every value that makes the receiver act on a
|
||||||
* explicit --super (super-user device-node activities). Pure config, so the
|
* client-chosen owner, plus an explicit --super (super-user device-node
|
||||||
* daemon gate can evaluate it before identity_set_active(). */
|
* activities) and the preserve-source -o/-g requests. Pure config, so callers
|
||||||
|
* can evaluate it before identity_set_active(). The daemon module gate uses
|
||||||
|
* the narrower identity_explicit_ownership_requested() below, which treats a
|
||||||
|
* plain -o/-g/-a as a preserve-source request rather than arbitrary
|
||||||
|
* client-chosen ownership. */
|
||||||
|
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->preserve_owner || config->preserve_group || config->fake_super ||
|
||||||
|
config->super_mode == SUPER_MODE_ON;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool identity_explicit_ownership_requested(const Config* config) {
|
||||||
|
if (!config)
|
||||||
|
return false;
|
||||||
|
/* The narrow set the daemon gate refuses for a non-opted module: a request
|
||||||
|
* that lets the CLIENT choose an arbitrary owner/group (rather than preserve
|
||||||
|
* the source's own). Deliberately EXCLUDES preserve_owner/preserve_group so a
|
||||||
|
* plain -a/-o/-g push is not refused; for those the gate instead forces
|
||||||
|
* super-user ownership activity off (no chown happens) unless the module has
|
||||||
|
* `client owner = yes`. */
|
||||||
return config->numeric_ids || config->chown_uid_set || config->chown_gid_set ||
|
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->usermap_count > 0 || config->groupmap_count > 0 || config->copy_as_set ||
|
||||||
config->fake_super || config->super_mode == SUPER_MODE_ON;
|
config->fake_super || config->super_mode == SUPER_MODE_ON;
|
||||||
@@ -567,9 +618,6 @@ int identity_parse_copy_as(Config* config, const char* value) {
|
|||||||
config->copy_as_set = true;
|
config->copy_as_set = true;
|
||||||
config->copy_as_uid = uid;
|
config->copy_as_uid = uid;
|
||||||
config->copy_as_gid = gid;
|
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;
|
ret = 0;
|
||||||
|
|
||||||
done:
|
done:
|
||||||
@@ -596,18 +644,13 @@ static bool identity_map_lookup(const IdentityMap* map, int count, int32_t sourc
|
|||||||
* paths. Returns false when no side is to be changed. */
|
* 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,
|
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) {
|
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
|
/* --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 /
|
* and group of every written entry to the requested ids, beating usermap /
|
||||||
* groupmap / --chown / --numeric-ids and the best-effort name lookup. Only
|
* groupmap / --chown / --numeric-ids and the best-effort name lookup. Only
|
||||||
* skip when the entry already carries exactly those ids. */
|
* skip when the entry already carries exactly those ids. */
|
||||||
if (g_identity.copy_as_set) {
|
if (g_identity.copy_as_set) {
|
||||||
uid = (uid_t)g_identity.copy_as_uid;
|
uid_t uid = (uid_t)g_identity.copy_as_uid;
|
||||||
gid = (gid_t)g_identity.copy_as_gid;
|
gid_t gid = (gid_t)g_identity.copy_as_gid;
|
||||||
if (st->st_uid == uid && st->st_gid == gid)
|
if (st->st_uid == uid && st->st_gid == gid)
|
||||||
return false;
|
return false;
|
||||||
*out_uid = uid;
|
*out_uid = uid;
|
||||||
@@ -615,61 +658,68 @@ static bool identity_resolve_targets(const struct stat* st, int32_t source_uid,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
int32_t target;
|
/* Each side is resolved independently: -o/-g and the explicit identity flags
|
||||||
if (identity_map_lookup(g_identity.usermap, g_identity.usermap_count, source_uid, &target)) {
|
* request the owner/group respectively, and a side that is NOT requested must
|
||||||
uid = target == IDENTITY_CURRENT ? geteuid() : (uid_t)target;
|
* be left exactly as it is (`-1` to fchown on that side). This is what lets
|
||||||
set_uid = true;
|
* plain -g change only the group, or -o only the owner. */
|
||||||
} else if (g_identity.chown_uid_set) {
|
bool owner_requested = g_identity.chown_uid_set || g_identity.numeric_ids ||
|
||||||
uid = g_identity.chown_uid == IDENTITY_CURRENT ? geteuid() : (uid_t)g_identity.chown_uid;
|
g_identity.preserve_owner || g_identity.usermap_count > 0;
|
||||||
set_uid = true;
|
bool group_requested = g_identity.chown_gid_set || g_identity.numeric_ids ||
|
||||||
} else if (g_identity.numeric_ids) {
|
g_identity.preserve_group || g_identity.groupmap_count > 0;
|
||||||
uid = (uid_t)source_uid;
|
if (!owner_requested && !group_requested)
|
||||||
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;
|
return false;
|
||||||
/* An unset side keeps the file's current id so the other side can change. */
|
|
||||||
if (!set_uid)
|
int32_t target;
|
||||||
uid = st->st_uid;
|
uid_t uid = (uid_t)-1;
|
||||||
if (!set_gid)
|
gid_t gid = (gid_t)-1;
|
||||||
gid = st->st_gid;
|
|
||||||
/* Only change ownership when the target differs (avoid needless syscalls and
|
/* Priority (unchanged): usermap/groupmap > --chown > --numeric-ids (raw) >
|
||||||
* any chance of clearing setuid/setgid on an already-correct entry). */
|
* name mapping on the transmitted numeric id, with a raw-id fallback when the
|
||||||
if (st->st_uid == uid && st->st_gid == gid)
|
* receiver has no name for that id. */
|
||||||
|
if (owner_requested) {
|
||||||
|
if (identity_map_lookup(g_identity.usermap, g_identity.usermap_count, source_uid, &target)) {
|
||||||
|
uid = target == IDENTITY_CURRENT ? geteuid() : (uid_t)target;
|
||||||
|
} else if (g_identity.chown_uid_set) {
|
||||||
|
uid = g_identity.chown_uid == IDENTITY_CURRENT ? geteuid() : (uid_t)g_identity.chown_uid;
|
||||||
|
} else if (g_identity.numeric_ids) {
|
||||||
|
uid = (uid_t)source_uid;
|
||||||
|
} else {
|
||||||
|
/* Best-effort name mapping against the receiver's own database. When the
|
||||||
|
* transmitted (numeric) id has no name here, fall back to the raw numeric
|
||||||
|
* id so -o still preserves the source owner. */
|
||||||
|
struct passwd* pw = getpwuid((uid_t)source_uid);
|
||||||
|
if (pw) {
|
||||||
|
const struct passwd* mapped = getpwnam(pw->pw_name);
|
||||||
|
uid = mapped ? mapped->pw_uid : (uid_t)source_uid;
|
||||||
|
} else {
|
||||||
|
uid = (uid_t)source_uid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group_requested) {
|
||||||
|
if (identity_map_lookup(g_identity.groupmap, g_identity.groupmap_count, source_gid, &target)) {
|
||||||
|
gid = target == IDENTITY_CURRENT ? getegid() : (gid_t)target;
|
||||||
|
} else if (g_identity.chown_gid_set) {
|
||||||
|
gid = g_identity.chown_gid == IDENTITY_CURRENT ? getegid() : (gid_t)g_identity.chown_gid;
|
||||||
|
} else if (g_identity.numeric_ids) {
|
||||||
|
gid = (gid_t)source_gid;
|
||||||
|
} else {
|
||||||
|
struct group* gr = getgrgid((gid_t)source_gid);
|
||||||
|
if (gr) {
|
||||||
|
const struct group* mapped = getgrnam(gr->gr_name);
|
||||||
|
gid = mapped ? mapped->gr_gid : (gid_t)source_gid;
|
||||||
|
} else {
|
||||||
|
gid = (gid_t)source_gid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only change ownership when a requested side actually differs (avoid
|
||||||
|
* needless syscalls and any chance of clearing setuid/setgid on an
|
||||||
|
* already-correct entry). */
|
||||||
|
bool changed = (owner_requested && uid != st->st_uid) || (group_requested && gid != st->st_gid);
|
||||||
|
if (!changed)
|
||||||
return false;
|
return false;
|
||||||
*out_uid = uid;
|
*out_uid = uid;
|
||||||
*out_gid = gid;
|
*out_gid = gid;
|
||||||
|
|||||||
+27
-6
@@ -80,16 +80,37 @@ void identity_clear_active(void);
|
|||||||
* snapshot. Ownership stays OFF ("do not apply") for every transfer that
|
* snapshot. Ownership stays OFF ("do not apply") for every transfer that
|
||||||
* requests none of them, preserving FastSync's existing behavior. --super /
|
* requests none of them, preserving FastSync's existing behavior. --super /
|
||||||
* --no-super alone does NOT enable ownership; an explicit identity flag
|
* --no-super alone does NOT enable ownership; an explicit identity flag
|
||||||
* (--numeric-ids / --chown / --usermap / --groupmap / --copy-as) is required. */
|
* (--numeric-ids / --chown / --usermap / --groupmap / --copy-as) or a
|
||||||
|
* preserve-source -o/--owner / -g/--group request is required. */
|
||||||
bool identity_active_enabled(void);
|
bool identity_active_enabled(void);
|
||||||
|
|
||||||
/* Pure, config-only predicate: true when the client requested ANY
|
/* Per-side predicates over the ACTIVE per-connection snapshot (call
|
||||||
* client-chosen ownership or super-user activity (--numeric-ids, --chown,
|
* identity_set_active() first). They mirror the owner_requested /
|
||||||
* --usermap/--groupmap, --copy-as, --fake-super, or an explicit --super). Used
|
* group_requested conditions inside identity_resolve_targets() exactly, so
|
||||||
* by the daemon module gate to decide whether a module's per-module opt-in is
|
* callers that must apply only one side (e.g. the --fake-super owner replay)
|
||||||
* required; it never reads the per-connection snapshot. */
|
* can pass (uid_t)-1 / (gid_t)-1 for the side that was NOT requested and leave
|
||||||
|
* it untouched. The owner side is requested by --copy-as, --chown USER,
|
||||||
|
* --numeric-ids, -o/--owner, or a non-empty --usermap; the group side by
|
||||||
|
* --copy-as, --chown :GROUP, --numeric-ids, -g/--group, or a non-empty
|
||||||
|
* --groupmap. */
|
||||||
|
bool identity_owner_requested(void);
|
||||||
|
bool identity_group_requested(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, an explicit --super, or a preserve-source -o/-g).
|
||||||
|
* General awareness only; the daemon module gate uses the narrower
|
||||||
|
* identity_explicit_ownership_requested() below. Never reads the snapshot. */
|
||||||
bool identity_ownership_requested(const Config* config);
|
bool identity_ownership_requested(const Config* config);
|
||||||
|
|
||||||
|
/* Pure, config-only predicate for the narrow set that lets the CLIENT choose an
|
||||||
|
* arbitrary owner/group: --numeric-ids, --chown, --usermap/--groupmap,
|
||||||
|
* --copy-as, --fake-super, or an explicit --super. Deliberately EXCLUDES a
|
||||||
|
* plain -o/--owner / -g/--group (or -a) preserve-source request, which the
|
||||||
|
* daemon gate handles by forcing super-user ownership activity off rather than
|
||||||
|
* refusing the whole transfer. Never reads the snapshot. */
|
||||||
|
bool identity_explicit_ownership_requested(const Config* config);
|
||||||
|
|
||||||
/* Apply the negotiated ownership to an already-written file descriptor.
|
/* Apply the negotiated ownership to an already-written file descriptor.
|
||||||
* source_uid/source_gid are the transmitted numeric ids. Resolution order:
|
* source_uid/source_gid are the transmitted numeric ids. Resolution order:
|
||||||
* --copy-as (highest priority, forces both ids), then a matching
|
* --copy-as (highest priority, forces both ids), then a matching
|
||||||
|
|||||||
+104
-52
@@ -209,22 +209,55 @@ FileMetadata* metadata_receive(int file_descriptor, int* ok) {
|
|||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
static mode_t metadata_mode(const FileMetadata* metadata, mode_t current_mode,
|
bool metadata_mode_for_policy(mode_t source_mode, mode_t current_mode, FileAttrPolicy policy,
|
||||||
bool preserve_executability) {
|
mode_t* out_mode) {
|
||||||
const mode_t execute_bits = S_IXUSR | S_IXGRP | S_IXOTH;
|
const mode_t execute_bits = S_IXUSR | S_IXGRP | S_IXOTH;
|
||||||
if (preserve_executability)
|
if (policy.perms) {
|
||||||
return (current_mode & 0777 & ~execute_bits) | (metadata->mode & execute_bits);
|
/* Group/other write is never granted from a client-supplied mode. */
|
||||||
return metadata->mode & 0777 & ~(S_IWGRP | S_IWOTH);
|
*out_mode = source_mode & 0777 & ~(S_IWGRP | S_IWOTH);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (policy.executability) {
|
||||||
|
/* -E/--executability (rsync 3.4 rule): do NOT copy the source's execute
|
||||||
|
* bits per class. If the source is executable at all, derive the execute
|
||||||
|
* bits from the DESTINATION's own read bits (so a class that can read may
|
||||||
|
* execute); otherwise clear every execute bit. This runs on the
|
||||||
|
* destination-derived base (pre-existing dest mode, or source&~umask for a
|
||||||
|
* new file), and leaves special bits untouched. --perms wins when both are
|
||||||
|
* set (handled above). */
|
||||||
|
mode_t base = current_mode & 0777;
|
||||||
|
if (source_mode & 0111)
|
||||||
|
*out_mode = base | ((base & 0444) >> 2);
|
||||||
|
else
|
||||||
|
*out_mode = base & ~execute_bits;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/* Neither requested: no source mode is applied at all. */
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void file_restore_metadata(const char* path, const FileMetadata* metadata,
|
FileAttrPolicy file_attr_policy_from_config(const Config* config) {
|
||||||
bool preserve_executability) {
|
FileAttrPolicy policy = {false, false, false, false};
|
||||||
|
if (config) {
|
||||||
|
policy.perms = config->preserve_perms;
|
||||||
|
policy.times = config->preserve_times;
|
||||||
|
policy.atimes = config->preserve_atimes;
|
||||||
|
policy.executability = config->use_executability;
|
||||||
|
}
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
void file_restore_metadata(const char* path, const FileMetadata* metadata, FileAttrPolicy policy) {
|
||||||
if (metadata == NULL)
|
if (metadata == NULL)
|
||||||
return;
|
return;
|
||||||
struct stat current;
|
bool apply_mode = false;
|
||||||
mode_t current_mode = stat(path, ¤t) == 0 ? current.st_mode : 0;
|
mode_t safe_mode = 0;
|
||||||
mode_t safe_mode = metadata_mode(metadata, current_mode, preserve_executability);
|
if (policy.perms || policy.executability) {
|
||||||
if (chmod(path, safe_mode) != 0) {
|
struct stat current;
|
||||||
|
mode_t current_mode = stat(path, ¤t) == 0 ? current.st_mode : 0;
|
||||||
|
apply_mode = metadata_mode_for_policy(metadata->mode, current_mode, policy, &safe_mode);
|
||||||
|
}
|
||||||
|
if (apply_mode && chmod(path, safe_mode) != 0) {
|
||||||
char* escaped_path = output_escape(path, log_get_8_bit_output());
|
char* escaped_path = output_escape(path, log_get_8_bit_output());
|
||||||
log_message(LOG_LEVEL_WARNING, "Failed to chmod %s: %s",
|
log_message(LOG_LEVEL_WARNING, "Failed to chmod %s: %s",
|
||||||
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
|
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
|
||||||
@@ -232,14 +265,23 @@ void file_restore_metadata(const char* path, const FileMetadata* metadata,
|
|||||||
}
|
}
|
||||||
/* Never apply client-supplied ownership. The descriptor API below is the
|
/* Never apply client-supplied ownership. The descriptor API below is the
|
||||||
receiver write path; retain this legacy API only for compatibility. */
|
receiver write path; retain this legacy API only for compatibility. */
|
||||||
struct timespec times[2];
|
if (policy.times || (policy.atimes && metadata->atime_valid)) {
|
||||||
times[0].tv_sec = 0;
|
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
|
||||||
times[0].tv_nsec = UTIME_OMIT;
|
{.tv_sec = 0, .tv_nsec = UTIME_OMIT}};
|
||||||
times[1].tv_sec = metadata->mtime_sec;
|
if (policy.times) {
|
||||||
times[1].tv_nsec = metadata->mtime_nsec;
|
times[1].tv_sec = metadata->mtime_sec;
|
||||||
if (metadata->atime_valid) {
|
times[1].tv_nsec = metadata->mtime_nsec;
|
||||||
times[0].tv_sec = metadata->atime_sec;
|
}
|
||||||
times[0].tv_nsec = metadata->atime_nsec;
|
if (policy.atimes && metadata->atime_valid) {
|
||||||
|
times[0].tv_sec = metadata->atime_sec;
|
||||||
|
times[0].tv_nsec = metadata->atime_nsec;
|
||||||
|
}
|
||||||
|
if (utimensat(AT_FDCWD, path, times, 0) != 0) {
|
||||||
|
char* escaped_path = output_escape(path, log_get_8_bit_output());
|
||||||
|
log_message(LOG_LEVEL_WARNING, "Failed to set timestamps on %s: %s",
|
||||||
|
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
|
||||||
|
free(escaped_path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (metadata->crtime_valid) {
|
if (metadata->crtime_valid) {
|
||||||
log_message(LOG_LEVEL_DEBUG,
|
log_message(LOG_LEVEL_DEBUG,
|
||||||
@@ -247,16 +289,10 @@ void file_restore_metadata(const char* path, const FileMetadata* metadata,
|
|||||||
"setter exists",
|
"setter exists",
|
||||||
(long long)metadata->crtime_sec, metadata->crtime_nsec, path);
|
(long long)metadata->crtime_sec, metadata->crtime_nsec, path);
|
||||||
}
|
}
|
||||||
if (utimensat(AT_FDCWD, path, times, 0) != 0) {
|
|
||||||
char* escaped_path = output_escape(path, log_get_8_bit_output());
|
|
||||||
log_message(LOG_LEVEL_WARNING, "Failed to set timestamps on %s: %s",
|
|
||||||
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
|
|
||||||
free(escaped_path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool file_restore_symlink_metadata(const char* path, const FileMetadata* metadata,
|
bool file_restore_symlink_metadata(const char* path, const FileMetadata* metadata,
|
||||||
bool omit_link_times) {
|
FileAttrPolicy policy, bool omit_link_times) {
|
||||||
if (path == NULL || metadata == NULL)
|
if (path == NULL || metadata == NULL)
|
||||||
return !identity_copy_as_active();
|
return !identity_copy_as_active();
|
||||||
char* leaf = NULL;
|
char* leaf = NULL;
|
||||||
@@ -269,18 +305,25 @@ bool file_restore_symlink_metadata(const char* path, const FileMetadata* metadat
|
|||||||
best-effort. */
|
best-effort. */
|
||||||
bool owned = identity_apply_ownership_link(parent_fd, leaf, (int32_t)metadata->uid,
|
bool owned = identity_apply_ownership_link(parent_fd, leaf, (int32_t)metadata->uid,
|
||||||
(int32_t)metadata->gid);
|
(int32_t)metadata->gid);
|
||||||
/* Symlink mode: not settable on Linux (fchmodat AT_SYMLINK_NOFOLLOW returns
|
/* Symlink mode: only when -p is in effect. It is not settable on Linux
|
||||||
EOPNOTSUPP/ENOTSUP); attempt it for platforms that support it and quietly
|
(fchmodat AT_SYMLINK_NOFOLLOW returns EOPNOTSUPP/ENOTSUP); attempt it for
|
||||||
ignore the unsupported case so the transfer never fails over it. */
|
platforms that support it and quietly ignore the unsupported case so the
|
||||||
mode_t link_mode = metadata->mode & 0777;
|
transfer never fails over it. */
|
||||||
if (fchmodat(parent_fd, leaf, link_mode, AT_SYMLINK_NOFOLLOW) != 0 && errno != EOPNOTSUPP &&
|
if (policy.perms) {
|
||||||
errno != ENOTSUP && errno != ENOSYS) {
|
mode_t link_mode = metadata->mode & 0777 & ~(S_IWGRP | S_IWOTH);
|
||||||
log_message(LOG_LEVEL_DEBUG, "Could not set symlink mode on %s: %s", path, strerror(errno));
|
if (fchmodat(parent_fd, leaf, link_mode, AT_SYMLINK_NOFOLLOW) != 0 && errno != EOPNOTSUPP &&
|
||||||
|
errno != ENOTSUP && errno != ENOSYS) {
|
||||||
|
log_message(LOG_LEVEL_DEBUG, "Could not set symlink mode on %s: %s", path, strerror(errno));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!omit_link_times) {
|
if (!omit_link_times && (policy.times || (policy.atimes && metadata->atime_valid))) {
|
||||||
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
|
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
|
||||||
{.tv_sec = metadata->mtime_sec, .tv_nsec = metadata->mtime_nsec}};
|
{.tv_sec = 0, .tv_nsec = UTIME_OMIT}};
|
||||||
if (metadata->atime_valid) {
|
if (policy.times) {
|
||||||
|
times[1].tv_sec = metadata->mtime_sec;
|
||||||
|
times[1].tv_nsec = metadata->mtime_nsec;
|
||||||
|
}
|
||||||
|
if (policy.atimes && metadata->atime_valid) {
|
||||||
times[0].tv_sec = metadata->atime_sec;
|
times[0].tv_sec = metadata->atime_sec;
|
||||||
times[0].tv_nsec = metadata->atime_nsec;
|
times[0].tv_nsec = metadata->atime_nsec;
|
||||||
}
|
}
|
||||||
@@ -296,19 +339,22 @@ bool file_restore_symlink_metadata(const char* path, const FileMetadata* metadat
|
|||||||
return owned;
|
return owned;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserve_executability) {
|
bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, FileAttrPolicy policy) {
|
||||||
if (fd < 0 || metadata == NULL)
|
if (fd < 0 || metadata == NULL)
|
||||||
return metadata == NULL;
|
return metadata == NULL;
|
||||||
bool ok = true;
|
bool ok = true;
|
||||||
struct stat current;
|
if (policy.perms || policy.executability) {
|
||||||
if (fstat(fd, ¤t) != 0)
|
struct stat current;
|
||||||
return false;
|
if (fstat(fd, ¤t) != 0)
|
||||||
mode_t safe_mode = metadata_mode(metadata, current.st_mode, preserve_executability);
|
return false;
|
||||||
if (fchmod(fd, safe_mode) != 0)
|
mode_t safe_mode = 0;
|
||||||
ok = false;
|
bool apply_mode = metadata_mode_for_policy(metadata->mode, current.st_mode, policy, &safe_mode);
|
||||||
|
if (apply_mode && fchmod(fd, safe_mode) != 0)
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
/* Client uid/gid values are deliberately not authoritative UNLESS the client
|
/* Client uid/gid values are deliberately not authoritative UNLESS the client
|
||||||
explicitly opted in with an identity flag (--numeric-ids / --usermap /
|
explicitly opted in with an identity flag (--numeric-ids / --usermap /
|
||||||
--groupmap / --chown). identity_apply_ownership is the controlled,
|
--groupmap / --chown / -o/-g). identity_apply_ownership is the controlled,
|
||||||
privilege-gated path: it consults the negotiated policy, resolves the
|
privilege-gated path: it consults the negotiated policy, resolves the
|
||||||
target ids, and applies them via an fd-relative fchown() that is confined
|
target ids, and applies them via an fd-relative fchown() that is confined
|
||||||
to the just-written file (EPERM/EACCES are logged, never fatal) -- EXCEPT
|
to the just-written file (EPERM/EACCES are logged, never fatal) -- EXCEPT
|
||||||
@@ -319,12 +365,6 @@ bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserv
|
|||||||
ownership. */
|
ownership. */
|
||||||
if (!identity_apply_ownership(fd, (int32_t)metadata->uid, (int32_t)metadata->gid))
|
if (!identity_apply_ownership(fd, (int32_t)metadata->uid, (int32_t)metadata->gid))
|
||||||
ok = false;
|
ok = false;
|
||||||
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
|
|
||||||
{.tv_sec = metadata->mtime_sec, .tv_nsec = metadata->mtime_nsec}};
|
|
||||||
if (metadata->atime_valid) {
|
|
||||||
times[0].tv_sec = metadata->atime_sec;
|
|
||||||
times[0].tv_nsec = metadata->atime_nsec;
|
|
||||||
}
|
|
||||||
/* --crtimes captures and transmits the source birth time, but there is no
|
/* --crtimes captures and transmits the source birth time, but there is no
|
||||||
* portable way to set a birth time (utimensat can only set atime/mtime), so
|
* portable way to set a birth time (utimensat can only set atime/mtime), so
|
||||||
* the receiver deliberately does NOT apply it. This is explicit, honest
|
* the receiver deliberately does NOT apply it. This is explicit, honest
|
||||||
@@ -335,7 +375,19 @@ bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserv
|
|||||||
"crtime (birth time) %lld.%09ld transmitted but not applied: no portable setter",
|
"crtime (birth time) %lld.%09ld transmitted but not applied: no portable setter",
|
||||||
(long long)metadata->crtime_sec, metadata->crtime_nsec);
|
(long long)metadata->crtime_sec, metadata->crtime_nsec);
|
||||||
}
|
}
|
||||||
if (futimens(fd, times) != 0)
|
if (policy.times || (policy.atimes && metadata->atime_valid)) {
|
||||||
ok = false;
|
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
|
||||||
|
{.tv_sec = 0, .tv_nsec = UTIME_OMIT}};
|
||||||
|
if (policy.times) {
|
||||||
|
times[1].tv_sec = metadata->mtime_sec;
|
||||||
|
times[1].tv_nsec = metadata->mtime_nsec;
|
||||||
|
}
|
||||||
|
if (policy.atimes && metadata->atime_valid) {
|
||||||
|
times[0].tv_sec = metadata->atime_sec;
|
||||||
|
times[0].tv_nsec = metadata->atime_nsec;
|
||||||
|
}
|
||||||
|
if (futimens(fd, times) != 0)
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-7
@@ -2,6 +2,7 @@
|
|||||||
#define METADATA_H
|
#define METADATA_H
|
||||||
|
|
||||||
#include "file.h"
|
#include "file.h"
|
||||||
|
#include "file_attr.h"
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
@@ -49,19 +50,31 @@ void metadata_to_buf(char** buf, const FileMetadata* m);
|
|||||||
FileMetadata* metadata_from_buf(const uint8_t* buf, size_t len);
|
FileMetadata* metadata_from_buf(const uint8_t* buf, size_t len);
|
||||||
bool metadata_send(int file_descriptor, const FileMetadata* m);
|
bool metadata_send(int file_descriptor, const FileMetadata* m);
|
||||||
FileMetadata* metadata_receive(int file_descriptor, int* ok);
|
FileMetadata* metadata_receive(int file_descriptor, int* ok);
|
||||||
void file_restore_metadata(const char* path, const FileMetadata* metadata,
|
void file_restore_metadata(const char* path, const FileMetadata* metadata, FileAttrPolicy policy);
|
||||||
bool preserve_executability);
|
bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, FileAttrPolicy policy);
|
||||||
bool file_restore_metadata_fd(int fd, const FileMetadata* metadata, bool preserve_executability);
|
|
||||||
|
/* Shared mode-policy helper: the single source of truth for the receiver's
|
||||||
|
* mode rule. Given a source mode and the destination's CURRENT mode, returns
|
||||||
|
* true and stores the exact mode to apply in *out_mode when `policy` requests
|
||||||
|
* a change, or false when it requests neither --perms nor --executability (the
|
||||||
|
* caller then leaves the destination mode alone). --perms wins over -E; the
|
||||||
|
* -E rule derives exec bits from the destination's read bits (rsync 3.4);
|
||||||
|
* group/other write is never granted from a client-supplied mode. Shared by
|
||||||
|
* file_restore_metadata_fd() and the --fake-super replay so the two cannot
|
||||||
|
* diverge. */
|
||||||
|
bool metadata_mode_for_policy(mode_t source_mode, mode_t current_mode, FileAttrPolicy policy,
|
||||||
|
mode_t* out_mode);
|
||||||
/* P7 Wave D: apply a SYMLINK's own metadata using no-follow primitives only
|
/* P7 Wave D: apply a SYMLINK's own metadata using no-follow primitives only
|
||||||
* (utimensat/lchown/fchmodat with AT_SYMLINK_NOFOLLOW), confined fd-relative
|
* (utimensat/lchown/fchmodat with AT_SYMLINK_NOFOLLOW), confined fd-relative
|
||||||
* under the authorized root. `omit_link_times` (-J/--omit-link-times)
|
* under the authorized root. The link's mode is applied only when policy.perms;
|
||||||
* suppresses the timestamps; the link's mode/ownership are still attempted
|
* policy.times (further suppressed by `omit_link_times` for -J) applies the
|
||||||
* (ownership stays gated by the identity policy and by default is not applied).
|
* mtime with policy.atimes controlling the atime slot; ownership stays gated by
|
||||||
|
* the identity policy and by default is not applied.
|
||||||
* A null metadata or an unfollowable parent is a harmless no-op. Returns false
|
* A null metadata or an unfollowable parent is a harmless no-op. Returns false
|
||||||
* only when a REQUIRED --copy-as ownership application failed, so the caller can
|
* only when a REQUIRED --copy-as ownership application failed, so the caller can
|
||||||
* report the entry as failed instead of claiming a wrong-owner success. */
|
* report the entry as failed instead of claiming a wrong-owner success. */
|
||||||
bool file_restore_symlink_metadata(const char* path, const FileMetadata* metadata,
|
bool file_restore_symlink_metadata(const char* path, const FileMetadata* metadata,
|
||||||
bool omit_link_times);
|
FileAttrPolicy policy, bool omit_link_times);
|
||||||
|
|
||||||
/* Compare timestamps using rsync's whole-second modification window. */
|
/* Compare timestamps using rsync's whole-second modification window. */
|
||||||
bool metadata_mtime_matches(time_t left_sec, long left_nsec, time_t right_sec, long right_nsec,
|
bool metadata_mtime_matches(time_t left_sec, long left_nsec, time_t right_sec, long right_nsec,
|
||||||
|
|||||||
+47
-23
@@ -2,6 +2,7 @@
|
|||||||
#include "xattr.h"
|
#include "xattr.h"
|
||||||
#include "identity.h"
|
#include "identity.h"
|
||||||
#include "log.h"
|
#include "log.h"
|
||||||
|
#include "metadata.h"
|
||||||
#include "protocol.h"
|
#include "protocol.h"
|
||||||
#include "utils.h"
|
#include "utils.h"
|
||||||
#include "file_types.h"
|
#include "file_types.h"
|
||||||
@@ -371,11 +372,15 @@ void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, int6
|
|||||||
* still applies mode/mtime where permitted.
|
* still applies mode/mtime where permitted.
|
||||||
*
|
*
|
||||||
* The OWNER leg additionally honors three policies:
|
* The OWNER leg additionally honors three policies:
|
||||||
* - an explicit ownership identity policy must be active (numeric-ids /
|
* - an ownership identity policy must be active: the explicit flags
|
||||||
* chown / usermap / groupmap / copy-as). --fake-super on its own only
|
* (--numeric-ids / --chown / --usermap / --groupmap / --copy-as) OR the
|
||||||
* RECORDS the source owner; replaying that owner as a live chown without an
|
* preserve-source -o/--owner / -g/--group requests. --fake-super on its own
|
||||||
* explicit ownership opt-in would be an un-gated client-chosen-ownership
|
* only RECORDS the source owner; replaying that owner as a live chown
|
||||||
* primitive.
|
* without an ownership opt-in would be an un-gated client-chosen-ownership
|
||||||
|
* primitive. The owner and group sides are applied INDEPENDENTLY (through
|
||||||
|
* identity_owner_requested()/identity_group_requested()), so a plain -o or
|
||||||
|
* -g touches only the requested side and passes (uid_t)-1 / (gid_t)-1 for
|
||||||
|
* the other.
|
||||||
* - --no-super (privilege_super_permitted() false) suppresses it even for a
|
* - --no-super (privilege_super_permitted() false) suppresses it even for a
|
||||||
* root receiver, exactly like the normal metadata identity path.
|
* root receiver, exactly like the normal metadata identity path.
|
||||||
* - an active --copy-as is AUTHORITATIVE: the identity path already forced the
|
* - an active --copy-as is AUTHORITATIVE: the identity path already forced the
|
||||||
@@ -383,7 +388,7 @@ void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, int6
|
|||||||
* override it. The xattr record is still stored/replayed for a later
|
* override it. The xattr record is still stored/replayed for a later
|
||||||
* privileged restore; only the live chown is skipped. Mode/mtime remain
|
* privileged restore; only the live chown is skipped. Mode/mtime remain
|
||||||
* applied either way so unprivileged --fake-super still works. */
|
* applied either way so unprivileged --fake-super still works. */
|
||||||
bool fake_super_restore_fd(int fd) {
|
bool fake_super_restore_fd(int fd, FileAttrPolicy policy) {
|
||||||
if (fd < 0)
|
if (fd < 0)
|
||||||
return false;
|
return false;
|
||||||
char record[128];
|
char record[128];
|
||||||
@@ -405,21 +410,40 @@ bool fake_super_restore_fd(int fd) {
|
|||||||
not hidden. --no-super suppresses the owner leg even for root, and an
|
not hidden. --no-super suppresses the owner leg even for root, and an
|
||||||
active --copy-as is authoritative so its forced owner must not be
|
active --copy-as is authoritative so its forced owner must not be
|
||||||
overwritten by the recorded source owner. */
|
overwritten by the recorded source owner. */
|
||||||
if (identity_active_enabled() && privilege_super_permitted() && !identity_copy_as_active() &&
|
if (identity_active_enabled() && privilege_super_permitted() && !identity_copy_as_active()) {
|
||||||
fchown(fd, (uid_t)ul_uid, (gid_t)ul_gid) != 0 && errno != EPERM && errno != EACCES)
|
/* Apply only the requested side(s): an unchosen side is passed as -1 so the
|
||||||
log_message(LOG_LEVEL_WARNING, "--fake-super: could not restore owner on destination file: %s",
|
* kernel leaves it exactly as-is. */
|
||||||
strerror(errno));
|
uid_t owner = identity_owner_requested() ? (uid_t)ul_uid : (uid_t)-1;
|
||||||
/* Mode is applied through the same sanitization the normal metadata path
|
gid_t group = identity_group_requested() ? (gid_t)ul_gid : (gid_t)-1;
|
||||||
uses (metadata_mode): group/other write bits are never granted, so a
|
if (fchown(fd, owner, group) != 0 && errno != EPERM && errno != EACCES)
|
||||||
recorded source mode of 0666 restores as 0644 — identical to a non-fake-
|
log_message(LOG_LEVEL_WARNING,
|
||||||
super --preserve run, never a privilege-granting regression. */
|
"--fake-super: could not restore owner on destination file: %s", strerror(errno));
|
||||||
if (fchmod(fd, (mode_t)(ul_mode & 0777U & ~(S_IWGRP | S_IWOTH))) != 0)
|
}
|
||||||
log_message(LOG_LEVEL_WARNING, "--fake-super: could not restore mode on destination file: %s",
|
/* Mode is applied only when the per-attribute policy asks for it, through the
|
||||||
strerror(errno));
|
SAME shared helper the normal metadata path uses (metadata_mode_for_policy):
|
||||||
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
|
group/other write bits are never granted, so a recorded source mode of 0666
|
||||||
{.tv_sec = (time_t)mtime_sec, .tv_nsec = mtime_nsec}};
|
restores as 0644 — identical to a non-fake-super --preserve run, never a
|
||||||
if (futimens(fd, times) != 0)
|
privilege-granting regression — and the -E rule derives exec bits from the
|
||||||
log_message(LOG_LEVEL_WARNING, "--fake-super: could not restore mtime on destination file: %s",
|
destination's read bits exactly like file_restore_metadata_fd. */
|
||||||
strerror(errno));
|
if (policy.perms || policy.executability) {
|
||||||
|
struct stat cur;
|
||||||
|
mode_t want = 0;
|
||||||
|
if (fstat(fd, &cur) != 0) {
|
||||||
|
log_message(LOG_LEVEL_WARNING, "--fake-super: could not read destination mode: %s",
|
||||||
|
strerror(errno));
|
||||||
|
} else if (metadata_mode_for_policy((mode_t)ul_mode, cur.st_mode, policy, &want)) {
|
||||||
|
if (fchmod(fd, want) != 0)
|
||||||
|
log_message(LOG_LEVEL_WARNING,
|
||||||
|
"--fake-super: could not restore mode on destination file: %s",
|
||||||
|
strerror(errno));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (policy.times) {
|
||||||
|
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
|
||||||
|
{.tv_sec = (time_t)mtime_sec, .tv_nsec = mtime_nsec}};
|
||||||
|
if (futimens(fd, times) != 0)
|
||||||
|
log_message(LOG_LEVEL_WARNING,
|
||||||
|
"--fake-super: could not restore mtime on destination file: %s", strerror(errno));
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-6
@@ -1,6 +1,7 @@
|
|||||||
#ifndef XATTR_H
|
#ifndef XATTR_H
|
||||||
#define XATTR_H
|
#define XATTR_H
|
||||||
|
|
||||||
|
#include "file_attr.h"
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
@@ -99,11 +100,13 @@ void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, int6
|
|||||||
* Best-effort: absence of the xattr or a malformed record is a silent no-op
|
* Best-effort: absence of the xattr or a malformed record is a silent no-op
|
||||||
* that never fails the transfer. The OWNER leg is applied only when an explicit
|
* that never fails the transfer. The OWNER leg is applied only when an explicit
|
||||||
* ownership identity policy is active (numeric-ids/chown/usermap/groupmap/
|
* ownership identity policy is active (numeric-ids/chown/usermap/groupmap/
|
||||||
* copy-as), when super-user activities are permitted, and when --copy-as is not
|
* copy-as/-o/-g), when super-user activities are permitted, and when --copy-as
|
||||||
* authoritative; a non-root EPERM/EACCES is skipped silently, matching
|
* is not authoritative; a non-root EPERM/EACCES is skipped silently, matching
|
||||||
* FastSync's identity philosophy. The mode is sanitized exactly like the normal
|
* FastSync's identity philosophy. The MODE leg is applied only when
|
||||||
* metadata path (group/other write bits never granted). Returns true when the
|
* policy.perms||policy.executability and the MTIME leg only when policy.times,
|
||||||
* xattr was present and parsed. */
|
* so the fake-super replay cannot bypass the per-attribute split; the mode is
|
||||||
bool fake_super_restore_fd(int fd);
|
* sanitized exactly like the normal metadata path (group/other write bits never
|
||||||
|
* granted). Returns true when the xattr was present and parsed. */
|
||||||
|
bool fake_super_restore_fd(int fd, FileAttrPolicy policy);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
@@ -36,7 +36,7 @@ from common import ( # noqa: E402
|
|||||||
verify_transfer,
|
verify_transfer,
|
||||||
)
|
)
|
||||||
|
|
||||||
PROTOCOL_VERSION = b"2.21.0"
|
PROTOCOL_VERSION = b"2.22.0"
|
||||||
STATUS_MANIFEST = 5
|
STATUS_MANIFEST = 5
|
||||||
STATUS_OK = 0
|
STATUS_OK = 0
|
||||||
|
|
||||||
|
|||||||
@@ -881,9 +881,16 @@ class TestExecutability:
|
|||||||
assert result.returncode == 0, f"Executability sync failed: {result.stderr[:200]}"
|
assert result.returncode == 0, f"Executability sync failed: {result.stderr[:200]}"
|
||||||
received_file = os.path.join(get_dest_received_dir(dest, source), "tool.sh")
|
received_file = os.path.join(get_dest_received_dir(dest, source), "tool.sh")
|
||||||
received_mode = os.stat(received_file).st_mode
|
received_mode = os.stat(received_file).st_mode
|
||||||
assert received_mode & 0o111 == 0o111
|
# rsync -E on a fresh destination: the base is source & ~umask, then the
|
||||||
assert received_mode & 0o600 == 0o600
|
# execute bits are derived from that base's read bits. For a source of
|
||||||
assert received_mode & 0o077 == 0o011
|
# 0751 this is exactly source & ~umask (owner rwx, group r-x, other --x
|
||||||
|
# under the usual 022 umask => group/other bits 0o051, not 0o011).
|
||||||
|
current_umask = os.umask(0)
|
||||||
|
os.umask(current_umask)
|
||||||
|
expected_mode = 0o751 & ~current_umask
|
||||||
|
assert received_mode & 0o777 == expected_mode, (
|
||||||
|
f"expected mode {oct(expected_mode)}, got {oct(received_mode & 0o777)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestChmod:
|
class TestChmod:
|
||||||
|
|||||||
@@ -94,14 +94,14 @@ def _seed_protocol_source(source):
|
|||||||
class TestProtocol:
|
class TestProtocol:
|
||||||
@pytest.mark.ci
|
@pytest.mark.ci
|
||||||
def test_protocol_current_version_accepted(self, shared_server):
|
def test_protocol_current_version_accepted(self, shared_server):
|
||||||
"""--protocol=2.21.0 (the current PROTOCOL_VERSION) is accepted and the
|
"""--protocol=2.22.0 (the current PROTOCOL_VERSION) is accepted and the
|
||||||
transfer completes normally."""
|
transfer completes normally."""
|
||||||
source = os.path.join(TEST_DATA_DIR, "proto_ok_src")
|
source = os.path.join(TEST_DATA_DIR, "proto_ok_src")
|
||||||
dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst")
|
dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst")
|
||||||
shutil.rmtree(dest, ignore_errors=True)
|
shutil.rmtree(dest, ignore_errors=True)
|
||||||
os.makedirs(dest)
|
os.makedirs(dest)
|
||||||
_seed_protocol_source(source)
|
_seed_protocol_source(source)
|
||||||
result, _ = run_client(source, dest, flags=["--protocol=2.21.0"],
|
result, _ = run_client(source, dest, flags=["--protocol=2.22.0"],
|
||||||
port=shared_server.port)
|
port=shared_server.port)
|
||||||
assert result.returncode == 0, \
|
assert result.returncode == 0, \
|
||||||
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
|
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
|
||||||
@@ -118,7 +118,8 @@ class TestProtocol:
|
|||||||
shutil.rmtree(dest, ignore_errors=True)
|
shutil.rmtree(dest, ignore_errors=True)
|
||||||
os.makedirs(dest)
|
os.makedirs(dest)
|
||||||
_seed_protocol_source(source)
|
_seed_protocol_source(source)
|
||||||
for bad in ("2.20.0", "2.19.0", "2.18.0", "2.17.0", "2.15.0", "2.16.0", "216", "31"):
|
for bad in ("2.21.0", "2.20.0", "2.19.0", "2.18.0", "2.17.0", "2.15.0", "2.16.0", "216",
|
||||||
|
"31"):
|
||||||
result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
|
result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
|
||||||
port=shared_server.port)
|
port=shared_server.port)
|
||||||
assert result.returncode != 0, f"--protocol={bad} should be rejected"
|
assert result.returncode != 0, f"--protocol={bad} should be rejected"
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
"""Wave 2b: per-attribute preservation split (-p/-t/-o/-g and their negations).
|
||||||
|
|
||||||
|
The receiver applies each attribute independently (see src/shared/file_attr.h).
|
||||||
|
These tests cover the per-flag behavior end-to-end, the CLI negations, directory
|
||||||
|
modes, and the unprivileged best-effort / root-only ownership paths. They reuse
|
||||||
|
the established helpers from common.py.
|
||||||
|
|
||||||
|
The `-s` spelling is rsync's --secluded-args no-op in FastSync; chunk
|
||||||
|
serialization is the long-form --chunk-serialization, which is what the feature
|
||||||
|
matrix below exercises.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from common import (
|
||||||
|
TEST_DATA_DIR,
|
||||||
|
run_client,
|
||||||
|
clean_dir,
|
||||||
|
get_dest_received_dir,
|
||||||
|
ServerManager,
|
||||||
|
)
|
||||||
|
|
||||||
|
DISTINCT_MTIME = 1_000_000_000 # 2001-09-09T01:46:40Z, a whole second
|
||||||
|
|
||||||
|
|
||||||
|
def _process_umask():
|
||||||
|
current = os.umask(0)
|
||||||
|
os.umask(current)
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_file(source, dest, name, content, mode, mtime=None):
|
||||||
|
"""Create a one-file source tree at an explicit mode (and mtime), and a
|
||||||
|
clean destination. Returns the source file path."""
|
||||||
|
clean_dir(source)
|
||||||
|
clean_dir(dest)
|
||||||
|
path = os.path.join(source, name)
|
||||||
|
with open(path, "wb") as fh:
|
||||||
|
fh.write(content)
|
||||||
|
os.chmod(path, mode)
|
||||||
|
if mtime is not None:
|
||||||
|
os.utime(path, (mtime, mtime))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _received(dest, source, name):
|
||||||
|
return os.path.join(get_dest_received_dir(dest, source), name)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreservePerms:
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_p_applies_source_mode(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "perms_p_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "perms_p_dst")
|
||||||
|
_seed_file(source, dest, "f.txt", b"perms\n", 0o750)
|
||||||
|
|
||||||
|
result, _ = run_client(source, dest, flags=["-p"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, \
|
||||||
|
f"-p failed: {(result.stderr or result.stdout)[:300]}"
|
||||||
|
got = os.stat(_received(dest, source, "f.txt")).st_mode & 0o777
|
||||||
|
assert got == 0o750, f"-p must apply the source mode, got {oct(got)}"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_without_p_preexisting_dest_keeps_mode(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "perms_nop_exist_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "perms_nop_exist_dst")
|
||||||
|
src_file = _seed_file(source, dest, "f.txt", b"one\n", 0o750)
|
||||||
|
|
||||||
|
# Seed the destination.
|
||||||
|
result, _ = run_client(source, dest, flags=["-p"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"seed failed: {(result.stderr or '')[:200]}"
|
||||||
|
|
||||||
|
# Give the destination a distinguishable mode, then re-transfer without
|
||||||
|
# -p (but with -t so metadata still travels).
|
||||||
|
dst_file = _received(dest, source, "f.txt")
|
||||||
|
os.chmod(dst_file, 0o600)
|
||||||
|
with open(src_file, "wb") as fh:
|
||||||
|
fh.write(b"two, changed content\n")
|
||||||
|
|
||||||
|
result, _ = run_client(source, dest, flags=["-t"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"re-run failed: {(result.stderr or '')[:200]}"
|
||||||
|
got = os.stat(dst_file).st_mode & 0o777
|
||||||
|
assert got == 0o600, \
|
||||||
|
f"without -p a pre-existing destination must keep its mode, got {oct(got)}"
|
||||||
|
with open(dst_file, "rb") as fh:
|
||||||
|
assert fh.read() == b"two, changed content\n"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_without_p_new_dest_gets_source_and_umask(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "perms_nop_new_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "perms_nop_new_dst")
|
||||||
|
# 0664 has group/other bits that the umask strips, so the result is not
|
||||||
|
# just the source mode.
|
||||||
|
_seed_file(source, dest, "f.txt", b"new\n", 0o664)
|
||||||
|
|
||||||
|
result, _ = run_client(source, dest, flags=["-t"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"-t failed: {(result.stderr or '')[:300]}"
|
||||||
|
want = 0o664 & ~_process_umask()
|
||||||
|
got = os.stat(_received(dest, source, "f.txt")).st_mode & 0o777
|
||||||
|
assert got == want, \
|
||||||
|
f"new no--p destination mode: want {oct(want)}, got {oct(got)}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreserveTimes:
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_t_applies_mtime(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "times_t_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "times_t_dst")
|
||||||
|
_seed_file(source, dest, "f.txt", b"times\n", 0o644, mtime=DISTINCT_MTIME)
|
||||||
|
|
||||||
|
result, _ = run_client(source, dest, flags=["-t"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"-t failed: {(result.stderr or '')[:300]}"
|
||||||
|
dst_m = os.stat(_received(dest, source, "f.txt")).st_mtime
|
||||||
|
assert abs(dst_m - DISTINCT_MTIME) < 2, \
|
||||||
|
f"-t must apply the source mtime, got {dst_m}"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_without_t_dest_mtime_differs(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "times_not_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "times_not_dst")
|
||||||
|
_seed_file(source, dest, "f.txt", b"times\n", 0o644, mtime=DISTINCT_MTIME)
|
||||||
|
|
||||||
|
# -p transmits metadata but must not apply the source mtime.
|
||||||
|
result, _ = run_client(source, dest, flags=["-p"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"-p failed: {(result.stderr or '')[:300]}"
|
||||||
|
dst_m = os.stat(_received(dest, source, "f.txt")).st_mtime
|
||||||
|
assert abs(dst_m - DISTINCT_MTIME) > 24 * 3600, \
|
||||||
|
f"without -t the destination mtime must not be the source mtime ({dst_m})"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_incremental_t_retransfers_after_no_t(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "times_incr_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "times_incr_dst")
|
||||||
|
_seed_file(source, dest, "f.txt", b"retransfer\n", 0o644, mtime=DISTINCT_MTIME)
|
||||||
|
|
||||||
|
# First run without -t: the destination mtime becomes "now", differing
|
||||||
|
# from the pinned source mtime.
|
||||||
|
result, _ = run_client(source, dest, flags=["-p"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"seed failed: {(result.stderr or '')[:200]}"
|
||||||
|
dst_file = _received(dest, source, "f.txt")
|
||||||
|
assert abs(os.stat(dst_file).st_mtime - DISTINCT_MTIME) > 24 * 3600
|
||||||
|
|
||||||
|
# The incremental quick-check now sees a mtime mismatch, so the file is
|
||||||
|
# re-transferred and -t stamps the source time.
|
||||||
|
result, _ = run_client(source, dest, flags=["--incremental", "-t"],
|
||||||
|
port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"incremental -t failed: {(result.stderr or '')[:300]}"
|
||||||
|
dst_m = os.stat(dst_file).st_mtime
|
||||||
|
assert abs(dst_m - DISTINCT_MTIME) < 2, \
|
||||||
|
f"second --incremental -t run must re-transfer and stamp the mtime, got {dst_m}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreserveNegations:
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_a_no_owner_no_group_keeps_perms_and_times(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "neg_owner_group_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "neg_owner_group_dst")
|
||||||
|
_seed_file(source, dest, "f.txt", b"neg\n", 0o750, mtime=DISTINCT_MTIME)
|
||||||
|
|
||||||
|
result, _ = run_client(source, dest, flags=["-a", "--no-owner", "--no-group"],
|
||||||
|
port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"-a --no-owner --no-group: {(result.stderr or '')[:300]}"
|
||||||
|
st = os.stat(_received(dest, source, "f.txt"))
|
||||||
|
assert st.st_mode & 0o777 == 0o750, "perms must survive the owner/group negation"
|
||||||
|
assert abs(st.st_mtime - DISTINCT_MTIME) < 2, "times must survive the owner/group negation"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_a_no_perms_keeps_times_and_dest_mode(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "neg_perms_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "neg_perms_dst")
|
||||||
|
src_file = _seed_file(source, dest, "f.txt", b"one\n", 0o750, mtime=DISTINCT_MTIME)
|
||||||
|
|
||||||
|
result, _ = run_client(source, dest, flags=["-a"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"seed failed: {(result.stderr or '')[:200]}"
|
||||||
|
dst_file = _received(dest, source, "f.txt")
|
||||||
|
os.chmod(dst_file, 0o600)
|
||||||
|
with open(src_file, "wb") as fh:
|
||||||
|
fh.write(b"changed\n")
|
||||||
|
# Rewriting the source bumped its mtime; restore the pinned value so the
|
||||||
|
# --no-perms run still has a distinct source time to apply.
|
||||||
|
os.utime(src_file, (DISTINCT_MTIME, DISTINCT_MTIME))
|
||||||
|
|
||||||
|
result, _ = run_client(source, dest, flags=["-a", "--no-perms"],
|
||||||
|
port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"-a --no-perms: {(result.stderr or '')[:300]}"
|
||||||
|
st = os.stat(dst_file)
|
||||||
|
assert st.st_mode & 0o777 == 0o600, \
|
||||||
|
f"--no-perms must keep the destination mode, got {oct(st.st_mode & 0o777)}"
|
||||||
|
assert abs(st.st_mtime - DISTINCT_MTIME) < 2, "--no-perms must not disable times"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_a_no_times_keeps_perms_but_not_mtime(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "neg_times_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "neg_times_dst")
|
||||||
|
_seed_file(source, dest, "f.txt", b"neg times\n", 0o750, mtime=DISTINCT_MTIME)
|
||||||
|
|
||||||
|
result, _ = run_client(source, dest, flags=["-a", "--no-times"],
|
||||||
|
port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"-a --no-times: {(result.stderr or '')[:300]}"
|
||||||
|
st = os.stat(_received(dest, source, "f.txt"))
|
||||||
|
assert st.st_mode & 0o777 == 0o750, "--no-times must not disable perms"
|
||||||
|
assert abs(st.st_mtime - DISTINCT_MTIME) > 24 * 3600, \
|
||||||
|
"--no-times must not apply the source mtime"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_preserve_no_preserve_clears_all(self, shared_server):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "neg_bundle_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "neg_bundle_dst")
|
||||||
|
_seed_file(source, dest, "f.txt", b"bundle\n", 0o750, mtime=DISTINCT_MTIME)
|
||||||
|
|
||||||
|
result, _ = run_client(source, dest, flags=["--preserve", "--no-preserve"],
|
||||||
|
port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"--preserve --no-preserve: {(result.stderr or '')[:300]}"
|
||||||
|
dst_file = _received(dest, source, "f.txt")
|
||||||
|
with open(dst_file, "rb") as fh:
|
||||||
|
assert fh.read() == b"bundle\n"
|
||||||
|
st = os.stat(dst_file)
|
||||||
|
# No metadata travels at all: a new file gets the fixed safe 0644 and
|
||||||
|
# the source mtime is not applied.
|
||||||
|
assert st.st_mode & 0o777 == 0o644, \
|
||||||
|
f"--no-preserve must not apply the source mode, got {oct(st.st_mode & 0o777)}"
|
||||||
|
assert abs(st.st_mtime - DISTINCT_MTIME) > 24 * 3600, \
|
||||||
|
"--no-preserve must not apply the source mtime"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDirectoryModes:
|
||||||
|
def _tree(self, name, dir_mode, pin_mtime):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, name + "_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, name + "_dst")
|
||||||
|
clean_dir(source)
|
||||||
|
clean_dir(dest)
|
||||||
|
sub = os.path.join(source, "sub")
|
||||||
|
os.makedirs(sub)
|
||||||
|
with open(os.path.join(sub, "file.txt"), "wb") as fh:
|
||||||
|
fh.write(b"dir mode content\n")
|
||||||
|
os.chmod(sub, dir_mode)
|
||||||
|
if pin_mtime:
|
||||||
|
os.utime(sub, (DISTINCT_MTIME, DISTINCT_MTIME))
|
||||||
|
return source, dest, sub
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_p_applies_directory_mode(self, shared_server):
|
||||||
|
source, dest, _ = self._tree("dirmode_p", 0o750, pin_mtime=False)
|
||||||
|
result, _ = run_client(source, dest, flags=["-p"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"-p failed: {(result.stderr or result.stdout)[:300]}"
|
||||||
|
got = os.stat(os.path.join(get_dest_received_dir(dest, source), "sub")).st_mode & 0o777
|
||||||
|
assert got == 0o750, f"-p must apply the source directory mode, got {oct(got)}"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_p_sanitizes_directory_group_other_write(self, shared_server):
|
||||||
|
# A 0777 source directory must never produce a group/other-writable
|
||||||
|
# destination directory: the file-mode sanitization is applied to dirs.
|
||||||
|
source, dest, _ = self._tree("dirmode_sanitize", 0o777, pin_mtime=False)
|
||||||
|
result, _ = run_client(source, dest, flags=["-p"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"-p failed: {(result.stderr or result.stdout)[:300]}"
|
||||||
|
mode = os.stat(os.path.join(get_dest_received_dir(dest, source), "sub")).st_mode & 0o777
|
||||||
|
assert mode & 0o022 == 0, \
|
||||||
|
f"directory must never be group/other writable, got {oct(mode)}"
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_omit_dir_times_suppresses_times_not_modes(self, shared_server):
|
||||||
|
source, dest, _ = self._tree("dirmode_omit", 0o750, pin_mtime=True)
|
||||||
|
result, _ = run_client(source, dest, flags=["-a", "-O"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"-a -O failed: {(result.stderr or result.stdout)[:300]}"
|
||||||
|
st = os.stat(os.path.join(get_dest_received_dir(dest, source), "sub"))
|
||||||
|
assert st.st_mode & 0o777 == 0o750, \
|
||||||
|
f"-O must suppress only dir times, not dir modes (got {oct(st.st_mode & 0o777)})"
|
||||||
|
assert abs(st.st_mtime - DISTINCT_MTIME) > 5, \
|
||||||
|
f"-O must not apply the directory mtime (got {st.st_mtime})"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOwnershipBestEffort:
|
||||||
|
"""-o/-g/-a must succeed with correct content even when the receiver cannot
|
||||||
|
chown (the unprivileged CI case). Ownership is deliberately not asserted."""
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
@pytest.mark.parametrize("flags", [["-o"], ["-g"], ["-a"]])
|
||||||
|
def test_ownership_flags_succeed_unprivileged(self, shared_server, flags):
|
||||||
|
tag = flags[0].strip("-")
|
||||||
|
source = os.path.join(TEST_DATA_DIR, f"best_effort_{tag}_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, f"best_effort_{tag}_dst")
|
||||||
|
_seed_file(source, dest, "f.txt", b"best effort ownership\n", 0o640)
|
||||||
|
|
||||||
|
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||||
|
assert result.returncode == 0, \
|
||||||
|
f"{flags} exit {result.returncode}: {(result.stderr or result.stdout)[:300]}"
|
||||||
|
with open(_received(dest, source, "f.txt"), "rb") as fh:
|
||||||
|
assert fh.read() == b"best effort ownership\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
|
||||||
|
class TestOwnershipRoot:
|
||||||
|
"""Root-only per-attribute ownership application. Not marked ci: the PR
|
||||||
|
gate runs as an unprivileged user."""
|
||||||
|
|
||||||
|
def _seed_owned(self, tag, uid, gid):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, f"root_owner_{tag}_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, f"root_owner_{tag}_dst")
|
||||||
|
path = _seed_file(source, dest, "f.txt", b"root ownership\n", 0o644)
|
||||||
|
os.chown(path, uid, gid)
|
||||||
|
return source, dest
|
||||||
|
|
||||||
|
def test_o_applies_owner_only(self, shared_server):
|
||||||
|
source, dest = self._seed_owned("o", 12345, 12346)
|
||||||
|
result, _ = run_client(source, dest, flags=["-o"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"-o failed: {(result.stderr or '')[:300]}"
|
||||||
|
st = os.stat(_received(dest, source, "f.txt"))
|
||||||
|
assert st.st_uid == 12345, f"-o must apply the owner, got uid={st.st_uid}"
|
||||||
|
assert st.st_gid != 12346, "-o must not change the group"
|
||||||
|
|
||||||
|
def test_g_applies_group_only(self, shared_server):
|
||||||
|
source, dest = self._seed_owned("g", 12345, 54321)
|
||||||
|
result, _ = run_client(source, dest, flags=["-g"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"-g failed: {(result.stderr or '')[:300]}"
|
||||||
|
st = os.stat(_received(dest, source, "f.txt"))
|
||||||
|
assert st.st_gid == 54321, f"-g must apply the group, got gid={st.st_gid}"
|
||||||
|
assert st.st_uid != 12345, "-g must not change the owner"
|
||||||
|
|
||||||
|
def test_a_applies_owner_and_group(self, shared_server):
|
||||||
|
source, dest = self._seed_owned("a", 12345, 54321)
|
||||||
|
result, _ = run_client(source, dest, flags=["-a"], port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"-a failed: {(result.stderr or '')[:300]}"
|
||||||
|
st = os.stat(_received(dest, source, "f.txt"))
|
||||||
|
assert (st.st_uid, st.st_gid) == (12345, 54321), \
|
||||||
|
f"-a must apply owner+group, got uid={st.st_uid} gid={st.st_gid}"
|
||||||
|
|
||||||
|
def test_chown_overrides_o(self, shared_server):
|
||||||
|
source, dest = self._seed_owned("chown", 11111, 22222)
|
||||||
|
result, _ = run_client(source, dest, flags=["-o", "--chown=@33333:@44444"],
|
||||||
|
port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"-o --chown failed: {(result.stderr or '')[:300]}"
|
||||||
|
st = os.stat(_received(dest, source, "f.txt"))
|
||||||
|
assert (st.st_uid, st.st_gid) == (33333, 44444), \
|
||||||
|
f"--chown must override -o, got uid={st.st_uid} gid={st.st_gid}"
|
||||||
|
|
||||||
|
def test_fake_super_o_does_not_change_group(self, shared_server):
|
||||||
|
# --fake-super replays the recorded source stat; with only -o requested
|
||||||
|
# it must apply the owner but leave the group untouched (MAJOR 1).
|
||||||
|
source, dest = self._seed_owned("fake_o", 12345, 54321)
|
||||||
|
result, _ = run_client(source, dest, flags=["--fake-super", "-o"],
|
||||||
|
port=shared_server.port)
|
||||||
|
assert result.returncode == 0, f"--fake-super -o failed: {(result.stderr or '')[:300]}"
|
||||||
|
st = os.stat(_received(dest, source, "f.txt"))
|
||||||
|
assert st.st_uid == 12345, f"--fake-super -o must apply the owner, got uid={st.st_uid}"
|
||||||
|
assert st.st_gid != 54321, "--fake-super -o must not change the group"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreserveFeatureMatrix:
|
||||||
|
"""A representative per-attribute check under the alternate transfer engines
|
||||||
|
(chunk serialization, --delay-updates, and the multithreaded scanner)."""
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
@pytest.mark.parametrize("extra", ["--chunk-serialization", "--delay-updates", "--threads"])
|
||||||
|
def test_p_and_t_hold_under_engine(self, shared_server, extra):
|
||||||
|
tag = extra.strip("-").replace("-", "_")
|
||||||
|
source = os.path.join(TEST_DATA_DIR, f"matrix_{tag}_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, f"matrix_{tag}_dst")
|
||||||
|
_seed_file(source, dest, "f.txt", b"matrix\n", 0o750, mtime=DISTINCT_MTIME)
|
||||||
|
|
||||||
|
result, _ = run_client(source, dest, flags=["-p", "-t", extra],
|
||||||
|
port=shared_server.port)
|
||||||
|
assert result.returncode == 0, \
|
||||||
|
f"-p -t {extra} failed: {(result.stderr or result.stdout)[:300]}"
|
||||||
|
st = os.stat(_received(dest, source, "f.txt"))
|
||||||
|
assert st.st_mode & 0o777 == 0o750, f"mode lost under {extra}"
|
||||||
|
assert abs(st.st_mtime - DISTINCT_MTIME) < 2, f"mtime lost under {extra}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpecialNodeModes:
|
||||||
|
"""Security: a client can never grant group/other write, including on a
|
||||||
|
recreated special node (FIFO). The special-node creation path sanitizes
|
||||||
|
S_IWGRP|S_IWOTH just like the regular-file and directory paths, so a source
|
||||||
|
FIFO with mode 0777 must land as 0755 (owner/group/other read+exec from the
|
||||||
|
source otherwise preserved). FIFOs are created unprivileged via mkfifo."""
|
||||||
|
|
||||||
|
@pytest.mark.ci
|
||||||
|
def test_specials_p_sanitizes_fifo_group_other_write(self):
|
||||||
|
source = os.path.join(TEST_DATA_DIR, "specialmode_src")
|
||||||
|
dest = os.path.join(TEST_DATA_DIR, "specialmode_dst")
|
||||||
|
clean_dir(source)
|
||||||
|
clean_dir(dest)
|
||||||
|
|
||||||
|
src_fifo = os.path.join(source, "world.fifo")
|
||||||
|
os.mkfifo(src_fifo)
|
||||||
|
os.chmod(src_fifo, 0o777)
|
||||||
|
assert os.stat(src_fifo).st_mode & 0o777 == 0o777
|
||||||
|
|
||||||
|
# Production daemonizes with umask(0) (server.c) so the source mode is
|
||||||
|
# what reaches mkfifo. The session server runs in the foreground and
|
||||||
|
# would inherit the runner's umask, which alone would strip the write
|
||||||
|
# bits and mask a regression in the sanitization. Start a dedicated
|
||||||
|
# foreground server under umask(0) to exercise the real path.
|
||||||
|
server = ServerManager()
|
||||||
|
saved_umask = os.umask(0)
|
||||||
|
try:
|
||||||
|
server.start(extra_args=["--allow-super"])
|
||||||
|
finally:
|
||||||
|
os.umask(saved_umask)
|
||||||
|
try:
|
||||||
|
result, _ = run_client(source, dest, flags=["--specials", "-p"],
|
||||||
|
port=server.port)
|
||||||
|
finally:
|
||||||
|
server.stop()
|
||||||
|
|
||||||
|
assert result.returncode == 0, \
|
||||||
|
f"--specials -p failed: {(result.stderr or result.stdout)[:300]}"
|
||||||
|
|
||||||
|
received = _received(dest, source, "world.fifo")
|
||||||
|
assert os.path.lexists(received), "source FIFO was not recreated on the destination"
|
||||||
|
st = os.lstat(received)
|
||||||
|
assert stat.S_ISFIFO(st.st_mode), f"received entry is not a FIFO: {oct(st.st_mode)}"
|
||||||
|
mode = st.st_mode & 0o777
|
||||||
|
assert mode & 0o022 == 0, \
|
||||||
|
f"recreated FIFO must never be group/other writable, got {oct(mode)}"
|
||||||
|
assert mode == 0o755, \
|
||||||
|
f"-p must preserve the source FIFO mode minus group/other write (want 0o755), got {oct(mode)}"
|
||||||
+321
-7
@@ -10,6 +10,7 @@
|
|||||||
#include "test_utils.h"
|
#include "test_utils.h"
|
||||||
#include "utils.h"
|
#include "utils.h"
|
||||||
#include <pwd.h>
|
#include <pwd.h>
|
||||||
|
#include <stddef.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
@@ -200,9 +201,19 @@ static void test_cli_archive_flags() {
|
|||||||
|
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||||
EXPECT_TRUE(cfg->follow_symlinks);
|
EXPECT_TRUE(cfg->follow_symlinks);
|
||||||
EXPECT_TRUE(cfg->use_metadata);
|
EXPECT_TRUE(cfg->preserve_perms);
|
||||||
|
EXPECT_TRUE(cfg->preserve_times);
|
||||||
|
EXPECT_TRUE(cfg->preserve_owner);
|
||||||
|
EXPECT_TRUE(cfg->preserve_group);
|
||||||
EXPECT_TRUE(cfg->preserve_devices);
|
EXPECT_TRUE(cfg->preserve_devices);
|
||||||
EXPECT_TRUE(cfg->preserve_specials);
|
EXPECT_TRUE(cfg->preserve_specials);
|
||||||
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
|
EXPECT_FALSE(cfg->preserve_acls);
|
||||||
|
EXPECT_FALSE(cfg->preserve_xattrs);
|
||||||
|
EXPECT_FALSE(cfg->use_xattrs);
|
||||||
|
EXPECT_FALSE(cfg->preserve_atimes);
|
||||||
|
EXPECT_FALSE(cfg->preserve_crtimes);
|
||||||
|
EXPECT_FALSE(cfg->preserve_hard_links);
|
||||||
EXPECT_FALSE(cfg->use_compression);
|
EXPECT_FALSE(cfg->use_compression);
|
||||||
EXPECT_FALSE(cfg->use_multithreading);
|
EXPECT_FALSE(cfg->use_multithreading);
|
||||||
|
|
||||||
@@ -306,7 +317,7 @@ static void test_parse_args_protocol_accept_current() {
|
|||||||
Config* cfg = valid_client_config();
|
Config* cfg = valid_client_config();
|
||||||
EXPECT_NOT_NULL(cfg);
|
EXPECT_NOT_NULL(cfg);
|
||||||
char* argv_equals[] = {"fastsync", "--source-dir", "/src",
|
char* argv_equals[] = {"fastsync", "--source-dir", "/src",
|
||||||
"--dest-dir", "/dst", "--protocol=2.21.0"};
|
"--dest-dir", "/dst", "--protocol=2.22.0"};
|
||||||
int positional_args[2];
|
int positional_args[2];
|
||||||
int positional_count = 0;
|
int positional_count = 0;
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
|
||||||
@@ -316,7 +327,7 @@ static void test_parse_args_protocol_accept_current() {
|
|||||||
cfg = valid_client_config();
|
cfg = valid_client_config();
|
||||||
EXPECT_NOT_NULL(cfg);
|
EXPECT_NOT_NULL(cfg);
|
||||||
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
|
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
|
||||||
"/dst", "--protocol", "2.21.0"};
|
"/dst", "--protocol", "2.22.0"};
|
||||||
positional_count = 0;
|
positional_count = 0;
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0);
|
||||||
EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION);
|
EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION);
|
||||||
@@ -326,9 +337,9 @@ static void test_parse_args_protocol_accept_current() {
|
|||||||
/* Any --protocol value other than the current PROTOCOL_VERSION must end in
|
/* Any --protocol value other than the current PROTOCOL_VERSION must end in
|
||||||
* failure (parse_args simply stores it; validate_config rejects it up front). */
|
* failure (parse_args simply stores it; validate_config rejects it up front). */
|
||||||
static void test_parse_args_protocol_rejects_other_versions() {
|
static void test_parse_args_protocol_rejects_other_versions() {
|
||||||
static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0",
|
static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0",
|
||||||
"2.17.0", "2.18.0", "2.19.0", "2.20.0",
|
"2.18.0", "2.19.0", "2.20.0", "2.21.0", "216",
|
||||||
"216", "31", "abc", ""};
|
"31", "abc", ""};
|
||||||
for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) {
|
for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) {
|
||||||
Config* cfg = valid_client_config();
|
Config* cfg = valid_client_config();
|
||||||
EXPECT_NOT_NULL(cfg);
|
EXPECT_NOT_NULL(cfg);
|
||||||
@@ -373,6 +384,7 @@ static void test_parse_args_xattrs_acls() {
|
|||||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||||
EXPECT_TRUE(cfg->preserve_xattrs);
|
EXPECT_TRUE(cfg->preserve_xattrs);
|
||||||
EXPECT_FALSE(cfg->preserve_acls);
|
EXPECT_FALSE(cfg->preserve_acls);
|
||||||
|
EXPECT_FALSE(cfg->preserve_perms);
|
||||||
EXPECT_TRUE(cfg->use_xattrs);
|
EXPECT_TRUE(cfg->use_xattrs);
|
||||||
EXPECT_TRUE(cfg->use_metadata);
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
config_delete(cfg);
|
config_delete(cfg);
|
||||||
@@ -382,6 +394,7 @@ static void test_parse_args_xattrs_acls() {
|
|||||||
char* argv_long[] = {"fastsync", "--acls", "/src", "/dst"};
|
char* argv_long[] = {"fastsync", "--acls", "/src", "/dst"};
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_long, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv_long, positional_args, &positional_count), 0);
|
||||||
EXPECT_TRUE(cfg->preserve_acls);
|
EXPECT_TRUE(cfg->preserve_acls);
|
||||||
|
EXPECT_TRUE(cfg->preserve_perms);
|
||||||
EXPECT_TRUE(cfg->use_xattrs);
|
EXPECT_TRUE(cfg->use_xattrs);
|
||||||
EXPECT_TRUE(cfg->use_metadata);
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
config_delete(cfg);
|
config_delete(cfg);
|
||||||
@@ -392,6 +405,7 @@ static void test_parse_args_xattrs_acls() {
|
|||||||
EXPECT_EQ_INT(parse_args(cfg, 6, argv_neg, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 6, argv_neg, positional_args, &positional_count), 0);
|
||||||
EXPECT_FALSE(cfg->preserve_xattrs);
|
EXPECT_FALSE(cfg->preserve_xattrs);
|
||||||
EXPECT_TRUE(cfg->preserve_acls);
|
EXPECT_TRUE(cfg->preserve_acls);
|
||||||
|
EXPECT_TRUE(cfg->preserve_perms);
|
||||||
EXPECT_TRUE(cfg->use_xattrs);
|
EXPECT_TRUE(cfg->use_xattrs);
|
||||||
config_delete(cfg);
|
config_delete(cfg);
|
||||||
}
|
}
|
||||||
@@ -503,6 +517,7 @@ static void test_parse_args_executability() {
|
|||||||
|
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||||
EXPECT_TRUE(cfg->use_executability);
|
EXPECT_TRUE(cfg->use_executability);
|
||||||
|
EXPECT_FALSE(cfg->preserve_perms);
|
||||||
EXPECT_TRUE(cfg->use_metadata);
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
|
|
||||||
config_delete(cfg);
|
config_delete(cfg);
|
||||||
@@ -515,6 +530,7 @@ static void test_parse_args_chmod() {
|
|||||||
int positional_count = 0;
|
int positional_count = 0;
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||||
EXPECT_EQ_STR(cfg->chmod_spec, "u=rw,go=r");
|
EXPECT_EQ_STR(cfg->chmod_spec, "u=rw,go=r");
|
||||||
|
EXPECT_TRUE(cfg->preserve_perms);
|
||||||
EXPECT_TRUE(cfg->use_metadata);
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
mode_t result;
|
mode_t result;
|
||||||
EXPECT_TRUE(chmod_apply(0777, cfg->chmod_spec, &result));
|
EXPECT_TRUE(chmod_apply(0777, cfg->chmod_spec, &result));
|
||||||
@@ -529,6 +545,7 @@ static void test_parse_args_numeric_chmod() {
|
|||||||
int positional_count = 0;
|
int positional_count = 0;
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
|
||||||
EXPECT_EQ_STR(cfg->chmod_spec, "7777");
|
EXPECT_EQ_STR(cfg->chmod_spec, "7777");
|
||||||
|
EXPECT_TRUE(cfg->preserve_perms);
|
||||||
EXPECT_TRUE(cfg->use_metadata);
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
config_delete(cfg);
|
config_delete(cfg);
|
||||||
}
|
}
|
||||||
@@ -1280,9 +1297,13 @@ static void test_parse_args_archive() {
|
|||||||
int ret = parse_args(cfg, 4, argv, positional_args, &positional_count);
|
int ret = parse_args(cfg, 4, argv, positional_args, &positional_count);
|
||||||
EXPECT_EQ_INT(ret, 0);
|
EXPECT_EQ_INT(ret, 0);
|
||||||
EXPECT_TRUE(cfg->follow_symlinks);
|
EXPECT_TRUE(cfg->follow_symlinks);
|
||||||
EXPECT_TRUE(cfg->use_metadata);
|
EXPECT_TRUE(cfg->preserve_perms);
|
||||||
|
EXPECT_TRUE(cfg->preserve_times);
|
||||||
|
EXPECT_TRUE(cfg->preserve_owner);
|
||||||
|
EXPECT_TRUE(cfg->preserve_group);
|
||||||
EXPECT_TRUE(cfg->preserve_devices);
|
EXPECT_TRUE(cfg->preserve_devices);
|
||||||
EXPECT_TRUE(cfg->preserve_specials);
|
EXPECT_TRUE(cfg->preserve_specials);
|
||||||
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
EXPECT_FALSE(cfg->use_compression);
|
EXPECT_FALSE(cfg->use_compression);
|
||||||
EXPECT_FALSE(cfg->use_multithreading);
|
EXPECT_FALSE(cfg->use_multithreading);
|
||||||
|
|
||||||
@@ -2659,6 +2680,7 @@ static void test_parse_args_usermap() {
|
|||||||
int positional_count = 0;
|
int positional_count = 0;
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||||
EXPECT_TRUE(cfg->use_metadata);
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
|
EXPECT_TRUE(cfg->preserve_owner);
|
||||||
EXPECT_EQ_INT(cfg->usermap_count, 1);
|
EXPECT_EQ_INT(cfg->usermap_count, 1);
|
||||||
EXPECT_EQ_INT(cfg->usermap[0].from, 1000);
|
EXPECT_EQ_INT(cfg->usermap[0].from, 1000);
|
||||||
EXPECT_EQ_INT(cfg->usermap[0].to, 1001);
|
EXPECT_EQ_INT(cfg->usermap[0].to, 1001);
|
||||||
@@ -2693,6 +2715,7 @@ static void test_parse_args_groupmap() {
|
|||||||
int positional_count = 0;
|
int positional_count = 0;
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||||
EXPECT_TRUE(cfg->use_metadata);
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
|
EXPECT_TRUE(cfg->preserve_group);
|
||||||
EXPECT_EQ_INT(cfg->groupmap_count, 1);
|
EXPECT_EQ_INT(cfg->groupmap_count, 1);
|
||||||
EXPECT_EQ_INT(cfg->groupmap[0].from, 100);
|
EXPECT_EQ_INT(cfg->groupmap[0].from, 100);
|
||||||
EXPECT_EQ_INT(cfg->groupmap[0].to, 101);
|
EXPECT_EQ_INT(cfg->groupmap[0].to, 101);
|
||||||
@@ -2724,6 +2747,8 @@ static void test_parse_args_chown() {
|
|||||||
int positional_count = 0;
|
int positional_count = 0;
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||||
EXPECT_TRUE(cfg->use_metadata);
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
|
EXPECT_TRUE(cfg->preserve_owner);
|
||||||
|
EXPECT_TRUE(cfg->preserve_group);
|
||||||
EXPECT_TRUE(cfg->chown_uid_set);
|
EXPECT_TRUE(cfg->chown_uid_set);
|
||||||
EXPECT_EQ_INT(cfg->chown_uid, 1000);
|
EXPECT_EQ_INT(cfg->chown_uid, 1000);
|
||||||
EXPECT_TRUE(cfg->chown_gid_set);
|
EXPECT_TRUE(cfg->chown_gid_set);
|
||||||
@@ -2736,7 +2761,9 @@ static void test_parse_args_chown() {
|
|||||||
char* argv2[] = {"fastsync", "--chown=:@1001", "/src", "/dst"};
|
char* argv2[] = {"fastsync", "--chown=:@1001", "/src", "/dst"};
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv2, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv2, positional_args, &positional_count), 0);
|
||||||
EXPECT_FALSE(cfg->chown_uid_set);
|
EXPECT_FALSE(cfg->chown_uid_set);
|
||||||
|
EXPECT_FALSE(cfg->preserve_owner);
|
||||||
EXPECT_TRUE(cfg->chown_gid_set);
|
EXPECT_TRUE(cfg->chown_gid_set);
|
||||||
|
EXPECT_TRUE(cfg->preserve_group);
|
||||||
EXPECT_EQ_INT(cfg->chown_gid, 1001);
|
EXPECT_EQ_INT(cfg->chown_gid, 1001);
|
||||||
config_delete(cfg);
|
config_delete(cfg);
|
||||||
|
|
||||||
@@ -2746,8 +2773,10 @@ static void test_parse_args_chown() {
|
|||||||
char* argv3[] = {"fastsync", "--chown=@1000", "/src", "/dst"};
|
char* argv3[] = {"fastsync", "--chown=@1000", "/src", "/dst"};
|
||||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv3, positional_args, &positional_count), 0);
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv3, positional_args, &positional_count), 0);
|
||||||
EXPECT_TRUE(cfg->chown_uid_set);
|
EXPECT_TRUE(cfg->chown_uid_set);
|
||||||
|
EXPECT_TRUE(cfg->preserve_owner);
|
||||||
EXPECT_EQ_INT(cfg->chown_uid, 1000);
|
EXPECT_EQ_INT(cfg->chown_uid, 1000);
|
||||||
EXPECT_FALSE(cfg->chown_gid_set);
|
EXPECT_FALSE(cfg->chown_gid_set);
|
||||||
|
EXPECT_FALSE(cfg->preserve_group);
|
||||||
config_delete(cfg);
|
config_delete(cfg);
|
||||||
|
|
||||||
/* '*' means current user/group. */
|
/* '*' means current user/group. */
|
||||||
@@ -2894,6 +2923,8 @@ static void test_parse_args_metadata_times() {
|
|||||||
EXPECT_TRUE(cfg->omit_dir_times);
|
EXPECT_TRUE(cfg->omit_dir_times);
|
||||||
EXPECT_TRUE(cfg->omit_link_times);
|
EXPECT_TRUE(cfg->omit_link_times);
|
||||||
EXPECT_TRUE(cfg->open_noatime);
|
EXPECT_TRUE(cfg->open_noatime);
|
||||||
|
/* -U/-N govern atimes/crtimes only; they do NOT enable -t/--times. */
|
||||||
|
EXPECT_FALSE(cfg->preserve_times);
|
||||||
/* -U/-N carry their times inside the metadata payload, so they imply it. */
|
/* -U/-N carry their times inside the metadata payload, so they imply it. */
|
||||||
EXPECT_TRUE(cfg->use_metadata);
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
EXPECT_TRUE(validate_config(cfg));
|
EXPECT_TRUE(validate_config(cfg));
|
||||||
@@ -3327,6 +3358,280 @@ static void test_validate_config_dry_run_rejects_write_batch() {
|
|||||||
config_delete(cfg);
|
config_delete(cfg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -p/-t/-o/-g are independent per-attribute preservation flags: each sets only
|
||||||
|
* its own bit and enables metadata transmission (config_derived_use_metadata). */
|
||||||
|
static void test_parse_args_preserve_attributes_are_independent() {
|
||||||
|
struct {
|
||||||
|
const char* arg;
|
||||||
|
size_t offset;
|
||||||
|
} cases[] = {
|
||||||
|
{"-p", offsetof(Config, preserve_perms)}, {"--perms", offsetof(Config, preserve_perms)},
|
||||||
|
{"-t", offsetof(Config, preserve_times)}, {"--times", offsetof(Config, preserve_times)},
|
||||||
|
{"-o", offsetof(Config, preserve_owner)}, {"--owner", offsetof(Config, preserve_owner)},
|
||||||
|
{"-g", offsetof(Config, preserve_group)}, {"--group", offsetof(Config, preserve_group)},
|
||||||
|
};
|
||||||
|
const size_t all[] = {offsetof(Config, preserve_perms), offsetof(Config, preserve_times),
|
||||||
|
offsetof(Config, preserve_owner), offsetof(Config, preserve_group)};
|
||||||
|
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
||||||
|
Config* cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
char* argv[] = {"fastsync", (char*)cases[i].arg, "/src", "/dst"};
|
||||||
|
int positional_args[2];
|
||||||
|
int positional_count = 0;
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||||
|
for (size_t j = 0; j < sizeof(all) / sizeof(all[0]); j++) {
|
||||||
|
bool expected = all[j] == cases[i].offset;
|
||||||
|
EXPECT_TRUE(*(bool*)((char*)cfg + all[j]) == expected);
|
||||||
|
}
|
||||||
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
|
config_delete(cfg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --preserve is the long-only rsync alias for perms+times (NOT owner/group). */
|
||||||
|
static void test_parse_args_preserve_long_form() {
|
||||||
|
Config* cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
char* argv[] = {"fastsync", "--preserve", "/src", "/dst"};
|
||||||
|
int positional_args[2];
|
||||||
|
int positional_count = 0;
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_TRUE(cfg->preserve_perms);
|
||||||
|
EXPECT_TRUE(cfg->preserve_times);
|
||||||
|
EXPECT_FALSE(cfg->preserve_owner);
|
||||||
|
EXPECT_FALSE(cfg->preserve_group);
|
||||||
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
|
config_delete(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --no-perms/--no-times/--no-owner/--no-group (long and short) clear only
|
||||||
|
* their own attribute bit; they never set metadata_explicitly_disabled. */
|
||||||
|
static void test_parse_args_preserve_negations() {
|
||||||
|
struct {
|
||||||
|
const char* arg;
|
||||||
|
size_t offset;
|
||||||
|
} cases[] = {
|
||||||
|
{"--no-perms", offsetof(Config, preserve_perms)},
|
||||||
|
{"--no-p", offsetof(Config, preserve_perms)},
|
||||||
|
{"--no-times", offsetof(Config, preserve_times)},
|
||||||
|
{"--no-t", offsetof(Config, preserve_times)},
|
||||||
|
{"--no-owner", offsetof(Config, preserve_owner)},
|
||||||
|
{"--no-o", offsetof(Config, preserve_owner)},
|
||||||
|
{"--no-group", offsetof(Config, preserve_group)},
|
||||||
|
{"--no-g", offsetof(Config, preserve_group)},
|
||||||
|
};
|
||||||
|
const size_t all[] = {offsetof(Config, preserve_perms), offsetof(Config, preserve_times),
|
||||||
|
offsetof(Config, preserve_owner), offsetof(Config, preserve_group)};
|
||||||
|
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
||||||
|
Config* cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
char* argv[] = {"fastsync", "-a", (char*)cases[i].arg, "/src", "/dst"};
|
||||||
|
int positional_args[2];
|
||||||
|
int positional_count = 0;
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
|
||||||
|
for (size_t j = 0; j < sizeof(all) / sizeof(all[0]); j++) {
|
||||||
|
bool expected = all[j] != cases[i].offset;
|
||||||
|
EXPECT_TRUE(*(bool*)((char*)cfg + all[j]) == expected);
|
||||||
|
}
|
||||||
|
EXPECT_FALSE(cfg->metadata_explicitly_disabled);
|
||||||
|
/* -a's devices/specials keep the metadata frame on. */
|
||||||
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
|
config_delete(cfg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Negations are order-dependent like rsync: -a after --no-owner re-enables it. */
|
||||||
|
static void test_parse_args_preserve_negation_order() {
|
||||||
|
Config* cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
char* argv1[] = {"fastsync", "-a", "--no-owner", "/src", "/dst"};
|
||||||
|
int positional_args[2];
|
||||||
|
int positional_count = 0;
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 5, argv1, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_FALSE(cfg->preserve_owner);
|
||||||
|
EXPECT_TRUE(cfg->preserve_perms);
|
||||||
|
EXPECT_TRUE(cfg->preserve_times);
|
||||||
|
EXPECT_TRUE(cfg->preserve_group);
|
||||||
|
config_delete(cfg);
|
||||||
|
|
||||||
|
cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
positional_count = 0;
|
||||||
|
char* argv2[] = {"fastsync", "--no-owner", "-a", "/src", "/dst"};
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 5, argv2, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_TRUE(cfg->preserve_owner);
|
||||||
|
config_delete(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --no-preserve clears the whole four-attribute bundle and records the explicit
|
||||||
|
* metadata opt-out, so the incremental/delta implication stays off. */
|
||||||
|
static void test_parse_args_no_preserve_disables_bundle() {
|
||||||
|
Config* cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
char* argv[] = {"fastsync", "--incremental", "--preserve", "--no-preserve", "/src", "/dst"};
|
||||||
|
int positional_args[2];
|
||||||
|
int positional_count = 0;
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 6, argv, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_FALSE(cfg->preserve_perms);
|
||||||
|
EXPECT_FALSE(cfg->preserve_times);
|
||||||
|
EXPECT_FALSE(cfg->preserve_owner);
|
||||||
|
EXPECT_FALSE(cfg->preserve_group);
|
||||||
|
EXPECT_TRUE(cfg->metadata_explicitly_disabled);
|
||||||
|
EXPECT_TRUE(cfg->use_incremental);
|
||||||
|
EXPECT_FALSE(cfg->use_metadata);
|
||||||
|
config_delete(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MAJOR 4: --incremental/--delta historically auto-enabled mode+mtime
|
||||||
|
* preservation (README: "--incremental Auto-enables --preserve"), while an
|
||||||
|
* explicit per-attribute negation must still win. */
|
||||||
|
static void test_parse_args_incremental_implies_preserve() {
|
||||||
|
int positional_args[2];
|
||||||
|
int positional_count = 0;
|
||||||
|
|
||||||
|
/* --incremental alone implies both perms and times. */
|
||||||
|
Config* cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
char* argv[] = {"fastsync", "--incremental", "/src", "/dst"};
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_TRUE(cfg->preserve_perms);
|
||||||
|
EXPECT_TRUE(cfg->preserve_times);
|
||||||
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
|
config_delete(cfg);
|
||||||
|
|
||||||
|
/* --incremental --no-perms keeps the auto-preserved times but not perms. */
|
||||||
|
cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
positional_count = 0;
|
||||||
|
char* argv2[] = {"fastsync", "--incremental", "--no-perms", "/src", "/dst"};
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 5, argv2, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_FALSE(cfg->preserve_perms);
|
||||||
|
EXPECT_TRUE(cfg->preserve_times);
|
||||||
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
|
config_delete(cfg);
|
||||||
|
|
||||||
|
/* --incremental --no-times keeps perms but not times. */
|
||||||
|
cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
positional_count = 0;
|
||||||
|
char* argv3[] = {"fastsync", "--incremental", "--no-times", "/src", "/dst"};
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 5, argv3, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_TRUE(cfg->preserve_perms);
|
||||||
|
EXPECT_FALSE(cfg->preserve_times);
|
||||||
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
|
config_delete(cfg);
|
||||||
|
|
||||||
|
/* --incremental --no-preserve turns the whole implication off. */
|
||||||
|
cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
positional_count = 0;
|
||||||
|
char* argv4[] = {"fastsync", "--incremental", "--no-preserve", "/src", "/dst"};
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 5, argv4, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_FALSE(cfg->preserve_perms);
|
||||||
|
EXPECT_FALSE(cfg->preserve_times);
|
||||||
|
EXPECT_TRUE(cfg->metadata_explicitly_disabled);
|
||||||
|
EXPECT_FALSE(cfg->use_metadata);
|
||||||
|
config_delete(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* config_derived_use_metadata via the CLI: representative options that turn the
|
||||||
|
* transport bit on, and a bare run / --no-preserve that leave it off. */
|
||||||
|
static void test_parse_args_derived_use_metadata() {
|
||||||
|
static const char* const true_args[] = {"-p", "-t",
|
||||||
|
"-o", "-g",
|
||||||
|
"-U", "-N",
|
||||||
|
"-E", "--chmod=u=rw",
|
||||||
|
"--fake-super", "-D",
|
||||||
|
"--devices", "-X",
|
||||||
|
"-A", "--copy-as=@1:@1",
|
||||||
|
"-u", "--incremental",
|
||||||
|
"--delta"};
|
||||||
|
for (size_t i = 0; i < sizeof(true_args) / sizeof(true_args[0]); i++) {
|
||||||
|
Config* cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
char* argv[] = {"fastsync", (char*)true_args[i], "/src", "/dst"};
|
||||||
|
int positional_args[2];
|
||||||
|
int positional_count = 0;
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
|
config_delete(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A bare run does not derive metadata. */
|
||||||
|
Config* bare = config_create();
|
||||||
|
EXPECT_NOT_NULL(bare);
|
||||||
|
char* bare_argv[] = {"fastsync", "/src", "/dst"};
|
||||||
|
int positional_args[2];
|
||||||
|
int positional_count = 0;
|
||||||
|
EXPECT_EQ_INT(parse_args(bare, 3, bare_argv, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_FALSE(bare->use_metadata);
|
||||||
|
config_delete(bare);
|
||||||
|
|
||||||
|
/* --no-preserve suppresses the derived bit entirely. */
|
||||||
|
Config* neg = config_create();
|
||||||
|
EXPECT_NOT_NULL(neg);
|
||||||
|
positional_count = 0;
|
||||||
|
char* neg_argv[] = {"fastsync", "-p", "--no-preserve", "/src", "/dst"};
|
||||||
|
EXPECT_EQ_INT(parse_args(neg, 5, neg_argv, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_FALSE(neg->use_metadata);
|
||||||
|
config_delete(neg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -U/--atimes and -N/--crtimes govern only their own time attribute: each
|
||||||
|
* carries its time inside the metadata payload (so it enables metadata
|
||||||
|
* transmission), but neither may imply -t/--times. */
|
||||||
|
static void test_parse_args_atimes_crtimes_do_not_imply_times() {
|
||||||
|
Config* cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
char* argv_short_u[] = {"fastsync", "-U", "/src", "/dst"};
|
||||||
|
int positional_args[2];
|
||||||
|
int positional_count = 0;
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv_short_u, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_TRUE(cfg->preserve_atimes);
|
||||||
|
EXPECT_FALSE(cfg->preserve_times);
|
||||||
|
EXPECT_FALSE(cfg->preserve_perms);
|
||||||
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
|
config_delete(cfg);
|
||||||
|
|
||||||
|
cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
positional_count = 0;
|
||||||
|
char* argv_long_n[] = {"fastsync", "--crtimes", "/src", "/dst"};
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv_long_n, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_TRUE(cfg->preserve_crtimes);
|
||||||
|
EXPECT_FALSE(cfg->preserve_times);
|
||||||
|
EXPECT_FALSE(cfg->preserve_perms);
|
||||||
|
EXPECT_TRUE(cfg->use_metadata);
|
||||||
|
config_delete(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -A/--acls implies --perms (ACL application goes through the mode path);
|
||||||
|
* -X/--xattrs preserves only the extended attributes and must NOT set
|
||||||
|
* preserve_perms. Either enables the derived xattr transport bit. */
|
||||||
|
static void test_parse_args_acls_implies_perms_xattrs_does_not() {
|
||||||
|
Config* cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
char* argv_x[] = {"fastsync", "-X", "/src", "/dst"};
|
||||||
|
int positional_args[2];
|
||||||
|
int positional_count = 0;
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv_x, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_TRUE(cfg->preserve_xattrs);
|
||||||
|
EXPECT_FALSE(cfg->preserve_acls);
|
||||||
|
EXPECT_FALSE(cfg->preserve_perms);
|
||||||
|
EXPECT_TRUE(cfg->use_xattrs);
|
||||||
|
config_delete(cfg);
|
||||||
|
|
||||||
|
cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
positional_count = 0;
|
||||||
|
char* argv_a[] = {"fastsync", "-A", "/src", "/dst"};
|
||||||
|
EXPECT_EQ_INT(parse_args(cfg, 4, argv_a, positional_args, &positional_count), 0);
|
||||||
|
EXPECT_TRUE(cfg->preserve_acls);
|
||||||
|
EXPECT_TRUE(cfg->preserve_perms);
|
||||||
|
EXPECT_TRUE(cfg->use_xattrs);
|
||||||
|
config_delete(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
void test_client_cli() {
|
void test_client_cli() {
|
||||||
test_validate_config_required_paths();
|
test_validate_config_required_paths();
|
||||||
test_parse_args_numeric_ids();
|
test_parse_args_numeric_ids();
|
||||||
@@ -3412,6 +3717,15 @@ void test_client_cli() {
|
|||||||
test_parse_args_info_verbose_order();
|
test_parse_args_info_verbose_order();
|
||||||
test_parse_args_rejects_invalid_info_flag();
|
test_parse_args_rejects_invalid_info_flag();
|
||||||
test_parse_args_archive();
|
test_parse_args_archive();
|
||||||
|
test_parse_args_preserve_attributes_are_independent();
|
||||||
|
test_parse_args_preserve_long_form();
|
||||||
|
test_parse_args_preserve_negations();
|
||||||
|
test_parse_args_preserve_negation_order();
|
||||||
|
test_parse_args_no_preserve_disables_bundle();
|
||||||
|
test_parse_args_incremental_implies_preserve();
|
||||||
|
test_parse_args_derived_use_metadata();
|
||||||
|
test_parse_args_atimes_crtimes_do_not_imply_times();
|
||||||
|
test_parse_args_acls_implies_perms_xattrs_does_not();
|
||||||
test_parse_args_negations();
|
test_parse_args_negations();
|
||||||
test_parse_args_negate_preserve_without_devices();
|
test_parse_args_negate_preserve_without_devices();
|
||||||
test_parse_args_negation_order();
|
test_parse_args_negation_order();
|
||||||
|
|||||||
+210
-8
@@ -9,6 +9,7 @@
|
|||||||
#include "test_utils.h"
|
#include "test_utils.h"
|
||||||
#include "utils.h"
|
#include "utils.h"
|
||||||
#include <signal.h>
|
#include <signal.h>
|
||||||
|
#include <stddef.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <sys/socket.h>
|
#include <sys/socket.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
@@ -1292,6 +1293,9 @@ static void test_config_metadata_times_wire_roundtrip() {
|
|||||||
send_cfg->preserve_crtimes = true;
|
send_cfg->preserve_crtimes = true;
|
||||||
send_cfg->omit_dir_times = true;
|
send_cfg->omit_dir_times = true;
|
||||||
send_cfg->omit_link_times = true;
|
send_cfg->omit_link_times = true;
|
||||||
|
/* The preservation attributes now require the metadata frame to travel
|
||||||
|
* (config_invariants_error rejects them otherwise). */
|
||||||
|
send_cfg->use_metadata = true;
|
||||||
/* --open-noatime is client-only and must NOT cross the wire. */
|
/* --open-noatime is client-only and must NOT cross the wire. */
|
||||||
send_cfg->open_noatime = true;
|
send_cfg->open_noatime = true;
|
||||||
|
|
||||||
@@ -2056,6 +2060,46 @@ static void test_identity_ownership_requested() {
|
|||||||
config_delete(gm);
|
config_delete(gm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The narrow client-CHOSEN ownership predicate the daemon module gate refuses:
|
||||||
|
* a preserve-source -o/-g (or -a) must NOT be in it (it is handled by forcing
|
||||||
|
* super-user activity off instead), while every explicit identity flag is. */
|
||||||
|
static void test_identity_explicit_ownership_requested() {
|
||||||
|
EXPECT_FALSE(identity_explicit_ownership_requested(NULL));
|
||||||
|
|
||||||
|
Config* c = config_create();
|
||||||
|
EXPECT_NOT_NULL(c);
|
||||||
|
EXPECT_FALSE(identity_explicit_ownership_requested(c));
|
||||||
|
c->preserve_owner = true;
|
||||||
|
EXPECT_FALSE(identity_explicit_ownership_requested(c));
|
||||||
|
EXPECT_TRUE(identity_ownership_requested(c)); /* general awareness does see -o */
|
||||||
|
c->preserve_group = true;
|
||||||
|
EXPECT_FALSE(identity_explicit_ownership_requested(c));
|
||||||
|
c->preserve_owner = false;
|
||||||
|
c->preserve_group = false;
|
||||||
|
|
||||||
|
c->numeric_ids = true;
|
||||||
|
EXPECT_TRUE(identity_explicit_ownership_requested(c));
|
||||||
|
c->numeric_ids = false;
|
||||||
|
c->chown_uid_set = true;
|
||||||
|
EXPECT_TRUE(identity_explicit_ownership_requested(c));
|
||||||
|
c->chown_uid_set = false;
|
||||||
|
c->chown_gid_set = true;
|
||||||
|
EXPECT_TRUE(identity_explicit_ownership_requested(c));
|
||||||
|
c->chown_gid_set = false;
|
||||||
|
c->copy_as_set = true;
|
||||||
|
EXPECT_TRUE(identity_explicit_ownership_requested(c));
|
||||||
|
c->copy_as_set = false;
|
||||||
|
c->fake_super = true;
|
||||||
|
EXPECT_TRUE(identity_explicit_ownership_requested(c));
|
||||||
|
c->fake_super = false;
|
||||||
|
c->super_mode = SUPER_MODE_ON;
|
||||||
|
EXPECT_TRUE(identity_explicit_ownership_requested(c));
|
||||||
|
c->super_mode = SUPER_MODE_AUTO;
|
||||||
|
EXPECT_EQ_INT(identity_parse_map(c, "@1:@2", false), 0);
|
||||||
|
EXPECT_TRUE(identity_explicit_ownership_requested(c));
|
||||||
|
config_delete(c);
|
||||||
|
}
|
||||||
|
|
||||||
/* P7 Wave E hardening (A3): --super no longer implies raw numeric-id
|
/* P7 Wave E hardening (A3): --super no longer implies raw numeric-id
|
||||||
preservation, so it must never enable ownership application on its own; an
|
preservation, so it must never enable ownership application on its own; an
|
||||||
explicit identity flag is required. */
|
explicit identity flag is required. */
|
||||||
@@ -2073,6 +2117,32 @@ static void test_super_does_not_imply_numeric() {
|
|||||||
config_delete(c);
|
config_delete(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The preserve-source -o/-g requests enable ownership application through the
|
||||||
|
* active snapshot (identity_active_enabled) even though they are deliberately
|
||||||
|
* absent from the narrow client-chosen identity_explicit_ownership_requested()
|
||||||
|
* gate. */
|
||||||
|
static void test_identity_active_enabled_includes_preserve_attrs() {
|
||||||
|
Config* c = config_create();
|
||||||
|
EXPECT_NOT_NULL(c);
|
||||||
|
c->use_metadata = true;
|
||||||
|
EXPECT_TRUE(identity_set_active(c));
|
||||||
|
EXPECT_FALSE(identity_active_enabled());
|
||||||
|
|
||||||
|
c->preserve_owner = true;
|
||||||
|
EXPECT_TRUE(identity_set_active(c));
|
||||||
|
EXPECT_TRUE(identity_active_enabled());
|
||||||
|
EXPECT_FALSE(identity_explicit_ownership_requested(c));
|
||||||
|
|
||||||
|
c->preserve_owner = false;
|
||||||
|
c->preserve_group = true;
|
||||||
|
EXPECT_TRUE(identity_set_active(c));
|
||||||
|
EXPECT_TRUE(identity_active_enabled());
|
||||||
|
EXPECT_FALSE(identity_explicit_ownership_requested(c));
|
||||||
|
|
||||||
|
identity_clear_active();
|
||||||
|
config_delete(c);
|
||||||
|
}
|
||||||
|
|
||||||
/* The single shared predicate must reject every cross-field combination the
|
/* The single shared predicate must reject every cross-field combination the
|
||||||
client/server enforce and accept a plain valid config. Because both
|
client/server enforce and accept a plain valid config. Because both
|
||||||
validate_config() (client) and validate_received_config() (server) call it,
|
validate_config() (client) and validate_received_config() (server) call it,
|
||||||
@@ -2193,6 +2263,95 @@ static void test_config_invariants_error_all_combinations() {
|
|||||||
config_delete(c);
|
config_delete(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Every per-attribute preservation flag requires the metadata frame to travel:
|
||||||
|
* the invariant rejects any of them while use_metadata is false, and setting
|
||||||
|
* use_metadata clears the violation. */
|
||||||
|
static void test_config_preservation_requires_metadata() {
|
||||||
|
static const size_t attrs[] = {
|
||||||
|
offsetof(Config, preserve_perms), offsetof(Config, preserve_times),
|
||||||
|
offsetof(Config, preserve_owner), offsetof(Config, preserve_group),
|
||||||
|
offsetof(Config, preserve_atimes), offsetof(Config, preserve_crtimes),
|
||||||
|
offsetof(Config, use_executability),
|
||||||
|
};
|
||||||
|
Config* c = config_create();
|
||||||
|
EXPECT_NOT_NULL(c);
|
||||||
|
EXPECT_NULL(config_invariants_error(c));
|
||||||
|
for (size_t i = 0; i < sizeof(attrs) / sizeof(attrs[0]); i++) {
|
||||||
|
bool* field = (bool*)((char*)c + attrs[i]);
|
||||||
|
*field = true;
|
||||||
|
EXPECT_NOT_NULL(config_invariants_error(c));
|
||||||
|
c->use_metadata = true;
|
||||||
|
EXPECT_NULL(config_invariants_error(c));
|
||||||
|
c->use_metadata = false;
|
||||||
|
*field = false;
|
||||||
|
}
|
||||||
|
config_delete(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* config_derived_use_metadata is the single source of truth for the derived
|
||||||
|
* transport bit: each representative flag turns it on, and it stays off for a
|
||||||
|
* bare config (numeric_ids alone, omit flags, whole-file, ...). */
|
||||||
|
static void test_config_derived_use_metadata() {
|
||||||
|
static const size_t true_flags[] = {
|
||||||
|
offsetof(Config, preserve_perms), offsetof(Config, preserve_times),
|
||||||
|
offsetof(Config, preserve_owner), offsetof(Config, preserve_group),
|
||||||
|
offsetof(Config, preserve_atimes), offsetof(Config, preserve_crtimes),
|
||||||
|
offsetof(Config, use_executability), offsetof(Config, preserve_xattrs),
|
||||||
|
offsetof(Config, preserve_acls), offsetof(Config, fake_super),
|
||||||
|
offsetof(Config, preserve_devices), offsetof(Config, preserve_specials),
|
||||||
|
offsetof(Config, copy_devices), offsetof(Config, write_devices),
|
||||||
|
offsetof(Config, copy_as_set), offsetof(Config, chown_uid_set),
|
||||||
|
offsetof(Config, chown_gid_set), offsetof(Config, update),
|
||||||
|
};
|
||||||
|
Config* c = config_create();
|
||||||
|
EXPECT_NOT_NULL(c);
|
||||||
|
EXPECT_FALSE(config_derived_use_metadata(c));
|
||||||
|
EXPECT_FALSE(config_derived_use_metadata(NULL));
|
||||||
|
for (size_t i = 0; i < sizeof(true_flags) / sizeof(true_flags[0]); i++) {
|
||||||
|
bool* field = (bool*)((char*)c + true_flags[i]);
|
||||||
|
*field = true;
|
||||||
|
EXPECT_TRUE(config_derived_use_metadata(c));
|
||||||
|
*field = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A non-empty --chmod spec. */
|
||||||
|
c->chmod_spec = str_dup("u=rw");
|
||||||
|
EXPECT_TRUE(config_derived_use_metadata(c));
|
||||||
|
free(c->chmod_spec);
|
||||||
|
c->chmod_spec = NULL;
|
||||||
|
|
||||||
|
/* Identity-map counts. */
|
||||||
|
c->usermap_count = 1;
|
||||||
|
EXPECT_TRUE(config_derived_use_metadata(c));
|
||||||
|
c->usermap_count = 0;
|
||||||
|
c->groupmap_count = 1;
|
||||||
|
EXPECT_TRUE(config_derived_use_metadata(c));
|
||||||
|
c->groupmap_count = 0;
|
||||||
|
|
||||||
|
/* Incremental/delta imply metadata unless --no-preserve disabled it. */
|
||||||
|
c->use_incremental = true;
|
||||||
|
EXPECT_TRUE(config_derived_use_metadata(c));
|
||||||
|
c->metadata_explicitly_disabled = true;
|
||||||
|
EXPECT_FALSE(config_derived_use_metadata(c));
|
||||||
|
c->metadata_explicitly_disabled = false;
|
||||||
|
c->use_incremental = false;
|
||||||
|
c->use_delta = true;
|
||||||
|
EXPECT_TRUE(config_derived_use_metadata(c));
|
||||||
|
c->metadata_explicitly_disabled = true;
|
||||||
|
EXPECT_FALSE(config_derived_use_metadata(c));
|
||||||
|
c->metadata_explicitly_disabled = false;
|
||||||
|
c->use_delta = false;
|
||||||
|
|
||||||
|
/* Flags that must NOT imply metadata on their own. */
|
||||||
|
c->numeric_ids = true;
|
||||||
|
c->omit_dir_times = true;
|
||||||
|
c->omit_link_times = true;
|
||||||
|
c->whole_file = true;
|
||||||
|
c->ignore_times = true;
|
||||||
|
EXPECT_FALSE(config_derived_use_metadata(c));
|
||||||
|
config_delete(c);
|
||||||
|
}
|
||||||
|
|
||||||
/* The receiver previously missed several of these; a forged frame that sets
|
/* The receiver previously missed several of these; a forged frame that sets
|
||||||
the offending serialized fields must now be refused at the config
|
the offending serialized fields must now be refused at the config
|
||||||
handshake. (whole_file is client-only, so its rules cannot appear here.) */
|
handshake. (whole_file is client-only, so its rules cannot appear here.) */
|
||||||
@@ -2355,6 +2514,34 @@ static void test_config_wire_roundtrip_all_fields() {
|
|||||||
config_delete(populated);
|
config_delete(populated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Each of the four split-out preservation bools must survive a frame
|
||||||
|
* round-trip on its own. The all-fields golden sets an alternating
|
||||||
|
* true/false pattern precisely because a run of identical adjacent booleans
|
||||||
|
* would let a same-KIND field swap produce the same bytes; isolating one true
|
||||||
|
* bit at a time pins each new field's position and width independently. */
|
||||||
|
static void test_config_preserve_attribute_wire_roundtrip() {
|
||||||
|
if (is_running_under_valgrind())
|
||||||
|
return;
|
||||||
|
static const size_t attrs[] = {
|
||||||
|
offsetof(Config, preserve_perms),
|
||||||
|
offsetof(Config, preserve_times),
|
||||||
|
offsetof(Config, preserve_owner),
|
||||||
|
offsetof(Config, preserve_group),
|
||||||
|
};
|
||||||
|
for (size_t i = 0; i < sizeof(attrs) / sizeof(attrs[0]); i++) {
|
||||||
|
Config* c = config_create();
|
||||||
|
EXPECT_NOT_NULL(c);
|
||||||
|
c->send_directory = str_dup("/src");
|
||||||
|
c->receive_root_directory = str_dup("/dst");
|
||||||
|
/* The preservation invariant requires the metadata frame to travel, so set
|
||||||
|
* the transport bit; otherwise config_receive() legitimately refuses. */
|
||||||
|
c->use_metadata = true;
|
||||||
|
*(bool*)((char*)c + attrs[i]) = true;
|
||||||
|
EXPECT_TRUE(roundtrip_and_compare(c));
|
||||||
|
config_delete(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Populate every serialized field with a non-default value so the wire frame
|
/* Populate every serialized field with a non-default value so the wire frame
|
||||||
* exercises each table entry. Boolean runs deliberately alternate true/false:
|
* exercises each table entry. Boolean runs deliberately alternate true/false:
|
||||||
* a run of identical booleans would make an adjacent swap (same KIND) produce
|
* a run of identical booleans would make an adjacent swap (same KIND) produce
|
||||||
@@ -2427,7 +2614,7 @@ static void golden_config_populate(Config* c) {
|
|||||||
c->modify_window = 3;
|
c->modify_window = 3;
|
||||||
c->compress_choice = str_dup("zstd");
|
c->compress_choice = str_dup("zstd");
|
||||||
/* "u=rwx,go=rx" is the same 11 bytes as the original "u=rwX,go=rX" (so the
|
/* "u=rwx,go=rx" is the same 11 bytes as the original "u=rwX,go=rX" (so the
|
||||||
* frame stays 637 bytes) but X is not in FastSync's chmod grammar, and the
|
* frame stays 653 bytes) but X is not in FastSync's chmod grammar, and the
|
||||||
* receive-side golden validates the frame. */
|
* receive-side golden validates the frame. */
|
||||||
c->chmod_spec = str_dup("u=rwx,go=rx");
|
c->chmod_spec = str_dup("u=rwx,go=rx");
|
||||||
c->skip_compress_set = true;
|
c->skip_compress_set = true;
|
||||||
@@ -2459,6 +2646,12 @@ static void golden_config_populate(Config* c) {
|
|||||||
c->preserve_crtimes = false;
|
c->preserve_crtimes = false;
|
||||||
c->omit_dir_times = true;
|
c->omit_dir_times = true;
|
||||||
c->omit_link_times = false;
|
c->omit_link_times = false;
|
||||||
|
/* Mixed true/false so a field reorder or a dropped attribute changes the
|
||||||
|
* pinned hash rather than passing silently. */
|
||||||
|
c->preserve_perms = true;
|
||||||
|
c->preserve_times = false;
|
||||||
|
c->preserve_owner = true;
|
||||||
|
c->preserve_group = false;
|
||||||
c->munge_links = true;
|
c->munge_links = true;
|
||||||
c->keep_dirlinks = false;
|
c->keep_dirlinks = false;
|
||||||
c->fake_super = true;
|
c->fake_super = true;
|
||||||
@@ -2472,13 +2665,14 @@ static void golden_config_populate(Config* c) {
|
|||||||
c->copy_as_gid = 222;
|
c->copy_as_gid = 222;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The pinned golden frame (protocol 2.21.0). The values below are the only
|
/* The pinned golden frame (protocol 2.22.0). The values below are the only
|
||||||
* thing that ties the generated table to the historical wire format; update
|
* thing that ties the generated table to the historical wire format; update
|
||||||
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. The combined
|
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. The 2.22.0
|
||||||
* 2.21.0 wave appends the serialized dry_run bool to CONFIG_WIRE_CORE_FIELDS
|
* preserve-attribute split appends four serialized bools
|
||||||
* and keeps the protocol version string at 2.21.0. */
|
* (preserve_perms/times/owner/group) to CONFIG_WIRE_METADATA_TIMES_FIELDS after
|
||||||
#define GOLDEN_WIRE_LEN 637
|
* omit_link_times. */
|
||||||
#define GOLDEN_WIRE_HASH 13228626061067899189ULL
|
#define GOLDEN_WIRE_LEN 653
|
||||||
|
#define GOLDEN_WIRE_HASH 95530566005420798ULL
|
||||||
|
|
||||||
static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) {
|
static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) {
|
||||||
unsigned long long h = 1469598103934665603ULL;
|
unsigned long long h = 1469598103934665603ULL;
|
||||||
@@ -2560,7 +2754,7 @@ static unsigned long long capture_wire_hash(const Config* cfg, size_t* out_len)
|
|||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Byte-for-byte wire compatibility guard (protocol 2.21.0). The expected hash
|
/* Byte-for-byte wire compatibility guard (protocol 2.22.0). The expected hash
|
||||||
* pins the pre-X-macro byte stream; the refactor MUST NOT change it. */
|
* pins the pre-X-macro byte stream; the refactor MUST NOT change it. */
|
||||||
static void test_config_wire_golden() {
|
static void test_config_wire_golden() {
|
||||||
if (is_running_under_valgrind())
|
if (is_running_under_valgrind())
|
||||||
@@ -2613,6 +2807,9 @@ static void test_config_wire_golden_receive() {
|
|||||||
!recv->use_multithreading;
|
!recv->use_multithreading;
|
||||||
ok = ok && recv->compression_level == 7 && recv->chunk_size == 65536;
|
ok = ok && recv->compression_level == 7 && recv->chunk_size == 65536;
|
||||||
ok = ok && recv->use_delta && !recv->whole_file && recv->use_xattrs;
|
ok = ok && recv->use_delta && !recv->whole_file && recv->use_xattrs;
|
||||||
|
/* Per-attribute preservation split decoded from the pinned bytes. */
|
||||||
|
ok = ok && recv->preserve_perms && !recv->preserve_times && recv->preserve_owner &&
|
||||||
|
!recv->preserve_group;
|
||||||
/* Bounded/validated KINDs decoded from the pinned bytes. */
|
/* Bounded/validated KINDs decoded from the pinned bytes. */
|
||||||
ok = ok && recv->checksum_algo == CHECKSUM_ALGO_MD5;
|
ok = ok && recv->checksum_algo == CHECKSUM_ALGO_MD5;
|
||||||
ok = ok && recv->super_mode == SUPER_MODE_ON;
|
ok = ok && recv->super_mode == SUPER_MODE_ON;
|
||||||
@@ -2867,15 +3064,20 @@ void test_config() {
|
|||||||
test_config_receive_rejects_oversized_string_budget();
|
test_config_receive_rejects_oversized_string_budget();
|
||||||
test_config_receive_with_validate_rejects();
|
test_config_receive_with_validate_rejects();
|
||||||
test_config_invariants_error_all_combinations();
|
test_config_invariants_error_all_combinations();
|
||||||
|
test_config_preservation_requires_metadata();
|
||||||
|
test_config_derived_use_metadata();
|
||||||
test_config_receive_rejects_unified_invariants();
|
test_config_receive_rejects_unified_invariants();
|
||||||
test_config_wire_golden();
|
test_config_wire_golden();
|
||||||
test_config_wire_golden_receive();
|
test_config_wire_golden_receive();
|
||||||
test_config_wire_receive_bounds();
|
test_config_wire_receive_bounds();
|
||||||
test_config_receive_rejects_overcap_counts();
|
test_config_receive_rejects_overcap_counts();
|
||||||
test_config_wire_roundtrip_all_fields();
|
test_config_wire_roundtrip_all_fields();
|
||||||
|
test_config_preserve_attribute_wire_roundtrip();
|
||||||
}
|
}
|
||||||
test_identity_copy_as_refused();
|
test_identity_copy_as_refused();
|
||||||
test_identity_ownership_requested();
|
test_identity_ownership_requested();
|
||||||
|
test_identity_explicit_ownership_requested();
|
||||||
|
test_identity_active_enabled_includes_preserve_attrs();
|
||||||
test_super_does_not_imply_numeric();
|
test_super_does_not_imply_numeric();
|
||||||
test_privilege_super_permitted_modes();
|
test_privilege_super_permitted_modes();
|
||||||
test_config_delete_timing_early_helper();
|
test_config_delete_timing_early_helper();
|
||||||
|
|||||||
+194
-11
@@ -410,7 +410,7 @@ static void test_file_write_to_disk_with_fsync() {
|
|||||||
const char* path = "test_file_write_to_disk_fsync.txt";
|
const char* path = "test_file_write_to_disk_fsync.txt";
|
||||||
const char* content = "fsync file content";
|
const char* content = "fsync file content";
|
||||||
EXPECT_TRUE(file_to_disk_secure_with_fsync(path, content, strlen(content), false, false, false,
|
EXPECT_TRUE(file_to_disk_secure_with_fsync(path, content, strlen(content), false, false, false,
|
||||||
NULL, false, true, NULL));
|
NULL, (FileAttrPolicy){0}, true, NULL));
|
||||||
struct stat st;
|
struct stat st;
|
||||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||||
EXPECT_EQ_INT((int)st.st_size, (int)strlen(content));
|
EXPECT_EQ_INT((int)st.st_size, (int)strlen(content));
|
||||||
@@ -420,8 +420,8 @@ static void test_file_write_to_disk_with_fsync() {
|
|||||||
static void test_file_write_to_disk_preallocate_atomic() {
|
static void test_file_write_to_disk_preallocate_atomic() {
|
||||||
const char* path = "test_file_write_prealloc_atomic.txt";
|
const char* path = "test_file_write_prealloc_atomic.txt";
|
||||||
const char* content = "prealloc atomic content";
|
const char* content = "prealloc atomic content";
|
||||||
EXPECT_TRUE(
|
EXPECT_TRUE(file_to_disk_secure(path, content, strlen(content), false, false, true, NULL,
|
||||||
file_to_disk_secure(path, content, strlen(content), false, false, true, NULL, false, NULL));
|
(FileAttrPolicy){0}, NULL));
|
||||||
struct stat st;
|
struct stat st;
|
||||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||||
EXPECT_EQ_INT((int)st.st_size, (int)strlen(content));
|
EXPECT_EQ_INT((int)st.st_size, (int)strlen(content));
|
||||||
@@ -438,8 +438,8 @@ static void test_file_write_to_disk_preallocate_atomic() {
|
|||||||
static void test_file_write_to_disk_preallocate_inplace() {
|
static void test_file_write_to_disk_preallocate_inplace() {
|
||||||
const char* path = "test_file_write_prealloc_inplace.txt";
|
const char* path = "test_file_write_prealloc_inplace.txt";
|
||||||
const char* content = "prealloc inplace content";
|
const char* content = "prealloc inplace content";
|
||||||
EXPECT_TRUE(
|
EXPECT_TRUE(file_to_disk_secure(path, content, strlen(content), true, false, true, NULL,
|
||||||
file_to_disk_secure(path, content, strlen(content), true, false, true, NULL, false, NULL));
|
(FileAttrPolicy){0}, NULL));
|
||||||
struct stat st;
|
struct stat st;
|
||||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||||
EXPECT_EQ_INT((int)st.st_size, (int)strlen(content));
|
EXPECT_EQ_INT((int)st.st_size, (int)strlen(content));
|
||||||
@@ -951,6 +951,178 @@ static void test_inplace_overwrite_metadata_strips_special_bits() {
|
|||||||
rmdir(root);
|
rmdir(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The per-attribute split: with no -p/-E the atomic (inode-replacing) write
|
||||||
|
* must restore the PRE-EXISTING destination mode instead of the source mode; a
|
||||||
|
* brand-new file keeps the historical 0644 default; -p applies the source. */
|
||||||
|
static void test_atomic_no_perms_preserves_destination_mode() {
|
||||||
|
const char* path = "test_attr_split_mode.txt";
|
||||||
|
unlink(path);
|
||||||
|
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0640);
|
||||||
|
EXPECT_TRUE(fd >= 0);
|
||||||
|
/* cppcheck-suppress knownConditionTrueFalse */
|
||||||
|
if (fd < 0)
|
||||||
|
return;
|
||||||
|
EXPECT_EQ_INT(fchmod(fd, 0640), 0);
|
||||||
|
EXPECT_EQ_INT(close(fd), 0);
|
||||||
|
|
||||||
|
FileMetadata m;
|
||||||
|
memset(&m, 0, sizeof(m));
|
||||||
|
m.mode = 0755;
|
||||||
|
m.uid = geteuid();
|
||||||
|
m.gid = getegid();
|
||||||
|
m.mtime_sec = 1700000000;
|
||||||
|
|
||||||
|
/* No -p/-E: the pre-existing 0640 survives the atomic overwrite. */
|
||||||
|
bool ok = file_to_disk_secure_attrs(path, "data", 4, false, false, false, &m,
|
||||||
|
(FileAttrPolicy){false, false, false, false}, false, false,
|
||||||
|
false, NULL, false, false, NULL);
|
||||||
|
EXPECT_TRUE(ok);
|
||||||
|
struct stat st;
|
||||||
|
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||||
|
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0640);
|
||||||
|
|
||||||
|
/* -p: the source mode wins. */
|
||||||
|
ok = file_to_disk_secure_attrs(path, "data2", 5, false, false, false, &m,
|
||||||
|
(FileAttrPolicy){true, true, false, false}, false, false, false,
|
||||||
|
NULL, false, false, NULL);
|
||||||
|
EXPECT_TRUE(ok);
|
||||||
|
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||||
|
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0755);
|
||||||
|
|
||||||
|
/* -E only (rsync rule): an executable source derives exec from the
|
||||||
|
pre-existing destination's read bits. Dest 0640 (owner+group read) with a
|
||||||
|
source 0755 gives 0750, not 0751 and not the scratch 0711. */
|
||||||
|
EXPECT_EQ_INT(chmod(path, 0640), 0);
|
||||||
|
ok = file_to_disk_secure_attrs(path, "data3", 6, false, false, false, &m,
|
||||||
|
(FileAttrPolicy){false, false, false, true}, false, false, false,
|
||||||
|
NULL, false, false, NULL);
|
||||||
|
EXPECT_TRUE(ok);
|
||||||
|
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||||
|
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0750);
|
||||||
|
|
||||||
|
unlink(path);
|
||||||
|
|
||||||
|
/* A brand-new file with no -p uses rsync's source&~umask base when metadata
|
||||||
|
is available (m.mode is 0755 here). */
|
||||||
|
const char* fresh = "test_attr_split_fresh.txt";
|
||||||
|
unlink(fresh);
|
||||||
|
ok = file_to_disk_secure_attrs(fresh, "data", 4, false, false, false, &m,
|
||||||
|
(FileAttrPolicy){false, false, false, false}, false, false, false,
|
||||||
|
NULL, false, false, NULL);
|
||||||
|
EXPECT_TRUE(ok);
|
||||||
|
EXPECT_EQ_INT(stat(fresh, &st), 0);
|
||||||
|
EXPECT_EQ_INT((int)(st.st_mode & 0777), (int)(m.mode & 0777 & ~(mode_t)file_process_umask()));
|
||||||
|
unlink(fresh);
|
||||||
|
|
||||||
|
/* Without any metadata the historical fixed 0644 default still applies. */
|
||||||
|
unlink(fresh);
|
||||||
|
ok = file_to_disk_secure_attrs(fresh, "data", 4, false, false, false, NULL,
|
||||||
|
(FileAttrPolicy){false, false, false, false}, false, false, false,
|
||||||
|
NULL, false, false, NULL);
|
||||||
|
EXPECT_TRUE(ok);
|
||||||
|
EXPECT_EQ_INT(stat(fresh, &st), 0);
|
||||||
|
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0644);
|
||||||
|
unlink(fresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MAJOR 2: a brand-new destination file must never be created group/other
|
||||||
|
* writable from a client-supplied source mode. The daemon runs with umask(0),
|
||||||
|
* so without the explicit S_IWGRP|S_IWOTH strip a source 0666 (with no -p)
|
||||||
|
* would materialize as world-writable. */
|
||||||
|
static void test_new_file_mode_never_group_other_writable() {
|
||||||
|
const char* path = "test_new_file_no_go_write.bin";
|
||||||
|
unlink(path);
|
||||||
|
FileMetadata m;
|
||||||
|
memset(&m, 0, sizeof(m));
|
||||||
|
m.mode = 0666; /* maximal group/other write in the source mode */
|
||||||
|
m.uid = geteuid();
|
||||||
|
m.gid = getegid();
|
||||||
|
|
||||||
|
bool ok = file_to_disk_secure_attrs(path, "x", 1, false, false, false, &m,
|
||||||
|
(FileAttrPolicy){false, false, false, false}, false, false,
|
||||||
|
false, NULL, false, false, NULL);
|
||||||
|
EXPECT_TRUE(ok);
|
||||||
|
struct stat st;
|
||||||
|
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||||
|
EXPECT_EQ_INT((int)(st.st_mode & (S_IWGRP | S_IWOTH)), 0);
|
||||||
|
/* The rest of the source mode is still honored (owner write survives). */
|
||||||
|
EXPECT_EQ_INT((int)(st.st_mode & S_IWUSR), S_IWUSR);
|
||||||
|
unlink(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Security: a client-supplied special-node mode must never materialize a
|
||||||
|
* group/other-writable FIFO. file_save_special_to_disk() sanitizes the
|
||||||
|
* creation bits the same way the regular-file policy does: under -p the source
|
||||||
|
* mode loses S_IWGRP|S_IWOTH (0777 -> 0755), and without -p a safe 0644 default
|
||||||
|
* is used. The daemon runs with umask(0) (server.c), so the explicit strip is
|
||||||
|
* what keeps the node safe -- the test clears the umask to prove it. */
|
||||||
|
static void test_special_fifo_mode_never_group_other_writable_impl() {
|
||||||
|
const char* root = "test_special_mode_tmp";
|
||||||
|
const char* with_p = "test_special_mode_tmp/with_p.fifo";
|
||||||
|
const char* no_p = "test_special_mode_tmp/no_p.fifo";
|
||||||
|
unlink(with_p);
|
||||||
|
unlink(no_p);
|
||||||
|
rmdir(root);
|
||||||
|
EXPECT_EQ_INT(mkdir(root, 0700), 0);
|
||||||
|
|
||||||
|
Config* cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
|
||||||
|
FileMetadata meta;
|
||||||
|
memset(&meta, 0, sizeof(meta));
|
||||||
|
meta.mode = S_IFIFO | 0777;
|
||||||
|
meta.uid = geteuid();
|
||||||
|
meta.gid = getegid();
|
||||||
|
meta.mtime_sec = 1000000000;
|
||||||
|
|
||||||
|
/* -p: the source mode is honored minus group/other write. */
|
||||||
|
File* f = file_create("with_p.fifo");
|
||||||
|
EXPECT_NOT_NULL(f);
|
||||||
|
f->is_special = true;
|
||||||
|
f->metadata = &meta;
|
||||||
|
cfg->preserve_specials = true;
|
||||||
|
cfg->preserve_perms = true;
|
||||||
|
cfg->use_metadata = true;
|
||||||
|
EXPECT_EQ_INT(file_save_to_disk_full(root, f, cfg), FILE_SAVE_WRITTEN);
|
||||||
|
struct stat st;
|
||||||
|
EXPECT_EQ_INT(lstat(with_p, &st), 0);
|
||||||
|
EXPECT_TRUE(S_ISFIFO(st.st_mode));
|
||||||
|
EXPECT_EQ_INT((int)(st.st_mode & (S_IWGRP | S_IWOTH)), 0);
|
||||||
|
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0755);
|
||||||
|
f->metadata = NULL;
|
||||||
|
file_destroy(f);
|
||||||
|
|
||||||
|
/* No -p: the fixed safe default, never the source's 0777. */
|
||||||
|
f = file_create("no_p.fifo");
|
||||||
|
EXPECT_NOT_NULL(f);
|
||||||
|
f->is_special = true;
|
||||||
|
f->metadata = &meta;
|
||||||
|
cfg->preserve_perms = false;
|
||||||
|
EXPECT_EQ_INT(file_save_to_disk_full(root, f, cfg), FILE_SAVE_WRITTEN);
|
||||||
|
EXPECT_EQ_INT(lstat(no_p, &st), 0);
|
||||||
|
EXPECT_TRUE(S_ISFIFO(st.st_mode));
|
||||||
|
EXPECT_EQ_INT((int)(st.st_mode & (S_IWGRP | S_IWOTH)), 0);
|
||||||
|
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0644);
|
||||||
|
f->metadata = NULL;
|
||||||
|
file_destroy(f);
|
||||||
|
|
||||||
|
config_delete(cfg);
|
||||||
|
unlink(with_p);
|
||||||
|
unlink(no_p);
|
||||||
|
rmdir(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The receiver daemon runs umask(0), so an unsanitized source mode would reach
|
||||||
|
* mkfifo unmasked. Run the body with umask(0) to exercise the explicit strip,
|
||||||
|
* and restore the process umask from this wrapper so a failing EXPECT inside the
|
||||||
|
* body (which returns from the body only) cannot leak umask(0) into later
|
||||||
|
* tests. */
|
||||||
|
static void test_special_fifo_mode_never_group_other_writable() {
|
||||||
|
mode_t saved_umask = umask(0);
|
||||||
|
test_special_fifo_mode_never_group_other_writable_impl();
|
||||||
|
umask(saved_umask);
|
||||||
|
}
|
||||||
|
|
||||||
static void test_inplace_overwrite_truncates_shorter_payload() {
|
static void test_inplace_overwrite_truncates_shorter_payload() {
|
||||||
const char* root = "test_inplace_trunc_tmp";
|
const char* root = "test_inplace_trunc_tmp";
|
||||||
const char* path = "test_inplace_trunc_tmp/big.txt";
|
const char* path = "test_inplace_trunc_tmp/big.txt";
|
||||||
@@ -1335,7 +1507,8 @@ static void test_file_write_to_disk_sparse_preserves_holes() {
|
|||||||
buf[size - 1 - i] = (unsigned char)((i * 7) % 253);
|
buf[size - 1 - i] = (unsigned char)((i * 7) % 253);
|
||||||
}
|
}
|
||||||
|
|
||||||
EXPECT_TRUE(file_to_disk_secure(path, buf, size, false, true, false, NULL, false, NULL));
|
EXPECT_TRUE(
|
||||||
|
file_to_disk_secure(path, buf, size, false, true, false, NULL, (FileAttrPolicy){0}, NULL));
|
||||||
|
|
||||||
/* Logical size must equal data_size exactly. */
|
/* Logical size must equal data_size exactly. */
|
||||||
struct stat st;
|
struct stat st;
|
||||||
@@ -1400,8 +1573,9 @@ static void test_file_write_to_disk_partial_retention() {
|
|||||||
m.mtime_nsec = 2000000000; /* invalid: forces futimens EINVAL after the write */
|
m.mtime_nsec = 2000000000; /* invalid: forces futimens EINVAL after the write */
|
||||||
m.atime_valid = false;
|
m.atime_valid = false;
|
||||||
m.crtime_valid = false;
|
m.crtime_valid = false;
|
||||||
bool ok = file_to_disk_secure_attrs(path, content, strlen(content), false, false, true, &m, false,
|
bool ok = file_to_disk_secure_attrs(path, content, strlen(content), false, false, true, &m,
|
||||||
false, false, false, NULL, false, true, NULL);
|
(FileAttrPolicy){true, true, false, false}, false, false,
|
||||||
|
false, NULL, false, true, NULL);
|
||||||
EXPECT_FALSE(ok); /* the write itself succeeded, but metadata restore failed */
|
EXPECT_FALSE(ok); /* the write itself succeeded, but metadata restore failed */
|
||||||
/* Retained: the already-written temp now sits at the destination path. */
|
/* Retained: the already-written temp now sits at the destination path. */
|
||||||
int fd = open(path, O_RDONLY);
|
int fd = open(path, O_RDONLY);
|
||||||
@@ -1419,8 +1593,9 @@ static void test_file_write_to_disk_partial_retention() {
|
|||||||
unlink(path);
|
unlink(path);
|
||||||
|
|
||||||
/* Same failure with keep_partial=false: temp is unlinked, nothing retained. */
|
/* Same failure with keep_partial=false: temp is unlinked, nothing retained. */
|
||||||
ok = file_to_disk_secure_attrs(path, content, strlen(content), false, false, true, &m, false,
|
ok = file_to_disk_secure_attrs(path, content, strlen(content), false, false, true, &m,
|
||||||
false, false, false, NULL, false, false, NULL);
|
(FileAttrPolicy){true, true, false, false}, false, false, false,
|
||||||
|
NULL, false, false, NULL);
|
||||||
EXPECT_FALSE(ok);
|
EXPECT_FALSE(ok);
|
||||||
EXPECT_TRUE(access(path, F_OK) == -1);
|
EXPECT_TRUE(access(path, F_OK) == -1);
|
||||||
}
|
}
|
||||||
@@ -1444,10 +1619,15 @@ static void test_dir_time_list() {
|
|||||||
EXPECT_TRUE(dir_time_list_add(&list, "sub", &metadata));
|
EXPECT_TRUE(dir_time_list_add(&list, "sub", &metadata));
|
||||||
EXPECT_EQ_INT((int)list.count, 2);
|
EXPECT_EQ_INT((int)list.count, 2);
|
||||||
|
|
||||||
dir_time_list_apply(&list, root);
|
Config* cfg = config_create();
|
||||||
|
EXPECT_NOT_NULL(cfg);
|
||||||
|
cfg->use_metadata = true;
|
||||||
|
cfg->preserve_times = true;
|
||||||
|
dir_metadata_list_apply(&list, root, cfg);
|
||||||
struct stat st;
|
struct stat st;
|
||||||
EXPECT_EQ_INT(stat(sub, &st), 0);
|
EXPECT_EQ_INT(stat(sub, &st), 0);
|
||||||
EXPECT_EQ_INT((int)st.st_mtime, 1000000000);
|
EXPECT_EQ_INT((int)st.st_mtime, 1000000000);
|
||||||
|
config_delete(cfg);
|
||||||
|
|
||||||
dir_time_list_free(&list);
|
dir_time_list_free(&list);
|
||||||
EXPECT_EQ_INT((int)list.count, 0);
|
EXPECT_EQ_INT((int)list.count, 0);
|
||||||
@@ -1694,6 +1874,9 @@ void test_file() {
|
|||||||
test_keep_dirlinks_secure_open();
|
test_keep_dirlinks_secure_open();
|
||||||
test_inplace_overwrite_clears_special_mode_bits();
|
test_inplace_overwrite_clears_special_mode_bits();
|
||||||
test_inplace_overwrite_metadata_strips_special_bits();
|
test_inplace_overwrite_metadata_strips_special_bits();
|
||||||
|
test_atomic_no_perms_preserves_destination_mode();
|
||||||
|
test_new_file_mode_never_group_other_writable();
|
||||||
|
test_special_fifo_mode_never_group_other_writable();
|
||||||
test_inplace_overwrite_truncates_shorter_payload();
|
test_inplace_overwrite_truncates_shorter_payload();
|
||||||
test_inplace_refuses_fifo_destination();
|
test_inplace_refuses_fifo_destination();
|
||||||
test_inplace_refuses_device_destination();
|
test_inplace_refuses_device_destination();
|
||||||
|
|||||||
+237
-7
@@ -1,8 +1,10 @@
|
|||||||
#include "test_metadata.h"
|
#include "test_metadata.h"
|
||||||
#include "chmod.h"
|
#include "chmod.h"
|
||||||
|
#include "identity.h"
|
||||||
#include "metadata.h"
|
#include "metadata.h"
|
||||||
#include "protocol.h"
|
#include "protocol.h"
|
||||||
#include "test_utils.h"
|
#include "test_utils.h"
|
||||||
|
#include <fcntl.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <sys/ioctl.h>
|
#include <sys/ioctl.h>
|
||||||
@@ -337,7 +339,7 @@ static void test_file_restore_metadata_applies_atime() {
|
|||||||
m.crtime_sec = 0;
|
m.crtime_sec = 0;
|
||||||
m.crtime_nsec = 0;
|
m.crtime_nsec = 0;
|
||||||
|
|
||||||
file_restore_metadata(path, &m, false);
|
file_restore_metadata(path, &m, (FileAttrPolicy){true, true, true, false});
|
||||||
|
|
||||||
struct stat st;
|
struct stat st;
|
||||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||||
@@ -376,7 +378,7 @@ static void test_file_restore_metadata() {
|
|||||||
.atime_valid = false,
|
.atime_valid = false,
|
||||||
.crtime_valid = false};
|
.crtime_valid = false};
|
||||||
|
|
||||||
file_restore_metadata(path, &m, false);
|
file_restore_metadata(path, &m, (FileAttrPolicy){true, true, false, false});
|
||||||
|
|
||||||
struct stat st;
|
struct stat st;
|
||||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||||
@@ -393,7 +395,7 @@ static void test_file_restore_executability_only() {
|
|||||||
|
|
||||||
FileMetadata m = {
|
FileMetadata m = {
|
||||||
.mode = 0751, .uid = getuid(), .gid = getgid(), .mtime_sec = 0, .mtime_nsec = 0};
|
.mode = 0751, .uid = getuid(), .gid = getgid(), .mtime_sec = 0, .mtime_nsec = 0};
|
||||||
file_restore_metadata(path, &m, true);
|
file_restore_metadata(path, &m, (FileAttrPolicy){false, false, false, true});
|
||||||
|
|
||||||
struct stat st;
|
struct stat st;
|
||||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||||
@@ -407,14 +409,163 @@ static void test_directory_restore_executability_only() {
|
|||||||
|
|
||||||
FileMetadata m = {
|
FileMetadata m = {
|
||||||
.mode = 0755, .uid = getuid(), .gid = getgid(), .mtime_sec = 0, .mtime_nsec = 0};
|
.mode = 0755, .uid = getuid(), .gid = getgid(), .mtime_sec = 0, .mtime_nsec = 0};
|
||||||
file_restore_metadata(path, &m, true);
|
file_restore_metadata(path, &m, (FileAttrPolicy){false, false, false, true});
|
||||||
|
|
||||||
struct stat st;
|
struct stat st;
|
||||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||||
EXPECT_EQ_INT(st.st_mode & 0777, 0711);
|
/* rsync -E: an executable source derives exec from the DESTINATION's read
|
||||||
|
* bits. A 0700 directory has read only for the owner, so only the owner
|
||||||
|
* gains exec -- the result stays 0700 (not 0711, the old per-class copy). */
|
||||||
|
EXPECT_EQ_INT(st.st_mode & 0777, 0700);
|
||||||
rmdir(path);
|
rmdir(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* rsync 3.4 -E truth table (preserve_perms off), verified against rsync 3.4.1:
|
||||||
|
* (src,dest) -> result. A non-executable source clears every execute bit; an
|
||||||
|
* executable source sets a class's execute bit iff that class can read. */
|
||||||
|
static void test_file_restore_executability_rsync_rule() {
|
||||||
|
static const struct {
|
||||||
|
mode_t src;
|
||||||
|
mode_t dest;
|
||||||
|
mode_t want;
|
||||||
|
} cases[] = {
|
||||||
|
{0755, 0644, 0755}, {0755, 0600, 0700}, {0755, 0640, 0750}, {0755, 0666, 0777},
|
||||||
|
{0700, 0640, 0750}, {0111, 0644, 0755}, {0644, 0755, 0644}, {0644, 0600, 0600},
|
||||||
|
};
|
||||||
|
const char* path = "temp_exec_rsync_rule.txt";
|
||||||
|
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
||||||
|
EXPECT_TRUE(file_write_to_disk(path, "x", 1, false, false));
|
||||||
|
EXPECT_EQ_INT(chmod(path, cases[i].dest), 0);
|
||||||
|
FileMetadata m = {.mode = cases[i].src, .uid = getuid(), .gid = getgid()};
|
||||||
|
file_restore_metadata(path, &m, (FileAttrPolicy){false, false, false, true});
|
||||||
|
struct stat st;
|
||||||
|
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||||
|
EXPECT_EQ_INT(st.st_mode & 0777, cases[i].want);
|
||||||
|
unlink(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The shared metadata_mode_for_policy() helper is the single source of truth
|
||||||
|
* used by both the normal metadata path and the --fake-super replay. It must
|
||||||
|
* reproduce the per-attribute split: no mode change when neither -p nor -E is
|
||||||
|
* set; -p applies the sanitized source mode (group/other write cleared)
|
||||||
|
* regardless of the destination; -E derives exec bits from the destination and
|
||||||
|
* --perms wins when both are set. */
|
||||||
|
static void test_metadata_mode_for_policy() {
|
||||||
|
mode_t out = 0xdead;
|
||||||
|
EXPECT_FALSE(
|
||||||
|
metadata_mode_for_policy(0777, 0644, (FileAttrPolicy){false, false, false, false}, &out));
|
||||||
|
EXPECT_EQ_INT((int)out, 0xdead); /* untouched when no change is requested */
|
||||||
|
|
||||||
|
EXPECT_TRUE(
|
||||||
|
metadata_mode_for_policy(0777, 0644, (FileAttrPolicy){true, false, false, false}, &out));
|
||||||
|
EXPECT_EQ_INT((int)(out & 0777), 0755); /* group/other write always cleared */
|
||||||
|
|
||||||
|
/* -E: exec bits derive from the DESTINATION's read bits. */
|
||||||
|
EXPECT_TRUE(
|
||||||
|
metadata_mode_for_policy(0755, 0644, (FileAttrPolicy){false, false, false, true}, &out));
|
||||||
|
EXPECT_EQ_INT((int)(out & 0777), 0755);
|
||||||
|
EXPECT_TRUE(
|
||||||
|
metadata_mode_for_policy(0644, 0755, (FileAttrPolicy){false, false, false, true}, &out));
|
||||||
|
EXPECT_EQ_INT((int)(out & 0777), 0644);
|
||||||
|
EXPECT_TRUE(
|
||||||
|
metadata_mode_for_policy(0755, 0600, (FileAttrPolicy){false, false, false, true}, &out));
|
||||||
|
EXPECT_EQ_INT((int)(out & 0777), 0700);
|
||||||
|
|
||||||
|
/* --perms wins over -E when both are set. */
|
||||||
|
EXPECT_TRUE(
|
||||||
|
metadata_mode_for_policy(0700, 0644, (FileAttrPolicy){true, false, false, true}, &out));
|
||||||
|
EXPECT_EQ_INT((int)(out & 0777), 0700);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A brand-new file with -p off is created like rsync: source_mode & 0777 &
|
||||||
|
* ~umask (when metadata is available). The -E rule is then layered on top. */
|
||||||
|
static void test_new_file_mode_from_source_and_umask() {
|
||||||
|
const char* path = "temp_new_file_base.txt";
|
||||||
|
unlink(path);
|
||||||
|
FileMetadata m = {.mode = 0751, .uid = getuid(), .gid = getgid()};
|
||||||
|
mode_t want = (mode_t)(0751 & 0777 & ~(mode_t)file_process_umask());
|
||||||
|
|
||||||
|
bool ok = file_to_disk_secure_attrs(path, "x", 1, false, false, false, &m,
|
||||||
|
(FileAttrPolicy){false, false, false, false}, false, false,
|
||||||
|
false, NULL, false, false, NULL);
|
||||||
|
EXPECT_TRUE(ok);
|
||||||
|
struct stat st;
|
||||||
|
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||||
|
EXPECT_EQ_INT(st.st_mode & 0777, want);
|
||||||
|
unlink(path);
|
||||||
|
|
||||||
|
/* -E on top of the source&~umask base (src 0751, umask 022 -> 0751). */
|
||||||
|
ok = file_to_disk_secure_attrs(path, "x", 1, false, false, false, &m,
|
||||||
|
(FileAttrPolicy){false, false, false, true}, false, false, false,
|
||||||
|
NULL, false, false, NULL);
|
||||||
|
EXPECT_TRUE(ok);
|
||||||
|
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||||
|
mode_t want_e =
|
||||||
|
(want & 0444) ? (mode_t)(want | ((want & 0444) >> 2)) : (mode_t)(want & ~(mode_t)0111);
|
||||||
|
EXPECT_EQ_INT(st.st_mode & 0777, want_e);
|
||||||
|
unlink(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The per-attribute split: -t/-U apply times without touching the mode; an
|
||||||
|
* all-off policy applies neither mode nor times. */
|
||||||
|
static void test_file_restore_attribute_split() {
|
||||||
|
const char* path = "temp_meta_split_test.txt";
|
||||||
|
EXPECT_TRUE(file_write_to_disk(path, "x", 1, false, false));
|
||||||
|
EXPECT_EQ_INT(chmod(path, 0640), 0);
|
||||||
|
|
||||||
|
FileMetadata m = {.mode = 0755,
|
||||||
|
.uid = getuid(),
|
||||||
|
.gid = getgid(),
|
||||||
|
.mtime_sec = 1234567890,
|
||||||
|
.mtime_nsec = 0,
|
||||||
|
.atime_valid = false,
|
||||||
|
.crtime_valid = false};
|
||||||
|
|
||||||
|
/* times only: mtime changes, mode stays 0640. */
|
||||||
|
file_restore_metadata(path, &m, (FileAttrPolicy){false, true, false, false});
|
||||||
|
struct stat st;
|
||||||
|
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||||
|
EXPECT_EQ_INT(st.st_mode & 0777, 0640);
|
||||||
|
EXPECT_EQ_INT((int)st.st_mtime, 1234567890);
|
||||||
|
|
||||||
|
/* no attributes: neither mode nor mtime changes. */
|
||||||
|
EXPECT_EQ_INT(chmod(path, 0640), 0);
|
||||||
|
struct timespec ts[2] = {{.tv_sec = 1000000000, .tv_nsec = 0},
|
||||||
|
{.tv_sec = 1000000000, .tv_nsec = 0}};
|
||||||
|
EXPECT_EQ_INT(utimensat(AT_FDCWD, path, ts, 0), 0);
|
||||||
|
FileMetadata m2 = m;
|
||||||
|
m2.mode = 0700;
|
||||||
|
m2.mtime_sec = 1600000000;
|
||||||
|
file_restore_metadata(path, &m2, (FileAttrPolicy){false, false, false, false});
|
||||||
|
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||||
|
EXPECT_EQ_INT(st.st_mode & 0777, 0640);
|
||||||
|
EXPECT_EQ_INT((int)st.st_mtime, 1000000000);
|
||||||
|
|
||||||
|
unlink(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_file_attr_policy_from_config() {
|
||||||
|
FileAttrPolicy none = file_attr_policy_from_config(NULL);
|
||||||
|
EXPECT_FALSE(none.perms);
|
||||||
|
EXPECT_FALSE(none.times);
|
||||||
|
EXPECT_FALSE(none.atimes);
|
||||||
|
EXPECT_FALSE(none.executability);
|
||||||
|
|
||||||
|
Config* c = config_create();
|
||||||
|
EXPECT_NOT_NULL(c);
|
||||||
|
c->preserve_perms = true;
|
||||||
|
c->preserve_times = true;
|
||||||
|
c->preserve_atimes = true;
|
||||||
|
c->use_executability = true;
|
||||||
|
FileAttrPolicy p = file_attr_policy_from_config(c);
|
||||||
|
EXPECT_TRUE(p.perms);
|
||||||
|
EXPECT_TRUE(p.times);
|
||||||
|
EXPECT_TRUE(p.atimes);
|
||||||
|
EXPECT_TRUE(p.executability);
|
||||||
|
config_delete(c);
|
||||||
|
}
|
||||||
|
|
||||||
static void test_chmod_changes() {
|
static void test_chmod_changes() {
|
||||||
mode_t result;
|
mode_t result;
|
||||||
EXPECT_TRUE(chmod_apply(0777, "u=rw,go=r", &result));
|
EXPECT_TRUE(chmod_apply(0777, "u=rw,go=r", &result));
|
||||||
@@ -455,7 +606,7 @@ static void test_file_restore_symlink_metadata() {
|
|||||||
|
|
||||||
/* Positive path: a non-omitted apply stamps the link's own mtime. */
|
/* Positive path: a non-omitted apply stamps the link's own mtime. */
|
||||||
FileMetadata applied = {.mtime_sec = 1000000000, .mtime_nsec = 0};
|
FileMetadata applied = {.mtime_sec = 1000000000, .mtime_nsec = 0};
|
||||||
file_restore_symlink_metadata(link, &applied, false);
|
file_restore_symlink_metadata(link, &applied, (FileAttrPolicy){false, true, false, false}, false);
|
||||||
struct stat st;
|
struct stat st;
|
||||||
EXPECT_EQ_INT(lstat(link, &st), 0);
|
EXPECT_EQ_INT(lstat(link, &st), 0);
|
||||||
EXPECT_TRUE(S_ISLNK(st.st_mode));
|
EXPECT_TRUE(S_ISLNK(st.st_mode));
|
||||||
@@ -464,7 +615,7 @@ static void test_file_restore_symlink_metadata() {
|
|||||||
|
|
||||||
/* -J: a different time must be left untouched. */
|
/* -J: a different time must be left untouched. */
|
||||||
FileMetadata newer = {.mtime_sec = 1234567890, .mtime_nsec = 0};
|
FileMetadata newer = {.mtime_sec = 1234567890, .mtime_nsec = 0};
|
||||||
file_restore_symlink_metadata(link, &newer, true);
|
file_restore_symlink_metadata(link, &newer, (FileAttrPolicy){false, true, false, false}, true);
|
||||||
EXPECT_EQ_INT(lstat(link, &st), 0);
|
EXPECT_EQ_INT(lstat(link, &st), 0);
|
||||||
EXPECT_EQ_INT((int)st.st_mtime, (int)t1);
|
EXPECT_EQ_INT((int)st.st_mtime, (int)t1);
|
||||||
if (symlink_times_supported)
|
if (symlink_times_supported)
|
||||||
@@ -475,6 +626,79 @@ static void test_file_restore_symlink_metadata() {
|
|||||||
rmdir(dir);
|
rmdir(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Per-attribute gating of the descriptor restore path: -p alone applies the
|
||||||
|
* mode, -t alone the mtime, -U alone the atime, and an all-off policy leaves
|
||||||
|
* the destination's mode and times exactly as they are. This pins the fd API
|
||||||
|
* the receiver actually uses (the path-based file_restore_metadata has its own
|
||||||
|
* split test). */
|
||||||
|
static void test_file_restore_metadata_fd_attribute_split() {
|
||||||
|
identity_clear_active(); /* no ownership policy leaking from a previous test */
|
||||||
|
const char* path = "temp_meta_fd_split_test.txt";
|
||||||
|
EXPECT_TRUE(file_write_to_disk(path, "x", 1, false, false));
|
||||||
|
EXPECT_EQ_INT(chmod(path, 0640), 0);
|
||||||
|
int fd = open(path, O_RDWR);
|
||||||
|
EXPECT_TRUE(fd >= 0);
|
||||||
|
/* cppcheck-suppress knownConditionTrueFalse -- EXPECT_TRUE above asserts,
|
||||||
|
but cppcheck cannot see through the macro; the guard is defensive. */
|
||||||
|
if (fd < 0) {
|
||||||
|
unlink(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FileMetadata m = {.mode = 0755,
|
||||||
|
.uid = getuid(),
|
||||||
|
.gid = getgid(),
|
||||||
|
.mtime_sec = 1234567890,
|
||||||
|
.mtime_nsec = 0,
|
||||||
|
.atime_valid = true,
|
||||||
|
.atime_sec = 999999999,
|
||||||
|
.atime_nsec = 0,
|
||||||
|
.crtime_valid = false};
|
||||||
|
struct stat st;
|
||||||
|
struct stat before;
|
||||||
|
|
||||||
|
/* perms-only: mode applied, mtime untouched. */
|
||||||
|
EXPECT_EQ_INT(fstat(fd, &before), 0);
|
||||||
|
EXPECT_TRUE(file_restore_metadata_fd(fd, &m, (FileAttrPolicy){true, false, false, false}));
|
||||||
|
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||||
|
EXPECT_EQ_INT(st.st_mode & 0777, 0755);
|
||||||
|
EXPECT_EQ_INT((int)st.st_mtime, (int)before.st_mtime);
|
||||||
|
|
||||||
|
/* times-only: mtime applied, mode untouched. */
|
||||||
|
EXPECT_EQ_INT(chmod(path, 0600), 0);
|
||||||
|
EXPECT_TRUE(file_restore_metadata_fd(fd, &m, (FileAttrPolicy){false, true, false, false}));
|
||||||
|
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||||
|
EXPECT_EQ_INT(st.st_mode & 0777, 0600);
|
||||||
|
EXPECT_EQ_INT((int)st.st_mtime, 1234567890);
|
||||||
|
|
||||||
|
/* atime-only: atime applied, mtime and mode untouched. */
|
||||||
|
struct timespec reset[2] = {{.tv_sec = 1000000000, .tv_nsec = 0},
|
||||||
|
{.tv_sec = 1000000000, .tv_nsec = 0}};
|
||||||
|
EXPECT_EQ_INT(futimens(fd, reset), 0);
|
||||||
|
EXPECT_EQ_INT(fstat(fd, &before), 0);
|
||||||
|
EXPECT_TRUE(file_restore_metadata_fd(fd, &m, (FileAttrPolicy){false, false, true, false}));
|
||||||
|
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||||
|
EXPECT_EQ_INT((int)st.st_atime, 999999999);
|
||||||
|
EXPECT_EQ_INT((int)st.st_mtime, (int)before.st_mtime);
|
||||||
|
EXPECT_EQ_INT(st.st_mode & 0777, 0600);
|
||||||
|
|
||||||
|
/* all-off: neither mode nor either time is touched. */
|
||||||
|
EXPECT_EQ_INT(chmod(path, 0640), 0);
|
||||||
|
EXPECT_EQ_INT(futimens(fd, reset), 0);
|
||||||
|
FileMetadata m2 = m;
|
||||||
|
m2.mode = 0700;
|
||||||
|
m2.mtime_sec = 1600000000;
|
||||||
|
m2.atime_sec = 1700000000;
|
||||||
|
EXPECT_TRUE(file_restore_metadata_fd(fd, &m2, (FileAttrPolicy){false, false, false, false}));
|
||||||
|
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||||
|
EXPECT_EQ_INT(st.st_mode & 0777, 0640);
|
||||||
|
EXPECT_EQ_INT((int)st.st_mtime, 1000000000);
|
||||||
|
EXPECT_EQ_INT((int)st.st_atime, 1000000000);
|
||||||
|
|
||||||
|
close(fd);
|
||||||
|
unlink(path);
|
||||||
|
}
|
||||||
|
|
||||||
void test_metadata() {
|
void test_metadata() {
|
||||||
test_metadata_to_from_buf_roundtrip();
|
test_metadata_to_from_buf_roundtrip();
|
||||||
test_metadata_to_buf_null();
|
test_metadata_to_buf_null();
|
||||||
@@ -491,6 +715,12 @@ void test_metadata() {
|
|||||||
test_file_restore_metadata_applies_atime();
|
test_file_restore_metadata_applies_atime();
|
||||||
test_file_restore_executability_only();
|
test_file_restore_executability_only();
|
||||||
test_directory_restore_executability_only();
|
test_directory_restore_executability_only();
|
||||||
|
test_file_restore_executability_rsync_rule();
|
||||||
|
test_metadata_mode_for_policy();
|
||||||
|
test_new_file_mode_from_source_and_umask();
|
||||||
|
test_file_restore_attribute_split();
|
||||||
|
test_file_restore_metadata_fd_attribute_split();
|
||||||
|
test_file_attr_policy_from_config();
|
||||||
test_file_restore_symlink_metadata();
|
test_file_restore_symlink_metadata();
|
||||||
test_chmod_changes();
|
test_chmod_changes();
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-9
@@ -246,7 +246,8 @@ static void test_link_copy_fallback_preserves_xattrs() {
|
|||||||
m.atime_valid = false;
|
m.atime_valid = false;
|
||||||
m.crtime_valid = false;
|
m.crtime_valid = false;
|
||||||
|
|
||||||
bool ok = file_to_disk_secure_link_attrs(dest, basis_dir, "payload", 7, false, &m, false, false,
|
bool ok = file_to_disk_secure_link_attrs(dest, basis_dir, "payload", 7, false, &m,
|
||||||
|
(FileAttrPolicy){true, true, false, false}, false,
|
||||||
xattrs, true, NULL);
|
xattrs, true, NULL);
|
||||||
xattr_list_free(xattrs);
|
xattr_list_free(xattrs);
|
||||||
EXPECT_TRUE(ok);
|
EXPECT_TRUE(ok);
|
||||||
@@ -367,10 +368,11 @@ static void test_fake_super_restore() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* No xattr present yet: restore is a silent no-op (returns false, no crash). */
|
/* No xattr present yet: restore is a silent no-op (returns false, no crash). */
|
||||||
EXPECT_FALSE(fake_super_restore_fd(fd));
|
FileAttrPolicy policy = {true, true, false, false};
|
||||||
|
EXPECT_FALSE(fake_super_restore_fd(fd, policy));
|
||||||
|
|
||||||
fake_super_store_fd(fd, 1001, 1002, 0751, 1700000000, 123456789);
|
fake_super_store_fd(fd, 1001, 1002, 0751, 1700000000, 123456789);
|
||||||
EXPECT_TRUE(fake_super_restore_fd(fd));
|
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
|
||||||
struct stat st;
|
struct stat st;
|
||||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||||
EXPECT_EQ_INT((int)(st.st_mode & 07777), 0751);
|
EXPECT_EQ_INT((int)(st.st_mode & 07777), 0751);
|
||||||
@@ -379,7 +381,7 @@ static void test_fake_super_restore() {
|
|||||||
bits, and fake-super replay must not re-add them (a recorded 0666 restores
|
bits, and fake-super replay must not re-add them (a recorded 0666 restores
|
||||||
as 0644, never as world-writable). */
|
as 0644, never as world-writable). */
|
||||||
fake_super_store_fd(fd, 1001, 1002, 0666, 1700000000, 0);
|
fake_super_store_fd(fd, 1001, 1002, 0666, 1700000000, 0);
|
||||||
EXPECT_TRUE(fake_super_restore_fd(fd));
|
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
|
||||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||||
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0644);
|
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0644);
|
||||||
|
|
||||||
@@ -390,7 +392,7 @@ static void test_fake_super_restore() {
|
|||||||
EXPECT_EQ_INT((int)fsetxattr(wfd, FAKESUPER_XATTR, "not-a-valid-record", 19, 0), 0);
|
EXPECT_EQ_INT((int)fsetxattr(wfd, FAKESUPER_XATTR, "not-a-valid-record", 19, 0), 0);
|
||||||
close(wfd);
|
close(wfd);
|
||||||
}
|
}
|
||||||
EXPECT_FALSE(fake_super_restore_fd(fd));
|
EXPECT_FALSE(fake_super_restore_fd(fd, policy));
|
||||||
fstat(fd, &st);
|
fstat(fd, &st);
|
||||||
EXPECT_EQ_INT((int)st.st_mtime, (int)before);
|
EXPECT_EQ_INT((int)st.st_mtime, (int)before);
|
||||||
|
|
||||||
@@ -426,6 +428,7 @@ static void test_fake_super_owner_gate() {
|
|||||||
fake_super_store_fd(fd, 12345, 12346, 0755, 1700000000, 0);
|
fake_super_store_fd(fd, 12345, 12346, 0755, 1700000000, 0);
|
||||||
|
|
||||||
Config* c = config_create();
|
Config* c = config_create();
|
||||||
|
FileAttrPolicy policy = {true, true, false, false};
|
||||||
EXPECT_NOT_NULL(c);
|
EXPECT_NOT_NULL(c);
|
||||||
|
|
||||||
/* An explicit ownership policy is required before fake-super replay may
|
/* An explicit ownership policy is required before fake-super replay may
|
||||||
@@ -435,7 +438,7 @@ static void test_fake_super_owner_gate() {
|
|||||||
/* --no-super: the owner leg is skipped even as root. */
|
/* --no-super: the owner leg is skipped even as root. */
|
||||||
c->super_mode = SUPER_MODE_OFF;
|
c->super_mode = SUPER_MODE_OFF;
|
||||||
EXPECT_TRUE(identity_set_active(c));
|
EXPECT_TRUE(identity_set_active(c));
|
||||||
EXPECT_TRUE(fake_super_restore_fd(fd));
|
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
|
||||||
struct stat st;
|
struct stat st;
|
||||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||||
EXPECT_EQ_INT((int)st.st_uid, 0);
|
EXPECT_EQ_INT((int)st.st_uid, 0);
|
||||||
@@ -444,7 +447,7 @@ static void test_fake_super_owner_gate() {
|
|||||||
/* AUTO with an identity policy: the recorded source owner is applied. */
|
/* AUTO with an identity policy: the recorded source owner is applied. */
|
||||||
c->super_mode = SUPER_MODE_AUTO;
|
c->super_mode = SUPER_MODE_AUTO;
|
||||||
EXPECT_TRUE(identity_set_active(c));
|
EXPECT_TRUE(identity_set_active(c));
|
||||||
EXPECT_TRUE(fake_super_restore_fd(fd));
|
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
|
||||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||||
EXPECT_EQ_INT((int)st.st_uid, 12345);
|
EXPECT_EQ_INT((int)st.st_uid, 12345);
|
||||||
EXPECT_EQ_INT((int)st.st_gid, 12346);
|
EXPECT_EQ_INT((int)st.st_gid, 12346);
|
||||||
@@ -455,7 +458,7 @@ static void test_fake_super_owner_gate() {
|
|||||||
c->numeric_ids = false;
|
c->numeric_ids = false;
|
||||||
c->super_mode = SUPER_MODE_ON;
|
c->super_mode = SUPER_MODE_ON;
|
||||||
EXPECT_TRUE(identity_set_active(c));
|
EXPECT_TRUE(identity_set_active(c));
|
||||||
EXPECT_TRUE(fake_super_restore_fd(fd));
|
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
|
||||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||||
EXPECT_EQ_INT((int)st.st_uid, 0);
|
EXPECT_EQ_INT((int)st.st_uid, 0);
|
||||||
EXPECT_EQ_INT((int)st.st_gid, 0);
|
EXPECT_EQ_INT((int)st.st_gid, 0);
|
||||||
@@ -466,7 +469,7 @@ static void test_fake_super_owner_gate() {
|
|||||||
c->copy_as_uid = 777;
|
c->copy_as_uid = 777;
|
||||||
c->copy_as_gid = 778;
|
c->copy_as_gid = 778;
|
||||||
EXPECT_TRUE(identity_set_active(c));
|
EXPECT_TRUE(identity_set_active(c));
|
||||||
EXPECT_TRUE(fake_super_restore_fd(fd));
|
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
|
||||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||||
EXPECT_EQ_INT((int)st.st_uid, 0);
|
EXPECT_EQ_INT((int)st.st_uid, 0);
|
||||||
EXPECT_EQ_INT((int)st.st_gid, 0);
|
EXPECT_EQ_INT((int)st.st_gid, 0);
|
||||||
@@ -477,6 +480,63 @@ static void test_fake_super_owner_gate() {
|
|||||||
unlink(path);
|
unlink(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* MAJOR 1: the --fake-super owner replay must honor the per-side -o/-g split.
|
||||||
|
* With only -o (preserve_owner) requested the recorded GROUP must be left
|
||||||
|
* untouched, and with only -g (preserve_group) the recorded OWNER must be left
|
||||||
|
* untouched. Root-gated: only root can observe a chown actually landing. */
|
||||||
|
static void test_fake_super_owner_group_split() {
|
||||||
|
if (geteuid() != 0)
|
||||||
|
return; /* non-root cannot observe ownership changes; skip silently */
|
||||||
|
const char* path = "test_fake_super_owner_group_split.txt";
|
||||||
|
unlink(path);
|
||||||
|
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||||
|
if (fd < 0)
|
||||||
|
return;
|
||||||
|
bool has_xattr = setxattr(path, "user.fastsync.xprobe", "p", 1, 0) == 0;
|
||||||
|
if (has_xattr)
|
||||||
|
removexattr(path, "user.fastsync.xprobe");
|
||||||
|
if (!has_xattr) {
|
||||||
|
close(fd);
|
||||||
|
unlink(path);
|
||||||
|
return; /* filesystem without xattr support */
|
||||||
|
}
|
||||||
|
if (fchown(fd, 0, 0) != 0) {
|
||||||
|
close(fd);
|
||||||
|
unlink(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fake_super_store_fd(fd, 12345, 12346, 0755, 1700000000, 0);
|
||||||
|
|
||||||
|
Config* c = config_create();
|
||||||
|
FileAttrPolicy policy = {true, true, false, false};
|
||||||
|
EXPECT_NOT_NULL(c);
|
||||||
|
struct stat st;
|
||||||
|
|
||||||
|
/* -o only: the owner is applied, the group stays at its current value (0). */
|
||||||
|
c->preserve_owner = true;
|
||||||
|
c->preserve_group = false;
|
||||||
|
EXPECT_TRUE(identity_set_active(c));
|
||||||
|
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
|
||||||
|
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||||
|
EXPECT_EQ_INT((int)st.st_uid, 12345);
|
||||||
|
EXPECT_EQ_INT((int)st.st_gid, 0);
|
||||||
|
|
||||||
|
/* -g only: the group is applied, the owner stays at its current value (0). */
|
||||||
|
EXPECT_EQ_INT(fchown(fd, 0, 0), 0);
|
||||||
|
c->preserve_owner = false;
|
||||||
|
c->preserve_group = true;
|
||||||
|
EXPECT_TRUE(identity_set_active(c));
|
||||||
|
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
|
||||||
|
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||||
|
EXPECT_EQ_INT((int)st.st_uid, 0);
|
||||||
|
EXPECT_EQ_INT((int)st.st_gid, 12346);
|
||||||
|
|
||||||
|
identity_clear_active();
|
||||||
|
config_delete(c);
|
||||||
|
close(fd);
|
||||||
|
unlink(path);
|
||||||
|
}
|
||||||
|
|
||||||
void test_xattr() {
|
void test_xattr() {
|
||||||
test_xattr_wire_roundtrip();
|
test_xattr_wire_roundtrip();
|
||||||
test_xattr_reject_privileged_namespace();
|
test_xattr_reject_privileged_namespace();
|
||||||
@@ -488,4 +548,5 @@ void test_xattr() {
|
|||||||
test_link_copy_fallback_preserves_xattrs();
|
test_link_copy_fallback_preserves_xattrs();
|
||||||
test_fake_super_restore();
|
test_fake_super_restore();
|
||||||
test_fake_super_owner_gate();
|
test_fake_super_owner_gate();
|
||||||
|
test_fake_super_owner_group_split();
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user