Skip to content

WORM & object locking

Write Once Read Many – prevent accidental or malicious deletion.

Path glob WORM mode

Application-level WORM enforced by PutFS itself, scoped by path glob. 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.

# Globs that should be treated as write-once (comma-separated, fnmatch syntax)
PUTFS_WORM_GLOBS=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_GLOBS 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 glob 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.

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.

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.

Pair with WORM for full protection

Without PUTFS_WORM_GLOBS covering the same prefix, a bad PUT to a key that already holds correct content still truncates the existing file via O_TRUNC before the mismatch is detected. The bad bytes are then unlinked, but the original is gone too. Enable both settings together:

PUTFS_WORM_GLOBS=acme-corp/lake/**
PUTFS_WORM_STRICT=true
PUTFS_CHECKSUM_PATHS=^acme-corp/lake/(?P<sha256>[0-9a-f]{64})/blob$

With this layout, re-PUTs short-circuit at O_EXCL (409 strict / 204 lenient) before any truncation, and only fresh keys ever reach the hashing 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.

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