nova-cache-0.8.0.0: src/NovaCache/NAR.hs
{-# LANGUAGE CPP #-}
-- | NAR (Nix ARchive) binary format serialization and deserialization.
--
-- NAR is a deterministic archive format used by Nix. All strings are
-- length-prefixed and padded to 8-byte alignment. The grammar is:
--
-- @
-- archive ::= "nix-archive-1" node
-- node ::= "(" "type" ("regular" regular | "symlink" symlink | "directory" directory) ")"
-- regular ::= ["executable" ""] "contents" STRING
-- symlink ::= "target" STRING
-- 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.
--
-- Parsing is the whole-input instantiation of the incremental machine
-- in "NovaCache.NAR.Stream", and serialisation draws on that module's
-- wire vocabulary - the grammar exists once. To serialise a tree
-- without holding file contents in memory, see 'withNarSource'.
module NovaCache.NAR
( NarEntry (..),
serialise,
deserialise,
narHash,
serialiseFromPath,
serialiseFromPathWith,
withNarSource,
CaseHack (..),
defaultCaseHack,
caseHackSuffix,
)
where
import Control.Exception (finally)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Builder as B
import qualified Data.ByteString.Lazy as BL
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.List (sort, sortBy)
import Data.Ord (comparing)
import Data.Word (Word64)
import qualified NovaCache.Hash as Hash
import NovaCache.NAR.Stream
( NarEvent (..),
NarStep (..),
narPad,
narPadOf,
narStreamBounded,
tokContents,
tokDirectory,
tokEntry,
tokExecutable,
tokLParen,
tokMagic,
tokName,
tokNode,
tokRParen,
tokRegular,
tokSymlink,
tokTarget,
tokType,
)
import System.Directory.OsPath
( doesDirectoryExist,
doesFileExist,
executable,
getPermissions,
getSymbolicLinkTarget,
listDirectory,
pathIsSymbolicLink,
)
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
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)
#endif
-- ---------------------------------------------------------------------------
-- Types
-- ---------------------------------------------------------------------------
-- | A node in the NAR tree.
data NarEntry
= -- | Regular file: executable flag and contents.
NarRegular !Bool !ByteString
| -- | 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 ![(ByteString, NarEntry)]
deriving (Eq, Show)
-- ---------------------------------------------------------------------------
-- Serialization (pure Builder pipeline)
-- ---------------------------------------------------------------------------
-- | Serialise a 'NarEntry' to NAR binary format.
serialise :: NarEntry -> ByteString
serialise = BL.toStrict . B.toLazyByteString . buildArchive
-- | Build the full NAR archive: magic header followed by the root node.
buildArchive :: NarEntry -> B.Builder
buildArchive entry = narStr tokMagic <> buildNode entry
-- | Build a NAR node.
buildNode :: NarEntry -> B.Builder
buildNode (NarRegular isExec contents) =
narStr tokLParen
<> narStr tokType
<> narStr tokRegular
<> execFlag isExec
<> narStr tokContents
<> narStr contents
<> narStr tokRParen
buildNode (NarSymlink target) =
narStr tokLParen
<> narStr tokType
<> narStr tokSymlink
<> narStr tokTarget
<> narStr target
<> narStr tokRParen
buildNode (NarDirectory entries) =
narStr tokLParen
<> narStr tokType
<> narStr tokDirectory
<> foldMap buildDirEntry (sortBy (comparing fst) entries)
<> narStr tokRParen
-- | Emit the executable flag tokens if the file is executable.
execFlag :: Bool -> B.Builder
execFlag True = narStr tokExecutable <> narStr BS.empty
execFlag False = mempty
-- | Build a single directory entry: @"entry" "(" "name" \<n\> "node" \<node\> ")"@.
buildDirEntry :: (ByteString, NarEntry) -> B.Builder
buildDirEntry (entryName, entry) =
narStr tokEntry
<> narStr tokLParen
<> narStr tokName
<> narStr entryName
<> narStr tokNode
<> buildNode entry
<> narStr tokRParen
-- | Write a length-prefixed, 8-byte-padded bytestring to the Builder.
narStr :: ByteString -> B.Builder
narStr bs =
B.word64LE (fromIntegral len)
<> B.byteString bs
<> B.byteString (BS.replicate padLen 0)
where
len = BS.length bs
padLen = narPad len
-- ---------------------------------------------------------------------------
-- Deserialization (the streaming machine, driven over the whole input)
-- ---------------------------------------------------------------------------
-- | Deserialise NAR binary format to a 'NarEntry'.
--
-- Drives "NovaCache.NAR.Stream" over the whole input, folding its
-- events back into a tree. The structural-string bound is the input's
-- own length - a wire string cannot outgrow its container - so this
-- accepts exactly what the dedicated whole-input parser accepted,
-- with no extra ceiling. Contents events are slices of the input, so
-- single-chunk files rebuild by sharing, not copying.
deserialise :: ByteString -> Either String NarEntry
deserialise input = drive True (narStreamBounded (fromIntegral (BS.length input))) [] Nothing
where
drive !firstFeed step stack root = case step of
NarFail err -> Left err
NarDone -> case (stack, root) of
([], Just entry) -> Right entry
_ -> Left malformedEventStream
NarYield event continue -> do
(stackNext, rootNext) <- applyEvent event stack root
drive firstFeed continue stackNext rootNext
NarAwait continue
| firstFeed -> drive False (continue input) stack root
| otherwise -> drive False (continue BS.empty) stack root
-- | One frame of the event fold in 'deserialise': the construct
-- enclosing the node currently being built.
data BuildFrame
= -- | A regular file: executable flag and reversed contents slices.
FrameRegular !Bool ![ByteString]
| -- | A directory: completed children, reversed.
FrameDirectory ![(ByteString, NarEntry)]
| -- | A directory entry: its name, then its node once complete.
FrameEntry !ByteString !(Maybe NarEntry)
-- | Apply one event to the frame stack. The machine already validated
-- the grammar, so the mismatch arms are unreachable through
-- 'narStreamBounded'; they fail closed rather than building partially.
applyEvent :: NarEvent -> [BuildFrame] -> Maybe NarEntry -> Either String ([BuildFrame], Maybe NarEntry)
applyEvent event stack root = case (event, stack) of
(EventRegularBegin isExec _declaredSize, _) ->
Right (FrameRegular isExec [] : stack, root)
(EventRegularChunk slice, FrameRegular isExec chunks : rest) ->
Right (FrameRegular isExec (slice : chunks) : rest, root)
(EventRegularEnd, FrameRegular isExec chunks : rest) ->
complete (NarRegular isExec (BS.concat (reverse chunks))) rest
(EventSymlink target, _) ->
complete (NarSymlink target) stack
(EventDirectoryBegin, _) ->
Right (FrameDirectory [] : stack, root)
(EventEntryBegin entryName, _) ->
Right (FrameEntry entryName Nothing : stack, root)
(EventEntryEnd, FrameEntry entryName (Just entry) : FrameDirectory entriesRev : rest) ->
Right (FrameDirectory ((entryName, entry) : entriesRev) : rest, root)
(EventDirectoryEnd, FrameDirectory entriesRev : rest) ->
complete (NarDirectory (reverse entriesRev)) rest
_ -> Left malformedEventStream
where
complete entry remaining = case remaining of
[] -> case root of
Nothing -> Right ([], Just entry)
Just _ -> Left malformedEventStream
FrameEntry entryName Nothing : rest ->
Right (FrameEntry entryName (Just entry) : rest, root)
_ -> Left malformedEventStream
malformedEventStream :: String
malformedEventStream = "malformed NAR event stream"
-- ---------------------------------------------------------------------------
-- Hashing
-- ---------------------------------------------------------------------------
-- | SHA-256 hash of the NAR serialization. Pure composition.
narHash :: NarEntry -> Hash.NixHash
narHash = Hash.hashBytes . serialise
-- ---------------------------------------------------------------------------
-- Filesystem to NarEntry (IO boundary)
-- ---------------------------------------------------------------------------
-- | Whether the serialiser strips upstream's case-hack suffix from
-- on-disk names. A case-folding store filesystem cannot hold two
-- sibling names differing only by case, so an extractor there
-- materializes the second with a reversible suffix; serialisation must
-- strip it for the tree to reproduce its original NAR bytes.
data CaseHack = CaseHackEnabled | CaseHackDisabled
deriving (Eq, Show)
-- | The platform default 'serialiseFromPath' uses: enabled where the
-- store filesystem folds case (Windows NTFS, default macOS APFS),
-- disabled elsewhere - a Linux file legitimately named with the suffix
-- must serialise verbatim. Matches upstream's use-case-hack defaults.
defaultCaseHack :: CaseHack
defaultCaseHack = case System.Info.os of
"mingw32" -> CaseHackEnabled
"darwin" -> CaseHackEnabled
_ -> CaseHackDisabled
-- | 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. 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: the platform-native walk
-- 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 = 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 <$> (osPathBytes =<< getSymbolicLinkTarget path)
else do
isDir <- doesDirectoryExist path
if isDir
then buildDirectory mode path
else buildRegularFile path
-- | Build a directory entry by recursively walking children.
buildDirectory :: CaseHack -> OsPath -> IO NarEntry
buildDirectory mode path = do
resolved <- resolvedDirEntries mode path
NarDirectory <$> traverse walkChild resolved
where
walkChild (entryName, diskName) = do
entry <- walkPath mode (path </> diskName)
pure (entryName, entry)
-- | A directory's children as (NAR name, on-disk name) pairs under the
-- case-hack mode. Under 'CaseHackEnabled', each on-disk name is
-- stripped of the case-hack suffix and entries are ordered by the
-- STRIPPED name (the NAR name); two on-disk names stripping to the
-- same entry name fail loudly, as upstream's serialiser does -
-- continuing would emit an archive with duplicate entries no parser
-- accepts.
resolvedDirEntries :: CaseHack -> OsPath -> IO [(ByteString, OsPath)]
resolvedDirEntries mode path = do
names <- sort <$> listDirectory path
named <- traverse withNameBytes names
case unhackedDirNames mode named of
Left (collidedA, collidedB) -> do
pathA <- decodeFS (path </> collidedA)
pathB <- decodeFS (path </> collidedB)
fail
( "serialiseFromPath: file name collision between '"
++ pathA
++ "' and '"
++ pathB
++ "' after case-hack stripping"
)
Right resolved -> pure resolved
where
withNameBytes diskName = do
nameBytes <- osPathBytes diskName
pure (nameBytes, diskName)
-- | 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 (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)
| ((entryA, diskA), (entryB, diskB)) <- zip resolved (drop 1 resolved),
entryA == entryB
] of
((diskA, diskB) : _) -> Left (diskA, diskB)
[] -> Right resolved
-- | Build a regular file entry, checking the executable bit.
buildRegularFile :: OsPath -> IO NarEntry
buildRegularFile path = do
isFile <- doesFileExist path
if isFile
then do
contents <- readFileBytes path
isExec <- checkExecutable path
pure (NarRegular isExec contents)
else specialFileFailure path
-- | The shared refusal for a path that is not a symlink, directory, or
-- regular file: a special file (FIFO, socket, device) or a path that
-- vanished mid-walk. Fail loudly rather than fabricating an empty
-- regular (which would silently change the NAR and its hash) -
-- matching Nix, which aborts on unsupported types.
specialFileFailure :: OsPath -> IO a
specialFileFailure path = do
shownPath <- decodeFS path
fail ("serialiseFromPath: not a regular file (special or vanished): " ++ shownPath)
-- | Check whether a file has the executable permission set.
-- Uses 'System.Directory.OsPath.getPermissions' which is cross-platform:
-- checks the user-execute bit on Unix, file extension on Windows.
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
-- ---------------------------------------------------------------------------
-- Streaming filesystem serialisation (IO boundary)
-- ---------------------------------------------------------------------------
-- | Chunk size for streaming file contents: large enough to amortize
-- per-chunk handling in consumers, small enough that one pull's memory
-- and latency stay flat.
narSourceChunkBytes :: Int
narSourceChunkBytes = 131072
-- | One planned piece of the archive: structural bytes rendered up
-- front, or a regular file whose length prefix, contents, and padding
-- stream at pull time.
data NarSegment
= SegmentBytes !ByteString
| SegmentFile !OsPath
-- | What the puller is doing between calls. The 'IORef' holding this
-- is the module's one piece of mutable state - the same deliberate,
-- documented boundary as the streaming write in "NovaCache.Store".
data SourceState
= SourceSegments ![NarSegment]
| -- | Mid-file: the open handle, its decoded path for error text,
-- the bytes still owed, the padding after them, and the remaining
-- segments.
SourceFile !Handle !FilePath !Word64 !Int ![NarSegment]
| SourceDrained
-- | Serialise a filesystem tree as a pull source of NAR chunks,
-- without ever holding a file's contents in memory: the tree's
-- structure is planned up front (names, kinds, symlink targets -
-- never contents), then each pull returns the next chunk, reading
-- regular files 128 KiB at a time. The empty chunk
-- means end of input and repeats on further pulls - the convention
-- 'NovaCache.Store.writeNarStreaming' consumes, so the two ends
-- compose directly. Pair with "NovaCache.Hash"'s incremental hashing
-- to compute the NAR hash in the same pass.
--
-- The walk applies the same case-hack resolution and loud failures as
-- 'serialiseFromPathWith', and emits entries in the same bytewise
-- order, so the pulled bytes equal @'serialise' \<tree\>@ exactly. A
-- file's size is read when its streaming starts and exactly that many
-- bytes are emitted, as upstream's dump does; a file that shrinks
-- mid-stream fails loudly rather than emitting a torn archive. Any
-- file handle still open when the continuation exits is closed.
withNarSource :: CaseHack -> FilePath -> (IO ByteString -> IO a) -> IO a
withNarSource mode root consume = do
rootPath <- encodeFS root
segments <- planSegments mode rootPath
stateRef <- newIORef (SourceSegments segments)
consume (pullChunk stateRef) `finally` closeCurrent stateRef
where
closeCurrent stateRef = do
state <- readIORef stateRef
case state of
SourceFile handle _ _ _ _ -> hClose handle
_ -> pure ()
-- | Produce the next chunk of the planned archive.
pullChunk :: IORef SourceState -> IO ByteString
pullChunk stateRef = advance =<< readIORef stateRef
where
advance (SourceSegments []) = do
writeIORef stateRef SourceDrained
pure BS.empty
advance (SourceSegments (SegmentBytes bytes : rest)) = do
writeIORef stateRef (SourceSegments rest)
pure bytes
advance (SourceSegments (SegmentFile path : rest)) = do
shownPath <- decodeFS path
handle <- openBinaryFile shownPath ReadMode
size <- hFileSize handle
let owed = fromIntegral size :: Word64
writeIORef stateRef (SourceFile handle shownPath owed (narPadOf owed) rest)
pure (BL.toStrict (B.toLazyByteString (B.word64LE owed)))
advance (SourceFile handle _ 0 padLen rest) = do
hClose handle
writeIORef stateRef (SourceSegments rest)
if padLen == 0
then pullChunk stateRef
else pure (BS.replicate padLen 0)
advance (SourceFile handle shownPath owed padLen rest) = do
chunk <- BS.hGet handle (fromIntegral (min owed (fromIntegral narSourceChunkBytes)))
if BS.null chunk
then do
hClose handle
writeIORef stateRef SourceDrained
ioError (userError ("withNarSource: " ++ shownPath ++ " shrank while streaming"))
else do
writeIORef
stateRef
(SourceFile handle shownPath (owed - fromIntegral (BS.length chunk)) padLen rest)
pure chunk
advance SourceDrained = pure BS.empty
-- | Plan the archive: every structural byte rendered, file contents
-- deferred as 'SegmentFile's. Holds structure only - O(entries),
-- never contents.
planSegments :: CaseHack -> OsPath -> IO [NarSegment]
planSegments mode path = do
pieces <- planNode mode path
pure (coalesce (PieceBytes (narStr tokMagic) : pieces))
-- | Plan pieces before coalescing: structural builders, or a deferred
-- regular file.
data PlanPiece
= PieceBytes B.Builder
| PieceFile !OsPath
-- | Merge adjacent structural runs and render each strict, so a pull
-- returns a directory's worth of tokens in one chunk instead of one
-- token at a time.
coalesce :: [PlanPiece] -> [NarSegment]
coalesce = go mempty
where
go pending [] = flushOnto pending []
go pending (PieceBytes builder : rest) = go (pending <> builder) rest
go pending (PieceFile path : rest) =
flushOnto pending (SegmentFile path : go mempty rest)
flushOnto pending segments =
let bytes = BL.toStrict (B.toLazyByteString pending)
in if BS.null bytes then segments else SegmentBytes bytes : segments
-- | Plan one node, mirroring 'walkPath'.
planNode :: CaseHack -> OsPath -> IO [PlanPiece]
planNode mode path = do
isSym <- pathIsSymbolicLink path
if isSym
then do
target <- osPathBytes =<< getSymbolicLinkTarget path
pure [PieceBytes (buildNode (NarSymlink target))]
else do
isDir <- doesDirectoryExist path
if isDir
then planDirectory mode path
else planRegular path
-- | Plan a directory. Children are ordered by their NAR-name bytes -
-- the same order 'buildNode' emits - not by on-disk order, which can
-- differ on Windows where 'OsPath' sorts by UTF-16 units.
planDirectory :: CaseHack -> OsPath -> IO [PlanPiece]
planDirectory mode path = do
resolved <- resolvedDirEntries mode path
children <- traverse planChild (sortBy (comparing fst) resolved)
pure
( PieceBytes (narStr tokLParen <> narStr tokType <> narStr tokDirectory)
: concat children
++ [PieceBytes (narStr tokRParen)]
)
where
planChild (entryName, diskName) = do
node <- planNode mode (path </> diskName)
pure
( PieceBytes
( narStr tokEntry
<> narStr tokLParen
<> narStr tokName
<> narStr entryName
<> narStr tokNode
)
: node
++ [PieceBytes (narStr tokRParen)]
)
-- | Plan a regular file: the node's structure now, its contents at
-- pull time. The pieces mirror 'buildNode' on 'NarRegular' exactly,
-- with the contents wire string (length, bytes, padding) deferred.
planRegular :: OsPath -> IO [PlanPiece]
planRegular path = do
isFile <- doesFileExist path
if isFile
then do
isExec <- checkExecutable path
pure
[ PieceBytes
( narStr tokLParen
<> narStr tokType
<> narStr tokRegular
<> execFlag isExec
<> narStr tokContents
),
PieceFile path,
PieceBytes (narStr tokRParen)
]
else specialFileFailure path