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 +8/−0
- nova-cache.cabal +3/−3
- src/NovaCache/NAR.hs +125/−68
- src/NovaCache/SafeName.hs +20/−9
- src/NovaCache/Store.hs +7/−2
- test/Main.hs +60/−1
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.