packages feed

nova-cache-0.5.0.0: src/NovaCache/NarInfo.hs

-- | NarInfo text format parsing and rendering.
--
-- A @.narinfo@ file is a simple @Key: Value@ text format describing a
-- store path in a binary cache. The format supports multiple @Sig@ lines
-- and optional fields for @Deriver@ and @CA@.
module NovaCache.NarInfo
  ( NarInfo (..),
    parseNarInfo,
    renderNarInfo,
  )
where

import Data.List (find)
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Read as TR

-- ---------------------------------------------------------------------------
-- Types
-- ---------------------------------------------------------------------------

-- | A parsed @.narinfo@ record. All fields are strict.
--
-- Field optionality mirrors upstream Nix's parser: only StorePath, URL,
-- NarHash, and NarSize are mandatory; Compression defaults to bzip2 when
-- absent, and FileHash\/FileSize describe the compressed blob only when
-- the cache provides them.
data NarInfo = NarInfo
  { niStorePath :: !Text,
    niUrl :: !Text,
    niCompression :: !Text,
    niFileHash :: !(Maybe Text),
    niFileSize :: !(Maybe Integer),
    niNarHash :: !Text,
    niNarSize :: !Integer,
    niReferences :: ![Text],
    niDeriver :: !(Maybe Text),
    niSigs :: ![Text],
    niCA :: !(Maybe Text)
  }
  deriving (Eq, Show)

-- ---------------------------------------------------------------------------
-- Key constants (no magic strings in logic)
-- ---------------------------------------------------------------------------

keyStorePath, keyUrl, keyCompression, keyFileHash :: Text
keyStorePath = "StorePath"
keyUrl = "URL"
keyCompression = "Compression"
keyFileHash = "FileHash"

keyFileSize, keyNarHash, keyNarSize, keyReferences :: Text
keyFileSize = "FileSize"
keyNarHash = "NarHash"
keyNarSize = "NarSize"
keyReferences = "References"

keyDeriver, keySig, keyCA :: Text
keyDeriver = "Deriver"
keySig = "Sig"
keyCA = "CA"

-- | Key-value separator used when rendering narinfo lines.
kvSeparator :: Text
kvSeparator = ": "

-- | Field delimiter used when parsing (Nix splits on the first colon).
fieldColon :: Text
fieldColon = ":"

-- ---------------------------------------------------------------------------
-- Parsing
-- ---------------------------------------------------------------------------

-- | Compression assumed when the narinfo omits the field, as upstream's
-- parser does.
defaultCompression :: Text
defaultCompression = "bzip2"

-- | Parse a narinfo text body into a 'NarInfo'.  Only StorePath, URL,
-- NarHash, and NarSize are required, matching upstream Nix; a valid
-- narinfo from a foreign cache must not be rejected over an absent
-- optional field.
parseNarInfo :: Text -> Either String NarInfo
parseNarInfo txt = do
  let kvs = mapMaybe parseLine (T.lines txt)
  storePath <- require keyStorePath kvs
  url <- require keyUrl kvs
  fileSize <- traverse (parseInteger keyFileSize) (lookupFirst keyFileSize kvs)
  narHashVal <- require keyNarHash kvs
  narSize <- require keyNarSize kvs >>= parseInteger keyNarSize
  pure
    NarInfo
      { niStorePath = storePath,
        niUrl = url,
        niCompression = fromMaybe defaultCompression (lookupFirst keyCompression kvs),
        niFileHash = lookupFirst keyFileHash kvs,
        niFileSize = fileSize,
        niNarHash = narHashVal,
        niNarSize = narSize,
        niReferences = parseRefs (lookupFirst keyReferences kvs),
        niDeriver = lookupFirst keyDeriver kvs,
        niSigs = lookupAll keySig kvs,
        niCA = lookupFirst keyCA kvs
      }

-- | Parse a space-separated references field.
parseRefs :: Maybe Text -> [Text]
parseRefs Nothing = []
parseRefs (Just r)
  | T.null (T.strip r) = []
  | otherwise = T.words r

-- ---------------------------------------------------------------------------
-- Rendering
-- ---------------------------------------------------------------------------

-- | Render a 'NarInfo' to its text representation.
renderNarInfo :: NarInfo -> Text
renderNarInfo ni =
  T.unlines $
    [ kv keyStorePath (niStorePath ni),
      kv keyUrl (niUrl ni),
      kv keyCompression (niCompression ni)
    ]
      ++ optionalKV keyFileHash (niFileHash ni)
      ++ optionalKV keyFileSize (showT <$> niFileSize ni)
      ++ [ kv keyNarHash (niNarHash ni),
           kv keyNarSize (showT (niNarSize ni)),
           kv keyReferences (T.unwords (niReferences ni))
         ]
      ++ optionalKV keyDeriver (niDeriver ni)
      ++ map (kv keySig) (niSigs ni)
      ++ optionalKV keyCA (niCA ni)

-- ---------------------------------------------------------------------------
-- Key-value primitives
-- ---------------------------------------------------------------------------

-- | Parse a single @Key: Value@ line.
--
-- Splits on the first colon (matching Nix), strips a single leading space
-- from the value, and tolerates a trailing carriage return so CRLF-terminated
-- narinfo from other tools parses correctly.
parseLine :: Text -> Maybe (Text, Text)
parseLine rawLine = case T.breakOn fieldColon line of
  (_, rest)
    | T.null rest -> Nothing
  (key, rest) -> Just (key, dropLeadingSpace (T.drop 1 rest))
  where
    line = T.dropWhileEnd (== '\r') rawLine
    dropLeadingSpace value = fromMaybe value (T.stripPrefix " " value)

-- | Render a key-value pair.
kv :: Text -> Text -> Text
kv key val = key <> kvSeparator <> val

-- | Render an optional key-value pair.
optionalKV :: Text -> Maybe Text -> [Text]
optionalKV _ Nothing = []
optionalKV key (Just val) = [kv key val]

-- | Look up the first occurrence of a key.
lookupFirst :: Text -> [(Text, Text)] -> Maybe Text
lookupFirst key kvs = snd <$> find ((== key) . fst) kvs

-- | Look up all occurrences of a key.
lookupAll :: Text -> [(Text, Text)] -> [Text]
lookupAll key = map snd . filter ((== key) . fst)

-- | Require a key to be present.
require :: Text -> [(Text, Text)] -> Either String Text
require key kvs = case lookupFirst key kvs of
  Nothing -> Left ("missing required key: " ++ T.unpack key)
  Just val -> Right val

-- | Parse a non-negative base-10 integer, matching C++ Nix's narinfo parser.
-- Uses 'TR.decimal' (not 'reads', which also accepts hex/octal/leading space)
-- and requires the whole field to be consumed, so a non-canonical value cannot
-- slip through and then be re-signed under the cache's key.
parseInteger :: Text -> Text -> Either String Integer
parseInteger key txt = case TR.decimal txt of
  Right (n, rest) | T.null rest -> Right n
  _ -> Left ("invalid integer for " ++ T.unpack key ++ ": " ++ T.unpack txt)

-- | Show a value as 'Text'.
showT :: (Show a) => a -> Text
showT = T.pack . show