packages feed

nova-nix-0.8.0.0: src/Nix/Derivation.hs

-- | Nix derivation representation and serialization.
--
-- == What is a derivation?
--
-- A derivation (.drv file) is a BUILD RECIPE.  It is the output of
-- evaluating a Nix expression.  When you write:
--
-- @
-- stdenv.mkDerivation {
--   pname = "hello";
--   version = "2.12.1";
--   src = fetchurl { ... };
--   buildInputs = [ zlib ];
-- }
-- @
--
-- The evaluator reduces this to a @.drv@ file that says:
--
-- @
-- Derive(
--   [("out", "\/nix\/store\/abc...-hello-2.12.1", "", "sha256")],
--   [("\/nix\/store\/def...-zlib-1.3.1.drv", ["out"]),
--    ("\/nix\/store\/ghi...-bash-5.2.drv", ["out"])],
--   ["\/nix\/store\/jkl...-hello-2.12.1.tar.gz"],
--   "x86_64-linux",
--   "\/nix\/store\/mno...-bash-5.2\/bin\/bash",
--   ["\/nix\/store\/pqr...-stdenv\/setup"],
--   [("buildInputs", "\/nix\/store\/def...-zlib-1.3.1"),
--    ("builder", "\/nix\/store\/mno...-bash-5.2\/bin\/bash"),
--    ("name", "hello-2.12.1"),
--    ("out", "\/nix\/store\/abc...-hello-2.12.1"),
--    ("src", "\/nix\/store\/jkl...-hello-2.12.1.tar.gz"),
--    ("system", "x86_64-linux")]
-- )
-- @
--
-- That's ATerm format.  Every derivation is:
--
-- 1. __Outputs__: what store paths will be produced (usually just "out")
-- 2. __Input derivations__: other .drv files this depends on (and which
--    of their outputs we need)
-- 3. __Input sources__: non-derivation store paths (source tarballs, patches)
-- 4. __Platform__: what system to build on ("x86_64-linux", "x86_64-windows")
-- 5. __Builder__: the executable to run (usually bash)
-- 6. __Args__: command-line arguments to the builder
-- 7. __Environment__: environment variables for the build
--
-- The HASH of this .drv file (after ATerm serialization) determines the
-- output store path.  Change any input yields a different hash and a different output
-- path.  This is how Nix achieves reproducibility.
module Nix.Derivation
  ( -- * Derivation type
    Derivation (..),
    DerivationOutput (..),

    -- * ATerm serialization
    toATerm,
    toATermForHash,
    fromATerm,

    -- * Platform
    Platform (..),
    currentPlatform,
    platformToText,
    textToPlatform,
  )
where

import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Builder as B
import qualified Data.ByteString.Char8 as BC
import qualified Data.ByteString.Lazy as LBS
import Data.Function (on)
import Data.List (sortBy)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Text.Encoding.Error (lenientDecode)
import Nix.Store.Path (StorePath)
import qualified Nix.Store.Path as SP
import qualified System.Info as SI

-- | Target platform for a derivation.
data Platform
  = X86_64_Linux
  | X86_64_Darwin
  | Aarch64_Darwin
  | X86_64_Windows
  | Aarch64_Linux
  | OtherPlatform !Text
  deriving (Eq, Ord, Show)

-- | Detect the current platform using 'System.Info'.
-- GHC bakes @os@ and @arch@ into the compiled binary at compile time,
-- so this is equivalent to CPP ifdefs but ormolu-compatible.
currentPlatform :: Platform
currentPlatform = case (SI.arch, SI.os) of
  ("x86_64", "mingw32") -> X86_64_Windows
  ("x86_64", "darwin") -> X86_64_Darwin
  ("aarch64", "darwin") -> Aarch64_Darwin
  ("aarch64", "linux") -> Aarch64_Linux
  ("x86_64", "linux") -> X86_64_Linux
  (arch, os) -> OtherPlatform (packPlatform arch os)

-- | Format an arch-os pair as a Nix platform string.
packPlatform :: String -> String -> Text
packPlatform arch os =
  let archText = case arch of
        "x86_64" -> "x86_64"
        "aarch64" -> "aarch64"
        other -> other
      osText = case os of
        "mingw32" -> "windows"
        "darwin" -> "darwin"
        "linux" -> "linux"
        other -> other
   in mconcat [T.pack archText, "-", T.pack osText]

-- | Convert a 'Platform' to its Nix text representation.
platformToText :: Platform -> Text
platformToText X86_64_Linux = "x86_64-linux"
platformToText X86_64_Darwin = "x86_64-darwin"
platformToText Aarch64_Darwin = "aarch64-darwin"
platformToText X86_64_Windows = "x86_64-windows"
platformToText Aarch64_Linux = "aarch64-linux"
platformToText (OtherPlatform t) = t

-- | Parse a Nix platform string into a 'Platform'.
textToPlatform :: Text -> Platform
textToPlatform t = case t of
  "x86_64-linux" -> X86_64_Linux
  "x86_64-darwin" -> X86_64_Darwin
  "aarch64-darwin" -> Aarch64_Darwin
  "x86_64-windows" -> X86_64_Windows
  "aarch64-linux" -> Aarch64_Linux
  other -> OtherPlatform other

-- | A single output of a derivation.
data DerivationOutput = DerivationOutput
  { -- | Output name (usually "out", sometimes "dev", "lib", "doc").
    doName :: !Text,
    -- | The store path this output will be placed at.
    doPath :: !StorePath,
    -- | Hash algorithm (empty for input-addressed, "sha256" for fixed).
    doHashAlgo :: !Text,
    -- | Expected hash (empty for input-addressed, actual hash for fixed).
    doHash :: !Text
  }
  deriving (Eq, Show)

-- | A complete derivation - everything needed to build a package.
--
-- The builder, args, and env VALUES are byte strings: they are coerced
-- Nix strings, which carry arbitrary bytes, and they flow byte-exact
-- into the ATerm (and therefore the @.drv@ hash and every output path).
-- Env KEYS are attr names, which nova-nix constrains to valid UTF-8
-- 'Text'; for valid UTF-8, Text ordering equals byte ordering, so the
-- sorted env section matches upstream's bytewise @std::map@ order.
data Derivation = Derivation
  { -- | What this derivation produces.
    drvOutputs :: ![DerivationOutput],
    -- | Other derivations this depends on, and which outputs we need.
    drvInputDrvs :: !(Map StorePath [Text]),
    -- | Non-derivation store paths used as inputs (sources, patches).
    drvInputSrcs :: ![StorePath],
    -- | Target platform: "x86_64-linux", "x86_64-windows", etc.
    drvPlatform :: !Platform,
    -- | The builder executable (store path to bash, or other).
    drvBuilder :: !ByteString,
    -- | Arguments to the builder.
    drvArgs :: ![ByteString],
    -- | Environment variables for the build.
    drvEnv :: !(Map Text ByteString)
  }
  deriving (Eq, Show)

-- | Serialize a derivation to ATerm format (the .drv file format).
-- This serialization is what gets hashed to compute the store path,
-- so the result is BYTES: env values, args, and the builder land in it
-- exactly as coerced, with no encoding step in between.
--
-- Format: @Derive([outputs],[inputDrvs],[inputSrcs],platform,builder,[args],[env])@
toATerm :: Derivation -> ByteString
toATerm = toATermForHash False Nothing

-- | Serialize a derivation for HASHING, with the two knobs the Nix
-- derivation-hash algorithm needs:
--
--   * @maskOutputs@: render every output path as the empty string.  Used
--     when computing a derivation's own output paths (not yet known) via
--     @hashDerivationModulo@.
--   * @inputSubst@: when @Just subs@, render the input-derivations section
--     from @subs@ - pairs of @(moduloHashHex, outputNames)@ - instead of
--     from 'drvInputDrvs'.  Each input derivation's store path is replaced
--     by its own modulo hash, so two derivations differing only in an
--     input's path spelling (not its content) hash identically.
--
-- @toATermForHash False Nothing@ is exactly 'toATerm'.
toATermForHash :: Bool -> Maybe [(Text, [Text])] -> Derivation -> ByteString
toATermForHash maskOutputs inputSubst drv =
  LBS.toStrict
    $ B.toLazyByteString
    $ "Derive("
      <> atermOutputsWith maskOutputs (drvOutputs drv)
      <> ","
      <> inputDrvsSection
      <> ","
      <> atermInputSrcs (drvInputSrcs drv)
      <> ","
      <> atermStringT (platformToText (drvPlatform drv))
      <> ","
      <> atermString (drvBuilder drv)
      <> ","
      <> atermList (map atermString (drvArgs drv))
      <> ","
      <> atermEnv (drvEnv drv)
      <> ")"
  where
    inputDrvsSection = case inputSubst of
      Nothing -> atermInputDrvs (drvInputDrvs drv)
      Just subs -> atermInputDrvsSubst subs

-- | Serialize outputs: @[(name,path,hashAlgo,hash)]@, sorted by output name.
-- When @maskOutputs@ is set, every path is rendered as @\"\"@ (used by the
-- masked modulo hash, where output paths aren't known yet).
atermOutputsWith :: Bool -> [DerivationOutput] -> B.Builder
atermOutputsWith maskOutputs outs =
  let sorted = sortBy (compare `on` doName) outs
   in atermList (map render sorted)
  where
    render out =
      "("
        <> atermStringT (doName out)
        <> ","
        <> atermStringT (if maskOutputs then "" else SP.storePathToText SP.defaultStoreDir (doPath out))
        <> ","
        <> atermStringT (doHashAlgo out)
        <> ","
        <> atermStringT (doHash out)
        <> ")"

-- | Input-derivations serializer for the modulo substitution: keys are the
-- modulo-hash hex strings.  Entries that share a key are merged, unioning
-- their output-name sets, so the section carries one entry per distinct modulo
-- hash - mirroring upstream @hashDerivationModulo@'s @std::map<Hash, StringSet>@,
-- where two inputs that collapse to the same modulo hash (e.g. two fetches
-- differing only in URL) become a single entry.  'Set.union' makes the merge
-- order-independent; 'Map.toAscList' and 'Set.toAscList' fix the ascending
-- hex-key and output-name order (hex is lowercase, so this matches the bytewise
-- order of upstream's @std::map<std::string>@).
atermInputDrvsSubst :: [(Text, [Text])] -> B.Builder
atermInputDrvsSubst subs =
  let merged = Map.fromListWith Set.union [(key, Set.fromList outs) | (key, outs) <- subs]
   in atermList (map render (Map.toAscList merged))
  where
    render (key, outs) = "(" <> atermStringT key <> "," <> atermList (map atermStringT (Set.toAscList outs)) <> ")"

-- | Sort and deduplicate output names (matches C++ @std::set<string>@).
sortNubText :: [Text] -> [Text]
sortNubText = Set.toAscList . Set.fromList

-- | Serialize input derivations: @[(drvPath,[outName1,outName2])]@
-- Sorted by store path for determinism.
atermInputDrvs :: Map StorePath [Text] -> B.Builder
atermInputDrvs drvs =
  let sorted = Map.toAscList drvs
   in atermList (map atermInputDrv sorted)

atermInputDrv :: (StorePath, [Text]) -> B.Builder
atermInputDrv (sp, outs) =
  "("
    <> atermStringT (SP.storePathToText SP.defaultStoreDir sp)
    <> ","
    <> atermList (map atermStringT (sortNubText outs))
    <> ")"

-- | Serialize input sources: @[path1,path2,...]@
-- Sorted for deterministic ATerm hashing.
atermInputSrcs :: [StorePath] -> B.Builder
atermInputSrcs srcs =
  let sorted = sortBy (compare `on` SP.storePathToText SP.defaultStoreDir) srcs
   in atermList (map (atermStringT . SP.storePathToText SP.defaultStoreDir) sorted)

-- | Bracketed, comma-separated section.
atermList :: [B.Builder] -> B.Builder
atermList items = "[" <> mconcat (intersperseComma items) <> "]"
  where
    intersperseComma [] = []
    intersperseComma [only] = [only]
    intersperseComma (item : rest) = item : "," : intersperseComma rest

-- | Serialize environment: @[(key,value)]@ sorted by key.  Keys are
-- valid-UTF-8 Text, so 'Map.toAscList' order equals upstream's bytewise
-- order; values are raw bytes.
atermEnv :: Map Text ByteString -> B.Builder
atermEnv env =
  let sorted = Map.toAscList env
   in atermList (map atermEnvPair sorted)

atermEnvPair :: (Text, ByteString) -> B.Builder
atermEnvPair (key, val) =
  "(" <> atermStringT key <> "," <> atermString val <> ")"

-- | ATerm string: double-quoted with standard escaping, byte-level.
-- Every escapable is a single ASCII byte, so escaping the byte stream is
-- exactly upstream's per-char escaping over its byte strings.
atermString :: ByteString -> B.Builder
atermString s = "\"" <> escapeATermBytes s <> "\""

-- | ATerm string from Text (names, store paths, hex keys): UTF-8 bytes,
-- then the shared byte-level escaping.
atermStringT :: Text -> B.Builder
atermStringT = atermString . TE.encodeUtf8

-- | Escape the five ATerm specials; all other bytes pass through verbatim.
-- Scans for the next special with 'BC.break' so clean spans copy in bulk.
escapeATermBytes :: ByteString -> B.Builder
escapeATermBytes s =
  let (plain, rest) = BC.break atermSpecial s
   in case BC.uncons rest of
        Nothing -> B.byteString plain
        Just (c, remaining) ->
          B.byteString plain <> escapeOne c <> escapeATermBytes remaining
  where
    atermSpecial c = c == '\\' || c == '"' || c == '\n' || c == '\r' || c == '\t'
    escapeOne '\\' = "\\\\"
    escapeOne '"' = "\\\""
    escapeOne '\n' = "\\n"
    escapeOne '\r' = "\\r"
    escapeOne '\t' = "\\t"
    -- Unreachable: 'BC.break atermSpecial' only stops at the five specials.
    escapeOne c = B.charUtf8 c

-- ---------------------------------------------------------------------------
-- ATerm parser (hand-rolled recursive descent)
-- ---------------------------------------------------------------------------

-- | Parser state: remaining input bytes.  The @.drv@ on disk is a byte
-- string; only Text-shaped fields (names, paths, keys) decode, and only
-- after unescaping.
newtype Parser a = Parser {runParser :: ByteString -> Either Text (a, ByteString)}

instance Functor Parser where
  fmap f (Parser p) = Parser $ \input -> case p input of
    Left err -> Left err
    Right (val, rest) -> Right (f val, rest)

instance Applicative Parser where
  pure val = Parser $ \input -> Right (val, input)
  Parser pf <*> Parser pa = Parser $ \input -> case pf input of
    Left err -> Left err
    Right (f, rest) -> case pa rest of
      Left err -> Left err
      Right (a, rest2) -> Right (f a, rest2)

instance Monad Parser where
  Parser pa >>= f = Parser $ \input -> case pa input of
    Left err -> Left err
    Right (a, rest) -> runParser (f a) rest

parserFail :: Text -> Parser a
parserFail msg = Parser $ \_ -> Left msg

-- | Lossy decode for parse-error snippets only.
snippet :: ByteString -> Text
snippet = TE.decodeUtf8With lenientDecode

-- | Consume a specific character.
pChar :: Char -> Parser ()
pChar expected = Parser $ \input ->
  case BC.uncons input of
    Just (c, rest) | c == expected -> Right ((), rest)
    Just (c, _) -> Left ("expected '" <> T.singleton expected <> "' but got '" <> T.singleton c <> "'")
    Nothing -> Left ("expected '" <> T.singleton expected <> "' but got end of input")

-- | Consume a specific string prefix.
pString :: ByteString -> Parser ()
pString prefix = Parser $ \input ->
  case BS.stripPrefix prefix input of
    Just rest -> Right ((), rest)
    Nothing -> Left ("expected \"" <> snippet prefix <> "\" at: " <> snippet (BS.take 20 input))

-- | Parse a quoted ATerm string with escape handling; the content is the
-- raw unescaped bytes.
pQuotedString :: Parser ByteString
pQuotedString = do
  pChar '"'
  content <- pStringContent
  pChar '"'
  pure content

-- | Like 'pQuotedString' for Text-shaped fields (names, store paths, env
-- keys, platform): strict UTF-8 decode after unescaping, so invalid bytes
-- in a field nova-nix represents as Text are a parse error, never mojibake.
pQuotedText :: Parser Text
pQuotedText = do
  content <- pQuotedString
  case TE.decodeUtf8' content of
    Right t -> pure t
    Left _ -> parserFail ("invalid UTF-8 in ATerm string: " <> snippet (BS.take 40 content))

-- | Parse the contents of a quoted string (up to unescaped quote).
-- Scans to the next special byte with 'BC.break' so clean spans are
-- copied in bulk, accumulating reversed chunks (O(n) total).
pStringContent :: Parser ByteString
pStringContent = Parser $ \input -> go input []
  where
    go remaining acc =
      let (plain, rest) = BC.break (\c -> c == '"' || c == '\\') remaining
       in case BC.uncons rest of
            Nothing -> Left "unterminated string"
            Just ('"', _) -> Right (BS.concat (reverse (plain : acc)), rest)
            Just ('\\', afterSlash) -> case BC.uncons afterSlash of
              Nothing -> Left "unterminated escape"
              Just ('\\', rest2) -> go rest2 ("\\" : plain : acc)
              Just ('"', rest2) -> go rest2 ("\"" : plain : acc)
              Just ('n', rest2) -> go rest2 ("\n" : plain : acc)
              Just ('r', rest2) -> go rest2 ("\r" : plain : acc)
              Just ('t', rest2) -> go rest2 ("\t" : plain : acc)
              -- A non-standard escape keeps the byte and drops the
              -- backslash, as upstream's .drv string parser does.
              Just (c, rest2) -> go rest2 (BC.singleton c : plain : acc)
            -- Unreachable: BC.break stops only at '"' or '\\'.
            Just (c, _) -> Left ("pStringContent: impossible break byte '" <> T.singleton c <> "'")

-- | Parse a comma-separated list enclosed in brackets: @[item,item,...]@
pList :: Parser a -> Parser [a]
pList pItem = do
  pChar '['
  items <- pSepBy pItem (pChar ',')
  pChar ']'
  pure items

-- | Parse zero or more items separated by a delimiter.
pSepBy :: Parser a -> Parser () -> Parser [a]
pSepBy pItem pSep = Parser $ \input ->
  case runParser pItem input of
    Left _ -> Right ([], input) -- empty list
    Right (first, rest) -> goMore rest [first]
  where
    goMore remaining acc =
      case runParser pSep remaining of
        Left _ -> Right (reverse acc, remaining)
        Right ((), afterSep) ->
          case runParser pItem afterSep of
            Left err -> Left err
            Right (item, rest2) -> goMore rest2 (item : acc)

-- | Parse a single output tuple: @(name, path, hashAlgo, hash)@.
-- The output name is validated with the store-name rules at this parse
-- boundary: it later becomes a filesystem component under the build
-- dir and an environment variable name, and a @.drv@ read from disk
-- is input, not trusted state.
pOutput :: Parser DerivationOutput
pOutput = do
  pChar '('
  name <- pQuotedText
  pChar ','
  pathStr <- pQuotedText
  pChar ','
  hashAlgo <- pQuotedText
  pChar ','
  hashVal <- pQuotedText
  pChar ')'
  case SP.checkStorePathName name of
    Left err -> parserFail ("invalid output name: " <> SP.storePathNameErrorText err)
    Right () -> case parseStorePathFromATerm pathStr of
      Just sp -> pure (DerivationOutput name sp hashAlgo hashVal)
      Nothing -> parserFail ("invalid output store path: " <> pathStr)

-- | Parse a store path from an ATerm string.
-- Tries defaultStoreDir first, then Windows store dir.
parseStorePathFromATerm :: Text -> Maybe StorePath
parseStorePathFromATerm pathStr =
  case SP.parseStorePath SP.defaultStoreDir pathStr of
    Just sp -> Just sp
    Nothing -> SP.parseStorePath (SP.StoreDir "C:\\nix\\store") pathStr

-- | Parse an input derivation tuple: @(drvPath, [outName1, outName2])@.
pInputDrv :: Parser (StorePath, [Text])
pInputDrv = do
  pChar '('
  pathStr <- pQuotedText
  pChar ','
  outs <- pList pQuotedText
  pChar ')'
  case parseStorePathFromATerm pathStr of
    Just sp -> pure (sp, outs)
    Nothing -> parserFail ("invalid input drv store path: " <> pathStr)

-- | Parse an input source (quoted store path string).
pInputSrc :: Parser StorePath
pInputSrc = do
  pathStr <- pQuotedText
  case parseStorePathFromATerm pathStr of
    Just sp -> pure sp
    Nothing -> parserFail ("invalid input src store path: " <> pathStr)

-- | Parse an environment pair: @(key, value)@.  The key is an attr name
-- (Text); the value keeps its raw bytes.
pEnvPair :: Parser (Text, ByteString)
pEnvPair = do
  pChar '('
  key <- pQuotedText
  pChar ','
  val <- pQuotedString
  pChar ')'
  pure (key, val)

-- | Parse a full derivation from its ATerm bytes.
-- Format: @Derive([outputs],[inputDrvs],[inputSrcs],platform,builder,[args],[env])@
--
-- Total function: returns @Left@ on any malformed input.
fromATerm :: ByteString -> Either Text Derivation
fromATerm input = case runParser pDerivation input of
  Left err -> Left ("ATerm parse error: " <> err)
  Right (drv, remaining)
    | BS.null remaining -> Right drv
    | otherwise -> Left ("ATerm parse error: unexpected trailing: " <> snippet (BS.take 40 remaining))

pDerivation :: Parser Derivation
pDerivation = do
  pString "Derive("
  outputs <- pList pOutput
  pChar ','
  inputDrvsList <- pList pInputDrv
  pChar ','
  inputSrcs <- pList pInputSrc
  pChar ','
  platformStr <- pQuotedText
  pChar ','
  builder <- pQuotedString
  pChar ','
  args <- pList pQuotedString
  pChar ','
  envPairs <- pList pEnvPair
  pChar ')'
  let inputDrvs = Map.fromListWith (++) inputDrvsList
      env = Map.fromList envPairs
      platform = textToPlatform platformStr
  pure
    Derivation
      { drvOutputs = outputs,
        drvInputDrvs = inputDrvs,
        drvInputSrcs = inputSrcs,
        drvPlatform = platform,
        drvBuilder = builder,
        drvArgs = args,
        drvEnv = env
      }