packages feed

nova-cache 0.6.0.0 → 0.7.0.0

raw patch · 6 files changed

+223/−83 lines, 6 filesdep ~directorydep ~filepathPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: directory, filepath

API changes (from Hackage documentation)

- NovaCache.NAR: NarDirectory :: ![(Text, NarEntry)] -> NarEntry
+ NovaCache.NAR: NarDirectory :: ![(ByteString, NarEntry)] -> NarEntry
- NovaCache.NAR: NarSymlink :: !Text -> NarEntry
+ NovaCache.NAR: NarSymlink :: !ByteString -> NarEntry
- NovaCache.NAR: caseHackSuffix :: Text
+ NovaCache.NAR: caseHackSuffix :: ByteString
- NovaCache.SafeName: hasTrailingDotOrSpace :: Text -> Bool
+ NovaCache.SafeName: hasTrailingDotOrSpace :: ByteString -> Bool
- NovaCache.SafeName: isReservedDeviceName :: Text -> Bool
+ NovaCache.SafeName: isReservedDeviceName :: ByteString -> Bool

Files

CHANGELOG.md view
@@ -1,5 +1,13 @@ # Changelog +## 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).+- **`serialiseFromPath` walks the filesystem byte-true.** The walk moves to the platform-native path type (`System.Directory.OsPath`). On POSIX, names and symlink targets enter the archive as the raw bytes the filesystem reports - previously a non-UTF-8 on-disk name was silently rewritten with replacement characters, changing the archived name and hash. On Windows, names are the UTF-8 encoding of their UTF-16 spelling, and a name holding an unpaired surrogate (no UTF-8 form exists, and upstream defines no byte spelling) fails loudly instead of guessing. Public signatures are unchanged.+- **`NovaCache.SafeName` predicates take bytes.** `isReservedDeviceName` and `hasTrailingDotOrSpace` operate on `ByteString`, the form NAR entry names have; Text callers (the store-key allowlist) encode first.+- **`caseHackSuffix` is a `ByteString`**, matching the entry names it marks.+- Dependency floors rise to `directory >= 1.3.8` and `filepath >= 1.4.100` (the `OsPath` API); both the 1.4 and 1.5 `filepath` lineages are supported.+ ## 0.6.0.0 - 2026-07-20  - **NAR serialisation understands upstream's case-hack.** A case-folding store filesystem (Windows NTFS, default macOS APFS) cannot hold two sibling names differing only by case, so an extractor there materializes the collision with upstream's reversible `~nix~case~hack~<N>` suffix. `serialiseFromPath` now strips the suffix on those platforms - entries are emitted under their NAR names, ordered by them - so a hacked tree reproduces its original NAR bytes; two on-disk names stripping to the same entry fail loudly. New `serialiseFromPathWith` takes the mode explicitly (`CaseHack`, `defaultCaseHack`, `caseHackSuffix` exported); on other platforms a file legitimately named with the suffix still serialises verbatim. The platform-dependent default of `serialiseFromPath` is the behavior change behind the major bump.
nova-cache.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               nova-cache-version:            0.6.0.0+version:            0.7.0.0 synopsis:           Pure-first Nix binary cache protocol library description:   A pure-first library implementing the Nix binary cache protocol -@@ -48,8 +48,8 @@     , bytestring          >= 0.11 && < 0.13     , containers          >= 0.6 && < 0.9     , crypton             >= 1.1 && < 2-    , directory           >= 1.3 && < 1.4-    , filepath            >= 1.4 && < 1.6+    , directory           >= 1.3.8 && < 1.4+    , filepath            >= 1.4.100 && < 1.6     , http-types          >= 0.12 && < 0.13     , ram                 >= 0.20 && < 1     , text                >= 2.0 && < 2.2
src/NovaCache/NAR.hs view
@@ -1,3 +1,5 @@+{-# LANGUAGE CPP #-}+ -- | NAR (Nix ARchive) binary format serialization and deserialization. -- -- NAR is a deterministic archive format used by Nix. All strings are@@ -11,6 +13,9 @@ -- directory ::= (entry)* -- entry     ::= "entry" "(" "name" STRING "node" node ")" -- @+--+-- Entry names and symlink targets are raw byte strings: the format+-- imposes no text encoding on them, and upstream carries them verbatim. module NovaCache.NAR   ( NarEntry (..),     serialise,@@ -28,16 +33,14 @@ 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.List (sort, sortBy) import Data.Ord (comparing)-import Data.Text (Text)-import qualified Data.Text as T-import qualified Data.Text.Encoding as TE import Data.Word (Word64) import qualified NovaCache.Hash as Hash import NovaCache.SafeName (hasTrailingDotOrSpace, isReservedDeviceName)-import System.Directory+import System.Directory.OsPath   ( doesDirectoryExist,     doesFileExist,     executable,@@ -46,8 +49,15 @@     listDirectory,     pathIsSymbolicLink,   )-import System.FilePath ((</>)) import qualified System.Info+import System.OsPath (OsPath, decodeFS, encodeFS, (</>))+import qualified System.OsPath as OP+#ifdef mingw32_HOST_OS+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+#else+import System.IO (latin1)+#endif  -- --------------------------------------------------------------------------- -- Types@@ -57,12 +67,14 @@ data NarEntry   = -- | Regular file: executable flag and contents.     NarRegular !Bool !ByteString-  | -- | Symbolic link: target path.-    NarSymlink !Text-  | -- | Directory: list of (name, entry) pairs.  Names must be unique; the-    -- serializer sorts them and 'deserialise' rejects duplicate or+  | -- | Symbolic link: target path, as the raw bytes the archive+    -- carries.+    NarSymlink !ByteString+  | -- | Directory: list of (name, entry) pairs.  Names are the raw+    -- bytes the archive carries; they must be unique, the serializer+    -- sorts them bytewise, and 'deserialise' rejects duplicate or     -- out-of-order names.-    NarDirectory ![(Text, NarEntry)]+    NarDirectory ![(ByteString, NarEntry)]   deriving (Eq, Show)  -- ---------------------------------------------------------------------------@@ -121,7 +133,7 @@     <> narStr tokType     <> narStr tokSymlink     <> narStr tokTarget-    <> narStr (TE.encodeUtf8 target)+    <> narStr target     <> narStr tokRParen buildNode (NarDirectory entries) =   narStr tokLParen@@ -136,12 +148,12 @@ execFlag False = mempty  -- | Build a single directory entry: @"entry" "(" "name" \<n\> "node" \<node\> ")"@.-buildDirEntry :: (Text, NarEntry) -> B.Builder+buildDirEntry :: (ByteString, NarEntry) -> B.Builder buildDirEntry (entryName, entry) =   narStr tokEntry     <> narStr tokLParen     <> narStr tokName-    <> narStr (TE.encodeUtf8 entryName)+    <> narStr entryName     <> narStr tokNode     <> buildNode entry     <> narStr tokRParen@@ -225,7 +237,8 @@           -- a regular node without it is malformed - reject, matching Nix.           Left ("expected 'executable' or 'contents' in regular, got: " ++ show tok) --- | Parse a symlink node.+-- | 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@@ -233,8 +246,7 @@   (targetPath, afterPath) <- readStr afterTgt   (rp, final) <- readStr afterPath   expect tokRParen rp-  symTarget <- decodeUtf8Safe targetPath-  pure (NarSymlink symTarget, final)+  pure (NarSymlink targetPath, final)  -- | Parse a directory node (zero or more child entries). parseDirectory :: NarParser NarEntry@@ -256,33 +268,35 @@           (entry, afterEntry) <- parseNode afterNodeTok           (rp, afterRp) <- readStr afterEntry           expect tokRParen rp-          decodedName <- decodeUtf8Safe entryName-          _ <- checkName prev decodedName-          go (Just decodedName) ((decodedName, entry) : acc) afterRp+          _ <- checkName prev entryName+          go (Just entryName) ((entryName, entry) : acc) afterRp     -- NAR directory entries must have safe names in strictly increasing-    -- (sorted, unique) 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.+    -- (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-      | T.null name = Left "empty NAR directory entry 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 == ".." || T.any (\c -> c == '/' || c == '\\' || c == '\0' || c == ':') name =-          Left ("unsafe NAR directory entry name: " ++ T.unpack 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: " ++ T.unpack 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: " ++ T.unpack 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: " ++ T.unpack name)+          Left ("NAR directory entries not strictly increasing: " ++ show name)       | otherwise = Right ()  -- ---------------------------------------------------------------------------@@ -347,12 +361,6 @@   | got == expected = Right ()   | otherwise = Left ("expected " ++ show expected ++ ", got " ++ show got) --- | Decode a UTF-8 bytestring, converting decode failures to parse errors.-decodeUtf8Safe :: ByteString -> Either String Text-decodeUtf8Safe bs = case TE.decodeUtf8' bs of-  Right txt -> Right txt-  Left err -> Left ("invalid UTF-8 in NAR: " ++ show err)- -- --------------------------------------------------------------------------- -- Hashing -- ---------------------------------------------------------------------------@@ -386,25 +394,34 @@ -- | Upstream's reversible collision suffix (its @caseHackSuffix@): an -- extractor appends @~nix~case~hack~<N>@ to a sibling whose name -- case-folds onto an earlier one, and serialisation strips from the--- suffix onward to recover the NAR name.-caseHackSuffix :: Text+-- suffix onward to recover the NAR name.  Bytes, matching the entry+-- names it marks.+caseHackSuffix :: ByteString caseHackSuffix = "~nix~case~hack~"  -- | Walk a filesystem path and build a 'NarEntry' under -- 'defaultCaseHack'. ----- This is the module's IO boundary. It classifies each path as symlink,--- directory, or regular file, then delegates to pure constructors.+-- 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. serialiseFromPath :: FilePath -> IO NarEntry serialiseFromPath = serialiseFromPathWith defaultCaseHack  -- | '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 = do+serialiseFromPathWith mode path = walkPath mode =<< 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   isSym <- pathIsSymbolicLink path   if isSym-    then NarSymlink . T.pack <$> getSymbolicLinkTarget path+    then NarSymlink <$> (osPathBytes =<< getSymbolicLinkTarget path)     else do       isDir <- doesDirectoryExist path       if isDir@@ -417,40 +434,45 @@ -- 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.-buildDirectory :: CaseHack -> FilePath -> IO NarEntry+buildDirectory :: CaseHack -> OsPath -> IO NarEntry buildDirectory mode path = do   names <- sort <$> listDirectory path-  case unhackedDirNames mode names of-    Left (first, second) ->+  named <- traverse withNameBytes names+  case unhackedDirNames mode named of+    Left (first, second) -> do+      firstPath <- decodeFS (path </> first)+      secondPath <- decodeFS (path </> second)       fail         ( "serialiseFromPath: file name collision between '"-            ++ (path </> first)+            ++ firstPath             ++ "' and '"-            ++ (path </> second)+            ++ secondPath             ++ "' after case-hack stripping"         )-    Right resolved -> do-      entries <- traverse walkChild resolved-      pure (NarDirectory entries)+    Right resolved -> NarDirectory <$> traverse walkChild resolved   where+    withNameBytes diskName = do+      nameBytes <- osPathBytes diskName+      pure (nameBytes, diskName)     walkChild (entryName, diskName) = do-      entry <- serialiseFromPathWith mode (path </> diskName)+      entry <- walkPath mode (path </> diskName)       pure (entryName, entry) --- | Resolve on-disk child names to (NAR entry name, on-disk name) pairs,--- ordered by entry name.  Under 'CaseHackDisabled' names pass through--- verbatim (already sorted by the caller).  Under 'CaseHackEnabled' the--- case-hack suffix is stripped; @Left@ carries the first pair of disk--- names whose stripped entry names coincide.-unhackedDirNames :: CaseHack -> [FilePath] -> Either (FilePath, FilePath) [(Text, FilePath)]-unhackedDirNames CaseHackDisabled names = Right [(T.pack name, name) | name <- names]-unhackedDirNames CaseHackEnabled names =-  detectCollision (sortBy (comparing fst) (map resolve names))+-- | Resolve (NAR name, on-disk name) pairs for a directory's children.+-- Under 'CaseHackDisabled' pairs pass through verbatim (serialisation+-- sorts at emit).  Under 'CaseHackEnabled' the case-hack suffix is+-- stripped from each NAR name and pairs are re-sorted by the stripped+-- bytes; @Left@ carries the first pair of disk names whose stripped+-- entry names coincide.+unhackedDirNames :: CaseHack -> [(ByteString, OsPath)] -> Either (OsPath, OsPath) [(ByteString, OsPath)]+unhackedDirNames CaseHackDisabled named = Right named+unhackedDirNames CaseHackEnabled named =+  detectCollision (sortBy (comparing fst) (map resolve named))   where-    resolve diskName =-      let (unhacked, rest) = T.breakOn caseHackSuffix (T.pack diskName)-       in if T.null rest-            then (T.pack diskName, diskName)+    resolve (nameBytes, diskName) =+      let (unhacked, rest) = BS.breakSubstring caseHackSuffix nameBytes+       in if BS.null rest+            then (nameBytes, diskName)             else (unhacked, diskName)     detectCollision resolved =       case [ (diskA, diskB)@@ -461,23 +483,58 @@         [] -> Right resolved  -- | Build a regular file entry, checking the executable bit.-buildRegularFile :: FilePath -> IO NarEntry+buildRegularFile :: OsPath -> IO NarEntry buildRegularFile path = do   isFile <- doesFileExist path   if isFile     then do-      contents <- BS.readFile path+      contents <- readFileBytes path       isExec <- checkExecutable path       pure (NarRegular isExec contents)-    else+    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.-      fail ("serialiseFromPath: not a regular file (special or vanished): " ++ path)+      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.getPermissions' which is cross-platform:+-- Uses 'System.Directory.OsPath.getPermissions' which is cross-platform: -- checks the user-execute bit on Unix, file extension on Windows.-checkExecutable :: FilePath -> IO Bool+checkExecutable :: OsPath -> IO Bool checkExecutable path = executable <$> getPermissions path++-- | Read a file's contents by platform-native path.  The byte-string+-- file API still takes 'FilePath', so the path bridges through+-- 'decodeFS' - interop with unmigrated APIs is that function's+-- documented purpose, and its contract is the exact round-trip: the+-- reopened path names the same file even when the name has no text+-- decoding.+readFileBytes :: OsPath -> IO ByteString+readFileBytes path = BS.readFile =<< decodeFS path++-- | The NAR name for one platform-native path component: on POSIX the+-- raw bytes the filesystem reports, on Windows the UTF-8 encoding of+-- the UTF-16 name - each platform's spelling of the upstream rule that+-- a NAR carries names as byte strings.  Symlink targets take the same+-- path.  The one refusal is a Windows name holding an unpaired+-- surrogate: it has no UTF-8 form and upstream defines no byte+-- spelling for it, so failing loudly beats inventing a name (the same+-- policy 'buildRegularFile' applies to special files).+#ifdef mingw32_HOST_OS+osPathBytes :: OsPath -> IO ByteString+osPathBytes path = case OP.decodeUtf path of+  Just decoded -> pure (TE.encodeUtf8 (T.pack decoded))+  Nothing ->+    fail ("serialiseFromPath: name has no UTF-8 form (unpaired surrogate): " ++ show path)+#else+osPathBytes :: OsPath -> IO ByteString+osPathBytes path = case OP.decodeWith latin1 latin1 path of+  Right decoded -> pure (BS8.pack decoded)+  Left err ->+    -- Unreachable: latin1 decoding is total - byte N reads as code+    -- point N, and Char8 re-truncation above inverts it exactly - but+    -- surfacing the impossible beats hiding it.+    fail ("serialiseFromPath: undecodable name: " ++ show err)+#endif
src/NovaCache/SafeName.hs view
@@ -3,32 +3,43 @@ -- "NovaCache.NAR": names Windows resolves to something other than an -- 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. module NovaCache.SafeName   ( isReservedDeviceName,     hasTrailingDotOrSpace,   ) where -import Data.Text (Text)-import qualified Data.Text as T+import Data.ByteString (ByteString)+import qualified Data.ByteString.Char8 as BS8+import Data.Char (isAsciiUpper, 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.-isReservedDeviceName :: Text -> Bool-isReservedDeviceName txt = T.toLower (T.takeWhile (/= '.') txt) `elem` reservedNames+--+-- Device matching is ASCII case-insensitive, so only @A@-@Z@ fold; any+-- other byte passes through and can never match the reserved set.+isReservedDeviceName :: ByteString -> Bool+isReservedDeviceName name =+  BS8.map asciiLower (BS8.takeWhile (/= '.') name) `elem` reservedNames   where+    asciiLower c = if isAsciiUpper c then toLower c else c     reservedNames =       ["con", "prn", "aux", "nul"]-        ++ ["com" <> n | n <- digits]-        ++ ["lpt" <> n | n <- digits]-    digits = [T.pack (show n) | n <- [1 .. 9 :: Int]]+        ++ [device <> digit | device <- ["com", "lpt"], digit <- digits]+    digits = [BS8.pack (show n) | n <- [1 .. 9 :: Int]]  -- | 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 -- one and the materialized tree no longer matches what named it.-hasTrailingDotOrSpace :: Text -> Bool-hasTrailingDotOrSpace name = case T.unsnoc name of+hasTrailingDotOrSpace :: ByteString -> Bool+hasTrailingDotOrSpace name = case BS8.unsnoc name of   Just (_, end) -> end == '.' || end == ' '   Nothing -> False
src/NovaCache/Store.hs view
@@ -29,6 +29,7 @@ import Data.Char (isAsciiLower, isAsciiUpper, isDigit) import Data.Text (Text) import qualified Data.Text as T+import qualified Data.Text.Encoding as TE import NovaCache.SafeName (hasTrailingDotOrSpace, isReservedDeviceName) import System.Directory   ( createDirectoryIfMissing,@@ -248,10 +249,14 @@   | T.null txt = Nothing   | T.isPrefixOf "." txt = Nothing   | T.any (not . isSafeChar) txt = Nothing-  | isReservedDeviceName txt = Nothing-  | hasTrailingDotOrSpace txt = Nothing+  | isReservedDeviceName keyBytes = Nothing+  | hasTrailingDotOrSpace keyBytes = Nothing   | otherwise = Just (T.unpack txt)   where+    -- The shared hazard predicates take the byte form NAR entry names+    -- have; a store key is ASCII by the allowlist above, so its UTF-8+    -- encoding is the same spelling.+    keyBytes = TE.encodeUtf8 txt     isSafeChar c =       isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` ("._-+" :: [Char]) 
test/Main.hs view
@@ -32,6 +32,7 @@ import System.Directory (createDirectory, getTemporaryDirectory, listDirectory, removeDirectoryRecursive) import System.Exit (exitFailure, exitSuccess) import System.IO (hFlush, stdout)+import qualified System.Info  -- --------------------------------------------------------------------------- -- Test harness (hand-rolled, no framework)@@ -403,6 +404,36 @@             names = ["nul2", "com10", "conx", "foo.bar", "a.b.c", "lpt0"]             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+      -- do not decode as UTF-8 parse and round-trip.+      test "non-UTF-8 entry name round-trips" $+        let entry = NAR.NarDirectory [(BS.pack [0x66, 0xFF], NAR.NarRegular False "x")]+         in assertRight "raw-byte name" entry (NAR.deserialise (NAR.serialise entry)),+      test "non-UTF-8 symlink target round-trips" $+        let entry = NAR.NarSymlink (BS.pack [0x2F, 0x74, 0x6D, 0x70, 0x2F, 0xFF])+         in assertRight "raw-byte target" entry (NAR.deserialise (NAR.serialise entry)),+      test "entries sort bytewise, non-UTF-8 names included" $+        let entry =+              NAR.NarDirectory+                [ (BS.pack [0xFF], NAR.NarRegular False "hi"),+                  ("b", NAR.NarRegular False "lo")+                ]+         in case NAR.deserialise (NAR.serialise entry) of+              Left err -> do+                putStrLn ("  deserialise failed: " ++ err)+                pure False+              Right (NAR.NarDirectory entries) ->+                assertEqual "byte order" ["b", BS.pack [0xFF]] (map fst entries)+              Right other -> do+                putStrLn ("  expected directory, got: " ++ show other)+                pure False,+      -- The hazard checks are ASCII-structural, so they fire inside+      -- names that do not decode as text.+      test "hazards inside non-UTF-8 names still rejected" $+        let evil name = NAR.serialise (NAR.NarDirectory [(name, NAR.NarRegular False "x")])+            names = ["nul." <> BS.pack [0xFF], BS.pack [0xFF, 0x2E], BS.pack [0xFF] <> ":x"]+            rejected bytes = either (const True) (const False) (NAR.deserialise bytes)+         in assertTrue "all hazard bytes rejected" (all (rejected . evil) names),       -- The case-hack strip: a tree materialized with upstream's       -- collision suffix serialises back under its NAR names.       test "serialiseFromPathWith strips the case-hack suffix" $ do@@ -435,7 +466,35 @@         removeDirectoryRecursive dir         pure $ case (outcome :: Either SomeException NAR.NarEntry) of           Left _ -> True-          Right _ -> False+          Right _ -> False,+      -- 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+        dir <- caseHackFixture "nova-cache-test-uniname"+        BS.writeFile (dir <> "/caf\233") "au lait"+        entry <- NAR.serialiseFromPathWith NAR.CaseHackDisabled dir+        removeDirectoryRecursive dir+        assertEqual+          "utf8 name"+          (NAR.NarDirectory [(BS.pack [0x63, 0x61, 0x66, 0xC3, 0xA9], NAR.NarRegular False "au lait")])+          entry,+      -- POSIX names are bytes; one that is not valid UTF-8 must archive+      -- verbatim (it used to be silently rewritten with replacement+      -- characters).  Linux-gated: NTFS and APFS names are Unicode, so+      -- the fixture cannot exist there.  "\56575" is the lone surrogate+      -- GHC's filesystem encoding round-trips to byte 0xFF.+      test "serialiseFromPath carries a non-UTF-8 disk name verbatim" $+        if System.Info.os /= "linux"+          then pure True+          else do+            dir <- caseHackFixture "nova-cache-test-rawname"+            BS.writeFile (dir <> "/f\56575") "raw"+            entry <- NAR.serialiseFromPathWith NAR.CaseHackDisabled dir+            removeDirectoryRecursive dir+            assertEqual+              "raw byte name"+              (NAR.NarDirectory [(BS.pack [0x66, 0xFF], NAR.NarRegular False "raw")])+              entry     ]  -- | A fresh, empty fixture directory under the system temp dir.