packages feed

nova-nix 0.6.0.0 → 0.8.0.0

raw patch · 59 files changed

+24948/−11346 lines, 59 filesdep +asyncdep +filelockdep +networkdep ~basedep ~nova-cachePVP ok

version bump matches the API change (PVP)

Dependencies added: async, filelock, network

Dependency ranges changed: base, nova-cache

API changes (from Hackage documentation)

- Nix.Builder: [bcBashPath] :: BuildConfig -> !FilePath
- Nix.Builder: [bcSandbox] :: BuildConfig -> !Bool
- Nix.Builder: instance GHC.Classes.Eq Nix.Builder.BuildConfig
- Nix.Builder: instance GHC.Classes.Eq Nix.Builder.BuildResult
- Nix.Builder: instance GHC.Show.Show Nix.Builder.BuildConfig
- Nix.Builder: instance GHC.Show.Show Nix.Builder.BuildResult
- Nix.DependencyGraph: instance GHC.Classes.Eq Nix.DependencyGraph.DepGraph
- Nix.DependencyGraph: instance GHC.Classes.Eq Nix.DependencyGraph.DepNode
- Nix.DependencyGraph: instance GHC.Classes.Eq Nix.DependencyGraph.TopoResult
- Nix.DependencyGraph: instance GHC.Show.Show Nix.DependencyGraph.DepGraph
- Nix.DependencyGraph: instance GHC.Show.Show Nix.DependencyGraph.DepNode
- Nix.DependencyGraph: instance GHC.Show.Show Nix.DependencyGraph.TopoResult
- Nix.Derivation: instance GHC.Base.Applicative Nix.Derivation.Parser
- Nix.Derivation: instance GHC.Base.Functor Nix.Derivation.Parser
- Nix.Derivation: instance GHC.Base.Monad Nix.Derivation.Parser
- Nix.Derivation: instance GHC.Classes.Eq Nix.Derivation.Derivation
- Nix.Derivation: instance GHC.Classes.Eq Nix.Derivation.DerivationOutput
- Nix.Derivation: instance GHC.Classes.Eq Nix.Derivation.Platform
- Nix.Derivation: instance GHC.Classes.Ord Nix.Derivation.Platform
- Nix.Derivation: instance GHC.Show.Show Nix.Derivation.Derivation
- Nix.Derivation: instance GHC.Show.Show Nix.Derivation.DerivationOutput
- Nix.Derivation: instance GHC.Show.Show Nix.Derivation.Platform
- Nix.Eval: instance GHC.Show.Show Nix.Eval.TOMLValue
- Nix.Eval: readFileText :: MonadEval m => Text -> m Text
- Nix.Eval.CList: instance GHC.Classes.Eq Nix.Eval.CList.CList
- Nix.Eval.CList: instance GHC.Show.Show Nix.Eval.CList.CList
- Nix.Eval.CThunk: cthunkNew :: Ptr () -> IO CThunkPtr
- Nix.Eval.EvalFormals: instance GHC.Classes.Eq Nix.Eval.EvalFormals.EvalFormal
- Nix.Eval.EvalFormals: instance GHC.Classes.Eq Nix.Eval.EvalFormals.EvalFormals
- Nix.Eval.EvalFormals: instance GHC.Show.Show Nix.Eval.EvalFormals.EvalFormal
- Nix.Eval.EvalFormals: instance GHC.Show.Show Nix.Eval.EvalFormals.EvalFormals
- Nix.Eval.IO: instance GHC.Base.Applicative Nix.Eval.IO.EvalIO
- Nix.Eval.IO: instance GHC.Base.Functor Nix.Eval.IO.EvalIO
- Nix.Eval.IO: instance GHC.Base.Monad Nix.Eval.IO.EvalIO
- Nix.Eval.IO: instance GHC.Exception.Type.Exception Nix.Eval.IO.NixAbortError
- Nix.Eval.IO: instance GHC.Exception.Type.Exception Nix.Eval.IO.NixEvalError
- Nix.Eval.IO: instance GHC.Show.Show Nix.Eval.IO.NixAbortError
- Nix.Eval.IO: instance GHC.Show.Show Nix.Eval.IO.NixEvalError
- Nix.Eval.IO: newtype NixEvalError
- Nix.Eval.Symbol: instance GHC.Classes.Eq Nix.Eval.Symbol.Symbol
- Nix.Eval.Symbol: instance GHC.Classes.Ord Nix.Eval.Symbol.Symbol
- Nix.Eval.Symbol: instance GHC.Show.Show Nix.Eval.Symbol.Symbol
- Nix.Eval.Types: instance GHC.Base.Applicative Nix.Eval.Types.PureEval
- Nix.Eval.Types: instance GHC.Base.Functor Nix.Eval.Types.PureEval
- Nix.Eval.Types: instance GHC.Base.Monad Nix.Eval.Types.PureEval
- Nix.Eval.Types: instance GHC.Base.Monoid Nix.Eval.Types.StringContext
- Nix.Eval.Types: instance GHC.Base.Semigroup Nix.Eval.Types.StringContext
- Nix.Eval.Types: instance GHC.Classes.Eq Nix.Eval.Types.AttrSet
- Nix.Eval.Types: instance GHC.Classes.Eq Nix.Eval.Types.CompiledRegex
- Nix.Eval.Types: instance GHC.Classes.Eq Nix.Eval.Types.Env
- Nix.Eval.Types: instance GHC.Classes.Eq Nix.Eval.Types.NixValue
- Nix.Eval.Types: instance GHC.Classes.Eq Nix.Eval.Types.StringContext
- Nix.Eval.Types: instance GHC.Classes.Eq Nix.Eval.Types.StringContextElement
- Nix.Eval.Types: instance GHC.Classes.Eq Nix.Eval.Types.Thunk
- Nix.Eval.Types: instance GHC.Classes.Ord Nix.Eval.Types.StringContext
- Nix.Eval.Types: instance GHC.Classes.Ord Nix.Eval.Types.StringContextElement
- Nix.Eval.Types: instance GHC.Show.Show Nix.Eval.Types.AttrSet
- Nix.Eval.Types: instance GHC.Show.Show Nix.Eval.Types.CompiledRegex
- Nix.Eval.Types: instance GHC.Show.Show Nix.Eval.Types.Env
- Nix.Eval.Types: instance GHC.Show.Show Nix.Eval.Types.NixValue
- Nix.Eval.Types: instance GHC.Show.Show Nix.Eval.Types.StringContext
- Nix.Eval.Types: instance GHC.Show.Show Nix.Eval.Types.StringContextElement
- Nix.Eval.Types: instance GHC.Show.Show Nix.Eval.Types.Thunk
- Nix.Eval.Types: readFileText :: MonadEval m => Text -> m Text
- Nix.Expr.Types: instance GHC.Classes.Eq Nix.Expr.Types.AttrKey
- Nix.Expr.Types: instance GHC.Classes.Eq Nix.Expr.Types.BinaryOp
- Nix.Expr.Types: instance GHC.Classes.Eq Nix.Expr.Types.Binding
- Nix.Expr.Types: instance GHC.Classes.Eq Nix.Expr.Types.CaptureInfo
- Nix.Expr.Types: instance GHC.Classes.Eq Nix.Expr.Types.Expr
- Nix.Expr.Types: instance GHC.Classes.Eq Nix.Expr.Types.Formal
- Nix.Expr.Types: instance GHC.Classes.Eq Nix.Expr.Types.Formals
- Nix.Expr.Types: instance GHC.Classes.Eq Nix.Expr.Types.NixAtom
- Nix.Expr.Types: instance GHC.Classes.Eq Nix.Expr.Types.StringPart
- Nix.Expr.Types: instance GHC.Classes.Eq Nix.Expr.Types.UnaryOp
- Nix.Expr.Types: instance GHC.Show.Show Nix.Expr.Types.AttrKey
- Nix.Expr.Types: instance GHC.Show.Show Nix.Expr.Types.BinaryOp
- Nix.Expr.Types: instance GHC.Show.Show Nix.Expr.Types.Binding
- Nix.Expr.Types: instance GHC.Show.Show Nix.Expr.Types.CaptureInfo
- Nix.Expr.Types: instance GHC.Show.Show Nix.Expr.Types.Expr
- Nix.Expr.Types: instance GHC.Show.Show Nix.Expr.Types.Formal
- Nix.Expr.Types: instance GHC.Show.Show Nix.Expr.Types.Formals
- Nix.Expr.Types: instance GHC.Show.Show Nix.Expr.Types.NixAtom
- Nix.Expr.Types: instance GHC.Show.Show Nix.Expr.Types.StringPart
- Nix.Expr.Types: instance GHC.Show.Show Nix.Expr.Types.UnaryOp
- Nix.Parser.Internal: instance GHC.Base.Applicative Nix.Parser.Internal.Parser
- Nix.Parser.Internal: instance GHC.Base.Functor Nix.Parser.Internal.Parser
- Nix.Parser.Internal: instance GHC.Base.Monad Nix.Parser.Internal.Parser
- Nix.Parser.Lexer: instance GHC.Classes.Eq Nix.Parser.Lexer.LexMode
- Nix.Parser.Lexer: instance GHC.Classes.Eq Nix.Parser.Lexer.Token
- Nix.Parser.Lexer: instance GHC.Show.Show Nix.Parser.Lexer.LexMode
- Nix.Parser.Lexer: instance GHC.Show.Show Nix.Parser.Lexer.Located
- Nix.Parser.Lexer: instance GHC.Show.Show Nix.Parser.Lexer.Token
- Nix.Parser.ParseError: instance GHC.Classes.Eq Nix.Parser.ParseError.ParseError
- Nix.Parser.ParseError: instance GHC.Show.Show Nix.Parser.ParseError.ParseError
- Nix.Push: instance GHC.Classes.Eq Nix.Push.PushConfig
- Nix.Push: instance GHC.Classes.Eq Nix.Push.PushSummary
- Nix.Push: instance GHC.Show.Show Nix.Push.PushConfig
- Nix.Push: instance GHC.Show.Show Nix.Push.PushSummary
- Nix.Store.DB: instance GHC.Classes.Eq Nix.Store.DB.PathInfo
- Nix.Store.DB: instance GHC.Classes.Eq Nix.Store.DB.PathRegistration
- Nix.Store.DB: instance GHC.Show.Show Nix.Store.DB.PathInfo
- Nix.Store.DB: instance GHC.Show.Show Nix.Store.DB.PathRegistration
- Nix.Store.Path: StorePath :: !Text -> !Text -> StorePath
- Nix.Store.Path: [spHash] :: StorePath -> !Text
- Nix.Store.Path: [spName] :: StorePath -> !Text
- Nix.Store.Path: instance GHC.Classes.Eq Nix.Store.Path.StoreDir
- Nix.Store.Path: instance GHC.Classes.Eq Nix.Store.Path.StorePath
- Nix.Store.Path: instance GHC.Classes.Ord Nix.Store.Path.StorePath
- Nix.Store.Path: instance GHC.Show.Show Nix.Store.Path.StoreDir
- Nix.Store.Path: instance GHC.Show.Show Nix.Store.Path.StorePath
- Nix.Substituter: [ccPublicKey] :: CacheConfig -> !Text
- Nix.Substituter: instance GHC.Classes.Eq Nix.Substituter.CacheConfig
- Nix.Substituter: instance GHC.Classes.Eq Nix.Substituter.SubstResult
- Nix.Substituter: instance GHC.Show.Show Nix.Substituter.CacheConfig
- Nix.Substituter: instance GHC.Show.Show Nix.Substituter.SubstResult
+ Nix.Builder: SpawnNative :: BuilderSpawn
+ Nix.Builder: SpawnThrough :: FilePath -> BuilderSpawn
+ Nix.Builder: SpawnUnsupported :: Text -> BuilderSpawn
+ Nix.Builder: [bcExecWrappers] :: BuildConfig -> Map Text FilePath
+ Nix.Builder: [bcUnpackLimits] :: BuildConfig -> UnpackLimits
+ Nix.Builder: buildPath :: FilePath -> Text
+ Nix.Builder: data BuilderSpawn
+ Nix.Builder: execWrapperConfig :: [String] -> Either Text (Map Text FilePath)
+ Nix.Builder: execWrapperFor :: BuildConfig -> Derivation -> BuilderSpawn
+ Nix.Builder: fetchUrlsFromEnv :: Map Text ByteString -> Either Text (NonEmpty Text)
+ Nix.Builder: instance GHC.Internal.Classes.Eq Nix.Builder.BuildConfig
+ Nix.Builder: instance GHC.Internal.Classes.Eq Nix.Builder.BuildResult
+ Nix.Builder: instance GHC.Internal.Classes.Eq Nix.Builder.BuilderSpawn
+ Nix.Builder: instance GHC.Internal.Show.Show Nix.Builder.BuildConfig
+ Nix.Builder: instance GHC.Internal.Show.Show Nix.Builder.BuildResult
+ Nix.Builder: instance GHC.Internal.Show.Show Nix.Builder.BuilderSpawn
+ Nix.Builder: rewriteEnv :: (Text -> Text) -> Map Text Text -> Map Text Text
+ Nix.Builder: rewritePlaceholders :: [(Text, FilePath)] -> Text -> Text
+ Nix.Builder: scrubAmbient :: Map Text Text -> Map Text Text
+ Nix.Builder: tryFetchUrlsWith :: Monad m => (Text -> m (Either Text a)) -> NonEmpty Text -> m (Either Text a)
+ Nix.Builder: unionEnvs :: [Map Text Text] -> Map Text Text
+ Nix.Builder: verifyFetchHash :: Text -> DerivationOutput -> ByteString -> Either (Int, Text) ()
+ Nix.Builder.Unpack: UnpackLimits :: Int64 -> Int -> UnpackLimits
+ Nix.Builder.Unpack: [ulMaxBytes] :: UnpackLimits -> Int64
+ Nix.Builder.Unpack: [ulMaxEntries] :: UnpackLimits -> Int
+ Nix.Builder.Unpack: data UnpackLimits
+ Nix.Builder.Unpack: defaultUnpackLimits :: UnpackLimits
+ Nix.Builder.Unpack: entryComponents :: FilePath -> Either Text [FilePath]
+ Nix.Builder.Unpack: instance GHC.Internal.Classes.Eq Nix.Builder.Unpack.UnpackLimits
+ Nix.Builder.Unpack: instance GHC.Internal.Show.Show Nix.Builder.Unpack.UnpackLimits
+ Nix.Builder.Unpack: resolveLinkTarget :: [FilePath] -> FilePath -> Either Text [FilePath]
+ Nix.Builtins: splitNixPath :: Text -> [Text]
+ Nix.Compression: CompressionBzip2 :: NarCompression
+ Nix.Compression: CompressionNone :: NarCompression
+ Nix.Compression: CompressionXz :: NarCompression
+ Nix.Compression: CompressionZstd :: NarCompression
+ Nix.Compression: compressionNameBzip2 :: Text
+ Nix.Compression: compressionNameNone :: Text
+ Nix.Compression: compressionNameXz :: Text
+ Nix.Compression: compressionNameZstd :: Text
+ Nix.Compression: data NarCompression
+ Nix.Compression: instance GHC.Internal.Classes.Eq Nix.Compression.NarCompression
+ Nix.Compression: instance GHC.Internal.Show.Show Nix.Compression.NarCompression
+ Nix.Compression: parseNarCompression :: Text -> Either Text NarCompression
+ Nix.Config: ConfigAssignment :: Text -> Text -> ConfigAssignment
+ Nix.Config: NixConfig :: [Text] -> [Text] -> NixConfig
+ Nix.Config: [caName] :: ConfigAssignment -> Text
+ Nix.Config: [caValue] :: ConfigAssignment -> Text
+ Nix.Config: [ncSubstituters] :: NixConfig -> [Text]
+ Nix.Config: [ncTrustedPublicKeys] :: NixConfig -> [Text]
+ Nix.Config: applyAssignment :: NixConfig -> ConfigAssignment -> NixConfig
+ Nix.Config: applyConfigText :: NixConfig -> Text -> Either Text NixConfig
+ Nix.Config: data ConfigAssignment
+ Nix.Config: data NixConfig
+ Nix.Config: defaultNixConfig :: NixConfig
+ Nix.Config: instance GHC.Internal.Classes.Eq Nix.Config.ApplyMode
+ Nix.Config: instance GHC.Internal.Classes.Eq Nix.Config.ConfigAssignment
+ Nix.Config: instance GHC.Internal.Classes.Eq Nix.Config.ConfigField
+ Nix.Config: instance GHC.Internal.Classes.Eq Nix.Config.NixConfig
+ Nix.Config: instance GHC.Internal.Show.Show Nix.Config.ApplyMode
+ Nix.Config: instance GHC.Internal.Show.Show Nix.Config.ConfigAssignment
+ Nix.Config: instance GHC.Internal.Show.Show Nix.Config.ConfigField
+ Nix.Config: instance GHC.Internal.Show.Show Nix.Config.NixConfig
+ Nix.Config: parseConfigText :: Text -> Either Text [ConfigAssignment]
+ Nix.Config: resolveConfig :: [Text] -> Either Text NixConfig
+ Nix.DependencyGraph: instance GHC.Internal.Classes.Eq Nix.DependencyGraph.DepGraph
+ Nix.DependencyGraph: instance GHC.Internal.Classes.Eq Nix.DependencyGraph.DepNode
+ Nix.DependencyGraph: instance GHC.Internal.Classes.Eq Nix.DependencyGraph.TopoResult
+ Nix.DependencyGraph: instance GHC.Internal.Show.Show Nix.DependencyGraph.DepGraph
+ Nix.DependencyGraph: instance GHC.Internal.Show.Show Nix.DependencyGraph.DepNode
+ Nix.DependencyGraph: instance GHC.Internal.Show.Show Nix.DependencyGraph.TopoResult
+ Nix.Derivation: instance GHC.Internal.Base.Applicative Nix.Derivation.Parser
+ Nix.Derivation: instance GHC.Internal.Base.Functor Nix.Derivation.Parser
+ Nix.Derivation: instance GHC.Internal.Base.Monad Nix.Derivation.Parser
+ Nix.Derivation: instance GHC.Internal.Classes.Eq Nix.Derivation.Derivation
+ Nix.Derivation: instance GHC.Internal.Classes.Eq Nix.Derivation.DerivationOutput
+ Nix.Derivation: instance GHC.Internal.Classes.Eq Nix.Derivation.Platform
+ Nix.Derivation: instance GHC.Internal.Classes.Ord Nix.Derivation.Platform
+ Nix.Derivation: instance GHC.Internal.Show.Show Nix.Derivation.Derivation
+ Nix.Derivation: instance GHC.Internal.Show.Show Nix.Derivation.DerivationOutput
+ Nix.Derivation: instance GHC.Internal.Show.Show Nix.Derivation.Platform
+ Nix.Eval: FetchCache :: Text -> Text -> Integer -> Integer -> Text -> FetchCache
+ Nix.Eval: [fcLastModified] :: FetchCache -> Integer
+ Nix.Eval: [fcNarHash] :: FetchCache -> Text
+ Nix.Eval: [fcRevCount] :: FetchCache -> Integer
+ Nix.Eval: [fcRev] :: FetchCache -> Text
+ Nix.Eval: [fcStorePath] :: FetchCache -> Text
+ Nix.Eval: addFixedOutputFile :: MonadEval m => Text -> ByteString -> m Text
+ Nix.Eval: addSourceNar :: MonadEval m => Text -> ByteString -> m Text
+ Nix.Eval: adoptStorePath :: MonadEval m => Text -> m Bool
+ Nix.Eval: checkGitRef :: Text -> Either Text Text
+ Nix.Eval: checkGitRev :: Text -> Either Text Text
+ Nix.Eval: checkGitUrl :: Text -> Either Text Text
+ Nix.Eval: createScratchDir :: MonadEval m => Text -> m Text
+ Nix.Eval: data FetchCache
+ Nix.Eval: decodeFetchCache :: Text -> Maybe FetchCache
+ Nix.Eval: encodeFetchCache :: FetchCache -> Text
+ Nix.Eval: fetchCacheKey :: Text -> Text -> Text -> Bool -> Bool -> Text
+ Nix.Eval: instance GHC.Internal.Classes.Eq Nix.Eval.FetchCache
+ Nix.Eval: instance GHC.Internal.Classes.Eq Nix.Eval.TomlStringState
+ Nix.Eval: instance GHC.Internal.Show.Show Nix.Eval.FetchCache
+ Nix.Eval: instance GHC.Internal.Show.Show Nix.Eval.TOMLValue
+ Nix.Eval: isExecutableFile :: MonadEval m => Text -> m Bool
+ Nix.Eval: lookupFetchCache :: MonadEval m => Text -> m (Maybe Text)
+ Nix.Eval: lookupSessionDrv :: MonadEval m => Text -> m (Maybe Derivation)
+ Nix.Eval: narHashOfPath :: MonadEval m => Text -> m ByteString
+ Nix.Eval: onEvalError :: MonadEval m => m a -> m () -> m a
+ Nix.Eval: readStoreDerivation :: MonadEval m => StorePath -> m (Maybe Derivation)
+ Nix.Eval: readSymlinkTarget :: MonadEval m => Text -> m Text
+ Nix.Eval: removeScratchDir :: MonadEval m => Text -> m ()
+ Nix.Eval: setExecutableFile :: MonadEval m => Text -> m ()
+ Nix.Eval: throwCatchableError :: MonadEval m => Text -> m a
+ Nix.Eval: writeFetchCache :: MonadEval m => Text -> Text -> m ()
+ Nix.Eval.AttrPath: parseAttrPath :: Text -> Either Text [Text]
+ Nix.Eval.AttrPath: selectAttrPath :: MonadEval m => Text -> NixValue -> m (Either Text NixValue)
+ Nix.Eval.CBytecode: cbcCountedPayload :: Word32 -> Word32 -> IO (Int, Word32)
+ Nix.Eval.CBytecode: pattern OpPathStr :: Word8
+ Nix.Eval.CBytecode: spilledCountSentinel :: Word16
+ Nix.Eval.CList: instance GHC.Internal.Classes.Eq Nix.Eval.CList.CList
+ Nix.Eval.CList: instance GHC.Internal.Show.Show Nix.Eval.CList.CList
+ Nix.Eval.CThunk: cthunkMarkPending :: CThunkPtr -> IO Bool
+ Nix.Eval.CanonPath: canonBaseName :: Text -> Text
+ Nix.Eval.CanonPath: canonDirName :: Text -> Text
+ Nix.Eval.CanonPath: canonPath :: Text -> Text
+ Nix.Eval.CanonPath: canonPathValue :: Text -> Text
+ Nix.Eval.Context: extractAllOutputRefs :: StringContext -> [StorePath]
+ Nix.Eval.EvalFormals: instance GHC.Internal.Classes.Eq Nix.Eval.EvalFormals.EvalFormal
+ Nix.Eval.EvalFormals: instance GHC.Internal.Classes.Eq Nix.Eval.EvalFormals.EvalFormals
+ Nix.Eval.EvalFormals: instance GHC.Internal.Show.Show Nix.Eval.EvalFormals.EvalFormal
+ Nix.Eval.EvalFormals: instance GHC.Internal.Show.Show Nix.Eval.EvalFormals.EvalFormals
+ Nix.Eval.IO: ErrorThrown :: EvalErrorKind
+ Nix.Eval.IO: ErrorUncatchable :: EvalErrorKind
+ Nix.Eval.IO: [esStoreWriteCache] :: EvalState -> IORef (Map Text ([StorePath], StoreWriteMode))
+ Nix.Eval.IO: data EvalErrorKind
+ Nix.Eval.IO: data NixEvalError
+ Nix.Eval.IO: instance GHC.Internal.Base.Applicative Nix.Eval.IO.EvalIO
+ Nix.Eval.IO: instance GHC.Internal.Base.Functor Nix.Eval.IO.EvalIO
+ Nix.Eval.IO: instance GHC.Internal.Base.Monad Nix.Eval.IO.EvalIO
+ Nix.Eval.IO: instance GHC.Internal.Classes.Eq Nix.Eval.IO.EvalErrorKind
+ Nix.Eval.IO: instance GHC.Internal.Exception.Type.Exception Nix.Eval.IO.NixAbortError
+ Nix.Eval.IO: instance GHC.Internal.Exception.Type.Exception Nix.Eval.IO.NixEvalError
+ Nix.Eval.IO: instance GHC.Internal.Show.Show Nix.Eval.IO.EvalErrorKind
+ Nix.Eval.IO: instance GHC.Internal.Show.Show Nix.Eval.IO.NixAbortError
+ Nix.Eval.IO: instance GHC.Internal.Show.Show Nix.Eval.IO.NixEvalError
+ Nix.Eval.Operator: checkedAdd :: Int64 -> Int64 -> Either Text Int64
+ Nix.Eval.Operator: checkedMul :: Int64 -> Int64 -> Either Text Int64
+ Nix.Eval.Operator: checkedSub :: Int64 -> Int64 -> Either Text Int64
+ Nix.Eval.StringInterp: formatJsonFloat :: Double -> Text
+ Nix.Eval.StringInterp: formatXmlFloat :: Double -> Text
+ Nix.Eval.StringInterp: type CoercePath (m :: Type -> Type) = Text -> m (ByteString, StringContext)
+ Nix.Eval.Symbol: instance GHC.Internal.Classes.Eq Nix.Eval.Symbol.Symbol
+ Nix.Eval.Symbol: instance GHC.Internal.Classes.Ord Nix.Eval.Symbol.Symbol
+ Nix.Eval.Symbol: instance GHC.Internal.Show.Show Nix.Eval.Symbol.Symbol
+ Nix.Eval.Symbol: symbolBytes :: Symbol -> ByteString
+ Nix.Eval.Symbol: symbolInternBytes :: ByteString -> IO Symbol
+ Nix.Eval.Types: addFixedOutputFile :: MonadEval m => Text -> ByteString -> m Text
+ Nix.Eval.Types: addSourceNar :: MonadEval m => Text -> ByteString -> m Text
+ Nix.Eval.Types: adoptStorePath :: MonadEval m => Text -> m Bool
+ Nix.Eval.Types: bytesToTextLossy :: ByteString -> Text
+ Nix.Eval.Types: checkedCPtr :: String -> Ptr a -> Ptr a
+ Nix.Eval.Types: createScratchDir :: MonadEval m => Text -> m Text
+ Nix.Eval.Types: instance GHC.Internal.Base.Applicative Nix.Eval.Types.PureEval
+ Nix.Eval.Types: instance GHC.Internal.Base.Functor Nix.Eval.Types.PureEval
+ Nix.Eval.Types: instance GHC.Internal.Base.Monad Nix.Eval.Types.PureEval
+ Nix.Eval.Types: instance GHC.Internal.Base.Monoid Nix.Eval.Types.StringContext
+ Nix.Eval.Types: instance GHC.Internal.Base.Semigroup Nix.Eval.Types.StringContext
+ Nix.Eval.Types: instance GHC.Internal.Classes.Eq Nix.Eval.Types.AttrSet
+ Nix.Eval.Types: instance GHC.Internal.Classes.Eq Nix.Eval.Types.CompiledRegex
+ Nix.Eval.Types: instance GHC.Internal.Classes.Eq Nix.Eval.Types.Env
+ Nix.Eval.Types: instance GHC.Internal.Classes.Eq Nix.Eval.Types.NixValue
+ Nix.Eval.Types: instance GHC.Internal.Classes.Eq Nix.Eval.Types.StringContext
+ Nix.Eval.Types: instance GHC.Internal.Classes.Eq Nix.Eval.Types.StringContextElement
+ Nix.Eval.Types: instance GHC.Internal.Classes.Eq Nix.Eval.Types.Thunk
+ Nix.Eval.Types: instance GHC.Internal.Classes.Ord Nix.Eval.Types.StringContext
+ Nix.Eval.Types: instance GHC.Internal.Classes.Ord Nix.Eval.Types.StringContextElement
+ Nix.Eval.Types: instance GHC.Internal.Show.Show Nix.Eval.Types.AttrSet
+ Nix.Eval.Types: instance GHC.Internal.Show.Show Nix.Eval.Types.CompiledRegex
+ Nix.Eval.Types: instance GHC.Internal.Show.Show Nix.Eval.Types.Env
+ Nix.Eval.Types: instance GHC.Internal.Show.Show Nix.Eval.Types.NixValue
+ Nix.Eval.Types: instance GHC.Internal.Show.Show Nix.Eval.Types.StringContext
+ Nix.Eval.Types: instance GHC.Internal.Show.Show Nix.Eval.Types.StringContextElement
+ Nix.Eval.Types: instance GHC.Internal.Show.Show Nix.Eval.Types.Thunk
+ Nix.Eval.Types: isExecutableFile :: MonadEval m => Text -> m Bool
+ Nix.Eval.Types: lookupFetchCache :: MonadEval m => Text -> m (Maybe Text)
+ Nix.Eval.Types: lookupSessionDrv :: MonadEval m => Text -> m (Maybe Derivation)
+ Nix.Eval.Types: mkStrBytes :: ByteString -> NixValue
+ Nix.Eval.Types: narHashOfPath :: MonadEval m => Text -> m ByteString
+ Nix.Eval.Types: onEvalError :: MonadEval m => m a -> m () -> m a
+ Nix.Eval.Types: readStoreDerivation :: MonadEval m => StorePath -> m (Maybe Derivation)
+ Nix.Eval.Types: readSymlinkTarget :: MonadEval m => Text -> m Text
+ Nix.Eval.Types: removeScratchDir :: MonadEval m => Text -> m ()
+ Nix.Eval.Types: setExecutableFile :: MonadEval m => Text -> m ()
+ Nix.Eval.Types: storePathOrThrow :: MonadEval m => Text -> Either StorePathNameError StorePath -> m StorePath
+ Nix.Eval.Types: throwCatchableError :: MonadEval m => Text -> m a
+ Nix.Eval.Types: writeFetchCache :: MonadEval m => Text -> Text -> m ()
+ Nix.Expr.Resolve: resolveRelativePaths :: FilePath -> Expr -> Expr
+ Nix.Expr.Resolve: staticGlobalNames :: Set Text
+ Nix.Expr.Types: EPathStr :: [StringPart] -> Expr
+ Nix.Expr.Types: instance GHC.Internal.Classes.Eq Nix.Expr.Types.AttrKey
+ Nix.Expr.Types: instance GHC.Internal.Classes.Eq Nix.Expr.Types.BinaryOp
+ Nix.Expr.Types: instance GHC.Internal.Classes.Eq Nix.Expr.Types.Binding
+ Nix.Expr.Types: instance GHC.Internal.Classes.Eq Nix.Expr.Types.CaptureInfo
+ Nix.Expr.Types: instance GHC.Internal.Classes.Eq Nix.Expr.Types.Expr
+ Nix.Expr.Types: instance GHC.Internal.Classes.Eq Nix.Expr.Types.Formal
+ Nix.Expr.Types: instance GHC.Internal.Classes.Eq Nix.Expr.Types.Formals
+ Nix.Expr.Types: instance GHC.Internal.Classes.Eq Nix.Expr.Types.NixAtom
+ Nix.Expr.Types: instance GHC.Internal.Classes.Eq Nix.Expr.Types.StringPart
+ Nix.Expr.Types: instance GHC.Internal.Classes.Eq Nix.Expr.Types.UnaryOp
+ Nix.Expr.Types: instance GHC.Internal.Show.Show Nix.Expr.Types.AttrKey
+ Nix.Expr.Types: instance GHC.Internal.Show.Show Nix.Expr.Types.BinaryOp
+ Nix.Expr.Types: instance GHC.Internal.Show.Show Nix.Expr.Types.Binding
+ Nix.Expr.Types: instance GHC.Internal.Show.Show Nix.Expr.Types.CaptureInfo
+ Nix.Expr.Types: instance GHC.Internal.Show.Show Nix.Expr.Types.Expr
+ Nix.Expr.Types: instance GHC.Internal.Show.Show Nix.Expr.Types.Formal
+ Nix.Expr.Types: instance GHC.Internal.Show.Show Nix.Expr.Types.Formals
+ Nix.Expr.Types: instance GHC.Internal.Show.Show Nix.Expr.Types.NixAtom
+ Nix.Expr.Types: instance GHC.Internal.Show.Show Nix.Expr.Types.StringPart
+ Nix.Expr.Types: instance GHC.Internal.Show.Show Nix.Expr.Types.UnaryOp
+ Nix.Hash: base64HashLen :: Int -> Int
+ Nix.Hash: data IncrementalHash
+ Nix.Hash: hashAlgoBytes :: Text -> Maybe Int
+ Nix.Hash: hashFinalizeBytes :: IncrementalHash -> ByteString
+ Nix.Hash: hashInitWithAlgo :: Text -> Maybe IncrementalHash
+ Nix.Hash: hashUpdateChunk :: IncrementalHash -> ByteString -> IncrementalHash
+ Nix.Hash: hexHashLen :: Int -> Int
+ Nix.Hash: nix32HashLen :: Int -> Int
+ Nix.Parser.Internal: deeperError :: ParseError -> ParseError -> ParseError
+ Nix.Parser.Internal: failWithError :: ParseError -> Parser a
+ Nix.Parser.Internal: instance GHC.Internal.Base.Applicative Nix.Parser.Internal.Parser
+ Nix.Parser.Internal: instance GHC.Internal.Base.Functor Nix.Parser.Internal.Parser
+ Nix.Parser.Internal: instance GHC.Internal.Base.Monad Nix.Parser.Internal.Parser
+ Nix.Parser.Internal: tryParserKeepError :: Parser a -> Parser (Either ParseError a)
+ Nix.Parser.Lexer: TokPathEnd :: Token
+ Nix.Parser.Lexer: TokPathInterpStart :: Text -> Token
+ Nix.Parser.Lexer: TokPathLit :: Text -> Token
+ Nix.Parser.Lexer: TokStringEsc :: Text -> Token
+ Nix.Parser.Lexer: instance GHC.Internal.Classes.Eq Nix.Parser.Lexer.LexMode
+ Nix.Parser.Lexer: instance GHC.Internal.Classes.Eq Nix.Parser.Lexer.Token
+ Nix.Parser.Lexer: instance GHC.Internal.Show.Show Nix.Parser.Lexer.LexMode
+ Nix.Parser.Lexer: instance GHC.Internal.Show.Show Nix.Parser.Lexer.Located
+ Nix.Parser.Lexer: instance GHC.Internal.Show.Show Nix.Parser.Lexer.Token
+ Nix.Parser.ParseError: instance GHC.Internal.Classes.Eq Nix.Parser.ParseError.ParseError
+ Nix.Parser.ParseError: instance GHC.Internal.Show.Show Nix.Parser.ParseError.ParseError
+ Nix.Push: PushArtifact :: ByteString -> Text -> Int -> Text -> Text -> Text -> Int -> PushArtifact
+ Nix.Push: PushNone :: PushCompression
+ Nix.Push: PushZstd :: PushCompression
+ Nix.Push: [paBytes] :: PushArtifact -> ByteString
+ Nix.Push: [paCompressionText] :: PushArtifact -> Text
+ Nix.Push: [paFileHash] :: PushArtifact -> Text
+ Nix.Push: [paFileSize] :: PushArtifact -> Int
+ Nix.Push: [paNarHash] :: PushArtifact -> Text
+ Nix.Push: [paNarSize] :: PushArtifact -> Int
+ Nix.Push: [paObjectName] :: PushArtifact -> Text
+ Nix.Push: [pcCompression] :: PushConfig -> PushCompression
+ Nix.Push: checkRecordedNarHash :: Maybe PathInfo -> Text -> StorePath -> Either Text ()
+ Nix.Push: data PushArtifact
+ Nix.Push: data PushCompression
+ Nix.Push: instance GHC.Internal.Classes.Eq Nix.Push.PushArtifact
+ Nix.Push: instance GHC.Internal.Classes.Eq Nix.Push.PushCompression
+ Nix.Push: instance GHC.Internal.Classes.Eq Nix.Push.PushConfig
+ Nix.Push: instance GHC.Internal.Classes.Eq Nix.Push.PushSummary
+ Nix.Push: instance GHC.Internal.Show.Show Nix.Push.PushArtifact
+ Nix.Push: instance GHC.Internal.Show.Show Nix.Push.PushCompression
+ Nix.Push: instance GHC.Internal.Show.Show Nix.Push.PushConfig
+ Nix.Push: instance GHC.Internal.Show.Show Nix.Push.PushSummary
+ Nix.Push: mkPushArtifact :: PushCompression -> Text -> ByteString -> PushArtifact
+ Nix.Push: narHashMatches :: Text -> Text -> Bool
+ Nix.Push: parsePushCompression :: Text -> Either Text PushCompression
+ Nix.Push: pushCompressionValues :: Text
+ Nix.Store: DeleteOutcome :: Bool -> Bool -> DeleteOutcome
+ Nix.Store: [doRowRemoved] :: DeleteOutcome -> Bool
+ Nix.Store: [doTreeRemoved] :: DeleteOutcome -> Bool
+ Nix.Store: abortNarUnpack :: NarUnpackSink -> IO ()
+ Nix.Store: caseHackDiskNames :: [Text] -> [(Text, Text)]
+ Nix.Store: copyPathInto :: FilePath -> FilePath -> IO ()
+ Nix.Store: data DeleteOutcome
+ Nix.Store: data NarUnpackSink
+ Nix.Store: deleteStorePathRaw :: Store -> Text -> IO (Either Text DeleteOutcome)
+ Nix.Store: finishNarUnpack :: NarUnpackSink -> IO (Either Text ())
+ Nix.Store: instance GHC.Internal.Classes.Eq Nix.Store.DeleteOutcome
+ Nix.Store: instance GHC.Internal.Show.Show Nix.Store.DeleteOutcome
+ Nix.Store: isSafeNarName :: Text -> Bool
+ Nix.Store: materializeEvalSources :: Store -> Map Text Text -> IO ()
+ Nix.Store: materializeEvalStoreWrites :: Store -> Map Text ([StorePath], StoreWriteMode) -> IO ()
+ Nix.Store: newNarUnpackSink :: FilePath -> IO NarUnpackSink
+ Nix.Store: orderLinks :: [(FilePath, Text)] -> [(FilePath, Text)]
+ Nix.Store: resolveDeleteTarget :: StoreDir -> Text -> Either Text Text
+ Nix.Store: sinkNarEvent :: NarUnpackSink -> NarEvent -> IO (Either Text ())
+ Nix.Store: unpackNarEntry :: FilePath -> NarEntry -> IO (Either Text ())
+ Nix.Store: writeDrvClosure :: Store -> Map Text ByteString -> IO ()
+ Nix.Store.CaseSensitive: trySetCaseSensitiveDir :: FilePath -> IO Bool
+ Nix.Store.DB: RowAbsent :: UnregisterResult
+ Nix.Store.DB: RowReferenced :: [Text] -> UnregisterResult
+ Nix.Store.DB: RowUnregistered :: UnregisterResult
+ Nix.Store.DB: data UnregisterResult
+ Nix.Store.DB: instance GHC.Internal.Classes.Eq Nix.Store.DB.PathInfo
+ Nix.Store.DB: instance GHC.Internal.Classes.Eq Nix.Store.DB.PathRegistration
+ Nix.Store.DB: instance GHC.Internal.Classes.Eq Nix.Store.DB.UnregisterResult
+ Nix.Store.DB: instance GHC.Internal.Show.Show Nix.Store.DB.PathInfo
+ Nix.Store.DB: instance GHC.Internal.Show.Show Nix.Store.DB.PathRegistration
+ Nix.Store.DB: instance GHC.Internal.Show.Show Nix.Store.DB.UnregisterResult
+ Nix.Store.DB: unregisterPathRow :: StoreDB -> Text -> IO UnregisterResult
+ Nix.Store.ExecBit: copyExecMark :: FilePath -> FilePath -> IO ()
+ Nix.Store.ExecBit: execStreamName :: String
+ Nix.Store.ExecBit: isExecutable :: FilePath -> IO Bool
+ Nix.Store.ExecBit: markExecutable :: FilePath -> IO ()
+ Nix.Store.ExecBit: narHashOfPath :: FilePath -> IO NixHash
+ Nix.Store.ExecBit: serialiseFromPath :: FilePath -> IO NarEntry
+ Nix.Store.Lock: acquirePathLock :: StoreDir -> StorePath -> IO PathLock
+ Nix.Store.Lock: data PathLock
+ Nix.Store.Lock: instance GHC.Internal.Classes.Eq Nix.Store.Lock.PathLock
+ Nix.Store.Lock: instance GHC.Internal.Show.Show Nix.Store.Lock.PathLock
+ Nix.Store.Lock: lockFileSuffix :: FilePath
+ Nix.Store.Lock: pathLockFilePath :: StoreDir -> StorePath -> FilePath
+ Nix.Store.Lock: releasePathLock :: PathLock -> IO ()
+ Nix.Store.Lock: tryAcquirePathLock :: StoreDir -> StorePath -> IO (Maybe PathLock)
+ Nix.Store.Lock: withLockFile :: FilePath -> (PathLock -> IO a) -> IO a
+ Nix.Store.Lock: withPathLock :: StoreDir -> StorePath -> (PathLock -> IO a) -> IO a
+ Nix.Store.Path: NameDotSegment :: Text -> StorePathNameReason
+ Nix.Store.Path: NameEmpty :: StorePathNameReason
+ Nix.Store.Path: NameIllegalChar :: Char -> StorePathNameReason
+ Nix.Store.Path: NameTooLong :: Int -> StorePathNameReason
+ Nix.Store.Path: StorePathNameError :: Text -> StorePathNameReason -> StorePathNameError
+ Nix.Store.Path: WriteFlat :: StoreWriteMode
+ Nix.Store.Path: WriteRecursive :: StoreWriteMode
+ Nix.Store.Path: WriteText :: StoreWriteMode
+ Nix.Store.Path: [spneName] :: StorePathNameError -> Text
+ Nix.Store.Path: [spneReason] :: StorePathNameError -> StorePathNameReason
+ Nix.Store.Path: checkStorePathName :: Text -> Either StorePathNameError ()
+ Nix.Store.Path: data StorePathNameError
+ Nix.Store.Path: data StorePathNameReason
+ Nix.Store.Path: data StoreWriteMode
+ Nix.Store.Path: instance GHC.Internal.Classes.Eq Nix.Store.Path.StoreDir
+ Nix.Store.Path: instance GHC.Internal.Classes.Eq Nix.Store.Path.StorePathNameError
+ Nix.Store.Path: instance GHC.Internal.Classes.Eq Nix.Store.Path.StorePathNameReason
+ Nix.Store.Path: instance GHC.Internal.Classes.Eq Nix.Store.Path.StoreWriteMode
+ Nix.Store.Path: instance GHC.Internal.Show.Show Nix.Store.Path.StoreDir
+ Nix.Store.Path: instance GHC.Internal.Show.Show Nix.Store.Path.StorePathNameError
+ Nix.Store.Path: instance GHC.Internal.Show.Show Nix.Store.Path.StorePathNameReason
+ Nix.Store.Path: instance GHC.Internal.Show.Show Nix.Store.Path.StoreWriteMode
+ Nix.Store.Path: isCanonicalStoreText :: Text -> Bool
+ Nix.Store.Path: parseStorePathBaseName :: Text -> Maybe StorePath
+ Nix.Store.Path: storePathNameErrorText :: StorePathNameError -> Text
+ Nix.Store.Path: storePathNameReasonText :: StorePathNameReason -> Text
+ Nix.Store.Path: storeTextToFilePath :: StoreDir -> Text -> FilePath
+ Nix.Store.Path: validStorePathName :: Text -> Bool
+ Nix.Store.Path.Internal: StorePath :: Text -> Text -> StorePath
+ Nix.Store.Path.Internal: [spHash] :: StorePath -> Text
+ Nix.Store.Path.Internal: [spName] :: StorePath -> Text
+ Nix.Store.Path.Internal: data StorePath
+ Nix.Store.Path.Internal: instance GHC.Internal.Classes.Eq Nix.Store.Path.Internal.StorePath
+ Nix.Store.Path.Internal: instance GHC.Internal.Classes.Ord Nix.Store.Path.Internal.StorePath
+ Nix.Store.Path.Internal: instance GHC.Internal.Show.Show Nix.Store.Path.Internal.StorePath
+ Nix.Store.Path.Internal: maskedOutputPath :: StorePath
+ Nix.Substituter: FatalFailure :: Text -> AttemptFailure
+ Nix.Substituter: SubstAlreadyValid :: SubstResult
+ Nix.Substituter: TransientFailure :: Text -> AttemptFailure
+ Nix.Substituter: [ccPublicKeys] :: CacheConfig -> [Text]
+ Nix.Substituter: attemptFailureMessage :: AttemptFailure -> Text
+ Nix.Substituter: cappedBodySource :: Int -> BodyReader -> IO (IO ByteString)
+ Nix.Substituter: catchSync :: IO a -> (SomeException -> IO a) -> IO a
+ Nix.Substituter: clearStaleDestination :: FilePath -> IO ()
+ Nix.Substituter: compressedBodyCeiling :: Integer -> Integer
+ Nix.Substituter: consumeNarStream :: FilePath -> NarInfo -> NixHash -> IO ByteString -> IO (Either AttemptFailure Int)
+ Nix.Substituter: data AttemptFailure
+ Nix.Substituter: decompressorFor :: Integer -> Text -> Either Text (ByteString -> IO (Either Text ByteString))
+ Nix.Substituter: downloadCapFor :: NarInfo -> Either Text Int
+ Nix.Substituter: httpStatusFailure :: Int -> AttemptFailure
+ Nix.Substituter: instance GHC.Internal.Classes.Eq Nix.Substituter.AttemptFailure
+ Nix.Substituter: instance GHC.Internal.Classes.Eq Nix.Substituter.CacheConfig
+ Nix.Substituter: instance GHC.Internal.Classes.Eq Nix.Substituter.SubstResult
+ Nix.Substituter: instance GHC.Internal.Exception.Type.Exception Nix.Substituter.StreamAbort
+ Nix.Substituter: instance GHC.Internal.Show.Show Nix.Substituter.AttemptFailure
+ Nix.Substituter: instance GHC.Internal.Show.Show Nix.Substituter.CacheConfig
+ Nix.Substituter: instance GHC.Internal.Show.Show Nix.Substituter.StreamAbort
+ Nix.Substituter: instance GHC.Internal.Show.Show Nix.Substituter.SubstResult
+ Nix.Substituter: materializeNarFromSource :: Store -> StorePath -> NarInfo -> NixHash -> [StorePath] -> Maybe Text -> IO ByteString -> IO (Either AttemptFailure PathRegistration)
+ Nix.Substituter: maxNarInfoBody :: Int
+ Nix.Substituter: narInfoPreflight :: CacheConfig -> NarInfo -> Either Text ()
+ Nix.Substituter: parseDeriver :: StoreDir -> Maybe Text -> Either Text (Maybe Text)
+ Nix.Substituter: readBodyCapped :: Int -> BodyReader -> IO (Maybe ByteString)
+ Nix.Substituter: streamingDecompressionSupported :: Text -> Either Text ()
+ Nix.Substituter: tryCachesWith :: Monad m => (CacheConfig -> m SubstResult) -> [CacheConfig] -> m SubstResult
+ Nix.Substituter: unpackAndVerify :: Store -> StorePath -> NarInfo -> ByteString -> IO SubstResult
+ Nix.Substituter: validateNarInfoFields :: NarInfo -> Either Text ()
+ Nix.Substituter: verifyNarSize :: NarInfo -> ByteString -> Either Text ()
+ Nix.Substituter: withDecompressedSource :: Integer -> Text -> IO ByteString -> (IO ByteString -> IO (Either AttemptFailure a)) -> IO (Either AttemptFailure a)
- Nix.Builder: BuildConfig :: !StoreDir -> !FilePath -> !FilePath -> !Bool -> ![CacheConfig] -> BuildConfig
+ Nix.Builder: BuildConfig :: StoreDir -> FilePath -> [CacheConfig] -> UnpackLimits -> Map Text FilePath -> BuildConfig
- Nix.Builder: BuildFailure :: !Text -> !Int -> BuildResult
+ Nix.Builder: BuildFailure :: Text -> Int -> BuildResult
- Nix.Builder: BuildSuccess :: !StorePath -> BuildResult
+ Nix.Builder: BuildSuccess :: StorePath -> BuildResult
- Nix.Builder: [bcCaches] :: BuildConfig -> ![CacheConfig]
+ Nix.Builder: [bcCaches] :: BuildConfig -> [CacheConfig]
- Nix.Builder: [bcStoreDir] :: BuildConfig -> !StoreDir
+ Nix.Builder: [bcStoreDir] :: BuildConfig -> StoreDir
- Nix.Builder: [bcTmpDir] :: BuildConfig -> !FilePath
+ Nix.Builder: [bcTmpDir] :: BuildConfig -> FilePath
- Nix.Builder.Unpack: builtinUnpackBuilder :: Text
+ Nix.Builder.Unpack: builtinUnpackBuilder :: ByteString
- Nix.Builder.Unpack: runBuiltinUnpack :: Derivation -> [(Text, FilePath)] -> IO (Either (Int, Text) ())
+ Nix.Builder.Unpack: runBuiltinUnpack :: StoreDir -> UnpackLimits -> Derivation -> [(Text, FilePath)] -> IO (Either (Int, Text) ())
- Nix.DependencyGraph: DepNode :: !StorePath -> !Derivation -> ![StorePath] -> DepNode
+ Nix.DependencyGraph: DepNode :: StorePath -> Derivation -> [StorePath] -> DepNode
- Nix.DependencyGraph: TopoCycle :: ![StorePath] -> TopoResult
+ Nix.DependencyGraph: TopoCycle :: [StorePath] -> TopoResult
- Nix.DependencyGraph: TopoSorted :: ![StorePath] -> TopoResult
+ Nix.DependencyGraph: TopoSorted :: [StorePath] -> TopoResult
- Nix.DependencyGraph: [dnDeps] :: DepNode -> ![StorePath]
+ Nix.DependencyGraph: [dnDeps] :: DepNode -> [StorePath]
- Nix.DependencyGraph: [dnDerivation] :: DepNode -> !Derivation
+ Nix.DependencyGraph: [dnDerivation] :: DepNode -> Derivation
- Nix.DependencyGraph: [dnDrvPath] :: DepNode -> !StorePath
+ Nix.DependencyGraph: [dnDrvPath] :: DepNode -> StorePath
- Nix.Derivation: Derivation :: ![DerivationOutput] -> !Map StorePath [Text] -> ![StorePath] -> !Platform -> !Text -> ![Text] -> !Map Text Text -> Derivation
+ Nix.Derivation: Derivation :: [DerivationOutput] -> Map StorePath [Text] -> [StorePath] -> Platform -> ByteString -> [ByteString] -> Map Text ByteString -> Derivation
- Nix.Derivation: DerivationOutput :: !Text -> !StorePath -> !Text -> !Text -> DerivationOutput
+ Nix.Derivation: DerivationOutput :: Text -> StorePath -> Text -> Text -> DerivationOutput
- Nix.Derivation: OtherPlatform :: !Text -> Platform
+ Nix.Derivation: OtherPlatform :: Text -> Platform
- Nix.Derivation: [doHashAlgo] :: DerivationOutput -> !Text
+ Nix.Derivation: [doHashAlgo] :: DerivationOutput -> Text
- Nix.Derivation: [doHash] :: DerivationOutput -> !Text
+ Nix.Derivation: [doHash] :: DerivationOutput -> Text
- Nix.Derivation: [doName] :: DerivationOutput -> !Text
+ Nix.Derivation: [doName] :: DerivationOutput -> Text
- Nix.Derivation: [doPath] :: DerivationOutput -> !StorePath
+ Nix.Derivation: [doPath] :: DerivationOutput -> StorePath
- Nix.Derivation: [drvArgs] :: Derivation -> ![Text]
+ Nix.Derivation: [drvArgs] :: Derivation -> [ByteString]
- Nix.Derivation: [drvBuilder] :: Derivation -> !Text
+ Nix.Derivation: [drvBuilder] :: Derivation -> ByteString
- Nix.Derivation: [drvEnv] :: Derivation -> !Map Text Text
+ Nix.Derivation: [drvEnv] :: Derivation -> Map Text ByteString
- Nix.Derivation: [drvInputDrvs] :: Derivation -> !Map StorePath [Text]
+ Nix.Derivation: [drvInputDrvs] :: Derivation -> Map StorePath [Text]
- Nix.Derivation: [drvInputSrcs] :: Derivation -> ![StorePath]
+ Nix.Derivation: [drvInputSrcs] :: Derivation -> [StorePath]
- Nix.Derivation: [drvOutputs] :: Derivation -> ![DerivationOutput]
+ Nix.Derivation: [drvOutputs] :: Derivation -> [DerivationOutput]
- Nix.Derivation: [drvPlatform] :: Derivation -> !Platform
+ Nix.Derivation: [drvPlatform] :: Derivation -> Platform
- Nix.Derivation: fromATerm :: Text -> Either Text Derivation
+ Nix.Derivation: fromATerm :: ByteString -> Either Text Derivation
- Nix.Derivation: toATerm :: Derivation -> Text
+ Nix.Derivation: toATerm :: Derivation -> ByteString
- Nix.Derivation: toATermForHash :: Bool -> Maybe [(Text, [Text])] -> Derivation -> Text
+ Nix.Derivation: toATermForHash :: Bool -> Maybe [(Text, [Text])] -> Derivation -> ByteString
- Nix.Eval: BuiltinDef :: !Int -> ([NixValue] -> m NixValue) -> BuiltinDef (m :: Type -> Type)
+ Nix.Eval: BuiltinDef :: Int -> ([NixValue] -> m NixValue) -> BuiltinDef (m :: Type -> Type)
- Nix.Eval: CompiledRegex :: !Text -> Regex -> CompiledRegex
+ Nix.Eval: CompiledRegex :: ByteString -> Regex -> CompiledRegex
- Nix.Eval: SCAllOutputs :: !StorePath -> StringContextElement
+ Nix.Eval: SCAllOutputs :: StorePath -> StringContextElement
- Nix.Eval: SCDrvOutput :: !StorePath -> !Text -> StringContextElement
+ Nix.Eval: SCDrvOutput :: StorePath -> Text -> StringContextElement
- Nix.Eval: SCPlain :: !StorePath -> StringContextElement
+ Nix.Eval: SCPlain :: StorePath -> StringContextElement
- Nix.Eval: VAttrs :: !AttrSet -> NixValue
+ Nix.Eval: VAttrs :: AttrSet -> NixValue
- Nix.Eval: VBool :: !Bool -> NixValue
+ Nix.Eval: VBool :: Bool -> NixValue
- Nix.Eval: VBuiltin :: !Text -> ![NixValue] -> NixValue
+ Nix.Eval: VBuiltin :: Text -> [NixValue] -> NixValue
- Nix.Eval: VCompiledRegex :: !CompiledRegex -> NixValue
+ Nix.Eval: VCompiledRegex :: CompiledRegex -> NixValue
- Nix.Eval: VDerivation :: !Derivation -> NixValue
+ Nix.Eval: VDerivation :: Derivation -> NixValue
- Nix.Eval: VFloat :: !Double -> NixValue
+ Nix.Eval: VFloat :: Double -> NixValue
- Nix.Eval: VInt :: !Int64 -> NixValue
+ Nix.Eval: VInt :: Int64 -> NixValue
- Nix.Eval: VLambda :: !Env -> !EvalFormals -> !Word32 -> NixValue
+ Nix.Eval: VLambda :: Env -> EvalFormals -> Word32 -> NixValue
- Nix.Eval: VList :: !CList -> NixValue
+ Nix.Eval: VList :: CList -> NixValue
- Nix.Eval: VPath :: !Text -> NixValue
+ Nix.Eval: VPath :: Text -> NixValue
- Nix.Eval: VStr :: !Text -> !StringContext -> NixValue
+ Nix.Eval: VStr :: ByteString -> StringContext -> NixValue
- Nix.Eval: [bdArity] :: BuiltinDef (m :: Type -> Type) -> !Int
+ Nix.Eval: [bdArity] :: BuiltinDef (m :: Type -> Type) -> Int
- Nix.Eval: copyPathToStore :: MonadEval m => Text -> Text -> m Text
+ Nix.Eval: copyPathToStore :: MonadEval m => Text -> Text -> Maybe (Text, ByteString) -> m Text
- Nix.Eval: recordDrvAterm :: MonadEval m => Text -> Text -> m ()
+ Nix.Eval: recordDrvAterm :: MonadEval m => Text -> ByteString -> m ()
- Nix.Eval: writeToStore :: MonadEval m => Text -> Text -> m Text
+ Nix.Eval: writeToStore :: MonadEval m => Text -> ByteString -> [StorePath] -> m Text
- Nix.Eval.CCtxStr: cctxstrCtxCount :: CCtxStrPtr -> IO Word16
+ Nix.Eval.CCtxStr: cctxstrCtxCount :: CCtxStrPtr -> IO Word32
- Nix.Eval.CCtxStr: cctxstrElemHash :: CCtxStrPtr -> Word16 -> IO Word32
+ Nix.Eval.CCtxStr: cctxstrElemHash :: CCtxStrPtr -> Word32 -> IO Word32
- Nix.Eval.CCtxStr: cctxstrElemName :: CCtxStrPtr -> Word16 -> IO Word32
+ Nix.Eval.CCtxStr: cctxstrElemName :: CCtxStrPtr -> Word32 -> IO Word32
- Nix.Eval.CCtxStr: cctxstrElemOutput :: CCtxStrPtr -> Word16 -> IO Word32
+ Nix.Eval.CCtxStr: cctxstrElemOutput :: CCtxStrPtr -> Word32 -> IO Word32
- Nix.Eval.CCtxStr: cctxstrElemTag :: CCtxStrPtr -> Word16 -> IO Word8
+ Nix.Eval.CCtxStr: cctxstrElemTag :: CCtxStrPtr -> Word32 -> IO Word8
- Nix.Eval.CCtxStr: cctxstrNew :: Word32 -> Word16 -> IO CCtxStrPtr
+ Nix.Eval.CCtxStr: cctxstrNew :: Word32 -> Word32 -> IO CCtxStrPtr
- Nix.Eval.CCtxStr: cctxstrSetAllOutputs :: CCtxStrPtr -> Word16 -> Word32 -> Word32 -> IO ()
+ Nix.Eval.CCtxStr: cctxstrSetAllOutputs :: CCtxStrPtr -> Word32 -> Word32 -> Word32 -> IO ()
- Nix.Eval.CCtxStr: cctxstrSetDrvOutput :: CCtxStrPtr -> Word16 -> Word32 -> Word32 -> Word32 -> IO ()
+ Nix.Eval.CCtxStr: cctxstrSetDrvOutput :: CCtxStrPtr -> Word32 -> Word32 -> Word32 -> Word32 -> IO ()
- Nix.Eval.CCtxStr: cctxstrSetPlain :: CCtxStrPtr -> Word16 -> Word32 -> Word32 -> IO ()
+ Nix.Eval.CCtxStr: cctxstrSetPlain :: CCtxStrPtr -> Word32 -> Word32 -> Word32 -> IO ()
- Nix.Eval.CEnv: cenvAllocWithScopes :: Word16 -> IO (Ptr (Ptr ()))
+ Nix.Eval.CEnv: cenvAllocWithScopes :: Word32 -> IO (Ptr (Ptr ()))
- Nix.Eval.CEnv: cenvNew :: Ptr CThunkPtr -> Word32 -> Ptr () -> Ptr NnEnv -> Ptr (Ptr ()) -> Word16 -> IO (Ptr NnEnv)
+ Nix.Eval.CEnv: cenvNew :: Ptr CThunkPtr -> Word32 -> Ptr () -> Ptr NnEnv -> Ptr (Ptr ()) -> Word32 -> IO (Ptr NnEnv)
- Nix.Eval.CEnv: cenvWithCount :: Ptr NnEnv -> IO Word16
+ Nix.Eval.CEnv: cenvWithCount :: Ptr NnEnv -> IO Word32
- Nix.Eval.CLambda: clambdaEntryDefault :: CLambdaPtr -> Word16 -> IO Word32
+ Nix.Eval.CLambda: clambdaEntryDefault :: CLambdaPtr -> Word32 -> IO Word32
- Nix.Eval.CLambda: clambdaEntryHasDefault :: CLambdaPtr -> Word16 -> IO Word32
+ Nix.Eval.CLambda: clambdaEntryHasDefault :: CLambdaPtr -> Word32 -> IO Word32
- Nix.Eval.CLambda: clambdaEntryName :: CLambdaPtr -> Word16 -> IO Word32
+ Nix.Eval.CLambda: clambdaEntryName :: CLambdaPtr -> Word32 -> IO Word32
- Nix.Eval.CLambda: clambdaFormalCount :: CLambdaPtr -> IO Word16
+ Nix.Eval.CLambda: clambdaFormalCount :: CLambdaPtr -> IO Word32
- Nix.Eval.CLambda: clambdaNew :: Ptr NnEnv -> Word32 -> Word8 -> Word32 -> Word8 -> Word16 -> IO CLambdaPtr
+ Nix.Eval.CLambda: clambdaNew :: Ptr NnEnv -> Word32 -> Word8 -> Word32 -> Word8 -> Word32 -> IO CLambdaPtr
- Nix.Eval.CLambda: clambdaSetEntry :: CLambdaPtr -> Word16 -> Word32 -> Word32 -> Word32 -> IO ()
+ Nix.Eval.CLambda: clambdaSetEntry :: CLambdaPtr -> Word32 -> Word32 -> Word32 -> Word32 -> IO ()
- Nix.Eval.Compile: BcDynamicKey :: !Word32 -> BcAttrKey
+ Nix.Eval.Compile: BcDynamicKey :: Word32 -> BcAttrKey
- Nix.Eval.Compile: BcInherit :: ![Word32] -> BcBinding
+ Nix.Eval.Compile: BcInherit :: [Word32] -> BcBinding
- Nix.Eval.Compile: BcInheritFrom :: !Word32 -> ![Word32] -> BcBinding
+ Nix.Eval.Compile: BcInheritFrom :: Word32 -> [Word32] -> BcBinding
- Nix.Eval.Compile: BcNamed :: ![BcAttrKey] -> !Word32 -> BcBinding
+ Nix.Eval.Compile: BcNamed :: [BcAttrKey] -> Word32 -> BcBinding
- Nix.Eval.Compile: BcStaticKey :: !Word32 -> BcAttrKey
+ Nix.Eval.Compile: BcStaticKey :: Word32 -> BcAttrKey
- Nix.Eval.Compile: decodeBcBindings :: Word16 -> Word32 -> IO [BcBinding]
+ Nix.Eval.Compile: decodeBcBindings :: Int -> Word32 -> IO [BcBinding]
- Nix.Eval.EvalFormals: EFName :: !Text -> EvalFormals
+ Nix.Eval.EvalFormals: EFName :: Text -> EvalFormals
- Nix.Eval.EvalFormals: EFNamedSet :: !Text -> ![EvalFormal] -> !Bool -> EvalFormals
+ Nix.Eval.EvalFormals: EFNamedSet :: Text -> [EvalFormal] -> Bool -> EvalFormals
- Nix.Eval.EvalFormals: EFSet :: ![EvalFormal] -> !Bool -> EvalFormals
+ Nix.Eval.EvalFormals: EFSet :: [EvalFormal] -> Bool -> EvalFormals
- Nix.Eval.EvalFormals: EvalFormal :: !Text -> !Maybe Word32 -> EvalFormal
+ Nix.Eval.EvalFormals: EvalFormal :: Text -> Maybe Word32 -> EvalFormal
- Nix.Eval.EvalFormals: [efDefault] :: EvalFormal -> !Maybe Word32
+ Nix.Eval.EvalFormals: [efDefault] :: EvalFormal -> Maybe Word32
- Nix.Eval.EvalFormals: [efName] :: EvalFormal -> !Text
+ Nix.Eval.EvalFormals: [efName] :: EvalFormal -> Text
- Nix.Eval.IO: EvalState :: !IORef (Map FilePath NixValue) -> !IORef (Map Text Text) -> !IORef (Map Text Text) -> !IORef (Map Text Text) -> !FilePath -> !FilePath -> !Int64 -> ![Thunk] -> EvalState
+ Nix.Eval.IO: EvalState :: IORef (Map FilePath NixValue) -> IORef (Map Text Text) -> IORef (Map Text ByteString) -> IORef (Map Text Text) -> IORef (Map Text ([StorePath], StoreWriteMode)) -> FilePath -> StoreDir -> Int64 -> [Thunk] -> EvalState
- Nix.Eval.IO: NixEvalError :: Text -> NixEvalError
+ Nix.Eval.IO: NixEvalError :: EvalErrorKind -> Text -> NixEvalError
- Nix.Eval.IO: [esBaseDir] :: EvalState -> !FilePath
+ Nix.Eval.IO: [esBaseDir] :: EvalState -> FilePath
- Nix.Eval.IO: [esDrvClosure] :: EvalState -> !IORef (Map Text Text)
+ Nix.Eval.IO: [esDrvClosure] :: EvalState -> IORef (Map Text ByteString)
- Nix.Eval.IO: [esDrvModuloCache] :: EvalState -> !IORef (Map Text Text)
+ Nix.Eval.IO: [esDrvModuloCache] :: EvalState -> IORef (Map Text Text)
- Nix.Eval.IO: [esImportCache] :: EvalState -> !IORef (Map FilePath NixValue)
+ Nix.Eval.IO: [esImportCache] :: EvalState -> IORef (Map FilePath NixValue)
- Nix.Eval.IO: [esSearchPaths] :: EvalState -> ![Thunk]
+ Nix.Eval.IO: [esSearchPaths] :: EvalState -> [Thunk]
- Nix.Eval.IO: [esSourcePathCache] :: EvalState -> !IORef (Map Text Text)
+ Nix.Eval.IO: [esSourcePathCache] :: EvalState -> IORef (Map Text Text)
- Nix.Eval.IO: [esStoreDir] :: EvalState -> !FilePath
+ Nix.Eval.IO: [esStoreDir] :: EvalState -> StoreDir
- Nix.Eval.IO: [esTimestamp] :: EvalState -> !Int64
+ Nix.Eval.IO: [esTimestamp] :: EvalState -> Int64
- Nix.Eval.IO: newEvalState :: FilePath -> IO EvalState
+ Nix.Eval.IO: newEvalState :: StoreDir -> FilePath -> IO EvalState
- Nix.Eval.StringInterp: coerceToString :: MonadEval m => Bool -> Force m -> Apply m -> NixValue -> m (Text, StringContext)
+ Nix.Eval.StringInterp: coerceToString :: MonadEval m => Bool -> Force m -> Apply m -> CoercePath m -> NixValue -> m (ByteString, StringContext)
- Nix.Eval.StringInterp: stripIndentedChunks :: [(Bool, Text, StringContext)] -> (Text, StringContext)
+ Nix.Eval.StringInterp: stripIndentedChunks :: [(Bool, ByteString, StringContext)] -> (ByteString, StringContext)
- Nix.Eval.Types: CompiledRegex :: !Text -> Regex -> CompiledRegex
+ Nix.Eval.Types: CompiledRegex :: ByteString -> Regex -> CompiledRegex
- Nix.Eval.Types: EFName :: !Text -> EvalFormals
+ Nix.Eval.Types: EFName :: Text -> EvalFormals
- Nix.Eval.Types: EFNamedSet :: !Text -> ![EvalFormal] -> !Bool -> EvalFormals
+ Nix.Eval.Types: EFNamedSet :: Text -> [EvalFormal] -> Bool -> EvalFormals
- Nix.Eval.Types: EFSet :: ![EvalFormal] -> !Bool -> EvalFormals
+ Nix.Eval.Types: EFSet :: [EvalFormal] -> Bool -> EvalFormals
- Nix.Eval.Types: EvalFormal :: !Text -> !Maybe Word32 -> EvalFormal
+ Nix.Eval.Types: EvalFormal :: Text -> Maybe Word32 -> EvalFormal
- Nix.Eval.Types: SCAllOutputs :: !StorePath -> StringContextElement
+ Nix.Eval.Types: SCAllOutputs :: StorePath -> StringContextElement
- Nix.Eval.Types: SCDrvOutput :: !StorePath -> !Text -> StringContextElement
+ Nix.Eval.Types: SCDrvOutput :: StorePath -> Text -> StringContextElement
- Nix.Eval.Types: SCPlain :: !StorePath -> StringContextElement
+ Nix.Eval.Types: SCPlain :: StorePath -> StringContextElement
- Nix.Eval.Types: VAttrs :: !AttrSet -> NixValue
+ Nix.Eval.Types: VAttrs :: AttrSet -> NixValue
- Nix.Eval.Types: VBool :: !Bool -> NixValue
+ Nix.Eval.Types: VBool :: Bool -> NixValue
- Nix.Eval.Types: VBuiltin :: !Text -> ![NixValue] -> NixValue
+ Nix.Eval.Types: VBuiltin :: Text -> [NixValue] -> NixValue
- Nix.Eval.Types: VCompiledRegex :: !CompiledRegex -> NixValue
+ Nix.Eval.Types: VCompiledRegex :: CompiledRegex -> NixValue
- Nix.Eval.Types: VDerivation :: !Derivation -> NixValue
+ Nix.Eval.Types: VDerivation :: Derivation -> NixValue
- Nix.Eval.Types: VFloat :: !Double -> NixValue
+ Nix.Eval.Types: VFloat :: Double -> NixValue
- Nix.Eval.Types: VInt :: !Int64 -> NixValue
+ Nix.Eval.Types: VInt :: Int64 -> NixValue
- Nix.Eval.Types: VLambda :: !Env -> !EvalFormals -> !Word32 -> NixValue
+ Nix.Eval.Types: VLambda :: Env -> EvalFormals -> Word32 -> NixValue
- Nix.Eval.Types: VList :: !CList -> NixValue
+ Nix.Eval.Types: VList :: CList -> NixValue
- Nix.Eval.Types: VPath :: !Text -> NixValue
+ Nix.Eval.Types: VPath :: Text -> NixValue
- Nix.Eval.Types: VStr :: !Text -> !StringContext -> NixValue
+ Nix.Eval.Types: VStr :: ByteString -> StringContext -> NixValue
- Nix.Eval.Types: [efDefault] :: EvalFormal -> !Maybe Word32
+ Nix.Eval.Types: [efDefault] :: EvalFormal -> Maybe Word32
- Nix.Eval.Types: [efName] :: EvalFormal -> !Text
+ Nix.Eval.Types: [efName] :: EvalFormal -> Text
- Nix.Eval.Types: copyPathToStore :: MonadEval m => Text -> Text -> m Text
+ Nix.Eval.Types: copyPathToStore :: MonadEval m => Text -> Text -> Maybe (Text, ByteString) -> m Text
- Nix.Eval.Types: envWithScopesRaw :: Env -> (Ptr (Ptr ()), Word16)
+ Nix.Eval.Types: envWithScopesRaw :: Env -> (Ptr (Ptr ()), Word32)
- Nix.Eval.Types: marshalStringContext :: Text -> StringContext -> IO CCtxStrPtr
+ Nix.Eval.Types: marshalStringContext :: ByteString -> StringContext -> IO CCtxStrPtr
- Nix.Eval.Types: newCEnv :: Ptr CThunkPtr -> Int -> Maybe AttrSet -> Maybe Env -> Ptr (Ptr ()) -> Word16 -> Env
+ Nix.Eval.Types: newCEnv :: Ptr CThunkPtr -> Int -> Maybe AttrSet -> Maybe Env -> Ptr (Ptr ()) -> Word32 -> Env
- Nix.Eval.Types: recordDrvAterm :: MonadEval m => Text -> Text -> m ()
+ Nix.Eval.Types: recordDrvAterm :: MonadEval m => Text -> ByteString -> m ()
- Nix.Eval.Types: unmarshalStringContext :: CCtxStrPtr -> IO (Text, StringContext)
+ Nix.Eval.Types: unmarshalStringContext :: CCtxStrPtr -> IO (ByteString, StringContext)
- Nix.Eval.Types: withScopesForCapture :: Env -> (Ptr (Ptr ()), Word16)
+ Nix.Eval.Types: withScopesForCapture :: Env -> (Ptr (Ptr ()), Word32)
- Nix.Eval.Types: writeToStore :: MonadEval m => Text -> Text -> m Text
+ Nix.Eval.Types: writeToStore :: MonadEval m => Text -> ByteString -> [StorePath] -> m Text
- Nix.Expr.Types: Captures :: ![(Int, Int)] -> CaptureInfo
+ Nix.Expr.Types: Captures :: [(Int, Int)] -> CaptureInfo
- Nix.Expr.Types: CapturesWithScopes :: ![(Int, Int)] -> CaptureInfo
+ Nix.Expr.Types: CapturesWithScopes :: [(Int, Int)] -> CaptureInfo
- Nix.Expr.Types: DynamicKey :: !Expr -> AttrKey
+ Nix.Expr.Types: DynamicKey :: Expr -> AttrKey
- Nix.Expr.Types: EApp :: !Expr -> !Expr -> Expr
+ Nix.Expr.Types: EApp :: Expr -> Expr -> Expr
- Nix.Expr.Types: EAssert :: !Expr -> !Expr -> Expr
+ Nix.Expr.Types: EAssert :: Expr -> Expr -> Expr
- Nix.Expr.Types: EAttrs :: !Bool -> ![Binding] -> !CaptureInfo -> Expr
+ Nix.Expr.Types: EAttrs :: Bool -> [Binding] -> CaptureInfo -> Expr
- Nix.Expr.Types: EBinary :: !BinaryOp -> !Expr -> !Expr -> Expr
+ Nix.Expr.Types: EBinary :: BinaryOp -> Expr -> Expr -> Expr
- Nix.Expr.Types: EHasAttr :: !Expr -> !AttrPath -> Expr
+ Nix.Expr.Types: EHasAttr :: Expr -> AttrPath -> Expr
- Nix.Expr.Types: EIf :: !Expr -> !Expr -> !Expr -> Expr
+ Nix.Expr.Types: EIf :: Expr -> Expr -> Expr -> Expr
- Nix.Expr.Types: EIndStr :: ![StringPart] -> Expr
+ Nix.Expr.Types: EIndStr :: [StringPart] -> Expr
- Nix.Expr.Types: ELambda :: !Formals -> !Expr -> !CaptureInfo -> Expr
+ Nix.Expr.Types: ELambda :: Formals -> Expr -> CaptureInfo -> Expr
- Nix.Expr.Types: ELet :: ![Binding] -> !Expr -> !CaptureInfo -> Expr
+ Nix.Expr.Types: ELet :: [Binding] -> Expr -> CaptureInfo -> Expr
- Nix.Expr.Types: EList :: ![Expr] -> Expr
+ Nix.Expr.Types: EList :: [Expr] -> Expr
- Nix.Expr.Types: ELit :: !NixAtom -> Expr
+ Nix.Expr.Types: ELit :: NixAtom -> Expr
- Nix.Expr.Types: EResolvedVar :: !Int -> !Int -> Expr
+ Nix.Expr.Types: EResolvedVar :: Int -> Int -> Expr
- Nix.Expr.Types: ESearchPath :: !Text -> Expr
+ Nix.Expr.Types: ESearchPath :: Text -> Expr
- Nix.Expr.Types: ESelect :: !Expr -> !AttrPath -> !Maybe Expr -> Expr
+ Nix.Expr.Types: ESelect :: Expr -> AttrPath -> Maybe Expr -> Expr
- Nix.Expr.Types: EStr :: ![StringPart] -> Expr
+ Nix.Expr.Types: EStr :: [StringPart] -> Expr
- Nix.Expr.Types: EUnary :: !UnaryOp -> !Expr -> Expr
+ Nix.Expr.Types: EUnary :: UnaryOp -> Expr -> Expr
- Nix.Expr.Types: EVar :: !Text -> Expr
+ Nix.Expr.Types: EVar :: Text -> Expr
- Nix.Expr.Types: EWith :: !Expr -> !Expr -> Expr
+ Nix.Expr.Types: EWith :: Expr -> Expr -> Expr
- Nix.Expr.Types: EWithVar :: !Text -> Expr
+ Nix.Expr.Types: EWithVar :: Text -> Expr
- Nix.Expr.Types: Formal :: !Text -> !Maybe Expr -> Formal
+ Nix.Expr.Types: Formal :: Text -> Maybe Expr -> Formal
- Nix.Expr.Types: FormalName :: !Text -> Formals
+ Nix.Expr.Types: FormalName :: Text -> Formals
- Nix.Expr.Types: FormalNamedSet :: !Text -> ![Formal] -> !Bool -> Formals
+ Nix.Expr.Types: FormalNamedSet :: Text -> [Formal] -> Bool -> Formals
- Nix.Expr.Types: FormalSet :: ![Formal] -> !Bool -> Formals
+ Nix.Expr.Types: FormalSet :: [Formal] -> Bool -> Formals
- Nix.Expr.Types: Inherit :: !Maybe Expr -> ![Text] -> Binding
+ Nix.Expr.Types: Inherit :: Maybe Expr -> [Text] -> Binding
- Nix.Expr.Types: NamedBinding :: !AttrPath -> !Expr -> Binding
+ Nix.Expr.Types: NamedBinding :: AttrPath -> Expr -> Binding
- Nix.Expr.Types: NixBool :: !Bool -> NixAtom
+ Nix.Expr.Types: NixBool :: Bool -> NixAtom
- Nix.Expr.Types: NixFloat :: !Double -> NixAtom
+ Nix.Expr.Types: NixFloat :: Double -> NixAtom
- Nix.Expr.Types: NixInt :: !Int64 -> NixAtom
+ Nix.Expr.Types: NixInt :: Int64 -> NixAtom
- Nix.Expr.Types: NixPath :: !Text -> NixAtom
+ Nix.Expr.Types: NixPath :: Text -> NixAtom
- Nix.Expr.Types: NixUri :: !Text -> NixAtom
+ Nix.Expr.Types: NixUri :: Text -> NixAtom
- Nix.Expr.Types: StaticKey :: !Text -> AttrKey
+ Nix.Expr.Types: StaticKey :: Text -> AttrKey
- Nix.Expr.Types: StrInterp :: !Expr -> StringPart
+ Nix.Expr.Types: StrInterp :: Expr -> StringPart
- Nix.Expr.Types: StrLit :: !Text -> StringPart
+ Nix.Expr.Types: StrLit :: Text -> StringPart
- Nix.Expr.Types: [fDefault] :: Formal -> !Maybe Expr
+ Nix.Expr.Types: [fDefault] :: Formal -> Maybe Expr
- Nix.Expr.Types: [fName] :: Formal -> !Text
+ Nix.Expr.Types: [fName] :: Formal -> Text
- Nix.Hash: makeFixedOutputPath :: Text -> Text -> Text -> ByteString -> StorePath
+ Nix.Hash: makeFixedOutputPath :: Text -> Text -> Text -> ByteString -> Either StorePathNameError StorePath
- Nix.Hash: makeOutputPath :: Text -> ByteString -> Text -> StorePath
+ Nix.Hash: makeOutputPath :: Text -> ByteString -> Text -> Either StorePathNameError StorePath
- Nix.Hash: makeStorePath :: StoreDir -> Text -> ByteString -> Text -> StorePath
+ Nix.Hash: makeStorePath :: StoreDir -> Text -> ByteString -> Text -> Either StorePathNameError StorePath
- Nix.Hash: makeTextPath :: Text -> ByteString -> [StorePath] -> StorePath
+ Nix.Hash: makeTextPath :: Text -> ByteString -> [StorePath] -> Either StorePathNameError StorePath
- Nix.Parser: ParseError :: !Text -> !Int -> !Int -> !Text -> ParseError
+ Nix.Parser: ParseError :: Text -> Int -> Int -> Text -> ParseError
- Nix.Parser: [peCol] :: ParseError -> !Int
+ Nix.Parser: [peCol] :: ParseError -> Int
- Nix.Parser: [peFile] :: ParseError -> !Text
+ Nix.Parser: [peFile] :: ParseError -> Text
- Nix.Parser: [peLine] :: ParseError -> !Int
+ Nix.Parser: [peLine] :: ParseError -> Int
- Nix.Parser: [peMessage] :: ParseError -> !Text
+ Nix.Parser: [peMessage] :: ParseError -> Text
- Nix.Parser: parseNix :: Text -> Text -> Either ParseError Expr
+ Nix.Parser: parseNix :: FilePath -> Text -> Text -> Either ParseError Expr
- Nix.Parser.Internal: ParseState :: ![Located] -> !Text -> ParseState
+ Nix.Parser.Internal: ParseState :: [Located] -> Text -> ParseState
- Nix.Parser.Internal: [psFile] :: ParseState -> !Text
+ Nix.Parser.Internal: [psFile] :: ParseState -> Text
- Nix.Parser.Internal: [psTokens] :: ParseState -> ![Located]
+ Nix.Parser.Internal: [psTokens] :: ParseState -> [Located]
- Nix.Parser.Lexer: Located :: !Int -> !Int -> !Token -> Located
+ Nix.Parser.Lexer: Located :: Int -> Int -> Token -> Located
- Nix.Parser.Lexer: TokFloat :: !Double -> Token
+ Nix.Parser.Lexer: TokFloat :: Double -> Token
- Nix.Parser.Lexer: TokIdent :: !Text -> Token
+ Nix.Parser.Lexer: TokIdent :: Text -> Token
- Nix.Parser.Lexer: TokInt :: !Int64 -> Token
+ Nix.Parser.Lexer: TokInt :: Int64 -> Token
- Nix.Parser.Lexer: TokPath :: !Text -> Token
+ Nix.Parser.Lexer: TokPath :: Text -> Token
- Nix.Parser.Lexer: TokSearchPath :: !Text -> Token
+ Nix.Parser.Lexer: TokSearchPath :: Text -> Token
- Nix.Parser.Lexer: TokStringLit :: !Text -> Token
+ Nix.Parser.Lexer: TokStringLit :: Text -> Token
- Nix.Parser.Lexer: TokUri :: !Text -> Token
+ Nix.Parser.Lexer: TokUri :: Text -> Token
- Nix.Parser.Lexer: [locCol] :: Located -> !Int
+ Nix.Parser.Lexer: [locCol] :: Located -> Int
- Nix.Parser.Lexer: [locLine] :: Located -> !Int
+ Nix.Parser.Lexer: [locLine] :: Located -> Int
- Nix.Parser.Lexer: [locToken] :: Located -> !Token
+ Nix.Parser.Lexer: [locToken] :: Located -> Token
- Nix.Parser.ParseError: ParseError :: !Text -> !Int -> !Int -> !Text -> ParseError
+ Nix.Parser.ParseError: ParseError :: Text -> Int -> Int -> Text -> ParseError
- Nix.Parser.ParseError: [peCol] :: ParseError -> !Int
+ Nix.Parser.ParseError: [peCol] :: ParseError -> Int
- Nix.Parser.ParseError: [peFile] :: ParseError -> !Text
+ Nix.Parser.ParseError: [peFile] :: ParseError -> Text
- Nix.Parser.ParseError: [peLine] :: ParseError -> !Int
+ Nix.Parser.ParseError: [peLine] :: ParseError -> Int
- Nix.Parser.ParseError: [peMessage] :: ParseError -> !Text
+ Nix.Parser.ParseError: [peMessage] :: ParseError -> Text
- Nix.Push: PushConfig :: !Text -> !Maybe Text -> PushConfig
+ Nix.Push: PushConfig :: Text -> Maybe Text -> PushCompression -> PushConfig
- Nix.Push: PushSummary :: !Int -> !Int -> PushSummary
+ Nix.Push: PushSummary :: Int -> Int -> PushSummary
- Nix.Push: [pcApiKey] :: PushConfig -> !Maybe Text
+ Nix.Push: [pcApiKey] :: PushConfig -> Maybe Text
- Nix.Push: [pcCacheUrl] :: PushConfig -> !Text
+ Nix.Push: [pcCacheUrl] :: PushConfig -> Text
- Nix.Push: [psPushed] :: PushSummary -> !Int
+ Nix.Push: [psPushed] :: PushSummary -> Int
- Nix.Push: [psSkipped] :: PushSummary -> !Int
+ Nix.Push: [psSkipped] :: PushSummary -> Int
- Nix.Push: mkNarInfo :: StorePath -> Text -> Int -> [StorePath] -> Maybe StorePath -> NarInfo
+ Nix.Push: mkNarInfo :: PushArtifact -> StorePath -> [StorePath] -> Maybe StorePath -> NarInfo
- Nix.Store: Store :: !StoreDir -> !StoreDB -> Store
+ Nix.Store: Store :: StoreDir -> StoreDB -> Store
- Nix.Store: [stDB] :: Store -> !StoreDB
+ Nix.Store: [stDB] :: Store -> StoreDB
- Nix.Store: [stDir] :: Store -> !StoreDir
+ Nix.Store: [stDir] :: Store -> StoreDir
- Nix.Store: scanReferences :: StoreDir -> [StorePath] -> FilePath -> IO [StorePath]
+ Nix.Store: scanReferences :: [StorePath] -> FilePath -> IO [StorePath]
- Nix.Store: writeDrvAterm :: Store -> StorePath -> Text -> IO ()
+ Nix.Store: writeDrvAterm :: Store -> StorePath -> ByteString -> IO ()
- Nix.Store.DB: PathInfo :: !Text -> !Text -> !Int -> !Maybe Text -> !Int -> PathInfo
+ Nix.Store.DB: PathInfo :: Text -> Text -> Int -> Maybe Text -> Int -> PathInfo
- Nix.Store.DB: PathRegistration :: !StorePath -> !Text -> !Int -> !Maybe Text -> ![StorePath] -> PathRegistration
+ Nix.Store.DB: PathRegistration :: StorePath -> Text -> Int -> Maybe Text -> [StorePath] -> PathRegistration
- Nix.Store.DB: [piDeriver] :: PathInfo -> !Maybe Text
+ Nix.Store.DB: [piDeriver] :: PathInfo -> Maybe Text
- Nix.Store.DB: [piNarHash] :: PathInfo -> !Text
+ Nix.Store.DB: [piNarHash] :: PathInfo -> Text
- Nix.Store.DB: [piNarSize] :: PathInfo -> !Int
+ Nix.Store.DB: [piNarSize] :: PathInfo -> Int
- Nix.Store.DB: [piPath] :: PathInfo -> !Text
+ Nix.Store.DB: [piPath] :: PathInfo -> Text
- Nix.Store.DB: [piRegTime] :: PathInfo -> !Int
+ Nix.Store.DB: [piRegTime] :: PathInfo -> Int
- Nix.Store.DB: [prDeriver] :: PathRegistration -> !Maybe Text
+ Nix.Store.DB: [prDeriver] :: PathRegistration -> Maybe Text
- Nix.Store.DB: [prNarHash] :: PathRegistration -> !Text
+ Nix.Store.DB: [prNarHash] :: PathRegistration -> Text
- Nix.Store.DB: [prNarSize] :: PathRegistration -> !Int
+ Nix.Store.DB: [prNarSize] :: PathRegistration -> Int
- Nix.Store.DB: [prPath] :: PathRegistration -> !StorePath
+ Nix.Store.DB: [prPath] :: PathRegistration -> StorePath
- Nix.Store.DB: [prReferences] :: PathRegistration -> ![StorePath]
+ Nix.Store.DB: [prReferences] :: PathRegistration -> [StorePath]
- Nix.Substituter: CacheConfig :: !Text -> !Text -> !Int -> CacheConfig
+ Nix.Substituter: CacheConfig :: Text -> [Text] -> Int -> CacheConfig
- Nix.Substituter: SubstError :: !Text -> SubstResult
+ Nix.Substituter: SubstError :: Text -> SubstResult
- Nix.Substituter: SubstSuccess :: !StorePath -> SubstResult
+ Nix.Substituter: SubstSuccess :: PathRegistration -> PathLock -> SubstResult
- Nix.Substituter: [ccPriority] :: CacheConfig -> !Int
+ Nix.Substituter: [ccPriority] :: CacheConfig -> Int
- Nix.Substituter: [ccUrl] :: CacheConfig -> !Text
+ Nix.Substituter: [ccUrl] :: CacheConfig -> Text
- Nix.Substituter: decompressNar :: Text -> ByteString -> Either Text ByteString
+ Nix.Substituter: decompressNar :: Integer -> Text -> ByteString -> IO (Either Text ByteString)
- Nix.Substituter: parseReferences :: StoreDir -> [Text] -> [StorePath]
+ Nix.Substituter: parseReferences :: [Text] -> Either Text [StorePath]
- Nix.Substituter: verifyNarHash :: NarInfo -> ByteString -> Either Text ()
+ Nix.Substituter: verifyNarHash :: NarInfo -> ByteString -> Either Text NixHash

Files

CHANGELOG.md view
@@ -1,5 +1,107 @@ # Changelog +## 0.8.0.0 - 2026-10-08++0.7.0.0 was published on GitHub but not uploaded to Hackage, so on Hackage this release follows 0.6.0.0. Library users upgrading from 0.6.0.0 should read the 0.7.0.0 entries below as well.++### Library API++Breaking changes for code that uses nova-nix as a library:++- **`Nix.Eval.Types.MonadEval` has five new methods with no defaults:** `adoptStorePath`, `lookupFetchCache`, `onEvalError`, `setExecutableFile` and `writeFetchCache`. Every instance defined outside this package needs them.+- **`Nix.Builder.BuildConfig` has a new strict field,** `bcExecWrappers :: Map Text FilePath`.+- **`BuildConfig` drops `bcBashPath` and `bcSandbox`, which nothing read.** Both were defined and defaulted, and no code consulted either one: the Windows stdenv takes bash from the MSYS2 seed, and no platform implements build sandboxing. A field that reads like a switch while switching nothing suggests isolation that does not exist, so it goes until there is a mechanism for it to gate (#25). Library code that set either field stops compiling and loses no behavior. (#209)+- **`Nix.Substituter.CacheConfig` replaces `ccPublicKey :: Text` with `ccPublicKeys :: [Text]`,** since a narinfo is now accepted when any trusted key verifies it.+- **The store-write cache records how each path was written.** `Nix.Eval.IO.EvalState`'s `esStoreWriteCache` maps each key to `([StorePath], StoreWriteMode)`, and `Nix.Store.materializeEvalStoreWrites` takes the same shape.+- **`Nix.Eval.StringInterp.coerceToString` takes a `CoercePath m` argument** that decides how a path is coerced at each call site.+- **`Nix.Expr.Types.Expr` has a new constructor, `EPathStr`, and `Nix.Parser.Lexer.Token` has `TokPathInterpStart`, `TokPathLit` and `TokPathEnd`,** so exhaustive matches over either need new cases.++### CLI++- **`build` selects an attribute, or takes an expression, not only a file.** `nova-nix build FILE.nix -A a.b.c` follows a dotted attribute path into the file's value, and `nova-nix build --expr 'EXPR'` builds with no file at all. A build previously took one file whose value had to be a derivation itself, so a package set could only be built by carrying a pointer file per package, which is the arrangement the nixpkgs by-name layout exists to remove. A component may be double-quoted to carry a literal dot (`-A 'foo."bar.baz"'`), matching upstream's tokenizer including the behaviours that read as accidents: quotes concatenate rather than delimit, so `foo"bar"` is the single name `foobar`, and a trailing dot is dropped rather than becoming an empty component. Three upstream behaviours are deliberately absent. A numeric component indexes a list upstream and is an ordinary attribute name here, because nothing this selects over is a list and upstream's integer-or-name test defers to a C++ numeric parse whose accepted spellings have not been checked against a running Nix. A near-miss attribute name gets no `Did you mean` line, which upstream renders from a Levenshtein search this evaluator has no suggestion channel to carry; the message itself is upstream's, only without that second line. Every selection message matched upstream byte for byte when diffed against `nix-instantiate` 2.33.2. And `-A` is not repeatable: upstream accumulates a derivation per occurrence, while a build here realizes one, so a second `-A` is an error rather than a silent last-one-wins. (#174)+- **`--exec-wrapper SYSTEM=PATH` builds a derivation whose system this machine cannot execute, through a named launcher.** `--exec-wrapper x86_64-windows=/path/to/wine` builds an `x86_64-windows` derivation on Linux through Wine. `SYSTEM` is the derivation's own `system` string, spelled exactly as a `.drv` spells it. The launcher is prepended to the builder at the spawn boundary and never enters the derivation, so the store paths a wine-hosted build produces are the same ones a Windows host produces. A derivation for the host's own platform is spawned directly whatever is configured; a derivation for any other system with no launcher named for it is refused before the builder runs, rather than spawned natively and left to fail in the loader. The option is accepted before or after `build`'s target, and repeated options accumulate across both positions with the same duplicate-system and launcher checks. (#171, #201)+- **`eval` accepts `--store` after the subcommand.** The eval sub-parser rejected `--store` where `build`, `push` and `store delete` accepted it, even though eval-side reads follow the selected store identically. (#193)+- **Substituters and trusted public keys can be configured in nix.conf and NIX_CONFIG, not only as flags.** A machine can now say once, in `nix/nix.conf` under `$XDG_CONFIG_HOME` (by default `~/.config`, or `%APPDATA%` on Windows) or in the NIX_CONFIG environment variable, which binary caches to substitute from and which public keys to trust, instead of repeating `--substituter` and `--trusted-key` on every invocation. The format matches upstream's: name = value lines, a # comment truncates the rest of the line, list values split on whitespace, the binary-caches and binary-cache-public-keys aliases are honored, and an extra- prefix appends to a list rather than replacing it. Sources are resolved in upstream's precedence order, the user file, then NIX_CONFIG, then the command line, with the command line winning; a plain assignment in a higher source replaces the accumulated value and an extra- widens it, which is the load-bearing rule for trusted-public-keys since a mishandled precedence would silently widen what the machine trusts. The trusted-key set is now flat as upstream models it: a narinfo is accepted when its signature verifies under any trusted key, and a malformed key anywhere in the set is a loud error rather than a silently skipped one. The include/!include directives and the /etc/nix and XDG_CONFIG_DIRS sources are tracked separately in #199. (#200)++### Evaluation++- **Path literals accept string interpolation (`./${v}/x`), which the nixpkgs 26.05 tree uses 89 times across 54 files.** The lexer had no representation for an interpolated path, so both ./${v}/x and /tmp/${v}/y were parse errors. The lexer now carries a path mode alongside the string modes, mirroring upstream's INPATH machinery: the head piece opens the literal, chunks and interpolations follow, and a synthesized end token closes at the first non-path character. Every semantic rule is pinned to nix-instantiate 2.33.2's observed behavior: the result is a path; pieces concatenate with no separator (mid-segment and back-to-back interpolation included) and the whole canonicalizes textually, so a dot-dot arriving at runtime collapses; a path segment coerces without a store copy, unlike string interpolation, and a string carrying store-path context is refused ("cannot be appended to a path"); and a trailing slash is a parse error ("path has a trailing slash"), now for plain path literals too, matching upstream's grammar. A search path followed by /${v} stays two expressions, as upstream parses it. The head piece is absolutized in the same pre-eval pass as plain literals, so closure capture across import keeps working. (#189)+- **An attrset coerced through outPath now copies to the store and carries context, matching upstream byte for byte.** The coercion engine handled paths with a fixed context-free case, so a path reached through an attrset's outPath (or a __toString result) rendered as raw filesystem text with no store copy and no string context, wherever the attrset stood in for the path: src = builtins.fetchGit { ... } produced empty inputSrcs and a drvPath diverging from real Nix 2.33.2 while the .outPath spelling worked, a user { outPath = ./dir; } leaked the raw path into the build environment, and the same drop reached interpolation, the + operator, and builtins.toJSON. The path behavior is now a parameter the attrset recursion carries, so every coercion site states its mode: interpolation, derivation fields, +, and toJSON copy to the store; builtins.toString stays verbatim. Verified against nix-instantiate 2.33.2: the attrset form's drvPath now matches upstream exactly. (#187)+- **A syntax error inside a lambda is reported where it happened, not as an attr-set complaint at the top of the file.** The parser decided lambda-versus-attr-set (and lambda-versus-expression) by backtracking, and a failed try discarded its error entirely, so a real failure at line 410 of a nixpkgs file surfaced as "expected '=' but got ','" at line 2, column 6: the valid comma in the file's opening formals. Two changes close it. A parsed lambda header now commits: "ident :", "ident @", and "{ formals } :" never begin anything else, so a body failure propagates from its own position instead of rewinding, which also covers the case where the other reading succeeds on a prefix (the empty attr set in "{ }: body") and the old parser complained at the stray colon. And when no reading parses at all, the error from the branch that got furthest into the input is the one reported, with end-of-input ranking past every positioned failure; upstream's LR parser never backtracks and always errors at the true site, and this is the closest a backtracking parser comes. (#181)+- **`builtins.fetchGit` remembers a pinned revision.** A fetch of a pinned `rev` is now recorded under the user's cache directory and reused while the tree it names is still in the store, since re-cloning it on every evaluation is pure waste and fatal whenever the far end is down. A bare `ref` is never cached, since it means "whatever this branch points at now". Everything that decides what comes back is in the key, `shallow` included: it does not change the tree, but it does change `revCount`, which can reach a derivation's environment. A hit re-records the store write the fetch would have made, so the path it hands back is registered before any derivation naming it is built. (#172)+- **`builtins.fetchGit` with `shallow = true` reports revCount 0 and can check out a pinned rev.** A shallow fetch counted the truncated history, so revCount was always 1 regardless of the real depth, a wrong number that can reach a derivation's environment. Upstream reports 0 under shallow (its fetcher skips computing the attribute and evaluation fills the default; observed from nix-instantiate 2.33.2, whose shallow result keeps all eight attributes with revCount = 0), and nova-nix now records and reports the same 0 without running rev-list at all. A pinned rev also becomes the fetch refspec itself, matching upstream's construction: a depth-1 fetch of a branch tip cannot contain any other revision, so asking the remote for the SHA is what lets shallow and rev compose, and what finds a rev not on the fetched ref. A server may refuse a SHA it does not advertise; that failure surfaces as git's own fetch error, the same surface upstream has. (#184)+- **`builtins.fetchGit` honors allRefs, verifies a declared narHash, refuses unsupported attributes, and cleans up after a failed fetch.** allRefs = true fetches every remote ref (upstream's refs/*:refs/* refspec), the caller's way to a pinned rev the remote refuses as a direct SHA want; the scratch clone has a worktree, unlike upstream's bare cache repo, so the fetch passes --update-head-ok, which is safe because an explicit checkout always follows. A declared narHash is verified against the fetched tree on the cache-hit and fresh paths alike, and a mismatch is an error in upstream's orientation (expected is what the fetch produced, got is the declared pin), never a silent recompute; upstream matched from nix-instantiate 2.33.2. An attribute outside the supported set (url, name, ref, rev, submodules, shallow, allRefs, narHash) is refused instead of silently dropped, which is exactly how the unhonored pins stayed invisible. A failing fetch no longer leaks its scratch clone: every throwing step runs under a new onEvalError cleanup primitive, the error half of a bracket. (#188)+- **`builtins.placeholder` is substituted at build time.** `builtins.placeholder "out"` evaluates to a sentinel, since an input-addressed derivation's output path is a hash of the derivation itself and the expression cannot name a path it hasn't produced yet. The sentinel now gets replaced in a derivation's `args` and in its environment, names as well as values, at the spawn boundary, with the same per-output path `$out` already carries, so argument-passing and environment-passing builders agree on where to write. Upstream rewrites the whole `name=value` string rather than the value alone, so a placeholder in a dynamic attribute name is substituted too. This is what lets a builder that takes its destination as an argument rather than reading the environment (stage0's `hex0 input output`) be driven directly. The builder path itself is left alone: a placeholder there would name a program that does not exist yet. (#126)++### Store++- **Eval-time store writes are verified at both ends: writers rewrite a mismatched leftover, and registration refuses content that does not reproduce its path.** Every eval-side writer skipped its write when the destination existed (and the fetchurl writer wrote unconditionally, crashing on a sealed leftover), so a file truncated by an interrupted earlier run was adopted as-is, registered valid under the hash of the content it should have had, sealed read-only so nothing could repair it, and then consumed by builds. Writers now adopt an existing destination only when its bytes are the intended bytes (raw comparison for toFile and fetchurl, recursive NAR digest for source copies and builtins.path) and otherwise clear and rewrite, which is where the bytes exist to rewrite with, matching upstream's delete-then-rewrite in addTextToStore. Registration independently re-derives each recorded path from its on-disk content under the scheme that named it (the write cache now carries text, recursive, or flat alongside the references) and refuses loudly on a mismatch rather than sealing a hash the bytes do not have. (#182)+- **The eval-side materializers join the per-path lock protocol.** materializeEvalSources ran its check-then-act with no lock: two processes materializing the same source path raced the validity check, and the loser's clear-and-recopy deleted the tree the winner had just registered. Both materializers now run each path's adopt or prepare under the same cross-process path lock the builder and substituter hold, with validity re-checked once the lock is held, so a peer mid-producing a path is waited out rather than raced. The store-write materializer's batched registration stays outside the lock deliberately: registration is an upsert over content both holders verified reproduces the same path. (#192)+- **A concurrent invocation waits for the store database instead of dying on ErrorBusy.** SQLite's default busy timeout is zero, so while one process held a write transaction (a registration mid-commit), a second nova-nix crashed in under a tenth of a second with an uncaught SQLError instead of waiting its turn, which at build volume means any concurrent pair of builds or substitutions could kill one side. The connection now sets the one-hour busy timeout upstream configures at open (sqlite3_busy_timeout, sqlite.cc at 2.28.7; the pragma reaches the same API), so concurrent invocations queue on the database exactly as upstream's do. (#180)+- **The executable bit survives on Windows.** A NAR records whether each regular file is executable, and a store path's identity is the hash of that. Windows has no such bit: `getPermissions` answers from the file's extension there, so a checked-out `configure` serialised as non-executable and a tracked `foo.exe` as executable, whatever either one was, and the substituter's unpacked tree then failed to reproduce the declared hash, so any cached path holding a mis-extensioned executable was downloaded, rejected, and deleted. The bit now lives in an NTFS alternate data stream whose presence is the bit. `builtins.fetchGit` sets the marks from git's index before `.git` is stripped. On Unix nothing changes. (#128)+- **Windows NAR hashing streams instead of materializing the tree in memory.** The executable bit lives in an alternate data stream that nova-cache's own walk could not see, so on Windows narHashOfPath serialised the whole tree in memory and rewrote the flags afterwards. nova-cache 0.11.1 added the exec-bit resolver hook for exactly this: the walk now asks the ADS source of truth per regular file, with the on-disk case-hack-suffixed name, so hashing streams in chunks on every platform and the post-hoc flag-rewrite walk is gone. (#191)++### Builder++- **A build writes into its output path, not into a temp directory it is moved out of.** `$out` is now the final store path, as upstream's is. Building somewhere else and moving afterwards looks equivalent and is not: anything that records where it was built (a compiler driver, a libtool archive) recorded the temp path, which is deleted the moment the build ends, leaving a store path that isn't self-contained and that the reference scanner can note but not repair. A stale tree from an interrupted run is cleared before the builder starts rather than at registration, and a failed build takes its partial output back out of the store, an interrupted one included. (#130)+- **A partial rebuild no longer hands the builder an output that is already valid.** A multi-output derivation with one output deleted still runs one builder, which writes every output; a registered path's `NarHash` describes bytes that must not change, and it is sealed read-only besides. Such an output gets a scratch path for the duration of the build, discarded afterwards, exactly as upstream's `makeFallbackPath` does. Where upstream then rewrites the scratch path back out of the produced outputs' contents, this refuses instead: an output that recorded a scratch path fails the build and names it. That divergence is deliberate, because a dangling reference becomes a loud failure rather than a registered lie, and it is the only part of upstream's hash-rewriting machinery not reproduced here. (#130)+- **Builders run in a scrubbed environment: the ambient environment no longer flows into builds.** The child process previously inherited every host variable not overridden by the build environment, so a builder consulting an undeclared variable embedded machine-specific data in its output, undermining the reproducibility SOURCE_DATE_EPOCH exists to pin. Upstream builds in a cleared environment (initEnv begins with env.clear() and the child is exec'd with exactly that block), and nova-nix now matches: on Unix nothing ambient passes through, and on Windows, where a process block cannot be empty, exactly SystemRoot, SystemDrive, and windir pass through (SystemRoot is a hard execution requirement, verified on a clean Windows 11 machine, where the CLR's crypto provider fails to load without it), COMSPEC is synthesized from SystemRoot, and PATHEXT is pinned to .COM;.EXE;.BAT;.CMD instead of inheriting the host's resolution rules. All four temp-directory names (TMPDIR, TEMPDIR, TMP, TEMP) now point at the build directory, matching upstream's single assignment of all four; scrubbing without repointing them would send Windows temp files to the Windows directory itself. (#186)+- **A fixed-output derivation's impureEnvVars reach the scrubbed build environment.** Upstream's carve-out for fetchers that need proxies: the variables the derivation's impureEnvVars attribute lists are copied from the ambient environment into the child, gated on the derivation being fixed-output (upstream gates on the type being non-sandboxed, which for nova-nix is exactly the fixed-output case). A listed variable absent from the ambient environment is set to the empty string, not omitted, and the carve-out is written last so it wins even over the derivation's own values, both matching upstream's initEnv. Every other derivation still sees only the scrubbed allowlist. (#190)+- **On Windows a build's process tree runs in a Win32 job object, so an interrupt or a builder exit no longer orphans grandchildren.** The builder was spawned without process-tree containment, so terminateProcess reached only the direct child: on Ctrl-C or a nonzero builder exit, a builder's whole grandchild tree (bash to make to cc, or anything cmd.exe spawned) was orphaned and kept running. The build spawn now sets use_process_jobs, wrapping the tree in a job with kill-on-job-close, so terminateProcess becomes TerminateJobObject and reaps the whole tree and waitForProcess waits for all of it. This is Windows only; POSIX builds are unchanged, and there is still no build timeout. It is phase one of build sandboxing (process containment); filesystem and privilege isolation and resource limits remain tracked in #194 and #195. (#196)++### Substitution++- **Substituting a store path whose symlinks have multi-component targets now works on Windows.** A symlink target like bin/tool was stored by Windows as a reparse point with backslashes, so re-serialising the unpacked tree read it back as bin\tool, the NAR hash no longer matched the signed narinfo, and the on-disk recheck failed the substitution systematically (a hard failure, never silent corruption). nova-cache 0.11.1.1 normalises a symlink target's separators to the POSIX spelling at the NAR boundary on Windows. (#198)++### Windows packages++- **`pkgs/windows` follows the nixpkgs layout, and the package set is a fixpoint.** Packages moved to `by-name/<shard>/<pname>/package.nix` (shard = the pname's first two characters), the stdenv machinery to `stdenv/`, and the sha256-pinned MSYS2 seeds to `bootstrap/`. `pkgs/windows/lib.nix` adds `callPackageWith`, modelled on nixpkgs' `lib.customisation.callPackageWith`, and `default.nix` discovers `by-name` with `builtins.readDir` and calls each `package.nix` through it. A package now declares its dependencies as formal parameters (`{ stdenv, zlib }: ...`) and callPackage supplies them from the set, instead of each recipe reaching across the tree with a relative import; adding a package is adding a directory. The walk reads the entry type `readDir` returns, so a stray file in `by-name` is passed over rather than opened as a directory. No recipe changed beyond its dependency plumbing, but every one of them moved, so all six drvPaths change and nothing already built is reused. `pkgs/windows/hello.nix` now builds the by-name GNU Hello through the stdenv, so its executable is at `bin\hello.exe`, where `make install` puts it, rather than at the output root. (#127)+- **Windows source fetches retry ordered mirrors and verify every download.** `pkgs/windows/fetchurl.nix` accepts a non-empty `urls` list for flat fixed-output downloads. HTTP errors, incomplete responses, and hash mismatches advance to the next URL; only verified bytes enter the store. Cancellation stops the build and releases its output lock without trying another mirror. Hello, sed, and zlib use multiple pinned source locations. Their source and package output paths stay unchanged, while their derivation paths change to describe the new inputs. The bundled upstream `fetchurl.nix` remains unchanged, and single-URL calls retain its interface. (#203)++### Dependencies++- **nova-cache moves to `>= 0.11.1.1 && < 0.12`, with the xz binding pinned to its bundled sources.** The 0.11.1 releases carry four things this project consumes. The per-file executable-bit resolver (`SerialiseOptions`, `withNarSourceOpts`) is the seam the Windows alternate-data-stream executable model plugs into, streaming verifier included. The POSIX default executable answer now reads the file's own owner-execute bit as upstream's dump does, where it previously asked `access(2)` what the calling process could do, so NAR bytes stop depending on who serialised (root, ACLs, and foreign-owned files were the divergence). A symlink target's separators are normalised on Windows. And the xz codec's binding moved off the Hackage-deprecated `lzma-static` onto its successor `xz`, which prefers a pkg-config-found system liblzma; the new `constraints: xz -system-xz` line pins the bundled sources so every build links the same C regardless of the host. (#177, #191, #198)++## 0.7.0.0 - 2026-08-22++- **A copied nova-nix finds its own bundled expressions.** The `<nix/*>` search path came from Cabal's `getDataDir`, which bakes an absolute path in at configure time naming the machine that did the build. That is right for a local `cabal install` and wrong for every copied or downloaded binary: `import <nix/fetchurl.nix>` resolved to a directory that does not exist on the host running it, and the Windows package recipes all open with that line. The data dir now comes from `NIX_DATA_DIR` when set (upstream's own variable name), otherwise from `share/nova-nix` beside the executable's own `bin` directory, which is the layout a release archive ships and needs nothing configured, and otherwise from Cabal as before, so a local install is unchanged. The build matrix now checks both fallbacks against a relocated copy with Cabal's own override pointed at nothing, so a pass cannot come from the baked path.+- **Tags publish downloadable binaries, not only a Hackage sdist.** Seventeen tags had produced zero GitHub releases, because the publish workflow only ever uploaded an sdist, so the only way to get nova-nix was to build it. Each tag now also attaches a per-platform archive for Linux, macOS and Windows, carrying `bin/` beside `share/` and `pkgs/`, so an unpacked copy resolves `<nix/*>` and builds with no toolchain and nothing to configure, plus a `SHA256SUMS` file generated over the exact uploaded bytes. The recipes ride along because they are what there is to build: every path in `pkgs/` is either relative to itself or the `<nix/*>` entry `share/` already provides, so the command the README opens with runs against the unpacked copy rather than needing the repository cloned beside it. Every archive is smoke-tested before it is attached, and the release is created as a draft and only published once all four assets are present, since `releases/latest/download` is a public URL that must not exist while empty. (#12)++  Two gaps the first cut left open. The smoke test ran on the machine that built the binary, where a full GHC toolchain sits on `PATH`, so it could not tell a self-contained executable from one quietly finding its DLLs next door; the Windows archive is now run again with `PATH` cut to the system directories, which is the download's actual situation, so a dependency that starts linking zlib, zstd or libgcc dynamically fails the release instead of the first person to unpack the zip. And the Hackage upload waited only on the checks, not on the archives, so a tag whose binaries failed to build would still have taken the one step of a release that cannot be undone: a Hackage version is immutable. It waits on the packaging job now.+- **`nova-nix --version` and `--help` answer, and `--help` answers on stdout.** A downloaded binary could not say what it was. The CLI had no version handling at all, so a bug report from a machine with no toolchain had no way to name the build it came from, which is most of what a release exists to enable. `--help` did not work either, failing with `unknown argument: --help`: usage came only from a bare invocation, on stderr, exiting non-zero. Both are flags now, answered before the rest of the line is parsed, so `--version` reports the build even when the command after it is one this build does not have. The two spellings of usage keep their difference for the reason they have one: a bare invocation is a usage error and stays on stderr with a non-zero status, while `--help` is a request that succeeded and goes to stdout exiting zero, so it pipes without redirecting stderr. The version is Cabal's, the one the publish workflow's tag guard already checks a tag against, and the archive smoke test now asserts the binary agrees with it before the release is attached: the guard reads a source tree, this reads the executable actually built from it.+- **`--store` is honored on evaluation's reads, not only its writes.** The store directory reached the five eval-time write sites and none of the eleven read sites, which resolved through the platform default, so a redirected store was written to and then read from somewhere else: `builtins.readFile (builtins.toFile "a" "hi")` wrote the file into the chosen store and then failed to find it under `/nix/store`. `builtins.path`, `filterSource`, `fetchGit` followed by `import`, and every other eval-time read had the same split. Before the store directory reached evaluation at all both sides ignored the flag and were at least consistent with each other, so this was newly broken rather than long-standing. The resolver takes the store directory it should read from instead of assuming the platform one, and evaluation passes the store it was given.++  No test caught it because the integration harness built its evaluator with the platform store while handing the build a different one, so the two halves were never asked to agree. The harness now uses one store for both. (#145)+- **Every store object evaluation writes is registered, not only `builtins.toFile`'s.** `writeToStore` recorded its writes for the build driver to register; `copyPathToStore`, `addSourceNar` and `addFixedOutputFile` recorded nothing, so a path from `builtins.path`, `filterSource`, `builtins.fetchurl`, or `builtins.fetchGit` reached `drvInputSrcs` with no registration row and the build died with `registerPaths: ... references unregistered path`. That made `builtins.fetchGit`'s `outPath` unusable as a derivation input, which is the reason it computes one. Recording now happens in one place every eval-time writer goes through, rather than being repeated per builtin and forgotten in three of four.+- **The release workflow's artifact actions match the rest of CI.** `publish.yml` pinned `actions/upload-artifact@v4` and `actions/download-artifact@v4` while `ci.yml` was already on v7, so the workflow that produces every release asset was two and four majors behind the one that builds them. Both move to the current majors (upload v7, download v8), whose inputs the workflow already uses.+- **CI lints its own workflows.** Haskell, C and source encoding were all covered while `.github/workflows/` itself was validated only by GitHub agreeing to parse it after a push. `actionlint` now checks `uses:` references, `${{ }}` expressions, matrix references and job dependencies, and runs shellcheck over every `run:` block, pinned to a version rather than floating so a lint release cannot turn an unrelated pull request red.+- **A build holds its outputs' path locks, so two builds of one derivation cannot interleave.** Per-path locks made delete, materialize and register a single critical section, and the build path never joined it, while `computeBuildDir` hands both processes the same build directory. Two concurrent builds of one derivation therefore wrote into one tree and registered the result as valid: reproduced with a builder appending a line per second, where the registered output held eight interleaved lines instead of six and nothing reported an error. A build now takes every output's lock before it starts and holds them until registration commits, and re-checks validity once they are granted so a derivation another process finished while we waited is adopted rather than rebuilt over. Outputs are locked in store-path order so two processes racing a multi-output derivation request them in the same sequence, and a build holds locks for its own outputs only, so there is no cycle to deadlock on.+- **`builtins.fetchGit` validates `ref` and `rev` before they reach git, closing an eval-time command execution.** Both attributes were spliced straight into git's argument vector with no separator and no shape check, and git parses options after the remote name, so a `ref` of `--upload-pack=<cmd>` named the command git runs as the transport. With a local or `file://` url (both accepted, and the local transport is enabled in the clone's own config) git ran that command through a shell, so evaluating an untrusted expression executed it and the evaluation still succeeded with an ordinary attrset. `ref` is now checked against upstream's `refRegexS` and `rev` against its `revRegexS`, the same shapes upstream enforces before either value reaches git, and `--` separates the positional arguments in `git fetch`. Rejecting an abbreviated `rev` is upstream parity in its own right: a revision that still resolves through git's DWIM rules is not pinned. (#143)+- **The Windows stdenv builds again: no drive letter in derivation text.** `mkDerivation` passed its setup script as `/cygdrive/c${setup}`, a hardcoded drive glued in front of a canonical store path. Once the builder began rendering `/nix/store` to the machine's real store directory at the spawn boundary, the two composed into `/cygdrive/cC:\nix\store/...-setup.sh` and every `mkDerivation` package failed before the builder's first line. The same rewrite reaches environment values, so `setup.sh`'s mapping (which matched on the canonical spelling) stopped firing and put a drive-lettered path into a colon-separated `PATH`, splitting the entry in two. Derivation text is now canonical throughout, which is what its hash is computed over and the only spelling that is correct on a store that is not on C:. `setup.sh` maps native paths to the two forms its consumers need, MSYS2 `/cygdrive/<drive>` for bash and mixed `C:/` for the native mingw tools, in pure parameter expansion, so it no longer shells out to `cygpath` (which the seed's package set does not provide) and no longer infers a drive from `$NIX_STORE`. The Windows E2E job now builds GNU hello through the real stdenv: the existing job drives `cmd.exe` directly and never loaded `stdenv.nix`, which is why this went unnoticed. (#144)++- **`--store` reaches evaluation, not only the builder.** Evaluation resolved every store read and write through the platform default (`C:\nix\store` on Windows, `/nix/store` elsewhere) while the builder honored the flag, so a non-default store left evaluation writing to one directory and the build reading from another. The store directory is now carried in the evaluation state and every eval-time store path resolves through it. A derivation's own strings are rendered at the spawn boundary for the same reason: the builder path, its arguments and its environment values all carry the canonical `/nix/store` spelling that hashes depend on, and that text is mapped to the configured directory only as the process is spawned. On Windows this is what lets a store-path builder be executed at all, since a bare `/`-rooted path there resolves against the current drive rather than the store's.+- **`builtins.fetchGit` fetches a pinned revision, not just a branch tip** - `rev`, `ref`, `submodules` and `shallow` are all now read from the argument set, `git fetch`/`checkout` replace the old unconditional `--depth 1` clone, and the result is the same attrset upstream's returns: `outPath`, `rev`, `shortRev`, `revCount`, `narHash`, `lastModified`, `lastModifiedDate` and `submodules`, all read from the clone before its (and every submodule's) `.git` metadata is stripped and it is copied to the store. Checked against a real Nix on the same two repositories: identical `outPath`, `narHash`, and every other field, submodule fetches included.+- **Substitution accepts bzip2, so historical cache paths and narinfos with no Compression field at all can be fetched.** cache.nixos.org narinfos written before the xz switch declare `Compression: bzip2`, and upstream C++ Nix reads an absent or empty Compression field as bzip2, so those paths could only ever fail here: the register named the codec in its rejection but no decoder stood behind the name. Both decompression paths - streaming and the strict oracle - now dispatch it through nova-cache 0.11's bzip2 sublibrary, bounded by the declared NarSize exactly as xz and zstd are, with stream errors retried and output past the bound refused as the served object misdeclaring; there is no decoder-memory knob to set, because bzip2 carries no attacker-chosen dictionary size and its decoder state is a small constant of the format. The empty spelling now decodes rather than rejects, completing the upstream parity the register was written for. The bound rises to nova-cache >= 0.11 && < 0.12 with the bzip2 sublibrary added to the library's set, and the pinned index-state advances past the release. 0.11 also tightens the zstd codec, which the suite now pins: a truncated frame and trailing bytes after the last frame refuse with a stream error instead of yielding a short output, so truncation is caught at the codec rather than left to the NAR grammar above it. (#115)+- **Cancellation is never spent as retry budget nor as cache fallthrough.** The retry classifier already re-threw asynchronous exceptions, but the per-cache catch-all one frame up converted every exception into a substitution error, so a Ctrl-C or timeout arriving mid-download was swallowed, the cache scan continued, and the build fell through to building locally instead of aborting. The sync/async split now lives in one shared helper used by the retry classifier, the per-cache attempt, and the builder's top-level catch-all: only synchronous exceptions convert into recoverable failures, and an interrupt propagates out of the whole operation.+- **Per-store-path locks close the substitution race.** The streaming substituter deleted the destination before the first byte downloaded, with no lock and no validity re-check anywhere, so two processes sharing a store could interleave delete, materialize, and register into a valid database row pointing at deleted or torn bytes. Substitution now takes an exclusive lock on upstream's "\<store-path\>.lock" file - a raw write-access descriptor locked through filelock (flock on POSIX, LockFileEx on Windows; CC0-licensed, so nothing encumbers the Apache-2.0 distribution), because base's Handle-based locks cannot express this lock - before touching the destination, re-checks validity under the lock and adopts another process's finished path instead of deleting and redoing it (waiting on a busy lock is announced, as upstream does), and holds the lock across download, materialization, the on-disk recheck, and the caller's registration transaction, releasing it on every exit path. The protocol is upstream C++ Nix's pathlocks mechanism (src/libstore/pathlocks.cc and the substitution path in local-store.cc) exactly. One deliberate divergence, documented in Nix.Store.Lock: lock files persist instead of being marked and deleted, which removes upstream's deleted-lock-file hazard outright and needs no separate Windows path.++- **store delete joins the per-path lock protocol, and lock files are not delete targets.** The delete command ran its unregister-and-remove sequence with no lock, so a delete racing another process's substitution of the same path could remove the freshly materialized tree between the substituter's on-disk recheck and its registration commit - the exact valid-row-pointing-at-deleted-bytes race the locks close, reopened through the other door. Deletion now holds the target's path lock across the whole sequence, blocking (with the announced wait) while a substituter holds it, as upstream's deletePath does. A target naming a lock file is refused with the reason: lock files are never deleted here by design, and removing one would reopen the deleted-lock-file hazard that design removes. Names like flake.lock are legal store-path names, so the refusal consults the registration rows: only an unregistered file whose suffix-stripped name is a well-formed store basename reads as a lock file, and a registered object of the same shape deletes normally.++- **zstd, both directions: substitution accepts it, push can produce it.** The modern caches (Cachix, attic, FlakeHub) serve NARs zstd-compressed; both decompression paths - streaming and the strict oracle - now speak it through nova-cache 0.10's zstandard sublibrary, bounded by the declared NarSize like xz (the strict decompressor's resolved function moves into IO: the zstd binding's decoder is IO-native, and the shape follows the codecs). Push gains --compression none|zstd, a property of the destination cache as upstream models it - none stays the default so existing cache content stays uniform - with the compressed object named by its own file hash and the narinfo's file fields describing it. The bound rises to nova-cache 0.10 with the zstandard sublibrary and the pinned index-state advances past the release. (#111)+- **Substitution failures carry a retry class, and every failure path cleans up.** Exceptions thrown mid-stream - a dropped connection, a full disk - previously escaped both the retry budget and the partial-tree cleanup, so the single most common transient failure never retried and could orphan an unverified tree at the store path. Every attempt now converts synchronous exceptions into the pipeline's failure channel (asynchronous cancellation re-throws untouched) and removes the tree on every exit. Failures classify explicitly, matching upstream's transfer layer: transport errors, torn transfers, and truncation retry with backoff; a completed transfer that verifies wrong, a body past its ceiling, or a 4xx fails at once, since retrying a deterministic failure only delays the local-build fallback.+- **The NAR download cap derives from the signed NarSize.** The compressed-body cap was keyed to the narinfo's FileSize, which the Ed25519 fingerprint does not cover, so a rewritten FileSize on a validly-signed narinfo could buy an unbounded download. The cap is now a ceiling computed from the signed NarSize plus a small framing-overhead margin, and the unsigned FileSize may only lower it, never raise it. Caches that omit FileSize (on-the-fly compressors) gain the same margin, so incompressible NARs whose compressed body slightly exceeds NarSize substitute where they previously aborted.+- **An empty Compression field means bzip2, never identity.** Upstream decodes an absent or empty Compression as bzip2, the field's historical default, and nova-cache's parser matches that coercion; treating the empty string as identity diverged from it. The compression vocabulary now lives in one register (Nix.Compression) parsed once at the narinfo boundary, so the streaming and strict dispatch cannot drift.+- **Substitution streams: download, decompress, hash, parse, and unpack in one bounded pass.** The substituter realized every NAR in memory before touching disk - an RSS spike the size of the path being fetched, documented in place since the first cut. The pipeline now drives nova-cache's streaming pieces end to end: the HTTP body feeds the bounded xz source, the incremental hash, the chunk-fed NAR parser, and a new streaming store sink that materializes events as they arrive, so memory is bounded by decoder buffers and the parser's structural-string cap, never by archive or file size. Disk writes therefore begin before the hash can be known - upstream's ordering exactly - and any failure or mismatch removes the tree it wrote; the on-disk recheck streams too. One divergence, documented at the sink: sibling names colliding on a folding filesystem always take upstream's case-hack renaming, never the NTFS true-name path, which can only be enabled on an empty directory the stream cannot pre-scan. (#107)+- **Substitution from cache.nixos.org works: xz NARs decompress.** The default cache serves every NAR xz-compressed, and decompressorFor rejected the value up front, so the shipped configuration could never substitute a single path. nova-cache 0.9's bounded decoder is wired in via its public nova-cache:xz sublibrary - an ordinary solver-visible dependency, no flags on either side; the bound rises to >= 0.9 && < 0.10 and the pinned index-state advances past the release - with output capped at the narinfo's signed NarSize and decoder memory at nova-cache's default, so a hostile stream expands to nothing the narinfo did not promise. Unsupported compression values still reject before any download is paid for. Verified against production data: recorded cache.nixos.org narinfos parse, validate, and signature-verify offline in the suite, and a real path substitutes end to end from the live cache. (#27)+- **nova-cache 0.8.0.0.** The bound rises to >= 0.8 && < 0.9 and the pinned index-state advances past the release. NAR parsing now rejects the full Windows reserved-device set at the parse boundary - COM0/LPT0, space-padded device stems ("NUL .txt"), and the superscript-digit forms - the same names materialization already refused, so the refusal moves earlier and the two guards agree. 0.8 also brings the streaming NAR interface and the NarSize-bounded xz decoder that foreign-cache substitution builds on next.+- **builtin:unpack resolves srcs through the store dir.** The srcs env value carries eval's canonical /nix/store spelling; unpack opened it verbatim, and on Windows a bare /-rooted path resolves against the current drive - the build worked only when the process happened to run from C:. Found by the e2e job's first flights (#100) on GitHub's D:-workspace runners. Each srcs entry now parses as a store path and renders through the configured store dir (the identity on Unix), a non-store-path entry fails loudly, and the e2e job no longer pins its working directory. (#101)+- **Toolchain: GHC 9.14.1 (the first GHC LTS release)** - CI, the release pipeline, and the README badge move from GHC 9.8.4 to 9.14.1. GHC 9.14 opens GHC's new LTS scheme (a minimum of two years of bugfix releases), and under that scheme every earlier series stops receiving fixes, so no version in between had a future. Dependency bounds already admitted the newer compiler and the full set resolves on it. The project targets the LTS alone: base >= 4.22, and foldl' comes from the Prelude, so its eleven redundant Data.List imports are gone. The deprecated pattern namespace specifier stays for now - hlint cannot yet parse the data replacement GHC suggests - with its deprecation warning muted in the cabal file until hlint learns the new spelling.+- **`builtins.toFile` writes are registered before a build that names them.** `writeToStore` computed a content-addressed path and wrote the bytes during evaluation, but nothing registered it, so a derivation naming a `toFile` output as an input died with `registerPaths: ... references unregistered path ...`. The write is recorded in eval state and registered in one batch by the build driver, before building - the same treatment `materializeEvalSources` already gives path literals eval coerces into the store, batched so a text path referencing another resolves. The write is idempotent too: the path is the hash of the bytes, so a file already there already holds them, and skipping the rewrite avoids a permission error on a second evaluation once registration makes the path read-only.+- **A path literal resolves against the file it is written in, not the file being evaluated when it is forced.** Relative path literals were rewritten to absolute ones only for `import`ed and `scopedImport`ed files; the top-level file and `--expr` were left to resolve at force time against whatever directory the evaluator happened to be in. Those differ whenever a literal outlives the scope it was written in, and `builtins.scopedImport { q = ./data.txt; } ./sub/useq.nix` is the short way to see it: `q` is forced during `sub/useq.nix`'s evaluation, so `./data.txt` named `sub/data.txt`, and `builtins.readFile` on it returned the wrong file's contents into whatever consumed them. Resolution is now part of parsing, which is where upstream does it (`absPath(path, state->basePath...)` in the `PATH` production through 2.24, `CanonPath(literal, state->basePath.path).abs()` in 2.35, unchanged in between), so `parseNix` takes the directory to resolve against and no caller can skip the step. A second bug fell out of widening it: `~/x` was treated as relative and joined to the importing file's directory, burying the tilde mid-path where nothing expands it, so `~/x` inside an imported file resolved to `<dir>/~/x`. Home-relative literals are now left for the evaluator, which expands them against `HOME`.+- **A value that needs no evaluation is shown, not reported as a thunk.** Printing an unforced list or attr set showed `<thunk>` for entries that were already values: `[ "a" "b" ]` came back as `[ <thunk> <thunk> ]` where upstream prints the strings, `[ ./x ]` and `[ [ ] ]` the same, and every attr set value regardless of what it was, so `{ a = 1; }` reported a thunk over the literal `1`. The cause was that the decision was made on how a value is represented at runtime (an integer fits an immediate slot, a string does not) rather than on what the expression is, which is what upstream asks: `maybeThunk` is overridden by `ExprInt`, `ExprFloat`, `ExprString` and `ExprPath` to hand back a value already built, and both `ExprList::eval` and the non-recursive branch of `ExprAttrs::eval` go through it. String and URI literals, path literals, and the empty list join the integers and booleans that were already answered directly, and a non-recursive attr set resolves its values the same way; recursive sets and `let` still defer, because the frame their variables point into is still being tied. Path literals can only be answered here because parsing now resolves them, so what reaches the evaluator is already absolute; a `~/` literal still defers, since expanding it reads the environment. Checked against a real `nix-instantiate` 2.24.9 in CI rather than against a reading of its source, and the same overrides are byte-identical in 2.35.2.++  Empty containers print as `[ ]` and `{ }` rather than with the two spaces a join of no elements left between the brackets.+ ## 0.6.0.0 - 2026-06-12  ### Milestone: A Stage-1 stdenv That Builds Real Software
+ NOTICE view
@@ -0,0 +1,6 @@+nova-nix+Copyright 2026 Novavero AI Inc. and contributors++This product is licensed under the Apache License, Version 2.0.+A copy of the License is provided in the LICENSE file, or at+http://www.apache.org/licenses/LICENSE-2.0
README.md view
@@ -1,141 +1,190 @@-<div align="center">-<h1>nova-nix</h1>-<p><strong>A Windows-native Nix, from scratch.</strong></p>-<p>Parser, lazy evaluator, content-addressed store, derivation builder, and binary-cache substituter - in Haskell and C99. Runs natively on Windows, macOS, and Linux. No WSL, no Cygwin.</p>+# nova-nix  [![CI](https://github.com/Novavero-AI/nova-nix/actions/workflows/ci.yml/badge.svg)](https://github.com/Novavero-AI/nova-nix/actions/workflows/ci.yml) [![Hackage](https://img.shields.io/hackage/v/nova-nix.svg)](https://hackage.haskell.org/package/nova-nix)-![GHC](https://img.shields.io/badge/GHC-9.8-purple)-![License](https://img.shields.io/badge/license-Apache--2.0-blue)+[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE) -</div>+nova-nix is an implementation of [Nix](https://nixos.org) for Windows, written+in Haskell with a C99 data layer. It also builds and runs on macOS and Linux.+It has its own parser, evaluator, store, builder and binary-cache substituter,+and does not need an existing Nix installation. ----+nova-nix is experimental. Read [Limitations](#limitations) before relying on+it.  ## Status -nova-nix builds natively on Windows. The toolchain is itself a store path (fetched, hash-verified, and unpacked through derivations), and the builder runs it directly:+- **Evaluation matches upstream Nix on the cases CI checks.** On a pinned+  nixpkgs 24.11 revision, `hello.drvPath` and a dependent derivation evaluate+  to the same store paths as Nix 2.24.9, and 23 smaller evaluation cases+  produce the same output. CI runs this comparison on every change. A matching+  `drvPath` means the whole build-time closure behind it matches too. How much+  of the rest of nixpkgs evaluates has not been measured yet ([#29]).+- **Windows builds run natively.** CI builds GNU Hello on a Windows runner+  through a stage-1 stdenv. Its toolchain is 17 MinGW-w64 packages and 22+  MSYS2 packages, each fetched as a fixed-output derivation pinned by SHA-256+  and unpacked into the store. sed, zlib and a small library-linking example+  also have recipes, but CI does not build them. Replacing these binary seeds+  with a toolchain built from source is tracked in [#26].+- **Binary caches work in both directions.** `nova-nix push` uploads a+  closure, and `build --substituter` downloads signed NARs instead of+  building. CI checks the round trip on Linux against a local nova-cache+  server. -```console-$ nova-nix build pkgs\windows\hello.nix-  [build]  mingw-w64-x86_64-gcc-16.1.0-5-any.pkg.tar.zst-  ...      (17 fixed-output fetches, one per MSYS2 package)-  [build]  mingw-w64-seed-  [build]  hello-C:\nix\store\zr07i99kqnv48q29n706qxar7h1gfins-hello+### Limitations -$ C:\nix\store\zr07i99kqnv48q29n706qxar7h1gfins-hello\hello.exe-Hello from the first native Windows Nix build.-```+- No flakes, no `nix-shell` or `nix develop`, and no daemon or multi-user mode.+- No garbage collection yet ([#24]). `nova-nix store delete` removes+  individual paths.+- On Windows a build's process tree runs in a job object, so stopping a build+  stops everything it started. There is no filesystem or network isolation+  yet ([#25]).+- nova-nix itself needs neither WSL nor Cygwin, but the current Windows stdenv+  runs its build scripts with MSYS2's bash.+- Store paths that contain symlinks need Windows Developer Mode or an elevated+  shell. nova-nix creates native symlinks and fails rather than falling back+  to a copy. -The seed (`pkgs/windows/seed.nix`) is stage 0 of a native Windows stdenv: the sha256-pinned runtime closure of MinGW-w64 GCC, merged into one toolchain root by in-process fetch and unpack builtins.+## Install -It also evaluates real nixpkgs. `import <nixpkgs> {}` resolves the top-level package set (~23,000 attributes) to weak head normal form, and a package evaluates through the stdenv bootstrap down to a derivation:+Each [release](https://github.com/Novavero-AI/nova-nix/releases/latest) has an+archive per platform: -```console-$ NIX_PATH=nixpkgs=/path/to/nixpkgs \-    nova-nix eval --expr '(import <nixpkgs> { system = "x86_64-linux"; }).hello.drvPath'-"/nix/store/gciipqhqkdlqqn803zd4a389v86ran45-hello-2.12.1.drv"-```+| Platform | Archive |+| --- | --- |+| Linux x86_64 | [`nova-nix-linux-x64.tar.gz`](https://github.com/Novavero-AI/nova-nix/releases/latest/download/nova-nix-linux-x64.tar.gz) |+| macOS arm64 | [`nova-nix-macos-arm64.tar.gz`](https://github.com/Novavero-AI/nova-nix/releases/latest/download/nova-nix-macos-arm64.tar.gz) |+| Windows x86_64 | [`nova-nix-windows-x64.zip`](https://github.com/Novavero-AI/nova-nix/releases/latest/download/nova-nix-windows-x64.zip) | -That `drvPath`, and the 253-derivation closure behind it, byte-matches upstream `nix-instantiate` - verified in CI on the same nixpkgs tree. It targets the Nix 2.24 language.+Each archive unpacks to a single directory holding `bin/`, `share/nova-nix/`+and `pkgs/`. `share/nova-nix/` provides the `<nix/*>` search path, so the+binary works wherever the directory is unpacked, and `pkgs/` holds the package+recipes. Check the download against the `SHA256SUMS` file attached to the same+release. -## Quickstart+In a GitHub Actions workflow: -```bash-git clone https://github.com/Novavero-AI/nova-nix.git-cd nova-nix-cabal build+```yaml+- uses: Novavero-AI/install-nova-nix@v1+- run: nova-nix eval --expr '1 + 2' ``` +## Usage+ ```console $ nova-nix eval --expr '1 + 2' 3--$ nova-nix eval --expr 'builtins.map (x: x * x) [ 1 2 3 4 5 ]'+$ nova-nix eval --strict --expr 'builtins.map (x: x * x) [ 1 2 3 4 5 ]' [ 1 4 9 16 25 ]--$ nova-nix eval FILE.nix                       # evaluate a file-$ nova-nix build FILE.nix                       # build a derivation-$ nova-nix push --cache URL --key-file KEY --all # publish the store to a cache-$ nova-nix --nix-path nixpkgs=/path eval FILE.nix ``` -## Binary cache--Built outputs are content-addressed, so they can be served and substituted like any Nix store path. `nova-nix push` uploads a path and its closure to a cache (NAR before narinfo, skipping what the cache already has, signed server-side); `build --substituter` pulls from one before compiling.- ```console-$ nova-nix build pkgs\windows\hello.nix \-    --store C:\scratch\store \-    --substituter https://cache.novavero.ai \-    --trusted-key cache.novavero.ai-1:9gQ7tLWMM+2tdC9H5sKMJltDIPfD7X2GWlZe8Aa8hHQ=-  [subst]  mingw-w64-x86_64-gcc-16.1.0-5-any.pkg.tar.zst-  ...      (signed download instead of a rebuild)-  [subst]  hello+$ nova-nix eval FILE.nix                          # evaluate a file+$ nova-nix build FILE.nix -A ATTR                 # build an attribute of it+$ nova-nix build FILE.nix --substituter URL --trusted-key KEY  # try a cache first+$ nova-nix push --cache URL --key-file KEY --all  # upload the store to a cache+$ nova-nix --help ``` -A public instance runs at `cache.novavero.ai`, seeded with the MinGW-w64 toolchain and the `hello` build.--## How it works--Six layers - Haskell for logic, C99 for data:--1. **Parser** (`Nix.Parser`, `Nix.Expr`) - hand-rolled recursive descent; the full Nix grammar to a 19-constructor AST.-2. **Evaluator** (`Nix.Eval`) - the AST compiles to a flat 24-opcode bytecode that a lazy, thunk-memoizing evaluator runs. Recursive `let`/`rec` are knot-tied; reference cycles are caught by blackhole detection. The evaluator is polymorphic over its effect via `MonadEval`: `PureEval` for tests, `EvalIO` for the real thing.-3. **Data layer** (`cbits/nn_*.c`) - nine arena-allocated C99 modules (interned symbols, sorted attrsets, thunks, environments, lists, context strings, bytecode, lambdas) hold evaluation data off the GHC heap. Haskell calls C to create and query it; C never calls back.-4. **Store** (`Nix.Store`) - content-addressed `/nix/store` (`C:\nix\store` on Windows) with SQLite metadata and reference scanning.-5. **Builder** (`Nix.Builder`) - dependency graph, topological sort, and binary-cache substitution before local builds.-6. **Substituter** (`Nix.Substituter`) - the HTTP binary-cache protocol: narinfo parsing, Ed25519 verification, NAR download and unpack. Built on [nova-cache](https://github.com/Novavero-AI/nova-cache).--Two decisions shape the rest. **Haskell owns evaluation, C owns data layout** - bulk eval state lives off the GHC heap, so large evaluations don't thrash the collector. And **`derivation` is a lazy wrapper over the eager `derivationStrict` primop** (as in Nix's `corepkgs/derivation.nix`), so referencing a package never forces its build closure.+Evaluating a package from nixpkgs, on the 24.11 revision CI pins: -## Windows-native+```console+$ NIX_PATH=nixpkgs=/path/to/nixpkgs nova-nix eval --expr \+    '(import <nixpkgs> { system = "x86_64-linux"; config = {}; overlays = []; }).hello.drvPath'+"/nix/store/gciipqhqkdlqqn803zd4a389v86ran45-hello-2.12.1.drv"+``` -| Unix assumption | How nova-nix handles it |-|---|---|-| `/nix/store` | `C:\nix\store` - every path is parameterized, never hardcoded |-| `fork`/`exec` | `CreateProcess` via `System.Process` |-| Symlinks | Developer-mode symlinks, with junction / copy fallback |-| 260-char path limit | `\\?\` extended-length prefixes |-| A system `bash` | the builder ships `bash.exe` in the store (MSYS2) |+Building GNU Hello on Windows, from the unpacked release directory: -## Roadmap+```console+> bin\nova-nix build pkgs\windows\hello.nix+...+C:\nix\store\<hash>-hello+> C:\nix\store\<hash>-hello\bin\hello.exe+Hello, world!+``` -**Done** - parser, lazy bytecode evaluator, the Nix `builtins` set, the C99 data layer, content-addressed store, derivation builder, binary-cache substituter and `push`, `import <nixpkgs> {}` evaluation, derivation-hash parity with upstream Nix (`hello`'s 253-derivation closure byte-matches `nix-instantiate`), and native Windows builds from a store-pinned MinGW-w64 toolchain (stage 0), published to and substituted from a binary cache.+The first build fetches 40 pinned archives (the 39 toolchain packages and the+Hello source), then builds the MSYS2 seed, the MinGW-w64 seed and Hello. -**In progress** - an experimental stage-1 stdenv: a store-pinned MSYS2 userland seed and a lean `setup.sh` + `mkDerivation`, so a package builds from just `{ name; src; }`. It wires inter-package dependencies (`buildInputs`), routes compilation through a `gcc` wrapper (hermetic flags, deterministic links), and bundles non-system DLLs so outputs run standalone. Proven by building GNU hello, GNU sed, and zlib from source, and by linking a program against a library built the same way.+## How it works -**Next**+- **Parser** (`Nix.Parser`): a hand-written recursive-descent parser for the+  Nix 2.24 language.+- **Evaluator** (`Nix.Eval`): the AST compiles to a small bytecode that a lazy+  evaluator runs, memoizing thunks and detecting infinite recursion. The+  evaluator is generic over `MonadEval`, with a pure instance for tests and an+  IO instance for real evaluation. `derivation` is a lazy wrapper over the+  strict `derivationStrict` primop, as in upstream's+  `src/libexpr/primops/derivation.nix`, so referring to a package does not+  force its build closure.+- **Data layer** (`cbits/`): attribute sets, lists, thunks, environments,+  symbols, context strings, lambdas and bytecode are C99 structures outside+  the GHC heap, freed together when an evaluation session ends. Haskell calls+  into C, and C never calls back.+- **Store** (`Nix.Store`): a content-addressed store at `/nix/store`, or+  `C:\nix\store` on Windows, with SQLite metadata, reference scanning and+  per-path locks that follow upstream's protocol.+- **Builder** (`Nix.Builder`): orders derivations by their dependencies, tries+  substitution first, and runs each builder in a scrubbed environment.+- **Substituter** (`Nix.Substituter`): the HTTP binary-cache protocol, with+  Ed25519 signature checks and xz, zstd and bzip2 decompression, built on+  [nova-cache](https://github.com/Novavero-AI/nova-cache). -- Grow the native package set - more real-world libraries and programs built from source through the stdenv.-- Parity across more of nixpkgs - extend the byte-match check beyond `hello`'s closure.-- Cache - serve large NARs from object storage, and compress them.+On Windows, derivations keep the canonical `/nix/store` spelling that hashes+are computed over, and it is mapped to the real store directory only when a+builder is started. NTFS has no executable bit, so nova-nix keeps it in an+alternate data stream. A store path therefore serializes to the same NAR, with+the same hash, as it does on other platforms. -## Library usage+## Library  ```haskell-import Nix.Parser (parseNix)-import Nix.Eval (PureEval (..), eval)+{-# LANGUAGE OverloadedStrings #-}++import Control.Exception (bracket_) import Nix.Builtins (builtinEnv)+import Nix.Eval (eval, runPureEval)+import Nix.Eval.Arena (arenaDestroy, arenaInit)+import Nix.Parser (parseNix)  main :: IO ()-main = case parseNix "<expr>" "let x = 5; in x * 2 + 1" of-  Left err   -> print err-  Right expr -> print (runPureEval (eval (builtinEnv 0 []) expr))  -- Right (VInt 11)+main = bracket_ arenaInit arenaDestroy $+  -- The first argument is what relative path literals resolve against.+  case parseNix "/tmp" "<expr>" "let x = 5; in x * 2 + 1" of+    Left err -> print err+    Right expr -> print (runPureEval (eval (builtinEnv 0 []) expr)) ``` -The evaluator is polymorphic over `MonadEval`: `PureEval` for deterministic tests, `EvalIO` for filesystem access.+This prints `Right (VInt 11)`. Evaluation needs the C data layer, so it must+run between `arenaInit` and `arenaDestroy`. -## Build & test+## Building from source +Tested with GHC 9.14.1. CI uses the latest cabal-install release. You only+need this to work on nova-nix itself, not to use a release.+ ```bash+git clone https://github.com/Novavero-AI/nova-nix.git+cd nova-nix+cabal update cabal build cabal test ``` -Requires GHC 9.8+ and cabal-install 3.10+.+## Contributing ----+See [CONTRIBUTING.md](CONTRIBUTING.md). Planned work is on the+[Nova roadmap](https://github.com/orgs/Novavero-AI/projects/1) project. Please+report security issues privately through+[GitHub's vulnerability reporting](https://github.com/Novavero-AI/nova-nix/security/advisories/new)+rather than in a public issue. -<p align="center"><sub>Apache-2.0 - <a href="https://github.com/Novavero-AI">Novavero AI Inc.</a></sub></p>+## License++Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).++[#24]: https://github.com/Novavero-AI/nova-nix/issues/24+[#25]: https://github.com/Novavero-AI/nova-nix/issues/25+[#26]: https://github.com/Novavero-AI/nova-nix/issues/26+[#29]: https://github.com/Novavero-AI/nova-nix/issues/29
app/Main.hs view
@@ -1,47 +1,49 @@--- | nova-nix CLI entry point.------ Commands:------ @--- nova-nix eval  FILE.nix                  Evaluate a .nix file, print result--- nova-nix eval  --expr 'EXPR'             Evaluate an inline expression--- nova-nix build FILE.nix                  Build a derivation from a .nix file--- nova-nix push  --cache URL --all         Push store paths to a binary cache--- @------ Flags:+{-# LANGUAGE ScopedTypeVariables #-}++-- | nova-nix CLI entry point: parse the argument vector, dispatch one+-- command, map its outcome to an exit code. ----- @--- --nix-path NAME=PATH   Add a search path entry (repeatable, merged with NIX_PATH)--- --expr EXPR            Evaluate an inline expression instead of a file--- @+-- The command and flag tables are deliberately not repeated here.+-- 'usageLines' is the one place they live, and both @--help@ and the+-- usage-error path print it, so the flag that adds itself to the parser+-- documents itself in the same edit.  A second copy in this header went+-- nine flags and two commands stale before anyone noticed, because+-- nothing renders it: Haddock builds library targets by default, and+-- this module is in the executable stanza. module Main (main) where +import Control.Exception (IOException, displayException, try) import Control.Monad (void, (>=>))+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BC import Data.IORef (readIORef) import qualified Data.Map.Strict as Map import Data.Maybe (catMaybes) import qualified Data.Text as T+import qualified Data.Text.Encoding as TE import qualified Data.Text.IO as TIO-import Nix.Builder (BuildConfig (..), BuildResult (..), buildWithDeps, defaultBuildConfig)+import Data.Version (showVersion)+import Nix.Builder (BuildConfig (..), BuildResult (..), buildWithDeps, defaultBuildConfig, execWrapperConfig) import Nix.Builtins (builtinEnv, parseNixPath)+import Nix.Config (NixConfig (..))+import qualified Nix.Config as Config import Nix.Derivation (Derivation (..), DerivationOutput (..), toATerm) import Nix.Eval (MonadEval, NixValue (..), Thunk (..), attrSetFromMap, attrSetLookup, attrSetToAscList, attrSetToMap, eval, evaluated, force, readThunkValue) import Nix.Eval.Arena (arenaInit)+import Nix.Eval.AttrPath (selectAttrPath) import Nix.Eval.IO (EvalState (..), newEvalState, runEvalIO)-import Nix.Eval.Types (clistFromThunks, clistThunks, thunkToCPtr)+import Nix.Eval.Types (bytesToTextLossy, clistFromThunks, clistThunks, thunkToCPtr) import Nix.Parser (parseNix, readFileAutoEncoding)-import Nix.Push (PushConfig (..), PushSummary (..), loadApiKeyFile, pushPaths)-import Nix.Store (Store (..), closeStore, isValid, openStore, queryAllValidPaths, registerPaths, registrationFor, setReadOnly, writeDrv, writeDrvAterm)-import Nix.Store.Path (StoreDir (..), StorePath (..), defaultStoreDir, parseStorePath, platformStoreDir, storePathHashLen, storePathToFilePath)+import Nix.Push (PushCompression (..), PushConfig (..), PushSummary (..), loadApiKeyFile, parsePushCompression, pushCompressionValues, pushPaths)+import Nix.Store (DeleteOutcome (..), Store (..), closeStore, deleteStorePathRaw, materializeEvalSources, materializeEvalStoreWrites, openStore, queryAllValidPaths, resolveDeleteTarget, writeDrv, writeDrvClosure)+import Nix.Store.Path (StoreDir (..), StorePath, defaultStoreDir, parseStorePath, parseStorePathBaseName, platformStoreDir, storePathToFilePath) import Nix.Substituter (CacheConfig (..))-import Paths_nova_nix (getDataDir)-import System.Directory (canonicalizePath, copyFile, createDirectoryIfMissing, doesDirectoryExist, getCurrentDirectory, getTemporaryDirectory, listDirectory)-import System.Environment (getArgs)+import Paths_nova_nix (getDataDir, version)+import System.Directory (Permissions (executable), XdgDirectory (XdgConfig), canonicalizePath, doesFileExist, findExecutable, getCurrentDirectory, getPermissions, getTemporaryDirectory, getXdgDirectory)+import System.Environment (getArgs, getExecutablePath, lookupEnv) import System.Exit (exitFailure)-import System.FilePath (takeDirectory)-import qualified System.FilePath as FP-import System.IO (BufferMode (..), hPutStrLn, hSetBuffering, stderr, stdout)+import System.FilePath (takeDirectory, takeFileName, (</>))+import System.IO (BufferMode (..), hPutStrLn, hSetBuffering, hSetEncoding, stderr, stdout, utf8)  -- --------------------------------------------------------------------------- -- Argument parsing@@ -58,32 +60,69 @@     optSubstituter :: !(Maybe String),     -- | Trusted public key (@name:base64@) for the substituter.     optTrustedKey :: !(Maybe String),+    -- | @SYSTEM=PATH@ launchers for derivations this machine cannot execute+    -- directly, e.g. @x86_64-windows=/path/to/wine@.+    optExecWrappers :: ![String],     optCommand :: !Command   }  data Command   = CmdEvalFile !FilePath   | CmdEvalExpr !T.Text-  | CmdBuild !FilePath+  | CmdBuild !BuildTarget !(Maybe T.Text)   | CmdPush !PushArgs-  | CmdHelp+  | CmdStoreDelete ![String]+  | -- | No command given.  Usage on stderr, non-zero: a bare invocation is+    -- a usage error, and a caller testing the exit status must see one.+    CmdUsage+  | -- | @--help@.  The same text on stdout, zero: it is a request that+    -- succeeded, and pipeable without redirecting stderr.+    CmdHelp+  | CmdVersion +-- | Where a build's expression comes from.+data BuildTarget+  = -- | A @.nix@ file.  Relative paths inside it resolve beside the file.+    TargetFile !FilePath+  | -- | An inline expression.  Relative paths inside it resolve against the+    -- working directory, since there is no file to sit beside.+    TargetExpr !T.Text++-- | Arguments to the build command, while the target is still unknown.+data BuildArgs = BuildArgs+  { baTarget :: !(Maybe BuildTarget),+    baAttrPath :: !(Maybe T.Text)+  }++-- | Build arguments before any flag is parsed.+emptyBuildArgs :: BuildArgs+emptyBuildArgs = BuildArgs Nothing Nothing+ -- | Arguments to the push command. data PushArgs = PushArgs   { paCacheUrl :: !(Maybe String),     paKeyFile :: !(Maybe FilePath),+    paCompressionArg :: !(Maybe String),     paAll :: !Bool,     paPaths :: ![String]   }  -- | Push arguments before any flag is parsed. emptyPushArgs :: PushArgs-emptyPushArgs = PushArgs Nothing Nothing False []+emptyPushArgs = PushArgs Nothing Nothing Nothing False [] -parseArgs :: [String] -> CliOpts-parseArgs = go (CliOpts [] False False Nothing Nothing Nothing CmdHelp)+-- | Parse the command line.  A malformed invocation is an error, never a+-- silent drop: an unknown or typo'd flag once ended parsing and quietly+-- discarded everything after it (e.g. a requested @--substituter@).+parseArgs :: [String] -> Either String CliOpts+parseArgs = go (CliOpts [] False False Nothing Nothing Nothing [] CmdUsage)   where-    go opts [] = opts+    go opts [] = Right opts+    -- Answered before anything else is looked at, and the rest of the line+    -- is not parsed: --version must report the build even when the command+    -- after it is one this build does not have.+    go opts ("--version" : _) = Right opts {optCommand = CmdVersion}+    go opts ("--help" : _) = Right opts {optCommand = CmdHelp}     go opts ("--nix-path" : val : rest) =       go (opts {optNixPaths = optNixPaths opts ++ [T.pack val]}) rest     go opts ("--strict" : rest) =@@ -96,34 +135,153 @@       go (opts {optSubstituter = Just url}) rest     go opts ("--trusted-key" : key : rest) =       go (opts {optTrustedKey = Just key}) rest+    go opts ("--exec-wrapper" : spec : rest) =+      go (opts {optExecWrappers = optExecWrappers opts ++ [spec]}) rest     go opts ("eval" : rest) = goEval opts rest-    go opts ("build" : path : rest) =-      go (opts {optCommand = CmdBuild path}) rest+    go opts ("build" : rest) = goBuild opts emptyBuildArgs rest     go opts ("push" : rest) = goPush opts emptyPushArgs rest-    go opts _ = opts+    go opts ("store" : rest) = goStore opts rest+    go _ [flag]+      | flag `elem` valueFlags = Left (flag ++ " requires a value")+    go _ (arg : _) = Left ("unknown argument: " ++ arg ++ " (run nova-nix --help for usage)")     -- Sub-parser for eval: handles --strict and --expr interleaved with the file arg.-    goEval opts [] = opts+    goEval opts [] = Right opts     goEval opts ("--strict" : rest) = goEval (opts {optStrict = True}) rest     goEval opts ("--aterm" : rest) = goEval (opts {optAterm = True}) rest     goEval opts ("--nix-path" : val : rest) =       goEval (opts {optNixPaths = optNixPaths opts ++ [T.pack val]}) rest+    -- Accepted here as well as at the top level, like build and push:+    -- eval-side reads follow the selected store, so the flag means as+    -- much after the subcommand as before it.+    goEval opts ("--store" : dir : rest) =+      goEval (opts {optStore = Just dir}) rest     goEval opts ("--expr" : expr : rest) =       go (opts {optCommand = CmdEvalExpr (T.pack expr)}) rest+    goEval _ [flag]+      | flag `elem` valueFlags = Left (flag ++ " requires a value")+    goEval _ (arg@('-' : _) : _) = Left ("unknown eval flag: " ++ arg)     goEval opts (path : rest) =       go (opts {optCommand = CmdEvalFile path}) rest+    -- Sub-parser for build: the target, -A, and the shared flags in any+    -- order.  The shared flags are handled here rather than deferred back to+    -- 'go' so that one can follow the file argument, which is where a caller+    -- reaches for it, and so that -A is still recognised after it.+    goBuild opts buildArgs [] = finishBuild opts buildArgs+    -- Answered here as well as at the top level: a sub-parser that rejected+    -- them would make 'build --help' a usage error rather than a request.+    goBuild opts _ ("--help" : _) = Right opts {optCommand = CmdHelp}+    goBuild opts _ ("--version" : _) = Right opts {optCommand = CmdVersion}+    goBuild opts buildArgs ("--store" : dir : rest) =+      goBuild (opts {optStore = Just dir}) buildArgs rest+    goBuild opts buildArgs ("--substituter" : url : rest) =+      goBuild (opts {optSubstituter = Just url}) buildArgs rest+    goBuild opts buildArgs ("--trusted-key" : key : rest) =+      goBuild (opts {optTrustedKey = Just key}) buildArgs rest+    goBuild opts buildArgs ("--nix-path" : val : rest) =+      goBuild (opts {optNixPaths = optNixPaths opts ++ [T.pack val]}) buildArgs rest+    goBuild opts buildArgs ("--exec-wrapper" : spec : rest) =+      goBuild (opts {optExecWrappers = optExecWrappers opts ++ [spec]}) buildArgs rest+    goBuild opts buildArgs ("--expr" : expr : rest) =+      withTarget opts buildArgs (TargetExpr (T.pack expr)) rest+    goBuild opts buildArgs (flag : path : rest)+      | flag `elem` attrFlags = case baAttrPath buildArgs of+          Just _ -> Left "build accepts one attribute path"+          Nothing -> goBuild opts (buildArgs {baAttrPath = Just (T.pack path)}) rest+    goBuild _ _ [flag]+      | flag `elem` valueFlags = Left (flag ++ " requires a value")+    goBuild _ _ (arg@('-' : _) : _) = Left ("unknown build flag: " ++ arg)+    goBuild opts buildArgs (path : rest) =+      withTarget opts buildArgs (TargetFile path) rest+    -- A build evaluates one expression, so a second target is a mistake+    -- worth naming rather than a silent last-one-wins.+    withTarget opts buildArgs target rest = case baTarget buildArgs of+      Just _ -> Left "build takes one FILE.nix or one --expr, not both"+      Nothing -> goBuild opts (buildArgs {baTarget = Just target}) rest+    finishBuild opts buildArgs = case baTarget buildArgs of+      Nothing -> Left "build requires a FILE.nix argument or --expr EXPR"+      Just target -> Right opts {optCommand = CmdBuild target (baAttrPath buildArgs)}     -- Sub-parser for push: flags and explicit store paths in any order.-    goPush opts pushArgs [] = opts {optCommand = CmdPush pushArgs}+    goPush opts pushArgs [] = Right opts {optCommand = CmdPush pushArgs}     goPush opts pushArgs ("--store" : dir : rest) =       goPush (opts {optStore = Just dir}) pushArgs rest     goPush opts pushArgs ("--cache" : url : rest) =       goPush opts (pushArgs {paCacheUrl = Just url}) rest     goPush opts pushArgs ("--key-file" : path : rest) =       goPush opts (pushArgs {paKeyFile = Just path}) rest+    goPush opts pushArgs ("--compression" : value : rest) =+      goPush opts (pushArgs {paCompressionArg = Just value}) rest     goPush opts pushArgs ("--all" : rest) =       goPush opts (pushArgs {paAll = True}) rest+    goPush _ _ [flag]+      | flag `elem` valueFlags = Left (flag ++ " requires a value")+    goPush _ _ (arg@('-' : _) : _) = Left ("unknown push flag: " ++ arg)     goPush opts pushArgs (path : rest) =       goPush opts (pushArgs {paPaths = paPaths pushArgs ++ [path]}) rest+    -- Sub-parser for store maintenance verbs.+    goStore _ [] = Left "store: expected a subcommand (delete)"+    goStore opts ("delete" : rest) = goStoreDelete opts [] rest+    goStore _ (sub : _) = Left ("unknown store subcommand: " ++ sub ++ " (expected: delete)")+    goStoreDelete opts paths []+      | null paths = Left "store delete: name at least one store path"+      | otherwise = Right opts {optCommand = CmdStoreDelete paths}+    goStoreDelete opts paths ("--store" : dir : rest) =+      goStoreDelete (opts {optStore = Just dir}) paths rest+    goStoreDelete _ _ [flag]+      | flag `elem` valueFlags = Left (flag ++ " requires a value")+    goStoreDelete _ _ (arg@('-' : _) : _) = Left ("unknown store delete flag: " ++ arg)+    goStoreDelete opts paths (path : rest) =+      goStoreDelete opts (paths ++ [path]) rest+    -- Flags that consume the following argument as their value.+    valueFlags =+      ["--nix-path", "--store", "--substituter", "--trusted-key", "--exec-wrapper", "--expr", "--cache", "--key-file", "--compression"]+        ++ attrFlags+    -- Attribute selection, under both of upstream's spellings.+    attrFlags = ["-A", "--attr"] +-- | Upstream C++ Nix's name for this directory, so an operator who knows+-- one knows the other.+nixDataDirVar :: String+nixDataDirVar = "NIX_DATA_DIR"++-- | The environment variable carrying inline nix.conf settings, above the+-- config files and below the command line in precedence.+nixConfigVar :: String+nixConfigVar = "NIX_CONFIG"++-- | Where a release archive keeps the bundled expressions, relative to the+-- directory holding @bin@.+bundledDataSubdir :: FilePath+bundledDataSubdir = "share" </> "nova-nix"++-- | The one file a data dir must contain, used to tell a real one from a+-- directory that merely exists.+dataDirMarker :: FilePath+dataDirMarker = "nix" </> "fetchurl.nix"++-- | Locate the bundled @\<nix/*\>@ expressions.+--+-- Cabal bakes an absolute @datadir@ into the binary at configure time.  That+-- is right for a @cabal install@ on this machine and wrong for every copied+-- or downloaded one, because the path names the machine that did the build:+-- a released binary would resolve @\<nix/fetchurl.nix\>@ to a directory that+-- does not exist on the host running it.+--+-- @NIX_DATA_DIR@ wins when set, since an operator setting upstream's own+-- variable means it.  Otherwise a release layout (@bin/@ beside @share/@)+-- answers from the executable's own location, which needs no configuration+-- at all.  The Cabal path remains the fallback, so a local install is+-- unaffected.+resolveDataDir :: IO FilePath+resolveDataDir = do+  fromEnv <- lookupEnv nixDataDirVar+  case fromEnv of+    Just dir | not (null dir) -> pure dir+    _ -> do+      exeDir <- takeDirectory <$> getExecutablePath+      let bundled = takeDirectory exeDir </> bundledDataSubdir+      bundledUsable <- doesFileExist (bundled </> dataDirMarker)+      if bundledUsable then pure bundled else getDataDir+ -- | Merge --nix-path entries, bundled data dir, and NIX_PATH search paths. -- The data dir is appended last so user paths take priority. mergeSearchPaths :: [T.Text] -> FilePath -> [Thunk] -> [Thunk]@@ -137,53 +295,105 @@ main :: IO () main = do   hSetBuffering stdout LineBuffering+  -- UTF-8 on both output handles regardless of the console code page:+  -- locale encodings THROW on any character they cannot represent, so a+  -- store path or eval result containing one would otherwise abort the+  -- whole invocation mid-print on legacy Windows consoles.+  hSetEncoding stdout utf8+  hSetEncoding stderr utf8   -- Initialize C data layer (symbol interning, thunk arena, env allocator)   arenaInit   args <- getArgs-  dataDir <- getDataDir-  let opts = parseArgs args+  dataDir <- resolveDataDir+  opts <- either (failWith . T.pack) pure (parseArgs args)   case optCommand opts of-    CmdEvalFile filePath -> evalFile (optStrict opts) (optNixPaths opts) dataDir filePath+    CmdEvalFile filePath -> evalFile (chosenStoreDir opts) (optStrict opts) (optNixPaths opts) dataDir filePath     CmdEvalExpr expr-      | optAterm opts -> evalExprAterm (optNixPaths opts) dataDir expr-      | otherwise -> evalExpr (optStrict opts) (optNixPaths opts) dataDir expr-    CmdBuild filePath -> buildFile opts dataDir filePath+      | optAterm opts -> evalExprAterm (chosenStoreDir opts) (optNixPaths opts) dataDir expr+      | otherwise -> evalExpr (chosenStoreDir opts) (optStrict opts) (optNixPaths opts) dataDir expr+    CmdBuild target attrPath -> buildCommand opts dataDir target attrPath     CmdPush pushArgs -> pushCommand opts pushArgs-    CmdHelp -> do-      hPutStrLn stderr "Usage: nova-nix [--nix-path NAME=PATH] <command>"-      hPutStrLn stderr ""-      hPutStrLn stderr "Commands:"-      hPutStrLn stderr "  eval FILE.nix          Evaluate a .nix file, print result"-      hPutStrLn stderr "  eval --expr 'EXPR'     Evaluate an inline expression"-      hPutStrLn stderr "  build FILE.nix         Build a derivation from a .nix file"-      hPutStrLn stderr "  push --cache URL       Push store paths (and their closures) to a binary cache"-      hPutStrLn stderr ""-      hPutStrLn stderr "Flags:"-      hPutStrLn stderr "  --strict               Deep-force all thunks before printing (warning: OOM on large results)"-      hPutStrLn stderr "  --aterm                With eval --expr, print the derivation's .drv ATerm"-      hPutStrLn stderr "  --nix-path NAME=PATH   Add search path (repeatable, merged with NIX_PATH)"-      hPutStrLn stderr "  --all                  With push: select every valid path in the store"-      hPutStrLn stderr "  --key-file PATH        With push: file holding the cache API key"-      hPutStrLn stderr "  --store DIR            Use DIR as the store (default: the platform store)"-      hPutStrLn stderr "  --substituter URL      Try this binary cache before building"-      hPutStrLn stderr "  --trusted-key K        Public key (name:base64) for the substituter"-      exitFailure+    CmdStoreDelete paths -> storeDeleteCommand opts paths+    CmdUsage -> mapM_ (hPutStrLn stderr) usageLines >> exitFailure+    CmdHelp -> mapM_ putStrLn usageLines+    CmdVersion -> putStrLn versionLine +-- | What @--version@ reports.  Cabal's version, which is the one the publish+-- workflow's tag guard checks a tag against, so a downloaded binary names+-- exactly the release it came from and a bug report can say which build.+versionLine :: String+versionLine = "nova-nix " <> showVersion version++-- | The usage text, returned rather than printed: a bare invocation is a+-- usage error (stderr, non-zero) while @--help@ is a request that succeeded+-- (stdout, zero), and the words are the same either way.+usageLines :: [String]+usageLines =+  [ "Usage: nova-nix [--nix-path NAME=PATH] <command>",+    "",+    "Commands:",+    "  eval FILE.nix          Evaluate a .nix file, print result",+    "  eval --expr 'EXPR'     Evaluate an inline expression",+    "  build FILE.nix         Build a derivation from a .nix file",+    "  build --expr 'EXPR'    Build a derivation from an inline expression",+    "  push --cache URL       Push store paths (and their closures) to a binary cache",+    "  store delete PATH...   Remove store paths, refused while other valid paths reference them",+    "",+    "Flags:",+    "  --strict               Deep-force all thunks before printing (warning: OOM on large results)",+    "  --aterm                With eval --expr, print the derivation's .drv ATerm",+    "  -A, --attr ATTRPATH    With build: select a dotted attribute path (a.b.c)",+    "  --nix-path NAME=PATH   Add search path (repeatable, merged with NIX_PATH)",+    "  --all                  With push: select every valid path in the store",+    "  --key-file PATH        With push: file holding the cache API key",+    "  --compression KIND     With push: artifact packaging (" <> T.unpack pushCompressionValues <> "; default none)",+    "  --exec-wrapper S=PATH  Run system S's derivations through PATH (repeatable),",+    "                         e.g. --exec-wrapper x86_64-windows=/usr/bin/wine",+    "  --store DIR            Use DIR as the store (default: the platform store)",+    "  --substituter URL      Try this binary cache before building",+    "  --trusted-key K        Public key (name:base64) for the substituter",+    "",+    "  substituters and trusted-public-keys also read from",+    "  $XDG_CONFIG_HOME/nix/nix.conf and $NIX_CONFIG; the flags above",+    "  add to whatever those configure.",+    "",+    "  --help                 Print this text and exit",+    "  --version              Print the version and exit"+  ]++-- | Canonicalize and read a source file.  The canonicalization matters:+-- relative path literals inside the file resolve against the file's+-- directory ('esBaseDir'), and a relative base dir would be re-prefixed on+-- every resolution (doubling it).  An unreadable argument (missing file,+-- a directory, no permission) is a clean CLI error, never an uncaught+-- exception.  Only 'IOException' is caught: an interrupt or any other+-- async exception must abort the run, not print as a read failure.+readSourceFile :: FilePath -> IO (FilePath, T.Text)+readSourceFile rawPath = do+  attempt <- try $ do+    path <- canonicalizePath rawPath+    source <- readFileAutoEncoding path+    pure (path, source)+  case attempt of+    Left (e :: IOException) ->+      failWith ("cannot read " <> T.pack rawPath <> ": " <> T.pack (displayException e))+    Right ok -> pure ok++-- | What a parse error names when the source came from @--expr@ and there is+-- no file to point at.+exprSourceName :: T.Text+exprSourceName = "<expr>"+ -- | Evaluate a .nix file and print the result.------ The file argument is canonicalized first: relative path literals inside the--- file resolve against the file's directory ('esBaseDir'), and a relative base--- dir would be re-prefixed on every resolution (doubling it).-evalFile :: Bool -> [T.Text] -> FilePath -> FilePath -> IO ()-evalFile strict extraPaths dataDir rawFilePath = do-  filePath <- canonicalizePath rawFilePath-  source <- readFileAutoEncoding filePath-  case parseNix (T.pack filePath) source of+evalFile :: StoreDir -> Bool -> [T.Text] -> FilePath -> FilePath -> IO ()+evalFile storeDir strict extraPaths dataDir rawFilePath = do+  (filePath, source) <- readSourceFile rawFilePath+  case parseNix (takeDirectory filePath) (T.pack filePath) source of     Left err -> do       hPutStrLn stderr ("parse error: " ++ show err)       exitFailure     Right expr -> do-      st0 <- newEvalState (takeDirectory filePath)+      st0 <- newEvalState storeDir (takeDirectory filePath)       let searchPaths = mergeSearchPaths extraPaths dataDir (esSearchPaths st0)           st = st0 {esSearchPaths = searchPaths}       result <-@@ -196,15 +406,15 @@         Right forced -> TIO.putStrLn (prettyValue forced)  -- | Evaluate an inline expression and print the result.-evalExpr :: Bool -> [T.Text] -> FilePath -> T.Text -> IO ()-evalExpr strict extraPaths dataDir source = do-  case parseNix "<expr>" source of+evalExpr :: StoreDir -> Bool -> [T.Text] -> FilePath -> T.Text -> IO ()+evalExpr storeDir strict extraPaths dataDir source = do+  cwd <- getCurrentDirectory+  case parseNix cwd exprSourceName source of     Left err -> do       hPutStrLn stderr ("parse error: " ++ show err)       exitFailure     Right expr -> do-      cwd <- getCurrentDirectory-      st0 <- newEvalState cwd+      st0 <- newEvalState storeDir cwd       let searchPaths = mergeSearchPaths extraPaths dataDir (esSearchPaths st0)           st = st0 {esSearchPaths = searchPaths}       result <-@@ -218,25 +428,20 @@  -- | Evaluate an inline expression to a derivation and print its ATerm (.drv -- contents), for diffing nova-nix's serialization against upstream Nix.-evalExprAterm :: [T.Text] -> FilePath -> T.Text -> IO ()-evalExprAterm extraPaths dataDir source =-  case parseNix "<expr>" source of+evalExprAterm :: StoreDir -> [T.Text] -> FilePath -> T.Text -> IO ()+evalExprAterm storeDir extraPaths dataDir source = do+  cwd <- getCurrentDirectory+  case parseNix cwd exprSourceName source of     Left err -> do       hPutStrLn stderr ("parse error: " ++ show err)       exitFailure     Right expr -> do-      cwd <- getCurrentDirectory-      st0 <- newEvalState cwd+      st0 <- newEvalState storeDir cwd       let searchPaths = mergeSearchPaths extraPaths dataDir (esSearchPaths st0)           st = st0 {esSearchPaths = searchPaths}       result <- runEvalIO st $ do         val <- eval (builtinEnv (esTimestamp st) searchPaths) expr-        case val of-          VAttrs attrs ->-            mapM_-              (\k -> maybe (pure ()) (void . force) (attrSetLookup k attrs))-              ["_derivation", "drvPath"]-          _ -> pure ()+        forceDerivationAttrs val         pure val       case result of         Left err -> do@@ -244,51 +449,86 @@           exitFailure         Right val -> do           (drv, _) <- extractDerivation val-          TIO.putStrLn (toATerm drv)+          -- Raw ATerm bytes (BC.putStrLn bypasses the handle encoding),+          -- so the printed .drv diffs byte-exactly against upstream's.+          BC.putStrLn (toATerm drv)  -- | Parse, evaluate, extract derivation, build, and print result. -- The file argument is canonicalized for the same reason as in 'evalFile'.-buildFile :: CliOpts -> FilePath -> FilePath -> IO ()-buildFile opts dataDir rawFilePath = do-  caches <- either failWith pure (substituterConfig (optSubstituter opts) (optTrustedKey opts))-  filePath <- canonicalizePath rawFilePath-  source <- readFileAutoEncoding filePath-  case parseNix (T.pack filePath) source of+-- | Where a build reads its expression, and the directory relative paths+-- inside it resolve against.+loadBuildSource :: BuildTarget -> IO (FilePath, T.Text, T.Text)+loadBuildSource (TargetFile rawFilePath) = do+  (filePath, source) <- readSourceFile rawFilePath+  pure (takeDirectory filePath, T.pack filePath, source)+loadBuildSource (TargetExpr source) = do+  cwd <- getCurrentDirectory+  pure (cwd, exprSourceName, source)++-- | Force the attributes 'extractDerivation' goes on to read.  @derivation@+-- is a lazy wrapper, and 'readThunkValue' answers 'Nothing' for a thunk that+-- was never forced, so skipping this reports a real derivation as not one.+forceDerivationAttrs :: (MonadEval m) => NixValue -> m ()+forceDerivationAttrs val = case val of+  VAttrs attrs ->+    mapM_+      (\k -> maybe (pure ()) (void . force) (attrSetLookup k attrs))+      derivationAttrKeys+  _ -> pure ()++-- | What a build needs forced: the marker, the wrapper, and the path whose+-- closure the build driver writes.+derivationAttrKeys :: [T.Text]+derivationAttrKeys = ["type", "_derivation", "drvPath"]++buildCommand :: CliOpts -> FilePath -> BuildTarget -> Maybe T.Text -> IO ()+buildCommand opts dataDir target attrPath = do+  let storeDir = chosenStoreDir opts+  configSources <- loadConfigSources+  caches <- either failWith pure (resolveCaches configSources (optSubstituter opts) (optTrustedKey opts))+  wrappers <- either failWith pure (execWrapperConfig (optExecWrappers opts)) >>= checkExecWrappers+  (baseDir, sourceName, source) <- loadBuildSource target+  case parseNix baseDir sourceName source of     Left err -> do       hPutStrLn stderr ("parse error: " ++ show err)       exitFailure     Right expr -> do-      st0 <- newEvalState (takeDirectory filePath)+      st0 <- newEvalState storeDir baseDir       let searchPaths = mergeSearchPaths (optNixPaths opts) dataDir (esSearchPaths st0)           st = st0 {esSearchPaths = searchPaths}       result <- runEvalIO st $ do-        val <- eval (builtinEnv (esTimestamp st) searchPaths) expr-        -- 'derivation' is a lazy wrapper now; force the attrs extractDerivation-        -- reads (a build legitimately needs the drvPath + closure).-        case val of-          VAttrs attrs ->-            mapM_-              (\k -> maybe (pure ()) (void . force) (attrSetLookup k attrs))-              ["_derivation", "drvPath"]-          _ -> pure ()-        pure val+        root <- eval (builtinEnv (esTimestamp st) searchPaths) expr+        selected <- case attrPath of+          Nothing -> pure (Right root)+          Just path -> selectAttrPath path root+        -- Forced after selection, not before: forcing the root leaves the+        -- selected value's own attributes unforced, and extractDerivation+        -- then reports a real derivation as not being one.+        either (pure . Left) (\val -> Right val <$ forceDerivationAttrs val) selected       case result of         Left err -> do           TIO.hPutStrLn stderr ("eval error: " <> err)           exitFailure-        Right val -> do+        Right (Left selectionErr) -> do+          TIO.hPutStrLn stderr ("error: " <> selectionErr)+          exitFailure+        Right (Right val) -> do           (drv, drvSP) <- extractDerivation val           -- The full .drv closure (root + every transitive input) recorded           -- during evaluation; written to the store before building.           drvClosure <- readIORef (esDrvClosure st)           sourceCache <- readIORef (esSourcePathCache st)+          storeWrites <- readIORef (esStoreWriteCache st)           store <- openStore (chosenStoreDir opts)           -- Materialize eval-coerced source paths (src = ./file, path           -- interpolation): evaluation computes their store paths as text           -- only - the parity runner's store is not writable - so the build           -- driver performs the copy and registration.           materializeEvalSources store sourceCache-          buildResult <- buildAndRegister store caches drvClosure drv drvSP+          -- builtins.toFile wrote these during evaluation but could not+          -- register them; a derivation naming one needs them valid first.+          materializeEvalStoreWrites store storeWrites+          buildResult <- buildAndRegister store caches wrappers drvClosure drv drvSP           closeStore store           case buildResult of             BuildSuccess sp ->@@ -309,7 +549,6 @@     _ -> do       hPutStrLn stderr "error: result is not a derivation (no type = \"derivation\")"       exitFailure-  -- Extract the Derivation struct from _derivation   drv <- case attrSetLookup "_derivation" attrs of     Just thunk | Just (VDerivation d) <- readThunkValue thunk -> pure d     _ -> do@@ -317,12 +556,16 @@       exitFailure   -- Extract drvPath - this is the store path of the .drv file itself,   -- computed by hashing the ATerm serialization during evaluation.+  -- Store paths are ASCII, so the byte payload decodes strictly.   drvSP <- case attrSetLookup "drvPath" attrs of-    Just thunk | Just (VStr path _) <- readThunkValue thunk -> case parseStorePath defaultStoreDir path of-      Just sp -> pure sp-      Nothing -> do-        TIO.hPutStrLn stderr ("error: invalid drvPath: " <> path)-        exitFailure+    Just thunk+      | Just (VStr pathBytes _) <- readThunkValue thunk,+        Right path <- TE.decodeUtf8' pathBytes ->+          case parseStorePath defaultStoreDir path of+            Just sp -> pure sp+            Nothing -> do+              TIO.hPutStrLn stderr ("error: invalid drvPath: " <> path)+              exitFailure     _ -> do       hPutStrLn stderr "error: derivation result missing drvPath"       exitFailure@@ -335,31 +578,91 @@ chosenStoreDir :: CliOpts -> StoreDir chosenStoreDir opts = maybe platformStoreDir StoreDir (optStore opts) --- | Default priority for a CLI-configured substituter (cache.nixos.org is 40).+-- | Default priority for a config- or CLI-configured substituter+-- (cache.nixos.org is 40). substituterPriority :: Int substituterPriority = 50 --- | Build the cache list from @--substituter@\/@--trusted-key@.  Both or--- neither: a substituter without a trusted key would skip signature--- verification, and a key without a substituter is a mistake.-substituterConfig :: Maybe String -> Maybe String -> Either T.Text [CacheConfig]-substituterConfig Nothing Nothing = Right []-substituterConfig Nothing (Just _) = Left "--trusted-key requires --substituter"-substituterConfig (Just _) Nothing = Left "--substituter requires --trusted-key (name:base64)"-substituterConfig (Just url) (Just key) =-  Right-    [ CacheConfig-        { ccUrl = T.dropWhileEnd (== '/') (T.pack url),-          ccPublicKey = T.pack key,-          ccPriority = substituterPriority-        }-    ]+-- | Turn resolved settings into the cache list.  One 'CacheConfig' per+-- substituter, each carrying the whole trusted-key set: upstream's keys+-- are not bound to a substituter, so a narinfo from any cache is accepted+-- by any trusted key.  A substituter with no trusted key anywhere is not+-- refused here (it simply accepts nothing at the signature gate), matching+-- upstream, where @substituters@ and @trusted-public-keys@ are independent.+configToCaches :: NixConfig -> [CacheConfig]+configToCaches config =+  [ CacheConfig+      { ccUrl = T.dropWhileEnd (== '/') url,+        ccPublicKeys = ncTrustedPublicKeys config,+        ccPriority = substituterPriority+      }+  | url <- ncSubstituters config+  ] +-- | Resolve the caches from the config sources plus the CLI flags.  The+-- sources are ordered weakest first (user file, then @NIX_CONFIG@); the+-- CLI @--substituter@ and @--trusted-key@ append on top, the highest+-- precedence, so a flag adds to the configured set rather than being+-- overridden by it.+resolveCaches :: [T.Text] -> Maybe String -> Maybe String -> Either T.Text [CacheConfig]+resolveCaches sources mUrl mKey = do+  base <- Config.resolveConfig sources+  let withCli =+        base+          { ncSubstituters = ncSubstituters base ++ maybe [] (\url -> [T.pack url]) mUrl,+            ncTrustedPublicKeys = ncTrustedPublicKeys base ++ maybe [] (\key -> [T.pack key]) mKey+          }+  pure (configToCaches withCli)++-- | The nix.conf sources, weakest first: the user file, then @NIX_CONFIG@.+-- Reading is best effort - a missing or unreadable file is simply absent -+-- but a file that IS read and does not parse is a hard error downstream,+-- so a malformed security-relevant setting cannot pass for no setting.+loadConfigSources :: IO [T.Text]+loadConfigSources = do+  userFile <- readUserConfigFile+  nixConfigEnv <- lookupEnv nixConfigVar+  pure (catMaybes [userFile, T.pack <$> nixConfigEnv])++-- | Read @$XDG_CONFIG_HOME\/nix\/nix.conf@ (the user config file), or+-- 'Nothing' when it is absent or unreadable.+readUserConfigFile :: IO (Maybe T.Text)+readUserConfigFile = do+  dir <- getXdgDirectory XdgConfig "nix"+  let path = dir </> "nix.conf"+  present <- doesFileExist path+  if not present+    then pure Nothing+    else do+      result <- try (BS.readFile path) :: IO (Either IOException BS.ByteString)+      pure (either (const Nothing) (Just . TE.decodeUtf8Lenient) result)++-- | Resolve every launcher before any building starts, so a typo'd path is+-- a configuration error now rather than a build failure after the whole+-- closure has been realized.  A bare name resolves through @PATH@, the way+-- a shell would; anything else has to be an executable file where it says.+checkExecWrappers :: Map.Map T.Text FilePath -> IO (Map.Map T.Text FilePath)+checkExecWrappers = Map.traverseWithKey check+  where+    check system path+      | path == takeFileName path =+          findExecutable path+            >>= maybe (failWith ("--exec-wrapper " <> system <> ": " <> T.pack path <> " is not on PATH")) pure+      | otherwise = do+          there <- doesFileExist path+          if not there+            then failWith ("--exec-wrapper " <> system <> ": " <> T.pack path <> " does not exist")+            else do+              perms <- getPermissions path+              if executable perms+                then pure path+                else failWith ("--exec-wrapper " <> system <> ": " <> T.pack path <> " is not executable")+ -- | Write the .drv file to the store and build with dependency resolution. -- The drvPath is the store path of the .drv file itself, extracted from -- the evaluation result alongside the Derivation struct.-buildAndRegister :: Store -> [CacheConfig] -> Map.Map T.Text T.Text -> Derivation -> StorePath -> IO BuildResult-buildAndRegister store caches drvClosure drv drvSP = do+buildAndRegister :: Store -> [CacheConfig] -> Map.Map T.Text FilePath -> Map.Map T.Text BS.ByteString -> Derivation -> StorePath -> IO BuildResult+buildAndRegister store caches wrappers drvClosure drv drvSP = do   -- Materialize the full input-.drv closure (every transitive dependency's   -- recipe) to the store.  buildWithDeps reads these back to construct the   -- dependency graph; without them it cannot realize any non-leaf derivation.@@ -372,7 +675,8 @@   let config =         (defaultBuildConfig (stDir store))           { bcTmpDir = tmpDir,-            bcCaches = caches+            bcCaches = caches,+            bcExecWrappers = wrappers           }   buildWithDeps config store drv drvSP @@ -395,6 +699,9 @@     Just path -> do       loaded <- loadApiKeyFile path       either failWith (pure . Just) loaded+  compression <- case paCompressionArg pushArgs of+    Nothing -> pure PushNone+    Just value -> either (failWith . ("push: " <>)) pure (parsePushCompression (T.pack value))   store <- openStore (chosenStoreDir opts)   rootsResult <- resolvePushRoots store pushArgs   case rootsResult of@@ -402,7 +709,7 @@       closeStore store       failWith err     Right roots -> do-      result <- pushPaths (PushConfig cacheUrl apiKey) store roots+      result <- pushPaths (PushConfig cacheUrl apiKey compression) store roots       closeStore store       case result of         Left err -> failWith ("push failed: " <> err)@@ -415,6 +722,32 @@                 <> " already cached"             ) +-- | Delete store paths: registration rows and on-disk trees.  Paths are+-- processed in argument order and the first failure stops the run, so a+-- reference chain deletes leaf-first in one invocation.+storeDeleteCommand :: CliOpts -> [String] -> IO ()+storeDeleteCommand opts rawPaths = do+  store <- openStore (chosenStoreDir opts)+  result <- deleteEach store rawPaths+  closeStore store+  either failWith pure result+  where+    deleteEach _ [] = pure (Right ())+    deleteEach store (raw : rest) =+      case resolveDeleteTarget (stDir store) (T.pack raw) of+        Left err -> pure (Left ("store delete: " <> err))+        Right basename -> do+          outcome <- deleteStorePathRaw store basename+          case outcome of+            Left err -> pure (Left ("store delete: " <> err))+            Right removed -> do+              TIO.putStrLn ("deleted " <> basename <> describeOutcome removed)+              deleteEach store rest+    describeOutcome removed+      | doRowRemoved removed && doTreeRemoved removed = ""+      | doRowRemoved removed = " (no tree on disk)"+      | otherwise = " (unregistered tree)"+ -- | Resolve push roots: every valid path with @--all@, otherwise each named -- path.  Named paths may be full store paths in either store-dir form, or a -- bare @hash-name@ basename.@@ -425,25 +758,24 @@       pure (traverse parseDbPath pathTexts)   | otherwise = pure (traverse parseArgPath (paPaths pushArgs))   where+    -- DB rows are rendered with the OPENED store's dir - parsing against+    -- platformStoreDir made 'push --all --store DIR' fail on every row of+    -- a non-default store.     parseDbPath txt =-      maybe (Left ("unparseable store DB path: " <> txt)) Right (parseStorePath platformStoreDir txt)+      maybe (Left ("unparseable store DB path: " <> txt)) Right (parseStorePath (stDir store) txt)     parseArgPath raw =       let txt = T.pack raw           attempts =-            [ parseStorePath platformStoreDir txt,+            [ parseStorePath (stDir store) txt,+              parseStorePath platformStoreDir txt,               parseStorePath defaultStoreDir txt,-              parseBareBasename txt+              -- A bare basename passes the same charset gate as every+              -- other spelling: push targets are real store paths.+              parseStorePathBaseName txt             ]        in case catMaybes attempts of             (sp : _) -> Right sp             [] -> Left ("not a store path: " <> txt)-    parseBareBasename txt-      | T.length txt >= storePathHashLen + 2,-        (hashPart, rest) <- T.splitAt storePathHashLen txt,-        Just ('-', name) <- T.uncons rest,-        not (T.null name) =-          Just (StorePath hashPart name)-      | otherwise = Nothing  -- | Print an error to stderr and exit. failWith :: T.Text -> IO a@@ -451,51 +783,6 @@   TIO.hPutStrLn stderr msg   exitFailure --- | Copy eval-coerced source paths into the store and register them.  The--- evaluator's source-path cache maps each coerced filesystem path to its--- @source@ fixed-output store path (text only - eval performs no store--- writes).  Each entry not already valid is copied in, made read-only, and--- registered with its real NAR hash.  A copied source carries no references.-materializeEvalSources :: Store -> Map.Map T.Text T.Text -> IO ()-materializeEvalSources store sourceCache = do-  regs <- catMaybes <$> mapM adopt (Map.toList sourceCache)-  registerPaths (stDB store) regs-  where-    adopt (rawPath, spText) =-      case parseStorePath defaultStoreDir spText of-        Nothing -> pure Nothing-        Just sp -> do-          valid <- isValid store sp-          if valid-            then pure Nothing-            else do-              let dest = storePathToFilePath (stDir store) sp-              copyPathInto (T.unpack rawPath) dest-              setReadOnly dest-              Just <$> registrationFor store sp Nothing []---- | Recursively copy a file or directory tree to a destination path.-copyPathInto :: FilePath -> FilePath -> IO ()-copyPathInto src dest = do-  isDir <- doesDirectoryExist src-  if isDir-    then do-      createDirectoryIfMissing True dest-      names <- listDirectory src-      mapM_ (\name -> copyPathInto (src FP.</> name) (dest FP.</> name)) names-    else copyFile src dest---- | Write every recorded @.drv@ ATerm (keyed by its store-path text) to the--- store.  Keys come from evaluation via 'storePathToText' so they always parse;--- an unparseable key is skipped defensively.-writeDrvClosure :: Store -> Map.Map T.Text T.Text -> IO ()-writeDrvClosure store = mapM_ writeOne . Map.toList-  where-    writeOne (pathText, aterm) =-      case parseStorePath defaultStoreDir pathText of-        Just sp -> writeDrvAterm store sp aterm-        Nothing -> pure ()- -- --------------------------------------------------------------------------- -- Output formatting -- ---------------------------------------------------------------------------@@ -532,14 +819,14 @@ prettyValue (VBool True) = "true" prettyValue (VBool False) = "false" prettyValue VNull = "null"-prettyValue (VStr s _) = "\"" <> escapeNixString s <> "\""+prettyValue (VStr s _) = "\"" <> escapeNixString (bytesToTextLossy s) <> "\"" prettyValue (VPath p) = p prettyValue (VList cl) =-  "[ " <> T.intercalate " " (map (prettyThunk . Thunk) (clistThunks cl)) <> " ]"+  wrapNixSeq "[" "]" (map (prettyThunk . Thunk) (clistThunks cl)) prettyValue (VAttrs attrs) =   let entries = attrSetToAscList attrs       rendered = map (\(k, t) -> k <> " = " <> prettyThunk t <> ";") entries-   in "{ " <> T.intercalate " " rendered <> " }"+   in wrapNixSeq "{" "}" rendered prettyValue (VLambda {}) = "<lambda>" prettyValue (VBuiltin name _) = "<builtin " <> name <> ">" prettyValue (VCompiledRegex _) = "<compiled-regex>"@@ -547,6 +834,14 @@   case drvOutputs drv of     (out : _) -> "<derivation " <> T.pack (storePathToFilePath platformStoreDir (doPath out)) <> ">"     [] -> "<derivation>"++-- | Render a bracketed sequence the way upstream prints one: the brackets are+-- separated from the contents by a space, and an empty sequence is @[ ]@ or+-- @{ }@ rather than the two spaces that a bare join of no elements leaves+-- between them.+wrapNixSeq :: T.Text -> T.Text -> [T.Text] -> T.Text+wrapNixSeq open close [] = open <> " " <> close+wrapNixSeq open close parts = open <> " " <> T.intercalate " " parts <> " " <> close  -- | Pretty-print a thunk.  After deep-forcing, all thunks should be -- computed thunks render their value; pending thunks render as a placeholder.
cbits/nn_bytecode.h view
@@ -48,6 +48,7 @@ #define NN_OP_UNARY         21 #define NN_OP_BINARY        22 #define NN_OP_SEARCH_PATH   23+#define NN_OP_PATH_STR      24  /* --- UnaryOp flags (NN_OP_UNARY) --- */ @@ -104,7 +105,13 @@ typedef struct nn_op {     uint8_t  opcode;     /* Which Expr constructor */     uint8_t  flags;      /* Sub-type: BinaryOp, UnaryOp, formal type, etc. */-    uint16_t short_arg;  /* Small immediate: count, bool flag, etc. */+    uint16_t short_arg;  /* Small immediate: count, bool flag, etc.+                            Payload counts use a spill convention so the+                            op stays 16 bytes: 0xFFFF here means the true+                            count is the FIRST uint32 of the op's data+                            region and the payload starts one word later+                            (emit/decode live on the Haskell side:+                            Nix.Eval.CBytecode.cbcCountedPayload). */     uint32_t arg1;       /* Child index, symbol, data offset, lo32, etc. */     uint32_t arg2;       /* Child index, symbol, hi32, etc. */     uint32_t arg3;       /* Child index, data offset, etc. */@@ -122,11 +129,15 @@  /* --- Emit --- */ -/* Append one instruction.  Returns the instruction index. */+/* Append one instruction.  Returns the instruction index, or+ * UINT32_MAX when the array cannot grow (allocation failure or the+ * uint32 index ceiling) - the caller must check before using the+ * result as an index. */ uint32_t nn_bc_emit(uint8_t opcode, uint8_t flags, uint16_t short_arg,                     uint32_t arg1, uint32_t arg2, uint32_t arg3); -/* Append one uint32 to the data buffer.  Returns the data offset. */+/* Append one uint32 to the data buffer.  Returns the data offset, or+ * UINT32_MAX on failure (same contract as nn_bc_emit). */ uint32_t nn_bc_emit_data(uint32_t value);  /* --- Read instructions --- */
cbits/nn_ctxstr.c view
@@ -43,10 +43,16 @@ /* --- Lifecycle --- */  nn_ctxstr_t *-nn_ctxstr_new(uint32_t text, uint16_t ctx_count)+nn_ctxstr_new(uint32_t text, uint32_t ctx_count) {-    size_t size = sizeof(nn_ctxstr_t) + (size_t)ctx_count * sizeof(nn_sce_t);-    nn_ctxstr_t *s = (nn_ctxstr_t *)malloc(size);+    /* The count is input-reachable (it scales with the evaluated+     * expression); computing the size in uint64_t and capping it at the+     * UINT32_MAX byte limit used across the C layer keeps the malloc+     * argument from wrapping size_t on any platform. */+    uint64_t bytes64 = sizeof(nn_ctxstr_t)+                     + (uint64_t)ctx_count * sizeof(nn_sce_t);+    if (bytes64 > UINT32_MAX) return NULL;+    nn_ctxstr_t *s = (nn_ctxstr_t *)malloc((size_t)bytes64);     if (!s) return NULL;     s->text = text;     s->ctx_count = ctx_count;@@ -70,11 +76,16 @@  /* --- Element setters --- */ +/* Element bounds stay live under NDEBUG: the fill index derives from+ * input-sized data crossing the FFI, so a violated bound must drop the+ * write, not run past the sized array. */+ void-nn_ctxstr_set_plain(nn_ctxstr_t *s, uint16_t idx,+nn_ctxstr_set_plain(nn_ctxstr_t *s, uint32_t idx,                     uint32_t sp_hash, uint32_t sp_name) {-    NN_ASSERT(idx < s->ctx_count, "nn_ctxstr_set_plain: idx out of bounds");+    NN_ASSERT(s != NULL && idx < s->ctx_count, "nn_ctxstr_set_plain: idx out of bounds");+    if (s == NULL || idx >= s->ctx_count) return;     s->ctx[idx].tag = NN_SCE_PLAIN;     s->ctx[idx].sp_hash = sp_hash;     s->ctx[idx].sp_name = sp_name;@@ -82,11 +93,12 @@ }  void-nn_ctxstr_set_drv_output(nn_ctxstr_t *s, uint16_t idx,+nn_ctxstr_set_drv_output(nn_ctxstr_t *s, uint32_t idx,                          uint32_t sp_hash, uint32_t sp_name,                          uint32_t output) {-    NN_ASSERT(idx < s->ctx_count, "nn_ctxstr_set_drv_output: idx out of bounds");+    NN_ASSERT(s != NULL && idx < s->ctx_count, "nn_ctxstr_set_drv_output: idx out of bounds");+    if (s == NULL || idx >= s->ctx_count) return;     s->ctx[idx].tag = NN_SCE_DRV_OUTPUT;     s->ctx[idx].sp_hash = sp_hash;     s->ctx[idx].sp_name = sp_name;@@ -94,10 +106,11 @@ }  void-nn_ctxstr_set_all_outputs(nn_ctxstr_t *s, uint16_t idx,+nn_ctxstr_set_all_outputs(nn_ctxstr_t *s, uint32_t idx,                           uint32_t sp_hash, uint32_t sp_name) {-    NN_ASSERT(idx < s->ctx_count, "nn_ctxstr_set_all_outputs: idx out of bounds");+    NN_ASSERT(s != NULL && idx < s->ctx_count, "nn_ctxstr_set_all_outputs: idx out of bounds");+    if (s == NULL || idx >= s->ctx_count) return;     s->ctx[idx].tag = NN_SCE_ALL_OUTPUTS;     s->ctx[idx].sp_hash = sp_hash;     s->ctx[idx].sp_name = sp_name;@@ -112,36 +125,43 @@     return s->text; } -uint16_t+uint32_t nn_ctxstr_ctx_count(const nn_ctxstr_t *s) {     return s->ctx_count; } +/* Element reads keep their bound live under NDEBUG (see setters);+ * a violated bound returns 0 rather than reading out of bounds. */+ uint8_t-nn_ctxstr_elem_tag(const nn_ctxstr_t *s, uint16_t idx)+nn_ctxstr_elem_tag(const nn_ctxstr_t *s, uint32_t idx) {-    NN_ASSERT(idx < s->ctx_count, "nn_ctxstr_elem_tag: idx out of bounds");+    NN_ASSERT(s != NULL && idx < s->ctx_count, "nn_ctxstr_elem_tag: idx out of bounds");+    if (s == NULL || idx >= s->ctx_count) return 0;     return s->ctx[idx].tag; }  uint32_t-nn_ctxstr_elem_hash(const nn_ctxstr_t *s, uint16_t idx)+nn_ctxstr_elem_hash(const nn_ctxstr_t *s, uint32_t idx) {-    NN_ASSERT(idx < s->ctx_count, "nn_ctxstr_elem_hash: idx out of bounds");+    NN_ASSERT(s != NULL && idx < s->ctx_count, "nn_ctxstr_elem_hash: idx out of bounds");+    if (s == NULL || idx >= s->ctx_count) return 0;     return s->ctx[idx].sp_hash; }  uint32_t-nn_ctxstr_elem_name(const nn_ctxstr_t *s, uint16_t idx)+nn_ctxstr_elem_name(const nn_ctxstr_t *s, uint32_t idx) {-    NN_ASSERT(idx < s->ctx_count, "nn_ctxstr_elem_name: idx out of bounds");+    NN_ASSERT(s != NULL && idx < s->ctx_count, "nn_ctxstr_elem_name: idx out of bounds");+    if (s == NULL || idx >= s->ctx_count) return 0;     return s->ctx[idx].sp_name; }  uint32_t-nn_ctxstr_elem_output(const nn_ctxstr_t *s, uint16_t idx)+nn_ctxstr_elem_output(const nn_ctxstr_t *s, uint32_t idx) {-    NN_ASSERT(idx < s->ctx_count, "nn_ctxstr_elem_output: idx out of bounds");+    NN_ASSERT(s != NULL && idx < s->ctx_count, "nn_ctxstr_elem_output: idx out of bounds");+    if (s == NULL || idx >= s->ctx_count) return 0;     return s->ctx[idx].output; }
cbits/nn_ctxstr.h view
@@ -44,14 +44,16 @@ } nn_sce_t;  /* A string with context.  text is an interned nn_symbol_t for the- * string content.  ctx_count is the number of context elements.+ * string content.  ctx_count is the number of context elements; it is+ * a full uint32_t because context sets scale with the evaluated+ * expression (a narrower count would wrap and undersize the array).  * ctx[] is a C99 flexible array of context elements, sorted by  * (tag, sp_hash, sp_name, output) for deterministic comparison.  *  * Single contiguous allocation: header + elements. */ typedef struct nn_ctxstr {     uint32_t    text;-    uint16_t    ctx_count;+    uint32_t    ctx_count;     nn_sce_t    ctx[]; } nn_ctxstr_t; @@ -59,8 +61,10 @@  /* Allocate a new nn_ctxstr_t with space for ctx_count elements.  * Elements are uninitialized - caller must fill via nn_ctxstr_set_*.- * Tracked globally for bulk cleanup. */-nn_ctxstr_t *nn_ctxstr_new(uint32_t text, uint16_t ctx_count);+ * Tracked globally for bulk cleanup.  Returns NULL on allocation+ * failure or if the element array size would overflow - the caller+ * must check. */+nn_ctxstr_t *nn_ctxstr_new(uint32_t text, uint32_t ctx_count);  /* Free all tracked nn_ctxstr_t allocations.  Called during arena  * teardown, after thunk iteration but before env/symbol cleanup. */@@ -68,24 +72,30 @@  /* --- Element setters --- */ -void nn_ctxstr_set_plain(nn_ctxstr_t *s, uint16_t idx,+/* idx must be < ctx_count.  A NULL struct or out-of-range idx is+ * dropped (asserts in debug builds) rather than writing out of+ * bounds - the bound stays live under NDEBUG. */++void nn_ctxstr_set_plain(nn_ctxstr_t *s, uint32_t idx,                          uint32_t sp_hash, uint32_t sp_name); -void nn_ctxstr_set_drv_output(nn_ctxstr_t *s, uint16_t idx,+void nn_ctxstr_set_drv_output(nn_ctxstr_t *s, uint32_t idx,                               uint32_t sp_hash, uint32_t sp_name,                               uint32_t output); -void nn_ctxstr_set_all_outputs(nn_ctxstr_t *s, uint16_t idx,+void nn_ctxstr_set_all_outputs(nn_ctxstr_t *s, uint32_t idx,                                uint32_t sp_hash, uint32_t sp_name);  /* --- Accessors --- */  uint32_t nn_ctxstr_text(const nn_ctxstr_t *s);-uint16_t nn_ctxstr_ctx_count(const nn_ctxstr_t *s);+uint32_t nn_ctxstr_ctx_count(const nn_ctxstr_t *s); -uint8_t  nn_ctxstr_elem_tag(const nn_ctxstr_t *s, uint16_t idx);-uint32_t nn_ctxstr_elem_hash(const nn_ctxstr_t *s, uint16_t idx);-uint32_t nn_ctxstr_elem_name(const nn_ctxstr_t *s, uint16_t idx);-uint32_t nn_ctxstr_elem_output(const nn_ctxstr_t *s, uint16_t idx);+/* Element reads with idx >= ctx_count return 0 (asserts in debug+ * builds) rather than reading out of bounds. */+uint8_t  nn_ctxstr_elem_tag(const nn_ctxstr_t *s, uint32_t idx);+uint32_t nn_ctxstr_elem_hash(const nn_ctxstr_t *s, uint32_t idx);+uint32_t nn_ctxstr_elem_name(const nn_ctxstr_t *s, uint32_t idx);+uint32_t nn_ctxstr_elem_output(const nn_ctxstr_t *s, uint32_t idx);  #endif /* NN_CTXSTR_H */
cbits/nn_env.c view
@@ -60,13 +60,20 @@ }  /* Allocate raw bytes from the page-based bump allocator.- * Returns aligned, zero-initialized memory. */+ * Returns aligned, zero-initialized memory, or NULL on overflow/OOM. */ static void * nn_env_alloc_raw(uint32_t bytes) {+    /* align_up would wrap for sizes near UINT32_MAX; such a request can+     * only be corrupt input, so fail it instead of wrapping small. */+    if (bytes > UINT32_MAX - (NN_ENV_ALIGN - 1)) return NULL;     bytes = align_up(bytes, NN_ENV_ALIGN); -    if (!g_current_page || g_current_page->used + bytes > g_current_page->capacity) {+    /* used <= capacity always holds, so capacity - used cannot underflow;+     * the additive form (used + bytes > capacity) overflows uint32_t for+     * near-4GB requests and would pass the check, then memset past the+     * end of a 256 KB page. */+    if (!g_current_page || bytes > g_current_page->capacity - g_current_page->used) {         uint32_t page_cap = bytes > NN_ENV_PAGE_SIZE ? bytes : NN_ENV_PAGE_SIZE;         struct nn_env_page *page = alloc_page(page_cap);         if (!page) return NULL;@@ -140,7 +147,7 @@ nn_env_t * nn_env_new(void **slots, uint32_t slot_count,            void *lazy_scope, nn_env_t *parent,-           void **with_scopes, uint16_t with_count)+           void **with_scopes, uint32_t with_count) {     nn_env_t *env = (nn_env_t *)nn_env_alloc_raw((uint32_t)sizeof(nn_env_t));     if (!env) return NULL;@@ -171,10 +178,12 @@ nn_env_t * nn_env_push_with(nn_env_t *base, void *scope) {-    if (base->with_count >= UINT16_MAX) return NULL;-    uint16_t new_count = base->with_count + 1;-    void **new_withs = (void **)nn_env_alloc_raw(-        (uint32_t)new_count * (uint32_t)sizeof(void *));+    /* The increment must not wrap.  Counts anywhere near this bound+     * cannot allocate anyway (the array alloc caps at UINT32_MAX+     * bytes), so this is the formal guard, not a reachable limit. */+    if (base->with_count == UINT32_MAX) return NULL;+    uint32_t new_count = base->with_count + 1;+    void **new_withs = nn_env_alloc_with_scopes(new_count);     if (!new_withs) return NULL;      /* New scope at index 0 (innermost) */@@ -239,7 +248,7 @@     return env->with_scopes; } -uint16_t+uint32_t nn_env_with_count(const nn_env_t *env) {     return env->with_count;@@ -284,7 +293,7 @@ /* --- With-scopes array allocation --- */  void **-nn_env_alloc_with_scopes(uint16_t count)+nn_env_alloc_with_scopes(uint32_t count) {     if (count == 0) return NULL;     uint64_t bytes64 = (uint64_t)count * sizeof(void *);
cbits/nn_env.h view
@@ -29,7 +29,7 @@     void          *lazy_scope;   /* nn_attrset_t* or NULL */     struct nn_env *parent;       /* nn_env_t* or NULL */     void         **with_scopes;  /* array of nn_attrset_t* (or NULL) */-    uint16_t       with_count;   /* 2 bytes + 6 pad to struct alignment */+    uint32_t       with_count;   /* 4 bytes + 4 pad to struct alignment */ } nn_env_t;  /* --- Lifecycle --- */@@ -57,7 +57,7 @@ /* Full constructor: set all fields explicitly. */ nn_env_t *nn_env_new(void **slots, uint32_t slot_count,                      void *lazy_scope, nn_env_t *parent,-                     void **with_scopes, uint16_t with_count);+                     void **with_scopes, uint32_t with_count);  /* Child env with positional slots, inheriting parent's with-scopes.  * lazy_scope = NULL. */@@ -78,7 +78,7 @@ void        *nn_env_lazy_scope(const nn_env_t *env); nn_env_t    *nn_env_parent(const nn_env_t *env); void       **nn_env_with_scopes(const nn_env_t *env);-uint16_t     nn_env_with_count(const nn_env_t *env);+uint32_t     nn_env_with_count(const nn_env_t *env);  /* --- Lookup --- */ @@ -95,8 +95,10 @@ /* --- With-scopes array allocation --- */  /* Allocate an arena array of `count` void* pointers for with-scopes.- * All entries initialized to NULL.  Returns NULL if count is 0. */-void **nn_env_alloc_with_scopes(uint16_t count);+ * All entries initialized to NULL.  Returns NULL if count is 0, if the+ * array size would exceed the allocator's uint32 byte limit, or on+ * OOM - the caller must check. */+void **nn_env_alloc_with_scopes(uint32_t count);  /* --- Diagnostics --- */ 
cbits/nn_lambda.c view
@@ -40,8 +40,15 @@ nn_lambda_t * nn_lambda_new(struct nn_env *env, uint32_t body_bc_idx,               uint8_t formals_type, uint32_t name_sym,-              uint8_t allow_extra, uint16_t formal_count)+              uint8_t allow_extra, uint32_t formal_count) {+    /* The count is input-reachable (it scales with the source formal+     * list); computing the entries size in uint64_t and capping it at+     * the UINT32_MAX byte limit used across the C layer keeps the+     * malloc argument from wrapping size_t on any platform. */+    uint64_t entry_bytes64 = (uint64_t)formal_count * sizeof(nn_formal_entry_t);+    if (entry_bytes64 > UINT32_MAX) return NULL;+     nn_lambda_t *lam = (nn_lambda_t *)malloc(sizeof(nn_lambda_t));     if (!lam) return NULL; @@ -53,14 +60,12 @@     lam->formal_count = formal_count;      if (formal_count > 0) {-        lam->entries = (nn_formal_entry_t *)malloc(-            (size_t)formal_count * sizeof(nn_formal_entry_t));+        lam->entries = (nn_formal_entry_t *)malloc((size_t)entry_bytes64);         if (!lam->entries) {             free(lam);             return NULL;         }-        memset(lam->entries, 0,-               (size_t)formal_count * sizeof(nn_formal_entry_t));+        memset(lam->entries, 0, (size_t)entry_bytes64);     } else {         lam->entries = NULL;     }@@ -70,11 +75,16 @@ }  void-nn_lambda_set_entry(nn_lambda_t *lam, uint16_t idx,+nn_lambda_set_entry(nn_lambda_t *lam, uint32_t idx,                     uint32_t name_sym, uint32_t has_default,                     uint32_t default_bc_idx) {-    NN_ASSERT(idx < lam->formal_count, "nn_lambda_set_entry: idx out of bounds");+    /* Bound stays live under NDEBUG: the fill index derives from+     * input-sized data crossing the FFI, so a violated bound must drop+     * the write, not run past the sized array. */+    NN_ASSERT(lam != NULL && lam->entries != NULL && idx < lam->formal_count,+              "nn_lambda_set_entry: idx out of bounds");+    if (lam == NULL || lam->entries == NULL || idx >= lam->formal_count) return;     lam->entries[idx].name_sym       = name_sym;     lam->entries[idx].has_default    = has_default;     lam->entries[idx].default_bc_idx = default_bc_idx;@@ -126,32 +136,38 @@     return lam->allow_extra; } -uint16_t+uint32_t nn_lambda_formal_count(const nn_lambda_t *lam) {     return lam->formal_count; } +/* Entry reads keep their bound live under NDEBUG (see set_entry);+ * a violated bound returns 0 rather than reading out of bounds. */+ uint32_t-nn_lambda_entry_name(const nn_lambda_t *lam, uint16_t idx)+nn_lambda_entry_name(const nn_lambda_t *lam, uint32_t idx) {-    NN_ASSERT(lam->entries != NULL && idx < lam->formal_count,+    NN_ASSERT(lam != NULL && lam->entries != NULL && idx < lam->formal_count,               "nn_lambda_entry_name: idx out of bounds or no formals");+    if (lam == NULL || lam->entries == NULL || idx >= lam->formal_count) return 0;     return lam->entries[idx].name_sym; }  uint32_t-nn_lambda_entry_has_default(const nn_lambda_t *lam, uint16_t idx)+nn_lambda_entry_has_default(const nn_lambda_t *lam, uint32_t idx) {-    NN_ASSERT(lam->entries != NULL && idx < lam->formal_count,+    NN_ASSERT(lam != NULL && lam->entries != NULL && idx < lam->formal_count,               "nn_lambda_entry_has_default: idx out of bounds or no formals");+    if (lam == NULL || lam->entries == NULL || idx >= lam->formal_count) return 0;     return lam->entries[idx].has_default; }  uint32_t-nn_lambda_entry_default(const nn_lambda_t *lam, uint16_t idx)+nn_lambda_entry_default(const nn_lambda_t *lam, uint32_t idx) {-    NN_ASSERT(lam->entries != NULL && idx < lam->formal_count,+    NN_ASSERT(lam != NULL && lam->entries != NULL && idx < lam->formal_count,               "nn_lambda_entry_default: idx out of bounds or no formals");+    if (lam == NULL || lam->entries == NULL || idx >= lam->formal_count) return 0;     return lam->entries[idx].default_bc_idx; }
cbits/nn_lambda.h view
@@ -43,14 +43,16 @@ } nn_formal_entry_t;  /* Lambda closure: env + body + formals specification.- * Packed for minimal size (pointers first, then uint32s, then small fields).- * 28 bytes on 64-bit (no padding). */+ * Packed for minimal size: pointers first, then uint32s, then small+ * fields - no internal padding.  formal_count is a full uint32_t+ * because the formal list scales with the source expression (a+ * narrower count would wrap and undersize the entries array). */ typedef struct nn_lambda {     struct nn_env         *env;          /* captured closure environment */     nn_formal_entry_t     *entries;      /* formal entries (malloc'd, or NULL) */     uint32_t               body_bc_idx;  /* bytecode index of lambda body */     uint32_t               name_sym;     /* symbol for Name/NamedSet binding */-    uint16_t               formal_count; /* number of formal entries */+    uint32_t               formal_count; /* number of formal entries */     uint8_t                formals_type; /* NN_FORMALS_NAME/SET/NAMED_SET */     uint8_t                allow_extra;  /* 1 if ellipsis (...) present */ } nn_lambda_t;@@ -60,14 +62,17 @@ /* Allocate a new lambda closure.  The entries array is allocated  * internally (formal_count entries).  Fill entries via  * nn_lambda_set_entry() after construction.- * Returns NULL on allocation failure. */+ * Returns NULL on allocation failure or if the entries array size+ * would overflow - the caller must check. */ nn_lambda_t *nn_lambda_new(struct nn_env *env, uint32_t body_bc_idx,                            uint8_t formals_type, uint32_t name_sym,-                           uint8_t allow_extra, uint16_t formal_count);+                           uint8_t allow_extra, uint32_t formal_count);  /* Set a formal entry at the given index (for construction).- * Index must be < formal_count. */-void nn_lambda_set_entry(nn_lambda_t *lam, uint16_t idx,+ * idx must be < formal_count.  A NULL struct or out-of-range idx is+ * dropped (asserts in debug builds) rather than writing out of+ * bounds - the bound stays live under NDEBUG. */+void nn_lambda_set_entry(nn_lambda_t *lam, uint32_t idx,                          uint32_t name_sym, uint32_t has_default,                          uint32_t default_bc_idx); @@ -83,11 +88,13 @@ uint8_t        nn_lambda_formals_type(const nn_lambda_t *lam); uint32_t       nn_lambda_name_sym(const nn_lambda_t *lam); uint8_t        nn_lambda_allow_extra(const nn_lambda_t *lam);-uint16_t       nn_lambda_formal_count(const nn_lambda_t *lam);+uint32_t       nn_lambda_formal_count(const nn_lambda_t *lam); -/* Read formal entry fields by index (must be < formal_count). */-uint32_t nn_lambda_entry_name(const nn_lambda_t *lam, uint16_t idx);-uint32_t nn_lambda_entry_has_default(const nn_lambda_t *lam, uint16_t idx);-uint32_t nn_lambda_entry_default(const nn_lambda_t *lam, uint16_t idx);+/* Read formal entry fields by index (must be < formal_count).+ * Out-of-range reads return 0 (asserts in debug builds) rather than+ * reading out of bounds. */+uint32_t nn_lambda_entry_name(const nn_lambda_t *lam, uint32_t idx);+uint32_t nn_lambda_entry_has_default(const nn_lambda_t *lam, uint32_t idx);+uint32_t nn_lambda_entry_default(const nn_lambda_t *lam, uint32_t idx);  #endif /* NN_LAMBDA_H */
cbits/nn_symbol.c view
@@ -196,6 +196,14 @@  nn_symbol_t nn_symbol_intern(const char *str, size_t len) {+    /* An empty string arrives from Haskell's zero-copy marshalling as+     * (NULL, 0).  memcpy/memcmp require valid pointers even for a zero+     * length, so normalize at the boundary.  Empty symbols are reachable+     * from evaluated input ({ "" = 1; }), so the branch survives release+     * builds. */+    if (len == 0) {+        str = "";+    }     uint32_t hash = fnv1a(str, len);     uint32_t idx = hash & g_sym.slots_mask; 
cbits/nn_symbol.h view
@@ -45,7 +45,8 @@ /* Intern a string, returning its symbol.  If the string was already  * interned, returns the existing symbol (O(1) amortized).  * The input string is copied - the caller may free it after this call.- * str must not be NULL.  len is the byte length (not null-terminated). */+ * len is the byte length (not null-terminated).  (NULL, 0) denotes the+ * empty string; str must not be NULL when len > 0. */ nn_symbol_t nn_symbol_intern(const char *str, size_t len);  /* Return the string data for a symbol.  The pointer is valid until
cbits/nn_thunk.c view
@@ -15,6 +15,7 @@  #include "nn_thunk.h" +#include <stdio.h> #include <stdlib.h> #include <string.h> @@ -85,14 +86,19 @@     uint32_t cap = initial_capacity > 0 ? initial_capacity                                         : NN_THUNK_DEFAULT_BLOCK_CAPACITY; +    /* An allocation failure here would leave g_arena NULL and the first+     * thunk allocation would dereference it - fail loudly at startup+     * instead (same policy as nn_env_init). */     g_arena = (struct nn_thunk_arena *)malloc(sizeof(struct nn_thunk_arena));-    if (!g_arena) return;+    if (!g_arena) {+        fprintf(stderr, "nn_thunk_init: arena alloc failed\n");+        abort();+    }      struct nn_thunk_block *first = alloc_block(cap);     if (!first) {-        free(g_arena);-        g_arena = NULL;-        return;+        fprintf(stderr, "nn_thunk_init: block alloc failed\n");+        abort();     }      g_arena->head = first;@@ -120,19 +126,6 @@ /* --- Allocation --- */  nn_thunk_t *-nn_thunk_new(void *pending_data)-{-    nn_thunk_t *t = arena_alloc(g_arena);-    if (!t) return NULL;-    t->state = NN_THUNK_PENDING;-    t->val_tag = 0;-    t->_pad = 0;-    t->bc_idx = 0;  /* legacy StablePtr thunk marker */-    t->payload = pending_data;-    return t;-}--nn_thunk_t * nn_thunk_new_bc(uint32_t bc_idx, void *env_ptr) {     nn_thunk_t *t = arena_alloc(g_arena);@@ -383,6 +376,18 @@ {     if (thunk->state != NN_THUNK_PENDING) return 0;     thunk->state = NN_THUNK_BLACKHOLE;+    return 1;+}++/* Restore BLACKHOLE -> PENDING after a force threw and was caught upstream.+ * mark_blackhole only flips the state byte, so payload + bc_idx are intact and+ * the thunk is fully re-forceable; a later force re-evaluates rather than+ * misreporting infinite recursion.  Returns 1 on success, 0 if not BLACKHOLE. */+int+nn_thunk_mark_pending(nn_thunk_t *thunk)+{+    if (thunk->state != NN_THUNK_BLACKHOLE) return 0;+    thunk->state = NN_THUNK_PENDING;     return 1; } 
cbits/nn_thunk.h view
@@ -81,7 +81,8 @@  /* Initialize the global thunk arena.  initial_capacity is the number  * of thunks to pre-allocate per block (0 uses default: 65536 = 1 MB).- * Must be called once before any nn_thunk_new() calls. */+ * Must be called once before any thunk allocation (nn_thunk_new_bc or+ * the computed-value constructors). */ void nn_thunk_init(uint32_t initial_capacity);  /* Destroy the global thunk arena, freeing all block memory.@@ -92,13 +93,8 @@  /* --- Allocation --- */ -/* Allocate a new PENDING thunk from the arena (legacy StablePtr path).- * pending_data is an opaque pointer (StablePtr to Haskell value).- * Sets bc_idx = 0 to distinguish from bytecode thunks.- * Returns a pointer into arena memory, valid until nn_thunk_destroy(). */-nn_thunk_t *nn_thunk_new(void *pending_data);- /* Allocate a new PENDING thunk with a bytecode index + env payload.+ * Returns a pointer into arena memory, valid until nn_thunk_destroy().  * bc_idx is the root instruction index in the bytecode store.  * env_ptr is a StablePtr Env from Haskell (required for knot-tying;  * raw Ptr would force the env at allocation time, breaking laziness). */@@ -162,6 +158,12 @@  * Payload remains unchanged (caller reads it before this call).  * Returns 1 on success, 0 if thunk is not PENDING. */ int nn_thunk_mark_blackhole(nn_thunk_t *thunk);++/* Transition BLACKHOLE -> PENDING (a force threw and was caught upstream).+ * Payload + bc_idx are preserved, so the thunk is re-forceable; matches C+++ * Nix restoring a thunk after a caught exception during force.+ * Returns 1 on success, 0 if thunk is not BLACKHOLE. */+int nn_thunk_mark_pending(nn_thunk_t *thunk);  /* Set a non-COMPUTED thunk to COMPUTED with a StablePtr value (NN_VALUE_PTR).  * Accepts PENDING or BLACKHOLE state (skips blackhole for direct memoization).
+ cbits/nn_winfs.c view
@@ -0,0 +1,67 @@+#include "nn_winfs.h"++#ifdef _WIN32++#include <windows.h>+#include <winternl.h>++/* FileCaseSensitiveInformation and its flag are absent from older SDK and+ * MinGW headers; both values are fixed ABI, defined locally. */+enum { NN_FILE_CASE_SENSITIVE_INFORMATION = 71 };++#ifndef FILE_CS_FLAG_CASE_SENSITIVE_DIR+#define FILE_CS_FLAG_CASE_SENSITIVE_DIR 0x00000001+#endif++typedef struct nn_file_case_sensitive_info {+  ULONG flags;+} nn_file_case_sensitive_info;++typedef NTSTATUS(NTAPI *nn_nt_set_information_file)(HANDLE, PIO_STATUS_BLOCK,+                                                    PVOID, ULONG, ULONG);++int nn_dir_set_case_sensitive(const wchar_t *path) {+  HMODULE ntdll;+  nn_nt_set_information_file set_information_file;+  HANDLE dir;+  nn_file_case_sensitive_info info;+  IO_STATUS_BLOCK iosb;+  NTSTATUS status;++  /* NtSetInformationFile is the documented route to the per-directory+   * flag (fsutil and WSL use it); resolved dynamically because it lives+   * in ntdll, which is always loaded but not in the link line. */+  ntdll = GetModuleHandleW(L"ntdll.dll");+  if (ntdll == NULL) {+    return 0;+  }+  set_information_file =+      (nn_nt_set_information_file)GetProcAddress(ntdll, "NtSetInformationFile");+  if (set_information_file == NULL) {+    return 0;+  }++  dir = CreateFileW(path, FILE_WRITE_ATTRIBUTES,+                    FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,+                    NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL);+  if (dir == INVALID_HANDLE_VALUE) {+    return 0;+  }++  info.flags = FILE_CS_FLAG_CASE_SENSITIVE_DIR;+  status = set_information_file(dir, &iosb, &info, sizeof info,+                                NN_FILE_CASE_SENSITIVE_INFORMATION);+  CloseHandle(dir);+  return status >= 0 ? 1 : 0;+}++#else /* !_WIN32 */++/* Per-directory case sensitivity is an NTFS feature; APFS decides at+ * volume creation and Linux filesystems are case-sensitive by nature. */+int nn_dir_set_case_sensitive(const wchar_t *path) {+  (void)path;+  return 0;+}++#endif
+ cbits/nn_winfs.h view
@@ -0,0 +1,18 @@+/* Platform filesystem capabilities the store layer probes at runtime.+ * Windows-only functionality; other platforms compile the constant-failure+ * stubs so callers fall back without conditional compilation. */+#ifndef NN_WINFS_H+#define NN_WINFS_H++#include <wchar.h>++/* Make an EMPTY directory's namespace case-sensitive (the NTFS+ * per-directory flag, the same mechanism WSL uses to host Linux trees).+ * Returns 1 on success, 0 when refused or unsupported - a non-NTFS+ * volume, policy, a non-empty directory, or a non-Windows build - and+ * the caller falls back to the case-hack.  The check guards external+ * input (NAR trees choose which directories need it), so the failure+ * path is a live branch, never an assert. */+int nn_dir_set_case_sensitive(const wchar_t *path);++#endif
nova-nix.cabal view
@@ -1,19 +1,23 @@ cabal-version:      3.0 name:               nova-nix-version:            0.6.0.0+version:            0.8.0.0 synopsis:           Windows-native Nix implementation in Haskell and C99 description:-  A from-scratch implementation of the Nix package manager that runs-  natively on Windows, macOS, and Linux - no WSL or Cygwin.  A-  Haskell evaluator handles parsing and lazy evaluation, backed by a C99-  data layer that keeps evaluation data off the GHC heap.  It evaluates-  real Nix expressions (including nixpkgs), computes derivations and-  content-addressed store paths that byte-match upstream Nix, and builds-  real packages from source natively on Windows through a stage-1 stdenv-  over a store-pinned MinGW-w64 toolchain.-  Built outputs are content-addressed, substituted from a binary cache-  before building, and pushed to one with @nova-nix push@.+  An implementation of Nix for Windows, written in Haskell with a C99+  data layer that keeps evaluation data off the GHC heap.  It also runs+  on macOS and Linux.  It has its own parser, lazy evaluator,+  content-addressed store, derivation builder and binary-cache+  substituter, and does not need an existing Nix installation. +  On the pinned nixpkgs revision its CI checks, the derivation paths it+  computes match upstream Nix 2.24.9.  On Windows it builds packages from+  source through a stage-1 stdenv over a store-pinned MinGW-w64+  toolchain.  The project is experimental; see the README for what is+  not implemented yet.++  Evaluation needs the C data layer, so library code must run it between+  @Nix.Eval.Arena.arenaInit@ and @Nix.Eval.Arena.arenaDestroy@.+   Built on @nova-cache@ for NAR serialization, narinfo handling, and   Ed25519-signed binary substitution. @@ -27,9 +31,10 @@ category:           Nix, Distribution, System stability:          experimental build-type:         Simple-tested-with:        GHC == 9.8.4+tested-with:        GHC == 9.14.1 extra-doc-files:     CHANGELOG.md+    NOTICE     README.md -- C data-layer headers.  These are #included by the cbits/*.c sources -- (via include-dirs: cbits) but are not auto-added to the sdist by the@@ -56,6 +61,7 @@     Nix.Parser.Lexer     Nix.Parser.ParseError     Nix.Eval+    Nix.Eval.CanonPath     Nix.Eval.Context     Nix.Eval.Types     Nix.Eval.IO@@ -72,20 +78,27 @@     Nix.Eval.Compile     Nix.Eval.EvalFormals     Nix.Eval.Arena+    Nix.Eval.AttrPath     Nix.Store     Nix.Store.Path+    Nix.Store.Path.Internal     Nix.Store.DB+    Nix.Store.Lock+    Nix.Store.CaseSensitive+    Nix.Store.ExecBit     Nix.DependencyGraph     Nix.Derivation     Nix.Builder     Nix.Builder.Unpack+    Nix.Compression+    Nix.Config     Nix.Push     Nix.Substituter     Nix.Builtins     Nix.Hash    build-depends:-      base                >= 4.16 && < 5+      base                >= 4.22 && < 5     , array               >= 0.5 && < 0.6     , bytestring          >= 0.11 && < 0.13     , containers          >= 0.6 && < 0.9@@ -96,8 +109,9 @@     , http-client-tls     >= 0.3 && < 0.5     , http-types          >= 0.12 && < 0.13     , ram                 >= 0.20 && < 1+    , filelock            >= 0.1.1 && < 0.2     , mtl                 >= 2.2 && < 2.4-    , nova-cache          >= 0.4.2 && < 0.5+    , nova-cache:{nova-cache, xz, zstandard, bzip2} >= 0.11.1.1 && < 0.12     , process             >= 1.6 && < 1.7     , regex-tdfa          >= 1.3 && < 1.4     , sqlite-simple       >= 0.4 && < 0.5@@ -116,8 +130,12 @@     cbits/nn_arena.c     cbits/nn_bytecode.c     cbits/nn_lambda.c+    cbits/nn_winfs.c   include-dirs:     cbits-  cc-options:       -std=c99 -Wall -Wextra -pedantic -Werror+  -- -Werror deliberately NOT baked in: a future gcc/clang adding a new+  -- -Wall diagnostic must not retroactively break installing published+  -- releases.  CI enforces warnings-as-errors via its C99-strict job.+  cc-options:       -std=c99 -Wall -Wextra -pedantic   hs-source-dirs:   src   default-language:  Haskell2010   default-extensions:@@ -128,6 +146,11 @@     -Wcompat     -Wincomplete-record-updates     -Wincomplete-uni-patterns+    -- GHC 9.14 deprecates the pattern namespace specifier in import and+    -- export lists in favour of data, but hlint (through 3.10) cannot+    -- parse the replacement.  The old spelling stays, warning muted,+    -- until hlint learns it (then: pattern -> data, drop these flags).+    -Wno-pattern-namespace-specifier  -- --------------------------------------------------------------------------- -- CLI executable@@ -143,7 +166,8 @@   ghc-options:      -Wall -Wcompat -threaded -rtsopts    build-depends:-      base                >= 4.16 && < 5+      base                >= 4.22 && < 5+    , bytestring          >= 0.11 && < 0.13     , containers          >= 0.6 && < 0.9     , directory           >= 1.3 && < 1.4     , filepath            >= 1.4 && < 1.6@@ -157,20 +181,28 @@   type:             exitcode-stdio-1.0   main-is:          Main.hs   hs-source-dirs:   test+  other-modules:    FetchurlFixture   default-language: Haskell2010   default-extensions:     OverloadedStrings-  ghc-options:      -Wall -Wcompat+  -- -threaded: the path-lock race tests hold a blocking OS file lock in+  -- one thread while another waits on it, which needs real concurrency+  -- under blocking foreign calls.+  ghc-options:      -Wall -Wcompat -Wno-pattern-namespace-specifier -threaded    build-depends:-      base                >= 4.16 && < 5+      base                >= 4.22 && < 5+    , async               >= 2.2 && < 2.3     , bytestring          >= 0.11 && < 0.13     , containers          >= 0.6 && < 0.9     , directory           >= 1.3 && < 1.4     , filepath            >= 1.4 && < 1.6-    , nova-cache          >= 0.4.2 && < 0.5+    , http-client         >= 0.7 && < 0.8+    , network             >= 3.2 && < 3.3+    , nova-cache:{nova-cache, zstandard} >= 0.11.1.1 && < 0.12     , nova-nix     , process             >= 1.6 && < 1.7+    , sqlite-simple       >= 0.4 && < 0.5     , tar                 >= 0.7.1 && < 0.8     , text                >= 2.0 && < 2.2     , zstd                >= 0.1 && < 0.2
src/Nix/Builder.hs view
@@ -20,13 +20,16 @@ -- -- == On Windows ----- The key difference is process creation.  Linux uses @fork\/exec@ with--- namespace isolation.  We use 'System.Process.createProcess' which maps--- to @CreateProcess@ on Windows - native, no POSIX layer.+-- The key difference is process creation.  Upstream on Linux uses+-- @fork\/exec@ with namespace isolation.  nova-nix spawns builders through+-- "System.Process", which reaches @CreateProcess@ on Windows with no POSIX+-- layer, and does not sandbox them yet (#25). ----- For now, builds run without sandboxing (same as Nix on macOS did for--- years).  Future work: Windows Job Objects for resource limits,--- App Containers for filesystem isolation.+-- The builder's process tree runs inside a Win32 job object with+-- kill-on-job-close ('Proc.use_process_jobs'), so an interrupt or a+-- builder exit reaps grandchildren instead of orphaning them.  Filesystem+-- and privilege isolation are still future work: a restricted token with a+-- store-granting ACL (the security boundary), then AppContainer for more. -- -- We ship @bash.exe@ (from MSYS2) as the default builder on Windows. -- Same approach as Git for Windows.@@ -39,33 +42,55 @@     -- * Build configuration     BuildConfig (..),     defaultBuildConfig,++    -- * Pure pieces (exported for tests)+    BuilderSpawn (..),+    buildPath,+    execWrapperConfig,+    execWrapperFor,+    rewriteEnv,+    rewritePlaceholders,+    scrubAmbient,+    unionEnvs,+    verifyFetchHash,+    fetchUrlsFromEnv,+    tryFetchUrlsWith,   ) where -import Control.Exception (SomeException, try)-import Control.Monad (filterM, when)+import Control.Exception (IOException, SomeException, displayException, finally, onException, try)+import Control.Monad (filterM, unless, when)+import Data.Bifunctor (first) import qualified Data.ByteString as BS-import qualified Data.ByteString.Lazy as LBS-import Data.Char (toLower)+import Data.Char (isAscii, isSpace, toLower, toUpper) import Data.Either (fromRight)+import Data.Foldable (for_)+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef)+import Data.List (sort)+import Data.List.NonEmpty (NonEmpty (..))+import qualified Data.List.NonEmpty as NE import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map-import Data.Maybe (catMaybes)+import Data.Maybe (catMaybes, isJust) import Data.Text (Text) import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import Data.Text.Encoding.Error (lenientDecode) import qualified Data.Text.IO as TIO import qualified Network.HTTP.Client as HTTP import qualified Network.HTTP.Client.TLS as HTTPS import qualified Network.HTTP.Types.Status as HTTP-import Nix.Builder.Unpack (builtinUnpackBuilder, runBuiltinUnpack)+import Nix.Builder.Unpack (UnpackLimits, builtinUnpackBuilder, defaultUnpackLimits, runBuiltinUnpack) import Nix.DependencyGraph (DepGraph, TopoResult (..), buildDepGraph, topoSort) import qualified Nix.DependencyGraph-import Nix.Derivation (Derivation (..), DerivationOutput (..), fromATerm)-import Nix.Hash (bytesToHexText, hexToBytes, rawHashWithAlgo)-import Nix.Store (PathRegistration, Store (..), isValid, placeInStore, registerPaths, registrationFor, scanReferences, scanTempReferences)-import Nix.Store.Path (StoreDir (..), StorePath (..), storePathToFilePath)-import Nix.Substituter (CacheConfig, SubstResult (..), trySubstitute)-import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesPathExist, removeDirectoryRecursive)+import Nix.Derivation (Derivation (..), DerivationOutput (..), currentPlatform, fromATerm, platformToText)+import Nix.Hash (IncrementalHash, bytesToHexText, hashFinalizeBytes, hashInitWithAlgo, hashPlaceholder, hashUpdateChunk, hexToBytes, makeStorePath, rawHashWithAlgo)+import Nix.Store (PathLock, PathRegistration, Store (..), acquirePathLock, isValid, placeInStore, registerPaths, releasePathLock, scanReferences, scanTempReferences)+import qualified Nix.Store.ExecBit as ExecBit+import Nix.Store.Path (StoreDir (..), StorePath (spHash, spName), StorePathNameError, defaultStoreDir, defaultStoreDirText, storePathToFilePath, unStoreDir)+import Nix.Substituter (CacheConfig, SubstResult (..), catchSync, trySubstitute)+import qualified NovaCache.NAR as NAR+import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesPathExist, removeDirectoryRecursive, removePathForcibly) import qualified System.Environment import System.Exit (ExitCode (..)) import System.FilePath (takeDirectory, takeFileName, (</>))@@ -82,14 +107,49 @@ envNixBuildTop :: Text envNixBuildTop = "NIX_BUILD_TOP" --- | Environment variable for the temp directory.-envTmpDir :: Text+-- | The four temp-directory names, every one pointed at the build+-- directory, matching upstream's single assignment of all four.  Tools+-- disagree about which one they read (POSIX tools take @TMPDIR@, msys+-- reads @TMPDIR@ and @TMP@, native Windows tools take @TMP@ then+-- @TEMP@), and a scrubbed environment that repoints only one leaves+-- the others to fall back to a directory the build cannot own - on+-- Windows, GetTempPath bottoms out at the Windows directory itself.+envTmpDir, envTempDir, envTmp, envTemp :: Text envTmpDir = "TMPDIR"+envTempDir = "TEMPDIR"+envTmp = "TMP"+envTemp = "TEMP"  -- | Environment variable for the Nix store path. envNixStore :: Text envNixStore = "NIX_STORE" +-- | Ambient variables a Windows builder may inherit, by uppercase-folded+-- name.  SystemRoot is a hard execution requirement - the CLR's crypto+-- provider fails to load without it (verified live on a clean Windows 11+-- box), while plain Win32 binaries merely tolerate its absence - and+-- SystemDrive and windir are the aliases some tools consult instead of it.+windowsAmbientAllowlist :: [Text]+windowsAmbientAllowlist = [systemRootKey, "SYSTEMDRIVE", "WINDIR"]++-- | The folded name 'scrubAmbient' derives COMSPEC from.+systemRootKey :: Text+systemRootKey = "SYSTEMROOT"++-- | The command interpreter variable, synthesized from SystemRoot rather+-- than inherited: programs shelling out through the C runtime's+-- @system()@ read it from the block.+envComspec :: Text+envComspec = "COMSPEC"++-- | Executable-extension resolution, pinned instead of inherited so name+-- resolution does not drift with host configuration.  cmd.exe's own+-- built-in default when the variable is absent adds @.VBS;.JS;.WS;.MSC@;+-- a build has no business resolving those implicitly.+envPathext, pathextValue :: Text+envPathext = "PATHEXT"+pathextValue = ".COM;.EXE;.BAT;.CMD"+ -- | Environment variable for PATH. envPath :: Text envPath = "PATH"@@ -97,12 +157,14 @@ -- | The magic builder string for the built-in URL fetcher.  A derivation with -- this builder is not executed as a process - the Builder downloads its @url@ -- and verifies it against @outputHash@ (see 'runBuiltinFetchurl').-builtinFetchurlBuilder :: Text+-- Bytes, matching the 'drvBuilder' field it is compared against.+builtinFetchurlBuilder :: BS.ByteString builtinFetchurlBuilder = "builtin:fetchurl"  -- | Derivation environment keys read by @builtin:fetchurl@.-envUrl, envOut :: Text+envUrl, envUrls, envOut :: Text envUrl = "url"+envUrls = "urls" envOut = "out"  -- | HTTP success status code.@@ -119,6 +181,11 @@ envSourceDateEpoch :: Text envSourceDateEpoch = "SOURCE_DATE_EPOCH" +-- | The derivation attribute naming ambient variables a fixed-output+-- build may read, upstream's carve-out for fetchers that need proxies.+envImpureEnvVars :: Text+envImpureEnvVars = "impureEnvVars"+ -- | The fixed build timestamp handed to every builder: 1980-01-01 UTC. -- Determinism-aware tools (binutils @ld@ writes it into the PE header -- instead of the wall clock; gcc uses it for @__DATE__@\/@__TIME__@)@@ -138,12 +205,18 @@     bcStoreDir :: !StoreDir,     -- | Temp directory for builds (cleaned after each build).     bcTmpDir :: !FilePath,-    -- | Path to bash executable (shipped with nova-nix on Windows).-    bcBashPath :: !FilePath,-    -- | Enable sandboxing (not yet implemented on Windows).-    bcSandbox :: !Bool,     -- | Binary caches to try before building (checked in priority order).-    bcCaches :: ![CacheConfig]+    bcCaches :: ![CacheConfig],+    -- | Extraction budget for @builtin:unpack@ builds.+    bcUnpackLimits :: !UnpackLimits,+    -- | Launchers for derivations whose @system@ this machine cannot execute+    -- directly, keyed by that system string exactly as+    -- 'Nix.Derivation.platformToText' spells it (@x86_64-windows@ -> a wine+    -- binary). Prepended to the builder at the spawn boundary only, so the+    -- derivation itself - and its store paths - stay identical to a native+    -- build's. A foreign system with no entry here is refused rather than+    -- spawned natively; see 'execWrapperFor'.+    bcExecWrappers :: !(Map Text FilePath)   }   deriving (Eq, Show) @@ -156,12 +229,9 @@         if isWindows           then "C:\\Temp\\nova-nix-build"           else "/tmp/nova-nix-build",-      bcBashPath =-        if isWindows-          then "bash" -- rely on PATH (MSYS2/Git Bash)-          else "/bin/bash",-      bcSandbox = False,-      bcCaches = []+      bcCaches = [],+      bcExecWrappers = Map.empty,+      bcUnpackLimits = defaultUnpackLimits     }  -- | Result of a build attempt.@@ -184,68 +254,328 @@ -- 4. Run the builder process -- 5. On success: scan references, move outputs to store, register -- 6. On failure: clean up and report error--- 7. Exception safety: wrap in try, convert to BuildFailure+-- 7. Exception safety: synchronous exceptions convert to BuildFailure;+--    asynchronous exceptions propagate ('catchSync') - an interrupt+--    must abort the build, never be reported as a build failure. buildDerivation :: BuildConfig -> Store -> Derivation -> IO BuildResult-buildDerivation config store drv = do-  result <- try (buildDerivationInner config store drv)-  case result of-    Right buildResult -> pure buildResult-    Left (err :: SomeException) ->-      pure (BuildFailure ("build exception: " <> T.pack (show err)) 1)+buildDerivation config store drv =+  buildDerivationInner config store drv+    `catchSync` \err -> pure (BuildFailure ("build exception: " <> T.pack (show err)) 1) +-- | Build under the outputs' path locks.  A derivation another process+-- finished while we waited for them is adopted rather than redone:+-- rebuilding would delete a registered path out from under whoever is+-- already using it, which is why substitution adopts a finished path too. buildDerivationInner :: BuildConfig -> Store -> Derivation -> IO BuildResult-buildDerivationInner config store drv = do+buildDerivationInner config store drv =+  withOutputLocks config store drv (maybe (buildUnderLock config store drv) (pure . BuildSuccess))++-- | Hold every output's path lock for the whole build, and report+-- whether the derivation was already finished by the time they were+-- granted.+--+-- #119 and #121 made delete, materialize and register one critical+-- section under @\<store-path\>.lock@.  The build path never joined it,+-- so two concurrent builds of one derivation shared a build directory+-- ('computeBuildDir' is deterministic), interleaved their writes into a+-- single tree, and each registered it as valid.  Nothing failed; the+-- registered output was simply not what either build produced.+--+-- Outputs are locked in 'StorePath' order so two processes racing the+-- same multi-output derivation request them in the same sequence.  A+-- build holds locks for its own outputs only - dependencies are+-- resolved before 'buildDerivation' is called, never underneath it - so+-- no process holds one lock while waiting on another, and there is no+-- cycle to deadlock on.+--+-- The validity re-check happens under the locks, not before them:+-- checked earlier it would answer about a moment that has already+-- passed by the time the build starts.+withOutputLocks :: BuildConfig -> Store -> Derivation -> (Maybe StorePath -> IO a) -> IO a+withOutputLocks config store drv act = do+  heldLocks <- newIORef []+  let takeLock sp = do+        lock <- acquirePathLock (bcStoreDir config) sp+        atomicModifyIORef' heldLocks (\locks -> (lock : locks, ()))+  ( do+      mapM_ takeLock (sort (map doPath (drvOutputs drv)))+      finishedElsewhere <- allOutputsValid store drv+      act finishedElsewhere+    )+    `finally` (readIORef heldLocks >>= mapM_ releasePathLock)++-- | The derivation's first output path when EVERY output is already+-- registered, otherwise 'Nothing'.+--+-- Every output, never just the first: a partially valid multi-output+-- derivation still has to build, and treating it as finished would leave+-- the missing outputs missing.  Both the lock-held re-check and the gate+-- in 'resolveDep' ask this one question, so the two cannot disagree.+allOutputsValid :: Store -> Derivation -> IO (Maybe StorePath)+allOutputsValid store drv = case map doPath (drvOutputs drv) of+  [] -> pure Nothing+  outputPaths@(firstOutput : _) -> do+    validity <- mapM (isValid store) outputPaths+    pure (if and validity then Just firstOutput else Nothing)++-- | The build itself, with every output's lock already held.+buildUnderLock :: BuildConfig -> Store -> Derivation -> IO BuildResult+buildUnderLock config store drv = do   -- 1. Validate inputs (sources + input-derivation outputs) exist   inputsOk <- validateInputs config store drv   case inputsOk of     Left errMsg -> pure (BuildFailure errMsg 1)     Right () -> do-      -- 2. Create temp build directory+      -- 2. Create temp build directory.  The location is deterministic+      --    (computeBuildDir), so a crashed earlier run may have left stale+      --    contents that builtin:unpack would misread as an archive+      --    collision - always start from an empty directory, and always+      --    remove it on the way out (finally), builder exceptions included.       let buildDir = computeBuildDir config drv+      removePathForcibly buildDir       createDirectoryIfMissing True buildDir+      (`finally` cleanupBuildDir buildDir) $ do+        -- 3. Compute output paths (but do NOT pre-create them - the builder+        --    is responsible for creating $out, $dev, etc.).+        --+        --    $out is the FINAL store path now, as upstream's is: a temp+        --    path recorded by the build (a compiler driver, a libtool+        --    archive) would otherwise outlive the temp dir it named.+        outputValidity <- mapM (isValid store . doPath) (drvOutputs drv)+        case traverse outputPlan (zip (drvOutputs drv) outputValidity) of+          Left nameErr -> pure (BuildFailure ("output name is not a store path name: " <> T.pack (show nameErr)) 1)+          Right plans -> runPlannedBuild config store drv buildDir plans -      -- 3. Compute output paths (but do NOT pre-create them - the builder-      --    is responsible for creating $out, $dev, etc.)-      let outputDirs = [(doName out, buildDir </> T.unpack (doName out)) | out <- drvOutputs drv]+-- | Where the builder writes each output, and where that has to end up.+--+-- The two coincide for an output being built: writing into the final store+-- path is what this whole arrangement is for.  They differ for an output+-- that is already valid, which the builder is still handed (a package with+-- a @dev@ output has one builder writing both) but which must not be+-- touched: it is registered, its @NarHash@ describes its current bytes,+-- and 'placeInStore' has already sealed it read-only.+data OutputPlan = OutputPlan+  { -- | The derivation output's name, as it reaches the environment.+    opName :: !Text,+    -- | Where the output has to be when the build is done.+    opFinal :: !StorePath,+    -- | Where the builder is told to write.+    opScratch :: !StorePath,+    -- | Whether 'opFinal' is already registered, and so must survive.+    opValid :: !Bool+  } -      -- 4. Set up environment-      let builderPath = T.unpack (drvBuilder drv)-          environ = buildEnvironment config drv builderPath buildDir outputDirs+-- | Plan one output, given whether its final path is already valid.+outputPlan :: (DerivationOutput, Bool) -> Either StorePathNameError OutputPlan+outputPlan (out, valid)+  | not valid = Right (OutputPlan (doName out) (doPath out) (doPath out) False)+  | otherwise = do+      fallback <- fallbackOutputPath (doPath out)+      pure (OutputPlan (doName out) (doPath out) fallback True) -          -- 5. Run the builder-          builderArgs = map T.unpack (drvArgs drv)-      -- A "builtin:" builder (e.g. builtin:fetchurl) is handled in-process;-      -- everything else is a normal subprocess.  The output validation and-      -- registration below are shared by both paths.+-- | A scratch store path for an output that must not be written to, built+-- so it cannot collide with any real store path.+--+-- Upstream's @makeFallbackPath@ (the @StorePath@ overload, in+-- @src/libstore/unix/build/local-derivation-goal.cc@ at 2.24.9) with the+-- same shape: a bogus @rewrite:@ path type and an all-zeroes inner hash,+-- keyed on the output path it stands in for.+-- Upstream also keys on the @.drv@ path; that is not reachable here, and+-- the output path already names one output of one derivation, so the+-- result is just as unique.+fallbackOutputPath :: StorePath -> Either StorePathNameError StorePath+fallbackOutputPath sp =+  makeStorePath+    defaultStoreDir+    ("rewrite:" <> spHash sp <> "-" <> spName sp)+    (BS.replicate 32 0)+    (spName sp)++-- | Parse @--exec-wrapper SYSTEM=PATH@ specs into the map 'bcExecWrappers'+-- wants.+--+-- @SYSTEM@ is a derivation's own @system@ string, spelled as+-- 'Nix.Derivation.platformToText' spells it, because 'execWrapperFor'+-- matches it by equality and nothing else.+--+-- A system named twice is an error rather than last-one-wins: two specs for+-- one system is a mistake in the invocation either way, and silently+-- picking one of them means a build runs through a launcher the operator+-- did not think they had asked for.+execWrapperConfig :: [String] -> Either Text (Map Text FilePath)+execWrapperConfig = foldl' step (Right Map.empty)+  where+    step acc spec = do+      wrappers <- acc+      (system, path) <- one spec+      if Map.member system wrappers+        then Left ("--exec-wrapper names " <> system <> " twice")+        else Right (Map.insert system path wrappers)+    one spec = case break (== '=') spec of+      (system, '=' : path)+        | not (null system), not (null path) -> Right (T.pack system, path)+      _ -> Left ("--exec-wrapper expects SYSTEM=PATH, got: " <> T.pack spec)++-- | How this machine can spawn a derivation's builder.+data BuilderSpawn+  = -- | The derivation targets this machine's platform; spawn it directly.+    SpawnNative+  | -- | Spawn through this launcher, named for the derivation's system.+    SpawnThrough !FilePath+  | -- | Nothing here can execute this derivation's builder; the payload is+    -- the system string that has no launcher.+    SpawnUnsupported !Text+  deriving (Eq, Show)++-- | How to spawn this derivation's builder on this machine.+--+-- \"This is my platform\" and \"I have no launcher for this platform\" are+-- different answers: collapsing them to \"spawn directly\" runs a foreign+-- builder natively and reports whatever the loader says, after the whole+-- closure has already been realized. A system this machine cannot execute+-- and has not been told how to is refused instead.+execWrapperFor :: BuildConfig -> Derivation -> BuilderSpawn+execWrapperFor config drv+  | drvPlatform drv == currentPlatform = SpawnNative+  | otherwise = case Map.lookup system (bcExecWrappers config) of+      Just launcher -> SpawnThrough launcher+      Nothing -> SpawnUnsupported system+  where+    system = platformToText (drvPlatform drv)++-- | The build, once every output knows where it is written and where it+-- belongs.+runPlannedBuild :: BuildConfig -> Store -> Derivation -> FilePath -> [OutputPlan] -> IO BuildResult+runPlannedBuild config store drv buildDir plans = do+  let scratchDir p = storePathToFilePath (bcStoreDir config) (opScratch p)+      outputDirs = [(opName p, scratchDir p) | p <- plans]+      -- Every scratch path is the build's to use: the ones that are final+      -- paths may hold a tree an interrupted run left behind, and the+      -- fallbacks may hold one an interrupted run failed to discard.+      clearScratch = for_ plans (removePathForcibly . scratchDir)+  clearScratch+  createDirectoryIfMissing True (unStoreDir (bcStoreDir config))+  -- The cleanup contract: the build directory is not where outputs live+  -- any more, so an interrupt or a throw between the builder starting and+  -- registration committing would otherwise strand an unregistered tree at+  -- a real store path.  Once the registrations are committed there is+  -- nothing left to take back, and the flag says so.+  committed <- newIORef False+  let cleanupUnlessCommitted = do+        done <- readIORef committed+        unless done clearScratch+  ( do+      -- 4. Decode builder/args/env for the spawn boundary.  Derivation+      --    strings are BYTES (identity); spawning a process needs real+      --    text, so invalid UTF-8 in any of them is a clean build+      --    failure, never a mojibake spawn.  The builtin builders skip+      --    this - they read the byte fields directly.       exitResult <- case drvBuilder drv of         b           | b == builtinFetchurlBuilder -> runBuiltinFetchurl drv outputDirs-          | b == builtinUnpackBuilder -> runBuiltinUnpack drv outputDirs-        _ -> runBuilder builderPath builderArgs environ buildDir+          | b == builtinUnpackBuilder -> runBuiltinUnpack (bcStoreDir config) (bcUnpackLimits config) drv outputDirs+        _ -> case (execWrapperFor config drv, decodeBuilderStrings drv) of+          (SpawnUnsupported system, _) ->+            pure+              ( Left+                  ( 1,+                    "no way to run a "+                      <> system+                      <> " builder on this machine; pass --exec-wrapper "+                      <> system+                      <> "=PATH to name a launcher for it"+                  )+              )+          (_, Left errMsg) -> pure (Left (1, errMsg))+          (spawn, Right (builderText, argTexts, decodedEnv)) ->+            let -- onStore first, so nothing a placeholder expands to gets+                -- rewritten a second time.+                rewrite = rewritePlaceholders outputDirs . onStore config+                builderPath = T.unpack (onStore config builderText)+                environ = buildEnvironment config (rewriteEnv rewrite decodedEnv) builderPath buildDir outputDirs+                builderArgs = map (T.unpack . rewrite) argTexts+                -- Env still names the real builder - a launcher only+                -- changes what's spawned, not what the derivation says.+                (spawnPath, spawnArgs) = case spawn of+                  SpawnThrough launcher -> (launcher, builderPath : builderArgs)+                  _ -> (builderPath, builderArgs)+                -- Only a fixed-output derivation's impureEnvVars reach+                -- the spawn: upstream gates the carve-out on the+                -- derivation type being non-sandboxed, which for us is+                -- exactly the fixed-output case.+                carriesHash out = not (T.null (doHashAlgo out))+                impureVars+                  | any carriesHash (drvOutputs drv) =+                      T.words (Map.findWithDefault "" envImpureEnvVars decodedEnv)+                  | otherwise = []+             in -- 5. Run the builder+                runBuilder spawnPath spawnArgs environ impureVars buildDir       case exitResult of         Left (exitCode, stderrText) -> do-          -- 6. Failure: clean up-          cleanupBuildDir buildDir+          -- Whatever the builder wrote is in the store now, so failure has+          -- to take it back out: an unregistered tree there is invisible to+          -- the database and would only confuse the next run.+          clearScratch           pure (BuildFailure ("builder failed: " <> stderrText) exitCode)         Right () -> do-          -- 7. Success: validate that the builder created all expected outputs.-          --    Outputs may be files or directories - both are valid (real Nix-          --    allows $out to be a single file, a directory tree, or a symlink).+          -- 6. Success: validate that the builder created all expected+          --    outputs.  Outputs may be files or directories - both are+          --    valid (real Nix allows $out to be a single file, a+          --    directory tree, or a symlink).           missing <- filterM (fmap not . doesPathExist . snd) outputDirs           case missing of-            [] -> do-              registerResult <- registerOutputs config store drv buildDir outputDirs-              cleanupBuildDir buildDir-              pure registerResult-            _ -> do-              cleanupBuildDir buildDir+            _ : _ -> do               let names = T.intercalate ", " (map fst missing)+              -- The builder may have produced some outputs before omitting+              -- another.  None were registered, so take those orphaned+              -- trees back out of the store.+              clearScratch               pure (BuildFailure ("builder succeeded but outputs missing: " <> names) 1)+            [] -> do+              result <- registerOutputs config store drv buildDir plans+              case result of+                BuildSuccess _ -> do+                  writeIORef committed True+                  -- Upstream discards a valid output's redirected copy+                  -- rather than reading it; only the fallbacks go, the+                  -- registered trees stay.+                  for_ [p | p <- plans, opValid p] (removePathForcibly . scratchDir)+                _ -> clearScratch+              pure result+    )+    `onException` cleanupUnlessCommitted +-- | Decode a derivation's builder path, arguments, and env values from+-- their identity bytes to the 'Text' the process-spawn boundary needs.+-- @Left@ names the offending field.+decodeBuilderStrings :: Derivation -> Either Text (Text, [Text], Map Text Text)+decodeBuilderStrings drv = do+  builderText <- decodeField "the builder path" (drvBuilder drv)+  argTexts <- traverse (decodeField "a builder argument") (drvArgs drv)+  envTexts <-+    Map.traverseWithKey+      (\key val -> decodeField ("environment variable '" <> key <> "'") val)+      (drvEnv drv)+  pure (builderText, argTexts, envTexts)+  where+    decodeField what bytes = case TE.decodeUtf8' bytes of+      Right t -> Right t+      Left _ -> Left ("cannot spawn builder: " <> what <> " contains invalid UTF-8")+ -- --------------------------------------------------------------------------- -- Input validation -- --------------------------------------------------------------------------- +-- | Rewrite a derivation string's store paths from their identity (the+-- canonical @\/nix\/store@ spelling) to where this machine actually keeps+-- them, at the spawn boundary. A no-op when the two coincide.+onStore :: BuildConfig -> Text -> Text+onStore config+  | storeDirText == defaultStoreDirText = id+  | otherwise = T.replace (defaultStoreDirText <> "/") (storeDirText <> "/")+  where+    storeDirText = T.pack (unStoreDir (bcStoreDir config))+ -- | Check that all inputs needed to build a derivation are present in the store. -- -- Input sources must be valid.  For input derivations, the artifacts a build@@ -317,6 +647,27 @@ -- Environment -- --------------------------------------------------------------------------- +-- | Replace each output's @builtins.placeholder@ sentinel with its+-- build-time path (the same value @$out@ carries).  Only the args and the+-- environment are rewritten; the builder path is left alone, matching+-- upstream, which assigns @builder = drv->builder@ unrewritten and rewrites+-- only @drv->args@ (local-derivation-goal.cc:2170,2176).+rewritePlaceholders :: [(Text, FilePath)] -> Text -> Text+rewritePlaceholders outputDirs value =+  foldr substitute value outputDirs+  where+    substitute (name, path) = T.replace (hashPlaceholder name) (T.pack path)++-- | Apply a build-time rewrite to an environment's names as well as its+-- values.  Upstream rewrites the whole @name=value@ string+-- (local-derivation-goal.cc:2004), so a placeholder in a dynamic attribute+-- name is substituted too; rewriting only the values would hand the builder+-- a variable whose name still held the sentinel.+rewriteEnv :: (Text -> Text) -> Map Text Text -> Map Text Text+rewriteEnv rewrite = Map.fromList . map rewritePair . Map.toList+  where+    rewritePair (name, value) = (rewrite name, rewrite value)+ -- | Build the process environment from the derivation env + standard vars. -- The builder path is used to derive PATH entries - the builder's own -- directory and its sibling @usr\/bin@ are included so that coreutils@@ -325,14 +676,15 @@ -- store paths from declared build dependencies. buildEnvironment ::   BuildConfig ->-  Derivation ->+  Map Text Text ->   FilePath ->   FilePath ->   [(Text, FilePath)] ->   Map Text Text-buildEnvironment config drv builderPath buildDir outputDirs =-  let -- Start with derivation environment-      baseEnv = drvEnv drv+buildEnvironment config decodedEnv builderPath buildDir outputDirs =+  let -- Start with the derivation environment (values decoded at the+      -- spawn boundary by 'decodeBuilderStrings')+      baseEnv = decodedEnv       -- Add output paths: $out, $dev, etc.       outputEnv = Map.fromList [(name, T.pack path) | (name, path) <- outputDirs]       -- Standard build variables@@ -340,25 +692,82 @@         Map.fromList           [ (envNixBuildTop, T.pack buildDir),             (envTmpDir, T.pack buildDir),+            (envTempDir, T.pack buildDir),+            (envTmp, T.pack buildDir),+            (envTemp, T.pack buildDir),             (homeEnvVar, T.pack buildDir),             (envNixStore, T.pack (unStoreDir (bcStoreDir config))),             (envPath, buildPath builderPath),             (envSourceDateEpoch, sourceDateEpochValue)           ]    in -- Priority: output paths > derivation env > standard env-      Map.unions [outputEnv, baseEnv, standardEnv]+      unionEnvs [outputEnv, baseEnv, standardEnv] +-- | Union environment maps left to right (an earlier map's variable wins).+-- On Windows environment names are one case-insensitive namespace, so+-- displacement folds names by per-character uppercase - matching the+-- kernel's env-name comparison - and the winner keeps its own spelling:+-- a build's @PATH@ must displace an inherited @Path@, or both would land+-- in the child's environment block and which one the child sees would be+-- runtime-dependent.  On Unix names are distinct by case and this is+-- 'Map.unions'.+unionEnvs :: [Map Text Text] -> Map Text Text+unionEnvs envs+  | isWindows =+      let foldName = T.map toUpper+          folded =+            [ Map.fromList [(foldName name, (name, val)) | (name, val) <- Map.toList env]+            | env <- envs+            ]+       in Map.fromList (Map.elems (Map.unions folded))+  | otherwise = Map.unions envs++-- | The ambient environment a builder is allowed to see.  Everything+-- else is scrubbed: upstream builds in a cleared environment (its+-- initEnv begins with @env.clear()@ and the child is exec'd with+-- exactly that block), so an undeclared ambient variable reaching a+-- build embeds machine-specific data in its output - undermining the+-- reproducibility SOURCE_DATE_EPOCH exists to pin.+--+-- On Unix the allowance is empty.  A Windows process block cannot be:+-- 'windowsAmbientAllowlist' names what passes through, COMSPEC is+-- synthesized from the allowed SystemRoot, and PATHEXT is pinned to+-- 'pathextValue'.  Names compare case-insensitively on Windows, so the+-- allowlist matches by folded name and a passed variable keeps its+-- ambient spelling.+scrubAmbient :: Map Text Text -> Map Text Text+scrubAmbient ambient+  | not isWindows = Map.empty+  | otherwise =+      let foldName = T.map toUpper+          folded = Map.fromList [(foldName name, (name, val)) | (name, val) <- Map.toList ambient]+          allowed = Map.fromList [pair | key <- windowsAmbientAllowlist, Just pair <- [Map.lookup key folded]]+          synthesized =+            Map.fromList $+              (envPathext, pathextValue)+                : [ (envComspec, root <> "\\System32\\cmd.exe")+                  | Just (_, root) <- [Map.lookup systemRootKey folded]+                  ]+       in unionEnvs [allowed, synthesized]+ -- | Construct the build PATH from the builder's location. -- Includes the builder's directory, its sibling @usr\/bin@ (for MSYS2 -- coreutils bundled with Git for Windows), and system directories. -- On a bootstrapped store, the builder's dir IS a store path, so this -- naturally becomes a store-only PATH.+--+-- A bare-name or dot-relative builder has no directory to derive: @.@+-- here would put the BUILD WORKING DIRECTORY first on PATH, so a file+-- dropped into the build dir would resolve ahead of every tool.  Such+-- builders get the system directories only. buildPath :: FilePath -> Text buildPath builderPath =   let builderDir = takeDirectory builderPath       parentDir = takeDirectory builderDir       -- Builder's own dir + coreutils sibling (MSYS2 layout)-      builderDirs = [builderDir, parentDir </> "usr" </> "bin"]+      builderDirs+        | builderDir == "." = []+        | otherwise = [builderDir, parentDir </> "usr" </> "bin"]       systemDirs =         if System.Info.os == "mingw32"           then ["C:\\Windows\\System32", "C:\\Windows"]@@ -380,28 +789,51 @@ -- | Run the builder process, returning either (exitCode, stderr) on failure -- or () on success. ----- The build environment is overlaid on top of the inherited system--- environment.  Build variables take priority, but system-critical--- variables (e.g. SYSTEMROOT on Windows) pass through.  This matches--- unsandboxed build behavior - proper isolation comes with Phase 5.+-- The child's environment is exactly the build environment plus+-- 'scrubAmbient''s allowance - the ambient environment does not flow+-- through.  Filesystem and process isolation remain future work; the+-- environment no longer waits on them.+--+-- @impureVars@ is the impureEnvVars carve-out: the caller passes the+-- names a fixed-output derivation listed (empty otherwise), and each+-- is copied from the ambient environment - the EMPTY STRING when+-- absent, not omitted, matching upstream's @getEnv(i).value_or("")@.+-- Upstream writes these last in initEnv, so they win even over the+-- derivation's own values.  Names match exactly, as upstream's do. runBuilder ::   FilePath ->   [String] ->   Map Text Text ->+  [Text] ->   FilePath ->   IO (Either (Int, Text) ())-runBuilder builderPath builderArgs buildEnv workDir = do+runBuilder builderPath builderArgs buildEnv impureVars workDir = do   systemEnv <- System.Environment.getEnvironment   let systemMap = Map.fromList [(T.pack k, T.pack v) | (k, v) <- systemEnv]-      -- Build env wins over system env-      mergedEnv = Map.union buildEnv systemMap+      impureAllowance =+        Map.fromList [(var, Map.findWithDefault "" var systemMap) | var <- impureVars]+      -- Impure carve-out wins, then build env, displacing case variants+      -- on Windows; the scrubbed ambient allowance fills underneath.+      mergedEnv = unionEnvs [impureAllowance, buildEnv, scrubAmbient systemMap]       envList = [(T.unpack k, T.unpack v) | (k, v) <- Map.toList mergedEnv]       cp =         (mkBuilderProcess builderPath builderArgs)           { Proc.cwd = Just workDir,             Proc.env = Just envList,             Proc.std_out = Proc.CreatePipe,-            Proc.std_err = Proc.CreatePipe+            Proc.std_err = Proc.CreatePipe,+            -- Wrap the builder's whole process tree in a Win32 job with+            -- kill-on-job-close.  Without it, a builder's grandchildren+            -- (bash -> make -> cc, or anything cmd.exe spawns) outlive an+            -- interrupt: terminateProcess reaches only the direct child,+            -- so the tree is orphaned and keeps running.  With the job,+            -- terminateProcess becomes TerminateJobObject and reaps the+            -- tree, and waitForProcess waits for all of it.  Ignored on+            -- POSIX, where a process group already scopes the children.+            -- A deliberately daemonizing grandchild changes from leaking+            -- to blocking the build until it exits; that is the correct+            -- trade for a build, which owns everything it spawned.+            Proc.use_process_jobs = True           }   (exitCode, _stdout, stderrText) <- Proc.readCreateProcessWithExitCode cp ""   case exitCode of@@ -452,54 +884,107 @@ -- Built-in fetcher (builtin:fetchurl) -- --------------------------------------------------------------------------- --- | Run a @builtin:fetchurl@ derivation: download its @url@ into @$out@ and--- verify the bytes against the derivation's @outputHash@.  Nix's bootstrap+-- | Run a @builtin:fetchurl@ derivation: try URLs in order and verify the+-- written bytes against the derivation's @outputHash@. Nix's bootstrap -- fetcher is baked into the binary because nothing can be fetched before a -- fetcher exists.  Returns the same @Either (exit, msg) ()@ shape as -- 'runBuilder', so the shared output-registration path is reused unchanged. runBuiltinFetchurl :: Derivation -> [(Text, FilePath)] -> IO (Either (Int, Text) ()) runBuiltinFetchurl drv outputDirs =-  case (Map.lookup envUrl (drvEnv drv), lookup envOut outputDirs, fixedOutput) of-    (Nothing, _, _) -> pure (Left (1, "builtin:fetchurl: derivation has no 'url'"))+  case (fetchUrlsFromEnv (drvEnv drv), lookup envOut outputDirs, fixedOutput) of+    (Left err, _, _) -> pure (Left (1, "builtin:fetchurl: " <> err))     (_, Nothing, _) -> pure (Left (1, "builtin:fetchurl: derivation defines no 'out' output"))     (_, _, Nothing) -> pure (Left (1, "builtin:fetchurl: 'out' output has no fixed-output hash"))-    (Just url, Just outPath, Just out) -> do-      downloaded <- downloadUrl url-      case downloaded of-        Left err -> pure (Left (1, "builtin:fetchurl: " <> err))-        Right body -> do-          BS.writeFile outPath body-          pure (verifyFetchHash url out body)+    (Right urls@(firstUrl :| _), Just outPath, Just out)+      -- Invalid specifications reject before any network traffic.+      | recursive -> pure (Left (1, recursiveUnsupportedMessage))+      | Nothing <- hexToBytes (doHash out) ->+          pure (Left (1, malformedExpectedHash firstUrl))+      | otherwise -> case hashInitWithAlgo algo of+          Nothing ->+            pure (Left (1, "builtin:fetchurl: unsupported hash algorithm '" <> algo <> "'"))+          Just ctx -> do+            result <- tryFetchUrlsWith (fetchAndVerify outPath out ctx) urls+            pure $ case result of+              Left err -> Left (1, err)+              Right value -> Right value+      where+        (recursive, algo) = splitHashMode (doHashAlgo out)   where     -- builtin:fetchurl derivations are always fixed-output; the expected hash     -- lives in the canonical output spec (doHashAlgo + doHash), not the env.     fixedOutput = case drvOutputs drv of       (out : _) | not (T.null (doHashAlgo out)) -> Just out       _ -> Nothing+    fetchAndVerify outPath out ctx url = do+      downloaded <- downloadUrlTo url outPath ctx+      pure $ case downloaded of+        Left err -> Left ("builtin:fetchurl: " <> err)+        Right digest -> first snd (verifyFetchedDigest url out digest) --- | Download a URL to a strict 'BS.ByteString' using nova-nix's own linked--- HTTP client (the same 'Network.HTTP.Client' the substituter uses) - no--- external @curl@, which is what makes this a genuine builtin.  Any network--- exception is turned into a 'Left' so it becomes a clean build failure.-downloadUrl :: Text -> IO (Either Text BS.ByteString)-downloadUrl url = do-  attempt <- try fetch-  pure $ case attempt of-    Left (e :: SomeException) -> Left ("download error: " <> T.pack (show e))-    Right result -> result+-- | Nova's mirror extension uses the ASCII-whitespace-separated @urls@ field.+-- An absent field preserves the legacy single @url@ interface. An explicit+-- empty or malformed list is an error, rather than a silent fallback.+fetchUrlsFromEnv :: Map Text BS.ByteString -> Either Text (NonEmpty Text)+fetchUrlsFromEnv env = case Map.lookup envUrls env of+  Just bytes -> do+    text <- decode envUrls bytes+    maybe (Left "'urls' is empty") Right (NE.nonEmpty (filter (not . T.null) (T.split separator text)))+  Nothing -> case Map.lookup envUrl env of+    Nothing -> Left "derivation has no 'url' or 'urls'"+    Just bytes -> do+      url <- decode envUrl bytes+      if T.null url then Left "'url' is empty" else Right (url :| [])   where+    -- Match the fetcher expression's POSIX space class, preserving non-ASCII+    -- URL characters that 'T.words' would otherwise split into new candidates.+    separator char = isAscii char && isSpace char+    decode field = first (const ("'" <> field <> "' contains invalid UTF-8")) . TE.decodeUtf8'++-- | Ordered fallback policy, independent of HTTP and files. Only the first+-- successful /verified/ attempt wins; report every failure if none wins.+-- Exceptions propagate, so cancellation cannot start another download.+tryFetchUrlsWith :: (Monad m) => (Text -> m (Either Text a)) -> NonEmpty Text -> m (Either Text a)+tryFetchUrlsWith attempt = go []+  where+    go !failures (url :| rest) = do+      result <- attempt url+      case result of+        Right value -> pure (Right value)+        Left err -> case NE.nonEmpty rest of+          Nothing -> pure (Left (T.intercalate "\n" (reverse (err : failures))))+          Just urls -> go (err : failures) urls++-- | Download a URL to a file using nova-nix's own linked HTTP client+-- (the same 'Network.HTTP.Client' the substituter uses) - no external+-- @curl@, which is what makes this a genuine builtin.  The body streams+-- to disk through the incremental hash chunk by chunk, so memory stays+-- at chunk size no matter the download's size, and the returned digest+-- is of exactly the written bytes. Synchronous exceptions become a 'Left';+-- cancellation propagates through the shared build cleanup. Every attempt+-- opens the output in WriteMode and starts from the original hash context.+downloadUrlTo :: Text -> FilePath -> IncrementalHash -> IO (Either Text BS.ByteString)+downloadUrlTo url outPath ctx0 =+  fetch `catchSync` \err -> pure (Left ("download error: " <> T.pack (show err)))+  where     fetch :: IO (Either Text BS.ByteString)     fetch = do-      manager <- HTTP.newManager HTTPS.tlsManagerSettings+      manager <- HTTPS.getGlobalManager       request0 <- HTTP.parseRequest (T.unpack url)       let request = request0 {HTTP.requestHeaders = ("User-Agent", fetchUserAgent) : HTTP.requestHeaders request0}-      response <- HTTP.httpLbs request manager-      pure (bodyOrError response)-    bodyOrError response-      | code == httpStatusOk = Right (LBS.toStrict (HTTP.responseBody response))-      | otherwise = Left ("HTTP " <> T.pack (show code) <> " fetching " <> url)-      where-        code = HTTP.statusCode (HTTP.responseStatus response)+      HTTP.withResponse request manager $ \response -> do+        let code = HTTP.statusCode (HTTP.responseStatus response)+        if code /= httpStatusOk+          then pure (Left ("HTTP " <> T.pack (show code) <> " fetching " <> url))+          else System.IO.withBinaryFile outPath System.IO.WriteMode $ \handle ->+            let consume !ctx = do+                  chunk <- HTTP.brRead (HTTP.responseBody response)+                  if BS.null chunk+                    then pure (Right (hashFinalizeBytes ctx))+                    else do+                      BS.hPut handle chunk+                      consume (hashUpdateChunk ctx chunk)+             in consume ctx0  -- | Verify the fetched bytes against the derivation's fixed-output hash, read -- from the canonical output spec.  @doHashAlgo@ is @sha256@/@sha512@/... (or@@ -509,33 +994,53 @@ -- the download - and fail with a clear message rather than a wrong result. verifyFetchHash :: Text -> DerivationOutput -> BS.ByteString -> Either (Int, Text) () verifyFetchHash url out body-  | recursive =-      Left (1, "builtin:fetchurl: recursive outputHashMode (unpack/executable) not yet supported; fetch flat and unpack in a build phase")+  | recursive = Left (1, recursiveUnsupportedMessage)   | otherwise = case (hexToBytes (doHash out), rawHashWithAlgo algo body) of-      (Nothing, _) -> Left (1, "builtin:fetchurl: malformed expected hash for " <> url)+      (Nothing, _) -> Left (1, malformedExpectedHash url)       (_, Nothing) -> Left (1, "builtin:fetchurl: unsupported hash algorithm '" <> algo <> "'")-      (Just expected, Just got)-        | expected == got -> Right ()-        | otherwise ->-            Left-              ( 1,-                "builtin:fetchurl: hash mismatch for "-                  <> url-                  <> "\n  expected: "-                  <> algoField-                  <> ":"-                  <> doHash out-                  <> "\n  got:      "-                  <> algoField-                  <> ":"-                  <> bytesToHexText got-              )+      (Just _, Just got) -> verifyFetchedDigest url out got   where+    (recursive, algo) = splitHashMode (doHashAlgo out)++-- | Compare an already-computed digest of the fetched bytes against the+-- derivation's fixed-output hash - the streaming twin of+-- 'verifyFetchHash', which hashes a buffered body.+verifyFetchedDigest :: Text -> DerivationOutput -> BS.ByteString -> Either (Int, Text) ()+verifyFetchedDigest url out got = case hexToBytes (doHash out) of+  Nothing -> Left (1, malformedExpectedHash url)+  Just expected+    | expected == got -> Right ()+    | otherwise ->+        Left+          ( 1,+            "builtin:fetchurl: hash mismatch for "+              <> url+              <> "\n  expected: "+              <> algoField+              <> ":"+              <> doHash out+              <> "\n  got:      "+              <> algoField+              <> ":"+              <> bytesToHexText got+          )+  where     algoField = doHashAlgo out-    (recursive, algo) = case T.stripPrefix "r:" algoField of-      Just rest -> (True, rest)-      Nothing -> (False, algoField) +-- | Split @outputHashAlgo@ into the recursive-mode marker and the bare+-- algorithm name (@r:sha256@ is recursive @sha256@).+splitHashMode :: Text -> (Bool, Text)+splitHashMode algoField = case T.stripPrefix "r:" algoField of+  Just rest -> (True, rest)+  Nothing -> (False, algoField)++recursiveUnsupportedMessage :: Text+recursiveUnsupportedMessage =+  "builtin:fetchurl: recursive outputHashMode (unpack/executable) not yet supported; fetch flat and unpack in a build phase"++malformedExpectedHash :: Text -> Text+malformedExpectedHash url = "builtin:fetchurl: malformed expected hash for " <> url+ -- --------------------------------------------------------------------------- -- Output registration -- ---------------------------------------------------------------------------@@ -549,9 +1054,9 @@   Store ->   Derivation ->   FilePath ->-  [(Text, FilePath)] ->+  [OutputPlan] ->   IO BuildResult-registerOutputs config store drv _buildDir outputDirs = do+registerOutputs config store drv _buildDir plans = do   let -- Candidates for reference scanning: input sources, the input       -- derivations' realized OUTPUT paths, and this derivation's own outputs.       inputOutputs = fromRight [] (resolveInputOutputs config drv)@@ -560,12 +1065,19 @@       -- the caller (buildWithDeps) would need to pass it through.       -- Register with no deriver for now - queryDeriver will return Nothing.       drvPathText = Nothing-      -- (temp output dir, final store path) for every output, used to detect-      -- self- and cross-output references that appear as build-temp paths.-      tempPairs = [(outDir, doPath out) | (out, (_, outDir)) <- zip (drvOutputs drv) outputDirs]+      -- (fallback dir, the output it stood in for) for every already-valid+      -- output.  Nothing may reference one: the tree is discarded after the+      -- build, so a reference to it is a dangling edge, and upstream avoids+      -- the same thing by rewriting the hashes back out of the contents.+      -- Scanning for it and failing is louder and needs no rewriting.+      fallbackPairs =+        [ (storePathToFilePath (bcStoreDir config) (opScratch p), opFinal p)+        | p <- plans,+          opValid p+        ]   -- Phase 1: scan references and move each output into the store, collecting a   -- PathRegistration (no DB writes yet).  Already-valid outputs are skipped.-  prepared <- mapM (prepareOutput config store allCandidates tempPairs drvPathText) (zip (drvOutputs drv) outputDirs)+  prepared <- mapM (prepareOutput config store allCandidates fallbackPairs drvPathText) (zip (drvOutputs drv) plans)   case sequence prepared of     Left errMsg -> pure (BuildFailure errMsg 1)     Right regs -> do@@ -585,35 +1097,126 @@   [StorePath] ->   [(FilePath, StorePath)] ->   Maybe Text ->-  (DerivationOutput, (Text, FilePath)) ->+  (DerivationOutput, OutputPlan) ->   IO (Either Text (Maybe PathRegistration))-prepareOutput config store candidates tempPairs drvPathText (output, (_outName, outDir)) = do+prepareOutput config store candidates fallbackPairs drvPathText (output, plan) = do   let targetSP = doPath output       targetPath = storePathToFilePath (bcStoreDir config) targetSP+      outDir = storePathToFilePath (bcStoreDir config) (opScratch plan)   -- Check the build actually produced the output (file or directory).   exists <- doesPathExist outDir   if not exists     then pure (Left ("output missing: " <> T.pack outDir))     else do-      -- Validity is a DB fact, not mere disk presence: an output left on disk by-      -- an interrupted build is NOT valid and must be (re-)registered.+      -- Validity is a DB fact, not mere disk presence: an output left on disk+      -- by an interrupted run is NOT valid, and build outputs are not+      -- content-addressed, so a leftover cannot be verified against its path.+      -- Replace it with the freshly built output (upstream's delete-then-move+      -- at output registration) rather than adopt unverifiable bytes;+      -- 'removePathForcibly' clears read-only marks, so a tree an earlier run+      -- already marked read-only cannot wedge the replacement.       valid <- isValid store targetSP       if valid         then pure (Right Nothing)         else do-          onDisk <- doesPathExist targetPath-          -- Scan whichever artifact we will register: a leftover store path if-          -- present, otherwise the fresh build output.-          let scanPath = if onDisk then targetPath else outDir-          inputRefs <- scanReferences (bcStoreDir config) candidates scanPath-          selfRefs <- scanTempReferences tempPairs scanPath-          let refs = dedupStorePaths (inputRefs ++ selfRefs)-          reg <--            if onDisk-              then registrationFor store targetSP drvPathText refs-              else placeInStore store outDir targetSP drvPathText refs-          pure (Right (Just reg))+          -- The builder wrote straight into the store path, so there is+          -- nothing to clear: outDir IS targetPath.  A stale tree from an+          -- interrupted run was removed before the build, not here.+          --+          -- One scan, not two: 'candidates' already carries this+          -- derivation's own outputs, and outDir is the final path, so a+          -- self- or cross-output reference is an ordinary hit.+          leaked <- scanTempReferences fallbackPairs outDir+          case leaked of+            _ : _ -> do+              -- A fallback path is about to stop existing, so a produced+              -- output naming one would be registered with a dangling+              -- reference.  Fail loudly instead, and take the tree back+              -- out so no later run meets it.+              removePathForcibly outDir+              pure+                ( Left+                    ( "output "+                        <> doName output+                        <> " refers to the scratch path of an already-valid output ("+                        <> T.intercalate ", " [spName sp <> "@" <> spHash sp | sp <- leaked]+                        <> "); rebuild every output of this derivation instead"+                    )+                )+            [] -> do+              refs <- dedupStorePaths <$> scanReferences candidates outDir+              reg <- placeInStore store outDir targetSP drvPathText refs+              fixedCheck <- verifyFixedOutput output targetPath+              case fixedCheck of+                Left err -> do+                  -- Wrong bytes for a declared content address: remove the+                  -- placed tree so a later run cannot meet it on disk.+                  removePathForcibly targetPath+                  pure (Left err)+                Right () -> pure (Right (Just reg)) +-- | Re-check a placed fixed-output output against its declared hash.+-- A fixed-output derivation declares both its store path and, via the+-- output spec (@doHashAlgo@ + @doHash@), the exact content the path+-- must carry - but the builder process writes the actual bytes, so the+-- placed result must reproduce the declared digest before it may+-- register as valid (upstream: the fixed-output check in its+-- registerOutputs).  Flat mode hashes the output file's bytes;+-- recursive (@r:@) mode hashes the canonical NAR of the placed tree.+-- Non-fixed outputs (empty @doHashAlgo@) pass unchecked.+verifyFixedOutput :: DerivationOutput -> FilePath -> IO (Either Text ())+verifyFixedOutput out placedPath+  | T.null (doHashAlgo out) = pure (Right ())+  | recursive = do+      -- Re-serialises the placed tree: the registration's NAR hash is+      -- always sha256, while the declared algorithm may be any.+      entry <- ExecBit.serialiseFromPath placedPath+      pure (finish (rawHashWithAlgo algo (NAR.serialise entry)))+  | otherwise = do+      isDir <- doesDirectoryExist placedPath+      if isDir+        then pure (Left (subject <> ": flat outputHashMode, but the output is a directory"))+        else finish <$> hashFileWithAlgo algo placedPath+  where+    (recursive, algo) = splitHashMode (doHashAlgo out)+    subject = "fixed-output '" <> spName (doPath out) <> "'"+    finish Nothing =+      Left (subject <> ": unsupported hash algorithm '" <> algo <> "'")+    finish (Just got) = case hexToBytes (doHash out) of+      Nothing -> Left (subject <> ": malformed expected hash")+      Just expected+        | expected == got -> Right ()+        | otherwise ->+            Left+              ( subject+                  <> ": hash mismatch\n  expected: "+                  <> doHashAlgo out+                  <> ":"+                  <> doHash out+                  <> "\n  got:      "+                  <> doHashAlgo out+                  <> ":"+                  <> bytesToHexText got+              )++-- | Stream a file through the incremental hash without materializing it+-- (a fixed-output file is input-sized).  'Nothing' for an unsupported+-- algorithm.+hashFileWithAlgo :: Text -> FilePath -> IO (Maybe BS.ByteString)+hashFileWithAlgo algo path = case hashInitWithAlgo algo of+  Nothing -> pure Nothing+  Just initialCtx -> System.IO.withBinaryFile path System.IO.ReadMode $ \handle ->+    let consume !ctx = do+          chunk <- BS.hGet handle fixedOutputHashChunk+          if BS.null chunk+            then pure (Just (hashFinalizeBytes ctx))+            else consume (hashUpdateChunk ctx chunk)+     in consume initialCtx++-- | Read-chunk size for hashing a placed output file.+fixedOutputHashChunk :: Int+fixedOutputHashChunk = 65536+ -- | Deduplicate store paths by their (unique) hash. dedupStorePaths :: [StorePath] -> [StorePath] dedupStorePaths = Map.elems . Map.fromList . map (\sp -> (spHash sp, sp))@@ -679,11 +1282,12 @@         Nothing -> Left ("cannot read .drv file: " <> T.pack drvFilePath)         Just content -> fromATerm content --- | Read a file as Text, returning Nothing on any error.--- Used only for reading immutable .drv files from the store.-unsafeReadFile :: FilePath -> Maybe Text+-- | Read a file's raw bytes, returning Nothing on any error.  Used only+-- for reading immutable .drv files from the store - byte IO, never+-- text-mode (no locale decode, no newline translation).+unsafeReadFile :: FilePath -> Maybe BS.ByteString unsafeReadFile path =-  case System.IO.Unsafe.unsafePerformIO (try (TIO.readFile path)) of+  case System.IO.Unsafe.unsafePerformIO (try (BS.readFile path)) of     Left (_ :: SomeException) -> Nothing     Right content -> Just content @@ -721,7 +1325,11 @@ -- | Resolve a single dependency: check cache, try substitution, or build. resolveDep :: BuildConfig -> Store -> Derivation -> IO (Either Text DepStatus) resolveDep config store drv = do-  cached <- isOutputCached store drv+  -- Every output, not just the first: a derivation whose first output is+  -- valid and whose second was deleted would otherwise be logged cached+  -- and skipped, and the missing output would stay missing.  Upstream+  -- skips a build only when all of them are valid.+  cached <- isJust <$> allOutputsValid store drv   if cached     then pure (Right Cached)     else do@@ -735,34 +1343,77 @@             BuildFailure msg code ->               pure (Left ("exit " <> T.pack (show code) <> ": " <> msg)) --- | Check whether the first output of a derivation is already in the store.-isOutputCached :: Store -> Derivation -> IO Bool-isOutputCached store drv = case drvOutputs drv of-  (out : _) -> isValid store (doPath out)-  [] -> pure False- -- | Log dependency resolution status to stderr. logDepStatus :: DepStatus -> Derivation -> IO () logDepStatus status drv =   TIO.hPutStrLn System.IO.stderr ("  " <> statusTag status <> " " <> formatDrvName drv)  -- | Try to substitute all outputs of a derivation from binary caches.--- Returns True if all outputs were successfully substituted.+-- Returns True if every output was substituted or already valid.+--+-- Registration is all-or-nothing and batched: every output is verified+-- and unpacked first, then recorded in one 'registerPaths' transaction,+-- so cross-output reference edges land after both endpoints' rows exist+-- and a partial substitution registers nothing (the subsequent build+-- starts from unregistered outputs and 'prepareOutput' replaces the+-- leftover unpacked trees).+--+-- Each substituted output arrives with its path lock STILL HELD (see+-- 'Nix.Substituter.SubstSuccess'), and the exclusion must survive until+-- the registration transaction commits - released earlier, another+-- process could meet the unpacked-but-unregistered window and delete+-- the tree the row is about to describe.  Every held lock is released+-- here on every exit path (finally), including failures that never+-- reach registration.+--+-- A registration the database REFUSES - its unregistered-referent guard,+-- reachable only through cache-declared references this store has never+-- seen - is a substitution failure like any other: the transaction+-- recorded nothing, so report it and fall back to building locally. trySubstituteOutputs :: BuildConfig -> Store -> Derivation -> IO Bool trySubstituteOutputs config store drv   | null (bcCaches config) = pure False   | otherwise = do-      results <- mapM (trySubstitute store (bcCaches config) . doPath) (drvOutputs drv)-      pure (all isSubstSuccess results)+      heldLocks <- newIORef []+      registerSubstituted heldLocks+        `finally` (readIORef heldLocks >>= mapM_ releasePathLock)   where-    isSubstSuccess (SubstSuccess _) = True-    isSubstSuccess _ = False+    registerSubstituted :: IORef [PathLock] -> IO Bool+    registerSubstituted heldLocks = do+      results <- mapM (substituteOne heldLocks . doPath) (drvOutputs drv)+      case traverse substOutcome results of+        Nothing -> pure False+        Just outcomes -> do+          registered <- try (registerPaths (stDB store) (catMaybes outcomes))+          case registered of+            Right () -> pure True+            Left (e :: IOException) -> do+              TIO.hPutStrLn+                System.IO.stderr+                ("  [subst] registration refused, building instead: " <> T.pack (displayException e))+              pure False+    -- Record a returned lock the moment it exists, so the enclosing+    -- finally owns it even when a later output's attempt fails or+    -- throws.+    substituteOne heldLocks sp = do+      result <- trySubstitute store (bcCaches config) sp+      case result of+        SubstSuccess _ lock -> atomicModifyIORef' heldLocks (\locks -> (lock : locks, ()))+        _ -> pure ()+      pure result+    -- An already-valid output counts as substituted but contributes no+    -- registration row (and holds no lock).+    substOutcome (SubstSuccess reg _) = Just (Just reg)+    substOutcome SubstAlreadyValid = Just Nothing+    substOutcome SubstNotFound = Nothing+    substOutcome (SubstError _) = Nothing --- | Format a derivation name for status output.+-- | Format a derivation name for status output (display-only, so the+-- byte-string env value decodes lossily). formatDrvName :: Derivation -> Text formatDrvName drv =   case Map.lookup "name" (drvEnv drv) of-    Just n -> n+    Just n -> TE.decodeUtf8With lenientDecode n     Nothing -> case drvOutputs drv of       (out : _) -> spName (doPath out)       [] -> "<unknown>"
src/Nix/Builder/Unpack.hs view
@@ -39,8 +39,16 @@     -- * Derivation environment keys     envSrcs, +    -- * Extraction budget+    UnpackLimits (..),+    defaultUnpackLimits,+     -- * Running     runBuiltinUnpack,++    -- * Path validation (exposed for testing)+    entryComponents,+    resolveLinkTarget,   ) where @@ -50,26 +58,28 @@ import Control.Exception (SomeException, try) import Control.Monad (when) import Data.Bits ((.&.))+import qualified Data.ByteString as BS import qualified Data.ByteString.Lazy as BL import Data.Char (toLower)+import Data.Int (Int64) import Data.List (isPrefixOf, isSuffixOf) import qualified Data.Map.Strict as Map import Data.Text (Text) import qualified Data.Text as T+import qualified Data.Text.Encoding as TE import Nix.Derivation (Derivation (..))+import qualified Nix.Store.ExecBit as ExecBit+import Nix.Store.Path (StoreDir, defaultStoreDir, parseStorePath, storePathToFilePath) import System.Directory   ( copyFile,     createDirectoryIfMissing,     doesDirectoryExist,     doesFileExist,     doesPathExist,-    getPermissions,+    getFileSize,     listDirectory,-    setOwnerExecutable,-    setPermissions,   )-import System.FilePath (isAbsolute, joinPath, splitDirectories, takeDirectory, (</>))-import qualified System.Info+import System.FilePath (isAbsolute, isPathSeparator, joinPath, splitDirectories, takeDirectory, (</>))  -- --------------------------------------------------------------------------- -- Named constants@@ -78,7 +88,8 @@ -- | The magic builder string for the built-in archive extractor.  A -- derivation with this builder is not executed as a process - the Builder -- extracts its @srcs@ archives into @$out@ (see 'runBuiltinUnpack').-builtinUnpackBuilder :: Text+-- Bytes, matching the 'drvBuilder' field it is compared against.+builtinUnpackBuilder :: BS.ByteString builtinUnpackBuilder = "builtin:unpack"  -- | Derivation environment key holding the whitespace-separated archive@@ -91,6 +102,37 @@ unpackOutputName :: Text unpackOutputName = "out" +-- | Extraction budget for one @builtin:unpack@ build, across every+-- archive in @srcs@: total bytes materialized and total filesystem+-- entries created.  Archive bytes decompress and expand with no+-- relation to their compressed size, so extraction without a budget+-- lets a small input produce an output with no upper bound.+data UnpackLimits = UnpackLimits+  { ulMaxBytes :: !Int64,+    ulMaxEntries :: !Int+  }+  deriving (Eq, Show)++-- | Default extraction budget: 4 GiB and one million entries.  The+-- MSYS2 toolchain seed decompresses to well under half of either, and+-- 4 GiB matches the largest NAR nova-cache's server accepts.+defaultUnpackLimits :: UnpackLimits+defaultUnpackLimits =+  UnpackLimits+    { ulMaxBytes = 4 * 1024 * 1024 * 1024,+      ulMaxEntries = 1000000+    }++-- | Charge one created entry plus its bytes against the remaining+-- budget; a breached budget is a loud extraction failure.+chargeBudget :: Text -> Int64 -> UnpackLimits -> Either Text UnpackLimits+chargeBudget label bytes (UnpackLimits remainingBytes remainingEntries)+  | remainingEntries < 1 =+      Left ("extraction exceeds the entry budget at: " <> label)+  | bytes > remainingBytes =+      Left ("extraction exceeds the size budget at: " <> label)+  | otherwise = Right (UnpackLimits (remainingBytes - bytes) (remainingEntries - 1))+ -- | Owner-execute bit of a tar header's mode field (octal @0o100@). ownerExecuteMode :: TarEntry.Permissions ownerExecuteMode = 0o100@@ -103,11 +145,6 @@ paxPerFileCode = 'x' paxGlobalCode = 'g' --- | True on a Windows host.  NTFS has no executable bit (PATHEXT decides),--- so tar mode bits are only materialized on Unix.-isWindowsHost :: Bool-isWindowsHost = System.Info.os == "mingw32"- -- --------------------------------------------------------------------------- -- Entry types after long-name decoding -- ---------------------------------------------------------------------------@@ -132,38 +169,55 @@ -- @Either (exit, msg) ()@ shape as the process runner, so the shared -- output-validation and registration path in "Nix.Builder" is reused -- unchanged.-runBuiltinUnpack :: Derivation -> [(Text, FilePath)] -> IO (Either (Int, Text) ())-runBuiltinUnpack drv outputDirs =+--+-- @srcs@ carries eval's canonical @\/nix\/store@ spelling, and the physical+-- root may live elsewhere - @C:\\nix\\store@ on Windows, where a bare+-- \/-rooted path resolves against the current DRIVE - so each entry is+-- parsed as a store path and rendered through the store dir rather than+-- opened verbatim (#101).  On Unix the rendering is the identity.+runBuiltinUnpack :: StoreDir -> UnpackLimits -> Derivation -> [(Text, FilePath)] -> IO (Either (Int, Text) ())+runBuiltinUnpack storeDir limits drv outputDirs =   case (Map.lookup envSrcs (drvEnv drv), lookup unpackOutputName outputDirs) of     (Nothing, _) -> failure "derivation has no 'srcs'"-    (Just srcs, _)-      | null (sourcePaths srcs) -> failure "'srcs' is empty"-    (_, Nothing) -> failure "derivation defines no 'out' output"-    (Just srcs, Just outDir) -> do-      createDirectoryIfMissing True outDir-      result <- unpackAll outDir (sourcePaths srcs)-      pure $ case result of-        Left msg -> Left (1, "builtin:unpack: " <> msg)-        Right () -> Right ()+    (Just srcsBytes, mOutDir) -> case TE.decodeUtf8' srcsBytes of+      -- Store paths are ASCII; a non-UTF-8 srcs value cannot name any.+      Left _ -> failure "'srcs' contains invalid UTF-8"+      Right srcs+        | null (T.words srcs) -> failure "'srcs' is empty"+        | otherwise -> case traverse resolveSrc (T.words srcs) of+            Left bad -> failure ("'srcs' entry is not a store path: " <> bad)+            Right archives -> case mOutDir of+              Nothing -> failure "derivation defines no 'out' output"+              Just outDir -> do+                createDirectoryIfMissing True outDir+                result <- unpackAll outDir limits archives+                pure $ case result of+                  Left msg -> Left (1, "builtin:unpack: " <> msg)+                  Right () -> Right ()   where     failure msg = pure (Left (1, "builtin:unpack: " <> msg))-    sourcePaths = map T.unpack . T.words+    resolveSrc word = case parseStorePath defaultStoreDir word of+      Just sp -> Right (storePathToFilePath storeDir sp)+      Nothing -> Left word --- | Extract archives in order, stopping at the first failure.-unpackAll :: FilePath -> [FilePath] -> IO (Either Text ())-unpackAll _ [] = pure (Right ())-unpackAll outDir (archive : rest) = do-  result <- unpackArchive outDir archive+-- | Extract archives in order, stopping at the first failure.  One+-- budget spans all of them: the caps bound the BUILD's output, not any+-- single archive.+unpackAll :: FilePath -> UnpackLimits -> [FilePath] -> IO (Either Text ())+unpackAll _ _ [] = pure (Right ())+unpackAll outDir budget (archive : rest) = do+  result <- unpackArchive outDir budget archive   case result of     Left err -> pure (Left err)-    Right () -> unpackAll outDir rest+    Right remaining -> unpackAll outDir remaining rest --- | Unpack one archive into @outDir@.  The decompressor is chosen by file--- extension; the tar stream is decoded (GNU + pax long names) and extracted--- entry by entry.  Decompression errors surface lazily mid-stream, so the--- whole extraction is exception-wrapped into a clean failure.-unpackArchive :: FilePath -> FilePath -> IO (Either Text ())-unpackArchive outDir archivePath = do+-- | Unpack one archive into @outDir@, returning the budget left for the+-- archives after it.  The decompressor is chosen by file extension; the+-- tar stream is decoded (GNU + pax long names) and extracted entry by+-- entry.  Decompression errors surface lazily mid-stream, so the whole+-- extraction is exception-wrapped into a clean failure.+unpackArchive :: FilePath -> UnpackLimits -> FilePath -> IO (Either Text UnpackLimits)+unpackArchive outDir budget archivePath = do   attempt <- try run   pure $ case attempt of     Left (e :: SomeException) ->@@ -174,7 +228,7 @@       Nothing -> pure (Left ("unsupported archive format: " <> T.pack archivePath))       Just decoder -> do         raw <- BL.readFile archivePath-        extractEntries outDir (Tar.decodeLongNames (Tar.read (decoder raw)))+        extractEntries outDir budget (Tar.decodeLongNames (Tar.read (decoder raw)))  -- | Choose a decompressor from the archive file name.  @.tar.zst@ (MSYS2 -- packages) and plain @.tar@ are supported.  MSYS2's zstd frames do not@@ -192,48 +246,58 @@ -- Entry extraction -- --------------------------------------------------------------------------- --- | Walk the entry stream, extracting each entry under @outDir@.-extractEntries :: FilePath -> DecodedEntries -> IO (Either Text ())+-- | Walk the entry stream, extracting each entry under @outDir@ and+-- threading the remaining extraction budget.+extractEntries :: FilePath -> UnpackLimits -> DecodedEntries -> IO (Either Text UnpackLimits) extractEntries outDir = go   where-    go stream = case stream of-      Tar.Done -> pure (Right ())+    go budget stream = case stream of+      Tar.Done -> pure (Right budget)       Tar.Fail err -> pure (Left ("malformed archive: " <> T.pack (show err)))       Tar.Next entry rest -> do-        result <- extractEntry outDir entry+        result <- extractEntry outDir budget entry         case result of           Left err -> pure (Left err)-          Right () -> go rest+          Right remaining -> go remaining rest  -- | Extract a single entry, after validating its path stays inside the -- archive root and skipping pacman package metadata.-extractEntry :: FilePath -> DecodedEntry -> IO (Either Text ())-extractEntry outDir entry =+extractEntry :: FilePath -> UnpackLimits -> DecodedEntry -> IO (Either Text UnpackLimits)+extractEntry outDir budget entry =   case entryComponents (TarEntry.entryTarPath entry) of     Left err -> pure (Left err)     -- The archive root itself (an entry for "." or "./").-    Right [] -> pure (Right ())+    Right [] -> pure (Right budget)     Right comps-      | isPackageMetadata comps -> pure (Right ())-      | otherwise -> extractContent outDir comps entry+      | isPackageMetadata comps -> pure (Right budget)+      | otherwise -> extractContent outDir budget comps entry --- | Extract a path-validated entry's content.  @comps@ is non-empty (the+-- | Extract a path-validated entry's content, charging every created+-- entry and its bytes against the budget.  @comps@ is non-empty (the -- empty case is consumed by 'extractEntry').-extractContent :: FilePath -> [FilePath] -> DecodedEntry -> IO (Either Text ())-extractContent outDir comps entry =+extractContent :: FilePath -> UnpackLimits -> [FilePath] -> DecodedEntry -> IO (Either Text UnpackLimits)+extractContent outDir budget comps entry =   case TarEntry.entryContent entry of-    Tar.Directory -> do-      createDirectoryIfMissing True dest-      pure (Right ())-    Tar.NormalFile bytes _size -> do-      fresh <- freshDestination dest-      case fresh of+    Tar.Directory ->+      case chargeBudget pathText 0 budget of         Left err -> pure (Left err)-        Right () -> do-          createDirectoryIfMissing True (takeDirectory dest)-          BL.writeFile dest bytes-          when (executableEntry entry && not isWindowsHost) (markExecutable dest)-          pure (Right ())+        Right remaining -> do+          createDirectoryIfMissing True dest+          pure (Right remaining)+    Tar.NormalFile bytes size ->+      -- The header size is charged before the lazy content is realized,+      -- so a breach rejects the entry rather than materializing it.+      case chargeBudget pathText size budget of+        Left err -> pure (Left err)+        Right remaining -> do+          fresh <- freshDestination dest+          case fresh of+            Left err -> pure (Left err)+            Right () -> do+              createDirectoryIfMissing True (takeDirectory dest)+              BL.writeFile dest bytes+              when (executableEntry entry) (ExecBit.markExecutable dest)+              pure (Right remaining)     -- A symlink target is relative to the link's own directory.     Tar.SymbolicLink target ->       copyLinkTarget "symlink" (resolveLinkTarget parentComps target)@@ -241,7 +305,7 @@     Tar.HardLink target ->       copyLinkTarget "hardlink" (entryComponents target)     Tar.OtherEntryType code _ _-      | code == paxPerFileCode || code == paxGlobalCode -> pure (Right ())+      | code == paxPerFileCode || code == paxGlobalCode -> pure (Right budget)       | otherwise ->           pure (Left ("unsupported tar entry type '" <> T.singleton code <> "': " <> pathText))     Tar.CharacterDevice _ _ -> unsupportedSpecial "character device"@@ -266,12 +330,27 @@           case fresh of             Left err -> pure (Left err)             Right () -> do-              createDirectoryIfMissing True (takeDirectory dest)-              copyFile targetPath dest-              pure (Right ())+              size <- getFileSize targetPath+              case chargeBudget pathText (fromIntegral size) budget of+                Left err -> pure (Left err)+                Right remaining -> do+                  createDirectoryIfMissing True (takeDirectory dest)+                  copyFile targetPath dest+                  -- copyFile carries the unnamed stream only, so on+                  -- Windows a hardlink entry materialized as a copy would+                  -- arrive without its target's exec mark.+                  ExecBit.copyExecMark targetPath dest+                  pure (Right remaining)       | isDir = do-          copyTree targetPath dest-          pure (Right ())+          -- The same collision guard as regular entries: without it a+          -- directory link copy silently merges into content another+          -- archive extracted, making the result entry-order-dependent.+          fresh <- freshDestination dest+          case fresh of+            Left err -> pure (Left err)+            Right () -> case chargeBudget pathText 0 budget of+              Left err -> pure (Left err)+              Right remaining -> copyTree remaining targetPath dest       | otherwise =           pure             ( Left@@ -294,6 +373,7 @@ entryComponents :: FilePath -> Either Text [FilePath] entryComponents raw   | isAbsolute raw = Left ("absolute entry path: " <> T.pack raw)+  | startsAtRoot comps = Left ("rooted entry path: " <> T.pack raw)   | ".." `elem` comps = Left ("entry path escapes archive root: " <> T.pack raw)   | any (elem ':') comps = Left ("entry path contains ':': " <> T.pack raw)   | otherwise = Right comps@@ -306,6 +386,7 @@ resolveLinkTarget :: [FilePath] -> FilePath -> Either Text [FilePath] resolveLinkTarget parentComps target   | isAbsolute target = Left ("absolute symlink target: " <> T.pack target)+  | startsAtRoot targetComps = Left ("rooted symlink target: " <> T.pack target)   | otherwise = walk (reverse parentComps) targetComps   where     targetComps = filter (/= ".") (splitDirectories target)@@ -318,6 +399,19 @@         | ':' `elem` comp -> Left ("symlink target contains ':': " <> T.pack target)         | otherwise -> walk (comp : stack) rest +-- | True when the first path component is a bare separator, i.e. a path rooted+-- at the current drive rather than a named location (a leading @/@ or @\\@ with+-- no drive letter).  On Windows 'isAbsolute' returns 'False' for such paths -+-- it wants a drive letter or a UNC prefix - yet 'System.FilePath.combine' still+-- discards the output directory when the right operand begins with a separator,+-- so joining one onto @outDir@ drops @outDir@ and the write lands at the drive+-- root.  'isPathSeparator' follows the host convention, so on POSIX a leading+-- @\\@ is an ordinary filename character and correctly is not treated as rooted.+startsAtRoot :: [FilePath] -> Bool+startsAtRoot comps = case comps of+  (comp : _) -> not (null comp) && all isPathSeparator comp+  [] -> False+ -- | pacman package metadata at the archive root (@.PKGINFO@, @.BUILDINFO@, -- @.MTREE@, @.INSTALL@): describes the package to pacman, is not part of the -- installed tree, and collides across packages when several archives merge@@ -346,24 +440,35 @@ executableEntry :: DecodedEntry -> Bool executableEntry entry = TarEntry.entryPermissions entry .&. ownerExecuteMode /= 0 --- | Materialize the executable bit on Unix hosts.-markExecutable :: FilePath -> IO ()-markExecutable path = do-  perms <- getPermissions path-  setPermissions path (setOwnerExecutable True perms)- -- | Recursively copy a directory tree (used to materialize directory--- symlinks, which cannot be store-portable links on Windows).-copyTree :: FilePath -> FilePath -> IO ()-copyTree src dest = do+-- symlinks, which cannot be store-portable links on Windows), charging+-- every created entry and byte against the budget: each tree copy+-- duplicates content already extracted and charged once, so uncharged+-- copies would let K link entries multiply the output roughly 2^K-fold.+copyTree :: UnpackLimits -> FilePath -> FilePath -> IO (Either Text UnpackLimits)+copyTree budget0 src dest = do   createDirectoryIfMissing True dest   names <- listDirectory src-  mapM_ copyOne names+  go budget0 names   where-    copyOne name = do+    go budget [] = pure (Right budget)+    go budget (name : rest) = do       let from = src </> name           to = dest </> name       isDir <- doesDirectoryExist from-      if isDir-        then copyTree from to-        else copyFile from to+      copied <-+        if isDir+          then case chargeBudget (T.pack to) 0 budget of+            Left err -> pure (Left err)+            Right remaining -> copyTree remaining from to+          else do+            size <- getFileSize from+            case chargeBudget (T.pack to) (fromIntegral size) budget of+              Left err -> pure (Left err)+              Right remaining -> do+                copyFile from to+                ExecBit.copyExecMark from to+                pure (Right remaining)+      case copied of+        Left err -> pure (Left err)+        Right remaining -> go remaining rest
src/Nix/Builtins.hs view
@@ -12,9 +12,11 @@      -- * NIX_PATH parsing     parseNixPath,+    splitNixPath,   ) where +import Data.Char (isAsciiLower, isAsciiUpper) import Data.Int (Int64) import qualified Data.Map.Strict as Map import Data.Text (Text)@@ -22,7 +24,7 @@ import Foreign.Ptr (nullPtr) import Nix.Eval (Env (..), NixValue (..), Thunk (..), attrSetFromMap, builtinNames, currentSystemStr, evaluated) import Nix.Eval.Types (clistFromThunks, mkStr, newCEnv, thunkToCPtr)-import Nix.Store.Path (platformStoreDirText)+import Nix.Store.Path (defaultStoreDirText)  -- | The initial environment containing all builtins. --@@ -38,33 +40,39 @@ builtinEnv :: Int64 -> [Thunk] -> Env builtinEnv timestamp searchPaths =   let scope =-        attrSetFromMap $-          Map.fromList $-            -- Values-            [ ("true", evaluated (VBool True)),-              ("false", evaluated (VBool False)),-              ("null", evaluated VNull),-              ("builtins", evaluated (builtinsAttrSet timestamp searchPaths)),-              -- Search path support: <name> desugars to __findFile __nixPath "name"-              -- (matching C++ Nix's parser desugaring).-              ("__findFile", evaluated (VBuiltin "findFile" [])),-              ("__nixPath", evaluated (VList (clistFromThunks (map thunkToCPtr searchPaths))))-            ]-              -- Top-level builtin functions (available without builtins. prefix)-              ++ map topLevelBuiltin topLevelBuiltinNames+        attrSetFromMap+          $ Map.fromList+          $+          -- Values+          [ ("true", evaluated (VBool True)),+            ("false", evaluated (VBool False)),+            ("null", evaluated VNull),+            ("builtins", evaluated (builtinsAttrSet timestamp searchPaths)),+            -- Search path support: <name> desugars to __findFile __nixPath "name"+            -- (matching C++ Nix's parser desugaring).+            ("__findFile", evaluated (VBuiltin "findFile" [])),+            ("__nixPath", evaluated (VList (clistFromThunks (map thunkToCPtr searchPaths))))+          ]+            -- Top-level builtin functions (available without builtins. prefix)+            ++ map topLevelBuiltin topLevelBuiltinNames    in newCEnv nullPtr 0 (Just scope) Nothing nullPtr 0  -- | Builtins exposed at the top level (without @builtins.@ prefix). -- This matches real Nix - nixpkgs uses these unqualified everywhere.+-- Exactly upstream's unprefixed surface: fetchurl and toFile are+-- deliberately NOT here (upstream exposes them only under @builtins.@,+-- and nixpkgs relies on @with pkgs; fetchurl@ binding pkgs.fetchurl). topLevelBuiltinNames :: [Text] topLevelBuiltinNames =   [ "abort",     "baseNameOf",+    "break",     "derivation",+    "derivationStrict",     "dirOf",     "fetchGit",     "fetchTarball",-    "fetchurl",+    "fromTOML",     "import",     "isNull",     "map",@@ -72,7 +80,6 @@     "removeAttrs",     "scopedImport",     "throw",-    "toFile",     "toString"   ] @@ -102,7 +109,10 @@     [ ("true", evaluated (VBool True)),       ("false", evaluated (VBool False)),       ("null", evaluated VNull),-      ("storeDir", evaluated (mkStr platformStoreDirText)),+      -- Canonical, not platform: eval-visible store paths carry the+      -- /nix/store spelling on every platform, and storeDir must agree+      -- with them (upstream returns the store dir its paths are under).+      ("storeDir", evaluated (mkStr defaultStoreDirText)),       ("nixVersion", evaluated (mkStr "2.24.0")),       ("langVersion", evaluated (VInt 6)),       ("nixPath", evaluated (VList (clistFromThunks (map thunkToCPtr searchPaths)))),@@ -142,20 +152,48 @@                 )             ) --- | Split a NIX_PATH string on colon separators, respecting Windows--- drive letters.  A colon followed by @\\@ or @/@ (e.g. @C:\\@) is--- part of a path, not a separator.+-- | Split a NIX_PATH string on colon separators.  A colon stays part of+-- its entry, rather than separating, in exactly two shapes:+--+-- * a URL colon - followed by @//@ - so an entry like+--   @nixpkgs=https://example.com/nixpkgs.tar.gz@ stays whole (upstream's+--   NIX_PATH parser keeps pseudo-URL entries whole);+-- * a Windows drive colon - preceded by a single ASCII letter that opens+--   the entry or follows @=@, and followed by @\\@ or @/@ - so @C:\\x@,+--   @C:/x@, and @nixpkgs=C:\\x@ stay whole.+--+-- Every other colon separates, so a Unix-style list of absolute paths+-- (@/foo:/bar@) splits at each colon: @/foo@ does not end in a drive+-- letter, and a lone @/@ after the colon is not a URL. splitNixPath :: Text -> [Text]-splitNixPath = go T.empty+splitNixPath = go []   where-    go acc remaining = case T.uncons remaining of-      Nothing -> [acc | not (T.null acc)]-      Just (':', rest)-        | isDriveSep rest -> go (acc <> ":" <> T.take 1 rest) (T.drop 1 rest)-        | otherwise -> acc : go T.empty rest-      Just (c, rest) -> go (T.snoc acc c) rest-    -- After a colon, if the next char is \ or /, it's a drive letter-    isDriveSep t = case T.uncons t of-      Just ('\\', _) -> True-      Just ('/', _) -> True-      _ -> False+    -- Accumulates reversed chunks and concatenates once per entry, so a+    -- long entry costs O(n) instead of the O(n^2) of per-character snoc.+    go !chunks remaining =+      let (chunk, rest) = T.break (== ':') remaining+       in case T.uncons rest of+            Nothing ->+              let entry = T.concat (reverse (chunk : chunks))+               in [entry | not (T.null entry)]+            Just (_, afterColon)+              | keepsColon (null chunks) chunk afterColon ->+                  go (T.take 1 afterColon : ":" : chunk : chunks) (T.drop 1 afterColon)+              | otherwise ->+                  T.concat (reverse (chunk : chunks)) : go [] afterColon+    -- Whether the colon between chunk and afterColon is a URL or drive+    -- colon (the two shapes above).  atEntryStart says chunk opens its+    -- entry (nothing absorbed before it), so a lone letter can only be a+    -- drive letter there.+    keepsColon atEntryStart chunk afterColon+      | T.isPrefixOf "//" afterColon = True+      | startsWithPathSep afterColon = endsInDriveLetter atEntryStart chunk+      | otherwise = False+    startsWithPathSep t = case T.uncons t of+      Just (c, _) -> c == '/' || c == '\\'+      Nothing -> False+    endsInDriveLetter atEntryStart chunk = case T.unsnoc chunk of+      Just (beforeLetter, letter) ->+        (isAsciiUpper letter || isAsciiLower letter)+          && (T.isSuffixOf "=" beforeLetter || (atEntryStart && T.null beforeLetter))+      Nothing -> False
+ src/Nix/Compression.hs view
@@ -0,0 +1,66 @@+-- | The narinfo compression register - one vocabulary for every+-- consumer (the substituter's dispatch, and push once it writes+-- compressed objects), so the accepted set exists exactly once and+-- cannot drift between encodings.+--+-- Upstream note: C++ Nix decodes an absent or empty @Compression@+-- field as @bzip2@ (the field's historical default).  nova-cache's+-- parser defaults only the ABSENT key; a present-but-empty+-- @Compression:@ line parses to the empty string and arrives here+-- verbatim, so this register handles that spelling itself - the+-- 'T.null' branch is reached by ordinary narinfos off the wire and is+-- not dead code.  Both spellings decode to 'CompressionBzip2', so+-- such a narinfo substitutes exactly as upstream would rather than+-- failing over a field that looks like nothing at all.+module Nix.Compression+  ( NarCompression (..),+    parseNarCompression,+    compressionNameNone,+    compressionNameXz,+    compressionNameZstd,+    compressionNameBzip2,+  )+where++import Data.Text (Text)+import qualified Data.Text as T++-- | A compression codec the substituter can decode.  Parsing into this+-- sum happens once at the narinfo boundary; every dispatch downstream+-- is an exhaustive match, so a new codec is added by extending the type+-- and following the compiler.+data NarCompression+  = CompressionNone+  | CompressionXz+  | CompressionZstd+  | CompressionBzip2+  deriving (Eq, Show)++-- | The wire spelling of the identity codec.+compressionNameNone :: Text+compressionNameNone = "none"++-- | The wire spelling of the xz codec (cache.nixos.org's format).+compressionNameXz :: Text+compressionNameXz = "xz"++-- | The wire spelling of the zstd codec (the modern caches' format,+-- and the compression @nova-nix push@ can produce).+compressionNameZstd :: Text+compressionNameZstd = "zstd"++-- | The wire spelling of the bzip2 codec (the historical caches'+-- format, and what upstream decodes an absent field as).+compressionNameBzip2 :: Text+compressionNameBzip2 = "bzip2"++-- | Parse a narinfo @Compression@ value.  Unsupported codecs reject by+-- name; the empty string decodes as the codec upstream defaults it to+-- (see the module header).+parseNarCompression :: Text -> Either Text NarCompression+parseNarCompression name+  | name == compressionNameNone = Right CompressionNone+  | name == compressionNameXz = Right CompressionXz+  | name == compressionNameZstd = Right CompressionZstd+  | name == compressionNameBzip2 || T.null name = Right CompressionBzip2+  | otherwise = Left ("unsupported compression: " <> name)
+ src/Nix/Config.hs view
@@ -0,0 +1,190 @@+-- | Nix configuration: the @nix.conf@ / @NIX_CONFIG@ settings that decide+-- which binary caches a machine substitutes from and which public keys it+-- trusts.+--+-- == The format+--+-- One @name = value@ assignment per line.  A @#@ truncates the rest of the+-- line (a comment).  A list value is whitespace separated.  These are the+-- rules upstream's @parseConfigFiles@ applies (comment truncation, no line+-- continuation, whitespace tokenizing), matched here.+--+-- == Precedence+--+-- Sources are folded weakest first, so a later source overrides an earlier+-- one.  A plain assignment REPLACES the accumulated value; an @extra-@+-- prefixed assignment APPENDS to it.  The caller supplies the sources in+-- order (built-in default, then files, then @NIX_CONFIG@, then the command+-- line), so the command line wins, exactly as upstream orders them.+--+-- == Security+--+-- @trusted-public-keys@ decides which signatures a substituted path is+-- accepted under.  The precedence is therefore load bearing: a source that+-- REPLACES the trusted set where it should APPEND, or an @extra-@ that is+-- mishandled, silently widens what the machine trusts.  The fold here is+-- pure and total so the whole rule set can be tested directly.+--+-- Not yet modelled (tracked separately): @include@ \/ @!include@+-- directives, the @\/etc\/nix@ system file and the @XDG_CONFIG_DIRS@+-- cascade.  A line opening with @include@ is refused loudly rather than+-- silently skipped, so a config that depends on one fails visibly.+module Nix.Config+  ( -- * Resolved settings+    NixConfig (..),+    defaultNixConfig,++    -- * Parsing and folding+    ConfigAssignment (..),+    parseConfigText,+    applyAssignment,+    applyConfigText,+    resolveConfig,+  )+where++import Data.Text (Text)+import qualified Data.Text as T++-- ---------------------------------------------------------------------------+-- Settings+-- ---------------------------------------------------------------------------++-- | The subset of Nix settings that this layer resolves: the binary caches+-- to try and the public keys their signatures are trusted under.  Both are+-- ordered lists (upstream's @substituters@ is a @Strings@, and+-- @trusted-public-keys@ is too); order is preserved on write, though it+-- does not affect key acceptance (any trusted key is enough).+data NixConfig = NixConfig+  { ncSubstituters :: ![Text],+    ncTrustedPublicKeys :: ![Text]+  }+  deriving (Eq, Show)++-- | The baseline the fold starts from: no substituters and no trusted+-- keys.  This is nova-nix's existing default (nothing is substituted+-- unless configured), a deliberate divergence from upstream's+-- cache.nixos.org default: turning a cache on for every machine is the+-- operator's decision to make in a config file, not a built-in.+defaultNixConfig :: NixConfig+defaultNixConfig = NixConfig {ncSubstituters = [], ncTrustedPublicKeys = []}++-- ---------------------------------------------------------------------------+-- Parsing+-- ---------------------------------------------------------------------------++-- | One parsed @name = value@ assignment, before its name is resolved+-- against the known settings and aliases.+data ConfigAssignment = ConfigAssignment+  { caName :: !Text,+    caValue :: !Text+  }+  deriving (Eq, Show)++-- | Parse config text into ordered assignments.  Comments and blank lines+-- drop out; a malformed line (fewer than @name = value@, or a missing+-- @=@) is a loud error rather than a silent skip, matching upstream's+-- @UsageError@ - a typo in a security-relevant file must not pass for an+-- empty setting.  An @include@ \/ @!include@ line is refused as not yet+-- supported.+parseConfigText :: Text -> Either Text [ConfigAssignment]+parseConfigText = traverse parseLine . filter (not . isBlank) . map stripComment . T.lines+  where+    isBlank line = null (T.words line)+    stripComment = T.takeWhile (/= '#')+    parseLine line = case T.words line of+      (directive : _)+        | directive == includeDirective || directive == bangIncludeDirective ->+            Left ("nix.conf: '" <> directive <> "' is not supported yet")+      (name : eq : valueTokens)+        | eq == assignEq -> Right (ConfigAssignment name (T.unwords valueTokens))+      _ -> Left ("nix.conf: syntax error in line '" <> T.strip line <> "'")++-- ---------------------------------------------------------------------------+-- Applying+-- ---------------------------------------------------------------------------++-- | Apply one assignment to the accumulated config.  The name is resolved+-- through the aliases (@binary-caches@ -> @substituters@,+-- @binary-cache-public-keys@ -> @trusted-public-keys@) and the @extra-@+-- prefix (append rather than replace).  A name outside the known set is+-- ignored, not an error: upstream warns and continues, and refusing every+-- unknown key would reject a config that also carries settings this layer+-- does not model yet.+applyAssignment :: NixConfig -> ConfigAssignment -> NixConfig+applyAssignment config (ConfigAssignment name value) =+  case resolveName name of+    Nothing -> config+    Just (field, mode) ->+      let parsed = T.words value+       in setField field (combine mode (getField field config) parsed) config+  where+    combine ReplaceMode _ new = new+    combine AppendMode old new = old ++ new++-- | Which list a setting name targets, once the @extra-@ prefix and the+-- aliases are resolved, and whether it replaces or appends.+resolveName :: Text -> Maybe (ConfigField, ApplyMode)+resolveName name =+  case T.stripPrefix extraPrefix name of+    Just base -> tag AppendMode (baseField base)+    Nothing -> tag ReplaceMode (baseField name)+  where+    tag mode mField = case mField of+      Just field -> Just (field, mode)+      Nothing -> Nothing+    baseField n+      | n == substitutersKey || n == substitutersAlias = Just SubstitutersField+      | n == trustedKeysKey || n == trustedKeysAlias = Just TrustedKeysField+      | otherwise = Nothing++-- | The two list settings this layer resolves.+data ConfigField = SubstitutersField | TrustedKeysField+  deriving (Eq, Show)++-- | Whether an assignment replaces the accumulated value or appends to it.+data ApplyMode = ReplaceMode | AppendMode+  deriving (Eq, Show)++getField :: ConfigField -> NixConfig -> [Text]+getField SubstitutersField = ncSubstituters+getField TrustedKeysField = ncTrustedPublicKeys++setField :: ConfigField -> [Text] -> NixConfig -> NixConfig+setField SubstitutersField v config = config {ncSubstituters = v}+setField TrustedKeysField v config = config {ncTrustedPublicKeys = v}++-- | Parse and apply one source's text onto the accumulated config.+applyConfigText :: NixConfig -> Text -> Either Text NixConfig+applyConfigText config text = foldl applyAssignment config <$> parseConfigText text++-- | Fold a list of sources onto the default config, weakest first.  The+-- caller orders the list so the command line is last (and so wins); a+-- parse error in any source aborts, since a security-relevant file that+-- does not parse must not be treated as absent.+resolveConfig :: [Text] -> Either Text NixConfig+resolveConfig = foldl step (Right defaultNixConfig)+  where+    step acc source = acc >>= \config -> applyConfigText config source++-- ---------------------------------------------------------------------------+-- Setting names+-- ---------------------------------------------------------------------------++substitutersKey, substitutersAlias :: Text+substitutersKey = "substituters"+substitutersAlias = "binary-caches"++trustedKeysKey, trustedKeysAlias :: Text+trustedKeysKey = "trusted-public-keys"+trustedKeysAlias = "binary-cache-public-keys"++extraPrefix :: Text+extraPrefix = "extra-"++assignEq :: Text+assignEq = "="++includeDirective, bangIncludeDirective :: Text+includeDirective = "include"+bangIncludeDirective = "!include"
src/Nix/DependencyGraph.hs view
@@ -22,7 +22,6 @@   ) where -import Data.List (foldl') import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map import Data.Maybe (fromMaybe)@@ -180,16 +179,21 @@  -- | All transitive dependencies of a store path (not including itself). transitiveDeps :: DepGraph -> StorePath -> Set StorePath-transitiveDeps (DepGraph graph) root = go Set.empty (Seq.singleton root)+transitiveDeps (DepGraph graph) root =+  Set.delete root (go (Set.singleton root) (Seq.singleton root))   where-    go visited queue = case Seq.viewl queue of+    -- visited marks ENQUEUED nodes - the root is seeded, so a+    -- self-dependent root is expanded once instead of recursing forever.+    go !visited queue = case Seq.viewl queue of       Seq.EmptyL -> visited-      sp Seq.:< rest-        | Set.member sp visited -> go visited rest-        | otherwise ->-            let deps = maybe [] dnDeps (Map.lookup sp graph)-                visitedWithDep = if sp == root then visited else Set.insert sp visited-             in go visitedWithDep (foldl' (|>) rest deps)+      sp Seq.:< rest ->+        enqueueFresh visited rest (maybe [] dnDeps (Map.lookup sp graph))+    -- Mark and enqueue each dep not yet seen, then continue the walk.+    enqueueFresh !visited !queue deps = case deps of+      [] -> go visited queue+      (dep : more)+        | Set.member dep visited -> enqueueFresh visited queue more+        | otherwise -> enqueueFresh (Set.insert dep visited) (queue |> dep) more  -- | Direct dependencies of a store path. directDeps :: DepGraph -> StorePath -> [StorePath]
src/Nix/Derivation.hs view
@@ -66,6 +66,11 @@   ) 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)@@ -73,7 +78,9 @@ import qualified Data.Set as Set import Data.Text (Text) import qualified Data.Text as T-import Nix.Store.Path (StorePath (..))+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 @@ -146,6 +153,13 @@   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],@@ -156,19 +170,21 @@     -- | Target platform: "x86_64-linux", "x86_64-windows", etc.     drvPlatform :: !Platform,     -- | The builder executable (store path to bash, or other).-    drvBuilder :: !Text,+    drvBuilder :: !ByteString,     -- | Arguments to the builder.-    drvArgs :: ![Text],+    drvArgs :: ![ByteString],     -- | Environment variables for the build.-    drvEnv :: !(Map Text Text)+    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.+-- 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 -> Text+toATerm :: Derivation -> ByteString toATerm = toATermForHash False Nothing  -- | Serialize a derivation for HASHING, with the two knobs the Nix@@ -184,23 +200,25 @@ --     input's path spelling (not its content) hash identically. -- -- @toATermForHash False Nothing@ is exactly 'toATerm'.-toATermForHash :: Bool -> Maybe [(Text, [Text])] -> Derivation -> Text+toATermForHash :: Bool -> Maybe [(Text, [Text])] -> Derivation -> ByteString toATermForHash maskOutputs inputSubst drv =-  "Derive("-    <> atermOutputsWith maskOutputs (drvOutputs drv)-    <> ","-    <> inputDrvsSection-    <> ","-    <> atermInputSrcs (drvInputSrcs drv)-    <> ","-    <> atermString (platformToText (drvPlatform drv))-    <> ","-    <> atermString (drvBuilder drv)-    <> ","-    <> atermStringList (drvArgs drv)-    <> ","-    <> atermEnv (drvEnv 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)@@ -209,30 +227,37 @@ -- | 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] -> Text+atermOutputsWith :: Bool -> [DerivationOutput] -> B.Builder atermOutputsWith maskOutputs outs =   let sorted = sortBy (compare `on` doName) outs-   in "[" <> T.intercalate "," (map render sorted) <> "]"+   in atermList (map render sorted)   where     render out =       "("-        <> atermString (doName out)+        <> atermStringT (doName out)         <> ","-        <> atermString (if maskOutputs then "" else SP.storePathToText SP.defaultStoreDir (doPath out))+        <> atermStringT (if maskOutputs then "" else SP.storePathToText SP.defaultStoreDir (doPath out))         <> ","-        <> atermString (doHashAlgo out)+        <> atermStringT (doHashAlgo out)         <> ","-        <> atermString (doHash out)+        <> atermStringT (doHash out)         <> ")"  -- | Input-derivations serializer for the modulo substitution: keys are the--- modulo-hash hex strings (sorted), output-name lists sorted and deduplicated.-atermInputDrvsSubst :: [(Text, [Text])] -> Text+-- 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 sorted = sortBy (compare `on` fst) subs-   in "[" <> T.intercalate "," (map render sorted) <> "]"+  let merged = Map.fromListWith Set.union [(key, Set.fromList outs) | (key, outs) <- subs]+   in atermList (map render (Map.toAscList merged))   where-    render (key, outs) = "(" <> atermString key <> "," <> atermStringList (sortNubText outs) <> ")"+    render (key, outs) = "(" <> atermStringT key <> "," <> atermList (map atermStringT (Set.toAscList outs)) <> ")"  -- | Sort and deduplicate output names (matches C++ @std::set<string>@). sortNubText :: [Text] -> [Text]@@ -240,60 +265,84 @@  -- | Serialize input derivations: @[(drvPath,[outName1,outName2])]@ -- Sorted by store path for determinism.-atermInputDrvs :: Map StorePath [Text] -> Text+atermInputDrvs :: Map StorePath [Text] -> B.Builder atermInputDrvs drvs =   let sorted = Map.toAscList drvs-   in "[" <> T.intercalate "," (map atermInputDrv sorted) <> "]"+   in atermList (map atermInputDrv sorted) -atermInputDrv :: (StorePath, [Text]) -> Text+atermInputDrv :: (StorePath, [Text]) -> B.Builder atermInputDrv (sp, outs) =   "("-    <> atermString (SP.storePathToText SP.defaultStoreDir sp)+    <> atermStringT (SP.storePathToText SP.defaultStoreDir sp)     <> ","-    <> atermStringList (sortNubText outs)+    <> atermList (map atermStringT (sortNubText outs))     <> ")"  -- | Serialize input sources: @[path1,path2,...]@ -- Sorted for deterministic ATerm hashing.-atermInputSrcs :: [StorePath] -> Text+atermInputSrcs :: [StorePath] -> B.Builder atermInputSrcs srcs =   let sorted = sortBy (compare `on` SP.storePathToText SP.defaultStoreDir) srcs-   in "[" <> T.intercalate "," (map (atermString . SP.storePathToText SP.defaultStoreDir) sorted) <> "]"+   in atermList (map (atermStringT . SP.storePathToText SP.defaultStoreDir) sorted) --- | Serialize a list of strings: @[s1,s2,...]@-atermStringList :: [Text] -> Text-atermStringList strs =-  "[" <> T.intercalate "," (map atermString strs) <> "]"+-- | 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.-atermEnv :: Map Text Text -> Text+-- | 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 "[" <> T.intercalate "," (map atermEnvPair sorted) <> "]"+   in atermList (map atermEnvPair sorted) -atermEnvPair :: (Text, Text) -> Text+atermEnvPair :: (Text, ByteString) -> B.Builder atermEnvPair (key, val) =-  "(" <> atermString key <> "," <> atermString val <> ")"+  "(" <> atermStringT key <> "," <> atermString val <> ")" --- | ATerm string: double-quoted with standard escaping.-atermString :: Text -> Text-atermString s = "\"" <> T.concatMap escapeATerm s <> "\""+-- | 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 <> "\"" --- | Escape a character for ATerm format.-escapeATerm :: Char -> Text-escapeATerm '\\' = "\\\\"-escapeATerm '"' = "\\\""-escapeATerm '\n' = "\\n"-escapeATerm '\r' = "\\r"-escapeATerm '\t' = "\\t"-escapeATerm c = T.singleton c+-- | 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 text.-newtype Parser a = Parser {runParser :: Text -> Either Text (a, Text)}+-- | 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@@ -316,46 +365,67 @@ 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 T.uncons input of+  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 :: Text -> Parser ()+pString :: ByteString -> Parser () pString prefix = Parser $ \input ->-  case T.stripPrefix prefix input of+  case BS.stripPrefix prefix input of     Just rest -> Right ((), rest)-    Nothing -> Left ("expected \"" <> prefix <> "\" at: " <> T.take 20 input)+    Nothing -> Left ("expected \"" <> snippet prefix <> "\" at: " <> snippet (BS.take 20 input)) --- | Parse a quoted ATerm string with escape handling.-pQuotedString :: Parser Text+-- | 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).-pStringContent :: Parser Text-pStringContent = Parser $ \input -> go input T.empty+-- 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 =-      case T.uncons remaining of-        Nothing -> Left "unterminated string"-        Just ('"', _) -> Right (acc, remaining)-        Just ('\\', rest) -> case T.uncons rest of-          Nothing -> Left "unterminated escape"-          Just ('\\', rest2) -> go rest2 (acc <> "\\")-          Just ('"', rest2) -> go rest2 (acc <> "\"")-          Just ('n', rest2) -> go rest2 (acc <> "\n")-          Just ('r', rest2) -> go rest2 (acc <> "\r")-          Just ('t', rest2) -> go rest2 (acc <> "\t")-          Just (c, rest2) -> go rest2 (acc <> "\\" <> T.singleton c)-        Just (c, rest) -> go rest (acc <> T.singleton c)+      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]@@ -381,20 +451,26 @@             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 <- pQuotedString+  name <- pQuotedText   pChar ','-  pathStr <- pQuotedString+  pathStr <- pQuotedText   pChar ','-  hashAlgo <- pQuotedString+  hashAlgo <- pQuotedText   pChar ','-  hashVal <- pQuotedString+  hashVal <- pQuotedText   pChar ')'-  case parseStorePathFromATerm pathStr of-    Just sp -> pure (DerivationOutput name sp hashAlgo hashVal)-    Nothing -> parserFail ("invalid output store path: " <> pathStr)+  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.@@ -408,9 +484,9 @@ pInputDrv :: Parser (StorePath, [Text]) pInputDrv = do   pChar '('-  pathStr <- pQuotedString+  pathStr <- pQuotedText   pChar ','-  outs <- pList pQuotedString+  outs <- pList pQuotedText   pChar ')'   case parseStorePathFromATerm pathStr of     Just sp -> pure (sp, outs)@@ -419,31 +495,32 @@ -- | Parse an input source (quoted store path string). pInputSrc :: Parser StorePath pInputSrc = do-  pathStr <- pQuotedString+  pathStr <- pQuotedText   case parseStorePathFromATerm pathStr of     Just sp -> pure sp     Nothing -> parserFail ("invalid input src store path: " <> pathStr) --- | Parse an environment pair: @(key, value)@.-pEnvPair :: Parser (Text, Text)+-- | 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 <- pQuotedString+  key <- pQuotedText   pChar ','   val <- pQuotedString   pChar ')'   pure (key, val) --- | Parse a full derivation from ATerm format.+-- | 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 :: Text -> Either Text Derivation+fromATerm :: ByteString -> Either Text Derivation fromATerm input = case runParser pDerivation input of   Left err -> Left ("ATerm parse error: " <> err)   Right (drv, remaining)-    | T.null remaining -> Right drv-    | otherwise -> Left ("ATerm parse error: unexpected trailing: " <> T.take 40 remaining)+    | BS.null remaining -> Right drv+    | otherwise -> Left ("ATerm parse error: unexpected trailing: " <> snippet (BS.take 40 remaining))  pDerivation :: Parser Derivation pDerivation = do@@ -454,7 +531,7 @@   pChar ','   inputSrcs <- pList pInputSrc   pChar ','-  platformStr <- pQuotedString+  platformStr <- pQuotedText   pChar ','   builder <- pQuotedString   pChar ','
src/Nix/Eval.hs view
@@ -1,4167 +1,5430 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE PatternSynonyms #-}---- | Nix expression evaluator.------ Nix evaluation is LAZY.  Attribute set members and list elements--- are stored as thunks and only forced when their value is demanded.--- Function arguments are likewise thunked - @(x: 1) (throw "boom")@--- returns @1@ because @x@ is never referenced.------ The evaluator maintains an environment ('Env') that maps variable--- names to thunks.  @let@, @with@, function application, and--- recursive attribute sets all extend the environment.-module Nix.Eval-  ( -- * Values (re-exported from Types)-    NixValue (..),-    CompiledRegex (..),-    Thunk (..),--    -- * Attribute sets (re-exported from Types)-    AttrSet (..),-    CAttrSet,-    attrSetFromMap,-    attrSetLookup,-    attrSetKeys,-    attrSetToMap,-    attrSetToAscList,-    attrSetMember,-    attrSetElems,-    attrSetNull,-    attrSetRemoveKeys,-    attrSetSize,--    -- * String context (re-exported from Types)-    StringContextElement (..),-    StringContext (..),-    emptyContext,-    mkStr,--    -- * Environment (re-exported from Types)-    Env (..),-    emptyEnv,--    -- * Evaluation monad (re-exported from Types)-    MonadEval (..),-    PureEval,-    runPureEval,--    -- * Evaluation-    eval,-    evalBytecode,-    force,--    -- * Helpers (for Builtins)-    typeName,-    evaluated,-    readThunkValue,--    -- * Builtin registry-    BuiltinDef (..),-    builtinRegistry,-    builtinNames,--    -- * Platform-    currentSystemStr,-  )-where--import Control.Monad (foldM, when, (>=>))-import qualified Crypto.Hash as CH-import qualified Data.Array as Array-import Data.Bits (complement, xor, (.&.), (.|.))-import qualified Data.ByteArray as BA-import qualified Data.ByteString as BS-import Data.Char (chr, digitToInt, isAlpha, isDigit, isHexDigit, isOctDigit, ord)-import Data.IORef (IORef, atomicModifyIORef', newIORef)-import Data.Int (Int64)-import Data.List (find, foldl', sort)-import Data.Map.Strict (Map)-import qualified Data.Map.Strict as Map-import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, maybeToList)-import Data.Sequence (Seq (..))-import qualified Data.Sequence as Seq-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.Word (Word32, Word8)-import Foreign.Ptr (Ptr, castPtr, nullPtr, ptrToWordPtr, wordPtrToPtr)-import Foreign.Storable (peekElemOff, pokeElemOff)-import Nix.Derivation (Derivation (..), DerivationOutput (..), textToPlatform, toATerm, toATermForHash)-import Nix.Eval.CBytecode (cbcArg1, cbcArg2, cbcArg3, cbcData, cbcFlags, cbcOpcode, cbcShortArg, pattern OpApp, pattern OpAssert, pattern OpAttrs, pattern OpBinary, pattern OpHasAttr, pattern OpIf, pattern OpIndStr, pattern OpLambda, pattern OpLet, pattern OpList, pattern OpLitBool, pattern OpLitFloat, pattern OpLitInt, pattern OpLitNull, pattern OpLitPath, pattern OpLitUri, pattern OpResolvedVar, pattern OpSearchPath, pattern OpSelect, pattern OpStr, pattern OpUnary, pattern OpVar, pattern OpWith, pattern OpWithVar)-import Nix.Eval.CEnv (cenvPushWith)-import Nix.Eval.CList (CList (..), clistGet)-import Nix.Eval.CThunk (CThunkPtr)-import Nix.Eval.Compile (BcAttrKey (..), BcBinding (..), compileExpr, decodeBcBindings, decodeBcCaptureInfo, decodeBcFormals, reassembleDouble, reassembleInt64)-import Nix.Eval.Context (extractInputDrvs, extractInputSrcs, plainContext)-import Nix.Eval.Operator (evalBinary, evalUnary, nixCompare, nixEqual)-import Nix.Eval.StringInterp (coerceToString, formatNixFloat, stripIndentedChunks)-import Nix.Eval.Symbol (Symbol (..), symbolText)-import Nix.Eval.Types-  ( AttrSet (..),-    CAttrSet,-    CompiledRegex (..),-    Env (..),-    EvalFormal (..),-    EvalFormals (..),-    MonadEval (..),-    NixValue (..),-    PureEval,-    StringContext (..),-    StringContextElement (..),-    Thunk (..),-    allocCSlots,-    attrSetElems,-    attrSetFromMap,-    attrSetKeys,-    attrSetLookup,-    attrSetMapWithKey,-    attrSetMember,-    attrSetNull,-    attrSetRemoveKeys,-    attrSetSize,-    attrSetToAscList,-    attrSetToMap,-    attrSetUnionWith,-    buildCAttrSetKeys,-    buildCSlots,-    cheapThunkBc,-    clistFromThunks,-    clistLen,-    clistThunks,-    emptyContext,-    emptyEnv,-    envFromSlots,-    envLookup,-    envLookupResolved,-    envWithScopesRaw,-    evaluated,-    fillCAttrSetValues,-    fillCSlots,-    mkStr,-    mkSyntheticThunk,-    mkThunk,-    mkThunkBc,-    newCEnv,-    newMinimalEnv,-    readThunkValue,-    runPureEval,-    thunkToCPtr,-    typeName,-    withScopesForCapture,-  )-import Nix.Expr.Types-  ( AttrKey (..),-    BinaryOp (..),-    CaptureInfo (..),-    Expr (..),-    NixAtom (..),-    UnaryOp (..),-  )-import Nix.Hash (bytesToHexText, hashPlaceholder, hexToBytes, makeFixedOutputPath, makeOutputPath, makeTextPath, sha256Digest, sha256Hex)-import Nix.Store.Path (StorePath (..), defaultStoreDir, defaultStoreDirText, parseStorePath, storePathToFilePath, storePathToText)-import qualified NovaCache.Base32 as Nix32-import qualified NovaCache.Base64 as B64-import System.IO.Unsafe (unsafePerformIO)-import qualified System.Info-import Text.Regex.TDFA (matchAllText)-import qualified Text.Regex.TDFA as RE---- | Evaluate a Nix expression in an environment.--- Compiles the expression to bytecode and dispatches to 'evalBytecode'.-eval :: (MonadEval m) => Env -> Expr -> m NixValue-eval env expr =-  let bcIdx = unsafePerformIO (compileExpr expr)-   in evalBytecode env bcIdx---- | Force a thunk to a value.------ Delegates to 'forceThunk' which is a 'MonadEval' method - this--- allows IO evaluators to implement memoization (caching the result--- after the first force) while pure evaluators simply re-evaluate.-force :: (MonadEval m) => Thunk -> m NixValue-force = forceThunk evalBytecode---- | Evaluate a bytecode instruction by index.--- This is the primary evaluator - reads opcodes from the C bytecode--- store and dispatches.  All recursive evaluation goes through this--- function, never through 'eval' directly.-evalBytecode :: (MonadEval m) => Env -> Word32 -> m NixValue-evalBytecode env bcIdx =-  let opcode = unsafePerformIO (cbcOpcode bcIdx)-   in case opcode of-        OpLitInt ->-          let lo = unsafePerformIO (cbcArg1 bcIdx)-              hi = unsafePerformIO (cbcArg2 bcIdx)-           in pure (VInt (reassembleInt64 lo hi))-        OpLitFloat ->-          let lo = unsafePerformIO (cbcArg1 bcIdx)-              hi = unsafePerformIO (cbcArg2 bcIdx)-           in pure (VFloat (reassembleDouble lo hi))-        OpLitBool ->-          let flag = unsafePerformIO (cbcShortArg bcIdx)-           in pure (VBool (flag /= 0))-        OpLitNull -> pure VNull-        OpLitUri ->-          let sym = unsafePerformIO (cbcArg1 bcIdx)-           in pure (mkStr (symbolText (Symbol sym)))-        OpLitPath ->-          let sym = unsafePerformIO (cbcArg1 bcIdx)-           in VPath <$> resolvePathLiteral (symbolText (Symbol sym))-        OpStr -> evalBcStr env bcIdx-        OpIndStr -> evalBcIndStr env bcIdx-        OpVar ->-          let sym = unsafePerformIO (cbcArg1 bcIdx)-           in evalVar env (symbolText (Symbol sym))-        OpWithVar ->-          let sym = unsafePerformIO (cbcArg1 bcIdx)-              name = symbolText (Symbol sym)-           in evalWithVar env name-        OpResolvedVar ->-          let level = fromIntegral (unsafePerformIO (cbcArg1 bcIdx))-              idx = fromIntegral (unsafePerformIO (cbcArg2 bcIdx))-           in force (envLookupResolved level idx env)-        OpAttrs -> evalBcAttrs env bcIdx-        OpList -> evalBcList env bcIdx-        OpSelect -> evalBcSelect env bcIdx-        OpHasAttr -> evalBcHasAttr env bcIdx-        OpApp -> evalBcApp env bcIdx-        OpLambda -> evalBcLambda env bcIdx-        OpLet -> evalBcLet env bcIdx-        OpIf ->-          let condIdx = unsafePerformIO (cbcArg1 bcIdx)-              thenIdx = unsafePerformIO (cbcArg2 bcIdx)-              elseIdx = unsafePerformIO (cbcArg3 bcIdx)-           in do-                condVal <- evalBytecode env condIdx-                case condVal of-                  VBool True -> evalBytecode env thenIdx-                  VBool False -> evalBytecode env elseIdx-                  _ -> throwEvalError ("'if' condition must be a Boolean, got " <> typeName condVal)-        OpWith ->-          let scopeIdx = unsafePerformIO (cbcArg1 bcIdx)-              bodyIdx = unsafePerformIO (cbcArg2 bcIdx)-              -- Lazy with: defer forcing the scope until a WITH_VAR lookup-              -- actually needs it.  This is critical for nixpkgs where-              -- all-packages.nix uses `with pkgs;` inside a fixpoint --              -- eagerly forcing the scope would blackhole.-              scopeThunk = cheapThunkBc env scopeIdx-           in evalBytecode (pushLazyWithScope scopeThunk env) bodyIdx-        OpAssert ->-          let condIdx = unsafePerformIO (cbcArg1 bcIdx)-              bodyIdx = unsafePerformIO (cbcArg2 bcIdx)-           in do-                condVal <- evalBytecode env condIdx-                case condVal of-                  VBool True -> evalBytecode env bodyIdx-                  VBool False -> throwEvalError "assertion failed"-                  _ -> throwEvalError ("assertion condition must be a Boolean, got " <> typeName condVal)-        OpUnary ->-          let flags_ = unsafePerformIO (cbcFlags bcIdx)-              operandIdx = unsafePerformIO (cbcArg1 bcIdx)-           in do-                val <- evalBytecode env operandIdx-                evalUnary (decodeUnaryOp flags_) val-        OpBinary -> evalBcBinary env bcIdx-        OpSearchPath ->-          let sym = unsafePerformIO (cbcArg1 bcIdx)-           in evalSearchPath env (symbolText (Symbol sym))-        _ -> throwEvalError "evalBytecode: unknown opcode"---- ------------------------------------------------------------------------------ Bytecode helpers--- ------------------------------------------------------------------------------- | Decode a UnaryOp from bytecode flags.-decodeUnaryOp :: Word8 -> UnaryOp-decodeUnaryOp 0 = OpNot-decodeUnaryOp 1 = OpNegate--- Unreachable: the tag is written by Nix.Eval.Compile's encodeUnaryOp, which--- only emits 0 or 1.-decodeUnaryOp n = error ("decodeUnaryOp: unknown tag " <> show n)---- | Decode a BinaryOp from bytecode flags.-decodeBinaryOp :: Word8 -> BinaryOp-decodeBinaryOp 0 = OpAdd-decodeBinaryOp 1 = OpSub-decodeBinaryOp 2 = OpMul-decodeBinaryOp 3 = OpDiv-decodeBinaryOp 4 = OpAnd-decodeBinaryOp 5 = OpOr-decodeBinaryOp 6 = OpImpl-decodeBinaryOp 7 = OpEq-decodeBinaryOp 8 = OpNeq-decodeBinaryOp 9 = OpLt-decodeBinaryOp 10 = OpLte-decodeBinaryOp 11 = OpGt-decodeBinaryOp 12 = OpGte-decodeBinaryOp 13 = OpConcat-decodeBinaryOp 14 = OpUpdate--- Unreachable: the tag is written by Nix.Eval.Compile's encodeBinaryOp, which--- only emits 0..14.-decodeBinaryOp n = error ("decodeBinaryOp: unknown tag " <> show n)---- | Evaluate a binary operation from bytecode, with short-circuit--- support for &&, ||, ->.-evalBcBinary :: (MonadEval m) => Env -> Word32 -> m NixValue-evalBcBinary env bcIdx0 =-  let flags_ = unsafePerformIO (cbcFlags bcIdx0)-      leftIdx = unsafePerformIO (cbcArg1 bcIdx0)-      rightIdx = unsafePerformIO (cbcArg2 bcIdx0)-      op = decodeBinaryOp flags_-   in case op of-        OpAnd -> evalShortCircuitAnd env leftIdx rightIdx-        OpOr -> evalShortCircuitOr env leftIdx rightIdx-        OpImpl -> evalShortCircuitImpl env leftIdx rightIdx-        OpAdd -> do-          leftVal <- evalBytecode env leftIdx-          rightVal <- evalBytecode env rightIdx-          evalAddWithCoercion leftVal rightVal-        _ -> do-          leftVal <- evalBytecode env leftIdx-          rightVal <- evalBytecode env rightIdx-          evalBinary force op leftVal rightVal---- | Addition with string coercion fallback, matching C++ Nix behavior.--- C++ Nix's ExprOpAdd falls through to concatStrings when operands--- are not both numeric and neither is a path.  concatStrings calls--- coerceToString on each part, which handles attrsets via outPath.-evalAddWithCoercion :: (MonadEval m) => NixValue -> NixValue -> m NixValue-evalAddWithCoercion left right = case (left, right) of-  -- Numeric: direct arithmetic (matches C++ Nix priority)-  (VFloat _, _) -> evalBinary force OpAdd left right-  (_, VFloat _) -> evalBinary force OpAdd left right-  (VInt _, VInt _) -> evalBinary force OpAdd left right-  -- Path: direct concat-  (VPath _, _) -> evalBinary force OpAdd left right-  -- String-like: direct concat when both are string/path-  (VStr {}, VStr {}) -> evalBinary force OpAdd left right-  (VStr {}, VPath _) -> evalBinary force OpAdd left right-  -- Otherwise: string concatenation with STRICT coercion (Nix coerceMore=false)-  -- - only strings and sets with __toString/outPath coerce; numbers, bools,-  -- null, lists, and functions are type errors, matching C++ Nix's `+`.-  _ -> do-    (leftStr, leftCtx) <- coerceAddOperand left-    (rightStr, rightCtx) <- coerceAddOperand right-    pure (VStr (leftStr <> rightStr) (leftCtx <> rightCtx))---- | Strict string coercion for the @+@ operator (Nix @coerceMore = false@):--- strings, and sets with @__toString@/@outPath@, coerce; numbers, booleans,--- null, lists, and functions are type errors.-coerceAddOperand :: (MonadEval m) => NixValue -> m (Text, StringContext)-coerceAddOperand v@(VStr {}) = coerceToString False force applyValue v-coerceAddOperand v@(VAttrs {}) = coerceToString False force applyValue v-coerceAddOperand v =-  throwEvalError ("cannot coerce " <> typeName v <> " to a string with the + operator")---- | Bytecode short-circuit &&-evalShortCircuitAnd :: (MonadEval m) => Env -> Word32 -> Word32 -> m NixValue-evalShortCircuitAnd env leftIdx rightIdx = do-  leftVal <- evalBytecode env leftIdx-  case leftVal of-    VBool False -> pure (VBool False)-    VBool True -> do-      rightVal <- evalBytecode env rightIdx-      case rightVal of-        VBool _ -> pure rightVal-        _ -> throwEvalError ("second operand of && must be a Boolean, got " <> typeName rightVal)-    _ -> throwEvalError ("first operand of && must be a Boolean, got " <> typeName leftVal)---- | Bytecode short-circuit ||-evalShortCircuitOr :: (MonadEval m) => Env -> Word32 -> Word32 -> m NixValue-evalShortCircuitOr env leftIdx rightIdx = do-  leftVal <- evalBytecode env leftIdx-  case leftVal of-    VBool True -> pure (VBool True)-    VBool False -> do-      rightVal <- evalBytecode env rightIdx-      case rightVal of-        VBool _ -> pure rightVal-        _ -> throwEvalError ("second operand of || must be a Boolean, got " <> typeName rightVal)-    _ -> throwEvalError ("first operand of || must be a Boolean, got " <> typeName leftVal)---- | Bytecode short-circuit ->-evalShortCircuitImpl :: (MonadEval m) => Env -> Word32 -> Word32 -> m NixValue-evalShortCircuitImpl env leftIdx rightIdx = do-  leftVal <- evalBytecode env leftIdx-  case leftVal of-    VBool False -> pure (VBool True)-    VBool True -> do-      rightVal <- evalBytecode env rightIdx-      case rightVal of-        VBool _ -> pure rightVal-        _ -> throwEvalError ("second operand of -> must be a Boolean, got " <> typeName rightVal)-    _ -> throwEvalError ("first operand of -> must be a Boolean, got " <> typeName leftVal)---- | Evaluate a string literal from bytecode data buffer.-evalBcStr :: (MonadEval m) => Env -> Word32 -> m NixValue-evalBcStr env bcIdx0 = do-  let count = fromIntegral (unsafePerformIO (cbcShortArg bcIdx0))-      dataOff = unsafePerformIO (cbcArg1 bcIdx0)-  chunks <- evalBcStringParts env count dataOff-  pure (VStr (T.concat [t | (_, t, _) <- chunks]) (mconcat [c | (_, _, c) <- chunks]))---- | Evaluate an indented string literal from bytecode data buffer.  The common--- indentation is stripped from the LITERAL chunks before concatenation, so an--- interpolated multi-line value cannot drag the computed indent down - matching--- C++ Nix.-evalBcIndStr :: (MonadEval m) => Env -> Word32 -> m NixValue-evalBcIndStr env bcIdx0 = do-  let count = fromIntegral (unsafePerformIO (cbcShortArg bcIdx0))-      dataOff = unsafePerformIO (cbcArg1 bcIdx0)-  chunks <- evalBcStringParts env count dataOff-  let (text, ctx) = stripIndentedChunks chunks-  pure (VStr text ctx)---- | Evaluate string parts from the bytecode data buffer.  Each part is two--- words: (tag, value).  tag=0 means literal (value = symbol), tag=1 means--- interpolation (value = bc_idx).  The 'Bool' marks literal (@True@) vs--- interpolated (@False@) so indented strings strip only the literals.-evalBcStringParts :: (MonadEval m) => Env -> Int -> Word32 -> m [(Bool, Text, StringContext)]-evalBcStringParts _ 0 _ = pure []-evalBcStringParts env n off = do-  let tag = unsafePerformIO (cbcData off)-      val = unsafePerformIO (cbcData (off + 1))-  chunk <- case tag of-    0 -> pure (True, symbolText (Symbol val), emptyContext)-    _ -> do-      v <- evalBytecode env val-      (txt, ctx) <- coerceToStringInterp v-      pure (False, txt, ctx)-  rest <- evalBcStringParts env (n - 1) (off + 2)-  pure (chunk : rest)---- | Evaluate a list from bytecode data buffer.-evalBcList :: (MonadEval m) => Env -> Word32 -> m NixValue-evalBcList env bcIdx0 =-  let count = fromIntegral (unsafePerformIO (cbcShortArg bcIdx0)) :: Int-      dataOff = unsafePerformIO (cbcArg1 bcIdx0)-      readChildren 0 _ = []-      readChildren n off =-        let childIdx = unsafePerformIO (cbcData off)-         in cheapThunkBc env childIdx : readChildren (n - 1) (off + 1)-   in pure (VList (clistFromThunks (map thunkToCPtr (readChildren count dataOff))))---- | Evaluate a function application from bytecode.-evalBcApp :: (MonadEval m) => Env -> Word32 -> m NixValue-evalBcApp env bcIdx0 = do-  let funcIdx = unsafePerformIO (cbcArg1 bcIdx0)-      argIdx = unsafePerformIO (cbcArg2 bcIdx0)-  funcVal <- evalBytecode env funcIdx-  case funcVal of-    VLambda closureEnv formals bodyBcIdx -> do-      let argThunk = cheapThunkBc env argIdx-      extEnv <- matchFormals closureEnv formals argThunk-      evalBytecode extEnv bodyBcIdx-    VBuiltin "tryEval" [] -> do-      result <- catchEvalError (evalBytecode env argIdx)-      case result of-        Right val ->-          pure-            ( VAttrs-                ( attrSetFromMap $-                    Map.fromList-                      [ ("success", evaluated (VBool True)),-                        ("value", evaluated val)-                      ]-                )-            )-        Left _ ->-          pure-            ( VAttrs-                ( attrSetFromMap $-                    Map.fromList-                      [ ("success", evaluated (VBool False)),-                        ("value", evaluated (VBool False))-                      ]-                )-            )-    VBuiltin name accArgs -> do-      argVal <- evalBytecode env argIdx-      applyBuiltin name accArgs argVal-    VAttrs attrs-      | Just functorThunk <- attrSetLookup "__functor" attrs -> do-          functor <- force functorThunk-          partiallyApplied <- applyValue functor funcVal-          -- Maintain laziness: thunk the argument for lambdas, like-          -- the normal VLambda path above.  Builtins force anyway.-          case partiallyApplied of-            VLambda closureEnv formals bodyBcIdx -> do-              let argThunk = cheapThunkBc env argIdx-              extEnv <- matchFormals closureEnv formals argThunk-              evalBytecode extEnv bodyBcIdx-            _ -> do-              argVal <- evalBytecode env argIdx-              applyValue partiallyApplied argVal-    _ -> throwEvalError ("attempt to call " <> typeName funcVal <> ", which is not a function")---- | Evaluate a lambda literal from bytecode - returns VLambda.-evalBcLambda :: (MonadEval m) => Env -> Word32 -> m NixValue-evalBcLambda env bcIdx0 =-  let flags_ = unsafePerformIO (cbcFlags bcIdx0)-      formalsOff = unsafePerformIO (cbcArg1 bcIdx0)-      bodyBcIdx = unsafePerformIO (cbcArg2 bcIdx0)-      captureOff = unsafePerformIO (cbcArg3 bcIdx0)-      formals = unsafePerformIO (decodeBcFormals flags_ formalsOff)-      captureInfo = unsafePerformIO (decodeBcCaptureInfo captureOff)-   in pure (VLambda (buildCaptureEnv env captureInfo) formals bodyBcIdx)---- | Evaluate a select expression from bytecode.-evalBcSelect :: (MonadEval m) => Env -> Word32 -> m NixValue-evalBcSelect env bcIdx0 = do-  let hasDef = unsafePerformIO (cbcFlags bcIdx0) /= 0-      pathLen = fromIntegral (unsafePerformIO (cbcShortArg bcIdx0))-      targetIdx = unsafePerformIO (cbcArg1 bcIdx0)-      pathOff = unsafePerformIO (cbcArg2 bcIdx0)-      defIdx = unsafePerformIO (cbcArg3 bcIdx0)-  targetVal <- evalBytecode env targetIdx-  result <- walkBcAttrPath env pathLen pathOff targetVal-  case result of-    Just val -> pure val-    Nothing-      | hasDef -> evalBytecode env defIdx-      | otherwise -> do-          let pathName = collectBcAttrPathNames pathLen pathOff-              targetKeys = case targetVal of-                VAttrs attrs -> T.intercalate ", " (take 20 (attrSetKeys attrs))-                _ -> ""-          throwEvalError ("attribute '" <> pathName <> "' not found in " <> typeName targetVal <> " {" <> targetKeys <> "}")---- | Evaluate a hasAttr expression from bytecode.-evalBcHasAttr :: (MonadEval m) => Env -> Word32 -> m NixValue-evalBcHasAttr env bcIdx0 = do-  let pathLen = fromIntegral (unsafePerformIO (cbcShortArg bcIdx0))-      targetIdx = unsafePerformIO (cbcArg1 bcIdx0)-      pathOff = unsafePerformIO (cbcArg2 bcIdx0)-  targetVal <- evalBytecode env targetIdx-  result <- walkBcAttrPath env pathLen pathOff targetVal-  pure (VBool (isJust result))---- | Collect static attribute path names for error reporting.-collectBcAttrPathNames :: Int -> Word32 -> Text-collectBcAttrPathNames pathLen pathOff = T.intercalate "." (go pathLen pathOff)-  where-    go 0 _ = []-    go n off =-      let isExpr = unsafePerformIO (cbcData off)-          keyVal = unsafePerformIO (cbcData (off + 1))-          name = if isExpr /= 0 then "<expr>" else symbolText (Symbol keyVal)-       in name : go (n - 1) (off + 2)---- | Walk an attribute path stored in the bytecode data buffer.--- Each element is two words: (is_expr, key_or_bc_idx).-walkBcAttrPath :: (MonadEval m) => Env -> Int -> Word32 -> NixValue -> m (Maybe NixValue)-walkBcAttrPath _ 0 _ val = pure (Just val)-walkBcAttrPath env n off val = case val of-  VAttrs attrs -> do-    let isExpr = unsafePerformIO (cbcData off)-        keyVal = unsafePerformIO (cbcData (off + 1))-    resolved <--      if isExpr /= 0-        then do-          keyResult <- evalBytecode env keyVal-          case keyResult of-            VStr s _ -> pure (Just s)-            VNull -> pure Nothing-            _ -> throwEvalError ("dynamic attribute key must be a string, got " <> typeName keyResult)-        else pure (Just (symbolText (Symbol keyVal)))-    case resolved >>= (`attrSetLookup` attrs) of-      Just thunk -> do-        inner <- force thunk-        walkBcAttrPath env (n - 1) (off + 2) inner-      Nothing -> pure Nothing-  -- Non-attrset: attribute path cannot continue.  Return Nothing so-  -- that callers with a default (``a.b or def'') or hasAttr (``a ? b'')-  -- can handle it gracefully, matching C++ Nix behaviour.-  _ -> pure Nothing---- | Evaluate attribute set from bytecode.-evalBcAttrs :: (MonadEval m) => Env -> Word32 -> m NixValue-evalBcAttrs env bcIdx0 = do-  let isRec = unsafePerformIO (cbcFlags bcIdx0) /= 0-      bindCount = unsafePerformIO (cbcShortArg bcIdx0)-      dataOff = unsafePerformIO (cbcArg1 bcIdx0)-      captureOff = unsafePerformIO (cbcArg2 bcIdx0)-      bindings = unsafePerformIO (decodeBcBindings bindCount dataOff)-  if isRec-    then do-      let captureInfo = unsafePerformIO (decodeBcCaptureInfo captureOff)-      evalBcRecAttrs env bindings captureInfo-    else evalBcNonRecAttrs env bindings---- | Evaluate a non-recursive attr set from bytecode bindings.-evalBcNonRecAttrs :: (MonadEval m) => Env -> [BcBinding] -> m NixValue-evalBcNonRecAttrs env bindings = do-  thunkMap <- buildBcThunkMap env bindings-  pure (VAttrs (attrSetFromMap thunkMap))---- | Evaluate a recursive attr set from bytecode bindings.-evalBcRecAttrs :: (MonadEval m) => Env -> [BcBinding] -> CaptureInfo -> m NixValue-evalBcRecAttrs env bindings captureInfo-  | allBcPositional bindings =-      -- Positional path: slots for variable lookup, CAttrSet for return.-      let slotCount = bcBindingSlotCount bindings-          slotsPtr = allocCSlots slotCount-          parentEnv = buildCaptureEnv env captureInfo-          (withArr, withCount) = case captureInfo of-            NoCaptureInfo -> envWithScopesRaw env-            Captures _ -> (nullPtr, 0)-            CapturesWithScopes _ -> withScopesForCapture env-          recEnv = newCEnv slotsPtr slotCount Nothing (Just parentEnv) withArr withCount-          thunkList = buildBcSlotThunks recEnv env bindings-          filled = fillCSlots slotsPtr thunkList-          attrMap = buildBcAttrMapFromSlots bindings thunkList-       in filled `seq` pure (VAttrs (attrSetFromMap attrMap))-  | otherwise = do-      -- Fallback: dynamic/nested keys - two-phase CAttrSet.-      allKeys <- bcBindingAllKeys env bindings-      let cset = buildCAttrSetKeys allKeys-          attrSet = AttrSet cset-          parentEnv = buildCaptureEnv env captureInfo-          (withArr, withCount) = case captureInfo of-            NoCaptureInfo -> envWithScopesRaw env-            Captures _ -> (nullPtr, 0)-            CapturesWithScopes _ -> withScopesForCapture env-          recEnv = newCEnv nullPtr 0 (Just attrSet) (Just parentEnv) withArr withCount-      thunkMap <- buildBcThunkMap recEnv bindings-      let filled = fillCAttrSetValues cset thunkMap-       in filled `seq` pure (VAttrs attrSet)---- | Evaluate a let expression from bytecode.-evalBcLet :: (MonadEval m) => Env -> Word32 -> m NixValue-evalBcLet env bcIdx0 = do-  let bindCount = unsafePerformIO (cbcShortArg bcIdx0)-      dataOff = unsafePerformIO (cbcArg1 bcIdx0)-      bodyIdx = unsafePerformIO (cbcArg2 bcIdx0)-      captureOff = unsafePerformIO (cbcArg3 bcIdx0)-      bindings = unsafePerformIO (decodeBcBindings bindCount dataOff)-      captureInfo = unsafePerformIO (decodeBcCaptureInfo captureOff)-  if allBcPositional bindings-    then do-      -- Positional path: slots for O(1) lookup.-      let slotCount = bcBindingSlotCount bindings-          slotsPtr = allocCSlots slotCount-          parentEnv = buildCaptureEnv env captureInfo-          (withArr, withCount) = case captureInfo of-            NoCaptureInfo -> envWithScopesRaw env-            Captures _ -> (nullPtr, 0)-            CapturesWithScopes _ -> withScopesForCapture env-          letEnv = newCEnv slotsPtr slotCount Nothing (Just parentEnv) withArr withCount-          filled = fillCSlots slotsPtr (buildBcSlotThunks letEnv env bindings)-       in filled `seq` evalBytecode letEnv bodyIdx-    else do-      -- Fallback: dynamic/nested keys - two-phase CAttrSet.-      allKeys <- bcBindingAllKeys env bindings-      let cset = buildCAttrSetKeys allKeys-          parentEnv = buildCaptureEnv env captureInfo-          (withArr, withCount) = case captureInfo of-            NoCaptureInfo -> envWithScopesRaw env-            Captures _ -> (nullPtr, 0)-            CapturesWithScopes _ -> withScopesForCapture env-          letEnv = newCEnv nullPtr 0 (Just (AttrSet cset)) (Just parentEnv) withArr withCount-      thunkMap <- buildBcThunkMap letEnv bindings-      let filled = fillCAttrSetValues cset thunkMap-       in filled `seq` evalBytecode letEnv bodyIdx---- | Check if all bytecode bindings are single static keys (eligible for positional).--- Must stay in sync with 'allStaticSingleKey' in 'Nix.Expr.Resolve'.-allBcPositional :: [BcBinding] -> Bool-allBcPositional = all isEligible-  where-    isEligible (BcNamed [BcStaticKey _] _) = True-    isEligible (BcInherit _) = True-    isEligible (BcInheritFrom _ _) = True-    isEligible _ = False---- | Count positional slots for bytecode bindings.--- Must stay in sync with 'lexicalScopeFromBindings' in 'Nix.Expr.Resolve'.-bcBindingSlotCount :: [BcBinding] -> Int-bcBindingSlotCount = foldl' countOne 0-  where-    countOne !acc (BcNamed [BcStaticKey _] _) = acc + 1-    countOne !acc (BcInherit syms) = acc + length syms-    countOne !acc (BcInheritFrom _ syms) = acc + length syms-    countOne !acc _ = acc---- | Build thunks for positional bytecode bindings in declaration order.-buildBcSlotThunks :: Env -> Env -> [BcBinding] -> [Thunk]-buildBcSlotThunks recEnv outerEnv = concatMap slotThunk-  where-    slotThunk (BcNamed [BcStaticKey _] valBcIdx) =-      [mkThunkBc recEnv valBcIdx]-    slotThunk (BcInherit syms) =-      map (inheritLookup outerEnv . symbolText . Symbol) syms-    slotThunk (BcInheritFrom fromBcIdx syms) =-      -- inherit (from) x y z; becomes one thunk per name that selects from the from-expr.-      -- Each thunk gets a minimal env with the from-value at slot 0.-      let fromThunk = mkThunkBc recEnv fromBcIdx-          mkInheritThunk sym =-            let name = symbolText (Symbol sym)-                selectExpr = ESelect (EResolvedVar 0 0) [StaticKey name] Nothing-                (sp, sc) = buildCSlots [fromThunk]-                fromEnv = newMinimalEnv sp sc-             in mkSyntheticThunk fromEnv selectExpr-       in map mkInheritThunk syms-    -- Unreachable: allBcPositional guards this path.-    slotThunk _ = []---- | Build attr map from slots for positional bytecode bindings.-buildBcAttrMapFromSlots :: [BcBinding] -> [Thunk] -> Map Text Thunk-buildBcAttrMapFromSlots bindings thunks = go bindings thunks Map.empty-  where-    go [] _ !acc = acc-    go (BcNamed [BcStaticKey sym] _ : bs) (t : ts) !acc =-      go bs ts (Map.insert (symbolText (Symbol sym)) t acc)-    go (BcInherit syms : bs) ts !acc =-      let (used, rest) = splitAt (length syms) ts-          accMerged = foldl' (\a (sym, t0) -> Map.insert (symbolText (Symbol sym)) t0 a) acc (zip syms used)-       in go bs rest accMerged-    go (BcInheritFrom _ syms : bs) ts !acc =-      let (used, rest) = splitAt (length syms) ts-          accMerged = foldl' (\a (sym, t0) -> Map.insert (symbolText (Symbol sym)) t0 a) acc (zip syms used)-       in go bs rest accMerged-    -- Unreachable: allBcPositional guards this path.-    go (_ : bs) ts !acc = go bs ts acc---- | Build thunk map for bytecode attrs (non-rec or fallback rec path).-buildBcThunkMap :: (MonadEval m) => Env -> [BcBinding] -> m (Map Text Thunk)-buildBcThunkMap thunkEnv = foldM addBinding Map.empty-  where-    addBinding acc (BcNamed keys valBcIdx) = do-      resolvedKeys <- mapM (resolveBcKey thunkEnv) keys-      case sequence resolvedKeys of-        Nothing -> pure acc -- null key -> skip-        Just [key] ->-          pure (insertWithMerge acc key (mkThunkBc thunkEnv valBcIdx))-        Just path ->-          let nested = buildBcNestedAttr thunkEnv path valBcIdx-           in pure (foldl' (\a (k, t0) -> insertWithMerge a k t0) acc (Map.toList nested))-    addBinding acc (BcInherit syms) =-      pure (foldl' (\a sym -> let name = symbolText (Symbol sym) in insertWithMerge a name (inheritLookup thunkEnv name)) acc syms)-    addBinding acc (BcInheritFrom fromBcIdx syms) =-      -- inherit (from) name selects name from the from-expr.-      -- Create a small env with the from value at slot 0, then a-      -- synthetic expression that selects name from slot 0.-      let addInheritFrom a sym =-            let name = symbolText (Symbol sym)-                selectExpr = ESelect (EResolvedVar 0 0) [StaticKey name] Nothing-                (sp, sc) = buildCSlots [mkThunkBc thunkEnv fromBcIdx]-                fromEnv = newMinimalEnv sp sc-             in insertWithMerge a name (mkSyntheticThunk fromEnv selectExpr)-       in pure (foldl' addInheritFrom acc syms)--    insertWithMerge acc key thunk =-      case Map.lookup key acc of-        Nothing -> Map.insert key thunk acc-        Just existing -> Map.insert key (mergeThunks existing thunk) acc---- | Resolve a bytecode attr key to text.-resolveBcKey :: (MonadEval m) => Env -> BcAttrKey -> m (Maybe Text)-resolveBcKey _env (BcStaticKey sym) = pure (Just (symbolText (Symbol sym)))-resolveBcKey env (BcDynamicKey bcIdx0) = do-  val <- evalBytecode env bcIdx0-  case val of-    VStr s _ -> pure (Just s)-    VNull -> pure Nothing-    _ -> throwEvalError ("dynamic attribute key must be a string, got " <> typeName val)---- | Build a nested attribute structure from a resolved dotted path (bytecode).-buildBcNestedAttr :: Env -> [Text] -> Word32 -> Map Text Thunk-buildBcNestedAttr _thunkEnv [] _valBcIdx = Map.empty-buildBcNestedAttr thunkEnv [key] valBcIdx =-  Map.singleton key (mkThunkBc thunkEnv valBcIdx)-buildBcNestedAttr thunkEnv (key : rest) valBcIdx =-  Map.singleton key (evaluated (VAttrs (attrSetFromMap (buildBcNestedAttr thunkEnv rest valBcIdx))))---- | Extract all top-level keys from bytecode bindings, resolving--- dynamic keys as needed.  Used by the fallback path (two-phase--- CAttrSet construction) where all keys must be known before thunks.-bcBindingAllKeys :: (MonadEval m) => Env -> [BcBinding] -> m [Text]-bcBindingAllKeys env bindings = fmap concat (mapM oneBinding bindings)-  where-    oneBinding (BcNamed (key : _) _) = do-      resolved <- resolveBcKey env key-      pure (maybeToList resolved)-    oneBinding (BcNamed [] _) = pure []-    oneBinding (BcInherit syms) =-      pure (map (symbolText . Symbol) syms)-    oneBinding (BcInheritFrom _ syms) =-      pure (map (symbolText . Symbol) syms)---- ------------------------------------------------------------------------------ Search paths (<nixpkgs>, <nixpkgs/lib>)--- ------------------------------------------------------------------------------- | Evaluate a search path expression.--- Desugars to @builtins.findFile builtins.nixPath "name"@ - exactly how--- real Nix handles @\<name\>@ expressions.-evalSearchPath :: (MonadEval m) => Env -> Text -> m NixValue-evalSearchPath env name = do-  builtinsVal <- evalVar env "builtins"-  case builtinsVal of-    VAttrs builtinsAttrs ->-      case attrSetLookup "nixPath" builtinsAttrs of-        Just nixPathThunk -> do-          nixPathVal <- force nixPathThunk-          builtinFindFile nixPathVal (mkStr name)-        Nothing ->-          throwEvalError ("file '" <> name <> "' was not found in the Nix search path")-    _ ->-      throwEvalError ("file '" <> name <> "' was not found in the Nix search path")---- ------------------------------------------------------------------------------ Variables--- -----------------------------------------------------------------------------evalVar :: (MonadEval m) => Env -> Text -> m NixValue-evalVar env name =-  case envLookup name env of-    Just thunk -> force thunk-    Nothing -> throwEvalError ("undefined variable '" <> name <> "'")---- | Evaluate a with-scoped variable: check with-scopes first (innermost--- to outermost), then fall back to the standard name-based lookup--- (parent chain to builtins).  For trimmed envs ('CapturesWithScopes'),--- the root scope is already appended to 'envWithScopes' so the--- with-scope lookup finds builtins without needing a parent chain.--- Supports both resolved (CAttrSet*) and lazy (CThunk*, tagged with bit 0)--- with-scope entries.  Lazy entries are forced on first lookup and the--- pointer is updated in place so subsequent lookups hit the resolved--- attrset directly.-evalWithVar :: (MonadEval m) => Env -> Text -> m NixValue-evalWithVar env name =-  let (withArr, withCount) = envWithScopesRaw env-   in evalWithVarScopes env name withArr (fromIntegral withCount) 0--evalWithVarScopes :: (MonadEval m) => Env -> Text -> Ptr (Ptr ()) -> Int -> Int -> m NixValue-evalWithVarScopes env name withArr count idx-  | idx >= count = evalVar env name-  | otherwise = do-      let scopePtr = unsafePerformIO (peekElemOff withArr idx)-      if isLazyWithScope scopePtr-        then do-          -- Lazy with-scope: force the thunk to get the attrset-          let thunkPtr = untagWithScope scopePtr-          scopeVal <- force (Thunk thunkPtr)-          case scopeVal of-            VAttrs (AttrSet cset) -> do-              -- Cache: replace the tagged thunk pointer with the resolved-              -- attrset pointer so future lookups are fast.-              let resolvedPtr = castPtr cset-              seq (unsafePerformIO (pokeElemOff withArr idx resolvedPtr)) (pure ())-              case attrSetLookup name (AttrSet cset) of-                Just thunk -> force thunk-                Nothing -> evalWithVarScopes env name withArr count (idx + 1)-            _ -> throwEvalError ("'with' requires a set, got " <> typeName scopeVal)-        else-          -- Resolved attrset scope (normal path)-          case attrSetLookup name (AttrSet (castPtr scopePtr)) of-            Just thunk -> force thunk-            Nothing -> evalWithVarScopes env name withArr count (idx + 1)---- | Check if a with-scope pointer is tagged as lazy (bit 0 set).-isLazyWithScope :: Ptr () -> Bool-isLazyWithScope ptr = ptrToWordPtr ptr .&. 1 /= 0---- | Remove the lazy tag from a with-scope pointer, returning a CThunkPtr.-untagWithScope :: Ptr () -> CThunkPtr-untagWithScope ptr =-  let tagged = ptrToWordPtr ptr-   in wordPtrToPtr (tagged .&. complement 1)---- | Tag a CThunkPtr as a lazy with-scope (set bit 0).-tagLazyWithScope :: CThunkPtr -> Ptr ()-tagLazyWithScope ptr =-  let raw = ptrToWordPtr (castPtr ptr)-   in wordPtrToPtr (raw .|. 1)---- | Push a lazy (thunk-based) with-scope onto the environment.--- The scope is NOT forced until a WITH_VAR lookup actually needs it.-{-# NOINLINE pushLazyWithScope #-}-pushLazyWithScope :: Thunk -> Env -> Env-pushLazyWithScope (Thunk thunkPtr) =-  pushWithScopeRaw (tagLazyWithScope thunkPtr)---- | Push a raw pointer as a with-scope.-{-# NOINLINE pushWithScopeRaw #-}-pushWithScopeRaw :: Ptr () -> Env -> Env-pushWithScopeRaw ptr (Env envPtr) =-  Env (unsafePerformIO (cenvPushWith envPtr ptr))---- ------------------------------------------------------------------------------ Formals matching + env helpers (used by evalBcApp, applyValue, etc.)--- ------------------------------------------------------------------------------- | Match a lambda's formals against an argument thunk.-matchFormals :: (MonadEval m) => Env -> EvalFormals -> Thunk -> m Env-matchFormals closureEnv (EFName _) argThunk =-  let (sp, sc) = buildCSlots [argThunk]-   in pure (envFromSlots sp sc closureEnv)-matchFormals closureEnv (EFSet formals allowExtra) argThunk = do-  argVal <- force argThunk-  matchFormalSet closureEnv formals allowExtra argVal Nothing-matchFormals closureEnv (EFNamedSet _ formals allowExtra) argThunk = do-  argVal <- force argThunk-  matchFormalSet closureEnv formals allowExtra argVal (Just argThunk)---- | Match destructuring set pattern formals against an attrset value.-matchFormalSet :: (MonadEval m) => Env -> [EvalFormal] -> Bool -> NixValue -> Maybe Thunk -> m Env-matchFormalSet closureEnv formals allowExtra argVal atThunk =-  case argVal of-    VAttrs attrs -> do-      checkExtraKeys formals allowExtra attrs-      checkMissingFormals attrs formals-      let formalEnv = envFromSlots formalSlotsPtr formalSlotCount closureEnv-          (formalSlotsPtr, formalSlotCount) = buildCSlots formalThunks-          formalThunks = case atThunk of-            Nothing -> map resolveOneFormal formals-            Just at -> at : map resolveOneFormal formals-          resolveOneFormal (EvalFormal name defBcIdx) =-            case attrSetLookup name attrs of-              Just thunk -> thunk-              Nothing -> case defBcIdx of-                Just bcIdx -> mkThunkBc formalEnv bcIdx-                Nothing -> error "matchFormalSet: missing required formal (unreachable)"-      pure formalEnv-    _ -> throwEvalError ("function expects a set argument, got " <> typeName argVal)--checkExtraKeys :: (MonadEval m) => [EvalFormal] -> Bool -> AttrSet -> m ()-checkExtraKeys _ True _ = pure ()-checkExtraKeys formals False attrs =-  let expected = map efName formals-      actual = attrSetKeys attrs-      extra = filter (`notElem` expected) actual-   in case extra of-        [] -> pure ()-        (k : _) -> throwEvalError ("unexpected attribute '" <> k <> "' in function argument")--checkMissingFormals :: (MonadEval m) => AttrSet -> [EvalFormal] -> m ()-checkMissingFormals attrs formals =-  let allMissing = [efName f | f <- formals, isNothing (efDefault f), not (attrSetMember (efName f) attrs)]-   in case allMissing of-        [] -> pure ()-        (name : _) ->-          let provided = attrSetKeys attrs-              provSnippet = T.intercalate ", " (take 20 provided)-              missSnippet = T.intercalate ", " allMissing-           in throwEvalError ("missing required attribute '" <> name <> "'; all missing: [" <> missSnippet <> "]; provided keys (" <> T.pack (show (length provided)) <> "): [" <> provSnippet <> "]")---- | Build capture environment from capture info.--- NoCaptureInfo: no trimming, use env as-is.--- Captures: build minimal env from captured slots.--- CapturesWithScopes: build minimal env + copy with-scopes.-buildCaptureEnv :: Env -> CaptureInfo -> Env-buildCaptureEnv env NoCaptureInfo = env-buildCaptureEnv env (Captures captureList) =-  let (slotsPtr, slotCount) = buildCSlots [envLookupResolved lvl idx env | (lvl, idx) <- captureList]-   in newMinimalEnv slotsPtr slotCount-buildCaptureEnv env (CapturesWithScopes captureList) =-  let (slotsPtr, slotCount) = buildCSlots [envLookupResolved lvl idx env | (lvl, idx) <- captureList]-      (withArr, withCount) = withScopesForCapture env-   in newCEnv slotsPtr slotCount Nothing Nothing withArr withCount---- | Look up a name in the environment and return its thunk.--- Used by @inherit@ bindings (both bytecode and Expr paths).-inheritLookup :: Env -> Text -> Thunk-inheritLookup env name =-  case envLookup name env of-    Just thunk -> thunk-    Nothing -> error ("inheritLookup: undefined variable '" <> T.unpack name <> "' (unreachable)")---- | Merge two thunks for nested attribute update.--- If both are computed attrsets, recursively merge with attrSetUnionWith.--- Otherwise the new value wins.-mergeThunks :: Thunk -> Thunk -> Thunk-mergeThunks a b =-  case (readThunkValue a, readThunkValue b) of-    (Just (VAttrs aa), Just (VAttrs bb)) ->-      evaluated (VAttrs (attrSetUnionWith mergeThunks aa bb))-    _ -> b---- ------------------------------------------------------------------------------ Builtin registry (single-definition-site for all builtins)--- ------------------------------------------------------------------------------- | A builtin function definition: its arity and implementation.-data BuiltinDef m = BuiltinDef-  { bdArity :: !Int,-    bdApply :: [NixValue] -> m NixValue-  }---- | Define an arity-1 builtin.-builtin1 :: (MonadEval m) => Text -> (NixValue -> m NixValue) -> (Text, BuiltinDef m)-builtin1 name f =-  ( name,-    BuiltinDef 1 $ \case-      [a] -> f a-      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")-  )---- | Define an arity-2 builtin.-builtin2 ::-  (MonadEval m) =>-  Text ->-  (NixValue -> NixValue -> m NixValue) ->-  (Text, BuiltinDef m)-builtin2 name f =-  ( name,-    BuiltinDef 2 $ \case-      [a, b] -> f a b-      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")-  )---- | Define an arity-3 builtin.-builtin3 ::-  (MonadEval m) =>-  Text ->-  (NixValue -> NixValue -> NixValue -> m NixValue) ->-  (Text, BuiltinDef m)-builtin3 name f =-  ( name,-    BuiltinDef 3 $ \case-      [a, b, c] -> f a b c-      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")-  )---- | Central registry of all builtins.  Adding a new builtin is a single--- entry here plus its implementation function - no other files need changes.-builtinRegistry :: (MonadEval m) => Map Text (BuiltinDef m)-builtinRegistry =-  Map.fromList-    [ -- Type checking (arity 1)-      builtin1 "typeOf" (pure . mkStr . typeOfValue),-      builtin1 "isNull" (pure . VBool . isNullVal),-      builtin1 "isInt" (pure . VBool . isIntVal),-      builtin1 "isFloat" (pure . VBool . isFloatVal),-      builtin1 "isBool" (pure . VBool . isBoolVal),-      builtin1 "isString" (pure . VBool . isStringVal),-      builtin1 "isList" (pure . VBool . isListVal),-      builtin1 "isAttrs" (pure . VBool . isAttrsVal),-      builtin1 "isFunction" (pure . VBool . isFunctionVal),-      -- List operations (arity 1)-      builtin1 "length" builtinLength,-      builtin1 "head" builtinHead,-      builtin1 "tail" builtinTail,-      -- String operations (arity 1)-      builtin1 "toString" (fmap (uncurry VStr) . coerceToStringPermissive),-      builtin1 "stringLength" builtinStringLength,-      -- Control (arity 1)-      builtin1 "throw" builtinThrow,-      builtin1 "abort" builtinAbort,-      -- Attr set operations (arity 1)-      builtin1 "attrNames" builtinAttrNames,-      builtin1 "attrValues" builtinAttrValues,-      builtin1 "listToAttrs" builtinListToAttrs,-      -- Attr set operations (arity 2)-      builtin2 "hasAttr" builtinHasAttr,-      builtin2 "getAttr" builtinGetAttr,-      builtin2 "removeAttrs" builtinRemoveAttrs,-      builtin2 "intersectAttrs" builtinIntersectAttrs,-      builtin2 "catAttrs" builtinCatAttrs,-      -- List higher-order (arity 2)-      builtin2 "map" builtinMap,-      builtin2 "filter" builtinFilter,-      builtin2 "genList" builtinGenList,-      builtin2 "sort" builtinSort,-      builtin2 "concatMap" builtinConcatMap,-      builtin2 "any" builtinAny,-      builtin2 "all" builtinAll,-      builtin2 "elem" builtinElem,-      builtin2 "elemAt" builtinElemAt,-      builtin2 "partition" builtinPartition,-      builtin2 "groupBy" builtinGroupBy,-      -- String operations (arity 2)-      builtin2 "concatStringsSep" builtinConcatStringsSep,-      -- Arity 3-      builtin3 "foldl'" builtinFoldl,-      builtin3 "substring" builtinSubstring,-      -- Numeric-      builtin1 "isPath" (pure . VBool . isPathVal),-      builtin1 "ceil" builtinCeil,-      builtin1 "floor" builtinFloor,-      builtin2 "seq" builtinSeq,-      builtin2 "trace" builtinTrace,-      builtin2 "warn" builtinWarn,-      builtin1 "unsafeDiscardStringContext" builtinDiscardContext,-      builtin1 "unsafeDiscardOutputDependency" builtinDiscardOutputDep,-      -- String context introspection-      builtin1 "hasContext" builtinHasContext,-      builtin1 "getContext" builtinGetContext,-      builtin2 "appendContext" builtinAppendContext,-      builtin1 "baseNameOf" builtinBaseNameOf,-      builtin1 "dirOf" builtinDirOf,-      builtin1 "concatLists" builtinConcatLists,-      builtin2 "lessThan" builtinLessThan,-      -- Arithmetic + bitwise-      builtin2 "add" builtinAdd,-      builtin2 "sub" builtinSub,-      builtin2 "mul" builtinMul,-      builtin2 "div" builtinDiv,-      builtin2 "mod" builtinMod,-      builtin2 "bitAnd" builtinBitAnd,-      builtin2 "bitOr" builtinBitOr,-      builtin2 "bitXor" builtinBitXor,-      builtin2 "min" builtinMin,-      builtin2 "max" builtinMax,-      -- Attr set higher-order-      builtin2 "mapAttrs" builtinMapAttrs,-      builtin1 "functionArgs" builtinFunctionArgs,-      builtin2 "setFunctionArgs" builtinSetFunctionArgs,-      builtin2 "zipAttrsWith" builtinZipAttrsWith,-      -- String manipulation-      builtin2 "match" builtinMatch,-      builtin2 "split" builtinSplit,-      builtin3 "replaceStrings" builtinReplaceStrings,-      builtin2 "compareVersions" builtinCompareVersions,-      builtin1 "splitVersion" builtinSplitVersion,-      builtin1 "parseDrvName" builtinParseDrvName,-      -- Serialization + hashing-      builtin1 "toJSON" builtinToJSON,-      builtin1 "fromJSON" builtinFromJSON,-      builtin2 "hashString" builtinHashString,-      -- Error handling + sequencing-      builtin1 "tryEval" (\_ -> throwEvalError "unreachable: tryEval handled in evalApp"),-      builtin2 "deepSeq" builtinDeepSeq,-      -- Graph traversal-      builtin1 "genericClosure" builtinGenericClosure,-      -- IO builtins (delegate to MonadEval methods)-      builtin1 "import" builtinImport,-      builtin1 "readFile" builtinReadFile,-      builtin1 "pathExists" builtinPathExists,-      builtin1 "readDir" builtinReadDir,-      builtin1 "getEnv" builtinGetEnv,-      builtin1 "toPath" builtinToPath,-      -- Store path operations-      builtin1 "placeholder" builtinPlaceholder,-      builtin1 "storePath" builtinStorePath,-      builtin2 "findFile" builtinFindFile,-      builtin2 "toFile" builtinToFile,-      builtin2 "scopedImport" builtinScopedImport,-      -- Network fetchers-      builtin1 "fetchurl" builtinFetchurl,-      builtin1 "fetchTarball" builtinFetchTarball,-      builtin1 "fetchGit" builtinFetchGit,-      -- Derivation construction: lazy 'derivation' wrapper over the eager-      -- 'derivationStrict' primop (matches C++ Nix corepkgs/derivation.nix).-      builtin1 "derivation" builtinDerivationLazy,-      builtin1 "derivationStrict" builtinDerivationStrict,-      -- Error context (pass-through - context only matters on error)-      builtin2 "addErrorContext" (\_ val -> pure val),-      -- Attr position (return null - nixpkgs handles this gracefully)-      builtin2 "unsafeGetAttrPos" (\_ _ -> pure VNull),-      -- Debugging (traceVerbose: same as trace for now, --trace-verbose not yet gated)-      builtin2 "traceVerbose" builtinTrace,-      builtin1 "break" pure,-      -- IO: file hashing + type detection-      builtin2 "hashFile" builtinHashFile,-      builtin1 "readFileType" builtinReadFileType,-      -- Serialization-      builtin1 "fromTOML" builtinFromTOML,-      -- Hash conversion-      builtin1 "convertHash" builtinConvertHash,-      -- XML serialization-      builtin1 "toXML" builtinToXML,-      -- Source filtering + path import-      builtin1 "path" builtinPath,-      builtin2 "filterSource" builtinFilterSource,-      -- Experimental feature stubs-      builtin2 "outputOf" builtinOutputOf,-      builtin1 "fetchTree" builtinFetchTree,-      builtin1 "fetchClosure" builtinFetchClosure-    ]---- | Names of all registered builtins.-builtinNames :: [Text]-builtinNames = Map.keys (builtinRegistry :: Map Text (BuiltinDef PureEval))---- ------------------------------------------------------------------------------ Builtin dispatch (partial application via accumulated args)--- ------------------------------------------------------------------------------- | Arity of a builtin (how many arguments before execution).-builtinArity :: Text -> Int-builtinArity name = maybe 1 bdArity (Map.lookup name (builtinRegistry :: Map Text (BuiltinDef PureEval)))---- | Apply a builtin with accumulated args.  If we have enough args,--- execute; otherwise return a partially applied builtin.-applyBuiltin :: (MonadEval m) => Text -> [NixValue] -> NixValue -> m NixValue-applyBuiltin name accArgs arg =-  let allArgs = accArgs ++ [arg]-      arity = builtinArity name-   in if length allArgs < arity-        then pure (VBuiltin name (precompileArgs name allArgs))-        else executeBuiltin name allArgs---- | Pre-compile regex patterns at partial application time.--- When builtins.match or builtins.split receives its first argument--- (the pattern string), compile it immediately and store the compiled--- RE.Regex in a VCompiledRegex, replacing the raw VStr.  The compiled--- form is carried in VBuiltin's accumulated args and reused on every--- subsequent application - zero recompilation.-precompileArgs :: Text -> [NixValue] -> [NixValue]-precompileArgs "match" [VStr pat _] =-  let anchored = "^" <> pat <> "$"-   in case cachedCompileRegex anchored of-        Just compiled -> [VCompiledRegex (CompiledRegex pat compiled)]-        Nothing -> [VStr pat emptyContext] -- fail later at execute time-precompileArgs "split" [VStr pat _] =-  case cachedCompileRegex pat of-    Just compiled -> [VCompiledRegex (CompiledRegex pat compiled)]-    Nothing -> [VStr pat emptyContext] -- fail later at execute time-precompileArgs _ args = args---- | Apply a function value (lambda or builtin) to one argument.--- Used by higher-order builtins to invoke user-supplied functions.-applyValue :: (MonadEval m) => NixValue -> NixValue -> m NixValue-applyValue (VLambda closureEnv formals bodyBcIdx) arg = do-  extEnv <- matchFormals closureEnv formals (evaluated arg)-  evalBytecode extEnv bodyBcIdx-applyValue (VBuiltin name accArgs) arg =-  applyBuiltin name accArgs arg-applyValue other _ =-  throwEvalError ("attempt to call " <> typeName other <> ", which is not a function")---- | Execute a builtin once all arguments are collected.------ Direct case dispatch avoids rebuilding the polymorphic 'builtinRegistry'--- Map on every call.  'builtinRegistry' is polymorphic in @m@ so GHC--- cannot cache it as a CAF - it gets reconstructed on every use.--- Pattern matching on the name is zero-allocation.-executeBuiltin :: (MonadEval m) => Text -> [NixValue] -> m NixValue-executeBuiltin name args = case name of-  -- Type checking (arity 1)-  "typeOf" -> apply1 (pure . mkStr . typeOfValue)-  "isNull" -> apply1 (pure . VBool . isNullVal)-  "isInt" -> apply1 (pure . VBool . isIntVal)-  "isFloat" -> apply1 (pure . VBool . isFloatVal)-  "isBool" -> apply1 (pure . VBool . isBoolVal)-  "isString" -> apply1 (pure . VBool . isStringVal)-  "isList" -> apply1 (pure . VBool . isListVal)-  "isAttrs" -> apply1 (pure . VBool . isAttrsVal)-  "isFunction" -> apply1 (pure . VBool . isFunctionVal)-  -- List operations (arity 1)-  "length" -> apply1 builtinLength-  "head" -> apply1 builtinHead-  "tail" -> apply1 builtinTail-  -- String operations (arity 1)-  "toString" -> apply1 (fmap (uncurry VStr) . coerceToStringPermissive)-  "stringLength" -> apply1 builtinStringLength-  -- Control (arity 1)-  "throw" -> apply1 builtinThrow-  "abort" -> apply1 builtinAbort-  -- Attr set operations (arity 1)-  "attrNames" -> apply1 builtinAttrNames-  "attrValues" -> apply1 builtinAttrValues-  "listToAttrs" -> apply1 builtinListToAttrs-  -- Attr set operations (arity 2)-  "hasAttr" -> apply2 builtinHasAttr-  "getAttr" -> apply2 builtinGetAttr-  "removeAttrs" -> apply2 builtinRemoveAttrs-  "intersectAttrs" -> apply2 builtinIntersectAttrs-  "catAttrs" -> apply2 builtinCatAttrs-  -- List higher-order (arity 2)-  "map" -> apply2 builtinMap-  "filter" -> apply2 builtinFilter-  "genList" -> apply2 builtinGenList-  "sort" -> apply2 builtinSort-  "concatMap" -> apply2 builtinConcatMap-  "any" -> apply2 builtinAny-  "all" -> apply2 builtinAll-  "elem" -> apply2 builtinElem-  "elemAt" -> apply2 builtinElemAt-  "partition" -> apply2 builtinPartition-  "groupBy" -> apply2 builtinGroupBy-  -- String operations (arity 2)-  "concatStringsSep" -> apply2 builtinConcatStringsSep-  -- Arity 3-  "foldl'" -> apply3 builtinFoldl-  "substring" -> apply3 builtinSubstring-  -- Numeric-  "isPath" -> apply1 (pure . VBool . isPathVal)-  "ceil" -> apply1 builtinCeil-  "floor" -> apply1 builtinFloor-  "seq" -> apply2 builtinSeq-  "trace" -> apply2 builtinTrace-  "warn" -> apply2 builtinWarn-  "unsafeDiscardStringContext" -> apply1 builtinDiscardContext-  "unsafeDiscardOutputDependency" -> apply1 builtinDiscardOutputDep-  -- String context introspection-  "hasContext" -> apply1 builtinHasContext-  "getContext" -> apply1 builtinGetContext-  "appendContext" -> apply2 builtinAppendContext-  "baseNameOf" -> apply1 builtinBaseNameOf-  "dirOf" -> apply1 builtinDirOf-  "concatLists" -> apply1 builtinConcatLists-  "lessThan" -> apply2 builtinLessThan-  -- Arithmetic + bitwise-  "add" -> apply2 builtinAdd-  "sub" -> apply2 builtinSub-  "mul" -> apply2 builtinMul-  "div" -> apply2 builtinDiv-  "mod" -> apply2 builtinMod-  "bitAnd" -> apply2 builtinBitAnd-  "bitOr" -> apply2 builtinBitOr-  "bitXor" -> apply2 builtinBitXor-  "min" -> apply2 builtinMin-  "max" -> apply2 builtinMax-  -- Attr set higher-order-  "mapAttrs" -> apply2 builtinMapAttrs-  "functionArgs" -> apply1 builtinFunctionArgs-  "setFunctionArgs" -> apply2 builtinSetFunctionArgs-  "zipAttrsWith" -> apply2 builtinZipAttrsWith-  -- String manipulation-  "match" -> apply2 builtinMatch-  "split" -> apply2 builtinSplit-  "replaceStrings" -> apply3 builtinReplaceStrings-  "compareVersions" -> apply2 builtinCompareVersions-  "splitVersion" -> apply1 builtinSplitVersion-  "parseDrvName" -> apply1 builtinParseDrvName-  -- Serialization + hashing-  "toJSON" -> apply1 builtinToJSON-  "fromJSON" -> apply1 builtinFromJSON-  "hashString" -> apply2 builtinHashString-  -- Error handling + sequencing-  "tryEval" -> apply1 (\_ -> throwEvalError "unreachable: tryEval handled in evalApp")-  "deepSeq" -> apply2 builtinDeepSeq-  -- Graph traversal-  "genericClosure" -> apply1 builtinGenericClosure-  -- IO builtins (delegate to MonadEval methods)-  "import" -> apply1 builtinImport-  "readFile" -> apply1 builtinReadFile-  "pathExists" -> apply1 builtinPathExists-  "readDir" -> apply1 builtinReadDir-  "getEnv" -> apply1 builtinGetEnv-  "toPath" -> apply1 builtinToPath-  -- Store path operations-  "placeholder" -> apply1 builtinPlaceholder-  "storePath" -> apply1 builtinStorePath-  "findFile" -> apply2 builtinFindFile-  "toFile" -> apply2 builtinToFile-  "scopedImport" -> apply2 builtinScopedImport-  -- Network fetchers-  "fetchurl" -> apply1 builtinFetchurl-  "fetchTarball" -> apply1 builtinFetchTarball-  "fetchGit" -> apply1 builtinFetchGit-  -- Derivation construction: lazy 'derivation' over eager 'derivationStrict'-  "derivation" -> apply1 builtinDerivationLazy-  "derivationStrict" -> apply1 builtinDerivationStrict-  -- Error context (pass-through - context only matters on error)-  "addErrorContext" -> apply2 (\_ val -> pure val)-  -- Attr position (return null - nixpkgs handles this gracefully)-  "unsafeGetAttrPos" -> apply2 (\_ _ -> pure VNull)-  -- Debugging (traceVerbose: same as trace for now)-  "traceVerbose" -> apply2 builtinTrace-  "break" -> apply1 pure-  -- IO: file hashing + type detection-  "hashFile" -> apply2 builtinHashFile-  "readFileType" -> apply1 builtinReadFileType-  -- Serialization-  "fromTOML" -> apply1 builtinFromTOML-  -- Hash conversion-  "convertHash" -> apply1 builtinConvertHash-  -- XML serialization-  "toXML" -> apply1 builtinToXML-  -- Source filtering + path import-  "path" -> apply1 builtinPath-  "filterSource" -> apply2 builtinFilterSource-  -- Experimental feature stubs-  "outputOf" -> apply2 builtinOutputOf-  "fetchTree" -> apply1 builtinFetchTree-  "fetchClosure" -> apply1 builtinFetchClosure-  _ -> throwEvalError ("unknown builtin '" <> name <> "'")-  where-    apply1 f = case args of-      [a] -> f a-      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")-    apply2 f = case args of-      [a, b] -> f a b-      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")-    apply3 f = case args of-      [a, b, c] -> f a b c-      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")---- ------------------------------------------------------------------------------ Builtin implementations - type checking--- -----------------------------------------------------------------------------typeOfValue :: NixValue -> Text-typeOfValue val = case val of-  VInt _ -> "int"-  VFloat _ -> "float"-  VBool _ -> "bool"-  VNull -> "null"-  VStr _ _ -> "string"-  VPath _ -> "path"-  VList _ -> "list"-  VAttrs _ -> "set"-  VLambda {} -> "lambda"-  VBuiltin _ _ -> "lambda"-  VDerivation _ -> "set"-  VCompiledRegex _ -> "lambda"--isNullVal :: NixValue -> Bool-isNullVal VNull = True-isNullVal _ = False--isIntVal :: NixValue -> Bool-isIntVal (VInt _) = True-isIntVal _ = False--isFloatVal :: NixValue -> Bool-isFloatVal (VFloat _) = True-isFloatVal _ = False--isBoolVal :: NixValue -> Bool-isBoolVal (VBool _) = True-isBoolVal _ = False--isStringVal :: NixValue -> Bool-isStringVal (VStr _ _) = True-isStringVal _ = False--isListVal :: NixValue -> Bool-isListVal (VList _) = True-isListVal _ = False--isAttrsVal :: NixValue -> Bool-isAttrsVal (VAttrs _) = True-isAttrsVal _ = False--isFunctionVal :: NixValue -> Bool-isFunctionVal (VLambda {}) = True-isFunctionVal (VBuiltin _ _) = True-isFunctionVal _ = False---- ------------------------------------------------------------------------------ Builtin implementations - list (arity 1)--- -----------------------------------------------------------------------------builtinLength :: (MonadEval m) => NixValue -> m NixValue-builtinLength (VList cl) = pure (VInt (fromIntegral (clistLen cl)))-builtinLength other = throwEvalError ("builtins.length: expected a list, got " <> typeName other)--builtinHead :: (MonadEval m) => NixValue -> m NixValue-builtinHead (VList cl)-  | clistLen cl == 0 = throwEvalError "builtins.head: empty list"-  | otherwise = case clistThunks cl of-      (p : _) -> force (Thunk p)-      [] -> throwEvalError "builtins.head: empty list" -- unreachable: clistLen > 0-builtinHead other = throwEvalError ("builtins.head: expected a list, got " <> typeName other)--builtinTail :: (MonadEval m) => NixValue -> m NixValue-builtinTail (VList cl)-  | clistLen cl == 0 = throwEvalError "builtins.tail: empty list"-  | otherwise = pure (VList (clistFromThunks (drop 1 (clistThunks cl))))-builtinTail other = throwEvalError ("builtins.tail: expected a list, got " <> typeName other)---- ------------------------------------------------------------------------------ Builtin implementations - string (arity 1)--- -----------------------------------------------------------------------------builtinStringLength :: (MonadEval m) => NixValue -> m NixValue-builtinStringLength (VStr s _) = pure (VInt (fromIntegral (T.length s)))-builtinStringLength other =-  throwEvalError ("builtins.stringLength: expected a string, got " <> typeName other)---- ------------------------------------------------------------------------------ Builtin implementations - control--- -----------------------------------------------------------------------------builtinThrow :: (MonadEval m) => NixValue -> m NixValue-builtinThrow (VStr msg _) = throwEvalError msg-builtinThrow other = throwEvalError ("builtins.throw: expected a string, got " <> typeName other)--builtinAbort :: (MonadEval m) => NixValue -> m NixValue-builtinAbort (VStr msg _) = abortEvaluation msg-builtinAbort other = abortEvaluation ("builtins.abort: expected a string, got " <> typeName other)---- ------------------------------------------------------------------------------ Builtin implementations - attr set (arity 1)--- -----------------------------------------------------------------------------builtinAttrNames :: (MonadEval m) => NixValue -> m NixValue-builtinAttrNames (VAttrs attrs) =-  -- Nix returns attribute names lexicographically sorted; the C array is in-  -- interned-symbol order, so sort here (consistent with builtins.attrValues,-  -- which sorts via attrSetElems).-  let thunks = map (evaluated . mkStr) (sort (attrSetKeys attrs))-   in pure (VList (clistFromThunks (map thunkToCPtr thunks)))-builtinAttrNames other =-  throwEvalError ("builtins.attrNames: expected a set, got " <> typeName other)--builtinAttrValues :: (MonadEval m) => NixValue -> m NixValue-builtinAttrValues (VAttrs attrs) =-  pure (VList (clistFromThunks (map thunkToCPtr (attrSetElems attrs))))-builtinAttrValues other =-  throwEvalError ("builtins.attrValues: expected a set, got " <> typeName other)--builtinListToAttrs :: (MonadEval m) => NixValue -> m NixValue-builtinListToAttrs (VList cl) = do-  let thunks = map Thunk (clistThunks cl)-  pairs <- mapM listToAttrsPair thunks-  -- Nix listToAttrs uses first-wins: if duplicate name, first element wins.-  let firstWins = Map.fromListWith (\_ kept -> kept) pairs-  pure (VAttrs (attrSetFromMap firstWins))-builtinListToAttrs other =-  throwEvalError ("builtins.listToAttrs: expected a list, got " <> typeName other)---- | Extract { name, value } from a thunk for listToAttrs.-listToAttrsPair :: (MonadEval m) => Thunk -> m (Text, Thunk)-listToAttrsPair thunk = do-  val <- force thunk-  case val of-    VAttrs attrs -> do-      nameThunk <--        maybe (throwEvalError "builtins.listToAttrs: element missing 'name'") pure $-          attrSetLookup "name" attrs-      nameVal <- force nameThunk-      case nameVal of-        VStr keyName _ ->-          case attrSetLookup "value" attrs of-            Just valueThunk -> pure (keyName, valueThunk)-            Nothing -> throwEvalError "builtins.listToAttrs: element missing 'value'"-        _ -> throwEvalError "builtins.listToAttrs: 'name' must be a string"-    _ -> throwEvalError "builtins.listToAttrs: element must be a set"---- ------------------------------------------------------------------------------ Builtin implementations - attr set (arity 2)--- -----------------------------------------------------------------------------builtinHasAttr :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinHasAttr (VStr key _) (VAttrs attrs) =-  pure (VBool (attrSetMember key attrs))-builtinHasAttr (VStr _ _) other =-  throwEvalError ("builtins.hasAttr: expected a set, got " <> typeName other)-builtinHasAttr other _ =-  throwEvalError ("builtins.hasAttr: expected a string, got " <> typeName other)--builtinGetAttr :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinGetAttr (VStr key _) (VAttrs attrs) =-  case attrSetLookup key attrs of-    Just thunk -> force thunk-    Nothing -> throwEvalError ("builtins.getAttr: attribute '" <> key <> "' not found")-builtinGetAttr (VStr _ _) other =-  throwEvalError ("builtins.getAttr: expected a set, got " <> typeName other)-builtinGetAttr other _ =-  throwEvalError ("builtins.getAttr: expected a string, got " <> typeName other)--builtinRemoveAttrs :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinRemoveAttrs (VAttrs attrs) (VList cl) = do-  let thunks = map Thunk (clistThunks cl)-  keys <- mapM forceToString thunks-  pure (VAttrs (attrSetRemoveKeys keys attrs))-  where-    forceToString thunk = do-      val <- force thunk-      case val of-        VStr s _ -> pure s-        _ -> throwEvalError "builtins.removeAttrs: key list must contain strings"-builtinRemoveAttrs (VAttrs _) other =-  throwEvalError ("builtins.removeAttrs: expected a list, got " <> typeName other)-builtinRemoveAttrs other _ =-  throwEvalError ("builtins.removeAttrs: expected a set, got " <> typeName other)--builtinIntersectAttrs :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinIntersectAttrs (VAttrs a) (VAttrs b) =-  -- Iterate keys of 'a' (typically the smaller set, e.g. functionArgs)-  -- and point-lookup each in 'b' (typically the large set, e.g. nixpkgs).-  -- This avoids materializing all thunks in 'b'.-  let keysA = attrSetKeys a-      result = Map.fromList [(k, thunk) | k <- keysA, Just thunk <- [attrSetLookup k b]]-   in pure (VAttrs (attrSetFromMap result))-builtinIntersectAttrs (VAttrs _) other =-  throwEvalError ("builtins.intersectAttrs: expected a set, got " <> typeName other)-builtinIntersectAttrs other _ =-  throwEvalError ("builtins.intersectAttrs: expected a set, got " <> typeName other)--builtinCatAttrs :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinCatAttrs (VStr key _) (VList cl) = do-  let thunks = map Thunk (clistThunks cl)-  vals <- catAttrsCollect key thunks-  pure (VList (clistFromThunks (map thunkToCPtr vals)))-builtinCatAttrs (VStr _ _) other =-  throwEvalError ("builtins.catAttrs: expected a list, got " <> typeName other)-builtinCatAttrs other _ =-  throwEvalError ("builtins.catAttrs: expected a string, got " <> typeName other)---- | Collect values for a given key from a list of attrsets.--- Tail-recursive with accumulator to avoid stack overflow on large lists.-catAttrsCollect :: (MonadEval m) => Text -> [Thunk] -> m [Thunk]-catAttrsCollect key = go []-  where-    go !acc [] = pure (reverse acc)-    go !acc (thunk : rest) = do-      val <- force thunk-      case val of-        VAttrs attrs ->-          case attrSetLookup key attrs of-            Just found -> go (found : acc) rest-            Nothing -> go acc rest-        _ -> throwEvalError "builtins.catAttrs: list element must be a set"---- ------------------------------------------------------------------------------ Builtin implementations - list higher-order (arity 2)--- -----------------------------------------------------------------------------builtinMap :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinMap func (VList cl) =-  -- Lazy: each element is a deferred application, forced only on demand.-  let thunks = map Thunk (clistThunks cl)-   in pure (VList (clistFromThunks (map (thunkToCPtr . deferApply func) thunks)))-builtinMap _ other =-  throwEvalError ("builtins.map: expected a list, got " <> typeName other)--builtinFilter :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinFilter predFn (VList cl) = do-  let thunks = map Thunk (clistThunks cl)-  filtered <- filterThunks predFn thunks-  pure (VList (clistFromThunks (map thunkToCPtr filtered)))-builtinFilter _ other =-  throwEvalError ("builtins.filter: expected a list, got " <> typeName other)--filterThunks :: (MonadEval m) => NixValue -> [Thunk] -> m [Thunk]-filterThunks _ [] = pure []-filterThunks predFn (thunk : rest) = do-  val <- force thunk-  result <- applyValue predFn val-  case result of-    VBool True -> (thunk :) <$> filterThunks predFn rest-    VBool False -> filterThunks predFn rest-    _ -> throwEvalError "builtins.filter: predicate must return a bool"--builtinGenList :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinGenList func (VInt n)-  | n < 0 = throwEvalError "builtins.genList: length must be non-negative"-  | otherwise =-      -- Lazy: each element is a deferred @f i@, forced only on demand.-      -- Slot 0 = function.-      let fnThunk = evaluated func-          (sp, sc) = buildCSlots [fnThunk]-          env = newMinimalEnv sp sc-          mkIndexThunk i = mkThunk env (EApp (EResolvedVar 0 0) (ELit (NixInt i)))-       in pure (VList (clistFromThunks (map (thunkToCPtr . mkIndexThunk) [0 .. n - 1])))-builtinGenList _ other =-  throwEvalError ("builtins.genList: expected an integer, got " <> typeName other)--builtinSort :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinSort comparator (VList cl) = do-  let thunks = map Thunk (clistThunks cl)-  vals <- mapM force thunks-  sorted <- mergeSort comparator vals-  pure (VList (clistFromThunks (map (thunkToCPtr . evaluated) sorted)))-builtinSort _ other =-  throwEvalError ("builtins.sort: expected a list, got " <> typeName other)---- | Stable O(n log n) merge sort using a user-supplied comparator.--- The comparator takes two args (curried) and returns bool.-mergeSort :: (MonadEval m) => NixValue -> [NixValue] -> m [NixValue]-mergeSort _ [] = pure []-mergeSort _ [x] = pure [x]-mergeSort cmp xs = do-  let half = length xs `div` 2-      (left, right) = splitAt half xs-  sortedLeft <- mergeSort cmp left-  sortedRight <- mergeSort cmp right-  mergeSorted cmp sortedLeft sortedRight--mergeSorted :: (MonadEval m) => NixValue -> [NixValue] -> [NixValue] -> m [NixValue]-mergeSorted _ [] ys = pure ys-mergeSorted _ xs [] = pure xs-mergeSorted cmp (x : xs) (y : ys) = do-  partial <- applyValue cmp x-  result <- applyValue partial y-  case result of-    VBool True -> (x :) <$> mergeSorted cmp xs (y : ys)-    VBool False -> (y :) <$> mergeSorted cmp (x : xs) ys-    _ -> throwEvalError "builtins.sort: comparator must return a bool"--builtinConcatMap :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinConcatMap func (VList cl) = do-  -- Semi-eager: must force each application to discover list structure for-  -- concatenation, but element thunks within those sub-lists stay lazy.-  let thunks = map Thunk (clistThunks cl)-      deferredApps = map (deferApply func) thunks-  results <- mapM force deferredApps-  concatted <- mapM extractList results-  pure (VList (clistFromThunks (map thunkToCPtr (concat concatted))))-builtinConcatMap _ other =-  throwEvalError ("builtins.concatMap: expected a list, got " <> typeName other)--extractList :: (MonadEval m) => NixValue -> m [Thunk]-extractList (VList cl) = pure (map Thunk (clistThunks cl))-extractList other =-  throwEvalError ("builtins.concatMap: function must return a list, got " <> typeName other)--builtinAny :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinAny predFn (VList cl) = do-  let thunks = map Thunk (clistThunks cl)-  result <- anyThunk predFn thunks-  pure (VBool result)-builtinAny _ other =-  throwEvalError ("builtins.any: expected a list, got " <> typeName other)--anyThunk :: (MonadEval m) => NixValue -> [Thunk] -> m Bool-anyThunk _ [] = pure False-anyThunk predFn (thunk : rest) = do-  val <- force thunk-  result <- applyValue predFn val-  case result of-    VBool True -> pure True-    VBool False -> anyThunk predFn rest-    _ -> throwEvalError "builtins.any: predicate must return a bool"--builtinAll :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinAll predFn (VList cl) = do-  let thunks = map Thunk (clistThunks cl)-  result <- allThunk predFn thunks-  pure (VBool result)-builtinAll _ other =-  throwEvalError ("builtins.all: expected a list, got " <> typeName other)--allThunk :: (MonadEval m) => NixValue -> [Thunk] -> m Bool-allThunk _ [] = pure True-allThunk predFn (thunk : rest) = do-  val <- force thunk-  result <- applyValue predFn val-  case result of-    VBool True -> allThunk predFn rest-    VBool False -> pure False-    _ -> throwEvalError "builtins.all: predicate must return a bool"--builtinElem :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinElem needle (VList cl) = do-  let thunks = map Thunk (clistThunks cl)-  found <- elemCheck needle thunks-  pure (VBool found)-builtinElem _ other =-  throwEvalError ("builtins.elem: expected a list, got " <> typeName other)--elemCheck :: (MonadEval m) => NixValue -> [Thunk] -> m Bool-elemCheck _ [] = pure False-elemCheck needle (thunk : rest) = do-  val <- force thunk-  eq <- nixEqual force needle val-  if eq then pure True else elemCheck needle rest--builtinElemAt :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinElemAt (VList cl) (VInt idx)-  | idx < 0 || fromIntegral idx >= clistLen cl = elemAtOOB idx cl-  | otherwise =-      -- O(1) direct C array access instead of materializing the whole list-      let ptr = unsafePerformIO (clistGet (unCList cl) (fromIntegral idx))-       in force (Thunk ptr)-  where-    elemAtOOB i c =-      throwEvalError-        ( "builtins.elemAt: index "-            <> T.pack (show i)-            <> " out of bounds for list of length "-            <> T.pack (show (clistLen c))-        )-builtinElemAt (VList _) other =-  throwEvalError ("builtins.elemAt: expected an integer, got " <> typeName other)-builtinElemAt other _ =-  throwEvalError ("builtins.elemAt: expected a list, got " <> typeName other)--builtinPartition :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinPartition predFn (VList cl) = do-  let thunks = map Thunk (clistThunks cl)-  (rightThunks, wrongThunks) <- partitionThunks predFn thunks-  pure-    ( VAttrs-        ( attrSetFromMap $-            Map.fromList-              [ ("right", evaluated (VList (clistFromThunks (map thunkToCPtr rightThunks)))),-                ("wrong", evaluated (VList (clistFromThunks (map thunkToCPtr wrongThunks))))-              ]-        )-    )-builtinPartition _ other =-  throwEvalError ("builtins.partition: expected a list, got " <> typeName other)---- | Tail-recursive partition with accumulator to avoid stack overflow.-partitionThunks :: (MonadEval m) => NixValue -> [Thunk] -> m ([Thunk], [Thunk])-partitionThunks predFn = go [] []-  where-    go !rs !ws [] = pure (reverse rs, reverse ws)-    go !rs !ws (thunk : rest) = do-      val <- force thunk-      result <- applyValue predFn val-      case result of-        VBool True -> go (thunk : rs) ws rest-        VBool False -> go rs (thunk : ws) rest-        _ -> throwEvalError "builtins.partition: predicate must return a bool"--builtinGroupBy :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinGroupBy func (VList cl) = do-  let thunks = map Thunk (clistThunks cl)-  groups <- groupByCollect func thunks Map.empty-  pure (VAttrs (attrSetFromMap (Map.map (evaluated . VList . clistFromThunks . map thunkToCPtr . reverse) groups)))-builtinGroupBy _ other =-  throwEvalError ("builtins.groupBy: expected a list, got " <> typeName other)--groupByCollect ::-  (MonadEval m) =>-  NixValue ->-  [Thunk] ->-  Map Text [Thunk] ->-  m (Map Text [Thunk])-groupByCollect _ [] acc = pure acc-groupByCollect func (thunk : rest) acc = do-  val <- force thunk-  result <- applyValue func val-  case result of-    VStr key _ ->-      groupByCollect func rest (Map.insertWith (++) key [thunk] acc)-    _ -> throwEvalError "builtins.groupBy: function must return a string"---- ------------------------------------------------------------------------------ Builtin implementations - string (arity 2)--- -----------------------------------------------------------------------------builtinConcatStringsSep :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinConcatStringsSep (VStr sep sepCtx) (VList cl) = do-  let thunks = map Thunk (clistThunks cl)-  pairs <- mapM forceToStrCtx thunks-  let texts = map fst pairs-      mergedCtx = sepCtx <> mconcat (map snd pairs)-  pure (VStr (T.intercalate sep texts) mergedCtx)-  where-    forceToStrCtx thunk = do-      val <- force thunk-      -- Nix coerces list elements to strings (paths, derivations, etc.).-      -- concatStringsSep is strict (coerceMore=False): non-string scalars error.-      coerceToString False force applyValue val-builtinConcatStringsSep (VStr _ _) other =-  throwEvalError ("builtins.concatStringsSep: expected a list, got " <> typeName other)-builtinConcatStringsSep other _ =-  throwEvalError ("builtins.concatStringsSep: expected a string, got " <> typeName other)---- ------------------------------------------------------------------------------ Builtin implementations - arity 3--- -----------------------------------------------------------------------------builtinFoldl :: (MonadEval m) => NixValue -> NixValue -> NixValue -> m NixValue-builtinFoldl op initial (VList cl) =-  foldlStrict op initial (map Thunk (clistThunks cl))-builtinFoldl _ _ other =-  throwEvalError ("builtins.foldl': expected a list, got " <> typeName other)---- | Strict left fold: apply @op acc elem@ for each element.--- @op@ is curried so we call @applyValue op acc@ then @applyValue partial elem@.-foldlStrict :: (MonadEval m) => NixValue -> NixValue -> [Thunk] -> m NixValue-foldlStrict _ acc [] = pure acc-foldlStrict op acc (thunk : rest) = do-  val <- force thunk-  partial <- applyValue op acc-  stepped <- applyValue partial val-  foldlStrict op stepped rest--builtinSubstring :: (MonadEval m) => NixValue -> NixValue -> NixValue -> m NixValue-builtinSubstring (VInt start) (VInt len) (VStr s ctx)-  | start < 0 = throwEvalError "builtins.substring: negative start position"-  | otherwise =-      let startPos = fromIntegral start-          -- Nix clamps len to available length (negative len means rest of string)-          available = T.length s - startPos-          clampedLen =-            if len < 0-              then available-              else min (fromIntegral len) available-       in -- Context is preserved through substring (matching real Nix).-          pure (VStr (T.take clampedLen (T.drop startPos s)) ctx)-builtinSubstring _ _ (VStr _ _) =-  throwEvalError "builtins.substring: start and length must be integers"-builtinSubstring _ _ other =-  throwEvalError ("builtins.substring: expected a string, got " <> typeName other)---- ------------------------------------------------------------------------------ Builtin helpers--- ------------------------------------------------------------------------------- | Build a thunk that defers @f arg@ - the application only happens when--- the thunk is forced.  Reuses the existing eval machinery via a synthetic--- @EApp (EResolvedVar 0 0) (EResolvedVar 0 1)@ in a self-contained env.--- Slot 0 = function, slot 1 = argument.-deferApply :: NixValue -> Thunk -> Thunk-deferApply func argThunk =-  let (sp, sc) = buildCSlots [evaluated func, argThunk]-      env = newMinimalEnv sp sc-   in mkSyntheticThunk env deferApplyExpr---- | Shared expression for 'deferApply'.  Allocated once as a CAF.-deferApplyExpr :: Expr-deferApplyExpr = EApp (EResolvedVar 0 0) (EResolvedVar 0 1)-{-# NOINLINE deferApplyExpr #-}---- | Permissive coercion used by @builtins.toString@.------ Like 'coerceToString' but additionally handles lists: elements are--- recursively coerced and joined with spaces, matching real Nix semantics.--- @toString [1 2 3]@ gives @"1 2 3"@.-coerceToStringPermissive :: (MonadEval m) => NixValue -> m (Text, StringContext)-coerceToStringPermissive (VList cl) = do-  let thunks = map Thunk (clistThunks cl)-  parts <- mapM coerceThunk thunks-  let texts = map fst parts-      ctx = mconcat (map snd parts)-  pure (T.intercalate " " texts, ctx)-  where-    coerceThunk thunk = do-      val <- force thunk-      coerceToStringPermissive val-coerceToStringPermissive other = coerceToString True force applyValue other---- | Coerce a value to a string for a DERIVATION field (an env value or an--- arg).  Like 'coerceToStringPermissive', but a path literal is copied into--- the store: it becomes its source store path, with that path added to the--- string context so it lands in the derivation's @inputSrcs@ - matching C++--- Nix's copy-to-store coercion of paths in derivation arguments/environment.-coerceToStoreString :: (MonadEval m) => NixValue -> m (Text, StringContext)-coerceToStoreString (VPath p) = do-  spText <- storeSourcePath p-  case parseStorePath defaultStoreDir spText of-    Just sp -> pure (spText, StringContext (Set.singleton (SCPlain sp)))-    Nothing -> pure (spText, mempty)-coerceToStoreString (VList cl) = do-  let thunks = map Thunk (clistThunks cl)-  parts <- mapM (force >=> coerceToStoreString) thunks-  pure (T.intercalate " " (map fst parts), mconcat (map snd parts))-coerceToStoreString other = coerceToStringPermissive other---- | Coerce a value for string interpolation (@"${...}"@).  Like--- 'coerceToString', but a path literal is copied into the store and replaced by--- its source store path (with context) - matching C++ Nix, where interpolation--- uses @copyToStore = true@, unlike 'builtins.toString', which does not copy.-coerceToStringInterp :: (MonadEval m) => NixValue -> m (Text, StringContext)-coerceToStringInterp (VPath p) = do-  spText <- storeSourcePath p-  case parseStorePath defaultStoreDir spText of-    Just sp -> pure (spText, StringContext (Set.singleton (SCPlain sp)))-    Nothing -> pure (spText, mempty)-coerceToStringInterp other = coerceToString False force applyValue other---- | The current system platform string.-currentSystemStr :: Text-currentSystemStr = case (System.Info.arch, System.Info.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) -> T.pack arch <> "-" <> T.pack os---- | Store dir with trailing slash, for building store paths.-storeDirPrefix :: Text-storeDirPrefix = defaultStoreDirText <> "/"---- ------------------------------------------------------------------------------ Builtin implementations - numeric + context--- -----------------------------------------------------------------------------isPathVal :: NixValue -> Bool-isPathVal (VPath _) = True-isPathVal _ = False--builtinCeil :: (MonadEval m) => NixValue -> m NixValue-builtinCeil (VFloat f) = pure (VInt (ceiling f))-builtinCeil (VInt n) = pure (VInt n)-builtinCeil other = throwEvalError ("builtins.ceil: expected a number, got " <> typeName other)--builtinFloor :: (MonadEval m) => NixValue -> m NixValue-builtinFloor (VFloat f) = pure (VInt (floor f))-builtinFloor (VInt n) = pure (VInt n)-builtinFloor other = throwEvalError ("builtins.floor: expected a number, got " <> typeName other)--builtinDiscardContext :: (MonadEval m) => NixValue -> m NixValue-builtinDiscardContext (VStr s _) = pure (mkStr s)-builtinDiscardContext other =-  throwEvalError ("builtins.unsafeDiscardStringContext: expected a string, got " <> typeName other)---- | Strip only derivation output dependencies (SCDrvOutput, SCAllOutputs),--- keeping plain store path references (SCPlain).-builtinDiscardOutputDep :: (MonadEval m) => NixValue -> m NixValue-builtinDiscardOutputDep (VStr s (StringContext ctx)) =-  let kept = Set.filter isPlain ctx-   in pure (VStr s (StringContext kept))-  where-    isPlain (SCPlain _) = True-    isPlain _ = False-builtinDiscardOutputDep other =-  throwEvalError ("builtins.unsafeDiscardOutputDependency: expected a string, got " <> typeName other)---- | Check whether a string has any context elements.-builtinHasContext :: (MonadEval m) => NixValue -> m NixValue-builtinHasContext (VStr _ ctx) = pure (VBool (ctx /= emptyContext))-builtinHasContext other =-  throwEvalError ("builtins.hasContext: expected a string, got " <> typeName other)---- | Return the context of a string as an attrset.------ Each key is a store path string.  Each value is an attrset with:---   - @path@: true if there's a SCPlain reference---   - @allOutputs@: true if there's a SCAllOutputs reference---   - @outputs@: list of output names from SCDrvOutput references-builtinGetContext :: (MonadEval m) => NixValue -> m NixValue-builtinGetContext (VStr _ (StringContext ctx)) = do-  let grouped = groupContextByPath (Set.toList ctx)-      attrMap = Map.map contextEntryToAttrs grouped-  pure (VAttrs (attrSetFromMap attrMap))-builtinGetContext other =-  throwEvalError ("builtins.getContext: expected a string, got " <> typeName other)---- | Intermediate representation for grouping context elements by store path.-data ContextEntry = ContextEntry-  { cePath :: !Bool,-    ceAllOutputs :: !Bool,-    ceOutputs :: ![Text]-  }---- | Group context elements by their store path.-groupContextByPath :: [StringContextElement] -> Map Text ContextEntry-groupContextByPath = foldl' addElement Map.empty-  where-    addElement acc (SCPlain sp) =-      Map.insertWith mergeEntry (spToText sp) (ContextEntry True False []) acc-    addElement acc (SCDrvOutput sp outName) =-      Map.insertWith mergeEntry (spToText sp) (ContextEntry False False [outName]) acc-    addElement acc (SCAllOutputs sp) =-      Map.insertWith mergeEntry (spToText sp) (ContextEntry False True []) acc-    mergeEntry new old =-      ContextEntry-        (cePath new || cePath old)-        (ceAllOutputs new || ceAllOutputs old)-        (ceOutputs new ++ ceOutputs old)-    spToText sp =-      T.pack (storePathToFilePath defaultStoreDir sp)---- | Convert a ContextEntry to an attrset thunk.-contextEntryToAttrs :: ContextEntry -> Thunk-contextEntryToAttrs entry =-  let fields =-        [("path", evaluated (VBool True)) | cePath entry]-          ++ [("allOutputs", evaluated (VBool True)) | ceAllOutputs entry]-          ++ [("outputs", evaluated (VList (clistFromThunks [thunkToCPtr (evaluated (mkStr o)) | o <- ceOutputs entry]))) | not (null (ceOutputs entry))]-   in evaluated (VAttrs (attrSetFromMap (Map.fromList fields)))---- | Append context entries to a string from an attrset.------ @builtins.appendContext string contextAttrset@ adds the specified--- context elements to the string.-builtinAppendContext :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinAppendContext (VStr s ctx) (VAttrs contextAttrs) = do-  newCtx <- parseContextAttrs (attrSetToMap contextAttrs)-  pure (VStr s (ctx <> newCtx))-builtinAppendContext (VStr _ _) other =-  throwEvalError ("builtins.appendContext: second argument must be a set, got " <> typeName other)-builtinAppendContext other _ =-  throwEvalError ("builtins.appendContext: first argument must be a string, got " <> typeName other)---- | Parse a context attrset into a StringContext.--- Each key is a store path; each value is an attrset with optional--- @path@, @allOutputs@, and @outputs@ fields.-parseContextAttrs :: (MonadEval m) => Map Text Thunk -> m StringContext-parseContextAttrs attrs = do-  elements <- mapM parseOneCtx (Map.toList attrs)-  pure (StringContext (Set.fromList (concat elements)))-  where-    parseOneCtx (pathText, thunk) = do-      val <- force thunk-      case val of-        VAttrs inner -> do-          let sp = case parseStorePath defaultStoreDir pathText of-                Just parsed -> parsed-                Nothing -> StorePath (T.take 32 (T.drop (T.length storeDirPrefix) pathText)) (T.drop 33 (T.drop (T.length storeDirPrefix) pathText))-          hasPath <- getBoolAttr "path" inner-          hasAllOuts <- getBoolAttr "allOutputs" inner-          outNames <- getOutputsList inner-          let pathElems = [SCPlain sp | hasPath]-              allOutElems = [SCAllOutputs sp | hasAllOuts]-              outElems = [SCDrvOutput sp o | o <- outNames]-          pure (pathElems ++ allOutElems ++ outElems)-        _ -> throwEvalError "builtins.appendContext: context entry must be a set"--    getBoolAttr key attrs' = case attrSetLookup key attrs' of-      Nothing -> pure False-      Just thunk -> do-        val <- force thunk-        case val of-          VBool b -> pure b-          _ -> pure False--    getOutputsList attrs' = case attrSetLookup "outputs" attrs' of-      Nothing -> pure []-      Just thunk -> do-        val <- force thunk-        case val of-          VList cl -> mapM (forceToOutputName . Thunk) (clistThunks cl)-          _ -> pure []--    forceToOutputName thunk = do-      val <- force thunk-      case val of-        VStr s _ -> pure s-        _ -> throwEvalError "builtins.appendContext: output name must be a string"--builtinBaseNameOf :: (MonadEval m) => NixValue -> m NixValue-builtinBaseNameOf (VStr s ctx) = pure (VStr (lastComponent s) ctx)-builtinBaseNameOf (VPath p) = pure (mkStr (lastComponent p))-builtinBaseNameOf other =-  throwEvalError ("builtins.baseNameOf: expected a string or path, got " <> typeName other)--lastComponent :: Text -> Text-lastComponent t = case T.splitOn "/" t of-  [] -> t-  parts -> case reverse (filter (not . T.null) parts) of-    [] -> ""-    (final : _) -> final--builtinDirOf :: (MonadEval m) => NixValue -> m NixValue-builtinDirOf (VStr s ctx) = pure (VStr (dirComponent s) ctx)-builtinDirOf (VPath p) = pure (VPath (dirComponent p))-builtinDirOf other =-  throwEvalError ("builtins.dirOf: expected a string or path, got " <> typeName other)--dirComponent :: Text -> Text-dirComponent t =-  let idx = T.findIndex (== '/') (T.reverse t)-   in case idx of-        Nothing -> "."-        Just n -> T.take (T.length t - n - 1) t--builtinConcatLists :: (MonadEval m) => NixValue -> m NixValue-builtinConcatLists (VList cl) = do-  let thunks = map Thunk (clistThunks cl)-  sublists <- mapM forceThenExtractList thunks-  pure (VList (clistFromThunks (concat sublists)))-  where-    forceThenExtractList thunk = do-      val <- force thunk-      case val of-        VList innerCl -> pure (clistThunks innerCl)-        _ -> throwEvalError "builtins.concatLists: element must be a list"-builtinConcatLists other =-  throwEvalError ("builtins.concatLists: expected a list, got " <> typeName other)--builtinLessThan :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinLessThan a b = VBool <$> nixCompare force a b---- ------------------------------------------------------------------------------ Builtin implementations - arithmetic + bitwise--- -----------------------------------------------------------------------------builtinAdd :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinAdd (VInt a) (VInt b) = pure (VInt (a + b))-builtinAdd (VInt a) (VFloat b) = pure (VFloat (fromIntegral a + b))-builtinAdd (VFloat a) (VInt b) = pure (VFloat (a + fromIntegral b))-builtinAdd (VFloat a) (VFloat b) = pure (VFloat (a + b))-builtinAdd l r = throwEvalError ("builtins.add: expected numbers, got " <> typeName l <> " and " <> typeName r)--builtinSub :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinSub (VInt a) (VInt b) = pure (VInt (a - b))-builtinSub (VInt a) (VFloat b) = pure (VFloat (fromIntegral a - b))-builtinSub (VFloat a) (VInt b) = pure (VFloat (a - fromIntegral b))-builtinSub (VFloat a) (VFloat b) = pure (VFloat (a - b))-builtinSub l r = throwEvalError ("builtins.sub: expected numbers, got " <> typeName l <> " and " <> typeName r)--builtinMul :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinMul (VInt a) (VInt b) = pure (VInt (a * b))-builtinMul (VInt a) (VFloat b) = pure (VFloat (fromIntegral a * b))-builtinMul (VFloat a) (VInt b) = pure (VFloat (a * fromIntegral b))-builtinMul (VFloat a) (VFloat b) = pure (VFloat (a * b))-builtinMul l r = throwEvalError ("builtins.mul: expected numbers, got " <> typeName l <> " and " <> typeName r)--builtinDiv :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinDiv _ (VInt 0) = throwEvalError "builtins.div: division by zero"-builtinDiv (VInt a) (VInt b)-  | a == minBound && b == -1 = pure (VInt minBound)-  | otherwise = pure (VInt (quot a b))-builtinDiv _ (VFloat 0) = throwEvalError "builtins.div: division by zero"-builtinDiv (VInt a) (VFloat b) = pure (VFloat (fromIntegral a / b))-builtinDiv (VFloat a) (VInt b) = pure (VFloat (a / fromIntegral b))-builtinDiv (VFloat a) (VFloat b) = pure (VFloat (a / b))-builtinDiv l r = throwEvalError ("builtins.div: expected numbers, got " <> typeName l <> " and " <> typeName r)--builtinMod :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinMod _ (VInt 0) = throwEvalError "builtins.mod: division by zero"-builtinMod (VInt a) (VInt b)-  | a == minBound && b == -1 = pure (VInt 0)-  | otherwise = pure (VInt (rem a b))-builtinMod l r = throwEvalError ("builtins.mod: expected two integers, got " <> typeName l <> " and " <> typeName r)--builtinMin :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinMin a b = do-  aIsLess <- nixCompare force a b-  pure (if aIsLess then a else b)--builtinMax :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinMax a b = do-  aIsLess <- nixCompare force a b-  pure (if aIsLess then b else a)--builtinBitAnd :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinBitAnd (VInt a) (VInt b) = pure (VInt (a .&. b))-builtinBitAnd _ _ = throwEvalError "builtins.bitAnd: expected two integers"--builtinBitOr :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinBitOr (VInt a) (VInt b) = pure (VInt (a .|. b))-builtinBitOr _ _ = throwEvalError "builtins.bitOr: expected two integers"--builtinBitXor :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinBitXor (VInt a) (VInt b) = pure (VInt (xor a b))-builtinBitXor _ _ = throwEvalError "builtins.bitXor: expected two integers"---- ------------------------------------------------------------------------------ Builtin implementations - attr set higher-order--- -----------------------------------------------------------------------------builtinMapAttrs :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinMapAttrs func (VAttrs attrs) =-  -- Each attr value is a deferred @f key val@, forced only on demand.-  -- Eagerly builds all thunks via attrSetMapWithKey - with C arena thunks-  -- (~16 bytes each), this is cheaper than the former MappedAttrs overhead-  -- and keeps all data off the GHC heap.-  -- Slot 0 = function, slot 1 = key, slot 2 = value.-  pure (VAttrs (attrSetMapWithKey deferAttr attrs))-  where-    deferAttr key valThunk =-      let (sp, sc) = buildCSlots [evaluated func, evaluated (mkStr key), valThunk]-          env = newMinimalEnv sp sc-       in mkSyntheticThunk env mapAttrsExpr-builtinMapAttrs _ other =-  throwEvalError ("builtins.mapAttrs: expected a set, got " <> typeName other)---- | Shared expression for 'builtinMapAttrs'.  Allocated once as a CAF.-mapAttrsExpr :: Expr-mapAttrsExpr = EApp (EApp (EResolvedVar 0 0) (EResolvedVar 0 1)) (EResolvedVar 0 2)-{-# NOINLINE mapAttrsExpr #-}--builtinFunctionArgs :: (MonadEval m) => NixValue -> m NixValue-builtinFunctionArgs (VLambda _ formals _) = pure (formalsToAttrs formals)-builtinFunctionArgs (VBuiltin _ _) = pure (VAttrs (attrSetFromMap Map.empty))--- Callable sets with __functionArgs metadata (from setFunctionArgs).-builtinFunctionArgs (VAttrs attrs)-  | Just faThunk <- attrSetLookup "__functionArgs" attrs = force faThunk-builtinFunctionArgs other =-  throwEvalError ("builtins.functionArgs: expected a function, got " <> typeName other)--formalsToAttrs :: EvalFormals -> NixValue-formalsToAttrs (EFName _) = VAttrs (attrSetFromMap Map.empty)-formalsToAttrs (EFSet formals _) = formalsListToAttrs formals-formalsToAttrs (EFNamedSet _ formals _) = formalsListToAttrs formals--formalsListToAttrs :: [EvalFormal] -> NixValue-formalsListToAttrs formals =-  VAttrs $-    attrSetFromMap $-      Map.fromList-        [(efName f, evaluated (VBool (isJust (efDefault f)))) | f <- formals]---- | @builtins.setFunctionArgs f args@ - wraps @f@ in a callable attrset--- with @__functor@ (so it remains callable) and @__functionArgs@ metadata.--- Used by nixpkgs @lib.mirrorFunctionArgs@ / @lib.makeOverridable@.------ @__functor@ is @self: f@ - a lambda that ignores @self@ and returns--- the original function.  The function is captured in the closure env.-builtinSetFunctionArgs :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinSetFunctionArgs func (VAttrs argSpec) =-  -- Closure env holds the function at slot 0.  The __functor lambda-  -- body is EResolvedVar 1 0: level 1 (past _self's slot), index 0.-  let (sp, sc) = buildCSlots [evaluated func]-      closureEnv = newMinimalEnv sp sc-      bodyBcIdx = unsafePerformIO (compileExpr (EResolvedVar 1 0))-      -- __functor = self: __fn  (ignores self, returns the original function)-      functorLambda = VLambda closureEnv (EFName "_self") bodyBcIdx-   in pure $-        VAttrs $-          attrSetFromMap $-            Map.fromList-              [ ("__functor", evaluated functorLambda),-                ("__functionArgs", evaluated (VAttrs argSpec))-              ]-builtinSetFunctionArgs func args =-  throwEvalError ("builtins.setFunctionArgs: expected a set as second argument, got " <> typeName args <> " (func: " <> typeName func <> ")")--builtinZipAttrsWith :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinZipAttrsWith func (VList cl) = do-  let thunks = map Thunk (clistThunks cl)-  attrSets <- mapM forceToAttrSet thunks-  let merged = mergeAllAttrs attrSets-      -- Lazy: each result is a deferred f(name)(values) thunk, not eagerly-      -- evaluated.  Critical for nixpkgs evalModules fixpoint - config is a-      -- self-referencing lazy attrset that must be COMPUTED (holding the lazy-      -- result) before any individual attribute thunks are forced.-      resultPairs = map (deferZip func) (Map.toList merged)-  pure (VAttrs (attrSetFromMap (Map.fromList resultPairs)))-  where-    forceToAttrSet thunk = do-      val <- force thunk-      case val of-        VAttrs attrs -> pure (attrSetToMap attrs)-        _ -> throwEvalError "builtins.zipAttrsWith: list element must be a set"-    mergeAllAttrs = foldl' (\acc m -> Map.unionWith (++) acc (Map.map (: []) m)) Map.empty-    -- Slot 0 = function, slot 1 = name, slot 2 = values list.-    deferZip fn (key, thunkList) =-      let valueList = VList (clistFromThunks (map thunkToCPtr thunkList))-          (slots, slotCount) = buildCSlots [evaluated fn, evaluated (mkStr key), evaluated valueList]-          env = newMinimalEnv slots slotCount-       in (key, mkSyntheticThunk env mapAttrsExpr)-builtinZipAttrsWith _ other =-  throwEvalError ("builtins.zipAttrsWith: expected a list, got " <> typeName other)---- ------------------------------------------------------------------------------ Builtin implementations - string manipulation--- -----------------------------------------------------------------------------builtinReplaceStrings ::-  (MonadEval m) => NixValue -> NixValue -> NixValue -> m NixValue-builtinReplaceStrings (VList fromCl) (VList toCl) (VStr input inputCtx) = do-  let fromThunks = map Thunk (clistThunks fromCl)-      toThunks = map Thunk (clistThunks toCl)-  froms <- mapM forceStr fromThunks-  toStrs <- mapM forceStr toThunks-  when (length froms /= length toStrs) $-    throwEvalError "builtins.replaceStrings: 'from' and 'to' must have the same length"-  let fromTexts = map fst froms-      toTexts = map fst toStrs-      -- Nix semantics: input context + 'to' string contexts (not 'from').-      -- Ideally only 'to' contexts for patterns that matched, but including-      -- all 'to' contexts is the common implementation.-      mergedCtx = inputCtx <> mconcat (map snd toStrs)-      pairs = zip fromTexts toTexts-  pure (VStr (replaceAll pairs input) mergedCtx)-  where-    forceStr thunk = do-      val <- force thunk-      case val of-        VStr s ctx -> pure (s, ctx)-        _ -> throwEvalError "builtins.replaceStrings: elements must be strings"-builtinReplaceStrings _ _ (VStr _ _) =-  throwEvalError "builtins.replaceStrings: first two arguments must be lists"-builtinReplaceStrings _ _ other =-  throwEvalError ("builtins.replaceStrings: expected a string, got " <> typeName other)---- | Replace all occurrences, O(n) via chunk list + T.concat.-replaceAll :: [(Text, Text)] -> Text -> Text-replaceAll pairs input = T.concat (go input)-  where-    go remaining-      | T.null remaining =-          -- At end of string, still check for empty-from match-          case findMatch pairs remaining of-            Just (replacement, _, _) -> [replacement]-            Nothing -> []-      | otherwise = case findMatch pairs remaining of-          Just (replacement, rest, matched) ->-            if T.null matched-              then case T.uncons remaining of-                -- empty-from: insert replacement then advance 1 char-                Just (ch, after) -> replacement : T.singleton ch : go after-                Nothing -> [replacement]-              else replacement : go rest-          Nothing -> case T.uncons remaining of-            Just (ch, after) -> T.singleton ch : go after-            Nothing -> []-    findMatch [] _ = Nothing-    findMatch ((from, to) : rest) txt-      | T.null from = Just (to, txt, from)-      | Just suffix <- T.stripPrefix from txt = Just (to, suffix, from)-      | otherwise = findMatch rest txt---- ------------------------------------------------------------------------------ Builtin implementations - regex (POSIX ERE via regex-tdfa)--- ------------------------------------------------------------------------------- ------------------------------------------------------------------------------ Regex compilation cache--- ------------------------------------------------------------------------------- | Global regex compilation cache.  Keyed by the raw pattern string--- (including anchoring for match).  Idempotent memoization via--- unsafePerformIO - same rationale as thunk memoization.-{-# NOINLINE regexCacheRef #-}-regexCacheRef :: IORef (Map Text RE.Regex)-regexCacheRef = unsafePerformIO (newIORef Map.empty)---- | Compile a regex, using the global cache to avoid recompilation.--- Returns Nothing for invalid patterns.  NOINLINE prevents GHC from--- inlining and floating the unsafePerformIO reads.-{-# NOINLINE cachedCompileRegex #-}-cachedCompileRegex :: Text -> Maybe RE.Regex-cachedCompileRegex pat =-  unsafePerformIO $ do-    cache <- atomicModifyIORef' regexCacheRef (\c -> (c, c))-    case Map.lookup pat cache of-      Just compiled -> pure (Just compiled)-      Nothing -> case RE.makeRegexM (T.unpack pat) :: Maybe RE.Regex of-        Nothing -> pure Nothing-        Just compiled -> do-          atomicModifyIORef' regexCacheRef (\c -> (Map.insert pat compiled c, ()))-          pure (Just compiled)---- ------------------------------------------------------------------------------ Regex builtins--- ------------------------------------------------------------------------------- | @builtins.match regex str@: match a POSIX ERE against a string.--- The regex is implicitly anchored (must match the entire string).--- Returns @null@ if no match, or a list of capture group strings--- (empty string for unmatched optional groups).-builtinMatch :: (MonadEval m) => NixValue -> NixValue -> m NixValue--- Pre-compiled path: regex was compiled at partial-application time.-builtinMatch (VCompiledRegex (CompiledRegex _ compiled)) (VStr str _) =-  matchWithCompiled compiled str--- Direct 2-arg call: use global compilation cache.-builtinMatch (VStr regex _) (VStr str _) =-  let anchored = "^" <> regex <> "$"-   in case cachedCompileRegex anchored of-        Nothing -> throwEvalError ("builtins.match: invalid regex: " <> regex)-        Just compiled -> matchWithCompiled compiled str-builtinMatch (VStr _ _) other =-  throwEvalError ("builtins.match: expected a string, got " <> typeName other)-builtinMatch (VCompiledRegex _) other =-  throwEvalError ("builtins.match: expected a string, got " <> typeName other)-builtinMatch other _ =-  throwEvalError ("builtins.match: expected a string (regex), got " <> typeName other)---- | Shared match logic for pre-compiled and freshly-compiled regex paths.-matchWithCompiled :: (MonadEval m) => RE.Regex -> Text -> m NixValue-matchWithCompiled compiled str =-  let matches = matchAllText compiled (T.unpack str)-   in case matches of-        [] -> pure VNull-        (match : _) ->-          -- match is an Array of (String, (offset, len)) pairs.-          -- Index 0 is the full match; indices 1.. are capture groups.-          let groups = Array.elems match-              -- Skip index 0 (full match) - return only capture groups.-              captureGroups = drop 1 groups-              -- A non-participating capture group has offset (-1); C++ Nix-              -- yields null for it, not the empty string.-              toThunk (s, (off, _)) =-                if off < 0 then evaluated VNull else evaluated (mkStr (T.pack s))-           in pure (VList (clistFromThunks (map (thunkToCPtr . toThunk) captureGroups)))---- | @builtins.split regex str@: split a string by a POSIX ERE.--- Returns an alternating list of non-matched strings and match-group lists.--- Example: @split "(x)" "axbxc"@ yields @["a" ["x"] "b" ["x"] "c"]@-builtinSplit :: (MonadEval m) => NixValue -> NixValue -> m NixValue--- Pre-compiled path: regex was compiled at partial-application time.-builtinSplit (VCompiledRegex (CompiledRegex _ compiled)) (VStr str _) =-  splitWithCompiled compiled str--- Direct 2-arg call: use global compilation cache.-builtinSplit (VStr regex _) (VStr str _) =-  case cachedCompileRegex regex of-    Nothing -> throwEvalError ("builtins.split: invalid regex: " <> regex)-    Just compiled -> splitWithCompiled compiled str-builtinSplit (VStr _ _) other =-  throwEvalError ("builtins.split: expected a string, got " <> typeName other)-builtinSplit (VCompiledRegex _) other =-  throwEvalError ("builtins.split: expected a string, got " <> typeName other)-builtinSplit other _ =-  throwEvalError ("builtins.split: expected a string (regex), got " <> typeName other)---- | Shared split logic for pre-compiled and freshly-compiled regex paths.-splitWithCompiled :: (MonadEval m) => RE.Regex -> Text -> m NixValue-splitWithCompiled compiled str =-  let allMatches = matchAllText compiled (T.unpack str)-      strText = T.unpack str-      result = buildSplitResult strText 0 allMatches-   in pure (VList (clistFromThunks (map thunkToCPtr result)))---- | Build the alternating list for builtins.split.-buildSplitResult :: String -> Int -> [Array.Array Int (String, (Int, Int))] -> [Thunk]-buildSplitResult remaining pos [] =-  -- No more matches - emit the rest of the string.-  [evaluated (mkStr (T.pack (drop pos remaining)))]-buildSplitResult remaining pos (match : rest) =-  let elems = Array.elems match-      (_, (matchStart, matchLen)) = case elems of-        (full : _) -> full-        [] -> ("", (pos, 0)) -- defensive: should not happen from matchAllText-        -- Text before this match-      before = T.pack (take (matchStart - pos) (drop pos remaining))-      -- Capture groups (indices 1..)-      groups = drop 1 (Array.elems match)-      -- A non-participating capture group has offset (-1) becomes null (as 'match').-      groupThunks =-        map (\(s, (off, _)) -> if off < 0 then evaluated VNull else evaluated (mkStr (T.pack s))) groups-      -- Continue after this match-      afterPos = matchStart + matchLen-   in evaluated (mkStr before)-        : evaluated (VList (clistFromThunks (map thunkToCPtr groupThunks)))-        : buildSplitResult remaining afterPos rest--builtinCompareVersions :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinCompareVersions (VStr a _) (VStr b _) =-  pure (VInt (compareVersionParts (splitVersionStr a) (splitVersionStr b)))-builtinCompareVersions _ _ = throwEvalError "builtins.compareVersions: expected two strings"---- | Convert 'Ordering' to the Nix compareVersions convention: -1, 0, 1.-ordToNix :: Ordering -> Int64-ordToNix LT = -1-ordToNix EQ = 0-ordToNix GT = 1--compareVersionParts :: [Text] -> [Text] -> Int64-compareVersionParts [] [] = 0--- When one version runs out, Nix pads the shorter side with an empty--- component and keeps comparing - so "1.0" > "1.0pre" (empty sorts AFTER--- "pre"), not "<" as a naive length comparison would give.-compareVersionParts [] (b : bs) =-  case compareComponent "" b of-    EQ -> compareVersionParts [] bs-    cmp -> ordToNix cmp-compareVersionParts (a : as) [] =-  case compareComponent a "" of-    EQ -> compareVersionParts as []-    cmp -> ordToNix cmp-compareVersionParts (a : as) (b : bs) =-  case compareComponent a b of-    EQ -> compareVersionParts as bs-    cmp -> ordToNix cmp--compareComponent :: Text -> Text -> Ordering-compareComponent a b-  | a == b = EQ-  | allDigits a && allDigits b = compare (readInt a) (readInt b)-  -- "pre" sorts before everything else (Nix pre-release convention)-  | a == "pre" = LT-  | b == "pre" = GT-  | a == "" = LT-  | b == "" = GT-  -- Alphabetic components sort before numeric in Nix-  | isAlphaComp a && allDigits b = LT-  | allDigits a && isAlphaComp b = GT-  | otherwise = compare a b-  where-    allDigits t = not (T.null t) && T.all isDigit t-    isAlphaComp t = case T.uncons t of-      Just (c, _) -> isAlpha c-      Nothing -> False-    readInt :: Text -> Int64-    readInt = T.foldl' (\acc c -> acc * 10 + fromIntegral (digitToInt c)) (0 :: Int64)--splitVersionStr :: Text -> [Text]-splitVersionStr t-  | T.null t = []-  | otherwise =-      let (component, rest) = spanComponent t-       in component : splitVersionAfterComponent rest--splitVersionAfterComponent :: Text -> [Text]-splitVersionAfterComponent t = case T.uncons t of-  Nothing -> []-  Just ('.', rest) -> splitVersionStr rest-  Just _ -> splitVersionStr t--spanComponent :: Text -> (Text, Text)-spanComponent t = case T.uncons t of-  Nothing -> ("", "")-  Just (c, rest)-    | isDigit c -> T.span isDigit t-    | isAlpha c -> T.span isAlpha t-    | otherwise -> (T.singleton c, rest)--builtinSplitVersion :: (MonadEval m) => NixValue -> m NixValue-builtinSplitVersion (VStr s _) =-  pure (VList (clistFromThunks (map (thunkToCPtr . evaluated . mkStr) (splitVersionComponents s))))-builtinSplitVersion other =-  throwEvalError ("builtins.splitVersion: expected a string, got " <> typeName other)--splitVersionComponents :: Text -> [Text]-splitVersionComponents t = case T.uncons t of-  Nothing -> []-  Just ('.', rest) -> splitVersionComponents rest-  Just (c, _)-    | isDigit c ->-        let (digits, rest) = T.span isDigit t-         in digits : splitVersionComponents rest-    | isAlpha c ->-        let (alpha, rest) = T.span isAlpha t-         in alpha : splitVersionComponents rest-    | otherwise ->-        -- Non-alphanumeric separator (e.g. '-', '_'): consume as single char-        let (_, rest) = T.splitAt 1 t-         in splitVersionComponents rest--builtinParseDrvName :: (MonadEval m) => NixValue -> m NixValue-builtinParseDrvName (VStr s _) =-  let (name, version) = parseName s-   in pure-        ( VAttrs-            ( attrSetFromMap $-                Map.fromList-                  [ ("name", evaluated (mkStr name)),-                    ("version", evaluated (mkStr version))-                  ]-            )-        )-builtinParseDrvName other =-  throwEvalError ("builtins.parseDrvName: expected a string, got " <> typeName other)--parseName :: Text -> (Text, Text)-parseName t =-  case findVersionDash t 0 of-    Nothing -> (t, "")-    Just idx -> (T.take idx t, T.drop (idx + 1) t)--findVersionDash :: Text -> Int -> Maybe Int-findVersionDash t idx = case T.uncons (T.drop idx t) of-  Nothing -> Nothing-  Just ('-', after)-    | Just (d, _) <- T.uncons after,-      isDigit d ->-        Just idx-  Just _ -> findVersionDash t (idx + 1)---- ------------------------------------------------------------------------------ Builtin implementations - serialization + hashing--- -----------------------------------------------------------------------------builtinToJSON :: (MonadEval m) => NixValue -> m NixValue-builtinToJSON val = do-  (json, ctx) <- valueToJSON val-  pure (VStr json ctx)--valueToJSON :: (MonadEval m) => NixValue -> m (Text, StringContext)-valueToJSON VNull = pure ("null", emptyContext)-valueToJSON (VBool True) = pure ("true", emptyContext)-valueToJSON (VBool False) = pure ("false", emptyContext)-valueToJSON (VInt n) = pure (T.pack (show n), emptyContext)-valueToJSON (VFloat f) = pure (formatNixFloat f, emptyContext)-valueToJSON (VStr s ctx) = pure (jsonEscapeString s, ctx)-valueToJSON (VList cl) = do-  let thunks = map Thunk (clistThunks cl)-  vals <- mapM force thunks-  results <- mapM valueToJSON vals-  let jsonVals = map fst results-      ctx = mconcat (map snd results)-  pure ("[" <> T.intercalate "," jsonVals <> "]", ctx)-valueToJSON (VAttrs attrs) =-  -- C++ Nix serializes an attrset via __toString first, then outPath, and only-  -- falls back to an object when neither is present.-  case (attrSetLookup "__toString" attrs, attrSetLookup "outPath" attrs) of-    (Nothing, Nothing) -> do-      let m = attrSetToMap attrs-          sortedKeys = Map.keys m-      results <- mapM (jsonPair m) sortedKeys-      let pairs = map fst results-          ctx = mconcat (map snd results)-      pure ("{" <> T.intercalate "," pairs <> "}", ctx)-    _ -> do-      (s, ctx) <- coerceToString True force applyValue (VAttrs attrs)-      pure (jsonEscapeString s, ctx)-  where-    jsonPair attrMap key = case Map.lookup key attrMap of-      Nothing -> pure ("", emptyContext)-      Just thunk -> do-        val <- force thunk-        (jsonVal, ctx) <- valueToJSON val-        pure (jsonEscapeString key <> ":" <> jsonVal, ctx)-valueToJSON (VPath p) = pure (jsonEscapeString p, emptyContext)-valueToJSON (VLambda {}) = throwEvalError "builtins.toJSON: cannot convert a function to JSON"-valueToJSON (VBuiltin _ _) = throwEvalError "builtins.toJSON: cannot convert a function to JSON"-valueToJSON (VDerivation _) = throwEvalError "builtins.toJSON: cannot convert a derivation to JSON"-valueToJSON (VCompiledRegex _) = throwEvalError "builtins.toJSON: cannot convert a function to JSON"--jsonEscapeString :: Text -> Text-jsonEscapeString s = "\"" <> T.concatMap escapeChar s <> "\""-  where-    escapeChar '"' = "\\\""-    escapeChar '\\' = "\\\\"-    escapeChar '\n' = "\\n"-    escapeChar '\r' = "\\r"-    escapeChar '\t' = "\\t"-    escapeChar c-      | ord c < 0x20 = "\\u" <> T.pack (padHex 4 (showHex' (ord c)))-      | otherwise = T.singleton c-    padHex n str = replicate (n - length str) '0' ++ str-    showHex' 0 = "0"-    showHex' num = go num ""-      where-        go 0 acc = acc-        go v acc =-          let (q, r) = quotRem v 16-           in go q (hexDigit r : acc)---- | Safe hex digit lookup (total for 0-15).-hexDigit :: Int -> Char-hexDigit n-  | n >= 0 && n <= 9 = chr (ord '0' + n)-  | n >= 10 && n <= 15 = chr (ord 'a' + n - 10)-  | otherwise = '?' -- unreachable for valid hex--builtinFromJSON :: (MonadEval m) => NixValue -> m NixValue-builtinFromJSON (VStr s _) = case parseJSON (T.strip s) of-  Just (val, rest)-    | T.null (T.strip rest) -> pure val-    | otherwise -> throwEvalError "builtins.fromJSON: trailing content after JSON value"-  Nothing -> throwEvalError "builtins.fromJSON: invalid JSON"-builtinFromJSON other =-  throwEvalError ("builtins.fromJSON: expected a string, got " <> typeName other)--parseJSON :: Text -> Maybe (NixValue, Text)-parseJSON t = case T.uncons (T.stripStart t) of-  Nothing -> Nothing-  Just ('n', rest)-    | Just suffix <- T.stripPrefix "ull" rest -> Just (VNull, suffix)-  Just ('t', rest)-    | Just suffix <- T.stripPrefix "rue" rest -> Just (VBool True, suffix)-  Just ('f', rest)-    | Just suffix <- T.stripPrefix "alse" rest -> Just (VBool False, suffix)-  Just ('"', _) -> parseJSONString (T.stripStart t)-  Just ('[', rest) -> parseJSONArray rest-  Just ('{', rest) -> parseJSONObject rest-  Just (c, _)-    | c == '-' || isDigit c -> parseJSONNumber (T.stripStart t)-  _ -> Nothing--parseJSONString :: Text -> Maybe (NixValue, Text)-parseJSONString t = case T.uncons t of-  Just ('"', rest) ->-    let (strVal, remaining) = parseJSONStringContent rest-     in Just (mkStr strVal, remaining)-  _ -> Nothing---- | Parse JSON string content, O(n) via chunk list + T.concat.-parseJSONStringContent :: Text -> (Text, Text)-parseJSONStringContent = go []-  where-    go !chunks t = case T.uncons t of-      Nothing -> (T.concat (reverse chunks), "")-      Just ('"', rest) -> (T.concat (reverse chunks), rest)-      Just ('\\', rest) -> case T.uncons rest of-        Just ('"', r) -> go ("\"" : chunks) r-        Just ('\\', r) -> go ("\\" : chunks) r-        Just ('/', r) -> go ("/" : chunks) r-        Just ('n', r) -> go ("\n" : chunks) r-        Just ('r', r) -> go ("\r" : chunks) r-        Just ('t', r) -> go ("\t" : chunks) r-        Just ('u', r) -> case parseHex4 r of-          Just (hi, r2)-            -- UTF-16 surrogate pair: high surrogate followed by \uXXXX low-            | hi >= 0xD800 && hi <= 0xDBFF ->-                case T.stripPrefix "\\u" r2 of-                  Just r3 -> case parseHex4 r3 of-                    Just (lo, r4)-                      | lo >= 0xDC00 && lo <= 0xDFFF ->-                          let combined = 0x10000 + (hi - 0xD800) * 0x400 + (lo - 0xDC00)-                           in go (T.singleton (chr combined) : chunks) r4-                    _ -> go (T.singleton (chr hi) : chunks) r2-                  Nothing -> go (T.singleton (chr hi) : chunks) r2-          Just (codepoint, r2) ->-            go (T.singleton (chr codepoint) : chunks) r2-          Nothing -> go ("u" : chunks) r-        _ -> (T.concat (reverse chunks), rest)-      Just (c, rest) -> go (T.singleton c : chunks) rest--parseHex4 :: Text -> Maybe (Int, Text)-parseHex4 t-  | T.length t >= 4 =-      let hex = T.take 4 t-       in if T.all isHexDigit hex-            then Just (readHex4 hex, T.drop 4 t)-            else Nothing-  | otherwise = Nothing--readHex4 :: Text -> Int-readHex4 = T.foldl' (\acc c -> acc * 16 + digitToInt c) 0--parseJSONNumber :: Text -> Maybe (NixValue, Text)-parseJSONNumber t =-  let (numStr, rest) = T.span (\c -> isDigit c || c == '.' || c == '-' || c == 'e' || c == 'E' || c == '+') t-   in if T.null numStr-        then Nothing-        else-          if T.any (\c -> c == '.' || c == 'e' || c == 'E') numStr-            then case reads (T.unpack numStr) :: [(Double, String)] of-              [(d, "")] -> Just (VFloat d, rest)-              _ -> Nothing-            else case reads (T.unpack numStr) :: [(Int64, String)] of-              [(n, "")] -> Just (VInt n, rest)-              _ -> Nothing--parseJSONArray :: Text -> Maybe (NixValue, Text)-parseJSONArray t = parseJSONArrayElements (T.stripStart t) []--parseJSONArrayElements :: Text -> [Thunk] -> Maybe (NixValue, Text)-parseJSONArrayElements t acc = case T.uncons (T.stripStart t) of-  Just (']', rest) -> Just (VList (clistFromThunks (map thunkToCPtr (reverse acc))), rest)-  _ -> case parseJSON t of-    Just (val, rest) ->-      let stripped = T.stripStart rest-       in case T.uncons stripped of-            Just (',', rest2) -> parseJSONArrayElements rest2 (evaluated val : acc)-            Just (']', rest2) -> Just (VList (clistFromThunks (map thunkToCPtr (reverse (evaluated val : acc)))), rest2)-            _ -> Nothing-    Nothing -> Nothing--parseJSONObject :: Text -> Maybe (NixValue, Text)-parseJSONObject t = parseJSONObjectEntries (T.stripStart t) Map.empty--parseJSONObjectEntries :: Text -> Map Text Thunk -> Maybe (NixValue, Text)-parseJSONObjectEntries t acc = case T.uncons (T.stripStart t) of-  Just ('}', rest) -> Just (VAttrs (attrSetFromMap acc), rest)-  _ -> case parseJSONString (T.stripStart t) of-    Just (VStr key _, rest) -> case T.uncons (T.stripStart rest) of-      Just (':', rest2) -> case parseJSON rest2 of-        Just (val, rest3) ->-          let stripped = T.stripStart rest3-              updated = Map.insert key (evaluated val) acc-           in case T.uncons stripped of-                Just (',', rest4) -> parseJSONObjectEntries rest4 updated-                Just ('}', rest4) -> Just (VAttrs (attrSetFromMap updated), rest4)-                _ -> Nothing-        Nothing -> Nothing-      _ -> Nothing-    _ -> Nothing--builtinHashString :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinHashString (VStr algo _) (VStr input _) =-  hashBytesWithAlgo "hashString" algo (TE.encodeUtf8 input)-builtinHashString (VStr _ _) other =-  throwEvalError ("builtins.hashString: expected a string, got " <> typeName other)-builtinHashString other _ =-  throwEvalError ("builtins.hashString: expected a string, got " <> typeName other)--digestToHex :: (BA.ByteArrayAccess a) => a -> Text-digestToHex = bytesToHexText . BA.convert---- ------------------------------------------------------------------------------ Builtin implementations - deep evaluation--- -----------------------------------------------------------------------------builtinDeepSeq :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinDeepSeq first second = do-  deepForce first-  pure second--deepForce :: (MonadEval m) => NixValue -> m ()-deepForce (VList cl) = mapM_ ((force >=> deepForce) . Thunk) (clistThunks cl)-deepForce (VAttrs attrs) = mapM_ (force >=> deepForce) (attrSetElems attrs)-deepForce _ = pure ()---- | @builtins.seq a b@ - evaluate @a@ to WHNF, then return @b@.-builtinSeq :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinSeq !_first = pure---- | @builtins.trace msg val@ - print @msg@ to stderr, return @val@.-builtinTrace :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinTrace msgVal result = do-  msg <- case msgVal of-    VStr s _ -> pure s-    other -> pure (showValueForTrace other)-  traceMessage ("trace: " <> msg)-  pure result---- | @builtins.warn msg val@ - print warning to stderr, return @val@.-builtinWarn :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinWarn msgVal result = do-  msg <- case msgVal of-    VStr s _ -> pure s-    other -> pure (showValueForTrace other)-  traceMessage ("warning: " <> msg)-  pure result---- | Pretty-print a value for trace/warn, matching C++ Nix's printValue.-showValueForTrace :: NixValue -> Text-showValueForTrace (VInt n) = T.pack (show n)-showValueForTrace (VFloat f) = formatNixFloat f-showValueForTrace (VBool True) = "true"-showValueForTrace (VBool False) = "false"-showValueForTrace VNull = "null"-showValueForTrace (VPath p) = p-showValueForTrace other = "<<" <> typeName other <> ">>"---- ------------------------------------------------------------------------------ Builtin implementations - graph traversal--- -----------------------------------------------------------------------------builtinGenericClosure :: (MonadEval m) => NixValue -> m NixValue-builtinGenericClosure (VAttrs attrs) = do-  startSetThunk <--    maybe (throwEvalError "builtins.genericClosure: missing 'startSet'") pure $-      attrSetLookup "startSet" attrs-  operatorThunk <--    maybe (throwEvalError "builtins.genericClosure: missing 'operator'") pure $-      attrSetLookup "operator" attrs-  startSetVal <- force startSetThunk-  operatorVal <- force operatorThunk-  case startSetVal of-    VList cl -> do-      let items = map Thunk (clistThunks cl)-      result <- closureLoop operatorVal (Seq.fromList items) [] []-      pure (VList (clistFromThunks (map (thunkToCPtr . evaluated) result)))-    _ -> throwEvalError "builtins.genericClosure: 'startSet' must be a list"-builtinGenericClosure other =-  throwEvalError ("builtins.genericClosure: expected a set, got " <> typeName other)---- | BFS loop for genericClosure.  Uses Data.Sequence for O(1) queue--- append (the old list-based version was O(n) per operator call).--- seenKeys is still a linear scan - Nix value equality is monadic so--- Set/HashMap is not directly applicable without specialising on key type.-closureLoop ::-  (MonadEval m) =>-  NixValue ->-  Seq Thunk ->-  [NixValue] ->-  [NixValue] ->-  m [NixValue]-closureLoop _ Empty _ acc = pure (reverse acc)-closureLoop operator (thunk :<| rest) seenKeys acc = do-  item <- force thunk-  key <- extractKey item-  alreadySeen <- keyInList key seenKeys-  if alreadySeen-    then closureLoop operator rest seenKeys acc-    else do-      newItems <- applyValue operator item-      case newItems of-        VList newCl ->-          closureLoop operator (rest <> Seq.fromList (map Thunk (clistThunks newCl))) (key : seenKeys) (item : acc)-        _ -> throwEvalError "builtins.genericClosure: operator must return a list"--extractKey :: (MonadEval m) => NixValue -> m NixValue-extractKey (VAttrs attrs) =-  case attrSetLookup "key" attrs of-    Just thunk -> force thunk-    Nothing -> throwEvalError "builtins.genericClosure: item missing 'key' attribute"-extractKey _ = throwEvalError "builtins.genericClosure: item must be a set with 'key'"--keyInList :: (MonadEval m) => NixValue -> [NixValue] -> m Bool-keyInList _ [] = pure False-keyInList key (seen : rest) = do-  eq <- nixEqual force key seen-  if eq then pure True else keyInList key rest---- ------------------------------------------------------------------------------ IO builtins (delegate to MonadEval methods)--- ------------------------------------------------------------------------------- | Coerce a value to a path 'Text'.  Accepts 'VPath' and 'VStr';--- throws a type error for anything else.-coerceToPath :: (MonadEval m) => Text -> NixValue -> m Text-coerceToPath _ (VPath p) = pure p-coerceToPath _ (VStr s _) = pure s-coerceToPath name other =-  throwEvalError ("builtins." <> name <> ": expected a path or string, got " <> typeName other)--builtinImport :: (MonadEval m) => NixValue -> m NixValue-builtinImport (VPath p) = importFile p-builtinImport (VStr s _) = importFile s-builtinImport other =-  throwEvalError ("import: expected a path or string, got " <> typeName other)--builtinReadFile :: (MonadEval m) => NixValue -> m NixValue-builtinReadFile val = do-  p <- coerceToPath "readFile" val-  mkStr <$> readFileText p--builtinPathExists :: (MonadEval m) => NixValue -> m NixValue-builtinPathExists val = do-  p <- coerceToPath "pathExists" val-  VBool <$> doesPathExist p--builtinReadDir :: (MonadEval m) => NixValue -> m NixValue-builtinReadDir val = do-  p <- coerceToPath "readDir" val-  entries <- listDirectory p-  pure (VAttrs (attrSetFromMap (Map.fromList [(name, evaluated (mkStr fileType)) | (name, fileType) <- entries])))---- ------------------------------------------------------------------------------ Builtin implementations - environment + paths--- -----------------------------------------------------------------------------builtinGetEnv :: (MonadEval m) => NixValue -> m NixValue-builtinGetEnv (VStr name _) = mkStr <$> getEnvVar name-builtinGetEnv other =-  throwEvalError ("builtins.getEnv: expected a string, got " <> typeName other)--builtinToPath :: (MonadEval m) => NixValue -> m NixValue-builtinToPath (VPath p) = pure (VPath p)-builtinToPath (VStr s _) = case T.uncons s of-  Nothing -> throwEvalError "builtins.toPath: empty path"-  Just ('/', _) -> pure (VPath s)-  Just _ -> throwEvalError ("builtins.toPath: path must be absolute, got " <> s)-builtinToPath other =-  throwEvalError ("builtins.toPath: expected a string or path, got " <> typeName other)---- ------------------------------------------------------------------------------ Builtin implementations - store path operations--- -----------------------------------------------------------------------------builtinPlaceholder :: (MonadEval m) => NixValue -> m NixValue-builtinPlaceholder (VStr outputName _) = pure (mkStr (hashPlaceholder outputName))-builtinPlaceholder other =-  throwEvalError ("builtins.placeholder: expected a string, got " <> typeName other)--builtinStorePath :: (MonadEval m) => NixValue -> m NixValue-builtinStorePath (VPath p) = validateStorePath p-builtinStorePath (VStr s _) = validateStorePath s-builtinStorePath other =-  throwEvalError ("builtins.storePath: expected a path or string, got " <> typeName other)--validateStorePath :: (MonadEval m) => Text -> m NixValue-validateStorePath p-  | storeDirPrefix `T.isPrefixOf` p,-    T.length p > T.length storeDirPrefix,-    let basename = T.drop (T.length storeDirPrefix) p,-    T.length basename >= 33,-    Just ('-', _) <- T.uncons (T.drop 32 basename) =-      pure (VPath p)-  | otherwise =-      throwEvalError ("builtins.storePath: not a valid store path: " <> p)---- ------------------------------------------------------------------------------ Builtin implementations - Nix search path--- -----------------------------------------------------------------------------builtinFindFile :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinFindFile (VList cl) (VStr name _) = do-  let searchPath = map Thunk (clistThunks cl)-  entries <- mapM forceSearchEntry searchPath-  findFirst entries name-builtinFindFile (VList _) other =-  throwEvalError ("builtins.findFile: expected a string, got " <> typeName other)-builtinFindFile other _ =-  throwEvalError ("builtins.findFile: expected a list, got " <> typeName other)---- | Extract {prefix, path} from a search path entry thunk.-forceSearchEntry :: (MonadEval m) => Thunk -> m (Text, Text)-forceSearchEntry thunk = do-  val <- force thunk-  case val of-    VAttrs attrs -> do-      prefixThunk <--        maybe (throwEvalError "builtins.findFile: entry missing 'prefix'") pure $-          attrSetLookup "prefix" attrs-      pathThunk <--        maybe (throwEvalError "builtins.findFile: entry missing 'path'") pure $-          attrSetLookup "path" attrs-      prefixVal <- force prefixThunk-      pathVal <- force pathThunk-      prefix <- case prefixVal of-        VStr s _ -> pure s-        _ -> throwEvalError "builtins.findFile: 'prefix' must be a string"-      path <- case pathVal of-        VStr s _ -> pure s-        VPath s -> pure s-        _ -> throwEvalError "builtins.findFile: 'path' must be a string or path"-      pure (prefix, path)-    _ -> throwEvalError "builtins.findFile: search path entry must be a set"---- | Iterate search path entries, checking for a match.-findFirst :: (MonadEval m) => [(Text, Text)] -> Text -> m NixValue-findFirst [] name =-  throwEvalError ("file '" <> name <> "' was not found in the Nix search path")-findFirst ((prefix, path) : rest) name-  | prefix == name || (not (T.null prefix) && (prefix <> "/") `T.isPrefixOf` name) =-      let suffix = if prefix == name then "" else T.drop (T.length prefix + 1) name-          candidate = if T.null suffix then path else path <> "/" <> suffix-       in do-            exists <- doesPathExist candidate-            if exists-              then pure (VPath candidate)-              else findFirst rest name-  | T.null prefix =-      let candidate = path <> "/" <> name-       in do-            exists <- doesPathExist candidate-            if exists-              then pure (VPath candidate)-              else findFirst rest name-  | otherwise = findFirst rest name---- ------------------------------------------------------------------------------ Builtin implementations - store file creation--- -----------------------------------------------------------------------------builtinToFile :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinToFile (VStr name _) (VStr contents _) = do-  storePath <- writeToStore name contents-  pure (VPath storePath)-builtinToFile (VStr _ _) other =-  throwEvalError ("builtins.toFile: expected a string, got " <> typeName other)-builtinToFile other _ =-  throwEvalError ("builtins.toFile: expected a string, got " <> typeName other)---- ------------------------------------------------------------------------------ Builtin implementations - scoped import--- -----------------------------------------------------------------------------builtinScopedImport :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinScopedImport (VAttrs attrs) pathVal = do-  p <- coerceToPath "scopedImport" pathVal-  let scope = Map.toList (attrSetToMap attrs)-  scopedImportFile scope p-builtinScopedImport other _ =-  throwEvalError ("builtins.scopedImport: expected a set, got " <> typeName other)---- ------------------------------------------------------------------------------ Builtin implementations - network fetchers--- -----------------------------------------------------------------------------builtinFetchurl :: (MonadEval m) => NixValue -> m NixValue-builtinFetchurl (VStr url _) = fetchUrlSimple url Nothing-builtinFetchurl (VAttrs attrs) = do-  url <- forceAttrStr "builtins.fetchurl" "url" attrs-  sha256 <- forceOptionalAttrStr attrs "sha256"-  fetchUrlSimple url sha256-builtinFetchurl other =-  throwEvalError ("builtins.fetchurl: expected a string or set, got " <> typeName other)--builtinFetchTarball :: (MonadEval m) => NixValue -> m NixValue-builtinFetchTarball (VStr url _) = fetchAndExtractTarball url-builtinFetchTarball (VAttrs attrs) = do-  url <- forceAttrStr "builtins.fetchTarball" "url" attrs-  fetchAndExtractTarball url-builtinFetchTarball other =-  throwEvalError ("builtins.fetchTarball: expected a string or set, got " <> typeName other)---- | Download a tarball, extract it, and return the path to the extracted--- directory.  Uses a content-hashed temp directory.  Downloads and extracts--- in a single shell pipeline to avoid binary-as-text encoding issues.-fetchAndExtractTarball :: (MonadEval m) => Text -> m NixValue-fetchAndExtractTarball url = do-  sysTmp <- getTempDir-  let urlHash = sha256Hex (TE.encodeUtf8 url)-      extractDir = sysTmp <> "/nova-nix-tarball-" <> urlHash-  -- Single pipeline: mkdir, download, extract with --strip-components=1-  -- The -- separator prevents argument injection from the URL.-  (code, _, errOut) <--    runProcess-      "sh"-      [ "-c",-        "mkdir -p \"$1\" && curl -sSfL -- \"$2\" | tar -xz -C \"$1\" --strip-components=1",-        "--",-        extractDir,-        url-      ]-      ""-  case code of-    0 -> pure (VPath extractDir)-    _ -> throwEvalError ("builtins.fetchTarball: " <> errOut)--builtinFetchGit :: (MonadEval m) => NixValue -> m NixValue-builtinFetchGit (VStr url _) = do-  sysTmp <- getTempDir-  let urlHash = sha256Hex (TE.encodeUtf8 url)-      cloneDir = sysTmp <> "/nova-nix-fetchgit-" <> urlHash-  (code, _, errOut) <- runProcess "git" ["clone", "--depth", "1", "--", url, cloneDir] ""-  case code of-    0 -> pure (VPath cloneDir)-    _ -> throwEvalError ("builtins.fetchGit: git clone failed: " <> errOut)-builtinFetchGit (VAttrs attrs) = do-  url <- forceAttrStr "builtins.fetchGit" "url" attrs-  builtinFetchGit (mkStr url)-builtinFetchGit other =-  throwEvalError ("builtins.fetchGit: expected a string or set, got " <> typeName other)---- | Resolve the system temp directory.  Checks @TMPDIR@ (Unix), then--- @TEMP@ (Windows), falls back to @\/tmp@.-getTempDir :: (MonadEval m) => m Text-getTempDir = do-  candidates <- mapM getEnvVar ["TMPDIR", "TEMP"]-  pure (fromMaybe "/tmp" (find (not . T.null) candidates))---- | Fetch a URL and optionally verify its hash.-fetchUrlSimple :: (MonadEval m) => Text -> Maybe Text -> m NixValue-fetchUrlSimple url _sha256 = do-  (code, stdout, stderr) <- runProcess "curl" ["-sSfL", "--", url] ""-  if code /= 0-    then throwEvalError ("fetch failed: " <> stderr)-    else do-      storePath <- writeToStore "fetchurl-result" stdout-      pure (VPath storePath)---- | Force a required string attribute from an attrset, using full Nix string--- coercion (VStr, VPath, VInt, VBool, VAttrs via __toString/outPath).-forceAttrStr :: (MonadEval m) => Text -> Text -> AttrSet -> m Text-forceAttrStr builtin key attrs =-  case attrSetLookup key attrs of-    Nothing -> throwEvalError (builtin <> ": missing required attribute '" <> key <> "'")-    Just thunk -> do-      val <- force thunk-      result <- catchEvalError (coerceToString True force applyValue val)-      case result of-        Right (s, _ctx) -> pure s-        Left _ -> throwEvalError (builtin <> ": '" <> key <> "' must be a string")---- | Force an optional string attribute via full Nix coercion.-forceOptionalAttrStr :: (MonadEval m) => AttrSet -> Text -> m (Maybe Text)-forceOptionalAttrStr attrs key =-  case attrSetLookup key attrs of-    Nothing -> pure Nothing-    Just thunk -> do-      val <- force thunk-      result <- catchEvalError (coerceToString True force applyValue val)-      case result of-        Right (s, _ctx) -> pure (Just s)-        Left _ -> pure Nothing---- ------------------------------------------------------------------------------ Builtin implementations - derivation construction--- ------------------------------------------------------------------------------- | Eager derivation computation - @builtins.derivationStrict@.  Forces all--- input attrs into env vars, content-hashes, and returns the full derivation--- attrset (drvPath, outPath, per-output, _derivation).  Called LAZILY by the--- @derivation@ wrapper ('builtinDerivationLazy'), so forcing a derivation to--- WHNF never forces this - matching C++ Nix's derivationStrict/derivation split.-builtinDerivationStrict :: (MonadEval m) => NixValue -> m NixValue-builtinDerivationStrict (VAttrs attrs) = do-  -- Extract required attributes-  drvName <- forceAttrStr "derivation" "name" attrs-  system <- forceAttrStr ("derivation \"" <> drvName <> "\"") "system" attrs-  builder <- forceAttrStr ("derivation \"" <> drvName <> "\"") "builder" attrs--  -- Extract optional outputs (default ["out"])-  outputNames <- case attrSetLookup "outputs" attrs of-    Nothing -> pure ["out"]-    Just thunk -> do-      val <- force thunk-      case val of-        VList cl -> mapM (forceToText . Thunk) (clistThunks cl)-        _ -> throwEvalError "derivation: 'outputs' must be a list of strings"--  -- Extract optional args (default []).  Path literals in args (e.g. stdenv's-  -- ./default-builder.sh) are copied into the store; their source paths flow-  -- into inputSrcs via the returned context.-  (builderArgs, argsContext) <- case attrSetLookup "args" attrs of-    Nothing -> pure ([], mempty)-    Just thunk -> do-      val <- force thunk-      case val of-        VList cl -> do-          parts <- mapM (\t -> force (Thunk t) >>= coerceToStoreString) (clistThunks cl)-          pure (map fst parts, mconcat (map snd parts))-        _ -> throwEvalError "derivation: 'args' must be a list of strings"--  -- Materialize once, reuse for both env collection and result merge-  let materialized = attrSetToMap attrs--  -- Collect string-coercible attrs into the build env, EXCLUDING "args"-  -- (C++ Nix puts args in the Derive() args field, never the env).  The-  -- per-output env vars ($out, ...) are added below.  Carries merged context.-  (drvEnvPairs, envContext) <- collectDrvEnvWithContext (Map.delete "__ignoreNulls" (Map.delete "args" materialized))--  let fullContext = envContext <> argsContext-      inputDrvs = extractInputDrvs fullContext-      inputSrcs = extractInputSrcs fullContext-      platform = textToPlatform system-      baseEnv = Map.fromList drvEnvPairs-      drvRefs = inputSrcs ++ Map.keys inputDrvs-      drvFileName = drvName <> ".drv"-      -- Build a Derivation sharing this call's inputs/platform/builder/args.-      mkDrv outs env =-        Derivation-          { drvOutputs = outs,-            drvInputDrvs = inputDrvs,-            drvInputSrcs = inputSrcs,-            drvPlatform = platform,-            drvBuilder = builder,-            drvArgs = builderArgs,-            drvEnv = env-          }-      -- Output carrying only its name; the path is masked at render time.-      maskedOutput name = DerivationOutput name (StorePath "" "") "" ""-      -- Resolve an input derivation's modulo hash (hex) from the cache,-      -- populated bottom-up by earlier 'builtinDerivationStrict' calls.-      resolveInputModulo (sp, outs) = do-        let inputPathText = storePathToText defaultStoreDir sp-        cached <- lookupDrvHash inputPathText-        case cached of-          Just hex -> pure (hex, outs)-          Nothing -> do-            -- Eval is bottom-up, so inputs evaluated this session are always-            -- cached by the time a dependent hashes.  A miss means an input-            -- referenced but not evaluated here (a pure-eval synthetic context,-            -- or a pre-built store drv): fall back to its store hash so-            -- evaluation still produces a value, and warn for visibility.-            traceMessage-              ("derivation: input modulo hash not cached, using store hash for " <> inputPathText)-            pure (spHash sp, outs)--  -- Fixed-output derivations (fetchurl etc.) are content-addressed and hash-  -- via the @fixed:out:@ scheme; input-addressed derivations recurse through-  -- the modulo hashes of their inputs.-  mFixed <- detectFixedOutput attrs--  (drvPathText, drvSP, outPaths, completeDrv) <- case mFixed of-    Just (foAlgo, foMode, foDigest) -> do-      let foPath = makeFixedOutputPath drvName foAlgo foMode foDigest-          foPathText = storePathToText defaultStoreDir foPath-          algoField = (if foMode == "recursive" then "r:" else "") <> foAlgo-          foHashHex = bytesToHexText foDigest-          foModulo =-            sha256Digest-              (TE.encodeUtf8 ("fixed:out:" <> algoField <> ":" <> foHashHex <> ":" <> foPathText))-          contents =-            mkDrv-              [DerivationOutput "out" foPath algoField foHashHex]-              (Map.insert "out" foPathText baseEnv)-          drvSp = makeTextPath drvFileName (sha256Digest (TE.encodeUtf8 (toATerm contents))) drvRefs-          drvText = storePathToText defaultStoreDir drvSp-      cacheDrvHash drvText (bytesToHexText foModulo)-      pure (drvText, drvSp, [("out", foPathText)], contents)-    Nothing -> do-      inputSubst <- mapM resolveInputModulo (Map.toList inputDrvs)-      let maskedEnv = foldr (`Map.insert` "") baseEnv outputNames-          maskedDrv = mkDrv (map maskedOutput outputNames) maskedEnv-          -- Masked modulo hash yields this derivation's own output paths.-          moduloMasked = sha256Digest (TE.encodeUtf8 (toATermForHash True (Just inputSubst) maskedDrv))-          outStorePaths = [(n, makeOutputPath n moduloMasked drvName) | n <- outputNames]-          outPathTexts = [(n, storePathToText defaultStoreDir sp) | (n, sp) <- outStorePaths]-          realEnv = foldr (\(n, t) e -> Map.insert n t e) baseEnv outPathTexts-          contents = mkDrv [DerivationOutput n sp "" "" | (n, sp) <- outStorePaths] realEnv-          -- Unmasked modulo hash (real outputs, inputs substituted) cached for-          -- when this derivation is itself an input to another.-          moduloUnmasked = sha256Digest (TE.encodeUtf8 (toATermForHash False (Just inputSubst) contents))-          drvSp = makeTextPath drvFileName (sha256Digest (TE.encodeUtf8 (toATerm contents))) drvRefs-          drvText = storePathToText defaultStoreDir drvSp-      cacheDrvHash drvText (bytesToHexText moduloUnmasked)-      pure (drvText, drvSp, outPathTexts, contents)--  -- Record this derivation's full .drv ATerm (the exact bytes whose hash is its-  -- store path) so the build driver can materialize the entire input-.drv-  -- closure before building.  Bottom-up eval guarantees every transitive input-  -- is recorded by the time a dependent is.-  recordDrvAterm drvPathText (toATerm completeDrv)--  let mainOutPath = case outPaths of-        ((_, p) : _) -> p-        [] -> ""-      -- The default output is the FIRST in @outputs@ (matching C++ Nix, which-      -- returns @(head outputsList).value@) - not necessarily @out@.-      mainOutName = case outPaths of-        ((n, _) : _) -> n-        [] -> "out"--  -- Context for output paths: each output carries SCDrvOutput context-  -- Context for drvPath: carries SCAllOutputs context-  let drvPathCtx = StringContext (Set.singleton (SCAllOutputs drvSP))-      outPathCtx outName = StringContext (Set.singleton (SCDrvOutput drvSP outName))--  -- Build per-output attrsets matching Nix: drv.out = { outPath, drvPath, type }-  let mkOutputAttrs outName outP =-        let outCtx = outPathCtx outName-            outputAttrMap =-              Map.fromList-                [ ("outPath", evaluated (VStr outP outCtx)),-                  ("drvPath", evaluated (VStr drvPathText drvPathCtx)),-                  ("type", evaluated (mkStr "derivation"))-                ]-         in evaluated (VAttrs (attrSetFromMap outputAttrMap))--  -- Build result attrset: original attrs + drvPath, outPath, type, per-output attrs-  let baseAttrs =-        Map.fromList $-          [ ("type", evaluated (mkStr "derivation")),-            ("drvPath", evaluated (VStr drvPathText drvPathCtx)),-            ("outPath", evaluated (VStr mainOutPath (outPathCtx mainOutName))),-            ("name", evaluated (mkStr drvName)),-            ("system", evaluated (mkStr system)),-            ("builder", evaluated (mkStr builder)),-            ("_derivation", evaluated (VDerivation completeDrv))-          ]-            ++ [(outName, mkOutputAttrs outName outP) | (outName, outP) <- outPaths]-      -- Merge original attrs underneath so computed attrs take priority-      resultAttrs = Map.union baseAttrs materialized--  pure (VAttrs (attrSetFromMap resultAttrs))-builtinDerivationStrict other =-  throwEvalError ("derivation: expected a set, got " <> typeName other)---- | Detect a fixed-output derivation.  Returns @Just (algo, mode, rawDigest)@--- when @outputHash@ is present and non-empty (fetchurl, fetchgit, ...), else--- 'Nothing' for an ordinary input-addressed derivation.  @mode@ is--- @\"flat\"@ or @\"recursive\"@; @algo@ is e.g. @\"sha256\"@.-detectFixedOutput :: (MonadEval m) => AttrSet -> m (Maybe (Text, Text, BS.ByteString))-detectFixedOutput attrs =-  case attrSetLookup "outputHash" attrs of-    Nothing -> pure Nothing-    Just thunk -> do-      val <- force thunk-      case val of-        VStr ohash _-          | not (T.null ohash) -> do-              ohAlgo <- optDrvStrAttr "outputHashAlgo" attrs-              ohMode <- optDrvStrAttr "outputHashMode" attrs-              (algo, digest) <- normalizeFixedHash ohash ohAlgo-              let mode = if ohMode == "recursive" then "recursive" else "flat"-              pure (Just (algo, mode, digest))-        _ -> pure Nothing---- | Read an optional string attribute, defaulting to @\"\"@ when absent or--- not a string.-optDrvStrAttr :: (MonadEval m) => Text -> AttrSet -> m Text-optDrvStrAttr key attrs =-  case attrSetLookup key attrs of-    Nothing -> pure ""-    Just thunk -> do-      val <- force thunk-      case val of-        VStr s _ -> pure s-        _ -> pure ""---- | Decode a fixed-output hash (SRI @algo-base64@, @algo:hash@, or a bare--- hash plus a separate algorithm) to its algorithm name and raw bytes.-normalizeFixedHash :: (MonadEval m) => Text -> Text -> m (Text, BS.ByteString)-normalizeFixedHash ohash ohAlgo-  | Just (algo, b64) <- parseSRI ohash = do-      bytes <- decodeBase64E "derivation" b64-      pure (algo, bytes)-  | Just (algo, rest) <- parseAlgoPrefix ohash = decodeWithAlgo algo rest-  | not (T.null ohAlgo) = decodeWithAlgo ohAlgo ohash-  | otherwise =-      throwEvalError ("derivation: cannot determine outputHash algorithm for " <> ohash)---- | Lazy @derivation@ wrapper - mirrors C++ Nix's @corepkgs/derivation.nix@.--- Returns a WHNF attrset whose @drvPath@/@outPath@/output-path/@_derivation@--- attrs are LAZY thunks that defer to 'builtinDerivationStrict'.  Forcing a--- derivation to WHNF therefore does NOT force its input/env closure - which is--- essential for nixpkgs, where merely referencing a derivation (e.g.--- @drv != null@, @assert (libxcrypt != null)@) must not build its whole closure.------ The lazy thunks are built with the same synthetic-select pattern used by--- @inherit (from)@: a single shared @strict@ thunk (so the eager computation--- runs at most once) selected from via fresh minimal envs.-builtinDerivationLazy :: (MonadEval m) => NixValue -> m NixValue-builtinDerivationLazy (VAttrs attrs) = do-  -- Output names are cheap (matches @drvAttrs @ { outputs ? [ "out" ], ... }@).-  outputNames <- case attrSetLookup "outputs" attrs of-    Nothing -> pure ["out"]-    Just thunk -> do-      val <- force thunk-      case val of-        VList cl -> mapM (forceToText . Thunk) (clistThunks cl)-        _ -> throwEvalError "derivation: 'outputs' must be a list of strings"-  -- One shared thunk computing @derivationStrict attrs@, forced only when an-  -- output path / drvPath is actually read.-  let drvAttrsThunk = evaluated (VAttrs attrs)-      strictBuiltinThunk = evaluated (VBuiltin "derivationStrict" [])-      strictThunk =-        let (sp, sc) = buildCSlots [drvAttrsThunk, strictBuiltinThunk]-            envDS = newMinimalEnv sp sc-         in mkSyntheticThunk envDS (EApp (EResolvedVar 0 1) (EResolvedVar 0 0))-      selectStrict field =-        let (sp, sc) = buildCSlots [strictThunk]-            envF = newMinimalEnv sp sc-         in mkSyntheticThunk envF (ESelect (EResolvedVar 0 0) [StaticKey field] Nothing)-  -- WHNF spine: input attrs (unforced) overlaid with the lazy computed attrs.-  let computedAttrs =-        Map.fromList $-          [ ("type", evaluated (mkStr "derivation")),-            ("drvPath", selectStrict "drvPath"),-            ("outPath", selectStrict "outPath"),-            ("_derivation", selectStrict "_derivation")-          ]-            ++ [(outName, selectStrict outName) | outName <- outputNames]-      resultAttrs = Map.union computedAttrs (attrSetToMap attrs)-  pure (VAttrs (attrSetFromMap resultAttrs))-builtinDerivationLazy other =-  throwEvalError ("derivation: expected a set, got " <> typeName other)---- | Force a thunk to a Text string via full Nix coercion.-forceToText :: (MonadEval m) => Thunk -> m Text-forceToText thunk = do-  val <- force thunk-  (s, _ctx) <- coerceToString True force applyValue val-  pure s---- | Collect all string-coercible attributes for the derivation environment,--- along with the merged string context from all collected values.--- Uses 'coerceToStringPermissive' for full Nix coercion including--- __toString, outPath, and list-to-space-separated-string.-collectDrvEnvWithContext :: (MonadEval m) => Map Text Thunk -> m ([(Text, Text)], StringContext)-collectDrvEnvWithContext attrs = do-  let pairs = Map.toList attrs-  results <- mapM tryCoerce pairs-  let envPairs = catMaybes [fmap (\(k, v, _) -> (k, v)) r | r <- results]-      mergedCtx = mconcat [ctx | Just (_, _, ctx) <- results]-  pure (envPairs, mergedCtx)-  where-    tryCoerce (key, thunk) = do-      val <- force thunk-      case val of-        VNull -> pure Nothing-        _ -> do-          result <- catchEvalError (coerceToStoreString val)-          case result of-            Right (s, ctx) -> pure (Just (key, s, ctx))-            Left _ -> pure Nothing---- ------------------------------------------------------------------------------ Builtin implementations - hashFile, readFileType--- ------------------------------------------------------------------------------- | @builtins.hashFile algo path@ - hash raw bytes of a file on disk.--- Returns base-16 hex string, matching @builtins.hashString@ output format.-builtinHashFile :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinHashFile (VStr algo _) (VPath path) = do-  bytes <- readFileBytes path-  hashBytesWithAlgo "hashFile" algo bytes-builtinHashFile (VStr algo _) (VStr path _) = do-  bytes <- readFileBytes path-  hashBytesWithAlgo "hashFile" algo bytes-builtinHashFile (VStr _ _) other =-  throwEvalError ("builtins.hashFile: expected a path, got " <> typeName other)-builtinHashFile other _ =-  throwEvalError ("builtins.hashFile: expected a string, got " <> typeName other)---- | Shared hash dispatch for raw 'ByteString' input.-hashBytesWithAlgo :: (MonadEval m) => Text -> Text -> BS.ByteString -> m NixValue-hashBytesWithAlgo ctx algo bytes = case algo of-  "sha256" -> pure (mkStr (digestToHex (CH.hash bytes :: CH.Digest CH.SHA256)))-  "sha512" -> pure (mkStr (digestToHex (CH.hash bytes :: CH.Digest CH.SHA512)))-  "sha1" -> pure (mkStr (digestToHex (CH.hash bytes :: CH.Digest CH.SHA1)))-  "md5" -> pure (mkStr (digestToHex (CH.hash bytes :: CH.Digest CH.MD5)))-  _ -> throwEvalError ("builtins." <> ctx <> ": unknown hash algorithm '" <> algo <> "'")---- | @builtins.readFileType path@ - classify a filesystem entry.--- Returns @"regular"@, @"directory"@, @"symlink"@, or @"unknown"@.-builtinReadFileType :: (MonadEval m) => NixValue -> m NixValue-builtinReadFileType (VPath path) = mkStr <$> getFileType path-builtinReadFileType (VStr path _) = mkStr <$> getFileType path-builtinReadFileType other =-  throwEvalError ("builtins.readFileType: expected a path, got " <> typeName other)---- ------------------------------------------------------------------------------ Builtin implementations - convertHash--- ------------------------------------------------------------------------------- | @builtins.convertHash { hash, hashAlgo?, toHashFormat }@ - convert--- between hash representations.  Supports base16, nix32, base64, and sri.-builtinConvertHash :: (MonadEval m) => NixValue -> m NixValue-builtinConvertHash (VAttrs attrs) = do-  hashVal <- requireStrAttr "convertHash" "hash" attrs-  toFmt <- requireStrAttr "convertHash" "toHashFormat" attrs-  -- Detect input format and decode to raw bytes + algo-  (algo, rawBytes) <- decodeHashInput attrs hashVal-  -- Encode to target format-  case toFmt of-    "base16" -> pure (mkStr (bytesToHexText rawBytes))-    "nix32" -> pure (mkStr (Nix32.encode rawBytes))-    "base32" -> pure (mkStr (Nix32.encode rawBytes)) -- deprecated alias-    "base64" -> pure (mkStr (bytesToBase64 rawBytes))-    "sri" -> pure (mkStr (algo <> "-" <> bytesToBase64 rawBytes))-    _ -> throwEvalError ("builtins.convertHash: unknown toHashFormat '" <> toFmt <> "'")-builtinConvertHash other =-  throwEvalError ("builtins.convertHash: expected a set, got " <> typeName other)---- | Extract algo + raw bytes from the hash input, handling SRI, prefixed, and plain formats.-decodeHashInput :: (MonadEval m) => AttrSet -> Text -> m (Text, BS.ByteString)-decodeHashInput attrs hashStr-  -- SRI format: algo-base64-  | Just (algo, b64) <- parseSRI hashStr = do-      bytes <- decodeBase64E "convertHash" b64-      pure (algo, bytes)-  -- Prefixed format: algo:hex or algo:nix32-  | Just (algo, rest) <- parseAlgoPrefix hashStr =-      decodeWithAlgo algo rest-  -- Plain hash - need hashAlgo attribute-  | otherwise = do-      algo <- requireStrAttr "convertHash" "hashAlgo" attrs-      decodeWithAlgo algo hashStr---- | Try to decode as hex, then nix32, then base64.-decodeWithAlgo :: (MonadEval m) => Text -> Text -> m (Text, BS.ByteString)-decodeWithAlgo algo s-  | Just bytes <- hexToBytes s = pure (algo, bytes)-  | Right bytes <- Nix32.decode s = pure (algo, bytes)-  | Right bytes <- decodeBase64Pure s = pure (algo, bytes)-  | otherwise = throwEvalError ("builtins.convertHash: cannot decode hash '" <> s <> "'")---- | Parse @sha256-base64...@ SRI format.-parseSRI :: Text -> Maybe (Text, Text)-parseSRI t = case T.breakOn "-" t of-  (algo, rest)-    | not (T.null rest) && algo `elem` ["sha256", "sha512", "sha1", "md5"] ->-        Just (algo, T.drop 1 rest)-  _ -> Nothing---- | Parse @sha256:value@ prefixed format.-parseAlgoPrefix :: Text -> Maybe (Text, Text)-parseAlgoPrefix t = case T.breakOn ":" t of-  (algo, rest)-    | not (T.null rest) && algo `elem` ["sha256", "sha512", "sha1", "md5"] ->-        Just (algo, T.drop 1 rest)-  _ -> Nothing---- | Require a string attribute from an attrset.-requireStrAttr :: (MonadEval m) => Text -> Text -> AttrSet -> m Text-requireStrAttr ctx key attrs = case attrSetLookup key attrs of-  Just thunk -> do-    val <- force thunk-    case val of-      VStr s _ -> pure s-      _ -> throwEvalError ("builtins." <> ctx <> ": " <> key <> " must be a string")-  Nothing -> throwEvalError ("builtins." <> ctx <> ": missing required attribute '" <> key <> "'")---- ------------------------------------------------------------------------------ Base64 encode/decode - delegates to nova-cache (base64-bytestring under the hood)--- ------------------------------------------------------------------------------- | Encode bytes to base64.-bytesToBase64 :: BS.ByteString -> Text-bytesToBase64 = B64.encode---- | Decode base64 text to bytes (pure).-decodeBase64Pure :: Text -> Either Text BS.ByteString-decodeBase64Pure t =-  -- Strip whitespace and any existing padding, then re-pad to a multiple of-  -- 4.  base64-bytestring's 'decode' requires correct padding, so SRI hashes-  -- (correctly-padded standard base64, e.g. @sha256-...NQ=@) would otherwise be-  -- rejected once their trailing @=@ was removed.-  let stripped = T.filter (\c -> c /= '\n' && c /= '\r' && c /= '=') t-      padLen = (4 - (T.length stripped `mod` 4)) `mod` 4-      padded = stripped <> T.replicate padLen "="-   in case B64.decode padded of-        Right bytes -> Right bytes-        Left _ -> Left "invalid base64"---- | Decode base64 with error context for builtins.-decodeBase64E :: (MonadEval m) => Text -> Text -> m BS.ByteString-decodeBase64E ctx t = case decodeBase64Pure t of-  Right bytes -> pure bytes-  Left _ -> throwEvalError ("builtins." <> ctx <> ": invalid base64 encoding")---- ------------------------------------------------------------------------------ Builtin implementations - fromTOML--- ------------------------------------------------------------------------------- | @builtins.fromTOML str@ - parse a TOML document to a Nix value.--- Hand-rolled parser covering the TOML v1.0 subset used by nixpkgs:--- bare/quoted keys, dotted keys, basic/literal strings (multiline),--- integers (dec/hex/oct/bin), floats (inc. inf/nan), booleans,--- inline tables, arrays, array-of-tables, and standard tables.--- Datetimes are represented as strings (matching real Nix).-builtinFromTOML :: (MonadEval m) => NixValue -> m NixValue-builtinFromTOML (VStr s _) = case parseTOML s of-  Right val -> pure val-  Left err -> throwEvalError ("builtins.fromTOML: " <> err)-builtinFromTOML other =-  throwEvalError ("builtins.fromTOML: expected a string, got " <> typeName other)---- | Intermediate TOML value before conversion to NixValue.-data TOMLValue-  = TOMLStr !Text-  | TOMLInt !Int64-  | TOMLFloat !Double-  | TOMLBool !Bool-  | TOMLArray ![TOMLValue]-  | TOMLTable !(Map Text TOMLValue)-  deriving (Show)---- | Parse a TOML document into a NixValue.-parseTOML :: Text -> Either Text NixValue-parseTOML input = do-  table <- parseTOMLDoc (T.lines input)-  pure (tomlToNix (TOMLTable table))---- | Convert a TOMLValue to NixValue.-tomlToNix :: TOMLValue -> NixValue-tomlToNix val = case val of-  TOMLStr s -> mkStr s-  TOMLInt n -> VInt n-  TOMLFloat d -> VFloat d-  TOMLBool b -> VBool b-  TOMLArray xs -> VList (clistFromThunks (map (thunkToCPtr . evaluated . tomlToNix) xs))-  TOMLTable m -> VAttrs (attrSetFromMap (Map.map (evaluated . tomlToNix) m))---- | Parse all lines of a TOML document into a table.-parseTOMLDoc :: [Text] -> Either Text (Map Text TOMLValue)-parseTOMLDoc lns = go lns [] Map.empty-  where-    go [] _ root = Right root-    go (line : rest) currentPath root-      | T.null stripped || T.isPrefixOf "#" stripped =-          -- Empty line or comment-          go rest currentPath root-      | T.isPrefixOf "[[" stripped && T.isSuffixOf "]]" stripped =-          -- Array of tables: [[key]]-          let keyStr = T.strip (T.drop 2 (T.dropEnd 2 stripped))-              keys = parseDottedKey keyStr-           in go rest keys (insertArrayTable keys root)-      | T.isPrefixOf "[" stripped && T.isSuffixOf "]" stripped =-          -- Standard table: [key]-          let keyStr = T.strip (T.drop 1 (T.dropEnd 1 stripped))-              keys = parseDottedKey keyStr-           in go rest keys (ensureTable keys root)-      | otherwise =-          -- Key = value pair-          case parseKVLine stripped of-            Right (keys, val) ->-              go rest currentPath (insertNested (currentPath ++ keys) val root)-            Left err -> Left err-      where-        stripped = T.strip line---- | Parse a key = value line.-parseKVLine :: Text -> Either Text ([Text], TOMLValue)-parseKVLine line =-  let (keyPart, afterEq) = splitAtEquals line-   in case T.uncons afterEq of-        Nothing -> Left ("expected '=' in: " <> line)-        Just _ -> do-          let val = T.strip afterEq-          parsed <- parseTOMLValue val-          Right (parseDottedKey (T.strip keyPart), parsed)---- | Split a line at the first unquoted '=' sign.-splitAtEquals :: Text -> (Text, Text)-splitAtEquals = go T.empty-  where-    go acc t = case T.uncons t of-      Nothing -> (acc, T.empty)-      Just ('=', rest) -> (acc, rest)-      Just ('"', rest) ->-        let (quoted, after) = T.break (== '"') rest-         in case T.uncons after of-              Just ('"', r) -> go (acc <> "\"" <> quoted <> "\"") r-              _ -> go (acc <> "\"" <> quoted) after-      Just ('\'', rest) ->-        let (quoted, after) = T.break (== '\'') rest-         in case T.uncons after of-              Just ('\'', r) -> go (acc <> "'" <> quoted <> "'") r-              _ -> go (acc <> "'" <> quoted) after-      Just (c, rest) -> go (T.snoc acc c) rest---- | Parse dotted key like @foo.bar."baz qux"@ into @["foo", "bar", "baz qux"]@.-parseDottedKey :: Text -> [Text]-parseDottedKey t-  | T.null t = []-  | otherwise = case T.uncons t of-      Just ('"', rest) ->-        let (key, after) = T.break (== '"') rest-         in key : parseDottedKey (T.drop 1 (T.stripStart (dropDot after)))-      Just ('\'', rest) ->-        let (key, after) = T.break (== '\'') rest-         in key : parseDottedKey (T.drop 1 (T.stripStart (dropDot after)))-      _ ->-        let (key, after) = T.break (\c -> c == '.' || c == '"') t-         in T.strip key : case T.uncons after of-              Nothing -> []-              Just _ -> parseDottedKey (T.drop 1 (T.stripStart after))-  where-    dropDot txt = case T.uncons txt of-      Just ('.', rest) -> rest-      _ -> txt---- | Parse a TOML value (right side of '=').-parseTOMLValue :: Text -> Either Text TOMLValue-parseTOMLValue t =-  let stripped = T.strip t-      -- Strip inline comments (not inside strings)-      cleaned = stripInlineComment stripped-   in case T.uncons cleaned of-        Nothing -> Left "empty value"-        Just ('"', _)-          | T.isPrefixOf "\"\"\"" cleaned -> parseMultilineBasicStr (T.drop 3 cleaned)-          | otherwise -> parseBasicStr (T.drop 1 cleaned)-        Just ('\'', _)-          | T.isPrefixOf "'''" cleaned -> parseMultilineLiteralStr (T.drop 3 cleaned)-          | otherwise -> parseLiteralStr (T.drop 1 cleaned)-        Just ('{', _) -> parseInlineTable cleaned-        Just ('[', _) -> parseInlineArray cleaned-        Just ('t', _)-          | T.isPrefixOf "true" cleaned -> Right (TOMLBool True)-        Just ('f', _)-          | T.isPrefixOf "false" cleaned -> Right (TOMLBool False)-        Just ('i', _)-          | T.isPrefixOf "inf" cleaned -> Right (TOMLFloat (1 / 0))-        Just ('+', rest)-          | T.isPrefixOf "inf" rest -> Right (TOMLFloat (1 / 0))-          | T.isPrefixOf "nan" rest -> Right (TOMLFloat (0 / 0))-        Just ('-', rest)-          | T.isPrefixOf "inf" rest -> Right (TOMLFloat (negate (1 / 0)))-          | T.isPrefixOf "nan" rest -> Right (TOMLFloat (0 / 0))-        Just ('n', _)-          | T.isPrefixOf "nan" cleaned -> Right (TOMLFloat (0 / 0))-        _ -> parseTOMLNumberOrDatetime cleaned---- | Strip inline comment from a value (not inside quotes).-stripInlineComment :: Text -> Text-stripInlineComment = go (0 :: Int)-  where-    go _ t | T.null t = T.empty-    go depth t = case T.uncons t of-      Nothing -> T.empty-      Just ('#', _) | depth == (0 :: Int) -> T.empty-      Just ('"', rest)-        | depth == 0 ->-            let (str, after) = T.break (== '"') rest-             in T.cons '"' (str <> T.take 1 after <> go depth (T.drop 1 after))-      Just ('[', rest) -> T.cons '[' (go (depth + 1) rest)-      Just ('{', rest) -> T.cons '{' (go (depth + 1) rest)-      Just (']', rest) -> T.cons ']' (go (max 0 (depth - 1)) rest)-      Just ('}', rest) -> T.cons '}' (go (max 0 (depth - 1)) rest)-      Just (c, rest) -> T.cons c (go depth rest)---- | Parse a basic (double-quoted) TOML string.--- O(n) via chunk list + T.concat instead of O(n^2) T.snoc.-parseBasicStr :: Text -> Either Text TOMLValue-parseBasicStr = go []-  where-    go !chunks t = case T.uncons t of-      Nothing -> Left "unterminated basic string"-      Just ('"', _) -> Right (TOMLStr (T.concat (reverse chunks)))-      Just ('\\', rest) -> case T.uncons rest of-        Just ('n', r) -> go ("\n" : chunks) r-        Just ('t', r) -> go ("\t" : chunks) r-        Just ('r', r) -> go ("\r" : chunks) r-        Just ('\\', r) -> go ("\\" : chunks) r-        Just ('"', r) -> go ("\"" : chunks) r-        Just ('b', r) -> go ("\b" : chunks) r-        Just ('f', r) -> go ("\f" : chunks) r-        Just ('u', r) -> case parseHex4 r of-          Just (cp, r2) -> go (T.singleton (chr cp) : chunks) r2-          Nothing -> Left "invalid \\u escape"-        _ -> Left "invalid escape sequence"-      Just (c, rest) -> go (T.singleton c : chunks) rest---- | Parse a literal (single-quoted) TOML string.-parseLiteralStr :: Text -> Either Text TOMLValue-parseLiteralStr t =-  let (content, rest) = T.break (== '\'') t-   in case T.uncons rest of-        Just ('\'', _) -> Right (TOMLStr content)-        _ -> Left "unterminated literal string"---- | Parse a multiline basic string.-parseMultilineBasicStr :: Text -> Either Text TOMLValue-parseMultilineBasicStr t =-  case T.breakOn "\"\"\"" t of-    (content, rest)-      | T.isPrefixOf "\"\"\"" rest ->-          Right (TOMLStr (T.replace "\\\n" "" (stripLeadingNewline content)))-      | otherwise -> Left ("unterminated multiline basic string, remaining: " <> T.take 20 rest)---- | Parse a multiline literal string.-parseMultilineLiteralStr :: Text -> Either Text TOMLValue-parseMultilineLiteralStr t =-  case T.breakOn "'''" t of-    (content, rest)-      | T.isPrefixOf "'''" rest -> Right (TOMLStr (stripLeadingNewline content))-      | otherwise -> Left "unterminated multiline literal string"---- | Strip a leading newline (TOML spec: first newline after opening quotes is trimmed).-stripLeadingNewline :: Text -> Text-stripLeadingNewline t = case T.uncons t of-  Just ('\n', rest) -> rest-  Just ('\r', rest) -> case T.uncons rest of-    Just ('\n', r) -> r-    _ -> rest-  _ -> t---- | Parse a TOML number or datetime.-parseTOMLNumberOrDatetime :: Text -> Either Text TOMLValue-parseTOMLNumberOrDatetime s-  -- Hex, octal, binary integers-  | T.isPrefixOf "0x" s || T.isPrefixOf "0X" s = parseHexInt (T.drop 2 s)-  | T.isPrefixOf "0o" s || T.isPrefixOf "0O" s = parseOctInt (T.drop 2 s)-  | T.isPrefixOf "0b" s || T.isPrefixOf "0B" s = parseBinInt (T.drop 2 s)-  -- Contains date separators, so treat as a datetime string-  | T.any (== 'T') s && T.any (== '-') s = Right (TOMLStr s)-  | T.count "-" s >= 2 && T.any isDigit s = Right (TOMLStr s)-  | T.any (== ':') s && T.any isDigit s = Right (TOMLStr s)-  -- Float (has dot or exponent)-  | T.any (== '.') s || T.any (\c -> c == 'e' || c == 'E') s = parseFloat s-  -- Plain integer-  | otherwise = parseInt s---- | Parse a plain decimal integer, ignoring underscores.-parseInt :: Text -> Either Text TOMLValue-parseInt t =-  let cleaned = T.filter (/= '_') t-      (sign, digits) = case T.uncons cleaned of-        Just ('+', rest) -> (1, rest)-        Just ('-', rest) -> (-1, rest)-        _ -> (1, cleaned)-   in case readDecimal digits of-        Just n -> Right (TOMLInt (sign * n))-        Nothing -> Left ("invalid integer: " <> t)--parseHexInt :: Text -> Either Text TOMLValue-parseHexInt t =-  let cleaned = T.filter (/= '_') t-   in case readHexT cleaned of-        Just n -> Right (TOMLInt n)-        Nothing -> Left ("invalid hex integer: " <> t)--parseOctInt :: Text -> Either Text TOMLValue-parseOctInt t =-  let cleaned = T.filter (/= '_') t-   in case readOctT cleaned of-        Just n -> Right (TOMLInt n)-        Nothing -> Left ("invalid octal integer: " <> t)--parseBinInt :: Text -> Either Text TOMLValue-parseBinInt t =-  let cleaned = T.filter (/= '_') t-   in case readBinT cleaned of-        Just n -> Right (TOMLInt n)-        Nothing -> Left ("invalid binary integer: " <> t)--parseFloat :: Text -> Either Text TOMLValue-parseFloat t =-  let cleaned = T.filter (/= '_') t-   in case readDouble cleaned of-        Just d -> Right (TOMLFloat d)-        Nothing -> Left ("invalid float: " <> t)---- | Read a decimal integer from Text.-readDecimal :: Text -> Maybe Int64-readDecimal t-  | T.null t = Nothing-  | T.all isDigit t = Just (T.foldl' (\acc c -> acc * 10 + fromIntegral (digitToInt c)) 0 t)-  | otherwise = Nothing--readHexT :: Text -> Maybe Int64-readHexT t-  | T.null t = Nothing-  | T.all isHexDigit t = Just (T.foldl' (\acc c -> acc * 16 + fromIntegral (digitToInt c)) 0 t)-  | otherwise = Nothing--readOctT :: Text -> Maybe Int64-readOctT t-  | T.null t = Nothing-  | T.all isOctDigit t =-      Just (T.foldl' (\acc c -> acc * 8 + fromIntegral (digitToInt c)) 0 t)-  | otherwise = Nothing--readBinT :: Text -> Maybe Int64-readBinT t-  | T.null t = Nothing-  | T.all (\c -> c == '0' || c == '1') t =-      Just (T.foldl' (\acc c -> acc * 2 + fromIntegral (digitToInt c)) 0 t)-  | otherwise = Nothing--readDouble :: Text -> Maybe Double-readDouble t = case reads (T.unpack t) of-  [(d, "")] -> Just d-  _ -> Nothing---- | Parse an inline table: @{ key = val, ... }@.-parseInlineTable :: Text -> Either Text TOMLValue-parseInlineTable t = case T.uncons t of-  Just ('{', rest) ->-    let inner = T.strip (T.dropWhileEnd (== '}') (T.strip rest))-     in if T.null inner-          then Right (TOMLTable Map.empty)-          else do-            pairs <- mapM parseInlineKV (splitCommas inner)-            Right (TOMLTable (Map.fromList (concatMap flattenPair pairs)))-  _ -> Left "expected '{'"-  where-    flattenPair (keys, val) = case keys of-      [] -> []-      [k] -> [(k, val)]-      (k : ks) -> [(k, nestKeys ks val)]-    nestKeys [] v = v-    nestKeys (k : ks) v = TOMLTable (Map.singleton k (nestKeys ks v))---- | Parse an inline array: @[ val, ... ]@.-parseInlineArray :: Text -> Either Text TOMLValue-parseInlineArray t = case T.uncons t of-  Just ('[', rest) ->-    let inner = T.strip (T.dropWhileEnd (== ']') (T.strip rest))-     in if T.null inner-          then Right (TOMLArray [])-          else do-            vals <- mapM (parseTOMLValue . T.strip) (splitCommas inner)-            Right (TOMLArray vals)-  _ -> Left "expected '['"---- | Parse a single key=value pair in an inline table.-parseInlineKV :: Text -> Either Text ([Text], TOMLValue)-parseInlineKV t =-  let (keyPart, afterEq) = splitAtEquals (T.strip t)-   in do-        val <- parseTOMLValue (T.strip afterEq)-        Right (parseDottedKey (T.strip keyPart), val)---- | Split on commas not inside brackets or braces.--- O(n) via chunk list + T.concat instead of O(n^2) T.snoc.-splitCommas :: Text -> [Text]-splitCommas = go (0 :: Int) []-  where-    finalize chunks =-      let t = T.concat (reverse chunks)-       in [t | not (T.null (T.strip t))]-    go _ !chunks t | T.null t = finalize chunks-    go depth !chunks t = case T.uncons t of-      Nothing -> finalize chunks-      Just (',', rest) | depth == 0 -> T.concat (reverse chunks) : go 0 [] rest-      Just ('[', rest) -> go (depth + 1) ("[" : chunks) rest-      Just ('{', rest) -> go (depth + 1) ("{" : chunks) rest-      Just (']', rest) -> go (max 0 (depth - 1)) ("]" : chunks) rest-      Just ('}', rest) -> go (max 0 (depth - 1)) ("}" : chunks) rest-      Just ('"', rest) ->-        let (str, after) = T.break (== '"') rest-            consumed = "\"" <> str <> T.take 1 after-         in go depth (consumed : chunks) (T.drop 1 after)-      Just (c, rest) -> go depth (T.singleton c : chunks) rest---- | Insert a value at a nested key path into a table.-insertNested :: [Text] -> TOMLValue -> Map Text TOMLValue -> Map Text TOMLValue-insertNested [] _ m = m-insertNested [k] v m = Map.insert k v m-insertNested (k : ks) v m =-  let sub = case Map.lookup k m of-        Just (TOMLTable inner) -> inner-        _ -> Map.empty-   in Map.insert k (TOMLTable (insertNested ks v sub)) m---- | Ensure a table path exists (for @[table]@ headers).-ensureTable :: [Text] -> Map Text TOMLValue -> Map Text TOMLValue-ensureTable [] m = m-ensureTable [k] m = case Map.lookup k m of-  Just (TOMLTable _) -> m-  Nothing -> Map.insert k (TOMLTable Map.empty) m-  _ -> m-ensureTable (k : ks) m =-  let sub = case Map.lookup k m of-        Just (TOMLTable inner) -> inner-        _ -> Map.empty-   in Map.insert k (TOMLTable (ensureTable ks sub)) m---- | Insert an entry into an array-of-tables (@[[table]]@).-insertArrayTable :: [Text] -> Map Text TOMLValue -> Map Text TOMLValue-insertArrayTable [] m = m-insertArrayTable [k] m = case Map.lookup k m of-  Just (TOMLArray xs) -> Map.insert k (TOMLArray (xs ++ [TOMLTable Map.empty])) m-  Nothing -> Map.insert k (TOMLArray [TOMLTable Map.empty]) m-  _ -> Map.insert k (TOMLArray [TOMLTable Map.empty]) m-insertArrayTable (k : ks) m =-  let sub = case Map.lookup k m of-        Just (TOMLTable inner) -> inner-        Just (TOMLArray xs) ->-          -- Descend into the last element of the array-          case reverse xs of-            (TOMLTable inner : _) -> inner-            _ -> Map.empty-        _ -> Map.empty-      updated = insertArrayTable ks sub-   in case Map.lookup k m of-        Just (TOMLArray xs) ->-          case reverse xs of-            (TOMLTable _ : prev) ->-              Map.insert k (TOMLArray (reverse prev ++ [TOMLTable updated])) m-            _ -> Map.insert k (TOMLTable updated) m-        _ -> Map.insert k (TOMLTable updated) m---- ------------------------------------------------------------------------------ Builtin implementations - toXML--- ------------------------------------------------------------------------------- | @builtins.toXML val@ - convert a Nix value to its XML representation.--- Matches the format defined by the Nix manual: strings, ints, floats,--- bools, nulls, lists, and attrsets map to their XML counterparts.-builtinToXML :: (MonadEval m) => NixValue -> m NixValue-builtinToXML val = do-  xml <- valueToXML 0 val-  pure (mkStr ("<?xml version='1.0' encoding='utf-8'?>\n<expr>\n" <> xml <> "</expr>\n"))--valueToXML :: (MonadEval m) => Int -> NixValue -> m Text-valueToXML depth val = case val of-  VStr s _ ->-    pure (indent depth <> "<string value=" <> xmlQuote s <> " />\n")-  VInt n ->-    pure (indent depth <> "<int value=\"" <> T.pack (show n) <> "\" />\n")-  VFloat d ->-    pure (indent depth <> "<float value=\"" <> T.pack (show d) <> "\" />\n")-  VBool True ->-    pure (indent depth <> "<bool value=\"true\" />\n")-  VBool False ->-    pure (indent depth <> "<bool value=\"false\" />\n")-  VNull ->-    pure (indent depth <> "<null />\n")-  VPath p ->-    pure (indent depth <> "<path value=" <> xmlQuote p <> " />\n")-  VList cl -> do-    let thunks = map Thunk (clistThunks cl)-    items <- mapM (force >=> valueToXML (depth + 1)) thunks-    pure (indent depth <> "<list>\n" <> T.concat items <> indent depth <> "</list>\n")-  VAttrs attrs -> do-    let pairs = attrSetToAscList attrs-    items <- mapM (attrToXML (depth + 1)) pairs-    pure (indent depth <> "<attrs>\n" <> T.concat items <> indent depth <> "</attrs>\n")-  VLambda {} ->-    pure (indent depth <> "<function />\n")-  VBuiltin _ _ ->-    pure (indent depth <> "<function />\n")-  VDerivation _ ->-    pure (indent depth <> "<derivation />\n")-  VCompiledRegex _ ->-    pure (indent depth <> "<function />\n")-  where-    attrToXML d (name, thunk) = do-      v <- force thunk-      inner <- valueToXML d v-      pure (indent d <> "<attr name=" <> xmlQuote name <> ">\n" <> inner <> indent d <> "</attr>\n")--indent :: Int -> Text-indent n = T.replicate (n * 2) " "--xmlQuote :: Text -> Text-xmlQuote s = "\"" <> T.concatMap escapeChar s <> "\""-  where-    escapeChar '<' = "&lt;"-    escapeChar '>' = "&gt;"-    escapeChar '&' = "&amp;"-    escapeChar '"' = "&quot;"-    escapeChar c = T.singleton c---- ------------------------------------------------------------------------------ Builtin implementations - builtins.path--- ------------------------------------------------------------------------------- | @builtins.path { path; name?; filter?; sha256?; recursive?; }@------ Copy a path to the store and return the store path as a string with--- context.  @name@ defaults to the basename of @path@.  @filter@ is--- accepted but not yet applied (copies everything).-builtinPath :: (MonadEval m) => NixValue -> m NixValue-builtinPath (VAttrs attrs) = do-  pathStr <- forceAttrStr "builtins.path" "path" attrs-  nameOverride <- forceOptionalAttrStr attrs "name"-  let name = fromMaybe (extractBaseName pathStr) nameOverride-  storePathText <- copyPathToStore pathStr name-  -- Parse the store path to construct proper SCPlain context-  case parseStorePath defaultStoreDir storePathText of-    Just sp -> pure (VStr storePathText (plainContext sp))-    Nothing -> pure (VStr storePathText emptyContext)-builtinPath other =-  throwEvalError ("builtins.path: expected an attribute set, got " <> typeName other)---- | Extract the last path component from a path string.-extractBaseName :: Text -> Text-extractBaseName path =-  let stripped = T.dropWhileEnd (\c -> c == '/' || c == '\\') path-   in case T.breakOnEnd "/" stripped of-        ("", _) -> case T.breakOnEnd "\\" stripped of-          ("", _) -> stripped-          (_, name) -> name-        (_, name) -> name---- ------------------------------------------------------------------------------ Builtin implementations - filterSource--- ------------------------------------------------------------------------------- | @builtins.filterSource filter path@ - copy a path to the store,--- filtering entries via a predicate.  The filter function receives--- @(path, type)@ where type is @"regular"@, @"directory"@, @"symlink"@,--- or @"unknown"@.-builtinFilterSource :: (MonadEval m) => NixValue -> NixValue -> m NixValue-builtinFilterSource _filterFn (VPath _path) =-  throwEvalError "builtins.filterSource: not yet implemented (requires store integration)"-builtinFilterSource _filterFn (VStr _path _) =-  throwEvalError "builtins.filterSource: not yet implemented (requires store integration)"-builtinFilterSource _ other =-  throwEvalError ("builtins.filterSource: expected a path, got " <> typeName other)+{-# LANGUAGE TupleSections #-}++-- | Nix expression evaluator.+--+-- Nix evaluation is LAZY.  Attribute set members and list elements+-- are stored as thunks and only forced when their value is demanded.+-- Function arguments are likewise thunked - @(x: 1) (throw "boom")@+-- returns @1@ because @x@ is never referenced.+--+-- The evaluator maintains an environment ('Env') that maps variable+-- names to thunks.  @let@, @with@, function application, and+-- recursive attribute sets all extend the environment.+module Nix.Eval+  ( -- * Values (re-exported from Types)+    NixValue (..),+    CompiledRegex (..),+    Thunk (..),++    -- * Attribute sets (re-exported from Types)+    AttrSet (..),+    CAttrSet,+    attrSetFromMap,+    attrSetLookup,+    attrSetKeys,+    attrSetToMap,+    attrSetToAscList,+    attrSetMember,+    attrSetElems,+    attrSetNull,+    attrSetRemoveKeys,+    attrSetSize,++    -- * String context (re-exported from Types)+    StringContextElement (..),+    StringContext (..),+    emptyContext,+    mkStr,++    -- * Environment (re-exported from Types)+    Env (..),+    emptyEnv,++    -- * Evaluation monad (re-exported from Types)+    MonadEval (..),+    PureEval,+    runPureEval,++    -- * Evaluation+    eval,+    evalBytecode,+    force,++    -- * Helpers (for Builtins)+    typeName,+    evaluated,+    readThunkValue,++    -- * Fetcher transport validation (pure, exported for tests)+    checkGitUrl,+    checkGitRef,+    checkGitRev,++    -- * Remembered fetches (pure, exported for tests)+    FetchCache (..),+    fetchCacheKey,+    encodeFetchCache,+    decodeFetchCache,++    -- * Builtin registry+    BuiltinDef (..),+    builtinRegistry,+    builtinNames,++    -- * Platform+    currentSystemStr,+  )+where++import Control.Monad (foldM, forM_, unless, void, when, (>=>))+import qualified Crypto.Hash as CH+import qualified Data.Array as Array+import Data.Bits (complement, xor, (.&.), (.|.))+import qualified Data.ByteArray as BA+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BC+import Data.Char (chr, digitToInt, isAsciiLower, isAsciiUpper, isDigit, isHexDigit, isOctDigit, ord)+import Data.IORef (IORef, atomicModifyIORef', newIORef)+import Data.Int (Int64)+import Data.List (find, partition, sort)+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe)+import Data.Sequence (Seq (..))+import qualified Data.Sequence as Seq+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.Text.Read as TR+import Data.Time.Clock.POSIX (posixSecondsToUTCTime)+import Data.Time.Format (defaultTimeLocale, formatTime)+import Data.Word (Word32, Word64, Word8)+import Foreign.Ptr (Ptr, castPtr, nullPtr, ptrToWordPtr, wordPtrToPtr)+import Foreign.Storable (peekElemOff, pokeElemOff)+import Nix.Derivation (Derivation (..), DerivationOutput (..), textToPlatform, toATerm, toATermForHash)+import Nix.Eval.CBytecode (cbcArg1, cbcArg2, cbcArg3, cbcCountedPayload, cbcData, cbcFlags, cbcOpcode, cbcShortArg, pattern OpApp, pattern OpAssert, pattern OpAttrs, pattern OpBinary, pattern OpHasAttr, pattern OpIf, pattern OpIndStr, pattern OpLambda, pattern OpLet, pattern OpList, pattern OpLitBool, pattern OpLitFloat, pattern OpLitInt, pattern OpLitNull, pattern OpLitPath, pattern OpLitUri, pattern OpPathStr, pattern OpResolvedVar, pattern OpSearchPath, pattern OpSelect, pattern OpStr, pattern OpUnary, pattern OpVar, pattern OpWith, pattern OpWithVar)+import Nix.Eval.CEnv (cenvPushWith)+import Nix.Eval.CList (CList (..), clistGet)+import Nix.Eval.CThunk (CThunkPtr)+import Nix.Eval.CanonPath (canonBaseName, canonDirName, canonPathValue)+import Nix.Eval.Compile (BcAttrKey (..), BcBinding (..), compileExpr, decodeBcBindings, decodeBcCaptureInfo, decodeBcFormals, reassembleDouble, reassembleInt64)+import Nix.Eval.Context (extractAllOutputRefs, extractInputDrvs, extractInputSrcs, plainContext)+import Nix.Eval.Operator (checkedAdd, checkedMul, checkedSub, evalBinary, evalUnary, nixCompare, nixEqual)+import Nix.Eval.StringInterp (coerceToString, formatJsonFloat, formatNixFloat, formatXmlFloat, stripIndentedChunks)+import Nix.Eval.Symbol (Symbol (..), symbolBytes, symbolText)+import Nix.Eval.Types+  ( AttrSet (..),+    CAttrSet,+    CompiledRegex (..),+    Env (..),+    EvalFormal (..),+    EvalFormals (..),+    MonadEval (..),+    NixValue (..),+    PureEval,+    StringContext (..),+    StringContextElement (..),+    Thunk (..),+    allocCSlots,+    attrSetElems,+    attrSetFromMap,+    attrSetKeys,+    attrSetLookup,+    attrSetMapWithKey,+    attrSetMember,+    attrSetNull,+    attrSetRemoveKeys,+    attrSetSize,+    attrSetToAscList,+    attrSetToMap,+    buildCAttrSetKeys,+    buildCSlots,+    bytesToTextLossy,+    cheapThunkBc,+    checkedCPtr,+    clistFromThunks,+    clistLen,+    clistThunks,+    emptyContext,+    emptyEnv,+    envFromSlots,+    envLookup,+    envLookupResolved,+    envWithScopesRaw,+    evaluated,+    fillCAttrSetValues,+    fillCSlots,+    mkStr,+    mkStrBytes,+    mkSyntheticThunk,+    mkThunk,+    mkThunkBc,+    newCEnv,+    newMinimalEnv,+    readThunkValue,+    runPureEval,+    storePathOrThrow,+    thunkToCPtr,+    typeName,+    withScopesForCapture,+  )+import Nix.Expr.Types+  ( AttrKey (..),+    BinaryOp (..),+    CaptureInfo (..),+    Expr (..),+    NixAtom (..),+    UnaryOp (..),+  )+import Nix.Hash (base64HashLen, bytesToHexText, hashAlgoBytes, hashPlaceholder, hexHashLen, hexToBytes, makeFixedOutputPath, makeOutputPath, makeTextPath, nix32HashLen, sha256Digest, sha256Hex)+import Nix.Store.Path (StorePath (spName), StorePathNameError (..), checkStorePathName, defaultStoreDir, defaultStoreDirText, parseStorePath, parseStorePathBaseName, storePathNameErrorText, storePathNameReasonText, storePathToText)+import Nix.Store.Path.Internal (maskedOutputPath)+import qualified NovaCache.Base32 as Nix32+import qualified NovaCache.Base64 as B64+import qualified NovaCache.NAR as NAR+import System.IO.Unsafe (unsafePerformIO)+import qualified System.Info+import Text.Regex.TDFA (matchAllText)+import qualified Text.Regex.TDFA as RE+import Text.Regex.TDFA.ByteString ()++-- | Evaluate a Nix expression in an environment.+-- Compiles the expression to bytecode and dispatches to 'evalBytecode'.+eval :: (MonadEval m) => Env -> Expr -> m NixValue+eval env expr =+  let bcIdx = unsafePerformIO (compileExpr expr)+   in evalBytecode env bcIdx++-- | Force a thunk to a value.+--+-- Delegates to 'forceThunk' which is a 'MonadEval' method - this+-- allows IO evaluators to implement memoization (caching the result+-- after the first force) while pure evaluators simply re-evaluate.+force :: (MonadEval m) => Thunk -> m NixValue+force = forceThunk evalBytecode++-- | Strict UTF-8 decode at a Text-typed boundary (attr names, filesystem+-- paths, algorithm names, ...).  A Nix string is a byte string; where the+-- implementation needs 'Text', invalid UTF-8 is a clean eval error - never+-- a lossy replacement, which would smuggle a DIFFERENT string through an+-- identity-bearing boundary.+decodedText :: (MonadEval m) => Text -> BS.ByteString -> m Text+decodedText what bytes = case TE.decodeUtf8' bytes of+  Right t -> pure t+  Left _ -> throwEvalError (what <> ": invalid UTF-8 in string")++-- | Evaluate a bytecode instruction by index.+-- This is the primary evaluator - reads opcodes from the C bytecode+-- store and dispatches.  All recursive evaluation goes through this+-- function, never through 'eval' directly.+evalBytecode :: (MonadEval m) => Env -> Word32 -> m NixValue+evalBytecode env bcIdx =+  let opcode = unsafePerformIO (cbcOpcode bcIdx)+   in case opcode of+        OpLitInt ->+          let lo = unsafePerformIO (cbcArg1 bcIdx)+              hi = unsafePerformIO (cbcArg2 bcIdx)+           in pure (VInt (reassembleInt64 lo hi))+        OpLitFloat ->+          let lo = unsafePerformIO (cbcArg1 bcIdx)+              hi = unsafePerformIO (cbcArg2 bcIdx)+           in pure (VFloat (reassembleDouble lo hi))+        OpLitBool ->+          let flag = unsafePerformIO (cbcShortArg bcIdx)+           in pure (VBool (flag /= 0))+        OpLitNull -> pure VNull+        OpLitUri ->+          let sym = unsafePerformIO (cbcArg1 bcIdx)+           in pure (mkStrBytes (symbolBytes (Symbol sym)))+        OpLitPath ->+          let sym = unsafePerformIO (cbcArg1 bcIdx)+           in VPath <$> resolvePathLiteral (symbolText (Symbol sym))+        OpStr -> evalBcStr env bcIdx+        OpIndStr -> evalBcIndStr env bcIdx+        OpPathStr -> evalBcPathStr env bcIdx+        OpVar ->+          let sym = unsafePerformIO (cbcArg1 bcIdx)+           in evalVar env (symbolText (Symbol sym))+        OpWithVar ->+          let sym = unsafePerformIO (cbcArg1 bcIdx)+              name = symbolText (Symbol sym)+           in evalWithVar env name+        OpResolvedVar ->+          let level = fromIntegral (unsafePerformIO (cbcArg1 bcIdx))+              idx = fromIntegral (unsafePerformIO (cbcArg2 bcIdx))+           in force (envLookupResolved level idx env)+        OpAttrs -> evalBcAttrs env bcIdx+        OpList -> evalBcList env bcIdx+        OpSelect -> evalBcSelect env bcIdx+        OpHasAttr -> evalBcHasAttr env bcIdx+        OpApp -> evalBcApp env bcIdx+        OpLambda -> evalBcLambda env bcIdx+        OpLet -> evalBcLet env bcIdx+        OpIf ->+          let condIdx = unsafePerformIO (cbcArg1 bcIdx)+              thenIdx = unsafePerformIO (cbcArg2 bcIdx)+              elseIdx = unsafePerformIO (cbcArg3 bcIdx)+           in do+                condVal <- evalBytecode env condIdx+                case condVal of+                  VBool True -> evalBytecode env thenIdx+                  VBool False -> evalBytecode env elseIdx+                  _ -> throwEvalError ("'if' condition must be a Boolean, got " <> typeName condVal)+        OpWith ->+          let scopeIdx = unsafePerformIO (cbcArg1 bcIdx)+              bodyIdx = unsafePerformIO (cbcArg2 bcIdx)+              -- Lazy with: defer forcing the scope until a WITH_VAR lookup+              -- actually needs it.  This is critical for nixpkgs where+              -- all-packages.nix uses `with pkgs;` inside a fixpoint -+              -- eagerly forcing the scope would blackhole.+              scopeThunk = cheapThunkBc env scopeIdx+           in evalBytecode (pushLazyWithScope scopeThunk env) bodyIdx+        OpAssert ->+          let condIdx = unsafePerformIO (cbcArg1 bcIdx)+              bodyIdx = unsafePerformIO (cbcArg2 bcIdx)+           in do+                condVal <- evalBytecode env condIdx+                case condVal of+                  VBool True -> evalBytecode env bodyIdx+                  -- A failed assert is catchable by tryEval (upstream+                  -- AssertionError); a non-bool condition is a type error.+                  VBool False -> throwCatchableError "assertion failed"+                  _ -> throwEvalError ("assertion condition must be a Boolean, got " <> typeName condVal)+        OpUnary ->+          let flags_ = unsafePerformIO (cbcFlags bcIdx)+              operandIdx = unsafePerformIO (cbcArg1 bcIdx)+           in do+                val <- evalBytecode env operandIdx+                evalUnary (decodeUnaryOp flags_) val+        OpBinary -> evalBcBinary env bcIdx+        OpSearchPath ->+          let sym = unsafePerformIO (cbcArg1 bcIdx)+           in evalSearchPath env (symbolText (Symbol sym))+        _ -> throwEvalError "evalBytecode: unknown opcode"++-- ---------------------------------------------------------------------------+-- Bytecode helpers+-- ---------------------------------------------------------------------------++-- | Decode a UnaryOp from bytecode flags.+decodeUnaryOp :: Word8 -> UnaryOp+decodeUnaryOp 0 = OpNot+decodeUnaryOp 1 = OpNegate+-- Unreachable: the tag is written by Nix.Eval.Compile's encodeUnaryOp, which+-- only emits 0 or 1.+decodeUnaryOp n = error ("decodeUnaryOp: unknown tag " <> show n)++-- | Decode a BinaryOp from bytecode flags.+decodeBinaryOp :: Word8 -> BinaryOp+decodeBinaryOp 0 = OpAdd+decodeBinaryOp 1 = OpSub+decodeBinaryOp 2 = OpMul+decodeBinaryOp 3 = OpDiv+decodeBinaryOp 4 = OpAnd+decodeBinaryOp 5 = OpOr+decodeBinaryOp 6 = OpImpl+decodeBinaryOp 7 = OpEq+decodeBinaryOp 8 = OpNeq+decodeBinaryOp 9 = OpLt+decodeBinaryOp 10 = OpLte+decodeBinaryOp 11 = OpGt+decodeBinaryOp 12 = OpGte+decodeBinaryOp 13 = OpConcat+decodeBinaryOp 14 = OpUpdate+-- Unreachable: the tag is written by Nix.Eval.Compile's encodeBinaryOp, which+-- only emits 0..14.+decodeBinaryOp n = error ("decodeBinaryOp: unknown tag " <> show n)++-- | Evaluate a binary operation from bytecode, with short-circuit+-- support for &&, ||, ->.+evalBcBinary :: (MonadEval m) => Env -> Word32 -> m NixValue+evalBcBinary env bcIdx0 =+  let flags_ = unsafePerformIO (cbcFlags bcIdx0)+      leftIdx = unsafePerformIO (cbcArg1 bcIdx0)+      rightIdx = unsafePerformIO (cbcArg2 bcIdx0)+      op = decodeBinaryOp flags_+   in case op of+        OpAnd -> evalShortCircuitAnd env leftIdx rightIdx+        OpOr -> evalShortCircuitOr env leftIdx rightIdx+        OpImpl -> evalShortCircuitImpl env leftIdx rightIdx+        OpAdd -> do+          leftVal <- evalBytecode env leftIdx+          rightVal <- evalBytecode env rightIdx+          evalAddWithCoercion leftVal rightVal+        _ -> do+          leftVal <- evalBytecode env leftIdx+          rightVal <- evalBytecode env rightIdx+          evalBinary force op leftVal rightVal++-- | Addition with string coercion fallback, matching C++ Nix behavior.+-- C++ Nix's ExprOpAdd falls through to concatStrings when operands+-- are not both numeric and neither is a path.  concatStrings calls+-- coerceToString on each part, which handles attrsets via outPath.+evalAddWithCoercion :: (MonadEval m) => NixValue -> NixValue -> m NixValue+evalAddWithCoercion left right = case (left, right) of+  -- Numeric: direct arithmetic (matches C++ Nix priority)+  (VFloat _, _) -> evalBinary force OpAdd left right+  (_, VFloat _) -> evalBinary force OpAdd left right+  (VInt _, VInt _) -> evalBinary force OpAdd left right+  -- Path + path: text concatenation, canonicalized - the joined spelling+  -- (dot segments, doubled separators) never survives into the value, as+  -- upstream (CanonPath on the concatenated text).+  (VPath a, VPath b) -> pure (VPath (canonPathValue (a <> b)))+  -- Path + coercible: the result stays a path, the right side coerces+  -- WITHOUT a store copy, and a right side carrying string context is an+  -- error, as upstream (a store-path reference cannot survive inside a+  -- path value).  Paths are Text in nova, so the appended bytes must+  -- decode; invalid UTF-8 cannot form a filesystem path here.+  (VPath a, _) -> do+    (rightStr, rightCtx) <- coerceAddOperand right+    if rightCtx == emptyContext+      then do+        appended <- decodedText "path concatenation" rightStr+        pure (VPath (canonPathValue (a <> appended)))+      else throwEvalError "cannot append a string with context (a store-path reference) to a path"+  -- Strings: direct concat.+  (VStr {}, VStr {}) -> evalBinary force OpAdd left right+  -- Otherwise: string concatenation with STRICT coercion (Nix coerceMore=false)+  -- - only strings, sets with __toString/outPath, and paths coerce; numbers,+  -- bools, null, lists, and functions are type errors, matching C++ Nix's `+`.+  -- A path on the right of a string is COPIED to the store (upstream+  -- copyToStore=true), so "x" + ./src concatenates the source's store path+  -- and carries it in the result's context.+  _ -> do+    (leftStr, leftCtx) <- coerceAddOperand left+    (rightStr, rightCtx) <- coerceAddOperand right+    pure (VStr (leftStr <> rightStr) (leftCtx <> rightCtx))++-- | Strict string coercion for the @+@ operator (Nix @coerceMore = false@):+-- strings, sets with @__toString@\/@outPath@, and paths (copied to the+-- store, carrying context) coerce; numbers, booleans, null, lists, and+-- functions are type errors.+coerceAddOperand :: (MonadEval m) => NixValue -> m (BS.ByteString, StringContext)+coerceAddOperand v@(VStr {}) = coerceToString False force applyValue coercePathToStore v+coerceAddOperand v@(VAttrs {}) = coerceToString False force applyValue coercePathToStore v+coerceAddOperand v@(VPath _) = coerceToStoreString v+coerceAddOperand v =+  throwEvalError ("cannot coerce " <> typeName v <> " to a string with the + operator")++-- | Bytecode short-circuit &&+evalShortCircuitAnd :: (MonadEval m) => Env -> Word32 -> Word32 -> m NixValue+evalShortCircuitAnd env leftIdx rightIdx = do+  leftVal <- evalBytecode env leftIdx+  case leftVal of+    VBool False -> pure (VBool False)+    VBool True -> do+      rightVal <- evalBytecode env rightIdx+      case rightVal of+        VBool _ -> pure rightVal+        _ -> throwEvalError ("second operand of && must be a Boolean, got " <> typeName rightVal)+    _ -> throwEvalError ("first operand of && must be a Boolean, got " <> typeName leftVal)++-- | Bytecode short-circuit ||+evalShortCircuitOr :: (MonadEval m) => Env -> Word32 -> Word32 -> m NixValue+evalShortCircuitOr env leftIdx rightIdx = do+  leftVal <- evalBytecode env leftIdx+  case leftVal of+    VBool True -> pure (VBool True)+    VBool False -> do+      rightVal <- evalBytecode env rightIdx+      case rightVal of+        VBool _ -> pure rightVal+        _ -> throwEvalError ("second operand of || must be a Boolean, got " <> typeName rightVal)+    _ -> throwEvalError ("first operand of || must be a Boolean, got " <> typeName leftVal)++-- | Bytecode short-circuit ->+evalShortCircuitImpl :: (MonadEval m) => Env -> Word32 -> Word32 -> m NixValue+evalShortCircuitImpl env leftIdx rightIdx = do+  leftVal <- evalBytecode env leftIdx+  case leftVal of+    VBool False -> pure (VBool True)+    VBool True -> do+      rightVal <- evalBytecode env rightIdx+      case rightVal of+        VBool _ -> pure rightVal+        _ -> throwEvalError ("second operand of -> must be a Boolean, got " <> typeName rightVal)+    _ -> throwEvalError ("first operand of -> must be a Boolean, got " <> typeName leftVal)++-- | Evaluate a string literal from bytecode data buffer.+evalBcStr :: (MonadEval m) => Env -> Word32 -> m NixValue+evalBcStr env bcIdx0 = do+  let (count, dataOff) =+        unsafePerformIO (cbcCountedPayload bcIdx0 =<< cbcArg1 bcIdx0)+  chunks <- evalBcStringParts env count dataOff+  pure (VStr (BS.concat [t | (_, t, _) <- chunks]) (mconcat [c | (_, _, c) <- chunks]))++-- | Evaluate an indented string literal from bytecode data buffer.  The common+-- indentation is stripped from the LITERAL chunks before concatenation, so an+-- interpolated multi-line value cannot drag the computed indent down - matching+-- C++ Nix.+evalBcIndStr :: (MonadEval m) => Env -> Word32 -> m NixValue+evalBcIndStr env bcIdx0 = do+  let (count, dataOff) =+        unsafePerformIO (cbcCountedPayload bcIdx0 =<< cbcArg1 bcIdx0)+  chunks <- evalBcStringParts env count dataOff+  let (text, ctx) = stripIndentedChunks chunks+  pure (VStr text ctx)++-- | Evaluate an interpolated path literal from the bytecode data+-- buffer.  The pieces concatenate with no separator, each interpolated+-- value coerced as a path segment, and the whole resolves like a plain+-- path literal - absolutized if still relative, @~\/@ expanded, and+-- canonicalized, so a @..@ or a slash arriving at runtime collapses+-- textually, matching upstream (verified against nix-instantiate+-- 2.33.2: @let v = "a\/.."; in .\/${v}@ is the base directory itself).+evalBcPathStr :: (MonadEval m) => Env -> Word32 -> m NixValue+evalBcPathStr env bcIdx0 = do+  let (count, dataOff) =+        unsafePerformIO (cbcCountedPayload bcIdx0 =<< cbcArg1 bcIdx0)+  chunks <- evalBcPathParts env count dataOff+  text <- decodedText "path literal" (BS.concat chunks)+  VPath <$> resolvePathLiteral text++-- | The path-segment walk under 'evalBcPathStr': literal pieces pass+-- through; an interpolated value coerces with upstream's path-segment+-- rules - a context-free string passes, a path contributes its text+-- WITHOUT a store copy (unlike string interpolation, which copies),+-- and a string carrying store-path context is refused, since a path+-- value has nowhere to keep the context that holds a store reference+-- alive.  All three observed from nix-instantiate 2.33.2.+evalBcPathParts :: (MonadEval m) => Env -> Int -> Word32 -> m [BS.ByteString]+evalBcPathParts _ 0 _ = pure []+evalBcPathParts env n off = do+  let tag = unsafePerformIO (cbcData off)+      val = unsafePerformIO (cbcData (off + 1))+  chunk <- case tag of+    0 -> pure (symbolBytes (Symbol val))+    _ -> do+      v <- evalBytecode env val+      (txt, ctx) <- coerceToString False force applyValue coercePathVerbatim v+      unless (ctx == emptyContext) $+        throwEvalError "a string that refers to a store path cannot be appended to a path"+      pure txt+  rest <- evalBcPathParts env (n - 1) (off + 2)+  pure (chunk : rest)++-- | Evaluate string parts from the bytecode data buffer.  Each part is two+-- words: (tag, value).  tag=0 means literal (value = symbol), tag=1 means+-- interpolation (value = bc_idx).  The 'Bool' marks literal (@True@) vs+-- interpolated (@False@) so indented strings strip only the literals.+evalBcStringParts :: (MonadEval m) => Env -> Int -> Word32 -> m [(Bool, BS.ByteString, StringContext)]+evalBcStringParts _ 0 _ = pure []+evalBcStringParts env n off = do+  let tag = unsafePerformIO (cbcData off)+      val = unsafePerformIO (cbcData (off + 1))+  chunk <- case tag of+    0 -> pure (True, symbolBytes (Symbol val), emptyContext)+    _ -> do+      v <- evalBytecode env val+      (txt, ctx) <- coerceToStringInterp v+      pure (False, txt, ctx)+  rest <- evalBcStringParts env (n - 1) (off + 2)+  pure (chunk : rest)++-- | Evaluate a list from bytecode data buffer.+evalBcList :: (MonadEval m) => Env -> Word32 -> m NixValue+evalBcList env bcIdx0 =+  let (count, dataOff) =+        unsafePerformIO (cbcCountedPayload bcIdx0 =<< cbcArg1 bcIdx0)+      readChildren 0 _ = []+      readChildren n off =+        let childIdx = unsafePerformIO (cbcData off)+         in cheapThunkBc env childIdx : readChildren (n - 1) (off + 1)+   in pure (VList (clistFromThunks (map thunkToCPtr (readChildren count dataOff))))++-- | Evaluate a function application from bytecode.+evalBcApp :: (MonadEval m) => Env -> Word32 -> m NixValue+evalBcApp env bcIdx0 = do+  let funcIdx = unsafePerformIO (cbcArg1 bcIdx0)+      argIdx = unsafePerformIO (cbcArg2 bcIdx0)+  funcVal <- evalBytecode env funcIdx+  case funcVal of+    VLambda closureEnv formals bodyBcIdx -> do+      let argThunk = cheapThunkBc env argIdx+      extEnv <- matchFormals closureEnv formals argThunk+      evalBytecode extEnv bodyBcIdx+    VBuiltin "tryEval" [] -> tryEvalAction (evalBytecode env argIdx)+    VBuiltin name accArgs -> do+      argVal <- evalBytecode env argIdx+      applyBuiltin name accArgs argVal+    VAttrs attrs+      | Just functorThunk <- attrSetLookup "__functor" attrs -> do+          functor <- force functorThunk+          partiallyApplied <- applyValue functor funcVal+          -- Maintain laziness: thunk the argument for lambdas, like+          -- the normal VLambda path above.  Builtins force anyway.+          case partiallyApplied of+            VLambda closureEnv formals bodyBcIdx -> do+              let argThunk = cheapThunkBc env argIdx+              extEnv <- matchFormals closureEnv formals argThunk+              evalBytecode extEnv bodyBcIdx+            -- A functor returning tryEval keeps the catch around the+            -- argument's evaluation, like the direct arm above.+            VBuiltin "tryEval" [] -> tryEvalAction (evalBytecode env argIdx)+            _ -> do+              argVal <- evalBytecode env argIdx+              applyValue partiallyApplied argVal+    _ -> throwEvalError ("attempt to call " <> typeName funcVal <> ", which is not a function")++-- | @builtins.tryEval@ over an argument's evaluation: @{ success, value }@+-- with catchable errors (throw, failed assert - the only kinds+-- 'catchEvalError' recovers) mapped to @success = false@.  The catch must+-- wrap the ARGUMENT'S EVALUATION: an application path that forces the+-- argument before dispatching here has already let the throw escape.+-- Callers holding an already-forced value pass @pure val@ - nothing left+-- to catch, so it success-wraps, exactly as upstream treats a value in+-- WHNF.+tryEvalAction :: (MonadEval m) => m NixValue -> m NixValue+tryEvalAction act = do+  result <- catchEvalError act+  pure $ case result of+    Right val ->+      VAttrs+        ( attrSetFromMap $+            Map.fromList+              [ ("success", evaluated (VBool True)),+                ("value", evaluated val)+              ]+        )+    Left _ ->+      VAttrs+        ( attrSetFromMap $+            Map.fromList+              [ ("success", evaluated (VBool False)),+                ("value", evaluated (VBool False))+              ]+        )++-- | Evaluate a lambda literal from bytecode - returns VLambda.+evalBcLambda :: (MonadEval m) => Env -> Word32 -> m NixValue+evalBcLambda env bcIdx0 =+  let flags_ = unsafePerformIO (cbcFlags bcIdx0)+      formalsOff = unsafePerformIO (cbcArg1 bcIdx0)+      bodyBcIdx = unsafePerformIO (cbcArg2 bcIdx0)+      captureOff = unsafePerformIO (cbcArg3 bcIdx0)+      formals = unsafePerformIO (decodeBcFormals flags_ formalsOff)+      captureInfo = unsafePerformIO (decodeBcCaptureInfo captureOff)+   in pure (VLambda (buildCaptureEnv env captureInfo) formals bodyBcIdx)++-- | Evaluate a select expression from bytecode.+evalBcSelect :: (MonadEval m) => Env -> Word32 -> m NixValue+evalBcSelect env bcIdx0 = do+  let hasDef = unsafePerformIO (cbcFlags bcIdx0) /= 0+      targetIdx = unsafePerformIO (cbcArg1 bcIdx0)+      (pathLen, pathOff) =+        unsafePerformIO (cbcCountedPayload bcIdx0 =<< cbcArg2 bcIdx0)+      defIdx = unsafePerformIO (cbcArg3 bcIdx0)+  targetVal <- evalBytecode env targetIdx+  result <- walkBcAttrPath env pathLen pathOff targetVal+  case result of+    Just val -> pure val+    Nothing+      | hasDef -> evalBytecode env defIdx+      | otherwise -> do+          let pathName = collectBcAttrPathNames pathLen pathOff+              targetKeys = case targetVal of+                VAttrs attrs -> T.intercalate ", " (take 20 (attrSetKeys attrs))+                _ -> ""+          throwEvalError ("attribute '" <> pathName <> "' not found in " <> typeName targetVal <> " {" <> targetKeys <> "}")++-- | Evaluate a hasAttr expression from bytecode.+evalBcHasAttr :: (MonadEval m) => Env -> Word32 -> m NixValue+evalBcHasAttr env bcIdx0 = do+  let targetIdx = unsafePerformIO (cbcArg1 bcIdx0)+      (pathLen, pathOff) =+        unsafePerformIO (cbcCountedPayload bcIdx0 =<< cbcArg2 bcIdx0)+  targetVal <- evalBytecode env targetIdx+  VBool <$> hasBcAttrPath env pathLen pathOff targetVal++-- | Collect static attribute path names for error reporting.+collectBcAttrPathNames :: Int -> Word32 -> Text+collectBcAttrPathNames pathLen pathOff = T.intercalate "." (go pathLen pathOff)+  where+    go 0 _ = []+    go n off =+      let isExpr = unsafePerformIO (cbcData off)+          keyVal = unsafePerformIO (cbcData (off + 1))+          name = if isExpr /= 0 then "<expr>" else symbolText (Symbol keyVal)+       in name : go (n - 1) (off + 2)++-- | Resolve one attr-path element (two words at @off@) to its key text.+-- A dynamic key must produce a string - null included, as upstream's+-- select and has-attr key coercion rejects null with a type error.+resolveBcAttrKey :: (MonadEval m) => Env -> Word32 -> m Text+resolveBcAttrKey env off = do+  let isExpr = unsafePerformIO (cbcData off)+      keyVal = unsafePerformIO (cbcData (off + 1))+  if isExpr /= 0+    then do+      keyResult <- evalBytecode env keyVal+      case keyResult of+        VStr s _ -> decodedText "dynamic attribute key" s+        _ -> throwEvalError ("dynamic attribute key must be a string, got " <> typeName keyResult)+    else pure (symbolText (Symbol keyVal))++-- | Walk an attribute path stored in the bytecode data buffer.+-- Each element is two words: (is_expr, key_or_bc_idx).  Every matched+-- element is forced - select needs the terminal value, and the walk+-- needs each intermediate to be a set.+walkBcAttrPath :: (MonadEval m) => Env -> Int -> Word32 -> NixValue -> m (Maybe NixValue)+walkBcAttrPath _ 0 _ val = pure (Just val)+walkBcAttrPath env n off val = case val of+  VAttrs attrs -> do+    key <- resolveBcAttrKey env off+    case attrSetLookup key attrs of+      Just thunk -> do+        inner <- force thunk+        walkBcAttrPath env (n - 1) (off + 2) inner+      Nothing -> pure Nothing+  -- Non-attrset: attribute path cannot continue.  Return Nothing so+  -- that callers with a default (``a.b or def'') can handle it+  -- gracefully, matching C++ Nix behaviour.+  _ -> pure Nothing++-- | Presence walk for has-attr: intermediates force (the walk must reach+-- a set), but the terminal element is a membership check only - upstream+-- ``a ? b.c`` never evaluates the final attribute's value.+hasBcAttrPath :: (MonadEval m) => Env -> Int -> Word32 -> NixValue -> m Bool+hasBcAttrPath _ 0 _ _ = pure True+hasBcAttrPath env n off val = case val of+  VAttrs attrs -> do+    key <- resolveBcAttrKey env off+    case attrSetLookup key attrs of+      Just thunk+        | n == 1 -> pure True+        | otherwise -> do+            inner <- force thunk+            hasBcAttrPath env (n - 1) (off + 2) inner+      Nothing -> pure False+  _ -> pure False++-- | Evaluate attribute set from bytecode.+evalBcAttrs :: (MonadEval m) => Env -> Word32 -> m NixValue+evalBcAttrs env bcIdx0 = do+  let isRec = unsafePerformIO (cbcFlags bcIdx0) /= 0+      (bindCount, dataOff) =+        unsafePerformIO (cbcCountedPayload bcIdx0 =<< cbcArg1 bcIdx0)+      captureOff = unsafePerformIO (cbcArg2 bcIdx0)+      bindings = unsafePerformIO (decodeBcBindings bindCount dataOff)+  if isRec+    then do+      let captureInfo = unsafePerformIO (decodeBcCaptureInfo captureOff)+      evalBcRecAttrs env bindings captureInfo+    else evalBcNonRecAttrs env bindings++-- | Evaluate a non-recursive attr set from bytecode bindings.+--+-- Values go through 'cheapThunkBc', so a constant or a variable already in+-- scope becomes the value rather than a thunk over it, which is what+-- upstream's non-recursive @ExprAttrs::eval@ does through @maybeThunk@.  The+-- env here is the enclosing one and is fully built, so reading a variable+-- out of it now is safe; the recursive and @let@ paths knot-tie their own+-- frame and cannot make the same shortcut.+evalBcNonRecAttrs :: (MonadEval m) => Env -> [BcBinding] -> m NixValue+evalBcNonRecAttrs env bindings = do+  thunkMap <- buildBcThunkMap cheapThunkBc env bindings+  pure (VAttrs (attrSetFromMap thunkMap))++-- | Build a let\/rec frame env, sharing the parent-chain and with-scope+-- plumbing otherwise repeated across the positional and dynamic paths.  The+-- frame's own bindings are positional slots (@slots@\/@slotCount@) or a lazy+-- name-table @scope@; the parent and with-scopes come from the enclosing @env@+-- and @captureInfo@.+newFrameEnv :: Env -> CaptureInfo -> Ptr CThunkPtr -> Int -> Maybe AttrSet -> Env+newFrameEnv env captureInfo slots slotCount scope =+  newCEnv slots slotCount scope (Just (buildCaptureEnv env captureInfo)) withArr withCount+  where+    (withArr, withCount) = case captureInfo of+      NoCaptureInfo -> envWithScopesRaw env+      Captures _ -> (nullPtr, 0)+      CapturesWithScopes _ -> withScopesForCapture env++-- | Evaluate a recursive attr set from bytecode bindings.+evalBcRecAttrs :: (MonadEval m) => Env -> [BcBinding] -> CaptureInfo -> m NixValue+evalBcRecAttrs env bindings captureInfo+  | allBcPositional bindings =+      -- Positional path: slots for variable lookup, CAttrSet for return.+      let slotCount = bcBindingSlotCount bindings+          slotsPtr = allocCSlots slotCount+          recEnv = newFrameEnv env captureInfo slotsPtr slotCount Nothing+          thunkList = buildBcSlotThunks recEnv env bindings+          filled = fillCSlots slotsPtr thunkList+          attrMap = buildBcAttrMapFromSlots bindings thunkList+       in filled `seq` pure (VAttrs (attrSetFromMap attrMap))+  | otherwise = do+      -- Dynamic-key path: the set has a ${dynamic} key or a nested a.b path, so+      -- names are not all known at compile time.  Not a corner-cutting fallback:+      -- it follows C++ Nix's env2 semantics.  The rec env holds the+      -- statically-named bindings as thunks, and the dynamic keys AND values are+      -- evaluated against it - so they can see static siblings and enclosing+      -- vars, but not another dynamic key.  The result is the static bindings+      -- plus the dynamic (name -> value) pairs.+      let scopeCset = buildCAttrSetKeys (bcBindingStaticKeys bindings)+          recEnv = newFrameEnv env captureInfo nullPtr 0 (Just (AttrSet scopeCset))+          (staticBs, dynBs) = partition bcBindingIsStatic bindings+      staticThunks <- buildBcThunkMap mkThunkBc recEnv staticBs+      let scopeFilled = fillCAttrSetValues scopeCset staticThunks+      dynThunks <- scopeFilled `seq` buildBcThunkMap mkThunkBc recEnv dynBs+      -- A dynamic key colliding with a static sibling is an eval error+      -- upstream ("dynamic attribute already defined"), never a merge.+      case Map.keys (Map.intersection dynThunks staticThunks) of+        (dupKey : _) -> throwEvalError ("dynamic attribute '" <> dupKey <> "' already defined")+        [] ->+          let allThunks = Map.union dynThunks staticThunks+              resultCset = buildCAttrSetKeys (Map.keys allThunks)+              filled = fillCAttrSetValues resultCset allThunks+           in filled `seq` pure (VAttrs (AttrSet resultCset))++-- | Evaluate a let expression from bytecode.+evalBcLet :: (MonadEval m) => Env -> Word32 -> m NixValue+evalBcLet env bcIdx0 = do+  let (bindCount, dataOff) =+        unsafePerformIO (cbcCountedPayload bcIdx0 =<< cbcArg1 bcIdx0)+      bodyIdx = unsafePerformIO (cbcArg2 bcIdx0)+      captureOff = unsafePerformIO (cbcArg3 bcIdx0)+      bindings = unsafePerformIO (decodeBcBindings bindCount dataOff)+      captureInfo = unsafePerformIO (decodeBcCaptureInfo captureOff)+  if allBcPositional bindings+    then do+      -- Positional path: slots for O(1) lookup.+      let slotCount = bcBindingSlotCount bindings+          slotsPtr = allocCSlots slotCount+          letEnv = newFrameEnv env captureInfo slotsPtr slotCount Nothing+          filled = fillCSlots slotsPtr (buildBcSlotThunks letEnv env bindings)+       in filled `seq` evalBytecode letEnv bodyIdx+    else do+      -- Dynamic path: a nested a.b binding (a dynamic top-level key is not valid+      -- in a let).  Every top-level key is therefore static; sub-keys and values+      -- resolve in the let env, which the body also sees.+      let cset = buildCAttrSetKeys (bcBindingStaticKeys bindings)+          letEnv = newFrameEnv env captureInfo nullPtr 0 (Just (AttrSet cset))+      thunkMap <- buildBcThunkMap mkThunkBc letEnv bindings+      let filled = fillCAttrSetValues cset thunkMap+       in filled `seq` evalBytecode letEnv bodyIdx++-- | Check if all bytecode bindings are single static keys (eligible for positional).+-- Must stay in sync with 'allStaticSingleKey' in 'Nix.Expr.Resolve'.+allBcPositional :: [BcBinding] -> Bool+allBcPositional = all isEligible+  where+    isEligible (BcNamed [BcStaticKey _] _) = True+    isEligible (BcInherit _) = True+    isEligible (BcInheritFrom _ _) = True+    isEligible _ = False++-- | Count positional slots for bytecode bindings.+-- Must stay in sync with 'lexicalScopeFromBindings' in 'Nix.Expr.Resolve'.+bcBindingSlotCount :: [BcBinding] -> Int+bcBindingSlotCount = foldl' countOne 0+  where+    countOne !acc (BcNamed [BcStaticKey _] _) = acc + 1+    countOne !acc (BcInherit syms) = acc + length syms+    countOne !acc (BcInheritFrom _ syms) = acc + length syms+    countOne !acc _ = acc++-- | Build thunks for positional bytecode bindings in declaration order.+buildBcSlotThunks :: Env -> Env -> [BcBinding] -> [Thunk]+buildBcSlotThunks recEnv outerEnv = concatMap slotThunk+  where+    slotThunk (BcNamed [BcStaticKey _] valBcIdx) =+      [mkThunkBc recEnv valBcIdx]+    slotThunk (BcInherit syms) =+      map (inheritLookup outerEnv . symbolText . Symbol) syms+    slotThunk (BcInheritFrom fromBcIdx syms) =+      -- inherit (from) x y z; becomes one thunk per name that selects from the from-expr.+      -- Each thunk gets a minimal env with the from-value at slot 0.+      let fromThunk = mkThunkBc recEnv fromBcIdx+          mkInheritThunk sym =+            let name = symbolText (Symbol sym)+                selectExpr = ESelect (EResolvedVar 0 0) [StaticKey name] Nothing+                (sp, sc) = buildCSlots [fromThunk]+                fromEnv = newMinimalEnv sp sc+             in mkSyntheticThunk fromEnv selectExpr+       in map mkInheritThunk syms+    -- Unreachable: allBcPositional guards this path.+    slotThunk _ = []++-- | Build attr map from slots for positional bytecode bindings.+buildBcAttrMapFromSlots :: [BcBinding] -> [Thunk] -> Map Text Thunk+buildBcAttrMapFromSlots bindings thunks = go bindings thunks Map.empty+  where+    go [] _ !acc = acc+    go (BcNamed [BcStaticKey sym] _ : bs) (t : ts) !acc =+      go bs ts (Map.insert (symbolText (Symbol sym)) t acc)+    go (BcInherit syms : bs) ts !acc =+      let (used, rest) = splitAt (length syms) ts+          accMerged = foldl' (\a (sym, t0) -> Map.insert (symbolText (Symbol sym)) t0 a) acc (zip syms used)+       in go bs rest accMerged+    go (BcInheritFrom _ syms : bs) ts !acc =+      let (used, rest) = splitAt (length syms) ts+          accMerged = foldl' (\a (sym, t0) -> Map.insert (symbolText (Symbol sym)) t0 a) acc (zip syms used)+       in go bs rest accMerged+    -- Unreachable: allBcPositional guards this path.+    go (_ : bs) ts !acc = go bs ts acc++-- | Build thunk map for bytecode attrs (non-rec or fallback rec path).+-- | Build the name-to-thunk map for a set of bindings.+--+-- How a value becomes a thunk is the caller's decision: a non-recursive+-- attr set can resolve constants and in-scope variables immediately, while a+-- frame that is still being tied has to defer everything, since the slots+-- the variables point at are not filled yet.+buildBcThunkMap :: (MonadEval m) => (Env -> Word32 -> Thunk) -> Env -> [BcBinding] -> m (Map Text Thunk)+buildBcThunkMap mkValueThunk thunkEnv = foldM addBinding Map.empty+  where+    addBinding acc (BcNamed keys valBcIdx) = do+      resolvedKeys <- mapM (resolveBcKey thunkEnv) keys+      case sequence resolvedKeys of+        Nothing -> pure acc -- null key -> skip+        Just [key] ->+          insertChecked acc key (mkValueThunk thunkEnv valBcIdx)+        Just path ->+          let nested = buildBcNestedAttr thunkEnv path valBcIdx+           in foldM (\a (k, t0) -> insertChecked a k t0) acc (Map.toList nested)+    addBinding acc (BcInherit syms) =+      foldM (\a sym -> let name = symbolText (Symbol sym) in insertChecked a name (inheritLookup thunkEnv name)) acc syms+    addBinding acc (BcInheritFrom fromBcIdx syms) =+      -- inherit (from) name selects name from the from-expr.+      -- Create a small env with the from value at slot 0, then a+      -- synthetic expression that selects name from slot 0.+      let addInheritFrom a sym =+            let name = symbolText (Symbol sym)+                selectExpr = ESelect (EResolvedVar 0 0) [StaticKey name] Nothing+                (sp, sc) = buildCSlots [mkThunkBc thunkEnv fromBcIdx]+                fromEnv = newMinimalEnv sp sc+             in insertChecked a name (mkSyntheticThunk fromEnv selectExpr)+       in foldM addInheritFrom acc syms++    -- The parser normalizes static keys to appear exactly once, so a+    -- collision here means an evaluated DYNAMIC key hit an existing+    -- attr - an eval error upstream, never a silent merge.+    insertChecked acc key thunk =+      case Map.lookup key acc of+        Nothing -> pure (Map.insert key thunk acc)+        Just _ -> throwEvalError ("dynamic attribute '" <> key <> "' already defined")++-- | Resolve a bytecode attr key to text.+resolveBcKey :: (MonadEval m) => Env -> BcAttrKey -> m (Maybe Text)+resolveBcKey _env (BcStaticKey sym) = pure (Just (symbolText (Symbol sym)))+resolveBcKey env (BcDynamicKey bcIdx0) = do+  val <- evalBytecode env bcIdx0+  case val of+    VStr s _ -> Just <$> decodedText "dynamic attribute key" s+    VNull -> pure Nothing+    _ -> throwEvalError ("dynamic attribute key must be a string, got " <> typeName val)++-- | Build a nested attribute structure from a resolved dotted path (bytecode).+buildBcNestedAttr :: Env -> [Text] -> Word32 -> Map Text Thunk+buildBcNestedAttr _thunkEnv [] _valBcIdx = Map.empty+buildBcNestedAttr thunkEnv [key] valBcIdx =+  Map.singleton key (mkThunkBc thunkEnv valBcIdx)+buildBcNestedAttr thunkEnv (key : rest) valBcIdx =+  Map.singleton key (evaluated (VAttrs (attrSetFromMap (buildBcNestedAttr thunkEnv rest valBcIdx))))++-- | Top-level keys knowable without evaluating any dynamic key: static keys+-- and inherited names.  These populate the rec env's name table while the+-- dynamic keys are evaluated, so a dynamic key can reference a static sibling+-- or an enclosing variable (matching C++ Nix's env2), but not another dynamic+-- key.+bcBindingStaticKeys :: [BcBinding] -> [Text]+bcBindingStaticKeys = concatMap oneBinding+  where+    oneBinding (BcNamed (BcStaticKey sym : _) _) = [symbolText (Symbol sym)]+    oneBinding (BcNamed _ _) = []+    oneBinding (BcInherit syms) = map (symbolText . Symbol) syms+    oneBinding (BcInheritFrom _ syms) = map (symbolText . Symbol) syms++-- | True when a binding's top-level key is known statically (a static key or an+-- inherit).  These bindings populate the rec env's name table (C++ Nix's env2);+-- a binding with a dynamic top-level key does not - it is added to the value.+bcBindingIsStatic :: BcBinding -> Bool+bcBindingIsStatic (BcNamed (BcStaticKey _ : _) _) = True+bcBindingIsStatic (BcNamed _ _) = False+bcBindingIsStatic (BcInherit _) = True+bcBindingIsStatic (BcInheritFrom _ _) = True++-- ---------------------------------------------------------------------------+-- Search paths (<nixpkgs>, <nixpkgs/lib>)+-- ---------------------------------------------------------------------------++-- | Evaluate a search path expression.+-- Desugars to @builtins.findFile builtins.nixPath "name"@ - exactly how+-- real Nix handles @\<name\>@ expressions.+evalSearchPath :: (MonadEval m) => Env -> Text -> m NixValue+evalSearchPath env name = do+  builtinsVal <- evalVar env "builtins"+  case builtinsVal of+    VAttrs builtinsAttrs ->+      case attrSetLookup "nixPath" builtinsAttrs of+        Just nixPathThunk -> do+          nixPathVal <- force nixPathThunk+          builtinFindFile nixPathVal (mkStr name)+        Nothing ->+          throwCatchableError ("file '" <> name <> "' was not found in the Nix search path")+    _ ->+      throwCatchableError ("file '" <> name <> "' was not found in the Nix search path")++-- ---------------------------------------------------------------------------+-- Variables+-- ---------------------------------------------------------------------------++evalVar :: (MonadEval m) => Env -> Text -> m NixValue+evalVar env name =+  case envLookup name env of+    Just thunk -> force thunk+    Nothing -> throwEvalError ("undefined variable '" <> name <> "'")++-- | Evaluate a with-scoped variable: check with-scopes first (innermost+-- to outermost), then fall back to the standard name-based lookup+-- (parent chain to builtins).  For trimmed envs ('CapturesWithScopes'),+-- the root scope is already appended to 'envWithScopes' so the+-- with-scope lookup finds builtins without needing a parent chain.+-- Supports both resolved (CAttrSet*) and lazy (CThunk*, tagged with bit 0)+-- with-scope entries.  Lazy entries are forced on first lookup and the+-- pointer is updated in place so subsequent lookups hit the resolved+-- attrset directly.+evalWithVar :: (MonadEval m) => Env -> Text -> m NixValue+evalWithVar env name =+  let (withArr, withCount) = envWithScopesRaw env+   in evalWithVarScopes env name withArr (fromIntegral withCount) 0++evalWithVarScopes :: (MonadEval m) => Env -> Text -> Ptr (Ptr ()) -> Int -> Int -> m NixValue+evalWithVarScopes env name withArr count idx+  | idx >= count = evalVar env name+  | otherwise = do+      let scopePtr = unsafePerformIO (peekElemOff withArr idx)+      if isLazyWithScope scopePtr+        then do+          -- Lazy with-scope: force the thunk to get the attrset+          let thunkPtr = untagWithScope scopePtr+          scopeVal <- force (Thunk thunkPtr)+          case scopeVal of+            VAttrs (AttrSet cset) -> do+              -- Cache: replace the tagged thunk pointer with the resolved+              -- attrset pointer so future lookups are fast.+              let resolvedPtr = castPtr cset+              seq (unsafePerformIO (pokeElemOff withArr idx resolvedPtr)) (pure ())+              case attrSetLookup name (AttrSet cset) of+                Just thunk -> force thunk+                Nothing -> evalWithVarScopes env name withArr count (idx + 1)+            _ -> throwEvalError ("'with' requires a set, got " <> typeName scopeVal)+        else+          -- Resolved attrset scope (normal path)+          case attrSetLookup name (AttrSet (castPtr scopePtr)) of+            Just thunk -> force thunk+            Nothing -> evalWithVarScopes env name withArr count (idx + 1)++-- | Check if a with-scope pointer is tagged as lazy (bit 0 set).+isLazyWithScope :: Ptr () -> Bool+isLazyWithScope ptr = ptrToWordPtr ptr .&. 1 /= 0++-- | Remove the lazy tag from a with-scope pointer, returning a CThunkPtr.+untagWithScope :: Ptr () -> CThunkPtr+untagWithScope ptr =+  let tagged = ptrToWordPtr ptr+   in wordPtrToPtr (tagged .&. complement 1)++-- | Tag a CThunkPtr as a lazy with-scope (set bit 0).+tagLazyWithScope :: CThunkPtr -> Ptr ()+tagLazyWithScope ptr =+  let raw = ptrToWordPtr (castPtr ptr)+   in wordPtrToPtr (raw .|. 1)++-- | Push a lazy (thunk-based) with-scope onto the environment.+-- The scope is NOT forced until a WITH_VAR lookup actually needs it.+{-# NOINLINE pushLazyWithScope #-}+pushLazyWithScope :: Thunk -> Env -> Env+pushLazyWithScope (Thunk thunkPtr) =+  pushWithScopeRaw (tagLazyWithScope thunkPtr)++-- | Push a raw pointer as a with-scope.+{-# NOINLINE pushWithScopeRaw #-}+pushWithScopeRaw :: Ptr () -> Env -> Env+pushWithScopeRaw ptr (Env envPtr) =+  Env (checkedCPtr "pushWithScopeRaw" (unsafePerformIO (cenvPushWith envPtr ptr)))++-- ---------------------------------------------------------------------------+-- Formals matching + env helpers (used by evalBcApp, applyValue, etc.)+-- ---------------------------------------------------------------------------++-- | Match a lambda's formals against an argument thunk.+matchFormals :: (MonadEval m) => Env -> EvalFormals -> Thunk -> m Env+matchFormals closureEnv (EFName _) argThunk =+  let (sp, sc) = buildCSlots [argThunk]+   in pure (envFromSlots sp sc closureEnv)+matchFormals closureEnv (EFSet formals allowExtra) argThunk = do+  argVal <- force argThunk+  matchFormalSet closureEnv formals allowExtra argVal Nothing+matchFormals closureEnv (EFNamedSet _ formals allowExtra) argThunk = do+  argVal <- force argThunk+  matchFormalSet closureEnv formals allowExtra argVal (Just argThunk)++-- | Match destructuring set pattern formals against an attrset value.+matchFormalSet :: (MonadEval m) => Env -> [EvalFormal] -> Bool -> NixValue -> Maybe Thunk -> m Env+matchFormalSet closureEnv formals allowExtra argVal atThunk =+  case argVal of+    VAttrs attrs -> do+      checkExtraKeys formals allowExtra attrs+      checkMissingFormals attrs formals+      let formalEnv = envFromSlots formalSlotsPtr formalSlotCount closureEnv+          (formalSlotsPtr, formalSlotCount) = buildCSlots formalThunks+          formalThunks = case atThunk of+            Nothing -> map resolveOneFormal formals+            Just at -> at : map resolveOneFormal formals+          resolveOneFormal (EvalFormal name defBcIdx) =+            case attrSetLookup name attrs of+              Just thunk -> thunk+              Nothing -> case defBcIdx of+                Just bcIdx -> mkThunkBc formalEnv bcIdx+                Nothing -> error "matchFormalSet: missing required formal (unreachable)"+      pure formalEnv+    _ -> throwEvalError ("function expects a set argument, got " <> typeName argVal)++checkExtraKeys :: (MonadEval m) => [EvalFormal] -> Bool -> AttrSet -> m ()+checkExtraKeys _ True _ = pure ()+checkExtraKeys formals False attrs =+  let expected = map efName formals+      actual = attrSetKeys attrs+      extra = filter (`notElem` expected) actual+   in case extra of+        [] -> pure ()+        (k : _) -> throwEvalError ("unexpected attribute '" <> k <> "' in function argument")++checkMissingFormals :: (MonadEval m) => AttrSet -> [EvalFormal] -> m ()+checkMissingFormals attrs formals =+  let allMissing = [efName f | f <- formals, isNothing (efDefault f), not (attrSetMember (efName f) attrs)]+   in case allMissing of+        [] -> pure ()+        (name : _) ->+          let provided = attrSetKeys attrs+              provSnippet = T.intercalate ", " (take 20 provided)+              missSnippet = T.intercalate ", " allMissing+           in throwEvalError ("missing required attribute '" <> name <> "'; all missing: [" <> missSnippet <> "]; provided keys (" <> T.pack (show (length provided)) <> "): [" <> provSnippet <> "]")++-- | Build capture environment from capture info.+-- NoCaptureInfo: no trimming, use env as-is.+-- Captures: build minimal env from captured slots.+-- CapturesWithScopes: build minimal env + copy with-scopes.+buildCaptureEnv :: Env -> CaptureInfo -> Env+buildCaptureEnv env NoCaptureInfo = env+buildCaptureEnv env (Captures captureList) =+  let (slotsPtr, slotCount) = buildCSlots [envLookupResolved lvl idx env | (lvl, idx) <- captureList]+   in newMinimalEnv slotsPtr slotCount+buildCaptureEnv env (CapturesWithScopes captureList) =+  let (slotsPtr, slotCount) = buildCSlots [envLookupResolved lvl idx env | (lvl, idx) <- captureList]+      (withArr, withCount) = withScopesForCapture env+   in newCEnv slotsPtr slotCount Nothing Nothing withArr withCount++-- | Look up a name in the environment and return its thunk.+-- Used by @inherit@ bindings (both bytecode and Expr paths).+inheritLookup :: Env -> Text -> Thunk+inheritLookup env name =+  case envLookup name env of+    Just thunk -> thunk+    Nothing -> error ("inheritLookup: undefined variable '" <> T.unpack name <> "' (unreachable)")++-- ---------------------------------------------------------------------------+-- Builtin registry (single-definition-site for all builtins)+-- ---------------------------------------------------------------------------++-- | A builtin function definition: its arity and implementation.+data BuiltinDef m = BuiltinDef+  { bdArity :: !Int,+    bdApply :: [NixValue] -> m NixValue+  }++-- | Define an arity-1 builtin.+builtin1 :: (MonadEval m) => Text -> (NixValue -> m NixValue) -> (Text, BuiltinDef m)+builtin1 name f =+  ( name,+    BuiltinDef 1 $ \case+      [a] -> f a+      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")+  )++-- | Define an arity-2 builtin.+builtin2 ::+  (MonadEval m) =>+  Text ->+  (NixValue -> NixValue -> m NixValue) ->+  (Text, BuiltinDef m)+builtin2 name f =+  ( name,+    BuiltinDef 2 $ \case+      [a, b] -> f a b+      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")+  )++-- | Define an arity-3 builtin.+builtin3 ::+  (MonadEval m) =>+  Text ->+  (NixValue -> NixValue -> NixValue -> m NixValue) ->+  (Text, BuiltinDef m)+builtin3 name f =+  ( name,+    BuiltinDef 3 $ \case+      [a, b, c] -> f a b c+      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")+  )++-- | Central registry of all builtins.  Adding a new builtin is a single+-- entry here plus its implementation function - no other files need changes.+builtinRegistry :: (MonadEval m) => Map Text (BuiltinDef m)+builtinRegistry =+  Map.fromList+    [ -- Type checking (arity 1)+      builtin1 "typeOf" (pure . mkStr . typeOfValue),+      builtin1 "isNull" (pure . VBool . isNullVal),+      builtin1 "isInt" (pure . VBool . isIntVal),+      builtin1 "isFloat" (pure . VBool . isFloatVal),+      builtin1 "isBool" (pure . VBool . isBoolVal),+      builtin1 "isString" (pure . VBool . isStringVal),+      builtin1 "isList" (pure . VBool . isListVal),+      builtin1 "isAttrs" (pure . VBool . isAttrsVal),+      builtin1 "isFunction" (pure . VBool . isFunctionVal),+      -- List operations (arity 1)+      builtin1 "length" builtinLength,+      builtin1 "head" builtinHead,+      builtin1 "tail" builtinTail,+      -- String operations (arity 1)+      builtin1 "toString" (fmap (uncurry VStr) . coerceToStringPermissive),+      builtin1 "stringLength" builtinStringLength,+      -- Control (arity 1)+      builtin1 "throw" builtinThrow,+      builtin1 "abort" builtinAbort,+      -- Attr set operations (arity 1)+      builtin1 "attrNames" builtinAttrNames,+      builtin1 "attrValues" builtinAttrValues,+      builtin1 "listToAttrs" builtinListToAttrs,+      -- Attr set operations (arity 2)+      builtin2 "hasAttr" builtinHasAttr,+      builtin2 "getAttr" builtinGetAttr,+      builtin2 "removeAttrs" builtinRemoveAttrs,+      builtin2 "intersectAttrs" builtinIntersectAttrs,+      builtin2 "catAttrs" builtinCatAttrs,+      -- List higher-order (arity 2)+      builtin2 "map" builtinMap,+      builtin2 "filter" builtinFilter,+      builtin2 "genList" builtinGenList,+      builtin2 "sort" builtinSort,+      builtin2 "concatMap" builtinConcatMap,+      builtin2 "any" builtinAny,+      builtin2 "all" builtinAll,+      builtin2 "elem" builtinElem,+      builtin2 "elemAt" builtinElemAt,+      builtin2 "partition" builtinPartition,+      builtin2 "groupBy" builtinGroupBy,+      -- String operations (arity 2)+      builtin2 "concatStringsSep" builtinConcatStringsSep,+      -- Arity 3+      builtin3 "foldl'" builtinFoldl,+      builtin3 "substring" builtinSubstring,+      -- Numeric+      builtin1 "isPath" (pure . VBool . isPathVal),+      builtin1 "ceil" builtinCeil,+      builtin1 "floor" builtinFloor,+      builtin2 "seq" builtinSeq,+      builtin2 "trace" builtinTrace,+      builtin2 "warn" builtinWarn,+      builtin1 "unsafeDiscardStringContext" builtinDiscardContext,+      builtin1 "unsafeDiscardOutputDependency" builtinDiscardOutputDep,+      builtin1 "addDrvOutputDependencies" builtinAddDrvOutputDeps,+      -- String context introspection+      builtin1 "hasContext" builtinHasContext,+      builtin1 "getContext" builtinGetContext,+      builtin2 "appendContext" builtinAppendContext,+      builtin1 "baseNameOf" builtinBaseNameOf,+      builtin1 "dirOf" builtinDirOf,+      builtin1 "concatLists" builtinConcatLists,+      builtin2 "lessThan" builtinLessThan,+      -- Arithmetic + bitwise+      builtin2 "add" builtinAdd,+      builtin2 "sub" builtinSub,+      builtin2 "mul" builtinMul,+      builtin2 "div" builtinDiv,+      builtin2 "bitAnd" builtinBitAnd,+      builtin2 "bitOr" builtinBitOr,+      builtin2 "bitXor" builtinBitXor,+      -- Attr set higher-order+      builtin2 "mapAttrs" builtinMapAttrs,+      builtin1 "functionArgs" builtinFunctionArgs,+      builtin2 "zipAttrsWith" builtinZipAttrsWith,+      -- String manipulation+      builtin2 "match" builtinMatch,+      builtin2 "split" builtinSplit,+      builtin3 "replaceStrings" builtinReplaceStrings,+      builtin2 "compareVersions" builtinCompareVersions,+      builtin1 "splitVersion" builtinSplitVersion,+      builtin1 "parseDrvName" builtinParseDrvName,+      -- Serialization + hashing+      builtin1 "toJSON" builtinToJSON,+      builtin1 "fromJSON" builtinFromJSON,+      builtin2 "hashString" builtinHashString,+      -- Error handling + sequencing+      -- An argument reaching here is already forced (any throw happened+      -- before tryEval saw it), so it success-wraps; the catching paths+      -- are evalBcApp and applyValueLazy.+      builtin1 "tryEval" (tryEvalAction . pure),+      builtin2 "deepSeq" builtinDeepSeq,+      -- Graph traversal+      builtin1 "genericClosure" builtinGenericClosure,+      -- IO builtins (delegate to MonadEval methods)+      builtin1 "import" builtinImport,+      builtin1 "readFile" builtinReadFile,+      builtin1 "pathExists" builtinPathExists,+      builtin1 "readDir" builtinReadDir,+      builtin1 "getEnv" builtinGetEnv,+      builtin1 "toPath" builtinToPath,+      -- Store path operations+      builtin1 "placeholder" builtinPlaceholder,+      builtin1 "storePath" builtinStorePath,+      builtin2 "findFile" builtinFindFile,+      builtin2 "toFile" builtinToFile,+      builtin2 "scopedImport" builtinScopedImport,+      -- Network fetchers+      builtin1 "fetchurl" builtinFetchurl,+      builtin1 "fetchTarball" builtinFetchTarball,+      builtin1 "fetchGit" builtinFetchGit,+      -- Derivation construction: lazy 'derivation' wrapper over the eager+      -- 'derivationStrict' primop, as upstream's+      -- src/libexpr/primops/derivation.nix does.+      builtin1 "derivation" builtinDerivationLazy,+      builtin1 "derivationStrict" builtinDerivationStrict,+      -- Error context (pass-through - context only matters on error)+      builtin2 "addErrorContext" (\_ val -> pure val),+      -- Attr position (return null - nixpkgs handles this gracefully)+      builtin2 "unsafeGetAttrPos" (\_ _ -> pure VNull),+      -- Debugging (traceVerbose: same as trace for now, --trace-verbose not yet gated)+      builtin2 "traceVerbose" builtinTrace,+      builtin1 "break" pure,+      -- IO: file hashing + type detection+      builtin2 "hashFile" builtinHashFile,+      builtin1 "readFileType" builtinReadFileType,+      -- Serialization+      builtin1 "fromTOML" builtinFromTOML,+      -- Hash conversion+      builtin1 "convertHash" builtinConvertHash,+      -- XML serialization+      builtin1 "toXML" builtinToXML,+      -- Source filtering + path import+      builtin1 "path" builtinPath,+      builtin2 "filterSource" builtinFilterSource,+      -- Experimental feature stubs+      builtin2 "outputOf" builtinOutputOf,+      builtin1 "fetchTree" builtinFetchTree,+      builtin1 "fetchClosure" builtinFetchClosure+    ]++-- | Names of all registered builtins.+builtinNames :: [Text]+builtinNames = Map.keys (builtinRegistry :: Map Text (BuiltinDef PureEval))++-- ---------------------------------------------------------------------------+-- Builtin dispatch (partial application via accumulated args)+-- ---------------------------------------------------------------------------++-- | Arity of a builtin (how many arguments before execution).+builtinArity :: Text -> Int+builtinArity name = maybe 1 bdArity (Map.lookup name (builtinRegistry :: Map Text (BuiltinDef PureEval)))++-- | Apply a builtin with accumulated args.  If we have enough args,+-- execute; otherwise return a partially applied builtin.+applyBuiltin :: (MonadEval m) => Text -> [NixValue] -> NixValue -> m NixValue+applyBuiltin name accArgs arg =+  let allArgs = accArgs ++ [arg]+      arity = builtinArity name+   in if length allArgs < arity+        then pure (VBuiltin name (precompileArgs name allArgs))+        else executeBuiltin name allArgs++-- | Pre-compile regex patterns at partial application time.+-- When builtins.match or builtins.split receives its first argument+-- (the pattern string), compile it immediately and store the compiled+-- RE.Regex in a VCompiledRegex, replacing the raw VStr.  The compiled+-- form is carried in VBuiltin's accumulated args and reused on every+-- subsequent application - zero recompilation.  Both builtins compile+-- the RAW pattern: match's whole-string requirement is a span check at+-- match time, not textual @^...$@ anchoring, which would misparse a+-- top-level alternation (@^a|b$@ is @(^a)|(b$)@).+precompileArgs :: Text -> [NixValue] -> [NixValue]+precompileArgs "match" [VStr pat _] = compiledRegexArg pat+precompileArgs "split" [VStr pat _] = compiledRegexArg pat+precompileArgs _ args = args++-- | The single-element arg list for a regex builtin: the compiled pattern+-- if valid, the raw string otherwise (the error surfaces at execute time).+compiledRegexArg :: BS.ByteString -> [NixValue]+compiledRegexArg pat = case cachedCompileRegex pat of+  Just compiled -> [VCompiledRegex (CompiledRegex pat compiled)]+  Nothing -> [VStr pat emptyContext]++-- | Apply a function value (lambda or builtin) to one argument.+-- Used by higher-order builtins to invoke user-supplied functions.+applyValue :: (MonadEval m) => NixValue -> NixValue -> m NixValue+applyValue (VLambda closureEnv formals bodyBcIdx) arg = do+  extEnv <- matchFormals closureEnv formals (evaluated arg)+  evalBytecode extEnv bodyBcIdx+applyValue (VBuiltin name accArgs) arg =+  applyBuiltin name accArgs arg+applyValue other _ =+  throwEvalError ("attempt to call " <> typeName other <> ", which is not a function")++-- | Apply a function value to an UNFORCED thunk argument.  Upstream's+-- higher-order list builtins (filter, any, all, partition, groupBy,+-- foldl') pass elements as unforced values, so an element the function+-- never inspects may contain a throw without failing the call.  A+-- builtin callee still receives a forced value (builtins force their+-- arguments regardless), and set-pattern formals force on destructuring+-- exactly as upstream does.+applyValueLazy :: (MonadEval m) => NixValue -> Thunk -> m NixValue+applyValueLazy (VLambda closureEnv formals bodyBcIdx) argThunk = do+  extEnv <- matchFormals closureEnv formals argThunk+  evalBytecode extEnv bodyBcIdx+-- tryEval's catch must wrap the argument's forcing (see 'tryEvalAction'):+-- map/filter passing a throwing element to tryEval yields success = false,+-- not an escaped error.+applyValueLazy (VBuiltin "tryEval" []) argThunk = tryEvalAction (force argThunk)+applyValueLazy other argThunk = do+  val <- force argThunk+  applyValue other val++-- | Execute a builtin once all arguments are collected.+--+-- Direct case dispatch avoids rebuilding the polymorphic 'builtinRegistry'+-- Map on every call.  'builtinRegistry' is polymorphic in @m@ so GHC+-- cannot cache it as a CAF - it gets reconstructed on every use.+-- Pattern matching on the name is zero-allocation.+executeBuiltin :: (MonadEval m) => Text -> [NixValue] -> m NixValue+executeBuiltin name args = case name of+  -- Type checking (arity 1)+  "typeOf" -> apply1 (pure . mkStr . typeOfValue)+  "isNull" -> apply1 (pure . VBool . isNullVal)+  "isInt" -> apply1 (pure . VBool . isIntVal)+  "isFloat" -> apply1 (pure . VBool . isFloatVal)+  "isBool" -> apply1 (pure . VBool . isBoolVal)+  "isString" -> apply1 (pure . VBool . isStringVal)+  "isList" -> apply1 (pure . VBool . isListVal)+  "isAttrs" -> apply1 (pure . VBool . isAttrsVal)+  "isFunction" -> apply1 (pure . VBool . isFunctionVal)+  -- List operations (arity 1)+  "length" -> apply1 builtinLength+  "head" -> apply1 builtinHead+  "tail" -> apply1 builtinTail+  -- String operations (arity 1)+  "toString" -> apply1 (fmap (uncurry VStr) . coerceToStringPermissive)+  "stringLength" -> apply1 builtinStringLength+  -- Control (arity 1)+  "throw" -> apply1 builtinThrow+  "abort" -> apply1 builtinAbort+  -- Attr set operations (arity 1)+  "attrNames" -> apply1 builtinAttrNames+  "attrValues" -> apply1 builtinAttrValues+  "listToAttrs" -> apply1 builtinListToAttrs+  -- Attr set operations (arity 2)+  "hasAttr" -> apply2 builtinHasAttr+  "getAttr" -> apply2 builtinGetAttr+  "removeAttrs" -> apply2 builtinRemoveAttrs+  "intersectAttrs" -> apply2 builtinIntersectAttrs+  "catAttrs" -> apply2 builtinCatAttrs+  -- List higher-order (arity 2)+  "map" -> apply2 builtinMap+  "filter" -> apply2 builtinFilter+  "genList" -> apply2 builtinGenList+  "sort" -> apply2 builtinSort+  "concatMap" -> apply2 builtinConcatMap+  "any" -> apply2 builtinAny+  "all" -> apply2 builtinAll+  "elem" -> apply2 builtinElem+  "elemAt" -> apply2 builtinElemAt+  "partition" -> apply2 builtinPartition+  "groupBy" -> apply2 builtinGroupBy+  -- String operations (arity 2)+  "concatStringsSep" -> apply2 builtinConcatStringsSep+  -- Arity 3+  "foldl'" -> apply3 builtinFoldl+  "substring" -> apply3 builtinSubstring+  -- Numeric+  "isPath" -> apply1 (pure . VBool . isPathVal)+  "ceil" -> apply1 builtinCeil+  "floor" -> apply1 builtinFloor+  "seq" -> apply2 builtinSeq+  "trace" -> apply2 builtinTrace+  "warn" -> apply2 builtinWarn+  "unsafeDiscardStringContext" -> apply1 builtinDiscardContext+  "unsafeDiscardOutputDependency" -> apply1 builtinDiscardOutputDep+  "addDrvOutputDependencies" -> apply1 builtinAddDrvOutputDeps+  -- String context introspection+  "hasContext" -> apply1 builtinHasContext+  "getContext" -> apply1 builtinGetContext+  "appendContext" -> apply2 builtinAppendContext+  "baseNameOf" -> apply1 builtinBaseNameOf+  "dirOf" -> apply1 builtinDirOf+  "concatLists" -> apply1 builtinConcatLists+  "lessThan" -> apply2 builtinLessThan+  -- Arithmetic + bitwise+  "add" -> apply2 builtinAdd+  "sub" -> apply2 builtinSub+  "mul" -> apply2 builtinMul+  "div" -> apply2 builtinDiv+  "bitAnd" -> apply2 builtinBitAnd+  "bitOr" -> apply2 builtinBitOr+  "bitXor" -> apply2 builtinBitXor+  -- Attr set higher-order+  "mapAttrs" -> apply2 builtinMapAttrs+  "functionArgs" -> apply1 builtinFunctionArgs+  "zipAttrsWith" -> apply2 builtinZipAttrsWith+  -- String manipulation+  "match" -> apply2 builtinMatch+  "split" -> apply2 builtinSplit+  "replaceStrings" -> apply3 builtinReplaceStrings+  "compareVersions" -> apply2 builtinCompareVersions+  "splitVersion" -> apply1 builtinSplitVersion+  "parseDrvName" -> apply1 builtinParseDrvName+  -- Serialization + hashing+  "toJSON" -> apply1 builtinToJSON+  "fromJSON" -> apply1 builtinFromJSON+  "hashString" -> apply2 builtinHashString+  -- Error handling + sequencing+  -- Already-forced argument: success-wrap (see the registry entry).+  "tryEval" -> apply1 (tryEvalAction . pure)+  "deepSeq" -> apply2 builtinDeepSeq+  -- Graph traversal+  "genericClosure" -> apply1 builtinGenericClosure+  -- IO builtins (delegate to MonadEval methods)+  "import" -> apply1 builtinImport+  "readFile" -> apply1 builtinReadFile+  "pathExists" -> apply1 builtinPathExists+  "readDir" -> apply1 builtinReadDir+  "getEnv" -> apply1 builtinGetEnv+  "toPath" -> apply1 builtinToPath+  -- Store path operations+  "placeholder" -> apply1 builtinPlaceholder+  "storePath" -> apply1 builtinStorePath+  "findFile" -> apply2 builtinFindFile+  "toFile" -> apply2 builtinToFile+  "scopedImport" -> apply2 builtinScopedImport+  -- Network fetchers+  "fetchurl" -> apply1 builtinFetchurl+  "fetchTarball" -> apply1 builtinFetchTarball+  "fetchGit" -> apply1 builtinFetchGit+  -- Derivation construction: lazy 'derivation' over eager 'derivationStrict'+  "derivation" -> apply1 builtinDerivationLazy+  "derivationStrict" -> apply1 builtinDerivationStrict+  -- Error context (pass-through - context only matters on error)+  "addErrorContext" -> apply2 (\_ val -> pure val)+  -- Attr position (return null - nixpkgs handles this gracefully)+  "unsafeGetAttrPos" -> apply2 (\_ _ -> pure VNull)+  -- Debugging (traceVerbose: same as trace for now)+  "traceVerbose" -> apply2 builtinTrace+  "break" -> apply1 pure+  -- IO: file hashing + type detection+  "hashFile" -> apply2 builtinHashFile+  "readFileType" -> apply1 builtinReadFileType+  -- Serialization+  "fromTOML" -> apply1 builtinFromTOML+  -- Hash conversion+  "convertHash" -> apply1 builtinConvertHash+  -- XML serialization+  "toXML" -> apply1 builtinToXML+  -- Source filtering + path import+  "path" -> apply1 builtinPath+  "filterSource" -> apply2 builtinFilterSource+  -- Experimental feature stubs+  "outputOf" -> apply2 builtinOutputOf+  "fetchTree" -> apply1 builtinFetchTree+  "fetchClosure" -> apply1 builtinFetchClosure+  _ -> throwEvalError ("unknown builtin '" <> name <> "'")+  where+    apply1 f = case args of+      [a] -> f a+      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")+    apply2 f = case args of+      [a, b] -> f a b+      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")+    apply3 f = case args of+      [a, b, c] -> f a b c+      _ -> throwEvalError ("builtins." <> name <> ": internal arity error")++-- ---------------------------------------------------------------------------+-- Builtin implementations - type checking+-- ---------------------------------------------------------------------------++typeOfValue :: NixValue -> Text+typeOfValue val = case val of+  VInt _ -> "int"+  VFloat _ -> "float"+  VBool _ -> "bool"+  VNull -> "null"+  VStr _ _ -> "string"+  VPath _ -> "path"+  VList _ -> "list"+  VAttrs _ -> "set"+  VLambda {} -> "lambda"+  VBuiltin _ _ -> "lambda"+  VDerivation _ -> "set"+  VCompiledRegex _ -> "lambda"++isNullVal :: NixValue -> Bool+isNullVal VNull = True+isNullVal _ = False++isIntVal :: NixValue -> Bool+isIntVal (VInt _) = True+isIntVal _ = False++isFloatVal :: NixValue -> Bool+isFloatVal (VFloat _) = True+isFloatVal _ = False++isBoolVal :: NixValue -> Bool+isBoolVal (VBool _) = True+isBoolVal _ = False++isStringVal :: NixValue -> Bool+isStringVal (VStr _ _) = True+isStringVal _ = False++isListVal :: NixValue -> Bool+isListVal (VList _) = True+isListVal _ = False++isAttrsVal :: NixValue -> Bool+isAttrsVal (VAttrs _) = True+isAttrsVal _ = False++isFunctionVal :: NixValue -> Bool+isFunctionVal (VLambda {}) = True+isFunctionVal (VBuiltin _ _) = True+isFunctionVal _ = False++-- ---------------------------------------------------------------------------+-- Builtin implementations - list (arity 1)+-- ---------------------------------------------------------------------------++builtinLength :: (MonadEval m) => NixValue -> m NixValue+builtinLength (VList cl) = pure (VInt (fromIntegral (clistLen cl)))+builtinLength other = throwEvalError ("builtins.length: expected a list, got " <> typeName other)++builtinHead :: (MonadEval m) => NixValue -> m NixValue+builtinHead (VList cl)+  | clistLen cl == 0 = throwEvalError "builtins.head: empty list"+  | otherwise = case clistThunks cl of+      (p : _) -> force (Thunk p)+      [] -> throwEvalError "builtins.head: empty list" -- unreachable: clistLen > 0+builtinHead other = throwEvalError ("builtins.head: expected a list, got " <> typeName other)++builtinTail :: (MonadEval m) => NixValue -> m NixValue+builtinTail (VList cl)+  | clistLen cl == 0 = throwEvalError "builtins.tail: empty list"+  | otherwise = pure (VList (clistFromThunks (drop 1 (clistThunks cl))))+builtinTail other = throwEvalError ("builtins.tail: expected a list, got " <> typeName other)++-- ---------------------------------------------------------------------------+-- Builtin implementations - string (arity 1)+-- ---------------------------------------------------------------------------++-- | Byte length, as upstream: stringLength of a 2-byte character is 2, not 1.+builtinStringLength :: (MonadEval m) => NixValue -> m NixValue+builtinStringLength (VStr s _) = pure (VInt (fromIntegral (BS.length s)))+builtinStringLength other =+  throwEvalError ("builtins.stringLength: expected a string, got " <> typeName other)++-- ---------------------------------------------------------------------------+-- Builtin implementations - control+-- ---------------------------------------------------------------------------++-- | The error channel is Text and display-only (tryEval discards the+-- message), so throw/abort messages decode lossily.+builtinThrow :: (MonadEval m) => NixValue -> m NixValue+builtinThrow (VStr msg _) = throwCatchableError (bytesToTextLossy msg)+builtinThrow other = throwEvalError ("builtins.throw: expected a string, got " <> typeName other)++builtinAbort :: (MonadEval m) => NixValue -> m NixValue+builtinAbort (VStr msg _) = abortEvaluation (bytesToTextLossy msg)+builtinAbort other = abortEvaluation ("builtins.abort: expected a string, got " <> typeName other)++-- ---------------------------------------------------------------------------+-- Builtin implementations - attr set (arity 1)+-- ---------------------------------------------------------------------------++builtinAttrNames :: (MonadEval m) => NixValue -> m NixValue+builtinAttrNames (VAttrs attrs) =+  -- Nix returns attribute names lexicographically sorted; the C array is in+  -- interned-symbol order, so sort here (consistent with builtins.attrValues,+  -- which sorts via attrSetElems).+  let thunks = map (evaluated . mkStr) (sort (attrSetKeys attrs))+   in pure (VList (clistFromThunks (map thunkToCPtr thunks)))+builtinAttrNames other =+  throwEvalError ("builtins.attrNames: expected a set, got " <> typeName other)++builtinAttrValues :: (MonadEval m) => NixValue -> m NixValue+builtinAttrValues (VAttrs attrs) =+  pure (VList (clistFromThunks (map thunkToCPtr (attrSetElems attrs))))+builtinAttrValues other =+  throwEvalError ("builtins.attrValues: expected a set, got " <> typeName other)++builtinListToAttrs :: (MonadEval m) => NixValue -> m NixValue+builtinListToAttrs (VList cl) = do+  let thunks = map Thunk (clistThunks cl)+  pairs <- mapM listToAttrsPair thunks+  -- Nix listToAttrs uses first-wins: if duplicate name, first element wins.+  let firstWins = Map.fromListWith (\_ kept -> kept) pairs+  pure (VAttrs (attrSetFromMap firstWins))+builtinListToAttrs other =+  throwEvalError ("builtins.listToAttrs: expected a list, got " <> typeName other)++-- | Extract { name, value } from a thunk for listToAttrs.+listToAttrsPair :: (MonadEval m) => Thunk -> m (Text, Thunk)+listToAttrsPair thunk = do+  val <- force thunk+  case val of+    VAttrs attrs -> do+      nameThunk <-+        maybe (throwEvalError "builtins.listToAttrs: element missing 'name'") pure $+          attrSetLookup "name" attrs+      nameVal <- force nameThunk+      case nameVal of+        VStr keyName _ ->+          case attrSetLookup "value" attrs of+            Just valueThunk -> do+              key <- decodedText "builtins.listToAttrs: attribute name" keyName+              pure (key, valueThunk)+            Nothing -> throwEvalError "builtins.listToAttrs: element missing 'value'"+        _ -> throwEvalError "builtins.listToAttrs: 'name' must be a string"+    _ -> throwEvalError "builtins.listToAttrs: element must be a set"++-- ---------------------------------------------------------------------------+-- Builtin implementations - attr set (arity 2)+-- ---------------------------------------------------------------------------++builtinHasAttr :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinHasAttr (VStr key _) (VAttrs attrs) = do+  name <- decodedText "builtins.hasAttr" key+  pure (VBool (attrSetMember name attrs))+builtinHasAttr (VStr _ _) other =+  throwEvalError ("builtins.hasAttr: expected a set, got " <> typeName other)+builtinHasAttr other _ =+  throwEvalError ("builtins.hasAttr: expected a string, got " <> typeName other)++builtinGetAttr :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinGetAttr (VStr key _) (VAttrs attrs) = do+  name <- decodedText "builtins.getAttr" key+  case attrSetLookup name attrs of+    Just thunk -> force thunk+    Nothing -> throwEvalError ("builtins.getAttr: attribute '" <> name <> "' not found")+builtinGetAttr (VStr _ _) other =+  throwEvalError ("builtins.getAttr: expected a set, got " <> typeName other)+builtinGetAttr other _ =+  throwEvalError ("builtins.getAttr: expected a string, got " <> typeName other)++builtinRemoveAttrs :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinRemoveAttrs (VAttrs attrs) (VList cl) = do+  let thunks = map Thunk (clistThunks cl)+  keys <- mapM forceToString thunks+  pure (VAttrs (attrSetRemoveKeys keys attrs))+  where+    forceToString thunk = do+      val <- force thunk+      case val of+        VStr s _ -> decodedText "builtins.removeAttrs" s+        _ -> throwEvalError "builtins.removeAttrs: key list must contain strings"+builtinRemoveAttrs (VAttrs _) other =+  throwEvalError ("builtins.removeAttrs: expected a list, got " <> typeName other)+builtinRemoveAttrs other _ =+  throwEvalError ("builtins.removeAttrs: expected a set, got " <> typeName other)++builtinIntersectAttrs :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinIntersectAttrs (VAttrs a) (VAttrs b) =+  -- Iterate keys of 'a' (typically the smaller set, e.g. functionArgs)+  -- and point-lookup each in 'b' (typically the large set, e.g. nixpkgs).+  -- This avoids materializing all thunks in 'b'.+  let keysA = attrSetKeys a+      result = Map.fromList [(k, thunk) | k <- keysA, Just thunk <- [attrSetLookup k b]]+   in pure (VAttrs (attrSetFromMap result))+builtinIntersectAttrs (VAttrs _) other =+  throwEvalError ("builtins.intersectAttrs: expected a set, got " <> typeName other)+builtinIntersectAttrs other _ =+  throwEvalError ("builtins.intersectAttrs: expected a set, got " <> typeName other)++builtinCatAttrs :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinCatAttrs (VStr key _) (VList cl) = do+  name <- decodedText "builtins.catAttrs" key+  let thunks = map Thunk (clistThunks cl)+  vals <- catAttrsCollect name thunks+  pure (VList (clistFromThunks (map thunkToCPtr vals)))+builtinCatAttrs (VStr _ _) other =+  throwEvalError ("builtins.catAttrs: expected a list, got " <> typeName other)+builtinCatAttrs other _ =+  throwEvalError ("builtins.catAttrs: expected a string, got " <> typeName other)++-- | Collect values for a given key from a list of attrsets.+-- Tail-recursive with accumulator to avoid stack overflow on large lists.+catAttrsCollect :: (MonadEval m) => Text -> [Thunk] -> m [Thunk]+catAttrsCollect key = go []+  where+    go !acc [] = pure (reverse acc)+    go !acc (thunk : rest) = do+      val <- force thunk+      case val of+        VAttrs attrs ->+          case attrSetLookup key attrs of+            Just found -> go (found : acc) rest+            Nothing -> go acc rest+        _ -> throwEvalError "builtins.catAttrs: list element must be a set"++-- ---------------------------------------------------------------------------+-- Builtin implementations - list higher-order (arity 2)+-- ---------------------------------------------------------------------------++builtinMap :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinMap func (VList cl) =+  -- Lazy: each element is a deferred application, forced only on demand.+  let thunks = map Thunk (clistThunks cl)+   in pure (VList (clistFromThunks (map (thunkToCPtr . deferApply func) thunks)))+builtinMap _ other =+  throwEvalError ("builtins.map: expected a list, got " <> typeName other)++builtinFilter :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinFilter predFn (VList cl) = do+  let thunks = map Thunk (clistThunks cl)+  filtered <- filterThunks predFn thunks+  pure (VList (clistFromThunks (map thunkToCPtr filtered)))+builtinFilter _ other =+  throwEvalError ("builtins.filter: expected a list, got " <> typeName other)++filterThunks :: (MonadEval m) => NixValue -> [Thunk] -> m [Thunk]+filterThunks _ [] = pure []+filterThunks predFn (thunk : rest) = do+  result <- applyValueLazy predFn thunk+  case result of+    VBool True -> (thunk :) <$> filterThunks predFn rest+    VBool False -> filterThunks predFn rest+    _ -> throwEvalError "builtins.filter: predicate must return a bool"++builtinGenList :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinGenList func (VInt n)+  | n < 0 = throwEvalError "builtins.genList: length must be non-negative"+  | otherwise =+      -- Lazy: each element is a deferred @f i@, forced only on demand.+      -- Slot 0 = function.+      let fnThunk = evaluated func+          (sp, sc) = buildCSlots [fnThunk]+          env = newMinimalEnv sp sc+          mkIndexThunk i = mkThunk env (EApp (EResolvedVar 0 0) (ELit (NixInt i)))+       in pure (VList (clistFromThunks (map (thunkToCPtr . mkIndexThunk) [0 .. n - 1])))+builtinGenList _ other =+  throwEvalError ("builtins.genList: expected an integer, got " <> typeName other)++builtinSort :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinSort comparator (VList cl) = do+  let thunks = map Thunk (clistThunks cl)+  vals <- mapM force thunks+  sorted <- mergeSort comparator vals+  pure (VList (clistFromThunks (map (thunkToCPtr . evaluated) sorted)))+builtinSort _ other =+  throwEvalError ("builtins.sort: expected a list, got " <> typeName other)++-- | Stable O(n log n) merge sort using a user-supplied comparator.+-- The comparator takes two args (curried) and returns bool.+mergeSort :: (MonadEval m) => NixValue -> [NixValue] -> m [NixValue]+mergeSort _ [] = pure []+mergeSort _ [x] = pure [x]+mergeSort cmp xs = do+  let half = length xs `div` 2+      (left, right) = splitAt half xs+  sortedLeft <- mergeSort cmp left+  sortedRight <- mergeSort cmp right+  mergeSorted cmp sortedLeft sortedRight++mergeSorted :: (MonadEval m) => NixValue -> [NixValue] -> [NixValue] -> m [NixValue]+mergeSorted _ [] ys = pure ys+mergeSorted _ xs [] = pure xs+mergeSorted cmp (x : xs) (y : ys) = do+  -- Stable merge: take the right element only when it is STRICTLY less than the+  -- left (@cmp y x@).  On a tie - neither strictly less - take the left element,+  -- so comparator-equal elements keep their input order, matching C++ Nix's+  -- std::stable_sort.  (Comparing @cmp x y@ instead would emit @y@ on a tie and+  -- reverse equal runs.)+  partial <- applyValue cmp y+  result <- applyValue partial x+  case result of+    VBool True -> (y :) <$> mergeSorted cmp (x : xs) ys+    VBool False -> (x :) <$> mergeSorted cmp xs (y : ys)+    _ -> throwEvalError "builtins.sort: comparator must return a bool"++builtinConcatMap :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinConcatMap func (VList cl) = do+  -- Semi-eager: must force each application to discover list structure for+  -- concatenation, but element thunks within those sub-lists stay lazy.+  let thunks = map Thunk (clistThunks cl)+      deferredApps = map (deferApply func) thunks+  results <- mapM force deferredApps+  concatted <- mapM extractList results+  pure (VList (clistFromThunks (map thunkToCPtr (concat concatted))))+builtinConcatMap _ other =+  throwEvalError ("builtins.concatMap: expected a list, got " <> typeName other)++extractList :: (MonadEval m) => NixValue -> m [Thunk]+extractList (VList cl) = pure (map Thunk (clistThunks cl))+extractList other =+  throwEvalError ("builtins.concatMap: function must return a list, got " <> typeName other)++builtinAny :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinAny predFn (VList cl) = do+  let thunks = map Thunk (clistThunks cl)+  result <- anyThunk predFn thunks+  pure (VBool result)+builtinAny _ other =+  throwEvalError ("builtins.any: expected a list, got " <> typeName other)++anyThunk :: (MonadEval m) => NixValue -> [Thunk] -> m Bool+anyThunk _ [] = pure False+anyThunk predFn (thunk : rest) = do+  result <- applyValueLazy predFn thunk+  case result of+    VBool True -> pure True+    VBool False -> anyThunk predFn rest+    _ -> throwEvalError "builtins.any: predicate must return a bool"++builtinAll :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinAll predFn (VList cl) = do+  let thunks = map Thunk (clistThunks cl)+  result <- allThunk predFn thunks+  pure (VBool result)+builtinAll _ other =+  throwEvalError ("builtins.all: expected a list, got " <> typeName other)++allThunk :: (MonadEval m) => NixValue -> [Thunk] -> m Bool+allThunk _ [] = pure True+allThunk predFn (thunk : rest) = do+  result <- applyValueLazy predFn thunk+  case result of+    VBool True -> allThunk predFn rest+    VBool False -> pure False+    _ -> throwEvalError "builtins.all: predicate must return a bool"++builtinElem :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinElem needle (VList cl) = do+  let thunks = map Thunk (clistThunks cl)+  found <- elemCheck needle thunks+  pure (VBool found)+builtinElem _ other =+  throwEvalError ("builtins.elem: expected a list, got " <> typeName other)++elemCheck :: (MonadEval m) => NixValue -> [Thunk] -> m Bool+elemCheck _ [] = pure False+elemCheck needle (thunk : rest) = do+  val <- force thunk+  eq <- nixEqual force needle val+  if eq then pure True else elemCheck needle rest++builtinElemAt :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinElemAt (VList cl) (VInt idx)+  | idx < 0 || fromIntegral idx >= clistLen cl = elemAtOOB idx cl+  | otherwise =+      -- O(1) direct C array access instead of materializing the whole list+      let ptr = unsafePerformIO (clistGet (unCList cl) (fromIntegral idx))+       in force (Thunk ptr)+  where+    elemAtOOB i c =+      throwEvalError+        ( "builtins.elemAt: index "+            <> T.pack (show i)+            <> " out of bounds for list of length "+            <> T.pack (show (clistLen c))+        )+builtinElemAt (VList _) other =+  throwEvalError ("builtins.elemAt: expected an integer, got " <> typeName other)+builtinElemAt other _ =+  throwEvalError ("builtins.elemAt: expected a list, got " <> typeName other)++builtinPartition :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinPartition predFn (VList cl) = do+  let thunks = map Thunk (clistThunks cl)+  (rightThunks, wrongThunks) <- partitionThunks predFn thunks+  pure+    ( VAttrs+        ( attrSetFromMap $+            Map.fromList+              [ ("right", evaluated (VList (clistFromThunks (map thunkToCPtr rightThunks)))),+                ("wrong", evaluated (VList (clistFromThunks (map thunkToCPtr wrongThunks))))+              ]+        )+    )+builtinPartition _ other =+  throwEvalError ("builtins.partition: expected a list, got " <> typeName other)++-- | Tail-recursive partition with accumulator to avoid stack overflow.+partitionThunks :: (MonadEval m) => NixValue -> [Thunk] -> m ([Thunk], [Thunk])+partitionThunks predFn = go [] []+  where+    go !rs !ws [] = pure (reverse rs, reverse ws)+    go !rs !ws (thunk : rest) = do+      result <- applyValueLazy predFn thunk+      case result of+        VBool True -> go (thunk : rs) ws rest+        VBool False -> go rs (thunk : ws) rest+        _ -> throwEvalError "builtins.partition: predicate must return a bool"++builtinGroupBy :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinGroupBy func (VList cl) = do+  let thunks = map Thunk (clistThunks cl)+  groups <- groupByCollect func thunks Map.empty+  pure (VAttrs (attrSetFromMap (Map.map (evaluated . VList . clistFromThunks . map thunkToCPtr . reverse) groups)))+builtinGroupBy _ other =+  throwEvalError ("builtins.groupBy: expected a list, got " <> typeName other)++groupByCollect ::+  (MonadEval m) =>+  NixValue ->+  [Thunk] ->+  Map Text [Thunk] ->+  m (Map Text [Thunk])+groupByCollect _ [] acc = pure acc+groupByCollect func (thunk : rest) acc = do+  result <- applyValueLazy func thunk+  case result of+    VStr key _ -> do+      name <- decodedText "builtins.groupBy" key+      groupByCollect func rest (Map.insertWith (++) name [thunk] acc)+    _ -> throwEvalError "builtins.groupBy: function must return a string"++-- ---------------------------------------------------------------------------+-- Builtin implementations - string (arity 2)+-- ---------------------------------------------------------------------------++builtinConcatStringsSep :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinConcatStringsSep (VStr sep sepCtx) (VList cl) = do+  let thunks = map Thunk (clistThunks cl)+  pairs <- mapM forceToStrCtx thunks+  let texts = map fst pairs+      mergedCtx = sepCtx <> mconcat (map snd pairs)+  pure (VStr (BS.intercalate sep texts) mergedCtx)+  where+    forceToStrCtx thunk = do+      val <- force thunk+      -- Nix 2.24's coerceToString defaults copyToStore=true and+      -- concatStringsSep passes no override, so a path element is copied into+      -- the store and the result carries its SCPlain context.  Non-string+      -- scalars still error (coerceMore=False), matching interpolation.+      coerceToStringInterp val+builtinConcatStringsSep (VStr _ _) other =+  throwEvalError ("builtins.concatStringsSep: expected a list, got " <> typeName other)+builtinConcatStringsSep other _ =+  throwEvalError ("builtins.concatStringsSep: expected a string, got " <> typeName other)++-- ---------------------------------------------------------------------------+-- Builtin implementations - arity 3+-- ---------------------------------------------------------------------------++builtinFoldl :: (MonadEval m) => NixValue -> NixValue -> NixValue -> m NixValue+builtinFoldl op initial (VList cl) =+  foldlStrict op initial (map Thunk (clistThunks cl))+builtinFoldl _ _ other =+  throwEvalError ("builtins.foldl': expected a list, got " <> typeName other)++-- | Strict left fold: apply @op acc elem@ for each element.  The+-- accumulator is forced each step (upstream foldl' strictness); the+-- element is passed as an unforced thunk, as upstream does.+foldlStrict :: (MonadEval m) => NixValue -> NixValue -> [Thunk] -> m NixValue+foldlStrict _ acc [] = pure acc+foldlStrict op acc (thunk : rest) = do+  partial <- applyValue op acc+  stepped <- applyValueLazy partial thunk+  foldlStrict op stepped rest++-- | Byte-indexed slicing, as upstream: offsets and lengths count bytes,+-- and a slice may fall mid-codepoint (the resulting bytes are the value).+builtinSubstring :: (MonadEval m) => NixValue -> NixValue -> NixValue -> m NixValue+builtinSubstring (VInt start) (VInt len) (VStr s ctx)+  | start < 0 = throwEvalError "builtins.substring: negative start position"+  | otherwise =+      let startPos = fromIntegral start+          -- Nix clamps len to available length (negative len means rest of string)+          available = BS.length s - startPos+          clampedLen =+            if len < 0+              then available+              else min (fromIntegral len) available+       in -- Context is preserved through substring (matching real Nix).+          pure (VStr (BS.take clampedLen (BS.drop startPos s)) ctx)+builtinSubstring _ _ (VStr _ _) =+  throwEvalError "builtins.substring: start and length must be integers"+builtinSubstring _ _ other =+  throwEvalError ("builtins.substring: expected a string, got " <> typeName other)++-- ---------------------------------------------------------------------------+-- Builtin helpers+-- ---------------------------------------------------------------------------++-- | Build a thunk that defers @f arg@ - the application only happens when+-- the thunk is forced.  Reuses the existing eval machinery via a synthetic+-- @EApp (EResolvedVar 0 0) (EResolvedVar 0 1)@ in a self-contained env.+-- Slot 0 = function, slot 1 = argument.+deferApply :: NixValue -> Thunk -> Thunk+deferApply func argThunk =+  let (sp, sc) = buildCSlots [evaluated func, argThunk]+      env = newMinimalEnv sp sc+   in mkSyntheticThunk env deferApplyExpr++-- | Shared expression for 'deferApply'.  Allocated once as a CAF.+deferApplyExpr :: Expr+deferApplyExpr = EApp (EResolvedVar 0 0) (EResolvedVar 0 1)+{-# NOINLINE deferApplyExpr #-}++-- | Permissive coercion used by @builtins.toString@.+--+-- Like 'coerceToString' but additionally handles lists: elements are+-- recursively coerced and joined with spaces, matching real Nix semantics.+-- @toString [1 2 3]@ gives @"1 2 3"@.+-- | The non-copying path coercion: the path text verbatim, no context -+-- upstream's @coerceToString@ with @copyToStore = false@.+coercePathVerbatim :: (MonadEval m) => Text -> m (BS.ByteString, StringContext)+coercePathVerbatim p = pure (TE.encodeUtf8 p, emptyContext)++-- | The copy-to-store path coercion: the path becomes its source store+-- path, carried in the context - upstream's @copyToStore = true@.  Passed+-- into 'coerceToString' so a path reached through an attrset's @outPath@+-- (or a @__toString@ result) coerces exactly as the same path written+-- directly would; a fixed path case in the engine dropped both the copy+-- and the context for every such value.+coercePathToStore :: (MonadEval m) => Text -> m (BS.ByteString, StringContext)+coercePathToStore p = do+  (spText, ctx) <- sourcePathWithContext p+  pure (TE.encodeUtf8 spText, ctx)++coerceToStringPermissive :: (MonadEval m) => NixValue -> m (BS.ByteString, StringContext)+coerceToStringPermissive (VList cl) = do+  let thunks = map Thunk (clistThunks cl)+  parts <- mapM coerceThunk thunks+  let texts = map fst parts+      ctx = mconcat (map snd parts)+  pure (BS.intercalate " " texts, ctx)+  where+    coerceThunk thunk = do+      val <- force thunk+      coerceToStringPermissive val+coerceToStringPermissive other = coerceToString True force applyValue coercePathVerbatim other++-- | Coerce a value to a string for a DERIVATION field (an env value or an+-- arg).  Like 'coerceToStringPermissive', but a path literal is copied into+-- the store: it becomes its source store path, with that path added to the+-- string context so it lands in the derivation's @inputSrcs@ - matching C+++-- Nix's copy-to-store coercion of paths in derivation arguments/environment.+coerceToStoreString :: (MonadEval m) => NixValue -> m (BS.ByteString, StringContext)+coerceToStoreString (VList cl) = do+  let thunks = map Thunk (clistThunks cl)+  parts <- mapM (force >=> coerceToStoreString) thunks+  pure (BS.intercalate " " (map fst parts), mconcat (map snd parts))+coerceToStoreString other = coerceToString True force applyValue coercePathToStore other++-- | Coerce a value for string interpolation (@"${...}"@).  Like+-- 'coerceToString', but a path literal is copied into the store and replaced by+-- its source store path (with context) - matching C++ Nix, where interpolation+-- uses @copyToStore = true@, unlike 'builtins.toString', which does not copy.+coerceToStringInterp :: (MonadEval m) => NixValue -> m (BS.ByteString, StringContext)+coerceToStringInterp = coerceToString False force applyValue coercePathToStore++-- | The store path a source path literal coerces to, carrying its+-- SCPlain context - the copy-to-store coercion shared by interpolation,+-- derivation arguments/env values, and @builtins.toJSON@.+sourcePathWithContext :: (MonadEval m) => Text -> m (Text, StringContext)+sourcePathWithContext p+  -- An already-in-store path coerces to itself with an SCPlain (Opaque)+  -- reference - never re-copied.  This is the shared choke point for+  -- interpolation, derivation args, concatStringsSep, and toJSON, so all of+  -- them inherit the in-store short-circuit (and the Windows re-NAR fix).+  | Just sp <- enclosingStorePath p = pure (p, plainContext sp)+  | otherwise = do+      spText <- storeSourcePath p+      case parseStorePath defaultStoreDir spText of+        Just sp -> pure (spText, plainContext sp)+        Nothing -> pure (spText, mempty)++-- | The current system platform string.+currentSystemStr :: Text+currentSystemStr = case (System.Info.arch, System.Info.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) -> T.pack arch <> "-" <> T.pack os++-- | Store dir with trailing slash, for building store paths.+storeDirPrefix :: Text+storeDirPrefix = defaultStoreDirText <> "/"++-- ---------------------------------------------------------------------------+-- Builtin implementations - numeric + context+-- ---------------------------------------------------------------------------++isPathVal :: NixValue -> Bool+isPathVal (VPath _) = True+isPathVal _ = False++builtinCeil :: (MonadEval m) => NixValue -> m NixValue+builtinCeil (VFloat f) = intFromDouble "builtins.ceil" ceiling f+builtinCeil (VInt n) = pure (VInt n)+builtinCeil other = throwEvalError ("builtins.ceil: expected a number, got " <> typeName other)++builtinFloor :: (MonadEval m) => NixValue -> m NixValue+builtinFloor (VFloat f) = intFromDouble "builtins.floor" floor f+builtinFloor (VInt n) = pure (VInt n)+builtinFloor other = throwEvalError ("builtins.floor: expected a number, got " <> typeName other)++-- | Round a float to Int64 with the checks Nix 2.24 performs: NaN,+-- infinity, and out-of-range values are eval errors, never a garbage+-- Int64 out of Haskell's unchecked conversion.+intFromDouble :: (MonadEval m) => Text -> (Double -> Integer) -> Double -> m NixValue+intFromDouble ctx rounder f+  | isNaN f = throwEvalError (ctx <> ": NaN cannot be converted to an integer")+  | isInfinite f = throwEvalError (ctx <> ": infinity cannot be converted to an integer")+  | rounded < toInteger (minBound :: Int64) || rounded > toInteger (maxBound :: Int64) =+      throwEvalError (ctx <> ": " <> formatNixFloat f <> " is out of integer range")+  | otherwise = pure (VInt (fromInteger rounded))+  where+    rounded = rounder f++builtinDiscardContext :: (MonadEval m) => NixValue -> m NixValue+builtinDiscardContext (VStr s _) = pure (mkStrBytes s)+builtinDiscardContext other =+  throwEvalError ("builtins.unsafeDiscardStringContext: expected a string, got " <> typeName other)++-- | @builtins.unsafeDiscardOutputDependency@ - downgrade all-outputs (DrvDeep)+-- references to a plain (Opaque) reference on the same @.drv@ path, and KEEP+-- derivation-output (Built) references unchanged, matching upstream.  It+-- formerly dropped both kinds and kept only plain references, losing the+-- @.drv@ reference the downgrade must preserve.  'Set.map' also folds a+-- downgraded element into an existing plain reference on the same path.+builtinDiscardOutputDep :: (MonadEval m) => NixValue -> m NixValue+builtinDiscardOutputDep (VStr s (StringContext ctx)) =+  pure (VStr s (StringContext (Set.map downgrade ctx)))+  where+    downgrade (SCAllOutputs sp) = SCPlain sp+    downgrade other = other+builtinDiscardOutputDep other =+  throwEvalError ("builtins.unsafeDiscardOutputDependency: expected a string, got " <> typeName other)++-- | @builtins.addDrvOutputDependencies@ - the inverse of the+-- 'builtinDiscardOutputDep' downgrade for a single element: upgrade a plain+-- (Opaque) reference to a @.drv@ path into an all-outputs (DrvDeep) reference.+-- Upstream requires the string's context to have exactly one element and+-- errors on a derivation-output (Built) element or a non-@.drv@ plain path.+builtinAddDrvOutputDeps :: (MonadEval m) => NixValue -> m NixValue+builtinAddDrvOutputDeps (VStr s (StringContext ctx)) =+  case Set.toList ctx of+    [only] -> VStr s . StringContext . Set.singleton <$> upgrade only+    _ ->+      throwEvalError+        ( "builtins.addDrvOutputDependencies: the string must have exactly one "+            <> "context element, but has "+            <> T.pack (show (Set.size ctx))+        )+  where+    upgrade (SCPlain sp)+      | ".drv" `T.isSuffixOf` spName sp = pure (SCAllOutputs sp)+      | otherwise =+          throwEvalError+            ( "builtins.addDrvOutputDependencies: path "+                <> storePathToText defaultStoreDir sp+                <> " is not a derivation"+            )+    upgrade (SCAllOutputs sp) = pure (SCAllOutputs sp)+    upgrade (SCDrvOutput _ outName) =+      throwEvalError+        ( "builtins.addDrvOutputDependencies: can only act on derivations, not "+            <> "on a derivation output such as "+            <> outName+        )+builtinAddDrvOutputDeps other =+  throwEvalError ("builtins.addDrvOutputDependencies: expected a string, got " <> typeName other)++-- | Check whether a string has any context elements.+builtinHasContext :: (MonadEval m) => NixValue -> m NixValue+builtinHasContext (VStr _ ctx) = pure (VBool (ctx /= emptyContext))+builtinHasContext other =+  throwEvalError ("builtins.hasContext: expected a string, got " <> typeName other)++-- | Return the context of a string as an attrset.+--+-- Each key is a store path string.  Each value is an attrset with:+--   - @path@: true if there's a SCPlain reference+--   - @allOutputs@: true if there's a SCAllOutputs reference+--   - @outputs@: list of output names from SCDrvOutput references+builtinGetContext :: (MonadEval m) => NixValue -> m NixValue+builtinGetContext (VStr _ (StringContext ctx)) = do+  let grouped = groupContextByPath (Set.toList ctx)+      attrMap = Map.map contextEntryToAttrs grouped+  pure (VAttrs (attrSetFromMap attrMap))+builtinGetContext other =+  throwEvalError ("builtins.getContext: expected a string, got " <> typeName other)++-- | Intermediate representation for grouping context elements by store path.+data ContextEntry = ContextEntry+  { cePath :: !Bool,+    ceAllOutputs :: !Bool,+    ceOutputs :: ![Text]+  }++-- | Group context elements by their store path.+groupContextByPath :: [StringContextElement] -> Map Text ContextEntry+groupContextByPath = foldl' addElement Map.empty+  where+    addElement acc (SCPlain sp) =+      Map.insertWith mergeEntry (spToText sp) (ContextEntry True False []) acc+    addElement acc (SCDrvOutput sp outName) =+      Map.insertWith mergeEntry (spToText sp) (ContextEntry False False [outName]) acc+    addElement acc (SCAllOutputs sp) =+      Map.insertWith mergeEntry (spToText sp) (ContextEntry False True []) acc+    -- 'Set.toList' feeds elements in ascending order and 'old' holds the+    -- earlier (smaller) output names, so 'old ++ new' keeps the rendered+    -- outputs list ascending, as upstream getContext produces it.+    mergeEntry new old =+      ContextEntry+        (cePath new || cePath old)+        (ceAllOutputs new || ceAllOutputs old)+        (ceOutputs old ++ ceOutputs new)+    -- Context keys are identity, not IO: always the canonical /nix/store+    -- spelling, never the platform file-path mapping.+    spToText = storePathToText defaultStoreDir++-- | Convert a ContextEntry to an attrset thunk.+contextEntryToAttrs :: ContextEntry -> Thunk+contextEntryToAttrs entry =+  let fields =+        [("path", evaluated (VBool True)) | cePath entry]+          ++ [("allOutputs", evaluated (VBool True)) | ceAllOutputs entry]+          ++ [("outputs", evaluated (VList (clistFromThunks [thunkToCPtr (evaluated (mkStr o)) | o <- ceOutputs entry]))) | not (null (ceOutputs entry))]+   in evaluated (VAttrs (attrSetFromMap (Map.fromList fields)))++-- | Append context entries to a string from an attrset.+--+-- @builtins.appendContext string contextAttrset@ adds the specified+-- context elements to the string.+builtinAppendContext :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinAppendContext (VStr s ctx) (VAttrs contextAttrs) = do+  newCtx <- parseContextAttrs (attrSetToMap contextAttrs)+  pure (VStr s (ctx <> newCtx))+builtinAppendContext (VStr _ _) other =+  throwEvalError ("builtins.appendContext: second argument must be a set, got " <> typeName other)+builtinAppendContext other _ =+  throwEvalError ("builtins.appendContext: first argument must be a string, got " <> typeName other)++-- | Parse a context attrset into a StringContext.+-- Each key is a store path; each value is an attrset with optional+-- @path@, @allOutputs@, and @outputs@ fields.+parseContextAttrs :: (MonadEval m) => Map Text Thunk -> m StringContext+parseContextAttrs attrs = do+  elements <- mapM parseOneCtx (Map.toList attrs)+  pure (StringContext (Set.fromList (concat elements)))+  where+    parseOneCtx (pathText, thunk) = do+      val <- force thunk+      case val of+        VAttrs inner -> do+          -- The key must parse as a store path, as upstream requires; a+          -- fabricated identity here would flow into derivation inputs.+          sp <- case parseStorePath defaultStoreDir pathText of+            Just parsed -> pure parsed+            Nothing ->+              throwEvalError+                ("builtins.appendContext: context key is not a store path: " <> pathText)+          hasPath <- getBoolAttr "path" inner+          hasAllOuts <- getBoolAttr "allOutputs" inner+          outNames <- getOutputsList inner+          let pathElems = [SCPlain sp | hasPath]+              allOutElems = [SCAllOutputs sp | hasAllOuts]+              outElems = [SCDrvOutput sp o | o <- outNames]+          pure (pathElems ++ allOutElems ++ outElems)+        _ -> throwEvalError "builtins.appendContext: context entry must be a set"++    getBoolAttr key attrs' = case attrSetLookup key attrs' of+      Nothing -> pure False+      Just thunk -> do+        val <- force thunk+        case val of+          VBool b -> pure b+          _ -> pure False++    getOutputsList attrs' = case attrSetLookup "outputs" attrs' of+      Nothing -> pure []+      Just thunk -> do+        val <- force thunk+        case val of+          VList cl -> mapM (forceToOutputName . Thunk) (clistThunks cl)+          _ -> pure []++    forceToOutputName thunk = do+      val <- force thunk+      case val of+        VStr s _ -> decodedText "builtins.appendContext" s+        _ -> throwEvalError "builtins.appendContext: output name must be a string"++-- | For a STRING operand, upstream's rule is textual: everything after+-- the final @/@.  For a PATH operand the value may be native-spelled+-- (eval's base dir can be a native Windows path), so the split is+-- separator-aware via 'canonBaseName'.+builtinBaseNameOf :: (MonadEval m) => NixValue -> m NixValue+builtinBaseNameOf (VStr s ctx) = pure (VStr (lastComponentBytes s) ctx)+builtinBaseNameOf (VPath p) = pure (mkStr (canonBaseName p))+builtinBaseNameOf other =+  throwEvalError ("builtins.baseNameOf: expected a string or path, got " <> typeName other)++-- | Byte-level last component for string operands ('/' is a single byte+-- in UTF-8 and never occurs inside a multi-byte sequence).+lastComponentBytes :: BS.ByteString -> BS.ByteString+lastComponentBytes t = case reverse (filter (not . BS.null) (BC.split '/' t)) of+  [] -> ""+  (final : _) -> final++-- | String operands keep upstream's textual '/'-only rule+-- ('dirComponentBytes'); path operands may be native-spelled and split+-- separator-aware via 'canonDirName'.+builtinDirOf :: (MonadEval m) => NixValue -> m NixValue+builtinDirOf (VStr s ctx) = pure (VStr (dirComponentBytes s) ctx)+builtinDirOf (VPath p) = pure (VPath (canonDirName p))+builtinDirOf other =+  throwEvalError ("builtins.dirOf: expected a string or path, got " <> typeName other)++-- | Byte-level dir component for string operands: everything before the+-- last '/' byte, with upstream's "." / "/" edge results.+dirComponentBytes :: BS.ByteString -> BS.ByteString+dirComponentBytes t =+  case BC.elemIndexEnd '/' t of+    Nothing -> "."+    Just idx ->+      let dir = BS.take idx t+       in if BS.null dir then "/" else dir++builtinConcatLists :: (MonadEval m) => NixValue -> m NixValue+builtinConcatLists (VList cl) = do+  let thunks = map Thunk (clistThunks cl)+  sublists <- mapM forceThenExtractList thunks+  pure (VList (clistFromThunks (concat sublists)))+  where+    forceThenExtractList thunk = do+      val <- force thunk+      case val of+        VList innerCl -> pure (clistThunks innerCl)+        _ -> throwEvalError "builtins.concatLists: element must be a list"+builtinConcatLists other =+  throwEvalError ("builtins.concatLists: expected a list, got " <> typeName other)++builtinLessThan :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinLessThan a b = VBool <$> nixCompare force a b++-- ---------------------------------------------------------------------------+-- Builtin implementations - arithmetic + bitwise+-- ---------------------------------------------------------------------------++builtinAdd :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinAdd (VInt a) (VInt b) = either throwEvalError (pure . VInt) (checkedAdd a b)+builtinAdd (VInt a) (VFloat b) = pure (VFloat (fromIntegral a + b))+builtinAdd (VFloat a) (VInt b) = pure (VFloat (a + fromIntegral b))+builtinAdd (VFloat a) (VFloat b) = pure (VFloat (a + b))+builtinAdd l r = throwEvalError ("builtins.add: expected numbers, got " <> typeName l <> " and " <> typeName r)++builtinSub :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinSub (VInt a) (VInt b) = either throwEvalError (pure . VInt) (checkedSub a b)+builtinSub (VInt a) (VFloat b) = pure (VFloat (fromIntegral a - b))+builtinSub (VFloat a) (VInt b) = pure (VFloat (a - fromIntegral b))+builtinSub (VFloat a) (VFloat b) = pure (VFloat (a - b))+builtinSub l r = throwEvalError ("builtins.sub: expected numbers, got " <> typeName l <> " and " <> typeName r)++builtinMul :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinMul (VInt a) (VInt b) = either throwEvalError (pure . VInt) (checkedMul a b)+builtinMul (VInt a) (VFloat b) = pure (VFloat (fromIntegral a * b))+builtinMul (VFloat a) (VInt b) = pure (VFloat (a * fromIntegral b))+builtinMul (VFloat a) (VFloat b) = pure (VFloat (a * b))+builtinMul l r = throwEvalError ("builtins.mul: expected numbers, got " <> typeName l <> " and " <> typeName r)++builtinDiv :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinDiv _ (VInt 0) = throwEvalError "builtins.div: division by zero"+builtinDiv (VInt a) (VInt b)+  -- The one overflowing division: |minBound| has no representation.+  | a == minBound && b == -1 =+      throwEvalError+        ("integer overflow in dividing " <> T.pack (show a) <> " and " <> T.pack (show b))+  | otherwise = pure (VInt (quot a b))+builtinDiv _ (VFloat 0) = throwEvalError "builtins.div: division by zero"+builtinDiv (VInt a) (VFloat b) = pure (VFloat (fromIntegral a / b))+builtinDiv (VFloat a) (VInt b) = pure (VFloat (a / fromIntegral b))+builtinDiv (VFloat a) (VFloat b) = pure (VFloat (a / b))+builtinDiv l r = throwEvalError ("builtins.div: expected numbers, got " <> typeName l <> " and " <> typeName r)++builtinBitAnd :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinBitAnd (VInt a) (VInt b) = pure (VInt (a .&. b))+builtinBitAnd _ _ = throwEvalError "builtins.bitAnd: expected two integers"++builtinBitOr :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinBitOr (VInt a) (VInt b) = pure (VInt (a .|. b))+builtinBitOr _ _ = throwEvalError "builtins.bitOr: expected two integers"++builtinBitXor :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinBitXor (VInt a) (VInt b) = pure (VInt (xor a b))+builtinBitXor _ _ = throwEvalError "builtins.bitXor: expected two integers"++-- ---------------------------------------------------------------------------+-- Builtin implementations - attr set higher-order+-- ---------------------------------------------------------------------------++builtinMapAttrs :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinMapAttrs func (VAttrs attrs) =+  -- Each attr value is a deferred @f key val@, forced only on demand.+  -- Eagerly builds all thunks via attrSetMapWithKey - with C arena thunks+  -- (~16 bytes each), this is cheaper than the former MappedAttrs overhead+  -- and keeps all data off the GHC heap.+  -- Slot 0 = function, slot 1 = key, slot 2 = value.+  pure (VAttrs (attrSetMapWithKey deferAttr attrs))+  where+    deferAttr key valThunk =+      let (sp, sc) = buildCSlots [evaluated func, evaluated (mkStr key), valThunk]+          env = newMinimalEnv sp sc+       in mkSyntheticThunk env mapAttrsExpr+builtinMapAttrs _ other =+  throwEvalError ("builtins.mapAttrs: expected a set, got " <> typeName other)++-- | Shared expression for 'builtinMapAttrs'.  Allocated once as a CAF.+mapAttrsExpr :: Expr+mapAttrsExpr = EApp (EApp (EResolvedVar 0 0) (EResolvedVar 0 1)) (EResolvedVar 0 2)+{-# NOINLINE mapAttrsExpr #-}++-- | Formals for lambdas, an empty set for builtins, an error otherwise -+-- including functor sets: upstream's primop never consults+-- @__functionArgs@ (nixpkgs lib.functionArgs handles that in Nix).+builtinFunctionArgs :: (MonadEval m) => NixValue -> m NixValue+builtinFunctionArgs (VLambda _ formals _) = pure (formalsToAttrs formals)+builtinFunctionArgs (VBuiltin _ _) = pure (VAttrs (attrSetFromMap Map.empty))+builtinFunctionArgs other =+  throwEvalError ("builtins.functionArgs: expected a function, got " <> typeName other)++formalsToAttrs :: EvalFormals -> NixValue+formalsToAttrs (EFName _) = VAttrs (attrSetFromMap Map.empty)+formalsToAttrs (EFSet formals _) = formalsListToAttrs formals+formalsToAttrs (EFNamedSet _ formals _) = formalsListToAttrs formals++formalsListToAttrs :: [EvalFormal] -> NixValue+formalsListToAttrs formals =+  VAttrs+    $ attrSetFromMap+    $ Map.fromList+      [(efName f, evaluated (VBool (isJust (efDefault f)))) | f <- formals]++builtinZipAttrsWith :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinZipAttrsWith func (VList cl) = do+  let thunks = map Thunk (clistThunks cl)+  attrSets <- mapM forceToAttrSet thunks+  let merged = mergeAllAttrs attrSets+      -- Lazy: each result is a deferred f(name)(values) thunk, not eagerly+      -- evaluated.  Critical for nixpkgs evalModules fixpoint - config is a+      -- self-referencing lazy attrset that must be COMPUTED (holding the lazy+      -- result) before any individual attribute thunks are forced.+      resultPairs = map (deferZip func) (Map.toList merged)+  pure (VAttrs (attrSetFromMap (Map.fromList resultPairs)))+  where+    forceToAttrSet thunk = do+      val <- force thunk+      case val of+        VAttrs attrs -> pure (attrSetToMap attrs)+        _ -> throwEvalError "builtins.zipAttrsWith: list element must be a set"+    mergeAllAttrs = foldl' (\acc m -> Map.unionWith (++) acc (Map.map (: []) m)) Map.empty+    -- Slot 0 = function, slot 1 = name, slot 2 = values list.+    deferZip fn (key, thunkList) =+      let valueList = VList (clistFromThunks (map thunkToCPtr thunkList))+          (slots, slotCount) = buildCSlots [evaluated fn, evaluated (mkStr key), evaluated valueList]+          env = newMinimalEnv slots slotCount+       in (key, mkSyntheticThunk env mapAttrsExpr)+builtinZipAttrsWith _ other =+  throwEvalError ("builtins.zipAttrsWith: expected a list, got " <> typeName other)++-- ---------------------------------------------------------------------------+-- Builtin implementations - string manipulation+-- ---------------------------------------------------------------------------++builtinReplaceStrings ::+  (MonadEval m) => NixValue -> NixValue -> NixValue -> m NixValue+builtinReplaceStrings (VList fromCl) (VList toCl) (VStr input inputCtx) = do+  let fromThunks = map Thunk (clistThunks fromCl)+      toThunks = map Thunk (clistThunks toCl)+  when (length fromThunks /= length toThunks) $+    throwEvalError "builtins.replaceStrings: 'from' and 'to' must have the same length"+  froms <- mapM forceStr fromThunks+  -- Match-gated replacement forcing, as upstream: a 'to' element is+  -- forced - and its context joins the result - only the first time its+  -- pattern matches, memoized per index.  An unmatched replacement is+  -- never evaluated, so it may throw or diverge harmlessly.+  --+  -- Matching and stepping are byte-level, as upstream: an empty @from@+  -- element inserts between BYTES, so a 2-byte character gets a+  -- replacement inside it.  Chunks accumulate reversed, one BS.concat+  -- at the end.+  let rules = zip3 [0 :: Int ..] (map fst froms) toThunks+      findRule txt =+        listToMaybe [r | r@(_, from, _) <- rules, BS.null from || from `BS.isPrefixOf` txt]+      forcedTo memo (i, _, toThunk) = case Map.lookup i memo of+        Just hit -> pure (hit, memo)+        Nothing -> do+          forced <- forceStr toThunk+          pure (forced, Map.insert i forced memo)+      step remaining acc memo+        | BS.null remaining = case findRule remaining of+            -- At end of string, still check for an empty-from match.+            Just rule -> do+              ((to, _), advanced) <- forcedTo memo rule+              pure (to : acc, advanced)+            Nothing -> pure (acc, memo)+        | otherwise = case findRule remaining of+            Just rule@(_, from, _) -> do+              ((to, _), advanced) <- forcedTo memo rule+              if BS.null from+                then case BS.uncons remaining of+                  -- empty-from: insert replacement then advance ONE BYTE+                  Just (byte, after) -> step after (BS.singleton byte : to : acc) advanced+                  -- Unreachable: the null string is handled by the guard above.+                  Nothing -> pure (to : acc, advanced)+                else step (BS.drop (BS.length from) remaining) (to : acc) advanced+            Nothing -> case BS.uncons remaining of+              Just (byte, after) -> step after (BS.singleton byte : acc) memo+              Nothing -> pure (acc, memo)+  (revChunks, forcedTos) <- step input [] Map.empty+  let mergedCtx = inputCtx <> mconcat (map snd (Map.elems forcedTos))+  pure (VStr (BS.concat (reverse revChunks)) mergedCtx)+  where+    forceStr thunk = do+      val <- force thunk+      case val of+        VStr s ctx -> pure (s, ctx)+        _ -> throwEvalError "builtins.replaceStrings: elements must be strings"+builtinReplaceStrings _ _ (VStr _ _) =+  throwEvalError "builtins.replaceStrings: first two arguments must be lists"+builtinReplaceStrings _ _ other =+  throwEvalError ("builtins.replaceStrings: expected a string, got " <> typeName other)++-- ---------------------------------------------------------------------------+-- Builtin implementations - regex (POSIX ERE via regex-tdfa)+-- ---------------------------------------------------------------------------++-- ---------------------------------------------------------------------------+-- Regex compilation cache+-- ---------------------------------------------------------------------------++-- | Global regex compilation cache.  Keyed by the raw pattern bytes+-- (match and split share entries).  Idempotent memoization via+-- unsafePerformIO - same rationale as thunk memoization.+{-# NOINLINE regexCacheRef #-}+regexCacheRef :: IORef (Map BS.ByteString RE.Regex)+regexCacheRef = unsafePerformIO (newIORef Map.empty)++-- | Compile options matching C++ Nix's POSIX ERE semantics: no multiline+-- mode, so @^@\/@$@ anchor only at the string boundaries and @.@ (and a+-- negated bracket class) match a newline.  regex-tdfa's default has+-- multiline=True, which silently diverges on any subject containing @\n@.+wholeStringCompOpt :: RE.CompOption+wholeStringCompOpt = RE.defaultCompOpt {RE.multiline = False}++-- | Compile a regex from its raw pattern bytes, using the global cache to+-- avoid recompilation.  Compiling from bytes makes the PATTERN byte-level+-- too, matching upstream: a multi-byte character in the pattern is a+-- sequence of single-byte atoms, so it lines up with byte-level subjects.+-- Returns Nothing for invalid patterns.  NOINLINE prevents GHC from+-- inlining and floating the unsafePerformIO reads.+{-# NOINLINE cachedCompileRegex #-}+cachedCompileRegex :: BS.ByteString -> Maybe RE.Regex+cachedCompileRegex pat =+  unsafePerformIO $ do+    cache <- atomicModifyIORef' regexCacheRef (\c -> (c, c))+    case Map.lookup pat cache of+      Just compiled -> pure (Just compiled)+      Nothing -> case RE.makeRegexOptsM wholeStringCompOpt RE.defaultExecOpt pat :: Maybe RE.Regex of+        Nothing -> pure Nothing+        Just compiled -> do+          atomicModifyIORef' regexCacheRef (\c -> (Map.insert pat compiled c, ()))+          pure (Just compiled)++-- ---------------------------------------------------------------------------+-- Regex builtins+-- ---------------------------------------------------------------------------++-- | @builtins.match regex str@: match a POSIX ERE against a string.+-- The regex must match the ENTIRE string (like C++ Nix's regex_match).+-- Returns @null@ if no match, or a list of capture group strings+-- (@null@ for non-participating groups).+builtinMatch :: (MonadEval m) => NixValue -> NixValue -> m NixValue+-- Pre-compiled path: regex was compiled at partial-application time.+builtinMatch (VCompiledRegex (CompiledRegex _ compiled)) (VStr str _) =+  matchWithCompiled compiled str+-- Direct 2-arg call: use global compilation cache.+builtinMatch (VStr regex _) (VStr str _) =+  case cachedCompileRegex regex of+    Nothing -> throwEvalError ("builtins.match: invalid regex: " <> bytesToTextLossy regex)+    Just compiled -> matchWithCompiled compiled str+builtinMatch (VStr _ _) other =+  throwEvalError ("builtins.match: expected a string, got " <> typeName other)+builtinMatch (VCompiledRegex _) other =+  throwEvalError ("builtins.match: expected a string, got " <> typeName other)+builtinMatch other _ =+  throwEvalError ("builtins.match: expected a string (regex), got " <> typeName other)++-- | Shared match logic for pre-compiled and freshly-compiled regex paths.+--+-- Whole-string semantics via a span check on the leftmost-longest match:+-- if any whole-string match exists it starts at 0, and POSIX picks the+-- longest match at the leftmost start, so the reported match spans the+-- whole subject exactly when a whole-string match exists.  This is what+-- regex_match gives C++ Nix; textual @^...$@ anchoring is NOT equivalent+-- (it misparses top-level alternation).+matchWithCompiled :: (MonadEval m) => RE.Regex -> BS.ByteString -> m NixValue+matchWithCompiled compiled str =+  case RE.matchOnceText compiled str of+    Just (beforeMatch, match, afterMatch)+      | BS.null beforeMatch,+        BS.null afterMatch ->+          -- match is an Array of (bytes, (offset, len)) pairs.+          -- Index 0 is the full match; indices 1.. are capture groups.+          let captureGroups = drop 1 (Array.elems match)+              -- A non-participating capture group has offset (-1); C++ Nix+              -- yields null for it, not the empty string.+              toThunk (s, (off, _)) =+                if off < 0 then evaluated VNull else evaluated (mkStrBytes s)+           in pure (VList (clistFromThunks (map (thunkToCPtr . toThunk) captureGroups)))+    _ -> pure VNull++-- | @builtins.split regex str@: split a string by a POSIX ERE.+-- Returns an alternating list of non-matched strings and match-group lists.+-- Example: @split "(x)" "axbxc"@ yields @["a" ["x"] "b" ["x"] "c"]@+builtinSplit :: (MonadEval m) => NixValue -> NixValue -> m NixValue+-- Pre-compiled path: regex was compiled at partial-application time.+builtinSplit (VCompiledRegex (CompiledRegex _ compiled)) (VStr str _) =+  splitWithCompiled compiled str+-- Direct 2-arg call: use global compilation cache.+builtinSplit (VStr regex _) (VStr str _) =+  case cachedCompileRegex regex of+    Nothing -> throwEvalError ("builtins.split: invalid regex: " <> bytesToTextLossy regex)+    Just compiled -> splitWithCompiled compiled str+builtinSplit (VStr _ _) other =+  throwEvalError ("builtins.split: expected a string, got " <> typeName other)+builtinSplit (VCompiledRegex _) other =+  throwEvalError ("builtins.split: expected a string, got " <> typeName other)+builtinSplit other _ =+  throwEvalError ("builtins.split: expected a string (regex), got " <> typeName other)++-- | Shared split logic for pre-compiled and freshly-compiled regex paths.+splitWithCompiled :: (MonadEval m) => RE.Regex -> BS.ByteString -> m NixValue+splitWithCompiled compiled str =+  let allMatches = matchAllText compiled str+      result = buildSplitResult str 0 allMatches+   in pure (VList (clistFromThunks (map thunkToCPtr result)))++-- | Build the alternating list for builtins.split.  Offsets from the+-- ByteString regex instance are byte offsets, so slicing is O(1)+-- 'BS.take'/'BS.drop'.+buildSplitResult :: BS.ByteString -> Int -> [Array.Array Int (BS.ByteString, (Int, Int))] -> [Thunk]+buildSplitResult remaining pos [] =+  -- No more matches - emit the rest of the string.+  [evaluated (mkStrBytes (BS.drop pos remaining))]+buildSplitResult remaining pos (match : rest) =+  let elems = Array.elems match+      (_, (matchStart, matchLen)) = case elems of+        (full : _) -> full+        [] -> ("", (pos, 0)) -- defensive: should not happen from matchAllText+        -- Bytes before this match+      before = BS.take (matchStart - pos) (BS.drop pos remaining)+      -- Capture groups (indices 1..)+      groups = drop 1 elems+      -- A non-participating capture group has offset (-1) becomes null (as 'match').+      groupThunks =+        map (\(s, (off, _)) -> if off < 0 then evaluated VNull else evaluated (mkStrBytes s)) groups+      -- Continue after this match+      afterPos = matchStart + matchLen+   in evaluated (mkStrBytes before)+        : evaluated (VList (clistFromThunks (map thunkToCPtr groupThunks)))+        : buildSplitResult remaining afterPos rest++builtinCompareVersions :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinCompareVersions (VStr a _) (VStr b _) =+  pure (VInt (compareVersionParts (splitVersionStr a) (splitVersionStr b)))+builtinCompareVersions _ _ = throwEvalError "builtins.compareVersions: expected two strings"++-- | Convert 'Ordering' to the Nix compareVersions convention: -1, 0, 1.+ordToNix :: Ordering -> Int64+ordToNix LT = -1+ordToNix EQ = 0+ordToNix GT = 1++compareVersionParts :: [BS.ByteString] -> [BS.ByteString] -> Int64+compareVersionParts [] [] = 0+-- When one version runs out, Nix pads the shorter side with an empty+-- component and keeps comparing - so "1.0" > "1.0pre" (empty sorts AFTER+-- "pre"), not "<" as a naive length comparison would give.+compareVersionParts [] (b : bs) =+  case compareComponent "" b of+    EQ -> compareVersionParts [] bs+    cmp -> ordToNix cmp+compareVersionParts (a : as) [] =+  case compareComponent a "" of+    EQ -> compareVersionParts as []+    cmp -> ordToNix cmp+compareVersionParts (a : as) (b : bs) =+  case compareComponent a b of+    EQ -> compareVersionParts as bs+    cmp -> ordToNix cmp++-- | Byte-level alpha classification, as upstream (C @isalpha@ over+-- bytes): a non-ASCII letter is a separator, never an alphabetic+-- component.  Digits need no counterpart - 'isDigit' is @0-9@ only.+isVersionAlpha :: Char -> Bool+isVersionAlpha c = isAsciiLower c || isAsciiUpper c++compareComponent :: BS.ByteString -> BS.ByteString -> Ordering+compareComponent a b+  | a == b = EQ+  | allDigits a && allDigits b = compare (readInt a) (readInt b)+  -- "pre" sorts before everything else (Nix pre-release convention)+  | a == "pre" = LT+  | b == "pre" = GT+  | a == "" = LT+  | b == "" = GT+  -- Alphabetic components sort before numeric in Nix+  | isAlphaComp a && allDigits b = LT+  | allDigits a && isAlphaComp b = GT+  | otherwise = compare a b+  where+    allDigits t = not (BS.null t) && BC.all isDigit t+    isAlphaComp t = case BC.uncons t of+      Just (c, _) -> isVersionAlpha c+      Nothing -> False+    readInt :: BS.ByteString -> Int64+    readInt = BC.foldl' (\acc c -> acc * 10 + fromIntegral (digitToInt c)) (0 :: Int64)++splitVersionStr :: BS.ByteString -> [BS.ByteString]+splitVersionStr t+  | BS.null t = []+  | otherwise =+      let (component, rest) = spanComponent t+       in component : splitVersionAfterComponent rest++splitVersionAfterComponent :: BS.ByteString -> [BS.ByteString]+splitVersionAfterComponent t = case BC.uncons t of+  Nothing -> []+  -- '.' and '-' are both component separators in Nix's version grammar, so a+  -- '-' is skipped, not emitted as a one-character component.+  Just (sep, rest) | sep == '.' || sep == '-' -> splitVersionStr rest+  Just _ -> splitVersionStr t++spanComponent :: BS.ByteString -> (BS.ByteString, BS.ByteString)+spanComponent t = case BC.uncons t of+  Nothing -> ("", "")+  Just (c, rest)+    | isDigit c -> BC.span isDigit t+    | isVersionAlpha c -> BC.span isVersionAlpha t+    | otherwise -> (BS.take 1 t, rest)++builtinSplitVersion :: (MonadEval m) => NixValue -> m NixValue+builtinSplitVersion (VStr s _) =+  pure (VList (clistFromThunks (map (thunkToCPtr . evaluated . mkStrBytes) (splitVersionComponents s))))+builtinSplitVersion other =+  throwEvalError ("builtins.splitVersion: expected a string, got " <> typeName other)++splitVersionComponents :: BS.ByteString -> [BS.ByteString]+splitVersionComponents t = case BC.uncons t of+  Nothing -> []+  Just ('.', rest) -> splitVersionComponents rest+  Just (c, _)+    | isDigit c ->+        let (digits, rest) = BC.span isDigit t+         in digits : splitVersionComponents rest+    | isVersionAlpha c ->+        let (alpha, rest) = BC.span isVersionAlpha t+         in alpha : splitVersionComponents rest+    | otherwise ->+        -- Non-alphanumeric separator byte (e.g. '-', '_'): consume it+        splitVersionComponents (BS.drop 1 t)++builtinParseDrvName :: (MonadEval m) => NixValue -> m NixValue+builtinParseDrvName (VStr s _) =+  let (name, version) = parseName s+   in pure+        ( VAttrs+            ( attrSetFromMap $+                Map.fromList+                  [ ("name", evaluated (mkStrBytes name)),+                    ("version", evaluated (mkStrBytes version))+                  ]+            )+        )+builtinParseDrvName other =+  throwEvalError ("builtins.parseDrvName: expected a string, got " <> typeName other)++parseName :: BS.ByteString -> (BS.ByteString, BS.ByteString)+parseName t =+  case findVersionDash t 0 of+    Nothing -> (t, "")+    Just idx -> (BS.take idx t, BS.drop (idx + 1) t)++findVersionDash :: BS.ByteString -> Int -> Maybe Int+findVersionDash t idx = case BC.uncons (BS.drop idx t) of+  Nothing -> Nothing+  Just ('-', after)+    | Just (d, _) <- BC.uncons after,+      isDigit d ->+        Just idx+  Just _ -> findVersionDash t (idx + 1)++-- ---------------------------------------------------------------------------+-- Builtin implementations - serialization + hashing+-- ---------------------------------------------------------------------------++builtinToJSON :: (MonadEval m) => NixValue -> m NixValue+builtinToJSON val = do+  (json, ctx) <- valueToJSON val+  pure (VStr (TE.encodeUtf8 json) ctx)++-- | JSON is built as 'Text' and encoded once at the top: upstream's+-- serializer (nlohmann) REJECTS invalid UTF-8, so string payloads decode+-- strictly here and invalid bytes are an eval error, matching upstream.+valueToJSON :: (MonadEval m) => NixValue -> m (Text, StringContext)+valueToJSON VNull = pure ("null", emptyContext)+valueToJSON (VBool True) = pure ("true", emptyContext)+valueToJSON (VBool False) = pure ("false", emptyContext)+valueToJSON (VInt n) = pure (T.pack (show n), emptyContext)+valueToJSON (VFloat f)+  -- upstream's serializer (nlohmann dump) writes a non-finite float as null+  | isNaN f || isInfinite f = pure ("null", emptyContext)+  | otherwise = pure (formatJsonFloat f, emptyContext)+valueToJSON (VStr s ctx) = do+  decoded <- decodedText "builtins.toJSON" s+  pure (jsonEscapeString decoded, ctx)+valueToJSON (VList cl) = do+  let thunks = map Thunk (clistThunks cl)+  vals <- mapM force thunks+  results <- mapM valueToJSON vals+  let jsonVals = map fst results+      ctx = mconcat (map snd results)+  pure ("[" <> T.intercalate "," jsonVals <> "]", ctx)+valueToJSON (VAttrs attrs) =+  -- C++ Nix serializes an attrset via __toString first, then outPath, and only+  -- falls back to an object when neither is present.+  case (attrSetLookup "__toString" attrs, attrSetLookup "outPath" attrs) of+    (Nothing, Nothing) -> do+      let m = attrSetToMap attrs+          sortedKeys = Map.keys m+      results <- mapM (jsonPair m) sortedKeys+      let pairs = map fst results+          ctx = mconcat (map snd results)+      pure ("{" <> T.intercalate "," pairs <> "}", ctx)+    _ -> do+      (s, ctx) <- coerceToString True force applyValue coercePathToStore (VAttrs attrs)+      decoded <- decodedText "builtins.toJSON" s+      pure (jsonEscapeString decoded, ctx)+  where+    jsonPair attrMap key = case Map.lookup key attrMap of+      Nothing -> pure ("", emptyContext)+      Just thunk -> do+        val <- force thunk+        (jsonVal, ctx) <- valueToJSON val+        pure (jsonEscapeString key <> ":" <> jsonVal, ctx)+-- A path serializes as its source store path, with context - the same+-- copy-to-store coercion as interpolation (upstream value-to-json.cc+-- serializes paths with copyToStore = true).+valueToJSON (VPath p) = do+  (spText, ctx) <- sourcePathWithContext p+  pure (jsonEscapeString spText, ctx)+valueToJSON (VLambda {}) = throwEvalError "builtins.toJSON: cannot convert a function to JSON"+valueToJSON (VBuiltin _ _) = throwEvalError "builtins.toJSON: cannot convert a function to JSON"+valueToJSON (VDerivation _) = throwEvalError "builtins.toJSON: cannot convert a derivation to JSON"+valueToJSON (VCompiledRegex _) = throwEvalError "builtins.toJSON: cannot convert a function to JSON"++jsonEscapeString :: Text -> Text+jsonEscapeString s = "\"" <> T.concatMap escapeChar s <> "\""+  where+    escapeChar '"' = "\\\""+    escapeChar '\\' = "\\\\"+    escapeChar '\n' = "\\n"+    escapeChar '\r' = "\\r"+    escapeChar '\t' = "\\t"+    escapeChar c+      | ord c < 0x20 = "\\u" <> T.pack (padHex 4 (showHex' (ord c)))+      | otherwise = T.singleton c+    padHex n str = replicate (n - length str) '0' ++ str+    showHex' 0 = "0"+    showHex' num = go num ""+      where+        go 0 acc = acc+        go v acc =+          let (q, r) = quotRem v 16+           in go q (hexDigit r : acc)++-- | Safe hex digit lookup (total for 0-15).+hexDigit :: Int -> Char+hexDigit n+  | n >= 0 && n <= 9 = chr (ord '0' + n)+  | n >= 10 && n <= 15 = chr (ord 'a' + n - 10)+  | otherwise = '?' -- unreachable for valid hex++builtinFromJSON :: (MonadEval m) => NixValue -> m NixValue+builtinFromJSON (VStr s _) = do+  -- JSON input must be valid UTF-8 (upstream's parser rejects it too).+  decoded <- decodedText "builtins.fromJSON" s+  case parseJSON (T.strip decoded) of+    Just (val, rest)+      | T.null (T.strip rest) -> pure val+      | otherwise -> throwEvalError "builtins.fromJSON: trailing content after JSON value"+    Nothing -> throwEvalError "builtins.fromJSON: invalid JSON"+builtinFromJSON other =+  throwEvalError ("builtins.fromJSON: expected a string, got " <> typeName other)++parseJSON :: Text -> Maybe (NixValue, Text)+parseJSON t = case T.uncons (T.stripStart t) of+  Nothing -> Nothing+  Just ('n', rest)+    | Just suffix <- T.stripPrefix "ull" rest -> Just (VNull, suffix)+  Just ('t', rest)+    | Just suffix <- T.stripPrefix "rue" rest -> Just (VBool True, suffix)+  Just ('f', rest)+    | Just suffix <- T.stripPrefix "alse" rest -> Just (VBool False, suffix)+  Just ('"', _) -> parseJSONString (T.stripStart t)+  Just ('[', rest) -> parseJSONArray rest+  Just ('{', rest) -> parseJSONObject rest+  Just (c, _)+    | c == '-' || isDigit c -> parseJSONNumber (T.stripStart t)+  _ -> Nothing++parseJSONString :: Text -> Maybe (NixValue, Text)+parseJSONString t = case T.uncons t of+  Just ('"', rest) ->+    let (strVal, remaining) = parseJSONStringContent rest+     in Just (mkStr strVal, remaining)+  _ -> Nothing++-- | Parse JSON string content, O(n) via chunk list + T.concat.+parseJSONStringContent :: Text -> (Text, Text)+parseJSONStringContent = go []+  where+    go !chunks t = case T.uncons t of+      Nothing -> (T.concat (reverse chunks), "")+      Just ('"', rest) -> (T.concat (reverse chunks), rest)+      Just ('\\', rest) -> case T.uncons rest of+        Just ('"', r) -> go ("\"" : chunks) r+        Just ('\\', r) -> go ("\\" : chunks) r+        Just ('/', r) -> go ("/" : chunks) r+        Just ('n', r) -> go ("\n" : chunks) r+        Just ('r', r) -> go ("\r" : chunks) r+        Just ('t', r) -> go ("\t" : chunks) r+        Just ('u', r) -> case parseHex4 r of+          Just (hi, r2)+            -- UTF-16 surrogate pair: high surrogate followed by \uXXXX low+            | hi >= 0xD800 && hi <= 0xDBFF ->+                case T.stripPrefix "\\u" r2 of+                  Just r3 -> case parseHex4 r3 of+                    Just (lo, r4)+                      | lo >= 0xDC00 && lo <= 0xDFFF ->+                          let combined = 0x10000 + (hi - 0xD800) * 0x400 + (lo - 0xDC00)+                           in go (T.singleton (chr combined) : chunks) r4+                    _ -> go (T.singleton (chr hi) : chunks) r2+                  Nothing -> go (T.singleton (chr hi) : chunks) r2+          Just (codepoint, r2) ->+            go (T.singleton (chr codepoint) : chunks) r2+          Nothing -> go ("u" : chunks) r+        _ -> (T.concat (reverse chunks), rest)+      Just (c, rest) -> go (T.singleton c : chunks) rest++parseHex4 :: Text -> Maybe (Int, Text)+parseHex4 t+  | T.length t >= 4 =+      let hex = T.take 4 t+       in if T.all isHexDigit hex+            then Just (readHex4 hex, T.drop 4 t)+            else Nothing+  | otherwise = Nothing++readHex4 :: Text -> Int+readHex4 = T.foldl' (\acc c -> acc * 16 + digitToInt c) 0++parseJSONNumber :: Text -> Maybe (NixValue, Text)+parseJSONNumber t =+  let (numStr, rest) = T.span (\c -> isDigit c || c == '.' || c == '-' || c == 'e' || c == 'E' || c == '+') t+   in if T.null numStr+        then Nothing+        else+          if T.any (\c -> c == '.' || c == 'e' || c == 'E') numStr+            then case reads (T.unpack numStr) :: [(Double, String)] of+              [(d, "")] -> Just (VFloat d, rest)+              _ -> Nothing+            else case reads (T.unpack numStr) :: [(Integer, String)] of+              [(n, "")] -> Just (jsonInteger n, rest)+              _ -> Nothing++-- | Nix value for a JSON integer literal, as upstream's nlohmann-based+-- parser produces it: a value in int64 range is an int; a positive value+-- that fits only uint64 still arrives as an int (nlohmann hands it over+-- unsigned, upstream stores it into the signed NixInt, two's-complement);+-- anything wider than 64 bits falls back to a float.+jsonInteger :: Integer -> NixValue+jsonInteger n+  | n >= toInteger (minBound :: Int64) && n <= toInteger (maxBound :: Word64) = VInt (fromInteger n)+  | otherwise = VFloat (fromInteger n)++parseJSONArray :: Text -> Maybe (NixValue, Text)+parseJSONArray t = parseJSONArrayElements (T.stripStart t) []++parseJSONArrayElements :: Text -> [Thunk] -> Maybe (NixValue, Text)+parseJSONArrayElements t acc = case T.uncons (T.stripStart t) of+  Just (']', rest) -> Just (VList (clistFromThunks (map thunkToCPtr (reverse acc))), rest)+  _ -> case parseJSON t of+    Just (val, rest) ->+      let stripped = T.stripStart rest+       in case T.uncons stripped of+            Just (',', rest2) -> parseJSONArrayElements rest2 (evaluated val : acc)+            Just (']', rest2) -> Just (VList (clistFromThunks (map thunkToCPtr (reverse (evaluated val : acc)))), rest2)+            _ -> Nothing+    Nothing -> Nothing++parseJSONObject :: Text -> Maybe (NixValue, Text)+parseJSONObject t = parseJSONObjectEntries (T.stripStart t) Map.empty++parseJSONObjectEntries :: Text -> Map Text Thunk -> Maybe (NixValue, Text)+parseJSONObjectEntries t acc = case T.uncons (T.stripStart t) of+  Just ('}', rest) -> Just (VAttrs (attrSetFromMap acc), rest)+  -- Keys read via 'parseJSONStringContent' directly: they become attr+  -- names (Text), not string values.+  Just ('"', afterQuote) ->+    let (key, rest) = parseJSONStringContent afterQuote+     in case T.uncons (T.stripStart rest) of+          Just (':', rest2) -> case parseJSON rest2 of+            Just (val, rest3) ->+              let stripped = T.stripStart rest3+                  updated = Map.insert key (evaluated val) acc+               in case T.uncons stripped of+                    Just (',', rest4) -> parseJSONObjectEntries rest4 updated+                    Just ('}', rest4) -> Just (VAttrs (attrSetFromMap updated), rest4)+                    _ -> Nothing+            Nothing -> Nothing+          _ -> Nothing+  _ -> Nothing++builtinHashString :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinHashString (VStr algo _) (VStr input _) = do+  -- The payload IS the hashed bytes - no encode step, so a mid-codepoint+  -- substring hashes to the sha256 of exactly those bytes, as upstream.+  algoName <- decodedText "builtins.hashString" algo+  hashBytesWithAlgo "hashString" algoName input+builtinHashString (VStr _ _) other =+  throwEvalError ("builtins.hashString: expected a string, got " <> typeName other)+builtinHashString other _ =+  throwEvalError ("builtins.hashString: expected a string, got " <> typeName other)++digestToHex :: (BA.ByteArrayAccess a) => a -> Text+digestToHex = bytesToHexText . BA.convert++-- ---------------------------------------------------------------------------+-- Builtin implementations - deep evaluation+-- ---------------------------------------------------------------------------++builtinDeepSeq :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinDeepSeq first second = do+  deepForce first+  pure second++deepForce :: (MonadEval m) => NixValue -> m ()+deepForce (VList cl) = mapM_ ((force >=> deepForce) . Thunk) (clistThunks cl)+deepForce (VAttrs attrs) = mapM_ (force >=> deepForce) (attrSetElems attrs)+deepForce _ = pure ()++-- | @builtins.seq a b@ - evaluate @a@ to WHNF, then return @b@.+builtinSeq :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinSeq !_first = pure++-- | @builtins.trace msg val@ - print @msg@ to stderr, return @val@.+builtinTrace :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinTrace msgVal result = do+  msg <- case msgVal of+    VStr s _ -> pure (bytesToTextLossy s)+    other -> pure (showValueForTrace other)+  traceMessage ("trace: " <> msg)+  pure result++-- | @builtins.warn msg val@ - print warning to stderr, return @val@.+builtinWarn :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinWarn msgVal result = do+  msg <- case msgVal of+    VStr s _ -> pure (bytesToTextLossy s)+    other -> pure (showValueForTrace other)+  traceMessage ("warning: " <> msg)+  pure result++-- | Pretty-print a value for trace/warn, matching C++ Nix's printValue.+showValueForTrace :: NixValue -> Text+showValueForTrace (VInt n) = T.pack (show n)+showValueForTrace (VFloat f) = formatNixFloat f+showValueForTrace (VBool True) = "true"+showValueForTrace (VBool False) = "false"+showValueForTrace VNull = "null"+showValueForTrace (VPath p) = p+showValueForTrace other = "<<" <> typeName other <> ">>"++-- ---------------------------------------------------------------------------+-- Builtin implementations - graph traversal+-- ---------------------------------------------------------------------------++builtinGenericClosure :: (MonadEval m) => NixValue -> m NixValue+builtinGenericClosure (VAttrs attrs) = do+  startSetThunk <-+    maybe (throwEvalError "builtins.genericClosure: missing 'startSet'") pure $+      attrSetLookup "startSet" attrs+  operatorThunk <-+    maybe (throwEvalError "builtins.genericClosure: missing 'operator'") pure $+      attrSetLookup "operator" attrs+  startSetVal <- force startSetThunk+  operatorVal <- force operatorThunk+  case startSetVal of+    VList cl -> do+      let items = map Thunk (clistThunks cl)+      result <- closureLoop operatorVal (Seq.fromList items) [] []+      pure (VList (clistFromThunks (map (thunkToCPtr . evaluated) result)))+    _ -> throwEvalError "builtins.genericClosure: 'startSet' must be a list"+builtinGenericClosure other =+  throwEvalError ("builtins.genericClosure: expected a set, got " <> typeName other)++-- | BFS loop for genericClosure.  Uses Data.Sequence for O(1) queue+-- append (the old list-based version was O(n) per operator call).+-- seenKeys is still a linear scan - Nix value equality is monadic so+-- Set/HashMap is not directly applicable without specialising on key type.+closureLoop ::+  (MonadEval m) =>+  NixValue ->+  Seq Thunk ->+  [NixValue] ->+  [NixValue] ->+  m [NixValue]+closureLoop _ Empty _ acc = pure (reverse acc)+closureLoop operator (thunk :<| rest) seenKeys acc = do+  item <- force thunk+  key <- extractKey item+  alreadySeen <- keyInList key seenKeys+  if alreadySeen+    then closureLoop operator rest seenKeys acc+    else do+      newItems <- applyValue operator item+      case newItems of+        VList newCl ->+          closureLoop operator (rest <> Seq.fromList (map Thunk (clistThunks newCl))) (key : seenKeys) (item : acc)+        _ -> throwEvalError "builtins.genericClosure: operator must return a list"++extractKey :: (MonadEval m) => NixValue -> m NixValue+extractKey (VAttrs attrs) =+  case attrSetLookup "key" attrs of+    Just thunk -> force thunk+    Nothing -> throwEvalError "builtins.genericClosure: item missing 'key' attribute"+extractKey _ = throwEvalError "builtins.genericClosure: item must be a set with 'key'"++keyInList :: (MonadEval m) => NixValue -> [NixValue] -> m Bool+keyInList _ [] = pure False+keyInList key (seen : rest) = do+  eq <- nixEqual force key seen+  if eq then pure True else keyInList key rest++-- ---------------------------------------------------------------------------+-- IO builtins (delegate to MonadEval methods)+-- ---------------------------------------------------------------------------++-- | Coerce a value to a path 'Text'.  Accepts 'VPath' and 'VStr';+-- throws a type error for anything else.  A string operand is a byte+-- string naming a filesystem path, so it decodes strictly.+coerceToPath :: (MonadEval m) => Text -> NixValue -> m Text+coerceToPath _ (VPath p) = pure p+coerceToPath name (VStr s _) = decodedText ("builtins." <> name) s+coerceToPath name other =+  throwEvalError ("builtins." <> name <> ": expected a path or string, got " <> typeName other)++builtinImport :: (MonadEval m) => NixValue -> m NixValue+builtinImport (VPath p) = importFile p+builtinImport (VStr s _) = importFile =<< decodedText "import" s+builtinImport other =+  throwEvalError ("import: expected a path or string, got " <> typeName other)++builtinReadFile :: (MonadEval m) => NixValue -> m NixValue+builtinReadFile val = do+  p <- coerceToPath "readFile" val+  bytes <- readFileBytes p+  -- The value is the file's RAW BYTES, as upstream: no BOM stripping, no+  -- UTF-16 transcoding, no replacement characters (the auto-decode stays+  -- on the parser/import path only).  The only rejection is NUL, which a+  -- Nix string cannot represent upstream.+  when (BS.elem 0 bytes) $+    throwEvalError+      ("builtins.readFile: the contents of the file '" <> p <> "' cannot be represented as a Nix string")+  pure (mkStrBytes bytes)++builtinPathExists :: (MonadEval m) => NixValue -> m NixValue+builtinPathExists val = do+  p <- coerceToPath "pathExists" val+  VBool <$> doesPathExist p++builtinReadDir :: (MonadEval m) => NixValue -> m NixValue+builtinReadDir val = do+  p <- coerceToPath "readDir" val+  entries <- listDirectory p+  pure (VAttrs (attrSetFromMap (Map.fromList [(name, evaluated (mkStr fileType)) | (name, fileType) <- entries])))++-- ---------------------------------------------------------------------------+-- Builtin implementations - environment + paths+-- ---------------------------------------------------------------------------++builtinGetEnv :: (MonadEval m) => NixValue -> m NixValue+builtinGetEnv (VStr name _) = do+  varName <- decodedText "builtins.getEnv" name+  mkStr <$> getEnvVar varName+builtinGetEnv other =+  throwEvalError ("builtins.getEnv: expected a string, got " <> typeName other)++builtinToPath :: (MonadEval m) => NixValue -> m NixValue+builtinToPath (VPath p) = pure (VPath p)+builtinToPath (VStr rawBytes _) = do+  s <- decodedText "builtins.toPath" rawBytes+  case T.uncons s of+    Nothing -> throwEvalError "builtins.toPath: empty path"+    -- Canonicalized like every other path production site, as upstream.+    Just ('/', _) -> pure (VPath (canonPathValue s))+    Just _ -> throwEvalError ("builtins.toPath: path must be absolute, got " <> s)+builtinToPath other =+  throwEvalError ("builtins.toPath: expected a string or path, got " <> typeName other)++-- ---------------------------------------------------------------------------+-- Builtin implementations - store path operations+-- ---------------------------------------------------------------------------++builtinPlaceholder :: (MonadEval m) => NixValue -> m NixValue+builtinPlaceholder (VStr outputName _) = do+  name <- decodedText "builtins.placeholder" outputName+  pure (mkStr (hashPlaceholder name))+builtinPlaceholder other =+  throwEvalError ("builtins.placeholder: expected a string, got " <> typeName other)++builtinStorePath :: (MonadEval m) => NixValue -> m NixValue+builtinStorePath (VPath p) = validateStorePath p+builtinStorePath (VStr s _) = validateStorePath =<< decodedText "builtins.storePath" s+builtinStorePath other =+  throwEvalError ("builtins.storePath: expected a path or string, got " <> typeName other)++-- | @builtins.storePath@ - mark an already-in-store path as such.  Upstream+-- returns a STRING carrying an Opaque (SCPlain) context entry for the enclosing+-- store path, NOT a bare path: the Opaque marker is what stops a later coercion+-- from re-serialising (re-NARing) the already-present path into a new store+-- path.  Returning a context-free path (the old behavior) re-copied the path on+-- coercion - a wrong store path on Unix, and a file-not-found on Windows, where+-- the canonical @/nix/store@ text is not the on-disk location.+validateStorePath :: (MonadEval m) => Text -> m NixValue+validateStorePath p = case enclosingStorePath p of+  Just sp -> pure (VStr (TE.encodeUtf8 p) (plainContext sp))+  Nothing -> throwEvalError ("builtins.storePath: not a valid store path: " <> p)++-- | The store path enclosing an absolute path under the store dir, or+-- 'Nothing' if the path is not in the store.  Accepts a bare store path and a+-- subpath (@\/nix\/store\/\<hash\>-\<name\>\/sub@ resolves to its+-- @\<hash\>-\<name\>@ component), the way upstream marks the enclosing store+-- path Opaque for either.  The component passes the same charset+-- validation as every other store-path parse boundary.+enclosingStorePath :: Text -> Maybe StorePath+enclosingStorePath p = do+  rest <- T.stripPrefix storeDirPrefix p+  parseStorePathBaseName (T.takeWhile (/= '/') rest)++-- ---------------------------------------------------------------------------+-- Builtin implementations - Nix search path+-- ---------------------------------------------------------------------------++builtinFindFile :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinFindFile (VList cl) (VStr name _) = do+  fileName <- decodedText "builtins.findFile" name+  let searchPath = map Thunk (clistThunks cl)+  entries <- mapM forceSearchEntry searchPath+  findFirst entries fileName+builtinFindFile (VList _) other =+  throwEvalError ("builtins.findFile: expected a string, got " <> typeName other)+builtinFindFile other _ =+  throwEvalError ("builtins.findFile: expected a list, got " <> typeName other)++-- | Extract {prefix, path} from a search path entry thunk.+forceSearchEntry :: (MonadEval m) => Thunk -> m (Text, Text)+forceSearchEntry thunk = do+  val <- force thunk+  case val of+    VAttrs attrs -> do+      prefixThunk <-+        maybe (throwEvalError "builtins.findFile: entry missing 'prefix'") pure $+          attrSetLookup "prefix" attrs+      pathThunk <-+        maybe (throwEvalError "builtins.findFile: entry missing 'path'") pure $+          attrSetLookup "path" attrs+      prefixVal <- force prefixThunk+      pathVal <- force pathThunk+      prefix <- case prefixVal of+        VStr s _ -> decodedText "builtins.findFile" s+        _ -> throwEvalError "builtins.findFile: 'prefix' must be a string"+      path <- case pathVal of+        VStr s _ -> decodedText "builtins.findFile" s+        VPath s -> pure s+        _ -> throwEvalError "builtins.findFile: 'path' must be a string or path"+      pure (prefix, path)+    _ -> throwEvalError "builtins.findFile: search path entry must be a set"++-- | Iterate search path entries, checking for a match.+-- A miss is a CATCHABLE error (upstream raises ThrownError here):+-- nixpkgs' impure.nix wraps @<nixpkgs-overlays>@ in @tryEval@ and+-- relies on catching the miss.+findFirst :: (MonadEval m) => [(Text, Text)] -> Text -> m NixValue+findFirst [] name =+  throwCatchableError ("file '" <> name <> "' was not found in the Nix search path")+findFirst ((prefix, path) : rest) name+  | prefix == name || (not (T.null prefix) && (prefix <> "/") `T.isPrefixOf` name) =+      let suffix = if prefix == name then "" else T.drop (T.length prefix + 1) name+          candidate = canonPathValue (if T.null suffix then path else path <> "/" <> suffix)+       in do+            exists <- doesPathExist candidate+            if exists+              then pure (VPath candidate)+              else findFirst rest name+  | T.null prefix =+      let candidate = canonPathValue (path <> "/" <> name)+       in do+            exists <- doesPathExist candidate+            if exists+              then pure (VPath candidate)+              else findFirst rest name+  | otherwise = findFirst rest name++-- ---------------------------------------------------------------------------+-- Builtin implementations - store file creation+-- ---------------------------------------------------------------------------++builtinToFile :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinToFile (VStr name _) (VStr contents ctx) = do+  fileName <- decodedText "builtins.toFile" name+  refs <- toFileRefs ctx+  -- The contents are stored (and hashed) as their raw bytes.+  storePath <- writeToStore fileName contents refs+  -- Upstream returns a STRING whose context is the new path itself; the+  -- refs travel through the store path computation, not the eval context.+  let selfContext = maybe emptyContext plainContext (parseStorePath defaultStoreDir storePath)+  pure (VStr (TE.encodeUtf8 storePath) selfContext)+builtinToFile (VStr _ _) other =+  throwEvalError ("builtins.toFile: expected a string, got " <> typeName other)+builtinToFile other _ =+  throwEvalError ("builtins.toFile: expected a string, got " <> typeName other)++-- | The store-path references a toFile output may carry: the contents'+-- plain-path context, in sorted order.  A derivation-output reference is+-- an error, exactly as upstream ("files created by builtins.toFile may+-- not reference derivations").+toFileRefs :: (MonadEval m) => StringContext -> m [StorePath]+toFileRefs (StringContext elems) = mapM refOf (Set.toAscList elems)+  where+    refOf (SCPlain sp) = pure sp+    refOf (SCDrvOutput _ _) =+      throwEvalError "builtins.toFile: files created by toFile may not reference derivations"+    refOf (SCAllOutputs _) =+      throwEvalError "builtins.toFile: files created by toFile may not reference derivations"++-- ---------------------------------------------------------------------------+-- Builtin implementations - scoped import+-- ---------------------------------------------------------------------------++builtinScopedImport :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinScopedImport (VAttrs attrs) pathVal = do+  p <- coerceToPath "scopedImport" pathVal+  let scope = Map.toList (attrSetToMap attrs)+  scopedImportFile scope p+builtinScopedImport other _ =+  throwEvalError ("builtins.scopedImport: expected a set, got " <> typeName other)++-- ---------------------------------------------------------------------------+-- Builtin implementations - network fetchers+-- ---------------------------------------------------------------------------++builtinFetchurl :: (MonadEval m) => NixValue -> m NixValue+builtinFetchurl (VStr url _) = do+  urlText <- decodedText "builtins.fetchurl" url+  fetchUrlSimple urlText Nothing+builtinFetchurl (VAttrs attrs) = do+  url <- forceAttrStr "builtins.fetchurl" "url" attrs+  sha256 <- forceOptionalAttrStr attrs "sha256"+  fetchUrlSimple url sha256+builtinFetchurl other =+  throwEvalError ("builtins.fetchurl: expected a string or set, got " <> typeName other)++builtinFetchTarball :: (MonadEval m) => NixValue -> m NixValue+builtinFetchTarball (VStr url _) = do+  urlText <- decodedText "builtins.fetchTarball" url+  fetchAndExtractTarball urlText Nothing tarballSourceName+builtinFetchTarball (VAttrs attrs) = do+  url <- forceAttrStr "builtins.fetchTarball" "url" attrs+  sha256 <- forceOptionalAttrStr attrs "sha256"+  nameOverride <- forceOptionalAttrStr attrs "name"+  fetchAndExtractTarball url sha256 (fromMaybe tarballSourceName nameOverride)+builtinFetchTarball other =+  throwEvalError ("builtins.fetchTarball: expected a string or set, got " <> typeName other)++-- | Upstream's default store name for fetched source trees.+tarballSourceName :: Text+tarballSourceName = "source"++-- | Download a tarball, extract it, verify the optional sha256 pin, and+-- copy the tree to its content-addressed store path.  The pin is the+-- recursive NAR hash of the EXTRACTED tree, as upstream.  Download and+-- extraction share one shell pipeline to avoid binary-as-text encoding+-- issues.  The scratch dir is exclusively created with an unpredictable+-- name ('createScratchDir') and removed once the tree reaches the store.+fetchAndExtractTarball :: (MonadEval m) => Text -> Maybe Text -> Text -> m NixValue+fetchAndExtractTarball url mSha256 name = do+  extractDir <- createScratchDir "nova-nix-tarball-"+  -- The -- separator prevents argument injection from the URL.+  (code, _, errOut) <-+    runProcess+      "sh"+      [ "-c",+        "curl -sSfL -- \"$1\" | tar -xz -C \"$2\" --strip-components=1",+        "--",+        url,+        extractDir+      ]+      ""+  case code of+    0 -> do+      pin <- traverse (decodeSha256Pin "builtins.fetchTarball") mSha256+      storePath <-+        copyPathToStore+          extractDir+          name+          (fmap ("builtins.fetchTarball: " <> url,) pin)+      removeScratchDir extractDir+      pure (VPath storePath)+    _ -> do+      removeScratchDir extractDir+      throwEvalError ("builtins.fetchTarball: " <> errOut)++-- | Transports 'builtinFetchGit' accepts.  Also spelled into the+-- clone's @protocol.*.allow@ config, so git enforces the same set+-- internally.+allowedGitSchemes :: [Text]+allowedGitSchemes = ["http", "https", "ssh", "git", "file"]++-- | Validate a fetchGit URL's transport before anything spawns.  git's+-- transport-helper syntax (@\<helper\>::\<address\>@) makes the helper+-- name a command to run as the transport (@ext::@ runs a shell command,+-- @fd::@ reads a descriptor), so evaluating an expression carrying such+-- a URL would execute it.  Accepted: an allowlisted explicit scheme, an+-- scp-like remote, or a local path.  Left carries the eval-error text.+checkGitUrl :: Text -> Either Text Text+checkGitUrl url+  | T.null url = Left "builtins.fetchGit: empty url"+  | not (T.null schemeRest),+    schemeShaped scheme,+    T.toLower scheme `elem` allowedGitSchemes =+      Right url+  | not (T.null schemeRest) = Left (disallowed scheme)+  | Just helper <- helperPrefix = Left (disallowed helper)+  | otherwise = Right url+  where+    (scheme, schemeRest) = T.breakOn "://" url+    -- RFC 3986 scheme shape; anything else before :// is not a scheme.+    schemeShaped s = case T.uncons s of+      Just (leading, rest) -> asciiAlpha leading && T.all schemeChar rest+      Nothing -> False+    schemeChar c = asciiAlpha c || isDigit c || c == '+' || c == '-' || c == '.'+    asciiAlpha c = isAsciiLower c || isAsciiUpper c+    -- <helper>:: counts only when the prefix is shaped like a helper+    -- name; "[::1]"-style scp hosts contain :: but are not helpers.+    helperPrefix = case T.breakOn "::" url of+      (prefix, rest)+        | not (T.null rest),+          T.null prefix || T.all helperChar prefix ->+            Just prefix+      _ -> Nothing+    helperChar c = asciiAlpha c || isDigit c || c == '-' || c == '_'+    disallowed t =+      "builtins.fetchGit: transport '" <> t <> "' is not allowed in url: " <> url++-- | What @git fetch@ asks for when the caller pinned no @ref@: the+-- remote's default branch.+defaultFetchRef :: Text+defaultFetchRef = "HEAD"++-- | What @git checkout@ resolves when the caller pinned no @rev@: the+-- tip of whatever the fetch just brought down.+fetchHeadRef :: Text+fetchHeadRef = "FETCH_HEAD"++-- | Characters upstream's @refRegexS@ allows after the first one.+gitRefTrailingPunctuation :: [Char]+gitRefTrailingPunctuation = "_./@+-"++-- | Validate a @ref@ against upstream's @refRegexS@+-- (@[a-zA-Z0-9\@][a-zA-Z0-9_.\/\@+-]*@).  The leading class excludes+-- @-@, and that is the load-bearing part: git parses options after the+-- remote name, so a @ref@ of @--upload-pack=\<cmd\>@ names the command+-- git runs as the transport.  Left carries the eval-error text.+checkGitRef :: Text -> Either Text Text+checkGitRef ref = case T.uncons ref of+  Just (leading, rest)+    | refLeadChar leading,+      T.all refChar rest ->+        Right ref+  _ -> Left ("builtins.fetchGit: ref is not a valid git ref name: " <> ref)+  where+    refLeadChar c = asciiAlphaNum c || c == '@'+    refChar c = asciiAlphaNum c || c `elem` gitRefTrailingPunctuation+    asciiAlphaNum c = isAsciiLower c || isAsciiUpper c || isDigit c++-- | Hex characters in a full SHA-1.+gitRevHexLength :: Int+gitRevHexLength = 40++-- | Validate a @rev@ against upstream's @revRegexS@ (@[0-9a-fA-F]{40}@).+-- Upstream parses it as a SHA-1 @Hash@ before it reaches git, which+-- rejects a tag or an abbreviated prefix as well as a leading @-@: a+-- \"pinned\" fetch that still resolves through git's DWIM rules is not+-- pinned.  Left carries the eval-error text.+checkGitRev :: Text -> Either Text Text+checkGitRev rev+  | T.length rev == gitRevHexLength,+    T.all isHexDigit rev =+      Right rev+  | otherwise = Left ("builtins.fetchGit: rev is not a full SHA-1: " <> rev)++-- | @-c@ config pinning the clone to 'allowedGitSchemes': every+-- transport defaults to never, each allowed scheme is re-enabled.  This+-- enforces what URL validation cannot see up front - a helper reached+-- indirectly, or a @remote-\<helper\>@ binary on PATH.+gitTransportConfig :: [Text]+gitTransportConfig =+  [ "-c",+    "protocol.allow=never",+    -- Keep checkout bytes platform-independent: no CRLF rewriting.+    "-c",+    "core.autocrlf=false",+    "-c",+    "core.eol=lf",+    -- Fail loudly rather than hash a symlink as a plain file (Git for+    -- Windows' fallback when it can't create real symlinks).+    "-c",+    "core.symlinks=true"+  ]+    ++ concat [["-c", "protocol." <> scheme <> ".allow=always"] | scheme <- allowedGitSchemes]++-- | The parsed arguments of a @builtins.fetchGit@ call.  A record+-- rather than positional threading: eight independent knobs invite+-- transposition, and the names keep every site readable.+data FetchGitArgs = FetchGitArgs+  { fgaUrl :: !Text,+    fgaName :: !Text,+    fgaRef :: !(Maybe Text),+    fgaRev :: !(Maybe Text),+    fgaSubmodules :: !Bool,+    fgaShallow :: !Bool,+    fgaAllRefs :: !Bool,+    fgaNarHash :: !(Maybe Text)+  }++-- | The attributes @builtins.fetchGit@ accepts.  Anything outside the+-- set is an error rather than a silent drop - an ignored attribute is+-- exactly how an unhonored pin stays invisible.  Upstream refuses the+-- same way ("input attribute '...' not supported by scheme 'git'",+-- observed from nix-instantiate 2.33.2).+fetchGitSupportedAttrs :: [Text]+fetchGitSupportedAttrs = ["url", "name", "ref", "rev", "submodules", "shallow", "allRefs", "narHash"]++builtinFetchGit :: (MonadEval m) => NixValue -> m NixValue+builtinFetchGit (VStr rawUrl _) = do+  url <- decodedText "builtins.fetchGit" rawUrl+  fetchGit+    FetchGitArgs+      { fgaUrl = url,+        fgaName = "source",+        fgaRef = Nothing,+        fgaRev = Nothing,+        fgaSubmodules = False,+        fgaShallow = False,+        fgaAllRefs = False,+        fgaNarHash = Nothing+      }+builtinFetchGit (VAttrs attrs) = do+  case filter (`notElem` fetchGitSupportedAttrs) (Map.keys (attrSetToMap attrs)) of+    (unsupported : _) ->+      throwEvalError ("builtins.fetchGit: attribute '" <> unsupported <> "' not supported")+    [] -> pure ()+  url <- forceAttrStr "builtins.fetchGit" "url" attrs+  name <- fromMaybe "source" <$> forceOptionalAttrStr attrs "name"+  ref <- forceOptionalAttrStr attrs "ref"+  rev <- forceOptionalAttrStr attrs "rev"+  submodules <- forceOptionalAttrBool "builtins.fetchGit" attrs "submodules" False+  shallow <- forceOptionalAttrBool "builtins.fetchGit" attrs "shallow" False+  allRefs <- forceOptionalAttrBool "builtins.fetchGit" attrs "allRefs" False+  narHash <- forceOptionalAttrStr attrs "narHash"+  fetchGit+    FetchGitArgs+      { fgaUrl = url,+        fgaName = name,+        fgaRef = ref,+        fgaRev = rev,+        fgaSubmodules = submodules,+        fgaShallow = shallow,+        fgaAllRefs = allRefs,+        fgaNarHash = narHash+      }+builtinFetchGit other =+  throwEvalError ("builtins.fetchGit: expected a string or set, got " <> typeName other)++-- | Fetch one revision into the store; returns upstream's @fetchGit@+-- attrset (@outPath@, @rev@, @shortRev@, @revCount@, @submodules@,+-- @lastModified@, @lastModifiedDate@, @narHash@).  @ref@ and @rev@ are+-- validated to upstream's shapes before they reach git (see+-- 'checkGitRef' and 'checkGitRev'); neither may lead with @-@.+fetchGit :: (MonadEval m) => FetchGitArgs -> m NixValue+fetchGit args =+  case (,,) <$> checkGitUrl (fgaUrl args) <*> traverse checkGitRef (fgaRef args) <*> traverse checkGitRev (fgaRev args) of+    Left err -> throwEvalError err+    Right (url, checkedRef, checkedRev) -> do+      let name = fgaName args+          submodules = fgaSubmodules args+          shallow = fgaShallow args+      -- Only a pinned rev is cached: a bare ref means "whatever this branch+      -- points at now", which a note taken earlier cannot answer.+      cached <- case checkedRev of+        Nothing -> pure Nothing+        Just rev -> do+          entry <- lookupFetchCache (fetchCacheKey url name rev submodules shallow)+          case entry >>= decodeFetchCache of+            Nothing -> pure Nothing+            Just fields -> do+              -- The note is only good while the tree it names is still+              -- there, and a hit has to re-record the write the fetch it+              -- stands in for would have recorded: on disk and registered+              -- are different facts, and a derivation naming an+              -- unregistered outPath fails to build.+              adopted <- adoptStorePath (fcStorePath fields)+              pure (if adopted then Just fields else Nothing)+      fields <- case cached of+        Just hit -> pure hit+        Nothing -> fetchGitUncached args {fgaUrl = url} checkedRef checkedRev+      -- A declared narHash pins the tree; a mismatch is an error on the+      -- hit and fresh paths alike, never a silent recompute.  Upstream's+      -- orientation: "expected" is what the fetch produced, "got" is the+      -- declared pin (observed from nix-instantiate 2.33.2).+      case fgaNarHash args of+        Nothing -> pure ()+        Just declared ->+          let computed = "sha256-" <> fcNarHash fields+           in unless (declared == computed) $+                throwEvalError+                  ("builtins.fetchGit: NAR hash mismatch in '" <> url <> "', expected '" <> computed <> "' but got '" <> declared <> "'")+      pure (fetchGitResult submodules fields)++-- | The fetch-everything refspec @allRefs@ selects, upstream's exact+-- spelling: every remote ref lands under the same local name, so a+-- pinned rev reachable from ANY ref becomes fetchable even when the+-- remote refuses a direct SHA want.+allRefsFetchSpec :: Text+allRefsFetchSpec = "refs/*:refs/*"++-- | The fetch itself, when nothing is remembered about it.  @checkedRef@+-- and @checkedRev@ have already passed 'checkGitRef'\/'checkGitRev'.+-- The scratch clone is removed on success once the tree is copied out,+-- and by 'onEvalError' when any step throws: a failing fetch must not+-- leave the clone behind.+fetchGitUncached :: (MonadEval m) => FetchGitArgs -> Maybe Text -> Maybe Text -> m FetchCache+fetchGitUncached args checkedRef checkedRev = do+  cloneDir <- createScratchDir "nova-nix-fetchgit-"+  fetchGitInClone args checkedRef checkedRev cloneDir+    `onEvalError` removeScratchDir cloneDir++-- | Every step of an uncached fetch that can throw, run under+-- 'fetchGitUncached''s scratch-dir cleanup.+fetchGitInClone :: (MonadEval m) => FetchGitArgs -> Maybe Text -> Maybe Text -> Text -> m FetchCache+fetchGitInClone args checkedRef checkedRev cloneDir = do+  let ctx = "builtins.fetchGit"+      git = gitRun ctx cloneDir+      url = fgaUrl args+      name = fgaName args+      submodules = fgaSubmodules args+      shallow = fgaShallow args+      depthArgs = if shallow then ["--depth", "1"] else []+      -- An allRefs fetch writes every remote ref, including the one the+      -- scratch clone's unborn HEAD names, which git refuses in a repo+      -- with a worktree (upstream fetches into a BARE cache repo and+      -- never hits this).  The refusal protects a checked-out branch+      -- from moving under the worktree; the explicit checkout below+      -- replaces the worktree state anyway, so allowing it is safe.+      updateHeadArgs = ["--update-head-ok" | fgaAllRefs args]+      -- Upstream's refspec ternary, in its order.  @allRefs@ fetches+      -- everything.  Otherwise a pinned rev is itself the refspec: a+      -- depth-1 fetch of a branch tip cannot contain any other revision,+      -- so asking the remote for the SHA is what lets @shallow@ and+      -- @rev@ compose (and what finds a rev not on the fetched ref).  A+      -- server may refuse a SHA it does not advertise; that surfaces as+      -- git's own fetch error, the same surface upstream has, and+      -- @allRefs = true@ is the caller's way around it.+      fetchTarget+        | fgaAllRefs args = allRefsFetchSpec+        | Just pinned <- checkedRev = pinned+        | otherwise = fromMaybe defaultFetchRef checkedRef+      -- The pinned rev when there is one; under an allRefs fetch with+      -- only a ref, that ref (FETCH_HEAD after a refs/* fetch is+      -- whichever ref arrived last, not the named one); otherwise+      -- whatever the fetch brought down.+      checkoutTarget = case (checkedRev, fgaAllRefs args, checkedRef) of+        (Just pinned, _, _) -> pinned+        (Nothing, True, Just ref) -> ref+        _ -> fetchHeadRef+  _ <- git ["init", "--quiet", "."]+  _ <- git ["remote", "add", "origin", "--", url]+  -- @--@ before the remote: git keeps parsing options after it, so+  -- without a separator a refspec is indistinguishable from a flag.+  _ <- git (["fetch", "--quiet"] ++ depthArgs ++ updateHeadArgs ++ ["--", "origin", fetchTarget])+  -- Trailing @--@, not leading: @git checkout -- X@ reads X as a+  -- pathspec, the opposite of what a leading separator means elsewhere.+  _ <- git ["checkout", "--quiet", checkoutTarget, "--"]+  when submodules $+    void (git ["submodule", "update", "--init", "--recursive"])+  markExecutablesFromIndex ctx cloneDir+  rev <- git ["rev-parse", "HEAD"]+  -- Truncated history cannot answer rev-list: the count would be the+  -- fetch depth, not the revision's depth, and @revCount@ can reach a+  -- derivation's environment.  Upstream reports 0 under @shallow@ (its+  -- fetcher skips computing the attribute and eval fills the default;+  -- observed from nix-instantiate 2.33.2), so 0 is recorded here and+  -- cache hits replay it.+  revCount <-+    if shallow+      then pure 0+      else decodeDecimal ctx =<< git ["rev-list", "--count", "HEAD"]+  lastModifiedText <- git ["show", "-s", "--format=%ct", "HEAD"]+  removeGitMetadata ctx cloneDir+  storePath <- copyPathToStore cloneDir name Nothing+  narHash <- narHashOfPath cloneDir+  removeScratchDir cloneDir+  lastModified <- decodeDecimal ctx lastModifiedText+  let fields =+        FetchCache+          { fcStorePath = storePath,+            fcRev = rev,+            fcRevCount = revCount,+            fcLastModified = lastModified,+            fcNarHash = bytesToBase64 narHash+          }+  -- Remembered only for a pinned revision; see fetchGit.  @allRefs@ is+  -- deliberately not in the key: it changes only whether a fetch can+  -- succeed, never what a given revision's entry holds.+  case checkedRev of+    Nothing -> pure ()+    Just pinned -> writeFetchCache (fetchCacheKey url name pinned submodules shallow) (encodeFetchCache fields)+  pure fields++-- | Mark every file git records as executable (mode 100755) in the+-- checked-out tree.  A no-op on Unix; on Windows the mode has nowhere to+-- live on disk, so it's read back from the index before @.git@ is stripped.+markExecutablesFromIndex :: (MonadEval m) => Text -> Text -> m ()+markExecutablesFromIndex ctx cloneDir = do+  listing <- gitRun ctx cloneDir ["ls-files", "--recurse-submodules", "--stage", "-z"]+  mapM_ markOne (T.split (== '\0') listing)+  where+    markOne row+      | Just rest <- T.stripPrefix "100755 " row,+        (_, tabbed) <- T.breakOn "\t" rest,+        Just relPath <- T.stripPrefix "\t" tabbed,+        not (T.null relPath) =+          setExecutableFile (cloneDir <> "/" <> relPath)+      | otherwise = pure ()++-- | What @builtins.fetchGit@ hands back, from the few facts about a fetch+-- that are worth keeping.  Shared by the fetch and by the cache, so the two+-- cannot describe the same tree differently.+data FetchCache = FetchCache+  { fcStorePath :: !Text,+    fcRev :: !Text,+    fcRevCount :: !Integer,+    fcLastModified :: !Integer,+    fcNarHash :: !Text+  }+  deriving (Eq, Show)++fetchGitResult :: Bool -> FetchCache -> NixValue+fetchGitResult submodules fields =+  let lastModifiedDate =+        T.pack+          ( formatTime+              defaultTimeLocale+              "%Y%m%d%H%M%S"+              (posixSecondsToUTCTime (fromIntegral (fcLastModified fields)))+          )+   in VAttrs+        ( attrSetFromMap $+            Map.fromList+              [ ("outPath", evaluated (VPath (canonPathValue (fcStorePath fields)))),+                ("rev", evaluated (mkStr (fcRev fields))),+                ("shortRev", evaluated (mkStr (T.take 7 (fcRev fields)))),+                ("revCount", evaluated (VInt (fromIntegral (fcRevCount fields)))),+                ("submodules", evaluated (VBool submodules)),+                ("lastModified", evaluated (VInt (fromIntegral (fcLastModified fields)))),+                ("lastModifiedDate", evaluated (mkStr lastModifiedDate)),+                ("narHash", evaluated (mkStr ("sha256-" <> fcNarHash fields)))+              ]+        )++-- | What a remembered fetch is filed under.  Everything that decides what+-- comes back is in the key, so a hit cannot describe a different fetch: the+-- URL, the store name, the revision, whether submodules were taken, and+-- whether the clone was shallow.+--+-- @shallow@ is in the key even though it cannot change the tree, because it+-- decides @revCount@: a shallow fetch reports 0 where a full clone of the+-- same rev counts the real depth.  Sharing an entry between the two would+-- let whichever ran first decide what the other sees, and @revCount@ can+-- reach a derivation's environment, so that is a reproducibility problem+-- rather than a stale-data one.+--+-- The leading version tag invalidates every entry written before a change+-- in what the fields mean.  It last moved for the Windows executable bit,+-- which changes the @narHash@ of any tree holding an executable file.+fetchCacheKey :: Text -> Text -> Text -> Bool -> Bool -> Text+fetchCacheKey url name rev submodules shallow =+  T.intercalate+    "\n"+    [ "fetchGit-2",+      url,+      name,+      rev,+      if submodules then "submodules" else "no-submodules",+      if shallow then "shallow" else "full"+    ]++-- | One field per line, in a fixed order.  Anything that does not read back+-- as exactly five lines is treated as no entry at all.+encodeFetchCache :: FetchCache -> Text+encodeFetchCache fields =+  T.intercalate+    "\n"+    [ fcStorePath fields,+      fcRev fields,+      T.pack (show (fcRevCount fields)),+      T.pack (show (fcLastModified fields)),+      fcNarHash fields+    ]++decodeFetchCache :: Text -> Maybe FetchCache+decodeFetchCache raw = case T.splitOn "\n" raw of+  [storePath, rev, revCount, lastModified, narHash] -> do+    count <- readDecimalMaybe revCount+    modified <- readDecimalMaybe lastModified+    -- Every field is checked, the last one included.  Truncation before+    -- the last field yields fewer than five and is rejected above, but a+    -- write cut inside narHash still yields five and would otherwise+    -- decode as valid with a short hash - and an entry is trusted for as+    -- long as its store path survives.+    validBase64Sha256 narHash+    pure+      FetchCache+        { fcStorePath = storePath,+          fcRev = rev,+          fcRevCount = count,+          fcLastModified = modified,+          fcNarHash = narHash+        }+  _ -> Nothing+  where+    readDecimalMaybe t = case TR.decimal t of+      Right (value, rest) | T.null rest -> Just value+      _ -> Nothing+    -- Base64 of 32 bytes: 44 characters, the last one the single pad.+    validBase64Sha256 t =+      if T.length t == 44 && T.all isBase64Char (T.init t) && T.last t == '='+        then Just ()+        else Nothing+    isBase64Char c = isAsciiUpper c || isAsciiLower c || isDigit c || c == '+' || c == '/'++-- | Run one git subcommand under the same transport allowlist as the URL+-- check.  Returns trimmed stdout; a nonzero exit throws with git's stderr.+gitRun :: (MonadEval m) => Text -> Text -> [Text] -> m Text+gitRun ctx cloneDir args = do+  (code, out, err) <- runProcess "git" (gitTransportConfig ++ ["-C", cloneDir] ++ args) ""+  if code == 0+    then pure (T.strip out)+    else throwEvalError (ctx <> ": git " <> T.unwords args <> " failed: " <> err)++-- | Strip every @.git@ entry (clone's own, and each submodule's) before the+-- tree is copied to the store.  Walked directly, not shelled out to+-- @find@\/@rm@: no @sh@ on a bare Windows install.+removeGitMetadata :: (MonadEval m) => Text -> Text -> m ()+removeGitMetadata _ctx = go+  where+    go dir = do+      entries <- listDirectory dir+      forM_ entries $ \(name, fileType) ->+        let path = dir <> "/" <> name+         in if name == ".git"+              then removeScratchDir path+              else when (fileType == "directory") $ go path++-- | Parse a decimal from trusted git stdout (@rev-list --count@, @show+-- --format=%ct@); a failure means git itself misbehaved.+decodeDecimal :: (MonadEval m) => Text -> Text -> m Integer+decodeDecimal ctx t = case TR.decimal t of+  Right (n, rest) | T.null rest -> pure n+  _ -> throwEvalError (ctx <> ": expected a decimal integer from git, got " <> t)++-- | Resolve the system temp directory.  Checks @TMPDIR@ (Unix), then+-- @TEMP@ (Windows), falls back to @\/tmp@.+getTempDir :: (MonadEval m) => m Text+getTempDir = do+  candidates <- mapM getEnvVar ["TMPDIR", "TEMP"]+  pure (fromMaybe "/tmp" (find (not . T.null) candidates))++-- | Fetch a URL and optionally verify its hash.+fetchUrlSimple :: (MonadEval m) => Text -> Maybe Text -> m NixValue+fetchUrlSimple url mSha256 = do+  -- The store name derives from the URL alone, so an unusable name fails+  -- here, before the download side effect.  Upstream rejects the same+  -- names when it adds the fetched file to the store.+  let name = urlBaseName url+  case checkStorePathName name of+    Left err -> throwEvalError ("builtins.fetchurl: " <> storePathNameErrorText err)+    Right () -> pure ()+  -- Download to a file, not through the text-mode stdout pipe (which mangles+  -- binary), read the raw bytes, verify the pin if one was given, then store at+  -- the canonical fixed-output path.+  tmpDir <- getTempDir+  let tmpFile = tmpDir <> "/nova-nix-fetchurl-" <> sha256Hex (TE.encodeUtf8 url)+  (code, _, stderr) <- runProcess "curl" ["-sSfL", "-o", tmpFile, "--", url] ""+  if code /= 0+    then throwEvalError ("builtins.fetchurl: fetch failed: " <> stderr)+    else do+      bytes <- readFileBytes tmpFile+      mapM_ (verifyFetchPin "builtins.fetchurl" url bytes) mSha256+      storePath <- addFixedOutputFile name bytes+      pure (VPath storePath)++-- | Store name for a fetched URL: its basename (minus query/fragment), matching+-- C++ Nix's @baseNameOf url@ default so the fixed-output path agrees with it.+urlBaseName :: Text -> Text+urlBaseName url =+  let stripped = T.takeWhile (\c -> c /= '?' && c /= '#') url+      base = T.takeWhileEnd (/= '/') stripped+   in if T.null base then "source" else base++-- | Verify fetched bytes against a user-supplied sha256 pin, erroring on+-- mismatch.  Accepts SRI, @sha256:@-prefixed, and bare digest forms.+verifyFetchPin :: (MonadEval m) => Text -> Text -> BS.ByteString -> Text -> m ()+verifyFetchPin ctx url bytes expectedStr = do+  expected <- decodeSha256Pin ctx expectedStr+  let got = sha256Digest bytes+  when (expected /= got) $+    throwEvalError+      ( ctx+          <> ": hash mismatch for "+          <> url+          <> "\n  expected: "+          <> expectedStr+          <> "\n  got:      sha256:"+          <> bytesToHexText got+      )++-- | Decode a sha256 pin to raw bytes, reusing the convertHash decoders (SRI,+-- @sha256:@-prefixed, or a bare hex/nix32/base64 digest).+decodeSha256Pin :: (MonadEval m) => Text -> Text -> m BS.ByteString+decodeSha256Pin ctx s+  | Just (algo, b64) <- parseSRI s = do+      requireSha256 algo+      decodeSRI ctx algo b64+  | Just (algo, rest) <- parseAlgoPrefix s = do+      requireSha256 algo+      snd <$> decodeWithAlgo algo rest+  | otherwise = snd <$> decodeWithAlgo "sha256" s+  where+    -- The pin names a sha256 digest specifically, so a spelling carrying+    -- its own algorithm tag must carry sha256 - a sha512 pin here is an+    -- error at decode time, as upstream's typed Hash parse.+    requireSha256 "sha256" = pure ()+    requireSha256 algo =+      throwEvalError (ctx <> ": hash '" <> s <> "' should have type 'sha256', not '" <> algo <> "'")++-- | Force a required string attribute from an attrset, using full Nix string+-- coercion (VStr, VPath, VInt, VBool, VAttrs via __toString/outPath).+-- A coercion failure (or a throwing attr value) propagates with its own+-- message, as upstream - swallowing it here once masked user throws.+-- The Text result decodes strictly: callers use it as a name, URL, or+-- platform string, none of which may carry invalid UTF-8 here.+forceAttrStr :: (MonadEval m) => Text -> Text -> AttrSet -> m Text+forceAttrStr builtin key attrs = do+  bytes <- forceAttrBytes builtin key attrs+  decodedText builtin bytes++-- | Like 'forceAttrStr' but returns the coerced RAW BYTES - for+-- derivation fields (builder) that flow byte-exact into the ATerm.+forceAttrBytes :: (MonadEval m) => Text -> Text -> AttrSet -> m BS.ByteString+forceAttrBytes builtin key attrs =+  case attrSetLookup key attrs of+    Nothing -> throwEvalError (builtin <> ": missing required attribute '" <> key <> "'")+    Just thunk -> do+      val <- force thunk+      (s, _ctx) <- coerceToString True force applyValue coercePathVerbatim val+      pure s++-- | Force an optional string attribute via full Nix coercion.  A present+-- but uncoercible (or throwing) value is an error, not 'Nothing'.+forceOptionalAttrStr :: (MonadEval m) => AttrSet -> Text -> m (Maybe Text)+forceOptionalAttrStr attrs key =+  case attrSetLookup key attrs of+    Nothing -> pure Nothing+    Just thunk -> do+      val <- force thunk+      (s, _ctx) <- coerceToString True force applyValue coercePathVerbatim val+      Just <$> decodedText "string attribute" s++-- | Force an optional boolean attribute; present but non-boolean is an error,+-- not a silent coercion.+forceOptionalAttrBool :: (MonadEval m) => Text -> AttrSet -> Text -> Bool -> m Bool+forceOptionalAttrBool builtin attrs key def =+  case attrSetLookup key attrs of+    Nothing -> pure def+    Just thunk -> do+      val <- force thunk+      case val of+        VBool b -> pure b+        other -> throwEvalError (builtin <> ": attribute '" <> key <> "' should be a bool, but is " <> typeName other)++-- ---------------------------------------------------------------------------+-- Builtin implementations - derivation construction+-- ---------------------------------------------------------------------------++-- | Resolve an input derivation's modulo hash (hex) for the ATerm substitution+-- 'toATermForHash' performs.  The drv-hash cache is populated bottom-up as each+-- derivation is evaluated, so an in-session input hits directly.  A miss - a+-- cross-session reference, or a path fabricated by @builtins.appendContext@ -+-- reads the input @.drv@ from the store and recurses, exactly as upstream+-- @hashDerivationModulo@ does.  A miss whose @.drv@ cannot be read (pure+-- evaluation, or a @.drv@ absent from the store) fails loudly: a divergent+-- derivation hash is never emitted from a guessed input hash.  Reading the+-- store is an effect, so hashing a dependent derivation is an IO-evaluator+-- capability - pure evaluation, which cannot read the store, cannot do it.+resolveInputModulo :: (MonadEval m) => (StorePath, [Text]) -> m (Text, [Text])+resolveInputModulo (sp, outs) = do+  let inputPathText = storePathToText defaultStoreDir sp+  cached <- lookupDrvHash inputPathText+  case cached of+    Just hex -> pure (hex, outs)+    Nothing -> do+      mDrv <- readStoreDerivation sp+      case mDrv of+        Just inputDrv -> do+          hex <- derivationModuloHex inputDrv+          cacheDrvHash inputPathText hex+          pure (hex, outs)+        Nothing ->+          throwEvalError+            ( "derivation: cannot compute the input-derivation modulo hash for "+                <> inputPathText+                <> " - it was not evaluated in this session and its .drv is not "+                <> "readable from the store (dependent-derivation hashing needs the IO evaluator)"+            )++-- | The derivation-modulo hash (hex) of a derivation, matching upstream+-- @hashDerivationModulo@: a fixed-output derivation hashes the+-- @fixed:out:\<algo\>:\<hash\>:\<path\>@ form; an input-addressed derivation+-- substitutes each input's modulo hash into its ATerm and hashes that.  These+-- are the same two rules 'builtinDerivationStrict' applies when it first+-- computes a derivation's hash, so a store-read input yields the value it had+-- in-session.+derivationModuloHex :: (MonadEval m) => Derivation -> m Text+derivationModuloHex drv = case drvOutputs drv of+  [DerivationOutput "out" outPath algoField hashHex]+    | not (T.null algoField) && not (T.null hashHex) ->+        let outPathText = storePathToText defaultStoreDir outPath+            fixedForm = "fixed:out:" <> algoField <> ":" <> hashHex <> ":" <> outPathText+         in pure (bytesToHexText (sha256Digest (TE.encodeUtf8 fixedForm)))+  _ -> do+    inputSubst <- mapM resolveInputModulo (Map.toList (drvInputDrvs drv))+    pure (bytesToHexText (sha256Digest (toATermForHash False (Just inputSubst) drv)))++-- | The full output-name list of an all-outputs (upstream DrvDeep) reference:+-- every output name of the referenced @.drv@, which upstream's derivationStrict+-- inserts into the consuming derivation's inputDrvs.  Mirrors the+-- 'resolveInputModulo' ladder - an in-session derivation from the recorded+-- ATerm ('lookupSessionDrv'), else a cross-session @.drv@ read from the store+-- ('readStoreDerivation'), else a loud error rather than a dropped reference+-- (which would under-hash the dependent).  Pure evaluation can read neither+-- source, so a dependent carrying an all-outputs reference errors there,+-- consistent with dependent-derivation hashing being an IO-evaluator capability.+resolveAllOutputNames :: (MonadEval m) => StorePath -> m [Text]+resolveAllOutputNames sp = do+  let drvPathText = storePathToText defaultStoreDir sp+  session <- lookupSessionDrv drvPathText+  case session of+    Just drv -> pure (outputNamesOf drv)+    Nothing -> do+      onDisk <- readStoreDerivation sp+      case onDisk of+        Just drv -> pure (outputNamesOf drv)+        Nothing ->+          throwEvalError+            ( "derivation: cannot resolve the output names of the all-outputs reference "+                <> drvPathText+                <> " - it was not evaluated in this session and its .drv is not "+                <> "readable from the store (all-outputs references need the IO evaluator)"+            )+  where+    outputNamesOf = map doName . drvOutputs++-- | Eager derivation computation - @builtins.derivationStrict@.  Forces all+-- input attrs into env vars, content-hashes, and returns the full derivation+-- attrset (drvPath, outPath, per-output, _derivation).  Called LAZILY by the+-- @derivation@ wrapper ('builtinDerivationLazy'), so forcing a derivation to+-- WHNF never forces this - matching C++ Nix's derivationStrict/derivation split.+builtinDerivationStrict :: (MonadEval m) => NixValue -> m NixValue+builtinDerivationStrict (VAttrs attrs) = do+  -- Extract required attributes.  The name and system are Text (they feed+  -- store-path and platform machinery); the builder stays raw bytes - it+  -- lands byte-exact in the ATerm's builder field.+  drvName <- forceAttrStr "derivation" "name" attrs+  -- The name becomes the store-path name of the .drv and of every output,+  -- so it must satisfy the store-path name rules.  The path constructors+  -- re-check what they build; this early check reports the offending+  -- FIELD rather than a composed path name.+  case checkStorePathName drvName of+    Left err ->+      throwEvalError+        ("derivation: invalid derivation name '" <> drvName <> "': " <> storePathNameReasonText (spneReason err))+    Right () -> pure ()+  system <- forceAttrStr ("derivation \"" <> drvName <> "\"") "system" attrs+  builder <- forceAttrBytes ("derivation \"" <> drvName <> "\"") "builder" attrs++  -- __ignoreNulls: when true, null-valued attrs are dropped from the+  -- derivation env (stdenv.mkDerivation sets it); when absent or false,+  -- null coerces to "" like any other coerceMore value - C++ Nix semantics.+  ignoreNulls <- case attrSetLookup "__ignoreNulls" attrs of+    Nothing -> pure False+    Just thunk -> do+      val <- force thunk+      case val of+        VBool b -> pure b+        other -> throwEvalError ("derivation: '__ignoreNulls' must be a boolean, got " <> typeName other)++  -- Extract optional outputs (default ["out"])+  outputNames <- case attrSetLookup "outputs" attrs of+    Nothing -> pure ["out"]+    Just thunk -> do+      val <- force thunk+      case val of+        VList cl -> mapM (forceToText . Thunk) (clistThunks cl)+        -- A null outputs attr is dropped by __ignoreNulls, falling back to+        -- the default output set; without it, null is an error as upstream.+        VNull | ignoreNulls -> pure ["out"]+        _ -> throwEvalError "derivation: 'outputs' must be a list of strings"++  -- Each output name composes into a store-path name (drvName-<output>)+  -- and names the on-disk location the builder later clears and moves+  -- onto, so it must satisfy the same store-path name rules (the+  -- composed length is re-checked at construction).+  forM_ outputNames $ \outName ->+    case checkStorePathName outName of+      Left err ->+        throwEvalError+          ( "derivation \""+              <> drvName+              <> "\": invalid derivation output name '"+              <> outName+              <> "': "+              <> storePathNameReasonText (spneReason err)+          )+      Right () -> pure ()++  -- Extract optional args (default []).  Path literals in args (e.g. stdenv's+  -- ./default-builder.sh) are copied into the store; their source paths flow+  -- into inputSrcs via the returned context.+  (builderArgs, argsContext) <- case attrSetLookup "args" attrs of+    Nothing -> pure ([], mempty)+    Just thunk -> do+      val <- force thunk+      case val of+        VList cl -> do+          parts <- mapM (\t -> force (Thunk t) >>= coerceToStoreString) (clistThunks cl)+          pure (map fst parts, mconcat (map snd parts))+        VNull | ignoreNulls -> pure ([], mempty)+        _ -> throwEvalError "derivation: 'args' must be a list of strings"++  -- Materialize once, reuse for both env collection and result merge+  let materialized = attrSetToMap attrs++  -- Collect string-coercible attrs into the build env, EXCLUDING "args"+  -- (C++ Nix puts args in the Derive() args field, never the env).  The+  -- per-output env vars ($out, ...) are added below.  Carries merged context.+  (drvEnvPairs, envContext) <- collectDrvEnvWithContext ignoreNulls (Map.delete "__ignoreNulls" (Map.delete "args" materialized))++  let fullContext = envContext <> argsContext+      builtInputDrvs = extractInputDrvs fullContext+      inputSrcs = extractInputSrcs fullContext+      allOutputRefs = extractAllOutputRefs fullContext+  -- All-outputs (DrvDeep) references - e.g. an embedded @dep.drvPath@ - add the+  -- referenced .drv to inputDrvs with ALL its output names, as upstream's+  -- derivationStrict does.  Merged in BEFORE drvRefs and the modulo+  -- substitution so both the dependent's own .drv hash and its output paths+  -- account for the reference.+  deepInputDrvs <-+    Map.fromList <$> mapM (\drvSp -> (,) drvSp <$> resolveAllOutputNames drvSp) allOutputRefs+  let inputDrvs = Map.unionWith (++) builtInputDrvs deepInputDrvs+      platform = textToPlatform system+      baseEnv = Map.fromList drvEnvPairs+      drvRefs = inputSrcs ++ Map.keys inputDrvs+      drvFileName = drvName <> ".drv"+      -- Build a Derivation sharing this call's inputs/platform/builder/args.+      mkDrv outs env =+        Derivation+          { drvOutputs = outs,+            drvInputDrvs = inputDrvs,+            drvInputSrcs = inputSrcs,+            drvPlatform = platform,+            drvBuilder = builder,+            drvArgs = builderArgs,+            drvEnv = env+          }+      -- Output carrying only its name; the path is masked at render time.+      maskedOutput name = DerivationOutput name maskedOutputPath "" ""++  -- Fixed-output derivations (fetchurl etc.) are content-addressed and hash+  -- via the @fixed:out:@ scheme; input-addressed derivations recurse through+  -- the modulo hashes of their inputs.+  mFixed <- detectFixedOutput attrs++  -- Path construction returns Left on a name the store rules reject; the+  -- early field checks above make that unreachable here except through+  -- composition (drvName <> ".drv", drvName-output past the length cap),+  -- which only the constructors see.+  let drvContext = "derivation \"" <> drvName <> "\""+  (drvPathText, drvSP, outPaths, completeDrv) <- case mFixed of+    Just (foAlgo, foMode, foDigest) -> do+      foPath <- storePathOrThrow drvContext (makeFixedOutputPath drvName foAlgo foMode foDigest)+      let foPathText = storePathToText defaultStoreDir foPath+          algoField = (if foMode == "recursive" then "r:" else "") <> foAlgo+          foHashHex = bytesToHexText foDigest+          foModulo =+            sha256Digest+              (TE.encodeUtf8 ("fixed:out:" <> algoField <> ":" <> foHashHex <> ":" <> foPathText))+          contents =+            mkDrv+              [DerivationOutput "out" foPath algoField foHashHex]+              (Map.insert "out" (TE.encodeUtf8 foPathText) baseEnv)+      drvSp <- storePathOrThrow drvContext (makeTextPath drvFileName (sha256Digest (toATerm contents)) drvRefs)+      let drvText = storePathToText defaultStoreDir drvSp+      cacheDrvHash drvText (bytesToHexText foModulo)+      pure (drvText, drvSp, [("out", foPathText)], contents)+    Nothing -> do+      inputSubst <- mapM resolveInputModulo (Map.toList inputDrvs)+      let maskedEnv = foldr (`Map.insert` "") baseEnv outputNames+          maskedDrv = mkDrv (map maskedOutput outputNames) maskedEnv+          -- Masked modulo hash yields this derivation's own output paths.+          moduloMasked = sha256Digest (toATermForHash True (Just inputSubst) maskedDrv)+      outStorePaths <-+        mapM+          (\n -> (,) n <$> storePathOrThrow drvContext (makeOutputPath n moduloMasked drvName))+          outputNames+      let outPathTexts = [(n, storePathToText defaultStoreDir sp) | (n, sp) <- outStorePaths]+          realEnv = foldr (\(n, t) e -> Map.insert n (TE.encodeUtf8 t) e) baseEnv outPathTexts+          contents = mkDrv [DerivationOutput n sp "" "" | (n, sp) <- outStorePaths] realEnv+          -- Unmasked modulo hash (real outputs, inputs substituted) cached for+          -- when this derivation is itself an input to another.+          moduloUnmasked = sha256Digest (toATermForHash False (Just inputSubst) contents)+      drvSp <- storePathOrThrow drvContext (makeTextPath drvFileName (sha256Digest (toATerm contents)) drvRefs)+      let drvText = storePathToText defaultStoreDir drvSp+      cacheDrvHash drvText (bytesToHexText moduloUnmasked)+      pure (drvText, drvSp, outPathTexts, contents)++  -- Record this derivation's full .drv ATerm (the exact bytes whose hash is its+  -- store path) so the build driver can materialize the entire input-.drv+  -- closure before building.  Bottom-up eval guarantees every transitive input+  -- is recorded by the time a dependent is.+  recordDrvAterm drvPathText (toATerm completeDrv)++  let mainOutPath = case outPaths of+        ((_, p) : _) -> p+        [] -> ""+      -- The default output is the FIRST in @outputs@ (matching C++ Nix, which+      -- returns @(head outputsList).value@) - not necessarily @out@.+      mainOutName = case outPaths of+        ((n, _) : _) -> n+        [] -> "out"++  -- Context for output paths: each output carries SCDrvOutput context+  -- Context for drvPath: carries SCAllOutputs context+  let drvPathCtx = StringContext (Set.singleton (SCAllOutputs drvSP))+      outPathCtx outName = StringContext (Set.singleton (SCDrvOutput drvSP outName))++  -- Build per-output attrsets matching Nix: drv.out = { outPath, drvPath, type }+  let mkOutputAttrs outName outP =+        let outCtx = outPathCtx outName+            outputAttrMap =+              Map.fromList+                [ ("outPath", evaluated (VStr (TE.encodeUtf8 outP) outCtx)),+                  ("drvPath", evaluated (VStr (TE.encodeUtf8 drvPathText) drvPathCtx)),+                  ("type", evaluated (mkStr "derivation"))+                ]+         in evaluated (VAttrs (attrSetFromMap outputAttrMap))++  -- Build result attrset: original attrs + drvPath, outPath, type, per-output attrs+  let baseAttrs =+        Map.fromList $+          [ ("type", evaluated (mkStr "derivation")),+            ("drvPath", evaluated (VStr (TE.encodeUtf8 drvPathText) drvPathCtx)),+            ("outPath", evaluated (VStr (TE.encodeUtf8 mainOutPath) (outPathCtx mainOutName))),+            ("name", evaluated (mkStr drvName)),+            ("system", evaluated (mkStr system)),+            ("builder", evaluated (mkStrBytes builder)),+            ("_derivation", evaluated (VDerivation completeDrv))+          ]+            ++ [(outName, mkOutputAttrs outName outP) | (outName, outP) <- outPaths]+      -- Merge original attrs underneath so computed attrs take priority+      resultAttrs = Map.union baseAttrs materialized++  pure (VAttrs (attrSetFromMap resultAttrs))+builtinDerivationStrict other =+  throwEvalError ("derivation: expected a set, got " <> typeName other)++-- | Detect a fixed-output derivation.  Returns @Just (algo, mode, rawDigest)@+-- when @outputHash@ is present and non-empty (fetchurl, fetchgit, ...), else+-- 'Nothing' for an ordinary input-addressed derivation.  @mode@ is+-- @\"flat\"@ or @\"recursive\"@; @algo@ is e.g. @\"sha256\"@.+detectFixedOutput :: (MonadEval m) => AttrSet -> m (Maybe (Text, Text, BS.ByteString))+detectFixedOutput attrs =+  case attrSetLookup "outputHash" attrs of+    Nothing -> pure Nothing+    Just thunk -> do+      val <- force thunk+      case val of+        VStr rawHash _+          | not (BS.null rawHash) -> do+              ohash <- decodedText "derivation: outputHash" rawHash+              ohAlgo <- optDrvStrAttr "outputHashAlgo" attrs+              ohMode <- optDrvStrAttr "outputHashMode" attrs+              (algo, digest) <- normalizeFixedHash ohash ohAlgo+              let mode = if ohMode == "recursive" then "recursive" else "flat"+              pure (Just (algo, mode, digest))+        _ -> pure Nothing++-- | Read an optional string attribute, defaulting to @\"\"@ when absent or+-- not a string.+optDrvStrAttr :: (MonadEval m) => Text -> AttrSet -> m Text+optDrvStrAttr key attrs =+  case attrSetLookup key attrs of+    Nothing -> pure ""+    Just thunk -> do+      val <- force thunk+      case val of+        VStr s _ -> decodedText ("derivation: " <> key) s+        _ -> pure ""++-- | Decode a fixed-output hash (SRI @algo-base64@, @algo:hash@, or a bare+-- hash plus a separate algorithm) to its algorithm name and raw bytes.+-- A non-empty @outputHashAlgo@ must name a known algorithm and agree with+-- the algorithm an SRI or prefixed spelling carries: the enforced+-- algorithm must be the declared one, never a silent substitute.+normalizeFixedHash :: (MonadEval m) => Text -> Text -> m (Text, BS.ByteString)+normalizeFixedHash ohash ohAlgo+  | Just (algo, b64) <- parseSRI ohash = do+      requireDeclaredAlgo algo+      bytes <- decodeSRI "derivation" algo b64+      pure (algo, bytes)+  | Just (algo, rest) <- parseAlgoPrefix ohash = do+      requireDeclaredAlgo algo+      decodeWithAlgo algo rest+  | not (T.null ohAlgo) = decodeWithAlgo ohAlgo ohash+  | otherwise =+      throwEvalError ("derivation: cannot determine outputHash algorithm for " <> ohash)+  where+    -- 'decodeSha256Pin's requireSha256 with the expected type supplied by+    -- @outputHashAlgo@ instead of fixed at sha256 (upstream's typed Hash+    -- parse, hash.cc parseAny with an expected type).  An unknown declared+    -- algorithm is an error even when the spelling carries its own tag.+    requireDeclaredAlgo embedded+      | T.null ohAlgo = pure ()+      | Nothing <- hashAlgoBytes ohAlgo =+          throwEvalError ("unknown hash algorithm '" <> ohAlgo <> "'")+      | embedded == ohAlgo = pure ()+      | otherwise =+          throwEvalError+            ("derivation: hash '" <> ohash <> "' should have type '" <> ohAlgo <> "', not '" <> embedded <> "'")++-- | Lazy @derivation@ wrapper, mirroring upstream's+-- @src/libexpr/primops/derivation.nix@.+-- Returns a WHNF attrset whose @drvPath@/@outPath@/output-path/@_derivation@+-- attrs are LAZY thunks that defer to 'builtinDerivationStrict'.  Forcing a+-- derivation to WHNF therefore does NOT force its input/env closure - which is+-- essential for nixpkgs, where merely referencing a derivation (e.g.+-- @drv != null@, @assert (libxcrypt != null)@) must not build its whole closure.+--+-- The lazy thunks are built with the same synthetic-select pattern used by+-- @inherit (from)@: a single shared @strict@ thunk (so the eager computation+-- runs at most once) selected from via fresh minimal envs.+builtinDerivationLazy :: (MonadEval m) => NixValue -> m NixValue+builtinDerivationLazy (VAttrs attrs) = do+  -- Output names are cheap (matches @drvAttrs @ { outputs ? [ "out" ], ... }@).+  outputNames <- case attrSetLookup "outputs" attrs of+    Nothing -> pure ["out"]+    Just thunk -> do+      val <- force thunk+      case val of+        VList cl -> mapM (forceToText . Thunk) (clistThunks cl)+        _ -> throwEvalError "derivation: 'outputs' must be a list of strings"+  -- One shared thunk computing @derivationStrict attrs@, forced only when an+  -- output path / drvPath is actually read.+  let drvAttrsThunk = evaluated (VAttrs attrs)+      strictBuiltinThunk = evaluated (VBuiltin "derivationStrict" [])+      strictThunk =+        let (sp, sc) = buildCSlots [drvAttrsThunk, strictBuiltinThunk]+            envDS = newMinimalEnv sp sc+         in mkSyntheticThunk envDS (EApp (EResolvedVar 0 1) (EResolvedVar 0 0))+      selectStrict field =+        let (sp, sc) = buildCSlots [strictThunk]+            envF = newMinimalEnv sp sc+         in mkSyntheticThunk envF (ESelect (EResolvedVar 0 0) [StaticKey field] Nothing)+  -- WHNF spine: input attrs (unforced) overlaid with the lazy computed attrs.+  let computedAttrs =+        Map.fromList $+          [ ("type", evaluated (mkStr "derivation")),+            ("drvPath", selectStrict "drvPath"),+            ("outPath", selectStrict "outPath"),+            ("_derivation", selectStrict "_derivation")+          ]+            ++ [(outName, selectStrict outName) | outName <- outputNames]+      resultAttrs = Map.union computedAttrs (attrSetToMap attrs)+  pure (VAttrs (attrSetFromMap resultAttrs))+builtinDerivationLazy other =+  throwEvalError ("derivation: expected a set, got " <> typeName other)++-- | Force a thunk to a Text string via full Nix coercion (strict decode:+-- used for output names, which are ASCII-shaped identity components).+forceToText :: (MonadEval m) => Thunk -> m Text+forceToText thunk = do+  val <- force thunk+  (s, _ctx) <- coerceToString True force applyValue coercePathVerbatim val+  decodedText "derivation" s++-- | Collect all derivation attributes into env pairs via full Nix coercion+-- (__toString, outPath, list-to-space-separated-string), along with the+-- merged string context from all collected values.  Values are RAW BYTES:+-- they flow byte-exact into the ATerm env section (and its hash).+--+-- A coercion failure (a thrown attr value, an uncoercible function, a failed+-- import inside the value) fails the WHOLE derivation, as in C++ Nix.+-- Swallowing it silently produces a wrong .drv - this exact bug once turned+-- a seed derivation with 17 failed src attrs into an empty no-input drv that+-- "built" successfully.  Null attrs are dropped only under __ignoreNulls;+-- otherwise null coerces to @""@ like any other coerceMore value.+collectDrvEnvWithContext :: (MonadEval m) => Bool -> Map Text Thunk -> m ([(Text, BS.ByteString)], StringContext)+collectDrvEnvWithContext ignoreNulls attrs = do+  let pairs = Map.toList attrs+  results <- mapM coerceEnvAttr pairs+  let envPairs = catMaybes [fmap (\(k, v, _) -> (k, v)) r | r <- results]+      mergedCtx = mconcat [ctx | Just (_, _, ctx) <- results]+  pure (envPairs, mergedCtx)+  where+    coerceEnvAttr (key, thunk) = do+      val <- force thunk+      case val of+        VNull | ignoreNulls -> pure Nothing+        _ -> do+          (s, ctx) <- coerceToStoreString val+          pure (Just (key, s, ctx))++-- ---------------------------------------------------------------------------+-- Builtin implementations - hashFile, readFileType+-- ---------------------------------------------------------------------------++-- | @builtins.hashFile algo path@ - hash raw bytes of a file on disk.+-- Returns base-16 hex string, matching @builtins.hashString@ output format.+builtinHashFile :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinHashFile (VStr algo _) (VPath path) = do+  algoName <- decodedText "builtins.hashFile" algo+  bytes <- readFileBytes path+  hashBytesWithAlgo "hashFile" algoName bytes+builtinHashFile (VStr algo _) (VStr path _) = do+  algoName <- decodedText "builtins.hashFile" algo+  filePath <- decodedText "builtins.hashFile" path+  bytes <- readFileBytes filePath+  hashBytesWithAlgo "hashFile" algoName bytes+builtinHashFile (VStr _ _) other =+  throwEvalError ("builtins.hashFile: expected a path, got " <> typeName other)+builtinHashFile other _ =+  throwEvalError ("builtins.hashFile: expected a string, got " <> typeName other)++-- | Shared hash dispatch for raw 'ByteString' input.+hashBytesWithAlgo :: (MonadEval m) => Text -> Text -> BS.ByteString -> m NixValue+hashBytesWithAlgo ctx algo bytes = case algo of+  "sha256" -> pure (mkStr (digestToHex (CH.hash bytes :: CH.Digest CH.SHA256)))+  "sha512" -> pure (mkStr (digestToHex (CH.hash bytes :: CH.Digest CH.SHA512)))+  "sha1" -> pure (mkStr (digestToHex (CH.hash bytes :: CH.Digest CH.SHA1)))+  "md5" -> pure (mkStr (digestToHex (CH.hash bytes :: CH.Digest CH.MD5)))+  _ -> throwEvalError ("builtins." <> ctx <> ": unknown hash algorithm '" <> algo <> "'")++-- | @builtins.readFileType path@ - classify a filesystem entry.+-- Returns @"regular"@, @"directory"@, @"symlink"@, or @"unknown"@.+builtinReadFileType :: (MonadEval m) => NixValue -> m NixValue+builtinReadFileType (VPath path) = mkStr <$> getFileType path+builtinReadFileType (VStr path _) = do+  filePath <- decodedText "builtins.readFileType" path+  mkStr <$> getFileType filePath+builtinReadFileType other =+  throwEvalError ("builtins.readFileType: expected a path, got " <> typeName other)++-- ---------------------------------------------------------------------------+-- Builtin implementations - convertHash+-- ---------------------------------------------------------------------------++-- | @builtins.convertHash { hash, hashAlgo?, toHashFormat }@ - convert+-- between hash representations.  Supports base16, nix32, base64, and sri.+builtinConvertHash :: (MonadEval m) => NixValue -> m NixValue+builtinConvertHash (VAttrs attrs) = do+  hashVal <- requireStrAttr "convertHash" "hash" attrs+  toFmt <- requireStrAttr "convertHash" "toHashFormat" attrs+  -- Detect input format and decode to raw bytes + algo+  (algo, rawBytes) <- decodeHashInput attrs hashVal+  -- Encode to target format+  case toFmt of+    "base16" -> pure (mkStr (bytesToHexText rawBytes))+    "nix32" -> pure (mkStr (Nix32.encode rawBytes))+    "base32" -> pure (mkStr (Nix32.encode rawBytes)) -- deprecated alias+    "base64" -> pure (mkStr (bytesToBase64 rawBytes))+    "sri" -> pure (mkStr (algo <> "-" <> bytesToBase64 rawBytes))+    _ -> throwEvalError ("builtins.convertHash: unknown toHashFormat '" <> toFmt <> "'")+builtinConvertHash other =+  throwEvalError ("builtins.convertHash: expected a set, got " <> typeName other)++-- | Extract algo + raw bytes from the hash input, handling SRI, prefixed, and plain formats.+decodeHashInput :: (MonadEval m) => AttrSet -> Text -> m (Text, BS.ByteString)+decodeHashInput attrs hashStr+  -- SRI format: algo-base64+  | Just (algo, b64) <- parseSRI hashStr = do+      bytes <- decodeSRI "convertHash" algo b64+      pure (algo, bytes)+  -- Prefixed format: algo:hex or algo:nix32+  | Just (algo, rest) <- parseAlgoPrefix hashStr =+      decodeWithAlgo algo rest+  -- Plain hash - need hashAlgo attribute+  | otherwise = do+      algo <- requireStrAttr "convertHash" "hashAlgo" attrs+      decodeWithAlgo algo hashStr++-- | Decode a bare hash string for a known algorithm, keyed by length as+-- upstream (@hash.cc@ @Hash@ parsing): an algorithm's base16, nix32, and+-- base64 spellings have pairwise distinct lengths, so the input length+-- selects the decoder and every other length is an error.  Trying decoders+-- in sequence instead mis-reads edge inputs - an all-hex-digit string of+-- nix32 length is a nix32 hash, and a truncated hash must be rejected, not+-- decoded by whichever shorter format happens to accept it.+decodeWithAlgo :: (MonadEval m) => Text -> Text -> m (Text, BS.ByteString)+decodeWithAlgo algo s = case hashAlgoBytes algo of+  Nothing -> throwEvalError ("unknown hash algorithm '" <> algo <> "'")+  Just size+    | T.length s == hexHashLen size -> accept size "base16" (hexToBytes s)+    | T.length s == nix32HashLen size -> accept size "nix32" (rightToMaybe (Nix32.decode s))+    | T.length s == base64HashLen size -> accept size "base64" (rightToMaybe (decodeBase64Pure s))+    | otherwise ->+        throwEvalError ("hash '" <> s <> "' has wrong length for hash algorithm '" <> algo <> "'")+  where+    accept size spelling decoded = case decoded of+      Just bytes | BS.length bytes == size -> pure (algo, bytes)+      _ -> throwEvalError ("hash '" <> s <> "' is not a valid " <> spelling <> " " <> algo <> " hash")+    rightToMaybe = either (const Nothing) Just++-- | Decode an SRI digest (@algo-base64@) with the decoded-length check+-- every spelling gets upstream (hash.cc checks SRI too): well-formed+-- base64 of the wrong byte count is an invalid hash at eval time, not a+-- short digest that defers failure to fetch or build time.  Shared by+-- convertHash, the fetch pins, and fixed-output outputHash.+decodeSRI :: (MonadEval m) => Text -> Text -> Text -> m BS.ByteString+decodeSRI ctx algo b64 = case hashAlgoBytes algo of+  -- Unreachable via parseSRI (it admits only known algorithm tags), but+  -- this helper must not silently skip the check for an unknown one.+  Nothing -> throwEvalError ("unknown hash algorithm '" <> algo <> "'")+  Just size -> do+    bytes <- decodeBase64E ctx b64+    when (BS.length bytes /= size) $+      throwEvalError+        ("hash '" <> algo <> "-" <> b64 <> "' has wrong length for hash algorithm '" <> algo <> "'")+    pure bytes++-- | Parse @sha256-base64...@ SRI format.+parseSRI :: Text -> Maybe (Text, Text)+parseSRI t = case T.breakOn "-" t of+  (algo, rest)+    | not (T.null rest) && algo `elem` ["sha256", "sha512", "sha1", "md5"] ->+        Just (algo, T.drop 1 rest)+  _ -> Nothing++-- | Parse @sha256:value@ prefixed format.+parseAlgoPrefix :: Text -> Maybe (Text, Text)+parseAlgoPrefix t = case T.breakOn ":" t of+  (algo, rest)+    | not (T.null rest) && algo `elem` ["sha256", "sha512", "sha1", "md5"] ->+        Just (algo, T.drop 1 rest)+  _ -> Nothing++-- | Require a string attribute from an attrset.+requireStrAttr :: (MonadEval m) => Text -> Text -> AttrSet -> m Text+requireStrAttr ctx key attrs = case attrSetLookup key attrs of+  Just thunk -> do+    val <- force thunk+    case val of+      VStr s _ -> decodedText ("builtins." <> ctx) s+      _ -> throwEvalError ("builtins." <> ctx <> ": " <> key <> " must be a string")+  Nothing -> throwEvalError ("builtins." <> ctx <> ": missing required attribute '" <> key <> "'")++-- ---------------------------------------------------------------------------+-- Base64 encode/decode - delegates to nova-cache (base64-bytestring under the hood)+-- ---------------------------------------------------------------------------++-- | Encode bytes to base64.+bytesToBase64 :: BS.ByteString -> Text+bytesToBase64 = B64.encode++-- | Decode base64 text to bytes (pure).+decodeBase64Pure :: Text -> Either Text BS.ByteString+decodeBase64Pure t =+  -- Strip whitespace and any existing padding, then re-pad to a multiple of+  -- 4.  base64-bytestring's 'decode' requires correct padding, so SRI hashes+  -- (correctly-padded standard base64, e.g. @sha256-...NQ=@) would otherwise be+  -- rejected once their trailing @=@ was removed.+  let stripped = T.filter (\c -> c /= '\n' && c /= '\r' && c /= '=') t+      padLen = (4 - (T.length stripped `mod` 4)) `mod` 4+      padded = stripped <> T.replicate padLen "="+   in case B64.decode padded of+        Right bytes -> Right bytes+        Left _ -> Left "invalid base64"++-- | Decode base64 with error context for builtins.+decodeBase64E :: (MonadEval m) => Text -> Text -> m BS.ByteString+decodeBase64E ctx t = case decodeBase64Pure t of+  Right bytes -> pure bytes+  Left _ -> throwEvalError ("builtins." <> ctx <> ": invalid base64 encoding")++-- ---------------------------------------------------------------------------+-- Builtin implementations - fromTOML+-- ---------------------------------------------------------------------------++-- | @builtins.fromTOML str@ - parse a TOML document to a Nix value.+-- Hand-rolled parser covering the TOML v1.0 subset used by nixpkgs:+-- bare/quoted keys, dotted keys, basic/literal strings (multiline),+-- integers (dec/hex/oct/bin), floats (inc. inf/nan), booleans,+-- inline tables, arrays, array-of-tables, and standard tables.+-- Datetimes are represented as strings (matching real Nix).+builtinFromTOML :: (MonadEval m) => NixValue -> m NixValue+builtinFromTOML (VStr s _) = do+  -- TOML is UTF-8 by definition; upstream's parser rejects invalid bytes.+  decoded <- decodedText "builtins.fromTOML" s+  case parseTOML decoded of+    Right val -> pure val+    Left err -> throwEvalError ("builtins.fromTOML: " <> err)+builtinFromTOML other =+  throwEvalError ("builtins.fromTOML: expected a string, got " <> typeName other)++-- | Intermediate TOML value before conversion to NixValue.+data TOMLValue+  = TOMLStr !Text+  | TOMLInt !Int64+  | TOMLFloat !Double+  | TOMLBool !Bool+  | TOMLArray ![TOMLValue]+  | TOMLTable !(Map Text TOMLValue)+  deriving (Show)++-- | Parse a TOML document into a NixValue.+parseTOML :: Text -> Either Text NixValue+parseTOML input = do+  table <- parseTOMLDoc (T.lines input)+  pure (tomlToNix (TOMLTable table))++-- | Convert a TOMLValue to NixValue.+tomlToNix :: TOMLValue -> NixValue+tomlToNix val = case val of+  TOMLStr s -> mkStr s+  TOMLInt n -> VInt n+  TOMLFloat d -> VFloat d+  TOMLBool b -> VBool b+  TOMLArray xs -> VList (clistFromThunks (map (thunkToCPtr . evaluated . tomlToNix) xs))+  TOMLTable m -> VAttrs (attrSetFromMap (Map.map (evaluated . tomlToNix) m))++-- | Parse all lines of a TOML document into a table.+parseTOMLDoc :: [Text] -> Either Text (Map Text TOMLValue)+parseTOMLDoc lns = go lns [] Map.empty+  where+    go [] _ root = Right root+    go (line : rest) currentPath root+      | T.null stripped || T.isPrefixOf "#" stripped =+          -- Empty line or comment+          go rest currentPath root+      | T.isPrefixOf "[[" stripped && T.isSuffixOf "]]" stripped =+          -- Array of tables: [[key]]+          let keyStr = T.strip (T.drop 2 (T.dropEnd 2 stripped))+              keys = parseDottedKey keyStr+           in go rest keys (insertArrayTable keys root)+      | T.isPrefixOf "[" stripped && T.isSuffixOf "]" stripped =+          -- Standard table: [key]+          let keyStr = T.strip (T.drop 1 (T.dropEnd 1 stripped))+              keys = parseDottedKey keyStr+           in go rest keys (ensureTable keys root)+      | otherwise =+          -- Key = value pair.  The value may span physical lines (arrays+          -- and multi-line strings - the Cargo.lock shapes), so join+          -- until brackets and multi-line string delimiters balance.+          let (logical, remaining) = joinLogicalLine stripped rest+           in case parseKVLine logical of+                Right (keys, val) ->+                  go remaining currentPath (insertNested (currentPath ++ keys) val root)+                Left err -> Left err+      where+        stripped = T.strip line++-- | Which TOML string form a scan position is inside.+data TomlStringState = TSNone | TSBasic | TSLiteral | TSMultiBasic | TSMultiLiteral+  deriving (Eq)++-- | Scanner state for joining physical lines into one logical+-- key = value line: array-bracket depth outside strings, plus the+-- string form currently open.+data TomlScan = TomlScan+  { tsBracketDepth :: !Int,+    tsString :: !TomlStringState+  }++emptyTomlScan :: TomlScan+emptyTomlScan = TomlScan 0 TSNone++-- | Whether a logical value is still open at end of line: inside a+-- multi-line string, or under an unclosed array bracket.  A single-line+-- string left open is NOT continuable (TOML basic\/literal strings+-- cannot span lines) - the value parser reports that case.+tomlNeedsMoreLines :: TomlScan -> Bool+tomlNeedsMoreLines st =+  tsBracketDepth st > 0+    || tsString st == TSMultiBasic+    || tsString st == TSMultiLiteral++-- | Scan one physical line, returning the state at end of line and the+-- line's visible text: a comment outside strings is cut here, where the+-- string context is still known ('#' inside any string form is content).+scanTomlLine :: TomlScan -> Text -> (TomlScan, Text)+scanTomlLine st0 line = go st0 line 0+  where+    go st t !seen = case T.uncons t of+      Nothing -> (st, line)+      Just (c, rest) ->+        let one newSt = go newSt rest (seen + 1)+            jump n newSt = go newSt (T.drop (n - 1) rest) (seen + n)+         in case tsString st of+              TSBasic+                | c == '\\' -> jump 2 st+                | c == '"' -> one st {tsString = TSNone}+                | otherwise -> one st+              TSLiteral+                | c == '\'' -> one st {tsString = TSNone}+                | otherwise -> one st+              TSMultiBasic+                | c == '\\' -> jump 2 st+                | c == '"', Just _ <- T.stripPrefix "\"\"" rest -> jump 3 st {tsString = TSNone}+                | otherwise -> one st+              TSMultiLiteral+                | c == '\'', Just _ <- T.stripPrefix "''" rest -> jump 3 st {tsString = TSNone}+                | otherwise -> one st+              TSNone+                | c == '#' -> (st, T.take seen line)+                | c == '"', Just _ <- T.stripPrefix "\"\"" rest -> jump 3 st {tsString = TSMultiBasic}+                | c == '"' -> one st {tsString = TSBasic}+                | c == '\'', Just _ <- T.stripPrefix "''" rest -> jump 3 st {tsString = TSMultiLiteral}+                | c == '\'' -> one st {tsString = TSLiteral}+                | c == '[' -> one st {tsBracketDepth = tsBracketDepth st + 1}+                | c == ']' -> one st {tsBracketDepth = max 0 (tsBracketDepth st - 1)}+                | otherwise -> one st++-- | Join physical lines into one logical key = value line, returning it+-- with the unconsumed lines.  An unterminated construct at end of input+-- hands what accumulated to the value parser, which reports the specific+-- failure.+joinLogicalLine :: Text -> [Text] -> (Text, [Text])+joinLogicalLine firstLine rest0 =+  let (st0, visible0) = scanTomlLine emptyTomlScan firstLine+   in go st0 [visible0] rest0+  where+    -- Reversed line chunks, joined once - O(n) in the logical line's+    -- length instead of the O(n^2) of appending per physical line.+    go st !chunks remaining+      | not (tomlNeedsMoreLines st) = (T.intercalate "\n" (reverse chunks), remaining)+      | otherwise = case remaining of+          [] -> (T.intercalate "\n" (reverse chunks), [])+          (next : more) ->+            let (advanced, visible) = scanTomlLine st next+             in go advanced (visible : chunks) more++-- | Parse a key = value line.+parseKVLine :: Text -> Either Text ([Text], TOMLValue)+parseKVLine line =+  let (keyPart, afterEq) = splitAtEquals line+   in case T.uncons afterEq of+        Nothing -> Left ("expected '=' in: " <> line)+        Just _ -> do+          let val = T.strip afterEq+          parsed <- parseTOMLValue val+          Right (parseDottedKey (T.strip keyPart), parsed)++-- | Split a line at the first unquoted '=' sign.+-- O(n) via bulk spans into a chunk list instead of O(n^2) T.snoc.+splitAtEquals :: Text -> (Text, Text)+splitAtEquals = go []+  where+    keyPart chunks = T.concat (reverse chunks)+    go !chunks t = case T.uncons t of+      Nothing -> (keyPart chunks, T.empty)+      Just ('=', rest) -> (keyPart chunks, rest)+      Just ('"', rest) ->+        let (quoted, after) = T.break (== '"') rest+         in case T.uncons after of+              Just ('"', r) -> go ("\"" : quoted : "\"" : chunks) r+              _ -> go (quoted : "\"" : chunks) after+      Just ('\'', rest) ->+        let (quoted, after) = T.break (== '\'') rest+         in case T.uncons after of+              Just ('\'', r) -> go ("'" : quoted : "'" : chunks) r+              _ -> go (quoted : "'" : chunks) after+      Just (_, _) ->+        let (plain, after) = T.break (\c -> c == '=' || c == '"' || c == '\'') t+         in go (plain : chunks) after++-- | Parse dotted key like @foo.bar."baz qux"@ into @["foo", "bar", "baz qux"]@.+parseDottedKey :: Text -> [Text]+parseDottedKey t+  | T.null t = []+  | otherwise = case T.uncons t of+      Just ('"', rest) ->+        let (key, after) = T.break (== '"') rest+         in key : parseDottedKey (T.drop 1 (T.stripStart (dropDot after)))+      Just ('\'', rest) ->+        let (key, after) = T.break (== '\'') rest+         in key : parseDottedKey (T.drop 1 (T.stripStart (dropDot after)))+      _ ->+        let (key, after) = T.break (\c -> c == '.' || c == '"') t+         in T.strip key : case T.uncons after of+              Nothing -> []+              Just _ -> parseDottedKey (T.drop 1 (T.stripStart after))+  where+    dropDot txt = case T.uncons txt of+      Just ('.', rest) -> rest+      _ -> txt++-- | Parse a TOML value (right side of '=').+parseTOMLValue :: Text -> Either Text TOMLValue+parseTOMLValue t =+  let stripped = T.strip t+      -- Strip inline comments (not inside strings)+      cleaned = stripInlineComment stripped+   in case T.uncons cleaned of+        Nothing -> Left "empty value"+        Just ('"', _)+          | T.isPrefixOf "\"\"\"" cleaned -> parseMultilineBasicStr (T.drop 3 cleaned)+          | otherwise -> parseBasicStr (T.drop 1 cleaned)+        Just ('\'', _)+          | T.isPrefixOf "'''" cleaned -> parseMultilineLiteralStr (T.drop 3 cleaned)+          | otherwise -> parseLiteralStr (T.drop 1 cleaned)+        Just ('{', _) -> parseInlineTable cleaned+        Just ('[', _) -> parseInlineArray cleaned+        Just ('t', _)+          | T.isPrefixOf "true" cleaned -> Right (TOMLBool True)+        Just ('f', _)+          | T.isPrefixOf "false" cleaned -> Right (TOMLBool False)+        Just ('i', _)+          | T.isPrefixOf "inf" cleaned -> Right (TOMLFloat (1 / 0))+        Just ('+', rest)+          | T.isPrefixOf "inf" rest -> Right (TOMLFloat (1 / 0))+          | T.isPrefixOf "nan" rest -> Right (TOMLFloat (0 / 0))+        Just ('-', rest)+          | T.isPrefixOf "inf" rest -> Right (TOMLFloat (negate (1 / 0)))+          | T.isPrefixOf "nan" rest -> Right (TOMLFloat (0 / 0))+        Just ('n', _)+          | T.isPrefixOf "nan" cleaned -> Right (TOMLFloat (0 / 0))+        _ -> parseTOMLNumberOrDatetime cleaned++-- | Strip inline comment from a value (not inside quotes).+-- O(n) via bulk spans into a chunk list instead of O(n^2)+-- per-character T.cons (each cons copies the whole tail).+stripInlineComment :: Text -> Text+stripInlineComment = go (0 :: Int) []+  where+    finish chunks = T.concat (reverse chunks)+    go depth !chunks t = case T.uncons t of+      Nothing -> finish chunks+      Just ('#', _) | depth == 0 -> finish chunks+      Just ('"', rest)+        | depth == 0 ->+            let (str, after) = T.break (== '"') rest+             in go depth (T.take 1 after : str : "\"" : chunks) (T.drop 1 after)+      Just ('[', rest) -> go (depth + 1) ("[" : chunks) rest+      Just ('{', rest) -> go (depth + 1) ("{" : chunks) rest+      Just (']', rest) -> go (max 0 (depth - 1)) ("]" : chunks) rest+      Just ('}', rest) -> go (max 0 (depth - 1)) ("}" : chunks) rest+      Just (c, rest) ->+        -- c is plain (or a quote/hash inside brackets, kept as content);+        -- take it plus the whole plain run after it in one span.+        let (plain, after) = T.break scanBreak rest+         in go depth (plain : T.singleton c : chunks) after+    scanBreak c = c == '#' || c == '"' || c == '[' || c == '{' || c == ']' || c == '}'++-- | Parse a basic (double-quoted) TOML string.+-- O(n) via chunk list + T.concat instead of O(n^2) T.snoc.+parseBasicStr :: Text -> Either Text TOMLValue+parseBasicStr = go []+  where+    go !chunks t = case T.uncons t of+      Nothing -> Left "unterminated basic string"+      Just ('"', _) -> Right (TOMLStr (T.concat (reverse chunks)))+      Just ('\\', rest) -> case T.uncons rest of+        Just ('n', r) -> go ("\n" : chunks) r+        Just ('t', r) -> go ("\t" : chunks) r+        Just ('r', r) -> go ("\r" : chunks) r+        Just ('\\', r) -> go ("\\" : chunks) r+        Just ('"', r) -> go ("\"" : chunks) r+        Just ('b', r) -> go ("\b" : chunks) r+        Just ('f', r) -> go ("\f" : chunks) r+        Just ('u', r) -> case parseHex4 r of+          Just (cp, r2) -> go (T.singleton (chr cp) : chunks) r2+          Nothing -> Left "invalid \\u escape"+        _ -> Left "invalid escape sequence"+      Just (c, rest) -> go (T.singleton c : chunks) rest++-- | Parse a literal (single-quoted) TOML string.+parseLiteralStr :: Text -> Either Text TOMLValue+parseLiteralStr t =+  let (content, rest) = T.break (== '\'') t+   in case T.uncons rest of+        Just ('\'', _) -> Right (TOMLStr content)+        _ -> Left "unterminated literal string"++-- | Parse a multiline basic string.+parseMultilineBasicStr :: Text -> Either Text TOMLValue+parseMultilineBasicStr t =+  case T.breakOn "\"\"\"" t of+    (content, rest)+      | T.isPrefixOf "\"\"\"" rest ->+          Right (TOMLStr (T.replace "\\\n" "" (stripLeadingNewline content)))+      | otherwise -> Left ("unterminated multiline basic string, remaining: " <> T.take 20 rest)++-- | Parse a multiline literal string.+parseMultilineLiteralStr :: Text -> Either Text TOMLValue+parseMultilineLiteralStr t =+  case T.breakOn "'''" t of+    (content, rest)+      | T.isPrefixOf "'''" rest -> Right (TOMLStr (stripLeadingNewline content))+      | otherwise -> Left "unterminated multiline literal string"++-- | Strip a leading newline (TOML spec: first newline after opening quotes is trimmed).+stripLeadingNewline :: Text -> Text+stripLeadingNewline t = case T.uncons t of+  Just ('\n', rest) -> rest+  Just ('\r', rest) -> case T.uncons rest of+    Just ('\n', r) -> r+    _ -> rest+  _ -> t++-- | Parse a TOML number or datetime.+parseTOMLNumberOrDatetime :: Text -> Either Text TOMLValue+parseTOMLNumberOrDatetime s+  -- Hex, octal, binary integers+  | T.isPrefixOf "0x" s || T.isPrefixOf "0X" s = parseHexInt (T.drop 2 s)+  | T.isPrefixOf "0o" s || T.isPrefixOf "0O" s = parseOctInt (T.drop 2 s)+  | T.isPrefixOf "0b" s || T.isPrefixOf "0B" s = parseBinInt (T.drop 2 s)+  -- Contains date separators, so treat as a datetime string+  | T.any (== 'T') s && T.any (== '-') s = Right (TOMLStr s)+  | T.count "-" s >= 2 && T.any isDigit s = Right (TOMLStr s)+  | T.any (== ':') s && T.any isDigit s = Right (TOMLStr s)+  -- Float (has dot or exponent)+  | T.any (== '.') s || T.any (\c -> c == 'e' || c == 'E') s = parseFloat s+  -- Plain integer+  | otherwise = parseInt s++-- | Parse a plain decimal integer, ignoring underscores.+parseInt :: Text -> Either Text TOMLValue+parseInt t =+  let cleaned = T.filter (/= '_') t+      (sign, digits) = case T.uncons cleaned of+        Just ('+', rest) -> (1 :: Integer, rest)+        Just ('-', rest) -> (-1, rest)+        _ -> (1, cleaned)+   in case readDecimal digits of+        Just n -> tomlInt t (sign * n)+        Nothing -> Left ("invalid integer: " <> t)++parseHexInt :: Text -> Either Text TOMLValue+parseHexInt t =+  let cleaned = T.filter (/= '_') t+   in case readHexT cleaned of+        Just n -> tomlInt t n+        Nothing -> Left ("invalid hex integer: " <> t)++parseOctInt :: Text -> Either Text TOMLValue+parseOctInt t =+  let cleaned = T.filter (/= '_') t+   in case readOctT cleaned of+        Just n -> tomlInt t n+        Nothing -> Left ("invalid octal integer: " <> t)++parseBinInt :: Text -> Either Text TOMLValue+parseBinInt t =+  let cleaned = T.filter (/= '_') t+   in case readBinT cleaned of+        Just n -> tomlInt t n+        Nothing -> Left ("invalid binary integer: " <> t)++-- | Finish a parsed TOML integer: a value outside the 64-bit signed range+-- is a parse error, as upstream's TOML parser reports - silently wrapping+-- would hand the evaluator a different number than the document wrote.+tomlInt :: Text -> Integer -> Either Text TOMLValue+tomlInt original n+  | n < toInteger (minBound :: Int64) || n > toInteger (maxBound :: Int64) =+      Left ("integer out of 64-bit range: " <> original)+  | otherwise = Right (TOMLInt (fromInteger n))++parseFloat :: Text -> Either Text TOMLValue+parseFloat t =+  let cleaned = T.filter (/= '_') t+   in case readDouble cleaned of+        Just d -> Right (TOMLFloat d)+        Nothing -> Left ("invalid float: " <> t)++-- Digit readers accumulate an unbounded 'Integer'; 'tomlInt' applies the+-- 64-bit range gate after any sign, so the minimum int64 (whose magnitude+-- alone exceeds the maximum) still parses.++-- | Read an unbounded decimal integer from Text.+readDecimal :: Text -> Maybe Integer+readDecimal t+  | T.null t = Nothing+  | T.all isDigit t = Just (T.foldl' (\acc c -> acc * 10 + fromIntegral (digitToInt c)) 0 t)+  | otherwise = Nothing++readHexT :: Text -> Maybe Integer+readHexT t+  | T.null t = Nothing+  | T.all isHexDigit t = Just (T.foldl' (\acc c -> acc * 16 + fromIntegral (digitToInt c)) 0 t)+  | otherwise = Nothing++readOctT :: Text -> Maybe Integer+readOctT t+  | T.null t = Nothing+  | T.all isOctDigit t =+      Just (T.foldl' (\acc c -> acc * 8 + fromIntegral (digitToInt c)) 0 t)+  | otherwise = Nothing++readBinT :: Text -> Maybe Integer+readBinT t+  | T.null t = Nothing+  | T.all (\c -> c == '0' || c == '1') t =+      Just (T.foldl' (\acc c -> acc * 2 + fromIntegral (digitToInt c)) 0 t)+  | otherwise = Nothing++readDouble :: Text -> Maybe Double+readDouble t = case reads (T.unpack t) of+  [(d, "")] -> Just d+  _ -> Nothing++-- | Parse an inline table: @{ key = val, ... }@.+parseInlineTable :: Text -> Either Text TOMLValue+parseInlineTable t = case T.uncons t of+  Just ('{', rest) ->+    let inner = T.strip (T.dropWhileEnd (== '}') (T.strip rest))+     in if T.null inner+          then Right (TOMLTable Map.empty)+          else do+            pairs <- mapM parseInlineKV (splitCommas inner)+            Right (TOMLTable (Map.fromList (concatMap flattenPair pairs)))+  _ -> Left "expected '{'"+  where+    flattenPair (keys, val) = case keys of+      [] -> []+      [k] -> [(k, val)]+      (k : ks) -> [(k, nestKeys ks val)]+    nestKeys [] v = v+    nestKeys (k : ks) v = TOMLTable (Map.singleton k (nestKeys ks v))++-- | Parse an inline array: @[ val, ... ]@.+parseInlineArray :: Text -> Either Text TOMLValue+parseInlineArray t = case T.uncons t of+  Just ('[', rest) ->+    let inner = T.strip (T.dropWhileEnd (== ']') (T.strip rest))+     in if T.null inner+          then Right (TOMLArray [])+          else do+            vals <- mapM (parseTOMLValue . T.strip) (splitCommas inner)+            Right (TOMLArray vals)+  _ -> Left "expected '['"++-- | Parse a single key=value pair in an inline table.+parseInlineKV :: Text -> Either Text ([Text], TOMLValue)+parseInlineKV t =+  let (keyPart, afterEq) = splitAtEquals (T.strip t)+   in do+        val <- parseTOMLValue (T.strip afterEq)+        Right (parseDottedKey (T.strip keyPart), val)++-- | Split on commas not inside brackets or braces.+-- O(n) via chunk list + T.concat instead of O(n^2) T.snoc.+splitCommas :: Text -> [Text]+splitCommas = go (0 :: Int) []+  where+    finalize chunks =+      let t = T.concat (reverse chunks)+       in [t | not (T.null (T.strip t))]+    go _ !chunks t | T.null t = finalize chunks+    go depth !chunks t = case T.uncons t of+      Nothing -> finalize chunks+      Just (',', rest) | depth == 0 -> T.concat (reverse chunks) : go 0 [] rest+      Just ('[', rest) -> go (depth + 1) ("[" : chunks) rest+      Just ('{', rest) -> go (depth + 1) ("{" : chunks) rest+      Just (']', rest) -> go (max 0 (depth - 1)) ("]" : chunks) rest+      Just ('}', rest) -> go (max 0 (depth - 1)) ("}" : chunks) rest+      Just ('"', rest) ->+        let (str, after) = T.break (== '"') rest+            consumed = "\"" <> str <> T.take 1 after+         in go depth (consumed : chunks) (T.drop 1 after)+      Just (c, rest) -> go depth (T.singleton c : chunks) rest++-- | Insert a value at a nested key path into a table.+insertNested :: [Text] -> TOMLValue -> Map Text TOMLValue -> Map Text TOMLValue+insertNested [] _ m = m+insertNested [k] v m = Map.insert k v m+insertNested (k : ks) v m = case Map.lookup k m of+  -- Under a [[table]] header the path crosses an array of tables: keys+  -- belong to its LAST element, not to a table replacing the array.+  Just (TOMLArray xs)+    | (TOMLTable lastInner : prev) <- reverse xs ->+        Map.insert k (TOMLArray (reverse prev ++ [TOMLTable (insertNested ks v lastInner)])) m+  Just (TOMLTable inner) -> Map.insert k (TOMLTable (insertNested ks v inner)) m+  _ -> Map.insert k (TOMLTable (insertNested ks v Map.empty)) m++-- | Ensure a table path exists (for @[table]@ headers).+ensureTable :: [Text] -> Map Text TOMLValue -> Map Text TOMLValue+ensureTable [] m = m+ensureTable [k] m = case Map.lookup k m of+  Just (TOMLTable _) -> m+  Nothing -> Map.insert k (TOMLTable Map.empty) m+  _ -> m+ensureTable (k : ks) m =+  let sub = case Map.lookup k m of+        Just (TOMLTable inner) -> inner+        _ -> Map.empty+   in Map.insert k (TOMLTable (ensureTable ks sub)) m++-- | Insert an entry into an array-of-tables (@[[table]]@).+insertArrayTable :: [Text] -> Map Text TOMLValue -> Map Text TOMLValue+insertArrayTable [] m = m+insertArrayTable [k] m = case Map.lookup k m of+  Just (TOMLArray xs) -> Map.insert k (TOMLArray (xs ++ [TOMLTable Map.empty])) m+  Nothing -> Map.insert k (TOMLArray [TOMLTable Map.empty]) m+  _ -> Map.insert k (TOMLArray [TOMLTable Map.empty]) m+insertArrayTable (k : ks) m =+  let sub = case Map.lookup k m of+        Just (TOMLTable inner) -> inner+        Just (TOMLArray xs) ->+          -- Descend into the last element of the array+          case reverse xs of+            (TOMLTable inner : _) -> inner+            _ -> Map.empty+        _ -> Map.empty+      updated = insertArrayTable ks sub+   in case Map.lookup k m of+        Just (TOMLArray xs) ->+          case reverse xs of+            (TOMLTable _ : prev) ->+              Map.insert k (TOMLArray (reverse prev ++ [TOMLTable updated])) m+            _ -> Map.insert k (TOMLTable updated) m+        _ -> Map.insert k (TOMLTable updated) m++-- ---------------------------------------------------------------------------+-- Builtin implementations - toXML+-- ---------------------------------------------------------------------------++-- | @builtins.toXML val@ - convert a Nix value to its XML representation.+-- Matches the format defined by the Nix manual: strings, ints, floats,+-- bools, nulls, lists, and attrsets map to their XML counterparts.+-- Built over BYTES: upstream's serializer copies string payloads into the+-- output with only the four ASCII escapes, so invalid UTF-8 passes+-- through raw rather than erroring (unlike toJSON, whose upstream+-- serializer validates).+builtinToXML :: (MonadEval m) => NixValue -> m NixValue+builtinToXML val = do+  xml <- valueToXML 0 val+  pure (mkStrBytes ("<?xml version='1.0' encoding='utf-8'?>\n<expr>\n" <> xml <> "</expr>\n"))++valueToXML :: (MonadEval m) => Int -> NixValue -> m BS.ByteString+valueToXML depth val = case val of+  VStr s _ ->+    pure (indent depth <> "<string value=" <> xmlQuote s <> " />\n")+  VInt n ->+    pure (indent depth <> "<int value=\"" <> BC.pack (show n) <> "\" />\n")+  VFloat d ->+    pure (indent depth <> "<float value=\"" <> TE.encodeUtf8 (formatXmlFloat d) <> "\" />\n")+  VBool True ->+    pure (indent depth <> "<bool value=\"true\" />\n")+  VBool False ->+    pure (indent depth <> "<bool value=\"false\" />\n")+  VNull ->+    pure (indent depth <> "<null />\n")+  VPath p ->+    pure (indent depth <> "<path value=" <> xmlQuote (TE.encodeUtf8 p) <> " />\n")+  VList cl -> do+    let thunks = map Thunk (clistThunks cl)+    items <- mapM (force >=> valueToXML (depth + 1)) thunks+    pure (indent depth <> "<list>\n" <> BS.concat items <> indent depth <> "</list>\n")+  VAttrs attrs -> do+    let pairs = attrSetToAscList attrs+    items <- mapM (attrToXML (depth + 1)) pairs+    pure (indent depth <> "<attrs>\n" <> BS.concat items <> indent depth <> "</attrs>\n")+  VLambda {} ->+    pure (indent depth <> "<function />\n")+  VBuiltin _ _ ->+    pure (indent depth <> "<function />\n")+  VDerivation _ ->+    pure (indent depth <> "<derivation />\n")+  VCompiledRegex _ ->+    pure (indent depth <> "<function />\n")+  where+    attrToXML d (name, thunk) = do+      v <- force thunk+      inner <- valueToXML d v+      pure (indent d <> "<attr name=" <> xmlQuote (TE.encodeUtf8 name) <> ">\n" <> inner <> indent d <> "</attr>\n")++indent :: Int -> BS.ByteString+indent n = BC.replicate (n * 2) ' '++xmlQuote :: BS.ByteString -> BS.ByteString+xmlQuote s = "\"" <> BC.concatMap escapeChar s <> "\""+  where+    escapeChar '<' = "&lt;"+    escapeChar '>' = "&gt;"+    escapeChar '&' = "&amp;"+    escapeChar '"' = "&quot;"+    escapeChar c = BC.singleton c++-- ---------------------------------------------------------------------------+-- Builtin implementations - builtins.path+-- ---------------------------------------------------------------------------++-- | @builtins.path { path; name?; filter?; sha256?; recursive?; }@+--+-- Copy a path to the store and return the store path as a string with+-- context.  @name@ defaults to the basename of @path@.  @filter@ is+-- accepted but not yet applied (copies everything).+builtinPath :: (MonadEval m) => NixValue -> m NixValue+builtinPath (VAttrs attrs) = do+  pathStr <- forceAttrStr "builtins.path" "path" attrs+  nameOverride <- forceOptionalAttrStr attrs "name"+  expectedDigest <- case attrSetLookup "sha256" attrs of+    Nothing -> pure Nothing+    Just thunk -> do+      pinVal <- force thunk+      case pinVal of+        VStr pin _ -> do+          pinText <- decodedText "builtins.path" pin+          Just <$> decodeSha256Pin "builtins.path" pinText+        other -> throwEvalError ("builtins.path: 'sha256' must be a string, got " <> typeName other)+  let name = fromMaybe (canonBaseName pathStr) nameOverride+      pinSubject = "builtins.path: " <> pathStr+  storePathText <- case attrSetLookup "filter" attrs of+    Nothing -> copyPathToStore pathStr name (fmap (pinSubject,) expectedDigest)+    Just filterThunk -> do+      filterFn <- force filterThunk+      narBytes <- filteredSourceNar filterFn pathStr+      -- The sha256 pin applies to the FILTERED tree, as upstream.+      let filteredDigest = sha256Digest narBytes+      case expectedDigest of+        Just expected+          | expected /= filteredDigest ->+              throwEvalError+                ( pinSubject+                    <> ": hash mismatch: expected sha256:"+                    <> bytesToHexText expected+                    <> ", got sha256:"+                    <> bytesToHexText filteredDigest+                )+        _ -> pure ()+      addSourceNar name narBytes+  pure (sourceResultString storePathText)+builtinPath other =+  throwEvalError ("builtins.path: expected an attribute set, got " <> typeName other)++-- | The result value both source importers return: the store path as a+-- string carrying itself as context.+sourceResultString :: Text -> NixValue+sourceResultString storePathText =+  case parseStorePath defaultStoreDir storePathText of+    Just sp -> VStr (TE.encodeUtf8 storePathText) (plainContext sp)+    Nothing -> VStr (TE.encodeUtf8 storePathText) emptyContext++-- | Serialise a source tree to a NAR, keeping only entries the Nix filter+-- function accepts - upstream's addToStore filtering: the filter receives+-- @(path, type)@ for every entry BELOW the root (the root itself is never+-- filtered), and rejecting a directory prunes its whole subtree.  Child+-- paths hand the filter @parent/name@ with a forward slash; both path+-- styles reach Nix code only through separator-agnostic helpers like+-- @baseNameOf@.+filteredSourceNar :: (MonadEval m) => NixValue -> Text -> m BS.ByteString+filteredSourceNar filterFn rootPath = do+  rootType <- getFileType rootPath+  entry <- buildEntry rootPath rootType+  pure (NAR.serialise entry)+  where+    buildEntry path fileType = case fileType of+      "regular" -> do+        executable <- isExecutableFile path+        bytes <- readFileBytes path+        pure (NAR.NarRegular executable bytes)+      "symlink" -> NAR.NarSymlink . TE.encodeUtf8 <$> readSymlinkTarget path+      "directory" -> do+        children <- listDirectory path+        kept <- mapM (keepChild path) children+        pure (NAR.NarDirectory (catMaybes kept))+      other -> throwEvalError ("builtins.path: unsupported file type '" <> other <> "' at " <> path)+    keepChild parent (name, childType) = do+      let childPath = parent <> "/" <> name+      keep <- filterAccepts childPath childType+      if keep+        then Just . (,) (TE.encodeUtf8 name) <$> buildEntry childPath childType+        else pure Nothing+    filterAccepts path fileType = do+      partial <- applyValue filterFn (mkStr path)+      result <- applyValue partial (mkStr fileType)+      case result of+        VBool b -> pure b+        other -> throwEvalError ("builtins.path: the filter function must return a Boolean, got " <> typeName other)++-- ---------------------------------------------------------------------------+-- Builtin implementations - filterSource+-- ---------------------------------------------------------------------------++-- | @builtins.filterSource filter path@ - copy a path to the store,+-- filtering entries via a predicate.  The filter function receives+-- @(path, type)@ where type is @"regular"@, @"directory"@, @"symlink"@,+-- or @"unknown"@.  Equivalent to @builtins.path@ with a filter and the+-- source's basename as the store name.+builtinFilterSource :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinFilterSource filterFn (VPath path) = filterSourceInto filterFn path+builtinFilterSource filterFn (VStr path _) =+  filterSourceInto filterFn =<< decodedText "builtins.filterSource" path+builtinFilterSource _ other =+  throwEvalError ("builtins.filterSource: expected a path, got " <> typeName other)++filterSourceInto :: (MonadEval m) => NixValue -> Text -> m NixValue+filterSourceInto filterFn path = do+  narBytes <- filteredSourceNar filterFn path+  storePathText <- addSourceNar (canonBaseName path) narBytes+  pure (sourceResultString storePathText)  -- --------------------------------------------------------------------------- -- Builtin stubs - experimental features
+ src/Nix/Eval/AttrPath.hs view
@@ -0,0 +1,147 @@+-- | Attribute-path selection, the machinery behind @build -A@.+--+-- An attribute path is a dotted sequence of names selected left to right+-- from an evaluated value: @stdenv.mkDerivation@ takes @mkDerivation@ out+-- of @stdenv@.  A component may be double-quoted to carry a literal dot,+-- as in @foo.\"bar.baz\"@.+--+-- Upstream tokenizes the same way (@parseAttrPath@,+-- src\/libexpr\/attr-path.cc), including three behaviours that read as+-- accidents but are load-bearing for compatibility: quotes concatenate+-- rather than delimit, so @foo\"bar\"@ is the single name @foobar@; the+-- final component is pushed only when non-empty, so a trailing dot and a+-- lone @\"\"@ both vanish instead of becoming a component; and an interior+-- empty component (a leading or doubled dot) survives tokenizing to be+-- rejected during selection, after the type of the value it would index.+--+-- Two upstream behaviours are deliberately absent.  A numeric component+-- indexes a list upstream (@-A foo.3.bar@); here it is an ordinary name,+-- because nothing this selects over is a list and upstream's+-- integer-or-name test defers to a C++ numeric parse whose accepted+-- spellings (a leading @+@, leading zeros) have not been checked against a+-- running Nix.  And a near-miss attribute name gets no @Did you mean@+-- line, which upstream renders from a Levenshtein search this evaluator+-- has no suggestion channel to carry.+--+-- The four messages otherwise match upstream byte for byte, diffed against+-- @nix-instantiate@ 2.33.2.+module Nix.Eval.AttrPath+  ( parseAttrPath,+    selectAttrPath,+  )+where++import Data.Text (Text)+import qualified Data.Text as T+import Nix.Eval (MonadEval, NixValue (..), attrSetLookup, force)++-- ---------------------------------------------------------------------------+-- Tokenizing+-- ---------------------------------------------------------------------------++-- | Component separator.+attrSeparator :: Char+attrSeparator = '.'++-- | Opens and closes a component that may contain 'attrSeparator'.+quoteChar :: Char+quoteChar = '"'++quoteMark :: Text+quoteMark = T.singleton quoteChar++isDelimiter :: Char -> Bool+isDelimiter c = c == attrSeparator || c == quoteChar++-- | Split an attribute path into its components.+--+-- @Left@ carries a message ready for the user; the only way to get one is+-- a quote that is never closed.  An empty component is returned rather+-- than rejected, because upstream reports it against the value being+-- indexed and so cannot decide it here.+parseAttrPath :: Text -> Either Text [Text]+parseAttrPath path = go path T.empty []+  where+    go rest !current acc =+      let (plain, delimited) = T.break isDelimiter rest+          taken = current <> plain+       in case T.uncons delimited of+            Nothing+              | T.null taken -> Right (reverse acc)+              | otherwise -> Right (reverse (taken : acc))+            Just (c, more)+              | c == attrSeparator -> go more T.empty (taken : acc)+              | otherwise ->+                  let (quotedRun, closing) = T.breakOn quoteMark more+                   in case T.stripPrefix quoteMark closing of+                        Nothing -> Left (missingQuoteMessage path)+                        Just after -> go after (taken <> quotedRun) acc++-- ---------------------------------------------------------------------------+-- Selecting+-- ---------------------------------------------------------------------------++-- | Follow an attribute path into an evaluated value, forcing each step.+--+-- Only the components named are forced: selecting one attribute leaves its+-- siblings as thunks, so naming one package does not evaluate the rest of+-- a package set.+selectAttrPath :: (MonadEval m) => Text -> NixValue -> m (Either Text NixValue)+selectAttrPath path root = case parseAttrPath path of+  Left err -> pure (Left err)+  Right names -> walk names root+  where+    walk [] val = pure (Right val)+    walk (name : rest) val = case val of+      VAttrs attrs+        | T.null name -> pure (Left (emptyNameMessage path))+        | otherwise -> case attrSetLookup name attrs of+            Nothing -> pure (Left (notFoundMessage name path))+            Just thunk -> force thunk >>= walk rest+      -- Ordered as upstream orders it: the type of what is being indexed+      -- is reported before an empty component is complained about.+      _ -> pure (Left (notASetMessage path (describe val)))++-- | How a value is named in a selection error.+--+-- Deliberately not 'typeOfValue', which answers @builtins.typeOf@ and so+-- says @int@ and @lambda@.  These are upstream's @showType@ words, article+-- included, so the message reads as upstream's does; @null@ is the one+-- that takes no article.  Verified against @nix-instantiate@ 2.33.2.+describe :: NixValue -> Text+describe val = case val of+  VInt _ -> "an integer"+  VFloat _ -> "a float"+  VBool _ -> "a Boolean"+  VNull -> "null"+  VStr _ _ -> "a string"+  VPath _ -> "a path"+  VList _ -> "a list"+  VAttrs _ -> "a set"+  VDerivation _ -> "a set"+  VLambda {} -> "a function"+  VBuiltin _ _ -> "a function"+  VCompiledRegex _ -> "a function"++-- ---------------------------------------------------------------------------+-- Messages+-- ---------------------------------------------------------------------------++missingQuoteMessage :: Text -> Text+missingQuoteMessage path =+  "missing closing quote in selection path '" <> path <> "'"++emptyNameMessage :: Text -> Text+emptyNameMessage path =+  "empty attribute name in selection path '" <> path <> "'"++notFoundMessage :: Text -> Text -> Text+notFoundMessage name path =+  "attribute '" <> name <> "' in selection path '" <> path <> "' not found"++notASetMessage :: Text -> Text -> Text+notASetMessage path actual =+  "the expression selected by the selection path '"+    <> path+    <> "' should be a set but is "+    <> actual
src/Nix/Eval/CBytecode.hs view
@@ -33,6 +33,10 @@     -- * Read data     cbcData, +    -- * Counted payloads (short_arg spill)+    spilledCountSentinel,+    cbcCountedPayload,+     -- * Diagnostics     cbcOpCount,     cbcDataCount,@@ -62,6 +66,7 @@     pattern OpUnary,     pattern OpBinary,     pattern OpSearchPath,+    pattern OpPathStr,      -- * UnaryOp flags     unaryNot,@@ -172,12 +177,29 @@  -- | Append one instruction.  Returns the instruction index. cbcEmit :: Word8 -> Word8 -> Word16 -> Word32 -> Word32 -> Word32 -> IO Word32-cbcEmit = c_nn_bc_emit+cbcEmit opcode flags shortArg arg1 arg2 arg3 =+  checkedEmit "nn_bc_emit" =<< c_nn_bc_emit opcode flags shortArg arg1 arg2 arg3  -- | Append one uint32 to the data buffer.  Returns the data offset. cbcEmitData :: Word32 -> IO Word32-cbcEmitData = c_nn_bc_emit_data+cbcEmitData value = checkedEmit "nn_bc_emit_data" =<< c_nn_bc_emit_data value +-- | @UINT32_MAX@ from a C emit function signals a failed append (realloc+-- exhaustion or the uint32 index ceiling), not a valid index.  Must stay+-- in lockstep with the emit docs in @cbits\/nn_bytecode.h@.+emitFailedSentinel :: Word32+emitFailedSentinel = 0xFFFFFFFF++-- | Reject the failure sentinel before it can flow onward as an index:+-- the bytecode arrays are global C state, so a failed append leaves+-- nothing to recover, and the read-side bounds on a sentinel index do+-- not survive a release build.+checkedEmit :: String -> Word32 -> IO Word32+checkedEmit site idx+  | idx == emitFailedSentinel =+      ioError (userError (site <> ": bytecode append failed (allocation failure or index ceiling)"))+  | otherwise = pure idx+ -- --------------------------------------------------------------------------- -- Read instructions -- ---------------------------------------------------------------------------@@ -216,6 +238,32 @@ cbcData = c_nn_bc_data  -- ---------------------------------------------------------------------------+-- Counted payloads (short_arg spill)+-- ---------------------------------------------------------------------------++-- | @short_arg@ value marking a spilled payload count: the true count is+-- the FIRST word of the op's data region and the payload begins one word+-- later.  Counts below the sentinel travel inline, so @nn_op_t@ stays 16+-- bytes (four ops per cache line) and only an oversized literal pays the+-- extra data word.  Must stay in lockstep with the @short_arg@ doc in+-- @cbits\/nn_bytecode.h@.+spilledCountSentinel :: Word16+spilledCountSentinel = 0xFFFF++-- | Read an op's payload count and payload start offset, resolving the+-- spill convention.  @dataOff@ is the raw offset stored in the op's arg+-- word (arg1 for most counted ops, arg2 for select\/hasAttr paths).+-- The inline case costs one compare.+cbcCountedPayload :: Word32 -> Word32 -> IO (Int, Word32)+cbcCountedPayload bcIdx dataOff = do+  inline <- cbcShortArg bcIdx+  if inline == spilledCountSentinel+    then do+      spilled <- cbcData dataOff+      pure (fromIntegral spilled, dataOff + 1)+    else pure (fromIntegral inline, dataOff)++-- --------------------------------------------------------------------------- -- Diagnostics -- --------------------------------------------------------------------------- @@ -287,6 +335,9 @@ -- | Search-path opcode: an angle-bracket path lookup like @\<nixpkgs\>@. pattern OpSearchPath :: Word8 pattern OpSearchPath = 23++pattern OpPathStr :: Word8+pattern OpPathStr = 24  -- --------------------------------------------------------------------------- -- Sub-type flags
src/Nix/Eval/CCtxStr.hs view
@@ -32,7 +32,7 @@   ) where -import Data.Word (Word16, Word32, Word8)+import Data.Word (Word32, Word8) import Foreign.Ptr (Ptr)  -- | Phantom type for C-side @nn_ctxstr_t@.@@ -46,37 +46,37 @@ -- ---------------------------------------------------------------------------  foreign import ccall unsafe "nn_ctxstr_new"-  c_nn_ctxstr_new :: Word32 -> Word16 -> IO CCtxStrPtr+  c_nn_ctxstr_new :: Word32 -> Word32 -> IO CCtxStrPtr  foreign import ccall unsafe "nn_ctxstr_free_all"   c_nn_ctxstr_free_all :: IO ()  foreign import ccall unsafe "nn_ctxstr_set_plain"-  c_nn_ctxstr_set_plain :: CCtxStrPtr -> Word16 -> Word32 -> Word32 -> IO ()+  c_nn_ctxstr_set_plain :: CCtxStrPtr -> Word32 -> Word32 -> Word32 -> IO ()  foreign import ccall unsafe "nn_ctxstr_set_drv_output"-  c_nn_ctxstr_set_drv_output :: CCtxStrPtr -> Word16 -> Word32 -> Word32 -> Word32 -> IO ()+  c_nn_ctxstr_set_drv_output :: CCtxStrPtr -> Word32 -> Word32 -> Word32 -> Word32 -> IO ()  foreign import ccall unsafe "nn_ctxstr_set_all_outputs"-  c_nn_ctxstr_set_all_outputs :: CCtxStrPtr -> Word16 -> Word32 -> Word32 -> IO ()+  c_nn_ctxstr_set_all_outputs :: CCtxStrPtr -> Word32 -> Word32 -> Word32 -> IO ()  foreign import ccall unsafe "nn_ctxstr_text"   c_nn_ctxstr_text :: CCtxStrPtr -> IO Word32  foreign import ccall unsafe "nn_ctxstr_ctx_count"-  c_nn_ctxstr_ctx_count :: CCtxStrPtr -> IO Word16+  c_nn_ctxstr_ctx_count :: CCtxStrPtr -> IO Word32  foreign import ccall unsafe "nn_ctxstr_elem_tag"-  c_nn_ctxstr_elem_tag :: CCtxStrPtr -> Word16 -> IO Word8+  c_nn_ctxstr_elem_tag :: CCtxStrPtr -> Word32 -> IO Word8  foreign import ccall unsafe "nn_ctxstr_elem_hash"-  c_nn_ctxstr_elem_hash :: CCtxStrPtr -> Word16 -> IO Word32+  c_nn_ctxstr_elem_hash :: CCtxStrPtr -> Word32 -> IO Word32  foreign import ccall unsafe "nn_ctxstr_elem_name"-  c_nn_ctxstr_elem_name :: CCtxStrPtr -> Word16 -> IO Word32+  c_nn_ctxstr_elem_name :: CCtxStrPtr -> Word32 -> IO Word32  foreign import ccall unsafe "nn_ctxstr_elem_output"-  c_nn_ctxstr_elem_output :: CCtxStrPtr -> Word16 -> IO Word32+  c_nn_ctxstr_elem_output :: CCtxStrPtr -> Word32 -> IO Word32  -- --------------------------------------------------------------------------- -- Lifecycle@@ -84,7 +84,9 @@  -- | Allocate a new context string with space for @ctxCount@ elements. -- Elements are uninitialized - caller must fill via set functions.-cctxstrNew :: Word32 -> Word16 -> IO CCtxStrPtr+-- Returns 'Foreign.Ptr.nullPtr' on allocation failure or if the+-- element array size would overflow - the caller must check.+cctxstrNew :: Word32 -> Word32 -> IO CCtxStrPtr cctxstrNew = c_nn_ctxstr_new  -- | Free all tracked nn_ctxstr_t allocations (arena-style cleanup).@@ -97,17 +99,17 @@  -- | Set context element @i@ to a plain store-path reference (@SCPlain@), -- identified by its name and hash symbols.-cctxstrSetPlain :: CCtxStrPtr -> Word16 -> Word32 -> Word32 -> IO ()+cctxstrSetPlain :: CCtxStrPtr -> Word32 -> Word32 -> Word32 -> IO () cctxstrSetPlain = c_nn_ctxstr_set_plain  -- | Set context element @i@ to a derivation-output reference (@SCDrvOutput@): -- name and hash symbols plus the output-name symbol.-cctxstrSetDrvOutput :: CCtxStrPtr -> Word16 -> Word32 -> Word32 -> Word32 -> IO ()+cctxstrSetDrvOutput :: CCtxStrPtr -> Word32 -> Word32 -> Word32 -> Word32 -> IO () cctxstrSetDrvOutput = c_nn_ctxstr_set_drv_output  -- | Set context element @i@ to an all-outputs reference (@SCAllOutputs@), -- identified by its name and hash symbols.-cctxstrSetAllOutputs :: CCtxStrPtr -> Word16 -> Word32 -> Word32 -> IO ()+cctxstrSetAllOutputs :: CCtxStrPtr -> Word32 -> Word32 -> Word32 -> IO () cctxstrSetAllOutputs = c_nn_ctxstr_set_all_outputs  -- ---------------------------------------------------------------------------@@ -119,21 +121,21 @@ cctxstrText = c_nn_ctxstr_text  -- | The number of context elements attached to the string.-cctxstrCtxCount :: CCtxStrPtr -> IO Word16+cctxstrCtxCount :: CCtxStrPtr -> IO Word32 cctxstrCtxCount = c_nn_ctxstr_ctx_count  -- | The tag of context element @i@ (plain / drv-output / all-outputs).-cctxstrElemTag :: CCtxStrPtr -> Word16 -> IO Word8+cctxstrElemTag :: CCtxStrPtr -> Word32 -> IO Word8 cctxstrElemTag = c_nn_ctxstr_elem_tag  -- | The store-path-hash symbol of context element @i@.-cctxstrElemHash :: CCtxStrPtr -> Word16 -> IO Word32+cctxstrElemHash :: CCtxStrPtr -> Word32 -> IO Word32 cctxstrElemHash = c_nn_ctxstr_elem_hash  -- | The store-path-name symbol of context element @i@.-cctxstrElemName :: CCtxStrPtr -> Word16 -> IO Word32+cctxstrElemName :: CCtxStrPtr -> Word32 -> IO Word32 cctxstrElemName = c_nn_ctxstr_elem_name  -- | The output-name symbol of context element @i@ (drv-output elements only).-cctxstrElemOutput :: CCtxStrPtr -> Word16 -> IO Word32+cctxstrElemOutput :: CCtxStrPtr -> Word32 -> IO Word32 cctxstrElemOutput = c_nn_ctxstr_elem_output
src/Nix/Eval/CEnv.hs view
@@ -43,7 +43,7 @@   ) where -import Data.Word (Word16, Word32)+import Data.Word (Word32) import Foreign.C.Types (CInt (..)) import Foreign.Ptr (Ptr) import Nix.Eval.CThunk (CThunkPtr)@@ -78,7 +78,7 @@     Ptr () ->     Ptr NnEnv ->     Ptr (Ptr ()) ->-    Word16 ->+    Word32 ->     IO (Ptr NnEnv)  foreign import ccall unsafe "nn_env_from_slots"@@ -107,7 +107,7 @@   c_nn_env_with_scopes :: Ptr NnEnv -> IO (Ptr (Ptr ()))  foreign import ccall unsafe "nn_env_with_count"-  c_nn_env_with_count :: Ptr NnEnv -> IO Word16+  c_nn_env_with_count :: Ptr NnEnv -> IO Word32  foreign import ccall unsafe "nn_env_lookup_resolved"   c_nn_env_lookup_resolved :: Ptr NnEnv -> CInt -> CInt -> IO CThunkPtr@@ -116,7 +116,7 @@   c_nn_env_root_scope :: Ptr NnEnv -> IO (Ptr ())  foreign import ccall unsafe "nn_env_alloc_with_scopes"-  c_nn_env_alloc_with_scopes :: Word16 -> IO (Ptr (Ptr ()))+  c_nn_env_alloc_with_scopes :: Word32 -> IO (Ptr (Ptr ()))  -- --------------------------------------------------------------------------- -- Lifecycle@@ -155,7 +155,7 @@   Ptr () ->   Ptr NnEnv ->   Ptr (Ptr ()) ->-  Word16 ->+  Word32 ->   IO (Ptr NnEnv) cenvNew = c_nn_env_new @@ -196,7 +196,7 @@ cenvWithScopes = c_nn_env_with_scopes  -- | Read the with-scope count from a C env.-cenvWithCount :: Ptr NnEnv -> IO Word16+cenvWithCount :: Ptr NnEnv -> IO Word32 cenvWithCount = c_nn_env_with_count  -- ---------------------------------------------------------------------------@@ -219,5 +219,7 @@ -- ---------------------------------------------------------------------------  -- | Allocate an arena array for with-scopes.  Zero-initialized.-cenvAllocWithScopes :: Word16 -> IO (Ptr (Ptr ()))+-- Returns 'Foreign.Ptr.nullPtr' if the count is 0 or the allocation+-- fails - the caller must check.+cenvAllocWithScopes :: Word32 -> IO (Ptr (Ptr ())) cenvAllocWithScopes = c_nn_env_alloc_with_scopes
src/Nix/Eval/CLambda.hs view
@@ -30,7 +30,7 @@   ) where -import Data.Word (Word16, Word32, Word8)+import Data.Word (Word32, Word8) import Foreign.Ptr (Ptr) import Nix.Eval.CEnv (NnEnv) @@ -45,10 +45,10 @@ -- ---------------------------------------------------------------------------  foreign import ccall unsafe "nn_lambda_new"-  c_nn_lambda_new :: Ptr NnEnv -> Word32 -> Word8 -> Word32 -> Word8 -> Word16 -> IO CLambdaPtr+  c_nn_lambda_new :: Ptr NnEnv -> Word32 -> Word8 -> Word32 -> Word8 -> Word32 -> IO CLambdaPtr  foreign import ccall unsafe "nn_lambda_set_entry"-  c_nn_lambda_set_entry :: CLambdaPtr -> Word16 -> Word32 -> Word32 -> Word32 -> IO ()+  c_nn_lambda_set_entry :: CLambdaPtr -> Word32 -> Word32 -> Word32 -> Word32 -> IO ()  foreign import ccall unsafe "nn_lambda_free_all"   c_nn_lambda_free_all :: IO ()@@ -69,16 +69,16 @@   c_nn_lambda_allow_extra :: CLambdaPtr -> IO Word8  foreign import ccall unsafe "nn_lambda_formal_count"-  c_nn_lambda_formal_count :: CLambdaPtr -> IO Word16+  c_nn_lambda_formal_count :: CLambdaPtr -> IO Word32  foreign import ccall unsafe "nn_lambda_entry_name"-  c_nn_lambda_entry_name :: CLambdaPtr -> Word16 -> IO Word32+  c_nn_lambda_entry_name :: CLambdaPtr -> Word32 -> IO Word32  foreign import ccall unsafe "nn_lambda_entry_has_default"-  c_nn_lambda_entry_has_default :: CLambdaPtr -> Word16 -> IO Word32+  c_nn_lambda_entry_has_default :: CLambdaPtr -> Word32 -> IO Word32  foreign import ccall unsafe "nn_lambda_entry_default"-  c_nn_lambda_entry_default :: CLambdaPtr -> Word16 -> IO Word32+  c_nn_lambda_entry_default :: CLambdaPtr -> Word32 -> IO Word32  -- --------------------------------------------------------------------------- -- Lifecycle@@ -86,11 +86,13 @@  -- | Allocate a new lambda closure with space for @formalCount@ entries. -- Fill entries via 'clambdaSetEntry' after construction.-clambdaNew :: Ptr NnEnv -> Word32 -> Word8 -> Word32 -> Word8 -> Word16 -> IO CLambdaPtr+-- Returns 'Foreign.Ptr.nullPtr' on allocation failure or if the+-- entries array size would overflow - the caller must check.+clambdaNew :: Ptr NnEnv -> Word32 -> Word8 -> Word32 -> Word8 -> Word32 -> IO CLambdaPtr clambdaNew = c_nn_lambda_new  -- | Set a formal entry at the given index.-clambdaSetEntry :: CLambdaPtr -> Word16 -> Word32 -> Word32 -> Word32 -> IO ()+clambdaSetEntry :: CLambdaPtr -> Word32 -> Word32 -> Word32 -> Word32 -> IO () clambdaSetEntry = c_nn_lambda_set_entry  -- | Free all tracked lambda structs (arena-style cleanup).@@ -122,17 +124,17 @@ clambdaAllowExtra = c_nn_lambda_allow_extra  -- | Read the number of formal entries.-clambdaFormalCount :: CLambdaPtr -> IO Word16+clambdaFormalCount :: CLambdaPtr -> IO Word32 clambdaFormalCount = c_nn_lambda_formal_count  -- | Read a formal entry's name symbol.-clambdaEntryName :: CLambdaPtr -> Word16 -> IO Word32+clambdaEntryName :: CLambdaPtr -> Word32 -> IO Word32 clambdaEntryName = c_nn_lambda_entry_name  -- | Read whether a formal entry has a default.-clambdaEntryHasDefault :: CLambdaPtr -> Word16 -> IO Word32+clambdaEntryHasDefault :: CLambdaPtr -> Word32 -> IO Word32 clambdaEntryHasDefault = c_nn_lambda_entry_has_default  -- | Read a formal entry's default bytecode index.-clambdaEntryDefault :: CLambdaPtr -> Word16 -> IO Word32+clambdaEntryDefault :: CLambdaPtr -> Word32 -> IO Word32 clambdaEntryDefault = c_nn_lambda_entry_default
src/Nix/Eval/CThunk.hs view
@@ -29,7 +29,6 @@     cthunkDestroy,      -- * Allocation-    cthunkNew,     cthunkNewBc,     cthunkNewComputed,     cthunkNewComputedInt,@@ -61,6 +60,7 @@      -- * State transitions     cthunkMarkBlackhole,+    cthunkMarkPending,     cthunkSetComputed,     cthunkSetComputedInt,     cthunkSetComputedFloat,@@ -100,9 +100,6 @@ foreign import ccall unsafe "nn_thunk_destroy"   c_nn_thunk_destroy :: IO () -foreign import ccall unsafe "nn_thunk_new"-  c_nn_thunk_new :: Ptr () -> IO CThunkPtr- foreign import ccall unsafe "nn_thunk_new_bc"   c_nn_thunk_new_bc :: Word32 -> Ptr () -> IO CThunkPtr @@ -184,6 +181,9 @@ foreign import ccall unsafe "nn_thunk_mark_blackhole"   c_nn_thunk_mark_blackhole :: CThunkPtr -> IO CInt +foreign import ccall unsafe "nn_thunk_mark_pending"+  c_nn_thunk_mark_pending :: CThunkPtr -> IO CInt+ foreign import ccall unsafe "nn_thunk_set_computed"   c_nn_thunk_set_computed :: CThunkPtr -> Ptr () -> IO (Ptr ()) @@ -242,11 +242,6 @@ -- Allocation -- --------------------------------------------------------------------------- --- | Allocate a new PENDING thunk from the arena (legacy StablePtr path).--- The payload is an opaque pointer (StablePtr cast to Ptr ()).-cthunkNew :: Ptr () -> IO CThunkPtr-cthunkNew = c_nn_thunk_new- -- | Allocate a new PENDING thunk with a bytecode index + C env pointer. -- No Haskell heap references - zero GC pressure for pending thunks. cthunkNewBc :: Word32 -> Ptr () -> IO CThunkPtr@@ -373,6 +368,14 @@ cthunkMarkBlackhole :: CThunkPtr -> IO Bool cthunkMarkBlackhole ptr = do   result <- c_nn_thunk_mark_blackhole ptr+  pure (result /= 0)++-- | Restore a BLACKHOLE thunk to PENDING after a caught throw during its force,+-- so a later force re-evaluates instead of misreporting infinite recursion.+-- Returns 'True' on success, 'False' if the thunk was not BLACKHOLE.+cthunkMarkPending :: CThunkPtr -> IO Bool+cthunkMarkPending ptr = do+  result <- c_nn_thunk_mark_pending ptr   pure (result /= 0)  -- | Set a BLACKHOLE thunk to COMPUTED with a StablePtr value (complex types).
+ src/Nix/Eval/CanonPath.hs view
@@ -0,0 +1,126 @@+-- | Lexical path canonicalization for eval-produced path values.+--+-- Upstream Nix canonicalizes every path value (@CanonPath@): @.@ segments+-- drop, @..@ pops the previous segment (at a root it drops), repeated+-- separators collapse to one.  nova-nix applies the same algorithm at the+-- points a path value is produced - literal resolution, @toPath@, path+-- concatenation, search-path candidates - so the canonical text, not the+-- user's spelling, is what reaches store-copy names, string coercions, and+-- comparisons.+--+-- Purely lexical: no filesystem access, and symlinks are not resolved+-- (upstream resolves symlinks separately, where required).  The separator+-- style of the input is preserved: a canonical forward-slash path stays+-- forward-slash (this is the identity form for store paths, on every+-- platform), and a native Windows path keeps its backslashes.  A leading+-- separator marks a rooted path independent of platform, because eval-time+-- path values are rooted in the canonical @\/nix\/store@ sense even on+-- Windows, where 'System.FilePath.splitDrive' would not see a bare @\/@ as+-- rooted.  A relative input stays relative: leading @..@ segments are kept,+-- and a fully-collapsed relative path is @.@.+module Nix.Eval.CanonPath+  ( canonPath,+    canonPathValue,+    canonBaseName,+    canonDirName,+  )+where++import Data.Char (isAlpha)+import Data.Text (Text)+import qualified Data.Text as T+import System.FilePath (isPathSeparator, pathSeparator)++-- | The producer gate for path VALUES: a path value's text is its+-- absolute path spelled with forward slashes; on Windows a drive+-- designator precedes the root.  Platform spelling exists only at the+-- filesystem boundary.  (The same split git's object model makes: tree+-- identity is slash-canonical, the working-tree boundary converts.)+--+-- Folding is by 'isPathSeparator', so it is platform-correct with no+-- conditional: on POSIX a backslash is an ordinary file-name character+-- and passes through untouched - upstream's semantics - while on+-- Windows it is a separator and folds to @/@.  Every site that+-- produces a 'VPath' from platform-tainted text (literal resolution,+-- base-dir joins, fetcher scratch dirs, search-path entries) goes+-- through this gate; store-path text is canonical by construction.+canonPathValue :: Text -> Text+canonPathValue t = canonPath (if T.any needsFold t then T.map foldSeparator t else t)+  where+    -- Copy only when a non-'/' separator is present: on POSIX+    -- 'isPathSeparator' is '/' alone, so this is never, and the+    -- common already-canonical path shares its text on Windows too.+    needsFold c = isPathSeparator c && c /= '/'+    foldSeparator c = if needsFold c then '/' else c++-- | Canonicalize a path's text form.  See the module comment for the+-- algorithm and the separator-preservation guarantee.+canonPath :: Text -> Text+canonPath t+  | T.null t = "."+  | otherwise = assemble+  where+    (drive, afterDrive) = splitDriveLetter t+    (leadingSeps, body) = T.span isPathSeparator afterDrive+    rooted = not (T.null leadingSeps)+    -- Preserve style: emit backslashes only when the input already uses the+    -- platform separator (Windows '\\').  On POSIX 'pathSeparator' is '/',+    -- so a literal backslash - an ordinary file-name character there - never+    -- flips the choice.+    sep = if T.any (== pathSeparator) t then pathSeparator else '/'+    segments = filter (not . T.null) (splitOnSeparators body)+    resolved = reverse (foldl' (collapseStep rooted) [] segments)+    joined = T.intercalate (T.singleton sep) resolved+    rootTok = if rooted then T.singleton sep else ""+    assemble+      | rooted = drive <> rootTok <> joined+      | not (T.null drive) = drive <> joined -- drive-relative (@C:foo@)+      | null resolved = "."+      | otherwise = joined++-- | Split a leading @X:@ drive letter (Windows) from the rest.  A bare+-- rooted path (@\/foo@) or a POSIX path has no drive.  UNC roots are left+-- to the leading-separator handling, which collapses them to a single root.+splitDriveLetter :: Text -> (Text, Text)+splitDriveLetter t+  | Just (c0, rest0) <- T.uncons t,+    isAlpha c0,+    Just (':', _) <- T.uncons rest0 =+      T.splitAt 2 t+  | otherwise = ("", t)++-- | One segment of the collapse fold; the accumulator holds resolved+-- segments in reverse.  A @..@ pops a real predecessor, drops at a root,+-- and is otherwise kept (a relative path may lead with @..@).+collapseStep :: Bool -> [Text] -> Text -> [Text]+collapseStep rooted acc segment = case segment of+  "." -> acc+  ".." -> case acc of+    [] -> [".." | not rooted]+    (top : below)+      | top == ".." -> ".." : acc+      | otherwise -> below+  _ -> segment : acc++-- | Split on platform path separators: both @\/@ and @\\@ on Windows, only+-- @\/@ on POSIX, where a backslash is an ordinary file-name character.+splitOnSeparators :: Text -> [Text]+splitOnSeparators = T.split isPathSeparator++-- | Last segment of a path - upstream @baseNameOf@.  Empty for a root or a+-- trailing separator, which the caller treats as "no base name".+canonBaseName :: Text -> Text+canonBaseName = T.takeWhileEnd (not . isPathSeparator)++-- | Parent of a path VALUE - upstream @dirOf@ on paths.  Splits on the+-- platform's separators like 'canonBaseName', because path values may+-- be native-spelled; the textual '/'-only rule for STRING operands+-- lives with its builtin.  A sole leading separator is its own parent+-- (@dirOf \/foo@ is @\/@, and a drive root @C:\\foo@ keeps its rooted+-- @C:\\@); a separatorless path has parent @.@.+canonDirName :: Text -> Text+canonDirName t = case T.dropWhileEnd (not . isPathSeparator) t of+  "" -> "."+  prefix ->+    let dir = T.dropWhileEnd isPathSeparator prefix+     in if T.null dir || fst (splitDriveLetter dir) == dir then prefix else dir
src/Nix/Eval/Compile.hs view
@@ -56,6 +56,7 @@     formalName,     formalNamedSet,     formalSet,+    spilledCountSentinel,     strpartInterp,     strpartLit,     unaryNegate,@@ -76,6 +77,7 @@     pattern OpLitNull,     pattern OpLitPath,     pattern OpLitUri,+    pattern OpPathStr,     pattern OpResolvedVar,     pattern OpSearchPath,     pattern OpSelect,@@ -109,6 +111,7 @@     go (ELit atom) = compileLit atom     go (EStr parts) = compileStringParts OpStr parts     go (EIndStr parts) = compileStringParts OpIndStr parts+    go (EPathStr parts) = compileStringParts OpPathStr parts     go (EVar name) = compileSymbolOp OpVar name     go (EWithVar name) = compileSymbolOp OpWithVar name     go (EResolvedVar level idx) =@@ -180,8 +183,8 @@     compileStringParts :: Word8 -> [StringPart] -> IO Word32     compileStringParts op parts = do       compiled <- mapM compileOnePart parts-      dataOff <- emitPairs compiled-      cbcEmit op 0 (fi (length parts)) dataOff 0 0+      (partCount, dataOff) <- emitCounted "string with parts" (length parts) (pairWords compiled)+      cbcEmit op 0 partCount dataOff 0 0      compileOnePart :: StringPart -> IO (Word32, Word32)     compileOnePart (StrLit t) = do@@ -197,9 +200,10 @@      compileAttrs :: Bool -> [Binding] -> CaptureInfo -> IO Word32     compileAttrs isRec bindings captureInfo = do-      dataOff <- compileBindings bindings+      bindingWords <- compileBindingWords bindings       capOff <- compileCaptureInfo captureInfo-      cbcEmit OpAttrs (if isRec then 1 else 0) (fi (length bindings)) dataOff capOff 0+      (bindingCount, dataOff) <- emitCounted "attribute set with bindings" (length bindings) bindingWords+      cbcEmit OpAttrs (if isRec then 1 else 0) bindingCount dataOff capOff 0      -- -----------------------------------------------------------------     -- Lists (EList)@@ -208,8 +212,8 @@     compileList :: [Expr] -> IO Word32     compileList exprs = do       childIndices <- mapM go exprs-      dataOff <- emitWordList childIndices-      cbcEmit OpList 0 (fi (length exprs)) dataOff 0 0+      (elemCount, dataOff) <- emitCounted "list with elements" (length exprs) childIndices+      cbcEmit OpList 0 elemCount dataOff 0 0      -- -----------------------------------------------------------------     -- Select / HasAttr@@ -234,8 +238,8 @@     compileAttrPath :: [AttrKey] -> IO (Word32, Word16)     compileAttrPath path = do       compiled <- mapM compileAttrKey path-      off <- emitPairs compiled-      pure (off, fi (length path))+      (pathLen, off) <- emitCounted "attribute path with segments" (length path) (pairWords compiled)+      pure (off, pathLen)      compileAttrKey :: AttrKey -> IO (Word32, Word32)     compileAttrKey (StaticKey name) = do@@ -291,18 +295,20 @@     compileLet :: [Binding] -> Expr -> CaptureInfo -> IO Word32     compileLet bindings body captureInfo = do       bodyIdx <- go body-      dataOff <- compileBindings bindings+      bindingWords <- compileBindingWords bindings       capOff <- compileCaptureInfo captureInfo-      cbcEmit OpLet 0 (fi (length bindings)) dataOff bodyIdx capOff+      (bindingCount, dataOff) <- emitCounted "let with bindings" (length bindings) bindingWords+      cbcEmit OpLet 0 bindingCount dataOff bodyIdx capOff      -- -----------------------------------------------------------------     -- Bindings (shared by EAttrs and ELet)     -- ----------------------------------------------------------------- -    compileBindings :: [Binding] -> IO Word32-    compileBindings bindings = do-      allWords <- mapM compileOneBinding bindings-      emitWordList (concat allWords)+    -- \| The flat data words for a binding list; the caller emits them+    -- (with the count, via emitCounted) so a spilled count can precede+    -- them contiguously.+    compileBindingWords :: [Binding] -> IO [Word32]+    compileBindingWords bindings = concat <$> mapM compileOneBinding bindings      compileOneBinding :: Binding -> IO [Word32]     compileOneBinding (NamedBinding path expr) = do@@ -392,15 +398,9 @@     -- Data buffer helpers     -- ----------------------------------------------------------------- -    -- \| Emit pairs of (tag, value) to the data buffer.-    -- Returns the offset of the first emitted word, or 0 if empty.-    emitPairs :: [(Word32, Word32)] -> IO Word32-    emitPairs [] = pure 0-    emitPairs ((tag, val) : rest) = do-      off <- cbcEmitData tag-      _ <- cbcEmitData val-      mapM_ (\(t, v) -> cbcEmitData t >> cbcEmitData v) rest-      pure off+    -- \| Flatten (tag, value) pairs into data-buffer words.+    pairWords :: [(Word32, Word32)] -> [Word32]+    pairWords = concatMap (\(tag, val) -> [tag, val])      -- \| Emit a list of uint32 values to the data buffer.     -- Returns the offset of the first emitted word, or 0 if empty.@@ -411,6 +411,31 @@       mapM_ cbcEmitData xs       pure off +    -- \| Emit an op's counted payload, returning the short_arg and data+    -- offset to store in the op.  A count below 'spilledCountSentinel'+    -- travels inline in short_arg; a larger one is written as the first+    -- data word with the sentinel inline ('cbcCountedPayload' is the+    -- decode side).  nn_op_t stays 16 bytes either way; only an+    -- oversized literal pays the extra word.+    emitCounted :: String -> Int -> [Word32] -> IO (Word16, Word32)+    emitCounted what n payload+      | n < fromIntegral spilledCountSentinel = do+          off <- emitWordList payload+          pure (fromIntegral n, off)+      | otherwise = do+          spilled <- countArg what n+          off <- emitWordList (spilled : payload)+          pure (spilledCountSentinel, off)++    -- \| Convert a spilled element count into its 32-bit data word,+    -- failing loudly at the (unreachable-in-practice) ceiling: a silent+    -- truncation would make an oversized literal report a wrong length.+    countArg :: String -> Int -> IO Word32+    countArg what n+      | toInteger n > toInteger (maxBound :: Word32) =+          ioError (userError ("compile: " <> what <> " exceeding the bytecode limit of 4294967295 (got " <> show n <> ")"))+      | otherwise = pure (fromIntegral n)+     -- \| @fromIntegral@ shorthand.     fi :: (Integral a, Num b) => a -> b     fi = fromIntegral@@ -522,8 +547,9 @@     BcDynamicKey !Word32  -- | Decode all bindings from the bytecode data buffer.--- @bindCount@ is the number of bindings, @dataOff@ is the start offset.-decodeBcBindings :: Word16 -> Word32 -> IO [BcBinding]+-- @bindCount@ is the number of bindings (already resolved through the+-- short_arg spill by the caller), @dataOff@ is the start offset.+decodeBcBindings :: Int -> Word32 -> IO [BcBinding] decodeBcBindings 0 _ = pure [] decodeBcBindings count dataOff = do   (binding, nextOff) <- decodeOneBinding dataOff
src/Nix/Eval/Context.hs view
@@ -17,6 +17,7 @@     -- * Extraction (for derivation building)     extractInputSrcs,     extractInputDrvs,+    extractAllOutputRefs,      -- * String operations with context     appendStrings,@@ -24,7 +25,6 @@   ) where -import Data.List (foldl') import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map import qualified Data.Set as Set@@ -70,6 +70,14 @@ extractInputDrvs :: StringContext -> Map StorePath [Text] extractInputDrvs (StringContext s) =   Map.fromListWith (++) [(sp, [outName]) | SCDrvOutput sp outName <- Set.toList s]++-- | Extract all-outputs (upstream DrvDeep) derivation references - the @.drv@+-- store paths only.  Unlike 'extractInputDrvs', the output names are not+-- carried by the context element; the caller reads the referenced derivation+-- to recover its full set of output names.+extractAllOutputRefs :: StringContext -> [StorePath]+extractAllOutputRefs (StringContext s) =+  [sp | SCAllOutputs sp <- Set.toList s]  -- --------------------------------------------------------------------------- -- String operations with context
src/Nix/Eval/IO.hs view
@@ -23,15 +23,17 @@     newEvalState,      -- * Errors+    EvalErrorKind (..),     NixEvalError (..),     NixAbortError (..),   ) where -import Control.Exception (Exception, SomeAsyncException, SomeException, displayException, fromException, throwIO, try)+import Control.Exception (Exception, IOException, SomeAsyncException, SomeException, displayException, fromException, onException, throwIO, try) import Control.Monad (unless, when) import Control.Monad.IO.Class (liftIO) import Control.Monad.Reader (ReaderT (..), ask, asks, local)+import Crypto.Random (getRandomBytes) import qualified Data.ByteString as BS import Data.IORef (IORef, modifyIORef', newIORef, readIORef) import Data.Int (Int64)@@ -39,26 +41,28 @@ import qualified Data.Map.Strict as Map import Data.Text (Text) import qualified Data.Text as T-import Data.Text.Encoding (encodeUtf8)-import qualified Data.Text.IO as TIO+import qualified Data.Text.Encoding as TE import Data.Time.Clock.POSIX (getPOSIXTime) import Foreign.Ptr (Ptr, castPtr, nullPtr) import Foreign.StablePtr (castPtrToStablePtr, castStablePtrToPtr, deRefStablePtr, freeStablePtr, newStablePtr) import Nix.Builtins (builtinEnv, builtinEnvWithScope, parseNixPath)+import Nix.Derivation (fromATerm) import Nix.Eval (eval) import Nix.Eval.CList (CList (..))-import Nix.Eval.CThunk (CThunkPtr, cthunkGetAttrs, cthunkGetBcIdx, cthunkGetBool, cthunkGetCtxStr, cthunkGetFloat, cthunkGetInt, cthunkGetLambda, cthunkGetList, cthunkGetPath, cthunkGetStr, cthunkMarkBlackhole, cthunkPayload, cthunkSetComputed, cthunkSetComputedAttrs, cthunkSetComputedBool, cthunkSetComputedCtxStr, cthunkSetComputedFloat, cthunkSetComputedInt, cthunkSetComputedLambda, cthunkSetComputedList, cthunkSetComputedNull, cthunkSetComputedPath, cthunkSetComputedStr, cthunkState, cthunkValueTag)-import Nix.Eval.Symbol (Symbol (..), symbolIntern, symbolText)-import Nix.Eval.Types (AttrSet (..), Env (..), MonadEval (..), NixValue (..), Thunk (..), attrSetSize, emptyContext, marshalLambda, marshalStringContext, unmarshalLambdaValue, unmarshalStringContext, pattern ValueAttrs, pattern ValueBool, pattern ValueCtxStr, pattern ValueFloat, pattern ValueInt, pattern ValueLambda, pattern ValueList, pattern ValueNull, pattern ValuePath, pattern ValueStr)-import Nix.Expr.Types (AttrKey (..), Binding (..), Expr (..), Formal (..), Formals (..), NixAtom (..), StringPart (..))-import Nix.Hash (makeFixedOutputPath, sha256Digest, sha256Hex, truncatedBase32)+import Nix.Eval.CThunk (CThunkPtr, cthunkGetAttrs, cthunkGetBcIdx, cthunkGetBool, cthunkGetCtxStr, cthunkGetFloat, cthunkGetInt, cthunkGetLambda, cthunkGetList, cthunkGetPath, cthunkGetStr, cthunkMarkBlackhole, cthunkMarkPending, cthunkPayload, cthunkSetComputed, cthunkSetComputedAttrs, cthunkSetComputedBool, cthunkSetComputedCtxStr, cthunkSetComputedFloat, cthunkSetComputedInt, cthunkSetComputedLambda, cthunkSetComputedList, cthunkSetComputedNull, cthunkSetComputedPath, cthunkSetComputedStr, cthunkState, cthunkValueTag)+import Nix.Eval.CanonPath (canonBaseName, canonPath, canonPathValue)+import Nix.Eval.Symbol (Symbol (..), symbolBytes, symbolIntern, symbolInternBytes, symbolText)+import Nix.Eval.Types (AttrSet (..), Env (..), MonadEval (..), NixValue (..), Thunk (..), attrSetSize, emptyContext, marshalLambda, marshalStringContext, storePathOrThrow, unmarshalLambdaValue, unmarshalStringContext, pattern ValueAttrs, pattern ValueBool, pattern ValueCtxStr, pattern ValueFloat, pattern ValueInt, pattern ValueLambda, pattern ValueList, pattern ValueNull, pattern ValuePath, pattern ValueStr)+import Nix.Hash (bytesToHexText, makeFixedOutputPath, makeTextPath, sha256Digest) import Nix.Parser (parseNix, readFileAutoEncoding)+import Nix.Store (copyPathInto, unpackNarEntry)+import qualified Nix.Store.ExecBit as ExecBit import qualified Nix.Store.Path as SP import qualified NovaCache.NAR as NAR import qualified System.Directory as Dir import System.Environment (lookupEnv) import System.Exit (ExitCode (..))-import System.FilePath (isRelative, takeDirectory, takeFileName, (</>))+import System.FilePath (isRelative, takeDirectory, (</>)) import System.IO (hPutStrLn, stderr) import qualified System.Process as Proc @@ -66,8 +70,16 @@ -- Error type -- --------------------------------------------------------------------------- --- | Evaluation error surfaced as an IO exception (catchable by tryEval).-newtype NixEvalError = NixEvalError Text+-- | How an eval-time failure interacts with @builtins.tryEval@:+-- 'ErrorThrown' (@builtins.throw@, a failed @assert@) is catchable,+-- matching upstream's ThrownError\/AssertionError; 'ErrorUncatchable'+-- (type errors, missing attributes, IO failures) escapes tryEval like+-- every other upstream EvalError.+data EvalErrorKind = ErrorThrown | ErrorUncatchable+  deriving (Eq, Show)++-- | Evaluation error surfaced as an IO exception.+data NixEvalError = NixEvalError !EvalErrorKind !Text   deriving (Show)  instance Exception NixEvalError@@ -99,27 +111,38 @@     -- bottom-up by 'builtinDerivationStrict' so input derivations can be     -- substituted by their content hashes when computing output paths.     esDrvModuloCache :: !(IORef (Map Text Text)),-    -- | Accumulated @.drv@ closure (drv store path text to its ATerm), recorded-    -- bottom-up by 'builtinDerivationStrict'.  The build driver reads this after-    -- evaluation to write every input @.drv@ to the store before building.-    esDrvClosure :: !(IORef (Map Text Text)),+    -- | Accumulated @.drv@ closure (drv store path text to its ATerm bytes),+    -- recorded bottom-up by 'builtinDerivationStrict'.  The build driver reads+    -- this after evaluation to write every input @.drv@ to the store before+    -- building.+    esDrvClosure :: !(IORef (Map Text BS.ByteString)),     -- | Cache of source path to its store path (recursive NAR hash), so a path     -- literal used across many derivations is hashed only once.     esSourcePathCache :: !(IORef (Map Text Text)),+    -- | Every store object evaluation wrote (store path to its+    -- references), for the build driver to register before building -+    -- eval has no store DB handle.  All of them, not only+    -- @builtins.toFile@: an unrecorded write reaches @drvInputSrcs@ as+    -- an unregistered path and fails the build that names it.+    esStoreWriteCache :: !(IORef (Map Text ([SP.StorePath], SP.StoreWriteMode))),     esBaseDir :: !FilePath,-    esStoreDir :: !FilePath,+    -- | Where store objects live on this machine, so eval's own reads and+    -- writes honor @--store@ the same way the builder does.+    esStoreDir :: !SP.StoreDir,     esTimestamp :: !Int64,     esSearchPaths :: ![Thunk]   } --- | Create a fresh evaluation state rooted at the given directory.+-- | Create a fresh evaluation state rooted at the given directory, reading+-- and writing store objects under the given store directory. -- Reads @NIX_PATH@ from the environment to populate search paths.-newEvalState :: FilePath -> IO EvalState-newEvalState baseDir = do+newEvalState :: SP.StoreDir -> FilePath -> IO EvalState+newEvalState storeDir baseDir = do   cache <- newIORef Map.empty   drvCache <- newIORef Map.empty   drvClosure <- newIORef Map.empty   srcCache <- newIORef Map.empty+  storeWriteCache <- newIORef Map.empty   now <- floor <$> getPOSIXTime :: IO Int64   nixPathStr <- lookupEnvText "NIX_PATH"   let searchPaths = case nixPathStr of@@ -131,8 +154,9 @@         esDrvModuloCache = drvCache,         esDrvClosure = drvClosure,         esSourcePathCache = srcCache,+        esStoreWriteCache = storeWriteCache,         esBaseDir = baseDir,-        esStoreDir = T.unpack SP.platformStoreDirText,+        esStoreDir = storeDir,         esTimestamp = now,         esSearchPaths = searchPaths       }@@ -150,51 +174,53 @@ -- ---------------------------------------------------------------------------  instance MonadEval EvalIO where-  throwEvalError msg = EvalIO (liftIO (throwIO (NixEvalError msg)))+  throwEvalError msg = EvalIO (liftIO (throwIO (NixEvalError ErrorUncatchable msg)))+  throwCatchableError msg = EvalIO (liftIO (throwIO (NixEvalError ErrorThrown msg)))   abortEvaluation msg = EvalIO (liftIO (throwIO (NixAbortError msg))) +  -- tryEval semantics: recover from a throw/assert only; an uncatchable+  -- eval error is rethrown (aborts are a separate exception type and+  -- never enter the 'try').   catchEvalError (EvalIO action) = EvalIO $ do     st <- ask     result <- liftIO (try (runReaderT action st))-    pure (case result of Left (NixEvalError msg) -> Left msg; Right val -> Right val)+    case result of+      Left (NixEvalError ErrorThrown msg) -> pure (Left msg)+      Left err@(NixEvalError ErrorUncatchable _) -> liftIO (throwIO err)+      Right val -> pure (Right val) -  readFileText path = wrapIO (readFileAutoEncoding (T.unpack path))+  -- Over IO exceptions, so eval errors, aborts, and IO failures+  -- crossing 'wrapIO' all trigger the cleanup before propagating.+  onEvalError (EvalIO action) (EvalIO cleanup) = EvalIO $ do+    st <- ask+    liftIO (runReaderT action st `onException` runReaderT cleanup st) -  doesPathExist path = wrapIO (Dir.doesPathExist (T.unpack path))+  doesPathExist path = evalStoreTextPath path >>= \resolved -> wrapIO (Dir.doesPathExist resolved) -  listDirectory path = wrapIO $ do-    let dir = T.unpack path-    entries <- Dir.listDirectory dir-    mapM (classifyEntry dir) entries+  listDirectory path = do+    dir <- evalStoreTextPath path+    wrapIO $ do+      entries <- Dir.listDirectory dir+      mapM (classifyEntry dir) entries    importFile rawPath = do     baseDir <- EvalIO (asks esBaseDir)     timestamp <- EvalIO (asks esTimestamp)     searchPaths <- EvalIO (asks esSearchPaths)-    let raw = T.unpack rawPath-        resolved = if isRelative raw then baseDir </> raw else raw-    canonical <- wrapIO (Dir.canonicalizePath resolved)-    -- Directory import: append /default.nix if target is a directory-    target <- wrapIO $ do-      isDir <- Dir.doesDirectoryExist canonical-      pure (if isDir then canonical </> "default.nix" else canonical)+    (target, ioTarget) <- resolveImportTarget baseDir rawPath     -- Check import cache (readIORef cannot throw, no wrapIO needed)     cacheRef <- EvalIO (asks esImportCache)     cache <- EvalIO (liftIO (readIORef cacheRef))     case Map.lookup target cache of       Just cached -> pure cached       Nothing -> do-        source <- wrapIO (readFileAutoEncoding target)-        case parseNix (T.pack target) source of+        source <- wrapIO (readFileAutoEncoding ioTarget)+        let fileDir = takeDirectory target+        case parseNix fileDir (T.pack target) source of           Left err ->             throwEvalError               ("import " <> T.pack target <> ": " <> T.pack (show err))-          Right rawExpr -> do-            -- Resolve relative paths in the AST to absolute, matching-            -- real Nix (which resolves at parse time).  This ensures paths-            -- captured in closures remain valid after the import scope ends.-            let fileDir = takeDirectory target-                expr = resolveRelativePaths fileDir rawExpr+          Right expr -> do             -- local sets new base dir for nested imports - pure, exception-safe             let nested =                   EvalIO@@ -230,70 +256,92 @@     ref <- asks esDrvClosure     liftIO (modifyIORef' ref (Map.insert key aterm)) +  -- Read a .drv from the store on a modulo-hash cache miss (a cross-session or+  -- appendContext reference).  Mirrors the build side's readDrvFromStore: map+  -- to the on-disk path, read the raw bytes, parse the ATerm byte-level (env+  -- values keep arbitrary bytes).  Any failure - absent file, malformed ATerm+  -- - is 'Nothing', which the caller turns into a loud modulo-hash error.+  readStoreDerivation sp = EvalIO $ do+    filePath <- asks ((`storeFilePath` sp) . esStoreDir)+    result <- liftIO (try (BS.readFile filePath) :: IO (Either SomeException BS.ByteString))+    pure $ case result of+      Left _ -> Nothing+      Right bytes -> either (const Nothing) Just (fromATerm bytes)++  -- Look up a derivation recorded earlier this session by its .drv path,+  -- reusing the esDrvClosure ATerm map (populated bottom-up by recordDrvAterm).+  -- This recovers an in-session all-outputs reference's output names without a+  -- disk read - the .drv is not written to the store until after evaluation.+  lookupSessionDrv drvPathText = EvalIO $ do+    ref <- asks esDrvClosure+    closure <- liftIO (readIORef ref)+    pure (Map.lookup drvPathText closure >>= either (const Nothing) Just . fromATerm)+   storeSourcePath rawPath = do     ref <- EvalIO (asks esSourcePathCache)     cached <- EvalIO (liftIO (Map.lookup rawPath <$> readIORef ref))     case cached of       Just hit -> pure hit       Nothing -> do-        entry <- wrapIO (NAR.serialiseFromPath (T.unpack rawPath))+        -- Upstream names the copy baseNameOf(canonicalized path); path+        -- values arrive canonicalized, so the last segment is the name.+        -- The name is checked before the tree read: it derives from the+        -- path alone, and the tree behind it can be arbitrarily large.+        let name = canonBaseName rawPath+            copyContext = "cannot copy '" <> rawPath <> "' to the store"+        when (T.null name) $+          throwEvalError (copyContext <> ": the path has no base name")+        case SP.checkStorePathName name of+          Left err -> throwEvalError (copyContext <> ": " <> SP.storePathNameErrorText err)+          Right () -> pure ()+        resolvedSource <- evalStoreTextPath rawPath+        entry <- wrapIO (ExecBit.serialiseFromPath resolvedSource)         let narDigest = sha256Digest (NAR.serialise entry)-            name = T.pack (takeFileName (T.unpack rawPath))-            sp = makeFixedOutputPath name "sha256" "recursive" narDigest-            spText = SP.storePathToText SP.defaultStoreDir sp+        sp <- storePathOrThrow copyContext (makeFixedOutputPath name "sha256" "recursive" narDigest)+        let spText = canonicalStorePathText sp         EvalIO (liftIO (modifyIORef' ref (Map.insert rawPath spText)))         pure spText    getCurrentTime = EvalIO (asks esTimestamp) -  writeToStore name contents = do-    -- Validate name to prevent path traversal-    when (T.any (== '/') name) $-      throwEvalError ("writeToStore: name must not contain '/': " <> name)-    when (T.any (== '\\') name) $-      throwEvalError ("writeToStore: name must not contain '\\': " <> name)-    when (T.isInfixOf ".." name) $-      throwEvalError ("writeToStore: name must not contain '..': " <> name)-    when (T.any (== '\0') name) $-      throwEvalError ("writeToStore: name must not contain null bytes: " <> name)-    storeDir <- EvalIO (asks esStoreDir)-    -- The preimage is built inline against the LIVE storeDir (esStoreDir), not-    -- via Nix.Hash.makeStorePath which pins the canonical defaultStoreDir for-    -- cross-platform .drv-hash parity.  toFile outputs are host-local, so they-    -- must use the running store dir - keep this format in sync with-    -- makeStorePath's preimage (type:sha256:hex:storeDir:name).-    let contentHash = sha256Hex (encodeUtf8 contents)-        inner = "nix-store:sha256:" <> contentHash <> ":" <> T.pack storeDir <> ":" <> name-        pathHash = truncatedBase32 (encodeUtf8 inner)-        basename = pathHash <> "-" <> name-        -- File path uses platform separator for I/O-        filePath = storeDir </> T.unpack basename-        -- Store path text always uses forward slash (Nix convention)-        storePath = T.pack storeDir <> "/" <> basename+  writeToStore name contents refs = do+    -- Upstream's text-path scheme via makeTextPath - the same scheme .drv+    -- paths use, so it is parity-validated: type @text:<refs>@, flat+    -- sha256 of the contents, canonical store dir.  Construction also+    -- validates the name, so the write below never targets a path+    -- outside the store root.  The contents are the string's RAW BYTES,+    -- hashed and written as-is: no encoding step, and no text-mode IO+    -- (which would CRLF-translate on Windows and store bytes that no+    -- longer match the hash that named the path).+    sp <- storePathOrThrow "builtins.toFile" (makeTextPath name (sha256Digest contents) refs)+    filePath <- evalFilePath sp+    let storePath = canonicalStorePathText sp     wrapIO $ do-      Dir.createDirectoryIfMissing True storeDir-      TIO.writeFile filePath contents+      Dir.createDirectoryIfMissing True (takeDirectory filePath)+      -- Adopt an existing file only when its bytes are these bytes: an+      -- interrupted earlier run can leave a truncated file here, and+      -- skipping on bare existence adopted it under this path's hash.+      -- 'Dir.removePathForcibly' clears read-only marks and accepts a+      -- missing path, so a sealed or squatting leftover rewrites too.+      existing <- readBytesIfPresent filePath+      unless (existing == Just contents) $ do+        Dir.removePathForcibly filePath+        BS.writeFile filePath contents+    recordStoreWrite storePath refs SP.WriteText     pure storePath    scopedImportFile scope rawPath = do     baseDir <- EvalIO (asks esBaseDir)     timestamp <- EvalIO (asks esTimestamp)     searchPaths <- EvalIO (asks esSearchPaths)-    let raw = T.unpack rawPath-        resolved = if isRelative raw then baseDir </> raw else raw-    canonical <- wrapIO (Dir.canonicalizePath resolved)-    -- Directory import: append /default.nix if target is a directory-    target <- wrapIO $ do-      isDir <- Dir.doesDirectoryExist canonical-      pure (if isDir then canonical </> "default.nix" else canonical)-    source <- wrapIO (readFileAutoEncoding target)-    case parseNix (T.pack target) source of+    (target, ioTarget) <- resolveImportTarget baseDir rawPath+    source <- wrapIO (readFileAutoEncoding ioTarget)+    let fileDir = takeDirectory target+    case parseNix fileDir (T.pack target) source of       Left err ->         throwEvalError           ("scopedImport " <> T.pack target <> ": " <> T.pack (show err))-      Right rawExpr -> do-        let fileDir = takeDirectory target-            expr = resolveRelativePaths fileDir rawExpr+      Right expr -> do         -- No import cache for scoped imports (different scopes = different results)         let scopedEnv = builtinEnvWithScope timestamp searchPaths scope         EvalIO@@ -302,9 +350,9 @@               (unEvalIO (eval scopedEnv expr))           ) -  readFileBytes path = wrapIO (BS.readFile (T.unpack path))+  readFileBytes path = evalStoreTextPath path >>= \resolved -> wrapIO (BS.readFile resolved) -  getFileType path = wrapIO (classifyPath (T.unpack path))+  getFileType path = evalStoreTextPath path >>= \resolved -> wrapIO (classifyPath resolved)    runProcess cmd cmdArgs stdinText = wrapIO $ do     let cp =@@ -320,38 +368,160 @@           ExitFailure n -> n     pure (code, T.pack stdoutStr, T.pack stderrStr) -  copyPathToStore srcPath name = do-    -- Validate name to prevent path traversal (matches writeToStore)-    when (T.any (== '/') name) $-      throwEvalError ("copyPathToStore: name must not contain '/': " <> name)-    when (T.any (== '\\') name) $-      throwEvalError ("copyPathToStore: name must not contain '\\': " <> name)-    when (T.isInfixOf ".." name) $-      throwEvalError ("copyPathToStore: name must not contain '..': " <> name)-    when (T.any (== '\0') name) $-      throwEvalError ("copyPathToStore: name must not contain null bytes: " <> name)-    storeDir <- EvalIO (asks esStoreDir)-    -- Inline host-local store-path preimage (see writeToStore's note): uses the-    -- live esStoreDir, not makeStorePath's pinned defaultStoreDir.-    let contentHash = sha256Hex (encodeUtf8 ("source:" <> srcPath <> ":" <> name))-        inner = "nix-store:sha256:" <> contentHash <> ":" <> T.pack storeDir <> ":" <> name-        pathHash = truncatedBase32 (encodeUtf8 inner)-        basename = pathHash <> "-" <> name-        -- File path uses platform separator for I/O-        destFilePath = storeDir </> T.unpack basename-        -- Store path text always uses forward slash (Nix convention)-        destPath = T.pack storeDir <> "/" <> basename-    wrapIO (copyToStoreIfMissing (T.unpack srcPath) destFilePath storeDir)+  createScratchDir prefix = wrapIO $ do+    tmpBase <- Dir.getTemporaryDirectory+    suffix <- getRandomBytes scratchSuffixBytes+    -- Forward-slash join: the scratch path feeds sh pipelines (tar -C)+    -- and store copies, both of which accept '/' on every host.+    let dir = tmpBase <> "/" <> T.unpack (prefix <> bytesToHexText suffix)+    -- createDirectory is exclusive: an already-existing path fails the+    -- fetch rather than being silently adopted.+    Dir.createDirectory dir+    pure (T.pack dir)++  removeScratchDir dir = wrapIO (Dir.removePathForcibly (T.unpack dir))++  copyPathToStore srcPath name expectedSha256 = do+    -- The name is checked before the tree read: it arrives independently+    -- of the source, and the tree can be arbitrarily large.+    let copyContext = "cannot copy '" <> srcPath <> "' to the store"+    case SP.checkStorePathName name of+      Left err -> throwEvalError (copyContext <> ": " <> SP.storePathNameErrorText err)+      Right () -> pure ()+    -- Content-addressed like upstream addToStore: recursive NAR sha256+    -- under the caller's name.  Same content means same path, so the+    -- existence check in copyToStoreIfMissing is sound - changed source+    -- content can never serve stale bytes from an earlier copy (the old+    -- scheme hashed the path STRING, so it did exactly that).+    resolvedSource <- evalStoreTextPath srcPath+    entry <- wrapIO (ExecBit.serialiseFromPath resolvedSource)+    let narDigest = sha256Digest (NAR.serialise entry)+    case expectedSha256 of+      Just (subject, expected)+        | expected /= narDigest ->+            throwEvalError+              ( subject+                  <> ": hash mismatch: expected sha256:"+                  <> bytesToHexText expected+                  <> ", got sha256:"+                  <> bytesToHexText narDigest+              )+      _ -> pure ()+    sp <- storePathOrThrow copyContext (makeFixedOutputPath name "sha256" "recursive" narDigest)+    destFilePath <- evalFilePath sp+    let destPath = canonicalStorePathText sp+    wrapIO (copyToStoreVerified resolvedSource destFilePath (takeDirectory destFilePath) narDigest)+    recordStoreWrite destPath [] SP.WriteRecursive     pure destPath +  narHashOfPath path = do+    resolved <- evalStoreTextPath path+    wrapIO (sha256Digest . NAR.serialise <$> ExecBit.serialiseFromPath resolved)++  isExecutableFile path = evalStoreTextPath path >>= \resolved -> wrapIO (ExecBit.isExecutable resolved)++  setExecutableFile path = evalStoreTextPath path >>= \resolved -> wrapIO (ExecBit.markExecutable resolved)++  lookupFetchCache key = wrapIO $ do+    file <- fetchCacheFile key+    there <- Dir.doesFileExist file+    if not there+      then pure Nothing+      else do+        recorded <- try (BS.readFile file) :: IO (Either SomeException BS.ByteString)+        pure $ case recorded of+          Left _ -> Nothing+          Right bytes -> either (const Nothing) Just (TE.decodeUtf8' bytes)++  adoptStorePath path = do+    resolved <- evalStoreTextPath path+    there <- wrapIO (Dir.doesPathExist resolved)+    -- Same recording the uncached fetch's copyPathToStore would have done.+    -- materializeEvalStoreWrites skips a path that is already valid, so+    -- re-recording one costs nothing and closes the case where the row is+    -- missing.+    when there (recordStoreWrite path [] SP.WriteRecursive)+    pure there++  writeFetchCache key value = wrapIO $ do+    file <- fetchCacheFile key+    -- The cache is an optimisation: a machine that cannot write one still+    -- has to be able to build.+    _ <-+      try+        ( do+            Dir.createDirectoryIfMissing True (takeDirectory file)+            -- Written beside the entry and renamed onto it, so a reader+            -- sees either the whole entry or none of it. A plain write can+            -- be cut short and leave a torn file behind, and an entry is+            -- trusted for as long as its store path survives.+            let staging = file ++ ".tmp"+            BS.writeFile staging (TE.encodeUtf8 value)+            Dir.renameFile staging file+        ) ::+        IO (Either SomeException ())+    pure ()++  readSymlinkTarget path = evalStoreTextPath path >>= \resolved -> wrapIO (T.pack <$> Dir.getSymbolicLinkTarget resolved)++  addSourceNar name narBytes =+    case NAR.deserialise narBytes of+      -- Unreachable in practice: the bytes come from NAR.serialise of a+      -- tree this process just built.  Kept total for the FFI-adjacent+      -- boundary rather than trusting the round trip.+      Left err -> throwEvalError ("builtins.path: internal NAR round-trip error: " <> T.pack err)+      Right entry -> do+        sp <- storePathOrThrow "builtins.path" (makeFixedOutputPath name "sha256" "recursive" (sha256Digest narBytes))+        destFilePath <- evalFilePath sp+        let destPath = canonicalStorePathText sp+        wrapIO $ do+          Dir.createDirectoryIfMissing True (takeDirectory destFilePath)+          -- Adopt an existing tree only when it serialises to exactly+          -- these NAR bytes; an interrupted earlier unpack is cleared+          -- and unpacked afresh.+          onDiskNar <- narBytesIfPresent destFilePath+          unless (onDiskNar == Just narBytes) $ do+            Dir.removePathForcibly destFilePath+            unpacked <- unpackNarEntry destFilePath entry+            either (throwIO . userError . T.unpack) pure unpacked+        recordStoreWrite destPath [] SP.WriteRecursive+        pure destPath++  addFixedOutputFile name bytes = do+    -- Canonical fixed-output path: a sha256-pinned fetch must land at the same+    -- store path C++ Nix computes, so it stays reproducible and cache-compatible.+    sp <- storePathOrThrow "builtins.fetchurl" (makeFixedOutputPath name "sha256" "flat" (sha256Digest bytes))+    filePath <- evalFilePath sp+    let storePath = canonicalStorePathText sp+    wrapIO $ do+      Dir.createDirectoryIfMissing True (takeDirectory filePath)+      -- Same verified adoption as the other writers: this one used to+      -- write unconditionally, which both adopted nothing (fine) and+      -- crashed on a sealed leftover (the write hits read-only).+      existing <- readBytesIfPresent filePath+      unless (existing == Just bytes) $ do+        Dir.removePathForcibly filePath+        BS.writeFile filePath bytes+    recordStoreWrite storePath [] SP.WriteFlat+    pure storePath+   traceMessage msg = EvalIO (liftIO (hPutStrLn stderr (T.unpack msg)))    resolvePathLiteral path = do     baseDir <- EvalIO (asks esBaseDir)-    let raw = T.unpack path-    if isRelative raw-      then pure (T.pack (baseDir </> raw))-      else pure path+    -- ~/x resolves against the home directory (upstream lexes HPATH and+    -- expands it at eval); everything else relative joins the base dir.+    -- Both end at the producer gate ('canonPathValue'): the value is+    -- absolute, lexically canonical, and slash-spelled regardless of+    -- the base dir's native spelling - platform separators exist only+    -- at the filesystem boundary.+    expanded <- case T.stripPrefix "~/" path of+      Just below -> do+        home <- wrapIO Dir.getHomeDirectory+        pure (home </> T.unpack below)+      Nothing -> pure (T.unpack path)+    let absolute = if isRelative expanded then baseDir </> expanded else expanded+    pure (canonPathValue (T.pack absolute))    forceThunk evalFn (Thunk ptr) = do     -- Force protocol: PENDING to BLACKHOLE to COMPUTED with memoization.@@ -384,7 +554,17 @@         -- Mark blackhole BEFORE evaluation - any re-entry hits the         -- BLACKHOLE branch above.         _ <- EvalIO (liftIO (cthunkMarkBlackhole ptr))-        val <- evalFn env bcIdx+        -- If the force throws (a builtins.throw caught by an upstream tryEval,+        -- a type error, a failed import), restore the thunk to PENDING and+        -- rethrow, so a later force of this shared thunk re-evaluates instead of+        -- taking the BLACKHOLE branch above and aborting with a bogus "infinite+        -- recursion".  Mirrors C++ Nix forceValue: catch (...) { restore; throw }.+        -- A genuine self-recursion still rethrows its NixAbortError, which+        -- escapes tryEval exactly as before.+        val <- EvalIO $ do+          st <- ask+          liftIO+            (runReaderT (unEvalIO (evalFn env bcIdx)) st `onException` cthunkMarkPending ptr)         oldPayload <- EvalIO (liftIO (storeComputed ptr val))         -- Free the pending env StablePtr.         when (oldPayload /= nullPtr) $@@ -406,7 +586,7 @@     cthunkSetComputedPath ptr sym   VStr t ctx     | ctx == emptyContext -> do-        Symbol sym <- symbolIntern t+        Symbol sym <- symbolInternBytes t         cthunkSetComputedStr ptr sym     | otherwise -> do         csptr <- marshalStringContext t ctx@@ -431,7 +611,7 @@     ValueNull -> pure VNull     ValueStr -> do       sym <- cthunkGetStr ptr-      pure (VStr (symbolText (Symbol sym)) emptyContext)+      pure (VStr (symbolBytes (Symbol sym)) emptyContext)     ValuePath -> VPath . symbolText . Symbol <$> cthunkGetPath ptr     ValueList -> do       listPtr <- cthunkGetList ptr@@ -457,10 +637,112 @@ importCacheMaxAttrs :: Int importCacheMaxAttrs = 1000 +-- | Random bytes in a scratch-dir name suffix (hex-encoded).  128 bits:+-- unguessable by another local process, collision-free in practice.+scratchSuffixBytes :: Int+scratchSuffixBytes = 16+ -- --------------------------------------------------------------------------- -- Helpers -- --------------------------------------------------------------------------- +-- | Where a recorded fetch is kept: one file per key, under this user's+-- cache directory, named by the key's own hash. Outside the store on+-- purpose - it's a note about work already done, not a derivation input.+fetchCacheFile :: Text -> IO FilePath+fetchCacheFile key = do+  dir <- Dir.getXdgDirectory Dir.XdgCache "nova-nix/fetch"+  pure (dir </> T.unpack (bytesToHexText (sha256Digest (TE.encodeUtf8 key))))++-- | Where a store path lives on this machine: this evaluation's store dir+-- mapped to a filesystem path.  This is the 'SP.StorePath' direction;+-- path-value text goes through 'evalStoreTextPath'.  Both must read the+-- same store dir, since a store honored on one side and not the other+-- writes to one directory and reads from another.+storeFilePath :: SP.StoreDir -> SP.StorePath -> FilePath+storeFilePath = SP.storePathToFilePath++-- | Record a store object evaluation just wrote, so the build driver+-- registers it before a derivation naming it is built.  Every eval-time+-- writer goes through this: a write that skips it lands in+-- @drvInputSrcs@ with no registration row and fails the build with+-- \"references unregistered path\".  The mode names the scheme that+-- constructed the path, so materialization can verify the on-disk+-- content reproduces it before registering.+recordStoreWrite :: Text -> [SP.StorePath] -> SP.StoreWriteMode -> EvalIO ()+recordStoreWrite storePath refs mode = do+  cacheRef <- EvalIO (asks esStoreWriteCache)+  EvalIO (liftIO (modifyIORef' cacheRef (Map.insert storePath (refs, mode))))++-- | The file's bytes, 'Nothing' when nothing readable is there: absent,+-- or something unreadable squatting on the name; either way the writer+-- must replace rather than adopt.+readBytesIfPresent :: FilePath -> IO (Maybe BS.ByteString)+readBytesIfPresent path = do+  result <- try (BS.readFile path) :: IO (Either IOException BS.ByteString)+  pure (either (const Nothing) Just result)++-- | The NAR serialisation of what is on disk, 'Nothing' when nothing+-- serialisable is there.  Through 'ExecBit.serialiseFromPath', the+-- same walk every producer and verifier uses, so the comparison sees+-- the executable flags the store's model records, not the platform's+-- permission guesses.+narBytesIfPresent :: FilePath -> IO (Maybe BS.ByteString)+narBytesIfPresent path = do+  onDisk <- Dir.doesPathExist path+  if not onDisk+    then pure Nothing+    else do+      result <- try (ExecBit.serialiseFromPath path) :: IO (Either IOException NAR.NarEntry)+      pure (either (const Nothing) (Just . NAR.serialise) result)++-- | 'storeFilePath' against the store this evaluation was given.+evalFilePath :: SP.StorePath -> EvalIO FilePath+evalFilePath sp = EvalIO (asks ((`storeFilePath` sp) . esStoreDir))++-- | Resolve path-value text to a filesystem location against the store+-- this evaluation was given.  The read counterpart of 'evalFilePath':+-- both sides of every eval-time store access must agree on the store+-- dir, or a redirected store is written to and read from two places.+evalStoreTextPath :: Text -> EvalIO FilePath+evalStoreTextPath txt = EvalIO (asks ((`SP.storeTextToFilePath` txt) . esStoreDir))++-- | A store path's identity: the canonical @/nix/store@ spelling every+-- platform shares.  Hashes and eval-visible strings carry this form; it+-- never names a location on disk.+canonicalStorePathText :: SP.StorePath -> Text+canonicalStorePathText = SP.storePathToText SP.defaultStoreDir++-- | Resolve an import's raw path to the value-domain target (the import+-- cache key, the parse name, and the base dir the file's relative path+-- literals resolve against) and the filesystem location to read.+--+-- Store text stays canonical in the value domain: 'Dir.canonicalizePath'+-- would attach the working drive to the rooted @/nix@ prefix on Windows,+-- so store text gets lexical canonicalization ('canonPath') only, and+-- its reads resolve through 'evalStoreTextPath'.  Every other path+-- resolves and canonicalizes as a platform path, where the two returned+-- forms coincide.+resolveImportTarget :: FilePath -> Text -> EvalIO (FilePath, FilePath)+resolveImportTarget baseDir rawPath+  | SP.isCanonicalStoreText rawPath = do+      let valueBase = T.unpack (canonPath rawPath)+      resolvedBase <- evalStoreTextPath (T.pack valueBase)+      isDir <- wrapIO (Dir.doesDirectoryExist resolvedBase)+      -- The value-domain join stays "/" so the canonical spelling survives.+      let valueTarget = if isDir then valueBase <> "/default.nix" else valueBase+      resolvedTarget <- evalStoreTextPath (T.pack valueTarget)+      pure (valueTarget, resolvedTarget)+  | otherwise = do+      let raw = T.unpack rawPath+          resolved = if isRelative raw then baseDir </> raw else raw+      canonical <- wrapIO (Dir.canonicalizePath resolved)+      -- Directory import: append /default.nix if target is a directory+      target <- wrapIO $ do+        isDir <- Dir.doesDirectoryExist canonical+        pure (if isDir then canonical </> "default.nix" else canonical)+      pure (target, target)+ -- | Classify a filesystem path as @"regular"@, @"directory"@, @"symlink"@, -- or @"unknown"@ - matching Nix's @builtins.readDir@ / @readFileType@. classifyPath :: FilePath -> IO Text@@ -498,7 +780,7 @@       | Just (_ :: SomeAsyncException) <- fromException err -> throwIO err       | Just abortErr <- fromException err -> throwIO (abortErr :: NixAbortError)       | Just nixErr <- fromException err -> throwIO (nixErr :: NixEvalError)-      | otherwise -> throwIO (NixEvalError (T.pack (displayException err)))+      | otherwise -> throwIO (NixEvalError ErrorUncatchable (T.pack (displayException err)))  -- | Run an IO evaluation, returning @Left@ on error. --@@ -511,7 +793,7 @@     Right val -> pure (Right val)     Left (err :: SomeException)       | Just (_ :: SomeAsyncException) <- fromException err -> throwIO err-      | Just (NixEvalError msg) <- fromException err -> pure (Left msg)+      | Just (NixEvalError _ msg) <- fromException err -> pure (Left msg)       | Just (NixAbortError msg) <- fromException err -> pure (Left msg)       | otherwise -> pure (Left (T.pack (displayException err))) @@ -524,79 +806,24 @@     Right mval -> pure mval  -- ------------------------------------------------------------------------------ Path resolution (matching real Nix: resolve at parse time)--- ------------------------------------------------------------------------------- | Resolve all relative 'NixPath' literals in an expression to absolute--- paths relative to the given directory.  Real Nix resolves path literals--- at parse time based on the source file location.  We do the same right--- after parsing in 'importFile' so that paths captured in closures remain--- valid after the import scope ends.-resolveRelativePaths :: FilePath -> Expr -> Expr-resolveRelativePaths dir = goExpr-  where-    goExpr expr = case expr of-      ELit (NixPath p)-        | isRelative (T.unpack p) ->-            ELit (NixPath (T.pack (dir </> T.unpack p)))-      ELit _ -> expr-      EStr parts -> EStr (map goPart parts)-      EIndStr parts -> EIndStr (map goPart parts)-      EVar _ -> expr-      EWithVar _ -> expr-      EResolvedVar _ _ -> expr-      EAttrs isRec bindings captureInfo -> EAttrs isRec (map goBinding bindings) captureInfo-      EList elems -> EList (map goExpr elems)-      ESelect target path mDef ->-        ESelect (goExpr target) (map goKey path) (fmap goExpr mDef)-      EHasAttr target path -> EHasAttr (goExpr target) (map goKey path)-      EApp f x -> EApp (goExpr f) (goExpr x)-      ELambda formals body captures -> ELambda (goFormals formals) (goExpr body) captures-      ELet bindings body captureInfo -> ELet (map goBinding bindings) (goExpr body) captureInfo-      EIf c t f -> EIf (goExpr c) (goExpr t) (goExpr f)-      EWith scope body -> EWith (goExpr scope) (goExpr body)-      EAssert cond body -> EAssert (goExpr cond) (goExpr body)-      EUnary op e -> EUnary op (goExpr e)-      EBinary op l r -> EBinary op (goExpr l) (goExpr r)-      ESearchPath _ -> expr--    goPart part = case part of-      StrLit _ -> part-      StrInterp e -> StrInterp (goExpr e)--    goBinding binding = case binding of-      NamedBinding path e -> NamedBinding (map goKey path) (goExpr e)-      Inherit from names -> Inherit (fmap goExpr from) names--    goKey key = case key of-      StaticKey _ -> key-      DynamicKey e -> DynamicKey (goExpr e)--    goFormals formals = case formals of-      FormalName _ -> formals-      FormalSet fs ellipsis -> FormalSet (map goFormal fs) ellipsis-      FormalNamedSet n fs ellipsis -> FormalNamedSet n (map goFormal fs) ellipsis--    goFormal (Formal n mDef) = Formal n (fmap goExpr mDef)---- --------------------------------------------------------------------------- -- Store copy helpers -- --------------------------------------------------------------------------- --- | Copy a source path (file or directory) to the store if not already present.-copyToStoreIfMissing :: FilePath -> FilePath -> FilePath -> IO ()-copyToStoreIfMissing src dest storeDir = do+-- | Copy a source path (file or directory) to the store if not already+-- present.  The copy is 'copyPathInto', which replicates symlinks as+-- symlinks: the destination's name came from a NAR hash computed by+-- 'NovaCache.NAR.serialiseFromPath', which treats links as leaves, so a+-- dereferencing copy would store bytes that do not match their own+-- content address (and would not terminate on a link cycle).+-- | Copy a tree to its content-addressed destination.  An existing+-- destination is adopted only when its recursive NAR digest matches+-- the expected one - same content means same path, so a matching tree+-- is byte-identical by construction; anything else (an interrupted+-- earlier copy, a squatter) is cleared and re-copied.+copyToStoreVerified :: FilePath -> FilePath -> FilePath -> BS.ByteString -> IO ()+copyToStoreVerified src dest storeDir expectedDigest = do   Dir.createDirectoryIfMissing True storeDir-  alreadyExists <- Dir.doesPathExist dest-  unless alreadyExists (copyPath src dest)---- | Copy a file or directory tree to a destination.-copyPath :: FilePath -> FilePath -> IO ()-copyPath src dest = do-  isDir <- Dir.doesDirectoryExist src-  if isDir-    then do-      Dir.createDirectoryIfMissing True dest-      entries <- Dir.listDirectory src-      mapM_ (\entry -> copyPath (src </> entry) (dest </> entry)) entries-    else Dir.copyFile src dest+  onDiskNar <- narBytesIfPresent dest+  unless ((sha256Digest <$> onDiskNar) == Just expectedDigest) $ do+    Dir.removePathForcibly dest+    copyPathInto src dest
src/Nix/Eval/Operator.hs view
@@ -8,11 +8,15 @@     evalUnary,     nixCompare,     nixEqual,+    checkedAdd,+    checkedSub,+    checkedMul,   ) where  import Data.Int (Int64) import Data.Text (Text)+import qualified Data.Text as T import Nix.Eval.CAttrSet (cattrsetUnion) import Nix.Eval.CList (clistFromThunks, clistLen, clistThunks) import Nix.Eval.Types@@ -22,6 +26,7 @@     Thunk (..),     attrSetElems,     attrSetKeys,+    attrSetLookup,     thunkSameRef,     typeName,   )@@ -40,21 +45,19 @@ evalBinary :: (MonadEval m) => Force m -> BinaryOp -> NixValue -> NixValue -> m NixValue evalBinary forceFn op left right = case op of   OpAdd -> evalAdd left right-  OpSub -> evalArith "subtraction" (-) (-) left right-  OpMul -> evalArith "multiplication" (*) (*) left right+  OpSub -> evalArith "subtraction" checkedSub (-) left right+  OpMul -> evalArith "multiplication" checkedMul (*) left right   OpDiv -> evalDiv left right   OpEq -> VBool <$> nixEqual forceFn left right   OpNeq -> VBool . not <$> nixEqual forceFn left right   OpLt -> VBool <$> nixCompare forceFn left right-  OpLte -> do-    lt <- nixCompare forceFn left right-    eq <- nixEqual forceFn left right-    pure (VBool (lt || eq))+  -- <= and >= are negated swapped <, never (< or ==): upstream's parser+  -- desugars them that way (parser.y: a <= b becomes !(b < a)), which+  -- fixes NaN (nan <= x is true), matches the swapped operand order in+  -- incomparable-type errors, and needs one comparison instead of two.+  OpLte -> VBool . not <$> nixCompare forceFn right left   OpGt -> VBool <$> nixCompare forceFn right left-  OpGte -> do-    gt <- nixCompare forceFn right left-    eq <- nixEqual forceFn left right-    pure (VBool (gt || eq))+  OpGte -> VBool . not <$> nixCompare forceFn left right   OpConcat -> evalConcat left right   OpUpdate -> evalUpdate left right   -- Short-circuit ops must be handled by the caller@@ -68,34 +71,64 @@   VBool b -> pure (VBool (not b))   other -> throwEvalError ("cannot apply ! to " <> typeName other) evalUnary OpNegate val = case val of-  VInt n -> pure (VInt (negate n))+  -- negate minBound has no Int64 representation; upstream desugars unary+  -- minus to 0 - n, so it reports the same checked-subtraction overflow.+  VInt n -> either throwEvalError (pure . VInt) (checkedSub 0 n)   VFloat n -> pure (VFloat (negate n))   other -> throwEvalError ("cannot negate " <> typeName other) --- | Addition: int/float arithmetic, string concatenation, path append.+-- | Addition: int/float arithmetic and string concatenation.  Path+-- operands never reach here - @Nix.Eval.evalAddWithCoercion@ handles them+-- (store-copy coercion for @string + path@, context checks for+-- @path + string@) before delegating. evalAdd :: (MonadEval m) => NixValue -> NixValue -> m NixValue-evalAdd (VInt a) (VInt b) = pure (VInt (a + b))+evalAdd (VInt a) (VInt b) = either throwEvalError (pure . VInt) (checkedAdd a b) evalAdd (VInt a) (VFloat b) = pure (VFloat (fromIntegral a + b)) evalAdd (VFloat a) (VInt b) = pure (VFloat (a + fromIntegral b)) evalAdd (VFloat a) (VFloat b) = pure (VFloat (a + b)) evalAdd (VStr a ctxA) (VStr b ctxB) = pure (VStr (a <> b) (ctxA <> ctxB))-evalAdd (VPath a) (VStr b _) = pure (VPath (a <> b))-evalAdd (VPath a) (VPath b) = pure (VPath (a <> b))-evalAdd (VStr a ctxA) (VPath b) = pure (VStr (a <> b) ctxA) evalAdd left right =   throwEvalError ("cannot add " <> typeName left <> " and " <> typeName right) --- | Generic arithmetic for subtraction and multiplication.+-- | Checked Int64 arithmetic: integer overflow is an eval error (Nix+-- 2.24 semantics), never a two's-complement wrap.  Computed in Integer+-- and bounds-checked.+checkedIntOp :: Text -> (Integer -> Integer -> Integer) -> Int64 -> Int64 -> Either Text Int64+checkedIntOp verb op a b+  | wide < toInteger (minBound :: Int64) || wide > toInteger (maxBound :: Int64) =+      Left+        ( "integer overflow in "+            <> verb+            <> " "+            <> T.pack (show a)+            <> " and "+            <> T.pack (show b)+        )+  | otherwise = Right (fromInteger wide)+  where+    wide = op (toInteger a) (toInteger b)++checkedAdd :: Int64 -> Int64 -> Either Text Int64+checkedAdd = checkedIntOp "adding" (+)++checkedSub :: Int64 -> Int64 -> Either Text Int64+checkedSub = checkedIntOp "subtracting" (-)++checkedMul :: Int64 -> Int64 -> Either Text Int64+checkedMul = checkedIntOp "multiplying" (*)++-- | Generic arithmetic for subtraction and multiplication.  The integer+-- side is a checked op ('checkedSub' / 'checkedMul'). evalArith ::   (MonadEval m) =>   Text ->-  (Int64 -> Int64 -> Int64) ->+  (Int64 -> Int64 -> Either Text Int64) ->   (Double -> Double -> Double) ->   NixValue ->   NixValue ->   m NixValue-evalArith name intOp floatOp left right = case (left, right) of-  (VInt a, VInt b) -> pure (VInt (intOp a b))+evalArith name checkedOp floatOp left right = case (left, right) of+  (VInt a, VInt b) -> either throwEvalError (pure . VInt) (checkedOp a b)   (VInt a, VFloat b) -> pure (VFloat (floatOp (fromIntegral a) b))   (VFloat a, VInt b) -> pure (VFloat (floatOp a (fromIntegral b)))   (VFloat a, VFloat b) -> pure (VFloat (floatOp a b))@@ -115,9 +148,10 @@ evalDiv left right = case (left, right) of   (VInt _, VInt 0) -> throwEvalError "division by zero"   (VInt a, VInt b)-    -- quot minBound (-1) throws arithmetic overflow in Haskell;-    -- C++ Nix wraps silently (undefined behavior, wraps to minBound).-    | a == minBound && b == -1 -> pure (VInt minBound)+    -- The one overflowing division: |minBound| has no representation.+    | a == minBound && b == -1 ->+        throwEvalError+          ("integer overflow in dividing " <> T.pack (show a) <> " and " <> T.pack (show b))     | otherwise -> pure (VInt (quot a b))   (VInt a, VFloat b)     | b == 0 -> throwEvalError "division by zero"@@ -160,9 +194,12 @@         <> typeName right     ) --- | Lexicographic comparison of two thunk lists for the @<@ operator: the--- first differing element decides; a proper prefix is less than the longer--- list.  Mirrors 'listEqual'.+-- | Lexicographic comparison of two thunk lists for the @<@ operator:+-- the first NON-EQUAL element pair decides via @<@ on that pair, as+-- upstream does (eqValues, then CompareValues on the first difference).+-- An unequal pair where @<@ holds in neither direction (NaN) therefore+-- decides False rather than being skipped as equal.  A proper prefix is+-- less than the longer list.  Mirrors 'listEqual'. listCompare :: (MonadEval m) => Force m -> [Thunk] -> [Thunk] -> m Bool listCompare _ [] [] = pure False listCompare _ [] (_ : _) = pure True@@ -172,12 +209,10 @@   | otherwise = do       va <- forceFn a       vb <- forceFn b-      ltAB <- nixCompare forceFn va vb-      if ltAB-        then pure True-        else do-          ltBA <- nixCompare forceFn vb va-          if ltBA then pure False else listCompare forceFn as bs+      equal <- nixEqual forceFn va vb+      if equal+        then listCompare forceFn as bs+        else nixCompare forceFn va vb  -- | Deep structural equality.  Forces thunks inside lists and -- attribute sets as needed.@@ -194,14 +229,58 @@ nixEqual forceFn (VList clA) (VList clB)   | clistLen clA /= clistLen clB = pure False   | otherwise = listEqual forceFn (map Thunk (clistThunks clA)) (map Thunk (clistThunks clB))-nixEqual forceFn (VAttrs as) (VAttrs bs)-  | attrSetKeys as /= attrSetKeys bs = pure False-  | otherwise = do-      let pairs = zip (attrSetElems as) (attrSetElems bs)-      results <- mapM (thunkPairEqual forceFn) pairs-      pure (and results)+nixEqual forceFn (VAttrs as) (VAttrs bs) = do+  drvOutPaths <- derivationOutPathPair forceFn as bs+  case drvOutPaths of+    Just outPathPair -> thunkPairEqual forceFn outPathPair+    Nothing+      | attrSetKeys as /= attrSetKeys bs -> pure False+      | otherwise ->+          -- Short-circuit on the first mismatch: later pairs are never+          -- forced, so errors past the deciding pair cannot surface+          -- (upstream stops comparing there too).+          allPairsEqual (zip (attrSetElems as) (attrSetElems bs))+  where+    allPairsEqual [] = pure True+    allPairsEqual (pair : rest) = do+      eq <- thunkPairEqual forceFn pair+      if eq then allPairsEqual rest else pure False nixEqual _ _ _ = pure False +-- | When both attr sets are derivations (a @type@ attr forcing to the string+-- @"derivation"@) and both carry an @outPath@, the pair of outPath thunks.+--+-- C++ Nix's eqValues compares derivations by outPath ALONE, before any+-- key-set comparison: two mkDerivation results with the same outPath are+-- equal even though their lambda attrs (override, overrideAttrs) never are,+-- and distinct self-referential finalAttrs packages would otherwise recurse+-- forever.  If either set lacks an outPath, fall through to deep comparison,+-- exactly as upstream does.+derivationOutPathPair :: (MonadEval m) => Force m -> AttrSet -> AttrSet -> m (Maybe (Thunk, Thunk))+derivationOutPathPair forceFn as bs = do+  leftIsDrv <- isDerivationSet forceFn as+  if not leftIsDrv+    then pure Nothing+    else do+      rightIsDrv <- isDerivationSet forceFn bs+      pure $+        if rightIsDrv+          then (,) <$> attrSetLookup "outPath" as <*> attrSetLookup "outPath" bs+          else Nothing++-- | Does the set carry @type = "derivation"@?  Forces only the @type@ attr+-- (as upstream's isDerivation does); a non-string type is simply not a+-- derivation, not an error.+isDerivationSet :: (MonadEval m) => Force m -> AttrSet -> m Bool+isDerivationSet forceFn attrs =+  case attrSetLookup "type" attrs of+    Nothing -> pure False+    Just typeThunk -> do+      typeVal <- forceFn typeThunk+      case typeVal of+        VStr tag _ -> pure (tag == "derivation")+        _ -> pure False+ -- | Pairwise equality of two thunk lists (for list comparison). listEqual :: (MonadEval m) => Force m -> [Thunk] -> [Thunk] -> m Bool listEqual _ [] [] = pure True@@ -237,10 +316,6 @@  -- | Attribute set merge (//).  Right-biased: keys in the right -- operand shadow keys in the left.------ When one side is a 'LazyAttrs', avoid full materialization by--- merging binding recipes directly.  This is critical for nixpkgs--- where the overlay system does @big_set // small_set@. evalUpdate :: (MonadEval m) => NixValue -> NixValue -> m NixValue evalUpdate (VAttrs as) (VAttrs bs) = pure (VAttrs (mergeAttrSets as bs)) evalUpdate left right =
src/Nix/Eval/StringInterp.hs view
@@ -6,16 +6,22 @@ -- import cycle with @Nix.Eval@. module Nix.Eval.StringInterp   ( stripIndentedChunks,+    CoercePath,     coerceToString,     formatNixFloat,+    formatJsonFloat,+    formatXmlFloat,   ) where -import Data.List (foldl')+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BC import Data.Text (Text) import qualified Data.Text as T+import qualified Data.Text.Encoding as TE import Nix.Eval.Types (MonadEval (..), NixValue (..), StringContext, Thunk, attrSetLookup, emptyContext, typeName)-import Numeric (showFFloat)+import Numeric (floatToDigits, showFFloat)  -- | Force a thunk to a value. type Force m = Thunk -> m NixValue@@ -23,32 +29,45 @@ -- | Apply a function value to an argument value. type Apply m = NixValue -> NixValue -> m NixValue +-- | What the caller does with a coerced path: the verbatim text for+-- non-copying coercions (@builtins.toString@), the source store path+-- with context for copy-to-store coercions (interpolation, derivation+-- fields, @builtins.toJSON@).  A parameter for the same reason+-- 'Force' and 'Apply' are: the store copy lives above this module,+-- and the attrset case's recursion must carry the caller's choice -+-- an @outPath@ reached through an attrset coerces exactly as the same+-- path written directly would.+type CoercePath m = Text -> m (ByteString, StringContext)+ -- | Strip the common indentation from already-evaluated indented-string chunks.--- Each chunk is @(isLiteral, text, context)@.  Indentation is computed and+-- Each chunk is @(isLiteral, bytes, context)@.  Indentation is computed and -- stripped from the LITERAL chunks only - interpolated chunks are opaque content -- - the single leading newline is dropped, and the trailing newline is kept. -- This matches C++ Nix, which strips at the string-part level (so a multi-line--- interpolated value cannot drag the common indent down).-stripIndentedChunks :: [(Bool, Text, StringContext)] -> (Text, StringContext)+-- interpolated value cannot drag the common indent down).  The scan is+-- byte-level via the Char8 view: it only ever compares against space, tab,+-- and newline, which are single bytes in UTF-8 and never occur inside a+-- multi-byte sequence, so multi-byte content passes through untouched.+stripIndentedChunks :: [(Bool, ByteString, StringContext)] -> (ByteString, StringContext) stripIndentedChunks chunks =   let stripped = dropLeadingNL (chunksStrip (chunksMinIndent chunks) chunks)-   in (T.concat (map snd stripped), mconcat [c | (_, _, c) <- chunks])+   in (BS.concat (map snd stripped), mconcat [c | (_, _, c) <- chunks])   where     dropLeadingNL ((True, t) : rest) =-      (True, case T.uncons t of { Just ('\n', r) -> r; _ -> t }) : rest+      (True, case BC.uncons t of { Just ('\n', r) -> r; _ -> t }) : rest     dropLeadingNL other = other  -- | Common indentation across the LITERAL chunks.  An interpolation at line -- start fixes that line's indent at the preceding literal whitespace and counts -- as content; whitespace-only lines do not contribute.-chunksMinIndent :: [(Bool, Text, StringContext)] -> Int+chunksMinIndent :: [(Bool, ByteString, StringContext)] -> Int chunksMinIndent = result . foldl' stepChunk (True, 0, Nothing)   where     result (_, _, Nothing) = 0     result (_, _, Just m) = m     stepChunk (atStart, cur, mi) (isLit, t, _)       | not isLit = if atStart then (False, cur, bump mi cur) else (False, cur, mi)-      | otherwise = T.foldl' stepChar (atStart, cur, mi) t+      | otherwise = BC.foldl' stepChar (atStart, cur, mi) t     stepChar (atStart, cur, mi) c       | atStart && (c == ' ' || c == '\t') = (True, cur + 1, mi)       | atStart && c == '\n' = (True, 0, mi)@@ -60,14 +79,14 @@  -- | Strip @n@ columns of leading indentation from each line of the literal -- chunks; interpolated chunks are emitted verbatim and reset the line position.-chunksStrip :: Int -> [(Bool, Text, StringContext)] -> [(Bool, Text)]+chunksStrip :: Int -> [(Bool, ByteString, StringContext)] -> [(Bool, ByteString)] chunksStrip n = go True 0   where     go _ _ [] = []     go _ _ ((False, t, _) : rest) = (False, t) : go False 0 rest     go atStart dropped ((True, t, _) : rest) =-      let (acc, atStart', dropped') = T.foldl' stepC ([], atStart, dropped) t-       in (True, T.pack (reverse acc)) : go atStart' dropped' rest+      let (acc, advancedStart, advancedDrop) = BC.foldl' stepC ([], atStart, dropped) t+       in (True, BC.pack (reverse acc)) : go advancedStart advancedDrop rest     stepC (acc, atStart, dropped) c       | atStart && (c == ' ' || c == '\t') =           if dropped < n then (acc, True, dropped + 1) else (c : acc, True, dropped + 1)@@ -76,7 +95,7 @@       | c == '\n' = ('\n' : acc, True, 0)       | otherwise = (c : acc, False, dropped) --- | Coerce a Nix value to a string.+-- | Coerce a Nix value to a (byte) string. -- -- The @coerceMore@ flag mirrors C++ Nix's @coerceToString@ argument: when -- 'True' (e.g. @builtins.toString@, derivation-env values) ints, floats, bools@@ -84,33 +103,45 @@ -- @builtins.concatStringsSep@) those are type errors, matching C++ Nix. -- Strings, paths, and attribute sets with @__toString@/@outPath@ coerce in -- both modes; lists and bare functions are always errors.-coerceToString :: (MonadEval m) => Bool -> Force m -> Apply m -> NixValue -> m (Text, StringContext)-coerceToString _ _ _ (VStr s ctx) = pure (s, ctx)-coerceToString _ _ _ (VPath p) = pure (p, emptyContext)-coerceToString True _ _ (VInt n) = pure (T.pack (show n), emptyContext)-coerceToString True _ _ (VFloat n) = pure (formatNixFloat n, emptyContext)-coerceToString True _ _ VNull = pure ("", emptyContext)-coerceToString True _ _ (VBool True) = pure ("1", emptyContext)-coerceToString True _ _ (VBool False) = pure ("", emptyContext)+coerceToString :: (MonadEval m) => Bool -> Force m -> Apply m -> CoercePath m -> NixValue -> m (ByteString, StringContext)+coerceToString _ _ _ _ (VStr s ctx) = pure (s, ctx)+coerceToString _ _ _ coercePathFn (VPath p) = coercePathFn p+coerceToString True _ _ _ (VInt n) = pure (BC.pack (show n), emptyContext)+coerceToString True _ _ _ (VFloat n) = pure (TE.encodeUtf8 (formatNixFloatFixed n), emptyContext)+coerceToString True _ _ _ VNull = pure ("", emptyContext)+coerceToString True _ _ _ (VBool True) = pure ("1", emptyContext)+coerceToString True _ _ _ (VBool False) = pure ("", emptyContext) -- Attribute sets: try __toString first, then outPath (both modes).-coerceToString coerceMore forceFn applyFn (VAttrs attrs) =+-- The recursion carries the caller's path coercion, so a path-valued+-- @outPath@ lands on the caller's path case, not a fixed one.+coerceToString coerceMore forceFn applyFn coercePathFn (VAttrs attrs) =   case attrSetLookup "__toString" attrs of     Just toStrThunk -> do       toStrFn <- forceFn toStrThunk       result <- applyFn toStrFn (VAttrs attrs)-      coerceToString coerceMore forceFn applyFn result+      coerceToString coerceMore forceFn applyFn coercePathFn result     Nothing -> case attrSetLookup "outPath" attrs of       Just outPathThunk -> do         outPathVal <- forceFn outPathThunk-        coerceToString coerceMore forceFn applyFn outPathVal+        coerceToString coerceMore forceFn applyFn coercePathFn outPathVal       Nothing ->         throwEvalError "cannot coerce a set to a string (missing __toString or outPath)"-coerceToString _ _ _ other =+coerceToString _ _ _ _ other =   throwEvalError ("cannot coerce " <> typeName other <> " to a string") --- | Format a float the way C++ Nix does: 6 fixed decimal places--- (@std::to_string@), then strip trailing zeros and unnecessary--- decimal point.  E.g. @1.0@ becomes @"1"@, @3.14@ becomes @"3.14"@.+-- | Format a float the way C++ Nix's coerceToString does -+-- @std::to_string@, i.e. FIXED 6 decimal places with no trimming:+-- @toString 1.5@ is @"1.500000"@.  Derivation env values and+-- @builtins.toString@ both see this form, so it is hash-relevant.+formatNixFloatFixed :: Double -> Text+formatNixFloatFixed n+  | isNaN n = "nan"+  | isInfinite n = if n > 0 then "inf" else "-inf"+  | otherwise = T.pack (showFFloat (Just 6) n "")++-- | Format a float for value display and JSON: 6 fixed decimal places,+-- then strip trailing zeros and an unnecessary decimal point.  E.g.+-- @1.0@ becomes @"1"@, @3.14@ becomes @"3.14"@. formatNixFloat :: Double -> Text formatNixFloat n   | isNaN n = "nan"@@ -124,3 +155,127 @@       | otherwise = s     dropDot ('.' : rest) = rest     dropDot xs = xs++-- | Format a finite float exactly as the JSON serializer upstream links+-- (nlohmann @to_chars@): shortest round-trip digits, laid out as plain+-- decimal only while the decimal point lands within positions+-- 'jsonMinPointPos'..'jsonMaxPointPos', a @.0@ suffix on integral values,+-- and otherwise @d.ddde+XX@ with a signed exponent of at least two digits.+-- Zero is @0.0@ (sign preserved).  Digits come from 'floatToDigits', which+-- is always shortest; nlohmann's grisu2 can emit a longer-than-shortest+-- form for rare values, an accepted divergence.  Non-finite input is the+-- caller's concern (JSON spells it @null@).+formatJsonFloat :: Double -> Text+formatJsonFloat d+  | isNegativeZero d = "-0.0"+  | d == 0 = "0.0"+  | d < 0 = "-" <> formatJsonFloat (negate d)+  | otherwise =+      let (digitList, pointPos) = floatToDigits 10 d+          digits = concatMap show digitList+       in T.pack (jsonFloatLayout digits (length digits) pointPos)++-- | Positional layout of shortest digits with the decimal point at+-- @pointPos@, replicating nlohmann's @format_buffer@ branch by branch.+jsonFloatLayout :: String -> Int -> Int -> String+jsonFloatLayout digits digitCount pointPos+  -- Integral value with the point in plain range: digits, zeros, ".0".+  | digitCount <= pointPos && pointPos <= jsonMaxPointPos =+      digits <> replicate (pointPos - digitCount) '0' <> ".0"+  -- Point falls inside the digit run.+  | 0 < pointPos && pointPos <= jsonMaxPointPos =+      take pointPos digits <> "." <> drop pointPos digits+  -- Small magnitude: leading "0." and padding zeros.+  | jsonMinPointPos < pointPos && pointPos <= 0 =+      "0." <> replicate (negate pointPos) '0' <> digits+  -- Scientific notation.+  | otherwise = mantissa <> "e" <> signedExponent (pointPos - 1)+  where+    mantissa = case digits of+      [single] -> [single]+      lead : rest -> lead : '.' : rest+      [] -> "0" -- unreachable: a positive double yields at least one digit++-- | nlohmann @format_buffer@ bounds (@kMaxExp@ = double's @digits10@,+-- @kMinExp@): plain decimal only while the decimal point position is in+-- (-4, 15]; everything else is scientific.+jsonMaxPointPos :: Int+jsonMaxPointPos = 15++-- | Lower point-position bound, exclusive.  See 'jsonMaxPointPos'.+jsonMinPointPos :: Int+jsonMinPointPos = -4++-- | Exponent suffix shared by the JSON and XML float layouts: sign always+-- present, magnitude zero-padded to at least two digits (@+05@, @-21@).+signedExponent :: Int -> String+signedExponent e+  | e < 0 = '-' : padded (negate e)+  | otherwise = '+' : padded e+  where+    padded n+      | n < 10 = '0' : show n+      | otherwise = show n++-- | Format a float as upstream @toXML@ renders one - C++ @operator<<@ on a+-- default-format ostream: 6 significant digits, trailing zeros stripped,+-- plain decimal only for decimal exponents in [-4, 5], otherwise+-- @d.ddde+XX@ with a signed exponent of at least two digits.  Rounding is+-- half-even on the exact binary value, matching a correctly-rounded printf.+formatXmlFloat :: Double -> Text+formatXmlFloat d+  | isNaN d = "nan"+  | isInfinite d = if d > 0 then "inf" else "-inf"+  | d == 0 = if isNegativeZero d then "-0" else "0"+  | d < 0 = "-" <> formatXmlFloat (negate d)+  | otherwise = T.pack (xmlFloatPositive d)++-- | 6-significant-digit @%g@ layout of a positive finite double.+xmlFloatPositive :: Double -> String+xmlFloatPositive d =+  let exact = toRational d+      roughExp = decimalExponentOf exact+      rounded = round (exact * 10 ^^ (xmlSigDigits - 1 - roughExp)) :: Integer+      -- Rounding can carry into a new leading digit (999999.9 -> 1000000).+      (sigDigits, pointExp) =+        if rounded >= 10 ^ xmlSigDigits+          then (rounded `div` 10, roughExp + 1)+          else (rounded, roughExp)+      digits = show sigDigits+   in if xmlMinFixedExp <= pointExp && pointExp < xmlSigDigits+        then fixedForm digits pointExp+        else sciForm digits pointExp+  where+    fixedForm digits pointExp+      | pointExp >= 0 =+          let (intPart, fracPart) = splitAt (pointExp + 1) digits+           in joinFraction intPart (stripTrailingZeros fracPart)+      | otherwise =+          joinFraction "0" (stripTrailingZeros (replicate (negate pointExp - 1) '0' <> digits))+    sciForm digits pointExp =+      joinFraction (take 1 digits) (stripTrailingZeros (drop 1 digits))+        <> "e"+        <> signedExponent pointExp+    joinFraction intPart fracPart+      | null fracPart = intPart+      | otherwise = intPart <> "." <> fracPart+    stripTrailingZeros = reverse . dropWhile (== '0') . reverse++-- | @%g@ default precision: 6 significant digits.+xmlSigDigits :: Int+xmlSigDigits = 6++-- | @%g@ switches to scientific below a decimal exponent of -4.+xmlMinFixedExp :: Int+xmlMinFixedExp = -4++-- | The decimal exponent @e@ of a positive rational: the unique @e@ with+-- @10^e <= r < 10^(e+1)@.  A float log gives the estimate; the exact+-- comparisons correct it, since the log is off by one near powers of ten.+decimalExponentOf :: Rational -> Int+decimalExponentOf r = correct (floor (logBase 10 (fromRational r :: Double)))+  where+    correct e+      | 10 ^^ e > r = correct (e - 1)+      | 10 ^^ (e + 1) <= r = correct (e + 1)+      | otherwise = e
src/Nix/Eval/Symbol.hs view
@@ -20,7 +20,9 @@      -- * Core operations     symbolIntern,+    symbolInternBytes,     symbolText,+    symbolBytes,     symbolLen,      -- * Diagnostics@@ -28,6 +30,9 @@   ) where +import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Unsafe as BSU import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Foreign as TF@@ -85,16 +90,32 @@  -- | Intern a 'Text' value, returning its 'Symbol'. -- If the string was already interned, returns the existing symbol.--- This is the canonical entry point for Text to C conversion.+-- This is the canonical entry point for Text to C conversion; the table+-- stores the UTF-8 bytes, so a 'Text' and its 'Data.Text.Encoding.encodeUtf8'+-- image intern to the SAME symbol. symbolIntern :: Text -> IO Symbol symbolIntern txt =   TF.withCStringLen txt $ \(ptr, len) -> do     sid <- c_nn_symbol_intern ptr (fromIntegral len)     pure (Symbol sid) +-- | Intern raw bytes, returning their 'Symbol'.  The C table is+-- length-prefixed bytes with no encoding assumption, so arbitrary+-- (even invalid-UTF-8) byte strings intern losslessly.  The canonical+-- entry point for string VALUES, whose payload is a byte string.+-- 'BSU.unsafeUseAsCStringLen' is safe here: @nn_symbol_intern@ copies+-- the bytes into its own arena and never retains the pointer.+symbolInternBytes :: ByteString -> IO Symbol+symbolInternBytes bs =+  BSU.unsafeUseAsCStringLen bs $ \(ptr, len) -> do+    sid <- c_nn_symbol_intern ptr (fromIntegral len)+    pure (Symbol sid)+ -- | Retrieve the text of an interned symbol. -- Returns the original string.  The result is safe to use - it copies--- from the C arena into a fresh 'Text'.+-- from the C arena into a fresh 'Text'.  Only valid for symbols interned+-- from 'Text' (attr names, paths, output names); a symbol holding a raw+-- byte-string payload must be read with 'symbolBytes' instead. symbolText :: Symbol -> Text symbolText (Symbol sid)   | sid == 0 = T.empty@@ -105,6 +126,19 @@         else do           len <- c_nn_symbol_len sid           TF.peekCStringLen (ptr, fromIntegral len)++-- | Retrieve the raw bytes of an interned symbol - the exact bytes that+-- were interned, with no decoding.  The read side of 'symbolInternBytes'.+symbolBytes :: Symbol -> ByteString+symbolBytes (Symbol sid)+  | sid == 0 = BS.empty+  | otherwise = unsafePerformIO $ do+      ptr <- c_nn_symbol_text sid+      if ptr == nullPtr+        then pure BS.empty+        else do+          len <- c_nn_symbol_len sid+          BS.packCStringLen (ptr, fromIntegral len)  -- | Byte length of a symbol's string. symbolLen :: Symbol -> Int
src/Nix/Eval/Types.hs view
@@ -43,6 +43,8 @@     StringContext (..),     emptyContext,     mkStr,+    mkStrBytes,+    bytesToTextLossy,     marshalStringContext,     unmarshalStringContext, @@ -57,6 +59,7 @@     lookupWithScopes,     withScopesForCapture,     envWithScopesRaw,+    checkedCPtr,     newCEnv,     newMinimalEnv, @@ -103,36 +106,43 @@     MonadEval (..),     PureEval,     runPureEval,+    storePathOrThrow,   ) where  import Control.Monad (forM_) import Data.Bits (shiftL, (.&.), (.|.)) import Data.ByteString (ByteString)+import qualified Data.ByteString as BS import Data.Int (Int64) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map import Data.Set (Set) import qualified Data.Set as Set import Data.Text (Text)-import Data.Word (Word16, Word32, Word64, Word8)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import Data.Text.Encoding.Error (lenientDecode)+import Data.Word (Word32, Word64, Word8) import Foreign.Ptr (Ptr, castPtr, nullPtr, ptrToWordPtr) import Foreign.StablePtr (castPtrToStablePtr, castStablePtrToPtr, deRefStablePtr, newStablePtr) import Foreign.Storable (peekElemOff, pokeElemOff) import GHC.Float (castWord64ToDouble) import Nix.Derivation (Derivation) import Nix.Eval.CAttrSet (CAttrSet, cattrsetFreeze, cattrsetGetKey, cattrsetGetValue, cattrsetIndex, cattrsetInsert, cattrsetKeys, cattrsetLookup, cattrsetNew, cattrsetRemoveKeys, cattrsetSetValue, cattrsetSize)-import Nix.Eval.CBytecode (cbcArg1, cbcArg2, cbcOpcode, cbcShortArg)+import Nix.Eval.CBytecode (cbcArg1, cbcArg2, cbcCountedPayload, cbcData, cbcOpcode, cbcShortArg, pattern OpList, pattern OpLitBool, pattern OpLitFloat, pattern OpLitInt, pattern OpLitNull, pattern OpLitPath, pattern OpLitUri, pattern OpResolvedVar, pattern OpStr) import Nix.Eval.CCtxStr (CCtxStrPtr, cctxstrCtxCount, cctxstrElemHash, cctxstrElemName, cctxstrElemOutput, cctxstrElemTag, cctxstrNew, cctxstrSetAllOutputs, cctxstrSetDrvOutput, cctxstrSetPlain, cctxstrText) import Nix.Eval.CEnv (NnEnv, cenvAllocSlots, cenvAllocWithScopes, cenvEmpty, cenvFromSlots, cenvLazyScope, cenvLookupResolved, cenvNew, cenvNewMinimal, cenvParent, cenvPushWith, cenvRootScope, cenvSlotCount, cenvWithCount, cenvWithScopes) import Nix.Eval.CLambda (clambdaAllowExtra, clambdaBody, clambdaEntryDefault, clambdaEntryHasDefault, clambdaEntryName, clambdaEnv, clambdaFormalCount, clambdaFormalsType, clambdaNameSym, clambdaNew, clambdaSetEntry) import Nix.Eval.CList (CList (..), clistFromThunks, clistLen, clistThunks, emptyCList) import Nix.Eval.CThunk (CThunkPtr, cthunkGetAttrs, cthunkGetBcIdx, cthunkGetBool, cthunkGetCtxStr, cthunkGetFloat, cthunkGetInt, cthunkGetList, cthunkGetPath, cthunkGetStr, cthunkNewBc, cthunkNewComputed, cthunkNewComputedAttrs, cthunkNewComputedBool, cthunkNewComputedCtxStr, cthunkNewComputedFloat, cthunkNewComputedInt, cthunkNewComputedLambda, cthunkNewComputedList, cthunkNewComputedNull, cthunkNewComputedPath, cthunkNewComputedStr, cthunkPayload, cthunkState, cthunkValueTag)+import Nix.Eval.CanonPath (canonPathValue) import Nix.Eval.Compile (compileExpr, compileFormalsToEval) import Nix.Eval.EvalFormals (EvalFormal (..), EvalFormals (..))-import Nix.Eval.Symbol (Symbol (..), symbolIntern, symbolText)+import Nix.Eval.Symbol (Symbol (..), symbolBytes, symbolIntern, symbolInternBytes, symbolText) import Nix.Expr.Types (CaptureInfo (..), Expr (..), NixAtom (..))-import Nix.Store.Path (StorePath (..))+import Nix.Store.Path (StorePath, StorePathNameError, storePathNameErrorText)+import Nix.Store.Path.Internal (StorePath (StorePath)) import System.IO.Unsafe (unsafePerformIO) import qualified Text.Regex.TDFA as RE @@ -140,11 +150,13 @@ -- Compiled regex -- --------------------------------------------------------------------------- --- | Compiled regex with Eq/Show based on source pattern text.+-- | Compiled regex with Eq/Show based on the source pattern bytes. -- Carries the pre-compiled 'RE.Regex' alongside the original pattern -- so that partial application of @builtins.match@ / @builtins.split@--- avoids recompiling the same pattern on every invocation.-data CompiledRegex = CompiledRegex !Text RE.Regex+-- avoids recompiling the same pattern on every invocation.  The pattern+-- is the raw byte string the regex was compiled from: upstream regexes+-- run over bytes, so byte-identical patterns are the sharing key.+data CompiledRegex = CompiledRegex !ByteString RE.Regex  instance Eq CompiledRegex where   CompiledRegex a _ == CompiledRegex b _ = a == b@@ -175,10 +187,24 @@ emptyContext :: StringContext emptyContext = mempty --- | Smart constructor for context-free strings.+-- | Smart constructor for context-free strings from 'Text'.+-- Encodes to the UTF-8 bytes that ARE the string's value: a Nix string+-- is a byte string, and every Text-producing site goes through here. mkStr :: Text -> NixValue-mkStr t = VStr t emptyContext+mkStr t = VStr (TE.encodeUtf8 t) emptyContext +-- | Smart constructor for context-free strings from raw bytes+-- (@builtins.readFile@, substring slices - anything already byte-shaped).+mkStrBytes :: ByteString -> NixValue+mkStrBytes b = VStr b emptyContext++-- | Decode string-value bytes for DISPLAY ONLY (trace output, error+-- messages, value pretty-printing): invalid UTF-8 becomes U+FFFD.+-- Never feed the result back into a value, a hash, or an attr name -+-- identity-bearing boundaries decode strictly and error instead.+bytesToTextLossy :: ByteString -> Text+bytesToTextLossy = TE.decodeUtf8With lenientDecode+ -- --------------------------------------------------------------------------- -- Thunks -- ---------------------------------------------------------------------------@@ -257,7 +283,7 @@           ValueFloat -> VFloat <$> cthunkGetFloat ptr           ValueBool -> (\b -> VBool (b /= 0)) <$> cthunkGetBool ptr           ValueNull -> pure VNull-          ValueStr -> (\sym -> VStr (symbolText (Symbol sym)) emptyContext) <$> cthunkGetStr ptr+          ValueStr -> (\sym -> VStr (symbolBytes (Symbol sym)) emptyContext) <$> cthunkGetStr ptr           ValuePath -> VPath . symbolText . Symbol <$> cthunkGetPath ptr           ValueList -> do             listPtr <- cthunkGetList ptr@@ -283,8 +309,13 @@     VBool !Bool   | -- | The null value.     VNull-  | -- | String with dependency context.-    VStr !Text !StringContext+  | -- | String with dependency context.  The payload is a BYTE string,+    -- as upstream: @stringLength@/@substring@ index bytes, regexes match+    -- bytes, and @readFile@ carries raw file bytes (so invalid UTF-8 is+    -- representable).  Text enters via 'mkStr' (UTF-8 encode) and leaves+    -- through explicit decode at boundaries (attr names strictly, display+    -- lossily via 'bytesToTextLossy').+    VStr !ByteString !StringContext   | -- | Path.     VPath !Text   | -- | List of thunks (lazy elements), backed by C array.@@ -485,28 +516,39 @@ emptyEnv :: Env emptyEnv = Env (unsafePerformIO cenvEmpty) +-- | Fail loudly when a C allocator returns NULL (arena or malloc+-- exhaustion, or a rejected over-large size).  The C side signals+-- failure deliberately; deferring the NULL to the next dereference+-- would be undefined behavior in a release build, so convert it into+-- a clean Haskell exception here.+checkedCPtr :: String -> Ptr a -> Ptr a+checkedCPtr site ptr+  | ptr == nullPtr = error (site ++ ": C allocation failed")+  | otherwise = ptr+ -- | General C-backed env constructor. -- Takes Haskell-level types; converts Maybe to nullPtr internally. {-# NOINLINE newCEnv #-}-newCEnv :: Ptr CThunkPtr -> Int -> Maybe AttrSet -> Maybe Env -> Ptr (Ptr ()) -> Word16 -> Env+newCEnv :: Ptr CThunkPtr -> Int -> Maybe AttrSet -> Maybe Env -> Ptr (Ptr ()) -> Word32 -> Env newCEnv slots slotCount lazyScope parent withs withCount =   Env-    ( unsafePerformIO-        ( cenvNew-            slots-            (fromIntegral slotCount)-            (case lazyScope of Nothing -> nullPtr; Just (AttrSet cset) -> castPtr cset)-            (case parent of Nothing -> nullPtr; Just (Env p) -> p)-            withs-            withCount-        )+    ( checkedCPtr "newCEnv" $+        unsafePerformIO+          ( cenvNew+              slots+              (fromIntegral slotCount)+              (case lazyScope of Nothing -> nullPtr; Just (AttrSet cset) -> castPtr cset)+              (case parent of Nothing -> nullPtr; Just (Env p) -> p)+              withs+              withCount+          )     )  -- | Minimal env: slots only, no parent, no with-scopes, no lazy scope. {-# NOINLINE newMinimalEnv #-} newMinimalEnv :: Ptr CThunkPtr -> Int -> Env newMinimalEnv slots n =-  Env (unsafePerformIO (cenvNewMinimal slots (fromIntegral n)))+  Env (checkedCPtr "newMinimalEnv" (unsafePerformIO (cenvNewMinimal slots (fromIntegral n))))  -- | Look up a resolved variable by level and index.  Single C call: -- O(level) parent hops in C, then O(1) array read.@@ -542,10 +584,11 @@ -- | Walk with-scopes (C array) innermost to outermost. -- Skips tagged lazy entries (bit 0 set) - those are thunk pointers -- that can only be resolved by 'evalWithVarScopes' which has monadic--- 'force'.  This is a safety guard: currently unreachable because--- 'resolveVars' ensures EVar behind a with-scope always becomes--- EWithVar, but protects against future changes.-lookupWithScopesC :: Text -> Ptr (Ptr ()) -> Word16 -> Maybe Thunk+-- 'force'.  Defensive: an 'EVar' under a with is either a lexical+-- (barrier) binding or a static global, both found on the parent chain+-- before this fallback fires ('EVar' also blocks closure trimming, so+-- the chain is always intact) - but keep it for safety.+lookupWithScopesC :: Text -> Ptr (Ptr ()) -> Word32 -> Maybe Thunk lookupWithScopesC _ _ 0 = Nothing lookupWithScopesC name withArr count = unsafePerformIO $ go 0   where@@ -572,18 +615,18 @@ {-# NOINLINE envFromSlots #-} envFromSlots :: Ptr CThunkPtr -> Int -> Env -> Env envFromSlots slotsPtr slotCount (Env parentPtr) =-  Env (unsafePerformIO (cenvFromSlots slotsPtr (fromIntegral slotCount) parentPtr))+  Env (checkedCPtr "envFromSlots" (unsafePerformIO (cenvFromSlots slotsPtr (fromIntegral slotCount) parentPtr)))  -- | Push a with-scope onto the scope chain (innermost position). -- Allocates a new C env struct with extended with-scopes array. {-# NOINLINE pushWithScope #-} pushWithScope :: AttrSet -> Env -> Env pushWithScope (AttrSet cset) (Env envPtr) =-  Env (unsafePerformIO (cenvPushWith envPtr (castPtr cset)))+  Env (checkedCPtr "pushWithScope" (unsafePerformIO (cenvPushWith envPtr (castPtr cset))))  -- | Read with-scopes array pointer and count from a C env. {-# NOINLINE envWithScopesRaw #-}-envWithScopesRaw :: Env -> (Ptr (Ptr ()), Word16)+envWithScopesRaw :: Env -> (Ptr (Ptr ()), Word32) envWithScopesRaw (Env envPtr) = unsafePerformIO $ do   withs <- cenvWithScopes envPtr   count <- cenvWithCount envPtr@@ -594,7 +637,7 @@ -- outermost entry so 'EWithVar' can fall back to builtins without -- retaining the parent chain.  Returns C array + count. {-# NOINLINE withScopesForCapture #-}-withScopesForCapture :: Env -> (Ptr (Ptr ()), Word16)+withScopesForCapture :: Env -> (Ptr (Ptr ()), Word32) withScopesForCapture (Env envPtr) = unsafePerformIO $ do   rootPtr <- cenvRootScope envPtr   existingWiths <- cenvWithScopes envPtr@@ -602,13 +645,14 @@   if rootPtr == nullPtr     then pure (existingWiths, existingCount)     else do+      -- existingCount sits far below 2^32 (the C side caps the array+      -- allocation at UINT32_MAX bytes), so the increment cannot wrap.       let newCount = existingCount + 1-      arr <- cenvAllocWithScopes newCount-      -- Copy existing with-scopes+      arr <- checkedCPtr "withScopesForCapture" <$> cenvAllocWithScopes newCount       forM_ [0 .. fromIntegral existingCount - 1] $ \i -> do         val <- peekElemOff existingWiths i         pokeElemOff arr i val-      -- Append root scope at end (outermost)+      -- The root scope goes last: it is the outermost.       pokeElemOff arr (fromIntegral existingCount) rootPtr       pure (arr, newCount) @@ -678,28 +722,88 @@ -- pending thunk.  Everything else falls back to 'mkThunkBc'. cheapThunkBc :: Env -> Word32 -> Thunk cheapThunkBc env bcIdx =-  let opcode = unsafePerformIO (cbcOpcode bcIdx)-   in case opcode of-        10 {- RESOLVED_VAR -} ->-          let level = fromIntegral (unsafePerformIO (cbcArg1 bcIdx))-              idx = fromIntegral (unsafePerformIO (cbcArg2 bcIdx))-           in envLookupResolved level idx env-        0 {- LIT_INT -} ->-          let lo = unsafePerformIO (cbcArg1 bcIdx)-              hi = unsafePerformIO (cbcArg2 bcIdx)-              w64 = fromIntegral lo .|. (fromIntegral hi `shiftL` 32) :: Word64-           in Thunk (newComputedIntPtr (fromIntegral w64 :: Int64))-        1 {- LIT_FLOAT -} ->-          let lo = unsafePerformIO (cbcArg1 bcIdx)-              hi = unsafePerformIO (cbcArg2 bcIdx)-              w64 = fromIntegral lo .|. (fromIntegral hi `shiftL` 32) :: Word64-           in Thunk (newComputedFloatPtr (castWord64ToDouble w64))-        2 {- LIT_BOOL -} ->-          let flag = unsafePerformIO (cbcShortArg bcIdx)-           in Thunk (newComputedBoolPtr (if flag /= 0 then 1 else 0))-        3 {- LIT_NULL -} -> Thunk newComputedNullPtr-        _ -> mkThunkBc env bcIdx+  case unsafePerformIO (cbcOpcode bcIdx) of+    OpResolvedVar ->+      let level = fromIntegral (unsafePerformIO (cbcArg1 bcIdx))+          idx = fromIntegral (unsafePerformIO (cbcArg2 bcIdx))+       in envLookupResolved level idx env+    OpLitInt ->+      let lo = unsafePerformIO (cbcArg1 bcIdx)+          hi = unsafePerformIO (cbcArg2 bcIdx)+          w64 = fromIntegral lo .|. (fromIntegral hi `shiftL` 32) :: Word64+       in Thunk (newComputedIntPtr (fromIntegral w64 :: Int64))+    OpLitFloat ->+      let lo = unsafePerformIO (cbcArg1 bcIdx)+          hi = unsafePerformIO (cbcArg2 bcIdx)+          w64 = fromIntegral lo .|. (fromIntegral hi `shiftL` 32) :: Word64+       in Thunk (newComputedFloatPtr (castWord64ToDouble w64))+    OpLitBool ->+      let flag = unsafePerformIO (cbcShortArg bcIdx)+       in Thunk (newComputedBoolPtr (if flag /= 0 then 1 else 0))+    OpLitNull -> Thunk newComputedNullPtr+    OpLitUri ->+      let sym = unsafePerformIO (cbcArg1 bcIdx)+       in Thunk (newComputedStrPtr (symbolBytes (Symbol sym)))+    OpStr+      | Just bytes <- literalStrBytes bcIdx -> Thunk (newComputedStrPtr bytes)+    OpLitPath+      | Just resolved <- constantPathValue bcIdx -> Thunk (newComputedPathPtr resolved)+    OpList+      | emptyBcList bcIdx -> Thunk (newComputedThunkPtr (VList emptyCList))+    _ -> mkThunkBc env bcIdx +-- | The value of a path literal, when producing it is not work.+--+-- Parsing resolves every relative path literal against the file's directory+-- ('Nix.Expr.Resolve.resolveRelativePaths'), so what reaches here is already+-- absolute and only wants the lexical canonicalisation every path value+-- carries.  The exception is a home-relative literal, which parsing leaves+-- alone: expanding it reads @HOME@, and that is an effect this cannot run.+constantPathValue :: Word32 -> Maybe Text+constantPathValue bcIdx+  | T.isPrefixOf "~/" text = Nothing+  | otherwise = Just (canonPathValue text)+  where+    text = symbolText (Symbol (unsafePerformIO (cbcArg1 bcIdx)))++-- | Whether a list expression has no elements, and so is the same empty list+-- however many times it is written.  Upstream hands back one shared+-- @vEmptyList@ for this rather than a thunk.+emptyBcList :: Word32 -> Bool+emptyBcList bcIdx =+  let (count, _) = unsafePerformIO (cbcCountedPayload bcIdx =<< cbcArg1 bcIdx)+   in count == 0++-- | @NN_STRPART_LIT@ from @cbits/nn_bytecode.h@: a string part that is+-- literal text rather than an interpolation.+strPartLiteral :: Word32+strPartLiteral = 0++-- | The bytes of a string expression every part of which is literal, or+-- 'Nothing' when one is an interpolation and producing the value is work.+--+-- Upstream draws this line in the parser: a fully literal string folds into+-- an @ExprString@ carrying a ready-made value, and only a string with+-- interpolation survives as @ExprConcatStrings@.  The split is made here+-- instead by reading the parts back, because this compiler emits one opcode+-- for both shapes.+--+-- Deliberately not extended to @OpIndStr@, whose common indentation is+-- stripped during evaluation rather than in the parser, so its value is not+-- yet a constant at this point.  Upstream can treat one as a constant+-- because its parser has already done that stripping.+literalStrBytes :: Word32 -> Maybe ByteString+literalStrBytes bcIdx =+  let (count, dataOff) = unsafePerformIO (cbcCountedPayload bcIdx =<< cbcArg1 bcIdx)+   in BS.concat <$> partBytes count dataOff+  where+    partBytes 0 _ = Just []+    partBytes n off+      | unsafePerformIO (cbcData off) /= strPartLiteral = Nothing+      | otherwise =+          let sym = unsafePerformIO (cbcData (off + 1))+           in (symbolBytes (Symbol sym) :) <$> partBytes (n - 1 :: Int) (off + 2)+ -- | Allocate a fresh C arena thunk cell with bytecode. -- -- @NOINLINE@ prevents inlining so GHC can't see inside or CSE calls.@@ -803,10 +907,10 @@  -- | Wrap a context-free string in a pre-computed C thunk (interned symbol, no StablePtr). {-# NOINLINE newComputedStrPtr #-}-newComputedStrPtr :: Text -> CThunkPtr+newComputedStrPtr :: ByteString -> CThunkPtr newComputedStrPtr t =   unsafePerformIO $ t `seq` do-    Symbol sym <- symbolIntern t+    Symbol sym <- symbolInternBytes t     cthunkNewComputedStr sym  -- | Wrap a path in a pre-computed C thunk (interned symbol, no StablePtr).@@ -821,14 +925,14 @@ {-# NOINLINE newComputedListPtrC #-} newComputedListPtrC :: CList -> CThunkPtr newComputedListPtrC (CList clistPtr) =-  unsafePerformIO $-    clistPtr `seq`-      cthunkNewComputedList (castPtr clistPtr)+  unsafePerformIO+    $ clistPtr+    `seq` cthunkNewComputedList (castPtr clistPtr)  -- | Wrap a string with context in a pre-computed C thunk (no StablePtr).--- Interns the text and all StorePath fields as symbols, builds nn_ctxstr_t.+-- Interns the payload bytes and all StorePath fields as symbols, builds nn_ctxstr_t. {-# NOINLINE newComputedCtxStrPtr #-}-newComputedCtxStrPtr :: Text -> StringContext -> CThunkPtr+newComputedCtxStrPtr :: ByteString -> StringContext -> CThunkPtr newComputedCtxStrPtr t ctx =   unsafePerformIO $     t `seq`@@ -851,19 +955,19 @@ marshalLambda envPtr formals bodyBcIdx = case formals of   EFName name -> do     Symbol nameSym <- symbolIntern name-    lam <- clambdaNew envPtr bodyBcIdx 0 nameSym 0 0+    lam <- checkedCPtr "marshalLambda" <$> clambdaNew envPtr bodyBcIdx 0 nameSym 0 0     pure (castPtr lam)   EFSet entries allowExtra -> do-    let count = fromIntegral (length entries) :: Word16+    let count = fromIntegral (length entries) :: Word32         extraFlag = if allowExtra then 1 else 0 :: Word8-    lam <- clambdaNew envPtr bodyBcIdx 1 0 extraFlag count+    lam <- checkedCPtr "marshalLambda" <$> clambdaNew envPtr bodyBcIdx 1 0 extraFlag count     fillEntries lam 0 entries     pure (castPtr lam)   EFNamedSet name entries allowExtra -> do     Symbol nameSym <- symbolIntern name-    let count = fromIntegral (length entries) :: Word16+    let count = fromIntegral (length entries) :: Word32         extraFlag = if allowExtra then 1 else 0 :: Word8-    lam <- clambdaNew envPtr bodyBcIdx 2 nameSym extraFlag count+    lam <- checkedCPtr "marshalLambda" <$> clambdaNew envPtr bodyBcIdx 2 nameSym extraFlag count     fillEntries lam 0 entries     pure (castPtr lam)   where@@ -901,7 +1005,11 @@       pure (EFNamedSet (symbolText (Symbol nameSym)) entries (extra /= 0))   pure (VLambda (Env envPtr) formals bodyIdx)   where-    readLambdaEntries lamPtr count = mapM (readOneEntry lamPtr) [0 .. count - 1]+    -- Zero-formal set patterns ({}:, { ... }:) store count 0 and a NULL+    -- entries array; count - 1 would underflow Word32.+    readLambdaEntries lamPtr count+      | count == 0 = pure []+      | otherwise = mapM (readOneEntry lamPtr) [0 .. count - 1]     readOneEntry lamPtr idx = do       nameSym <- clambdaEntryName lamPtr idx       hasDef <- clambdaEntryHasDefault lamPtr idx@@ -909,13 +1017,15 @@       let defMaybe = if hasDef /= 0 then Just defIdx else Nothing       pure (EvalFormal (symbolText (Symbol nameSym)) defMaybe) --- | Marshal Text + StringContext to a C nn_ctxstr_t.-marshalStringContext :: Text -> StringContext -> IO CCtxStrPtr+-- | Marshal payload bytes + StringContext to a C nn_ctxstr_t.  The+-- payload interns byte-level ('symbolInternBytes'); the StorePath and+-- output-name fields are Text and intern as UTF-8.+marshalStringContext :: ByteString -> StringContext -> IO CCtxStrPtr marshalStringContext textVal (StringContext ctxSet) = do-  Symbol textSym <- symbolIntern textVal+  Symbol textSym <- symbolInternBytes textVal   let elems = Set.toAscList ctxSet-      count = fromIntegral (length elems) :: Word16-  ptr <- cctxstrNew textSym count+      count = fromIntegral (length elems) :: Word32+  ptr <- checkedCPtr "marshalStringContext" <$> cctxstrNew textSym count   fillElems ptr 0 elems   pure ptr   where@@ -937,19 +1047,28 @@       Symbol nameSym <- symbolIntern n       cctxstrSetAllOutputs eptr eidx hashSym nameSym --- | Unmarshal a C nn_ctxstr_t back to (Text, StringContext).-unmarshalStringContext :: CCtxStrPtr -> IO (Text, StringContext)+-- | Unmarshal a C nn_ctxstr_t back to (payload bytes, StringContext).+unmarshalStringContext :: CCtxStrPtr -> IO (ByteString, StringContext) unmarshalStringContext ptr = do   textSym <- cctxstrText ptr   count <- cctxstrCtxCount ptr-  let textVal = symbolText (Symbol textSym)-  elems <- mapM (readElem ptr) [0 .. count - 1]+  let textVal = symbolBytes (Symbol textSym)+  -- count - 1 underflows Word32 at 0 (defensive: marshal sites store+  -- only non-empty contexts today).+  elems <-+    if count == 0+      then pure []+      else mapM (readElem ptr) [0 .. count - 1]   pure (textVal, StringContext (Set.fromList elems))   where     readElem cptr idx = do       tag <- cctxstrElemTag cptr idx       hashSym <- cctxstrElemHash cptr idx       nameSym <- cctxstrElemName cptr idx+      -- Raw construction (Nix.Store.Path.Internal): provenance-safe -+      -- these symbols were interned from a validated StorePath's own+      -- fields by marshalStringContext, so this is a round-trip, not a+      -- parse boundary.       let sp = StorePath (symbolText (Symbol hashSym)) (symbolText (Symbol nameSym))       case tag of         0 -> pure (SCPlain sp)@@ -962,9 +1081,9 @@ {-# NOINLINE newComputedAttrsPtr #-} newComputedAttrsPtr :: CAttrSet -> CThunkPtr newComputedAttrsPtr cset =-  unsafePerformIO $-    cset `seq`-      cthunkNewComputedAttrs (castPtr cset)+  unsafePerformIO+    $ cset+    `seq` cthunkNewComputedAttrs (castPtr cset)  -- | Build a C-allocated slot array from a list of 'Thunk' values. -- Each thunk is converted to 'CThunkPtr' via 'thunkToCPtr'.@@ -1033,13 +1152,31 @@ -- so the same evaluator composes into 'PureEval' for tests or @IO@ for -- real file-system access (e.g. @import@, @readFile@). class (Monad m) => MonadEval m where+  -- | Raise an evaluation error (type error, missing attribute, IO+  -- failure).  NOT caught by @builtins.tryEval@: upstream Nix catches only+  -- ThrownError\/AssertionError there, and every other EvalError escapes.   throwEvalError :: Text -> m a +  -- | Raise a catchable error - @builtins.throw@ and a failed @assert@.+  -- These are the only errors 'catchEvalError' (tryEval) recovers from.+  throwCatchableError :: Text -> m a+   -- | Abort evaluation (uncatchable by tryEval, matching real Nix).   abortEvaluation :: Text -> m a +  -- | tryEval semantics: recover from a 'throwCatchableError'+  -- (throw\/assert); eval errors and aborts propagate.   catchEvalError :: m a -> m (Either Text a)-  readFileText :: Text -> m Text++  -- | Run the action; when it fails with ANY evaluation failure+  -- (catchable throw, eval error, or abort), run the cleanup and let+  -- the failure propagate unchanged.  The error half of a bracket -+  -- success runs no cleanup - for builtins that hold external state+  -- (a scratch directory) across throwing calls.  'catchEvalError'+  -- cannot express this: it recovers only catchable throws, and a+  -- failed fetch is an eval error.+  onEvalError :: m a -> m () -> m a+   doesPathExist :: Text -> m Bool    -- | List a directory, returning @(name, fileType)@ pairs.@@ -1055,8 +1192,13 @@   -- | Get the current epoch time (seconds since 1970-01-01).   getCurrentTime :: m Int64 -  -- | Write a named file to the store, returning the store path.-  writeToStore :: Text -> Text -> m Text+  -- | Write a named file to the store at upstream's text-path+  -- (@text:\<refs\>@ scheme, flat sha256 of the contents), returning the+  -- canonical store path.  The contents are raw bytes (a Nix string's+  -- payload) - hashed and stored exactly as given.  The refs are the+  -- contents' plain store-path references; they participate in the path+  -- computation exactly as upstream's addTextToStore.+  writeToStore :: Text -> ByteString -> [StorePath] -> m Text    -- | Import a file with a custom scope overlaid on builtins.   scopedImportFile :: [(Text, Thunk)] -> Text -> m NixValue@@ -1071,17 +1213,77 @@   -- | Run an external process: @(command, args, stdin) -> (exitCode, stdout, stderr)@.   runProcess :: Text -> [Text] -> Text -> m (Int, Text, Text) -  -- | Resolve a path literal to an absolute path.-  -- In IO evaluation, relative paths are resolved against the current-  -- file's directory (@esBaseDir@).  In pure evaluation, paths are-  -- returned unchanged.  This ensures that path values captured in-  -- closures remain valid after the import scope ends.+  -- | Create a fetcher scratch directory under the system temp dir: the+  -- given name prefix plus an unpredictable suffix, exclusively created.+  -- A fixed scratch name in the shared temp dir gives any other local+  -- process a pre-place\/swap window between creation and the store+  -- copy, so the name must be unguessable and creation must fail rather+  -- than adopt an existing directory.+  createScratchDir :: Text -> m Text++  -- | Recursively remove a directory 'createScratchDir' created.+  removeScratchDir :: Text -> m ()++  -- | Resolve a path literal to a canonical path.+  -- In IO evaluation, @~/@ resolves against the home directory and other+  -- relative paths resolve against the current file's directory+  -- (@esBaseDir@); this ensures that path values captured in closures+  -- remain valid after the import scope ends.  In pure evaluation the+  -- text is not absolutized.  Every implementation ends with lexical+  -- canonicalization ('Nix.Eval.CanonPath.canonPath'), as upstream: no+  -- dot segment or repeated separator survives into a path value.   resolvePathLiteral :: Text -> m Text -  -- | Copy a path (file or directory) to the store, returning the store path.-  -- First argument is the source path, second is the store name.-  copyPathToStore :: Text -> Text -> m Text+  -- | Copy a path (file or directory) to the store, returning the store+  -- path.  Content-addressed like upstream addToStore: recursive NAR+  -- sha256 under the given name.  Arguments: source path, store name,+  -- and an optional sha256 pin as (error subject, expected digest): a+  -- digest mismatch fails the copy with the subject heading the message.+  -- @builtins.path@ and @builtins.fetchTarball@ share this pin.+  copyPathToStore :: Text -> Text -> Maybe (Text, ByteString) -> m Text +  -- | Recursive NAR sha256 digest of a path, without copying it anywhere.+  -- Used by @builtins.fetchGit@ to report @narHash@.+  narHashOfPath :: Text -> m ByteString++  -- | Whether a regular file carries the executable bit (NAR encodes it).+  isExecutableFile :: Text -> m Bool++  -- | Mark a regular file executable (mode bit on Unix, an alternate data+  -- stream on Windows). Run before the tree is copied to the store.+  setExecutableFile :: Text -> m ()++  -- | Look up a previously recorded fetch by a key naming exactly what was+  -- fetched. 'Nothing' when nothing was recorded, or the record no longer+  -- describes anything on disk.+  lookupFetchCache :: Text -> m (Maybe Text)++  -- | Take over a store path an earlier evaluation wrote: 'True' when it is+  -- still on disk, and recorded as this evaluation's own store write in+  -- that case. Reusing a path instead of rewriting it must still leave the+  -- registration behind, or the build driver never registers it and a+  -- derivation naming it fails with \"references unregistered path\".+  adoptStorePath :: Text -> m Bool++  -- | Record a fetch under that key. Failing to record is not an error -+  -- the cache is an optimisation.+  writeFetchCache :: Text -> Text -> m ()++  -- | Read a symlink's target WITHOUT following it.+  readSymlinkTarget :: Text -> m Text++  -- | Unpack a serialized NAR into the store under the given name at its+  -- canonical recursive fixed-output path (sha256 of the bytes),+  -- returning the store path text.  Used by @builtins.path@ and+  -- @builtins.filterSource@ with a filter, whose filtered tree exists+  -- only as a NAR value and never on the source filesystem.+  addSourceNar :: Text -> ByteString -> m Text++  -- | Write raw bytes to the store at a canonical flat fixed-output path+  -- (@makeFixedOutputPath name "sha256" "flat"@), so a hash-pinned fetch lands+  -- at the same store path C++ Nix computes - reproducible and cacheable.+  addFixedOutputFile :: Text -> ByteString -> m Text+   -- | Print a trace/warning message.   -- IO evaluators write to stderr; pure evaluators silently discard.   traceMessage :: Text -> m ()@@ -1103,23 +1305,53 @@   -- A no-op in pure evaluators (no memoization).   cacheDrvHash :: Text -> Text -> m () -  -- | Record a derivation's serialized @.drv@ ATerm under its @.drv@ store-  -- path, as each derivation is computed (bottom-up).  Unlike 'cacheDrvHash'-  -- (which stores only the modulo hash), this retains the full ATerm so the-  -- build driver can materialize the entire input-@.drv@ closure to the store-  -- before a dependency-aware build.  A no-op in pure evaluators.-  recordDrvAterm :: Text -> Text -> m ()+  -- | Record a derivation's serialized @.drv@ ATerm (the exact bytes whose+  -- hash is its store path) under its @.drv@ store path, as each derivation+  -- is computed (bottom-up).  Unlike 'cacheDrvHash' (which stores only the+  -- modulo hash), this retains the full ATerm so the build driver can+  -- materialize the entire input-@.drv@ closure to the store before a+  -- dependency-aware build.  A no-op in pure evaluators.+  recordDrvAterm :: Text -> ByteString -> m () +  -- | Read and parse a derivation from its @.drv@ in the store, or 'Nothing'+  -- if it is absent or unreadable.  Used to resolve an input derivation's+  -- modulo hash on a cache miss - a cross-session reference, or a path+  -- fabricated by @builtins.appendContext@ - where the referenced derivation+  -- was not evaluated this session.  Reading the store is an effect, so this+  -- is an IO-evaluator capability: pure evaluators return 'Nothing', which+  -- makes hashing a dependent derivation something only the IO evaluator can do.+  readStoreDerivation :: StorePath -> m (Maybe Derivation)++  -- | Look up a derivation evaluated earlier THIS session by its @.drv@ store+  -- path, or 'Nothing' if it was not.  The IO evaluator reads the ATerm it+  -- recorded bottom-up ('recordDrvAterm'); pure evaluators return 'Nothing'.+  -- Used to recover a referenced derivation's output names for an all-outputs+  -- (DrvDeep) context reference without a disk read - the in-session @.drv@ is+  -- not on disk during evaluation.+  lookupSessionDrv :: Text -> m (Maybe Derivation)+   -- | Compute the store path a source file/directory gets when copied into   -- the store (recursive NAR sha256 to a @source@ fixed-output path), WITHOUT   -- performing the copy.  Used when a path literal is coerced in a derivation   -- argument or environment value.  Unavailable in pure evaluation.   storeSourcePath :: Text -> m Text --- | Error raised during pure evaluation.  'PAbort' (from @abort@ and infinite--- recursion) must escape 'tryEval' exactly as in C++ Nix; 'PThrow' is catchable.-data PureError = PThrow !Text | PAbort !Text+-- | Unwrap a store-path construction result (the @makeStorePath@ family+-- in "Nix.Hash"), converting a rejected name into an eval error under+-- the given context prefix (e.g. @builtins.toFile@).  Every eval-side+-- path construction goes through this or a bespoke equivalent, so a+-- name rejection always surfaces as a clean eval error rather than an+-- unchecked write path.+storePathOrThrow :: (MonadEval m) => Text -> Either StorePathNameError StorePath -> m StorePath+storePathOrThrow context =+  either (throwEvalError . ((context <> ": ") <>) . storePathNameErrorText) pure +-- | Error raised during pure evaluation.  'PThrow' (@builtins.throw@, a+-- failed @assert@) is the only kind 'tryEval' catches; 'PError' (type+-- errors, missing attributes) and 'PAbort' (@abort@, infinite recursion)+-- escape it, exactly as in C++ Nix.+data PureError = PThrow !Text | PError !Text | PAbort !Text+ -- | Pure evaluation monad - wraps @Either PureError@. -- IO builtins ('readFile', 'import') are unavailable; -- everything else evaluates identically to the IO version.@@ -1131,40 +1363,65 @@ runPureEval :: PureEval a -> Either Text a runPureEval (PureEval (Right a)) = Right a runPureEval (PureEval (Left (PThrow t))) = Left t+runPureEval (PureEval (Left (PError t))) = Left t runPureEval (PureEval (Left (PAbort t))) = Left ("evaluation aborted: " <> t)  instance MonadEval PureEval where-  throwEvalError msg = PureEval (Left (PThrow msg))+  throwEvalError msg = PureEval (Left (PError msg))+  throwCatchableError msg = PureEval (Left (PThrow msg))   abortEvaluation msg = PureEval (Left (PAbort msg)) -  -- Catch a throw (tryEval sees the error), but let an abort / infinite-  -- recursion propagate past tryEval, matching EvalIO and C++ Nix.+  -- Catch only a throw/assert (tryEval sees the error); eval errors,+  -- aborts, and infinite recursion propagate, matching EvalIO and C++ Nix.   catchEvalError (PureEval action) =     PureEval $ case action of       Left (PThrow t) -> Right (Left t)-      Left (PAbort t) -> Left (PAbort t)+      Left other -> Left other       Right a -> Right (Right a)-  readFileText _ = throwEvalError "readFile: not available in pure evaluation"++  -- Pure evaluation has no external state, so there is nothing to+  -- clean up; the failure passes through unchanged.+  onEvalError (PureEval action) _ = PureEval action   doesPathExist _ = pure False   listDirectory _ = throwEvalError "builtins.readDir: not available in pure evaluation"   importFile _ = throwEvalError "import: not available in pure evaluation"   getEnvVar _ = pure ""   getCurrentTime = pure 0-  writeToStore _ _ = throwEvalError "toFile: not available in pure evaluation"+  writeToStore _ _ _ = throwEvalError "toFile: not available in pure evaluation"   scopedImportFile _ _ = throwEvalError "scopedImport: not available in pure evaluation"-  readFileBytes _ = throwEvalError "hashFile: not available in pure evaluation"+  readFileBytes _ = throwEvalError "readFile: not available in pure evaluation"   getFileType _ = throwEvalError "readFileType: not available in pure evaluation"   runProcess _ _ _ = throwEvalError "runProcess: not available in pure evaluation"-  copyPathToStore _ _ = throwEvalError "builtins.path: not available in pure evaluation"+  createScratchDir _ = throwEvalError "createScratchDir: not available in pure evaluation"+  removeScratchDir _ = pure ()+  copyPathToStore _ _ _ = throwEvalError "builtins.path: not available in pure evaluation"+  narHashOfPath _ = throwEvalError "builtins.fetchGit: not available in pure evaluation"+  isExecutableFile _ = throwEvalError "builtins.path: not available in pure evaluation"+  setExecutableFile _ = throwEvalError "builtins.fetchGit: not available in pure evaluation"+  lookupFetchCache _ = pure Nothing+  adoptStorePath _ = pure False+  writeFetchCache _ _ = pure ()+  readSymlinkTarget _ = throwEvalError "builtins.path: not available in pure evaluation"+  addSourceNar _ _ = throwEvalError "builtins.path: not available in pure evaluation"+  addFixedOutputFile _ _ = throwEvalError "builtins.fetchurl: not available in pure evaluation"   traceMessage _ = pure ()   lookupDrvHash _ = pure Nothing   cacheDrvHash _ _ = pure ()   recordDrvAterm _ _ = pure () +  -- Pure eval cannot read the store, so a dependent derivation's input hash+  -- is never recoverable here; the caller turns this into a loud error rather+  -- than a guessed hash.+  readStoreDerivation _ = pure Nothing++  -- Pure eval keeps no session drv closure, so an all-outputs reference's+  -- output names are never recoverable here either.+  lookupSessionDrv _ = pure Nothing+   -- Pure eval cannot read files: a path coerces to itself (no store copy);   -- the real copy-to-store happens only under 'EvalIO'.   storeSourcePath = pure-  resolvePathLiteral = pure+  resolvePathLiteral = pure . canonPathValue   forceThunk evalFn (Thunk ptr) =     -- Read the C thunk via unsafePerformIO - safe because reads are     -- idempotent and PureEval never writes back (no memoization).@@ -1180,7 +1437,7 @@               ValueNull -> pure VNull               ValueStr ->                 let sym = unsafePerformIO (cthunkGetStr ptr)-                 in pure (VStr (symbolText (Symbol sym)) emptyContext)+                 in pure (VStr (symbolBytes (Symbol sym)) emptyContext)               ValuePath ->                 let sym = unsafePerformIO (cthunkGetPath ptr)                  in pure (VPath (symbolText (Symbol sym)))
src/Nix/Expr/ClosureTrim.hs view
@@ -19,39 +19,33 @@   ) where -import Data.List (foldl', sortBy)+import Data.List (sortBy) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map import Data.Ord (comparing) import Data.Set (Set) import qualified Data.Set as Set-import Data.Text (Text) import Nix.Expr.Types  -- | Walk the AST and attach 'Captures' info to trimmable scopes -- (lambdas, let blocks, and recursive attr sets). trimClosures :: Expr -> Expr-trimClosures = trimExpr Set.empty+trimClosures = trimExpr --- | Walk expression, tracking names bound in enclosing inner scopes--- (let/rec/lambda formals) so we can tell if an 'EVar' escapes to--- an outer scope.------ @innerNames@ accumulates names from scopes INSIDE the current--- lambda being analyzed.  It resets to empty when we enter a new--- lambda (since that lambda's body is the new analysis target).-trimExpr :: Set Text -> Expr -> Expr-trimExpr innerNames expr = case expr of+-- | Walk the expression tree, dispatching each trimmable scope to+-- 'trimOneLambda' and friends.  Escape analysis happens per scope in+-- those helpers; the walk itself carries no state.+trimExpr :: Expr -> Expr+trimExpr expr = case expr of   ELit {} -> expr-  EStr parts -> EStr (map (trimPart innerNames) parts)-  EIndStr parts -> EIndStr (map (trimPart innerNames) parts)+  EStr parts -> EStr (map trimPart parts)+  EIndStr parts -> EIndStr (map trimPart parts)+  EPathStr parts -> EPathStr (map trimPart parts)   EVar {} -> expr   EWithVar {} -> expr   EResolvedVar {} -> expr   EAttrs True bindings captureInfo ->-    let names = collectBindingNames bindings-        innerNamesRec = Set.union innerNames names-        trimmedBindings = map (trimBinding innerNamesRec) bindings+    let trimmedBindings = map trimBinding bindings      in case captureInfo of           Captures {} -> EAttrs True trimmedBindings captureInfo           CapturesWithScopes {} -> EAttrs True trimmedBindings captureInfo@@ -65,22 +59,20 @@                         else Captures captureList                  in EAttrs True rewrittenBindings ci   EAttrs False bindings _captureInfo ->-    EAttrs False (map (trimBinding innerNames) bindings) NoCaptureInfo-  EList elems -> EList (map (trimExpr innerNames) elems)+    EAttrs False (map trimBinding bindings) NoCaptureInfo+  EList elems -> EList (map trimExpr elems)   ESelect target path defExpr ->     ESelect-      (trimExpr innerNames target)-      (map (trimKey innerNames) path)-      (fmap (trimExpr innerNames) defExpr)+      (trimExpr target)+      (map trimKey path)+      (fmap trimExpr defExpr)   EHasAttr target path ->-    EHasAttr (trimExpr innerNames target) (map (trimKey innerNames) path)-  EApp f x -> EApp (trimExpr innerNames f) (trimExpr innerNames x)+    EHasAttr (trimExpr target) (map trimKey path)+  EApp f x -> EApp (trimExpr f) (trimExpr x)   ELambda formals body captures ->     -- First, recursively trim nested lambdas in the body.-    -- Reset innerNames since we're entering a new lambda scope.-    let formalNames = formalsNames formals-        trimmedFormals = trimFormals formals-        trimmedBody = trimExpr formalNames body+    let trimmedFormals = trimFormals formals+        trimmedBody = trimExpr body      in case captures of           Captures {} -> ELambda trimmedFormals trimmedBody captures           CapturesWithScopes {} -> ELambda trimmedFormals trimmedBody captures@@ -94,10 +86,8 @@                         else Captures captureList                  in ELambda rewrittenFormals rewrittenBody captureInfo   ELet bindings body captureInfo ->-    let names = collectBindingNames bindings-        innerNamesLet = Set.union innerNames names-        trimmedBindings = map (trimBinding innerNamesLet) bindings-        trimmedBody = trimExpr innerNamesLet body+    let trimmedBindings = map trimBinding bindings+        trimmedBody = trimExpr body      in case captureInfo of           Captures {} -> ELet trimmedBindings trimmedBody captureInfo           CapturesWithScopes {} -> ELet trimmedBindings trimmedBody captureInfo@@ -112,29 +102,29 @@                         else Captures captureList                  in ELet rewrittenBindings rewrittenBody ci   EIf c t f ->-    EIf (trimExpr innerNames c) (trimExpr innerNames t) (trimExpr innerNames f)+    EIf (trimExpr c) (trimExpr t) (trimExpr f)   EWith scope body ->-    EWith (trimExpr innerNames scope) (trimExpr innerNames body)+    EWith (trimExpr scope) (trimExpr body)   EAssert cond body ->-    EAssert (trimExpr innerNames cond) (trimExpr innerNames body)-  EUnary op operand -> EUnary op (trimExpr innerNames operand)-  EBinary op l r -> EBinary op (trimExpr innerNames l) (trimExpr innerNames r)+    EAssert (trimExpr cond) (trimExpr body)+  EUnary op operand -> EUnary op (trimExpr operand)+  EBinary op l r -> EBinary op (trimExpr l) (trimExpr r)   ESearchPath {} -> expr -trimPart :: Set Text -> StringPart -> StringPart-trimPart _ p@(StrLit _) = p-trimPart innerNames (StrInterp e) = StrInterp (trimExpr innerNames e)+trimPart :: StringPart -> StringPart+trimPart p@(StrLit _) = p+trimPart (StrInterp e) = StrInterp (trimExpr e) -trimKey :: Set Text -> AttrKey -> AttrKey-trimKey _ k@(StaticKey _) = k-trimKey innerNames (DynamicKey e) = DynamicKey (trimExpr innerNames e)+trimKey :: AttrKey -> AttrKey+trimKey k@(StaticKey _) = k+trimKey (DynamicKey e) = DynamicKey (trimExpr e) -trimBinding :: Set Text -> Binding -> Binding-trimBinding innerNames (NamedBinding path bodyExpr) =-  NamedBinding (map (trimKey innerNames) path) (trimExpr innerNames bodyExpr)-trimBinding innerNames (Inherit (Just fromExpr) names) =-  Inherit (Just (trimExpr innerNames fromExpr)) names-trimBinding _ b@(Inherit Nothing _) = b+trimBinding :: Binding -> Binding+trimBinding (NamedBinding path bodyExpr) =+  NamedBinding (map trimKey path) (trimExpr bodyExpr)+trimBinding (Inherit (Just fromExpr) names) =+  Inherit (Just (trimExpr fromExpr)) names+trimBinding b@(Inherit Nothing _) = b  trimFormals :: Formals -> Formals trimFormals f@(FormalName _) = f@@ -145,11 +135,9 @@  trimFormal :: Formal -> Formal trimFormal (Formal name defExpr) =-  -- Default expressions are evaluated in the lambda's own scope,-  -- so they get fresh innerNames (just the formals themselves).-  -- But we already trimmed nested lambdas, and defaults are part-  -- of the lambda's body - they'll be captured correctly.-  Formal name (fmap (trimExpr Set.empty) defExpr)+  -- Default expressions are part of the lambda's own body; nested+  -- lambdas inside them were already trimmed by the walk.+  Formal name (fmap trimExpr defExpr)  -- | Analyze a single lambda body and formals defaults, produce a capture -- list + rewritten body + rewritten formals, or return the original@@ -193,6 +181,7 @@   ELit {} -> (Set.empty, False, False)   EStr parts -> foldParts depth parts   EIndStr parts -> foldParts depth parts+  EPathStr parts -> foldParts depth parts   EVar _ ->     -- Any EVar in the body means we need the parent chain for name lookup.     -- This makes the lambda untrimable.@@ -338,6 +327,7 @@   ELit {} -> expr   EStr parts -> EStr (map (rewritePart depth captureMap) parts)   EIndStr parts -> EIndStr (map (rewritePart depth captureMap) parts)+  EPathStr parts -> EPathStr (map (rewritePart depth captureMap) parts)   EVar {} -> expr   EWithVar {} -> expr   EResolvedVar level idx@@ -448,14 +438,6 @@                 Nothing -> (lvl, idx)       | otherwise = (lvl, idx) --- | Extract names introduced by lambda formals.-formalsNames :: Formals -> Set Text-formalsNames (FormalName name) = Set.singleton name-formalsNames (FormalSet formals _) =-  Set.fromList (map fName formals)-formalsNames (FormalNamedSet name formals _) =-  Set.insert name (Set.fromList (map fName formals))- -- | Analyze a let block's binding bodies + body for parent references, -- produce a capture list + rewritten bindings + body, or return the -- originals unchanged if untrimable.@@ -491,11 +473,3 @@               captureMap = Map.fromList (zip captureList [0 ..])               rewrittenBindings = map (rewriteBinding 0 captureMap) bindings            in Right (captureList, rewrittenBindings, bindingHasWithVar)---- | Collect top-level binding names (mirrors 'Nix.Expr.Resolve.collectBindingNames').-collectBindingNames :: [Binding] -> Set Text-collectBindingNames = foldl' addNames Set.empty-  where-    addNames acc (NamedBinding (StaticKey name : _) _) = Set.insert name acc-    addNames acc (NamedBinding _ _) = acc-    addNames acc (Inherit _ names) = foldl' (flip Set.insert) acc names
src/Nix/Expr/Resolve.hs view
@@ -1,4 +1,8 @@--- | Variable resolution pass: replaces 'EVar' with 'EResolvedVar'+-- | The resolution passes run over a parsed expression before it is+-- evaluated: variables to positional slots, and relative path literals to+-- absolute ones.+--+-- Variable resolution replaces 'EVar' with 'EResolvedVar' -- for variables bound by lambda formals and let\/rec bindings. -- -- Lambda formals and eligible let\/rec bindings get positional@@ -6,19 +10,27 @@ -- dynamic keys or nested paths fall back to 'NameBarrier' (name-based -- lookup at runtime).  With-scopes and builtins remain name-based. ----- Called once at parse time ('Nix.Parser.parseNix').+-- Path resolution rewrites a relative path literal to an absolute one, so+-- what it names is fixed by the file it was written in.+--+-- Both are called once at parse time ('Nix.Parser.parseNix'). module Nix.Expr.Resolve   ( resolveVars,+    resolveRelativePaths,++    -- * Static global names (exported for the sync test)+    staticGlobalNames,   ) where -import Data.List (foldl') import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map import Data.Set (Set) import qualified Data.Set as Set import Data.Text (Text)+import qualified Data.Text as T import Nix.Expr.Types+import System.FilePath (isRelative, (</>))  -- | Scope entry for static variable resolution. data ScopeEntry@@ -28,8 +40,10 @@     NameBarrier !(Set Text)   | -- | Marks a with-scope boundary on the stack.     -- Does NOT increment the de Bruijn level (with doesn't create a-    -- parent env level at runtime).  Variables not found in any lexical-    -- scope below a WithBarrier are upgraded to 'EWithVar'.+    -- parent env level at runtime).  Variables bound by no 'LexicalScope'+    -- or 'NameBarrier' anywhere on the stack - and not static globals -+    -- are upgraded to 'EWithVar' when at least one WithBarrier encloses+    -- them; lexical bindings and globals always win over with-scopes.     WithBarrier  -- | Resolve variables in an expression.  Replaces 'EVar' with@@ -45,6 +59,7 @@   ELit _ -> expr   EStr parts -> EStr (map (resolvePart stack) parts)   EIndStr parts -> EIndStr (map (resolvePart stack) parts)+  EPathStr parts -> EPathStr (map (resolvePart stack) parts)   EVar name -> resolveVar stack 0 name   EResolvedVar _ _ -> expr   EAttrs True bindings _captureInfo@@ -54,10 +69,13 @@             innerStack = scope : stack          in EAttrs True (concatMap (resolveLetBinding stack innerStack) bindings) NoCaptureInfo     | otherwise ->-        -- Fallback: dynamic keys or nested paths - use NameBarrier.+        -- Fallback: dynamic keys or nested paths - use NameBarrier.  Bindings+        -- resolve against newStack (siblings visible), but a plain @inherit x@+        -- must reference the OUTER scope - resolveLetBinding handles that, so+        -- the barrier does not turn @inherit x@ into a self-reference.         let names = collectBindingNames bindings             newStack = NameBarrier names : stack-         in EAttrs True (concatMap (resolveBinding newStack) bindings) NoCaptureInfo+         in EAttrs True (concatMap (resolveLetBinding stack newStack) bindings) NoCaptureInfo   EAttrs False bindings _captureInfo ->     -- Non-recursive: bindings use the outer scope.     EAttrs False (concatMap (resolveBinding stack) bindings) NoCaptureInfo@@ -78,10 +96,12 @@             innerStack = scope : stack          in ELet (concatMap (resolveLetBinding stack innerStack) bindings) (resolve innerStack body) NoCaptureInfo     | otherwise ->-        -- Fallback: dynamic keys or nested paths - use NameBarrier.+        -- Fallback: dynamic keys or nested paths - use NameBarrier.  As above,+        -- resolveLetBinding resolves a plain @inherit x@ against the outer scope+        -- so the barrier does not make @x@ self-referential.         let names = collectBindingNames bindings             newStack = NameBarrier names : stack-         in ELet (concatMap (resolveBinding newStack) bindings) (resolve newStack body) NoCaptureInfo+         in ELet (concatMap (resolveLetBinding stack newStack) bindings) (resolve newStack body) NoCaptureInfo   EIf c t f -> EIf (resolve stack c) (resolve stack t) (resolve stack f)   EWithVar _ -> expr   EWith scope body ->@@ -108,29 +128,75 @@ -- | Resolve a variable by walking the scope stack. -- -- @level@ counts how many scope entries we've crossed (each corresponds--- to one parent hop at runtime).  If the name hits a 'LexicalScope',--- we return 'EResolvedVar'.  If it hits a 'NameBarrier', we stop and--- leave it as 'EVar' (name-based lookup at runtime).  If not found,--- 'EVar' is returned unchanged (looked up via with-scopes or builtins).+-- to one parent hop at runtime).  A 'LexicalScope' hit yields+-- 'EResolvedVar'; a 'NameBarrier' hit yields 'EVar' (name-based lookup at+-- runtime).  Both are LEXICAL bindings, so a hit ends the walk no matter+-- how many 'WithBarrier's were crossed on the way out - in Nix a+-- with-scope never shadows a binding introduced by other means.+--+-- Only a name bound by NEITHER becomes a with-variable, and then only if+-- it is not a static global: like C++ Nix's staticBaseEnv, a global+-- (@map@, @toString@, @builtins@, ...) binds at parse time, so+-- @with { map = 42; }; map@ is the builtin upstream and here. resolveVar :: [ScopeEntry] -> Int -> Text -> Expr-resolveVar [] _ name = EVar name-resolveVar (LexicalScope scope : rest) level name =-  case Map.lookup name scope of-    Just idx -> EResolvedVar level idx-    Nothing -> resolveVar rest (level + 1) name-resolveVar (NameBarrier names : rest) level name =-  if Set.member name names-    then EVar name-    else resolveVar rest (level + 1) name-resolveVar (WithBarrier : rest) level name =-  -- WithBarrier does NOT increment level (with doesn't create an env level).-  -- Continue searching lexical scopes below.  If the name is found in a-  -- lower LexicalScope, use that.  If it reaches the bottom as EVar-  -- (not found lexically), upgrade to EWithVar.-  case resolveVar rest level name of-    EVar _ -> EWithVar name-    resolved -> resolved+resolveVar fullStack startLevel name = go fullStack startLevel False+  where+    go [] _ crossedWith+      | crossedWith && not (Set.member name staticGlobalNames) = EWithVar name+      | otherwise = EVar name+    go (LexicalScope scope : rest) level crossedWith =+      case Map.lookup name scope of+        Just idx -> EResolvedVar level idx+        Nothing -> go rest (level + 1) crossedWith+    go (NameBarrier names : rest) level crossedWith+      | Set.member name names = EVar name+      | otherwise = go rest (level + 1) crossedWith+    -- WithBarrier does NOT increment level (with doesn't create an env+    -- level at runtime); it only records that an enclosing with exists.+    go (WithBarrier : rest) level _ = go rest level True +-- | Names statically bound in the root environment+-- ('Nix.Builtins.builtinEnv'): the value constants, @builtins@, the+-- search-path plumbing, and the top-level builtins that upstream Nix also+-- exposes unprefixed (its staticBaseEnv).  A name in this set is never a+-- with-variable - the global binds at parse time and an enclosing @with@+-- cannot shadow it.+--+-- @fetchurl@ and @toFile@ are absent on purpose: upstream exposes them+-- only under @builtins.@, and nixpkgs relies on @with pkgs; fetchurl@+-- binding @pkgs.fetchurl@.  The root env matches (they are not bound+-- there either).+--+-- Layering keeps this module from importing 'Nix.Builtins'; a test+-- asserts this set stays in sync with the root env's actual bindings.+staticGlobalNames :: Set Text+staticGlobalNames =+  Set.fromList+    [ "true",+      "false",+      "null",+      "builtins",+      "__findFile",+      "__nixPath",+      "abort",+      "baseNameOf",+      "break",+      "derivation",+      "derivationStrict",+      "dirOf",+      "fetchGit",+      "fetchTarball",+      "fromTOML",+      "import",+      "isNull",+      "map",+      "placeholder",+      "removeAttrs",+      "scopedImport",+      "throw",+      "toString"+    ]+ -- | Build a 'LexicalScope' from lambda formals. -- -- Index assignment:@@ -169,7 +235,7 @@ -- 'Inherit Nothing' is desugared into 'NamedBinding' entries so that -- each inherited name goes through normal variable resolution.  This -- is necessary because @inherit x@ does a name-based lookup at runtime,--- but lambda formals are stored in positional 'envSlots' (no names).+-- but lambda formals are stored in positional env slots (no names). -- Desugaring @inherit x@ to @x = x;@ lets the RHS 'EVar' resolve to -- 'EResolvedVar' when @x@ is a lambda formal. resolveBinding :: [ScopeEntry] -> Binding -> [Binding]@@ -207,14 +273,16 @@     -- Unreachable: allStaticSingleKey guards this path.     bindingNames _ = [] --- | Resolve bindings in a let\/rec block that uses positional resolution.--- Takes two stacks: @outerStack@ (before the let scope) and @innerStack@--- (with the let's 'LexicalScope' pushed).+-- | Resolve bindings in a let\/rec block, for both the positional+-- ('LexicalScope') and fallback ('NameBarrier') paths.  Takes two stacks:+-- @outerStack@ (before the block) and @innerStack@ (with the block's scope+-- entry pushed). -- -- Regular bindings resolve their RHS against @innerStack@ (recursive).--- @inherit x@ desugars to @x = x@ where the RHS resolves against--- @outerStack@ - the inherited name must reference the enclosing scope,--- not the let scope being defined.+-- @inherit x@ desugars to @x = x@ where the RHS resolves against @outerStack@:+-- the inherited name must reference the enclosing scope, not the block being+-- defined - otherwise the pushed scope\/barrier makes @x@ a self-reference and+-- forcing it recurses forever. resolveLetBinding :: [ScopeEntry] -> [ScopeEntry] -> Binding -> [Binding] resolveLetBinding _ innerStack (NamedBinding path bodyExpr) =   [NamedBinding (map (resolveKey innerStack) path) (resolve innerStack bodyExpr)]@@ -246,3 +314,89 @@ resolveFormal :: [ScopeEntry] -> Formal -> Formal resolveFormal stack (Formal name defExpr) =   Formal name (fmap (resolve stack) defExpr)++-- ---------------------------------------------------------------------------+-- Path resolution+-- ---------------------------------------------------------------------------++-- | Rewrite every relative path literal to an absolute one, against the+-- directory of the file the expression was parsed from.+--+-- This is where upstream does it too, and it has to be: a path literal names+-- a location relative to the file it is written in, and nothing downstream+-- of the parser knows which file that was.  Resolving later means resolving+-- against whatever directory happens to be current when the value is forced,+-- which for a literal captured in a closure and forced inside an @import@ is+-- a different file's directory.+--+-- Upstream 2.24 spells it @absPath(path, state->basePath.path.abs())@ in the+-- @PATH@ production and 2.35 spells it @CanonPath(literal,+-- state->basePath.path).abs()@; the two are the same operation.+--+-- A @~\/@ literal is not handled here: it reaches the evaluator, which+-- expands it against the home directory.  Upstream expands it in the parser+-- and has to guard that with a pure-eval check, because reading @HOME@ while+-- parsing is an impurity.+resolveRelativePaths :: FilePath -> Expr -> Expr+resolveRelativePaths dir = goExpr+  where+    goExpr expr = case expr of+      ELit (NixPath p)+        -- A ~/ literal names a location under the home directory, not one+        -- relative to this file.  Joining it to the base would bury the+        -- tilde mid-path, where nothing expands it and the result names+        -- a directory literally called "~".  The evaluator resolves it+        -- against HOME instead; upstream does it in the parser and has to+        -- guard that with a pure-eval check for reading the environment.+        | homeRelative p -> expr+        | isRelative (T.unpack p) ->+            ELit (NixPath (T.pack (dir </> T.unpack p)))+      ELit _ -> expr+      EStr parts -> EStr (map goPart parts)+      EIndStr parts -> EIndStr (map goPart parts)+      -- The head piece of an interpolated path is static text and gets+      -- the same absolutization as a plain literal, for the same+      -- closure-capture reason; the interpolated pieces only recurse.+      EPathStr (StrLit headPiece : rest)+        | not (homeRelative headPiece),+          isRelative (T.unpack headPiece) ->+            EPathStr (StrLit (T.pack (dir </> T.unpack headPiece)) : map goPart rest)+      EPathStr parts -> EPathStr (map goPart parts)+      EVar _ -> expr+      EWithVar _ -> expr+      EResolvedVar _ _ -> expr+      EAttrs isRec bindings captureInfo -> EAttrs isRec (map goBinding bindings) captureInfo+      EList elems -> EList (map goExpr elems)+      ESelect target path mDef ->+        ESelect (goExpr target) (map goKey path) (fmap goExpr mDef)+      EHasAttr target path -> EHasAttr (goExpr target) (map goKey path)+      EApp f x -> EApp (goExpr f) (goExpr x)+      ELambda formals body captures -> ELambda (goFormals formals) (goExpr body) captures+      ELet bindings body captureInfo -> ELet (map goBinding bindings) (goExpr body) captureInfo+      EIf c t f -> EIf (goExpr c) (goExpr t) (goExpr f)+      EWith scope body -> EWith (goExpr scope) (goExpr body)+      EAssert cond body -> EAssert (goExpr cond) (goExpr body)+      EUnary op e -> EUnary op (goExpr e)+      EBinary op l r -> EBinary op (goExpr l) (goExpr r)+      ESearchPath _ -> expr++    goPart part = case part of+      StrLit _ -> part+      StrInterp e -> StrInterp (goExpr e)++    goBinding binding = case binding of+      NamedBinding path e -> NamedBinding (map goKey path) (goExpr e)+      Inherit from names -> Inherit (fmap goExpr from) names++    goKey key = case key of+      StaticKey _ -> key+      DynamicKey e -> DynamicKey (goExpr e)++    goFormals formals = case formals of+      FormalName _ -> formals+      FormalSet fs ellipsis -> FormalSet (map goFormal fs) ellipsis+      FormalNamedSet n fs ellipsis -> FormalNamedSet n (map goFormal fs) ellipsis++    goFormal (Formal n mDef) = Formal n (fmap goExpr mDef)++    homeRelative = T.isPrefixOf "~/"
src/Nix/Expr/Types.hs view
@@ -143,6 +143,14 @@     EStr ![StringPart]   | -- | Indented string (double single-quoted).     EIndStr ![StringPart]+  | -- | Path literal with interpolations (@.\/${v}\/x@).  The first+    -- part is always the literal head piece.  Evaluates to a path:+    -- the pieces concatenate with no separator, each interpolation+    -- coerced as a path segment (a path contributes its text without+    -- a store copy; a string carrying store-path context is refused),+    -- and the whole is canonicalized - the same semantics as the+    -- @+@ chain @head + s1 + s2@, which upstream desugars it to.+    EPathStr ![StringPart]   | -- | Variable reference.     EVar !Text   | -- | Attribute set: @{ bindings }@ or @rec { bindings }@.@@ -188,7 +196,7 @@   | -- | De Bruijn-style resolved variable: @(level, index)@.     -- Produced by 'Nix.Expr.Resolve.resolveVars' for variables     -- bound by lambda formals.  @level@ counts parent-chain hops;-    -- @index@ is the positional slot within that env's 'envSlots'.+    -- @index@ is the positional slot within that env's slot array.     EResolvedVar !Int !Int   | -- | Variable resolved via with-scopes (not lexical).     -- Produced by 'Nix.Expr.Resolve.resolveVars' for names inside
src/Nix/Hash.hs view
@@ -1,3 +1,5 @@+{-# LANGUAGE ExistentialQuantification #-}+ -- | Cryptographic hashing for the Nix store. -- -- == How Nix uses hashes@@ -36,6 +38,14 @@     byteToHex,     hexToBytes,     rawHashWithAlgo,+    IncrementalHash,+    hashInitWithAlgo,+    hashUpdateChunk,+    hashFinalizeBytes,+    hashAlgoBytes,+    hexHashLen,+    nix32HashLen,+    base64HashLen,      -- * Store path construction (Nix @makeStorePath@ family)     makeStorePath,@@ -58,13 +68,13 @@ import qualified Data.ByteArray as BA import qualified Data.ByteString as BS import Data.Char (digitToInt, isHexDigit)-import Data.List (foldl') import qualified Data.Set as Set import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Data.Word (Word8)-import Nix.Store.Path (StoreDir (..), StorePath (..), defaultStoreDir, storePathToText)+import Nix.Store.Path (StoreDir (..), StorePath, StorePathNameError, checkStorePathName, defaultStoreDir, storePathToText)+import Nix.Store.Path.Internal (StorePath (StorePath)) import NovaCache.Base32 (encode) import NovaCache.Hash (formatNixHash, hashBytes, parseNixHash) @@ -160,6 +170,57 @@   "md5" -> Just (BA.convert (CH.hash bytes :: CH.Digest CH.MD5))   _ -> Nothing +-- | An in-progress digest under a named algorithm - the incremental+-- form of 'rawHashWithAlgo', for hashing a stream chunk by chunk+-- without materializing the whole input.+data IncrementalHash = forall a. (CH.HashAlgorithm a) => IncrementalHash !(CH.Context a)++-- | Start an incremental digest.  'Nothing' for an unknown algorithm+-- name (same names 'rawHashWithAlgo' accepts).+hashInitWithAlgo :: Text -> Maybe IncrementalHash+hashInitWithAlgo algo = case algo of+  "sha256" -> Just (IncrementalHash (CH.hashInit :: CH.Context CH.SHA256))+  "sha512" -> Just (IncrementalHash (CH.hashInit :: CH.Context CH.SHA512))+  "sha1" -> Just (IncrementalHash (CH.hashInit :: CH.Context CH.SHA1))+  "md5" -> Just (IncrementalHash (CH.hashInit :: CH.Context CH.MD5))+  _ -> Nothing++-- | Absorb one chunk.+hashUpdateChunk :: IncrementalHash -> BS.ByteString -> IncrementalHash+hashUpdateChunk (IncrementalHash ctx) chunk = IncrementalHash (CH.hashUpdate ctx chunk)++-- | Finish, yielding the same raw digest bytes 'rawHashWithAlgo'+-- produces for the concatenated chunks.+hashFinalizeBytes :: IncrementalHash -> BS.ByteString+hashFinalizeBytes (IncrementalHash ctx) = BA.convert (CH.hashFinalize ctx)++-- | Raw digest size in bytes of a supported hash algorithm.  'Nothing' for+-- an unknown algorithm name.+hashAlgoBytes :: Text -> Maybe Int+hashAlgoBytes algo = case algo of+  "md5" -> Just 16+  "sha1" -> Just 20+  "sha256" -> Just 32+  "sha512" -> Just 64+  _ -> Nothing++-- The three spelling lengths of an @n@-byte digest, mirroring upstream+-- (@hash.cc@ @base16Len@\/@base32Len@\/@base64Len@).  For every supported+-- digest size the three lengths are pairwise distinct, which is what makes+-- length-keyed hash-format detection sound.++-- | Character length of an @n@-byte digest spelled in base-16.+hexHashLen :: Int -> Int+hexHashLen n = 2 * n++-- | Character length of an @n@-byte digest spelled in nix-base32.+nix32HashLen :: Int -> Int+nix32HashLen n = (8 * n - 1) `div` 5 + 1++-- | Character length of an @n@-byte digest spelled in padded base64.+base64HashLen :: Int -> Int+base64HashLen n = 4 * ((n + 2) `div` 3)+ -- | The core store-path construction primitive.  Given a @type@ string, the -- inner content digest (raw SHA-256 bytes), and a name, produce the store -- path.  Mirrors C++ Nix @makeStorePath@:@@ -173,25 +234,36 @@ -- The @type@ string varies by caller: @\"text\"@ (+ references) for @.drv@ -- and @toFile@ paths, @\"output:<id>\"@ for derivation outputs, @\"source\"@ -- for recursive fixed-output paths.-makeStorePath :: StoreDir -> Text -> BS.ByteString -> Text -> StorePath+--+-- 'Left' when the name breaks the store-path name rules+-- ('checkStorePathName').  Construction validates like the parse boundary+-- does, so every 'StorePath' that exists has a clean name: the write sinks+-- downstream (store copies, the builder's delete-and-move) never see a+-- path that resolves outside the store root, and no sink needs its own+-- ad hoc name check.+makeStorePath :: StoreDir -> Text -> BS.ByteString -> Text -> Either StorePathNameError StorePath makeStorePath (StoreDir dir) typ innerDigest name =-  let preimage =-        typ-          <> ":sha256:"-          <> bytesToHexText innerDigest-          <> ":"-          <> T.pack dir-          <> ":"-          <> name-      compressed = compressHash storePathHashBytes (BS.unpack (sha256Digest (encodeUtf8 preimage)))-   in StorePath (encode (BS.pack compressed)) name+  case checkStorePathName name of+    Left err -> Left err+    Right () ->+      let preimage =+            typ+              <> ":sha256:"+              <> bytesToHexText innerDigest+              <> ":"+              <> T.pack dir+              <> ":"+              <> name+          compressed = compressHash storePathHashBytes (BS.unpack (sha256Digest (encodeUtf8 preimage)))+       in Right (StorePath (encode (BS.pack compressed)) name)  -- | Construct a text store path (used for @.drv@ files and @builtins.toFile@). -- The references are embedded in the @type@ string - @\"text\"@ followed by -- each referenced store path - which is why a derivation's @.drv@ path depends -- on the paths of all its inputs.  @contentsDigest@ is the SHA-256 of the file--- contents (the ATerm, for a @.drv@).-makeTextPath :: Text -> BS.ByteString -> [StorePath] -> StorePath+-- contents (the ATerm, for a @.drv@).  'Left' on an invalid name, as+-- 'makeStorePath'.+makeTextPath :: Text -> BS.ByteString -> [StorePath] -> Either StorePathNameError StorePath makeTextPath name contentsDigest refs =   let sortedRefs = Set.toAscList (Set.fromList refs)       typ = "text" <> T.concat [":" <> storePathToText defaultStoreDir r | r <- sortedRefs]@@ -203,7 +275,9 @@ -- -- * @sha256@ + @recursive@ yields @makeStorePath \"source\" foHash name@ -- * otherwise, @makeStorePath \"output:out\" sha256(\"fixed:out:\" prefix algo \":\" hex \":\") name@-makeFixedOutputPath :: Text -> Text -> Text -> BS.ByteString -> StorePath+--+-- 'Left' on an invalid name, as 'makeStorePath'.+makeFixedOutputPath :: Text -> Text -> Text -> BS.ByteString -> Either StorePathNameError StorePath makeFixedOutputPath name algo mode foHashDigest   | algo == "sha256" && mode == "recursive" =       makeStorePath defaultStoreDir "source" foHashDigest name@@ -215,8 +289,11 @@  -- | Construct an input-addressed output store path.  @moduloDigest@ is the raw -- bytes of @hashDerivationModulo@ (masked).  Mirrors C++ Nix @makeOutputPath@:--- the path name gets an @-<output>@ suffix for non-@out@ outputs.-makeOutputPath :: Text -> BS.ByteString -> Text -> StorePath+-- the path name gets an @-<output>@ suffix for non-@out@ outputs.  The+-- composed name is what gets validated - a name and output each clean on+-- their own can still compose past the length limit, so the check belongs+-- here, after composition.  'Left' as 'makeStorePath'.+makeOutputPath :: Text -> BS.ByteString -> Text -> Either StorePathNameError StorePath makeOutputPath outName moduloDigest drvName =   let pathName = if outName == "out" then drvName else drvName <> "-" <> outName    in makeStorePath defaultStoreDir ("output:" <> outName) moduloDigest pathName
src/Nix/Parser.hs view
@@ -56,24 +56,35 @@ import qualified Data.Text.Encoding as TE import Data.Text.Encoding.Error (lenientDecode) import Nix.Expr.ClosureTrim (trimClosures)-import Nix.Expr.Resolve (resolveVars)+import Nix.Expr.Resolve (resolveRelativePaths, resolveVars) import Nix.Expr.Types (Expr) import Nix.Parser.Expr (parseTopLevel) import Nix.Parser.Internal (ParseState (..), runParser) import Nix.Parser.Lexer (tokenize) import Nix.Parser.ParseError (ParseError (..))+import System.FilePath (takeDirectory)  -- | Parse a Nix expression from source text. -- -- The input is the full file contents. The file name is used only for -- error messages.  Strips a leading UTF-8 BOM if present - Windows -- editors (Notepad, PowerShell) commonly add one.-parseNix :: Text -> Text -> Either ParseError Expr-parseNix fileName source = do+--+-- The base directory is what relative path literals resolve against, and it+-- is an argument here rather than something the evaluator supplies later+-- because a path literal names a location relative to the file it was+-- written in.  By the time a value is forced the evaluator can be evaluating+-- a different file, and a literal that resolved against that one would name+-- a file its author never wrote.  Upstream resolves in its parser for the+-- same reason.  Pass the directory holding the file; for source with no file+-- behind it (@--expr@) pass the working directory, which is what upstream+-- parses against.+parseNix :: FilePath -> Text -> Text -> Either ParseError Expr+parseNix baseDir fileName source = do   tokens <- tokenize fileName (stripBOM source)   let st = ParseState {psTokens = tokens, psFile = fileName}   (expr, _remaining) <- runParser parseTopLevel st-  pure (trimClosures (resolveVars expr))+  pure (resolveRelativePaths baseDir (trimClosures (resolveVars expr)))  -- | Strip a leading UTF-8 byte order mark (U+FEFF) if present. stripBOM :: Text -> Text@@ -86,7 +97,7 @@ parseNixFile :: FilePath -> IO (Either ParseError Expr) parseNixFile path = do   source <- readFileAutoEncoding path-  pure $ parseNix (T.pack path) source+  pure $ parseNix (takeDirectory path) (T.pack path) source  -- --------------------------------------------------------------------------- -- Encoding detection
src/Nix/Parser/Expr.hs view
@@ -22,6 +22,9 @@   ) where +import Control.Monad (foldM, when)+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map import Data.Text (Text) import Nix.Expr.Types import Nix.Parser.Internal@@ -60,42 +63,69 @@ -- ---------------------------------------------------------------------------  -- | Identifier at start: could be @x: body@ or @name\@{...}: body@ or just expr.+-- A lambda header (@ident :@, or @ident \@ { formals } :@) is+-- grammatically unambiguous, so once one parses we are committed and a+-- failure in the body propagates from where it happened; it must not+-- backtrack into the expression reading, whose partial success would+-- otherwise leave the outer parser complaining at the stray colon.+-- When no reading parses, the deepest failure is the one reported. parseLambdaOrIdent :: Parser Expr parseLambdaOrIdent = do-  result <- tryParser trySimpleLambda-  case result of-    Just lam -> pure lam-    Nothing -> do-      result2 <- tryParser tryNamedSetLambda-      maybe parseImplication pure result2+  simple <- tryParserKeepError trySimpleLambdaHeader+  case simple of+    Right body -> body+    Left simpleErr -> do+      named <- tryParserKeepError tryNamedSetLambdaHeader+      case named of+        Right body -> body+        Left namedErr -> do+          plain <- tryParserKeepError parseImplication+          case plain of+            Right e -> pure e+            Left plainErr ->+              failWithError (deeperError simpleErr (deeperError namedErr plainErr)) --- | Try @x: body@-trySimpleLambda :: Parser Expr-trySimpleLambda = do+-- | The header of @x: body@; on success, returns the committed body parse.+trySimpleLambdaHeader :: Parser (Parser Expr)+trySimpleLambdaHeader = do   name <- expectIdent   expect TokColon-  (\body -> ELambda (FormalName name) body NoCaptureInfo) <$> parseExpr+  pure ((\body -> ELambda (FormalName name) body NoCaptureInfo) <$> parseExpr) --- | Try @name\@{ formals }: body@-tryNamedSetLambda :: Parser Expr-tryNamedSetLambda = do+-- | The header of @name\@{ formals }: body@; on success, returns the+-- committed body parse.+tryNamedSetLambdaHeader :: Parser (Parser Expr)+tryNamedSetLambdaHeader = do   name <- expectIdent   expect TokAt   expect TokLBrace   (formals, hasEllipsis) <- parseFormalsBody   expect TokColon-  (\body -> ELambda (FormalNamedSet name formals hasEllipsis) body NoCaptureInfo) <$> parseExpr+  pure ((\body -> ELambda (FormalNamedSet name formals hasEllipsis) body NoCaptureInfo) <$> parseExpr)  -- | Brace at start: could be @{ formals }: body@, @{ formals }\@name: body@, or attr set.--- Falls back to parsing as attr set (via implication) if lambda parse fails.+-- Same commitment rule as 'parseLambdaOrIdent': @... } :@ and+-- @... } \@ name :@ never follow an attr set, so a parsed lambda+-- header commits, and a body failure is reported from its own+-- position (a real error at line 410 of a formals-lambda file used to+-- surface as an attr-set complaint at line 2).  When both readings+-- fail, the deeper failure wins, so a malformed formal beats the+-- attr-set branch's earlier stumble over the first comma. parseLambdaOrAttrSet :: Parser Expr parseLambdaOrAttrSet = do-  result <- tryParser trySetLambda-  maybe parseImplication pure result+  headed <- tryParserKeepError trySetLambdaHeader+  case headed of+    Right body -> body+    Left lambdaErr -> do+      alt <- tryParserKeepError parseImplication+      case alt of+        Right e -> pure e+        Left attrErr -> failWithError (deeperError lambdaErr attrErr) --- | Try @{ a, b ? default, ... }: body@ or @{ a, b }\@name: body@-trySetLambda :: Parser Expr-trySetLambda = do+-- | The header of @{ a, b ? default, ... }: body@ or+-- @{ a, b }\@name: body@; on success, returns the committed body parse.+trySetLambdaHeader :: Parser (Parser Expr)+trySetLambdaHeader = do   expect TokLBrace   (formals, hasEllipsis) <- parseFormalsBody   -- Check for @name after the closing brace@@ -103,12 +133,12 @@   case tok of     TokColon -> do       _ <- advance-      (\body -> ELambda (FormalSet formals hasEllipsis) body NoCaptureInfo) <$> parseExpr+      pure ((\body -> ELambda (FormalSet formals hasEllipsis) body NoCaptureInfo) <$> parseExpr)     TokAt -> do       _ <- advance       name <- expectIdent       expect TokColon-      (\body -> ELambda (FormalNamedSet name formals hasEllipsis) body NoCaptureInfo) <$> parseExpr+      pure ((\body -> ELambda (FormalNamedSet name formals hasEllipsis) body NoCaptureInfo) <$> parseExpr)     _ -> parseError ("expected ':' or '@' after formals, got " <> showToken tok)  -- | Parse the inside of @{ ... }@ formals (comma-separated, optional defaults,@@ -129,6 +159,9 @@ parseFormalsList :: [Formal] -> Parser ([Formal], Bool) parseFormalsList acc = do   name <- expectIdent+  -- Upstream rejects a repeated formal name at parse time.+  when (any ((== name) . fName) acc) $+    parseError ("duplicate formal function argument '" <> name <> "'")   -- Check for default value   hasDefault <- match TokQuestion   defVal <-@@ -383,6 +416,10 @@     TokNull -> advance >> pure (ELit NixNull)     TokUri u -> advance >> pure (ELit (NixUri u))     TokPath p -> advance >> pure (ELit (NixPath p))+    TokPathInterpStart headPiece -> do+      _ <- advance+      parts <- parsePathParts+      pure (EPathStr (StrLit headPiece : parts))     TokSearchPath p -> advance >> pure (ESearchPath p)     TokIdent name -> advance >> pure (EVar name)     TokStringOpen -> parseString@@ -430,6 +467,13 @@         TokStringLit txt -> do           _ <- advance           go (StrLit txt : acc)+        -- Indented-string escapes are opaque to indentation stripping+        -- (upstream marks them hasIndentation = false and strips only+        -- marked chunks): a constant-string interpolation part gets+        -- exactly that treatment from the evaluator.+        TokStringEsc txt -> do+          _ <- advance+          go (StrInterp (EStr [StrLit txt]) : acc)         TokInterpOpen -> do           _ <- advance           expr <- parseExpr@@ -437,6 +481,30 @@           go (StrInterp expr : acc)         _ -> parseError ("unexpected " <> showToken tok <> " in string") +-- | The pieces of an interpolated path literal, after the opening head+-- piece: literal chunks and interpolations until the lexer's+-- synthesized 'TokPathEnd'.  The lexer emits nothing else between the+-- opener and the end, so the trailing case is a defect signal, not a+-- user-facing grammar error.+parsePathParts :: Parser [StringPart]+parsePathParts = go []+  where+    go acc = do+      tok <- peek+      case tok of+        TokPathEnd -> do+          _ <- advance+          pure (reverse acc)+        TokPathLit txt -> do+          _ <- advance+          go (StrLit txt : acc)+        TokInterpOpen -> do+          _ <- advance+          expr <- parseExpr+          expect TokInterpClose+          go (StrInterp expr : acc)+        _ -> parseError ("unexpected " <> showToken tok <> " in path")+ -- --------------------------------------------------------------------------- -- Compound expressions -- ---------------------------------------------------------------------------@@ -472,7 +540,7 @@   (\bs -> EAttrs isRec bs NoCaptureInfo) <$> parseBindings  parseBindings :: Parser [Binding]-parseBindings = go []+parseBindings = go [] >>= normalizeBindings   where     go acc = do       tok <- peek@@ -487,6 +555,98 @@           binding <- parseNamedBinding           go (binding : acc) +-- ---------------------------------------------------------------------------+-- Binding normalization (upstream parser.y addAttr semantics)+-- ---------------------------------------------------------------------------++-- | Accumulated definition while normalizing one binding list.+data NormEntry+  = -- | @name = value@ - mergeable when the value is a plain attrset literal.+    StaticEntry !Text !Expr+  | -- | An @inherit@ - its names are never mergeable.+    InheritEntry !Binding+  | -- | A dynamic-key binding - collisions surface at eval time.+    DynamicEntry !Binding++-- | How a name was defined, for duplicate checking.+data DefKind = DefStatic | DefInherit++-- | Normalize a binding list the way upstream's parser (@addAttr@) does:+--+-- * a nested attrpath (@a.b.c = v@) hoists into nested attrset literals+--   under its first key (@a = { b = { c = v; }; }@);+-- * two definitions of one key merge recursively when BOTH values are+--   attrset literals - so @a.b = 1; a.c = 2;@ and @a = { b = 1; }; a.c = 2;@+--   both work, with inner duplicates checked recursively.  A @rec@ marker+--   on the existing set governs the merged result; one on the new set is+--   discarded, exactly as upstream;+-- * any other duplicate definition is a parse error, as upstream+--   (@a = 1; a = 2;@, an @inherit@ name reused, merging into a+--   non-literal value).+--+-- After normalization every static top-level key appears exactly once -+-- which also makes the positional (slot-based) eval path applicable to+-- sets and lets that use nested attrpaths.+normalizeBindings :: [Binding] -> Parser [Binding]+normalizeBindings bindings =+  case normalizeBindingList bindings of+    Left err -> parseError err+    Right normalized -> pure normalized++-- | Pure core of 'normalizeBindings'; recurses into merged literals.+normalizeBindingList :: [Binding] -> Either Text [Binding]+normalizeBindingList bindings = do+  (entriesRev, _defined) <- foldM step ([], Map.empty) (map canonicalBinding bindings)+  pure (map renderEntry (reverse entriesRev))+  where+    step :: ([NormEntry], Map Text DefKind) -> Binding -> Either Text ([NormEntry], Map Text DefKind)+    step (entries, defined) binding = case binding of+      NamedBinding [StaticKey key] value ->+        case Map.lookup key defined of+          Nothing ->+            Right (StaticEntry key value : entries, Map.insert key DefStatic defined)+          Just DefStatic -> do+            updated <- mergeIntoEntries key value entries+            Right (updated, defined)+          Just DefInherit -> Left (duplicateAttr key)+      b@(NamedBinding _ _) -> Right (DynamicEntry b : entries, defined)+      b@(Inherit _ names) -> do+        mapM_ (\name -> if Map.member name defined then Left (duplicateAttr name) else Right ()) names+        Right (InheritEntry b : entries, foldl' (\m name -> Map.insert name DefInherit m) defined names)++    -- Replace the existing entry for @key@ with the merged value.+    mergeIntoEntries key newValue entries = case entries of+      [] -> Left (duplicateAttr key)+      (StaticEntry existingKey existingValue : rest)+        | existingKey == key -> do+            merged <- mergeAttrValues key existingValue newValue+            Right (StaticEntry existingKey merged : rest)+      (entry : rest) -> (entry :) <$> mergeIntoEntries key newValue rest++    -- Upstream's addAttr (parser-state.hh) merges ANY two attrset+    -- literals without consulting ->recursive on either side: the+    -- EXISTING set's rec marker governs the merged result and the new+    -- set's marker is DISCARDED (upstream's own comment documents the+    -- discard).  The merged binding list is re-normalized so inner+    -- duplicates are caught.+    mergeAttrValues _ (EAttrs existingRec existingBindings _) (EAttrs _ newBindings _) =+      (\merged -> EAttrs existingRec merged NoCaptureInfo)+        <$> normalizeBindingList (existingBindings ++ newBindings)+    mergeAttrValues key _ _ = Left (duplicateAttr key)++    renderEntry (StaticEntry key value) = NamedBinding [StaticKey key] value+    renderEntry (InheritEntry b) = b+    renderEntry (DynamicEntry b) = b++    duplicateAttr key = "attribute '" <> key <> "' already defined"++-- | Hoist a nested attrpath under its first key: @a.b.c = v@ becomes+-- @a = { b = { c = v; }; }@, one literal per level.+canonicalBinding :: Binding -> Binding+canonicalBinding (NamedBinding (firstKey : rest@(_ : _)) value) =+  NamedBinding [firstKey] (EAttrs False [canonicalBinding (NamedBinding rest value)] NoCaptureInfo)+canonicalBinding b = b+ parseNamedBinding :: Parser Binding parseNamedBinding = do   path <- parseAttrPath@@ -561,8 +721,12 @@     -- Keywords are valid as attribute names in Nix:     -- { if = 1; }, a.then, { else = 2; }, etc.     _ | Just name <- keywordToText tok -> advance >> pure (StaticKey name)-    TokStringOpen ->-      DynamicKey <$> parseString+    TokStringOpen -> do+      strExpr <- parseString+      -- A string key with no interpolation is static, as in upstream Nix's+      -- parser (the string becomes a symbol at parse time) - which is also+      -- what makes @let "x" = 1; in x@ legal.+      pure (maybe (DynamicKey strExpr) StaticKey (literalStringKey strExpr))     TokInterpOpen -> do       _ <- advance       expr <- parseExpr@@ -572,6 +736,16 @@       pure (DynamicKey expr)     _ -> parseError ("expected attribute key, got " <> showToken tok) +-- | The literal text of a string expression that contains no interpolation.+-- Escape handling can split a literal into several 'StrLit' chunks, so the+-- parts are concatenated; any 'StrInterp' makes the key dynamic.+literalStringKey :: Expr -> Maybe Text+literalStringKey (EStr parts) = mconcat <$> traverse literalPart parts+  where+    literalPart (StrLit t) = Just t+    literalPart (StrInterp _) = Nothing+literalStringKey _ = Nothing+ -- | Convert keyword tokens to their text for use as attribute names. -- All Nix keywords are valid as attr keys in binding and select position. keywordToText :: Token -> Maybe Text@@ -597,7 +771,7 @@   (\body -> ELet bindings body NoCaptureInfo) <$> parseExpr  parseLetBindings :: Parser [Binding]-parseLetBindings = go []+parseLetBindings = go [] >>= normalizeBindings   where     go acc = do       tok <- peek@@ -608,7 +782,13 @@           go (binding : acc)         _ -> do           binding <- parseNamedBinding-          go (binding : acc)+          -- Upstream rejects a dynamic TOP-LEVEL key in a let at parse time;+          -- nested dynamic keys (let a.${k} = 1) live in a nested attrset+          -- and are fine.+          case binding of+            NamedBinding (DynamicKey _ : _) _ ->+              parseError "dynamic attributes not allowed in let"+            _ -> go (binding : acc)  parseIf :: Parser Expr parseIf = do@@ -647,6 +827,7 @@ canStartAtom TokNull = True canStartAtom (TokUri _) = True canStartAtom (TokPath _) = True+canStartAtom (TokPathInterpStart _) = True canStartAtom (TokSearchPath _) = True canStartAtom TokStringOpen = True canStartAtom TokIndStringOpen = True
src/Nix/Parser/Internal.hs view
@@ -17,6 +17,9 @@     expect,     match,     tryParser,+    tryParserKeepError,+    deeperError,+    failWithError,     pMany,     atEnd, @@ -123,6 +126,36 @@   Left _ -> Right (Nothing, st)   Right (val, st2) -> Right (Just val, st2) +-- | Backtracking that keeps the failure instead of discarding it.+-- 'tryParser' is right for iteration ('pMany') and wrong for+-- alternation between competing readings, where the discarded error+-- may be the true one: a failure deep in a lambda body used to+-- surface as the attr-set branch's complaint at the top of the file.+tryParserKeepError :: Parser a -> Parser (Either ParseError a)+tryParserKeepError (Parser p) = Parser $ \st -> case p st of+  Left err -> Right (Left err, st)+  Right (val, st2) -> Right (Right val, st2)++-- | Of two competing failures, the one that got further into the+-- input; upstream's LR parser never backtracks, so its errors sit at+-- the true site, and reporting the deeper branch is the closest a+-- backtracking parser comes to that.  End-of-input errors carry+-- position (0,0) by construction and rank past every positioned+-- error: end of input is the furthest a branch can get.  Ties keep+-- the second error, matching the old fallback order.+deeperError :: ParseError -> ParseError -> ParseError+deeperError a b+  | rank a > rank b = a+  | otherwise = b+  where+    rank e = case (peLine e, peCol e) of+      (0, 0) -> (maxBound, maxBound)+      pos -> pos++-- | Fail with an already-built error, position included.+failWithError :: ParseError -> Parser a+failWithError err = Parser $ \_ -> Left err+ -- | Parse zero or more occurrences. pMany :: Parser a -> Parser [a] pMany p = go []@@ -201,12 +234,16 @@ showToken (TokFloat d) = "float " <> T.pack (show d) showToken (TokUri u) = "URI '" <> u <> "'" showToken (TokPath p) = "path '" <> p <> "'"+showToken (TokPathInterpStart p) = "path '" <> p <> "'"+showToken (TokPathLit p) = "path piece '" <> p <> "'"+showToken TokPathEnd = "end of path" showToken (TokSearchPath p) = "search path '<" <> p <> ">'" showToken TokStringOpen = "'\"'" showToken TokStringClose = "'\"'" showToken TokIndStringOpen = "'''" showToken TokIndStringClose = "'''" showToken (TokStringLit _) = "string literal"+showToken (TokStringEsc _) = "string escape" showToken TokInterpOpen = "'${'" showToken TokInterpClose = "'}'" showToken TokPlus = "'+'"
src/Nix/Parser/Lexer.hs view
@@ -16,6 +16,7 @@ import Data.Int (Int64) import Data.Text (Text) import qualified Data.Text as T+import Data.Text.Foreign (lengthWord8) import qualified Data.Text.Lazy as TL import qualified Data.Text.Lazy.Builder as TB import Nix.Parser.ParseError (ParseError (..))@@ -50,12 +51,26 @@   | TokUri !Text   | TokPath !Text   | TokSearchPath !Text+  | -- | Interpolated path literal: the head piece opens (its text+    -- carried), 'TokPathLit' chunks and 'TokInterpOpen'/'TokInterpClose'+    -- pairs follow, and 'TokPathEnd' closes.  A path has no closing+    -- delimiter of its own, so the lexer synthesizes the end token at+    -- the first non-path character, the way upstream emits PATH_END.+    TokPathInterpStart !Text+  | TokPathLit !Text+  | TokPathEnd   | -- Strings     TokStringOpen   | TokStringClose   | TokIndStringOpen   | TokIndStringClose   | TokStringLit !Text+  | -- | Resolved escape text inside an indented string (@'''@, @''$@,+    -- @''${@, @''\x@).  Kept apart from 'TokStringLit' because escapes+    -- are opaque to indentation stripping (upstream lexer.l emits them+    -- without the hasIndentation mark): they end start-of-line+    -- whitespace but are never scanned or stripped.+    TokStringEsc !Text   | TokInterpOpen   | TokInterpClose   | -- Operators@@ -99,21 +114,43 @@ -- Lexer state -- --------------------------------------------------------------------------- --- | Which mode the lexer is in (for string interpolation).+-- | Which mode the lexer is in (for string and path interpolation). data LexMode   = ModeNormal   | ModeString   | ModeIndString+  | ModePath   deriving (Eq, Show)  -- | Internal lexer state.+--+-- @lsBraceDepth@ counts unmatched @{@ in the CURRENT normal-mode context;+-- a @}@ at depth 0 closes the current interpolation.  Entering @${@ from a+-- string pushes the enclosing context's count onto @lsBraceStack@ and+-- starts a fresh count; the matching interpolation close pops it back.+-- Without the stack, a nested interpolated string inside braces zeroes the+-- enclosing count and the outer attrset's @}@ mislexes as TokInterpClose. data LexState = LexState   { lsInput :: !Text,     lsFile :: !Text,     lsLine :: !Int,     lsCol :: !Int,     lsModes :: ![LexMode],-    lsBraceDepth :: !Int+    lsBraceDepth :: !Int,+    lsBraceStack :: ![Int],+    -- | Path-lookahead watermark: every position whose remaining input is+    -- LONGER than this byte count lies inside an already-scanned+    -- slash-free path-char run, so 'looksLikePathFrom' answers False+    -- without rescanning.  Without it, a long dot-and-ident run+    -- (@x ? p0.p1. ... .p69999@) rescans the rest of the run at every+    -- token - a quadratic that took minutes at 70000 segments.  The+    -- input only ever shrinks, so a recorded run end stays comparable.+    lsNoSlashFloor :: !Int,+    -- | The same watermark for the URI lookahead: positions inside an+    -- already-scanned scheme-char run that ended at a NON-colon carry no+    -- URI, so 'uriSpanFrom' answers Nothing without rescanning.  Dots+    -- are scheme chars, so the same long dot-run is quadratic without it.+    lsNoColonFloor :: !Int   }  -- ---------------------------------------------------------------------------@@ -130,7 +167,11 @@             lsLine = 1,             lsCol = 1,             lsModes = [ModeNormal],-            lsBraceDepth = 0+            lsBraceDepth = 0,+            lsBraceStack = [],+            -- No runs scanned yet: nothing may shortcut.+            lsNoSlashFloor = maxBound,+            lsNoColonFloor = maxBound           }    in lexLoop initialState [] @@ -142,123 +183,147 @@ lexLoop st acc = case lsModes st of   (ModeString : _) -> lexStringMode st acc   (ModeIndString : _) -> lexIndStringMode st acc+  (ModePath : _) -> lexPathMode st acc   _ -> lexNormalMode st acc  lexNormalMode :: LexState -> [Located] -> Either ParseError [Located] lexNormalMode st acc = case T.uncons (lsInput st) of   Nothing -> Right (reverse (Located (lsLine st) (lsCol st) TokEOF : acc))-  Just (c, rest) -> case c of-    _ | isSpace c -> lexNormalMode (skipWhitespace st) acc-    '#' -> lexNormalMode (skipLineComment st) acc-    '/'-      | Just '*' <- safeHead rest ->-          case skipBlockComment (advanceCol 2 st {lsInput = T.drop 2 (lsInput st)}) of-            Left err -> Left err-            Right newSt -> lexNormalMode newSt acc-    '"' ->-      let tok = Located (lsLine st) (lsCol st) TokStringOpen-          newSt = advanceCol 1 st {lsInput = rest, lsModes = ModeString : lsModes st}-       in lexLoop newSt (tok : acc)-    '\''-      | Just '\'' <- safeHead rest ->-          let tok = Located (lsLine st) (lsCol st) TokIndStringOpen-              newSt = advanceCol 2 st {lsInput = T.drop 2 (lsInput st), lsModes = ModeIndString : lsModes st}-           in lexLoop newSt (tok : acc)-    '.'-      | Just '.' <- safeHead rest,-        Just '.' <- safeHead (T.drop 1 rest) ->-          emit3 st TokEllipsis acc-    '.'-      | Just '/' <- safeHead rest ->-          lexPath st acc-      | Just '.' <- safeHead rest,-        Just c2 <- safeHead (T.drop 1 rest),-        c2 == '/' ->-          lexPath st acc-      | Just d <- safeHead rest,-        isDigit d ->-          lexLeadingDotFloat st acc-    '.' -> emit1 st TokDot acc-    ',' -> emit1 st TokComma acc-    ';' -> emit1 st TokSemicolon acc-    ':' -> emit1 st TokColon acc-    '@' -> emit1 st TokAt acc-    '?' -> emit1 st TokQuestion acc-    '(' -> emit1 st TokLParen acc-    ')' -> emit1 st TokRParen acc-    '[' -> emit1 st TokLBracket acc-    ']' -> emit1 st TokRBracket acc-    '{' ->-      let tok = Located (lsLine st) (lsCol st) TokLBrace-          newSt = advanceCol 1 st {lsInput = rest, lsBraceDepth = lsBraceDepth st + 1}-       in lexNormalMode newSt (tok : acc)-    '}' ->-      case lsModes st of-        -- closing an interpolation: pop back to string/indstring mode-        (ModeNormal : outerMode : restModes)-          | lsBraceDepth st == 0,-            outerMode == ModeString || outerMode == ModeIndString ->-              let tok = Located (lsLine st) (lsCol st) TokInterpClose-                  newSt = advanceCol 1 st {lsInput = rest, lsModes = outerMode : restModes}-               in lexLoop newSt (tok : acc)-        _ ->-          let depth = lsBraceDepth st-              newDepth = if depth > 0 then depth - 1 else 0-              tok = Located (lsLine st) (lsCol st) TokRBrace-              newSt = advanceCol 1 st {lsInput = rest, lsBraceDepth = newDepth}-           in lexNormalMode newSt (tok : acc)-    '+' | Just '+' <- safeHead rest -> emit2 st TokConcat acc-    '+' -> emit1 st TokPlus acc-    '*' -> emit1 st TokStar acc-    '-' | Just '>' <- safeHead rest -> emit2 st TokImpl acc-    '-' -> emit1 st TokMinus acc-    '!' | Just '=' <- safeHead rest -> emit2 st TokNeq acc-    '!' -> emit1 st TokNot acc-    '&' | Just '&' <- safeHead rest -> emit2 st TokAnd acc-    '|' | Just '|' <- safeHead rest -> emit2 st TokOr acc-    '=' | Just '=' <- safeHead rest -> emit2 st TokEq acc-    '=' -> emit1 st TokAssign acc-    '<' | Just '=' <- safeHead rest -> emit2 st TokLte acc-    '<'-      | maybe False (\ch -> isAlpha ch || ch == '_') (safeHead rest) ->-          lexSearchPath st acc-    '<' -> emit1 st TokLt acc-    '>' | Just '=' <- safeHead rest -> emit2 st TokGte acc-    '>' -> emit1 st TokGt acc-    '/' | Just '/' <- safeHead rest -> emit2 st TokUpdate acc-    '/'-      -- A '/' that begins a path segment (slash followed by a path char)-      -- starts a path: /abs/path.  A bare '/' (followed by whitespace) is the-      -- division operator: a / b.  Relative paths like a/b are caught by the-      -- path guard further down, before the identifier/number cases.-      | looksLikePath (lsInput st) -> lexPath st acc-      | otherwise -> emit1 st TokSlash acc-    '$'-      | Just '{' <- safeHead rest ->-          -- Increment brace depth so the closing } is TokRBrace, not-          -- TokInterpClose.  Without this, ${name} inside a string-          -- interpolation like "${env.${name}}" prematurely ends the-          -- outer interpolation.-          let tok = Located (lsLine st) (lsCol st) TokInterpOpen-              newSt = advanceCol 2 st {lsInput = T.drop 1 rest, lsBraceDepth = lsBraceDepth st + 1}-           in lexNormalMode newSt (tok : acc)-    '~'-      | Just '/' <- safeHead rest ->-          lexPath st acc-    -- A path-char run that contains a '/' segment is a path, not an-    -- identifier or number: a/b and 6/2 lex as paths, matching Nix.-    _ | looksLikePath (lsInput st) -> lexPath st acc-    _ | isDigit c -> lexNumber st acc-    _ | isIdentStart c -> lexIdentOrKeyword st acc-    _ ->-      Left-        ParseError-          { peFile = lsFile st,-            peLine = lsLine st,-            peCol = lsCol st,-            peMessage = "unexpected character: " <> T.singleton c-          }+  Just (c, rest) ->+    -- Lazy: forced only by the three path guards below.  Branches taken+    -- when the guard says "not a path" continue with 'flooredSt' so a+    -- freshly recorded slash-free run is remembered, not rescanned.+    let (startsPath, advancedFloor) = looksLikePathFrom (lsNoSlashFloor st) (lsInput st)+        flooredSt = st {lsNoSlashFloor = advancedFloor}+     in lexNormalModeAt st flooredSt startsPath c rest acc +-- | The normal-mode dispatch, after the path lookahead has been prepared.+-- @st@ is the incoming state; @flooredSt@ carries the advanced path+-- watermark for the continuations that bypassed a path reading.+lexNormalModeAt :: LexState -> LexState -> Bool -> Char -> Text -> [Located] -> Either ParseError [Located]+lexNormalModeAt st flooredSt startsPath c rest acc = case c of+  _ | isSpace c -> lexNormalMode (skipWhitespace st) acc+  '#' -> lexNormalMode (skipLineComment st) acc+  '/'+    | Just '*' <- safeHead rest ->+        case skipBlockComment (advanceCol 2 st {lsInput = T.drop 2 (lsInput st)}) of+          Left err -> Left err+          Right newSt -> lexNormalMode newSt acc+  '"' ->+    let tok = Located (lsLine st) (lsCol st) TokStringOpen+        newSt = advanceCol 1 st {lsInput = rest, lsModes = ModeString : lsModes st}+     in lexLoop newSt (tok : acc)+  '\''+    | Just '\'' <- safeHead rest ->+        let tok = Located (lsLine st) (lsCol st) TokIndStringOpen+            newSt = advanceCol 2 st {lsInput = T.drop 2 (lsInput st), lsModes = ModeIndString : lsModes st}+         in lexLoop newSt (tok : acc)+  '.'+    | Just '.' <- safeHead rest,+      Just '.' <- safeHead (T.drop 1 rest) ->+        emit3 st TokEllipsis acc+  '.'+    -- Maximal munch, as upstream's PATH regex: a dot-led path-char run+    -- containing a /-segment is ONE path token (./x, ../x, .github/x,+    -- even .5/x) - the path reading beats the float and TokDot readings.+    -- A dot-run with no slash falls through: .5 is a float, x.y selects.+    | startsPath -> lexPath st acc+    | Just d <- safeHead rest,+      isDigit d ->+        lexLeadingDotFloat flooredSt acc+  '.' -> emit1 flooredSt TokDot acc+  ',' -> emit1 st TokComma acc+  ';' -> emit1 st TokSemicolon acc+  ':' -> emit1 st TokColon acc+  '@' -> emit1 st TokAt acc+  '?' -> emit1 st TokQuestion acc+  '(' -> emit1 st TokLParen acc+  ')' -> emit1 st TokRParen acc+  '[' -> emit1 st TokLBracket acc+  ']' -> emit1 st TokRBracket acc+  '{' ->+    let tok = Located (lsLine st) (lsCol st) TokLBrace+        newSt = advanceCol 1 st {lsInput = rest, lsBraceDepth = lsBraceDepth st + 1}+     in lexNormalMode newSt (tok : acc)+  '}' ->+    case lsModes st of+      -- closing an interpolation: pop back to string/indstring mode and+      -- restore the enclosing normal-mode context's brace count.+      (ModeNormal : outerMode : restModes)+        | lsBraceDepth st == 0,+          outerMode == ModeString || outerMode == ModeIndString || outerMode == ModePath ->+            let tok = Located (lsLine st) (lsCol st) TokInterpClose+                (restoredDepth, restoredStack) = case lsBraceStack st of+                  (saved : outerSaved) -> (saved, outerSaved)+                  [] -> (0, [])+                newSt =+                  advanceCol+                    1+                    st+                      { lsInput = rest,+                        lsModes = outerMode : restModes,+                        lsBraceDepth = restoredDepth,+                        lsBraceStack = restoredStack+                      }+             in lexLoop newSt (tok : acc)+      _ ->+        let depth = lsBraceDepth st+            newDepth = if depth > 0 then depth - 1 else 0+            tok = Located (lsLine st) (lsCol st) TokRBrace+            newSt = advanceCol 1 st {lsInput = rest, lsBraceDepth = newDepth}+         in lexNormalMode newSt (tok : acc)+  '+' | Just '+' <- safeHead rest -> emit2 st TokConcat acc+  '+' -> emit1 st TokPlus acc+  '*' -> emit1 st TokStar acc+  '-' | Just '>' <- safeHead rest -> emit2 st TokImpl acc+  '-' -> emit1 st TokMinus acc+  '!' | Just '=' <- safeHead rest -> emit2 st TokNeq acc+  '!' -> emit1 st TokNot acc+  '&' | Just '&' <- safeHead rest -> emit2 st TokAnd acc+  '|' | Just '|' <- safeHead rest -> emit2 st TokOr acc+  '=' | Just '=' <- safeHead rest -> emit2 st TokEq acc+  '=' -> emit1 st TokAssign acc+  '<' | Just '=' <- safeHead rest -> emit2 st TokLte acc+  '<'+    | maybe False (\ch -> isAlpha ch || ch == '_') (safeHead rest) ->+        lexSearchPath st acc+  '<' -> emit1 st TokLt acc+  '>' | Just '=' <- safeHead rest -> emit2 st TokGte acc+  '>' -> emit1 st TokGt acc+  '/' | Just '/' <- safeHead rest -> emit2 st TokUpdate acc+  '/'+    -- A '/' that begins a path segment (slash followed by a path char)+    -- starts a path: /abs/path.  A bare '/' (followed by whitespace) is the+    -- division operator: a / b.  Relative paths like a/b are caught by the+    -- path guard further down, before the identifier/number cases.+    | startsPath -> lexPath st acc+    | otherwise -> emit1 flooredSt TokSlash acc+  '$'+    | Just '{' <- safeHead rest ->+        -- Increment brace depth so the closing } is TokRBrace, not+        -- TokInterpClose.  Without this, ${name} inside a string+        -- interpolation like "${env.${name}}" prematurely ends the+        -- outer interpolation.+        let tok = Located (lsLine st) (lsCol st) TokInterpOpen+            newSt = advanceCol 2 st {lsInput = T.drop 1 rest, lsBraceDepth = lsBraceDepth st + 1}+         in lexNormalMode newSt (tok : acc)+  '~'+    | Just '/' <- safeHead rest ->+        lexPath st acc+  -- A path-char run that contains a '/' segment is a path, not an+  -- identifier or number: a/b and 6/2 lex as paths, matching Nix.+  _ | startsPath -> lexPath st acc+  _ | isDigit c -> lexNumber flooredSt acc+  _ | isIdentStart c -> lexIdentOrKeyword flooredSt acc+  _ ->+    Left+      ParseError+        { peFile = lsFile st,+          peLine = lsLine st,+          peCol = lsCol st,+          peMessage = "unexpected character: " <> T.singleton c+        }+ -- --------------------------------------------------------------------------- -- String modes -- ---------------------------------------------------------------------------@@ -287,7 +352,11 @@                   st                     { lsInput = T.drop 1 rest,                       lsModes = ModeNormal : lsModes st,-                      lsBraceDepth = 0+                      -- Fresh count for the interpolation body; the+                      -- enclosing context's count is restored at the+                      -- matching TokInterpClose.+                      lsBraceDepth = 0,+                      lsBraceStack = lsBraceDepth st : lsBraceStack st                     }            in lexLoop newSt (tok : acc)     _ -> lexStringLiteral st acc@@ -309,21 +378,22 @@               -- Check for escape sequences: ''', ''$, ''\x, ''${               case T.uncons rest2 of                 Just ('\'', rest3) ->-                  -- ''' is a literal single quote (consume all 3)-                  let litTok = Located (lsLine st) (lsCol st) (TokStringLit "'")+                  -- ''' escapes a literal '' (two quotes) - Nix's escape for the+                  -- indented-string terminator, not a single quote.+                  let escTok = Located (lsLine st) (lsCol st) (TokStringEsc "''")                       newSt = advanceCol 3 st {lsInput = rest3}-                   in lexIndStringMode newSt (litTok : acc)+                   in lexIndStringMode newSt (escTok : acc)                 Just ('$', rest3)                   | Just ('{', rest4) <- T.uncons rest3 ->                       -- ''${ is a literal ${-                      let litTok = Located (lsLine st) (lsCol st) (TokStringLit "${")+                      let escTok = Located (lsLine st) (lsCol st) (TokStringEsc "${")                           newSt = advanceCol 4 st {lsInput = rest4}-                       in lexIndStringMode newSt (litTok : acc)+                       in lexIndStringMode newSt (escTok : acc)                 Just ('$', rest3) ->                   -- ''$ (without brace) is a literal $-                  let litTok = Located (lsLine st) (lsCol st) (TokStringLit "$")+                  let escTok = Located (lsLine st) (lsCol st) (TokStringEsc "$")                       newSt = advanceCol 3 st {lsInput = rest3}-                   in lexIndStringMode newSt (litTok : acc)+                   in lexIndStringMode newSt (escTok : acc)                 Just ('\\', rest3) ->                   -- ''\x is an escape sequence                   case T.uncons rest3 of@@ -333,10 +403,11 @@                             't' -> "\t"                             'r' -> "\r"                             '\\' -> "\\"-                            _ -> "\\" <> T.singleton ec-                          litTok = Located (lsLine st) (lsCol st) (TokStringLit escaped)+                            -- Unknown escape: Nix drops the backslash (''\q -> q).+                            _ -> T.singleton ec+                          escTok = Located (lsLine st) (lsCol st) (TokStringEsc escaped)                           newSt = advanceCol 4 st {lsInput = rest4}-                       in lexIndStringMode newSt (litTok : acc)+                       in lexIndStringMode newSt (escTok : acc)                     Nothing ->                       Left                         ParseError@@ -360,7 +431,10 @@                           st                             { lsInput = rest2,                               lsModes = ModeNormal : lsModes st,-                              lsBraceDepth = 0+                              -- Fresh count; enclosing context restored at+                              -- the matching TokInterpClose.+                              lsBraceDepth = 0,+                              lsBraceStack = lsBraceDepth st : lsBraceStack st                             }                    in lexLoop newSt (tok : acc)             _ -> lexIndStringLiteral st acc@@ -381,6 +455,11 @@             }       Just (c, rest) -> case c of         '"' -> finishChunk st builder+        -- \$$ is two literal dollars (maximal munch): the second $ cannot begin an+        -- interpolation, so $${ does not interpolate (documented Nix behavior).+        '$'+          | Just '$' <- safeHead rest ->+              go (advanceCol 2 st {lsInput = T.drop 1 rest}) (builder <> TB.singleton '$' <> TB.singleton '$')         '$' | Just '{' <- safeHead rest -> finishChunk st builder         '\\' -> case T.uncons rest of           Just (ec, rest2) ->@@ -391,7 +470,8 @@                   '\\' -> TB.singleton '\\'                   '"' -> TB.singleton '"'                   '$' -> TB.singleton '$'-                  _ -> TB.singleton '\\' <> TB.singleton ec+                  -- Unknown escape: Nix drops the backslash (\q -> q).+                  _ -> TB.singleton ec                 newSt = advanceBy ec (advanceCol 1 st {lsInput = rest2})              in go newSt (builder <> escaped)           Nothing ->@@ -405,6 +485,17 @@         '\n' ->           let newSt = st {lsInput = rest, lsLine = lsLine st + 1, lsCol = 1}            in go newSt (builder <> TB.singleton '\n')+        -- Raw CR and CRLF normalize to LF, matching upstream unescapeStr+        -- (lexer.l).  Only double-quoted strings do this: indented-string+        -- chunks bypass unescapeStr upstream and keep CR verbatim.  An+        -- ESCAPED CR (backslash before it) stays literal via the escape+        -- branch above, also matching upstream.+        '\r' ->+          let afterEol = case T.uncons rest of+                Just ('\n', afterCrlf) -> afterCrlf+                _ -> rest+              newSt = st {lsInput = afterEol, lsLine = lsLine st + 1, lsCol = 1}+           in go newSt (builder <> TB.singleton '\n')         _ ->           let newSt = advanceCol 1 st {lsInput = rest}            in go newSt (builder <> TB.singleton c)@@ -421,6 +512,11 @@ -- No escape sequences here (those are handled by 'lexIndStringMode'), -- so the chunk is identical to the source text - count chars, then slice -- once at the end.  This avoids O(n^2) 'T.snoc' allocation.+--+-- Raw CR\/CRLF is deliberately NOT normalized here: upstream's indented+-- string chunks bypass unescapeStr (lexer.l), and stripIndentation treats+-- CR as ordinary content, so only double-quoted strings normalize line+-- endings. lexIndStringLiteral :: LexState -> [Located] -> Either ParseError [Located] lexIndStringLiteral st0 acc = go st0 0   where@@ -441,6 +537,11 @@                   | Just ('\'', _) <- T.uncons rest1 ->                       finishChunk st consumed                 Just ('$', rest1)+                  | Just ('$', _) <- T.uncons rest1 ->+                      -- \$$ is two literal dollars; the second cannot begin an+                      -- interpolation (matches lexStringLiteral and Nix).+                      go (advanceCol 2 st {lsInput = T.drop 1 rest1}) (consumed + 2)+                Just ('$', rest1)                   | Just ('{', _) <- T.uncons rest1 ->                       finishChunk st consumed                 Just ('\n', rest1) ->@@ -473,20 +574,23 @@   let (digits, after) = T.span isDigit (lsInput st)       len = T.length digits    in case T.uncons after of-        Just ('.', after2)-          | Just (d, _) <- T.uncons after2,-            isDigit d ->-              let (decimals, after3) = T.span isDigit after2-                  (expVal, after4, expLen) = lexExponent after3-                  fullLen = T.length digits + 1 + T.length decimals + expLen-                  val = applyExponent (readDouble digits decimals) expVal-                  tok = Located (lsLine st) (lsCol st) (TokFloat val)-                  newSt = advanceCol fullLen st {lsInput = after4}-               in lexNormalMode newSt (tok : acc)+        -- A '.' after the integer part starts a float even with no fractional+        -- digits or with only an exponent - Nix's grammar is [0-9]+\.[0-9]*(exp)?,+        -- so 12. and 12.e5 are floats, not an integer followed by a dot.+        Just ('.', after2) ->+          let (decimals, after3) = T.span isDigit after2+              (expVal, after4, expLen) = lexExponent after3+              fullLen = T.length digits + 1 + T.length decimals + expLen+              val = readDouble digits decimals expVal+              tok = Located (lsLine st) (lsCol st) (TokFloat val)+              newSt = advanceCol fullLen st {lsInput = after4}+           in lexNormalMode newSt (tok : acc)         _           -- C++ Nix rejects out-of-range integer literals rather than-          -- silently wrapping modulo 2^64.-          | readInteger digits > toInteger (maxBound :: Int64) ->+          -- silently wrapping modulo 2^64.  Range is decided by digit+          -- count first: 'readInteger' is quadratic in the digit count,+          -- and the guard must not pay that on input it rejects.+          | not (integerLiteralInRange digits) ->               Left                 ParseError                   { peFile = lsFile st,@@ -504,15 +608,84 @@ readInteger :: Text -> Integer readInteger = T.foldl' (\n c -> n * decimalBase + fromIntegral (fromEnum c - zeroOrd)) 0 --- | Read a floating-point number from its integer and decimal parts.--- Total - no 'read', no exceptions.-readDouble :: Text -> Text -> Double-readDouble intPart decPart =-  let whole = fromIntegral (readInteger intPart) :: Double-      fracDigits = T.length decPart-      frac = fromIntegral (readInteger decPart) / (decimalBase' ^ fracDigits)-   in whole + frac+-- | Digit count of @maxBound :: Int64@ (9223372036854775807).  A literal+-- with more significant digits is out of range on count alone.+int64MaxDigits :: Int+int64MaxDigits = 19 +-- | Whether an all-digit literal fits 'Int64', decided by significant+-- digit count before any bignum is built.+integerLiteralInRange :: Text -> Bool+integerLiteralInRange digits =+  let significant = T.dropWhile (== '0') digits+      count = T.length significant+   in count < int64MaxDigits+        || (count == int64MaxDigits && readInteger significant <= toInteger (maxBound :: Int64))++-- | Read a floating-point literal from its integer, decimal, and exponent+-- parts.  The digits form one exact 'Rational' scaled by the exponent, and+-- 'fromRational' rounds once - the correctly-rounded conversion C++ Nix+-- gets from strtod (lexer.l float rule).  Rounding the whole, fraction,+-- and exponent steps separately drifts an ulp from upstream on long+-- literals and flushes subnormals (e.g. 1.0e-320) to zero.  Total - no+-- 'read', no exceptions.+--+-- The exact path runs only within double's decimal range: @10 ^^ scale@+-- materializes a bignum of |scale| digits, so the cost must follow the+-- literal's length, never the exponent's magnitude (@1.0e999999999@+-- would otherwise build a gigabyte of Rational).  A scale provably past+-- the overflow bound saturates to Infinity and past the underflow bound+-- to 0.0 - the values strtod rounds such literals to.  Mantissas keep+-- 'mantissaDigitBound' significant digits, a dropped nonzero tail+-- standing in as one sticky digit; past that bound the tail cannot+-- change the correctly-rounded result.+readDouble :: Text -> Text -> Integer -> Double+readDouble intPart decPart expVal+  | T.null significantAll = 0.0+  | overflows = positiveInfinity+  | underflows = 0.0+  | otherwise = fromRational (toRational mantissa * fromInteger decimalBase ^^ scale)+  where+    significantAll = T.dropWhile (== '0') (intPart <> decPart)+    truncated = T.length significantAll > mantissaDigitBound+    (keptDigits, droppedTail) = T.splitAt mantissaDigitBound significantAll+    stickyDigit = if T.any (/= '0') droppedTail then "1" else "0"+    mantissaText = if truncated then keptDigits <> stickyDigit else significantAll+    mantissa = readInteger mantissaText+    -- Each dropped tail digit shifts the represented value's scale up by+    -- one; the appended sticky digit takes the place of the last one.+    droppedCount = if truncated then T.length droppedTail - 1 else 0+    scale = expVal - toInteger (T.length decPart) + toInteger droppedCount+    -- mantissa has exactly digitCount digits (leading digit nonzero), so+    -- the value lies in [10^(digitCount-1+scale), 10^(digitCount+scale)).+    digitCount = toInteger (T.length mantissaText)+    overflows = digitCount - 1 + scale >= doubleOverflowExp10+    underflows = digitCount + scale <= doubleUnderflowExp10++-- | Significant decimal digits kept of a float mantissa.  Correctly+-- rounding binary64 never needs more than 767 significant digits (the+-- longest exactly-representable double and every rounding midpoint fit+-- in 767), so with one sticky digit for the dropped tail, 768 kept+-- digits decide every rounding exactly as the full literal would.+mantissaDigitBound :: Int+mantissaDigitBound = 768++-- | Any value at or above 10^309 exceeds double's maximum (~1.798e308)+-- and rounds to Infinity.+doubleOverflowExp10 :: Integer+doubleOverflowExp10 = 309++-- | Any value below 10^-324 is under half the smallest denormal+-- (~4.94e-324) and rounds to 0.0.+doubleUnderflowExp10 :: Integer+doubleUnderflowExp10 = -324++-- | IEEE positive infinity, strtod's overflow result.  Float literals+-- are unsigned at the lexer (minus is an operator), so only the+-- positive infinity is ever produced.+positiveInfinity :: Double+positiveInfinity = 1 / 0+ -- | Consume an optional exponent @[eE][+-]?[0-9]+@ after a float's digits. -- Returns the signed exponent, the remaining input, and the characters -- consumed.  An @e@ not followed by digits is not an exponent (consumes 0), so@@ -529,13 +702,32 @@               (expDigits, afterExp) = T.span isDigit afterSign            in if T.null expDigits                 then (0, input, 0)-                else (sign * readInteger expDigits, afterExp, 1 + signLen + T.length expDigits)+                else (sign * exponentMagnitude expDigits, afterExp, 1 + signLen + T.length expDigits)     _ -> (0, input, 0) --- | Scale a float mantissa by @10 ^^ exp@ (exp may be negative).-applyExponent :: Double -> Integer -> Double-applyExponent mantissa expVal = mantissa * (10 ^^ expVal)+-- | The magnitude of an exponent's digit run.  Reading n digits builds+-- an n-digit bignum quadratically, so runs past 'exponentDigitBound'+-- saturate to a stand-in already so far outside double's range that+-- 'readDouble' collapses it to the same Infinity or 0.0 the exact+-- exponent would round to.+exponentMagnitude :: Text -> Integer+exponentMagnitude expDigits =+  let significant = T.dropWhile (== '0') expDigits+   in if T.length significant > exponentDigitBound+        then saturatedExponent+        else readInteger significant +-- | Exponent digit runs past this bound saturate (see+-- 'exponentMagnitude').+exponentDigitBound :: Int+exponentDigitBound = 18++-- | Stand-in exponent magnitude past 'exponentDigitBound': any exponent+-- with more significant digits is at least 10^18, and double's whole+-- decimal range spans only around 10^+-324.+saturatedExponent :: Integer+saturatedExponent = 10 ^ (18 :: Int)+ -- | Lex a leading-dot float like @.5@ or @.5e3@ (Nix's @0?\\.[0-9]+@ form). -- The current input begins with the @.@. lexLeadingDotFloat :: LexState -> [Located] -> Either ParseError [Located]@@ -544,7 +736,7 @@       (decimals, after3) = T.span isDigit afterDot       (expVal, after4, expLen) = lexExponent after3       fullLen = 1 + T.length decimals + expLen-      val = applyExponent (readDouble "" decimals) expVal+      val = readDouble "" decimals expVal       tok = Located (lsLine st) (lsCol st) (TokFloat val)       newSt = advanceCol fullLen st {lsInput = after4}    in lexNormalMode newSt (tok : acc)@@ -553,10 +745,6 @@ decimalBase :: Integer decimalBase = 10 --- | Floating-point decimal base for fraction computation.-decimalBase' :: Double-decimalBase' = 10.0- -- | Ordinal of ASCII @\'0\'@ for digit-to-int conversion. zeroOrd :: Int zeroOrd = fromEnum '0'@@ -567,22 +755,51 @@  lexIdentOrKeyword :: LexState -> [Located] -> Either ParseError [Located] lexIdentOrKeyword st acc =-  let (ident, after) = T.span isIdentChar (lsInput st)-      len = T.length ident-   in -- Check for URI: identifier followed by ://-      case T.stripPrefix "://" after of-        Just afterScheme ->-          let (uriRest, afterUri) = T.span isUriChar afterScheme-              full = ident <> "://" <> uriRest-              fullLen = T.length full-              tok = Located (lsLine st) (lsCol st) (TokUri full)-              newSt = advanceCol fullLen st {lsInput = afterUri}-           in lexNormalMode newSt (tok : acc)-        Nothing ->+  let input = lsInput st+      (ident, after) = T.span isIdentChar input+      (uriReading, advancedFloor) = uriSpanFrom (lsNoColonFloor st) input+   in case uriReading of+        -- Flex maximal munch: the URI rule beats identifiers AND keywords+        -- whenever it matches more characters, so @x:y@ is the URI "x:y"+        -- (the classic reason the identity function must be written+        -- @x: x@) and @mailto:a\@b.com@ needs no @//@.+        Just (uri, afterUri)+          | T.length uri > T.length ident ->+              let tok = Located (lsLine st) (lsCol st) (TokUri uri)+                  newSt = advanceCol (T.length uri) st {lsInput = afterUri}+               in lexNormalMode newSt (tok : acc)+        _ ->           let tok = Located (lsLine st) (lsCol st) (identToToken ident)-              newSt = advanceCol len st {lsInput = after}+              newSt = advanceCol (T.length ident) st {lsInput = after, lsNoColonFloor = advancedFloor}            in lexNormalMode newSt (tok : acc) +-- | Match upstream's URI token at the start of the input:+-- @[a-zA-Z][a-zA-Z0-9+.-]*:[uri-char]+@ (lexer.l).  The scheme starts+-- with a letter (never @_@), and one URI char after the colon suffices -+-- scheme-only URIs like @mailto:x@ count.  Returns the URI text and the+-- remaining input.+--+-- Threads the 'lsNoColonFloor' watermark: when the scheme-char run ends+-- at a NON-colon, no position inside that run can start a URI (a suffix+-- of the run spans to the same terminator), so the run's end is recorded+-- and later positions inside it answer Nothing in O(1).  A run ending at+-- @:@ records nothing - a shorter suffix of it may itself be a URI.+uriSpanFrom :: Int -> Text -> (Maybe (Text, Text), Int)+uriSpanFrom noColonFloor input+  | lengthWord8 input > noColonFloor = (Nothing, noColonFloor)+  | otherwise = case T.uncons input of+      Just (schemeStart, _)+        | isAlpha schemeStart ->+            let (scheme, afterScheme) = T.span isSchemeChar input+             in case T.stripPrefix ":" afterScheme of+                  Just afterColon+                    | (body, afterUri) <- T.span isUriChar afterColon,+                      not (T.null body) ->+                        (Just (scheme <> ":" <> body, afterUri), noColonFloor)+                  Just _ -> (Nothing, noColonFloor)+                  Nothing -> (Nothing, lengthWord8 input - lengthWord8 scheme)+      _ -> (Nothing, noColonFloor)+ identToToken :: Text -> Token identToToken "if" = TokIf identToToken "then" = TokThen@@ -606,10 +823,78 @@ lexPath st acc =   let (pathText, after) = T.span isPathChar (lsInput st)       len = T.length pathText-      tok = Located (lsLine st) (lsCol st) (TokPath pathText)       newSt = advanceCol len st {lsInput = after}-   in lexNormalMode newSt (tok : acc)+   in if "${" `T.isPrefixOf` after+        then+          -- The literal continues through an interpolation: the head+          -- piece opens and 'lexPathMode' carries on, upstream's+          -- INPATH machinery.+          let tok = Located (lsLine st) (lsCol st) (TokPathInterpStart pathText)+           in lexPathMode (newSt {lsModes = ModePath : lsModes newSt}) (tok : acc)+        else+          if T.isSuffixOf "/" pathText+            then pathTrailingSlashError st+            else+              let tok = Located (lsLine st) (lsCol st) (TokPath pathText)+               in lexNormalMode newSt (tok : acc) +-- | Inside an interpolated path literal, after the head piece: literal+-- chunks, @${@ interpolations, and the synthesized end.  Mirrors the+-- string modes, with two path-specific rules from upstream's lexer: any+-- non-path character ends the literal (there is no closing delimiter),+-- and ending while the last piece ends in @/@ is the "path has a+-- trailing slash" parse error.+lexPathMode :: LexState -> [Located] -> Either ParseError [Located]+lexPathMode st acc+  | "${" `T.isPrefixOf` lsInput st =+      let tok = Located (lsLine st) (lsCol st) TokInterpOpen+          newSt =+            advanceCol+              2+              st+                { lsInput = T.drop 2 (lsInput st),+                  lsModes = ModeNormal : lsModes st,+                  -- Fresh count for the interpolation body; the+                  -- enclosing context's count is restored at the+                  -- matching TokInterpClose.+                  lsBraceDepth = 0,+                  lsBraceStack = lsBraceDepth st : lsBraceStack st+                }+       in lexLoop newSt (tok : acc)+  | otherwise =+      let (chunk, after) = T.span isPathChar (lsInput st)+          len = T.length chunk+          newSt = advanceCol len st {lsInput = after}+          endTok = Located (lsLine newSt) (lsCol newSt) TokPathEnd+          popped = newSt {lsModes = safeTail (lsModes newSt)}+       in if T.null chunk+            then -- The character after an interpolation is not a path+            -- character: the literal ends right there.+              lexNormalMode popped (endTok : acc)+            else+              if "${" `T.isPrefixOf` after+                then+                  let tok = Located (lsLine st) (lsCol st) (TokPathLit chunk)+                   in lexPathMode newSt (tok : acc)+                else+                  if T.isSuffixOf "/" chunk+                    then pathTrailingSlashError st+                    else+                      let tok = Located (lsLine st) (lsCol st) (TokPathLit chunk)+                       in lexNormalMode popped (endTok : tok : acc)++-- | Upstream's INPATH_SLASH error, verbatim: a path literal may not end+-- with @/@, whether plain or after an interpolation.+pathTrailingSlashError :: LexState -> Either ParseError [Located]+pathTrailingSlashError st =+  Left+    ParseError+      { peFile = lsFile st,+        peLine = lsLine st,+        peCol = lsCol st,+        peMessage = "path has a trailing slash"+      }+ lexSearchPath :: LexState -> [Located] -> Either ParseError [Located] lexSearchPath st acc =   -- st is at '<', skip it@@ -677,18 +962,48 @@ -- as a path rather than an identifier, number, or division operator - -- matching Nix, where @a/b@ and @/abs/path@ are paths, @a / b@ (slash -- surrounded by whitespace) is division, and @a // b@ is the update operator.-looksLikePath :: Text -> Bool-looksLikePath input =-  case T.break (== '/') (T.takeWhile isPathChar input) of-    (_, slashAndRest) -> case T.uncons (T.drop 1 slashAndRest) of-      Just (afterSlash, _) -> isPathChar afterSlash && afterSlash /= '/'-      Nothing -> False+--+-- Threads the 'lsNoSlashFloor' watermark: a position inside an+-- already-scanned SLASH-FREE run answers False in O(1), and a freshly+-- scanned slash-free run records its end (no position within it can start+-- a path, since the run's characters and its terminator are the same+-- bytes every later check would rescan).  A run that CONTAINS a slash+-- records nothing: a later position inside it can legitimately answer+-- differently (@a//b.c/d@ is update-then-path).  'lengthWord8' is the+-- O(1) position measure; byte counts, so it is monotone under suffixing.+looksLikePathFrom :: Int -> Text -> (Bool, Int)+looksLikePathFrom noSlashFloor input+  | lengthWord8 input > noSlashFloor = (False, noSlashFloor)+  | otherwise =+      let run = T.takeWhile isPathChar input+          (_, slashAndRest) = T.break (== '/') run+          -- Upstream's PATH_SEG rule: a run ending in @/@ immediately+          -- followed by @${@ opens an interpolated path (@./${v}@,+          -- @/${v}@, @a/${v}@), even though the slash has no segment+          -- after it yet - the segment arrives at eval.  Such runs+          -- contain a slash, so the slash-free watermark is untouched.+          interpFollows =+            T.isSuffixOf "/" run+              && "${" `T.isPrefixOf` T.drop (T.length run) input+       in if T.null slashAndRest+            then (False, lengthWord8 input - lengthWord8 run)+            else case T.uncons (T.drop 1 slashAndRest) of+              Just (afterSlash, _) -> (interpFollows || (isPathChar afterSlash && afterSlash /= '/'), noSlashFloor)+              Nothing -> (interpFollows, noSlashFloor)  isSearchPathChar :: Char -> Bool isSearchPathChar c = isAlphaNum c || c `elem` ("/.~_-+" :: [Char]) +-- | Chars allowed in a URI scheme after the leading letter, per upstream+-- lexer.l: @[a-zA-Z][a-zA-Z0-9+.-]*@.+isSchemeChar :: Char -> Bool+isSchemeChar c = isAlphaNum c || c `elem` ("+.-" :: [Char])++-- | Chars allowed after the scheme colon, exactly upstream lexer.l's URI+-- class @[a-zA-Z0-9%\/?:\@&=+$,-_.!~*']@.  Notably @#@ is NOT a URI char+-- (it starts a comment mid-URI upstream), while @*@ and @'@ are. isUriChar :: Char -> Bool-isUriChar c = isAlphaNum c || c `elem` ("%/?:@&=+$,#._~!-" :: [Char])+isUriChar c = isAlphaNum c || c `elem` ("%/?:@&=+$,-_.!~*'" :: [Char])  -- --------------------------------------------------------------------------- -- Emit helpers
src/Nix/Push.hs view
@@ -25,13 +25,17 @@ -- -- == Compression ----- Uploads use @Compression: none@: the substituter round-trips it on every--- platform today (XZ support is gated behind nova-cache's @compression@--- flag, which is off on Windows), and the dominant seed payloads are--- already-compressed source tarballs.+-- Uploads default to @Compression: none@ - the cache's existing+-- content is uncompressed, and mixing artifact kinds is an operator+-- decision, not a default flip.  'PushZstd' compresses each NAR with+-- nova-cache's zstandard encoder before upload: the narinfo's file+-- fields describe the compressed artifact, its object name carries+-- the compressed file hash, and the substituter decompresses under+-- the declared NarSize bound. module Nix.Push   ( -- * Configuration     PushConfig (..),+    PushCompression (..),     PushSummary (..),      -- * Pushing@@ -40,16 +44,22 @@     loadApiKeyFile,      -- * Pure pieces (exported for tests)+    parsePushCompression,+    pushCompressionValues,     mkNarInfo,+    mkPushArtifact,+    PushArtifact (..),     planMissing,     narFileName,     stripHashPrefix,     storePathBasename,+    checkRecordedNarHash,+    narHashMatches,   ) where  import Control.Exception (SomeException, try)-import Control.Monad (forM, forM_, unless, when)+import Control.Monad (foldM, forM, forM_, unless, when) import Control.Monad.Except (ExceptT (..), liftEither, runExceptT, throwError) import Control.Monad.IO.Class (liftIO) import qualified Data.ByteString as BS@@ -63,13 +73,16 @@ import qualified Network.HTTP.Client as HTTP import qualified Network.HTTP.Client.TLS as HTTPS import qualified Network.HTTP.Types as HTTP+import Nix.Compression (compressionNameNone, compressionNameZstd) import Nix.Store (Store (..), queryDeriver, queryPathInfo, queryReferences) import qualified Nix.Store.DB as DB-import Nix.Store.Path (StorePath (..), defaultStoreDir, parseStorePath, storePathToFilePath, storePathToText)+import qualified Nix.Store.ExecBit as ExecBit+import Nix.Store.Path (StorePath (spHash, spName), defaultStoreDir, parseStorePath, storePathToFilePath, storePathToText) import qualified NovaCache.Hash as Hash import qualified NovaCache.NAR as NAR import NovaCache.NarInfo (NarInfo (..), parseNarInfo, renderNarInfo) import NovaCache.Signing (normalizeKeyText)+import qualified NovaCache.Zstd as Zstd import System.IO (stderr)  -- ---------------------------------------------------------------------------@@ -92,9 +105,9 @@ narInfoExtension :: Text narInfoExtension = ".narinfo" --- | The narinfo @Compression@ value used for uploads.-compressionNone :: Text-compressionNone = "none"+-- | Extension for zstd-compressed NAR objects.+narZstExtension :: Text+narZstExtension = ".nar.zst"  -- | Authorization scheme prefix for the API key. bearerPrefix :: BS.ByteString@@ -108,13 +121,42 @@ -- Configuration and results -- --------------------------------------------------------------------------- +-- | How each NAR is packaged for upload.  A property of the+-- destination cache, as upstream models compression on binary cache+-- stores - not a per-path whim.+data PushCompression = PushNone | PushZstd+  deriving (Eq, Show)++-- | The accepted @--compression@ spellings, for parser errors and the+-- CLI help line - one rendering of the register in 'Nix.Compression'.+pushCompressionValues :: Text+pushCompressionValues = compressionNameNone <> ", " <> compressionNameZstd++-- | Parse the CLI spelling of a push compression.  The spellings are+-- the narinfo @Compression@ names from 'Nix.Compression', so the CLI+-- vocabulary and the wire vocabulary cannot drift.+parsePushCompression :: Text -> Either Text PushCompression+parsePushCompression name+  | name == compressionNameNone = Right PushNone+  | name == compressionNameZstd = Right PushZstd+  | otherwise =+      Left+        ( "unknown --compression "+            <> name+            <> " (expected: "+            <> pushCompressionValues+            <> ")"+        )+ -- | Where and how to push. data PushConfig = PushConfig   { -- | Cache base URL, no trailing slash (e.g. @https:\/\/cache.example.com@).     pcCacheUrl :: !Text,     -- | Bearer token for authenticated writes.  'Nothing' sends no     -- Authorization header (only useful against an open-writes server).-    pcApiKey :: !(Maybe Text)+    pcApiKey :: !(Maybe Text),+    -- | Artifact packaging for this destination (see 'PushCompression').+    pcCompression :: !PushCompression   }   deriving (Eq, Show) @@ -134,36 +176,47 @@ -- | Read an API key from a file, dropping byte-order marks and surrounding -- whitespace.  Key files written by Windows tooling are a known source of -- BOM contamination; 'normalizeKeyText' makes the transfer byte-clean.+--+-- The bytes are decoded as UTF-8 EXPLICITLY: locale text IO decodes by+-- console code page, which turns the BOM bytes into codepage characters+-- that no normalizer recognizes - on the very consoles the BOM handling+-- exists for. loadApiKeyFile :: FilePath -> IO (Either Text Text) loadApiKeyFile path = do-  attempt <- try (TIO.readFile path)+  attempt <- try (BS.readFile path)   pure $ case attempt of     Left (e :: SomeException) -> Left ("cannot read key file: " <> T.pack (show e))-    Right raw ->-      let key = normalizeKeyText raw-       in if T.null key-            then Left ("key file is empty: " <> T.pack path)-            else Right key+    Right bytes -> case TE.decodeUtf8' bytes of+      Left _ -> Left ("key file is not valid UTF-8: " <> T.pack path)+      Right raw ->+        let key = normalizeKeyText raw+         in if T.null key+              then Left ("key file is empty: " <> T.pack path)+              else Right key  -- --------------------------------------------------------------------------- -- Closure computation -- ---------------------------------------------------------------------------  -- | Compute the full reference closure of the given paths from the store--- database (breadth-first over @Refs@).  Fails if a recorded reference does--- not parse as a store path - that would mean a corrupt database, and--- pushing a closure with holes would poison the cache.+-- database, in reverse-topological order: every path's references precede+-- it (postorder over @Refs@; a self-reference or already-visited path is+-- skipped).  Publishing narinfos in this order never announces a path+-- whose references are not yet visible.  Fails if a recorded reference+-- does not parse as a store path - that would mean a corrupt database,+-- and pushing a closure with holes would poison the cache. computeClosure :: Store -> [StorePath] -> IO (Either Text [StorePath])-computeClosure store roots = runExceptT (go Set.empty [] roots)+computeClosure store roots =+  runExceptT (reverse . snd <$> foldM visit (Set.empty, []) roots)   where-    go :: Set Text -> [StorePath] -> [StorePath] -> ExceptT Text IO [StorePath]-    go _ acc [] = pure (reverse acc)-    go seen acc (sp : rest)-      | spHash sp `Set.member` seen = go seen acc rest+    visit :: (Set Text, [StorePath]) -> StorePath -> ExceptT Text IO (Set Text, [StorePath])+    visit (seen, acc) sp+      | spHash sp `Set.member` seen = pure (seen, acc)       | otherwise = do           refTexts <- liftIO (queryReferences (stDB store) sp)           refs <- liftEither (traverse parseRef refTexts)-          go (Set.insert (spHash sp) seen) (sp : acc) (refs ++ rest)+          (seenAfterRefs, accAfterRefs) <- foldM visit (Set.insert (spHash sp) seen, acc) refs+          pure (seenAfterRefs, sp : accAfterRefs)     parseRef txt = case parseStorePath (stDir store) txt of       Just sp -> Right sp       Nothing -> Left ("unparseable reference in store DB: " <> txt)@@ -191,21 +244,69 @@ narFileName :: Text -> Text narFileName narHash = stripHashPrefix narHash <> narExtension --- | Construct the narinfo describing a store path.------ With @Compression: none@ the file fields equal the NAR fields.  The--- @StorePath@ uses the canonical @\/nix\/store@ form; references and the--- deriver are basenames, matching the wire format.-mkNarInfo :: StorePath -> Text -> Int -> [StorePath] -> Maybe StorePath -> NarInfo-mkNarInfo sp narHash narSize refs deriver =+-- | The uploadable artifact for one NAR under the configured+-- compression: the bytes the cache stores, the file fields the+-- narinfo declares, and the object name.  With 'PushNone' every field+-- equals the NAR's own, byte-identical to what this module always+-- pushed.+data PushArtifact = PushArtifact+  { paBytes :: !BS.ByteString,+    paFileHash :: !Text,+    paFileSize :: !Int,+    paCompressionText :: !Text,+    paObjectName :: !Text,+    -- | The source NAR's hash and byte count, recorded at packaging+    -- time: a narinfo built from this artifact can only ever describe+    -- one byte stream, so the NAR fields and file fields cannot be+    -- paired wrongly by a caller.+    paNarHash :: !Text,+    paNarSize :: !Int+  }+  deriving (Eq, Show)++-- | Package one NAR for upload.  The zstd object is named by its own+-- (compressed) file hash, the convention the public caches follow.+mkPushArtifact :: PushCompression -> Text -> BS.ByteString -> PushArtifact+mkPushArtifact compression narHash narBytes = case compression of+  PushNone ->+    PushArtifact+      { paBytes = narBytes,+        paFileHash = narHash,+        paFileSize = BS.length narBytes,+        paCompressionText = compressionNameNone,+        paObjectName = narFileName narHash,+        paNarHash = narHash,+        paNarSize = BS.length narBytes+      }+  PushZstd ->+    let compressed = Zstd.compress Zstd.defaultCompressionLevel narBytes+        fileHash = Hash.formatNixHash (Hash.hashBytes compressed)+     in PushArtifact+          { paBytes = compressed,+            paFileHash = fileHash,+            paFileSize = BS.length compressed,+            paCompressionText = compressionNameZstd,+            paObjectName = stripHashPrefix fileHash <> narZstExtension,+            paNarHash = narHash,+            paNarSize = BS.length narBytes+          }++-- | Construct the narinfo describing a store path.  Every artifact+-- and NAR field - file hash, file size, compression, object name, NAR+-- hash, NAR size - comes from the one 'PushArtifact', so the narinfo+-- is internally consistent by construction.  The @StorePath@ uses+-- the canonical @\/nix\/store@ form; references and the deriver are+-- basenames, matching the wire format.+mkNarInfo :: PushArtifact -> StorePath -> [StorePath] -> Maybe StorePath -> NarInfo+mkNarInfo artifact sp refs deriver =   NarInfo     { niStorePath = storePathToText defaultStoreDir sp,-      niUrl = narDirSegment <> "/" <> narFileName narHash,-      niCompression = compressionNone,-      niFileHash = narHash,-      niFileSize = fromIntegral narSize,-      niNarHash = narHash,-      niNarSize = fromIntegral narSize,+      niUrl = narDirSegment <> "/" <> paObjectName artifact,+      niCompression = paCompressionText artifact,+      niFileHash = Just (paFileHash artifact),+      niFileSize = Just (fromIntegral (paFileSize artifact)),+      niNarHash = paNarHash artifact,+      niNarSize = fromIntegral (paNarSize artifact),       niReferences = sort (map storePathBasename refs),       niDeriver = storePathBasename <$> deriver,       niSigs = [],@@ -242,7 +343,9 @@       pairs <- forM missing $ \sp -> do         narInfo <- uploadNar manager cfg store sp         pure (sp, narInfo)-      -- Phase 2: narinfos, only after every NAR is in place.+      -- Phase 2: narinfos, only after every NAR is in place, in the+      -- closure's deps-first order: a freshly announced path's references+      -- are always already announced.       forM_ pairs $ \(sp, narInfo) -> do         uploadNarInfo manager cfg sp narInfo         logLine ("[push]  " <> storePathBasename sp)@@ -260,11 +363,13 @@   HTTP.newManager     HTTPS.tlsManagerSettings {HTTP.managerResponseTimeout = HTTP.responseTimeoutNone} --- | Fetch the set of narinfo hashes the cache already stores.+-- | Fetch the set of narinfo hashes the cache already stores.  The+-- endpoint is push-tool plumbing and the server requires the write key+-- for it (nova-cache 0.5), so the request authenticates like the PUTs. fetchRemoteHashes :: HTTP.Manager -> PushConfig -> ExceptT Text IO (Set Text) fetchRemoteHashes manager cfg = do   let url = pcCacheUrl cfg <> "/" <> narinfoHashesEndpoint-  response <- httpRequest manager "GET" url [] Nothing+  response <- httpRequest manager "GET" url (authHeaders cfg) Nothing   let code = HTTP.statusCode (HTTP.responseStatus response)   unless (code == httpStatusOk) $     throwError ("GET " <> narinfoHashesEndpoint <> " returned HTTP " <> T.pack (show code))@@ -276,30 +381,18 @@ uploadNar :: HTTP.Manager -> PushConfig -> Store -> StorePath -> ExceptT Text IO NarInfo uploadNar manager cfg store sp = do   let physicalPath = storePathToFilePath (stDir store) sp-  narEntry <- liftIO (NAR.serialiseFromPath physicalPath)+  narEntry <- liftIO (ExecBit.serialiseFromPath physicalPath)   let narBytes = NAR.serialise narEntry       narHash = Hash.formatNixHash (Hash.hashBytes narBytes)       narSize = BS.length narBytes-  -- The database recorded this path's NAR hash at registration; a mismatch-  -- now means the path changed on disk - refuse to publish corruption.   recorded <- liftIO (queryPathInfo (stDB store) sp)-  case recorded of-    Just info-      | DB.piNarHash info /= narHash ->-          throwError-            ( "store integrity: "-                <> storePathBasename sp-                <> " hashes to "-                <> narHash-                <> " but the DB recorded "-                <> DB.piNarHash info-            )-    _ -> pure ()+  liftEither (checkRecordedNarHash recorded narHash sp)   refTexts <- liftIO (queryReferences (stDB store) sp)   refs <- liftEither (traverse (parseRefText store) refTexts)   deriverText <- liftIO (queryDeriver (stDB store) sp)   let deriver = deriverText >>= parseStorePath (stDir store)-      narInfo = mkNarInfo sp narHash narSize refs deriver+      artifact = mkPushArtifact (pcCompression cfg) narHash narBytes+      narInfo = mkNarInfo artifact sp refs deriver       url = pcCacheUrl cfg <> "/" <> niUrl narInfo   logLine     ( "[nar]   "@@ -308,9 +401,46 @@         <> T.pack (show narSize)         <> " bytes)"     )-  response <- httpRequest manager "PUT" url (authHeaders cfg) (Just narBytes)+  response <- httpRequest manager "PUT" url (authHeaders cfg) (Just (paBytes artifact))   expectOk ("PUT " <> niUrl narInfo) response   pure narInfo++-- | Pre-upload integrity gate.  The store DB recorded the path's NAR hash+-- at registration; a mismatch now means the path changed on disk - refuse+-- to publish corruption.  An on-disk path with NO registration (an+-- interrupted build) is refused too: its references are unknown, so+-- publishing would fabricate an empty-reference narinfo and serve a+-- closure with holes.+checkRecordedNarHash :: Maybe DB.PathInfo -> Text -> StorePath -> Either Text ()+checkRecordedNarHash recorded narHash sp = case recorded of+  Nothing ->+    Left+      ( "store integrity: "+          <> storePathBasename sp+          <> " is on disk but not registered as valid; refusing to publish it with unknown references"+      )+  Just info+    | not (narHashMatches (DB.piNarHash info) narHash) ->+        Left+          ( "store integrity: "+              <> storePathBasename sp+              <> " hashes to "+              <> narHash+              <> " but the DB recorded "+              <> DB.piNarHash info+          )+    | otherwise -> Right ()++-- | Recorded and computed NAR hashes match when their decoded digests+-- agree, so any valid spelling of one digest matches (a foreign cache+-- may record base16 where local hashing renders nix-base32).  A+-- recorded value no spelling parses falls back to exact text equality,+-- keeping legacy rows comparable rather than un-checkable.+narHashMatches :: Text -> Text -> Bool+narHashMatches recorded computed =+  case (Hash.parseNixHash recorded, Hash.parseNixHash computed) of+    (Right a, Right b) -> a == b+    _ -> recorded == computed  -- | Upload a rendered narinfo (the server validates and signs it). uploadNarInfo :: HTTP.Manager -> PushConfig -> StorePath -> NarInfo -> ExceptT Text IO ()
src/Nix/Store.hs view
@@ -40,285 +40,1106 @@     isValid,     pathExists, -    -- * Store operations-    addToStore,-    placeInStore,-    registrationFor,-    scanReferences,-    scanTempReferences,-    setReadOnly,-    writeDrv,-    writeDrvAterm,--    -- * Re-exports-    module Nix.Store.Path,-    module Nix.Store.DB,-  )-where--import Control.Exception (IOException, catch)-import Control.Monad (when)-import qualified Data.ByteString as BS-import Data.Set (Set)-import qualified Data.Set as Set-import Data.Text (Text)-import qualified Data.Text as T-import qualified Data.Text.Encoding as TE-import qualified Data.Text.IO as TIO-import Nix.Derivation (Derivation, toATerm)-import Nix.Store.DB-import Nix.Store.Path-import qualified NovaCache.Hash as Hash-import qualified NovaCache.NAR as NAR-import System.Directory-  ( copyFile,-    createDirectoryIfMissing,-    doesDirectoryExist,-    doesFileExist,-    doesPathExist,-    listDirectory,-    removeDirectoryRecursive,-    removeFile,-    renamePath,-    setPermissions,-  )-import qualified System.Directory as Dir-import System.FilePath ((</>))---- | An open store with database and configuration.-data Store = Store-  { stDir :: !StoreDir,-    stDB :: !StoreDB-  }---- | Open a Nix store at the given directory.--- Creates the store directory and database if they don't exist.-openStore :: StoreDir -> IO Store-openStore dir = do-  createDirectoryIfMissing True (unStoreDir dir)-  db <- openStoreDB dir-  pure Store {stDir = dir, stDB = db}---- | Close the store (flushes the database).-closeStore :: Store -> IO ()-closeStore = closeStoreDB . stDB---- | Check if a store path is registered as valid in the database.-isValid :: Store -> StorePath -> IO Bool-isValid = isValidPath . stDB---- | Check if a store path exists on disk (file or directory, regardless of DB).-pathExists :: Store -> StorePath -> IO Bool-pathExists store sp = doesPathExist (storePathToFilePath (stDir store) sp)---- ------------------------------------------------------------------------------ Store operations--- ------------------------------------------------------------------------------- | Move a build output (file or directory) to the store path, set read-only,--- and register.------ If @renamePath@ fails (cross-device move), falls back to copy + remove.-addToStore ::-  Store ->-  FilePath ->-  StorePath ->-  Maybe Text ->-  [StorePath] ->-  IO ()-addToStore store srcPath sp deriver refs = do-  reg <- placeInStore store srcPath sp deriver refs-  registerPath (stDB store) reg---- | Move a build output into the store (read-only) and compute its--- registration (NAR hash, size, references) WITHOUT writing to the database.------ Splitting placement from registration lets a multi-output build place every--- output first and then register them together, so intra-derivation--- cross-output references are preserved (see 'registerPaths').-placeInStore ::-  Store ->-  FilePath ->-  StorePath ->-  Maybe Text ->-  [StorePath] ->-  IO PathRegistration-placeInStore store srcPath sp deriver refs = do-  let destPath = storePathToFilePath (stDir store) sp-  -- Move (or copy) source to store-  moveOutput srcPath destPath-  -- Set read-only permissions-  setReadOnly destPath-  registrationFor store sp deriver refs---- | Compute the registration metadata for a store path already present on--- disk, without moving anything.  Used to (re-)register an output that an--- interrupted build left in place but never recorded in the database.-registrationFor :: Store -> StorePath -> Maybe Text -> [StorePath] -> IO PathRegistration-registrationFor store sp deriver refs = do-  let destPath = storePathToFilePath (stDir store) sp-  -- Compute the NAR hash and size of the final store contents.  The NAR-  -- serialization is canonical (entries sorted, 8-byte padding), so this is-  -- exactly the NarHash/NarSize a binary cache reports for the path.-  narEntry <- NAR.serialiseFromPath destPath-  let narBytes = NAR.serialise narEntry-  pure-    PathRegistration-      { prPath = sp,-        prNarHash = Hash.formatNixHash (Hash.hashBytes narBytes),-        prNarSize = BS.length narBytes,-        prDeriver = deriver,-        prReferences = refs-      }---- | Cross-device safe move for files or directories.--- Tries 'renamePath' first; on IOException falls back to copy + remove.-moveOutput :: FilePath -> FilePath -> IO ()-moveOutput src dest =-  renamePath src dest `catch` \(_ :: IOException) -> do-    isDir <- doesDirectoryExist src-    if isDir-      then do-        copyDirectoryRecursive src dest-        removeDirectoryRecursive src-      else do-        copyFile src dest-        removeFile src---- | Recursively copy a directory tree.-copyDirectoryRecursive :: FilePath -> FilePath -> IO ()-copyDirectoryRecursive src dest = do-  createDirectoryIfMissing True dest-  entries <- listDirectory src-  mapM_ (copyEntry src dest) entries-  where-    copyEntry srcDir destDir name = do-      let srcPath = srcDir </> name-          destPath = destDir </> name-      isDir <- doesDirectoryExist srcPath-      if isDir-        then copyDirectoryRecursive srcPath destPath-        else copyFile srcPath destPath---- | Byte-scan all files under a directory for store path references.------ Builds a 'Set' of candidate hash strings from the given store paths.--- Walks all regular files, reads each as ByteString, searches for the--- store dir prefix followed by a 32-character hash.  Returns matching--- store paths from the candidate set.-scanReferences :: StoreDir -> [StorePath] -> FilePath -> IO [StorePath]-scanReferences storeDir candidates dir = do-  let candidateSet = Set.fromList [(spHash sp, sp) | sp <- candidates]-      storeDirStr = unStoreDir storeDir-      -- Scan for both forward-slash and backslash store prefixes.  On Windows,-      -- binaries may contain either separator style.  Both separators are a-      -- single ASCII byte, so the two prefixes share a length.-      prefixFwd = TE.encodeUtf8 (T.pack (storeDirStr <> "/"))-      prefixBwd = TE.encodeUtf8 (T.pack (storeDirStr <> "\\"))-      prefixLen = BS.length prefixFwd-  files <- collectRegularFiles dir-  foundHashes <- foldlIO Set.empty files $ \acc filePath -> do-    contents <- BS.readFile filePath-    -- Scan with forward-slash prefix, then backslash (for Windows)-    let acc1 = scanBytes prefixFwd prefixLen storePathHashLen contents acc-    pure (scanBytes prefixBwd prefixLen storePathHashLen contents acc1)-  pure [sp | (h, sp) <- Set.toList candidateSet, Set.member h foundHashes]---- | Scan an output for references to build-temp output locations.------ The builder runs under a temp directory, so an output that embeds its own or--- a sibling output's path embeds the TEMP path - which 'scanReferences' (store--- prefix only) cannot see.  Given @(tempDir, storePath)@ for every output of--- the derivation, returns the store paths whose temp location is referenced--- from the scanned output, capturing self- and cross-output references.------ This records the dependency edge; it does not rewrite the embedded bytes--- (self-reference hash rewriting is a separate, future concern).-scanTempReferences :: [(FilePath, StorePath)] -> FilePath -> IO [StorePath]-scanTempReferences tempPairs dir = do-  let needles = [(TE.encodeUtf8 (T.pack tempDir), sp) | (tempDir, sp) <- tempPairs]-  files <- collectRegularFiles dir-  foundHashes <- foldlIO Set.empty files $ \acc filePath -> do-    contents <- BS.readFile filePath-    pure (Set.union acc (Set.fromList [spHash sp | (needle, sp) <- needles, needle `BS.isInfixOf` contents]))-  pure [sp | (_, sp) <- tempPairs, Set.member (spHash sp) foundHashes]---- | Collect all regular files under a path, recursively.--- If the path is itself a regular file, returns it directly.-collectRegularFiles :: FilePath -> IO [FilePath]-collectRegularFiles path = do-  isDir <- doesDirectoryExist path-  if isDir-    then do-      entries <- listDirectory path-      concat <$> mapM (classifyAndCollect path) entries-    else do-      isFile <- doesFileExist path-      pure [path | isFile]-  where-    classifyAndCollect parent name = do-      let fullPath = parent </> name-      isDir <- doesDirectoryExist fullPath-      if isDir-        then collectRegularFiles fullPath-        else do-          isFile <- doesFileExist fullPath-          pure [fullPath | isFile]---- | Scan a ByteString for store path prefix occurrences, extract hashes.-scanBytes :: BS.ByteString -> Int -> Int -> BS.ByteString -> Set Text -> Set Text-scanBytes prefix prefixLen hashLen bs =-  go 0-  where-    bsLen = BS.length bs-    go !idx !found-      | idx + prefixLen + hashLen > bsLen = found-      | BS.isPrefixOf prefix (BS.drop idx bs) =-          let hashBytes = BS.take hashLen (BS.drop (idx + prefixLen) bs)-           in case TE.decodeUtf8' hashBytes of-                Right hashText -> go (idx + prefixLen + hashLen) (Set.insert hashText found)-                Left _ -> go (idx + 1) found-      | otherwise = go (idx + 1) found---- | Strict left fold over a list in IO.-foldlIO :: a -> [b] -> (a -> b -> IO a) -> IO a-foldlIO z [] _ = pure z-foldlIO z (x : xs) f = do-  acc <- f z x-  foldlIO acc xs f---- | Recursively mark a store path and its contents read-only after a build.------ On Windows the directory read-only attribute does not prevent adding or--- removing entries - only the per-file read-only attribute protects a file.--- Immutability here is therefore enforced at FILE granularity (every file is--- made read-only); hardening the directory itself against entry changes would--- require ACLs and is deferred.-setReadOnly :: FilePath -> IO ()-setReadOnly path = do-  isDir <- doesDirectoryExist path-  if isDir-    then do-      entries <- listDirectory path-      mapM_ (setReadOnly . (path </>)) entries-      perms <- Dir.getPermissions path-      Dir.setPermissions path (Dir.setOwnerWritable False perms)-    else do-      isFile <- doesFileExist path-      when isFile $ do-        perms <- Dir.getPermissions path-        setPermissions path (Dir.setOwnerWritable False perms)---- | Write an already-serialized derivation ATerm to its store path.  Used to--- materialize the input @.drv@ closure (root plus every transitive input)--- before a dependency-aware build: evaluation computes these ATerms but does--- no store IO, so the build driver writes them here.-writeDrvAterm :: Store -> StorePath -> Text -> IO ()-writeDrvAterm store sp aterm = do-  let destPath = storePathToFilePath (stDir store) sp-  createDirectoryIfMissing True (unStoreDir (stDir store))-  TIO.writeFile destPath aterm---- | Serialize a derivation to ATerm and write it to the store at the given path.-writeDrv :: Store -> Derivation -> StorePath -> IO ()-writeDrv store drv sp = writeDrvAterm store sp (toATerm drv)+    -- * Deletion+    DeleteOutcome (..),+    deleteStorePathRaw,+    resolveDeleteTarget,++    -- * Store operations+    addToStore,+    copyPathInto,+    placeInStore,+    registrationFor,+    materializeEvalSources,+    materializeEvalStoreWrites,+    scanReferences,+    scanTempReferences,+    setReadOnly,+    unpackNarEntry,+    writeDrv,+    writeDrvAterm,+    writeDrvClosure,++    -- * Streaming NAR unpacking+    NarUnpackSink,+    newNarUnpackSink,+    sinkNarEvent,+    finishNarUnpack,+    abortNarUnpack,++    -- * NAR entry-name safety+    isSafeNarName,++    -- * Link ordering (exposed for testing)+    orderLinks,++    -- * Case-hack naming (exposed for testing)+    caseHackDiskNames,++    -- * Re-exports+    module Nix.Store.Path,+    module Nix.Store.DB,+    module Nix.Store.Lock,+  )+where++import Control.Exception (IOException, SomeException, catch, throwIO, try)+import Control.Monad (unless, when)+import qualified Data.ByteString as BS+import Data.Char (isDigit, toUpper)+import Data.IORef (IORef, newIORef, readIORef, writeIORef)+import Data.List (inits)+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Maybe (catMaybes)+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import Nix.Derivation (Derivation (..), fromATerm, toATerm)+import Nix.Hash (makeFixedOutputPath, makeTextPath, sha256Digest)+import Nix.Store.CaseSensitive (trySetCaseSensitiveDir)+import Nix.Store.DB+import qualified Nix.Store.ExecBit as ExecBit+import Nix.Store.Lock+import Nix.Store.Path+import qualified NovaCache.Hash as Hash+import qualified NovaCache.NAR as NAR+import qualified NovaCache.NAR.Stream as Stream+import System.Directory+  ( copyFile,+    createDirectoryIfMissing,+    doesDirectoryExist,+    doesFileExist,+    doesPathExist,+    listDirectory,+    renamePath,+    setPermissions,+  )+import qualified System.Directory as Dir+import System.FilePath (splitDirectories, takeDirectory, (</>))+import System.IO (Handle, IOMode (WriteMode), hClose, openBinaryFile)+import qualified System.Info++-- | An open store with database and configuration.+data Store = Store+  { stDir :: !StoreDir,+    stDB :: !StoreDB+  }++-- | Open a Nix store at the given directory.+-- Creates the store directory and database if they don't exist.+openStore :: StoreDir -> IO Store+openStore dir = do+  createDirectoryIfMissing True (unStoreDir dir)+  db <- openStoreDB dir+  pure Store {stDir = dir, stDB = db}++-- | Close the store (flushes the database).+closeStore :: Store -> IO ()+closeStore = closeStoreDB . stDB++-- | Check if a store path is registered as valid in the database.+isValid :: Store -> StorePath -> IO Bool+isValid = isValidPath . stDB++-- | Check if a store path exists on disk (file or directory, regardless of DB).+pathExists :: Store -> StorePath -> IO Bool+pathExists store sp = doesPathExist (storePathToFilePath (stDir store) sp)++-- ---------------------------------------------------------------------------+-- Deletion+-- ---------------------------------------------------------------------------++-- | What 'deleteStorePathRaw' removed.+data DeleteOutcome = DeleteOutcome+  { -- | A ValidPaths row (with its outgoing reference edges) was removed.+    doRowRemoved :: !Bool,+    -- | An on-disk tree was removed.+    doTreeRemoved :: !Bool+  }+  deriving (Eq, Show)++-- | Resolve a @store delete@ argument to the basename it names: a bare+-- @hash-name@ basename, or a full path spelled under the opened store's+-- directory, the platform store, or the canonical store.  The basename is+-- NOT validated as a store path - the entries deletion exists to remove+-- are the ones current name rules reject - so only traversal shapes are+-- refused: separators (excluded by construction), dot-leading names (the+-- store's metadata directory lives at a dot name), and colons (an NTFS+-- alternate-data-stream spelling).  Lock files are refused too, but not+-- here: names like @flake.lock@ are legal store-path names, so telling a+-- lock file from a store object needs the registration rows, and that+-- decision lives in 'deleteStorePathRaw'.+resolveDeleteTarget :: StoreDir -> Text -> Either Text Text+resolveDeleteTarget storeDir raw+  | T.null basename = Left (raw <> ": empty store path name")+  | T.isPrefixOf "." basename =+      Left (basename <> ": dot-leading names are not store paths")+  | T.any (== ':') basename =+      Left (basename <> ": ':' is not valid in a store path name")+  | not dirOk = Left (raw <> ": not a path under this store")+  | otherwise = Right basename+  where+    basename = T.takeWhileEnd (\c -> c /= '/' && c /= '\\') raw+    dirPart =+      normalizeSeps+        (T.dropWhileEnd (\c -> c == '/' || c == '\\') (T.dropEnd (T.length basename) raw))+    dirOk =+      T.null dirPart+        || dirPart+          `elem` [ normalizeSeps (T.pack (unStoreDir storeDir)),+                   normalizeSeps (T.pack (unStoreDir platformStoreDir)),+                   normalizeSeps (T.pack (unStoreDir defaultStoreDir))+                 ]+    normalizeSeps = T.map (\c -> if c == '\\' then '/' else c)++-- | Delete one store entry by basename: the registration row (refused+-- while other valid paths reference it) and the on-disk tree.  Row first,+-- tree second: an orphan tree left by a failed removal is inert debris,+-- while a still-registered row whose tree is gone would be adopted as+-- valid by existence checks.  A row without a tree and a tree without a+-- row both delete (the repair cases); only a target with neither is an+-- error.  The whole sequence holds the target's per-path lock - the same+-- file a substituter of the path locks, as upstream's deletePath does -+-- so a concurrent substitution's delete-materialize-register cannot be+-- torn apart by this delete landing between its on-disk recheck and its+-- registration commit.+deleteStorePathRaw :: Store -> Text -> IO (Either Text DeleteOutcome)+deleteStorePathRaw store basename =+  withLockFile (target <> lockFileSuffix) $ \_ -> do+    -- Lock files are never deleted (the 'Nix.Store.Lock' header).  A+    -- target is one when stripping the suffix leaves a well-formed+    -- store basename AND no registration row bears the full name:+    -- names like @flake.lock@ are legal store-path names, so a+    -- registered object of this exact name deletes normally, and only+    -- the rows can tell the two apart.  The check precedes the row+    -- removal below, which is destructive.+    registered <- case parseStorePathBaseName basename of+      Just sp -> isValidPath (stDB store) sp+      Nothing -> pure False+    case (registered, lockedPathOf basename) of+      (False, Just guardedPath) ->+        pure+          ( Left+              ( basename+                  <> ": names the lock file of "+                  <> guardedPath+                  <> "; lock files coordinate concurrent store access"+                  <> " and are never deleted (an unregistered store"+                  <> " object of this exact name must be removed outside"+                  <> " the store tool)"+              )+          )+      _ -> deleteRowAndTree+  where+    target = unStoreDir (stDir store) </> T.unpack basename+    deleteRowAndTree = do+      rowResult <- unregisterPathRow (stDB store) (T.pack target)+      case rowResult of+        RowReferenced referrers ->+          pure+            ( Left+                ( basename+                    <> " is referenced by:"+                    <> T.concat ["\n  " <> r | r <- referrers]+                )+            )+        _ -> do+          -- 'doesPathExist' follows links, so a dangling top-level symlink+          -- would read as absent; links are leaves here (as in store walks),+          -- and leftover link debris must still be removable.+          treeExisted <- do+            onDisk <- doesPathExist target+            if onDisk+              then pure True+              else Dir.pathIsSymbolicLink target `catch` \(_ :: IOException) -> pure False+          when treeExisted (Dir.removePathForcibly target)+          let rowRemoved = rowResult == RowUnregistered+          if rowRemoved || treeExisted+            then pure (Right DeleteOutcome {doRowRemoved = rowRemoved, doTreeRemoved = treeExisted})+            else pure (Left (basename <> ": not in this store (no registration row, no tree on disk)"))++-- | The store path a lock-shaped name would guard: the basename with+-- 'lockFileSuffix' stripped, provided the remainder is a well-formed+-- store basename.  Whether the file actually IS a lock file still+-- depends on the registration rows (see 'deleteStorePathRaw').+lockedPathOf :: Text -> Maybe Text+lockedPathOf basename = do+  stripped <- T.stripSuffix (T.pack lockFileSuffix) basename+  _ <- parseStorePathBaseName stripped+  pure stripped++-- ---------------------------------------------------------------------------+-- Store operations+-- ---------------------------------------------------------------------------++-- | Move a build output (file or directory) to the store path, set read-only,+-- and register.+--+-- If @renamePath@ fails (cross-device move), falls back to copy + remove.+addToStore ::+  Store ->+  FilePath ->+  StorePath ->+  Maybe Text ->+  [StorePath] ->+  IO ()+addToStore store srcPath sp deriver refs = do+  reg <- placeInStore store srcPath sp deriver refs+  registerPath (stDB store) reg++-- | Move a build output into the store (read-only) and compute its+-- registration (NAR hash, size, references) WITHOUT writing to the database.+--+-- Splitting placement from registration lets a multi-output build place every+-- output first and then register them together, so intra-derivation+-- cross-output references are preserved (see 'registerPaths').+placeInStore ::+  Store ->+  FilePath ->+  StorePath ->+  Maybe Text ->+  [StorePath] ->+  IO PathRegistration+placeInStore store srcPath sp deriver refs = do+  let destPath = storePathToFilePath (stDir store) sp+  moveOutput srcPath destPath+  setReadOnly destPath+  registrationFor store sp deriver refs++-- | Compute the registration metadata for a store path already present on+-- disk, without moving anything.  Used by 'placeInStore' after its move,+-- and by 'materializeEvalSources' to register a verified adopted tree.+registrationFor :: Store -> StorePath -> Maybe Text -> [StorePath] -> IO PathRegistration+registrationFor store sp deriver refs = do+  let destPath = storePathToFilePath (stDir store) sp+  -- Compute the NAR hash and size of the final store contents.  The NAR+  -- serialization is canonical (entries sorted, 8-byte padding), so this is+  -- exactly the NarHash/NarSize a binary cache reports for the path.+  narEntry <- ExecBit.serialiseFromPath destPath+  let narBytes = NAR.serialise narEntry+  pure+    PathRegistration+      { prPath = sp,+        prNarHash = Hash.formatNixHash (Hash.hashBytes narBytes),+        prNarSize = BS.length narBytes,+        prDeriver = deriver,+        prReferences = refs+      }++-- | Cross-device safe move for files or directories.+-- Tries 'renamePath' first; on IOException falls back to copy + remove.+-- The fallback copies via 'copyPathInto', which preserves symlinks as+-- symlinks: a dereferencing copy here would make the stored bytes - and+-- so the NAR hash a cache signs - depend on whether the source and the+-- store share a volume.+moveOutput :: FilePath -> FilePath -> IO ()+moveOutput src dest+  -- A build writes into its own output path, so the usual case is a move+  -- onto itself.  POSIX rename(a,a) is a benign no-op, but MoveFileEx is+  -- documented as unusable when either name is a directory, and the+  -- IOException fallback below would then copy a tree into itself and+  -- delete the result.  Answering here settles both platforms.+  | src == dest = pure ()+  | otherwise =+      renamePath src dest `catch` \(_ :: IOException) -> do+        copyPathInto src dest+        Dir.removePathForcibly src++-- | Byte-scan a tree for store path references.+--+-- Searches each scan unit ('collectScanUnits': regular file bytes and+-- symlink target strings) for each candidate's bare 32-character hash -+-- the same needle upstream Nix scans for.  Matching the hash rather+-- than a store-dir-prefixed path keeps the scan independent of the+-- spelling the builder embedded (canonical @\/nix\/store\/...@,+-- @C:\\nix\\store\\...@, MSYS2 forms): eval injects canonical+-- forward-slash text into builder environments, which a+-- platform-store-dir prefix never matches on Windows.+scanReferences :: [StorePath] -> FilePath -> IO [StorePath]+scanReferences candidates dir = do+  let candidateSet = Set.fromList [(spHash sp, sp) | sp <- candidates]+      needles = [(TE.encodeUtf8 h, h) | (h, _) <- Set.toList candidateSet]+  units <- collectScanUnits dir+  foundHashes <- foldlIO Set.empty units $ \acc unit -> do+    contents <- scanUnitBytes unit+    pure (Set.union acc (Set.fromList [h | (needle, h) <- needles, needle `BS.isInfixOf` contents]))+  pure [sp | (h, sp) <- Set.toList candidateSet, Set.member h foundHashes]++-- | Scan an output for references to build-temp output locations.+--+-- The builder runs under a temp directory, so an output that embeds its own or+-- a sibling output's path embeds the TEMP path - which 'scanReferences' does+-- not look for.  Given @(tempDir, storePath)@ for every output of+-- the derivation, returns the store paths whose temp location is referenced+-- from the scanned output, capturing self- and cross-output references.+--+-- This records the dependency edge; it does not rewrite the embedded bytes+-- (self-reference hash rewriting is a separate, future concern).+scanTempReferences :: [(FilePath, StorePath)] -> FilePath -> IO [StorePath]+scanTempReferences tempPairs dir = do+  let needles = [(TE.encodeUtf8 (T.pack tempDir), sp) | (tempDir, sp) <- tempPairs]+  units <- collectScanUnits dir+  foundHashes <- foldlIO Set.empty units $ \acc unit -> do+    contents <- scanUnitBytes unit+    pure (Set.union acc (Set.fromList [spHash sp | (needle, sp) <- needles, needle `BS.isInfixOf` contents]))+  pure [sp | (_, sp) <- tempPairs, Set.member (spHash sp) foundHashes]++-- | A node kind for store walks, classified WITHOUT following symlinks:+-- the link test runs first because 'doesDirectoryExist' and+-- 'doesFileExist' follow links and would report a link as its target.+-- A dangling link still classifies as 'WalkSymlink'; a probe failure+-- classifies as 'WalkAbsent' rather than throwing mid-walk.+data WalkNode = WalkSymlink | WalkDirectory | WalkRegular | WalkAbsent++-- | Classify one path for a store walk.  The walks in this module+-- dispatch on this (or, in 'copyPathInto', run the same link-first+-- probe order) so no store walk follows a symlink: following one reads+-- or mutates content outside the tree being walked, and does not+-- terminate on a link cycle.+classifyWalkNode :: FilePath -> IO WalkNode+classifyWalkNode path = do+  isLink <- Dir.pathIsSymbolicLink path `catch` \(_ :: IOException) -> pure False+  if isLink+    then pure WalkSymlink+    else do+      isDir <- doesDirectoryExist path+      if isDir+        then pure WalkDirectory+        else do+          isFile <- doesFileExist path+          pure (if isFile then WalkRegular else WalkAbsent)++-- | One scannable unit of a walked tree: a regular file's bytes read+-- from disk, or a symlink's target string.  The NAR serialization+-- carries both, so reference scanning covers both - a link into a+-- dependency (@bin\/tool -> \/nix\/store\/\<hash\>-dep\/tool@)+-- references the dependency even when no file byte does.+data ScanUnit = ScanFile !FilePath | ScanLinkTarget !BS.ByteString++-- | Collect the scannable units under a path: regular files and symlink+-- targets, links never followed.  A path that is itself a regular file+-- or link is its own single unit.+collectScanUnits :: FilePath -> IO [ScanUnit]+collectScanUnits path = do+  node <- classifyWalkNode path+  case node of+    WalkSymlink -> do+      target <- Dir.getSymbolicLinkTarget path+      pure [ScanLinkTarget (TE.encodeUtf8 (T.pack target))]+    WalkDirectory -> do+      entries <- listDirectory path+      concat <$> mapM (collectScanUnits . (path </>)) entries+    WalkRegular -> pure [ScanFile path]+    WalkAbsent -> pure []++-- | The bytes a 'ScanUnit' contributes to the scan.+scanUnitBytes :: ScanUnit -> IO BS.ByteString+scanUnitBytes (ScanFile path) = BS.readFile path+scanUnitBytes (ScanLinkTarget target) = pure target++-- | Strict left fold over a list in IO.  The accumulator is forced to+-- WHNF each step - without it, the scan retains every scanned file's+-- bytes in Set.union thunks until the end (peak memory ~ total tree+-- size).  Set's spine-strict nodes make WHNF force the whole union.+foldlIO :: a -> [b] -> (a -> b -> IO a) -> IO a+foldlIO z [] _ = pure z+foldlIO z (x : xs) f = do+  !acc <- f z x+  foldlIO acc xs f++-- | Recursively mark a store path and its contents read-only after a build.+--+-- On Windows the directory read-only attribute does not prevent adding or+-- removing entries - only the per-file read-only attribute protects a file.+-- Immutability here is therefore enforced at FILE granularity (every file is+-- made read-only); hardening the directory itself against entry changes would+-- require ACLs and is deferred.+setReadOnly :: FilePath -> IO ()+setReadOnly path = do+  node <- classifyWalkNode path+  case node of+    -- A symlink is a leaf: descending would mark content outside the+    -- tree (or loop on a link cycle), and a permission change applied+    -- to the link resolves through to its target.+    WalkSymlink -> pure ()+    WalkDirectory -> do+      entries <- listDirectory path+      mapM_ (setReadOnly . (path </>)) entries+      perms <- Dir.getPermissions path+      Dir.setPermissions path (Dir.setOwnerWritable False perms)+    WalkRegular -> do+      perms <- Dir.getPermissions path+      setPermissions path (Dir.setOwnerWritable False perms)+    WalkAbsent -> pure ()++-- | Write an already-serialized derivation ATerm to its store path.  Used to+-- materialize the input @.drv@ closure (root plus every transitive input)+-- before a dependency-aware build: evaluation computes these ATerms but does+-- no store IO, so the build driver writes them here.+writeDrvAterm :: Store -> StorePath -> BS.ByteString -> IO ()+writeDrvAterm store sp aterm = do+  let destPath = storePathToFilePath (stDir store) sp+  createDirectoryIfMissing True (unStoreDir (stDir store))+  -- Raw bytes, not text-mode IO: the path was computed from exactly these+  -- bytes, and a locale-dependent or newline-translating write would store+  -- bytes that no longer match their content address.+  BS.writeFile destPath aterm++-- | Serialize a derivation to ATerm, write it to the store, and register+-- it: a @.drv@ is a store object like any other, so it gets a ValidPaths+-- row with its NAR hash and its references.  Reference scans may name a+-- @.drv@ (an output that embeds an input drv hash), and an unregistered+-- referent fails the whole registration batch.+writeDrv :: Store -> Derivation -> StorePath -> IO ()+writeDrv store drv sp = do+  writeDrvAterm store sp (toATerm drv)+  reg <- registrationFor store sp Nothing (drvReferences drv)+  registerPath (stDB store) reg++-- | A @.drv@'s references: its input sources and input @.drv@ paths -+-- the same set upstream records when writing a derivation to the store.+drvReferences :: Derivation -> [StorePath]+drvReferences drv = drvInputSrcs drv ++ Map.keys (drvInputDrvs drv)++-- | Write every recorded @.drv@ ATerm (keyed by its store-path text) to+-- the store and register the whole closure in one batch: rows all land+-- before edges ('registerPaths'), so references between the closure's+-- own @.drv@ files resolve regardless of map order.  Input SOURCES must+-- already be registered - the build driver runs 'materializeEvalSources'+-- first.+--+-- Keys come from evaluation via 'storePathToText' so they always parse;+-- an unparseable key is skipped defensively.  The ATerm bytes were+-- rendered by evaluation, so a re-parse failure is an invariant break+-- and throws rather than registering a recipe with dropped references.+writeDrvClosure :: Store -> Map Text BS.ByteString -> IO ()+writeDrvClosure store closure = do+  regs <- mapM writeOne (Map.toList closure)+  registerPaths (stDB store) (catMaybes regs)+  where+    writeOne (pathText, aterm) =+      case parseStorePath defaultStoreDir pathText of+        Nothing -> pure Nothing+        Just sp -> do+          writeDrvAterm store sp aterm+          case fromATerm aterm of+            Right drv -> Just <$> registrationFor store sp Nothing (drvReferences drv)+            Left err ->+              throwIO+                ( userError+                    ( "writeDrvClosure: recorded ATerm for "+                        <> T.unpack pathText+                        <> " does not re-parse: "+                        <> T.unpack err+                    )+                )++-- ---------------------------------------------------------------------------+-- NAR unpacking+-- ---------------------------------------------------------------------------++-- | Unpack a NarEntry tree to a filesystem destination.  Returns @Left@ on an+-- unsafe entry name (path traversal); these can come from untrusted cache+-- data, so a typed failure is used instead of a partial 'error'.+--+-- Regular files and directories are written in one pass; symlinks are+-- created in a second pass, after their targets are materialized.  Windows+-- symlinks are typed (file vs directory) and the NAR format does not record+-- the target's kind, so the only reliable way to pick the flavor is to look+-- at the target on disk - which may sort after the link within the tree.+unpackNarEntry :: FilePath -> NAR.NarEntry -> IO (Either Text ())+unpackNarEntry path entry = do+  walked <- unpackTree path entry+  case walked of+    Left err -> pure (Left err)+    Right links -> createSymlinks links++-- | First unpack pass: write regular files and directories, recording+-- symlinks as (link path, target) for the second pass.+unpackTree :: FilePath -> NAR.NarEntry -> IO (Either Text [(FilePath, Text)])+unpackTree path entry = case entry of+  NAR.NarRegular isExec contents -> do+    createDirectoryIfMissing True (takeDirectory path)+    BS.writeFile path contents+    when isExec (ExecBit.markExecutable path)+    pure (Right [])+  NAR.NarSymlink target -> pure $ case decodeNarText "symlink target" target of+    Left err -> Left err+    Right decoded -> Right [(path, decoded)]+  NAR.NarDirectory entries -> do+    createDirectoryIfMissing True path+    unpackChildren path entries++-- | The on-disk identity a NAR entry name occupies on this platform's+-- store filesystem.  Windows (NTFS\/Win32) compares names+-- case-insensitively and strips trailing dots and spaces; the default+-- macOS APFS volume folds case; Linux preserves names byte-for-byte.+-- Two sibling entries sharing a key land on ONE file, the second+-- silently overwriting the first.  The fold is per-character uppercase:+-- a corruption backstop for the collisions real trees carry+-- (@Makefile@\/@makefile@), not a full model of filesystem Unicode+-- folding.+onDiskNameKey :: Text -> Text+onDiskNameKey = case System.Info.os of+  "mingw32" -> T.map toUpper . T.dropWhileEnd (\c -> c == '.' || c == ' ')+  "darwin" -> T.map toUpper+  _ -> id++-- | The first pair of sibling names folding to the same on-disk file,+-- if any: (earlier entry, colliding later entry).+firstNameCollision :: [Text] -> Maybe (Text, Text)+firstNameCollision = go Map.empty+  where+    go !_ [] = Nothing+    go !seen (name : rest) =+      let key = onDiskNameKey name+       in case Map.lookup key seen of+            Just earlier -> Just (earlier, name)+            Nothing -> go (Map.insert key name seen) rest++-- | Whether this platform's NAR serialiser strips the case-hack suffix+-- ('NAR.defaultCaseHack').  Where it does, an INCOMING entry name+-- carrying the suffix must be rejected: materialized verbatim it would+-- re-serialise under a different name and fail its own hash recheck.+-- Upstream rejects such names whenever its case-hack is active.+platformStripsCaseHack :: Bool+platformStripsCaseHack = NAR.defaultCaseHack == NAR.CaseHackEnabled++-- | 'NAR.caseHackSuffix' as Text for the name machinery here, which+-- runs on decoded names ('decodeNarText').  The suffix is ASCII, so+-- the latin1 read is exact.+caseHackSuffixText :: Text+caseHackSuffixText = TE.decodeLatin1 NAR.caseHackSuffix++-- | Decode a NAR-carried byte string that must become a filesystem+-- name.  The NAR format carries entry names and symlink targets as+-- raw bytes and the parser accepts them; the store's invariant is+-- stricter - every materialized name exists identically on every+-- platform the store targets, and NTFS names are UTF-16 - so bytes+-- with no Unicode reading are refused at the write boundary rather+-- than approximated.+decodeNarText :: Text -> BS.ByteString -> Either Text Text+decodeNarText what bytes = case TE.decodeUtf8' bytes of+  Right decoded -> Right decoded+  Left _ -> Left ("NAR " <> what <> " is not valid UTF-8: " <> T.pack (show bytes))++-- | Disk names for a sibling list on a folding filesystem WITHOUT+-- per-directory case sensitivity: upstream's case-hack.  The first+-- occurrence of each folded name keeps its spelling; every later+-- variant gains the reversible suffix and a per-name counter, which+-- the platform serialiser strips on the way back out.  Order is+-- preserved; result pairs are (NAR name, on-disk name).+caseHackDiskNames :: [Text] -> [(Text, Text)]+caseHackDiskNames = reverse . snd . foldl' step (Map.empty, [])+  where+    step (!seen, !acc) name =+      let key = onDiskNameKey name+       in case Map.lookup key seen of+            Nothing -> (Map.insert key (0 :: Int) seen, (name, name) : acc)+            Just occurrences ->+              let next = occurrences + 1+                  disk = name <> caseHackSuffixText <> T.pack (show next)+               in (Map.insert key next seen, (name, disk) : acc)++-- | Unpack directory children.  Entry names arrive as the raw bytes+-- the NAR carries; the store's invariant is that every materialized+-- name is valid Unicode - a name every platform the store targets can+-- hold - so each name is decoded here at the write boundary, and a+-- byte name with no Unicode reading refuses the unpack+-- ('decodeNarText') rather than approximating a spelling.+unpackChildren :: FilePath -> [(BS.ByteString, NAR.NarEntry)] -> IO (Either Text [(FilePath, Text)])+unpackChildren path rawEntries = case traverse decodeChild rawEntries of+  Left err -> pure (Left err)+  Right entries -> unpackNamedChildren path entries+  where+    decodeChild (nameBytes, child) = do+      name <- decodeNarText "directory entry name" nameBytes+      Right (name, child)++-- | Unpack decoded directory children, short-circuiting with a typed+-- failure on the first unsafe entry name rather than crashing on+-- untrusted input.+--+-- Sibling names folding to one on-disk name (NTFS, default APFS) take+-- the TRUE-NAME path when the platform provides one: the just-created+-- empty directory gains NTFS per-directory case sensitivity and the+-- tree materializes under its real names.  Where the flag is+-- unavailable (a non-NTFS store volume, macOS) the collision falls+-- back to upstream's case-hack renaming, which the platform serialiser+-- reverses.  Either way a registered path re-serialises to its NAR+-- byte-for-byte - the substituter's on-disk recheck verifies it.+unpackNamedChildren :: FilePath -> [(Text, NAR.NarEntry)] -> IO (Either Text [(FilePath, Text)])+unpackNamedChildren path entries = do+  diskNames <- resolveDiskNames+  walkChildren (zip diskNames entries)+  where+    names = map fst entries+    resolveDiskNames = case firstNameCollision names of+      Nothing -> pure names+      Just _ -> do+        trueNames <- trySetCaseSensitiveDir path+        pure+          ( if trueNames+              then names+              else map snd (caseHackDiskNames names)+          )+    walkChildren [] = pure (Right [])+    walkChildren ((diskName, (name, child)) : rest)+      | not (isSafeNarName name) =+          pure (Left ("unsafe NAR directory entry name: " <> name))+      | platformStripsCaseHack && caseHackSuffixText `T.isInfixOf` name =+          pure (Left ("NAR entry name contains the case-hack suffix: " <> name))+      | otherwise = do+          result <- unpackTree (path </> T.unpack diskName) child+          case result of+            Left err -> pure (Left err)+            Right links -> do+              restResult <- walkChildren rest+              case restResult of+                Left err -> pure (Left err)+                Right moreLinks -> pure (Right (links <> moreLinks))++-- | Second unpack pass: create the recorded symlinks in dependency+-- order - each link is created after every pending link its target+-- resolves at or through - so the Windows link flavor (file vs+-- directory) is read off the real target with one probe per link.+-- (The previous ready-set rounds re-stat'd every remaining link per+-- round: quadratic filesystem stats on a link chain.)  Links on a+-- dependency cycle have no knowable kind and default to file links,+-- exactly as dangling links always have.+createSymlinks :: [(FilePath, Text)] -> IO (Either Text ())+createSymlinks pending = createAll (orderLinks pending)+  where+    createAll [] = pure (Right ())+    createAll (link : rest) = do+      made <- uncurry createSymlink link+      case made of+        Left err -> pure (Left err)+        Right () -> createAll rest++-- | Order pending links so each follows every pending link its target+-- path resolves at or through: the target itself, or a link standing on+-- one of the target's ancestor directories.  Purely textual over the+-- same @takeDirectory linkPath \</\> target@ resolution 'createSymlink'+-- probes - no filesystem access.  Kahn's ordering, deterministic:+-- ready links leave in input order, and cycle members keep input order+-- at the end.  Exported for testing (the ordering property is pure).+orderLinks :: [(FilePath, Text)] -> [(FilePath, Text)]+orderLinks pending =+  let indexed = zip [0 :: Int ..] pending+      linkByIndex = Map.fromList indexed+      indexByKey =+        Map.fromList [(normalisedComponents linkPath, i) | (i, (linkPath, _)) <- indexed]+      -- The pending links this link's resolved target lands on or+      -- passes through (every nonempty component prefix).+      depsOf (linkPath, target) =+        let resolved = normalisedComponents (takeDirectory linkPath </> T.unpack target)+         in Set.fromList+              [j | prefix <- drop 1 (inits resolved), Just j <- [Map.lookup prefix indexByKey]]+      dependsOn = Map.fromList [(i, depsOf link) | (i, link) <- indexed]+      dependents =+        Map.fromListWith+          (flip (++))+          [(dep, [i]) | (i, deps) <- Map.toList dependsOn, dep <- Set.toList deps]+      initialCounts = Map.map Set.size dependsOn+      initialReady = [i | (i, count) <- Map.toList initialCounts, count == 0]+      -- Kahn's ordering with a two-list queue (amortized O(1) pops).+      run !emittedRev !counts front back = case front of+        [] -> case back of+          [] -> reverse emittedRev+          _ -> run emittedRev counts (reverse back) []+        (i : rest) ->+          let (updatedCounts, readied) = release counts (Map.findWithDefault [] i dependents)+           in run (i : emittedRev) updatedCounts rest (readied ++ back)+      release !counts deps = case deps of+        [] -> (counts, [])+        (d : more) ->+          let updated = Map.adjust (subtract 1) d counts+              (finalCounts, readied) = release updated more+           in (finalCounts, [d | Map.lookup d updated == Just 0] ++ readied)+      emittedOrder = run [] initialCounts initialReady []+      emittedSet = Set.fromList emittedOrder+      cycleRemainder = [link | (i, link) <- indexed, not (Set.member i emittedSet)]+   in [link | i <- emittedOrder, Just link <- [Map.lookup i linkByIndex]] ++ cycleRemainder++-- | Path components with @.@ dropped and @..@ collapsed textually - the+-- spelling-insensitive key that matches a link target against pending+-- link paths ('splitDirectories' accepts both separator spellings).  A+-- @..@ with nothing left to pop stays, matching no real path.+normalisedComponents :: FilePath -> [FilePath]+normalisedComponents path = reverse (foldl' step [] (splitDirectories path))+  where+    step stack comp+      | comp == "." = stack+      | comp == ".." = case stack of+          (top : rest) | top /= ".." -> rest+          _ -> comp : stack+      | otherwise = comp : stack++-- | Create one symlink, choosing the Windows flavor from the target's kind.+-- A creation failure is loud: the old fallback of writing the target text+-- as a regular file registered a tree whose NAR hash differed from the+-- signed narinfo's - silent store corruption that a later push refuses to+-- publish.  Failing lets the caller fall back to a local build.+createSymlink :: FilePath -> Text -> IO (Either Text ())+createSymlink linkPath target = do+  createDirectoryIfMissing True (takeDirectory linkPath)+  let targetStr = T.unpack target+  targetIsDir <- Dir.doesDirectoryExist (takeDirectory linkPath </> targetStr)+  result <-+    try $+      if targetIsDir+        then Dir.createDirectoryLink targetStr linkPath+        else Dir.createFileLink targetStr linkPath+  case result of+    Right () -> pure (Right ())+    Left (e :: SomeException) ->+      pure+        ( Left+            ( "cannot create symlink "+                <> T.pack linkPath+                <> " -> "+                <> target+                <> ": "+                <> T.pack (show e)+                <> " (on Windows this needs Developer Mode or elevation)"+            )+        )++-- ---------------------------------------------------------------------------+-- Streaming NAR unpacking+-- ---------------------------------------------------------------------------++-- | One open directory in the streaming unpack: its on-disk path and+-- the sibling names materialized so far, keyed by their on-disk+-- identity ('onDiskNameKey') for collision handling.+data UnpackFrame = UnpackFrame+  { ufPath :: !FilePath,+    ufSeen :: !(Map Text Int)+  }++-- | Mutable state behind a 'NarUnpackSink' - the same deliberate IO+-- boundary as the store's other materializers.  'nusTargets' is the+-- stack of on-disk paths the NEXT node materializes at: the+-- destination at the root, plus one pushed per open directory entry.+data NarUnpackState = NarUnpackState+  { nusFrames :: ![UnpackFrame],+    nusTargets :: ![FilePath],+    nusOpen :: !(Maybe (Handle, FilePath, Bool)),+    nusLinks :: ![(FilePath, Text)]+  }++-- | A push sink materializing 'Stream.NarEvent's under a destination+-- path as they arrive, so a substituted NAR unpacks in the same pass+-- that downloads it.  Semantics mirror 'unpackNarEntry' - the same+-- name decoding, safety checks, executable bit, and second-pass+-- symlink creation - with one divergence: sibling names colliding on+-- a folding filesystem always take upstream's case-hack renaming,+-- never the NTFS true-name path, because per-directory case+-- sensitivity can only be enabled on an EMPTY directory and a stream+-- cannot know a directory's siblings before materializing the first.+-- Upstream's own streaming restore behaves identically, and the+-- substituter's on-disk recheck proves the tree re-serialises to its+-- NAR either way.+newtype NarUnpackSink = NarUnpackSink (IORef NarUnpackState)++-- | A sink for one NAR unpack under the given destination path.+newNarUnpackSink :: FilePath -> IO NarUnpackSink+newNarUnpackSink destPath =+  NarUnpackSink <$> newIORef (NarUnpackState [] [destPath] Nothing [])++-- | Feed one event.  On 'Left' the partial tree stays for the caller+-- to remove - 'abortNarUnpack' first, so no handle stays open on it.+sinkNarEvent :: NarUnpackSink -> Stream.NarEvent -> IO (Either Text ())+sinkNarEvent (NarUnpackSink ref) event = do+  narState <- readIORef ref+  outcome <- applyNarEvent narState event+  case outcome of+    Left err -> pure (Left err)+    Right updated -> do+      writeIORef ref updated+      pure (Right ())++-- | One event's filesystem effects plus the state that follows it.+-- The stream machine already proved grammar well-formedness, so the+-- mismatch arms guard sink-state desync, not archive syntax.+applyNarEvent :: NarUnpackState -> Stream.NarEvent -> IO (Either Text NarUnpackState)+applyNarEvent narState event = case event of+  Stream.EventRegularBegin isExec _declaredSize -> withNodeTarget narState $ \path -> do+    createDirectoryIfMissing True (takeDirectory path)+    fileHandle <- openBinaryFile path WriteMode+    pure (Right narState {nusOpen = Just (fileHandle, path, isExec)})+  Stream.EventRegularChunk slice -> case nusOpen narState of+    Nothing -> pure (Left "NAR stream sink: file contents outside an open file")+    Just (fileHandle, _, _) -> do+      BS.hPut fileHandle slice+      pure (Right narState)+  Stream.EventRegularEnd -> case nusOpen narState of+    Nothing -> pure (Left "NAR stream sink: file close without an open file")+    Just (fileHandle, path, isExec) -> do+      hClose fileHandle+      when isExec (ExecBit.markExecutable path)+      pure (Right narState {nusOpen = Nothing})+  Stream.EventSymlink targetBytes -> withNodeTarget narState $ \path ->+    pure $ case decodeNarText "symlink target" targetBytes of+      Left err -> Left err+      Right decoded -> Right narState {nusLinks = (path, decoded) : nusLinks narState}+  Stream.EventDirectoryBegin -> withNodeTarget narState $ \path -> do+    createDirectoryIfMissing True path+    pure (Right narState {nusFrames = UnpackFrame path Map.empty : nusFrames narState})+  Stream.EventEntryBegin nameBytes -> case nusFrames narState of+    [] -> pure (Left "NAR stream sink: entry outside a directory")+    (frame : outer) -> pure $ do+      name <- decodeNarText "directory entry name" nameBytes+      if not (isSafeNarName name)+        then Left ("unsafe NAR directory entry name: " <> name)+        else+          if platformStripsCaseHack && caseHackSuffixText `T.isInfixOf` name+            then Left ("NAR entry name contains the case-hack suffix: " <> name)+            else+              -- Sequential case-hack: the disk name of entry N depends+              -- only on the siblings before it, the same sequence+              -- 'caseHackDiskNames' folds over a whole list.+              let key = onDiskNameKey name+                  (diskName, occurrences) = case Map.lookup key (ufSeen frame) of+                    Nothing -> (name, 0)+                    Just seen -> (name <> caseHackSuffixText <> T.pack (show (seen + 1)), seen + 1)+                  updatedFrame = frame {ufSeen = Map.insert key occurrences (ufSeen frame)}+               in Right+                    narState+                      { nusFrames = updatedFrame : outer,+                        nusTargets = (ufPath frame </> T.unpack diskName) : nusTargets narState+                      }+  Stream.EventEntryEnd -> case nusTargets narState of+    -- The root destination never pops; only entry-pushed paths do.+    (_ : rest@(_ : _)) -> pure (Right narState {nusTargets = rest})+    _ -> pure (Left "NAR stream sink: entry close without an open entry")+  Stream.EventDirectoryEnd -> case nusFrames narState of+    [] -> pure (Left "NAR stream sink: directory close without an open directory")+    (_ : outer) -> pure (Right narState {nusFrames = outer})++-- | Run an action on the path the next node materializes at.+withNodeTarget :: NarUnpackState -> (FilePath -> IO (Either Text NarUnpackState)) -> IO (Either Text NarUnpackState)+withNodeTarget narState act = case nusTargets narState of+  (path : _) -> act path+  [] -> pure (Left "NAR stream sink: node with no destination")++-- | Finish after 'Stream.NarDone': every node must be closed, then+-- the recorded symlinks are created - the same dependency ordering+-- and flavor probing as the strict path's second pass.+finishNarUnpack :: NarUnpackSink -> IO (Either Text ())+finishNarUnpack (NarUnpackSink ref) = do+  narState <- readIORef ref+  case (nusOpen narState, nusFrames narState) of+    (Just _, _) -> pure (Left "NAR stream sink: stream ended inside a file")+    (Nothing, _ : _) -> pure (Left "NAR stream sink: stream ended inside a directory")+    (Nothing, []) -> createSymlinks (reverse (nusLinks narState))++-- | Close any open handle so the caller can remove the partial tree;+-- Windows will not delete a file a handle still holds open.+abortNarUnpack :: NarUnpackSink -> IO ()+abortNarUnpack (NarUnpackSink ref) = do+  narState <- readIORef ref+  case nusOpen narState of+    Nothing -> pure ()+    Just (fileHandle, _, _) ->+      hClose fileHandle `catch` \(_ :: IOException) -> pure ()+  writeIORef ref narState {nusOpen = Nothing}++-- | Whether a NAR directory entry name is safe to materialize on every+-- platform the store targets.  Two rejection classes:+--+-- 1. Path escapes: empty, @.@, @..@, a separator, a NUL (truncates the+--    name in any NUL-terminated API downstream), or a @:@ - a+--    drive-prefixed name like @C:evil@ makes 'System.FilePath.</>'+--    discard the store prefix entirely.+--+-- 2. Names the Win32 path layer silently REWRITES rather than refuses,+--    landing the bytes somewhere other than the named entry so the+--    on-disk tree no longer reproduces the NAR hash that named it: an+--    alternate-data-stream @:@ diverts the contents into a stream of+--    another file, a reserved device stem (CON, PRN, AUX, NUL,+--    COM0-COM9, LPT0-LPT9, plus the superscript-digit forms) addresses+--    the device instead of a file, and a trailing dot or space is+--    stripped on create, folding distinct NAR names onto one on-disk+--    name.+--+-- Characters Windows merely REFUSES (@\"@, @*@, @<@, @>@, @|@) stay+-- allowed: the create call fails loudly and the unpack loop surfaces+-- the failure, which cannot misplace or corrupt anything.+isSafeNarName :: Text -> Bool+isSafeNarName name =+  not (T.null name)+    && name /= ".."+    && name /= "."+    && not (T.any escapesTree name)+    && not (trailingRewritten name)+    && not (reservedDeviceStem name)+  where+    escapesTree c = c == '/' || c == '\\' || c == ':' || c == '\0'+    trailingRewritten n = case T.unsnoc n of+      Just (_, end) -> end == '.' || end == ' '+      Nothing -> False+    -- Win32 device parsing takes the name up to the first dot as the+    -- stem and ignores trailing spaces there ("NUL .txt" still+    -- addresses NUL), so the stem is space-trimmed before comparison.+    reservedDeviceStem n =+      let stem = T.toUpper (T.dropWhileEnd (== ' ') (T.takeWhile (/= '.') n))+       in stem == "CON"+            || stem == "PRN"+            || stem == "AUX"+            || stem == "NUL"+            || numberedDeviceStem stem+    numberedDeviceStem stem = case T.unpack stem of+      [a, b, c, digit] -> ([a, b, c] == "COM" || [a, b, c] == "LPT") && deviceDigit digit+      _ -> False+    -- Digits 0-9 plus the superscript forms ('\185' '\178' '\179') the+    -- platform also reserves.+    deviceDigit c = isDigit c || c == '\185' || c == '\178' || c == '\179'++-- ---------------------------------------------------------------------------+-- Eval source materialization+-- ---------------------------------------------------------------------------++-- | Copy eval-coerced source paths into the store and register them.  The+-- evaluator's source-path cache maps each coerced filesystem path to its+-- @source@ fixed-output store path (text only - eval performs no store+-- writes).  Each entry not already valid is copied in, made read-only, and+-- registered with its real NAR hash.  A copied source carries no references.+materializeEvalSources :: Store -> Map Text Text -> IO ()+materializeEvalSources store sourceCache = mapM_ adopt (Map.toList sourceCache)+  where+    -- Each source registers IMMEDIATELY after its copy (sources carry no+    -- cross-references, so there is nothing to batch).  A tree already on+    -- disk is adopted only after verification: its NAR digest must+    -- reproduce the store path being registered - an interrupted earlier+    -- copy leaves a partial tree, and registering it as-is validates+    -- content that does not match its address.  A verified adoption never+    -- touches the files (re-copying onto a read-only tree fails on+    -- Windows and would wedge the store permanently); a failed one clears+    -- and re-copies.  Mirrors the builder's own prepareOutput recovery.+    -- The whole check-then-act runs under the path's cross-process+    -- lock, validity re-checked once held: without it, two processes+    -- materializing the same source raced isValid, and the loser's+    -- removePathForcibly deleted the tree the winner had just+    -- registered.  Same protocol as the builder's withOutputLocks.+    adopt (rawPath, spText) =+      case parseStorePath defaultStoreDir spText of+        Nothing -> pure ()+        Just sp -> withPathLock (stDir store) sp $ \_ -> do+          valid <- isValid store sp+          unless valid $ do+            let dest = storePathToFilePath (stDir store) sp+            onDisk <- doesPathExist dest+            adoptable <- if onDisk then adoptedTreeMatches dest sp else pure False+            unless adoptable $ do+              when onDisk (Dir.removePathForcibly dest)+              copyPathInto (T.unpack rawPath) dest+              setReadOnly dest+            reg <- registrationFor store sp Nothing []+            registerPath (stDB store) reg++-- | Register the store objects evaluation wrote: makes each read-only+-- and records it in the DB.  Batched so a write referring to another+-- resolves.  Covers every eval-time writer, not only @builtins.toFile@:+-- an unregistered write reaches @drvInputSrcs@ and fails the build.+materializeEvalStoreWrites :: Store -> Map Text ([StorePath], StoreWriteMode) -> IO ()+materializeEvalStoreWrites store storeWrites = do+  regs <- catMaybes <$> mapM prepare (Map.toList storeWrites)+  unless (null regs) (registerPaths (stDB store) regs)+  where+    -- Checked and sealed under the path's lock, validity re-checked+    -- once held, so a peer mid-producing the same path is waited out+    -- rather than torn-read (and refused).  The lock releases before+    -- the batched registration: registration is an upsert over content+    -- both holders verified reproduces the same path, so the winner+    -- and loser record the same row.+    prepare (spText, (refs, mode)) =+      case parseStorePath defaultStoreDir spText of+        Nothing -> pure Nothing+        Just sp -> withPathLock (stDir store) sp $ \_ -> do+          valid <- isValid store sp+          if valid+            then pure Nothing+            else do+              let dest = storePathToFilePath (stDir store) sp+              onDisk <- doesPathExist dest+              -- Absent means removed since eval; nothing to register.+              if not onDisk+                then pure Nothing+                else do+                  -- The writers verified (or rewrote) this content at+                  -- write time; a mismatch here means the tree changed+                  -- between evaluation and registration, and registering+                  -- it would record a hash its bytes do not have, then+                  -- seal the lie read-only.  Refuse loudly instead.+                  reproduces <- writeReproducesPath dest sp refs mode+                  unless reproduces $+                    throwIO+                      ( userError+                          ( "refusing to register "+                              <> T.unpack spText+                              <> ": the on-disk content does not reproduce its store path; "+                              <> "delete the path and re-evaluate"+                          )+                      )+                  setReadOnly dest+                  Just <$> registrationFor store sp Nothing refs++-- | Whether on-disk content re-derives exactly the store path it is+-- about to be registered under, under the scheme that named the write.+-- An unreadable destination counts as a mismatch.+writeReproducesPath :: FilePath -> StorePath -> [StorePath] -> StoreWriteMode -> IO Bool+writeReproducesPath dest sp refs mode = case mode of+  WriteRecursive -> adoptedTreeMatches dest sp+  WriteFlat ->+    withFileBytes (\bytes -> makeFixedOutputPath (spName sp) "sha256" "flat" (sha256Digest bytes) == Right sp)+  WriteText ->+    withFileBytes (\bytes -> makeTextPath (spName sp) (sha256Digest bytes) refs == Right sp)+  where+    withFileBytes check = do+      result <- try (BS.readFile dest) :: IO (Either IOException BS.ByteString)+      pure (either (const False) check result)++-- | Whether an on-disk tree reproduces the source store path it is about to+-- be registered under: its recursive NAR digest and the path's own name must+-- derive exactly this path.  An unreadable tree counts as a mismatch.+adoptedTreeMatches :: FilePath -> StorePath -> IO Bool+adoptedTreeMatches dest sp = do+  result <- try (ExecBit.serialiseFromPath dest)+  pure $ case result of+    Left (_ :: SomeException) -> False+    Right entry ->+      makeFixedOutputPath (spName sp) "sha256" "recursive" (sha256Digest (NAR.serialise entry)) == Right sp++-- | Recursively copy a file or directory tree to a destination path.+-- A symlink is replicated as a symlink: the store path's name came from a+-- NAR hash that ENCODES the link entry, so dereferencing it here would+-- store content that no longer matches its own address (and a+-- self-referential link would recurse forever).  On Windows without+-- symlink privilege the link creation fails loudly rather than silently+-- corrupting the content address.+copyPathInto :: FilePath -> FilePath -> IO ()+copyPathInto src dest = do+  isLink <- Dir.pathIsSymbolicLink src+  if isLink+    then do+      target <- Dir.getSymbolicLinkTarget src+      linkedDir <- doesDirectoryExist src+      if linkedDir+        then Dir.createDirectoryLink target dest+        else Dir.createFileLink target dest+    else do+      isDir <- doesDirectoryExist src+      if isDir+        then do+          createDirectoryIfMissing True dest+          names <- listDirectory src+          mapM_ (\name -> copyPathInto (src </> name) (dest </> name)) names+        else do+          copyFile src dest+          -- copyFile copies the unnamed stream only, so on Windows the+          -- exec mark would not survive the copy.+          ExecBit.copyExecMark src dest
+ src/Nix/Store/CaseSensitive.hs view
@@ -0,0 +1,37 @@+{-# LANGUAGE CPP #-}++-- | Per-directory case sensitivity: the capability probe behind+-- true-name NAR materialization on a folding filesystem.+--+-- Windows NTFS exposes case sensitivity as a per-directory flag (the+-- mechanism WSL uses to host Linux trees), so an unpacker can hold+-- case-variant siblings under their REAL names instead of mangling+-- them.  Other platforms report unsupported - APFS fixes sensitivity+-- per volume at creation, Linux needs nothing - and the caller falls+-- back to the case-hack.+module Nix.Store.CaseSensitive (trySetCaseSensitiveDir) where++#ifdef mingw32_HOST_OS++import Foreign.C.String (CWString, withCWString)+import Foreign.C.Types (CInt (..))++foreign import ccall unsafe "nn_winfs.h nn_dir_set_case_sensitive"+  c_nn_dir_set_case_sensitive :: CWString -> IO CInt++-- | Enable case-sensitive naming on an EMPTY directory the caller just+-- created.  True means the directory now holds case-variant sibling+-- names as distinct files; False (non-NTFS volume, policy) means fall+-- back to the case-hack.+trySetCaseSensitiveDir :: FilePath -> IO Bool+trySetCaseSensitiveDir path =+  withCWString path (fmap (/= 0) . c_nn_dir_set_case_sensitive)++#else++-- | Unsupported off Windows: sensitivity is a volume-level property on+-- macOS (chosen at store-volume creation) and inherent on Linux.+trySetCaseSensitiveDir :: FilePath -> IO Bool+trySetCaseSensitiveDir _ = pure False++#endif
src/Nix/Store/DB.hs view
@@ -41,12 +41,17 @@     queryPathInfo,     queryAllValidPaths, +    -- * Unregistration+    UnregisterResult (..),+    unregisterPathRow,+     -- * Constants     metaDirName,     dbFileName,   ) where +import Control.Exception (throwIO) import Data.Text (Text) import qualified Data.Text as T import Database.SQLite.Simple@@ -60,7 +65,7 @@     query,     withTransaction,   )-import Nix.Store.Path (StoreDir (..), StorePath (..), storePathToFilePath)+import Nix.Store.Path (StoreDir (..), StorePath, storePathToFilePath) import System.Directory (createDirectoryIfMissing) import System.FilePath ((</>)) @@ -138,7 +143,8 @@  -- | Open (or create) the store database. -- Creates the store directory, metadata subdirectory, and database--- tables if they don't exist.  Enables WAL mode for concurrency.+-- tables if they don't exist.  Enables WAL mode for concurrency and+-- foreign-key enforcement for referential integrity. openStoreDB :: StoreDir -> IO StoreDB openStoreDB dir = do   let storeRoot = unStoreDir dir@@ -147,6 +153,17 @@   createDirectoryIfMissing True metaDir   conn <- open dbPath   execute_ conn "PRAGMA journal_mode=WAL"+  -- SQLite's default busy timeout is zero, so a second process hitting+  -- a peer's write transaction died on an uncaught ErrorBusy SQLError+  -- instead of waiting its turn.  Upstream waits an hour+  -- (sqlite3_busy_timeout(db, 60 * 60 * 1000), sqlite.cc:78 at+  -- 2.28.7); the pragma reaches the same API.+  execute_ conn "PRAGMA busy_timeout=3600000"+  -- SQLite leaves foreign keys OFF per connection; without this the Refs+  -- REFERENCES clauses are inert, and deleting a ValidPaths row (path+  -- deletion, garbage collection) would leave dangling Refs edges that+  -- closure JOINs silently under-report.+  execute_ conn "PRAGMA foreign_keys=ON"   execute_ conn (fromString createValidPathsSQL)   execute_ conn (fromString createRefsSQL)   pure StoreDB {sdbDir = dir, sdbConn = conn}@@ -184,6 +201,12 @@ -- | Insert (or refresh) a single ValidPaths row. insertPathRow :: StoreDB -> PathRegistration -> IO () insertPathRow db reg = do+  -- DB rows key store paths in PLATFORM spelling (storePathToFilePath):+  -- the database is host-local state describing this host's store tree,+  -- every writer and reader in this module uses the same spelling, and+  -- the store dir itself is host configuration.  Identity artifacts+  -- (drv ATerm, narinfo, eval-visible store-path strings) spell+  -- canonically; the DB is deliberately not one of them.   let pathText = T.pack (storePathToFilePath (sdbDir db) (prPath reg))   execute     (sdbConn db)@@ -193,13 +216,26 @@     (pathText, prNarHash reg, prDeriver reg, prNarSize reg)  -- | Insert the reference edges for a path whose row already exists.+--+-- Re-registration REPLACES the edge set: the metadata-refresh contract+-- (see 'registerPaths') applies to references too, and keeping the union+-- of old and new edges would over-report - 'queryReferences' feeds pushed+-- narinfos, which would advertise references the path no longer has.+--+-- A reference to an unregistered path is an error, not a skip: silently+-- dropping the edge under-reports the same narinfos and hands a future GC+-- permission to delete a live dependency.  Referents must be registered+-- first or in the same 'registerPaths' batch (path rows are all inserted+-- before any edge). insertPathRefs :: StoreDB -> PathRegistration -> IO () insertPathRefs db reg = do   let conn = sdbConn db       pathText = T.pack (storePathToFilePath (sdbDir db) (prPath reg))   referrerRows <- query conn "SELECT id FROM ValidPaths WHERE path = ?" (Only pathText) :: IO [Only Int]   case referrerRows of-    (Only referrerId : _) -> mapM_ (insertRef conn referrerId) (prReferences reg)+    (Only referrerId : _) -> do+      execute conn "DELETE FROM Refs WHERE referrer = ?" (Only referrerId)+      mapM_ (insertRef conn referrerId) (prReferences reg)     [] -> pure () -- Should not happen: the row was just inserted above.   where     insertRef conn referrerId refPath = do@@ -208,7 +244,15 @@       case refRows of         (Only refId : _) ->           execute conn "INSERT OR IGNORE INTO Refs (referrer, reference) VALUES (?, ?)" (referrerId, refId)-        [] -> pure () -- Reference not in this batch or the store yet - skip.+        [] ->+          throwIO+            ( userError+                ( "registerPaths: "+                    <> storePathToFilePath (sdbDir db) (prPath reg)+                    <> " references unregistered path "+                    <> T.unpack refPathText+                )+            )  -- --------------------------------------------------------------------------- -- Queries@@ -223,6 +267,13 @@  -- | Query the references of a registered store path. -- Returns the full path strings of referenced store paths.+--+-- Reads return the stored text without re-parsing: every row was+-- written from a validated 'StorePath' inside this module's+-- transactions, so this is trust-on-read of host-local state the+-- module itself wrote (the delete path documents the same stance for+-- its raw-basename key).  Callers that need a 'StorePath' back parse+-- at their own boundary. queryReferences :: StoreDB -> StorePath -> IO [Text] queryReferences db sp = do   let pathText = T.pack (storePathToFilePath (sdbDir db) sp)@@ -252,6 +303,54 @@   case rows of     (Only deriver : _) -> pure deriver     [] -> pure Nothing++-- ---------------------------------------------------------------------------+-- Unregistration+-- ---------------------------------------------------------------------------++-- | Outcome of 'unregisterPathRow'.+data UnregisterResult+  = -- | The row and its outgoing reference edges were removed.+    RowUnregistered+  | -- | No row carries this path text.+    RowAbsent+  | -- | Other valid paths still reference this one (their path texts,+    -- sorted); nothing was changed.+    RowReferenced ![Text]+  deriving (Eq, Show)++-- | Remove a path's ValidPaths row and outgoing Refs edges, keyed by the+-- EXACT stored path text.  Deliberately not keyed by 'StorePath': the+-- rows this exists to clean up include ones whose names the current+-- validator rejects, and those cannot round-trip through a parse.+--+-- Refuses while any OTHER valid path references this one; a+-- self-reference does not block.  Lookup, referrer check, and deletion+-- run in one transaction, so a registration cannot interleave between+-- the check and the delete.+unregisterPathRow :: StoreDB -> Text -> IO UnregisterResult+unregisterPathRow db pathText = withTransaction conn $ do+  idRows <- query conn "SELECT id FROM ValidPaths WHERE path = ?" (Only pathText) :: IO [Only Int]+  case idRows of+    [] -> pure RowAbsent+    (Only pathId : _) -> do+      referrerRows <-+        query+          conn+          "SELECT vp.path FROM Refs r \+          \JOIN ValidPaths vp ON r.referrer = vp.id \+          \WHERE r.reference = ? AND r.referrer != ? \+          \ORDER BY vp.path"+          (pathId, pathId) ::+          IO [Only Text]+      case [p | Only p <- referrerRows] of+        referrers@(_ : _) -> pure (RowReferenced referrers)+        [] -> do+          execute conn "DELETE FROM Refs WHERE referrer = ?" (Only pathId)+          execute conn "DELETE FROM ValidPaths WHERE id = ?" (Only pathId)+          pure RowUnregistered+  where+    conn = sdbConn db  -- | Query full path info for a registered store path. queryPathInfo :: StoreDB -> StorePath -> IO (Maybe PathInfo)
+ src/Nix/Store/ExecBit.hs view
@@ -0,0 +1,119 @@+-- | The executable bit, on a platform that does not have one.+--+-- Windows' 'System.Directory.getPermissions' answers from the file+-- extension, not a real bit, so a NAR round-trip through it loses+-- executability and the substituter rejects a mismatched hash. The bit is+-- instead stored in an NTFS alternate data stream ('execStreamName') whose+-- presence is the bit: set before a store path is sealed read-only, and+-- carried across copies explicitly since 'Dir.copyFile' drops it. On Unix+-- this all collapses to the mode bit.+module Nix.Store.ExecBit+  ( isExecutable,+    markExecutable,+    copyExecMark,+    serialiseFromPath,+    narHashOfPath,+    execStreamName,+  )+where++import Control.Exception (bracket_)+import Control.Monad (unless, when)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8+import qualified NovaCache.Hash as Hash+import qualified NovaCache.NAR as NAR+import qualified System.Directory as Dir+import qualified System.Info++-- | The alternate data stream whose presence marks a file executable.+-- Named for this project so it cannot collide with @Zone.Identifier@ or+-- another tool's stream.+execStreamName :: String+execStreamName = "nova.exec"++-- | Whether the exec bit needs the stream representation.  A plain+-- comparison rather than CPP: the same binary is not built for both, but+-- keeping one code path means the Unix branch is type-checked on Windows+-- and vice versa.+usesStream :: Bool+usesStream = System.Info.os == "mingw32"++-- | The stream that marks a file, addressable through ordinary file IO.+execStreamPath :: FilePath -> FilePath+execStreamPath path = path ++ ":" ++ execStreamName++-- | Whether a regular file is executable, by whichever representation the+-- platform keeps it in.+isExecutable :: FilePath -> IO Bool+isExecutable path+  | usesStream = Dir.doesFileExist (execStreamPath path)+  | otherwise = Dir.executable <$> Dir.getPermissions path++-- | Mark a regular file executable.+--+-- A stream cannot be created on a read-only file, and a copy out of a+-- sealed store path is read-only by the time it exists: @directory@'s+-- 'Dir.copyFile' is @atomicCopyFileContents@ with @copyPermissions@ from+-- the source as its post-action, and that post-action runs on the+-- replacement file just before it is renamed into place, so on Windows the+-- destination carries the source's @FILE_ATTRIBUTE_READONLY@ from the+-- moment it appears.  Marking before the copy is not an option either --+-- the rename would replace whatever stream had been written.  So the write+-- is bracketed by clearing and restoring the attribute, which leaves an+-- already-writable file exactly as it was, and every caller gets this+-- rather than each remembering the order.+markExecutable :: FilePath -> IO ()+markExecutable path+  | usesStream = do+      perms <- Dir.getPermissions path+      let writable = Dir.writable perms+      bracket_+        (unless writable (Dir.setPermissions path (Dir.setOwnerWritable True perms)))+        (unless writable (Dir.setPermissions path perms))+        (BS8.writeFile (execStreamPath path) (BS8.pack "1"))+  | otherwise = do+      perms <- Dir.getPermissions path+      Dir.setPermissions path (Dir.setOwnerExecutable True perms)++-- | Carry a file's exec mark from one path to another.  'Dir.copyFile'+-- copies the unnamed stream only, so without this a copy silently+-- de-executables its result.+copyExecMark :: FilePath -> FilePath -> IO ()+copyExecMark from to = do+  exec <- isExecutable from+  when exec (markExecutable to)++-- | nova-cache serialise options wiring 'isExecutable' in as the+-- exec-bit source of truth: the walk asks the resolver per regular+-- file, with the on-disk (case-hack-suffixed) name, which is exactly+-- the file 'isExecutable' stats.  On Unix 'isExecutable' reads the+-- owner-execute permission, the same answer the default resolver+-- gives, so one options value serves both platforms.+streamOptions :: NAR.SerialiseOptions+streamOptions = NAR.defaultSerialiseOptions {NAR.soExecBit = isExecutable}++-- | 'NovaCache.NAR.serialiseFromPath', with every regular file's+-- executable flag taken from 'isExecutable' rather than from the+-- file's permissions.  The walk is nova-cache's own -- name safety,+-- case-hack folding and entry ordering are subtle and belong there --+-- and the resolver hook (nova-cache 0.11.1) answers the flag during+-- the walk, replacing the post-hoc rewrite this module used to do.+serialiseFromPath :: FilePath -> IO NAR.NarEntry+serialiseFromPath = NAR.serialiseFromPathOpts streamOptions++-- | The NAR hash of a path, read through the same exec-bit source of+-- truth 'serialiseFromPath' writes.  Streams on every platform:+-- 'NAR.withNarSourceOpts' pulls the archive in chunks with the ADS+-- resolver answering the flags, and the digest folds over them, so no+-- file's contents are ever held whole.  Before the resolver hook,+-- Windows had to materialize the tree in memory to rewrite the flags.+narHashOfPath :: FilePath -> IO Hash.NixHash+narHashOfPath path =+  NAR.withNarSourceOpts streamOptions path $ \pull ->+    let go !ctx = do+          chunk <- pull+          if BS.null chunk+            then pure (Hash.hashFinalize ctx)+            else go (Hash.hashUpdate ctx chunk)+     in go Hash.hashInit
+ src/Nix/Store/Lock.hs view
@@ -0,0 +1,152 @@+-- | Per-store-path locking: mutual exclusion across delete, materialize,+-- and register.+--+-- == Why store paths lock+--+-- Two processes sharing a store can interleave substitution of one path:+-- process A removes the stale destination, process B materializes and+-- registers, then A materializes over B's tree - a valid database row+-- pointing at deleted or torn bytes.  Upstream C++ Nix+-- (@src\/libstore\/pathlocks.cc@, and the substitution path in+-- @local-store.cc@) prevents this with an exclusive lock on a+-- @\<store-path\>.lock@ file held across the whole+-- delete-materialize-register sequence, re-checking validity under the+-- lock so a waiter adopts the winner's finished work instead of redoing+-- it.+--+-- == Why filelock, not base's file locks+--+-- The lock is taken on a raw descriptor through the @filelock@+-- library (@flock@ on POSIX, @LockFileEx@ on Windows; CC0-licensed,+-- so nothing encumbers this package's Apache-2.0 distribution).+-- Base's 'GHC.IO.Handle.Lock' cannot express this lock: GHC's handle+-- registry forbids a second in-process writable handle on one file,+-- while Linux's open-file-description locks refuse an exclusive lock+-- on a read-only descriptor, so any 'System.IO.Handle' design must+-- pick between spurious in-process open failures and @EBADF@ at lock+-- time.  @filelock@'s descriptor lives outside the registry, opened+-- write-access and created atomically; the @flock@ lock attaches to+-- the open description, so it excludes other holders in this process+-- and any other, and releases when the description closes, which also+-- covers a crashed holder.  @flock@'s guarantee is for local+-- filesystems - the store's single-machine model.+--+-- == Lock files persist+--+-- Upstream deletes a lock file once its holder finishes, which opens the+-- deleted-lock-file hazard: a waiter blocked on the old file can acquire+-- it just after deletion and then hold a lock no later process can see;+-- upstream closes the hazard by writing a marker byte before deleting+-- and having every acquirer re-check the file it locked.  Here lock+-- files are never deleted: the file a waiter blocked on is always the+-- file the next holder locks, the marker dance disappears, and Windows -+-- where deleting a file another process holds open fails anyway - needs+-- no separate path.  The cost is one empty @\<store-path\>.lock@ per+-- substituted path left beside it in the store directory; the files are+-- inert debris, invisible to path queries (only exact store-path+-- basenames resolve).+module Nix.Store.Lock+  ( -- * Held locks+    PathLock,+    acquirePathLock,+    tryAcquirePathLock,+    releasePathLock,+    withPathLock,+    withLockFile,++    -- * Naming+    pathLockFilePath,+    lockFileSuffix,+  )+where++import Control.Concurrent.MVar (MVar, modifyMVar_, newMVar)+import Control.Exception (bracket)+import Nix.Store.Path (StoreDir, StorePath, storePathToFilePath)+import System.FileLock (FileLock, SharedExclusive (Exclusive), lockFile, tryLockFile, unlockFile)+import System.IO (hPutStrLn, stderr)++-- | An exclusive lock held on one store path: the lock file's path and+-- the descriptor whose OS lock is the exclusion.  The descriptor sits+-- behind an 'MVar' so release is idempotent and thread-safe: a bracket+-- and an explicit release can both fire on one lock without a+-- double-close.+data PathLock = PathLock !FilePath !(MVar (Maybe FileLock))++-- | Identity is the lock file; the descriptor is process-local plumbing.+instance Eq PathLock where+  PathLock leftFile _ == PathLock rightFile _ = leftFile == rightFile++instance Show PathLock where+  show (PathLock lockedFile _) = "PathLock " <> show lockedFile++-- | Upstream's lock-file naming convention: the lock for a store path+-- lives beside it, under the path's own name plus this suffix.+lockFileSuffix :: FilePath+lockFileSuffix = ".lock"++-- | The lock file guarding one store path.+pathLockFilePath :: StoreDir -> StorePath -> FilePath+pathLockFilePath dir sp = storePathToFilePath dir sp <> lockFileSuffix++-- | Take the exclusive lock on a store path, blocking until granted.+-- Blocking is upstream's behavior on a busy path lock, announced the+-- same way ('waitingForLockMessage') so a stalled substitution names+-- what it is waiting for.  The non-blocking probe runs first, so the+-- contended case announces itself before the wait begins; the lock+-- file is created if absent, atomically at the open.+acquirePathLock :: StoreDir -> StorePath -> IO PathLock+acquirePathLock dir sp = acquireLockFile (pathLockFilePath dir sp)++-- | Take the exclusive lock on a lock file named directly, blocking+-- until granted.  The raw form exists for the delete path: deletion+-- accepts basenames the current store-path name rules reject, so their+-- lock files cannot be named through 'StorePath' - yet they must be the+-- very files substituters of the same path contend on.+acquireLockFile :: FilePath -> IO PathLock+acquireLockFile lockPath = do+  probe <- tryLockFile lockPath Exclusive+  held <- case probe of+    Just granted -> pure granted+    Nothing -> do+      hPutStrLn stderr (waitingForLockMessage lockPath)+      lockFile lockPath Exclusive+  heldRef <- newMVar (Just held)+  pure (PathLock lockPath heldRef)++-- | Upstream's log line for a busy path lock.+waitingForLockMessage :: FilePath -> String+waitingForLockMessage lockPath = "waiting for lock on '" <> lockPath <> "'..."++-- | Take the exclusive lock only if it is free: 'Nothing' when another+-- holder - this process's or another's - already has it.+tryAcquirePathLock :: StoreDir -> StorePath -> IO (Maybe PathLock)+tryAcquirePathLock dir sp = do+  let lockPath = pathLockFilePath dir sp+  probe <- tryLockFile lockPath Exclusive+  case probe of+    Nothing -> pure Nothing+    Just granted -> do+      heldRef <- newMVar (Just granted)+      pure (Just (PathLock lockPath heldRef))++-- | Release a held lock.  Closing the descriptor releases the OS lock+-- under every backend; the lock file stays - never deleted, see the+-- module header.  Idempotent: a second release finds the descriptor+-- already surrendered and does nothing.+releasePathLock :: PathLock -> IO ()+releasePathLock (PathLock _ heldRef) = modifyMVar_ heldRef surrender+  where+    surrender Nothing = pure Nothing+    surrender (Just held) = do+      unlockFile held+      pure Nothing++-- | Run an action holding a path's lock, released on every exit.+withPathLock :: StoreDir -> StorePath -> (PathLock -> IO a) -> IO a+withPathLock dir sp = bracket (acquirePathLock dir sp) releasePathLock++-- | Run an action holding a directly named lock file's lock, released+-- on every exit.  See 'acquireLockFile' for why the raw form exists.+withLockFile :: FilePath -> (PathLock -> IO a) -> IO a+withLockFile lockPath = bracket (acquireLockFile lockPath) releasePathLock
src/Nix/Store/Path.hs view
@@ -38,18 +38,32 @@     windowsStoreDir,      -- * Store paths-    StorePath (..),+    StorePath (spHash, spName),+    StoreWriteMode (..),     storePathToFilePath,     storePathToText,+    isCanonicalStoreText,+    storeTextToFilePath,     parseStorePath,+    parseStorePathBaseName, +    -- * Name validation+    StorePathNameError (..),+    StorePathNameReason (..),+    checkStorePathName,+    validStorePathName,+    storePathNameErrorText,+    storePathNameReasonText,+     -- * Constants     storePathHashLen,   ) where +import Data.Char (isAsciiLower, isAsciiUpper, isDigit) import Data.Text (Text) import qualified Data.Text as T+import Nix.Store.Path.Internal (StorePath (..)) import System.FilePath ((</>)) import qualified System.Info @@ -67,8 +81,9 @@  -- | Platform-appropriate store directory. -- Returns @C:\\nix\\store@ on Windows, @\/nix\/store@ on Unix.--- Use this for filesystem operations and user-facing output.--- Use 'defaultStoreDir' only for Nix-internal canonical paths (ATerm hashing).+-- Use this for filesystem operations and operator-facing output.+-- Use 'defaultStoreDir' for identity - hashing, ATerm text, and every+-- eval-visible store-path string (including @builtins.storeDir@). platformStoreDir :: StoreDir platformStoreDir = case System.Info.os of   "mingw32" -> windowsStoreDir@@ -76,7 +91,6 @@  -- | Platform-appropriate store directory as 'Text'. -- Returns @C:\\nix\\store@ on Windows, @\/nix\/store@ on Unix.--- Used for user-facing values like @builtins.storeDir@. platformStoreDirText :: Text platformStoreDirText = T.pack (unStoreDir platformStoreDir) @@ -84,15 +98,6 @@ windowsStoreDir :: StoreDir windowsStoreDir = StoreDir "C:\\nix\\store" --- | A parsed store path: the hash and name components.-data StorePath = StorePath-  { -- | The 32-character Nix base-32 hash.-    spHash :: !Text,-    -- | The human-readable name (e.g. @hello-2.12.1@).-    spName :: !Text-  }-  deriving (Eq, Ord, Show)- -- | Convert a 'StorePath' to a full filesystem path under a 'StoreDir'. storePathToFilePath :: StoreDir -> StorePath -> FilePath storePathToFilePath (StoreDir dir) sp =@@ -105,6 +110,33 @@ storePathToText (StoreDir dir) sp =   T.pack dir <> "/" <> spHash sp <> "-" <> spName sp +-- | Whether path text lies under the canonical store dir: exactly+-- @\/nix\/store@, or @\/nix\/store@ followed by a separator of either+-- spelling.  This is the spelling writers put into eval-visible values.+isCanonicalStoreText :: Text -> Bool+isCanonicalStoreText txt = case T.stripPrefix defaultStoreDirText txt of+  Just rest -> case T.uncons rest of+    Nothing -> True+    Just (c, _) -> c == '/' || c == '\\'+  Nothing -> False++-- | Resolve path-value text to the filesystem location it names: text+-- under the canonical store dir resolves into the given store dir, and+-- every other path is taken as written.  Writers emit the canonical+-- spelling into eval values, so every reader that performs IO on a path+-- value must resolve through this - on Windows the rooted @\/nix@ prefix+-- would otherwise resolve against the working drive.+--+-- The store dir is a parameter rather than 'platformStoreDir' because a+-- caller may have been given one: resolving reads against the platform+-- default while writes went elsewhere is how @--store@ came to write to+-- one directory and read from another.+storeTextToFilePath :: StoreDir -> Text -> FilePath+storeTextToFilePath storeDir txt+  | isCanonicalStoreText txt =+      unStoreDir storeDir <> T.unpack (T.drop (T.length defaultStoreDirText) txt)+  | otherwise = T.unpack txt+ -- | Length of the Nix base-32 hash component in store paths (32 chars). storePathHashLen :: Int storePathHashLen = 32@@ -117,17 +149,126 @@ parseStorePath :: StoreDir -> Text -> Maybe StorePath parseStorePath (StoreDir dir) path =   let dirText = T.pack dir-      tryWithSep sep = T.stripPrefix (dirText <> sep) path >>= parseRest+      tryWithSep sep = T.stripPrefix (dirText <> sep) path >>= parseStorePathBaseName    in case tryWithSep "/" of         Just sp -> Just sp         Nothing -> tryWithSep "\\"++-- | Parse a store path basename like @abc...-name@ - the form narinfo+-- @References@ and @Deriver@ fields carry on the wire - into a+-- 'StorePath': 32 nix-base32 hash chars, dash, valid non-empty name.+--+-- Both components are charset-checked, as upstream does at its parse+-- boundary: parsed text can come from a cache, and an unchecked+-- component (separators, dots, drive colons in the hash or name slot)+-- would later become a filesystem path via 'storePathToFilePath' that+-- escapes the store root.+parseStorePathBaseName :: Text -> Maybe StorePath+parseStorePathBaseName basename+  | T.length basename < storePathHashLen + 2 = Nothing+  | otherwise =+      let hashPart = T.take storePathHashLen basename+          afterHash = T.drop storePathHashLen basename+       in case T.uncons afterHash of+            Just ('-', name)+              | T.all isNixBase32Char hashPart && validStorePathName name ->+                  Just (StorePath hashPart name)+            _ -> Nothing++-- | The nix-base32 alphabet: @0-9a-z@ without @e o u t@ (chosen upstream+-- to avoid accidental words).  Hash components may contain nothing else.+isNixBase32Char :: Char -> Bool+isNixBase32Char c =+  isDigit c+    || (isAsciiLower c && c /= 'e' && c /= 'o' && c /= 'u' && c /= 't')++-- | A rejected store-path name: the name itself plus the first rule it+-- broke, so every boundary reports the same diagnosis.+data StorePathNameError = StorePathNameError+  { -- | The rejected name.+    spneName :: !Text,+    -- | The rule it broke.+    spneReason :: !StorePathNameReason+  }+  deriving (Eq, Show)++-- | Which path-construction scheme named an eval-time store write:+-- upstream's text-path scheme (@builtins.toFile@), a recursive+-- fixed-output path (source copies, @builtins.path@), or a flat+-- fixed-output path (@builtins.fetchurl@).  Materialization re-derives+-- the path from on-disk content under the same scheme before+-- registering, so a tree that does not reproduce its path is refused+-- instead of registered valid under a hash its bytes do not have.+data StoreWriteMode = WriteText | WriteRecursive | WriteFlat+  deriving (Eq, Show)++-- | The store-path name rules, one constructor per rule.+data StorePathNameReason+  = -- | The name is empty.+    NameEmpty+  | -- | The name exceeds 'maxStorePathNameLen'; carries the actual length.+    NameTooLong !Int+  | -- | The name contains a character outside @[A-Za-z0-9+._?=-]@.+    NameIllegalChar !Char+  | -- | The first dash-separated component is the carried @.@ or @..@,+    -- which would name a dot segment on disk.+    NameDotSegment !Text+  deriving (Eq, Show)++-- | Upstream's store path name rules (its checkName boundary):+-- 1-211 characters from @[A-Za-z0-9+._?=-]@, and the first dash-separated+-- component may not be @.@ or @..@.  One rule rejects the traversal names+-- and their @.-@ / @..-@ prefixed forms alike, while other dot-leading+-- names (@.config-1.0@) stay valid.+--+-- Enforced at BOTH boundaries: parse ('parseStorePathBaseName') and+-- construction (the @makeStorePath@ family in "Nix.Hash"), so an unclean+-- name cannot become a 'StorePath' from either side - in particular, no+-- write sink can be handed a path that resolves outside the store root.+checkStorePathName :: Text -> Either StorePathNameError ()+checkStorePathName name+  | T.null name = broke NameEmpty+  | T.length name > maxStorePathNameLen = broke (NameTooLong (T.length name))+  | firstDashComponent == "." || firstDashComponent == ".." = broke (NameDotSegment firstDashComponent)+  | Just c <- T.find (not . isStorePathNameChar) name = broke (NameIllegalChar c)+  | otherwise = Right ()   where-    parseRest rest-      | T.length rest < storePathHashLen + 2 = Nothing-      | otherwise =-          let hashPart = T.take storePathHashLen rest-              afterHash = T.drop storePathHashLen rest-           in case T.uncons afterHash of-                Just ('-', name)-                  | not (T.null name) -> Just (StorePath hashPart name)-                _ -> Nothing+    broke = Left . StorePathNameError name+    firstDashComponent = T.takeWhile (/= '-') name+    isStorePathNameChar c =+      isAsciiLower c+        || isAsciiUpper c+        || isDigit c+        || c == '+'+        || c == '.'+        || c == '_'+        || c == '?'+        || c == '='+        || c == '-'++-- | Boolean form of 'checkStorePathName', for the parse boundary.+validStorePathName :: Text -> Bool+validStorePathName = either (const False) (const True) . checkStorePathName++-- | Render a rejection as @invalid store path name '<name>': <rule>@.+storePathNameErrorText :: StorePathNameError -> Text+storePathNameErrorText (StorePathNameError name reason) =+  "invalid store path name '" <> name <> "': " <> storePathNameReasonText reason++-- | Render just the broken rule, for callers that frame the name+-- themselves (e.g. @invalid derivation output name@).+storePathNameReasonText :: StorePathNameReason -> Text+storePathNameReasonText reason = case reason of+  NameEmpty -> "the name is empty"+  NameTooLong len ->+    "the name is "+      <> T.pack (show len)+      <> " characters, above the "+      <> T.pack (show maxStorePathNameLen)+      <> " maximum"+  NameIllegalChar c -> "contains the illegal character " <> T.pack (show c)+  NameDotSegment seg -> "the first dash-separated component may not be '" <> seg <> "'"++-- | Upstream's maximum store path name length.+maxStorePathNameLen :: Int+maxStorePathNameLen = 211
+ src/Nix/Store/Path/Internal.hs view
@@ -0,0 +1,38 @@+-- | The bare 'StorePath' representation.  Importing this module is an+-- assertion: every value constructed here satisfies the store-path+-- invariants (32 nix-base32 hash characters; a name+-- 'Nix.Store.Path.checkStorePathName' accepts) - or is+-- 'maskedOutputPath', which exists only for derivation-hash masking+-- and is never a real identity.  Production code imports this at+-- exactly the validated construction gates+-- ("Nix.Store.Path", "Nix.Hash") and the two documented+-- provenance-safe sites (the C string-context unmarshal, the masking+-- placeholder).  Everything else constructs through+-- 'Nix.Store.Path.parseStorePath' \/+-- 'Nix.Store.Path.parseStorePathBaseName' and reads through the+-- exported selectors.  Tests may construct fixtures freely.+module Nix.Store.Path.Internal+  ( StorePath (..),+    maskedOutputPath,+  )+where++import Data.Text (Text)++-- | A parsed store path: the hash and name components.+data StorePath = StorePath+  { -- | The 32-character Nix base-32 hash.+    spHash :: !Text,+    -- | The human-readable name (e.g. @hello-2.12.1@).+    spName :: !Text+  }+  deriving (Eq, Ord, Show)++-- | The empty output-path placeholder that derivation-hash masking+-- renders: upstream computes a derivation's modulo hash over an ATerm+-- whose output path fields are empty strings, and this value exists+-- only to carry that rendering through 'DerivationOutput'.  It is+-- never a real store identity and must never reach the store, the+-- builder, or a context element.+maskedOutputPath :: StorePath+maskedOutputPath = StorePath "" ""
src/Nix/Substituter.hs view
@@ -10,414 +10,1081 @@ -- 1. Compute the output store path hash from the derivation -- 2. @GET https:\/\/cache.example.com\/\<hash\>.narinfo@ -- 3. If 200: parse the narinfo (NAR hash, size, references, signature)--- 4. Verify the signature against a trusted public key--- 5. @GET https:\/\/cache.example.com\/nar\/\<narhash\>.nar.xz@--- 6. Decompress, verify NAR hash, unpack into store path--- 7. Register in the store DB with references from narinfo------ If the cache doesn't have it (404), fall through to building locally.------ == Cache priority------ Multiple caches can be configured, checked in priority order:------ @--- substituters = https:\/\/cache.novavero.ai https:\/\/cache.nixos.org--- trusted-public-keys = cache.novavero.ai-1:... cache.nixos.org-1:...--- @------ Our nova-cache server implements this protocol.  The narinfo format,--- NAR serialization, signature verification - all handled by the--- @nova-cache@ library.  This module orchestrates the HTTP requests--- and store registration.-module Nix.Substituter-  ( -- * Substitution-    SubstResult (..),-    trySubstitute,--    -- * Cache configuration-    CacheConfig (..),-    defaultCacheConfig,--    -- * Pure helpers (exported for testing)-    sortCaches,-    verifySigs,-    verifyNarHash,-    narInfoMatchesPath,-    decompressNar,-    unpackNarEntry,-    parseReferences,-  )-where--import Control.Concurrent (threadDelay)-import Control.Exception (SomeException, try)-import Control.Monad (when)-import qualified Data.ByteString as BS-import qualified Data.ByteString.Lazy as LBS-import Data.List (sortBy)-import Data.Ord (comparing)-import Data.Text (Text)-import qualified Data.Text as T-import qualified Data.Text.Encoding as TE-import qualified Network.HTTP.Client as HTTP-import qualified Network.HTTP.Client.TLS as HTTPS-import qualified Network.HTTP.Types.Status as HTTP-import Nix.Store (Store (..), setReadOnly)-import Nix.Store.DB (PathRegistration (..), registerPath)-import Nix.Store.Path (StoreDir, StorePath (..), parseStorePath, storePathHashLen, storePathToFilePath)-import qualified NovaCache.Hash as Hash-import qualified NovaCache.NAR as NAR-import qualified NovaCache.NarInfo as NarInfo-import qualified NovaCache.Signing as Signing-import System.Directory (createDirectoryIfMissing, setPermissions)-import qualified System.Directory as Dir-import System.FilePath (takeDirectory, (</>))---- ------------------------------------------------------------------------------ Types--- ------------------------------------------------------------------------------- | Configuration for a binary cache.-data CacheConfig = CacheConfig-  { -- | Base URL of the cache (e.g. @https:\/\/cache.novavero.ai@).-    ccUrl :: !Text,-    -- | Trusted public key for signature verification (@name:base64key@).-    ccPublicKey :: !Text,-    -- | Priority (lower = checked first). cache.nixos.org is 40.-    ccPriority :: !Int-  }-  deriving (Eq, Show)---- | Default cache configuration for cache.nixos.org.-defaultCacheConfig :: CacheConfig-defaultCacheConfig =-  CacheConfig-    { ccUrl = "https://cache.nixos.org",-      ccPublicKey = "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=",-      ccPriority = 40-    }---- | Result of a substitution attempt.-data SubstResult-  = -- | Successfully substituted - path is now in the store.-    SubstSuccess !StorePath-  | -- | Cache doesn't have this path.-    SubstNotFound-  | -- | Download or verification failed.-    SubstError !Text-  deriving (Eq, Show)---- ------------------------------------------------------------------------------ Main substitution logic--- ------------------------------------------------------------------------------- | Try to substitute a store path from configured caches.------ Checks each cache in priority order.  Returns on first success.--- Uses nova-cache library for narinfo parsing, NAR unpacking,--- and signature verification.-trySubstitute :: Store -> [CacheConfig] -> StorePath -> IO SubstResult-trySubstitute _ [] _ = pure SubstNotFound-trySubstitute store caches sp = do-  -- Reuse the process-global TLS manager (connection pooling / keep-alive)-  -- rather than creating a fresh one per call and per output.-  manager <- HTTPS.getGlobalManager-  tryCaches manager (sortCaches caches) sp-  where-    tryCaches _ [] _ = pure SubstNotFound-    tryCaches mgr (cache : rest) storePath = do-      result <- tryOneCache mgr store cache storePath-      case result of-        SubstNotFound -> tryCaches mgr rest storePath-        other -> pure other---- | Attempt substitution from a single cache, catching all exceptions.-tryOneCache :: HTTP.Manager -> Store -> CacheConfig -> StorePath -> IO SubstResult-tryOneCache mgr store cache sp = do-  result <- try (substituteFromCache mgr store cache sp)-  case result of-    Left (err :: SomeException) ->-      pure (SubstError ("substitution exception: " <> T.pack (show err)))-    Right substResult -> pure substResult---- | Substitution pipeline for a single cache.------ Each step is a pure or IO action that produces @Either@ on failure.--- The pipeline short-circuits on the first error via early return.-substituteFromCache :: HTTP.Manager -> Store -> CacheConfig -> StorePath -> IO SubstResult-substituteFromCache mgr store cache sp = do-  -- 1. Fetch narinfo-  narInfoResult <- fetchNarInfo mgr cache sp-  case narInfoResult of-    Left notFoundOrErr -> pure notFoundOrErr-    Right narInfo-      -- 1b. The served narinfo must describe the requested path.  A-      -- misconfigured or hostile cache could return a validly-signed narinfo-      -- for a DIFFERENT path under this hash's URL.-      | not (narInfoMatchesPath sp narInfo) ->-          pure-            ( SubstError-                ( "narinfo identity mismatch: requested "-                    <> spHash sp-                    <> ", narinfo names "-                    <> NarInfo.niStorePath narInfo-                )-            )-      | otherwise ->-          -- 2-4. Verify, download, decompress (pure pipeline after fetch)-          case verifyAndDecompress cache mgr narInfo of-            Left err -> pure (SubstError err)-            Right fetchDecompress -> do-              narBytes <- fetchDecompress-              case narBytes of-                Left err -> pure (SubstError err)-                Right rawNar -> unpackAndRegister store sp narInfo rawNar---- | Pure pipeline: verify signature, then produce an IO action--- that downloads and decompresses.-verifyAndDecompress ::-  CacheConfig ->-  HTTP.Manager ->-  NarInfo.NarInfo ->-  Either Text (IO (Either Text BS.ByteString))-verifyAndDecompress cache mgr narInfo = do-  verifySigs cache narInfo-  let compression = NarInfo.niCompression narInfo-  pure $ do-    downloaded <- downloadNarWithRetry mgr cache narInfo-    pure $ downloaded >>= decompressNar compression---- | Verify the NAR hash, deserialize, unpack to the store, set permissions,--- and register.-unpackAndRegister :: Store -> StorePath -> NarInfo.NarInfo -> BS.ByteString -> IO SubstResult-unpackAndRegister store sp narInfo rawNar =-  -- Verify the downloaded NAR's hash matches the (signed) narinfo BEFORE-  -- trusting its bytes.  The NAR hash is the content-addressed integrity-  -- contract; the signature only attests to the narinfo, not the body, so-  -- network corruption or a compromised cache must be caught here.-  case verifyNarHash narInfo rawNar of-    Left err -> pure (SubstError err)-    Right () -> case NAR.deserialise rawNar of-      Left err -> pure (SubstError ("NAR deserialisation failed: " <> T.pack err))-      Right narEntry -> do-        let destPath = storePathToFilePath (stDir store) sp-        unpackResult <- try (unpackNarEntry destPath narEntry)-        case (unpackResult :: Either SomeException (Either Text ())) of-          Left err -> pure (SubstError ("unpack failed: " <> T.pack (show err)))-          Right (Left err) -> pure (SubstError ("unpack failed: " <> err))-          Right (Right ()) -> do-            setReadOnly destPath-            registerPath-              (stDB store)-              PathRegistration-                { prPath = sp,-                  prNarHash = NarInfo.niNarHash narInfo,-                  prNarSize = fromInteger (NarInfo.niNarSize narInfo),-                  prDeriver = NarInfo.niDeriver narInfo,-                  prReferences = parseReferences (stDir store) (NarInfo.niReferences narInfo)-                }-            pure (SubstSuccess sp)---- | Verify that the downloaded NAR bytes hash to the narinfo's declared--- NarHash.  Compares decoded hash bytes, so any valid encoding of the digest--- validates; @prNarHash@ stays sourced from the (now-verified) narinfo.-verifyNarHash :: NarInfo.NarInfo -> BS.ByteString -> Either Text ()-verifyNarHash narInfo rawNar =-  case Hash.parseNixHash (NarInfo.niNarHash narInfo) of-    Left err -> Left ("invalid narinfo NarHash: " <> T.pack err)-    Right declared-      | declared == actual -> Right ()-      | otherwise ->-          Left-            ( "NAR hash mismatch: narinfo declares "-                <> NarInfo.niNarHash narInfo-                <> " but downloaded bytes hash to "-                <> Hash.formatNixHash actual-            )-  where-    actual = Hash.hashBytes rawNar---- | Whether a served narinfo describes the requested path: the hash component--- of its declared StorePath must equal the requested path's hash.  Only the--- hash is compared, since the cache may use a different store directory.-narInfoMatchesPath :: StorePath -> NarInfo.NarInfo -> Bool-narInfoMatchesPath sp narInfo =-  storePathHashOf (NarInfo.niStorePath narInfo) == Just (spHash sp)---- | Extract the leading hash from a full store path's basename, if well-formed--- (@\<hash\>-\<name\>@ with a hash of the expected length).-storePathHashOf :: Text -> Maybe Text-storePathHashOf path =-  let base = T.takeWhileEnd (\c -> c /= '/' && c /= '\\') path-      (hashPart, rest) = T.splitAt storePathHashLen base-   in if T.length hashPart == storePathHashLen && T.isPrefixOf "-" rest-        then Just hashPart-        else Nothing---- ------------------------------------------------------------------------------ HTTP fetching--- ------------------------------------------------------------------------------- | HTTP status code constants.-httpOk :: Int-httpOk = 200--httpNotFound :: Int-httpNotFound = 404---- | Fetch a narinfo from a cache.--- Returns @Left SubstNotFound@ on 404, @Left (SubstError msg)@ on other errors.-fetchNarInfo :: HTTP.Manager -> CacheConfig -> StorePath -> IO (Either SubstResult NarInfo.NarInfo)-fetchNarInfo mgr cache sp = do-  let url = T.unpack (ccUrl cache) <> "/" <> T.unpack (spHash sp) <> ".narinfo"-  request <- HTTP.parseRequest url-  response <- HTTP.httpLbs request mgr-  let code = HTTP.statusCode (HTTP.responseStatus response)-  if code == httpOk-    then case NarInfo.parseNarInfo (TE.decodeUtf8 (LBS.toStrict (HTTP.responseBody response))) of-      Left err -> pure (Left (SubstError ("narinfo parse error: " <> T.pack err)))-      Right ni -> pure (Right ni)-    else-      if code == httpNotFound-        then pure (Left SubstNotFound)-        else pure (Left (SubstError ("narinfo fetch failed: HTTP " <> T.pack (show code))))---- | How many times to attempt a NAR download before giving up and letting the--- caller fall back to a local build.  Matches Nix's @download-attempts@ default.-narDownloadAttempts :: Int-narDownloadAttempts = 5---- | Base delay between NAR download attempts, in microseconds.  The delay grows--- linearly with each retry (0.5s, 1s, ...).-narRetryBaseDelayMicros :: Int-narRetryBaseDelayMicros = 500000---- | Download a NAR, retrying transient failures.------ By the time this runs the narinfo has already been fetched and signature---- verified, so the cache claims to hold this path: a failed blob fetch (a--- transient HTTP error, a stale-negative at a CDN edge, or a dropped--- connection) is far more likely a hiccup than a real miss.  Retrying a few--- times is much cheaper than the local rebuild a hard failure forces.  A 404--- on the narinfo itself (a genuine cache miss) is handled earlier in--- 'fetchNarInfo' and never reaches here.-downloadNarWithRetry :: HTTP.Manager -> CacheConfig -> NarInfo.NarInfo -> IO (Either Text BS.ByteString)-downloadNarWithRetry mgr cache narInfo = attempt narDownloadAttempts-  where-    attempt remaining = do-      outcome <- try (downloadNar mgr cache narInfo)-      case outcome of-        Right (Right bytes) -> pure (Right bytes)-        Right (Left err) -> retryOr err remaining-        Left (e :: SomeException) -> retryOr ("NAR download error: " <> T.pack (show e)) remaining-    retryOr err remaining-      | remaining <= 1 = pure (Left err)-      | otherwise = do-          threadDelay (narRetryBaseDelayMicros * (narDownloadAttempts - remaining + 1))-          attempt (remaining - 1)---- | Download the NAR file referenced by a narinfo.------ The whole NAR is realized in memory (nova-cache's 'NAR.deserialise' consumes--- a strict 'BS.ByteString'), so a very large path briefly spikes RSS.  Streaming--- would need a streaming NAR parser that nova-cache does not yet provide.-downloadNar :: HTTP.Manager -> CacheConfig -> NarInfo.NarInfo -> IO (Either Text BS.ByteString)-downloadNar mgr cache narInfo = do-  let narUrl = T.unpack (ccUrl cache) <> "/" <> T.unpack (NarInfo.niUrl narInfo)-  request <- HTTP.parseRequest narUrl-  response <- HTTP.httpLbs request mgr-  let code = HTTP.statusCode (HTTP.responseStatus response)-  if code == httpOk-    then pure (Right (LBS.toStrict (HTTP.responseBody response)))-    else pure (Left ("NAR download failed: HTTP " <> T.pack (show code)))---- ------------------------------------------------------------------------------ Pure helpers--- ------------------------------------------------------------------------------- | Sort caches by priority (lower = first).-sortCaches :: [CacheConfig] -> [CacheConfig]-sortCaches = sortBy (comparing ccPriority)---- | Verify narinfo signatures against the cache's trusted public key.--- At least one signature must match.-verifySigs :: CacheConfig -> NarInfo.NarInfo -> Either Text ()-verifySigs cache narInfo =-  case Signing.parsePublicKey (ccPublicKey cache) of-    Left err -> Left ("invalid public key: " <> T.pack err)-    Right pubKey ->-      let sigs = NarInfo.niSigs narInfo-       in if null sigs-            then Left "narinfo has no signatures"-            else-              if any (Signing.verify pubKey narInfo) sigs-                then Right ()-                else Left "no valid signature found"---- | Decompress NAR data based on the compression type from narinfo.------ Currently supports @\"none\"@ (raw NAR) and @\"\"@ (empty = no compression).--- XZ decompression requires enabling the @compression@ flag in nova-cache.-decompressNar :: Text -> BS.ByteString -> Either Text BS.ByteString-decompressNar compression narData-  | compression == "none" || T.null compression = Right narData-  | compression == "xz" = Left "xz decompression not yet available (enable nova-cache compression flag)"-  | otherwise = Left ("unsupported compression: " <> compression)---- | Parse narinfo references (store path basenames) into StorePaths.-parseReferences :: StoreDir -> [Text] -> [StorePath]-parseReferences storeDir refs =-  [sp | ref <- refs, Just sp <- [parseStorePath storeDir ref]]---- ------------------------------------------------------------------------------ NAR unpacking--- ------------------------------------------------------------------------------- | Unpack a NarEntry tree to a filesystem destination.  Returns @Left@ on an--- unsafe entry name (path traversal); these come from untrusted cache data, so--- a typed failure is used instead of a partial 'error'.-unpackNarEntry :: FilePath -> NAR.NarEntry -> IO (Either Text ())-unpackNarEntry path entry = case entry of-  NAR.NarRegular isExec contents -> do-    createDirectoryIfMissing True (takeDirectory path)-    BS.writeFile path contents-    when isExec $ do-      perms <- Dir.getPermissions path-      setPermissions path (Dir.setOwnerExecutable True perms)-    pure (Right ())-  NAR.NarSymlink target -> do-    createDirectoryIfMissing True (takeDirectory path)-    -- On Windows, symlinks require elevated permissions.-    -- Fall back to writing the target as a text file.-    result <- try (Dir.createFileLink (T.unpack target) path)-    case result of-      Right () -> pure (Right ())-      Left (_ :: SomeException) -> do-        writeFile path (T.unpack target)-        pure (Right ())-  NAR.NarDirectory entries -> do-    createDirectoryIfMissing True path-    unpackChildren path entries---- | Unpack directory children, short-circuiting with a typed failure on the--- first unsafe entry name rather than crashing on untrusted input.-unpackChildren :: FilePath -> [(Text, NAR.NarEntry)] -> IO (Either Text ())-unpackChildren _ [] = pure (Right ())-unpackChildren path ((name, child) : rest)-  | not (isSafeNarName name) =-      pure (Left ("unsafe NAR directory entry name: " <> name))-  | otherwise = do-      result <- unpackNarEntry (path </> T.unpack name) child-      case result of-        Left err -> pure (Left err)-        Right () -> unpackChildren path rest---- | Validate that a NAR entry name is safe (no path traversal).-isSafeNarName :: Text -> Bool-isSafeNarName name =-  not (T.null name)-    && name /= ".."-    && name /= "."-    && not (T.isInfixOf "/" name)-    && not (T.isInfixOf "\\" name)+-- 4. Validate the narinfo's fields, then verify the signature against a+--    trusted public key+-- 5. @GET https:\/\/cache.example.com\/nar\/\<narhash\>.nar.xz@ and, in+--    one bounded streaming pass, decompress, hash, parse, and unpack+--    into the store path+-- 6. Verify the declared NAR hash and size against the streamed bytes,+--    then re-verify the materialized tree from disk+-- 7. Register in the store DB with references from narinfo+--+-- If the cache doesn't have it (404), fall through to building locally.+--+-- The whole sequence runs under an exclusive per-path lock+-- ('Nix.Store.Lock'), upstream's pathlocks protocol: taken before any+-- deletion or download, validity re-checked under it (another process's+-- finished path is adopted without touching disk), and held until the+-- caller's registration transaction commits.+--+-- == Cache priority+--+-- Multiple caches can be configured, checked in priority order:+--+-- @+-- substituters = https:\/\/cache.novavero.ai https:\/\/cache.nixos.org+-- trusted-public-keys = cache.novavero.ai-1:... cache.nixos.org-1:...+-- @+--+-- Our nova-cache server implements this protocol.  The narinfo format,+-- NAR serialization, signature verification - all handled by the+-- @nova-cache@ library.  This module orchestrates the HTTP requests+-- and store registration.+module Nix.Substituter+  ( -- * Substitution+    SubstResult (..),+    trySubstitute,++    -- * Cache configuration+    CacheConfig (..),+    defaultCacheConfig,++    -- * Failure classification+    AttemptFailure (..),+    attemptFailureMessage,+    catchSync,+    httpStatusFailure,+    compressedBodyCeiling,+    downloadCapFor,++    -- * Pure helpers (exported for testing)+    maxNarInfoBody,+    readBodyCapped,+    sortCaches,+    tryCachesWith,+    validateNarInfoFields,+    narInfoPreflight,+    verifySigs,+    verifyNarHash,+    verifyNarSize,+    narInfoMatchesPath,+    decompressorFor,+    decompressNar,+    streamingDecompressionSupported,+    withDecompressedSource,+    cappedBodySource,+    materializeNarFromSource,+    consumeNarStream,+    unpackNarEntry,+    unpackAndVerify,+    clearStaleDestination,+    parseReferences,+    parseDeriver,+  )+where++import Control.Applicative ((<|>))+import Control.Concurrent (threadDelay)+import Control.Exception (Exception, SomeAsyncException (..), SomeException, catch, fromException, onException, throwIO)+import Control.Monad (void)+import qualified Data.ByteString as BS+import Data.IORef (newIORef, readIORef, writeIORef)+import Data.List (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 Network.HTTP.Client as HTTP+import qualified Network.HTTP.Client.TLS as HTTPS+import qualified Network.HTTP.Types.Status as HTTP+import Nix.Compression (NarCompression (..), parseNarCompression)+import Nix.Store (PathLock, Store (..), abortNarUnpack, acquirePathLock, finishNarUnpack, isValid, newNarUnpackSink, releasePathLock, setReadOnly, sinkNarEvent, unpackNarEntry)+import Nix.Store.DB (PathRegistration (..))+import qualified Nix.Store.ExecBit as ExecBit+import Nix.Store.Path (StoreDir, StorePath (spHash), parseStorePathBaseName, storePathHashLen, storePathToFilePath)+import qualified NovaCache.Bzip2 as Bzip2+import qualified NovaCache.Hash as Hash+import qualified NovaCache.NAR as NAR+import qualified NovaCache.NAR.Stream as Stream+import qualified NovaCache.NarInfo as NarInfo+import qualified NovaCache.Signing as Signing+import qualified NovaCache.Validate as Validate+import qualified NovaCache.Xz as Xz+import qualified NovaCache.Zstd as Zstd+import qualified System.Directory as Dir++-- ---------------------------------------------------------------------------+-- Types+-- ---------------------------------------------------------------------------++-- | Configuration for a binary cache.+data CacheConfig = CacheConfig+  { -- | Base URL of the cache (e.g. @https:\/\/cache.novavero.ai@).+    ccUrl :: !Text,+    -- | Trusted public keys (@name:base64key@).  A narinfo is accepted+    -- when ANY of them verifies a signature, matching upstream's flat+    -- @trusted-public-keys@ set: the keys are not bound to a particular+    -- substituter, and one signed by any trusted key is enough.+    ccPublicKeys :: ![Text],+    -- | Priority (lower = checked first). cache.nixos.org is 40.+    ccPriority :: !Int+  }+  deriving (Eq, Show)++-- | Default cache configuration for cache.nixos.org.+defaultCacheConfig :: CacheConfig+defaultCacheConfig =+  CacheConfig+    { ccUrl = "https://cache.nixos.org",+      ccPublicKeys = ["cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="],+      ccPriority = 40+    }++-- | Result of a substitution attempt.+data SubstResult+  = -- | Verified and unpacked on disk, NOT yet registered: the carried+    -- registration is recorded by the caller, which batches every output+    -- of a derivation into one 'registerPaths' transaction so+    -- cross-output reference edges are never dropped.  The path's lock+    -- rides along STILL HELD, because the exclusion must survive until+    -- that transaction commits - released earlier, another process could+    -- meet the unpacked-but-unregistered window and delete the tree the+    -- row is about to describe.  The caller releases it after+    -- registration, on every exit path ('Nix.Builder').+    SubstSuccess !PathRegistration !PathLock+  | -- | The path was already valid under its lock - another process+    -- registered it while this one waited - so its work is adopted:+    -- nothing was downloaded, nothing touched disk, and no registration+    -- is needed.  The caller treats this as success.  No lock rides+    -- along; it was released before returning.+    SubstAlreadyValid+  | -- | Cache doesn't have this path.+    SubstNotFound+  | -- | Download or verification failed.+    SubstError !Text+  deriving (Eq, Show)++-- ---------------------------------------------------------------------------+-- Main substitution logic+-- ---------------------------------------------------------------------------++-- | Try to substitute a store path from configured caches.+--+-- Upstream's per-path substitution protocol: take the path's exclusive+-- lock FIRST, before any deletion or download, and re-check validity+-- under it - another process may have registered the path while this+-- one waited, and its finished work must be adopted+-- ('SubstAlreadyValid') rather than deleted and redone.  The lock then+-- holds across the delete, the download, materialization, and the+-- on-disk recheck; a successful result carries it still held (see+-- 'SubstSuccess'), and every other exit releases it here.+--+-- Caches are checked in priority order; the first success stops the+-- scan and a failing cache falls through to the remaining ones.  On+-- success the path is unpacked and read-only on disk but NOT+-- registered - the caller records the returned 'PathRegistration'.+trySubstitute :: Store -> [CacheConfig] -> StorePath -> IO SubstResult+trySubstitute _ [] _ = pure SubstNotFound+trySubstitute store caches sp = do+  -- Reuse the process-global TLS manager (connection pooling / keep-alive)+  -- rather than creating a fresh one per call and per output.+  manager <- HTTPS.getGlobalManager+  lock <- acquirePathLock (stDir store) sp+  substituteLocked manager lock `onException` releasePathLock lock+  where+    substituteLocked manager lock = do+      valid <- isValid store sp+      if valid+        then do+          releasePathLock lock+          pure SubstAlreadyValid+        else do+          result <- tryCachesWith (\cache -> tryOneCache manager store cache sp lock) (sortCaches caches)+          case result of+            SubstSuccess _ _ -> pure result+            other -> do+              releasePathLock lock+              pure other++-- | Fold per-cache attempts in priority order.  The first success wins and+-- stops the scan.  An erroring cache falls through to the remaining ones -+-- a transient failure (DNS, TLS, HTTP 500, unsupported compression) from a+-- higher-priority cache must not mask a hit in the next - and the first+-- error, tagged with its cache URL, is reported only when no cache has the+-- path.+tryCachesWith :: (Monad m) => (CacheConfig -> m SubstResult) -> [CacheConfig] -> m SubstResult+tryCachesWith attempt = go Nothing+  where+    go firstErr [] = pure (maybe SubstNotFound SubstError firstErr)+    go firstErr (cache : rest) = do+      result <- attempt cache+      case result of+        SubstSuccess reg lock -> pure (SubstSuccess reg lock)+        -- A path found valid mid-scan is terminal like a success:+        -- there is nothing left to fetch from any cache.+        SubstAlreadyValid -> pure SubstAlreadyValid+        SubstNotFound -> go firstErr rest+        SubstError err -> go (firstErr <|> Just (ccUrl cache <> ": " <> err)) rest++-- | Attempt substitution from a single cache.  Synchronous exceptions+-- become 'SubstError', so the scan falls through to the remaining+-- caches; asynchronous exceptions propagate ('catchSync') - an+-- interrupt mid-download must abort the scan, never continue to the+-- next cache and from there to a local build.+tryOneCache :: HTTP.Manager -> Store -> CacheConfig -> StorePath -> PathLock -> IO SubstResult+tryOneCache mgr store cache sp lock =+  substituteFromCache mgr store cache sp lock+    `catchSync` \err -> pure (SubstError ("substitution exception: " <> T.pack (show err)))++-- | Substitution pipeline for a single cache.+--+-- Each step is a pure or IO action that produces @Either@ on failure.+-- The pipeline short-circuits on the first error via early return.+substituteFromCache :: HTTP.Manager -> Store -> CacheConfig -> StorePath -> PathLock -> IO SubstResult+substituteFromCache mgr store cache sp lock = do+  -- 1. Fetch narinfo+  narInfoResult <- fetchNarInfo mgr cache sp+  case narInfoResult of+    Left notFoundOrErr -> pure notFoundOrErr+    Right narInfo+      -- 1b. The served narinfo must describe the requested path.  A+      -- misconfigured or hostile cache could return a validly-signed narinfo+      -- for a DIFFERENT path under this hash's URL.+      | not (narInfoMatchesPath sp narInfo) ->+          pure+            ( SubstError+                ( "narinfo identity mismatch: requested "+                    <> spHash sp+                    <> ", narinfo names "+                    <> NarInfo.niStorePath narInfo+                )+            )+      | otherwise ->+          -- 2-4. The pure preflight, then stream: download,+          -- decompress, hash, parse, and materialize in one bounded+          -- pass ('streamNarIntoStore').+          case narInfoPreflight cache narInfo of+            Left err -> pure (SubstError err)+            Right () -> streamWithRetry mgr store cache sp narInfo lock++-- | The pure preflight the pipeline runs before any download is paid+-- for, everything decided from the narinfo alone: field validation+-- FIRST - above all before 'verifySigs' builds the signed fingerprint+-- from the fields - then the signature, then streaming decompression+-- support.  Unsupported compression rejects here: the value is known+-- from the narinfo, and a multi-hundred-MB download that can only+-- fail in decompression is pure waste.  The strict 'decompressorFor'+-- encodes the same support set; the suite pins their agreement.+narInfoPreflight :: CacheConfig -> NarInfo.NarInfo -> Either Text ()+narInfoPreflight cache narInfo = do+  validateNarInfoFields narInfo+  verifySigs cache narInfo+  streamingDecompressionSupported (NarInfo.niCompression narInfo)++-- | Validate narinfo field syntax before anything consumes the fields -+-- above all before 'verifySigs' builds the signed fingerprint from them.+-- The fingerprint is delimited text (semicolons between fields, commas+-- between references), so each field must have parsed as well-formed+-- before it is spliced in; a malformed narinfo fails here as a plain+-- parse error instead of flowing onward.  The checks are nova-cache's+-- 'Validate.validateNarInfo': store path, references, hash spellings,+-- sizes, and the no-@.drv@ rule.+validateNarInfoFields :: NarInfo.NarInfo -> Either Text ()+validateNarInfoFields narInfo = case Validate.validateNarInfo narInfo of+  Right _ -> Right ()+  Left errs ->+    Left ("invalid narinfo: " <> T.intercalate "; " (map renderValidationError errs))++-- | One 'Validate.ValidationError' in the register the other+-- substitution errors use.+renderValidationError :: Validate.ValidationError -> Text+renderValidationError verr = case verr of+  Validate.NegativeFileSize n -> "negative FileSize " <> T.pack (show n)+  Validate.NegativeNarSize n -> "negative NarSize " <> T.pack (show n)+  Validate.InvalidStorePath raw parseErr -> fieldError "StorePath" raw parseErr+  Validate.InvalidFileHash raw parseErr -> fieldError "FileHash" raw parseErr+  Validate.InvalidNarHash raw parseErr -> fieldError "NarHash" raw parseErr+  Validate.InvalidReference raw parseErr -> fieldError "Reference" raw parseErr+  Validate.NarHashMismatch expected actual ->+    "NarHash mismatch: declared " <> expected <> ", computed " <> actual+  Validate.FileHashMismatch expected actual ->+    "FileHash mismatch: declared " <> expected <> ", computed " <> actual+  Validate.SignatureInvalid sig -> "signature failed verification: " <> sig+  Validate.NoSignatures -> "narinfo has no signatures"+  Validate.DerivationStorePath path -> "StorePath names a derivation: " <> path+  where+    fieldError field raw parseErr =+      field <> " '" <> raw <> "' does not parse: " <> T.pack parseErr++-- | Verify the NAR hash and size, deserialize, unpack to the store, and set+-- permissions.  Returns the path's registration for the caller to record;+-- no database write happens here (see 'SubstSuccess').  The strict+-- counterpart of the streaming pipeline, kept as its differential+-- oracle: the suite materializes the same NAR through both and+-- requires identical trees.  It follows the same per-path lock+-- protocol as the live pipeline: the lock is taken before the stale+-- destination is cleared, validity re-checks under it, and a success+-- carries the lock still held.+unpackAndVerify :: Store -> StorePath -> NarInfo.NarInfo -> BS.ByteString -> IO SubstResult+unpackAndVerify store sp narInfo rawNar =+  -- Verify the downloaded NAR's hash matches the (signed) narinfo BEFORE+  -- trusting its bytes.  The NAR hash is the content-addressed integrity+  -- contract; the signature only attests to the narinfo, not the body, so+  -- network corruption or a compromised cache must be caught here.+  -- Narinfo metadata is likewise parsed before any disk write: a malformed+  -- narinfo must not leave an unpacked-but-unregistered path behind.+  case verifiedInputs of+    Left err -> pure (SubstError err)+    Right (declared, (refs, deriver)) -> case NAR.deserialise rawNar of+      Left err -> pure (SubstError ("NAR deserialisation failed: " <> T.pack err))+      Right narEntry -> do+        lock <- acquirePathLock (stDir store) sp+        result <- unpackLocked declared refs deriver narEntry lock `onException` releasePathLock lock+        case result of+          SubstSuccess _ _ -> pure result+          other -> do+            releasePathLock lock+            pure other+  where+    unpackLocked declared refs deriver narEntry lock = do+      alreadyValid <- isValid store sp+      if alreadyValid+        then pure SubstAlreadyValid+        else do+          let destPath = storePathToFilePath (stDir store) sp+          unpackResult <-+            fmap+              Right+              ( do+                  clearStaleDestination destPath+                  unpackNarEntry destPath narEntry+              )+              `catchSync` (pure . Left)+          case (unpackResult :: Either SomeException (Either Text ())) of+            Left err -> pure (SubstError ("unpack failed: " <> T.pack (show err)))+            Right (Left err) -> pure (SubstError ("unpack failed: " <> err))+            Right (Right ()) -> do+              setReadOnly destPath+              -- A path registered valid must match its recorded hash ON+              -- DISK, not merely in the downloaded bytes: any divergence+              -- the filesystem introduced between the NAR and the+              -- materialized tree (name folding, link replication) must+              -- surface here, before the row exists.  A mismatching tree+              -- is removed - left in place it would be adopted by+              -- existence checks at this path.+              onDisk <- ExecBit.serialiseFromPath destPath+              case verifyNarHash narInfo (NAR.serialise onDisk) of+                Left _ -> do+                  Dir.removePathForcibly destPath+                  pure+                    ( SubstError+                        ( "unpacked tree does not reproduce the declared NAR hash at "+                            <> T.pack destPath+                        )+                    )+                Right _ ->+                  pure $+                    SubstSuccess+                      PathRegistration+                        { prPath = sp,+                          -- The canonical spelling of the verified digest,+                          -- so the DB converges on one hash spelling+                          -- regardless of the cache's.+                          prNarHash = Hash.formatNixHash declared,+                          -- The verified actual byte count (equal to the declared+                          -- NarSize per 'verifyNarSize') - no Integer conversion+                          -- that could wrap.+                          prNarSize = BS.length rawNar,+                          prDeriver = deriver,+                          prReferences = refs+                        }+                      lock+    verifiedInputs = do+      declared <- verifyNarHash narInfo rawNar+      verifyNarSize narInfo rawNar+      meta <- registrationMeta+      pure (declared, meta)+    registrationMeta = do+      refs <- parseReferences (NarInfo.niReferences narInfo)+      deriver <- parseDeriver (stDir store) (NarInfo.niDeriver narInfo)+      pure (refs, deriver)++-- | Verify that the downloaded NAR bytes hash to the narinfo's declared+-- NarHash, returning the decoded digest on success so registration can+-- record its canonical spelling.  Compares decoded hash bytes, so any+-- valid encoding of the digest validates.+verifyNarHash :: NarInfo.NarInfo -> BS.ByteString -> Either Text Hash.NixHash+verifyNarHash narInfo rawNar =+  case Hash.parseNixHash (NarInfo.niNarHash narInfo) of+    Left err -> Left ("invalid narinfo NarHash: " <> T.pack err)+    Right declared+      | declared == actual -> Right declared+      | otherwise ->+          Left+            ( "NAR hash mismatch: narinfo declares "+                <> NarInfo.niNarHash narInfo+                <> " but downloaded bytes hash to "+                <> Hash.formatNixHash actual+            )+  where+    actual = Hash.hashBytes rawNar++-- | Verify that the downloaded NAR's byte count equals the narinfo's+-- declared NarSize.  The hash check pins the content, but the size is a+-- separate signed claim that flows into the store DB (and from there+-- into re-pushed narinfos), so a wrong declaration must be rejected+-- rather than recorded.+verifyNarSize :: NarInfo.NarInfo -> BS.ByteString -> Either Text ()+verifyNarSize narInfo rawNar+  | toInteger (BS.length rawNar) == NarInfo.niNarSize narInfo = Right ()+  | otherwise =+      Left+        ( "NAR size mismatch: narinfo declares "+            <> T.pack (show (NarInfo.niNarSize narInfo))+            <> " bytes but downloaded "+            <> T.pack (show (BS.length rawNar))+        )++-- | Whether a served narinfo describes the requested path: the hash component+-- of its declared StorePath must equal the requested path's hash.  Only the+-- hash is compared, since the cache may use a different store directory.+narInfoMatchesPath :: StorePath -> NarInfo.NarInfo -> Bool+narInfoMatchesPath sp narInfo =+  storePathHashOf (NarInfo.niStorePath narInfo) == Just (spHash sp)++-- | Extract the leading hash from a full store path's basename, if well-formed+-- (@\<hash\>-\<name\>@ with a hash of the expected length).+storePathHashOf :: Text -> Maybe Text+storePathHashOf path =+  let base = T.takeWhileEnd (\c -> c /= '/' && c /= '\\') path+      (hashPart, rest) = T.splitAt storePathHashLen base+   in if T.length hashPart == storePathHashLen && T.isPrefixOf "-" rest+        then Just hashPart+        else Nothing++-- ---------------------------------------------------------------------------+-- HTTP fetching+-- ---------------------------------------------------------------------------++-- | HTTP status code constants.+httpOk :: Int+httpOk = 200++httpNotFound :: Int+httpNotFound = 404++-- | Cap on a narinfo response body, mirroring nova-cache's server-side+-- @maxNarInfoBodySize@ - the server bounds what it reads, and the+-- client bounds what any cache in its list can make it buffer.+-- Narinfo is small key-value text; 4 MB is far beyond any real one.+maxNarInfoBody :: Int+maxNarInfoBody = 4 * 1024 * 1024++-- | Read an HTTP response body in bounded chunks up to a byte cap -+-- 'Nothing' once the cap is exceeded, so an over-large body aborts+-- mid-stream instead of buffering without limit.  The client-side+-- mirror of nova-cache's @readBodyLimited@.+readBodyCapped :: Int -> HTTP.BodyReader -> IO (Maybe BS.ByteString)+readBodyCapped cap reader = go [] 0+  where+    go chunks !total = do+      chunk <- HTTP.brRead reader+      if BS.null chunk+        then pure (Just (BS.concat (reverse chunks)))+        else+          let newTotal = total + BS.length chunk+           in if newTotal > cap+                then pure Nothing+                else go (chunk : chunks) newTotal++-- | Fetch a narinfo from a cache.+-- Returns @Left SubstNotFound@ on 404, @Left (SubstError msg)@ on other errors.+fetchNarInfo :: HTTP.Manager -> CacheConfig -> StorePath -> IO (Either SubstResult NarInfo.NarInfo)+fetchNarInfo mgr cache sp = do+  let url = T.unpack (ccUrl cache) <> "/" <> T.unpack (spHash sp) <> ".narinfo"+  request <- HTTP.parseRequest url+  HTTP.withResponse request mgr $ \response -> do+    let code = HTTP.statusCode (HTTP.responseStatus response)+    -- Lenient decode: the body is cache-controlled bytes, and a stray+    -- invalid UTF-8 sequence must surface as a narinfo parse error, not an+    -- impure UnicodeException (the push side decodes the same way).+    if code == httpOk+      then do+        body <- readBodyCapped maxNarInfoBody (HTTP.responseBody response)+        case body of+          Nothing ->+            pure (Left (SubstError ("narinfo body exceeds " <> T.pack (show maxNarInfoBody) <> " bytes")))+          Just bytes -> case NarInfo.parseNarInfo (TE.decodeUtf8Lenient bytes) of+            Left err -> pure (Left (SubstError ("narinfo parse error: " <> T.pack err)))+            Right ni -> pure (Right ni)+      else+        if code == httpNotFound+          then pure (Left SubstNotFound)+          else pure (Left (SubstError ("narinfo fetch failed: HTTP " <> T.pack (show code))))++-- | How many times to attempt a NAR download before giving up and letting the+-- caller fall back to a local build.  Matches Nix's @download-attempts@ default.+narDownloadAttempts :: Int+narDownloadAttempts = 5++-- | Base delay between NAR download attempts, in microseconds.  The delay grows+-- linearly with each retry (0.5s, 1s, ...).+narRetryBaseDelayMicros :: Int+narRetryBaseDelayMicros = 500000++-- | Stream one substitution end to end, retrying transient failures.+--+-- By the time this runs the narinfo has already been fetched and+-- signature-verified, so the cache claims to hold this path.  Failures+-- carry their own retry class: a 'TransientFailure' - transport+-- errors, torn or truncated transfers, anything a fresh attempt could+-- plausibly complete - consumes retry budget with linear backoff,+-- while a 'FatalFailure' - a completed transfer that verifies wrong,+-- a body past its signed ceiling, a 4xx - ends the attempt at once:+-- retrying a deterministic failure only delays the local-build+-- fallback, and upstream's transfer layer likewise retries only the+-- transport class.  Every attempt starts from a clean slate+-- ('streamNarIntoStore' clears the destination first, and every+-- failure path, exceptions included, removes what it wrote).  A 404+-- on the narinfo itself (a genuine cache miss) is handled earlier in+-- 'fetchNarInfo' and never reaches here.+streamWithRetry :: HTTP.Manager -> Store -> CacheConfig -> StorePath -> NarInfo.NarInfo -> PathLock -> IO SubstResult+streamWithRetry mgr store cache sp narInfo lock = attempt narDownloadAttempts+  where+    attempt remaining = do+      outcome <- streamNarIntoStore mgr cache store sp narInfo+      case outcome of+        Right registration -> pure (SubstSuccess registration lock)+        Left (FatalFailure err) -> pure (SubstError err)+        Left (TransientFailure err)+          | remaining <= 1 -> pure (SubstError err)+          | otherwise -> do+              threadDelay (narRetryBaseDelayMicros * (narDownloadAttempts - remaining + 1))+              attempt (remaining - 1)++-- | How one streaming attempt failed, deciding whether the retry+-- budget applies.  'TransientFailure' is a failure a fresh attempt+-- could plausibly complete; 'FatalFailure' is deterministic - the+-- same served object fails the same way every time.+data AttemptFailure+  = TransientFailure !Text+  | FatalFailure !Text+  deriving (Eq, Show)++-- | The failure's message, independent of its retry class.+attemptFailureMessage :: AttemptFailure -> Text+attemptFailureMessage failure = case failure of+  TransientFailure msg -> msg+  FatalFailure msg -> msg++-- | Thrown inside the streaming pipeline where a chunk convention has+-- no error channel (the capped body source); converted back to the+-- pipeline's 'Left' at the attempt boundary in 'streamNarIntoStore'.+newtype StreamAbort = StreamAbort Text+  deriving (Show)++instance Exception StreamAbort++-- | Run an action, passing only synchronous exceptions to the handler.+-- Asynchronous exceptions (a Ctrl-C, a timeout) re-throw untouched: an+-- interrupt converted into a recoverable failure would be spent as+-- retry budget, as fallthrough to the next cache, or as a local build+-- instead of aborting.  Every catch-all on the substitution and build+-- paths goes through this one split.+catchSync :: IO a -> (SomeException -> IO a) -> IO a+catchSync action handler = action `catch` classify+  where+    classify someErr+      | Just (SomeAsyncException _) <- fromException someErr = throwIO someErr+      | otherwise = handler someErr++-- | Convert synchronous exceptions from one download attempt into the+-- pipeline's failure channel, so the retry budget governs them and the+-- caller's cleanup contract holds on every exit.  Asynchronous+-- exceptions re-throw untouched ('catchSync'): an interrupt must+-- never be spent as retry budget.+tryAttempt :: IO (Either AttemptFailure a) -> IO (Either AttemptFailure a)+tryAttempt action = action `catchSync` handler+  where+    handler someErr+      | Just (StreamAbort msg) <- fromException someErr =+          -- A body past its ceiling: the cap derives from the signed+          -- NarSize, so a longer body is the server misdeclaring, not+          -- a hiccup.+          pure (Left (FatalFailure msg))+      | Just (httpErr :: HTTP.HttpException) <- fromException someErr =+          -- Dropped connections, resets, timeouts - the class the+          -- retry budget exists for.+          pure (Left (TransientFailure ("HTTP transport failure: " <> T.pack (show httpErr))))+      | otherwise =+          -- Local failures (a full disk, a permission error) do not+          -- heal by re-downloading.+          pure (Left (FatalFailure ("substitution attempt failed: " <> T.pack (show (someErr :: SomeException)))))++-- | HTTP status codes whose failures a retry could plausibly outlive.+httpRequestTimeout :: Int+httpRequestTimeout = 408++httpTooManyRequests :: Int+httpTooManyRequests = 429++httpServerErrorFloor :: Int+httpServerErrorFloor = 500++-- | Classify a non-200 NAR response: server-side and rate-limit+-- statuses are transient, every other status (above all a 404 on an+-- object the narinfo just promised) is deterministic.+httpStatusFailure :: Int -> AttemptFailure+httpStatusFailure code+  | code == httpRequestTimeout || code == httpTooManyRequests || code >= httpServerErrorFloor =+      TransientFailure message+  | otherwise = FatalFailure message+  where+    message = "NAR download failed: HTTP " <> T.pack (show code)++-- | One streaming substitution attempt: download, decompress, hash,+-- parse, and materialize in a single bounded pass, then verify.+-- Memory is bounded by the decoder's buffers and the parser's+-- structural-string cap, never by archive or file size.+--+-- Disk writes begin before the NAR hash can be known - the price of+-- never holding the archive, and exactly upstream's ordering - so+-- every failure path, exceptions included, removes the tree it wrote+-- ('materializeNarFromSource' owns that contract; 'tryAttempt'+-- classifies what escapes it).  The narinfo metadata registration+-- needs (declared hash, references, deriver) still parses BEFORE the+-- first byte downloads: a malformed narinfo must not leave an+-- unpacked-but-unregistered path behind.+streamNarIntoStore :: HTTP.Manager -> CacheConfig -> Store -> StorePath -> NarInfo.NarInfo -> IO (Either AttemptFailure PathRegistration)+streamNarIntoStore mgr cache store sp narInfo = case preflight of+  Left err -> pure (Left (FatalFailure err))+  Right (declaredDigest, refs, deriver, downloadCap) -> do+    let destPath = storePathToFilePath (stDir store) sp+        narUrl = T.unpack (ccUrl cache) <> "/" <> T.unpack (NarInfo.niUrl narInfo)+    clearStaleDestination destPath+    request <- HTTP.parseRequest narUrl+    tryAttempt $ HTTP.withResponse request mgr $ \response -> do+      let code = HTTP.statusCode (HTTP.responseStatus response)+      if code /= httpOk+        then pure (Left (httpStatusFailure code))+        else do+          source <- cappedBodySource downloadCap (HTTP.responseBody response)+          materializeNarFromSource store sp narInfo declaredDigest refs deriver source+  where+    preflight = do+      declaredDigest <- case Hash.parseNixHash (NarInfo.niNarHash narInfo) of+        Left err -> Left ("invalid narinfo NarHash: " <> T.pack err)+        Right digest -> Right digest+      refs <- parseReferences (NarInfo.niReferences narInfo)+      deriver <- parseDeriver (stDir store) (NarInfo.niDeriver narInfo)+      downloadCap <- downloadCapFor narInfo+      pure (declaredDigest, refs, deriver, downloadCap)++-- | The byte cap on the compressed NAR body, derived from the SIGNED+-- NarSize: the Ed25519 fingerprint covers StorePath, NarHash, NarSize,+-- and references - not FileSize - so the unsigned FileSize may only+-- LOWER the cap, never raise it.  A rewritten FileSize on an otherwise+-- validly-signed narinfo must not buy an unbounded download.+downloadCapFor :: NarInfo.NarInfo -> Either Text Int+downloadCapFor narInfo+  | narSize < 0 || narSize > toInteger (maxBound :: Int) =+      Left ("narinfo declares an unusable NAR size: " <> T.pack (show narSize))+  | otherwise =+      let signedCeiling = compressedBodyCeiling narSize+          capped = maybe signedCeiling (min signedCeiling . max 0) (NarInfo.niFileSize narInfo)+       in Right (fromInteger (min capped (toInteger (maxBound :: Int))))+  where+    narSize = NarInfo.niNarSize narInfo++-- | The most a compressed NAR body may legitimately exceed its NarSize+-- by: xz and zstd expand incompressible input by well under one+-- percent of framing overhead, so a 1/64 (~1.6%) margin plus a fixed+-- floor for small NARs is generous for any real codec, while a+-- hostile FileSize claiming orders of magnitude more is refused.+compressedBodyCeiling :: Integer -> Integer+compressedBodyCeiling narSize =+  narSize + max compressionOverheadFloorBytes (narSize `div` compressionOverheadDivisor)++-- | Fixed overhead floor for small NARs, where framing dominates.+compressionOverheadFloorBytes :: Integer+compressionOverheadFloorBytes = 64 * 1024++-- | Proportional overhead margin: 1/64 of the NarSize.+compressionOverheadDivisor :: Integer+compressionOverheadDivisor = 64++-- | Materialize a NAR from a compressed chunk source into the store+-- path: decompress, hash, parse, and unpack in one pass, then verify+-- the tree on disk and build its registration.  The post-download half+-- of 'streamNarIntoStore', taking a plain chunk source so the failure+-- contract is testable without HTTP.+--+-- The cleanup contract: every failing exit - the pipeline's 'Left',+-- a verification mismatch, or an exception (asynchronous included) -+-- removes the destination tree, so no partial or unverified tree ever+-- survives at the store path.+materializeNarFromSource :: Store -> StorePath -> NarInfo.NarInfo -> Hash.NixHash -> [StorePath] -> Maybe Text -> IO BS.ByteString -> IO (Either AttemptFailure PathRegistration)+materializeNarFromSource store sp narInfo declaredDigest refs deriver source =+  materialize `onException` Dir.removePathForcibly destPath+  where+    destPath = storePathToFilePath (stDir store) sp+    materialize = do+      streamed <-+        withDecompressedSource (NarInfo.niNarSize narInfo) (NarInfo.niCompression narInfo) source $+          consumeNarStream destPath narInfo declaredDigest+      case streamed of+        Left err -> do+          Dir.removePathForcibly destPath+          pure (Left err)+        Right narByteCount -> do+          setReadOnly destPath+          -- A path registered valid must match its recorded hash ON+          -- DISK, not merely in the streamed bytes: any divergence the+          -- filesystem introduced between the NAR and the materialized+          -- tree must surface here, before the row exists.  The recheck+          -- streams too, so its memory no longer scales with the path.+          -- Through 'ExecBit', not nova-cache's walk directly: on Windows+          -- the sink writes the exec bit to a stream that walk cannot see,+          -- and a verifier reading the flag back out of the file extension+          -- would reject exactly the paths this representation exists for.+          -- On Unix it is still the streaming hash.+          onDiskDigest <- ExecBit.narHashOfPath destPath+          if onDiskDigest /= declaredDigest+            then do+              Dir.removePathForcibly destPath+              pure (Left (FatalFailure ("unpacked tree does not reproduce the declared NAR hash at " <> T.pack destPath)))+            else+              pure $+                Right+                  PathRegistration+                    { prPath = sp,+                      -- The canonical spelling of the verified digest,+                      -- so the DB converges on one hash spelling+                      -- regardless of the cache's.+                      prNarHash = Hash.formatNixHash declaredDigest,+                      prNarSize = narByteCount,+                      prDeriver = deriver,+                      prReferences = refs+                    }++-- | Drive the decompressed chunk source through incremental hashing,+-- the streaming NAR parser, and the store's streaming unpack sink,+-- returning the verified NAR byte count.  The hash context folds over+-- exactly the bytes the parser consumes, so the digest is of the NAR+-- the tree was built from.+consumeNarStream :: FilePath -> NarInfo.NarInfo -> Hash.NixHash -> IO BS.ByteString -> IO (Either AttemptFailure Int)+consumeNarStream destPath narInfo declaredDigest narSource = do+  sink <- newNarUnpackSink destPath+  go sink Hash.hashInit 0 Stream.narStream `onException` abortNarUnpack sink+  where+    go sink !ctx !narBytes step = case step of+      Stream.NarAwait continue -> do+        chunk <- narSource+        go sink (Hash.hashUpdate ctx chunk) (narBytes + BS.length chunk) (continue chunk)+      Stream.NarYield event next -> do+        sunk <- sinkNarEvent sink event+        case sunk of+          Left err -> do+            abortNarUnpack sink+            -- A name or shape the store refuses is a property of the+            -- archive, not of this transfer.+            pure (Left (FatalFailure err))+          Right () -> go sink ctx narBytes next+      Stream.NarFail msg -> do+        abortNarUnpack sink+        -- A truncated body and a torn transfer parse-fail the same+        -- way, so the retry budget applies.+        pure (Left (TransientFailure ("NAR stream parse failed: " <> T.pack msg)))+      Stream.NarDone -> do+        let digest = Hash.hashFinalize ctx+        if toInteger narBytes /= NarInfo.niNarSize narInfo+          then do+            abortNarUnpack sink+            -- The grammar completed, so the transfer was whole: a+            -- size that still disagrees is the narinfo misdeclaring.+            pure+              ( Left+                  ( FatalFailure+                      ( "NAR size mismatch: narinfo declares "+                          <> T.pack (show (NarInfo.niNarSize narInfo))+                          <> " bytes but the stream carried "+                          <> T.pack (show narBytes)+                      )+                  )+              )+          else+            if digest /= declaredDigest+              then do+                abortNarUnpack sink+                -- Size matched, so the transfer completed; wrong+                -- bytes are deterministic corruption, not a hiccup.+                pure+                  ( Left+                      ( FatalFailure+                          ( "NAR hash mismatch: narinfo declares "+                              <> NarInfo.niNarHash narInfo+                              <> " but downloaded bytes hash to "+                              <> Hash.formatNixHash digest+                          )+                      )+                  )+              else do+                finished <- finishNarUnpack sink+                case finished of+                  Left err -> pure (Left (FatalFailure err))+                  Right () -> pure (Right narBytes)++-- | Stream an HTTP body as a chunk source bounded by the download cap+-- 'downloadCapFor' derived from the signed NarSize -+-- 'readBodyCapped''s discipline without the buffering.  Exceeding the+-- cap throws 'StreamAbort'; the attempt boundary converts it back to+-- the pipeline's error channel.+cappedBodySource :: Int -> HTTP.BodyReader -> IO (IO BS.ByteString)+cappedBodySource cap reader = do+  countRef <- newIORef 0+  pure $ do+    chunk <- HTTP.brRead reader+    consumed <- readIORef countRef+    let total = consumed + BS.length chunk+    if total > cap+      then throwIO (StreamAbort ("NAR body exceeds its download ceiling (" <> T.pack (show cap) <> " bytes)"))+      else do+        writeIORef countRef total+        pure chunk++-- | Whether the streaming pipeline can decompress a narinfo+-- @Compression@ value, decided from the value alone so unsupported+-- compression rejects before any download.  Both this and the strict+-- 'decompressorFor' dispatch through 'parseNarCompression', so the+-- support set exists exactly once.+streamingDecompressionSupported :: Text -> Either Text ()+streamingDecompressionSupported = void . parseNarCompression++-- | Run a consumer over the decompressed view of a chunk source:+-- identity for 'CompressionNone', nova-cache's bounded decoders for+-- 'CompressionXz', 'CompressionZstd', and 'CompressionBzip2' (output+-- capped at the declared NarSize; thrown codec errors convert to the+-- pipeline's error channel here, carrying their retry class from+-- 'xzFailure', 'zstdFailure', and 'bzip2Failure').+withDecompressedSource :: Integer -> Text -> IO BS.ByteString -> (IO BS.ByteString -> IO (Either AttemptFailure a)) -> IO (Either AttemptFailure a)+withDecompressedSource declaredNarSize compression source consume =+  case parseNarCompression compression of+    Left err -> pure (Left (FatalFailure err))+    Right CompressionNone -> consume source+    Right CompressionXz -> case xzLimitsFor declaredNarSize of+      Left err -> pure (Left (FatalFailure err))+      Right limits ->+        Xz.withXzSource limits source consume+          `catch` \xzErr -> pure (Left (xzFailure xzErr))+    Right CompressionZstd -> case zstdLimitsFor declaredNarSize of+      Left err -> pure (Left (FatalFailure err))+      Right limits ->+        Zstd.withZstdSource limits source consume+          `catch` \zstdErr -> pure (Left (zstdFailure zstdErr))+    Right CompressionBzip2 -> case bzip2LimitsFor declaredNarSize of+      Left err -> pure (Left (FatalFailure err))+      Right limits ->+        Bzip2.withBzip2Source limits source consume+          `catch` \bzip2Err -> pure (Left (bzip2Failure bzip2Err))++-- | Classify a decoder failure: a stream error is how truncation and+-- torn transfers surface, so it retries; output or memory past the+-- declared bounds is the served object misdeclaring - deterministic.+xzFailure :: Xz.XzError -> AttemptFailure+xzFailure xzErr = case xzErr of+  Xz.XzStreamError _ -> TransientFailure rendered+  Xz.XzOutputOverBound _ -> FatalFailure rendered+  Xz.XzMemoryOverBound _ -> FatalFailure rendered+  where+    rendered = renderXzError xzErr++-- | 'xzFailure''s zstd counterpart, under the same taxonomy.+zstdFailure :: Zstd.ZstdError -> AttemptFailure+zstdFailure zstdErr = case zstdErr of+  Zstd.ZstdStreamError _ -> TransientFailure rendered+  Zstd.ZstdOutputOverBound _ -> FatalFailure rendered+  where+    rendered = renderZstdError zstdErr++-- | 'xzFailure''s bzip2 counterpart, under the same taxonomy.+bzip2Failure :: Bzip2.Bzip2Error -> AttemptFailure+bzip2Failure bzip2Err = case bzip2Err of+  Bzip2.Bzip2StreamError _ -> TransientFailure rendered+  Bzip2.Bzip2OutputOverBound _ -> FatalFailure rendered+  where+    rendered = renderBzip2Error bzip2Err++-- ---------------------------------------------------------------------------+-- Pure helpers+-- ---------------------------------------------------------------------------++-- | Sort caches by priority (lower = first).+sortCaches :: [CacheConfig] -> [CacheConfig]+sortCaches = sortBy (comparing ccPriority)++-- | Verify narinfo signatures against the cache's trusted public keys.+-- Accepts when at least one signature verifies under at least one key,+-- upstream's any-trusted-key rule.  A malformed key is an error rather+-- than a silently skipped one: upstream rejects the configuration, so a+-- typo cannot quietly narrow the trusted set to the keys that parsed.+verifySigs :: CacheConfig -> NarInfo.NarInfo -> Either Text ()+verifySigs cache narInfo =+  case NarInfo.niSigs narInfo of+    [] -> Left "narinfo has no signatures"+    sigs -> do+      keys <- traverse parseKey (ccPublicKeys cache)+      if any (\key -> any (Signing.verify key narInfo) sigs) keys+        then Right ()+        else Left "no valid signature found"+  where+    parseKey raw = case Signing.parsePublicKey raw of+      Left err -> Left ("invalid public key: " <> T.pack err)+      Right pubKey -> Right pubKey++-- | The whole-buffer decompressor for a narinfo @Compression@ value,+-- decided from the narinfo's declared values alone so unsupported+-- compression rejects before any download.  'CompressionNone' is+-- identity; 'CompressionXz' (cache.nixos.org's format),+-- 'CompressionZstd' (the modern caches'), and 'CompressionBzip2' (the+-- historical caches', and what an absent field means) decompress+-- bounded by the declared NarSize, a signed claim the caller validates+-- before resolving the decompressor.  The resolved function runs in IO+-- because the zstd and bzip2 decoders are IO-native (see+-- 'NovaCache.Zstd' and 'NovaCache.Bzip2'); the strict shape follows its+-- codecs.  Dispatches through 'parseNarCompression' like the streaming+-- path, so the support set exists exactly once.+decompressorFor :: Integer -> Text -> Either Text (BS.ByteString -> IO (Either Text BS.ByteString))+decompressorFor declaredNarSize compression = do+  kind <- parseNarCompression compression+  case kind of+    CompressionNone -> Right (pure . Right)+    CompressionXz -> do+      limits <- xzLimitsFor declaredNarSize+      Right (pure . either (Left . renderXzError) Right . Xz.decompress limits)+    CompressionZstd -> do+      limits <- zstdLimitsFor declaredNarSize+      Right (fmap (either (Left . renderZstdError) Right) . Zstd.decompress limits)+    CompressionBzip2 -> do+      limits <- bzip2LimitsFor declaredNarSize+      Right (fmap (either (Left . renderBzip2Error) Right) . Bzip2.decompress limits)++-- | Decompress NAR data based on the compression type from narinfo,+-- bounded by the declared NarSize.  Support is decided by+-- 'decompressorFor'; this applies the result.+decompressNar :: Integer -> Text -> BS.ByteString -> IO (Either Text BS.ByteString)+decompressNar declaredNarSize compression narData =+  case decompressorFor declaredNarSize compression of+    Left err -> pure (Left err)+    Right decompress -> decompress narData++-- | The bounds for one xz decode: output capped at the narinfo's+-- declared NarSize, decoder memory at nova-cache's default, so a+-- hostile stream can expand to neither more output nor more decoder+-- state than the narinfo promised.  Narinfo validation upstream+-- already rejected a negative size; the guard keeps the function+-- total for direct callers, and a size past Word64 cannot name a+-- real NAR.+xzLimitsFor :: Integer -> Either Text Xz.XzLimits+xzLimitsFor declaredNarSize+  | declaredNarSize < 0 || declaredNarSize > toInteger (maxBound :: Word64) =+      Left ("xz decompression bound out of range: " <> T.pack (show declaredNarSize))+  | otherwise =+      Right+        Xz.XzLimits+          { Xz.xzMaxOutputBytes = fromInteger declaredNarSize,+            Xz.xzMaxDecoderMemoryBytes = Xz.defaultXzDecoderMemoryBytes+          }++-- | One 'Xz.XzError' in the register the other substitution errors use.+renderXzError :: Xz.XzError -> Text+renderXzError xzErr = case xzErr of+  Xz.XzStreamError msg -> "xz stream error: " <> T.pack msg+  Xz.XzOutputOverBound bound ->+    "xz output exceeds the declared NarSize (" <> T.pack (show bound) <> " bytes)"+  Xz.XzMemoryOverBound bound ->+    "xz decoder memory over its cap (" <> T.pack (show bound) <> " bytes)"++-- | The bounds for one zstd decode: output capped at the narinfo's+-- declared NarSize; decoder state rides libzstd's built-in window+-- limit (see 'NovaCache.Zstd').  The same totality guard as+-- 'xzLimitsFor'.+zstdLimitsFor :: Integer -> Either Text Zstd.ZstdLimits+zstdLimitsFor declaredNarSize+  | declaredNarSize < 0 || declaredNarSize > toInteger (maxBound :: Word64) =+      Left ("zstd decompression bound out of range: " <> T.pack (show declaredNarSize))+  | otherwise = Right Zstd.ZstdLimits {Zstd.zstdMaxOutputBytes = fromInteger declaredNarSize}++-- | One 'Zstd.ZstdError' in the register the other substitution+-- errors use.+renderZstdError :: Zstd.ZstdError -> Text+renderZstdError zstdErr = case zstdErr of+  Zstd.ZstdStreamError msg -> "zstd stream error: " <> T.pack msg+  Zstd.ZstdOutputOverBound bound ->+    "zstd output exceeds the declared NarSize (" <> T.pack (show bound) <> " bytes)"++-- | The bounds for one bzip2 decode: output capped at the narinfo's+-- declared NarSize.  There is no decoder-memory knob to set - bzip2+-- carries no attacker-chosen dictionary size, so decoder state is a+-- small constant of the format (see 'NovaCache.Bzip2').  The same+-- totality guard as 'xzLimitsFor'.+bzip2LimitsFor :: Integer -> Either Text Bzip2.Bzip2Limits+bzip2LimitsFor declaredNarSize+  | declaredNarSize < 0 || declaredNarSize > toInteger (maxBound :: Word64) =+      Left ("bzip2 decompression bound out of range: " <> T.pack (show declaredNarSize))+  | otherwise = Right Bzip2.Bzip2Limits {Bzip2.bzip2MaxOutputBytes = fromInteger declaredNarSize}++-- | One 'Bzip2.Bzip2Error' in the register the other substitution+-- errors use.+renderBzip2Error :: Bzip2.Bzip2Error -> Text+renderBzip2Error bzip2Err = case bzip2Err of+  Bzip2.Bzip2StreamError msg -> "bzip2 stream error: " <> T.pack msg+  Bzip2.Bzip2OutputOverBound bound ->+    "bzip2 output exceeds the declared NarSize (" <> T.pack (show bound) <> " bytes)"++-- | Parse narinfo references (store path basenames, e.g.+-- @abc...-glibc-2.40@) into StorePaths.  A malformed token is an error,+-- not filtered: silently dropping a reference registers the path with a+-- hole in its closure, which re-push then publishes as a signed narinfo+-- missing runtime deps, and GC reads as permission to delete them.+parseReferences :: [Text] -> Either Text [StorePath]+parseReferences = traverse parseRef+  where+    parseRef ref =+      maybe (Left ("malformed narinfo reference: " <> ref)) Right (parseStorePathBaseName ref)++-- | The sentinel upstream caches emit for a path whose deriver is unknown.+unknownDeriverSentinel :: Text+unknownDeriverSentinel = "unknown-deriver"++-- | Parse a narinfo Deriver - a store path basename on the wire, or the+-- @unknown-deriver@ sentinel - into the full path text the store DB+-- records (the form 'Nix.Push' parses back with 'parseStorePath').+parseDeriver :: StoreDir -> Maybe Text -> Either Text (Maybe Text)+parseDeriver _ Nothing = Right Nothing+parseDeriver storeDir (Just txt)+  | txt == unknownDeriverSentinel = Right Nothing+  | otherwise = case parseStorePathBaseName txt of+      Just sp -> Right (Just (T.pack (storePathToFilePath storeDir sp)))+      Nothing -> Left ("malformed narinfo deriver: " <> txt)++-- ---------------------------------------------------------------------------+-- NAR unpacking+-- ---------------------------------------------------------------------------++-- | Remove a leftover destination tree before unpacking.  A crash (or a+-- failed registration) between 'setReadOnly' here and the caller's+-- 'Nix.Store.DB.registerPaths' leaves a read-only, unregistered tree;+-- unpacking over it would then fail with permission-denied on every+-- retry, permanently wedging substitution of that path.+-- 'Dir.removePathForcibly' clears read-only marks and accepts a missing+-- path, so the fresh unpack always starts from a clean slate.+-- Substitution callers reach this only holding the path's exclusive+-- lock ('trySubstitute', 'unpackAndVerify'): unlocked, this deletion is+-- exactly the race that lets one process remove a tree another just+-- registered.+clearStaleDestination :: FilePath -> IO ()+clearStaleDestination = Dir.removePathForcibly++-- unpackNarEntry lives in 'Nix.Store' (one tree-materializer for the+-- codebase) and is re-exported here for its historical callers and tests.
+ test/FetchurlFixture.hs view
@@ -0,0 +1,91 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Scoped loopback HTTP responses for fetch integrity and cancellation tests.+module FetchurlFixture (withFetchurlServer) where++import Control.Concurrent (newEmptyMVar, readMVar, threadDelay, tryPutMVar)+import Control.Concurrent.Async (race, wait, withAsync)+import Control.Exception (IOException, bracket, bracketOnError, finally, try)+import Control.Monad (forever, void)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BSC+import Data.IORef (atomicModifyIORef', newIORef, readIORef)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Network.Socket as Socket+import qualified Network.Socket.ByteString as Socket++-- | Loopback HTTP only: no external services, fixed ports, or subprocesses.+-- The callback gets the base URL, request history, and a first-request signal.+-- Socket and server-thread lifetimes are scoped to the callback.+withFetchurlServer :: (Text -> IO [BS.ByteString] -> IO () -> IO a) -> IO a+withFetchurlServer action = Socket.withSocketsDo $ do+  requests <- newIORef []+  requested <- newEmptyMVar+  let serve listener = forever $ bracket (fst <$> Socket.accept listener) Socket.close $ \client -> do+        header <- readHeaders client BS.empty+        path <- case BSC.words (BSC.takeWhile (/= '\r') header) of+          "GET" : target : _ -> pure target+          _ -> fail "unexpected fixture request"+        atomicModifyIORef' requests (\seen -> (path : seen, ()))+        case path of+          "/slow" -> do+            Socket.sendAll client "HTTP/1.1 200 OK\r\nContent-Length: 1000\r\nConnection: close\r\n\r\npartial"+            void (tryPutMVar requested ())+            sendSlowBody client slowBodyChunks+          _ -> do+            void (tryPutMVar requested ())+            Socket.sendAll client (response path)+  bracket listen Socket.close $ \listener -> do+    address <- Socket.getSocketName listener+    base <- case address of+      Socket.SockAddrInet port _ -> pure ("http://127.0.0.1:" <> T.pack (show port))+      _ -> fail "fixture did not bind IPv4"+    withAsync (serve listener) $ \worker -> do+      -- WinSock accept is a blocking safe FFI call. Close the listener before+      -- withAsync waits for cancellation, or that wait can never finish.+      outcome <- race (wait worker) (action base (reverse <$> readIORef requests) (readMVar requested)) `finally` Socket.close listener+      case outcome of+        Left () -> fail "fixture server stopped unexpectedly"+        Right result -> pure result+  where+    listen = bracketOnError (Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol) Socket.close $ \sock -> do+      Socket.bind sock (Socket.SockAddrInet 0 (Socket.tupleToHostAddress (127, 0, 0, 1)))+      Socket.listen sock fixtureBacklog+      pure sock+    readHeaders client bytes+      | "\r\n\r\n" `BS.isInfixOf` bytes = pure bytes+      | BS.length bytes > maxHeaderBytes = fail "fixture request headers too large"+      | otherwise = do+          chunk <- Socket.recv client headerChunkBytes+          if BS.null chunk then fail "incomplete fixture request" else readHeaders client (bytes <> chunk)+    -- Keep WinSock's safe recv calls returning while the response remains in+    -- flight: cancellation is delivered between chunks. A peer disconnect is+    -- expected when the cancellation test closes its HTTP response.+    sendSlowBody _ 0 = pure ()+    sendSlowBody client remaining = do+      sent <- try (Socket.sendAll client "x") :: IO (Either IOException ())+      case sent of+        Left _ -> pure ()+        Right () -> threadDelay slowBodyIntervalMicros >> sendSlowBody client (remaining - 1)+    response "/missing" = reply "404 Not Found" "missing"+    response "/error" = reply "500 Internal Server Error" "error"+    response "/partial" = "HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\nshort"+    response "/bad" = reply "200 OK" "wrong and longer than the expected output"+    response "/good" = reply "200 OK" "hello"+    response _ = reply "500 Internal Server Error" "unexpected request"+    reply status body =+      "HTTP/1.1 "+        <> status+        <> "\r\nContent-Length: "+        <> BSC.pack (show (BS.length body))+        <> "\r\nConnection: close\r\n\r\n"+        <> body++-- The slow body outlives the cancellation watchdog; fixture cleanup stops it.+slowBodyChunks, slowBodyIntervalMicros, fixtureBacklog, maxHeaderBytes, headerChunkBytes :: Int+slowBodyChunks = 600+slowBodyIntervalMicros = 100000+fixtureBacklog = 8+maxHeaderBytes = 8192+headerChunkBytes = 4096
test/Main.hs view
@@ -1,4833 +1,11214 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE PatternSynonyms #-}--module Main (main) where--import qualified Codec.Archive.Tar as Tar-import qualified Codec.Archive.Tar.Entry as TarEntry-import qualified Codec.Compression.Zstd.Lazy as ZstdL-import Control.Exception (bracket_)-import Control.Monad (filterM, void, when)-import qualified Data.ByteString as BS-import qualified Data.ByteString.Lazy as BL-import qualified Data.Map.Strict as Map-import Data.Maybe (fromMaybe, isJust)-import qualified Data.Set as Set-import Data.Text (Text)-import qualified Data.Text as T-import qualified Data.Text.Encoding as TE-import qualified Data.Text.IO as TIO-import Foreign.Ptr (castPtr)-import Foreign.StablePtr (StablePtr, castPtrToStablePtr, castStablePtrToPtr, deRefStablePtr, freeStablePtr, newStablePtr)-import Nix.Builder (BuildConfig (..), BuildResult (..), buildDerivation, buildWithDeps, defaultBuildConfig)-import Nix.Builder.Unpack (builtinUnpackBuilder, envSrcs)-import Nix.Builtins (builtinEnv, parseNixPath)-import qualified Nix.DependencyGraph as DepGraph-import Nix.Derivation (Derivation (..), DerivationOutput (..), Platform (..), currentPlatform, fromATerm, platformToText, toATerm)-import Nix.Eval (NixValue (..), StringContext (..), StringContextElement (..), attrSetFromMap, attrSetLookup, attrSetNull, attrSetSize, builtinNames, emptyContext, emptyEnv, eval, force, mkStr, readThunkValue, runPureEval)-import Nix.Eval.Arena (arenaDestroy, arenaInit)-import Nix.Eval.CAttrSet (cattrsetFreeze, cattrsetInsert, cattrsetKeys, cattrsetLookup, cattrsetNew, cattrsetSize, cattrsetUnion)-import Nix.Eval.CBytecode (binaryAdd, captureSlots, captureWithScopes, cbcArg1, cbcArg2, cbcArg3, cbcData, cbcFlags, cbcOpCount, cbcOpcode, cbcShortArg, formalName, formalNamedSet, formalSet, strpartInterp, strpartLit, unaryNegate, pattern OpApp, pattern OpAssert, pattern OpAttrs, pattern OpBinary, pattern OpHasAttr, pattern OpIf, pattern OpIndStr, pattern OpLambda, pattern OpLet, pattern OpList, pattern OpLitBool, pattern OpLitFloat, pattern OpLitInt, pattern OpLitNull, pattern OpLitPath, pattern OpLitUri, pattern OpResolvedVar, pattern OpSelect, pattern OpStr, pattern OpUnary, pattern OpVar, pattern OpWith, pattern OpWithVar)-import Nix.Eval.CThunk (CThunkPtr, cthunkCount, cthunkGet, cthunkMarkBlackhole, cthunkNew, cthunkNewComputed, cthunkPayload, cthunkSetComputed, cthunkState)-import Nix.Eval.Compile (compileExpr)-import qualified Nix.Eval.Context as Context-import Nix.Eval.IO (EvalState (..), newEvalState, runEvalIO)-import Nix.Eval.Symbol (Symbol (..), symbolCount, symbolIntern, symbolLen, symbolText)-import Nix.Eval.Types (emptyCList)-import Nix.Expr.Types-import qualified Nix.Hash as Hash-import Nix.Parser (parseNix)-import Nix.Parser.Lexer (Located (..), Token (..), tokenize)-import Nix.Push (computeClosure, mkNarInfo, narFileName, planMissing, storePathBasename, stripHashPrefix)-import Nix.Store (Store (..), addToStore, closeStore, isValid, openStore, pathExists, scanReferences, setReadOnly, writeDrv)-import Nix.Store.DB (PathInfo (..), PathRegistration (..), closeStoreDB, isValidPath, openStoreDB, queryDeriver, queryPathInfo, queryReferences, registerPath, registerPaths)-import Nix.Store.Path (StoreDir (..), StorePath (..), defaultStoreDir, parseStorePath, platformStoreDirText, storePathToFilePath, storePathToText, windowsStoreDir)-import qualified Nix.Substituter as Subst-import qualified NovaCache.Hash as CHash-import qualified NovaCache.NarInfo as NarInfo-import System.Directory (createDirectoryIfMissing, doesDirectoryExist, getPermissions, getTemporaryDirectory, removeDirectoryRecursive, writable)-import qualified System.Directory as Dir-import System.Exit (ExitCode (..), exitFailure, exitSuccess)-import System.FilePath ((</>))-import System.IO (BufferMode (..), hSetBuffering, stdout)-import qualified System.Info as SI-import qualified System.Process as Proc---- ------------------------------------------------------------------------------ Test harness--- -----------------------------------------------------------------------------data TestResult = Pass | Fail !Text--runTest :: Text -> TestResult -> IO Bool-runTest name result = case result of-  Pass -> do-    putStrLn $ "  PASS  " ++ T.unpack name-    pure True-  Fail msg -> do-    putStrLn $ "  FAIL  " ++ T.unpack name ++ ": " ++ T.unpack msg-    pure False---- | Like 'runTest' but for tests that need IO to produce their result.-runTestM :: Text -> IO TestResult -> IO Bool-runTestM name action = do-  result <- action-  runTest name result--assertEqual :: (Eq a, Show a) => Text -> a -> a -> TestResult-assertEqual label expected actual-  | expected == actual = Pass-  | otherwise =-      Fail $-        label-          <> ": expected "-          <> T.pack (show expected)-          <> " but got "-          <> T.pack (show actual)--assertRight :: (Show e) => Text -> Either e a -> (a -> TestResult) -> TestResult-assertRight label result check = case result of-  Left err -> Fail (label <> ": got error: " <> T.pack (show err))-  Right val -> check val--assertLeft :: (Show a) => Text -> Either e a -> TestResult-assertLeft _ (Left _) = Pass-assertLeft label (Right val) = Fail (label <> ": expected error but got: " <> T.pack (show val))---- | Helper: parse and check result.-assertParse :: Text -> Text -> Expr -> TestResult-assertParse label source expected =-  assertRight label (parseNix "<test>" source) $ \actual ->-    assertEqual label expected actual---- | Helper: extract just token types from Located list (drop positions and EOF).-tokenTypes :: [Located] -> [Token]-tokenTypes = filter (/= TokEOF) . map locToken---- | Helper: parse Nix source and evaluate with builtinEnv.-evalNix :: Text -> Either Text NixValue-evalNix source = case parseNix "<test>" source of-  Left err -> Left (T.pack (show err))-  Right expr -> runPureEval (eval (builtinEnv 0 []) expr)---- | Assert that a Nix expression evaluates to the expected value.-assertEval :: Text -> Text -> NixValue -> TestResult-assertEval label source expected =-  assertRight label (evalNix source) $ \actual ->-    assertEqual label expected actual---- | Assert that a Nix expression fails to evaluate.-assertEvalFail :: Text -> Text -> TestResult-assertEvalFail label source =-  assertLeft label (evalNix source)---- ------------------------------------------------------------------------------ Shell discovery for builder tests--- ------------------------------------------------------------------------------- | Find a POSIX-compatible shell for builder tests.--- On Unix, always @\/bin\/sh@.  On Windows, searches for @bash.exe@--- at known Git for Windows locations first, then PATH.  Checks known--- paths first to avoid picking up the WSL launcher at--- @C:\\Windows\\System32\\bash.exe@ which exits 1 when WSL is not--- configured.  Real Nix builders always use bash from the store ---- this bridges the gap until nova-nix bootstraps its own bash--- derivation.-findTestShell :: IO Text-findTestShell = case SI.os of-  "mingw32" -> do-    let known =-          [ "C:\\Program Files\\Git\\bin\\bash.exe",-            "C:\\Program Files (x86)\\Git\\bin\\bash.exe"-          ]-    found <- filterM Dir.doesFileExist known-    case found of-      (p : _) -> pure (T.pack p)-      [] -> do-        inPath <- Dir.findExecutable "bash"-        case inPath of-          Just p -> pure (T.pack p)-          Nothing ->-            error-              "bash not found: install Git for Windows or add bash to PATH"-  _ -> pure "/bin/sh"---- ------------------------------------------------------------------------------ Tests: Expr types (existing)--- -----------------------------------------------------------------------------testExprTypes :: IO [Bool]-testExprTypes = do-  putStrLn "expr/types"-  sequence-    [ runTest "int literal" $-        assertEqual "ELit NixInt" (ELit (NixInt 42)) (ELit (NixInt 42)),-      runTest "bool literal" $-        assertEqual "ELit NixBool" (ELit (NixBool True)) (ELit (NixBool True)),-      runTest "null literal" $-        assertEqual "ELit NixNull" (ELit NixNull) (ELit NixNull),-      runTest "var" $-        assertEqual "EVar" (EVar "x") (EVar "x"),-      runTest "string parts" $-        let parts = [StrLit "hello ", StrInterp (EVar "name")]-         in assertEqual "EStr" (EStr parts) (EStr parts),-      runTest "binary op" $-        let expr = EBinary OpAdd (ELit (NixInt 1)) (ELit (NixInt 2))-         in assertEqual "EBinary" expr expr,-      runTest "lambda" $-        let expr = ELambda (FormalName "x") (EVar "x") NoCaptureInfo-         in assertEqual "ELambda" expr expr,-      runTest "let binding" $-        let expr = ELet [NamedBinding [StaticKey "x"] (ELit (NixInt 1))] (EVar "x") NoCaptureInfo-         in assertEqual "ELet" expr expr,-      runTest "attrs" $-        let expr = EAttrs False [NamedBinding [StaticKey "a"] (ELit (NixInt 1))] NoCaptureInfo-         in assertEqual "EAttrs" expr expr,-      runTest "if-then-else" $-        let expr = EIf (ELit (NixBool True)) (ELit (NixInt 1)) (ELit (NixInt 2))-         in assertEqual "EIf" expr expr-    ]---- ------------------------------------------------------------------------------ Tests: Store paths (existing)--- -----------------------------------------------------------------------------testStorePaths :: IO [Bool]-testStorePaths = do-  putStrLn "store/path"-  let sp = StorePath {spHash = "s66mzxpvicwk07gjbjfw9izjfa797vsw", spName = "hello-2.12.1"}-  sequence-    [ runTest "default store dir" $-        assertEqual "defaultStoreDir" "/nix/store" (unStoreDir defaultStoreDir),-      runTest "windows store dir" $-        assertEqual "windowsStoreDir" "C:\\nix\\store" (unStoreDir windowsStoreDir),-      runTest "store path to text" $-        assertEqual-          "storePathToText"-          "/nix/store/s66mzxpvicwk07gjbjfw9izjfa797vsw-hello-2.12.1"-          (T.unpack (storePathToText defaultStoreDir sp)),-      runTest "store path ordering" $-        let sp2 = StorePath {spHash = "zzz", spName = "later"}-         in assertEqual "Ord" True (sp < sp2)-    ]---- ------------------------------------------------------------------------------ Tests: Derivation (existing)--- -----------------------------------------------------------------------------testDerivation :: IO [Bool]-testDerivation = do-  putStrLn "derivation"-  sequence-    [ runTest "current platform is known" $-        case currentPlatform of-          OtherPlatform _ -> Fail "currentPlatform returned OtherPlatform"-          _ -> Pass-    ]---- ------------------------------------------------------------------------------ Tests: Eval - Literals--- -----------------------------------------------------------------------------testEvalLiterals :: IO [Bool]-testEvalLiterals = do-  putStrLn "eval/literals"-  sequence-    [ runTest "empty env" $-        assertEqual "emptyEnv" emptyEnv emptyEnv,-      runTest "int" $-        assertEval "int" "42" (VInt 42),-      runTest "float" $-        assertEval "float" "3.14" (VFloat 3.14),-      runTest "bool true" $-        assertEval "true" "true" (VBool True),-      runTest "null" $-        assertEval "null" "null" VNull,-      runTest "string" $-        assertEval "string" "\"hello\"" (mkStr "hello")-    ]---- ------------------------------------------------------------------------------ Tests: Eval - Variables--- -----------------------------------------------------------------------------testEvalVariables :: IO [Bool]-testEvalVariables = do-  putStrLn "eval/variables"-  sequence-    [ runTest "let variable" $-        assertEval "let-var" "let x = 1; in x" (VInt 1),-      runTest "undefined variable" $-        assertEvalFail "undef" "x",-      runTest "builtin true" $-        assertEval "builtin-true" "true" (VBool True)-    ]---- ------------------------------------------------------------------------------ Tests: Eval - Arithmetic--- -----------------------------------------------------------------------------testEvalArithmetic :: IO [Bool]-testEvalArithmetic = do-  putStrLn "eval/arithmetic"-  sequence-    [ runTest "int add" $-        assertEval "add" "1 + 2" (VInt 3),-      runTest "int sub" $-        assertEval "sub" "10 - 3" (VInt 7),-      runTest "int mul" $-        assertEval "mul" "4 * 5" (VInt 20),-      runTest "int div" $-        assertEval "div" "10 / 3" (VInt 3),-      runTest "float add" $-        assertEval "float-add" "1.5 + 2.5" (VFloat 4.0),-      runTest "int-float promotion" $-        assertEval "promote" "1 + 2.0" (VFloat 3.0),-      runTest "negate int" $-        assertEval "negate" "- 5" (VInt (-5)),-      runTest "division by zero" $-        assertEvalFail "div0" "1 / 0",-      runTest "absolute path lexes as path, not division" $-        assertEval "path-abs" "builtins.typeOf /abs/path" (mkStr "path"),-      runTest "bareword relative path lexes as path" $-        assertEval "path-rel" "builtins.typeOf foo/bar" (mkStr "path"),-      runTest "function applied to an absolute path argument" $-        assertEval "app-abs-path" "(p: builtins.typeOf p) /abs/path" (mkStr "path")-    ]---- ------------------------------------------------------------------------------ Tests: Eval - Comparison--- -----------------------------------------------------------------------------testEvalComparison :: IO [Bool]-testEvalComparison = do-  putStrLn "eval/comparison"-  sequence-    [ runTest "int eq" $-        assertEval "eq" "1 == 1" (VBool True),-      runTest "int neq" $-        assertEval "neq" "1 != 2" (VBool True),-      runTest "int lt" $-        assertEval "lt" "1 < 2" (VBool True),-      runTest "int gte" $-        assertEval "gte" "3 >= 3" (VBool True),-      runTest "string compare" $-        assertEval "str-lt" "\"abc\" < \"def\"" (VBool True)-    ]---- ------------------------------------------------------------------------------ Tests: Eval - Logic--- -----------------------------------------------------------------------------testEvalLogic :: IO [Bool]-testEvalLogic = do-  putStrLn "eval/logic"-  sequence-    [ runTest "and true" $-        assertEval "and-true" "true && true" (VBool True),-      runTest "and short-circuit" $-        assertEval "and-short" "false && true" (VBool False),-      runTest "or true" $-        assertEval "or-true" "true || false" (VBool True),-      runTest "or short-circuit" $-        assertEval "or-short" "false || true" (VBool True),-      runTest "not" $-        assertEval "not" "!false" (VBool True),-      runTest "implication false->x" $-        assertEval "impl-false" "false -> false" (VBool True),-      runTest "implication true->true" $-        assertEval "impl-true" "true -> true" (VBool True)-    ]---- ------------------------------------------------------------------------------ Tests: Eval - Strings--- -----------------------------------------------------------------------------testEvalStrings :: IO [Bool]-testEvalStrings = do-  putStrLn "eval/strings"-  sequence-    [ runTest "string concat" $-        assertEval "concat" "\"hello\" + \" world\"" (mkStr "hello world"),-      runTest "string interpolation" $-        assertEval "interp" "let x = \"world\"; in \"hello ${x}\"" (mkStr "hello world"),-      runTest "interpolation coerce int" $-        assertEval "coerce-int" "\"val=${builtins.toString 42}\"" (mkStr "val=42"),-      runTest "empty string" $-        assertEval "empty" "\"\"" (mkStr "")-    ]---- ------------------------------------------------------------------------------ Tests: Eval - If/Assert--- -----------------------------------------------------------------------------testEvalIfAssert :: IO [Bool]-testEvalIfAssert = do-  putStrLn "eval/if-assert"-  sequence-    [ runTest "if true" $-        assertEval "if-true" "if true then 1 else 2" (VInt 1),-      runTest "if false" $-        assertEval "if-false" "if false then 1 else 2" (VInt 2),-      runTest "assert pass" $-        assertEval "assert-pass" "assert true; 42" (VInt 42),-      runTest "assert fail" $-        assertEvalFail "assert-fail" "assert false; 42"-    ]---- ------------------------------------------------------------------------------ Tests: Eval - Let--- -----------------------------------------------------------------------------testEvalLet :: IO [Bool]-testEvalLet = do-  putStrLn "eval/let"-  sequence-    [ runTest "simple let" $-        assertEval "let" "let x = 1; in x" (VInt 1),-      runTest "multi let" $-        assertEval "multi" "let x = 1; y = 2; in x + y" (VInt 3),-      runTest "recursive let" $-        assertEval "rec-let" "let x = 1; y = x + 1; in y" (VInt 2)-    ]---- ------------------------------------------------------------------------------ Tests: Eval - Attribute sets--- -----------------------------------------------------------------------------testEvalAttrs :: IO [Bool]-testEvalAttrs = do-  putStrLn "eval/attrs"-  sequence-    [ runTest "simple select" $-        assertEval "select" "{ a = 1; }.a" (VInt 1),-      runTest "nested select" $-        assertEval "nested" "{ a = { b = 2; }; }.a.b" (VInt 2),-      runTest "select or default" $-        assertEval "default" "{ a = 1; }.b or 42" (VInt 42),-      runTest "has-attr true" $-        assertEval "has-true" "{ a = 1; } ? a" (VBool True),-      runTest "has-attr false" $-        assertEval "has-false" "{ a = 1; } ? b" (VBool False),-      runTest "nested attr path" $-        assertEval "dot-path" "{ a.b.c = 1; }.a.b.c" (VInt 1)-    ]---- ------------------------------------------------------------------------------ Tests: Eval - Recursive attribute sets--- -----------------------------------------------------------------------------testEvalRecAttrs :: IO [Bool]-testEvalRecAttrs = do-  putStrLn "eval/rec-attrs"-  sequence-    [ runTest "rec self-reference" $-        assertEval "rec-self" "rec { a = 1; b = a + 1; }.b" (VInt 2),-      runTest "rec mutual reference" $-        assertEval "rec-mutual" "rec { a = 1; b = a; }.b" (VInt 1)-    ]---- ------------------------------------------------------------------------------ Tests: Eval - Lists--- -----------------------------------------------------------------------------testEvalLists :: IO [Bool]-testEvalLists = do-  putStrLn "eval/lists"-  sequence-    [ runTest "list head" $-        assertEval "head" "builtins.head [ 1 2 3 ]" (VInt 1),-      runTest "list length" $-        assertEval "length" "builtins.length [ 1 2 3 ]" (VInt 3),-      runTest "list concat" $-        assertEval "concat" "builtins.length ([ 1 ] ++ [ 2 3 ])" (VInt 3)-    ]---- ------------------------------------------------------------------------------ Tests: Eval - Lambda--- -----------------------------------------------------------------------------testEvalLambda :: IO [Bool]-testEvalLambda = do-  putStrLn "eval/lambda"-  sequence-    [ runTest "identity" $-        assertEval "id" "(x: x) 42" (VInt 42),-      runTest "closure" $-        assertEval "closure" "let f = x: x + 1; in f 5" (VInt 6),-      runTest "set pattern" $-        assertEval "set-pat" "({ a, b }: a + b) { a = 1; b = 2; }" (VInt 3),-      runTest "default param" $-        assertEval "default" "({ a ? 10 }: a) { }" (VInt 10)-    ]---- ------------------------------------------------------------------------------ Tests: Eval - With--- -----------------------------------------------------------------------------testEvalWith :: IO [Bool]-testEvalWith = do-  putStrLn "eval/with"-  sequence-    [ runTest "with basic" $-        assertEval "with" "with { a = 1; }; a" (VInt 1),-      runTest "with lexical wins" $-        assertEval "lexical" "let a = 1; in with { a = 2; }; a" (VInt 1),-      -- EWithVar: with-scoped variable inside lambda (closure trimming must preserve with-scopes)-      runTest "with inside lambda" $-        assertEval-          "with-lambda"-          "with { a = 1; }; let f = x: a + x; in f 2"-          (VInt 3),-      -- Nested with: inner with wins-      runTest "nested with inner wins" $-        assertEval-          "nested-with"-          "with { a = 1; }; with { a = 2; }; a"-          (VInt 2),-      -- Let shadows with-      runTest "let shadows with" $-        assertEval-          "let-shadows-with"-          "with { a = 1; }; let a = 2; in a"-          (VInt 2),-      -- Builtin fallback: builtins still accessible inside with-      runTest "with builtin fallback" $-        assertEval-          "with-builtin"-          "with { a = 1; }; true"-          (VBool True),-      -- Builtin attr fallback: builtins.length still works inside with-      runTest "with builtin attr fallback" $-        assertEval-          "with-builtin-attr"-          "with { a = 1; }; builtins.length [1 2 3]"-          (VInt 3),-      -- With in rec attrs-      runTest "with in rec attrs" $-        assertEval-          "with-rec"-          "with { a = 1; }; rec { b = a + 1; c = b + 1; }.c"-          (VInt 3),-      -- AST test: parse "with a; b" produces EWithVar-      runTest "parse with produces EWithVar" $-        assertRight "with-ast" (parseNix "<test>" "with a; b") $ \case-          EWith (EVar "a") (EWithVar "b") -> Pass-          other -> Fail ("expected EWith (EVar a) (EWithVar b), got: " <> T.pack (show other)),-      -- AST test: formal wins over with-      runTest "parse lambda formal wins over with" $-        assertRight "formal-wins" (parseNix "<test>" "x: with a; x") $ \case-          ELambda _ (EWith _ (EResolvedVar 0 0)) _ -> Pass-          other -> Fail ("expected formal to win, got: " <> T.pack (show other)),-      -- Trimming test: lambda inside with gets CapturesWithScopes-      runTest "with lambda trimmed with CapturesWithScopes" $-        assertRight "with-trim" (parseNix "<test>" "with a; x: b + x") $ \case-          EWith _ (ELambda _ _ (CapturesWithScopes _)) -> Pass-          other -> Fail ("expected CapturesWithScopes, got: " <> T.pack (show other))-    ]---- ------------------------------------------------------------------------------ Tests: Eval - Builtins--- -----------------------------------------------------------------------------testEvalBuiltins :: IO [Bool]-testEvalBuiltins = do-  putStrLn "eval/builtins"-  sequence-    [ runTest "typeOf int" $-        assertEval "typeOf-int" "builtins.typeOf 42" (mkStr "int"),-      runTest "typeOf string" $-        assertEval "typeOf-str" "builtins.typeOf \"hi\"" (mkStr "string"),-      runTest "isNull true" $-        assertEval "isNull-t" "builtins.isNull null" (VBool True),-      runTest "isNull false" $-        assertEval "isNull-f" "builtins.isNull 1" (VBool False),-      runTest "stringLength" $-        assertEval "strlen" "builtins.stringLength \"hello\"" (VInt 5),-      runTest "toString int" $-        assertEval "toStr" "builtins.toString 42" (mkStr "42")-    ]---- ------------------------------------------------------------------------------ Tests: Eval - Errors--- -----------------------------------------------------------------------------testEvalErrors :: IO [Bool]-testEvalErrors = do-  putStrLn "eval/errors"-  sequence-    [ runTest "type error in add (set + list)" $-        assertEvalFail "type-add" "{} + []",-      runTest "call non-function" $-        assertEvalFail "call-non" "42 1",-      runTest "builtins.throw" $-        assertEvalFail "throw" "builtins.throw \"boom\""-    ]---- ------------------------------------------------------------------------------ Tests: Eval - Higher-order builtins--- -----------------------------------------------------------------------------testEvalHigherOrder :: IO [Bool]-testEvalHigherOrder = do-  putStrLn "eval/higher-order"-  sequence-    [ -- map-      runTest "map basic" $-        assertEval "map" "builtins.map (x: x + 1) [ 1 2 3 ] == [ 2 3 4 ]" (VBool True),-      runTest "map identity" $-        assertEval "map-id" "builtins.map (x: x) [ 1 2 ] == [ 1 2 ]" (VBool True),-      runTest "map empty" $-        assertEval "map-empty" "builtins.map (x: x) [ ]" (VList emptyCList),-      runTest "map lazy" $-        assertEval "map-lazy" "let xs = builtins.map (x: x * 2) [ 1 (throw \"boom\") 3 ]; in builtins.elemAt xs 0" (VInt 2),-      -- filter-      runTest "filter match" $-        assertEval "filter" "builtins.filter (x: x == 2) [ 1 2 3 ] == [ 2 ]" (VBool True),-      runTest "filter none" $-        assertEval "filter-none" "builtins.filter (x: false) [ 1 2 ] == [ ]" (VBool True),-      -- foldl'-      runTest "foldl' sum" $-        assertEval "foldl-sum" "builtins.foldl' (a: b: a + b) 0 [ 1 2 3 ]" (VInt 6),-      runTest "foldl' string concat" $-        assertEval "foldl-str" "builtins.foldl' (a: b: a + b) \"\" [ \"x\" \"y\" \"z\" ]" (mkStr "xyz"),-      runTest "foldl' empty" $-        assertEval "foldl-empty" "builtins.foldl' (a: b: a + b) 0 [ ]" (VInt 0),-      -- genList-      runTest "genList basic" $-        assertEval "genList" "builtins.genList (i: i * 2) 4 == [ 0 2 4 6 ]" (VBool True),-      runTest "genList zero" $-        assertEval "genList-0" "builtins.genList (i: i) 0" (VList emptyCList),-      runTest "genList lazy" $-        assertEval "genList-lazy" "let xs = builtins.genList (i: if i == 0 then 42 else throw \"boom\") 5; in builtins.elemAt xs 0" (VInt 42),-      -- sort-      runTest "sort ints" $-        assertEval "sort" "builtins.sort (a: b: a < b) [ 3 1 2 ] == [ 1 2 3 ]" (VBool True),-      runTest "sort already sorted" $-        assertEval "sort-sorted" "builtins.sort (a: b: a < b) [ 1 2 3 ] == [ 1 2 3 ]" (VBool True),-      -- concatMap-      runTest "concatMap" $-        assertEval "concatMap" "builtins.concatMap (x: [ x (x * 2) ]) [ 1 2 ] == [ 1 2 2 4 ]" (VBool True),-      -- any-      runTest "any true" $-        assertEval "any-t" "builtins.any (x: x == 2) [ 1 2 3 ]" (VBool True),-      runTest "any false" $-        assertEval "any-f" "builtins.any (x: x == 5) [ 1 2 3 ]" (VBool False),-      -- all-      runTest "all true" $-        assertEval "all-t" "builtins.all (x: x > 0) [ 1 2 3 ]" (VBool True),-      runTest "all false" $-        assertEval "all-f" "builtins.all (x: x > 1) [ 1 2 3 ]" (VBool False),-      -- elem-      runTest "elem found" $-        assertEval "elem-t" "builtins.elem 2 [ 1 2 3 ]" (VBool True),-      runTest "elem not found" $-        assertEval "elem-f" "builtins.elem 5 [ 1 2 3 ]" (VBool False),-      -- elemAt-      runTest "elemAt valid" $-        assertEval "elemAt" "builtins.elemAt [ 10 20 30 ] 1" (VInt 20),-      runTest "elemAt out of bounds" $-        assertEvalFail "elemAt-oob" "builtins.elemAt [ 1 2 ] 5",-      -- partition-      runTest "partition right" $-        assertEval "partition-right" "(builtins.partition (x: x > 2) [ 1 2 3 4 ]).right == [ 3 4 ]" (VBool True),-      runTest "partition wrong" $-        assertEval "partition-wrong" "(builtins.partition (x: x > 2) [ 1 2 3 4 ]).wrong == [ 1 2 ]" (VBool True),-      -- groupBy-      runTest "groupBy pos" $-        assertEval "groupBy-pos" "(builtins.groupBy (x: if x > 0 then \"pos\" else \"neg\") [ 1 (- 2) 3 ]).pos == [ 1 3 ]" (VBool True),-      runTest "groupBy neg" $-        assertEval "groupBy-neg" "(builtins.groupBy (x: if x > 0 then \"pos\" else \"neg\") [ 1 (- 2) 3 ]).neg == [ (- 2) ]" (VBool True),-      -- attrNames-      runTest "attrNames sorted" $-        assertEval "attrNames" "builtins.attrNames { b = 2; a = 1; c = 3; } == [ \"a\" \"b\" \"c\" ]" (VBool True),-      -- attrValues-      runTest "attrValues count" $-        assertEval "attrValues" "builtins.length (builtins.attrValues { a = 1; b = 2; })" (VInt 2),-      -- hasAttr-      runTest "hasAttr true" $-        assertEval "hasAttr-t" "builtins.hasAttr \"a\" { a = 1; }" (VBool True),-      runTest "hasAttr false" $-        assertEval "hasAttr-f" "builtins.hasAttr \"z\" { a = 1; }" (VBool False),-      -- getAttr-      runTest "getAttr" $-        assertEval "getAttr" "builtins.getAttr \"a\" { a = 42; }" (VInt 42),-      runTest "getAttr missing" $-        assertEvalFail "getAttr-miss" "builtins.getAttr \"z\" { a = 1; }",-      -- removeAttrs-      runTest "removeAttrs" $-        assertEval "removeAttrs" "builtins.attrNames (builtins.removeAttrs { a = 1; b = 2; c = 3; } [ \"b\" ]) == [ \"a\" \"c\" ]" (VBool True),-      -- intersectAttrs-      runTest "intersectAttrs" $-        assertEval "intersectAttrs" "(builtins.intersectAttrs { a = 1; b = 2; } { b = 20; c = 30; }).b" (VInt 20),-      runTest "intersectAttrs keys" $-        assertEval "intersectAttrs-keys" "builtins.attrNames (builtins.intersectAttrs { a = 1; b = 2; } { b = 20; c = 30; }) == [ \"b\" ]" (VBool True),-      -- catAttrs-      runTest "catAttrs" $-        assertEval "catAttrs" "builtins.catAttrs \"a\" [ { a = 1; } { b = 2; } { a = 3; } ] == [ 1 3 ]" (VBool True),-      -- listToAttrs-      runTest "listToAttrs" $-        assertEval "listToAttrs" "(builtins.listToAttrs [ { name = \"x\"; value = 1; } { name = \"y\"; value = 2; } ]).x" (VInt 1),-      -- substring-      runTest "substring basic" $-        assertEval "substr" "builtins.substring 1 2 \"hello\"" (mkStr "el"),-      runTest "substring clamped" $-        assertEval "substr-clamp" "builtins.substring 3 100 \"hello\"" (mkStr "lo"),-      -- concatStringsSep-      runTest "concatStringsSep" $-        assertEval "concatSep" "builtins.concatStringsSep \", \" [ \"a\" \"b\" \"c\" ]" (mkStr "a, b, c"),-      -- Partial application-      runTest "partial application" $-        assertEval "partial" "let f = builtins.map (x: x + 1); in f [ 1 2 3 ] == [ 2 3 4 ]" (VBool True)-    ]---- ------------------------------------------------------------------------------ Tests: Lexer--- -----------------------------------------------------------------------------testLexer :: IO [Bool]-testLexer = do-  putStrLn "parser/lexer"-  sequence-    [ runTest "integer" $-        assertRight "lex int" (tokenize "<test>" "42") $ \toks ->-          assertEqual "tokens" [TokInt 42] (tokenTypes toks),-      runTest "float" $-        assertRight "lex float" (tokenize "<test>" "3.14") $ \toks ->-          assertEqual "tokens" [TokFloat 3.14] (tokenTypes toks),-      runTest "true" $-        assertRight "lex true" (tokenize "<test>" "true") $ \toks ->-          assertEqual "tokens" [TokTrue] (tokenTypes toks),-      runTest "false" $-        assertRight "lex false" (tokenize "<test>" "false") $ \toks ->-          assertEqual "tokens" [TokFalse] (tokenTypes toks),-      runTest "null" $-        assertRight "lex null" (tokenize "<test>" "null") $ \toks ->-          assertEqual "tokens" [TokNull] (tokenTypes toks),-      runTest "identifier" $-        assertRight "lex ident" (tokenize "<test>" "foo") $ \toks ->-          assertEqual "tokens" [TokIdent "foo"] (tokenTypes toks),-      runTest "hyphened identifier" $-        assertRight "lex hyphened" (tokenize "<test>" "hello-world") $ \toks ->-          assertEqual "tokens" [TokIdent "hello-world"] (tokenTypes toks),-      runTest "path ./foo" $-        assertRight "lex path" (tokenize "<test>" "./foo") $ \toks ->-          assertEqual "tokens" [TokPath "./foo"] (tokenTypes toks),-      runTest "path ~/foo" $-        assertRight "lex path home" (tokenize "<test>" "~/foo") $ \toks ->-          assertEqual "tokens" [TokPath "~/foo"] (tokenTypes toks),-      runTest "search path" $-        assertRight "lex search path" (tokenize "<test>" "<nixpkgs>") $ \toks ->-          assertEqual "tokens" [TokSearchPath "nixpkgs"] (tokenTypes toks),-      runTest "URI" $-        assertRight "lex uri" (tokenize "<test>" "https://example.com") $ \toks ->-          assertEqual "tokens" [TokUri "https://example.com"] (tokenTypes toks),-      runTest "multi-char operators" $-        assertRight "lex ops" (tokenize "<test>" "++ // -> == != && || <= >=") $ \toks ->-          assertEqual-            "tokens"-            [TokConcat, TokUpdate, TokImpl, TokEq, TokNeq, TokAnd, TokOr, TokLte, TokGte]-            (tokenTypes toks),-      runTest "single-char operators" $-        assertRight "lex single ops" (tokenize "<test>" "+ - * ! ? < >") $ \toks ->-          assertEqual-            "tokens"-            [TokPlus, TokMinus, TokStar, TokNot, TokQuestion, TokLt, TokGt]-            (tokenTypes toks),-      runTest "division vs path" $-        assertRight "lex div" (tokenize "<test>" "6 / 3") $ \toks ->-          assertEqual "tokens" [TokInt 6, TokSlash, TokInt 3] (tokenTypes toks),-      runTest "string tokens" $-        assertRight "lex string" (tokenize "<test>" "\"hello\"") $ \toks ->-          assertEqual-            "tokens"-            [TokStringOpen, TokStringLit "hello", TokStringClose]-            (tokenTypes toks),-      runTest "empty string" $-        assertRight "lex empty string" (tokenize "<test>" "\"\"") $ \toks ->-          assertEqual-            "tokens"-            [TokStringOpen, TokStringClose]-            (tokenTypes toks),-      runTest "string interpolation tokens" $-        assertRight "lex interp" (tokenize "<test>" "\"a${x}b\"") $ \toks ->-          assertEqual-            "tokens"-            [ TokStringOpen,-              TokStringLit "a",-              TokInterpOpen,-              TokIdent "x",-              TokInterpClose,-              TokStringLit "b",-              TokStringClose-            ]-            (tokenTypes toks),-      runTest "line comment" $-        assertRight "lex comment" (tokenize "<test>" "# comment\n42") $ \toks ->-          assertEqual "tokens" [TokInt 42] (tokenTypes toks),-      runTest "block comment" $-        assertRight "lex block comment" (tokenize "<test>" "/* comment */ 42") $ \toks ->-          assertEqual "tokens" [TokInt 42] (tokenTypes toks),-      runTest "ellipsis" $-        assertRight "lex ellipsis" (tokenize "<test>" "...") $ \toks ->-          assertEqual "tokens" [TokEllipsis] (tokenTypes toks),-      runTest "punctuation" $-        assertRight "lex punct" (tokenize "<test>" ". @ : ; = ,") $ \toks ->-          assertEqual-            "tokens"-            [TokDot, TokAt, TokColon, TokSemicolon, TokAssign, TokComma]-            (tokenTypes toks),-      runTest "delimiters" $-        assertRight "lex delimiters" (tokenize "<test>" "( ) { } [ ]") $ \toks ->-          assertEqual-            "tokens"-            [TokLParen, TokRParen, TokLBrace, TokRBrace, TokLBracket, TokRBracket]-            (tokenTypes toks),-      runTest "keywords" $-        assertRight "lex keywords" (tokenize "<test>" "if then else let in with assert rec inherit") $ \toks ->-          assertEqual-            "tokens"-            [TokIf, TokThen, TokElse, TokLet, TokIn, TokWith, TokAssert, TokRec, TokInherit]-            (tokenTypes toks),-      runTest "or is identifier" $-        assertRight "lex or" (tokenize "<test>" "or") $ \toks ->-          assertEqual "tokens" [TokIdent "or"] (tokenTypes toks),-      runTest "string escape sequences" $-        assertRight "lex escapes" (tokenize "<test>" "\"\\n\\t\\\\\\\"\"") $ \toks ->-          assertEqual-            "tokens"-            [TokStringOpen, TokStringLit "\n\t\\\"", TokStringClose]-            (tokenTypes toks)-    ]---- ------------------------------------------------------------------------------ Tests: Parser expressions--- -----------------------------------------------------------------------------testParserExprs :: IO [Bool]-testParserExprs = do-  putStrLn "parser/exprs"-  sequence-    [ -- Atoms-      runTest "parse int" $-        assertParse "int" "42" (ELit (NixInt 42)),-      runTest "parse float" $-        assertParse "float" "3.14" (ELit (NixFloat 3.14)),-      runTest "parse true" $-        assertParse "true" "true" (ELit (NixBool True)),-      runTest "parse false" $-        assertParse "false" "false" (ELit (NixBool False)),-      runTest "parse null" $-        assertParse "null" "null" (ELit NixNull),-      runTest "parse var" $-        assertParse "var" "x" (EVar "x"),-      runTest "parse empty string" $-        assertParse "empty string" "\"\"" (EStr []),-      runTest "parse string literal" $-        assertParse "string" "\"hello\"" (EStr [StrLit "hello"]),-      runTest "parse string interpolation" $-        assertParse-          "interp"-          "\"hello ${name}\""-          (EStr [StrLit "hello ", StrInterp (EVar "name")]),-      runTest "parse nested string interpolation" $-        assertParse-          "nested interp"-          "\"${\"inner\"}\""-          (EStr [StrInterp (EStr [StrLit "inner"])]),-      -- Arithmetic-      runTest "parse add" $-        assertParse "add" "1 + 2" (EBinary OpAdd (ELit (NixInt 1)) (ELit (NixInt 2))),-      runTest "parse sub" $-        assertParse "sub" "3 - 1" (EBinary OpSub (ELit (NixInt 3)) (ELit (NixInt 1))),-      runTest "parse mul" $-        assertParse "mul" "2 * 3" (EBinary OpMul (ELit (NixInt 2)) (ELit (NixInt 3))),-      runTest "parse left-assoc add" $-        assertParse-          "left-assoc"-          "1 + 2 + 3"-          (EBinary OpAdd (EBinary OpAdd (ELit (NixInt 1)) (ELit (NixInt 2))) (ELit (NixInt 3))),-      runTest "parse precedence mul over add" $-        assertParse-          "precedence"-          "1 + 2 * 3"-          (EBinary OpAdd (ELit (NixInt 1)) (EBinary OpMul (ELit (NixInt 2)) (ELit (NixInt 3)))),-      -- Unary-      runTest "parse negation" $-        assertParse "negate" "-1" (EUnary OpNegate (ELit (NixInt 1))),-      runTest "parse logical not" $-        assertParse "not" "!true" (EUnary OpNot (ELit (NixBool True))),-      -- Non-associative-      runTest "parse eq" $-        assertParse "eq" "1 == 2" (EBinary OpEq (ELit (NixInt 1)) (ELit (NixInt 2))),-      runTest "parse lt" $-        assertParse "lt" "1 < 2" (EBinary OpLt (ELit (NixInt 1)) (ELit (NixInt 2))),-      -- Right-associative-      runTest "parse implication" $-        assertParse-          "impl"-          "a -> b -> c"-          (EBinary OpImpl (EVar "a") (EBinary OpImpl (EVar "b") (EVar "c"))),-      runTest "parse concat right" $-        assertParse-          "concat"-          "a ++ b ++ c"-          (EBinary OpConcat (EVar "a") (EBinary OpConcat (EVar "b") (EVar "c"))),-      runTest "parse update right" $-        assertParse-          "update"-          "a // b // c"-          (EBinary OpUpdate (EVar "a") (EBinary OpUpdate (EVar "b") (EVar "c"))),-      -- Lambda-      -- After variable resolution, lambda-bound vars become EResolvedVar.-      -- FormalName "x" maps to slot 0; FormalSet [a,b] maps to a=0, b=1;-      -- FormalNamedSet "args" [a] maps to args=0, a=1.-      runTest "parse simple lambda" $-        assertParse "lambda" "x: x" (ELambda (FormalName "x") (EResolvedVar 0 0) NoCaptureInfo),-      runTest "parse set pattern lambda" $-        assertParse-          "set pattern"-          "{ a, b }: a"-          ( ELambda-              (FormalSet [Formal "a" Nothing, Formal "b" Nothing] False)-              (EResolvedVar 0 0)-              NoCaptureInfo-          ),-      runTest "parse set pattern with defaults" $-        assertParse-          "defaults"-          "{ a ? 1 }: a"-          ( ELambda-              (FormalSet [Formal "a" (Just (ELit (NixInt 1)))] False)-              (EResolvedVar 0 0)-              NoCaptureInfo-          ),-      runTest "parse set pattern with ellipsis" $-        assertParse-          "ellipsis"-          "{ a, ... }: a"-          ( ELambda-              (FormalSet [Formal "a" Nothing] True)-              (EResolvedVar 0 0)-              NoCaptureInfo-          ),-      runTest "parse named set pattern (name@{...})" $-        assertParse-          "named set"-          "args@{ a }: a"-          ( ELambda-              (FormalNamedSet "args" [Formal "a" Nothing] False)-              (EResolvedVar 0 1)-              NoCaptureInfo-          ),-      runTest "parse named set pattern ({...}@name)" $-        assertParse-          "set@name"-          "{ a }@args: a"-          ( ELambda-              (FormalNamedSet "args" [Formal "a" Nothing] False)-              (EResolvedVar 0 1)-              NoCaptureInfo-          ),-      -- Application-      runTest "parse application" $-        assertParse "app" "f x" (EApp (EVar "f") (EVar "x")),-      runTest "parse left-assoc application" $-        assertParse "app left" "f x y" (EApp (EApp (EVar "f") (EVar "x")) (EVar "y")),-      runTest "parse application with parens" $-        assertParse-          "app parens"-          "f (1 + 2)"-          (EApp (EVar "f") (EBinary OpAdd (ELit (NixInt 1)) (ELit (NixInt 2)))),-      -- Select-      runTest "parse select" $-        assertParse "select" "a.b" (ESelect (EVar "a") [StaticKey "b"] Nothing),-      runTest "parse nested select" $-        assertParse-          "nested select"-          "a.b.c"-          (ESelect (EVar "a") [StaticKey "b", StaticKey "c"] Nothing),-      runTest "parse select or default" $-        assertParse-          "select or"-          "a.b or 1"-          (ESelect (EVar "a") [StaticKey "b"] (Just (ELit (NixInt 1)))),-      runTest "parse has-attr" $-        assertParse "has-attr" "a ? b" (EHasAttr (EVar "a") [StaticKey "b"]),-      -- Attr sets-      runTest "parse empty attrs" $-        assertParse "empty attrs" "{ }" (EAttrs False [] NoCaptureInfo),-      runTest "parse attrs with binding" $-        assertParse-          "attrs"-          "{ a = 1; }"-          (EAttrs False [NamedBinding [StaticKey "a"] (ELit (NixInt 1))] NoCaptureInfo),-      runTest "parse rec attrs" $-        assertParse-          "rec attrs"-          "rec { a = 1; }"-          (EAttrs True [NamedBinding [StaticKey "a"] (ELit (NixInt 1))] NoCaptureInfo),-      -- inherit x y; is desugared to x = x; y = y; by the resolution pass-      -- (needed because lambda formals are positional, not name-based).-      runTest "parse inherit" $-        assertParse-          "inherit"-          "{ inherit x y; }"-          (EAttrs False [NamedBinding [StaticKey "x"] (EVar "x"), NamedBinding [StaticKey "y"] (EVar "y")] NoCaptureInfo),-      runTest "parse inherit from" $-        assertParse-          "inherit from"-          "{ inherit (a) x; }"-          (EAttrs False [Inherit (Just (EVar "a")) ["x"]] NoCaptureInfo),-      -- Let/if/with/assert-      runTest "parse let" $-        assertParse-          "let"-          "let x = 1; in x"-          (ELet [NamedBinding [StaticKey "x"] (ELit (NixInt 1))] (EResolvedVar 0 0) NoCaptureInfo),-      runTest "parse if-then-else" $-        assertParse-          "if"-          "if true then 1 else 2"-          (EIf (ELit (NixBool True)) (ELit (NixInt 1)) (ELit (NixInt 2))),-      runTest "parse with" $-        assertParse-          "with"-          "with a; b"-          (EWith (EVar "a") (EWithVar "b")),-      runTest "parse assert" $-        assertParse-          "assert"-          "assert true; 1"-          (EAssert (ELit (NixBool True)) (ELit (NixInt 1))),-      -- Lists-      runTest "parse empty list" $-        assertParse "empty list" "[ ]" (EList []),-      runTest "parse list elements" $-        assertParse-          "list"-          "[ 1 2 3 ]"-          (EList [ELit (NixInt 1), ELit (NixInt 2), ELit (NixInt 3)]),-      -- Parens-      runTest "parse parens" $-        assertParse "parens" "(42)" (ELit (NixInt 42)),-      -- 'or' as identifier-      runTest "or as identifier" $-        assertParse "or ident" "or" (EVar "or"),-      -- 'or' as attr key-      runTest "or as attr key" $-        assertParse-          "or attr key"-          "{ or = 1; }"-          (EAttrs False [NamedBinding [StaticKey "or"] (ELit (NixInt 1))] NoCaptureInfo)-    ]---- ------------------------------------------------------------------------------ Tests: Parser errors--- -----------------------------------------------------------------------------testParserErrors :: IO [Bool]-testParserErrors = do-  putStrLn "parser/errors"-  sequence-    [ runTest "empty input" $-        assertLeft "empty" (parseNix "<test>" ""),-      runTest "unclosed paren" $-        assertLeft "unclosed paren" (parseNix "<test>" "(1"),-      runTest "unclosed string" $-        assertLeft "unclosed string" (parseNix "<test>" "\"hello"),-      runTest "unclosed brace" $-        assertLeft "unclosed brace" (parseNix "<test>" "{ a = 1;"),-      runTest "missing semicolon" $-        assertLeft "missing semi" (parseNix "<test>" "{ a = 1 }"),-      runTest "unclosed bracket" $-        assertLeft "unclosed bracket" (parseNix "<test>" "[ 1 2"),-      runTest "unexpected token" $-        assertLeft "unexpected" (parseNix "<test>" ")")-    ]---- ------------------------------------------------------------------------------ Tests: Parser integration--- -----------------------------------------------------------------------------testParserIntegration :: IO [Bool]-testParserIntegration = do-  putStrLn "parser/integration"-  sequence-    [ runTest "shell.nix pattern" $-        assertRight "shell.nix" (parseNix "<test>" "{ pkgs ? import <nixpkgs> {} }: pkgs.mkShell { buildInputs = [ pkgs.ghc ]; }") $ \case-          ELambda {} -> Pass-          other -> Fail ("expected ELambda, got: " <> T.pack (show other)),-      runTest "let with multiple bindings" $-        assertParse-          "multi-let"-          "let x = 1; y = 2; in x + y"-          ( ELet-              [ NamedBinding [StaticKey "x"] (ELit (NixInt 1)),-                NamedBinding [StaticKey "y"] (ELit (NixInt 2))-              ]-              (EBinary OpAdd (EResolvedVar 0 0) (EResolvedVar 0 1))-              NoCaptureInfo-          ),-      runTest "nested attr set" $-        assertParse-          "nested attrs"-          "{ a.b.c = 1; d = { e = 2; }; }"-          ( EAttrs-              False-              [ NamedBinding [StaticKey "a", StaticKey "b", StaticKey "c"] (ELit (NixInt 1)),-                NamedBinding [StaticKey "d"] (EAttrs False [NamedBinding [StaticKey "e"] (ELit (NixInt 2))] NoCaptureInfo)-              ]-              NoCaptureInfo-          ),-      runTest "indented string" $-        assertRight "ind string" (parseNix "<test>" "''hello''") $ \case-          EIndStr _ -> Pass-          other -> Fail ("expected EIndStr, got: " <> T.pack (show other)),-      -- Positional let/rec resolution tests-      runTest "let inherit from outer lambda" $-        assertRight "let-inherit-lambda" (parseNix "<test>" "x: let inherit x; in x") $ \case-          -- x: let inherit x; in x-          -- The lambda formal x is at level 0, index 0.-          -- The let scope is level 0 (for the let body).-          -- inherit x desugars to x = x where RHS resolves against outer-          -- (the lambda scope), so the let binding's RHS is EResolvedVar 0 0-          -- (one level up from the let to the lambda).-          -- The body x resolves to level 0, index 0 (the let scope).-          ELambda _ (ELet [NamedBinding [StaticKey "x"] _rhsExpr] (EResolvedVar 0 0) _) _ -> Pass-          other -> Fail ("expected ELambda with let-inherit, got: " <> T.pack (show other)),-      runTest "nested lambda in let" $-        assertEval-          "let-nested-lambda"-          "let f = x: x + 1; g = y: f y; in g 5"-          (VInt 6),-      runTest "rec attrs positional resolution" $-        assertEval-          "rec-positional"-          "let s = rec { a = 1; b = a + 1; }; in s.b"-          (VInt 2)-    ]---- ------------------------------------------------------------------------------ Tests: Batch 1 - Trivial pure builtins + constants--- -----------------------------------------------------------------------------testBatch1 :: IO [Bool]-testBatch1 = do-  putStrLn "eval/builtins-batch1"-  sequence-    [ -- isPath-      runTest "isPath true" $-        assertEval "isPath-t" "builtins.isPath ./foo" (VBool True),-      runTest "isPath false" $-        assertEval "isPath-f" "builtins.isPath \"foo\"" (VBool False),-      -- ceil-      runTest "ceil float" $-        assertEval "ceil" "builtins.ceil 1.2" (VInt 2),-      runTest "ceil int passthrough" $-        assertEval "ceil-int" "builtins.ceil 5" (VInt 5),-      runTest "ceil negative" $-        assertEval "ceil-neg" "builtins.ceil (- 1.7)" (VInt (-1)),-      runTest "ceil type error" $-        assertEvalFail "ceil-err" "builtins.ceil \"hi\"",-      -- floor-      runTest "floor float" $-        assertEval "floor" "builtins.floor 1.7" (VInt 1),-      runTest "floor int passthrough" $-        assertEval "floor-int" "builtins.floor 5" (VInt 5),-      runTest "floor negative" $-        assertEval "floor-neg" "builtins.floor (- 1.2)" (VInt (-2)),-      -- seq-      runTest "seq returns second" $-        assertEval "seq" "builtins.seq 1 42" (VInt 42),-      -- trace-      runTest "trace returns second" $-        assertEval "trace" "builtins.trace \"msg\" 42" (VInt 42),-      -- unsafeDiscardStringContext-      runTest "discardContext" $-        assertEval "discard" "builtins.unsafeDiscardStringContext \"hello\"" (mkStr "hello"),-      -- unsafeDiscardOutputDependency-      runTest "discardOutputDep" $-        assertEval "discardOut" "builtins.unsafeDiscardOutputDependency \"hello\"" (mkStr "hello"),-      -- baseNameOf-      runTest "baseNameOf string" $-        assertEval "baseName-str" "builtins.baseNameOf \"/foo/bar/baz\"" (mkStr "baz"),-      runTest "baseNameOf path" $-        assertEval "baseName-path" "builtins.baseNameOf ./foo/bar" (mkStr "bar"),-      runTest "baseNameOf no slash" $-        assertEval "baseName-flat" "builtins.baseNameOf \"filename\"" (mkStr "filename"),-      runTest "baseNameOf type error" $-        assertEvalFail "baseName-err" "builtins.baseNameOf 42",-      -- dirOf-      runTest "dirOf string" $-        assertEval "dirOf-str" "builtins.dirOf \"/foo/bar/baz\"" (mkStr "/foo/bar"),-      runTest "dirOf no slash" $-        assertEval "dirOf-flat" "builtins.dirOf \"filename\"" (mkStr "."),-      -- concatLists-      runTest "concatLists basic" $-        assertEval "concatLists" "builtins.concatLists [ [ 1 2 ] [ 3 ] [ 4 5 ] ] == [ 1 2 3 4 5 ]" (VBool True),-      runTest "concatLists empty" $-        assertEval "concatLists-empty" "builtins.concatLists [ ]" (VList emptyCList),-      runTest "concatLists type error" $-        assertEvalFail "concatLists-err" "builtins.concatLists [ 1 2 ]",-      -- lessThan-      runTest "lessThan true" $-        assertEval "lt-t" "builtins.lessThan 1 2" (VBool True),-      runTest "lessThan false" $-        assertEval "lt-f" "builtins.lessThan 2 1" (VBool False),-      runTest "lessThan strings" $-        assertEval "lt-str" "builtins.lessThan \"a\" \"b\"" (VBool True),-      -- Constants-      runTest "storeDir" $-        assertEval "storeDir" "builtins.storeDir" (mkStr platformStoreDirText),-      runTest "nixVersion" $-        assertEval "nixVersion" "builtins.nixVersion" (mkStr "2.24.0"),-      runTest "langVersion" $-        assertEval "langVersion" "builtins.langVersion" (VInt 6),-      runTest "nixPath" $-        assertEval "nixPath" "builtins.nixPath" (VList emptyCList)-    ]---- ------------------------------------------------------------------------------ Tests: Batch 2 - Arithmetic + bitwise builtins--- -----------------------------------------------------------------------------testBatch2 :: IO [Bool]-testBatch2 = do-  putStrLn "eval/builtins-batch2"-  sequence-    [ -- add-      runTest "add ints" $-        assertEval "add-int" "builtins.add 3 4" (VInt 7),-      runTest "add int+float" $-        assertEval "add-mixed" "builtins.add 1 2.5" (VFloat 3.5),-      runTest "add type error" $-        assertEvalFail "add-err" "builtins.add \"a\" 1",-      -- sub-      runTest "sub ints" $-        assertEval "sub-int" "builtins.sub 10 3" (VInt 7),-      runTest "sub float" $-        assertEval "sub-float" "builtins.sub 5.5 2.0" (VFloat 3.5),-      -- mul-      runTest "mul ints" $-        assertEval "mul-int" "builtins.mul 3 4" (VInt 12),-      runTest "mul float" $-        assertEval "mul-float" "builtins.mul 2 3.0" (VFloat 6.0),-      -- div-      runTest "div ints" $-        assertEval "div-int" "builtins.div 10 3" (VInt 3),-      runTest "div float" $-        assertEval "div-float" "builtins.div 7.0 2.0" (VFloat 3.5),-      runTest "div by zero" $-        assertEvalFail "div-zero" "builtins.div 1 0",-      -- bitAnd-      runTest "bitAnd" $-        assertEval "bitAnd" "builtins.bitAnd 12 10" (VInt 8),-      runTest "bitAnd type error" $-        assertEvalFail "bitAnd-err" "builtins.bitAnd 1.0 2",-      -- bitOr-      runTest "bitOr" $-        assertEval "bitOr" "builtins.bitOr 12 10" (VInt 14),-      -- bitXor-      runTest "bitXor" $-        assertEval "bitXor" "builtins.bitXor 12 10" (VInt 6)-    ]---- ------------------------------------------------------------------------------ Tests: Batch 3 - Attrset higher-order builtins--- -----------------------------------------------------------------------------testBatch3 :: IO [Bool]-testBatch3 = do-  putStrLn "eval/builtins-batch3"-  sequence-    [ -- mapAttrs-      runTest "mapAttrs basic" $-        assertEval "mapAttrs" "(builtins.mapAttrs (name: val: val + 1) { a = 1; b = 2; }).a" (VInt 2),-      runTest "mapAttrs name usage" $-        assertEval "mapAttrs-name" "(builtins.mapAttrs (name: val: name) { a = 1; }).a" (mkStr "a"),-      runTest "mapAttrs type error" $-        assertEvalFail "mapAttrs-err" "builtins.mapAttrs (n: v: v) [ 1 ]",-      runTest "mapAttrs lazy" $-        assertEval "mapAttrs-lazy" "let s = builtins.mapAttrs (k: v: if k == \"a\" then v else throw \"boom\") { a = 1; b = 2; }; in s.a" (VInt 1),-      -- functionArgs-      runTest "functionArgs set pattern" $-        assertEval "funcArgs" "(builtins.functionArgs ({ a, b ? 1 }: a)).b" (VBool True),-      runTest "functionArgs no default" $-        assertEval "funcArgs-nodef" "(builtins.functionArgs ({ a, b ? 1 }: a)).a" (VBool False),-      runTest "functionArgs simple lambda" $-        assertEval "funcArgs-simple" "builtins.functionArgs (x: x)" (VAttrs (attrSetFromMap Map.empty)),-      runTest "functionArgs type error" $-        assertEvalFail "funcArgs-err" "builtins.functionArgs 42",-      -- zipAttrsWith-      runTest "zipAttrsWith basic" $-        assertEval-          "zipAttrs"-          "(builtins.zipAttrsWith (name: vals: builtins.head vals) [ { a = 1; } { a = 2; b = 3; } ]).b"-          (VInt 3),-      runTest "zipAttrsWith collect" $-        assertEval-          "zipAttrs-collect"-          "builtins.length (builtins.zipAttrsWith (name: vals: vals) [ { a = 1; } { a = 2; } ]).a"-          (VInt 2)-    ]---- ------------------------------------------------------------------------------ Tests: Batch 4 - String operations--- -----------------------------------------------------------------------------testBatch4 :: IO [Bool]-testBatch4 = do-  putStrLn "eval/builtins-batch4"-  sequence-    [ -- replaceStrings-      runTest "replaceStrings basic" $-        assertEval "replace" "builtins.replaceStrings [ \"o\" ] [ \"0\" ] \"foobar\"" (mkStr "f00bar"),-      runTest "replaceStrings multi" $-        assertEval "replace-multi" "builtins.replaceStrings [ \"a\" \"b\" ] [ \"A\" \"B\" ] \"abc\"" (mkStr "ABc"),-      runTest "replaceStrings empty from" $-        assertEval "replace-empty" "builtins.replaceStrings [ \"\" ] [ \"x\" ] \"ab\"" (mkStr "xaxbx"),-      runTest "replaceStrings no match" $-        assertEval "replace-nomatch" "builtins.replaceStrings [ \"z\" ] [ \"Z\" ] \"abc\"" (mkStr "abc"),-      -- compareVersions-      runTest "compareVersions equal" $-        assertEval "cmpVer-eq" "builtins.compareVersions \"1.2.3\" \"1.2.3\"" (VInt 0),-      runTest "compareVersions less" $-        assertEval "cmpVer-lt" "builtins.compareVersions \"1.2\" \"1.3\"" (VInt (-1)),-      runTest "compareVersions greater" $-        assertEval "cmpVer-gt" "builtins.compareVersions \"2.0\" \"1.9\"" (VInt 1),-      runTest "compareVersions type error" $-        assertEvalFail "cmpVer-err" "builtins.compareVersions 1 2",-      -- splitVersion-      runTest "splitVersion basic" $-        assertEval "splitVer" "builtins.splitVersion \"1.2.3\" == [ \"1\" \"2\" \"3\" ]" (VBool True),-      runTest "splitVersion pre" $-        assertEval "splitVer-pre" "builtins.splitVersion \"1.2pre\" == [ \"1\" \"2\" \"pre\" ]" (VBool True),-      runTest "splitVersion type error" $-        assertEvalFail "splitVer-err" "builtins.splitVersion 42",-      -- parseDrvName-      runTest "parseDrvName basic" $-        assertEval "parseDrv" "(builtins.parseDrvName \"hello-1.2.3\").name" (mkStr "hello"),-      runTest "parseDrvName version" $-        assertEval "parseDrv-ver" "(builtins.parseDrvName \"hello-1.2.3\").version" (mkStr "1.2.3"),-      runTest "parseDrvName no version" $-        assertEval "parseDrv-nover" "(builtins.parseDrvName \"hello\").version" (mkStr ""),-      runTest "parseDrvName type error" $-        assertEvalFail "parseDrv-err" "builtins.parseDrvName 42"-    ]---- ------------------------------------------------------------------------------ Tests: Batch 5 - Serialization + hashing--- -----------------------------------------------------------------------------testBatch5 :: IO [Bool]-testBatch5 = do-  putStrLn "eval/builtins-batch5"-  sequence-    [ -- toJSON-      runTest "toJSON int" $-        assertEval "toJSON-int" "builtins.toJSON 42" (mkStr "42"),-      runTest "toJSON string" $-        assertEval "toJSON-str" "builtins.toJSON \"hello\"" (mkStr "\"hello\""),-      runTest "toJSON null" $-        assertEval "toJSON-null" "builtins.toJSON null" (mkStr "null"),-      runTest "toJSON bool" $-        assertEval "toJSON-bool" "builtins.toJSON true" (mkStr "true"),-      runTest "toJSON list" $-        assertEval "toJSON-list" "builtins.toJSON [ 1 2 3 ]" (mkStr "[1,2,3]"),-      runTest "toJSON attrs" $-        assertEval "toJSON-attrs" "builtins.toJSON { a = 1; }" (mkStr "{\"a\":1}"),-      runTest "toJSON lambda error" $-        assertEvalFail "toJSON-fn" "builtins.toJSON (x: x)",-      -- fromJSON-      runTest "fromJSON int" $-        assertEval "fromJSON-int" "builtins.fromJSON \"42\"" (VInt 42),-      runTest "fromJSON string" $-        assertEval "fromJSON-str" "builtins.fromJSON \"\\\"hello\\\"\"" (mkStr "hello"),-      runTest "fromJSON null" $-        assertEval "fromJSON-null" "builtins.fromJSON \"null\"" VNull,-      runTest "fromJSON bool" $-        assertEval "fromJSON-bool" "builtins.fromJSON \"true\"" (VBool True),-      runTest "fromJSON array" $-        assertEval "fromJSON-arr" "builtins.length (builtins.fromJSON \"[1,2,3]\")" (VInt 3),-      runTest "fromJSON object" $-        assertEval "fromJSON-obj" "(builtins.fromJSON \"{\\\"a\\\": 1}\").a" (VInt 1),-      runTest "fromJSON roundtrip" $-        assertEval "fromJSON-rt" "let x = builtins.fromJSON (builtins.toJSON { a = 1; b = [ 2 3 ]; }); in x.a == 1 && x.b == [ 2 3 ]" (VBool True),-      runTest "fromJSON invalid" $-        assertEvalFail "fromJSON-bad" "builtins.fromJSON \"not json\"",-      -- hashString-      runTest "hashString sha256" $-        assertEval "hash-sha256" "builtins.hashString \"sha256\" \"hello\"" (mkStr "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"),-      runTest "hashString md5" $-        assertEval "hash-md5" "builtins.hashString \"md5\" \"hello\"" (mkStr "5d41402abc4b2a76b9719d911017c592"),-      runTest "hashString sha1" $-        assertEval "hash-sha1" "builtins.hashString \"sha1\" \"hello\"" (mkStr "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"),-      runTest "hashString unknown algo" $-        assertEvalFail "hash-bad" "builtins.hashString \"sha999\" \"hello\"",-      runTest "hashString type error" $-        assertEvalFail "hash-err" "builtins.hashString \"sha256\" 42"-    ]---- ------------------------------------------------------------------------------ Tests: Batch 6 - tryEval + deepSeq--- -----------------------------------------------------------------------------testBatch6 :: IO [Bool]-testBatch6 = do-  putStrLn "eval/builtins-batch6"-  sequence-    [ -- tryEval success-      runTest "tryEval success" $-        assertEval "tryEval-ok" "(builtins.tryEval 42).value" (VInt 42),-      runTest "tryEval success flag" $-        assertEval "tryEval-flag" "(builtins.tryEval 42).success" (VBool True),-      -- tryEval failure-      runTest "tryEval catches throw" $-        assertEval "tryEval-throw" "(builtins.tryEval (builtins.throw \"boom\")).success" (VBool False),-      runTest "tryEval failure value" $-        assertEval "tryEval-fval" "(builtins.tryEval (builtins.throw \"boom\")).value" (VBool False),-      -- tryEval catches coercion error (+ on attrset without outPath)-      runTest "tryEval catches coercion error" $-        assertEval "tryEval-tyerr" "(builtins.tryEval ({} + [])).success" (VBool False),-      -- deepSeq-      runTest "deepSeq returns second" $-        assertEval "deepSeq" "builtins.deepSeq [ 1 2 3 ] 42" (VInt 42),-      runTest "deepSeq forces nested" $-        assertEvalFail "deepSeq-err" "builtins.deepSeq [ (builtins.throw \"boom\") ] 42",-      runTest "deepSeq forces attrs" $-        assertEvalFail "deepSeq-attr" "builtins.deepSeq { a = builtins.throw \"boom\"; } 42"-    ]---- ------------------------------------------------------------------------------ Tests: Batch 7 - genericClosure--- -----------------------------------------------------------------------------testBatch7 :: IO [Bool]-testBatch7 = do-  putStrLn "eval/builtins-batch7"-  sequence-    [ runTest "genericClosure basic" $-        assertEval-          "closure-basic"-          "builtins.length (builtins.genericClosure { startSet = [ { key = 1; } ]; operator = item: [ ]; })"-          (VInt 1),-      runTest "genericClosure expansion" $-        assertEval-          "closure-expand"-          "builtins.length (builtins.genericClosure { startSet = [ { key = 1; next = 2; } ]; operator = item: if item.next == 0 then [ ] else [ { key = item.next; next = 0; } ]; })"-          (VInt 2),-      runTest "genericClosure dedup" $-        assertEval-          "closure-dedup"-          "builtins.length (builtins.genericClosure { startSet = [ { key = 1; } { key = 1; } ]; operator = item: [ ]; })"-          (VInt 1),-      runTest "genericClosure missing startSet" $-        assertEvalFail "closure-nostart" "builtins.genericClosure { operator = x: [ ]; }",-      runTest "genericClosure type error" $-        assertEvalFail "closure-tyerr" "builtins.genericClosure 42"-    ]---- ------------------------------------------------------------------------------ Tests: import and IO builtins (pure)--- -----------------------------------------------------------------------------testImportPure :: IO [Bool]-testImportPure = do-  putStrLn "eval/import-pure"-  sequence-    [ runTest "import errors in pure mode" $-        assertEvalFail "import-pure" "import ./foo.nix",-      runTest "builtins.typeOf import is lambda" $-        assertEval "typeof-import" "builtins.typeOf import" (mkStr "lambda"),-      runTest "pathExists returns false in pure mode" $-        assertEval "pathExists-pure" "builtins.pathExists ./nonexistent" (VBool False),-      runTest "readFile errors in pure mode" $-        assertEvalFail "readFile-pure" "builtins.readFile ./foo.nix",-      runTest "readDir errors in pure mode" $-        assertEvalFail "readDir-pure" "builtins.readDir ./some-dir"-    ]---- ------------------------------------------------------------------------------ Tests: import and IO builtins (IO)--- ------------------------------------------------------------------------------- | Parse and evaluate Nix source using the IO evaluator.-evalNixIO :: FilePath -> Text -> IO (Either Text NixValue)-evalNixIO baseDir source = case parseNix "<test>" source of-  Left err -> pure (Left (T.pack (show err)))-  Right expr -> do-    st <- newEvalState baseDir-    runEvalIO st (eval (builtinEnv (esTimestamp st) (esSearchPaths st)) expr)---- | Run a named IO eval test - single label, no double-wrapping.-runTestIO :: Text -> FilePath -> Text -> NixValue -> IO Bool-runTestIO label baseDir source expected = do-  result <- evalNixIO baseDir source-  runTest label $ assertRight label result $ \actual ->-    assertEqual label expected actual---- | Run a named IO eval test that should fail.-runTestIOFail :: Text -> FilePath -> Text -> IO Bool-runTestIOFail label baseDir source = do-  result <- evalNixIO baseDir source-  runTest label $ assertLeft label result---- | Quoted path literal for embedding absolute paths in Nix source.-nixQuotedPath :: FilePath -> Text-nixQuotedPath p = T.pack (show p)--testImportIO :: IO [Bool]-testImportIO = do-  putStrLn "eval/import-io"-  tmpBase <- getTemporaryDirectory-  let testDir = tmpBase </> "nova-nix-test-import"-      subDir = testDir </> "sub"-      setup = do-        createDirectoryIfMissing True subDir-        TIO.writeFile (testDir </> "literal.nix") "42"-        TIO.writeFile (testDir </> "expr.nix") "1 + 2"-        TIO.writeFile (testDir </> "nested-inner.nix") "99"-        TIO.writeFile (testDir </> "nested-outer.nix") "import ./nested-inner.nix"-        TIO.writeFile (testDir </> "attrset.nix") "{ x = 1; y = 2; }"-        TIO.writeFile (testDir </> "uses-arg.nix") "let f = x: x + 10; in f 5"-        TIO.writeFile (subDir </> "from-sub.nix") "7"-      cleanup = do-        exists <- doesDirectoryExist testDir-        when exists (removeDirectoryRecursive testDir)-  -- bracket_: cleanup runs even if tests throw-  bracket_ setup cleanup $-    sequence-      [ -- import-        runTestIO "import literal" testDir "import ./literal.nix" (VInt 42),-        runTestIO "import expression" testDir "import ./expr.nix" (VInt 3),-        runTestIO "import nested (A imports B)" testDir "import ./nested-outer.nix" (VInt 99),-        runTestIO-          "import cache (same file twice)"-          testDir-          "(import ./literal.nix) + (import ./literal.nix)"-          (VInt 84),-        runTestIOFail "import nonexistent -> error" testDir "import ./nonexistent.nix",-        runTestIO "import attrset + select" testDir "(import ./attrset.nix).x" (VInt 1),-        runTestIO "import let/lambda" testDir "import ./uses-arg.nix" (VInt 15),-        -- import accepts strings (real Nix coerces string to path)-        runTestIO "import accepts string" testDir "import \"./literal.nix\"" (VInt 42),-        -- pathExists-        runTestIO-          "pathExists true"-          testDir-          ("builtins.pathExists " <> nixQuotedPath (testDir </> "literal.nix"))-          (VBool True),-        runTestIO-          "pathExists false"-          testDir-          ("builtins.pathExists " <> nixQuotedPath (testDir </> "nope.nix"))-          (VBool False),-        -- readFile-        runTestIO-          "readFile contents"-          testDir-          ("builtins.readFile " <> nixQuotedPath (testDir </> "literal.nix"))-          (mkStr "42"),-        runTestIOFail-          "readFile missing -> error"-          testDir-          ("builtins.readFile " <> nixQuotedPath (testDir </> "ghost.nix")),-        -- readDir: entries have correct file types-        runTestIO-          "readDir classifies directory"-          testDir-          ("(builtins.readDir " <> nixQuotedPath testDir <> ").sub")-          (mkStr "directory"),-        runTestIO-          "readDir classifies regular file"-          testDir-          ("builtins.getAttr \"literal.nix\" (builtins.readDir " <> nixQuotedPath testDir <> ")")-          (mkStr "regular")-      ]---- ------------------------------------------------------------------------------ Tests: Batch A - getEnv, currentTime, toPath--- -----------------------------------------------------------------------------testBatchA :: IO [Bool]-testBatchA = do-  putStrLn "eval/builtins-batchA"-  sequence-    [ -- getEnv-      runTest "getEnv pure returns empty" $-        assertEval "getEnv-pure" "builtins.getEnv \"HOME\"" (mkStr ""),-      runTest "getEnv type error" $-        assertEvalFail "getEnv-err" "builtins.getEnv 42",-      -- toPath-      runTest "toPath absolute" $-        assertEval "toPath-abs" "builtins.toPath \"/foo/bar\"" (VPath "/foo/bar"),-      runTest "toPath rejects relative" $-        assertEvalFail "toPath-rel" "builtins.toPath \"foo/bar\"",-      runTest "toPath passthrough VPath" $-        assertEval "toPath-vpath" "builtins.toPath (builtins.toPath \"/foo/bar\")" (VPath "/foo/bar"),-      runTest "toPath type error" $-        assertEvalFail "toPath-err" "builtins.toPath 42",-      runTest "toPath rejects empty" $-        assertEvalFail "toPath-empty" "builtins.toPath \"\"",-      -- currentTime-      runTest "currentTime is int" $-        assertEval "currentTime" "builtins.typeOf builtins.currentTime" (mkStr "int"),-      runTest "currentTime is 0 in pure" $-        assertEval "currentTime-pure" "builtins.currentTime" (VInt 0),-      runTest "currentTime >= 0" $-        assertEval "currentTime-pos" "builtins.currentTime >= 0" (VBool True)-    ]---- ------------------------------------------------------------------------------ Tests: Batch A - IO tests (getEnv)--- -----------------------------------------------------------------------------testBatchAIO :: IO [Bool]-testBatchAIO = do-  putStrLn "eval/builtins-batchA-io"-  tmpBase <- getTemporaryDirectory-  let testDir = tmpBase </> "nova-nix-test-batchA"-  bracket_-    (createDirectoryIfMissing True testDir)-    ( do-        exists <- doesDirectoryExist testDir-        when exists (removeDirectoryRecursive testDir)-    )-    $ sequence-      [ -- getEnv HOME should be non-empty in IO mode-        do-          result <- evalNixIO testDir "builtins.getEnv \"PATH\""-          runTest "getEnv PATH non-empty (IO)" $ assertRight "getEnv-io" result $ \val ->-            case val of-              VStr s _ -> if T.null s then Fail "PATH was empty" else Pass-              _ -> Fail ("expected VStr, got " <> T.pack (show val)),-        -- currentTime in IO should be > 0-        do-          result <- evalNixIO testDir "builtins.currentTime"-          runTest "currentTime > 0 (IO)" $ assertRight "currentTime-io" result $ \val ->-            case val of-              VInt n -> if n > 0 then Pass else Fail ("expected > 0, got " <> T.pack (show n))-              _ -> Fail ("expected VInt, got " <> T.pack (show val))-      ]---- ------------------------------------------------------------------------------ Tests: Batch B - placeholder, storePath--- -----------------------------------------------------------------------------testBatchB :: IO [Bool]-testBatchB = do-  putStrLn "eval/builtins-batchB"-  sequence-    [ -- placeholder-      runTest "placeholder out matches Nix hashPlaceholder" $-        assertRight "placeholder-out" (evalNix "builtins.placeholder \"out\"") $ \val ->-          case val of-            VStr p _ -> assertEqual "placeholder out" "/1rz4g4znpzjwh1xymhjpm42vipw92pr73vdgl6xs1hycac8kf2n9" p-            _ -> Fail ("expected VStr, got " <> T.pack (show val)),-      runTest "placeholder deterministic" $-        assertRight "placeholder-det" (evalNix "builtins.placeholder \"out\" == builtins.placeholder \"out\"") $ \val ->-          assertEqual "deterministic" (VBool True) val,-      runTest "placeholder out /= placeholder dev" $-        assertRight "placeholder-diff" (evalNix "builtins.placeholder \"out\" == builtins.placeholder \"dev\"") $ \val ->-          assertEqual "different" (VBool False) val,-      runTest "placeholder type error" $-        assertEvalFail "placeholder-err" "builtins.placeholder 42",-      -- storePath-      runTest "storePath valid" $-        assertEval-          "storePath-valid"-          "builtins.storePath \"/nix/store/s66mzxpvicwk07gjbjfw9izjfa797vsw-hello-2.12.1\""-          (VPath "/nix/store/s66mzxpvicwk07gjbjfw9izjfa797vsw-hello-2.12.1"),-      runTest "storePath invalid" $-        assertEvalFail "storePath-bad" "builtins.storePath \"/tmp/not-a-store-path\"",-      runTest "storePath type error" $-        assertEvalFail "storePath-err" "builtins.storePath 42"-    ]---- ------------------------------------------------------------------------------ Tests: Batch C - findFile--- -----------------------------------------------------------------------------testBatchC :: IO [Bool]-testBatchC = do-  putStrLn "eval/builtins-batchC"-  sequence-    [ runTest "findFile empty list errors" $-        assertEvalFail "findFile-empty" "builtins.findFile [ ] \"foo\"",-      runTest "findFile type error arg1" $-        assertEvalFail "findFile-err1" "builtins.findFile 42 \"foo\"",-      runTest "findFile type error arg2" $-        assertEvalFail "findFile-err2" "builtins.findFile [ ] 42"-    ]--testBatchCIO :: IO [Bool]-testBatchCIO = do-  putStrLn "eval/builtins-batchC-io"-  tmpBase <- getTemporaryDirectory-  let testDir = tmpBase </> "nova-nix-test-batchC"-      nixpkgsDir = testDir </> "nixpkgs"-  bracket_-    ( do-        createDirectoryIfMissing True nixpkgsDir-        TIO.writeFile (nixpkgsDir </> "default.nix") "42"-    )-    ( do-        exists <- doesDirectoryExist testDir-        when exists (removeDirectoryRecursive testDir)-    )-    $ sequence-      [ runTestIO-          "findFile with matching entry"-          testDir-          ( "builtins.findFile [ { prefix = \"nixpkgs\"; path = "-              <> nixQuotedPath nixpkgsDir-              <> "; } ] \"nixpkgs\""-          )-          (VPath (T.pack nixpkgsDir)),-        runTestIOFail-          "findFile no match"-          testDir-          "builtins.findFile [ { prefix = \"other\"; path = \"/nope\"; } ] \"nixpkgs\""-      ]---- ------------------------------------------------------------------------------ Tests: Blackhole (infinite recursion detection)--- -----------------------------------------------------------------------------testBlackhole :: IO [Bool]-testBlackhole = do-  putStrLn "eval/blackhole"-  tmpBase <- getTemporaryDirectory-  sequence-    [ runTestIOFail "let x = x; in x" tmpBase "let x = x; in x",-      runTestIOFail "rec { a = a; }.a" tmpBase "rec { a = a; }.a",-      runTestIOFail "let a = b; b = a; in a" tmpBase "let a = b; b = a; in a",-      -- Non-recursive cases must still work-      runTestIO "rec { a = 1; b = a; }.b" tmpBase "rec { a = 1; b = a; }.b" (VInt 1),-      runTestIO "let a = 1; b = a + 1; in b" tmpBase "let a = 1; b = a + 1; in b" (VInt 2)-    ]---- ------------------------------------------------------------------------------ Tests: Batch D - toFile--- -----------------------------------------------------------------------------testBatchD :: IO [Bool]-testBatchD = do-  putStrLn "eval/builtins-batchD"-  sequence-    [ runTest "toFile pure mode error" $-        assertEvalFail "toFile-pure" "builtins.toFile \"hello\" \"world\"",-      runTest "toFile type error arg1" $-        assertEvalFail "toFile-err1" "builtins.toFile 42 \"world\"",-      runTest "toFile type error arg2" $-        assertEvalFail "toFile-err2" "builtins.toFile \"hello\" 42"-    ]---- ------------------------------------------------------------------------------ Tests: Batch E - scopedImport--- -----------------------------------------------------------------------------testBatchE :: IO [Bool]-testBatchE = do-  putStrLn "eval/builtins-batchE"-  sequence-    [ runTest "scopedImport pure error" $-        assertEvalFail "scopedImport-pure" "builtins.scopedImport { } ./foo.nix",-      runTest "scopedImport type error arg1" $-        assertEvalFail "scopedImport-err1" "builtins.scopedImport 42 ./foo.nix",-      runTest "scopedImport type error arg2" $-        assertEvalFail "scopedImport-err2" "builtins.scopedImport { } 42"-    ]--testBatchEIO :: IO [Bool]-testBatchEIO = do-  putStrLn "eval/builtins-batchE-io"-  tmpBase <- getTemporaryDirectory-  let testDir = tmpBase </> "nova-nix-test-batchE"-  bracket_-    ( do-        createDirectoryIfMissing True testDir-        TIO.writeFile (testDir </> "scoped.nix") "x"-    )-    ( do-        exists <- doesDirectoryExist testDir-        when exists (removeDirectoryRecursive testDir)-    )-    $ sequence-      [ runTestIO-          "scopedImport injects scope"-          testDir-          ("builtins.scopedImport { x = 42; } " <> nixQuotedPath (testDir </> "scoped.nix"))-          (VInt 42)-      ]---- ------------------------------------------------------------------------------ Tests: Batch F - fetchurl, fetchTarball, fetchGit--- -----------------------------------------------------------------------------testBatchF :: IO [Bool]-testBatchF = do-  putStrLn "eval/builtins-batchF"-  sequence-    [ runTest "fetchurl pure error" $-        assertEvalFail "fetchurl-pure" "builtins.fetchurl \"http://example.com\"",-      runTest "fetchurl type error" $-        assertEvalFail "fetchurl-err" "builtins.fetchurl 42",-      runTest "fetchTarball pure error" $-        assertEvalFail "fetchTarball-pure" "builtins.fetchTarball \"http://example.com\"",-      runTest "fetchTarball type error" $-        assertEvalFail "fetchTarball-err" "builtins.fetchTarball 42",-      runTest "fetchGit pure error" $-        assertEvalFail "fetchGit-pure" "builtins.fetchGit \"http://example.com\"",-      runTest "fetchGit type error" $-        assertEvalFail "fetchGit-err" "builtins.fetchGit 42"-    ]---- ------------------------------------------------------------------------------ Tests: Batch G - ATerm serialization--- -----------------------------------------------------------------------------testBatchG :: IO [Bool]-testBatchG = do-  putStrLn "derivation/aterm"-  let minimalDrv =-        Derivation-          { drvOutputs = [],-            drvInputDrvs = Map.empty,-            drvInputSrcs = [],-            drvPlatform = X86_64_Linux,-            drvBuilder = "/bin/sh",-            drvArgs = [],-            drvEnv = Map.empty-          }-  let drvWithOutput =-        minimalDrv-          { drvOutputs =-              [ DerivationOutput-                  { doName = "out",-                    doPath = StorePath "abc" "hello",-                    doHashAlgo = "",-                    doHash = ""-                  }-              ]-          }-  let drvWithEnv =-        minimalDrv-          { drvEnv = Map.fromList [("name", "hello"), ("system", "x86_64-linux")]-          }-  sequence-    [ runTest "ATerm minimal" $-        let aterm = toATerm minimalDrv-         in if T.isPrefixOf "Derive(" aterm && T.isSuffixOf ")" aterm-              then Pass-              else Fail ("bad ATerm: " <> aterm),-      runTest "ATerm has output" $-        let aterm = toATerm drvWithOutput-         in if "\"out\"" `T.isInfixOf` aterm-              then Pass-              else Fail ("missing output in ATerm: " <> aterm),-      runTest "ATerm env sorted" $-        let aterm = toATerm drvWithEnv-         in -- "name" should come before "system" in sorted order-            case (T.breakOn "\"name\"" aterm, T.breakOn "\"system\"" aterm) of-              ((before1, _), (before2, _)) ->-                if T.length before1 < T.length before2-                  then Pass-                  else Fail ("env not sorted in ATerm: " <> aterm),-      runTest "ATerm string escaping" $-        let drv = minimalDrv {drvEnv = Map.fromList [("msg", "hello\nworld")]}-            aterm = toATerm drv-         in if "\\n" `T.isInfixOf` aterm-              then Pass-              else Fail ("missing escaped newline: " <> aterm),-      runTest "ATerm deterministic" $-        assertEqual "deterministic" (toATerm minimalDrv) (toATerm minimalDrv),-      -- platformToText-      runTest "platformToText linux" $-        assertEqual "linux" "x86_64-linux" (platformToText X86_64_Linux),-      runTest "platformToText darwin" $-        assertEqual "darwin" "x86_64-darwin" (platformToText X86_64_Darwin),-      runTest "platformToText aarch64-darwin" $-        assertEqual "aarch64" "aarch64-darwin" (platformToText Aarch64_Darwin),-      runTest "platformToText windows" $-        assertEqual "windows" "x86_64-windows" (platformToText X86_64_Windows),-      runTest "platformToText aarch64-linux" $-        assertEqual "aarch64-linux" "aarch64-linux" (platformToText Aarch64_Linux),-      runTest "platformToText other" $-        assertEqual "other" "riscv64-freebsd" (platformToText (OtherPlatform "riscv64-freebsd"))-    ]---- ------------------------------------------------------------------------------ Tests: Batch H - derivation--- -----------------------------------------------------------------------------testBatchH :: IO [Bool]-testBatchH = do-  putStrLn "eval/builtins-batchH"-  sequence-    [ runTest "derivation has type" $-        assertEval-          "drv-type"-          "let d = derivation { name = \"hello\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; in d.type"-          (mkStr "derivation"),-      runTest "derivation has drvPath" $-        assertRight "drv-drvPath" (evalNix "let d = derivation { name = \"hello\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; in d.drvPath") $ \val ->-          case val of-            VStr p ctx ->-              if "/nix/store/" `T.isPrefixOf` p && ".drv" `T.isSuffixOf` p && ctx /= emptyContext-                then Pass-                else Fail ("bad drvPath: " <> p)-            _ -> Fail ("expected VStr with context, got " <> T.pack (show val)),-      runTest "derivation has outPath" $-        assertRight "drv-outPath" (evalNix "let d = derivation { name = \"hello\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; in d.outPath") $ \val ->-          case val of-            VStr p ctx ->-              if "/nix/store/" `T.isPrefixOf` p && ctx /= emptyContext-                then Pass-                else Fail ("bad outPath: " <> p)-            _ -> Fail ("expected VStr with context, got " <> T.pack (show val)),-      -- 'derivation' is lazy (matches C++ Nix): the missing-required-attribute-      -- error fires when a path is forced (.drvPath), not at construction.-      runTest "derivation missing name" $-        assertEvalFail "drv-noname" "(derivation { system = \"x86_64-linux\"; builder = \"/bin/sh\"; }).drvPath",-      runTest "derivation missing system" $-        assertEvalFail "drv-nosys" "(derivation { name = \"hello\"; builder = \"/bin/sh\"; }).drvPath",-      runTest "derivation missing builder" $-        assertEvalFail "drv-nobuilder" "(derivation { name = \"hello\"; system = \"x86_64-linux\"; }).drvPath",-      runTest "derivation type error" $-        assertEvalFail "drv-tyerr" "derivation 42",-      runTest "derivation deterministic" $-        assertRight "drv-det" (evalNix "let d1 = derivation { name = \"a\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; d2 = derivation { name = \"a\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; in d1.drvPath == d2.drvPath") $ \val ->-          assertEqual "deterministic" (VBool True) val-    ]---- ------------------------------------------------------------------------------ Tests: StringContext (Phase 3, Batch 1)--- -----------------------------------------------------------------------------testStringContext :: IO [Bool]-testStringContext = do-  putStrLn "eval/string-context"-  let sp1 = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "hello"-      sp2 = StorePath "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" "world"-  sequence-    [ runTest "StringContext Eq" $-        let ctx1 = StringContext (Set.singleton (SCPlain sp1))-            ctx2 = StringContext (Set.singleton (SCPlain sp1))-         in assertEqual "ctx-eq" ctx1 ctx2,-      runTest "mkStr constructor" $-        let v = mkStr "hello"-         in case v of-              VStr t ctx ->-                if t == "hello" && ctx == emptyContext-                  then Pass-                  else Fail "mkStr produced wrong value"-              _ -> Fail "mkStr did not produce VStr",-      runTest "mergeContexts mempty" $-        let ctx1 = StringContext (Set.singleton (SCPlain sp1))-            merged = ctx1 <> emptyContext-         in assertEqual "merge-mempty" ctx1 merged,-      runTest "mergeContexts union" $-        let ctx1 = StringContext (Set.singleton (SCPlain sp1))-            ctx2 = StringContext (Set.singleton (SCDrvOutput sp2 "out"))-            merged = ctx1 <> ctx2-            expected = StringContext (Set.fromList [SCPlain sp1, SCDrvOutput sp2 "out"])-         in assertEqual "merge-union" expected merged-    ]---- ------------------------------------------------------------------------------ Tests: Context propagation (Phase 3, Batch 3)--- -----------------------------------------------------------------------------testContextPropagation :: IO [Bool]-testContextPropagation = do-  putStrLn "eval/context-propagation"-  sequence-    [ -- String equality ignores context-      runTest "string equality ignores context" $-        assertEval "str-eq-ctx" "\"hello\" == \"hello\"" (VBool True),-      -- String comparison ignores context-      runTest "string comparison ignores context" $-        assertEval "str-cmp-ctx" "\"a\" < \"b\"" (VBool True),-      -- Interpolation produces correct text-      runTest "interp text correct" $-        assertEval "interp-text" "let x = \"world\"; in \"hello ${x}\"" (mkStr "hello world"),-      -- String + merges (tested at value level)-      runTest "string + merges text" $-        assertEval "str-plus" "\"a\" + \"b\"" (mkStr "ab"),-      -- concatStringsSep merges text-      runTest "concatStringsSep result" $-        assertEval "css-text" "builtins.concatStringsSep \"-\" [\"a\" \"b\"]" (mkStr "a-b"),-      -- substring preserves text-      runTest "substring text" $-        assertEval "substr-text" "builtins.substring 1 2 \"hello\"" (mkStr "el"),-      -- unsafeDiscardStringContext strips context-      runTest "discardContext strips" $-        assertEval "discard-ctx" "builtins.unsafeDiscardStringContext \"hello\"" (mkStr "hello"),-      -- stringLength drops context (returns int)-      runTest "stringLength drops context" $-        assertEval "strlen-drop" "builtins.stringLength \"hello\"" (VInt 5),-      -- hashString drops context (returns string with no context)-      runTest "hashString result type" $-        assertRight "hash-type" (evalNix "builtins.typeOf (builtins.hashString \"sha256\" \"x\")") $ \val ->-          assertEqual "hash-typeof" (mkStr "string") val,-      -- replaceStrings text result-      runTest "replaceStrings text" $-        assertEval "replace-text" "builtins.replaceStrings [\"o\"] [\"0\"] \"foo\"" (mkStr "f00"),-      -- baseNameOf preserves text-      runTest "baseNameOf text" $-        assertEval "basename-text" "builtins.baseNameOf \"/foo/bar\"" (mkStr "bar"),-      -- dirOf preserves text-      runTest "dirOf text" $-        assertEval "dirof-text" "builtins.dirOf \"/foo/bar\"" (mkStr "/foo"),-      -- toString propagates-      runTest "toString on string" $-        assertEval "tostr-str" "builtins.toString \"hello\"" (mkStr "hello"),-      -- toString on int (no context)-      runTest "toString on int" $-        assertEval "tostr-int" "builtins.toString 42" (mkStr "42")-    ]---- ------------------------------------------------------------------------------ Tests: Context helpers (Phase 3, Batch 2)--- -----------------------------------------------------------------------------testContextHelpers :: IO [Bool]-testContextHelpers = do-  putStrLn "eval/context-helpers"-  let sp1 = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "hello"-      sp2 = StorePath "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" "world.drv"-      sp3 = StorePath "cccccccccccccccccccccccccccccccc" "source.tar.gz"-  sequence-    [ runTest "plainContext singleton" $-        let ctx = Context.plainContext sp1-         in assertEqual "plain" (StringContext (Set.singleton (SCPlain sp1))) ctx,-      runTest "drvOutputContext singleton" $-        let ctx = Context.drvOutputContext sp2 "out"-         in assertEqual "drvOut" (StringContext (Set.singleton (SCDrvOutput sp2 "out"))) ctx,-      runTest "allOutputsContext singleton" $-        let ctx = Context.allOutputsContext sp2-         in assertEqual "allOut" (StringContext (Set.singleton (SCAllOutputs sp2))) ctx,-      runTest "contextIsEmpty on mempty" $-        assertEqual "emptyCtx" True (Context.contextIsEmpty emptyContext),-      runTest "contextIsEmpty on non-empty" $-        assertEqual "nonEmptyCtx" False (Context.contextIsEmpty (Context.plainContext sp1)),-      runTest "extractInputSrcs" $-        let ctx = Context.plainContext sp1 <> Context.drvOutputContext sp2 "out"-         in assertEqual "srcs" [sp1] (Context.extractInputSrcs ctx),-      runTest "extractInputDrvs" $-        let ctx = Context.drvOutputContext sp2 "out" <> Context.drvOutputContext sp2 "dev" <> Context.plainContext sp3-            drvs = Context.extractInputDrvs ctx-         in case Map.lookup sp2 drvs of-              Just outs -> if length outs == 2 then Pass else Fail ("expected 2 outputs, got " <> T.pack (show (length outs)))-              Nothing -> Fail "sp2 not found in drvs",-      runTest "appendStrings merges" $-        let ctx1 = Context.plainContext sp1-            ctx2 = Context.drvOutputContext sp2 "out"-            (txt, ctx) = Context.appendStrings "hello" ctx1 "world" ctx2-         in if txt == "helloworld" && not (Context.contextIsEmpty ctx) then Pass else Fail "bad append",-      runTest "concatStrings empty" $-        let (txt, ctx) = Context.concatStrings []-         in if txt == "" && Context.contextIsEmpty ctx then Pass else Fail "bad empty concat",-      runTest "concatStrings merges all" $-        let ctx1 = Context.plainContext sp1-            ctx2 = Context.drvOutputContext sp2 "out"-            (txt, ctx) = Context.concatStrings [("a", ctx1), ("b", ctx2), ("c", mempty)]-         in if txt == "abc" && Set.size (unStringContext ctx) == 2 then Pass else Fail "bad concat"-    ]---- ------------------------------------------------------------------------------ Tests: Derivation context + new builtins (Phase 3, Batch 4)--- -----------------------------------------------------------------------------testDrvContext :: IO [Bool]-testDrvContext = do-  putStrLn "eval/drv-context"-  sequence-    [ -- hasContext: plain string has no context-      runTest "hasContext on plain string" $-        assertEval "hasCtx-plain" "builtins.hasContext \"hello\"" (VBool False),-      -- hasContext: derivation outPath has context-      runTest "hasContext on drv outPath" $-        assertEval-          "hasCtx-drv"-          "builtins.hasContext (derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }).outPath"-          (VBool True),-      -- hasContext: after discardContext, no context-      runTest "hasContext after discard" $-        assertEval-          "hasCtx-discard"-          "builtins.hasContext (builtins.unsafeDiscardStringContext (derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }).outPath)"-          (VBool False),-      -- getContext: plain string returns empty attrset-      runTest "getContext on plain string" $-        assertRight "getCtx-plain" (evalNix "builtins.getContext \"hello\"") $ \val ->-          case val of-            VAttrs m -> if attrSetNull m then Pass else Fail "expected empty attrset"-            _ -> Fail ("expected VAttrs, got " <> T.pack (show val)),-      -- getContext: drv outPath has outputs entry-      runTest "getContext on drv outPath"-        $ assertRight-          "getCtx-drv"-          (evalNix "builtins.getContext (derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }).outPath")-        $ \val -> case val of-          VAttrs m ->-            if attrSetSize m == 1-              then Pass-              else Fail ("expected 1 entry, got " <> T.pack (show (attrSetSize m)))-          _ -> Fail ("expected VAttrs, got " <> T.pack (show val)),-      -- getContext: drvPath has allOutputs-      runTest "getContext on drvPath has allOutputs"-        $ assertRight-          "getCtx-drvPath"-          (evalNix "let d = derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; ctx = builtins.getContext d.drvPath; in builtins.length (builtins.attrNames ctx)")-        $ \val -> assertEqual "one-entry" (VInt 1) val,-      -- appendContext: adds context to plain string-      runTest "appendContext adds context" $-        assertEval-          "appendCtx-add"-          "let ctx = builtins.listToAttrs [{ name = \"/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-foo\"; value = { path = true; }; }]; in builtins.hasContext (builtins.appendContext \"hello\" ctx)"-          (VBool True),-      -- appendContext: empty context is no-op-      runTest "appendContext empty is no-op" $-        assertEval "appendCtx-empty" "builtins.hasContext (builtins.appendContext \"hello\" {})" (VBool False),-      -- unsafeDiscardOutputDependency: strips drv context, keeps plain-      runTest "discardOutputDep strips drv context"-        $ assertRight-          "discardOutDep"-          ( evalNix $-              T.concat-                [ "let d = derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; ",-                  "stripped = builtins.unsafeDiscardOutputDependency d.outPath; ",-                  "in builtins.hasContext stripped"-                ]-          )-        $ \val -> assertEqual "no-ctx" (VBool False) val,-      -- derivation outPath is a string (not path) with context-      runTest "drv outPath is VStr"-        $ assertRight-          "drv-outPath-type"-          (evalNix "builtins.typeOf (derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }).outPath")-        $ \val -> assertEqual "string-type" (mkStr "string") val,-      -- derivation drvPath is a string (not path) with context-      runTest "drv drvPath is VStr"-        $ assertRight-          "drv-drvPath-type"-          (evalNix "builtins.typeOf (derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }).drvPath")-        $ \val -> assertEqual "string-type" (mkStr "string") val,-      -- hasContext error on non-string-      runTest "hasContext type error" $-        assertEvalFail "hasCtx-err" "builtins.hasContext 42",-      -- getContext error on non-string-      runTest "getContext type error" $-        assertEvalFail "getCtx-err" "builtins.getContext 42",-      -- appendContext error on non-string first arg-      runTest "appendContext type error" $-        assertEvalFail "appendCtx-err" "builtins.appendContext 42 {}",-      -- deterministic: same derivation produces same paths-      runTest "derivation with context deterministic"-        $ assertRight-          "drv-det-ctx"-          (evalNix "let d1 = derivation { name = \"a\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; d2 = derivation { name = \"a\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; in d1.outPath == d2.outPath")-        $ \val -> assertEqual "deterministic" (VBool True) val-    ]---- ------------------------------------------------------------------------------ Tests: DependencyGraph (Phase 3, Batch 5)--- -----------------------------------------------------------------------------testDepGraph :: IO [Bool]-testDepGraph = do-  putStrLn "dep-graph"-  let mkSP = StorePath-      spA = mkSP "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "a.drv"-      spB = mkSP "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" "b.drv"-      spC = mkSP "cccccccccccccccccccccccccccccccccc" "c.drv"-      spD = mkSP "dddddddddddddddddddddddddddddddd" "d.drv"-      baseDrv =-        Derivation-          { drvOutputs = [],-            drvInputDrvs = Map.empty,-            drvInputSrcs = [],-            drvPlatform = X86_64_Linux,-            drvBuilder = "/bin/sh",-            drvArgs = [],-            drvEnv = Map.empty-          }-      -- Single node: A has no deps-      drvA = baseDrv-      -- Linear chain: B depends on C-      drvB = baseDrv {drvInputDrvs = Map.singleton spC ["out"]}-      -- C has no deps-      drvC = baseDrv-      -- Diamond: D depends on B and C, B depends on C-      drvD = baseDrv {drvInputDrvs = Map.fromList [(spB, ["out"]), (spC, ["out"])]}-      -- Cycle: A depends on B, B depends on A-      drvACycle = baseDrv {drvInputDrvs = Map.singleton spB ["out"]}-      drvBCycle = baseDrv {drvInputDrvs = Map.singleton spA ["out"]}-      readSingle _ = Left "not found"-      readChain sp-        | sp == spC = Right drvC-        | otherwise = Left ("unknown drv: " <> spName sp)-      readDiamond sp-        | sp == spB = Right drvB-        | sp == spC = Right drvC-        | otherwise = Left ("unknown drv: " <> spName sp)-      readCycle sp-        | sp == spB = Right drvBCycle-        | sp == spA = Right drvACycle-        | otherwise = Left ("unknown drv: " <> spName sp)-  sequence-    [ -- Single node-      runTest "single node graph" $ case DepGraph.buildDepGraph readSingle drvA spA of-        Right (DepGraph.DepGraph g) -> assertEqual "single-size" 1 (Map.size g)-        Left err -> Fail ("unexpected error: " <> err),-      -- Linear chain A to C: topoSort should give [C, A]-      runTest "linear chain topo" $ case DepGraph.buildDepGraph readChain drvB spB of-        Right graph -> case DepGraph.topoSort graph of-          DepGraph.TopoSorted order ->-            case order of-              [first, _] | first == spC -> Pass-              _ -> Fail ("bad order: " <> T.pack (show order))-          DepGraph.TopoCycle cyc -> Fail ("unexpected cycle: " <> T.pack (show cyc))-        Left err -> Fail ("graph build failed: " <> err),-      -- Diamond D to B,C; B to C: topoSort should have C first, D last-      runTest "diamond topo" $ case DepGraph.buildDepGraph readDiamond drvD spD of-        Right graph -> case DepGraph.topoSort graph of-          DepGraph.TopoSorted order ->-            case order of-              [first, _, lastElem] | first == spC, lastElem == spD -> Pass-              _ -> Fail ("bad diamond order: " <> T.pack (show order))-          DepGraph.TopoCycle cyc -> Fail ("unexpected cycle: " <> T.pack (show cyc))-        Left err -> Fail ("graph build failed: " <> err),-      -- transitiveDeps-      runTest "transitiveDeps diamond" $ case DepGraph.buildDepGraph readDiamond drvD spD of-        Right graph ->-          let deps = DepGraph.transitiveDeps graph spD-           in if Set.size deps == 2 && Set.member spB deps && Set.member spC deps-                then Pass-                else Fail ("bad transitive deps: " <> T.pack (show deps))-        Left err -> Fail ("graph build failed: " <> err),-      -- directDeps-      runTest "directDeps diamond" $ case DepGraph.buildDepGraph readDiamond drvD spD of-        Right graph ->-          let deps = DepGraph.directDeps graph spD-           in assertEqual "direct-count" 2 (length deps)-        Left err -> Fail ("graph build failed: " <> err),-      -- Missing .drv causes failure-      runTest "missing drv fails" $ case DepGraph.buildDepGraph readSingle drvB spB of-        Left _ -> Pass-        Right _ -> Fail "expected failure for missing drv",-      -- Single node topoSort-      runTest "single node topoSort" $ case DepGraph.buildDepGraph readSingle drvA spA of-        Right graph -> case DepGraph.topoSort graph of-          DepGraph.TopoSorted [x] -> assertEqual "single-topo" spA x-          other -> Fail ("unexpected topo result: " <> T.pack (show other))-        Left err -> Fail ("graph build failed: " <> err),-      -- buildDepGraph with mock for cycle detection-      -- (Cycle detection happens at topoSort level, not buildDepGraph)-      runTest "cycle detection" $ case DepGraph.buildDepGraph readCycle drvACycle spA of-        Right graph ->-          -- Graph builds but topo should detect the cycle or return partial-          case DepGraph.topoSort graph of-            DepGraph.TopoSorted _ -> Pass -- Kahn's returns what it can-            DepGraph.TopoCycle _ -> Pass-        Left _ -> Pass -- Also acceptable if buildDepGraph fails-    ]---- ------------------------------------------------------------------------------ Tests: Substituter (Phase 3, Batch 6)--- -----------------------------------------------------------------------------testSubstituter :: IO [Bool]-testSubstituter = do-  putStrLn "substituter"-  sequence-    [ -- sortCaches: priority ordering-      runTest "sortCaches priority ordering" $-        let c1 = Subst.CacheConfig "https://a.example.com" "key-a" 40-            c2 = Subst.CacheConfig "https://b.example.com" "key-b" 10-            c3 = Subst.CacheConfig "https://c.example.com" "key-c" 30-            sorted = Subst.sortCaches [c1, c2, c3]-         in case sorted of-              [s1, s2, s3] ->-                if Subst.ccPriority s1 == 10 && Subst.ccPriority s2 == 30 && Subst.ccPriority s3 == 40-                  then Pass-                  else Fail ("bad order: " <> T.pack (show (map Subst.ccPriority sorted)))-              _ -> Fail "expected 3 caches",-      -- sortCaches: empty list-      runTest "sortCaches empty" $-        assertEqual "empty-sort" [] (Subst.sortCaches []),-      -- decompressNar: "none" passes through-      runTest "decompressNar none" $-        let input = "fake nar data"-         in assertEqual "decompress-none" (Right input) (Subst.decompressNar "none" input),-      -- decompressNar: empty compression passes through-      runTest "decompressNar empty" $-        let input = "fake nar data"-         in assertEqual "decompress-empty" (Right input) (Subst.decompressNar "" input),-      -- decompressNar: xz returns error-      runTest "decompressNar xz unsupported" $-        case Subst.decompressNar "xz" "data" of-          Left _ -> Pass-          Right _ -> Fail "expected error for xz",-      -- decompressNar: unknown compression-      runTest "decompressNar unknown" $-        case Subst.decompressNar "brotli" "data" of-          Left _ -> Pass-          Right _ -> Fail "expected error for unknown",-      -- parseReferences: valid store paths-      runTest "parseReferences valid" $-        let refs = ["/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello"]-            parsed = Subst.parseReferences defaultStoreDir refs-         in assertEqual "parse-refs" 1 (length parsed),-      -- parseReferences: invalid paths filtered-      runTest "parseReferences invalid filtered" $-        let refs = ["not-a-store-path", "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello"]-            parsed = Subst.parseReferences defaultStoreDir refs-         in assertEqual "parse-refs-filter" 1 (length parsed),-      -- trySubstitute: empty caches returns SubstNotFound-      runTestM "trySubstitute no caches" $ do-        tmpBase <- getTemporaryDirectory-        let tmpStore = tmpBase </> "nova-nix-test-subst"-        createDirectoryIfMissing True tmpStore-        store <- openStore (StoreDir tmpStore)-        result <- Subst.trySubstitute store [] (StorePath "test" "hello")-        closeStore store-        removeDirectoryRecursive tmpStore-        pure (assertEqual "no-caches" Subst.SubstNotFound result),-      -- defaultCacheConfig has correct URL-      runTest "defaultCacheConfig url" $-        assertEqual "default-url" "https://cache.nixos.org" (Subst.ccUrl Subst.defaultCacheConfig),-      -- defaultCacheConfig has priority 40-      runTest "defaultCacheConfig priority" $-        assertEqual "default-prio" 40 (Subst.ccPriority Subst.defaultCacheConfig),-      -- verifyNarHash: matching NAR hash accepted (HIGH#2 integrity gate)-      runTest "verifyNarHash accepts matching hash" $-        assertEqual "narhash-match" (Right ()) (Subst.verifyNarHash (sampleNarInfo sampleNarHash) sampleNarBytes),-      -- verifyNarHash: mismatched NAR hash rejected-      runTest "verifyNarHash rejects mismatched hash" $-        case Subst.verifyNarHash (sampleNarInfo wrongNarHash) sampleNarBytes of-          Left _ -> Pass-          Right () -> Fail "expected mismatch rejection",-      -- verifyNarHash: malformed NAR hash rejected-      runTest "verifyNarHash rejects malformed hash" $-        case Subst.verifyNarHash (sampleNarInfo "not-a-hash") sampleNarBytes of-          Left _ -> Pass-          Right () -> Fail "expected malformed-hash rejection",-      -- narInfoMatchesPath: identity match accepted-      runTest "narInfoMatchesPath accepts matching identity" $-        if Subst.narInfoMatchesPath (StorePath sampleHash "hello") (sampleNarInfo sampleNarHash)-          then Pass-          else Fail "expected identity match",-      -- narInfoMatchesPath: identity mismatch rejected-      runTest "narInfoMatchesPath rejects mismatched identity" $-        if Subst.narInfoMatchesPath (StorePath (T.replicate 32 "b") "hello") (sampleNarInfo sampleNarHash)-          then Fail "expected identity mismatch"-          else Pass-    ]-  where-    sampleNarBytes = "nova-nix nar sample bytes" :: BS.ByteString-    sampleNarHash = CHash.formatNixHash (CHash.hashBytes sampleNarBytes)-    wrongNarHash = CHash.formatNixHash (CHash.hashBytes ("different bytes" :: BS.ByteString))-    sampleHash = T.replicate 32 "a"-    sampleNarInfo narHash =-      NarInfo.NarInfo-        { NarInfo.niStorePath = "/nix/store/" <> sampleHash <> "-hello",-          NarInfo.niUrl = "nar/sample.nar",-          NarInfo.niCompression = "none",-          NarInfo.niFileHash = sampleNarHash,-          NarInfo.niFileSize = 0,-          NarInfo.niNarHash = narHash,-          NarInfo.niNarSize = fromIntegral (BS.length sampleNarBytes),-          NarInfo.niReferences = [],-          NarInfo.niDeriver = Nothing,-          NarInfo.niSigs = [],-          NarInfo.niCA = Nothing-        }---- ------------------------------------------------------------------------------ Tests: Build orchestrator (Phase 3, Batch 7)--- -----------------------------------------------------------------------------testBuildOrchestrator :: IO [Bool]-testBuildOrchestrator = do-  putStrLn "build/orchestrator"-  sequence-    [ -- BuildConfig has caches field-      runTest "defaultBuildConfig has empty caches" $-        assertEqual "empty-caches" [] (bcCaches (defaultBuildConfig defaultStoreDir)),-      -- BuildConfig with caches-      runTest "BuildConfig accepts caches" $-        let cache = Subst.CacheConfig "https://cache.example.com" "key" 10-            config = (defaultBuildConfig defaultStoreDir) {bcCaches = [cache]}-         in assertEqual "one-cache" 1 (length (bcCaches config)),-      -- buildWithDeps on a simple derivation (no deps, builder fails but graph resolves)-      runTestM "buildWithDeps single drv" $ do-        tmpBase <- getTemporaryDirectory-        let tmpStore = tmpBase </> "nova-nix-test-orch"-        createDirectoryIfMissing True tmpStore-        store <- openStore (StoreDir tmpStore)-        let drv =-              Derivation-                { drvOutputs =-                    [ DerivationOutput-                        { doName = "out",-                          doPath = StorePath "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" "test-out",-                          doHashAlgo = "",-                          doHash = ""-                        }-                    ],-                  drvInputDrvs = Map.empty,-                  drvInputSrcs = [],-                  drvPlatform = currentPlatform,-                  drvBuilder = "/nonexistent-builder",-                  drvArgs = [],-                  drvEnv = Map.singleton "name" "test"-                }-            drvSP = StorePath "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" "test.drv"-        -- Write .drv to store so buildWithDeps can read it-        writeDrv store drv drvSP-        let config = (defaultBuildConfig (StoreDir tmpStore)) {bcTmpDir = tmpBase </> "nova-nix-test-orch-tmp"}-        result <- buildWithDeps config store drv drvSP-        closeStore store-        forceRemoveIfExists tmpStore-        -- Builder will fail (nonexistent) but the graph should resolve correctly-        pure $ case result of-          BuildFailure _ _ -> Pass-          BuildSuccess _ -> Fail "expected build failure for nonexistent builder",-      -- buildWithDeps with cycle detection (mocked through malformed graph)-      runTest "cycle detection returns failure" $-        let spA = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "a.drv"-            spB = StorePath "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" "b.drv"-            drvACyc =-              Derivation-                { drvOutputs = [],-                  drvInputDrvs = Map.singleton spB ["out"],-                  drvInputSrcs = [],-                  drvPlatform = X86_64_Linux,-                  drvBuilder = "/bin/sh",-                  drvArgs = [],-                  drvEnv = Map.empty-                }-            drvBCyc =-              Derivation-                { drvOutputs = [],-                  drvInputDrvs = Map.singleton spA ["out"],-                  drvInputSrcs = [],-                  drvPlatform = X86_64_Linux,-                  drvBuilder = "/bin/sh",-                  drvArgs = [],-                  drvEnv = Map.empty-                }-            readFn sp-              | sp == spB = Right drvBCyc-              | sp == spA = Right drvACyc-              | otherwise = Left "unknown"-         in case DepGraph.buildDepGraph readFn drvACyc spA of-              Right graph -> case DepGraph.topoSort graph of-                DepGraph.TopoCycle _ -> Pass-                DepGraph.TopoSorted order -> Fail ("expected cycle, got sorted: " <> T.pack (show order))-              Left err -> Fail ("expected graph to build, got: " <> err),-      -- missing .drv causes failure in dep graph-      runTest "missing drv in dep graph" $-        let sp = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "missing.drv"-            drv =-              Derivation-                { drvOutputs = [],-                  drvInputDrvs = Map.singleton sp ["out"],-                  drvInputSrcs = [],-                  drvPlatform = X86_64_Linux,-                  drvBuilder = "/bin/sh",-                  drvArgs = [],-                  drvEnv = Map.empty-                }-            readFn _ = Left "not found"-         in case DepGraph.buildDepGraph readFn drv (StorePath "rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr" "root.drv") of-              Left _ -> Pass-              Right _ -> Fail "expected failure for missing .drv",-      -- derivation with context creates populated inputDrvs-      runTest "derivation context populates inputDrvs"-        $ assertRight-          "drv-ctx-inputs"-          ( evalNix $-              T.concat-                [ "let dep = derivation { name = \"dep\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; ",-                  "main = derivation { name = \"main\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; src = dep.outPath; }; ",-                  "in main._derivation"-                ]-          )-        $ \case-          VDerivation drv ->-            if Map.null (drvInputDrvs drv)-              then Fail "expected non-empty drvInputDrvs"-              else Pass-          _ -> Fail "expected VDerivation"-    ]---- ------------------------------------------------------------------------------ Tests: Store.DB (Phase 2, Batch 1)--- ------------------------------------------------------------------------------- | Helper: run a test with a temporary store DB, cleaning up after.-withTempStoreDB :: (StoreDir -> IO [Bool]) -> IO [Bool]-withTempStoreDB action = do-  tmpBase <- getTemporaryDirectory-  let tmpStore = tmpBase </> "nova-nix-test-store-db"-  removeIfExists tmpStore-  createDirectoryIfMissing True tmpStore-  results <- action (StoreDir tmpStore)-  removeIfExists tmpStore-  pure results--removeIfExists :: FilePath -> IO ()-removeIfExists path = do-  exists <- doesDirectoryExist path-  when exists (removeDirectoryRecursive path)--testStoreDB :: IO [Bool]-testStoreDB = do-  putStrLn "store/db"-  withTempStoreDB $ \storeDir ->-    sequence-      [ -- open and close without error-        runTestM "db open/close" $ do-          db <- openStoreDB storeDir-          closeStoreDB db-          pure Pass,-        -- isValidPath returns False for unknown path-        runTestM "db isValidPath false for unknown" $ do-          db <- openStoreDB storeDir-          result <- isValidPath db (StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1" "unknown")-          closeStoreDB db-          pure (assertEqual "unknown" False result),-        -- register + isValidPath returns True-        runTestM "db register + isValid" $ do-          db <- openStoreDB storeDir-          let sp = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2" "hello"-              reg = PathRegistration sp "sha256:abc" 100 Nothing []-          registerPath db reg-          result <- isValidPath db sp-          closeStoreDB db-          pure (assertEqual "registered" True result),-        -- register with refs + query-        runTestM "db register with refs + query" $ do-          db <- openStoreDB storeDir-          let ref1 = StorePath "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" "dep1"-              ref2 = StorePath "cccccccccccccccccccccccccccccccc" "dep2"-              mainSp = StorePath "dddddddddddddddddddddddddddddddd" "mainpkg"-          registerPath db (PathRegistration ref1 "sha256:r1" 50 Nothing [])-          registerPath db (PathRegistration ref2 "sha256:r2" 60 Nothing [])-          registerPath db (PathRegistration mainSp "sha256:m1" 200 Nothing [ref1, ref2])-          refs <- queryReferences db mainSp-          closeStoreDB db-          let ref1Path = T.pack (storePathToFilePath storeDir ref1)-              ref2Path = T.pack (storePathToFilePath storeDir ref2)-              hasRef1 = ref1Path `elem` refs-              hasRef2 = ref2Path `elem` refs-          pure $-            if hasRef1 && hasRef2-              then Pass-              else Fail ("expected refs to contain both deps, got: " <> T.pack (show refs)),-        -- queryDeriver nothing-        runTestM "db queryDeriver nothing" $ do-          db <- openStoreDB storeDir-          let sp = StorePath "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" "noderiver"-          registerPath db (PathRegistration sp "sha256:nd" 80 Nothing [])-          result <- queryDeriver db sp-          closeStoreDB db-          pure (assertEqual "no deriver" Nothing result),-        -- queryDeriver just-        runTestM "db queryDeriver just" $ do-          db <- openStoreDB storeDir-          let sp = StorePath "ffffffffffffffffffffffffffffffff" "hasdrv"-          registerPath db (PathRegistration sp "sha256:hd" 90 (Just "/nix/store/xxx.drv") [])-          result <- queryDeriver db sp-          closeStoreDB db-          pure (assertEqual "has deriver" (Just "/nix/store/xxx.drv") result),-        -- queryPathInfo-        runTestM "db queryPathInfo" $ do-          db <- openStoreDB storeDir-          let sp = StorePath "gggggggggggggggggggggggggggggggg" "infotest"-          registerPath db (PathRegistration sp "sha256:info" 150 (Just "/drv") [])-          minfo <- queryPathInfo db sp-          closeStoreDB db-          pure $ case minfo of-            Nothing -> Fail "expected PathInfo but got Nothing"-            Just info ->-              if piNarHash info == "sha256:info"-                && piNarSize info == 150-                && piDeriver info == Just "/drv"-                then Pass-                else Fail ("bad PathInfo: " <> T.pack (show info)),-        -- queryPathInfo for unknown returns Nothing-        runTestM "db queryPathInfo unknown" $ do-          db <- openStoreDB storeDir-          minfo <- queryPathInfo db (StorePath "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh" "nope")-          closeStoreDB db-          pure (assertEqual "no info" Nothing minfo),-        -- double register is idempotent-        runTestM "db double register idempotent" $ do-          db <- openStoreDB storeDir-          let sp = StorePath "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" "double"-              reg = PathRegistration sp "sha256:dup" 120 Nothing []-          registerPath db reg-          registerPath db reg-          result <- isValidPath db sp-          closeStoreDB db-          pure (assertEqual "still valid" True result),-        -- multi-path reference graph-        runTestM "db multi-path reference graph" $ do-          db <- openStoreDB storeDir-          let spA = StorePath "jjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj" "a"-              spB = StorePath "kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk" "b"-              spC = StorePath "llllllllllllllllllllllllllllllll" "c"-          registerPath db (PathRegistration spA "sha256:a" 10 Nothing [])-          registerPath db (PathRegistration spB "sha256:b" 20 Nothing [spA])-          registerPath db (PathRegistration spC "sha256:c" 30 Nothing [spA, spB])-          refsC <- queryReferences db spC-          refsA <- queryReferences db spA-          closeStoreDB db-          let aPath = T.pack (storePathToFilePath storeDir spA)-              bPath = T.pack (storePathToFilePath storeDir spB)-          pure $-            if length refsC == 2 && aPath `elem` refsC && bPath `elem` refsC && null refsA-              then Pass-              else Fail ("bad ref graph: c refs=" <> T.pack (show refsC) <> " a refs=" <> T.pack (show refsA))-      ]---- ------------------------------------------------------------------------------ Tests: Store Operations + parseStorePath (Phase 2, Batch 2)--- -----------------------------------------------------------------------------testParseStorePath :: IO [Bool]-testParseStorePath = do-  putStrLn "store/parseStorePath"-  let sd = defaultStoreDir-  sequence-    [ runTest "parse valid store path" $-        assertEqual-          "valid"-          (Just (StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "hello-2.12"))-          (parseStorePath sd "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-2.12"),-      runTest "parse missing prefix" $-        assertEqual "no prefix" Nothing (parseStorePath sd "/tmp/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello"),-      runTest "parse too short hash" $-        assertEqual "short hash" Nothing (parseStorePath sd "/nix/store/aaa-hello"),-      runTest "parse missing dash after hash" $-        assertEqual "no dash" Nothing (parseStorePath sd "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaahello"),-      runTest "parse empty name" $-        assertEqual "empty name" Nothing (parseStorePath sd "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-"),-      runTest "parse windows store path" $-        assertEqual-          "windows"-          (Just (StorePath "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" "pkg"))-          (parseStorePath windowsStoreDir "C:\\nix\\store/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-pkg")-    ]---- ------------------------------------------------------------------------------ Tests: Push (pure narinfo construction + closure computation)--- -----------------------------------------------------------------------------testPushPure :: IO [Bool]-testPushPure = do-  putStrLn "push/narinfo"-  let hashA = T.replicate 32 "a"-      hashB = T.replicate 32 "b"-      spA = StorePath hashA "hello-1.0"-      spB = StorePath hashB "dep-2.0"-      narHash = "sha256:0123abcdef"-      -- References deliberately unsorted; deriver present.-      ni = mkNarInfo spA narHash 1234 [spB, spA] (Just spB)-  sequence-    [ runTest "narinfo StorePath is canonical /nix/store" $-        assertEqual "StorePath" ("/nix/store/" <> hashA <> "-hello-1.0") (NarInfo.niStorePath ni),-      runTest "narinfo URL is nar/<digest>.nar" $-        assertEqual "URL" "nar/0123abcdef.nar" (NarInfo.niUrl ni),-      runTest "compression none mirrors NAR fields into file fields" $-        assertEqual-          "file fields"-          ("none", NarInfo.niNarHash ni, NarInfo.niNarSize ni)-          (NarInfo.niCompression ni, NarInfo.niFileHash ni, NarInfo.niFileSize ni),-      runTest "narinfo sizes carry through" $-        assertEqual "NarSize" 1234 (NarInfo.niNarSize ni),-      runTest "references are sorted basenames" $-        assertEqual-          "References"-          [hashA <> "-hello-1.0", hashB <> "-dep-2.0"]-          (NarInfo.niReferences ni),-      runTest "deriver renders as a basename" $-        assertEqual "Deriver" (Just (hashB <> "-dep-2.0")) (NarInfo.niDeriver ni),-      runTest "no client-side signatures" $-        assertEqual "Sigs" ([] :: [Text]) (NarInfo.niSigs ni),-      runTest "planMissing keeps only uncached paths" $-        assertEqual-          "missing"-          [hashB]-          (map spHash (planMissing (Set.singleton hashA) [spA, spB])),-      runTest "stripHashPrefix drops the algo tag" $-        assertEqual "tagged" "deadbeef" (stripHashPrefix "sha256:deadbeef"),-      runTest "stripHashPrefix tolerates a bare digest" $-        assertEqual "bare" "deadbeef" (stripHashPrefix "deadbeef"),-      runTest "storePathBasename joins hash and name" $-        assertEqual "basename" (hashA <> "-hello-1.0") (storePathBasename spA),-      runTest "narFileName appends .nar" $-        assertEqual "file" "0123abcdef.nar" (narFileName narHash)-    ]---- | Closure computation against a real (temp) store database.-testPushClosureIO :: IO [Bool]-testPushClosureIO = do-  putStrLn "push/closure"-  withTempStore $ \store -> do-    let spTop = StorePath (T.replicate 32 "1") "top"-        spMid = StorePath (T.replicate 32 "2") "mid"-        spLeaf = StorePath (T.replicate 32 "3") "leaf"-        regFor sp refs =-          PathRegistration-            { prPath = sp,-              prNarHash = "sha256:" <> T.replicate 52 "0",-              prNarSize = 1,-              prDeriver = Nothing,-              prReferences = refs-            }-    registerPaths (stDB store) [regFor spTop [spMid], regFor spMid [spLeaf], regFor spLeaf []]-    fromTop <- computeClosure store [spTop]-    fromBoth <- computeClosure store [spTop, spLeaf]-    sequence-      [ runTest "closure walks transitive references" $-          assertRight "fromTop" fromTop $ \closure ->-            assertEqual-              "hashes"-              (Set.fromList (map spHash [spTop, spMid, spLeaf]))-              (Set.fromList (map spHash closure)),-        runTest "closure deduplicates across roots" $-          assertRight "fromBoth" fromBoth $ \closure ->-            assertEqual "count" (3 :: Int) (length closure)-      ]---- | Helper: create a fresh temp store for IO tests.-withTempStore :: (Store -> IO [Bool]) -> IO [Bool]-withTempStore action = do-  tmpBase <- getTemporaryDirectory-  let tmpStore = tmpBase </> "nova-nix-test-store-ops"-  forceRemoveIfExists tmpStore-  createDirectoryIfMissing True tmpStore-  store <- openStore (StoreDir tmpStore)-  results <- action store-  closeStore store-  forceRemoveIfExists tmpStore-  pure results---- | Recursively restore writable permissions then remove.--- Needed because addToStore/setReadOnly makes paths read-only.-forceRemoveIfExists :: FilePath -> IO ()-forceRemoveIfExists path = do-  exists <- doesDirectoryExist path-  when exists $ do-    restoreWritable path-    removeDirectoryRecursive path--restoreWritable :: FilePath -> IO ()-restoreWritable path = do-  isDir <- doesDirectoryExist path-  when isDir $ do-    perms <- getPermissions path-    Dir.setPermissions path (Dir.setOwnerWritable True perms)-    entries <- Dir.listDirectory path-    mapM_ (restoreWritable . (path </>)) entries-  isFile <- Dir.doesFileExist path-  when isFile $ do-    perms <- getPermissions path-    Dir.setPermissions path (Dir.setOwnerWritable True perms)---- | Step 1 of the Windows-stdenv ladder: prove the Builder can run a--- trivial derivation end-to-end natively - a recipe that writes to @$out@,--- with the output landing in the store.  No stdenv, no dependencies: just--- the raw build path (process spawn, output capture, addToStore),--- exercised on the host platform (@cmd.exe@ on Windows, @\/bin\/sh@ elsewhere).-testTrivialBuildIO :: IO [Bool]-testTrivialBuildIO = do-  putStrLn "builder/trivial-native-build"-  withTempStore $ \store -> do-    let storeDir = stDir store-        outPath = StorePath (T.replicate 31 "0" <> "1") "trivial"-        (builder, args)-          | SI.os == "mingw32" = ("cmd.exe", ["/c", "echo hi>%out%"])-          | otherwise = ("/bin/sh", ["-c", "echo hi > $out"])-        drv =-          Derivation-            { drvOutputs =-                [ DerivationOutput-                    { doName = "out",-                      doPath = outPath,-                      doHashAlgo = "",-                      doHash = ""-                    }-                ],-              drvInputDrvs = Map.empty,-              drvInputSrcs = [],-              drvPlatform = currentPlatform,-              drvBuilder = builder,-              drvArgs = args,-              drvEnv = Map.empty-            }-    buildTmp <- (</> "nova-nix-test-trivial-build-tmp") <$> getTemporaryDirectory-    forceRemoveIfExists buildTmp-    createDirectoryIfMissing True buildTmp-    let config = (defaultBuildConfig storeDir) {bcTmpDir = buildTmp}-    result <- buildDerivation config store drv-    forceRemoveIfExists buildTmp-    case result of-      BuildSuccess sp -> do-        let outFile = storePathToFilePath storeDir sp-        landed <- Dir.doesFileExist outFile-        content <- if landed then TIO.readFile outFile else pure ""-        narInfo <- queryPathInfo (stDB store) sp-        let realNarHash = case narInfo of-              Just info ->-                piNarSize info > 0-                  && T.isPrefixOf "sha256:" (piNarHash info)-                  && T.any (/= '0') (T.drop 7 (piNarHash info))-              Nothing -> False-        sequence-          [ runTest "trivial build succeeds" Pass,-            runTest "trivial build output landed in store" $-              if landed then Pass else Fail ("missing output file: " <> T.pack outFile),-            runTest "trivial build actually ran the command" $-              if "hi" `T.isInfixOf` content-                then Pass-                else Fail ("unexpected output content: " <> T.pack (show content)),-            runTest "trivial build records a real NAR hash + size" $-              if realNarHash-                then Pass-                else Fail ("NAR hash/size not real: " <> T.pack (show narInfo))-          ]-      BuildFailure msg code ->-        sequence-          [ runTest "trivial build succeeds" $-              Fail ("build failed (exit " <> T.pack (show code) <> "): " <> msg)-          ]---- | The builder hands every build a fixed SOURCE_DATE_EPOCH (1980-01-01),--- the reproducible-builds.org convention that determinism-aware tools--- (ld's PE timestamp, gcc's __DATE__) honor.  Proven behaviorally: a build--- that echoes the variable into $out must see the pinned value.-testSourceDateEpochIO :: IO [Bool]-testSourceDateEpochIO = do-  putStrLn "builder/source-date-epoch"-  withTempStore $ \store -> do-    let storeDir = stDir store-        outPath = StorePath (T.replicate 31 "0" <> "5") "sde-probe"-        (builder, args)-          | SI.os == "mingw32" = ("cmd.exe", ["/c", "echo %SOURCE_DATE_EPOCH%>%out%"])-          | otherwise = ("/bin/sh", ["-c", "echo $SOURCE_DATE_EPOCH > $out"])-        drv =-          Derivation-            { drvOutputs =-                [ DerivationOutput-                    { doName = "out",-                      doPath = outPath,-                      doHashAlgo = "",-                      doHash = ""-                    }-                ],-              drvInputDrvs = Map.empty,-              drvInputSrcs = [],-              drvPlatform = currentPlatform,-              drvBuilder = builder,-              drvArgs = args,-              drvEnv = Map.empty-            }-    buildTmp <- (</> "nova-nix-test-sde-build-tmp") <$> getTemporaryDirectory-    forceRemoveIfExists buildTmp-    createDirectoryIfMissing True buildTmp-    let config = (defaultBuildConfig storeDir) {bcTmpDir = buildTmp}-    result <- buildDerivation config store drv-    forceRemoveIfExists buildTmp-    case result of-      BuildFailure msg code ->-        sequence-          [ runTest "SOURCE_DATE_EPOCH build succeeds" $-              Fail ("build failed (exit " <> T.pack (show code) <> "): " <> msg)-          ]-      BuildSuccess sp -> do-        content <- TIO.readFile (storePathToFilePath storeDir sp)-        sequence-          [ runTest "builder pins SOURCE_DATE_EPOCH to 1980-01-01" $-              if "315532800" `T.isInfixOf` content-                then Pass-                else Fail ("unexpected content: " <> T.pack (show content))-          ]---- | Zstd compression level for test archives (zstd's own default).-testZstdCompressionLevel :: Int-testZstdCompressionLevel = 3---- | Build a @.tar.zst@ archive from tar entries, MSYS2-package style.-compressArchive :: [TarEntry.Entry] -> BL.ByteString-compressArchive = ZstdL.compress testZstdCompressionLevel . Tar.write---- | Test-setup helpers: the paths and link targets below are short literals,--- so encoding them cannot fail; 'error' marks setup bugs, not test failures.-tarPathOrDie :: Bool -> FilePath -> TarEntry.TarPath-tarPathOrDie isDir p =-  either (error . ("test tar path: " ++)) id (TarEntry.toTarPath isDir p)--linkOrDie :: FilePath -> TarEntry.LinkTarget-linkOrDie t =-  fromMaybe (error ("test link target: " ++ t)) (TarEntry.toLinkTarget t)--tarDir :: FilePath -> TarEntry.Entry-tarDir p = TarEntry.directoryEntry (tarPathOrDie True p)--tarFile :: FilePath -> BL.ByteString -> TarEntry.Entry-tarFile p = TarEntry.fileEntry (tarPathOrDie False p)--tarExecFile :: FilePath -> BL.ByteString -> TarEntry.Entry-tarExecFile p content = (tarFile p content) {TarEntry.entryPermissions = 0o755}--tarHardLink :: FilePath -> FilePath -> TarEntry.Entry-tarHardLink p target =-  TarEntry.simpleEntry (tarPathOrDie False p) (Tar.HardLink (linkOrDie target))--tarSymLink :: FilePath -> FilePath -> TarEntry.Entry-tarSymLink p target =-  TarEntry.simpleEntry (tarPathOrDie False p) (Tar.SymbolicLink (linkOrDie target))---- | Step 3 of the ladder: @builtin:unpack@, the stage-0 seed extractor.--- Two zstd-compressed tar archives sharing a top-level prefix (the MSYS2--- @mingw64\/@ shape) are extracted into ONE output: regular files, an--- executable, a hardlink and a relative symlink (both materialized as--- copies), with pacman metadata (@.PKGINFO@\/@.MTREE@) skipped.  A second--- build proves cross-archive file collisions fail loudly, and a third that--- path traversal is rejected.-testUnpackBuildIO :: IO [Bool]-testUnpackBuildIO = do-  putStrLn "builder/unpack-seed-archives"-  withTempStore $ \store -> do-    let storeDir = stDir store-    tmpBase <- getTemporaryDirectory-    let workDir = tmpBase </> "nova-nix-test-unpack-src"-    forceRemoveIfExists workDir-    createDirectoryIfMissing True workDir-    let archiveTools = workDir </> "pkg-tools.tar.zst"-        archiveData = workDir </> "pkg-data.tar.zst"-        archiveCollide = workDir </> "pkg-collide.tar.zst"-        archiveEscape = workDir </> "pkg-escape.tar.zst"-    BL.writeFile archiveTools $-      compressArchive-        [ tarDir "pkg",-          tarDir "pkg/bin",-          tarExecFile "pkg/bin/tool.exe" "tool-payload",-          tarHardLink "pkg/bin/tool-link.exe" "pkg/bin/tool.exe",-          tarSymLink "pkg/bin/sh.exe" "tool.exe",-          tarFile ".PKGINFO" "pkgname = tools"-        ]-    BL.writeFile archiveData $-      compressArchive-        [ tarDir "pkg",-          tarDir "pkg/share",-          tarFile "pkg/share/data.txt" "shared-data",-          tarFile ".MTREE" "mtree-bytes"-        ]-    BL.writeFile archiveCollide $-      compressArchive [tarFile "pkg/bin/tool.exe" "conflicting"]-    BL.writeFile archiveEscape $-      compressArchive [tarFile "../evil.txt" "escape"]-    let mkUnpackDrv outP srcFiles =-          Derivation-            { drvOutputs =-                [ DerivationOutput-                    { doName = "out",-                      doPath = outP,-                      doHashAlgo = "",-                      doHash = ""-                    }-                ],-              drvInputDrvs = Map.empty,-              drvInputSrcs = [],-              drvPlatform = currentPlatform,-              drvBuilder = builtinUnpackBuilder,-              drvArgs = [],-              drvEnv =-                Map.fromList-                  [(envSrcs, T.intercalate " " (map T.pack srcFiles))]-            }-        seedOut = StorePath (T.replicate 31 "0" <> "2") "unpack-seed"-        collideOut = StorePath (T.replicate 31 "0" <> "3") "unpack-collide"-        escapeOut = StorePath (T.replicate 31 "0" <> "4") "unpack-escape"-    buildTmp <- (</> "nova-nix-test-unpack-build-tmp") <$> getTemporaryDirectory-    forceRemoveIfExists buildTmp-    createDirectoryIfMissing True buildTmp-    let config = (defaultBuildConfig storeDir) {bcTmpDir = buildTmp}-    seedResult <- buildDerivation config store (mkUnpackDrv seedOut [archiveTools, archiveData])-    collideResult <- buildDerivation config store (mkUnpackDrv collideOut [archiveTools, archiveCollide])-    escapeResult <- buildDerivation config store (mkUnpackDrv escapeOut [archiveEscape])-    forceRemoveIfExists buildTmp-    forceRemoveIfExists workDir-    seedChecks <- case seedResult of-      BuildFailure msg code ->-        sequence-          [ runTest "unpack seed build succeeds" $-              Fail ("build failed (exit " <> T.pack (show code) <> "): " <> msg)-          ]-      BuildSuccess sp -> do-        let outRoot = storePathToFilePath storeDir sp-            readOut rel = do-              let path = outRoot </> rel-              exists <- Dir.doesFileExist path-              if exists then Just <$> TIO.readFile path else pure Nothing-        tool <- readOut ("pkg" </> "bin" </> "tool.exe")-        toolLink <- readOut ("pkg" </> "bin" </> "tool-link.exe")-        shLink <- readOut ("pkg" </> "bin" </> "sh.exe")-        dataFile <- readOut ("pkg" </> "share" </> "data.txt")-        pkgInfo <- Dir.doesFileExist (outRoot </> ".PKGINFO")-        mtree <- Dir.doesFileExist (outRoot </> ".MTREE")-        execBitOk <--          if SI.os == "mingw32"-            then pure True -- NTFS has no exec bit; PATHEXT decides-            else Dir.executable <$> getPermissions (outRoot </> "pkg" </> "bin" </> "tool.exe")-        narInfo <- queryPathInfo (stDB store) sp-        let realNarHash = case narInfo of-              Just info ->-                piNarSize info > 0 && T.isPrefixOf "sha256:" (piNarHash info)-              Nothing -> False-        sequence-          [ runTest "unpack seed build succeeds" Pass,-            runTest "unpack: file extracted with content" $-              assertEqual "tool.exe" (Just "tool-payload") tool,-            runTest "unpack: hardlink materialized as copy" $-              assertEqual "tool-link.exe" (Just "tool-payload") toolLink,-            runTest "unpack: relative symlink materialized as copy" $-              assertEqual "sh.exe" (Just "tool-payload") shLink,-            runTest "unpack: second archive merged into shared prefix" $-              assertEqual "data.txt" (Just "shared-data") dataFile,-            runTest "unpack: pacman metadata skipped" $-              if pkgInfo || mtree-                then Fail ".PKGINFO/.MTREE leaked into the output"-                else Pass,-            runTest "unpack: executable bit materialized (unix)" $-              if execBitOk then Pass else Fail "tool.exe not executable",-            runTest "unpack: real NAR hash registered" $-              if realNarHash then Pass else Fail (T.pack (show narInfo))-          ]-    collideChecks <--      sequence-        [ runTest "unpack: cross-archive file collision fails loudly" $-            case collideResult of-              BuildFailure msg _-                | "file collision" `T.isInfixOf` msg -> Pass-                | otherwise -> Fail ("wrong failure: " <> msg)-              BuildSuccess _ -> Fail "collision build unexpectedly succeeded"-        ]-    escapeChecks <--      sequence-        [ runTest "unpack: path traversal rejected" $-            case escapeResult of-              BuildFailure msg _-                | "escapes archive root" `T.isInfixOf` msg -> Pass-                | otherwise -> Fail ("wrong failure: " <> msg)-              BuildSuccess _ -> Fail "escape build unexpectedly succeeded"-        ]-    pure (seedChecks ++ collideChecks ++ escapeChecks)---- | Step 2 of the ladder: a dependency-aware build end-to-end.  A root--- derivation depends on a leaf; both @.drv@ files are pre-written to the store--- (as the build driver's closure-writing does), and 'buildWithDeps' must read--- the closure, topologically order it, build the leaf first, then the root ---- whose 'validateInputs' requires the leaf's realized output to be valid.------ This is the regression guard for the input-@.drv@-closure fix: before it, no--- derivation with a non-empty @drvInputDrvs@ could be realized (the closure was--- never on disk, so the dependency graph could not be read).-testDependentBuildIO :: IO [Bool]-testDependentBuildIO = do-  putStrLn "builder/dependent-native-build"-  withTempStore $ \store -> do-    let storeDir = stDir store-        depOut = StorePath (T.replicate 31 "c" <> "0") "dep"-        depDrvSP = StorePath (T.replicate 31 "c" <> "1") "dep.drv"-        rootOut = StorePath (T.replicate 31 "a" <> "0") "root"-        rootDrvSP = StorePath (T.replicate 31 "a" <> "1") "root.drv"-        mkBuilder word-          | SI.os == "mingw32" = ("cmd.exe", ["/c", "echo " <> word <> ">%out%"])-          | otherwise = ("/bin/sh", ["-c", "echo " <> word <> " > $out"])-        (depBuilder, depArgs) = mkBuilder "leaf"-        (rootBuilder, rootArgs) = mkBuilder "root"-        depDrv =-          Derivation-            { drvOutputs = [DerivationOutput {doName = "out", doPath = depOut, doHashAlgo = "", doHash = ""}],-              drvInputDrvs = Map.empty,-              drvInputSrcs = [],-              drvPlatform = currentPlatform,-              drvBuilder = depBuilder,-              drvArgs = depArgs,-              drvEnv = Map.singleton "name" "dep"-            }-        rootDrv =-          Derivation-            { drvOutputs = [DerivationOutput {doName = "out", doPath = rootOut, doHashAlgo = "", doHash = ""}],-              drvInputDrvs = Map.singleton depDrvSP ["out"],-              drvInputSrcs = [],-              drvPlatform = currentPlatform,-              drvBuilder = rootBuilder,-              drvArgs = rootArgs,-              drvEnv = Map.singleton "name" "root"-            }-    -- The build driver writes the full .drv closure before building; emulate-    -- that here by writing both recipes to the store.-    writeDrv store depDrv depDrvSP-    writeDrv store rootDrv rootDrvSP-    buildTmp <- (</> "nova-nix-test-dep-build-tmp") <$> getTemporaryDirectory-    forceRemoveIfExists buildTmp-    createDirectoryIfMissing True buildTmp-    let config = (defaultBuildConfig storeDir) {bcTmpDir = buildTmp}-    result <- buildWithDeps config store rootDrv rootDrvSP-    depValid <- isValid store depOut-    rootValid <- isValid store rootOut-    forceRemoveIfExists buildTmp-    case result of-      BuildSuccess sp ->-        sequence-          [ runTest "dependent build succeeds with root output" $-              assertEqual "root output path" rootOut sp,-            runTest "leaf dependency built and registered first" $-              if depValid then Pass else Fail "leaf output not valid after build",-            runTest "root output built and registered" $-              if rootValid then Pass else Fail "root output not valid after build"-          ]-      BuildFailure msg code ->-        sequence-          [ runTest "dependent build succeeds with root output" $-              Fail ("dependency-aware build failed (exit " <> T.pack (show code) <> "): " <> msg)-          ]---- | Regression tests for the 2026-06-09 audit eval-fidelity fixes.  All are--- parity-safe - none affects a derivation or store-path hash.-testEvalFidelity :: IO [Bool]-testEvalFidelity = do-  putStrLn "eval/audit-fidelity"-  sequence-    [ -- builtins.match: a non-participating capture group is null, not ""-      runTest "match null capture group" $-        assertEval "match-null" "builtins.isNull (builtins.elemAt (builtins.match \"(a)(b)?\" \"a\") 1)" (VBool True),-      runTest "match participating group value" $-        assertEval "match-val" "builtins.elemAt (builtins.match \"(a)(b)\" \"ab\") 1" (mkStr "b"),-      -- builtins.split: a non-participating group is null too-      runTest "split null capture group" $-        assertEval "split-null" "builtins.isNull (builtins.elemAt (builtins.elemAt (builtins.split \"(a)|(b)\" \"a\") 1) 1)" (VBool True),-      -- builtins.toJSON honors __toString, preferring it over outPath-      runTest "toJSON __toString" $-        assertEval "tojson-tostr" "builtins.toJSON { __toString = self: \"x\"; }" (mkStr "\"x\""),-      runTest "toJSON __toString beats outPath" $-        assertEval "tojson-tostr-out" "builtins.toJSON { __toString = self: \"a\"; outPath = \"/b\"; }" (mkStr "\"a\""),-      runTest "toJSON outPath still works" $-        assertEval "tojson-out" "builtins.toJSON { outPath = \"/b\"; }" (mkStr "\"/b\""),-      -- String interpolation is strict (coerceMore = False): scalars error-      runTest "interp rejects int" $-        assertEvalFail "interp-int" "\"v${1}\"",-      runTest "interp rejects bool" $-        assertEvalFail "interp-bool" "\"${true}\"",-      runTest "interp rejects null" $-        assertEvalFail "interp-null" "\"${null}\"",-      runTest "concatStringsSep rejects int" $-        assertEvalFail "ccs-int" "builtins.concatStringsSep \",\" [ 1 2 ]",-      -- builtins.toString stays permissive-      runTest "toString still coerces int" $-        assertEval "tostr-int" "builtins.toString 1" (mkStr "1"),-      runTest "interp via toString works" $-        assertEval "interp-tostr" "\"v${builtins.toString 1}\"" (mkStr "v1"),-      -- builtins.substring rejects a negative start position-      runTest "substring negative start errors" $-        assertEvalFail "substr-neg" "builtins.substring (0 - 1) 3 \"hello\"",-      -- an integer literal that overflows Int64 is a parse error, not a wrap-      runTest "integer literal overflow errors" $-        assertEvalFail "int-overflow" "99999999999999999999",-      -- float exponents and leading-dot floats lex per the Nix grammar-      runTest "float exponent lexes as float" $-        assertEval "float-exp-type" "builtins.typeOf 6.022e23" (mkStr "float"),-      runTest "float negative exponent lexes" $-        assertEval "float-negexp-type" "builtins.typeOf 1.0e-3" (mkStr "float"),-      runTest "float exponent value" $-        assertEval "float-exp-val" "1.5e1 == 15.0" (VBool True),-      runTest "leading-dot float lexes as float" $-        assertEval "leading-dot-type" "builtins.typeOf .5" (mkStr "float"),-      runTest "leading-dot float value" $-        assertEval "leading-dot-val" ".5 == 0.5" (VBool True),-      -- PureEval tryEval must NOT catch abort - it propagates (matches C++ Nix)-      runTest "tryEval does not catch abort" $-        assertEvalFail "tryeval-abort" "(builtins.tryEval (builtins.abort \"x\")).success",-      -- every builtin in the registry (the source of builtinNames/builtinArity)-      -- is actually exposed in the builtins set - guards against builtinRegistry-      -- drifting from the exposed builtins-      runTest "every registered builtin is exposed" $-        let missing = [n | n <- builtinNames, evalNix ("builtins ? \"" <> n <> "\"") /= Right (VBool True)]-         in if null missing then Pass else Fail ("not exposed: " <> T.intercalate ", " missing)-    ]---- | Pure hash decode/digest helpers shared by eval (fixed-output paths) and--- the builder (builtin:fetchurl verification).-testHashHelpers :: IO [Bool]-testHashHelpers = do-  putStrLn "hash/decode-helpers"-  sequence-    [ runTest "hexToBytes empty" $ assertEqual "empty" (Just BS.empty) (Hash.hexToBytes ""),-      runTest "hexToBytes deadbeef" $-        assertEqual "deadbeef" (Just (BS.pack [0xde, 0xad, 0xbe, 0xef])) (Hash.hexToBytes "deadbeef"),-      runTest "hexToBytes uppercase" $-        assertEqual "DEADBEEF" (Just (BS.pack [0xde, 0xad, 0xbe, 0xef])) (Hash.hexToBytes "DEADBEEF"),-      runTest "hexToBytes odd length rejected" $ assertEqual "odd" Nothing (Hash.hexToBytes "abc"),-      runTest "hexToBytes non-hex rejected" $ assertEqual "nonhex" Nothing (Hash.hexToBytes "zz"),-      runTest "rawHashWithAlgo sha256 empty == known vector" $-        assertEqual-          "sha256-empty"-          (Hash.hexToBytes "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")-          (Hash.rawHashWithAlgo "sha256" BS.empty),-      runTest "rawHashWithAlgo unknown algo rejected" $-        assertEqual "unknown" Nothing (Hash.rawHashWithAlgo "sha3-256" (BS.pack [1, 2, 3]))-    ]--testStoreOps :: IO [Bool]-testStoreOps = do-  putStrLn "store/ops"-  withTempStore $ \store -> do-    let sd = stDir store-    sequence-      [ -- scanReferences finds embedded store path-        runTestM "scanReferences finds ref" $ do-          tmpBase <- getTemporaryDirectory-          let scanDir = tmpBase </> "nova-nix-test-scan"-          removeIfExists scanDir-          createDirectoryIfMissing True scanDir-          let candidate = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "dep1"-              prefix = unStoreDir sd-              refString = prefix <> "/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-dep1"-          BS.writeFile (scanDir </> "output.txt") (TE.encodeUtf8 (T.pack ("hello " <> refString <> " world")))-          refs <- scanReferences sd [candidate] scanDir-          removeIfExists scanDir-          pure $-            if candidate `elem` refs-              then Pass-              else Fail ("expected to find ref, got: " <> T.pack (show refs)),-        -- scanReferences misses non-matching-        runTestM "scanReferences misses non-match" $ do-          tmpBase <- getTemporaryDirectory-          let scanDir = tmpBase </> "nova-nix-test-scan2"-          removeIfExists scanDir-          createDirectoryIfMissing True scanDir-          let candidate = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "dep1"-          BS.writeFile (scanDir </> "output.txt") "no store paths here"-          refs <- scanReferences sd [candidate] scanDir-          removeIfExists scanDir-          pure (assertEqual "no refs" [] refs),-        -- scanReferences ignores partial match-        runTestM "scanReferences ignores partial" $ do-          tmpBase <- getTemporaryDirectory-          let scanDir = tmpBase </> "nova-nix-test-scan3"-          removeIfExists scanDir-          createDirectoryIfMissing True scanDir-          let candidate = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "dep1"-              prefix = unStoreDir sd-              -- Only 20 chars of hash - should not match 32-char candidate-              partialRef = prefix <> "/aaaaaaaaaaaaaaaaaaaa"-          BS.writeFile (scanDir </> "output.txt") (TE.encodeUtf8 (T.pack partialRef))-          refs <- scanReferences sd [candidate] scanDir-          removeIfExists scanDir-          pure (assertEqual "no partial refs" [] refs),-        -- addToStore moves dir + registers in DB-        runTestM "addToStore moves + registers" $ do-          tmpBase <- getTemporaryDirectory-          let srcDir = tmpBase </> "nova-nix-test-add-src"-          removeIfExists srcDir-          createDirectoryIfMissing True srcDir-          writeFile (srcDir </> "hello.txt") "hello world"-          let sp = StorePath "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" "addtest"-          addToStore store srcDir sp Nothing []-          valid <- isValid store sp-          exists <- pathExists store sp-          pure $-            if valid && exists-              then Pass-              else Fail ("valid=" <> T.pack (show valid) <> " exists=" <> T.pack (show exists)),-        -- addToStore sets read-only-        runTestM "addToStore sets read-only" $ do-          tmpBase <- getTemporaryDirectory-          let srcDir = tmpBase </> "nova-nix-test-add-ro"-          removeIfExists srcDir-          createDirectoryIfMissing True srcDir-          writeFile (srcDir </> "data.txt") "data"-          let sp = StorePath "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" "rotest"-          addToStore store srcDir sp Nothing []-          let destFile = storePathToFilePath sd sp </> "data.txt"-          perms <- getPermissions destFile-          pure $-            if not (writable perms)-              then Pass-              else Fail "expected read-only but file is writable",-        -- setReadOnly works on a plain directory-        runTestM "setReadOnly makes dir read-only" $ do-          tmpBase <- getTemporaryDirectory-          let roDir = tmpBase </> "nova-nix-test-readonly"-          removeIfExists roDir-          createDirectoryIfMissing True roDir-          writeFile (roDir </> "f.txt") "content"-          setReadOnly roDir-          dirPerms <- getPermissions roDir-          filePerms <- getPermissions (roDir </> "f.txt")-          -- Cleanup: restore writable so removeIfExists works-          Dir.setPermissions roDir (Dir.setOwnerWritable True dirPerms)-          Dir.setPermissions (roDir </> "f.txt") (Dir.setOwnerWritable True filePerms)-          removeIfExists roDir-          pure $-            if not (writable dirPerms) && not (writable filePerms)-              then Pass-              else-                Fail-                  ( "dir writable="-                      <> T.pack (show (writable dirPerms))-                      <> " file writable="-                      <> T.pack (show (writable filePerms))-                  ),-        -- pathExists true after addToStore-        runTestM "pathExists after addToStore" $ do-          tmpBase <- getTemporaryDirectory-          let srcDir = tmpBase </> "nova-nix-test-exists"-          removeIfExists srcDir-          createDirectoryIfMissing True srcDir-          writeFile (srcDir </> "x.txt") "x"-          let sp = StorePath "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww" "existstest"-          addToStore store srcDir sp Nothing []-          exists <- pathExists store sp-          pure (assertEqual "exists" True exists)-      ]---- ------------------------------------------------------------------------------ Tests: fromATerm + Derivation Output Population (Phase 2, Batch 3)--- ------------------------------------------------------------------------------- | A simple test derivation for round-trip testing.-simpleTestDrv :: Derivation-simpleTestDrv =-  Derivation-    { drvOutputs =-        [ DerivationOutput-            { doName = "out",-              doPath = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "hello-1.0",-              doHashAlgo = "",-              doHash = ""-            }-        ],-      drvInputDrvs = Map.empty,-      drvInputSrcs = [],-      drvPlatform = X86_64_Linux,-      drvBuilder = "/nix/store/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-bash-5.2/bin/bash",-      drvArgs = ["-e", "/nix/store/cccccccccccccccccccccccccccccccc-stdenv/setup"],-      drvEnv = Map.fromList [("name", "hello-1.0"), ("system", "x86_64-linux")]-    }---- | A complex test derivation with multiple outputs, input drvs, and input srcs.-complexTestDrv :: Derivation-complexTestDrv =-  Derivation-    { drvOutputs =-        [ DerivationOutput "dev" (StorePath "dddddddddddddddddddddddddddddddd" "pkg-2.0-dev") "" "",-          DerivationOutput "out" (StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "pkg-2.0") "" ""-        ],-      drvInputDrvs =-        Map.fromList-          [ (StorePath "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" "dep1.drv", ["out"]),-            (StorePath "ffffffffffffffffffffffffffffffff" "dep2.drv", ["lib", "out"])-          ],-      drvInputSrcs = [StorePath "gggggggggggggggggggggggggggggggg" "source.tar.gz"],-      drvPlatform = Aarch64_Darwin,-      drvBuilder = "/nix/store/hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh-bash/bin/bash",-      drvArgs = ["-e", "build.sh"],-      drvEnv =-        Map.fromList-          [ ("buildInputs", "/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-dep1"),-            ("name", "pkg-2.0"),-            ("out", "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-pkg-2.0"),-            ("system", "aarch64-darwin")-          ]-    }--testFromATerm :: IO [Bool]-testFromATerm = do-  putStrLn "derivation/fromATerm"-  sequence-    [ -- Round-trip: simple derivation-      runTest "fromATerm round-trip simple" $-        assertEqual "simple round-trip" (Right simpleTestDrv) (fromATerm (toATerm simpleTestDrv)),-      -- Round-trip: complex derivation-      runTest "fromATerm round-trip complex" $-        assertEqual "complex round-trip" (Right complexTestDrv) (fromATerm (toATerm complexTestDrv)),-      -- Round-trip: empty derivation (no outputs, no inputs, no args, no env)-      runTest "fromATerm round-trip empty" $-        let emptyDrv =-              Derivation-                { drvOutputs = [],-                  drvInputDrvs = Map.empty,-                  drvInputSrcs = [],-                  drvPlatform = X86_64_Linux,-                  drvBuilder = "/bin/true",-                  drvArgs = [],-                  drvEnv = Map.empty-                }-         in assertEqual "empty round-trip" (Right emptyDrv) (fromATerm (toATerm emptyDrv)),-      -- Reject empty string-      runTest "fromATerm rejects empty" $-        assertLeft "empty" (fromATerm ""),-      -- Reject malformed-      runTest "fromATerm rejects malformed" $-        assertLeft "malformed" (fromATerm "NotADerivation"),-      -- Reject truncated-      runTest "fromATerm rejects truncated" $-        assertLeft "truncated" (fromATerm "Derive(["),-      -- Escaping round-trip: strings with special chars-      runTest "fromATerm escaping round-trip" $-        let escapeDrv =-              Derivation-                { drvOutputs =-                    [ DerivationOutput-                        { doName = "out",-                          doPath = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "esc-test",-                          doHashAlgo = "",-                          doHash = ""-                        }-                    ],-                  drvInputDrvs = Map.empty,-                  drvInputSrcs = [],-                  drvPlatform = X86_64_Linux,-                  drvBuilder = "/bin/bash",-                  drvArgs = ["-c", "echo \"hello\nworld\""],-                  drvEnv = Map.fromList [("msg", "line1\nline2\ttab\\slash")]-                }-         in assertEqual "escape round-trip" (Right escapeDrv) (fromATerm (toATerm escapeDrv)),-      -- writeDrv writes correct ATerm-      runTestM "writeDrv writes correct ATerm" $ do-        tmpBase <- getTemporaryDirectory-        let tmpStore = tmpBase </> "nova-nix-test-writeDrv"-        removeIfExists tmpStore-        store <- openStore (StoreDir tmpStore)-        let sp = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "test.drv"-            destFile = storePathToFilePath (stDir store) sp-        writeDrv store simpleTestDrv sp-        contents <- TIO.readFile destFile-        closeStore store-        removeIfExists tmpStore-        pure (assertEqual "writeDrv content" (toATerm simpleTestDrv) contents),-      -- builtinDerivation populates drvOutputs-      runTest "builtinDerivation populates drvOutputs"-        $ assertRight-          "drvOutputs"-          (evalNix "let d = derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; in d._derivation")-        $ \val -> case val of-          VDerivation drv ->-            case drvOutputs drv of-              [] -> Fail "drvOutputs is empty"-              (firstOut : _) ->-                if doName firstOut == "out"-                  then Pass-                  else Fail ("first output name: " <> doName firstOut)-          _ -> Fail ("expected VDerivation, got " <> T.pack (show val)),-      -- builtinDerivation multi-output populates drvOutputs-      runTest "builtinDerivation multi-output"-        $ assertRight-          "multi-output"-          (evalNix "let d = derivation { name = \"multi\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; outputs = [\"out\" \"dev\"]; }; in d._derivation")-        $ \val -> case val of-          VDerivation drv ->-            let names = map doName (drvOutputs drv)-             in if names == ["out", "dev"]-                  then Pass-                  else Fail ("output names: " <> T.pack (show names))-          _ -> Fail ("expected VDerivation, got " <> T.pack (show val)),-      -- builtinDerivation populates drvEnv with the output paths ($out, ...)-      -- and the build attributes.  Note: the .drv env does NOT contain a-      -- "drvPath" key - matching C++ Nix, which never writes one.-      runTest "builtinDerivation populates drvEnv"-        $ assertRight-          "drvEnv"-          (evalNix "let d = derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; in d._derivation")-        $ \val -> case val of-          VDerivation drv-            | Just op <- Map.lookup "out" (drvEnv drv),-              "/nix/store/" `T.isPrefixOf` op,-              Just nm <- Map.lookup "name" (drvEnv drv),-              nm == "test" ->-                Pass-            | otherwise ->-                Fail ("drvEnv keys: " <> T.pack (show (Map.toList (drvEnv drv))))-          _ -> Fail ("expected VDerivation, got " <> T.pack (show val))-    ]---- ------------------------------------------------------------------------------ Tests: Builder (Phase 2, Batch 4)--- ------------------------------------------------------------------------------- | Create a minimal Derivation for builder tests.--- The shell path is discovered once via 'findTestShell' and threaded through.--- All scripts are POSIX shell - bash is used on every platform.-mkTestBuildDrv :: Text -> StorePath -> Text -> Derivation-mkTestBuildDrv shell outSP script =-  Derivation-    { drvOutputs =-        [ DerivationOutput-            { doName = "out",-              doPath = outSP,-              doHashAlgo = "",-              doHash = ""-            }-        ],-      drvInputDrvs = Map.empty,-      drvInputSrcs = [],-      drvPlatform = currentPlatform,-      drvBuilder = shell,-      drvArgs = ["-c", script],-      drvEnv = Map.fromList [("name", "test-build"), ("system", platformToText currentPlatform)]-    }--testBuilder :: IO [Bool]-testBuilder = do-  putStrLn "builder"-  shell <- findTestShell-  sequence-    [ -- Build simple script that writes to $out-      runTestM "build simple script" $ do-        tmpBase <- getTemporaryDirectory-        let tmpStore = tmpBase </> "nova-nix-test-builder1"-        forceRemoveIfExists tmpStore-        store <- openStore (StoreDir tmpStore)-        let outSP = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1" "simple-test"-            drv = mkTestBuildDrv shell outSP "mkdir -p $out && echo hello > $out/result.txt"-            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder1-tmp"}-        result <- buildDerivation config store drv-        closeStore store-        let ret = case result of-              BuildSuccess sp -> assertEqual "success path" outSP sp-              BuildFailure msg code -> Fail ("build failed (" <> T.pack (show code) <> "): " <> msg)-        forceRemoveIfExists tmpStore-        forceRemoveIfExists (bcTmpDir config)-        pure ret,-      -- Build with specific file content-      runTestM "build writes file content" $ do-        tmpBase <- getTemporaryDirectory-        let tmpStore = tmpBase </> "nova-nix-test-builder2"-        forceRemoveIfExists tmpStore-        store <- openStore (StoreDir tmpStore)-        let outSP = StorePath "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" "content-test"-            drv = mkTestBuildDrv shell outSP "mkdir -p $out && echo 'test content 42' > $out/data.txt"-            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder2-tmp"}-        result <- buildDerivation config store drv-        ret <- case result of-          BuildSuccess _ -> do-            let dataFile = storePathToFilePath (stDir store) outSP </> "data.txt"-            content <- TIO.readFile dataFile-            pure $-              if T.strip content == "test content 42"-                then Pass-                else Fail ("unexpected content: " <> content)-          BuildFailure msg code -> pure (Fail ("build failed (" <> T.pack (show code) <> "): " <> msg))-        closeStore store-        forceRemoveIfExists tmpStore-        forceRemoveIfExists (bcTmpDir config)-        pure ret,-      -- Missing builder fails-      runTestM "missing builder fails" $ do-        tmpBase <- getTemporaryDirectory-        let tmpStore = tmpBase </> "nova-nix-test-builder3"-        forceRemoveIfExists tmpStore-        store <- openStore (StoreDir tmpStore)-        let outSP = StorePath "cccccccccccccccccccccccccccccccc" "fail-test"-            drv =-              Derivation-                { drvOutputs = [DerivationOutput "out" outSP "" ""],-                  drvInputDrvs = Map.empty,-                  drvInputSrcs = [],-                  drvPlatform = currentPlatform,-                  drvBuilder = "/nonexistent/builder",-                  drvArgs = [],-                  drvEnv = Map.empty-                }-            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder3-tmp"}-        result <- buildDerivation config store drv-        closeStore store-        forceRemoveIfExists tmpStore-        forceRemoveIfExists (bcTmpDir config)-        pure $ case result of-          BuildFailure _ _ -> Pass-          BuildSuccess _ -> Fail "expected failure for missing builder",-      -- Exit failure returns error code-      runTestM "exit failure returns error code" $ do-        tmpBase <- getTemporaryDirectory-        let tmpStore = tmpBase </> "nova-nix-test-builder4"-        forceRemoveIfExists tmpStore-        store <- openStore (StoreDir tmpStore)-        let outSP = StorePath "dddddddddddddddddddddddddddddddd" "exitfail"-            drv = mkTestBuildDrv shell outSP "exit 42"-            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder4-tmp"}-        result <- buildDerivation config store drv-        closeStore store-        forceRemoveIfExists tmpStore-        forceRemoveIfExists (bcTmpDir config)-        pure $ case result of-          BuildFailure _ code -> if code == 42 then Pass else Fail ("expected code 42, got " <> T.pack (show code))-          BuildSuccess _ -> Fail "expected failure",-      -- Output at expected path-      runTestM "output at expected store path" $ do-        tmpBase <- getTemporaryDirectory-        let tmpStore = tmpBase </> "nova-nix-test-builder5"-        forceRemoveIfExists tmpStore-        store <- openStore (StoreDir tmpStore)-        let outSP = StorePath "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" "pathtest"-            drv = mkTestBuildDrv shell outSP "mkdir -p $out && touch $out/marker"-            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder5-tmp"}-        result <- buildDerivation config store drv-        ret <- case result of-          BuildSuccess _ -> do-            let expectedDir = storePathToFilePath (stDir store) outSP-            exists <- doesDirectoryExist expectedDir-            pure $ if exists then Pass else Fail "output dir doesn't exist at expected path"-          BuildFailure msg code -> pure (Fail ("build failed (" <> T.pack (show code) <> "): " <> msg))-        closeStore store-        forceRemoveIfExists tmpStore-        forceRemoveIfExists (bcTmpDir config)-        pure ret,-      -- Path registered in DB after build-      runTestM "path registered in DB" $ do-        tmpBase <- getTemporaryDirectory-        let tmpStore = tmpBase </> "nova-nix-test-builder6"-        forceRemoveIfExists tmpStore-        store <- openStore (StoreDir tmpStore)-        let outSP = StorePath "ffffffffffffffffffffffffffffffff" "dbtest"-            drv = mkTestBuildDrv shell outSP "mkdir -p $out && touch $out/file"-            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder6-tmp"}-        result <- buildDerivation config store drv-        ret <- case result of-          BuildSuccess _ -> do-            valid <- isValid store outSP-            pure $ if valid then Pass else Fail "path not valid in DB after build"-          BuildFailure msg code -> pure (Fail ("build failed (" <> T.pack (show code) <> "): " <> msg))-        closeStore store-        forceRemoveIfExists tmpStore-        forceRemoveIfExists (bcTmpDir config)-        pure ret,-      -- Multiple outputs-      runTestM "multiple outputs" $ do-        tmpBase <- getTemporaryDirectory-        let tmpStore = tmpBase </> "nova-nix-test-builder7"-        forceRemoveIfExists tmpStore-        store <- openStore (StoreDir tmpStore)-        let outSP = StorePath "ggggggggggggggggggggggggggggggg1" "multi"-            devSP = StorePath "ggggggggggggggggggggggggggggggg2" "multi-dev"-            drv =-              Derivation-                { drvOutputs =-                    [ DerivationOutput "out" outSP "" "",-                      DerivationOutput "dev" devSP "" ""-                    ],-                  drvInputDrvs = Map.empty,-                  drvInputSrcs = [],-                  drvPlatform = currentPlatform,-                  drvBuilder = shell,-                  drvArgs = ["-c", "mkdir -p $out && echo lib > $out/lib.txt && mkdir -p $dev && echo headers > $dev/include.h"],-                  drvEnv = Map.fromList [("name", "multi")]-                }-            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder7-tmp"}-        result <- buildDerivation config store drv-        ret <- case result of-          BuildSuccess _ -> do-            outExists <- doesDirectoryExist (storePathToFilePath (stDir store) outSP)-            devExists <- doesDirectoryExist (storePathToFilePath (stDir store) devSP)-            pure $-              if outExists && devExists-                then Pass-                else Fail ("out exists=" <> T.pack (show outExists) <> " dev exists=" <> T.pack (show devExists))-          BuildFailure msg code -> pure (Fail ("build failed (" <> T.pack (show code) <> "): " <> msg))-        closeStore store-        forceRemoveIfExists tmpStore-        forceRemoveIfExists (bcTmpDir config)-        pure ret,-      -- Builder succeeds but doesn't create $out, so the build fails-      runTestM "missing output fails build" $ do-        tmpBase <- getTemporaryDirectory-        let tmpStore = tmpBase </> "nova-nix-test-builder-noout"-        forceRemoveIfExists tmpStore-        store <- openStore (StoreDir tmpStore)-        let outSP = StorePath "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" "nooutput"-            drv = mkTestBuildDrv shell outSP "echo 'forgot to create output'"-            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder-noout-tmp"}-        result <- buildDerivation config store drv-        closeStore store-        forceRemoveIfExists tmpStore-        forceRemoveIfExists (bcTmpDir config)-        pure $ case result of-          BuildFailure msg _ ->-            if T.isInfixOf "outputs missing" msg-              then Pass-              else Fail ("expected 'outputs missing' error, got: " <> msg)-          BuildSuccess _ -> Fail "expected failure when builder doesn't create $out",-      -- File output (not directory) should succeed-      runTestM "file output succeeds" $ do-        tmpBase <- getTemporaryDirectory-        let tmpStore = tmpBase </> "nova-nix-test-builder-fileout"-        forceRemoveIfExists tmpStore-        store <- openStore (StoreDir tmpStore)-        let outSP = StorePath "jjjjjjjjjjjjjjjjjjjjjjjjjjjjjj" "fileout"-            drv = mkTestBuildDrv shell outSP "echo 'I am a file output' > $out"-            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder-fileout-tmp"}-        result <- buildDerivation config store drv-        closeStore store-        forceRemoveIfExists tmpStore-        forceRemoveIfExists (bcTmpDir config)-        pure $ case result of-          BuildSuccess sp -> assertEqual "file output path" outSP sp-          BuildFailure msg code -> Fail ("file output build failed (" <> T.pack (show code) <> "): " <> msg),-      -- Cleanup after failure-      runTestM "cleanup after failure" $ do-        tmpBase <- getTemporaryDirectory-        let tmpStore = tmpBase </> "nova-nix-test-builder8"-        forceRemoveIfExists tmpStore-        store <- openStore (StoreDir tmpStore)-        let outSP = StorePath "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh" "cleantest"-            drv = mkTestBuildDrv shell outSP "exit 1"-            tmpDir = tmpBase </> "nova-nix-test-builder8-tmp"-            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpDir}-        _ <- buildDerivation config store drv-        -- Build dir should be cleaned up-        let buildDir = tmpDir </> T.unpack (spHash outSP)-        buildDirExists <- doesDirectoryExist buildDir-        closeStore store-        forceRemoveIfExists tmpStore-        forceRemoveIfExists tmpDir-        pure $ if not buildDirExists then Pass else Fail "build dir not cleaned up after failure"-    ]---- ------------------------------------------------------------------------------ Tests: CLI Integration (Phase 2, Batch 5)--- ------------------------------------------------------------------------------- | End-to-end: eval .nix source, extract derivation, build, verify output.-evalAndBuild :: StoreDir -> Text -> IO (Either Text (BuildResult, Store))-evalAndBuild storeDir source = do-  case parseNix "<test>" source of-    Left err -> pure (Left ("parse error: " <> T.pack (show err)))-    Right expr -> do-      st <- newEvalState "."-      evalResult <- runEvalIO st $ do-        val <- eval (builtinEnv (esTimestamp st) (esSearchPaths st)) expr-        -- 'derivation' is lazy now; force _derivation so the peek below sees it-        -- (and so a missing required attr surfaces as an eval error).-        case val of-          VAttrs attrs -> maybe (pure ()) (void . force) (attrSetLookup "_derivation" attrs)-          _ -> pure ()-        pure val-      case evalResult of-        Left err -> pure (Left ("eval error: " <> err))-        Right val -> case val of-          VAttrs attrs -> case attrSetLookup "_derivation" attrs >>= readThunkValue of-            Just (VDerivation drv) -> do-              store <- openStore storeDir-              tmpBase <- getTemporaryDirectory-              let config = (defaultBuildConfig storeDir) {bcTmpDir = tmpBase </> "nova-nix-e2e-tmp"}-              result <- buildDerivation config store drv-              pure (Right (result, store))-            _ -> pure (Left "no _derivation in result attrs")-          _ -> pure (Left "result is not an attrset")--testE2E :: IO [Bool]-testE2E = do-  putStrLn "cli/e2e"-  shell <- findTestShell-  sequence-    [ -- End-to-end: eval -> build a simple derivation-      runTestM "e2e eval -> build" $ do-        tmpBase <- getTemporaryDirectory-        let tmpStore = tmpBase </> "nova-nix-test-e2e1"-        forceRemoveIfExists tmpStore-        -- Build the Nix source with the discovered shell path.-        -- Nix strings use \\ for literal backslash, \" for literal quote.-        let nixEscape = T.concatMap (\c -> if c == '\\' then "\\\\" else if c == '"' then "\\\"" else T.singleton c)-            e2eSource =-              T.concat-                [ "derivation { name = \"e2e-test\"; system = builtins.currentSystem; ",-                  "builder = \"" <> nixEscape shell <> "\"; ",-                  "args = [\"-c\" \"mkdir -p $out && echo e2e > $out/e2e.txt\"]; }"-                ]-        result <- evalAndBuild (StoreDir tmpStore) e2eSource-        ret <- case result of-          Left err -> pure (Fail err)-          Right (BuildSuccess _, store) -> do-            closeStore store-            pure Pass-          Right (BuildFailure msg code, store) -> do-            closeStore store-            pure (Fail ("build failed (" <> T.pack (show code) <> "): " <> msg))-        forceRemoveIfExists tmpStore-        forceRemoveIfExists (tmpBase </> "nova-nix-e2e-tmp")-        pure ret,-      -- Parse error produces Left-      runTestM "e2e parse error" $ do-        tmpBase <- getTemporaryDirectory-        let tmpStore = tmpBase </> "nova-nix-test-e2e2"-        forceRemoveIfExists tmpStore-        result <- evalAndBuild (StoreDir tmpStore) "{{ invalid nix"-        forceRemoveIfExists tmpStore-        pure $ case result of-          Left msg ->-            if "parse error" `T.isInfixOf` msg-              then Pass-              else Fail ("expected parse error, got: " <> msg)-          Right _ -> Fail "expected error but got success",-      -- Eval error produces Left-      runTestM "e2e eval error" $ do-        tmpBase <- getTemporaryDirectory-        let tmpStore = tmpBase </> "nova-nix-test-e2e3"-        forceRemoveIfExists tmpStore-        result <- evalAndBuild (StoreDir tmpStore) "derivation { }"-        forceRemoveIfExists tmpStore-        pure $ case result of-          Left msg ->-            if "error" `T.isInfixOf` T.toLower msg-              then Pass-              else Fail ("expected eval error, got: " <> msg)-          Right _ -> Fail "expected error but got success",-      -- E2E with subprocess: nova-nix eval on a .nix file-      runTestM "e2e nova-nix eval subprocess" $ do-        tmpBase <- getTemporaryDirectory-        let tmpDir = tmpBase </> "nova-nix-test-e2e-sub"-            nixFile = tmpDir </> "test.nix"-        forceRemoveIfExists tmpDir-        createDirectoryIfMissing True tmpDir-        writeFile nixFile "1 + 2"-        -- Run nova-nix eval via Process-        (exitCode, stdoutStr, stderrStr) <--          Proc.readCreateProcessWithExitCode-            (Proc.proc "cabal" ["run", "nova-nix", "--", "eval", nixFile])-            ""-        forceRemoveIfExists tmpDir-        pure $ case exitCode of-          ExitSuccess ->-            let nonEmpty = filter (not . T.null) (T.lines (T.pack stdoutStr))-                lastLine = case reverse nonEmpty of-                  (l : _) -> Just l-                  [] -> Nothing-             in if lastLine == Just "3"-                  then Pass-                  else Fail ("expected 3 as last line, got: " <> T.pack stdoutStr)-          ExitFailure code ->-            Fail ("nova-nix eval failed (" <> T.pack (show code) <> "): stderr=" <> T.pack stderrStr)-    ]---- ------------------------------------------------------------------------------ Tests: Phase 4 - search paths, dynamic keys, directory import--- -----------------------------------------------------------------------------testPhase4 :: IO [Bool]-testPhase4 = do-  putStrLn "phase4/search-paths"-  sequence-    [ -- parseNixPath tests-      runTest "parseNixPath empty" $-        assertEqual "empty" [] (parseNixPath ""),-      runTest "parseNixPath single" $-        let result = parseNixPath "nixpkgs=/home/user/nixpkgs"-         in case result of-              [thunk] | Just (VAttrs m) <- readThunkValue thunk ->-                case (attrSetLookup "prefix" m >>= readThunkValue, attrSetLookup "path" m >>= readThunkValue) of-                  (Just (VStr "nixpkgs" _), Just (VStr "/home/user/nixpkgs" _)) -> Pass-                  _ -> Fail "wrong prefix/path"-              _ -> Fail ("expected one entry, got " <> T.pack (show (length result))),-      runTest "parseNixPath multiple" $-        assertEqual "count" 2 (length (parseNixPath "nixpkgs=/nix:custom=/opt")),-      runTest "parseNixPath plain path" $-        let result = parseNixPath "/some/path"-         in case result of-              [thunk] | Just (VAttrs m) <- readThunkValue thunk ->-                case (attrSetLookup "prefix" m >>= readThunkValue, attrSetLookup "path" m >>= readThunkValue) of-                  (Just (VStr "" _), Just (VStr "/some/path" _)) -> Pass-                  _ -> Fail "wrong prefix/path for plain"-              _ -> Fail "expected one entry",-      -- ESearchPath desugars to __findFile __nixPath "name" during resolution-      runTest "parse <nixpkgs>" $-        assertParse "search path" "<nixpkgs>" (EApp (EApp (EVar "__findFile") (EVar "__nixPath")) (EStr [StrLit "nixpkgs"])),-      runTest "parse <nixpkgs/lib>" $-        assertParse "search path with subpath" "<nixpkgs/lib>" (EApp (EApp (EVar "__findFile") (EVar "__nixPath")) (EStr [StrLit "nixpkgs/lib"])),-      -- ESearchPath eval (should fail in pure mode since no search paths)-      runTest "eval <nixpkgs> fails without path" $-        assertEvalFail "search path not found" "<nixpkgs>",-      -- Dynamic attribute keys-      runTest "dynamic key basic" $-        assertEval "dynamic key" "{ ${\"hello\"} = 42; }.hello" (VInt 42),-      runTest "dynamic key from let" $-        assertEval "dynamic key let" "let name = \"x\"; in { ${name} = 1; }.x" (VInt 1),-      runTest "dynamic key in select" $-        assertEval "dynamic key select" "let s = { x = 10; }; in s.${\"x\"}" (VInt 10),-      runTest "dynamic key in hasAttr" $-        assertEval "dynamic key hasAttr" "let s = { x = 10; }; in s ? ${\"x\"}" (VBool True),-      runTest "dynamic key hasAttr missing" $-        assertEval "dynamic key hasAttr missing" "let s = { x = 10; }; in s ? ${\"y\"}" (VBool False),-      -- Dynamic attr inside string interpolation (the ${name} must not-      -- prematurely close the outer interpolation)-      runTest "dynamic key in string interp" $-        assertEval "dynamic key interp" "let s = { x = 10; }; in \"${toString s.${\"x\"}}\"" (VStr "10" mempty)-    ]--testPhase4IO :: IO [Bool]-testPhase4IO = do-  putStrLn "phase4/directory-import"-  tmpDir <- getTemporaryDirectory-  let testDir = tmpDir </> "nova-nix-phase4-test"-      subDir = testDir </> "mypkg"-      defaultNix = subDir </> "default.nix"-  -- Create temp directory structure-  createDirectoryIfMissing True subDir-  TIO.writeFile defaultNix "42"-  results <--    sequence-      [ -- Directory import: import ./dir resolves to ./dir/default.nix-        runTestM "import directory" $ do-          result <- evalNixIO testDir ("import " <> T.pack "./mypkg")-          pure $ case result of-            Right (VInt 42) -> Pass-            Right other -> Fail ("expected VInt 42, got " <> T.pack (show other))-            Left err -> Fail ("eval error: " <> err),-        -- Search path with --nix-path equivalent (populated nixPath)-        runTestM "search path with populated nixPath" $ do-          st <- newEvalState testDir-          let nixPaths = parseNixPath ("mypkg=" <> T.pack subDir)-              env = builtinEnv (esTimestamp st) nixPaths-          result <- runEvalIO st (eval env (EApp (EApp (EVar "__findFile") (EVar "__nixPath")) (EStr [StrLit "mypkg"])))-          pure $ case result of-            Right (VPath _) -> Pass-            Right other -> Fail ("expected VPath, got " <> T.pack (show other))-            Left err -> Fail ("eval error: " <> err)-      ]-  -- Cleanup-  removeDirectoryRecursive testDir-  pure results---- ------------------------------------------------------------------------------ Symbol interning (C FFI)--- -----------------------------------------------------------------------------testSymbol :: IO [Bool]-testSymbol = do-  putStrLn "symbol"-  -- Symbol table is initialized by arenaInit in main bracket.-  sequence-    [ runTestM "intern returns non-zero" $ do-        sym <- symbolIntern "hello"-        pure (if unSymbol sym /= 0 then Pass else Fail "got symbol 0"),-      runTestM "intern same string returns same symbol" $ do-        sym1 <- symbolIntern "name"-        sym2 <- symbolIntern "name"-        pure (assertEqual "same symbol" sym1 sym2),-      runTestM "intern different strings returns different symbols" $ do-        sym1 <- symbolIntern "foo"-        sym2 <- symbolIntern "bar"-        pure (if sym1 /= sym2 then Pass else Fail "symbols should differ"),-      runTestM "symbolText round-trips" $ do-        sym <- symbolIntern "version"-        let txt = symbolText sym-        pure (assertEqual "text" "version" txt),-      runTestM "symbolLen correct" $ do-        sym <- symbolIntern "outputs"-        pure (assertEqual "len" 7 (symbolLen sym)),-      runTestM "empty string interns" $ do-        sym <- symbolIntern ""-        let txt = symbolText sym-        pure (assertEqual "empty" "" txt),-      runTestM "symbolCount tracks unique entries" $ do-        _ <- symbolIntern "alpha"-        _ <- symbolIntern "beta"-        _ <- symbolIntern "alpha"-        count <- symbolCount-        -- count includes all symbols interned in this bracket,-        -- so at least the ones from prior tests plus alpha + beta-        pure (if count >= 2 then Pass else Fail ("count too low: " <> T.pack (show count))),-      runTestM "many symbols (stress)" $ do-        let names = map (\i -> "pkg_" <> T.pack (show (i :: Int))) [1 .. 1000]-        syms <- mapM symbolIntern names-        -- All unique-        let unique = length (Set.fromList (map unSymbol syms))-        pure (assertEqual "1000 unique" 1000 unique)-    ]---- ------------------------------------------------------------------------------ C attribute set (FFI)--- ------------------------------------------------------------------------------- | Cast a StablePtr to CThunkPtr for CAttrSet tests.--- CAttrSet stores void* - we use StablePtrs as opaque values in tests.-spToCPtr :: StablePtr a -> CThunkPtr-spToCPtr = castPtr . castStablePtrToPtr---- | Cast a CThunkPtr back to StablePtr for CAttrSet test verification.-cptrToSp :: CThunkPtr -> StablePtr a-cptrToSp = castPtrToStablePtr . castPtr--testCAttrSet :: IO [Bool]-testCAttrSet = do-  putStrLn "cattrset"-  -- Symbol table is initialized by arenaInit in main bracket.-  sequence-    [ runTestM "new/free" $ do-        _set <- cattrsetNew 16-        -- set freed by arenaDestroy via nn_attrset_free_all-        pure Pass,-      runTestM "insert + freeze + lookup" $ do-        set <- cattrsetNew 4-        kName <- symbolIntern "name"-        kVer <- symbolIntern "version"-        valName <- newStablePtr ("hello" :: Text)-        valVer <- newStablePtr ("1.0" :: Text)-        cattrsetInsert set kName (spToCPtr valName)-        cattrsetInsert set kVer (spToCPtr valVer)-        cattrsetFreeze set-        result <- cattrsetLookup set kName-        case result of-          Nothing -> do-            -- set freed by arenaDestroy via nn_attrset_free_all-            freeStablePtr valName-            freeStablePtr valVer-            pure (Fail "lookup returned Nothing")-          Just cptr -> do-            val <- deRefStablePtr (cptrToSp cptr) :: IO Text-            -- set freed by arenaDestroy via nn_attrset_free_all-            freeStablePtr valName-            freeStablePtr valVer-            pure (assertEqual "lookup name" "hello" val),-      runTestM "lookup missing key returns Nothing" $ do-        set <- cattrsetNew 4-        kFoo <- symbolIntern "foo"-        kBar <- symbolIntern "bar"-        sp <- newStablePtr ("x" :: Text)-        cattrsetInsert set kFoo (spToCPtr sp)-        cattrsetFreeze set-        result <- cattrsetLookup set kBar-        -- set freed by arenaDestroy via nn_attrset_free_all-        freeStablePtr sp-        pure (case result of Nothing -> Pass; Just _ -> Fail "expected Nothing"),-      runTestM "size after freeze" $ do-        set <- cattrsetNew 4-        k1 <- symbolIntern "a"-        k2 <- symbolIntern "b"-        k3 <- symbolIntern "c"-        sp <- newStablePtr (42 :: Int)-        cattrsetInsert set k1 (spToCPtr sp)-        cattrsetInsert set k2 (spToCPtr sp)-        cattrsetInsert set k3 (spToCPtr sp)-        cattrsetFreeze set-        n <- cattrsetSize set-        -- set freed by arenaDestroy via nn_attrset_free_all-        freeStablePtr sp-        pure (assertEqual "size" 3 n),-      runTestM "duplicate keys: last writer wins" $ do-        set <- cattrsetNew 4-        kName <- symbolIntern "name"-        sp1 <- newStablePtr ("first" :: Text)-        sp2 <- newStablePtr ("second" :: Text)-        cattrsetInsert set kName (spToCPtr sp1)-        cattrsetInsert set kName (spToCPtr sp2)-        cattrsetFreeze set-        n <- cattrsetSize set-        result <- cattrsetLookup set kName-        val <- case result of-          Nothing -> pure "MISSING"-          Just cptr -> deRefStablePtr (cptrToSp cptr)-        -- set freed by arenaDestroy via nn_attrset_free_all-        freeStablePtr sp1-        freeStablePtr sp2-        pure-          ( if n == 1 && val == ("second" :: Text)-              then Pass-              else Fail ("size=" <> T.pack (show n) <> " val=" <> val)-          ),-      runTestM "keys returned sorted" $ do-        set <- cattrsetNew 8-        -- Insert in reverse order; after freeze keys should be sorted by symbol ID-        k1 <- symbolIntern "zzz"-        k2 <- symbolIntern "aaa"-        k3 <- symbolIntern "mmm"-        sp <- newStablePtr (0 :: Int)-        cattrsetInsert set k1 (spToCPtr sp)-        cattrsetInsert set k2 (spToCPtr sp)-        cattrsetInsert set k3 (spToCPtr sp)-        cattrsetFreeze set-        keys <- cattrsetKeys set-        -- set freed by arenaDestroy via nn_attrset_free_all-        freeStablePtr sp-        -- Keys should be sorted by symbol ID (ascending)-        let ids = map unSymbol keys-            sorted = ids == foldl (\acc x -> acc ++ [x]) [] (Set.toAscList (Set.fromList ids))-        pure (if sorted then Pass else Fail ("unsorted: " <> T.pack (show ids))),-      runTestM "union right-biased" $ do-        setA <- cattrsetNew 4-        setB <- cattrsetNew 4-        kX <- symbolIntern "x"-        kY <- symbolIntern "y"-        kZ <- symbolIntern "z"-        spA <- newStablePtr ("fromA" :: Text)-        spB <- newStablePtr ("fromB" :: Text)-        spZ <- newStablePtr ("onlyA" :: Text)-        cattrsetInsert setA kX (spToCPtr spA)-        cattrsetInsert setA kZ (spToCPtr spZ)-        cattrsetInsert setB kX (spToCPtr spB)-        cattrsetInsert setB kY (spToCPtr spB)-        cattrsetFreeze setA-        cattrsetFreeze setB-        merged <- cattrsetUnion setA setB-        n <- cattrsetSize merged-        resultX <- cattrsetLookup merged kX-        valX <- case resultX of-          Nothing -> pure "MISSING"-          Just cptr -> deRefStablePtr (cptrToSp cptr)-        -- sets freed by arenaDestroy via nn_attrset_free_all-        freeStablePtr spA-        freeStablePtr spB-        freeStablePtr spZ-        pure-          ( if n == 3 && valX == ("fromB" :: Text)-              then Pass-              else Fail ("size=" <> T.pack (show n) <> " x=" <> valX)-          ),-      runTestM "stress: 10k entries" $ do-        set <- cattrsetNew 1024-        sp <- newStablePtr (0 :: Int)-        syms <- mapM (\i -> symbolIntern ("key_" <> T.pack (show (i :: Int)))) [1 .. 10000]-        mapM_ (\sym -> cattrsetInsert set sym (spToCPtr sp)) syms-        cattrsetFreeze set-        n <- cattrsetSize set-        -- Spot-check a few lookups-        hit <- cattrsetLookup set (syms !! 5000)-        -- set freed by arenaDestroy via nn_attrset_free_all-        freeStablePtr sp-        pure-          ( if n == 10000 && isJust hit-              then Pass-              else Fail ("size=" <> T.pack (show n))-          )-    ]---- ------------------------------------------------------------------------------ C thunk arena (FFI)--- -----------------------------------------------------------------------------testCThunk :: IO [Bool]-testCThunk = do-  putStrLn "cthunk"-  -- Arena is already initialized by main's bracket.-  -- Each test uses the shared arena (thunks accumulate - that's fine).-  sequence-    [ runTestM "new pending + state" $ do-        sp <- newStablePtr ("pending" :: Text)-        ptr <- cthunkNew (castStablePtrToPtr sp)-        state <- cthunkState ptr-        pure (assertEqual "state" 0 state),-      runTestM "new computed + state" $ do-        sp <- newStablePtr ("computed" :: Text)-        ptr <- cthunkNewComputed (castStablePtrToPtr sp)-        state <- cthunkState ptr-        pure (assertEqual "state" 1 state),-      runTestM "payload round-trips (pending)" $ do-        sp <- newStablePtr ("hello" :: Text)-        ptr <- cthunkNew (castStablePtrToPtr sp)-        payload <- cthunkPayload ptr-        val <- deRefStablePtr (castPtrToStablePtr payload) :: IO Text-        pure (assertEqual "payload" "hello" val),-      runTestM "payload round-trips (computed)" $ do-        sp <- newStablePtr (42 :: Int)-        ptr <- cthunkNewComputed (castStablePtrToPtr sp)-        payload <- cthunkPayload ptr-        val <- deRefStablePtr (castPtrToStablePtr payload) :: IO Int-        pure (assertEqual "payload" 42 val),-      runTestM "mark_blackhole succeeds on pending" $ do-        sp <- newStablePtr ("x" :: Text)-        ptr <- cthunkNew (castStablePtrToPtr sp)-        ok <- cthunkMarkBlackhole ptr-        state <- cthunkState ptr-        pure-          ( if ok && state == 2-              then Pass-              else Fail ("ok=" <> T.pack (show ok) <> " state=" <> T.pack (show state))-          ),-      runTestM "mark_blackhole fails on computed" $ do-        sp <- newStablePtr ("x" :: Text)-        ptr <- cthunkNewComputed (castStablePtrToPtr sp)-        ok <- cthunkMarkBlackhole ptr-        pure (if not ok then Pass else Fail "should have failed"),-      runTestM "mark_blackhole fails on blackhole" $ do-        sp <- newStablePtr ("x" :: Text)-        ptr <- cthunkNew (castStablePtrToPtr sp)-        _ <- cthunkMarkBlackhole ptr-        ok <- cthunkMarkBlackhole ptr-        pure (if not ok then Pass else Fail "should have failed"),-      runTestM "set_computed returns old payload" $ do-        pendingSp <- newStablePtr ("old" :: Text)-        ptr <- cthunkNew (castStablePtrToPtr pendingSp)-        _ <- cthunkMarkBlackhole ptr-        computedSp <- newStablePtr ("new" :: Text)-        oldPayload <- cthunkSetComputed ptr (castStablePtrToPtr computedSp)-        oldVal <- deRefStablePtr (castPtrToStablePtr oldPayload) :: IO Text-        state <- cthunkState ptr-        newPayload <- cthunkPayload ptr-        newVal <- deRefStablePtr (castPtrToStablePtr newPayload) :: IO Text-        freeStablePtr pendingSp-        pure-          ( if oldVal == "old" && newVal == "new" && state == 1-              then Pass-              else-                Fail-                  ( "old="-                      <> oldVal-                      <> " new="-                      <> newVal-                      <> " state="-                      <> T.pack (show state)-                  )-          ),-      runTestM "set_computed on pending returns old payload" $ do-        sp <- newStablePtr ("x" :: Text)-        ptr <- cthunkNew (castStablePtrToPtr sp)-        valSp <- newStablePtr ("v" :: Text)-        oldPayload <- cthunkSetComputed ptr (castStablePtrToPtr valSp)-        -- set_computed accepts PENDING (direct memoization, no blackhole step)-        oldVal <- deRefStablePtr (castPtrToStablePtr oldPayload) :: IO Text-        freeStablePtr sp-        pure (assertEqual "old payload" "x" oldVal),-      runTestM "count tracks allocations" $ do-        countBefore <- cthunkCount-        sp <- newStablePtr (0 :: Int)-        _ <- cthunkNew (castStablePtrToPtr sp)-        _ <- cthunkNew (castStablePtrToPtr sp)-        _ <- cthunkNewComputed (castStablePtrToPtr sp)-        countAfter <- cthunkCount-        let delta = countAfter - countBefore-        pure (assertEqual "delta" 3 delta),-      runTestM "get retrieves by index" $ do-        countBefore <- cthunkCount-        sp <- newStablePtr ("indexed" :: Text)-        ptr <- cthunkNew (castStablePtrToPtr sp)-        retrieved <- cthunkGet countBefore-        stateOrig <- cthunkState ptr-        stateRetrieved <- cthunkState retrieved-        pure-          ( if ptr == retrieved && stateOrig == stateRetrieved-              then Pass-              else Fail "get returned wrong pointer"-          ),-      runTestM "stress: 100k thunks" $ do-        countBefore <- cthunkCount-        sp <- newStablePtr (0 :: Int)-        mapM_ (\_ -> cthunkNew (castStablePtrToPtr sp)) [(1 :: Int) .. 100000]-        countAfter <- cthunkCount-        let delta = countAfter - countBefore-        -- Spot-check: retrieve one from the middle-        midPtr <- cthunkGet (countBefore + 50000)-        midState <- cthunkState midPtr-        pure-          ( if delta == 100000 && midState == 0-              then Pass-              else-                Fail-                  ( "delta="-                      <> T.pack (show delta)-                      <> " midState="-                      <> T.pack (show midState)-                  )-          )-    ]---- ------------------------------------------------------------------------------ Bytecode compilation tests--- -----------------------------------------------------------------------------testBytecodeCompile :: IO [Bool]-testBytecodeCompile = do-  putStrLn "bytecode"-  -- Arena (including bytecode store) already initialized by main's bracket.-  sequence-    [ runTestM "compile ELit NixInt" $ do-        idx <- compileExpr (ELit (NixInt 42))-        op <- cbcOpcode idx-        a1 <- cbcArg1 idx-        a2 <- cbcArg2 idx-        pure-          ( if op == OpLitInt && a1 == 42 && a2 == 0-              then Pass-              else Fail ("op=" <> T.pack (show op) <> " a1=" <> T.pack (show a1))-          ),-      runTestM "compile ELit NixInt negative" $ do-        idx <- compileExpr (ELit (NixInt (-1)))-        op <- cbcOpcode idx-        a1 <- cbcArg1 idx-        a2 <- cbcArg2 idx-        -- -1 as uint64 = 0xFFFFFFFFFFFFFFFF, lo=0xFFFFFFFF, hi=0xFFFFFFFF-        pure-          ( if op == OpLitInt && a1 == 0xFFFFFFFF && a2 == 0xFFFFFFFF-              then Pass-              else-                Fail-                  ( "a1="-                      <> T.pack (show a1)-                      <> " a2="-                      <> T.pack (show a2)-                  )-          ),-      runTestM "compile ELit NixBool" $ do-        idxT <- compileExpr (ELit (NixBool True))-        idxF <- compileExpr (ELit (NixBool False))-        opT <- cbcOpcode idxT-        opF <- cbcOpcode idxF-        saT <- cbcShortArg idxT-        saF <- cbcShortArg idxF-        pure-          ( if opT == OpLitBool && saT == 1 && opF == OpLitBool && saF == 0-              then Pass-              else Fail "bool encoding mismatch"-          ),-      runTestM "compile ELit NixNull" $ do-        idx <- compileExpr (ELit NixNull)-        op <- cbcOpcode idx-        pure (assertEqual "opcode" OpLitNull op),-      runTestM "compile EResolvedVar" $ do-        idx <- compileExpr (EResolvedVar 3 7)-        op <- cbcOpcode idx-        a1 <- cbcArg1 idx-        a2 <- cbcArg2 idx-        pure-          ( if op == OpResolvedVar && a1 == 3 && a2 == 7-              then Pass-              else-                Fail-                  ( "op="-                      <> T.pack (show op)-                      <> " a1="-                      <> T.pack (show a1)-                      <> " a2="-                      <> T.pack (show a2)-                  )-          ),-      runTestM "compile EVar" $ do-        idx <- compileExpr (EVar "hello")-        op <- cbcOpcode idx-        sym <- cbcArg1 idx-        let symText = symbolText (Symbol sym)-        pure-          ( if op == OpVar && symText == "hello"-              then Pass-              else Fail ("op=" <> T.pack (show op) <> " sym=" <> symText)-          ),-      runTestM "compile EApp" $ do-        idx <- compileExpr (EApp (EVar "f") (ELit (NixInt 1)))-        op <- cbcOpcode idx-        funcIdx <- cbcArg1 idx-        argIdx <- cbcArg2 idx-        funcOp <- cbcOpcode funcIdx-        argOp <- cbcOpcode argIdx-        pure-          ( if op == OpApp && funcOp == OpVar && argOp == OpLitInt-              then Pass-              else-                Fail-                  ( "op="-                      <> T.pack (show op)-                      <> " funcOp="-                      <> T.pack (show funcOp)-                      <> " argOp="-                      <> T.pack (show argOp)-                  )-          ),-      runTestM "compile EIf" $ do-        idx <--          compileExpr-            (EIf (ELit (NixBool True)) (ELit (NixInt 1)) (ELit (NixInt 2)))-        op <- cbcOpcode idx-        condIdx <- cbcArg1 idx-        thenIdx <- cbcArg2 idx-        elseIdx <- cbcArg3 idx-        condOp <- cbcOpcode condIdx-        thenA1 <- cbcArg1 thenIdx-        elseA1 <- cbcArg1 elseIdx-        pure-          ( if op == OpIf && condOp == OpLitBool && thenA1 == 1 && elseA1 == 2-              then Pass-              else Fail "if structure mismatch"-          ),-      runTestM "compile EBinary" $ do-        idx <--          compileExpr-            (EBinary OpAdd (ELit (NixInt 10)) (ELit (NixInt 20)))-        op <- cbcOpcode idx-        fl <- cbcFlags idx-        leftIdx <- cbcArg1 idx-        rightIdx <- cbcArg2 idx-        leftA1 <- cbcArg1 leftIdx-        rightA1 <- cbcArg1 rightIdx-        pure-          ( if op == OpBinary && fl == binaryAdd && leftA1 == 10 && rightA1 == 20-              then Pass-              else Fail "binary structure mismatch"-          ),-      runTestM "compile EUnary" $ do-        idx <- compileExpr (EUnary OpNegate (ELit (NixInt 5)))-        op <- cbcOpcode idx-        fl <- cbcFlags idx-        operandIdx <- cbcArg1 idx-        operandA1 <- cbcArg1 operandIdx-        pure-          ( if op == OpUnary && fl == unaryNegate && operandA1 == 5-              then Pass-              else Fail "unary structure mismatch"-          ),-      runTestM "compile EList" $ do-        idx <--          compileExpr-            (EList [ELit (NixInt 1), ELit (NixInt 2), ELit (NixInt 3)])-        op <- cbcOpcode idx-        count <- cbcShortArg idx-        dataOff <- cbcArg1 idx-        c0 <- cbcData dataOff-        c1 <- cbcData (dataOff + 1)-        c2 <- cbcData (dataOff + 2)-        c0a1 <- cbcArg1 c0-        c1a1 <- cbcArg1 c1-        c2a1 <- cbcArg1 c2-        pure-          ( if op == OpList && count == 3 && c0a1 == 1 && c1a1 == 2 && c2a1 == 3-              then Pass-              else-                Fail-                  ( "op="-                      <> T.pack (show op)-                      <> " count="-                      <> T.pack (show count)-                  )-          ),-      runTestM "compile EStr with interpolation" $ do-        idx <--          compileExpr-            (EStr [StrLit "hello ", StrInterp (EVar "name"), StrLit "!"])-        op <- cbcOpcode idx-        count <- cbcShortArg idx-        dataOff <- cbcArg1 idx-        -- 3 parts x 2 words each = 6 data words-        tag0 <- cbcData dataOff-        _val0 <- cbcData (dataOff + 1)-        tag1 <- cbcData (dataOff + 2)-        val1 <- cbcData (dataOff + 3)-        tag2 <- cbcData (dataOff + 4)-        -- tag0=0(lit), tag1=1(interp), tag2=0(lit)-        interpOp <- cbcOpcode val1-        pure-          ( if op == OpStr-              && count == 3-              && tag0 == strpartLit-              && tag1 == strpartInterp-              && tag2 == strpartLit-              && interpOp == OpVar-              then Pass-              else-                Fail-                  ( "op="-                      <> T.pack (show op)-                      <> " count="-                      <> T.pack (show count)-                      <> " tag0="-                      <> T.pack (show tag0)-                      <> " tag1="-                      <> T.pack (show tag1)-                  )-          ),-      runTestM "compile EWith" $ do-        idx <- compileExpr (EWith (EVar "lib") (EVar "x"))-        op <- cbcOpcode idx-        scopeIdx <- cbcArg1 idx-        bodyIdx <- cbcArg2 idx-        scopeOp <- cbcOpcode scopeIdx-        bodyOp <- cbcOpcode bodyIdx-        pure-          ( if op == OpWith && scopeOp == OpVar && bodyOp == OpVar-              then Pass-              else Fail "with structure mismatch"-          ),-      runTestM "compile EAssert" $ do-        idx <- compileExpr (EAssert (ELit (NixBool True)) (ELit (NixInt 1)))-        op <- cbcOpcode idx-        condIdx <- cbcArg1 idx-        bodyIdx <- cbcArg2 idx-        condOp <- cbcOpcode condIdx-        bodyOp <- cbcOpcode bodyIdx-        pure-          ( if op == OpAssert && condOp == OpLitBool && bodyOp == OpLitInt-              then Pass-              else Fail "assert structure mismatch"-          ),-      runTestM "compile ELambda (FormalName)" $ do-        idx <--          compileExpr-            (ELambda (FormalName "x") (EResolvedVar 0 0) NoCaptureInfo)-        op <- cbcOpcode idx-        fl <- cbcFlags idx-        bodyIdx <- cbcArg2 idx-        bodyOp <- cbcOpcode bodyIdx-        pure-          ( if op == OpLambda && fl == formalName && bodyOp == OpResolvedVar-              then Pass-              else-                Fail-                  ( "op="-                      <> T.pack (show op)-                      <> " flags="-                      <> T.pack (show fl)-                  )-          ),-      runTestM "compile ELet" $ do-        idx <--          compileExpr-            ( ELet-                [NamedBinding [StaticKey "x"] (ELit (NixInt 42))]-                (EResolvedVar 0 0)-                NoCaptureInfo-            )-        op <- cbcOpcode idx-        count <- cbcShortArg idx-        bodyIdx <- cbcArg2 idx-        bodyOp <- cbcOpcode bodyIdx-        pure-          ( if op == OpLet && count == 1 && bodyOp == OpResolvedVar-              then Pass-              else-                Fail-                  ( "op="-                      <> T.pack (show op)-                      <> " count="-                      <> T.pack (show count)-                  )-          ),-      runTestM "compile EAttrs" $ do-        idx <--          compileExpr-            ( EAttrs-                False-                [NamedBinding [StaticKey "a"] (ELit (NixInt 1))]-                NoCaptureInfo-            )-        op <- cbcOpcode idx-        fl <- cbcFlags idx-        count <- cbcShortArg idx-        pure-          ( if op == OpAttrs && fl == 0 && count == 1-              then Pass-              else Fail "attrs structure mismatch"-          ),-      runTestM "compile EAttrs recursive" $ do-        idx <--          compileExpr-            ( EAttrs-                True-                [ NamedBinding [StaticKey "x"] (ELit (NixInt 1)),-                  NamedBinding [StaticKey "y"] (EResolvedVar 0 0)-                ]-                (Captures [(0, 0)])-            )-        op <- cbcOpcode idx-        fl <- cbcFlags idx-        count <- cbcShortArg idx-        pure-          ( if op == OpAttrs && fl == 1 && count == 2-              then Pass-              else-                Fail-                  ( "flags="-                      <> T.pack (show fl)-                      <> " count="-                      <> T.pack (show count)-                  )-          ),-      runTestM "compile ESelect" $ do-        idx <--          compileExpr-            (ESelect (EVar "x") [StaticKey "a"] Nothing)-        op <- cbcOpcode idx-        fl <- cbcFlags idx-        pure-          ( if op == OpSelect && fl == 0-              then Pass-              else Fail ("flags=" <> T.pack (show fl))-          ),-      runTestM "compile ESelect with default" $ do-        idx <--          compileExpr-            (ESelect (EVar "x") [StaticKey "a"] (Just (ELit NixNull)))-        op <- cbcOpcode idx-        fl <- cbcFlags idx-        defIdx <- cbcArg3 idx-        defOp <- cbcOpcode defIdx-        pure-          ( if op == OpSelect && fl == 1 && defOp == OpLitNull-              then Pass-              else Fail ("flags=" <> T.pack (show fl))-          ),-      runTestM "compile EHasAttr" $ do-        idx <--          compileExpr-            (EHasAttr (EVar "x") [StaticKey "a", StaticKey "b"])-        op <- cbcOpcode idx-        pathLen <- cbcShortArg idx-        pure-          ( if op == OpHasAttr && pathLen == 2-              then Pass-              else-                Fail-                  ( "op="-                      <> T.pack (show op)-                      <> " pathLen="-                      <> T.pack (show pathLen)-                  )-          ),-      -- ESearchPath is desugared by resolver to __findFile __nixPath "name",-      -- so the compiler sees an EApp chain, not a raw ESearchPath.-      runTestM "compile desugared search path" $ do-        idx <- compileExpr (EApp (EApp (EVar "__findFile") (EVar "__nixPath")) (EStr [StrLit "nixpkgs"]))-        op <- cbcOpcode idx-        pure-          ( if op == OpApp-              then Pass-              else Fail ("expected OpApp, got op=" <> T.pack (show op))-          ),-      runTestM "op_count grows after compilation" $ do-        before <- cbcOpCount-        _ <- compileExpr (EApp (EVar "f") (ELit (NixInt 1)))-        after <- cbcOpCount-        pure-          ( if after > before-              then Pass-              else-                Fail-                  ( "before="-                      <> T.pack (show before)-                      <> " after="-                      <> T.pack (show after)-                  )-          ),-      runTestM "compile ELit NixFloat" $ do-        idx <- compileExpr (ELit (NixFloat 3.14))-        op <- cbcOpcode idx-        pure (assertEqual "opcode" OpLitFloat op),-      runTestM "compile ELit NixUri" $ do-        idx <- compileExpr (ELit (NixUri "https://example.com"))-        op <- cbcOpcode idx-        sym <- cbcArg1 idx-        pure-          ( if op == OpLitUri && symbolText (Symbol sym) == "https://example.com"-              then Pass-              else Fail "uri mismatch"-          ),-      runTestM "compile ELit NixPath" $ do-        idx <- compileExpr (ELit (NixPath "/nix/store/foo"))-        op <- cbcOpcode idx-        sym <- cbcArg1 idx-        pure-          ( if op == OpLitPath && symbolText (Symbol sym) == "/nix/store/foo"-              then Pass-              else Fail "path mismatch"-          ),-      runTestM "compile Inherit binding" $ do-        idx <--          compileExpr-            ( EAttrs-                False-                [Inherit Nothing ["x", "y"]]-                NoCaptureInfo-            )-        op <- cbcOpcode idx-        count <- cbcShortArg idx-        pure-          ( if op == OpAttrs && count == 1-              then Pass-              else Fail "inherit binding mismatch"-          ),-      runTestM "compile ELambda (FormalSet)" $ do-        idx <--          compileExpr-            ( ELambda-                (FormalSet [Formal "a" Nothing, Formal "b" (Just (ELit (NixInt 0)))] False)-                (EResolvedVar 0 0)-                NoCaptureInfo-            )-        op <- cbcOpcode idx-        fl <- cbcFlags idx-        pure-          ( if op == OpLambda && fl == formalSet-              then Pass-              else Fail ("flags=" <> T.pack (show fl))-          ),-      runTestM "compile ELambda (FormalNamedSet)" $ do-        idx <--          compileExpr-            ( ELambda-                (FormalNamedSet "args" [Formal "x" Nothing] True)-                (EResolvedVar 0 0)-                NoCaptureInfo-            )-        op <- cbcOpcode idx-        fl <- cbcFlags idx-        pure-          ( if op == OpLambda && fl == formalNamedSet-              then Pass-              else Fail ("flags=" <> T.pack (show fl))-          ),-      runTestM "compile CaptureInfo (Captures)" $ do-        idx <--          compileExpr-            ( ELambda-                (FormalName "x")-                (EResolvedVar 0 0)-                (Captures [(1, 2), (3, 4)])-            )-        op <- cbcOpcode idx-        capOff <- cbcArg3 idx-        capTag <- cbcData capOff-        capCount <- cbcData (capOff + 1)-        capL0 <- cbcData (capOff + 2)-        capI0 <- cbcData (capOff + 3)-        capL1 <- cbcData (capOff + 4)-        capI1 <- cbcData (capOff + 5)-        pure-          ( if op == OpLambda-              && capTag == captureSlots-              && capCount == 2-              && capL0 == 1-              && capI0 == 2-              && capL1 == 3-              && capI1 == 4-              then Pass-              else-                Fail-                  ( "capTag="-                      <> T.pack (show capTag)-                      <> " capCount="-                      <> T.pack (show capCount)-                  )-          ),-      runTestM "compile CaptureInfo (CapturesWithScopes)" $ do-        idx <--          compileExpr-            ( ELambda-                (FormalName "x")-                (EResolvedVar 0 0)-                (CapturesWithScopes [(0, 0)])-            )-        capOff <- cbcArg3 idx-        capTag <- cbcData capOff-        pure (assertEqual "captureTag" captureWithScopes capTag),-      runTestM "compile EWithVar" $ do-        idx <- compileExpr (EWithVar "dynamic")-        op <- cbcOpcode idx-        sym <- cbcArg1 idx-        pure-          ( if op == OpWithVar && symbolText (Symbol sym) == "dynamic"-              then Pass-              else Fail "withvar mismatch"-          ),-      runTestM "compile EIndStr" $ do-        idx <- compileExpr (EIndStr [StrLit "indented"])-        op <- cbcOpcode idx-        count <- cbcShortArg idx-        pure-          ( if op == OpIndStr && count == 1-              then Pass-              else Fail "indstr mismatch"-          )-    ]---- ------------------------------------------------------------------------------ Main--- -----------------------------------------------------------------------------main :: IO ()-main = bracket_ arenaInit arenaDestroy $ do-  hSetBuffering stdout LineBuffering-  putStrLn "nova-nix test suite"-  putStrLn "==================="-  results <--    concat-      <$> sequence-        [ testExprTypes,-          testStorePaths,-          testDerivation,-          testTrivialBuildIO,-          testSourceDateEpochIO,-          testUnpackBuildIO,-          testDependentBuildIO,-          testEvalFidelity,-          testHashHelpers,-          testEvalLiterals,-          testEvalVariables,-          testEvalArithmetic,-          testEvalComparison,-          testEvalLogic,-          testEvalStrings,-          testEvalIfAssert,-          testEvalLet,-          testEvalAttrs,-          testEvalRecAttrs,-          testEvalLists,-          testEvalLambda,-          testEvalWith,-          testEvalBuiltins,-          testEvalErrors,-          testEvalHigherOrder,-          testLexer,-          testParserExprs,-          testParserErrors,-          testParserIntegration,-          testBatch1,-          testBatch2,-          testBatch3,-          testBatch4,-          testBatch5,-          testBatch6,-          testBatch7,-          testImportPure,-          testImportIO,-          testBatchA,-          testBatchAIO,-          testBatchB,-          testBatchC,-          testBatchCIO,-          testBlackhole,-          testBatchD,-          testBatchE,-          testBatchEIO,-          testBatchF,-          testBatchG,-          testBatchH,-          testStringContext,-          testContextHelpers,-          testContextPropagation,-          testDrvContext,-          testDepGraph,-          testSubstituter,-          testPushPure,-          testPushClosureIO,-          testBuildOrchestrator,-          testStoreDB,-          testParseStorePath,-          testStoreOps,-          testFromATerm,-          testBuilder,-          testE2E,-          testPhase4,-          testPhase4IO,-          testSymbol,-          testCAttrSet,-          testCThunk,-          testBytecodeCompile+{-# LANGUAGE ScopedTypeVariables #-}++-- | The nova-nix test suite.  One executable runs every group, prints each+-- failing case, and exits non-zero unless every case passes.+module Main (main) where++import qualified Codec.Archive.Tar as Tar+import qualified Codec.Archive.Tar.Entry as TarEntry+import qualified Codec.Compression.Zstd.Lazy as ZstdL+import Control.Concurrent (forkIO, newEmptyMVar, putMVar, takeMVar, threadDelay)+import Control.Concurrent.Async (cancel, waitCatch, withAsync)+import Control.Exception (ErrorCall (..), SomeAsyncException (..), SomeException, asyncExceptionToException, bracket, bracket_, evaluate, fromException, throwIO, try)+import Control.Monad (filterM, void, when)+import Data.Bits (shiftR, (.&.))+import qualified Data.ByteString as BS+import qualified Data.ByteString.Lazy as BL+import Data.IORef (atomicModifyIORef', newIORef, readIORef)+import Data.List (isPrefixOf, sort)+import Data.List.NonEmpty (NonEmpty (..))+import qualified Data.Map.Strict as Map+import Data.Maybe (catMaybes, fromMaybe, isJust, listToMaybe)+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 qualified Data.Text.IO as TIO+import qualified Database.SQLite.Simple as SQL+import FetchurlFixture (withFetchurlServer)+import Foreign.Ptr (castPtr)+import Foreign.StablePtr (StablePtr, castPtrToStablePtr, castStablePtrToPtr, deRefStablePtr, freeStablePtr, newStablePtr)+import Nix.Builder (BuildConfig (..), BuildResult (..), BuilderSpawn (..), buildDerivation, buildPath, buildWithDeps, defaultBuildConfig, execWrapperConfig, execWrapperFor, fetchUrlsFromEnv, rewriteEnv, rewritePlaceholders, scrubAmbient, tryFetchUrlsWith, unionEnvs, verifyFetchHash)+import Nix.Builder.Unpack (UnpackLimits (..), builtinUnpackBuilder, entryComponents, envSrcs, resolveLinkTarget)+import Nix.Builtins (builtinEnv, parseNixPath, splitNixPath)+import Nix.Config (NixConfig (..))+import qualified Nix.Config as Config+import qualified Nix.DependencyGraph as DepGraph+import Nix.Derivation (Derivation (..), DerivationOutput (..), Platform (..), currentPlatform, fromATerm, platformToText, toATerm, toATermForHash)+import Nix.Eval (FetchCache (..), MonadEval (..), NixValue (..), StringContext (..), StringContextElement (..), Thunk (..), attrSetFromMap, attrSetLookup, attrSetNull, attrSetSize, builtinNames, checkGitRef, checkGitRev, checkGitUrl, decodeFetchCache, emptyContext, emptyEnv, encodeFetchCache, eval, fetchCacheKey, force, mkStr, readThunkValue, runPureEval)+import Nix.Eval.Arena (arenaDestroy, arenaInit)+import Nix.Eval.AttrPath (parseAttrPath)+import Nix.Eval.CAttrSet (cattrsetFreeze, cattrsetInsert, cattrsetKeys, cattrsetLookup, cattrsetNew, cattrsetSize, cattrsetUnion)+import Nix.Eval.CBytecode (binaryAdd, captureSlots, captureWithScopes, cbcArg1, cbcArg2, cbcArg3, cbcData, cbcFlags, cbcOpCount, cbcOpcode, cbcShortArg, formalName, formalNamedSet, formalSet, strpartInterp, strpartLit, unaryNegate, pattern OpApp, pattern OpAssert, pattern OpAttrs, pattern OpBinary, pattern OpHasAttr, pattern OpIf, pattern OpIndStr, pattern OpLambda, pattern OpLet, pattern OpList, pattern OpLitBool, pattern OpLitFloat, pattern OpLitInt, pattern OpLitNull, pattern OpLitPath, pattern OpLitUri, pattern OpResolvedVar, pattern OpSelect, pattern OpStr, pattern OpUnary, pattern OpVar, pattern OpWith, pattern OpWithVar)+import Nix.Eval.CThunk (CThunkPtr, cthunkCount, cthunkGet, cthunkGetBcIdx, cthunkMarkBlackhole, cthunkNewBc, cthunkNewComputed, cthunkPayload, cthunkSetComputed, cthunkState)+import Nix.Eval.CanonPath (canonPath, canonPathValue)+import Nix.Eval.Compile (compileExpr)+import qualified Nix.Eval.Context as Context+import Nix.Eval.IO (EvalState (..), newEvalState, runEvalIO)+import Nix.Eval.Symbol (Symbol (..), symbolCount, symbolIntern, symbolLen, symbolText)+import Nix.Eval.Types (emptyCList)+import Nix.Expr.Resolve (staticGlobalNames)+import Nix.Expr.Types+import Nix.Hash (hashPlaceholder, makeFixedOutputPath, makeTextPath, sha256Digest)+import qualified Nix.Hash as Hash+import Nix.Parser (ParseError (..), parseNix)+import Nix.Parser.Lexer (Located (..), Token (..), tokenize)+import Nix.Push (PushArtifact (..), PushCompression (..), checkRecordedNarHash, computeClosure, loadApiKeyFile, mkNarInfo, mkPushArtifact, narFileName, narHashMatches, parsePushCompression, planMissing, storePathBasename, stripHashPrefix)+import Nix.Store (DeleteOutcome (..), Store (..), acquirePathLock, addToStore, caseHackDiskNames, closeStore, copyPathInto, deleteStorePathRaw, isSafeNarName, isValid, materializeEvalSources, materializeEvalStoreWrites, openStore, orderLinks, pathExists, registrationFor, releasePathLock, resolveDeleteTarget, scanReferences, scanTempReferences, setReadOnly, tryAcquirePathLock, writeDrv, writeDrvClosure)+import Nix.Store.CaseSensitive (trySetCaseSensitiveDir)+import Nix.Store.DB (PathInfo (..), PathRegistration (..), closeStoreDB, dbFileName, isValidPath, metaDirName, openStoreDB, queryAllValidPaths, queryDeriver, queryPathInfo, queryReferences, registerPath, registerPaths)+import qualified Nix.Store.ExecBit as ExecBit+import Nix.Store.Path (StoreDir (..), StorePath, StoreWriteMode (..), defaultStoreDir, defaultStoreDirText, isCanonicalStoreText, parseStorePath, platformStoreDir, storePathToFilePath, storePathToText, storeTextToFilePath, windowsStoreDir)+import Nix.Store.Path.Internal (StorePath (..))+import qualified Nix.Substituter as Subst+import qualified NovaCache.Base64 as B64+import qualified NovaCache.Hash as CHash+import qualified NovaCache.NAR as NAR+import qualified NovaCache.NarInfo as NarInfo+import qualified NovaCache.Signing as Signing+import qualified NovaCache.Zstd as CZstd+import System.Directory (createDirectoryIfMissing, doesDirectoryExist, getPermissions, getTemporaryDirectory, removeDirectoryRecursive, writable)+import qualified System.Directory as Dir+import System.Environment (getEnvironment, lookupEnv, setEnv, unsetEnv)+import System.Exit (ExitCode (..), exitFailure, exitSuccess)+import System.FilePath (takeDirectory, (</>))+import System.IO (BufferMode (..), hSetBuffering, stdout)+import System.IO.Unsafe (unsafePerformIO)+import qualified System.Info as SI+import qualified System.Process as Proc+import System.Timeout (timeout)++-- ---------------------------------------------------------------------------+-- Test harness+-- ---------------------------------------------------------------------------++data TestResult = Pass | Fail !Text++runTest :: Text -> TestResult -> IO Bool+runTest name result = case result of+  Pass -> do+    putStrLn $ "  PASS  " ++ T.unpack name+    pure True+  Fail msg -> do+    putStrLn $ "  FAIL  " ++ T.unpack name ++ ": " ++ T.unpack msg+    pure False++-- | Like 'runTest' but for tests that need IO to produce their result.+-- An exception escaping the action becomes one FAIL line instead of+-- aborting the whole suite (which would skip every later group and its+-- cleanup).+runTestM :: Text -> IO TestResult -> IO Bool+runTestM name action = do+  outcome <- try action+  runTest name $ case outcome of+    Left (e :: SomeException) -> Fail ("uncaught exception: " <> T.pack (show e))+    Right result -> result++assertEqual :: (Eq a, Show a) => Text -> a -> a -> TestResult+assertEqual label expected actual+  | expected == actual = Pass+  | otherwise =+      Fail $+        label+          <> ": expected "+          <> T.pack (show expected)+          <> " but got "+          <> T.pack (show actual)++-- | Render string-value bytes in a failure message (display-only decode).+bytesText :: BS.ByteString -> Text+bytesText = TE.decodeUtf8With lenientDecode++assertRight :: (Show e) => Text -> Either e a -> (a -> TestResult) -> TestResult+assertRight label result check = case result of+  Left err -> Fail (label <> ": got error: " <> T.pack (show err))+  Right val -> check val++assertLeft :: (Show a) => Text -> Either e a -> TestResult+assertLeft _ (Left _) = Pass+assertLeft label (Right val) = Fail (label <> ": expected error but got: " <> T.pack (show val))++-- | What a test expression's relative path literals resolve against.+-- Absolute, because that is the invariant the parser now establishes: no+-- relative path literal survives parsing, so nothing downstream has to know+-- what one would have meant.+testBaseDir :: FilePath+testBaseDir = "/nova-nix-test"++-- | Helper: parse and check result.+assertParse :: Text -> Text -> Expr -> TestResult+assertParse label source expected =+  assertRight label (parseNix testBaseDir "<test>" source) $ \actual ->+    assertEqual label expected actual++-- | Helper: extract just token types from Located list (drop positions and EOF).+tokenTypes :: [Located] -> [Token]+tokenTypes = filter (/= TokEOF) . map locToken++-- | Helper: parse Nix source and evaluate with builtinEnv.  Parse errors+-- are tagged so failure assertions can tell them apart from eval errors.+evalNix :: Text -> Either Text NixValue+evalNix source = case parseNix testBaseDir "<test>" source of+  Left err -> Left (parseErrorTag <> T.pack (show err))+  Right expr -> runPureEval (eval (builtinEnv 0 []) expr)++-- | Prefix marking a parse (not eval) failure in 'evalNix' results.+parseErrorTag :: Text+parseErrorTag = "parse error: "++-- | Assert that a Nix expression evaluates to the expected value.+assertEval :: Text -> Text -> NixValue -> TestResult+assertEval label source expected =+  assertRight label (evalNix source) $ \actual ->+    assertEqual label expected actual++-- | Assert that a Nix expression parses but fails to EVALUATE.  A parse+-- failure fails the assertion: a test documenting a runtime error must+-- not keep passing after a regression stops the construct from parsing.+assertEvalFail :: Text -> Text -> TestResult+assertEvalFail label source = case evalNix source of+  Left err+    | parseErrorTag `T.isPrefixOf` err ->+        Fail (label <> ": expected an eval error but the source did not parse: " <> err)+    | otherwise -> Pass+  Right val -> Fail (label <> ": expected eval failure but got: " <> T.pack (show val))++-- | Assert that a Nix expression fails at PARSE time.+assertParseFail :: Text -> Text -> TestResult+assertParseFail label source = case evalNix source of+  Left err+    | parseErrorTag `T.isPrefixOf` err -> Pass+    | otherwise ->+        Fail (label <> ": expected a parse error but evaluation failed instead: " <> err)+  Right val -> Fail (label <> ": expected parse failure but got: " <> T.pack (show val))++-- ---------------------------------------------------------------------------+-- Shell discovery for builder tests+-- ---------------------------------------------------------------------------++-- | Find a POSIX-compatible shell for builder tests.+-- On Unix, always @\/bin\/sh@.  On Windows, searches for @bash.exe@+-- at known Git for Windows locations first, then PATH.  Checks known+-- paths first to avoid picking up the WSL launcher at+-- @C:\\Windows\\System32\\bash.exe@ which exits 1 when WSL is not+-- configured.  Real Nix builders always use bash from the store -+-- this bridges the gap until nova-nix bootstraps its own bash+-- derivation.+findTestShell :: IO Text+findTestShell = case SI.os of+  "mingw32" -> do+    let known =+          [ "C:\\Program Files\\Git\\bin\\bash.exe",+            "C:\\Program Files (x86)\\Git\\bin\\bash.exe"+          ]+    found <- filterM Dir.doesFileExist known+    case found of+      (p : _) -> pure (T.pack p)+      [] -> do+        inPath <- Dir.findExecutable "bash"+        case inPath of+          Just p -> pure (T.pack p)+          Nothing ->+            error+              "bash not found: install Git for Windows or add bash to PATH"+  _ -> pure "/bin/sh"++-- ---------------------------------------------------------------------------+-- Tests: Expr types (existing)+-- ---------------------------------------------------------------------------++testExprTypes :: IO [Bool]+testExprTypes = do+  putStrLn "expr/types"+  sequence+    [ runTest "int literal" $+        assertEqual "ELit NixInt" (ELit (NixInt 42)) (ELit (NixInt 42)),+      runTest "bool literal" $+        assertEqual "ELit NixBool" (ELit (NixBool True)) (ELit (NixBool True)),+      runTest "null literal" $+        assertEqual "ELit NixNull" (ELit NixNull) (ELit NixNull),+      runTest "var" $+        assertEqual "EVar" (EVar "x") (EVar "x"),+      runTest "string parts" $+        let parts = [StrLit "hello ", StrInterp (EVar "name")]+         in assertEqual "EStr" (EStr parts) (EStr parts),+      runTest "binary op" $+        let expr = EBinary OpAdd (ELit (NixInt 1)) (ELit (NixInt 2))+         in assertEqual "EBinary" expr expr,+      runTest "lambda" $+        let expr = ELambda (FormalName "x") (EVar "x") NoCaptureInfo+         in assertEqual "ELambda" expr expr,+      runTest "let binding" $+        let expr = ELet [NamedBinding [StaticKey "x"] (ELit (NixInt 1))] (EVar "x") NoCaptureInfo+         in assertEqual "ELet" expr expr,+      runTest "attrs" $+        let expr = EAttrs False [NamedBinding [StaticKey "a"] (ELit (NixInt 1))] NoCaptureInfo+         in assertEqual "EAttrs" expr expr,+      runTest "if-then-else" $+        let expr = EIf (ELit (NixBool True)) (ELit (NixInt 1)) (ELit (NixInt 2))+         in assertEqual "EIf" expr expr+    ]++-- ---------------------------------------------------------------------------+-- Tests: Store paths (existing)+-- ---------------------------------------------------------------------------++testStorePaths :: IO [Bool]+testStorePaths = do+  putStrLn "store/path"+  let sp = StorePath {spHash = "s66mzxpvicwk07gjbjfw9izjfa797vsw", spName = "hello-2.12.1"}+  sequence+    [ runTest "default store dir" $+        assertEqual "defaultStoreDir" "/nix/store" (unStoreDir defaultStoreDir),+      runTest "windows store dir" $+        assertEqual "windowsStoreDir" "C:\\nix\\store" (unStoreDir windowsStoreDir),+      runTest "store path to text" $+        assertEqual+          "storePathToText"+          "/nix/store/s66mzxpvicwk07gjbjfw9izjfa797vsw-hello-2.12.1"+          (T.unpack (storePathToText defaultStoreDir sp)),+      runTest "store path ordering" $+        let sp2 = StorePath {spHash = "zzz", spName = "later"}+         in assertEqual "Ord" True (sp < sp2),+      -- The reader-side mapping: canonical store text resolves into the+      -- store dir it is given; everything else is the path as written.+      runTest "store text maps into the platform store dir" $+        assertEqual+          "mapped"+          (unStoreDir platformStoreDir <> "/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-x/sub/f")+          (storeTextToFilePath platformStoreDir "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-x/sub/f"),+      runTest "bare store dir text maps to the platform store root" $+        assertEqual "mapped-root" (unStoreDir platformStoreDir) (storeTextToFilePath platformStoreDir "/nix/store"),+      runTest "store text with a backslash subpath maps" $+        assertEqual+          "mapped-backslash"+          (unStoreDir platformStoreDir <> "\\bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-y")+          (storeTextToFilePath platformStoreDir "/nix/store\\bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-y"),+      runTest "non-store text is the path as written" $+        assertEqual "unmapped" "/etc/hosts" (storeTextToFilePath platformStoreDir "/etc/hosts"),+      runTest "a store-prefix-like name does not map" $+        assertEqual "unmapped-like" "/nix/storefoo" (storeTextToFilePath platformStoreDir "/nix/storefoo"),+      -- The reason this takes a store dir at all: a caller given one must+      -- read from it, not from the platform default.+      runTest "store text maps into a redirected store dir" $+        assertEqual+          "redirected"+          "/tmp/scratch-store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-x"+          (storeTextToFilePath (StoreDir "/tmp/scratch-store") "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-x"),+      -- Both sides must land in the SAME store dir.  Not the same string:+      -- storePathToFilePath joins with the native separator while+      -- storeTextToFilePath passes the canonical text's tail through, so+      -- the two spellings differ on Windows and open the same file.+      runTest "a redirected read lands in the same store as the write" $+        let redirected = StoreDir "/tmp/scratch-store"+            redirectedPath = StorePath {spHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", spName = "x"}+            readSide = storeTextToFilePath redirected (storePathToText defaultStoreDir redirectedPath)+            writeSide = storePathToFilePath redirected redirectedPath+            underRedirected p = unStoreDir redirected `isPrefixOf` p+         in assertEqual+              "both under the redirected store"+              (True, True, False)+              (underRedirected readSide, underRedirected writeSide, unStoreDir platformStoreDir `isPrefixOf` readSide),+      runTest "isCanonicalStoreText accepts both separators and rejects lookalikes" $+        assertEqual+          "predicate"+          [True, True, True, False, False]+          ( map+              isCanonicalStoreText+              ["/nix/store", "/nix/store/x", "/nix/store\\x", "/nix/storefoo", "C:\\nix\\store\\x"]+          )+    ]++-- ---------------------------------------------------------------------------+-- Tests: Derivation (existing)+-- ---------------------------------------------------------------------------++testDerivation :: IO [Bool]+testDerivation = do+  putStrLn "derivation"+  sequence+    [ runTest "current platform is known" $+        case currentPlatform of+          OtherPlatform _ -> Fail "currentPlatform returned OtherPlatform"+          _ -> Pass+    ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - Literals+-- ---------------------------------------------------------------------------++testEvalLiterals :: IO [Bool]+testEvalLiterals = do+  putStrLn "eval/literals"+  sequence+    [ runTest "empty env" $+        assertEqual "emptyEnv" emptyEnv emptyEnv,+      runTest "int" $+        assertEval "int" "42" (VInt 42),+      runTest "float" $+        assertEval "float" "3.14" (VFloat 3.14),+      runTest "bool true" $+        assertEval "true" "true" (VBool True),+      runTest "null" $+        assertEval "null" "null" VNull,+      runTest "string" $+        assertEval "string" "\"hello\"" (mkStr "hello")+    ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - Variables+-- ---------------------------------------------------------------------------++testEvalVariables :: IO [Bool]+testEvalVariables = do+  putStrLn "eval/variables"+  sequence+    [ runTest "let variable" $+        assertEval "let-var" "let x = 1; in x" (VInt 1),+      runTest "undefined variable" $+        assertEvalFail "undef" "x",+      runTest "builtin true" $+        assertEval "builtin-true" "true" (VBool True)+    ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - Arithmetic+-- ---------------------------------------------------------------------------++testEvalArithmetic :: IO [Bool]+testEvalArithmetic = do+  putStrLn "eval/arithmetic"+  sequence+    [ runTest "int add" $+        assertEval "add" "1 + 2" (VInt 3),+      runTest "int sub" $+        assertEval "sub" "10 - 3" (VInt 7),+      runTest "int mul" $+        assertEval "mul" "4 * 5" (VInt 20),+      runTest "int div" $+        assertEval "div" "10 / 3" (VInt 3),+      runTest "float add" $+        assertEval "float-add" "1.5 + 2.5" (VFloat 4.0),+      -- Integer overflow is an eval error (Nix 2.24 semantics), never a+      -- two's-complement wrap; the boundary itself still computes.+      runTest "add overflow fails" $+        assertEvalFail "add-overflow" "9223372036854775807 + 1",+      runTest "sub overflow fails" $+        assertEvalFail "sub-overflow" "(-9223372036854775807) - 2",+      runTest "mul overflow fails" $+        assertEvalFail "mul-overflow" "builtins.mul 9223372036854775807 2",+      runTest "div minBound by -1 overflows" $+        assertEvalFail "div-overflow" "((-9223372036854775807) - 1) / (-1)",+      runTest "negate minBound overflows" $+        assertEvalFail "neg-overflow" "-((-9223372036854775807) - 1)",+      runTest "add at the boundary still works" $+        assertEval "add-boundary" "9223372036854775806 + 1 == 9223372036854775807" (VBool True),+      runTest "int-float promotion" $+        assertEval "promote" "1 + 2.0" (VFloat 3.0),+      runTest "trailing-dot float" $+        assertEval "trailing-dot" "12. == 12.0" (VBool True),+      runTest "trailing-dot float with exponent" $+        assertEval "trailing-dot-exp" "12.e2 == 1200.0" (VBool True),+      runTest "trailing-dot float typeOf" $+        assertEval "trailing-dot-type" "builtins.typeOf 12." (mkStr "float"),+      runTest "negate int" $+        assertEval "negate" "- 5" (VInt (-5)),+      runTest "division by zero" $+        assertEvalFail "div0" "1 / 0",+      runTest "absolute path lexes as path, not division" $+        assertEval "path-abs" "builtins.typeOf /abs/path" (mkStr "path"),+      runTest "bareword relative path lexes as path" $+        assertEval "path-rel" "builtins.typeOf foo/bar" (mkStr "path"),+      runTest "function applied to an absolute path argument" $+        assertEval "app-abs-path" "(p: builtins.typeOf p) /abs/path" (mkStr "path")+    ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - Comparison+-- ---------------------------------------------------------------------------++testEvalComparison :: IO [Bool]+testEvalComparison = do+  putStrLn "eval/comparison"+  sequence+    [ runTest "int eq" $+        assertEval "eq" "1 == 1" (VBool True),+      runTest "int neq" $+        assertEval "neq" "1 != 2" (VBool True),+      runTest "int lt" $+        assertEval "lt" "1 < 2" (VBool True),+      runTest "int gte" $+        assertEval "gte" "3 >= 3" (VBool True),+      runTest "string compare" $+        assertEval "str-lt" "\"abc\" < \"def\"" (VBool True),+      -- <= and >= are negated swapped <, so NaN compares as upstream:+      -- nan <= x and nan >= x are true while nan < x and nan == nan are+      -- false ((1.0e308 * 10) * 0.0 is inf * 0.0, i.e. nan).+      runTest "nan lte is true" $+        assertEval "nan-lte" "let nan = (1.0e308 * 10) * 0.0; in nan <= 1.0" (VBool True),+      runTest "nan gte is true" $+        assertEval "nan-gte" "let nan = (1.0e308 * 10) * 0.0; in nan >= 1.0" (VBool True),+      runTest "nan lt is false" $+        assertEval "nan-lt" "let nan = (1.0e308 * 10) * 0.0; in nan < 1.0" (VBool False),+      runTest "nan self-equality is false" $+        assertEval "nan-eq" "let nan = (1.0e308 * 10) * 0.0; in nan == nan" (VBool False),+      runTest "lte on equal lists" $+        assertEval "list-lte-eq" "[ 1 2 ] <= [ 1 2 ]" (VBool True),+      runTest "lte incomparable types fails" $+        assertEvalFail "lte-err" "1 <= \"a\""+    ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - Logic+-- ---------------------------------------------------------------------------++testEvalLogic :: IO [Bool]+testEvalLogic = do+  putStrLn "eval/logic"+  sequence+    [ runTest "and true" $+        assertEval "and-true" "true && true" (VBool True),+      runTest "and short-circuit" $+        assertEval "and-short" "false && true" (VBool False),+      runTest "or true" $+        assertEval "or-true" "true || false" (VBool True),+      runTest "or short-circuit" $+        assertEval "or-short" "false || true" (VBool True),+      runTest "not" $+        assertEval "not" "!false" (VBool True),+      runTest "implication false->x" $+        assertEval "impl-false" "false -> false" (VBool True),+      runTest "implication true->true" $+        assertEval "impl-true" "true -> true" (VBool True)+    ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - Strings+-- ---------------------------------------------------------------------------++testEvalStrings :: IO [Bool]+testEvalStrings = do+  putStrLn "eval/strings"+  sequence+    [ runTest "string concat" $+        assertEval "concat" "\"hello\" + \" world\"" (mkStr "hello world"),+      runTest "string interpolation" $+        assertEval "interp" "let x = \"world\"; in \"hello ${x}\"" (mkStr "hello world"),+      runTest "interpolation coerce int" $+        assertEval "coerce-int" "\"val=${builtins.toString 42}\"" (mkStr "val=42"),+      runTest "empty string" $+        assertEval "empty" "\"\"" (mkStr "")+    ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - If/Assert+-- ---------------------------------------------------------------------------++testEvalIfAssert :: IO [Bool]+testEvalIfAssert = do+  putStrLn "eval/if-assert"+  sequence+    [ runTest "if true" $+        assertEval "if-true" "if true then 1 else 2" (VInt 1),+      runTest "if false" $+        assertEval "if-false" "if false then 1 else 2" (VInt 2),+      runTest "assert pass" $+        assertEval "assert-pass" "assert true; 42" (VInt 42),+      runTest "assert fail" $+        assertEvalFail "assert-fail" "assert false; 42"+    ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - Let+-- ---------------------------------------------------------------------------++testEvalLet :: IO [Bool]+testEvalLet = do+  putStrLn "eval/let"+  sequence+    [ runTest "simple let" $+        assertEval "let" "let x = 1; in x" (VInt 1),+      runTest "multi let" $+        assertEval "multi" "let x = 1; y = 2; in x + y" (VInt 3),+      runTest "recursive let" $+        assertEval "rec-let" "let x = 1; y = x + 1; in y" (VInt 2)+    ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - Attribute sets+-- ---------------------------------------------------------------------------++testEvalAttrs :: IO [Bool]+testEvalAttrs = do+  putStrLn "eval/attrs"+  sequence+    [ runTest "simple select" $+        assertEval "select" "{ a = 1; }.a" (VInt 1),+      runTest "nested select" $+        assertEval "nested" "{ a = { b = 2; }; }.a.b" (VInt 2),+      runTest "select or default" $+        assertEval "default" "{ a = 1; }.b or 42" (VInt 42),+      runTest "has-attr true" $+        assertEval "has-true" "{ a = 1; } ? a" (VBool True),+      runTest "has-attr false" $+        assertEval "has-false" "{ a = 1; } ? b" (VBool False),+      runTest "nested attr path" $+        assertEval "dot-path" "{ a.b.c = 1; }.a.b.c" (VInt 1)+    ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - Recursive attribute sets+-- ---------------------------------------------------------------------------++testEvalRecAttrs :: IO [Bool]+testEvalRecAttrs = do+  putStrLn "eval/rec-attrs"+  sequence+    [ runTest "rec self-reference" $+        assertEval "rec-self" "rec { a = 1; b = a + 1; }.b" (VInt 2),+      runTest "rec mutual reference" $+        assertEval "rec-mutual" "rec { a = 1; b = a; }.b" (VInt 1),+      -- A plain inherit inside a fallback (nested-path) rec/let must reference+      -- the OUTER binding, not self-reference into infinite recursion.+      runTest "inherit in fallback rec set references outer" $+        assertEval "inherit-fallback-rec" "let n = 7; in (rec { inherit n; a.b = 1; }).n" (VInt 7),+      runTest "inherit in fallback let references outer" $+        assertEval "inherit-fallback-let" "let x = 5; in let a.b = 1; inherit x; in x" (VInt 5),+      -- Guard the fix: a genuine sibling reference in a fallback rec still resolves.+      runTest "sibling reference survives in fallback rec" $+        assertEval "sibling-fallback-rec" "(rec { a = 1; b = a + 10; c.d = 2; }).b" (VInt 11),+      -- Dynamic rec-set keys evaluate in the rec env (C++ Nix env2): a key may+      -- reference an enclosing var or a static sibling, and must not abort.+      runTest "dynamic key references enclosing var" $+        assertEval "dyn-key-enclosing" "let k = \"x\"; in (rec { p.q = 1; ${k} = 2; }).x" (VInt 2),+      runTest "dynamic key resolves the correct enclosing slot" $+        assertEval "dyn-key-slot" "let a = \"A\"; b = \"B\"; in let c = \"C\"; in (rec { p.q = 1; ${b} = 2; }).B" (VInt 2),+      runTest "dynamic key references a static rec sibling" $+        assertEval "dyn-key-sibling" "(rec { a = \"b\"; ${a} = 1; }).b" (VInt 1)+    ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - Lists+-- ---------------------------------------------------------------------------++testEvalLists :: IO [Bool]+testEvalLists = do+  putStrLn "eval/lists"+  sequence+    [ runTest "list head" $+        assertEval "head" "builtins.head [ 1 2 3 ]" (VInt 1),+      runTest "list length" $+        assertEval "length" "builtins.length [ 1 2 3 ]" (VInt 3),+      runTest "list concat" $+        assertEval "concat" "builtins.length ([ 1 ] ++ [ 2 3 ])" (VInt 3)+    ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - Lambda+-- ---------------------------------------------------------------------------++testEvalLambda :: IO [Bool]+testEvalLambda = do+  putStrLn "eval/lambda"+  sequence+    [ runTest "identity" $+        assertEval "id" "(x: x) 42" (VInt 42),+      runTest "closure" $+        assertEval "closure" "let f = x: x + 1; in f 5" (VInt 6),+      runTest "set pattern" $+        assertEval "set-pat" "({ a, b }: a + b) { a = 1; b = 2; }" (VInt 3),+      runTest "default param" $+        assertEval "default" "({ a ? 10 }: a) { }" (VInt 10),+      -- Zero-formal set patterns marshal count 0 / entries NULL; the+      -- second force of the same lambda thunk re-reads the entries and+      -- must not underflow the unsigned count.+      runTest "empty formals forced twice" $+        assertEval "empty-formals" "let f = {}: 1; in f {} + f {}" (VInt 2),+      runTest "ellipsis-only formals forced twice" $+        assertEval "ellipsis-only" "let f = { ... }: 1; in f {} + f {}" (VInt 2),+      runTest "named empty formals forced twice" $+        assertEval "named-empty" "let f = args@{ ... }: 1; in f {} + f {}" (VInt 2)+    ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - With+-- ---------------------------------------------------------------------------++testEvalWith :: IO [Bool]+testEvalWith = do+  putStrLn "eval/with"+  sequence+    [ runTest "with basic" $+        assertEval "with" "with { a = 1; }; a" (VInt 1),+      runTest "with lexical wins" $+        assertEval "lexical" "let a = 1; in with { a = 2; }; a" (VInt 1),+      -- EWithVar: with-scoped variable inside lambda (closure trimming must preserve with-scopes)+      runTest "with inside lambda" $+        assertEval+          "with-lambda"+          "with { a = 1; }; let f = x: a + x; in f 2"+          (VInt 3),+      -- Nested with: inner with wins+      runTest "nested with inner wins" $+        assertEval+          "nested-with"+          "with { a = 1; }; with { a = 2; }; a"+          (VInt 2),+      -- Let shadows with+      runTest "let shadows with" $+        assertEval+          "let-shadows-with"+          "with { a = 1; }; let a = 2; in a"+          (VInt 2),+      -- A fallback (nested-path) let/rec binding shadows with too: the+      -- NameBarrier must not be upgraded to a with-variable.+      runTest "barrier let shadows with" $+        assertEval+          "barrier-shadows-with"+          "let a.b = 1; x = 42; in with { x = 99; }; x"+          (VInt 42),+      runTest "barrier rec binding shadows with" $+        assertEval+          "barrier-rec-shadows-with"+          "with { q = 99; }; (rec { c.d = 1; q = 5; b = q; }).b"+          (VInt 5),+      -- Static globals bind at parse time; with never shadows them.+      runTest "with cannot shadow global map" $+        assertEval+          "with-global-map"+          "builtins.typeOf (with { map = 42; }; map)"+          (mkStr "lambda"),+      -- fetchurl is NOT an upstream global: the with-scope must win, or+      -- nixpkgs-style 'with pkgs; fetchurl' would bind the builtin.+      runTest "with shadows non-global fetchurl" $+        assertEval+          "with-fetchurl"+          "with { fetchurl = 42; }; fetchurl"+          (VInt 42),+      -- Builtin fallback: builtins still accessible inside with+      runTest "with builtin fallback" $+        assertEval+          "with-builtin"+          "with { a = 1; }; true"+          (VBool True),+      -- Builtin attr fallback: builtins.length still works inside with+      runTest "with builtin attr fallback" $+        assertEval+          "with-builtin-attr"+          "with { a = 1; }; builtins.length [1 2 3]"+          (VInt 3),+      -- With in rec attrs+      runTest "with in rec attrs" $+        assertEval+          "with-rec"+          "with { a = 1; }; rec { b = a + 1; c = b + 1; }.c"+          (VInt 3),+      -- AST test: parse "with a; b" produces EWithVar+      runTest "parse with produces EWithVar" $+        assertRight "with-ast" (parseNix testBaseDir "<test>" "with a; b") $ \case+          EWith (EVar "a") (EWithVar "b") -> Pass+          other -> Fail ("expected EWith (EVar a) (EWithVar b), got: " <> T.pack (show other)),+      -- AST test: formal wins over with+      runTest "parse lambda formal wins over with" $+        assertRight "formal-wins" (parseNix testBaseDir "<test>" "x: with a; x") $ \case+          ELambda _ (EWith _ (EResolvedVar 0 0)) _ -> Pass+          other -> Fail ("expected formal to win, got: " <> T.pack (show other)),+      -- Trimming test: lambda inside with gets CapturesWithScopes+      runTest "with lambda trimmed with CapturesWithScopes" $+        assertRight "with-trim" (parseNix testBaseDir "<test>" "with a; x: b + x") $ \case+          EWith _ (ELambda _ _ (CapturesWithScopes _)) -> Pass+          other -> Fail ("expected CapturesWithScopes, got: " <> T.pack (show other))+    ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - Builtins+-- ---------------------------------------------------------------------------++testEvalBuiltins :: IO [Bool]+testEvalBuiltins = do+  putStrLn "eval/builtins"+  sequence+    [ runTest "typeOf int" $+        assertEval "typeOf-int" "builtins.typeOf 42" (mkStr "int"),+      runTest "typeOf string" $+        assertEval "typeOf-str" "builtins.typeOf \"hi\"" (mkStr "string"),+      runTest "isNull true" $+        assertEval "isNull-t" "builtins.isNull null" (VBool True),+      runTest "isNull false" $+        assertEval "isNull-f" "builtins.isNull 1" (VBool False),+      runTest "stringLength" $+        assertEval "strlen" "builtins.stringLength \"hello\"" (VInt 5),+      -- Lexer edge cases pinned to upstream: $${ is literal, ''' escapes to '', \q -> q.+      runTest "$${ does not interpolate (double dollar is literal)" $+        assertEval "dollar-dollar-str" "builtins.stringLength \"a$${b}c\"" (VInt 7),+      runTest "$${ literal in indented string" $+        assertEval "dollar-dollar-ind" "builtins.stringLength ''a$${b}c''" (VInt 7),+      runTest "''' escapes two quotes in indented string" $+        assertEval "triple-quote" "''a'''b'' == \"a''b\"" (VBool True),+      runTest "unknown escape drops backslash (regular string)" $+        assertEval "esc-drop-str" "\"a\\qb\" == \"aqb\"" (VBool True),+      runTest "unknown escape drops backslash (indented string)" $+        assertEval "esc-drop-ind" "''a''\\qb'' == \"aqb\"" (VBool True),+      -- Indented-string escapes are opaque to indentation stripping: an+      -- escaped newline is content, not a line break, so text after it+      -- is not at line start and the column scan does not reset.+      runTest "escaped newline does not strip following text" $+        assertEval "ind-esc-nl-strip" "''  a''\\n  b'' == \"a\\n  b\"" (VBool True),+      runTest "escaped newline does not lower common indent" $+        assertEval "ind-esc-nl-indent" "''\n    x''\\n y\n    z'' == \"x\\n y\\nz\"" (VBool True),+      runTest "toString int" $+        assertEval "toStr" "builtins.toString 42" (mkStr "42")+    ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - Errors+-- ---------------------------------------------------------------------------++testEvalErrors :: IO [Bool]+testEvalErrors = do+  putStrLn "eval/errors"+  sequence+    [ runTest "type error in add (set + list)" $+        assertEvalFail "type-add" "{} + []",+      runTest "call non-function" $+        assertEvalFail "call-non" "42 1",+      runTest "builtins.throw" $+        assertEvalFail "throw" "builtins.throw \"boom\""+    ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - Higher-order builtins+-- ---------------------------------------------------------------------------++testEvalHigherOrder :: IO [Bool]+testEvalHigherOrder = do+  putStrLn "eval/higher-order"+  sequence+    [ -- map+      runTest "map basic" $+        assertEval "map" "builtins.map (x: x + 1) [ 1 2 3 ] == [ 2 3 4 ]" (VBool True),+      runTest "map identity" $+        assertEval "map-id" "builtins.map (x: x) [ 1 2 ] == [ 1 2 ]" (VBool True),+      runTest "map empty" $+        assertEval "map-empty" "builtins.map (x: x) [ ]" (VList emptyCList),+      runTest "map lazy" $+        assertEval "map-lazy" "let xs = builtins.map (x: x * 2) [ 1 (throw \"boom\") 3 ]; in builtins.elemAt xs 0" (VInt 2),+      -- filter+      runTest "filter match" $+        assertEval "filter" "builtins.filter (x: x == 2) [ 1 2 3 ] == [ 2 ]" (VBool True),+      runTest "filter none" $+        assertEval "filter-none" "builtins.filter (x: false) [ 1 2 ] == [ ]" (VBool True),+      -- foldl'+      runTest "foldl' sum" $+        assertEval "foldl-sum" "builtins.foldl' (a: b: a + b) 0 [ 1 2 3 ]" (VInt 6),+      runTest "foldl' string concat" $+        assertEval "foldl-str" "builtins.foldl' (a: b: a + b) \"\" [ \"x\" \"y\" \"z\" ]" (mkStr "xyz"),+      runTest "foldl' empty" $+        assertEval "foldl-empty" "builtins.foldl' (a: b: a + b) 0 [ ]" (VInt 0),+      -- genList+      runTest "genList basic" $+        assertEval "genList" "builtins.genList (i: i * 2) 4 == [ 0 2 4 6 ]" (VBool True),+      runTest "genList zero" $+        assertEval "genList-0" "builtins.genList (i: i) 0" (VList emptyCList),+      runTest "genList lazy" $+        assertEval "genList-lazy" "let xs = builtins.genList (i: if i == 0 then 42 else throw \"boom\") 5; in builtins.elemAt xs 0" (VInt 42),+      -- sort+      runTest "sort ints" $+        assertEval "sort" "builtins.sort (a: b: a < b) [ 3 1 2 ] == [ 1 2 3 ]" (VBool True),+      runTest "sort already sorted" $+        assertEval "sort-sorted" "builtins.sort (a: b: a < b) [ 1 2 3 ] == [ 1 2 3 ]" (VBool True),+      -- Stable: comparator-equal elements keep their input order (std::stable_sort).+      runTest "sort is stable across ties" $+        assertEval+          "sort-stable"+          "builtins.sort (a: b: a.k < b.k) [ { k = 1; v = 1; } { k = 0; v = 2; } { k = 1; v = 3; } { k = 0; v = 4; } ] == [ { k = 0; v = 2; } { k = 0; v = 4; } { k = 1; v = 1; } { k = 1; v = 3; } ]"+          (VBool True),+      runTest "sort preserves order of all-equal elements" $+        assertEval+          "sort-stable-all"+          "builtins.sort (a: b: a.k < b.k) [ { k = 0; v = 1; } { k = 0; v = 2; } { k = 0; v = 3; } ] == [ { k = 0; v = 1; } { k = 0; v = 2; } { k = 0; v = 3; } ]"+          (VBool True),+      -- concatMap+      runTest "concatMap" $+        assertEval "concatMap" "builtins.concatMap (x: [ x (x * 2) ]) [ 1 2 ] == [ 1 2 2 4 ]" (VBool True),+      -- any+      runTest "any true" $+        assertEval "any-t" "builtins.any (x: x == 2) [ 1 2 3 ]" (VBool True),+      runTest "any false" $+        assertEval "any-f" "builtins.any (x: x == 5) [ 1 2 3 ]" (VBool False),+      -- all+      runTest "all true" $+        assertEval "all-t" "builtins.all (x: x > 0) [ 1 2 3 ]" (VBool True),+      runTest "all false" $+        assertEval "all-f" "builtins.all (x: x > 1) [ 1 2 3 ]" (VBool False),+      -- elem+      runTest "elem found" $+        assertEval "elem-t" "builtins.elem 2 [ 1 2 3 ]" (VBool True),+      runTest "elem not found" $+        assertEval "elem-f" "builtins.elem 5 [ 1 2 3 ]" (VBool False),+      -- elemAt+      runTest "elemAt valid" $+        assertEval "elemAt" "builtins.elemAt [ 10 20 30 ] 1" (VInt 20),+      runTest "elemAt out of bounds" $+        assertEvalFail "elemAt-oob" "builtins.elemAt [ 1 2 ] 5",+      -- partition+      runTest "partition right" $+        assertEval "partition-right" "(builtins.partition (x: x > 2) [ 1 2 3 4 ]).right == [ 3 4 ]" (VBool True),+      runTest "partition wrong" $+        assertEval "partition-wrong" "(builtins.partition (x: x > 2) [ 1 2 3 4 ]).wrong == [ 1 2 ]" (VBool True),+      -- groupBy+      runTest "groupBy pos" $+        assertEval "groupBy-pos" "(builtins.groupBy (x: if x > 0 then \"pos\" else \"neg\") [ 1 (- 2) 3 ]).pos == [ 1 3 ]" (VBool True),+      runTest "groupBy neg" $+        assertEval "groupBy-neg" "(builtins.groupBy (x: if x > 0 then \"pos\" else \"neg\") [ 1 (- 2) 3 ]).neg == [ (- 2) ]" (VBool True),+      -- attrNames+      runTest "attrNames sorted" $+        assertEval "attrNames" "builtins.attrNames { b = 2; a = 1; c = 3; } == [ \"a\" \"b\" \"c\" ]" (VBool True),+      -- attrValues+      runTest "attrValues count" $+        assertEval "attrValues" "builtins.length (builtins.attrValues { a = 1; b = 2; })" (VInt 2),+      -- hasAttr+      runTest "hasAttr true" $+        assertEval "hasAttr-t" "builtins.hasAttr \"a\" { a = 1; }" (VBool True),+      runTest "hasAttr false" $+        assertEval "hasAttr-f" "builtins.hasAttr \"z\" { a = 1; }" (VBool False),+      -- getAttr+      runTest "getAttr" $+        assertEval "getAttr" "builtins.getAttr \"a\" { a = 42; }" (VInt 42),+      runTest "getAttr missing" $+        assertEvalFail "getAttr-miss" "builtins.getAttr \"z\" { a = 1; }",+      -- removeAttrs+      runTest "removeAttrs" $+        assertEval "removeAttrs" "builtins.attrNames (builtins.removeAttrs { a = 1; b = 2; c = 3; } [ \"b\" ]) == [ \"a\" \"c\" ]" (VBool True),+      -- intersectAttrs+      runTest "intersectAttrs" $+        assertEval "intersectAttrs" "(builtins.intersectAttrs { a = 1; b = 2; } { b = 20; c = 30; }).b" (VInt 20),+      runTest "intersectAttrs keys" $+        assertEval "intersectAttrs-keys" "builtins.attrNames (builtins.intersectAttrs { a = 1; b = 2; } { b = 20; c = 30; }) == [ \"b\" ]" (VBool True),+      -- catAttrs+      runTest "catAttrs" $+        assertEval "catAttrs" "builtins.catAttrs \"a\" [ { a = 1; } { b = 2; } { a = 3; } ] == [ 1 3 ]" (VBool True),+      -- listToAttrs+      runTest "listToAttrs" $+        assertEval "listToAttrs" "(builtins.listToAttrs [ { name = \"x\"; value = 1; } { name = \"y\"; value = 2; } ]).x" (VInt 1),+      -- substring+      runTest "substring basic" $+        assertEval "substr" "builtins.substring 1 2 \"hello\"" (mkStr "el"),+      runTest "substring clamped" $+        assertEval "substr-clamp" "builtins.substring 3 100 \"hello\"" (mkStr "lo"),+      -- concatStringsSep+      runTest "concatStringsSep" $+        assertEval "concatSep" "builtins.concatStringsSep \", \" [ \"a\" \"b\" \"c\" ]" (mkStr "a, b, c"),+      -- Partial application+      runTest "partial application" $+        assertEval "partial" "let f = builtins.map (x: x + 1); in f [ 1 2 3 ] == [ 2 3 4 ]" (VBool True)+    ]++-- ---------------------------------------------------------------------------+-- Tests: Lexer+-- ---------------------------------------------------------------------------++testLexer :: IO [Bool]+testLexer = do+  putStrLn "parser/lexer"+  sequence+    [ runTest "integer" $+        assertRight "lex int" (tokenize "<test>" "42") $ \toks ->+          assertEqual "tokens" [TokInt 42] (tokenTypes toks),+      runTest "float" $+        assertRight "lex float" (tokenize "<test>" "3.14") $ \toks ->+          assertEqual "tokens" [TokFloat 3.14] (tokenTypes toks),+      runTest "trailing-dot float lexes as one float token" $+        assertRight "lex trailing-dot" (tokenize "<test>" "12.") $ \toks ->+          assertEqual "tokens" [TokFloat 12.0] (tokenTypes toks),+      -- Maximal munch: a dot-led run with a /-segment is one PATH token+      -- (upstream's PATH regex), while a slashless dot-run is not.+      runTest "dot-relative path lexes as one token" $+        assertRight "lex dot-path" (tokenize "<test>" ".github/x") $ \toks ->+          assertEqual "tokens" [TokPath ".github/x"] (tokenTypes toks),+      runTest "leading-dot float still lexes as float" $+        assertRight "lex dot-float" (tokenize "<test>" ".5") $ \toks ->+          assertEqual "tokens" [TokFloat 0.5] (tokenTypes toks),+      runTest "trailing-dot float with exponent lexes as one float token" $+        assertRight "lex trailing-dot-exp" (tokenize "<test>" "12.e2") $ \toks ->+          assertEqual "tokens" [TokFloat 1200.0] (tokenTypes toks),+      -- Float literals convert with a single rounding (strtod semantics).+      -- The exact value here sits between representable ..992 and ..994,+      -- nearer ..994; rounding whole and fraction separately lands on ..992.+      runTest "float literal rounds once at the 2^53 boundary" $+        assertRight "lex float-tie" (tokenize "<test>" "9007199254740993.5") $ \toks ->+          assertEqual "tokens" [TokFloat 9007199254740994.0] (tokenTypes toks),+      -- A float 10 ^^ exponent overflows on subnormals and flushes to 0.+      runTest "subnormal float literal survives" $+        assertRight "lex float-subnormal" (tokenize "<test>" "1.0e-320") $ \toks ->+          assertEqual "tokens" [TokFloat 1.0e-320] (tokenTypes toks),+      -- Bounded number lexing: a literal's cost must follow its length,+      -- never its exponent's magnitude, and megadigit literals must+      -- resolve promptly.  The watchdog turns a cost regression into a+      -- FAIL instead of a stuck suite; the checks force full values.+      runTestM "huge positive exponent saturates to Infinity" $ do+        let saturated = case tokenize "<test>" "1.0e999999999" of+              Right toks | [TokFloat f] <- tokenTypes toks -> isInfinite f && f > 0+              _ -> False+        outcome <- timeout walkWatchdogMicros (evaluate saturated)+        pure $ case outcome of+          Just True -> Pass+          Just False -> Fail "wrong tokens for a huge positive exponent"+          Nothing -> Fail "lexing did not return promptly",+      runTestM "huge negative exponent saturates to zero" $ do+        let flushed = case tokenize "<test>" "1.0e-999999999" of+              Right toks -> tokenTypes toks == [TokFloat 0.0]+              Left _ -> False+        outcome <- timeout walkWatchdogMicros (evaluate flushed)+        pure $ case outcome of+          Just True -> Pass+          Just False -> Fail "wrong tokens for a huge negative exponent"+          Nothing -> Fail "lexing did not return promptly",+      runTestM "zero mantissa ignores a huge exponent" $ do+        let zeroed = case tokenize "<test>" "0.0e999999999" of+              Right toks -> tokenTypes toks == [TokFloat 0.0]+              Left _ -> False+        outcome <- timeout walkWatchdogMicros (evaluate zeroed)+        pure $ case outcome of+          Just True -> Pass+          Just False -> Fail "wrong tokens for a zero mantissa"+          Nothing -> Fail "lexing did not return promptly",+      runTestM "megadigit integer literal rejects promptly" $ do+        let rejected = case tokenize "<test>" (T.replicate 1000000 "9") of+              Left _ -> True+              Right _ -> False+        outcome <- timeout walkWatchdogMicros (evaluate rejected)+        pure $ case outcome of+          Just True -> Pass+          Just False -> Fail "megadigit literal was not rejected"+          Nothing -> Fail "range rejection did not return promptly",+      runTestM "megadigit mantissa still rounds correctly" $ do+        -- 1.999...9e5 sits within half an ulp of 200000.0; the kept-768+        -- digits plus the sticky digit must round it there.+        let rounded = case tokenize "<test>" ("1." <> T.replicate 1000000 "9" <> "e5") of+              Right toks -> tokenTypes toks == [TokFloat 200000.0]+              Left _ -> False+        outcome <- timeout walkWatchdogMicros (evaluate rounded)+        pure $ case outcome of+          Just True -> Pass+          Just False -> Fail "wrong rounding for a megadigit mantissa"+          Nothing -> Fail "lexing did not return promptly",+      runTest "true" $+        assertRight "lex true" (tokenize "<test>" "true") $ \toks ->+          assertEqual "tokens" [TokTrue] (tokenTypes toks),+      runTest "false" $+        assertRight "lex false" (tokenize "<test>" "false") $ \toks ->+          assertEqual "tokens" [TokFalse] (tokenTypes toks),+      runTest "null" $+        assertRight "lex null" (tokenize "<test>" "null") $ \toks ->+          assertEqual "tokens" [TokNull] (tokenTypes toks),+      runTest "identifier" $+        assertRight "lex ident" (tokenize "<test>" "foo") $ \toks ->+          assertEqual "tokens" [TokIdent "foo"] (tokenTypes toks),+      runTest "hyphened identifier" $+        assertRight "lex hyphened" (tokenize "<test>" "hello-world") $ \toks ->+          assertEqual "tokens" [TokIdent "hello-world"] (tokenTypes toks),+      runTest "path ./foo" $+        assertRight "lex path" (tokenize "<test>" "./foo") $ \toks ->+          assertEqual "tokens" [TokPath "./foo"] (tokenTypes toks),+      runTest "path ~/foo" $+        assertRight "lex path home" (tokenize "<test>" "~/foo") $ \toks ->+          assertEqual "tokens" [TokPath "~/foo"] (tokenTypes toks),+      runTest "search path" $+        assertRight "lex search path" (tokenize "<test>" "<nixpkgs>") $ \toks ->+          assertEqual "tokens" [TokSearchPath "nixpkgs"] (tokenTypes toks),+      runTest "URI" $+        assertRight "lex uri" (tokenize "<test>" "https://example.com") $ \toks ->+          assertEqual "tokens" [TokUri "https://example.com"] (tokenTypes toks),+      -- Upstream URI = [a-zA-Z][a-zA-Z0-9+.-]*:[uri-char]+ with flex+      -- maximal munch: no // required, and the URI beats identifiers,+      -- keywords, and the lambda colon when it matches more characters.+      runTest "scheme-only URI x:y" $+        assertRight "lex uri x:y" (tokenize "<test>" "x:y") $ \toks ->+          assertEqual "tokens" [TokUri "x:y"] (tokenTypes toks),+      runTest "scheme-only URI mailto" $+        assertRight "lex uri mailto" (tokenize "<test>" "mailto:foo@example.com") $ \toks ->+          assertEqual "tokens" [TokUri "mailto:foo@example.com"] (tokenTypes toks),+      runTest "URI scheme allows + . -" $+        assertRight "lex uri scheme" (tokenize "<test>" "a+b.c:d") $ \toks ->+          assertEqual "tokens" [TokUri "a+b.c:d"] (tokenTypes toks),+      runTest "URI beats keyword by maximal munch" $+        assertRight "lex uri keyword" (tokenize "<test>" "then:x") $ \toks ->+          assertEqual "tokens" [TokUri "then:x"] (tokenTypes toks),+      runTest "hash is not a URI char (starts a comment)" $+        assertRight "lex uri hash" (tokenize "<test>" "http://a#frag") $ \toks ->+          assertEqual "tokens" [TokUri "http://a"] (tokenTypes toks),+      runTest "apostrophe and star are URI chars" $+        assertRight "lex uri quote star" (tokenize "<test>" "http://e.com/a'b*c") $ \toks ->+          assertEqual "tokens" [TokUri "http://e.com/a'b*c"] (tokenTypes toks),+      runTest "lambda colon needs the space" $+        assertRight "lex lambda colon" (tokenize "<test>" "x: y") $ \toks ->+          assertEqual "tokens" [TokIdent "x", TokColon, TokIdent "y"] (tokenTypes toks),+      runTest "underscore cannot start a URI scheme" $+        assertRight "lex underscore colon" (tokenize "<test>" "_a:b") $ \toks ->+          assertEqual "tokens" [TokIdent "_a", TokColon, TokIdent "b"] (tokenTypes toks),+      -- Raw CR/CRLF normalizes to LF in double-quoted strings (upstream+      -- unescapeStr); an escaped CR survives; indented strings keep CR+      -- verbatim because upstream's IND_STRING chunks skip unescapeStr.+      runTest "CRLF in double-quoted string normalizes to LF" $+        assertRight "lex crlf string" (tokenize "<test>" "\"a\r\nb\"") $ \toks ->+          assertEqual "tokens" [TokStringOpen, TokStringLit "a\nb", TokStringClose] (tokenTypes toks),+      runTest "lone CR in double-quoted string normalizes to LF" $+        assertRight "lex cr string" (tokenize "<test>" "\"a\rb\"") $ \toks ->+          assertEqual "tokens" [TokStringOpen, TokStringLit "a\nb", TokStringClose] (tokenTypes toks),+      runTest "escaped CR stays a literal CR" $+        assertRight "lex escaped cr" (tokenize "<test>" "\"a\\\rb\"") $ \toks ->+          assertEqual "tokens" [TokStringOpen, TokStringLit "a\rb", TokStringClose] (tokenTypes toks),+      runTest "indented string keeps CRLF verbatim" $+        assertRight "lex ind crlf" (tokenize "<test>" "''a\r\nb''") $ \toks ->+          assertEqual "tokens" [TokIndStringOpen, TokStringLit "a\r\nb", TokIndStringClose] (tokenTypes toks),+      runTest "multi-char operators" $+        assertRight "lex ops" (tokenize "<test>" "++ // -> == != && || <= >=") $ \toks ->+          assertEqual+            "tokens"+            [TokConcat, TokUpdate, TokImpl, TokEq, TokNeq, TokAnd, TokOr, TokLte, TokGte]+            (tokenTypes toks),+      runTest "single-char operators" $+        assertRight "lex single ops" (tokenize "<test>" "+ - * ! ? < >") $ \toks ->+          assertEqual+            "tokens"+            [TokPlus, TokMinus, TokStar, TokNot, TokQuestion, TokLt, TokGt]+            (tokenTypes toks),+      runTest "division vs path" $+        assertRight "lex div" (tokenize "<test>" "6 / 3") $ \toks ->+          assertEqual "tokens" [TokInt 6, TokSlash, TokInt 3] (tokenTypes toks),+      runTest "string tokens" $+        assertRight "lex string" (tokenize "<test>" "\"hello\"") $ \toks ->+          assertEqual+            "tokens"+            [TokStringOpen, TokStringLit "hello", TokStringClose]+            (tokenTypes toks),+      runTest "empty string" $+        assertRight "lex empty string" (tokenize "<test>" "\"\"") $ \toks ->+          assertEqual+            "tokens"+            [TokStringOpen, TokStringClose]+            (tokenTypes toks),+      runTest "string interpolation tokens" $+        assertRight "lex interp" (tokenize "<test>" "\"a${x}b\"") $ \toks ->+          assertEqual+            "tokens"+            [ TokStringOpen,+              TokStringLit "a",+              TokInterpOpen,+              TokIdent "x",+              TokInterpClose,+              TokStringLit "b",+              TokStringClose+            ]+            (tokenTypes toks),+      runTest "line comment" $+        assertRight "lex comment" (tokenize "<test>" "# comment\n42") $ \toks ->+          assertEqual "tokens" [TokInt 42] (tokenTypes toks),+      runTest "block comment" $+        assertRight "lex block comment" (tokenize "<test>" "/* comment */ 42") $ \toks ->+          assertEqual "tokens" [TokInt 42] (tokenTypes toks),+      runTest "ellipsis" $+        assertRight "lex ellipsis" (tokenize "<test>" "...") $ \toks ->+          assertEqual "tokens" [TokEllipsis] (tokenTypes toks),+      runTest "punctuation" $+        assertRight "lex punct" (tokenize "<test>" ". @ : ; = ,") $ \toks ->+          assertEqual+            "tokens"+            [TokDot, TokAt, TokColon, TokSemicolon, TokAssign, TokComma]+            (tokenTypes toks),+      runTest "delimiters" $+        assertRight "lex delimiters" (tokenize "<test>" "( ) { } [ ]") $ \toks ->+          assertEqual+            "tokens"+            [TokLParen, TokRParen, TokLBrace, TokRBrace, TokLBracket, TokRBracket]+            (tokenTypes toks),+      runTest "keywords" $+        assertRight "lex keywords" (tokenize "<test>" "if then else let in with assert rec inherit") $ \toks ->+          assertEqual+            "tokens"+            [TokIf, TokThen, TokElse, TokLet, TokIn, TokWith, TokAssert, TokRec, TokInherit]+            (tokenTypes toks),+      runTest "or is identifier" $+        assertRight "lex or" (tokenize "<test>" "or") $ \toks ->+          assertEqual "tokens" [TokIdent "or"] (tokenTypes toks),+      runTest "string escape sequences" $+        assertRight "lex escapes" (tokenize "<test>" "\"\\n\\t\\\\\\\"\"") $ \toks ->+          assertEqual+            "tokens"+            [TokStringOpen, TokStringLit "\n\t\\\"", TokStringClose]+            (tokenTypes toks)+    ]++-- ---------------------------------------------------------------------------+-- Tests: Parser expressions+-- ---------------------------------------------------------------------------++testParserExprs :: IO [Bool]+testParserExprs = do+  putStrLn "parser/exprs"+  sequence+    [ -- Atoms+      runTest "parse int" $+        assertParse "int" "42" (ELit (NixInt 42)),+      runTest "parse float" $+        assertParse "float" "3.14" (ELit (NixFloat 3.14)),+      runTest "parse true" $+        assertParse "true" "true" (ELit (NixBool True)),+      runTest "parse false" $+        assertParse "false" "false" (ELit (NixBool False)),+      runTest "parse null" $+        assertParse "null" "null" (ELit NixNull),+      runTest "parse var" $+        assertParse "var" "x" (EVar "x"),+      runTest "parse empty string" $+        assertParse "empty string" "\"\"" (EStr []),+      runTest "parse string literal" $+        assertParse "string" "\"hello\"" (EStr [StrLit "hello"]),+      runTest "parse string interpolation" $+        assertParse+          "interp"+          "\"hello ${name}\""+          (EStr [StrLit "hello ", StrInterp (EVar "name")]),+      runTest "parse nested string interpolation" $+        assertParse+          "nested interp"+          "\"${\"inner\"}\""+          (EStr [StrInterp (EStr [StrLit "inner"])]),+      -- Arithmetic+      runTest "parse add" $+        assertParse "add" "1 + 2" (EBinary OpAdd (ELit (NixInt 1)) (ELit (NixInt 2))),+      runTest "parse sub" $+        assertParse "sub" "3 - 1" (EBinary OpSub (ELit (NixInt 3)) (ELit (NixInt 1))),+      runTest "parse mul" $+        assertParse "mul" "2 * 3" (EBinary OpMul (ELit (NixInt 2)) (ELit (NixInt 3))),+      runTest "parse left-assoc add" $+        assertParse+          "left-assoc"+          "1 + 2 + 3"+          (EBinary OpAdd (EBinary OpAdd (ELit (NixInt 1)) (ELit (NixInt 2))) (ELit (NixInt 3))),+      runTest "parse precedence mul over add" $+        assertParse+          "precedence"+          "1 + 2 * 3"+          (EBinary OpAdd (ELit (NixInt 1)) (EBinary OpMul (ELit (NixInt 2)) (ELit (NixInt 3)))),+      -- Unary+      runTest "parse negation" $+        assertParse "negate" "-1" (EUnary OpNegate (ELit (NixInt 1))),+      runTest "parse logical not" $+        assertParse "not" "!true" (EUnary OpNot (ELit (NixBool True))),+      -- Non-associative+      runTest "parse eq" $+        assertParse "eq" "1 == 2" (EBinary OpEq (ELit (NixInt 1)) (ELit (NixInt 2))),+      runTest "parse lt" $+        assertParse "lt" "1 < 2" (EBinary OpLt (ELit (NixInt 1)) (ELit (NixInt 2))),+      -- Right-associative+      runTest "parse implication" $+        assertParse+          "impl"+          "a -> b -> c"+          (EBinary OpImpl (EVar "a") (EBinary OpImpl (EVar "b") (EVar "c"))),+      runTest "parse concat right" $+        assertParse+          "concat"+          "a ++ b ++ c"+          (EBinary OpConcat (EVar "a") (EBinary OpConcat (EVar "b") (EVar "c"))),+      runTest "parse update right" $+        assertParse+          "update"+          "a // b // c"+          (EBinary OpUpdate (EVar "a") (EBinary OpUpdate (EVar "b") (EVar "c"))),+      -- Lambda+      -- After variable resolution, lambda-bound vars become EResolvedVar.+      -- FormalName "x" maps to slot 0; FormalSet [a,b] maps to a=0, b=1;+      -- FormalNamedSet "args" [a] maps to args=0, a=1.+      runTest "parse simple lambda" $+        assertParse "lambda" "x: x" (ELambda (FormalName "x") (EResolvedVar 0 0) NoCaptureInfo),+      runTest "parse set pattern lambda" $+        assertParse+          "set pattern"+          "{ a, b }: a"+          ( ELambda+              (FormalSet [Formal "a" Nothing, Formal "b" Nothing] False)+              (EResolvedVar 0 0)+              NoCaptureInfo+          ),+      runTest "parse set pattern with defaults" $+        assertParse+          "defaults"+          "{ a ? 1 }: a"+          ( ELambda+              (FormalSet [Formal "a" (Just (ELit (NixInt 1)))] False)+              (EResolvedVar 0 0)+              NoCaptureInfo+          ),+      runTest "parse set pattern with ellipsis" $+        assertParse+          "ellipsis"+          "{ a, ... }: a"+          ( ELambda+              (FormalSet [Formal "a" Nothing] True)+              (EResolvedVar 0 0)+              NoCaptureInfo+          ),+      runTest "parse named set pattern (name@{...})" $+        assertParse+          "named set"+          "args@{ a }: a"+          ( ELambda+              (FormalNamedSet "args" [Formal "a" Nothing] False)+              (EResolvedVar 0 1)+              NoCaptureInfo+          ),+      runTest "parse named set pattern ({...}@name)" $+        assertParse+          "set@name"+          "{ a }@args: a"+          ( ELambda+              (FormalNamedSet "args" [Formal "a" Nothing] False)+              (EResolvedVar 0 1)+              NoCaptureInfo+          ),+      -- Application+      runTest "parse application" $+        assertParse "app" "f x" (EApp (EVar "f") (EVar "x")),+      runTest "parse left-assoc application" $+        assertParse "app left" "f x y" (EApp (EApp (EVar "f") (EVar "x")) (EVar "y")),+      runTest "parse application with parens" $+        assertParse+          "app parens"+          "f (1 + 2)"+          (EApp (EVar "f") (EBinary OpAdd (ELit (NixInt 1)) (ELit (NixInt 2)))),+      -- Select+      runTest "parse select" $+        assertParse "select" "a.b" (ESelect (EVar "a") [StaticKey "b"] Nothing),+      runTest "parse nested select" $+        assertParse+          "nested select"+          "a.b.c"+          (ESelect (EVar "a") [StaticKey "b", StaticKey "c"] Nothing),+      runTest "parse select or default" $+        assertParse+          "select or"+          "a.b or 1"+          (ESelect (EVar "a") [StaticKey "b"] (Just (ELit (NixInt 1)))),+      runTest "parse has-attr" $+        assertParse "has-attr" "a ? b" (EHasAttr (EVar "a") [StaticKey "b"]),+      -- Attr sets+      runTest "parse empty attrs" $+        assertParse "empty attrs" "{ }" (EAttrs False [] NoCaptureInfo),+      runTest "parse attrs with binding" $+        assertParse+          "attrs"+          "{ a = 1; }"+          (EAttrs False [NamedBinding [StaticKey "a"] (ELit (NixInt 1))] NoCaptureInfo),+      runTest "parse rec attrs" $+        assertParse+          "rec attrs"+          "rec { a = 1; }"+          (EAttrs True [NamedBinding [StaticKey "a"] (ELit (NixInt 1))] NoCaptureInfo),+      -- inherit x y; is desugared to x = x; y = y; by the resolution pass+      -- (needed because lambda formals are positional, not name-based).+      runTest "parse inherit" $+        assertParse+          "inherit"+          "{ inherit x y; }"+          (EAttrs False [NamedBinding [StaticKey "x"] (EVar "x"), NamedBinding [StaticKey "y"] (EVar "y")] NoCaptureInfo),+      runTest "parse inherit from" $+        assertParse+          "inherit from"+          "{ inherit (a) x; }"+          (EAttrs False [Inherit (Just (EVar "a")) ["x"]] NoCaptureInfo),+      -- Let/if/with/assert+      runTest "parse let" $+        assertParse+          "let"+          "let x = 1; in x"+          (ELet [NamedBinding [StaticKey "x"] (ELit (NixInt 1))] (EResolvedVar 0 0) NoCaptureInfo),+      runTest "parse if-then-else" $+        assertParse+          "if"+          "if true then 1 else 2"+          (EIf (ELit (NixBool True)) (ELit (NixInt 1)) (ELit (NixInt 2))),+      runTest "parse with" $+        assertParse+          "with"+          "with a; b"+          (EWith (EVar "a") (EWithVar "b")),+      runTest "parse assert" $+        assertParse+          "assert"+          "assert true; 1"+          (EAssert (ELit (NixBool True)) (ELit (NixInt 1))),+      -- Lists+      runTest "parse empty list" $+        assertParse "empty list" "[ ]" (EList []),+      runTest "parse list elements" $+        assertParse+          "list"+          "[ 1 2 3 ]"+          (EList [ELit (NixInt 1), ELit (NixInt 2), ELit (NixInt 3)]),+      -- Parens+      runTest "parse parens" $+        assertParse "parens" "(42)" (ELit (NixInt 42)),+      -- 'or' as identifier+      runTest "or as identifier" $+        assertParse "or ident" "or" (EVar "or"),+      -- 'or' as attr key+      runTest "or as attr key" $+        assertParse+          "or attr key"+          "{ or = 1; }"+          (EAttrs False [NamedBinding [StaticKey "or"] (ELit (NixInt 1))] NoCaptureInfo)+    ]++-- ---------------------------------------------------------------------------+-- Tests: Parser errors+-- ---------------------------------------------------------------------------++testParserErrors :: IO [Bool]+testParserErrors = do+  putStrLn "parser/errors"+  sequence+    [ runTest "empty input" $+        assertLeft "empty" (parseNix testBaseDir "<test>" ""),+      runTest "unclosed paren" $+        assertLeft "unclosed paren" (parseNix testBaseDir "<test>" "(1"),+      runTest "unclosed string" $+        assertLeft "unclosed string" (parseNix testBaseDir "<test>" "\"hello"),+      runTest "unclosed brace" $+        assertLeft "unclosed brace" (parseNix testBaseDir "<test>" "{ a = 1;"),+      runTest "missing semicolon" $+        assertLeft "missing semi" (parseNix testBaseDir "<test>" "{ a = 1 }"),+      runTest "unclosed bracket" $+        assertLeft "unclosed bracket" (parseNix testBaseDir "<test>" "[ 1 2"),+      runTest "unexpected token" $+        assertLeft "unexpected" (parseNix testBaseDir "<test>" ")"),+      -- A failure deep in a lambda body is reported where it happened:+      -- a parsed lambda header commits, instead of backtracking into+      -- the attr-set reading and reporting its early stumble (a real+      -- error at line 410 of a nixpkgs file used to surface at line 2).+      runTest "deep formals-lambda body error reports its own line" $+        case parseNix testBaseDir "<test>" "{ a, b }:\n{\n  x = 1;\n  y = (;\n}" of+          Left err -> assertEqual "error line" 4 (peLine err)+          Right _ -> Fail "parsed unexpectedly",+      runTest "simple-lambda body error reports its own line" $+        case parseNix testBaseDir "<test>" "x:\n(;" of+          Left err -> assertEqual "error line" 2 (peLine err)+          Right _ -> Fail "parsed unexpectedly",+      -- The empty attr set is a successful PREFIX parse of "{ }: body",+      -- so without commitment the outer parser complained at the colon.+      runTest "empty-formals lambda body error reports its own line" $+        case parseNix testBaseDir "<test>" "{ }:\n(;" of+          Left err -> assertEqual "error line" 2 (peLine err)+          Right _ -> Fail "parsed unexpectedly",+      -- When neither reading parses, the deeper failure wins: the+      -- malformed formal at column 7, not the attr-set branch's+      -- stumble over the first comma at column 4.+      runTest "a malformed formal beats the attr-set branch's earlier error" $+        case parseNix testBaseDir "<test>" "{ a, b.c }: x" of+          Left err -> assertEqual "deeper than the attr-set stumble" True ((peLine err, peCol err) > (1, 4))+          Right _ -> Fail "parsed unexpectedly",+      -- End-of-input failures carry no position and must rank as the+      -- furthest a branch can get.+      runTest "truncated lambda reports end of input" $+        case parseNix testBaseDir "<test>" "{ a, b }:" of+          Left err -> assertEqual "end of input" True ("end of input" `T.isInfixOf` peMessage err)+          Right _ -> Fail "parsed unexpectedly"+    ]++-- ---------------------------------------------------------------------------+-- Tests: Parser integration+-- ---------------------------------------------------------------------------++testParserIntegration :: IO [Bool]+testParserIntegration = do+  putStrLn "parser/integration"+  sequence+    [ runTest "shell.nix pattern" $+        assertRight "shell.nix" (parseNix testBaseDir "<test>" "{ pkgs ? import <nixpkgs> {} }: pkgs.mkShell { buildInputs = [ pkgs.ghc ]; }") $ \case+          ELambda {} -> Pass+          other -> Fail ("expected ELambda, got: " <> T.pack (show other)),+      runTest "let with multiple bindings" $+        assertParse+          "multi-let"+          "let x = 1; y = 2; in x + y"+          ( ELet+              [ NamedBinding [StaticKey "x"] (ELit (NixInt 1)),+                NamedBinding [StaticKey "y"] (ELit (NixInt 2))+              ]+              (EBinary OpAdd (EResolvedVar 0 0) (EResolvedVar 0 1))+              NoCaptureInfo+          ),+      runTest "nested attr set" $+        -- Normalization hoists a nested attrpath into nested literal sets+        -- (upstream parser.y addAttr), so a.b.c = 1 parses like+        -- a = { b = { c = 1; }; }.+        assertParse+          "nested attrs"+          "{ a.b.c = 1; d = { e = 2; }; }"+          ( EAttrs+              False+              [ NamedBinding+                  [StaticKey "a"]+                  ( EAttrs+                      False+                      [ NamedBinding+                          [StaticKey "b"]+                          (EAttrs False [NamedBinding [StaticKey "c"] (ELit (NixInt 1))] NoCaptureInfo)+                      ]+                      NoCaptureInfo+                  ),+                NamedBinding [StaticKey "d"] (EAttrs False [NamedBinding [StaticKey "e"] (ELit (NixInt 2))] NoCaptureInfo)+              ]+              NoCaptureInfo+          ),+      runTest "indented string" $+        assertRight "ind string" (parseNix testBaseDir "<test>" "''hello''") $ \case+          EIndStr _ -> Pass+          other -> Fail ("expected EIndStr, got: " <> T.pack (show other)),+      -- Positional let/rec resolution tests+      runTest "let inherit from outer lambda" $+        assertRight "let-inherit-lambda" (parseNix testBaseDir "<test>" "x: let inherit x; in x") $ \case+          -- x: let inherit x; in x+          -- The lambda formal x is at level 0, index 0.+          -- The let scope is level 0 (for the let body).+          -- inherit x desugars to x = x where RHS resolves against outer+          -- (the lambda scope), so the let binding's RHS is EResolvedVar 0 0+          -- (one level up from the let to the lambda).+          -- The body x resolves to level 0, index 0 (the let scope).+          ELambda _ (ELet [NamedBinding [StaticKey "x"] _rhsExpr] (EResolvedVar 0 0) _) _ -> Pass+          other -> Fail ("expected ELambda with let-inherit, got: " <> T.pack (show other)),+      runTest "nested lambda in let" $+        assertEval+          "let-nested-lambda"+          "let f = x: x + 1; g = y: f y; in g 5"+          (VInt 6),+      runTest "rec attrs positional resolution" $+        assertEval+          "rec-positional"+          "let s = rec { a = 1; b = a + 1; }; in s.b"+          (VInt 2)+    ]++-- ---------------------------------------------------------------------------+-- Tests: Batch 1 - Trivial pure builtins + constants+-- ---------------------------------------------------------------------------++testBatch1 :: IO [Bool]+testBatch1 = do+  putStrLn "eval/builtins-batch1"+  sequence+    [ -- isPath+      runTest "isPath true" $+        assertEval "isPath-t" "builtins.isPath ./foo" (VBool True),+      runTest "isPath false" $+        assertEval "isPath-f" "builtins.isPath \"foo\"" (VBool False),+      -- ceil+      runTest "ceil float" $+        assertEval "ceil" "builtins.ceil 1.2" (VInt 2),+      runTest "ceil int passthrough" $+        assertEval "ceil-int" "builtins.ceil 5" (VInt 5),+      runTest "ceil negative" $+        assertEval "ceil-neg" "builtins.ceil (- 1.7)" (VInt (-1)),+      runTest "ceil type error" $+        assertEvalFail "ceil-err" "builtins.ceil \"hi\"",+      -- floor+      runTest "floor float" $+        assertEval "floor" "builtins.floor 1.7" (VInt 1),+      runTest "floor int passthrough" $+        assertEval "floor-int" "builtins.floor 5" (VInt 5),+      runTest "floor negative" $+        assertEval "floor-neg" "builtins.floor (- 1.2)" (VInt (-2)),+      -- NaN, infinity, and out-of-range floats are eval errors (Nix 2.24),+      -- not whatever Int64 Haskell's unchecked conversion produces.+      runTest "ceil NaN fails" $+        assertEvalFail "ceil-nan" "builtins.ceil ((1.0e308 * 10) * 0.0)",+      runTest "floor infinity fails" $+        assertEvalFail "floor-inf" "builtins.floor (1.0e308 * 10)",+      runTest "ceil out of integer range fails" $+        assertEvalFail "ceil-range" "builtins.ceil 1.0e300",+      -- seq+      runTest "seq returns second" $+        assertEval "seq" "builtins.seq 1 42" (VInt 42),+      -- trace+      runTest "trace returns second" $+        assertEval "trace" "builtins.trace \"msg\" 42" (VInt 42),+      -- unsafeDiscardStringContext+      runTest "discardContext" $+        assertEval "discard" "builtins.unsafeDiscardStringContext \"hello\"" (mkStr "hello"),+      -- unsafeDiscardOutputDependency+      runTest "discardOutputDep" $+        assertEval "discardOut" "builtins.unsafeDiscardOutputDependency \"hello\"" (mkStr "hello"),+      -- baseNameOf+      runTest "baseNameOf string" $+        assertEval "baseName-str" "builtins.baseNameOf \"/foo/bar/baz\"" (mkStr "baz"),+      runTest "baseNameOf path" $+        assertEval "baseName-path" "builtins.baseNameOf ./foo/bar" (mkStr "bar"),+      runTest "baseNameOf no slash" $+        assertEval "baseName-flat" "builtins.baseNameOf \"filename\"" (mkStr "filename"),+      runTest "baseNameOf type error" $+        assertEvalFail "baseName-err" "builtins.baseNameOf 42",+      -- dirOf+      runTest "dirOf string" $+        assertEval "dirOf-str" "builtins.dirOf \"/foo/bar/baz\"" (mkStr "/foo/bar"),+      runTest "dirOf no slash" $+        assertEval "dirOf-flat" "builtins.dirOf \"filename\"" (mkStr "."),+      runTest "dirOf root-level path" $+        assertEval "dirOf-root" "builtins.dirOf \"/foo\"" (mkStr "/"),+      -- Path VALUES arrive native-spelled when eval's base dir is a+      -- native path (the CLI case); the path-operand splits must be+      -- separator-aware.  The forward-slash tests above use a '/'+      -- base and cannot see this.+      runTestM "baseNameOf on a native-based path value" $ do+        cwd <- Dir.getCurrentDirectory+        result <- evalNixIO cwd "builtins.baseNameOf ./regression-name.nix"+        pure $ case result of+          Right v+            | v == mkStr "regression-name.nix" -> Pass+            | otherwise -> Fail ("wrong basename: " <> T.pack (show v))+          Left err -> Fail ("eval failed: " <> T.pack (show err)),+      -- The path-value spec: absolute, lexically canonical, and+      -- slash-spelled regardless of the base dir's native spelling.+      runTestM "path values are slash-canonical from a native base" $ do+        cwd <- Dir.getCurrentDirectory+        result <- evalNixIO cwd "toString ./spec-name.nix"+        let expected = mkStr (T.replace "\\" "/" (T.pack cwd) <> "/spec-name.nix")+        pure $ case result of+          Right v+            | v == expected -> Pass+            | otherwise -> Fail ("wrong spelling: " <> T.pack (show v))+          Left err -> Fail ("eval failed: " <> T.pack (show err)),+      runTest "canonPathValue folds platform separators only" $+        let folded = canonPathValue "C:\\a\\.\\b"+            expectedByPlatform =+              if SI.os == "mingw32"+                then "C:/a/b" -- '\\' is a separator here and folds+                else "C:\\a\\.\\b" -- '\\' is a file-name character, preserved+         in assertEqual "platform fold" expectedByPlatform folded,+      runTestM "dirOf on a native-based path value" $ do+        cwd <- Dir.getCurrentDirectory+        dirResult <- evalNixIO cwd "builtins.dirOf ./sub/regression-name.nix"+        parentResult <- evalNixIO cwd "./sub"+        pure $+          if dirResult == parentResult+            then Pass+            else+              Fail+                ( "dirOf mismatch: "+                    <> T.pack (show dirResult)+                    <> " vs "+                    <> T.pack (show parentResult)+                ),+      -- appendContext: a key that is not a store path must refuse, as+      -- upstream does - a fabricated identity would flow into+      -- derivation inputs.+      runTest "appendContext rejects a non-store-path key" $+        assertEvalFail+          "appendContext-badkey"+          "builtins.appendContext \"x\" { \"not-a-store-path\" = { path = true; }; }",+      runTest "appendContext accepts a store-path key" $+        assertEval+          "appendContext-ok"+          "if (builtins.appendContext \"x\" { \"/nix/store/00000000000000000000000000000000-y\" = { path = true; }; }) == \"x\" then \"ok\" else \"no\""+          (mkStr "ok"),+      -- storePath: the hash component is charset-validated like every+      -- other store-path parse boundary ('E' is outside nix-base32).+      runTest "storePath rejects a non-base32 hash" $+        assertEvalFail+          "storePath-badhash"+          "builtins.storePath \"/nix/store/EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE-x\"",+      runTest "storePath accepts a valid store path" $+        assertEval+          "storePath-ok"+          "if (builtins.storePath \"/nix/store/00000000000000000000000000000000-x\") == \"/nix/store/00000000000000000000000000000000-x\" then \"ok\" else \"no\""+          (mkStr "ok"),+      runTest "dirOf root" $+        assertEval "dirOf-slash" "builtins.dirOf \"/\"" (mkStr "/"),+      -- concatLists+      runTest "concatLists basic" $+        assertEval "concatLists" "builtins.concatLists [ [ 1 2 ] [ 3 ] [ 4 5 ] ] == [ 1 2 3 4 5 ]" (VBool True),+      runTest "concatLists empty" $+        assertEval "concatLists-empty" "builtins.concatLists [ ]" (VList emptyCList),+      runTest "concatLists type error" $+        assertEvalFail "concatLists-err" "builtins.concatLists [ 1 2 ]",+      -- lessThan+      runTest "lessThan true" $+        assertEval "lt-t" "builtins.lessThan 1 2" (VBool True),+      runTest "lessThan false" $+        assertEval "lt-f" "builtins.lessThan 2 1" (VBool False),+      runTest "lessThan strings" $+        assertEval "lt-str" "builtins.lessThan \"a\" \"b\"" (VBool True),+      -- List < decides at the first UNEQUAL pair, as upstream: a pair+      -- where < holds in neither direction (NaN) decides False rather+      -- than being skipped as equal.+      -- Distinct NaN values: a shared binding would be skipped as equal+      -- via reference identity, which upstream's eqValues also does.+      runTest "list compare decides at the first unequal pair" $+        assertEval "lt-list-nan" "let inf = 1.0e308 * 10; in [ (inf - inf) 1 ] < [ (inf - inf) 2 ]" (VBool False),+      runTest "list compare skips equal prefixes" $+        assertEval "lt-list-prefix" "[ 1 2 ] < [ 1 3 ]" (VBool True),+      -- Constants+      runTest "storeDir" $+        assertEval "storeDir" "builtins.storeDir" (mkStr defaultStoreDirText),+      runTest "nixVersion" $+        assertEval "nixVersion" "builtins.nixVersion" (mkStr "2.24.0"),+      runTest "langVersion" $+        assertEval "langVersion" "builtins.langVersion" (VInt 6),+      runTest "nixPath" $+        assertEval "nixPath" "builtins.nixPath" (VList emptyCList)+    ]++-- ---------------------------------------------------------------------------+-- Tests: Batch 2 - Arithmetic + bitwise builtins+-- ---------------------------------------------------------------------------++testBatch2 :: IO [Bool]+testBatch2 = do+  putStrLn "eval/builtins-batch2"+  sequence+    [ -- add+      runTest "add ints" $+        assertEval "add-int" "builtins.add 3 4" (VInt 7),+      runTest "add int+float" $+        assertEval "add-mixed" "builtins.add 1 2.5" (VFloat 3.5),+      runTest "add type error" $+        assertEvalFail "add-err" "builtins.add \"a\" 1",+      -- sub+      runTest "sub ints" $+        assertEval "sub-int" "builtins.sub 10 3" (VInt 7),+      runTest "sub float" $+        assertEval "sub-float" "builtins.sub 5.5 2.0" (VFloat 3.5),+      -- mul+      runTest "mul ints" $+        assertEval "mul-int" "builtins.mul 3 4" (VInt 12),+      runTest "mul float" $+        assertEval "mul-float" "builtins.mul 2 3.0" (VFloat 6.0),+      -- div+      runTest "div ints" $+        assertEval "div-int" "builtins.div 10 3" (VInt 3),+      runTest "div float" $+        assertEval "div-float" "builtins.div 7.0 2.0" (VFloat 3.5),+      runTest "div by zero" $+        assertEvalFail "div-zero" "builtins.div 1 0",+      -- bitAnd+      runTest "bitAnd" $+        assertEval "bitAnd" "builtins.bitAnd 12 10" (VInt 8),+      runTest "bitAnd type error" $+        assertEvalFail "bitAnd-err" "builtins.bitAnd 1.0 2",+      -- bitOr+      runTest "bitOr" $+        assertEval "bitOr" "builtins.bitOr 12 10" (VInt 14),+      -- bitXor+      runTest "bitXor" $+        assertEval "bitXor" "builtins.bitXor 12 10" (VInt 6)+    ]++-- ---------------------------------------------------------------------------+-- Tests: Batch 3 - Attrset higher-order builtins+-- ---------------------------------------------------------------------------++testBatch3 :: IO [Bool]+testBatch3 = do+  putStrLn "eval/builtins-batch3"+  sequence+    [ -- mapAttrs+      runTest "mapAttrs basic" $+        assertEval "mapAttrs" "(builtins.mapAttrs (name: val: val + 1) { a = 1; b = 2; }).a" (VInt 2),+      runTest "mapAttrs name usage" $+        assertEval "mapAttrs-name" "(builtins.mapAttrs (name: val: name) { a = 1; }).a" (mkStr "a"),+      runTest "mapAttrs type error" $+        assertEvalFail "mapAttrs-err" "builtins.mapAttrs (n: v: v) [ 1 ]",+      runTest "mapAttrs lazy" $+        assertEval "mapAttrs-lazy" "let s = builtins.mapAttrs (k: v: if k == \"a\" then v else throw \"boom\") { a = 1; b = 2; }; in s.a" (VInt 1),+      -- functionArgs+      runTest "functionArgs set pattern" $+        assertEval "funcArgs" "(builtins.functionArgs ({ a, b ? 1 }: a)).b" (VBool True),+      runTest "functionArgs no default" $+        assertEval "funcArgs-nodef" "(builtins.functionArgs ({ a, b ? 1 }: a)).a" (VBool False),+      runTest "functionArgs simple lambda" $+        assertEval "funcArgs-simple" "builtins.functionArgs (x: x)" (VAttrs (attrSetFromMap Map.empty)),+      runTest "functionArgs type error" $+        assertEvalFail "funcArgs-err" "builtins.functionArgs 42",+      -- The builtins set is observable (hasAttr/attrNames), so it must+      -- match upstream's primop set exactly: no mod/min/max/+      -- setFunctionArgs extensions (nixpkgs lib defines those in Nix),+      -- and functionArgs never consults __functionArgs - a functor set+      -- is an error, as upstream throws (lib.functionArgs unwraps+      -- functor sets itself before reaching the builtin).+      runTest "no invented arithmetic builtins" $+        assertEval+          "no-fake-builtins"+          "!(builtins ? mod) && !(builtins ? min) && !(builtins ? max) && !(builtins ? setFunctionArgs)"+          (VBool True),+      runTest "functionArgs rejects functor sets" $+        assertEvalFail+          "funcArgs-functor-err"+          "builtins.functionArgs { __functor = self: x: x; __functionArgs = { a = false; }; }",+      -- zipAttrsWith+      runTest "zipAttrsWith basic" $+        assertEval+          "zipAttrs"+          "(builtins.zipAttrsWith (name: vals: builtins.head vals) [ { a = 1; } { a = 2; b = 3; } ]).b"+          (VInt 3),+      runTest "zipAttrsWith collect" $+        assertEval+          "zipAttrs-collect"+          "builtins.length (builtins.zipAttrsWith (name: vals: vals) [ { a = 1; } { a = 2; } ]).a"+          (VInt 2)+    ]++-- ---------------------------------------------------------------------------+-- Tests: Batch 4 - String operations+-- ---------------------------------------------------------------------------++testBatch4 :: IO [Bool]+testBatch4 = do+  putStrLn "eval/builtins-batch4"+  sequence+    [ -- replaceStrings+      runTest "replaceStrings basic" $+        assertEval "replace" "builtins.replaceStrings [ \"o\" ] [ \"0\" ] \"foobar\"" (mkStr "f00bar"),+      runTest "replaceStrings multi" $+        assertEval "replace-multi" "builtins.replaceStrings [ \"a\" \"b\" ] [ \"A\" \"B\" ] \"abc\"" (mkStr "ABc"),+      runTest "replaceStrings empty from" $+        assertEval "replace-empty" "builtins.replaceStrings [ \"\" ] [ \"x\" ] \"ab\"" (mkStr "xaxbx"),+      runTest "replaceStrings no match" $+        assertEval "replace-nomatch" "builtins.replaceStrings [ \"z\" ] [ \"Z\" ] \"abc\"" (mkStr "abc"),+      -- Match-gated forcing: an unmatched replacement is never evaluated.+      runTest "replaceStrings never forces an unmatched replacement" $+        assertEval "replace-lazy" "builtins.replaceStrings [ \"z\" ] [ (builtins.throw \"unused\") ] \"abc\"" (mkStr "abc"),+      -- compareVersions+      runTest "compareVersions equal" $+        assertEval "cmpVer-eq" "builtins.compareVersions \"1.2.3\" \"1.2.3\"" (VInt 0),+      runTest "compareVersions less" $+        assertEval "cmpVer-lt" "builtins.compareVersions \"1.2\" \"1.3\"" (VInt (-1)),+      runTest "compareVersions greater" $+        assertEval "cmpVer-gt" "builtins.compareVersions \"2.0\" \"1.9\"" (VInt 1),+      runTest "compareVersions type error" $+        assertEvalFail "cmpVer-err" "builtins.compareVersions 1 2",+      runTest "compareVersions treats - as a separator" $+        assertEval "cmpVer-dash" "builtins.compareVersions \"1.0-2\" \"1.0.2\"" (VInt 0),+      -- splitVersion+      runTest "splitVersion basic" $+        assertEval "splitVer" "builtins.splitVersion \"1.2.3\" == [ \"1\" \"2\" \"3\" ]" (VBool True),+      runTest "splitVersion pre" $+        assertEval "splitVer-pre" "builtins.splitVersion \"1.2pre\" == [ \"1\" \"2\" \"pre\" ]" (VBool True),+      runTest "splitVersion type error" $+        assertEvalFail "splitVer-err" "builtins.splitVersion 42",+      -- parseDrvName+      runTest "parseDrvName basic" $+        assertEval "parseDrv" "(builtins.parseDrvName \"hello-1.2.3\").name" (mkStr "hello"),+      runTest "parseDrvName version" $+        assertEval "parseDrv-ver" "(builtins.parseDrvName \"hello-1.2.3\").version" (mkStr "1.2.3"),+      runTest "parseDrvName no version" $+        assertEval "parseDrv-nover" "(builtins.parseDrvName \"hello\").version" (mkStr ""),+      runTest "parseDrvName type error" $+        assertEvalFail "parseDrv-err" "builtins.parseDrvName 42"+    ]++-- ---------------------------------------------------------------------------+-- Tests: Batch 5 - Serialization + hashing+-- ---------------------------------------------------------------------------++testBatch5 :: IO [Bool]+testBatch5 = do+  putStrLn "eval/builtins-batch5"+  sequence+    [ -- toJSON+      runTest "toJSON int" $+        assertEval "toJSON-int" "builtins.toJSON 42" (mkStr "42"),+      runTest "toJSON string" $+        assertEval "toJSON-str" "builtins.toJSON \"hello\"" (mkStr "\"hello\""),+      runTest "toJSON null" $+        assertEval "toJSON-null" "builtins.toJSON null" (mkStr "null"),+      runTest "toJSON bool" $+        assertEval "toJSON-bool" "builtins.toJSON true" (mkStr "true"),+      runTest "toJSON list" $+        assertEval "toJSON-list" "builtins.toJSON [ 1 2 3 ]" (mkStr "[1,2,3]"),+      runTest "toJSON attrs" $+        assertEval "toJSON-attrs" "builtins.toJSON { a = 1; }" (mkStr "{\"a\":1}"),+      runTest "toJSON lambda error" $+        assertEvalFail "toJSON-fn" "builtins.toJSON (x: x)",+      -- fromJSON+      runTest "fromJSON int" $+        assertEval "fromJSON-int" "builtins.fromJSON \"42\"" (VInt 42),+      runTest "fromJSON string" $+        assertEval "fromJSON-str" "builtins.fromJSON \"\\\"hello\\\"\"" (mkStr "hello"),+      runTest "fromJSON null" $+        assertEval "fromJSON-null" "builtins.fromJSON \"null\"" VNull,+      runTest "fromJSON bool" $+        assertEval "fromJSON-bool" "builtins.fromJSON \"true\"" (VBool True),+      runTest "fromJSON array" $+        assertEval "fromJSON-arr" "builtins.length (builtins.fromJSON \"[1,2,3]\")" (VInt 3),+      runTest "fromJSON object" $+        assertEval "fromJSON-obj" "(builtins.fromJSON \"{\\\"a\\\": 1}\").a" (VInt 1),+      runTest "fromJSON roundtrip" $+        assertEval "fromJSON-rt" "let x = builtins.fromJSON (builtins.toJSON { a = 1; b = [ 2 3 ]; }); in x.a == 1 && x.b == [ 2 3 ]" (VBool True),+      runTest "fromJSON invalid" $+        assertEvalFail "fromJSON-bad" "builtins.fromJSON \"not json\"",+      -- hashString+      runTest "hashString sha256" $+        assertEval "hash-sha256" "builtins.hashString \"sha256\" \"hello\"" (mkStr "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"),+      runTest "hashString md5" $+        assertEval "hash-md5" "builtins.hashString \"md5\" \"hello\"" (mkStr "5d41402abc4b2a76b9719d911017c592"),+      runTest "hashString sha1" $+        assertEval "hash-sha1" "builtins.hashString \"sha1\" \"hello\"" (mkStr "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"),+      runTest "hashString unknown algo" $+        assertEvalFail "hash-bad" "builtins.hashString \"sha999\" \"hello\"",+      runTest "hashString type error" $+        assertEvalFail "hash-err" "builtins.hashString \"sha256\" 42"+    ]++-- ---------------------------------------------------------------------------+-- Tests: Batch 6 - tryEval + deepSeq+-- ---------------------------------------------------------------------------++testBatch6 :: IO [Bool]+testBatch6 = do+  putStrLn "eval/builtins-batch6"+  sequence+    [ -- tryEval success+      runTest "tryEval success" $+        assertEval "tryEval-ok" "(builtins.tryEval 42).value" (VInt 42),+      runTest "tryEval success flag" $+        assertEval "tryEval-flag" "(builtins.tryEval 42).success" (VBool True),+      -- tryEval failure+      runTest "tryEval catches throw" $+        assertEval "tryEval-throw" "(builtins.tryEval (builtins.throw \"boom\")).success" (VBool False),+      runTest "tryEval failure value" $+        assertEval "tryEval-fval" "(builtins.tryEval (builtins.throw \"boom\")).value" (VBool False),+      -- tryEval catches ONLY throw/assert (upstream ThrownError/+      -- AssertionError); type errors and missing attrs escape it.+      runTest "tryEval catches failed assert" $+        assertEval "tryEval-assert" "(builtins.tryEval (assert false; 1)).success" (VBool False),+      runTest "tryEval does not catch a type error" $+        assertEvalFail "tryEval-tyerr" "(builtins.tryEval ({} + [])).success",+      runTest "tryEval does not catch a missing attribute" $+        assertEvalFail "tryEval-noattr" "(builtins.tryEval ({ a = 1; }.b)).success",+      runTest "tryEval catches a throw nested under forcing" $+        assertEval "tryEval-deep-throw" "(builtins.tryEval (builtins.deepSeq [ (builtins.throw \"x\") ] 1)).success" (VBool False),+      -- tryEval passed as a VALUE (comparator/element positions) instead+      -- of applied syntactically: every application path must keep the+      -- catch around the argument's evaluation, and none may leak the+      -- old internal 'unreachable' dispatch error.+      runTest "tryEval as sort comparator fails like upstream" $+        case evalNix "builtins.sort builtins.tryEval [ 1 2 ]" of+          Left err+            | "unreachable" `T.isInfixOf` err -> Fail ("internal error leaked: " <> err)+            | otherwise -> Pass+          Right v -> Fail ("expected a boolean-coercion failure, got " <> T.pack (show v)),+      runTest "tryEval via map catches a throwing element" $+        assertEval+          "tryEval-map"+          "(builtins.elemAt (builtins.map builtins.tryEval [ (builtins.throw \"m\") ]) 0).success"+          (VBool False),+      runTest "tryEval via map success-wraps a clean element" $+        assertEval+          "tryEval-map-ok"+          "(builtins.elemAt (builtins.map builtins.tryEval [ 7 ]) 0).value"+          (VInt 7),+      runTest "tryEval through filter catches the element throw" $+        case evalNix "builtins.filter builtins.tryEval [ (builtins.throw \"leaked\") ]" of+          Left err+            | "leaked" `T.isInfixOf` err -> Fail ("throw escaped tryEval: " <> err)+            | otherwise -> Pass+          Right v -> Fail ("expected a boolean-coercion failure, got " <> T.pack (show v)),+      runTest "functor returning tryEval keeps the catch" $+        assertEval+          "tryEval-functor"+          "(({ __functor = self: builtins.tryEval; }) (builtins.throw \"f\")).success"+          (VBool False),+      -- deepSeq+      runTest "deepSeq returns second" $+        assertEval "deepSeq" "builtins.deepSeq [ 1 2 3 ] 42" (VInt 42),+      runTest "deepSeq forces nested" $+        assertEvalFail "deepSeq-err" "builtins.deepSeq [ (builtins.throw \"boom\") ] 42",+      runTest "deepSeq forces attrs" $+        assertEvalFail "deepSeq-attr" "builtins.deepSeq { a = builtins.throw \"boom\"; } 42"+    ]++-- ---------------------------------------------------------------------------+-- Tests: Batch 7 - genericClosure+-- ---------------------------------------------------------------------------++testBatch7 :: IO [Bool]+testBatch7 = do+  putStrLn "eval/builtins-batch7"+  sequence+    [ runTest "genericClosure basic" $+        assertEval+          "closure-basic"+          "builtins.length (builtins.genericClosure { startSet = [ { key = 1; } ]; operator = item: [ ]; })"+          (VInt 1),+      runTest "genericClosure expansion" $+        assertEval+          "closure-expand"+          "builtins.length (builtins.genericClosure { startSet = [ { key = 1; next = 2; } ]; operator = item: if item.next == 0 then [ ] else [ { key = item.next; next = 0; } ]; })"+          (VInt 2),+      runTest "genericClosure dedup" $+        assertEval+          "closure-dedup"+          "builtins.length (builtins.genericClosure { startSet = [ { key = 1; } { key = 1; } ]; operator = item: [ ]; })"+          (VInt 1),+      runTest "genericClosure missing startSet" $+        assertEvalFail "closure-nostart" "builtins.genericClosure { operator = x: [ ]; }",+      runTest "genericClosure type error" $+        assertEvalFail "closure-tyerr" "builtins.genericClosure 42"+    ]++-- ---------------------------------------------------------------------------+-- Tests: import and IO builtins (pure)+-- ---------------------------------------------------------------------------++testImportPure :: IO [Bool]+testImportPure = do+  putStrLn "eval/import-pure"+  sequence+    [ runTest "import errors in pure mode" $+        assertEvalFail "import-pure" "import ./foo.nix",+      runTest "builtins.typeOf import is lambda" $+        assertEval "typeof-import" "builtins.typeOf import" (mkStr "lambda"),+      runTest "pathExists returns false in pure mode" $+        assertEval "pathExists-pure" "builtins.pathExists ./nonexistent" (VBool False),+      runTest "readFile errors in pure mode" $+        assertEvalFail "readFile-pure" "builtins.readFile ./foo.nix",+      runTest "readDir errors in pure mode" $+        assertEvalFail "readDir-pure" "builtins.readDir ./some-dir"+    ]++-- ---------------------------------------------------------------------------+-- Tests: import and IO builtins (IO)+-- ---------------------------------------------------------------------------++-- | Parse and evaluate Nix source using the IO evaluator.+-- | The per-suite temp store 'evalNixIO' evaluates against, exposed so+-- a test inspecting materialized trees resolves the same directory.+evalNixIOStoreDir :: IO StoreDir+evalNixIOStoreDir = do+  tmpBase <- getTemporaryDirectory+  pure (StoreDir (tmpBase </> "nova-nix-test-eval-store"))++-- | IO eval against a per-suite temp store, so no test needs a real,+-- writable platform store root: eval-time store writes land under the+-- temp directory, and the suite runs everywhere without provisioning.+evalNixIO :: FilePath -> Text -> IO (Either Text NixValue)+evalNixIO baseDir source = do+  storeDir <- evalNixIOStoreDir+  evalNixIOStore storeDir baseDir source++-- | Run a named IO eval test - single label, no double-wrapping.+runTestIO :: Text -> FilePath -> Text -> NixValue -> IO Bool+runTestIO label baseDir source expected = do+  result <- evalNixIO baseDir source+  runTest label $ assertRight label result $ \actual ->+    assertEqual label expected actual++-- | Run a named IO eval test that should fail.+runTestIOFail :: Text -> FilePath -> Text -> IO Bool+runTestIOFail label baseDir source = do+  result <- evalNixIO baseDir source+  runTest label $ assertLeft label result++-- | Quoted path literal for embedding absolute paths in Nix source.+nixQuotedPath :: FilePath -> Text+nixQuotedPath p = T.pack (show p)++-- | Regression: a thunk whose force throws a catchable error (builtins.throw,+-- a type error) must be restored to PENDING, not left BLACKHOLE - otherwise a+-- later force of the same shared thunk aborts with a bogus "infinite recursion"+-- that escapes tryEval.  Genuine self-recursion must still escape tryEval.+-- EvalIO-only: PureEval never blackholes.+testBlackholeRecoveryIO :: IO [Bool]+testBlackholeRecoveryIO = do+  putStrLn "eval/blackhole-recovery-io"+  sequence+    [ runTestIO+        "tryEval twice on a shared throwing thunk recovers (no bogus recursion)"+        "."+        "let x = builtins.throw \"boom\"; in (if (builtins.tryEval x).success then 1 else 0) + (if (builtins.tryEval x).success then 1 else 0)"+        (VInt 0),+      runTestIOFail+        "genuine infinite recursion still escapes tryEval"+        "."+        "builtins.tryEval (let y = y; in y)"+    ]++-- | builtins.path/filterSource with a filter: the tree is serialized with+-- rejected entries removed (a rejected directory prunes its subtree),+-- content-addressed over the FILTERED NAR, and materialized to the store.+testPathFilterIO :: IO [Bool]+testPathFilterIO = do+  putStrLn "eval/path-filter-io"+  testPathFilterBody++testPathFilterBody :: IO [Bool]+testPathFilterBody = do+  tmpBase <- getTemporaryDirectory+  let srcDir = tmpBase </> "nova-nix-test-path-filter"+      setup = do+        removeIfExists srcDir+        createDirectoryIfMissing True (srcDir </> "sub")+        createDirectoryIfMissing True (srcDir </> "dropdir")+        BS.writeFile (srcDir </> "keep.txt") "keep"+        BS.writeFile (srcDir </> "drop.log") "drop"+        BS.writeFile (srcDir </> "sub" </> "inner.txt") "inner"+        BS.writeFile (srcDir </> "dropdir" </> "x.txt") "gone"+      quoted = nixQuotedPath srcDir+      filterExpr = "(p: t: builtins.match \".*[.]log\" p == null && baseNameOf p != \"dropdir\")"+      filteredPath = "(builtins.path { path = " <> quoted <> "; name = \"src\"; filter = " <> filterExpr <> "; })"+      unfilteredPath = "(builtins.path { path = " <> quoted <> "; name = \"src\"; })"+  -- bracket_: cleanup runs even if tests throw+  bracket_ setup (removeIfExists srcDir) $+    sequence+      [ runTestIO+          "kept file survives with its content"+          "."+          ("builtins.readFile (" <> filteredPath <> " + \"/keep.txt\")")+          (mkStr "keep"),+        runTestIO+          "kept subtree survives"+          "."+          ("builtins.readFile (" <> filteredPath <> " + \"/sub/inner.txt\")")+          (mkStr "inner"),+        runTestIO+          "rejected file is dropped"+          "."+          ("builtins.pathExists (" <> filteredPath <> " + \"/drop.log\")")+          (VBool False),+        runTestIO+          "rejected directory prunes its subtree"+          "."+          ("builtins.pathExists (" <> filteredPath <> " + \"/dropdir\")")+          (VBool False),+        runTestIO+          "filtered and unfiltered store paths differ"+          "."+          (filteredPath <> " == " <> unfilteredPath)+          (VBool False),+        runTestIO+          "same filter yields the same store path"+          "."+          (filteredPath <> " == " <> filteredPath)+          (VBool True),+        runTestIO+          "filterSource is path-with-filter under the source basename"+          "."+          ( "builtins.filterSource (p: t: true) "+              <> quoted+              <> " == builtins.path { path = "+              <> quoted+              <> "; filter = (p: t: true); }"+          )+          (VBool True)+      ]++-- | Whether this host can create symlinks (Windows needs Developer Mode+-- or elevation).  The symlink-walk fixtures cannot be built without it,+-- so their groups skip loudly rather than fail.+symlinksAvailable :: IO Bool+symlinksAvailable = do+  tmpBase <- getTemporaryDirectory+  let probeDir = tmpBase </> "nova-nix-test-link-probe"+  Dir.removePathForcibly probeDir+  createDirectoryIfMissing True probeDir+  BS.writeFile (probeDir </> "target.txt") "t"+  outcome <- try (Dir.createFileLink "target.txt" (probeDir </> "link"))+  Dir.removePathForcibly probeDir+  pure $ case (outcome :: Either SomeException ()) of+    Left _ -> False+    Right () -> True++-- | Watchdog for cycle-termination tests: with symlinks as leaves every+-- walk returns promptly, while a regression to link-following recurses+-- forever - and a hung suite is worse than a failed one.+walkWatchdogMicros :: Int+walkWatchdogMicros = 30 * 1000 * 1000++-- | @builtins.path@ with no filter over a tree that contains a symlink.+-- The store-path name comes from the NAR serialization, which records+-- the link as a leaf entry - so the copy must replicate the link for+-- the stored bytes to still match the recorded content address.+testPathSymlinkIO :: IO [Bool]+testPathSymlinkIO = do+  putStrLn "eval/path-symlink-io"+  canLink <- symlinksAvailable+  if not canLink+    then do+      putStrLn "  SKIP  needs symlink privilege"+      pure []+    else testPathSymlinkBody++testPathSymlinkBody :: IO [Bool]+testPathSymlinkBody = do+  tmpBase <- getTemporaryDirectory+  let srcDir = tmpBase </> "nova-nix-test-path-symlink"+      cycleDir = tmpBase </> "nova-nix-test-path-cycle"+  Dir.removePathForcibly srcDir+  Dir.removePathForcibly cycleDir+  createDirectoryIfMissing True srcDir+  BS.writeFile (srcDir </> "data.txt") "linked bytes"+  Dir.createFileLink "data.txt" (srcDir </> "link")+  createDirectoryIfMissing True cycleDir+  BS.writeFile (cycleDir </> "f.txt") "f"+  Dir.createDirectoryLink "." (cycleDir </> "loop")+  linkEntry <- NAR.serialiseFromPath srcDir+  cycleEntry <- NAR.serialiseFromPath cycleDir+  let pathExprFor dir name =+        "builtins.path { path = " <> nixQuotedPath dir <> "; name = \"" <> name <> "\"; }"+      spFor name entry =+        makeFixedOutputPath name "sha256" "recursive" (sha256Digest (NAR.serialise entry))+  results <- case (spFor "path-symlink-src" linkEntry, spFor "path-symlink-cycle" cycleEntry) of+    (Right linkSp, Right cycleSp) -> do+      -- The same mapping the evaluator's copy uses for its destination.+      evalStore <- evalNixIOStoreDir+      let linkDest = storePathToFilePath evalStore linkSp+          cycleDest = storePathToFilePath evalStore cycleSp+      -- A leftover materialization from an earlier (possibly pre-fix) run+      -- would short-circuit the copy under test.+      Dir.removePathForcibly linkDest+      Dir.removePathForcibly cycleDest+      linkEval <- evalNixIO "." (pathExprFor srcDir "path-symlink-src")+      cycleEval <- timeout walkWatchdogMicros (evalNixIO "." (pathExprFor cycleDir "path-symlink-cycle"))+      sequence+        [ runTestM "unfiltered builtins.path replicates a symlink" $+            case linkEval of+              Left err -> pure (Fail ("eval failed: " <> err))+              Right _ -> do+                isLink <- Dir.pathIsSymbolicLink (linkDest </> "link")+                if isLink+                  then do+                    target <- Dir.getSymbolicLinkTarget (linkDest </> "link")+                    pure (assertEqual "link target" "data.txt" target)+                  else pure (Fail "materialized 'link' is not a symlink"),+          runTestM "materialized bytes reproduce the recorded content address" $+            case linkEval of+              Left err -> pure (Fail ("eval failed: " <> err))+              Right _ -> do+                onDisk <- NAR.serialiseFromPath linkDest+                pure $ case spFor "path-symlink-src" onDisk of+                  Left err -> Fail ("on-disk tree's name rejected: " <> T.pack (show err))+                  Right recomputed -> assertEqual "recomputed path" linkSp recomputed,+          runTestM "builtins.path terminates on a link cycle" $+            case cycleEval of+              Nothing -> pure (Fail "copy did not terminate on a link cycle")+              Just (Left err) -> pure (Fail ("eval failed: " <> err))+              Just (Right _) -> do+                isLink <- Dir.pathIsSymbolicLink (cycleDest </> "loop")+                pure (if isLink then Pass else Fail "cycle link not replicated as a link")+        ]+    (badLink, badCycle) ->+      sequence+        [ runTest "path-symlink fixture store paths accepted" $+            Fail ("test store path rejected: " <> T.pack (show (badLink, badCycle)))+        ]+  Dir.removePathForcibly srcDir+  Dir.removePathForcibly cycleDir+  pure results++testImportIO :: IO [Bool]+testImportIO = do+  putStrLn "eval/import-io"+  tmpBase <- getTemporaryDirectory+  let testDir = tmpBase </> "nova-nix-test-import"+      subDir = testDir </> "sub"+      setup = do+        createDirectoryIfMissing True subDir+        TIO.writeFile (testDir </> "literal.nix") "42"+        TIO.writeFile (testDir </> "expr.nix") "1 + 2"+        TIO.writeFile (testDir </> "nested-inner.nix") "99"+        TIO.writeFile (testDir </> "nested-outer.nix") "import ./nested-inner.nix"+        TIO.writeFile (testDir </> "attrset.nix") "{ x = 1; y = 2; }"+        TIO.writeFile (testDir </> "uses-arg.nix") "let f = x: x + 10; in f 5"+        TIO.writeFile (subDir </> "from-sub.nix") "7"+      cleanup = do+        exists <- doesDirectoryExist testDir+        when exists (removeDirectoryRecursive testDir)+  -- bracket_: cleanup runs even if tests throw+  bracket_ setup cleanup $+    sequence+      [ -- import+        runTestIO "import literal" testDir "import ./literal.nix" (VInt 42),+        runTestIO "import expression" testDir "import ./expr.nix" (VInt 3),+        runTestIO "import nested (A imports B)" testDir "import ./nested-outer.nix" (VInt 99),+        runTestIO+          "import cache (same file twice)"+          testDir+          "(import ./literal.nix) + (import ./literal.nix)"+          (VInt 84),+        runTestIOFail "import nonexistent -> error" testDir "import ./nonexistent.nix",+        runTestIO "import attrset + select" testDir "(import ./attrset.nix).x" (VInt 1),+        runTestIO "import let/lambda" testDir "import ./uses-arg.nix" (VInt 15),+        -- import accepts strings (real Nix coerces string to path)+        runTestIO "import accepts string" testDir "import \"./literal.nix\"" (VInt 42),+        -- pathExists+        runTestIO+          "pathExists true"+          testDir+          ("builtins.pathExists " <> nixQuotedPath (testDir </> "literal.nix"))+          (VBool True),+        runTestIO+          "pathExists false"+          testDir+          ("builtins.pathExists " <> nixQuotedPath (testDir </> "nope.nix"))+          (VBool False),+        -- readFile+        runTestIO+          "readFile contents"+          testDir+          ("builtins.readFile " <> nixQuotedPath (testDir </> "literal.nix"))+          (mkStr "42"),+        runTestIOFail+          "readFile missing -> error"+          testDir+          ("builtins.readFile " <> nixQuotedPath (testDir </> "ghost.nix")),+        -- readDir: entries have correct file types+        runTestIO+          "readDir classifies directory"+          testDir+          ("(builtins.readDir " <> nixQuotedPath testDir <> ").sub")+          (mkStr "directory"),+        runTestIO+          "readDir classifies regular file"+          testDir+          ("builtins.getAttr \"literal.nix\" (builtins.readDir " <> nixQuotedPath testDir <> ")")+          (mkStr "regular")+      ]++-- ---------------------------------------------------------------------------+-- Tests: Batch A - getEnv, currentTime, toPath+-- ---------------------------------------------------------------------------++testBatchA :: IO [Bool]+testBatchA = do+  putStrLn "eval/builtins-batchA"+  sequence+    [ -- getEnv+      runTest "getEnv pure returns empty" $+        assertEval "getEnv-pure" "builtins.getEnv \"HOME\"" (mkStr ""),+      runTest "getEnv type error" $+        assertEvalFail "getEnv-err" "builtins.getEnv 42",+      -- toPath+      runTest "toPath absolute" $+        assertEval "toPath-abs" "builtins.toPath \"/foo/bar\"" (VPath "/foo/bar"),+      runTest "toPath rejects relative" $+        assertEvalFail "toPath-rel" "builtins.toPath \"foo/bar\"",+      runTest "toPath passthrough VPath" $+        assertEval "toPath-vpath" "builtins.toPath (builtins.toPath \"/foo/bar\")" (VPath "/foo/bar"),+      runTest "toPath type error" $+        assertEvalFail "toPath-err" "builtins.toPath 42",+      runTest "toPath rejects empty" $+        assertEvalFail "toPath-empty" "builtins.toPath \"\"",+      -- currentTime+      runTest "currentTime is int" $+        assertEval "currentTime" "builtins.typeOf builtins.currentTime" (mkStr "int"),+      runTest "currentTime is 0 in pure" $+        assertEval "currentTime-pure" "builtins.currentTime" (VInt 0),+      runTest "currentTime >= 0" $+        assertEval "currentTime-pos" "builtins.currentTime >= 0" (VBool True)+    ]++-- ---------------------------------------------------------------------------+-- Tests: Batch A - IO tests (getEnv)+-- ---------------------------------------------------------------------------++testBatchAIO :: IO [Bool]+testBatchAIO = do+  putStrLn "eval/builtins-batchA-io"+  tmpBase <- getTemporaryDirectory+  let testDir = tmpBase </> "nova-nix-test-batchA"+  bracket_+    (createDirectoryIfMissing True testDir)+    ( do+        exists <- doesDirectoryExist testDir+        when exists (removeDirectoryRecursive testDir)+    )+    $ sequence+      [ -- getEnv HOME should be non-empty in IO mode+        do+          result <- evalNixIO testDir "builtins.getEnv \"PATH\""+          runTest "getEnv PATH non-empty (IO)" $ assertRight "getEnv-io" result $ \val ->+            case val of+              VStr s _ -> if BS.null s then Fail "PATH was empty" else Pass+              _ -> Fail ("expected VStr, got " <> T.pack (show val)),+        -- currentTime in IO should be > 0+        do+          result <- evalNixIO testDir "builtins.currentTime"+          runTest "currentTime > 0 (IO)" $ assertRight "currentTime-io" result $ \val ->+            case val of+              VInt n -> if n > 0 then Pass else Fail ("expected > 0, got " <> T.pack (show n))+              _ -> Fail ("expected VInt, got " <> T.pack (show val))+      ]++-- ---------------------------------------------------------------------------+-- Tests: Batch B - placeholder, storePath+-- ---------------------------------------------------------------------------++testBatchB :: IO [Bool]+testBatchB = do+  putStrLn "eval/builtins-batchB"+  sequence+    [ -- placeholder+      runTest "placeholder out matches Nix hashPlaceholder" $+        assertRight "placeholder-out" (evalNix "builtins.placeholder \"out\"") $ \val ->+          case val of+            VStr p _ -> assertEqual "placeholder out" "/1rz4g4znpzjwh1xymhjpm42vipw92pr73vdgl6xs1hycac8kf2n9" p+            _ -> Fail ("expected VStr, got " <> T.pack (show val)),+      runTest "placeholder deterministic" $+        assertRight "placeholder-det" (evalNix "builtins.placeholder \"out\" == builtins.placeholder \"out\"") $ \val ->+          assertEqual "deterministic" (VBool True) val,+      runTest "placeholder out /= placeholder dev" $+        assertRight "placeholder-diff" (evalNix "builtins.placeholder \"out\" == builtins.placeholder \"dev\"") $ \val ->+          assertEqual "different" (VBool False) val,+      runTest "placeholder type error" $+        assertEvalFail "placeholder-err" "builtins.placeholder 42",+      -- storePath returns a STRING marked already-in-store (SCPlain context),+      -- not a bare path - the marker is what stops a later coercion re-NARing it.+      runTest "storePath returns an in-store string" $+        assertEval+          "storePath-isstring"+          "builtins.isString (builtins.storePath \"/nix/store/s66mzxpvicwk07gjbjfw9izjfa797vsw-hello-2.12.1\")"+          (VBool True),+      runTest "storePath result carries a plain-path context" $+        assertEval+          "storePath-hasctx"+          "(builtins.getContext (builtins.storePath \"/nix/store/s66mzxpvicwk07gjbjfw9izjfa797vsw-hello-2.12.1\")).\"/nix/store/s66mzxpvicwk07gjbjfw9izjfa797vsw-hello-2.12.1\".path"+          (VBool True),+      runTest "storePath preserves the path text" $+        assertEval+          "storePath-text"+          "builtins.unsafeDiscardStringContext (builtins.storePath \"/nix/store/s66mzxpvicwk07gjbjfw9izjfa797vsw-hello-2.12.1\")"+          (mkStr "/nix/store/s66mzxpvicwk07gjbjfw9izjfa797vsw-hello-2.12.1"),+      runTest "storePath invalid" $+        assertEvalFail "storePath-bad" "builtins.storePath \"/tmp/not-a-store-path\"",+      runTest "storePath type error" $+        assertEvalFail "storePath-err" "builtins.storePath 42"+    ]++-- ---------------------------------------------------------------------------+-- Tests: Batch C - findFile+-- ---------------------------------------------------------------------------++testBatchC :: IO [Bool]+testBatchC = do+  putStrLn "eval/builtins-batchC"+  sequence+    [ runTest "findFile empty list errors" $+        assertEvalFail "findFile-empty" "builtins.findFile [ ] \"foo\"",+      runTest "findFile type error arg1" $+        assertEvalFail "findFile-err1" "builtins.findFile 42 \"foo\"",+      runTest "findFile type error arg2" $+        assertEvalFail "findFile-err2" "builtins.findFile [ ] 42"+    ]++testBatchCIO :: IO [Bool]+testBatchCIO = do+  putStrLn "eval/builtins-batchC-io"+  tmpBase <- getTemporaryDirectory+  let testDir = tmpBase </> "nova-nix-test-batchC"+      nixpkgsDir = testDir </> "nixpkgs"+  bracket_+    ( do+        createDirectoryIfMissing True nixpkgsDir+        TIO.writeFile (nixpkgsDir </> "default.nix") "42"+    )+    ( do+        exists <- doesDirectoryExist testDir+        when exists (removeDirectoryRecursive testDir)+    )+    $ sequence+      [ runTestIO+          "findFile with matching entry"+          testDir+          ( "builtins.findFile [ { prefix = \"nixpkgs\"; path = "+              <> nixQuotedPath nixpkgsDir+              <> "; } ] \"nixpkgs\""+          )+          (VPath (canonPathValue (T.pack nixpkgsDir))),+        runTestIOFail+          "findFile no match"+          testDir+          "builtins.findFile [ { prefix = \"other\"; path = \"/nope\"; } ] \"nixpkgs\""+      ]++-- ---------------------------------------------------------------------------+-- Tests: Blackhole (infinite recursion detection)+-- ---------------------------------------------------------------------------++testBlackhole :: IO [Bool]+testBlackhole = do+  putStrLn "eval/blackhole"+  tmpBase <- getTemporaryDirectory+  sequence+    [ runTestIOFail "let x = x; in x" tmpBase "let x = x; in x",+      runTestIOFail "rec { a = a; }.a" tmpBase "rec { a = a; }.a",+      runTestIOFail "let a = b; b = a; in a" tmpBase "let a = b; b = a; in a",+      -- Non-recursive cases must still work+      runTestIO "rec { a = 1; b = a; }.b" tmpBase "rec { a = 1; b = a; }.b" (VInt 1),+      runTestIO "let a = 1; b = a + 1; in b" tmpBase "let a = 1; b = a + 1; in b" (VInt 2)+    ]++-- ---------------------------------------------------------------------------+-- Tests: Batch D - toFile+-- ---------------------------------------------------------------------------++testBatchD :: IO [Bool]+testBatchD = do+  putStrLn "eval/builtins-batchD"+  sequence+    [ runTest "toFile pure mode error" $+        assertEvalFail "toFile-pure" "builtins.toFile \"hello\" \"world\"",+      runTest "toFile type error arg1" $+        assertEvalFail "toFile-err1" "builtins.toFile 42 \"world\"",+      runTest "toFile type error arg2" $+        assertEvalFail "toFile-err2" "builtins.toFile \"hello\" 42"+    ]++-- ---------------------------------------------------------------------------+-- Tests: Batch E - scopedImport+-- ---------------------------------------------------------------------------++testBatchE :: IO [Bool]+testBatchE = do+  putStrLn "eval/builtins-batchE"+  sequence+    [ runTest "scopedImport pure error" $+        assertEvalFail "scopedImport-pure" "builtins.scopedImport { } ./foo.nix",+      runTest "scopedImport type error arg1" $+        assertEvalFail "scopedImport-err1" "builtins.scopedImport 42 ./foo.nix",+      runTest "scopedImport type error arg2" $+        assertEvalFail "scopedImport-err2" "builtins.scopedImport { } 42"+    ]++testBatchEIO :: IO [Bool]+testBatchEIO = do+  putStrLn "eval/builtins-batchE-io"+  tmpBase <- getTemporaryDirectory+  let testDir = tmpBase </> "nova-nix-test-batchE"+  bracket_+    ( do+        createDirectoryIfMissing True testDir+        TIO.writeFile (testDir </> "scoped.nix") "x"+    )+    ( do+        exists <- doesDirectoryExist testDir+        when exists (removeDirectoryRecursive testDir)+    )+    $ sequence+      [ runTestIO+          "scopedImport injects scope"+          testDir+          ("builtins.scopedImport { x = 42; } " <> nixQuotedPath (testDir </> "scoped.nix"))+          (VInt 42)+      ]++-- ---------------------------------------------------------------------------+-- Tests: Batch F - fetchurl, fetchTarball, fetchGit+-- ---------------------------------------------------------------------------++testBatchF :: IO [Bool]+testBatchF = do+  putStrLn "eval/builtins-batchF"+  sequence+    [ runTest "fetchurl pure error" $+        assertEvalFail "fetchurl-pure" "builtins.fetchurl \"http://example.com\"",+      runTest "fetchurl type error" $+        assertEvalFail "fetchurl-err" "builtins.fetchurl 42",+      runTest "fetchTarball pure error" $+        assertEvalFail "fetchTarball-pure" "builtins.fetchTarball \"http://example.com\"",+      runTest "fetchTarball type error" $+        assertEvalFail "fetchTarball-err" "builtins.fetchTarball 42",+      runTest "fetchGit pure error" $+        assertEvalFail "fetchGit-pure" "builtins.fetchGit \"http://example.com\"",+      runTest "fetchGit type error" $+        assertEvalFail "fetchGit-err" "builtins.fetchGit 42"+    ]++-- ---------------------------------------------------------------------------+-- Tests: Batch G - ATerm serialization+-- ---------------------------------------------------------------------------++testBatchG :: IO [Bool]+testBatchG = do+  putStrLn "derivation/aterm"+  let minimalDrv =+        Derivation+          { drvOutputs = [],+            drvInputDrvs = Map.empty,+            drvInputSrcs = [],+            drvPlatform = X86_64_Linux,+            drvBuilder = "/bin/sh",+            drvArgs = [],+            drvEnv = Map.empty+          }+  let drvWithOutput =+        minimalDrv+          { drvOutputs =+              [ DerivationOutput+                  { doName = "out",+                    doPath = StorePath "abc" "hello",+                    doHashAlgo = "",+                    doHash = ""+                  }+              ]+          }+  let drvWithEnv =+        minimalDrv+          { drvEnv = Map.fromList [("name", "hello"), ("system", "x86_64-linux")]+          }+  sequence+    [ runTest "ATerm minimal" $+        let aterm = toATerm minimalDrv+         in if BS.isPrefixOf "Derive(" aterm && BS.isSuffixOf ")" aterm+              then Pass+              else Fail ("bad ATerm: " <> bytesText aterm),+      runTest "ATerm has output" $+        let aterm = toATerm drvWithOutput+         in if "\"out\"" `BS.isInfixOf` aterm+              then Pass+              else Fail ("missing output in ATerm: " <> bytesText aterm),+      runTest "ATerm env sorted" $+        let aterm = toATerm drvWithEnv+         in -- "name" should come before "system" in sorted order+            case (BS.breakSubstring "\"name\"" aterm, BS.breakSubstring "\"system\"" aterm) of+              ((before1, _), (before2, _)) ->+                if BS.length before1 < BS.length before2+                  then Pass+                  else Fail ("env not sorted in ATerm: " <> bytesText aterm),+      runTest "ATerm string escaping" $+        let drv = minimalDrv {drvEnv = Map.fromList [("msg", "hello\nworld")]}+            aterm = toATerm drv+         in if "\\n" `BS.isInfixOf` aterm+              then Pass+              else Fail ("missing escaped newline: " <> bytesText aterm),+      runTest "ATerm deterministic" $+        assertEqual "deterministic" (toATerm minimalDrv) (toATerm minimalDrv),+      -- platformToText+      runTest "platformToText linux" $+        assertEqual "linux" "x86_64-linux" (platformToText X86_64_Linux),+      runTest "platformToText darwin" $+        assertEqual "darwin" "x86_64-darwin" (platformToText X86_64_Darwin),+      runTest "platformToText aarch64-darwin" $+        assertEqual "aarch64" "aarch64-darwin" (platformToText Aarch64_Darwin),+      runTest "platformToText windows" $+        assertEqual "windows" "x86_64-windows" (platformToText X86_64_Windows),+      runTest "platformToText aarch64-linux" $+        assertEqual "aarch64-linux" "aarch64-linux" (platformToText Aarch64_Linux),+      runTest "platformToText other" $+        assertEqual "other" "riscv64-freebsd" (platformToText (OtherPlatform "riscv64-freebsd"))+    ]++-- ---------------------------------------------------------------------------+-- Tests: Batch H - derivation+-- ---------------------------------------------------------------------------++testBatchH :: IO [Bool]+testBatchH = do+  putStrLn "eval/builtins-batchH"+  sequence+    [ runTest "derivation has type" $+        assertEval+          "drv-type"+          "let d = derivation { name = \"hello\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; in d.type"+          (mkStr "derivation"),+      runTest "derivation has drvPath" $+        assertRight "drv-drvPath" (evalNix "let d = derivation { name = \"hello\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; in d.drvPath") $ \val ->+          case val of+            VStr p ctx ->+              if "/nix/store/" `BS.isPrefixOf` p && ".drv" `BS.isSuffixOf` p && ctx /= emptyContext+                then Pass+                else Fail ("bad drvPath: " <> bytesText p)+            _ -> Fail ("expected VStr with context, got " <> T.pack (show val)),+      runTest "derivation has outPath" $+        assertRight "drv-outPath" (evalNix "let d = derivation { name = \"hello\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; in d.outPath") $ \val ->+          case val of+            VStr p ctx ->+              if "/nix/store/" `BS.isPrefixOf` p && ctx /= emptyContext+                then Pass+                else Fail ("bad outPath: " <> bytesText p)+            _ -> Fail ("expected VStr with context, got " <> T.pack (show val)),+      -- 'derivation' is lazy (matches C++ Nix): the missing-required-attribute+      -- error fires when a path is forced (.drvPath), not at construction.+      runTest "derivation missing name" $+        assertEvalFail "drv-noname" "(derivation { system = \"x86_64-linux\"; builder = \"/bin/sh\"; }).drvPath",+      runTest "derivation missing system" $+        assertEvalFail "drv-nosys" "(derivation { name = \"hello\"; builder = \"/bin/sh\"; }).drvPath",+      runTest "derivation missing builder" $+        assertEvalFail "drv-nobuilder" "(derivation { name = \"hello\"; system = \"x86_64-linux\"; }).drvPath",+      runTest "derivation type error" $+        assertEvalFail "drv-tyerr" "derivation 42",+      runTest "derivation deterministic" $+        assertRight "drv-det" (evalNix "let d1 = derivation { name = \"a\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; d2 = derivation { name = \"a\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; in d1.drvPath == d2.drvPath") $ \val ->+          assertEqual "deterministic" (VBool True) val+    ]++-- ---------------------------------------------------------------------------+-- Tests: StringContext (Phase 3, Batch 1)+-- ---------------------------------------------------------------------------++testStringContext :: IO [Bool]+testStringContext = do+  putStrLn "eval/string-context"+  let sp1 = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "hello"+      sp2 = StorePath "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" "world"+  sequence+    [ runTest "StringContext Eq" $+        let ctx1 = StringContext (Set.singleton (SCPlain sp1))+            ctx2 = StringContext (Set.singleton (SCPlain sp1))+         in assertEqual "ctx-eq" ctx1 ctx2,+      runTest "mkStr constructor" $+        let v = mkStr "hello"+         in case v of+              VStr t ctx ->+                if t == "hello" && ctx == emptyContext+                  then Pass+                  else Fail "mkStr produced wrong value"+              _ -> Fail "mkStr did not produce VStr",+      runTest "mergeContexts mempty" $+        let ctx1 = StringContext (Set.singleton (SCPlain sp1))+            merged = ctx1 <> emptyContext+         in assertEqual "merge-mempty" ctx1 merged,+      runTest "mergeContexts union" $+        let ctx1 = StringContext (Set.singleton (SCPlain sp1))+            ctx2 = StringContext (Set.singleton (SCDrvOutput sp2 "out"))+            merged = ctx1 <> ctx2+            expected = StringContext (Set.fromList [SCPlain sp1, SCDrvOutput sp2 "out"])+         in assertEqual "merge-union" expected merged+    ]++-- ---------------------------------------------------------------------------+-- Tests: Context propagation (Phase 3, Batch 3)+-- ---------------------------------------------------------------------------++testContextPropagation :: IO [Bool]+testContextPropagation = do+  putStrLn "eval/context-propagation"+  sequence+    [ -- String equality ignores context+      runTest "string equality ignores context" $+        assertEval "str-eq-ctx" "\"hello\" == \"hello\"" (VBool True),+      -- String comparison ignores context+      runTest "string comparison ignores context" $+        assertEval "str-cmp-ctx" "\"a\" < \"b\"" (VBool True),+      -- Interpolation produces correct text+      runTest "interp text correct" $+        assertEval "interp-text" "let x = \"world\"; in \"hello ${x}\"" (mkStr "hello world"),+      -- String + merges (tested at value level)+      runTest "string + merges text" $+        assertEval "str-plus" "\"a\" + \"b\"" (mkStr "ab"),+      -- concatStringsSep merges text+      runTest "concatStringsSep result" $+        assertEval "css-text" "builtins.concatStringsSep \"-\" [\"a\" \"b\"]" (mkStr "a-b"),+      -- substring preserves text+      runTest "substring text" $+        assertEval "substr-text" "builtins.substring 1 2 \"hello\"" (mkStr "el"),+      -- unsafeDiscardStringContext strips context+      runTest "discardContext strips" $+        assertEval "discard-ctx" "builtins.unsafeDiscardStringContext \"hello\"" (mkStr "hello"),+      -- stringLength drops context (returns int)+      runTest "stringLength drops context" $+        assertEval "strlen-drop" "builtins.stringLength \"hello\"" (VInt 5),+      -- hashString drops context (returns string with no context)+      runTest "hashString result type" $+        assertRight "hash-type" (evalNix "builtins.typeOf (builtins.hashString \"sha256\" \"x\")") $ \val ->+          assertEqual "hash-typeof" (mkStr "string") val,+      -- replaceStrings text result+      runTest "replaceStrings text" $+        assertEval "replace-text" "builtins.replaceStrings [\"o\"] [\"0\"] \"foo\"" (mkStr "f00"),+      -- baseNameOf preserves text+      runTest "baseNameOf text" $+        assertEval "basename-text" "builtins.baseNameOf \"/foo/bar\"" (mkStr "bar"),+      -- dirOf preserves text+      runTest "dirOf text" $+        assertEval "dirof-text" "builtins.dirOf \"/foo/bar\"" (mkStr "/foo"),+      -- toString propagates+      runTest "toString on string" $+        assertEval "tostr-str" "builtins.toString \"hello\"" (mkStr "hello"),+      -- toString on int (no context)+      runTest "toString on int" $+        assertEval "tostr-int" "builtins.toString 42" (mkStr "42")+    ]++-- ---------------------------------------------------------------------------+-- Tests: Context helpers (Phase 3, Batch 2)+-- ---------------------------------------------------------------------------++testContextHelpers :: IO [Bool]+testContextHelpers = do+  putStrLn "eval/context-helpers"+  let sp1 = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "hello"+      sp2 = StorePath "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" "world.drv"+      sp3 = StorePath "cccccccccccccccccccccccccccccccc" "source.tar.gz"+  sequence+    [ runTest "plainContext singleton" $+        let ctx = Context.plainContext sp1+         in assertEqual "plain" (StringContext (Set.singleton (SCPlain sp1))) ctx,+      runTest "drvOutputContext singleton" $+        let ctx = Context.drvOutputContext sp2 "out"+         in assertEqual "drvOut" (StringContext (Set.singleton (SCDrvOutput sp2 "out"))) ctx,+      runTest "allOutputsContext singleton" $+        let ctx = Context.allOutputsContext sp2+         in assertEqual "allOut" (StringContext (Set.singleton (SCAllOutputs sp2))) ctx,+      runTest "contextIsEmpty on mempty" $+        assertEqual "emptyCtx" True (Context.contextIsEmpty emptyContext),+      runTest "contextIsEmpty on non-empty" $+        assertEqual "nonEmptyCtx" False (Context.contextIsEmpty (Context.plainContext sp1)),+      runTest "extractInputSrcs" $+        let ctx = Context.plainContext sp1 <> Context.drvOutputContext sp2 "out"+         in assertEqual "srcs" [sp1] (Context.extractInputSrcs ctx),+      runTest "extractInputDrvs" $+        let ctx = Context.drvOutputContext sp2 "out" <> Context.drvOutputContext sp2 "dev" <> Context.plainContext sp3+            drvs = Context.extractInputDrvs ctx+         in case Map.lookup sp2 drvs of+              Just outs -> if length outs == 2 then Pass else Fail ("expected 2 outputs, got " <> T.pack (show (length outs)))+              Nothing -> Fail "sp2 not found in drvs",+      runTest "appendStrings merges" $+        let ctx1 = Context.plainContext sp1+            ctx2 = Context.drvOutputContext sp2 "out"+            (txt, ctx) = Context.appendStrings "hello" ctx1 "world" ctx2+         in if txt == "helloworld" && not (Context.contextIsEmpty ctx) then Pass else Fail "bad append",+      runTest "concatStrings empty" $+        let (txt, ctx) = Context.concatStrings []+         in if txt == "" && Context.contextIsEmpty ctx then Pass else Fail "bad empty concat",+      runTest "concatStrings merges all" $+        let ctx1 = Context.plainContext sp1+            ctx2 = Context.drvOutputContext sp2 "out"+            (txt, ctx) = Context.concatStrings [("a", ctx1), ("b", ctx2), ("c", mempty)]+         in if txt == "abc" && Set.size (unStringContext ctx) == 2 then Pass else Fail "bad concat"+    ]++-- ---------------------------------------------------------------------------+-- Tests: Derivation context + new builtins (Phase 3, Batch 4)+-- ---------------------------------------------------------------------------++testDrvContext :: IO [Bool]+testDrvContext = do+  putStrLn "eval/drv-context"+  sequence+    [ -- hasContext: plain string has no context+      runTest "hasContext on plain string" $+        assertEval "hasCtx-plain" "builtins.hasContext \"hello\"" (VBool False),+      -- hasContext: derivation outPath has context+      runTest "hasContext on drv outPath" $+        assertEval+          "hasCtx-drv"+          "builtins.hasContext (derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }).outPath"+          (VBool True),+      -- hasContext: after discardContext, no context+      runTest "hasContext after discard" $+        assertEval+          "hasCtx-discard"+          "builtins.hasContext (builtins.unsafeDiscardStringContext (derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }).outPath)"+          (VBool False),+      -- getContext: plain string returns empty attrset+      runTest "getContext on plain string" $+        assertRight "getCtx-plain" (evalNix "builtins.getContext \"hello\"") $ \val ->+          case val of+            VAttrs m -> if attrSetNull m then Pass else Fail "expected empty attrset"+            _ -> Fail ("expected VAttrs, got " <> T.pack (show val)),+      -- getContext: drv outPath has outputs entry+      runTest "getContext on drv outPath"+        $ assertRight+          "getCtx-drv"+          (evalNix "builtins.getContext (derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }).outPath")+        $ \val -> case val of+          VAttrs m ->+            if attrSetSize m == 1+              then Pass+              else Fail ("expected 1 entry, got " <> T.pack (show (attrSetSize m)))+          _ -> Fail ("expected VAttrs, got " <> T.pack (show val)),+      -- getContext: drvPath has allOutputs+      runTest "getContext on drvPath has allOutputs"+        $ assertRight+          "getCtx-drvPath"+          (evalNix "let d = derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; ctx = builtins.getContext d.drvPath; in builtins.length (builtins.attrNames ctx)")+        $ \val -> assertEqual "one-entry" (VInt 1) val,+      -- getContext keys are identity: canonical /nix/store spelling on+      -- every platform, never the platform file-path mapping.+      runTest "getContext key is canonical store path"+        $ assertRight+          "getCtx-canonical"+          (evalNix "let d = derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; in builtins.elemAt (builtins.attrNames (builtins.getContext d.drvPath)) 0")+        $ \val -> case val of+          VStr key _+            | "/nix/store/" `BS.isPrefixOf` key && not ("\\" `BS.isInfixOf` key) -> Pass+            | otherwise -> Fail ("expected a canonical /nix/store key, got " <> bytesText key)+          _ -> Fail ("expected VStr, got " <> T.pack (show val)),+      -- appendContext: adds context to plain string+      runTest "appendContext adds context" $+        assertEval+          "appendCtx-add"+          "let ctx = builtins.listToAttrs [{ name = \"/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-foo\"; value = { path = true; }; }]; in builtins.hasContext (builtins.appendContext \"hello\" ctx)"+          (VBool True),+      -- appendContext: empty context is no-op+      runTest "appendContext empty is no-op" $+        assertEval "appendCtx-empty" "builtins.hasContext (builtins.appendContext \"hello\" {})" (VBool False),+      -- unsafeDiscardOutputDependency KEEPS a derivation-output (Built) ref+      -- unchanged (upstream), rather than dropping it as it once did.+      runTest "discardOutputDep keeps output-dependency context"+        $ assertRight+          "discardOutDep-keep"+          ( evalNix $+              T.concat+                [ "let d = derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; ",+                  "stripped = builtins.unsafeDiscardOutputDependency d.outPath; ",+                  "in builtins.hasContext stripped"+                ]+          )+        $ \val -> assertEqual "keep-ctx" (VBool True) val,+      -- ctx3: an all-outputs (DrvDeep) reference on d.drvPath is DOWNGRADED to+      -- a plain path reference, not dropped.+      runTest "discardOutputDep downgrades drvPath to a plain ref" $+        assertEval+          "discardOutDep-downgrade"+          "let d = derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; ctx = builtins.getContext (builtins.unsafeDiscardOutputDependency d.drvPath); key = builtins.elemAt (builtins.attrNames ctx) 0; in ctx.${key} == { path = true; }"+          (VBool True),+      -- ctx4: getContext renders each path's output names ascending.+      runTest "getContext output names are ascending" $+        assertEval+          "getctx-ascending"+          "let d = derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; outputs = [ \"out\" \"dev\" \"lib\" ]; }; s = \"${d.out}${d.lib}${d.dev}\"; ctx = builtins.getContext s; key = builtins.elemAt (builtins.attrNames ctx) 0; in ctx.${key}.outputs == [ \"dev\" \"lib\" \"out\" ]"+          (VBool True),+      -- ctx5: addDrvOutputDependencies upgrades a plain .drv reference back to+      -- an all-outputs reference (the inverse of the ctx3 downgrade).+      runTest "addDrvOutputDependencies upgrades a plain drv ref" $+        assertEval+          "adddrvout-upgrade"+          "let d = derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; plain = builtins.unsafeDiscardOutputDependency d.drvPath; ctx = builtins.getContext (builtins.addDrvOutputDependencies plain); key = builtins.elemAt (builtins.attrNames ctx) 0; in ctx.${key} == { allOutputs = true; }"+          (VBool True),+      runTest "addDrvOutputDependencies rejects a derivation output" $+        assertEvalFail+          "adddrvout-built"+          "let d = derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; in builtins.addDrvOutputDependencies d.outPath",+      runTest "addDrvOutputDependencies rejects a multi-element context" $+        assertEvalFail+          "adddrvout-multi"+          "let d = derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; in builtins.addDrvOutputDependencies \"${d.drvPath}${d.outPath}\"",+      -- drv1: a derivation embedding another's drvPath (an all-outputs ref)+      -- needs the referenced .drv's output names, which only the IO evaluator+      -- supplies; pure eval fails loudly rather than dropping the reference.+      runTest "deep drvPath reference fails loudly in pure eval" $+        assertEvalFail+          "drv1-pure-miss"+          "let dep = derivation { name = \"dep\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; main = derivation { name = \"main\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; ref = dep.drvPath; }; in main.drvPath",+      -- derivation outPath is a string (not path) with context+      runTest "drv outPath is VStr"+        $ assertRight+          "drv-outPath-type"+          (evalNix "builtins.typeOf (derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }).outPath")+        $ \val -> assertEqual "string-type" (mkStr "string") val,+      -- derivation drvPath is a string (not path) with context+      runTest "drv drvPath is VStr"+        $ assertRight+          "drv-drvPath-type"+          (evalNix "builtins.typeOf (derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }).drvPath")+        $ \val -> assertEqual "string-type" (mkStr "string") val,+      -- hasContext error on non-string+      runTest "hasContext type error" $+        assertEvalFail "hasCtx-err" "builtins.hasContext 42",+      -- getContext error on non-string+      runTest "getContext type error" $+        assertEvalFail "getCtx-err" "builtins.getContext 42",+      -- appendContext error on non-string first arg+      runTest "appendContext type error" $+        assertEvalFail "appendCtx-err" "builtins.appendContext 42 {}",+      -- deterministic: same derivation produces same paths+      runTest "derivation with context deterministic"+        $ assertRight+          "drv-det-ctx"+          (evalNix "let d1 = derivation { name = \"a\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; d2 = derivation { name = \"a\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; in d1.outPath == d2.outPath")+        $ \val -> assertEqual "deterministic" (VBool True) val+    ]++-- ---------------------------------------------------------------------------+-- Tests: DependencyGraph (Phase 3, Batch 5)+-- ---------------------------------------------------------------------------++testDepGraph :: IO [Bool]+testDepGraph = do+  putStrLn "dep-graph"+  let mkSP = StorePath+      spA = mkSP "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "a.drv"+      spB = mkSP "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" "b.drv"+      spC = mkSP "cccccccccccccccccccccccccccccccccc" "c.drv"+      spD = mkSP "dddddddddddddddddddddddddddddddd" "d.drv"+      baseDrv =+        Derivation+          { drvOutputs = [],+            drvInputDrvs = Map.empty,+            drvInputSrcs = [],+            drvPlatform = X86_64_Linux,+            drvBuilder = "/bin/sh",+            drvArgs = [],+            drvEnv = Map.empty+          }+      -- Single node: A has no deps+      drvA = baseDrv+      -- Linear chain: B depends on C+      drvB = baseDrv {drvInputDrvs = Map.singleton spC ["out"]}+      -- C has no deps+      drvC = baseDrv+      -- Diamond: D depends on B and C, B depends on C+      drvD = baseDrv {drvInputDrvs = Map.fromList [(spB, ["out"]), (spC, ["out"])]}+      -- Cycle: A depends on B, B depends on A+      drvACycle = baseDrv {drvInputDrvs = Map.singleton spB ["out"]}+      drvBCycle = baseDrv {drvInputDrvs = Map.singleton spA ["out"]}+      readSingle _ = Left "not found"+      readChain sp+        | sp == spC = Right drvC+        | otherwise = Left ("unknown drv: " <> spName sp)+      readDiamond sp+        | sp == spB = Right drvB+        | sp == spC = Right drvC+        | otherwise = Left ("unknown drv: " <> spName sp)+      readCycle sp+        | sp == spB = Right drvBCycle+        | sp == spA = Right drvACycle+        | otherwise = Left ("unknown drv: " <> spName sp)+      -- Self-loop: A depends on itself+      drvSelf = baseDrv {drvInputDrvs = Map.singleton spA ["out"]}+      readSelf sp+        | sp == spA = Right drvSelf+        | otherwise = Left ("unknown drv: " <> spName sp)+  sequence+    [ -- Single node+      runTest "single node graph" $ case DepGraph.buildDepGraph readSingle drvA spA of+        Right (DepGraph.DepGraph g) -> assertEqual "single-size" 1 (Map.size g)+        Left err -> Fail ("unexpected error: " <> err),+      -- Linear chain A to C: topoSort should give [C, A]+      runTest "linear chain topo" $ case DepGraph.buildDepGraph readChain drvB spB of+        Right graph -> case DepGraph.topoSort graph of+          DepGraph.TopoSorted order ->+            case order of+              [first, _] | first == spC -> Pass+              _ -> Fail ("bad order: " <> T.pack (show order))+          DepGraph.TopoCycle cyc -> Fail ("unexpected cycle: " <> T.pack (show cyc))+        Left err -> Fail ("graph build failed: " <> err),+      -- Diamond D to B,C; B to C: topoSort should have C first, D last+      runTest "diamond topo" $ case DepGraph.buildDepGraph readDiamond drvD spD of+        Right graph -> case DepGraph.topoSort graph of+          DepGraph.TopoSorted order ->+            case order of+              [first, _, lastElem] | first == spC, lastElem == spD -> Pass+              _ -> Fail ("bad diamond order: " <> T.pack (show order))+          DepGraph.TopoCycle cyc -> Fail ("unexpected cycle: " <> T.pack (show cyc))+        Left err -> Fail ("graph build failed: " <> err),+      -- transitiveDeps+      runTest "transitiveDeps diamond" $ case DepGraph.buildDepGraph readDiamond drvD spD of+        Right graph ->+          let deps = DepGraph.transitiveDeps graph spD+           in if Set.size deps == 2 && Set.member spB deps && Set.member spC deps+                then Pass+                else Fail ("bad transitive deps: " <> T.pack (show deps))+        Left err -> Fail ("graph build failed: " <> err),+      -- directDeps+      runTest "directDeps diamond" $ case DepGraph.buildDepGraph readDiamond drvD spD of+        Right graph ->+          let deps = DepGraph.directDeps graph spD+           in assertEqual "direct-count" 2 (length deps)+        Left err -> Fail ("graph build failed: " <> err),+      -- Missing .drv causes failure+      runTest "missing drv fails" $ case DepGraph.buildDepGraph readSingle drvB spB of+        Left _ -> Pass+        Right _ -> Fail "expected failure for missing drv",+      -- Single node topoSort+      runTest "single node topoSort" $ case DepGraph.buildDepGraph readSingle drvA spA of+        Right graph -> case DepGraph.topoSort graph of+          DepGraph.TopoSorted [x] -> assertEqual "single-topo" spA x+          other -> Fail ("unexpected topo result: " <> T.pack (show other))+        Left err -> Fail ("graph build failed: " <> err),+      -- buildDepGraph with mock for cycle detection+      -- (Cycle detection happens at topoSort level, not buildDepGraph)+      runTest "cycle detection" $ case DepGraph.buildDepGraph readCycle drvACycle spA of+        Right graph -> case DepGraph.topoSort graph of+          DepGraph.TopoCycle _ -> Pass+          -- A "sorted" cyclic graph means Kahn's silently dropped the+          -- cycle: buildWithDeps would then loop or build with unbuilt+          -- inputs.+          DepGraph.TopoSorted order ->+            Fail ("cyclic graph topo-sorted as " <> T.pack (show (length order)) <> " nodes")+        -- A loud rejection at graph-build time also counts as detection.+        Left _ -> Pass,+      -- A root that depends on itself: the walk marks the root visited+      -- on enqueue, so it terminates - and per contract the result+      -- excludes the root, leaving nothing.+      runTestM "transitiveDeps terminates on a self-loop" $ do+        outcome <-+          timeout walkWatchdogMicros $+            evaluate $ case DepGraph.buildDepGraph readSelf drvSelf spA of+              Left _ -> Nothing+              Right graph -> Just $! DepGraph.transitiveDeps graph spA+        pure $ case outcome of+          Nothing -> Fail "did not terminate on a self-loop"+          Just Nothing -> Fail "graph build failed"+          Just (Just deps) -> assertEqual "self-loop deps" Set.empty deps,+      -- A two-node cycle already terminated; pin that the root stays+      -- excluded from its own transitive closure.+      runTestM "transitiveDeps two-node cycle excludes the root" $ do+        outcome <-+          timeout walkWatchdogMicros $+            evaluate $ case DepGraph.buildDepGraph readCycle drvACycle spA of+              Left _ -> Nothing+              Right graph -> Just $! DepGraph.transitiveDeps graph spA+        pure $ case outcome of+          Nothing -> Fail "did not terminate on a cycle"+          Just Nothing -> Fail "graph build failed"+          Just (Just deps) -> assertEqual "cycle deps" (Set.singleton spB) deps+    ]++-- ---------------------------------------------------------------------------+-- Tests: Substituter (Phase 3, Batch 6)+-- ---------------------------------------------------------------------------++-- | A fake HTTP body reader: yields the given chunks, then empty+-- forever (an HTTP BodyReader is just an IO ByteString).+chunkReader :: [BS.ByteString] -> IO (IO BS.ByteString)+chunkReader chunks = do+  ref <- newIORef chunks+  pure $+    atomicModifyIORef' ref $ \case+      [] -> ([], BS.empty)+      (c : cs) -> (cs, c)++-- | Drain a chunk source to its byte count - the shape of a streaming+-- consumer, for tests that only care whether the pipeline fails.+drainChunkSource :: IO BS.ByteString -> IO (Either Subst.AttemptFailure Int)+drainChunkSource pull = go 0+  where+    go n = do+      chunk <- pull+      if BS.null chunk+        then pure (Right n)+        else let total = n + BS.length chunk in total `seq` go total++testSubstituter :: IO [Bool]+testSubstituter = do+  putStrLn "substituter"+  sequence+    [ -- sortCaches: priority ordering+      runTest "sortCaches priority ordering" $+        let c1 = Subst.CacheConfig "https://a.example.com" ["key-a"] 40+            c2 = Subst.CacheConfig "https://b.example.com" ["key-b"] 10+            c3 = Subst.CacheConfig "https://c.example.com" ["key-c"] 30+            sorted = Subst.sortCaches [c1, c2, c3]+         in case sorted of+              [s1, s2, s3] ->+                if Subst.ccPriority s1 == 10 && Subst.ccPriority s2 == 30 && Subst.ccPriority s3 == 40+                  then Pass+                  else Fail ("bad order: " <> T.pack (show (map Subst.ccPriority sorted)))+              _ -> Fail "expected 3 caches",+      -- sortCaches: empty list+      runTest "sortCaches empty" $+        assertEqual "empty-sort" [] (Subst.sortCaches []),+      -- readBodyCapped: the client-side body cap (mirror of the server's+      -- readBodyLimited).  A body reader is just IO ByteString yielding+      -- chunks then empty.+      runTestM "readBodyCapped concatenates under the cap" $ do+        reader <- chunkReader ["abc", "def", "g"]+        body <- Subst.readBodyCapped 10 reader+        pure (assertEqual "under cap" (Just "abcdefg") body),+      runTestM "readBodyCapped allows exactly the cap" $ do+        reader <- chunkReader ["abcde", "fghij"]+        body <- Subst.readBodyCapped 10 reader+        pure (assertEqual "at cap" (Just "abcdefghij") body),+      runTestM "readBodyCapped aborts past the cap" $ do+        reader <- chunkReader ["abcdef", "ghijkl"]+        body <- Subst.readBodyCapped 10 reader+        pure (assertEqual "over cap" Nothing body),+      -- decompressorFor decides support from the narinfo's declared+      -- values alone, so unsupported compression rejects before any+      -- NAR download; xz resolves a bounded decompressor.+      runTest "decompressorFor xz resolves" $+        case Subst.decompressorFor 1024 "xz" of+          Right _ -> Pass+          Left err -> Fail ("xz rejected: " <> err),+      runTestM "decompressorFor none is identity" $+        case Subst.decompressorFor 5 "none" of+          Right decompress -> assertEqual "identity" (Right "bytes") <$> decompress "bytes"+          Left err -> pure (Fail ("expected none to be supported, got: " <> err)),+      -- verifyNarSize: the declared NarSize is a signed claim that flows+      -- into the store DB, so it must equal the downloaded byte count.+      runTest "verifyNarSize accepts matching size" $+        assertEqual+          "narsize-match"+          (Right ())+          (Subst.verifyNarSize ((sampleNarInfo sampleNarHash) {NarInfo.niNarSize = toInteger (BS.length sampleNarBytes)}) sampleNarBytes),+      runTest "verifyNarSize rejects mismatched size" $+        case Subst.verifyNarSize ((sampleNarInfo sampleNarHash) {NarInfo.niNarSize = toInteger (BS.length sampleNarBytes) + 1}) sampleNarBytes of+          Left err | "size mismatch" `T.isInfixOf` err -> Pass+          other -> Fail ("expected size mismatch, got: " <> T.pack (show other)),+      -- decompressNar: "none" passes through+      runTestM "decompressNar none" $+        let input = "fake nar data"+         in assertEqual "decompress-none" (Right input) <$> Subst.decompressNar (toInteger (BS.length input)) "none" input,+      -- decompressNar: an empty Compression field means bzip2 upstream+      -- (the field's historical default), never identity, so the empty+      -- spelling must decode a real bzip2 body exactly as the named+      -- one does.+      runTestM "decompressNar empty means bzip2" $+        case B64.decode bzip2FixtureB64 of+          Left err -> pure (Fail ("fixture base64 does not decode: " <> T.pack err))+          Right compressed -> do+            out <- Subst.decompressNar xzFixtureSize "" compressed+            pure $ case out of+              Right decoded -> assertEqual "empty-is-bzip2" xzFixturePayload decoded+              Left err -> Fail ("empty Compression failed to decode as bzip2: " <> err),+      -- decompressNar: bzip2 decodes under the same declared-NarSize+      -- bound as the other codecs; the fixture comes from the bzip2+      -- CLI, so the decoder is checked against the format's own+      -- reference encoder rather than its own library.+      runTestM "decompressNar bzip2 bounded roundtrip" $+        case B64.decode bzip2FixtureB64 of+          Left err -> pure (Fail ("fixture base64 does not decode: " <> T.pack err))+          Right compressed -> do+            out <- Subst.decompressNar xzFixtureSize "bzip2" compressed+            pure $ case out of+              Right decoded -> assertEqual "bzip2-roundtrip" xzFixturePayload decoded+              Left err -> Fail ("bzip2 roundtrip failed: " <> err),+      runTestM "decompressNar bzip2 over-bound rejects" $+        case B64.decode bzip2FixtureB64 of+          Left err -> pure (Fail ("fixture base64 does not decode: " <> T.pack err))+          Right compressed -> do+            out <- Subst.decompressNar (xzFixtureSize - 1) "bzip2" compressed+            pure $ case out of+              Left err | "NarSize" `T.isInfixOf` err -> Pass+              other -> Fail ("expected over-bound rejection, got: " <> T.pack (show other)),+      -- decompressNar: xz decodes under the declared-NarSize bound+      -- (the fixture is a real 112-byte xz stream of 1792 payload+      -- bytes, embedded base64 to keep the source ASCII).+      runTestM "decompressNar xz bounded roundtrip" $+        case B64.decode xzFixtureB64 of+          Left err -> pure (Fail ("fixture base64 does not decode: " <> T.pack err))+          Right compressed -> do+            out <- Subst.decompressNar xzFixtureSize "xz" compressed+            pure $ case out of+              Right decoded -> assertEqual "xz-roundtrip" xzFixturePayload decoded+              Left err -> Fail ("xz roundtrip failed: " <> err),+      -- The output bound is exact: one byte under the real size must+      -- refuse, naming the declared NarSize.+      runTestM "decompressNar xz over-bound rejects" $+        case B64.decode xzFixtureB64 of+          Left err -> pure (Fail ("fixture base64 does not decode: " <> T.pack err))+          Right compressed -> do+            out <- Subst.decompressNar (xzFixtureSize - 1) "xz" compressed+            pure $ case out of+              Left err | "NarSize" `T.isInfixOf` err -> Pass+              other -> Fail ("expected over-bound rejection, got: " <> T.pack (show other)),+      -- decompressNar: bytes that are not an xz stream are an error,+      -- never silently passed through+      runTestM "decompressNar xz garbage rejects" $ do+        out <- Subst.decompressNar 64 "xz" "not an xz stream"+        pure $ case out of+          Left _ -> Pass+          Right _ -> Fail "garbage decoded as xz",+      -- decompressNar: zstd decodes under the same declared-NarSize+      -- bound; the fixture compresses with the sublibrary's own+      -- encoder, the same pairing the push path ships.+      runTestM "decompressNar zstd bounded roundtrip" $ do+        out <- Subst.decompressNar xzFixtureSize "zstd" zstdFixtureCompressed+        pure $ case out of+          Right decoded -> assertEqual "zstd-roundtrip" xzFixturePayload decoded+          Left err -> Fail ("zstd roundtrip failed: " <> err),+      runTestM "decompressNar zstd over-bound rejects" $ do+        out <- Subst.decompressNar (xzFixtureSize - 1) "zstd" zstdFixtureCompressed+        pure $ case out of+          Left err | "NarSize" `T.isInfixOf` err -> Pass+          other -> Fail ("expected over-bound rejection, got: " <> T.pack (show other)),+      -- decompressNar: unknown compression+      runTestM "decompressNar unknown" $ do+        out <- Subst.decompressNar 4 "brotli" "data"+        pure $ case out of+          Left _ -> Pass+          Right _ -> Fail "expected error for unknown",+      -- parseReferences: narinfo references are wire-format basenames+      runTest "parseReferences basenames" $+        let refs = ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-dep-2.0"]+            expected =+              [ StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "hello",+                StorePath "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" "dep-2.0"+              ]+         in assertEqual "parse-refs" (Right expected) (Subst.parseReferences refs),+      -- parseReferences: a malformed token is a loud error, never dropped+      runTest "parseReferences malformed rejected" $+        case Subst.parseReferences ["not-a-store-path"] of+          Left _ -> Pass+          Right refs -> Fail ("expected rejection, got: " <> T.pack (show refs)),+      -- parseDeriver: basename becomes the DB's full path text+      runTest "parseDeriver basename" $+        let sp = StorePath "cccccccccccccccccccccccccccccccc" "hello.drv"+            expected = Just (T.pack (storePathToFilePath defaultStoreDir sp))+         in assertEqual "parse-deriver" (Right expected) (Subst.parseDeriver defaultStoreDir (Just "cccccccccccccccccccccccccccccccc-hello.drv")),+      -- parseDeriver: upstream's unknown-deriver sentinel means no deriver+      runTest "parseDeriver unknown sentinel" $+        assertEqual "parse-deriver-unknown" (Right Nothing) (Subst.parseDeriver defaultStoreDir (Just "unknown-deriver")),+      -- parseDeriver: absent stays absent, malformed is an error+      runTest "parseDeriver absent and malformed" $+        case (Subst.parseDeriver defaultStoreDir Nothing, Subst.parseDeriver defaultStoreDir (Just "bogus")) of+          (Right Nothing, Left _) -> Pass+          other -> Fail ("unexpected: " <> T.pack (show other)),+      -- Recorded production narinfos from cache.nixos.org (paths this+      -- codebase did not produce): the parse -> validate -> signature+      -- pipeline against real cache data, the production ed25519+      -- signature verified offline with the shipped public key.+      runTest "recorded cache.nixos.org narinfo verifies end to end" $+        case NarInfo.parseNarInfo recordedHelloNarInfo of+          Left err -> Fail ("parse failed: " <> T.pack err)+          Right ni ->+            case Subst.validateNarInfoFields ni >> Subst.verifySigs Subst.defaultCacheConfig ni of+              Left err -> Fail ("validate/verify failed: " <> err)+              Right () -> assertEqual "compression" "xz" (NarInfo.niCompression ni),+      -- References of a path built by Hydra, including the self+      -- reference, and its Deriver - both produced by upstream Nix.+      runTest "recorded narinfo references and deriver parse" $+        case NarInfo.parseNarInfo recordedHelloNarInfo of+          Left err -> Fail ("parse failed: " <> T.pack err)+          Right ni ->+            case (Subst.parseReferences (NarInfo.niReferences ni), Subst.parseDeriver defaultStoreDir (NarInfo.niDeriver ni)) of+              (Right refs, Right (Just _)) ->+                assertEqual+                  "reference hashes"+                  ["dska0s3gxxd8azbsn85pwm6xcqhivldw", "jppkr0h8aap5z7m4xy4vg5yrwlly9h2v"]+                  (map spHash refs)+              other -> Fail ("references/deriver did not parse: " <> T.pack (show other)),+      -- The empty References line ("References: " with a trailing+      -- space) production caches emit for leaf paths.+      runTest "recorded no-reference narinfo verifies" $+        case NarInfo.parseNarInfo recordedHelloDocNarInfo of+          Left err -> Fail ("parse failed: " <> T.pack err)+          Right ni ->+            case Subst.validateNarInfoFields ni >> Subst.verifySigs Subst.defaultCacheConfig ni >> Subst.parseReferences (NarInfo.niReferences ni) of+              Right [] -> Pass+              other -> Fail ("expected verified empty references, got: " <> T.pack (show other)),+      -- The fingerprint covers NarSize: altering the signed claim must+      -- break the production signature.+      runTest "tampered recorded narinfo fails signature" $+        case NarInfo.parseNarInfo recordedHelloNarInfo of+          Left err -> Fail ("parse failed: " <> T.pack err)+          Right ni ->+            case Subst.verifySigs Subst.defaultCacheConfig (ni {NarInfo.niNarSize = NarInfo.niNarSize ni + 1}) of+              Left _ -> Pass+              Right () -> Fail "signature verified over an altered NarSize",+      -- Streaming pipeline: the chunk-fed download materializes,+      -- hashes, and verifies without ever holding the NAR.  The tree+      -- carries the interesting shapes: nesting, an executable, a+      -- symlink, and a sibling pair colliding on folding filesystems -+      -- the digest equality proves the materialized tree re-serialises+      -- to its NAR on every platform strategy.+      -- Every length word, tag, and padding run must survive splitting+      -- across Await boundaries, so the whole materialization runs at+      -- several chunk sizes including single-byte feeds.+      runTestM "streaming unpack materializes and verifies across chunk sizes" $ do+        tmpBase <- getTemporaryDirectory+        let tmpDir = tmpBase </> "nova-nix-test-stream-unpack"+            chunkSizes = [1, 7, 8, 11] :: [Int]+            runAt n = do+              let dest = tmpDir </> ("out-" <> show n)+              source <- chunkReader (streamChunks n streamTestNar)+              result <- Subst.consumeNarStream dest (streamTestNarInfo streamTestNar) streamTestDigest source+              onDisk <- ExecBit.serialiseFromPath dest+              pure $ case result of+                Left err ->+                  Just ("chunk size " <> T.pack (show n) <> ": " <> Subst.attemptFailureMessage err)+                Right narByteCount+                  | narByteCount == BS.length streamTestNar+                      && CHash.hashBytes (NAR.serialise onDisk) == streamTestDigest ->+                      Nothing+                  | otherwise ->+                      Just ("chunk size " <> T.pack (show n) <> ": streamed tree diverges from its NAR")+        forceRemoveIfExists tmpDir+        createDirectoryIfMissing True tmpDir+        outcomes <- mapM runAt chunkSizes+        forceRemoveIfExists tmpDir+        pure $ case catMaybes outcomes of+          [] -> Pass+          (msg : _) -> Fail msg,+      -- A truncated stream is a loud parse failure, never a short+      -- tree - and it retries: truncation and a torn transfer+      -- parse-fail the same way.+      runTestM "streaming unpack refuses a truncated stream as transient" $ do+        tmpBase <- getTemporaryDirectory+        let tmpDir = tmpBase </> "nova-nix-test-stream-trunc"+        forceRemoveIfExists tmpDir+        createDirectoryIfMissing True tmpDir+        source <- chunkReader (streamChunks 7 (BS.take (BS.length streamTestNar - 10) streamTestNar))+        result <- Subst.consumeNarStream (tmpDir </> "out") (streamTestNarInfo streamTestNar) streamTestDigest source+        forceRemoveIfExists tmpDir+        pure $ case result of+          Left (Subst.TransientFailure _) -> Pass+          Left (Subst.FatalFailure err) -> Fail ("truncation classified fatal: " <> err)+          Right _ -> Fail "truncated NAR stream was accepted",+      -- A digest mismatch reports before the tree is trusted, and it+      -- is fatal: the size matched, so the transfer completed.+      runTestM "streaming unpack refuses a digest mismatch as fatal" $ do+        tmpBase <- getTemporaryDirectory+        let tmpDir = tmpBase </> "nova-nix-test-stream-digest"+        forceRemoveIfExists tmpDir+        createDirectoryIfMissing True tmpDir+        source <- chunkReader (streamChunks 7 streamTestNar)+        result <- Subst.consumeNarStream (tmpDir </> "out") (streamTestNarInfo streamTestNar) (CHash.hashBytes "not the nar") source+        forceRemoveIfExists tmpDir+        pure $ case result of+          Left (Subst.FatalFailure err) | "hash mismatch" `T.isInfixOf` err -> Pass+          Left other -> Fail ("expected fatal hash mismatch, got: " <> Subst.attemptFailureMessage other)+          Right _ -> Fail "digest mismatch was accepted",+      -- A narinfo lying about NarSize in either direction is refused+      -- at NarDone - the grammar completed, so the mismatch is the+      -- narinfo misdeclaring, and fatal.+      runTestM "streaming unpack refuses an overdeclared NarSize" $ do+        tmpBase <- getTemporaryDirectory+        let tmpDir = tmpBase </> "nova-nix-test-stream-oversize"+            lying = (streamTestNarInfo streamTestNar) {NarInfo.niNarSize = toInteger (BS.length streamTestNar) + 1}+        forceRemoveIfExists tmpDir+        createDirectoryIfMissing True tmpDir+        source <- chunkReader (streamChunks 7 streamTestNar)+        result <- Subst.consumeNarStream (tmpDir </> "out") lying streamTestDigest source+        forceRemoveIfExists tmpDir+        pure $ case result of+          Left (Subst.FatalFailure err) | "size mismatch" `T.isInfixOf` err -> Pass+          Left other -> Fail ("expected fatal size mismatch, got: " <> Subst.attemptFailureMessage other)+          Right _ -> Fail "overdeclared NarSize was accepted",+      runTestM "streaming unpack refuses an underdeclared NarSize" $ do+        tmpBase <- getTemporaryDirectory+        let tmpDir = tmpBase </> "nova-nix-test-stream-undersize"+            lying = (streamTestNarInfo streamTestNar) {NarInfo.niNarSize = toInteger (BS.length streamTestNar) - 1}+        forceRemoveIfExists tmpDir+        createDirectoryIfMissing True tmpDir+        source <- chunkReader (streamChunks 7 streamTestNar)+        result <- Subst.consumeNarStream (tmpDir </> "out") lying streamTestDigest source+        forceRemoveIfExists tmpDir+        pure $ case result of+          Left (Subst.FatalFailure err) | "size mismatch" `T.isInfixOf` err -> Pass+          Left other -> Fail ("expected fatal size mismatch, got: " <> Subst.attemptFailureMessage other)+          Right _ -> Fail "underdeclared NarSize was accepted",+      -- cappedBodySource: the streaming mirror of readBodyCapped -+      -- a body past the key-trusted declared size aborts mid-stream.+      runTestM "cappedBodySource aborts past the cap" $ do+        reader <- chunkReader ["abcdef", "ghijkl"]+        source <- Subst.cappedBodySource 10 reader+        firstChunk <- source+        overCap <- try source :: IO (Either SomeException BS.ByteString)+        pure $ case (firstChunk, overCap) of+          ("abcdef", Left _) -> Pass+          other -> Fail ("expected abort past the cap, got: " <> T.pack (show other)),+      -- withDecompressedSource: xz chunks decompress under the+      -- declared bound; the support decision mirrors decompressorFor.+      runTestM "withDecompressedSource xz chunked roundtrip" $+        case B64.decode xzFixtureB64 of+          Left err -> pure (Fail ("fixture base64 does not decode: " <> T.pack err))+          Right compressed -> do+            source <- chunkReader (streamChunks 7 compressed)+            result <- Subst.withDecompressedSource xzFixtureSize "xz" source $ \pull ->+              let go acc = do+                    chunk <- pull+                    if BS.null chunk+                      then pure (Right (BS.concat (reverse acc)))+                      else go (chunk : acc)+               in go []+            pure $ case result of+              Right out | out == xzFixturePayload -> Pass+              other -> Fail ("xz source roundtrip diverged: " <> T.pack (show (fmap BS.length other))),+      runTestM "withDecompressedSource zstd chunked roundtrip" $ do+        source <- chunkReader (streamChunks 7 zstdFixtureCompressed)+        result <- Subst.withDecompressedSource xzFixtureSize "zstd" source $ \pull ->+          let go acc = do+                chunk <- pull+                if BS.null chunk+                  then pure (Right (BS.concat (reverse acc)))+                  else go (chunk : acc)+           in go []+        pure $ case result of+          Right out | out == xzFixturePayload -> Pass+          other -> Fail ("zstd source roundtrip diverged: " <> T.pack (show (fmap BS.length other))),+      runTestM "withDecompressedSource xz single-byte chunks" $+        case B64.decode xzFixtureB64 of+          Left err -> pure (Fail ("fixture base64 does not decode: " <> T.pack err))+          Right compressed -> do+            source <- chunkReader (streamChunks 1 compressed)+            result <- Subst.withDecompressedSource xzFixtureSize "xz" source $ \pull ->+              let go acc = do+                    chunk <- pull+                    if BS.null chunk+                      then pure (Right (BS.concat (reverse acc)))+                      else go (chunk : acc)+               in go []+            pure $ case result of+              Right out | out == xzFixturePayload -> Pass+              other -> Fail ("xz 1-byte roundtrip diverged: " <> T.pack (show (fmap BS.length other))),+      -- The LIVE pipeline's defenses, not the strict oracle's: hostile+      -- compressed input through withDecompressedSource must land in+      -- the Left channel with the right retry class, never escape as+      -- an exception.+      runTestM "withDecompressedSource xz over-bound is fatal" $+        case B64.decode xzFixtureB64 of+          Left err -> pure (Fail ("fixture base64 does not decode: " <> T.pack err))+          Right compressed -> do+            source <- chunkReader (streamChunks 7 compressed)+            result <- Subst.withDecompressedSource (xzFixtureSize - 1) "xz" source drainChunkSource+            pure $ case result of+              Left (Subst.FatalFailure err) | "NarSize" `T.isInfixOf` err -> Pass+              other -> Fail ("expected fatal over-bound, got: " <> T.pack (show (void other))),+      runTestM "withDecompressedSource xz garbage is transient" $ do+        source <- chunkReader ["not an xz stream"]+        result <- Subst.withDecompressedSource 64 "xz" source drainChunkSource+        pure $ case result of+          Left (Subst.TransientFailure _) -> Pass+          other -> Fail ("expected transient stream error, got: " <> T.pack (show (void other))),+      runTestM "withDecompressedSource xz truncated is transient" $+        case B64.decode xzFixtureB64 of+          Left err -> pure (Fail ("fixture base64 does not decode: " <> T.pack err))+          Right compressed -> do+            source <- chunkReader (streamChunks 7 (BS.take 40 compressed))+            result <- Subst.withDecompressedSource xzFixtureSize "xz" source drainChunkSource+            pure $ case result of+              Left (Subst.TransientFailure _) -> Pass+              other -> Fail ("expected transient truncation, got: " <> T.pack (show (void other))),+      -- The failure contract of the post-download pipeline: every+      -- failing materialization removes the tree it wrote, and a+      -- clean one registers the verified metadata.+      runTestM "materialize registers a verified tree" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-materialize-ok"+        forceRemoveIfExists tmpStore+        createDirectoryIfMissing True tmpStore+        store <- openStore (StoreDir tmpStore)+        let sp = StorePath sampleHash "stream"+            destPath = storePathToFilePath (StoreDir tmpStore) sp+        source <- chunkReader (streamChunks 7 streamTestNar)+        result <- Subst.materializeNarFromSource store sp (streamTestNarInfo streamTestNar) streamTestDigest [] Nothing source+        survived <- doesDirectoryExist destPath+        closeStore store+        forceRemoveIfExists tmpStore+        pure $ case result of+          Left err -> Fail ("materialize failed: " <> Subst.attemptFailureMessage err)+          Right reg+            | not survived -> Fail "verified tree missing after materialize"+            | prNarSize reg /= BS.length streamTestNar -> Fail "registration NarSize diverges"+            | prNarHash reg /= CHash.formatNixHash streamTestDigest -> Fail "registration NarHash diverges"+            | otherwise -> Pass,+      runTestM "materialize removes the tree on a digest mismatch" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-materialize-digest"+        forceRemoveIfExists tmpStore+        createDirectoryIfMissing True tmpStore+        store <- openStore (StoreDir tmpStore)+        let sp = StorePath sampleHash "stream"+            destPath = storePathToFilePath (StoreDir tmpStore) sp+        source <- chunkReader (streamChunks 7 streamTestNar)+        result <- Subst.materializeNarFromSource store sp (streamTestNarInfo streamTestNar) (CHash.hashBytes "not the nar") [] Nothing source+        survived <- doesDirectoryExist destPath+        closeStore store+        forceRemoveIfExists tmpStore+        pure $ case result of+          Right _ -> Fail "digest mismatch was accepted"+          Left (Subst.TransientFailure err) -> Fail ("mismatch classified transient: " <> err)+          Left (Subst.FatalFailure err)+            | not ("hash mismatch" `T.isInfixOf` err) -> Fail ("unexpected failure: " <> err)+            | survived -> Fail "tree survived a digest mismatch"+            | otherwise -> Pass,+      runTestM "materialize removes the tree on a truncated stream" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-materialize-trunc"+        forceRemoveIfExists tmpStore+        createDirectoryIfMissing True tmpStore+        store <- openStore (StoreDir tmpStore)+        let sp = StorePath sampleHash "stream"+            destPath = storePathToFilePath (StoreDir tmpStore) sp+        source <- chunkReader (streamChunks 7 (BS.take (BS.length streamTestNar - 10) streamTestNar))+        result <- Subst.materializeNarFromSource store sp (streamTestNarInfo streamTestNar) streamTestDigest [] Nothing source+        survived <- doesDirectoryExist destPath+        closeStore store+        forceRemoveIfExists tmpStore+        pure $ case result of+          Right _ -> Fail "truncated stream was accepted"+          Left (Subst.FatalFailure err) -> Fail ("truncation classified fatal: " <> err)+          Left (Subst.TransientFailure _)+            | survived -> Fail "tree survived a truncated stream"+            | otherwise -> Pass,+      -- Retry classification: server-side statuses retry, a 404 on an+      -- object the narinfo just promised is deterministic.+      runTest "httpStatusFailure classes" $+        case (Subst.httpStatusFailure 404, Subst.httpStatusFailure 500, Subst.httpStatusFailure 429, Subst.httpStatusFailure 403) of+          (Subst.FatalFailure _, Subst.TransientFailure _, Subst.TransientFailure _, Subst.FatalFailure _) -> Pass+          other -> Fail ("unexpected status classes: " <> T.pack (show other)),+      -- The download cap derives from the SIGNED NarSize; the unsigned+      -- FileSize may only lower it, never raise it.+      runTest "downloadCapFor lets FileSize lower but never raise the cap" $+        let base = streamTestNarInfo streamTestNar+            narSize = NarInfo.niNarSize base+            ceilingCap = Subst.compressedBodyCeiling narSize+            absent = Subst.downloadCapFor base {NarInfo.niFileSize = Nothing}+            lowered = Subst.downloadCapFor base {NarInfo.niFileSize = Just 100}+            inflated = Subst.downloadCapFor base {NarInfo.niFileSize = Just (ceilingCap * 1000)}+         in case (absent, lowered, inflated) of+              (Right capAbsent, Right capLowered, Right capInflated)+                | toInteger capAbsent == ceilingCap+                    && capLowered == 100+                    && toInteger capInflated == ceilingCap ->+                    Pass+              other -> Fail ("unexpected caps: " <> T.pack (show other)),+      runTest "compressedBodyCeiling floors small and scales large" $+        let smallCeiling = Subst.compressedBodyCeiling 10+            largeSize = 64 * 1024 * 1024+            largeCeiling = Subst.compressedBodyCeiling largeSize+         in if smallCeiling == 10 + 64 * 1024 && largeCeiling == largeSize + largeSize `div` 64+              then Pass+              else Fail ("unexpected ceilings: " <> T.pack (show (smallCeiling, largeCeiling))),+      -- The zstd mirror of the xz hostile trio: over-bound output is+      -- deterministic, while a corrupt frame and a frame cut off+      -- mid-way both retry - the codec judges end of input by+      -- libzstd's own frame-boundary signal, so truncation refuses+      -- here instead of yielding a short output.+      runTestM "withDecompressedSource zstd over-bound is fatal" $ do+        source <- chunkReader (streamChunks 7 zstdFixtureCompressed)+        result <- Subst.withDecompressedSource (xzFixtureSize - 1) "zstd" source drainChunkSource+        pure $ case result of+          Left (Subst.FatalFailure err) | "NarSize" `T.isInfixOf` err -> Pass+          other -> Fail ("expected fatal over-bound, got: " <> T.pack (show (void other))),+      runTestM "withDecompressedSource zstd garbage is transient" $ do+        source <- chunkReader ["not a zstd frame"]+        result <- Subst.withDecompressedSource 64 "zstd" source drainChunkSource+        pure $ case result of+          Left (Subst.TransientFailure _) -> Pass+          other -> Fail ("expected transient stream error, got: " <> T.pack (show (void other))),+      runTestM "withDecompressedSource zstd truncated is transient" $ do+        source <- chunkReader (streamChunks 7 (BS.take (BS.length zstdFixtureCompressed - 8) zstdFixtureCompressed))+        result <- Subst.withDecompressedSource xzFixtureSize "zstd" source drainChunkSource+        pure $ case result of+          Left (Subst.TransientFailure _) -> Pass+          other -> Fail ("expected transient truncation, got: " <> T.pack (show (void other))),+      -- The codec refuses a truncated frame, and the whole pipeline+      -- must carry that refusal into the Left channel as retryable+      -- rather than let a short NAR reach the store.+      runTestM "zstd truncated body fails the pipeline as transient" $ do+        tmpBase <- getTemporaryDirectory+        let tmpDir = tmpBase </> "nova-nix-test-zstd-trunc"+            compressedNar = CZstd.compress CZstd.defaultCompressionLevel streamTestNar+        forceRemoveIfExists tmpDir+        createDirectoryIfMissing True tmpDir+        source <- chunkReader (streamChunks 7 (BS.take (BS.length compressedNar - 8) compressedNar))+        result <-+          Subst.withDecompressedSource (toInteger (BS.length streamTestNar)) "zstd" source $+            Subst.consumeNarStream (tmpDir </> "out") ((streamTestNarInfo streamTestNar) {NarInfo.niCompression = "zstd"}) streamTestDigest+        forceRemoveIfExists tmpDir+        pure $ case result of+          Left (Subst.TransientFailure _) -> Pass+          Left (Subst.FatalFailure err) -> Fail ("zstd truncation classified fatal: " <> err)+          Right _ -> Fail "truncated zstd body was accepted",+      -- The bzip2 mirror of the xz hostile trio, over a fixture the+      -- bzip2 CLI produced: over-bound output is deterministic, while+      -- garbage and a truncated stream retry.+      runTestM "withDecompressedSource bzip2 chunked roundtrip" $+        case B64.decode bzip2FixtureB64 of+          Left err -> pure (Fail ("fixture base64 does not decode: " <> T.pack err))+          Right compressed -> do+            source <- chunkReader (streamChunks 7 compressed)+            result <- Subst.withDecompressedSource xzFixtureSize "bzip2" source $ \pull ->+              let go acc = do+                    chunk <- pull+                    if BS.null chunk+                      then pure (Right (BS.concat (reverse acc)))+                      else go (chunk : acc)+               in go []+            pure $ case result of+              Right out | out == xzFixturePayload -> Pass+              other -> Fail ("bzip2 source roundtrip diverged: " <> T.pack (show (fmap BS.length other))),+      runTestM "withDecompressedSource bzip2 over-bound is fatal" $+        case B64.decode bzip2FixtureB64 of+          Left err -> pure (Fail ("fixture base64 does not decode: " <> T.pack err))+          Right compressed -> do+            source <- chunkReader (streamChunks 7 compressed)+            result <- Subst.withDecompressedSource (xzFixtureSize - 1) "bzip2" source drainChunkSource+            pure $ case result of+              Left (Subst.FatalFailure err) | "NarSize" `T.isInfixOf` err -> Pass+              other -> Fail ("expected fatal over-bound, got: " <> T.pack (show (void other))),+      runTestM "withDecompressedSource bzip2 garbage is transient" $ do+        source <- chunkReader ["not a bzip2 stream"]+        result <- Subst.withDecompressedSource 64 "bzip2" source drainChunkSource+        pure $ case result of+          Left (Subst.TransientFailure _) -> Pass+          other -> Fail ("expected transient stream error, got: " <> T.pack (show (void other))),+      runTestM "withDecompressedSource bzip2 truncated is transient" $+        case B64.decode bzip2FixtureB64 of+          Left err -> pure (Fail ("fixture base64 does not decode: " <> T.pack err))+          Right compressed -> do+            source <- chunkReader (streamChunks 7 (BS.take (BS.length compressed - 8) compressed))+            result <- Subst.withDecompressedSource xzFixtureSize "bzip2" source drainChunkSource+            pure $ case result of+              Left (Subst.TransientFailure _) -> Pass+              other -> Fail ("expected transient truncation, got: " <> T.pack (show (void other))),+      -- A bzip2-compressed NAR through the whole post-download+      -- pipeline: the format the historical caches serve decompresses,+      -- hashes, parses, and materializes in one bounded pass.+      runTestM "bzip2 NAR streams through the pipeline" $+        case B64.decode bzip2NarFixtureB64 of+          Left err -> pure (Fail ("fixture base64 does not decode: " <> T.pack err))+          Right compressed -> do+            tmpBase <- getTemporaryDirectory+            let tmpDir = tmpBase </> "nova-nix-test-bzip2-pipeline"+                destPath = tmpDir </> "out"+            forceRemoveIfExists tmpDir+            createDirectoryIfMissing True tmpDir+            source <- chunkReader (streamChunks 7 compressed)+            result <-+              Subst.withDecompressedSource (toInteger (BS.length bzip2NarFixture)) "bzip2" source $+                Subst.consumeNarStream+                  destPath+                  ((streamTestNarInfo bzip2NarFixture) {NarInfo.niCompression = "bzip2"})+                  (CHash.hashBytes bzip2NarFixture)+            materialized <- Dir.doesFileExist (destPath </> "greeting")+            forceRemoveIfExists tmpDir+            pure $ case result of+              Left err -> Fail ("bzip2 pipeline failed: " <> Subst.attemptFailureMessage err)+              Right narBytes+                | narBytes /= BS.length bzip2NarFixture -> Fail "bzip2 pipeline counted the wrong NAR size"+                | not materialized -> Fail "bzip2 pipeline left no tree at the destination"+                | otherwise -> Pass,+      runTest "streaming compression support matches the strict set" $+        case (Subst.streamingDecompressionSupported "xz", Subst.streamingDecompressionSupported "zstd", Subst.streamingDecompressionSupported "bzip2", Subst.streamingDecompressionSupported "brotli") of+          (Right (), Right (), Right (), Left _) -> Pass+          other -> Fail ("unexpected support set: " <> T.pack (show other)),+      -- The support set is encoded twice - the strict decompressor and+      -- the streaming decision - so their agreement is pinned across+      -- the codecs either could plausibly grow, not left to comments.+      runTest "strict and streaming support sets agree" $+        let agrees compression =+              case (Subst.decompressorFor 1 compression, Subst.streamingDecompressionSupported compression) of+                (Right _, Right ()) -> True+                (Left _, Left _) -> True+                _ -> False+         in if all agrees ["none", "", "xz", "zstd", "bzip2", "brotli"]+              then Pass+              else Fail "strict and streaming compression support drifted",+      -- The strict path is the streaming path's differential oracle:+      -- the same NAR materialized by both must produce the same tree.+      runTestM "strict and streaming unpack agree" $ do+        tmpBase <- getTemporaryDirectory+        let tmpDir = tmpBase </> "nova-nix-test-stream-oracle"+        forceRemoveIfExists tmpDir+        createDirectoryIfMissing True tmpDir+        strictOutcome <- case NAR.deserialise streamTestNar of+          Left err -> pure (Left (T.pack err))+          Right entry -> Subst.unpackNarEntry (tmpDir </> "strict") entry+        source <- chunkReader (streamChunks 11 streamTestNar)+        streamOutcome <- Subst.consumeNarStream (tmpDir </> "streamed") (streamTestNarInfo streamTestNar) streamTestDigest source+        strictTree <- NAR.serialiseFromPath (tmpDir </> "strict")+        streamedTree <- NAR.serialiseFromPath (tmpDir </> "streamed")+        forceRemoveIfExists tmpDir+        pure $ case (strictOutcome, streamOutcome) of+          (Right (), Right _)+            | NAR.serialise strictTree == NAR.serialise streamedTree -> Pass+            | otherwise -> Fail "strict and streaming trees diverge"+          other -> Fail ("oracle setup failed: " <> T.pack (show other)),+      -- trySubstitute: empty caches returns SubstNotFound+      runTestM "trySubstitute no caches" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-subst"+        createDirectoryIfMissing True tmpStore+        store <- openStore (StoreDir tmpStore)+        result <- Subst.trySubstitute store [] (StorePath "test" "hello")+        closeStore store+        removeDirectoryRecursive tmpStore+        pure (assertEqual "no-caches" Subst.SubstNotFound result),+      -- defaultCacheConfig has correct URL+      runTest "defaultCacheConfig url" $+        assertEqual "default-url" "https://cache.nixos.org" (Subst.ccUrl Subst.defaultCacheConfig),+      -- defaultCacheConfig has priority 40+      runTest "defaultCacheConfig priority" $+        assertEqual "default-prio" 40 (Subst.ccPriority Subst.defaultCacheConfig),+      -- verifyNarHash: matching NAR hash accepted (HIGH#2 integrity gate);+      -- the returned digest is the declared hash, decoded.+      runTest "verifyNarHash accepts matching hash" $+        case Subst.verifyNarHash (sampleNarInfo sampleNarHash) sampleNarBytes of+          Right declared+            | CHash.formatNixHash declared == sampleNarHash -> Pass+            | otherwise -> Fail "returned digest does not match the declared hash"+          Left err -> Fail ("expected acceptance, got: " <> err),+      -- verifyNarHash: mismatched NAR hash rejected+      runTest "verifyNarHash rejects mismatched hash" $+        case Subst.verifyNarHash (sampleNarInfo wrongNarHash) sampleNarBytes of+          Left _ -> Pass+          Right _ -> Fail "expected mismatch rejection",+      -- verifyNarHash: malformed NAR hash rejected+      runTest "verifyNarHash rejects malformed hash" $+        case Subst.verifyNarHash (sampleNarInfo "not-a-hash") sampleNarBytes of+          Left _ -> Pass+          Right _ -> Fail "expected malformed-hash rejection",+      -- narInfoMatchesPath: identity match accepted+      runTest "narInfoMatchesPath accepts matching identity" $+        if Subst.narInfoMatchesPath (StorePath sampleHash "hello") (sampleNarInfo sampleNarHash)+          then Pass+          else Fail "expected identity match",+      -- narInfoMatchesPath: identity mismatch rejected+      runTest "narInfoMatchesPath rejects mismatched identity" $+        if Subst.narInfoMatchesPath (StorePath (T.replicate 32 "b") "hello") (sampleNarInfo sampleNarHash)+          then Fail "expected identity mismatch"+          else Pass,+      -- tryCachesWith: the first success stops the scan (later caches+      -- are never contacted)+      runTestM "tryCachesWith success stops scan" $ do+        calls <- newIORef (0 :: Int)+        withChainLock "nova-nix-test-chain-stop" $ \lock -> do+          let hit = PathRegistration (StorePath (T.replicate 32 "d") "hit") "sha256:d" 1 Nothing []+              attempt cache = do+                atomicModifyIORef' calls (\n -> (n + 1, ()))+                pure $+                  if Subst.ccUrl cache == "https://b"+                    then Subst.SubstSuccess hit lock+                    else Subst.SubstNotFound+          result <- Subst.tryCachesWith attempt [chainCache "https://a", chainCache "https://b", chainCache "https://c"]+          made <- readIORef calls+          pure $+            if result == Subst.SubstSuccess hit lock && made == 2+              then Pass+              else Fail ("got " <> T.pack (show result) <> " after " <> T.pack (show made) <> " attempts"),+      -- tryCachesWith: an already-valid path found mid-scan is terminal+      -- like a success - later caches are never contacted+      runTestM "tryCachesWith already-valid stops scan" $ do+        calls <- newIORef (0 :: Int)+        let attempt _ = do+              atomicModifyIORef' calls (\n -> (n + 1, ()))+              pure Subst.SubstAlreadyValid+        result <- Subst.tryCachesWith attempt [chainCache "https://a", chainCache "https://b"]+        made <- readIORef calls+        pure $+          if result == Subst.SubstAlreadyValid && made == 1+            then Pass+            else Fail ("got " <> T.pack (show result) <> " after " <> T.pack (show made) <> " attempts"),+      -- tryCachesWith: an erroring cache falls through to a later hit+      -- instead of aborting the chain+      runTestM "tryCachesWith error falls through to next cache" $+        withChainLock "nova-nix-test-chain-fallthrough" $ \lock -> do+          let hit = PathRegistration (StorePath (T.replicate 32 "f") "hit") "sha256:f" 1 Nothing []+              attempt cache+                | Subst.ccUrl cache == "https://a" = pure (Subst.SubstError "transient 500")+                | otherwise = pure (Subst.SubstSuccess hit lock)+          result <- Subst.tryCachesWith attempt [chainCache "https://a", chainCache "https://b"]+          pure (assertEqual "fallthrough" (Subst.SubstSuccess hit lock) result),+      -- tryCachesWith: all misses stay SubstNotFound+      runTestM "tryCachesWith all misses" $ do+        result <- Subst.tryCachesWith (\_ -> pure Subst.SubstNotFound) [chainCache "https://a", chainCache "https://b"]+        pure (assertEqual "misses" Subst.SubstNotFound result),+      -- tryCachesWith: with no hit anywhere, the FIRST error is reported,+      -- tagged with the URL of the cache that produced it+      runTestM "tryCachesWith reports first error tagged with cache" $ do+        let attempt cache+              | Subst.ccUrl cache == "https://a" = pure Subst.SubstNotFound+              | Subst.ccUrl cache == "https://b" = pure (Subst.SubstError "first")+              | otherwise = pure (Subst.SubstError "second")+        result <- Subst.tryCachesWith attempt [chainCache "https://a", chainCache "https://b", chainCache "https://c"]+        pure $ case result of+          Subst.SubstError err+            | "https://b" `T.isPrefixOf` err && "first" `T.isSuffixOf` err -> Pass+          other -> Fail ("expected tagged first error, got " <> T.pack (show other)),+      -- catchSync (the split every attempt wraps itself in): a+      -- synchronous exception folds into SubstError and the scan falls+      -- through to the next cache+      runTestM "catchSync folds sync exception into SubstError" $ do+        let attempt cache =+              Subst.catchSync+                ( if Subst.ccUrl cache == "https://a"+                    then throwIO (ErrorCall "disk full")+                    else pure Subst.SubstNotFound+                )+                (pure . Subst.SubstError . T.pack . show)+        result <- Subst.tryCachesWith attempt [chainCache "https://a", chainCache "https://b"]+        pure $ case result of+          Subst.SubstError err+            | "disk full" `T.isInfixOf` err -> Pass+          other -> Fail ("expected SubstError from sync exception, got " <> T.pack (show other)),+      -- catchSync: an asynchronous exception thrown mid-attempt+      -- propagates out of the cache scan instead of folding into+      -- SubstError - cancellation must abort substitution, never be+      -- spent as fallthrough to the next cache or to a local build+      runTestM "catchSync propagates async exception out of the scan" $ do+        scanned <- newIORef (0 :: Int)+        let attempt _ =+              Subst.catchSync+                ( do+                    atomicModifyIORef' scanned (\n -> (n + 1, ()))+                    throwIO (asyncExceptionToException (ErrorCall "cancelled"))+                )+                (pure . Subst.SubstError . T.pack . show)+        outcome <- try (Subst.tryCachesWith attempt [chainCache "https://a", chainCache "https://b"])+        made <- readIORef scanned+        pure $ case (outcome :: Either SomeException Subst.SubstResult) of+          Left escaped -> case fromException escaped of+            Just (SomeAsyncException _)+              | made == 1 -> Pass+              | otherwise -> Fail ("scan continued past the interrupt: " <> T.pack (show made) <> " attempts")+            Nothing -> Fail ("wrong exception escaped: " <> T.pack (show escaped))+          Right result -> Fail ("async exception folded into " <> T.pack (show result)),+      -- clearStaleDestination: removes a read-only leftover tree (the+      -- crash-between-unpack-and-register wedge)+      runTestM "clearStaleDestination removes read-only leftovers" $ do+        tmpBase <- getTemporaryDirectory+        let staleRoot = tmpBase </> "nova-nix-test-stale-dest"+            staleFile = staleRoot </> "bin" </> "tool"+        createDirectoryIfMissing True (staleRoot </> "bin")+        writeFile staleFile "leftover"+        perms <- getPermissions staleFile+        Dir.setPermissions staleFile (Dir.setOwnerWritable False perms)+        Subst.clearStaleDestination staleRoot+        gone <- Dir.doesPathExist staleRoot+        pure (if gone then Fail "stale tree survived" else Pass),+      -- clearStaleDestination: a missing destination is a no-op+      runTestM "clearStaleDestination missing path no-op" $ do+        tmpBase <- getTemporaryDirectory+        outcome <- try (Subst.clearStaleDestination (tmpBase </> "nova-nix-test-no-such-dir"))+        pure $ case (outcome :: Either SomeException ()) of+          Right () -> Pass+          Left e -> Fail ("threw: " <> T.pack (show e)),+      -- unpackNarEntry: traversal-shaped entry names from an untrusted+      -- cache are rejected before anything is written+      runTestM "unpackNarEntry rejects unsafe entry names" $ do+        tmpBase <- getTemporaryDirectory+        let dest = tmpBase </> "nova-nix-test-unpack-unsafe"+            evil name = NAR.NarDirectory [(name, NAR.NarRegular False "x")]+        results <- mapM (Subst.unpackNarEntry dest . evil) ["..", ".", "", "a/b", "a\\b"]+        Subst.clearStaleDestination dest+        pure $+          if all (\case Left _ -> True; Right () -> False) results+            then Pass+            else Fail ("accepted an unsafe name: " <> T.pack (show results)),+      -- unpackNarEntry: names and targets arrive as the raw bytes the+      -- wire carries; the store materializes only valid-Unicode names,+      -- so a byte name with no Unicode reading refuses the unpack+      -- before anything is written+      runTestM "unpackNarEntry refuses a non-UTF-8 entry name" $ do+        tmpBase <- getTemporaryDirectory+        let dest = tmpBase </> "nova-nix-test-unpack-rawname"+            tree = NAR.NarDirectory [(BS.pack [0xFF], NAR.NarRegular False "x")]+        result <- Subst.unpackNarEntry dest tree+        Subst.clearStaleDestination dest+        pure $ case result of+          Left err -> if "not valid UTF-8" `T.isInfixOf` err then Pass else Fail ("wrong error: " <> err)+          Right () -> Fail "accepted a non-UTF-8 entry name",+      runTestM "unpackNarEntry refuses a non-UTF-8 symlink target" $ do+        tmpBase <- getTemporaryDirectory+        let dest = tmpBase </> "nova-nix-test-unpack-rawtarget"+            tree = NAR.NarDirectory [("link", NAR.NarSymlink (BS.pack [0xFF]))]+        result <- Subst.unpackNarEntry dest tree+        Subst.clearStaleDestination dest+        pure $ case result of+          Left err -> if "not valid UTF-8" `T.isInfixOf` err then Pass else Fail ("wrong error: " <> err)+          Right () -> Fail "accepted a non-UTF-8 symlink target",+      -- unpackNarEntry: a NAR symlink materializes as a REAL link or fails+      -- loudly - never as a regular file holding the target text, which+      -- would silently diverge from the signed NAR hash+      runTestM "unpackNarEntry symlink is real or loud" $ do+        tmpBase <- getTemporaryDirectory+        let dest = tmpBase </> "nova-nix-test-unpack-symlink"+            tree =+              NAR.NarDirectory+                [ ("real", NAR.NarRegular False "contents"),+                  ("link", NAR.NarSymlink "real")+                ]+        Subst.clearStaleDestination dest+        result <- Subst.unpackNarEntry dest tree+        outcome <- case result of+          Right () -> do+            isLink <- Dir.pathIsSymbolicLink (dest </> "link")+            pure (if isLink then Pass else Fail "symlink materialized as a non-link")+          Left _ -> do+            leftBehind <- Dir.doesFileExist (dest </> "link")+            pure (if leftBehind then Fail "failed loudly but left a regular file behind" else Pass)+        Subst.clearStaleDestination dest+        pure outcome,+      -- unpackNarEntry: a directory-target symlink that sorts BEFORE its+      -- target still gets the directory link flavor (second-pass typing)+      runTestM "unpackNarEntry types forward dir symlink" $ do+        tmpBase <- getTemporaryDirectory+        let dest = tmpBase </> "nova-nix-test-unpack-dirlink"+            tree =+              NAR.NarDirectory+                [ ("alink", NAR.NarSymlink "zdir"),+                  ("zdir", NAR.NarDirectory [("f", NAR.NarRegular False "x")])+                ]+        Subst.clearStaleDestination dest+        result <- Subst.unpackNarEntry dest tree+        outcome <- case result of+          Left _ -> pure Pass -- symlinks unavailable here; the loud failure is the contract+          Right () -> do+            throughLink <- doesDirectoryExist (dest </> "alink")+            pure (if throughLink then Pass else Fail "dir symlink not traversable (wrong flavor)")+        Subst.clearStaleDestination dest+        pure outcome,+      -- Folding sibling names MATERIALIZE on every platform: true names+      -- via the NTFS per-directory flag on Windows, upstream's case-hack+      -- renaming on macOS, plain files on Linux.  The platform serialiser+      -- reverses whichever branch ran, so the tree re-serialises to its+      -- original NAR on all three.+      runTestM "folding sibling names materialize and round-trip" $ do+        tmpBase <- getTemporaryDirectory+        let dest = tmpBase </> "nova-nix-test-unpack-fold"+            tree =+              NAR.NarDirectory+                [ ("Foo", NAR.NarRegular False "upper"),+                  ("foo", NAR.NarRegular False "lower")+                ]+        Subst.clearStaleDestination dest+        result <- Subst.unpackNarEntry dest tree+        outcome <- case result of+          Left err -> pure (Fail ("unpack failed: " <> err))+          Right () -> do+            onDisk <- NAR.serialiseFromPath dest+            if onDisk /= tree+              then pure (Fail "materialized tree does not re-serialise to its NAR")+              else+                if SI.os == "mingw32"+                  then do+                    -- The flag path, not the hack: both TRUE names hold+                    -- distinct contents inside the case-sensitive dir.+                    upper <- BS.readFile (dest </> "Foo")+                    lower <- BS.readFile (dest </> "foo")+                    pure (assertEqual "true names on NTFS" ("upper", "lower") (upper, lower))+                  else pure Pass+        Subst.clearStaleDestination dest+        pure outcome,+      -- The pure case-hack naming: later case variants gain the+      -- reversible suffix with a per-name counter.  The fold key is+      -- platform-derived, so Linux (no folding) keeps every name.+      runTest "caseHackDiskNames renames later case variants" $+        let resolved = caseHackDiskNames ["Foo", "foo", "fOO", "bar"]+         in if SI.os == "mingw32" || SI.os == "darwin"+              then+                assertEqual+                  "hack naming"+                  [("Foo", "Foo"), ("foo", "foo~nix~case~hack~1"), ("fOO", "fOO~nix~case~hack~2"), ("bar", "bar")]+                  resolved+              else+                assertEqual+                  "identity naming"+                  [("Foo", "Foo"), ("foo", "foo"), ("fOO", "fOO"), ("bar", "bar")]+                  resolved,+      -- Where the platform serialiser strips the suffix, an incoming+      -- name carrying it must reject (it could not round-trip); on+      -- Linux such a name is legitimate and materializes verbatim.+      runTestM "incoming case-hack suffix names reject where the serialiser strips" $ do+        tmpBase <- getTemporaryDirectory+        let dest = tmpBase </> "nova-nix-test-unpack-suffix"+            tree = NAR.NarDirectory [("x~nix~case~hack~1", NAR.NarRegular False "v")]+        Subst.clearStaleDestination dest+        result <- Subst.unpackNarEntry dest tree+        outcome <-+          if SI.os == "mingw32" || SI.os == "darwin"+            then case result of+              Left _ -> pure Pass+              Right () -> pure (Fail "suffix-bearing name accepted where the serialiser strips")+            else case result of+              Right () -> do+                kept <- BS.readFile (dest </> "x~nix~case~hack~1")+                pure (assertEqual "verbatim on Linux" "v" kept)+              Left err -> pure (Fail ("Linux rejected a legitimate name: " <> err))+        Subst.clearStaleDestination dest+        pure outcome,+      -- The capability probe itself: NTFS grants the per-directory flag+      -- (CI runners and the dev box run NTFS temp dirs); other+      -- platforms report unsupported.+      runTestM "trySetCaseSensitiveDir reflects platform support" $ do+        tmpBase <- getTemporaryDirectory+        let dir = tmpBase </> "nova-nix-test-csdir"+        Subst.clearStaleDestination dir+        createDirectoryIfMissing True dir+        flagged <- trySetCaseSensitiveDir dir+        Subst.clearStaleDestination dir+        pure (assertEqual "flag support" (SI.os == "mingw32") flagged),+      -- unpackAndVerify re-serialises the materialized tree and checks it+      -- against the declared hash before returning a registration: a+      -- faithful round-trip passes, and the registration carries the+      -- declared hash.+      runTestM "unpackAndVerify accepts a tree that reproduces its hash" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-unpack-verify"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let sp = StorePath (T.replicate 32 "b") "roundtrip"+            tree =+              NAR.NarDirectory+                [ ("data.txt", NAR.NarRegular False "verified bytes"),+                  ("sub", NAR.NarDirectory [("inner", NAR.NarRegular False "nested")])+                ]+            rawNar = NAR.serialise tree+            narHash = CHash.formatNixHash (CHash.hashBytes rawNar)+            info =+              NarInfo.NarInfo+                { NarInfo.niStorePath = storePathToText defaultStoreDir sp,+                  NarInfo.niUrl = "nar/roundtrip.nar",+                  NarInfo.niCompression = "none",+                  NarInfo.niFileHash = Nothing,+                  NarInfo.niFileSize = Nothing,+                  NarInfo.niNarHash = narHash,+                  NarInfo.niNarSize = fromIntegral (BS.length rawNar),+                  NarInfo.niReferences = [],+                  NarInfo.niDeriver = Nothing,+                  NarInfo.niSigs = [],+                  NarInfo.niCA = Nothing+                }+        result <- Subst.unpackAndVerify store sp info rawNar+        onDisk <- BS.readFile (storePathToFilePath (stDir store) sp </> "data.txt")+        -- A success carries the path lock still held; release it before+        -- the store teardown (Windows cannot delete a file a handle+        -- holds open).+        case result of+          Subst.SubstSuccess _ lock -> releasePathLock lock+          _ -> pure ()+        closeStore store+        forceRemoveIfExists tmpStore+        pure $ case result of+          Subst.SubstSuccess reg _ ->+            if prNarHash reg == narHash && onDisk == "verified bytes"+              then Pass+              else Fail "registration or on-disk bytes diverge from the declared hash"+          other -> Fail ("unpackAndVerify failed: " <> T.pack (show other))+    ]+  where+    chainCache url = Subst.CacheConfig url ["unused-key"] 10+    -- A real held lock for tests that construct 'SubstSuccess' by hand:+    -- the constructor carries the path's lock, so a fabricated success+    -- needs a genuine one, taken in a scratch directory.+    withChainLock dirName act = do+      tmpBase <- getTemporaryDirectory+      let lockRoot = tmpBase </> (dirName :: FilePath)+      forceRemoveIfExists lockRoot+      createDirectoryIfMissing True lockRoot+      lock <- acquirePathLock (StoreDir lockRoot) (StorePath (T.replicate 32 "d") "hit")+      result <- act lock+      releasePathLock lock+      forceRemoveIfExists lockRoot+      pure result+    sampleNarBytes = "nova-nix nar sample bytes" :: BS.ByteString+    sampleNarHash = CHash.formatNixHash (CHash.hashBytes sampleNarBytes)+    wrongNarHash = CHash.formatNixHash (CHash.hashBytes ("different bytes" :: BS.ByteString))+    sampleHash = T.replicate 32 "a"+    sampleNarInfo narHash =+      NarInfo.NarInfo+        { NarInfo.niStorePath = "/nix/store/" <> sampleHash <> "-hello",+          NarInfo.niUrl = "nar/sample.nar",+          NarInfo.niCompression = "none",+          NarInfo.niFileHash = Just sampleNarHash,+          NarInfo.niFileSize = Just 0,+          NarInfo.niNarHash = narHash,+          NarInfo.niNarSize = fromIntegral (BS.length sampleNarBytes),+          NarInfo.niReferences = [],+          NarInfo.niDeriver = Nothing,+          NarInfo.niSigs = [],+          NarInfo.niCA = Nothing+        }+    -- A real xz stream: "nova-nix xz fixture payload\n" 64 times+    -- (1792 bytes) compressed with xz -9 to 112 bytes, embedded as+    -- base64 so the test source stays ASCII.+    xzFixturePayload = BS.concat (replicate 64 "nova-nix xz fixture payload\n")+    xzFixtureSize = toInteger (BS.length xzFixturePayload)+    xzFixtureB64 =+      "/Td6WFoAAATm1rRGAgAhARwAAAAQz1jM4Ab/AC1dADcbyzKSinKbUBue2bMHcUt1zJQMzAec5W6XwPbK3dKJtL3q9K3rfg1KQ/ZOAAAAAAAWUPRD8XwohgABSYAOAAAA2Eg7urHEZ/sCAAAAAARZWg==" :: Text+    -- cache.nixos.org narinfos recorded 2026-08-20 (nixos-25.05+    -- channel), verbatim: GNU hello (references including itself,+    -- deriver, production signature) and a doc output whose References+    -- line is empty.+    recordedHelloNarInfo =+      T.unlines+        [ "StorePath: /nix/store/jppkr0h8aap5z7m4xy4vg5yrwlly9h2v-hello-2.12.1",+          "URL: nar/1m1sbal63vqhlvbcxzdj8yr6fhhld7dsyhxbxwip54dgvg56rjnc.nar.xz",+          "Compression: xz",+          "FileHash: sha256:1m1sbal63vqhlvbcxzdj8yr6fhhld7dsyhxbxwip54dgvg56rjnc",+          "FileSize: 52056",+          "NarHash: sha256:0s8wi24d3bc4zxyxq3hffj102zi7cjp3wqnpj6nhqrwgjsgqff81",+          "NarSize: 249480",+          "References: dska0s3gxxd8azbsn85pwm6xcqhivldw-glibc-2.40-66 jppkr0h8aap5z7m4xy4vg5yrwlly9h2v-hello-2.12.1",+          "Deriver: a9y211d3lq3yf2mpfvb72f0qvsl0wi3s-hello-2.12.1.drv",+          "Sig: cache.nixos.org-1:xV9xUvtPGHTSo6eeFWYYhaNFiH8MYSXKZzmxe0cf+acdDkIYxFPLC/MNc1CjlbsVoaUwGhcigUdr9eW2+W1UAw=="+        ]+    recordedHelloDocNarInfo =+      T.unlines+        [ "StorePath: /nix/store/4xrqj3kcd89bsg5crbpnx1ppg7nxw9xp-hello-1.0.0.2-doc",+          "URL: nar/1r86zzj3g8jhzyvi17vchsj3frrqk5l329viq2i24iish53x922l.nar.xz",+          "Compression: xz",+          "FileHash: sha256:1r86zzj3g8jhzyvi17vchsj3frrqk5l329viq2i24iish53x922l",+          "FileSize: 892",+          "NarHash: sha256:0amizmj17fgj5sz1gvr743kncrzwvq747lml3zhd374f14hzgd7a",+          "NarSize: 2072",+          "References: ",+          "Deriver: 5knzybx32a3iq53jjw6xd6fn4awn2l3h-hello-1.0.0.2.drv",+          "Sig: cache.nixos.org-1:jUFHex7R7zPonZZqjVy/OOTguUZZE/sityC0zvu3sb35Az8zBTROeFhUC/J/UFx8Ui8UflA7uHyLJm5Z+Ca6Bw=="+        ]+    -- A NAR with the shapes streaming unpack must materialize:+    -- nesting, an executable, a symlink, a zero-byte file, an empty+    -- directory, and a sibling pair that collides on folding+    -- filesystems.  Entries in NAR name order.+    -- Both survive a round trip through disk on every platform.  The+    -- executable bit does on Windows because the store keeps it in an+    -- alternate data stream (#35); the nested symlink target does because+    -- nova-cache 0.11.1.1 normalises a Windows reparse point's separators+    -- back to the POSIX spelling at the NAR boundary (#112).+    streamTestLinkTarget = "bin/tool"+    streamTestNar =+      NAR.serialise+        ( NAR.NarDirectory+            [ ("Makefile", NAR.NarRegular False "all:\n"),+              ("bin", NAR.NarDirectory [("tool", NAR.NarRegular True "#!/bin/sh\n")]),+              ("empty", NAR.NarRegular False ""),+              ("emptydir", NAR.NarDirectory []),+              ("link", NAR.NarSymlink streamTestLinkTarget),+              ("makefile", NAR.NarRegular False "lower\n")+            ]+        )+    streamTestDigest = CHash.hashBytes streamTestNar+    streamTestNarInfo rawNar =+      NarInfo.NarInfo+        { NarInfo.niStorePath = "/nix/store/" <> sampleHash <> "-stream",+          NarInfo.niUrl = "nar/stream.nar",+          NarInfo.niCompression = "none",+          NarInfo.niFileHash = Nothing,+          NarInfo.niFileSize = Nothing,+          NarInfo.niNarHash = CHash.formatNixHash (CHash.hashBytes rawNar),+          NarInfo.niNarSize = fromIntegral (BS.length rawNar),+          NarInfo.niReferences = [],+          NarInfo.niDeriver = Nothing,+          NarInfo.niSigs = [],+          NarInfo.niCA = Nothing+        }+    streamChunks chunkLen bytes+      | BS.null bytes = []+      | otherwise = BS.take chunkLen bytes : streamChunks chunkLen (BS.drop chunkLen bytes)+    -- The xz fixture's payload, compressed by nova-cache:zstandard's+    -- own encoder - the exact pairing the push path ships.+    zstdFixtureCompressed = CZstd.compress CZstd.defaultCompressionLevel xzFixturePayload+    -- The same payload compressed by the bzip2 CLI (bzip2 -9),+    -- embedded base64 so the test source stays ASCII.  The reference+    -- encoder is deliberately the format's own tool and not the+    -- decoder's library: nova-cache:bzip2 decodes only, so a+    -- self-produced fixture could not catch the two disagreeing.+    bzip2FixtureB64 =+      "QlpoOTFBWSZTWTop7coAAf/RgAAQQAInJddwIACQKZMTIMjAqqBkDaJtTgCQKgZA5AyBAFwNwMAeAYAgCQLgXA+AoBUCQIAkCAJAUAgCgH4u5IpwoSB0U9uU" :: Text+    -- A NAR every platform materializes identically (no executable+    -- bit, no separator-bearing symlink target), and the bzip2 CLI's+    -- compression of exactly these bytes.+    bzip2NarFixture =+      NAR.serialise+        (NAR.NarDirectory [("greeting", NAR.NarRegular False "nova-nix bzip2 fixture\n")])+    bzip2NarFixtureB64 =+      "QlpoOTFBWSZTWa3VDM4AAFJ5gG7yAIBAYjAAP+ffcCAAlIaTJM1PUnqD1DJpoHo01BkkA9I0AAAAtARwcfWQAq1WLMjpmBpVKUAJM6EA8IDQEABOzcRgdDpvigJ6ZZa760fpxZtj+ts6GDiuqt/B6UuthWIrabWmZTkFlI46C8MmbMoVmMYwEYRUCYQW/F3JFOFCQrdUMzg=" :: Text++-- ---------------------------------------------------------------------------+-- Tests: per-store-path locks (the substitution race)+-- ---------------------------------------------------------------------------++-- | Watchdog for the concurrent-substitution race: generous, because a+-- lock-protocol regression shows up as a hang, never as a fast wrong+-- answer.+raceWatchdogMicros :: Int+raceWatchdogMicros = 30 * 1000000++-- | How long a delete gets to (wrongly) finish while the path lock is+-- held elsewhere: long enough that an unlocked delete of one tiny tree+-- always completes inside it, while a correctly blocked delete waits+-- until the release that follows the probe.+deleteLockProbeMicros :: Int+deleteLockProbeMicros = 500 * 1000++-- | The executable bit's own representation, independent of any store.+--+-- Every assertion here is platform-agnostic on purpose: the module's whole+-- point is that a caller asks 'ExecBit' rather than the filesystem, so the+-- same questions must have the same answers on Windows and on Unix even+-- though the bit is kept in different places.+testExecBit :: IO [Bool]+testExecBit = do+  putStrLn "store/exec-bit"+  sequence+    [ -- The round-trip the representation exists for: what was marked+      -- reads back marked, and an unmarked sibling stays unmarked.+      runTestM "exec mark round-trips through isExecutable" $ do+        dir <- freshExecBitDir "roundtrip"+        let marked = dir </> "tool"+            plain = dir </> "data"+        BS.writeFile marked "#!x"+        BS.writeFile plain "hello"+        ExecBit.markExecutable marked+        isMarked <- ExecBit.isExecutable marked+        isPlain <- ExecBit.isExecutable plain+        forceRemoveIfExists dir+        pure $ case (isMarked, isPlain) of+          (True, False) -> Pass+          (False, _) -> Fail "a marked file did not read back executable"+          (_, True) -> Fail "an unmarked file read back executable",+      -- copyExecMark out of a sealed store path.  setReadOnly is what+      -- placeInStore does, and on Windows copyFile propagates that+      -- attribute to the copy, where a mark cannot be written until it is+      -- cleared again.  The source must also survive read-only.+      runTestM "exec mark survives a copy out of a read-only source" $ do+        dir <- freshExecBitDir "copy"+        let src = dir </> "tool"+            dest = dir </> "tool-copy"+        BS.writeFile src "#!x"+        ExecBit.markExecutable src+        setReadOnly src+        Dir.copyFile src dest+        ExecBit.copyExecMark src dest+        copied <- ExecBit.isExecutable dest+        srcStillExec <- ExecBit.isExecutable src+        -- Unseal before cleanup: a read-only file resists removal on+        -- Windows the same way it resists a stream write.+        Dir.getPermissions src >>= Dir.setPermissions src . Dir.setOwnerWritable True+        forceRemoveIfExists dir+        pure $ case (copied, srcStillExec) of+          (True, True) -> Pass+          (False, _) -> Fail "the copy lost its exec mark"+          (_, False) -> Fail "sealing the source dropped its exec mark",+      -- The serialiser reads the mark, not the file's permissions: this is+      -- the flag a NAR carries, and on Windows getPermissions would answer+      -- from the extension instead.+      runTestM "serialiseFromPath reports the marked flag" $ do+        dir <- freshExecBitDir "serialise"+        let marked = dir </> "tool"+        BS.writeFile marked "#!x"+        BS.writeFile (dir </> "data") "hello"+        ExecBit.markExecutable marked+        entry <- ExecBit.serialiseFromPath dir+        forceRemoveIfExists dir+        pure $ case entry of+          NAR.NarDirectory entries ->+            assertEqual+              "flags of (data, tool)"+              [("data", False), ("tool", True)]+              [(n, e) | (n, NAR.NarRegular e _) <- entries]+          other -> Fail ("expected a directory entry, got: " <> T.pack (show other)),+      -- The sink and the verifier must read one source of truth.  A+      -- substituted tree is written through markExecutable and its hash+      -- rechecked on disk, so if narHashOfPath consulted the filesystem's+      -- own idea of executability instead, every path holding a marked+      -- file would be downloaded, rejected and deleted.+      runTestM "narHashOfPath agrees with the serialiser it verifies" $ do+        dir <- freshExecBitDir "hash"+        let marked = dir </> "tool"+        BS.writeFile marked "#!x"+        BS.writeFile (dir </> "data") "hello"+        ExecBit.markExecutable marked+        streamed <- ExecBit.narHashOfPath dir+        fromEntry <- NAR.narHash <$> ExecBit.serialiseFromPath dir+        -- A control on the same axis: an unmarked tree must hash+        -- differently, or agreement above would prove nothing.+        unmarkedDir <- freshExecBitDir "hash-plain"+        BS.writeFile (unmarkedDir </> "tool") "#!x"+        BS.writeFile (unmarkedDir </> "data") "hello"+        unmarked <- ExecBit.narHashOfPath unmarkedDir+        forceRemoveIfExists dir+        forceRemoveIfExists unmarkedDir+        pure $+          if streamed /= fromEntry+            then Fail "the verifier's hash disagrees with the serialiser's"+            else+              if streamed == unmarked+                then Fail "the exec mark did not reach the hash at all"+                else Pass,+      -- correct/ recurses by name, and a non-ASCII name must survive that+      -- recursion: decoding it a byte at a time names a file that is not+      -- there and the mark silently reads as absent.+      runTestM "a non-ASCII directory name keeps its child's mark" $ do+        dir <- freshExecBitDir "utf8"+        let sub = dir </> "\955-dir"+            marked = sub </> "\955-tool"+        createDirectoryIfMissing True sub+        BS.writeFile marked "#!x"+        ExecBit.markExecutable marked+        entry <- ExecBit.serialiseFromPath dir+        forceRemoveIfExists dir+        pure $ case entry of+          NAR.NarDirectory [(_, NAR.NarDirectory [(_, NAR.NarRegular True _)])] -> Pass+          other -> Fail ("expected one marked child, got: " <> T.pack (show other))+    ]++-- | An empty scratch directory for one 'testExecBit' case.+freshExecBitDir :: String -> IO FilePath+freshExecBitDir name = do+  tmpBase <- getTemporaryDirectory+  let dir = tmpBase </> ("nova-nix-test-execbit-" ++ name)+  forceRemoveIfExists dir+  createDirectoryIfMissing True dir+  pure dir++testPathLocks :: IO [Bool]+testPathLocks = do+  putStrLn "store/path-locks"+  sequence+    [ -- Two independent handles on one path lock exclude each other -+      -- the guarantee flock and LockFileEx share, and exactly the shape+      -- of two processes contending for one store path.+      runTestM "path lock excludes a second handle" $ do+        tmpBase <- getTemporaryDirectory+        let lockRoot = tmpBase </> "nova-nix-test-lock-excl"+        forceRemoveIfExists lockRoot+        createDirectoryIfMissing True lockRoot+        let dir = StoreDir lockRoot+            sp = StorePath (T.replicate 32 "a") "lockee"+        held <- acquirePathLock dir sp+        second <- tryAcquirePathLock dir sp+        releasePathLock held+        third <- tryAcquirePathLock dir sp+        mapM_ releasePathLock second+        mapM_ releasePathLock third+        forceRemoveIfExists lockRoot+        pure $ case (second, third) of+          (Nothing, Just _) -> Pass+          (Just _, _) -> Fail "a second handle acquired a held lock"+          (Nothing, Nothing) -> Fail "release did not free the lock",+      -- The already-valid short-circuit: a registered path substitutes+      -- as SubstAlreadyValid with no network and no disk writes - the+      -- configured cache is unreachable, so any contact would surface+      -- as an error result.+      runTestM "trySubstitute adopts an already-valid path untouched" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-lock-valid"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let sp = StorePath (T.replicate 32 "c") "validpath"+            destPath = storePathToFilePath (StoreDir tmpStore) sp+        createDirectoryIfMissing True destPath+        BS.writeFile (destPath </> "payload") "registered bytes"+        reg <- registrationFor store sp Nothing []+        registerPath (stDB store) reg+        result <- Subst.trySubstitute store [unreachableCache] sp+        survivor <- BS.readFile (destPath </> "payload")+        -- The short-circuit released the lock, so the path must be+        -- lockable again at once.+        reLock <- tryAcquirePathLock (StoreDir tmpStore) sp+        mapM_ releasePathLock reLock+        closeStore store+        forceRemoveIfExists tmpStore+        pure $ case (result, reLock) of+          (Subst.SubstAlreadyValid, Just _)+            | survivor == "registered bytes" -> Pass+            | otherwise -> Fail "tree touched during already-valid adoption"+          (Subst.SubstAlreadyValid, Nothing) -> Fail "lock still held after already-valid return"+          (other, _) -> Fail ("expected SubstAlreadyValid, got: " <> T.pack (show other)),+      -- The strict pipeline honors the same short-circuit: fed a+      -- DIFFERENT (self-consistent) NAR for an already-valid path, it+      -- must adopt the registered tree rather than overwrite it.+      runTestM "unpackAndVerify adopts an already-valid path untouched" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-lock-valid-strict"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let sp = StorePath (T.replicate 32 "d") "strictvalid"+            destPath = storePathToFilePath (StoreDir tmpStore) sp+        createDirectoryIfMissing True destPath+        BS.writeFile (destPath </> "payload") "registered bytes"+        reg <- registrationFor store sp Nothing []+        registerPath (stDB store) reg+        let differentNar = NAR.serialise (NAR.NarDirectory [("other", NAR.NarRegular False "different bytes")])+        result <- Subst.unpackAndVerify store sp (lockTestNarInfo sp differentNar) differentNar+        survivor <- BS.readFile (destPath </> "payload")+        closeStore store+        forceRemoveIfExists tmpStore+        pure $ case result of+          Subst.SubstAlreadyValid+            | survivor == "registered bytes" -> Pass+            | otherwise -> Fail "tree touched during already-valid adoption"+          other -> Fail ("expected SubstAlreadyValid, got: " <> T.pack (show other)),+      -- The race the locks exist for: two concurrent substitutions of+      -- one path, independent lock handles.  Exactly one materializes;+      -- the other waits at the lock and adopts the winner's registered+      -- path; the surviving tree is intact and registered once.+      runTestM "concurrent substitutions: one materializes, one adopts" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-lock-race"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let sp = StorePath (T.replicate 32 "e") "racepath"+            destPath = storePathToFilePath (StoreDir tmpStore) sp+            rawNar = NAR.serialise (NAR.NarDirectory [("data", NAR.NarRegular False "race payload")])+            -- The caller's contract per attempt: register under the+            -- still-held lock, then release it.+            attempt = do+              result <- Subst.unpackAndVerify store sp (lockTestNarInfo sp rawNar) rawNar+              case result of+                Subst.SubstSuccess winnerReg lock -> do+                  registerPath (stDB store) winnerReg+                  releasePathLock lock+                _ -> pure ()+              pure result+        firstDone <- newEmptyMVar+        secondDone <- newEmptyMVar+        _ <- forkIO ((try attempt :: IO (Either SomeException Subst.SubstResult)) >>= putMVar firstDone)+        _ <- forkIO ((try attempt :: IO (Either SomeException Subst.SubstResult)) >>= putMVar secondDone)+        outcomeA <- timeout raceWatchdogMicros (takeMVar firstDone)+        outcomeB <- timeout raceWatchdogMicros (takeMVar secondDone)+        rowCount <- length <$> queryAllValidPaths (stDB store)+        onDisk <- NAR.serialiseFromPath destPath+        closeStore store+        forceRemoveIfExists tmpStore+        pure $ case (outcomeA, outcomeB) of+          (Just (Right resultA), Just (Right resultB)) ->+            let outcomes = [resultA, resultB]+                materialized = length [() | Subst.SubstSuccess _ _ <- outcomes]+                adopted = length [() | Subst.SubstAlreadyValid <- outcomes]+             in if materialized == 1 && adopted == 1 && rowCount == 1 && NAR.serialise onDisk == rawNar+                  then Pass+                  else+                    Fail+                      ( "unexpected race outcome: "+                          <> T.pack (show outcomes)+                          <> ", registered rows: "+                          <> T.pack (show rowCount)+                      )+          other -> Fail ("a race attempt hung or threw: " <> T.pack (show other))+    ]+  where+    -- Never contacted when the already-valid short-circuit holds; a+    -- regression that reaches for the network fails loudly here.+    unreachableCache = Subst.CacheConfig "http://127.0.0.1:9" ["unused-key"] 10+    lockTestNarInfo sp rawNar =+      NarInfo.NarInfo+        { NarInfo.niStorePath = storePathToText defaultStoreDir sp,+          NarInfo.niUrl = "nar/lock-test.nar",+          NarInfo.niCompression = "none",+          NarInfo.niFileHash = Nothing,+          NarInfo.niFileSize = Nothing,+          NarInfo.niNarHash = CHash.formatNixHash (CHash.hashBytes rawNar),+          NarInfo.niNarSize = fromIntegral (BS.length rawNar),+          NarInfo.niReferences = [],+          NarInfo.niDeriver = Nothing,+          NarInfo.niSigs = [],+          NarInfo.niCA = Nothing+        }++-- ---------------------------------------------------------------------------+-- Tests: Build orchestrator (Phase 3, Batch 7)+-- ---------------------------------------------------------------------------++testBuildOrchestrator :: IO [Bool]+testBuildOrchestrator = do+  putStrLn "build/orchestrator"+  sequence+    [ -- BuildConfig has caches field+      runTest "defaultBuildConfig has empty caches" $+        assertEqual "empty-caches" [] (bcCaches (defaultBuildConfig defaultStoreDir)),+      -- The build PATH must never open with the build working directory:+      -- a bare-name builder has no directory to derive.+      runTest "bare-name builder derives no PATH entry" $+        let entries = T.splitOn (if SI.os == "mingw32" then ";" else ":") (buildPath "bash")+         in assertEqual "no-dot" False (any (\e -> e == "." || T.isPrefixOf "./" e || T.isPrefixOf ".\\" e) entries),+      runTest "dot-relative builder derives no PATH entry" $+        let entries = T.splitOn (if SI.os == "mingw32" then ";" else ":") (buildPath "./bash")+         in assertEqual "no-dot-rel" False (any (\e -> e == "." || T.isPrefixOf "./" e || T.isPrefixOf ".\\" e) entries),+      runTest "absolute builder opens PATH with its own directory" $+        let path = buildPath ("/store/aaa-bootstrap/bin" </> "bash")+         in assertEqual "builder-dir-first" (Just "/store/aaa-bootstrap/bin") (listToMaybe (T.splitOn (if SI.os == "mingw32" then ";" else ":") path)),+      -- unionEnvs: earlier maps win; on Windows displacement is+      -- case-insensitive and keeps the winner's spelling.+      runTest "unionEnvs left map wins" $+        assertEqual+          "left-bias"+          (Just "build")+          (Map.lookup "A" (unionEnvs [Map.fromList [("A", "build")], Map.fromList [("A", "host")]])),+      runTest "unionEnvs displaces a case variant on Windows" $+        let merged = unionEnvs [Map.fromList [("PATH", "build")], Map.fromList [("Path", "host")]]+         in if SI.os == "mingw32"+              then assertEqual "displaced" (Map.fromList [("PATH", "build")]) merged+              else assertEqual "distinct" (Map.fromList [("PATH", "build"), ("Path", "host")]) merged,+      -- scrubAmbient: nothing ambient survives on Unix; on Windows only+      -- the allowlist passes (keeping its ambient spelling), COMSPEC is+      -- synthesized from SystemRoot, and PATHEXT is pinned.+      runTest "scrubAmbient drops undeclared ambient variables" $+        let ambient =+              Map.fromList+                [ ("SystemRoot", "C:\\WINDOWS"),+                  ("APPDATA", "C:\\Users\\host\\AppData\\Roaming"),+                  ("LANG", "en_US.UTF-8"),+                  ("Path", "C:\\host\\bin")+                ]+            scrubbed = scrubAmbient ambient+         in if SI.os == "mingw32"+              then+                assertEqual+                  "allowlist-only"+                  ( Map.fromList+                      [ ("SystemRoot", "C:\\WINDOWS"),+                        ("COMSPEC", "C:\\WINDOWS\\System32\\cmd.exe"),+                        ("PATHEXT", ".COM;.EXE;.BAT;.CMD")+                      ]+                  )+                  scrubbed+              else assertEqual "empty" Map.empty scrubbed,+      runTest "scrubAmbient pins PATHEXT even without SystemRoot" $+        let scrubbed = scrubAmbient Map.empty+         in if SI.os == "mingw32"+              then assertEqual "pinned" (Map.fromList [("PATHEXT", ".COM;.EXE;.BAT;.CMD")]) scrubbed+              else assertEqual "empty" Map.empty scrubbed,+      -- Build-time placeholder substitution.  Upstream rewrites the whole+      -- @name=value@ string (local-derivation-goal.cc:2004), so a sentinel in+      -- a dynamic attribute name is substituted along with the values.+      runTest "rewritePlaceholders substitutes an output sentinel" $+        assertEqual+          "substituted"+          "/store/aaa-p/lib"+          (rewritePlaceholders [("out", "/store/aaa-p")] (hashPlaceholder "out" <> "/lib")),+      runTest "rewritePlaceholders leaves a foreign output's sentinel alone" $+        assertEqual+          "untouched"+          (hashPlaceholder "dev")+          (rewritePlaceholders [("out", "/store/aaa-p")] (hashPlaceholder "dev")),+      runTest "rewriteEnv rewrites names as well as values" $+        let rewrite = rewritePlaceholders [("out", "/store/aaa-p")]+            env = Map.fromList [(hashPlaceholder "out" <> "-flag", hashPlaceholder "out" <> "/lib")]+         in assertEqual+              "name-and-value"+              (Map.fromList [("/store/aaa-p-flag", "/store/aaa-p/lib")])+              (rewriteEnv rewrite env),+      -- BuildConfig with caches+      runTest "BuildConfig accepts caches" $+        let cache = Subst.CacheConfig "https://cache.example.com" ["key"] 10+            config = (defaultBuildConfig defaultStoreDir) {bcCaches = [cache]}+         in assertEqual "one-cache" 1 (length (bcCaches config)),+      -- buildWithDeps on a simple derivation (no deps, builder fails but graph resolves)+      runTestM "buildWithDeps single drv" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-orch"+        createDirectoryIfMissing True tmpStore+        store <- openStore (StoreDir tmpStore)+        let drv =+              Derivation+                { drvOutputs =+                    [ DerivationOutput+                        { doName = "out",+                          doPath = StorePath "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" "test-out",+                          doHashAlgo = "",+                          doHash = ""+                        }+                    ],+                  drvInputDrvs = Map.empty,+                  drvInputSrcs = [],+                  drvPlatform = currentPlatform,+                  drvBuilder = "/nonexistent-builder",+                  drvArgs = [],+                  drvEnv = Map.singleton "name" "test"+                }+            drvSP = StorePath "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" "test.drv"+        -- Write .drv to store so buildWithDeps can read it+        writeDrv store drv drvSP+        let config = (defaultBuildConfig (StoreDir tmpStore)) {bcTmpDir = tmpBase </> "nova-nix-test-orch-tmp"}+        result <- buildWithDeps config store drv drvSP+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        -- Builder will fail (nonexistent) but the graph should resolve+        -- correctly: the failure must come from SPAWNING THE BUILDER, not+        -- from graph resolution or drv reading upstream of it.+        pure $ case result of+          BuildFailure msg _+            | "nonexistent-builder" `T.isInfixOf` msg -> Pass+            | otherwise -> Fail ("failed before reaching the builder: " <> msg)+          BuildSuccess _ -> Fail "expected build failure for nonexistent builder",+      -- buildWithDeps with cycle detection (mocked through malformed graph)+      runTest "cycle detection returns failure" $+        let spA = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "a.drv"+            spB = StorePath "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" "b.drv"+            drvACyc =+              Derivation+                { drvOutputs = [],+                  drvInputDrvs = Map.singleton spB ["out"],+                  drvInputSrcs = [],+                  drvPlatform = X86_64_Linux,+                  drvBuilder = "/bin/sh",+                  drvArgs = [],+                  drvEnv = Map.empty+                }+            drvBCyc =+              Derivation+                { drvOutputs = [],+                  drvInputDrvs = Map.singleton spA ["out"],+                  drvInputSrcs = [],+                  drvPlatform = X86_64_Linux,+                  drvBuilder = "/bin/sh",+                  drvArgs = [],+                  drvEnv = Map.empty+                }+            readFn sp+              | sp == spB = Right drvBCyc+              | sp == spA = Right drvACyc+              | otherwise = Left "unknown"+         in case DepGraph.buildDepGraph readFn drvACyc spA of+              Right graph -> case DepGraph.topoSort graph of+                DepGraph.TopoCycle _ -> Pass+                DepGraph.TopoSorted order -> Fail ("expected cycle, got sorted: " <> T.pack (show order))+              Left err -> Fail ("expected graph to build, got: " <> err),+      -- missing .drv causes failure in dep graph+      runTest "missing drv in dep graph" $+        let sp = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "missing.drv"+            drv =+              Derivation+                { drvOutputs = [],+                  drvInputDrvs = Map.singleton sp ["out"],+                  drvInputSrcs = [],+                  drvPlatform = X86_64_Linux,+                  drvBuilder = "/bin/sh",+                  drvArgs = [],+                  drvEnv = Map.empty+                }+            readFn _ = Left "not found"+         in case DepGraph.buildDepGraph readFn drv (StorePath "rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr" "root.drv") of+              Left _ -> Pass+              Right _ -> Fail "expected failure for missing .drv",+      -- derivation with context creates populated inputDrvs.  Hashing a+      -- dependent derivation reads input modulo hashes from the drv-hash+      -- cache, which only the IO evaluator maintains, so this runs under+      -- evalNixIO (an in-session dependency hits the cache bottom-up).+      runTestM "derivation context populates inputDrvs" $ do+        tmpBase <- getTemporaryDirectory+        result <-+          evalNixIO tmpBase $+            T.concat+              [ "let dep = derivation { name = \"dep\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; ",+                "main = derivation { name = \"main\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; src = dep.outPath; }; ",+                "in main._derivation"+              ]+        pure $ assertRight "drv-ctx-inputs" result $ \case+          VDerivation drv ->+            if Map.null (drvInputDrvs drv)+              then Fail "expected non-empty drvInputDrvs"+              else Pass+          _ -> Fail "expected VDerivation",+      -- drv3: a dependent derivation's drvPath is stable across evaluations+      -- (the input modulo substitution is deterministic).+      runTestM "dependent derivation drvPath is deterministic (IO eval)" $ do+        tmpBase <- getTemporaryDirectory+        let expr =+              T.concat+                [ "let dep = derivation { name = \"dep\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; ",+                  "main = derivation { name = \"main\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; src = dep.outPath; }; ",+                  "in main.drvPath"+                ]+        r1 <- evalNixIO tmpBase expr+        r2 <- evalNixIO tmpBase expr+        pure $ case (r1, r2) of+          (Right (VStr a _), Right (VStr b _))+            | a == b -> Pass+            | otherwise -> Fail ("drvPath not deterministic: " <> bytesText a <> " vs " <> bytesText b)+          _ -> Fail "expected main.drvPath to evaluate to a string under IO eval",+      -- drv1: embedding another derivation's drvPath (an all-outputs ref) adds+      -- it to inputDrvs carrying the referenced derivation's FULL+      -- output-name set; the IO evaluator recovers the names in-session.+      runTestM "derivation embedding a drvPath lists all its outputs in inputDrvs (IO eval)" $ do+        tmpBase <- getTemporaryDirectory+        let depSrc = "derivation { name = \"dep\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; outputs = [ \"out\" \"dev\" ]; }"+        depPathR <- evalNixIO tmpBase ("(" <> depSrc <> ").drvPath")+        result <-+          evalNixIO tmpBase $+            T.concat+              [ "let dep = ",+                depSrc,+                "; main = derivation { name = \"main\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; ref = dep.drvPath; }; ",+                "in main._derivation"+              ]+        pure $ case (depPathR, result) of+          (Right (VStr depPathBytes _), Right (VDerivation drv)) ->+            case parseStorePath defaultStoreDir (bytesText depPathBytes) of+              Nothing -> Fail ("unparseable dep drvPath: " <> bytesText depPathBytes)+              Just depSP -> case Map.lookup depSP (drvInputDrvs drv) of+                Just outs+                  | Set.fromList outs == Set.fromList ["dev", "out"] -> Pass+                  | otherwise -> Fail ("expected the full output set [dev, out], got " <> T.pack (show outs))+                Nothing -> Fail "dep .drv missing from inputDrvs despite the deep drvPath ref"+          other -> Fail ("expected dep drvPath and main._derivation, got " <> T.pack (show other))+    ]++-- ---------------------------------------------------------------------------+-- Tests: Store.DB (Phase 2, Batch 1)+-- ---------------------------------------------------------------------------++-- | Helper: run a test with a temporary store DB, cleaning up after.+withTempStoreDB :: (StoreDir -> IO [Bool]) -> IO [Bool]+withTempStoreDB action = do+  tmpBase <- getTemporaryDirectory+  let tmpStore = tmpBase </> "nova-nix-test-store-db"+  removeIfExists tmpStore+  createDirectoryIfMissing True tmpStore+  results <- action (StoreDir tmpStore)+  removeIfExists tmpStore+  pure results++removeIfExists :: FilePath -> IO ()+removeIfExists path = do+  exists <- doesDirectoryExist path+  when exists (removeDirectoryRecursive path)++testStoreDB :: IO [Bool]+testStoreDB = do+  putStrLn "store/db"+  withTempStoreDB $ \storeDir ->+    sequence+      [ -- open and close without error+        runTestM "db open/close" $ do+          db <- openStoreDB storeDir+          closeStoreDB db+          pure Pass,+        -- isValidPath returns False for unknown path+        runTestM "db isValidPath false for unknown" $ do+          db <- openStoreDB storeDir+          result <- isValidPath db (StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1" "unknown")+          closeStoreDB db+          pure (assertEqual "unknown" False result),+        -- register + isValidPath returns True+        runTestM "db register + isValid" $ do+          db <- openStoreDB storeDir+          let sp = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2" "hello"+              reg = PathRegistration sp "sha256:abc" 100 Nothing []+          registerPath db reg+          result <- isValidPath db sp+          closeStoreDB db+          pure (assertEqual "registered" True result),+        -- A peer's held write transaction must be waited out, not died+        -- on: SQLite's default busy timeout is zero, and registration+        -- used to crash with an uncaught ErrorBusy SQLError while a+        -- second process was mid-commit.  Upstream waits+        -- (sqlite3_busy_timeout, one hour); this pins that we do too.+        runTestM "db registration waits out a busy peer" $ do+          db <- openStoreDB storeDir+          let dbPath = unStoreDir storeDir </> metaDirName </> dbFileName+          peer <- SQL.open dbPath+          SQL.execute_ peer "BEGIN IMMEDIATE"+          released <- newEmptyMVar+          _ <- forkIO $ do+            threadDelay 300000+            SQL.execute_ peer "COMMIT"+            SQL.close peer+            putMVar released ()+          let sp = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa3" "busywait"+          registerPath db (PathRegistration sp "sha256:busy" 10 Nothing [])+          takeMVar released+          result <- isValidPath db sp+          closeStoreDB db+          pure (assertEqual "registered after the peer released" True result),+        -- register with refs + query+        runTestM "db register with refs + query" $ do+          db <- openStoreDB storeDir+          let ref1 = StorePath "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" "dep1"+              ref2 = StorePath "cccccccccccccccccccccccccccccccc" "dep2"+              mainSp = StorePath "dddddddddddddddddddddddddddddddd" "mainpkg"+          registerPath db (PathRegistration ref1 "sha256:r1" 50 Nothing [])+          registerPath db (PathRegistration ref2 "sha256:r2" 60 Nothing [])+          registerPath db (PathRegistration mainSp "sha256:m1" 200 Nothing [ref1, ref2])+          refs <- queryReferences db mainSp+          closeStoreDB db+          let ref1Path = T.pack (storePathToFilePath storeDir ref1)+              ref2Path = T.pack (storePathToFilePath storeDir ref2)+              hasRef1 = ref1Path `elem` refs+              hasRef2 = ref2Path `elem` refs+          pure $+            if hasRef1 && hasRef2+              then Pass+              else Fail ("expected refs to contain both deps, got: " <> T.pack (show refs)),+        -- Re-registration replaces the edge set: the refresh contract+        -- covers references too, and a union would over-report into+        -- pushed narinfos.+        runTestM "db re-register replaces refs" $ do+          db <- openStoreDB storeDir+          let refA = StorePath "gggggggggggggggggggggggggggggggg" "refresh-a"+              refB = StorePath "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh" "refresh-b"+              sp = StorePath "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" "refresh-main"+          registerPaths+            db+            [ PathRegistration refA "sha256:ra" 10 Nothing [],+              PathRegistration refB "sha256:rb" 11 Nothing [],+              PathRegistration sp "sha256:rm" 12 Nothing [refA, refB]+            ]+          registerPath db (PathRegistration sp "sha256:rm" 12 Nothing [refA])+          refs <- queryReferences db sp+          closeStoreDB db+          let refAPath = T.pack (storePathToFilePath storeDir refA)+              refBPath = T.pack (storePathToFilePath storeDir refB)+          pure $+            if refAPath `elem` refs && refBPath `notElem` refs+              then Pass+              else Fail ("expected only refresh-a after re-register, got: " <> T.pack (show refs)),+        -- A reference to an unregistered path is a loud error: a silently+        -- dropped edge under-reports narinfos and hands a future GC+        -- permission to delete a live dependency.+        runTestM "db register with unregistered ref throws" $ do+          db <- openStoreDB storeDir+          let ghost = StorePath "jjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj" "ghost"+              sp = StorePath "kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk" "orphan-edge"+          outcome <- try (registerPath db (PathRegistration sp "sha256:oe" 13 Nothing [ghost]))+          closeStoreDB db+          pure $ case (outcome :: Either SomeException ()) of+            Left _ -> Pass+            Right () -> Fail "expected registration to throw on an unregistered reference",+        -- queryDeriver nothing+        runTestM "db queryDeriver nothing" $ do+          db <- openStoreDB storeDir+          let sp = StorePath "cccccccccccccccccccccccccccccccc" "noderiver"+          registerPath db (PathRegistration sp "sha256:nd" 80 Nothing [])+          result <- queryDeriver db sp+          closeStoreDB db+          pure (assertEqual "no deriver" Nothing result),+        -- queryDeriver just+        runTestM "db queryDeriver just" $ do+          db <- openStoreDB storeDir+          let sp = StorePath "ffffffffffffffffffffffffffffffff" "hasdrv"+          registerPath db (PathRegistration sp "sha256:hd" 90 (Just "/nix/store/xxx.drv") [])+          result <- queryDeriver db sp+          closeStoreDB db+          pure (assertEqual "has deriver" (Just "/nix/store/xxx.drv") result),+        -- queryPathInfo+        runTestM "db queryPathInfo" $ do+          db <- openStoreDB storeDir+          let sp = StorePath "gggggggggggggggggggggggggggggggg" "infotest"+          registerPath db (PathRegistration sp "sha256:info" 150 (Just "/drv") [])+          minfo <- queryPathInfo db sp+          closeStoreDB db+          pure $ case minfo of+            Nothing -> Fail "expected PathInfo but got Nothing"+            Just info ->+              if piNarHash info == "sha256:info"+                && piNarSize info == 150+                && piDeriver info == Just "/drv"+                then Pass+                else Fail ("bad PathInfo: " <> T.pack (show info)),+        -- queryPathInfo for unknown returns Nothing+        runTestM "db queryPathInfo unknown" $ do+          db <- openStoreDB storeDir+          minfo <- queryPathInfo db (StorePath "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh" "nope")+          closeStoreDB db+          pure (assertEqual "no info" Nothing minfo),+        -- double register is idempotent+        runTestM "db double register idempotent" $ do+          db <- openStoreDB storeDir+          let sp = StorePath "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" "double"+              reg = PathRegistration sp "sha256:dup" 120 Nothing []+          registerPath db reg+          registerPath db reg+          result <- isValidPath db sp+          closeStoreDB db+          pure (assertEqual "still valid" True result),+        -- multi-path reference graph+        runTestM "db multi-path reference graph" $ do+          db <- openStoreDB storeDir+          let spA = StorePath "jjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj" "a"+              spB = StorePath "kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk" "b"+              spC = StorePath "llllllllllllllllllllllllllllllll" "c"+          registerPath db (PathRegistration spA "sha256:a" 10 Nothing [])+          registerPath db (PathRegistration spB "sha256:b" 20 Nothing [spA])+          registerPath db (PathRegistration spC "sha256:c" 30 Nothing [spA, spB])+          refsC <- queryReferences db spC+          refsA <- queryReferences db spA+          closeStoreDB db+          let aPath = T.pack (storePathToFilePath storeDir spA)+              bPath = T.pack (storePathToFilePath storeDir spB)+          pure $+            if length refsC == 2 && aPath `elem` refsC && bPath `elem` refsC && null refsA+              then Pass+              else Fail ("bad ref graph: c refs=" <> T.pack (show refsC) <> " a refs=" <> T.pack (show refsA))+      ]++-- ---------------------------------------------------------------------------+-- Tests: Store Operations + parseStorePath (Phase 2, Batch 2)+-- ---------------------------------------------------------------------------++testParseStorePath :: IO [Bool]+testParseStorePath = do+  putStrLn "store/parseStorePath"+  let sd = defaultStoreDir+  sequence+    [ runTest "parse valid store path" $+        assertEqual+          "valid"+          (Just (StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "hello-2.12"))+          (parseStorePath sd "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-2.12"),+      runTest "parse missing prefix" $+        assertEqual "no prefix" Nothing (parseStorePath sd "/tmp/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello"),+      runTest "parse too short hash" $+        assertEqual "short hash" Nothing (parseStorePath sd "/nix/store/aaa-hello"),+      runTest "parse missing dash after hash" $+        assertEqual "no dash" Nothing (parseStorePath sd "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaahello"),+      runTest "parse empty name" $+        assertEqual "empty name" Nothing (parseStorePath sd "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-"),+      runTest "parse windows store path" $+        assertEqual+          "windows"+          (Just (StorePath "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" "pkg"))+          (parseStorePath windowsStoreDir "C:\\nix\\store/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-pkg"),+      -- The backslash separator is the form storePathToFilePath actually+      -- produces on Windows (what %out% and DB rows look like there).+      runTest "parse windows store path with backslash separator" $+        assertEqual+          "windows-backslash"+          (Just (StorePath "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" "pkg"))+          (parseStorePath windowsStoreDir "C:\\nix\\store\\bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-pkg"),+      -- Charset gates (upstream's parse-boundary checks): the hash slot+      -- accepts only nix-base32 (no e o u t), and the name only+      -- [A-Za-z0-9+._?=-] - parsed text can come from a cache, and an+      -- unchecked component would later become a filesystem path.+      runTest "parse rejects non-base32 hash" $+        assertEqual "e-hash" Nothing (parseStorePath sd ("/nix/store/" <> T.replicate 32 "e" <> "-hello")),+      runTest "parse rejects traversal text in hash slot" $+        assertEqual "traversal-hash" Nothing (parseStorePath sd ("/nix/store/" <> T.replicate 16 ".\\" <> "-x")),+      runTest "parse rejects separator in name" $+        assertEqual "sep-name" Nothing (parseStorePath sd "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-he/llo"),+      runTest "parse rejects dot-dot name" $+        assertEqual "dotdot-name" Nothing (parseStorePath sd "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-.."),+      runTest "parse rejects overlong name" $+        assertEqual "overlong-name" Nothing (parseStorePath sd ("/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-" <> T.replicate 212 "x")),+      runTest "parse accepts the full name charset" $+        assertEqual+          "name-specials"+          (Just (StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "gcc-13.2.0_pre+x?="))+          (parseStorePath sd "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-gcc-13.2.0_pre+x?="),+      -- The first dash-separated component of a name may not be . or ..+      -- (upstream checkName): the traversal names' .- / ..- prefixed forms+      -- are rejected while other dot-leading names stay valid.+      runTest "parse rejects a .- first component" $+        assertEqual "dot-dash" Nothing (parseStorePath sd "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-.-cfg"),+      runTest "parse rejects a ..- first component" $+        assertEqual "dotdot-dash" Nothing (parseStorePath sd "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-..-cfg"),+      runTest "parse accepts a dot-leading name" $+        assertEqual+          "dot-leading"+          (Just (StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ".config-1.0"))+          (parseStorePath sd "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-.config-1.0")+    ]++-- ---------------------------------------------------------------------------+-- Tests: Push (pure narinfo construction + closure computation)+-- ---------------------------------------------------------------------------++testPushPure :: IO [Bool]+testPushPure = do+  putStrLn "push/narinfo"+  let hashA = T.replicate 32 "a"+      hashB = T.replicate 32 "b"+      spA = StorePath hashA "hello-1.0"+      spB = StorePath hashB "dep-2.0"+      narHash = "sha256:0123abcdef"+      narBytes = BS.replicate 1234 0x6e+      artifactNone = mkPushArtifact PushNone narHash narBytes+      artifactZstd = mkPushArtifact PushZstd narHash narBytes+      -- References deliberately unsorted; deriver present.+      ni = mkNarInfo artifactNone spA [spB, spA] (Just spB)+      niZstd = mkNarInfo artifactZstd spA [spB, spA] (Just spB)+  sequence+    [ runTest "narinfo StorePath is canonical /nix/store" $+        assertEqual "StorePath" ("/nix/store/" <> hashA <> "-hello-1.0") (NarInfo.niStorePath ni),+      runTest "narinfo URL is nar/<digest>.nar" $+        assertEqual "URL" "nar/0123abcdef.nar" (NarInfo.niUrl ni),+      runTest "compression none mirrors NAR fields into file fields" $+        assertEqual+          "file fields"+          ("none", Just (NarInfo.niNarHash ni), Just (NarInfo.niNarSize ni))+          (NarInfo.niCompression ni, NarInfo.niFileHash ni, NarInfo.niFileSize ni),+      runTest "narinfo sizes carry through" $+        assertEqual "NarSize" 1234 (NarInfo.niNarSize ni),+      -- The zstd artifact: file fields describe the compressed object,+      -- named by its own file hash, while the NAR fields stay the+      -- archive's - and the substituter's strict decoder round-trips+      -- the bytes under the declared NarSize bound.+      runTest "zstd artifact declares the compressed object" $+        assertEqual+          "zstd fields"+          ("zstd", Just (paFileHash artifactZstd), Just (fromIntegral (paFileSize artifactZstd)), 1234)+          (NarInfo.niCompression niZstd, NarInfo.niFileHash niZstd, NarInfo.niFileSize niZstd, NarInfo.niNarSize niZstd),+      -- Ground truth, not field copying: the file fields must equal+      -- independent computation over the artifact's actual bytes, and+      -- must NOT collapse into the NAR fields (the copy-paste hazard+      -- for a compressible input like this one).+      runTest "zstd file fields are computed from the compressed bytes" $+        let independentHash = CHash.formatNixHash (CHash.hashBytes (paBytes artifactZstd))+         in if paFileHash artifactZstd == independentHash+              && paFileSize artifactZstd == BS.length (paBytes artifactZstd)+              && paFileHash artifactZstd /= narHash+              && paFileSize artifactZstd /= BS.length narBytes+              then Pass+              else Fail "zstd file fields do not match the compressed bytes",+      runTest "none artifact IS the NAR" $+        if paBytes artifactNone == narBytes+          && paFileHash artifactNone == narHash+          && paFileSize artifactNone == BS.length narBytes+          then Pass+          else Fail "none artifact diverges from its NAR",+      runTest "zstd object is named by its independently computed file hash" $+        assertEqual+          "zstd URL"+          ("nar/" <> stripHashPrefix (CHash.formatNixHash (CHash.hashBytes (paBytes artifactZstd))) <> ".nar.zst")+          (NarInfo.niUrl niZstd),+      -- mkNarInfo cannot be handed NAR fields that disagree with the+      -- artifact: they now come from the artifact itself.+      runTest "narinfo NAR fields come from the artifact" $+        assertEqual+          "NAR fields"+          (narHash, toInteger (BS.length narBytes))+          (NarInfo.niNarHash niZstd, NarInfo.niNarSize niZstd),+      runTestM "zstd artifact round-trips through the substituter decoder" $ do+        out <- Subst.decompressNar 1234 "zstd" (paBytes artifactZstd)+        pure (assertEqual "push-substitute roundtrip" (Right narBytes) out),+      -- The CLI vocabulary is the wire vocabulary, parsed in one place.+      runTest "parsePushCompression accepts the register and rejects by name" $+        case (parsePushCompression "none", parsePushCompression "zstd", parsePushCompression "brotli") of+          (Right PushNone, Right PushZstd, Left err)+            | "none, zstd" `T.isInfixOf` err -> Pass+          other -> Fail ("unexpected parse outcomes: " <> T.pack (show other)),+      runTest "references are sorted basenames" $+        assertEqual+          "References"+          [hashA <> "-hello-1.0", hashB <> "-dep-2.0"]+          (NarInfo.niReferences ni),+      runTest "deriver renders as a basename" $+        assertEqual "Deriver" (Just (hashB <> "-dep-2.0")) (NarInfo.niDeriver ni),+      runTest "no client-side signatures" $+        assertEqual "Sigs" ([] :: [Text]) (NarInfo.niSigs ni),+      runTest "planMissing keeps only uncached paths" $+        assertEqual+          "missing"+          [hashB]+          (map spHash (planMissing (Set.singleton hashA) [spA, spB])),+      runTest "stripHashPrefix drops the algo tag" $+        assertEqual "tagged" "deadbeef" (stripHashPrefix "sha256:deadbeef"),+      runTest "stripHashPrefix tolerates a bare digest" $+        assertEqual "bare" "deadbeef" (stripHashPrefix "deadbeef"),+      runTest "storePathBasename joins hash and name" $+        assertEqual "basename" (hashA <> "-hello-1.0") (storePathBasename spA),+      runTest "narFileName appends .nar" $+        assertEqual "file" "0123abcdef.nar" (narFileName narHash),+      -- checkRecordedNarHash: the pre-upload integrity gate+      runTest "push gate accepts a registered matching path" $+        assertEqual+          "gate ok"+          (Right ())+          (checkRecordedNarHash (Just (pathInfoFor narHash)) narHash spA),+      runTest "push gate refuses an unregistered path" $+        case checkRecordedNarHash Nothing narHash spA of+          Left err+            | "not registered" `T.isInfixOf` err -> Pass+            | otherwise -> Fail ("wrong error: " <> err)+          Right () -> Fail "unregistered path must not be publishable",+      runTest "push gate refuses a changed-on-disk path" $+        case checkRecordedNarHash (Just (pathInfoFor "sha256:other")) narHash spA of+          Left err+            | "DB recorded" `T.isInfixOf` err -> Pass+            | otherwise -> Fail ("wrong error: " <> err)+          Right () -> Fail "hash mismatch must not be publishable",+      -- narHashMatches decodes both spellings before comparing.  Today+      -- parseNixHash reads only the sha256:nix-base32 spelling, so a+      -- base16-recorded digest (both literals spell the empty-string+      -- sha256) falls back to text equality and refuses - the safe+      -- direction.  When the parser learns more spellings (foreign+      -- caches), this pin flips and the gate widens with it.+      runTest "push gate pins the accepted hash spellings" $+        if narHashMatches+          "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"+          "sha256:0mdqa9w1p6cmli6976v4wi0sw9r4p5prkj7lzfd1877wk11c9c73"+          then Fail "base16 spelling unexpectedly matched - widen this pin"+          else Pass,+      runTest "push gate still refuses different digests" $+        if narHashMatches+          "sha256:0mdqa9w1p6cmli6976v4wi0sw9r4p5prkj7lzfd1877wk11c9c73"+          (Hash.formatNixHash (Hash.hashBytes "different"))+          then Fail "distinct digests must not match"+          else Pass+    ]+  where+    pathInfoFor recordedHash =+      PathInfo+        { piPath = "/nix/store/" <> T.replicate 32 "a" <> "-hello-1.0",+          piNarHash = recordedHash,+          piNarSize = 1234,+          piDeriver = Nothing,+          piRegTime = 0+        }++-- | Closure computation against a real (temp) store database.+testPushClosureIO :: IO [Bool]+testPushClosureIO = do+  putStrLn "push/closure"+  withTempStore $ \store -> do+    let spTop = StorePath (T.replicate 32 "1") "top"+        spMid = StorePath (T.replicate 32 "2") "mid"+        spLeaf = StorePath (T.replicate 32 "3") "leaf"+        regFor sp refs =+          PathRegistration+            { prPath = sp,+              prNarHash = "sha256:" <> T.replicate 52 "0",+              prNarSize = 1,+              prDeriver = Nothing,+              prReferences = refs+            }+    registerPaths (stDB store) [regFor spTop [spMid], regFor spMid [spLeaf], regFor spLeaf []]+    fromTop <- computeClosure store [spTop]+    fromBoth <- computeClosure store [spTop, spLeaf]+    sequence+      [ runTest "closure walks transitive references" $+          assertRight "fromTop" fromTop $ \closure ->+            assertEqual+              "hashes"+              (Set.fromList (map spHash [spTop, spMid, spLeaf]))+              (Set.fromList (map spHash closure)),+        runTest "closure deduplicates across roots" $+          assertRight "fromBoth" fromBoth $ \closure ->+            assertEqual "count" (3 :: Int) (length closure),+        -- loadApiKeyFile: keys copied through Windows editors arrive with+        -- a BOM and CRLF; skipping the normalization turns every push+        -- into an auth rejection with no visible cause.+        runTestM "loadApiKeyFile strips BOM and CRLF" $ do+          tmpBase <- getTemporaryDirectory+          let keyFile = tmpBase </> "nova-nix-test-api-key"+          BS.writeFile keyFile (BS.pack [0xEF, 0xBB, 0xBF] <> "the-secret-key\r\n")+          loaded <- loadApiKeyFile keyFile+          Dir.removeFile keyFile+          pure (assertEqual "normalized key" (Right "the-secret-key") loaded),+        runTestM "loadApiKeyFile rejects an effectively-empty key file" $ do+          tmpBase <- getTemporaryDirectory+          let keyFile = tmpBase </> "nova-nix-test-api-key-empty"+          BS.writeFile keyFile (BS.pack [0xEF, 0xBB, 0xBF] <> "  \r\n")+          loaded <- loadApiKeyFile keyFile+          Dir.removeFile keyFile+          pure $ case loaded of+            Left err | "empty" `T.isInfixOf` err -> Pass+            other -> Fail ("expected empty-key rejection, got: " <> T.pack (show other)),+        runTestM "loadApiKeyFile reports a missing file" $ do+          tmpBase <- getTemporaryDirectory+          loaded <- loadApiKeyFile (tmpBase </> "nova-nix-test-no-such-key-file")+          pure $ case loaded of+            Left err | "cannot read key file" `T.isInfixOf` err -> Pass+            other -> Fail ("expected read error, got: " <> T.pack (show other))+      ]++-- | Helper: create a fresh temp store for IO tests.+withTempStore :: (Store -> IO [Bool]) -> IO [Bool]+withTempStore action = do+  tmpBase <- getTemporaryDirectory+  let tmpStore = tmpBase </> "nova-nix-test-store-ops"+  forceRemoveIfExists tmpStore+  createDirectoryIfMissing True tmpStore+  store <- openStore (StoreDir tmpStore)+  results <- action store+  closeStore store+  forceRemoveIfExists tmpStore+  pure results++-- | Recursively restore writable permissions then remove.+-- Needed because addToStore/setReadOnly makes paths read-only.+forceRemoveIfExists :: FilePath -> IO ()+forceRemoveIfExists path = do+  exists <- doesDirectoryExist path+  when exists $ do+    restoreWritable path+    removeDirectoryRecursive path++restoreWritable :: FilePath -> IO ()+restoreWritable path = do+  isDir <- doesDirectoryExist path+  when isDir $ do+    perms <- getPermissions path+    Dir.setPermissions path (Dir.setOwnerWritable True perms)+    entries <- Dir.listDirectory path+    mapM_ (restoreWritable . (path </>)) entries+  isFile <- Dir.doesFileExist path+  when isFile $ do+    perms <- getPermissions path+    Dir.setPermissions path (Dir.setOwnerWritable True perms)++-- | Step 1 of the Windows-stdenv ladder: prove the Builder can run a+-- trivial derivation end-to-end natively - a recipe that writes to @$out@,+-- with the output landing in the store.  No stdenv, no dependencies: just+-- the raw build path (process spawn, output capture, addToStore),+-- exercised on the host platform (@cmd.exe@ on Windows, @\/bin\/sh@ elsewhere).+testTrivialBuildIO :: IO [Bool]+testTrivialBuildIO = do+  putStrLn "builder/trivial-native-build"+  withTempStore $ \store -> do+    let storeDir = stDir store+        outPath = StorePath (T.replicate 31 "0" <> "1") "trivial"+        (builder, args)+          | SI.os == "mingw32" = ("cmd.exe", ["/c", "echo hi>%out%"])+          | otherwise = ("/bin/sh", ["-c", "echo hi > $out"])+        drv =+          Derivation+            { drvOutputs =+                [ DerivationOutput+                    { doName = "out",+                      doPath = outPath,+                      doHashAlgo = "",+                      doHash = ""+                    }+                ],+              drvInputDrvs = Map.empty,+              drvInputSrcs = [],+              drvPlatform = currentPlatform,+              drvBuilder = builder,+              drvArgs = args,+              drvEnv = Map.empty+            }+    buildTmp <- (</> "nova-nix-test-trivial-build-tmp") <$> getTemporaryDirectory+    forceRemoveIfExists buildTmp+    createDirectoryIfMissing True buildTmp+    let config = (defaultBuildConfig storeDir) {bcTmpDir = buildTmp}+    result <- buildDerivation config store drv+    forceRemoveIfExists buildTmp+    case result of+      BuildSuccess sp -> do+        let outFile = storePathToFilePath storeDir sp+        landed <- Dir.doesFileExist outFile+        content <- if landed then TIO.readFile outFile else pure ""+        narInfo <- queryPathInfo (stDB store) sp+        let realNarHash = case narInfo of+              Just info ->+                piNarSize info > 0+                  && T.isPrefixOf "sha256:" (piNarHash info)+                  && T.any (/= '0') (T.drop 7 (piNarHash info))+              Nothing -> False+        sequence+          [ runTest "trivial build succeeds" Pass,+            runTest "trivial build output landed in store" $+              if landed then Pass else Fail ("missing output file: " <> T.pack outFile),+            runTest "trivial build actually ran the command" $+              if "hi" `T.isInfixOf` content+                then Pass+                else Fail ("unexpected output content: " <> T.pack (show content)),+            runTest "trivial build records a real NAR hash + size" $+              if realNarHash+                then Pass+                else Fail ("NAR hash/size not real: " <> T.pack (show narInfo))+          ]+      BuildFailure msg code ->+        sequence+          [ runTest "trivial build succeeds" $+              Fail ("build failed (exit " <> T.pack (show code) <> "): " <> msg)+          ]++-- | The builder hands every build a fixed SOURCE_DATE_EPOCH (1980-01-01),+-- the reproducible-builds.org convention that determinism-aware tools+-- (ld's PE timestamp, gcc's __DATE__) honor.  Proven behaviorally: a build+-- that echoes the variable into $out must see the pinned value.+testSourceDateEpochIO :: IO [Bool]+testSourceDateEpochIO = do+  putStrLn "builder/source-date-epoch"+  withTempStore $ \store -> do+    let storeDir = stDir store+        outPath = StorePath (T.replicate 31 "0" <> "5") "sde-probe"+        (builder, args)+          | SI.os == "mingw32" = ("cmd.exe", ["/c", "echo %SOURCE_DATE_EPOCH%>%out%"])+          | otherwise = ("/bin/sh", ["-c", "echo $SOURCE_DATE_EPOCH > $out"])+        drv =+          Derivation+            { drvOutputs =+                [ DerivationOutput+                    { doName = "out",+                      doPath = outPath,+                      doHashAlgo = "",+                      doHash = ""+                    }+                ],+              drvInputDrvs = Map.empty,+              drvInputSrcs = [],+              drvPlatform = currentPlatform,+              drvBuilder = builder,+              drvArgs = args,+              drvEnv = Map.empty+            }+    buildTmp <- (</> "nova-nix-test-sde-build-tmp") <$> getTemporaryDirectory+    forceRemoveIfExists buildTmp+    createDirectoryIfMissing True buildTmp+    let config = (defaultBuildConfig storeDir) {bcTmpDir = buildTmp}+    result <- buildDerivation config store drv+    forceRemoveIfExists buildTmp+    case result of+      BuildFailure msg code ->+        sequence+          [ runTest "SOURCE_DATE_EPOCH build succeeds" $+              Fail ("build failed (exit " <> T.pack (show code) <> "): " <> msg)+          ]+      BuildSuccess sp -> do+        content <- TIO.readFile (storePathToFilePath storeDir sp)+        sequence+          [ runTest "builder pins SOURCE_DATE_EPOCH to 1980-01-01" $+              if "315532800" `T.isInfixOf` content+                then Pass+                else Fail ("unexpected content: " <> T.pack (show content))+          ]++-- | The child's environment is the build environment plus the ambient+-- allowlist, nothing else.  Proven behaviorally at both ends: an ambient+-- canary set in the test process must not reach a build, and the four+-- temp-directory names must all point at the same build-owned directory.+testScrubbedBuildEnvIO :: IO [Bool]+testScrubbedBuildEnvIO = do+  putStrLn "builder/scrubbed-env"+  withTempStore $ \store -> do+    let storeDir = stDir store+        probeDrv name suffix (builder, args) =+          Derivation+            { drvOutputs =+                [ DerivationOutput+                    { doName = "out",+                      doPath = StorePath (T.replicate 31 "0" <> suffix) name,+                      doHashAlgo = "",+                      doHash = ""+                    }+                ],+              drvInputDrvs = Map.empty,+              drvInputSrcs = [],+              drvPlatform = currentPlatform,+              drvBuilder = builder,+              drvArgs = args,+              drvEnv = Map.empty+            }+        canaryDrv =+          probeDrv "env-canary" "6" $+            if SI.os == "mingw32"+              then ("cmd.exe", ["/c", "echo %NOVA_NIX_TEST_CANARY%>%out%"])+              else ("/bin/sh", ["-c", "echo \"${NOVA_NIX_TEST_CANARY-scrubbed}\" > $out"])+        quartetDrv =+          probeDrv "temp-quartet" "7" $+            if SI.os == "mingw32"+              then ("cmd.exe", ["/c", "if \"%TMPDIR%\"==\"%TEMPDIR%\" if \"%TMPDIR%\"==\"%TMP%\" if \"%TMPDIR%\"==\"%TEMP%\" if not \"%TMPDIR%\"==\"\" echo quartet-ok>%out%"])+              else ("/bin/sh", ["-c", "[ -n \"$TMPDIR\" ] && [ \"$TMPDIR\" = \"$TEMPDIR\" ] && [ \"$TMPDIR\" = \"$TMP\" ] && [ \"$TMPDIR\" = \"$TEMP\" ] && echo quartet-ok > $out"])+    buildTmp <- (</> "nova-nix-test-scrub-build-tmp") <$> getTemporaryDirectory+    forceRemoveIfExists buildTmp+    createDirectoryIfMissing True buildTmp+    let config = (defaultBuildConfig storeDir) {bcTmpDir = buildTmp}+    setEnv "NOVA_NIX_TEST_CANARY" "ambient-leak"+    canaryResult <- buildDerivation config store canaryDrv+    unsetEnv "NOVA_NIX_TEST_CANARY"+    quartetResult <- buildDerivation config store quartetDrv+    forceRemoveIfExists buildTmp+    sequence+      [ runTestM "an undeclared ambient variable does not reach a build" $+          case canaryResult of+            BuildFailure msg code -> pure (Fail ("build failed (exit " <> T.pack (show code) <> "): " <> msg))+            BuildSuccess sp -> do+              content <- TIO.readFile (storePathToFilePath storeDir sp)+              pure $+                if "ambient-leak" `T.isInfixOf` content+                  then Fail ("ambient canary leaked into the build: " <> T.pack (show content))+                  else Pass,+        runTestM "all four temp names point at the build directory" $+          case quartetResult of+            BuildFailure msg code -> pure (Fail ("quartet disagreed or build failed (exit " <> T.pack (show code) <> "): " <> msg))+            BuildSuccess sp -> do+              content <- TIO.readFile (storePathToFilePath storeDir sp)+              pure $+                if "quartet-ok" `T.isInfixOf` content+                  then Pass+                  else Fail ("unexpected content: " <> T.pack (show content))+      ]++-- | Zstd compression level for test archives (zstd's own default).+testZstdCompressionLevel :: Int+testZstdCompressionLevel = 3++-- | Build a @.tar.zst@ archive from tar entries, MSYS2-package style.+compressArchive :: [TarEntry.Entry] -> BL.ByteString+compressArchive = ZstdL.compress testZstdCompressionLevel . Tar.write++-- | Test-setup helpers: the paths and link targets below are short literals,+-- so encoding them cannot fail; 'error' marks setup bugs, not test failures.+tarPathOrDie :: Bool -> FilePath -> TarEntry.TarPath+tarPathOrDie isDir p =+  either (error . ("test tar path: " ++)) id (TarEntry.toTarPath isDir p)++linkOrDie :: FilePath -> TarEntry.LinkTarget+linkOrDie t =+  fromMaybe (error ("test link target: " ++ t)) (TarEntry.toLinkTarget t)++tarDir :: FilePath -> TarEntry.Entry+tarDir p = TarEntry.directoryEntry (tarPathOrDie True p)++tarFile :: FilePath -> BL.ByteString -> TarEntry.Entry+tarFile p = TarEntry.fileEntry (tarPathOrDie False p)++tarExecFile :: FilePath -> BL.ByteString -> TarEntry.Entry+tarExecFile p content = (tarFile p content) {TarEntry.entryPermissions = 0o755}++tarHardLink :: FilePath -> FilePath -> TarEntry.Entry+tarHardLink p target =+  TarEntry.simpleEntry (tarPathOrDie False p) (Tar.HardLink (linkOrDie target))++tarSymLink :: FilePath -> FilePath -> TarEntry.Entry+tarSymLink p target =+  TarEntry.simpleEntry (tarPathOrDie False p) (Tar.SymbolicLink (linkOrDie target))++-- | Step 3 of the ladder: @builtin:unpack@, the stage-0 seed extractor.+-- Two zstd-compressed tar archives sharing a top-level prefix (the MSYS2+-- @mingw64\/@ shape) are extracted into ONE output: regular files, an+-- executable, a hardlink and a relative symlink (both materialized as+-- copies), with pacman metadata (@.PKGINFO@\/@.MTREE@) skipped.  A second+-- build proves cross-archive file collisions fail loudly, and further builds+-- that a parent-climbing (..) entry and drive-rooted file, symlink, and+-- hardlink paths are all rejected.+testUnpackBuildIO :: IO [Bool]+testUnpackBuildIO = do+  putStrLn "builder/unpack-seed-archives"+  tmpBase0 <- getTemporaryDirectory+  if ' ' `elem` tmpBase0+    then do+      -- The srcs env is space-separated by the derivation contract (store+      -- paths never contain spaces), but these test archives live under+      -- TEMP: a spaced TEMP would fragment the paths and fail spuriously.+      putStrLn "  SKIP  unpack archive tests need a space-free TEMP dir"+      pure []+    else withTempStore $ \store -> do+      let storeDir = stDir store+      -- Archives live in the temp store under store-path names: the srcs+      -- env is store paths by contract, and runBuiltinUnpack insists on+      -- that now (#101) - each entry is parsed and rendered through the+      -- build's store dir, which for these builds is the temp store.+      let archiveFile name = storePathToFilePath storeDir (StorePath (T.replicate 32 "0") name)+          archiveTools = archiveFile "pkg-tools.tar.zst"+          archiveData = archiveFile "pkg-data.tar.zst"+          archiveCollide = archiveFile "pkg-collide.tar.zst"+          archiveEscape = archiveFile "pkg-escape.tar.zst"+          archiveRootFile = archiveFile "pkg-root-file.tar.zst"+          archiveEscapeSymlink = archiveFile "pkg-escape-symlink.tar.zst"+          archiveEscapeHardlink = archiveFile "pkg-escape-hardlink.tar.zst"+          archiveDirLink = archiveFile "pkg-dirlink.tar.zst"+          archiveDirLinkBase = archiveFile "pkg-dirlink-base.tar.zst"+          archiveDirLinkCollide = archiveFile "pkg-dirlink-collide.tar.zst"+          archiveHardLinkCollide = archiveFile "pkg-hardlink-collide.tar.zst"+          archiveManyEntries = archiveFile "pkg-many-entries.tar.zst"+          archiveBigFile = archiveFile "pkg-big-file.tar.zst"+          archiveAmplify = archiveFile "pkg-amplify.tar.zst"+      BL.writeFile archiveTools $+        compressArchive+          [ tarDir "pkg",+            tarDir "pkg/bin",+            tarExecFile "pkg/bin/tool.exe" "tool-payload",+            tarHardLink "pkg/bin/tool-link.exe" "pkg/bin/tool.exe",+            tarSymLink "pkg/bin/sh.exe" "tool.exe",+            tarFile ".PKGINFO" "pkgname = tools"+          ]+      BL.writeFile archiveData $+        compressArchive+          [ tarDir "pkg",+            tarDir "pkg/share",+            tarFile "pkg/share/data.txt" "shared-data",+            tarFile ".MTREE" "mtree-bytes"+          ]+      BL.writeFile archiveCollide $+        compressArchive [tarFile "pkg/bin/tool.exe" "conflicting"]+      BL.writeFile archiveEscape $+        compressArchive [tarFile "../evil.txt" "escape"]+      -- A leading '/' entry: rooted at the current drive.  tar stores paths with+      -- '/', so this is the on-disk form even of a Windows-native "\..." name.+      BL.writeFile archiveRootFile $+        compressArchive [tarFile "/planted-abs.txt" "rooted-file"]+      -- Escaping link targets: '..' climbs above the archive root.  Unlike a+      -- rooted ('/') target, this builds portably - tar's toLinkTarget accepts a+      -- relative path on every host - so the real unpacker is exercised end to end.+      BL.writeFile archiveEscapeSymlink $+        compressArchive [tarSymLink "sneaky-link.txt" "../escape-link-target.txt"]+      BL.writeFile archiveEscapeHardlink $+        compressArchive [tarHardLink "sneaky-hardlink.txt" "../escape-hardlink-target.txt"]+      -- A directory symlink entry with a fresh destination: materialized+      -- by copying its target tree.+      BL.writeFile archiveDirLink $+        compressArchive+          [ tarDir "realdir",+            tarFile "realdir/g.txt" "dir-link-payload",+            tarSymLink "dirlink" "realdir"+          ]+      -- Collisions: the first archive already provides pkg/, and a later+      -- archive's directory symlink/hardlink also lands at pkg.  The+      -- tree copy must hit the same collision guard as a regular entry,+      -- not silently merge - the merged result would depend on entry+      -- order.+      BL.writeFile archiveDirLinkBase $+        compressArchive [tarDir "pkg", tarFile "pkg/original.txt" "original"]+      BL.writeFile archiveDirLinkCollide $+        compressArchive+          [ tarDir "smuggle",+            tarFile "smuggle/extra.txt" "smuggled",+            tarSymLink "pkg" "smuggle"+          ]+      BL.writeFile archiveHardLinkCollide $+        compressArchive+          [ tarDir "smuggle",+            tarFile "smuggle/extra.txt" "smuggled",+            tarHardLink "pkg" "smuggle"+          ]+      -- Budget probes, built against tiny caps injected via BuildConfig.+      BL.writeFile archiveManyEntries $+        compressArchive [tarFile ("e" <> show i <> ".txt") "x" | i <- [1 :: Int .. 6]]+      BL.writeFile archiveBigFile $+        compressArchive [tarFile "big.bin" (BL.replicate 100 55)]+      -- Two directory symlinks each re-copy the 40-byte payload: the+      -- copies must charge the budget like first-class content.+      BL.writeFile archiveAmplify $+        compressArchive+          [ tarDir "base",+            tarFile "base/blob.bin" (BL.replicate 40 55),+            tarSymLink "dup1" "base",+            tarSymLink "dup2" "base"+          ]+      let -- srcs carries the canonical /nix/store spelling, as eval writes+          -- it; the physical archives live under the temp store dir.+          canonicalSrc file = case parseStorePath storeDir (T.pack file) of+            Just sp -> storePathToText defaultStoreDir sp+            Nothing -> T.pack file+          mkUnpackDrv outP srcFiles =+            Derivation+              { drvOutputs =+                  [ DerivationOutput+                      { doName = "out",+                        doPath = outP,+                        doHashAlgo = "",+                        doHash = ""+                      }+                  ],+                drvInputDrvs = Map.empty,+                drvInputSrcs = [],+                drvPlatform = currentPlatform,+                drvBuilder = builtinUnpackBuilder,+                drvArgs = [],+                drvEnv =+                  Map.fromList+                    [(envSrcs, TE.encodeUtf8 (T.intercalate " " (map canonicalSrc srcFiles)))]+              }+          seedOut = StorePath (T.replicate 31 "0" <> "2") "unpack-seed"+          collideOut = StorePath (T.replicate 31 "0" <> "3") "unpack-collide"+          escapeOut = StorePath (T.replicate 31 "0" <> "4") "unpack-escape"+          rootFileOut = StorePath (T.replicate 31 "0" <> "5") "unpack-root-file"+          escapeSymlinkOut = StorePath (T.replicate 31 "0" <> "6") "unpack-escape-symlink"+          escapeHardlinkOut = StorePath (T.replicate 31 "0" <> "7") "unpack-escape-hardlink"+          dirLinkOut = StorePath (T.replicate 31 "0" <> "8") "unpack-dirlink"+          dirLinkCollideOut = StorePath (T.replicate 31 "0" <> "9") "unpack-dirlink-collide"+          hardLinkCollideOut = StorePath (T.replicate 30 "0" <> "10") "unpack-hardlink-collide"+          manyEntriesOut = StorePath (T.replicate 30 "0" <> "11") "unpack-entry-budget"+          bigFileOut = StorePath (T.replicate 30 "0" <> "12") "unpack-size-budget"+          amplifyOut = StorePath (T.replicate 30 "0" <> "13") "unpack-amplify-budget"+      buildTmp <- (</> "nova-nix-test-unpack-build-tmp") <$> getTemporaryDirectory+      forceRemoveIfExists buildTmp+      createDirectoryIfMissing True buildTmp+      let config = (defaultBuildConfig storeDir) {bcTmpDir = buildTmp}+      seedResult <- buildDerivation config store (mkUnpackDrv seedOut [archiveTools, archiveData])+      collideResult <- buildDerivation config store (mkUnpackDrv collideOut [archiveTools, archiveCollide])+      escapeResult <- buildDerivation config store (mkUnpackDrv escapeOut [archiveEscape])+      rootFileResult <- buildDerivation config store (mkUnpackDrv rootFileOut [archiveRootFile])+      escapeSymlinkResult <- buildDerivation config store (mkUnpackDrv escapeSymlinkOut [archiveEscapeSymlink])+      escapeHardlinkResult <- buildDerivation config store (mkUnpackDrv escapeHardlinkOut [archiveEscapeHardlink])+      dirLinkResult <- buildDerivation config store (mkUnpackDrv dirLinkOut [archiveDirLink])+      dirLinkCollideResult <- buildDerivation config store (mkUnpackDrv dirLinkCollideOut [archiveDirLinkBase, archiveDirLinkCollide])+      hardLinkCollideResult <- buildDerivation config store (mkUnpackDrv hardLinkCollideOut [archiveDirLinkBase, archiveHardLinkCollide])+      let tinyBudget bytes entries =+            config {bcUnpackLimits = UnpackLimits {ulMaxBytes = bytes, ulMaxEntries = entries}}+      manyEntriesResult <- buildDerivation (tinyBudget 1000000 4) store (mkUnpackDrv manyEntriesOut [archiveManyEntries])+      bigFileResult <- buildDerivation (tinyBudget 64 1000) store (mkUnpackDrv bigFileOut [archiveBigFile])+      amplifyResult <- buildDerivation (tinyBudget 100 1000) store (mkUnpackDrv amplifyOut [archiveAmplify])+      forceRemoveIfExists buildTmp+      seedChecks <- case seedResult of+        BuildFailure msg code ->+          sequence+            [ runTest "unpack seed build succeeds" $+                Fail ("build failed (exit " <> T.pack (show code) <> "): " <> msg)+            ]+        BuildSuccess sp -> do+          let outRoot = storePathToFilePath storeDir sp+              readOut rel = do+                let path = outRoot </> rel+                exists <- Dir.doesFileExist path+                if exists then Just <$> TIO.readFile path else pure Nothing+          tool <- readOut ("pkg" </> "bin" </> "tool.exe")+          toolLink <- readOut ("pkg" </> "bin" </> "tool-link.exe")+          shLink <- readOut ("pkg" </> "bin" </> "sh.exe")+          dataFile <- readOut ("pkg" </> "share" </> "data.txt")+          pkgInfo <- Dir.doesFileExist (outRoot </> ".PKGINFO")+          mtree <- Dir.doesFileExist (outRoot </> ".MTREE")+          execBitOk <-+            if SI.os == "mingw32"+              then pure True -- NTFS has no exec bit; PATHEXT decides+              else Dir.executable <$> getPermissions (outRoot </> "pkg" </> "bin" </> "tool.exe")+          narInfo <- queryPathInfo (stDB store) sp+          let realNarHash = case narInfo of+                Just info ->+                  piNarSize info > 0 && T.isPrefixOf "sha256:" (piNarHash info)+                Nothing -> False+          sequence+            [ runTest "unpack seed build succeeds" Pass,+              runTest "unpack: file extracted with content" $+                assertEqual "tool.exe" (Just "tool-payload") tool,+              runTest "unpack: hardlink materialized as copy" $+                assertEqual "tool-link.exe" (Just "tool-payload") toolLink,+              runTest "unpack: relative symlink materialized as copy" $+                assertEqual "sh.exe" (Just "tool-payload") shLink,+              runTest "unpack: second archive merged into shared prefix" $+                assertEqual "data.txt" (Just "shared-data") dataFile,+              runTest "unpack: pacman metadata skipped" $+                if pkgInfo || mtree+                  then Fail ".PKGINFO/.MTREE leaked into the output"+                  else Pass,+              runTest "unpack: executable bit materialized (unix)" $+                if execBitOk then Pass else Fail "tool.exe not executable",+              runTest "unpack: real NAR hash registered" $+                if realNarHash then Pass else Fail (T.pack (show narInfo))+            ]+      collideChecks <-+        sequence+          [ runTest "unpack: cross-archive file collision fails loudly" $+              case collideResult of+                BuildFailure msg _+                  | "file collision" `T.isInfixOf` msg -> Pass+                  | otherwise -> Fail ("wrong failure: " <> msg)+                BuildSuccess _ -> Fail "collision build unexpectedly succeeded"+          ]+      escapeChecks <-+        sequence+          [ runTest "unpack: path traversal rejected" $+              case escapeResult of+                BuildFailure msg _+                  | "escapes archive root" `T.isInfixOf` msg -> Pass+                  | otherwise -> Fail ("wrong failure: " <> msg)+                BuildSuccess _ -> Fail "escape build unexpectedly succeeded"+          ]+      let rejectedWith needle res = case res of+            BuildFailure msg _+              | needle `T.isInfixOf` msg -> Pass+              | otherwise -> Fail ("wrong failure: " <> msg)+            BuildSuccess _ -> Fail "malicious entry unexpectedly built"+      -- Integration: the real unpacker rejects a rooted entry path and an+      -- escaping ('..') symlink/hardlink target, end to end.+      maliciousChecks <-+        sequence+          [ runTest "unpack: rooted file entry rejected" $+              rejectedWith "planted-abs.txt" rootFileResult,+            runTest "unpack: escaping symlink target rejected" $+              rejectedWith "escape-link-target.txt" escapeSymlinkResult,+            runTest "unpack: escaping hardlink target rejected" $+              rejectedWith "escape-hardlink-target.txt" escapeHardlinkResult+          ]+      dirLinkChecks <- case dirLinkResult of+        BuildFailure msg code ->+          sequence+            [ runTest "unpack: directory symlink materialized as copied tree" $+                Fail ("build failed (exit " <> T.pack (show code) <> "): " <> msg)+            ]+        BuildSuccess sp -> do+          let outRoot = storePathToFilePath storeDir sp+              linkedFile = outRoot </> "dirlink" </> "g.txt"+          linkedExists <- Dir.doesFileExist linkedFile+          outcome <-+            if not linkedExists+              then pure (Fail "dirlink/g.txt missing from the output")+              else do+                linked <- TIO.readFile linkedFile+                -- The copy contract: a real directory, never a link (a+                -- store link would need privilege and be machine-dependent).+                isLink <- Dir.pathIsSymbolicLink (outRoot </> "dirlink")+                pure $+                  if linked == "dir-link-payload" && not isLink+                    then Pass+                    else Fail ("content=" <> linked <> " isLink=" <> T.pack (show isLink))+          sequence [runTest "unpack: directory symlink materialized as copied tree" outcome]+      collisionGuardChecks <-+        sequence+          [ runTest "unpack: directory symlink onto existing content fails loudly" $+              rejectedWith "file collision" dirLinkCollideResult,+            runTest "unpack: directory hardlink onto existing content fails loudly" $+              rejectedWith "file collision" hardLinkCollideResult+          ]+      budgetChecks <-+        sequence+          [ runTest "unpack: entry budget breach fails loudly" $+              rejectedWith "entry budget" manyEntriesResult,+            runTest "unpack: size budget breach fails loudly" $+              rejectedWith "size budget" bigFileResult,+            runTest "unpack: directory link copies charge the budget" $+              rejectedWith "size budget" amplifyResult+          ]+      -- Unit: the rooted (drive-relative) case is the Blocker-4 Windows vuln.+      -- tar's toLinkTarget refuses an absolute payload on POSIX, so exercise the+      -- guard predicate directly - portable and exact on every host.+      guardChecks <-+        sequence+          [ runTest "unpack guard: rooted entry path rejected" $+              assertLeft "rooted entry" (entryComponents "/planted-abs.txt"),+            runTest "unpack guard: rooted symlink target rejected" $+              assertLeft "rooted symlink" (resolveLinkTarget [] "/planted-link-target.txt"),+            runTest "unpack guard: rooted hardlink target rejected" $+              assertLeft "rooted hardlink" (entryComponents "/planted-hardlink-target.txt")+          ]+      pure (seedChecks ++ collideChecks ++ escapeChecks ++ maliciousChecks ++ dirLinkChecks ++ collisionGuardChecks ++ budgetChecks ++ guardChecks)++-- | Step 2 of the ladder: a dependency-aware build end-to-end.  A root+-- derivation depends on a leaf; both @.drv@ files are pre-written to the store+-- (as the build driver's closure-writing does), and 'buildWithDeps' must read+-- the closure, topologically order it, build the leaf first, then the root -+-- whose 'validateInputs' requires the leaf's realized output to be valid.+--+-- This is the regression guard for the input-@.drv@-closure fix: before it, no+-- derivation with a non-empty @drvInputDrvs@ could be realized (the closure was+-- never on disk, so the dependency graph could not be read).+testDependentBuildIO :: IO [Bool]+testDependentBuildIO = do+  putStrLn "builder/dependent-native-build"+  withTempStore $ \store -> do+    let storeDir = stDir store+        depOut = StorePath (T.replicate 31 "c" <> "0") "dep"+        depDrvSP = StorePath (T.replicate 31 "c" <> "1") "dep.drv"+        rootOut = StorePath (T.replicate 31 "a" <> "0") "root"+        rootDrvSP = StorePath (T.replicate 31 "a" <> "1") "root.drv"+        mkBuilder word+          | SI.os == "mingw32" = ("cmd.exe", ["/c", "echo " <> word <> ">%out%"])+          | otherwise = ("/bin/sh", ["-c", "echo " <> word <> " > $out"])+        (depBuilder, depArgs) = mkBuilder "leaf"+        (rootBuilder, rootArgs) = mkBuilder "root"+        depDrv =+          Derivation+            { drvOutputs = [DerivationOutput {doName = "out", doPath = depOut, doHashAlgo = "", doHash = ""}],+              drvInputDrvs = Map.empty,+              drvInputSrcs = [],+              drvPlatform = currentPlatform,+              drvBuilder = depBuilder,+              drvArgs = depArgs,+              drvEnv = Map.singleton "name" "dep"+            }+        rootDrv =+          Derivation+            { drvOutputs = [DerivationOutput {doName = "out", doPath = rootOut, doHashAlgo = "", doHash = ""}],+              drvInputDrvs = Map.singleton depDrvSP ["out"],+              drvInputSrcs = [],+              drvPlatform = currentPlatform,+              drvBuilder = rootBuilder,+              drvArgs = rootArgs,+              drvEnv = Map.singleton "name" "root"+            }+    -- The build driver writes the full .drv closure before building; emulate+    -- that here by writing both recipes to the store.+    writeDrv store depDrv depDrvSP+    writeDrv store rootDrv rootDrvSP+    buildTmp <- (</> "nova-nix-test-dep-build-tmp") <$> getTemporaryDirectory+    forceRemoveIfExists buildTmp+    createDirectoryIfMissing True buildTmp+    let config = (defaultBuildConfig storeDir) {bcTmpDir = buildTmp}+    result <- buildWithDeps config store rootDrv rootDrvSP+    depValid <- isValid store depOut+    rootValid <- isValid store rootOut+    forceRemoveIfExists buildTmp+    case result of+      BuildSuccess sp ->+        sequence+          [ runTest "dependent build succeeds with root output" $+              assertEqual "root output path" rootOut sp,+            runTest "leaf dependency built and registered first" $+              if depValid then Pass else Fail "leaf output not valid after build",+            runTest "root output built and registered" $+              if rootValid then Pass else Fail "root output not valid after build"+          ]+      BuildFailure msg code ->+        sequence+          [ runTest "dependent build succeeds with root output" $+              Fail ("dependency-aware build failed (exit " <> T.pack (show code) <> "): " <> msg)+          ]++-- | Upstream value-conformance tests: each pins exact output bytes (hash+-- decode, number formatting) that must match what upstream Nix computes for+-- the same input, because these values can reach names, derivations, and+-- hashes.+testUpstreamConformance :: IO [Bool]+testUpstreamConformance = do+  putStrLn "eval/conformance"+  sequence+    [ -- Hash decode is length-keyed per algorithm, never first-format-wins:+      -- a 52-char all-hex-digit string is a nix32 sha256 hash (64 hex chars+      -- after conversion), not a 26-byte hex string echoed back.+      runTest "hash decode keys on nix32 length" $+        assertEval+          "hash-nix32-length"+          "builtins.stringLength (builtins.convertHash { hash = \"0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"; hashAlgo = \"sha256\"; toHashFormat = \"base16\"; })"+          (VInt 64),+      runTest "hash hex to nix32 round-trips" $+        assertEval+          "hash-roundtrip"+          "builtins.convertHash { hash = builtins.convertHash { hash = \"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"; toHashFormat = \"nix32\"; }; hashAlgo = \"sha256\"; toHashFormat = \"base16\"; }"+          (mkStr "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"),+      runTest "hash base64 spelling decodes by length" $+        assertEval+          "hash-base64"+          "builtins.convertHash { hash = \"sha256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=\"; toHashFormat = \"base16\"; }"+          (mkStr "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"),+      runTest "truncated hash is rejected" $+        assertEvalFail+          "hash-truncated"+          "builtins.convertHash { hash = \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b85\"; hashAlgo = \"sha256\"; toHashFormat = \"base16\"; }",+      runTest "unknown hash algorithm is rejected" $+        assertEvalFail+          "hash-bad-algo"+          "builtins.convertHash { hash = \"00\"; hashAlgo = \"sha3\"; toHashFormat = \"base16\"; }",+      -- SRI digests get the same decoded-length check as every other+      -- spelling (upstream checks SRI too), at all three decode sites:+      -- convertHash, fixed-output outputHash, and the fetch/path pins.+      runTest "valid SRI digest converts" $+        assertEval+          "hash-sri-valid"+          "builtins.convertHash { hash = \"sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=\"; toHashFormat = \"base16\"; }"+          (mkStr "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"),+      runTest "convertHash rejects a truncated SRI digest" $+        case evalNix "builtins.convertHash { hash = \"sha256-YWJj\"; toHashFormat = \"base16\"; }" of+          Left err+            | "wrong length" `T.isInfixOf` err -> Pass+            | otherwise -> Fail ("expected an SRI length error, got: " <> err)+          Right val -> Fail ("expected failure, got: " <> T.pack (show val)),+      runTest "fixed-output outputHash rejects a truncated SRI digest" $+        case evalNix "(derivation { name = \"t\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; outputHash = \"sha256-YWJj\"; }).drvPath" of+          Left err+            | "wrong length" `T.isInfixOf` err -> Pass+            | otherwise -> Fail ("expected an SRI length error, got: " <> err)+          Right val -> Fail ("expected failure, got: " <> T.pack (show val)),+      -- An SRI or prefixed outputHash carries its own algorithm tag; a+      -- non-empty outputHashAlgo must agree with it (hash.cc parseAny+      -- with an expected type), and an unknown declared algorithm is an+      -- error even when the spelling carries a valid tag of its own.+      runTest "fixed-output SRI algorithm must match outputHashAlgo" $+        case evalNix "(derivation { name = \"t\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; outputHash = \"sha1-2jmj7l5rSw0yVb/vlWAYkK/YBwk=\"; outputHashAlgo = \"sha256\"; }).drvPath" of+          Left err+            | "should have type 'sha256'" `T.isInfixOf` err -> Pass+            | otherwise -> Fail ("expected a hash-type error, got: " <> err)+          Right val -> Fail ("expected failure, got: " <> T.pack (show val)),+      runTest "fixed-output prefixed algorithm must match outputHashAlgo" $+        case evalNix "(derivation { name = \"t\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; outputHash = \"md5:d41d8cd98f00b204e9800998ecf8427e\"; outputHashAlgo = \"sha256\"; }).drvPath" of+          Left err+            | "should have type 'sha256'" `T.isInfixOf` err -> Pass+            | otherwise -> Fail ("expected a hash-type error, got: " <> err)+          Right val -> Fail ("expected failure, got: " <> T.pack (show val)),+      runTest "fixed-output SRI agreeing with outputHashAlgo accepted" $+        case evalNix "(derivation { name = \"t\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; outputHash = \"sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=\"; outputHashAlgo = \"sha256\"; }).drvPath" of+          Right _ -> Pass+          Left err -> Fail ("expected success, got: " <> err),+      runTest "fixed-output unknown outputHashAlgo rejected despite SRI tag" $+        case evalNix "(derivation { name = \"t\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; outputHash = \"sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=\"; outputHashAlgo = \"sha3\"; }).drvPath" of+          Left err+            | "unknown hash algorithm" `T.isInfixOf` err -> Pass+            | otherwise -> Fail ("expected an unknown-algorithm error, got: " <> err)+          Right val -> Fail ("expected failure, got: " <> T.pack (show val)),+      runTest "builtins.path pin rejects a truncated SRI digest" $+        case evalNix "builtins.path { path = ./x; sha256 = \"sha256-YWJj\"; }" of+          Left err+            | "wrong length" `T.isInfixOf` err -> Pass+            | otherwise -> Fail ("expected an SRI length error, got: " <> err)+          Right val -> Fail ("expected failure, got: " <> T.pack (show val)),+      runTest "builtins.path pin rejects a non-sha256 SRI digest" $+        case evalNix ("builtins.path { path = ./x; sha256 = \"sha512-" <> T.replicate 86 "A" <> "==\"; }") of+          Left err+            | "should have type 'sha256'" `T.isInfixOf` err -> Pass+            | otherwise -> Fail ("expected a hash-type error, got: " <> err)+          Right val -> Fail ("expected failure, got: " <> T.pack (show val)),+      -- toJSON floats: nlohmann's layout - shortest round-trip digits, .0+      -- kept on integral values, scientific outside point positions (-4, 15].+      runTest "toJSON float shortest digits" $+        assertEval "json-float-third" "builtins.toJSON (1.0 / 3.0)" (mkStr "0.3333333333333333"),+      runTest "toJSON integral float keeps .0" $+        assertEval "json-float-integral" "builtins.toJSON 100.0" (mkStr "100.0"),+      runTest "toJSON float plain decimal" $+        assertEval "json-float-tenth" "builtins.toJSON 0.1" (mkStr "0.1"),+      runTest "toJSON float zero" $+        assertEval "json-float-zero" "builtins.toJSON 0.0" (mkStr "0.0"),+      runTest "toJSON float widest plain integral" $+        assertEval "json-float-e14" "builtins.toJSON 1.0e14" (mkStr "100000000000000.0"),+      runTest "toJSON float first scientific integral" $+        assertEval "json-float-e15" "builtins.toJSON 1.0e15" (mkStr "1e+15"),+      runTest "toJSON float large exponent" $+        assertEval "json-float-e21" "builtins.toJSON 1.0e21" (mkStr "1e+21"),+      runTest "toJSON float negative exponent" $+        assertEval "json-float-e-5" "builtins.toJSON 1.0e-5" (mkStr "1e-05"),+      runTest "toJSON float smallest plain" $+        assertEval "json-float-e-4" "builtins.toJSON 1.0e-4" (mkStr "0.0001"),+      runTest "toJSON non-finite float is null" $+        assertEval "json-float-inf" "builtins.toJSON (1.0e308 * 10.0)" (mkStr "null"),+      -- fromJSON integers: int64 range stays int, wider falls back to float.+      runTest "fromJSON promotes past-64-bit integer to float" $+        assertEval "json-bigint-type" "builtins.typeOf (builtins.fromJSON \"999999999999999999999\")" (mkStr "float"),+      runTest "fromJSON past-64-bit integer value" $+        assertEval "json-bigint-val" "builtins.fromJSON \"999999999999999999999\" == 999999999999999999999.0" (VBool True),+      runTest "fromJSON int64 max stays an int" $+        assertEval "json-int64-max" "builtins.fromJSON \"9223372036854775807\"" (VInt 9223372036854775807),+      -- toXML floats: C++ default ostream formatting, 6 significant digits.+      runTest "toXML float 6 significant digits" $+        assertEval+          "xml-float-third"+          "builtins.toXML (1.0 / 3.0)"+          (mkStr "<?xml version='1.0' encoding='utf-8'?>\n<expr>\n<float value=\"0.333333\" />\n</expr>\n"),+      runTest "toXML float plain decimal" $+        assertEval+          "xml-float-centi"+          "builtins.toXML 0.01"+          (mkStr "<?xml version='1.0' encoding='utf-8'?>\n<expr>\n<float value=\"0.01\" />\n</expr>\n"),+      runTest "toXML integral float drops the point" $+        assertEval+          "xml-float-integral"+          "builtins.toXML 100.0"+          (mkStr "<?xml version='1.0' encoding='utf-8'?>\n<expr>\n<float value=\"100\" />\n</expr>\n"),+      runTest "toXML float large exponent" $+        assertEval+          "xml-float-e21"+          "builtins.toXML 1.0e21"+          (mkStr "<?xml version='1.0' encoding='utf-8'?>\n<expr>\n<float value=\"1e+21\" />\n</expr>\n"),+      runTest "toXML float small exponent" $+        assertEval+          "xml-float-e-5"+          "builtins.toXML 2.5e-5"+          (mkStr "<?xml version='1.0' encoding='utf-8'?>\n<expr>\n<float value=\"2.5e-05\" />\n</expr>\n"),+      -- fromTOML integers: 64-bit signed range enforced, no silent wrap.+      runTest "fromTOML rejects past-64-bit integer" $+        assertEvalFail "toml-int-overflow" "builtins.fromTOML \"v = 99999999999999999999\"",+      runTest "fromTOML rejects past-64-bit hex integer" $+        assertEvalFail "toml-hex-overflow" "builtins.fromTOML \"v = 0xffffffffffffffff\"",+      runTest "fromTOML int64 max parses" $+        assertEval "toml-int64-max" "(builtins.fromTOML \"v = 9223372036854775807\").v" (VInt 9223372036854775807),+      runTest "fromTOML int64 min parses" $+        assertEval "toml-int64-min" "(builtins.fromTOML \"v = -9223372036854775808\").v == (0 - 9223372036854775807 - 1)" (VBool True),+      -- drv3: pure eval cannot read the store, so hashing a dependent+      -- derivation fails loudly rather than emitting a guessed input hash.+      runTest "dependent derivation drvPath fails loudly in pure eval" $+        assertEvalFail+          "drv-modulo-pure-miss"+          "let dep = derivation { name = \"dep\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; main = derivation { name = \"main\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; src = dep.outPath; }; in main.drvPath",+      -- Path values canonicalize lexically (CanonPath): no dot segment or+      -- doubled separator survives into a path value's text, so different+      -- spellings of the same path compare equal.+      runTest "path literal canonicalizes dot segments" $+        assertEval "path-canon-dot" "builtins.toString ./. == builtins.toString ./x/.." (VBool True),+      runTest "path literal collapses inner dot" $+        assertEval "path-canon-inner" "builtins.toString ./a/./b == builtins.toString ./a/b" (VBool True),+      runTest "path plus string canonicalizes" $+        assertEval "path-canon-plus" "./a + \"/../b\" == ./b" (VBool True),+      runTest "toPath canonicalizes" $+        assertEval "path-canon-topath" "builtins.toString (builtins.toPath \"/a/../b\")" (mkStr "/b"),+      runTest "toPath collapses inner dot" $+        assertEval "path-canon-topath-dot" "builtins.toString (builtins.toPath \"/a/./b\")" (mkStr "/a/b"),+      runTest "toPath preserves forward-slash root" $+        assertEval "path-canon-topath-root" "builtins.toString (builtins.toPath \"//nix//store\")" (mkStr "/nix/store")+    ]++-- | Eval-fidelity regression tests: each pins a semantic where the evaluator+-- must match upstream Nix.  All are parity-safe - none affects a derivation+-- or store-path hash.+testEvalFidelity :: IO [Bool]+testEvalFidelity = do+  putStrLn "eval/fidelity"+  sequence+    [ -- builtins.match: a non-participating capture group is null, not ""+      runTest "match null capture group" $+        assertEval "match-null" "builtins.isNull (builtins.elemAt (builtins.match \"(a)(b)?\" \"a\") 1)" (VBool True),+      runTest "match participating group value" $+        assertEval "match-val" "builtins.elemAt (builtins.match \"(a)(b)\" \"ab\") 1" (mkStr "b"),+      -- builtins.split: a non-participating group is null too+      runTest "split null capture group" $+        assertEval "split-null" "builtins.isNull (builtins.elemAt (builtins.elemAt (builtins.split \"(a)|(b)\" \"a\") 1) 1)" (VBool True),+      -- builtins.match is whole-string like regex_match: top-level+      -- alternation must not decay into a prefix/suffix match.+      runTest "match alternation is whole-string" $+        assertEval "match-alt" "builtins.match \"a|b\" \"ab\"" VNull,+      runTest "match alternation positive" $+        assertEval "match-alt-pos" "builtins.match \"a|b\" \"a\" == [ ]" (VBool True),+      -- No multiline mode: ^/$ anchor only at the string boundaries and+      -- '.' matches a newline, as POSIX ERE whole-string matching does.+      runTest "match does not anchor at inner newlines" $+        assertEval "match-nl" "builtins.match \"foo\" \"bar\\nfoo\"" VNull,+      runTest "match dot spans newline" $+        assertEval "match-dot-nl" "builtins.match \"(.*)\" \"a\\nb\" == [ \"a\\nb\" ]" (VBool True),+      runTest "split dot spans newline" $+        assertEval "split-dot-nl" "builtins.split \"a.b\" \"xa\\nby\" == [ \"x\" [ ] \"y\" ]" (VBool True),+      -- Derivations compare by outPath alone (C++ eqValues special case) -+      -- even when the rest of the attrs (or the key sets) differ.+      runTest "derivation eq by outPath" $+        assertEval+          "drv-eq"+          "{ type = \"derivation\"; outPath = \"/nix/store/a\"; f = (x: x); } == { type = \"derivation\"; outPath = \"/nix/store/a\"; f = (y: y); extra = 1; }"+          (VBool True),+      runTest "derivation neq by outPath" $+        assertEval+          "drv-neq"+          "{ type = \"derivation\"; outPath = \"/a\"; } == { type = \"derivation\"; outPath = \"/b\"; }"+          (VBool False),+      runTest "derivation-tagged sets without outPath deep-compare" $+        assertEval+          "drv-no-outpath"+          "{ type = \"derivation\"; n = 1; } == { type = \"derivation\"; n = 1; }"+          (VBool True),+      -- A throwing derivation attr fails evaluation - never a silent drop+      -- (a dropped attr once produced an empty no-input .drv that "built").+      runTest "derivation attr throw propagates" $+        assertEvalFail "drv-throw" "(derivation { name = \"x\"; system = \"s\"; builder = \"/b\"; src = throw \"boom\"; }).drvPath",+      -- __ignoreNulls drops null attrs; without it null coerces to "".+      runTest "derivation ignoreNulls drops null" $+        assertEval "drv-ignore-nulls" "builtins.isString (derivation { name = \"x\"; system = \"s\"; builder = \"/b\"; src = null; __ignoreNulls = true; }).drvPath" (VBool True),+      runTest "derivation null coerces without ignoreNulls" $+        assertEval "drv-null-empty" "builtins.isString (derivation { name = \"x\"; system = \"s\"; builder = \"/b\"; foo = null; }).drvPath" (VBool True),+      -- A literal string key is static, as in upstream's parser: legal in+      -- let, referenceable as a rec sibling.+      runTest "let with literal string key" $+        assertEval "let-string-key" "let \"x\" = 1; in x" (VInt 1),+      runTest "rec string key is a referenceable sibling" $+        assertEval "rec-string-key" "(rec { \"a\" = 1; b = a; }).b" (VInt 1),+      -- A dynamic TOP-LEVEL key in let is a parse error upstream; nested+      -- dynamic keys live in a nested attrset and stay legal.+      runTest "dynamic key in let rejected" $+        assertParseFail "let-dyn-key" "let k = \"y\"; in let ${k} = 1; in y",+      runTest "interpolated string key in let rejected" $+        assertParseFail "let-interp-key" "let k = \"y\"; in let \"${k}\" = 1; in y",+      runTest "nested dynamic key in let allowed" $+        assertEval "let-nested-dyn" "let k = \"q\"; in let a.${k} = 1; in a.q" (VInt 1),+      -- Nested interpolated strings inside braces: the lexer's brace-depth+      -- stack must restore the enclosing count when an interpolation+      -- closes, or the outer attrset's '}' mislexes as TokInterpClose.+      runTest "nested interpolated string inside attrset braces" $+        assertEval "nested-interp" "let y = \"v\"; in \"${ { a = \"${y}\"; }.a }\"" (mkStr "v"),+      runTest "nested interpolation in indented string" $+        assertEval "nested-interp-ind" "let y = \"w\"; in ''pre ${ { a = ''${y}''; }.a } post''" (mkStr "pre w post"),+      runTest "doubly nested interpolated strings" $+        assertEval "nested-interp-2" "let y = \"z\"; in \"${ { a = \"${ { b = \"${y}\"; }.b }\"; }.a }\"" (mkStr "z"),+      -- Attrpath merging (upstream parser.y addAttr): a literal set and a+      -- nested path under the same key merge; genuine duplicates error.+      runTest "literal set merges with attrpath (kept attr)" $+        assertEval "merge-keep" "{ a = { b = 1; }; a.c = 2; }.a.b" (VInt 1),+      runTest "literal set merges with attrpath (added attr)" $+        assertEval "merge-add" "{ a = { b = 1; }; a.c = 2; }.a.c" (VInt 2),+      runTest "duplicate plain key is a parse error" $+        assertParseFail "dup-key" "{ a = 1; a = 2; }",+      runTest "duplicate formal is rejected" $+        assertParseFail "dup-formal" "{ a, a }: a",+      runTest "duplicate formal with defaults is rejected" $+        assertParseFail "dup-formal-default" "{ a ? 1, a ? 2 }: a",+      runTest "duplicate inner key on merge is an error" $+        assertParseFail "dup-inner" "{ a.b = 1; a = { b = 2; }; }",+      runTest "inherit conflicting with a definition is an error" $+        assertParseFail "dup-inherit" "let q = 1; in { inherit q; q = 2; }",+      runTest "attrpath into a non-set is an error" $+        assertParseFail "merge-non-set" "{ a = 1; a.b = 2; }",+      -- rec markers in attrpath merging follow upstream's addAttr: the+      -- existing set's marker governs the result; the new set's marker is+      -- discarded.+      runTest "merge into rec literal keeps rec (sibling visible)" $+        assertEval "merge-rec-keep" "{ a = rec { b = 1; }; a.c = b; }.a.c" (VInt 1),+      runTest "merged-in rec marker is discarded (outer binding wins)" $+        assertEval "merge-rec-discard" "let d = 7; in { a = { x = 1; }; a = rec { c = d; }; }.a.c" (VInt 7),+      -- Exactly upstream's unprefixed global surface: fetchurl and toFile+      -- exist only under builtins.+      runTest "bare fetchurl is not a global" $+        assertEvalFail "no-bare-fetchurl" "fetchurl",+      runTest "bare toFile is not a global" $+        assertEvalFail "no-bare-tofile" "toFile",+      -- Dynamic keys colliding with an existing attr are an eval error,+      -- never a silent last-win merge.+      runTest "dynamic key colliding with static errors" $+        assertEvalFail "dyn-collide" "{ b = 1; ${\"b\"} = 2; }",+      runTest "rec dynamic key colliding with static errors" $+        assertEvalFail "dyn-collide-rec" "rec { b = 1; p.q = 1; ${\"b\"} = 2; }",+      -- Higher-order list builtins pass elements as unforced thunks: an+      -- element the function never inspects may throw without failing.+      runTest "filter does not force elements" $+        assertEval "filter-lazy" "builtins.length (builtins.filter (x: true) [ (builtins.throw \"never\") ])" (VInt 1),+      runTest "any does not force undecided elements" $+        assertEval "any-lazy" "builtins.any (x: x) [ true (builtins.throw \"never\") ]" (VBool True),+      runTest "foldl' passes elements unforced" $+        assertEval "foldl-lazy" "builtins.foldl' (a: b: a) 0 [ (builtins.throw \"never\") ]" (VInt 0),+      -- Attrset equality stops at the first mismatch; later pairs are+      -- never forced.+      runTest "attrset eq short-circuits" $+        assertEval "eq-short" "{ a = 1; b = builtins.throw \"never\"; } == { a = 2; b = builtins.throw \"never\"; }" (VBool False),+      -- toString of a float is std::to_string's fixed 6 decimals; value+      -- printing and toJSON keep the trimmed form.+      runTest "toString float has fixed 6 decimals" $+        assertEval "tostr-float" "builtins.toString 1.5" (mkStr "1.500000"),+      runTest "toJSON float stays trimmed" $+        assertEval "tojson-float" "builtins.toJSON 1.5" (mkStr "1.5"),+      -- path + string-with-context is an error (a store-path reference+      -- cannot survive inside a path value).+      runTest "path plus string with context errors" $+        assertEvalFail "path-ctx" "./x + (derivation { name = \"q\"; system = \"s\"; builder = \"/b\"; }).drvPath",+      -- builtins.toJSON honors __toString, preferring it over outPath+      runTest "toJSON __toString" $+        assertEval "tojson-tostr" "builtins.toJSON { __toString = self: \"x\"; }" (mkStr "\"x\""),+      runTest "toJSON __toString beats outPath" $+        assertEval "tojson-tostr-out" "builtins.toJSON { __toString = self: \"a\"; outPath = \"/b\"; }" (mkStr "\"a\""),+      runTest "toJSON outPath still works" $+        assertEval "tojson-out" "builtins.toJSON { outPath = \"/b\"; }" (mkStr "\"/b\""),+      -- String interpolation is strict (coerceMore = False): scalars error+      runTest "interp rejects int" $+        assertEvalFail "interp-int" "\"v${1}\"",+      runTest "interp rejects bool" $+        assertEvalFail "interp-bool" "\"${true}\"",+      runTest "interp rejects null" $+        assertEvalFail "interp-null" "\"${null}\"",+      runTest "concatStringsSep rejects int" $+        assertEvalFail "ccs-int" "builtins.concatStringsSep \",\" [ 1 2 ]",+      -- builtins.toString stays permissive+      runTest "toString still coerces int" $+        assertEval "tostr-int" "builtins.toString 1" (mkStr "1"),+      runTest "interp via toString works" $+        assertEval "interp-tostr" "\"v${builtins.toString 1}\"" (mkStr "v1"),+      -- builtins.substring rejects a negative start position+      runTest "substring negative start errors" $+        assertEvalFail "substr-neg" "builtins.substring (0 - 1) 3 \"hello\"",+      -- an integer literal that overflows Int64 is a parse error, not a wrap+      runTest "integer literal overflow errors" $+        assertParseFail "int-overflow" "99999999999999999999",+      -- float exponents and leading-dot floats lex per the Nix grammar+      runTest "float exponent lexes as float" $+        assertEval "float-exp-type" "builtins.typeOf 6.022e23" (mkStr "float"),+      runTest "float negative exponent lexes" $+        assertEval "float-negexp-type" "builtins.typeOf 1.0e-3" (mkStr "float"),+      runTest "float exponent value" $+        assertEval "float-exp-val" "1.5e1 == 15.0" (VBool True),+      runTest "leading-dot float lexes as float" $+        assertEval "leading-dot-type" "builtins.typeOf .5" (mkStr "float"),+      runTest "leading-dot float value" $+        assertEval "leading-dot-val" ".5 == 0.5" (VBool True),+      -- PureEval tryEval must NOT catch abort - it propagates (matches C++ Nix)+      runTest "tryEval does not catch abort" $+        assertEvalFail "tryeval-abort" "(builtins.tryEval (builtins.abort \"x\")).success",+      -- every builtin in the registry (the source of builtinNames/builtinArity)+      -- is actually exposed in the builtins set - guards against builtinRegistry+      -- drifting from the exposed builtins+      runTest "every registered builtin is exposed" $+        let missing = [n | n <- builtinNames, evalNix ("builtins ? \"" <> n <> "\"") /= Right (VBool True)]+         in if null missing then Pass else Fail ("not exposed: " <> T.intercalate ", " missing)+    ]++-- | Pure hash decode/digest helpers shared by eval (fixed-output paths) and+-- the builder (builtin:fetchurl verification).+testHashHelpers :: IO [Bool]+testHashHelpers = do+  putStrLn "hash/decode-helpers"+  sequence+    [ -- The incremental digest is the streaming twin of the one-shot:+      -- chunked input must produce identical bytes for every algorithm.+      runTest "incremental digest matches one-shot across algorithms" $+        let chunks = ["nova", "-", "nix", " streams", BS.replicate 1000 55]+            agrees algo = case (Hash.hashInitWithAlgo algo, Hash.rawHashWithAlgo algo (BS.concat chunks)) of+              (Just ctx0, Just expected) ->+                Hash.hashFinalizeBytes (foldl' Hash.hashUpdateChunk ctx0 chunks) == expected+              _ -> False+         in if all agrees ["sha256", "sha512", "sha1", "md5"]+              then Pass+              else Fail "incremental and one-shot digests disagree",+      runTest "incremental digest rejects an unknown algorithm" $+        case Hash.hashInitWithAlgo "blake3" of+          Nothing -> Pass+          Just _ -> Fail "unknown algorithm accepted",+      runTest "hexToBytes empty" $ assertEqual "empty" (Just BS.empty) (Hash.hexToBytes ""),+      runTest "hexToBytes deadbeef" $+        assertEqual "deadbeef" (Just (BS.pack [0xde, 0xad, 0xbe, 0xef])) (Hash.hexToBytes "deadbeef"),+      runTest "hexToBytes uppercase" $+        assertEqual "DEADBEEF" (Just (BS.pack [0xde, 0xad, 0xbe, 0xef])) (Hash.hexToBytes "DEADBEEF"),+      runTest "hexToBytes odd length rejected" $ assertEqual "odd" Nothing (Hash.hexToBytes "abc"),+      runTest "hexToBytes non-hex rejected" $ assertEqual "nonhex" Nothing (Hash.hexToBytes "zz"),+      runTest "rawHashWithAlgo sha256 empty == known vector" $+        assertEqual+          "sha256-empty"+          (Hash.hexToBytes "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")+          (Hash.rawHashWithAlgo "sha256" BS.empty),+      runTest "rawHashWithAlgo unknown algo rejected" $+        assertEqual "unknown" Nothing (Hash.rawHashWithAlgo "sha3-256" (BS.pack [1, 2, 3])),+      -- verifyFetchHash: the composed builtin:fetchurl integrity gate that+      -- guards every fixed-output seed fetch+      runTest "verifyFetchHash accepts a matching flat hash" $+        assertEqual+          "fetch-hash-ok"+          (Right ())+          (verifyFetchHash "https://x/f.tar.gz" (fetchOut "sha256" sha256Hello) "hello"),+      runTest "verifyFetchHash rejects mismatched bytes" $+        case verifyFetchHash "https://x/f.tar.gz" (fetchOut "sha256" sha256Hello) "world" of+          Left (_, msg) | "hash mismatch" `T.isInfixOf` msg -> Pass+          other -> Fail ("expected mismatch rejection, got: " <> T.pack (show other)),+      runTest "verifyFetchHash rejects a malformed expected hash" $+        case verifyFetchHash "https://x/f" (fetchOut "sha256" "zz-not-hex") "hello" of+          Left (_, msg) | "malformed expected hash" `T.isInfixOf` msg -> Pass+          other -> Fail ("expected malformed-hash rejection, got: " <> T.pack (show other)),+      runTest "verifyFetchHash rejects an unsupported algorithm" $+        case verifyFetchHash "https://x/f" (fetchOut "sha3-256" "00") "hello" of+          Left (_, msg) | "unsupported hash algorithm" `T.isInfixOf` msg -> Pass+          other -> Fail ("expected unsupported-algo rejection, got: " <> T.pack (show other)),+      runTest "verifyFetchHash rejects recursive mode rather than comparing flat" $+        case verifyFetchHash "https://x/f" (fetchOut "r:sha256" sha256Hello) "hello" of+          Left (_, msg) | "recursive" `T.isInfixOf` msg -> Pass+          other -> Fail ("expected recursive-mode rejection, got: " <> T.pack (show other))+    ]+  where+    -- sha256 of the ASCII bytes "hello".+    sha256Hello = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"+    fetchOut algo hash =+      DerivationOutput+        { doName = "out",+          doPath = StorePath (T.replicate 32 "f") "fetched",+          doHashAlgo = algo,+          doHash = hash+        }++-- ---------------------------------------------------------------------------+-- Tests: NAR and hash known-answer vectors+-- ---------------------------------------------------------------------------++-- | Encode one NAR string per the spec: little-endian u64 length, the+-- bytes, zero-padding to the next 8-byte boundary.  Written here from the+-- format definition, deliberately independent of nova-cache's encoder, so+-- these vectors pin the wire layout rather than the library against+-- itself.+narSpecStr :: BS.ByteString -> BS.ByteString+narSpecStr s = lenLE <> s <> padding+  where+    n = BS.length s+    lenLE = BS.pack [fromIntegral ((n `shiftR` (8 * i)) .&. 0xff) | i <- [0 .. 7]]+    padding = BS.replicate ((8 - n `mod` 8) `mod` 8) 0++-- | Hand-encoded NAR of a directory holding an executable, a plain file,+-- and a symlink - covering entry ordering, the executable marker, content+-- padding, and all node kinds.+narSpecVector :: BS.ByteString+narSpecVector =+  BS.concat $+    map+      narSpecStr+      [ "nix-archive-1",+        "(",+        "type",+        "directory",+        "entry",+        "(",+        "name",+        "bin",+        "node",+        "(",+        "type",+        "directory",+        "entry",+        "(",+        "name",+        "tool",+        "node",+        "(",+        "type",+        "regular",+        "executable",+        "",+        "contents",+        "#!x",+        ")",+        ")",+        ")",+        ")",+        "entry",+        "(",+        "name",+        "data.txt",+        "node",+        "(",+        "type",+        "regular",+        "contents",+        "hello\n",+        ")",+        ")",+        "entry",+        "(",+        "name",+        "link",+        "node",+        "(",+        "type",+        "symlink",+        "target",+        "data.txt",+        ")",+        ")",+        ")"+      ]++-- | The in-memory tree 'narSpecVector' encodes.+narSpecTree :: NAR.NarEntry+narSpecTree =+  NAR.NarDirectory+    [ ("bin", NAR.NarDirectory [("tool", NAR.NarRegular True "#!x")]),+      ("data.txt", NAR.NarRegular False "hello\n"),+      ("link", NAR.NarSymlink "data.txt")+    ]++-- | Hand-encoded NAR of just @data.txt@, for the on-disk addToStore vector.+-- No executable entry, so the vector stays a fixture for addToStore rather+-- than for the exec bit; 'testExecBit' covers that on both platforms.+narSpecFileVector :: BS.ByteString+narSpecFileVector =+  BS.concat $+    map+      narSpecStr+      [ "nix-archive-1",+        "(",+        "type",+        "directory",+        "entry",+        "(",+        "name",+        "data.txt",+        "node",+        "(",+        "type",+        "regular",+        "contents",+        "hello\n",+        ")",+        ")",+        ")"+      ]++-- | fromTOML: multi-line values (the Cargo.lock shapes) and comment+-- placement relative to strings.+testFromTOML :: IO [Bool]+testFromTOML = do+  putStrLn "eval/fromTOML"+  sequence+    [ -- Key splitting and logical-line joining accumulate in chunks, so+      -- cost is linear in the input (per-character/per-line append was+      -- quadratic).  The watchdog turns a cost regression into a FAIL+      -- rather than a stuck suite.+      runTestM "fromTOML long key parses in linear time" $ do+        let source =+              "builtins.length (builtins.attrNames (builtins.fromTOML \""+                <> T.replicate 200000 "k"+                <> " = 1\"))"+            counted = evalNix source == Right (VInt 1)+        outcome <- timeout walkWatchdogMicros (evaluate counted)+        pure $ case outcome of+          Just True -> Pass+          Just False -> Fail "wrong parse for a long key"+          Nothing -> Fail "long key did not parse promptly",+      runTestM "fromTOML many continued lines join in linear time" $ do+        -- 4000 physical lines of 50 elements each: one ~400KB logical+        -- line, one 200000-element array.+        let line = T.intercalate " " (replicate 50 "1,")+            body = "x = [\n" <> T.intercalate "\n" (replicate 4000 line) <> "\n]"+            source = "builtins.length (builtins.fromTOML \"" <> body <> "\").x"+            counted = evalNix source == Right (VInt 200000)+        outcome <- timeout walkWatchdogMicros (evaluate counted)+        pure $ case outcome of+          Just True -> Pass+          Just False -> Fail "wrong parse for continued lines"+          Nothing -> Fail "continued lines did not join promptly",+      runTest "multi-line array" $+        assertEval+          "toml-ml-array"+          "builtins.concatStringsSep \",\" (builtins.fromTOML \"deps = [\\n \\\"bar\\\",\\n \\\"baz\\\",\\n]\\n\").deps"+          (mkStr "bar,baz"),+      runTest "Cargo.lock package shape" $+        assertEval+          "toml-cargo-lock"+          "(builtins.elemAt (builtins.fromTOML \"[[package]]\\nname = \\\"foo\\\"\\ndependencies = [\\n \\\"bar\\\",\\n]\\n\").package 0).name"+          (mkStr "foo"),+      runTest "multi-line basic string" $+        assertEval+          "toml-ml-string"+          "(builtins.fromTOML \"s = \\\"\\\"\\\"\\nline1\\nline2\\\"\\\"\\\"\").s"+          (mkStr "line1\nline2"),+      runTest "multi-line literal string" $+        assertEval+          "toml-ml-literal"+          "(builtins.fromTOML \"s = '''\\nkeep'''\").s"+          (mkStr "keep"),+      runTest "comment inside a multi-line array" $+        assertEval+          "toml-ml-comment"+          "builtins.concatStringsSep \",\" (builtins.fromTOML \"deps = [ # opening\\n \\\"bar\\\", # trailing\\n]\").deps"+          (mkStr "bar"),+      runTest "hash inside a string is content" $+        assertEval+          "toml-hash-content"+          "(builtins.fromTOML \"s = \\\"\\\"\\\"a#b\\\"\\\"\\\"\").s"+          (mkStr "a#b"),+      runTest "single-line values still parse" $+        assertEval+          "toml-single-line"+          "(builtins.fromTOML \"x = 4\\ny = \\\"z\\\" # cmt\").y"+          (mkStr "z")+    ]++testAttrPath :: IO [Bool]+testAttrPath = do+  putStrLn "\n-- Attribute paths (build -A) --"+  sequence+    [ runTest "a bare name is one component" $+        assertEqual "single" (Right ["hello"]) (parseAttrPath "hello"),+      runTest "dots separate components" $+        assertEqual "dotted" (Right ["a", "b", "c"]) (parseAttrPath "a.b.c"),+      runTest "a quoted component carries a dot" $+        assertEqual "quoted" (Right ["a", "b.c", "d"]) (parseAttrPath "a.\"b.c\".d"),+      -- Upstream's quotes concatenate rather than delimit, so a quoted run+      -- glues onto whatever sits beside it instead of standing alone.+      runTest "quotes concatenate onto the surrounding name" $+        assertEqual+          "glued"+          [Right ["foobar"], Right ["foobar"]]+          [parseAttrPath "\"foo\"bar", parseAttrPath "foo\"bar\""],+      -- The last component is kept only when non-empty, so these three+      -- select nothing at all rather than naming an empty attribute.+      runTest "an empty path selects nothing" $+        assertEqual+          "nothing"+          [Right [], Right [], Right ["a"]]+          [parseAttrPath "", parseAttrPath "\"\"", parseAttrPath "a."],+      -- An interior empty component survives tokenizing; selection rejects+      -- it, and only after checking the type of what it would index.+      runTest "an interior empty component survives tokenizing" $+        assertEqual+          "interior"+          [Right ["", "a"], Right ["a", "", "b"]]+          [parseAttrPath ".a", parseAttrPath "a..b"],+      runTest "an unclosed quote is an error" $+        assertEqual+          "unclosed"+          (Left "missing closing quote in selection path \'a.\"b\'")+          (parseAttrPath "a.\"b")+    ]++-- | The remembered-fetch record, on its own.  All three functions are pure,+-- and every property that keeps a warm cache honest is decided here: what+-- shares an entry, what round-trips, and what a torn file decodes to.+testFetchCache :: IO [Bool]+testFetchCache = do+  putStrLn "eval/fetch-cache"+  sequence+    [ runTest "the key separates every input that decides the answer" $+        let base = fetchCacheKey "https://example.com/r" "source" "abc" False False+            variants =+              [ fetchCacheKey "https://example.com/s" "source" "abc" False False,+                fetchCacheKey "https://example.com/r" "other" "abc" False False,+                fetchCacheKey "https://example.com/r" "source" "def" False False,+                fetchCacheKey "https://example.com/r" "source" "abc" True False,+                -- shallow decides revCount, so it cannot share an entry+                -- with a full clone of the same revision.+                fetchCacheKey "https://example.com/r" "source" "abc" False True+              ]+         in assertEqual "collisions with the base key" [] (filter (== base) variants),+      runTest "the same fetch keys the same both times" $+        assertEqual+          "repeat"+          (fetchCacheKey "https://example.com/r" "source" "abc" True True)+          (fetchCacheKey "https://example.com/r" "source" "abc" True True),+      runTest "an entry round-trips through encode and decode" $+        assertEqual "decode . encode" (Just sampleFetchCache) (decodeFetchCache (encodeFetchCache sampleFetchCache)),+      runTest "a torn entry decodes to nothing" $+        let whole = encodeFetchCache sampleFetchCache+            -- Every prefix, so the cut lands in each field in turn.  The+            -- last field is the one that matters: a cut before it leaves+            -- fewer than five lines and is rejected on shape alone, while+            -- a cut inside narHash still leaves five.+            torn = [T.take n whole | n <- [0 .. T.length whole - 1]]+         in assertEqual "prefixes that decoded" [] (filter (isJust . decodeFetchCache) torn),+      runTest "a non-decimal count is not an entry" $+        assertEqual+          "revCount"+          Nothing+          (decodeFetchCache (replaceCount (encodeFetchCache sampleFetchCache) "12x")),+      -- The hit path itself, end to end through builtins.fetchGit.  Nothing+      -- here can reach the network: the stub's createScratchDir throws, so+      -- a Right at all proves the fetch was skipped, and every field comes+      -- from the entry rather than from git.+      runTest "a warm entry answers builtins.fetchGit without fetching" $+        case evalWarmFetchGit sampleFetchCache (Set.singleton (fcStorePath sampleFetchCache)) of+          Left err -> Fail ("a warm cache still tried to fetch: " <> err)+          Right val ->+            assertEqual+              "fields"+              [ ("outPath", VPath (fcStorePath sampleFetchCache)),+                ("rev", mkStr (fcRev sampleFetchCache)),+                ("revCount", VInt 1234),+                ("narHash", mkStr ("sha256-" <> fcNarHash sampleFetchCache))+              ]+              (fetchGitFields val ["outPath", "rev", "revCount", "narHash"]),+      -- The guard on the hit, on the axis that matters.  doesPathExist is+      -- False throughout the stub and adoptStorePath is what decides, so a+      -- hit that consulted mere existence would fail this and a hit that+      -- adopts the path passes the case above.  Adoption is what records+      -- the store write, and a hit that skipped it would hand back an+      -- outPath no derivation naming it could be built against.+      runTest "an entry whose path cannot be adopted refetches" $+        case evalWarmFetchGit sampleFetchCache Set.empty of+          Right _ -> Fail "an unadoptable entry was served from the cache"+          Left err+            | "createScratchDir" `T.isInfixOf` err -> Pass+            | otherwise -> Fail ("expected a refetch, got: " <> err),+      runTest "a bare ref is never served from the cache" $+        -- Whatever the cache holds, a ref means "wherever this branch+        -- points now", so the entry must not be consulted at all.+        case evalNixStubWith+          (warmEnv sampleFetchCache (Set.singleton (fcStorePath sampleFetchCache)))+          "builtins.fetchGit { url = \"https://example.com/r\"; ref = \"main\"; }" of+          Right _ -> Fail "a bare ref was served from the cache"+          Left err+            | "createScratchDir" `T.isInfixOf` err -> Pass+            | otherwise -> Fail ("expected a fetch, got: " <> err),+      runTest "a hash that is not base64 sha256 is not an entry" $+        assertEqual+          "narHash"+          [Nothing, Nothing, Nothing]+          ( map+              (decodeFetchCache . encodeFetchCache)+              [ sampleFetchCache {fcNarHash = T.take 43 (fcNarHash sampleFetchCache)},+                sampleFetchCache {fcNarHash = T.replicate 44 "="},+                sampleFetchCache {fcNarHash = T.take 43 (fcNarHash sampleFetchCache) <> "A"}+              ]+          )+    ]+  where+    -- Swap the encoded revCount line, which encodeFetchCache cannot+    -- produce: the field is an Integer on the way in.+    replaceCount encoded replacement = case T.splitOn "\n" encoded of+      [storePath, rev, _, lastModified, narHash] ->+        T.intercalate "\n" [storePath, rev, replacement, lastModified, narHash]+      _ -> encoded++-- | A stub store whose cache is warm for 'evalWarmFetchGit'\'s fetch, and+-- which will adopt exactly the given store paths.+warmEnv :: FetchCache -> Set.Set Text -> StubEnv+warmEnv fields adoptable =+  (stubEnv Map.empty)+    { seFetchCache =+        Map.singleton+          (fetchCacheKey "https://example.com/r" "source" warmFetchGitRev False False)+          (encodeFetchCache fields),+      seAdoptable = adoptable+    }++-- | The pinned revision 'evalWarmFetchGit' asks for.+warmFetchGitRev :: Text+warmFetchGitRev = T.replicate 40 "b"++-- | Evaluate a pinned @builtins.fetchGit@ against a warm cache.+evalWarmFetchGit :: FetchCache -> Set.Set Text -> Either Text NixValue+evalWarmFetchGit fields adoptable =+  evalNixStubWith+    (warmEnv fields adoptable)+    ("builtins.fetchGit { url = \"https://example.com/r\"; rev = \"" <> warmFetchGitRev <> "\"; }")++-- | Read the named attributes off a @fetchGit@ result, in the order asked.+fetchGitFields :: NixValue -> [Text] -> [(Text, NixValue)]+fetchGitFields val names = case val of+  VAttrs attrs ->+    [ (name, value)+    | name <- names,+      Just thunk <- [attrSetLookup name attrs],+      Just value <- [readThunkValue thunk]+    ]+  _ -> []++-- | One recorded fetch, with a real 44-character base64 sha256.+sampleFetchCache :: FetchCache+sampleFetchCache =+  FetchCache+    { fcStorePath = "/nix/store/" <> T.replicate 32 "a" <> "-source",+      fcRev = T.replicate 40 "b",+      fcRevCount = 1234,+      fcLastModified = 1700000000,+      fcNarHash = T.replicate 43 "A" <> "="+    }++-- | Which derivations this machine will spawn, and how.+--+-- The distinction that matters is between the two answers that used to be+-- one: a derivation for this platform needs no launcher, and a derivation+-- for another platform with no launcher configured is not the same thing.+testExecWrapper :: IO [Bool]+testExecWrapper = do+  putStrLn "builder/exec-wrapper"+  sequence+    [ runTest "a derivation for this platform is spawned directly" $+        assertEqual+          "native"+          SpawnNative+          (execWrapperFor (wrapperConfig [(platformToText foreignPlatform, "/usr/bin/wine")]) (drvFor currentPlatform)),+      runTest "a configured foreign system spawns through its launcher" $+        assertEqual+          "wrapped"+          (SpawnThrough "/usr/bin/wine")+          (execWrapperFor (wrapperConfig [(platformToText foreignPlatform, "/usr/bin/wine")]) (drvFor foreignPlatform)),+      -- The finding this exists for: without a distinct answer here, a+      -- foreign builder was spawned natively and failed in the loader,+      -- after the whole closure had already been realized.+      runTest "an unconfigured foreign system is refused, not spawned" $+        assertEqual+          "unsupported"+          (SpawnUnsupported (platformToText foreignPlatform))+          (execWrapperFor (wrapperConfig []) (drvFor foreignPlatform)),+      -- The system string is the derivation's own spelling, matched+      -- exactly.  The documentation once named a different one, which+      -- parsed, stored, and then never matched anything.+      runTest "SYSTEM=PATH splits at the first separator" $+        assertEqual+          "specs"+          (Right (Map.fromList [("x86_64-windows", "/usr/bin/wine"), ("aarch64-linux", "/opt/qemu=static/qemu")]))+          (execWrapperConfig ["x86_64-windows=/usr/bin/wine", "aarch64-linux=/opt/qemu=static/qemu"]),+      runTest "no specs is an empty map, not an error" $+        assertEqual "empty" (Right Map.empty) (execWrapperConfig []),+      runTest "a spec missing either half is rejected" $+        assertEqual+          "malformed"+          (replicate 4 True)+          (map (either (const True) (const False) . execWrapperConfig . pure) ["wine", "=wine", "x86_64-windows=", ""]),+      -- Last-one-wins would run a build through a launcher the operator+      -- did not think they had asked for.+      runTest "naming one system twice is rejected" $+        assertEqual+          "duplicate"+          (Left "--exec-wrapper names x86_64-windows twice")+          (execWrapperConfig ["x86_64-windows=/usr/bin/wine", "x86_64-windows=/usr/bin/wine64"]),+      runTest "a launcher keyed on any other spelling does not match" $+        assertEqual+          "near-miss keys"+          (replicate 3 (SpawnUnsupported (platformToText foreignPlatform)))+          ( [ execWrapperFor (wrapperConfig [(key, "/usr/bin/wine")]) (drvFor foreignPlatform)+            | key <- ["x86-windows", "windows", T.toUpper (platformToText foreignPlatform)]+            ]+          )+    ]+  where+    -- Some platform this test is not running on, so "foreign" is foreign+    -- whichever host runs the suite.+    foreignPlatform =+      if currentPlatform == X86_64_Windows then X86_64_Linux else X86_64_Windows+    wrapperConfig entries =+      (defaultBuildConfig (StoreDir "/nix/store")) {bcExecWrappers = Map.fromList entries}+    drvFor platform =+      Derivation+        { drvOutputs = [DerivationOutput "out" (StorePath (T.replicate 32 "a") "spawn") "" ""],+          drvInputDrvs = Map.empty,+          drvInputSrcs = [],+          drvPlatform = platform,+          drvBuilder = "/bin/sh",+          drvArgs = [],+          drvEnv = Map.empty+        }++testFetchMirrors :: IO [Bool]+testFetchMirrors = do+  putStrLn "builder/fetch-mirrors"+  sequence+    [ runTest "fetch URLs preserve the legacy single URL" $+        assertEqual+          "url"+          (Right ("https://a/file%20name" :| []))+          (fetchUrlsFromEnv (Map.singleton "url" "https://a/file%20name")),+      runTest "fetch URLs prefer the explicit ordered list" $+        assertEqual+          "urls"+          (Right ("https://a" :| ["https://b"]))+          (fetchUrlsFromEnv (Map.fromList [("url", "https://ignored"), ("urls", "  https://a\t https://b\n")])),+      runTest "fetch URLs preserve non-ASCII whitespace within a URL" $+        let url = "https://a/has\xA0space"+         in assertEqual+              "non-ASCII URL"+              (Right (url :| ["https://b"]))+              (fetchUrlsFromEnv (Map.singleton "urls" (TE.encodeUtf8 (url <> " https://b")))),+      runTest "fetch URLs reject missing, empty and invalid UTF-8 inputs" $+        assertEqual+          "invalid"+          (replicate 5 True)+          ( map+              (either (const True) (const False) . fetchUrlsFromEnv)+              [Map.empty, Map.singleton "url" "", Map.fromList [("urls", " "), ("url", "https://ignored")], Map.singleton "url" (BS.pack [255]), Map.singleton "urls" (BS.pack [255])]+          ),+      runTestM "mirror policy retries timeout and hash failures in order" $ do+        calls <- newIORef []+        let attempt url = do+              atomicModifyIORef' calls (\seen -> (seen ++ [url], ()))+              pure $ case url of+                "slow" -> Left "download timeout"+                "corrupt" -> Left "hash mismatch"+                _ -> Right ("verified" :: Text)+        result <- tryFetchUrlsWith attempt ("slow" :| ["corrupt", "good", "unused"])+        seen <- readIORef calls+        pure (assertEqual "ordered attempts" (Right "verified", ["slow", "corrupt", "good"]) (result, seen)),+      runTestM "mirror policy returns every failure in order" $ do+        result <- tryFetchUrlsWith (pure . Left) ("first failure" :| ["second failure"])+        pure (assertEqual "failures" (Left "first failure\nsecond failure" :: Either Text ()) result),+      runTestM "mirror policy does not evaluate candidates after success" $ do+        let attempt "good" = pure (Right "good")+            attempt _ = fail "unused mirror attempted"+        result <- tryFetchUrlsWith attempt ("good" :| ["unused"])+        pure (assertEqual "first success" (Right "good" :: Either Text Text) result),+      runTestM "HTTP mirrors verify hashes, truncate retries and stop on success" $+        httpCase "fallback" False ["/missing", "/bad", "/partial", "/good", "/unused"] True ["/missing", "/bad", "/partial", "/good"],+      runTestM "HTTP fetch retains single-url compatibility" $+        httpCase "legacy" True ["/good"] True ["/good"],+      runTestM "HTTP failure leaves no output or registration" $+        httpCase "errors" False ["/error", "/missing"] False ["/error", "/missing"],+      runTestM "hash failure leaves no corrupt output or registration" $+        httpCase "corrupt" False ["/bad", "/partial"] False ["/bad", "/partial"],+      runTestM "cancelling a download releases its lock and does not try another mirror" $+        withFetchurlServer $ \base requests requested ->+          withStore "cancel" $ \store config sp -> do+            let drv = fixtureDrv sp (Map.singleton "urls" (TE.encodeUtf8 (base <> "/slow " <> base <> "/good")))+            outcome <- timeout 10000000 $+              withAsync (buildDerivation config store drv) $ \worker -> do+                requested+                cancel worker+                waitCatch worker+            seen <- requests+            valid <- isValid store sp+            present <- Dir.doesPathExist (storePathToFilePath (stDir store) sp)+            lock <- tryAcquirePathLock (stDir store) sp+            mapM_ releasePathLock lock+            pure $ case outcome of+              Just (Left _) | seen == ["/slow"] && not valid && not present && isJust lock -> Pass+              _ -> Fail ("cancellation failed: " <> T.pack (show (outcome, seen, valid, present, isJust lock)))+    ]+  where+    hash = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"+    withStore label action = do+      tmpBase <- getTemporaryDirectory+      let root = tmpBase </> ("nova-nix-test-mirrors-" ++ label)+          dir = StoreDir (root </> "store")+          config = (defaultBuildConfig dir) {bcTmpDir = root </> "build"}+      sp <- either (fail . show) pure (makeFixedOutputPath "mirrors" "sha256" "flat" (sha256Digest "hello"))+      bracket_ (forceRemoveIfExists root) (forceRemoveIfExists root) $+        bracket (openStore dir) closeStore $+          \store -> action store config sp+    fixtureDrv sp env =+      Derivation+        { drvOutputs = [DerivationOutput "out" sp "sha256" hash],+          drvInputDrvs = Map.empty,+          drvInputSrcs = [],+          drvPlatform = currentPlatform,+          drvBuilder = "builtin:fetchurl",+          drvArgs = [],+          drvEnv = env+        }+    httpCase label legacy paths succeeds expectedRequests =+      withFetchurlServer $ \base requests _ ->+        withStore label $ \store config sp -> do+          let urls = T.unwords (map (base <>) paths)+              env = Map.singleton (if legacy then "url" else "urls") (TE.encodeUtf8 urls)+          result <- buildDerivation config store (fixtureDrv sp env)+          seen <- requests+          valid <- isValid store sp+          present <- Dir.doesPathExist (storePathToFilePath (stDir store) sp)+          case result of+            BuildSuccess _+              | succeeds && valid && present && seen == expectedRequests ->+                  assertEqual "verified output" "hello" <$> BS.readFile (storePathToFilePath (stDir store) sp)+            BuildFailure msg _ | not succeeds && not valid && not present && seen == expectedRequests && not (T.null msg) -> pure Pass+            _ -> pure (Fail (T.pack (show (result, seen, valid, present))))++testFetchGitTransport :: IO [Bool]+testFetchGitTransport = do+  putStrLn "eval/fetchgit-transport"+  sequence+    [ runTest "allowed schemes pass" $+        assertEqual+          "schemes"+          [ Right "https://example.com/repo.git",+            Right "http://example.com/repo.git",+            Right "ssh://git@example.com/repo.git",+            Right "git://example.com/repo.git",+            Right "file:///srv/repo"+          ]+          ( map+              checkGitUrl+              [ "https://example.com/repo.git",+                "http://example.com/repo.git",+                "ssh://git@example.com/repo.git",+                "git://example.com/repo.git",+                "file:///srv/repo"+              ]+          ),+      runTest "scheme match is case-insensitive" $+        assertEqual "upper" (Right "HTTPS://example.com/r") (checkGitUrl "HTTPS://example.com/r"),+      runTest "scp-like remotes and local paths pass" $+        assertEqual+          "plain"+          [ Right "git@github.com:owner/repo.git",+            Right "user@[::1]:repo",+            Right "/srv/repo",+            Right "./repo",+            Right "C:\\src\\repo"+          ]+          ( map+              checkGitUrl+              [ "git@github.com:owner/repo.git",+                "user@[::1]:repo",+                "/srv/repo",+                "./repo",+                "C:\\src\\repo"+              ]+          ),+      runTest "helper transports are rejected" $+        let rejected = ["ext::sh -c 'printf x'", "fd::17", "my-helper_2::payload", "::payload"]+         in case [url | url <- rejected, either (const False) (const True) (checkGitUrl url)] of+              [] -> Pass+              slipped -> Fail ("helper urls accepted: " <> T.pack (show slipped)),+      runTest "an unknown explicit scheme is rejected" $+        assertLeft "hg scheme" (checkGitUrl "hg://example.com/repo"),+      runTest "the empty url is rejected" $+        assertLeft "empty" (checkGitUrl ""),+      runTestM "fetchGit refuses a helper transport at eval time" $ do+        outcome <- evalNixIO "." "builtins.fetchGit { url = \"ext::printf x\"; }"+        pure $ case outcome of+          Left err+            | "transport" `T.isInfixOf` err -> Pass+            | otherwise -> Fail ("wrong error: " <> err)+          Right _ -> Fail "helper transport url evaluated",+      -- A ref reaching git as an option is command execution: git parses+      -- options after the remote name, and --upload-pack names the+      -- transport command.  The leading-character class is what stops it.+      runTest "a ref that git would read as an option is rejected" $+        let rejected =+              [ "--upload-pack=touch /tmp/pwned",+                "-o",+                "--exec=sh",+                "",+                "branch with spaces",+                "semi;colon",+                "back\\slash"+              ]+         in case [ref | ref <- rejected, either (const False) (const True) (checkGitRef ref)] of+              [] -> Pass+              slipped -> Fail ("refs accepted: " <> T.pack (show slipped)),+      runTest "ordinary ref names are accepted" $+        let accepted = ["main", "HEAD", "release/1.0", "v2.3.4", "user/topic-1", "@", "a+b"]+         in case [ref | ref <- accepted, either (const True) (const False) (checkGitRef ref)] of+              [] -> Pass+              refused -> Fail ("refs refused: " <> T.pack (show refused)),+      runTest "a rev must be a full SHA-1" $+        let rejected =+              [ "--upload-pack=touch /tmp/pwned",+                "dcb93b58",+                "v1.0",+                "main",+                "",+                T.replicate 40 "g",+                T.replicate 41 "a"+              ]+         in case [rev | rev <- rejected, either (const False) (const True) (checkGitRev rev)] of+              [] -> Pass+              slipped -> Fail ("revs accepted: " <> T.pack (show slipped)),+      runTest "a full SHA-1 is accepted in either case" $+        let accepted = [T.replicate 40 "a", T.replicate 40 "F", "dcb93b58bd982ab64323bac2a8c11b138d6b55e5"]+         in case [rev | rev <- accepted, either (const True) (const False) (checkGitRev rev)] of+              [] -> Pass+              refused -> Fail ("revs refused: " <> T.pack (show refused)),+      runTestM "fetchGit refuses an option-shaped ref at eval time" $ do+        outcome <- evalNixIO "." "builtins.fetchGit { url = \"/srv/repo\"; ref = \"--upload-pack=id\"; }"+        pure $ case outcome of+          Left err+            | "ref is not a valid git ref name" `T.isInfixOf` err -> Pass+            | otherwise -> Fail ("wrong error: " <> err)+          Right _ -> Fail "option-shaped ref evaluated"+    ]++-- ---------------------------------------------------------------------------+-- builtins.fetchGit against a real repository+-- ---------------------------------------------------------------------------++-- | One git command against a fixture repository, identity and signing+-- pinned so no host configuration participates.  Nonzero exit throws+-- with git's stderr, failing the calling test loudly.+fixtureGit :: FilePath -> [String] -> IO Text+fixtureGit dir args = do+  (code, out, errOut) <-+    Proc.readCreateProcessWithExitCode+      (Proc.proc "git" (["-C", dir, "-c", "user.name=nova-nix-test", "-c", "user.email=test@nova-nix.invalid", "-c", "commit.gpgsign=false", "-c", "core.autocrlf=false"] ++ args))+      ""+  case code of+    ExitSuccess -> pure (T.strip (T.pack out))+    ExitFailure n -> fail ("fixture git " ++ unwords args ++ " exited " ++ show n ++ ": " ++ errOut)++-- | A repository whose history is one commit per given file, oldest+-- first; returns the commit SHAs in that order.  SHA wants are enabled+-- up front: a pinned rev that is no branch tip is only fetchable when+-- the serving side allows it, the same opt-in real servers use.+makeGitFixture :: FilePath -> [(FilePath, Text)] -> IO [Text]+makeGitFixture dir files = do+  Dir.createDirectoryIfMissing True dir+  _ <- fixtureGit dir ["init", "--quiet"]+  _ <- fixtureGit dir ["config", "uploadpack.allowAnySHA1InWant", "true"]+  mapM commitOne files+  where+    commitOne (name, contents) = do+      BS.writeFile (dir </> name) (TE.encodeUtf8 contents)+      _ <- fixtureGit dir ["add", "-A"]+      _ <- fixtureGit dir ["commit", "--quiet", "-m", name]+      fixtureGit dir ["rev-parse", "HEAD"]++-- | 'evalNixIO' against an explicit store directory, for expressions+-- whose evaluation writes store objects.+evalNixIOStore :: StoreDir -> FilePath -> Text -> IO (Either Text NixValue)+evalNixIOStore storeDir baseDir source = case parseNix baseDir "<test>" source of+  Left err -> pure (Left (T.pack (show err)))+  Right expr -> do+    st <- newEvalState storeDir baseDir+    runEvalIO st (eval (builtinEnv (esTimestamp st) (esSearchPaths st)) expr)++testFetchGitShallow :: IO [Bool]+testFetchGitShallow = do+  tmpBase <- getTemporaryDirectory+  let repoDir = tmpBase </> "nova-nix-test-fetchgit-repo"+      tmpStore = tmpBase </> "nova-nix-test-fetchgit-store"+      cacheHome = tmpBase </> "nova-nix-test-fetchgit-cache"+      nixEscape = T.concatMap (\c -> if c == '\\' then "\\\\" else if c == '"' then "\\\"" else T.singleton c)+      urlAttr = "url = \"" <> nixEscape (T.pack repoDir) <> "\"; "+      evalFetch extra = evalNixIOStore (StoreDir tmpStore) "." ("builtins.fetchGit { " <> urlAttr <> extra <> "}")+  forceRemoveIfExists repoDir+  forceRemoveIfExists tmpStore+  forceRemoveIfExists cacheHome+  -- The fetch cache writes under XdgCache; point it at the scratch area+  -- for the duration so the suite never touches the developer's cache.+  savedCacheHome <- lookupEnv "XDG_CACHE_HOME"+  setEnv "XDG_CACHE_HOME" cacheHome+  shas <- makeGitFixture repoDir [("first.txt", "one\n"), ("second.txt", "two\n"), ("third.txt", "three\n")]+  results <-+    sequence+      [ -- Upstream reports revCount 0 under shallow, not an absent+        -- attribute and not the fetch depth: observed from+        -- nix-instantiate 2.33.2, where '.revCount or "ABSENT"' is 0.+        -- The tree itself is depth-independent, so both fetches must+        -- agree on narHash and outPath.+        runTestM "a shallow fetch reports revCount 0 for the same tree" $ do+          fullOutcome <- evalFetch ""+          shallowOutcome <- evalFetch "shallow = true; "+          pure $ case (fullOutcome, shallowOutcome) of+            (Left err, _) -> Fail ("full fetch failed: " <> err)+            (_, Left err) -> Fail ("shallow fetch failed: " <> err)+            (Right fullVal, Right shallowVal) ->+              let fullFields = fetchGitFields fullVal ["revCount", "narHash", "outPath"]+                  shallowFields = fetchGitFields shallowVal ["revCount", "narHash", "outPath"]+               in case (fullFields, shallowFields) of+                    ([("revCount", VInt 3), ("narHash", fullHash), ("outPath", fullPath)], [("revCount", VInt 0), ("narHash", shallowHash), ("outPath", shallowPath)])+                      | fullHash == shallowHash && fullPath == shallowPath -> Pass+                      | otherwise -> Fail "shallow and full fetches disagree about the tree"+                    _ -> Fail (T.pack (show (fullFields, shallowFields))),+        -- The rev itself is the refspec, so a depth-1 fetch of a rev+        -- that is not the branch tip still lands on exactly that rev.+        runTestM "a shallow fetch of a pinned rev checks out that rev" $ case shas of+          (firstSha : _) -> do+            outcome <- evalFetch ("shallow = true; rev = \"" <> firstSha <> "\"; ")+            case outcome of+              Left err -> pure (Fail err)+              Right val -> case fetchGitFields val ["rev", "revCount", "outPath"] of+                [("rev", revVal), ("revCount", VInt 0), ("outPath", VPath outText)]+                  | revVal == mkStr firstSha -> do+                      let outDir = storeTextToFilePath (StoreDir tmpStore) outText+                      firstThere <- Dir.doesFileExist (outDir </> "first.txt")+                      thirdThere <- Dir.doesFileExist (outDir </> "third.txt")+                      pure $+                        if firstThere && not thirdThere+                          then Pass+                          else Fail "outPath does not hold the pinned rev's tree"+                fields -> pure (Fail (T.pack (show fields)))+          [] -> pure (Fail "fixture returned no commits"),+        -- Pinned and unpinned differ in where counting starts: from the+        -- rev, the root commit counts 1 (upstream 2.33.2 agrees), not+        -- the branch's 3.+        runTestM "a pinned rev fetch counts from the rev" $ case shas of+          (firstSha : _) -> do+            outcome <- evalFetch ("rev = \"" <> firstSha <> "\"; ")+            pure $ case outcome of+              Left err -> Fail err+              Right val -> case fetchGitFields val ["rev", "revCount"] of+                [("rev", revVal), ("revCount", VInt 1)]+                  | revVal == mkStr firstSha -> Pass+                fields -> Fail (T.pack (show fields))+          [] -> pure (Fail "fixture returned no commits")+      ]+  maybe (unsetEnv "XDG_CACHE_HOME") (setEnv "XDG_CACHE_HOME") savedCacheHome+  forceRemoveIfExists repoDir+  forceRemoveIfExists tmpStore+  forceRemoveIfExists cacheHome+  pure results++-- | The fetchGit completeness remainder (#77): unsupported attributes+-- refuse, a declared narHash pins the tree on hit and fresh paths+-- alike, allRefs widens the fetch, and a failing fetch leaves no+-- scratch clone behind.+testFetchGitPins :: IO [Bool]+testFetchGitPins = do+  tmpBase <- getTemporaryDirectory+  let tmpStore = tmpBase </> "nova-nix-test-pins-store"+      repoDir = tmpBase </> "nova-nix-test-pins-repo"+      cacheHome = tmpBase </> "nova-nix-test-pins-cache"+      urlText = T.concatMap (\c -> if c == '\\' then "\\\\" else T.singleton c) (T.pack repoDir)+      evalHere = evalNixIOStore (StoreDir tmpStore) "."+      fetchWith extra = evalHere ("builtins.fetchGit { url = \"" <> urlText <> "\"; " <> extra <> "}")+  forceRemoveIfExists tmpStore+  forceRemoveIfExists repoDir+  forceRemoveIfExists cacheHome+  savedCacheHome <- lookupEnv "XDG_CACHE_HOME"+  setEnv "XDG_CACHE_HOME" cacheHome+  shas <- makeGitFixture repoDir [("a.txt", "one\n")]+  baseBranch <- fixtureGit repoDir ["rev-parse", "--abbrev-ref", "HEAD"]+  _ <- fixtureGit repoDir ["checkout", "-q", "-b", "side"]+  BS.writeFile (repoDir </> "side.txt") (TE.encodeUtf8 "side\n")+  _ <- fixtureGit repoDir ["add", "-A"]+  _ <- fixtureGit repoDir ["commit", "--quiet", "-m", "side"]+  sideSha <- fixtureGit repoDir ["rev-parse", "HEAD"]+  _ <- fixtureGit repoDir ["checkout", "-q", T.unpack baseBranch]+  results <-+    sequence+      [ runTestM "an unsupported fetchGit attribute is refused" $ do+          outcome <- fetchWith "bogus = 1; "+          pure $ case outcome of+            Left err+              | "attribute 'bogus' not supported" `T.isInfixOf` err -> Pass+              | otherwise -> Fail ("wrong error: " <> err)+            Right _ -> Fail "an unsupported attribute was silently dropped",+        runTestM "a mismatched narHash pin is an error" $ case shas of+          (sha : _) -> do+            outcome <- fetchWith ("rev = \"" <> sha <> "\"; narHash = \"sha256-" <> T.replicate 43 "A" <> "=\"; ")+            pure $ case outcome of+              Left err+                | "NAR hash mismatch" `T.isInfixOf` err -> Pass+                | otherwise -> Fail ("wrong error: " <> err)+              Right _ -> Fail "a bogus narHash pin was silently ignored"+          [] -> pure (Fail "fixture returned no commits"),+        -- The second fetch of the same rev is a cache hit, so a matching+        -- pin passing here proves the check runs on the hit path too.+        runTestM "a matching narHash pin passes, including on a cache hit" $ case shas of+          (sha : _) -> do+            first <- fetchWith ("rev = \"" <> sha <> "\"; ")+            case first of+              Left err -> pure (Fail err)+              Right val -> case fetchGitFields val ["narHash"] of+                [("narHash", VStr hashBytes _)] -> do+                  let hash = TE.decodeUtf8Lenient hashBytes+                  second <- fetchWith ("rev = \"" <> sha <> "\"; narHash = \"" <> hash <> "\"; ")+                  pure $ case second of+                    Right _ -> Pass+                    Left err -> Fail ("a correct pin was refused: " <> err)+                other -> pure (Fail (T.pack (show other)))+          [] -> pure (Fail "fixture returned no commits"),+        runTestM "allRefs finds a rev on an unfetched branch" $ do+          outcome <- fetchWith ("rev = \"" <> sideSha <> "\"; allRefs = true; ")+          pure $ case outcome of+            Left err -> Fail err+            Right val -> case fetchGitFields val ["rev"] of+              [("rev", revVal)] | revVal == mkStr sideSha -> Pass+              other -> Fail (T.pack (show other)),+        runTestM "a failing fetch leaves no scratch clone behind" $ do+          before <- filter ("nova-nix-fetchgit-" `T.isPrefixOf`) . map T.pack <$> Dir.listDirectory tmpBase+          outcome <- evalHere ("builtins.fetchGit { url = \"" <> urlText <> "-no-such-repo\"; }")+          after <- filter ("nova-nix-fetchgit-" `T.isPrefixOf`) . map T.pack <$> Dir.listDirectory tmpBase+          pure $ case outcome of+            Right _ -> Fail "a fetch of a missing repo succeeded"+            Left _+              | length after == length before -> Pass+              | otherwise -> Fail ("scratch clones leaked: " <> T.pack (show (length after - length before)))+      ]+  maybe (unsetEnv "XDG_CACHE_HOME") (setEnv "XDG_CACHE_HOME") savedCacheHome+  forceRemoveIfExists tmpStore+  forceRemoveIfExists repoDir+  forceRemoveIfExists cacheHome+  pure results++-- | String interpolation inside path literals (#178), the construct+-- nixpkgs uses 89 times on modern trees.  Every rule here is pinned to+-- nix-instantiate 2.33.2's observed behavior: the result is a path,+-- pieces concatenate with no separator and canonicalize textually, a+-- path segment coerces without a store copy, context is refused, and+-- a trailing slash is a parse error.+testPathInterp :: IO [Bool]+testPathInterp = do+  tmpBase <- getTemporaryDirectory+  let fixtureDir = tmpBase </> "nova-nix-test-path-interp"+      tmpStore = tmpBase </> "nova-nix-test-path-interp-store"+  forceRemoveIfExists fixtureDir+  forceRemoveIfExists tmpStore+  Dir.createDirectoryIfMissing True (fixtureDir </> "sub")+  BS.writeFile (fixtureDir </> "sub" </> "f.nix") (TE.encodeUtf8 "42")+  let evalHere = evalNixIO fixtureDir+      baseText = canonPathValue (T.pack fixtureDir)+  results <-+    sequence+      [ runTestM "an interpolated path literal is a path" $ do+          outcome <- evalHere "let v = \"sub\"; in builtins.typeOf ./${v}"+          pure (assertRightValue outcome (mkStr "path")),+        runTestM "a runtime dot-dot canonicalizes textually" $ do+          outcome <- evalHere "let v = \"sub/..\"; in toString ./${v}"+          pure (assertRightValue outcome (mkStr baseText)),+        runTestM "pieces concatenate with no separator, mid-segment included" $ do+          outcome <- evalHere "let v = \"a\"; in toString ./pre${v}${v}post"+          pure (assertRightValue outcome (mkStr (baseText <> "/preaapost"))),+        runTestM "an empty segment after the root is the root" $ do+          outcome <- evalHere "toString /${\"\"}"+          pure (assertRightValue outcome (mkStr "/")),+        runTestM "a dotless relative literal interpolates" $ do+          outcome <- evalHere "let v = \"f.nix\"; in toString sub/${v}"+          pure (assertRightValue outcome (mkStr (baseText <> "/sub/f.nix"))),+        runTestM "a path segment must coerce to a string" $ do+          outcome <- evalHere "./${1}"+          pure $ case outcome of+            Left err+              | "cannot coerce an integer to a string" `T.isInfixOf` err -> Pass+              | otherwise -> Fail ("wrong error: " <> err)+            Right _ -> Fail "an integer segment was accepted",+        -- A path INSIDE a path interpolation appends its absolute text+        -- with no store copy (string interpolation would copy) -+        -- upstream's exact, odd, observable behavior.  The expected text+        -- goes through the canonicalizer because the separator between+        -- the two halves is platform-shaped: on POSIX the inner path's+        -- own leading slash serves, on Windows (C:...) the head's slash+        -- survives instead.+        runTestM "a path segment appends textually without a store copy" $ do+          outcome <- evalHere "toString ./${./sub}"+          pure (assertRightValue outcome (mkStr (canonPathValue (baseText <> "/" <> baseText <> "/sub")))),+        runTestM "a context-carrying segment is refused" $ do+          outcome <- evalNixIOStore (StoreDir tmpStore) fixtureDir "./${builtins.toFile \"n\" \"x\"}"+          pure $ case outcome of+            Left err+              | "cannot be appended to a path" `T.isInfixOf` err -> Pass+              | otherwise -> Fail ("wrong error: " <> err)+            Right _ -> Fail "a store-path segment was accepted",+        runTestM "a trailing slash is a parse error" $ do+          outcome <- evalHere "let v = \"a\"; in ./sub/${v}/"+          pure $ case outcome of+            Left err+              | "path has a trailing slash" `T.isInfixOf` err -> Pass+              | otherwise -> Fail ("wrong error: " <> err)+            Right _ -> Fail "a trailing slash parsed",+        runTestM "a plain trailing-slash literal is a parse error" $ do+          outcome <- evalHere "./sub/"+          pure $ case outcome of+            Left err+              | "path has a trailing slash" `T.isInfixOf` err -> Pass+              | otherwise -> Fail ("wrong error: " <> err)+            Right _ -> Fail "a trailing slash parsed",+        runTestM "import resolves an interpolated dot-dot path" $ do+          outcome <- evalHere "let v = \"sub\"; in import ./${v}/../sub/f.nix"+          pure (assertRightValue outcome (VInt 42)),+        -- An spath followed by /${...} is NOT one literal: it parses as+        -- application of the search path to a path, which fails at+        -- eval - upstream's exact shape.+        runTestM "a search path does not absorb an interpolation" $ do+          outcome <- evalHere "let v = \"a\"; in <nixpkgs>/${v}"+          pure $ case outcome of+            Left _ -> Pass+            Right val -> Fail ("spath absorbed the interpolation: " <> T.pack (show val))+      ]+  forceRemoveIfExists fixtureDir+  forceRemoveIfExists tmpStore+  pure results+  where+    assertRightValue outcome expected = case outcome of+      Right val | val == expected -> Pass+      other -> Fail (T.pack (show other))++-- | A path reached through an attrset's @outPath@ (or a @__toString@+-- result) coerces exactly as the same path written directly: copied to+-- the store, with the store path in the string context.  The broken+-- shape rendered the raw filesystem path with no copy and no context,+-- so @src = builtins.fetchGit { ... }@ produced empty inputSrcs and a+-- drvPath diverging from upstream, while the @.outPath@ spelling+-- worked.  Verified against nix-instantiate 2.33.2: the attrset form's+-- drvPath now matches upstream byte for byte.+testOutPathCoercion :: IO [Bool]+testOutPathCoercion = do+  tmpBase <- getTemporaryDirectory+  let tmpStore = tmpBase </> "nova-nix-test-outpath-store"+      srcDir = tmpBase </> "nova-nix-test-outpath-src"+      cacheHome = tmpBase </> "nova-nix-test-outpath-cache"+      repoDir = tmpBase </> "nova-nix-test-outpath-repo"+      -- A RELATIVE path literal resolved against the eval's base dir: an+      -- absolute Windows path (C:/...) is not a path literal at all - it+      -- matches the URI rule (scheme:rest) and lexes as a string, which+      -- correctly never copies.  Relative spelling lexes as a path on+      -- every platform.+      srcLiteral = "./nova-nix-test-outpath-src" :: Text+      drvPathWith srcExpr =+        "(derivation { name = \"ctx-probe\"; system = \"x86_64-linux\"; "+          <> "builder = \"/bin/sh\"; args = [\"-c\" \"echo ok > $out\"]; "+          <> "src = "+          <> srcExpr+          <> "; }).drvPath"+      evalHere = evalNixIOStore (StoreDir tmpStore) tmpBase+      storeStr result = case result of+        Right (VStr s _) -> Just (TE.decodeUtf8Lenient s)+        _ -> Nothing+  forceRemoveIfExists tmpStore+  forceRemoveIfExists srcDir+  forceRemoveIfExists cacheHome+  forceRemoveIfExists repoDir+  Dir.createDirectoryIfMissing True srcDir+  BS.writeFile (srcDir </> "f.txt") (TE.encodeUtf8 "content\n")+  savedCacheHome <- lookupEnv "XDG_CACHE_HOME"+  setEnv "XDG_CACHE_HOME" cacheHome+  gitShas <- makeGitFixture repoDir [("g.txt", "tracked\n")]+  results <-+    sequence+      [ runTestM "an outPath attrset src derives the same drv as its path" $ do+          attrForm <- evalHere (drvPathWith ("{ outPath = " <> srcLiteral <> "; }"))+          pathForm <- evalHere (drvPathWith srcLiteral)+          pure $ case (attrForm, pathForm) of+            (Right a, Right b)+              | a == b -> Pass+              | otherwise -> Fail (T.pack (show (a, b)))+            other -> Fail (T.pack (show other)),+        runTestM "a fetchGit src derives the same drv as its outPath" $ case gitShas of+          (sha : _) -> do+            let fetch = "builtins.fetchGit { url = \"" <> T.concatMap (\c -> if c == '\\' then "\\\\" else T.singleton c) (T.pack repoDir) <> "\"; rev = \"" <> sha <> "\"; }"+            attrForm <- evalHere (drvPathWith fetch)+            outPathForm <- evalHere (drvPathWith ("(" <> fetch <> ").outPath"))+            pure $ case (attrForm, outPathForm) of+              (Right a, Right b)+                | a == b -> Pass+                | otherwise -> Fail (T.pack (show (a, b)))+              other -> Fail (T.pack (show other))+          [] -> pure (Fail "fixture returned no commits"),+        -- Text-only assertions here and below: a source copy is+        -- materialized by the build driver, not by a bare eval, so the+        -- observable at eval time is the store-path spelling.  The two+        -- drvPath tests above cover the context end (an uncontexted src+        -- would derive a different drv).+        runTestM "interpolating an outPath attrset coerces to its store path" $ do+          outcome <- evalHere ("\"${ { outPath = " <> srcLiteral <> "; } }\"")+          pure $ case storeStr outcome of+            Just text | isCanonicalStoreText text -> Pass+            _ -> Fail (T.pack (show outcome)),+        runTestM "a __toString path result coerces to its store path" $ do+          outcome <- evalHere ("\"${ { __toString = self: " <> srcLiteral <> "; } }\"")+          pure $ case storeStr outcome of+            Just text | isCanonicalStoreText text -> Pass+            _ -> Fail (T.pack (show outcome)),+        runTestM "adding an outPath attrset to a string coerces to its store path" $ do+          outcome <- evalHere ("\"src: \" + { outPath = " <> srcLiteral <> "; }")+          pure $ case storeStr outcome of+            Just text | Just rest <- T.stripPrefix "src: " text, isCanonicalStoreText rest -> Pass+            _ -> Fail (T.pack (show outcome)),+        runTestM "toJSON of an outPath attrset is its store path" $ do+          outcome <- evalHere ("builtins.toJSON { outPath = " <> srcLiteral <> "; }")+          pure $ case storeStr outcome of+            Just text+              | Just inner <- T.stripPrefix "\"" text >>= T.stripSuffix "\"",+                isCanonicalStoreText inner ->+                  Pass+            _ -> Fail (T.pack (show outcome))+      ]+  maybe (unsetEnv "XDG_CACHE_HOME") (setEnv "XDG_CACHE_HOME") savedCacheHome+  forceRemoveIfExists tmpStore+  forceRemoveIfExists srcDir+  forceRemoveIfExists cacheHome+  forceRemoveIfExists repoDir+  pure results++testScratchDirs :: IO [Bool]+testScratchDirs = do+  putStrLn "eval/scratch-dirs"+  st <- newEvalState platformStoreDir "."+  sequence+    [ runTestM "scratch dirs are distinct, real, and removable" $ do+        createdA <- runEvalIO st (createScratchDir "nova-nix-test-scratch-")+        createdB <- runEvalIO st (createScratchDir "nova-nix-test-scratch-")+        case (createdA, createdB) of+          (Right dirA, Right dirB)+            | dirA == dirB -> pure (Fail "two scratch dirs share a name")+            | otherwise -> do+                existedA <- Dir.doesDirectoryExist (T.unpack dirA)+                existedB <- Dir.doesDirectoryExist (T.unpack dirB)+                removed <- runEvalIO st (removeScratchDir dirA >> removeScratchDir dirB)+                goneA <- not <$> Dir.doesDirectoryExist (T.unpack dirA)+                goneB <- not <$> Dir.doesDirectoryExist (T.unpack dirB)+                pure $ case removed of+                  Left err -> Fail ("removeScratchDir failed: " <> err)+                  Right ()+                    | existedA && existedB && goneA && goneB -> Pass+                    | otherwise -> Fail "scratch dir lifecycle out of order"+          (Left err, _) -> pure (Fail ("createScratchDir failed: " <> err))+          (_, Left err) -> pure (Fail ("createScratchDir failed: " <> err)),+      runTestM "scratch names carry the requested prefix" $ do+        created <- runEvalIO st (createScratchDir "nova-nix-test-scratch-")+        case created of+          Left err -> pure (Fail ("createScratchDir failed: " <> err))+          Right dir -> do+            removed <- runEvalIO st (removeScratchDir dir)+            pure $ case removed of+              Left err -> Fail ("removeScratchDir failed: " <> err)+              Right ()+                | "nova-nix-test-scratch-" `T.isInfixOf` dir -> Pass+                | otherwise -> Fail ("prefix missing from: " <> dir)+    ]++testNarKnownAnswer :: IO [Bool]+testNarKnownAnswer = do+  putStrLn "nar/known-answer"+  sequence+    [ runTest "serialise matches the hand-written spec vector" $+        if NAR.serialise narSpecTree == narSpecVector+          then Pass+          else Fail "NAR encoder no longer matches the spec byte layout",+      runTest "deserialise reads the spec vector back" $+        case NAR.deserialise narSpecVector of+          Right entry+            | NAR.serialise entry == narSpecVector -> Pass+            | otherwise -> Fail "spec vector round-trips to different bytes"+          Left err -> Fail ("spec vector rejected: " <> T.pack err),+      -- The empty-input SHA-256 in nix-base32: pins digest, alphabet, and+      -- bit order against the value real Nix computes.+      runTest "sha256 empty formats to the known nix-base32 vector" $+        assertEqual+          "nix32-empty"+          "sha256:0mdqa9w1p6cmli6976v4wi0sw9r4p5prkj7lzfd1877wk11c9c73"+          (CHash.formatNixHash (CHash.hashBytes BS.empty)),+      -- addToStore must record exactly the hash of the spec bytes for the+      -- same tree, or interop with every real cache silently breaks.+      runTestM "addToStore records the externally-computed NAR hash" $ do+        tmpBase <- getTemporaryDirectory+        let srcDir = tmpBase </> "nova-nix-test-nar-vector-src"+            tmpStore = tmpBase </> "nova-nix-test-nar-vector-store"+        forceRemoveIfExists srcDir+        forceRemoveIfExists tmpStore+        createDirectoryIfMissing True srcDir+        createDirectoryIfMissing True tmpStore+        BS.writeFile (srcDir </> "data.txt") "hello\n"+        store <- openStore (StoreDir tmpStore)+        let sp = StorePath (T.replicate 32 "e") "nar-vector"+        addToStore store srcDir sp Nothing []+        recorded <- queryPathInfo (stDB store) sp+        closeStore store+        forceRemoveIfExists tmpStore+        let expected = CHash.formatNixHash (CHash.hashBytes narSpecFileVector)+        pure $ case recorded of+          Just info -> assertEqual "recorded NarHash" expected (piNarHash info)+          Nothing -> Fail "path not registered"+    ]++-- | store delete: row-and-tree removal with referrer refusal.  The raw+-- basename channel exists for rows the current name rules reject; the+-- legacy-row case plants one over SQL exactly as a pre-name-rules store+-- carries it.+testStoreDelete :: IO [Bool]+testStoreDelete = do+  putStrLn "store/delete"+  withTempStore $ \store -> do+    let sd = stDir store+        basenameOf sp = spHash sp <> "-" <> spName sp+        dep = StorePath (T.replicate 32 "a") "del-dep"+        user = StorePath (T.replicate 32 "b") "del-user"+    sequence+      [ runTestM "referenced path is refused with its referrers listed" $ do+          registerPaths+            (stDB store)+            [ PathRegistration dep "sha256:d" 1 Nothing [],+              PathRegistration user "sha256:u" 1 Nothing [dep]+            ]+          outcome <- deleteStorePathRaw store (basenameOf dep)+          stillValid <- isValid store dep+          pure $ case outcome of+            Left err+              | "referenced by" `T.isInfixOf` err,+                T.pack (storePathToFilePath sd user) `T.isInfixOf` err,+                stillValid ->+                  Pass+              | otherwise -> Fail ("wrong refusal: " <> err)+            Right removed -> Fail ("deleted a referenced path: " <> T.pack (show removed)),+        runTestM "a reference chain deletes leaf-first" $ do+          userGone <- deleteStorePathRaw store (basenameOf user)+          depGone <- deleteStorePathRaw store (basenameOf dep)+          depValid <- isValid store dep+          userValid <- isValid store user+          pure $ case (userGone, depGone) of+            (Right _, Right _)+              | not depValid && not userValid -> Pass+              | otherwise -> Fail "rows survived deletion"+            other -> Fail ("chain deletion failed: " <> T.pack (show other)),+        runTestM "a registered row without a tree deletes (repair case)" $ do+          let ghost = StorePath (T.replicate 32 "c") "del-ghost"+          registerPath (stDB store) (PathRegistration ghost "sha256:g" 1 Nothing [])+          outcome <- deleteStorePathRaw store (basenameOf ghost)+          pure $ case outcome of+            Right removed+              | doRowRemoved removed && not (doTreeRemoved removed) -> Pass+              | otherwise -> Fail ("wrong outcome: " <> T.pack (show removed))+            Left err -> Fail err,+        runTestM "an unregistered tree deletes (repair case)" $ do+          let strayBase = T.replicate 32 "d" <> "-del-stray"+              treePath = unStoreDir sd </> T.unpack strayBase+          createDirectoryIfMissing True treePath+          TIO.writeFile (treePath </> "junk.txt") "stray"+          outcome <- deleteStorePathRaw store strayBase+          gone <- not <$> Dir.doesPathExist treePath+          pure $ case outcome of+            Right removed+              | not (doRowRemoved removed), doTreeRemoved removed, gone -> Pass+              | otherwise -> Fail ("wrong outcome: " <> T.pack (show removed))+            Left err -> Fail err,+        runTestM "a read-only registered tree deletes fully" $ do+          let solid = StorePath (T.replicate 32 "e") "del-solid"+              treePath = storePathToFilePath sd solid+          registerPath (stDB store) (PathRegistration solid "sha256:s" 1 Nothing [])+          createDirectoryIfMissing True treePath+          TIO.writeFile (treePath </> "data.txt") "content"+          setReadOnly treePath+          outcome <- deleteStorePathRaw store (basenameOf solid)+          gone <- not <$> Dir.doesPathExist treePath+          stillValid <- isValid store solid+          pure $ case outcome of+            Right removed+              | doRowRemoved removed, doTreeRemoved removed, gone, not stillValid -> Pass+              | otherwise -> Fail ("wrong outcome: " <> T.pack (show removed))+            Left err -> Fail err,+        runTestM "a self-referencing path deletes" $ do+          let selfp = StorePath (T.replicate 32 "f") "del-self"+          registerPath (stDB store) (PathRegistration selfp "sha256:f" 1 Nothing [selfp])+          outcome <- deleteStorePathRaw store (basenameOf selfp)+          pure (assertEqual "self delete" (Right (DeleteOutcome True False)) outcome),+        runTestM "a target with neither row nor tree is an error" $ do+          outcome <- deleteStorePathRaw store (T.replicate 32 "9" <> "-del-nothing")+          pure $ case outcome of+            Left err | "not in this store" `T.isInfixOf` err -> Pass+            other -> Fail ("expected not-in-store, got: " <> T.pack (show other)),+        runTestM "a legacy row the validator rejects deletes by raw text" $ do+          let legacyBase = T.replicate 32 "g" <> "-old~marker"+              treePath = unStoreDir sd </> T.unpack legacyBase+              legacyPathText = T.pack treePath+          conn <- SQL.open (unStoreDir sd </> metaDirName </> dbFileName)+          SQL.execute+            conn+            "INSERT INTO ValidPaths (path, hash, registrationTime, deriver, narSize) VALUES (?, ?, 0, NULL, 0)"+            (legacyPathText, "sha256:legacy" :: T.Text)+          SQL.close conn+          createDirectoryIfMissing True treePath+          TIO.writeFile (treePath </> "seed.txt") "epoch"+          let resolved = resolveDeleteTarget sd legacyPathText+          outcome <- either (pure . Left) (deleteStorePathRaw store) resolved+          remaining <- queryAllValidPaths (stDB store)+          gone <- not <$> Dir.doesPathExist treePath+          pure $ case outcome of+            Right removed+              | doRowRemoved removed, doTreeRemoved removed, gone, legacyPathText `notElem` remaining -> Pass+              | otherwise -> Fail ("wrong outcome: " <> T.pack (show removed))+            Left err -> Fail err,+        runTest "resolveDeleteTarget accepts bare, store-dir, platform, and canonical spellings" $+          let nameBase = T.replicate 32 "h" <> "-name"+              spellings =+                [ nameBase,+                  T.pack (unStoreDir sd </> T.unpack nameBase),+                  T.pack (unStoreDir platformStoreDir) <> "\\" <> nameBase,+                  defaultStoreDirText <> "/" <> nameBase+                ]+           in assertEqual "all resolve" (replicate 4 (Right nameBase)) (map (resolveDeleteTarget sd) spellings),+        runTest "resolveDeleteTarget refuses traversal shapes" $+          let refused =+                [ ".nova-nix",+                  "..",+                  ".",+                  "",+                  T.pack (unStoreDir sd) <> "/",+                  "/somewhere/else/" <> T.replicate 32 "h" <> "-name",+                  T.replicate 32 "h" <> "-na:me"+                ]+           in case [t | t <- refused, either (const False) (const True) (resolveDeleteTarget sd t)] of+                [] -> Pass+                accepted -> Fail ("accepted: " <> T.pack (show accepted)),+        -- Lock files are never deleted (the Nix.Store.Lock design), but+        -- names like flake.lock are legal store-path names, so only the+        -- registration rows can tell a lock file from a store object:+        -- an unregistered lock-shaped file refuses, a registered object+        -- of the same shape deletes, and lock-shaped junk whose+        -- stripped prefix is not a store basename deletes as junk.+        runTestM "delete refuses an unregistered lock file by name" $ do+          let guarded = StorePath (T.replicate 32 "j") "del-guard"+              lockBase = basenameOf guarded <> ".lock"+              lockPath = storePathToFilePath sd guarded <> ".lock"+          TIO.writeFile lockPath ""+          outcome <- deleteStorePathRaw store lockBase+          survived <- Dir.doesPathExist lockPath+          pure $ case outcome of+            Left err+              | "never deleted" `T.isInfixOf` err ->+                  if survived then Pass else Fail "lock file removed despite refusal"+              | otherwise -> Fail ("wrong refusal: " <> err)+            Right _ -> Fail "unregistered lock file was deleted",+        runTestM "delete removes a registered store object named like a lock file" $ do+          let object = StorePath (T.replicate 32 "k") "flake.lock"+              objectPath = storePathToFilePath sd object+          registerPath (stDB store) (PathRegistration object "sha256:m" 1 Nothing [])+          TIO.writeFile objectPath "pinned inputs"+          outcome <- deleteStorePathRaw store (basenameOf object)+          gone <- not <$> Dir.doesPathExist objectPath+          pure $ case outcome of+            Right removed+              | doRowRemoved removed, doTreeRemoved removed, gone -> Pass+              | otherwise -> Fail ("wrong outcome: " <> T.pack (show removed))+            Left err -> Fail ("registered .lock-named object refused: " <> err),+        runTestM "delete removes lock-shaped junk with no store-shaped prefix" $ do+          let junkName = "not-a-store-path.lock"+              junkPath = unStoreDir sd </> T.unpack junkName+          TIO.writeFile junkPath "debris"+          outcome <- deleteStorePathRaw store junkName+          gone <- not <$> Dir.doesPathExist junkPath+          pure $ case outcome of+            Right removed+              | doTreeRemoved removed, gone -> Pass+              | otherwise -> Fail ("wrong outcome: " <> T.pack (show removed))+            Left err -> Fail ("lock-shaped junk refused: " <> err),+        -- Deletion and substitution contend on one per-path lock: a+        -- delete must wait while another handle holds the path's lock -+        -- otherwise it can tear a tree out between a substituter's+        -- on-disk recheck and its registration commit - and proceed+        -- once the holder releases.+        runTestM "delete waits for a held path lock and proceeds on release" $ do+          let guarded = StorePath (T.replicate 32 "i") "del-locked"+              treePath = storePathToFilePath sd guarded+          registerPath (stDB store) (PathRegistration guarded "sha256:l" 1 Nothing [])+          createDirectoryIfMissing True treePath+          TIO.writeFile (treePath </> "data.txt") "guarded"+          holder <- acquirePathLock sd guarded+          done <- newEmptyMVar+          _ <- forkIO (deleteStorePathRaw store (basenameOf guarded) >>= putMVar done)+          early <- timeout deleteLockProbeMicros (takeMVar done)+          releasePathLock holder+          outcome <- timeout raceWatchdogMicros (takeMVar done)+          gone <- not <$> Dir.doesPathExist treePath+          pure $ case (early, outcome) of+            (Just finished, _) ->+              Fail ("delete ignored the held lock: " <> T.pack (show finished))+            (Nothing, Just (Right removed))+              | doRowRemoved removed, doTreeRemoved removed, gone -> Pass+              | otherwise -> Fail ("wrong outcome after release: " <> T.pack (show removed))+            (Nothing, other) ->+              Fail ("delete never completed after release: " <> T.pack (show other)),+        -- The eval-side materializer joins the same protocol (#21): its+        -- check-then-act must wait while another handle holds the+        -- path's lock - unlocked, the loser's removePathForcibly could+        -- delete the tree the winner had just registered - and proceed+        -- once the holder releases.+        runTestM "materializeEvalSources waits for a held path lock" $ do+          srcDir <- (</> "nova-nix-test-mat-lock-src") <$> getTemporaryDirectory+          forceRemoveIfExists srcDir+          createDirectoryIfMissing True srcDir+          TIO.writeFile (srcDir </> "f.txt") "locked source"+          let guarded = StorePath (T.replicate 32 "j") "mat-locked"+              spText = storePathToText defaultStoreDir guarded+          holder <- acquirePathLock sd guarded+          done <- newEmptyMVar+          _ <- forkIO (materializeEvalSources store (Map.singleton (T.pack srcDir) spText) >>= putMVar done)+          early <- timeout deleteLockProbeMicros (takeMVar done)+          releasePathLock holder+          outcome <- timeout raceWatchdogMicros (takeMVar done)+          registered <- isValid store guarded+          forceRemoveIfExists srcDir+          pure $ case (early, outcome) of+            (Just _, _) -> Fail "materialize ignored the held lock"+            (Nothing, Just ())+              | registered -> Pass+              | otherwise -> Fail "proceeded after release but never registered"+            (Nothing, Nothing) -> Fail "materialize never completed after release"+      ]++testStoreOps :: IO [Bool]+testStoreOps = do+  putStrLn "store/ops"+  withTempStore $ \store -> do+    let sd = stDir store+    sequence+      [ -- computeClosure emits references before referrers (deps-first):+        -- phase-2 narinfo publication then never announces a path whose+        -- references are not yet visible.  The dep is shared by both+        -- roots and must precede both.+        runTestM "computeClosure orders references first" $ do+          let dep = StorePath "llllllllllllllllllllllllllllllll" "cl-dep"+              p1 = StorePath "mmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm" "cl-p1"+              p2 = StorePath "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn" "cl-p2"+          registerPaths+            (stDB store)+            [ PathRegistration dep "sha256:cd" 1 Nothing [],+              PathRegistration p1 "sha256:c1" 2 Nothing [dep],+              PathRegistration p2 "sha256:c2" 3 Nothing [dep]+            ]+          result <- computeClosure store [p1, p2]+          pure (assertEqual "closure order" (Right [dep, p1, p2]) result),+        -- materializeEvalSources adoption is verified: a partial tree left+        -- at the destination by an interrupted copy must be cleared and+        -- re-copied, never registered as-is.+        runTestM "materializeEvalSources re-copies a mismatched tree" $ do+          tmpBase <- getTemporaryDirectory+          let srcDir = tmpBase </> "nova-nix-test-adopt-src"+          removeIfExists srcDir+          createDirectoryIfMissing True srcDir+          TIO.writeFile (srcDir </> "data.txt") "real content"+          entry <- NAR.serialiseFromPath srcDir+          case makeFixedOutputPath "nova-nix-test-adopt-src" "sha256" "recursive" (sha256Digest (NAR.serialise entry)) of+            Left err -> pure (Fail ("test store path rejected: " <> T.pack (show err)))+            Right sp -> do+              let spText = storePathToText defaultStoreDir sp+                  dest = storePathToFilePath (stDir store) sp+              -- Fake an interrupted earlier copy: wrong partial content.+              removeIfExists dest+              createDirectoryIfMissing True dest+              TIO.writeFile (dest </> "data.txt") "partial garbage"+              materializeEvalSources store (Map.fromList [(T.pack srcDir, spText)])+              adopted <- TIO.readFile (dest </> "data.txt")+              registered <- isValid store sp+              removeIfExists srcDir+              pure $+                if adopted == "real content" && registered+                  then Pass+                  else Fail ("expected re-copied content, got: " <> adopted),+        -- The registrar's half of the eval-write pair: content that does+        -- not re-derive its store path under the recorded scheme must be+        -- refused loudly, never registered valid under a hash its bytes+        -- do not have and then sealed read-only.+        runTestM "materializeEvalStoreWrites refuses content that does not reproduce its path" $ do+          let goodBytes = TE.encodeUtf8 "the full flat content"+          case makeFixedOutputPath "flat-verify" "sha256" "flat" (sha256Digest goodBytes) of+            Left err -> pure (Fail ("test store path rejected: " <> T.pack (show err)))+            Right sp -> do+              let spText = storePathToText defaultStoreDir sp+                  dest = storePathToFilePath (stDir store) sp+              removeIfExists dest+              BS.writeFile dest (TE.encodeUtf8 "the full")+              outcome <- try (materializeEvalStoreWrites store (Map.fromList [(spText, ([], WriteFlat))]))+              stillInvalid <- not <$> isValid store sp+              removeIfExists dest+              pure $ case (outcome :: Either SomeException ()) of+                Left _ | stillInvalid -> Pass+                Left _ -> Fail "refused but registered anyway"+                Right () -> Fail "registered a truncated write as valid",+        runTestM "materializeEvalStoreWrites registers content that reproduces its path" $ do+          let goodBytes = TE.encodeUtf8 "registered text content"+              refs = []+          case makeTextPath "text-verify" (sha256Digest goodBytes) refs of+            Left err -> pure (Fail ("test store path rejected: " <> T.pack (show err)))+            Right sp -> do+              let spText = storePathToText defaultStoreDir sp+                  dest = storePathToFilePath (stDir store) sp+              removeIfExists dest+              BS.writeFile dest goodBytes+              materializeEvalStoreWrites store (Map.fromList [(spText, (refs, WriteText))])+              registered <- isValid store sp+              pure (assertEqual "registered" True registered),+        -- A tree that DOES reproduce its store path is adopted untouched.+        -- The source is deleted before the call: adoption never reads it,+        -- so a wrongful re-copy attempt throws and fails the test.+        runTestM "materializeEvalSources adopts a matching tree untouched" $ do+          tmpBase <- getTemporaryDirectory+          let srcDir = tmpBase </> "nova-nix-test-adopt-ok"+          removeIfExists srcDir+          createDirectoryIfMissing True srcDir+          TIO.writeFile (srcDir </> "data.txt") "same bytes"+          entry <- NAR.serialiseFromPath srcDir+          case makeFixedOutputPath "nova-nix-test-adopt-ok" "sha256" "recursive" (sha256Digest (NAR.serialise entry)) of+            Left err -> pure (Fail ("test store path rejected: " <> T.pack (show err)))+            Right sp -> do+              let spText = storePathToText defaultStoreDir sp+                  dest = storePathToFilePath (stDir store) sp+              removeIfExists dest+              createDirectoryIfMissing True dest+              TIO.writeFile (dest </> "data.txt") "same bytes"+              removeIfExists srcDir+              materializeEvalSources store (Map.fromList [(T.pack srcDir, spText)])+              registered <- isValid store sp+              pure (if registered then Pass else Fail "matching tree was not adopted and registered"),+        -- scanReferences finds the canonical /nix/store text eval injects+        -- into builder envs, independent of the platform store dir (the+        -- bare hash is the needle, matching upstream Nix).+        runTestM "scanReferences finds canonical ref" $ do+          tmpBase <- getTemporaryDirectory+          let scanDir = tmpBase </> "nova-nix-test-scan"+          removeIfExists scanDir+          createDirectoryIfMissing True scanDir+          let candidate = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "dep1"+              refString = "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-dep1"+          BS.writeFile (scanDir </> "output.txt") (TE.encodeUtf8 (T.pack ("hello " <> refString <> " world")))+          refs <- scanReferences [candidate] scanDir+          removeIfExists scanDir+          pure $+            if candidate `elem` refs+              then Pass+              else Fail ("expected to find ref, got: " <> T.pack (show refs)),+        -- scanReferences finds a Windows-form embedding too+        runTestM "scanReferences finds windows-form ref" $ do+          tmpBase <- getTemporaryDirectory+          let scanDir = tmpBase </> "nova-nix-test-scan-win"+          removeIfExists scanDir+          createDirectoryIfMissing True scanDir+          let candidate = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "dep1"+              refString = "C:\\nix\\store\\aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-dep1"+          BS.writeFile (scanDir </> "output.txt") (TE.encodeUtf8 (T.pack ("exec " <> refString)))+          refs <- scanReferences [candidate] scanDir+          removeIfExists scanDir+          pure $+            if candidate `elem` refs+              then Pass+              else Fail ("expected to find ref, got: " <> T.pack (show refs)),+        -- scanReferences misses non-matching+        runTestM "scanReferences misses non-match" $ do+          tmpBase <- getTemporaryDirectory+          let scanDir = tmpBase </> "nova-nix-test-scan2"+          removeIfExists scanDir+          createDirectoryIfMissing True scanDir+          let candidate = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "dep1"+          BS.writeFile (scanDir </> "output.txt") "no store paths here"+          refs <- scanReferences [candidate] scanDir+          removeIfExists scanDir+          pure (assertEqual "no refs" [] refs),+        -- scanReferences ignores partial match+        runTestM "scanReferences ignores partial" $ do+          tmpBase <- getTemporaryDirectory+          let scanDir = tmpBase </> "nova-nix-test-scan3"+          removeIfExists scanDir+          createDirectoryIfMissing True scanDir+          let candidate = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "dep1"+              -- Only 20 chars of hash - should not match the 32-char needle+              partialRef = "/nix/store/aaaaaaaaaaaaaaaaaaaa"+          BS.writeFile (scanDir </> "output.txt") (TE.encodeUtf8 (T.pack partialRef))+          refs <- scanReferences [candidate] scanDir+          removeIfExists scanDir+          pure (assertEqual "no partial refs" [] refs),+        -- scanTempReferences records the self/cross-output edges that the+        -- store-prefix scan cannot see (outputs embedding their TEMP path)+        runTestM "scanTempReferences finds temp-dir embedding" $ do+          tmpBase <- getTemporaryDirectory+          let scanDir = tmpBase </> "nova-nix-test-scan-temp"+              fakeTempOut = tmpBase </> "nova-nix-build" </> "fake-out"+          removeIfExists scanDir+          createDirectoryIfMissing True scanDir+          let selfSp = StorePath "cccccccccccccccccccccccccccccccc" "self"+          BS.writeFile+            (scanDir </> "wrapper.sh")+            (TE.encodeUtf8 (T.pack ("exec " <> fakeTempOut <> "/bin/tool")))+          found <- scanTempReferences [(fakeTempOut, selfSp)] scanDir+          removeIfExists scanDir+          pure (assertEqual "self-ref found" [selfSp] found),+        runTestM "scanTempReferences negative" $ do+          tmpBase <- getTemporaryDirectory+          let scanDir = tmpBase </> "nova-nix-test-scan-temp-neg"+          removeIfExists scanDir+          createDirectoryIfMissing True scanDir+          let selfSp = StorePath "cccccccccccccccccccccccccccccccc" "self"+          BS.writeFile (scanDir </> "clean.txt") "no temp paths embedded here"+          found <- scanTempReferences [(tmpBase </> "nova-nix-build" </> "fake-out", selfSp)] scanDir+          removeIfExists scanDir+          pure (assertEqual "no refs" [] found),+        -- addToStore moves dir + registers in DB+        runTestM "addToStore moves + registers" $ do+          tmpBase <- getTemporaryDirectory+          let srcDir = tmpBase </> "nova-nix-test-add-src"+          removeIfExists srcDir+          createDirectoryIfMissing True srcDir+          writeFile (srcDir </> "hello.txt") "hello world"+          let sp = StorePath "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" "addtest"+          addToStore store srcDir sp Nothing []+          valid <- isValid store sp+          exists <- pathExists store sp+          pure $+            if valid && exists+              then Pass+              else Fail ("valid=" <> T.pack (show valid) <> " exists=" <> T.pack (show exists)),+        -- addToStore sets read-only+        runTestM "addToStore sets read-only" $ do+          tmpBase <- getTemporaryDirectory+          let srcDir = tmpBase </> "nova-nix-test-add-ro"+          removeIfExists srcDir+          createDirectoryIfMissing True srcDir+          writeFile (srcDir </> "data.txt") "data"+          let sp = StorePath "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" "rotest"+          addToStore store srcDir sp Nothing []+          let destFile = storePathToFilePath sd sp </> "data.txt"+          perms <- getPermissions destFile+          pure $+            if not (writable perms)+              then Pass+              else Fail "expected read-only but file is writable",+        -- setReadOnly works on a plain directory+        runTestM "setReadOnly makes dir read-only" $ do+          tmpBase <- getTemporaryDirectory+          let roDir = tmpBase </> "nova-nix-test-readonly"+          removeIfExists roDir+          createDirectoryIfMissing True roDir+          writeFile (roDir </> "f.txt") "content"+          setReadOnly roDir+          dirPerms <- getPermissions roDir+          filePerms <- getPermissions (roDir </> "f.txt")+          -- Cleanup: restore writable so removeIfExists works+          Dir.setPermissions roDir (Dir.setOwnerWritable True dirPerms)+          Dir.setPermissions (roDir </> "f.txt") (Dir.setOwnerWritable True filePerms)+          removeIfExists roDir+          pure $+            if not (writable dirPerms) && not (writable filePerms)+              then Pass+              else+                Fail+                  ( "dir writable="+                      <> T.pack (show (writable dirPerms))+                      <> " file writable="+                      <> T.pack (show (writable filePerms))+                  ),+        -- pathExists true after addToStore+        runTestM "pathExists after addToStore" $ do+          tmpBase <- getTemporaryDirectory+          let srcDir = tmpBase </> "nova-nix-test-exists"+          removeIfExists srcDir+          createDirectoryIfMissing True srcDir+          writeFile (srcDir </> "x.txt") "x"+          let sp = StorePath "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww" "existstest"+          addToStore store srcDir sp Nothing []+          exists <- pathExists store sp+          pure (assertEqual "exists" True exists)+      ]++-- | Store walks treat a symlink as a leaf.  Each fixture plants a link+-- the walk must not follow - out of the tree or back into it (a cycle) -+-- and asserts the walk neither reads nor mutates through the link, and+-- still terminates.+testSymlinkWalksIO :: IO [Bool]+testSymlinkWalksIO = do+  putStrLn "store/symlink-walks"+  canLink <- symlinksAvailable+  if not canLink+    then do+      putStrLn "  SKIP  cannot create symlinks here (Windows needs Developer Mode or elevation)"+      pure []+    else+      sequence+        [ -- A link out of the tree must not have the read-only bit+          -- pushed through to its target.+          runTestM "setReadOnly does not mark link targets outside the tree" $ do+            tmpBase <- getTemporaryDirectory+            let base = tmpBase </> "nova-nix-test-ro-links"+                outside = base </> "outside"+                tree = base </> "tree"+            Dir.removePathForcibly base+            createDirectoryIfMissing True outside+            createDirectoryIfMissing True tree+            BS.writeFile (outside </> "victim.txt") "victim"+            BS.writeFile (tree </> "inside.txt") "inside"+            Dir.createFileLink (outside </> "victim.txt") (tree </> "file-link")+            Dir.createDirectoryLink outside (tree </> "dir-link")+            setReadOnly tree+            victimWritable <- writable <$> getPermissions (outside </> "victim.txt")+            insideWritable <- writable <$> getPermissions (tree </> "inside.txt")+            Dir.removePathForcibly base+            pure $+              if victimWritable && not insideWritable+                then Pass+                else+                  Fail+                    ( "victim writable="+                        <> T.pack (show victimWritable)+                        <> " inside writable="+                        <> T.pack (show insideWritable)+                    ),+          -- A link cycle must not recurse the walk forever.+          runTestM "setReadOnly terminates on a link cycle" $ do+            tmpBase <- getTemporaryDirectory+            let tree = tmpBase </> "nova-nix-test-ro-cycle"+            Dir.removePathForcibly tree+            createDirectoryIfMissing True (tree </> "sub")+            BS.writeFile (tree </> "sub" </> "f.txt") "f"+            Dir.createDirectoryLink tree (tree </> "sub" </> "loop")+            outcome <- timeout walkWatchdogMicros (setReadOnly tree)+            marked <- writable <$> getPermissions (tree </> "sub" </> "f.txt")+            Dir.removePathForcibly tree+            pure $ case outcome of+              Nothing -> Fail "walk did not terminate on a link cycle"+              Just ()+                | marked -> Fail "regular file next to the cycle link was not marked read-only"+                | otherwise -> Pass,+          -- The reference scan reads a link's TARGET STRING (the NAR+          -- carries it) and never the bytes behind the link.+          runTestM "scanReferences scans link targets, not linked bytes" $ do+            tmpBase <- getTemporaryDirectory+            let base = tmpBase </> "nova-nix-test-scan-links"+                outside = base </> "outside"+                scanDir = base </> "tree"+                inTargetHash = T.replicate 32 "a"+                outsideHash = T.replicate 32 "b"+                inTarget = StorePath inTargetHash "dep1"+                outsideOnly = StorePath outsideHash "dep2"+            Dir.removePathForcibly base+            createDirectoryIfMissing True outside+            createDirectoryIfMissing True scanDir+            -- outsideOnly's hash exists only in file bytes OUTSIDE the+            -- tree, reachable through a link; inTarget's exists only in+            -- a link's target string.+            BS.writeFile+              (outside </> "secret.txt")+              (TE.encodeUtf8 ("/nix/store/" <> outsideHash <> "-dep2"))+            Dir.createFileLink (outside </> "secret.txt") (scanDir </> "spy-link")+            Dir.createFileLink+              ("/nix/store/" <> T.unpack inTargetHash <> "-dep1/bin/tool")+              (scanDir </> "dep-link")+            Dir.createDirectoryLink scanDir (scanDir </> "loop")+            found <- timeout walkWatchdogMicros (scanReferences [inTarget, outsideOnly] scanDir)+            Dir.removePathForcibly base+            pure $ case found of+              Nothing -> Fail "scan did not terminate on a link cycle"+              Just refs -> assertEqual "scanned refs" [inTarget] refs,+          -- copyPathInto replicates every link kind, so the NAR bytes -+          -- and the hash a cache signs - are identical whether an output+          -- was renamed or copied across volumes.+          runTestM "copyPathInto replicates links and preserves NAR bytes" $ do+            tmpBase <- getTemporaryDirectory+            let base = tmpBase </> "nova-nix-test-copy-links"+                src = base </> "src"+                dest = base </> "dest"+            Dir.removePathForcibly base+            createDirectoryIfMissing True (src </> "subdir")+            BS.writeFile (src </> "file.txt") "payload"+            BS.writeFile (src </> "subdir" </> "inner.txt") "inner"+            Dir.createFileLink "file.txt" (src </> "rel-link")+            Dir.createDirectoryLink "subdir" (src </> "dir-link")+            Dir.createFileLink "missing.txt" (src </> "dangling")+            copyPathInto src dest+            relIsLink <- Dir.pathIsSymbolicLink (dest </> "rel-link")+            relTarget <- Dir.getSymbolicLinkTarget (dest </> "rel-link")+            dirIsLink <- Dir.pathIsSymbolicLink (dest </> "dir-link")+            danglingIsLink <- Dir.pathIsSymbolicLink (dest </> "dangling")+            srcNarHash <- CHash.formatNixHash . CHash.hashBytes . NAR.serialise <$> NAR.serialiseFromPath src+            destNarHash <- CHash.formatNixHash . CHash.hashBytes . NAR.serialise <$> NAR.serialiseFromPath dest+            Dir.removePathForcibly base+            pure $+              if relIsLink && dirIsLink && danglingIsLink && relTarget == "file.txt"+                then assertEqual "copied NAR hash" srcNarHash destNarHash+                else+                  Fail+                    ( "rel-link/dir-link/dangling as links: "+                        <> T.pack (show (relIsLink, dirIsLink, danglingIsLink))+                        <> ", rel target: "+                        <> T.pack (show relTarget)+                    )+        ]++-- | Pure ordering for the store's symlink second pass: each link+-- follows the pending links its target resolves at or through, and+-- cycle members fall out at the end in input order.+testLinkOrdering :: IO [Bool]+testLinkOrdering = do+  putStrLn "store/link-ordering"+  let root = "out"+  sequence+    [ runTest "chain orders targets first" $+        let pending = [(root </> "l1", "l2"), (root </> "l2", "l3"), (root </> "l3", "real")]+         in assertEqual+              "chain"+              [root </> "l3", root </> "l2", root </> "l1"]+              (map fst (orderLinks pending)),+      runTest "already-ordered chain is stable" $+        let pending = [(root </> "l3", "real"), (root </> "l2", "l3"), (root </> "l1", "l2")]+         in assertEqual+              "stable"+              [root </> "l3", root </> "l2", root </> "l1"]+              (map fst (orderLinks pending)),+      runTest "link through a linked directory follows it" $+        let pending = [(root </> "a", "dirlink/x"), (root </> "dirlink", "realdir")]+         in assertEqual+              "through-dir"+              [root </> "dirlink", root </> "a"]+              (map fst (orderLinks pending)),+      runTest "parent-relative target orders after its link" $+        let pending = [(root </> "sub" </> "l", "../other"), (root </> "other", "real")]+         in assertEqual+              "dotdot"+              [root </> "other", root </> "sub" </> "l"]+              (map fst (orderLinks pending)),+      runTest "cycle members keep input order at the end" $+        let pending = [(root </> "a", "b"), (root </> "b", "a"), (root </> "c", "real")]+         in assertEqual+              "cycle"+              [root </> "c", root </> "a", root </> "b"]+              (map fst (orderLinks pending)),+      runTest "self-target link survives to the fallback" $+        assertEqual+          "self"+          [root </> "x"]+          (map fst (orderLinks [(root </> "x", "x")])),+      runTestM "long chain orders in linear time" $ do+        -- 20000 links each targeting the next: the ready-set rounds+        -- this replaced were quadratic here.+        let chain =+              [ (root </> ("l" <> show i), T.pack ("l" <> show (i + 1)))+              | i <- [1 :: Int .. 20000]+              ]+                ++ [(root </> "l20001", "real")]+            complete = length (orderLinks chain) == length chain+        outcome <- timeout walkWatchdogMicros (evaluate complete)+        pure $ case outcome of+          Just True -> Pass+          Just False -> Fail "ordering dropped links"+          Nothing -> Fail "ordering did not return promptly"+    ]++-- ---------------------------------------------------------------------------+-- Tests: fromATerm + Derivation Output Population (Phase 2, Batch 3)+-- ---------------------------------------------------------------------------++-- | A simple test derivation for round-trip testing.+simpleTestDrv :: Derivation+simpleTestDrv =+  Derivation+    { drvOutputs =+        [ DerivationOutput+            { doName = "out",+              doPath = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "hello-1.0",+              doHashAlgo = "",+              doHash = ""+            }+        ],+      drvInputDrvs = Map.empty,+      drvInputSrcs = [],+      drvPlatform = X86_64_Linux,+      drvBuilder = "/nix/store/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-bash-5.2/bin/bash",+      drvArgs = ["-e", "/nix/store/cccccccccccccccccccccccccccccccc-stdenv/setup"],+      drvEnv = Map.fromList [("name", "hello-1.0"), ("system", "x86_64-linux")]+    }++-- | A complex test derivation with multiple outputs, input drvs, and input srcs.+complexTestDrv :: Derivation+complexTestDrv =+  Derivation+    { drvOutputs =+        [ DerivationOutput "dev" (StorePath "dddddddddddddddddddddddddddddddd" "pkg-2.0-dev") "" "",+          DerivationOutput "out" (StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "pkg-2.0") "" ""+        ],+      drvInputDrvs =+        Map.fromList+          -- Hash fixtures stay inside the nix-base32 alphabet (no e o u t):+          -- fromATerm parses these through parseStorePath, which now+          -- charset-checks the hash component.+          [ (StorePath "cccccccccccccccccccccccccccccccc" "dep1.drv", ["out"]),+            (StorePath "ffffffffffffffffffffffffffffffff" "dep2.drv", ["lib", "out"])+          ],+      drvInputSrcs = [StorePath "gggggggggggggggggggggggggggggggg" "source.tar.gz"],+      drvPlatform = Aarch64_Darwin,+      drvBuilder = "/nix/store/hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh-bash/bin/bash",+      drvArgs = ["-e", "build.sh"],+      drvEnv =+        Map.fromList+          [ ("buildInputs", "/nix/store/cccccccccccccccccccccccccccccccc-dep1"),+            ("name", "pkg-2.0"),+            ("out", "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-pkg-2.0"),+            ("system", "aarch64-darwin")+          ]+    }++testFromATerm :: IO [Bool]+testFromATerm = do+  putStrLn "derivation/fromATerm"+  sequence+    [ -- Round-trip: simple derivation+      runTest "fromATerm round-trip simple" $+        assertEqual "simple round-trip" (Right simpleTestDrv) (fromATerm (toATerm simpleTestDrv)),+      -- Round-trip: complex derivation+      runTest "fromATerm round-trip complex" $+        assertEqual "complex round-trip" (Right complexTestDrv) (fromATerm (toATerm complexTestDrv)),+      -- A .drv read from disk is input: an output name that violates the+      -- store-name rules (here a traversal shape that would later join+      -- the build dir) must refuse at parse.+      runTest "fromATerm rejects a traversal-shaped output name" $+        let evil =+              simpleTestDrv+                { drvOutputs =+                    [DerivationOutput "../evil" (StorePath (T.replicate 32 "a") "pkg") "" ""]+                }+         in case fromATerm (toATerm evil) of+              Left _ -> Pass+              Right _ -> Fail "parsed a drv with a traversal output name",+      -- drv4: the modulo-substitution section merges input-drv entries that+      -- share a modulo-hash key, unioning their output-name sets, so it is+      -- byte-identical to the already-merged form.  (Just subs replaces the+      -- whole input-drv section, so simpleTestDrv's own inputs are irrelevant.)+      runTest "inputDrvsSubst merges equal modulo keys" $+        assertEqual+          "subst-merge"+          (toATermForHash False (Just [("00000000", ["dev", "out"])]) simpleTestDrv)+          (toATermForHash False (Just [("00000000", ["out"]), ("00000000", ["dev"])]) simpleTestDrv),+      runTest "inputDrvsSubst unions and dedups merged out-names" $+        assertEqual+          "subst-union"+          (toATermForHash False (Just [("aabbccdd", ["dev", "out"])]) simpleTestDrv)+          (toATermForHash False (Just [("aabbccdd", ["out"]), ("aabbccdd", ["out", "dev"])]) simpleTestDrv),+      -- Distinct keys are untouched: still one entry each, ascending by key.+      runTest "inputDrvsSubst preserves distinct keys in order" $+        assertEqual+          "subst-distinct"+          (toATermForHash False (Just [("00000000", ["out"]), ("11111111", ["out"])]) simpleTestDrv)+          (toATermForHash False (Just [("11111111", ["out"]), ("00000000", ["out"])]) simpleTestDrv),+      -- Round-trip: empty derivation (no outputs, no inputs, no args, no env)+      runTest "fromATerm round-trip empty" $+        let emptyDrv =+              Derivation+                { drvOutputs = [],+                  drvInputDrvs = Map.empty,+                  drvInputSrcs = [],+                  drvPlatform = X86_64_Linux,+                  drvBuilder = "/bin/true",+                  drvArgs = [],+                  drvEnv = Map.empty+                }+         in assertEqual "empty round-trip" (Right emptyDrv) (fromATerm (toATerm emptyDrv)),+      -- Reject empty string+      runTest "fromATerm rejects empty" $+        assertLeft "empty" (fromATerm ""),+      -- Reject malformed+      runTest "fromATerm rejects malformed" $+        assertLeft "malformed" (fromATerm "NotADerivation"),+      -- Reject truncated+      runTest "fromATerm rejects truncated" $+        assertLeft "truncated" (fromATerm "Derive(["),+      -- Escaping round-trip: strings with special chars+      runTest "fromATerm escaping round-trip" $+        let escapeDrv =+              Derivation+                { drvOutputs =+                    [ DerivationOutput+                        { doName = "out",+                          doPath = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "esc-test",+                          doHashAlgo = "",+                          doHash = ""+                        }+                    ],+                  drvInputDrvs = Map.empty,+                  drvInputSrcs = [],+                  drvPlatform = X86_64_Linux,+                  drvBuilder = "/bin/bash",+                  drvArgs = ["-c", "echo \"hello\nworld\""],+                  drvEnv = Map.fromList [("msg", "line1\nline2\ttab\\slash")]+                }+         in assertEqual "escape round-trip" (Right escapeDrv) (fromATerm (toATerm escapeDrv)),+      -- A non-standard escape keeps the byte and drops the backslash,+      -- matching upstream's .drv string parser.+      runTest "fromATerm drops the backslash on a non-standard escape" $+        let aterm = "Derive([(\"out\",\"/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-x\",\"\",\"\")],[],[],\"x86_64-linux\",\"/bin/sh\",[],[(\"k\",\"a\\xb\")])"+         in assertRight "nonstandard escape" (fromATerm aterm) $ \drv ->+              assertEqual "escape dropped" (Just "axb") (Map.lookup "k" (drvEnv drv)),+      -- writeDrv writes correct ATerm+      runTestM "writeDrv writes correct ATerm" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-writeDrv"+        removeIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let sp = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" "test.drv"+            destFile = storePathToFilePath (stDir store) sp+        writeDrv store simpleTestDrv sp+        contents <- BS.readFile destFile+        closeStore store+        removeIfExists tmpStore+        pure (assertEqual "writeDrv content" (toATerm simpleTestDrv) contents),+      -- The .drv closure registers as store objects: a row for every+      -- recipe plus edges to its input sources and input .drvs, so an+      -- output reference scan that names an input .drv resolves instead+      -- of failing the registration batch.+      runTestM "writeDrvClosure registers recipes with their references" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-drv-closure"+            sd = StoreDir tmpStore+        removeIfExists tmpStore+        store <- openStore sd+        let srcSp = StorePath "gggggggggggggggggggggggggggggggg" "source.tar.gz"+            drvASp = StorePath "cccccccccccccccccccccccccccccccc" "dep1.drv"+            drvBSp = StorePath "dddddddddddddddddddddddddddddddd" "top.drv"+            drvB =+              simpleTestDrv+                { drvInputDrvs = Map.fromList [(drvASp, ["out"])],+                  drvInputSrcs = [srcSp]+                }+        -- The source row registers first, as in the build driver, where+        -- materializeEvalSources precedes the closure write.+        registerPath (stDB store) (PathRegistration srcSp "sha256:0" 0 Nothing [])+        writeDrvClosure+          store+          ( Map.fromList+              [ (storePathToText defaultStoreDir drvASp, toATerm simpleTestDrv),+                (storePathToText defaultStoreDir drvBSp, toATerm drvB)+              ]+          )+        validA <- isValid store drvASp+        validB <- isValid store drvBSp+        refsB <- queryReferences (stDB store) drvBSp+        closeStore store+        removeIfExists tmpStore+        let expectedRefs =+              sort+                [ T.pack (storePathToFilePath sd drvASp),+                  T.pack (storePathToFilePath sd srcSp)+                ]+        pure $+          if not validA+            then Fail "input .drv not registered"+            else+              if not validB+                then Fail "root .drv not registered"+                else assertEqual "drv references" expectedRefs (sort refsB),+      -- builtinDerivation populates drvOutputs+      runTest "builtinDerivation populates drvOutputs"+        $ assertRight+          "drvOutputs"+          (evalNix "let d = derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; in d._derivation")+        $ \val -> case val of+          VDerivation drv ->+            case drvOutputs drv of+              [] -> Fail "drvOutputs is empty"+              (firstOut : _) ->+                if doName firstOut == "out"+                  then Pass+                  else Fail ("first output name: " <> doName firstOut)+          _ -> Fail ("expected VDerivation, got " <> T.pack (show val)),+      -- builtinDerivation multi-output populates drvOutputs+      runTest "builtinDerivation multi-output"+        $ assertRight+          "multi-output"+          (evalNix "let d = derivation { name = \"multi\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; outputs = [\"out\" \"dev\"]; }; in d._derivation")+        $ \val -> case val of+          VDerivation drv ->+            let names = map doName (drvOutputs drv)+             in if names == ["out", "dev"]+                  then Pass+                  else Fail ("output names: " <> T.pack (show names))+          _ -> Fail ("expected VDerivation, got " <> T.pack (show val)),+      -- builtinDerivation populates drvEnv with the output paths ($out, ...)+      -- and the build attributes.  Note: the .drv env does NOT contain a+      -- "drvPath" key - matching C++ Nix, which never writes one.+      runTest "builtinDerivation populates drvEnv"+        $ assertRight+          "drvEnv"+          (evalNix "let d = derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }; in d._derivation")+        $ \val -> case val of+          VDerivation drv+            | Just op <- Map.lookup "out" (drvEnv drv),+              "/nix/store/" `BS.isPrefixOf` op,+              Just nm <- Map.lookup "name" (drvEnv drv),+              nm == "test" ->+                Pass+            | otherwise ->+                Fail ("drvEnv keys: " <> T.pack (show (Map.toList (drvEnv drv))))+          _ -> Fail ("expected VDerivation, got " <> T.pack (show val))+    ]++-- ---------------------------------------------------------------------------+-- Tests: Builder (Phase 2, Batch 4)+-- ---------------------------------------------------------------------------++-- | Create a minimal Derivation for builder tests.+-- The shell path is discovered once via 'findTestShell' and threaded through.+-- All scripts are POSIX shell - bash is used on every platform.+mkTestBuildDrv :: Text -> StorePath -> Text -> Derivation+mkTestBuildDrv shell outSP script =+  Derivation+    { drvOutputs =+        [ DerivationOutput+            { doName = "out",+              doPath = outSP,+              doHashAlgo = "",+              doHash = ""+            }+        ],+      drvInputDrvs = Map.empty,+      drvInputSrcs = [],+      drvPlatform = currentPlatform,+      drvBuilder = TE.encodeUtf8 shell,+      drvArgs = ["-c", TE.encodeUtf8 script],+      drvEnv = Map.fromList [("name", "test-build"), ("system", TE.encodeUtf8 (platformToText currentPlatform))]+    }++-- | 'mkTestBuildDrv' with a fixed-output spec: registration must+-- re-verify the placed bytes against the declared digest.+mkFixedOutputDrv :: Text -> StorePath -> Text -> Text -> Text -> Derivation+mkFixedOutputDrv shell outSP script algoField hexDigest =+  (mkTestBuildDrv shell outSP script)+    { drvOutputs =+        [ DerivationOutput+            { doName = "out",+              doPath = outSP,+              doHashAlgo = algoField,+              doHash = hexDigest+            }+        ]+    }++testBuilder :: IO [Bool]+testBuilder = do+  putStrLn "builder"+  shell <- findTestShell+  sequence+    [ -- Build simple script that writes to $out+      runTestM "build simple script" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-builder1"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let outSP = StorePath "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1" "simple-test"+            drv = mkTestBuildDrv shell outSP "mkdir -p $out && echo hello > $out/result.txt"+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder1-tmp"}+        result <- buildDerivation config store drv+        closeStore store+        let ret = case result of+              BuildSuccess sp -> assertEqual "success path" outSP sp+              BuildFailure msg code -> Fail ("build failed (" <> T.pack (show code) <> "): " <> msg)+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure ret,+      -- Build with specific file content+      runTestM "build writes file content" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-builder2"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let outSP = StorePath "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" "content-test"+            drv = mkTestBuildDrv shell outSP "mkdir -p $out && echo 'test content 42' > $out/data.txt"+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder2-tmp"}+        result <- buildDerivation config store drv+        ret <- case result of+          BuildSuccess _ -> do+            let dataFile = storePathToFilePath (stDir store) outSP </> "data.txt"+            content <- TIO.readFile dataFile+            pure $+              if T.strip content == "test content 42"+                then Pass+                else Fail ("unexpected content: " <> content)+          BuildFailure msg code -> pure (Fail ("build failed (" <> T.pack (show code) <> "): " <> msg))+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure ret,+      -- Missing builder fails+      runTestM "missing builder fails" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-builder3"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let outSP = StorePath "cccccccccccccccccccccccccccccccc" "fail-test"+            drv =+              Derivation+                { drvOutputs = [DerivationOutput "out" outSP "" ""],+                  drvInputDrvs = Map.empty,+                  drvInputSrcs = [],+                  drvPlatform = currentPlatform,+                  drvBuilder = "/nonexistent/builder",+                  drvArgs = [],+                  drvEnv = Map.empty+                }+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder3-tmp"}+        result <- buildDerivation config store drv+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure $ case result of+          BuildFailure _ _ -> Pass+          BuildSuccess _ -> Fail "expected failure for missing builder",+      -- Exit failure returns error code+      runTestM "exit failure returns error code" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-builder4"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let outSP = StorePath "dddddddddddddddddddddddddddddddd" "exitfail"+            drv = mkTestBuildDrv shell outSP "exit 42"+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder4-tmp"}+        result <- buildDerivation config store drv+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure $ case result of+          BuildFailure _ code -> if code == 42 then Pass else Fail ("expected code 42, got " <> T.pack (show code))+          BuildSuccess _ -> Fail "expected failure",+      -- A builder that THROWS (nonexistent executable) must not leak the+      -- deterministic build dir: stale contents would fake an archive+      -- collision on the next builtin:unpack run of the same drv.+      runTestM "crashed build removes its build dir" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-builder-leak"+            tmpBuild = tmpBase </> "nova-nix-test-builder-leak-tmp"+        forceRemoveIfExists tmpStore+        forceRemoveIfExists tmpBuild+        store <- openStore (StoreDir tmpStore)+        let outSP = StorePath "pppppppppppppppppppppppppppppppp" "leaktest"+            drv = mkTestBuildDrv (T.pack (tmpBase </> "no-such-builder.exe")) outSP "unused"+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBuild}+        result <- buildDerivation config store drv+        leftovers <- do+          exists <- doesDirectoryExist tmpBuild+          if exists then Dir.listDirectory tmpBuild else pure []+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists tmpBuild+        pure $ case result of+          BuildFailure _ _+            | null leftovers -> Pass+            | otherwise -> Fail ("build dir leaked: " <> T.pack (show leftovers))+          BuildSuccess _ -> Fail "expected failure for a nonexistent builder",+      -- Output at expected path+      runTestM "output at expected store path" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-builder5"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let outSP = StorePath "cccccccccccccccccccccccccccccccc" "pathtest"+            drv = mkTestBuildDrv shell outSP "mkdir -p $out && touch $out/marker"+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder5-tmp"}+        result <- buildDerivation config store drv+        ret <- case result of+          BuildSuccess _ -> do+            let expectedDir = storePathToFilePath (stDir store) outSP+            exists <- doesDirectoryExist expectedDir+            pure $ if exists then Pass else Fail "output dir doesn't exist at expected path"+          BuildFailure msg code -> pure (Fail ("build failed (" <> T.pack (show code) <> "): " <> msg))+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure ret,+      -- A leftover tree at the output path that is NOT valid in the DB has+      -- unknowable integrity (interrupted earlier run); the build replaces+      -- it with the fresh output - upstream's delete-then-move - clearing+      -- read-only marks rather than adopting stale bytes.+      runTestM "stale leftover output is replaced by the fresh build" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-builder-stale"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let outSP = StorePath "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" "staletest"+            drv = mkTestBuildDrv shell outSP "mkdir -p $out && echo fresh > $out/data.txt"+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder-stale-tmp"}+            targetPath = storePathToFilePath (stDir store) outSP+        -- Fake the interrupted run: stale read-only content at the output's+        -- store location, never registered.+        createDirectoryIfMissing True targetPath+        TIO.writeFile (targetPath </> "data.txt") "stale"+        setReadOnly targetPath+        result <- buildDerivation config store drv+        content <- TIO.readFile (targetPath </> "data.txt")+        registered <- isValid store outSP+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure $ case result of+          BuildSuccess _+            | T.strip content == "fresh" && registered -> Pass+            | otherwise -> Fail ("expected fresh registered content, got: " <> content)+          BuildFailure msg code -> Fail ("build failed (" <> T.pack (show code) <> "): " <> msg),+      -- Path registered in DB after build+      runTestM "path registered in DB" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-builder6"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let outSP = StorePath "ffffffffffffffffffffffffffffffff" "dbtest"+            drv = mkTestBuildDrv shell outSP "mkdir -p $out && touch $out/file"+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder6-tmp"}+        result <- buildDerivation config store drv+        ret <- case result of+          BuildSuccess _ -> do+            valid <- isValid store outSP+            pure $ if valid then Pass else Fail "path not valid in DB after build"+          BuildFailure msg code -> pure (Fail ("build failed (" <> T.pack (show code) <> "): " <> msg))+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure ret,+      -- Multiple outputs+      runTestM "multiple outputs" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-builder7"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let outSP = StorePath "ggggggggggggggggggggggggggggggg1" "multi"+            devSP = StorePath "ggggggggggggggggggggggggggggggg2" "multi-dev"+            drv =+              Derivation+                { drvOutputs =+                    [ DerivationOutput "out" outSP "" "",+                      DerivationOutput "dev" devSP "" ""+                    ],+                  drvInputDrvs = Map.empty,+                  drvInputSrcs = [],+                  drvPlatform = currentPlatform,+                  drvBuilder = TE.encodeUtf8 shell,+                  drvArgs = ["-c", "mkdir -p $out && echo lib > $out/lib.txt && mkdir -p $dev && echo headers > $dev/include.h"],+                  drvEnv = Map.fromList [("name", "multi")]+                }+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder7-tmp"}+        result <- buildDerivation config store drv+        ret <- case result of+          BuildSuccess _ -> do+            outExists <- doesDirectoryExist (storePathToFilePath (stDir store) outSP)+            devExists <- doesDirectoryExist (storePathToFilePath (stDir store) devSP)+            pure $+              if outExists && devExists+                then Pass+                else Fail ("out exists=" <> T.pack (show outExists) <> " dev exists=" <> T.pack (show devExists))+          BuildFailure msg code -> pure (Fail ("build failed (" <> T.pack (show code) <> "): " <> msg))+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure ret,+      runTestM "partial rebuild failure preserves a valid sibling output" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-builder-partial-valid"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let outSP = StorePath "ggggggggggggggggggggggggggggggg3" "partial-main"+            devSP = StorePath "ggggggggggggggggggggggggggggggg4" "partial-dev"+            mkDrv script =+              Derivation+                { drvOutputs =+                    [ DerivationOutput "out" outSP "" "",+                      DerivationOutput "dev" devSP "" ""+                    ],+                  drvInputDrvs = Map.empty,+                  drvInputSrcs = [],+                  drvPlatform = currentPlatform,+                  drvBuilder = TE.encodeUtf8 shell,+                  drvArgs = ["-c", script],+                  drvEnv = Map.fromList [("name", "partial-valid")]+                }+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder-partial-valid-tmp"}+            devPath = storePathToFilePath (stDir store) devSP+        initial <- buildDerivation config store (mkDrv "mkdir -p $out $dev && echo main > $out/value && echo keep > $dev/value")+        deleted <- deleteStorePathRaw store (spHash outSP <> "-" <> spName outSP)+        rebuilt <- buildDerivation config store (mkDrv "mkdir -p $out && echo partial > $out/value && exit 1")+        devOnDisk <- Dir.doesPathExist devPath+        devValid <- isValid store devSP+        devContents <- if devOnDisk then T.strip <$> TIO.readFile (devPath </> "value") else pure ""+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        let deletedFirst = either (const False) (\outcome -> doRowRemoved outcome && doTreeRemoved outcome) deleted+        pure $ case (initial, rebuilt) of+          (BuildSuccess _, BuildFailure _ _)+            | deletedFirst && devOnDisk && devValid && devContents == "keep" -> Pass+            | otherwise -> Fail "failed partial rebuild removed or changed its valid sibling output"+          _ -> Fail "partial rebuild test setup did not reach the expected states",+      -- The shape the reviewer's blocker had: a package with a dev output+      -- has ONE builder writing both, so a partial rebuild hands that+      -- builder a $dev that is valid, registered and sealed read-only.+      -- Writing there has to work and must not disturb what is registered.+      runTestM "partial rebuild writes both outputs and leaves the valid one alone" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-builder-partial-both"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let outSP = StorePath "ggggggggggggggggggggggggggggggg5" "both-main"+            devSP = StorePath "ggggggggggggggggggggggggggggggg6" "both-dev"+            mkDrv script =+              Derivation+                { drvOutputs =+                    [ DerivationOutput "out" outSP "" "",+                      DerivationOutput "dev" devSP "" ""+                    ],+                  drvInputDrvs = Map.empty,+                  drvInputSrcs = [],+                  drvPlatform = currentPlatform,+                  drvBuilder = TE.encodeUtf8 shell,+                  drvArgs = ["-c", script],+                  drvEnv = Map.fromList [("name", "partial-both")]+                }+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder-partial-both-tmp"}+            devPath = storePathToFilePath (stDir store) devSP+            outPath = storePathToFilePath (stDir store) outSP+            -- Both outputs, every time, as a real multi-output builder does.+            writeBoth v = "mkdir -p $out $dev && echo " <> v <> " > $out/value && echo " <> v <> " > $dev/value"+        initial <- buildDerivation config store (mkDrv (writeBoth "first"))+        deleted <- deleteStorePathRaw store (spHash outSP <> "-" <> spName outSP)+        rebuilt <- buildDerivation config store (mkDrv (writeBoth "second"))+        outContents <- T.strip <$> TIO.readFile (outPath </> "value")+        devContents <- T.strip <$> TIO.readFile (devPath </> "value")+        devValid <- isValid store devSP+        outValid <- isValid store outSP+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        let deletedFirst = either (const False) (\outcome -> doRowRemoved outcome && doTreeRemoved outcome) deleted+        pure $ case (initial, rebuilt) of+          (BuildSuccess _, BuildSuccess _)+            | not deletedFirst -> Fail "partial rebuild setup did not delete the first output"+            -- The valid sibling keeps the bytes its NarHash describes: the+            -- builder wrote to a scratch path, not to it.+            | devContents /= "first" -> Fail ("valid sibling was overwritten: " <> devContents)+            | outContents /= "second" -> Fail ("rebuilt output has the wrong contents: " <> outContents)+            | not (outValid && devValid) -> Fail "both outputs should be valid after the rebuild"+            | otherwise -> Pass+          (_, BuildFailure msg _) -> Fail ("a builder writing both outputs failed the rebuild: " <> msg)+          _ -> Fail "partial rebuild test setup did not reach the expected states",+      -- The scratch path is discarded after the build, so an output that+      -- records it would be registered with a reference to nothing.  We do+      -- not rewrite the contents the way upstream does; we refuse.+      runTestM "an output recording a valid sibling's scratch path fails the build" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-builder-scratch-leak"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let outSP = StorePath "ggggggggggggggggggggggggggggggg7" "leak-main"+            devSP = StorePath "ggggggggggggggggggggggggggggggg8" "leak-dev"+            mkDrv script =+              Derivation+                { drvOutputs =+                    [ DerivationOutput "out" outSP "" "",+                      DerivationOutput "dev" devSP "" ""+                    ],+                  drvInputDrvs = Map.empty,+                  drvInputSrcs = [],+                  drvPlatform = currentPlatform,+                  drvBuilder = TE.encodeUtf8 shell,+                  drvArgs = ["-c", script],+                  drvEnv = Map.fromList [("name", "scratch-leak")]+                }+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder-scratch-leak-tmp"}+            devPath = storePathToFilePath (stDir store) devSP+        initial <- buildDerivation config store (mkDrv "mkdir -p $out $dev && echo first > $out/value && echo first > $dev/value")+        deleted <- deleteStorePathRaw store (spHash outSP <> "-" <> spName outSP)+        -- \$dev now names a scratch path, and the builder bakes it into $out+        -- the way a compiler driver or a libtool archive would.+        rebuilt <- buildDerivation config store (mkDrv "mkdir -p $out $dev && echo $dev > $out/recorded && echo first > $dev/value")+        devContents <- T.strip <$> TIO.readFile (devPath </> "value")+        devValid <- isValid store devSP+        outValid <- isValid store outSP+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        let deletedFirst = either (const False) (\outcome -> doRowRemoved outcome && doTreeRemoved outcome) deleted+        pure $ case (initial, rebuilt) of+          (BuildSuccess _, BuildFailure msg _)+            | not deletedFirst -> Fail "scratch-leak setup did not delete the first output"+            | not ("scratch path" `T.isInfixOf` msg) -> Fail ("expected a scratch-path refusal, got: " <> msg)+            -- The refusal must not cost the sibling that was already valid.+            | not devValid || devContents /= "first" -> Fail "the refusal disturbed the valid sibling"+            | outValid -> Fail "the refused output registered anyway"+            | otherwise -> Pass+          (_, BuildSuccess _) -> Fail "an output naming a discarded scratch path was accepted"+          _ -> Fail "scratch-leak test setup did not reach the expected states",+      -- Builder succeeds but doesn't create $out, so the build fails+      runTestM "missing output fails build" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-builder-noout"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let outSP = StorePath "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" "nooutput"+            drv = mkTestBuildDrv shell outSP "echo 'forgot to create output'"+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder-noout-tmp"}+        result <- buildDerivation config store drv+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure $ case result of+          BuildFailure msg _ ->+            if T.isInfixOf "outputs missing" msg+              then Pass+              else Fail ("expected 'outputs missing' error, got: " <> msg)+          BuildSuccess _ -> Fail "expected failure when builder doesn't create $out",+      runTestM "missing sibling output cleans produced orphan" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-builder-partial-missing"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let outSP = StorePath "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiii1" "partial-output"+            devSP = StorePath "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiii2" "missing-output"+            drv =+              Derivation+                { drvOutputs =+                    [ DerivationOutput "out" outSP "" "",+                      DerivationOutput "dev" devSP "" ""+                    ],+                  drvInputDrvs = Map.empty,+                  drvInputSrcs = [],+                  drvPlatform = currentPlatform,+                  drvBuilder = TE.encodeUtf8 shell,+                  drvArgs = ["-c", "mkdir -p $out && echo orphan > $out/value"],+                  drvEnv = Map.fromList [("name", "partial-missing")]+                }+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder-partial-missing-tmp"}+            outPath = storePathToFilePath (stDir store) outSP+            devPath = storePathToFilePath (stDir store) devSP+        result <- buildDerivation config store drv+        outOnDisk <- Dir.doesPathExist outPath+        devOnDisk <- Dir.doesPathExist devPath+        outValid <- isValid store outSP+        devValid <- isValid store devSP+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure $ case result of+          BuildFailure msg _+            | "outputs missing" `T.isInfixOf` msg && not outOnDisk && not devOnDisk && not outValid && not devValid -> Pass+            | otherwise -> Fail ("missing-output failure left store state behind: " <> msg)+          BuildSuccess _ -> Fail "partial-output builder unexpectedly succeeded",+      -- File output (not directory) should succeed+      runTestM "file output succeeds" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-builder-fileout"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let outSP = StorePath "jjjjjjjjjjjjjjjjjjjjjjjjjjjjjj" "fileout"+            drv = mkTestBuildDrv shell outSP "echo 'I am a file output' > $out"+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder-fileout-tmp"}+        result <- buildDerivation config store drv+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure $ case result of+          BuildSuccess sp -> assertEqual "file output path" outSP sp+          BuildFailure msg code -> Fail ("file output build failed (" <> T.pack (show code) <> "): " <> msg),+      -- Cleanup after failure+      runTestM "cleanup after failure" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-builder8"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let outSP = StorePath "hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh" "cleantest"+            drv = mkTestBuildDrv shell outSP "exit 1"+            tmpDir = tmpBase </> "nova-nix-test-builder8-tmp"+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpDir}+        _ <- buildDerivation config store drv+        -- Build dir should be cleaned up+        let buildDir = tmpDir </> T.unpack (spHash outSP)+        buildDirExists <- doesDirectoryExist buildDir+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists tmpDir+        pure $ if not buildDirExists then Pass else Fail "build dir not cleaned up after failure",+      -- Fixed-output: registration re-checks the placed bytes+      runTestM "fixed-output flat output verifies and registers" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-fo1"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let payload = "fixed output payload\n"+            digest = maybe "" Hash.bytesToHexText (Hash.rawHashWithAlgo "sha256" payload)+            outSP = StorePath "ffffffffffffffffffffffffffffff01" "fo-flat-good"+            drv = mkFixedOutputDrv shell outSP "printf 'fixed output payload\\n' > $out" "sha256" digest+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-fo1-tmp"}+        result <- buildDerivation config store drv+        registered <- isValid store outSP+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure $ case result of+          BuildSuccess _+            | registered -> Pass+            | otherwise -> Fail "built but not registered valid"+          BuildFailure msg code -> Fail ("expected success (" <> T.pack (show code) <> "): " <> msg),+      -- The impureEnvVars carve-out (#185): a fixed-output derivation's+      -- listed ambient variables reach the scrubbed build, an absent+      -- listed name arrives as the EMPTY STRING (present, not omitted,+      -- upstream's value_or("")), and everything else stays scrubbed.+      runTestM "impureEnvVars reach a fixed-output build, absent names as empty" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-impure1"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        setEnv "NOVA_TEST_IMPURE" "proxy-ok"+        unsetEnv "NOVA_TEST_ABSENT"+        let payload = "fixed output payload\n"+            digest = maybe "" Hash.bytesToHexText (Hash.rawHashWithAlgo "sha256" payload)+            outSP = StorePath "ffffffffffffffffffffffffffffff03" "fo-impure-env"+            script = "[ \"$NOVA_TEST_IMPURE\" = \"proxy-ok\" ] && [ \"${NOVA_TEST_ABSENT+set}\" = \"set\" ] && [ -z \"$NOVA_TEST_ABSENT\" ] && printf 'fixed output payload\\n' > $out"+            baseDrv = mkFixedOutputDrv shell outSP script "sha256" digest+            drv = baseDrv {drvEnv = Map.insert "impureEnvVars" "NOVA_TEST_IMPURE NOVA_TEST_ABSENT" (drvEnv baseDrv)}+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-impure1-tmp"}+        result <- buildDerivation config store drv+        unsetEnv "NOVA_TEST_IMPURE"+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure $ case result of+          BuildSuccess _ -> Pass+          BuildFailure msg code -> Fail ("carve-out did not reach the build (" <> T.pack (show code) <> "): " <> msg),+      runTestM "impureEnvVars are ignored for a non-fixed-output build" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-impure2"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        setEnv "NOVA_TEST_IMPURE" "must-not-leak"+        let outSP = StorePath "ffffffffffffffffffffffffffffff04" "impure-scrubbed"+            script = "[ -z \"${NOVA_TEST_IMPURE-}\" ] && echo ok > $out"+            baseDrv = mkTestBuildDrv shell outSP script+            drv = baseDrv {drvEnv = Map.insert "impureEnvVars" "NOVA_TEST_IMPURE" (drvEnv baseDrv)}+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-impure2-tmp"}+        result <- buildDerivation config store drv+        unsetEnv "NOVA_TEST_IMPURE"+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure $ case result of+          BuildSuccess _ -> Pass+          BuildFailure msg code -> Fail ("ambient leaked into a non-fixed-output build (" <> T.pack (show code) <> "): " <> msg),+      runTestM "fixed-output flat mismatch fails and removes the output" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-fo2"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let digest = maybe "" Hash.bytesToHexText (Hash.rawHashWithAlgo "sha256" "expected content\n")+            outSP = StorePath "ffffffffffffffffffffffffffffff02" "fo-flat-bad"+            drv = mkFixedOutputDrv shell outSP "printf 'tampered content\\n' > $out" "sha256" digest+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-fo2-tmp"}+        result <- buildDerivation config store drv+        registered <- isValid store outSP+        onDisk <- Dir.doesPathExist (storePathToFilePath (stDir store) outSP)+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure $ case result of+          BuildFailure msg _+            | "hash mismatch" `T.isInfixOf` msg && not registered && not onDisk -> Pass+            | otherwise ->+                Fail+                  ( "wrong failure shape (valid="+                      <> T.pack (show registered)+                      <> ", onDisk="+                      <> T.pack (show onDisk)+                      <> "): "+                      <> msg+                  )+          BuildSuccess _ -> Fail "mismatched fixed output registered",+      runTestM "fixed-output recursive tree verifies against its NAR hash" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-fo3"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let tree = NAR.NarDirectory [("data.txt", NAR.NarRegular False "inner bytes\n")]+            digest = maybe "" Hash.bytesToHexText (Hash.rawHashWithAlgo "sha256" (NAR.serialise tree))+            outSP = StorePath "ffffffffffffffffffffffffffffff03" "fo-rec-good"+            drv = mkFixedOutputDrv shell outSP "mkdir -p $out && printf 'inner bytes\\n' > $out/data.txt" "r:sha256" digest+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-fo3-tmp"}+        result <- buildDerivation config store drv+        registered <- isValid store outSP+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure $ case result of+          BuildSuccess _+            | registered -> Pass+            | otherwise -> Fail "built but not registered valid"+          BuildFailure msg code -> Fail ("expected success (" <> T.pack (show code) <> "): " <> msg),+      runTestM "fixed-output recursive mismatch fails the build" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-fo4"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let tree = NAR.NarDirectory [("data.txt", NAR.NarRegular False "declared bytes\n")]+            digest = maybe "" Hash.bytesToHexText (Hash.rawHashWithAlgo "sha256" (NAR.serialise tree))+            outSP = StorePath "ffffffffffffffffffffffffffffff04" "fo-rec-bad"+            drv = mkFixedOutputDrv shell outSP "mkdir -p $out && printf 'other bytes\\n' > $out/data.txt" "r:sha256" digest+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-fo4-tmp"}+        result <- buildDerivation config store drv+        registered <- isValid store outSP+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure $ case result of+          BuildFailure msg _+            | "hash mismatch" `T.isInfixOf` msg && not registered -> Pass+            | otherwise -> Fail ("wrong failure shape: " <> msg)+          BuildSuccess _ -> Fail "mismatched fixed output registered",+      runTestM "fixed-output flat mode rejects a directory output" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-fo5"+        forceRemoveIfExists tmpStore+        store <- openStore (StoreDir tmpStore)+        let digest = maybe "" Hash.bytesToHexText (Hash.rawHashWithAlgo "sha256" "whatever\n")+            outSP = StorePath "ffffffffffffffffffffffffffffff05" "fo-flat-dir"+            drv = mkFixedOutputDrv shell outSP "mkdir -p $out && printf 'x' > $out/f" "sha256" digest+            config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-fo5-tmp"}+        result <- buildDerivation config store drv+        closeStore store+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (bcTmpDir config)+        pure $ case result of+          BuildFailure msg _+            | "directory" `T.isInfixOf` msg -> Pass+            | otherwise -> Fail ("wrong failure: " <> msg)+          BuildSuccess _ -> Fail "directory output passed a flat fixed-output check"+    ]++-- ---------------------------------------------------------------------------+-- Tests: CLI Integration (Phase 2, Batch 5)+-- ---------------------------------------------------------------------------++-- | End-to-end: eval .nix source, extract derivation, build, verify output.+evalAndBuild :: StoreDir -> Text -> IO (Either Text (BuildResult, Store))+evalAndBuild storeDir source = do+  case parseNix testBaseDir "<test>" source of+    Left err -> pure (Left ("parse error: " <> T.pack (show err)))+    Right expr -> do+      -- The SAME store the build below is given.  Evaluating against the+      -- platform default while building elsewhere is what let a store dir+      -- honored on writes but not on reads pass the whole suite.+      st <- newEvalState storeDir "."+      evalResult <- runEvalIO st $ do+        val <- eval (builtinEnv (esTimestamp st) (esSearchPaths st)) expr+        -- 'derivation' is lazy now; force _derivation so the peek below sees it+        -- (and so a missing required attr surfaces as an eval error).+        case val of+          VAttrs attrs -> maybe (pure ()) (void . force) (attrSetLookup "_derivation" attrs)+          _ -> pure ()+        pure val+      case evalResult of+        Left err -> pure (Left ("eval error: " <> err))+        Right val -> case val of+          VAttrs attrs -> case attrSetLookup "_derivation" attrs >>= readThunkValue of+            Just (VDerivation drv) -> do+              store <- openStore storeDir+              tmpBase <- getTemporaryDirectory+              let config = (defaultBuildConfig storeDir) {bcTmpDir = tmpBase </> "nova-nix-e2e-tmp"}+              -- What the CLI driver does before building, in the same+              -- order: eval has no store DB handle, so anything it wrote+              -- is registered here or not at all.  Skipping it made this+              -- harness unable to see a whole class of driver bug.+              sourceCache <- readIORef (esSourcePathCache st)+              storeWrites <- readIORef (esStoreWriteCache st)+              materializeEvalSources store sourceCache+              materializeEvalStoreWrites store storeWrites+              result <- buildDerivation config store drv+              pure (Right (result, store))+            _ -> pure (Left "no _derivation in result attrs")+          _ -> pure (Left "result is not an attrset")++testE2E :: IO [Bool]+testE2E = do+  putStrLn "cli/e2e"+  shell <- findTestShell+  sequence+    [ -- End-to-end: eval -> build a simple derivation+      runTestM "e2e eval -> build" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-e2e1"+        forceRemoveIfExists tmpStore+        -- Build the Nix source with the discovered shell path.+        -- Nix strings use \\ for literal backslash, \" for literal quote.+        let nixEscape = T.concatMap (\c -> if c == '\\' then "\\\\" else if c == '"' then "\\\"" else T.singleton c)+            e2eSource =+              T.concat+                [ "derivation { name = \"e2e-test\"; system = builtins.currentSystem; ",+                  "builder = \"" <> nixEscape shell <> "\"; ",+                  "args = [\"-c\" \"mkdir -p $out && echo e2e > $out/e2e.txt\"]; }"+                ]+        result <- evalAndBuild (StoreDir tmpStore) e2eSource+        ret <- case result of+          Left err -> pure (Fail err)+          Right (BuildSuccess _, store) -> do+            closeStore store+            pure Pass+          Right (BuildFailure msg code, store) -> do+            closeStore store+            pure (Fail ("build failed (" <> T.pack (show code) <> "): " <> msg))+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (tmpBase </> "nova-nix-e2e-tmp")+        pure ret,+      -- Every eval-time store writer must register, not only toFile: an+      -- unrecorded write reaches drvInputSrcs and the build dies with+      -- "references unregistered path".+      runTestM "e2e eval-time store writes register as derivation inputs" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-e2e-inputs"+            srcDir = tmpBase </> "nova-nix-test-e2e-inputs-src"+            nixEscape = T.concatMap (\c -> if c == '\\' then "\\\\" else if c == '"' then "\\\"" else T.singleton c)+            drvWith srcExpr =+              T.concat+                [ "derivation { name = \"inputs-test\"; system = builtins.currentSystem; ",+                  "builder = \"" <> nixEscape shell <> "\"; ",+                  "args = [\"-c\" \"echo ok > $out\"]; ",+                  "src = " <> srcExpr <> "; }"+                ]+        forceRemoveIfExists tmpStore+        forceRemoveIfExists srcDir+        Dir.createDirectoryIfMissing True srcDir+        writeFile (srcDir </> "f.txt") "content\n"+        let cases =+              [ ("toFile", "builtins.toFile \"note\" \"text\""),+                -- A path LITERAL, not a string, so it must be spelled the+                -- way the evaluator spells path values: the lexer rejects a+                -- backslash, and a native Windows temp dir is full of them.+                -- Through 'canonPathValue' rather than a separator replace,+                -- because a backslash is an ordinary file-name character on+                -- POSIX and only a separator on Windows.+                ("path", "builtins.path { path = " <> canonPathValue (T.pack srcDir) <> "; name = \"sd\"; }")+              ]+        outcomes <-+          mapM+            ( \(label, expr) -> do+                result <- evalAndBuild (StoreDir tmpStore) (drvWith expr)+                pure (label, result)+            )+            cases+        let failures =+              [ label <> ": " <> reason+              | (label, result) <- outcomes,+                Just reason <-+                  [ case result of+                      Left err -> Just err+                      Right (BuildFailure msg _, _) -> Just msg+                      Right (BuildSuccess _, _) -> Nothing+                  ]+              ]+        mapM_+          ( \(_, result) -> case result of+              Right (_, store) -> closeStore store+              Left _ -> pure ()+          )+          outcomes+        forceRemoveIfExists tmpStore+        forceRemoveIfExists srcDir+        pure $ case failures of+          [] -> Pass+          errs -> Fail (T.intercalate "; " errs),+      -- The writer half of the eval-write pair: an interrupted earlier+      -- run's truncated file at a toFile path must be rewritten, not+      -- adopted on bare existence (adopting it used to register such a file+      -- valid under the full content's hash, seal it read-only, and let a+      -- build consume it).+      runTestM "a truncated pre-existing toFile write is rewritten, not adopted" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-tofile-verify"+            nixEscape = T.concatMap (\c -> if c == '\\' then "\\\\" else if c == '"' then "\\\"" else T.singleton c)+            goodText = "the full intended content" :: Text+            goodBytes = TE.encodeUtf8 goodText+        forceRemoveIfExists tmpStore+        case makeTextPath "note" (sha256Digest goodBytes) [] of+          Left err -> pure (Fail ("test store path rejected: " <> T.pack (show err)))+          Right sp -> do+            let dest = storePathToFilePath (StoreDir tmpStore) sp+            Dir.createDirectoryIfMissing True (takeDirectory dest)+            BS.writeFile dest (TE.encodeUtf8 "the full")+            let expr =+                  "derivation { name = \"tofile-verify\"; system = builtins.currentSystem; "+                    <> "builder = \""+                    <> nixEscape shell+                    <> "\"; "+                    <> "args = [\"-c\" \"echo ok > $out\"]; "+                    <> "src = builtins.toFile \"note\" \""+                    <> goodText+                    <> "\"; }"+            result <- evalAndBuild (StoreDir tmpStore) expr+            rewritten <- BS.readFile dest+            outcome <- case result of+              Left err -> pure (Fail err)+              Right (BuildFailure msg _, store) -> closeStore store >> pure (Fail msg)+              Right (BuildSuccess _, store) -> do+                registered <- isValid store sp+                closeStore store+                pure $+                  if rewritten == goodBytes && registered+                    then Pass+                    else Fail "adopted the truncated file instead of rewriting it"+            forceRemoveIfExists tmpStore+            pure outcome,+      -- Same guarantee for the tree writer behind builtins.path: a+      -- corrupted partial tree at the computed path is cleared and+      -- re-unpacked, never adopted.+      runTestM "a corrupted pre-existing builtins.path tree is re-copied, not adopted" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-path-verify"+            srcDir = tmpBase </> "nova-nix-test-path-verify-src"+            nixEscape = T.concatMap (\c -> if c == '\\' then "\\\\" else if c == '"' then "\\\"" else T.singleton c)+        forceRemoveIfExists tmpStore+        forceRemoveIfExists srcDir+        Dir.createDirectoryIfMissing True srcDir+        BS.writeFile (srcDir </> "f.txt") (TE.encodeUtf8 "real content\n")+        entry <- NAR.serialiseFromPath srcDir+        case makeFixedOutputPath "sd" "sha256" "recursive" (sha256Digest (NAR.serialise entry)) of+          Left err -> pure (Fail ("test store path rejected: " <> T.pack (show err)))+          Right sp -> do+            let dest = storePathToFilePath (StoreDir tmpStore) sp+            Dir.createDirectoryIfMissing True dest+            BS.writeFile (dest </> "f.txt") (TE.encodeUtf8 "partial garbage")+            let expr =+                  "derivation { name = \"path-verify\"; system = builtins.currentSystem; "+                    <> "builder = \""+                    <> nixEscape shell+                    <> "\"; "+                    <> "args = [\"-c\" \"echo ok > $out\"]; "+                    <> "src = builtins.path { path = "+                    <> canonPathValue (T.pack srcDir)+                    <> "; name = \"sd\"; }; }"+            result <- evalAndBuild (StoreDir tmpStore) expr+            outcome <- case result of+              Left err -> pure (Fail err)+              Right (BuildFailure msg _, store) -> closeStore store >> pure (Fail msg)+              Right (BuildSuccess _, store) -> do+                -- Strict bytes: a lazy read here holds the file open past+                -- the cleanup below, and Windows refuses to delete it.+                adopted <- BS.readFile (dest </> "f.txt")+                registered <- isValid store sp+                closeStore store+                pure $+                  if adopted == TE.encodeUtf8 "real content\n" && registered+                    then Pass+                    else Fail ("adopted the corrupted tree: " <> T.pack (show adopted))+            forceRemoveIfExists tmpStore+            forceRemoveIfExists srcDir+            pure outcome,+      -- The build path joins the per-path lock protocol (#119, #121) that+      -- delete, materialize and register already used.  Lock files persist+      -- by design, so the one guarding the output is the observable+      -- evidence the build took it; without it two concurrent builds share+      -- a build directory and interleave into one tree.+      runTestM "e2e a build takes its output's path lock" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-e2e-lock"+            nixEscape = T.concatMap (\c -> if c == '\\' then "\\\\" else if c == '"' then "\\\"" else T.singleton c)+            lockSource =+              T.concat+                [ "derivation { name = \"lock-test\"; system = builtins.currentSystem; ",+                  "builder = \"" <> nixEscape shell <> "\"; ",+                  "args = [\"-c\" \"echo locked > $out\"]; }"+                ]+        forceRemoveIfExists tmpStore+        result <- evalAndBuild (StoreDir tmpStore) lockSource+        ret <- case result of+          Left err -> pure (Fail err)+          Right (BuildFailure msg code, store) -> do+            closeStore store+            pure (Fail ("build failed (" <> T.pack (show code) <> "): " <> msg))+          Right (BuildSuccess sp, store) -> do+            closeStore store+            let outPath = storePathToFilePath (StoreDir tmpStore) sp+            locked <- Dir.doesFileExist (outPath <> ".lock")+            pure $+              if locked+                then Pass+                else Fail "no lock file beside the built output"+        forceRemoveIfExists tmpStore+        forceRemoveIfExists (tmpBase </> "nova-nix-e2e-tmp")+        pure ret,+      -- Parse error produces Left+      runTestM "e2e parse error" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-e2e2"+        forceRemoveIfExists tmpStore+        result <- evalAndBuild (StoreDir tmpStore) "{{ invalid nix"+        forceRemoveIfExists tmpStore+        pure $ case result of+          Left msg ->+            if "parse error" `T.isInfixOf` msg+              then Pass+              else Fail ("expected parse error, got: " <> msg)+          Right _ -> Fail "expected error but got success",+      -- Eval error produces Left+      runTestM "e2e eval error" $ do+        tmpBase <- getTemporaryDirectory+        let tmpStore = tmpBase </> "nova-nix-test-e2e3"+        forceRemoveIfExists tmpStore+        result <- evalAndBuild (StoreDir tmpStore) "derivation { }"+        forceRemoveIfExists tmpStore+        pure $ case result of+          Left msg ->+            if "error" `T.isInfixOf` T.toLower msg+              then Pass+              else Fail ("expected eval error, got: " <> msg)+          Right _ -> Fail "expected error but got success",+      -- E2E with subprocess: nova-nix eval on a .nix file+      runTestM "e2e nova-nix eval subprocess" $ do+        tmpBase <- getTemporaryDirectory+        let tmpDir = tmpBase </> "nova-nix-test-e2e-sub"+            nixFile = tmpDir </> "test.nix"+        forceRemoveIfExists tmpDir+        createDirectoryIfMissing True tmpDir+        writeFile nixFile "1 + 2"+        -- Run nova-nix eval via Process+        (exitCode, stdoutStr, stderrStr) <-+          Proc.readCreateProcessWithExitCode+            (Proc.proc "cabal" ["run", "nova-nix", "--", "eval", nixFile])+            ""+        forceRemoveIfExists tmpDir+        pure $ case exitCode of+          ExitSuccess ->+            let nonEmpty = filter (not . T.null) (T.lines (T.pack stdoutStr))+                lastLine = case reverse nonEmpty of+                  (l : _) -> Just l+                  [] -> Nothing+             in if lastLine == Just "3"+                  then Pass+                  else Fail ("expected 3 as last line, got: " <> T.pack stdoutStr)+          ExitFailure code ->+            Fail ("nova-nix eval failed (" <> T.pack (show code) <> "): stderr=" <> T.pack stderrStr)+    ]++-- | Exercise the real CLI: testing 'execWrapperConfig' alone cannot catch+-- a sub-parser that never passes the option to it. The foreign builder is+-- deliberately "-c", so only launching it through the shell can succeed.+testExecWrapperCLI :: IO [Bool]+testExecWrapperCLI = do+  putStrLn "cli/exec-wrapper"+  shell <- findTestShell+  tmpBase <- getTemporaryDirectory+  ambient <- getEnvironment+  let root = tmpBase </> "nova-nix-test-cli-wrapper"+      system = platformToText (if currentPlatform == X86_64_Windows then X86_64_Linux else X86_64_Windows)+      wrapper = ["--exec-wrapper", T.unpack (system <> "=" <> shell)]+      otherWrapper = ["--exec-wrapper", T.unpack (platformToText currentPlatform <> "=" <> shell)]+      source =+        "derivation { name = \"cli-wrapper\"; system = \""+          <> T.unpack system+          <> "\"; builder = \"-c\"; args = [\"printf wrapped > \\\"$out\\\"\"]; }"+      target = ["--expr", source]+      isolatedEnv =+        ("NIX_CONFIG", "substituters =\ntrusted-public-keys =\n")+          : ("XDG_CONFIG_HOME", root </> "config")+          : filter (\(key, _) -> key `notElem` ["NIX_CONFIG", "XDG_CONFIG_HOME"]) ambient+      runCLI args =+        Proc.readCreateProcessWithExitCode+          ((Proc.proc "cabal" (["run", "-v0", "nova-nix", "--"] ++ args)) {Proc.env = Just isolatedEnv})+          ""+      buildCases =+        [ ("before build", wrapper ++ ["build"] ++ target),+          ("before target", ["build"] ++ wrapper ++ target),+          ("after target", ["build"] ++ target ++ wrapper),+          ("repeated across build", otherWrapper ++ ["build"] ++ target ++ wrapper)+        ]+      errorCases =+        [ ("missing value", ["build"] ++ target ++ ["--exec-wrapper"], "--exec-wrapper requires a value"),+          ("malformed spec", ["build"] ++ target ++ ["--exec-wrapper", "invalid"], "--exec-wrapper expects SYSTEM=PATH, got: invalid"),+          ("duplicate system", wrapper ++ ["build"] ++ target ++ wrapper, "--exec-wrapper names " <> system <> " twice"),+          ("missing launcher", ["build"] ++ target ++ ["--exec-wrapper", T.unpack system ++ "=" ++ (root </> "missing-launcher")], "does not exist")+        ]+  bracket_+    (forceRemoveIfExists root >> createDirectoryIfMissing True root)+    (forceRemoveIfExists root)+    ( do+        builds <-+          mapM+            ( \(label, args) -> runTestM ("CLI wrapper " <> label) $ do+                (code, out, err) <- runCLI (args ++ ["--store", root </> T.unpack label])+                case (code, reverse (filter (not . null) (lines out))) of+                  (ExitSuccess, path : _) -> assertEqual "wrapped output" "wrapped" <$> BS.readFile path+                  _ -> pure (Fail ("CLI failed: " <> T.pack (show code) <> "; stdout=" <> T.pack out <> "; stderr=" <> T.pack err))+            )+            buildCases+        errors <-+          mapM+            ( \(label, args, expected) -> runTestM ("CLI wrapper " <> label) $ do+                (code, _, err) <- runCLI args+                pure $+                  if code /= ExitSuccess && expected `T.isInfixOf` T.pack err+                    then Pass+                    else Fail ("expected failure containing " <> expected <> ", got " <> T.pack (show (code, err)))+            )+            errorCases+        pure (builds ++ errors)+    )++-- ---------------------------------------------------------------------------+-- Tests: Phase 4 - search paths, dynamic keys, directory import+-- ---------------------------------------------------------------------------++testPhase4 :: IO [Bool]+testPhase4 = do+  putStrLn "phase4/search-paths"+  sequence+    [ -- parseNixPath tests+      runTest "parseNixPath empty" $+        assertEqual "empty" [] (parseNixPath ""),+      runTest "parseNixPath single" $+        let result = parseNixPath "nixpkgs=/home/user/nixpkgs"+         in case result of+              [thunk] | Just (VAttrs m) <- readThunkValue thunk ->+                case (attrSetLookup "prefix" m >>= readThunkValue, attrSetLookup "path" m >>= readThunkValue) of+                  (Just (VStr "nixpkgs" _), Just (VStr "/home/user/nixpkgs" _)) -> Pass+                  _ -> Fail "wrong prefix/path"+              _ -> Fail ("expected one entry, got " <> T.pack (show (length result))),+      runTest "parseNixPath multiple" $+        assertEqual "count" 2 (length (parseNixPath "nixpkgs=/nix:custom=/opt")),+      runTest "splitNixPath drive letters and separators" $+        assertEqual+          "split"+          ["nixpkgs=C:\\nixpkgs", "custom=/opt/custom", "C:/x"]+          (splitNixPath "nixpkgs=C:\\nixpkgs:custom=/opt/custom:C:/x"),+      runTest "splitNixPath splits a Unix-style absolute path list" $+        assertEqual "split-unix" ["/foo", "/bar"] (splitNixPath "/foo:/bar"),+      runTest "splitNixPath splits a Unix entry after a drive entry" $+        assertEqual "split-mixed" ["C:\\a", "/foo"] (splitNixPath "C:\\a:/foo"),+      runTest "splitNixPath keeps a URL entry whole" $+        assertEqual+          "split-url"+          ["nixpkgs=https://example.com/nixpkgs.tar.gz", "custom=/opt"]+          (splitNixPath "nixpkgs=https://example.com/nixpkgs.tar.gz:custom=/opt"),+      runTest "splitNixPath keeps interior empty entries" $+        assertEqual "split-empty" ["a", "", "b"] (splitNixPath "a::b"),+      runTest "splitNixPath drops a trailing empty entry" $+        assertEqual "split-trail" ["a"] (splitNixPath "a:"),+      runTestM "splitNixPath long entry splits in linear time" $ do+        let entry = T.replicate 2000000 "p"+            split = splitNixPath ("first:" <> entry) == ["first", entry]+        outcome <- timeout walkWatchdogMicros (evaluate split)+        pure $ case outcome of+          Just True -> Pass+          Just False -> Fail "wrong split for a long entry"+          Nothing -> Fail "split did not return promptly",+      runTest "parseNixPath plain path" $+        let result = parseNixPath "/some/path"+         in case result of+              [thunk] | Just (VAttrs m) <- readThunkValue thunk ->+                case (attrSetLookup "prefix" m >>= readThunkValue, attrSetLookup "path" m >>= readThunkValue) of+                  (Just (VStr "" _), Just (VStr "/some/path" _)) -> Pass+                  _ -> Fail "wrong prefix/path for plain"+              _ -> Fail "expected one entry",+      -- ESearchPath desugars to __findFile __nixPath "name" during resolution+      runTest "parse <nixpkgs>" $+        assertParse "search path" "<nixpkgs>" (EApp (EApp (EVar "__findFile") (EVar "__nixPath")) (EStr [StrLit "nixpkgs"])),+      runTest "parse <nixpkgs/lib>" $+        assertParse "search path with subpath" "<nixpkgs/lib>" (EApp (EApp (EVar "__findFile") (EVar "__nixPath")) (EStr [StrLit "nixpkgs/lib"])),+      -- ESearchPath eval (should fail in pure mode since no search paths)+      runTest "eval <nixpkgs> fails without path" $+        assertEvalFail "search path not found" "<nixpkgs>",+      -- Dynamic attribute keys+      runTest "dynamic key basic" $+        assertEval "dynamic key" "{ ${\"hello\"} = 42; }.hello" (VInt 42),+      runTest "dynamic key from let" $+        assertEval "dynamic key let" "let name = \"x\"; in { ${name} = 1; }.x" (VInt 1),+      runTest "dynamic key in select" $+        assertEval "dynamic key select" "let s = { x = 10; }; in s.${\"x\"}" (VInt 10),+      runTest "dynamic key in hasAttr" $+        assertEval "dynamic key hasAttr" "let s = { x = 10; }; in s ? ${\"x\"}" (VBool True),+      runTest "dynamic key hasAttr missing" $+        assertEval "dynamic key hasAttr missing" "let s = { x = 10; }; in s ? ${\"y\"}" (VBool False),+      -- has-attr checks presence of the terminal attribute without+      -- forcing its value, matching upstream laziness.+      runTest "hasAttr does not force the final attribute" $+        assertEval "hasattr-lazy" "{ a = builtins.throw \"boom\"; } ? a" (VBool True),+      runTest "hasAttr path does not force the terminal attribute" $+        assertEval "hasattr-path-lazy" "{ a = { b = builtins.throw \"boom\"; }; } ? a.b" (VBool True),+      -- A null dynamic key in select or has-attr is a type error, as+      -- upstream; it is not treated as an absent attribute.+      runTest "null dynamic select key is a type error" $+        assertEvalFail "sel-null-key" "({ x = 1; }).${null}",+      runTest "null dynamic hasAttr key is a type error" $+        assertEvalFail "has-null-key" "{ x = 1; } ? ${null}",+      -- inherit inside a rec set with a dynamic key resolves against the+      -- enclosing scope, never the rec set's own binding.+      runTest "inherit in a dynamic-keyed rec set resolves outward" $+        assertEval "dyn-rec-inherit" "let v = 42; in (rec { ${\"d\"} = 1; inherit v; }).v" (VInt 42),+      -- Dynamic attr inside string interpolation (the ${name} must not+      -- prematurely close the outer interpolation)+      runTest "dynamic key in string interp" $+        assertEval "dynamic key interp" "let s = { x = 10; }; in \"${toString s.${\"x\"}}\"" (VStr "10" mempty)+    ]++testPhase4IO :: IO [Bool]+testPhase4IO = do+  putStrLn "phase4/directory-import"+  tmpDir <- getTemporaryDirectory+  let testDir = tmpDir </> "nova-nix-phase4-test"+      subDir = testDir </> "mypkg"+      defaultNix = subDir </> "default.nix"+  -- Create temp directory structure+  createDirectoryIfMissing True subDir+  TIO.writeFile defaultNix "42"+  results <-+    sequence+      [ -- Directory import: import ./dir resolves to ./dir/default.nix+        runTestM "import directory" $ do+          result <- evalNixIO testDir ("import " <> T.pack "./mypkg")+          pure $ case result of+            Right (VInt 42) -> Pass+            Right other -> Fail ("expected VInt 42, got " <> T.pack (show other))+            Left err -> Fail ("eval error: " <> err),+        -- Search path with --nix-path equivalent (populated nixPath)+        runTestM "search path with populated nixPath" $ do+          st <- newEvalState platformStoreDir testDir+          let nixPaths = parseNixPath ("mypkg=" <> T.pack subDir)+              env = builtinEnv (esTimestamp st) nixPaths+          result <- runEvalIO st (eval env (EApp (EApp (EVar "__findFile") (EVar "__nixPath")) (EStr [StrLit "mypkg"])))+          pure $ case result of+            Right (VPath _) -> Pass+            Right other -> Fail ("expected VPath, got " <> T.pack (show other))+            Left err -> Fail ("eval error: " <> err)+      ]+  -- Cleanup+  removeDirectoryRecursive testDir+  pure results++-- | @builtins.toJSON@ of a path uses copy-to-store coercion (upstream+-- value-to-json.cc serializes paths with @copyToStore = true@): the JSON+-- text is the quoted source store path and the result carries its+-- context.  EvalIO-only: PureEval has no store-path computation.+testToJSONPathIO :: IO [Bool]+testToJSONPathIO = do+  putStrLn "eval/tojson-path-io"+  tmpDir <- getTemporaryDirectory+  let testDir = tmpDir </> "nova-nix-tojson-path-test"+  createDirectoryIfMissing True testDir+  TIO.writeFile (testDir </> "data.txt") "payload\n"+  results <-+    sequence+      [ runTestM "toJSON of a path is its quoted store path with context" $ do+          result <- evalNixIO testDir "builtins.toJSON ./data.txt"+          pure $ case result of+            Right (VStr json ctx) ->+              if "\"/nix/store/" `BS.isPrefixOf` json+                && "-data.txt\"" `BS.isSuffixOf` json+                && ctx /= emptyContext+                then Pass+                else Fail ("expected a quoted store path with context, got " <> bytesText json)+            Right other -> Fail ("expected VStr, got " <> T.pack (show other))+            Left err -> Fail ("eval error: " <> err)+      ]+  removeDirectoryRecursive testDir+  pure results++-- ---------------------------------------------------------------------------+-- Symbol interning (C FFI)+-- ---------------------------------------------------------------------------++testSymbol :: IO [Bool]+testSymbol = do+  putStrLn "symbol"+  -- Symbol table is initialized by arenaInit in main bracket.+  sequence+    [ runTestM "intern returns non-zero" $ do+        sym <- symbolIntern "hello"+        pure (if unSymbol sym /= 0 then Pass else Fail "got symbol 0"),+      runTestM "intern same string returns same symbol" $ do+        sym1 <- symbolIntern "name"+        sym2 <- symbolIntern "name"+        pure (assertEqual "same symbol" sym1 sym2),+      runTestM "intern different strings returns different symbols" $ do+        sym1 <- symbolIntern "foo"+        sym2 <- symbolIntern "bar"+        pure (if sym1 /= sym2 then Pass else Fail "symbols should differ"),+      runTestM "symbolText round-trips" $ do+        sym <- symbolIntern "version"+        let txt = symbolText sym+        pure (assertEqual "text" "version" txt),+      runTestM "symbolLen correct" $ do+        sym <- symbolIntern "outputs"+        pure (assertEqual "len" 7 (symbolLen sym)),+      runTestM "empty string interns" $ do+        sym <- symbolIntern ""+        let txt = symbolText sym+        pure (assertEqual "empty" "" txt),+      runTestM "symbolCount tracks unique entries" $ do+        _ <- symbolIntern "alpha"+        _ <- symbolIntern "beta"+        _ <- symbolIntern "alpha"+        count <- symbolCount+        -- count includes all symbols interned in this bracket,+        -- so at least the ones from prior tests plus alpha + beta+        pure (if count >= 2 then Pass else Fail ("count too low: " <> T.pack (show count))),+      runTestM "many symbols (stress)" $ do+        let names = map (\i -> "pkg_" <> T.pack (show (i :: Int))) [1 .. 1000]+        syms <- mapM symbolIntern names+        -- All unique+        let unique = length (Set.fromList (map unSymbol syms))+        pure (assertEqual "1000 unique" 1000 unique)+    ]++-- ---------------------------------------------------------------------------+-- C attribute set (FFI)+-- ---------------------------------------------------------------------------++-- | Cast a StablePtr to CThunkPtr for CAttrSet tests.+-- CAttrSet stores void* - we use StablePtrs as opaque values in tests.+spToCPtr :: StablePtr a -> CThunkPtr+spToCPtr = castPtr . castStablePtrToPtr++-- | Cast a CThunkPtr back to StablePtr for CAttrSet test verification.+cptrToSp :: CThunkPtr -> StablePtr a+cptrToSp = castPtrToStablePtr . castPtr++testCAttrSet :: IO [Bool]+testCAttrSet = do+  putStrLn "cattrset"+  -- Symbol table is initialized by arenaInit in main bracket.+  sequence+    [ runTestM "new/free" $ do+        _set <- cattrsetNew 16+        -- set freed by arenaDestroy via nn_attrset_free_all+        pure Pass,+      runTestM "insert + freeze + lookup" $ do+        set <- cattrsetNew 4+        kName <- symbolIntern "name"+        kVer <- symbolIntern "version"+        valName <- newStablePtr ("hello" :: Text)+        valVer <- newStablePtr ("1.0" :: Text)+        cattrsetInsert set kName (spToCPtr valName)+        cattrsetInsert set kVer (spToCPtr valVer)+        cattrsetFreeze set+        result <- cattrsetLookup set kName+        case result of+          Nothing -> do+            -- set freed by arenaDestroy via nn_attrset_free_all+            freeStablePtr valName+            freeStablePtr valVer+            pure (Fail "lookup returned Nothing")+          Just cptr -> do+            val <- deRefStablePtr (cptrToSp cptr) :: IO Text+            -- set freed by arenaDestroy via nn_attrset_free_all+            freeStablePtr valName+            freeStablePtr valVer+            pure (assertEqual "lookup name" "hello" val),+      runTestM "lookup missing key returns Nothing" $ do+        set <- cattrsetNew 4+        kFoo <- symbolIntern "foo"+        kBar <- symbolIntern "bar"+        sp <- newStablePtr ("x" :: Text)+        cattrsetInsert set kFoo (spToCPtr sp)+        cattrsetFreeze set+        result <- cattrsetLookup set kBar+        -- set freed by arenaDestroy via nn_attrset_free_all+        freeStablePtr sp+        pure (case result of Nothing -> Pass; Just _ -> Fail "expected Nothing"),+      runTestM "size after freeze" $ do+        set <- cattrsetNew 4+        k1 <- symbolIntern "a"+        k2 <- symbolIntern "b"+        k3 <- symbolIntern "c"+        sp <- newStablePtr (42 :: Int)+        cattrsetInsert set k1 (spToCPtr sp)+        cattrsetInsert set k2 (spToCPtr sp)+        cattrsetInsert set k3 (spToCPtr sp)+        cattrsetFreeze set+        n <- cattrsetSize set+        -- set freed by arenaDestroy via nn_attrset_free_all+        freeStablePtr sp+        pure (assertEqual "size" 3 n),+      runTestM "duplicate keys: last writer wins" $ do+        set <- cattrsetNew 4+        kName <- symbolIntern "name"+        sp1 <- newStablePtr ("first" :: Text)+        sp2 <- newStablePtr ("second" :: Text)+        cattrsetInsert set kName (spToCPtr sp1)+        cattrsetInsert set kName (spToCPtr sp2)+        cattrsetFreeze set+        n <- cattrsetSize set+        result <- cattrsetLookup set kName+        val <- case result of+          Nothing -> pure "MISSING"+          Just cptr -> deRefStablePtr (cptrToSp cptr)+        -- set freed by arenaDestroy via nn_attrset_free_all+        freeStablePtr sp1+        freeStablePtr sp2+        pure+          ( if n == 1 && val == ("second" :: Text)+              then Pass+              else Fail ("size=" <> T.pack (show n) <> " val=" <> val)+          ),+      runTestM "keys returned sorted" $ do+        set <- cattrsetNew 8+        -- Insert in reverse order; after freeze keys should be sorted by symbol ID+        k1 <- symbolIntern "zzz"+        k2 <- symbolIntern "aaa"+        k3 <- symbolIntern "mmm"+        sp <- newStablePtr (0 :: Int)+        cattrsetInsert set k1 (spToCPtr sp)+        cattrsetInsert set k2 (spToCPtr sp)+        cattrsetInsert set k3 (spToCPtr sp)+        cattrsetFreeze set+        keys <- cattrsetKeys set+        -- set freed by arenaDestroy via nn_attrset_free_all+        freeStablePtr sp+        -- Keys should be sorted by symbol ID (ascending)+        let ids = map unSymbol keys+            sorted = ids == foldl (\acc x -> acc ++ [x]) [] (Set.toAscList (Set.fromList ids))+        pure (if sorted then Pass else Fail ("unsorted: " <> T.pack (show ids))),+      runTestM "union right-biased" $ do+        setA <- cattrsetNew 4+        setB <- cattrsetNew 4+        kX <- symbolIntern "x"+        kY <- symbolIntern "y"+        kZ <- symbolIntern "z"+        spA <- newStablePtr ("fromA" :: Text)+        spB <- newStablePtr ("fromB" :: Text)+        spZ <- newStablePtr ("onlyA" :: Text)+        cattrsetInsert setA kX (spToCPtr spA)+        cattrsetInsert setA kZ (spToCPtr spZ)+        cattrsetInsert setB kX (spToCPtr spB)+        cattrsetInsert setB kY (spToCPtr spB)+        cattrsetFreeze setA+        cattrsetFreeze setB+        merged <- cattrsetUnion setA setB+        n <- cattrsetSize merged+        resultX <- cattrsetLookup merged kX+        valX <- case resultX of+          Nothing -> pure "MISSING"+          Just cptr -> deRefStablePtr (cptrToSp cptr)+        -- sets freed by arenaDestroy via nn_attrset_free_all+        freeStablePtr spA+        freeStablePtr spB+        freeStablePtr spZ+        pure+          ( if n == 3 && valX == ("fromB" :: Text)+              then Pass+              else Fail ("size=" <> T.pack (show n) <> " x=" <> valX)+          ),+      runTestM "stress: 10k entries" $ do+        set <- cattrsetNew 1024+        sp <- newStablePtr (0 :: Int)+        syms <- mapM (\i -> symbolIntern ("key_" <> T.pack (show (i :: Int)))) [1 .. 10000]+        mapM_ (\sym -> cattrsetInsert set sym (spToCPtr sp)) syms+        cattrsetFreeze set+        n <- cattrsetSize set+        -- Spot-check a lookup from the middle of the key range+        hit <- case drop 5000 syms of+          middleSym : _ -> cattrsetLookup set middleSym+          [] -> pure Nothing+        -- set freed by arenaDestroy via nn_attrset_free_all+        freeStablePtr sp+        pure+          ( if n == 10000 && isJust hit+              then Pass+              else Fail ("size=" <> T.pack (show n))+          )+    ]++-- ---------------------------------------------------------------------------+-- C thunk arena (FFI)+-- ---------------------------------------------------------------------------++testCThunk :: IO [Bool]+testCThunk = do+  putStrLn "cthunk"+  -- Arena is already initialized by main's bracket.+  -- Each test uses the shared arena (thunks accumulate - that's fine).+  sequence+    [ runTestM "new pending + state" $ do+        sp <- newStablePtr ("pending" :: Text)+        ptr <- cthunkNewBc 0 (castStablePtrToPtr sp)+        state <- cthunkState ptr+        pure (assertEqual "state" 0 state),+      runTestM "new computed + state" $ do+        sp <- newStablePtr ("computed" :: Text)+        ptr <- cthunkNewComputed (castStablePtrToPtr sp)+        state <- cthunkState ptr+        pure (assertEqual "state" 1 state),+      runTestM "payload round-trips (pending)" $ do+        sp <- newStablePtr ("hello" :: Text)+        ptr <- cthunkNewBc 0 (castStablePtrToPtr sp)+        payload <- cthunkPayload ptr+        val <- deRefStablePtr (castPtrToStablePtr payload) :: IO Text+        pure (assertEqual "payload" "hello" val),+      runTestM "payload round-trips (computed)" $ do+        sp <- newStablePtr (42 :: Int)+        ptr <- cthunkNewComputed (castStablePtrToPtr sp)+        payload <- cthunkPayload ptr+        val <- deRefStablePtr (castPtrToStablePtr payload) :: IO Int+        pure (assertEqual "payload" 42 val),+      runTestM "mark_blackhole succeeds on pending" $ do+        sp <- newStablePtr ("x" :: Text)+        ptr <- cthunkNewBc 0 (castStablePtrToPtr sp)+        ok <- cthunkMarkBlackhole ptr+        state <- cthunkState ptr+        pure+          ( if ok && state == 2+              then Pass+              else Fail ("ok=" <> T.pack (show ok) <> " state=" <> T.pack (show state))+          ),+      runTestM "mark_blackhole fails on computed" $ do+        sp <- newStablePtr ("x" :: Text)+        ptr <- cthunkNewComputed (castStablePtrToPtr sp)+        ok <- cthunkMarkBlackhole ptr+        pure (if not ok then Pass else Fail "should have failed"),+      runTestM "mark_blackhole fails on blackhole" $ do+        sp <- newStablePtr ("x" :: Text)+        ptr <- cthunkNewBc 0 (castStablePtrToPtr sp)+        _ <- cthunkMarkBlackhole ptr+        ok <- cthunkMarkBlackhole ptr+        pure (if not ok then Pass else Fail "should have failed"),+      runTestM "set_computed returns old payload" $ do+        pendingSp <- newStablePtr ("old" :: Text)+        ptr <- cthunkNewBc 0 (castStablePtrToPtr pendingSp)+        _ <- cthunkMarkBlackhole ptr+        computedSp <- newStablePtr ("new" :: Text)+        oldPayload <- cthunkSetComputed ptr (castStablePtrToPtr computedSp)+        oldVal <- deRefStablePtr (castPtrToStablePtr oldPayload) :: IO Text+        state <- cthunkState ptr+        newPayload <- cthunkPayload ptr+        newVal <- deRefStablePtr (castPtrToStablePtr newPayload) :: IO Text+        freeStablePtr pendingSp+        pure+          ( if oldVal == "old" && newVal == "new" && state == 1+              then Pass+              else+                Fail+                  ( "old="+                      <> oldVal+                      <> " new="+                      <> newVal+                      <> " state="+                      <> T.pack (show state)+                  )+          ),+      runTestM "set_computed on pending returns old payload" $ do+        sp <- newStablePtr ("x" :: Text)+        ptr <- cthunkNewBc 0 (castStablePtrToPtr sp)+        valSp <- newStablePtr ("v" :: Text)+        oldPayload <- cthunkSetComputed ptr (castStablePtrToPtr valSp)+        -- set_computed accepts PENDING (direct memoization, no blackhole step)+        oldVal <- deRefStablePtr (castPtrToStablePtr oldPayload) :: IO Text+        freeStablePtr sp+        pure (assertEqual "old payload" "x" oldVal),+      runTestM "count tracks allocations" $ do+        countBefore <- cthunkCount+        sp <- newStablePtr (0 :: Int)+        _ <- cthunkNewBc 0 (castStablePtrToPtr sp)+        _ <- cthunkNewBc 0 (castStablePtrToPtr sp)+        _ <- cthunkNewComputed (castStablePtrToPtr sp)+        countAfter <- cthunkCount+        let delta = countAfter - countBefore+        pure (assertEqual "delta" 3 delta),+      runTestM "get retrieves by index" $ do+        countBefore <- cthunkCount+        sp <- newStablePtr ("indexed" :: Text)+        ptr <- cthunkNewBc 0 (castStablePtrToPtr sp)+        retrieved <- cthunkGet countBefore+        stateOrig <- cthunkState ptr+        stateRetrieved <- cthunkState retrieved+        pure+          ( if ptr == retrieved && stateOrig == stateRetrieved+              then Pass+              else Fail "get returned wrong pointer"+          ),+      runTestM "stress: 100k thunks" $ do+        countBefore <- cthunkCount+        sp <- newStablePtr (0 :: Int)+        mapM_ (\_ -> cthunkNewBc 0 (castStablePtrToPtr sp)) [(1 :: Int) .. 100000]+        countAfter <- cthunkCount+        let delta = countAfter - countBefore+        -- Spot-check: retrieve one from the middle+        midPtr <- cthunkGet (countBefore + 50000)+        midState <- cthunkState midPtr+        pure+          ( if delta == 100000 && midState == 0+              then Pass+              else+                Fail+                  ( "delta="+                      <> T.pack (show delta)+                      <> " midState="+                      <> T.pack (show midState)+                  )+          )+    ]++-- ---------------------------------------------------------------------------+-- Bytecode compilation tests+-- ---------------------------------------------------------------------------++-- ---------------------------------------------------------------------------+-- Tests: upstream conformance follow-ups (#50)+-- ---------------------------------------------------------------------------++-- | Error kind for 'StubStoreEval', mirroring PureEval's split: only a+-- throw/assert is tryEval-catchable.+data StubErr = StubThrow !Text | StubOther !Text++-- | Pure evaluator over a stubbed store: 'readStoreDerivation' serves+-- derivations from a fixed map (keyed by canonical @.drv@ path text);+-- everything else behaves like 'PureEval'.  Exercises the+-- input-derivation-modulo STORE-READ arm hermetically - the real arm+-- reads the platform store, which dev machines and CI runners must not+-- depend on (the build matrix has no writable @/nix/store@).+newtype StubStoreEval a = StubStoreEval (StubEnv -> Either StubErr a)++-- | What the stubbed store answers from.+data StubEnv = StubEnv+  { -- | Derivations 'readStoreDerivation' serves, by canonical @.drv@ path.+    seDrvs :: !(Map.Map Text Derivation),+    -- | Recorded fetches 'lookupFetchCache' serves, by key.+    seFetchCache :: !(Map.Map Text Text),+    -- | Store paths 'adoptStorePath' accepts.  Deliberately NOT the same+    -- answer as 'doesPathExist', which is 'False' throughout: on disk and+    -- registered are different facts, and a test where the two coincide+    -- cannot tell which one the code under test asked about.+    seAdoptable :: !(Set.Set Text)+  }++-- | A stub store holding nothing but the given derivations.+stubEnv :: Map.Map Text Derivation -> StubEnv+stubEnv drvs = StubEnv {seDrvs = drvs, seFetchCache = Map.empty, seAdoptable = Set.empty}++runStubStoreEvalWith :: StubEnv -> StubStoreEval a -> Either Text a+runStubStoreEvalWith env (StubStoreEval action) = case action env of+  Left (StubThrow msg) -> Left msg+  Left (StubOther msg) -> Left msg+  Right val -> Right val++instance Functor StubStoreEval where+  fmap f (StubStoreEval g) = StubStoreEval (fmap f . g)++instance Applicative StubStoreEval where+  pure val = StubStoreEval (const (Right val))+  StubStoreEval mf <*> StubStoreEval ma = StubStoreEval $ \env -> mf env <*> ma env++instance Monad StubStoreEval where+  StubStoreEval ma >>= f = StubStoreEval $ \env -> case ma env of+    Left err -> Left err+    Right val -> let StubStoreEval mb = f val in mb env++instance MonadEval StubStoreEval where+  throwEvalError msg = StubStoreEval (const (Left (StubOther msg)))+  throwCatchableError msg = StubStoreEval (const (Left (StubThrow msg)))+  abortEvaluation msg = StubStoreEval (const (Left (StubOther ("evaluation aborted: " <> msg))))+  catchEvalError (StubStoreEval action) = StubStoreEval $ \env -> case action env of+    Left (StubThrow msg) -> Right (Left msg)+    Left other -> Left other+    Right val -> Right (Right val)+  onEvalError (StubStoreEval action) _ = StubStoreEval action+  doesPathExist _ = pure False+  listDirectory _ = throwEvalError "readDir: not available in the stub evaluator"+  importFile _ = throwEvalError "import: not available in the stub evaluator"+  getEnvVar _ = pure ""+  getCurrentTime = pure 0+  writeToStore _ _ _ = throwEvalError "toFile: not available in the stub evaluator"+  scopedImportFile _ _ = throwEvalError "scopedImport: not available in the stub evaluator"+  readFileBytes _ = throwEvalError "readFile: not available in the stub evaluator"+  getFileType _ = throwEvalError "readFileType: not available in the stub evaluator"+  runProcess _ _ _ = throwEvalError "runProcess: not available in the stub evaluator"+  createScratchDir _ = throwEvalError "createScratchDir: not available in the stub evaluator"+  removeScratchDir _ = pure ()+  copyPathToStore _ _ _ = throwEvalError "builtins.path: not available in the stub evaluator"+  narHashOfPath _ = throwEvalError "builtins.fetchGit: not available in the stub evaluator"+  setExecutableFile _ = throwEvalError "builtins.fetchGit: not available in the stub evaluator"+  lookupFetchCache key = StubStoreEval $ \env -> Right (Map.lookup key (seFetchCache env))+  adoptStorePath path = StubStoreEval $ \env -> Right (Set.member path (seAdoptable env))+  writeFetchCache _ _ = pure ()+  isExecutableFile _ = throwEvalError "builtins.path: not available in the stub evaluator"+  readSymlinkTarget _ = throwEvalError "builtins.path: not available in the stub evaluator"+  addSourceNar _ _ = throwEvalError "builtins.path: not available in the stub evaluator"+  addFixedOutputFile _ _ = throwEvalError "builtins.fetchurl: not available in the stub evaluator"+  traceMessage _ = pure ()+  lookupDrvHash _ = pure Nothing+  cacheDrvHash _ _ = pure ()+  recordDrvAterm _ _ = pure ()+  readStoreDerivation sp =+    StubStoreEval $ \env -> Right (Map.lookup (storePathToText defaultStoreDir sp) (seDrvs env))+  lookupSessionDrv _ = pure Nothing+  storeSourcePath = pure+  resolvePathLiteral = pure . canonPath+  forceThunk evalFn thunk@(Thunk ptr) = case readThunkValue thunk of+    Just val -> pure val+    Nothing -> case unsafePerformIO (cthunkState ptr) of+      2 {- BLACKHOLE -} -> abortEvaluation "infinite recursion encountered"+      _ {- PENDING -} ->+        let bcIdx = unsafePerformIO (cthunkGetBcIdx ptr)+            envSp = unsafePerformIO (cthunkPayload ptr)+            env = unsafePerformIO (deRefStablePtr (castPtrToStablePtr envSp))+         in evalFn env bcIdx++-- | Parse and evaluate under the stubbed-store evaluator.+evalNixStub :: Map.Map Text Derivation -> Text -> Either Text NixValue+evalNixStub = evalNixStubWith . stubEnv++-- | 'evalNixStub' with the whole stub store spelled out.+evalNixStubWith :: StubEnv -> Text -> Either Text NixValue+evalNixStubWith env source = case parseNix testBaseDir "<test>" source of+  Left err -> Left (T.pack (show err))+  Right expr -> runStubStoreEvalWith env (eval (builtinEnv 0 []) expr)++-- | Upstream conformance follow-ups (#50): behaviors that landed+-- with #37 but had no direct test.  Pure cases here; filesystem-touching+-- cases in 'testClassIFollowupsIO'.+testClassIFollowups :: IO [Bool]+testClassIFollowups = do+  putStrLn "eval/class-i-followups"+  let storeA = "/nix/store/" <> T.replicate 32 "a"+      enclosingCtx = StringContext (Set.singleton (SCPlain (StorePath (T.replicate 32 "a") "x")))+  sequence+    [ -- path + path concatenates the spellings and canonicalizes+      runTest "path + path canonicalizes the joined spelling" $+        assertEval "pp-canon" "builtins.toString (/a/b + /c/../d)" (mkStr "/a/b/d"),+      -- concatStringsSep coerces a path element; an in-store path takes+      -- the short-circuit (itself + enclosing context, no store copy)+      runTest "concatStringsSep coerces an in-store path element" $+        assertEval+          "csep-path"+          ("builtins.concatStringsSep \":\" [ " <> storeA <> "-x \"y\" ]")+          (VStr (TE.encodeUtf8 (storeA <> "-x:y")) enclosingCtx),+      -- the same in-store short-circuit for general interpolation, on a+      -- SUBPATH: the text is the subpath, the context the enclosing path+      runTest "interpolating an in-store subpath keeps text, contexts the enclosing path" $+        assertEval+          "interp-substore"+          ("\"${" <> storeA <> "-x/sub}\"")+          (VStr (TE.encodeUtf8 (storeA <> "-x/sub")) enclosingCtx),+      -- storePath on a subpath: value text is the subpath itself+      runTest "storePath on a subpath keeps text, contexts the enclosing path" $+        assertEval+          "storepath-sub"+          ("builtins.storePath " <> storeA <> "-x/sub/file")+          (VStr (TE.encodeUtf8 (storeA <> "-x/sub/file")) enclosingCtx),+      -- fromJSON integers in the (int64 max, uint64 max] band arrive+      -- two's-complement wrapped, as upstream's nlohmann handoff+      runTest "fromJSON wraps int64 max + 1" $+        assertEval "fromjson-wrap-min" "builtins.fromJSON \"9223372036854775808\"" (VInt minBound),+      runTest "fromJSON wraps uint64 max" $+        assertEval "fromjson-wrap-neg1" "builtins.fromJSON \"18446744073709551615\"" (VInt (-1)),+      runTest "fromJSON falls to float past uint64" $+        assertEval "fromjson-float" "builtins.fromJSON \"18446744073709551616\"" (VFloat 18446744073709551616.0),+      -- the input-derivation-modulo STORE-READ arm: a dependent whose+      -- input .drv was NOT evaluated in-session resolves by reading the+      -- (stubbed) store, and lands on the same drvPath as the in-session+      -- cache-hit arm computes for the identical derivation+      runTestM "input modulo recurses through the store read (stub store)" $ do+        let depSrc = "derivation { name = \"dep\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }"+        depDrv <- case evalNix ("(" <> depSrc <> ")._derivation") of+          Right (VDerivation d) -> pure d+          other -> fail ("dep._derivation: " <> show other)+        depPath <- case evalNix ("(" <> depSrc <> ").drvPath") of+          Right (VStr p _) -> pure (bytesText p)+          other -> fail ("dep.drvPath: " <> show other)+        let stubResult =+              evalNixStub (Map.singleton depPath depDrv) $+                T.concat+                  [ "(derivation { name = \"main\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; ",+                    "input = builtins.appendContext \"x\" { \"",+                    depPath,+                    "\" = { outputs = [\"out\"]; }; }; }).drvPath"+                  ]+        tmpBase <- getTemporaryDirectory+        ioResult <-+          evalNixIO tmpBase $+            T.concat+              [ "let dep = ",+                depSrc,+                "; in (derivation { name = \"main\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; ",+                "input = builtins.appendContext \"x\" { \"${dep.drvPath}\" = { outputs = [\"out\"]; }; }; }).drvPath"+              ]+        pure $ case (stubResult, ioResult) of+          (Right (VStr a _), Right (VStr b _))+            | a == b -> Pass+            | otherwise ->+                Fail ("store-read and in-session arms disagree: " <> bytesText a <> " vs " <> bytesText b)+          other -> Fail ("expected two drvPaths, got: " <> T.pack (show other))+    ]++-- | Filesystem-touching conformance follow-ups (#50).+testClassIFollowupsIO :: IO [Bool]+testClassIFollowupsIO = do+  putStrLn "eval/class-i-followups-io"+  tmpDir <- getTemporaryDirectory+  let testDir = tmpDir </> "nova-nix-classi-test"+      testDirFwd = T.replace "\\" "/" (T.pack testDir)+  bracket_+    ( do+        createDirectoryIfMissing True (testDir </> "sub")+        TIO.writeFile (testDir </> "target.txt") "hit\n"+        TIO.writeFile (testDir </> "thefile") "payload\n"+        -- Imported files for the two path-resolution tests below.  Each is+        -- a directory deeper than the expression that names it, so a+        -- literal resolved against the wrong one is visibly wrong.+        TIO.writeFile (testDir </> "sub" </> "useq.nix") "q\n"+        TIO.writeFile (testDir </> "sub" </> "tilde.nix") "~/nova-tilde-probe\n"+    )+    ( do+        exists <- doesDirectoryExist testDir+        when exists (removeDirectoryRecursive testDir)+    )+    $ sequence+      [ -- ~/ expands against the home directory at path resolution+        runTestM "tilde path literal expands against the home directory" $ do+          home <- Dir.getHomeDirectory+          result <- evalNixIO testDir "builtins.toString ~/nova-tilde-probe"+          let expected = canonPathValue (T.pack (home </> "nova-tilde-probe"))+          pure $ assertRight "tilde" result $ \val ->+            assertEqual "tilde-expanded" (mkStr expected) val,+        -- The same literal inside an imported file.  Path resolution runs+        -- over every parsed file now, and joining ~/x to the importing+        -- directory would bury the tilde where nothing expands it.+        runTestM "tilde path literal expands the same inside an imported file" $ do+          home <- Dir.getHomeDirectory+          result <- evalNixIO testDir "builtins.toString (import ./sub/tilde.nix)"+          let expected = canonPathValue (T.pack (home </> "nova-tilde-probe"))+          pure $ assertRight "tilde-import" result $ \val ->+            assertEqual "tilde-expanded-in-import" (mkStr expected) val,+        -- A path literal names a location relative to the file it is+        -- written in.  This one is written here and forced during the+        -- evaluation of sub/useq.nix, where the base directory is sub/:+        -- resolving on force rather than on parse gave sub/target.txt.+        runTestM "a path literal resolves where it is written, not where it is forced" $ do+          let expr = "builtins.toString (builtins.scopedImport { q = ./target.txt; } ./sub/useq.nix)"+          result <- evalNixIO testDir expr+          let expected = canonPathValue (T.pack (testDir </> "target.txt"))+          pure $ assertRight "scoped-import-path" result $ \val ->+            assertEqual "resolved-against-writing-file" (mkStr expected) val,+        -- a search-path match is returned CANONICALIZED+        runTestM "findFile canonicalizes the matched candidate" $ do+          result <-+            evalNixIO testDir $+              T.concat+                ["builtins.findFile [ { prefix = \"\"; path = \"", testDirFwd, "/sub/..\"; } ] \"target.txt\""]+          pure $ assertRight "findfile-canon" result $ \val ->+            assertEqual "canonical" (VPath (testDirFwd <> "/target.txt")) val,+        -- the store copy is named by canonBaseName (the path's last segment)+        runTestM "source copy is named by canonBaseName" $ do+          result <- evalNixIO testDir "\"${./thefile}\""+          pure $ assertRight "canonbase" result $ \case+            VStr s ctx ->+              if "/nix/store/" `BS.isPrefixOf` s && "-thefile" `BS.isSuffixOf` s && ctx /= emptyContext+                then Pass+                else Fail ("expected a store path named -thefile with context, got " <> bytesText s)+            other -> Fail ("expected VStr, got " <> T.pack (show other)),+        -- canonBaseName's empty-name arm: the filesystem root has no+        -- base name to copy under (toPath keeps the probe off the+        -- platform filesystem - the error fires before any read)+        runTestM "coercing the filesystem root errors (no base name)" $ do+          result <- evalNixIO testDir "\"${builtins.toPath \"/\"}\""+          pure $ case result of+            Left err | "no base name" `T.isInfixOf` err -> Pass+            Left err -> Fail ("expected a base-name error, got: " <> err)+            Right val -> Fail ("expected failure, got " <> T.pack (show val))+      ]++-- | Bytecode short_arg spill: op-level payload counts at or above the+-- 0xFFFF sentinel move to the first data word, so literals are no longer+-- capped at 65535 elements.  The two exact-boundary list cases pin the+-- inline maximum (65534) and the first spilled count (65535); the rest+-- drive each counted-op kind (list, attrs, let, string parts, attr path)+-- well past the old ceiling.+testBytecodeCountSpill :: IO [Bool]+testBytecodeCountSpill = do+  putStrLn "bytecode/count-spill"+  let listOf n = "builtins.length [ " <> T.unwords (replicate n "1") <> " ]"+      bigAttrsBody = T.concat [T.pack ("a" <> show i <> " = " <> show i <> "; ") | i <- [0 :: Int .. 69999]]+  sequence+    [ runTest "list literal at the inline count maximum (65534)" $+        assertEval "spill-list-inline-max" (listOf 65534) (VInt 65534),+      runTest "list literal at the first spilled count (65535)" $+        assertEval "spill-list-first-spill" (listOf 65535) (VInt 65535),+      runTest "spilled list preserves element order" $+        assertEval+          "spill-list-order"+          ("builtins.elemAt [ " <> T.unwords (map (T.pack . show) [0 :: Int .. 69999]) <> " ] 69999")+          (VInt 69999),+      runTest "spilled attrset literal binds every attribute" $+        assertEval+          "spill-attrs-count"+          ("builtins.length (builtins.attrNames { " <> bigAttrsBody <> "})")+          (VInt 70000),+      runTest "spilled attrset lookup reads the right value" $+        assertEval+          "spill-attrs-lookup"+          ("{ " <> bigAttrsBody <> "}.a69999")+          (VInt 69999),+      runTest "spilled let binds every name" $+        assertEval+          "spill-let"+          ("let " <> T.concat [T.pack ("v" <> show i <> " = " <> show i <> "; ") | i <- [0 :: Int .. 69999]] <> "in v69999")+          (VInt 69999),+      runTest "spilled interpolated string keeps every part" $+        assertEval+          "spill-string-parts"+          ("builtins.stringLength \"" <> T.concat (replicate 70000 "${\"x\"}") <> "\"")+          (VInt 70000),+      runTest "spilled attr path walks (set ? long.path)" $+        assertEval+          "spill-attrpath"+          ("{ } ? " <> T.intercalate "." (map (\i -> T.pack ("p" <> show i)) [0 :: Int .. 69999]))+          (VBool False)+    ]++-- | C value structs carry element counts as a full uint32; a narrower+-- count would wrap at 65536 and size the C array smaller than the fill+-- loop that follows it.  Both cases marshal counts past that boundary+-- and force the value twice - the first force writes the C struct (the+-- marshal side), the second reads it back off the computed thunk cell+-- (the unmarshal side).+testValueCountWidths :: IO [Bool]+testValueCountWidths = do+  putStrLn "value/count-widths"+  let formalsBody = T.intercalate ", " [T.pack ("a" <> show i) | i <- [0 :: Int .. 69999]]+  sequence+    [ runTest "lambda with 70000 formals round-trips through the C closure" $+        assertEval+          "width-lambda-formals"+          ( "let f = { "+              <> formalsBody+              <> " }: 1; in builtins.seq (builtins.functionArgs f) (builtins.length (builtins.attrNames (builtins.functionArgs f)))"+          )+          (VInt 70000),+      runTest "string context with 70000 elements round-trips through the C thunk" $+        assertEval+          "width-ctxstr-elems"+          ( "let s = builtins.appendContext \"x\" (builtins.listToAttrs (builtins.genList (i: { "+              <> "name = \"/nix/store/00000000000000000000000000000000-p\" + builtins.toString i; "+              <> "value = { path = true; }; }) 70000)); "+              <> "in builtins.seq s (builtins.length (builtins.attrNames (builtins.getContext s)))"+          )+          (VInt 70000)+    ]++-- ---------------------------------------------------------------------------+-- Tests: store-path name validation at write sinks (#39)+-- ---------------------------------------------------------------------------++-- | Name validation at write sinks (#39): the store-path name rules hold+-- at path CONSTRUCTION, not only at parse.  Derivation names, output names, and+-- fetchurl basenames reject exactly what the parse boundary rejects,+-- before any write path is built from them - and names the old ad hoc+-- sink checks over-rejected (an interior @..@) are valid again.+testStoreNameSinks :: IO [Bool]+testStoreNameSinks = do+  putStrLn "store/name-sinks"+  let drvWith nameLit =+        "(derivation { name = " <> nameLit <> "; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }).drvPath"+      drvOutPath nameLit =+        "(derivation { name = " <> nameLit <> "; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }).outPath"+      drvWithOutputs outsLit =+        "(derivation { name = \"p\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; outputs = " <> outsLit <> "; }).drvPath"+      failsWith label source needle = case evalNix source of+        Left err+          | parseErrorTag `T.isPrefixOf` err -> Fail (label <> ": did not parse: " <> err)+          | needle `T.isInfixOf` err -> Pass+          | otherwise -> Fail (label <> ": wrong error: " <> err)+        Right val -> Fail (label <> ": expected an eval error, got " <> T.pack (show val))+      succeedsWithSuffix label source suffix = case evalNix source of+        Left err -> Fail (label <> ": unexpected error: " <> err)+        Right (VStr s _) ->+          if TE.encodeUtf8 suffix `BS.isSuffixOf` s+            then Pass+            else Fail (label <> ": expected suffix " <> suffix <> ", got " <> bytesText s)+        Right other -> Fail (label <> ": expected VStr, got " <> T.pack (show other))+  sequence+    [ runTest "derivation name with a space is rejected" $+        failsWith "name-space" (drvWith "\"a b\"") "invalid derivation name",+      runTest "derivation name with a slash is rejected" $+        failsWith "name-slash" (drvWith "\"a/b\"") "invalid derivation name",+      runTest "derivation name with a backslash is rejected" $+        failsWith "name-backslash" (drvWith "\"a\\\\b\"") "invalid derivation name",+      runTest "derivation name with a drive colon is rejected" $+        failsWith "name-colon" (drvWith "\"C:x\"") "invalid derivation name",+      runTest "empty derivation name is rejected" $+        failsWith "name-empty" (drvWith "\"\"") "the name is empty",+      runTest "212-character derivation name is rejected" $+        failsWith "name-long" (drvWith ("\"" <> T.replicate 212 "a" <> "\"")) "above the 211 maximum",+      runTest "derivation name '..' is rejected" $+        failsWith "name-dotdot" (drvWith "\"..\"") "dash-separated component",+      runTest "derivation name '.-x' is rejected" $+        failsWith "name-dotdash" (drvWith "\".-x\"") "dash-separated component",+      runTest "dotfile derivation name stays valid" $+        succeedsWithSuffix "name-dotfile" (drvWith "\".foo-1.0\"") "-.foo-1.0.drv",+      runTest "interior '..' in a derivation name stays valid" $+        succeedsWithSuffix "name-interior-dots" (drvOutPath "\"x..y\"") "-x..y",+      runTest "derivation output name with a traversal is rejected" $+        failsWith "out-traversal" (drvWithOutputs "[ \"out\" \"../x\" ]") "invalid derivation output name",+      runTest "derivation output name with a colon is rejected" $+        failsWith "out-colon" (drvWithOutputs "[ \"a:b\" ]") "invalid derivation output name",+      -- Both fields clean on their own, only the COMPOSED drvName-output+      -- crosses the length cap: the construction-side check catches what+      -- the field-level checks cannot.+      runTest "composed name-output past the length cap is rejected" $+        failsWith+          "composed-long"+          ( "(derivation { name = \""+              <> T.replicate 208 "a"+              <> "\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; outputs = [ \"dev\" ]; }).drvPath"+          )+          "invalid store path name",+      -- fetchurl derives the store name from the URL alone, so a bad+      -- basename fails BEFORE the download: under PureEval a reachable+      -- download would surface as "runProcess: not available" instead.+      runTest "fetchurl rejects a backslash basename before fetching" $+        failsWith "fetchurl-backslash" "builtins.fetchurl \"http://e/a\\\\b\"" "invalid store path name",+      runTest "fetchurl rejects a dot-segment basename before fetching" $+        failsWith "fetchurl-dotdot" "builtins.fetchurl \"http://e/..\"" "invalid store path name"+    ]++-- | The NAR entry-name check: rejects what escapes the tree and what the+-- Win32 path layer silently rewrites (stream colons, device stems,+-- trailing dot or space); ordinary names pass, including ones Windows+-- merely refuses loudly.+testNarNameSafety :: IO [Bool]+testNarNameSafety = do+  putStrLn "store/nar-name-safety"+  let rejects name = if isSafeNarName name then Fail ("expected rejection: " <> T.pack (show name)) else Pass+      accepts name = if isSafeNarName name then Pass else Fail ("expected acceptance: " <> T.pack (show name))+      allOf = foldr keepFirstFail Pass+      keepFirstFail Pass acc = acc+      keepFirstFail failure _ = failure+  sequence+    [ runTest "empty and dot names are rejected" $+        allOf [rejects "", rejects ".", rejects ".."],+      runTest "separators are rejected" $+        allOf [rejects "a/b", rejects "a\\b"],+      runTest "an embedded NUL is rejected" $+        rejects "a\NULb",+      runTest "colons are rejected (drive prefix and stream form)" $+        allOf [rejects "C:evil", rejects "a:b", rejects ":"],+      runTest "reserved device stems are rejected case-insensitively" $+        allOf [rejects "NUL", rejects "nul.txt", rejects "CON", rejects "com3", rejects "COM0.log", rejects "lpt9"],+      runTest "a space-padded device stem is rejected" $+        rejects "Nul .txt",+      runTest "a superscript-digit device stem is rejected" $+        rejects "COM\185",+      runTest "a trailing dot or space is rejected" $+        allOf [rejects "x.", rejects "x "],+      runTest "ordinary names pass" $+        allOf [accepts "a", accepts ".hidden", accepts "a.b", accepts "a b", accepts " a"],+      runTest "near-miss device names pass" $+        allOf [accepts "com10", accepts "COM", accepts "NULx", accepts "xNUL.txt"],+      runTest "a loud-refuse character stays allowed" $+        accepts "a\"b"+    ]++-- | IO-evaluator write sinks reject an invalid name BEFORE any write:+-- every case here errors out against a scratch dir with no store traffic.+testStoreNameSinksIO :: IO [Bool]+testStoreNameSinksIO = do+  putStrLn "store/name-sinks-io"+  tmpBase <- getTemporaryDirectory+  let testDir = tmpBase </> "nova-nix-test-name-sinks"+      needleTest label source needle = do+        result <- evalNixIO testDir source+        runTest label $ case result of+          Left err+            | needle `T.isInfixOf` err -> Pass+            | otherwise -> Fail (label <> ": wrong error: " <> err)+          Right val -> Fail (label <> ": expected an eval error, got " <> T.pack (show val))+  bracket_+    ( do+        createDirectoryIfMissing True (testDir </> "tree")+        TIO.writeFile (testDir </> "tree" </> "f.txt") "payload\n"+    )+    ( do+        exists <- doesDirectoryExist testDir+        when exists (removeDirectoryRecursive testDir)+    )+    $ sequence+      [ needleTest "toFile rejects a stream-colon name" "builtins.toFile \"a:b\" \"x\"" "invalid store path name",+        needleTest "toFile rejects a dot-segment name" "builtins.toFile \"..\" \"x\"" "invalid store path name",+        needleTest+          "path rejects a traversal name override"+          "builtins.path { path = ./tree; name = \"../../evil\"; }"+          "invalid store path name",+        needleTest+          "path with a filter rejects a drive-prefixed name"+          "builtins.path { path = ./tree; name = \"C:evil\"; filter = (_: _: true); }"+          "invalid store path name",+        needleTest+          "source coercion rejects a basename outside the name rules"+          "\"${./. + \"/sp ace\"}\""+          "invalid store path name"+      ]++testBytecodeCompile :: IO [Bool]+testBytecodeCompile = do+  putStrLn "bytecode"+  -- Arena (including bytecode store) already initialized by main's bracket.+  sequence+    [ runTestM "compile ELit NixInt" $ do+        idx <- compileExpr (ELit (NixInt 42))+        op <- cbcOpcode idx+        a1 <- cbcArg1 idx+        a2 <- cbcArg2 idx+        pure+          ( if op == OpLitInt && a1 == 42 && a2 == 0+              then Pass+              else Fail ("op=" <> T.pack (show op) <> " a1=" <> T.pack (show a1))+          ),+      runTestM "compile ELit NixInt negative" $ do+        idx <- compileExpr (ELit (NixInt (-1)))+        op <- cbcOpcode idx+        a1 <- cbcArg1 idx+        a2 <- cbcArg2 idx+        -- -1 as uint64 = 0xFFFFFFFFFFFFFFFF, lo=0xFFFFFFFF, hi=0xFFFFFFFF+        pure+          ( if op == OpLitInt && a1 == 0xFFFFFFFF && a2 == 0xFFFFFFFF+              then Pass+              else+                Fail+                  ( "a1="+                      <> T.pack (show a1)+                      <> " a2="+                      <> T.pack (show a2)+                  )+          ),+      runTestM "compile ELit NixBool" $ do+        idxT <- compileExpr (ELit (NixBool True))+        idxF <- compileExpr (ELit (NixBool False))+        opT <- cbcOpcode idxT+        opF <- cbcOpcode idxF+        saT <- cbcShortArg idxT+        saF <- cbcShortArg idxF+        pure+          ( if opT == OpLitBool && saT == 1 && opF == OpLitBool && saF == 0+              then Pass+              else Fail "bool encoding mismatch"+          ),+      runTestM "compile ELit NixNull" $ do+        idx <- compileExpr (ELit NixNull)+        op <- cbcOpcode idx+        pure (assertEqual "opcode" OpLitNull op),+      runTestM "compile EResolvedVar" $ do+        idx <- compileExpr (EResolvedVar 3 7)+        op <- cbcOpcode idx+        a1 <- cbcArg1 idx+        a2 <- cbcArg2 idx+        pure+          ( if op == OpResolvedVar && a1 == 3 && a2 == 7+              then Pass+              else+                Fail+                  ( "op="+                      <> T.pack (show op)+                      <> " a1="+                      <> T.pack (show a1)+                      <> " a2="+                      <> T.pack (show a2)+                  )+          ),+      runTestM "compile EVar" $ do+        idx <- compileExpr (EVar "hello")+        op <- cbcOpcode idx+        sym <- cbcArg1 idx+        let symText = symbolText (Symbol sym)+        pure+          ( if op == OpVar && symText == "hello"+              then Pass+              else Fail ("op=" <> T.pack (show op) <> " sym=" <> symText)+          ),+      runTestM "compile EApp" $ do+        idx <- compileExpr (EApp (EVar "f") (ELit (NixInt 1)))+        op <- cbcOpcode idx+        funcIdx <- cbcArg1 idx+        argIdx <- cbcArg2 idx+        funcOp <- cbcOpcode funcIdx+        argOp <- cbcOpcode argIdx+        pure+          ( if op == OpApp && funcOp == OpVar && argOp == OpLitInt+              then Pass+              else+                Fail+                  ( "op="+                      <> T.pack (show op)+                      <> " funcOp="+                      <> T.pack (show funcOp)+                      <> " argOp="+                      <> T.pack (show argOp)+                  )+          ),+      runTestM "compile EIf" $ do+        idx <-+          compileExpr+            (EIf (ELit (NixBool True)) (ELit (NixInt 1)) (ELit (NixInt 2)))+        op <- cbcOpcode idx+        condIdx <- cbcArg1 idx+        thenIdx <- cbcArg2 idx+        elseIdx <- cbcArg3 idx+        condOp <- cbcOpcode condIdx+        thenA1 <- cbcArg1 thenIdx+        elseA1 <- cbcArg1 elseIdx+        pure+          ( if op == OpIf && condOp == OpLitBool && thenA1 == 1 && elseA1 == 2+              then Pass+              else Fail "if structure mismatch"+          ),+      runTestM "compile EBinary" $ do+        idx <-+          compileExpr+            (EBinary OpAdd (ELit (NixInt 10)) (ELit (NixInt 20)))+        op <- cbcOpcode idx+        fl <- cbcFlags idx+        leftIdx <- cbcArg1 idx+        rightIdx <- cbcArg2 idx+        leftA1 <- cbcArg1 leftIdx+        rightA1 <- cbcArg1 rightIdx+        pure+          ( if op == OpBinary && fl == binaryAdd && leftA1 == 10 && rightA1 == 20+              then Pass+              else Fail "binary structure mismatch"+          ),+      runTestM "compile EUnary" $ do+        idx <- compileExpr (EUnary OpNegate (ELit (NixInt 5)))+        op <- cbcOpcode idx+        fl <- cbcFlags idx+        operandIdx <- cbcArg1 idx+        operandA1 <- cbcArg1 operandIdx+        pure+          ( if op == OpUnary && fl == unaryNegate && operandA1 == 5+              then Pass+              else Fail "unary structure mismatch"+          ),+      runTestM "compile EList" $ do+        idx <-+          compileExpr+            (EList [ELit (NixInt 1), ELit (NixInt 2), ELit (NixInt 3)])+        op <- cbcOpcode idx+        count <- cbcShortArg idx+        dataOff <- cbcArg1 idx+        c0 <- cbcData dataOff+        c1 <- cbcData (dataOff + 1)+        c2 <- cbcData (dataOff + 2)+        c0a1 <- cbcArg1 c0+        c1a1 <- cbcArg1 c1+        c2a1 <- cbcArg1 c2+        pure+          ( if op == OpList && count == 3 && c0a1 == 1 && c1a1 == 2 && c2a1 == 3+              then Pass+              else+                Fail+                  ( "op="+                      <> T.pack (show op)+                      <> " count="+                      <> T.pack (show count)+                  )+          ),+      runTestM "compile EStr with interpolation" $ do+        idx <-+          compileExpr+            (EStr [StrLit "hello ", StrInterp (EVar "name"), StrLit "!"])+        op <- cbcOpcode idx+        count <- cbcShortArg idx+        dataOff <- cbcArg1 idx+        -- 3 parts x 2 words each = 6 data words+        tag0 <- cbcData dataOff+        _val0 <- cbcData (dataOff + 1)+        tag1 <- cbcData (dataOff + 2)+        val1 <- cbcData (dataOff + 3)+        tag2 <- cbcData (dataOff + 4)+        -- tag0=0(lit), tag1=1(interp), tag2=0(lit)+        interpOp <- cbcOpcode val1+        pure+          ( if op == OpStr+              && count == 3+              && tag0 == strpartLit+              && tag1 == strpartInterp+              && tag2 == strpartLit+              && interpOp == OpVar+              then Pass+              else+                Fail+                  ( "op="+                      <> T.pack (show op)+                      <> " count="+                      <> T.pack (show count)+                      <> " tag0="+                      <> T.pack (show tag0)+                      <> " tag1="+                      <> T.pack (show tag1)+                  )+          ),+      runTestM "compile EWith" $ do+        idx <- compileExpr (EWith (EVar "lib") (EVar "x"))+        op <- cbcOpcode idx+        scopeIdx <- cbcArg1 idx+        bodyIdx <- cbcArg2 idx+        scopeOp <- cbcOpcode scopeIdx+        bodyOp <- cbcOpcode bodyIdx+        pure+          ( if op == OpWith && scopeOp == OpVar && bodyOp == OpVar+              then Pass+              else Fail "with structure mismatch"+          ),+      runTestM "compile EAssert" $ do+        idx <- compileExpr (EAssert (ELit (NixBool True)) (ELit (NixInt 1)))+        op <- cbcOpcode idx+        condIdx <- cbcArg1 idx+        bodyIdx <- cbcArg2 idx+        condOp <- cbcOpcode condIdx+        bodyOp <- cbcOpcode bodyIdx+        pure+          ( if op == OpAssert && condOp == OpLitBool && bodyOp == OpLitInt+              then Pass+              else Fail "assert structure mismatch"+          ),+      runTestM "compile ELambda (FormalName)" $ do+        idx <-+          compileExpr+            (ELambda (FormalName "x") (EResolvedVar 0 0) NoCaptureInfo)+        op <- cbcOpcode idx+        fl <- cbcFlags idx+        bodyIdx <- cbcArg2 idx+        bodyOp <- cbcOpcode bodyIdx+        pure+          ( if op == OpLambda && fl == formalName && bodyOp == OpResolvedVar+              then Pass+              else+                Fail+                  ( "op="+                      <> T.pack (show op)+                      <> " flags="+                      <> T.pack (show fl)+                  )+          ),+      runTestM "compile ELet" $ do+        idx <-+          compileExpr+            ( ELet+                [NamedBinding [StaticKey "x"] (ELit (NixInt 42))]+                (EResolvedVar 0 0)+                NoCaptureInfo+            )+        op <- cbcOpcode idx+        count <- cbcShortArg idx+        bodyIdx <- cbcArg2 idx+        bodyOp <- cbcOpcode bodyIdx+        pure+          ( if op == OpLet && count == 1 && bodyOp == OpResolvedVar+              then Pass+              else+                Fail+                  ( "op="+                      <> T.pack (show op)+                      <> " count="+                      <> T.pack (show count)+                  )+          ),+      runTestM "compile EAttrs" $ do+        idx <-+          compileExpr+            ( EAttrs+                False+                [NamedBinding [StaticKey "a"] (ELit (NixInt 1))]+                NoCaptureInfo+            )+        op <- cbcOpcode idx+        fl <- cbcFlags idx+        count <- cbcShortArg idx+        pure+          ( if op == OpAttrs && fl == 0 && count == 1+              then Pass+              else Fail "attrs structure mismatch"+          ),+      runTestM "compile EAttrs recursive" $ do+        idx <-+          compileExpr+            ( EAttrs+                True+                [ NamedBinding [StaticKey "x"] (ELit (NixInt 1)),+                  NamedBinding [StaticKey "y"] (EResolvedVar 0 0)+                ]+                (Captures [(0, 0)])+            )+        op <- cbcOpcode idx+        fl <- cbcFlags idx+        count <- cbcShortArg idx+        pure+          ( if op == OpAttrs && fl == 1 && count == 2+              then Pass+              else+                Fail+                  ( "flags="+                      <> T.pack (show fl)+                      <> " count="+                      <> T.pack (show count)+                  )+          ),+      runTestM "compile ESelect" $ do+        idx <-+          compileExpr+            (ESelect (EVar "x") [StaticKey "a"] Nothing)+        op <- cbcOpcode idx+        fl <- cbcFlags idx+        pure+          ( if op == OpSelect && fl == 0+              then Pass+              else Fail ("flags=" <> T.pack (show fl))+          ),+      runTestM "compile ESelect with default" $ do+        idx <-+          compileExpr+            (ESelect (EVar "x") [StaticKey "a"] (Just (ELit NixNull)))+        op <- cbcOpcode idx+        fl <- cbcFlags idx+        defIdx <- cbcArg3 idx+        defOp <- cbcOpcode defIdx+        pure+          ( if op == OpSelect && fl == 1 && defOp == OpLitNull+              then Pass+              else Fail ("flags=" <> T.pack (show fl))+          ),+      runTestM "compile EHasAttr" $ do+        idx <-+          compileExpr+            (EHasAttr (EVar "x") [StaticKey "a", StaticKey "b"])+        op <- cbcOpcode idx+        pathLen <- cbcShortArg idx+        pure+          ( if op == OpHasAttr && pathLen == 2+              then Pass+              else+                Fail+                  ( "op="+                      <> T.pack (show op)+                      <> " pathLen="+                      <> T.pack (show pathLen)+                  )+          ),+      -- ESearchPath is desugared by resolver to __findFile __nixPath "name",+      -- so the compiler sees an EApp chain, not a raw ESearchPath.+      runTestM "compile desugared search path" $ do+        idx <- compileExpr (EApp (EApp (EVar "__findFile") (EVar "__nixPath")) (EStr [StrLit "nixpkgs"]))+        op <- cbcOpcode idx+        pure+          ( if op == OpApp+              then Pass+              else Fail ("expected OpApp, got op=" <> T.pack (show op))+          ),+      runTestM "op_count grows after compilation" $ do+        before <- cbcOpCount+        _ <- compileExpr (EApp (EVar "f") (ELit (NixInt 1)))+        after <- cbcOpCount+        pure+          ( if after > before+              then Pass+              else+                Fail+                  ( "before="+                      <> T.pack (show before)+                      <> " after="+                      <> T.pack (show after)+                  )+          ),+      runTestM "compile ELit NixFloat" $ do+        idx <- compileExpr (ELit (NixFloat 3.14))+        op <- cbcOpcode idx+        pure (assertEqual "opcode" OpLitFloat op),+      runTestM "compile ELit NixUri" $ do+        idx <- compileExpr (ELit (NixUri "https://example.com"))+        op <- cbcOpcode idx+        sym <- cbcArg1 idx+        pure+          ( if op == OpLitUri && symbolText (Symbol sym) == "https://example.com"+              then Pass+              else Fail "uri mismatch"+          ),+      runTestM "compile ELit NixPath" $ do+        idx <- compileExpr (ELit (NixPath "/nix/store/foo"))+        op <- cbcOpcode idx+        sym <- cbcArg1 idx+        pure+          ( if op == OpLitPath && symbolText (Symbol sym) == "/nix/store/foo"+              then Pass+              else Fail "path mismatch"+          ),+      runTestM "compile Inherit binding" $ do+        idx <-+          compileExpr+            ( EAttrs+                False+                [Inherit Nothing ["x", "y"]]+                NoCaptureInfo+            )+        op <- cbcOpcode idx+        count <- cbcShortArg idx+        pure+          ( if op == OpAttrs && count == 1+              then Pass+              else Fail "inherit binding mismatch"+          ),+      runTestM "compile ELambda (FormalSet)" $ do+        idx <-+          compileExpr+            ( ELambda+                (FormalSet [Formal "a" Nothing, Formal "b" (Just (ELit (NixInt 0)))] False)+                (EResolvedVar 0 0)+                NoCaptureInfo+            )+        op <- cbcOpcode idx+        fl <- cbcFlags idx+        pure+          ( if op == OpLambda && fl == formalSet+              then Pass+              else Fail ("flags=" <> T.pack (show fl))+          ),+      runTestM "compile ELambda (FormalNamedSet)" $ do+        idx <-+          compileExpr+            ( ELambda+                (FormalNamedSet "args" [Formal "x" Nothing] True)+                (EResolvedVar 0 0)+                NoCaptureInfo+            )+        op <- cbcOpcode idx+        fl <- cbcFlags idx+        pure+          ( if op == OpLambda && fl == formalNamedSet+              then Pass+              else Fail ("flags=" <> T.pack (show fl))+          ),+      runTestM "compile CaptureInfo (Captures)" $ do+        idx <-+          compileExpr+            ( ELambda+                (FormalName "x")+                (EResolvedVar 0 0)+                (Captures [(1, 2), (3, 4)])+            )+        op <- cbcOpcode idx+        capOff <- cbcArg3 idx+        capTag <- cbcData capOff+        capCount <- cbcData (capOff + 1)+        capL0 <- cbcData (capOff + 2)+        capI0 <- cbcData (capOff + 3)+        capL1 <- cbcData (capOff + 4)+        capI1 <- cbcData (capOff + 5)+        pure+          ( if op == OpLambda+              && capTag == captureSlots+              && capCount == 2+              && capL0 == 1+              && capI0 == 2+              && capL1 == 3+              && capI1 == 4+              then Pass+              else+                Fail+                  ( "capTag="+                      <> T.pack (show capTag)+                      <> " capCount="+                      <> T.pack (show capCount)+                  )+          ),+      runTestM "compile CaptureInfo (CapturesWithScopes)" $ do+        idx <-+          compileExpr+            ( ELambda+                (FormalName "x")+                (EResolvedVar 0 0)+                (CapturesWithScopes [(0, 0)])+            )+        capOff <- cbcArg3 idx+        capTag <- cbcData capOff+        pure (assertEqual "captureTag" captureWithScopes capTag),+      runTestM "compile EWithVar" $ do+        idx <- compileExpr (EWithVar "dynamic")+        op <- cbcOpcode idx+        sym <- cbcArg1 idx+        pure+          ( if op == OpWithVar && symbolText (Symbol sym) == "dynamic"+              then Pass+              else Fail "withvar mismatch"+          ),+      runTestM "compile EIndStr" $ do+        idx <- compileExpr (EIndStr [StrLit "indented"])+        op <- cbcOpcode idx+        count <- cbcShortArg idx+        pure+          ( if op == OpIndStr && count == 1+              then Pass+              else Fail "indstr mismatch"+          )+    ]++-- ---------------------------------------------------------------------------+-- Tests: substituter signature verification (the --trusted-key trust gate)+-- ---------------------------------------------------------------------------++-- | Minimal narinfo for signing tests; the fingerprint covers StorePath,+-- NarHash, NarSize, and References.+sigTestNarInfo :: NarInfo.NarInfo+sigTestNarInfo =+  NarInfo.NarInfo+    { NarInfo.niStorePath = "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-1.0",+      NarInfo.niUrl = "nar/aaaa.nar",+      NarInfo.niCompression = "none",+      NarInfo.niFileHash = Just ("sha256:" <> T.replicate 52 "0"),+      NarInfo.niFileSize = Just 1,+      NarInfo.niNarHash = "sha256:" <> T.replicate 52 "0",+      NarInfo.niNarSize = 1,+      NarInfo.niReferences = [],+      NarInfo.niDeriver = Nothing,+      NarInfo.niSigs = [],+      NarInfo.niCA = Nothing+    }++-- | A substituter cache trusting the given public key text.+sigTestCache :: Text -> Subst.CacheConfig+sigTestCache publicKeyText =+  Subst.CacheConfig+    { Subst.ccUrl = "http://localhost/test-cache",+      Subst.ccPublicKeys = [publicKeyText],+      Subst.ccPriority = 40+    }++-- | Deterministic Ed25519 test keys: a nix secret key file is+-- seed(32) + public(32), and both signing and public-key derivation use+-- only the seed, so the public half can be zeros here.  Both keys carry+-- the SAME name so the wrong-key test exercises the cryptographic+-- verification, not just the name comparison.+sigTestKeyText, sigTestWrongKeyText :: Text+sigTestKeyText = "test-key-1:" <> B64.encode (BS.pack ([1 .. 32] ++ replicate 32 0))+sigTestWrongKeyText = "test-key-1:" <> B64.encode (BS.pack ([101 .. 132] ++ replicate 32 0))++-- | Derive (trusted cache, signature by the trusted key, signature by an+-- impostor key with the same name).  Left on any setup failure.+sigTestSetup :: Either String (Subst.CacheConfig, Text, Text)+sigTestSetup = do+  secretKey <- Signing.parseSecretKey sigTestKeyText+  impostorKey <- Signing.parseSecretKey sigTestWrongKeyText+  publicKey <- Signing.toPublicKey secretKey+  let publicKeyText = Signing.renderPublicKey publicKey+  validSig <- Signing.sign secretKey sigTestNarInfo+  impostorSig <- Signing.sign impostorKey sigTestNarInfo+  pure (sigTestCache publicKeyText, validSig, impostorSig)++-- | verifySigs is the security boundary behind @--trusted-key@: unsigned+-- narinfos, wrong-key signatures, and malformed trusted keys must all be+-- rejected; a valid signature must be accepted.+testVerifySigs :: IO [Bool]+testVerifySigs = do+  putStrLn "substituter/verify-sigs"+  case sigTestSetup of+    Left err -> (: []) <$> runTest "verifySigs test setup" (Fail (T.pack err))+    Right (cache, validSig, impostorSig) ->+      sequence+        [ runTest "valid signature accepted" $+            assertEqual "verify ok" (Right ()) (Subst.verifySigs cache sigTestNarInfo {NarInfo.niSigs = [validSig]}),+          runTest "unsigned narinfo rejected" $+            assertLeft "no sigs" (Subst.verifySigs cache sigTestNarInfo),+          runTest "same-name signature from a different key rejected" $+            assertLeft "impostor sig" (Subst.verifySigs cache sigTestNarInfo {NarInfo.niSigs = [impostorSig]}),+          runTest "valid signature among invalid ones accepted" $+            assertEqual "any valid" (Right ()) (Subst.verifySigs cache sigTestNarInfo {NarInfo.niSigs = [impostorSig, validSig]}),+          runTest "signature under a different key name rejected" $+            assertLeft "name mismatch" (Subst.verifySigs cache sigTestNarInfo {NarInfo.niSigs = ["other-key:" <> T.drop (T.length "test-key-1:") validSig]}),+          runTest "malformed trusted public key rejected" $+            assertLeft "bad pubkey" (Subst.verifySigs (sigTestCache "not-a-key") sigTestNarInfo {NarInfo.niSigs = [validSig]}),+          -- The flat trusted-key set: a signature valid under ANY of+          -- several trusted keys is accepted, even when the matching key+          -- is not first.+          runTest "a signature valid under any of several trusted keys is accepted" $+            let multiKey = cache {Subst.ccPublicKeys = Subst.ccPublicKeys Subst.defaultCacheConfig ++ Subst.ccPublicKeys cache}+             in assertEqual "any-of" (Right ()) (Subst.verifySigs multiKey sigTestNarInfo {NarInfo.niSigs = [validSig]}),+          runTest "a signature matching none of several trusted keys is rejected" $+            let multiKey = cache {Subst.ccPublicKeys = ["cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=", "other.cache-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="]}+             in assertLeft "none-of" (Subst.verifySigs multiKey sigTestNarInfo {NarInfo.niSigs = [validSig]}),+          -- A malformed key anywhere in the set is an error, not a+          -- silently skipped key: a typo must not quietly narrow the+          -- trusted set to the keys that happened to parse.+          runTest "a malformed key among valid ones is an error, not skipped" $+            let multiKey = cache {Subst.ccPublicKeys = "not-a-key" : Subst.ccPublicKeys cache}+             in assertLeft "typo not skipped" (Subst.verifySigs multiKey sigTestNarInfo {NarInfo.niSigs = [validSig]})+        ]++-- | The nix.conf / NIX_CONFIG resolver: parsing, aliases, the extra-+-- append rule, and the precedence fold.  The trusted-key cases are the+-- security-relevant ones, a plain assignment in a higher source replaces+-- the trusted set, an extra- widens it.+testNixConfig :: IO [Bool]+testNixConfig = do+  putStrLn "config/nix-conf"+  let subs = fmap ncSubstituters . Config.resolveConfig+      keys = fmap ncTrustedPublicKeys . Config.resolveConfig+  sequence+    [ runTest "parses a whitespace-split substituters list" $+        assertEqual "subs" (Right ["https://a", "https://b"]) (subs ["substituters = https://a https://b"]),+      runTest "a comment truncates the line" $+        assertEqual "comment" (Right ["https://a"]) (subs ["substituters = https://a # not https://b"]),+      runTest "a plain assignment replaces across sources" $+        assertEqual "replace" (Right ["https://b"]) (subs ["substituters = https://a", "substituters = https://b"]),+      runTest "extra- appends within a source set" $+        assertEqual "append" (Right ["https://a", "https://b"]) (subs ["substituters = https://a", "extra-substituters = https://b"]),+      runTest "binary-caches aliases substituters" $+        assertEqual "alias" (Right ["https://a"]) (subs ["binary-caches = https://a"]),+      runTest "extra- composes with an alias" $+        assertEqual "extra-alias" (Right ["https://a", "https://b"]) (subs ["substituters = https://a", "extra-binary-caches = https://b"]),+      runTest "binary-cache-public-keys aliases trusted-public-keys" $+        assertEqual "key-alias" (Right ["k1"]) (keys ["binary-cache-public-keys = k1"]),+      runTest "a higher source plainly replaces the trusted set" $+        assertEqual "trust-replace" (Right ["k2"]) (keys ["trusted-public-keys = k1", "trusted-public-keys = k2"]),+      runTest "a higher source with extra- widens the trusted set" $+        assertEqual "trust-widen" (Right ["k1", "k2"]) (keys ["trusted-public-keys = k1", "extra-trusted-public-keys = k2"]),+      runTest "an unknown setting is ignored, not an error" $+        assertEqual "unknown" (Right ["https://a"]) (subs ["cores = 4\nsubstituters = https://a"]),+      runTest "blank and comment-only lines are skipped" $+        assertEqual "blanks" (Right ["https://a"]) (subs ["# a comment\n\nsubstituters = https://a\n"]),+      runTest "a line missing = is a syntax error" $+        assertLeft "syntax" (Config.resolveConfig ["substituters https://a"]),+      runTest "an include directive is refused" $+        assertLeft "include" (Config.resolveConfig ["include /etc/nix/other.conf"]),+      runTest "the default config is empty" $+        assertEqual "default" (Right []) (subs [])+    ]++-- | Narinfo field validation gates the pipeline ahead of the signed+-- fingerprint: a malformed field must fail as a parse error before its+-- text can reach the fingerprint or anything downstream.+testNarInfoValidation :: IO [Bool]+testNarInfoValidation = do+  putStrLn "substituter/narinfo-field-validation"+  sequence+    [ runTest "well-formed narinfo passes" $+        assertEqual "valid" (Right ()) (Subst.validateNarInfoFields sigTestNarInfo),+      runTest "malformed StorePath rejected" $+        assertLeft "bad store path" (Subst.validateNarInfoFields sigTestNarInfo {NarInfo.niStorePath = "/nix/store/zzz"}),+      runTest "derivation StorePath rejected" $+        assertLeft "drv path" (Subst.validateNarInfoFields sigTestNarInfo {NarInfo.niStorePath = "/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-1.0.drv"}),+      runTest "malformed reference rejected" $+        assertLeft "bad reference" (Subst.validateNarInfoFields sigTestNarInfo {NarInfo.niReferences = ["../escape"]}),+      runTest "malformed NarHash rejected" $+        assertLeft "bad narhash" (Subst.validateNarInfoFields sigTestNarInfo {NarInfo.niNarHash = "sha256:nope"}),+      runTest "negative NarSize rejected" $+        assertLeft "negative narsize" (Subst.validateNarInfoFields sigTestNarInfo {NarInfo.niNarSize = -1}),+      runTest "field validation reports ahead of signature verification" $+        case sigTestSetup of+          Left err -> Fail (T.pack err)+          Right (cache, validSig, _) ->+            case Subst.narInfoPreflight cache sigTestNarInfo {NarInfo.niStorePath = "/nix/store/zzz", NarInfo.niSigs = [validSig]} of+              Left err+                | "invalid narinfo" `T.isInfixOf` err -> Pass+                | otherwise -> Fail ("expected the field-validation error, got: " <> err)+              Right () -> Fail "expected failure, got a clean preflight"+    ]++-- ---------------------------------------------------------------------------+-- Tests: resolver static globals stay in sync with the root env+-- ---------------------------------------------------------------------------++-- | Every name the resolver treats as a static global must actually be+-- bound in the root environment: 'Nix.Expr.Resolve.resolveVar' leaves such+-- names as 'EVar' even under a @with@, so an unbound one would surface as+-- an undefined variable.  Layering keeps Resolve from importing Builtins,+-- so this test is the sync guarantee between the two lists.+testStaticGlobalsSync :: IO [Bool]+testStaticGlobalsSync = do+  putStrLn "resolve/static-globals-sync"+  mapM checkBound (Set.toList staticGlobalNames)+  where+    checkBound name =+      runTest ("static global '" <> name <> "' is bound in the root env") $+        assertEval ("global-" <> name) ("builtins.seq " <> name <> " true") (VBool True)++-- ---------------------------------------------------------------------------+-- Main+-- ---------------------------------------------------------------------------++-- ---------------------------------------------------------------------------+-- Tests: byte-indexed string semantics (issue #34)+-- ---------------------------------------------------------------------------++-- | The pinned byte-semantics suite: a Nix string is a byte string, so+-- lengths, slices, regexes, and replaceStrings all index BYTES, and the+-- hash of a value is the hash of exactly its bytes.  Multibyte content+-- enters through source literals here (the parser interns their UTF-8);+-- arbitrary/invalid bytes enter via readFile in the IO group below.+testByteStringSemantics :: IO [Bool]+testByteStringSemantics = do+  putStrLn "eval/byte-strings"+  sequence+    [ -- stringLength / substring index bytes+      runTest "stringLength counts bytes" $+        assertEval "len-bytes" "builtins.stringLength \"\228\"" (VInt 2),+      runTest "substring slices at byte offsets" $+        assertEval "substr-bytes" "builtins.stringLength (builtins.substring 0 1 \"\228\")" (VInt 1),+      runTest "mid-codepoint slices reassemble byte-exactly" $+        assertEval+          "substr-reassemble"+          "builtins.substring 0 1 \"\228\" + builtins.substring 1 1 \"\228\" == \"\228\""+          (VBool True),+      -- the hash sees exactly the value's bytes+      runTest "hashString of a mid-codepoint slice is the raw byte's sha256" $+        assertEval+          "hash-midbyte"+          "builtins.hashString \"sha256\" (builtins.substring 0 1 \"\228\")"+          (mkStr (Hash.bytesToHexText (sha256Digest (BS.take 1 (TE.encodeUtf8 "\228"))))),+      runTest "hashString of valid UTF-8 is unchanged by the byte layer" $+        assertEval+          "hash-utf8-stable"+          "builtins.hashString \"sha256\" \"\228\""+          (mkStr (Hash.bytesToHexText (sha256Digest (TE.encodeUtf8 "\228")))),+      -- regexes run over bytes ('.' matches ONE byte)+      runTest "match . does not match a 2-byte char" $+        assertEval "match-one-byte" "builtins.match \".\" \"\228\"" VNull,+      runTest "match .. matches a 2-byte char" $+        assertEval "match-two-bytes" "builtins.match \"..\" \"\228\" == []" (VBool True),+      runTest "a multibyte pattern matches its own bytes" $+        assertEval "match-multibyte-pattern" "builtins.match \"\228\" \"\228\" == []" (VBool True),+      -- replaceStrings with an empty 'from' steps one BYTE+      runTest "replaceStrings empty-from inserts between bytes" $+        assertEval+          "replace-empty-from"+          "builtins.stringLength (builtins.replaceStrings [\"\"] [\"-\"] \"\228\")"+          (VInt 5),+      runTest "replaceStrings empty-from result is byte-exact" $+        assertEval+          "replace-empty-from-bytes"+          "builtins.replaceStrings [\"\"] [\"-\"] \"\228\" == \"-\" + builtins.substring 0 1 \"\228\" + \"-\" + builtins.substring 1 1 \"\228\" + \"-\""+          (VBool True),+      -- strict-decode boundaries reject bytes that are not UTF-8+      runTest "toJSON rejects invalid UTF-8" $+        assertEvalFail "tojson-invalid" "builtins.toJSON (builtins.substring 0 1 \"\228\")",+      runTest "getAttr rejects an invalid-UTF-8 attr name" $+        assertEvalFail "getattr-invalid" "builtins.getAttr (builtins.substring 0 1 \"\228\") {}",+      -- toXML passes bytes through raw (upstream's serializer never validates)+      runTest "toXML passes a mid-codepoint byte through" $+        assertEval+          "toxml-bytes"+          "builtins.stringLength (builtins.toXML (builtins.substring 0 1 \"\228\")) == builtins.stringLength (builtins.toXML \"x\")"+          (VBool True),+      -- a search-path miss is a CATCHABLE error (upstream ThrownError;+      -- nixpkgs' impure.nix relies on tryEval catching it)+      runTest "search-path miss is tryEval-catchable" $+        assertEval+          "findFile-catchable"+          "(builtins.tryEval (builtins.findFile builtins.nixPath \"nope-missing\")).success"+          (VBool False)+    ]++-- | readFile returns the file's RAW BYTES: BOMs survive, UTF-16 is not+-- transcoded, invalid UTF-8 is representable, and only NUL is rejected.+-- The expected hashes are computed from the fixture bytes themselves, so+-- the assertions pin "hash of the value = hash of the file's bytes".+testByteStringSemanticsIO :: IO [Bool]+testByteStringSemanticsIO = do+  putStrLn "eval/readfile-bytes-io"+  tmpDir <- getTemporaryDirectory+  let testDir = tmpDir </> "nova-nix-bytefile-test"+      bomBytes = BS.pack [0xEF, 0xBB, 0xBF] <> "hi"+      -- UTF-16LE BOM + U+0101 (both payload bytes nonzero, so no NUL):+      -- raw passthrough is observable as 4 bytes instead of a decoded char.+      utf16Bytes = BS.pack [0xFF, 0xFE, 0x01, 0x01]+      -- UTF-16LE ASCII interleaves NUL bytes - upstream readFile REJECTS it.+      utf16AsciiBytes = BS.pack [0xFF, 0xFE, 0x68, 0x00, 0x69, 0x00]+      invalidBytes = "a" <> BS.singleton 0xFF <> "b"+      nulBytes = "a" <> BS.singleton 0x00 <> "b"+  bracket_+    ( do+        createDirectoryIfMissing True testDir+        BS.writeFile (testDir </> "bom.bin") bomBytes+        BS.writeFile (testDir </> "utf16.bin") utf16Bytes+        BS.writeFile (testDir </> "utf16-ascii.bin") utf16AsciiBytes+        BS.writeFile (testDir </> "invalid.bin") invalidBytes+        BS.writeFile (testDir </> "nul.bin") nulBytes+        BS.writeFile (testDir </> "umlaut.bin") (TE.encodeUtf8 "\228")+    )+    ( do+        exists <- doesDirectoryExist testDir+        when exists (removeDirectoryRecursive testDir)+    )+    $ sequence+      [ runTestIO+          "readFile keeps a BOM (raw bytes)"+          testDir+          "builtins.stringLength (builtins.readFile ./bom.bin)"+          (VInt 5),+        runTestIO+          "readFile does not transcode UTF-16"+          testDir+          "builtins.stringLength (builtins.readFile ./utf16.bin)"+          (VInt 4),+        runTestIOFail+          "readFile rejects UTF-16 ASCII (its NUL bytes)"+          testDir+          "builtins.readFile ./utf16-ascii.bin",+        runTestIO+          "readFile passes invalid UTF-8 through"+          testDir+          "builtins.stringLength (builtins.readFile ./invalid.bin)"+          (VInt 3),+        runTestIO+          "readFile bytes hash as read (BOM file)"+          testDir+          "builtins.hashString \"sha256\" (builtins.readFile ./bom.bin)"+          (mkStr (Hash.bytesToHexText (sha256Digest bomBytes))),+        runTestIO+          "readFile bytes hash as read (UTF-16 file)"+          testDir+          "builtins.hashString \"sha256\" (builtins.readFile ./utf16.bin)"+          (mkStr (Hash.bytesToHexText (sha256Digest utf16Bytes))),+        runTestIO+          "readFile of invalid UTF-8 hashes its raw bytes"+          testDir+          "builtins.hashString \"sha256\" (builtins.readFile ./invalid.bin)"+          (mkStr (Hash.bytesToHexText (sha256Digest invalidBytes))),+        runTestIO+          "readFile round-trips a multibyte literal"+          testDir+          "builtins.readFile ./umlaut.bin == \"\228\""+          (VBool True),+        runTestIOFail+          "readFile rejects an embedded NUL"+          testDir+          "builtins.readFile ./nul.bin"+      ]++main :: IO ()+main = bracket_ arenaInit arenaDestroy $ do+  hSetBuffering stdout LineBuffering+  putStrLn "nova-nix test suite"+  putStrLn "==================="+  results <-+    concat+      <$> sequence+        [ testExprTypes,+          testStorePaths,+          testDerivation,+          testTrivialBuildIO,+          testSourceDateEpochIO,+          testScrubbedBuildEnvIO,+          testUnpackBuildIO,+          testDependentBuildIO,+          testEvalFidelity,+          testUpstreamConformance,+          testHashHelpers,+          testFetchMirrors,+          testNarKnownAnswer,+          testFetchGitTransport,+          testFetchGitShallow,+          testFetchGitPins,+          testPathInterp,+          testOutPathCoercion,+          testFetchCache,+          testExecWrapper,+          testAttrPath,+          testScratchDirs,+          testEvalLiterals,+          testEvalVariables,+          testEvalArithmetic,+          testEvalComparison,+          testEvalLogic,+          testEvalStrings,+          testEvalIfAssert,+          testEvalLet,+          testEvalAttrs,+          testEvalRecAttrs,+          testEvalLists,+          testEvalLambda,+          testEvalWith,+          testEvalBuiltins,+          testFromTOML,+          testEvalErrors,+          testEvalHigherOrder,+          testLexer,+          testParserExprs,+          testParserErrors,+          testParserIntegration,+          testStaticGlobalsSync,+          testBatch1,+          testBatch2,+          testBatch3,+          testBatch4,+          testBatch5,+          testBatch6,+          testBatch7,+          testImportPure,+          testImportIO,+          testBlackholeRecoveryIO,+          testPathFilterIO,+          testPathSymlinkIO,+          testBatchA,+          testBatchAIO,+          testBatchB,+          testBatchC,+          testBatchCIO,+          testBlackhole,+          testBatchD,+          testBatchE,+          testBatchEIO,+          testBatchF,+          testBatchG,+          testBatchH,+          testStringContext,+          testContextHelpers,+          testContextPropagation,+          testDrvContext,+          testDepGraph,+          testSubstituter,+          testPathLocks,+          testExecBit,+          testVerifySigs,+          testNarInfoValidation,+          testNixConfig,+          testPushPure,+          testPushClosureIO,+          testBuildOrchestrator,+          testStoreDB,+          testParseStorePath,+          testStoreOps,+          testStoreDelete,+          testSymlinkWalksIO,+          testLinkOrdering,+          testFromATerm,+          testBuilder,+          testE2E,+          testExecWrapperCLI,+          testPhase4,+          testPhase4IO,+          testToJSONPathIO,+          testByteStringSemantics,+          testByteStringSemanticsIO,+          testSymbol,+          testCAttrSet,+          testCThunk,+          testBytecodeCompile,+          testBytecodeCountSpill,+          testValueCountWidths,+          testStoreNameSinks,+          testNarNameSafety,+          testStoreNameSinksIO,+          testClassIFollowups,+          testClassIFollowupsIO         ]   let total = length results       passed = length (filter id results)