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 +6/−0
- nova-cache.cabal +17/−5
- src/NovaCache/NAR.hs +108/−38
- src/NovaCache/NAR/Stream.hs +2/−2
- src/NovaCache/Xz.hs +9/−12
- test/Main.hs +100/−3
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"