nova-cache-0.8.0.0: src/NovaCache/NAR/Stream.hs
-- | Incremental NAR parsing: a pure, chunk-fed event machine.
--
-- "NovaCache.NAR" parses a NAR held whole in memory; consumers realize
-- entire archives to walk them, and nova-nix's substituter documents
-- the resident-set spike that costs on large paths. This module parses
-- the same grammar incrementally: feed chunks as they arrive - from a
-- download, a decompressor - and act on events as they complete.
-- Regular-file contents pass through as slices of the fed chunks, so
-- memory is bounded by the largest structural wire string
-- ('maxWireStringBytes'), never by archive or file size.
--
-- The grammar lives here once: 'NovaCache.NAR.deserialise' is the
-- whole-input instantiation of this machine, and the serialiser draws
-- its wire vocabulary from the exports below, so the two directions
-- cannot drift apart.
module NovaCache.NAR.Stream
( -- * Events
NarEvent (..),
-- * The machine
NarStep (..),
narStream,
narStreamBounded,
maxWireStringBytes,
-- * Entry-name safety
checkEntryName,
-- * Wire vocabulary (shared with the serialiser in "NovaCache.NAR")
tokMagic,
tokLParen,
tokRParen,
tokType,
tokRegular,
tokDirectory,
tokSymlink,
tokContents,
tokTarget,
tokExecutable,
tokEntry,
tokName,
tokNode,
narAlignment,
narPad,
narPadOf,
)
where
import Data.Bits (shiftL, (.&.), (.|.))
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8
import Data.Word (Word64)
import NovaCache.SafeName (hasTrailingDotOrSpace, isReservedDeviceName)
-- ---------------------------------------------------------------------------
-- Wire vocabulary
-- ---------------------------------------------------------------------------
-- | Archive framing: the magic header, node delimiters, and the type
-- keyword.
tokMagic, tokLParen, tokRParen, tokType :: ByteString
tokMagic = "nix-archive-1"
tokLParen = "("
tokRParen = ")"
tokType = "type"
-- | The three node kinds.
tokRegular, tokDirectory, tokSymlink :: ByteString
tokRegular = "regular"
tokDirectory = "directory"
tokSymlink = "symlink"
-- | Regular-file and symlink field keywords.
tokContents, tokTarget, tokExecutable :: ByteString
tokContents = "contents"
tokTarget = "target"
tokExecutable = "executable"
-- | Directory-entry keywords.
tokEntry, tokName, tokNode :: ByteString
tokEntry = "entry"
tokName = "name"
tokNode = "node"
-- | Alignment boundary for NAR wire strings.
narAlignment :: Int
narAlignment = 8
-- | Compute padding to reach the next 8-byte boundary.
narPad :: Int -> Int
narPad len =
let remainder = len .&. (narAlignment - 1)
in if remainder == 0 then 0 else narAlignment - remainder
-- | 'narPad' over the wire's own length type, for sizes that may not
-- fit 'Int'. The result is a padding count, so it always does.
narPadOf :: Word64 -> Int
narPadOf len =
let remainder = len .&. fromIntegral (narAlignment - 1)
in if remainder == 0 then 0 else narAlignment - fromIntegral remainder
-- | Size of the length prefix preceding every wire string.
lengthPrefixBytes :: Int
lengthPrefixBytes = 8
-- ---------------------------------------------------------------------------
-- Events
-- ---------------------------------------------------------------------------
-- | One structural step of an archive. A node unfolds as either
--
-- @'EventRegularBegin' ('EventRegularChunk'*) 'EventRegularEnd'@,
-- an 'EventSymlink', or
-- @'EventDirectoryBegin' entry* 'EventDirectoryEnd'@ where each entry
-- is @'EventEntryBegin' node 'EventEntryEnd'@.
data NarEvent
= -- | A regular file opens: executable flag and its declared
-- contents size, known up front from the wire length prefix.
EventRegularBegin !Bool !Word64
| -- | One slice of regular-file contents, in order. Slices are
-- substrings of the fed chunks (no copying); their lengths sum to
-- the declared size.
EventRegularChunk !ByteString
| -- | The regular file's contents and padding are fully consumed and
-- its node is closed.
EventRegularEnd
| -- | A complete symlink node: the target, as the raw bytes the
-- archive carries.
EventSymlink !ByteString
| EventDirectoryBegin
| -- | An entry opens under the innermost open directory. The name
-- has already passed 'checkEntryName', order included.
EventEntryBegin !ByteString
| EventEntryEnd
| EventDirectoryEnd
deriving (Eq, Show)
-- | The machine's outward face. Drive it by pattern matching: hand
-- 'NarAwait' the next chunk (the empty string means end of input, the
-- same convention as 'NovaCache.Store.writeNarStreaming' consumes), and
-- read events off 'NarYield' as they complete. 'NarDone' confirms the
-- archive ended exactly at the root node's close; anything else that
-- can go wrong is a 'NarFail'.
data NarStep
= NarAwait !(ByteString -> NarStep)
| NarYield !NarEvent NarStep
| NarDone
| NarFail !String
-- | The bound 'narStream' places on structural wire strings - tokens,
-- entry names, symlink targets; never file contents, which stream
-- through unaccumulated. Real names fit a filesystem's 255-byte
-- component limit and targets its path limit, so 64 KiB is generous;
-- without some bound a hostile length prefix could demand an
-- arbitrary-size allocation from one 8-byte read.
maxWireStringBytes :: Word64
maxWireStringBytes = 65536
-- | The parser, positioned at the start of an archive, holding
-- 'maxWireStringBytes' over structural strings.
narStream :: NarStep
narStream = narStreamBounded maxWireStringBytes
-- ---------------------------------------------------------------------------
-- Parser
-- ---------------------------------------------------------------------------
-- | A parse state waiting for its share of the input: apply it to the
-- unconsumed bytes to proceed.
type Continue = ByteString -> NarStep
-- | 'narStream' with the structural-string bound explicit.
-- 'NovaCache.NAR.deserialise' passes its whole input's length - a
-- string cannot outgrow its container, so the strict parser accepts
-- exactly what it always accepted - while streaming callers keep the
-- documented default.
narStreamBounded :: Word64 -> NarStep
narStreamBounded bound =
expectWire limited "archive magic" tokMagic (parseNode limited archiveEnd) BS.empty
where
-- Declared lengths are compared in Word64 and narrowed only below
-- the bound, so the bound itself must fit Int for the narrowing to
-- be exact.
limited = min bound (fromIntegral (maxBound :: Int))
archiveEnd leftover
| BS.null leftover = NarAwait confirm
| otherwise = NarFail trailingBytes
confirm chunk
| BS.null chunk = NarDone
| otherwise = NarFail trailingBytes
trailingBytes = "trailing bytes after NAR root node"
-- | Parse one node and continue.
parseNode :: Word64 -> Continue -> Continue
parseNode bound k =
expectWire bound "node opening" tokLParen
$ expectWire bound "type keyword" tokType
$ wireString bound "node type" dispatch
where
dispatch kind
| kind == tokRegular = parseRegular bound k
| kind == tokSymlink = parseSymlink bound k
| kind == tokDirectory = parseDirectory bound k
| otherwise = failWith ("unknown NAR entry type: " ++ show kind)
-- | Parse a regular file node: optional executable flag, then contents
-- streamed through as chunk events.
parseRegular :: Word64 -> Continue -> Continue
parseRegular bound k = wireString bound "regular-node keyword" body
where
body tok
| tok == tokExecutable =
-- The format fixes the executable marker's value as the
-- empty string; upstream rejects a nonempty value.
wireString bound "executable marker" $ \marker ->
if BS.null marker
then expectWire bound "contents keyword" tokContents (contentsOf True)
else failWith ("executable marker must be empty, got: " ++ show marker)
| tok == tokContents = contentsOf False
| otherwise =
failWith ("expected 'executable' or 'contents' in regular, got: " ++ show tok)
contentsOf isExec = exactly lengthPrefixBytes "length of file contents" (withSize isExec)
withSize isExec lenBytes leftover =
let size = word64LE lenBytes
in NarYield
(EventRegularBegin isExec size)
(streamContents size (afterContents size) leftover)
afterContents size =
exactly (narPadOf size) "file contents padding" $ \padding ->
if BS.any (/= 0) padding
then failWith nonzeroPadding
else expectWire bound "node closing" tokRParen $ \leftover ->
NarYield EventRegularEnd (k leftover)
-- | Yield contents slices until the declared size is consumed. Slices
-- are substrings of the fed chunks; nothing accumulates.
streamContents :: Word64 -> Continue -> Continue
streamContents remaining k leftover
| remaining == 0 = k leftover
| BS.null leftover = NarAwait feed
| otherwise =
let sliceLen = fromIntegral (min remaining (fromIntegral (BS.length leftover)))
(slice, rest) = BS.splitAt sliceLen leftover
in NarYield
(EventRegularChunk slice)
(streamContents (remaining - fromIntegral sliceLen) k rest)
where
feed chunk
| BS.null chunk = NarFail "unexpected end of NAR: file contents"
| otherwise = streamContents remaining k chunk
-- | Parse a symlink node. The target is carried verbatim: upstream
-- imposes no text encoding on it.
parseSymlink :: Word64 -> Continue -> Continue
parseSymlink bound k =
expectWire bound "target keyword" tokTarget $
wireString bound "symlink target" $ \target ->
expectWire bound "node closing" tokRParen $ \leftover ->
NarYield (EventSymlink target) (k leftover)
-- | Parse a directory node: entries validated name by name as they
-- open, so a consumer can act on each entry before the next arrives.
parseDirectory :: Word64 -> Continue -> Continue
parseDirectory bound k leftover =
NarYield EventDirectoryBegin (entries Nothing leftover)
where
entries prev = wireString bound "directory token" (branch prev)
branch prev tok
| tok == tokRParen = NarYield EventDirectoryEnd . k
| tok == tokEntry =
expectWire bound "entry opening" tokLParen
$ expectWire bound "name keyword" tokName
$ wireString bound "entry name" (named prev)
| otherwise = failWith ("expected 'entry' or ')' in directory, got: " ++ show tok)
named prev entryName = case checkEntryName prev entryName of
Left err -> failWith err
Right () ->
NarYield (EventEntryBegin entryName)
. expectWire bound "node keyword" tokNode (parseNode bound (closeEntry entryName))
closeEntry entryName =
expectWire bound "entry closing" tokRParen $ \leftover2 ->
NarYield EventEntryEnd (entries (Just entryName) leftover2)
-- ---------------------------------------------------------------------------
-- Entry-name safety
-- ---------------------------------------------------------------------------
-- | Reject a NAR directory entry name that is unsafe or out of order
-- against its predecessor. Entries must have safe names in strictly
-- increasing (sorted, unique) byte order: enforcing this rejects
-- malformed or hostile archives, keeps @serialise . deserialise@ an
-- identity, and forecloses the path-traversal surface for any
-- NAR-extraction consumer. Names are arbitrary bytes; every check
-- here is ASCII-structural, so it stays exact whether or not the name
-- decodes as text (see "NovaCache.SafeName").
checkEntryName :: Maybe ByteString -> ByteString -> Either String ()
checkEntryName prev name
| BS.null name = Left "empty NAR directory entry name"
-- Backslash is a directory separator on Windows - this library's
-- primary consumer - so a name like "..\out.exe" is as much a
-- traversal vector as one with '/'. A colon is a drive prefix
-- ("C:evil") or an NTFS alternate data stream ("a:b"), either of
-- which resolves the write somewhere other than a file of this name.
| name == "." || name == ".." || BS8.any (\c -> c == '/' || c == '\\' || c == '\0' || c == ':') name =
Left ("unsafe NAR directory entry name: " ++ show name)
-- Windows-unsafe categories, shared with the store-key allowlist
-- (NovaCache.SafeName): a device name resolves to the device, and
-- NTFS strips a trailing dot or space so the on-disk name would
-- silently diverge from the NAR name.
| isReservedDeviceName name =
Left ("Windows reserved device name as NAR directory entry: " ++ show name)
| hasTrailingDotOrSpace name =
Left ("NAR directory entry name ends with a dot or space: " ++ show name)
| Just p <- prev,
name <= p =
Left ("NAR directory entries not strictly increasing: " ++ show name)
| otherwise = Right ()
-- ---------------------------------------------------------------------------
-- Chunk-fed primitives
-- ---------------------------------------------------------------------------
-- | Read one whole wire string - length prefix, payload, zero padding -
-- refusing a declared length over the bound before allocating for it.
-- For structural strings only; file contents go through
-- 'streamContents'.
wireString :: Word64 -> String -> (ByteString -> Continue) -> Continue
wireString bound what k = exactly lengthPrefixBytes ("length of " ++ what) withLength
where
withLength lenBytes =
let declared = word64LE lenBytes
in if declared > bound
then
failWith
( what
++ ": declared length "
++ show declared
++ " exceeds the "
++ show bound
++ "-byte wire-string bound"
)
else
-- Safe narrowing: declared <= bound, and narStreamBounded
-- clamps every bound to Int's range.
let len = fromIntegral declared
in exactly (len + narPad len) what $ \whole ->
case BS.splitAt len whole of
(payload, padding)
-- Nix's reader rejects nonzero padding; accepting it
-- would let archives that upstream tooling refuses
-- round-trip through this library.
| BS.any (/= 0) padding -> failWith nonzeroPadding
| otherwise -> k payload
-- | Read a wire string and require an exact token.
expectWire :: Word64 -> String -> ByteString -> Continue -> Continue
expectWire bound what expected k = wireString bound what check
where
check got
| got == expected = k
| otherwise = failWith ("expected " ++ show expected ++ ", got " ++ show got)
-- | Demand exactly @n@ bytes, awaiting more chunks as needed, then
-- continue with them and the leftover. Held chunks concatenate once,
-- so pathological chunking costs linear work, not quadratic.
exactly :: Int -> String -> (ByteString -> Continue) -> Continue
exactly n what k leftover = go [leftover] (BS.length leftover)
where
go !heldRev !heldLen
| heldLen >= n =
case BS.splitAt n (BS.concat (reverse heldRev)) of
(taken, rest) -> k taken rest
| otherwise = NarAwait $ \chunk ->
if BS.null chunk
then NarFail ("unexpected end of NAR: " ++ what)
else go (chunk : heldRev) (heldLen + BS.length chunk)
-- | Fail from any position that still owes the machine a continuation.
failWith :: String -> Continue
failWith err _ = NarFail err
nonzeroPadding :: String
nonzeroPadding = "nonzero padding bytes in NAR string"
-- | Read a little-endian 'Word64' from an 8-byte string.
word64LE :: ByteString -> Word64
word64LE = BS.foldr accumulate 0
where
accumulate byte acc = (acc `shiftL` bitsPerByte) .|. fromIntegral byte
-- | Bits per byte, for the length-prefix fold.
bitsPerByte :: Int
bitsPerByte = 8