diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
 # Changelog
 
+## 0.8.0.0 - 2026-08-20
+
+- **Bounded xz decompression returns: the new `NovaCache.Xz`, behind a manual, off-by-default `xz` flag.** 0.5.0.0 removed xz support for having no consumer; foreign-cache substitution (cache.nixos.org serves `.nar.xz`) is the consumer, and the decoder comes back shaped for untrusted input. The consumer knows the narinfo's declared NarSize before decompressing, so `decompress` takes that bound and fails past it - a small compressed input cannot expand to arbitrary memory ahead of the hash check - and the decoder's own state is capped too (`xzMaxDecoderMemoryBytes`; the dictionary size is an attacker-chosen number read from the stream header, and upstream passes no limit there). `withXzSource` decompresses a chunk source into a chunk source under the same limits, pairing with streaming NAR consumption. Concatenated streams decode as one output, matching upstream's `LZMA_CONCATENATED` decoder. The `lzma-static` dependency bundles liblzma's C sources, so the flag needs no system library on any platform - and it stays off by default anyway: the 0.5.0.0 lesson was a compression dependency nobody asked for in every install.
+- **Streaming NAR parsing: the new `NovaCache.NAR.Stream`.** A pure, chunk-fed event machine over the NAR grammar: feed chunks as they arrive (a download, a decompressor) and act on events as entries complete, with regular-file contents passing through as slices of the fed chunks. Memory is bounded by the largest structural wire string - names, targets, and tokens are capped at 64 KiB, since without a bound one hostile 8-byte length prefix could demand an arbitrary allocation - never by archive or file size, which is what let nova-nix's substituter document an RSS spike the size of the path being fetched. `deserialise` is now the whole-input instantiation of the same machine (its structural bound is the input's own length, so it accepts exactly what it always accepted): the grammar exists once, and the entire existing NAR test suite runs against the shared core.
+- **Streaming NAR serialisation: `NovaCache.NAR.withNarSource`.** Serialises a filesystem tree as a pull source of chunks without ever holding a file's contents in memory: structure (names, kinds, targets) is planned up front with the same case-hack resolution, bytewise entry order, and loud special-file/collision failures as `serialiseFromPath`, then regular files stream through 128 KiB reads. A file's size is read once when its streaming starts and exactly that many bytes are emitted, as upstream's dump does; a shrinking file fails loudly rather than emitting a torn archive. The empty-chunk end convention matches `writeNarStreaming`, so the two ends compose directly.
+- **Incremental hashing: `hashInit`/`hashUpdate`/`hashFinalize` in `NovaCache.Hash`.** Pure persistent SHA-256 contexts, so a consumer can hash a NAR in the same pass that streams it and verify a narinfo's NarHash without materializing the archive.
+- **The Windows reserved-device guard covers the platform's full set.** `isReservedDeviceName` - the shared predicate behind the NAR entry-name check and the store-key allowlist - compared the raw stem before the first dot against `con`/`prn`/`aux`/`nul` and `com1`-`com9`/`lpt1`-`lpt9`. Win32 device parsing also trims trailing spaces from the stem (`NUL .txt` still opens the device) and reserves `COM0`/`LPT0` and the superscript-digit forms (U+00B9/U+00B2/U+00B3, matched as their UTF-8 bytes), so archives naming `lpt0`, a space-padded device stem, or a superscript-digit device were accepted and would resolve to a device under a Windows extractor. The guard now matches Microsoft's documented reserved set - and the twin check nova-nix applies at materialization, which already rejected all three.
+- **Size fields are capped at the uint64 maximum.** The 20-digit length bound on `NarSize`/`FileSize` still admitted values between 2^64 and 10^20 - 1; upstream's `string2Int<uint64_t>` refuses those, so such a narinfo would be validated and signed here and then rejected as corrupt by every real Nix client. Values above 18446744073709551615 now fail the parse.
+- **Toolchain: GHC 9.14.1 (the first GHC LTS release)** - CI, the deploy and release pipelines, and the README badge move from GHC 9.8.4 to 9.14.1, matching nova-nix. GHC 9.14 opens GHC's new LTS scheme (a minimum of two years of bugfix releases), and under that scheme every earlier series stops receiving fixes, so no version in between had a future. Dependency bounds already admitted the newer compiler and the full set resolves on it. The project targets the LTS alone: base >= 4.22, and foldl' comes from the Prelude, so its one redundant Data.List import is gone.
+
 ## 0.7.0.0 - 2026-07-21
 
 - **NAR entry names and symlink targets are byte strings.** The format imposes no text encoding on either, and upstream carries both verbatim; decoding them as UTF-8 at parse rejected archives real Nix accepts. `NarEntry` now carries `ByteString` for directory entry names and symlink targets (the breaking change behind the major bump), the parser stops decoding, and entry order is defined bytewise. For names both representations accept - valid UTF-8 - code-point order and byte order coincide, so previously-valid archives keep their exact bytes and hashes. The `checkName` rejection categories apply unchanged to the byte form; they are ASCII-structural, so they now also catch hazards inside names that do not decode (a `nul.` device stem followed by undecodable bytes used to fail as bad UTF-8 and still fails - for the real reason).
diff --git a/NOTICE b/NOTICE
--- a/NOTICE
+++ b/NOTICE
@@ -1,5 +1,5 @@
 nova-cache
-Copyright 2026 Novavero AI Inc.
+Copyright 2026 Novavero AI Inc. and contributors
 
 This product is licensed under the Apache License, Version 2.0.
 A copy of the License is provided in the LICENSE file, or at
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -1,11 +1,11 @@
 <div align="center">
 <h1>nova-cache</h1>
 <p><strong>The Nix binary cache protocol, in Haskell.</strong></p>
-<p>nix-base32, NAR archives, narinfo, store paths, and Ed25519 signing - with an optional WAI cache server. A pure core; IO is confined to the storage and server boundaries.</p>
+<p>nix-base32, NAR archives (strict and streaming), narinfo, store paths, Ed25519 signing, and bounded xz decompression - with an optional WAI cache server. A pure core; IO is confined to the storage and server boundaries.</p>
 
 [![CI](https://github.com/Novavero-AI/nova-cache/actions/workflows/ci.yml/badge.svg)](https://github.com/Novavero-AI/nova-cache/actions/workflows/ci.yml)
 [![Hackage](https://img.shields.io/hackage/v/nova-cache.svg)](https://hackage.haskell.org/package/nova-cache)
-![GHC](https://img.shields.io/badge/GHC-9.8-purple)
+![GHC](https://img.shields.io/badge/GHC-9.14-purple)
 ![License](https://img.shields.io/badge/license-Apache--2.0-blue)
 
 </div>
@@ -48,6 +48,23 @@
   Left errs -> reject errs
 ```
 
+```haskell
+import NovaCache.NAR (defaultCaseHack, withNarSource)
+import qualified NovaCache.Hash as Hash
+
+-- Stream a tree's NAR and hash it in one pass; the archive never
+-- exists in memory. The parsing side is NovaCache.NAR.Stream, a
+-- chunk-fed event machine, and the xz flag adds decompression
+-- bounded by a narinfo's declared NarSize.
+narHash <- withNarSource defaultCaseHack path $ \pull ->
+  let go ctx = do
+        chunk <- pull
+        if BS.null chunk
+          then pure (Hash.hashFinalize ctx)
+          else go (Hash.hashUpdate ctx chunk)
+   in go Hash.hashInit
+```
+
 ## Server
 
 ```bash
@@ -99,7 +116,7 @@
 cabal test
 ```
 
-Optional flag: `--flag server` builds the cache server. Requires GHC 9.8+ and cabal-install 3.10+.
+Optional flags: `--flag server` builds the cache server; `--flag xz` builds the bounded xz decoder (liblzma is bundled - no system library needed). Requires GHC 9.14+ and cabal-install 3.10+.
 
 ---
 
diff --git a/exe/Main.hs b/exe/Main.hs
--- a/exe/Main.hs
+++ b/exe/Main.hs
@@ -135,9 +135,9 @@
     _ -> pure ()
 
   let settings =
-        Warp.setHost (fromString bindHost) $
-          Warp.setPort port $
-            Warp.setOnExceptionResponse onExceptionResponse Warp.defaultSettings
+        Warp.setHost (fromString bindHost)
+          $ Warp.setPort port
+          $ Warp.setOnExceptionResponse onExceptionResponse Warp.defaultSettings
   Warp.runSettings settings (requestLogger (cacheApp cfg))
 
 -- ---------------------------------------------------------------------------
diff --git a/nova-cache.cabal b/nova-cache.cabal
--- a/nova-cache.cabal
+++ b/nova-cache.cabal
@@ -1,11 +1,12 @@
 cabal-version:      3.0
 name:               nova-cache
-version:            0.7.0.0
+version:            0.8.0.0
 synopsis:           Pure-first Nix binary cache protocol library
 description:
   A pure-first library implementing the Nix binary cache protocol -
-  nix-base32, NAR serialization, narinfo parsing, Ed25519 signing, store
-  path handling, and content validation - with an optional WAI server.
+  nix-base32, NAR serialization (whole-tree and streaming), narinfo
+  parsing, Ed25519 signing, store path handling, content validation,
+  and bounded xz decompression - with an optional WAI server.
 
 license:            Apache-2.0
 license-file:       LICENSE
@@ -17,7 +18,7 @@
 category:           Nix, Distribution
 stability:          experimental
 build-type:         Simple
-tested-with:        GHC == 9.8.4
+tested-with:        GHC == 9.14.1
 extra-doc-files:
     CHANGELOG.md
     NOTICE
@@ -28,12 +29,18 @@
   default:     False
   manual:      True
 
+flag xz
+  description: Build NovaCache.Xz, bounded xz decompression (lzma-static bundles liblzma; no system library)
+  default:     False
+  manual:      True
+
 library
   exposed-modules:
     NovaCache.Base32
     NovaCache.Base64
     NovaCache.Hash
     NovaCache.NAR
+    NovaCache.NAR.Stream
     NovaCache.NarInfo
     NovaCache.SafeName
     NovaCache.Server
@@ -43,7 +50,7 @@
     NovaCache.Validate
 
   build-depends:
-      base                >= 4.16 && < 5
+      base                >= 4.22 && < 5
     , base64-bytestring   >= 1.2 && < 1.3
     , bytestring          >= 0.11 && < 0.13
     , containers          >= 0.6 && < 0.9
@@ -57,6 +64,10 @@
     , wai                 >= 3.2 && < 3.3
 
 
+  if flag(xz)
+    exposed-modules: NovaCache.Xz
+    build-depends:   lzma-static >= 5.2.5 && < 5.3
+
   hs-source-dirs:   src
   default-language:  Haskell2010
   default-extensions:
@@ -81,7 +92,7 @@
   ghc-options:      -Wall -Wcompat -threaded -rtsopts
 
   build-depends:
-      base                >= 4.16 && < 5
+      base                >= 4.22 && < 5
     , bytestring          >= 0.11 && < 0.13
     , nova-cache
     , http-types          >= 0.12 && < 0.13
@@ -100,7 +111,7 @@
   ghc-options:      -Wall -Wcompat
 
   build-depends:
-      base                >= 4.16 && < 5
+      base                >= 4.22 && < 5
     , base64-bytestring   >= 1.2 && < 1.3
     , bytestring          >= 0.11 && < 0.13
     , crypton             >= 1.1 && < 2
@@ -111,6 +122,23 @@
     , text                >= 2.0 && < 2.2
     , wai                 >= 3.2 && < 3.3
     , wai-extra           >= 3.1 && < 3.2
+
+test-suite nova-cache-xz-test
+  if !flag(xz)
+    buildable: False
+
+  type:             exitcode-stdio-1.0
+  main-is:          XzTest.hs
+  hs-source-dirs:   test
+  default-language: Haskell2010
+  default-extensions:
+    OverloadedStrings
+  ghc-options:      -Wall -Wcompat
+
+  build-depends:
+      base                >= 4.22 && < 5
+    , bytestring          >= 0.11 && < 0.13
+    , nova-cache
 
 source-repository head
   type:     git
diff --git a/src/NovaCache/Hash.hs b/src/NovaCache/Hash.hs
--- a/src/NovaCache/Hash.hs
+++ b/src/NovaCache/Hash.hs
@@ -7,12 +7,17 @@
   ( NixHash (..),
     hashBytes,
     hashFile,
+    HashContext,
+    hashInit,
+    hashUpdate,
+    hashFinalize,
     formatNixHash,
     parseNixHash,
   )
 where
 
 import Crypto.Hash (Digest, SHA256, hash)
+import qualified Crypto.Hash as CH
 import Data.ByteArray (convert)
 import Data.ByteString (ByteString)
 import qualified Data.ByteString as BS
@@ -40,11 +45,31 @@
 hashFile :: FilePath -> IO NixHash
 hashFile path = hashBytes <$> BS.readFile path
 
--- | Format a 'NixHash' as @sha256:\<nix-base32\>@.
+-- | An in-progress SHA-256 computation.  Pure - crypton contexts are
+-- persistent values - so a consumer can fold 'hashUpdate' over chunks
+-- as they stream (a download, 'NovaCache.NAR.withNarSource') and never
+-- hold the whole input.
+newtype HashContext = HashContext (CH.Context SHA256)
+
+-- | The empty hash computation.
+hashInit :: HashContext
+hashInit = HashContext CH.hashInit
+
+-- | Absorb one chunk.
+hashUpdate :: HashContext -> ByteString -> HashContext
+hashUpdate (HashContext ctx) chunk = HashContext (CH.hashUpdate ctx chunk)
+
+-- | Close the computation.  @'hashFinalize' . foldl' 'hashUpdate'
+-- 'hashInit'@ over any chunking of the input equals 'hashBytes' of the
+-- whole.
+hashFinalize :: HashContext -> NixHash
+hashFinalize (HashContext ctx) = NixHash (convert (CH.hashFinalize ctx))
+
+-- | Format a 't:NixHash' as @sha256:\<nix-base32\>@.
 formatNixHash :: NixHash -> Text
 formatNixHash (NixHash raw) = sha256Prefix <> Base32.encode raw
 
--- | Parse a @sha256:\<nix-base32\>@ string back to a 'NixHash'.
+-- | Parse a @sha256:\<nix-base32\>@ string back to a 't:NixHash'.
 --
 -- Validates both the prefix and the decoded length.
 parseNixHash :: Text -> Either String NixHash
diff --git a/src/NovaCache/NAR.hs b/src/NovaCache/NAR.hs
--- a/src/NovaCache/NAR.hs
+++ b/src/NovaCache/NAR.hs
@@ -16,6 +16,11 @@
 --
 -- Entry names and symlink targets are raw byte strings: the format
 -- imposes no text encoding on them, and upstream carries them verbatim.
+--
+-- Parsing is the whole-input instantiation of the incremental machine
+-- in "NovaCache.NAR.Stream", and serialisation draws on that module's
+-- wire vocabulary - the grammar exists once.  To serialise a tree
+-- without holding file contents in memory, see 'withNarSource'.
 module NovaCache.NAR
   ( NarEntry (..),
     serialise,
@@ -23,23 +28,43 @@
     narHash,
     serialiseFromPath,
     serialiseFromPathWith,
+    withNarSource,
     CaseHack (..),
     defaultCaseHack,
     caseHackSuffix,
   )
 where
 
-import Data.Bits (shiftL, (.&.), (.|.))
+import Control.Exception (finally)
 import Data.ByteString (ByteString)
 import qualified Data.ByteString as BS
 import qualified Data.ByteString.Builder as B
-import qualified Data.ByteString.Char8 as BS8
 import qualified Data.ByteString.Lazy as BL
+import Data.IORef (IORef, newIORef, readIORef, writeIORef)
 import Data.List (sort, sortBy)
 import Data.Ord (comparing)
 import Data.Word (Word64)
 import qualified NovaCache.Hash as Hash
-import NovaCache.SafeName (hasTrailingDotOrSpace, isReservedDeviceName)
+import NovaCache.NAR.Stream
+  ( NarEvent (..),
+    NarStep (..),
+    narPad,
+    narPadOf,
+    narStreamBounded,
+    tokContents,
+    tokDirectory,
+    tokEntry,
+    tokExecutable,
+    tokLParen,
+    tokMagic,
+    tokName,
+    tokNode,
+    tokRParen,
+    tokRegular,
+    tokSymlink,
+    tokTarget,
+    tokType,
+  )
 import System.Directory.OsPath
   ( doesDirectoryExist,
     doesFileExist,
@@ -55,8 +80,10 @@
 #ifdef mingw32_HOST_OS
 import qualified Data.Text as T
 import qualified Data.Text.Encoding as TE
+import System.IO (Handle, IOMode (ReadMode), hClose, hFileSize, openBinaryFile)
 #else
-import System.IO (latin1)
+import qualified Data.ByteString.Char8 as BS8
+import System.IO (Handle, IOMode (ReadMode), hClose, hFileSize, latin1, openBinaryFile)
 #endif
 
 -- ---------------------------------------------------------------------------
@@ -78,35 +105,6 @@
   deriving (Eq, Show)
 
 -- ---------------------------------------------------------------------------
--- Wire tokens (named constants, no magic strings)
--- ---------------------------------------------------------------------------
-
-tokMagic, tokLParen, tokRParen, tokType :: ByteString
-tokMagic = "nix-archive-1"
-tokLParen = "("
-tokRParen = ")"
-tokType = "type"
-
-tokRegular, tokDirectory, tokSymlink :: ByteString
-tokRegular = "regular"
-tokDirectory = "directory"
-tokSymlink = "symlink"
-
-tokContents, tokTarget, tokExecutable :: ByteString
-tokContents = "contents"
-tokTarget = "target"
-tokExecutable = "executable"
-
-tokEntry, tokName, tokNode :: ByteString
-tokEntry = "entry"
-tokName = "name"
-tokNode = "node"
-
--- | Alignment boundary for NAR wire strings.
-narAlignment :: Int
-narAlignment = 8
-
--- ---------------------------------------------------------------------------
 -- Serialization (pure Builder pipeline)
 -- ---------------------------------------------------------------------------
 
@@ -168,198 +166,76 @@
     len = BS.length bs
     padLen = narPad len
 
--- | 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
-
 -- ---------------------------------------------------------------------------
--- Deserialization (pure, cursor-passing parser)
+-- Deserialization (the streaming machine, driven over the whole input)
 -- ---------------------------------------------------------------------------
 
--- | Parser state: remaining bytes after consuming a token.
-type NarParser a = ByteString -> Either String (a, ByteString)
-
 -- | Deserialise NAR binary format to a 'NarEntry'.
+--
+-- Drives "NovaCache.NAR.Stream" over the whole input, folding its
+-- events back into a tree.  The structural-string bound is the input's
+-- own length - a wire string cannot outgrow its container - so this
+-- accepts exactly what the dedicated whole-input parser accepted,
+-- with no extra ceiling.  Contents events are slices of the input, so
+-- single-chunk files rebuild by sharing, not copying.
 deserialise :: ByteString -> Either String NarEntry
-deserialise bs = do
-  (magic, rest) <- readStr bs
-  expect tokMagic magic
-  (entry, rest2) <- parseNode rest
-  if BS.null rest2
-    then Right entry
-    else Left "trailing bytes after NAR root node"
-
--- | Parse a single NAR node.
-parseNode :: NarParser NarEntry
-parseNode bs = do
-  (lp, rest) <- readStr bs
-  expect tokLParen lp
-  (ty, afterTy) <- readStr rest
-  expect tokType ty
-  (kind, afterKind) <- readStr afterTy
-  dispatch kind afterKind
-  where
-    dispatch kind rest
-      | kind == tokRegular = parseRegular rest
-      | kind == tokSymlink = parseSymlink rest
-      | kind == tokDirectory = parseDirectory rest
-      | otherwise = Left ("unknown NAR entry type: " ++ show kind)
-
--- | Parse a regular file node (optional executable flag + contents).
-parseRegular :: NarParser NarEntry
-parseRegular bs = do
-  (tok, afterTok) <- readStr bs
-  regular tok afterTok
-  where
-    regular tok rest
-      | tok == tokExecutable = do
-          (marker, afterEmpty) <- readStr rest
-          -- The format fixes the executable marker's value as the empty
-          -- string; upstream rejects a nonempty value.
-          if BS.null marker
-            then Right ()
-            else Left ("executable marker must be empty, got: " ++ show marker)
-          (cTok, afterCTok) <- readStr afterEmpty
-          expect tokContents cTok
-          (contents, afterContents) <- readStr afterCTok
-          (rp, final) <- readStr afterContents
-          expect tokRParen rp
-          pure (NarRegular True contents, final)
-      | tok == tokContents = do
-          (contents, afterContents) <- readStr rest
-          (rp, final) <- readStr afterContents
-          expect tokRParen rp
-          pure (NarRegular False contents, final)
-      | otherwise =
-          -- 'contents' is mandatory (even an empty file serialises with it), so
-          -- a regular node without it is malformed - reject, matching Nix.
-          Left ("expected 'executable' or 'contents' in regular, got: " ++ show tok)
-
--- | Parse a symlink node.  The target is carried verbatim: upstream
--- imposes no text encoding on it.
-parseSymlink :: NarParser NarEntry
-parseSymlink bs = do
-  (tgt, afterTgt) <- readStr bs
-  expect tokTarget tgt
-  (targetPath, afterPath) <- readStr afterTgt
-  (rp, final) <- readStr afterPath
-  expect tokRParen rp
-  pure (NarSymlink targetPath, final)
-
--- | Parse a directory node (zero or more child entries).
-parseDirectory :: NarParser NarEntry
-parseDirectory = go Nothing []
+deserialise input = drive True (narStreamBounded (fromIntegral (BS.length input))) [] Nothing
   where
-    go !prev !acc bs = do
-      (tok, afterTok) <- readStr bs
-      if tok == tokRParen
-        then pure (NarDirectory (reverse acc), afterTok)
-        else do
-          expect tokEntry tok
-          (lp, afterLp) <- readStr afterTok
-          expect tokLParen lp
-          (nTok, afterNTok) <- readStr afterLp
-          expect tokName nTok
-          (entryName, afterName) <- readStr afterNTok
-          (nodeTok, afterNodeTok) <- readStr afterName
-          expect tokNode nodeTok
-          (entry, afterEntry) <- parseNode afterNodeTok
-          (rp, afterRp) <- readStr afterEntry
-          expect tokRParen rp
-          _ <- checkName prev entryName
-          go (Just entryName) ((entryName, entry) : acc) afterRp
-    -- NAR directory 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 future 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").
-    checkName 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 ()
-
--- ---------------------------------------------------------------------------
--- Wire primitives
--- ---------------------------------------------------------------------------
+    drive !firstFeed step stack root = case step of
+      NarFail err -> Left err
+      NarDone -> case (stack, root) of
+        ([], Just entry) -> Right entry
+        _ -> Left malformedEventStream
+      NarYield event continue -> do
+        (stackNext, rootNext) <- applyEvent event stack root
+        drive firstFeed continue stackNext rootNext
+      NarAwait continue
+        | firstFeed -> drive False (continue input) stack root
+        | otherwise -> drive False (continue BS.empty) stack root
 
--- | Read a length-prefixed, 8-byte-padded string from the buffer.
-readStr :: NarParser ByteString
-readStr bs
-  | BS.length bs < wordSize =
-      Left "unexpected end of NAR: need 8 bytes for string length"
-  -- Compare the Word64 length to the remaining bytes BEFORE narrowing it to
-  -- Int: a hostile length above maxBound::Int would otherwise wrap negative
-  -- and slip past the totalLen check below.
-  | len > fromIntegral (BS.length payload) =
-      Left
-        ( "unexpected end of NAR: string length "
-            ++ show len
-            ++ " exceeds remaining "
-            ++ show (BS.length payload)
-        )
-  | totalLen > BS.length payload =
-      Left
-        ( "unexpected end of NAR: padded string length "
-            ++ show totalLen
-            ++ " exceeds remaining "
-            ++ show (BS.length payload)
-        )
-  -- Nix's reader rejects nonzero padding; accepting it would let archives
-  -- that upstream tooling refuses round-trip through this library.
-  | BS.any (/= 0) padding =
-      Left "nonzero padding bytes in NAR string"
-  | otherwise =
-      Right (BS.take (fromIntegral len) payload, BS.drop totalLen payload)
-  where
-    len = readWord64LE bs
-    payload = BS.drop wordSize bs
-    totalLen = fromIntegral len + narPad (fromIntegral len)
-    padding = BS.take (totalLen - fromIntegral len) (BS.drop (fromIntegral len) payload)
+-- | One frame of the event fold in 'deserialise': the construct
+-- enclosing the node currently being built.
+data BuildFrame
+  = -- | A regular file: executable flag and reversed contents slices.
+    FrameRegular !Bool ![ByteString]
+  | -- | A directory: completed children, reversed.
+    FrameDirectory ![(ByteString, NarEntry)]
+  | -- | A directory entry: its name, then its node once complete.
+    FrameEntry !ByteString !(Maybe NarEntry)
 
--- | Read a little-endian 'Word64' from the first 8 bytes.
-readWord64LE :: ByteString -> Word64
-readWord64LE bs =
-  byte 0
-    .|. (byte 1 `shiftL` 8)
-    .|. (byte 2 `shiftL` 16)
-    .|. (byte 3 `shiftL` 24)
-    .|. (byte 4 `shiftL` 32)
-    .|. (byte 5 `shiftL` 40)
-    .|. (byte 6 `shiftL` 48)
-    .|. (byte 7 `shiftL` 56)
+-- | Apply one event to the frame stack.  The machine already validated
+-- the grammar, so the mismatch arms are unreachable through
+-- 'narStreamBounded'; they fail closed rather than building partially.
+applyEvent :: NarEvent -> [BuildFrame] -> Maybe NarEntry -> Either String ([BuildFrame], Maybe NarEntry)
+applyEvent event stack root = case (event, stack) of
+  (EventRegularBegin isExec _declaredSize, _) ->
+    Right (FrameRegular isExec [] : stack, root)
+  (EventRegularChunk slice, FrameRegular isExec chunks : rest) ->
+    Right (FrameRegular isExec (slice : chunks) : rest, root)
+  (EventRegularEnd, FrameRegular isExec chunks : rest) ->
+    complete (NarRegular isExec (BS.concat (reverse chunks))) rest
+  (EventSymlink target, _) ->
+    complete (NarSymlink target) stack
+  (EventDirectoryBegin, _) ->
+    Right (FrameDirectory [] : stack, root)
+  (EventEntryBegin entryName, _) ->
+    Right (FrameEntry entryName Nothing : stack, root)
+  (EventEntryEnd, FrameEntry entryName (Just entry) : FrameDirectory entriesRev : rest) ->
+    Right (FrameDirectory ((entryName, entry) : entriesRev) : rest, root)
+  (EventDirectoryEnd, FrameDirectory entriesRev : rest) ->
+    complete (NarDirectory (reverse entriesRev)) rest
+  _ -> Left malformedEventStream
   where
-    byte i = fromIntegral (BS.index bs i)
-
--- | Size of a Word64 in bytes.
-wordSize :: Int
-wordSize = 8
+    complete entry remaining = case remaining of
+      [] -> case root of
+        Nothing -> Right ([], Just entry)
+        Just _ -> Left malformedEventStream
+      FrameEntry entryName Nothing : rest ->
+        Right (FrameEntry entryName (Just entry) : rest, root)
+      _ -> Left malformedEventStream
 
--- | Assert that a token matches the expected value.
-expect :: ByteString -> ByteString -> Either String ()
-expect expected got
-  | got == expected = Right ()
-  | otherwise = Left ("expected " ++ show expected ++ ", got " ++ show got)
+malformedEventStream :: String
+malformedEventStream = "malformed NAR event stream"
 
 -- ---------------------------------------------------------------------------
 -- Hashing
@@ -403,8 +279,8 @@
 -- 'defaultCaseHack'.
 --
 -- This is the module's IO boundary: the platform-native walk
--- ('walkPath') classifies each path as symlink, directory, or regular
--- file and delegates to pure constructors.
+-- classifies each path as symlink, directory, or regular file and
+-- delegates to pure constructors.
 serialiseFromPath :: FilePath -> IO NarEntry
 serialiseFromPath = serialiseFromPathWith defaultCaseHack
 
@@ -428,35 +304,43 @@
         then buildDirectory mode path
         else buildRegularFile path
 
--- | Build a directory entry by recursively walking children.  Under
--- 'CaseHackEnabled', each on-disk name is stripped of the case-hack
--- suffix and entries are ordered by the STRIPPED name (the NAR name);
--- two on-disk names stripping to the same entry name fail loudly, as
--- upstream's serialiser does - continuing would emit an archive with
--- duplicate entries no parser accepts.
+-- | Build a directory entry by recursively walking children.
 buildDirectory :: CaseHack -> OsPath -> IO NarEntry
 buildDirectory mode path = do
+  resolved <- resolvedDirEntries mode path
+  NarDirectory <$> traverse walkChild resolved
+  where
+    walkChild (entryName, diskName) = do
+      entry <- walkPath mode (path </> diskName)
+      pure (entryName, entry)
+
+-- | A directory's children as (NAR name, on-disk name) pairs under the
+-- case-hack mode.  Under 'CaseHackEnabled', each on-disk name is
+-- stripped of the case-hack suffix and entries are ordered by the
+-- STRIPPED name (the NAR name); two on-disk names stripping to the
+-- same entry name fail loudly, as upstream's serialiser does -
+-- continuing would emit an archive with duplicate entries no parser
+-- accepts.
+resolvedDirEntries :: CaseHack -> OsPath -> IO [(ByteString, OsPath)]
+resolvedDirEntries mode path = do
   names <- sort <$> listDirectory path
   named <- traverse withNameBytes names
   case unhackedDirNames mode named of
-    Left (first, second) -> do
-      firstPath <- decodeFS (path </> first)
-      secondPath <- decodeFS (path </> second)
+    Left (collidedA, collidedB) -> do
+      pathA <- decodeFS (path </> collidedA)
+      pathB <- decodeFS (path </> collidedB)
       fail
         ( "serialiseFromPath: file name collision between '"
-            ++ firstPath
+            ++ pathA
             ++ "' and '"
-            ++ secondPath
+            ++ pathB
             ++ "' after case-hack stripping"
         )
-    Right resolved -> NarDirectory <$> traverse walkChild resolved
+    Right resolved -> pure resolved
   where
     withNameBytes diskName = do
       nameBytes <- osPathBytes diskName
       pure (nameBytes, diskName)
-    walkChild (entryName, diskName) = do
-      entry <- walkPath mode (path </> diskName)
-      pure (entryName, entry)
 
 -- | Resolve (NAR name, on-disk name) pairs for a directory's children.
 -- Under 'CaseHackDisabled' pairs pass through verbatim (serialisation
@@ -491,14 +375,18 @@
       contents <- readFileBytes path
       isExec <- checkExecutable path
       pure (NarRegular isExec contents)
-    else do
-      -- Not a symlink, directory, or regular file: a special file (FIFO,
-      -- socket, device) or a path that vanished mid-walk.  Fail loudly rather
-      -- than fabricating an empty regular (which would silently change the NAR
-      -- and its hash) - matching Nix, which aborts on unsupported types.
-      shownPath <- decodeFS path
-      fail ("serialiseFromPath: not a regular file (special or vanished): " ++ shownPath)
+    else specialFileFailure path
 
+-- | The shared refusal for a path that is not a symlink, directory, or
+-- regular file: a special file (FIFO, socket, device) or a path that
+-- vanished mid-walk.  Fail loudly rather than fabricating an empty
+-- regular (which would silently change the NAR and its hash) -
+-- matching Nix, which aborts on unsupported types.
+specialFileFailure :: OsPath -> IO a
+specialFileFailure path = do
+  shownPath <- decodeFS path
+  fail ("serialiseFromPath: not a regular file (special or vanished): " ++ shownPath)
+
 -- | Check whether a file has the executable permission set.
 -- Uses 'System.Directory.OsPath.getPermissions' which is cross-platform:
 -- checks the user-execute bit on Unix, file extension on Windows.
@@ -538,3 +426,189 @@
     -- surfacing the impossible beats hiding it.
     fail ("serialiseFromPath: undecodable name: " ++ show err)
 #endif
+
+-- ---------------------------------------------------------------------------
+-- Streaming filesystem serialisation (IO boundary)
+-- ---------------------------------------------------------------------------
+
+-- | Chunk size for streaming file contents: large enough to amortize
+-- per-chunk handling in consumers, small enough that one pull's memory
+-- and latency stay flat.
+narSourceChunkBytes :: Int
+narSourceChunkBytes = 131072
+
+-- | One planned piece of the archive: structural bytes rendered up
+-- front, or a regular file whose length prefix, contents, and padding
+-- stream at pull time.
+data NarSegment
+  = SegmentBytes !ByteString
+  | SegmentFile !OsPath
+
+-- | What the puller is doing between calls.  The 'IORef' holding this
+-- is the module's one piece of mutable state - the same deliberate,
+-- documented boundary as the streaming write in "NovaCache.Store".
+data SourceState
+  = SourceSegments ![NarSegment]
+  | -- | Mid-file: the open handle, its decoded path for error text,
+    -- the bytes still owed, the padding after them, and the remaining
+    -- segments.
+    SourceFile !Handle !FilePath !Word64 !Int ![NarSegment]
+  | SourceDrained
+
+-- | Serialise a filesystem tree as a pull source of NAR chunks,
+-- without ever holding a file's contents in memory: the tree's
+-- structure is planned up front (names, kinds, symlink targets -
+-- never contents), then each pull returns the next chunk, reading
+-- regular files 128 KiB at a time.  The empty chunk
+-- means end of input and repeats on further pulls - the convention
+-- 'NovaCache.Store.writeNarStreaming' consumes, so the two ends
+-- compose directly.  Pair with "NovaCache.Hash"'s incremental hashing
+-- to compute the NAR hash in the same pass.
+--
+-- The walk applies the same case-hack resolution and loud failures as
+-- 'serialiseFromPathWith', and emits entries in the same bytewise
+-- order, so the pulled bytes equal @'serialise' \<tree\>@ exactly.  A
+-- file's size is read when its streaming starts and exactly that many
+-- bytes are emitted, as upstream's dump does; a file that shrinks
+-- mid-stream fails loudly rather than emitting a torn archive.  Any
+-- file handle still open when the continuation exits is closed.
+withNarSource :: CaseHack -> FilePath -> (IO ByteString -> IO a) -> IO a
+withNarSource mode root consume = do
+  rootPath <- encodeFS root
+  segments <- planSegments mode rootPath
+  stateRef <- newIORef (SourceSegments segments)
+  consume (pullChunk stateRef) `finally` closeCurrent stateRef
+  where
+    closeCurrent stateRef = do
+      state <- readIORef stateRef
+      case state of
+        SourceFile handle _ _ _ _ -> hClose handle
+        _ -> pure ()
+
+-- | Produce the next chunk of the planned archive.
+pullChunk :: IORef SourceState -> IO ByteString
+pullChunk stateRef = advance =<< readIORef stateRef
+  where
+    advance (SourceSegments []) = do
+      writeIORef stateRef SourceDrained
+      pure BS.empty
+    advance (SourceSegments (SegmentBytes bytes : rest)) = do
+      writeIORef stateRef (SourceSegments rest)
+      pure bytes
+    advance (SourceSegments (SegmentFile path : rest)) = do
+      shownPath <- decodeFS path
+      handle <- openBinaryFile shownPath ReadMode
+      size <- hFileSize handle
+      let owed = fromIntegral size :: Word64
+      writeIORef stateRef (SourceFile handle shownPath owed (narPadOf owed) rest)
+      pure (BL.toStrict (B.toLazyByteString (B.word64LE owed)))
+    advance (SourceFile handle _ 0 padLen rest) = do
+      hClose handle
+      writeIORef stateRef (SourceSegments rest)
+      if padLen == 0
+        then pullChunk stateRef
+        else pure (BS.replicate padLen 0)
+    advance (SourceFile handle shownPath owed padLen rest) = do
+      chunk <- BS.hGet handle (fromIntegral (min owed (fromIntegral narSourceChunkBytes)))
+      if BS.null chunk
+        then do
+          hClose handle
+          writeIORef stateRef SourceDrained
+          ioError (userError ("withNarSource: " ++ shownPath ++ " shrank while streaming"))
+        else do
+          writeIORef
+            stateRef
+            (SourceFile handle shownPath (owed - fromIntegral (BS.length chunk)) padLen rest)
+          pure chunk
+    advance SourceDrained = pure BS.empty
+
+-- | Plan the archive: every structural byte rendered, file contents
+-- deferred as 'SegmentFile's.  Holds structure only - O(entries),
+-- never contents.
+planSegments :: CaseHack -> OsPath -> IO [NarSegment]
+planSegments mode path = do
+  pieces <- planNode mode path
+  pure (coalesce (PieceBytes (narStr tokMagic) : pieces))
+
+-- | Plan pieces before coalescing: structural builders, or a deferred
+-- regular file.
+data PlanPiece
+  = PieceBytes B.Builder
+  | PieceFile !OsPath
+
+-- | Merge adjacent structural runs and render each strict, so a pull
+-- returns a directory's worth of tokens in one chunk instead of one
+-- token at a time.
+coalesce :: [PlanPiece] -> [NarSegment]
+coalesce = go mempty
+  where
+    go pending [] = flushOnto pending []
+    go pending (PieceBytes builder : rest) = go (pending <> builder) rest
+    go pending (PieceFile path : rest) =
+      flushOnto pending (SegmentFile path : go mempty rest)
+    flushOnto pending segments =
+      let bytes = BL.toStrict (B.toLazyByteString pending)
+       in if BS.null bytes then segments else SegmentBytes bytes : segments
+
+-- | Plan one node, mirroring 'walkPath'.
+planNode :: CaseHack -> OsPath -> IO [PlanPiece]
+planNode mode path = do
+  isSym <- pathIsSymbolicLink path
+  if isSym
+    then do
+      target <- osPathBytes =<< getSymbolicLinkTarget path
+      pure [PieceBytes (buildNode (NarSymlink target))]
+    else do
+      isDir <- doesDirectoryExist path
+      if isDir
+        then planDirectory mode path
+        else planRegular path
+
+-- | Plan a directory.  Children are ordered by their NAR-name bytes -
+-- the same order 'buildNode' emits - not by on-disk order, which can
+-- differ on Windows where 'OsPath' sorts by UTF-16 units.
+planDirectory :: CaseHack -> OsPath -> IO [PlanPiece]
+planDirectory mode path = do
+  resolved <- resolvedDirEntries mode path
+  children <- traverse planChild (sortBy (comparing fst) resolved)
+  pure
+    ( PieceBytes (narStr tokLParen <> narStr tokType <> narStr tokDirectory)
+        : concat children
+        ++ [PieceBytes (narStr tokRParen)]
+    )
+  where
+    planChild (entryName, diskName) = do
+      node <- planNode mode (path </> diskName)
+      pure
+        ( PieceBytes
+            ( narStr tokEntry
+                <> narStr tokLParen
+                <> narStr tokName
+                <> narStr entryName
+                <> narStr tokNode
+            )
+            : node
+            ++ [PieceBytes (narStr tokRParen)]
+        )
+
+-- | Plan a regular file: the node's structure now, its contents at
+-- pull time.  The pieces mirror 'buildNode' on 'NarRegular' exactly,
+-- with the contents wire string (length, bytes, padding) deferred.
+planRegular :: OsPath -> IO [PlanPiece]
+planRegular path = do
+  isFile <- doesFileExist path
+  if isFile
+    then do
+      isExec <- checkExecutable path
+      pure
+        [ PieceBytes
+            ( narStr tokLParen
+                <> narStr tokType
+                <> narStr tokRegular
+                <> execFlag isExec
+                <> narStr tokContents
+            ),
+          PieceFile path,
+          PieceBytes (narStr tokRParen)
+        ]
+    else specialFileFailure path
diff --git a/src/NovaCache/NAR/Stream.hs b/src/NovaCache/NAR/Stream.hs
new file mode 100644
--- /dev/null
+++ b/src/NovaCache/NAR/Stream.hs
@@ -0,0 +1,394 @@
+-- | 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
diff --git a/src/NovaCache/NarInfo.hs b/src/NovaCache/NarInfo.hs
--- a/src/NovaCache/NarInfo.hs
+++ b/src/NovaCache/NarInfo.hs
@@ -10,7 +10,6 @@
   )
 where
 
-import Data.List (foldl')
 import Data.Maybe (fromMaybe, mapMaybe)
 import Data.Text (Text)
 import qualified Data.Text as T
@@ -79,7 +78,7 @@
 defaultCompression :: Text
 defaultCompression = "bzip2"
 
--- | Parse a narinfo text body into a 'NarInfo'.  Only StorePath, URL,
+-- | Parse a narinfo text body into a 't:NarInfo'.  Only StorePath, URL,
 -- NarHash, and NarSize are required, matching upstream Nix; a valid
 -- narinfo from a foreign cache must not be rejected over an absent
 -- optional field.
@@ -117,7 +116,7 @@
 -- Rendering
 -- ---------------------------------------------------------------------------
 
--- | Render a 'NarInfo' to its text representation.
+-- | Render a 't:NarInfo' to its text representation.
 renderNarInfo :: NarInfo -> Text
 renderNarInfo ni =
   T.unlines $
@@ -162,7 +161,6 @@
 optionalKV _ Nothing = []
 optionalKV key (Just val) = [kv key val]
 
--- | Look up the first occurrence of a key.
 -- | Look up the LAST occurrence of a scalar key: upstream's parser
 -- assigns each field as it reads, so a duplicated key resolves to the
 -- final value.  (@Sig@ is the one intentionally repeatable key -
@@ -190,17 +188,29 @@
 maxSizeFieldDigits :: Int
 maxSizeFieldDigits = 20
 
+-- | The uint64 ceiling (2^64 - 1).  Twenty digits still admit values
+-- past it; upstream's @string2Int\<uint64_t\>@ refuses those, so a
+-- narinfo carrying one would be signed here and then rejected as
+-- corrupt by every real Nix client.
+maxSizeFieldValue :: Integer
+maxSizeFieldValue = 18446744073709551615
+
 -- | Parse a non-negative base-10 integer, matching C++ Nix's narinfo parser.
 -- Uses 'TR.decimal' (not 'reads', which also accepts hex/octal/leading space)
 -- and requires the whole field to be consumed, so a non-canonical value cannot
 -- slip through and then be re-signed under the cache's key.  The length is
--- bounded first ('maxSizeFieldDigits'), so the parse cost is constant.
+-- bounded first ('maxSizeFieldDigits'), so the parse cost is constant, and the
+-- value is bounded after ('maxSizeFieldValue'), completing the uint64 contract.
 parseInteger :: Text -> Text -> Either String Integer
 parseInteger key txt
   | T.length txt > maxSizeFieldDigits =
       Left ("integer field for " ++ T.unpack key ++ " is " ++ show (T.length txt) ++ " characters, above the " ++ show maxSizeFieldDigits ++ " maximum")
   | otherwise = case TR.decimal txt of
-      Right (n, rest) | T.null rest -> Right n
+      Right (n, rest)
+        | T.null rest,
+          n > maxSizeFieldValue ->
+            Left ("integer field for " ++ T.unpack key ++ " is above the uint64 maximum: " ++ T.unpack txt)
+        | T.null rest -> Right n
       _ -> Left ("invalid integer for " ++ T.unpack key ++ ": " ++ T.unpack txt)
 
 -- | Show a value as 'Text'.
diff --git a/src/NovaCache/SafeName.hs b/src/NovaCache/SafeName.hs
--- a/src/NovaCache/SafeName.hs
+++ b/src/NovaCache/SafeName.hs
@@ -4,11 +4,13 @@
 -- ordinary file of that exact spelling.  Both guards reject the same
 -- categories from one definition, so they cannot drift apart.
 --
--- The predicates take raw bytes, the form NAR entry names have.  Every
--- category here is ASCII-structural, and UTF-8 lead and continuation
--- bytes are all @>= 0x80@, so byte-level matching is exact - inside
--- valid UTF-8 and inside names that decode as nothing at all.  Text
--- callers encode with 'Data.Text.Encoding.encodeUtf8' first.
+-- The predicates take raw bytes, the form NAR entry names have.  The
+-- categories are ASCII-structural - except the superscript device
+-- digits, matched as their exact UTF-8 sequences - and UTF-8 lead and
+-- continuation bytes are all @>= 0x80@, so byte-level matching is
+-- exact inside valid UTF-8 and inside names that decode as nothing at
+-- all.  Text callers encode with 'Data.Text.Encoding.encodeUtf8'
+-- first.
 module NovaCache.SafeName
   ( isReservedDeviceName,
     hasTrailingDotOrSpace,
@@ -16,25 +18,58 @@
 where
 
 import Data.ByteString (ByteString)
+import qualified Data.ByteString as BS
 import qualified Data.ByteString.Char8 as BS8
-import Data.Char (isAsciiUpper, toLower)
+import Data.Char (isAsciiUpper, isDigit, toLower)
 
 -- | Is the name a Windows reserved device (@con@, @prn@, @aux@, @nul@,
--- @com1@-@com9@, @lpt1@-@lpt9@)? Matched case-insensitively on the portion
--- before the first dot, since @nul.txt@ also opens the device. Enforced on
--- every platform so a Windows-hosted consumer is safe too.
+-- @com0@-@com9@, @lpt0@-@lpt9@, or a superscript-digit @com@\/@lpt@
+-- form)?  The comparison runs on the stem - the portion before the
+-- first dot, trailing spaces trimmed - since @nul.txt@ and @NUL .txt@
+-- also open the device.  Enforced on every platform so a
+-- Windows-hosted consumer is safe too, and kept in step with the twin
+-- guard nova-nix applies when it materializes NAR entries.
 --
 -- Device matching is ASCII case-insensitive, so only @A@-@Z@ fold; any
--- other byte passes through and can never match the reserved set.
+-- other byte passes through and can never match the named set.
 isReservedDeviceName :: ByteString -> Bool
-isReservedDeviceName name =
-  BS8.map asciiLower (BS8.takeWhile (/= '.') name) `elem` reservedNames
+isReservedDeviceName name = stem `elem` namedDevices || isNumberedDevice stem
   where
+    stem = BS8.map asciiLower (deviceStem name)
     asciiLower c = if isAsciiUpper c then toLower c else c
-    reservedNames =
-      ["con", "prn", "aux", "nul"]
-        ++ [device <> digit | device <- ["com", "lpt"], digit <- digits]
-    digits = [BS8.pack (show n) | n <- [1 .. 9 :: Int]]
+    namedDevices = ["con", "prn", "aux", "nul"]
+
+-- | The portion of a name Win32 device parsing compares against the
+-- reserved set: up to the first dot, trailing spaces trimmed.
+deviceStem :: ByteString -> ByteString
+deviceStem = BS8.dropWhileEnd (== ' ') . BS8.takeWhile (/= '.')
+
+-- | A numbered device stem: @com@ or @lpt@ followed by exactly one
+-- device digit (@com10@ is an ordinary name).
+isNumberedDevice :: ByteString -> Bool
+isNumberedDevice stem = case BS.splitAt numberedDevicePrefixLen stem of
+  (prefix, digit) -> (prefix == "com" || prefix == "lpt") && isDeviceDigit digit
+
+-- | Bytes @com@\/@lpt@ occupy in a numbered device stem.
+numberedDevicePrefixLen :: Int
+numberedDevicePrefixLen = 3
+
+-- | One device digit: ASCII @0@-@9@, or superscript one\/two\/three as
+-- UTF-8 bytes - Windows reserves the superscript @COM@\/@LPT@ forms
+-- alongside the plain ones.  Only the UTF-8 spelling is matched: a
+-- lone Latin-1 superscript byte is not valid UTF-8, so no UTF-8 write
+-- boundary ever lands it on a Windows filesystem, and on POSIX it
+-- names an ordinary file.
+isDeviceDigit :: ByteString -> Bool
+isDeviceDigit bytes =
+  (BS.length bytes == 1 && BS8.all isDigit bytes)
+    || bytes `elem` superscriptDigits
+
+-- | The UTF-8 encodings of U+00B9, U+00B2, U+00B3 (superscript one,
+-- two, three).
+superscriptDigits :: [ByteString]
+superscriptDigits =
+  [BS.pack [0xC2, 0xB9], BS.pack [0xC2, 0xB2], BS.pack [0xC2, 0xB3]]
 
 -- | Does the name end with a dot or a space?  NTFS strips both at
 -- create time, so the on-disk name silently diverges from the requested
diff --git a/src/NovaCache/Server.hs b/src/NovaCache/Server.hs
--- a/src/NovaCache/Server.hs
+++ b/src/NovaCache/Server.hs
@@ -265,7 +265,7 @@
 -- | Decode a raw narinfo body and validate it in a single pure pipeline.
 --
 -- Composes UTF-8 decoding, narinfo parsing, and field validation. Returns
--- the validated 'NarInfo' on success, or a user-facing error message.
+-- the validated 't:NarInfo' on success, or a user-facing error message.
 decodeAndValidate :: ByteString -> Either Text NarInfo
 decodeAndValidate body = do
   decoded <- first (const "request body is not valid UTF-8") (TE.decodeUtf8' body)
@@ -342,7 +342,7 @@
 -- Signing
 -- ---------------------------------------------------------------------------
 
--- | Sign a validated 'NarInfo' if a signing key is configured.
+-- | Sign a validated 't:NarInfo' if a signing key is configured.
 --
 -- With no key, returns the unsigned rendering (intentional - the operator
 -- configured none).  With a key, FAILS CLOSED: a signing error returns 'Left'
@@ -358,7 +358,7 @@
     let signed = ni {niSigs = niSigs ni ++ [sig]}
      in pure (Right (renderNarInfoBytes signed))
 
--- | Render a 'NarInfo' to its UTF-8 encoded wire format.
+-- | Render a 't:NarInfo' to its UTF-8 encoded wire format.
 renderNarInfoBytes :: NarInfo -> ByteString
 renderNarInfoBytes = TE.encodeUtf8 . renderNarInfo
 
diff --git a/src/NovaCache/Store.hs b/src/NovaCache/Store.hs
--- a/src/NovaCache/Store.hs
+++ b/src/NovaCache/Store.hs
@@ -83,7 +83,7 @@
 -- Initialization
 -- ---------------------------------------------------------------------------
 
--- | Create a 'FileStore' rooted at the given directory.
+-- | Create a 't:FileStore' rooted at the given directory.
 --
 -- Ensures the @narinfo@ and @nar@ subdirectories exist.
 newFileStore :: FilePath -> IO FileStore
diff --git a/src/NovaCache/StorePath.hs b/src/NovaCache/StorePath.hs
--- a/src/NovaCache/StorePath.hs
+++ b/src/NovaCache/StorePath.hs
@@ -131,7 +131,7 @@
 stripDirPrefix dir txt =
   fromMaybe txt (T.stripPrefix (T.pack dir <> "/") txt)
 
--- | Parse a @\<hash\>-\<name\>@ basename into a 'StorePath'.
+-- | Parse a @\<hash\>-\<name\>@ basename into a 't:StorePath'.
 parseBaseName :: Text -> Either String StorePath
 parseBaseName basename
   | T.length basename < minBaseNameLen =
diff --git a/src/NovaCache/Validate.hs b/src/NovaCache/Validate.hs
--- a/src/NovaCache/Validate.hs
+++ b/src/NovaCache/Validate.hs
@@ -57,7 +57,7 @@
 
 -- | Validate narinfo field semantics: sizes non-negative, store path parses,
 -- hash fields parse, references parse. Collects all errors (not short-circuit).
--- Returns the 'NarInfo' unchanged on success for composition.
+-- Returns the 't:NarInfo' unchanged on success for composition.
 validateNarInfo :: NarInfo -> Either [ValidationError] NarInfo
 validateNarInfo ni =
   case concat [sizeErrors, drvErrors, storePathErrors, fileHashErrors, narHashErrors, refErrors] of
diff --git a/src/NovaCache/Xz.hs b/src/NovaCache/Xz.hs
new file mode 100644
--- /dev/null
+++ b/src/NovaCache/Xz.hs
@@ -0,0 +1,204 @@
+-- | Bounded xz decompression for untrusted cache data.
+--
+-- cache.nixos.org serves NARs xz-compressed, and substitution
+-- decompresses bytes that arrive from the network BEFORE any hash can
+-- vouch for them, so the decoder must not be steerable into unbounded
+-- allocation.  The consumer knows the narinfo's declared NarSize
+-- before decompressing: decompression takes that bound and fails past
+-- it ('xzMaxOutputBytes'), so a small compressed input cannot expand
+-- to arbitrary memory ahead of the hash check.  The decoder's own
+-- state is capped as well ('xzMaxDecoderMemoryBytes') - upstream
+-- passes no limit there; the divergence is deliberate hardening and
+-- the cap is a parameter.
+--
+-- Concatenated streams decode as one output, matching upstream's
+-- @LZMA_CONCATENATED@ decoder in libutil's compression sink.
+--
+-- This module builds only under the @xz@ cabal flag.  The
+-- @lzma-static@ dependency bundles liblzma's C sources, so no system
+-- library is needed on any platform - but it is still an extra C
+-- build that consumers without foreign-cache needs should not pay
+-- for, and a default-on compression flag broke downstream installs
+-- once already (0.5.0.0).  The flag is manual and off by default;
+-- consumers that substitute from foreign caches turn it on.
+module NovaCache.Xz
+  ( XzLimits (..),
+    defaultXzDecoderMemoryBytes,
+    XzError (..),
+    decompress,
+    withXzSource,
+  )
+where
+
+import qualified Codec.Compression.Lzma as Lzma
+import Control.Exception (Exception, throwIO)
+-- decompressST runs in lazy ST (the upstream package's own lazy
+-- API drives it the same way); the driver's accumulator bangs and
+-- guard-before-recurse keep the bound checks strict regardless.
+import Control.Monad.ST.Lazy (runST)
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as BS
+import Data.IORef (IORef, newIORef, readIORef, writeIORef)
+import Data.Word (Word64)
+
+-- ---------------------------------------------------------------------------
+-- Limits
+-- ---------------------------------------------------------------------------
+
+-- | What a decode run may cost.  Both bounds are inclusive: output of
+-- exactly 'xzMaxOutputBytes' passes, one byte more fails - a narinfo's
+-- NarSize is exact, so the declared size itself must be reachable.
+data XzLimits = XzLimits
+  { -- | Maximum decompressed output, in bytes: the narinfo's declared
+    -- NarSize.
+    xzMaxOutputBytes :: !Word64,
+    -- | Maximum decoder-state memory liblzma may allocate.  Decoder
+    -- memory tracks the stream's declared dictionary size, an
+    -- attacker-chosen number read from the compressed header.
+    xzMaxDecoderMemoryBytes :: !Word64
+  }
+  deriving (Eq, Show)
+
+-- | A decoder-memory cap for callers without an opinion: 1 GiB.  The
+-- largest standard preset (@xz -9@) declares a 64 MiB dictionary and
+-- needs about 65 MiB to decode, so this refuses only hand-rolled
+-- dictionaries past 1 GiB.  Upstream passes no limit at all; a
+-- consumer matching that exactly can pass 'maxBound'.
+defaultXzDecoderMemoryBytes :: Word64
+defaultXzDecoderMemoryBytes = 1024 * 1024 * 1024
+
+-- ---------------------------------------------------------------------------
+-- Errors
+-- ---------------------------------------------------------------------------
+
+-- | Everything a bounded decode can refuse.  The pure 'decompress'
+-- returns these in 'Left'; the pull source behind 'withXzSource'
+-- throws them (see the 'Exception' instance).
+data XzError
+  = -- | The compressed stream is malformed, truncated, or carries
+    -- trailing garbage (liblzma's status, rendered).
+    XzStreamError !String
+  | -- | Decompressed output would exceed the bound (carried here).
+    XzOutputOverBound !Word64
+  | -- | The stream declares a dictionary needing more decoder memory
+    -- than the bound (carried here).
+    XzMemoryOverBound !Word64
+  deriving (Eq, Show)
+
+-- | Thrown by the pull source 'withXzSource' hands its continuation;
+-- a chunk convention has no error channel, and a throwing pull
+-- composes with consumers built around one (the store's streaming
+-- write cleans up via its exception path).
+instance Exception XzError
+
+-- ---------------------------------------------------------------------------
+-- Pure bounded decode
+-- ---------------------------------------------------------------------------
+
+-- | Decompress one xz blob under the given limits.  Output stops
+-- accumulating the moment it would pass the bound, so a
+-- high-expansion input costs at most the bound plus one decoder
+-- buffer, never what it claims to hold.
+decompress :: XzLimits -> ByteString -> Either XzError ByteString
+decompress limits input = runST $ do
+  start <- Lzma.decompressST (decompressParams limits)
+  drive (Just input) 0 [] start
+  where
+    bound = xzMaxOutputBytes limits
+    drive pending !produced acc step = case step of
+      -- The whole input feeds on the first request; the second request
+      -- gets the empty string, liblzma's end-of-input signal.
+      Lzma.DecompressInputRequired supply -> case pending of
+        Just bytes -> drive Nothing produced acc =<< supply bytes
+        Nothing -> drive Nothing produced acc =<< supply BS.empty
+      Lzma.DecompressOutputAvailable out next
+        | grown > bound -> pure (Left (XzOutputOverBound bound))
+        | otherwise -> drive pending grown (out : acc) =<< next
+        where
+          grown = produced + fromIntegral (BS.length out)
+      Lzma.DecompressStreamEnd leftover
+        | BS.null leftover -> pure (Right (BS.concat (reverse acc)))
+        | otherwise -> pure (Left (XzStreamError trailingDataMessage))
+      Lzma.DecompressStreamError ret -> pure (Left (mapRet limits ret))
+
+-- ---------------------------------------------------------------------------
+-- Streaming bounded decode (IO boundary)
+-- ---------------------------------------------------------------------------
+
+-- | What the pull source is doing between calls.  The 'IORef' holding
+-- this is the module's one piece of mutable state - the same
+-- deliberate, documented boundary as the streaming NAR source.
+data XzSourceState
+  = XzStreaming !(Lzma.DecompressStream IO) !Word64
+  | XzDrained
+
+-- | Decompress a chunk source into a chunk source, under the limits.
+-- The continuation's pull yields decompressed chunks; the empty chunk
+-- means end of output and repeats on further pulls.  The compressed
+-- source follows the same convention on its side.  Pairs with the
+-- incremental NAR parser and hashing, so a substituter can fetch,
+-- decompress, hash, and unpack in one bounded pass.
+--
+-- Limit violations and malformed input are thrown as 'XzError' from
+-- the pull.
+withXzSource :: XzLimits -> IO ByteString -> (IO ByteString -> IO a) -> IO a
+withXzSource limits compressedSource consume = do
+  start <- Lzma.decompressIO (decompressParams limits)
+  stateRef <- newIORef (XzStreaming start 0)
+  consume (pullDecompressed limits compressedSource stateRef)
+
+-- | Produce the next decompressed chunk.
+pullDecompressed :: XzLimits -> IO ByteString -> IORef XzSourceState -> IO ByteString
+pullDecompressed limits compressedSource stateRef = advance =<< readIORef stateRef
+  where
+    bound = xzMaxOutputBytes limits
+    advance XzDrained = pure BS.empty
+    advance (XzStreaming step produced) = case step of
+      Lzma.DecompressInputRequired supply -> do
+        chunk <- compressedSource
+        next <- supply chunk
+        advance (XzStreaming next produced)
+      Lzma.DecompressOutputAvailable out nextAction -> do
+        let grown = produced + fromIntegral (BS.length out)
+        if grown > bound
+          then do
+            writeIORef stateRef XzDrained
+            throwIO (XzOutputOverBound bound)
+          else do
+            next <- nextAction
+            writeIORef stateRef (XzStreaming next grown)
+            -- liblzma may hand back an empty buffer at stream
+            -- boundaries; returning it would read as end of output.
+            if BS.null out
+              then advance (XzStreaming next grown)
+              else pure out
+      Lzma.DecompressStreamEnd leftover -> do
+        writeIORef stateRef XzDrained
+        if BS.null leftover
+          then pure BS.empty
+          else throwIO (XzStreamError trailingDataMessage)
+      Lzma.DecompressStreamError ret -> do
+        writeIORef stateRef XzDrained
+        throwIO (mapRet limits ret)
+
+-- ---------------------------------------------------------------------------
+-- Shared decoder configuration
+-- ---------------------------------------------------------------------------
+
+-- | Decoder parameters under the limits: concatenated-stream decoding
+-- as upstream, memory capped, everything else at the library default.
+decompressParams :: XzLimits -> Lzma.DecompressParams
+decompressParams limits =
+  Lzma.defaultDecompressParams
+    { Lzma.decompressConcatenated = True,
+      Lzma.decompressMemLimit = xzMaxDecoderMemoryBytes limits
+    }
+
+-- | Map liblzma's status to the error vocabulary.
+mapRet :: XzLimits -> Lzma.LzmaRet -> XzError
+mapRet limits ret = case ret of
+  Lzma.LzmaRetMemlimitError -> XzMemoryOverBound (xzMaxDecoderMemoryBytes limits)
+  other -> XzStreamError (show other)
+
+trailingDataMessage :: String
+trailingDataMessage = "trailing data after the xz stream"
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -10,6 +10,7 @@
 import qualified Data.ByteString as BS
 import qualified Data.ByteString.Base64 as B64
 import qualified Data.ByteString.Lazy as BL
+import Data.Either (isLeft)
 import Data.IORef (atomicModifyIORef', newIORef)
 import Data.List (sort)
 import Data.Maybe (isJust)
@@ -23,6 +24,7 @@
 import qualified NovaCache.Base32 as Base32
 import qualified NovaCache.Hash as Hash
 import qualified NovaCache.NAR as NAR
+import qualified NovaCache.NAR.Stream as Stream
 import qualified NovaCache.NarInfo as NarInfo
 import qualified NovaCache.Server as Server
 import qualified NovaCache.Signing as Signing
@@ -133,6 +135,7 @@
       testHash,
       testStorePath,
       testNAR,
+      testStream,
       testNarInfo,
       testSigning,
       testFileStore,
@@ -396,12 +399,33 @@
       -- this spelling (drive/stream colon, device, NTFS dot/space strip).
       test "Windows-hazard entry names rejected" $
         let evil name = NAR.serialise (NAR.NarDirectory [(name, NAR.NarRegular False "x")])
-            names = ["C:evil", "a:b", "nul", "NUL", "com1", "nul.txt", "foo.", "foo "]
+            -- The last three: COM0/LPT0 are reserved alongside COM1-9,
+            -- the device stem is compared with trailing spaces trimmed
+            -- ("NUL .txt" still opens the device), and the superscript
+            -- digits (here U+00B9 as UTF-8) count as device digits.
+            names =
+              [ "C:evil",
+                "a:b",
+                "nul",
+                "NUL",
+                "com1",
+                "nul.txt",
+                "foo.",
+                "foo ",
+                "com0",
+                "lpt0",
+                "nul .txt",
+                "CON .x",
+                "com" <> BS.pack [0xC2, 0xB9]
+              ]
             rejected bytes = either (const True) (const False) (NAR.deserialise bytes)
          in assertTrue "all hazard names rejected" (all (rejected . evil) names),
       test "near-miss names still parse" $
         let plain name = NAR.serialise (NAR.NarDirectory [(name, NAR.NarRegular False "x")])
-            names = ["nul2", "com10", "conx", "foo.bar", "a.b.c", "lpt0"]
+            -- "com" followed by a non-digit non-superscript byte (here
+            -- U+00B4, acute accent) is an ordinary name; so are stems
+            -- one character too long.
+            names = ["nul2", "com10", "conx", "foo.bar", "a.b.c", "lpt00", "com" <> BS.pack [0xC2, 0xB4]]
             accepted bytes = either (const False) (const True) (NAR.deserialise bytes)
          in assertTrue "all near-miss names accepted" (all (accepted . plain) names),
       -- Upstream carries names and targets as raw bytes: entries that
@@ -542,6 +566,212 @@
     ]
 
 -- ---------------------------------------------------------------------------
+-- NAR.Stream tests
+-- ---------------------------------------------------------------------------
+
+-- | Drive the streaming parser over the given chunks (all non-empty),
+-- then signal end of input, collecting events.
+runStream :: [ByteString] -> Either String [Stream.NarEvent]
+runStream chunks = go Stream.narStream chunks []
+  where
+    go step pending acc = case step of
+      Stream.NarFail err -> Left err
+      Stream.NarDone -> Right (reverse acc)
+      Stream.NarYield event continue -> go continue pending (event : acc)
+      Stream.NarAwait continue -> case pending of
+        [] -> go (continue BS.empty) [] acc
+        (chunk : rest) -> go (continue chunk) rest acc
+
+-- | Entry shapes the chunking-independence test sweeps: every node
+-- kind, empty and aligned contents, nesting, and a non-UTF-8 name.
+streamCorpus :: [NAR.NarEntry]
+streamCorpus =
+  [ NAR.NarRegular False "hello",
+    NAR.NarRegular True BS.empty,
+    NAR.NarRegular False (BS.replicate 8 0x41),
+    NAR.NarSymlink "/usr/bin/hello",
+    NAR.NarDirectory [],
+    NAR.NarDirectory
+      [ ("bin", NAR.NarDirectory [("hello", NAR.NarRegular True (BS.pack [42]))]),
+        ("lib", NAR.NarSymlink "../lib64"),
+        (BS.pack [0x66, 0xFF], NAR.NarRegular False "raw")
+      ]
+  ]
+
+-- | Merge consecutive contents slices.  Slice granularity deliberately
+-- follows the fed chunks, so parses of different chunkings compare by
+-- structure and content, not by how the input happened to arrive.
+coalesceChunks :: [Stream.NarEvent] -> [Stream.NarEvent]
+coalesceChunks (Stream.EventRegularChunk a : Stream.EventRegularChunk b : rest) =
+  coalesceChunks (Stream.EventRegularChunk (a <> b) : rest)
+coalesceChunks (event : rest) = event : coalesceChunks rest
+coalesceChunks [] = []
+
+-- | Split a byte string into fixed-size pieces.
+chunksOf :: Int -> ByteString -> [ByteString]
+chunksOf n bs
+  | BS.null bs = []
+  | otherwise = case BS.splitAt n bs of
+      (piece, rest) -> piece : chunksOf n rest
+
+-- | Pull a source dry, collecting its chunks (the terminating empty
+-- chunk excluded).
+collectChunks :: IO ByteString -> IO [ByteString]
+collectChunks pull = go []
+  where
+    go acc = do
+      chunk <- pull
+      if BS.null chunk then pure (reverse acc) else go (chunk : acc)
+
+-- | Pull a source dry into one byte string.
+drainSource :: IO ByteString -> IO ByteString
+drainSource pull = BS.concat <$> collectChunks pull
+
+-- | Hash a source chunk by chunk as it is pulled.
+hashPull :: Hash.HashContext -> IO ByteString -> IO Hash.NixHash
+hashPull ctx pull = do
+  chunk <- pull
+  if BS.null chunk
+    then pure (Hash.hashFinalize ctx)
+    else hashPull (Hash.hashUpdate ctx chunk) pull
+
+testStream :: IO Bool
+testStream =
+  runGroup
+    "NAR.Stream"
+    [ test "events for a regular file" $
+        assertEqual
+          "event sequence"
+          ( Right
+              [ Stream.EventRegularBegin True 2,
+                Stream.EventRegularChunk "hi",
+                Stream.EventRegularEnd
+              ]
+          )
+          (runStream [NAR.serialise (NAR.NarRegular True "hi")]),
+      test "events for a directory arrive entry by entry" $
+        let entry = NAR.NarDirectory [("a", NAR.NarSymlink "t"), ("b", NAR.NarRegular False "")]
+            expected =
+              [ Stream.EventDirectoryBegin,
+                Stream.EventEntryBegin "a",
+                Stream.EventSymlink "t",
+                Stream.EventEntryEnd,
+                Stream.EventEntryBegin "b",
+                Stream.EventRegularBegin False 0,
+                Stream.EventRegularEnd,
+                Stream.EventEntryEnd,
+                Stream.EventDirectoryEnd
+              ]
+         in assertEqual "event sequence" (Right expected) (runStream [NAR.serialise entry]),
+      test "byte-at-a-time chunking equals whole-input parsing" $
+        let normalize = fmap coalesceChunks
+            agrees entry =
+              let bytes = NAR.serialise entry
+               in normalize (runStream [bytes]) == normalize (runStream (map BS.singleton (BS.unpack bytes)))
+         in assertTrue "chunking-independent" (all agrees streamCorpus),
+      test "contents slices reassemble across misaligned chunks" $
+        -- 7-byte chunks sit deliberately askew of the 8-byte wire
+        -- alignment, so every length prefix and padding run straddles
+        -- a chunk boundary somewhere.
+        let payload = BS.pack (concat (replicate 40 [0 .. 7]))
+            bytes = NAR.serialise (NAR.NarRegular False payload)
+         in case runStream (chunksOf 7 bytes) of
+              Left err -> do
+                putStrLn ("  streaming parse failed: " ++ err)
+                pure False
+              Right events ->
+                assertEqual
+                  "reassembled contents"
+                  payload
+                  (BS.concat [c | Stream.EventRegularChunk c <- events]),
+      test "streaming agrees with deserialise on the malformed corpus" $
+        let evil name = NAR.serialise (NAR.NarDirectory [(name, NAR.NarRegular False "x")])
+            malformed =
+              [ outOfOrderDirNar,
+                badPaddingNar,
+                NAR.serialise (NAR.NarRegular False "x") <> "junk1234",
+                evil "..",
+                evil "a/b",
+                evil "nul"
+              ]
+            bothReject bytes = isLeft (runStream [bytes]) && isLeft (NAR.deserialise bytes)
+         in assertTrue "all rejected by both parsers" (all bothReject malformed),
+      test "the structural bound applies to streaming, not deserialise" $
+        -- deserialise instantiates the machine with its input's length
+        -- as the bound, so a name this size stays accepted there; the
+        -- streaming default caps structural strings at 64 KiB.
+        let longName = BS.replicate 70000 0x61
+            bytes = NAR.serialise (NAR.NarDirectory [(longName, NAR.NarRegular False "x")])
+         in do
+              ok1 <- assertTrue "deserialise accepts" (either (const False) (const True) (NAR.deserialise bytes))
+              ok2 <- assertTrue "streaming rejects" (isLeft (runStream [bytes]))
+              pure (ok1 && ok2),
+      test "a truncated archive fails" $
+        let bytes = NAR.serialise (NAR.NarRegular False "some contents here")
+         in assertLeft "eof" (runStream [BS.take (BS.length bytes - 9) bytes]),
+      test "the declared contents size arrives before the bytes" $
+        case runStream [NAR.serialise (NAR.NarRegular False (BS.replicate 24 0x2A))] of
+          Right (Stream.EventRegularBegin False declared : _) ->
+            assertEqual "declared size" 24 declared
+          other -> do
+            putStrLn ("    expected EventRegularBegin first, got: " ++ show other)
+            pure False,
+      test "withNarSource streams byte-identically to serialise" $ do
+        dir <- caseHackFixture "nova-cache-test-narsource"
+        createDirectory (dir <> "/sub")
+        -- Larger than two pull chunks, so mid-file continuation runs.
+        BS.writeFile (dir <> "/big.bin") (BS.replicate 300000 0x41)
+        BS.writeFile (dir <> "/sub/small") "tiny"
+        BS.writeFile (dir <> "/zero") ""
+        entry <- NAR.serialiseFromPathWith NAR.CaseHackDisabled dir
+        streamed <- NAR.withNarSource NAR.CaseHackDisabled dir drainSource
+        removeDirectoryRecursive dir
+        assertTrue "bytes equal" (NAR.serialise entry == streamed),
+      test "withNarSource under the case-hack matches the strict walk" $ do
+        dir <- caseHackFixture "nova-cache-test-narsource-hack"
+        BS.writeFile (dir <> "/Foo") "upper"
+        BS.writeFile (dir <> "/foo~nix~case~hack~1") "lower"
+        entry <- NAR.serialiseFromPathWith NAR.CaseHackEnabled dir
+        streamed <- NAR.withNarSource NAR.CaseHackEnabled dir drainSource
+        removeDirectoryRecursive dir
+        assertTrue "bytes equal" (NAR.serialise entry == streamed),
+      test "withNarSource keeps returning empty after the end" $ do
+        dir <- caseHackFixture "nova-cache-test-narsource-end"
+        BS.writeFile (dir <> "/f") "x"
+        ends <- NAR.withNarSource NAR.CaseHackDisabled dir $ \pull -> do
+          _ <- collectChunks pull
+          endA <- pull
+          endB <- pull
+          pure (endA, endB)
+        removeDirectoryRecursive dir
+        assertEqual "stable end" ("", "") ends,
+      test "pulled chunks parse back to the walked tree" $ do
+        dir <- caseHackFixture "nova-cache-test-narsource-loop"
+        BS.writeFile (dir <> "/data.bin") (BS.replicate 200000 0x5A)
+        entry <- NAR.serialiseFromPathWith NAR.CaseHackDisabled dir
+        chunks <- NAR.withNarSource NAR.CaseHackDisabled dir collectChunks
+        removeDirectoryRecursive dir
+        ok1 <- assertRight "strict parse of the stream" entry (NAR.deserialise (BS.concat chunks))
+        ok2 <- assertTrue "streaming parse of the chunk list" (either (const False) (const True) (runStream chunks))
+        pure (ok1 && ok2),
+      test "incremental hashing equals whole-input hashing" $
+        let payload = BS.pack [0 .. 255]
+            pieces = [BS.take 100 payload, BS.take 55 (BS.drop 100 payload), BS.drop 155 payload]
+            incremental = Hash.hashFinalize (foldl' Hash.hashUpdate Hash.hashInit pieces)
+         in do
+              ok1 <- assertEqual "split arbitrarily" (Hash.hashBytes payload) incremental
+              ok2 <- assertEqual "empty input" (Hash.hashBytes BS.empty) (Hash.hashFinalize Hash.hashInit)
+              pure (ok1 && ok2),
+      test "hashing a pulled source equals narHash of the walk" $ do
+        dir <- caseHackFixture "nova-cache-test-streamhash"
+        BS.writeFile (dir <> "/blob") (BS.replicate 150000 0x07)
+        entry <- NAR.serialiseFromPathWith NAR.CaseHackDisabled dir
+        digest <- NAR.withNarSource NAR.CaseHackDisabled dir (hashPull Hash.hashInit)
+        removeDirectoryRecursive dir
+        assertEqual "hash matches" (NAR.narHash entry) digest
+    ]
+
+-- ---------------------------------------------------------------------------
 -- NarInfo tests
 -- ---------------------------------------------------------------------------
 
@@ -693,6 +923,18 @@
                 putStrLn ("  parse failed: " ++ err)
                 pure False
               Right ni -> assertEqual "uint64 max" 18446744073709551615 (NarInfo.niNarSize ni),
+      -- Twenty digits still fit values past 2^64 - 1; upstream's
+      -- string2Int<uint64_t> refuses them, so signing one here would
+      -- produce a narinfo every real client rejects as corrupt.
+      test "a 20-digit size above the uint64 maximum rejects" $
+        let overCap =
+              T.unlines
+                [ "StorePath: /nix/store/aaaa-test",
+                  "URL: nar/test.nar.xz",
+                  "NarHash: sha256:def",
+                  "NarSize: 18446744073709551616"
+                ]
+         in assertLeft "size above uint64" (NarInfo.parseNarInfo overCap),
       -- Upstream assigns fields as it reads, so a duplicated scalar key
       -- resolves to the LAST value (Sig stays the repeatable exception).
       test "duplicate scalar keys resolve last-wins" $
@@ -958,6 +1200,11 @@
         assertEqual "valid" (Just "abc123def456") (Store.sanitizePath "abc123def456"),
       test "sanitizePath rejects windows device name" $
         assertEqual "device nul" Nothing (Store.sanitizePath "nul"),
+      test "sanitizePath rejects the zero-numbered devices" $ do
+        ok1 <- assertEqual "com0" Nothing (Store.sanitizePath "com0")
+        ok2 <- assertEqual "lpt0" Nothing (Store.sanitizePath "lpt0")
+        ok3 <- assertEqual "com10 stays valid" (Just "com10") (Store.sanitizePath "com10")
+        pure (ok1 && ok2 && ok3),
       test "sanitizePath rejects dotfile" $
         assertEqual "dotfile" Nothing (Store.sanitizePath ".hidden"),
       test "sanitizePath rejects a trailing dot" $
diff --git a/test/XzTest.hs b/test/XzTest.hs
new file mode 100644
--- /dev/null
+++ b/test/XzTest.hs
@@ -0,0 +1,217 @@
+-- | Tests for the bounded xz decoder.  A separate suite because it
+-- exists only under the @xz@ flag; the fixtures are real @xz -6@
+-- output embedded as hex, so no external tool runs at test time.
+module Main (main) where
+
+import Control.Exception (try)
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as BS
+import Data.Char (isDigit)
+import Data.IORef (newIORef, readIORef, writeIORef)
+import qualified NovaCache.Xz as Xz
+import System.Exit (exitFailure, exitSuccess)
+import System.IO (hFlush, stdout)
+
+-- ---------------------------------------------------------------------------
+-- Harness (mirrors test/Main.hs)
+-- ---------------------------------------------------------------------------
+
+test :: String -> IO Bool -> IO Bool
+test name action = do
+  putStr ("  " ++ name ++ "... ")
+  hFlush stdout
+  result <- action
+  putStrLn (if result then "OK" else "FAILED")
+  pure result
+
+assertEqual :: (Eq a, Show a) => String -> a -> a -> IO Bool
+assertEqual label expected actual
+  | expected == actual = pure True
+  | otherwise = do
+      putStrLn ""
+      putStrLn ("    " ++ label)
+      putStrLn ("    expected: " ++ show expected)
+      putStrLn ("    actual:   " ++ show actual)
+      pure False
+
+assertTrue :: String -> Bool -> IO Bool
+assertTrue _ True = pure True
+assertTrue label False = do
+  putStrLn ""
+  putStrLn ("    " ++ label ++ ": expected True")
+  pure False
+
+-- ---------------------------------------------------------------------------
+-- Fixtures
+-- ---------------------------------------------------------------------------
+
+-- | Decode a hex fixture literal.  Fixtures are constants, so a
+-- malformed one decodes short and the assertions fail loudly.
+unhex :: String -> ByteString
+unhex = BS.pack . pairs
+  where
+    pairs (hi : lo : rest) = case (hexVal hi, hexVal lo) of
+      (Just h, Just l) -> fromIntegral (h * 16 + l) : pairs rest
+      _ -> []
+    pairs _ = []
+    hexVal c
+      | isDigit c = Just (fromEnum c - fromEnum '0')
+      | c >= 'a' && c <= 'f' = Just (fromEnum c - fromEnum 'a' + 10)
+      | otherwise = Nothing
+
+-- | @xz -6@ of "nova-cache xz fixture\n" (22 bytes of output).
+textXz :: ByteString
+textXz =
+  unhex
+    "fd377a585a000004e6d6b44604c01a162101160000000000000000001caa3b74\
+    \0100156e6f76612d636163686520787a20666978747572650a0000002c84d5d2\
+    \3c233719000136160f914e5d1fb6f37d010000000004595a"
+
+-- | The bytes 'textXz' decompresses to.
+textPlain :: ByteString
+textPlain = "nova-cache xz fixture\n"
+
+-- | @xz -6@ of 65536 zero bytes: 148 bytes in, 64 KiB out - the
+-- expansion shape the output bound exists for.  The stream declares
+-- an 8 MiB dictionary, which the memory-bound test leans on.
+zerosXz :: ByteString
+zerosXz =
+  unhex
+    "fd377a585a000004e6d6b44604c05480800421011600000000000000e6b515ff\
+    \e0ffff004c5d00006ffdffffa3b7ff473e481572396151b89228e6a38607f9ee\
+    \e41e82d32fc53a3c014bb17ec98a8a4d2fa30dd97fa6e38c231153e05918c575\
+    \8ae277f8b6947f0c6ac0de7449645c9e3ad100005e654f49ca09af2600017080\
+    \800400006977c193b1c467fb020000000004595a"
+
+-- | Output size of 'zerosXz'.
+zerosLength :: Word
+zerosLength = 65536
+
+-- | Generous limits for the happy paths.
+openLimits :: Xz.XzLimits
+openLimits =
+  Xz.XzLimits
+    { Xz.xzMaxOutputBytes = 1024 * 1024,
+      Xz.xzMaxDecoderMemoryBytes = Xz.defaultXzDecoderMemoryBytes
+    }
+
+-- | 'openLimits' with the output bound replaced.
+boundedTo :: Word -> Xz.XzLimits
+boundedTo bound = openLimits {Xz.xzMaxOutputBytes = fromIntegral bound}
+
+-- | Split a byte string into fixed-size pieces.
+chunksOf :: Int -> ByteString -> [ByteString]
+chunksOf n bs
+  | BS.null bs = []
+  | otherwise = case BS.splitAt n bs of
+      (piece, rest) -> piece : chunksOf n rest
+
+-- | A chunk source over a fixed list (empty chunk on exhaustion), for
+-- feeding 'Xz.withXzSource'.
+listSource :: [ByteString] -> IO (IO ByteString)
+listSource chunks = do
+  remaining <- newIORef chunks
+  pure $ do
+    held <- readIORef remaining
+    case held of
+      [] -> pure BS.empty
+      (c : cs) -> do
+        writeIORef remaining cs
+        pure c
+
+-- ---------------------------------------------------------------------------
+-- Tests
+-- ---------------------------------------------------------------------------
+
+main :: IO ()
+main = do
+  putStrLn "nova-cache xz test suite"
+  putStrLn "========================"
+  results <-
+    sequence
+      [ test "roundtrip under the exact output bound" $
+          -- NarSize is exact, so output == bound must pass.
+          assertEqual
+            "text fixture"
+            (Right textPlain)
+            (Xz.decompress (boundedTo (fromIntegral (BS.length textPlain))) textXz),
+        test "high-expansion input inflates fully under an open bound" $
+          case Xz.decompress openLimits zerosXz of
+            Left err -> do
+              putStrLn ("    unexpected error: " ++ show err)
+              pure False
+            Right out -> do
+              ok1 <- assertEqual "length" zerosLength (fromIntegral (BS.length out))
+              ok2 <- assertTrue "all zero" (BS.all (== 0) out)
+              pure (ok1 && ok2),
+        test "output over the bound is refused" $
+          assertEqual
+            "far bound"
+            (Left (Xz.XzOutputOverBound 1000))
+            (Xz.decompress (boundedTo 1000) zerosXz),
+        test "one byte under the true size is refused" $
+          assertEqual
+            "tight bound"
+            (Left (Xz.XzOutputOverBound (fromIntegral (zerosLength - 1))))
+            (Xz.decompress (boundedTo (zerosLength - 1)) zerosXz),
+        test "garbage input is a stream error" $
+          assertTrue "garbage" (isStreamError (Xz.decompress openLimits "not an xz stream")),
+        test "a truncated stream is a stream error" $
+          assertTrue "truncated" (isStreamError (Xz.decompress openLimits (BS.take 40 textXz))),
+        test "concatenated streams decode as one output" $
+          -- Upstream decodes with LZMA_CONCATENATED; two streams
+          -- back-to-back are one valid input.
+          assertEqual
+            "two text streams"
+            (Right (textPlain <> textPlain))
+            (Xz.decompress openLimits (textXz <> textXz)),
+        test "trailing garbage after the stream is refused" $
+          assertTrue
+            "trailing garbage"
+            (isStreamError (Xz.decompress openLimits (textXz <> "garbage!"))),
+        test "a dictionary past the memory bound is refused" $
+          -- zerosXz declares an 8 MiB dictionary; cap the decoder at 1 MiB.
+          assertEqual
+            "memory bound"
+            (Left (Xz.XzMemoryOverBound smallMemory))
+            (Xz.decompress openLimits {Xz.xzMaxDecoderMemoryBytes = smallMemory} zerosXz),
+        test "withXzSource decompresses a chunked source" $ do
+          source <- listSource (chunksOf 7 textXz)
+          out <- Xz.withXzSource openLimits source drainSource
+          assertEqual "streamed output" textPlain out,
+        test "withXzSource throws past the output bound" $ do
+          source <- listSource (chunksOf 16 zerosXz)
+          outcome <-
+            try (Xz.withXzSource (boundedTo 1000) source drainSource) ::
+              IO (Either Xz.XzError ByteString)
+          assertEqual "thrown" (Left (Xz.XzOutputOverBound 1000)) outcome,
+        test "withXzSource keeps returning empty after the end" $ do
+          source <- listSource [textXz]
+          ends <- Xz.withXzSource openLimits source $ \pull -> do
+            _ <- drainSource pull
+            endA <- pull
+            endB <- pull
+            pure (endA, endB)
+          assertEqual "stable end" ("", "") ends
+      ]
+  if and results
+    then do
+      putStrLn ""
+      putStrLn ("All " ++ show (length results) ++ " tests passed.")
+      exitSuccess
+    else do
+      putStrLn ""
+      putStrLn "Some tests FAILED."
+      exitFailure
+  where
+    smallMemory = 1024 * 1024
+    isStreamError outcome = case outcome of
+      Left (Xz.XzStreamError _) -> True
+      _ -> False
+    drainSource pull = go []
+      where
+        go acc = do
+          chunk <- pull
+          if BS.null chunk
+            then pure (BS.concat (reverse acc))
+            else go (chunk : acc)
