packages feed

nova-cache 0.11.0.1 → 0.11.1.0

raw patch · 6 files changed

+242/−60 lines, 6 filesdep +unixdep +xzdep −lzma-staticPVP ok

version bump matches the API change (PVP)

Dependencies added: unix, xz

Dependencies removed: lzma-static

API changes (from Hackage documentation)

+ NovaCache.NAR: SerialiseOptions :: CaseHack -> ExecBitResolver -> SerialiseOptions
+ NovaCache.NAR: [soCaseHack] :: SerialiseOptions -> CaseHack
+ NovaCache.NAR: [soExecBit] :: SerialiseOptions -> ExecBitResolver
+ NovaCache.NAR: data SerialiseOptions
+ NovaCache.NAR: defaultExecBitResolver :: ExecBitResolver
+ NovaCache.NAR: defaultSerialiseOptions :: SerialiseOptions
+ NovaCache.NAR: serialiseFromPathOpts :: SerialiseOptions -> FilePath -> IO NarEntry
+ NovaCache.NAR: type ExecBitResolver = FilePath -> IO Bool
+ NovaCache.NAR: withNarSourceOpts :: SerialiseOptions -> FilePath -> (IO ByteString -> IO a) -> IO a

Files

CHANGELOG.md view
@@ -1,5 +1,11 @@ # Changelog +## 0.11.1.0 - 2026-08-26++- **The default executable answer on POSIX is the file's own owner-execute bit, matching upstream's dump.** `defaultExecBitResolver` (and so both producers before any custom resolver) read `getPermissions`, which answers from `access(2)`: what the calling process may do. Root read any execute bit as executable, ACLs counted, and a file the caller does not own answered by the caller's groups; upstream reads `st_mode & S_IXUSR`, a property of the file alone, and the flag lands in NAR bytes, so the divergence moved a hash exactly where the caller was unusual. The POSIX default now reads the mode bit directly; Windows keeps the extension answer, which is all the platform has.+- **The xz codec's binding moves from the deprecated `lzma-static` to its successor, `xz`.** Hackage marks `lzma-static` deprecated in favor of `xz` and `xz-clib`, and it has been frozen at liblzma 5.2.5 since 2022. The successor comes from the same repository with the same `Codec.Compression.Lzma` module (its Haskell sources are unchanged across the rename), so `NovaCache.Xz` is untouched; what changes is the bundled C: a current liblzma via `xz-clib` instead of 2022's. Two consequences are pinned in place rather than left to chance. The successor asks pkg-config for a system liblzma by default and bundles only as a fallback, so which library a build linked would have depended on the host; `cabal.project` pins the flag off and every build links the bundled sources, the behavior `lzma-static` always had (a consumer wanting the same guarantee carries the same one-line constraint, since a dependency's flag cannot be set from a `.cabal` file). And the package description now needs Cabal 3.4: under 3.0 a bare `build-depends` name is shadowed by a like-named in-package component, so the `xz` sublibrary could not have named the `xz` package at all, the hazard the `zstandard` sublibrary's name already dodges.+- **A per-file executable-bit resolver: `SerialiseOptions`, `serialiseFromPathOpts`, `withNarSourceOpts`.** Both NAR producers answered "is this file executable" only through `getPermissions`, which on Windows answers from the file extension, so a store that models the bit any other way (nova-nix keeps it in an NTFS alternate data stream) could not serialise correct NAR bytes through the streaming source at all, and wrapping the eager serialiser meant a second walk that re-derived on-disk paths from NAR entry names and got them wrong under the case hack. The resolver is called with the on-disk spelling the walk is actually reading - under the case hack, the suffixed name - and its answer lands verbatim in the executable flag, eager and streaming alike. `defaultSerialiseOptions` reproduces the previous behavior exactly, and the existing entry points are unchanged.+ ## 0.11.0.1 - 2026-08-21  - **`NovaCache.Bzip2` accepts trailing bytes that do not begin another stream, matching upstream.** The decoder re-initialized at every clean stream end while input remained and fed whatever followed to libbz2's magic check, so a stray NUL, a newline, or any non-stream trailer after the last stream failed the decode. Upstream C++ Nix no longer has a bzip2 sink of its own: `compression.cc` drives every libarchive-supported codec through `ArchiveDecompressionSource`, and libarchive decodes the payload and ignores such a trailer. Measured against libarchive 3.8.2 driven exactly as Nix drives it (`filter_all` + `format_raw` + `format_empty`), four of seven cases diverged, so a historical `.nar.bz2` carrying one stray byte substituted under `nix copy` and failed here, falling through to a source build while the operator was told the cache object was corrupt. Trailing bytes that do begin a stream header are still decoded as a concatenated stream, and a truncated one is still refused: libarchive refuses those too, and silently truncating a real stream is the failure mode a bounded decoder exists to prevent. One divergence is deliberate and documented in place: a trailer that is a well-formed header carrying no block data (`BZh9` alone) is refused here and accepted by libarchive, which buffers past it, and refusing is the safer side of a case that does not arise in practice.
nova-cache.cabal view
@@ -1,6 +1,10 @@-cabal-version:      3.0+cabal-version:      3.4+-- 3.4 so a bare name in build-depends always means the external+-- package: the xz sublibrary must depend on the xz package, and under+-- 3.0 an in-package component name shadowed its external namesake+-- (the hazard the zstandard library's name dodges, documented there). name:               nova-cache-version:            0.11.0.1+version:            0.11.1.0 synopsis:           Pure-first Nix binary cache protocol library description:   A pure-first library implementing the Nix binary cache protocol -@@ -62,6 +66,12 @@     , vector              >= 0.12 && < 0.14     , wai                 >= 3.2 && < 3.3 +  -- The POSIX default executable answer reads the file mode directly+  -- (S_IXUSR, as upstream's dump does); Windows has no mode bit and+  -- no unix package.+  if !os(windows)+    build-depends: unix  >= 2.8 && < 2.9+   hs-source-dirs:   src   default-language:  Haskell2010   default-extensions:@@ -75,8 +85,8 @@  -- The bounded xz decoder as a solver-visible opt-in: a consumer that -- substitutes foreign caches writes build-depends: nova-cache:xz and--- gets the module; everyone else never builds lzma-static's bundled--- liblzma.  This replaces the manual xz flag - a dependency's flag+-- gets the module; everyone else never builds the xz package's+-- bundled liblzma.  This replaces the manual xz flag - a dependency's flag -- cannot be set from a consumer's .cabal file, which forced mirror -- flags and matching constraints downstream (nova-nix#27) - while -- keeping the 0.5.0.0 lesson: xz stays out of the default install.@@ -97,7 +107,9 @@   build-depends:       base                >= 4.22 && < 5     , bytestring          >= 0.11 && < 0.13-    , lzma-static         >= 5.2.5 && < 5.3+    -- The external xz package, not this very sublibrary: bare names+    -- are external from cabal-version 3.4 on.+    , xz                  >= 5.6.3 && < 5.7  -- The bounded bzip2 decoder, the same solver-visible opt-in as xz. -- Historical cache.nixos.org narinfos declare Compression: bzip2, and
src/NovaCache/NAR.hs view
@@ -29,7 +29,13 @@     narHash,     serialiseFromPath,     serialiseFromPathWith,+    serialiseFromPathOpts,     withNarSource,+    withNarSourceOpts,+    SerialiseOptions (..),+    defaultSerialiseOptions,+    ExecBitResolver,+    defaultExecBitResolver,     CaseHack (..),     defaultCaseHack,     caseHackSuffix,@@ -70,8 +76,6 @@ import System.Directory.OsPath   ( doesDirectoryExist,     doesFileExist,-    executable,-    getPermissions,     getSymbolicLinkTarget,     listDirectory,     pathIsSymbolicLink,@@ -82,10 +86,12 @@ #ifdef mingw32_HOST_OS import qualified Data.Text as T import qualified Data.Text.Encoding as TE+import System.Directory.OsPath (executable, getPermissions) import System.IO (Handle, IOMode (ReadMode), hClose, hFileSize, openBinaryFile) #else import qualified Data.ByteString.Char8 as BS8 import System.IO (Handle, IOMode (ReadMode), hClose, hFileSize, latin1, openBinaryFile)+import qualified System.Posix.Files as Posix #endif  -- ---------------------------------------------------------------------------@@ -277,6 +283,61 @@ caseHackSuffix :: ByteString caseHackSuffix = "~nix~case~hack~" +-- | Answers "is this file executable" for one regular file during a+-- walk.  Called with the platform-native ON-DISK path - under+-- 'CaseHackEnabled' that is the suffixed spelling the walk is+-- reading, not the stripped NAR entry name - and the answer lands+-- verbatim in the entry's (or stream's) executable flag.  Lets a+-- store that models the bit outside POSIX permissions (an NTFS+-- alternate data stream, a sidecar, a database) serialise correct+-- NAR bytes in the same walk, streaming included.+--+-- The path is the walk's own 'decodeFS' rendering: pass it to any+-- 'FilePath'-taking API as-is; 'encodeFS' it first to compare raw+-- bytes, since a non-UTF-8 name arrives with surrogate escapes.+type ExecBitResolver = FilePath -> IO Bool++#ifdef mingw32_HOST_OS++-- | The stock resolver on Windows: 'getPermissions', which answers+-- from the file extension - the platform has no mode bit to read.+defaultExecBitResolver :: ExecBitResolver+defaultExecBitResolver path = executable <$> (getPermissions =<< encodeFS path)++#else++-- | The stock resolver on POSIX: the owner-execute bit of the file's+-- own mode, as upstream's dump reads it (@st_mode & S_IXUSR@).  Not+-- 'System.Directory.OsPath.getPermissions', which answers from+-- @access(2)@ - what the CALLING PROCESS may do - and so diverges for+-- root (any execute bit reads as executable), ACLs, and files the+-- caller does not own; the flag lands in NAR bytes, so that+-- divergence moves a hash (#65).+defaultExecBitResolver :: ExecBitResolver+defaultExecBitResolver path = do+  status <- Posix.getSymbolicLinkStatus path+  pure (Posix.intersectFileModes (Posix.fileMode status) Posix.ownerExecuteMode /= Posix.nullFileMode)++#endif++-- | How a tree is read for serialisation: the case-hack mode and the+-- executable-bit source.+data SerialiseOptions = SerialiseOptions+  { -- | Directory-name case-hack resolution (see 'CaseHack').+    soCaseHack :: !CaseHack,+    -- | Where a regular file's executable flag comes from.+    soExecBit :: !ExecBitResolver+  }++-- | 'defaultCaseHack' and 'defaultExecBitResolver': with these,+-- 'serialiseFromPathOpts' is exactly 'serialiseFromPath'.+defaultSerialiseOptions :: SerialiseOptions+defaultSerialiseOptions =+  SerialiseOptions+    { soCaseHack = defaultCaseHack,+      soExecBit = defaultExecBitResolver+    }+ -- | Walk a filesystem path and build a 'NarEntry' under -- 'defaultCaseHack'. --@@ -284,36 +345,40 @@ -- classifies each path as symlink, directory, or regular file and -- delegates to pure constructors. serialiseFromPath :: FilePath -> IO NarEntry-serialiseFromPath = serialiseFromPathWith defaultCaseHack+serialiseFromPath = serialiseFromPathOpts defaultSerialiseOptions  -- | 'serialiseFromPath' with the case-hack mode explicit, for callers -- and tests that need behavior independent of the host platform. serialiseFromPathWith :: CaseHack -> FilePath -> IO NarEntry-serialiseFromPathWith mode path = walkPath mode =<< encodeFS path+serialiseFromPathWith mode = serialiseFromPathOpts defaultSerialiseOptions {soCaseHack = mode} +-- | 'serialiseFromPath' with every knob explicit.+serialiseFromPathOpts :: SerialiseOptions -> FilePath -> IO NarEntry+serialiseFromPathOpts opts path = walkPath opts =<< encodeFS path+ -- | Walk one platform-native path.  The walk runs on 'OsPath' so child -- names reach the archive byte-true ('osPathBytes'); only the root -- enters as 'FilePath', and the root's own name never appears in a -- NAR.-walkPath :: CaseHack -> OsPath -> IO NarEntry-walkPath mode path = do+walkPath :: SerialiseOptions -> OsPath -> IO NarEntry+walkPath opts path = do   isSym <- pathIsSymbolicLink path   if isSym     then NarSymlink <$> (osPathBytes =<< getSymbolicLinkTarget path)     else do       isDir <- doesDirectoryExist path       if isDir-        then buildDirectory mode path-        else buildRegularFile path+        then buildDirectory opts path+        else buildRegularFile (soExecBit opts) path  -- | Build a directory entry by recursively walking children.-buildDirectory :: CaseHack -> OsPath -> IO NarEntry-buildDirectory mode path = do-  resolved <- resolvedDirEntries mode path+buildDirectory :: SerialiseOptions -> OsPath -> IO NarEntry+buildDirectory opts path = do+  resolved <- resolvedDirEntries (soCaseHack opts) path   NarDirectory <$> traverse walkChild resolved   where     walkChild (entryName, diskName) = do-      entry <- walkPath mode (path </> diskName)+      entry <- walkPath opts (path </> diskName)       pure (entryName, entry)  -- | A directory's children as (NAR name, on-disk name) pairs under the@@ -369,13 +434,13 @@         [] -> Right resolved  -- | Build a regular file entry, checking the executable bit.-buildRegularFile :: OsPath -> IO NarEntry-buildRegularFile path = do+buildRegularFile :: ExecBitResolver -> OsPath -> IO NarEntry+buildRegularFile resolver path = do   isFile <- doesFileExist path   if isFile     then do       contents <- readFileBytes path-      isExec <- checkExecutable path+      isExec <- resolveExecBit resolver path       pure (NarRegular isExec contents)     else specialFileFailure path @@ -389,11 +454,11 @@   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.-checkExecutable :: OsPath -> IO Bool-checkExecutable path = executable <$> getPermissions path+-- | Run the resolver on a platform-native path.  The resolver contract+-- is 'FilePath', so the path bridges through 'decodeFS' - the same+-- interop seam as 'readFileBytes'.+resolveExecBit :: ExecBitResolver -> OsPath -> IO Bool+resolveExecBit resolver path = resolver =<< decodeFS path  -- | Read a file's contents by platform-native path.  The byte-string -- file API still takes 'FilePath', so the path bridges through@@ -459,8 +524,9 @@  -- | 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+-- structure is planned up front (names, kinds, symlink targets,+-- executable flags - never contents), so the resolver's IO runs+-- before the first pull; 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@@ -475,9 +541,13 @@ -- 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+withNarSource mode = withNarSourceOpts defaultSerialiseOptions {soCaseHack = mode}++-- | 'withNarSource' with every knob explicit.+withNarSourceOpts :: SerialiseOptions -> FilePath -> (IO ByteString -> IO a) -> IO a+withNarSourceOpts opts root consume = do   rootPath <- encodeFS root-  segments <- planSegments mode rootPath+  segments <- planSegments opts rootPath   stateRef <- newIORef (SourceSegments segments)   consume (pullChunk stateRef) `finally` closeCurrent stateRef   where@@ -532,9 +602,9 @@ -- | 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+planSegments :: SerialiseOptions -> OsPath -> IO [NarSegment]+planSegments opts path = do+  pieces <- planNode opts path   pure (coalesce (PieceBytes (narStr tokMagic) : pieces))  -- | Plan pieces before coalescing: structural builders, or a deferred@@ -558,8 +628,8 @@        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+planNode :: SerialiseOptions -> OsPath -> IO [PlanPiece]+planNode opts path = do   isSym <- pathIsSymbolicLink path   if isSym     then do@@ -568,15 +638,15 @@     else do       isDir <- doesDirectoryExist path       if isDir-        then planDirectory mode path-        else planRegular path+        then planDirectory opts path+        else planRegular (soExecBit opts) 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+planDirectory :: SerialiseOptions -> OsPath -> IO [PlanPiece]+planDirectory opts path = do+  resolved <- resolvedDirEntries (soCaseHack opts) path   children <- traverse planChild (sortBy (comparing fst) resolved)   pure     ( PieceBytes (narStr tokLParen <> narStr tokType <> narStr tokDirectory)@@ -585,7 +655,7 @@     )   where     planChild (entryName, diskName) = do-      node <- planNode mode (path </> diskName)+      node <- planNode opts (path </> diskName)       pure         ( PieceBytes             ( narStr tokEntry@@ -601,12 +671,12 @@ -- | 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+planRegular :: ExecBitResolver -> OsPath -> IO [PlanPiece]+planRegular resolver path = do   isFile <- doesFileExist path   if isFile     then do-      isExec <- checkExecutable path+      isExec <- resolveExecBit resolver path       pure         [ PieceBytes             ( narStr tokLParen
src/NovaCache/NAR/Stream.hs view
@@ -169,8 +169,8 @@ narStream = narStreamBounded maxWireStringBytes  -- | The largest structural-string bound 'narStreamBounded' honors:--- 'Int''s ceiling less alignment headroom, so a payload at the bound--- still fits 'Int' together with its padding.+-- the ceiling of 'Int' less alignment headroom, so a payload at the+-- bound still fits 'Int' together with its padding. structuralBoundCeiling :: Word64 structuralBoundCeiling =   fromIntegral (maxBound :: Int) - fromIntegral (narAlignment - 1)
src/NovaCache/Xz.hs view
@@ -15,8 +15,8 @@ -- @LZMA_CONCATENATED@ decoder in libutil's compression sink. -- -- This module lives in the public @nova-cache:xz@ sublibrary.  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+-- @xz@ dependency bundles liblzma's C sources (via @xz-clib@), 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 dependency broke downstream -- installs once already (0.5.0.0).  Consumers that substitute from@@ -154,16 +154,13 @@ -- same exception. -- -- Despite the bracket-shaped name there is no bracket to run: the--- binding ("Codec.Compression.Lzma") exposes no teardown for a live--- 'Lzma.DecompressStream' - it runs @lzma_end@ itself on the clean--- end path and otherwise leaves it to the stream's ForeignPtr--- finalizer.  A pull that throws, or a consumer that exits early,--- therefore strands the decoder state (up to 'xzMaxDecoderMemoryBytes')--- until a GC runs the finalizer.  Undo condition: a lzma-static--- release surfacing live-stream teardown in the high-level API (its--- internal @LibLzma.endLzmaStream@ is what the fix needs), at which--- point this becomes a real bracket ending the stream on every exit--- path.+-- binding ("Codec.Compression.Lzma") runs @lzma_end@ itself on the+-- clean end path and otherwise frees the decoder through the+-- stream's ForeignPtr finalizer - the lifecycle the ecosystem's+-- zlib and bzlib bindings use for the same job.  A pull that+-- throws, or a consumer that exits early, holds the decoder state+-- (bounded by 'xzMaxDecoderMemoryBytes') until a GC runs the+-- finalizer. withXzSource :: XzLimits -> IO ByteString -> (IO ByteString -> IO a) -> IO a withXzSource limits compressedSource consume = do   start <- Lzma.decompressIO (decompressParams limits)
test/Main.hs view
@@ -11,8 +11,8 @@ 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.IORef (atomicModifyIORef', newIORef, readIORef)+import Data.List (isSuffixOf, sort) import Data.Maybe (isJust) import Data.Text (Text) import qualified Data.Text as T@@ -31,7 +31,7 @@ import qualified NovaCache.Store as Store import qualified NovaCache.StorePath as StorePath import qualified NovaCache.Validate as Validate-import System.Directory (createDirectory, getTemporaryDirectory, listDirectory, removeDirectoryRecursive)+import System.Directory (createDirectory, getPermissions, getTemporaryDirectory, listDirectory, removeDirectoryRecursive, setOwnerExecutable, setPermissions) import System.Exit (exitFailure, exitSuccess) import System.IO (hFlush, stdout) import qualified System.Info@@ -501,6 +501,78 @@         pure $ case (outcome :: Either SomeException NAR.NarEntry) of           Left _ -> True           Right _ -> False,+      -- The resolver is the executable-bit source: its answer lands+      -- verbatim, independent of permissions or extension.+      test "serialiseFromPathOpts takes the exec bit from the resolver" $ do+        dir <- caseHackFixture "nova-cache-test-execresolver"+        BS.writeFile (dir <> "/plain") "p"+        let opts =+              NAR.defaultSerialiseOptions+                { NAR.soCaseHack = NAR.CaseHackDisabled,+                  NAR.soExecBit = \_ -> pure True+                }+        entry <- NAR.serialiseFromPathOpts opts dir+        removeDirectoryRecursive dir+        assertEqual+          "resolver answer"+          (NAR.NarDirectory [("plain", NAR.NarRegular True "p")])+          entry,+      -- A real on-disk exec bit round-trips through the default+      -- resolver; no earlier fixture ever set one.  POSIX-gated:+      -- Windows has no mode bit for the default to read.+      test "a real owner-exec bit round-trips from disk" $+        if System.Info.os == "mingw32"+          then pure True+          else do+            dir <- caseHackFixture "nova-cache-test-realexec"+            BS.writeFile (dir <> "/doc") "txt"+            BS.writeFile (dir <> "/tool") "bin"+            perms <- getPermissions (dir <> "/tool")+            setPermissions (dir <> "/tool") (setOwnerExecutable True perms)+            entry <- NAR.serialiseFromPathWith NAR.CaseHackDisabled dir+            removeDirectoryRecursive dir+            assertEqual+              "owner bit from the file mode"+              ( NAR.NarDirectory+                  [ ("doc", NAR.NarRegular False "txt"),+                    ("tool", NAR.NarRegular True "bin")+                  ]+              )+              entry,+      -- The resolver contract, pinned as an exact call set for both+      -- producers: once per regular file, never for the directory,+      -- and always the on-disk spelling - under the case hack, the+      -- suffixed name the walk reads, not the stripped entry name.+      test "the resolver sees the on-disk case-hacked spelling" $ do+        dir <- caseHackFixture "nova-cache-test-execdisk"+        BS.writeFile (dir <> "/Foo") "upper"+        BS.writeFile (dir <> "/foo~nix~case~hack~1") "lower"+        seenWalk <- newIORef []+        seenPlan <- newIORef []+        let recordingOpts ref =+              NAR.defaultSerialiseOptions+                { NAR.soCaseHack = NAR.CaseHackEnabled,+                  NAR.soExecBit = \path ->+                    atomicModifyIORef' ref (\paths -> (path : paths, ())) >> pure False+                }+            baseName = reverse . takeWhile (\c -> c /= '/' && c /= '\\') . reverse+        _ <- NAR.serialiseFromPathOpts (recordingOpts seenWalk) dir+        _ <- NAR.withNarSourceOpts (recordingOpts seenPlan) dir drainSource+        removeDirectoryRecursive dir+        walked <- readIORef seenWalk+        planned <- readIORef seenPlan+        let expected = ["Foo", "foo~nix~case~hack~1"]+        walkOk <-+          assertEqual+            "eager walk call set"+            expected+            (sort (map baseName walked))+        planOk <-+          assertEqual+            "streaming plan call set"+            expected+            (sort (map baseName planned))+        pure (walkOk && planOk),       -- The walk's boundary encoding: a Unicode disk name enters the       -- archive as its UTF-8 bytes on every platform.       test "serialiseFromPath encodes a Unicode disk name as UTF-8" $ do@@ -765,6 +837,31 @@         streamed <- NAR.withNarSource NAR.CaseHackEnabled dir drainSource         removeDirectoryRecursive dir         assertTrue "bytes equal" (NAR.serialise entry == streamed),+      -- The streaming planner asks the same resolver the eager walk+      -- does, so a custom executable-bit source streams byte-equal.+      test "withNarSourceOpts streams the resolver's flags byte-identically" $ do+        dir <- caseHackFixture "nova-cache-test-narsource-exec"+        BS.writeFile (dir <> "/doc") "txt"+        BS.writeFile (dir <> "/tool") "bin"+        let opts =+              NAR.defaultSerialiseOptions+                { NAR.soCaseHack = NAR.CaseHackDisabled,+                  NAR.soExecBit = \path -> pure ("tool" `isSuffixOf` path)+                }+        entry <- NAR.serialiseFromPathOpts opts dir+        streamed <- NAR.withNarSourceOpts opts dir drainSource+        removeDirectoryRecursive dir+        flagsOk <-+          assertEqual+            "eager flags"+            ( NAR.NarDirectory+                [ ("doc", NAR.NarRegular False "txt"),+                  ("tool", NAR.NarRegular True "bin")+                ]+            )+            entry+        bytesOk <- assertTrue "stream equals serialise" (NAR.serialise entry == streamed)+        pure (flagsOk && bytesOk),       test "withNarSource keeps returning empty after the end" $ do         dir <- caseHackFixture "nova-cache-test-narsource-end"         BS.writeFile (dir <> "/f") "x"