nova-cache-0.10.0.0: src/NovaCache/Zstd.hs
-- | Bounded zstd decompression, and compression for the push path.
--
-- The modern caches (Cachix, attic, FlakeHub) serve NARs
-- zstd-compressed, and a cache of our own wants the same: near-xz
-- ratio on binaries with decompression an order of magnitude
-- faster. Substitution decompresses bytes that arrive from the
-- network BEFORE any hash can vouch for them, so the decoder must
-- not be steerable into unbounded allocation. The consumer knows
-- the narinfo's declared NarSize before decompressing:
-- decompression takes that bound and fails past it
-- ('zstdMaxOutputBytes'), so a small compressed input cannot expand
-- to arbitrary memory ahead of the hash check.
--
-- Decoder state is bounded differently from 'NovaCache.Xz': the
-- @zstd@ binding exposes no window-limit parameter, but libzstd
-- itself refuses any frame declaring a window past its default
-- @ZSTD_WINDOWLOG_LIMIT_DEFAULT@ (2^27, 128 MiB), so decoder memory
-- is capped by the library rather than by a caller-chosen number.
-- Take the tunable cap here too if the binding ever exposes
-- @ZSTD_d_windowLogMax@.
--
-- A truncated input yields truncated output at this layer rather
-- than an error: the binding's stream driver cannot observe
-- libzstd's more-input-expected state at end of input. The signed
-- NarSize and NarHash checks above this layer are the arbiter of
-- completeness - the same layering upstream relies on.
--
-- Everything here is IO: the binding's streaming interface is
-- IO-native, unlike lzma's lazy-ST driver under 'NovaCache.Xz'.
--
-- This module lives in the public @nova-cache:zstandard@ sublibrary
-- (a component named @zstd@ would shadow the @zstd@ dependency), the
-- same solver-visible opt-in as @nova-cache:xz@: the @zstd@ package
-- bundles libzstd's C sources (no system library on any platform),
-- and consumers that do not need the codec never build them.
module NovaCache.Zstd
( ZstdLimits (..),
ZstdError (..),
decompress,
compress,
defaultCompressionLevel,
withZstdSource,
)
where
import qualified Codec.Compression.Zstd as OneShot
import qualified Codec.Compression.Zstd.Streaming as S
import Control.Exception (Exception, throwIO)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Word (Word64)
-- ---------------------------------------------------------------------------
-- Limits
-- ---------------------------------------------------------------------------
-- | What a decode run may cost. The bound is inclusive: output of
-- exactly 'zstdMaxOutputBytes' passes, one byte more fails - a
-- narinfo's NarSize is exact, so the declared size itself must be
-- reachable. Decoder-state memory is capped by libzstd's default
-- window limit (see the module header), not by a field here.
newtype ZstdLimits = ZstdLimits
{ -- | Maximum decompressed output, in bytes: the narinfo's declared
-- NarSize.
zstdMaxOutputBytes :: Word64
}
deriving (Eq, Show)
-- ---------------------------------------------------------------------------
-- Errors
-- ---------------------------------------------------------------------------
-- | Everything a bounded decode can refuse. The pure-shaped
-- 'decompress' returns these in 'Left'; the pull source behind
-- 'withZstdSource' throws them (see the 'Exception' instance).
data ZstdError
= -- | The compressed stream is malformed (libzstd's error name,
-- rendered with the failing call site).
ZstdStreamError !String
| -- | Decompressed output would exceed the bound (carried here).
ZstdOutputOverBound !Word64
deriving (Eq, Show)
-- | Thrown by the pull source 'withZstdSource' hands its
-- continuation; a chunk convention has no error channel, and a
-- throwing pull composes with consumers built around one.
instance Exception ZstdError
-- ---------------------------------------------------------------------------
-- Bounded decode
-- ---------------------------------------------------------------------------
-- | Decompress one zstd payload under the given limits. Output
-- stops accumulating the moment it would pass the bound, so a
-- high-expansion input costs at most the bound plus one decoder
-- buffer, never what it claims to hold. Concatenated frames decode
-- as one output, as upstream's decompression sink accepts.
decompress :: ZstdLimits -> ByteString -> IO (Either ZstdError ByteString)
decompress limits input = drive (Just input) 0 [] =<< S.decompress
where
bound = zstdMaxOutputBytes limits
drive pending !produced acc step = case step of
-- The whole input feeds on the first request; the second
-- request gets the empty string, the driver's end-of-input
-- signal.
S.Consume supply -> case pending of
Just bytes -> drive Nothing produced acc =<< supply bytes
Nothing -> drive Nothing produced acc =<< supply BS.empty
S.Produce out next
| grown > bound -> pure (Left (ZstdOutputOverBound bound))
| otherwise -> drive pending grown (out : acc) =<< next
where
grown = produced + fromIntegral (BS.length out)
S.Done out
| produced + fromIntegral (BS.length out) > bound ->
pure (Left (ZstdOutputOverBound bound))
| otherwise -> pure (Right (BS.concat (reverse (out : acc))))
S.Error site name -> pure (Left (renderError site name))
-- | One libzstd failure in this module's error vocabulary.
renderError :: String -> String -> ZstdError
renderError site name = ZstdStreamError (site <> ": " <> name)
-- ---------------------------------------------------------------------------
-- Compression (push path)
-- ---------------------------------------------------------------------------
-- | Compress one payload at the given level (1 to the library
-- maximum). The produced frame records its content size, so
-- consumers with a one-shot decoder can allocate exactly. The
-- binding's one-shot API is pure and total for in-range levels.
compress :: Int -> ByteString -> ByteString
compress = OneShot.compress
-- | libzstd's own default (level 3): the ratio/speed point the
-- library authors tuned for, and far cheaper than xz at push time.
defaultCompressionLevel :: Int
defaultCompressionLevel = 3
-- ---------------------------------------------------------------------------
-- Streaming bounded decode (IO boundary)
-- ---------------------------------------------------------------------------
-- | What the pull source is doing between calls. The 'IORef'
-- holding this is the module's one piece of mutable state - the
-- same deliberate, documented boundary as the xz source.
data ZstdSourceState
= ZstdStreaming !S.Result !Word64
| ZstdDrained
-- | Decompress a chunk source into a chunk source, under the
-- limits. The continuation's pull yields decompressed chunks; the
-- empty chunk means end of output and repeats on further pulls.
-- The compressed source follows the same convention on its side.
-- Pairs with the incremental NAR parser and hashing, so a
-- substituter can fetch, decompress, hash, and unpack in one
-- bounded pass.
--
-- Limit violations and malformed input are thrown as 'ZstdError'
-- from the pull.
withZstdSource :: ZstdLimits -> IO ByteString -> (IO ByteString -> IO a) -> IO a
withZstdSource limits compressedSource consume = do
start <- S.decompress
stateRef <- newIORef (ZstdStreaming start 0)
consume (pullDecompressed limits compressedSource stateRef)
-- | Produce the next decompressed chunk.
pullDecompressed :: ZstdLimits -> IO ByteString -> IORef ZstdSourceState -> IO ByteString
pullDecompressed limits compressedSource stateRef = advance =<< readIORef stateRef
where
bound = zstdMaxOutputBytes limits
advance ZstdDrained = pure BS.empty
advance (ZstdStreaming step produced) = case step of
S.Consume supply -> do
chunk <- compressedSource
next <- supply chunk
advance (ZstdStreaming next produced)
S.Produce out nextAction -> do
let grown = produced + fromIntegral (BS.length out)
if grown > bound
then do
writeIORef stateRef ZstdDrained
throwIO (ZstdOutputOverBound bound)
else do
next <- nextAction
writeIORef stateRef (ZstdStreaming next grown)
-- The driver may hand back an empty buffer at frame
-- boundaries; returning it would read as end of output.
if BS.null out
then advance (ZstdStreaming next grown)
else pure out
S.Done out -> do
writeIORef stateRef ZstdDrained
if produced + fromIntegral (BS.length out) > bound
then throwIO (ZstdOutputOverBound bound)
else pure out
S.Error site name -> do
writeIORef stateRef ZstdDrained
throwIO (renderError site name)