Skip to content

chunkers, crypto: release the GIL in pure-C hot paths - #10014

Merged
ThomasWaldmann merged 1 commit into
borgbackup:masterfrom
ThomasWaldmann:nogil
Aug 2, 2026
Merged

chunkers, crypto: release the GIL in pure-C hot paths#10014
ThomasWaldmann merged 1 commit into
borgbackup:masterfrom
ThomasWaldmann:nogil

Conversation

@ThomasWaldmann

Copy link
Copy Markdown
Member

Release the GIL in all the no-risk pure-C regions of the Cython kernels, so other threads (FUSE request handling, the lock-refresh thread, and future parallel create/transfer pipelines) are no longer blocked while borg chunks, hashes, or en/decrypts.

What

Chunkers (buzhash, buzhash64, fastcdc):

  • _buzhash* / BARREL_SHIFT* helpers declared noexcept nogil (pure arithmetic, cannot raise),
  • with nogil: around the byte-scan loops, the initial window hash, and fill()'s bulk memmove/memcpy/memset (the PyBytes_AsString pointer is captured while holding the GIL).

crypto/low_level.pyx:

  • OpenSSL extern blocks declared nogil (declaration-only),
  • GIL released around only the bulk EVP_EncryptUpdate/EVP_DecryptUpdate calls (AEAD encrypt+decrypt, AES-CTR decrypt as used by borg transfer), using an rc variable so the error check/raise stays outside the block; the small Init/ctrl/Final/AAD calls are unchanged,
  • XXH64: _update and the _xxh_* inline helpers marked noexcept nogil.

Why this is safe

Every wrapped region is pure C on caller-owned buffers: no Python API use, no refcounting, no exceptions. No cipher or chunker object is used concurrently from multiple threads anywhere in borg: AEAD decrypt constructs a fresh cipher per call, the encrypt-side session cipher only runs on the single-threaded write paths, borg mount is read-only, and legacy repos cannot be mounted, so CTR decrypt only runs in single-threaded borg transfer.

Deliberately not converted: AES-CTR encrypt() (test-only), platform/*.pyx syscall wrappers (would need restructuring, not mechanical), item.pyx/hashindex.pyx (Python-object work), CSPRNG and XXH64.digest() (tiny buffers).

Measurements

Two threads doing equal work each, 256 MiB random data per thread (Apple Silicon, py311); 2T/1T wall-time ratio: 2.0 = fully serialized, 1.0 = perfectly parallel:

kernel before after
fastcdc chunker 2.11 1.32
buzhash64 chunker 1.98 1.28
buzhash chunker 2.04 1.23
xxh64 2.01 1.01
aes256-ocb encrypt / decrypt 2.02 / 1.98 1.04 / 1.09
chacha20-poly1305 encrypt / decrypt 2.08 / 2.04 1.07 / 1.08

Single-thread throughput is unchanged: the loop bodies are identical and the GIL is released once per scanned segment / bulk call, not per byte. The chunkers' residual ~1.3 is the per-chunk Python-level work (Chunk objects, reader), left for future work.

This is groundwork for a threaded create pipeline (#37).

Verification

  • chunkers + crypto test suites pass (287 tests), create/extract/transfer/compress archiver tests pass (167 tests),
  • end-to-end smoke: repo-create (chacha20-poly1305) → create → check → extract → diff OK (startup selftests exercise the changed kernels in every borg process),
  • the 2-thread scaling measurements above double as proof the GIL is actually released.

🤖 Generated with Claude Code

Mark the pure-C helpers (buzhash/buzhash64 hash+update, xxh64 rounds) as
noexcept nogil and release the GIL:

- in the chunkers' scan loops (buzhash, buzhash64, fastcdc) and around the
  bulk memmove/memcpy/memset in their buffer fill,
- around the bulk EVP_EncryptUpdate/EVP_DecryptUpdate calls of the AEAD
  ciphersuites and the legacy AES-CTR decrypt (used by borg transfer),
- around the XXH64 update loop.

All wrapped regions are pure C on caller-owned buffers: no Python API use,
no refcounting, no exceptions. Cipher/chunker objects are not shared
between threads anywhere in borg (mounts are read-only and AEAD decrypt
constructs a fresh cipher per call), so dropping the GIL does not change
semantics; other threads (FUSE, lock refresh, future parallel pipelines)
simply are no longer blocked while chunking/hashing/encrypting.

2-thread scaling of the kernels (M-series, 256 MiB random data per thread,
2T/1T wall time; 2.0 = fully serialized, 1.0 = perfectly parallel):

                        before  after
  fastcdc chunker         2.11   1.32
  buzhash64 chunker       1.98   1.28
  buzhash chunker         2.04   1.23
  xxh64                   2.01   1.01
  aes256-ocb encrypt      2.02   1.04
  aes256-ocb decrypt      1.98   1.09
  chacha20-poly1305 enc   2.08   1.07
  chacha20-poly1305 dec   2.04   1.08

Single-thread throughput is unchanged (the loop bodies are identical; the
GIL is released once per scanned segment / bulk call, not per byte).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.10%. Comparing base (2a5cb4c) to head (4d2e068).
⚠️ Report is 1 commits behind head on master.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #10014   +/-   ##
=======================================
  Coverage   86.10%   86.10%           
=======================================
  Files          96       96           
  Lines       17326    17326           
  Branches     2649     2649           
=======================================
  Hits        14918    14918           
  Misses       1667     1667           
  Partials      741      741           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@ThomasWaldmann
ThomasWaldmann merged commit 67ad3e3 into borgbackup:master Aug 2, 2026
18 checks passed
@ThomasWaldmann
ThomasWaldmann deleted the nogil branch August 2, 2026 20:00
ThomasWaldmann added a commit to ThomasWaldmann/borg that referenced this pull request Aug 4, 2026
Previously every data byte travelled through up to five userspace copies
on its way into a chunker's scan buffer: os.read into a block bytes
object, a slice copy in FileReader.read(), a bytearray.extend, a final
bytes(result), and the chunker's memcpy into its buffer. For the fast
chunkers, this copy chain costs as much as or more than the scan itself.

FileReader gains readinto(target, size): it walks its buffered file
blocks and copies each byte exactly once, directly into the caller's
buffer (a memoryview over the chunker's scan buffer); ranges stemming
from sparse holes or all-zero blocks are written as zeros. The chunkers'
fill() uses it instead of read()+memcpy/memset. read() stays unchanged
for other callers (e.g. the fixed chunker).

Cut behavior is untouched - the scan sees exactly the same bytes: all
golden chunk-point tests pass unchanged, and readinto was verified equal
to read() over 30 random read-size sequences on files with mixed
data/hole fmaps (holes correctly zeroing a dirtied target buffer).
End-to-end create+extract incl. a sparse file verified.

Measured (Apple M-series, 1 GiB random, 19,23,21, three runs, best):
fastcdc ~1210 -> ~1730 MB/s (~+40%), buzhash64 ~975 -> ~1215 (~+20%),
buzhash ~1050 -> ~1225 (~+15%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Merge pull request #10033 from ThomasWaldmann/py315-support

CI: add linux Python 3.15 jobs (non-blocking)
canary: add a linux Python 3.15 entry

Add ubuntu-24.04 / 3.15-dev / py315-mfusepy to the canary matrix, in addition
to (not replacing) the 3.14 entry - the canary runs against unlocked
requirements, so this is where a 3.15 breakage of a dependency shows up first.

fail-fast is already off for this matrix, so a red 3.15 entry does not affect
the other canary jobs.

ci: add a linux Python 3.15 job (non-blocking)

Add an ubuntu-24.04 / 3.15-dev / py315-mfusepy entry to both native_tests
matrices (PR and push). 3.15 is still a prerelease, so the entry is marked
"allow-failure": true and the job gets
continue-on-error: ${{ matrix.allow-failure || false }} - a broken prerelease
python (or a dependency without 3.15 wheels) then does not fail the build nor
cancel the rest of the matrix via fail-fast.

mfusepy is the pure-python FUSE binding, so it is the variant least likely to
need a source build against a moving 3.15 C API.

tox (py315-* envs) and pyproject.toml (3.15 classifier) already had 3.15
support from b9a0a33a6; only the ci.yml part had been reverted in 939568516
because PyO3 did not support 3.15 back then.

Merge pull request #10029 from borgbackup/dependabot/github_actions/actions-83bd6e4a5a

build(deps): bump github/codeql-action from 4 to 4.37.4 in the actions group
Merge pull request #10028 from borgbackup/dependabot/pip/requirements.d/pip-dependencies-ac1f890568

build(deps-dev): bump the pip-dependencies group in /requirements.d with 2 updates
build(deps): bump github/codeql-action in the actions group

Bumps the actions group with 1 update: [github/codeql-action](https://github.com/github/codeql-action).

Updates `github/codeql-action` from 4 to 4.37.4
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
build(deps-dev): bump the pip-dependencies group

Bumps the pip-dependencies group in /requirements.d with 2 updates: [tox](https://github.com/tox-dev/tox) and [coverage](https://github.com/coveragepy/coveragepy).

Updates `tox` from 4.56.1 to 4.56.4
- [Release notes](https://github.com/tox-dev/tox/releases)
- [Changelog](https://github.com/tox-dev/tox/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/tox/compare/4.56.1...4.56.4)

Updates `coverage` from 7.14.3 to 7.15.0
- [Release notes](https://github.com/coveragepy/coveragepy/releases)
- [Changelog](https://github.com/coveragepy/coveragepy/blob/main/CHANGES.rst)
- [Commits](https://github.com/coveragepy/coveragepy/compare/7.14.3...7.15.0)

---
updated-dependencies:
- dependency-name: tox
  dependency-version: 4.56.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: pip-dependencies
- dependency-name: coverage
  dependency-version: 7.15.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: pip-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Merge pull request #10027 from ThomasWaldmann/blake3-build-woes

Blake3 build woes
borgstore: require 0.6.x, with blake3 support

blake3 build woes

windows: 1.0.9 is broken
py315: 1.0.8 is broken

Merge pull request #10024 from ThomasWaldmann/fetch-many-missing-chunk

fix DownloadPipeline.fetch_many() crashing on a missing chunk
fix DownloadPipeline.fetch_many() crashing on a missing chunk

With replacement_chunk=False, fetch_many() is documented (and used) to yield None
for a chunk that is missing in the repository - but the size check right before
the yield then did len(None) and raised TypeError instead.

borg webdav is currently the only caller that passes replacement_chunk=False for
file content, so its "chunk missing" path (abort the connection instead of
serving corrupted data) never actually ran: the TypeError ended up in the request
error handler, which then tried to send a 500 for a response whose headers were
already on the wire.

Adds unit tests for both flavours of a missing chunk (the one without a
replacement chunk fails without this fix) and an end-to-end webdav test for
downloading a file with a chunk missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Merge pull request #10022 from borgbackup/auto/update-fame

FAME.md: update contributor statistics
FAME.md: update contributor statistics

Merge pull request #10014 from ThomasWaldmann/nogil

chunkers, crypto: release the GIL in pure-C hot paths
chunkers, crypto: release the GIL in pure-C hot paths

Mark the pure-C helpers (buzhash/buzhash64 hash+update, xxh64 rounds) as
noexcept nogil and release the GIL:

- in the chunkers' scan loops (buzhash, buzhash64, fastcdc) and around the
  bulk memmove/memcpy/memset in their buffer fill,
- around the bulk EVP_EncryptUpdate/EVP_DecryptUpdate calls of the AEAD
  ciphersuites and the legacy AES-CTR decrypt (used by borg transfer),
- around the XXH64 update loop.

All wrapped regions are pure C on caller-owned buffers: no Python API use,
no refcounting, no exceptions. Cipher/chunker objects are not shared
between threads anywhere in borg (mounts are read-only and AEAD decrypt
constructs a fresh cipher per call), so dropping the GIL does not change
semantics; other threads (FUSE, lock refresh, future parallel pipelines)
simply are no longer blocked while chunking/hashing/encrypting.

2-thread scaling of the kernels (M-series, 256 MiB random data per thread,
2T/1T wall time; 2.0 = fully serialized, 1.0 = perfectly parallel):

                        before  after
  fastcdc chunker         2.11   1.32
  buzhash64 chunker       1.98   1.28
  buzhash chunker         2.04   1.23
  xxh64                   2.01   1.01
  aes256-ocb encrypt      2.02   1.04
  aes256-ocb decrypt      1.98   1.09
  chacha20-poly1305 enc   2.08   1.07
  chacha20-poly1305 dec   2.04   1.08

Single-thread throughput is unchanged (the loop bodies are identical; the
GIL is released once per scanned segment / bulk call, not per byte).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Merge pull request #10010 from ThomasWaldmann/fame-tiebreak

fame.py: order contributors deterministically, drop the push trigger
fame.py: order contributors deterministically, drop the push trigger

The workflow no longer runs on pushes to FAME.md.  The "Lines" column is
blame of the current checkout and master sees dozens of commits a week, so
merging one of these pull requests immediately produced the next one:
#10008 was merged at 10:26:09 and #10009 opened 37 seconds later.  Both
files are written by the same run and cannot drift apart, so the weekly run
plus manual dispatch is enough.

git-fame also leaves people with the same commit count in its own order,
which is stable for one checkout but not across checkouts, so tied ranks
kept shuffling and added diff noise.  Sort by commits, then surviving
lines, then name.  The next run will apply that order to FAME.md.

Merge pull request #10008 from borgbackup/auto/update-fame

FAME.md: update contributor statistics
FAME.md: update contributor statistics

Merge pull request #10007 from ThomasWaldmann/fame-weekly

add a weekly workflow refreshing FAME.md
README: show the contributor chart in the "Helping" section

Uses the raw.githubusercontent URL rather than a relative path: README.rst
is also the PyPI long description, where a relative image does not resolve.

fame.py: use the borg green for the chart bars

The logo green (#00dd00) is made for a dark background; on white it only
reaches a contrast of 1.9:1, so the light scheme uses a darker green of the
same hue.

fame.py: also generate FAME.svg, a chart of the top contributors

git-fame's own --format=svg renders the whole table as monospaced text,
which works for a handful of contributors but not for a few hundred, so
draw a bar chart of the top 20 (--svg-top) instead.  FAME.md embeds it.

The chart follows the reader's colour scheme, sizes its viewport in user
units plus a viewBox (em on the root <svg> would resolve against the wrong
font size) and escapes author names, which are commit-controlled input.
Bots keep their row in the table, but do not take a chart slot away from a
person.

The workflow now also runs when FAME.md changes on master, so that the two
files cannot drift apart.

add a weekly workflow refreshing FAME.md

The workflow runs scripts/fame.py against master every Monday and opens a
pull request if the contributor statistics changed.

For that to be quiet in weeks where nothing changed, fame.py now leaves
FAME.md alone when only the generation date would differ.

Merge pull request #10005 from ThomasWaldmann/repo-compress-9663

re-add repo-compress, fixes #9663
Merge pull request #10006 from ThomasWaldmann/joke-issue-10000

docs: add the #10000 joke collection and its "We Are Borg" graphic
docs: add the #10000 joke collection and its "We Are Borg" graphic

Keeps the jokes from borgbackup/borg#10000 in the repo, next to the SVG
that illustrates them: a Borg cube tractor-beams in 14 files and emits a
chunk store holding 4 unique chunks with their reference counts.

"Redundancy is futile, your files will be deduplicated!"

The "WE ARE BORG" headline is set in the borg logo typeface (Black Ops One,
SIL OFL 1.1) and stored as outlines, so the SVG stays self-contained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

re-add repo-compress, fixes #9663

reimplemented for the pack-based repository format: the repository is
processed pack after pack - each pack is read as a whole and, if it has
objects not stored with the desired compression, those get recompressed
and the pack is rewritten (objects already fine are copied into the new
pack unchanged), the chunk index is updated and the old pack is deleted.
a pack whose objects all already match is not touched at all.

- new Repository.transform_pack: rewrite one pack, passing each indexed
  object's bytes through a transform callback. gap bytes are handled
  like in compact_pack: superseded duplicates are dropped, other
  unindexed bytes are carried forward for "borg check --repair".
- factored the check_pack_objects / superseded_gap_ranges helpers out
  of compact_pack, now shared with merge_packs and transform_pack.
- crash safety like compact (#9748): stored chunk indexes are
  invalidated before the first store change, a full updated index is
  written back at the end. SIGINT stops cleanly at a pack boundary,
  saving a valid index; a later run recompresses the remaining packs.
- --stats reports rewritten packs, per-object outcomes (recompressed /
  already had the desired compression / kept as-is because
  recompression brings no gain) and the repository size change.
- re-adding the fish completions also restores the compression_methods
  definition that the create/transfer/recreate/import-tar completions
  kept referencing after it was removed together with the old
  repo-compress.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Merge pull request #10004 from VedantPatel04/add_json_output_vedant

fix: version add --json output
fix:add --json output

Merge pull request #9995 from ThomasWaldmann/always-assert-id-9994

do not verify the chunk id on every read from an AEAD repo, fixes #9994
Merge pull request #9999 from ThomasWaldmann/progress-fps-8041

BORG_PROGRESS_FPS: how often --progress output is updated, #8041
Merge pull request #10003 from ThomasWaldmann/fastcdc-default-9957

make fastcdc the default chunker, fixes #9957
make fastcdc the default chunker, fixes #9957

fastcdc is ~1.3x faster than buzhash/buzhash64 at the same deduplication
and (with normalized chunking) a tighter chunk size distribution.

It is also the better choice security-wise: its Gear table is derived
from secret key material, while the "buzhash" chunker only XORs a 32bit
seed into its table - so chunk cut points are much harder to predict
without the key (resistance against chunk-size fingerprinting attacks).

Both places using a chunker are switched:

- CHUNKER_PARAMS (file content data): fastcdc,19,23,21,2
- ITEMS_CHUNKER_PARAMS (item metadata stream): fastcdc,15,19,17,2

The metadata stream chunker was still using the 32bit seeded buzhash, so
this also closes that gap.

Also rename CHUNKER64_PARAMS -> BUZHASH64_PARAMS and add BUZHASH_PARAMS,
so the per-algorithm defaults are named consistently and CHUNKER_PARAMS
unambiguously means "the default chunker".

This is intentionally done during the beta phase so it gets plenty of
practical testing before borg2 is released for production.

Statistics.show_progress: remove dt param, rate limit internally

All non-final callers used the same BORG_PROGRESS_FPS based dt, and the
final calls must always be shown - so apply the rate limit inside
show_progress (bypassed by final=True) instead of passing dt around.
Also initialize last_progress to -inf, so the first update is always
shown regardless of the platform's monotonic clock epoch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Merge pull request #10002 from ThomasWaldmann/mailmap-fame

mailmap: merge duplicate contributor identities, add scripts/fame.py
mailmap: merge duplicate contributor identities, add scripts/fame.py and FAME.md

git blame / git shortlog listed a number of contributors twice, either because
the same address was used with different spellings of the name, or because the
same person committed from several addresses.  Merge them in .mailmap: this
brings the number of distinct authors down from 380 to 370.

Note that two of the merges are judgement calls, see the diff for the entries
in question: one address was used with two different real names and a handle
(the most recent non-handle name is used), and another was used mostly with a
handle and a few times with a real name (the real name is used).

Add scripts/fame.py, which writes FAME.md from git-fame output, sorted by
commit count.  It bakes in an exclusion pattern for generated files - without
it, the three Excalidraw sources of the pack format figures contribute 39230
lines (a third of the repository) to a single contributor, because they are
pretty-printed JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

new BORG_PROGRESS_FPS env var: how often --progress output is updated, #8041

Like RESTIC_PROGRESS_FPS: maximum progress updates per second, default 5
(the traditional 0.2s interval). Fractional values below 1 are allowed,
e.g. BORG_PROGRESS_FPS=0.1 for one update every 10 seconds - useful when
the output goes into a logfile rather than to an interactive terminal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

do not verify the chunk id on every read from an AEAD repo, fixes #9994

For the AEAD encryption modes, the chunk id is part of the AAD, so a successful
decryption already proves that a repo key holder deliberately stored this exact
ciphertext for this exact chunk id: a malicious repository can not swap, splice
or substitute objects, whether we recompute the id hash over the plaintext or
not. What assert_id adds there is only the detection of chunks whose content
does not match their id, which only a malicious/compromised borg client that
had the borg key could have written (see #7362, which made the check mandatory).

Whether that extra full-plaintext hash pass is worth it depends on where borg
reads, so the new BORG_ASSERT_ID env var takes a comma-separated list of the
places that shall verify the chunk id:

    read       the general read path: extract, mount, export-tar, diff, ...
    repair     borg check --repair
    transfer   borg transfer (everything read from the source repo)
    rechunk    borg recreate --chunker-params (re-chunking reads)

Default (env var not set): "repair,transfer,rechunk", i.e. every place that
re-anchors content and would thus make a violation unnoticeable afterwards, but
not the hot read path. Unknown names are an error.
BORG_ASSERT_ID=read,repair,transfer,rechunk gives the old behaviour of always
verifying.

The id is verified no matter what the env var says:

- in borg check --verify-data - the audit that re-certifies the id/content
  invariant for all chunks of the repository is what makes not verifying
  elsewhere defensible, so it is not configurable (there is no "verify_data"
  place name, giving one is an error).
- for "authenticated" and "none" mode repos and for borg 1.x repo objects:
  there is no AEAD there, so assert_id IS the read path authentication
  (KeyBase.id_check_is_authentication) and skipping it would demote
  "authenticated" to "none".

Measured on an Apple M3 Pro (18GB RAM, hw accelerated sha256), reading one 20GiB
incompressible file back via "borg export-tar arch - > /dev/null", aes256-ocb,
--compression none, 4 runs alternating between the settings:

    id hash  BORG_ASSERT_ID  run times [s]                median  throughput
    sha256   default         20.43 21.13 20.79 20.93      20.86   982 MiB/s
    sha256   ...,read        29.42 28.91 28.82 28.05*     28.87   709 MiB/s
    blake3   default         19.99 19.97 20.00 19.93      19.98  1025 MiB/s
    blake3   ...,read        24.70 24.95* 24.72 24.60     24.71   829 MiB/s

    *) repeated run: the original one (31.64s resp. 28.37s) was disturbed by
       unrelated background load on the test machine.

i.e. 28% faster for sha256 id hashes and 19% faster for blake3 ones (the file is
much bigger than the RAM, so this includes reading it from the SSD every time).

With the default, running borg check --verify-data periodically is the
recommended audit that re-certifies the id/content invariant for all chunks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Merge pull request #9985 from ThomasWaldmann/list-blake3

list: add {blake3} format key, validate --format keys, fixes #9984
--format: give a clean error for invalid format keys/strings

An unknown format key (or a malformed format string) made borg crash
with a KeyError/ValueError traceback, e.g.:

    borg list -a aid:1234 --format '{nosuchkey}'
    KeyError: 'nosuchkey'

BaseFormatter.validate_format existed, but was only used by borg check
and it did not know about keys that are only in KEY_GROUPS (like the
hash keys of ItemFormatter), so it could not be used as-is elsewhere.

- add BaseFormatter.known_keys() (KEY_DESCRIPTIONS + KEY_GROUPS + FIXED_KEYS)
  and validate against that
- validate in BaseFormatter.__init__, so all formatters / commands
  (list, repo-list, diff, prune, check) give a CommandError now
- borg list: validate early, format_needs_cache parses the format before
  the ItemFormatter gets built

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

list: add {blake3} format key, fixes #9984

borg2 uses blake3 (as keyed hash) for chunk ids, but the --format
hash keys only offered the python stdlib hashes, so {blake3} raised
a KeyError.

The blake3 package is a hard dependency of borg2 anyway, so just add
blake3 to the set of supported hash algorithms. Output is the default
256bit hexdigest, like for the other hash keys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Merge pull request #9986 from ThomasWaldmann/shtab-blacklist

shtab 1.9.1 is still broken, disallow it
shtab 1.9.1 is still broken, disallow it

same issue as previous fix that disallowed 1.8.2 and 1.9.0.

Merge pull request #9979 from ThomasWaldmann/improve-crypto

crypto: misc. improvements to code and docs
crypto: move the AES type stub to borg.legacy.crypto

borg.crypto.low_level has no AES class - the legacy AES wrapper (only used for
legacy key file encryption) lives in borg.legacy.crypto.low_level, which had no
type stub at all. So move the stub entry where the class actually is.

crypto: raise IntegrityError for truncated AEAD/AE envelopes

A truncated envelope (shorter than header + auth tag, for the legacy AES-CTR
modes: header + mac + iv) previously ended up calling EVP_DecryptUpdate with a
negative length, which OpenSSL luckily rejects, resulting in
CryptoError('EVP_DecryptUpdate failed').

But CryptoError means "malfunction in the crypto module" and no caller catches
it, so a truncated chunk (e.g. 32..47 bytes for the AEAD envelope layout) gave
an ugly crash instead of being handled like what it is: corrupted or tampered
data. Now we check the envelope length first and raise IntegrityError, which
the upper layers already handle properly.

Also: requirements_check() raised the NotImplemented constant (a TypeError at
runtime) instead of NotImplementedError, and the _AEAD_BASE key param docstring
called the AEAD key an "encrypt-then-mac key" - AEAD modes are exactly what
replaced borg 1.x's encrypt-then-MAC construction, they take a single AEAD key.

CryptoTestCase is part of the startup self test, so SELFTEST_COUNT needs to be
updated for the 2 test methods added here (otherwise every borg process aborts
with "Self-test count mismatch").

crypto: fix the stale API sketch in the low_level docstring

None of the ciphersuite classes in this module has next_iv(len(data)) - both the
AEAD and the legacy AES-CTR classes compute the next iv without arguments.
Also, header_len and aad_offset are constructor arguments, not encrypt/decrypt
arguments, and encrypt takes iv and aad.

crypto: fix the "internal 32bit counter" claim, #6501

"AES-OCB, CHACHA20 ciphers all add a internal 32bit counter to the 96bit IV we
provide" is only true for chacha20-poly1305: the ChaCha20 block function has a
32bit block counter besides the 96bit nonce, limiting a message to 2^32 blocks
(256GiB) per (key, nonce). AES-OCB has no such counter, it derives the per-block
offsets from the nonce (RFC 7253).

The conclusions drawn from that claim were fine for both ciphers, so this is a
comment/docs fix only:

- the 2^32 blocks per message check stays as it is - it is required for chacha
  and just conservative for OCB (it can not trigger anyway, our messages are
  limited to MAX_DATA_SIZE == 20MiB).
- incrementing the IV by 1 per message stays correct for both ciphers, because
  the cipher blocks of a message do not consume IVs.

Also reworded the ValueError message, which claimed a counter overflow.

docs: we do not count forgery attempts, and why, #6501

The forgery attempts (v) limit is the only AEAD limit borg does not enforce
(chacha20-poly1305: about 2^33 for our biggest messages at p 2^-50, AES-OCB with
its 128bit auth tag is far less restrictive). Document that this is intentional:

- a failed decryption means tampered or corrupted data, borg refuses it and
  usually aborts the whole command (borg check and archive listing keep going,
  but only to report the damage).
- reaching the limit would need way more tampered data than a real repository
  will ever hold.

Also note it at the place where the failed decryptions actually happen.

docs: document the AEAD usage limits we aim for, #6501

The "48 bit IV is way more than needed" paragraph justified the IV size with the
amount of data encrypted in one session, but the IV only limits the number of
**messages** (2^48, borg refuses to encrypt more). How much **data** we may
encrypt with one session key is determined by the security bounds of the ciphers.

So now we state all 3 relevant quantities and the target probability we aim for:

- number of messages (q): 2^48 per session key, never the binding limit.
- data volume: AES-OCB only, 2^37 blocks (2TiB) per session key == p 2^-51
  (RFC 7253's 4PiB rule of thumb corresponds to p 2^-32). chacha20-poly1305
  does not have such a limit.
- forgery attempts (v): chacha20-poly1305, about 2^33 at p 2^-50 for our biggest
  messages, counted over all session keys (so more sessions do not help there).

Also mention the session key change in the in-depth crypto docs (security.rst).

crypto: limit the data encrypted with one aes256-ocb session key, #6501

AES-OCB has a birthday-type security bound in the amount of data encrypted
using one key: the attacker's advantage is about 6 * sigma^2 / 2^128, sigma
being the number of 128bit cipher blocks (Krovetz/Rogaway OCB3, Theorem 1).
RFC 7253 derives its "at most 2^48 blocks (4PiB) per key" rule of thumb from
that bound, which corresponds to an advantage of 2^-32.

We now aim higher and start a new session after 2^37 blocks (2TiB), giving an
advantage of about 2^-51, in line with the target probability used in the
examples of draft-irtf-cfrg-aead-limits. Starting a new session is cheap (one
sha256 for the new session key) and fully transparent, because the session id
is part of every chunk header - so old chunks stay decryptable and nothing
changes for reading existing repositories.

Rekeying that often also is what helps in the multi-key setting: the advantages
of the individual session keys just add up, so the quantity that matters over
the lifetime of a borg key is sum(sigma_i^2) and not (sum sigma_i)^2.

chacha20-poly1305 does not need such a limit: its confidentiality bound does not
depend on the amount of data encrypted at all and its integrity bound only limits
the number of forgery attempts, counted over all keys.

Merge pull request #9983 from ThomasWaldmann/shtab-exclude-broken

shtab: exclude 1.8.2 and 1.9.0 releases
shtab: exclude 1.8.2 and 1.9.0 releases

Both generate broken zsh completions: shtab replaced escape_zsh() with
shlex.quote() for the zsh help text, but still interpolates the result inside
a double-quoted _arguments spec. shlex.quote returns a self-contained shell
word (single-quoted, with ' written as '"'"'), which is only valid at top
level, so the quote parity flips and the rest of the spec goes unquoted.

Every description with a space gained stray literal quotes, and an apostrophe
anywhere left an unterminated quote that cascaded to shtab's own
default='*::: :->{name}', whose -> then parsed as a redirection:

    $ borg completion zsh | zsh -n
    (stdin):715: parse error near `>'

Our --sort-by help for `borg diff` contains '>size_added,path', which is what
tripped test_zsh_completion_syntax. Reported as tqdm/shtab#224, fix proposed
in tqdm/shtab#225; drop the exclusions once a release carries it.

Only CI jobs with a cold .tox cache saw this (macOS 15 first), since shtab was
unpinned above 1.8.0 -- cached envs kept the working 1.8.1.

Fixes #9982

Merge pull request #9981 from ThomasWaldmann/update-changes-master

update CHANGES (master)
update CHANGES

Merge pull request #9977 from ThomasWaldmann/units-5513

new BORG_UNITS env var: si / iec / raw size formatting, #5513
release chunk data memoryviews after use, see #1755 (#9978)

release chunk data memoryviews after use, see #1755

On pypy, a memoryview over a C-API-created bytes object pins the
buffer via cpyext: if the consumer drops the memoryview without
calling .release(), the memory is never reclaimed - not even by
gc.collect(). Since the chunkers wrap every yielded chunk in a
memoryview, borg create leaked roughly 2-3x the processed data
volume on pypy (a 20 GB create grew to a 43 GB memory footprint
and got killed by the OS).

Add release_chunk_data() to chunkers and call it at all places
that consume chunker output: ChunkBuffer.flush,
ChunksProcessor.process_file_chunks, ArchiveRecreater.chunk_processor,
transfer --rechunk and the chunker benchmark. Explicitly releasing
also frees the buffer timely on CPython instead of relying on
refcounting of the last reference. Document the consumer
obligation in the Chunk docstring.

Memory now stays flat for large creates on pypy (~160 MB for a
multi-GB create instead of linear growth).

Also fix a pre-existing AttributeError in transfer --rechunk
--dry-run: Chunk namedtuples have no .size attribute, all-zero
chunks need chunk.meta["size"].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
new BORG_UNITS env var: si / iec / raw size formatting, #5513

BORG_UNITS determines how borg formats sizes in human-readable output:

- si (default): decimal units, e.g. 1.23 MB (1kB = 1000B)
- iec: binary units, e.g. 1.18 MiB (1KiB = 1024B)
- raw: exact byte counts, e.g. 1234567 B

raw is meant for scripts (e.g. monitoring) that want to parse sizes without
having to deal with scaled values and units. An invalid BORG_UNITS value is
warned about (once) and ignored.

BORG_IEC was removed, use BORG_UNITS=iec.

format_file_size now determines the units itself (via BORG_UNITS), so the iec
argument plumbing through Archive / Statistics / ArchiveFormatter / Cache /
ArchiveChecker / ArchiveGarbageCollector and use_iec_units() are all gone.
Beside being less code, this also makes the setting effective at the call
sites that never passed iec=use_iec_units(), e.g. borg repo-space.

Merge pull request #9975 from ThomasWaldmann/repeated-chunks-1678

extract: avoid refetching/reparsing repeated chunks, fixes #1678
fetch_many: serve all-zero chunks without repository access, #1678

The holes of a sparse file produce long runs of references to the same
all-zero chunk. Instead of fetching that chunk from the repository over
and over, detect it and serve it directly from the zeros constant.

Detection compares the chunk id against the id of an all-zero chunk of
the same size, memoized in the already existing zero_chunk_ids mapping
(now shared with the create side via the new zero_chunk_id() function).
To bound the memoized id computations to a few chunk sizes, they are
only done for ids occurring repeatedly within the requested stream -
a repeated id means repeating plaintext, which usually is a run of
zeros. Unique ids are only compared against already memoized sizes.

Extracting a file with a 256 MiB hole now does not access the
repository at all for the hole (before: 63 object fetches).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

fetch_many: cache recently parsed chunks, #1678

A content data stream may reference the same chunk many times (e.g. the
all-zero chunks of a sparse file converge on the same ids). Previously,
every repetition was decrypted, authenticated and decompressed again.

Add a small LRU cache of recently parsed chunks to DownloadPipeline,
keyed on (id, ro_type), so repeated chunks are served from the cache.
The repository is still asked for every occurrence (a cheap cached-pack
slice for local repos, and it keeps the legacy remote repos' pipelining
in borg transfer intact), only the parsing work is skipped.

Extracting a file with a 256 MiB hole now parses the all-zero chunk
once instead of 63 times.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Merge pull request #9971 from ThomasWaldmann/analyze-set-dedup-5741

analyze: deduplicated size of a set of archives, #5741
info: drop stale deduplicated-size note from epilog

borg info no longer computes per-archive deduplicated sizes (Archive.calc_stats
sets usize=0 as it is expensive), and the output only shows the original size.
The epilog still explained "this archive vs all archives deduplicated size",
which no longer matches what the command prints.

Replace it with a note that deduplicated sizes are not shown here and point to
`borg analyze` (deduplicated size of a set of archives) and `borg compact --stats`
(repository-wide deduplicated size).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

analyze: deduplicated size of a set of archives, #5741

`borg analyze` now reports how much space a set of archives occupies and how much
deleting it would actually free. All figures come as source (uncompressed source
data) and stored (compressed, as stored in the repository) size, plus the
compression factor relating the two.

For the considered (matching) set of archives:

- deduplicated size of the set: the summed size of the union of chunks the set
  references (chunks shared within the set counted once),
- exclusive size of the set: the summed size of chunks referenced only by the set
  and by no other archive, i.e. what deleting the whole set would free,
- unreferenced chunks: chunks no non-deleted archive references, i.e. what
  `borg compact` could free in the current state of the repository.

Following ThomasWaldmann's proposal in #5741, chunks are flagged as referenced by
the considered set (C) and/or by the rest of the archives (R); the exclusive size
is sum(size(x) for x in C - R). Without an archive filter the considered set is all
archives, so everything referenced is trivially exclusive to it - the report is then
labelled as covering the whole repository and that line is left out.

`--by-name` decomposes the whole repository by archive name instead: one row per
name with what is exclusive to it, one row for the chunks shared by 2+ names and one
for the unreferenced ones. Every chunk is counted in exactly one row, so the rows
add up to the repository's deduplicated size. Archives sharing a name form a series,
so a name usually groups all backups of one source; old-style archives that do not
form a series have one name each and group just as well. This needs a single pass:
flag bits 5..22 hold the name that first referenced a chunk (as index + 1, so 0 means
unreferenced) and bit 23 marks chunks referenced by more than one name. As the shared
and unreferenced rows require looking at every archive, --by-name always covers the
whole repository and rejects archive filters.

Rather than building a second index, this reuses the repository's own chunk index:
it already holds every chunk's stored size (obj_size). The flag bits are OR-ed in and
the source size (0 in the repo index, it is only recorded in the archives referencing
a chunk) is filled in from the per-archive references cache, keeping the pack location
intact via _replace. The mutations never persist: write_chunkindex_to_repo zeroes flags
and size, and close() only serializes F_NEW entries, of which there are none here.

Chunk membership and source sizes come from the per-archive references cache that
`borg compact` maintains, so unchanged archives usually need not be opened at all.
Its helpers (get_archive_references and friends, ArchiveReferences) move from
archiver/compact_cmd.py to cache.py as free functions, so analyze and compact share
one implementation; compact keeps its previous behaviour.

The pre-existing directory hot-spot report is unchanged and still needs 2+ archives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Merge pull request #9946 from mr-raj12/pack-header-aad-binding

Bind pack object header into AEAD authentication
Drop unneeded envelope_header rename, fix inaccurate AAD size claim in docs

Merge pull request #9970 from ThomasWaldmann/re-add-xxh64-9935

re-add XXH64 to read borg 1.x integrity data, #9935
re-add XXH64 to read borg 1.x integrity data, #9935

XXH64 (and the XXH64FileHashingWrapper) were removed in #9672 / #9750,
which switched borg 2.x file integrity to SHA256. But borg 1.x wrote
XXH64 checksums into the repo index/hints integrity data, so we need
XXH64 to verify those files when reading a borg 1.x (legacy) repository
during `borg transfer`.

Rather than re-introducing the external "xxhash" PyPI package (and the
libxxhash system dependency on msys2 that #9750 dropped), add a small,
dependency-free, streaming XXH64 implementation in cython to
crypto.low_level. It is only used on the read path; borg 2.x native
repos keep using SHA256. XXH64 is non-cryptographic and must not be used
as a security mechanism.

Tests use the official xxHash sanity-check vectors (the test buffer
generator is transcribed verbatim from xxHash tests/sanity_test.c) and
cover all code paths, streaming vs one-shot, and a legacy XXH64
integrity round-trip through IntegrityCheckedFile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Merge pull request #9969 from borgbackup/fix-9949-legacy-date

legacy: support date: archive patterns for --from-borg1
Merge pull request #9968 from ThomasWaldmann/fix-9967-leap-year

fix calculate_relative_offset year offset from Feb 29
legacy: support date: archive patterns for --from-borg1

The date: archive matching pattern (#8776) was implemented for borg2
repos in manifest.py but not in the legacy code path used by
borg2 repo-list/transfer --from-borg1, so date: patterns matched no
archives when filtering legacy (borg 1.x) repos.

Add the same date: branch to LegacyArchives._matching_info_tuples,
mirroring the borg2 implementation.

Fixes #9949

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

fix calculate_relative_offset year offset from Feb 29, fixes #9967

Reusing offset_n_months for the year case naturally clamps the day to
the last day of the target month, so Feb 29 + Ny no longer crashes when
the target year is not a leap year.

Also preserve h/m/s/us in offset_n_months so year/month offsets keep the
time-of-day (previously dropped, now covered by tests).

Merge pull request #9954 from ThomasWaldmann/fuse-mount-acls-1042

fuse: expose POSIX ACLs on Linux mounts, fixes #1042
Rename key encrypt/decrypt header= param to aad=, tighten docs wording

Avoids a name collision with the low-level cipher's own header= param
and drops repetitive/negative-space phrasing in the packs.rst AAD section.

Tighten AAD/slot comments and add small robustness fixes from review

Merge pull request #9960 from ThomasWaldmann/fix-ci-warnings

CI: fix deprecation warnings
Merge pull request #9964 from borgbackup/dependabot/pip/requirements.d/pip-dependencies-5059dc7813

build(deps-dev): bump the pip-dependencies group in /requirements.d with 3 updates
build(deps-dev): bump the pip-dependencies group

Bumps the pip-dependencies group in /requirements.d with 3 updates: [tox](https://github.com/tox-dev/tox), [pre-commit](https://github.com/pre-commit/pre-commit) and [types-pyyaml](https://github.com/python/typeshed).

Updates `tox` from 4.55.1 to 4.56.1
- [Release notes](https://github.com/tox-dev/tox/releases)
- [Changelog](https://github.com/tox-dev/tox/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/tox/compare/4.55.1...4.56.1)

Updates `pre-commit` from 4.6.0 to 4.6.1
- [Release notes](https://github.com/pre-commit/pre-commit/releases)
- [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pre-commit/pre-commit/compare/v4.6.0...v4.6.1)

Updates `types-pyyaml` from 6.0.12.20260518 to 6.0.12.20260724
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: tox
  dependency-version: 4.56.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: pip-dependencies
- dependency-name: pre-commit
  dependency-version: 4.6.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: pip-dependencies
- dependency-name: types-pyyaml
  dependency-version: 6.0.12.20260724
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: pip-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Merge pull request #9962 from ThomasWaldmann/zstd-mt

use multithreaded zstd compression for big chunks, #9961
use multithreaded zstd compression for big chunks, #9961

libzstd can compress a single chunk with a thread pool, but borg never asked it
to: zstd.compress() was called without options, so nb_workers stayed 0.

Just setting nb_workers would not have helped either. zstd splits the input into
jobs sized from the window log, and that default job size swallows any chunk borg
produces whole, so the workers never engage - measured at 0.97x, i.e. slightly
slower than single-threaded. The job size has to be set explicitly.

libzstd refuses to use a job smaller than 512KiB, so we ask for exactly that: it
gives the most jobs and thus the most parallelism.

Small chunks are excluded, but not because they cannot be split - a 768KiB chunk
is happily cut into 512KiB + 256KiB. The problem is that such a split is very
uneven: every thread waits for the one full-size job, which takes about as long
as compressing the whole chunk single-threaded, and the thread overhead comes on
top. Measured on a 12-core machine over levels 1..10, that is a real loss:
0.80x..0.91x at 544KiB, 0.90x..1.07x at 608KiB, break-even around 640KiB, then
1.21x..1.38x at 768KiB. The cutoff is therefore 768KiB (= 1.5 jobs), which keeps
some margin over break-even because the thread overhead is machine dependent.

Measured on the same machine, zstd,3, 10GiB of compressible data in one file:
borg create 55.0s -> 33.8s (1.63x). Compressor alone: 2.61x for a 2MiB chunk,
4.16x for an 8MiB one.

This trades a little compression ratio for speed, because a job only sees its own
data: +0.05% archive size for 1MiB chunks at zstd,3, +0.64% for 8MiB ones, and
somewhat more at higher levels which rely on longer match history. It also uses
more cpu time in total (+22% for the 10GiB create) to reduce wallclock time.
BORG_ZSTD_MT_WORKERS=1 turns it off for those who prefer the smaller archive or
have to share the cpu with other work.

Note that the compressed bytes now depend on the worker count, so compressing the
same chunk twice on different machines can give different (equally valid) output.
Chunk ids are unaffected: they are computed on the plaintext before compression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

CI: fix deprecation warnings

- lint: bump astral-sh/ruff-action v3 -> v4.1.0.
  v3 runs on node20, which is deprecated and force-run on node24 now.
  v4 natively targets node24. No change in ruff version resolution.
  v4 uses immutable releases and publishes no floating major tag,
  so the exact version has to be pinned.

- vm_tests: stop using the deprecated "run" input of
  cross-platform-actions/action. Split the step into a "Start VM" step
  and a test step that uses the custom shell (shell: cpa.sh {0}), as
  recommended by the action. File sync (runner->vm before, vm->runner
  after) happens automatically, so the artifact / test-results.xml /
  coverage.xml uploads keep working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Merge pull request #9959 from ThomasWaldmann/blake3-mt

use multi-threaded blake3 for big chunks, #9958
use multi-threaded blake3 for big chunks, #9958

blake3 can hash a single input in parallel, but borg always used the
single-threaded path. Let id_hash use blake3's internal thread pool once the
input is big enough.

Multi-threading is not a win at every size: blake3 parallelises over its binary
hash tree, so the speedup peaks at powers of two and decays in between, and for
small inputs the thread dispatch costs much more than it saves. Measured on a
12-core machine (Apple M3 Pro): 0.16x at 8 KiB, still below 1x at 240 KiB, then
2.0x at 256 KiB, 3.2x at 512 KiB, 5.6x at 2 MiB and 7.3x at 8 MiB.

256 KiB was the smallest size that never lost there, so that is the default and
smaller chunks keep hashing single-threaded. Chunk sizes are content-defined, so
we can not rely on hitting the sizes that happen to parallelise well and have to
be conservative.

The optimal value depends on the core count and therefore differs per machine,
so BORG_BLAKE3_MT_THRESHOLD (in KiB) can override the default. It is evaluated
on first use and then cached: get_blake3_mt_threshold() runs for every chunk and
os.environ.get() costs ~425ns (~30% of id_hash for a 512B chunk), while the
cached call costs ~45ns. Evaluating it lazily rather than at import time also
means an invalid value is reported by borg's normal error handling instead of a
traceback while importing.

scripts/blake3-optimize-mt-threshold.py measures the best value for a machine:
it sweeps input sizes, verifies a candidate by probing the sizes that are
usually worst, re-measures the point that determines the threshold to keep
scheduling noise out, and optionally writes an HTML or SVG chart.

This only affects repos using --id-hash blake3. The resulting chunk ids are
unchanged - that single- and multi-threaded blake3 agree is the blake3
project's invariant, so there is no test for it here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Merge pull request #9940 from ThomasWaldmann/fix-macos-nfd-repo-relocation-2913

Fix false repo relocation warning on macOS due to NFC/NFD path differences (#2913)
Normalize unicode form of local repo paths to fix false relocation warning on macOS (#2913)

On macOS the filesystem returns paths in NFD (decomposed) form, while a
path typed as a command line argument is usually in NFC (composed) form.
These look identical but are byte-different, so accessing a repo via a
relative path / `.` / `$(pwd)` after `cd` made borg believe the repository
had been relocated and prompt for confirmation.

Add helpers.normalize_local_path() (NFD on macOS, no-op elsewhere) and run
local file locations through it in Location.canonical_path(). Also normalize
both sides of the location comparison in SecurityManager.assert_location_matches()
so existing NFC-form security "location" files keep matching after upgrade.

This only affects the repository *location* string used for the security /
relocation check (and its display); archived file paths are unaffected, and
the change is a no-op on non-macOS platforms.

Merge pull request #9944 from ThomasWaldmann/webdav

webdav: serve archives via WebDAV / HTTP, fixes #9942
webdav: address review comments

- docs: say 50 MB instead of 47 MiB for the Windows download limit (same size,
  friendlier unit).
- docs: drop the note about Windows refusing Basic auth over plain HTTP - the
  server has no authentication, so it does not matter.
- docs: drop the two notes about large directories being slow - that is expected
  behaviour, not a WebDAV issue worth documenting.
- use a separate line per method for the "not allowed" HTTP method assignments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

webdav: find names that differ only in Unicode normalization

macOS decomposes file names, so Finder (WebDAVFS) requests the NFD form
"gru<combining diaeresis>ße.txt" of a name the archive stores composed as
"grüße.txt". Looking children up verbatim answered 404 for a file the client had
just seen in our listing - in Finder: "the file can't be found" on double click.

Look up path segments (and archive names) exactly first, and only if that fails
retry by comparing NFC-normalized forms. The normalization index is built lazily,
per directory, so only trees that actually get such a request pay for it, and
names that are ambiguous after normalization (several children sharing one NFC
form, possible on file systems that keep both spellings) are excluded from the
index - those still require the exact spelling, so we never serve a different
file than requested.

resolve() now also returns the canonical (as stored) segments, and the handler
uses them from there on, so path-based operations - notably the tar download,
which matches item paths - work when the request used another normalization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Merge pull request #9952 from ThomasWaldmann/remove-remote-path-option

remove --remote-path, --rsh, --upload-ratelimit, --upload-buffer, --iec and --debug-profile options
fuse: test the ACL xattr emulation without a mount

The error paths of the ACL xattr emulation (no such ACL -> ENOATTR,
unconvertible ACL -> EIO) are not reachable via a real mount, so they were
not covered. Add tests that call listxattr/getxattr directly with a fake
item - these also work where the kernel does not offer ACL xattrs on FUSE
mounts (mounts made inside a user namespace).

Also decide once, at import time, whether the ACL xattrs can be emulated:
ACL_XATTRS is empty if not, so the handlers do not need to check is_linux.

fuse: expose POSIX ACLs on Linux mounts, fixes #1042

The FUSE mount did not expose the archived ACLs at all, so tools reading
from a mounted archive (getfacl, rsync -A, cp --preserve=all, ...) did not
see them.

On Linux, POSIX ACLs are exposed by the kernel via the special
system.posix_acl_access / system.posix_acl_default xattrs, which use a
binary format (see linux/include/uapi/linux/posix_acl_xattr.h): a 4 byte
little-endian version header, followed by 8 byte entries of
{__le16 e_tag, __le16 e_perm, __le32 e_id}.

Add acl_text_to_xattr() to convert from our stored ACL text representation
(acl_access / acl_default) to that binary format, honouring numeric_ids the
same way borg extract does, and serve these xattrs from listxattr/getxattr
in both FUSE implementations (fuse.py for llfuse/pyfuse3, hlfuse.py for
mfusepy).

Note that a previous attempt at this (#8843) returned libacl's internal
acl_t structure (via acl_size), which is not the kernel's xattr wire
format - that is why it did not work. The new conversion is tested against
what the kernel itself produces: acl_text_to_xattr() output must be byte
identical to the system.posix_acl_access xattr the kernel returns for the
same ACL.

The ACLs are exposed read-only and are not enforced for permission checks
(we do not negotiate FUSE_POSIX_ACL). Also note that since kernel 6.2, the
kernel refuses ACL xattr passthrough for FUSE mounts whose superblock lives
in a non-initial user namespace (e.g. rootless containers) unless
FUSE_POSIX_ACL was negotiated, which none of the Python FUSE bindings
support - the test skips in that case.

docs: fix name of the profile conversion command

The command is "borg debug convert-profile", not "profile-convert".
Also, scripts/msgpack2marshal.py does not exist any more - that command
does the same job.

remove --debug-profile option, use BORG_DEBUG_PROFILE

Set BORG_DEBUG_PROFILE to a filename to get an execution profile written
there; a ".pyprof" suffix still selects the Python-compatible format.

Note that this now applies to EVERY borg invocation while the variable is
set, not just to a single command like the option did - the docs say so and
the test switches it off around the "borg debug convert-profile" call, which
would otherwise profile itself over the profile it is reading.

Add slot tags to meta/data AAD, TODO for exception class split

remove --iec option, use BORG_IEC

Unlike the other options removed recently, --iec did work for borg2 repos -
but the same setting is better placed in the environment: it is a display
preference one usually wants for all borg invocations, not per command.

BORG_IEC=yes now selects IEC units (1KiB = 1024B); "true" and "1" are
accepted, too, because jsonargparse's automatic BORG_<OPTION> environment
variables accepted these for --iec. Note that removing the option also
removes that automatic environment variable, so the new use_iec_units()
helper reads BORG_IEC explicitly.

The iec=... plumbing through Archive, Statistics, ArchiveFormatter and Cache
is unchanged, only the places reading args.iec now call use_iec_units().

docs: no bandwidth limiting options any more

--remote-ratelimit was not replaced by --upload-ratelimit for long: that one
is gone now, too. Fix the upgrade note and the FAQ, which still told users to
use the (long removed) --remote-ratelimit option.

fish completions: drop the stale --socket option

borg has no --socket option (any more), so do not complete it.

remove --upload-ratelimit and --upload-buffer options

Both were only ever read by the legacy (borg 1.x, ssh://) remote repository
code, so they had no effect on borg2 repositories - borgstore does not do
rate limiting or upload buffering at all.

For the legacy code path they are not worth keeping either: we only support
transferring FROM borg1 repos, so there is not much traffic in the upload
direction of a borg1 repo anyway.

Removes the SleepingBandwidthLimiter class with them; the only thing left
from it is write_to_fd(), which turns a BrokenPipeError into the nicer
ConnectionBrokenWithHint. Without an upload buffer size limit, the "queue
more calls" condition simplifies to "the send buffer is empty".

remove --rsh option, use BORG_RSH

Like --remote-path, --rsh was only honoured on the legacy (borg 1.x, ssh://)
code path. For borg2 repositories the remote shell command comes from
borgstore, which reads its own BORGSTORE_RSH environment variable, so --rsh
silently had no effect there.

Remove the option; BORG_RSH is now the only way to set the remote shell
command. For borgstore-based repositories, borg gives the BORG_RSH value to
borgstore as BORGSTORE_RSH, except if that was set explicitly (then the user
wants a different command for borgstore).

borg benchmark crud does not need to inherit --rsh / --remote-path into its
sub-invocations any more (environment variables are inherited anyway), so the
parse_args wrapper is gone.

docs: BORG_REMOTE_PATH is the replacement for --remote-path

remove --remote-path option, use BORG_REMOTE_PATH

The --remote-path option was only honoured on the legacy (borg 1.x, ssh://)
code path in legacy/remote.py. For borg2 rest:// repositories, the remote
borg is determined by rest_serve_command(), which only ever looked at the
BORG_REMOTE_PATH environment variable - so giving --remote-path silently had
no effect and the far end failed with "command not found: borg".

Rather than plumbing args into rest_serve_command(), drop the option
completely and use BORG_REMOTE_PATH consistently for both repo types. Also
apply replace_placeholders() to it for rest:// repos, as the legacy code
path already did.

webdav: abort when a non-empty item has no chunks list

If an archive item has size > 0 but no chunks list (e.g. corrupted metadata),
we already sent Content-Length > 0 (or, for a tar member, a header declaring
that size), so returning without a body would leave the client waiting forever
for bytes that never come (or produce a silently corrupt tar member).

Detect this anomaly in both _send_file and _send_tar_content and abort the
connection - the same "never serve silently corrupted data" handling used for a
chunk that is missing in the repository. Empty files (size == 0, no chunks) and
HEAD requests keep returning an empty body as before.

Add a test that injects a non-empty, chunk-less node and checks the download is
aborted (the client sees a short read, not a hang).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

webdav: keep the query string when redirecting to add a trailing slash

A GET on a directory URL without the trailing slash (e.g. /test/input?tar=1)
redirects with a 301 to the slashed form. _redirect_to_dir rebuilt the Location
from the path segments only, dropping the query string - so /test/input?tar=1
redirected to /test/input/ and the tar download turned into a plain listing.

Preserve the query in the redirect target. The query is already percent-encoded
by the client and still passes through strip_crlf(), so it cannot split the
response.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

webdav: TCP_NODELAY and output buffering at the HTTP level

Two standard HTTP-server tunings for a server that answers many small requests:

- disable_nagle_algorithm (TCP_NODELAY): Nagle's algorithm interacting with the
  client's delayed ACKs can add ~40 ms to a small request/response over a real
  network. WebDAV clients issue lots of small PROPFIND/HEAD/GET requests, so this
  matters for mounted use (no effect on loopback, where the benchmark is flat).
- wbufsize: buffer the response so the several small writes of one response
  (status line, headers, HTML rows, tar block framing) coalesce into few
  packets/syscalls instead of one send() each; writes larger than the buffer
  (file/tar chunk data) still pass straight through, and handle_one_request()
  flushes wfile after every request so nothing is delayed.

HTTP/1.1 keep-alive (connection reuse) was already enabled. Behaviour is
unchanged; all tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Merge pull request #9951 from PhrozenByte/docs-return-codes-followup

Fix return code docs
Fix return code docs

Merge pull request #9945 from PhrozenByte/docs-return-codes

Refactor return code docs
Refactor return code docs

Bind pack object header into AEAD authentication

Authenticate magic, version and chunk_id as AAD so a tampered
header byte fails decryption instead of passing through silently.
Adds a version gate so existing objects stay readable.

webdav: document known WebDAV client issues

Add a "Client notes and known issues" section to the webdav docs covering the
common client-side quirks of a read-only WebDAV server: the Windows Explorer
~47 MiB download limit / WebClient service / Basic-auth-over-HTTP restriction,
Finder writing (and harmlessly failing to write) .DS_Store/AppleDouble/.Trash,
davfs2 needing use_locks 0, and the protocol-level notes (Depth: infinity
refused, trailing-slash redirect, symlinks/special files and metadata only via
the ?tar download).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

webdav: make header CR/LF sanitization explicit (response splitting)

CodeQL keeps flagging the Content-Disposition and Location headers as
py/http-response-splitting. The values are already newline-free (the
Content-Disposition fallback replaces non-printables, the RFC 8187 name and the
Location are percent-encoded), but that is not visible to the analyser, and
borg's CodeQL runs as advanced setup which does not honour inline
"# codeql[...]" suppression comments (the marker sat right on the flagged line).

Route every header value that derives from a client-supplied path through a
small strip_crlf() helper right at the sink. It is a no-op by construction, but
makes the CR/LF safety explicit as an actual sanitizer instead of relying on a
suppression comment. Drop the now-misleading "# codeql[...]" markers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

webdav: test the command itself, special files and request limits

Raise patch coverage by exercising code paths the existing tests missed:

- test_webdav_command_serves_and_stops runs the actual `borg webdav` command
  in the foreground, checks it serves a request, stops it with SIGTERM and
  confirms the repository lock was released (a following borg command works).
  This covers do_webdav(), which nothing exercised before.
- test_webdav_special_files (POSIX) backs up a fifo and checks it is listed
  but not downloadable, refused with 403 on GET, hidden from WebDAV, and
  included in a ?tar download as a fifo entry.
- extend test_webdav_errors with a GET on a symlink (403) and an oversized
  PROPFIND body (413, rejected via Content-Length without reading the body).
- extend the tar test with a ?tar download at the archive root (whole archive,
  no path prefix stripped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

webdav: don't link the current directory in the breadcrumb heading

In the breadcrumb path, only the parent segments need to be links (for
navigating up); the last segment is the directory being viewed, so show it as
plain text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

webdav: sanitize + suppress response-splitting on the tar Content-Disposition

The tar download's file name is derived from the client-supplied URL path and
flows into the Content-Disposition header, so CodeQL flags it as
py/http-response-splitting - like the plain-download and redirect headers before
it. The value is already sanitized: _content_disposition() strips non-printables
(killing CR/LF) for the fallback name and percent-encodes the RFC 8187 name. Put
the header on a single line so the # codeql[py/http-response-splitting] marker
sits on the flagged line (it was on the wrapped closing-paren line before),
matching the existing suppressions, and extend the header-injection test to a
?tar download of a directory whose name contains CR/LF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

webdav: download any directory as a (PAX) tar archive

Add a per-directory tar download: append ?tar (or ?tar=1) to a directory URL,
or click the download icon shown next to the heading in the browser listings.
Unlike a plain file download, the tar preserves POSIX metadata (owner, group,
mode, sub-second timestamps, symlinks, special files, xattrs, ACLs), so it is
the metadata-lossless way to restore a whole directory tree over the server.
The tar is rooted at the requested directory (paths above it are stripped);
?tar on the archive root exports the whole archive.

The tar size is not known in advance (PAX header sizes vary), so it is streamed
with chunked transfer encoding. Repository access (item iteration and chunk
fetching) is serialized under repo_lock, but each chunk is written to the client
outside the lock: a slow client cannot block other requests, and the
LockRefresher can keep the repository lock alive during a long download (holding
the lock for the whole stream would starve it and let the lock go stale). A
missing chunk leaves the chunked stream unterminated and closes the connection,
so the client detects the truncation instead of receiving a corrupt archive.

To avoid duplicating the borg-item -> tar mapping, item_to_tarinfo() and
item_to_paxheaders() are factored out of TarMixIn._export_tar to module level in
tar_cmds.py (behaviour-preserving; item_to_tarinfo now returns needs_content and
the caller builds the content stream). webdav writes the tar blocks itself
(TarInfo.tobuf for the header, chunk data, 512-byte padding, end-of-archive
marker) so it can control the per-chunk locking.

Verified end-to-end with the system tar tool: modes, symlink targets and a
multi-chunk file are preserved byte-identically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

webdav: parse PROPFIND with expat, drop xml.etree.fromstring

Parse the PROPFIND request body with expat directly instead of
xml.etree.ElementTree.fromstring(). This keeps the same, encoding-proof DTD
rejection (expat's StartDoctypeDeclHandler fires before any entity can be
declared, so entity expansion / "billion laughs" is impossible), but removes
the xml.etree parsing sink entirely - the CodeQL py/xml-bomb query only
recognises defusedxml as safe and cannot see the DTD rejection, so it kept
flagging the fromstring() call even though the code was not vulnerable.

xml.etree is still imported, but now only to *build* the response XML (which
is not an XML-bomb sink), so its bandit B405 nosec stays and the B314 nosec on
fromstring is gone. No new dependency. Behaviour is unchanged: valid
allprop/propname/prop bodies parse identically and the existing UTF-8/UTF-16
bomb and garbage-body tests still get a 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

webdav: test non-ASCII file name percent-encoding on all platforms

The FUNNY_NAME test file contains "<>" (and CR/LF for the injection test),
which are illegal in Windows file names, so it is skipped on Windows - which
left the Windows CI job exercising no non-ASCII file name at all. Add
UNICODE_NAME ("grüße.txt"), which is legal on every platform, and assert its
percent-encoding in the browser listing link, the download URL round-trip and
the PROPFIND href, unconditionally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

webdav: daemonize by default, like borg mount

Add a -f/--foreground flag; without it, the command daemonizes and runs
in the background, matching borg mount. Bind the socket in the foreground
first (so bind errors like "port in use" are reported before forking, and
the listening fd survives the fork), then migrate the repository lock to
the forked process. The LockRefresher and server threads are started after
the fork, since threads do not survive fork().

The daemon stops on SIGTERM (the usual way to stop a daemon) or SIGINT,
shutting down cleanly and releasing the repository lock.

Daemonizing needs os.fork(), which does not exist on Windows; there the
command stays in the foreground (borg webdav is meant to be usable on
Windows, unlike borg mount).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

webdav: silence bandit B405/B314 for the (guarded) XML parse

The CI "security"…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant