The local:// source type: files under one configured directory.
local://previews/track.wav names a path relative to AP_LOCAL_ROOT. The
root is deployment configuration — a bind mount, a volume, a directory on a
laptop — and it deliberately does not appear in the source's identity, so the
same relative path is the same variant whatever a deployment mounted it at.
When AP_LOCAL_ROOT is unset, local sources are disabled: nothing is
mounted, so nothing is served. The root is the allowlist for disk, which is
why this type has no allowlist of its own.
Confinement
Every hostile path is refused by authorize/1, and refused the same way —
{:error, :not_allowed}, which the HTTP layer renders as 404. Same status as
a missing file, on purpose: a distinct status would turn the root into an
existence oracle for the filesystem around it.
A hostile filesystem object is a different question, and one authorize/1
deliberately does not answer: a FIFO or a directory under the root is a
perfectly legitimate path. Those are refused by stat/1 and ffmpeg_input/1,
which are the two functions that actually touch the file — see The seam.
The order matters more than any single check:
AudioProxy.Sourcehas already decoded the source exactly once and refused control-class code points, NUL among them. A confinement check on a half-decoded string proves nothing, so it does not run on one.Path.safe_relative/2rejects absolute paths and any..that climbs out of the root, and normalizes.and interior..away.- The result is joined to the root and resolved link by link, because
safe_relativedoes not follow symlinks — apreviewssymlinked at/etcis a perfectly safe relative path. - The resolved path must still sit under the resolved root. This is the invariant, and the one the property test pins: whatever is accepted, its final path has the root as a prefix.
Nothing is normalized-and-continued. A path that fails any step is refused,
not repaired. Step 2 is stricter than step 4 in one respect worth knowing
about: OTP treats any absolute symlink target as unsafe, so a link
inside the root that spells its target absolutely is refused even though
the target is somewhere this type would happily serve. Relative links
inside the root work; absolute ones want a root pointed at the resolved
location instead.
parse/1 takes almost no part in this. It collapses empty and . segments —
lexical work that is safe under any filesystem, and stops one file from
wearing several cache keys — and otherwise hands the path through. It does
not touch .., and it leaves a leading / in place, so an absolute path
stays absolute and gets refused rather than quietly reinterpreted. Everything
that needs to know what is on disk stays in authorize/1, in one place, run
on every source including one a caller built by hand.
Two bounds are enforced before any of it: at most 64 path components and
1024 bytes. Path.safe_relative/2 is superlinear
in component count — a thousand components costs seconds of scheduler time on
one process — so the cap is a denial-of-service control.
The seam
stat/1 reads size and mtime and hashes them into ETag material.
ffmpeg_input/1 returns the resolved absolute path. Both re-run confinement
rather than trusting an earlier authorize/1, and both refuse anything that
is not a regular file: handing ffmpeg a FIFO is how you get a process that
blocks forever on a read that never completes, holding a render slot until
the timeout kills it.
What confinement does not cover
Confinement is defined over paths, and two things escape that definition. Both are deployment assumptions, not code defects, and both are documented in the README:
- Hardlinks. A hardlink inside the root pointing at an inode outside it is indistinguishable from an ordinary file to any path-based check.
- Time of check to time of use.
ffmpeg_input/1returns a path, and ffmpeg opens it a moment later. Anything that can rewrite the root in that window can swap the file for a symlink. Closing this needs the render pipeline to pass an already-open descriptor, which is tracked withadd-render-endpoint.
Both require write access to the root, which is the assumption to hold: mount it read-only, and do not let untrusted code write into it.
Summary
Functions
A short human-readable sentence for one of this type's own reasons.
Types
@type t() :: {:local, String.t()}
A local source: a path relative to AP_LOCAL_ROOT, as written.