WORM & object locking
Write Once Read Many – prevent accidental or malicious deletion.
Path regex WORM mode
Application-level WORM enforced by PutFS itself, scoped by path regex. Useful when the underlying filesystem is not (or cannot be) made read-only – for example, a content-addressed data lake where path = checksum and re-uploads of the same key are expected.
# Paths that should be treated as write-once (comma-separated Python regexes,
# the same form as PUTFS_CHECKSUM_PATHS; a key is write-once if any pattern
# matches it via re.search)
PUTFS_WORM_PATHS=^acme-corp/lake/,^acme-corp/archive/
# Response when a PUT targets an existing WORM-matched key:
PUTFS_WORM_STRICT=false # default – return 204 No Content (lenient)
PUTFS_WORM_STRICT=true # return 409 Conflict (strict)
# Whether DELETE on a WORM-matched key is permitted:
PUTFS_WORM_ALLOW_DELETE=false # default – refuse with 403 Forbidden
PUTFS_WORM_ALLOW_DELETE=true # allow deletes on WORM paths
Lenient mode (default). A PUT on a key that matches PUTFS_WORM_PATHS and already exists returns 204 No Content without reading the request body. This is correct-by-construction for content-addressed paths (same path ⇒ same bytes) and lets sync clients re-upload idempotently with no error handling. First-time PUTs on the same path proceed normally.
Mid-upload response can stress some clients
Both lenient and strict mode answer the WORM-matched PUT while the client is still streaming the request body. This is legal HTTP/1.1 (RFC 9112 §9.6 – Tear-down): the RFC instructs clients to monitor for an early response and stop transmitting if they see one. Most recent HTTP clients implement this correctly; older or hand-rolled clients may not, and instead surface a write error (e.g. BrokenPipeError, ECONNRESET) before they read the response – manifesting as a spurious upload failure on a key that is in fact already stored (lenient) or correctly rejected (strict). The robust client-side fix is a HEAD before PUT on content-addressed paths so the body is never sent in the first place. If you can't change the client, test against your specific library before relying on this behavior in production.
Strict mode. Same conditions return 409 Conflict. Use this when paths are not content-addressed and a silent skip would hide client bugs.
Concurrent re-PUT while a write is in flight
Each PUT streams into a private temp file and is published atomically (a rename for plain keys, a create-only link for WORM keys), so a key only ever exists on disk as a complete, checksum-verified object. A re-PUT that races an in-flight write to the same key therefore can never observe a partial: it either wins the create itself (201) or, if the other writer commits first, gets the usual 409/204. There is no false success and no lock contention – an aborted or failed upload leaves only its own temp, never a half-written key.
In-flight temp files
While a PUT streams, its temp lives beside the target as .putfs-tmp.<random> in the same directory, so the atomic publish never crosses a filesystem boundary. Temp files are filtered from listings, and the .putfs-tmp. prefix is reserved – a key whose path uses it is rejected with 400. A hard crash (SIGKILL, power loss) can orphan a temp before it is published; reclaim them out of band, e.g. a cron find /srv/putfs -name '.putfs-tmp.*' -mmin +120 -delete with the age set comfortably above your longest expected upload so it never removes a live temp.
Delete protection (default on). DELETE on a WORM-matched key returns 403 Forbidden. Set PUTFS_WORM_ALLOW_DELETE=true to disable and fall back to OS-level protection (chattr +i, zfs readonly – see below) only.
Content-addressed verification
WORM alone proves a key is write-once; it doesn't prove the bytes underneath actually match a content-addressed URL. PUTFS_CHECKSUM_PATHS closes that gap by hashing the request body in flight and rejecting any PUT whose computed digest disagrees with the digest captured from the URL.
# Comma-separated Python regexes. A match must capture the expected digest
# in a named group whose name selects the algorithm (sha256, sha1, sha512).
PUTFS_CHECKSUM_PATHS=^acme-corp/lake/(?P<sha256>[0-9a-f]{64})/blob$
Patterns are compiled at startup, so a malformed regex fails fast – not on first request.
Regex portability and systemd escaping
The pattern is a Python re regex in the reference build. The Go and Rust ports compile it with RE2-family engines (regexp and the regex crate), which reject lookarounds ((?=...)) and backreferences (\1): patterns using those run only under Python and abort at startup on the ports. Named groups ((?P<sha256>...)) work in all three, so keep cross-build patterns to that subset.
Setting the value in a systemd Environment= line is a common trap: systemd unescapes C-style backslashes, so every regex backslash must be doubled (\d becomes \\d) or the whole line is rejected (Invalid syntax, ignoring) and the variable is left unset. An EnvironmentFile= with the value in single quotes, or a systemctl edit drop-in, lets you keep the pattern verbatim.
A body whose digest disagrees with the URL returns 422 Unprocessable Content and the file under the canonical key is unlinked. Operator misconfiguration (regex matched but no recognized algorithm group) returns 400 Bad Request.
Checksum paths are automatically write-once
PUTFS_CHECKSUM_PATHS is only safe paired with WORM. Without it, a bad PUT to a key that already holds correct content truncates the existing file via O_TRUNC before the mismatch is detected; the bad bytes are then unlinked, but the original is gone too. This is true even of a single sequential re-upload, not just under concurrency. Because there is no safe use for content-addressed validation without WORM, the checksum patterns are folded into the PUTFS_WORM_PATHS set automatically – every checksum'd key is write-once by construction, with no separate WORM entry required and nothing to keep in sync. Add PUTFS_WORM_PATHS only for prefixes that are not content-addressed:
With this layout, re-PUTs of an existing key answer 409 strict / 204 lenient without reading the body, and only fresh keys ever reach the hashing (and temp-write) path.
Per-object immutability
Linux immutable flag – survives rm:
# Lock
chattr +i /srv/putfs/acme-corp/legal/contract.pdf
# Unlock
chattr -i /srv/putfs/acme-corp/legal/contract.pdf
PutFS DELETE on an immutable file returns 403 Forbidden. The file remains intact.
Per-dataset read-only
# ZFS
zfs set readonly=on tank/putfs/acme-corp/archive
# Any filesystem – mount read-only
mount -o remount,ro /srv/putfs/acme-corp/archive
# Or recursively set immutable
chattr -R +i /srv/putfs/acme-corp/archive/
PutFS PUT and DELETE on read-only or immutable paths return 403 Forbidden. The data remains protected.
Legal hold (ZFS snapshot hold)
Prevent snapshot destruction:
# Create snapshot
zfs snapshot tank/putfs/acme-corp/legal@hold-2024
# Place hold (prevents destroy)
zfs hold legal_hold tank/putfs/acme-corp/legal@hold-2024
# Release hold
zfs release legal_hold tank/putfs/acme-corp/legal@hold-2024
Further reading
chattr– Linux file attributes- OpenZFS
readonlyproperty - OpenZFS
zfs hold