Repository navigation
ADR 005 Extraction policy
Status: Accepted (2026-10-07).
-
The installer downloads
.7zarchives and has to put their contents on disk.internal/extractis the policy layer for that step. The plan (§4) gives staging, atomic publish and free-space checks tointernal/filesystem, so this decision is about what one extraction is allowed to do, not where its result is published. -
A 7z name is a string the archive chose. The format separates segments with
/but carries\just as well, so a check that only knows/can be walked through (the pull request that added the reader records this). Nothing else in a name is trustworthy either: it may be absolute, hold.., name a Windows device, or normalise onto a path another entry already took. -
Real archives carry symlinks, and they are not decoration — a Qt SDK's shared libraries are named that way. Counted from the vendors' own archives at Qt 5.15.2 (
qtbase, the desktop SDKs, for each platform's default architecture):target archive entries symlink entries Windows ( win64_msvc2019_64)qtbase-Windows-Windows_10-MSVC2019-Windows-Windows_10-X86_64.7z3743 0 macOS ( clang_64)qtbase-MacOS-MacOS_10_13-Clang-MacOS-MacOS_10_13-X86_64.7z3721 44 Linux ( gcc_64)qtbase-Linux-RHEL_7_6-GCC-Linux-RHEL_7_6-X86_64.7z3861 42 A macOS framework bundle cannot be represented without its links, and neither macOS nor Linux needs a privilege to create one. Every one of those targets is a plain relative path — the Linux 42 are single names (
libQt5Core.so.5.15.2) and the macOS 44 areVersions/Current/...or5; none of the 86 holds a..segment and none is absolute. -
The decoder already enforces a memory budget when the archive is opened, and the per-item attribute word is now surfaced to Go: DOS attributes in the low 16 bits, and a Unix
st_modein the high 16, present only when the extension bit (0x8000) is set. A Windows-written archive sets DOS attributes and no mode at all.
-
The destination is a root the caller owns, and it has to be new and empty. The extractor refuses a path that is missing, is not a directory, is a symlink, or holds anything. That is what makes the writes safe without an
O_NOFOLLOWrace dance: with nothing in the tree, there is no link to be led through. It is also exactly how the staging directory will be used later — the caller makes one, extracts into it, and publishes. -
A name is validated segment by segment, then joined, then checked to still be under the root. A segment is refused if it is empty,
.,.., holds a control character or a character no Windows filename may, ends in a dot or a space, or is a Windows device name (CON,NUL,COM1…). The Windows rules apply on every host: a name Windows would reinterpret is suspect whatever the host is, and a rule that changed with the host would be one more thing to get wrong. The finalfilepath.Relcheck is belt and braces, not the only defence. -
Only regular files, directories and symlinks are created. A device, fifo or socket entry is refused rather than materialised. Files are created
O_EXCL(an entry must not land on one already there), left at0600until the mode the archive records goes on, and directories at0750until every child exists. Permissions come from the archive's Unix mode, masked to the low nine bits, so setuid, setgid and sticky are dropped: which bits a file carries is the archive's to say, which identities it runs as is not. -
A symlink is recreated, in a second pass, and only where the platform can always make one. Refusing links breaks a Linux or macOS install; writing them as ordinary files produces a file whose contents are a path, which fails later in a way that is hard to read. Links are made after every file and directory, so a link the archive made can never be the path a later entry is written through.
A target is held to the same rule as a name: it is a plain relative path, or it is refused. An absolute or drive-qualified target is refused, and so is a target holding a
.or..segment. Resolving the target and refusing it only when it lands outside the root — the weaker rule — accepts two spellings (foo/../barandbar) for one location, which is how an entry collision is created, and it leaves the extractor's rule as "wherever it ends up is safe" instead of "an entry is a legal relative path". Nothing is given up for the stricter rule: in the real archives measured above, none of the Linux 42 or the macOS 44 targets holds.., and none is absolute.On Windows a link entry is refused outright: whether Windows will create one depends on a machine setting, so the same archive would yield different trees on different machines, and the supported Windows archives contain none. Should that ever be relaxed, it follows the pattern already used for the pre-5.14 relocation hard error: an explicit switch, a forced warning, and a line in the manifest.
-
Limits are read from the metadata before a byte is decoded, and cancellation is checked between entries. Entry count, total decoded bytes, per-entry bytes and the largest solid block are all checked from the header, so a bomb is turned away where the cost is reading metadata rather than writing gigabytes; the bytes that actually arrive are counted against the same limits as they are written. Running out of the decoder's memory budget is reported as a limit failure, since that is what it is. The context is checked between entries, never inside a decode. Sizes are unsigned throughout, matching the format.
-
This layer decides; it does not publish. No staging directory is created, no rename, no fsync, no free-space check, no rollback. A failed extraction leaves the destination part-written, which is correct precisely because the destination is the caller's private staging area and is discarded, not repaired.
- A caller has to create the destination itself and hand it over empty. That is one
more step than "point it at a directory", and it is the price of not needing
O_NOFOLLOWhandling to be correct on three platforms. - Windows cannot install an archive that contains a symlink. No supported Qt archive does, so the cost is a refusal path that is never taken in practice.
- Directory modes are applied last, so an archive that marks a directory unwritable still extracts.
- A name is a legal relative path or it is refused, and a link target answers to the same rule: two spellings never reach one location, so an archive cannot make two entries collide on one path.
The reference tool extracts with py7zr (SevenZipFile(...).extractall(path=...),
pinned at >=1.1.3). Run against the same Linux qtbase archive at Qt 5.15.2,
py7zr 1.1.3 recreates all 42 links, with the same targets, and reports 3490 files —
the same result this extractor produces (3490 files, 329 directories, 42 links,
about 284 MB, in 2.3 s). There is therefore no difference to record in
docs/compatibility.md for link handling; the comparison is recorded here so it
does not have to be redone.
Publishing, rollback, free-space checks, --keep and archive-retention semantics,
_meta.7z-style package metadata, format dispatch by extension and magic bytes, and
any CLI wiring. Those belong to internal/filesystem or to a later change; nothing
here presumes their shape.
- Requiring the destination to be empty but following links. Rejected: it makes the safety property depend on a race, and the empty requirement already removes the case.
- Refusing all symlinks. Rejected by the counts above: it fails 44 entries of a macOS SDK and 42 of a Linux one.
-
Writing a symlink entry as an ordinary file. Rejected: it produces a library
file named
libQt5Core.sowhose contents are the textlibQt5Core.so.5.15.2, which fails at link time with a message that says nothing about the cause. - Creating links on Windows with a capability probe. Rejected: it makes the result depend on the machine, and the input it would serve does not occur.
- Checking link targets in the metadata pass. Not possible: a link's target is the entry's content stream, so it is only known after decoding. The two-pass order contains the risk instead.