nova-nix 0.8.0.0 → 0.9.0.0
raw patch · 77 files changed
+31122/−19339 lines, 77 filesdep +QuickCheckdep +Win32dep +file-iodep ~directorydep ~filepathdep ~processPVP ok
version bump matches the API change (PVP)
Dependencies added: QuickCheck, Win32, file-io, random, unix
Dependency ranges changed: directory, filepath, process
API changes (from Hackage documentation)
- Nix.Config: applyConfigText :: NixConfig -> Text -> Either Text NixConfig
- Nix.Config: instance GHC.Internal.Classes.Eq Nix.Config.ConfigField
- Nix.Config: instance GHC.Internal.Show.Show Nix.Config.ConfigField
- Nix.Config: parseConfigText :: Text -> Either Text [ConfigAssignment]
- Nix.Eval: VDerivation :: Derivation -> NixValue
- Nix.Eval.CThunk: cthunkNewComputedBool :: Word8 -> IO CThunkPtr
- Nix.Eval.CThunk: cthunkNewComputedNull :: IO CThunkPtr
- 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.Types: VDerivation :: Derivation -> NixValue
- Nix.Store: Store :: StoreDir -> StoreDB -> Store
- Nix.Store: [stDB] :: Store -> StoreDB
- Nix.Store: [stDir] :: Store -> StoreDir
- Nix.Store: closeStore :: Store -> IO ()
- Nix.Store: data Store
- Nix.Store: isSafeNarName :: Text -> Bool
- Nix.Store: openStore :: StoreDir -> IO Store
- Nix.Substituter: httpStatusFailure :: Int -> AttemptFailure
- Nix.Substituter: instance GHC.Internal.Classes.Eq Nix.Substituter.AttemptFailure
- Nix.Substituter: instance GHC.Internal.Show.Show Nix.Substituter.AttemptFailure
- Nix.Substituter: unpackNarEntry :: FilePath -> NarEntry -> IO (Either Text ())
+ Nix.Builder: [bcFetchRetry] :: BuildConfig -> FetchRetryPolicy
+ Nix.Builder: spawnFor :: Platform -> BuildConfig -> Derivation -> BuilderSpawn
+ Nix.Builtins: isNixPathPseudoUrl :: ByteString -> Bool
+ Nix.Builtins: rootScopeNames :: EvalPolicy -> Set Text
+ Nix.Builtins: searchPathRoots :: [Thunk] -> [ByteString]
+ Nix.Config: ConfigFileMissing :: ConfigReadFailure
+ Nix.Config: ConfigFileUnreadable :: ConfigReadFailure
+ Nix.Config: ConfigLocations :: Maybe ByteString -> Maybe OsPath -> Maybe ByteString -> OsPath -> [OsPath] -> ConfigLocations
+ Nix.Config: ConfigSource :: OsPath -> ByteString -> ConfigSource
+ Nix.Config: IncludeOptional :: IncludeMode
+ Nix.Config: IncludeRequired :: IncludeMode
+ Nix.Config: LineAssignment :: ConfigAssignment -> ConfigLine
+ Nix.Config: LineInclude :: IncludeMode -> ByteString -> ConfigLine
+ Nix.Config: [clConfDir] :: ConfigLocations -> Maybe ByteString
+ Nix.Config: [clConfigDirs] :: ConfigLocations -> [OsPath]
+ Nix.Config: [clConfigHome] :: ConfigLocations -> OsPath
+ Nix.Config: [clSystemConfDir] :: ConfigLocations -> Maybe OsPath
+ Nix.Config: [clUserConfFiles] :: ConfigLocations -> Maybe ByteString
+ Nix.Config: [csName] :: ConfigSource -> OsPath
+ Nix.Config: [csText] :: ConfigSource -> ByteString
+ Nix.Config: [ncAllowedUris] :: NixConfig -> [Text]
+ Nix.Config: [ncMaxCallDepth] :: NixConfig -> Word32
+ Nix.Config: [ncPureEval] :: NixConfig -> Bool
+ Nix.Config: [ncRestrictEval] :: NixConfig -> Bool
+ Nix.Config: configFilePaths :: ConfigLocations -> Either Text [OsPath]
+ Nix.Config: data ConfigLine
+ Nix.Config: data ConfigLocations
+ Nix.Config: data ConfigReadFailure
+ Nix.Config: data ConfigSource
+ Nix.Config: data IncludeMode
+ Nix.Config: expandConfigSource :: Monad m => ReadConfigFile m -> ConfigSource -> m (Either Text [ConfigAssignment])
+ Nix.Config: instance GHC.Internal.Classes.Eq Nix.Config.BoolField
+ Nix.Config: instance GHC.Internal.Classes.Eq Nix.Config.ConfigLine
+ Nix.Config: instance GHC.Internal.Classes.Eq Nix.Config.ConfigLocations
+ Nix.Config: instance GHC.Internal.Classes.Eq Nix.Config.ConfigReadFailure
+ Nix.Config: instance GHC.Internal.Classes.Eq Nix.Config.ConfigSource
+ Nix.Config: instance GHC.Internal.Classes.Eq Nix.Config.IncludeMode
+ Nix.Config: instance GHC.Internal.Classes.Eq Nix.Config.ListField
+ Nix.Config: instance GHC.Internal.Classes.Eq Nix.Config.ScalarField
+ Nix.Config: instance GHC.Internal.Classes.Eq Nix.Config.Setting
+ Nix.Config: instance GHC.Internal.Show.Show Nix.Config.BoolField
+ Nix.Config: instance GHC.Internal.Show.Show Nix.Config.ConfigLine
+ Nix.Config: instance GHC.Internal.Show.Show Nix.Config.ConfigLocations
+ Nix.Config: instance GHC.Internal.Show.Show Nix.Config.ConfigReadFailure
+ Nix.Config: instance GHC.Internal.Show.Show Nix.Config.ConfigSource
+ Nix.Config: instance GHC.Internal.Show.Show Nix.Config.IncludeMode
+ Nix.Config: instance GHC.Internal.Show.Show Nix.Config.ListField
+ Nix.Config: instance GHC.Internal.Show.Show Nix.Config.ScalarField
+ Nix.Config: instance GHC.Internal.Show.Show Nix.Config.Setting
+ Nix.Config: loadConfig :: Monad m => ReadConfigFile m -> [OsPath] -> Maybe ByteString -> m (Either Text NixConfig)
+ Nix.Config: nixConfDirName :: OsPath
+ Nix.Config: nixConfFileName :: OsPath
+ Nix.Config: nixConfigSourceName :: OsPath
+ Nix.Config: parseConfigLines :: OsPath -> ByteString -> Either Text [ConfigLine]
+ Nix.Config: parseUnsignedSetting :: ByteString -> ByteString -> Either Text Word32
+ Nix.Config: type ReadConfigFile (m :: Type -> Type) = OsPath -> m Either ConfigReadFailure ByteString
+ Nix.Derivation: I686_Linux :: Platform
+ Nix.Derivation: I686_Windows :: Platform
+ Nix.Derivation: extraPlatforms :: Platform -> [Platform]
+ Nix.Derivation.StructuredAttrs: StructuredAttrs :: Map Text Json -> StructuredAttrs
+ Nix.Derivation.StructuredAttrs: decodeStructuredAttrs :: Text -> Either Text StructuredAttrs
+ Nix.Derivation.StructuredAttrs: encodeStructuredAttrs :: StructuredAttrs -> ByteString
+ Nix.Derivation.StructuredAttrs: instance GHC.Internal.Classes.Eq Nix.Derivation.StructuredAttrs.StructuredAttrs
+ Nix.Derivation.StructuredAttrs: instance GHC.Internal.Show.Show Nix.Derivation.StructuredAttrs.StructuredAttrs
+ Nix.Derivation.StructuredAttrs: newtype StructuredAttrs
+ Nix.Derivation.StructuredAttrs: renderAttrsJson :: (Text -> Text) -> StructuredAttrs -> ByteString
+ Nix.Derivation.StructuredAttrs: renderAttrsShell :: StructuredAttrs -> Text
+ Nix.Derivation.StructuredAttrs: stringsAttr :: Text -> StructuredAttrs -> Either Text (Maybe [Text])
+ Nix.Derivation.StructuredAttrs: structuredAttrsKey :: Text
+ Nix.Derivation.StructuredAttrs: withOutputPlaceholders :: [Text] -> StructuredAttrs -> StructuredAttrs
+ Nix.Environment: EnvUnpairedSurrogate :: EnvLookup
+ Nix.Environment: EnvUnset :: EnvLookup
+ Nix.Environment: EnvValue :: ByteString -> EnvLookup
+ Nix.Environment: data EnvLookup
+ Nix.Environment: instance GHC.Internal.Classes.Eq Nix.Environment.EnvLookup
+ Nix.Environment: instance GHC.Internal.Show.Show Nix.Environment.EnvLookup
+ Nix.Environment: lookupEnvBytes :: ByteString -> IO EnvLookup
+ Nix.Eval: EvalPolicy :: Bool -> Bool -> [Text] -> EvalPolicy
+ Nix.Eval: ExistsAsDirectory :: PathExistence
+ Nix.Eval: ExistsAsEntry :: PathExistence
+ Nix.Eval: FetchGitArgs :: Text -> Text -> Maybe Text -> Maybe Text -> Bool -> Bool -> Bool -> Maybe Text -> FetchGitArgs
+ Nix.Eval: [epAllowedUris] :: EvalPolicy -> [Text]
+ Nix.Eval: [epPureEval] :: EvalPolicy -> Bool
+ Nix.Eval: [epRestrictEval] :: EvalPolicy -> Bool
+ Nix.Eval: [fgaAllRefs] :: FetchGitArgs -> Bool
+ Nix.Eval: [fgaName] :: FetchGitArgs -> Text
+ Nix.Eval: [fgaNarHash] :: FetchGitArgs -> Maybe Text
+ Nix.Eval: [fgaRef] :: FetchGitArgs -> Maybe Text
+ Nix.Eval: [fgaRev] :: FetchGitArgs -> Maybe Text
+ Nix.Eval: [fgaShallow] :: FetchGitArgs -> Bool
+ Nix.Eval: [fgaSubmodules] :: FetchGitArgs -> Bool
+ Nix.Eval: [fgaUrl] :: FetchGitArgs -> Text
+ Nix.Eval: addErrorTrace :: MonadEval m => Text -> m a -> m a
+ Nix.Eval: checkUri :: MonadEval m => Text -> m ()
+ Nix.Eval: data EvalPolicy
+ Nix.Eval: data FetchGitArgs
+ Nix.Eval: data PathExistence
+ Nix.Eval: deferApply :: NixValue -> Thunk -> Thunk
+ Nix.Eval: evalPolicy :: MonadEval m => m EvalPolicy
+ Nix.Eval: gitInputUrl :: FetchGitArgs -> Text
+ Nix.Eval: lookupSessionReferences :: MonadEval m => StorePath -> m (Maybe [StorePath])
+ Nix.Eval: resolveSymlinks :: MonadEval m => Text -> m Text
+ Nix.Eval: unrestrictedPolicy :: EvalPolicy
+ Nix.Eval: withCallFrame :: MonadEval m => m a -> m a
+ Nix.Eval.Arena: ArenaNotInitialized :: String -> CStatusError
+ Nix.Eval.Arena: CAllocationFailed :: String -> String -> CStatusError
+ Nix.Eval.Arena: CInvariantViolated :: String -> String -> CStatusError
+ Nix.Eval.Arena: arenaLive :: IO Bool
+ Nix.Eval.Arena: data CStatusError
+ Nix.Eval.CBytecode: appDeferred :: Word8
+ Nix.Eval.CBytecode: appDirect :: Word8
+ Nix.Eval.CBytecode: bindInheritFrom :: Word32
+ Nix.Eval.CBytecode: reservedApply1 :: Word32
+ Nix.Eval.CBytecode: reservedApply2 :: Word32
+ Nix.Eval.CBytecode: strpartEsc :: Word32
+ Nix.Eval.CList: clistDrop :: Int -> CList -> CList
+ Nix.Eval.CList: clistIndex :: CList -> Int -> Maybe CThunkPtr
+ Nix.Eval.CStatus: ArenaNotInitialized :: String -> CStatusError
+ Nix.Eval.CStatus: CAllocationFailed :: String -> String -> CStatusError
+ Nix.Eval.CStatus: CInvariantViolated :: String -> String -> CStatusError
+ Nix.Eval.CStatus: arenaLive :: IO Bool
+ Nix.Eval.CStatus: cInvariantViolated :: String -> String -> IO a
+ Nix.Eval.CStatus: cStatusFailure :: String -> String -> IO a
+ Nix.Eval.CStatus: checkedCPtr :: String -> Ptr a -> IO (Ptr a)
+ Nix.Eval.CStatus: data CStatusError
+ Nix.Eval.CStatus: instance GHC.Internal.Classes.Eq Nix.Eval.CStatus.CStatusError
+ Nix.Eval.CStatus: instance GHC.Internal.Exception.Type.Exception Nix.Eval.CStatus.CStatusError
+ Nix.Eval.CStatus: instance GHC.Internal.Show.Show Nix.Eval.CStatus.CStatusError
+ Nix.Eval.CThunk: cthunkBool :: Bool -> CThunkPtr
+ Nix.Eval.CThunk: cthunkNull :: CThunkPtr
+ Nix.Eval.CallDepth: CallDepth :: Word32 -> Word32 -> CallDepth
+ Nix.Eval.CallDepth: [cdActive] :: CallDepth -> Word32
+ Nix.Eval.CallDepth: [cdCeiling] :: CallDepth -> Word32
+ Nix.Eval.CallDepth: data CallDepth
+ Nix.Eval.CallDepth: defaultMaxCallDepth :: Word32
+ Nix.Eval.CallDepth: enterCallFrame :: CallDepth -> Either Text CallDepth
+ Nix.Eval.CallDepth: instance GHC.Internal.Classes.Eq Nix.Eval.CallDepth.CallDepth
+ Nix.Eval.CallDepth: instance GHC.Internal.Show.Show Nix.Eval.CallDepth.CallDepth
+ Nix.Eval.CallDepth: maxCallDepthExceeded :: Text
+ Nix.Eval.CallDepth: topLevelCallDepth :: Word32 -> CallDepth
+ Nix.Eval.Compile: BcInheritedName :: Word32 -> Word32 -> BcInheritedName
+ Nix.Eval.Compile: data BcInheritedName
+ Nix.Eval.Context: firstContextEncoded :: StringContext -> Maybe Text
+ Nix.Eval.IO: EvalFailure :: [Text] -> Text -> EvalFailure
+ Nix.Eval.IO: [esAllowedPaths] :: EvalState -> IORef AllowedPaths
+ Nix.Eval.IO: [esCallDepth] :: EvalState -> CallDepth
+ Nix.Eval.IO: [esPolicy] :: EvalState -> EvalPolicy
+ Nix.Eval.IO: [failureMessage] :: EvalFailure -> Text
+ Nix.Eval.IO: [failureTrace] :: EvalFailure -> [Text]
+ Nix.Eval.IO: allowEvalPath :: EvalState -> Text -> IO ()
+ Nix.Eval.IO: data EvalFailure
+ Nix.Eval.IO: instance GHC.Internal.Classes.Eq Nix.Eval.IO.EvalFailure
+ Nix.Eval.IO: instance GHC.Internal.Classes.Eq Nix.Eval.IO.SymlinkResolution
+ Nix.Eval.IO: instance GHC.Internal.Show.Show Nix.Eval.IO.EvalFailure
+ Nix.Eval.IO: instance GHC.Internal.Show.Show Nix.Eval.IO.SymlinkResolution
+ Nix.Eval.IO: renderEvalFailure :: EvalFailure -> Text
+ Nix.Eval.IO: runEvalIOTraced :: EvalState -> EvalIO a -> IO (Either EvalFailure a)
+ Nix.Eval.Operator: addToFloat :: MonadEval m => Double -> NixValue -> m NixValue
+ Nix.Eval.Operator: addToInteger :: MonadEval m => Int64 -> NixValue -> m NixValue
+ Nix.Eval.Operator: evalUpdate :: MonadEval m => m NixValue -> m NixValue -> m NixValue
+ Nix.Eval.Operator: expectInt :: MonadEval m => NixValue -> m Int64
+ Nix.Eval.Operator: primAdd :: MonadEval m => NixValue -> NixValue -> m NixValue
+ Nix.Eval.Operator: primDiv :: MonadEval m => NixValue -> NixValue -> m NixValue
+ Nix.Eval.Operator: primMul :: MonadEval m => NixValue -> NixValue -> m NixValue
+ Nix.Eval.Operator: primSub :: MonadEval m => NixValue -> NixValue -> m NixValue
+ Nix.Eval.Policy: EvalPolicy :: Bool -> Bool -> [Text] -> EvalPolicy
+ Nix.Eval.Policy: [epAllowedUris] :: EvalPolicy -> [Text]
+ Nix.Eval.Policy: [epPureEval] :: EvalPolicy -> Bool
+ Nix.Eval.Policy: [epRestrictEval] :: EvalPolicy -> Bool
+ Nix.Eval.Policy: allowPathIn :: Text -> AllowedPaths -> AllowedPaths
+ Nix.Eval.Policy: data AllowedPaths
+ Nix.Eval.Policy: data EvalPolicy
+ Nix.Eval.Policy: forbiddenPathMessage :: EvalPolicy -> Text -> Text
+ Nix.Eval.Policy: forbiddenUriMessage :: Text -> Text
+ Nix.Eval.Policy: instance GHC.Internal.Classes.Eq Nix.Eval.Policy.AllowedPaths
+ Nix.Eval.Policy: instance GHC.Internal.Classes.Eq Nix.Eval.Policy.EvalPolicy
+ Nix.Eval.Policy: instance GHC.Internal.Show.Show Nix.Eval.Policy.AllowedPaths
+ Nix.Eval.Policy: instance GHC.Internal.Show.Show Nix.Eval.Policy.EvalPolicy
+ Nix.Eval.Policy: isAbsolutePath :: Text -> Bool
+ Nix.Eval.Policy: isAllowedPath :: AllowedPaths -> Text -> Bool
+ Nix.Eval.Policy: isAllowedPrefix :: AllowedPaths -> [Text] -> Bool
+ Nix.Eval.Policy: isAllowedUri :: [Text] -> Text -> Bool
+ Nix.Eval.Policy: joinComponents :: [Text] -> Text
+ Nix.Eval.Policy: modeInformation :: EvalPolicy -> Text
+ Nix.Eval.Policy: noAllowedPaths :: AllowedPaths
+ Nix.Eval.Policy: pathComponents :: Text -> [Text]
+ Nix.Eval.Policy: pathsRestricted :: EvalPolicy -> Bool
+ Nix.Eval.Policy: unrestrictedPolicy :: EvalPolicy
+ Nix.Eval.Policy: uriAccess :: EvalPolicy -> AllowedPaths -> Text -> Either Text ()
+ Nix.Eval.Print: PrintForError :: PrintOptions
+ Nix.Eval.Print: PrintInFull :: PrintOptions
+ Nix.Eval.Print: data PrintOptions
+ Nix.Eval.Print: formatXmlFloat :: Double -> Text
+ Nix.Eval.Print: printAmbiguous :: NixValue -> Builder
+ Nix.Eval.Print: printValue :: PrintOptions -> NixValue -> Text
+ Nix.Eval.SourceError: ReadDirectory :: SourceAccess
+ Nix.Eval.SourceError: ReadFile :: SourceAccess
+ Nix.Eval.SourceError: StatPath :: SourceAccess
+ Nix.Eval.SourceError: data SourceAccess
+ Nix.Eval.SourceError: instance GHC.Internal.Classes.Eq Nix.Eval.SourceError.SourceAccess
+ Nix.Eval.SourceError: instance GHC.Internal.Show.Show Nix.Eval.SourceError.SourceAccess
+ Nix.Eval.SourceError: sourceErrorMessage :: SourceAccess -> Text -> IOException -> Text
+ Nix.Eval.StringInterp: ChunkEsc :: ByteString -> StringChunk
+ Nix.Eval.StringInterp: ChunkInterp :: ByteString -> StringContext -> StringChunk
+ Nix.Eval.StringInterp: ChunkLit :: ByteString -> StringChunk
+ Nix.Eval.StringInterp: concatChunks :: [StringChunk] -> (ByteString, StringContext)
+ Nix.Eval.StringInterp: data StringChunk
+ Nix.Eval.StringInterp: instance GHC.Internal.Classes.Eq Nix.Eval.StringInterp.StringChunk
+ Nix.Eval.StringInterp: instance GHC.Internal.Show.Show Nix.Eval.StringInterp.StringChunk
+ Nix.Eval.StringInterp: joinCoercedList :: [(ByteString, Bool)] -> ByteString
+ Nix.Eval.Types: EvalPolicy :: Bool -> Bool -> [Text] -> EvalPolicy
+ Nix.Eval.Types: ExistsAsDirectory :: PathExistence
+ Nix.Eval.Types: ExistsAsEntry :: PathExistence
+ Nix.Eval.Types: [epAllowedUris] :: EvalPolicy -> [Text]
+ Nix.Eval.Types: [epPureEval] :: EvalPolicy -> Bool
+ Nix.Eval.Types: [epRestrictEval] :: EvalPolicy -> Bool
+ Nix.Eval.Types: addErrorTrace :: MonadEval m => Text -> m a -> m a
+ Nix.Eval.Types: checkUri :: MonadEval m => Text -> m ()
+ Nix.Eval.Types: data EvalPolicy
+ Nix.Eval.Types: data PathExistence
+ Nix.Eval.Types: evalPolicy :: MonadEval m => m EvalPolicy
+ Nix.Eval.Types: instance GHC.Internal.Classes.Eq Nix.Eval.Types.PathExistence
+ Nix.Eval.Types: instance GHC.Internal.Show.Show Nix.Eval.Types.PathExistence
+ Nix.Eval.Types: lookupSessionReferences :: MonadEval m => StorePath -> m (Maybe [StorePath])
+ Nix.Eval.Types: resolveSymlinks :: MonadEval m => Text -> m Text
+ Nix.Eval.Types: thunkUnderEvaluation :: Thunk -> Bool
+ Nix.Eval.Types: typeOfValue :: NixValue -> Text
+ Nix.Eval.Types: unrestrictedPolicy :: EvalPolicy
+ Nix.Eval.Types: withCallFrame :: MonadEval m => m a -> m a
+ Nix.Expr.Resolve: impureOnlyGlobalNames :: Set Text
+ Nix.Expr.Resolve: undefinedVariableMessage :: Text -> Text
+ Nix.Expr.Types: InheritFrom :: Expr -> [Text] -> Binding
+ Nix.Expr.Types: StrEsc :: Text -> StringPart
+ Nix.HostPath: hostPathFromBytes :: ByteString -> Maybe OsPath
+ Nix.HostPath: hostPathText :: OsPath -> Text
+ Nix.Http: FatalFailure :: Text -> AttemptFailure
+ Nix.Http: FetchRetryPolicy :: Int -> Int -> FetchRetryPolicy
+ Nix.Http: Forbidden :: TransferError
+ Nix.Http: Misc :: TransferError
+ Nix.Http: NotFound :: TransferError
+ Nix.Http: RetryEffects :: (Int -> m ()) -> m Double -> (Text -> m ()) -> RetryEffects (m :: Type -> Type)
+ Nix.Http: Transient :: TransferError
+ Nix.Http: TransientFailure :: Text -> AttemptFailure
+ Nix.Http: [frpAttempts] :: FetchRetryPolicy -> Int
+ Nix.Http: [frpBaseDelayMs] :: FetchRetryPolicy -> Int
+ Nix.Http: [reJitter] :: RetryEffects (m :: Type -> Type) -> m Double
+ Nix.Http: [reSleepMs] :: RetryEffects (m :: Type -> Type) -> Int -> m ()
+ Nix.Http: [reWarn] :: RetryEffects (m :: Type -> Type) -> Text -> m ()
+ Nix.Http: attemptFailureMessage :: AttemptFailure -> Text
+ Nix.Http: catchSync :: IO a -> (SomeException -> IO a) -> IO a
+ Nix.Http: data AttemptFailure
+ Nix.Http: data FetchRetryPolicy
+ Nix.Http: data RetryEffects (m :: Type -> Type)
+ Nix.Http: data TransferError
+ Nix.Http: defaultFetchRetryPolicy :: FetchRetryPolicy
+ Nix.Http: fetchExceptionFailure :: Text -> HttpException -> AttemptFailure
+ Nix.Http: fetchStatusFailure :: Text -> Status -> AttemptFailure
+ Nix.Http: fetchWriteFailure :: Text -> IOException -> AttemptFailure
+ Nix.Http: instance GHC.Internal.Classes.Eq Nix.Http.AttemptFailure
+ Nix.Http: instance GHC.Internal.Classes.Eq Nix.Http.FetchRetryPolicy
+ Nix.Http: instance GHC.Internal.Classes.Eq Nix.Http.TransferError
+ Nix.Http: instance GHC.Internal.Show.Show Nix.Http.AttemptFailure
+ Nix.Http: instance GHC.Internal.Show.Show Nix.Http.FetchRetryPolicy
+ Nix.Http: instance GHC.Internal.Show.Show Nix.Http.TransferError
+ Nix.Http: ioRetryEffects :: RetryEffects IO
+ Nix.Http: retryDelayMs :: FetchRetryPolicy -> Int -> Double -> Int
+ Nix.Http: retryTransient :: Monad m => FetchRetryPolicy -> RetryEffects m -> m (Either AttemptFailure a) -> m (Either AttemptFailure a)
+ Nix.Http: statusError :: Status -> TransferError
+ Nix.Http: transferBodyReader :: Request -> BodyReader -> BodyReader
+ Nix.Http: transferFailureHandlers :: Text -> [Handler (Either AttemptFailure a)]
+ Nix.Http: userAgent :: ByteString
+ Nix.Http: withTransfer :: Request -> Manager -> (Response BodyReader -> IO a) -> IO a
+ Nix.Http: withUserAgent :: Request -> Request
+ Nix.Json: JsonArray :: [Json] -> Json
+ Nix.Json: JsonBool :: Bool -> Json
+ Nix.Json: JsonFloat :: Double -> Json
+ Nix.Json: JsonInt :: Integer -> Json
+ Nix.Json: JsonNull :: Json
+ Nix.Json: JsonObject :: Map Text Json -> Json
+ Nix.Json: JsonString :: Text -> Json
+ Nix.Json: NixRange :: IntegerRange
+ Nix.Json: NlohmannRange :: IntegerRange
+ Nix.Json: data IntegerRange
+ Nix.Json: data Json
+ Nix.Json: formatJsonFloat :: Double -> Text
+ Nix.Json: instance GHC.Internal.Classes.Eq Nix.Json.IntegerRange
+ Nix.Json: instance GHC.Internal.Classes.Eq Nix.Json.Json
+ Nix.Json: instance GHC.Internal.Show.Show Nix.Json.IntegerRange
+ Nix.Json: instance GHC.Internal.Show.Show Nix.Json.Json
+ Nix.Json: parseJson :: Text -> Either Text Json
+ Nix.Json: parseJsonWith :: IntegerRange -> Text -> Either Text Json
+ Nix.Json: renderJson :: Json -> ByteString
+ Nix.Json: renderJsonWith :: (Text -> Text) -> Json -> ByteString
+ Nix.Parser: SyntaxError :: ParseError -> SourceError
+ Nix.Parser: UndefinedVariable :: Text -> SourceError
+ Nix.Parser: data SourceError
+ Nix.Parser: instance GHC.Internal.Classes.Eq Nix.Parser.SourceError
+ Nix.Parser: instance GHC.Internal.Show.Show Nix.Parser.SourceError
+ Nix.Parser: parseNixWithScope :: Set Text -> FilePath -> Text -> Text -> Either SourceError Expr
+ Nix.Push: isDerivationPath :: StorePath -> Bool
+ Nix.Push: outputPathsOnly :: [StorePath] -> [StorePath]
+ Nix.Store: CaseInsensitive :: CaseSensitivity
+ Nix.Store: CaseSensitive :: CaseSensitivity
+ Nix.Store: NarStreamMalformed :: Text -> NarStreamFailure
+ Nix.Store: NarStreamRefused :: Text -> NarStreamFailure
+ Nix.Store: data CaseSensitivity
+ Nix.Store: data NarStreamFailure
+ Nix.Store: deleteStorePathChecked :: Store -> LiveSet -> Text -> IO (Either Text DeleteOutcome)
+ Nix.Store: instance GHC.Internal.Classes.Eq Nix.Store.NarStreamFailure
+ Nix.Store: instance GHC.Internal.Show.Show Nix.Store.NarStreamFailure
+ Nix.Store: onDiskNameKey :: CaseSensitivity -> ByteString -> ByteString
+ Nix.Store: processCaseHack :: CaseHack
+ Nix.Store: sinkNarStream :: NarUnpackSink -> IO ByteString -> IO (Either NarStreamFailure (NixHash, Int))
+ Nix.Store: trySetCaseSensitiveDir :: FilePath -> IO Bool
+ Nix.Store: volumeCaseHack :: CaseSensitivity -> CaseHack
+ Nix.Store.CaseSensitive: CaseInsensitive :: CaseSensitivity
+ Nix.Store.CaseSensitive: CaseSensitive :: CaseSensitivity
+ Nix.Store.CaseSensitive: data CaseSensitivity
+ Nix.Store.CaseSensitive: instance GHC.Internal.Classes.Eq Nix.Store.CaseSensitive.CaseSensitivity
+ Nix.Store.CaseSensitive: instance GHC.Internal.Show.Show Nix.Store.CaseSensitive.CaseSensitivity
+ Nix.Store.CaseSensitive: probeCaseSensitivity :: FilePath -> IO CaseSensitivity
+ Nix.Store.CaseSensitive: processCaseHack :: CaseHack
+ Nix.Store.CaseSensitive: volumeCaseHack :: CaseSensitivity -> CaseHack
+ Nix.Store.DB: queryAllReferences :: StoreDB -> IO [(Text, Text)]
+ Nix.Store.DB: unregisterPathRows :: StoreDB -> [Text] -> IO UnregisterResult
+ Nix.Store.EntryName: DarwinNames :: NameRules
+ Nix.Store.EntryName: PosixNames :: NameRules
+ Nix.Store.EntryName: WindowsNames :: NameRules
+ Nix.Store.EntryName: checkEntryName :: NameRules -> ByteString -> Either Text ()
+ Nix.Store.EntryName: checkLinkTarget :: NameRules -> ByteString -> Either Text ()
+ Nix.Store.EntryName: data NameRules
+ Nix.Store.EntryName: entryNamePath :: ByteString -> IO (Either Text OsPath)
+ Nix.Store.EntryName: hostNameRules :: NameRules
+ Nix.Store.EntryName: instance GHC.Internal.Classes.Eq Nix.Store.EntryName.NameRules
+ Nix.Store.EntryName: instance GHC.Internal.Show.Show Nix.Store.EntryName.NameRules
+ Nix.Store.EntryName: linkTargetPath :: ByteString -> IO (Either Text OsPath)
+ Nix.Store.EntryName: shownBytes :: ByteString -> Text
+ Nix.Store.ExecBit: markExecutableOsPath :: OsPath -> IO ()
+ Nix.Store.ExecBit: withNarSource :: CaseHack -> FilePath -> (IO ByteString -> IO a) -> IO a
+ Nix.Store.GC: GcResults :: [Text] -> Integer -> GcResults
+ Nix.Store.GC: GcRoot :: FilePath -> Text -> GcRoot
+ Nix.Store.GC: SweepPlan :: [Text] -> [Text] -> SweepPlan
+ Nix.Store.GC: [gcrBytesFreed] :: GcResults -> Integer
+ Nix.Store.GC: [gcrDeleted] :: GcResults -> [Text]
+ Nix.Store.GC: [grLink] :: GcRoot -> FilePath
+ Nix.Store.GC: [grPath] :: GcRoot -> Text
+ Nix.Store.GC: [spDeadRows] :: SweepPlan -> [Text]
+ Nix.Store.GC: [spTrees] :: SweepPlan -> [Text]
+ Nix.Store.GC: addOutLinkRoot :: Store -> FilePath -> StorePath -> IO (Either Text FilePath)
+ Nix.Store.GC: autoRootsDir :: StoreDir -> FilePath
+ Nix.Store.GC: autoRootsDirName :: FilePath
+ Nix.Store.GC: collectGarbage :: Store -> IO (Either Text GcResults)
+ Nix.Store.GC: data GcResults
+ Nix.Store.GC: data GcRoot
+ Nix.Store.GC: data LiveSet
+ Nix.Store.GC: data SweepPlan
+ Nix.Store.GC: findRoots :: Store -> IO [GcRoot]
+ Nix.Store.GC: findingRootsMessage :: String
+ Nix.Store.GC: gcRootsDir :: StoreDir -> FilePath
+ Nix.Store.GC: gcRootsDirName :: FilePath
+ Nix.Store.GC: gcSummaryLine :: GcResults -> Text
+ Nix.Store.GC: indirectRootRecordName :: FilePath -> FilePath
+ Nix.Store.GC: instance GHC.Internal.Classes.Eq Nix.Store.GC.GcResults
+ Nix.Store.GC: instance GHC.Internal.Classes.Eq Nix.Store.GC.GcRoot
+ Nix.Store.GC: instance GHC.Internal.Classes.Eq Nix.Store.GC.SweepPlan
+ Nix.Store.GC: instance GHC.Internal.Classes.Ord Nix.Store.GC.GcRoot
+ Nix.Store.GC: instance GHC.Internal.Show.Show Nix.Store.GC.GcResults
+ Nix.Store.GC: instance GHC.Internal.Show.Show Nix.Store.GC.GcRoot
+ Nix.Store.GC: instance GHC.Internal.Show.Show Nix.Store.GC.SweepPlan
+ Nix.Store.GC: isLive :: LiveSet -> Text -> Bool
+ Nix.Store.GC: reachableFrom :: Map Text [Text] -> [Text] -> Set Text
+ Nix.Store.GC: referencesMap :: [(Text, Text)] -> Map Text [Text]
+ Nix.Store.GC: showFreedBytes :: Integer -> Text
+ Nix.Store.GC: stillAliveMessage :: Text -> Text
+ Nix.Store.GC: sweepPlan :: StoreDir -> Set Text -> Set Text -> [Text] -> SweepPlan
+ Nix.Store.GC: withLiveSet :: Store -> (LiveSet -> IO a) -> IO (Either Text a)
+ Nix.Store.Handle: Store :: StoreDir -> StoreDB -> MVar PathLock -> CaseSensitivity -> Store
+ Nix.Store.Handle: [stCaseSensitivity] :: Store -> CaseSensitivity
+ Nix.Store.Handle: [stDB] :: Store -> StoreDB
+ Nix.Store.Handle: [stDir] :: Store -> StoreDir
+ Nix.Store.Handle: [stGcLease] :: Store -> MVar PathLock
+ Nix.Store.Handle: canonicalStoreDir :: StoreDir -> IO StoreDir
+ Nix.Store.Handle: closeStore :: Store -> IO ()
+ Nix.Store.Handle: data Store
+ Nix.Store.Handle: gcLockFileName :: FilePath
+ Nix.Store.Handle: gcLockFilePath :: StoreDir -> FilePath
+ Nix.Store.Handle: openStore :: StoreDir -> IO Store
+ Nix.Store.Handle: withCollectorLock :: Store -> IO a -> IO a
+ Nix.Store.Lock: LockExclusive :: LockMode
+ Nix.Store.Lock: LockShared :: LockMode
+ Nix.Store.Lock: acquireLockFileWith :: LockMode -> String -> FilePath -> IO PathLock
+ Nix.Store.Lock: data LockMode
+ Nix.Store.Lock: instance GHC.Internal.Classes.Eq Nix.Store.Lock.LockMode
+ Nix.Store.Lock: instance GHC.Internal.Show.Show Nix.Store.Lock.LockMode
+ Nix.Store.Lock: lockedPathOf :: Text -> Maybe Text
+ Nix.Store.Lock: waitingForLockMessage :: FilePath -> String
+ Nix.Store.Lock: withLockFileWith :: LockMode -> String -> FilePath -> (PathLock -> IO a) -> IO a
+ Nix.Store.Path: parseStorePathPrefix :: StoreDir -> Text -> Maybe StorePath
+ Nix.Store.Path: storePathBaseNameError :: Text -> Maybe Text
+ Nix.Store.Path: storePathNameUpstreamText :: StorePathNameError -> Text
+ Nix.Store.Symlink: WalkAbsent :: WalkNode
+ Nix.Store.Symlink: WalkDirectory :: WalkNode
+ Nix.Store.Symlink: WalkRegular :: WalkNode
+ Nix.Store.Symlink: WalkSymlink :: WalkNode
+ Nix.Store.Symlink: classifyWalkNode :: FilePath -> IO WalkNode
+ Nix.Store.Symlink: createSymlinkOfKind :: OsPath -> OsPath -> IO (Either Text ())
+ Nix.Store.Symlink: data WalkNode
+ Nix.Store.Symlink: instance GHC.Internal.Classes.Eq Nix.Store.Symlink.WalkNode
+ Nix.Store.Symlink: instance GHC.Internal.Show.Show Nix.Store.Symlink.WalkNode
+ Nix.Substituter: data VerifiedNar
+ Nix.Substituter: instance GHC.Internal.Classes.Eq Nix.Substituter.VerifiedNar
+ Nix.Substituter: instance GHC.Internal.Show.Show Nix.Substituter.VerifiedNar
+ Nix.Substituter: trySubstituteWith :: FetchRetryPolicy -> RetryEffects IO -> Store -> [CacheConfig] -> StorePath -> IO SubstResult
+ Nix.Substituter: verifiedNarBytes :: VerifiedNar -> ByteString
+ Nix.Substituter: verifiedNarDigest :: VerifiedNar -> NixHash
+ Nix.Substituter: verifyNar :: NarInfo -> ByteString -> Either Text VerifiedNar
- Nix.Builder: BuildConfig :: StoreDir -> FilePath -> [CacheConfig] -> UnpackLimits -> Map Text FilePath -> BuildConfig
+ Nix.Builder: BuildConfig :: StoreDir -> FilePath -> [CacheConfig] -> UnpackLimits -> FetchRetryPolicy -> Map Text FilePath -> BuildConfig
- Nix.Builtins: builtinEnv :: Int64 -> [Thunk] -> Env
+ Nix.Builtins: builtinEnv :: EvalPolicy -> Int64 -> [Thunk] -> Env
- Nix.Builtins: builtinEnvWithScope :: Int64 -> [Thunk] -> [(Text, Thunk)] -> Env
+ Nix.Builtins: builtinEnvWithScope :: EvalPolicy -> Int64 -> [Thunk] -> [(Text, Thunk)] -> Env
- Nix.Builtins: parseNixPath :: Text -> [Thunk]
+ Nix.Builtins: parseNixPath :: ByteString -> [Thunk]
- Nix.Builtins: splitNixPath :: Text -> [Text]
+ Nix.Builtins: splitNixPath :: ByteString -> [ByteString]
- Nix.Config: ConfigAssignment :: Text -> Text -> ConfigAssignment
+ Nix.Config: ConfigAssignment :: ByteString -> ByteString -> ConfigAssignment
- Nix.Config: NixConfig :: [Text] -> [Text] -> NixConfig
+ Nix.Config: NixConfig :: [Text] -> [Text] -> Word32 -> [Text] -> Bool -> Bool -> NixConfig
- Nix.Config: [caName] :: ConfigAssignment -> Text
+ Nix.Config: [caName] :: ConfigAssignment -> ByteString
- Nix.Config: [caValue] :: ConfigAssignment -> Text
+ Nix.Config: [caValue] :: ConfigAssignment -> ByteString
- Nix.Config: applyAssignment :: NixConfig -> ConfigAssignment -> NixConfig
+ Nix.Config: applyAssignment :: NixConfig -> ConfigAssignment -> Either Text NixConfig
- Nix.Config: resolveConfig :: [Text] -> Either Text NixConfig
+ Nix.Config: resolveConfig :: [ConfigAssignment] -> Either Text NixConfig
- Nix.DependencyGraph: buildDepGraph :: (StorePath -> Either Text Derivation) -> Derivation -> StorePath -> Either Text DepGraph
+ Nix.DependencyGraph: buildDepGraph :: Monad m => (StorePath -> m (Either Text Derivation)) -> Derivation -> StorePath -> m (Either Text DepGraph)
- Nix.Eval: doesPathExist :: MonadEval m => Text -> m Bool
+ Nix.Eval: doesPathExist :: MonadEval m => PathExistence -> Text -> m Bool
- Nix.Eval: getEnvVar :: MonadEval m => Text -> m Text
+ Nix.Eval: getEnvVar :: MonadEval m => ByteString -> m ByteString
- Nix.Eval.CAttrSet: cattrsetNew :: Word32 -> IO CAttrSet
+ Nix.Eval.CAttrSet: cattrsetNew :: Int -> IO CAttrSet
- Nix.Eval.CThunk: cthunkSetComputed :: CThunkPtr -> Ptr () -> IO (Ptr ())
+ Nix.Eval.CThunk: cthunkSetComputed :: CThunkPtr -> Ptr () -> IO Bool
- Nix.Eval.CThunk: cthunkSetComputedAttrs :: CThunkPtr -> Ptr () -> IO (Ptr ())
+ Nix.Eval.CThunk: cthunkSetComputedAttrs :: CThunkPtr -> Ptr () -> IO Bool
- Nix.Eval.CThunk: cthunkSetComputedBool :: CThunkPtr -> Word8 -> IO (Ptr ())
+ Nix.Eval.CThunk: cthunkSetComputedBool :: CThunkPtr -> Word8 -> IO Bool
- Nix.Eval.CThunk: cthunkSetComputedCtxStr :: CThunkPtr -> Ptr () -> IO (Ptr ())
+ Nix.Eval.CThunk: cthunkSetComputedCtxStr :: CThunkPtr -> Ptr () -> IO Bool
- Nix.Eval.CThunk: cthunkSetComputedFloat :: CThunkPtr -> Double -> IO (Ptr ())
+ Nix.Eval.CThunk: cthunkSetComputedFloat :: CThunkPtr -> Double -> IO Bool
- Nix.Eval.CThunk: cthunkSetComputedInt :: CThunkPtr -> Int64 -> IO (Ptr ())
+ Nix.Eval.CThunk: cthunkSetComputedInt :: CThunkPtr -> Int64 -> IO Bool
- Nix.Eval.CThunk: cthunkSetComputedLambda :: CThunkPtr -> Ptr () -> IO (Ptr ())
+ Nix.Eval.CThunk: cthunkSetComputedLambda :: CThunkPtr -> Ptr () -> IO Bool
- Nix.Eval.CThunk: cthunkSetComputedList :: CThunkPtr -> Ptr () -> IO (Ptr ())
+ Nix.Eval.CThunk: cthunkSetComputedList :: CThunkPtr -> Ptr () -> IO Bool
- Nix.Eval.CThunk: cthunkSetComputedNull :: CThunkPtr -> IO (Ptr ())
+ Nix.Eval.CThunk: cthunkSetComputedNull :: CThunkPtr -> IO Bool
- Nix.Eval.CThunk: cthunkSetComputedPath :: CThunkPtr -> Word32 -> IO (Ptr ())
+ Nix.Eval.CThunk: cthunkSetComputedPath :: CThunkPtr -> Word32 -> IO Bool
- Nix.Eval.CThunk: cthunkSetComputedStr :: CThunkPtr -> Word32 -> IO (Ptr ())
+ Nix.Eval.CThunk: cthunkSetComputedStr :: CThunkPtr -> Word32 -> IO Bool
- Nix.Eval.CThunk: cthunkSetPayload :: CThunkPtr -> Ptr () -> IO ()
+ Nix.Eval.CThunk: cthunkSetPayload :: CThunkPtr -> Ptr () -> IO Bool
- Nix.Eval.Compile: BcInherit :: [Word32] -> BcBinding
+ Nix.Eval.Compile: BcInherit :: Word32 -> Word32 -> BcBinding
- Nix.Eval.Compile: BcInheritFrom :: Word32 -> [Word32] -> BcBinding
+ Nix.Eval.Compile: BcInheritFrom :: Word32 -> [BcInheritedName] -> BcBinding
- Nix.Eval.Compile: BcNamed :: [BcAttrKey] -> Word32 -> BcBinding
+ Nix.Eval.Compile: BcNamed :: BcAttrKey -> Word32 -> BcBinding
- 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: 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] -> CallDepth -> EvalPolicy -> IORef AllowedPaths -> EvalState
- Nix.Eval.IO: NixAbortError :: Text -> NixAbortError
+ Nix.Eval.IO: NixAbortError :: EvalFailure -> NixAbortError
- Nix.Eval.IO: NixEvalError :: EvalErrorKind -> Text -> NixEvalError
+ Nix.Eval.IO: NixEvalError :: EvalErrorKind -> EvalFailure -> NixEvalError
- Nix.Eval.StringInterp: stripIndentedChunks :: [(Bool, ByteString, StringContext)] -> (ByteString, StringContext)
+ Nix.Eval.StringInterp: stripIndentedChunks :: [StringChunk] -> (ByteString, StringContext)
- Nix.Eval.Types: checkedCPtr :: String -> Ptr a -> Ptr a
+ Nix.Eval.Types: checkedCPtr :: String -> Ptr a -> IO (Ptr a)
- Nix.Eval.Types: doesPathExist :: MonadEval m => Text -> m Bool
+ Nix.Eval.Types: doesPathExist :: MonadEval m => PathExistence -> Text -> m Bool
- Nix.Eval.Types: getEnvVar :: MonadEval m => Text -> m Text
+ Nix.Eval.Types: getEnvVar :: MonadEval m => ByteString -> m ByteString
- Nix.Eval.Types: mkSyntheticThunk :: Env -> Expr -> Thunk
+ Nix.Eval.Types: mkSyntheticThunk :: Env -> Word32 -> Thunk
- Nix.Expr.Resolve: resolveVars :: Expr -> Expr
+ Nix.Expr.Resolve: resolveVars :: Set Text -> Expr -> Either Text Expr
- Nix.Expr.Types: Inherit :: Maybe Expr -> [Text] -> Binding
+ Nix.Expr.Types: Inherit :: Text -> Expr -> Binding
- Nix.Expr.Types: NamedBinding :: AttrPath -> Expr -> Binding
+ Nix.Expr.Types: NamedBinding :: AttrKey -> Expr -> Binding
- Nix.Hash: compressHash :: Int -> [Word8] -> [Word8]
+ Nix.Hash: compressHash :: Int -> ByteString -> ByteString
- Nix.Parser: parseNix :: FilePath -> Text -> Text -> Either ParseError Expr
+ Nix.Parser: parseNix :: FilePath -> Text -> Text -> Either SourceError Expr
- Nix.Parser: parseNixFile :: FilePath -> IO (Either ParseError Expr)
+ Nix.Parser: parseNixFile :: FilePath -> IO (Either SourceError Expr)
- Nix.Store: caseHackDiskNames :: [Text] -> [(Text, Text)]
+ Nix.Store: caseHackDiskNames :: CaseSensitivity -> [ByteString] -> [(ByteString, ByteString)]
- Nix.Store: newNarUnpackSink :: FilePath -> IO NarUnpackSink
+ Nix.Store: newNarUnpackSink :: CaseSensitivity -> FilePath -> IO NarUnpackSink
- Nix.Store: orderLinks :: [(FilePath, Text)] -> [(FilePath, Text)]
+ Nix.Store: orderLinks :: [(OsPath, OsPath)] -> [(OsPath, OsPath)]
- Nix.Store: unpackNarEntry :: FilePath -> NarEntry -> IO (Either Text ())
+ Nix.Store: unpackNarEntry :: CaseSensitivity -> FilePath -> NarEntry -> IO (Either Text ())
- Nix.Store.ExecBit: narHashOfPath :: FilePath -> IO NixHash
+ Nix.Store.ExecBit: narHashOfPath :: CaseHack -> FilePath -> IO NixHash
- Nix.Store.ExecBit: serialiseFromPath :: FilePath -> IO NarEntry
+ Nix.Store.ExecBit: serialiseFromPath :: CaseHack -> FilePath -> IO NarEntry
- Nix.Substituter: consumeNarStream :: FilePath -> NarInfo -> NixHash -> IO ByteString -> IO (Either AttemptFailure Int)
+ Nix.Substituter: consumeNarStream :: CaseSensitivity -> FilePath -> NarInfo -> NixHash -> IO ByteString -> IO (Either AttemptFailure Int)
Files
- CHANGELOG.md +123/−0
- README.md +73/−26
- app/Main.hs +541/−252
- cbits/nn_arena.c +10/−0
- cbits/nn_arena.h +12/−1
- cbits/nn_attrset.c +45/−37
- cbits/nn_attrset.h +25/−8
- cbits/nn_bytecode.c +27/−1
- cbits/nn_bytecode.h +40/−5
- cbits/nn_ctxstr.c +9/−7
- cbits/nn_darwinfs.c +31/−0
- cbits/nn_darwinfs.h +18/−0
- cbits/nn_env.c +13/−6
- cbits/nn_env.h +6/−1
- cbits/nn_lambda.c +10/−7
- cbits/nn_list.c +37/−15
- cbits/nn_list.h +16/−2
- cbits/nn_symbol.c +80/−61
- cbits/nn_symbol.h +21/−4
- cbits/nn_thunk.c +66/−71
- cbits/nn_thunk.h +40/−29
- cbits/nn_winfs.h +4/−2
- nova-nix.cabal +57/−16
- src/Nix/Builder.hs +185/−107
- src/Nix/Builder/Unpack.hs +13/−9
- src/Nix/Builtins.hs +263/−110
- src/Nix/Config.hs +608/−92
- src/Nix/DependencyGraph.hs +17/−15
- src/Nix/Derivation.hs +22/−0
- src/Nix/Derivation/StructuredAttrs.hs +177/−0
- src/Nix/Environment.hs +67/−0
- src/Nix/Eval.hs +5692/−5407
- src/Nix/Eval/Arena.hs +36/−5
- src/Nix/Eval/AttrPath.hs +2/−23
- src/Nix/Eval/CAttrSet.hs +30/−10
- src/Nix/Eval/CBytecode.hs +53/−6
- src/Nix/Eval/CList.hs +60/−11
- src/Nix/Eval/CStatus.hs +111/−0
- src/Nix/Eval/CThunk.hs +107/−84
- src/Nix/Eval/CallDepth.hs +67/−0
- src/Nix/Eval/CanonPath.hs +4/−0
- src/Nix/Eval/Compile.hs +78/−56
- src/Nix/Eval/Context.hs +26/−1
- src/Nix/Eval/IO.hs +653/−172
- src/Nix/Eval/Operator.hs +143/−101
- src/Nix/Eval/Policy.hs +228/−0
- src/Nix/Eval/Print.hs +381/−0
- src/Nix/Eval/SourceError.hs +57/−0
- src/Nix/Eval/StringInterp.hs +174/−187
- src/Nix/Eval/Symbol.hs +25/−11
- src/Nix/Eval/Types.hs +194/−84
- src/Nix/Expr/ClosureTrim.hs +43/−31
- src/Nix/Expr/Resolve.hs +297/−160
- src/Nix/Expr/Types.hs +20/−4
- src/Nix/Hash.hs +17/−14
- src/Nix/HostPath.hs +64/−0
- src/Nix/Http.hs +318/−0
- src/Nix/Json.hs +414/−0
- src/Nix/Parser.hs +41/−8
- src/Nix/Parser/Expr.hs +37/−31
- src/Nix/Parser/Lexer.hs +19/−6
- src/Nix/Push.hs +45/−20
- src/Nix/Store.hs +502/−387
- src/Nix/Store/CaseSensitive.hs +110/−11
- src/Nix/Store/DB.hs +62/−0
- src/Nix/Store/EntryName.hs +185/−0
- src/Nix/Store/Exclusive.hs +214/−0
- src/Nix/Store/ExecBit.hs +45/−24
- src/Nix/Store/GC.hs +637/−0
- src/Nix/Store/Handle.hs +138/−0
- src/Nix/Store/Lock.hs +65/−14
- src/Nix/Store/Path.hs +44/−0
- src/Nix/Store/Symlink.hs +89/−0
- src/Nix/Substituter.hs +263/−276
- test/FetchurlFixture.hs +0/−91
- test/HttpFixture.hs +119/−0
- test/Main.hs +16557/−11220
CHANGELOG.md view
@@ -1,5 +1,128 @@ # Changelog +## 0.9.0.0 - 2026-10-31++On nixpkgs 26.05 and unstable, where 0.8.0.0 failed, CI now holds 22 derivations to the store paths upstream computes, on Nix 2.34.9 and 2.35.4 respectively, alongside Nix 2.24.9 with nixpkgs 24.11.++### Library API++Breaking changes for code that uses nova-nix as a library:++- **`Nix.Expr.Types.Binding`'s `NamedBinding` holds one `AttrKey` where it held an `AttrPath`,** as upstream's `ExprAttrs` holds one name per definition (nixexpr.hh at 2.24.9). The parser already hoisted `a.b.c = v;` into nested sets before evaluation; it now does so as it parses, so no binding carries a path. `Nix.Eval.Compile.BcNamed` holds one `BcAttrKey`, and a named binding's bytecode drops its path-length word. `Nix.Eval.CStatus.CStatusError` includes `CInvariantViolated`, raised through `cInvariantViolated`. In `Nix.Eval.CThunk`, `cthunkNull :: CThunkPtr` and `cthunkBool :: Bool -> CThunkPtr` replace `cthunkNewComputedNull` and `cthunkNewComputedBool`, and the `cthunkSetComputed` setters and `cthunkSetPayload` return `Bool`, whether the thunk took the value, where the setters returned the pending payload. `Nix.Eval.CAttrSet.cattrsetNew` takes the expected entry count as an `Int`, and it, `cattrsetInsert`, `cattrsetUnion`, `cattrsetRemoveKeys` and `Nix.Eval.CList.clistNew` raise `CStatusError` when the C side refuses. (#19)+- **`formatXmlFloat` moved to `Nix.Eval.Print` from `Nix.Eval.StringInterp`, which now prints the value its coercion failure names.** `Nix.Eval.Operator.evalBinary` no longer evaluates `OpAdd`, which its caller handles as it handles the short-circuit operators, because `+` looks at its left operand before the right is evaluated and coerces through the evaluator; `Nix.Eval.Operator` exports `addToInteger` and `addToFloat`, the arithmetic once the left operand is a number. `Nix.Eval.Types` exports `thunkUnderEvaluation`, whether a thunk is being forced, and `typeName` returns upstream's `showType` names. (#250)+- **`Nix.Hash.compressHash` takes and returns a `ByteString`,** the digest it folds and the bytes it folds it to, where both were `[Word8]`. (#48)+- **`Nix.Config` resolves configuration through a file-reading effect, so includes and the file cascade are testable against an in-memory map.** `loadConfig` reads, expands and folds a weakest-first path list plus the `NIX_CONFIG` bytes over a `ReadConfigFile m` (`OsPath -> m (Either ConfigReadFailure ByteString)`, the failure saying whether the file is missing or merely unreadable, which decides how a failed `include` is reported); `expandConfigSource` splices one `ConfigSource`'s includes; `parseConfigLines` replaces `parseConfigText` and yields `ConfigLine`, an assignment or an `IncludeMode` include; and `ConfigLocations` with `configFilePaths` computes upstream's file order from the raw environment and the platform's directories, which the caller supplies so the module carries no platform literal, refusing a relative `NIX_CONF_DIR` as upstream does. `resolveConfig` now folds a flat `[ConfigAssignment]`, failing only on a value a setting cannot take, and `applyConfigText` is gone, since a source's text cannot be applied without the files it includes. (#199)+- **`Nix.Config` carries a source as bytes and its paths as `OsPath`.** `ConfigSource` holds an `OsPath` name and `ByteString` text, `ReadConfigFile m` is `OsPath -> m (Either ConfigReadFailure ByteString)`, `loadConfig` takes `[OsPath]` and the `NIX_CONFIG` bytes, and `configFilePaths` returns `[OsPath]`. `ConfigLocations` takes `NIX_CONF_DIR` and `NIX_USER_CONF_FILES` as the bytes `Nix.Environment.lookupEnvBytes` returns and its directories as `OsPath`. `ConfigLine`'s include target and `ConfigAssignment`'s name and value are `ByteString`, `parseConfigLines` takes an `OsPath` and a `ByteString`, `parseUnsignedSetting` takes `ByteString`s, and `nixConfFileName`, `nixConfDirName` and `nixConfigSourceName` are `OsPath`. The new module `Nix.HostPath` spells bytes as a host path (`hostPathFromBytes`), the spelling `Nix.Store.EntryName` now uses too, and shows one in a message (`hostPathText`). The executable's `filepath` and `directory` floors rise to 1.4.100 and 1.3.8, the first with `System.OsPath` and `System.Directory.OsPath`, as the library's did, and it depends on `file-io` >= 0.1.1 to read a file by its `OsPath`, 0.1.1 being the first whose read does not create a missing file on Windows. (#244)+- **`Nix.Eval.Types.MonadEval` has a new method with no default, `addErrorTrace`, and `Nix.Eval.IO`'s `NixEvalError` and `NixAbortError` carry an `EvalFailure`, a message with its lines of context, where they carried the message alone.** Every instance defined outside this package needs the method. `runEvalIO` still returns the message; the new `runEvalIOTraced` returns the whole `EvalFailure`, and `renderEvalFailure` writes it as upstream reports an error. (#221)+- **`Nix.Eval.Types.NixValue` drops `VDerivation`.** The constructor existed only to carry the build recipe through the derivation value's private `_derivation` attribute, which no longer exists; nothing constructed it any more and no Nix operation could observe one. Exhaustive matches over `NixValue` lose a case. The recipe a build needs is the `.drv` ATerm the evaluator records under its path during evaluation (`Nix.Eval.IO.EvalState`'s `esDrvClosure`, or `lookupSessionDrv` inside the evaluator), which is what the build driver and the test harness now read. (#217) `Nix.Eval` now exports `deferApply`, the shared thunk that applies one value to another when forced; the derivation wrapper's fallback uses it, and library code that built the same synthetic thunk by hand can too. (#230)+- **`Nix.Eval.Types.MonadEval`'s `getEnvVar` takes the bytes of a variable's name and returns the bytes of its value,** `ByteString` both ways where it was `Text`, so every instance defined outside this package changes with it. `Nix.Builtins.parseNixPath`, `splitNixPath` and `isNixPathPseudoUrl` take a `ByteString` and `searchPathRoots` returns them, since a `NIX_PATH` entry is bytes. The new module `Nix.Environment` reads one variable as bytes (`lookupEnvBytes`, answering an `EnvLookup`). Outside Windows the read goes through `unix`'s `System.Posix.Env.ByteString`. (#241)+- **`Nix.Eval.Print` exports `printAmbiguous`, the `ByteString` `Builder` for what `nova-nix eval` prints,** after upstream's printer of the same name. (#259)+- **`Nix.Eval.CList` exports `clistIndex` and `clistDrop`,** a bounds-checked read of one element and the list without its first elements, which shares the source's C array through the C layer's new `nn_list_drop`. (#263)+- **`Nix.DependencyGraph.buildDepGraph` reads each `.drv` in the caller's monad,** `(Monad m) => (StorePath -> m (Either Text Derivation)) -> Derivation -> StorePath -> m (Either Text DepGraph)`, where it took a pure reader. The build driver passes a read in `IO`, which replaces the `unsafePerformIO` read the pure signature forced on it; a pure lookup runs under `Data.Functor.Identity`. (#251)+- **`Nix.Eval.Types.MonadEval` has a new method with no default, `withCallFrame`, and `Nix.Eval.IO.EvalState` a new strict field, `esCallDepth`.** Every instance defined outside this package needs the method; `newEvalState` fills the field with upstream's default ceiling, and a caller with a configured one replaces it. The new module `Nix.Eval.CallDepth` holds the counter type, the default and the entry rule. `Nix.Eval.CBytecode` exports the `OpApp` flag values `appDirect` and `appDeferred`. `Nix.Config.NixConfig` gains `ncMaxCallDepth`, `Nix.Config.applyAssignment` now returns `Either Text NixConfig` because a scalar setting's value can fail to parse, and `Nix.Config.parseUnsignedSetting` is exported. (#222)+- **`Nix.Store.EntryName` decides which NAR entry names and symlink targets a host can hold, replacing `Nix.Store.isSafeNarName`.** `NameRules` (`PosixNames`, `DarwinNames`, `WindowsNames`), `hostNameRules`, and the pure `checkEntryName` and `checkLinkTarget` answer for any platform on any platform; `entryNamePath` and `linkTargetPath` spell an admitted name or target as the running host's `OsPath`, and `shownBytes` renders one in a refusal. `onDiskNameKey` and `caseHackDiskNames` take the `ByteString` names a NAR carries instead of `Text`, `orderLinks` orders `(OsPath, OsPath)` pairs, and `Nix.Store.ExecBit` gains `markExecutableOsPath`. The library's floors rise to directory >= 1.3.8 and filepath >= 1.4.100, the first releases with the `OsPath` APIs it now calls, and the test suite's filepath floor with it. (#84)+- **`Nix.Eval.Types.MonadEval` has three new methods with no defaults,** `evalPolicy`, `checkUri` and `resolveSymlinks`, and `doesPathExist` takes a `PathExistence` first argument (`ExistsAsEntry` or `ExistsAsDirectory`, the two questions `builtins.pathExists` asks); every instance defined outside this package needs them. The new module `Nix.Eval.Policy` holds the policy record (`EvalPolicy`, `unrestrictedPolicy`) and the pure decisions behind the modes (`isAllowedPath`, `isAllowedUri`, `uriAccess` and their messages), and `Nix.Eval.Types` re-exports the record. `Nix.Builtins.builtinEnv` and `builtinEnvWithScope` take an `EvalPolicy` first argument, since the impure constants exist by policy; `Nix.Builtins.searchPathRoots` reads the roots back from a search path, and `isNixPathPseudoUrl` says which entries name a download. `Nix.Eval.IO.EvalState` gains `esPolicy` and `esAllowedPaths`, with `allowEvalPath` to seed the allowed roots. `Nix.Eval` exports `FetchGitArgs (..)` and `gitInputUrl`, the URL form a `fetchGit` input is checked and reported in. `Nix.Config.NixConfig` gains `ncAllowedUris`, `ncRestrictEval` and `ncPureEval`. (#75)+- **`Nix.Eval.Types.mkSyntheticThunk` takes the bytecode index of the code it runs instead of an `Expr`, and every bytecode store opens with five reserved instructions.** A caller compiles once (`Nix.Eval.Compile.compileExpr`) and passes the index, rather than having the code compiled again for each thunk. `Nix.Eval.CBytecode` exports `reservedApply1` and `reservedApply2`, the deferred applications of an env's slots a store starts with, so a fresh store's `cbcOpCount` is 5 and the first instruction compiled lands at index 5. `Nix.Eval.Compile.BcInheritFrom` carries a `BcInheritedName` per name, its symbol and the bc_idx of the select that reads it, where it carried the symbols alone, and the `inherit (from)` encoding stores that select after each symbol. (#269)+- **`Nix.Parser.parseNix` returns `Either SourceError Expr`, and a plain `inherit` is a binding of its own in the AST.** `SourceError` is `SyntaxError ParseError`, the failure it returned before, or `UndefinedVariable Text`, a variable binding found unbound. `parseNix` binds against the root environment's names under the default policy; `parseNixWithScope` binds against the names given, which for evaluation under a policy are `Nix.Builtins.rootScopeNames`, plus a `scopedImport` scope's. `Nix.Expr.Resolve.resolveVars` takes those names and returns `Either Text Expr`; `staticGlobalNames` is now upstream's whole base environment, `impureOnlyGlobalNames` the part `pure-eval` drops, and `undefinedVariableMessage` upstream's wording for the failure. `Nix.Expr.Types.Binding`'s `Inherit (Maybe Expr) [Text]` becomes `Inherit Text Expr`, one per name and holding the variable it copies, and `InheritFrom Expr [Text]`; every match on `Binding` outside this package must cover both. In the bytecode, `Nix.Eval.Compile.BcInherit` carries a symbol and the variable's bc_idx instead of a list of symbols, and `Nix.Eval.CBytecode.bindInheritFrom` is the tag of an `inherit (from)` binding, whose encoding no longer carries a has-from word. (#248)+- **`Nix.Store.Store` has a new strict field, `stGcLease`, `openStore` keys the handle by the directory's canonical spelling, and the store modules grow a garbage-collection surface.** Code that builds a `Store` with the constructor needs the field; `openStore` fills it, and `stDir` is now `canonicalStoreDir` of what was passed. `Nix.Store.GC` is new (`collectGarbage`, which returns `Either` with the refusal of a database keyed under another spelling of the directory, `findRoots`, `addOutLinkRoot`, the pure `reachableFrom`, `referencesMap` and `sweepPlan` with its `SweepPlan`, the abstract `LiveSet` with `withLiveSet` and `isLive`, `GcRoot`, `GcResults`, `gcSummaryLine`, `showFreedBytes`, the roots directory names), as are `Nix.Store.Handle` (`Store`, `openStore`, `closeStore`, `canonicalStoreDir`, `withCollectorLock`, `gcLockFilePath`) and `Nix.Store.Symlink` (`WalkNode`, `classifyWalkNode`, `createSymlinkOfKind`); `Nix.Store` re-exports the first two. `Nix.Store.Lock` adds `LockMode`, `acquireLockFileWith`, `withLockFileWith`, `waitingForLockMessage` and `lockedPathOf`; `Nix.Store.Path` adds `parseStorePathPrefix`, upstream's `toStorePath`; `Nix.Store.DB` adds `queryAllReferences` and the batch `unregisterPathRows`. `deleteStorePathRaw` now takes the collector lock and refuses a path reachable from a root before its referrer check, and `deleteStorePathChecked` is the same delete given a `LiveSet` from `withLiveSet`, for a batch under one lock. (#24)+- **The new module `Nix.Json` holds the JSON value type (`Json`), its rendering as upstream's serializer writes it (`renderJson`, `renderJsonWith`) and a parser that reads JSON as upstream's does (`parseJson`); `formatJsonFloat` moved there from `Nix.Eval.StringInterp`.** The new module `Nix.Derivation.StructuredAttrs` holds the `__json` entry's key, its encoding and decoding, and what a builder makes of it (`withOutputPlaceholders`, `renderAttrsJson`, `renderAttrsShell`, `stringsAttr`). (#36)+- **`Nix.Substituter.httpStatusFailure` is gone, and the transfer layer's retry policy and failure classification live in `Nix.Http`.** `fetchStatusFailure`, which takes the URL and the response's `Status`, replaces it. `Nix.Http` exports `AttemptFailure (..)`, `attemptFailureMessage` and `catchSync`, which `Nix.Substituter` still re-exports; the retry policy (`FetchRetryPolicy (..)`, `defaultFetchRetryPolicy`, `RetryEffects (..)`, `ioRetryEffects`, `retryTransient`, `retryDelayMs`); and the classification (`TransferError (..)` and `statusError`, upstream's verdict on a status, `withTransfer` and `transferBodyReader`, which raise every failure of the connection as an `HttpException`, `transferFailureHandlers`, `fetchStatusFailure`, `fetchExceptionFailure`, which takes an `HttpException` rather than any exception, and `fetchWriteFailure`, for an `IOException` writing a download). `Nix.Substituter.trySubstituteWith` substitutes under a given policy and effects; `trySubstitute` is it at upstream's default, and a build passes its `bcFetchRetry`. (#243)+- **`Nix.Substituter` gains `VerifiedNar`, the only input its strict unpack sink accepts, and stops re-exporting `unpackNarEntry`.** `unpackAndVerify` checked the downloaded bytes against the narinfo's signed NarHash and NarSize before deserializing and writing them, but the bytes had the same `ByteString` type on both sides of the check, so only statement order kept unverified bytes away from the disk writes, and the module's re-export of `unpackNarEntry` offered an unpack that skipped the check altogether. `verifyNar` is now the sole way to obtain a `VerifiedNar` (its constructor is not exported), a module-private sink is the only consumer of one, and `unpackAndVerify` is their composition, so the order is a type the compiler enforces. The wrapper carries the decoded declared digest rather than standing as a bare witness: the registration records its canonical spelling and the on-disk recheck compares against it, where the recheck used to parse the narinfo's NarHash a second time, and the strict recheck now compares a digest exactly as the streaming one does. `unpackNarEntry` stays in `Nix.Store`, which is where its eval-side caller (`builtins.path`, unpacking a NAR this process just serialized) imports it from; the substituter reaches it only through that sink. Upstream has no such type: `LocalStore::addToStore` (local-store.cc at 2.24.9) restores the path through a `TeeSource` over a `HashSink` and compares hash and size after the restore, the order the streaming pipeline mirrors with its cleanup contract; the strict path verifies first, and the wrapper makes that first step unskippable. (#226)+- **`Nix.Expr.Types.StringPart` has a new constructor, `StrEsc`, and `Nix.Eval.StringInterp.stripIndentedChunks` takes a list of the new `StringChunk` type.** An indented-string escape (`'''`, `''$`, `''\n`) was parsed as a constant interpolation, which made it indistinguishable from `${"..."}` by the time indentation was stripped, and upstream strips the two differently. `StrEsc` carries the resolved text as a part of its own, so exhaustive matches over `StringPart` need the case. `StringChunk` (`ChunkLit`, `ChunkEsc`, `ChunkInterp` with its context) replaces the `(Bool, ByteString, StringContext)` triple so an evaluated part keeps its kind, and `concatChunks` joins chunks without stripping, as a double-quoted string does. (#227)+- **The new module `Nix.Eval.Print` exports `printValue`, upstream's value printer, under `PrintInFull` or `PrintForError`.** `Nix.Eval.Operator` exports `evalUpdate`, which takes each operand as an action so the left is checked before the right is evaluated. (#247)+- **`unpackNarEntry`, `newNarUnpackSink`, `caseHackDiskNames`, `onDiskNameKey` and `Nix.Substituter.consumeNarStream` take a `CaseSensitivity` first argument, and `Store` has a new strict field,** `stCaseSensitivity :: CaseSensitivity`. The argument is the destination volume's answer: a caller unpacking into a `Store` passes the field, and one unpacking elsewhere says what kind of volume it writes to instead of inheriting the operating system's default. `Nix.Store.CaseSensitive` exports the type, re-exported from `Nix.Store`, and `probeCaseSensitivity`. (#68)+- **`Nix.Store.ExecBit.serialiseFromPath`, `withNarSource` and `narHashOfPath` take a `NovaCache.NAR.CaseHack` first argument, and `Nix.Store.CaseSensitive` exports `volumeCaseHack` and `processCaseHack`,** re-exported from `Nix.Store`. A store path is read with `volumeCaseHack (stCaseSensitivity store)`, and a source with `processCaseHack`, nova-cache's per-OS default. (#236)+- **`Nix.Store` exports `sinkNarStream` and `NarStreamFailure`, the streaming unpack loop the substituter and the source restore share, and `Nix.Store.ExecBit` exports `withNarSource`.** (#235)+- **Further API changes:** `MonadEval` has a new method with no default, `lookupSessionReferences`. `Nix.Parser.parseNixFile` returns `Either SourceError Expr`. The new module `Nix.Eval.SourceError` holds upstream's messages for a failed read of a source path (`SourceAccess`, `sourceErrorMessage`). `Nix.Eval.Operator` stops exporting `checkedAdd`, `checkedSub` and `checkedMul`. `Nix.Derivation.Platform` gains `I686_Linux` and `I686_Windows`, and `Nix.Builder.BuildConfig` gains `bcFetchRetry`.++### CLI++- **`eval` prints its result as `nix-instantiate --eval` does: a string as its bytes, escaped as upstream escapes it, an attribute name quoted when it is not an identifier, a float to six significant digits, and upstream's markers for an unforced element and a function.** A string is bytes, and the printer decoded it as UTF-8 with replacement before writing it, so `builtins.readFile` of a file holding `a`, the byte 0xFC and `b` printed the three bytes of U+FFFD in the middle, where `nix-instantiate --eval` prints the byte itself and `builtins.stringLength` of the same value is 3. The result now reaches stdout as bytes, with no decode, encoding or newline translation on the way, so on Windows the line ends in LF, as `--aterm` output already did, where it ended in CRLF. The rules are upstream's `printAmbiguous` (print-ambiguous.cc at 2.24.9). A string goes through `printLiteralString` (print.cc): `"` and `\` take a backslash, a newline, carriage return and tab print as `\n`, `\r` and `\t`, and a `$` followed by `{` prints as `\$`, the escape that was missing, so `"\${"` printed as `"${"`, which reads back as an interpolation. An attribute name goes through `printIdentifier`: bare when the lexer reads it back as an identifier and quoted otherwise, so `{ "a b" = 1; "if" = 2; }` prints as written instead of as `{ a b = 1; if = 2; }`. A float is written to a default ostream, so `1.0` prints as `1`, `1.23456789` as `1.23457` and `1234567.0` as `1.23457e+06`, where Haskell's `show` gave `1.0`, `1.23456789` and `1234567.0`. An unforced element prints as `<CODE>` and a function as `<LAMBDA>`, `<PRIMOP>` or `<PRIMOP-APP>`, where nova-nix wrote `<thunk>`, `<lambda>` and `<builtin NAME>`. Every case was compared byte for byte with nix-instantiate 2.33.2. A list or set reached twice under `--strict` still prints in full where upstream writes its `repeated` marker, which needs value identity this printer does not track. (#252)+- **An interrupted command reports `error: interrupted by the user` and exits 1.** A SIGINT reached the CLI's main thread as GHC's `UserInterrupt`, which, uncaught, ended the process by the signal (exit 130) with nothing printed. Upstream's `checkInterrupt` throws `Interrupted("interrupted by the user")`, which `handleExceptions` (libmain/shared.cc at 2.24.9) logs as an error and exits 1 with; the CLI now does the same, and every other asynchronous exception still propagates. (#287)+- **`push --all` selects every valid output path, and a named derivation is refused with the reason.** A store registers its derivations as valid paths like anything else, so `--all` sent them too, and a binary cache serves build outputs only: nova-cache refuses a narinfo for a `.drv` outright, after the path's NAR has already been uploaded. Derivations are recipes a substituter never asks a cache for, so they stay local, and naming one explicitly is refused before anything is uploaded. (#216)+- **`--restrict-eval` and `--pure-eval` on `eval` and `build`, and `restrict-eval`, `pure-eval` and `allowed-uris` in nix.conf and NIX_CONFIG.** Evaluation ran with the process's full ambient authority: any expression could read any file, reach the environment, and fetch from the network. The two flags turn on upstream's modes for one run, and the three settings turn them on for a machine, through the same cascade as `substituters` (a Boolean takes upstream's `true`, `yes`, `1`, `false`, `no`, `0` spellings and any other value is an error; `extra-allowed-uris` appends, and `extra-` on a Boolean names no setting). A flag only turns a mode on, so a machine configured for pure evaluation stays pure whatever the command line says. Under either mode every `--nix-path` and `NIX_PATH` root is allowed, as upstream allows its lookup path, so `--restrict-eval --nix-path nixpkgs=...` evaluates nixpkgs and computes the same `hello.drvPath` as the default mode and as `nix-instantiate --option restrict-eval true`. A root reached through a symlinked directory (macOS's `/tmp` is `/private/tmp`) is allowed under its resolved spelling as well; upstream allows only the given one and refuses the read, a mismatch between its own allow list and its symlink walk rather than a rule. A `channel:`, `flake:` or URL entry names a download this evaluator does not perform and allows nothing; upstream allows such an entry's working-directory spelling only by falling through after a failed download, and a run that cannot fetch should not read more for it. Under `--pure-eval` neither `--nix-path` nor `NIX_PATH` contributes, as upstream drops its lookup path; the bundled `<nix/*>` directory remains, since upstream serves those files from a built-in filesystem and this binary serves them from its data directory, which is why it stays listed in `builtins.nixPath` where upstream shows an empty list. (#75)+- **`nova-nix store gc` collects garbage, `build --out-link PATH` (or `-o PATH`) roots a result, and `store delete` refuses a path a root keeps alive.** `store gc` finds the roots, walks their references, removes every other valid path with its tree, sweeps every store directory entry that is not live (a tree no row describes from a build interrupted before registration, junk, a dead path's lock file) and prints upstream's progress lines on stderr and its summary (`N store paths deleted, X MiB freed`, the `%.2f MiB` of 2.24.9) on stdout; `store gc --print-roots` lists `LINK -> PATH` sorted, each link once, and deletes nothing, as `nix-store --gc --print-roots` does, and like it takes no collector lock, so it does not wait behind a running build. `build --out-link PATH` is nix-build's `-o`: the path is made absolute with `.` and `..` collapsed and its parent directories are created, as upstream's `addPermRoot` and `makeSymlink` do, then `PATH` is created as a symlink to the result and registered as a root, refusing a link inside the store (through `..` as well) or an existing `PATH` that is not a store link (a store link is replaced); the store path prints only once the link exists, so a failed link (Windows without Developer Mode) prints nothing a script would take for success. `build` creates no link unless `--out-link` is given, where nix-build and nix build default to `./result`. `store delete` of a path a root reaches is refused with upstream's words, `Cannot delete path '...' since it is still alive`, and the referrer refusal it already had stays. Upstream refuses a path with an unrooted referrer with the same still-alive line unless that referrer is named in the same invocation, in which case it deletes the named set referrer-first; this delete lists the referrers that stand in the way instead and deletes in argument order, so a chain deletes leaf-first. One invocation resolves every argument first, then runs under one collector lock with one live set, printing upstream's roots line once. The hint names `store gc --print-roots` rather than the `nix-store --query` forms this tool does not have. `--store DIR` is keyed by its canonical spelling (absolute, `.` and `..` collapsed, no trailing separator), as upstream's `absPath` keys a store path, so a store built under one spelling and collected under another is the same store; `store gc` and `store delete` refuse a store whose database holds paths under another spelling of its directory rather than sweep them as dead. (#24)+- **nix.conf `include` and `!include` directives are honored, and the system file and the full XDG cascade are read, in upstream's order.** An include splices the named file in at its line, resolved against the directory of the file being parsed with `.` and `..` collapsed lexically, so a nested include resolves against the nested file and an include from `NIX_CONFIG` must be absolute; `!include` goes on without a file that cannot be read, and `include` of a missing file fails with upstream's own `file '...' included from '...' not found`. The sources are now, weakest first: `$NIX_CONF_DIR/nix.conf` (default `/etc/nix`, an empty variable counting as unset, a relative one refused with upstream's `not an absolute path`, and `.` and `..` in it collapsed, as upstream's settings constructor canonicalizes it); the user files, which are `$NIX_USER_CONF_FILES` when it is set (first listed strongest, an empty value naming no files, empty entries dropped) and otherwise `nix/nix.conf` under the XDG config home and then under each `XDG_CONFIG_DIRS` entry, applied back to front so the config home wins; then `NIX_CONFIG`; then the flags. Every rule was read from `config.cc` and `globals.cc` at 2.24.9 and checked against the running 2.33.2 binary. Two deliberate differences: an `include` whose file exists but cannot be read is an error here (`file '...' included from '...' cannot be read`) where upstream silently drops it (its own TODO questions the swallow), because the only thing `include` promises over `!include` is to fail when the file is unusable, and an include is where a site narrows the substituter or trusted-key set, so dropping it leaves the wider set in force; and an include cycle is refused with a message naming both files where upstream recurses until it crashes. On Windows, where upstream compiles the same `/etc/nix` and `/etc/xdg` literals in and neither names anything, the system file is `%ProgramData%\nix\nix.conf`, the all-users application data directory being the Windows convention for machine-wide application configuration (chosen the way `%APPDATA%` was for the user file, and where the `directory` package maps the system-wide XDG dirs too), with no system file when `%ProgramData%` is unset; `XDG_CONFIG_DIRS` has no default there, since the one directory that answers to it is already read as the system file; `NIX_CONF_DIR` and `XDG_CONFIG_DIRS` are honored when set on every platform; a UNC `NIX_CONF_DIR` keeps its server through the collapse; and `NIX_USER_CONF_FILES` splits on `;` there, since upstream's `:` cannot carry a drive letter. (#199)+- **nix.conf is read as bytes, so an `include` target, `NIX_CONF_DIR` and `NIX_CONFIG` name the files and carry the values upstream's do.** A config file went through a lossy UTF-8 decode and the config variables through base's `lookupEnv` and a conversion to text, so a byte with no UTF-8 reading became U+FFFD, written back as EF BF BD when a file was opened: `include caf\xE9.conf`, a Latin-1 name on Linux, never opened the file it names (a required include failed as not found, an `!include` was dropped), a `NIX_CONF_DIR` holding such a byte named a system file that does not exist, so its settings silently did not apply, and an include beside a user file under such a directory (`NIX_USER_CONF_FILES`, `XDG_CONFIG_HOME`, `XDG_CONFIG_DIRS`) missed in the same way. Upstream keeps each line as bytes and opens the file the target's bytes name (`parseConfigFiles` in `src/libutil/config.cc` at Nix 2.24.9), reading the variables with `getenv`. A source now stays bytes until a setting takes a value from it: the variables are read as bytes, an include target and every config directory reach the open as a host path spelled from those bytes, and a comment or the value of a setting nova-nix does not model is never decoded. A line splits on upstream's separators alone (space, tab, CR, LF): every Unicode space split it before, so `pure-eval = true` followed by a no-break space read as true where upstream refuses the value, and an `allowed-uris` token holding a vertical tab or a form feed became two. A Boolean or integer value is compared as bytes, so such a byte there is upstream's own invalid-value error. Two differences remain. First, a `substituters`, `trusted-public-keys` or `allowed-uris` token with no UTF-8 reading is refused (`setting 'substituters' has invalid value '...': not valid UTF-8`) where upstream keeps the bytes, because each is text everywhere nova-nix uses it (a cache URL, a key matched against a narinfo's signatures, a URI prefix matched against the evaluator's URIs), and the U+FFFD spelling it silently took before names a different cache, key or URI. Second, a message still shows such a byte as U+FFFD where upstream writes the byte itself. On Windows, where a path is UTF-16, a target with no UTF-8 reading names no file, so `include` refuses it and `!include` goes on without it, and a config variable holding an unpaired surrogate is refused, as `NIX_PATH` holding one is. Checked against `nix config show` and `nix-instantiate` 2.33.2, the include case on an HFS+ volume, since APFS refuses such a name. (#244)+- **`NIX_PATH` keeps a `channel:` or `flake:` entry whole and splits an unlisted `scheme://` entry, as upstream's parser does.** A colon stayed inside its entry whenever `//` followed it, so `channel:nixos-24.11` broke into `channel` and `nixos-24.11` while `foo://x` was kept as one entry. Upstream's `EvalSettings::parseNixPath` (`src/libexpr/eval-settings.cc` at Nix 2.24.9) decides at an entry's first colon from the text after the entry's last `=`: `channel` and `flake` keep the entry whole whatever follows, `http`, `https`, `file`, `channel`, `git`, `s3` and `ssh` keep it whole when `//` follows, and the entry then extends through exactly one more colon-free run, so `https://example.com:8080/x` still splits after the host and `channel:channel:x` is `channel:channel` then `x`. The split now follows that rule, matched entry by entry against `builtins.nixPath` from `nix-instantiate` 2.33.2, whose parser is unchanged from 2.24.9. An empty entry is dropped, as upstream's `builtins.nixPath` drops it; it was not inert here, since `<name>` resolved against the filesystem root through it (`NIX_PATH=a::b` made `<etc>` resolve to `/etc`). An entry containing a space is kept whole where upstream's settings round trip would split it, since a Windows path may contain one. The Windows drive rule stays: `C:\x` and `nixpkgs=C:/x` are one entry each, where upstream, which has no drive rule, splits a drive path at its letter. Neither a URL nor a channel entry is fetched yet, so for those entries this changes what `builtins.nixPath` reports, not what `<nixpkgs>` resolves to. (#224)++### Evaluation++- **A derivation with `__structuredAttrs = true` passes its attributes as one JSON object, `__json`, and its drvPath matches upstream's.** Such a derivation was flattened like any other, every attribute coerced to a string, so a nested set or a list of sets failed with `cannot coerce a set to a string`, and nixpkgs packages that set the flag (libxo, sudo, cppitertools, nrr and lutok among them) did not evaluate. Upstream's derivationStrict (the `jsonObject` branch of `derivationStrictInternal`, primops.cc at 2.24.9) converts each attribute with the `printValueAsJSON` behind `builtins.toJSON` and stores the object, rendered by nlohmann's `dump()`, as the single environment entry `__json` beside one entry per output. `args` stays the argument list, `__structuredAttrs` and `__ignoreNulls` stay out of the object, and a null is left out only under `__ignoreNulls`. String context inside the object, a path copied to the store or a derivation's output, becomes the derivation's inputs as it does from the environment. In this mode upstream reads the builder as a string rather than coercing it, reads the system, the output hash fields and each output name as strings without context, and warns that a top-level `allowedReferences`, `allowedRequisites`, `disallowedReferences`, `disallowedRequisites`, `maxSize` or `maxClosureSize` has no effect; nova-nix does the same, with upstream's wording. libxo joins the drvPath parity set, and hand-written derivations covering nested sets, mixed lists, floats, nulls with and without `__ignoreNulls`, a path, a derivation, a placeholder, several outputs and a fixed output match the `.drv` nix-instantiate 2.33.2 writes byte for byte. (#36)+- **`builtins.fromJSON` reads JSON as upstream's nlohmann parser does.** It had a parser of its own, separate from the strict one the builder already uses for a derivation's `__json`, and that parser refused the `\b` and `\f` escapes while accepting what nlohmann refuses: an unterminated string, a trailing comma, a leading zero, a raw control character, a `\u` escape short of four hex digits (read as the letter `u`), a lone surrogate (read as U+FFFD), a number past the double range (read as `null`), and whitespace JSON does not define such as a no-break space. Upstream's `parseJSON` (json-to-value.cc at 2.24.9) hands the string to nlohmann's `sax_parse`, so `builtins.fromJSON` now goes through that same strict parser. The messages for refused input are that parser's, not nlohmann's, which name a line, a column and the last token read. (#276)+- **`builtins.fromJSON` refuses a positive integer past the Nix integer range.** A number above `9223372036854775807` that still fits an unsigned 64-bit value wrapped to a negative int, as Nix 2.24.9 does. Upstream made this an error at 2.25 (`json-to-value.cc`), `unsigned json number 18446744073709551615 outside of Nix integer range`, and `nova-nix` now matches it, as it already did for the integer overflow errors of the same change. A number past 64 bits stays a float, and the unsigned range is unchanged for a builder reading `__json`. (#311)+- **`builtins.stringLength`, `substring`, `unsafeDiscardStringContext`, `unsafeDiscardOutputDependency` and `addDrvOutputDependencies` coerce their string argument as upstream does.** Each refused anything but a string, where upstream calls `coerceToString` at its default arguments (`primops.cc` and `primops/context.cc` at 2.24.9): a path is copied to the store and becomes its store path with context, and a set coerces through `__toString` or `outPath`. A value that cannot be coerced now fails with upstream's `cannot coerce an integer to a string: 1`. 13 of 1,143 sampled top-level attributes on `nixos-unstable` failed on this, nearly all of them .NET packages, and all 13 now produce the store path Hydra built. (#306)+- **`derivation` is upstream's wrapper lambda evaluated as the builtin, so its result has upstream's shape: `outputName`, `all` and `drvAttrs` are present, every output attribute is a complete derivation set, and nothing internal leaks.** The builtin hand-built its result as a set with `drvPath`, `outPath`, `type` and one `{ drvPath, outPath, type }` set per output, plus a private `_derivation` attribute carrying the build recipe. nixpkgs reads what upstream's `src/libexpr/primops/derivation.nix` produces: `lib.customisation.extendDerivation` does `inherit (drv.${outputName}) type outputName`, which failed with `attribute 'outputName' not found` for firefox at the top level and for openjdk, gtk3, systemd and emacs one level down, and the leaked attribute reached `nodePackages.typescript` through an attribute enumeration as a value that cannot be coerced to a string. The source of that wrapper, byte for byte at 2.24.9 minus its documentation comment, is now the value of `derivation` in both the root scope and `builtins`, closed over the three names it reads, and `derivationStrict` returns what upstream's primop returns: `drvPath` with its whole-derivation context and one path per output with that output's context. The laziness is the wrapper's: `derivationStrict` is reached only through `outPath` and `drvPath`, so forcing a derivation to weak head normal form still forces none of its inputs. Upstream's three output-list rules in `derivationStrict` come with it: an output named `drvPath`, a repeated output name and an empty list are refused with upstream's messages. `builtins.functionArgs derivation` is now `{ outputs = true; }`, as upstream's lambda reports. `nix-instantiate` 2.33.2 agrees on every attribute of the result for a two-output derivation, on the hello and dependent drvPath parity cases, and on `nodePackages.typescript`, which now evaluates to upstream's drvPath. (#217)+- **`build` reads the derivation it realizes from the `.drv` the evaluator recorded, and prints the output the value names as its default.** The driver took the recipe from the derivation value's private `_derivation` attribute, which is gone. Every derivation an evaluation computes is recorded under its `.drv` path as the exact bytes that path hashes, for writing the input closure before a build; the root's own recipe now comes from the same record, so what is built is what was hashed. A `.drv` lists its outputs by name, sorted, with no notion of a default, so the output whose path `build` prints is the value's `outputName`, the first of its `outputs`, which is the output upstream's `nix-build` realizes and prints (`nix-build.cc`, `queryOutputName`), with upstream's message for a set that lacks the attribute. `eval --aterm` prints the recorded bytes rather than a re-serialization; the two are identical, and the recorded bytes are the ones whose hash is the path. (#217)+- **A derivation's `name`, `builder` and `system` are read as upstream's `derivationStrict` reads them.** The name is read with `forceStringNoCtx` (primops.cc at 2.24.9): `name = 1` evaluated, coerced to `"1"`, and is now `expected a string but found an integer: 1`, and a name with context is refused with upstream's `the string '...' is not allowed to refer to a store path (such as '...')`, naming the context element upstream lists first; an absent name is `attribute 'name' missing`. A path as the builder or system is copied to the store, as its environment entry already was, so `builder = ./b.sh` puts the store path in the .drv's builder field, where nova-nix wrote the source path and so computed a drvPath upstream does not. An empty builder or system is refused as absent, `required attribute 'builder' missing`, the builder checked first, where nova-nix accepted it. An invalid name reports upstream's `invalid derivation name: name '.' is not valid. Please pass a different 'name'.` and its kin. The same `(such as '...')` now ends the refusal of context in a structured derivation's fields. (#281)+- **A failure inside `derivationStrict` names the attribute and the derivation it came from, in upstream's words and layout.** `cannot coerce a set to a string` arrived with no position and no context, so finding which of a closure's thousands of attributes held the set took a bisection by hand. Upstream processes each attribute of a derivation inside a `catch` that adds `while evaluating attribute 'foo' of derivation 'x'` (derivationStrictInternal, primops.cc at 2.24.9), and so does nova-nix now, for the per-attribute coercion, the structured-attributes serialization and the string fields a structured derivation reads, `system`, `builder`, `outputs` and `args`; the name carries upstream's two lines for it, `__structuredAttrs` and `__ignoreNulls` the lines their `forceBool` adds, and a non-Boolean value for either is refused with upstream's `expected a Boolean` message. A failure several derivations deep names each attribute on the way, outermost first. The report is upstream's `showErrorInfo` layout: `error:` alone on the first line, each line of context after an ellipsis and a blank line, the message on an `error:` line of its own, and every line after the first indented to align with it, a multi-line message included; `build` reports an evaluation failure the same way, where it printed `eval error:`. What it cannot print is what upstream prints with a position: nova-nix tracks none, so the `while calling the 'derivationStrict' builtin` and `whose name attribute is located at` lines and the code excerpts under each line are absent, as is `--show-trace`; every line of context is printed. The lines are context only: a message is unchanged, and a throw stays catchable by `builtins.tryEval`. (#221)+- **A derivation attribute carrying another derivation's `drvPath` brings that `.drv`'s whole closure into the derivation, as upstream's does.** Upstream's `derivationStrict` takes the closure of the `.drv` (`computeFSClosure`, primops.cc at 2.24.9) and adds every path in it to `inputSrcs` and every `.drv` in it to `inputDrvs` with all its outputs; nova-nix added the one `.drv` to `inputDrvs` and nothing to `inputSrcs`, so `derivation { ...; x = "${dep.drvPath}"; }` had a drvPath upstream does not compute. The closure follows each `.drv`'s inputs and the references a `builtins.toFile` text recorded when this evaluation wrote it. (#294)+- **Restricted and pure evaluation gate the filesystem, the environment and the network the way upstream's `restrict-eval` and `pure-eval` do.** Under either mode a read (`readFile`, `import`, `readDir`, `readFileType`, `hashFile`, `builtins.path`, `filterSource`, a path coerced into the store) is refused unless the path lies within an allowed prefix or is an ancestor of one, with upstream's `CanonPath::isAllowed` rule and upstream's message, `access to absolute path '...' is forbidden in restricted mode` (or `in pure evaluation mode (use '--impure' to override)`), which `tryEval` does not catch. The check walks the path through its symlinks prefix by prefix, as 2.24.9's accessor does (`SourceAccessor::resolveSymlinks` through a `FilteringSourceAccessor::maybeLstat` that throws at 2.24.9), so a symlink inside an allowed root that points outside is refused at its target, and the refusal names the first prefix outside the allow list, `/etc` for `/etc/passwd` when nothing under `/etc` is allowed; 2.33.2 names the full path, because its `maybeLstat` answers nothing instead of throwing, and 2.24.9's source is followed. `readFileType` and a symlink read name the path asked for, since upstream checks those without a walk, and refuse a symlink anywhere above the path (`path '...' is a symlink`), as upstream's accessor does beneath its allow list, so the type of a path reached through a symlinked directory is never revealed. An `import` of a directory is checked at the `default.nix` it resolves to as well as at the path named, as upstream's `parseExprFromFile` walks the file it reads after `resolveExprPath` appends the name, so a `default.nix` that is a symlink outside the roots is refused at its target. `pathExists` answers false for a refused path, as upstream turns the refusal into false, and checks a symlink at the final component in place rather than following it, as upstream resolves only the ancestors there, so a link inside the roots exists whatever it points at; a trailing slash demands a directory and follows the link, as it does upstream. The allowed set starts as the search path roots and grows as the evaluation produces store objects, as upstream's `allowPath` grows it: a `toFile` result, a fetched tree, a copied source and a `builtins.path` result are readable afterwards, and the fetchers' scratch directories are the evaluator's own. `getEnv` answers the empty string under either mode (`prim_getEnv`). Under `restrict-eval` a fetch is refused unless `allowed-uris` admits it, with upstream's `isAllowedURI` rule (equal to a prefix, under it at a `/` boundary, or a bare scheme such as `https:`) and message (`access to URI '...' is forbidden in restricted mode`); a path or `file://` URI is checked against the allowed paths instead (`checkURI`). `fetchGit` is checked in the form upstream renders its input, `git+https://host/repo?exportIgnore=1` with the pins in key order, so an `allowed-uris` prefix has to be written for that form, as it does upstream, and a local repository given as a path is `git+file://`. Under `pure-eval` the search path holds only the bundled `<nix/*>` directory and a `<name>` miss says `cannot look up '<name>' in pure evaluation mode (use '--impure' to override)` (still catchable, as the default mode's miss is); `builtins.currentTime` and `builtins.currentSystem` do not exist; `builtins.storePath` is refused (`'builtins.storePath' is not allowed in pure evaluation mode`); `fetchurl` and `fetchTarball` need a `sha256` (`in pure evaluation mode, 'fetchurl' requires a 'sha256' argument`); `fetchGit` needs a `rev` (`in pure evaluation mode, 'fetchGit' will not fetch unlocked input '...'`, 2.24.9's wording where 2.33.2 says `doesn't fetch`, and a rev is what locks a git input at 2.24.9); and `~/x` is refused (`the path '~/x' can not be resolved in pure mode`, upstream's parser message, raised here where the expansion is). Every message was recorded from `nix-instantiate` under `--option restrict-eval true` and `--option pure-eval true` and matched byte for byte on the final error line. Two divergences are deliberate: `builtins.findFile` over an explicit list treats a candidate the policy refuses as absent (2.24.9 probes outside its accessor and hands the path back, an existence oracle; 2.33.2 refuses from there), and the closure of a search path root that is a store path is not allowed with it, since evaluation holds no store database handle. The default is unrestricted, as upstream's is. (#75)+- **A string that spells a relative path is refused wherever a path is coerced from a string, and a search path miss carries upstream's hint.** `builtins.readFile "foo"`, `import "foo"`, `pathExists`, `readDir`, `readFileType`, `hashFile`, `filterSource`, `storePath` and `scopedImport` read a relative string against the working directory (and `import` against the importing file's directory); upstream's `coerceToPath` refuses one with `string 'foo' doesn't represent an absolute path`, observed from `nix-instantiate` 2.33.2 in every mode, and so does nova-nix now. An absolute string is canonicalized like a path value, as upstream's `CanonPath` does. The refusal is also what keeps the access policy sound: a relative string would be checked against one location and opened at another. The miss message for `<name>` is now `file 'name' was not found in the Nix search path (add it using $NIX_PATH or -I)`, upstream's text. (#75)+- **Function calls nested past `max-call-depth` (10000 by default) are refused with upstream's "stack overflow; max-call-depth exceeded".** nova-nix had no ceiling: the counter recursion `f 100000`, which upstream rejects, evaluated here to 100000, and ten million frames evaluated in 12 s at 1.8 GB. The counter is evaluator state, carried per frame by each evaluator (the pure one enforces it too), and it counts what upstream's `EvalState::callFunction` counts (eval.cc at 2.24.9): every application of a lambda, of a builtin (a partial application included) and of a functor (two frames, its inner call being a `callFunction` of its own), a `__toString` coercion, and the operators upstream's parser desugars into builtin calls, `-`, `*`, `/`, `<`, `<=`, `>`, `>=` and unary `-` (parser.y at 2.24.9). The applications builtins build (`map`, `genList`, `mapAttrs`, `zipAttrsWith`) are upstream's app values: applied where their result is forced, with the frame opening before the function is forced, so a `mapAttrs` or `zipAttrsWith` callback's name application runs a frame below its value application, as upstream's two nested app values do; `builtins.sort` with `builtins.lessThan` itself as the comparator opens no frame per comparison, upstream's `prim_sort` bypass. The check runs on entry against the frames already active, so with the default ceiling 10001 nested lambda calls pass and 10002 fail, and the counter recursion passes at 9999 and fails at 10000, its deepest frame being the `n - 1` forced inside `f 0`; every boundary was confirmed against nix-instantiate 2.33.2. The error is an eval error that `tryEval` does not catch, as upstream's `EvalError` is. The ceiling reads from nix.conf and NIX_CONFIG through the cascade `substituters` uses, a plain assignment replacing and `extra-max-call-depth` being an unknown name since upstream accepts `extra-` on list settings only, with upstream's integer grammar: decimal digits, an optional leading `+`, and an optional `K`, `M`, `G` or `T` binary unit suffix in either case; anything else, or a value past `unsigned int`, is refused as `setting 'max-call-depth' has invalid value '...'`. One divergence: upstream narrows a unit product silently, so `5G` becomes 1073741824 there, while here it is refused like any other value outside the range. (#222)+- **A variable that no enclosing scope binds and no enclosing `with` could supply fails the expression before anything is evaluated, with upstream's `undefined variable 'y'`.** It was an error only where evaluation reached it, so `let x = y; in 1` and `if true then 1 else y` evaluated to 1, and `builtins.tryEval (let x = y; in 1)` to `{ success = true; value = 1; }`, three expressions nix-instantiate 2.24.9 and 2.33.2 both reject. Upstream binds every variable when it parses a source (`ExprVar::bindVars`, nixexpr.cc at 2.24.9) and raises `UndefinedVarError` there unless a `with` encloses the name, an error `tryEval` does not catch. Resolution here now does the same, once per source: the expression or file given on the command line, every `import` when it is forced, so a file with an unbound variable on a branch its importer never takes fails at the import, and `scopedImport`, whose scope's names are bound around the file as upstream binds them, so a `with` in that file no longer shadows them. A name under a `with` still waits for evaluation (`with {}; let x = y; in 1` is 1, and `with {}; y` fails when evaluated), `inherit x;` is checked like any other variable, and `inherit (s) x;` checks only `s`, selecting `x` when it is forced. With several unbound variables, upstream reports the first in its own binding order, which follows its symbol table, and nova-nix the first in the source. `__curPos`, which upstream's parser reads as a position rather than a variable, is exempt from the check and still fails only when forced, since nova-nix does not evaluate it yet. nix-instantiate 2.33.2 and nova-nix both bind every one of the 36860 `.nix` files in the nixpkgs 24.11 revision the parity job pins. (#248)+- **The root scope binds every name upstream's base environment does: `__typeOf` and the other `__`-prefixed builtins, and `fetchMercurial`.** Upstream installs each primop and constant under the name it registers and under that name less `__` in `builtins` (`addPrimOp` and `addConstant`, eval.cc at 2.24.9), so `__typeOf 1` is `"int"` there and was an undefined variable here, and with binding checked before evaluation `if false then __typeOf 1 else 2` would have been refused where upstream gives 2. The root scope is now built from the 114 names `createBaseEnv` and the default-enabled `RegisterPrimOp`s produce, each holding the value `builtins` holds under its unprefixed name. Under `pure-eval` it drops `__currentTime` and `__currentSystem`, as upstream drops its `impureOnly` constants, so binding rejects them there. `fetchMercurial` is the one name nova-nix does not implement: it is bound, so binding accepts it, and calling it fails with `unknown builtin 'fetchMercurial'`; `builtins` still does not list it. (#248)+- **An inherited value is evaluated in the scope around the binding set, not in the set itself.** `rec { ${"q"} = 0; x = 1; y = rec { ${"a"} = 1; inherit x; }; }.y.x` recursed forever, because the inner set looked `x` up by name from its own scope and found the attribute it was defining; upstream gives 1, evaluating an Inherited attribute in the enclosing environment (`ExprAttrs::eval` and `ExprLet::eval`, eval.cc at 2.24.9), and so does nova-nix now. A plain `inherit` carries its variable, bound like any other, so the evaluator has no name lookup of its own for one, and an inherit clause that repeats a name is refused when parsed with `attribute 'x' already defined`, as upstream's per-name check refuses it. (#248)+- **Indented strings strip exactly what upstream strips: whitespace-only lines, the opener's own line, and spaces but never tabs.** The stripping was approximate in four ways. A string with no measured line (blank lines and spaces only) kept every space, because the minimum indent started at zero when nothing contributed; upstream starts it at a sentinel, so such a string loses every leading space, and `''` followed by a blank line and an indented closer is `"\n"`, not the eight spaces of the closer. A tab counted as indentation and was stripped; upstream measures only spaces, so a tab fixes the minimum at its column and survives into the value. The closing `''`'s own indentation survived as a trailing line of spaces; upstream removes a final line of spaces from the last part after stripping. And the opener's trailing spaces survived as a line of their own, because only a bare newline after `''` was dropped; upstream's lexer consumes `''`, any spaces and one newline as the opener, so content starts on the next line. The same transcription corrects how escapes meet stripping: upstream's scan skips an escape (it ends a line's leading whitespace without being measured) while its strip runs over escapes as over any literal, so the spaces after an escaped newline are stripped but never measured, and `'' a''\n b''` is `"a\nb"` where it was `"a\n b"`. Every rule is `ParserState::stripIndentation` and the `IND_STRING_OPEN` lexer rule at Nix 2.24.9, unchanged through 2.33.2, and every case was checked against nix-instantiate 2.33.2. nixpkgs concatenates `+ ''`, a blank line, `''` into shell hooks constantly, and clang's drvPath differed from upstream's for that reason alone. (#220)+- **A builtin that takes a path coerces its argument as upstream's `coerceToPath` does.** `readFile`, `readDir`, `pathExists`, `hashFile`, `readFileType`, `import`, `scopedImport`, `builtins.path`, `filterSource`, `toPath` and `storePath` each refused anything but a path or a string in a message of nova-nix's own; upstream runs the argument through `coerceToPath` (eval.cc at 2.24.9), which takes a path as itself, follows a set's `__toString`, and coerces anything else to a string that must be absolute. So `builtins.readFile { outPath = "/etc/hosts"; }` reads the file, and `builtins.readFile 1` is `cannot coerce an integer to a string: 1`. `builtins.path { path = 1; }` read the relative path `1`, and `builtins.toPath` gave a path where upstream gives the coerced path as a string with its context, which interpolation does not copy. `baseNameOf` and `dirOf` coerce what is not a path as upstream's `coerceToString` does, so an `outPath` or a `__toString` answers, and `dirOf` of a set gives a string. (#283)+- **`builtins.pathExists` checks the final symlink in place and demands a directory for a trailing slash, `builtins.readFileType` refuses a symlink above its path, and `builtins.path`, `filterSource` and path coercion resolve a symlink root.** Upstream's `prim_pathExists` (2.24.9) resolves a plain argument through its ancestors only and lstats the final component, so a dangling symlink exists, and resolves a string ending in `/` or `/.` fully and requires a directory, so `builtins.pathExists "/etc/passwd/"` is `false`; nova-nix followed the final link (a dangling one read as absent) and erased the trailing slash in canonicalization. `readFileType` lstats its argument without resolution, and upstream's accessor refuses a symlink anywhere in the parent chain with `path '...' is a symlink` (`PosixSourceAccessor::assertNoSymlinks`) in every mode, so `builtins.readFileType /tmp/x` on macOS, where `/tmp` is a symlink, fails exactly as it does under `nix-instantiate`; nova-nix answered the target's type. `builtins.path`, `builtins.filterSource` and a path coerced into the store now resolve their root through its symlinks before reading the tree, as upstream's `addPath` and `copyPathToStore` do, so a root that is a symlink stores what it points at under the root's own name and lands at upstream's store path; nova-nix tried to store a symlink. (#75)+- **Interpolating a path that is a symlink copies what it points to.** `"${./link}"` stored the link itself, where upstream's `copyPathToStore` stores `path.resolveSymlinks()` under the path's own name (eval.cc at 2.24.9). The store path therefore differed from upstream's, and so did the drvPath of every derivation that interpolates such a path. (#279)+- **A list coerces to a string as upstream joins it.** `builtins.toString [ [ ] 1 ]` was `" 1"` and is now `"1"`: upstream's `coerceToString` (eval.cc at 2.24.9) puts a space after every element but the last, except after one that is itself an empty list, where nova-nix joined every element with a space. A list now coerces wherever upstream's permissive coercion runs, so `toString { __toString = s: [ 1 2 ]; }` and `toString { outPath = [ 1 2 ]; }` are `"1 2"`, where nova-nix refused the list, and a derivation attribute or argument holding a list joins the same way. Interpolation still refuses a list, as upstream's does. (#282)+- **`builtins.storePath` follows a symlink into the store and refuses with upstream's messages.** Upstream's `prim_storePath` resolves a path's symlinks unless the path is itself a store path (primops.cc at 2.24.9), so a link outside the store pointing into it names the store path it reaches, where nova-nix refused it. A path outside the store is `path '/x' is not in the Nix store`, naming the resolved path, and a component under the store that is no store path is the `StorePath` constructor's refusal (path.cc), `'x' is too short to be a valid store path` or `store path '...' contains illegal base-32 character 'E'`, where nova-nix said `builtins.storePath: not a valid store path` for all of them. (#293)+- **`builtins.toXML` writes upstream's document and keeps the contexts of the strings in it, so a NixOS toplevel evaluates to upstream's drvPath.** The writer was an approximation of `printValueAsXML` (value-to-xml.cc and xml-writer.cc at 2.24.9) in five ways. It indented by list and set nesting rather than by element: the value under `<expr>` started at column 0 and an `<attr>`'s value sat at the attribute's own indent, where upstream's `XMLWriter` indents every element two spaces per element open around it. A newline in an attribute value was written raw; upstream writes `
`, since an XML parser normalizes a literal one to a space. Every function was `<function />`; upstream writes a lambda's argument pattern, `<varpat name="x" />` or an `<attrspat>` listing the formals in name order with the `@` binding and the ellipsis as attributes, and a primop as `<unevaluated />`. A derivation was written as a plain set, and since its `all` and output attributes refer back to it the writer never finished; upstream writes a `<derivation drvPath=... outPath=...>` element for it, holding its attributes the first time its drvPath appears and only `<repeated />` every time after. And the result was a plain string, where `prim_toXML` gives it the contexts of the strings it wrote. NixOS writes GRUB's installer configuration with `pkgs.writeText "grub-config.xml" (builtins.toXML ...)`, so the configuration's text and its missing inputs made the toplevel's drvPath differ from upstream's. Every case was checked against nix-instantiate 2.24.9 and 2.33.2, and the toplevel joins the parity case set. (#221)+- **`builtins.toJSON` escapes backspace and form feed as `\b` and `\f`, as upstream does.** It wrote them as `\u0008` and `\u000c`. Upstream serializes through nlohmann's `dump()`, whose escaper has two-character escapes for the quote, the backslash, backspace, form feed, newline, carriage return and tab, and `\u00xx` only for the other control characters. The same rendering now writes a structured derivation's `__json`, whose bytes are hashed into its store path. (#36)+- **`builtins.toJSON` converts a set with `outPath` through the `outPath` value itself, and a set with `__toString` through upstream's coercion of the result.** Either kind of set was coerced to a string with the derivation-environment rules, so `{ outPath = { a = 1; }; }` and `{ outPath = [ 1 2 ]; }` failed with `cannot coerce`, `{ outPath = 1; }` and `{ __toString = s: 1; }` became the string `"1"`, and `{ __toString = s: ./x; }` copied `x` to the store. Upstream's `printValueAsJSON` (value-to-json.cc at 2.24.9, unchanged in 2.33.2) converts the `outPath` value as it converts any value, so those become `{"a":1}`, `[1,2]` and `1`, and a path there is still copied to the store with its context. It coerces a `__toString` result through `tryAttrsToString` with `coerceMore` and `copyToStore` both off, so an integer is an error, a path is its own absolute text with no store copy and no context, and a string keeps its context. A structured derivation's `__json` is built by the same conversion, so its attributes follow the same rules. Every case was compared with nix-instantiate 2.33.2. (#36)+- **An environment value's bytes reach the evaluator unchanged, as upstream's do.** `builtins.getEnv` and `builtins.nixPath` read a variable through base's `lookupEnv`, which decodes it with the file-system encoding and carries a byte with no UTF-8 reading as a lone surrogate, and the conversion to text then replaced that with U+FFFD: `NIX_PATH` holding the byte 0xFC followed by `=/x` gave a three-byte prefix (EF BF BD) where `nix-instantiate` 2.33.2 gives the one byte. Upstream reads a variable with `getenv` and keeps its bytes (`getEnv` in `src/libutil/environment-variables.cc` at Nix 2.24.9). On POSIX the evaluator now makes the same call through the unix package, for the name as well as the value, so `builtins.getEnv` of a name that is not valid UTF-8 looks that variable up instead of failing. `NIX_PATH` is split and parsed as bytes, as upstream's parser is, and a search path prefix is compared byte for byte (`suffixIfPotentialMatch`), so an entry whose prefix has no UTF-8 reading no longer stops a `<name>` lookup from reaching the entries after it. A matching entry whose path has no UTF-8 reading is an error, since path values are text here, and such a root adds nothing to the restricted-mode allow list instead of a lossy spelling of itself. A Windows environment is UTF-16: a value becomes its UTF-8 bytes, and one holding an unpaired surrogate, which has no UTF-8 form, is refused rather than approximated (`builtins.getEnv` raises an evaluation error, a `NIX_PATH` fails setting up the evaluation), the rule the NAR serializer applies to such a file name. The read also stops catching every exception as "unset", which turned an interrupt arriving during the read into an empty value. (#253)+- **`-`, `*`, `/`, the arithmetic and bitwise builtins and the comparisons fail with upstream's messages.** Each is an upstream primop (`prim_sub`, `prim_mul`, `prim_div`, `prim_add`, `prim_bitAnd` and its kin, `prim_lessThan`, primops.cc at 2.24.9) that checks its operands with `forceInt`, or with `forceFloat` once either is a float. So `1 - {}` reports `expected an integer but found a set: { }` where nova-nix said `cannot apply subtraction to an integer and a set`, `1.0 - {}` reports `expected a float but found a set: { }`, and `builtins.bitAnd 1 {}` names the value it refuses where nova-nix said `builtins.bitAnd: expected two integers`. Division looks at its divisor first, as upstream's does: `1 / {}` fails the divisor's `forceFloat`, and a zero divisor is `division by zero` before the dividend is checked, so `{} / 0` is a division by zero, for `/` and `builtins.div` alike (nova-nix prefixed the builtin's message). Comparing two types is `cannot compare an integer with a set`, and comparing a type with no order is `cannot compare a Boolean with a Boolean; values of that type are incomparable`, where nova-nix wrote `cannot compare an integer and a set` and `cannot compare a Boolean and a Boolean`; a lambda and a primop are one type there, upstream's `nFunction`. From 2.25 upstream appends both values to a comparison's message, and 2.24.9's wording is kept. (#278)+- **`+` and string coercion fail with upstream's messages, and a message that names a type names it as upstream's `showType` does.** `1 + "a"` reported `cannot coerce an integer to a string with the + operator` and now reports `cannot add a string to an integer`, and `"a" + 1`, `"${1}"` and `builtins.toString {}` report `cannot coerce an integer to a string: 1` and `cannot coerce a set to a string: { }`, the messages of upstream's `ExprConcatStrings::eval` and `coerceToString` (eval.cc at 2.24.9). The left operand of `+` is dealt with before the right is evaluated, and its type alone decides the operation, as upstream's loop decides it: an integer or float makes `+` arithmetic and refuses a right operand that is not a number, and anything else makes it a strict concatenation. So `true + 1` fails to coerce the Boolean instead of failing to add it, and `{} + throw "b"` fails on the set without evaluating the throw, an error `builtins.tryEval` does not catch. Upstream copies a path into the store only when the left operand is a string, so `{ outPath = ./src; } + "x"` is now the path's own text with no context, where nova-nix copied the source. A store-path context appended to a path reports upstream's `a string that refers to a store path cannot be appended to a path`, and an interpolated path literal looks for one only once every piece has coerced, as upstream does, so a later piece that fails to coerce is the error reported. A string with context is `a string with context`, a primop `the built-in function 'map'` and a partial application of one `the partially applied built-in function 'map'`, where nova-nix said `a string` and `a built-in function`, in every type error and in a selection path's error alike. (#250)+- **The value a type error shows, and the one `builtins.trace` prints, is a set's attributes and a list's items as upstream's `printValue` prints them (print.cc at 2.24.9), where nova-nix printed `<<a set>>` and `<<a list>>`.** Attributes print by name, and in an error with `_type` and `type` first, as upstream sorts them whenever the print is bounded. An error's print shows at most ten attributes and ten items over the whole value and ten levels of nesting, and counts what it leaves out, the bounds of upstream's `errorPrintOptions` (print-options.hh). Nothing is forced: an entry not yet evaluated prints as upstream's thunk marker, a primop as its primop marker, and a thunk being forced as upstream's marker for a possible infinite recursion. Which entries are thunks follows nova-nix's evaluator, which agrees with upstream's for a literal written in the set or list, a function's argument and anything already forced, and not for an indented string, a `~/` path, a variable bound to a literal by `let` or `rec`, or a global such as `map`, all of which upstream hands over as values. A function value carries neither its name nor its source position, so a lambda's marker is the bare word where upstream adds both, and a set or list reached twice prints again where upstream marks it repeated. (#250)+- **Integer overflow and negation fail as upstream does.** An overflowing `+`, `-` or `*` reports upstream's `integer overflow in adding 9223372036854775807 + 1`; upstream checks these from 2.25, and 2.24.9 leaves them to undefined C++ arithmetic. Dividing the smallest integer by -1, which 2.24.9 does check, reports its `overflow in integer division`, where nova-nix wrote `integer overflow in dividing a and b`. Unary minus is upstream's `__sub 0 e` (parser.y at 2.24.9) in every respect, so `-(0.0)` is positive zero and `builtins.toJSON (-0.0)` is `"0.0"`, and `-{ }` fails with `expected an integer but found a set: { }`. (#250)+- **A failed read of a source path reports upstream's message, not GHC's.** `builtins.readFile ./nope` reported `/d/nope: withBinaryFile: does not exist (No such file or directory)` and now reports `opening file '/d/nope': No such file or directory`, the wording of upstream's `PosixSourceAccessor` (posix-source-accessor.cc at 2.24.9), which names the access and the path and appends the system error. An absent path under an lstat (`readFileType`, interpolation, `builtins.path`, `filterSource`, the target of an `import`) is `path '/d/nope' does not exist`, as `SourceAccessor::lstat` has it, and a path reached through a file counts as absent there; a file is opened with `opening file`, a directory read as a file is `reading from file '/d/dir': Is a directory`, and a directory is listed with `reading directory`. Each names the path as upstream does: resolved through its symlinks for a read, `readDir` and a copy, as given for `readFileType`. A failure inside the walk of a copied directory still carries GHC's text. (#277)+- **A non-set operand of `//` and a non-function passed to `builtins.functionArgs` fail with upstream's messages.** `null // {}` reported `cannot merge null and a set` and now reports `expected a set but found null: null`, the message of `evalAttrs`, which upstream's `ExprOpUpdate::eval` runs on the left operand and then the right (eval.cc at 2.24.9). A non-set left therefore fails before the right is evaluated, so `builtins.tryEval (null // throw "b")` raises the type error instead of catching the throw. 2.33.2 checks the right operand first; 2.24.9 is followed. `builtins.functionArgs 1` reports upstream's `'functionArgs' requires a function` (`prim_functionArgs`, primops.cc). The offending value is printed as upstream's `printValue` prints it, and `builtins.trace` shares that printer, so a float it prints has upstream's six significant digits: `trace: 1.23457e+06`, where it printed `trace: 1234567`. (#245)+- **`builtins.warn` refuses a message that is not a string, as upstream does.** It printed any value as the warning; upstream's `prim_warn` forces the message with `forceString` (primops.cc at 2.24.9), so `builtins.warn 1 2` fails with `expected a string but found an integer: 1`, which `builtins.tryEval` does not catch. Upstream keeps the argument a string to leave room for extending it; `builtins.trace` remains the way to print any value. (#249)+- **`builtins.deepSeq`, `builtins.trace` and `builtins.warn` finish with their first argument before they force their second.** Upstream's `prim_deepSeq` forces the first argument deeply, `prim_trace` prints it and `prim_warn` warns with it, and only then does each force the second (primops.cc at 2.24.9). nova-nix forced both arguments before the builtin ran, so `builtins.deepSeq { a = throw "a"; } (throw "b")` failed with `b` where upstream fails with `a`, `builtins.trace "hi" (throw "b")` failed without printing the trace, and a value printed inside `deepSeq`'s second argument still showed unforced thunks. `builtins.warn`'s line now carries upstream's `evaluation warning:` prefix, where nova-nix wrote `warning:`. (#285)+- **`builtins.head` and `builtins.tail` no longer copy the list, and `head`, `tail`, `elemAt` and `length` fail with upstream's messages.** `head` built a Haskell list of every element to return the first, and `tail` built one to copy all but the first into a new C list. `head` now indexes the C array as `elemAt` does, and `tail` returns a new header over the same array, which is safe because a list is never written after construction; upstream's `prim_tail` copies (primops.cc). A copy per call made a chain of tails quadratic in time and in arena memory, which is freed only when evaluation ends: folding `tail` 9999 times over a 10000-element list took 0.72 s and 449 MB and now takes 0.01 s and 33 MB, and `head` of an 800000-element list takes 0.13 s, as `elemAt` does, where it took 0.20 s. The messages are 2.24.9's. An index outside the list fails with `list index 5 is out of bounds`, which `head` of an empty list reports too as `list index 0 is out of bounds`, since upstream's `prim_head` is its `elemAt` at index 0; `tail` of an empty list fails with `'tail' called on an empty list`, a non-list with `forceList`'s `expected a list but found an integer: 1`, and a non-integer index with `expected an integer but found a string: "x"`, checked before the list because upstream forces the index first. 2.26.0 reworded the empty-list and bounds messages, so nix-instantiate 2.33.2 prints `'builtins.head' called on an empty list` and `'builtins.elemAt' called with index 5 on a list of size 2`. (#48)+- **`map`, `genList`, `concatMap`, `mapAttrs` and `zipAttrsWith` no longer compile code for every element they make, and an `inherit (from)` no longer compiles its selects each time it is evaluated.** Each element these builtins make is a deferred application, and its three or five instructions were compiled afresh per element into the bytecode store, which is freed only when evaluation ends, so the store grew with every element mapped; an `inherit (from)` likewise compiled the select of each name it inherits every time its binding ran. Every store now opens with that application code at fixed indices, and each element runs it in an env of its own holding the function and its arguments, as upstream builds an app value per element (`mkApp` in primops.cc at 2.24.9); `genList` passes the index as an argument value, as `prim_genList` allocates an int per element. The compiler makes each inherited name's select once, with its binding, as upstream's parser makes an `ExprSelect` per name (parser.y at 2.24.9). Evaluating `hello.drvPath` on the pinned nixpkgs 24.11 compiles 102800 instructions where it compiled 862288 and peaks at 116 MB resident where it peaked at 127 MB, and mapping over 100000 elements compiles 79 instructions where it compiled 600074. `map` and `concatMap` also share one thunk for the function across their elements, where they allocated one per element. A `genList` that is built and never forced costs more than it did, since an env and an int per element replace three instructions: building a million elements takes 0.27 s of user time and 260 MB, against 0.19 s and 226 MB. (#48)+- **Store paths, derivations and string literals evaluate with less work.** A store path's hash was folded to its 20 bytes through a fresh 20-element list for every byte of the digest; it is now XOR'd together a chunk at a time, the same bytes upstream's `compressHash` produces by folding byte `i` into position `i mod 20` (hash.cc). A derivation's `.drv` text was rendered twice, once to hash for its path and once to record for the build, where upstream's `writeDerivation` hashes and writes one rendering; it is rendered once. A string with no interpolation went through the general path an interpolated string takes; it is now its bytes, as upstream's parser makes it an `ExprString` holding its value (parser.y). `hello.drvPath` allocates 2082 MB where it allocated 2203 MB and takes 0.90 s of user time where it took 0.96 s, 2000 derivations allocate 292 MB where they allocated 623 MB, and a million evaluations of `if a == "a" then "b" else "a"` take 0.65 s where they took 0.92 s. (#48)+- **Reaching the C data layer outside the `arenaInit` .. `arenaDestroy` window raises `ArenaNotInitialized` instead of crashing the process.** Evaluating anything before `Nix.Eval.Arena.arenaInit`, or after `arenaDestroy`, was a SIGSEGV, so a library user who left the bracket out could not tell a missing setup step from memory corruption. Each C sub-arena's one allocation funnel now reports failure through the sentinel its callers already check (NULL, `UINT32_MAX`, `NN_SYMBOL_INVALID`) instead of dereferencing state it does not have, and the Haskell boundary asks `nn_arena_live` on that failure path and raises the new `Nix.Eval.CStatus.CStatusError`: `ArenaNotInitialized` naming the C entry point that refused, or `CAllocationFailed` when the arena is live and the allocation itself failed. The question is asked only after a failure, so evaluation pays one sentinel comparison per allocation and nothing more, and `hello.drvPath` evaluates in the same time. Upstream guards the same boundary with `assert(gcInitialised)` in the `EvalState` constructor, an abort rather than an error a caller can catch, which is the one place this diverges on purpose. `checkedCPtr` moves to `Nix.Eval.CStatus` and runs in `IO`, so the arena is asked at the moment the call failed rather than when a lazy message is rendered; the bytecode emitters raise `CStatusError` where they raised an `IOError`; and the thunk allocators, slot arrays and list construction now check their C result, where a NULL previously flowed on to the next dereference or became a silently empty list. Parsing needs no arena. `ArenaNotInitialized` is a setup diagnosis rather than an error to catch and retry in the same process: GHC updates a thunk whose evaluation raised with that exception, so a shared value whose evaluation reached C before the window re-raises it inside a later, live arena. (#206)+- **The C data layer reports a failed or oversized allocation as `CAllocationFailed` instead of aborting the process, and its 32-bit counts no longer wrap.** The symbol table, the attribute set's insert, union and key removal, and the bookkeeping behind lists, closures and context strings called `abort()` when an allocation failed, so evaluation ended with SIGABRT and no way for a caller to recover. Each now refuses through its status, the Haskell boundary raises `Nix.Eval.CStatus.CAllocationFailed` naming the C entry point, and `builtins.tryEval` does not catch it. That is upstream's shape: its collector turns exhaustion into `std::bad_alloc` (eval-gc.cc at 2.24.9), which `tryEval` lets through and the CLI reports as an error. Upstream's symbol table still aborts at its own `uint32_t` ceiling (`ChunkedVector::addChunk`); refusing there instead is deliberate. The 32-bit arithmetic is now checked where it wrapped. An attribute set holds at most 4294967295 entries, the ceiling upstream's `Bindings` has, and its doubling and union sizes are computed in 64 bits; a key past index 2^31 was reported absent and is now found. A string of 4 GiB or more is refused where its length was truncated to 32 bits, so that it read back as only its first bytes, and a list of 2^32 or more elements is refused where its count wrapped to a shorter allocation. `nn_list_new` and `nn_list_drop` report failure through a status of their own, since the NULL they returned also meant the empty list. A thunk whose force fails while its value is being stored is left re-forceable, and a refused thunk state transition raises the new `CInvariantViolated` instead of being ignored. The `null`, `true` and `false` thunks are shared static cells, as upstream shares `vNull`, `vTrue` and `vFalse`: they need no arena, so forcing one before `arenaInit` no longer raises `ArenaNotInitialized` for the rest of the process, and one kept across `arenaDestroy` no longer points into freed memory. Re-initializing the symbol table frees the previous one. (#19)++### Store++- **The store has garbage-collector roots, a reachability walk, a sweep, and a store-wide lock that keeps a collection and a writer apart.** Roots live under the store's own metadata directory, `.nova-nix/gcroots`, since this store has no state directory apart from that one and a store copied as one directory then keeps its roots. Both of upstream's permanent-root forms are read: a symlink whose target lies in the store, which roots the store path the target lies in (the path itself, a trailing-separator spelling of it, or anything beneath it, upstream's `toStorePath`), and a regular file named `<hash>-<name>`, which is the form that needs no symlink privilege. An out-link's indirect root is a regular file under `gcroots/auto`, named by a digest of the link's absolute path and holding that path, where upstream's is a symlink to the link; the record has to be writable on every platform the store runs on, and a symlink on Windows needs Developer Mode. The record is published by rename, so a listing under the shared lease never reads a torn one. A record whose out-link is gone is removed on the next walk with upstream's `removing stale link` line; a symlink root naming a path the database does not hold is skipped with its `skipping invalid root` line, a regular-file root naming one is dropped silently, and an entry the walk cannot read is reported with its `cannot read potential root` line and skipped, each as upstream does. The walk is pure over the references map; dead rows go in one transaction, because the outputs of one derivation reference each other both ways and no order of one-row deletes gets through a cycle; trees go one by one after the rows, so an interruption leaves inert unregistered trees rather than rows describing nothing. What the sweep removes is every store directory entry that is not live, as upstream's sweep removes it: a dead path's tree, a store-shaped tree no row describes, junk, and a dead path's lock file. A live path's lock file stays: upstream's holders delete their lock files on release, so its sweep reclaims crash debris, while here a holder never deletes and the live path's lock file is its normal state. A dead row with no tree is unregistered without a line or a count, since upstream's sweep walks the directory and never meets one, and what is announced and counted is what upstream announces and counts. The store directory is keyed by its canonical spelling from the moment it is opened (absolute, `.` and `..` collapsed, no trailing separator, symlinks unresolved, as upstream's `absPath` keys a `--store` path), so one directory named two ways keys one set of rows; a database holding a path under another spelling of the directory would compare as dead throughout, so the collector and the checked delete refuse it rather than sweep a rooted closure over a text mismatch. Upstream serializes collection against writers with a shared-or-exclusive `gc.lock` plus a socket; nova-nix keeps the lock and drops the socket: every open store handle holds `.nova-nix/gc.lock` shared from open to close, and a collection (or a checked delete) trades the handle's lease for the exclusive lock, so it runs only while no other process has the store open. An opener arriving meanwhile waits, announced with upstream's `waiting for the big garbage collector lock...`; upstream's own opener never waits there, since it hands its roots to the running collector through the socket, so the collector's line is the one that names the wait. A handle whose lease cannot be renewed after a collection keeps a surrendered lease rather than none, so it still closes. Listing the roots needs only the handle's shared lease, as upstream's listing takes no collector lock. The per-path locks the builder and substituter hold were not enough on their own: they cover a path while it is being produced, not the window between a dependency's registration and the build that consumes it, nor the writers that register without one (the `.drv` closure, the eval-time store writes). Not implemented, and stated in the module: runtime roots (upstream's `/proc` scan on Linux and `lsof` elsewhere), temporary roots, `keep-derivations` and `keep-outputs` (the builder records no deriver, so an unrooted `.drv` and the sources only it references are collected), and the `.links` optimization sweep. (#24)+- **The store probes whether its volume folds case when it opens, and case-hacks sibling names only where it does.** Whether two NAR entries differing only by case can share a directory was decided at compile time by operating system, so a store on a case-sensitive APFS volume still had `makefile` written beside `Makefile` as `makefile~nix~case~hack~1`. The store now asks the volume (`pathconf(_PC_CASE_SENSITIVE)` on macOS, the Win32 default on Windows, inherent on Linux) and carries the answer in the new `Store` field `stCaseSensitivity`; every NAR materialization keys names by it, and a volume that does not fold gets its true names. Upstream's `use-case-hack` is a per-process setting defaulting to on for Darwin (archive.cc at 2.24.9), so upstream case-hacks on a case-sensitive macOS volume as well; that spelling is a workaround for folding, and a volume that does not fold needs none, so this is a deliberate divergence in on-disk spelling only, since either tree re-serializes to the same NAR. (#68)+- **A store path is read back under its volume's case-hack mode, so a case-sensitive macOS store keeps a name that literally ends in `~nix~case~hack~N`.** The store case-hacks sibling names only where its volume folds, but the serializer stripped the suffix by operating system (nova-cache's `defaultCaseHack`, on for macOS and Windows). On a case-sensitive macOS volume nothing is ever hacked, yet every registration, on-disk recheck, adoption check, fixed-output check, push and evaluation read of a store path stripped the suffix, and the unpack refused an incoming NAR entry carrying it, so a tree holding such a name could not be substituted or registered. Upstream ties both directions to one per-process setting, `use-case-hack`, on for Darwin (archive.cc at 2.24.9), so it strips and hacks alike on every macOS volume. The store now reads its own paths under its probed volume's answer: stripped where the volume folds, as before and as upstream, and verbatim where it does not, which is upstream with `use-case-hack` off, and an incoming name carrying the suffix is refused only where the volume strips it. A path read as a source, outside any store, keeps the per-process setting, as upstream's every read does. (#236)+- **A NAR entry name or symlink target the host's filesystem can hold is materialized as its bytes, as upstream restores it on POSIX; each platform refuses only what its own filesystems cannot hold.** The store refused, on every platform, a name with no UTF-8 reading and every name Windows reserves (a device stem such as `aux.c` or `con.h`, a colon, a backslash, a trailing dot or space), so a NAR that `nix-store --restore` unpacks on Linux and macOS failed here. Upstream's restore appends each name to the destination path and hands each target to `symlink` as bytes (`RestoreSink` in fs-sink.cc at Nix 2.24.9), refusing only what the archive grammar refuses: an empty name, `.`, `..`, a `/` or a NUL (`parse` in archive.cc), which stay refused on every platform. Linux now holds every other name and target byte for byte, through the strict and the streaming unpack alike. macOS refuses a name that is not UTF-8 or holds a Unicode noncharacter: APFS refuses both at the create, and HFS+ stores such a name with each byte outside UTF-8 (and U+FFFE and U+FFFF) rewritten as a `%XX` escape, a tree that no longer reproduces its NAR. A link target keeps its bytes there. Windows refuses a device name, a trailing dot or space, a character Win32 reserves (`<>:"\|?*` and the controls), and a name or target with no UTF-16 form. Names reach `open`, `mkdir` and `symlink` as `OsPath` bytes rather than through the locale's codec, which could not encode a UTF-8 name under an ASCII file-system encoding. A store tree is therefore no longer guaranteed to materialize identically on every platform: a path holding a name some host cannot hold is refused when it is substituted onto that host, naming the entry and the reason, and materializes everywhere else. (#84)+- **A sibling pair the volume folds but the case-hack key keeps apart fails at the write, naming the entry, instead of silently replacing the earlier one.** On a folding volume the store keys sibling names by their uppercase, and that is not the volume's own fold table: APFS reads capital and small sharp s (U+1E9E, U+00DF) as one name while the key keeps them apart. Both NAR unpack paths created or truncated each file and merged directories, so the second entry of such a pair landed on the first, `builtins.path` returned a store path its tree did not reproduce, and a later build refused to register it with advice that re-evaluation could not follow. Every regular file and directory an unpack writes is now created exclusively, so the volume decides and the collision fails where upstream's restore makes it fail (`RestoreSink` in fs-sink.cc at 2.24.9: `O_CREAT | O_EXCL` for a file, a refused `create_directory` for a directory), with upstream's text. A file reads `creating file '"<path>"': File exists`, the inner quotes being `std::quoted`, through which upstream streams a `std::filesystem::path`, and a directory over a directory reads `path '<path>' already exists`. A directory over anything else reads `creating directory '<path>': File exists`, and a link `creating symlink from '<path>' -> '<target>': File exists`: 2.24.9 printed the C++ library's `filesystem_error` text for these two, which is implementation-defined and differs by platform, and 2.25 wrapped it in these messages, which are followed. The unpack's root is created the same way, so `unpackNarEntry` and the streaming sink refuse a destination that already holds an entry rather than writing into it, as upstream's restore does; every caller clears a stale destination first. `copyPathInto`, the fallback for a build output that cannot be renamed into place, creates exclusively as well and raises the same refusals. (#234)+- **A coerced source path is restored into the store from a dump of the source, as upstream restores one, so a sibling pair the source's volume keeps apart and the store's folds is case-hacked instead of failing the build.** The build driver copied each coerced source (`src = ./dir`) file by file and registered whatever landed under the store path evaluation had computed from the source, with nothing checking that the copy reproduced it. On a folding store, a source from a case-sensitive volume holding `Makefile` and `makefile` merged and was registered under the two-file address. Upstream never copies: `LocalStore::addToStoreFromDump` restores the dump it hashed (`restorePath`, local-store.cc at 2.24.9), so its case-hack applies on the way in. The store now does the same and holds `Makefile` and `makefile~nix~case~hack~1` under the path, NAR hash and size nix-instantiate 2.33.2 records for that source. The source's NAR streams from the walk through the hash into the streaming unpack sink, so no file is held whole. The dump must reproduce the address evaluation computed, so a source edited between evaluation and the build is refused rather than registered under a hash its bytes no longer have, and the restored tree is rechecked on disk before its row exists. `builtins.path` without a filter, `fetchTarball` and `fetchGit` likewise unpack the NAR entry they hashed rather than copying the source, as a filtered `builtins.path` already did. Sources follow the store's per-platform name rules (#84): on Linux and macOS a device stem such as `aux.c`, a colon or a trailing dot restores as its bytes, as the copy stored it and as upstream does. (#235)++### Builder++- **A derivation carrying `__json` is built as upstream builds one: its attributes reach the builder as `.attrs.json` and `.attrs.sh` in the build directory, named by `NIX_ATTRS_JSON_FILE` and `NIX_ATTRS_SH_FILE`, and not as environment variables.** The builder copied every environment entry of the `.drv` into the build's environment, so a structured derivation's builder saw one `__json` variable and neither of the files stdenv's setup script looks for. Upstream takes the mode from the presence of `__json` (`ParsedDerivation`, parsed-derivations.cc at 2.24.9), sets `outputs` to an object naming each output's path, writes the object to `.attrs.json` and the part of it bash can hold to `.attrs.sh`, and passes no attribute and no output through the environment (`initTmpDir` and `writeStructuredAttrs`, local-derivation-goal.cc at 2.24.9). The shell file follows `writeStructuredAttrsShell`: a string single-quoted, an integral number in decimal, null as `''`, a boolean as `1` or nothing, an array or object of those as `declare -a` or `declare -A`, and anything else, or any name that is not a shell identifier, left out. nova-nix now does each of these, rewriting placeholders and store paths in both files as it does in the environment, and reads `impureEnvVars` from the object, where it must be a list of strings. A structured build here wrote `.attrs.json` and `.attrs.sh` byte-identical to a Nix 2.33.2 build of the same derivation, the store directory aside. Upstream writes a number through `static_cast<int>`, so an integer keeps its low 32 bits, and a float past `int`'s range is undefined behavior there; nova-nix writes what an aarch64 build of upstream writes, the saturated value, where an x86-64 build writes `-2147483648`. `outputChecks`, `exportReferencesGraph` and `unsafeDiscardReferences` are neither enforced nor exported in this mode, as their flat-mode counterparts are not. (#36)+- **An x86_64 host runs i686 derivations of its own operating system natively, as upstream's default extra-platforms does.** `i686-linux` and `i686-windows` are platforms of their own now rather than unrecognized strings. A derivation for `i686-windows` spawns directly on an x86_64 Windows host, where WoW64 runs it, and `i686-linux` does the same on an x86_64 Linux host, where the kernel does; upstream adds `i686-linux` to its extra platforms on `x86_64-linux` by default and has no Windows entry yet, so the Windows rule is the same one applied to the platform upstream has not reached. On any other host an i686 derivation is refused or launched through `--exec-wrapper` like any foreign system. (#213)+- **`builtin:fetchurl` retries a transient failure with backoff before moving to the next URL, as upstream's transfer layer does.** One timeout, reset or 5xx against a flapping host used to end that URL's attempt at once, so a short outage on every listed mirror cost the whole fetch. Each URL now gets upstream's five attempts (the `download-attempts` default in `libstore/filetransfer.hh` at 2.24.9), waiting 250 ms before the first retry and doubling after that, with upstream's jitter on the exponent, and each retry is announced on stderr with upstream's `; retrying in N ms` suffix. Upstream's classification decides what a retry can outlive (`libstore/filetransfer.cc` at 2.24.9): a 404 or 410, a 401, 403 or 407, any other 4xx except 408 and 429, a 501, 505 or 511, a malformed or unsupported URL, a redirect loop, a failure writing the output, and a download that verifies wrong are deterministic and move straight to the next URL; everything else, name resolution and TLS included, is retried. A retry restarts the download from the first byte with a fresh hash, where upstream resumes from the written offset only when the server accepts ranges. The policy lives in `BuildConfig` as `bcFetchRetry`, a `FetchRetryPolicy` whose default is upstream's, and the retry loop, delay formula and both classifications are exported from `Nix.Http` with their effects injected, so the policy is tested without a clock or a network. (#238)+- **An interrupt during a push, a fetch cache read or write, a store derivation read or an archive unpack stops the process instead of being read as a missing file, a cache miss or a failed push.** Each of these caught every exception, so a Ctrl-C or a killed thread arriving mid-operation became its fallback value and execution went on: a push reported `push failed: user interrupt` or `HTTP error for <url>: user interrupt` as if the cache had refused it, a fetch cache entry being read became a miss and one being written was dropped, a `.drv` being read became a missing derivation (a failed build or a modulo-hash error), a source tree being verified on disk became a mismatch and was deleted or refused, and an archive being unpacked failed the `builtin:unpack` build. Each now catches only the failure it expects: an `IOException` for a file read, write or removal, an `HttpException` for a push request, and the `ErrorCall` the zstd decoder raises for a corrupt frame. A push, which also meets sqlite and serializer failures, catches every synchronous exception through `Nix.Http.catchSync` and lets an asynchronous one through. Upstream draws the same line by type: `Interrupted` is a `BaseError` beside `Error` rather than under it (`src/libutil/signals.hh` and `src/libutil/error.hh` at 2.24.9), and its fallbacks catch `Error`, as substitution does (`substitution-goal.cc`). A push reads each response body through the transfer layer, so a connection lost mid-body is reported as an HTTP error like any other failure of the request. (#251)++### Substitution++- **The substituter retries a narinfo or NAR download under upstream's transfer-layer policy, the one `builtin:fetchurl` uses.** Upstream serves every download through one `FileTransfer` (`libstore/filetransfer.cc` at 2.24.9) with one backoff and one table of the failures that are never retried, and the substituter kept an older policy of its own. A narinfo fetch was not retried at all, so a single timeout, reset or 5xx on the narinfo cost the path its substitution and, with no other cache holding it, a local build; it now gets upstream's five attempts. A NAR download retried every 5xx and every failure the HTTP client raised, so a 501, 505 or 511 and a redirect loop spent five attempts on an answer that cannot change; they now stop at once, as upstream's never-retried statuses and curl results do, while a 4xx other than 408 and 429 stops at once as before. The delays between attempts become upstream's 250 ms doubling with jitter on the exponent, where the NAR retry waited 0.5, 1, 1.5 and 2 seconds, and each retry is announced on stderr with upstream's `; retrying in N ms` suffix. A narinfo answered 404, 410, 401, 403 or 407 is a miss and is never retried, where only a 404 was a miss and the other four were substitution errors: upstream's transfer layer calls the first two `NotFound` and the rest `Forbidden`, and its binary cache store reads both as the file not being in that cache, since an S3 bucket that cannot be listed answers a missing key with 403. A NAR the narinfo named and the cache answers the same way stops after one request, as before and as upstream's `SubstituteGone` does. An unreachable cache therefore costs each narinfo the full retry budget before the scan moves on (the delays alone come to four to five seconds), as it does upstream, which disables a failing cache for a minute only when `fallback` is set. A connection reset in the middle of a body is now a transport failure, retried like any other, in `builtin:fetchurl` too: the HTTP client wraps what fails while a response is opened but hands the body reader over unwrapped, so a reset mid-body arrived as a bare `IOException` and was taken for a failure writing the output, where curl's receive errors are transient upstream. `bcFetchRetry` governs the substituter's requests as well as `builtin:fetchurl`'s, as upstream's one `download-attempts` setting governs every transfer. (#243)+- **Every HTTP request nova-nix's own client makes identifies itself, as upstream's do.** Only `builtin:fetchurl` sent a User-Agent; the substituter and `push` sent none. A request with no User-Agent is what bot mitigation in front of a server scores lowest: Cloudflare in front of cache.novavero.ai challenged such requests from a GitHub runner and passed the same requests carrying `nova-nix/VERSION`, so the public cache could be read by a person's browser and not by the tool built to read it. Upstream sends `curl/VERSION Nix/VERSION` on every request. The header is now set in one place for the fetcher, the substituter and `push`, with the version Cabal built in. `builtins.fetchurl` and `builtins.fetchTarball` run `curl`, which sends its own User-Agent. (#215)++### Windows packages++- **`pkgs/windows/fetchurl.nix` understands nixpkgs' `mirror://<site>/<path>` scheme, and hello and sed fetch through `mirror://gnu/`.** A recipe named a hand-picked handful of hosts, so one unreachable host could fail the fetch. The site's hosts now come from `pkgs/windows/mirrors.nix`, a verbatim copy of the sets this package set uses from nixpkgs' `pkgs/build-support/fetchurl/mirrors.nix` at the commit the file names, so a recipe spells its source the way the nixpkgs recipe for the same package does and gets the whole list, tried in nixpkgs' order. nixpkgs expands the scheme in its fetch builder at build time; `builtin:fetchurl` runs no shell, so the expansion happens at eval and the derivation records the expanded list, which drops the `ftp://` host the HTTP client cannot fetch. An unknown site is an eval error naming the recipe's URL, where nixpkgs' build script only warns; a mirror URL with no path (`mirror://gnu`) is an eval error too, where nixpkgs expands it to the bare hosts. The source output paths are unchanged (they are fixed by the hash); the source and package derivation paths change, as they describe the new URL list. (#238)++### Distribution++- **Each Hackage upload carries the library's documentation, built on the pinned toolchain.** Hackage's own documentation builder runs a GHC older than the base floor this package declares, so it could not plan the build, and the package page showed a failed build report and no documentation. The Hackage workflow now builds the haddocks for the library and uploads them after the sdist, and a docs-only run uploads them for a version that is already on Hackage. (#211)+- **The public cache carries the current Windows Hello closure, refreshed on every push to main.** cache.novavero.ai was seeded by hand once and nothing republished it when the recipes changed, so a fresh Windows store pointed at it substituted the MinGW-w64 toolchain and built everything else. A publish job after the Windows end-to-end job builds the closure, runs Hello and pushes it, and a second job substitutes that closure into a fresh store from the public cache alone and fails on any local build, so a stale cache turns CI red instead of causing a silent rebuild. (#212)+- **The `default.nix` at the repository root is removed.** It pinned nixpkgs 24.11, whose newest GHC cannot satisfy this package's `base >= 4.22`, so it could not build nova-nix, and nothing used it. (#317)++### Dependencies++- **`unix` (POSIX) and `Win32` (Windows) become direct dependencies, at `unix >= 2.8 && < 2.9` and `Win32 >= 2.9 && < 2.15`.** `base` has no exclusive open, and the store's unpack now needs one, so it calls the platform's own (`openFd` with `O_EXCL`, `CreateFileW` with `CREATE_NEW`). Both are GHC boot packages that `directory` and `process` already pulled into every build, so the solver resolves the versions it already had. The floors are the APIs used: unix 2.8 is the first whose `openFd` takes `creat` and `cloexec` in its flags, and Win32 2.9 the first whose `hANDLEToHandle` serves the native Windows I/O manager. (#234)+- **New dependencies and raised floors.** The library adds `random >= 1.2 && < 1.4` and the executable adds `file-io >= 0.1.1 && < 0.2`. `directory` rises to `>= 1.3.8` in the library and executable, and `filepath` to `>= 1.4.100` in all three. The test suite adds `http-types >= 0.12 && < 0.13` and `QuickCheck >= 2.12.5 && < 2.19`, and requires `process >= 1.6.12`.+ ## 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.
README.md view
@@ -4,22 +4,31 @@ [](https://hackage.haskell.org/package/nova-nix) [](LICENSE) -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 exists to make [Nix](https://nixos.org) work natively on Windows:+the same language, the same `/nix/store` paths and the same binary caches as+upstream Nix, without WSL or an existing Nix installation. +It is an implementation of Nix in Haskell with a C99 data layer, and it has+its own parser, evaluator, store, builder and binary-cache substituter. It+also builds and runs on macOS and Linux.+ nova-nix is experimental. Read [Limitations](#limitations) before relying on it. ## Status -- **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]).+- **Evaluation matches upstream Nix on the cases CI checks.** 22 derivations+ evaluate to the same store paths as upstream on three pinned pairings: Nix+ 2.24.9 with nixpkgs 24.11, Nix 2.34.9 with nixpkgs 26.05, and Nix 2.35.4+ with nixpkgs unstable. They span the native, i686 and musl stdenv+ bootstraps, cross-compilation to aarch64 Linux and Windows, multi-output+ packages such as curl and systemd, sources fetched through git, GitHub,+ PyPI and the cargo and Go vendoring fetchers, a NixOS initrd and system,+ and fixtures for derivation inputs, `builtins.toFile` and+ `builtins.fetchGit`. 49 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@@ -28,19 +37,46 @@ 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.+ building. CI checks this two ways. On Linux, two fixtures are pushed to a+ local nova-cache server and substituted into a second empty store with+ signature verification on: a small tree whose two copies must serialize+ to the same NAR, and a 320 MiB tree that must substitute under a 128 MiB+ heap cap. On every push to main, a Windows job builds the Hello closure,+ runs Hello and publishes the closure to the public cache at+ [cache.novavero.ai](https://cache.novavero.ai), and a second Windows job+ builds the same closure into an empty store with that cache as its only+ substituter, fails if any path is built locally, and runs the result. ### Limitations - 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.+- Evaluation is slower than upstream's. CI's 22 `drvPath` cases take 10 to 13+ times as long in total, and a full NixOS system takes 22 to 30 times as long.+- `build --substituter` works through a package's build closure from the+ leaves up, so a package a cache holds still downloads its build-time+ dependencies ([#307]).+- Garbage collection has no runtime roots: `nova-nix store gc` keeps what+ `build --out-link` registered and what the operator roots under the store's+ `.nova-nix/gcroots`, not what a running program has open. `keep-derivations`+ and `keep-outputs` are not implemented, so an unrooted `.drv` is collected.+ `build` creates no result link unless `--out-link` is given, where+ `nix-build` defaults to `./result`.+- `--restrict-eval` and `--pure-eval` exist (and `restrict-eval`, `pure-eval`+ and `allowed-uris` in nix.conf), gating reads, the environment and fetches+ the way upstream's modes do. The closure of a search path root that is a+ store path is not allowed along with it, and the bundled `<nix/*>` directory+ stays listed in `builtins.nixPath` under `--pure-eval`, where upstream+ shows an empty list.+- The Windows store is `C:\nix\store`, where upstream's Windows default is+ `%PROGRAMDATA%\nix\store`, so the two do not share store paths on Windows+ ([#309]). - 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]).+ stops everything it started. No platform has 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.+- `builtins.fetchurl` runs `curl`, `builtins.fetchTarball` runs `sh`, `curl` and+ `tar`, and `builtins.fetchGit` runs `git`, each from `PATH`. - 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.@@ -82,7 +118,9 @@ $ 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 build FILE.nix --out-link result # link the result and root it+$ nova-nix store gc # delete everything unrooted+$ nova-nix push --cache URL --key-file FILE --all # upload every path except derivations $ nova-nix --help ``` @@ -105,12 +143,19 @@ ``` 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.+Hello source), then builds the MSYS2 seed, the MinGW-w64 seed and Hello. The+[public cache](https://cache.novavero.ai) holds this closure as CI last built+it from main, so a build that names the cache downloads what it holds and+builds only what it lacks: +```console+> bin\nova-nix build pkgs\windows\hello.nix --substituter https://cache.novavero.ai --trusted-key cache.novavero.ai-1:9gQ7tLWMM+2tdC9H5sKMJltDIPfD7X2GWlZe8Aa8hHQ=+```+ ## How it works - **Parser** (`Nix.Parser`): a hand-written recursive-descent parser for the- Nix 2.24 language.+ Nix 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@@ -122,11 +167,11 @@ 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.+- **Store** (`Nix.Store`): a store at `/nix/store`, or `C:\nix\store` on+ Windows, with SQLite metadata, reference scanning and per-path locks.+- **Builder** (`Nix.Builder`): orders derivations by their dependencies,+ substitutes each one a cache holds, and runs the rest 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).@@ -146,6 +191,7 @@ import Nix.Builtins (builtinEnv) import Nix.Eval (eval, runPureEval) import Nix.Eval.Arena (arenaDestroy, arenaInit)+import Nix.Eval.Policy (unrestrictedPolicy) import Nix.Parser (parseNix) main :: IO ()@@ -153,7 +199,7 @@ -- 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))+ Right expr -> print (runPureEval (eval (builtinEnv unrestrictedPolicy 0 []) expr)) ``` This prints `Right (VInt 11)`. Evaluation needs the C data layer, so it must@@ -184,7 +230,8 @@ 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+[#307]: https://github.com/Novavero-AI/nova-nix/issues/307+[#309]: https://github.com/Novavero-AI/nova-nix/issues/309
app/Main.hs view
@@ -12,38 +12,53 @@ -- this module is in the executable stanza. module Main (main) where -import Control.Exception (IOException, displayException, try)-import Control.Monad (void, (>=>))+import Control.Exception (AsyncException (UserInterrupt), IOException, displayException, handleJust, try)+import Control.Monad (join, mfilter, void, (>=>)) import qualified Data.ByteString as BS+import qualified Data.ByteString.Builder as BB import qualified Data.ByteString.Char8 as BC import Data.IORef (readIORef)+import Data.List (find) import qualified Data.Map.Strict as Map-import Data.Maybe (catMaybes)+import Data.Maybe (catMaybes, mapMaybe) import qualified Data.Text as T import qualified Data.Text.Encoding as TE import qualified Data.Text.IO as TIO import Data.Version (showVersion) import Nix.Builder (BuildConfig (..), BuildResult (..), buildWithDeps, defaultBuildConfig, execWrapperConfig)-import Nix.Builtins (builtinEnv, parseNixPath)+import Nix.Builtins (builtinEnv, isNixPathPseudoUrl, parseNixPath, rootScopeNames, searchPathRoots) 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.Derivation (Derivation (..), DerivationOutput (..), fromATerm)+import Nix.Environment (EnvLookup (..), lookupEnvBytes)+import Nix.Eval (Env, EvalPolicy (..), MonadEval, NixValue (..), Thunk (..), attrSetFromMap, attrSetLookup, 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 (bytesToTextLossy, clistFromThunks, clistThunks, thunkToCPtr)-import Nix.Parser (parseNix, readFileAutoEncoding)-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.Eval.CallDepth (topLevelCallDepth)+import Nix.Eval.CanonPath (canonPathValue)+import Nix.Eval.IO (EvalState (..), allowEvalPath, newEvalState, renderEvalFailure, runEvalIOTraced)+import Nix.Eval.Print (printAmbiguous)+import Nix.Eval.Types (clistFromThunks, clistThunks, thunkToCPtr)+import Nix.Expr.Resolve (undefinedVariableMessage)+import Nix.Expr.Types (Expr)+import Nix.HostPath (hostPathFromBytes, hostPathText)+import Nix.Parser (SourceError (..), parseNixWithScope, readFileAutoEncoding)+import Nix.Push (PushCompression (..), PushConfig (..), PushSummary (..), isDerivationPath, loadApiKeyFile, outputPathsOnly, parsePushCompression, pushCompressionValues, pushPaths, storePathBasename)+import Nix.Store (DeleteOutcome (..), GcRoot (..), LiveSet, Store (..), addOutLinkRoot, canonicalStoreDir, closeStore, collectGarbage, deleteStorePathChecked, findRoots, gcSummaryLine, materializeEvalSources, materializeEvalStoreWrites, openStore, queryAllValidPaths, resolveDeleteTarget, withLiveSet, writeDrv, writeDrvClosure)+import Nix.Store.Path (StoreDir (..), StorePath, defaultStoreDir, parseStorePath, parseStorePathBaseName, platformStoreDir, storePathToFilePath, storePathToText) import Nix.Substituter (CacheConfig (..)) import Paths_nova_nix (getDataDir, version)-import System.Directory (Permissions (executable), XdgDirectory (XdgConfig), canonicalizePath, doesFileExist, findExecutable, getCurrentDirectory, getPermissions, getTemporaryDirectory, getXdgDirectory)+import System.Directory (Permissions (executable), canonicalizePath, doesFileExist, findExecutable, getCurrentDirectory, getPermissions, getTemporaryDirectory, makeAbsolute)+import qualified System.Directory.OsPath as OsDir import System.Environment (getArgs, getExecutablePath, lookupEnv)-import System.Exit (exitFailure)+import System.Exit (ExitCode (..), exitFailure, exitWith)+import qualified System.File.OsPath as OsFile import System.FilePath (takeDirectory, takeFileName, (</>))-import System.IO (BufferMode (..), hPutStrLn, hSetBuffering, hSetEncoding, stderr, stdout, utf8)+import System.IO (BufferMode (..), hPutStrLn, hSetBinaryMode, hSetBuffering, hSetEncoding, stderr, stdout, utf8)+import System.IO.Error (isDoesNotExistError)+import qualified System.Info as SI+import System.OsPath (OsPath)+import qualified System.OsPath as OP -- --------------------------------------------------------------------------- -- Argument parsing@@ -63,15 +78,21 @@ -- | @SYSTEM=PATH@ launchers for derivations this machine cannot execute -- directly, e.g. @x86_64-windows=/path/to/wine@. optExecWrappers :: ![String],+ -- | @--restrict-eval@: upstream's restrict-eval, turned on for this run.+ optRestrictEval :: !Bool,+ -- | @--pure-eval@: upstream's pure-eval, turned on for this run.+ optPureEval :: !Bool, optCommand :: !Command } data Command = CmdEvalFile !FilePath | CmdEvalExpr !T.Text- | CmdBuild !BuildTarget !(Maybe T.Text)+ | CmdBuild !BuildTarget !(Maybe T.Text) !(Maybe FilePath) | CmdPush !PushArgs | CmdStoreDelete ![String]+ | -- | @store gc@: collect, or with the flag only list the roots.+ CmdStoreGc !Bool | -- | 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@@ -91,12 +112,14 @@ -- | Arguments to the build command, while the target is still unknown. data BuildArgs = BuildArgs { baTarget :: !(Maybe BuildTarget),- baAttrPath :: !(Maybe T.Text)+ baAttrPath :: !(Maybe T.Text),+ -- | Where to create the result symlink, registered as a GC root.+ baOutLink :: !(Maybe FilePath) } -- | Build arguments before any flag is parsed. emptyBuildArgs :: BuildArgs-emptyBuildArgs = BuildArgs Nothing Nothing+emptyBuildArgs = BuildArgs Nothing Nothing Nothing -- | Arguments to the push command. data PushArgs = PushArgs@@ -115,7 +138,7 @@ -- 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)+parseArgs = go (CliOpts [] False False Nothing Nothing Nothing [] False False CmdUsage) where go opts [] = Right opts -- Answered before anything else is looked at, and the rest of the line@@ -137,6 +160,10 @@ go (opts {optTrustedKey = Just key}) rest go opts ("--exec-wrapper" : spec : rest) = go (opts {optExecWrappers = optExecWrappers opts ++ [spec]}) rest+ go opts ("--restrict-eval" : rest) =+ go (opts {optRestrictEval = True}) rest+ go opts ("--pure-eval" : rest) =+ go (opts {optPureEval = True}) rest go opts ("eval" : rest) = goEval opts rest go opts ("build" : rest) = goBuild opts emptyBuildArgs rest go opts ("push" : rest) = goPush opts emptyPushArgs rest@@ -155,6 +182,10 @@ -- much after the subcommand as before it. goEval opts ("--store" : dir : rest) = goEval (opts {optStore = Just dir}) rest+ goEval opts ("--restrict-eval" : rest) =+ goEval (opts {optRestrictEval = True}) rest+ goEval opts ("--pure-eval" : rest) =+ goEval (opts {optPureEval = True}) rest goEval opts ("--expr" : expr : rest) = go (opts {optCommand = CmdEvalExpr (T.pack expr)}) rest goEval _ [flag]@@ -181,12 +212,19 @@ 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 ("--restrict-eval" : rest) =+ goBuild (opts {optRestrictEval = True}) buildArgs rest+ goBuild opts buildArgs ("--pure-eval" : rest) =+ goBuild (opts {optPureEval = True}) 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+ | flag `elem` outLinkFlags = case baOutLink buildArgs of+ Just _ -> Left "build accepts one --out-link"+ Nothing -> goBuild opts (buildArgs {baOutLink = Just path}) rest goBuild _ _ [flag] | flag `elem` valueFlags = Left (flag ++ " requires a value") goBuild _ _ (arg@('-' : _) : _) = Left ("unknown build flag: " ++ arg)@@ -199,7 +237,7 @@ 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)}+ Just target -> Right opts {optCommand = CmdBuild target (baAttrPath buildArgs) (baOutLink buildArgs)} -- Sub-parser for push: flags and explicit store paths in any order. goPush opts pushArgs [] = Right opts {optCommand = CmdPush pushArgs} goPush opts pushArgs ("--store" : dir : rest) =@@ -218,9 +256,10 @@ 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 _ [] = Left "store: expected a subcommand (delete, gc)" goStore opts ("delete" : rest) = goStoreDelete opts [] rest- goStore _ (sub : _) = Left ("unknown store subcommand: " ++ sub ++ " (expected: delete)")+ goStore opts ("gc" : rest) = goStoreGc opts False rest+ goStore _ (sub : _) = Left ("unknown store subcommand: " ++ sub ++ " (expected: delete, gc)") goStoreDelete opts paths [] | null paths = Left "store delete: name at least one store path" | otherwise = Right opts {optCommand = CmdStoreDelete paths}@@ -231,12 +270,22 @@ goStoreDelete _ _ (arg@('-' : _) : _) = Left ("unknown store delete flag: " ++ arg) goStoreDelete opts paths (path : rest) = goStoreDelete opts (paths ++ [path]) rest+ goStoreGc opts printRoots [] = Right opts {optCommand = CmdStoreGc printRoots}+ goStoreGc opts printRoots ("--store" : dir : rest) =+ goStoreGc (opts {optStore = Just dir}) printRoots rest+ goStoreGc opts _ ("--print-roots" : rest) = goStoreGc opts True rest+ goStoreGc _ _ [flag]+ | flag `elem` valueFlags = Left (flag ++ " requires a value")+ goStoreGc _ _ (arg : _) = Left ("unknown store gc argument: " ++ arg) -- Flags that consume the following argument as their value. valueFlags = ["--nix-path", "--store", "--substituter", "--trusted-key", "--exec-wrapper", "--expr", "--cache", "--key-file", "--compression"] ++ attrFlags+ ++ outLinkFlags -- Attribute selection, under both of upstream's spellings. attrFlags = ["-A", "--attr"]+ -- The result link, under both of nix-build's spellings.+ outLinkFlags = ["-o", "--out-link"] -- | Upstream C++ Nix's name for this directory, so an operator who knows -- one knows the other.@@ -245,9 +294,36 @@ -- | The environment variable carrying inline nix.conf settings, above the -- config files and below the command line in precedence.-nixConfigVar :: String+nixConfigVar :: BS.ByteString nixConfigVar = "NIX_CONFIG" +-- | The directory holding the system nix.conf, in place of @/etc/nix@.+nixConfDirVar :: BS.ByteString+nixConfDirVar = "NIX_CONF_DIR"++-- | The user config files, first strongest, in place of the XDG cascade.+nixUserConfFilesVar :: BS.ByteString+nixUserConfFilesVar = "NIX_USER_CONF_FILES"++-- | The XDG config dirs, searched for @nix\/nix.conf@ after the config home.+xdgConfigDirsVar :: BS.ByteString+xdgConfigDirsVar = "XDG_CONFIG_DIRS"++-- | Windows' all-users application data directory, the machine-wide+-- config directory there.+programDataVar :: BS.ByteString+programDataVar = "ProgramData"++-- | Upstream's @sysconfdir@ on Unix, which libstore's meson.build forces+-- absolute, so @\/etc\/nix@ is the compiled-in system directory.+posixSysconfDir :: FilePath+posixSysconfDir = "/etc"++-- | The XDG base directory spec's default for @XDG_CONFIG_DIRS@, the+-- literal upstream's @getConfigDirs@ falls back to.+posixXdgConfigDirs :: [FilePath]+posixXdgConfigDirs = ["/etc/xdg"]+ -- | Where a release archive keeps the bundled expressions, relative to the -- directory holding @bin@. bundledDataSubdir :: FilePath@@ -286,7 +362,7 @@ -- The data dir is appended last so user paths take priority. mergeSearchPaths :: [T.Text] -> FilePath -> [Thunk] -> [Thunk] mergeSearchPaths extraPaths dataDir envPaths =- concatMap parseNixPath extraPaths ++ envPaths ++ parseNixPath (T.pack dataDir)+ concatMap (parseNixPath . TE.encodeUtf8) extraPaths ++ envPaths ++ parseNixPath (TE.encodeUtf8 (T.pack dataDir)) -- --------------------------------------------------------------------------- -- Main@@ -297,8 +373,9 @@ 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.+ -- store path or message containing one would otherwise abort the whole+ -- invocation mid-print on legacy Windows consoles. An eval result does+ -- not pass through this: 'printResult' writes it as bytes. hSetEncoding stdout utf8 hSetEncoding stderr utf8 -- Initialize C data layer (symbol interning, thunk arena, env allocator)@@ -306,18 +383,36 @@ args <- getArgs dataDir <- resolveDataDir opts <- either (failWith . T.pack) pure (parseArgs args)- case optCommand opts of- CmdEvalFile filePath -> evalFile (chosenStoreDir opts) (optStrict opts) (optNixPaths opts) dataDir filePath+ config <- loadNixConfig+ storeDir <- chosenStoreDir opts+ -- SIGINT reaches the main thread as UserInterrupt, which uncaught ends+ -- the process by the signal with nothing said. Upstream's checkInterrupt+ -- throws Interrupted, a BaseError its handleExceptions logs and exits 1+ -- with (libmain/shared.cc at 2.24.9).+ handleJust userInterrupt (const reportInterrupt) $ case optCommand opts of+ CmdEvalFile filePath -> evalFile config opts storeDir dataDir filePath CmdEvalExpr expr- | 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- CmdStoreDelete paths -> storeDeleteCommand opts paths+ | optAterm opts -> evalExprAterm config opts storeDir dataDir expr+ | otherwise -> evalExpr config opts storeDir dataDir expr+ CmdBuild target attrPath outLink -> buildCommand config opts storeDir dataDir target attrPath outLink+ CmdPush pushArgs -> pushCommand storeDir pushArgs+ CmdStoreDelete paths -> storeDeleteCommand storeDir paths+ CmdStoreGc printRoots -> storeGcCommand storeDir printRoots CmdUsage -> mapM_ (hPutStrLn stderr) usageLines >> exitFailure CmdHelp -> mapM_ putStrLn usageLines CmdVersion -> putStrLn versionLine +-- | 'UserInterrupt' alone: every other asynchronous exception keeps+-- propagating.+userInterrupt :: AsyncException -> Maybe ()+userInterrupt UserInterrupt = Just ()+userInterrupt _ = Nothing++reportInterrupt :: IO a+reportInterrupt = do+ hPutStrLn stderr "error: interrupted by the user"+ exitWith (ExitFailure 1)+ -- | 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.@@ -337,14 +432,17 @@ " 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",+ " store delete PATH... Remove store paths, refused while a root or another valid path keeps them",+ " store gc Remove every store path not reachable from a root", "", "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)",+ " -o, --out-link PATH With build: create PATH as a symlink to the result and register it as a GC root",+ " --print-roots With store gc: list the roots as LINK -> PATH and delete nothing", " --nix-path NAME=PATH Add search path (repeatable, merged with NIX_PATH)",- " --all With push: select every valid path in the store",+ " --all With push: select every valid path except derivations", " --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),",@@ -352,10 +450,18 @@ " --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",+ " --restrict-eval Confine reads to the search path roots and what the",+ " evaluation itself writes; fetch only allowed-uris",+ " --pure-eval Confine reads as --restrict-eval does, with no search",+ " path, no currentTime, currentSystem or storePath, and",+ " pinned fetches only (allowed-uris applies only under",+ " --restrict-eval)", "",- " 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.",+ " substituters, trusted-public-keys, max-call-depth, restrict-eval,",+ " pure-eval and allowed-uris also read from nix.conf, in upstream's",+ " order: $NIX_CONF_DIR (default " <> systemConfDirName <> "), the XDG",+ " nix/nix.conf files or $NIX_USER_CONF_FILES, then $NIX_CONFIG; the",+ " flags above add to whatever those configure.", "", " --help Print this text and exit", " --version Print the version and exit"@@ -384,74 +490,126 @@ exprSourceName :: T.Text exprSourceName = "<expr>" +-- | The evaluation policy for this run: the config cascade's settings,+-- with either flag turning its mode on. A flag only turns a mode on, so+-- a machine configured for pure evaluation stays that way whatever the+-- command line says, which is the direction a confinement should fail in.+evalPolicyFor :: CliOpts -> NixConfig -> EvalPolicy+evalPolicyFor opts config =+ EvalPolicy+ { epRestrictEval = optRestrictEval opts || ncRestrictEval config,+ epPureEval = optPureEval opts || ncPureEval config,+ epAllowedUris = ncAllowedUris config+ }++-- | The evaluation state and root environment for one command. Upstream's+-- constructor decides the same things in the same order (eval.cc at+-- 2.24.9): under pure-eval the lookup path is empty, so neither+-- @--nix-path@ nor @NIX_PATH@ contributes, and only the bundled @<nix/*>@+-- directory remains, which upstream serves from an in-memory corepkgs+-- filesystem and this binary serves from its data directory (so it stays+-- visible in @builtins.nixPath@, where upstream shows nothing); under+-- either mode every search path root is allowed, as @resolveLookupPathPath@+-- allows the lookup path, so @-I nixpkgs=...@ is what makes a nixpkgs+-- tree readable.+--+-- Each root is allowed under its given spelling and under its resolved+-- one. The access walk follows symlinks, so a root reached through a+-- symlinked directory (macOS's @/tmp@ is @/private/tmp@) is checked under+-- the resolved spelling; upstream allows only the given one and refuses+-- the read, which is a mismatch between its own allow list and walk, not+-- a rule worth keeping.+setUpEval :: CliOpts -> NixConfig -> StoreDir -> FilePath -> FilePath -> IO (EvalState, Env)+setUpEval opts config storeDir dataDir baseDir = do+ let policy = evalPolicyFor opts config+ st0 <- newEvalState storeDir baseDir+ let searchPaths+ | epPureEval policy = parseNixPath (TE.encodeUtf8 (T.pack dataDir))+ | otherwise = mergeSearchPaths (optNixPaths opts) dataDir (esSearchPaths st0)+ st =+ st0+ { esSearchPaths = searchPaths,+ esPolicy = policy,+ esCallDepth = topLevelCallDepth (ncMaxCallDepth config)+ }+ -- A relative root resolves against the working directory, as upstream's+ -- absPath does. A pseudo-URL entry (channel:, flake:, a listed scheme)+ -- names a download this evaluator never performs and allows nothing:+ -- upstream allows such an entry's working-directory spelling only by+ -- falling through after a failed download (resolveLookupPathPath), and+ -- a run that cannot fetch should not widen what it may read. A root+ -- with no UTF-8 reading allows nothing: allowed paths are text, so no+ -- read can name it, and a lossy spelling would allow a different path.+ roots <- mapM rootSpellings (mapMaybe textRoot (filter (not . isNixPathPseudoUrl) (searchPathRoots searchPaths)))+ mapM_ (allowEvalPath st) (concat roots)+ pure (st, builtinEnv policy (esTimestamp st) searchPaths)+ where+ textRoot = either (const Nothing) Just . TE.decodeUtf8'+ rootSpellings root = do+ given <- makeAbsolute (T.unpack root)+ resolved <- canonicalizePath given+ pure (map (canonPathValue . T.pack) [given, resolved])++-- | Parse and bind source against the names the root environment+-- 'setUpEval' builds will bind, which depend on the policy.+parseForEval :: CliOpts -> NixConfig -> FilePath -> T.Text -> T.Text -> Either SourceError Expr+parseForEval opts config = parseNixWithScope (rootScopeNames (evalPolicyFor opts config))+ -- | Evaluate a .nix file and print the result.-evalFile :: StoreDir -> Bool -> [T.Text] -> FilePath -> FilePath -> IO ()-evalFile storeDir strict extraPaths dataDir rawFilePath = do+evalFile :: NixConfig -> CliOpts -> StoreDir -> FilePath -> FilePath -> IO ()+evalFile config opts storeDir 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+ case parseForEval opts config (takeDirectory filePath) (T.pack filePath) source of+ Left err -> failSource err Right expr -> do- st0 <- newEvalState storeDir (takeDirectory filePath)- let searchPaths = mergeSearchPaths extraPaths dataDir (esSearchPaths st0)- st = st0 {esSearchPaths = searchPaths}- result <-- runEvalIO st $- eval (builtinEnv (esTimestamp st) searchPaths) expr >>= finalize strict+ (st, env) <- setUpEval opts config storeDir dataDir (takeDirectory filePath)+ result <- runEvalIOTraced st (eval env expr >>= finalize (optStrict opts)) case result of- Left err -> do- TIO.hPutStrLn stderr ("error: " <> err)+ Left failure -> do+ TIO.hPutStrLn stderr (renderEvalFailure failure) exitFailure- Right forced -> TIO.putStrLn (prettyValue forced)+ Right forced -> printResult forced -- | Evaluate an inline expression and print the result.-evalExpr :: StoreDir -> Bool -> [T.Text] -> FilePath -> T.Text -> IO ()-evalExpr storeDir strict extraPaths dataDir source = do+evalExpr :: NixConfig -> CliOpts -> StoreDir -> FilePath -> T.Text -> IO ()+evalExpr config opts storeDir dataDir source = do cwd <- getCurrentDirectory- case parseNix cwd exprSourceName source of- Left err -> do- hPutStrLn stderr ("parse error: " ++ show err)- exitFailure+ case parseForEval opts config cwd exprSourceName source of+ Left err -> failSource err Right expr -> do- st0 <- newEvalState storeDir cwd- let searchPaths = mergeSearchPaths extraPaths dataDir (esSearchPaths st0)- st = st0 {esSearchPaths = searchPaths}- result <-- runEvalIO st $- eval (builtinEnv (esTimestamp st) searchPaths) expr >>= finalize strict+ (st, env) <- setUpEval opts config storeDir dataDir cwd+ result <- runEvalIOTraced st (eval env expr >>= finalize (optStrict opts)) case result of- Left err -> do- TIO.hPutStrLn stderr ("error: " <> err)+ Left failure -> do+ TIO.hPutStrLn stderr (renderEvalFailure failure) exitFailure- Right forced -> TIO.putStrLn (prettyValue forced)+ Right forced -> printResult forced -- | Evaluate an inline expression to a derivation and print its ATerm (.drv -- contents), for diffing nova-nix's serialization against upstream Nix.-evalExprAterm :: StoreDir -> [T.Text] -> FilePath -> T.Text -> IO ()-evalExprAterm storeDir extraPaths dataDir source = do+evalExprAterm :: NixConfig -> CliOpts -> StoreDir -> FilePath -> T.Text -> IO ()+evalExprAterm config opts storeDir dataDir source = do cwd <- getCurrentDirectory- case parseNix cwd exprSourceName source of- Left err -> do- hPutStrLn stderr ("parse error: " ++ show err)- exitFailure+ case parseForEval opts config cwd exprSourceName source of+ Left err -> failSource err Right expr -> do- 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+ (st, env) <- setUpEval opts config storeDir dataDir cwd+ result <- runEvalIOTraced st $ do+ val <- eval env expr forceDerivationAttrs val pure val case result of- Left err -> do- TIO.hPutStrLn stderr ("error: " <> err)+ Left failure -> do+ TIO.hPutStrLn stderr (renderEvalFailure failure) exitFailure Right val -> do- (drv, _) <- extractDerivation val- -- Raw ATerm bytes (BC.putStrLn bypasses the handle encoding),- -- so the printed .drv diffs byte-exactly against upstream's.- BC.putStrLn (toATerm drv)+ drvSP <- derivationPath val+ drvClosure <- readIORef (esDrvClosure st)+ aterm <- recordedAterm drvClosure drvSP+ -- The recorded bytes, not a re-serialization: these are what the+ -- path hashes. BC.putStrLn bypasses the handle encoding, so the+ -- printed .drv diffs byte-exactly against upstream's.+ BC.putStrLn aterm -- | Parse, evaluate, extract derivation, build, and print result. -- The file argument is canonicalized for the same reason as in 'evalFile'.@@ -465,9 +623,11 @@ cwd <- getCurrentDirectory pure (cwd, exprSourceName, source) --- | Force the attributes 'extractDerivation' goes on to read. @derivation@+-- | Force the attributes 'derivationPath' 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.+-- Forcing @drvPath@ is also what computes the derivation and records its+-- @.drv@ in the session closure ('recordedAterm'). forceDerivationAttrs :: (MonadEval m) => NixValue -> m () forceDerivationAttrs val = case val of VAttrs attrs ->@@ -476,50 +636,51 @@ derivationAttrKeys _ -> pure () --- | What a build needs forced: the marker, the wrapper, and the path whose--- closure the build driver writes.+-- | What a build needs forced: the marker, the path whose closure the+-- build driver writes, and the output it realizes. derivationAttrKeys :: [T.Text]-derivationAttrKeys = ["type", "_derivation", "drvPath"]+derivationAttrKeys = ["type", "drvPath", "outputName"] -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))+buildCommand :: NixConfig -> CliOpts -> StoreDir -> FilePath -> BuildTarget -> Maybe T.Text -> Maybe FilePath -> IO ()+buildCommand config opts storeDir dataDir target attrPath outLink = do+ let caches = resolveCaches config (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+ case parseForEval opts config baseDir sourceName source of+ Left err -> failSource err Right expr -> do- st0 <- newEvalState storeDir baseDir- let searchPaths = mergeSearchPaths (optNixPaths opts) dataDir (esSearchPaths st0)- st = st0 {esSearchPaths = searchPaths}- result <- runEvalIO st $ do- root <- eval (builtinEnv (esTimestamp st) searchPaths) expr+ (st, env) <- setUpEval opts config storeDir dataDir baseDir+ result <- runEvalIOTraced st $ do+ root <- eval env 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+ -- selected value's own attributes unforced, and derivationPath -- 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)+ Left failure -> do+ TIO.hPutStrLn stderr (renderEvalFailure failure) exitFailure Right (Left selectionErr) -> do TIO.hPutStrLn stderr ("error: " <> selectionErr) exitFailure Right (Right val) -> do- (drv, drvSP) <- extractDerivation val+ drvSP <- derivationPath val+ outputName <- defaultOutputName drvSP val -- The full .drv closure (root + every transitive input) recorded- -- during evaluation; written to the store before building.+ -- during evaluation; written to the store before building. The+ -- root's own recipe is read from the same map, so what is built+ -- is what its path hashes. drvClosure <- readIORef (esDrvClosure st)+ drv <- recordedDerivation drvClosure drvSP+ -- Resolved before the build, as nix-build resolves the output it+ -- realizes before building anything.+ outputPath <- namedOutputPath drv outputName sourceCache <- readIORef (esSourcePathCache st) storeWrites <- readIORef (esStoreWriteCache st)- store <- openStore (chosenStoreDir opts)+ store <- openStore storeDir -- 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@@ -529,55 +690,84 @@ -- register them; a derivation naming one needs them valid first. materializeEvalStoreWrites store storeWrites buildResult <- buildAndRegister store caches wrappers drvClosure drv drvSP+ -- The root is registered before the handle closes: the handle's+ -- lease is what keeps a concurrent collection from running+ -- between the build's end and the link's creation. The path+ -- prints after the link exists, as nix-build prints it, so a+ -- failed link prints nothing a script would take for success.+ rooted <- case buildResult of+ BuildSuccess _ -> traverse (\link -> addOutLinkRoot store link outputPath) outLink+ BuildFailure _ _ -> pure Nothing closeStore store- case buildResult of- BuildSuccess sp ->- TIO.putStrLn (T.pack (storePathToFilePath (chosenStoreDir opts) sp))- BuildFailure msg code -> do+ case (buildResult, rooted) of+ (BuildFailure msg code, _) -> do TIO.hPutStrLn stderr ("build failed (exit " <> T.pack (show code) <> "): " <> msg) exitFailure+ (BuildSuccess _, Just (Left err)) -> failWith ("build: " <> err)+ (BuildSuccess _, _) ->+ TIO.putStrLn (T.pack (storePathToFilePath (stDir store) outputPath)) --- | Extract a Derivation and its store path from an evaluated value.--- The value must be a VAttrs with type = "derivation", a _derivation key--- holding the Derivation struct, and a drvPath key holding the .drv store path.--- Both are computed by builtinDerivation during evaluation.-extractDerivation :: NixValue -> IO (Derivation, StorePath)-extractDerivation (VAttrs attrs) = do- -- Check type = "derivation"+-- | The @.drv@ store path of an evaluated derivation value: a set with+-- @type = "derivation"@ whose @drvPath@ has been forced+-- ('forceDerivationAttrs'). Store paths are ASCII, so the byte payload+-- decodes strictly.+derivationPath :: NixValue -> IO StorePath+derivationPath (VAttrs attrs) = do case attrSetLookup "type" attrs of Just thunk | Just (VStr "derivation" _) <- readThunkValue thunk -> pure ()- _ -> do- hPutStrLn stderr "error: result is not a derivation (no type = \"derivation\")"- exitFailure- drv <- case attrSetLookup "_derivation" attrs of- Just thunk | Just (VDerivation d) <- readThunkValue thunk -> pure d- _ -> do- hPutStrLn stderr "error: derivation result missing _derivation field"- 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+ _ -> failWith "error: result is not a derivation (no type = \"derivation\")"+ case attrSetLookup "drvPath" attrs of 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- pure (drv, drvSP)-extractDerivation _ = do- hPutStrLn stderr "error: result is not a derivation"- exitFailure+ maybe (failWith ("error: invalid drvPath: " <> path)) pure (parseStorePath defaultStoreDir path)+ _ -> failWith "error: derivation result missing drvPath"+derivationPath _ = failWith "error: result is not a derivation" --- | The store directory selected by @--store@, or the platform default.-chosenStoreDir :: CliOpts -> StoreDir-chosenStoreDir opts = maybe platformStoreDir StoreDir (optStore opts)+-- | The output a build realizes and prints: the value's @outputName@, the+-- first of its @outputs@, which is what upstream's nix-build builds and+-- prints for a derivation (nix-build.cc, @queryOutputName@), with its+-- message for a set that lacks the attribute.+defaultOutputName :: StorePath -> NixValue -> IO T.Text+defaultOutputName drvSP val = case val of+ VAttrs attrs+ | Just thunk <- attrSetLookup "outputName" attrs,+ Just (VStr nameBytes _) <- readThunkValue thunk,+ Right name <- TE.decodeUtf8' nameBytes ->+ pure name+ _ -> failWith ("error: derivation '" <> storePathToText defaultStoreDir drvSP <> "' lacks an 'outputName' attribute") +-- | The path of a named output. A @.drv@ lists its outputs by name, so+-- the default output is found here by name, never by position.+namedOutputPath :: Derivation -> T.Text -> IO StorePath+namedOutputPath drv outputName =+ case find ((== outputName) . doName) (drvOutputs drv) of+ Just out -> pure (doPath out)+ Nothing -> failWith ("error: derivation has no output named '" <> outputName <> "'")++-- | The @.drv@ ATerm evaluation recorded under a derivation path: the exact+-- bytes whose hash is the path. Every derivation computed in a session is+-- recorded when its @drvPath@ is forced ('esDrvClosure'), so an absent entry+-- means the path was never computed by this evaluation.+recordedAterm :: Map.Map T.Text BS.ByteString -> StorePath -> IO BS.ByteString+recordedAterm drvClosure drvSP =+ case Map.lookup (storePathToText defaultStoreDir drvSP) drvClosure of+ Just aterm -> pure aterm+ Nothing -> failWith ("error: no .drv was recorded for " <> storePathToText defaultStoreDir drvSP)++-- | The 'Derivation' a recorded ATerm describes ('recordedAterm').+recordedDerivation :: Map.Map T.Text BS.ByteString -> StorePath -> IO Derivation+recordedDerivation drvClosure drvSP = do+ aterm <- recordedAterm drvClosure drvSP+ either (failWith . (("error: the recorded .drv for " <> storePathToText defaultStoreDir drvSP <> " does not parse: ") <>)) pure (fromATerm aterm)++-- | The store directory selected by @--store@, or the platform default,+-- under the canonical spelling 'openStore' keys the store by, so what+-- evaluation and every command print is the spelling the database+-- holds.+chosenStoreDir :: CliOpts -> IO StoreDir+chosenStoreDir opts = canonicalStoreDir (maybe platformStoreDir StoreDir (optStore opts))+ -- | Default priority for a config- or CLI-configured substituter -- (cache.nixos.org is 40). substituterPriority :: Int@@ -599,44 +789,147 @@ | 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+-- | The caches from the resolved config plus the CLI flags. 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)+resolveCaches :: NixConfig -> Maybe String -> Maybe String -> [CacheConfig]+resolveCaches base mUrl mKey =+ configToCaches+ base+ { ncSubstituters = ncSubstituters base ++ maybe [] (\url -> [T.pack url]) mUrl,+ ncTrustedPublicKeys = ncTrustedPublicKeys base ++ maybe [] (\key -> [T.pack key]) mKey+ } --- | 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])+-- | Resolve nix.conf from the system file, the user-file cascade and+-- @NIX_CONFIG@, the file locations coming from the environment and the+-- platform's XDG defaults. A file that cannot be read is absent, but one+-- that is read and does not parse is a hard error, so a malformed+-- security-relevant setting cannot pass for no setting.+loadNixConfig :: IO NixConfig+loadNixConfig = do+ locations <- configLocations+ nixConfigBytes <- configVariable nixConfigVar+ paths <- either configError pure (Config.configFilePaths locations)+ Config.loadConfig readConfigFile paths nixConfigBytes+ >>= either configError pure+ where+ configError = failWith . ("error: " <>) --- | 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)+-- | A variable the configuration reads, as its bytes, the way upstream's+-- @getEnv@ reads it. A Windows value holding an unpaired UTF-16+-- surrogate has no bytes nova-nix can carry, so it is refused, as+-- @NIX_PATH@ holding one is.+configVariable :: BS.ByteString -> IO (Maybe BS.ByteString)+configVariable name = do+ found <- lookupEnvBytes name+ case found of+ EnvUnset -> pure Nothing+ EnvValue value -> pure (Just value)+ EnvUnpairedSurrogate ->+ failWith ("error: " <> TE.decodeLatin1 name <> " holds an unpaired UTF-16 surrogate, which has no UTF-8 form") +-- | A variable naming a directory or a list of them, as a host path.+configVariablePath :: BS.ByteString -> IO (Maybe OsPath)+configVariablePath name = configVariable name >>= traverse (variableHostPath name)++-- | A variable's bytes as a host path. Every value 'configVariable'+-- answers has one, since a Windows value arrives as UTF-8, so the+-- refusal is for completeness.+variableHostPath :: BS.ByteString -> BS.ByteString -> IO OsPath+variableHostPath name value =+ maybe (failWith ("error: " <> TE.decodeLatin1 name <> " is not valid UTF-8, so it names no Windows path")) pure (hostPathFromBytes value)++-- | Where the config files are on this machine. @NIX_CONF_DIR@ and+-- @XDG_CONFIG_DIRS@ are honoured on every platform; the defaults behind+-- them are the platform's. Upstream compiles @\/etc\/nix@ in as the+-- system directory and falls back to @\/etc\/xdg@ for the dirs on every+-- platform, Windows included, where neither names anything (a path with+-- no drive resolves against the current drive). The Windows analogues+-- are chosen here the way the user file's was (@%APPDATA%@ for the XDG+-- config home, the @directory@ package's own mapping): the system file is+-- @%ProgramData%\\nix\\nix.conf@, the all-users application data directory+-- being where Windows keeps machine-wide application configuration (it is+-- also where @directory@ maps the system-wide XDG dirs), and there is no+-- system file when @%ProgramData%@ is unset; the dirs default to none,+-- because the one Windows directory that answers to them is already the+-- system file, and a file read twice applies its @extra-@ settings twice.+-- The config home comes from @directory@ (@~\/.config@ on Unix,+-- @%APPDATA%@ on Windows, @XDG_CONFIG_HOME@ only when it is absolute); the+-- dirs list is filtered the same way, since 'splitSearchPath' turns an+-- empty POSIX entry into @.@, where upstream's tokenizer drops it, and the+-- XDG spec says a relative entry is ignored.+configLocations :: IO Config.ConfigLocations+configLocations = do+ confDir <- configVariable nixConfDirVar+ systemConfDir <- platformSystemConfDir+ userFiles <- configVariable nixUserConfFilesVar+ home <- OsDir.getXdgDirectory OsDir.XdgConfig mempty+ configDirs <- configVariablePath xdgConfigDirsVar+ defaultConfigDirs <- traverse OP.encodeFS platformConfigDirs+ pure+ Config.ConfigLocations+ { Config.clConfDir = confDir,+ Config.clSystemConfDir = systemConfDir,+ Config.clUserConfFiles = userFiles,+ Config.clConfigHome = home,+ Config.clConfigDirs = maybe defaultConfigDirs (filter OP.isAbsolute . OP.splitSearchPath) configDirs+ }++-- | Where the platform's machine-wide config directory comes from, with+-- @nix@ beneath it either way: a fixed path (upstream's @sysconfdir@ on+-- Unix) or an environment variable naming it (@%ProgramData%@ on+-- Windows). One value serves both the lookup and the help text, so the+-- two cannot disagree.+data SystemConfDirSource = FixedConfDir !FilePath | EnvConfDir !BS.ByteString++platformSystemConfDirSource :: SystemConfDirSource+platformSystemConfDirSource = case SI.os of+ "mingw32" -> EnvConfDir programDataVar+ _ -> FixedConfDir posixSysconfDir++-- | The machine-wide config directory the system file is read from unless+-- @NIX_CONF_DIR@ moves it: upstream's @sysconfdir\/nix@ on Unix;+-- @%ProgramData%\\nix@ on Windows, or none when that variable is unset or+-- empty.+platformSystemConfDir :: IO (Maybe OsPath)+platformSystemConfDir = case platformSystemConfDirSource of+ FixedConfDir dir -> Just . (OP.</> Config.nixConfDirName) <$> OP.encodeFS dir+ EnvConfDir var -> do+ value <- mfilter (not . BS.null) <$> configVariable var+ traverse (fmap (OP.</> Config.nixConfDirName) . variableHostPath var) value++-- | 'platformSystemConfDir' as the help text names it: the path on Unix,+-- the variable on Windows.+systemConfDirName :: String+systemConfDirName = case platformSystemConfDirSource of+ FixedConfDir dir -> dir </> nixDirName+ EnvConfDir var -> "%" <> BC.unpack var <> "%" </> nixDirName+ where+ nixDirName = T.unpack (hostPathText Config.nixConfDirName)++-- | What @XDG_CONFIG_DIRS@ names when it is unset: the spec's default on+-- Unix, nothing on Windows (see 'configLocations').+platformConfigDirs :: [FilePath]+platformConfigDirs = case SI.os of+ "mingw32" -> []+ _ -> posixXdgConfigDirs++-- | A config file's bytes, or why it could not be read: nothing at the+-- path, or something that will not open (a directory, a permission+-- refusal). The open takes the 'OsPath' itself, so a POSIX name reaches+-- it as the bytes it was spelled from rather than through the locale's+-- codec. Only 'IOException' is caught: an interrupt must abort the run,+-- not read as an absent file.+readConfigFile :: OsPath -> IO (Either Config.ConfigReadFailure BS.ByteString)+readConfigFile path = do+ result <- try (OsFile.readFile' path) :: IO (Either IOException BS.ByteString)+ pure (either (Left . classify) Right result)+ where+ classify err+ | isDoesNotExistError err = Config.ConfigFileMissing+ | otherwise = Config.ConfigFileUnreadable+ -- | 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@@ -685,8 +978,8 @@ -- --------------------------------------------------------------------------- -- | Push the closure of the selected store paths to a binary cache.-pushCommand :: CliOpts -> PushArgs -> IO ()-pushCommand opts pushArgs = do+pushCommand :: StoreDir -> PushArgs -> IO ()+pushCommand storeDir pushArgs = do cacheUrl <- case paCacheUrl pushArgs of Just url -> pure (T.dropWhileEnd (== '/') (T.pack url)) Nothing -> failWith "push: --cache URL is required"@@ -702,7 +995,7 @@ compression <- case paCompressionArg pushArgs of Nothing -> pure PushNone Just value -> either (failWith . ("push: " <>)) pure (parsePushCompression (T.pack value))- store <- openStore (chosenStoreDir opts)+ store <- openStore storeDir rootsResult <- resolvePushRoots store pushArgs case rootsResult of Left err -> do@@ -722,42 +1015,69 @@ <> " 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+-- | Delete store paths: registration rows and on-disk trees. Every+-- argument is resolved before anything is touched; the paths are then+-- deleted in argument order under one collector lock and one live set,+-- with upstream's roots line printed once per invocation as its+-- @--delete@ prints it, 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+storeDeleteCommand :: StoreDir -> [String] -> IO ()+storeDeleteCommand storeDir rawPaths = do+ targets <- either (failWith . prefixed) pure (traverse (resolveDeleteTarget storeDir . T.pack) rawPaths)+ store <- openStore storeDir+ result <- withLiveSet store (\live -> deleteEach store live targets) closeStore store- either failWith pure result+ either (failWith . prefixed) pure (join 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+ prefixed err = "store delete: " <> err+ deleteEach :: Store -> LiveSet -> [T.Text] -> IO (Either T.Text ())+ deleteEach _ _ [] = pure (Right ())+ deleteEach store live (basename : rest) = do+ outcome <- deleteStorePathChecked store live basename+ case outcome of+ Left err -> pure (Left err)+ Right removed -> do+ TIO.putStrLn ("deleted " <> basename <> describeOutcome removed)+ deleteEach store live 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.+-- | Collect garbage, or list the roots. The summary line is upstream's+-- (@PrintFreed@), on stdout; the progress lines come from the library on+-- stderr. @--print-roots@ lists @LINK -> PATH@ sorted, each once, as+-- @nix-store --gc --print-roots@ does, and deletes nothing; it runs+-- under the handle's own lease, as upstream's listing takes no+-- collector lock, so it does not wait behind a running build.+storeGcCommand :: StoreDir -> Bool -> IO ()+storeGcCommand storeDir printRoots = do+ store <- openStore storeDir+ if printRoots+ then do+ roots <- findRoots store+ closeStore store+ mapM_ (\root -> TIO.putStrLn (T.pack (grLink root) <> " -> " <> grPath root)) roots+ else do+ results <- collectGarbage store+ closeStore store+ either (failWith . ("store gc: " <>)) (TIO.putStrLn . gcSummaryLine) results++-- | Resolve push roots: every valid non-derivation 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. A named derivation is refused+-- here, with the rule, rather than by the cache's 400 after its NAR has+-- already been uploaded. resolvePushRoots :: Store -> PushArgs -> IO (Either T.Text [StorePath]) resolvePushRoots store pushArgs | paAll pushArgs = do pathTexts <- queryAllValidPaths (stDB store)- pure (traverse parseDbPath pathTexts)- | otherwise = pure (traverse parseArgPath (paPaths pushArgs))+ pure (outputPathsOnly <$> traverse parseDbPath pathTexts)+ | otherwise = pure (traverse parseArgPath (paPaths pushArgs) >>= refuseDerivations) where+ refuseDerivations roots = case filter isDerivationPath roots of+ [] -> Right roots+ drv : _ -> Left ("push: " <> storePathBasename drv <> " is a derivation; a binary cache serves build outputs, not recipes") -- 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.@@ -783,6 +1103,13 @@ TIO.hPutStrLn stderr msg exitFailure +-- | Fail on source that does not parse or bind. An unbound variable is an+-- evaluation error upstream, raised before evaluation starts, and reads as+-- one here.+failSource :: SourceError -> IO a+failSource (SyntaxError err) = failWith ("parse error: " <> T.pack (show err))+failSource (UndefinedVariable name) = failWith ("error: " <> undefinedVariableMessage name)+ -- --------------------------------------------------------------------------- -- Output formatting -- ---------------------------------------------------------------------------@@ -812,49 +1139,11 @@ pure (VAttrs (attrSetFromMap (Map.map evaluated forced))) deepForceValue val = pure val --- | Nix-style pretty-printing of a fully forced value.-prettyValue :: NixValue -> T.Text-prettyValue (VInt n) = T.pack (show n)-prettyValue (VFloat f) = T.pack (show f)-prettyValue (VBool True) = "true"-prettyValue (VBool False) = "false"-prettyValue VNull = "null"-prettyValue (VStr s _) = "\"" <> escapeNixString (bytesToTextLossy s) <> "\""-prettyValue (VPath p) = p-prettyValue (VList cl) =- wrapNixSeq "[" "]" (map (prettyThunk . Thunk) (clistThunks cl))-prettyValue (VAttrs attrs) =- let entries = attrSetToAscList attrs- rendered = map (\(k, t) -> k <> " = " <> prettyThunk t <> ";") entries- in wrapNixSeq "{" "}" rendered-prettyValue (VLambda {}) = "<lambda>"-prettyValue (VBuiltin name _) = "<builtin " <> name <> ">"-prettyValue (VCompiledRegex _) = "<compiled-regex>"-prettyValue (VDerivation drv) =- 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.-prettyThunk :: Thunk -> T.Text-prettyThunk thunk = maybe "<thunk>" prettyValue (readThunkValue thunk)---- | Escape a string for Nix-style output (quotes, backslashes, newlines, tabs, carriage returns).-escapeNixString :: T.Text -> T.Text-escapeNixString = T.concatMap escapeChar- where- escapeChar '\\' = "\\\\"- escapeChar '"' = "\\\""- escapeChar '\n' = "\\n"- escapeChar '\t' = "\\t"- escapeChar '\r' = "\\r"- escapeChar c = T.singleton c+-- | Write an eval result and its newline to stdout as bytes. A string's+-- payload need not be UTF-8, so nothing on this path decodes or re-encodes+-- it: the handle is put in binary mode, as hPutBuilder asks, and no+-- encoding or newline translation applies.+printResult :: NixValue -> IO ()+printResult val = do+ hSetBinaryMode stdout True+ BB.hPutBuilder stdout (printAmbiguous val <> BB.char7 '\n')
cbits/nn_arena.c view
@@ -14,7 +14,17 @@ */ #include "nn_arena.h"+#include "nn_bytecode.h"+#include "nn_env.h"+#include "nn_symbol.h" #include "nn_thunk.h"++int+nn_arena_live(void)+{+ return nn_symbol_live() && nn_thunk_live() && nn_env_live()+ && nn_bytecode_live();+} uint32_t nn_arena_stableptr_count(void)
cbits/nn_arena.h view
@@ -10,7 +10,14 @@ * PATH, LIST, ATTRS, CTXSTR) are skipped. * * Used by Haskell-side Arena.hs to free all StablePtrs before- * destroying the sub-arenas (thunk, env, symbol).+ * destroying the sub-arenas (thunk, env, symbol, bytecode).+ *+ * nn_arena_live answers whether every sub-arena is inside its init ..+ * destroy window. Outside it, each sub-arena's allocators report+ * failure through their ordinary sentinel (NULL, UINT32_MAX,+ * NN_SYMBOL_INVALID) instead of touching freed or never-allocated+ * state; the Haskell boundary asks this on that failure path to tell a+ * missing arenaInit from exhaustion. */ #ifndef NN_ARENA_H@@ -20,6 +27,10 @@ /* Bulk-free all tracked attribute sets (called during arena teardown). */ void nn_attrset_free_all(void);++/* Nonzero when the symbol table, thunk arena, env allocator and bytecode+ * store are all initialized. */+int nn_arena_live(void); /* Count how many thunk payloads are StablePtrs (need freeing). * Iterates all thunks, checks state + val_tag. */
cbits/nn_attrset.c view
@@ -17,7 +17,6 @@ #include "nn_attrset.h" #include "nn_assert.h" -#include <stdio.h> #include <stdlib.h> #include <string.h> @@ -27,20 +26,20 @@ static uint32_t g_tracked_count = 0; static uint32_t g_tracked_cap = 0; -static void nn_attrset_track(nn_attrset_t *set)+/* Returns 0, or -1 when the tracking array cannot grow. */+static int nn_attrset_track(nn_attrset_t *set) { if (g_tracked_count >= g_tracked_cap) {+ if (g_tracked_cap > UINT32_MAX / 2) return -1; uint32_t new_cap = g_tracked_cap ? g_tracked_cap * 2 : 256; nn_attrset_t **new_arr = (nn_attrset_t **)realloc( g_tracked, (size_t)new_cap * sizeof(nn_attrset_t *));- if (!new_arr) {- fprintf(stderr, "nn_attrset_track: realloc failed\n");- abort();- }+ if (!new_arr) return -1; g_tracked = new_arr; g_tracked_cap = new_cap; } g_tracked[g_tracked_count++] = set;+ return 0; } void nn_attrset_free_all(void)@@ -79,9 +78,17 @@ /* --- Helpers --- */ -/* Grow arrays to at least new_cap. Returns 0 on success, -1 on failure. */-static int grow(nn_attrset_t *set, uint32_t new_cap)+/* Double the capacity, stopping at NN_ATTRSET_MAX_ENTRIES; the doubling+ * is computed in uint64_t so it cannot wrap. Returns 0, or -1 with the+ * capacity unchanged when the set is at the ceiling or an allocation+ * fails (an array that did grow is kept, only larger). */+static int grow(nn_attrset_t *set) {+ if (set->capacity >= NN_ATTRSET_MAX_ENTRIES) return -1;+ uint64_t doubled = (uint64_t)set->capacity * 2;+ uint32_t new_cap = doubled > NN_ATTRSET_MAX_ENTRIES+ ? NN_ATTRSET_MAX_ENTRIES : (uint32_t)doubled;+ nn_symbol_t *new_keys = (nn_symbol_t *)realloc( set->keys, (size_t)new_cap * sizeof(nn_symbol_t)); if (!new_keys) return -1;@@ -97,8 +104,8 @@ } /* Binary search for a symbol in the sorted keys array.- * Returns the index if found, or -1. */-static int32_t bsearch_key(const nn_symbol_t *keys, uint32_t count, nn_symbol_t key)+ * Returns the index if found, or NN_ATTRSET_NOT_FOUND. */+static uint32_t bsearch_key(const nn_symbol_t *keys, uint32_t count, nn_symbol_t key) { uint32_t lo = 0; uint32_t hi = count;@@ -109,10 +116,10 @@ } else if (keys[mid] > key) { hi = mid; } else {- return (int32_t)mid;+ return mid; } }- return -1;+ return NN_ATTRSET_NOT_FOUND; } /* --- Lifecycle --- */@@ -130,14 +137,12 @@ set->capacity = capacity; set->frozen = 0; - if (!set->keys || !set->values) {+ if (!set->keys || !set->values || nn_attrset_track(set) != 0) { free(set->keys); free(set->values); free(set); return NULL; }-- nn_attrset_track(set); return set; } @@ -151,18 +156,13 @@ /* --- Construction --- */ -void nn_attrset_insert(nn_attrset_t *set, nn_symbol_t key, void *value)+int nn_attrset_insert(nn_attrset_t *set, nn_symbol_t key, void *value) {- if (set->count >= set->capacity) {- if (grow(set, set->capacity * 2) != 0) {- fprintf(stderr, "nn_attrset_insert: grow failed (capacity %u)\n",- (unsigned)set->capacity);- abort();- }- }+ if (set->count >= set->capacity && grow(set) != 0) return -1; set->keys[set->count] = key; set->values[set->count] = value; set->count++;+ return 0; } void nn_attrset_freeze(nn_attrset_t *set)@@ -265,12 +265,12 @@ void *nn_attrset_lookup(const nn_attrset_t *set, nn_symbol_t key) {- int32_t idx = bsearch_key(set->keys, set->count, key);- if (idx < 0) return NULL;+ uint32_t idx = bsearch_key(set->keys, set->count, key);+ if (idx == NN_ATTRSET_NOT_FOUND) return NULL; return set->values[idx]; } -int32_t nn_attrset_index(const nn_attrset_t *set, nn_symbol_t key)+uint32_t nn_attrset_index(const nn_attrset_t *set, nn_symbol_t key) { return bsearch_key(set->keys, set->count, key); }@@ -310,32 +310,40 @@ /* The merge-join below assumes both inputs are sorted; every set this * module produces is frozen (hence sorted), so guard it in debug. */ NN_ASSERT(a->frozen && b->frozen, "nn_attrset_union: inputs must be frozen");- /* Merge-join: all keys from both, b wins on conflict. */- uint32_t cap = a->count + b->count;+ /* Merge-join: all keys from both, b wins on conflict. The sum of two+ * uint32_t counts needs 33 bits; sized at the ceiling instead, the+ * result still holds any union with enough shared keys to fit, and+ * an insert refuses the one that does not. */+ uint64_t total = (uint64_t)a->count + b->count;+ uint32_t cap = total > NN_ATTRSET_MAX_ENTRIES+ ? NN_ATTRSET_MAX_ENTRIES : (uint32_t)total; nn_attrset_t *result = nn_attrset_new(cap);- if (!result) { fprintf(stderr, "nn_attrset_union: alloc failed\n"); abort(); }+ if (!result) return NULL; uint32_t ia = 0, ib = 0; + /* A refused result stays tracked, so teardown frees it. */ while (ia < a->count && ib < b->count) {+ int status; if (a->keys[ia] < b->keys[ib]) {- nn_attrset_insert(result, a->keys[ia], a->values[ia]);+ status = nn_attrset_insert(result, a->keys[ia], a->values[ia]); ia++; } else if (a->keys[ia] > b->keys[ib]) {- nn_attrset_insert(result, b->keys[ib], b->values[ib]);+ status = nn_attrset_insert(result, b->keys[ib], b->values[ib]); ib++; } else { /* Same key - b wins (right-biased //). */- nn_attrset_insert(result, b->keys[ib], b->values[ib]);+ status = nn_attrset_insert(result, b->keys[ib], b->values[ib]); ia++; ib++; }+ if (status != 0) return NULL; } while (ia < a->count) {- nn_attrset_insert(result, a->keys[ia], a->values[ia]);+ if (nn_attrset_insert(result, a->keys[ia], a->values[ia]) != 0) return NULL; ia++; } while (ib < b->count) {- nn_attrset_insert(result, b->keys[ib], b->values[ib]);+ if (nn_attrset_insert(result, b->keys[ib], b->values[ib]) != 0) return NULL; ib++; } @@ -350,7 +358,7 @@ { NN_ASSERT(set->frozen, "nn_attrset_remove_keys: input must be frozen"); nn_attrset_t *result = nn_attrset_new(set->count);- if (!result) { fprintf(stderr, "nn_attrset_remove_keys: alloc failed\n"); abort(); }+ if (!result) return NULL; uint32_t i; for (i = 0; i < set->count; i++) {@@ -364,8 +372,8 @@ break; } }- if (!skip) {- nn_attrset_insert(result, set->keys[i], set->values[i]);+ if (!skip && nn_attrset_insert(result, set->keys[i], set->values[i]) != 0) {+ return NULL; } }
cbits/nn_attrset.h view
@@ -21,6 +21,12 @@ * teardown's per-set primitive and must not be called on a tracked set (it * would then be double-freed by nn_attrset_free_all). * Values (StablePtrs) are NOT freed by nn_attrset_free - Haskell owns them.+ *+ * Counts and indices are uint32_t. A set holds at most+ * NN_ATTRSET_MAX_ENTRIES entries, the ceiling upstream's Bindings has+ * (its size_t is uint32_t), so every index is below NN_ATTRSET_NOT_FOUND.+ * An operation that would pass it, or that the allocator refuses,+ * reports failure instead of aborting. */ #ifndef NN_ATTRSET_H@@ -35,10 +41,16 @@ /* Opaque attribute set handle. */ typedef struct nn_attrset nn_attrset_t; +/* Most entries one set can hold. */+#define NN_ATTRSET_MAX_ENTRIES UINT32_MAX++/* nn_attrset_index's answer for an absent key: no index reaches it. */+#define NN_ATTRSET_NOT_FOUND UINT32_MAX+ /* --- Lifecycle --- */ -/* Allocate a new empty attribute set with space for `capacity` entries.- * Returns NULL on allocation failure. */+/* Allocate a new empty attribute set with space for `capacity` entries+ * (0 picks a small default). Returns NULL on allocation failure. */ nn_attrset_t *nn_attrset_new(uint32_t capacity); /* Free the attribute set structure. Does NOT free the value pointers@@ -50,8 +62,10 @@ /* Append a key-value pair. Keys need not be sorted or unique at this * stage - duplicates are resolved at freeze time (last insert wins, * matching Nix // merge semantics).- * Grows the internal arrays if capacity is exceeded. */-void nn_attrset_insert(nn_attrset_t *set, nn_symbol_t key, void *value);+ * Grows the internal arrays if capacity is exceeded. Returns 0, or -1+ * with the set unchanged when it already holds NN_ATTRSET_MAX_ENTRIES+ * entries or cannot grow. */+int nn_attrset_insert(nn_attrset_t *set, nn_symbol_t key, void *value); /* Sort entries by symbol ID and deduplicate (last writer wins). * Must be called exactly once before any lookup/keys/values access.@@ -64,9 +78,9 @@ * the key is not present. O(log n) binary search. */ void *nn_attrset_lookup(const nn_attrset_t *set, nn_symbol_t key); -/* Return the index of a key, or -1 if not found.+/* Return the index of a key, or NN_ATTRSET_NOT_FOUND. * Useful for updating value slots in place. */-int32_t nn_attrset_index(const nn_attrset_t *set, nn_symbol_t key);+uint32_t nn_attrset_index(const nn_attrset_t *set, nn_symbol_t key); /* Update the value at a known index. Index must be valid (0 <= idx < count). * Used for lazy materialization: slot starts NULL, gets filled on first force. */@@ -99,12 +113,15 @@ /* Create a new set that is the right-biased union of `a` and `b` * (keys in `b` override `a`). Both inputs must be frozen. * The result is returned frozen and is arena-tracked (freed by- * nn_attrset_free_all) - do NOT pass it to nn_attrset_free. */+ * nn_attrset_free_all) - do NOT pass it to nn_attrset_free. Returns+ * NULL when the union would pass NN_ATTRSET_MAX_ENTRIES or an+ * allocation fails. */ nn_attrset_t *nn_attrset_union(const nn_attrset_t *a, const nn_attrset_t *b); /* Create a new set with the given keys removed. Input must be frozen. * The result is returned frozen and is arena-tracked (freed by- * nn_attrset_free_all) - do NOT pass it to nn_attrset_free. */+ * nn_attrset_free_all) - do NOT pass it to nn_attrset_free. Returns+ * NULL when an allocation fails. */ nn_attrset_t *nn_attrset_remove_keys( const nn_attrset_t *set, const nn_symbol_t *keys,
cbits/nn_bytecode.c view
@@ -21,6 +21,20 @@ #define NN_BC_DEFAULT_OP_CAPACITY 65536u #define NN_BC_DEFAULT_DATA_CAPACITY 131072u +/* --- Reserved instructions (layout in nn_bytecode.h) --- */++static const nn_op_t g_reserved_ops[] = {+ { .opcode = NN_OP_RESOLVED_VAR, .arg1 = 0, .arg2 = 0 },+ { .opcode = NN_OP_RESOLVED_VAR, .arg1 = 0, .arg2 = 1 },+ { .opcode = NN_OP_APP, .flags = NN_APP_DEFERRED, .arg1 = 0, .arg2 = 1 },+ { .opcode = NN_OP_RESOLVED_VAR, .arg1 = 0, .arg2 = 2 },+ { .opcode = NN_OP_APP, .flags = NN_APP_DEFERRED,+ .arg1 = NN_BC_RESERVED_APPLY1, .arg2 = 3 },+};++typedef char nn_bc_reserved_count_check_[+ (sizeof g_reserved_ops / sizeof g_reserved_ops[0] == NN_BC_RESERVED_COUNT) ? 1 : -1];+ /* --- Global state --- */ static nn_op_t *g_ops = NULL;@@ -38,11 +52,13 @@ if (g_ops) nn_bytecode_destroy(); if (op_capacity == 0) op_capacity = NN_BC_DEFAULT_OP_CAPACITY;+ if (op_capacity < NN_BC_RESERVED_COUNT) op_capacity = NN_BC_RESERVED_COUNT; if (data_capacity == 0) data_capacity = NN_BC_DEFAULT_DATA_CAPACITY; g_ops = (nn_op_t *)malloc((size_t)op_capacity * sizeof(nn_op_t)); if (!g_ops) { fprintf(stderr, "nn_bytecode_init: ops alloc failed\n"); abort(); }- g_op_count = 0;+ memcpy(g_ops, g_reserved_ops, sizeof g_reserved_ops);+ g_op_count = NN_BC_RESERVED_COUNT; g_op_capacity = op_capacity; g_data = (uint32_t *)malloc((size_t)data_capacity * sizeof(uint32_t));@@ -51,6 +67,11 @@ g_data_capacity = data_capacity; } +int nn_bytecode_live(void)+{+ return g_ops != NULL;+}+ void nn_bytecode_destroy(void) { free(g_ops);@@ -68,6 +89,10 @@ static int ensure_op_space(void) {+ /* No store before nn_bytecode_init or after nn_bytecode_destroy.+ * Doubling a zero capacity would realloc to zero bytes and the+ * emit would then write past it. */+ if (!g_ops) return -1; if (g_op_count < g_op_capacity) return 0; uint32_t new_cap = g_op_capacity * 2; if (new_cap < g_op_capacity) return -1; /* overflow guard */@@ -80,6 +105,7 @@ static int ensure_data_space(void) {+ if (!g_data) return -1; if (g_data_count < g_data_capacity) return 0; uint32_t new_cap = g_data_capacity * 2; if (new_cap < g_data_capacity) return -1; /* overflow guard */
cbits/nn_bytecode.h view
@@ -50,6 +50,11 @@ #define NN_OP_SEARCH_PATH 23 #define NN_OP_PATH_STR 24 +/* --- App flags (NN_OP_APP) --- */++#define NN_APP_DIRECT 0+#define NN_APP_DEFERRED 1+ /* --- UnaryOp flags (NN_OP_UNARY) --- */ #define NN_UNARY_NOT 0@@ -83,11 +88,13 @@ #define NN_STRPART_LIT 0 #define NN_STRPART_INTERP 1+#define NN_STRPART_ESC 2 /* --- Binding type tags (data buffer) --- */ -#define NN_BIND_NAMED 0-#define NN_BIND_INHERIT 1+#define NN_BIND_NAMED 0+#define NN_BIND_INHERIT 1+#define NN_BIND_INHERIT_FROM 2 /* --- CaptureInfo type tags (data buffer) --- */ @@ -117,12 +124,40 @@ uint32_t arg3; /* Child index, data offset, etc. */ } nn_op_t; +/* --- Reserved instructions ---+ *+ * nn_bytecode_init lays these down at the front of every store, so each+ * index below names the same code for the store's whole life, and the+ * next init lays them down again. They are the code of the application+ * thunks a builtin makes once per element (map, genList, mapAttrs and+ * the like): each such thunk runs it against a minimal env of its own,+ * whose slots hold the function and its arguments. The store is freed+ * only by nn_bytecode_destroy, so code compiled per element would grow+ * it with the element count.+ *+ * 0 RESOLVED_VAR level 0, slot 0+ * 1 RESOLVED_VAR level 0, slot 1+ * 2 APP deferred 0 applied to 1 slot 0 applied to slot 1+ * 3 RESOLVED_VAR level 0, slot 2+ * 4 APP deferred 2 applied to 3 that applied to slot 2+ */++#define NN_BC_RESERVED_APPLY1 2u+#define NN_BC_RESERVED_APPLY2 4u+#define NN_BC_RESERVED_COUNT 5u+ /* --- Lifecycle --- */ -/* Initialize the global bytecode store. op_capacity is initial- * instruction slots (0 = default 65536). data_capacity is initial- * data buffer slots (0 = default 131072). Both grow automatically. */+/* Initialize the global bytecode store and lay down the reserved+ * instructions. op_capacity is initial instruction slots (0 = default+ * 65536; never fewer than NN_BC_RESERVED_COUNT). data_capacity is+ * initial data buffer slots (0 = default 131072). Both grow+ * automatically. Outside the init .. destroy window both emitters+ * return UINT32_MAX. */ void nn_bytecode_init(uint32_t op_capacity, uint32_t data_capacity);++/* Nonzero between nn_bytecode_init and nn_bytecode_destroy. */+int nn_bytecode_live(void); /* Destroy the global bytecode store, freeing all memory. */ void nn_bytecode_destroy(void);
cbits/nn_ctxstr.c view
@@ -9,7 +9,6 @@ #include "nn_ctxstr.h" #include "nn_assert.h" -#include <stdio.h> #include <stdlib.h> #include <string.h> @@ -21,23 +20,23 @@ static uint32_t g_tracked_count = 0; static uint32_t g_tracked_capacity = 0; -static void+/* Returns 0, or -1 when the tracking array cannot grow. */+static int track(nn_ctxstr_t *s) { if (g_tracked_count >= g_tracked_capacity) {+ if (g_tracked_capacity > UINT32_MAX / 2) return -1; uint32_t new_cap = g_tracked_capacity == 0 ? NN_CTXSTR_INITIAL_CAPACITY : g_tracked_capacity * 2; nn_ctxstr_t **new_arr = (nn_ctxstr_t **)realloc( g_tracked, (size_t)new_cap * sizeof(nn_ctxstr_t *));- if (!new_arr) {- fprintf(stderr, "nn_ctxstr_track: realloc failed\n");- abort();- }+ if (!new_arr) return -1; g_tracked = new_arr; g_tracked_capacity = new_cap; } g_tracked[g_tracked_count++] = s;+ return 0; } /* --- Lifecycle --- */@@ -57,7 +56,10 @@ s->text = text; s->ctx_count = ctx_count; memset(s->ctx, 0, (size_t)ctx_count * sizeof(nn_sce_t));- track(s);+ if (track(s) != 0) {+ free(s);+ return NULL;+ } return s; }
+ cbits/nn_darwinfs.c view
@@ -0,0 +1,31 @@+#include "nn_darwinfs.h"++#ifdef __APPLE__++#include <unistd.h>++int nn_path_case_sensitive(const char *path) {+ long answer;++ /* pathconf returns the queried value, not a status: -1 is the error+ * return (errno set), 0 means the volume folds, and a positive value+ * means it does not. */+ answer = pathconf(path, _PC_CASE_SENSITIVE);+ if (answer < 0) {+ return -1;+ }+ return answer > 0 ? 1 : 0;+}++#else /* !__APPLE__ */++/* _PC_CASE_SENSITIVE is a Darwin extension: Linux has no pathconf name for+ * case sensitivity (its filesystems are case-sensitive by convention, the+ * same assumption upstream Nix makes there), and Windows decides per+ * directory through nn_winfs.h rather than per volume. */+int nn_path_case_sensitive(const char *path) {+ (void)path;+ return -1;+}++#endif
+ cbits/nn_darwinfs.h view
@@ -0,0 +1,18 @@+/* Platform filesystem capabilities the store layer probes at runtime.+ * macOS-only functionality. Every other platform compiles the constant+ * -1 stub because the cabal c-sources entry is unconditional and the+ * C99 strict job lints every C file under cbits on Linux; the Haskell+ * side picks the platform's answer at compile time and never calls the+ * stub. */+#ifndef NN_DARWINFS_H+#define NN_DARWINFS_H++/* Whether the filesystem holding PATH (which must exist) compares names+ * case-sensitively: pathconf(_PC_CASE_SENSITIVE), a Darwin extension of+ * the POSIX call. Returns 1 when sensitive, 0 when folding, and -1 when+ * there is no answer - the path does not exist, the filesystem does not+ * implement the query, or a non-macOS build - and the caller falls back+ * to the platform default. */+int nn_path_case_sensitive(const char *path);++#endif
cbits/nn_env.c view
@@ -69,19 +69,20 @@ if (bytes > UINT32_MAX - (NN_ENV_ALIGN - 1)) return NULL; bytes = align_up(bytes, NN_ENV_ALIGN); + /* No pages before nn_env_init or after nn_env_destroy. Opening a+ * first page here instead would hand out memory the next+ * nn_env_init frees, under every pointer built from it. */+ if (!g_current_page) return NULL;+ /* 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) {+ if (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;- if (g_current_page) {- g_current_page->next = page;- } else {- g_first_page = page;- }+ g_current_page->next = page; g_current_page = page; } @@ -108,6 +109,12 @@ /* Initialize global empty env */ memset(&g_empty_env, 0, sizeof(g_empty_env));+}++int+nn_env_live(void)+{+ return g_first_page != NULL; } void
cbits/nn_env.h view
@@ -35,8 +35,13 @@ /* --- Lifecycle --- */ /* Initialize the global env allocator. Must be called once before- * any nn_env_* calls (except nn_env_destroy). */+ * any nn_env_* calls (except nn_env_destroy, nn_env_live and+ * nn_env_empty); outside the init .. destroy window every allocating+ * call returns NULL. */ void nn_env_init(void);++/* Nonzero between nn_env_init and nn_env_destroy. */+int nn_env_live(void); /* Destroy the global env allocator, freeing all page memory. * All pointers returned by any nn_env_* function become invalid. */
cbits/nn_lambda.c view
@@ -9,7 +9,6 @@ #include "nn_lambda.h" #include "nn_assert.h" -#include <stdio.h> #include <stdlib.h> #include <string.h> @@ -19,20 +18,20 @@ static uint32_t g_tracked_count = 0; static uint32_t g_tracked_cap = 0; -static void nn_lambda_track(nn_lambda_t *lam)+/* Returns 0, or -1 when the tracking array cannot grow. */+static int nn_lambda_track(nn_lambda_t *lam) { if (g_tracked_count >= g_tracked_cap) {+ if (g_tracked_cap > UINT32_MAX / 2) return -1; uint32_t new_cap = g_tracked_cap ? g_tracked_cap * 2 : 256; nn_lambda_t **new_arr = (nn_lambda_t **)realloc( g_tracked, (size_t)new_cap * sizeof(nn_lambda_t *));- if (!new_arr) {- fprintf(stderr, "nn_lambda_track: realloc failed\n");- abort();- }+ if (!new_arr) return -1; g_tracked = new_arr; g_tracked_cap = new_cap; } g_tracked[g_tracked_count++] = lam;+ return 0; } /* --- Lifecycle --- */@@ -70,7 +69,11 @@ lam->entries = NULL; } - nn_lambda_track(lam);+ if (nn_lambda_track(lam) != 0) {+ free(lam->entries);+ free(lam);+ return NULL;+ } return lam; }
cbits/nn_list.c view
@@ -10,7 +10,6 @@ #include "nn_env.h" #include "nn_assert.h" -#include <stdio.h> #include <stdlib.h> /* --- Global tracking for bulk cleanup --- */@@ -19,45 +18,68 @@ static uint32_t g_tracked_count = 0; static uint32_t g_tracked_cap = 0; -static void nn_list_track(nn_list_t *list)+/* Returns 0, or -1 when the tracking array cannot grow. */+static int nn_list_track(nn_list_t *list) { if (g_tracked_count >= g_tracked_cap) {+ if (g_tracked_cap > UINT32_MAX / 2) return -1; uint32_t new_cap = g_tracked_cap ? g_tracked_cap * 2 : 256; nn_list_t **new_arr = (nn_list_t **)realloc( g_tracked, (size_t)new_cap * sizeof(nn_list_t *));- if (!new_arr) {- fprintf(stderr, "nn_list_track: realloc failed\n");- abort();- }+ if (!new_arr) return -1; g_tracked = new_arr; g_tracked_cap = new_cap; } g_tracked[g_tracked_count++] = list;+ return 0; } /* --- Lifecycle --- */ -nn_list_t *-nn_list_new(uint32_t count)+int+nn_list_new(nn_list_t **out, uint32_t count) {- if (count == 0) return NULL;+ *out = NULL;+ if (count == 0) return 0; nn_list_t *list = (nn_list_t *)malloc(sizeof(nn_list_t));- if (!list) return NULL;+ if (!list) return -1; /* Items array via the env page allocator (O(1) bump allocation). * nn_env_alloc_slots returns void**, which we cast to nn_thunk**.- * All slots are zero-initialized (NULL pointers). */+ * All slots are zero-initialized (NULL pointers). When tracking+ * fails the items array stays in its page, which only nn_env_destroy+ * releases. */ list->items = (struct nn_thunk **)nn_env_alloc_slots(count); list->count = count; - if (!list->items) {+ if (!list->items || nn_list_track(list) != 0) { free(list);- return NULL;+ return -1; } - nn_list_track(list);- return list;+ *out = list;+ return 0;+}++int+nn_list_drop(nn_list_t **out, const nn_list_t *list, uint32_t n)+{+ *out = NULL;+ if (!list || n >= list->count) return 0;++ nn_list_t *rest = (nn_list_t *)malloc(sizeof(nn_list_t));+ if (!rest) return -1;++ rest->items = list->items + n;+ rest->count = list->count - n;+ if (nn_list_track(rest) != 0) {+ free(rest);+ return -1;+ }++ *out = rest;+ return 0; } void
cbits/nn_list.h view
@@ -11,7 +11,12 @@ * * Lists are immutable after construction: allocate with nn_list_new, * fill via nn_list_set, then read with nn_list_get/nn_list_count.+ * nn_list_drop builds a suffix over its source's items array, which+ * that immutability makes safe to share. *+ * NULL is the empty list, so a constructor cannot also use it to report+ * failure: each returns 0 or -1 and hands the list back through `out`.+ * * Lifecycle: constructed during evaluation, freed via nn_list_free_all() * at arena teardown. Not thread-safe - single-threaded evaluation only. */@@ -37,8 +42,17 @@ /* Allocate a new list with space for `count` thunk pointers. * The items array is allocated via the env page allocator (O(1)). * The header is malloc'd and tracked for bulk cleanup.- * Returns NULL on allocation failure or if count is 0. */-nn_list_t *nn_list_new(uint32_t count);+ * Returns 0 with *out the new list, or NULL when count is 0; returns -1+ * with *out NULL when an allocation fails. */+int nn_list_new(nn_list_t **out, uint32_t count);++/* The elements of `list` from index `n` on, as a new tracked header over+ * the same items array: O(1) whatever the length, where a copy would+ * add count - n slots to pages nothing frees before evaluation end.+ * Returns 0 with *out the suffix, or NULL when no element remains+ * (`list` empty or n >= count); returns -1 with *out NULL when the+ * header cannot be allocated. */+int nn_list_drop(nn_list_t **out, const nn_list_t *list, uint32_t n); /* Free all tracked list headers at once (arena-style cleanup). * Items arrays are freed by nn_env_destroy (page allocator).
cbits/nn_symbol.c view
@@ -20,6 +20,7 @@ /* --- Configuration --- */ #define NN_SYMBOL_DEFAULT_CAPACITY 4096+#define NN_SYMBOL_MAX_INITIAL (UINT32_C(1) << 30) /* slot table is twice this */ #define NN_SYMBOL_LOAD_PERCENT 75 #define NN_SYMBOL_ARENA_INITIAL (256 * 1024) /* 256 KB */ @@ -52,7 +53,7 @@ /* Contiguous string arena. */ char *arena;- size_t arena_used;+ size_t arena_used; /* never above NN_SYMBOL_ARENA_LIMIT */ size_t arena_cap; } g_sym; @@ -69,72 +70,58 @@ return h; } -/* --- Arena --- */+/* --- Growth --- */ -/* Ensure the arena has room for `needed` more bytes. */-static void arena_ensure(size_t needed)-{- if (g_sym.arena_used + needed <= g_sym.arena_cap) return;+/* Each reserve step returns 0, or -1 when the table cannot grow, and+ * changes no symbol, so nn_symbol_intern can refuse after any of them. */ +/* Room for `needed` more arena bytes; the caller has kept the total+ * within NN_SYMBOL_ARENA_LIMIT, which fits size_t on every 64-bit+ * target. */+static int arena_reserve(size_t needed)+{+ size_t want = g_sym.arena_used + needed; size_t new_cap = g_sym.arena_cap;- while (new_cap < g_sym.arena_used + needed) {- new_cap *= 2;- }- char *new_arena = (char *)realloc(g_sym.arena, new_cap);- if (!new_arena) {- fprintf(stderr, "nn_symbol: arena realloc failed (requested %zu bytes)\n", new_cap);- abort();- }- g_sym.arena = new_arena;- g_sym.arena_cap = new_cap;-}+ if (want <= new_cap) return 0; -/* Copy `len` bytes into the arena, returning the start offset. */-static uint32_t arena_push(const char *str, size_t len)-{- arena_ensure(len + 1); /* +1 for null terminator */- if (g_sym.arena_used > (size_t)UINT32_MAX) {- fprintf(stderr, "nn_symbol: arena offset exceeds uint32_t range\n");- abort();+ while (new_cap < want) {+ new_cap = new_cap > SIZE_MAX / 2 ? want : new_cap * 2; }- uint32_t offset = (uint32_t)g_sym.arena_used;- memcpy(g_sym.arena + offset, str, len);- g_sym.arena[offset + len] = '\0';- g_sym.arena_used += len + 1;- return offset;+ char *grown = (char *)realloc(g_sym.arena, new_cap);+ if (!grown) return -1;+ g_sym.arena = grown;+ g_sym.arena_cap = new_cap;+ return 0; } -/* --- Entry array --- */--/* Ensure the entry array has room for one more symbol. */-static void entries_ensure(void)+/* Room for one more entry. Entries are 1-indexed, so count + 1 must+ * stay below the capacity. */+static int entries_reserve(void) {- /* count is 0-based count, entries are 1-indexed, so we need count+1 < cap */- if (g_sym.count + 1 < g_sym.entries_cap) return;+ if (g_sym.count + 1 < g_sym.entries_cap) return 0;+ if (g_sym.entries_cap > UINT32_MAX / 2) return -1; uint32_t new_cap = g_sym.entries_cap * 2;- nn_symbol_entry_t *new_entries = (nn_symbol_entry_t *)realloc(+ nn_symbol_entry_t *grown = (nn_symbol_entry_t *)realloc( g_sym.entries, (size_t)new_cap * sizeof(nn_symbol_entry_t));- if (!new_entries) {- fprintf(stderr, "nn_symbol: entries realloc failed\n");- abort();- }- g_sym.entries = new_entries;+ if (!grown) return -1;+ g_sym.entries = grown; g_sym.entries_cap = new_cap;+ return 0; } -/* --- Hash table --- */--/* Rebuild the hash table at double capacity. */-static void slots_grow(void)+/* Rebuild the hash table at double capacity. The slot capacity is a+ * uint32_t power of two, so it stops at 2^31; with the load kept under+ * NN_SYMBOL_LOAD_PERCENT that bounds the symbol count well below+ * UINT32_MAX, which is why no separate ID ceiling is checked. */+static int slots_grow(void) {+ if (g_sym.slots_cap > UINT32_MAX / 2) return -1;+ uint32_t new_cap = g_sym.slots_cap * 2; uint32_t new_mask = new_cap - 1; nn_slot_t *new_slots = (nn_slot_t *)calloc((size_t)new_cap, sizeof(nn_slot_t));- if (!new_slots) {- fprintf(stderr, "nn_symbol: slots calloc failed\n");- abort();- }+ if (!new_slots) return -1; uint32_t i; for (i = 0; i < g_sym.slots_cap; i++) {@@ -150,17 +137,22 @@ g_sym.slots = new_slots; g_sym.slots_cap = new_cap; g_sym.slots_mask = new_mask;+ return 0; } /* --- Public API --- */ void nn_symbol_init(uint32_t initial_capacity) {+ if (g_sym.slots) nn_symbol_destroy();+ uint32_t cap = NN_SYMBOL_DEFAULT_CAPACITY; if (initial_capacity > cap) {- /* Round up to power of two. */- cap = 1;- while (cap < initial_capacity) cap *= 2;+ /* Round up to a power of two. The hint is clamped so that the+ * rounding and the slot table's doubling cannot wrap uint32_t. */+ uint32_t want = initial_capacity < NN_SYMBOL_MAX_INITIAL+ ? initial_capacity : NN_SYMBOL_MAX_INITIAL;+ while (cap < want) cap *= 2; } memset(&g_sym, 0, sizeof(g_sym));@@ -186,6 +178,11 @@ g_sym.arena_used = 0; } +int nn_symbol_live(void)+{+ return g_sym.slots != NULL;+}+ void nn_symbol_destroy(void) { free(g_sym.entries);@@ -196,6 +193,16 @@ nn_symbol_t nn_symbol_intern(const char *str, size_t len) {+ /* No table before nn_symbol_init or after nn_symbol_destroy: the+ * probe below would dereference a NULL slot array. The invalid+ * sentinel is the one value the Haskell boundary refuses to wrap. */+ if (!g_sym.slots) return NN_SYMBOL_INVALID;++ /* A length past uint32_t can be neither stored nor already present;+ * truncated into the entry it would read back as a shorter string.+ * Refused here, before a byte of str is read. */+ if ((uint64_t)len > UINT32_MAX) return NN_SYMBOL_INVALID;+ /* 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@@ -214,7 +221,7 @@ if (g_sym.slots[idx].hash == hash) { nn_symbol_entry_t *e = &g_sym.entries[id];- if (e->len == (uint32_t)len &&+ if (e->len == len && memcmp(g_sym.arena + e->offset, str, len) == 0) { return (nn_symbol_t)id; }@@ -222,12 +229,29 @@ idx = (idx + 1) & g_sym.slots_mask; } - /* Not found - insert new symbol. */- entries_ensure();+ /* Not found - insert. Everything the insert needs is reserved before+ * any of it is committed, so a refusal leaves the table as it was.+ * The slot table grows ahead of the insert that would reach the load+ * limit, which keeps an empty slot for every later probe to stop at. */+ if ((uint64_t)len >= NN_SYMBOL_ARENA_LIMIT - (uint64_t)g_sym.arena_used)+ return NN_SYMBOL_INVALID;+ if (arena_reserve(len + 1) != 0) return NN_SYMBOL_INVALID;+ if (entries_reserve() != 0) return NN_SYMBOL_INVALID;+ if ((uint64_t)(g_sym.count + 1) * 100 >= (uint64_t)g_sym.slots_cap * NN_SYMBOL_LOAD_PERCENT) {+ if (slots_grow() != 0) return NN_SYMBOL_INVALID;+ idx = hash & g_sym.slots_mask;+ while (g_sym.slots[idx].id != 0) {+ idx = (idx + 1) & g_sym.slots_mask;+ }+ }+ g_sym.count++; uint32_t new_id = g_sym.count; /* 1-based */+ uint32_t offset = (uint32_t)g_sym.arena_used; - uint32_t offset = arena_push(str, len);+ memcpy(g_sym.arena + offset, str, len);+ g_sym.arena[offset + len] = '\0';+ g_sym.arena_used += len + 1; g_sym.entries[new_id].offset = offset; g_sym.entries[new_id].len = (uint32_t)len;@@ -235,11 +259,6 @@ g_sym.slots[idx].hash = hash; g_sym.slots[idx].id = new_id;-- /* Grow hash table if load exceeds threshold. */- if ((uint64_t)g_sym.count * 100 >= (uint64_t)g_sym.slots_cap * NN_SYMBOL_LOAD_PERCENT) {- slots_grow();- } return (nn_symbol_t)new_id; }
cbits/nn_symbol.h view
@@ -12,6 +12,12 @@ * * Lifecycle: nn_symbol_init() before evaluation, nn_symbol_destroy() * after. Not thread-safe - single-threaded evaluation only.+ *+ * Offsets and lengths are uint32_t, so the string arena holds at most+ * NN_SYMBOL_ARENA_LIMIT bytes, terminators included. An intern that+ * would cross it, or that needs a table to grow past its uint32_t+ * capacity or past what the allocator gives, is refused with+ * NN_SYMBOL_INVALID and leaves the table as it was. */ #ifndef NN_SYMBOL_H@@ -29,13 +35,21 @@ /* Invalid symbol constant. */ #define NN_SYMBOL_INVALID ((nn_symbol_t)0) +/* Bytes the string arena may hold: every offset and length fits uint32_t. */+#define NN_SYMBOL_ARENA_LIMIT ((uint64_t)UINT32_MAX + 1)+ /* --- Lifecycle --- */ -/* Initialize the global symbol table. Must be called once before- * any nn_symbol_intern() calls. initial_capacity is the expected- * number of unique symbols (hint for pre-allocation; 0 uses default). */+/* Initialize the global symbol table, destroying a live one first.+ * Must be called before any nn_symbol_intern() calls; outside the+ * init .. destroy window nn_symbol_intern() returns NN_SYMBOL_INVALID.+ * initial_capacity is the expected number of unique symbols (hint for+ * pre-allocation; 0 uses default). */ void nn_symbol_init(uint32_t initial_capacity); +/* Nonzero between nn_symbol_init and nn_symbol_destroy. */+int nn_symbol_live(void);+ /* Destroy the global symbol table, freeing all memory. * All nn_symbol_t values become invalid after this call. */ void nn_symbol_destroy(void);@@ -46,7 +60,10 @@ * interned, returns the existing symbol (O(1) amortized). * The input string is copied - the caller may free it after this call. * len is the byte length (not null-terminated). (NULL, 0) denotes the- * empty string; str must not be NULL when len > 0. */+ * empty string; str must not be NULL when len > 0. Returns+ * NN_SYMBOL_INVALID outside the init .. destroy window and when the+ * table refuses the string (see above); a length past the arena limit+ * is refused before any byte of str is read. */ 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
@@ -60,6 +60,12 @@ static nn_thunk_t * arena_alloc(struct nn_thunk_arena *arena) {+ /* No arena before nn_thunk_init or after nn_thunk_destroy. Report+ * it through the NULL the callers already check for rather than+ * dereferencing it; nn_arena_live is how the Haskell boundary tells+ * this apart from exhaustion. */+ if (!arena) return NULL;+ /* Current block full - allocate a new one */ if (arena->current->count >= arena->current->capacity) { struct nn_thunk_block *block = alloc_block(arena->block_capacity);@@ -107,6 +113,12 @@ g_arena->block_capacity = cap; } +int+nn_thunk_live(void)+{+ return g_arena != NULL;+}+ void nn_thunk_destroy(void) {@@ -144,10 +156,12 @@ return thunk->bc_idx; } -void+int nn_thunk_set_payload(nn_thunk_t *thunk, void *payload) {+ if (thunk->state != NN_THUNK_PENDING) return 0; thunk->payload = payload;+ return 1; } nn_thunk_t *@@ -190,32 +204,6 @@ } nn_thunk_t *-nn_thunk_new_computed_bool(uint8_t value)-{- nn_thunk_t *t = arena_alloc(g_arena);- if (!t) return NULL;- t->state = NN_THUNK_COMPUTED;- t->val_tag = NN_VALUE_BOOL;- t->_pad = 0;- t->bc_idx = 0;- t->payload = (void *)(intptr_t)value;- return t;-}--nn_thunk_t *-nn_thunk_new_computed_null(void)-{- nn_thunk_t *t = arena_alloc(g_arena);- if (!t) return NULL;- t->state = NN_THUNK_COMPUTED;- t->val_tag = NN_VALUE_NULL;- t->_pad = 0;- t->bc_idx = 0;- t->payload = NULL;- return t;-}--nn_thunk_t * nn_thunk_new_computed_str(uint32_t symbol) { nn_thunk_t *t = arena_alloc(g_arena);@@ -293,6 +281,24 @@ return t; } +/* --- Shared constants --- */++static nn_thunk_t g_null_cell = { NN_THUNK_COMPUTED, NN_VALUE_NULL, 0, 0, NULL };+static nn_thunk_t g_true_cell = { NN_THUNK_COMPUTED, NN_VALUE_BOOL, 0, 0, (void *)(intptr_t)1 };+static nn_thunk_t g_false_cell = { NN_THUNK_COMPUTED, NN_VALUE_BOOL, 0, 0, NULL };++nn_thunk_t *+nn_thunk_null(void)+{+ return &g_null_cell;+}++nn_thunk_t *+nn_thunk_bool(uint8_t value)+{+ return value ? &g_true_cell : &g_false_cell;+}+ /* --- State queries --- */ uint8_t@@ -391,125 +397,114 @@ return 1; } -void *+int nn_thunk_set_computed(nn_thunk_t *thunk, void *value) {- if (thunk->state == NN_THUNK_COMPUTED) return NULL;- void *old = thunk->payload;+ if (thunk->state == NN_THUNK_COMPUTED) return 0; thunk->state = NN_THUNK_COMPUTED; thunk->val_tag = NN_VALUE_PTR; thunk->payload = value;- return old;+ return 1; } -void *+int nn_thunk_set_computed_int(nn_thunk_t *thunk, int64_t value) {- if (thunk->state == NN_THUNK_COMPUTED) return NULL;- void *old = thunk->payload;+ if (thunk->state == NN_THUNK_COMPUTED) return 0; thunk->state = NN_THUNK_COMPUTED; thunk->val_tag = NN_VALUE_INT; thunk->payload = (void *)(intptr_t)value;- return old;+ return 1; } -void *+int nn_thunk_set_computed_float(nn_thunk_t *thunk, double value) {- if (thunk->state == NN_THUNK_COMPUTED) return NULL;- void *old = thunk->payload;+ if (thunk->state == NN_THUNK_COMPUTED) return 0; thunk->state = NN_THUNK_COMPUTED; thunk->val_tag = NN_VALUE_FLOAT; memcpy(&thunk->payload, &value, sizeof(double));- return old;+ return 1; } -void *+int nn_thunk_set_computed_bool(nn_thunk_t *thunk, uint8_t value) {- if (thunk->state == NN_THUNK_COMPUTED) return NULL;- void *old = thunk->payload;+ if (thunk->state == NN_THUNK_COMPUTED) return 0; thunk->state = NN_THUNK_COMPUTED; thunk->val_tag = NN_VALUE_BOOL; thunk->payload = (void *)(intptr_t)value;- return old;+ return 1; } -void *+int nn_thunk_set_computed_null(nn_thunk_t *thunk) {- if (thunk->state == NN_THUNK_COMPUTED) return NULL;- void *old = thunk->payload;+ if (thunk->state == NN_THUNK_COMPUTED) return 0; thunk->state = NN_THUNK_COMPUTED; thunk->val_tag = NN_VALUE_NULL; thunk->payload = NULL;- return old;+ return 1; } -void *+int nn_thunk_set_computed_str(nn_thunk_t *thunk, uint32_t symbol) {- if (thunk->state == NN_THUNK_COMPUTED) return NULL;- void *old = thunk->payload;+ if (thunk->state == NN_THUNK_COMPUTED) return 0; thunk->state = NN_THUNK_COMPUTED; thunk->val_tag = NN_VALUE_STR; thunk->payload = (void *)(intptr_t)symbol;- return old;+ return 1; } -void *+int nn_thunk_set_computed_path(nn_thunk_t *thunk, uint32_t symbol) {- if (thunk->state == NN_THUNK_COMPUTED) return NULL;- void *old = thunk->payload;+ if (thunk->state == NN_THUNK_COMPUTED) return 0; thunk->state = NN_THUNK_COMPUTED; thunk->val_tag = NN_VALUE_PATH; thunk->payload = (void *)(intptr_t)symbol;- return old;+ return 1; } -void *+int nn_thunk_set_computed_list(nn_thunk_t *thunk, void *list) {- if (thunk->state == NN_THUNK_COMPUTED) return NULL;- void *old = thunk->payload;+ if (thunk->state == NN_THUNK_COMPUTED) return 0; thunk->state = NN_THUNK_COMPUTED; thunk->val_tag = NN_VALUE_LIST; thunk->payload = list;- return old;+ return 1; } -void *+int nn_thunk_set_computed_attrs(nn_thunk_t *thunk, void *attrset) {- if (thunk->state == NN_THUNK_COMPUTED) return NULL;- void *old = thunk->payload;+ if (thunk->state == NN_THUNK_COMPUTED) return 0; thunk->state = NN_THUNK_COMPUTED; thunk->val_tag = NN_VALUE_ATTRS; thunk->payload = attrset;- return old;+ return 1; } -void *+int nn_thunk_set_computed_ctxstr(nn_thunk_t *thunk, void *ctxstr) {- if (thunk->state == NN_THUNK_COMPUTED) return NULL;- void *old = thunk->payload;+ if (thunk->state == NN_THUNK_COMPUTED) return 0; thunk->state = NN_THUNK_COMPUTED; thunk->val_tag = NN_VALUE_CTXSTR; thunk->payload = ctxstr;- return old;+ return 1; } -void *+int nn_thunk_set_computed_lambda(nn_thunk_t *thunk, void *lambda) {- if (thunk->state == NN_THUNK_COMPUTED) return NULL;- void *old = thunk->payload;+ if (thunk->state == NN_THUNK_COMPUTED) return 0; thunk->state = NN_THUNK_COMPUTED; thunk->val_tag = NN_VALUE_LAMBDA; thunk->payload = lambda;- return old;+ return 1; } /* --- Arena diagnostics / cleanup iteration --- */
cbits/nn_thunk.h view
@@ -82,9 +82,13 @@ /* 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 thunk allocation (nn_thunk_new_bc or- * the computed-value constructors). */+ * the computed-value constructors); outside the init .. destroy window+ * every allocator returns NULL. */ void nn_thunk_init(uint32_t initial_capacity); +/* Nonzero between nn_thunk_init and nn_thunk_destroy. */+int nn_thunk_live(void);+ /* Destroy the global thunk arena, freeing all block memory. * Does NOT free payload StablePtrs - caller must iterate and free * them first (via nn_thunk_count/nn_thunk_get).@@ -103,11 +107,12 @@ /* Read the bytecode index from a PENDING thunk. */ uint32_t nn_thunk_get_bc_idx(const nn_thunk_t *thunk); -/* Set the payload of a thunk. Used for deferred env fixup in+/* Set the payload of a PENDING thunk. Used for deferred env fixup in * knot-tying (rec attrs, let, formal defaults): allocate the thunk * with NULL payload first, then fill the env pointer once the- * knot-tied env is constructed. */-void nn_thunk_set_payload(nn_thunk_t *thunk, void *payload);+ * knot-tied env is constructed. Returns 1, or 0 with the thunk+ * unchanged if it is not PENDING. */+int nn_thunk_set_payload(nn_thunk_t *thunk, void *payload); /* Allocate a new pre-COMPUTED thunk from the arena (StablePtr payload). * value is an opaque pointer (StablePtr to Haskell NixValue).@@ -117,8 +122,6 @@ /* Allocate pre-COMPUTED thunks with inline scalar values (no StablePtr). */ nn_thunk_t *nn_thunk_new_computed_int(int64_t value); nn_thunk_t *nn_thunk_new_computed_float(double value);-nn_thunk_t *nn_thunk_new_computed_bool(uint8_t value);-nn_thunk_t *nn_thunk_new_computed_null(void); /* Allocate pre-COMPUTED thunks with C-native complex values (no StablePtr). */ nn_thunk_t *nn_thunk_new_computed_str(uint32_t symbol);@@ -128,6 +131,18 @@ nn_thunk_t *nn_thunk_new_computed_ctxstr(void *ctxstr); nn_thunk_t *nn_thunk_new_computed_lambda(void *lambda); +/* --- Shared constants --- */++/* The COMPUTED null, true and false cells, as upstream shares one vNull,+ * vTrue and vFalse per EvalState. They are file-scope statics, not+ * arena memory, so each has one address in every init .. destroy window+ * and outside all of them: a constant built from one before+ * nn_thunk_init, or kept across nn_thunk_destroy, still reads correctly.+ * Every state transition refuses a COMPUTED thunk, so nothing writes+ * them. nn_thunk_bool answers the true cell for any nonzero value. */+nn_thunk_t *nn_thunk_null(void);+nn_thunk_t *nn_thunk_bool(uint8_t value);+ /* --- State queries --- */ /* Read the current state (NN_THUNK_PENDING/COMPUTED/BLACKHOLE). */@@ -165,29 +180,25 @@ * 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).- * Returns the old payload (pending StablePtr for caller to free).- * Returns NULL if thunk is already COMPUTED. */-void *nn_thunk_set_computed(nn_thunk_t *thunk, void *value);--/* Set a non-COMPUTED thunk to COMPUTED with inline scalar values.- * Returns the old payload (pending StablePtr for caller to free).- * Returns NULL if thunk is already COMPUTED. */-void *nn_thunk_set_computed_int(nn_thunk_t *thunk, int64_t value);-void *nn_thunk_set_computed_float(nn_thunk_t *thunk, double value);-void *nn_thunk_set_computed_bool(nn_thunk_t *thunk, uint8_t value);-void *nn_thunk_set_computed_null(nn_thunk_t *thunk);--/* Set a non-COMPUTED thunk to COMPUTED with C-native complex values.- * Returns the old payload (pending StablePtr for caller to free).- * Returns NULL if thunk is already COMPUTED. */-void *nn_thunk_set_computed_str(nn_thunk_t *thunk, uint32_t symbol);-void *nn_thunk_set_computed_path(nn_thunk_t *thunk, uint32_t symbol);-void *nn_thunk_set_computed_list(nn_thunk_t *thunk, void *list);-void *nn_thunk_set_computed_attrs(nn_thunk_t *thunk, void *attrset);-void *nn_thunk_set_computed_ctxstr(nn_thunk_t *thunk, void *ctxstr);-void *nn_thunk_set_computed_lambda(nn_thunk_t *thunk, void *lambda);+/* Set a non-COMPUTED thunk to COMPUTED, the setter naming the value+ * kind: a StablePtr NixValue (NN_VALUE_PTR), an inline scalar, or a+ * C-native complex value. Accepts PENDING or BLACKHOLE state (skips+ * blackhole for direct memoization). The pending payload is overwritten,+ * not handed back: a NULL return could not tell a refusal from a NULL+ * payload, and the caller already read the payload to evaluate the+ * thunk, so it frees the StablePtr itself. Returns 1, or 0 with the+ * thunk unchanged if it is already COMPUTED. */+int nn_thunk_set_computed(nn_thunk_t *thunk, void *value);+int nn_thunk_set_computed_int(nn_thunk_t *thunk, int64_t value);+int nn_thunk_set_computed_float(nn_thunk_t *thunk, double value);+int nn_thunk_set_computed_bool(nn_thunk_t *thunk, uint8_t value);+int nn_thunk_set_computed_null(nn_thunk_t *thunk);+int nn_thunk_set_computed_str(nn_thunk_t *thunk, uint32_t symbol);+int nn_thunk_set_computed_path(nn_thunk_t *thunk, uint32_t symbol);+int nn_thunk_set_computed_list(nn_thunk_t *thunk, void *list);+int nn_thunk_set_computed_attrs(nn_thunk_t *thunk, void *attrset);+int nn_thunk_set_computed_ctxstr(nn_thunk_t *thunk, void *ctxstr);+int nn_thunk_set_computed_lambda(nn_thunk_t *thunk, void *lambda); /* --- Arena diagnostics / cleanup iteration --- */
cbits/nn_winfs.h view
@@ -1,6 +1,8 @@ /* 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. */+ * Windows-only functionality. Every other platform compiles the constant+ * 0 stub because the cabal c-sources entry is unconditional and the C99+ * strict job lints every C file under cbits on Linux; the Haskell side+ * picks the platform's answer at compile time and never calls the stub. */ #ifndef NN_WINFS_H #define NN_WINFS_H
nova-nix.cabal view
@@ -1,19 +1,20 @@ cabal-version: 3.0 name: nova-nix-version: 0.8.0.0+version: 0.9.0.0 synopsis: Windows-native Nix implementation in Haskell and C99 description: 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 macOS and Linux. It has its own parser, lazy evaluator, 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.+ On the 22 derivations its CI checks, over pinned pairs of Nix and+ nixpkgs from 2.24.9 with nixpkgs 24.11 to 2.35.4 with nixpkgs unstable,+ the derivation paths it computes match upstream. 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@.@@ -61,11 +62,15 @@ Nix.Parser.Lexer Nix.Parser.ParseError Nix.Eval+ Nix.Eval.CallDepth Nix.Eval.CanonPath Nix.Eval.Context Nix.Eval.Types Nix.Eval.IO+ Nix.Eval.Policy Nix.Eval.Operator+ Nix.Eval.Print+ Nix.Eval.SourceError Nix.Eval.StringInterp Nix.Eval.Symbol Nix.Eval.CAttrSet@@ -79,32 +84,47 @@ Nix.Eval.EvalFormals Nix.Eval.Arena Nix.Eval.AttrPath+ Nix.Eval.CStatus Nix.Store Nix.Store.Path Nix.Store.Path.Internal Nix.Store.DB+ Nix.Store.GC+ Nix.Store.Handle Nix.Store.Lock+ Nix.Store.Symlink Nix.Store.CaseSensitive+ Nix.Store.EntryName Nix.Store.ExecBit Nix.DependencyGraph Nix.Derivation+ Nix.Derivation.StructuredAttrs Nix.Builder Nix.Builder.Unpack Nix.Compression Nix.Config+ Nix.Environment+ Nix.HostPath Nix.Push Nix.Substituter Nix.Builtins Nix.Hash+ Nix.Http+ Nix.Json + autogen-modules: Paths_nova_nix+ other-modules:+ Nix.Store.Exclusive+ Paths_nova_nix+ build-depends: base >= 4.22 && < 5 , array >= 0.5 && < 0.6 , bytestring >= 0.11 && < 0.13 , containers >= 0.6 && < 0.9 , crypton >= 1.1 && < 2- , directory >= 1.3 && < 1.4- , filepath >= 1.4 && < 1.6+ , directory >= 1.3.8 && < 1.4+ , filepath >= 1.4.100 && < 1.6 , http-client >= 0.7 && < 0.8 , http-client-tls >= 0.3 && < 0.5 , http-types >= 0.12 && < 0.13@@ -113,6 +133,7 @@ , mtl >= 2.2 && < 2.4 , nova-cache:{nova-cache, xz, zstandard, bzip2} >= 0.11.1.1 && < 0.12 , process >= 1.6 && < 1.7+ , random >= 1.2 && < 1.4 , regex-tdfa >= 1.3 && < 1.4 , sqlite-simple >= 0.4 && < 0.5 , tar >= 0.7.1 && < 0.8@@ -120,6 +141,19 @@ , time >= 1.9 && < 1.17 , zstd >= 0.1 && < 0.2 + -- Platform calls base cannot express: the exclusive create behind NAR+ -- unpacking (Nix.Store.Exclusive), and reading the environment as+ -- bytes on POSIX (Nix.Environment; getenv, as upstream reads it), where+ -- Windows reads it through base's UTF-16 API. Win32 2.9 is the first+ -- whose hANDLEToHandle serves the native Windows I/O manager; unix 2.8+ -- is the first whose openFd takes creat and cloexec in its flags.+ if os(windows)+ build-depends:+ Win32 >= 2.9 && < 2.15+ else+ build-depends:+ unix >= 2.8 && < 2.9+ c-sources: cbits/nn_symbol.c cbits/nn_attrset.c@@ -131,6 +165,7 @@ cbits/nn_bytecode.c cbits/nn_lambda.c cbits/nn_winfs.c+ cbits/nn_darwinfs.c include-dirs: cbits -- -Werror deliberately NOT baked in: a future gcc/clang adding a new -- -Wall diagnostic must not retroactively break installing published@@ -165,12 +200,16 @@ OverloadedStrings ghc-options: -Wall -Wcompat -threaded -rtsopts + -- file-io reads a config file by the bytes of its path. 0.1.1 is the+ -- first whose read does not create a missing file on Windows, which+ -- would turn a missing include into an empty one. build-depends: base >= 4.22 && < 5 , bytestring >= 0.11 && < 0.13 , containers >= 0.6 && < 0.9- , directory >= 1.3 && < 1.4- , filepath >= 1.4 && < 1.6+ , directory >= 1.3.8 && < 1.4+ , file-io >= 0.1.1 && < 0.2+ , filepath >= 1.4.100 && < 1.6 , nova-nix , text >= 2.0 && < 2.2 @@ -181,7 +220,7 @@ type: exitcode-stdio-1.0 main-is: Main.hs hs-source-dirs: test- other-modules: FetchurlFixture+ other-modules: HttpFixture default-language: Haskell2010 default-extensions: OverloadedStrings@@ -196,12 +235,14 @@ , bytestring >= 0.11 && < 0.13 , containers >= 0.6 && < 0.9 , directory >= 1.3 && < 1.4- , filepath >= 1.4 && < 1.6+ , filepath >= 1.4.100 && < 1.6 , http-client >= 0.7 && < 0.8+ , http-types >= 0.12 && < 0.13 , network >= 3.2 && < 3.3 , nova-cache:{nova-cache, zstandard} >= 0.11.1.1 && < 0.12 , nova-nix- , process >= 1.6 && < 1.7+ , process >= 1.6.12 && < 1.7+ , QuickCheck >= 2.12.5 && < 2.19 , sqlite-simple >= 0.4 && < 0.5 , tar >= 0.7.1 && < 0.8 , text >= 2.0 && < 2.2
src/Nix/Builder.hs view
@@ -48,6 +48,7 @@ buildPath, execWrapperConfig, execWrapperFor,+ spawnFor, rewriteEnv, rewritePlaceholders, scrubAmbient,@@ -58,8 +59,9 @@ ) where -import Control.Exception (IOException, SomeException, displayException, finally, onException, try)+import Control.Exception (IOException, catch, catches, displayException, finally, onException, try) import Control.Monad (filterM, unless, when)+import Control.Monad.Except (ExceptT (..), liftEither, runExceptT) import Data.Bifunctor (first) import qualified Data.ByteString as BS import Data.Char (isAscii, isSpace, toLower, toUpper)@@ -71,7 +73,7 @@ import qualified Data.List.NonEmpty as NE import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map-import Data.Maybe (catMaybes, isJust)+import Data.Maybe (catMaybes, fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as TE@@ -83,19 +85,20 @@ import Nix.Builder.Unpack (UnpackLimits, builtinUnpackBuilder, defaultUnpackLimits, runBuiltinUnpack) import Nix.DependencyGraph (DepGraph, TopoResult (..), buildDepGraph, topoSort) import qualified Nix.DependencyGraph-import Nix.Derivation (Derivation (..), DerivationOutput (..), currentPlatform, fromATerm, platformToText)+import Nix.Derivation (Derivation (..), DerivationOutput (..), Platform, currentPlatform, extraPlatforms, fromATerm, platformToText)+import Nix.Derivation.StructuredAttrs (decodeStructuredAttrs, renderAttrsJson, renderAttrsShell, stringsAttr, structuredAttrsKey, withOutputPlaceholders) 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 Nix.Http (AttemptFailure (..), FetchRetryPolicy, attemptFailureMessage, catchSync, defaultFetchRetryPolicy, fetchStatusFailure, ioRetryEffects, retryTransient, transferFailureHandlers, withTransfer, withUserAgent)+import Nix.Store (PathLock, PathRegistration, Store (..), acquirePathLock, isValid, placeInStore, registerPaths, releasePathLock, scanReferences, scanTempReferences, volumeCaseHack) 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 Nix.Substituter (CacheConfig, SubstResult (..), trySubstituteWith) 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, (</>)) import qualified System.IO-import qualified System.IO.Unsafe import qualified System.Info import qualified System.Process as Proc @@ -171,12 +174,6 @@ httpStatusOk :: Int httpStatusOk = 200 --- | User-Agent sent by @builtin:fetchurl@. Some upstream servers (e.g.--- ftp.gnu.org) reject requests with no User-Agent as bot traffic with a 403,--- so the fetcher identifies itself like any other download client.-fetchUserAgent :: BS.ByteString-fetchUserAgent = "nova-nix (+https://github.com/Novavero-AI/nova-nix)"- -- | Environment variable for the reproducible-builds.org build timestamp. envSourceDateEpoch :: Text envSourceDateEpoch = "SOURCE_DATE_EPOCH"@@ -186,6 +183,17 @@ envImpureEnvVars :: Text envImpureEnvVars = "impureEnvVars" +-- | The variables naming a structured derivation's two attribute files.+envNixAttrsJsonFile, envNixAttrsShFile :: Text+envNixAttrsJsonFile = "NIX_ATTRS_JSON_FILE"+envNixAttrsShFile = "NIX_ATTRS_SH_FILE"++-- | Where in the build directory a structured derivation's attributes+-- land, under upstream's names.+attrsJsonFileName, attrsShellFileName :: FilePath+attrsJsonFileName = ".attrs.json"+attrsShellFileName = ".attrs.sh"+ -- | 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__@)@@ -209,6 +217,11 @@ bcCaches :: ![CacheConfig], -- | Extraction budget for @builtin:unpack@ builds. bcUnpackLimits :: !UnpackLimits,+ -- | How a download is retried: each URL of a @builtin:fetchurl@+ -- before the next is tried, and each narinfo and NAR the substituter+ -- requests, as upstream's one @download-attempts@ setting governs+ -- every transfer.+ bcFetchRetry :: !FetchRetryPolicy, -- | 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@@ -231,7 +244,8 @@ else "/tmp/nova-nix-build", bcCaches = [], bcExecWrappers = Map.empty,- bcUnpackLimits = defaultUnpackLimits+ bcUnpackLimits = defaultUnpackLimits,+ bcFetchRetry = defaultFetchRetryPolicy } -- | Result of a build attempt.@@ -435,13 +449,22 @@ -- 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+execWrapperFor = spawnFor currentPlatform++-- | 'execWrapperFor' with the host named, so the rule can be checked for+-- every host on whichever one runs the suite. A derivation for the host's+-- own system, or for one the host executes through its compatibility layer+-- ('extraPlatforms'), spawns directly whatever launchers are configured:+-- upstream runs its extra platforms natively and has no launcher notion.+spawnFor :: Platform -> BuildConfig -> Derivation -> BuilderSpawn+spawnFor host config drv+ | platform == host || platform `elem` extraPlatforms host = SpawnNative | otherwise = case Map.lookup system (bcExecWrappers config) of Just launcher -> SpawnThrough launcher Nothing -> SpawnUnsupported system where- system = platformToText (drvPlatform drv)+ platform = drvPlatform drv+ system = platformToText platform -- | The build, once every output knows where it is written and where it -- belongs.@@ -472,7 +495,7 @@ -- this - they read the byte fields directly. exitResult <- case drvBuilder drv of b- | b == builtinFetchurlBuilder -> runBuiltinFetchurl drv outputDirs+ | b == builtinFetchurlBuilder -> runBuiltinFetchurl (bcFetchRetry config) drv outputDirs | b == builtinUnpackBuilder -> runBuiltinUnpack (bcStoreDir config) (bcUnpackLimits config) drv outputDirs _ -> case (execWrapperFor config drv, decodeBuilderStrings drv) of (SpawnUnsupported system, _) ->@@ -492,7 +515,6 @@ -- 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.@@ -504,12 +526,17 @@ -- 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+ impureVars delivery+ | any carriesHash (drvOutputs drv) = adImpureEnvVars delivery+ | otherwise = Right []+ in case attrsDelivery rewrite buildDir outputDirs decodedEnv of+ Left err -> pure (Left (1, "cannot process " <> structuredAttrsKey <> " attribute of '" <> formatDrvName drv <> "': " <> err))+ Right delivery -> case impureVars delivery of+ Left err -> pure (Left (1, err))+ Right passed -> do+ for_ (adFiles delivery) (uncurry BS.writeFile)+ -- 5. Run the builder+ runBuilder spawnPath spawnArgs (buildEnvironment config (adEnv delivery) builderPath buildDir) passed buildDir case exitResult of Left (exitCode, stderrText) -> do -- Whatever the builder wrote is in the store now, so failure has@@ -591,26 +618,28 @@ let missingSrcs = [sp | (sp, valid) <- zip (drvInputSrcs drv) srcResults, not valid] if not (null missingSrcs) then pure (Left ("missing input sources: " <> T.intercalate ", " (map formatSP missingSrcs)))- else case resolveInputOutputs config drv of- Left err -> pure (Left err)- Right requiredOutputs -> do- outResults <- mapM (isValid store) requiredOutputs- let missingOutputs = [sp | (sp, valid) <- zip requiredOutputs outResults, not valid]- if not (null missingOutputs)- then pure (Left ("missing input derivation outputs: " <> T.intercalate ", " (map formatSP missingOutputs)))- else pure (Right ())+ else do+ resolved <- resolveInputOutputs config drv+ case resolved of+ Left err -> pure (Left err)+ Right requiredOutputs -> do+ outResults <- mapM (isValid store) requiredOutputs+ let missingOutputs = [sp | (sp, valid) <- zip requiredOutputs outResults, not valid]+ if not (null missingOutputs)+ then pure (Left ("missing input derivation outputs: " <> T.intercalate ", " (map formatSP missingOutputs)))+ else pure (Right ()) -- | Resolve each input derivation's requested output names to their store -- paths by reading the input @.drv@ from the store. @Left@ if an input @.drv@ -- cannot be read or names an output the derivation does not define.-resolveInputOutputs :: BuildConfig -> Derivation -> Either Text [StorePath]+resolveInputOutputs :: BuildConfig -> Derivation -> IO (Either Text [StorePath]) resolveInputOutputs config drv =- concat <$> traverse resolveOne (Map.toList (drvInputDrvs drv))+ runExceptT (concat <$> traverse resolveOne (Map.toList (drvInputDrvs drv))) where resolveOne (inputDrvPath, wantedOutputs) = do- inputDrv <- readDrvFromStore config inputDrvPath+ inputDrv <- ExceptT (readDrvFromStore config inputDrvPath) let outputMap = Map.fromList [(doName o, doPath o) | o <- drvOutputs inputDrv]- traverse (lookupOutput inputDrvPath outputMap) wantedOutputs+ liftEither (traverse (lookupOutput inputDrvPath outputMap) wantedOutputs) lookupOutput inputDrvPath outputMap name = case Map.lookup name outputMap of Just sp -> Right sp@@ -633,15 +662,14 @@ [] -> "no-output" in bcTmpDir config </> uniqueSuffix --- | Remove the build directory, ignoring errors.+-- | Remove the build directory, ignoring a filesystem failure to remove+-- it. It runs as the build's 'finally', where a directory that will not+-- go away must not replace the build's own outcome. cleanupBuildDir :: FilePath -> IO () cleanupBuildDir dir = do exists <- doesDirectoryExist dir- when exists $ do- result <- try (removeDirectoryRecursive dir)- case (result :: Either SomeException ()) of- Right () -> pure ()- Left _ -> pure () -- Best effort cleanup+ when exists $+ removeDirectoryRecursive dir `catch` \(_ :: IOException) -> pure () -- --------------------------------------------------------------------------- -- Environment@@ -668,7 +696,57 @@ where rewritePair (name, value) = (rewrite name, rewrite value) --- | Build the process environment from the derivation env + standard vars.+-- | What a builder receives of its derivation beyond the builder path and+-- arguments.+data AttrsDelivery = AttrsDelivery+ { -- | Variables the derivation contributes to the environment.+ adEnv :: !(Map Text Text),+ -- | Files written into the build directory before the builder starts.+ adFiles :: ![(FilePath, BS.ByteString)],+ -- | The @impureEnvVars@ attribute, which matters only to a fixed-output+ -- derivation, so a malformed one fails only such a build, as upstream.+ adImpureEnvVars :: !(Either Text [Text])+ }++-- | How the derivation's attributes reach the builder: as environment+-- variables, outputs included, or for a derivation carrying structured+-- attributes, as @.attrs.json@ and @.attrs.sh@ in the build directory+-- named by two variables, with no attribute and no output in the+-- environment (upstream's @initTmpDir@ and @writeStructuredAttrs@,+-- local-derivation-goal.cc at 2.24.9).+--+-- @rewrite@ is the build's rewrite of store paths and placeholders. It+-- applies to the shell file's text as upstream applies it, after+-- rendering; the JSON file's strings take it before they are escaped, so+-- a store directory holding backslashes still yields JSON.+attrsDelivery :: (Text -> Text) -> FilePath -> [(Text, FilePath)] -> Map Text Text -> Either Text AttrsDelivery+attrsDelivery rewrite buildDir outputDirs decodedEnv = case Map.lookup structuredAttrsKey decodedEnv of+ Nothing ->+ Right+ AttrsDelivery+ { adEnv = unionEnvs [outputEnv, rewriteEnv rewrite decodedEnv],+ adFiles = [],+ adImpureEnvVars = Right (T.words (Map.findWithDefault "" envImpureEnvVars decodedEnv))+ }+ Just encoded -> do+ attrs <- decodeStructuredAttrs encoded+ let prepared = withOutputPlaceholders (map fst outputDirs) attrs+ jsonFile = buildDir </> attrsJsonFileName+ shellFile = buildDir </> attrsShellFileName+ Right+ AttrsDelivery+ { adEnv = Map.fromList [(envNixAttrsJsonFile, T.pack jsonFile), (envNixAttrsShFile, T.pack shellFile)],+ adFiles =+ [ (jsonFile, renderAttrsJson rewrite prepared),+ (shellFile, TE.encodeUtf8 (rewrite (renderAttrsShell prepared)))+ ],+ adImpureEnvVars = fromMaybe [] <$> stringsAttr envImpureEnvVars attrs+ }+ where+ outputEnv = Map.fromList [(name, T.pack path) | (name, path) <- outputDirs]++-- | Build the process environment from the derivation's contribution+-- ('adEnv') + 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 -- shipped alongside the builder (e.g. Git for Windows' MSYS2 tools)@@ -679,15 +757,9 @@ Map Text Text -> FilePath -> FilePath ->- [(Text, FilePath)] -> Map Text Text-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+buildEnvironment config derivationEnv builderPath buildDir =+ let -- Standard build variables standardEnv = Map.fromList [ (envNixBuildTop, T.pack buildDir),@@ -700,8 +772,8 @@ (envPath, buildPath builderPath), (envSourceDateEpoch, sourceDateEpochValue) ]- in -- Priority: output paths > derivation env > standard env- unionEnvs [outputEnv, baseEnv, standardEnv]+ in -- Priority: derivation env (output paths first) > standard env+ unionEnvs [derivationEnv, standardEnv] -- | Union environment maps left to right (an earlier map's variable wins). -- On Windows environment names are one case-insensitive namespace, so@@ -884,13 +956,14 @@ -- Built-in fetcher (builtin:fetchurl) -- --------------------------------------------------------------------------- --- | 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 =+-- | Run a @builtin:fetchurl@ derivation: try URLs in order, each under the+-- retry policy, 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 :: FetchRetryPolicy -> Derivation -> [(Text, FilePath)] -> IO (Either (Int, Text) ())+runBuiltinFetchurl policy drv outputDirs = 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"))@@ -904,7 +977,7 @@ Nothing -> pure (Left (1, "builtin:fetchurl: unsupported hash algorithm '" <> algo <> "'")) Just ctx -> do- result <- tryFetchUrlsWith (fetchAndVerify outPath out ctx) urls+ result <- tryFetchUrlsWith (fetchWithRetry outPath out ctx) urls pure $ case result of Left err -> Left (1, err) Right value -> Right value@@ -916,11 +989,19 @@ fixedOutput = case drvOutputs drv of (out : _) | not (T.null (doHashAlgo out)) -> Just out _ -> Nothing+ fetchWithRetry outPath out ctx url =+ first attemptFailureMessage <$> retryTransient policy ioRetryEffects (fetchAndVerify outPath out ctx url)+ -- A download that completed and verifies wrong is the server's bytes,+ -- not a hiccup: upstream checks the hash after the transfer layer has+ -- given up retrying, so a mismatch is never retried. 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)+ Left failure -> Left (prefixFailure failure)+ Right digest -> first (FatalFailure . snd) (verifyFetchedDigest url out digest)+ prefixFailure failure = case failure of+ TransientFailure msg -> TransientFailure ("builtin:fetchurl: " <> msg)+ FatalFailure msg -> FatalFailure ("builtin:fetchurl: " <> msg) -- | Nova's mirror extension uses the ASCII-whitespace-separated @urls@ field. -- An absent field preserves the legacy single @url@ interface. An explicit@@ -955,27 +1036,34 @@ Nothing -> pure (Left (T.intercalate "\n" (reverse (err : failures)))) Just urls -> go (err : failures) urls +-- ---------------------------------------------------------------------------+-- Download+-- ---------------------------------------------------------------------------+ -- | 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)+-- is of exactly the written bytes. A failure of the transfer or of the+-- write becomes a 'Left' carrying its retry class+-- ('transferFailureHandlers'); cancellation propagates through the shared+-- build cleanup. Every attempt opens the output in WriteMode and starts+-- from the original hash context, so a retry never resumes a torn+-- transfer: upstream resumes from the written offset when the server+-- accepts ranges and otherwise gives up, which this restart subsumes.+downloadUrlTo :: Text -> FilePath -> IncrementalHash -> IO (Either AttemptFailure BS.ByteString) downloadUrlTo url outPath ctx0 =- fetch `catchSync` \err -> pure (Left ("download error: " <> T.pack (show err)))+ fetch `catches` transferFailureHandlers url where- fetch :: IO (Either Text BS.ByteString)+ fetch :: IO (Either AttemptFailure BS.ByteString) fetch = do manager <- HTTPS.getGlobalManager- request0 <- HTTP.parseRequest (T.unpack url)- let request = request0 {HTTP.requestHeaders = ("User-Agent", fetchUserAgent) : HTTP.requestHeaders request0}- 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))+ request <- withUserAgent <$> HTTP.parseRequest (T.unpack url)+ withTransfer request manager $ \response -> do+ let status = HTTP.responseStatus response+ if HTTP.statusCode status /= httpStatusOk+ then pure (Left (fetchStatusFailure url status)) else System.IO.withBinaryFile outPath System.IO.WriteMode $ \handle -> let consume !ctx = do chunk <- HTTP.brRead (HTTP.responseBody response)@@ -1057,10 +1145,10 @@ [OutputPlan] -> IO BuildResult 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)- allCandidates = collectAllCandidates drv ++ inputOutputs+ -- Candidates for reference scanning: input sources, the input+ -- derivations' realized OUTPUT paths, and this derivation's own outputs.+ inputOutputs <- fromRight [] <$> resolveInputOutputs config drv+ let allCandidates = collectAllCandidates drv ++ inputOutputs -- Deriver path is not available from the Derivation type alone; -- the caller (buildWithDeps) would need to pass it through. -- Register with no deriver for now - queryDeriver will return Nothing.@@ -1146,7 +1234,7 @@ [] -> do refs <- dedupStorePaths <$> scanReferences candidates outDir reg <- placeInStore store outDir targetSP drvPathText refs- fixedCheck <- verifyFixedOutput output targetPath+ fixedCheck <- verifyFixedOutput (volumeCaseHack (stCaseSensitivity store)) output targetPath case fixedCheck of Left err -> do -- Wrong bytes for a declared content address: remove the@@ -1163,14 +1251,15 @@ -- 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+-- Non-fixed outputs (empty @doHashAlgo@) pass unchecked. The tree is+-- read back under the store volume's case-hack mode.+verifyFixedOutput :: NAR.CaseHack -> DerivationOutput -> FilePath -> IO (Either Text ())+verifyFixedOutput caseHack 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+ entry <- ExecBit.serialiseFromPath caseHack placedPath pure (finish (rawHashWithAlgo algo (NAR.serialise entry))) | otherwise = do isDir <- doesDirectoryExist placedPath@@ -1247,8 +1336,9 @@ -- Returns 'BuildSuccess' with the root output path on success, or -- 'BuildFailure' if any dependency fails to build or substitute. buildWithDeps :: BuildConfig -> Store -> Derivation -> StorePath -> IO BuildResult-buildWithDeps config store rootDrv rootDrvPath =- case buildDepGraph (readDrvFromStore config) rootDrv rootDrvPath of+buildWithDeps config store rootDrv rootDrvPath = do+ graph <- buildDepGraph (readDrvFromStore config) rootDrv rootDrvPath+ case graph of Left err -> pure (BuildFailure ("dependency graph error: " <> err) 1) Right depGraph -> case topoSort depGraph of@@ -1269,27 +1359,15 @@ lookupDrv (Nix.DependencyGraph.DepGraph g) sp = Nix.DependencyGraph.dnDerivation <$> Map.lookup sp g --- | Read and parse a .drv file from the store.------ Since 'buildDepGraph' is pure but needs to read immutable .drv files,--- we use 'System.IO.Unsafe.unsafePerformIO'. This is safe because .drv--- files are write-once: their content is determined by their hash, so--- repeated reads always yield the same result.-readDrvFromStore :: BuildConfig -> StorePath -> Either Text Derivation-readDrvFromStore config sp =+-- | Read and parse a .drv file from the store, as bytes (no locale+-- decode, no newline translation).+readDrvFromStore :: BuildConfig -> StorePath -> IO (Either Text Derivation)+readDrvFromStore config sp = do let drvFilePath = storePathToFilePath (bcStoreDir config) sp- in case unsafeReadFile drvFilePath of- Nothing -> Left ("cannot read .drv file: " <> T.pack drvFilePath)- Just content -> fromATerm content---- | 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 (BS.readFile path)) of- Left (_ :: SomeException) -> Nothing- Right content -> Just content+ content <- try (BS.readFile drvFilePath)+ pure $ case content of+ Left (_ :: IOException) -> Left ("cannot read .drv file: " <> T.pack drvFilePath)+ Right bytes -> fromATerm bytes -- --------------------------------------------------------------------------- -- Build in topological order@@ -1396,7 +1474,7 @@ -- finally owns it even when a later output's attempt fails or -- throws. substituteOne heldLocks sp = do- result <- trySubstitute store (bcCaches config) sp+ result <- trySubstituteWith (bcFetchRetry config) ioRetryEffects store (bcCaches config) sp case result of SubstSuccess _ lock -> atomicModifyIORef' heldLocks (\locks -> (lock : locks, ())) _ -> pure ()
src/Nix/Builder/Unpack.hs view
@@ -55,7 +55,7 @@ import qualified Codec.Archive.Tar as Tar import qualified Codec.Archive.Tar.Entry as TarEntry import qualified Codec.Compression.Zstd.Lazy as Zstd-import Control.Exception (SomeException, try)+import Control.Exception (ErrorCall, Handler (..), IOException, catches) import Control.Monad (when) import Data.Bits ((.&.)) import qualified Data.ByteString as BS@@ -214,16 +214,20 @@ -- | 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.+-- entry. The archive is read lazily and decoded as the entries are+-- forced, so a failure surfaces mid-extraction as one of two exceptions,+-- and the whole extraction is wrapped to turn either into a clean+-- failure: an 'IOException' reading the archive or writing the tree, or+-- the 'ErrorCall' the zstd decoder raises for a corrupt frame (its lazy+-- API reports every decoding error with 'error'). unpackArchive :: FilePath -> UnpackLimits -> FilePath -> IO (Either Text UnpackLimits)-unpackArchive outDir budget archivePath = do- attempt <- try run- pure $ case attempt of- Left (e :: SomeException) ->- Left (T.pack archivePath <> ": " <> T.pack (show e))- Right result -> result+unpackArchive outDir budget archivePath =+ run+ `catches` [ Handler (\(e :: IOException) -> failed e),+ Handler (\(e :: ErrorCall) -> failed e)+ ] where+ failed e = pure (Left (T.pack archivePath <> ": " <> T.pack (show e))) run = case decoderFor archivePath of Nothing -> pure (Left ("unsupported archive format: " <> T.pack archivePath)) Just decoder -> do
src/Nix/Builtins.hs view
@@ -4,84 +4,87 @@ -- ~100 functions. This module assembles the initial 'Env' from the -- central registry in "Nix.Eval" and adds standard constants -- (@true@, @false@, @null@, @storeDir@, @currentTime@,--- @currentSystem@, etc.).+-- @currentSystem@, etc.), among them @derivation@: upstream's wrapper+-- lambda around the @derivationStrict@ primop, evaluated from its source.+-- The impure constants follow the evaluation policy: under @pure-eval@+-- upstream installs neither @currentTime@ nor @currentSystem@+-- (@addConstant@ skips an @impureOnly@ constant), so neither exists here+-- then.+--+-- The environment is allocated in the C data layer, so 'builtinEnv' must+-- be forced between 'Nix.Eval.Arena.arenaInit' and+-- 'Nix.Eval.Arena.arenaDestroy'; outside that window forcing it raises+-- 'Nix.Eval.CStatus.ArenaNotInitialized'. module Nix.Builtins ( -- * Builtin registration builtinEnv, builtinEnvWithScope,+ rootScopeNames, -- * NIX_PATH parsing parseNixPath, splitNixPath,+ searchPathRoots,+ isNixPathPseudoUrl, ) where +import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BC import Data.Char (isAsciiLower, isAsciiUpper) import Data.Int (Int64)+import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map+import Data.Maybe (fromMaybe, mapMaybe)+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 Foreign.Ptr (nullPtr)-import Nix.Eval (Env (..), NixValue (..), Thunk (..), attrSetFromMap, builtinNames, currentSystemStr, evaluated)-import Nix.Eval.Types (clistFromThunks, mkStr, newCEnv, thunkToCPtr)+import Nix.Eval (Env (..), NixValue (..), Thunk (..), attrSetFromMap, attrSetLookup, builtinNames, currentSystemStr, deferApply, evaluated, readThunkValue)+import Nix.Eval.Types (AttrSet, EvalPolicy (..), cheapThunk, clistFromThunks, mkStr, mkStrBytes, newCEnv, thunkToCPtr)+import Nix.Expr.Resolve (impureOnlyGlobalNames, staticGlobalNames)+import Nix.Parser (parseNixWithScope) import Nix.Store.Path (defaultStoreDirText) --- | The initial environment containing all builtins.+-- | The initial environment: every name upstream's base environment binds+-- under the policy ('rootScopeNames'), each holding the value @builtins@+-- holds under that name less a @__@ prefix, as upstream's @addPrimOp@ and+-- @addConstant@ install one value under both (eval.cc at 2.24.9). So+-- @__typeOf@ is @builtins.typeOf@, and @map@ is @builtins.map@. ----- Real Nix exposes a subset of builtins at the top level without--- the @builtins.@ prefix. These are the functions most commonly--- used unqualified in nixpkgs and user code.+-- @fetchMercurial@ is the one name upstream binds by default that nova-nix+-- does not implement: it is bound as a builtin of that name, so binding+-- accepts it and a call fails with the evaluator's unknown-builtin error.+-- @builtins@ does not list it, so feature tests still see it is missing. -- -- @currentTime@ is an integer constant (seconds since epoch), -- passed in at startup. In tests, pass @0@. -- -- @searchPaths@ populates @builtins.nixPath@. Parsed from @NIX_PATH@ -- by 'parseNixPath'. In tests, pass @[]@.-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+--+-- The policy decides which constants exist: see 'standardEntries'.+builtinEnv :: EvalPolicy -> Int64 -> [Thunk] -> Env+builtinEnv policy timestamp searchPaths =+ let builtinsSet = builtinsAttrSet policy timestamp searchPaths+ bind name+ | name == "builtins" = evaluated (VAttrs builtinsSet)+ | otherwise =+ let unprefixed = fromMaybe name (T.stripPrefix "__" name)+ in fromMaybe (evaluated (VBuiltin unprefixed [])) (attrSetLookup unprefixed builtinsSet)+ scope = attrSetFromMap (Map.fromSet bind (rootScopeNames policy)) 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",- "fromTOML",- "import",- "isNull",- "map",- "placeholder",- "removeAttrs",- "scopedImport",- "throw",- "toString"- ]+-- | The names the root environment binds under a policy: upstream's base+-- environment, which leaves out its @impureOnly@ constants under+-- @pure-eval@. Source evaluated in 'builtinEnv' is bound against these.+rootScopeNames :: EvalPolicy -> Set Text+rootScopeNames policy+ | epPureEval policy = staticGlobalNames `Set.difference` impureOnlyGlobalNames+ | otherwise = staticGlobalNames -- | Create a top-level binding for a builtin function. topLevelBuiltin :: Text -> (Text, Thunk)@@ -89,23 +92,29 @@ -- | Like 'builtinEnv' but with additional scope bindings overlaid on -- the top-level environment. Used by @scopedImport@.-builtinEnvWithScope :: Int64 -> [Thunk] -> [(Text, Thunk)] -> Env-builtinEnvWithScope timestamp searchPaths scope =- let base = builtinEnv timestamp searchPaths+builtinEnvWithScope :: EvalPolicy -> Int64 -> [Thunk] -> [(Text, Thunk)] -> Env+builtinEnvWithScope policy timestamp searchPaths scope =+ let base = builtinEnv policy timestamp searchPaths scopeMap = Map.fromList scope in newCEnv nullPtr 0 (Just (attrSetFromMap scopeMap)) (Just base) nullPtr 0 -- | The @builtins@ attribute set, derived from the central registry.-builtinsAttrSet :: Int64 -> [Thunk] -> NixValue-builtinsAttrSet timestamp searchPaths =- VAttrs $ attrSetFromMap $ Map.union builtinEntries (standardEntries timestamp searchPaths)+builtinsAttrSet :: EvalPolicy -> Int64 -> [Thunk] -> AttrSet+builtinsAttrSet policy timestamp searchPaths =+ attrSetFromMap $ Map.union builtinEntries (standardEntries policy timestamp searchPaths) where builtinEntries = Map.fromList [(name, evaluated (VBuiltin name [])) | name <- builtinNames] -standardEntries :: Int64 -> [Thunk] -> Map.Map Text Thunk-standardEntries timestamp searchPaths =- Map.fromList+-- | The constants beside the registry. @currentTime@ and @currentSystem@+-- are upstream's @impureOnly@ constants and are absent under @pure-eval@+-- (@builtins ? currentTime@ is @false@ there, observed from+-- nix-instantiate 2.33.2); @nixPath@ stays, holding whatever search path+-- the caller passed, which under @pure-eval@ is what upstream leaves of+-- it.+standardEntries :: EvalPolicy -> Int64 -> [Thunk] -> Map.Map Text Thunk+standardEntries policy timestamp searchPaths =+ Map.fromList $ [ ("true", evaluated (VBool True)), ("false", evaluated (VBool False)), ("null", evaluated VNull),@@ -116,84 +125,228 @@ ("nixVersion", evaluated (mkStr "2.24.0")), ("langVersion", evaluated (VInt 6)), ("nixPath", evaluated (VList (clistFromThunks (map thunkToCPtr searchPaths)))),- ("currentTime", evaluated (VInt timestamp)),- ("currentSystem", evaluated (mkStr currentSystemStr))+ ("derivation", derivationWrapper) ]+ ++ [("currentTime", evaluated (VInt timestamp)) | not (epPureEval policy)]+ ++ [("currentSystem", evaluated (mkStr currentSystemStr)) | not (epPureEval policy)] -- ---------------------------------------------------------------------------+-- The derivation constant+-- ---------------------------------------------------------------------------++-- | Upstream's @src\/libexpr\/primops\/derivation.nix@ at 2.24.9, without its+-- documentation comment. @derivation@ is this lambda, not a primop, so the+-- result set has upstream's shape by construction: every output attribute+-- is the complete derivation set for that output, @all@ lists them,+-- @drvAttrs@ is the argument set, and @derivationStrict@ is reached only+-- through @outPath@ and @drvPath@, which is what keeps forcing a derivation+-- to WHNF from forcing any of its inputs.+derivationWrapperSource :: Text+derivationWrapperSource =+ T.unlines+ [ "drvAttrs @ { outputs ? [ \"out\" ], ... }:",+ "",+ "let",+ "",+ " strict = derivationStrict drvAttrs;",+ "",+ " commonAttrs = drvAttrs // (builtins.listToAttrs outputsList) //",+ " { all = map (x: x.value) outputsList;",+ " inherit drvAttrs;",+ " };",+ "",+ " outputToAttrListElement = outputName:",+ " { name = outputName;",+ " value = commonAttrs // {",+ " outPath = builtins.getAttr outputName strict;",+ " drvPath = strict.drvPath;",+ " type = \"derivation\";",+ " inherit outputName;",+ " };",+ " };",+ "",+ " outputsList = map outputToAttrListElement outputs;",+ "",+ "in (builtins.head outputsList).value"+ ]++-- | The name upstream gives the wrapper's source in error positions.+derivationWrapperName :: Text+derivationWrapperName = "derivation-internal.nix"++-- | The bindings the wrapper's free variables resolve to. Upstream evaluates+-- the file in its base environment; this is the slice of that environment+-- the wrapper reads, so the constant needs no knot through 'builtinEnv'.+-- The wrapper is bound against these names too, so it cannot read one the+-- slice lacks.+derivationWrapperScope :: Map Text Thunk+derivationWrapperScope =+ let builtinsUsed = ["getAttr", "head", "listToAttrs"]+ in Map.fromList+ [ ("derivationStrict", evaluated (VBuiltin "derivationStrict" [])),+ ("map", evaluated (VBuiltin "map" [])),+ ("builtins", evaluated (VAttrs (attrSetFromMap (Map.fromList (map topLevelBuiltin builtinsUsed)))))+ ]+{-# NOINLINE derivationWrapperScope #-}++-- | 'derivationWrapperScope' as the wrapper's environment.+derivationWrapperEnv :: Env+derivationWrapperEnv =+ newCEnv nullPtr 0 (Just (attrSetFromMap derivationWrapperScope)) Nothing nullPtr 0+{-# NOINLINE derivationWrapperEnv #-}++-- | The @derivation@ constant: the wrapper lambda closed over+-- 'derivationWrapperEnv'. One value bound in both the root scope and+-- @builtins@, as upstream's @addConstant@ binds it. It is compiled once+-- for the process rather than once per environment: 'builtinEnv' runs for+-- every imported file, and the arena the lambda lives in is the process's+-- ("Nix.Eval.Arena"). The source is a constant, so a parse failure is a+-- defect in this module; it surfaces as an abort at the first use of+-- @derivation@ rather than as a crash while the environment is assembled.+derivationWrapper :: Thunk+derivationWrapper =+ case parseNixWithScope (Map.keysSet derivationWrapperScope) "/" derivationWrapperName derivationWrapperSource of+ Right expr -> cheapThunk derivationWrapperEnv expr+ Left err -> abortingThunk ("the derivation wrapper does not parse: " <> T.pack (show err))+{-# NOINLINE derivationWrapper #-}++-- | A thunk that aborts evaluation with the message when forced: @abort@+-- applied to it, deferred the way "Nix.Eval" defers any application.+abortingThunk :: Text -> Thunk+abortingThunk message = deferApply (VBuiltin "abort" []) (evaluated (mkStr message))++-- --------------------------------------------------------------------------- -- NIX_PATH parsing -- --------------------------------------------------------------------------- -- | Parse a @NIX_PATH@-formatted string into a list of search path entry -- thunks. Each entry becomes a @{ prefix, path }@ attrset. ----- Format: colon-separated entries, each either @name=path@ or plain @path@.--- A plain path gets an empty prefix (matching real Nix behaviour).+-- Format: colon-separated entries, each either @name=path@ or plain @path@,+-- split at the first @=@. A plain path gets an empty prefix (matching+-- real Nix behaviour). The prefix and path keep the entry's bytes, as+-- upstream's @LookupPath::Elem::parse@ keeps them+-- (src/libexpr/search-path.cc at Nix 2.24.9). -- -- >>> parseNixPath "nixpkgs=/home/user/nixpkgs:custom=/opt/custom" -- [Evaluated (VAttrs {"prefix": "nixpkgs", "path": "/home/user/nixpkgs"}), ...]-parseNixPath :: Text -> [Thunk]+parseNixPath :: ByteString -> [Thunk] parseNixPath raw- | T.null raw = []+ | BS.null raw = [] | otherwise = map parseEntry (splitNixPath raw) where parseEntry entry =- let (prefix, path) = case T.breakOn "=" entry of+ let (prefix, path) = case BC.break (== '=') entry of (before, after)- | T.null after -> ("", before)- | otherwise -> (before, T.drop 1 after)+ | BS.null after -> ("", before)+ | otherwise -> (before, BS.drop 1 after) in evaluated ( VAttrs ( attrSetFromMap $ Map.fromList- [ ("prefix", evaluated (mkStr prefix)),- ("path", evaluated (mkStr path))+ [ ("prefix", evaluated (mkStrBytes prefix)),+ ("path", evaluated (mkStrBytes path)) ] ) ) --- | Split a NIX_PATH string on colon separators. A colon stays part of--- its entry, rather than separating, in exactly two shapes:+-- | The @path@ of every search path entry 'parseNixPath' produced, for the+-- restricted-mode allow list: upstream allows every lookup path root+-- (@resolveLookupPathPath@ with @initAccessControl@), so the roots are+-- read back from the very list the evaluator will search. A root is the+-- bytes the entry holds. An entry that is not an attribute set with a+-- string or path @path@ contributes nothing.+searchPathRoots :: [Thunk] -> [ByteString]+searchPathRoots = mapMaybe entryRoot+ where+ entryRoot thunk = do+ VAttrs attrs <- readThunkValue thunk+ pathThunk <- attrSetLookup "path" attrs+ pathVal <- readThunkValue pathThunk+ case pathVal of+ VStr bytes _ -> Just bytes+ VPath p -> Just (TE.encodeUtf8 p)+ _ -> Nothing++-- | URL schemes that keep a @NIX_PATH@ entry whole when @://@ follows+-- them: the allowlist in @EvalSettings::isPseudoUrl@,+-- src/libexpr/eval-settings.cc at Nix 2.24.9.+nixPathUrlSchemes :: [ByteString]+nixPathUrlSchemes = ["http", "https", "file", "channel", "git", "s3", "ssh"]++-- | Schemes that keep a @NIX_PATH@ entry whole whatever follows their+-- colon: @isPseudoUrl@'s @channel:@ test and @parseNixPath@'s @flake:@+-- test, both in src/libexpr/eval-settings.cc at Nix 2.24.9.+nixPathBareSchemes :: [ByteString]+nixPathBareSchemes = ["channel", "flake"]++-- | Whether a search path entry's path names something fetched rather+-- than a place on this filesystem: upstream's @EvalSettings::isPseudoUrl@+-- (a @channel:@ prefix, or one of 'nixPathUrlSchemes' before @://@),+-- plus the @flake:@ entries a lookup path hook resolves+-- (@resolveLookupPathPath@ in src/libexpr/eval.cc at Nix 2.24.9).+isNixPathPseudoUrl :: ByteString -> Bool+isNixPathPseudoUrl path =+ any (\bare -> (bare <> ":") `BS.isPrefixOf` path) nixPathBareSchemes+ || (not (BS.null afterScheme) && scheme `elem` nixPathUrlSchemes)+ where+ (scheme, afterScheme) = BS.breakSubstring "://" path++-- | Split a @NIX_PATH@ string into entries the way upstream's+-- @EvalSettings::parseNixPath@ does (src/libexpr/eval-settings.cc at+-- Nix 2.24.9). An entry runs to the first colon. That colon stays in+-- the entry, which then extends through exactly one more colon-free+-- run, when the text between the last @=@ before the colon (or the+-- entry's start) and the colon is: ----- * 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.+-- * one of 'nixPathBareSchemes', whatever follows the colon, so+-- @channel:nixos-24.11@ and @nixpkgs=channel:nixos-24.11@ stay whole;+-- * one of 'nixPathUrlSchemes' with @//@ after the colon, so+-- @nixpkgs=https://example.com/nixpkgs.tar.gz@ stays whole while an+-- unlisted @foo://x@ splits into @foo@ and @//x@, as it does upstream;+-- * a single ASCII letter with @/@ or @\\@ after the colon: a Windows+-- drive colon, so @C:\\x@, @C:/x@ and @nixpkgs=C:\\x@ stay whole.+-- Upstream has no such rule and splits every drive path at its letter+-- (nix-instantiate 2.33.2 reports @C:\\a@ as the entries @C@ and @\\a@).+-- The rule also keeps a one-letter scheme such as @C://x@ whole, which+-- upstream splits into @C@ and @//x@. ----- 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 []+-- Only one further run is absorbed, so @https://example.com:8080/x@+-- splits after the host exactly as it does upstream. Every other colon+-- separates: @/foo:/bar@ is two entries. An empty entry is dropped:+-- @a::b@ is @a@ and @b@, @::x@ is @x@, @a:@ is @a@. Upstream's parser+-- emits a leading or interior empty entry (never a trailing one), but+-- the entries are then round-tripped through a whitespace-separated+-- setting (initGC in src/libexpr/eval-gc.cc) that drops it, so+-- @builtins.nixPath@ never shows one. That round trip also splits an+-- entry at a space, which is not reproduced here: a Windows path may+-- contain one, the same reason the drive rule above diverges.+--+-- The split works on bytes, as upstream's does. Every delimiter is+-- ASCII, and no byte of a multibyte UTF-8 sequence is, so a UTF-8 entry+-- splits exactly where its text would and any other byte passes through+-- unchanged.+splitNixPath :: ByteString -> [ByteString]+splitNixPath = filter (not . BS.null) . go where- -- 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 == '\\'+ go remaining+ | BS.null remaining = []+ | otherwise =+ let (segment, rest) = BC.break (== ':') remaining+ in case BC.uncons rest of+ Nothing -> [segment]+ Just (_, afterColon)+ | keepsColon (BC.takeWhileEnd (/= '=') segment) afterColon ->+ let (absorbed, afterEntry) = BC.break (== ':') afterColon+ in BS.concat [segment, ":", absorbed] : go (BS.drop 1 afterEntry)+ | otherwise -> segment : go afterColon+ keepsColon scheme afterColon+ | scheme `elem` nixPathBareSchemes = True+ | scheme `elem` nixPathUrlSchemes = BS.isPrefixOf "//" afterColon+ | otherwise = isDriveLetter scheme && startsWithPathSep afterColon+ isDriveLetter scheme = case BC.uncons scheme of+ Just (letter, afterLetter) -> BS.null afterLetter && (isAsciiUpper letter || isAsciiLower letter) Nothing -> False- endsInDriveLetter atEntryStart chunk = case T.unsnoc chunk of- Just (beforeLetter, letter) ->- (isAsciiUpper letter || isAsciiLower letter)- && (T.isSuffixOf "=" beforeLetter || (atEntryStart && T.null beforeLetter))+ startsWithPathSep t = case BC.uncons t of+ Just (c, _) -> c == '/' || c == '\\' Nothing -> False
src/Nix/Config.hs view
@@ -1,20 +1,65 @@ -- | Nix configuration: the @nix.conf@ / @NIX_CONFIG@ settings that decide--- which binary caches a machine substitutes from and which public keys it--- trusts.+-- which binary caches a machine substitutes from, which public keys it+-- trusts, how deep function calls may nest, and how far an evaluation+-- may reach (@restrict-eval@, @pure-eval@, @allowed-uris@). -- -- == 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.+-- line (a comment). A list value is whitespace separated. An @include@+-- or @!include@ line splices another file in at that position: the path+-- is resolved against the directory of the file being parsed, and the two+-- forms differ only in that @!include@ goes on without a file that cannot+-- be read. These are the rules upstream's @parseConfigFiles@ applies+-- (@config.cc@ at 2.24.9: comment truncation, no line continuation,+-- whitespace tokenizing, inline include expansion), matched here. A+-- Boolean value is one of @true@, @yes@, @1@, @false@, @no@, @0@+-- (upstream's @BaseSetting\<bool\>::parse@), and any other spelling is+-- an error. An integer value is upstream's too ('parseUnsignedSetting'):+-- decimal digits, an optional leading @+@, and an optional binary unit+-- suffix (@K@, @M@, @G@, @T@). --+-- == Bytes+--+-- A source is bytes until a setting takes a value from it, as upstream's+-- @std::string@ is. A line splits into tokens on upstream's separators+-- alone (space, tab, CR, LF), not on every Unicode space; a comment, and+-- the value of a setting this layer does not model, are never decoded;+-- and an include target reaches the open as the bytes the line holds+-- ("Nix.HostPath"), so a target with no UTF-8 reading names the file it+-- names on disk. A Boolean or integer setting compares the value's bytes+-- with what it accepts, so a byte with no UTF-8 reading there is+-- upstream's own invalid-value error. A list setting's tokens are+-- decoded strictly where upstream keeps them as bytes, because everything+-- downstream holds them as text (a substituter's URL, a key matched+-- against a narinfo's signatures, a URI prefix matched against the+-- evaluator's URIs). A token that is not UTF-8 is refused rather than+-- spelled with U+FFFD, which would name a different cache, key or URI.+--+-- == Where the files are+--+-- Upstream's @loadConfFile@ (@globals.cc@) reads, weakest first: the system+-- file @$NIX_CONF_DIR\/nix.conf@, by default under the platform's+-- machine-wide config directory (@\/etc\/nix@ where upstream runs), the+-- directory having to be absolute and being collapsed lexically as the+-- @Settings@ constructor runs it through @canonPath@; the user files,+-- which are @$NIX_USER_CONF_FILES@ when that is set and otherwise+-- @nix\/nix.conf@ under the XDG config home and then under each+-- @XDG_CONFIG_DIRS@ entry, applied back to front so the first listed wins;+-- then @NIX_CONFIG@. 'configFilePaths' computes that order from the+-- environment and the platform's directories, which the caller looks up+-- (this module holds no platform literal), and 'loadConfig' reads and+-- folds it.+-- -- == 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+-- prefixed assignment APPENDS to a list. An @extra-@ on a Boolean or a+-- scalar names no setting (upstream appends only to an appendable+-- setting, so @extra-max-call-depth@ is an unknown name) and is ignored+-- like any unknown name. 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@@ -23,83 +68,465 @@ -- 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.+-- pure and total so the whole rule set can be tested directly, and the+-- include expander is written over a file-reading effect+-- ('ReadConfigFile') for the same reason. ----- 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.+-- One deliberate divergence: upstream silently drops an @include@ whose+-- file exists but cannot be read (its own TODO questions that). Here a+-- required include that cannot be read is an error, because the only+-- thing @include@ promises over @!include@ is to fail when the file is+-- unusable, and an include is where a site narrows the substituter or+-- trusted-key set; dropping it leaves the wider set in force. The reader+-- therefore reports which of the two happened ('ConfigReadFailure'), so a+-- missing include is reported in upstream's words and an unreadable one+-- in its own. module Nix.Config ( -- * Resolved settings NixConfig (..), defaultNixConfig, + -- * Where the files are+ ConfigLocations (..),+ configFilePaths,+ nixConfFileName,+ nixConfDirName,++ -- * Sources and includes+ ConfigSource (..),+ nixConfigSourceName,+ ConfigReadFailure (..),+ ReadConfigFile,+ loadConfig,+ expandConfigSource,+ -- * Parsing and folding+ ConfigLine (..),+ IncludeMode (..), ConfigAssignment (..),- parseConfigText,+ parseConfigLines,+ parseUnsignedSetting, applyAssignment,- applyConfigText, resolveConfig, ) where +import Control.Applicative ((<|>))+import Control.Monad (foldM, mfilter, when)+import Control.Monad.Except (ExceptT (..), liftEither, runExceptT, throwError)+import Control.Monad.Trans (lift)+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8+import Data.Char (isDigit, ord, toUpper)+import Data.Either (rights)+import Data.List (dropWhileEnd, intercalate)+import Data.List.NonEmpty (NonEmpty (..))+import qualified Data.List.NonEmpty as NE+import Data.Maybe (catMaybes, fromMaybe, maybeToList) import Data.Text (Text)-import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import Data.Word (Word32)+import Nix.Eval.CallDepth (defaultMaxCallDepth)+import Nix.HostPath (hostPathFromBytes, hostPathText)+import System.OsPath (OsChar, OsPath, OsString, (</>))+import qualified System.OsPath as OP -- --------------------------------------------------------------------------- -- 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).+-- | The subset of Nix settings that this layer resolves. The lists are+-- ordered (upstream's @substituters@, @trusted-public-keys@ and+-- @allowed-uris@ are each a @Strings@); order is preserved on write, though+-- it does not affect key acceptance (any trusted key is enough) or URI+-- admission (any prefix is enough). data NixConfig = NixConfig { ncSubstituters :: ![Text],- ncTrustedPublicKeys :: ![Text]+ ncTrustedPublicKeys :: ![Text],+ -- | Upstream's @max-call-depth@: how many function calls may be+ -- active around a new one before evaluation refuses it.+ ncMaxCallDepth :: !Word32,+ -- | URI prefixes a fetch may reach under @restrict-eval@.+ ncAllowedUris :: ![Text],+ -- | Upstream @restrict-eval@.+ ncRestrictEval :: !Bool,+ -- | Upstream @pure-eval@.+ ncPureEval :: !Bool } 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.+-- | The baseline the fold starts from: no substituters, no trusted keys,+-- upstream's call-depth ceiling, no allowed URIs, and both evaluation+-- modes off. The empty substituter list 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. The other defaults are upstream's own. defaultNixConfig :: NixConfig-defaultNixConfig = NixConfig {ncSubstituters = [], ncTrustedPublicKeys = []}+defaultNixConfig =+ NixConfig+ { ncSubstituters = [],+ ncTrustedPublicKeys = [],+ ncMaxCallDepth = defaultMaxCallDepth,+ ncAllowedUris = [],+ ncRestrictEval = False,+ ncPureEval = False+ } -- ---------------------------------------------------------------------------+-- Where the files are+-- ---------------------------------------------------------------------------++-- | The environment that decides which files are read. The two variables+-- are carried raw, as the bytes 'Nix.Environment.lookupEnvBytes' returns,+-- because upstream reads them differently: an empty @NIX_CONF_DIR@ is+-- unset (@getEnvNonEmpty@), while an empty @NIX_USER_CONF_FILES@ is set+-- and names no files at all. The directories arrive resolved, since the+-- platform defaults behind them (where the machine-wide config directory+-- is, what the XDG config home and dirs fall back to) are the caller's to+-- look up.+data ConfigLocations = ConfigLocations+ { -- | @NIX_CONF_DIR@.+ clConfDir :: !(Maybe ByteString),+ -- | The platform's machine-wide config directory, where the system+ -- file is unless @NIX_CONF_DIR@ moves it; 'Nothing' on a platform+ -- that names none, and then there is no system file to read.+ clSystemConfDir :: !(Maybe OsPath),+ -- | @NIX_USER_CONF_FILES@.+ clUserConfFiles :: !(Maybe ByteString),+ -- | The XDG config home.+ clConfigHome :: !OsPath,+ -- | The XDG config dirs, first entry strongest.+ clConfigDirs :: ![OsPath]+ }+ deriving (Eq, Show)++-- | The config files to read, weakest first: the system file, under+-- @NIX_CONF_DIR@ when that is set and non-empty and otherwise under the+-- platform's machine-wide directory (no file at all when the platform has+-- none); then the user files in reverse of the order upstream lists them,+-- since @loadConfFile@ walks @nixUserConfFiles@ back to front+-- (globals.cc:139) so that the first listed, the config home, wins.+-- The system directory goes through 'canonicalAbsolutePath', as upstream's+-- @Settings@ constructor runs it through @canonPath@ (globals.cc:65): a+-- relative one is refused, and @.@ and @..@ in it collapse, so the file+-- is named canonically wherever it is reported and its includes resolve+-- against the collapsed directory. The user files are read as named;+-- upstream canonicalizes none of them.+-- @NIX_USER_CONF_FILES@ replaces the XDG list outright, in its own order.+-- It is split on the platform's list separator with empty entries dropped+-- as upstream's tokenizer drops them; upstream splits on @:@ on every+-- platform, which cannot carry a Windows drive letter, so @;@ is honoured+-- there as it is for @PATH@ and @XDG_CONFIG_DIRS@.+configFilePaths :: ConfigLocations -> Either Text [OsPath]+configFilePaths locations = do+ confDir <- traverse variablePath (mfilter (not . BS.null) (clConfDir locations))+ systemDir <- traverse canonicalAbsolutePath (confDir <|> clSystemConfDir locations)+ userFiles <- case clUserConfFiles locations of+ Just listed -> splitListVar <$> variablePath listed+ Nothing -> pure (map underXdgDir (clConfigHome locations : clConfigDirs locations))+ pure (maybeToList ((</> nixConfFileName) <$> systemDir) ++ reverse userFiles)+ where+ underXdgDir dir = dir </> nixConfDirName </> nixConfFileName+ variablePath bytes = maybe (Left (noHostSpelling bytes)) Right (hostPathFromBytes bytes)++-- | Split a path-list variable on the platform's list separator, dropping+-- empty entries.+splitListVar :: OsString -> [OsPath]+splitListVar = map OP.pack . filter (not . null) . splitOn . OP.unpack+ where+ splitOn units = case break (== OP.searchPathSeparator) units of+ (item, []) -> [item]+ (item, _ : rest) -> item : splitOn rest++-- | The file name every source directory is read under.+nixConfFileName :: OsPath+nixConfFileName = asciiPath "nix.conf"++-- | The directory @nix.conf@ sits in beneath a config directory: @nix@+-- under each XDG directory (@getUserConfigFiles@) and under @sysconfdir@+-- for the system file (libstore's meson.build at 2.24.9).+nixConfDirName :: OsPath+nixConfDirName = asciiPath "nix"++-- | A path this module spells itself. 'OP.unsafeFromChar' narrows a+-- character to the platform's path unit (a byte, or a UTF-16 unit), which+-- is exact for ASCII on both.+asciiPath :: String -> OsPath+asciiPath = OP.pack . map OP.unsafeFromChar++-- ---------------------------------------------------------------------------+-- Sources and includes+-- ---------------------------------------------------------------------------++-- | One source of config text and the name it is reported under: a file's+-- path, or 'nixConfigSourceName' for the environment variable. The name+-- also anchors relative includes, so a source that is not a file cannot+-- include relatively; upstream passes the literal @NIX_CONFIG@ as the+-- path and its @canonPath@ refuses the @.\/x@ that results.+data ConfigSource = ConfigSource+ { csName :: !OsPath,+ csText :: !ByteString+ }+ deriving (Eq, Show)++-- | The name upstream applies the @NIX_CONFIG@ text under.+nixConfigSourceName :: OsPath+nixConfigSourceName = asciiPath "NIX_CONFIG"++-- | Why a file could not be read: there is nothing at the path, or there+-- is and it cannot be read (a directory, a permission refusal). Upstream+-- tells the two apart with @pathExists@ before @readFile@; here the+-- distinction decides only how a failed @include@ is reported.+data ConfigReadFailure = ConfigFileMissing | ConfigFileUnreadable+ deriving (Eq, Show)++-- | How the expander reads a file: its bytes, or why it could not be+-- read. A function so the expander runs over an in-memory map in tests+-- and over the filesystem in the CLI.+type ReadConfigFile m = OsPath -> m (Either ConfigReadFailure ByteString)++-- | Read, expand and fold the whole configuration: the files at the given+-- paths, weakest first (one that cannot be read is simply absent, as+-- upstream's @applyConfigFile@ treats a @SystemError@), then the+-- @NIX_CONFIG@ bytes when the variable is set. A file that is read and+-- does not expand is an error, never treated as absent.+loadConfig :: (Monad m) => ReadConfigFile m -> [OsPath] -> Maybe ByteString -> m (Either Text NixConfig)+loadConfig readConfigFile paths nixConfigText = runExceptT $ do+ fileSources <- lift (rights <$> traverse readSource paths)+ let sources = fileSources ++ maybeToList (ConfigSource nixConfigSourceName <$> nixConfigText)+ assignments <- traverse (ExceptT . expandConfigSource readConfigFile) sources+ liftEither (resolveConfig (concat assignments))+ where+ readSource path = fmap (ConfigSource path) <$> readConfigFile path++-- | Expand one source into its assignments, every include spliced in at+-- its line, as upstream's @parseConfigFiles@ pushes an included file's+-- pairs into the same list. An include whose resolved path is already+-- being expanded is refused: upstream recurses until the stack overflows,+-- and no cycle can mean anything.+expandConfigSource :: (Monad m) => ReadConfigFile m -> ConfigSource -> m (Either Text [ConfigAssignment])+expandConfigSource readConfigFile = runExceptT . expandWithin readConfigFile []++-- | Expand one source beneath the names of the files including it.+expandWithin :: (Monad m) => ReadConfigFile m -> [OsPath] -> ConfigSource -> ExceptT Text m [ConfigAssignment]+expandWithin readConfigFile ancestors (ConfigSource name text) = do+ parsed <- liftEither (parseConfigLines name text)+ concat <$> traverse (expandLine readConfigFile (name :| ancestors)) parsed++-- | Expand one line of the file at the chain's head: an assignment is+-- itself; an include is the included file, expanded beneath the chain.+-- A target with no host spelling (bytes that are not UTF-8, on Windows)+-- names no file that can exist, so @!include@ goes on without it as it+-- does without a missing file.+expandLine :: (Monad m) => ReadConfigFile m -> NonEmpty OsPath -> ConfigLine -> ExceptT Text m [ConfigAssignment]+expandLine _ _ (LineAssignment assignment) = pure [assignment]+expandLine readConfigFile chain@(from :| _) (LineInclude mode target) =+ case (hostPathFromBytes target, mode) of+ (Nothing, IncludeOptional) -> pure []+ (Nothing, IncludeRequired) -> throwError (includeUnspellable target from)+ (Just targetPath, _) -> do+ path <- liftEither (resolveIncludePath from targetPath)+ when (path `elem` chain) (throwError (includeCycle path from))+ contents <- lift (readConfigFile path)+ case (contents, mode) of+ (Right text, _) -> expandWithin readConfigFile (NE.toList chain) (ConfigSource path text)+ (Left _, IncludeOptional) -> pure []+ (Left failure, IncludeRequired) -> throwError (includeFailed failure path from)++-- | Where an include points: the target joined under the including+-- file's directory (an absolute target stands alone), then collapsed+-- through 'canonicalAbsolutePath', as upstream's @absPath@ then+-- @canonPath@ do. A result that is still relative is refused: the+-- including source has no directory, which is the case for @NIX_CONFIG@+-- and for a user file named relatively.+resolveIncludePath :: OsPath -> OsPath -> Either Text OsPath+resolveIncludePath from target = canonicalAbsolutePath (OP.takeDirectory from </> target)++-- | An absolute native path collapsed lexically, or upstream's own+-- complaint (@canonPath@, file-system.cc at 2.24.9) for a relative one.+-- The drive 'OP.splitDrive' finds (the POSIX root, a Windows drive letter+-- or UNC server) is kept as spelled, and the path beneath it collapses as+-- upstream's @canonPath@ collapses ('collapseUnits'), from the drive's+-- last separator on, so a UNC root survives rather than folding to the+-- current drive.+canonicalAbsolutePath :: OsPath -> Either Text OsPath+canonicalAbsolutePath path+ | OP.isAbsolute path = Right (OP.pack (root ++ collapseUnits (lastSeparator ++ OP.unpack below)))+ | otherwise = Left (configErrorPrefix <> "not an absolute path: '" <> hostPathText path <> "'")+ where+ (drive, below) = OP.splitDrive path+ driveUnits = OP.unpack drive+ root = dropWhileEnd OP.isPathSeparator driveUnits+ lastSeparator = take 1 (takeWhile OP.isPathSeparator (reverse driveUnits))++-- | Upstream's lexical collapse, over a native path's units: @.@ drops,+-- @..@ pops a real predecessor (at a root it drops, in a relative path it+-- stays), repeated separators fold to one, and an empty result is @.@.+-- The same rule 'Nix.Eval.CanonPath.canonPath' applies to path values,+-- which are text; a native path here is bytes, which text cannot hold.+-- The separator is the platform's when the path spells one with it and+-- @/@ otherwise, as there.+collapseUnits :: [OsChar] -> [OsChar]+collapseUnits units+ | rooted = separator : joined+ | null resolved = [dotUnit]+ | otherwise = joined+ where+ rooted = any OP.isPathSeparator (take 1 units)+ segments = filter (not . null) (splitOnSeparators units)+ resolved = reverse (foldl' (collapseStep rooted) [] segments)+ separator = if OP.pathSeparator `elem` units then OP.pathSeparator else slashUnit+ joined = intercalate [separator] resolved++-- | One segment of the collapse fold; the accumulator holds resolved+-- segments in reverse.+collapseStep :: Bool -> [[OsChar]] -> [OsChar] -> [[OsChar]]+collapseStep rooted acc segment+ | segment == [dotUnit] = acc+ | segment == parent = case acc of+ [] -> [parent | not rooted]+ (top : rest)+ | top == parent -> parent : acc+ | otherwise -> rest+ | otherwise = segment : acc+ where+ parent = [dotUnit, dotUnit]++splitOnSeparators :: [OsChar] -> [[OsChar]]+splitOnSeparators units = case break OP.isPathSeparator units of+ (segment, []) -> [segment]+ (segment, _ : rest) -> segment : splitOnSeparators rest++dotUnit, slashUnit :: OsChar+dotUnit = OP.unsafeFromChar '.'+slashUnit = OP.unsafeFromChar '/'++includeFailed :: ConfigReadFailure -> OsPath -> OsPath -> Text+includeFailed failure path from =+ configErrorPrefix <> "file '" <> hostPathText path <> "' included from '" <> hostPathText from <> "' " <> reason+ where+ reason = case failure of+ ConfigFileMissing -> "not found"+ ConfigFileUnreadable -> "cannot be read"++includeCycle :: OsPath -> OsPath -> Text+includeCycle path from =+ configErrorPrefix <> "file '" <> hostPathText path <> "' included from '" <> hostPathText from <> "' is already being included (include cycle)"++includeUnspellable :: ByteString -> OsPath -> Text+includeUnspellable target from =+ configErrorPrefix <> "file '" <> shownBytes target <> "' included from '" <> hostPathText from <> "' " <> noWindowsSpelling++-- | The refusal of a path variable with no host spelling. Unreachable+-- while the environment reaches this module as "Nix.Environment" reads+-- it, which spells a Windows value as UTF-8.+noHostSpelling :: ByteString -> Text+noHostSpelling bytes = configErrorPrefix <> "path '" <> shownBytes bytes <> "' " <> noWindowsSpelling++noWindowsSpelling :: Text+noWindowsSpelling = "is not valid UTF-8, so it names no Windows file"++-- --------------------------------------------------------------------------- -- Parsing -- --------------------------------------------------------------------------- +-- | One line of a config source after comment truncation: an assignment,+-- or an include directive and the bytes of the path it names.+data ConfigLine+ = LineAssignment !ConfigAssignment+ | LineInclude !IncludeMode !ByteString+ deriving (Eq, Show)++-- | @include@ fails when its file cannot be read; @!include@ goes on+-- without it. That is the only difference between the two directives.+data IncludeMode = IncludeRequired | IncludeOptional+ deriving (Eq, Show)+ -- | One parsed @name = value@ assignment, before its name is resolved--- against the known settings and aliases.+-- against the known settings and aliases. The value is its tokens+-- joined by single spaces, as upstream joins them. data ConfigAssignment = ConfigAssignment- { caName :: !Text,- caValue :: !Text+ { caName :: !ByteString,+ caValue :: !ByteString } 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+-- | Parse one source's bytes into its lines. Comments and blank lines+-- drop out; a malformed line (fewer than @name = value@, a missing @=@,+-- or an include with other than exactly one path) 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. The error+-- quotes the line as upstream does, comment-truncated and otherwise+-- verbatim, leading whitespace included.+parseConfigLines :: OsPath -> ByteString -> Either Text [ConfigLine]+parseConfigLines name = fmap catMaybes . traverse (parseLine . BS8.takeWhile (/= commentChar)) . BS8.lines where- isBlank line = null (T.words line)- stripComment = T.takeWhile (/= '#')- parseLine line = case T.words line of+ parseLine line = case configTokens line of+ [] -> Right Nothing+ [directive, target]+ | directive == includeDirective -> Right (Just (LineInclude IncludeRequired target))+ | directive == bangIncludeDirective -> Right (Just (LineInclude IncludeOptional target)) (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 <> "'")+ | directive == includeDirective || directive == bangIncludeDirective -> syntaxError line+ (key : eq : valueTokens)+ | eq == assignEq -> Right (Just (LineAssignment (ConfigAssignment key (BS.intercalate valueSeparator valueTokens))))+ _ -> syntaxError line+ syntaxError line =+ Left (configErrorPrefix <> "syntax error in configuration line '" <> shownBytes line <> "' in '" <> hostPathText name <> "'") +-- | Upstream's @tokenizeString@ under its default separators (util.hh at+-- 2.24.9): the maximal runs of bytes other than space, tab, CR and LF.+configTokens :: ByteString -> [ByteString]+configTokens = filter (not . BS.null) . BS8.splitWith (`BS8.elem` tokenSeparators)++-- | Parse an unsigned integer setting the way upstream's+-- @BaseSetting\<unsigned int\>@ does (@string2IntWithUnitPrefix@ over+-- @boost::lexical_cast@, config-impl.hh and util.hh at 2.24.9): decimal+-- digits with an optional leading @+@, then an optional binary unit+-- letter, @K@, @M@, @G@ or @T@ in either case, multiplying by 1024 to+-- that power. Anything else (a minus sign, a fraction, hex, a number+-- past @unsigned int@) is upstream's @UsageError@, worded as it words it,+-- with no source label: upstream names the setting alone, and the value+-- may have come from @NIX_CONFIG@ rather than a file.+-- One divergence: upstream computes the unit product in 64 bits and+-- narrows it silently, so @5G@ is accepted there as 1073741824; here it+-- is refused like any other value outside the range, since a ceiling+-- that cannot mean what it says must not quietly become another number.+parseUnsignedSetting :: ByteString -> ByteString -> Either Text Word32+parseUnsignedSetting name value = maybe (Left invalid) Right (unitValue >>= inRange)+ where+ invalid = "setting '" <> shownBytes name <> "' has invalid value '" <> shownBytes value <> "'"+ unsigned = fromMaybe value (BS.stripPrefix plusSign value)+ unitValue = case BS8.unsnoc unsigned of+ Just (digits, unit) | Just multiplier <- unitMultiplier unit -> (* multiplier) <$> decimalInteger digits+ _ -> decimalInteger unsigned+ inRange n+ | n <= toInteger (maxBound :: Word32) = Just (fromInteger n)+ | otherwise = Nothing++-- | A whole run of decimal digits as an 'Integer'; anything else, the+-- empty run included, is 'Nothing'.+decimalInteger :: ByteString -> Maybe Integer+decimalInteger digits+ | not (BS.null digits) && BS8.all isDigit digits = Just (BS8.foldl' step 0 digits)+ | otherwise = Nothing+ where+ step acc digit = acc * 10 + toInteger (ord digit - ord '0')++-- | Upstream's binary unit letters.+unitMultiplier :: Char -> Maybe Integer+unitMultiplier unit = case toUpper unit of+ 'K' -> Just (unitBase ^ (1 :: Int))+ 'M' -> Just (unitBase ^ (2 :: Int))+ 'G' -> Just (unitBase ^ (3 :: Int))+ 'T' -> Just (unitBase ^ (4 :: Int))+ _ -> Nothing++unitBase :: Integer+unitBase = 1024+ -- --------------------------------------------------------------------------- -- Applying -- ---------------------------------------------------------------------------@@ -107,84 +534,173 @@ -- | 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+-- prefix (append rather than replace, list settings only). 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. A value that does+-- not parse for its setting is an error, as upstream's @UsageError@ is:+-- @pure-eval = ture@ must not pass for off.+applyAssignment :: NixConfig -> ConfigAssignment -> Either Text 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+ Nothing -> Right config+ Just (ListSetting field, mode) ->+ (\items -> setList field (combine mode (getList field config) items) config) <$> listItems field value+ Just (BoolSetting field, _) ->+ (\flag -> setBool field flag config) <$> parseBool name value+ Just (ScalarSetting MaxCallDepthField, _) ->+ (\limit -> config {ncMaxCallDepth = limit}) <$> parseUnsignedSetting name value 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)+-- | A list value's tokens as text, split as upstream's @Strings@ setting+-- splits it, or the refusal of a token that is not UTF-8 (see the module+-- header's Bytes section). The refusal names the setting as upstream's+-- value errors do, by its own name rather than an alias or an @extra-@+-- spelling.+listItems :: ListField -> ByteString -> Either Text [Text]+listItems field value = maybe (Left refusal) Right (traverse decoded (configTokens value)) where- tag mode mField = case mField of- Just field -> Just (field, mode)+ decoded = either (const Nothing) Just . TE.decodeUtf8'+ refusal = "setting '" <> shownBytes (listFieldKey field) <> "' has invalid value '" <> shownBytes value <> "': not valid UTF-8"++-- | Which setting a name targets, once the @extra-@ prefix and the aliases+-- are resolved, and whether it replaces or appends. @extra-@ composes+-- with a list only: upstream looks the base name up and appends when the+-- setting is appendable, and a Boolean or a scalar is not.+resolveName :: ByteString -> Maybe (Setting, ApplyMode)+resolveName name =+ case BS.stripPrefix extraPrefix name of+ Just base -> case baseSetting base of+ Just setting@(ListSetting _) -> Just (setting, AppendMode)+ _ -> Nothing+ Nothing -> case baseSetting name of+ Just setting -> Just (setting, ReplaceMode) Nothing -> Nothing- baseField n- | n == substitutersKey || n == substitutersAlias = Just SubstitutersField- | n == trustedKeysKey || n == trustedKeysAlias = Just TrustedKeysField+ where+ baseSetting n+ | n == substitutersKey || n == substitutersAlias = Just (ListSetting SubstitutersField)+ | n == trustedKeysKey || n == trustedKeysAlias = Just (ListSetting TrustedKeysField)+ | n == allowedUrisKey = Just (ListSetting AllowedUrisField)+ | n == restrictEvalKey = Just (BoolSetting RestrictEvalField)+ | n == pureEvalKey = Just (BoolSetting PureEvalField)+ | n == maxCallDepthKey = Just (ScalarSetting MaxCallDepthField) | otherwise = Nothing --- | The two list settings this layer resolves.-data ConfigField = SubstitutersField | TrustedKeysField+-- | A setting this layer resolves, by the shape of its value.+data Setting = ListSetting !ListField | BoolSetting !BoolField | ScalarSetting !ScalarField deriving (Eq, Show) +-- | The list settings.+data ListField = SubstitutersField | TrustedKeysField | AllowedUrisField+ deriving (Eq, Show)++-- | The Boolean settings.+data BoolField = RestrictEvalField | PureEvalField+ deriving (Eq, Show)++-- | The scalar settings, which only ever replace.+data ScalarField = MaxCallDepthField+ 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+getList :: ListField -> NixConfig -> [Text]+getList SubstitutersField = ncSubstituters+getList TrustedKeysField = ncTrustedPublicKeys+getList AllowedUrisField = ncAllowedUris -setField :: ConfigField -> [Text] -> NixConfig -> NixConfig-setField SubstitutersField v config = config {ncSubstituters = v}-setField TrustedKeysField v config = config {ncTrustedPublicKeys = v}+setList :: ListField -> [Text] -> NixConfig -> NixConfig+setList SubstitutersField v config = config {ncSubstituters = v}+setList TrustedKeysField v config = config {ncTrustedPublicKeys = v}+setList AllowedUrisField v config = config {ncAllowedUris = 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+listFieldKey :: ListField -> ByteString+listFieldKey SubstitutersField = substitutersKey+listFieldKey TrustedKeysField = trustedKeysKey+listFieldKey AllowedUrisField = allowedUrisKey --- | 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+setBool :: BoolField -> Bool -> NixConfig -> NixConfig+setBool RestrictEvalField v config = config {ncRestrictEval = v}+setBool PureEvalField v config = config {ncPureEval = v} +-- | Upstream's Boolean spellings (@BaseSetting\<bool\>::parse@): anything+-- else is an error with upstream's wording, which names the setting alone,+-- as 'parseUnsignedSetting' does.+parseBool :: ByteString -> ByteString -> Either Text Bool+parseBool name value+ | value `elem` trueSpellings = Right True+ | value `elem` falseSpellings = Right False+ | otherwise = Left ("Boolean setting '" <> shownBytes name <> "' has invalid value '" <> shownBytes value <> "'")++trueSpellings, falseSpellings :: [ByteString]+trueSpellings = ["true", "yes", "1"]+falseSpellings = ["false", "no", "0"]++-- | Fold expanded assignments onto the default config, weakest first.+-- The caller orders them so the strongest source's assignments are last+-- (and so win). A value a setting cannot take aborts the fold, as+-- upstream's @UsageError@ does.+resolveConfig :: [ConfigAssignment] -> Either Text NixConfig+resolveConfig = foldM applyAssignment defaultNixConfig+ -- ------------------------------------------------------------------------------ Setting names+-- Messages -- --------------------------------------------------------------------------- -substitutersKey, substitutersAlias :: Text+-- | Bytes from a source as a message quotes them, for display only: their+-- UTF-8 reading, with U+FFFD where it fails. Upstream writes the bytes+-- themselves; a message here is text.+shownBytes :: ByteString -> Text+shownBytes = TE.decodeUtf8Lenient++-- | What every error about the files themselves opens with (a syntax+-- error, a failed include, a path that is not absolute): the format's+-- name. A setting's value error carries none, as upstream's does not.+configErrorPrefix :: Text+configErrorPrefix = "nix.conf: "++-- ---------------------------------------------------------------------------+-- Setting names and syntax+-- ---------------------------------------------------------------------------++substitutersKey, substitutersAlias :: ByteString substitutersKey = "substituters" substitutersAlias = "binary-caches" -trustedKeysKey, trustedKeysAlias :: Text+trustedKeysKey, trustedKeysAlias :: ByteString trustedKeysKey = "trusted-public-keys" trustedKeysAlias = "binary-cache-public-keys" -extraPrefix :: Text+allowedUrisKey, maxCallDepthKey, pureEvalKey, restrictEvalKey :: ByteString+allowedUrisKey = "allowed-uris"+maxCallDepthKey = "max-call-depth"+pureEvalKey = "pure-eval"+restrictEvalKey = "restrict-eval"++extraPrefix :: ByteString extraPrefix = "extra-" -assignEq :: Text+assignEq :: ByteString assignEq = "=" -includeDirective, bangIncludeDirective :: Text+plusSign :: ByteString+plusSign = "+"++includeDirective, bangIncludeDirective :: ByteString includeDirective = "include" bangIncludeDirective = "!include"++commentChar :: Char+commentChar = '#'++tokenSeparators :: ByteString+tokenSeparators = " \t\n\r"++-- | What a value's tokens are joined with, as upstream's+-- @concatStringsSep(" ", ...)@ joins them.+valueSeparator :: ByteString+valueSeparator = " "
src/Nix/DependencyGraph.hs view
@@ -22,6 +22,7 @@ ) where +import Control.Monad.Except (ExceptT (..), runExceptT) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map import Data.Maybe (fromMaybe)@@ -62,23 +63,26 @@ -- | Build the full dependency graph starting from a root derivation. ----- The @readDrv@ function reads a .drv file from the store and parses it.--- Returns @Left@ if any .drv file cannot be read.+-- The @readDrv@ function reads a .drv file from the store and parses it,+-- in the caller's monad: 'IO' for the build driver,+-- 'Data.Functor.Identity.Identity' over a pure lookup. Returns @Left@+-- for the first .drv file that cannot be read. -- -- Uses a 'Seq' work-queue for O(1) enqueue\/dequeue instead of list append. buildDepGraph ::- (StorePath -> Either Text Derivation) ->+ (Monad m) =>+ (StorePath -> m (Either Text Derivation)) -> Derivation -> StorePath ->- Either Text DepGraph+ m (Either Text DepGraph) buildDepGraph readDrv rootDrv rootPath =- go Map.empty (Seq.singleton (rootPath, rootDrv))+ runExceptT (go Map.empty (Seq.singleton (rootPath, rootDrv))) where go visited queue = case Seq.viewl queue of- Seq.EmptyL -> Right (DepGraph visited)+ Seq.EmptyL -> pure (DepGraph visited) (sp, drv) Seq.:< rest | Map.member sp visited -> go visited rest- | otherwise ->+ | otherwise -> do let deps = Map.keys (drvInputDrvs drv) node = DepNode@@ -87,22 +91,20 @@ dnDeps = deps } visitedWithNode = Map.insert sp node visited- in case resolveNewDeps readDrv visitedWithNode deps of- Left err -> Left err- Right newItems -> go visitedWithNode (foldl' (|>) rest newItems)+ newItems <- resolveNewDeps readDrv visitedWithNode deps+ go visitedWithNode (foldl' (|>) rest newItems) -- | Resolve unvisited dependencies by reading their .drv files. resolveNewDeps ::- (StorePath -> Either Text Derivation) ->+ (Monad m) =>+ (StorePath -> m (Either Text Derivation)) -> Map StorePath DepNode -> [StorePath] ->- Either Text [(StorePath, Derivation)]+ ExceptT Text m [(StorePath, Derivation)] resolveNewDeps readDrv visited = traverse resolve . filter unvisited where unvisited dep = not (Map.member dep visited)- resolve dep = case readDrv dep of- Left err -> Left err- Right drv -> Right (dep, drv)+ resolve dep = (,) dep <$> ExceptT (readDrv dep) -- --------------------------------------------------------------------------- -- Topological sort (Kahn's algorithm)
src/Nix/Derivation.hs view
@@ -61,6 +61,7 @@ -- * Platform Platform (..), currentPlatform,+ extraPlatforms, platformToText, textToPlatform, )@@ -91,6 +92,8 @@ | Aarch64_Darwin | X86_64_Windows | Aarch64_Linux+ | I686_Linux+ | I686_Windows | OtherPlatform !Text deriving (Eq, Ord, Show) @@ -104,8 +107,23 @@ ("aarch64", "darwin") -> Aarch64_Darwin ("aarch64", "linux") -> Aarch64_Linux ("x86_64", "linux") -> X86_64_Linux+ ("i386", "linux") -> I686_Linux+ ("i386", "mingw32") -> I686_Windows (arch, os) -> OtherPlatform (packPlatform arch os) +-- | The systems a host executes besides its own, as upstream's default+-- @extra-platforms@ lists them: an x86_64 Linux kernel runs i686 binaries,+-- so upstream adds @i686-linux@ on @x86_64-linux@ (outside WSL1, which+-- cannot), and 64-bit Windows runs i686 binaries through WoW64, the same+-- rule for the platform upstream has no entry for yet. Upstream also adds+-- @x86_64-darwin@ on @aarch64-darwin@ when Rosetta is installed; that is a+-- filesystem probe, not a property of the platform, and is not modelled+-- here, so such a build needs a launcher named on the command line.+extraPlatforms :: Platform -> [Platform]+extraPlatforms X86_64_Linux = [I686_Linux]+extraPlatforms X86_64_Windows = [I686_Windows]+extraPlatforms _ = []+ -- | Format an arch-os pair as a Nix platform string. packPlatform :: String -> String -> Text packPlatform arch os =@@ -127,6 +145,8 @@ platformToText Aarch64_Darwin = "aarch64-darwin" platformToText X86_64_Windows = "x86_64-windows" platformToText Aarch64_Linux = "aarch64-linux"+platformToText I686_Linux = "i686-linux"+platformToText I686_Windows = "i686-windows" platformToText (OtherPlatform t) = t -- | Parse a Nix platform string into a 'Platform'.@@ -137,6 +157,8 @@ "aarch64-darwin" -> Aarch64_Darwin "x86_64-windows" -> X86_64_Windows "aarch64-linux" -> Aarch64_Linux+ "i686-linux" -> I686_Linux+ "i686-windows" -> I686_Windows other -> OtherPlatform other -- | A single output of a derivation.
+ src/Nix/Derivation/StructuredAttrs.hs view
@@ -0,0 +1,177 @@+-- | Structured attributes: a derivation that passes its attributes to the+-- builder as one JSON object rather than one environment variable each.+--+-- == The two halves+--+-- Evaluation (@__structuredAttrs = true@) renders the attributes into a+-- single object and stores it in the derivation's environment under+-- 'structuredAttrsKey', so attributes may be nested sets and lists where+-- the environment would need strings. The @.drv@ hashes that entry, so+-- its bytes are upstream's ('Nix.Json.renderJson').+--+-- Building reads the entry back, replaces @outputs@ with an object naming+-- each output's path, and hands the builder two files instead of the+-- attributes themselves: @.attrs.json@, the object as JSON, and+-- @.attrs.sh@, the part of it bash can hold as @declare@ statements+-- ('renderAttrsShell'). A derivation is built this way when its+-- environment holds the entry, the test upstream's @ParsedDerivation@+-- makes (@src\/libstore\/parsed-derivations.cc@ at 2.24.9).+module Nix.Derivation.StructuredAttrs+ ( -- * The environment entry+ structuredAttrsKey,+ StructuredAttrs (..),+ encodeStructuredAttrs,+ decodeStructuredAttrs,++ -- * What the builder reads+ withOutputPlaceholders,+ renderAttrsJson,+ renderAttrsShell,+ stringsAttr,+ )+where++import Data.ByteString (ByteString)+import Data.Char (isAsciiLower, isAsciiUpper, isDigit)+import Data.Int (Int32)+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Maybe (mapMaybe)+import Data.Text (Text)+import qualified Data.Text as T+import GHC.Float (double2Float)+import Nix.Hash (hashPlaceholder)+import Nix.Json (Json (..), parseJson, renderJson, renderJsonWith)++-- | The environment entry holding a structured-attributes derivation's+-- attributes.+structuredAttrsKey :: Text+structuredAttrsKey = "__json"++-- | The member upstream fills with the output paths before the builder+-- runs, replacing whatever the attributes held there (the list of output+-- names, when the derivation declared one).+outputsMember :: Text+outputsMember = "outputs"++-- | A derivation's attributes, by name.+newtype StructuredAttrs = StructuredAttrs (Map Text Json)+ deriving (Eq, Show)++-- | The bytes of the 'structuredAttrsKey' entry.+encodeStructuredAttrs :: StructuredAttrs -> ByteString+encodeStructuredAttrs (StructuredAttrs members) = renderJson (JsonObject members)++-- | Read the 'structuredAttrsKey' entry back. It must hold an object, or+-- @null@, which nlohmann turns into an empty object the moment upstream+-- assigns @outputs@ into it; any other value has no member to assign.+decodeStructuredAttrs :: Text -> Either Text StructuredAttrs+decodeStructuredAttrs encoded = do+ value <- parseJson encoded+ case value of+ JsonObject members -> Right (StructuredAttrs members)+ JsonNull -> Right (StructuredAttrs Map.empty)+ _ -> Left "the attributes are not a JSON object"++-- | Set @outputs@ to an object naming each output's placeholder, as+-- upstream's @prepareStructuredAttrs@ does; the build rewrites each+-- placeholder to the path the output is written to, along with every+-- other placeholder the attributes hold.+withOutputPlaceholders :: [Text] -> StructuredAttrs -> StructuredAttrs+withOutputPlaceholders outputNames (StructuredAttrs members) =+ StructuredAttrs (Map.insert outputsMember outputs members)+ where+ outputs = JsonObject (Map.fromList [(name, JsonString (hashPlaceholder name)) | name <- outputNames])++-- | The @.attrs.json@ a builder reads: the attributes as JSON, every+-- string passed through the build's rewrite (see 'Nix.Json.renderJsonWith'+-- for why before escaping rather than after rendering).+renderAttrsJson :: (Text -> Text) -> StructuredAttrs -> ByteString+renderAttrsJson rewrite (StructuredAttrs members) = renderJsonWith rewrite (JsonObject members)++-- | Upstream's @writeStructuredAttrsShell@: one @declare@ per attribute+-- whose name is a shell variable name and whose value bash can hold. A+-- scalar becomes a variable, an array of scalars an indexed array, an+-- object of scalars an associative array; anything else, an array or+-- object holding a nested one or a non-integral number included, is+-- left out. Arrays and objects keep the space upstream writes after+-- each element.+renderAttrsShell :: StructuredAttrs -> Text+renderAttrsShell (StructuredAttrs members) = T.concat (mapMaybe declaration (Map.toAscList members))+ where+ declaration (name, value)+ | not (isShellVarName name) = Nothing+ | Just scalar <- shellScalar value = Just ("declare " <> name <> "=" <> scalar <> "\n")+ | JsonArray items <- value,+ Just scalars <- traverse shellScalar items =+ Just ("declare -a " <> name <> "=(" <> T.concat (map (<> " ") scalars) <> ")\n")+ | JsonObject entries <- value,+ Just pairs <- traverse shellEntry (Map.toAscList entries) =+ Just ("declare -A " <> name <> "=(" <> T.concat pairs <> ")\n")+ | otherwise = Nothing+ shellEntry (key, value) = (\scalar -> "[" <> shellQuote key <> "]=" <> scalar <> " ") <$> shellScalar value++-- | upstream's @std::regex@ @[A-Za-z_][A-Za-z0-9_]*@, matched against the+-- whole name.+isShellVarName :: Text -> Bool+isShellVarName name = case T.uncons name of+ Just (first, rest) -> (isAsciiLetter first || first == '_') && T.all (\c -> isAsciiLetter c || isDigit c || c == '_') rest+ Nothing -> False+ where+ isAsciiLetter c = isAsciiUpper c || isAsciiLower c++-- | upstream's @handleSimpleType@: a string single-quoted, an integral+-- number in decimal, @null@ as an empty quoted string, a boolean as @1@ or+-- nothing.+--+-- Upstream tests a number for integrality as a C @float@ and prints it+-- after @static_cast<int>@, so an integer keeps its low 32 bits and a float+-- that is integral at single precision truncates toward zero. A float+-- past @int@'s range is undefined behaviour in C++; the value here is what+-- an aarch64 build of upstream writes for it (saturation, observed with+-- Nix 2.33.2 on aarch64-darwin), where x86-64 writes @INT_MIN@ for every+-- such value.+shellScalar :: Json -> Maybe Text+shellScalar (JsonString s) = Just (shellQuote s)+shellScalar (JsonInt n) = Just (T.pack (show (fromInteger n :: Int32)))+shellScalar (JsonFloat d)+ | isIntegralAsFloat (double2Float d) = Just (T.pack (show (truncateToInt32 d)))+ | otherwise = Nothing+shellScalar JsonNull = Just "''"+shellScalar (JsonBool True) = Just "1"+shellScalar (JsonBool False) = Just ""+shellScalar (JsonArray _) = Nothing+shellScalar (JsonObject _) = Nothing++-- | C's @ceil(f) == f@: true of an infinity, false of NaN.+isIntegralAsFloat :: Float -> Bool+isIntegralAsFloat f+ | isNaN f = False+ | isInfinite f = True+ | otherwise = fromInteger (ceiling f) == f++-- | @static_cast<int>@ of a double as aarch64 performs it: toward zero,+-- saturating at the ends of the range, NaN to zero.+truncateToInt32 :: Double -> Int32+truncateToInt32 d+ | isNaN d = 0+ | d >= fromIntegral (maxBound :: Int32) = maxBound+ | d <= fromIntegral (minBound :: Int32) = minBound+ | otherwise = fromInteger (truncate d)++-- | Upstream's @shellEscape@: single quotes around the whole, each single+-- quote inside closed, escaped and reopened.+shellQuote :: Text -> Text+shellQuote s = "'" <> T.replace "'" "'\\''" s <> "'"++-- | A list-of-strings attribute (upstream's @getStringsAttr@ in structured+-- mode): absent is 'Nothing', and anything but a list of strings is an+-- error rather than a guess.+stringsAttr :: Text -> StructuredAttrs -> Either Text (Maybe [Text])+stringsAttr name (StructuredAttrs members) = case Map.lookup name members of+ Nothing -> Right Nothing+ Just (JsonArray items) | Just strings <- traverse asString items -> Right (Just strings)+ Just _ -> Left ("attribute '" <> name <> "' must be a list of strings")+ where+ asString (JsonString s) = Just s+ asString _ = Nothing
+ src/Nix/Environment.hs view
@@ -0,0 +1,67 @@+{-# LANGUAGE CPP #-}++-- | The process environment, read the way a Nix string holds it: as bytes.+--+-- Upstream reads a variable with @getenv@ and keeps the bytes it returns+-- (@getEnv@ in src\/libutil\/environment-variables.cc at Nix 2.24.9), so+-- a value reaches @builtins.getEnv@ and @builtins.nixPath@ unchanged+-- whatever its encoding. On POSIX this module makes the same call.+-- 'System.Environment.lookupEnv' does not: it decodes the value with the+-- file-system encoding, which carries an undecodable byte as a lone+-- surrogate that 'Data.Text.pack' then replaces with U+FFFD, and it+-- encodes the name with the foreign encoding, which drops a character+-- the locale cannot spell instead of failing.+--+-- A Windows environment is UTF-16. A value becomes the UTF-8 bytes a+-- Nix string carries, and a name is read as UTF-8. An unpaired+-- surrogate has no UTF-8 form, so a value holding one is refused rather+-- than approximated, the rule the NAR serialiser applies to such a file+-- name.+module Nix.Environment+ ( EnvLookup (..),+ lookupEnvBytes,+ )+where++import Data.ByteString (ByteString)+#if defined(mingw32_HOST_OS)+import Data.Char (GeneralCategory (Surrogate), generalCategory)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import System.Environment (lookupEnv)+#else+import qualified System.Posix.Env.ByteString as PosixEnv+#endif++-- | What the environment holds under one name.+data EnvLookup+ = -- | No variable has the name.+ EnvUnset+ | -- | The value, as the bytes a Nix string carries.+ EnvValue !ByteString+ | -- | A Windows value holding an unpaired UTF-16 surrogate, which has no+ -- UTF-8 form. Never the answer on POSIX, where a value is bytes.+ EnvUnpairedSurrogate+ deriving (Eq, Show)++-- | Look up a variable by the bytes of its name. Nothing is caught: the+-- read cannot fail other than by finding nothing, since @getenv@ answers+-- NULL for a missing name and base's 'System.Environment.lookupEnv'+-- answers 'Nothing' whenever @GetEnvironmentVariableW@ finds no value.+lookupEnvBytes :: ByteString -> IO EnvLookup+#if defined(mingw32_HOST_OS)+lookupEnvBytes name = case TE.decodeUtf8' name of+ -- A Windows name is UTF-16, so bytes with no UTF-8 reading name no+ -- variable.+ Left _ -> pure EnvUnset+ Right decoded -> maybe EnvUnset utf8Value <$> lookupEnv (T.unpack decoded)+ where+ -- base decodes a surrogate pair into one character and keeps an+ -- unpaired surrogate as its own code point, which 'T.pack' would+ -- replace with U+FFFD.+ utf8Value value+ | any ((== Surrogate) . generalCategory) value = EnvUnpairedSurrogate+ | otherwise = EnvValue (TE.encodeUtf8 (T.pack value))+#else+lookupEnvBytes name = maybe EnvUnset EnvValue <$> PosixEnv.getEnv name+#endif
src/Nix/Eval.hs view
@@ -12,5413 +12,5698 @@ -- 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 '<' = "<"- escapeChar '>' = ">"- escapeChar '&' = "&"- escapeChar '"' = """- 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)+--+-- Thunks, environments and values live in the C data layer, so+-- everything here that builds or forces one runs between+-- 'Nix.Eval.Arena.arenaInit' and 'Nix.Eval.Arena.arenaDestroy'+-- (@bracket_ arenaInit arenaDestroy@). Outside that window the first+-- C call raises 'Nix.Eval.CStatus.ArenaNotInitialized' instead of+-- faulting.+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 (..),+ EvalPolicy (..),+ PathExistence (..),+ unrestrictedPolicy,+ PureEval,+ runPureEval,++ -- * Evaluation+ eval,+ evalBytecode,+ force,++ -- * Helpers (for Builtins)+ typeName,+ evaluated,+ readThunkValue,+ deferApply,++ -- * Fetcher transport validation (pure, exported for tests)+ FetchGitArgs (..),+ checkGitUrl,+ checkGitRef,+ checkGitRev,+ gitInputUrl,++ -- * Remembered fetches (pure, exported for tests)+ FetchCache (..),+ fetchCacheKey,+ encodeFetchCache,+ decodeFetchCache,++ -- * Builtin registry+ BuiltinDef (..),+ builtinRegistry,+ builtinNames,++ -- * Platform+ currentSystemStr,+ )+where++import Control.Monad (foldM, 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.Builder as BB+import qualified Data.ByteString.Char8 as BC+import qualified Data.ByteString.Lazy as BL+import Data.Char (chr, digitToInt, intToDigit, isAsciiLower, isAsciiUpper, isDigit, isHexDigit, isOctDigit, ord, toUpper)+import Data.IORef (IORef, atomicModifyIORef', newIORef)+import Data.Int (Int64)+import Data.List (partition, sort, sortOn)+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 Data.Text.Encoding.Error (lenientDecode)+import qualified Data.Text.Read as TR+import Data.Time.Clock.POSIX (posixSecondsToUTCTime)+import Data.Time.Format (defaultTimeLocale, formatTime)+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.Derivation.StructuredAttrs (StructuredAttrs (..), encodeStructuredAttrs, structuredAttrsKey)+import Nix.Eval.CBytecode (appDeferred, cbcArg1, cbcArg2, cbcArg3, cbcCountedPayload, cbcData, cbcFlags, cbcOpcode, cbcShortArg, reservedApply1, reservedApply2, strpartEsc, strpartLit, 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, clistDrop, clistIndex)+import Nix.Eval.CThunk (CThunkPtr)+import Nix.Eval.CanonPath (canonBaseName, canonDirName, canonPathValue)+import Nix.Eval.Compile (BcAttrKey (..), BcBinding (..), BcInheritedName (..), compileExpr, decodeBcBindings, decodeBcCaptureInfo, decodeBcFormals, reassembleDouble, reassembleInt64)+import Nix.Eval.Context (contextIsEmpty, extractAllOutputRefs, extractInputDrvs, extractInputSrcs, firstContextEncoded, plainContext)+import Nix.Eval.Operator (addToFloat, addToInteger, evalBinary, evalUnary, evalUpdate, expectInt, nixCompare, nixEqual, primAdd, primDiv, primMul, primSub)+import Nix.Eval.Policy (isAbsolutePath)+import Nix.Eval.Print (PrintOptions (..), formatXmlFloat, printValue)+import Nix.Eval.StringInterp (StringChunk (..), coerceToString, concatChunks, formatNixFloat, stripIndentedChunks)+import Nix.Eval.Symbol (Symbol (..), symbolBytes, symbolText)+import Nix.Eval.Types+ ( AttrSet (..),+ CAttrSet,+ CompiledRegex (..),+ Env (..),+ EvalFormal (..),+ EvalFormals (..),+ EvalPolicy (..),+ MonadEval (..),+ NixValue (..),+ PathExistence (..),+ 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,+ mkThunkBc,+ newCEnv,+ newMinimalEnv,+ readThunkValue,+ runPureEval,+ storePathOrThrow,+ thunkToCPtr,+ typeName,+ typeOfValue,+ unrestrictedPolicy,+ withScopesForCapture,+ )+import Nix.Expr.Resolve (undefinedVariableMessage)+import Nix.Expr.Types+ ( BinaryOp (..),+ CaptureInfo (..),+ Expr (..),+ UnaryOp (..),+ )+import Nix.Hash (base64HashLen, bytesToHexText, hashAlgoBytes, hashPlaceholder, hexHashLen, hexToBytes, makeFixedOutputPath, makeOutputPath, makeTextPath, nix32HashLen, sha256Digest)+import Nix.Json (IntegerRange (..), Json (..), parseJsonWith, renderJson)+import Nix.Store.Path (StorePath (spName), StorePathNameError (..), checkStorePathName, defaultStoreDir, defaultStoreDirText, parseStorePath, parseStorePathBaseName, storePathBaseNameError, storePathNameErrorText, storePathNameReasonText, storePathNameUpstreamText, 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)+ unop = decodeUnaryOp flags_+ operate = evalBytecode env operandIdx >>= evalUnary unop+ in case unop of+ -- Upstream's parser turns @-e@ into @__sub 0 e@, a call+ -- frame around the operand's forcing.+ OpNegate -> withCallFrame operate+ OpNot -> operate+ 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)++-- | Whether upstream's parser desugars the operator into a builtin call+-- (@__sub@, @__mul@, @__div@ or @__lessThan@; parser.y at 2.24.9), which+-- then costs a call frame while its operands are forced. The others are+-- @Expr@ nodes of their own there and cost nothing.+desugaredToCall :: BinaryOp -> Bool+desugaredToCall op = case op of+ OpSub -> True+ OpMul -> True+ OpDiv -> True+ OpLt -> True+ OpLte -> True+ OpGt -> True+ OpGte -> True+ OpAdd -> False+ OpAnd -> False+ OpOr -> False+ OpImpl -> False+ OpEq -> False+ OpNeq -> False+ OpConcat -> False+ OpUpdate -> False++-- | Evaluate a binary operation from bytecode, with short-circuit+-- support for &&, ||, ->.+evalBcBinary :: (MonadEval m) => Env -> Word32 -> m NixValue+evalBcBinary env bcIdx0 = case op of+ OpAnd -> evalShortCircuitAnd env leftIdx rightIdx+ OpOr -> evalShortCircuitOr env leftIdx rightIdx+ OpImpl -> evalShortCircuitImpl env leftIdx rightIdx+ OpAdd -> evalAddition (evalBytecode env leftIdx) (evalBytecode env rightIdx)+ -- Not through 'operate', which forces both operands before either is+ -- looked at: the left must be checked before the right is evaluated.+ OpUpdate -> evalUpdate (evalBytecode env leftIdx) (evalBytecode env rightIdx)+ _+ | desugaredToCall op -> withCallFrame operate+ | otherwise -> operate+ where+ flags_ = unsafePerformIO (cbcFlags bcIdx0)+ leftIdx = unsafePerformIO (cbcArg1 bcIdx0)+ rightIdx = unsafePerformIO (cbcArg2 bcIdx0)+ op = decodeBinaryOp flags_+ operate = do+ leftVal <- evalBytecode env leftIdx+ rightVal <- evalBytecode env rightIdx+ evalBinary force op leftVal rightVal++-- | @+@, as upstream's @ExprConcatStrings::eval@ runs it over two operands+-- (eval.cc at 2.24.9, the same in 2.33.2). The left operand is evaluated+-- and dealt with before the right is evaluated, and its type alone decides+-- what @+@ means: an integer or float makes it arithmetic, a path makes the+-- result a path, and anything else makes it string concatenation, each+-- operand coerced strictly (no numbers, Booleans, null or lists), with+-- upstream's message for a value that does not coerce. So @{} + throw+-- "b"@ fails on the set without evaluating the throw, and @true + 1@ is a+-- coercion failure, not a refused addition.+--+-- Only a string on the left copies a path into the store, as upstream+-- copies only when the first operand is a string: @"x" + ./src@ carries+-- the source's store path in its context, while @{ outPath = ./src; } ++-- "x"@ is the path's own text with no context. A path on the left+-- appends the right operand's text without a copy, and a right operand+-- carrying string context is refused, since a path value cannot keep the+-- reference. Paths are Text here, so the appended bytes must decode.+evalAddition :: (MonadEval m) => m NixValue -> m NixValue -> m NixValue+evalAddition evalLeft evalRight =+ evalLeft >>= \case+ VInt a -> evalRight >>= addToInteger a+ VFloat a -> evalRight >>= addToFloat a+ VPath base -> do+ (appended, context) <- evalRight >>= coerceToString False force applyValue coercePathVerbatim+ unless (contextIsEmpty context) $+ throwEvalError "a string that refers to a store path cannot be appended to a path"+ text <- decodedText "path concatenation" appended+ pure (VPath (canonPathValue (base <> text)))+ left -> do+ let coercePath = case left of+ VStr {} -> coercePathToStore+ _ -> coercePathVerbatim+ coerce = coerceToString False force applyValue coercePath+ (leftStr, leftCtx) <- coerce left+ (rightStr, rightCtx) <- evalRight >>= coerce+ pure (VStr (leftStr <> rightStr) (leftCtx <> rightCtx))++-- | 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. A string with no+-- interpolation is one literal part, since the lexer joins a run of+-- characters and escapes into one chunk, and its value is that part's+-- bytes: upstream's parser makes it an @ExprString@ holding the value+-- (parser.y), where an interpolated string is an @ExprConcatStrings@.+evalBcStr :: (MonadEval m) => Env -> Word32 -> m NixValue+evalBcStr env bcIdx0 =+ case unsafePerformIO (cbcCountedPayload bcIdx0 =<< cbcArg1 bcIdx0) of+ (1, dataOff)+ | unsafePerformIO (cbcData dataOff) == strpartLit ->+ pure (VStr (symbolBytes (Symbol (unsafePerformIO (cbcData (dataOff + 1))))) emptyContext)+ (count, dataOff) -> do+ chunks <- evalBcStringParts env count dataOff+ let (text, ctx) = concatChunks chunks+ pure (VStr text ctx)++-- | Evaluate an indented string literal from bytecode data buffer. The+-- common indentation is stripped before concatenation, part by part as+-- upstream strips it, so an interpolated multi-line value cannot drag the+-- computed indent down.+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)+ parts <- evalBcPathParts env count dataOff+ unless (all (contextIsEmpty . snd) parts) $+ throwEvalError "a string that refers to a store path cannot be appended to a path"+ text <- decodedText "path literal" (BS.concat (map fst parts))+ 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, and a path contributes its text+-- WITHOUT a store copy (unlike string interpolation, which copies). The+-- context comes back with each piece, because a string carrying it is+-- refused only once every piece has coerced: a path value has nowhere to+-- keep the context that holds a store reference alive, and upstream's+-- @ExprConcatStrings::eval@ checks for one after its loop, so a later+-- piece that fails to coerce is the error reported. Observed from+-- nix-instantiate 2.33.2.+evalBcPathParts :: (MonadEval m) => Env -> Int -> Word32 -> m [(BS.ByteString, StringContext)]+evalBcPathParts _ 0 _ = pure []+evalBcPathParts env n off = do+ let tag = unsafePerformIO (cbcData off)+ val = unsafePerformIO (cbcData (off + 1))+ part <- case tag of+ 0 -> pure (symbolBytes (Symbol val), emptyContext)+ _ -> evalBytecode env val >>= coerceToString False force applyValue coercePathVerbatim+ rest <- evalBcPathParts env (n - 1) (off + 2)+ pure (part : rest)++-- | Evaluate string parts from the bytecode data buffer. Each part is two+-- words: (tag, value), a symbol under 'strpartLit' or 'strpartEsc' and a+-- bytecode index under 'strpartInterp'. The kind survives into the chunk+-- because an indented string strips an escape and an interpolation+-- differently.+evalBcStringParts :: (MonadEval m) => Env -> Int -> Word32 -> m [StringChunk]+evalBcStringParts _ 0 _ = pure []+evalBcStringParts env n off = do+ let tag = unsafePerformIO (cbcData off)+ val = unsafePerformIO (cbcData (off + 1))+ chunk <- decodeChunk tag val+ rest <- evalBcStringParts env (n - 1) (off + 2)+ pure (chunk : rest)+ where+ decodeChunk tag val+ | tag == strpartLit = pure (ChunkLit (symbolBytes (Symbol val)))+ | tag == strpartEsc = pure (ChunkEsc (symbolBytes (Symbol val)))+ | otherwise = do+ v <- evalBytecode env val+ (txt, ctx) <- coerceToStringInterp v+ pure (ChunkInterp txt ctx)++-- | 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. A source application+-- ('EApp') evaluates the function position outside the call frame and+-- everything after it inside, as upstream's @ExprCall::eval@ evaluates+-- @fun@ before @callFunction@ opens the frame. A builtin's deferred+-- application, one of the reserved instructions in @cbits\/nn_bytecode.c@,+-- opens the frame first and forces the function inside it, as upstream's+-- @forceValue@ on an app value is a @callFunction@ that forces the+-- function after the depth check.+evalBcApp :: (MonadEval m) => Env -> Word32 -> m NixValue+evalBcApp env bcIdx0+ | flags_ == appDeferred = withCallFrame (evalBytecode env funcIdx >>= callBc env argIdx)+ | otherwise = evalBytecode env funcIdx >>= withCallFrame . callBc env argIdx+ where+ flags_ = unsafePerformIO (cbcFlags bcIdx0)+ funcIdx = unsafePerformIO (cbcArg1 bcIdx0)+ argIdx = unsafePerformIO (cbcArg2 bcIdx0)++-- | Apply an evaluated function to the argument at a bytecode index,+-- inside the caller's frame. The argument stays a thunk for a lambda+-- (the normal laziness) and is forced for a builtin, which forces anyway.+callBc :: (MonadEval m) => Env -> Word32 -> NixValue -> m NixValue+callBc env argIdx funcVal = 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 [first]+ | Just finishFirst <- beforeSecondArgument name first -> finishFirst >> 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+ -- Upstream applies the functor to itself and to the argument in+ -- one nested callFunction, so both bodies run a frame below this+ -- one: 'applyValue' opens that frame for the self application,+ -- and the recursion opens it again for the argument.+ partiallyApplied <- applyValue functor funcVal+ withCallFrame (callBc env argIdx partiallyApplied)+ _ -> 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 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, 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 env staticBs+ let scopeFilled = fillCAttrSetValues scopeCset staticThunks+ dynThunks <- scopeFilled `seq` buildBcThunkMap mkThunkBc recEnv env 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 dynamic key, which the parser refuses in a let as+ -- upstream's parser.y does, so only an AST built outside the parser+ -- reaches here. 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 env bindings+ let filled = fillCAttrSetValues cset thunkMap+ in filled `seq` evalBytecode letEnv bodyIdx++-- | Check if all bytecode bindings are 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 _ _) = acc + 1+ countOne !acc (BcInheritFrom _ names) = acc + length names+ countOne !acc _ = acc++-- | Build thunks for positional bytecode bindings in declaration order.+-- An inherited variable is read from the enclosing env, as upstream's+-- @ExprAttrs::eval@ and @ExprLet::eval@ evaluate an Inherited attribute+-- there through @maybeThunk@ (eval.cc at 2.24.9); that env is fully+-- built, so the variable's own thunk can be shared now.+buildBcSlotThunks :: Env -> Env -> [BcBinding] -> [Thunk]+buildBcSlotThunks recEnv outerEnv = concatMap slotThunk+ where+ slotThunk (BcNamed (BcStaticKey _) valBcIdx) =+ [mkThunkBc recEnv valBcIdx]+ slotThunk (BcInherit _ valBcIdx) =+ [cheapThunkBc outerEnv valBcIdx]+ slotThunk (BcInheritFrom fromBcIdx names) =+ -- 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 (BcInheritedName _ selectBcIdx) =+ let (sp, sc) = buildCSlots [fromThunk]+ fromEnv = newMinimalEnv sp sc+ in mkSyntheticThunk fromEnv selectBcIdx+ in map mkInheritThunk names+ -- 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 sym _ : bs) (t : ts) !acc =+ go bs ts (Map.insert (symbolText (Symbol sym)) t acc)+ go (BcInheritFrom _ names : bs) ts !acc =+ let (used, rest) = splitAt (length names) ts+ accMerged = foldl' (\a (name, t0) -> Map.insert (inheritedNameText name) t0 a) acc (zip names 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. An inherited variable is read+-- from @outerEnv@, the env around the binding set, which is always built+-- (for a non-recursive set it is @thunkEnv@ itself).+buildBcThunkMap :: (MonadEval m) => (Env -> Word32 -> Thunk) -> Env -> Env -> [BcBinding] -> m (Map Text Thunk)+buildBcThunkMap mkValueThunk thunkEnv outerEnv = foldM addBinding Map.empty+ where+ addBinding acc (BcNamed bcKey valBcIdx) = do+ resolved <- resolveBcKey thunkEnv bcKey+ case resolved of+ Nothing -> pure acc -- null key -> skip+ Just key -> insertChecked acc key (mkValueThunk thunkEnv valBcIdx)+ addBinding acc (BcInherit sym valBcIdx) =+ insertChecked acc (symbolText (Symbol sym)) (cheapThunkBc outerEnv valBcIdx)+ addBinding acc (BcInheritFrom fromBcIdx names) =+ -- inherit (from) name selects name from the from-expr.+ -- Create a small env with the from value at slot 0, then run the+ -- select the compiler made for name against it.+ let addInheritFrom a inherited@(BcInheritedName _ selectBcIdx) =+ let (sp, sc) = buildCSlots [mkThunkBc thunkEnv fromBcIdx]+ fromEnv = newMinimalEnv sp sc+ in insertChecked a (inheritedNameText inherited) (mkSyntheticThunk fromEnv selectBcIdx)+ in foldM addInheritFrom acc names++ -- 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)++-- | 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 sym _) = [symbolText (Symbol sym)]+ oneBinding (BcInheritFrom _ names) = map inheritedNameText names++-- | The attribute name an @inherit (from)@ binds.+inheritedNameText :: BcInheritedName -> Text+inheritedNameText (BcInheritedName sym _) = symbolText (Symbol sym)++-- | 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 -> searchPathMiss name+ _ -> searchPathMiss name++-- | A lookup that matched no entry. A CATCHABLE error (upstream raises+-- ThrownError here): nixpkgs' impure.nix wraps @<nixpkgs-overlays>@ in+-- @tryEval@ and relies on catching the miss. The wording is upstream's+-- @findFile@: under @pure-eval@ the search path is empty by construction,+-- and the message says so instead of pointing at @NIX_PATH@.+searchPathMiss :: (MonadEval m) => Text -> m a+searchPathMiss name = do+ policy <- evalPolicy+ throwCatchableError $+ if epPureEval policy+ then "cannot look up '<" <> name <> ">' in pure evaluation mode (use '--impure' to override)"+ else "file '" <> name <> "' was not found in the Nix search path (add it using $NIX_PATH or -I)"++-- ---------------------------------------------------------------------------+-- Variables+-- ---------------------------------------------------------------------------++evalVar :: (MonadEval m) => Env -> Text -> m NixValue+evalVar env name =+ case envLookup name env of+ Just thunk -> force thunk+ Nothing -> throwEvalError (undefinedVariableMessage 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 (checkedCPtr "nn_env_push_with" =<< 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+ -- A knot, and its laziness is load-bearing: formalEnv is built from+ -- the slots, and a default's thunk in the slots holds formalEnv, the+ -- env a default is evaluated in (it may name another formal or the+ -- @-pattern), as upstream's callFunction allocates env2 before it+ -- fills the values (eval.cc). 'mkThunkBc' keeps that env unforced+ -- behind a StablePtr; 'cheapThunkBc' would look a variable default+ -- up in formalEnv while formalEnv is being built, and fail <<loop>>.+ 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++-- ---------------------------------------------------------------------------+-- 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" primAdd,+ builtin2 "sub" primSub,+ builtin2 "mul" primMul,+ builtin2 "div" primDiv,+ builtin2 "bitAnd" (bitwise (.&.)),+ builtin2 "bitOr" (bitwise (.|.)),+ builtin2 "bitXor" (bitwise xor),+ -- 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. The lazy 'derivation' wrapper is not a+ -- primop: it is upstream's derivation.nix, bound as a constant by+ -- "Nix.Builtins".+ 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, in a call+-- frame of its own. Used by higher-order builtins to invoke+-- user-supplied functions.+applyValue :: (MonadEval m) => NixValue -> NixValue -> m NixValue+applyValue fun arg = withCallFrame (applyForced fun arg)++-- | 'applyValue' inside the caller's frame.+applyForced :: (MonadEval m) => NixValue -> NixValue -> m NixValue+applyForced (VLambda closureEnv formals bodyBcIdx) arg = do+ extEnv <- matchFormals closureEnv formals (evaluated arg)+ evalBytecode extEnv bodyBcIdx+applyForced (VBuiltin name accArgs) arg =+ applyBuiltin name accArgs arg+applyForced 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 fun argThunk = withCallFrame (applyUnforced fun argThunk)++-- | 'applyValueLazy' inside the caller's frame.+applyUnforced :: (MonadEval m) => NixValue -> Thunk -> m NixValue+applyUnforced (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.+applyUnforced (VBuiltin "tryEval" []) argThunk = tryEvalAction (force argThunk)+applyUnforced (VBuiltin name [first]) argThunk+ | Just finishFirst <- beforeSecondArgument name first = finishFirst >> force argThunk+applyUnforced other argThunk = do+ val <- force argThunk+ applyForced 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 primAdd+ "sub" -> apply2 primSub+ "mul" -> apply2 primMul+ "div" -> apply2 primDiv+ "bitAnd" -> apply2 (bitwise (.&.))+ "bitOr" -> apply2 (bitwise (.|.))+ "bitXor" -> apply2 (bitwise xor)+ -- 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+ "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+-- ---------------------------------------------------------------------------++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 (expectedList other)++-- | 2.24.9's @prim_head@ is its @elemAt@ at index 0, so an empty list+-- fails as an index past the end does. From 2.26.0 upstream says+-- @'builtins.head' called on an empty list@ instead.+builtinHead :: (MonadEval m) => NixValue -> m NixValue+builtinHead (VList cl) = listElemAt cl 0+builtinHead other = throwEvalError (expectedList other)++-- | The result shares the argument's C array where upstream's+-- @prim_tail@ copies the elements after the first: a copy costs time and+-- arena memory in the list's length on every call, and sharing cannot be+-- observed because a list is never written after construction. The+-- empty-list message is 2.24.9's; from 2.26.0 upstream says+-- @'builtins.tail' called on an empty list@.+builtinTail :: (MonadEval m) => NixValue -> m NixValue+builtinTail (VList cl)+ | clistLen cl == 0 = throwEvalError "'tail' called on an empty list"+ | otherwise = pure (VList (clistDrop 1 cl))+builtinTail other = throwEvalError (expectedList other)++-- | Upstream's @forceList@ failure (eval-inline.hh at 2.24.9).+expectedList :: NixValue -> Text+expectedList other =+ "expected a list but found " <> typeName other <> ": " <> printValue PrintForError other++-- | The element at an index, failing as the @elemAt@ helper behind+-- @prim_elemAt@ and @prim_head@ does (primops.cc at 2.24.9). That helper+-- takes the index as a C @int@, so an index past 32 bits wraps there+-- before its bounds check; the whole index is checked here, as 2.33.2+-- checks it. 2.26.0 replaced the message with+-- @'builtins.elemAt' called with index N on a list of size M@.+listElemAt :: (MonadEval m) => CList -> Int64 -> m NixValue+listElemAt cl idx =+ maybe (throwEvalError ("list index " <> T.pack (show idx) <> " is out of bounds")) (force . Thunk) (clistIndex cl (fromIntegral idx))++-- ---------------------------------------------------------------------------+-- 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 value = VInt . fromIntegral . BS.length . fst <$> coerceToStringInterp value++-- ---------------------------------------------------------------------------+-- 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.+ -- The index is an argument value, as upstream's @prim_genList@+ -- allocates an int and an app per element, so every element runs+ -- the same code.+ let fnThunk = evaluated func+ mkIndexThunk = thunkToCPtr . deferApplyThunk fnThunk . evaluated . VInt+ in pure (VList (clistFromThunks (map 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 (sortComparison comparator) vals+ pure (VList (clistFromThunks (map (thunkToCPtr . evaluated) sorted)))+builtinSort _ other =+ throwEvalError ("builtins.sort: expected a list, got " <> typeName other)++-- | The comparison a sort runs between two elements. A bare+-- @builtins.lessThan@ compares directly, as upstream's @prim_sort@+-- bypasses @callFunction@ for it (primops.cc at 2.24.9) and so opens no+-- call frame; any other comparator is applied as a function, one frame+-- per application.+sortComparison :: (MonadEval m) => NixValue -> NixValue -> NixValue -> m NixValue+sortComparison (VBuiltin "lessThan" []) a b = builtinLessThan a b+sortComparison cmp a b = applyValue cmp a >>= (`applyValue` b)++-- | Stable O(n log n) merge sort under a comparison answering whether its+-- first argument is strictly less than its second.+mergeSort :: (MonadEval m) => (NixValue -> NixValue -> m NixValue) -> [NixValue] -> m [NixValue]+mergeSort _ [] = pure []+mergeSort _ [x] = pure [x]+mergeSort isLess xs = do+ let half = length xs `div` 2+ (left, right) = splitAt half xs+ sortedLeft <- mergeSort isLess left+ sortedRight <- mergeSort isLess right+ mergeSorted isLess sortedLeft sortedRight++mergeSorted :: (MonadEval m) => (NixValue -> NixValue -> m NixValue) -> [NixValue] -> [NixValue] -> m [NixValue]+mergeSorted _ [] ys = pure ys+mergeSorted _ xs [] = pure xs+mergeSorted isLess (x : xs) (y : ys) = do+ -- Stable merge: take the right element only when it is STRICTLY less than the+ -- left (@isLess 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 @isLess x y@ instead would emit @y@ on+ -- a tie and reverse equal runs.)+ result <- isLess y x+ case result of+ VBool True -> (y :) <$> mergeSorted isLess (x : xs) ys+ VBool False -> (x :) <$> mergeSorted isLess 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++-- | The index is checked before the list: @prim_elemAt@ forces it with+-- @forceInt@ (eval.cc) before its @elemAt@ forces the list.+builtinElemAt :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinElemAt (VList cl) (VInt idx) = listElemAt cl idx+builtinElemAt other (VInt _) = throwEvalError (expectedList other)+builtinElemAt _ other =+ throwEvalError ("expected an integer but found " <> typeName other <> ": " <> printValue PrintForError 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) value+ | start < 0 = throwEvalError "builtins.substring: negative start position"+ | otherwise = do+ (s, ctx) <- coerceToStringInterp value+ 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+ -- Context is preserved through substring (matching real Nix).+ pure (VStr (BS.take clampedLen (BS.drop startPos s)) ctx)+builtinSubstring _ _ _ =+ throwEvalError "builtins.substring: start and length must be integers"++-- ---------------------------------------------------------------------------+-- Builtin helpers+-- ---------------------------------------------------------------------------++-- | Build a thunk that defers @f arg@ - the application only happens when+-- the thunk is forced. Runs the reserved 'reservedApply1' in a+-- self-contained env, upstream's @mkApp@. Slot 0 = function, slot 1 =+-- argument.+deferApply :: NixValue -> Thunk -> Thunk+deferApply func = deferApplyThunk (evaluated func)++-- | 'deferApply' with the function already a thunk, so a builtin that+-- makes one application per element shares one function thunk across+-- them, as upstream's primops share @args[0]@.+deferApplyThunk :: Thunk -> Thunk -> Thunk+deferApplyThunk fnThunk argThunk =+ let (sp, sc) = buildCSlots [fnThunk, argThunk]+ env = newMinimalEnv sp sc+ in mkSyntheticThunk env reservedApply1++-- | 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)++-- | @builtins.toString@'s coercion: permissive, a path verbatim+-- (upstream's @coerceMore@ without @copyToStore@).+coerceToStringPermissive :: (MonadEval m) => NixValue -> m (BS.ByteString, StringContext)+coerceToStringPermissive = coerceToString True force applyValue coercePathVerbatim++-- | Coerce a value to a string for a DERIVATION field (an env value or an+-- arg). Like 'coerceToStringPermissive', but a path 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 = coerceToString True force applyValue coercePathToStore++-- | Upstream's @coerceToString@ at its default arguments (@coerceMore =+-- false@, @copyToStore = true@): what string interpolation (@"${...}"@) and+-- every builtin taking a string that calls it without overrides use. Like+-- 'coerceToString', but a path literal is copied into the store and replaced+-- by its source store path (with context), 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 value = mkStrBytes . fst <$> coerceToStringInterp value++-- | @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 value = do+ (s, StringContext ctx) <- coerceToStringInterp value+ pure (VStr s (StringContext (Set.map downgrade ctx)))+ where+ downgrade (SCAllOutputs sp) = SCPlain sp+ downgrade other = 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 value = do+ (s, StringContext ctx) <- coerceToStringInterp value+ 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+ )++-- | 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 (VPath p) = pure (mkStr (canonBaseName p))+-- Anything else is coerced to a string without a copy, as upstream's+-- prim_baseNameOf coerces it (primops.cc at 2.24.9): an outPath or a+-- __toString answers, an integer does not.+builtinBaseNameOf other = do+ (s, ctx) <- coerceToString False force applyValue coercePathVerbatim other+ pure (VStr (lastComponentBytes s) ctx)++-- | 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 (VPath p) = pure (VPath (canonDirName p))+-- Only a path value stays a path (prim_dirOf); anything else is coerced+-- to a string without a copy, so a set whose outPath is a path gives a+-- string.+builtinDirOf other = do+ (s, ctx) <- coerceToString False force applyValue coercePathVerbatim other+ pure (VStr (dirComponentBytes s) ctx)++-- | 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+-- ---------------------------------------------------------------------------++-- | @prim_bitAnd@ and its kin (primops.cc at 2.24.9): both operands go+-- through @forceInt@, the left first.+bitwise :: (MonadEval m) => (Int64 -> Int64 -> Int64) -> NixValue -> NixValue -> m NixValue+bitwise op a b = VInt <$> (op <$> expectInt a <*> expectInt b)++-- ---------------------------------------------------------------------------+-- 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+ fnThunk = evaluated func+ deferAttr key valThunk =+ let (sp, sc) = buildCSlots [fnThunk, evaluated (mkStr key), valThunk]+ env = newMinimalEnv sp sc+ in mkSyntheticThunk env reservedApply2+builtinMapAttrs _ other =+ throwEvalError ("builtins.mapAttrs: expected a set, got " <> typeName other)++-- | 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 _ = throwEvalError "'functionArgs' requires a function"++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 (evaluated 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 fnThunk (key, thunkList) =+ let valueList = VList (clistFromThunks (map thunkToCPtr thunkList))+ (slots, slotCount) = buildCSlots [fnThunk, evaluated (mkStr key), evaluated valueList]+ env = newMinimalEnv slots slotCount+ in (key, mkSyntheticThunk env reservedApply2)+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 (renderJson json) ctx)++-- | The JSON of a value, with the context of every string it holds.+-- Strings decode strictly: upstream's serializer (nlohmann) REJECTS+-- invalid UTF-8, so invalid bytes are an eval error, matching upstream.+-- 'Nix.Json.renderJson' writes the result; @builtins.toJSON@ and a+-- structured-attributes derivation's @__json@ share both halves, as+-- upstream's share @printValueAsJSON@.+valueToJSON :: (MonadEval m) => NixValue -> m (Json, StringContext)+valueToJSON VNull = pure (JsonNull, emptyContext)+valueToJSON (VBool b) = pure (JsonBool b, emptyContext)+valueToJSON (VInt n) = pure (JsonInt (toInteger n), emptyContext)+valueToJSON (VFloat f) = pure (JsonFloat f, emptyContext)+valueToJSON (VStr s ctx) = do+ decoded <- decodedText "builtins.toJSON" s+ pure (JsonString decoded, ctx)+valueToJSON (VList cl) = do+ let thunks = map Thunk (clistThunks cl)+ vals <- mapM force thunks+ results <- mapM valueToJSON vals+ pure (JsonArray (map fst results), mconcat (map snd results))+-- An attrset is upstream's three-way choice (value-to-json.cc at 2.24.9):+-- a __toString result coerced as tryAttrsToString is called there, with+-- coerceMore and copyToStore both off, so the result must already be a+-- string or a path and a path stays its own absolute text; otherwise the+-- outPath value, converted as any value is, so it need not be a string;+-- otherwise an object.+valueToJSON (VAttrs attrs)+ | Just _ <- attrSetLookup "__toString" attrs = do+ (s, ctx) <- coerceToString False force applyValue coercePathVerbatim (VAttrs attrs)+ decoded <- decodedText "builtins.toJSON" s+ pure (JsonString decoded, ctx)+ | Just outPathThunk <- attrSetLookup "outPath" attrs = force outPathThunk >>= valueToJSON+ | otherwise = do+ results <- mapM jsonMember (Map.toAscList (attrSetToMap attrs))+ pure (JsonObject (Map.fromAscList [(key, json) | (key, json, _) <- results]), mconcat [ctx | (_, _, ctx) <- results])+ where+ jsonMember (key, thunk) = do+ val <- force thunk+ (json, ctx) <- valueToJSON val+ pure (key, json, 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 (JsonString 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 (VCompiledRegex _) = throwEvalError "builtins.toJSON: cannot convert a function to JSON"++-- | Upstream reads the string with nlohmann's parser (json-to-value.cc),+-- which is what 'parseJsonWith' implements for the builder's @__json@ too:+-- strict RFC 8259, so a trailing comma, a raw control character or a lone+-- surrogate is refused. 'NixRange' keeps every integer a 'VInt' can hold.+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+ either throwEvalError (pure . jsonToValue) (parseJsonWith NixRange decoded)+builtinFromJSON other =+ throwEvalError ("builtins.fromJSON: expected a string, got " <> typeName other)++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++jsonToValue :: Json -> NixValue+jsonToValue json = case json of+ JsonNull -> VNull+ JsonBool b -> VBool b+ JsonInt n -> VInt (fromInteger n)+ JsonFloat d -> VFloat d+ JsonString str -> mkStr str+ JsonArray items -> VList (clistFromThunks (map (thunkToCPtr . evaluated . jsonToValue) items))+ JsonObject members -> VAttrs (attrSetFromMap (Map.map (evaluated . jsonToValue) members))++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 = deepForce first >> pure second++-- | What a builtin returning its second argument does with its first+-- before it forces that second: @prim_deepSeq@ forces the first deeply,+-- @prim_trace@ prints it and @prim_warn@ warns with it, and only then does+-- each force the second (primops.cc at 2.24.9). A builtin's arguments+-- are forced before it runs, so an application that has the second still+-- unforced does this first.+beforeSecondArgument :: (MonadEval m) => Text -> NixValue -> Maybe (m ())+beforeSecondArgument name first = case name of+ "deepSeq" -> Just (deepForce first)+ "trace" -> Just (emitTrace first)+ "warn" -> Just (emitWarning first)+ _ -> Nothing++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 = emitTrace msgVal >> pure result++emitTrace :: (MonadEval m) => NixValue -> m ()+emitTrace msgVal = traceMessage . ("trace: " <>) $ case msgVal of+ VStr s _ -> bytesToTextLossy s+ other -> printValue PrintInFull other++-- | @builtins.warn msg val@ - print warning to stderr, return @val@.+--+-- The message must be a string: upstream's @prim_warn@ forces it with+-- @forceString@ (primops.cc at 2.24.9), leaving room to extend the+-- argument later, where @builtins.trace@ prints any value.+builtinWarn :: (MonadEval m) => NixValue -> NixValue -> m NixValue+builtinWarn msgVal result = emitWarning msgVal >> pure result++-- | Upstream logs the message as a warning from an expression, which its+-- logger prefixes @evaluation warning:@.+emitWarning :: (MonadEval m) => NixValue -> m ()+emitWarning msgVal = case msgVal of+ VStr s _ -> traceMessage ("evaluation warning: " <> bytesToTextLossy s)+ other ->+ throwEvalError ("expected a string but found " <> typeName other <> ": " <> printValue PrintForError 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)+-- ---------------------------------------------------------------------------++-- | Upstream's @coerceToPath@ (eval.cc at 2.24.9), which every builtin+-- taking a path runs its argument through: a path is itself, an attribute+-- set's @__toString@ result is coerced again (it may be a path), and any+-- other value is coerced to a string without a copy, which must spell an+-- absolute path. The string's bytes name a filesystem path, so they+-- decode strictly, the label naming the builtin if they cannot. The+-- context the coercion gathers comes back with the path.+coerceToPathWithContext :: (MonadEval m) => Text -> NixValue -> m (Text, StringContext)+coerceToPathWithContext _ (VPath p) = pure (p, emptyContext)+coerceToPathWithContext label val@(VAttrs attrs)+ | Just toStringThunk <- attrSetLookup "__toString" attrs = do+ toStringFn <- force toStringThunk+ coerceToPathWithContext label =<< applyValue toStringFn val+coerceToPathWithContext label val = do+ (bytes, ctx) <- coerceToString False force applyValue coercePathVerbatim val+ path <- absolutePathString =<< decodedText label bytes+ pure (path, ctx)++-- | 'coerceToPathWithContext' for a builtin that reads the path and keeps+-- no string.+coerceToPath :: (MonadEval m) => Text -> NixValue -> m Text+coerceToPath label val = fst <$> coerceToPathWithContext label val++-- | Upstream @coerceToPath@'s rule for a string operand: it must spell an+-- absolute path, which is then canonicalized like any path value+-- (@rootPath(CanonPath(path))@). A relative string is refused with+-- upstream's wording rather than read against the working directory,+-- which is also what keeps the access policy sound: the path checked is+-- the path opened.+absolutePathString :: (MonadEval m) => Text -> m Text+absolutePathString s+ | isAbsolutePath canonical = pure canonical+ | otherwise = throwEvalError ("string '" <> s <> "' doesn't represent an absolute path")+ where+ canonical = canonPathValue s++builtinImport :: (MonadEval m) => NixValue -> m NixValue+builtinImport val = importFile =<< coerceToPath "import" val++builtinReadFile :: (MonadEval m) => NixValue -> m NixValue+builtinReadFile val = do+ p <- coerceToPath "builtins.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)++-- | @builtins.pathExists@. Whether a directory is demanded is read off+-- the argument's spelling before coercion, as upstream's+-- @prim_pathExists@ reads it: a string ending in @/@ or @/.@, which+-- canonicalization would otherwise erase.+builtinPathExists :: (MonadEval m) => NixValue -> m NixValue+builtinPathExists val = do+ p <- coerceToPath "builtins.pathExists" val+ VBool <$> doesPathExist (existenceQuery val) p+ where+ existenceQuery (VStr s _)+ | BS.isSuffixOf "/" s || BS.isSuffixOf "/." s = ExistsAsDirectory+ existenceQuery _ = ExistsAsEntry++builtinReadDir :: (MonadEval m) => NixValue -> m NixValue+builtinReadDir val = do+ p <- coerceToPath "builtins.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 _) = mkStrBytes <$> getEnvVar name+builtinGetEnv other =+ throwEvalError ("builtins.getEnv: expected a string, got " <> typeName other)++-- | Upstream's @prim_toPath@ returns the coerced path as a string, with+-- its context, not as a path value (primops.cc at 2.24.9).+builtinToPath :: (MonadEval m) => NixValue -> m NixValue+builtinToPath val = do+ (path, ctx) <- coerceToPathWithContext "builtins.toPath" val+ pure (VStr (TE.encodeUtf8 path) ctx)++-- ---------------------------------------------------------------------------+-- 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)++-- | Refused outright under @pure-eval@, before the argument is looked at,+-- as upstream's @prim_storePath@ is: a dependency on a store path that+-- merely exists is ambient state.+builtinStorePath :: (MonadEval m) => NixValue -> m NixValue+builtinStorePath val = do+ policy <- evalPolicy+ if epPureEval policy+ then throwEvalError "'builtins.storePath' is not allowed in pure evaluation mode"+ else validateStorePath =<< resolvedUnlessStorePath =<< coerceToPath "builtins.storePath" val+ where+ -- prim_storePath resolves the path's symlinks unless the path is itself+ -- a store path, a link directly in the store (primops.cc:1613 at+ -- 2.24.9), so a link outside the store into it names its target.+ resolvedUnlessStorePath path+ | isBareStorePath path = pure path+ | otherwise = resolveSymlinks path+ isBareStorePath path = case T.stripPrefix storeDirPrefix path of+ Just base -> not (T.any (== '/') base) && isJust (parseStorePathBaseName base)+ Nothing -> False++-- | @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.+--+-- A path outside the store is upstream's @isInStore@ refusal; one inside+-- it whose component is no store path is its @StorePath@ constructor's+-- ('storePathBaseNameError').+validateStorePath :: (MonadEval m) => Text -> m NixValue+validateStorePath p = case T.stripPrefix storeDirPrefix p of+ Just rest+ | not (T.null rest) -> case parseStorePathBaseName base of+ Just sp -> pure (VStr (TE.encodeUtf8 p) (plainContext sp))+ Nothing -> throwEvalError (fromMaybe ("store path '" <> base <> "' has no '-' after its hash") (storePathBaseNameError base))+ where+ base = T.takeWhile (/= '/') rest+ _ -> throwEvalError ("path '" <> p <> "' is not in the Nix store")++-- | 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+ entries <- mapM (forceSearchEntry . Thunk) (clistThunks cl)+ 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, as the bytes+-- the entry holds. Upstream keeps both as byte strings, so an entry+-- whose prefix or path has no UTF-8 reading is still an entry, and one+-- that names something else does not stop the lookup.+forceSearchEntry :: (MonadEval m) => Thunk -> m (BS.ByteString, BS.ByteString)+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 (TE.encodeUtf8 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+-- 'searchPathMiss'. The existence probe is the policy-gated one: a+-- candidate the policy refuses counts as absent, which is stricter than+-- the oracle (2.24.9 probes outside its accessor and would hand the path+-- back) and quieter than 2.33.2 (which refuses from here), and which+-- gives no caller an existence oracle over paths it may not read.+--+-- A matching entry's candidate becomes a path value, which is text, so+-- one with no UTF-8 reading is an error where upstream would probe the+-- bytes; a lossy spelling would probe a different file.+findFirst :: (MonadEval m) => [(BS.ByteString, BS.ByteString)] -> BS.ByteString -> m NixValue+findFirst [] name = searchPathMiss (bytesToTextLossy name)+findFirst ((prefix, path) : rest) name = case searchPathSuffix prefix name of+ Nothing -> findFirst rest name+ Just suffix -> do+ candidate <- canonPathValue <$> decodedText "builtins.findFile" (if BS.null suffix then path else path <> "/" <> suffix)+ exists <- doesPathExist ExistsAsEntry candidate+ if exists+ then pure (VPath candidate)+ else findFirst rest name++-- | The part of a looked-up name below a search path prefix that names+-- it, compared byte for byte: upstream's+-- @LookupPath::Prefix::suffixIfPotentialMatch@ (src/libexpr/search-path.cc+-- at Nix 2.24.9). An empty prefix names every lookup.+searchPathSuffix :: BS.ByteString -> BS.ByteString -> Maybe BS.ByteString+searchPathSuffix prefix name+ | BS.null prefix = Just name+ | prefix == name = Just BS.empty+ | otherwise = BS.stripPrefix (prefix <> "/") 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 "builtins.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+ checkUri url+ requireFetchPin "fetchTarball" mSha256+ 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+ inputUrl = gitInputUrl args {fgaUrl = url, fgaRef = checkedRef, fgaRev = checkedRev}+ -- Upstream's order (fetchTree.cc): the pure-mode lock check, then+ -- the URI gate, before anything is fetched or remembered. A rev+ -- is what locks a git input at 2.24.9 (GitInputScheme::isLocked).+ policy <- evalPolicy+ when (epPureEval policy && isNothing checkedRev) $+ throwEvalError ("in pure evaluation mode, 'fetchGit' will not fetch unlocked input '" <> inputUrl <> "'")+ checkUri inputUrl+ -- 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 URL upstream renders a @fetchGit@ input as+-- (@fetchers::Input::toURLString@ at 2.24.9): the url after @fixGitURL@+-- (an scp-like @user\@host:path@ becomes @ssh://user\@host/path@, a bare+-- path becomes @file://@ plus the path), its scheme prefixed @git+@ unless+-- it is @git@ already, and the query carrying the input's attributes in+-- key order as @ParsedURL::to_string@ sorts them: @exportIgnore=1@ (which+-- @fetchGit@ sets unless submodules are on), @ref@, @rev@, @shallow=1@,+-- @submodules=1@. This is the string @checkURI@ matches @allowed-uris@+-- against and the one the pure-mode refusal names, so an operator's+-- prefix has to be written for this form (@git+https://...@), as it does+-- upstream.+gitInputUrl :: FetchGitArgs -> Text+gitInputUrl args =+ let fixed = fixGitUrl (fgaUrl args)+ (scheme, afterScheme) = T.breakOn ":" fixed+ (authority, afterAuthority) = case T.stripPrefix "://" afterScheme of+ Just rest -> let (host, more) = T.break (\c -> c == '/' || c == '?' || c == '#') rest in (Just host, more)+ Nothing -> (Nothing, T.drop 1 afterScheme)+ (pathPart, afterPath) = T.break (\c -> c == '?' || c == '#') afterAuthority+ (queryPart, fragmentPart) = case T.uncons afterPath of+ Just ('?', rest) -> T.break (== '#') rest+ _ -> ("", afterPath)+ urlQuery = Map.fromList (map queryPair (filter (not . T.null) (T.splitOn "&" queryPart)))+ queryPair pair =+ let (k, v) = T.breakOn "=" pair+ in (percentDecode k, percentDecode (T.drop 1 v))+ pins =+ catMaybes+ [ ("rev",) . T.toLower <$> fgaRev args,+ ("ref",) <$> fgaRef args,+ ("shallow", "1") <$ guardFlag (fgaShallow args),+ ("submodules", "1") <$ guardFlag (fgaSubmodules args),+ ("exportIgnore", "1") <$ guardFlag (not (fgaSubmodules args))+ ]+ query = Map.union (Map.fromList pins) urlQuery+ renderedScheme = if scheme == gitScheme then scheme else "git+" <> scheme+ renderedQuery+ | Map.null query = ""+ | otherwise = "?" <> T.intercalate "&" [percentEncode queryKeep k <> "=" <> percentEncode queryKeep v | (k, v) <- Map.toAscList query]+ renderedFragment = case T.stripPrefix "#" fragmentPart of+ Just fragment | not (T.null fragment) -> "#" <> percentEncode "" (percentDecode fragment)+ _ -> ""+ in renderedScheme+ <> ":"+ <> maybe "" ("//" <>) authority+ <> percentEncode pathKeep (percentDecode pathPart)+ <> renderedQuery+ <> renderedFragment+ where+ guardFlag flag = if flag then Just () else Nothing++-- | The one scheme upstream leaves without a @git+@ prefix.+gitScheme :: Text+gitScheme = "git"++-- | Characters @ParsedURL::to_string@ leaves unencoded in a path and in a+-- query (@allowedInPath@ and @allowedInQuery@ in url.cc).+pathKeep, queryKeep :: Text+pathKeep = ":@/"+queryKeep = ":@/?"++-- | Upstream @fixGitURL@ (url.cc at 2.24.9): an scp-like remote not+-- starting with @/@ (@([^\/]*)\@(.*):(.*)@, the first group as long as a+-- match allows, the second greedy) is rewritten to @ssh://@; a @file:@ URL+-- and anything with @://@ pass through; a bare path becomes a @file://@+-- URL.+fixGitUrl :: Text -> Text+fixGitUrl url+ | not (T.isPrefixOf "/" url),+ Just (user, hostAndPath) <- scpSplit url =+ let (host, path) = T.breakOnEnd ":" hostAndPath+ in "ssh://" <> user <> "@" <> T.dropEnd 1 host <> "/" <> path+ | T.isPrefixOf "file:" url = url+ | not (T.isInfixOf "://" url) = "file://" <> url+ | otherwise = url+ where+ -- The longest slash-free prefix ending at an '@' whose remainder still+ -- carries a ':' is what the regex's backtracking selects.+ scpSplit u =+ let slashFree = T.takeWhile (/= '/') u+ candidates = [i | (i, c) <- zip [0 ..] (T.unpack slashFree), c == '@']+ viable i = T.any (== ':') (T.drop (i + 1) u)+ in case reverse (filter viable candidates) of+ (i : _) -> Just (T.take i u, T.drop (i + 1) u)+ [] -> Nothing++-- | @percentEncode@ from url.cc: unreserved characters and the kept set+-- pass, every other byte of the UTF-8 form becomes @%XX@.+percentEncode :: Text -> Text -> Text+percentEncode keep = T.concat . map encodeByte . BS.unpack . TE.encodeUtf8+ where+ encodeByte byte+ | unreserved byte || T.any (== chr (fromIntegral byte)) keep = T.singleton (chr (fromIntegral byte))+ | otherwise = T.pack ('%' : hexPair byte)+ unreserved byte =+ let c = chr (fromIntegral byte)+ in isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` unreservedPunctuation+ hexPair byte = map (toUpper . intToDigit) [fromIntegral byte `div` hexBase, fromIntegral byte `mod` hexBase]++-- | The unreserved punctuation of RFC 3986, kept as is.+unreservedPunctuation :: [Char]+unreservedPunctuation = "-._~"++hexBase :: Int+hexBase = 16++-- | @percentDecode@ from url.cc, over the UTF-8 bytes. A malformed escape+-- is kept literally; upstream throws BadURL there, which this renderer+-- has no reason to reproduce for an error message's sake.+percentDecode :: Text -> Text+percentDecode = TE.decodeUtf8With lenientDecode . BS.pack . go . BS.unpack . TE.encodeUtf8+ where+ go (percent : hi : lo : rest)+ | percent == percentByte,+ isHexDigit (chr (fromIntegral hi)),+ isHexDigit (chr (fromIntegral lo)) =+ fromIntegral (digitToInt (chr (fromIntegral hi)) * hexBase + digitToInt (chr (fromIntegral lo))) : go rest+ go (byte : rest) = byte : go rest+ go [] = []+ percentByte = fromIntegral (ord '%')++-- | 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)++-- | Fetch a URL and optionally verify its hash.+fetchUrlSimple :: (MonadEval m) => Text -> Maybe Text -> m NixValue+fetchUrlSimple url mSha256 = do+ -- Upstream's order (fetchTree.cc, fetch()): the URI gate, then the+ -- name, then pure mode's hash requirement, all before the download.+ checkUri url+ -- 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 ()+ requireFetchPin "fetchurl" mSha256+ -- 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. The file lives in an exclusively+ -- created scratch directory ('createScratchDir'), removed once read.+ scratch <- createScratchDir "nova-nix-fetchurl-"+ bytes <- downloadInto scratch url `onEvalError` removeScratchDir scratch+ removeScratchDir scratch+ mapM_ (verifyFetchPin "builtins.fetchurl" url bytes) mSha256+ storePath <- addFixedOutputFile name bytes+ pure (VPath storePath)++-- | Fetch a URL into the scratch directory and hand back its bytes.+downloadInto :: (MonadEval m) => Text -> Text -> m BS.ByteString+downloadInto scratch url = do+ let tmpFile = scratch <> "/" <> downloadFileName+ (code, _, stderr) <- runProcess "curl" ["-sSfL", "-o", tmpFile, "--", url] ""+ if code /= 0+ then throwEvalError ("builtins.fetchurl: fetch failed: " <> stderr)+ else readFileBytes tmpFile++-- | The downloaded file's name inside its scratch directory.+downloadFileName :: Text+downloadFileName = "download"++-- | Upstream's pure-mode rule for @fetchurl@ and @fetchTarball@+-- (fetchTree.cc): without a @sha256@ the fetch is refused, since its+-- result would be whatever the network answers today.+requireFetchPin :: (MonadEval m) => Text -> Maybe Text -> m ()+requireFetchPin who pin = do+ policy <- evalPolicy+ when (epPureEval policy && isNothing pin) $+ throwEvalError ("in pure evaluation mode, '" <> who <> "' requires a 'sha256' argument")++-- | 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.+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 = map doName . drvOutputs <$> derivationAt sp++-- | The derivation a @.drv@ path names, from this session or the store.+derivationAt :: (MonadEval m) => StorePath -> m Derivation+derivationAt sp = do+ let drvPathText = storePathToText defaultStoreDir sp+ session <- lookupSessionDrv drvPathText+ case session of+ Just drv -> pure drv+ Nothing -> do+ onDisk <- readStoreDerivation sp+ case onDisk of+ Just drv -> pure 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)"+ )++-- | The filesystem closure of the @.drv@ files an all-outputs context+-- names, as upstream's derivationStrict takes it for each+-- (@computeFSClosure@, primops.cc:1382 at 2.24.9): every @.drv@ reachable+-- through @inputDrvs@, every source in their @inputSrcs@, and the+-- references this session recorded for a source it wrote.+allOutputsClosure :: (MonadEval m) => [StorePath] -> m (Set.Set StorePath)+allOutputsClosure = go Set.empty+ where+ go !seen [] = pure seen+ go !seen (sp : rest)+ | Set.member sp seen = go seen rest+ | ".drv" `T.isSuffixOf` spName sp = do+ drv <- derivationAt sp+ go (Set.insert sp seen) (Map.keys (drvInputDrvs drv) ++ drvInputSrcs drv ++ rest)+ | otherwise = do+ refs <- fromMaybe [] <$> lookupSessionReferences sp+ go (Set.insert sp seen) (refs ++ rest)++-- | Eager derivation computation - @builtins.derivationStrict@. Forces all+-- input attrs into env vars, content-hashes, and returns upstream's result+-- set: @drvPath@ and one attribute per output holding that output's path,+-- each carrying its store context (primops.cc, @prim_derivationStrict@).+-- The @derivation@ wrapper ("Nix.Builtins") reaches this only through+-- @outPath@ and @drvPath@, so forcing a derivation to WHNF never runs it.+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.+ -- prim_derivationStrict names the attribute outside the line its+ -- forceStringNoCtx adds.+ let nameTrace =+ traceIfPresent attrs "name" "while evaluating the derivation attribute 'name'"+ . traceIfPresent attrs "name" "while evaluating the `name` attribute passed to builtins.derivationStrict"+ -- Read with forceStringNoCtx (primops.cc:1130 at 2.24.9): a string with+ -- no context, never a value coerced to one. An absent name is getAttr's+ -- error.+ drvName <- nameTrace $ case attrSetLookup "name" attrs of+ Nothing -> throwEvalError "attribute 'name' missing"+ Just thunk -> decodedText "derivation" =<< structuredStringValue RefuseContext =<< force thunk+ -- Upstream processes every other attribute inside a catch that names it+ -- (derivationStrictInternal); an absent one has nothing to name.+ let traceAttr key = traceIfPresent attrs key (derivationAttrTrace drvName key)+ -- 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 ("invalid derivation name: " <> storePathNameUpstreamText err <> ". Please pass a different 'name'.")+ Right () -> pure ()+ passing <-+ addErrorTrace "while evaluating the `__structuredAttrs` attribute passed to builtins.derivationStrict" $+ attrPassingOf attrs+ let fieldContext = "derivation \"" <> drvName <> "\""+ optionalCoerced key =+ traverse (\thunk -> fst <$> (coerceToString True force applyValue coercePathToStore =<< force thunk)) (attrSetLookup key attrs)+ optionalString rule key = traverse (force >=> structuredStringValue rule) (attrSetLookup key attrs)+ -- Upstream checks both once its attribute loop is done, the builder+ -- first, and an empty one counts as absent (primops.cc:1403).+ requiredFields (systemValue, builderValue) = do+ builderBytes <- required "builder" builderValue+ systemBytes <- required "system" systemValue+ systemText <- decodedText fieldContext systemBytes+ pure (systemText, builderBytes)+ required key value = case value of+ Just bytes | not (BS.null bytes) -> pure bytes+ _ -> throwEvalError ("required attribute '" <> key <> "' missing")+ -- A structured derivation's builder and system are read as strings+ -- (upstream's forceString and forceStringNoCtx) rather than coerced.+ -- Otherwise they are the strings their environment entries coerce to,+ -- a path copied to the store (primops.cc:1347 at 2.24.9).+ (system, builder) <- case passing of+ PassAsEnvironment ->+ requiredFields+ =<< (,)+ <$> traceAttr "system" (optionalCoerced "system")+ <*> traceAttr "builder" (optionalCoerced "builder")+ PassAsStructuredAttrs ->+ requiredFields+ =<< (,)+ <$> traceAttr "system" (optionalString RefuseContext "system")+ <*> traceAttr "builder" (optionalString AllowContext "builder")++ -- __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 ->+ addErrorTrace "while evaluating the `__ignoreNulls` attribute passed to builtins.derivationStrict" $+ force thunk >>= expectDerivationBool++ -- Extract optional outputs (default ["out"]). Each refusal below names+ -- the attribute, as upstream's handleOutputs runs inside its catch.+ outputNames <- traceAttr "outputs" $ case attrSetLookup "outputs" attrs of+ Nothing -> pure ["out"]+ Just thunk -> do+ val <- force thunk+ case val of+ VList cl -> case passing of+ PassAsEnvironment -> mapM (forceToText . Thunk) (clistThunks cl)+ PassAsStructuredAttrs -> mapM (structuredOutputName fieldContext . 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).+ traceAttr "outputs" $ forM_ outputNames $ \outName ->+ case checkStorePathName outName of+ Left err ->+ throwEvalError+ ( "derivation \""+ <> drvName+ <> "\": invalid derivation output name '"+ <> outName+ <> "': "+ <> storePathNameReasonText (spneReason err)+ )+ Right () -> pure ()++ -- Upstream's derivationStrict walks the output names in order and refuses+ -- a repeated name and the name drvPath, which its result set already+ -- carries, then refuses an empty list (primops.cc, handleOutputs in+ -- derivationStrictInternal).+ let checkOutputName seen outName+ | Set.member outName seen = throwEvalError ("duplicate derivation output '" <> outName <> "'")+ | outName == "drvPath" = throwEvalError "invalid derivation output name 'drvPath'"+ | otherwise = pure (Set.insert outName seen)+ traceAttr "outputs" $ do+ foldM_ checkOutputName Set.empty outputNames+ when (null outputNames) (throwEvalError "derivation cannot have an empty set of outputs")++ -- 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) <- traceAttr "args" $ 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 the attrs into the build env, EXCLUDING "args" (C++ Nix puts+ -- args in the Derive() args field, never the env): one string-coerced+ -- entry each, or one JSON object under __json for a structured+ -- derivation. The per-output env vars ($out, ...) are added below in+ -- both modes. Carries merged context.+ let envAttrs = Map.delete "__ignoreNulls" (Map.delete "args" materialized)+ (drvEnvPairs, envContext) <- case passing of+ PassAsEnvironment -> collectDrvEnvWithContext drvName ignoreNulls envAttrs+ PassAsStructuredAttrs -> do+ (members, ctx) <- collectStructuredAttrs drvName ignoreNulls (Map.delete structuredAttrsFlag envAttrs)+ forM_ (Map.keys (Map.restrictKeys members structuredPlainStrings)) $ \key ->+ traceAttr key (structuredString RefuseContext fieldContext key attrs)+ forM_ (Map.keys (Map.restrictKeys members structuredDisabledChecks)) $+ traceMessage . disabledByStructuredAttrs drvName+ pure ([(structuredAttrsKey, encodeStructuredAttrs (StructuredAttrs members))], ctx)++ let fullContext = envContext <> argsContext+ builtInputDrvs = extractInputDrvs fullContext+ allOutputRefs = extractAllOutputRefs fullContext+ -- All-outputs (DrvDeep) references - e.g. an embedded @dep.drvPath@ - add+ -- the referenced .drv's whole closure, as upstream's derivationStrict+ -- does: every path in it to inputSrcs, every .drv in it to inputDrvs with+ -- ALL its output names. Merged in BEFORE drvRefs and the modulo+ -- substitution so both the dependent's own .drv hash and its output paths+ -- account for the reference.+ deepClosure <- allOutputsClosure allOutputRefs+ deepInputDrvs <-+ Map.fromList+ <$> mapM (\drvSp -> (,) drvSp <$> resolveAllOutputNames drvSp) (filter ((".drv" `T.isSuffixOf`) . spName) (Set.toList deepClosure))+ let inputDrvs = Map.unionWith (++) builtInputDrvs deepInputDrvs+ inputSrcs = Set.toList (Set.fromList (extractInputSrcs fullContext) <> deepClosure)+ 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 <> "\""+ (outPaths, completeDrv, moduloHash) <- 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)+ pure ([("out", foPathText)], contents, foModulo)+ 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)+ pure (outPathTexts, contents, moduloUnmasked)++ -- The .drv's bytes are rendered once, as upstream's writeDerivation hashes+ -- and writes the one unparse. They are recorded whole 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.+ let aterm = toATerm completeDrv+ drvSP <- storePathOrThrow drvContext (makeTextPath drvFileName (sha256Digest aterm) drvRefs)+ let drvPathText = storePathToText defaultStoreDir drvSP+ cacheDrvHash drvPathText (bytesToHexText moduloHash)+ recordDrvAterm drvPathText aterm++ -- drvPath carries the whole derivation (upstream's DrvDeep); an output+ -- path carries that one output of it.+ let drvPathCtx = StringContext (Set.singleton (SCAllOutputs drvSP))+ outPathCtx outName = StringContext (Set.singleton (SCDrvOutput drvSP outName))+ outputEntry (outName, outP) = (outName, evaluated (VStr (TE.encodeUtf8 outP) (outPathCtx outName)))+ resultAttrs =+ Map.fromList+ (("drvPath", evaluated (VStr (TE.encodeUtf8 drvPathText) drvPathCtx)) : map outputEntry outPaths)++ 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 <> "'")++-- | 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) => Text -> Bool -> Map Text Thunk -> m ([(Text, BS.ByteString)], StringContext)+collectDrvEnvWithContext drvName 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) = addErrorTrace (derivationAttrTrace drvName key) $ do+ val <- force thunk+ case val of+ VNull | ignoreNulls -> pure Nothing+ _ -> do+ (s, ctx) <- coerceToStoreString val+ pure (Just (key, s, ctx))++-- | How a derivation hands its attributes to the builder.+data AttrPassing+ = -- | One environment variable per attribute, each coerced to a string.+ PassAsEnvironment+ | -- | @__structuredAttrs = true@: one JSON object under @__json@, so an+ -- attribute may be a set or a list (upstream's @jsonObject@ branch of+ -- @derivationStrictInternal@, primops.cc at 2.24.9).+ PassAsStructuredAttrs++-- | The attribute choosing structured attributes. It is not itself one of+-- them: upstream leaves it out of the JSON object.+structuredAttrsFlag :: Text+structuredAttrsFlag = "__structuredAttrs"++-- | Upstream reads @__structuredAttrs@ before any other attribute and as a+-- boolean, whatever @__ignoreNulls@ says, refusing anything else with+-- @forceBool@'s message.+attrPassingOf :: (MonadEval m) => AttrSet -> m AttrPassing+attrPassingOf attrs = case attrSetLookup structuredAttrsFlag attrs of+ Nothing -> pure PassAsEnvironment+ Just thunk -> do+ structured <- force thunk >>= expectDerivationBool+ pure (if structured then PassAsStructuredAttrs else PassAsEnvironment)++-- | Whether a string field read without coercion may carry context: a+-- structured derivation's builder may (upstream's forceString), the name+-- and the fields upstream reads with forceStringNoCtx may not.+data StringContextRule = AllowContext | RefuseContext++-- | A string field of a structured derivation, as upstream reads it:+-- a string, never a value coerced to one.+structuredString :: (MonadEval m) => StringContextRule -> Text -> Text -> AttrSet -> m BS.ByteString+structuredString rule fieldContext key attrs = case attrSetLookup key attrs of+ Nothing -> throwEvalError (fieldContext <> ": missing required attribute '" <> key <> "'")+ Just thunk -> force thunk >>= structuredStringValue rule++-- | 'structuredString' of a value already in hand.+structuredStringValue :: (MonadEval m) => StringContextRule -> NixValue -> m BS.ByteString+structuredStringValue rule val = case (val, rule) of+ (VStr s ctx, RefuseContext)+ | Just shown <- firstContextEncoded ctx ->+ throwEvalError ("the string '" <> bytesToTextLossy s <> "' is not allowed to refer to a store path (such as '" <> shown <> "')")+ (VStr s _, _) -> pure s+ (other, _) ->+ throwEvalError ("expected a string but found " <> typeName other <> ": " <> printValue PrintForError other)++-- | An element of a structured derivation's @outputs@ list: a string+-- without context, where the environment would coerce it.+structuredOutputName :: (MonadEval m) => Text -> Thunk -> m Text+structuredOutputName fieldContext thunk =+ force thunk >>= structuredStringValue RefuseContext >>= decodedText fieldContext++-- | The attributes a structured derivation reads as plain strings, past+-- @system@ and the outputs list, which are read where they are used.+structuredPlainStrings :: Set.Set Text+structuredPlainStrings = Set.fromList ["outputHash", "outputHashAlgo", "outputHashMode"]++-- | Reference and size checks that a structured derivation takes from+-- @outputChecks@ instead; upstream warns when one is set at the top level.+structuredDisabledChecks :: Set.Set Text+structuredDisabledChecks =+ Set.fromList+ ["allowedReferences", "allowedRequisites", "disallowedReferences", "disallowedRequisites", "maxSize", "maxClosureSize"]++-- | Upstream's warning for a 'structuredDisabledChecks' attribute.+disabledByStructuredAttrs :: Text -> Text -> Text+disabledByStructuredAttrs drvName key =+ "warning: In a derivation named '"+ <> drvName+ <> "', 'structuredAttrs' disables the effect of the derivation attribute '"+ <> key+ <> "'; use 'outputChecks.<output>."+ <> key+ <> "' instead"++-- | Collect a structured derivation's attributes into the members of its+-- @__json@ object, each converted as @builtins.toJSON@ converts it (upstream+-- calls the same @printValueAsJSON@), with the merged context of every+-- string they hold. Null attributes are dropped only under+-- @__ignoreNulls@, as in 'collectDrvEnvWithContext'.+collectStructuredAttrs :: (MonadEval m) => Text -> Bool -> Map Text Thunk -> m (Map Text Json, StringContext)+collectStructuredAttrs drvName ignoreNulls attrs = do+ results <- mapM member (Map.toAscList attrs)+ let members = catMaybes results+ pure (Map.fromAscList [(key, json) | (key, json, _) <- members], mconcat [ctx | (_, _, ctx) <- members])+ where+ member (key, thunk) = addErrorTrace (derivationAttrTrace drvName key) $ do+ val <- force thunk+ case val of+ VNull | ignoreNulls -> pure Nothing+ _ -> do+ (json, ctx) <- valueToJSON val+ pure (Just (key, json, ctx))++-- | Upstream's line of context for a failure processing one attribute of+-- a derivation's argument set (derivationStrictInternal, primops.cc at+-- 2.24.9).+derivationAttrTrace :: Text -> Text -> Text+derivationAttrTrace drvName key = "while evaluating attribute '" <> key <> "' of derivation '" <> drvName <> "'"++-- | Run the action with a line of context when the attribute is present,+-- and as it is when it is absent, which leaves a missing attribute's+-- refusal unadorned.+traceIfPresent :: (MonadEval m) => AttrSet -> Text -> Text -> m a -> m a+traceIfPresent attrs key line action+ | attrSetMember key attrs = addErrorTrace line action+ | otherwise = action++-- | A derivation's Boolean switch, refused with upstream's @forceBool@+-- message.+expectDerivationBool :: (MonadEval m) => NixValue -> m Bool+expectDerivationBool (VBool b) = pure b+expectDerivationBool other = throwEvalError ("expected a Boolean but found " <> typeName other <> ": " <> printValue PrintForError other)++-- ---------------------------------------------------------------------------+-- 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 _) pathVal = do+ algoName <- decodedText "builtins.hashFile" algo+ bytes <- readFileBytes =<< coerceToPath "builtins.hashFile" pathVal+ hashBytesWithAlgo "hashFile" algoName bytes+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 val = mkStr <$> (getFileType =<< coerceToPath "builtins.readFileType" val)++-- ---------------------------------------------------------------------------+-- 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@ - the value as upstream's @prim_toXML@ writes it:+-- @printValueAsXML@ forcing every value and recording no positions+-- (value-to-xml.cc and xml-writer.cc at 2.24.9). The result keeps the+-- contexts of the strings written into it, so a derivation built from the+-- document depends on the store paths it names, as upstream's does.+--+-- Built over BYTES: upstream's writer copies a string into an attribute+-- value with only its five 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+ (body, XmlWritten ctx _) <- valueToXML 1 (XmlWritten emptyContext Set.empty) val+ let document =+ "<?xml version='1.0' encoding='utf-8'?>\n"+ <> xmlOpen 0 "expr" []+ <> body+ <> xmlClose 0 "expr"+ pure (VStr (BL.toStrict (BB.toLazyByteString document)) ctx)++-- | What writing a document has gathered so far: the contexts of the+-- strings written, and the drvPaths of the derivations written in full,+-- each of which a later occurrence writes as @<repeated />@ (upstream's+-- @drvsSeen@). That is also what ends the cycle every derivation's+-- @all@ and output attributes close.+data XmlWritten = XmlWritten !StringContext !(Set.Set BS.ByteString)++-- | One XML attribute: its name and its unescaped value.+type XmlAttr = (BS.ByteString, BS.ByteString)++-- | Write one value as the element upstream writes for it, at the given+-- depth (an element's depth is the number of elements open around it, and+-- each level indents two spaces).+valueToXML :: (MonadEval m) => Int -> XmlWritten -> NixValue -> m (BB.Builder, XmlWritten)+valueToXML depth written val = case val of+ VInt n -> leaf "int" [("value", BC.pack (show n))]+ VBool b -> leaf "bool" [("value", if b then "true" else "false")]+ VStr s ctx ->+ let XmlWritten seenCtx seenDrvs = written+ in pure (xmlEmpty depth "string" [("value", s)], XmlWritten (seenCtx <> ctx) seenDrvs)+ VPath p -> leaf "path" [("value", TE.encodeUtf8 p)]+ VNull -> leaf "null" []+ VFloat d -> leaf "float" [("value", TE.encodeUtf8 (formatXmlFloat d))]+ VList cl ->+ xmlElement depth "list" [] $+ foldM (writeChild (\thunk inner -> force thunk >>= valueToXML (depth + 1) inner)) (mempty, written) (map Thunk (clistThunks cl))+ VAttrs attrs -> do+ identity <- xmlDerivationIdentity attrs+ case identity of+ Nothing -> xmlElement depth "attrs" [] (attrsToXML (depth + 1) written attrs)+ Just (drvAttrs, drvPath) ->+ let XmlWritten seenCtx seenDrvs = written+ in xmlElement depth "derivation" drvAttrs $+ if BS.null drvPath || Set.member drvPath seenDrvs+ then pure (xmlEmpty (depth + 1) "repeated" [], written)+ else attrsToXML (depth + 1) (XmlWritten seenCtx (Set.insert drvPath seenDrvs)) attrs+ VLambda _ formals _ -> xmlElement depth "function" [] (pure (formalsToXML (depth + 1) formals, written))+ -- A primop, applied in part or not at all, is not a lambda, which+ -- upstream writes as unevaluated (its FIXME: primops are not serialized).+ VBuiltin _ _ -> leaf "unevaluated" []+ VCompiledRegex _ -> leaf "unevaluated" []+ where+ leaf name attrs = pure (xmlEmpty depth name attrs, written)++-- | Each attribute of a set, in name order, as an @attr@ element holding+-- its value.+attrsToXML :: (MonadEval m) => Int -> XmlWritten -> AttrSet -> m (BB.Builder, XmlWritten)+attrsToXML depth written attrs =+ foldM (writeChild attrToXML) (mempty, written) (attrSetToAscList attrs)+ where+ attrToXML (name, thunk) inner =+ xmlElement depth "attr" [("name", TE.encodeUtf8 name)] (force thunk >>= valueToXML (depth + 1) inner)++-- | Append one child's output to what its siblings wrote, threading what+-- the document has gathered from one to the next.+writeChild :: (Monad m) => (a -> XmlWritten -> m (BB.Builder, XmlWritten)) -> (BB.Builder, XmlWritten) -> a -> m (BB.Builder, XmlWritten)+writeChild write (before, written) child = do+ (out, next) <- write child written+ pure (before <> out, next)++-- | The XML attributes of a derivation's element and the drvPath that+-- identifies it, or 'Nothing' for a set that is not a derivation: upstream's+-- @isDerivation@, a @type@ attribute that is the string @"derivation"@.+-- @drvPath@ and @outPath@ are written only when they are strings, and their+-- contexts are not gathered here: a derivation written in full gathers them+-- as attributes, and a repeated one adds none.+xmlDerivationIdentity :: (MonadEval m) => AttrSet -> m (Maybe ([XmlAttr], BS.ByteString))+xmlDerivationIdentity attrs = do+ typeVal <- traverse force (attrSetLookup "type" attrs)+ case typeVal of+ Just (VStr "derivation" _) -> do+ drvPath <- stringAttr "drvPath"+ outPath <- stringAttr "outPath"+ let present = [(key, s) | (key, Just s) <- [("drvPath", drvPath), ("outPath", outPath)]]+ pure (Just (present, fromMaybe "" drvPath))+ _ -> pure Nothing+ where+ stringAttr key = do+ found <- traverse force (attrSetLookup key attrs)+ pure $ case found of+ Just (VStr s _) -> Just s+ _ -> Nothing++-- | A lambda's argument pattern: @varpat@ for a plain argument, otherwise+-- @attrspat@ naming the formals in name order, with the @\@@ binding and+-- the ellipsis as its attributes.+formalsToXML :: Int -> EvalFormals -> BB.Builder+formalsToXML depth formals = case formals of+ EFName name -> xmlEmpty depth "varpat" [("name", TE.encodeUtf8 name)]+ EFSet entries ellipsis -> attrsPattern (ellipsisAttr ellipsis) entries+ EFNamedSet name entries ellipsis -> attrsPattern (ellipsisAttr ellipsis <> [("name", TE.encodeUtf8 name)]) entries+ where+ ellipsisAttr ellipsis = [("ellipsis", "1") | ellipsis]+ attrsPattern attrs entries =+ xmlOpen depth "attrspat" attrs+ <> foldMap (\formal -> xmlEmpty (depth + 1) "attr" [("name", TE.encodeUtf8 (efName formal))]) (sortOn efName entries)+ <> xmlClose depth "attrspat"++-- | An element with children: the opening tag, what the children write one+-- level deeper, and the closing tag.+xmlElement :: (Monad m) => Int -> BS.ByteString -> [XmlAttr] -> m (BB.Builder, XmlWritten) -> m (BB.Builder, XmlWritten)+xmlElement depth name attrs children = do+ (inner, written) <- children+ pure (xmlOpen depth name attrs <> inner <> xmlClose depth name, written)++-- | Upstream's @XMLWriter@ lines. The attributes are written in the order+-- given, which callers keep sorted by name as upstream's @std::map@ is.+xmlOpen, xmlEmpty :: Int -> BS.ByteString -> [XmlAttr] -> BB.Builder+xmlOpen depth name attrs = xmlIndent depth <> "<" <> BB.byteString name <> foldMap xmlAttr attrs <> ">\n"+xmlEmpty depth name attrs = xmlIndent depth <> "<" <> BB.byteString name <> foldMap xmlAttr attrs <> " />\n"++xmlClose :: Int -> BS.ByteString -> BB.Builder+xmlClose depth name = xmlIndent depth <> "</" <> BB.byteString name <> ">\n"++xmlIndent :: Int -> BB.Builder+xmlIndent depth = BB.byteString (BC.replicate (2 * depth) ' ')++-- | One attribute with upstream's escapes. A newline is written as a+-- character reference because an XML parser normalizes a literal one in an+-- attribute value to a space (XML 1.0, section 3.3.3).+xmlAttr :: XmlAttr -> BB.Builder+xmlAttr (name, value) = " " <> BB.byteString name <> "=\"" <> BC.foldr (\c rest -> escapeChar c <> rest) mempty value <> "\""+ where+ escapeChar '"' = """+ escapeChar '<' = "<"+ escapeChar '>' = ">"+ escapeChar '&' = "&"+ escapeChar '\n' = "
"+ escapeChar c = BB.char8 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+ -- prim_path coerces the attribute with coerceToPath and checks for it+ -- once its attribute loop is done (primops.cc at 2.24.9).+ pathStr <- case attrSetLookup "path" attrs of+ Nothing -> throwEvalError "missing required 'path' attribute in the first argument to builtins.path"+ Just thunk -> coerceToPath "builtins.path" =<< force thunk+ 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 rawRoot = do+ -- The root is resolved through its symlinks before the walk, as+ -- upstream's addPath and filterSource resolve theirs: a root that is a+ -- symlink stores what it points at, and no entry below has a symlink+ -- above it for the lstats to refuse.+ rootPath <- resolveSymlinks rawRoot+ 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 pathVal = filterSourceInto filterFn =<< coerceToPath "builtins.filterSource" pathVal filterSourceInto :: (MonadEval m) => NixValue -> Text -> m NixValue filterSourceInto filterFn path = do
src/Nix/Eval/Arena.hs view
@@ -1,18 +1,40 @@ -- | Unified arena lifecycle for the C data layer. -- -- Initializes and destroys all C sub-arenas (symbol table, thunk arena,--- env slot allocator) with a single pair of calls. Handles StablePtr--- cleanup on destruction: iterates all thunks, identifies payloads that--- are Haskell StablePtrs (PENDING + COMPUTED\/PTR), and frees them--- before tearing down the C memory.+-- env slot allocator, bytecode store) with a single pair of calls.+-- Handles StablePtr cleanup on destruction: iterates all thunks,+-- identifies payloads that are Haskell StablePtrs (PENDING ++-- COMPUTED\/PTR), and frees them before tearing down the C memory. --+-- Everything that evaluates runs between the two calls:+-- -- @ -- bracket_ arenaInit arenaDestroy $ do -- ... evaluation ... -- @+--+-- Outside that window the C layer has no memory to hand out, and every+-- path into it ('Nix.Builtins.builtinEnv', 'Nix.Eval.eval',+-- 'Nix.Eval.force', the value constructors) raises+-- 'ArenaNotInitialized' naming the C entry point that refused, rather+-- than dereferencing state that does not exist. Parsing ("Nix.Parser")+-- is pure and needs no arena.+--+-- 'ArenaNotInitialized' is a setup diagnosis: add the bracket and start+-- the process again. It is not an error to catch and retry in the same+-- process, because GHC updates a thunk whose evaluation raised with that+-- exception: any shared value whose evaluation reached C before the+-- window re-raises it inside a later, live arena. The @null@, @true@+-- and @false@ thunks are C statics outside every arena, so forcing one+-- needs no window and survives every cycle. module Nix.Eval.Arena- ( arenaInit,+ ( -- * Lifecycle+ arenaInit, arenaDestroy,+ arenaLive,++ -- * Errors+ CStatusError (..), ) where @@ -25,6 +47,7 @@ import Nix.Eval.CEnv (cenvDestroy, cenvInit) import Nix.Eval.CLambda (clambdaFreeAll) import Nix.Eval.CList (clistFreeAll)+import Nix.Eval.CStatus (CStatusError (..), arenaLive) import Nix.Eval.CThunk (cthunkDestroy, cthunkInit) import Nix.Eval.Symbol (symbolDestroy, symbolInit) @@ -59,6 +82,14 @@ cbcInit 0 0 -- | Destroy all C arenas, properly freeing StablePtrs first.+--+-- This ends the process's use of the evaluator; a second 'arenaInit' does+-- not restore one. Thunks the library allocates once for the process,+-- explicitly (@derivationWrapper@ in "Nix.Builtins") or as a+-- parameter-free entry of 'Nix.Builtins.builtinEnv' that GHC shares+-- across its calls (@storeDir@, @map@), keep pointing into the memory+-- freed here, and only an entry built per call (@currentTime@) or one of+-- the static @null@, @true@ and @false@ thunks survives a second cycle. -- -- 1. Collects all StablePtr payloads from thunks (batch C call) -- 2. Frees each StablePtr from Haskell
src/Nix/Eval/AttrPath.hs view
@@ -33,7 +33,7 @@ import Data.Text (Text) import qualified Data.Text as T-import Nix.Eval (MonadEval, NixValue (..), attrSetLookup, force)+import Nix.Eval (MonadEval, NixValue (..), attrSetLookup, force, typeName) -- --------------------------------------------------------------------------- -- Tokenizing@@ -100,28 +100,7 @@ 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"+ _ -> pure (Left (notASetMessage path (typeName val))) -- --------------------------------------------------------------------------- -- Messages
src/Nix/Eval/CAttrSet.hs view
@@ -11,6 +11,12 @@ -- Values are opaque @CThunkPtr@ handles stored as @void*@ on the C side. -- The C side never dereferences them. Thunk lifetimes are managed by -- the thunk arena; attribute set cleanup only frees the key\/value arrays.+--+-- A set holds at most 'maxBound' :: 'Word32' entries, upstream's own+-- ceiling (its @Bindings@ counts in @uint32_t@). Every constructor and+-- insert checks its C result, so a set the C side cannot build raises+-- 'Nix.Eval.CStatus.CStatusError' instead of leaving a NULL or a short+-- set behind. module Nix.Eval.CAttrSet ( -- * Opaque handle CAttrSet,@@ -39,10 +45,12 @@ ) where +import Control.Monad (unless) import Data.Word (Word32) import Foreign.C.Types (CInt (..)) import Foreign.Marshal.Array (peekArray, withArray) import Foreign.Ptr (Ptr, castPtr, nullPtr)+import Nix.Eval.CStatus (cStatusFailure, checkedCPtr) import Nix.Eval.CThunk (CThunkPtr) import Nix.Eval.Symbol (Symbol (..)) @@ -61,7 +69,7 @@ c_nn_attrset_new :: Word32 -> IO CAttrSet foreign import ccall unsafe "nn_attrset_insert"- c_nn_attrset_insert :: CAttrSet -> Word32 -> Ptr () -> IO ()+ c_nn_attrset_insert :: CAttrSet -> Word32 -> Ptr () -> IO CInt foreign import ccall unsafe "nn_attrset_freeze" c_nn_attrset_freeze :: CAttrSet -> IO ()@@ -70,7 +78,7 @@ c_nn_attrset_lookup :: CAttrSet -> Word32 -> IO (Ptr ()) foreign import ccall unsafe "nn_attrset_index"- c_nn_attrset_index :: CAttrSet -> Word32 -> IO CInt+ c_nn_attrset_index :: CAttrSet -> Word32 -> IO Word32 foreign import ccall unsafe "nn_attrset_set_value" c_nn_attrset_set_value :: CAttrSet -> Word32 -> Ptr () -> IO ()@@ -93,13 +101,23 @@ foreign import ccall unsafe "nn_attrset_remove_keys" c_nn_attrset_remove_keys :: CAttrSet -> Ptr Word32 -> Word32 -> IO CAttrSet +-- | @NN_ATTRSET_NOT_FOUND@, the index answer for an absent key. Must+-- stay in lockstep with @cbits\/nn_attrset.h@.+indexNotFound :: Word32+indexNotFound = maxBound+ -- --------------------------------------------------------------------------- -- Lifecycle -- --------------------------------------------------------------------------- --- | Allocate a new empty attribute set with the given capacity hint.-cattrsetNew :: Word32 -> IO CAttrSet-cattrsetNew = c_nn_attrset_new+-- | Allocate a new empty attribute set sized for the expected number of+-- entries. The count only sizes the first allocation, so one past the+-- ceiling is clamped to it: the inserts are what enforce the ceiling.+cattrsetNew :: Int -> IO CAttrSet+cattrsetNew expected = checkedCPtr "nn_attrset_new" =<< c_nn_attrset_new capacity+ where+ capacity = fromIntegral (max 0 (min expected entryCeiling))+ entryCeiling = fromIntegral (maxBound :: Word32) -- --------------------------------------------------------------------------- -- Construction@@ -108,8 +126,10 @@ -- | Insert a key-value pair. Call before freeze. -- The value is a CThunkPtr cast to Ptr () - C never dereferences it. cattrsetInsert :: CAttrSet -> Symbol -> CThunkPtr -> IO ()-cattrsetInsert set (Symbol sym) ptr =- c_nn_attrset_insert set sym (castPtr ptr)+cattrsetInsert set (Symbol sym) ptr = do+ status <- c_nn_attrset_insert set sym (castPtr ptr)+ unless (status == 0) $+ cStatusFailure "nn_attrset_insert" "the attribute set cannot grow (allocation failure or its 4294967295-entry ceiling)" -- | Sort and deduplicate. Must be called once before any queries. cattrsetFreeze :: CAttrSet -> IO ()@@ -130,7 +150,7 @@ cattrsetIndex :: CAttrSet -> Symbol -> IO (Maybe Word32) cattrsetIndex set (Symbol sym) = do idx <- c_nn_attrset_index set sym- pure (if idx < 0 then Nothing else Just (fromIntegral idx))+ pure (if idx == indexNotFound then Nothing else Just idx) -- | Get the value at a known index. cattrsetGetValue :: CAttrSet -> Word32 -> IO CThunkPtr@@ -166,7 +186,7 @@ -- | Right-biased union (// semantics). Result is frozen. cattrsetUnion :: CAttrSet -> CAttrSet -> IO CAttrSet-cattrsetUnion = c_nn_attrset_union+cattrsetUnion a b = checkedCPtr "nn_attrset_union" =<< c_nn_attrset_union a b -- | Remove a list of keys. Result is frozen. cattrsetRemoveKeys :: CAttrSet -> [Symbol] -> IO CAttrSet@@ -174,4 +194,4 @@ let raw = map unSymbol syms n = fromIntegral (length raw) withArray raw $ \ptr ->- c_nn_attrset_remove_keys set ptr n+ checkedCPtr "nn_attrset_remove_keys" =<< c_nn_attrset_remove_keys set ptr n
src/Nix/Eval/CBytecode.hs view
@@ -37,6 +37,10 @@ spilledCountSentinel, cbcCountedPayload, + -- * Reserved instructions+ reservedApply1,+ reservedApply2,+ -- * Diagnostics cbcOpCount, cbcDataCount,@@ -68,6 +72,10 @@ pattern OpSearchPath, pattern OpPathStr, + -- * App flags+ appDirect,+ appDeferred,+ -- * UnaryOp flags unaryNot, unaryNegate,@@ -97,10 +105,12 @@ -- * String part tags strpartLit, strpartInterp,+ strpartEsc, -- * Binding type tags bindNamed, bindInherit,+ bindInheritFrom, -- * CaptureInfo type tags captureNone,@@ -114,6 +124,7 @@ where import Data.Word (Word16, Word32, Word8)+import Nix.Eval.CStatus (cStatusFailure) -- --------------------------------------------------------------------------- -- FFI imports (all unsafe - no callbacks, fast data access)@@ -164,6 +175,8 @@ -- | Initialize the global bytecode store. Call once before evaluation. -- Arguments are capacity hints (0 = defaults: 65536 ops, 131072 data).+-- The store starts with the reserved instructions ('reservedApply1',+-- 'reservedApply2') already in place. cbcInit :: Word32 -> Word32 -> IO () cbcInit = c_nn_bytecode_init @@ -196,8 +209,7 @@ -- 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)"))+ | idx == emitFailedSentinel = cStatusFailure site "bytecode append failed (allocation failure or index ceiling)" | otherwise = pure idx -- ---------------------------------------------------------------------------@@ -264,6 +276,27 @@ else pure (fromIntegral inline, dataOff) -- ---------------------------------------------------------------------------+-- Reserved instructions+-- ---------------------------------------------------------------------------++-- | Code that applies slot 0 of its env to slot 1, a deferred call+-- (upstream's @mkApp@). 'cbcInit' lays it down at this index in every+-- store, so a builtin that makes such a thunk per element (@map@,+-- @genList@) points each one here instead of compiling its own copy.+-- Must stay in lockstep with @NN_BC_RESERVED_APPLY1@ in+-- @cbits\/nn_bytecode.h@.+reservedApply1 :: Word32+reservedApply1 = 2++-- | Code that applies slot 0 to slot 1 and the result to slot 2, both+-- deferred: @f name value@ as upstream's @mapAttrs@ builds it from two+-- nested @mkApp@s, so the name application runs a frame below the value+-- application's. Its function is 'reservedApply1'. Must stay in+-- lockstep with @NN_BC_RESERVED_APPLY2@ in @cbits\/nn_bytecode.h@.+reservedApply2 :: Word32+reservedApply2 = 4++-- --------------------------------------------------------------------------- -- Diagnostics -- --------------------------------------------------------------------------- @@ -343,6 +376,15 @@ -- Sub-type flags -- --------------------------------------------------------------------------- +-- | @OpApp@ flag values: a source application ('Nix.Expr.Types.EApp')+-- evaluates its function before the call frame opens; a deferred one+-- forces it inside the frame. Must stay in lockstep with @NN_APP_*@ in+-- @cbits\/nn_bytecode.h@, whose reserved instructions are the deferred+-- applications.+appDirect, appDeferred :: Word8+appDirect = 0+appDeferred = 1+ -- | @OpUnary@ flag values: which unary operator to apply. unaryNot, unaryNegate :: Word8 unaryNot = 0@@ -385,15 +427,20 @@ formalSet = 1 formalNamedSet = 2 --- | String-part kinds: a literal chunk or an interpolated @${...}@ expression.-strpartLit, strpartInterp :: Word32+-- | String-part kinds: a literal chunk, an interpolated @${...}@+-- expression, or an indented-string escape (literal text the indentation+-- scan must not measure).+strpartLit, strpartInterp, strpartEsc :: Word32 strpartLit = 0 strpartInterp = 1+strpartEsc = 2 --- | Attribute-binding kinds: a @name = value@ binding or an @inherit@.-bindNamed, bindInherit :: Word32+-- | Attribute-binding kinds: a @name = value@ binding, one name of an+-- @inherit@, or an @inherit (from) ...@.+bindNamed, bindInherit, bindInheritFrom :: Word32 bindNamed = 0 bindInherit = 1+bindInheritFrom = 2 -- | Closure capture kinds: capture nothing, capture specific slots, or capture -- slots plus the enclosing @with@ scopes.
src/Nix/Eval/CList.hs view
@@ -7,6 +7,11 @@ -- Lists are immutable after construction: allocate with 'clistNew', -- fill with 'clistSet', then read with 'clistGet'/'clistCount'. -- All memory is freed in bulk via 'clistFreeAll' at evaluation end.+--+-- 'nullPtr' is the empty list, so the C constructors report failure+-- through a status of their own rather than a NULL, and every one is+-- checked here: a list the C side cannot allocate raises+-- 'Nix.Eval.CStatus.CStatusError', never reads back as empty. module Nix.Eval.CList ( -- * Opaque handle NnList,@@ -18,6 +23,8 @@ clistFromThunks, clistThunks, clistLen,+ clistIndex,+ clistDrop, -- * Lifecycle clistNew,@@ -34,8 +41,13 @@ ) where +import Control.Monad (unless) import Data.Word (Word32)+import Foreign.C.Types (CInt (..))+import Foreign.Marshal.Alloc (alloca) import Foreign.Ptr (Ptr, nullPtr)+import Foreign.Storable (peek)+import Nix.Eval.CStatus (cStatusFailure) import Nix.Eval.CThunk (CThunkPtr) import System.IO.Unsafe (unsafePerformIO) @@ -50,7 +62,7 @@ -- --------------------------------------------------------------------------- foreign import ccall unsafe "nn_list_new"- c_nn_list_new :: Word32 -> IO CListPtr+ c_nn_list_new :: Ptr CListPtr -> Word32 -> IO CInt foreign import ccall unsafe "nn_list_free_all" c_nn_list_free_all :: IO ()@@ -64,15 +76,27 @@ foreign import ccall unsafe "nn_list_set" c_nn_list_set :: CListPtr -> Word32 -> CThunkPtr -> IO () +foreign import ccall unsafe "nn_list_drop"+ c_nn_list_drop :: Ptr CListPtr -> CListPtr -> Word32 -> IO CInt+ -- --------------------------------------------------------------------------- -- Lifecycle -- --------------------------------------------------------------------------- --- | Allocate a new list with space for @count@ thunk pointers.--- Returns 'nullPtr' if count is 0.+-- | Allocate a new list with space for @count@ thunk pointers, or+-- 'nullPtr' (the empty list) when count is 0. Raises+-- 'Nix.Eval.CStatus.CStatusError' when the C side cannot allocate it. clistNew :: Word32 -> IO CListPtr-clistNew = c_nn_list_new+clistNew count = alloca $ \out -> do+ checkedListStatus "nn_list_new" =<< c_nn_list_new out count+ peek out +-- | Raise the 'Nix.Eval.CStatus.CStatusError' for a list constructor's+-- failure status.+checkedListStatus :: String -> CInt -> IO ()+checkedListStatus site status =+ unless (status == 0) (cStatusFailure site "list allocation failed")+ -- | Free all tracked list headers (arena-style cleanup). -- Items arrays are freed by the env page allocator. clistFreeAll :: IO ()@@ -100,18 +124,20 @@ -- | Convert a Haskell list of 'CThunkPtr' to a C list. -- Allocates a new nn_list_t and fills it with the thunk pointers.--- Returns 'nullPtr' for empty lists.+-- Returns 'nullPtr' for empty lists. A list longer than the C count+-- can hold is refused rather than wrapped to a shorter allocation that+-- the fill would then run past. thunkListToCList :: [CThunkPtr] -> IO CListPtr thunkListToCList [] = pure nullPtr-thunkListToCList ptrs = do- let n = fromIntegral (length ptrs)- clist <- clistNew n- if clist == nullPtr- then pure nullPtr- else do+thunkListToCList ptrs+ | len > fromIntegral (maxBound :: Word32) =+ cStatusFailure "nn_list_new" ("a list of " ++ show len ++ " elements is past the uint32_t count a C list holds")+ | otherwise = do+ clist <- clistNew (fromIntegral len) fillList clist 0 ptrs pure clist where+ len = length ptrs fillList _ _ [] = pure () fillList cl !i (p : ps) = do clistSet cl i p@@ -167,3 +193,26 @@ clistLen (CList p) | p == nullPtr = 0 | otherwise = fromIntegral (unsafePerformIO (clistCount p))++-- | The element at an index, or 'Nothing' outside the list, read from the+-- C array without materializing the list. Pure because a list is never+-- written after construction.+clistIndex :: CList -> Int -> Maybe CThunkPtr+clistIndex cl@(CList p) i+ | i < 0 || i >= clistLen cl = Nothing+ | otherwise = Just $! unsafePerformIO (clistGet p (fromIntegral i))++-- | The list without its first @n@ elements, as 'drop' is on lists. The+-- result is a new header over this list's C array (@nn_list_drop@), so it+-- costs the same whatever the length. Allocating it under+-- 'unsafePerformIO' is safe because the array is immutable: a header+-- built twice or shared between calls describes the same elements.+clistDrop :: Int -> CList -> CList+clistDrop n cl@(CList p)+ | n <= 0 = cl+ | n >= clistLen cl = emptyCList+ | otherwise = CList (unsafePerformIO (alloca dropInto))+ where+ dropInto out = do+ checkedListStatus "nn_list_drop" =<< c_nn_list_drop out p (fromIntegral n)+ peek out
+ src/Nix/Eval/CStatus.hs view
@@ -0,0 +1,111 @@+-- | Failure statuses at the C data-layer boundary.+--+-- Every C allocator reports failure through a sentinel or a status+-- instead of aborting: a NULL pointer, @UINT32_MAX@ from the bytecode+-- emitters, @NN_SYMBOL_INVALID@ from the symbol table, @-1@ from the+-- list constructors and the attribute set insert. Each covers two+-- causes a caller needs to tell apart: the sub-arena is outside its+-- init .. destroy window (nothing ran 'Nix.Eval.Arena.arenaInit', or+-- 'Nix.Eval.Arena.arenaDestroy' already ran), or it is live and could+-- not grow. Only the C side knows which, so the failure path asks it+-- once, through @nn_arena_live@, and raises a 'CStatusError' that names+-- the C entry point. The question is asked only after a failure, so+-- the hot path pays the sentinel comparison and nothing more.+--+-- The thunk state transitions report a third kind of refusal: a thunk+-- not in the state the transition needs. The boundary only asks for a+-- transition it has just made valid, so that refusal is a defect in this+-- library, raised as 'CInvariantViolated' rather than carried on.+module Nix.Eval.CStatus+ ( -- * Errors+ CStatusError (..),++ -- * Checks+ checkedCPtr,+ cStatusFailure,+ cInvariantViolated,++ -- * Arena state+ arenaLive,+ )+where++import Control.Exception (Exception (..), throwIO)+import Foreign.C.Types (CInt (..))+import Foreign.Ptr (Ptr, nullPtr)++-- ---------------------------------------------------------------------------+-- FFI import+-- ---------------------------------------------------------------------------++foreign import ccall unsafe "nn_arena_live"+ c_nn_arena_live :: IO CInt++-- ---------------------------------------------------------------------------+-- Errors+-- ---------------------------------------------------------------------------++-- | Why a C data-layer call reported failure. Each constructor carries+-- the C entry point that refused.+data CStatusError+ = -- | The call ran outside the 'Nix.Eval.Arena.arenaInit' ..+ -- 'Nix.Eval.Arena.arenaDestroy' window. A setup diagnosis to act+ -- on by adding the bracket and starting the process again, not an+ -- error to catch and retry in place: GHC updates a thunk whose+ -- evaluation raised with that exception, so any shared value whose+ -- evaluation reached C before the window re-raises this inside a+ -- later, live arena. The @null@, @true@ and @false@ thunks are C+ -- statics rather than arena cells, so they are not among them.+ ArenaNotInitialized !String+ | -- | The arena is live and the allocation itself failed: exhaustion,+ -- or a size the C side rejects. The second field is the detail.+ CAllocationFailed !String !String+ | -- | The C side refused a state transition the boundary had just made+ -- valid, or reported a state no transition produces. A defect in+ -- this library, never a resource failure; the second field says+ -- which state was wrong.+ CInvariantViolated !String !String+ deriving (Eq, Show)++instance Exception CStatusError where+ displayException (ArenaNotInitialized site) =+ site+ ++ ": the C data layer is not initialized; evaluation must run between "+ ++ "Nix.Eval.Arena.arenaInit and Nix.Eval.Arena.arenaDestroy"+ displayException (CAllocationFailed site detail) = site ++ ": " ++ detail+ displayException (CInvariantViolated site detail) =+ site ++ ": internal invariant violated: " ++ detail++-- ---------------------------------------------------------------------------+-- Checks+-- ---------------------------------------------------------------------------++-- | Reject a NULL from a C allocator before it can flow onward as a+-- pointer: the C side signals failure deliberately, and the next+-- dereference would be undefined behaviour in a release build.+checkedCPtr :: String -> Ptr a -> IO (Ptr a)+checkedCPtr site ptr+ | ptr == nullPtr = cStatusFailure site "C allocation failed"+ | otherwise = pure ptr++-- | Raise the 'CStatusError' for a sentinel @site@ just returned. Asked+-- right after the failing call and before anything else touches the+-- arena, so the answer describes the state that call saw.+cStatusFailure :: String -> String -> IO a+cStatusFailure site detail = do+ live <- arenaLive+ throwIO (if live then CAllocationFailed site detail else ArenaNotInitialized site)++-- | Raise 'CInvariantViolated' for a transition @site@ refused. Unlike+-- 'cStatusFailure' it does not ask whether the arena is live: the caller+-- holds a cell the arena handed out, so liveness is not the question.+cInvariantViolated :: String -> String -> IO a+cInvariantViolated site detail = throwIO (CInvariantViolated site detail)++-- ---------------------------------------------------------------------------+-- Arena state+-- ---------------------------------------------------------------------------++-- | Whether every C sub-arena is inside its init .. destroy window.+arenaLive :: IO Bool+arenaLive = (/= 0) <$> c_nn_arena_live
src/Nix/Eval/CThunk.hs view
@@ -14,8 +14,8 @@ -- cthunkInit 0 -- ptr <- cthunkNew pendingStablePtr -- cthunkState ptr -- 0 (PENDING)--- cthunkMarkBlackhole ptr -- 1 (success)--- cthunkSetComputed ptr valueStablePtr -- returns old pending ptr+-- cthunkMarkBlackhole ptr -- True (was PENDING)+-- cthunkSetComputed ptr valueStablePtr -- True (was not yet COMPUTED) -- cthunkState ptr -- 1 (COMPUTED) -- cthunkDestroy -- @@@ -33,8 +33,6 @@ cthunkNewComputed, cthunkNewComputedInt, cthunkNewComputedFloat,- cthunkNewComputedBool,- cthunkNewComputedNull, cthunkNewComputedStr, cthunkNewComputedPath, cthunkNewComputedList,@@ -42,6 +40,10 @@ cthunkNewComputedCtxStr, cthunkNewComputedLambda, + -- * Shared constants+ cthunkNull,+ cthunkBool,+ -- * State queries cthunkState, cthunkPayload,@@ -83,6 +85,7 @@ import Data.Word (Word32, Word8) import Foreign.C.Types (CDouble (..), CInt (..)) import Foreign.Ptr (Ptr, nullPtr)+import Nix.Eval.CStatus (checkedCPtr) -- | Phantom type for C-side @nn_thunk_t@. data NnThunk@@ -107,7 +110,7 @@ c_nn_thunk_get_bc_idx :: CThunkPtr -> IO Word32 foreign import ccall unsafe "nn_thunk_set_payload"- c_nn_thunk_set_payload :: CThunkPtr -> Ptr () -> IO ()+ c_nn_thunk_set_payload :: CThunkPtr -> Ptr () -> IO CInt foreign import ccall unsafe "nn_thunk_new_computed" c_nn_thunk_new_computed :: Ptr () -> IO CThunkPtr@@ -118,12 +121,6 @@ foreign import ccall unsafe "nn_thunk_new_computed_float" c_nn_thunk_new_computed_float :: CDouble -> IO CThunkPtr -foreign import ccall unsafe "nn_thunk_new_computed_bool"- c_nn_thunk_new_computed_bool :: Word8 -> IO CThunkPtr--foreign import ccall unsafe "nn_thunk_new_computed_null"- c_nn_thunk_new_computed_null :: IO CThunkPtr- foreign import ccall unsafe "nn_thunk_new_computed_str" c_nn_thunk_new_computed_str :: Word32 -> IO CThunkPtr @@ -142,6 +139,17 @@ foreign import ccall unsafe "nn_thunk_new_computed_lambda" c_nn_thunk_new_computed_lambda :: Ptr () -> IO CThunkPtr +-- The shared cells are C statics, one address for the life of the+-- process, so these imports are pure: no arena window, nor its absence,+-- changes what they return.++-- | The shared COMPUTED @null@ thunk.+foreign import ccall unsafe "nn_thunk_null"+ cthunkNull :: CThunkPtr++foreign import ccall unsafe "nn_thunk_bool"+ c_nn_thunk_bool :: Word8 -> CThunkPtr+ foreign import ccall unsafe "nn_thunk_state" c_nn_thunk_state :: CThunkPtr -> IO Word8 @@ -185,37 +193,37 @@ 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 ())+ c_nn_thunk_set_computed :: CThunkPtr -> Ptr () -> IO CInt foreign import ccall unsafe "nn_thunk_set_computed_int"- c_nn_thunk_set_computed_int :: CThunkPtr -> Int64 -> IO (Ptr ())+ c_nn_thunk_set_computed_int :: CThunkPtr -> Int64 -> IO CInt foreign import ccall unsafe "nn_thunk_set_computed_float"- c_nn_thunk_set_computed_float :: CThunkPtr -> CDouble -> IO (Ptr ())+ c_nn_thunk_set_computed_float :: CThunkPtr -> CDouble -> IO CInt foreign import ccall unsafe "nn_thunk_set_computed_bool"- c_nn_thunk_set_computed_bool :: CThunkPtr -> Word8 -> IO (Ptr ())+ c_nn_thunk_set_computed_bool :: CThunkPtr -> Word8 -> IO CInt foreign import ccall unsafe "nn_thunk_set_computed_null"- c_nn_thunk_set_computed_null :: CThunkPtr -> IO (Ptr ())+ c_nn_thunk_set_computed_null :: CThunkPtr -> IO CInt foreign import ccall unsafe "nn_thunk_set_computed_str"- c_nn_thunk_set_computed_str :: CThunkPtr -> Word32 -> IO (Ptr ())+ c_nn_thunk_set_computed_str :: CThunkPtr -> Word32 -> IO CInt foreign import ccall unsafe "nn_thunk_set_computed_path"- c_nn_thunk_set_computed_path :: CThunkPtr -> Word32 -> IO (Ptr ())+ c_nn_thunk_set_computed_path :: CThunkPtr -> Word32 -> IO CInt foreign import ccall unsafe "nn_thunk_set_computed_list"- c_nn_thunk_set_computed_list :: CThunkPtr -> Ptr () -> IO (Ptr ())+ c_nn_thunk_set_computed_list :: CThunkPtr -> Ptr () -> IO CInt foreign import ccall unsafe "nn_thunk_set_computed_attrs"- c_nn_thunk_set_computed_attrs :: CThunkPtr -> Ptr () -> IO (Ptr ())+ c_nn_thunk_set_computed_attrs :: CThunkPtr -> Ptr () -> IO CInt foreign import ccall unsafe "nn_thunk_set_computed_ctxstr"- c_nn_thunk_set_computed_ctxstr :: CThunkPtr -> Ptr () -> IO (Ptr ())+ c_nn_thunk_set_computed_ctxstr :: CThunkPtr -> Ptr () -> IO CInt foreign import ccall unsafe "nn_thunk_set_computed_lambda"- c_nn_thunk_set_computed_lambda :: CThunkPtr -> Ptr () -> IO (Ptr ())+ c_nn_thunk_set_computed_lambda :: CThunkPtr -> Ptr () -> IO CInt foreign import ccall unsafe "nn_thunk_count" c_nn_thunk_count :: IO Word32@@ -242,67 +250,78 @@ -- Allocation -- --------------------------------------------------------------------------- +-- Every allocator below rejects the C side's NULL through 'checkedCPtr':+-- outside the 'cthunkInit' .. 'cthunkDestroy' window that is the only+-- answer the arena gives, and a NULL that flowed on would fault at the+-- next state read instead of raising 'Nix.Eval.CStatus.ArenaNotInitialized'.+ -- | 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-cthunkNewBc = c_nn_thunk_new_bc+cthunkNewBc bcIdx envPtr = checkedCPtr "nn_thunk_new_bc" =<< c_nn_thunk_new_bc bcIdx envPtr -- | Read the bytecode index from a PENDING thunk. cthunkGetBcIdx :: CThunkPtr -> IO Word32 cthunkGetBcIdx = c_nn_thunk_get_bc_idx --- | Set the payload of a thunk (deferred env fixup for knot-tying).-cthunkSetPayload :: CThunkPtr -> Ptr () -> IO ()-cthunkSetPayload = c_nn_thunk_set_payload+-- | Set the payload of a PENDING thunk (deferred env fixup for+-- knot-tying). 'False' when the thunk is not PENDING and was left as it+-- was, which keeps the shared constant cells unwritten.+cthunkSetPayload :: CThunkPtr -> Ptr () -> IO Bool+cthunkSetPayload ptr payload = accepted <$> c_nn_thunk_set_payload ptr payload -- | Allocate a new pre-COMPUTED thunk with StablePtr payload (complex types). cthunkNewComputed :: Ptr () -> IO CThunkPtr-cthunkNewComputed = c_nn_thunk_new_computed+cthunkNewComputed value = checkedCPtr "nn_thunk_new_computed" =<< c_nn_thunk_new_computed value -- | Allocate a pre-COMPUTED thunk with an inline int64 (no StablePtr). cthunkNewComputedInt :: Int64 -> IO CThunkPtr-cthunkNewComputedInt = c_nn_thunk_new_computed_int+cthunkNewComputedInt n = checkedCPtr "nn_thunk_new_computed_int" =<< c_nn_thunk_new_computed_int n -- | Allocate a pre-COMPUTED thunk with an inline double (no StablePtr). cthunkNewComputedFloat :: Double -> IO CThunkPtr-cthunkNewComputedFloat d = c_nn_thunk_new_computed_float (CDouble d)---- | Allocate a pre-COMPUTED thunk with an inline bool (no StablePtr).-cthunkNewComputedBool :: Word8 -> IO CThunkPtr-cthunkNewComputedBool = c_nn_thunk_new_computed_bool---- | Allocate a pre-COMPUTED thunk with null value (no StablePtr).-cthunkNewComputedNull :: IO CThunkPtr-cthunkNewComputedNull = c_nn_thunk_new_computed_null+cthunkNewComputedFloat d = checkedCPtr "nn_thunk_new_computed_float" =<< c_nn_thunk_new_computed_float (CDouble d) -- | Allocate a pre-COMPUTED thunk with an interned string symbol (no StablePtr). -- For context-free strings only (tag 4). cthunkNewComputedStr :: Word32 -> IO CThunkPtr-cthunkNewComputedStr = c_nn_thunk_new_computed_str+cthunkNewComputedStr sym = checkedCPtr "nn_thunk_new_computed_str" =<< c_nn_thunk_new_computed_str sym -- | Allocate a pre-COMPUTED thunk with an interned path symbol (no StablePtr). cthunkNewComputedPath :: Word32 -> IO CThunkPtr-cthunkNewComputedPath = c_nn_thunk_new_computed_path+cthunkNewComputedPath sym = checkedCPtr "nn_thunk_new_computed_path" =<< c_nn_thunk_new_computed_path sym -- | Allocate a pre-COMPUTED thunk with a CList pointer (no StablePtr). cthunkNewComputedList :: Ptr () -> IO CThunkPtr-cthunkNewComputedList = c_nn_thunk_new_computed_list+cthunkNewComputedList list = checkedCPtr "nn_thunk_new_computed_list" =<< c_nn_thunk_new_computed_list list -- | Allocate a pre-COMPUTED thunk with a CAttrSet pointer (no StablePtr). cthunkNewComputedAttrs :: Ptr () -> IO CThunkPtr-cthunkNewComputedAttrs = c_nn_thunk_new_computed_attrs+cthunkNewComputedAttrs attrs = checkedCPtr "nn_thunk_new_computed_attrs" =<< c_nn_thunk_new_computed_attrs attrs -- | Allocate a pre-COMPUTED thunk with a CCtxStr pointer (no StablePtr). -- For strings with non-empty context (tag 8). cthunkNewComputedCtxStr :: Ptr () -> IO CThunkPtr-cthunkNewComputedCtxStr = c_nn_thunk_new_computed_ctxstr+cthunkNewComputedCtxStr ctxstr = checkedCPtr "nn_thunk_new_computed_ctxstr" =<< c_nn_thunk_new_computed_ctxstr ctxstr -- | Allocate a pre-COMPUTED thunk with a CLambda pointer (no StablePtr). -- For lambda closures (tag 9). cthunkNewComputedLambda :: Ptr () -> IO CThunkPtr-cthunkNewComputedLambda = c_nn_thunk_new_computed_lambda+cthunkNewComputedLambda lambda = checkedCPtr "nn_thunk_new_computed_lambda" =<< c_nn_thunk_new_computed_lambda lambda -- ---------------------------------------------------------------------------+-- Shared constants+-- ---------------------------------------------------------------------------++-- | The shared COMPUTED @true@ or @false@ thunk.+cthunkBool :: Bool -> CThunkPtr+cthunkBool b = if b then trueCell else falseCell++trueCell, falseCell :: CThunkPtr+trueCell = c_nn_thunk_bool 1+falseCell = c_nn_thunk_bool 0++-- --------------------------------------------------------------------------- -- State queries -- --------------------------------------------------------------------------- @@ -366,63 +385,67 @@ -- | Mark a PENDING thunk as BLACKHOLE (being evaluated). -- Returns 'True' on success, 'False' if not PENDING. cthunkMarkBlackhole :: CThunkPtr -> IO Bool-cthunkMarkBlackhole ptr = do- result <- c_nn_thunk_mark_blackhole ptr- pure (result /= 0)+cthunkMarkBlackhole ptr = accepted <$> c_nn_thunk_mark_blackhole ptr -- | 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)+cthunkMarkPending ptr = accepted <$> c_nn_thunk_mark_pending ptr --- | Set a BLACKHOLE thunk to COMPUTED with a StablePtr value (complex types).--- Returns the old payload (pending StablePtr to free), or 'nullPtr'--- if the thunk was not in BLACKHOLE state.-cthunkSetComputed :: CThunkPtr -> Ptr () -> IO (Ptr ())-cthunkSetComputed = c_nn_thunk_set_computed+-- The setters below store a value in a thunk that is not yet COMPUTED and+-- answer 'True', or leave a COMPUTED thunk as it was and answer 'False'.+-- They do not hand back the pending payload: the forcing caller read it+-- to evaluate the thunk, and frees that StablePtr itself. --- | Set a BLACKHOLE thunk to COMPUTED with an inline int64 (no StablePtr).-cthunkSetComputedInt :: CThunkPtr -> Int64 -> IO (Ptr ())-cthunkSetComputedInt = c_nn_thunk_set_computed_int+-- | Store a StablePtr value (complex types).+cthunkSetComputed :: CThunkPtr -> Ptr () -> IO Bool+cthunkSetComputed ptr value = accepted <$> c_nn_thunk_set_computed ptr value --- | Set a BLACKHOLE thunk to COMPUTED with an inline double (no StablePtr).-cthunkSetComputedFloat :: CThunkPtr -> Double -> IO (Ptr ())-cthunkSetComputedFloat ptr d = c_nn_thunk_set_computed_float ptr (CDouble d)+-- | Store an inline int64 (no StablePtr).+cthunkSetComputedInt :: CThunkPtr -> Int64 -> IO Bool+cthunkSetComputedInt ptr n = accepted <$> c_nn_thunk_set_computed_int ptr n --- | Set a BLACKHOLE thunk to COMPUTED with an inline bool (no StablePtr).-cthunkSetComputedBool :: CThunkPtr -> Word8 -> IO (Ptr ())-cthunkSetComputedBool = c_nn_thunk_set_computed_bool+-- | Store an inline double (no StablePtr).+cthunkSetComputedFloat :: CThunkPtr -> Double -> IO Bool+cthunkSetComputedFloat ptr d = accepted <$> c_nn_thunk_set_computed_float ptr (CDouble d) --- | Set a BLACKHOLE thunk to COMPUTED with null value (no StablePtr).-cthunkSetComputedNull :: CThunkPtr -> IO (Ptr ())-cthunkSetComputedNull = c_nn_thunk_set_computed_null+-- | Store an inline bool (no StablePtr).+cthunkSetComputedBool :: CThunkPtr -> Word8 -> IO Bool+cthunkSetComputedBool ptr b = accepted <$> c_nn_thunk_set_computed_bool ptr b --- | Set a non-COMPUTED thunk to COMPUTED with an interned string symbol.-cthunkSetComputedStr :: CThunkPtr -> Word32 -> IO (Ptr ())-cthunkSetComputedStr = c_nn_thunk_set_computed_str+-- | Store null (no StablePtr).+cthunkSetComputedNull :: CThunkPtr -> IO Bool+cthunkSetComputedNull ptr = accepted <$> c_nn_thunk_set_computed_null ptr --- | Set a non-COMPUTED thunk to COMPUTED with an interned path symbol.-cthunkSetComputedPath :: CThunkPtr -> Word32 -> IO (Ptr ())-cthunkSetComputedPath = c_nn_thunk_set_computed_path+-- | Store an interned string symbol.+cthunkSetComputedStr :: CThunkPtr -> Word32 -> IO Bool+cthunkSetComputedStr ptr sym = accepted <$> c_nn_thunk_set_computed_str ptr sym --- | Set a non-COMPUTED thunk to COMPUTED with a CList pointer.-cthunkSetComputedList :: CThunkPtr -> Ptr () -> IO (Ptr ())-cthunkSetComputedList = c_nn_thunk_set_computed_list+-- | Store an interned path symbol.+cthunkSetComputedPath :: CThunkPtr -> Word32 -> IO Bool+cthunkSetComputedPath ptr sym = accepted <$> c_nn_thunk_set_computed_path ptr sym --- | Set a non-COMPUTED thunk to COMPUTED with a CAttrSet pointer.-cthunkSetComputedAttrs :: CThunkPtr -> Ptr () -> IO (Ptr ())-cthunkSetComputedAttrs = c_nn_thunk_set_computed_attrs+-- | Store a CList pointer.+cthunkSetComputedList :: CThunkPtr -> Ptr () -> IO Bool+cthunkSetComputedList ptr list = accepted <$> c_nn_thunk_set_computed_list ptr list --- | Set a non-COMPUTED thunk to COMPUTED with a CCtxStr pointer.-cthunkSetComputedCtxStr :: CThunkPtr -> Ptr () -> IO (Ptr ())-cthunkSetComputedCtxStr = c_nn_thunk_set_computed_ctxstr+-- | Store a CAttrSet pointer.+cthunkSetComputedAttrs :: CThunkPtr -> Ptr () -> IO Bool+cthunkSetComputedAttrs ptr attrs = accepted <$> c_nn_thunk_set_computed_attrs ptr attrs --- | Set a non-COMPUTED thunk to COMPUTED with a CLambda pointer.-cthunkSetComputedLambda :: CThunkPtr -> Ptr () -> IO (Ptr ())-cthunkSetComputedLambda = c_nn_thunk_set_computed_lambda+-- | Store a CCtxStr pointer.+cthunkSetComputedCtxStr :: CThunkPtr -> Ptr () -> IO Bool+cthunkSetComputedCtxStr ptr ctxstr = accepted <$> c_nn_thunk_set_computed_ctxstr ptr ctxstr++-- | Store a CLambda pointer.+cthunkSetComputedLambda :: CThunkPtr -> Ptr () -> IO Bool+cthunkSetComputedLambda ptr lambda = accepted <$> c_nn_thunk_set_computed_lambda ptr lambda++-- | A transition's C answer: 1 when it ran, 0 when the thunk's state+-- refused it.+accepted :: CInt -> Bool+accepted = (/= 0) -- --------------------------------------------------------------------------- -- Arena diagnostics / cleanup
+ src/Nix/Eval/CallDepth.hs view
@@ -0,0 +1,67 @@+-- | The function-call nesting ceiling: upstream's @max-call-depth@.+--+-- @EvalState::callFunction@ keeps a count of the frames active around the+-- current evaluation and refuses a new call once that count exceeds+-- @settings.maxCallDepth@ (eval.cc at 2.24.9). The check runs on entry,+-- before the callee is even forced, so the count is the number of frames+-- ALREADY active: with a ceiling of @n@, @n + 1@ nested calls succeed and+-- the @n + 2@-th is refused. The default is 10000 (eval-settings.hh).+--+-- What costs a frame is whatever upstream routes through @callFunction@:+-- every application of a lambda, a builtin (also when partially applied),+-- a functor (two frames, the inner call being a @callFunction@ of its own),+-- a @__toString@ coercion, and the operators upstream's parser desugars+-- into builtin calls (@-@, @*@, @/@, @<@, @<=@, @>@, @>=@ and unary @-@).+-- Forcing a thunk costs nothing, so a value forced inside a frame is+-- evaluated at that frame's depth. A builtin's deferred application+-- (upstream's app value, as @map@, @genList@, @mapAttrs@ and+-- @zipAttrsWith@ build it) is applied where its result is forced, and the+-- frame opens before its function is forced, so+-- @mapAttrs@'s @f name value@, two nested app values upstream, runs the+-- name application a frame below the value application. A+-- @builtins.sort@ whose comparator is @builtins.lessThan@ itself opens no+-- frame per comparison: upstream's @prim_sort@ bypasses @callFunction@+-- for it.+--+-- The count is evaluator state, carried per frame by each 'Nix.Eval.Types.MonadEval'+-- instance; this module holds the rule and the setting's default so the+-- evaluators and the config cascade agree on both.+module Nix.Eval.CallDepth+ ( CallDepth (..),+ defaultMaxCallDepth,+ topLevelCallDepth,+ enterCallFrame,+ maxCallDepthExceeded,+ )+where++import Data.Text (Text)+import Data.Word (Word32)++-- | The frames active around the current evaluation, and the ceiling they+-- may not exceed. 'Word32' because upstream's setting is an+-- @unsigned int@, so a configured ceiling has exactly that range.+data CallDepth = CallDepth+ { cdActive :: !Word32,+ cdCeiling :: !Word32+ }+ deriving (Eq, Show)++-- | Upstream's default @max-call-depth@.+defaultMaxCallDepth :: Word32+defaultMaxCallDepth = 10000++-- | No frames active, under the given ceiling: where an evaluation starts.+topLevelCallDepth :: Word32 -> CallDepth+topLevelCallDepth limit = CallDepth {cdActive = 0, cdCeiling = limit}++-- | The depth a new call frame runs at, or upstream's error when the frames+-- already active exceed the ceiling.+enterCallFrame :: CallDepth -> Either Text CallDepth+enterCallFrame depth@(CallDepth active limit)+ | active > limit = Left maxCallDepthExceeded+ | otherwise = Right depth {cdActive = active + 1}++-- | Upstream's message, verbatim.+maxCallDepthExceeded :: Text+maxCallDepthExceeded = "stack overflow; max-call-depth exceeded"
src/Nix/Eval/CanonPath.hs view
@@ -18,6 +18,10 @@ -- 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 @.@.+--+-- "Nix.Config" applies the same collapse to the native paths its includes+-- and @NIX_CONF_DIR@ name, over 'System.OsPath.OsPath' units rather than+-- text, since a native path there is bytes that text cannot hold. module Nix.Eval.CanonPath ( canonPath, canonPathValue,
src/Nix/Eval/Compile.hs view
@@ -15,6 +15,7 @@ decodeBcFormals, decodeBcCaptureInfo, BcBinding (..),+ BcInheritedName (..), BcAttrKey (..), decodeBcBindings, reassembleInt64,@@ -28,7 +29,8 @@ import Data.Word (Word16, Word32, Word64, Word8) import GHC.Float (castDoubleToWord64, castWord64ToDouble) import Nix.Eval.CBytecode- ( attrkeyDynamic,+ ( appDirect,+ attrkeyDynamic, attrkeyStatic, binaryAdd, binaryAnd,@@ -46,6 +48,7 @@ binarySub, binaryUpdate, bindInherit,+ bindInheritFrom, bindNamed, captureNone, captureSlots,@@ -57,6 +60,7 @@ formalNamedSet, formalSet, spilledCountSentinel,+ strpartEsc, strpartInterp, strpartLit, unaryNegate,@@ -122,10 +126,7 @@ go (ESelect target path defExpr) = compileSelect target path defExpr go (EHasAttr target path) = compileHasAttr target path- go (EApp func arg) = do- funcIdx <- go func- argIdx <- go arg- cbcEmit OpApp 0 0 funcIdx argIdx 0+ go (EApp func arg) = compileApp func arg go (ELambda formals body captureInfo) = compileLambda formals body captureInfo go (ELet bindings body captureInfo) =@@ -177,6 +178,16 @@ cbcEmit op 0 0 sym 0 0 -- -----------------------------------------------------------------+ -- Applications (EApp)+ -- -----------------------------------------------------------------++ compileApp :: Expr -> Expr -> IO Word32+ compileApp func arg = do+ funcIdx <- go func+ argIdx <- go arg+ cbcEmit OpApp appDirect 0 funcIdx argIdx 0++ -- ----------------------------------------------------------------- -- Strings (EStr / EIndStr) -- ----------------------------------------------------------------- @@ -190,6 +201,9 @@ compileOnePart (StrLit t) = do Symbol sym <- symbolIntern t pure (strpartLit, sym)+ compileOnePart (StrEsc t) = do+ Symbol sym <- symbolIntern t+ pure (strpartEsc, sym) compileOnePart (StrInterp expr) = do idx <- go expr pure (strpartInterp, idx)@@ -311,24 +325,32 @@ compileBindingWords bindings = concat <$> mapM compileOneBinding bindings compileOneBinding :: Binding -> IO [Word32]- compileOneBinding (NamedBinding path expr) = do- compiledKeys <- mapM compileAttrKey path+ compileOneBinding (NamedBinding key expr) = do+ (keyKind, keyWord) <- compileAttrKey key valIdx <- go expr- pure $- [bindNamed, fi (length path)]- ++ concatMap (\(t, v) -> [t, v]) compiledKeys- ++ [valIdx]- compileOneBinding (Inherit maybeFrom names) = do- (hasFrom, fromIdx) <- case maybeFrom of- Nothing -> pure (0 :: Word32, 0 :: Word32)- Just fromExpr -> do- idx <- go fromExpr- pure (1, idx)- syms <- mapM internName names+ pure [bindNamed, keyKind, keyWord, valIdx]+ compileOneBinding (Inherit name var) = do+ sym <- internName name+ valIdx <- go var+ pure [bindInherit, sym, valIdx]+ compileOneBinding (InheritFrom fromExpr names) = do+ fromIdx <- go fromExpr+ inherited <- mapM compileInheritedName names pure $- [bindInherit, hasFrom, fromIdx, fi (length names)]- ++ syms+ [bindInheritFrom, fromIdx, fi (length names)]+ ++ concat inherited + -- \| One name of @inherit (from) names@: its symbol, then the select+ -- that reads it from slot 0 of the env the evaluator puts the+ -- from-value in. Compiled here with the binding, as upstream's parser+ -- makes an @ExprSelect@ per name, rather than each time the binding+ -- is evaluated, which would add a copy to a store that only grows.+ compileInheritedName :: Text -> IO [Word32]+ compileInheritedName name = do+ sym <- internName name+ selectIdx <- go (ESelect (EResolvedVar 0 0) [StaticKey name] Nothing)+ pure [sym, selectIdx]+ internName :: Text -> IO Word32 internName name = do Symbol sym <- symbolIntern name@@ -532,14 +554,20 @@ -- | A decoded binding from the bytecode data buffer. data BcBinding- = -- | @path = expr@ with attr path keys and value bc_idx- BcNamed ![BcAttrKey] !Word32- | -- | @inherit names@ from surrounding scope (symbol ids)- BcInherit ![Word32]- | -- | @inherit (from) names@ with from-expr bc_idx and name symbols- BcInheritFrom !Word32 ![Word32]+ = -- | @key = expr@: the key and the value's bc_idx+ BcNamed !BcAttrKey !Word32+ | -- | One name of @inherit x@: its symbol and the bc_idx of the variable+ -- it copies, which is evaluated in the env around the binding set+ BcInherit !Word32 !Word32+ | -- | @inherit (from) names@ with from-expr bc_idx and the names+ BcInheritFrom !Word32 ![BcInheritedName] --- | An attribute key in a binding path.+-- | One name of an @inherit (from) names@ binding: its symbol and the+-- bc_idx of the select that reads it from slot 0 of an env holding the+-- from-value.+data BcInheritedName = BcInheritedName !Word32 !Word32++-- | A binding's attribute key. data BcAttrKey = -- | Static key (interned symbol) BcStaticKey !Word32@@ -562,43 +590,37 @@ tag <- cbcData off case tag of 0 {- NamedBinding -} -> do- pathLen <- cbcData (off + 1)- let keyStart = off + 2- keyWords = fromIntegral pathLen * 2- valOff = keyStart + keyWords- keys <- decodeAttrKeys (fromIntegral pathLen) keyStart- valBcIdx <- cbcData valOff- pure (BcNamed keys valBcIdx, valOff + 1)+ key <- decodeAttrKey (off + 1)+ valBcIdx <- cbcData (off + 3)+ pure (BcNamed key valBcIdx, off + 4) 1 {- Inherit -} -> do- hasFrom <- cbcData (off + 1)- fromBcIdx <- cbcData (off + 2)- nameCount <- cbcData (off + 3)- syms <- decodeSymList (fromIntegral nameCount) (off + 4)- let nextOff = off + 4 + nameCount- if hasFrom /= 0- then pure (BcInheritFrom fromBcIdx syms, nextOff)- else pure (BcInherit syms, nextOff)+ sym <- cbcData (off + 1)+ valBcIdx <- cbcData (off + 2)+ pure (BcInherit sym valBcIdx, off + 3)+ 2 {- InheritFrom -} -> do+ fromBcIdx <- cbcData (off + 1)+ nameCount <- cbcData (off + 2)+ names <- decodeInheritedNames (fromIntegral nameCount) (off + 3)+ pure (BcInheritFrom fromBcIdx names, off + 3 + 2 * nameCount) -- Unreachable: the tag is written only by this module's binding compiler,- -- which emits 0 or 1 - both matched above.+ -- which emits 0, 1 or 2 - all matched above. _ -> error "decodeOneBinding: invalid binding type tag" --- | Decode attr path keys: pairs of (is_expr, key_or_bc_idx).-decodeAttrKeys :: Int -> Word32 -> IO [BcAttrKey]-decodeAttrKeys 0 _ = pure []-decodeAttrKeys n off = do+-- | Decode a binding's key: the pair (is_expr, key_or_bc_idx).+decodeAttrKey :: Word32 -> IO BcAttrKey+decodeAttrKey off = do isExpr <- cbcData off val <- cbcData (off + 1)- rest <- decodeAttrKeys (n - 1) (off + 2)- let key = if isExpr /= 0 then BcDynamicKey val else BcStaticKey val- pure (key : rest)+ pure (if isExpr /= 0 then BcDynamicKey val else BcStaticKey val) --- | Decode a list of symbol IDs from the data buffer.-decodeSymList :: Int -> Word32 -> IO [Word32]-decodeSymList 0 _ = pure []-decodeSymList n off = do+-- | Decode inherited names: pairs of (name_sym, select_bc_idx).+decodeInheritedNames :: Int -> Word32 -> IO [BcInheritedName]+decodeInheritedNames 0 _ = pure []+decodeInheritedNames n off = do sym <- cbcData off- rest <- decodeSymList (n - 1) (off + 1)- pure (sym : rest)+ selectBcIdx <- cbcData (off + 1)+ rest <- decodeInheritedNames (n - 1) (off + 2)+ pure (BcInheritedName sym selectBcIdx : rest) -- --------------------------------------------------------------------------- -- Numeric reassembly (from two uint32 halves)
src/Nix/Eval/Context.hs view
@@ -13,6 +13,7 @@ -- * Queries contextIsEmpty,+ firstContextEncoded, -- * Extraction (for derivation building) extractInputSrcs,@@ -25,12 +26,16 @@ ) where +import Data.Foldable (minimumBy)+import qualified Data.List.NonEmpty as NE import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map+import Data.Ord (comparing) import qualified Data.Set as Set import Data.Text (Text)+import qualified Data.Text as T import Nix.Eval.Types (StringContext (..), StringContextElement (..))-import Nix.Store.Path (StorePath)+import Nix.Store.Path (StorePath (spHash, spName)) -- --------------------------------------------------------------------------- -- Construction@@ -55,6 +60,26 @@ -- | Check whether a string context is empty (no store path references). contextIsEmpty :: StringContext -> Bool contextIsEmpty (StringContext s) = Set.null s++-- | The element of a context upstream names first, in the encoding it+-- prints (@NixStringContextElem::to_string@ at 2.24.9): a plain path as+-- its base name, all outputs of a derivation as @=@ and the .drv's, one+-- output as @!output!@ and the .drv's. Upstream keeps a context in a+-- @std::set@ of a variant ordered plain, all outputs, one output, which+-- is not this type's constructor order, so that order is searched here.+firstContextEncoded :: StringContext -> Maybe Text+firstContextEncoded (StringContext elems) =+ encoded . minimumBy (comparing upstreamOrder) <$> NE.nonEmpty (Set.toList elems)+ where+ upstreamOrder element = case element of+ SCPlain sp -> (0 :: Int, sp, T.empty)+ SCAllOutputs sp -> (1, sp, T.empty)+ SCDrvOutput sp output -> (2, sp, output)+ encoded element = case element of+ SCPlain sp -> baseName sp+ SCAllOutputs sp -> "=" <> baseName sp+ SCDrvOutput sp output -> "!" <> output <> "!" <> baseName sp+ baseName sp = spHash sp <> "-" <> spName sp -- --------------------------------------------------------------------------- -- Extraction
src/Nix/Eval/IO.hs view
@@ -11,58 +11,71 @@ -- -- @ -- st <- newEvalState "/path/to/project"--- result <- runEvalIO st (eval (builtinEnv 0 []) expr)+-- result <- runEvalIO st (eval (builtinEnv unrestrictedPolicy 0 []) expr) -- @ module Nix.Eval.IO ( -- * Evaluator EvalIO, runEvalIO,+ runEvalIOTraced, -- * State EvalState (..), newEvalState,+ allowEvalPath, -- * Errors EvalErrorKind (..),+ EvalFailure (..),+ renderEvalFailure, NixEvalError (..), NixAbortError (..), ) where -import Control.Exception (Exception, IOException, SomeAsyncException, SomeException, displayException, fromException, onException, throwIO, try)-import Control.Monad (unless, when)+import Control.Exception (Exception, Handler (..), IOException, SomeAsyncException, SomeException, catch, catches, displayException, fromException, onException, throwIO, try)+import Control.Monad (unless, void, 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.Either (fromRight, isRight) import Data.IORef (IORef, modifyIORef', newIORef, readIORef) import Data.Int (Int64) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map+import Data.Maybe (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 Data.Time.Clock.POSIX (getPOSIXTime)-import Foreign.Ptr (Ptr, castPtr, nullPtr)+import Foreign.Ptr (castPtr) import Foreign.StablePtr (castPtrToStablePtr, castStablePtrToPtr, deRefStablePtr, freeStablePtr, newStablePtr)-import Nix.Builtins (builtinEnv, builtinEnvWithScope, parseNixPath)+import Nix.Builtins (builtinEnv, builtinEnvWithScope, parseNixPath, rootScopeNames) import Nix.Derivation (fromATerm)+import Nix.Environment (EnvLookup (..), lookupEnvBytes) import Nix.Eval (eval) import Nix.Eval.CList (CList (..))+import Nix.Eval.CStatus (cInvariantViolated) 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.CallDepth (CallDepth, defaultMaxCallDepth, enterCallFrame, topLevelCallDepth) import Nix.Eval.CanonPath (canonBaseName, canonPath, canonPathValue)+import Nix.Eval.Policy (AllowedPaths, EvalPolicy (..), allowPathIn, forbiddenPathMessage, isAbsolutePath, isAllowedPath, isAllowedPrefix, joinComponents, noAllowedPaths, pathComponents, pathsRestricted, unrestrictedPolicy, uriAccess)+import Nix.Eval.SourceError (SourceAccess (..), sourceErrorMessage) 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.Eval.Types (AttrSet (..), Env (..), MonadEval (..), NixValue (..), PathExistence (..), Thunk (..), attrSetSize, bytesToTextLossy, emptyContext, marshalLambda, marshalStringContext, storePathOrThrow, unmarshalLambdaValue, unmarshalStringContext, pattern ThunkBlackhole, pattern ThunkComputed, pattern ThunkPending, pattern ValueAttrs, pattern ValueBool, pattern ValueCtxStr, pattern ValueFloat, pattern ValueInt, pattern ValueLambda, pattern ValueList, pattern ValueNull, pattern ValuePath, pattern ValueStr)+import Nix.Expr.Resolve (undefinedVariableMessage) import Nix.Hash (bytesToHexText, makeFixedOutputPath, makeTextPath, sha256Digest)-import Nix.Parser (parseNix, readFileAutoEncoding)-import Nix.Store (copyPathInto, unpackNarEntry)+import Nix.Parser (SourceError (..), parseNixWithScope, readFileAutoEncoding)+import Nix.Store (unpackNarEntry)+import Nix.Store.CaseSensitive (probeCaseSensitivity, processCaseHack, volumeCaseHack) 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, (</>))+import System.FilePath (isAbsolute, isPathSeparator, isRelative, takeDirectory, (</>)) import System.IO (hPutStrLn, stderr) import qualified System.Process as Proc @@ -78,18 +91,51 @@ data EvalErrorKind = ErrorThrown | ErrorUncatchable deriving (Eq, Show) +-- | A failure's message and the lines of context it gathered unwinding+-- ('addErrorTrace'), outermost first: upstream's @ErrorInfo@ message and+-- traces, without positions.+data EvalFailure = EvalFailure+ { failureTrace :: ![Text],+ failureMessage :: !Text+ }+ deriving (Eq, Show)++-- | A failure with nothing gathered yet.+untracedFailure :: Text -> EvalFailure+untracedFailure = EvalFailure []+ -- | Evaluation error surfaced as an IO exception.-data NixEvalError = NixEvalError !EvalErrorKind !Text+data NixEvalError = NixEvalError !EvalErrorKind !EvalFailure deriving (Show) instance Exception NixEvalError -- | Abort error - NOT catchable by tryEval (matches real Nix semantics).-newtype NixAbortError = NixAbortError Text+newtype NixAbortError = NixAbortError EvalFailure deriving (Show) instance Exception NixAbortError +-- | A failure as upstream's @showErrorInfo@ prints it (error.cc at+-- 2.24.9) when no line has a position: each line of context after an+-- ellipsis and a blank line, then the message after an @error:@ of its+-- own. The report is upstream's @indent@: the first line follows+-- @error: @, every later line is indented to align with it, and each line+-- loses its trailing whitespace.+renderEvalFailure :: EvalFailure -> Text+renderEvalFailure (EvalFailure trace msg) =+ T.intercalate "\n" (zipWith indentLine (errorPrefix : repeat continuation) (T.splitOn "\n" (chomp report)))+ where+ report = foldMap traceLine trace <> (if null trace then "" else "\n" <> errorPrefix) <> msg+ traceLine line = "\n\x2026 " <> line <> "\n"+ indentLine prefix line = chomp (prefix <> line)+ continuation = T.replicate (T.length errorPrefix) " "+ chomp = T.dropWhileEnd (`elem` [' ', '\n', '\r', '\t'])++-- | What upstream prints before an error's message.+errorPrefix :: Text+errorPrefix = "error: "+ -- --------------------------------------------------------------------------- -- State -- ---------------------------------------------------------------------------@@ -130,12 +176,39 @@ -- writes honor @--store@ the same way the builder does. esStoreDir :: !SP.StoreDir, esTimestamp :: !Int64,- esSearchPaths :: ![Thunk]+ esSearchPaths :: ![Thunk],+ -- | The call frames active around the current evaluation and the+ -- @max-call-depth@ ceiling. Per frame like 'esBaseDir':+ -- 'withCallFrame' scopes it with 'local', so a failure unwinding+ -- through a frame leaves the count it found, upstream's RAII guard+ -- with nothing to restore.+ esCallDepth :: !CallDepth,+ -- | What this evaluation may reach: upstream's @restrict-eval@ and+ -- @pure-eval@ settings and the @allowed-uris@ list. Unrestricted+ -- unless the caller says otherwise, as upstream's defaults are.+ esPolicy :: !EvalPolicy,+ -- | The allowed path prefixes under a restricting policy: the search+ -- path roots the caller seeded with 'allowEvalPath', plus every store+ -- object this evaluation copies, fetches or writes (upstream's+ -- @allowPath@ after each such step), plus the fetchers' scratch+ -- directories. Mutable because it grows as evaluation proceeds, like+ -- the import cache.+ esAllowedPaths :: !(IORef AllowedPaths) } -- | 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.+-- Reads @NIX_PATH@ from the environment to populate search paths, its+-- bytes unchanged, as upstream's @initGC@ reads it+-- (src/libexpr/eval-gc.cc at Nix 2.24.9). The one refusal is a Windows+-- @NIX_PATH@ holding an unpaired UTF-16 surrogate, which has no UTF-8+-- form and raises an 'IOException'. The call-depth ceiling starts at+-- upstream's default; a caller with a configured one replaces+-- 'esCallDepth'.+--+-- The state is unrestricted. A restricting policy is set on 'esPolicy',+-- and the roots it should admit are seeded with 'allowEvalPath' before+-- evaluation starts, as upstream's constructor allows its lookup path. newEvalState :: SP.StoreDir -> FilePath -> IO EvalState newEvalState storeDir baseDir = do cache <- newIORef Map.empty@@ -143,11 +216,14 @@ drvClosure <- newIORef Map.empty srcCache <- newIORef Map.empty storeWriteCache <- newIORef Map.empty+ allowedPaths <- newIORef noAllowedPaths now <- floor <$> getPOSIXTime :: IO Int64- nixPathStr <- lookupEnvText "NIX_PATH"- let searchPaths = case nixPathStr of- Just val -> parseNixPath (T.pack val)- Nothing -> []+ nixPath <- lookupEnvBytes nixPathVar+ searchPaths <- case nixPath of+ EnvUnset -> pure []+ EnvValue value -> pure (parseNixPath value)+ EnvUnpairedSurrogate ->+ ioError (userError "NIX_PATH holds an unpaired UTF-16 surrogate, which has no UTF-8 form") pure EvalState { esImportCache = cache,@@ -158,9 +234,22 @@ esBaseDir = baseDir, esStoreDir = storeDir, esTimestamp = now,- esSearchPaths = searchPaths+ esSearchPaths = searchPaths,+ esCallDepth = topLevelCallDepth defaultMaxCallDepth,+ esPolicy = unrestrictedPolicy,+ esAllowedPaths = allowedPaths } +-- | Grant access to a path prefix (upstream @EvalState::allowPath@): the+-- caller seeds the search path roots before evaluation, and the instance+-- adds every store object the evaluation produces. Like upstream's, this+-- is a no-op when nothing restricts access, so an unrestricted evaluation+-- does not accumulate a set it never consults.+allowEvalPath :: EvalState -> Text -> IO ()+allowEvalPath st path =+ when (pathsRestricted (esPolicy st)) $+ modifyIORef' (esAllowedPaths st) (allowPathIn path)+ -- --------------------------------------------------------------------------- -- EvalIO newtype -- ---------------------------------------------------------------------------@@ -174,9 +263,9 @@ -- --------------------------------------------------------------------------- instance MonadEval EvalIO where- throwEvalError msg = EvalIO (liftIO (throwIO (NixEvalError ErrorUncatchable msg)))- throwCatchableError msg = EvalIO (liftIO (throwIO (NixEvalError ErrorThrown msg)))- abortEvaluation msg = EvalIO (liftIO (throwIO (NixAbortError msg)))+ throwEvalError msg = EvalIO (liftIO (throwIO (NixEvalError ErrorUncatchable (untracedFailure msg))))+ throwCatchableError msg = EvalIO (liftIO (throwIO (NixEvalError ErrorThrown (untracedFailure msg))))+ abortEvaluation msg = EvalIO (liftIO (throwIO (NixAbortError (untracedFailure msg)))) -- tryEval semantics: recover from a throw/assert only; an uncatchable -- eval error is rethrown (aborts are a separate exception type and@@ -185,7 +274,7 @@ st <- ask result <- liftIO (try (runReaderT action st)) case result of- Left (NixEvalError ErrorThrown msg) -> pure (Left msg)+ Left (NixEvalError ErrorThrown failure) -> pure (Left (failureMessage failure)) Left err@(NixEvalError ErrorUncatchable _) -> liftIO (throwIO err) Right val -> pure (Right val) @@ -195,11 +284,40 @@ st <- ask liftIO (runReaderT action st `onException` runReaderT cleanup st) - doesPathExist path = evalStoreTextPath path >>= \resolved -> wrapIO (Dir.doesPathExist resolved)+ addErrorTrace line (EvalIO action) = EvalIO $ do+ st <- ask+ liftIO+ ( runReaderT action st+ `catches` [ Handler (\(NixEvalError kind failure) -> throwIO (NixEvalError kind (traced failure))),+ Handler (\(NixAbortError failure) -> throwIO (NixAbortError (traced failure)))+ ]+ )+ where+ traced (EvalFailure trace msg) = EvalFailure (line : trace) msg + withCallFrame (EvalIO action) = EvalIO $ do+ depth <- asks esCallDepth+ either+ (unEvalIO . throwEvalError)+ (\deeper -> local (\s -> s {esCallDepth = deeper}) action)+ (enterCallFrame depth)++ -- A refused path reads as absent, as upstream's pathExists turns a+ -- RestrictedPathError into false. The query decides the walk and the+ -- stat as prim_pathExists does: a plain path is resolved through its+ -- ancestors only and lstat'ed in place, so a symlink exists whatever+ -- it points at; a path that must be a directory is resolved fully and+ -- must stat as one.+ doesPathExist query path = do+ allowed <- pathAllowed (existenceResolution query) path+ if allowed+ then evalStoreTextPath path >>= \resolved -> wrapIO (existsAs query resolved)+ else pure False+ listDirectory path = do+ accessPath path dir <- evalStoreTextPath path- wrapIO $ do+ sourceIOWith ReadDirectory (resolvedForMessage path) $ do entries <- Dir.listDirectory dir mapM (classifyEntry dir) entries @@ -207,6 +325,10 @@ baseDir <- EvalIO (asks esBaseDir) timestamp <- EvalIO (asks esTimestamp) searchPaths <- EvalIO (asks esSearchPaths)+ policy <- EvalIO (asks esPolicy)+ -- Resolved, and so checked, before the cache is consulted, as+ -- upstream resolves the file before its fileEvalCache lookup: a+ -- cached import is no more readable than a fresh one. (target, ioTarget) <- resolveImportTarget baseDir rawPath -- Check import cache (readIORef cannot throw, no wrapIO needed) cacheRef <- EvalIO (asks esImportCache)@@ -214,19 +336,17 @@ case Map.lookup target cache of Just cached -> pure cached Nothing -> do- source <- wrapIO (readFileAutoEncoding ioTarget)+ source <- sourceIO ReadFile (T.pack target) (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))+ case parseNixWithScope (rootScopeNames policy) fileDir (T.pack target) source of+ Left err -> rejectSource "import" target err Right expr -> do -- local sets new base dir for nested imports - pure, exception-safe let nested = EvalIO ( local (\s -> s {esBaseDir = fileDir})- (unEvalIO (eval (builtinEnv timestamp searchPaths) expr))+ (unEvalIO (eval (builtinEnv policy timestamp searchPaths) expr)) ) result <- nested -- Skip caching very large attr sets (e.g. all-packages.nix@@ -240,9 +360,20 @@ wrapIO (modifyIORef' cacheRef (Map.insert target result)) pure result - getEnvVar name = wrapIO $ do- mval <- lookupEnvText (T.unpack name)- pure (maybe "" T.pack mval)+ -- Upstream's getEnv answers empty under either setting+ -- (@prim_getEnv@: @restrictEval || pureEval ? "" : getEnv(name)@).+ -- The read cannot throw, so it needs no wrapIO.+ getEnvVar name = do+ policy <- EvalIO (asks esPolicy)+ if pathsRestricted policy+ then pure BS.empty+ else do+ found <- EvalIO (liftIO (lookupEnvBytes name))+ case found of+ EnvUnset -> pure BS.empty+ EnvValue value -> pure value+ EnvUnpairedSurrogate ->+ throwEvalError ("builtins.getEnv: the value of '" <> bytesToTextLossy name <> "' holds an unpaired UTF-16 surrogate, which has no UTF-8 form") lookupDrvHash key = EvalIO $ do ref <- asks esDrvModuloCache@@ -259,11 +390,11 @@ -- 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.+ -- values keep arbitrary bytes). A file that cannot be read or does not+ -- parse 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))+ result <- liftIO (try (BS.readFile filePath) :: IO (Either IOException BS.ByteString)) pure $ case result of Left _ -> Nothing Right bytes -> either (const Nothing) Just (fromATerm bytes)@@ -277,6 +408,11 @@ closure <- liftIO (readIORef ref) pure (Map.lookup drvPathText closure >>= either (const Nothing) Just . fromATerm) + lookupSessionReferences sp = EvalIO $ do+ ref <- asks esStoreWriteCache+ writes <- liftIO (readIORef ref)+ pure (fst <$> Map.lookup (canonicalStorePathText sp) writes)+ storeSourcePath rawPath = do ref <- EvalIO (asks esSourcePathCache) cached <- EvalIO (liftIO (Map.lookup rawPath <$> readIORef ref))@@ -294,12 +430,24 @@ case SP.checkStorePathName name of Left err -> throwEvalError (copyContext <> ": " <> SP.storePathNameErrorText err) Right () -> pure ()- resolvedSource <- evalStoreTextPath rawPath- entry <- wrapIO (ExecBit.serialiseFromPath resolvedSource)+ accessPath rawPath+ -- The tree behind the path's symlinks, under the path's own name:+ -- upstream's copyPathToStore stores path.resolveSymlinks() under+ -- path.baseName() (eval.cc:2393 at 2.24.9), so a path that is a+ -- link copies what it points to, not the link.+ resolvedText <- resolveSymlinks rawPath+ resolvedSource <- evalStoreTextPath resolvedText+ -- A path already in the store never reaches here (it coerces to+ -- itself), so this is a source, read as the build driver's+ -- restore reads it again.+ entry <- serialiseSource resolvedText processCaseHack resolvedSource let narDigest = sha256Digest (NAR.serialise entry) sp <- storePathOrThrow copyContext (makeFixedOutputPath name "sha256" "recursive" narDigest) let spText = canonicalStorePathText sp EvalIO (liftIO (modifyIORef' ref (Map.insert rawPath spText)))+ -- The copy is readable afterwards, as upstream's copyPathToStore+ -- allows its destination.+ allowPath spText pure spText getCurrentTime = EvalIO (asks esTimestamp)@@ -328,31 +476,43 @@ Dir.removePathForcibly filePath BS.writeFile filePath contents recordStoreWrite storePath refs SP.WriteText+ -- Upstream's toFile returns the path through allowAndSetStorePathString.+ allowPath storePath pure storePath scopedImportFile scope rawPath = do baseDir <- EvalIO (asks esBaseDir) timestamp <- EvalIO (asks esTimestamp) searchPaths <- EvalIO (asks esSearchPaths)+ policy <- EvalIO (asks esPolicy) (target, ioTarget) <- resolveImportTarget baseDir rawPath- source <- wrapIO (readFileAutoEncoding ioTarget)+ source <- sourceIO ReadFile (T.pack target) (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))+ case parseNixWithScope (Set.union (Set.fromList (map fst scope)) (rootScopeNames policy)) fileDir (T.pack target) source of+ Left err -> rejectSource "scopedImport" target err Right expr -> do -- No import cache for scoped imports (different scopes = different results)- let scopedEnv = builtinEnvWithScope timestamp searchPaths scope+ let scopedEnv = builtinEnvWithScope policy timestamp searchPaths scope EvalIO ( local (\s -> s {esBaseDir = fileDir}) (unEvalIO (eval scopedEnv expr)) ) - readFileBytes path = evalStoreTextPath path >>= \resolved -> wrapIO (BS.readFile resolved)+ readFileBytes path = do+ accessPath path+ evalStoreTextPath path >>= \resolved -> sourceIOWith ReadFile (resolvedForMessage path) (BS.readFile resolved) - getFileType path = evalStoreTextPath path >>= \resolved -> wrapIO (classifyPath resolved)+ -- An lstat of the path itself, no symlink walk: upstream's readFileType+ -- realises its argument without resolution, the allow list checks the+ -- path it is handed (so a refusal names the path asked for), and the+ -- posix accessor beneath refuses a symlink anywhere above it, in every+ -- mode, so the type of a path reached through a symlinked directory is+ -- never revealed.+ getFileType path = do+ accessPathDirect path+ refuseSymlinkedAncestor path+ evalStoreTextPath path >>= \resolved -> sourceIO StatPath path (classifyPath resolved) runProcess cmd cmdArgs stdinText = wrapIO $ do let cp =@@ -368,16 +528,27 @@ ExitFailure n -> n pure (code, T.pack stdoutStr, T.pack stderrStr) - 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)+ createScratchDir prefix = do+ dir <- 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)+ -- The evaluator's own scratch area, not ambient filesystem: what a+ -- fetch lands there is read back under the same rule that makes a+ -- fetched store path readable afterwards. Allowed under its resolved+ -- spelling too, because the walk follows symlinks and a temp dir can+ -- sit behind one (macOS's /var is /private/var), and under the given+ -- spelling so the walk may traverse the link's own ancestors.+ resolved <- wrapIO (Dir.canonicalizePath (T.unpack dir))+ allowPath dir+ allowPath (T.pack resolved)+ pure dir removeScratchDir dir = wrapIO (Dir.removePathForcibly (T.unpack dir)) @@ -393,8 +564,13 @@ -- 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)+ -- The tree behind the root's symlinks, under the root's own name, as+ -- upstream's copyPathToStore and addPath store path.resolveSymlinks()+ -- under path.baseName(); the walk checks the policy on the way.+ resolvedText <- resolveSymlinks srcPath+ caseHack <- readCaseHack resolvedText+ resolvedSource <- evalStoreTextPath resolvedText+ entry <- serialiseSource resolvedText caseHack resolvedSource let narDigest = sha256Digest (NAR.serialise entry) case expectedSha256 of Just (subject, expected)@@ -410,17 +586,27 @@ sp <- storePathOrThrow copyContext (makeFixedOutputPath name "sha256" "recursive" narDigest) destFilePath <- evalFilePath sp let destPath = canonicalStorePathText sp- wrapIO (copyToStoreVerified resolvedSource destFilePath (takeDirectory destFilePath) narDigest)+ -- Restored from the entry just hashed, not copied from the source:+ -- the store's volume may fold a pair the source's keeps apart, and+ -- the tree that lands is exactly the one the address names.+ wrapIO (unpackToStoreVerified destFilePath entry narDigest) >>= either throwEvalError pure recordStoreWrite destPath [] SP.WriteRecursive+ allowPath destPath pure destPath narHashOfPath path = do+ accessPath path+ caseHack <- readCaseHack path resolved <- evalStoreTextPath path- wrapIO (sha256Digest . NAR.serialise <$> ExecBit.serialiseFromPath resolved)+ wrapIO (sha256Digest . NAR.serialise <$> ExecBit.serialiseFromPath caseHack resolved) - isExecutableFile path = evalStoreTextPath path >>= \resolved -> wrapIO (ExecBit.isExecutable resolved)+ isExecutableFile path = do+ accessPath path+ evalStoreTextPath path >>= \resolved -> wrapIO (ExecBit.isExecutable resolved) - setExecutableFile path = evalStoreTextPath path >>= \resolved -> wrapIO (ExecBit.markExecutable resolved)+ setExecutableFile path = do+ accessPath path+ evalStoreTextPath path >>= \resolved -> wrapIO (ExecBit.markExecutable resolved) lookupFetchCache key = wrapIO $ do file <- fetchCacheFile key@@ -428,7 +614,7 @@ if not there then pure Nothing else do- recorded <- try (BS.readFile file) :: IO (Either SomeException BS.ByteString)+ recorded <- try (BS.readFile file) :: IO (Either IOException BS.ByteString) pure $ case recorded of Left _ -> Nothing Right bytes -> either (const Nothing) Just (TE.decodeUtf8' bytes)@@ -440,7 +626,11 @@ -- 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)+ when there $ do+ recordStoreWrite path [] SP.WriteRecursive+ -- A fetch result, whether fetched now or remembered: upstream+ -- allows the store path every fetcher returns.+ allowPath path pure there writeFetchCache key value = wrapIO $ do@@ -459,10 +649,15 @@ BS.writeFile staging (TE.encodeUtf8 value) Dir.renameFile staging file ) ::- IO (Either SomeException ())+ IO (Either IOException ()) pure () - readSymlinkTarget path = evalStoreTextPath path >>= \resolved -> wrapIO (T.pack <$> Dir.getSymbolicLinkTarget resolved)+ -- The two checks of getFileType: upstream's readLink asserts no+ -- symlink above the path it is handed as well.+ readSymlinkTarget path = do+ accessPathDirect path+ refuseSymlinkedAncestor path+ evalStoreTextPath path >>= \resolved -> wrapIO (T.pack <$> Dir.getSymbolicLinkTarget resolved) addSourceNar name narBytes = case NAR.deserialise narBytes of@@ -474,17 +669,11 @@ 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+ -- Raised here rather than inside 'wrapIO', which would render+ -- the refusal through an exception's own text ("user error (...)").+ wrapIO (unpackToStoreVerified destFilePath entry (sha256Digest narBytes)) >>= either throwEvalError pure recordStoreWrite destPath [] SP.WriteRecursive+ allowPath destPath pure destPath addFixedOutputFile name bytes = do@@ -503,22 +692,47 @@ Dir.removePathForcibly filePath BS.writeFile filePath bytes recordStoreWrite storePath [] SP.WriteFlat+ allowPath storePath pure storePath traceMessage msg = EvalIO (liftIO (hPutStrLn stderr (T.unpack msg))) + evalPolicy = EvalIO (asks esPolicy)++ checkUri uri = do+ st <- EvalIO ask+ allowed <- EvalIO (liftIO (readIORef (esAllowedPaths st)))+ either throwEvalError pure (uriAccess (esPolicy st) allowed uri)++ -- Store text stays canonical in the value domain, as it does for an+ -- import target ('resolveImportTarget' says why); every other path+ -- resolves as a platform path, the policy walk having followed the+ -- same links first.+ resolveSymlinks path = do+ accessPath path+ if SP.isCanonicalStoreText path+ then pure (canonPath path)+ else wrapIO (canonPathValue . T.pack <$> Dir.canonicalizePath (T.unpack path))+ resolvePathLiteral path = do baseDir <- EvalIO (asks esBaseDir)+ policy <- EvalIO (asks esPolicy) -- ~/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.+ -- at the filesystem boundary. Under pure-eval the home directory is+ -- ambient state, and upstream's parser refuses the literal outright+ -- (parser.y, the HPATH rule); the same refusal lands here, where the+ -- expansion is. expanded <- case T.stripPrefix "~/" path of- Just below -> do- home <- wrapIO Dir.getHomeDirectory- pure (home </> T.unpack below)+ Just below+ | epPureEval policy ->+ throwEvalError ("the path '" <> path <> "' can not be resolved in pure mode")+ | otherwise -> 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))@@ -536,14 +750,14 @@ -- forces itself. state <- EvalIO (liftIO (cthunkState ptr)) case state of- 1 {- COMPUTED -} ->+ ThunkComputed -> EvalIO (liftIO (readComputed ptr))- 2 {- BLACKHOLE -} ->+ ThunkBlackhole -> -- Infinite recursion is non-catchable (like abort), matching C++ Nix. -- tryEval must NOT catch blackholes - using abortEvaluation ensures -- the error propagates through tryEval/catchEvalError. abortEvaluation "infinite recursion encountered"- _ {- PENDING -} -> do+ ThunkPending -> do -- Bytecode thunks: read bc_idx + StablePtr Env. -- The Expr is gone (replaced by bc_idx in the struct). -- The Env is still a StablePtr (for knot-tying laziness).@@ -553,51 +767,69 @@ env <- EvalIO (liftIO (deRefStablePtr pendingSp)) -- Mark blackhole BEFORE evaluation - any re-entry hits the -- BLACKHOLE branch above.- _ <- EvalIO (liftIO (cthunkMarkBlackhole ptr))+ EvalIO . liftIO $ do+ marked <- cthunkMarkBlackhole ptr+ unless marked (cInvariantViolated "nn_thunk_mark_blackhole" "a thunk read as PENDING was not PENDING") -- 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.+ -- escapes tryEval exactly as before. Storing the value is inside the+ -- restore too, since interning or marshalling it can fail. The+ -- restore's own answer is not needed: it refuses only a thunk that is+ -- no longer BLACKHOLE, which a later force already handles, and the+ -- exception in flight is the one to report. 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) $- EvalIO (liftIO (freeStablePtr (castPtrToStablePtr oldPayload)))+ ( ( do+ result <- runReaderT (unEvalIO (evalFn env bcIdx)) st+ storeComputed ptr result+ pure result+ )+ `onException` cthunkMarkPending ptr+ )+ -- The value is stored, so nothing reads the pending env again.+ EvalIO (liftIO (freeStablePtr pendingSp)) pure val+ other ->+ EvalIO (liftIO (cInvariantViolated "nn_thunk_state" ("no thunk has state " ++ show other))) --- | Store a computed NixValue in a C thunk.+-- | Store a forced thunk's NixValue in its C cell. -- Scalars (int, float, bool, null) are stored inline (no StablePtr).--- Complex values use StablePtr. Returns old payload for cleanup.-storeComputed :: CThunkPtr -> NixValue -> IO (Ptr ())-storeComputed ptr val = case val of- VInt n -> cthunkSetComputedInt ptr n- VFloat d -> cthunkSetComputedFloat ptr d- VBool b -> cthunkSetComputedBool ptr (if b then 1 else 0)- VNull -> cthunkSetComputedNull ptr- VAttrs (AttrSet cset) -> cthunkSetComputedAttrs ptr (castPtr cset)- VPath p -> do- Symbol sym <- symbolIntern p- cthunkSetComputedPath ptr sym- VStr t ctx- | ctx == emptyContext -> do- Symbol sym <- symbolInternBytes t- cthunkSetComputedStr ptr sym- | otherwise -> do- csptr <- marshalStringContext t ctx- cthunkSetComputedCtxStr ptr (castPtr csptr)- VList (CList clistPtr) -> cthunkSetComputedList ptr (castPtr clistPtr)- VLambda (Env envPtr) formals bodyBcIdx -> do- lamPtr <- marshalLambda envPtr formals bodyBcIdx- cthunkSetComputedLambda ptr lamPtr- _ -> do- valSp <- newStablePtr val- cthunkSetComputed ptr (castStablePtrToPtr valSp)+-- Complex values use StablePtr. The cell is BLACKHOLE while its force+-- runs, which keeps every other path from computing it, so a setter's+-- refusal is an invariant violation rather than a value to drop.+storeComputed :: CThunkPtr -> NixValue -> IO ()+storeComputed ptr val = do+ stored <- case val of+ VInt n -> cthunkSetComputedInt ptr n+ VFloat d -> cthunkSetComputedFloat ptr d+ VBool b -> cthunkSetComputedBool ptr (if b then 1 else 0)+ VNull -> cthunkSetComputedNull ptr+ VAttrs (AttrSet cset) -> cthunkSetComputedAttrs ptr (castPtr cset)+ VPath p -> do+ Symbol sym <- symbolIntern p+ cthunkSetComputedPath ptr sym+ VStr t ctx+ | ctx == emptyContext -> do+ Symbol sym <- symbolInternBytes t+ cthunkSetComputedStr ptr sym+ | otherwise -> do+ csptr <- marshalStringContext t ctx+ cthunkSetComputedCtxStr ptr (castPtr csptr)+ VList (CList clistPtr) -> cthunkSetComputedList ptr (castPtr clistPtr)+ VLambda (Env envPtr) formals bodyBcIdx -> do+ lamPtr <- marshalLambda envPtr formals bodyBcIdx+ cthunkSetComputedLambda ptr lamPtr+ _ -> do+ valSp <- newStablePtr val+ kept <- cthunkSetComputed ptr (castStablePtrToPtr valSp)+ unless kept (freeStablePtr valSp)+ pure kept+ unless stored (cInvariantViolated "nn_thunk_set_computed" "a thunk being forced was already COMPUTED") -- | Read a computed NixValue from a C thunk. -- Dispatches on val_tag: scalars are read inline, complex via StablePtr.@@ -637,6 +869,10 @@ importCacheMaxAttrs :: Int importCacheMaxAttrs = 1000 +-- | The variable upstream reads its lookup path from.+nixPathVar :: BS.ByteString+nixPathVar = "NIX_PATH"+ -- | Random bytes in a scratch-dir name suffix (hex-encoded). 128 bits: -- unguessable by another local process, collision-free in practice. scratchSuffixBytes :: Int@@ -646,6 +882,173 @@ -- Helpers -- --------------------------------------------------------------------------- +-- ---------------------------------------------------------------------------+-- Access policy+-- ---------------------------------------------------------------------------++-- | Admit a store object this evaluation produced (upstream's @allowPath@+-- after a copy, fetch or write).+allowPath :: Text -> EvalIO ()+allowPath path = do+ st <- EvalIO ask+ EvalIO (liftIO (allowEvalPath st path))++-- | Refuse a read unless the whole path, symlinks resolved, lies in the+-- allowed prefixes. Upstream's accessor checks every prefix as its+-- symlink walk pushes it (@SourceAccessor::resolveSymlinks@ through+-- @FilteringSourceAccessor::maybeLstat@, which throws at 2.24.9), so the+-- refusal names the first prefix outside the allow list - @/etc@ for+-- @/etc/passwd@ when nothing under @/etc@ is allowed - and a symlink's+-- target is checked where the walk follows it. A no-op when nothing+-- restricts access.+accessPath :: Text -> EvalIO ()+accessPath path = do+ st <- EvalIO ask+ when (pathsRestricted (esPolicy st)) $ do+ allowed <- EvalIO (liftIO (readIORef (esAllowedPaths st)))+ walkAllowed ResolveFull st allowed path >>= either throwEvalError pure++-- | Refuse a read unless the path itself is allowed, no symlink walk: the+-- check upstream's accessor makes on an lstat or readlink of the path it+-- is handed. The refusal names the path asked for.+accessPathDirect :: Text -> EvalIO ()+accessPathDirect path = do+ st <- EvalIO ask+ when (pathsRestricted (esPolicy st)) $ do+ allowed <- EvalIO (liftIO (readIORef (esAllowedPaths st)))+ let canonical = canonPathValue path+ unless (isAbsolutePath canonical) $ throwEvalError (relativePathMessage path)+ unless (isAllowedPath allowed canonical) $+ throwEvalError (forbiddenPathMessage (esPolicy st) canonical)++-- | Whether the walk would admit the path, for the one reader that+-- answers a Boolean instead of refusing.+pathAllowed :: SymlinkResolution -> Text -> EvalIO Bool+pathAllowed mode path = do+ st <- EvalIO ask+ if pathsRestricted (esPolicy st)+ then do+ allowed <- EvalIO (liftIO (readIORef (esAllowedPaths st)))+ isRight <$> walkAllowed mode st allowed path+ else pure True++-- | Refuse a path with a symlink anywhere above it, naming the deepest+-- one: upstream's posix accessor asserts this on an lstat or readlink of+-- the path it is handed (@PosixSourceAccessor::assertNoSymlinks@ over+-- the parent, at 2.24.9), in every mode, and names the first symlink it+-- meets walking up from the parent.+refuseSymlinkedAncestor :: Text -> EvalIO ()+refuseSymlinkedAncestor path = do+ st <- EvalIO ask+ mapM_ (check st) (strictAncestors (canonPathValue path))+ where+ check st ancestor = do+ target <- EvalIO (liftIO (symlinkTargetAt st ancestor))+ when (isJust target) $+ throwEvalError ("path '" <> ancestor <> "' is a symlink")++-- | A canonical path's ancestors below the root, deepest first.+strictAncestors :: Text -> [Text]+strictAncestors path =+ [joinComponents (take n components) | n <- [length components - 1, length components - 2 .. 1]]+ where+ components = pathComponents path++-- | How a 'PathExistence' query walks the path: a directory is demanded+-- through the final symlink, an entry is found in place.+existenceResolution :: PathExistence -> SymlinkResolution+existenceResolution ExistsAsDirectory = ResolveFull+existenceResolution ExistsAsEntry = ResolveAncestors++-- | The stat a 'PathExistence' query ends in: a directory through every+-- symlink, or an entry of any kind in place.+existsAs :: PathExistence -> FilePath -> IO Bool+existsAs ExistsAsDirectory = Dir.doesDirectoryExist+existsAs ExistsAsEntry = lstatExists++-- | Whether an entry is at the path, a dangling symlink included: an+-- lstat, as upstream's @maybeLstat@ is. 'Dir.pathIsSymbolicLink' is the+-- lstat the directory package exposes, and it throws for a missing path,+-- so any failure is absence, as it is for 'Dir.doesPathExist'.+lstatExists :: FilePath -> IO Bool+lstatExists fp = do+ outcome <- try (Dir.pathIsSymbolicLink fp) :: IO (Either IOException Bool)+ pure (isRight outcome)++-- | Upstream's @coerceToPath@ refusal of a relative string. The builtins+-- refuse one before it gets here; the gate repeats the refusal because+-- its soundness depends on it.+relativePathMessage :: Text -> Text+relativePathMessage path = "string '" <> path <> "' doesn't represent an absolute path"++-- | Which symlinks a walk follows, upstream's @SymlinkResolution@: every+-- one, or every one but the last component's, which is then checked in+-- place.+data SymlinkResolution = ResolveFull | ResolveAncestors+ deriving (Eq, Show)++-- | The symlink-resolving prefix walk of upstream's+-- @SourceAccessor::resolveSymlinks@ at 2.24.9, with the allow list+-- consulted where its @maybeLstat@ would be: each component is pushed,+-- the prefix so far must be allowed, and a symlink at that prefix has+-- its target spliced in front of what remains (an absolute target+-- restarts at the root). 'Left' carries the refusal for the first+-- prefix outside the allow list. Under 'ResolveAncestors' the last+-- component's own symlink is left alone, as upstream consults+-- @maybeLstat@ only while something remains to resolve.+walkAllowed :: SymlinkResolution -> EvalState -> AllowedPaths -> Text -> EvalIO (Either Text ())+walkAllowed mode st allowed path+ | not (isAbsolutePath canonical) = pure (Left (relativePathMessage path))+ | otherwise = go symlinkFollowLimit [] (pathComponents canonical)+ where+ canonical = canonPathValue path+ policy = esPolicy st+ go _ _ [] = pure (Right ())+ go linksLeft done (component : rest)+ | component == "." = go linksLeft done rest+ | component == ".." = go linksLeft (drop 1 done) rest+ | not (isAllowedPrefix allowed prefix) = pure (Left (forbiddenPathMessage policy prefixText))+ | otherwise = do+ target <- if followsLinkAt rest then EvalIO (liftIO (symlinkTargetAt st prefixText)) else pure Nothing+ case target of+ Nothing -> go linksLeft prefixReversed rest+ Just _+ | linksLeft == 0 ->+ throwEvalError ("infinite symlink recursion in path '" <> canonical <> "'")+ Just link ->+ let linkComponents = filter (not . T.null) (T.split isPathSeparator link)+ -- Spliced in front of the remainder; the walk resumes from+ -- the root for an absolute target and from the link's own+ -- directory otherwise.+ resumeFrom = if isAbsoluteTarget link then [] else done+ in go (linksLeft - 1) resumeFrom (linkComponents ++ rest)+ where+ prefixReversed = component : done+ prefix = reverse prefixReversed+ prefixText = joinComponents prefix+ followsLinkAt rest = mode == ResolveFull || not (null rest)+ isAbsoluteTarget link = T.isPrefixOf "/" link || isAbsolute (T.unpack link)++-- | The target of the symlink at a path value, read through the store+-- mapping, or 'Nothing' when there is no symlink there. A path that+-- does not exist is simply not a symlink; the read that follows reports+-- it missing.+symlinkTargetAt :: EvalState -> Text -> IO (Maybe Text)+symlinkTargetAt st pathText = do+ let fsPath = SP.storeTextToFilePath (esStoreDir st) pathText+ outcome <-+ try+ ( do+ isLink <- Dir.pathIsSymbolicLink fsPath+ if isLink then Just . T.pack <$> Dir.getSymbolicLinkTarget fsPath else pure Nothing+ ) ::+ IO (Either IOException (Maybe Text))+ pure (fromRight Nothing outcome)++-- | Upstream's bound on symlinks followed while resolving one path.+symlinkFollowLimit :: Int+symlinkFollowLimit = 1024+ -- | 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.@@ -687,15 +1090,27 @@ -- 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+narBytesIfPresent :: NAR.CaseHack -> FilePath -> IO (Maybe BS.ByteString)+narBytesIfPresent caseHack path = do onDisk <- Dir.doesPathExist path if not onDisk then pure Nothing else do- result <- try (ExecBit.serialiseFromPath path) :: IO (Either IOException NAR.NarEntry)+ result <- try (ExecBit.serialiseFromPath caseHack path) :: IO (Either IOException NAR.NarEntry) pure (either (const Nothing) (Just . NAR.serialise) result) +-- | The case-hack mode a read of path-value text runs under. Store+-- text names a tree on the store's volume, read back as the store+-- reads its own paths ('volumeCaseHack' of the probed volume); any+-- other path is a source, read under the process's setting+-- ('processCaseHack').+readCaseHack :: Text -> EvalIO NAR.CaseHack+readCaseHack path+ | SP.isCanonicalStoreText path = do+ storeRoot <- EvalIO (asks (SP.unStoreDir . esStoreDir))+ wrapIO (volumeCaseHack <$> probeCaseSensitivity storeRoot)+ | otherwise = pure processCaseHack+ -- | 'storeFilePath' against the store this evaluation was given. evalFilePath :: SP.StorePath -> EvalIO FilePath evalFilePath sp = EvalIO (asks ((`storeFilePath` sp) . esStoreDir))@@ -713,9 +1128,23 @@ canonicalStorePathText :: SP.StorePath -> Text canonicalStorePathText = SP.storePathToText SP.defaultStoreDir +-- | Fail an import whose file does not parse or bind. Both are uncatchable,+-- as upstream's ParseError and UndefinedVarError are to @builtins.tryEval@.+-- An unbound variable reads exactly as upstream's message does; a syntax+-- error keeps the parser's detail behind the builtin and file it came from.+rejectSource :: Text -> FilePath -> SourceError -> EvalIO a+rejectSource builtinName target err = throwEvalError $ case err of+ SyntaxError syntax -> builtinName <> " " <> T.pack target <> ": " <> T.pack (show syntax)+ UndefinedVariable name -> undefinedVariableMessage name+ -- | 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.+-- literals resolve against) and the filesystem location to read, with+-- both the path named and the file resolved checked against the policy:+-- upstream's @resolveExprPath@ walks the path it is handed, and+-- @parseExprFromFile@ walks the file it reads after @default.nix@ has+-- been appended, so a directory whose @default.nix@ is a symlink is+-- refused at the link's target. -- -- Store text stays canonical in the value domain: 'Dir.canonicalizePath' -- would attach the working drive to the rooted @/nix@ prefix on Windows,@@ -724,24 +1153,31 @@ -- 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)+resolveImportTarget baseDir rawPath = do+ accessPath rawPath+ (target, ioTarget) <- locate+ accessPath (T.pack target)+ pure (target, ioTarget)+ where+ locate+ | SP.isCanonicalStoreText rawPath = do+ let valueBase = T.unpack (canonPath rawPath)+ resolvedBase <- evalStoreTextPath (T.pack valueBase)+ isDir <- sourceIO StatPath (T.pack valueBase) (statSource resolvedBase >> 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, which upstream's resolveExprPath learns from an+ -- lstat, so an absent target is a stat failure.+ isDir <- sourceIO StatPath (T.pack canonical) (statSource canonical >> Dir.doesDirectoryExist canonical)+ let target = 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@.@@ -780,50 +1216,95 @@ | Just (_ :: SomeAsyncException) <- fromException err -> throwIO err | Just abortErr <- fromException err -> throwIO (abortErr :: NixAbortError) | Just nixErr <- fromException err -> throwIO (nixErr :: NixEvalError)- | otherwise -> throwIO (NixEvalError ErrorUncatchable (T.pack (displayException err)))+ | otherwise -> throwIO (NixEvalError ErrorUncatchable (untracedFailure (T.pack (displayException err)))) --- | Run an IO evaluation, returning @Left@ on error.+-- | 'wrapIO' for an access to a source path: an 'IOException' becomes+-- the message upstream gives that access ('sourceErrorMessage'), naming+-- the path as given.+sourceIO :: SourceAccess -> Text -> IO a -> EvalIO a+sourceIO access path = sourceIOWith access (pure path)++-- | 'sourceIO' with the path to name computed once the access has failed,+-- so a successful read pays nothing for it.+sourceIOWith :: SourceAccess -> IO Text -> IO a -> EvalIO a+sourceIOWith access shownPath action =+ wrapIO $+ action `catch` \(err :: IOException) -> do+ shown <- shownPath+ throwIO (NixEvalError ErrorUncatchable (untracedFailure (sourceErrorMessage access shown err)))++-- | A path with its symlinks resolved, as upstream names one it reached+-- through realisePath (readFile, hashFile and readDir resolve fully).+-- A path the resolution itself cannot walk keeps the spelling given.+resolvedForMessage :: Text -> IO Text+resolvedForMessage path+ | SP.isCanonicalStoreText path = pure (canonPath path)+ | otherwise =+ (canonPathValue . T.pack <$> Dir.canonicalizePath (T.unpack path))+ `catch` \(_ :: IOException) -> pure path++-- | An lstat that raises for an absent path: 'Dir.pathIsSymbolicLink' is+-- the portable lstat, as 'lstatExists' says.+statSource :: FilePath -> IO ()+statSource = void . Dir.pathIsSymbolicLink++-- | A source path's NAR, its root lstat'ed first as upstream's dumpPath+-- begins. A regular file's only access in the walk is its read, so a+-- failure there is the read's. A failure below a directory keeps GHC's+-- text until nova-cache's walk names the access that failed+-- (Novavero-AI/nova-cache#80).+serialiseSource :: Text -> NAR.CaseHack -> FilePath -> EvalIO NAR.NarEntry+serialiseSource path caseHack resolved = do+ isLink <- sourceIO StatPath path (Dir.pathIsSymbolicLink resolved)+ isFile <- wrapIO (Dir.doesFileExist resolved)+ let walk = ExecBit.serialiseFromPath caseHack resolved+ if not isLink && isFile then sourceIO ReadFile path walk else wrapIO walk++-- | Run an IO evaluation, returning @Left@ with the failure's message on+-- error.+runEvalIO :: EvalState -> EvalIO a -> IO (Either Text a)+runEvalIO st action = either (Left . failureMessage) Right <$> runEvalIOTraced st action++-- | Run an IO evaluation, returning @Left@ with the whole failure on+-- error, its lines of context included, for a caller that reports it+-- ('renderEvalFailure'). -- -- Catches 'NixEvalError' (throw) and 'NixAbortError' (abort). -- Async exceptions (@StackOverflow@, @ThreadKilled@, etc.) propagate uncaught.-runEvalIO :: EvalState -> EvalIO a -> IO (Either Text a)-runEvalIO st (EvalIO action) = do+runEvalIOTraced :: EvalState -> EvalIO a -> IO (Either EvalFailure a)+runEvalIOTraced st (EvalIO action) = do result <- try (runReaderT action st) case result of Right val -> pure (Right val) Left (err :: SomeException) | Just (_ :: SomeAsyncException) <- fromException err -> throwIO err- | Just (NixEvalError _ msg) <- fromException err -> pure (Left msg)- | Just (NixAbortError msg) <- fromException err -> pure (Left msg)- | otherwise -> pure (Left (T.pack (displayException err)))---- | Look up an environment variable, returning Nothing if unset.-lookupEnvText :: String -> IO (Maybe String)-lookupEnvText name = do- result <- try (lookupEnv name)- case (result :: Either SomeException (Maybe String)) of- Left _ -> pure Nothing- Right mval -> pure mval+ | Just (NixEvalError _ failure) <- fromException err -> pure (Left failure)+ | Just (NixAbortError failure) <- fromException err -> pure (Left failure)+ | otherwise -> pure (Left (untracedFailure (T.pack (displayException err)))) -- --------------------------------------------------------------------------- -- Store copy helpers -- --------------------------------------------------------------------------- --- | 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- onDiskNar <- narBytesIfPresent dest- unless ((sha256Digest <$> onDiskNar) == Just expectedDigest) $ do- Dir.removePathForcibly dest- copyPathInto src dest+-- | Unpack a NAR entry at its content-addressed destination. An+-- existing destination is adopted only when its NAR digest is the+-- expected one - same content means same path, so a matching tree is+-- byte-identical by construction; anything else (an interrupted+-- earlier unpack, a squatter) is cleared and unpacked afresh. The+-- entry is the one evaluation hashed to name the path, so the tree+-- that lands is the one the address names, with the store volume's+-- own sibling-name handling.+unpackToStoreVerified :: FilePath -> NAR.NarEntry -> BS.ByteString -> IO (Either Text ())+unpackToStoreVerified dest entry expectedDigest = do+ Dir.createDirectoryIfMissing True (takeDirectory dest)+ -- Probed per write rather than carried in 'EvalState': the store+ -- directory need not exist when evaluation starts, and the probe+ -- answers for a path on disk. It was created just above, and the+ -- cost is one pathconf call per tree.+ sensitivity <- probeCaseSensitivity (takeDirectory dest)+ onDiskNar <- narBytesIfPresent (volumeCaseHack sensitivity) dest+ if (sha256Digest <$> onDiskNar) == Just expectedDigest+ then pure (Right ())+ else do+ Dir.removePathForcibly dest+ unpackNarEntry sensitivity dest entry
src/Nix/Eval/Operator.hs view
@@ -6,11 +6,16 @@ module Nix.Eval.Operator ( evalBinary, evalUnary,+ evalUpdate,+ addToInteger,+ addToFloat, nixCompare, nixEqual,- checkedAdd,- checkedSub,- checkedMul,+ primAdd,+ primSub,+ primMul,+ primDiv,+ expectInt, ) where @@ -19,6 +24,7 @@ import qualified Data.Text as T import Nix.Eval.CAttrSet (cattrsetUnion) import Nix.Eval.CList (clistFromThunks, clistLen, clistThunks)+import Nix.Eval.Print (PrintOptions (..), printValue) import Nix.Eval.Types ( AttrSet (..), MonadEval (..),@@ -29,6 +35,7 @@ attrSetLookup, thunkSameRef, typeName,+ typeOfValue, ) import Nix.Expr.Types (BinaryOp (..), UnaryOp (..)) import System.IO.Unsafe (unsafePerformIO)@@ -39,15 +46,16 @@ -- | Evaluate a binary operator on two forced values. ----- The caller must handle short-circuit operators ('OpAnd', 'OpOr',--- 'OpImpl') before calling this. The @Force@ function is used only--- for deep structural equality on compound values.+-- The caller must handle 'OpAdd' and the short-circuit operators+-- ('OpAnd', 'OpOr', 'OpImpl') before calling this: each looks at its left+-- operand before the right is evaluated, and @+@ coerces through the+-- evaluator. The @Force@ function is used only for deep structural+-- equality on compound values. 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" checkedSub (-) left right- OpMul -> evalArith "multiplication" checkedMul (*) left right- OpDiv -> evalDiv left right+ OpSub -> primSub left right+ OpMul -> primMul left right+ OpDiv -> primDiv left right OpEq -> VBool <$> nixEqual forceFn left right OpNeq -> VBool . not <$> nixEqual forceFn left right OpLt -> VBool <$> nixCompare forceFn left right@@ -59,8 +67,9 @@ OpGt -> VBool <$> nixCompare forceFn right left OpGte -> VBool . not <$> nixCompare forceFn left right OpConcat -> evalConcat left right- OpUpdate -> evalUpdate left right- -- Short-circuit ops must be handled by the caller+ OpUpdate -> evalUpdate (pure left) (pure right)+ -- Left-first ops must be handled by the caller+ OpAdd -> throwEvalError "internal error: OpAdd should be handled by eval" OpAnd -> throwEvalError "internal error: OpAnd should be handled by eval" OpOr -> throwEvalError "internal error: OpOr should be handled by eval" OpImpl -> throwEvalError "internal error: OpImpl should be handled by eval"@@ -70,105 +79,124 @@ evalUnary OpNot val = case val of VBool b -> pure (VBool (not b)) other -> throwEvalError ("cannot apply ! to " <> typeName other)-evalUnary OpNegate val = case val of- -- 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)+-- Upstream's parser turns -e into __sub 0 e (parser.y at 2.24.9), so+-- negation is builtins.sub with 0 on the left in every respect: minBound+-- reports the checked-subtraction overflow, a float zero comes out positive+-- (0 - 0.0, where negate would give -0.0), and a non-number fails sub's+-- forceInt on its second argument.+evalUnary OpNegate val = primSub (VInt 0) val --- | 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) = 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 left right =- throwEvalError ("cannot add " <> typeName left <> " and " <> typeName right)+-- | @+@ once its left operand is an integer, given the right: upstream's+-- @ExprConcatStrings::eval@ (eval.cc at 2.24.9) adds a number and refuses+-- anything else, naming the right operand's type. A float on the right+-- makes the sum a float.+addToInteger :: (MonadEval m) => Int64 -> NixValue -> m NixValue+addToInteger a right = case right of+ VInt b -> either throwEvalError (pure . VInt) (checkedAdd a b)+ VFloat b -> pure (VFloat (fromIntegral a + b))+ other -> throwEvalError ("cannot add " <> typeName other <> " to an integer") --- | 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+-- | @+@ once its left operand is a float, as 'addToInteger'.+addToFloat :: (MonadEval m) => Double -> NixValue -> m NixValue+addToFloat a right = case right of+ VInt b -> pure (VFloat (a + fromIntegral b))+ VFloat b -> pure (VFloat (a + b))+ other -> throwEvalError ("cannot add " <> typeName other <> " to a float")++-- | Checked Int64 arithmetic: integer overflow is an eval error, never a+-- two's-complement wrap. Computed in Integer and bounds-checked. 2.24.9+-- leaves overflow to C++ signed arithmetic, which is undefined; upstream+-- checks it from 2.25, and its message is followed. Division is the+-- exception, see 'divisionOverflowMessage'.+checkedIntOp :: Text -> Text -> (Integer -> Integer -> Integer) -> Int64 -> Int64 -> Either Text Int64+checkedIntOp verb symbol 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)- )+ Left (overflowMessage verb symbol a b) | otherwise = Right (fromInteger wide) where wide = op (toInteger a) (toInteger b) +-- | Upstream's overflow message: @integer overflow in adding a + b@.+overflowMessage :: Text -> Text -> Int64 -> Int64 -> Text+overflowMessage verb symbol a b =+ "integer overflow in " <> verb <> " " <> T.pack (show a) <> " " <> symbol <> " " <> T.pack (show b)++-- | @minBound / -1@, the one overflowing division. 2.24.9 checks it+-- itself (prim_div), so its message wins over 2.25's+-- @integer overflow in dividing a / b@.+divisionOverflowMessage :: Text+divisionOverflowMessage = "overflow in integer division"+ checkedAdd :: Int64 -> Int64 -> Either Text Int64-checkedAdd = checkedIntOp "adding" (+)+checkedAdd = checkedIntOp "adding" "+" (+) checkedSub :: Int64 -> Int64 -> Either Text Int64-checkedSub = checkedIntOp "subtracting" (-)+checkedSub = checkedIntOp "subtracting" "-" (-) checkedMul :: Int64 -> Int64 -> Either Text Int64-checkedMul = checkedIntOp "multiplying" (*)+checkedMul = checkedIntOp "multiplying" "*" (*) --- | Generic arithmetic for subtraction and multiplication. The integer--- side is a checked op ('checkedSub' / 'checkedMul').-evalArith ::+-- | Upstream's @forceInt@ on a forced value (eval.cc at 2.24.9).+expectInt :: (MonadEval m) => NixValue -> m Int64+expectInt (VInt n) = pure n+expectInt other = throwEvalError (expectedMessage "an integer" other)++-- | Upstream's @forceFloat@ on a forced value: an integer widens.+expectFloat :: (MonadEval m) => NixValue -> m Double+expectFloat (VInt n) = pure (fromIntegral n)+expectFloat (VFloat x) = pure x+expectFloat other = throwEvalError (expectedMessage "a float" other)++expectedMessage :: Text -> NixValue -> Text+expectedMessage wanted other =+ "expected " <> wanted <> " but found " <> typeName other <> ": " <> printValue PrintForError other++isFloat :: NixValue -> Bool+isFloat (VFloat _) = True+isFloat _ = False++-- | @-@, @*@, @builtins.add@, @builtins.sub@ and @builtins.mul@: upstream's+-- @prim_add@, @prim_sub@ and @prim_mul@ (primops.cc at 2.24.9). A float on+-- either side sends both operands through @forceFloat@, and otherwise both+-- go through @forceInt@, the left first. @+@ is not among them: upstream+-- evaluates it as @ExprConcatStrings@ ('addToInteger', 'addToFloat').+primArith :: (MonadEval m) =>- Text -> (Int64 -> Int64 -> Either Text Int64) -> (Double -> Double -> Double) -> NixValue -> NixValue -> m NixValue-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))- _ ->- throwEvalError- ( "cannot apply "- <> name- <> " to "- <> typeName left- <> " and "- <> typeName right- )+primArith checkedOp floatOp left right+ | isFloat left || isFloat right = VFloat <$> (floatOp <$> expectFloat left <*> expectFloat right)+ | otherwise = either throwEvalError (pure . VInt) =<< (checkedOp <$> expectInt left <*> expectInt right) --- | Division with zero check. Integer division uses 'quot'--- (truncation toward zero, matching C++ Nix semantics).-evalDiv :: (MonadEval m) => NixValue -> NixValue -> m NixValue-evalDiv left right = case (left, right) of- (VInt _, VInt 0) -> throwEvalError "division by zero"- (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))- (VInt a, VFloat b)- | b == 0 -> throwEvalError "division by zero"- | otherwise -> pure (VFloat (fromIntegral a / b))- (VFloat _, VInt 0) -> throwEvalError "division by zero"- (VFloat a, VInt b) -> pure (VFloat (a / fromIntegral b))- (VFloat a, VFloat b)- | b == 0 -> throwEvalError "division by zero"- | otherwise -> pure (VFloat (a / b))- _ ->- throwEvalError- ( "cannot divide "- <> typeName left- <> " by "- <> typeName right- )+primAdd, primSub, primMul :: (MonadEval m) => NixValue -> NixValue -> m NixValue+primAdd = primArith checkedAdd (+)+primSub = primArith checkedSub (-)+primMul = primArith checkedMul (*) +-- | @/@ and @builtins.div@, upstream's @prim_div@: the divisor goes through+-- @forceFloat@ and is checked for zero before the dividend is looked at,+-- so @{ } / 0@ is a division by zero. Integer division truncates toward+-- zero, as C++ does.+primDiv :: (MonadEval m) => NixValue -> NixValue -> m NixValue+primDiv left right = expectFloat right >>= divideBy+ where+ divideBy divisor+ | divisor == 0 = throwEvalError divisionByZeroMessage+ | isFloat left || isFloat right = VFloat . (/ divisor) <$> expectFloat left+ | otherwise = either throwEvalError (pure . VInt) =<< (checkedDiv <$> expectInt left <*> expectInt right)++checkedDiv :: Int64 -> Int64 -> Either Text Int64+checkedDiv a b+ | b == 0 = Left divisionByZeroMessage+ | a == minBound && b == -1 = Left divisionOverflowMessage+ | otherwise = Right (quot a b)++divisionByZeroMessage :: Text+divisionByZeroMessage = "division by zero"+ -- --------------------------------------------------------------------------- -- Comparison and equality -- ---------------------------------------------------------------------------@@ -186,13 +214,14 @@ -- Lists compare lexicographically, element by element (Nix semantics). nixCompare forceFn (VList clA) (VList clB) = listCompare forceFn (map Thunk (clistThunks clA)) (map Thunk (clistThunks clB))-nixCompare _ left right =- throwEvalError- ( "cannot compare "- <> typeName left- <> " and "- <> typeName right- )+-- Upstream's @CompareValues@ (primops.cc at 2.24.9). 2.33.2 appends the+-- two values; 2.24.9 does not.+nixCompare _ left right+ | typeOfValue left == typeOfValue right =+ throwEvalError (incomparable <> "; values of that type are incomparable")+ | otherwise = throwEvalError incomparable+ where+ incomparable = "cannot compare " <> typeName left <> " with " <> typeName right -- | Lexicographic comparison of two thunk lists for the @<@ operator: -- the first NON-EQUAL element pair decides via @<@ on that pair, as@@ -316,10 +345,23 @@ -- | Attribute set merge (//). Right-biased: keys in the right -- operand shadow keys in the left.-evalUpdate :: (MonadEval m) => NixValue -> NixValue -> m NixValue-evalUpdate (VAttrs as) (VAttrs bs) = pure (VAttrs (mergeAttrSets as bs))-evalUpdate left right =- throwEvalError ("cannot merge " <> typeName left <> " and " <> typeName right)+--+-- Each operand is evaluated and checked for a set in turn, the left+-- first, as @ExprOpUpdate::eval@ calls @evalAttrs@ on each (eval.cc at+-- 2.24.9): a non-set left fails before the right is evaluated at all, and+-- the refusal is @evalAttrs@'s message. 2.33.2 checks the right first;+-- 2.24.9 is followed. Upstream adds a trace line naming the operand ("in+-- the left operand of the update (//) operator") above the message;+-- nova-nix's errors carry no trace, so only the final line is reproduced.+evalUpdate :: (MonadEval m) => m NixValue -> m NixValue -> m NixValue+evalUpdate evalLeft evalRight = do+ leftSet <- evalLeft >>= expectSet+ rightSet <- evalRight >>= expectSet+ pure (VAttrs (mergeAttrSets leftSet rightSet))+ where+ expectSet (VAttrs set) = pure set+ expectSet other =+ throwEvalError ("expected a set but found " <> typeName other <> ": " <> printValue PrintForError other) -- | Merge two 'AttrSet's, right-biased (@//@). -- Delegates to C-side @nn_attrset_union@ which performs a linear merge
+ src/Nix/Eval/Policy.hs view
@@ -0,0 +1,228 @@+-- | The evaluation policy: upstream's @restrict-eval@ and @pure-eval@+-- settings, the @allowed-uris@ list, and the pure decisions they drive.+--+-- Upstream gates evaluation in two layers (@src/libexpr/eval.cc@ at+-- 2.24.9). Under either setting the root filesystem is wrapped in an+-- @AllowListSourceAccessor@ that admits a path when it lies within an+-- allowed prefix or is an ancestor of one (@CanonPath::isAllowed@), and+-- the allowed set starts as the search path roots and grows as the+-- evaluation copies, fetches or writes store objects (@allowPath@).+-- Under @restrict-eval@ alone a fetch is also checked against+-- @allowed-uris@ (@checkURI@). @pure-eval@ additionally empties the+-- search path, hides the impure constants, and makes the fetchers demand+-- a hash or revision; those decisions live with the builtins and consult+-- 'EvalPolicy' through 'Nix.Eval.Types.MonadEval'.+--+-- Everything here is pure so each rule can be tested on its own. The+-- filesystem side (walking a path through its symlinks, prefix by+-- prefix) lives in "Nix.Eval.IO".+module Nix.Eval.Policy+ ( -- * Policy+ EvalPolicy (..),+ unrestrictedPolicy,+ pathsRestricted,+ modeInformation,++ -- * Allowed paths+ AllowedPaths,+ noAllowedPaths,+ allowPathIn,+ isAllowedPath,+ isAllowedPrefix,+ pathComponents,+ joinComponents,+ isAbsolutePath,+ forbiddenPathMessage,++ -- * Allowed URIs+ isAllowedUri,+ uriAccess,+ forbiddenUriMessage,+ )+where++import Data.Char (isAsciiLower, isAsciiUpper, isDigit)+import Data.List (inits, isPrefixOf)+import Data.Set (Set)+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as T+import Nix.Eval.CanonPath (canonPathValue)++-- ---------------------------------------------------------------------------+-- Policy+-- ---------------------------------------------------------------------------++-- | What an evaluation may reach. The two flags are independent, as+-- upstream's settings are: @pure-eval@ does not imply the URI check+-- (@checkURI@ consults @restrictEval@ alone), and @restrict-eval@ does+-- not hide the impure constants or empty the search path.+data EvalPolicy = EvalPolicy+ { -- | Upstream @restrict-eval@: filesystem reads are confined to the+ -- allowed prefixes, @getEnv@ answers empty, and a fetch must match+ -- 'epAllowedUris'.+ epRestrictEval :: !Bool,+ -- | Upstream @pure-eval@: the same filesystem confinement, no search+ -- path, no @currentTime@ or @currentSystem@, no @storePath@, and the+ -- fetchers require a hash or revision.+ epPureEval :: !Bool,+ -- | Upstream @allowed-uris@: prefixes a fetch may reach under+ -- @restrict-eval@.+ epAllowedUris :: ![Text]+ }+ deriving (Eq, Show)++-- | Upstream's default: both settings off, nothing listed.+unrestrictedPolicy :: EvalPolicy+unrestrictedPolicy = EvalPolicy {epRestrictEval = False, epPureEval = False, epAllowedUris = []}++-- | Whether filesystem access goes through the allow list: upstream wraps+-- the root filesystem when either setting is on.+pathsRestricted :: EvalPolicy -> Bool+pathsRestricted policy = epRestrictEval policy || epPureEval policy++-- | The clause a path refusal ends with. Upstream names pure mode when+-- it is on, restricted mode otherwise (@eval.cc@, the accessor's error+-- callback). The @--impure@ hint is upstream's text, kept for parity.+modeInformation :: EvalPolicy -> Text+modeInformation policy+ | epPureEval policy = "in pure evaluation mode (use '--impure' to override)"+ | otherwise = "in restricted mode"++-- ---------------------------------------------------------------------------+-- Allowed paths+-- ---------------------------------------------------------------------------++-- | The allowed prefixes, as component lists under lexicographic order.+-- That order is upstream's @CanonPath@ order (a separator sorts before+-- every other character), which is what makes "the smallest element at+-- or after a path is its descendant or nothing is" a correct test.+newtype AllowedPaths = AllowedPaths (Set [Text])+ deriving (Eq, Show)++-- | Nothing allowed yet.+noAllowedPaths :: AllowedPaths+noAllowedPaths = AllowedPaths Set.empty++-- | Grant access to a prefix (upstream @allowPrefix@). The text is+-- canonicalized first, so the spelling that arrives does not matter.+allowPathIn :: Text -> AllowedPaths -> AllowedPaths+allowPathIn path (AllowedPaths allowed) = AllowedPaths (Set.insert (pathComponents path) allowed)++-- | Upstream @CanonPath::isAllowed@: access is allowed when the path is+-- within an allowed prefix (the prefix itself included) or when an+-- allowed prefix is within the path, which keeps the ancestors of an+-- allowed path reachable.+isAllowedPath :: AllowedPaths -> Text -> Bool+isAllowedPath allowed = isAllowedPrefix allowed . pathComponents++-- | 'isAllowedPath' over components, for a caller walking a path one+-- component at a time.+isAllowedPrefix :: AllowedPaths -> [Text] -> Bool+isAllowedPrefix (AllowedPaths allowed) path =+ descendantAllowed || any (`Set.member` allowed) (inits path)+ where+ descendantAllowed = maybe False (path `isPrefixOf`) (Set.lookupGE path allowed)++-- | A canonical path's components: the root has none, and a Windows+-- drive designator is the first one.+pathComponents :: Text -> [Text]+pathComponents = filter (not . T.null) . T.splitOn "/" . canonPathValue++-- | The canonical text of a component list, inverse of 'pathComponents'.+joinComponents :: [Text] -> Text+joinComponents components = case components of+ (drive : rest)+ | isDriveDesignator drive -> drive <> "/" <> T.intercalate "/" rest+ _ -> "/" <> T.intercalate "/" components+ where+ isDriveDesignator d = T.length d == driveDesignatorLength && T.isSuffixOf ":" d++-- | @X:@+driveDesignatorLength :: Int+driveDesignatorLength = 2++-- | Whether a path's text is rooted: a leading @/@, or a drive designator+-- (a canonical Windows path value spells @C:/...@). The gates refuse+-- anything else, since a relative path would be checked against one+-- location and opened at another.+isAbsolutePath :: Text -> Bool+isAbsolutePath path = T.isPrefixOf "/" path || hasDrive+ where+ hasDrive = case T.unpack (T.take (driveDesignatorLength + 1) path) of+ [letter, ':', '/'] -> isAsciiLetter letter+ _ -> False+ isAsciiLetter c = isAsciiLower c || isAsciiUpper c++-- | Upstream's refusal, from the accessor's error callback in @eval.cc@.+forbiddenPathMessage :: EvalPolicy -> Text -> Text+forbiddenPathMessage policy path =+ "access to absolute path '" <> path <> "' is forbidden " <> modeInformation policy++-- ---------------------------------------------------------------------------+-- Allowed URIs+-- ---------------------------------------------------------------------------++-- | Upstream @isAllowedURI@ (@eval.cc@ at 2.24.9): a URI is allowed when+-- it equals a prefix, or extends a prefix at a @/@ boundary (the prefix+-- ends in @/@, or the URI's next character is @/@), or the prefix is a+-- bare scheme ending in @:@. @https://github.co@ therefore does not+-- admit @https://github.com@.+isAllowedUri :: [Text] -> Text -> Bool+isAllowedUri prefixes uri = any admits prefixes+ where+ admits prefix =+ uri == prefix+ || ( T.length uri > T.length prefix+ && not (T.null prefix)+ && T.isPrefixOf prefix uri+ && ( T.isSuffixOf "/" prefix+ || T.isPrefixOf "/" (T.drop (T.length prefix) uri)+ || isJustSchemePrefix prefix+ )+ )++-- | Upstream @isJustSchemePrefix@: a scheme name followed by a colon and+-- nothing else, the scheme spelled as @url-parts.hh@ allows+-- (@[a-z][a-z0-9+.-]*@).+isJustSchemePrefix :: Text -> Bool+isJustSchemePrefix prefix = case T.unsnoc prefix of+ Just (scheme, ':') -> isValidSchemeName scheme+ _ -> False++isValidSchemeName :: Text -> Bool+isValidSchemeName scheme = case T.uncons scheme of+ Just (leading, rest) -> isAsciiLower leading && T.all schemeChar rest+ Nothing -> False+ where+ schemeChar c = isAsciiLower c || isDigit c || c `elem` schemePunctuation++-- | The punctuation a scheme name may carry after its first character.+schemePunctuation :: [Char]+schemePunctuation = "+.-"++-- | Upstream @EvalState::checkURI@: nothing is checked unless+-- @restrict-eval@ is on; an allowed URI passes; a URI that is a path, or+-- a @file://@ URL, is checked against the allowed paths instead+-- (upstream passes the remainder through @CanonPath@, so a rootless+-- remainder is rooted, and one that spells a drive, @file://C:/...@, is+-- rooted already); anything else is refused.+uriAccess :: EvalPolicy -> AllowedPaths -> Text -> Either Text ()+uriAccess policy allowed uri+ | not (epRestrictEval policy) = Right ()+ | isAllowedUri (epAllowedUris policy) uri = Right ()+ | T.isPrefixOf "/" uri = pathAccess uri+ | Just rest <- T.stripPrefix fileUrlPrefix uri = pathAccess (rooted rest)+ | otherwise = Left (forbiddenUriMessage uri)+ where+ rooted rest = if isAbsolutePath rest then rest else "/" <> rest+ pathAccess path =+ let canonical = canonPathValue path+ in if isAllowedPath allowed canonical then Right () else Left (forbiddenPathMessage policy canonical)++fileUrlPrefix :: Text+fileUrlPrefix = "file://"++-- | Upstream's refusal from @checkURI@.+forbiddenUriMessage :: Text -> Text+forbiddenUriMessage uri = "access to URI '" <> uri <> "' is forbidden in restricted mode"
+ src/Nix/Eval/Print.hs view
@@ -0,0 +1,381 @@+-- | The value printers upstream writes values with.+--+-- @printValue@ (print.cc at Nix 2.24.9) quotes a value in a message.+-- @builtins.trace@ prints a value that is not a string through it, and a+-- type error that shows the offending value prints it after the type, as+-- in @expected a set but found an integer: 1@.+--+-- Upstream prints with one of two option sets: its defaults, which print+-- the whole value, and @errorPrintOptions@ (print-options.hh), which cut+-- an oversized value short so it cannot flood an error message.+--+-- Neither option set forces anything, so in 'printValue' an element or+-- attribute not yet evaluated prints as upstream's thunk marker, and which+-- ones do follows nova-nix's thunk allocation. That is upstream's for a+-- literal written in the list or set itself, for a function's argument,+-- and for anything already forced. It is not where upstream hands over a+-- value without a thunk and nova-nix makes one (an indented string, a+-- @~\/@ path, a variable bound to a literal by @let@ or @rec@, a global+-- such as @map@). A function value carries neither the name nor+-- the source position upstream prints after @lambda@, so its marker holds+-- the word alone. A set or list reached a second time prints again where+-- upstream marks it repeated, which needs value identity this printer+-- does not track.+--+-- Upstream's @printAmbiguous@ (print-ambiguous.cc) writes what+-- @nix-instantiate --eval@ prints, and 'printAmbiguous' writes what+-- @nova-nix eval@ prints. It renders to bytes: a string's payload is+-- bytes that need not be UTF-8, and upstream writes them as they are.+module Nix.Eval.Print+ ( PrintOptions (..),+ printValue,+ printAmbiguous,+ formatXmlFloat,+ )+where++import Control.Monad.State.Strict (State, evalState, gets, modify')+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Builder as BB+import qualified Data.ByteString.Char8 as BC+import qualified Data.ByteString.Lazy as BL+import Data.Char (isAsciiLower, isAsciiUpper, isDigit)+import Data.List (partition)+import Data.String (IsString)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import Nix.Eval.Types (AttrSet, NixValue (..), Thunk (..), attrSetToAscList, bytesToTextLossy, clistThunks, readThunkValue, thunkUnderEvaluation)++-- | Which of upstream's option sets a print follows.+data PrintOptions+ = -- | Upstream's defaults: the whole value, as @builtins.trace@ prints it.+ PrintInFull+ | -- | @errorPrintOptions@: nesting, attributes, list items and string+ -- bytes past its limits are cut, and what was cut is counted instead.+ PrintForError++-- | A bound one of the option sets puts on a print.+data Limit = Unlimited | AtMost !Int++-- | Whether a count is still inside a bound.+within :: Int -> Limit -> Bool+within _ Unlimited = True+within count (AtMost bound) = count < bound++-- | @errorPrintOptions@ (print-options.hh at 2.24.9): how deep a print+-- goes, and how many attributes, list items and string bytes it shows.+errorMaxDepth, errorMaxAttrs, errorMaxListItems, errorMaxStringLength :: Int+errorMaxDepth = 10+errorMaxAttrs = 10+errorMaxListItems = 10+errorMaxStringLength = 1024++depthLimit, attrLimit, itemLimit, stringLimit :: PrintOptions -> Limit+depthLimit = limitedTo errorMaxDepth+attrLimit = limitedTo errorMaxAttrs+itemLimit = limitedTo errorMaxListItems+stringLimit = limitedTo errorMaxStringLength++-- | The defaults bound nothing; @errorPrintOptions@ bounds at its value.+limitedTo :: Int -> PrintOptions -> Limit+limitedTo _ PrintInFull = Unlimited+limitedTo bound PrintForError = AtMost bound++-- | How many attributes and list items one print has shown. Upstream's+-- bounds on them count over the whole print, not per set or list.+data Shown = Shown {shownAttrs :: !Int, shownItems :: !Int}++type Printer = State Shown++-- | Render a value as upstream's @printValue@ renders it under the given+-- options.+printValue :: PrintOptions -> NixValue -> Text+printValue options val = evalState (printAt options 0 val) (Shown 0 0)++-- | A value at a nesting depth, the top-level value being at 0.+printAt :: PrintOptions -> Int -> NixValue -> Printer Text+printAt options depth val = case val of+ VInt n -> pure (T.pack (show n))+ VFloat f -> pure (formatXmlFloat f)+ VBool True -> pure "true"+ VBool False -> pure "false"+ VNull -> pure "null"+ VStr bytes _ -> pure (printString options bytes)+ VPath p -> pure p+ VList cl -> printList options depth (map Thunk (clistThunks cl))+ VAttrs attrs -> printAttrs options depth attrs+ -- What upstream prints for a lambda whose expression it cannot reach.+ VLambda {} -> pure (marker "lambda")+ VBuiltin name [] -> pure (marker ("primop " <> name))+ VBuiltin name _ -> pure (marker ("partially applied primop " <> name))+ -- A compiled pattern exists only among the arguments of a partially+ -- applied match or split and names neither, so it prints as upstream+ -- prints an application whose primop it cannot name.+ VCompiledRegex _ -> pure (marker "partially applied primop")++-- | Upstream's @printAttrs@. A set's attributes count toward the bound+-- after the attributes of any set inside them, as upstream counts one+-- only once its value has printed.+printAttrs :: PrintOptions -> Int -> AttrSet -> Printer Text+printAttrs options depth attrs+ | within depth (depthLimit options) =+ bracketed "{" "}" <$> printBounded (attrLimit options) shownAttrs countAttr elidedAttrs (map printAttr (orderedAttrs options attrs))+ | otherwise = pure "{ ... }"+ where+ printAttr (name, thunk) = do+ shown <- printThunk options (depth + 1) thunk+ pure (builderText (printIdentifier (TE.encodeUtf8 name)) <> " = " <> shown <> ";")+ countAttr counts = counts {shownAttrs = shownAttrs counts + 1}+ elidedAttrs left = elided left "attribute" "attributes"++-- | Upstream's @printList@.+printList :: PrintOptions -> Int -> [Thunk] -> Printer Text+printList options depth thunks+ | within depth (depthLimit options) =+ bracketed "[" "]" <$> printBounded (itemLimit options) shownItems countItem elidedItems (map (printThunk options (depth + 1)) thunks)+ | otherwise = pure "[ ... ]"+ where+ countItem counts = counts {shownItems = shownItems counts + 1}+ elidedItems left = elided left "item" "items"++-- | Print the entries of one set or list while the print's running count+-- is inside the bound, then, in place of the rest, how many were left.+printBounded :: Limit -> (Shown -> Int) -> (Shown -> Shown) -> (Int -> Text) -> [Printer Text] -> Printer [Text]+printBounded bound counted count elision = go+ where+ go :: [Printer Text] -> Printer [Text]+ go [] = pure []+ go entries@(entry : rest) = gets counted >>= continueAt+ where+ continueAt sofar+ | within sofar bound = do+ shown <- entry+ modify' count+ (shown :) <$> go rest+ | otherwise = pure [elision (length entries)]++-- | A set's attributes in upstream's order: by name, with @_type@ and+-- @type@ ahead of the rest wherever attributes are bounded (print.cc sorts+-- by its @ImportantFirstAttrNameCmp@ then).+orderedAttrs :: PrintOptions -> AttrSet -> [(Text, Thunk)]+orderedAttrs options attrs = case attrLimit options of+ Unlimited -> byName+ AtMost _ -> important <> rest+ where+ byName = attrSetToAscList attrs+ (important, rest) = partition ((`elem` importantAttrNames) . fst) byName++-- | Upstream's @isImportantAttrName@.+importantAttrNames :: [Text]+importantAttrNames = ["_type", "type"]++-- | An element or attribute value, read without forcing it: its value+-- once it has one, and otherwise upstream's marker for a thunk, or for a+-- thunk whose force is still running.+printThunk :: PrintOptions -> Int -> Thunk -> Printer Text+printThunk options depth thunk = case readThunkValue thunk of+ Just val -> printAt options depth val+ Nothing+ | thunkUnderEvaluation thunk -> pure (marker "potential infinite recursion")+ | otherwise -> pure (marker "thunk")++-- | Upstream's @printLiteralString@: quoted and escaped so the lexer reads+-- the same string back. The cut and the count of what it left out are in+-- bytes, as upstream's are. A message is 'Text', so a multi-byte+-- character the cut splits decodes to U+FFFD where upstream writes the+-- partial bytes raw.+printString :: PrintOptions -> ByteString -> Text+printString options bytes = builderText (quotedBytes shown bytes) <> elision+ where+ total = BS.length bytes+ shown = case stringLimit options of+ Unlimited -> total+ AtMost bound -> min bound total+ elision+ | total == shown = ""+ | otherwise = " " <> elided (total - shown) "byte" "bytes"++-- | The first @count@ bytes as a string literal.+quotedBytes :: Int -> ByteString -> BB.Builder+quotedBytes count bytes = "\"" <> escapeBytes count bytes <> "\""++-- | The first @count@ bytes, escaped. The test for @${@ looks one byte+-- past the cut, as upstream's does, so a @$@ shown last is escaped when+-- the @{@ after it was cut.+escapeBytes :: Int -> ByteString -> BB.Builder+escapeBytes count bytes = case BC.uncons bytes of+ Just (c, rest) | count > 0 -> escapeChar c rest <> escapeBytes (count - 1) rest+ _ -> mempty++escapeChar :: Char -> ByteString -> BB.Builder+escapeChar c rest = case c of+ '"' -> "\\\""+ '\\' -> "\\\\"+ '\n' -> "\\n"+ '\r' -> "\\r"+ '\t' -> "\\t"+ '$' | "{" `BS.isPrefixOf` rest -> "\\$"+ _ -> BB.char8 c++-- | Upstream's @printElided@, with its @pluralize@: how many of a thing a+-- bound left out.+elided :: Int -> Text -> Text -> Text+elided 1 single _ = marker ("1 " <> single <> " elided")+elided count _ plural = marker (T.pack (show count) <> " " <> plural <> " elided")++-- | Upstream brackets its markers in guillemets (U+00AB, U+00BB), written+-- as escapes to keep the source ASCII.+marker :: Text -> Text+marker inner = "\x00AB" <> inner <> "\x00BB"++-- | Rendered bytes as message text.+builderText :: BB.Builder -> Text+builderText = bytesToTextLossy . BL.toStrict . BB.toLazyByteString++-- ---------------------------------------------------------------------------+-- The eval result+-- ---------------------------------------------------------------------------++-- | Render a value as @nix-instantiate --eval@ prints its result, through+-- upstream's @printAmbiguous@ (print-ambiguous.cc at 2.24.9), without the+-- newline the command writes after it. A string prints as its own bytes,+-- escaped as 'printValue' escapes it and never cut, and a float in a+-- default ostream's six-digit form, as 'formatXmlFloat' writes it.+printAmbiguous :: NixValue -> BB.Builder+printAmbiguous val = case val of+ VInt n -> BB.int64Dec n+ VFloat f -> TE.encodeUtf8Builder (formatXmlFloat f)+ VBool True -> "true"+ VBool False -> "false"+ VNull -> "null"+ VStr bytes _ -> quotedBytes (BS.length bytes) bytes+ VPath p -> TE.encodeUtf8Builder p+ VList cl -> bracketed "[" "]" (map (printElement . Thunk) (clistThunks cl))+ VAttrs attrs -> bracketed "{" "}" (map printAttribute (attrSetToAscList attrs))+ VLambda {} -> "<LAMBDA>"+ VBuiltin _ [] -> "<PRIMOP>"+ VBuiltin _ _ -> "<PRIMOP-APP>"+ -- A compiled pattern exists only among the arguments of a partially+ -- applied match or split, so it prints as that application does.+ VCompiledRegex _ -> "<PRIMOP-APP>"+ where+ printAttribute (name, thunk) =+ printIdentifier (TE.encodeUtf8 name) <> " = " <> printElement thunk <> ";"++-- | Upstream follows the opening bracket and every item with a space, so+-- an empty list is @[ ]@. Both printers bracket this way.+bracketed :: (IsString s, Monoid s) => s -> s -> [s] -> s+bracketed open close items = open <> " " <> foldMap (<> " ") items <> close++-- | A list element or attribute value: its value when it has one, and+-- upstream's marker for a thunk when it was never forced.+printElement :: Thunk -> BB.Builder+printElement thunk = maybe "<CODE>" printAmbiguous (readThunkValue thunk)++-- | Upstream's @printIdentifier@ (print.cc), which @printAmbiguous@ writes+-- an attribute name through (nixexpr.cc's @operator<<@ on a symbol): a+-- name the lexer reads back as an identifier prints bare, and any other+-- prints as a string literal. The test is on bytes, so a byte past ASCII+-- always quotes.+printIdentifier :: ByteString -> BB.Builder+printIdentifier name+ | isBareIdentifier name = BB.byteString name+ | otherwise = quotedBytes (BS.length name) name++isBareIdentifier :: ByteString -> Bool+isBareIdentifier name = case BC.uncons name of+ Just (first, rest) ->+ identifierStart first+ && BC.all identifierChar rest+ && name `notElem` reservedKeywords+ Nothing -> False+ where+ identifierStart c = isAsciiLower c || isAsciiUpper c || c == '_'+ identifierChar c = identifierStart c || isDigit c || c == '\'' || c == '-'++-- | Upstream's @isReservedKeyword@ set (print.cc). @or@ is not in it: the+-- lexer accepts @or@ as an attribute name.+reservedKeywords :: [ByteString]+reservedKeywords = ["if", "then", "else", "assert", "with", "let", "in", "rec", "inherit"]++-- ---------------------------------------------------------------------------+-- Floats+-- ---------------------------------------------------------------------------++-- | Exponent suffix of the XML float layout, as printf writes it: 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@ and @printValue@ render 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/SourceError.hs view
@@ -0,0 +1,57 @@+-- | The messages upstream gives a failed read of a source path.+--+-- Nix 2.24.9 reads source paths through @PosixSourceAccessor@, which+-- names the access that failed and the path, then appends @strerror@ as+-- every @SysError@ does. GHC raises the same failures as 'IOException's+-- whose text names the Haskell function instead, so each access the+-- evaluator makes is tagged and its failure reworded here.+module Nix.Eval.SourceError+ ( SourceAccess (..),+ sourceErrorMessage,+ )+where++import Data.Maybe (isNothing)+import Data.Text (Text)+import qualified Data.Text as T+import Foreign.C.Error (Errno (..), eISDIR, eNOTDIR)+import GHC.IO.Exception (IOErrorType (InappropriateType), IOException (..))+import System.IO.Error (isDoesNotExistError)++-- | The access to a source path that failed.+data SourceAccess+ = -- | An lstat. An absent path (@ENOENT@ or @ENOTDIR@, which+ -- @maybeLstat@ in file-system.cc treats alike) is+ -- @SourceAccessor::lstat@'s @path '%s' does not exist@, anything else+ -- @maybeLstat@'s @getting status of '%s'@.+ StatPath+ | -- | Opening a file and reading it (@PosixSourceAccessor::readFile@):+ -- @opening file '%s'@, or @reading from file '%s'@ for a directory,+ -- which upstream opens and then fails to read.+ ReadFile+ | -- | Listing a directory (@PosixSourceAccessor::readDirectory@):+ -- @reading directory %s@, the path unquoted.+ ReadDirectory+ deriving (Eq, Show)++-- | Upstream's message for an access to a path that raised an exception.+sourceErrorMessage :: SourceAccess -> Text -> IOException -> Text+sourceErrorMessage access path err = case access of+ StatPath+ | isDoesNotExistError err || errnoIs eNOTDIR -> "path '" <> path <> "' does not exist"+ | otherwise -> "getting status of '" <> path <> "': " <> reason+ ReadFile+ | isDirectory -> "reading from file '" <> path <> "': " <> isDirectoryReason+ | otherwise -> "opening file '" <> path <> "': " <> reason+ ReadDirectory -> "reading directory " <> path <> ": " <> reason+ where+ errnoIs code = fmap Errno (ioe_errno err) == Just code+ -- An errno-derived IOException carries strerror as its description.+ reason = T.pack (ioe_description err)+ -- GHC's openFile refuses a directory itself, with no errno and its own+ -- lower-case wording; upstream's open succeeds and the read fails+ -- with EISDIR.+ isDirectory =+ errnoIs eISDIR || (ioe_type err == InappropriateType && isNothing (ioe_errno err) && ioe_description err == ghcIsDirectory)+ ghcIsDirectory = "is a directory"+ isDirectoryReason = "Is a directory"
src/Nix/Eval/StringInterp.hs view
@@ -1,16 +1,18 @@ -- | String coercion and indented-string whitespace stripping. ----- Provides 'coerceToString' (string interpolation and @builtins.toString@) and--- 'stripIndentedChunks' (the indented-string indentation algorithm, applied by--- the bytecode evaluator). Force/apply are passed in as parameters to break the--- import cycle with @Nix.Eval@.+-- Provides 'coerceToString' (string interpolation and @builtins.toString@),+-- 'concatChunks' (a double-quoted string's parts joined) and+-- 'stripIndentedChunks' (the indented-string indentation algorithm, applied+-- by the bytecode evaluator). Force/apply are passed in as parameters to+-- break the import cycle with @Nix.Eval@. module Nix.Eval.StringInterp- ( stripIndentedChunks,+ ( StringChunk (..),+ concatChunks,+ stripIndentedChunks, CoercePath, coerceToString,+ joinCoercedList, formatNixFloat,- formatJsonFloat,- formatXmlFloat, ) where @@ -20,8 +22,10 @@ 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 (floatToDigits, showFFloat)+import Nix.Eval.CList (clistLen, clistThunks)+import Nix.Eval.Print (PrintOptions (..), printValue)+import Nix.Eval.Types (MonadEval (..), NixValue (..), StringContext, Thunk (..), attrSetLookup, emptyContext, typeName)+import Numeric (showFFloat) -- | Force a thunk to a value. type Force m = Thunk -> m NixValue@@ -39,62 +43,138 @@ -- 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, 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). 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)+-- | One evaluated part of a string, with the three-way distinction+-- upstream's @stripIndentation@ sees: a literal is measured by the+-- indentation scan and stripped; an escape (@'''@, @''\n@, ...) is+-- stripped but never measured, since upstream's lexer emits it without+-- the hasIndentation mark; an interpolated value is opaque to both and+-- carries the context it was coerced with.+data StringChunk+ = ChunkLit !ByteString+ | ChunkEsc !ByteString+ | ChunkInterp !ByteString !StringContext+ deriving (Eq, Show)++-- | The bytes a chunk contributes, whatever its kind.+chunkBytes :: StringChunk -> ByteString+chunkBytes (ChunkLit t) = t+chunkBytes (ChunkEsc t) = t+chunkBytes (ChunkInterp t _) = t++-- | The combined context of the interpolated chunks.+chunksContext :: [StringChunk] -> StringContext+chunksContext chunks = mconcat [c | ChunkInterp _ c <- chunks]++-- | Join evaluated chunks as a double-quoted string does: bytes in order,+-- nothing stripped.+concatChunks :: [StringChunk] -> (ByteString, StringContext)+concatChunks chunks = (BS.concat (map chunkBytes chunks), chunksContext chunks)++-- | Strip the common indentation from the evaluated parts of an indented+-- string, as upstream's @ParserState::stripIndentation@ (parser-state.hh+-- at 2.24.9, the same through 2.33.2) does at parse time. Three of its+-- rules are not the obvious ones:+--+-- * The minimum indent starts at 'indentSentinel', and only a character+-- that is neither a space nor a newline lowers it, an escape or an+-- interpolation at line start counting as one. A string with no such+-- line loses every leading space on every line.+--+-- * Only a space is indentation. A tab fixes the minimum at its column+-- and stays in the output.+--+-- * Once the last part is stripped, a final line holding only spaces is+-- removed from it, so the closing @''@'s own indentation never reaches+-- the value. The rule runs on that part's text alone: a string ending+-- in an interpolation keeps whatever follows it.+--+-- Escapes are stripped like literals and skipped by the scan, so an+-- escaped newline starts a line whose spaces are stripped but were never+-- measured; interpolated values are opaque to both passes and leave the+-- line mid-way. The scan is byte-level via the Char8 view: it only ever+-- compares against space and newline, single bytes in UTF-8 that never+-- occur inside a multi-byte sequence, so multi-byte content passes+-- through untouched.+stripIndentedChunks :: [StringChunk] -> (ByteString, StringContext) stripIndentedChunks chunks =- let stripped = dropLeadingNL (chunksStrip (chunksMinIndent chunks) chunks)- in (BS.concat (map snd stripped), mconcat [c | (_, _, c) <- chunks])+ (BS.concat (chunksStrip (chunksMinIndent chunks) chunks), chunksContext chunks)++-- | Upstream's starting minimum (@size_t minIndent = 1000000@): until a+-- line is measured, every leading space is indentation.+indentSentinel :: Int+indentSentinel = 1000000++-- | Scan state: whether the current line has held only spaces so far,+-- how many, and the minimum measured.+data ScanState = ScanState !Bool !Int !Int++-- | The common indentation of the literal chunks.+chunksMinIndent :: [StringChunk] -> Int+chunksMinIndent chunks =+ let ScanState _ _ measured = foldl' scanChunk (ScanState True 0 indentSentinel) chunks+ in measured where- dropLeadingNL ((True, t) : rest) =- (True, case BC.uncons t of { Just ('\n', r) -> r; _ -> t }) : rest- dropLeadingNL other = other+ scanChunk st (ChunkLit t) = BC.foldl' scanChar st t+ scanChunk st _ = endLeadingSpace st+ scanChar (ScanState True cur minIndent) ' ' = ScanState True (cur + 1) minIndent+ scanChar (ScanState _ _ minIndent) '\n' = ScanState True 0 minIndent+ scanChar st@(ScanState True _ _) _ = endLeadingSpace st+ scanChar st _ = st+ endLeadingSpace (ScanState True cur minIndent) = ScanState False cur (min minIndent cur)+ endLeadingSpace st = st --- | 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, ByteString, StringContext)] -> Int-chunksMinIndent = result . foldl' stepChunk (True, 0, Nothing)+-- | Strip state carried across parts: whether the current line has held+-- only spaces so far, and how many of them have been seen.+data StripState = StripState !Bool !Int++-- | Strip @minIndent@ leading spaces from every line of the literal and+-- escape chunks; an interpolated chunk is emitted verbatim.+chunksStrip :: Int -> [StringChunk] -> [ByteString]+chunksStrip minIndent = go (StripState True 0) 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 = 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)- | atStart = (False, cur, bump mi cur)- | c == '\n' = (True, 0, mi)- | otherwise = (False, cur, mi)- bump Nothing x = Just x- bump (Just m) x = Just (min m x)+ go _ [] = []+ go _ (ChunkInterp t _ : rest) = t : go (StripState False 0) rest+ go st (ChunkLit t : rest) = stripText st t rest+ go st (ChunkEsc t : rest) = stripText st t rest+ stripText st t rest =+ let (stripped, next) = stripLines minIndent st t+ in finishPart rest stripped : go next rest+ -- Upstream's @n == 1@: only the last part sheds a final line of spaces.+ finishPart [] = dropSpaceOnlyLastLine+ finishPart _ = id --- | 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, ByteString, StringContext)] -> [(Bool, ByteString)]-chunksStrip n = go True 0+-- | Strip one part line by line. At line start a space is dropped while+-- fewer than @minIndent@ of the line's spaces have been seen (upstream's+-- @curDropped++ >= minIndent@ keeps the rest); a line that is still only+-- spaces at the end of the part hands its count to the next part.+stripLines :: Int -> StripState -> ByteString -> (ByteString, StripState)+stripLines minIndent = go [] where- go _ _ [] = []- go _ _ ((False, t, _) : rest) = (False, t) : go False 0 rest- go atStart dropped ((True, t, _) : 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)- | atStart && c == '\n' = ('\n' : acc, True, 0)- | atStart = (c : acc, False, dropped)- | c == '\n' = ('\n' : acc, True, 0)- | otherwise = (c : acc, False, dropped)+ go acc st t+ | BS.null t = (BS.concat (reverse acc), st)+ | otherwise =+ let (line, afterLine) = BC.break (== '\n') t+ (kept, lineEnd) = stripLine st line+ in case BC.uncons afterLine of+ Just (_, remaining) -> go ("\n" : kept : acc) (StripState True 0) remaining+ Nothing -> (BS.concat (reverse (kept : acc)), lineEnd)+ stripLine st@(StripState False _) line = (line, st)+ stripLine (StripState True seen) line+ | BS.null content = (BS.drop dropped spaces, StripState True (seen + spaceCount))+ | otherwise = (BS.drop dropped line, StripState False 0)+ where+ (spaces, content) = BC.span (== ' ') line+ spaceCount = BS.length spaces+ dropped = max 0 (min spaceCount (minIndent - seen)) +-- | Remove a final line that holds only spaces, keeping the newline before+-- it (upstream: @find_last_of('\n')@, then nothing but spaces past it).+-- A part with no newline is left alone.+dropSpaceOnlyLastLine :: ByteString -> ByteString+dropSpaceOnlyLastLine s = case BC.elemIndexEnd '\n' s of+ Just p | BC.all (== ' ') (BS.drop (p + 1) s) -> BS.take (p + 1) s+ _ -> s+ -- | Coerce a Nix value to a (byte) string. -- -- The @coerceMore@ flag mirrors C++ Nix's @coerceToString@ argument: when@@ -102,7 +182,8 @@ -- and null coerce permissively; when 'False' (string interpolation, -- @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.+-- both modes, a list in the permissive one ('joinCoercedList'), and a+-- function never. 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@@ -111,6 +192,16 @@ coerceToString True _ _ _ VNull = pure ("", emptyContext) coerceToString True _ _ _ (VBool True) = pure ("1", emptyContext) coerceToString True _ _ _ (VBool False) = pure ("", emptyContext)+coerceToString True forceFn applyFn coercePathFn (VList cl) = do+ parts <- mapM element (clistThunks cl)+ pure (joinCoercedList [(bytes, isEmptyList) | (bytes, _, isEmptyList) <- parts], mconcat [ctx | (_, ctx, _) <- parts])+ where+ element thunk = do+ val <- forceFn (Thunk thunk)+ (bytes, ctx) <- coerceToString True forceFn applyFn coercePathFn val+ pure (bytes, ctx, emptyList val)+ emptyList (VList inner) = clistLen inner == 0+ emptyList _ = False -- Attribute sets: try __toString first, then outPath (both modes). -- The recursion carries the caller's path coercion, so a path-valued -- @outPath@ lands on the caller's path case, not a fixed one.@@ -124,11 +215,31 @@ Just outPathThunk -> do outPathVal <- forceFn outPathThunk coerceToString coerceMore forceFn applyFn coercePathFn outPathVal- Nothing ->- throwEvalError "cannot coerce a set to a string (missing __toString or outPath)"-coerceToString _ _ _ _ other =- throwEvalError ("cannot coerce " <> typeName other <> " to a string")+ Nothing -> uncoercible (VAttrs attrs)+coerceToString _ _ _ _ other = uncoercible other +-- | The coerced elements of a list joined as upstream's @coerceToString@+-- joins them (eval.cc at 2.24.9): a space after each but the last, except+-- after one that is itself an empty list, which upstream's own comment+-- calls "not quite correct". Each element comes with whether it was one.+joinCoercedList :: [(ByteString, Bool)] -> ByteString+joinCoercedList = BS.concat . pieces+ where+ pieces ((bytes, isEmptyList) : rest@(_ : _))+ | isEmptyList = bytes : pieces rest+ | otherwise = bytes : " " : pieces rest+ pieces [(bytes, _)] = [bytes]+ pieces [] = []++-- | Upstream's refusal of a value no coercion takes (@coerceToString@,+-- eval.cc at 2.24.9): its type, then the value as an error prints it.+-- The message is built before it is thrown, so a thunk it shows reads as+-- it is at the failure, not as it is once the force around the failure+-- has been unwound.+uncoercible :: (MonadEval m) => NixValue -> m a+uncoercible val =+ throwEvalError $! "cannot coerce " <> typeName val <> " to a string: " <> printValue PrintForError val+ -- | 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@@ -155,127 +266,3 @@ | 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
@@ -39,6 +39,7 @@ import Data.Word (Word32) import Foreign.C.Types (CChar, CSize (..)) import Foreign.Ptr (Ptr, nullPtr)+import Nix.Eval.CStatus (cStatusFailure) import System.IO.Unsafe (unsafePerformIO) -- | An interned symbol - a 'Word32' index into the global symbol table.@@ -47,6 +48,11 @@ newtype Symbol = Symbol {unSymbol :: Word32} deriving (Eq, Ord, Show) +-- | @NN_SYMBOL_INVALID@: the interner's failure sentinel, and the index+-- no symbol ever has. Must stay in lockstep with @cbits\/nn_symbol.h@.+invalidSymbolId :: Word32+invalidSymbolId = 0+ -- --------------------------------------------------------------------------- -- FFI imports (unsafe - these never call back to Haskell) -- ---------------------------------------------------------------------------@@ -73,9 +79,9 @@ -- Lifecycle -- --------------------------------------------------------------------------- --- | Initialize the global symbol table. Call once before evaluation.--- @capacity@ is a hint for the expected number of unique symbols.--- Pass 0 to use the default (4096).+-- | Initialize the global symbol table, destroying a live one first.+-- Call once before evaluation. @capacity@ is a hint for the expected+-- number of unique symbols. Pass 0 to use the default (4096). symbolInit :: Word32 -> IO () symbolInit = c_nn_symbol_init @@ -95,9 +101,8 @@ -- 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)+ TF.withCStringLen txt $ \(ptr, len) ->+ checkedSymbol =<< c_nn_symbol_intern ptr (fromIntegral len) -- | Intern raw bytes, returning their 'Symbol'. The C table is -- length-prefixed bytes with no encoding assumption, so arbitrary@@ -107,10 +112,19 @@ -- 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)+ BSU.unsafeUseAsCStringLen bs $ \(ptr, len) ->+ checkedSymbol =<< c_nn_symbol_intern ptr (fromIntegral len) +-- | Reject the interner's failure sentinel before it can become a+-- 'Symbol': wrapped it would read back as the empty string. The table+-- answers it outside its init .. destroy window, and inside it for a+-- string it cannot hold: one that would take the string arena past its+-- 4 GiB of uint32 offsets, or a growth the allocator refuses.+checkedSymbol :: Word32 -> IO Symbol+checkedSymbol sid+ | sid == invalidSymbolId = cStatusFailure "nn_symbol_intern" "the symbol table cannot hold the string (allocation failure or its 4 GiB arena limit)"+ | otherwise = 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'. Only valid for symbols interned@@ -118,7 +132,7 @@ -- byte-string payload must be read with 'symbolBytes' instead. symbolText :: Symbol -> Text symbolText (Symbol sid)- | sid == 0 = T.empty+ | sid == invalidSymbolId = T.empty | otherwise = unsafePerformIO $ do ptr <- c_nn_symbol_text sid if ptr == nullPtr@@ -131,7 +145,7 @@ -- were interned, with no decoding. The read side of 'symbolInternBytes'. symbolBytes :: Symbol -> ByteString symbolBytes (Symbol sid)- | sid == 0 = BS.empty+ | sid == invalidSymbolId = BS.empty | otherwise = unsafePerformIO $ do ptr <- c_nn_symbol_text sid if ptr == nullPtr
src/Nix/Eval/Types.hs view
@@ -12,6 +12,7 @@ CompiledRegex (..), Thunk (..), readThunkValue,+ thunkUnderEvaluation, -- * Attribute sets (C-backed sorted arrays) AttrSet (..),@@ -86,6 +87,7 @@ -- * Display typeName,+ typeOfValue, -- * C thunk-state and value tags (mirror cbits/nn_thunk.h) pattern ThunkPending,@@ -104,6 +106,9 @@ -- * Evaluation monad MonadEval (..),+ PathExistence (..),+ EvalPolicy (..),+ unrestrictedPolicy, PureEval, runPureEval, storePathOrThrow,@@ -111,6 +116,7 @@ where import Control.Monad (forM_)+import Control.Monad.Reader (ReaderT (..), lift) import Data.Bits (shiftL, (.&.), (.|.)) import Data.ByteString (ByteString) import qualified Data.ByteString as BS@@ -135,10 +141,13 @@ 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.CStatus (checkedCPtr)+import Nix.Eval.CThunk (CThunkPtr, cthunkBool, cthunkGetAttrs, cthunkGetBcIdx, cthunkGetBool, cthunkGetCtxStr, cthunkGetFloat, cthunkGetInt, cthunkGetList, cthunkGetPath, cthunkGetStr, cthunkNewBc, cthunkNewComputed, cthunkNewComputedAttrs, cthunkNewComputedCtxStr, cthunkNewComputedFloat, cthunkNewComputedInt, cthunkNewComputedLambda, cthunkNewComputedList, cthunkNewComputedPath, cthunkNewComputedStr, cthunkNull, cthunkPayload, cthunkState, cthunkValueTag)+import Nix.Eval.CallDepth (CallDepth, defaultMaxCallDepth, enterCallFrame, topLevelCallDepth) import Nix.Eval.CanonPath (canonPathValue) import Nix.Eval.Compile (compileExpr, compileFormalsToEval) import Nix.Eval.EvalFormals (EvalFormal (..), EvalFormals (..))+import Nix.Eval.Policy (EvalPolicy (..), unrestrictedPolicy) import Nix.Eval.Symbol (Symbol (..), symbolBytes, symbolIntern, symbolInternBytes, symbolText) import Nix.Expr.Types (CaptureInfo (..), Expr (..), NixAtom (..)) import Nix.Store.Path (StorePath, StorePathNameError, storePathNameErrorText)@@ -267,6 +276,13 @@ pattern ValueCtxStr = 8 pattern ValueLambda = 9 +-- | Whether a thunk is being forced right now, so it holds no value yet:+-- the cell's BLACKHOLE state. Pure for the reason 'readThunkValue' is,+-- and like it an answer about this moment: the state changes when the+-- force finishes or is unwound.+thunkUnderEvaluation :: Thunk -> Bool+thunkUnderEvaluation (Thunk ptr) = unsafePerformIO (cthunkState ptr) == ThunkBlackhole+ -- | Read a COMPUTED thunk's value without forcing. -- Returns 'Nothing' for PENDING or BLACKHOLE thunks. -- Uses 'unsafePerformIO' - safe because C reads are idempotent.@@ -324,8 +340,6 @@ VAttrs !AttrSet | -- | Lambda closure: captures environment, formals, body bytecode index. VLambda !Env !EvalFormals !Word32- | -- | A realized derivation (build recipe).- VDerivation !Derivation | -- | Built-in function, dispatched by name. -- Accumulated args support curried partial application. VBuiltin !Text ![NixValue]@@ -443,8 +457,7 @@ attrSetFromMap :: Map Text Thunk -> AttrSet attrSetFromMap m = m `seq` unsafePerformIO $ do let pairs = Map.toList m- n = fromIntegral (length pairs)- cset <- cattrsetNew n+ cset <- cattrsetNew (length pairs) forM_ pairs $ \(key, Thunk ptr) -> do sym <- symbolIntern key cattrsetInsert cset sym ptr@@ -458,8 +471,7 @@ {-# NOINLINE buildCAttrSetKeys #-} buildCAttrSetKeys :: [Text] -> CAttrSet buildCAttrSetKeys keys = unsafePerformIO $ do- let n = fromIntegral (length keys)- cset <- cattrsetNew n+ cset <- cattrsetNew (length keys) forM_ keys $ \key -> do sym <- symbolIntern key cattrsetInsert cset sym nullPtr@@ -516,39 +528,29 @@ 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 ()) -> Word32 -> Env newCEnv slots slotCount lazyScope parent withs withCount = Env- ( checkedCPtr "newCEnv" $- unsafePerformIO- ( cenvNew+ ( unsafePerformIO+ ( checkedCPtr "nn_env_new"+ =<< 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 (checkedCPtr "newMinimalEnv" (unsafePerformIO (cenvNewMinimal slots (fromIntegral n))))+ Env (unsafePerformIO (checkedCPtr "nn_env_new_minimal" =<< 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.@@ -615,14 +617,14 @@ {-# NOINLINE envFromSlots #-} envFromSlots :: Ptr CThunkPtr -> Int -> Env -> Env envFromSlots slotsPtr slotCount (Env parentPtr) =- Env (checkedCPtr "envFromSlots" (unsafePerformIO (cenvFromSlots slotsPtr (fromIntegral slotCount) parentPtr)))+ Env (unsafePerformIO (checkedCPtr "nn_env_from_slots" =<< 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 (checkedCPtr "pushWithScope" (unsafePerformIO (cenvPushWith envPtr (castPtr cset))))+ Env (unsafePerformIO (checkedCPtr "nn_env_push_with" =<< cenvPushWith envPtr (castPtr cset))) -- | Read with-scopes array pointer and count from a C env. {-# NOINLINE envWithScopesRaw #-}@@ -648,7 +650,7 @@ -- 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 <- checkedCPtr "withScopesForCapture" <$> cenvAllocWithScopes newCount+ arr <- checkedCPtr "nn_env_alloc_with_scopes" =<< cenvAllocWithScopes newCount forM_ [0 .. fromIntegral existingCount - 1] $ \i -> do val <- peekElemOff existingWiths i pokeElemOff arr i val@@ -668,17 +670,20 @@ mkThunk env thunkExpr = Thunk (newBcThunkPtr thunkExpr env) --- | Like 'mkThunk' but for synthetic thunks that reuse the same 'Expr'--- (e.g. @EApp (EResolvedVar 0 0) (EResolvedVar 0 1)@ in 'deferApply').--- Uses the env pointer for cell uniqueness instead of the expression,--- since GHC's full-laziness transform would otherwise float the shared--- expr to a CAF and all thunks would get the same cell.+-- | A pending thunk running code that many thunks share, each in an env+-- of its own, such as 'Nix.Eval.CBytecode.reservedApply1' for+-- 'Nix.Eval.deferApply'. It takes the index rather than an 'Expr'+-- because compiling per thunk adds a copy of the same code to a store+-- that only grows until 'Nix.Eval.Arena.arenaDestroy'. Uses the env+-- pointer for cell uniqueness instead of the index, which every such+-- thunk shares: keyed on a shared value, GHC's full-laziness transform+-- could float the allocation to a CAF and give every thunk one cell. -- -- Must only be called with freshly-constructed envs (not knot-tied -- recursive envs), since it forces the env pointer to WHNF.-mkSyntheticThunk :: Env -> Expr -> Thunk-mkSyntheticThunk env@(Env envPtr) thunkExpr =- Thunk (newSyntheticBcThunkPtr envPtr thunkExpr env)+mkSyntheticThunk :: Env -> Word32 -> Thunk+mkSyntheticThunk env@(Env envPtr) bcIdx =+ Thunk (newSyntheticBcThunkPtr envPtr bcIdx env) -- | Like 'mkThunk' but avoids C arena allocation for trivial expressions. -- Resolved variables reuse the existing thunk from the env (no wrapper).@@ -689,8 +694,8 @@ cheapThunk env (EResolvedVar level idx) = envLookupResolved level idx env cheapThunk _ (ELit (NixInt n)) = Thunk (newComputedIntPtr n) cheapThunk _ (ELit (NixFloat n)) = Thunk (newComputedFloatPtr n)-cheapThunk _ (ELit (NixBool b)) = Thunk (newComputedBoolPtr (if b then 1 else 0))-cheapThunk _ (ELit NixNull) = Thunk newComputedNullPtr+cheapThunk _ (ELit (NixBool b)) = Thunk (cthunkBool b)+cheapThunk _ (ELit NixNull) = Thunk cthunkNull cheapThunk env (ELambda formals body NoCaptureInfo) = let bodyBcIdx = unsafePerformIO (compileExpr body) evalFormals = unsafePerformIO (compileFormalsToEval formals)@@ -739,8 +744,8 @@ in Thunk (newComputedFloatPtr (castWord64ToDouble w64)) OpLitBool -> let flag = unsafePerformIO (cbcShortArg bcIdx)- in Thunk (newComputedBoolPtr (if flag /= 0 then 1 else 0))- OpLitNull -> Thunk newComputedNullPtr+ in Thunk (cthunkBool (flag /= 0))+ OpLitNull -> Thunk cthunkNull OpLitUri -> let sym = unsafePerformIO (cbcArg1 bcIdx) in Thunk (newComputedStrPtr (symbolBytes (Symbol sym)))@@ -822,14 +827,12 @@ sp <- newStablePtr env cthunkNewBc bcIdx (castStablePtrToPtr sp) --- | Like 'newBcThunkPtr' but keyed on the env's C pointer instead--- of the expression. Used by 'mkSyntheticThunk' where multiple thunks--- share the same expression (e.g. 'deferApplyExpr').+-- | Like 'newBcThunkPtrLazy' but forces the env's C pointer first, for+-- 'mkSyntheticThunk'. {-# NOINLINE newSyntheticBcThunkPtr #-}-newSyntheticBcThunkPtr :: Ptr NnEnv -> Expr -> Env -> CThunkPtr-newSyntheticBcThunkPtr envKey expr env =+newSyntheticBcThunkPtr :: Ptr NnEnv -> Word32 -> Env -> CThunkPtr+newSyntheticBcThunkPtr envKey bcIdx env = unsafePerformIO $ envKey `seq` do- bcIdx <- compileExpr expr sp <- newStablePtr env cthunkNewBc bcIdx (castStablePtrToPtr sp) @@ -849,14 +852,16 @@ cthunkNewBc bcIdx (castStablePtrToPtr sp) -- | Wrap an already-computed value as a thunk.--- Scalars (int/float/bool/null) use inline C thunks (no StablePtr).+-- Ints and floats use inline C thunks (no StablePtr); booleans and null+-- are the shared static cells, as upstream hands out one @vTrue@,+-- @vFalse@ and @vNull@ (eval.cc at 2.24.9, @EvalState::getBool@). -- Attrs, paths, and context-free strings use C-native tags (no StablePtr). -- Other complex values fall back to StablePtr-backed C thunks. evaluated :: NixValue -> Thunk evaluated (VInt n) = Thunk (newComputedIntPtr n) evaluated (VFloat d) = Thunk (newComputedFloatPtr d)-evaluated (VBool b) = Thunk (newComputedBoolPtr (if b then 1 else 0))-evaluated VNull = Thunk newComputedNullPtr+evaluated (VBool b) = Thunk (cthunkBool b)+evaluated VNull = Thunk cthunkNull evaluated (VAttrs (AttrSet cset)) = Thunk (newComputedAttrsPtr cset) evaluated (VPath p) = Thunk (newComputedPathPtr p) evaluated (VStr t ctx)@@ -895,16 +900,6 @@ newComputedFloatPtr :: Double -> CThunkPtr newComputedFloatPtr d = unsafePerformIO (cthunkNewComputedFloat d) --- | Wrap a bool in a pre-computed C thunk (inline, no StablePtr).-{-# NOINLINE newComputedBoolPtr #-}-newComputedBoolPtr :: Word8 -> CThunkPtr-newComputedBoolPtr b = unsafePerformIO (cthunkNewComputedBool b)---- | Wrap null in a pre-computed C thunk (inline, no StablePtr).-{-# NOINLINE newComputedNullPtr #-}-newComputedNullPtr :: CThunkPtr-newComputedNullPtr = unsafePerformIO cthunkNewComputedNull- -- | Wrap a context-free string in a pre-computed C thunk (interned symbol, no StablePtr). {-# NOINLINE newComputedStrPtr #-} newComputedStrPtr :: ByteString -> CThunkPtr@@ -955,19 +950,19 @@ marshalLambda envPtr formals bodyBcIdx = case formals of EFName name -> do Symbol nameSym <- symbolIntern name- lam <- checkedCPtr "marshalLambda" <$> clambdaNew envPtr bodyBcIdx 0 nameSym 0 0+ lam <- checkedCPtr "nn_lambda_new" =<< clambdaNew envPtr bodyBcIdx 0 nameSym 0 0 pure (castPtr lam) EFSet entries allowExtra -> do let count = fromIntegral (length entries) :: Word32 extraFlag = if allowExtra then 1 else 0 :: Word8- lam <- checkedCPtr "marshalLambda" <$> clambdaNew envPtr bodyBcIdx 1 0 extraFlag count+ lam <- checkedCPtr "nn_lambda_new" =<< 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) :: Word32 extraFlag = if allowExtra then 1 else 0 :: Word8- lam <- checkedCPtr "marshalLambda" <$> clambdaNew envPtr bodyBcIdx 2 nameSym extraFlag count+ lam <- checkedCPtr "nn_lambda_new" =<< clambdaNew envPtr bodyBcIdx 2 nameSym extraFlag count fillEntries lam 0 entries pure (castPtr lam) where@@ -1025,7 +1020,7 @@ Symbol textSym <- symbolInternBytes textVal let elems = Set.toAscList ctxSet count = fromIntegral (length elems) :: Word32- ptr <- checkedCPtr "marshalStringContext" <$> cctxstrNew textSym count+ ptr <- checkedCPtr "nn_ctxstr_new" =<< cctxstrNew textSym count fillElems ptr 0 elems pure ptr where@@ -1096,7 +1091,7 @@ if n == 0 then pure (nullPtr, 0) else do- arr <- cenvAllocSlots (fromIntegral n)+ arr <- checkedCPtr "nn_env_alloc_slots" =<< cenvAllocSlots (fromIntegral n) pokeSlots arr 0 thunks pure (arr, n) where@@ -1113,7 +1108,7 @@ {-# NOINLINE allocCSlots #-} allocCSlots :: Int -> Ptr CThunkPtr allocCSlots 0 = nullPtr-allocCSlots n = unsafePerformIO (cenvAllocSlots (fromIntegral n))+allocCSlots n = unsafePerformIO (checkedCPtr "nn_env_alloc_slots" =<< cenvAllocSlots (fromIntegral n)) -- | Fill a pre-allocated C slot array with thunks. Each thunk is -- converted to 'CThunkPtr' via 'thunkToCPtr' and poked at its index.@@ -1128,26 +1123,66 @@ pokeElemOff arr i (thunkToCPtr t) go (i + 1) ts --- | Human-readable type name for error messages.+-- | A value's type as an error message names it: upstream's @showType@ on+-- a value (eval.cc at 2.24.9, the same in 2.33.2), which every type error+-- there uses. It names a primop and the string context the type alone+-- does not show. Upstream's other cases (a thunk, a black hole, a+-- function application, an external value) have no 'NixValue': a value+-- here is always forced, and there are no plugins. typeName :: NixValue -> Text typeName val = case val of VInt _ -> "an integer" VFloat _ -> "a float" VBool _ -> "a Boolean" VNull -> "null"- VStr _ _ -> "a string"+ VStr _ ctx+ | ctx == emptyContext -> "a string"+ | otherwise -> "a string with context" VPath _ -> "a path" VList _ -> "a list" VAttrs _ -> "a set" VLambda {} -> "a function"- VDerivation _ -> "a derivation"- VBuiltin _ _ -> "a built-in function"- VCompiledRegex _ -> "a built-in function"+ VBuiltin name [] -> "the built-in function '" <> name <> "'"+ VBuiltin name _ -> "the partially applied built-in function '" <> name <> "'"+ -- Only ever an argument inside a 'VBuiltin', and carrying no primop+ -- name, so it takes the name upstream gives the function type itself.+ VCompiledRegex _ -> "a function" +-- | @builtins.typeOf@: the name of upstream's @ValueType@, the+-- classification its type checks compare (a lambda and a primop are both+-- @nFunction@).+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"+ VCompiledRegex _ -> "lambda"+ -- --------------------------------------------------------------------------- -- Evaluation monad -- --------------------------------------------------------------------------- +-- | What @builtins.pathExists@ asks of a path, decided from the+-- argument's spelling as upstream's @prim_pathExists@ decides it+-- (primops.cc at 2.24.9).+data PathExistence+ = -- | Whether an entry is there: the ancestors are resolved and the+ -- final component is checked in place, so a symlink exists whatever+ -- it points at (upstream's @SymlinkResolution::Ancestors@).+ ExistsAsEntry+ | -- | Whether a directory is there, asked by a string ending in @/@ or+ -- @/.@: every symlink is followed, the final one included+ -- (@SymlinkResolution::Full@), and a file answers false.+ ExistsAsDirectory+ deriving (Eq, Show)+ -- | Effect class for Nix evaluation. Core logic is polymorphic in @m@ -- so the same evaluator composes into 'PureEval' for tests or @IO@ for -- real file-system access (e.g. @import@, @readFile@).@@ -1177,8 +1212,27 @@ -- failed fetch is an eval error. onEvalError :: m a -> m () -> m a - doesPathExist :: Text -> m Bool+ -- | Run the action; a failure escaping it (a throw, an eval error or an+ -- abort) gains the given line of context outside the lines it already+ -- carries, as upstream's @Error::addTrace@ adds one where a @catch@+ -- rethrows. Nothing else about the failure changes: its message stays+ -- the same, and a throw stays catchable by @builtins.tryEval@.+ addErrorTrace :: Text -> m a -> m a + -- | Run an application one call frame deeper, or refuse it with+ -- upstream's "stack overflow; max-call-depth exceeded" when the frames+ -- already active exceed the ceiling ('Nix.Eval.CallDepth.enterCallFrame'+ -- is the rule). An eval error, not a throw: upstream raises an+ -- EvalError, which its tryEval does not catch. The frame ends with the+ -- action, on failure too, so an error unwinding through a tryEval+ -- leaves the count that tryEval was entered at.+ withCallFrame :: m a -> m a++ -- | Whether something is at the path, as @builtins.pathExists@ asks+ -- it; the query says how the final component is read. A path the+ -- policy refuses is absent.+ doesPathExist :: PathExistence -> Text -> m Bool+ -- | List a directory, returning @(name, fileType)@ pairs. -- @fileType@ is one of @"regular"@, @"directory"@, or @"symlink"@ -- (matching Nix's @builtins.readDir@ semantics).@@ -1186,8 +1240,9 @@ importFile :: Text -> m NixValue - -- | Look up an environment variable. Returns @""@ if unset.- getEnvVar :: Text -> m Text+ -- | Look up an environment variable by the bytes of its name, answering+ -- the bytes of its value, or @""@ if unset.+ getEnvVar :: ByteString -> m ByteString -- | Get the current epoch time (seconds since 1970-01-01). getCurrentTime :: m Int64@@ -1207,9 +1262,19 @@ readFileBytes :: Text -> m ByteString -- | Classify a single filesystem path as @"regular"@, @"directory"@,- -- @"symlink"@, or @"unknown"@. Used by @builtins.readFileType@.+ -- @"symlink"@, or @"unknown"@: an lstat of the path itself, refused+ -- when a symlink sits anywhere above it, as upstream's accessor+ -- refuses one. Used by @builtins.readFileType@ and, on a resolved+ -- root, by the source tree walk. getFileType :: Text -> m Text + -- | Follow every symlink in a path, as upstream's+ -- @SourcePath::resolveSymlinks@ does before a tree is read+ -- (@addPath@, @prim_filterSource@): no component of the result is a+ -- symlink, so the lstats of the walk that follows never meet one+ -- above the entry they classify.+ resolveSymlinks :: Text -> m Text+ -- | Run an external process: @(command, args, stdin) -> (exitCode, stdout, stderr)@. runProcess :: Text -> [Text] -> Text -> m (Int, Text, Text) @@ -1330,12 +1395,32 @@ -- not on disk during evaluation. lookupSessionDrv :: Text -> m (Maybe Derivation) + -- | The references recorded when this session wrote a store path (a+ -- @builtins.toFile@'s text with context, a copied source's none), or+ -- 'Nothing' if this session did not write it. The walk of a+ -- @drvPath@'s closure follows them as upstream's @computeFSClosure@+ -- follows its database's. Pure evaluators write nothing.+ lookupSessionReferences :: StorePath -> m (Maybe [StorePath])+ -- | 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 + -- | The policy this evaluation runs under. The builtins whose behaviour+ -- upstream changes by setting consult it: @storePath@ is refused and the+ -- fetchers demand a hash or revision under @pure-eval@, and a search path+ -- miss is worded for the mode. The filesystem and URI gates are not+ -- decided here; they live in the instance, where the allowed set is.+ evalPolicy :: m EvalPolicy++ -- | Upstream @checkURI@: refuse a fetch of this URI under @restrict-eval@+ -- unless @allowed-uris@ admits it (a path or @file://@ URI is checked+ -- against the allowed paths instead). A no-op under any other policy.+ -- Every fetcher calls it before anything is spawned or downloaded.+ checkUri :: Text -> m ()+ -- | 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@@ -1352,29 +1437,39 @@ -- 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.-newtype PureEval a = PureEval (Either PureError a)+-- | Pure evaluation monad - @Either PureError@ under a reader carrying+-- the call depth. IO builtins ('readFile', 'import') are unavailable;+-- everything else evaluates identically to the IO version, the+-- @max-call-depth@ ceiling included, at its upstream default. It carries+-- no filesystem policy: there is no filesystem, environment or network+-- behind it to gate, so it reports 'unrestrictedPolicy' and every URI+-- check passes.+newtype PureEval a = PureEval (ReaderT CallDepth (Either PureError) a) deriving (Functor, Applicative, Monad) +-- | Fail a pure evaluation.+pureFailure :: PureError -> PureEval a+pureFailure = PureEval . lift . Left+ -- | Run a pure evaluation, flattening the internal error into 'Text'. An abort -- is prefixed @\"evaluation aborted: \"@, matching 'EvalIO'. 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)+runPureEval (PureEval action) =+ case runReaderT action (topLevelCallDepth defaultMaxCallDepth) of+ Right a -> Right a+ Left (PThrow t) -> Left t+ Left (PError t) -> Left t+ Left (PAbort t) -> Left ("evaluation aborted: " <> t) instance MonadEval PureEval where- throwEvalError msg = PureEval (Left (PError msg))- throwCatchableError msg = PureEval (Left (PThrow msg))- abortEvaluation msg = PureEval (Left (PAbort msg))+ throwEvalError = pureFailure . PError+ throwCatchableError = pureFailure . PThrow+ abortEvaluation = pureFailure . PAbort -- 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+ PureEval $ ReaderT $ \depth -> case runReaderT action depth of Left (PThrow t) -> Right (Left t) Left other -> Left other Right a -> Right (Right a)@@ -1382,7 +1477,16 @@ -- 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++ -- 'runPureEval' reports a failure's message alone, so a line of context+ -- has nowhere to go.+ addErrorTrace _ action = action++ -- The reader scopes the frame: the action runs one deeper, and whatever+ -- follows it sees the depth it started from, failure or not.+ withCallFrame (PureEval action) =+ PureEval $ ReaderT $ \depth -> either (Left . PError) (runReaderT action) (enterCallFrame depth)+ doesPathExist _ _ = pure False listDirectory _ = throwEvalError "builtins.readDir: not available in pure evaluation" importFile _ = throwEvalError "import: not available in pure evaluation" getEnvVar _ = pure ""@@ -1391,6 +1495,9 @@ scopedImportFile _ _ = throwEvalError "scopedImport: not available in pure evaluation" readFileBytes _ = throwEvalError "readFile: not available in pure evaluation" getFileType _ = throwEvalError "readFileType: not available in pure evaluation"++ -- No filesystem, so no symlinks: a path resolves to itself.+ resolveSymlinks = pure runProcess _ _ _ = throwEvalError "runProcess: not available in pure evaluation" createScratchDir _ = throwEvalError "createScratchDir: not available in pure evaluation" removeScratchDir _ = pure ()@@ -1417,10 +1524,13 @@ -- Pure eval keeps no session drv closure, so an all-outputs reference's -- output names are never recoverable here either. lookupSessionDrv _ = pure Nothing+ lookupSessionReferences _ = 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+ evalPolicy = pure unrestrictedPolicy+ checkUri _ = pure () resolvePathLiteral = pure . canonPathValue forceThunk evalFn (Thunk ptr) = -- Read the C thunk via unsafePerformIO - safe because reads are
src/Nix/Expr/ClosureTrim.hs view
@@ -113,6 +113,7 @@ trimPart :: StringPart -> StringPart trimPart p@(StrLit _) = p+trimPart p@(StrEsc _) = p trimPart (StrInterp e) = StrInterp (trimExpr e) trimKey :: AttrKey -> AttrKey@@ -120,11 +121,11 @@ trimKey (DynamicKey e) = DynamicKey (trimExpr e) 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+trimBinding (NamedBinding key bodyExpr) =+ NamedBinding (trimKey key) (trimExpr bodyExpr)+trimBinding (Inherit name var) = Inherit name (trimExpr var)+trimBinding (InheritFrom fromExpr names) =+ InheritFrom (trimExpr fromExpr) names trimFormals :: Formals -> Formals trimFormals f@(FormalName _) = f@@ -194,7 +195,7 @@ | otherwise -> (Set.empty, False, False) EAttrs True bindings captureInfo -> -- Recursive attrs create a scope (depth + 1)- let (vs1, h1, w1) = foldBindings (depth + 1) bindings+ let (vs1, h1, w1) = foldBindings (depth + 1) (Just depth) bindings (vs2, innerWithVar) = case captureInfo of NoCaptureInfo -> (Set.empty, False) Captures caps ->@@ -204,7 +205,7 @@ in (Set.union vs1 vs2, h1, w1 || innerWithVar) EAttrs False bindings _captureInfo -> -- Non-recursive attrs: no new scope- foldBindings depth bindings+ foldBindings depth (Just depth) bindings EList elems -> foldExprs depth elems ESelect target path defExpr -> let (vs1, h1, w1) = collectFreeVars depth target@@ -217,10 +218,7 @@ let (vs1, h1, w1) = collectFreeVars depth target (vs2, h2, w2) = foldKeys depth path in (Set.union vs1 vs2, h1 || h2, w1 || w2)- EApp f x ->- let (vs1, h1, w1) = collectFreeVars depth f- (vs2, h2, w2) = collectFreeVars depth x- in (Set.union vs1 vs2, h1 || h2, w1 || w2)+ EApp f x -> foldExprs depth [f, x] ELambda formals body_ captures -> -- Nested lambda creates a LexicalScope (depth + 1). -- If the inner lambda was already trimmed (has Captures), its capture@@ -237,7 +235,7 @@ in (Set.unions [vs1, vs2, vs3], h1 || h2, w1 || w2 || innerWithVar) ELet bindings body_ captureInfo -> -- Let creates a scope (depth + 1)- let (vs1, h1, w1) = foldBindings (depth + 1) bindings+ let (vs1, h1, w1) = foldBindings (depth + 1) (Just depth) bindings (vs2, h2, w2) = collectFreeVars (depth + 1) body_ (vs3, innerWithVar) = case captureInfo of NoCaptureInfo -> (Set.empty, False)@@ -278,6 +276,7 @@ foldParts depth = foldl' combine (Set.empty, False, False) where combine (!acc, !h, !w) (StrLit _) = (acc, h, w)+ combine (!acc, !h, !w) (StrEsc _) = (acc, h, w) combine (!acc, !h, !w) (StrInterp e) = let (vs, hv, wv) = collectFreeVars depth e in (Set.union acc vs, h || hv, w || wv)@@ -290,17 +289,26 @@ let (vs, hv, wv) = collectFreeVars depth e in (Set.union acc vs, h || hv, w || wv) -foldBindings :: Int -> [Binding] -> (Set (Int, Int), Bool, Bool)-foldBindings depth = foldl' combine (Set.empty, False, False)+-- | Collect free variables from a binding set's values. A value is+-- evaluated at @depth@ (inside the set's own frame for a let or rec); an+-- inherited variable is evaluated in the env around the set, at+-- @outerDepth@, or outside the analysis altogether ('Nothing') when the+-- frame being trimmed is the set's own.+foldBindings :: Int -> Maybe Int -> [Binding] -> (Set (Int, Int), Bool, Bool)+foldBindings depth outerDepth = foldl' combine (Set.empty, False, False) where- combine (!acc, !h, !w) (NamedBinding path bodyExpr) =- let (vs1, h1, w1) = foldKeys depth path+ combine (!acc, !h, !w) (NamedBinding key bodyExpr) =+ let (vs1, h1, w1) = foldKeys depth [key] (vs2, h2, w2) = collectFreeVars depth bodyExpr in (Set.unions [acc, vs1, vs2], h || h1 || h2, w || w1 || w2)- combine (!acc, !h, !w) (Inherit (Just fromExpr) _) =+ combine (!acc, !h, !w) (Inherit _ var) = case outerDepth of+ Nothing -> (acc, h, w)+ Just outer ->+ let (vs, hv, wv) = collectFreeVars outer var+ in (Set.union acc vs, h || hv, w || wv)+ combine (!acc, !h, !w) (InheritFrom fromExpr _) = let (vs, hv, wv) = collectFreeVars depth fromExpr in (Set.union acc vs, h || hv, w || wv)- combine (!acc, !h, !w) (Inherit Nothing _) = (acc, h, w) foldFormalsDefaults :: Int -> Formals -> (Set (Int, Int), Bool, Bool) foldFormalsDefaults _ (FormalName _) = (Set.empty, False, False)@@ -339,9 +347,9 @@ Nothing -> expr | otherwise -> expr EAttrs True bindings captureInfo ->- EAttrs True (map (rewriteBinding (depth + 1) captureMap) bindings) (rewriteCaptures depth captureMap captureInfo)+ EAttrs True (map (rewriteBinding (depth + 1) (Just depth) captureMap) bindings) (rewriteCaptures depth captureMap captureInfo) EAttrs False bindings captureInfo ->- EAttrs False (map (rewriteBinding depth captureMap) bindings) captureInfo+ EAttrs False (map (rewriteBinding depth (Just depth) captureMap) bindings) captureInfo EList elems -> EList (map (rewriteBody depth captureMap) elems) ESelect target path defExpr -> ESelect@@ -361,7 +369,7 @@ (rewriteCaptures depth captureMap captures) ELet bindings body_ captureInfo -> ELet- (map (rewriteBinding (depth + 1) captureMap) bindings)+ (map (rewriteBinding (depth + 1) (Just depth) captureMap) bindings) (rewriteBody (depth + 1) captureMap body_) (rewriteCaptures depth captureMap captureInfo) EIf c t f ->@@ -380,6 +388,7 @@ rewritePart :: Int -> Map (Int, Int) Int -> StringPart -> StringPart rewritePart _ _ p@(StrLit _) = p+rewritePart _ _ p@(StrEsc _) = p rewritePart depth captureMap (StrInterp e) = StrInterp (rewriteBody depth captureMap e) @@ -388,14 +397,17 @@ rewriteKey depth captureMap (DynamicKey e) = DynamicKey (rewriteBody depth captureMap e) -rewriteBinding :: Int -> Map (Int, Int) Int -> Binding -> Binding-rewriteBinding depth captureMap (NamedBinding path bodyExpr) =+-- | Rewrite a binding's references, at the depths 'foldBindings' reads+-- them at; an inherited variable outside the analysis is left as it is.+rewriteBinding :: Int -> Maybe Int -> Map (Int, Int) Int -> Binding -> Binding+rewriteBinding depth _ captureMap (NamedBinding key bodyExpr) = NamedBinding- (map (rewriteKey depth captureMap) path)+ (rewriteKey depth captureMap key) (rewriteBody depth captureMap bodyExpr)-rewriteBinding depth captureMap (Inherit (Just fromExpr) names) =- Inherit (Just (rewriteBody depth captureMap fromExpr)) names-rewriteBinding _ _ b@(Inherit Nothing _) = b+rewriteBinding _ outerDepth captureMap (Inherit name var) =+ Inherit name (maybe var (\outer -> rewriteBody outer captureMap var) outerDepth)+rewriteBinding depth _ captureMap (InheritFrom fromExpr names) =+ InheritFrom (rewriteBody depth captureMap fromExpr) names rewriteFormals :: Int -> Map (Int, Int) Int -> Formals -> Formals rewriteFormals _ _ f@(FormalName _) = f@@ -446,7 +458,7 @@ -- Level 1+ = parent chain references (trimmed). trimOneLetBlock :: [Binding] -> Expr -> Either ([Binding], Expr) ([(Int, Int)], [Binding], Expr, Bool) trimOneLetBlock bindings body =- let (bindingVars, bindingHasEVar, bindingHasWithVar) = foldBindings 0 bindings+ let (bindingVars, bindingHasEVar, bindingHasWithVar) = foldBindings 0 Nothing bindings (bodyVars, bodyHasEVar, bodyHasWithVar) = collectFreeVars 0 body freeVars = Set.union bindingVars bodyVars hasOuterEVar = bindingHasEVar || bodyHasEVar@@ -456,7 +468,7 @@ else let captureList = sortBy (comparing fst <> comparing snd) (Set.toList freeVars) captureMap = Map.fromList (zip captureList [0 ..])- rewrittenBindings = map (rewriteBinding 0 captureMap) bindings+ rewrittenBindings = map (rewriteBinding 0 Nothing captureMap) bindings rewrittenBody = rewriteBody 0 captureMap body in Right (captureList, rewrittenBindings, rewrittenBody, needsWithScopes) @@ -465,11 +477,11 @@ -- unchanged if untrimable. trimOneRecAttrs :: [Binding] -> Either [Binding] ([(Int, Int)], [Binding], Bool) trimOneRecAttrs bindings =- let (bindingVars, bindingHasEVar, bindingHasWithVar) = foldBindings 0 bindings+ let (bindingVars, bindingHasEVar, bindingHasWithVar) = foldBindings 0 Nothing bindings in if bindingHasEVar || (Set.null bindingVars && not bindingHasWithVar) then Left bindings else let captureList = sortBy (comparing fst <> comparing snd) (Set.toList bindingVars) captureMap = Map.fromList (zip captureList [0 ..])- rewrittenBindings = map (rewriteBinding 0 captureMap) bindings+ rewrittenBindings = map (rewriteBinding 0 Nothing captureMap) bindings in Right (captureList, rewrittenBindings, bindingHasWithVar)
src/Nix/Expr/Resolve.hs view
@@ -2,13 +2,17 @@ -- 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.+-- Variable resolution is upstream's bindVars. Every variable is bound+-- before anything is evaluated, and one that no enclosing scope binds and+-- no enclosing @with@ could supply fails the whole expression then, even on+-- a branch evaluation would never take. -- -- Lambda formals and eligible let\/rec bindings get positional--- (de Bruijn-style) indices via 'LexicalScope'. Let\/rec blocks with--- dynamic keys or nested paths fall back to 'NameBarrier' (name-based--- lookup at runtime). With-scopes and builtins remain name-based.+-- (de Bruijn-style) indices via 'LexicalScope' and become 'EResolvedVar'.+-- Let\/rec blocks with dynamic keys fall back to+-- 'NameBarrier' (name-based lookup at runtime), as do the names bound+-- around the whole expression.+-- A name only a @with@ could supply becomes 'EWithVar'. -- -- Path resolution rewrites a relative path literal to an absolute one, so -- what it names is fixed by the file it was written in.@@ -17,9 +21,11 @@ module Nix.Expr.Resolve ( resolveVars, resolveRelativePaths,+ undefinedVariableMessage, - -- * Static global names (exported for the sync test)+ -- * The names bound around every expression staticGlobalNames,+ impureOnlyGlobalNames, ) where @@ -36,82 +42,97 @@ data ScopeEntry = -- | Lambda formals: name -> positional index. LexicalScope !(Map Text Int)- | -- | Let/rec binding names: blocks resolution (handled by name at runtime).+ | -- | Names looked up by name at runtime: a let\/rec block's bindings, and+ -- at the bottom of the stack the names bound around the expression. 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 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.+ -- or 'NameBarrier' anywhere on the stack 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--- 'EResolvedVar' where the variable is lexically bound by a lambda--- formal or an eligible let\/rec binding. Variables in with-scopes,--- builtins, and fallback let\/rec blocks remain as 'EVar'.-resolveVars :: Expr -> Expr-resolveVars = resolve []+-- | Bind every variable in an expression, given the names bound around it:+-- the root environment's ('staticGlobalNames', less+-- 'impureOnlyGlobalNames' under @pure-eval@), plus a @scopedImport@+-- scope's. Replaces 'EVar' with 'EResolvedVar' where a lambda formal or an+-- eligible let\/rec binding binds the variable, and with 'EWithVar' where+-- only an enclosing @with@ could. 'Left' names the first unbound variable+-- the walk meets: the first in the source, unless attribute paths split+-- across bindings were merged.+resolveVars :: Set Text -> Expr -> Either Text Expr+resolveVars globals = resolve [NameBarrier globals] +-- | Upstream's wording for a variable nothing binds, the same whether+-- binding finds it before evaluation or a @with@ lookup misses it during+-- (UndefinedVarError in ExprVar::bindVars and in lookupVar).+undefinedVariableMessage :: Text -> Text+undefinedVariableMessage name = "undefined variable '" <> name <> "'"+ -- | Walk the AST, maintaining a scope stack.-resolve :: [ScopeEntry] -> Expr -> Expr+resolve :: [ScopeEntry] -> Expr -> Either Text Expr resolve stack expr = case expr of- 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+ ELit _ -> Right expr+ EStr parts -> EStr <$> traverse (resolvePart stack) parts+ EIndStr parts -> EIndStr <$> traverse (resolvePart stack) parts+ EPathStr parts -> EPathStr <$> traverse (resolvePart stack) parts+ EVar name -> resolveVar stack name+ EResolvedVar _ _ -> Right expr EAttrs True bindings _captureInfo | allStaticSingleKey bindings -> -- Positional: all bindings are single static keys or inherits.- let scope = lexicalScopeFromBindings bindings- innerStack = scope : stack- in EAttrs True (concatMap (resolveLetBinding stack innerStack) bindings) NoCaptureInfo+ let innerStack = lexicalScopeFromBindings bindings : stack+ in recAttrs <$> traverse (resolveLetBinding stack innerStack) bindings | otherwise ->- -- Fallback: dynamic keys or nested paths - use NameBarrier. Bindings+ -- Fallback: dynamic keys - 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 (resolveLetBinding stack newStack) bindings) NoCaptureInfo+ let newStack = NameBarrier (collectBindingNames bindings) : stack+ in recAttrs <$> traverse (resolveLetBinding stack newStack) bindings EAttrs False bindings _captureInfo -> -- Non-recursive: bindings use the outer scope.- EAttrs False (concatMap (resolveBinding stack) bindings) NoCaptureInfo- EList elems -> EList (map (resolve stack) elems)+ (\resolved -> EAttrs False resolved NoCaptureInfo) <$> traverse (resolveBinding stack) bindings+ EList elems -> EList <$> traverse (resolve stack) elems ESelect target path defExpr ->- ESelect (resolve stack target) (map (resolveKey stack) path) (fmap (resolve stack) defExpr)+ ESelect+ <$> resolve stack target+ <*> traverse (resolveKey stack) path+ <*> traverse (resolve stack) defExpr EHasAttr target path ->- EHasAttr (resolve stack target) (map (resolveKey stack) path)- EApp f x -> EApp (resolve stack f) (resolve stack x)+ EHasAttr <$> resolve stack target <*> traverse (resolveKey stack) path+ EApp f x -> EApp <$> resolve stack f <*> resolve stack x ELambda formals body _captures ->- let scope = lexicalScopeFromFormals formals- newStack = scope : stack- in ELambda (resolveFormalsDefaults newStack formals) (resolve newStack body) NoCaptureInfo+ let newStack = lexicalScopeFromFormals formals : stack+ in (\resolvedFormals resolvedBody -> ELambda resolvedFormals resolvedBody NoCaptureInfo)+ <$> resolveFormalsDefaults newStack formals+ <*> resolve newStack body ELet bindings body _captureInfo | allStaticSingleKey bindings -> -- Positional: all bindings are single static keys or inherits.- let scope = lexicalScopeFromBindings bindings- innerStack = scope : stack- in ELet (concatMap (resolveLetBinding stack innerStack) bindings) (resolve innerStack body) NoCaptureInfo+ let innerStack = lexicalScopeFromBindings bindings : stack+ in letIn+ <$> traverse (resolveLetBinding stack innerStack) bindings+ <*> resolve innerStack body | otherwise ->- -- Fallback: dynamic keys or nested paths - use NameBarrier. As above,+ -- Fallback: dynamic keys - 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 (resolveLetBinding stack newStack) bindings) (resolve newStack body) NoCaptureInfo- EIf c t f -> EIf (resolve stack c) (resolve stack t) (resolve stack f)- EWithVar _ -> expr+ let newStack = NameBarrier (collectBindingNames bindings) : stack+ in letIn+ <$> traverse (resolveLetBinding stack newStack) bindings+ <*> resolve newStack body+ EIf c t f -> EIf <$> resolve stack c <*> resolve stack t <*> resolve stack f+ EWithVar _ -> Right expr EWith scope body -> -- Push WithBarrier for the body so that unresolved names -- inside a with-scope are upgraded to EWithVar.- EWith (resolve stack scope) (resolve (WithBarrier : stack) body)+ EWith <$> resolve stack scope <*> resolve (WithBarrier : stack) body EAssert cond body ->- EAssert (resolve stack cond) (resolve stack body)- EUnary op operand -> EUnary op (resolve stack operand)- EBinary op l r -> EBinary op (resolve stack l) (resolve stack r)+ EAssert <$> resolve stack cond <*> resolve stack body+ EUnary op operand -> EUnary op <$> resolve stack operand+ EBinary op l r -> EBinary op <$> resolve stack l <*> resolve stack r -- Desugar: <name> becomes __findFile __nixPath "name" -- Matches C++ Nix's parser desugaring. __findFile and __nixPath are -- in the root scope (Builtins.hs), so they resolve via name-based@@ -124,79 +145,206 @@ (EApp (EVar "__findFile") (EVar "__nixPath")) (EStr [StrLit name]) )+ where+ recAttrs resolved = EAttrs True resolved NoCaptureInfo+ letIn resolved body = ELet resolved body NoCaptureInfo -- | Resolve a variable by walking the scope stack. ----- @level@ counts how many scope entries we've crossed (each corresponds+-- The level counts how many scope entries we've crossed (each corresponds -- 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.+-- with-scope never shadows a binding introduced by other means. That+-- includes the globals at the bottom of the stack: 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. ----- 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 fullStack startLevel name = go fullStack startLevel False+-- Only a name bound by NEITHER becomes a with-variable, and only under a+-- @with@; with none to supply it, the name is undefined.+resolveVar :: [ScopeEntry] -> Text -> Either Text Expr+resolveVar fullStack name = go fullStack 0 False where go [] _ crossedWith- | crossedWith && not (Set.member name staticGlobalNames) = EWithVar name- | otherwise = EVar name+ | crossedWith = Right (EWithVar name)+ | name == curPosName = Right (EVar name)+ | otherwise = Left name go (LexicalScope scope : rest) level crossedWith = case Map.lookup name scope of- Just idx -> EResolvedVar level idx+ Just idx -> Right (EResolvedVar level idx) Nothing -> go rest (level + 1) crossedWith go (NameBarrier names : rest) level crossedWith- | Set.member name names = EVar name+ | Set.member name names = Right (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.+-- | A temporary accommodation. Upstream's parser reads @__curPos@ as a+-- position expression, never a variable (parser.y at 2.24.9, expr_simple),+-- so binding never sees it. Here it still parses as a variable, because+-- building the position needs to know whether the source is a file+-- (upstream's position set) or a string (upstream's null), and 'parseNix'+-- carries only a name. Until it does, the name is exempt from the check and+-- fails when forced, as before binding checked anything; nixpkgs uses it in+-- files every NixOS evaluation imports. Delete the exemption when+-- 'parseNix' carries the file-or-string origin and the parser builds the+-- position.+curPosName :: Text+curPosName = "__curPos"++-- | The names upstream binds in its base environment by default, which is+-- every name the root environment ('Nix.Builtins.builtinEnv') binds and+-- every name binding accepts with nothing else in scope. Upstream installs+-- each primop and constant under the name it registers (@addPrimOp@ and+-- @addConstant@, eval.cc at 2.24.9) and under that name less a @__@ prefix+-- in @builtins@, so @__typeOf@ and @builtins.typeOf@ are one value. The+-- set is @createBaseEnv@'s (primops.cc) with every @RegisterPrimOp@ not+-- gated on an experimental feature, which leaves out @__fetchClosure@,+-- @__outputOf@, @fetchTree@ and the flake builtins, and with+-- @__importNative@ and @__exec@, which need+-- @allow-unsafe-native-code-during-evaluation@, left out too.+-- 'impureOnlyGlobalNames' are dropped from it under @pure-eval@. ----- @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).+-- A name here is never a with-variable: the global binds at parse time and+-- an enclosing @with@ cannot shadow it. @fetchurl@ and @toFile@ are+-- @__@-prefixed upstream, so nixpkgs' @with pkgs; fetchurl@ binds+-- @pkgs.fetchurl@. ----- Layering keeps this module from importing 'Nix.Builtins'; a test--- asserts this set stays in sync with the root env's actual bindings.+-- Layering keeps this module from importing 'Nix.Builtins', which builds+-- the root environment from this set; a test checks it against the list+-- recorded from upstream's source. 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"- ]+ Set.fromList (unprefixedGlobals ++ map ("__" <>) prefixedGlobals)+ where+ unprefixedGlobals =+ [ "abort",+ "baseNameOf",+ "break",+ "builtins",+ "derivation",+ "derivationStrict",+ "dirOf",+ "false",+ "fetchGit",+ "fetchMercurial",+ "fetchTarball",+ "fromTOML",+ "import",+ "isNull",+ "map",+ "null",+ "placeholder",+ "removeAttrs",+ "scopedImport",+ "throw",+ "toString",+ "true"+ ]+ prefixedGlobals =+ [ "add",+ "addDrvOutputDependencies",+ "addErrorContext",+ "all",+ "any",+ "appendContext",+ "attrNames",+ "attrValues",+ "bitAnd",+ "bitOr",+ "bitXor",+ "catAttrs",+ "ceil",+ "compareVersions",+ "concatLists",+ "concatMap",+ "concatStringsSep",+ "convertHash",+ "currentSystem",+ "currentTime",+ "deepSeq",+ "div",+ "elem",+ "elemAt",+ "fetchurl",+ "filter",+ "filterSource",+ "findFile",+ "floor",+ "foldl'",+ "fromJSON",+ "functionArgs",+ "genList",+ "genericClosure",+ "getAttr",+ "getContext",+ "getEnv",+ "groupBy",+ "hasAttr",+ "hasContext",+ "hashFile",+ "hashString",+ "head",+ "intersectAttrs",+ "isAttrs",+ "isBool",+ "isFloat",+ "isFunction",+ "isInt",+ "isList",+ "isPath",+ "isString",+ "langVersion",+ "length",+ "lessThan",+ "listToAttrs",+ "mapAttrs",+ "match",+ "mul",+ "nixPath",+ "nixVersion",+ "parseDrvName",+ "partition",+ "path",+ "pathExists",+ "readDir",+ "readFile",+ "readFileType",+ "replaceStrings",+ "seq",+ "sort",+ "split",+ "splitVersion",+ "storeDir",+ "storePath",+ "stringLength",+ "sub",+ "substring",+ "tail",+ "toFile",+ "toJSON",+ "toPath",+ "toXML",+ "trace",+ "traceVerbose",+ "tryEval",+ "typeOf",+ "unsafeDiscardOutputDependency",+ "unsafeDiscardStringContext",+ "unsafeGetAttrPos",+ "warn",+ "zipAttrsWith"+ ] +-- | Upstream's @impureOnly@ constants, absent from its base environment+-- under @pure-eval@ (@addConstant@, eval.cc at 2.24.9), so binding rejects+-- them there as an undefined variable.+impureOnlyGlobalNames :: Set Text+impureOnlyGlobalNames = Set.fromList ["__currentSystem", "__currentTime"]+ -- | Build a 'LexicalScope' from lambda formals. -- -- Index assignment:@@ -216,46 +364,42 @@ collectBindingNames :: [Binding] -> Set Text collectBindingNames = foldl' addNames Set.empty where- addNames acc (NamedBinding (StaticKey name : _) _) = Set.insert name acc+ addNames acc (NamedBinding (StaticKey name) _) = Set.insert name acc addNames acc (NamedBinding _ _) = acc- addNames acc (Inherit _ names) = foldl' (flip Set.insert) acc names+ addNames acc (Inherit name _) = Set.insert name acc+ addNames acc (InheritFrom _ names) = foldl' (flip Set.insert) acc names -- | Resolve variables inside string parts.-resolvePart :: [ScopeEntry] -> StringPart -> StringPart-resolvePart _ p@(StrLit _) = p-resolvePart stack (StrInterp e) = StrInterp (resolve stack e)+resolvePart :: [ScopeEntry] -> StringPart -> Either Text StringPart+resolvePart _ p@(StrLit _) = Right p+resolvePart _ p@(StrEsc _) = Right p+resolvePart stack (StrInterp e) = StrInterp <$> resolve stack e -- | Resolve variables inside attribute keys.-resolveKey :: [ScopeEntry] -> AttrKey -> AttrKey-resolveKey _ k@(StaticKey _) = k-resolveKey stack (DynamicKey e) = DynamicKey (resolve stack e)+resolveKey :: [ScopeEntry] -> AttrKey -> Either Text AttrKey+resolveKey _ k@(StaticKey _) = Right k+resolveKey stack (DynamicKey e) = DynamicKey <$> resolve stack e --- | Resolve variables inside a binding's RHS.------ '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 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]-resolveBinding stack (NamedBinding path bodyExpr) =- [NamedBinding (map (resolveKey stack) path) (resolve stack bodyExpr)]-resolveBinding stack (Inherit (Just fromExpr) names) =- [Inherit (Just (resolve stack fromExpr)) names]-resolveBinding stack (Inherit Nothing names) =- -- Desugar: inherit x y; becomes x = x; y = y;- [NamedBinding [StaticKey name] (resolve stack (EVar name)) | name <- names]+-- | Resolve variables inside a non-recursive set's binding. The set adds+-- no env level, so an inherited variable resolves where the set stands,+-- like every other value.+resolveBinding :: [ScopeEntry] -> Binding -> Either Text Binding+resolveBinding stack (NamedBinding key bodyExpr) =+ NamedBinding <$> resolveKey stack key <*> resolve stack bodyExpr+resolveBinding stack (Inherit name var) = Inherit name <$> resolve stack var+resolveBinding stack (InheritFrom fromExpr names) =+ (`InheritFrom` names) <$> resolve stack fromExpr -- | Check if all bindings are eligible for positional resolution:--- each binding must be either a single static key or an inherit.--- Blocks with dynamic keys (@${expr} = val@) or nested paths--- (@a.b = val@) are ineligible and fall back to 'NameBarrier'.+-- each binding must be either a static key or an inherit. Blocks with+-- dynamic keys (@${expr} = val@) are ineligible and fall back to+-- 'NameBarrier'. allStaticSingleKey :: [Binding] -> Bool allStaticSingleKey = all isEligible where- isEligible (NamedBinding [StaticKey _] _) = True+ isEligible (NamedBinding (StaticKey _) _) = True isEligible (Inherit _ _) = True+ isEligible (InheritFrom _ _) = True isEligible _ = False -- | Build a 'LexicalScope' from let\/rec bindings, assigning positional@@ -268,8 +412,9 @@ LexicalScope (Map.fromList (zip names [0 ..])) where names = concatMap bindingNames bindings- bindingNames (NamedBinding [StaticKey name] _) = [name]- bindingNames (Inherit _ inheritNames) = inheritNames+ bindingNames (NamedBinding (StaticKey name) _) = [name]+ bindingNames (Inherit name _) = [name]+ bindingNames (InheritFrom _ inheritNames) = inheritNames -- Unreachable: allStaticSingleKey guards this path. bindingNames _ = [] @@ -279,41 +424,31 @@ -- 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 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)]-resolveLetBinding _ innerStack (Inherit (Just fromExpr) names) =- [Inherit (Just (resolve innerStack fromExpr)) names]-resolveLetBinding outerStack _ (Inherit Nothing names) =- -- Desugar @inherit x y;@ becomes @x = x; y = y;@, resolving each RHS against the- -- outer scope so it names the enclosing binding, not the one defined here.- -- 'shiftInheritLevel' corrects for the inner env the resulting thunk runs in.- [NamedBinding [StaticKey name] (shiftInheritLevel (resolve outerStack (EVar name))) | name <- names]---- | A desugared positional @inherit@ RHS is resolved against the outer scope--- but evaluated in the inner (let\/rec) env - one extra parent-chain hop - so--- its de Bruijn level is one too shallow. Bump it. 'EVar'\/'EWithVar' are--- name-based and need no adjustment.-shiftInheritLevel :: Expr -> Expr-shiftInheritLevel (EResolvedVar level idx) = EResolvedVar (level + 1) idx-shiftInheritLevel other = other+-- An @inherit x@ variable resolves against @outerStack@, and the evaluator+-- evaluates it in the enclosing env, as upstream's @ExprAttrs::eval@ and+-- @ExprLet::eval@ evaluate an Inherited attribute (eval.cc at 2.24.9):+-- the inherited name references the enclosing scope, never the block being+-- defined, whose own @x@ would make it a self-reference.+resolveLetBinding :: [ScopeEntry] -> [ScopeEntry] -> Binding -> Either Text Binding+resolveLetBinding _ innerStack (NamedBinding key bodyExpr) =+ NamedBinding <$> resolveKey innerStack key <*> resolve innerStack bodyExpr+resolveLetBinding _ innerStack (InheritFrom fromExpr names) =+ (`InheritFrom` names) <$> resolve innerStack fromExpr+resolveLetBinding outerStack _ (Inherit name var) =+ Inherit name <$> resolve outerStack var -- | Resolve variables inside formal default expressions.-resolveFormalsDefaults :: [ScopeEntry] -> Formals -> Formals-resolveFormalsDefaults _ f@(FormalName _) = f+resolveFormalsDefaults :: [ScopeEntry] -> Formals -> Either Text Formals+resolveFormalsDefaults _ f@(FormalName _) = Right f resolveFormalsDefaults stack (FormalSet formals ellipsis) =- FormalSet (map (resolveFormal stack) formals) ellipsis+ (`FormalSet` ellipsis) <$> traverse (resolveFormal stack) formals resolveFormalsDefaults stack (FormalNamedSet name formals ellipsis) =- FormalNamedSet name (map (resolveFormal stack) formals) ellipsis+ (\resolved -> FormalNamedSet name resolved ellipsis) <$> traverse (resolveFormal stack) formals -- | Resolve variables inside a single formal's default expression.-resolveFormal :: [ScopeEntry] -> Formal -> Formal+resolveFormal :: [ScopeEntry] -> Formal -> Either Text Formal resolveFormal stack (Formal name defExpr) =- Formal name (fmap (resolve stack) defExpr)+ Formal name <$> traverse (resolve stack) defExpr -- --------------------------------------------------------------------------- -- Path resolution@@ -382,11 +517,13 @@ goPart part = case part of StrLit _ -> part+ StrEsc _ -> 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+ NamedBinding key e -> NamedBinding (goKey key) (goExpr e)+ Inherit name var -> Inherit name (goExpr var)+ InheritFrom from names -> InheritFrom (goExpr from) names goKey key = case key of StaticKey _ -> key
src/Nix/Expr/Types.hs view
@@ -50,6 +50,13 @@ data StringPart = -- | Literal text. StrLit !Text+ | -- | Resolved escape text inside an indented string (@'''@, @''$@,+ -- @''${@, @''\x@). Kept apart from 'StrLit' because upstream's lexer+ -- emits an escape without the hasIndentation mark: the indentation+ -- scan skips it (it only ends a line's leading whitespace) while the+ -- strip runs over it as over any literal, so an escaped newline+ -- starts a line whose spaces are stripped but were never measured.+ StrEsc !Text | -- | Interpolated expression (@${expr}@). StrInterp !Expr deriving (Eq, Show)@@ -67,10 +74,19 @@ -- | A binding in an attribute set or let expression. data Binding- = -- | @path = expr;@- NamedBinding !AttrPath !Expr- | -- | @inherit expr;@ or @inherit (from) attrs;@- Inherit !(Maybe Expr) ![Text]+ = -- | @key = expr;@. One key, as upstream's @ExprAttrs@ holds one name+ -- per definition (nixexpr.hh at 2.24.9): the parser hoists a dotted+ -- @a.b.c = v;@ into @a = { b = { c = v; }; };@ the way upstream's+ -- @addAttr@ does (parser-state.hh), so no binding carries a path.+ NamedBinding !AttrKey !Expr+ | -- | One name of @inherit x y;@: the attribute and the variable it+ -- copies, which is bound and evaluated in the scope around the binding+ -- set rather than in the set itself, so @rec { inherit x; }@ does not+ -- name itself. Upstream's parser builds the same pair: an attribute of+ -- kind Inherited holding an ExprVar.+ Inherit !Text !Expr+ | -- | @inherit (from) x y;@+ InheritFrom !Expr ![Text] deriving (Eq, Show) -- | A single formal parameter with optional default.
src/Nix/Hash.hs view
@@ -92,9 +92,7 @@ truncatedBase32 :: BS.ByteString -> Text truncatedBase32 bs = let digest = CH.hash bs :: CH.Digest CH.SHA256- allBytes = BA.unpack digest :: [Word8]- compressed = compressHash 20 allBytes- in encode (BS.pack compressed)+ in encode (compressHash 20 (BA.convert digest)) -- | Nix's output placeholder for @builtins.placeholder@: @\/@ followed by the -- full SHA-256 of @"nix-output:" <> name@ in Nix base-32 (no truncation, no@@ -103,15 +101,20 @@ hashPlaceholder name = "/" <> encode (sha256Digest (encodeUtf8 ("nix-output:" <> name))) --- | XOR-fold a hash to @targetLen@ bytes, matching C++ Nix @compressHash@.--- Each source byte is XOR'd into position @i mod targetLen@.-compressHash :: Int -> [Word8] -> [Word8]-compressHash targetLen bytes =- let arr0 = replicate targetLen 0- fold_ acc (i, b) =- let pos = i `mod` targetLen- in zipWith (\j x -> if j == pos then xor x b else x) [0 :: Int ..] acc- in foldl' fold_ arr0 (zip [0 ..] bytes)+-- | XOR-fold a hash to @targetLen@ bytes, matching C++ Nix @compressHash@+-- (hash.cc): byte @i@ is XOR'd into position @i mod targetLen@, which is+-- the hash's @targetLen@-byte chunks XOR'd together, a short last chunk+-- into the prefix. Empty for a length below one, which leaves no+-- position to fold into.+compressHash :: Int -> BS.ByteString -> BS.ByteString+compressHash targetLen digest+ | targetLen < 1 = BS.empty+ | otherwise = foldl' xorChunk (BS.replicate targetLen 0) (chunks digest)+ where+ chunks bytes+ | BS.null bytes = []+ | otherwise = let (chunk, rest) = BS.splitAt targetLen bytes in chunk : chunks rest+ xorChunk acc chunk = BS.packZipWith xor acc chunk <> BS.drop (BS.length chunk) acc -- | Format a single byte as two lowercase hex digits. byteToHex :: Word8 -> String@@ -254,8 +257,8 @@ <> T.pack dir <> ":" <> name- compressed = compressHash storePathHashBytes (BS.unpack (sha256Digest (encodeUtf8 preimage)))- in Right (StorePath (encode (BS.pack compressed)) name)+ compressed = compressHash storePathHashBytes (sha256Digest (encodeUtf8 preimage))+ in Right (StorePath (encode 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
+ src/Nix/HostPath.hs view
@@ -0,0 +1,64 @@+{-# LANGUAGE CPP #-}++-- | A path as the host's file API takes it ('OsPath'), spelled from the+-- bytes nova-nix holds for it, and shown back in a message. One spelling+-- serves every such path: a NAR's entry names and link targets+-- ("Nix.Store.EntryName"), and nix.conf's include targets and directories+-- ("Nix.Config").+--+-- Upstream keeps such a path as the bytes it read and hands them to the+-- file system unchanged (@RestoreSink@ in fs-sink.cc, @parseConfigFiles@+-- in config.cc, at Nix 2.24.9). A POSIX path is bytes, and an 'OsPath'+-- holds them as such, so the call receives the same bytes whatever the+-- locale. Spelling the path through 'Data.Text.Text' would replace a byte+-- with no UTF-8 reading by U+FFFD, and through 'FilePath' would pass it+-- through the locale's codec and back, which does not return every byte+-- string under the multi-byte encodings (EUC-JP, CP932, GB18030 and+-- Big5-HKSCS through macOS's iconv); either way the call could name a+-- different file.+--+-- A Windows path is UTF-16, and the bytes nova-nix holds for one are UTF-8+-- (a NAR's names are, and "Nix.Environment" reads a Windows variable that+-- way), so a path is spelled from their UTF-8 reading, and bytes with none+-- name no Windows file.+module Nix.HostPath+ ( hostPathFromBytes,+ hostPathText,+ )+where++import Data.ByteString (ByteString)+import Data.Text (Text)+import qualified Data.Text.Encoding as TE+import System.OsPath (OsPath)+import qualified System.OsPath as OP+#if defined(mingw32_HOST_OS)+import qualified Data.Text as T+#else+import qualified Data.ByteString.Char8 as BS8+#endif++-- | The host path the bytes name: on POSIX the bytes themselves, on+-- Windows their UTF-8 reading, or 'Nothing' when they have none.+hostPathFromBytes :: ByteString -> Maybe OsPath+#if defined(mingw32_HOST_OS)+-- 'OP.encodeUtf' writes strict UTF-16LE, the form of every Windows path;+-- text read from UTF-8 always has one.+hostPathFromBytes bytes = either (const Nothing) (OP.encodeUtf . T.unpack) (TE.decodeUtf8' bytes)+#else+-- Each byte becomes one path unit. 'OP.unsafeFromChar' narrows a+-- character to the unit's width, which loses nothing on characters drawn+-- from bytes.+hostPathFromBytes = Just . OP.pack . map OP.unsafeFromChar . BS8.unpack+#endif++-- | A host path as a message shows it, for display only: its UTF-8+-- (POSIX) or UTF-16 (Windows) reading, with U+FFFD where that reading+-- fails. Never the way back to a path.+hostPathText :: OsPath -> Text+#if defined(mingw32_HOST_OS)+-- 'T.pack' replaces each lone surrogate, which only the fallback holds.+hostPathText path = maybe (T.pack (map OP.toChar (OP.unpack path))) T.pack (OP.decodeUtf path)+#else+hostPathText = TE.decodeUtf8Lenient . BS8.pack . map OP.toChar . OP.unpack+#endif
+ src/Nix/Http.hs view
@@ -0,0 +1,318 @@+-- | The HTTP transfer layer every download nova-nix makes goes through:+-- the User-Agent each request carries, and upstream's retry policy with+-- the classification that decides what a retry can outlive.+--+-- Upstream serves every download, a fetcher's and a substituter's alike,+-- through one @FileTransfer@ (@libstore/filetransfer.cc@ at 2.24.9), with+-- one @download-attempts@ setting, one backoff and one table of the+-- failures that are never retried. @builtin:fetchurl@ ("Nix.Builder")+-- and the substituter ("Nix.Substituter") share this module for the same+-- reason: a source tarball, a narinfo and a NAR fail and retry alike.+module Nix.Http+ ( -- * Identity+ userAgent,+ withUserAgent,++ -- * Attempt failures+ AttemptFailure (..),+ attemptFailureMessage,+ catchSync,++ -- * Retry policy+ FetchRetryPolicy (..),+ defaultFetchRetryPolicy,+ RetryEffects (..),+ ioRetryEffects,+ retryTransient,+ retryDelayMs,++ -- * Classification+ TransferError (..),+ statusError,+ withTransfer,+ transferBodyReader,+ transferFailureHandlers,+ fetchStatusFailure,+ fetchExceptionFailure,+ fetchWriteFailure,+ )+where++import Control.Concurrent (threadDelay)+import Control.Exception (Handler (..), IOException, SomeAsyncException (..), SomeException, catch, displayException, fromException, throwIO)+import qualified Data.ByteString.Char8 as BS8+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import Data.Version (showVersion)+import qualified Network.HTTP.Client as HTTP+import Network.HTTP.Types.Header (hUserAgent)+import qualified Network.HTTP.Types.Status as HTTP+import Paths_nova_nix (version)+import System.IO (stderr)+import System.Random (randomRIO)++-- ---------------------------------------------------------------------------+-- Identity+-- ---------------------------------------------------------------------------++-- | @nova-nix\/VERSION (+repository)@: the product token upstream's shape+-- calls for, and a comment naming where the client comes from.+--+-- Upstream identifies itself on every request as @curl\/VERSION+-- Nix\/VERSION@. A request with no User-Agent is what bot mitigation in+-- front of a server scores lowest: ftp.gnu.org answers one with 403+-- outright, and Cloudflare in front of cache.novavero.ai challenged every+-- request a GitHub runner made without one while passing the same requests+-- carrying this header. The fetcher sent one; the substituter and push+-- sent none, so the public cache could be read by a person and not by the+-- tool built to read it. The version comes from Cabal, so the header+-- names the build that made the request rather than a spelling that can+-- drift at release time.+userAgent :: BS8.ByteString+userAgent = BS8.pack ("nova-nix/" <> showVersion version <> " (+https://github.com/Novavero-AI/nova-nix)")++-- | The request with 'userAgent' as its only User-Agent, whatever it+-- carried before.+withUserAgent :: HTTP.Request -> HTTP.Request+withUserAgent request =+ request+ { HTTP.requestHeaders =+ (hUserAgent, userAgent) : filter ((/= hUserAgent) . fst) (HTTP.requestHeaders request)+ }++-- ---------------------------------------------------------------------------+-- Attempt failures+-- ---------------------------------------------------------------------------++-- | How one download 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++-- | 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, as a local build, or+-- as a failed push instead of aborting. Every catch-all on the+-- download, substitution, build and push 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++-- ---------------------------------------------------------------------------+-- Retry policy+-- ---------------------------------------------------------------------------++-- | How many times one download is tried before it fails: upstream's+-- @download-attempts@ default (@libstore/filetransfer.hh@ at 2.24.9,+-- @Setting<unsigned int> tries{this, 5, "download-attempts", ...}@).+downloadAttempts :: Int+downloadAttempts = 5++-- | The delay before the first retry, in milliseconds; every later retry+-- doubles it (@FileTransferRequest::baseRetryTimeMs = 250@ in the same+-- header).+retryBaseDelayMs :: Int+retryBaseDelayMs = 250++-- | The spread upstream adds to the backoff exponent so that clients+-- which failed together do not retry together: a uniform draw from+-- @[0, 0.5)@ (@filetransfer.cc@ at 2.24.9, line 500).+retryJitterCeiling :: Double+retryJitterCeiling = 0.5++microsPerMilli :: Int+microsPerMilli = 1000++-- | How often one download is tried and how long to wait between tries.+-- 'defaultFetchRetryPolicy' is upstream's; tests shrink the delay.+data FetchRetryPolicy = FetchRetryPolicy+ { frpAttempts :: !Int,+ frpBaseDelayMs :: !Int+ }+ deriving (Eq, Show)++-- | Upstream's policy: five attempts, 250 ms before the first retry.+defaultFetchRetryPolicy :: FetchRetryPolicy+defaultFetchRetryPolicy = FetchRetryPolicy {frpAttempts = downloadAttempts, frpBaseDelayMs = retryBaseDelayMs}++-- | The delay before retry number @retry@ (counting from one), given the+-- jitter drawn for it: upstream's @baseRetryTimeMs * 2 ^ (attempt - 1 ++-- jitter)@ (@filetransfer.cc@ at 2.24.9, line 500), truncated to whole+-- milliseconds as its assignment to an @int@ truncates.+retryDelayMs :: FetchRetryPolicy -> Int -> Double -> Int+retryDelayMs policy retry jitter =+ truncate (fromIntegral (frpBaseDelayMs policy) * 2 ** (fromIntegral (retry - 1) + jitter) :: Double)++-- | What the retry loop needs from the outside world, injected so the+-- policy is testable with no clock, no entropy and no network.+data RetryEffects m = RetryEffects+ { reSleepMs :: !(Int -> m ()),+ reJitter :: !(m Double),+ reWarn :: !(Text -> m ())+ }++-- | The real effects: a sleep, a uniform jitter, and a warning on stderr+-- in the shape of upstream's @warn("%s; retrying in %d ms", ...)@.+ioRetryEffects :: RetryEffects IO+ioRetryEffects =+ RetryEffects+ { reSleepMs = threadDelay . (* microsPerMilli),+ reJitter = randomRIO (0, retryJitterCeiling),+ reWarn = TIO.hPutStrLn stderr . ("warning: " <>)+ }++-- | Run one download's attempt under the retry policy: a+-- 'TransientFailure' is tried again after the backoff until the attempts+-- are spent, while a 'FatalFailure' or a success ends the loop at once.+-- Exceptions propagate, so a cancellation is never spent as retry budget.+retryTransient :: (Monad m) => FetchRetryPolicy -> RetryEffects m -> m (Either AttemptFailure a) -> m (Either AttemptFailure a)+retryTransient policy effects action = go 1+ where+ go !attempt = do+ result <- action+ case result of+ Left (TransientFailure err)+ | attempt < frpAttempts policy -> do+ jitter <- reJitter effects+ let delay = retryDelayMs policy attempt jitter+ reWarn effects (err <> "; retrying in " <> T.pack (show delay) <> " ms")+ reSleepMs effects delay+ go (attempt + 1)+ _ -> pure result++-- ---------------------------------------------------------------------------+-- Classification+-- ---------------------------------------------------------------------------++-- | 'HTTP.withResponse', with the body read through+-- 'transferBodyReader', so every failure of the connection is an+-- 'HTTP.HttpException', the body's included.+withTransfer :: HTTP.Request -> HTTP.Manager -> (HTTP.Response HTTP.BodyReader -> IO a) -> IO a+withTransfer request manager consume =+ HTTP.withResponse request manager (consume . fmap (transferBodyReader request))++-- | A response body reader whose failures are all+-- 'HTTP.HttpException's. The client's manager wraps what opening a+-- response throws (a reset, a TLS failure) into+-- 'HTTP.InternalException', but hands the body reader over unwrapped+-- (@responseOpen@ in http-client 0.7), so a connection reset or a torn+-- TLS record mid-body arrives as a bare 'IOException' or TLS exception,+-- the shape of a failure writing the output. Wrapping the read the way+-- the manager wraps the open keeps the two apart: a transport failure is+-- transient wherever it happens, as curl's receive errors are upstream,+-- and a write failure is not. The reader only ever reads the+-- connection, so every synchronous exception it raises is the+-- transport's; an asynchronous one passes through untouched.+transferBodyReader :: HTTP.Request -> HTTP.BodyReader -> HTTP.BodyReader+transferBodyReader request reader = reader `catchSync` asTransportFailure+ where+ asTransportFailure err = case fromException err :: Maybe HTTP.HttpException of+ Just _ -> throwIO err+ Nothing -> throwIO (HTTP.HttpExceptionRequest request (HTTP.InternalException err))++-- | What a transfer attempt throws, turned into its failure: an+-- 'HTTP.HttpException' by 'fetchExceptionFailure', an 'IOException' by+-- 'fetchWriteFailure'. Nothing else is caught, so an asynchronous+-- exception (an interrupt, a cancelled build) leaves the attempt as it+-- arrived and is never spent as retry budget, and anything unforeseen+-- reaches the caller's own boundary.+transferFailureHandlers :: Text -> [Handler (Either AttemptFailure a)]+transferFailureHandlers url =+ [ Handler (pure . Left . fetchExceptionFailure url),+ Handler (pure . Left . fetchWriteFailure url)+ ]++-- | Upstream's verdict on a response that is not a success+-- (@FileTransfer::Error@, @filetransfer.hh@ at 2.24.9, whose+-- @Interrupted@ is an asynchronous exception here, never a value). Only+-- 'Transient' is retried. A binary cache store reads 'NotFound' and+-- 'Forbidden' alike as the file not being in that cache+-- (@http-binary-cache-store.cc@ at 2.24.9, lines 123, 160 and 182),+-- since an S3 bucket that cannot be listed answers a missing key with+-- 403.+data TransferError+ = -- | 404 or 410: the file is not there.+ NotFound+ | -- | 401, 403 or 407: the request was refused.+ Forbidden+ | -- | A failure no retry can change.+ Misc+ | -- | A failure a retry could outlive.+ Transient+ deriving (Eq, Show)++-- | Classify a response status the way upstream's transfer layer does,+-- in its order (@filetransfer.cc@ at 2.24.9, lines 423-441): 404 and 410+-- are 'NotFound'; 401, 403 and 407 are 'Forbidden'; every other 4xx+-- except 408 and 429 (the server timed out waiting for the request, or+-- asked for a slower pace) is 'Misc', as are 501, 505 and 511 (the+-- server cannot speak this protocol, or a captive portal is in the way).+-- Everything else is 'Transient', the remaining 5xx included.+statusError :: HTTP.Status -> TransferError+statusError status+ | status `elem` [HTTP.status404, HTTP.status410] = NotFound+ | status `elem` [HTTP.status401, HTTP.status403, HTTP.status407] = Forbidden+ | HTTP.statusIsClientError status && status `notElem` [HTTP.status408, HTTP.status429] = Misc+ | status `elem` [HTTP.status501, HTTP.status505, HTTP.status511] = Misc+ | otherwise = Transient++-- | A response status as an attempt's failure, by 'statusError': only a+-- 'Transient' one is retried.+fetchStatusFailure :: Text -> HTTP.Status -> AttemptFailure+fetchStatusFailure url status = case statusError status of+ Transient -> TransientFailure message+ NotFound -> FatalFailure message+ Forbidden -> FatalFailure message+ Misc -> FatalFailure message+ where+ message = "HTTP " <> T.pack (show (HTTP.statusCode status)) <> " fetching " <> url++-- | Classify a failure of the HTTP client, by upstream's list of curl+-- results that are not retried (@filetransfer.cc@ at 2.24.9, lines+-- 443-465). A URL the client cannot parse (@CURLE_URL_MALFORMAT@) is an+-- 'HTTP.InvalidUrlException' from the parser, except for an empty host,+-- which the parser accepts and the connection lookup rejects as+-- 'HTTP.InvalidDestinationHost' on every attempt. A scheme the client+-- does not speak (@CURLE_UNSUPPORTED_PROTOCOL@) is an+-- 'HTTP.InvalidUrlException' too, or 'HTTP.TlsNotSupported' when the+-- manager has no TLS. A redirect loop (@CURLE_TOO_MANY_REDIRECTS@)+-- completes the deterministic set. Every other transport failure is+-- transient, name resolution and TLS included, as upstream has it. The+-- message names the URL and the failure only: the client's own rendering+-- prints the whole request record over a dozen lines, and a retry would+-- repeat it.+fetchExceptionFailure :: Text -> HTTP.HttpException -> AttemptFailure+fetchExceptionFailure url err = case err of+ HTTP.InvalidUrlException _ reason -> FatalFailure (describeFailure url reason)+ HTTP.HttpExceptionRequest _ content -> case content of+ HTTP.InvalidDestinationHost _ -> FatalFailure (describeFailure url "empty host")+ HTTP.TlsNotSupported -> FatalFailure (describeFailure url "TLS is not supported")+ HTTP.TooManyRedirects _ -> FatalFailure (describeFailure url "too many redirects")+ _ -> TransientFailure (describeFailure url (show content))++-- | A failure writing what the transfer delivered: upstream's+-- @CURLE_WRITE_ERROR@, never retried, since the disk that refused one+-- write refuses the next. An 'IOException' reaching the attempt boundary+-- is one, because 'withTransfer' has already raised every failure of the+-- connection as an 'HTTP.HttpException'.+fetchWriteFailure :: Text -> IOException -> AttemptFailure+fetchWriteFailure url err = FatalFailure (describeFailure url (displayException err))++describeFailure :: Text -> String -> Text+describeFailure url detail = "download error fetching " <> url <> ": " <> T.pack detail
+ src/Nix/Json.hs view
@@ -0,0 +1,414 @@+-- | JSON as upstream Nix writes and reads it.+--+-- Upstream serializes through nlohmann::json, so @builtins.toJSON@, the+-- @__json@ environment entry of a structured-attributes derivation and the+-- @.attrs.json@ file its builder reads are all nlohmann's @dump()@ with no+-- indentation. A derivation's @__json@ is hashed into its store path, so+-- 'renderJson' is identity, not presentation: every byte of it is+-- nlohmann's. 'parseJson' is the way back, for the builder, which reads+-- @__json@ out of a @.drv@ the way upstream's @nlohmann::json::parse@ does.+module Nix.Json+ ( -- * Values+ Json (..),++ -- * Rendering+ renderJson,+ renderJsonWith,+ formatJsonFloat,++ -- * Parsing+ IntegerRange (..),+ parseJson,+ parseJsonWith,+ )+where++import Data.ByteString (ByteString)+import qualified Data.ByteString.Builder as BB+import qualified Data.ByteString.Lazy as BL+import Data.Char (chr, isDigit, ord)+import Data.Int (Int64)+import Data.List (intersperse)+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import Data.Word (Word64)+import Numeric (floatToDigits, showHex)+import Text.Read (readMaybe)++-- ---------------------------------------------------------------------------+-- Values+-- ---------------------------------------------------------------------------++-- | A JSON value. Strings are 'Text' because nlohmann refuses a string+-- that is not UTF-8 in both directions, so bytes reach JSON decoded once,+-- at the boundary that produced them. Integers and floats stay apart, as+-- nlohmann keeps them: @1@ and @1.0@ render differently, and a builder's+-- @.attrs.sh@ treats them differently.+data Json+ = JsonNull+ | JsonBool !Bool+ | -- | Rendered in decimal. A parse produces one only in its+ -- 'IntegerRange', signed 64-bit below zero in both.+ JsonInt !Integer+ | -- | A non-finite float renders as @null@, as nlohmann writes one.+ JsonFloat !Double+ | JsonString !Text+ | JsonArray ![Json]+ | -- | Members render in byte order of their names, the order of the+ -- @std::map@ nlohmann keeps them in. 'Text' compares by code point,+ -- which on UTF-8 is the same order.+ JsonObject !(Map Text Json)+ deriving (Eq, Show)++-- ---------------------------------------------------------------------------+-- Rendering+-- ---------------------------------------------------------------------------++-- | nlohmann's @dump()@: no whitespace, members in name order, strings+-- escaped as 'escapeJsonString' describes, floats as 'formatJsonFloat'.+renderJson :: Json -> ByteString+renderJson = renderJsonWith id++-- | 'renderJson' with every string, member names included, passed through+-- a rewrite before it is escaped. Upstream's builder rewrites the rendered+-- text instead (@rewriteStrings@ over @json.dump()@). The two agree while+-- neither side of a rewrite holds a character JSON escapes; rewriting+-- first is what keeps the result JSON when a replacement does hold one,+-- as a Windows store directory holds backslashes.+renderJsonWith :: (Text -> Text) -> Json -> ByteString+renderJsonWith rewrite = BL.toStrict . BB.toLazyByteString . render+ where+ render JsonNull = "null"+ render (JsonBool True) = "true"+ render (JsonBool False) = "false"+ render (JsonInt n) = BB.integerDec n+ render (JsonFloat d)+ | isNaN d || isInfinite d = "null"+ | otherwise = TE.encodeUtf8Builder (formatJsonFloat d)+ render (JsonString s) = quoted s+ render (JsonArray items) = BB.char7 '[' <> commaSeparated (map render items) <> BB.char7 ']'+ render (JsonObject members) =+ BB.char7 '{' <> commaSeparated (map member (Map.toAscList members)) <> BB.char7 '}'+ member (name, value) = quoted name <> BB.char7 ':' <> render value+ quoted s = BB.char7 '"' <> escapeJsonString (rewrite s) <> BB.char7 '"'+ commaSeparated = mconcat . intersperse (BB.char7 ',')++-- | nlohmann's @dump_escaped@ without @ensure_ascii@: two-character escapes+-- for the quote, the backslash, backspace, form feed, newline, carriage+-- return and tab; @\\u00xx@ in lowercase hex for the other control+-- characters; every other character as its UTF-8 bytes, DEL and @/@+-- included.+escapeJsonString :: Text -> BB.Builder+escapeJsonString s = case T.break needsEscape s of+ (plain, rest) ->+ TE.encodeUtf8Builder plain <> case T.uncons rest of+ Nothing -> mempty+ Just (c, more) -> escapeChar c <> escapeJsonString more+ where+ needsEscape c = c == '"' || c == '\\' || c < ' '+ escapeChar '"' = "\\\""+ escapeChar '\\' = "\\\\"+ escapeChar '\b' = "\\b"+ escapeChar '\f' = "\\f"+ escapeChar '\n' = "\\n"+ escapeChar '\r' = "\\r"+ escapeChar '\t' = "\\t"+ escapeChar c = "\\u00" <> BB.word8HexFixed (fromIntegral (ord c))++-- | Format a finite float exactly as nlohmann's @to_chars@ does: 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" <> exponentDigits (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++-- | nlohmann's @append_exponent@: the sign always, then the magnitude+-- zero-padded to at least two digits (@+05@, @-21@, @+308@).+exponentDigits :: Int -> String+exponentDigits e+ | e < 0 = '-' : padded (negate e)+ | otherwise = '+' : padded e+ where+ padded n+ | n < 10 = '0' : show n+ | otherwise = show n++-- ---------------------------------------------------------------------------+-- Parsing+-- ---------------------------------------------------------------------------++-- | Which integers a parse keeps past the signed 64-bit range.+data IntegerRange+ = -- | nlohmann's own, unsigned 64-bit above zero: what upstream's DOM+ -- parser reads a builder's @__json@ into.+ NlohmannRange+ | -- | The Nix integer range, as @builtins.fromJSON@ reads a document+ -- (json-to-value.cc at 2.25 and after). Upstream's SAX consumer throws+ -- on reaching a number that fits only unsigned 64 bits, so the error is+ -- the first such number in the document, ahead of any later syntax+ -- error.+ NixRange+ deriving (Eq, Show)++-- | Read one JSON document as nlohmann's @parse@ does: a single value with+-- optional whitespace around it, a leading byte order mark skipped, and+-- nothing after it. A member name given twice keeps its last value, as+-- nlohmann's DOM parser assigns it. The input is 'Text' because nlohmann+-- rejects a document that is not UTF-8; decoding it is that check.+parseJson :: Text -> Either Text Json+parseJson = parseJsonWith NlohmannRange++-- | 'parseJson' over a chosen 'IntegerRange'.+parseJsonWith :: IntegerRange -> Text -> Either Text Json+parseJsonWith range input = do+ (value, rest) <- parseValue range (skipSpace (dropByteOrderMark input))+ let trailing = skipSpace rest+ if T.null trailing+ then Right value+ else Left ("unexpected content after the JSON value: " <> excerpt trailing)+ where+ dropByteOrderMark t = fromMaybe t (T.stripPrefix (T.singleton byteOrderMark) t)++-- | U+FEFF, which nlohmann skips at the start of a document.+byteOrderMark :: Char+byteOrderMark = '\xFEFF'++-- | JSON's insignificant whitespace: space, tab, newline, carriage return.+skipSpace :: Text -> Text+skipSpace = T.dropWhile (\c -> c == ' ' || c == '\t' || c == '\n' || c == '\r')++-- | The start of the input a parse stopped at, for an error message.+excerpt :: Text -> Text+excerpt t+ | T.null t = "end of input"+ | otherwise = "'" <> T.take excerptLength t <> "'"++-- | How much of the offending input an error quotes.+excerptLength :: Int+excerptLength = 20++-- | One value, its leading whitespace already skipped.+parseValue :: IntegerRange -> Text -> Either Text (Json, Text)+parseValue range t = case T.uncons t of+ Nothing -> Left "unexpected end of input, expected a JSON value"+ Just ('{', rest) -> parseObject range (skipSpace rest)+ Just ('[', rest) -> parseArray range (skipSpace rest)+ Just ('"', rest) -> do+ (s, afterString) <- parseString rest+ pure (JsonString s, afterString)+ Just ('t', _) -> literal "true" (JsonBool True)+ Just ('f', _) -> literal "false" (JsonBool False)+ Just ('n', _) -> literal "null" JsonNull+ Just (c, _)+ | c == '-' || isDigit c -> parseNumber range t+ Just _ -> Left ("unexpected " <> excerpt t <> ", expected a JSON value")+ where+ literal word value = case T.stripPrefix word t of+ Just rest -> Right (value, rest)+ Nothing -> Left ("invalid literal " <> excerpt t)++-- | The elements of an array, after its @[@ and any whitespace.+parseArray :: IntegerRange -> Text -> Either Text (Json, Text)+parseArray range t = case T.uncons t of+ Just (']', rest) -> Right (JsonArray [], rest)+ _ -> elements [] t+ where+ elements !acc s = do+ (value, rest) <- parseValue range s+ let after = skipSpace rest+ case T.uncons after of+ Just (',', more) -> elements (value : acc) (skipSpace more)+ Just (']', more) -> Right (JsonArray (reverse (value : acc)), more)+ _ -> Left ("unexpected " <> excerpt after <> " in an array, expected ',' or ']'")++-- | The members of an object, after its @{@ and any whitespace.+parseObject :: IntegerRange -> Text -> Either Text (Json, Text)+parseObject range t = case T.uncons t of+ Just ('}', rest) -> Right (JsonObject Map.empty, rest)+ _ -> members Map.empty t+ where+ members !acc s = case T.uncons s of+ Just ('"', afterQuote) -> do+ (name, afterName) <- parseString afterQuote+ let beforeColon = skipSpace afterName+ case T.uncons beforeColon of+ Just (':', afterColon) -> do+ (value, afterValue) <- parseValue range (skipSpace afterColon)+ let updated = Map.insert name value acc+ after = skipSpace afterValue+ case T.uncons after of+ Just (',', more) -> members updated (skipSpace more)+ Just ('}', more) -> Right (JsonObject updated, more)+ _ -> Left ("unexpected " <> excerpt after <> " in an object, expected ',' or '}'")+ _ -> Left ("unexpected " <> excerpt beforeColon <> " after a member name, expected ':'")+ _ -> Left ("unexpected " <> excerpt s <> " in an object, expected a member name")++-- | The rest of a string after its opening quote. A control character+-- must arrive escaped; nlohmann rejects one written raw.+parseString :: Text -> Either Text (Text, Text)+parseString = collect []+ where+ collect !chunks t =+ let (plain, rest) = T.break special t+ gathered = plain : chunks+ in case T.uncons rest of+ Nothing -> Left "unterminated string"+ Just ('"', more) -> Right (T.concat (reverse gathered), more)+ Just ('\\', more) -> do+ (decoded, afterEscape) <- parseEscape more+ collect (decoded : gathered) afterEscape+ Just (c, _) -> Left ("control character U+" <> hex4 (ord c) <> " must be escaped")+ special c = c == '"' || c == '\\' || c < ' '+ hex4 n = T.justifyRight 4 '0' (T.toUpper (T.pack (showHex n "")))++-- | One escape sequence, after its backslash. A @\\u@ escape naming a+-- UTF-16 high surrogate must be followed by one naming a low surrogate,+-- and a low surrogate alone is an error, as nlohmann's lexer has it.+parseEscape :: Text -> Either Text (Text, Text)+parseEscape t = case T.uncons t of+ Just ('"', rest) -> Right ("\"", rest)+ Just ('\\', rest) -> Right ("\\", rest)+ Just ('/', rest) -> Right ("/", rest)+ Just ('b', rest) -> Right ("\b", rest)+ Just ('f', rest) -> Right ("\f", rest)+ Just ('n', rest) -> Right ("\n", rest)+ Just ('r', rest) -> Right ("\r", rest)+ Just ('t', rest) -> Right ("\t", rest)+ Just ('u', rest) -> do+ (unit, afterUnit) <- codeUnit rest+ codePoint unit afterUnit+ _ -> Left ("invalid escape \\" <> T.take 1 t)+ where+ codePoint unit afterUnit+ | isHighSurrogate unit = case T.stripPrefix "\\u" afterUnit of+ Just afterMarker -> do+ (low, afterLow) <- codeUnit afterMarker+ if isLowSurrogate low+ then Right (T.singleton (chr (surrogateBase + (unit - highSurrogateMin) * surrogateSpan + (low - lowSurrogateMin))), afterLow)+ else Left "a high surrogate must be followed by a low surrogate"+ Nothing -> Left "a high surrogate must be followed by a low surrogate"+ | isLowSurrogate unit = Left "a low surrogate must follow a high surrogate"+ | otherwise = Right (T.singleton (chr unit), afterUnit)+ isHighSurrogate u = u >= highSurrogateMin && u < lowSurrogateMin+ isLowSurrogate u = u >= lowSurrogateMin && u < lowSurrogateMin + surrogateSpan++-- | UTF-16 surrogate ranges: high surrogates from U+D800, low from U+DC00,+-- each 0x400 wide, a pair naming a code point from U+10000.+highSurrogateMin, lowSurrogateMin, surrogateSpan, surrogateBase :: Int+highSurrogateMin = 0xD800+lowSurrogateMin = 0xDC00+surrogateSpan = 0x400+surrogateBase = 0x10000++-- | The four hex digits of a @\\u@ escape, either case.+codeUnit :: Text -> Either Text (Int, Text)+codeUnit t =+ let (digits, rest) = T.splitAt 4 t+ in case traverse hexValue (T.unpack digits) of+ Just values+ | length values == 4 -> Right (foldl' (\acc v -> acc * 16 + v) 0 values, rest)+ _ -> Left ("invalid \\u escape " <> excerpt t)+ where+ hexValue c+ | isDigit c = Just (ord c - ord '0')+ | c >= 'a' && c <= 'f' = Just (ord c - ord 'a' + 10)+ | c >= 'A' && c <= 'F' = Just (ord c - ord 'A' + 10)+ | otherwise = Nothing++-- | A number, as nlohmann's lexer scans it: an optional minus, an integer+-- part with no leading zero, an optional fraction and exponent. One with+-- neither is an integer while it fits nlohmann's integer types (strtoll+-- below zero, strtoull above) and a float past them, as nlohmann falls+-- back; any other is a float (strtod, which 'readMaybe' matches: correctly+-- rounded, infinite past the double range). An infinite one is an error,+-- nlohmann's out_of_range 406.+parseNumber :: IntegerRange -> Text -> Either Text (Json, Text)+parseNumber range t = do+ let (sign, afterSign) = case T.stripPrefix "-" t of+ Just rest -> ("-", rest)+ Nothing -> ("", t)+ (integral, afterIntegral) <- integerPart afterSign+ (fraction, afterFraction) <- optionalPart "." afterIntegral+ (exponentText, afterExponent) <- exponentPart afterFraction+ let lexeme = sign <> integral <> fraction <> exponentText+ asFloat = case readMaybe (T.unpack lexeme) of+ Just d+ | isInfinite d -> Left ("number overflow parsing '" <> lexeme <> "'")+ | otherwise -> Right (JsonFloat d, afterExponent)+ Nothing -> Left ("invalid number " <> excerpt t)+ if T.null fraction && T.null exponentText+ then case readMaybe (T.unpack lexeme) of+ Just n+ | n >= toInteger (minBound :: Int64) && n <= toInteger (maxBound :: Int64) -> Right (JsonInt n, afterExponent)+ | n > toInteger (maxBound :: Int64) && n <= toInteger (maxBound :: Word64) -> case range of+ NlohmannRange -> Right (JsonInt n, afterExponent)+ NixRange -> Left ("unsigned json number " <> T.pack (show n) <> " outside of Nix integer range")+ _ -> asFloat+ else asFloat+ where+ integerPart s = case T.uncons s of+ Just ('0', rest) -> Right ("0", rest)+ Just (c, _)+ | isDigit c -> Right (T.span isDigit s)+ _ -> Left ("invalid number " <> excerpt t)+ optionalPart marker s = case T.stripPrefix marker s of+ Nothing -> Right ("", s)+ Just rest -> digitsAfter marker rest+ exponentPart s = case T.uncons s of+ Just (e, rest)+ | e == 'e' || e == 'E' -> case T.uncons rest of+ Just (signChar, afterSignChar)+ | signChar == '+' || signChar == '-' -> digitsAfter (T.pack [e, signChar]) afterSignChar+ _ -> digitsAfter (T.singleton e) rest+ _ -> Right ("", s)+ digitsAfter marker s = case T.span isDigit s of+ (digits, rest)+ | T.null digits -> Left ("invalid number " <> excerpt t)+ | otherwise -> Right (marker <> digits, rest)
src/Nix/Parser.hs view
@@ -37,26 +37,36 @@ -- f x (left) function application -- e.a (left) attribute selection -- @+--+-- == Lifecycle+--+-- Parsing is pure and touches none of the C data layer, so it runs with+-- or without 'Nix.Eval.Arena.arenaInit'. Evaluating the resulting+-- 'Expr' does need the arena; see "Nix.Eval.Arena". module Nix.Parser ( -- * Parsing parseNix,+ parseNixWithScope, parseNixFile, -- * Encoding readFileAutoEncoding, -- * Errors+ SourceError (..), ParseError (..), ) where +import Data.Bifunctor (first) import qualified Data.ByteString as BS+import Data.Set (Set) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as TE import Data.Text.Encoding.Error (lenientDecode) import Nix.Expr.ClosureTrim (trimClosures)-import Nix.Expr.Resolve (resolveRelativePaths, resolveVars)+import Nix.Expr.Resolve (resolveRelativePaths, resolveVars, staticGlobalNames) import Nix.Expr.Types (Expr) import Nix.Parser.Expr (parseTopLevel) import Nix.Parser.Internal (ParseState (..), runParser)@@ -64,7 +74,20 @@ import Nix.Parser.ParseError (ParseError (..)) import System.FilePath (takeDirectory) --- | Parse a Nix expression from source text.+-- | Why source text did not become an expression to evaluate.+data SourceError+ = -- | The text is not a Nix expression.+ SyntaxError !ParseError+ | -- | A variable that no enclosing scope binds and no enclosing @with@+ -- could supply. Upstream raises this while binding variables, before+ -- evaluating anything, so it fails the expression wherever the+ -- variable sits, and @builtins.tryEval@ cannot catch it.+ UndefinedVariable !Text+ deriving (Eq, Show)++-- | Parse a Nix expression from source text and bind its variables against+-- the root environment's names under the default policy+-- ('staticGlobalNames'). -- -- 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@@ -79,12 +102,22 @@ -- 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)+parseNix :: FilePath -> Text -> Text -> Either SourceError Expr+parseNix = parseNixWithScope staticGlobalNames++-- | 'parseNix' against the names the environment the source is evaluated in+-- binds, which is upstream's @parse@ given a static environment: the root+-- environment's under the policy in force ('Nix.Builtins.rootScopeNames'),+-- with a @scopedImport@ scope's attributes over them, which upstream binds+-- in a static environment of their own over the base one. Every one binds+-- like a global, so a @with@ in the source cannot shadow it.+parseNixWithScope :: Set Text -> FilePath -> Text -> Text -> Either SourceError Expr+parseNixWithScope scope baseDir fileName source = do+ tokens <- first SyntaxError (tokenize fileName (stripBOM source)) let st = ParseState {psTokens = tokens, psFile = fileName}- (expr, _remaining) <- runParser parseTopLevel st- pure (resolveRelativePaths baseDir (trimClosures (resolveVars expr)))+ (expr, _remaining) <- first SyntaxError (runParser parseTopLevel st)+ resolved <- first UndefinedVariable (resolveVars scope expr)+ pure (resolveRelativePaths baseDir (trimClosures resolved)) -- | Strip a leading UTF-8 byte order mark (U+FEFF) if present. stripBOM :: Text -> Text@@ -94,7 +127,7 @@ -- | Parse a @.nix@ file from disk. -- Uses 'readFileAutoEncoding' to handle UTF-8, UTF-16 LE, and UTF-16 BE.-parseNixFile :: FilePath -> IO (Either ParseError Expr)+parseNixFile :: FilePath -> IO (Either SourceError Expr) parseNixFile path = do source <- readFileAutoEncoding path pure $ parseNix (takeDirectory path) (T.pack path) source
src/Nix/Parser/Expr.hs view
@@ -467,13 +467,9 @@ 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)+ go (StrEsc txt : acc) TokInterpOpen -> do _ <- advance expr <- parseExpr@@ -549,8 +545,8 @@ _ <- advance pure (reverse acc) TokInherit -> do- binding <- parseInherit- go (binding : acc)+ inherited <- parseInherit+ go (reverse inherited ++ acc) _ -> do binding <- parseNamedBinding go (binding : acc)@@ -571,10 +567,9 @@ -- | How a name was defined, for duplicate checking. data DefKind = DefStatic | DefInherit --- | Normalize a binding list the way upstream's parser (@addAttr@) does:+-- | Normalize a binding list the way upstream's parser (@addAttr@) does,+-- over bindings 'parseNamedBinding' has already hoisted to one key each: ----- * 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@@ -596,12 +591,12 @@ -- | 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)+ (entriesRev, _defined) <- foldM step ([], Map.empty) 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 ->+ NamedBinding (StaticKey key) value -> case Map.lookup key defined of Nothing -> Right (StaticEntry key value : entries, Map.insert key DefStatic defined)@@ -610,9 +605,17 @@ 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)+ b@(Inherit name _) -> inheritEntry b [name]+ b@(InheritFrom _ names) -> inheritEntry b names+ where+ -- One name at a time, so a name repeated within the clause is a+ -- duplicate too, as upstream's per-name dupAttr check makes it.+ inheritEntry b names = do+ updated <- foldM defineInherited defined names+ Right (InheritEntry b : entries, updated)+ defineInherited acc name+ | Map.member name acc = Left (duplicateAttr name)+ | otherwise = Right (Map.insert name DefInherit acc) -- Replace the existing entry for @key@ with the merged value. mergeIntoEntries key newValue entries = case entries of@@ -634,28 +637,30 @@ <$> normalizeBindingList (existingBindings ++ newBindings) mergeAttrValues key _ _ = Left (duplicateAttr key) - renderEntry (StaticEntry key value) = NamedBinding [StaticKey key] value+ 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-+-- | @a.b.c = v;@ as one binding under its first key, the rest of the+-- path hoisted into nested set literals, one per level: @a = { b = { c =+-- v; }; };@, as upstream's @addAttr@ builds them. parseNamedBinding :: Parser Binding parseNamedBinding = do- path <- parseAttrPath+ firstKey <- parseAttrKey+ rest <- pMany (expect TokDot >> parseAttrKey) expect TokAssign val <- parseExpr expect TokSemicolon- pure (NamedBinding path val)+ pure (NamedBinding firstKey (nestUnder rest val))+ where+ nestUnder [] value = value+ nestUnder (key : keys) value = EAttrs False [NamedBinding key (nestUnder keys value)] NoCaptureInfo -parseInherit :: Parser Binding+-- | An @inherit@ clause: one 'InheritFrom' for @inherit (from) x y;@, one+-- 'Inherit' per name for @inherit x y;@, each holding the variable it copies.+parseInherit :: Parser [Binding] parseInherit = do expect TokInherit tok <- peek@@ -666,11 +671,11 @@ expect TokRParen names <- parseInheritNames expect TokSemicolon- pure (Inherit (Just from) names)+ pure [InheritFrom from names] _ -> do names <- parseInheritNames expect TokSemicolon- pure (Inherit Nothing names)+ pure [Inherit name (EVar name) | name <- names] parseInheritNames :: Parser [Text] parseInheritNames = go []@@ -743,6 +748,7 @@ literalStringKey (EStr parts) = mconcat <$> traverse literalPart parts where literalPart (StrLit t) = Just t+ literalPart (StrEsc t) = Just t literalPart (StrInterp _) = Nothing literalStringKey _ = Nothing @@ -778,15 +784,15 @@ case tok of TokIn -> pure (reverse acc) TokInherit -> do- binding <- parseInherit- go (binding : acc)+ inherited <- parseInherit+ go (reverse inherited ++ acc) _ -> do binding <- parseNamedBinding -- 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 _ : _) _ ->+ NamedBinding (DynamicKey _) _ -> parseError "dynamic attributes not allowed in let" _ -> go (binding : acc)
src/Nix/Parser/Lexer.hs view
@@ -66,10 +66,11 @@ | 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.+ -- @''${@, @''\x@). Kept apart from 'TokStringLit' because upstream's+ -- lexer.l emits an escape without the hasIndentation mark: the+ -- indentation scan skips it (it ends a line's leading whitespace+ -- without being measured) while the strip runs over it as over any+ -- literal. TokStringEsc !Text | TokInterpOpen | TokInterpClose@@ -216,8 +217,8 @@ '\'' | 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)+ opened = advanceCol 2 st {lsInput = T.drop 2 (lsInput st), lsModes = ModeIndString : lsModes st}+ in lexLoop (skipIndStringOpenerLine opened) (tok : acc) '.' | Just '.' <- safeHead rest, Just '.' <- safeHead (T.drop 1 rest) ->@@ -360,6 +361,18 @@ } in lexLoop newSt (tok : acc) _ -> lexStringLiteral st acc++-- | The rest of an indented string's opening line. Upstream's opener is+-- @''@ with an optional run of spaces and one newline (lexer.l:+-- @\'\'(\ *\n)?@), so content starts on the next line and the opening+-- line's trailing spaces never reach the indentation scan. Only spaces+-- qualify: a tab after @''@ is content, as are spaces not followed by a+-- newline.+skipIndStringOpenerLine :: LexState -> LexState+skipIndStringOpenerLine st =+ case T.uncons (T.dropWhile (== ' ') (lsInput st)) of+ Just ('\n', rest) -> st {lsInput = rest, lsLine = lsLine st + 1, lsCol = 1}+ _ -> st lexIndStringMode :: LexState -> [Located] -> Either ParseError [Located] lexIndStringMode st acc
src/Nix/Push.hs view
@@ -50,6 +50,8 @@ mkPushArtifact, PushArtifact (..), planMissing,+ isDerivationPath,+ outputPathsOnly, narFileName, stripHashPrefix, storePathBasename,@@ -58,7 +60,7 @@ ) where -import Control.Exception (SomeException, try)+import Control.Exception (IOException, try) import Control.Monad (foldM, forM, forM_, unless, when) import Control.Monad.Except (ExceptT (..), liftEither, runExceptT, throwError) import Control.Monad.IO.Class (liftIO)@@ -74,7 +76,8 @@ 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 Nix.Http (catchSync, withTransfer, withUserAgent)+import Nix.Store (Store (..), queryDeriver, queryPathInfo, queryReferences, volumeCaseHack) import qualified Nix.Store.DB as DB import qualified Nix.Store.ExecBit as ExecBit import Nix.Store.Path (StorePath (spHash, spName), defaultStoreDir, parseStorePath, storePathToFilePath, storePathToText)@@ -185,7 +188,7 @@ loadApiKeyFile path = do attempt <- try (BS.readFile path) pure $ case attempt of- Left (e :: SomeException) -> Left ("cannot read key file: " <> T.pack (show e))+ Left (e :: IOException) -> Left ("cannot read key file: " <> T.pack (show e)) Right bytes -> case TE.decodeUtf8' bytes of Left _ -> Left ("key file is not valid UTF-8: " <> T.pack path) Right raw ->@@ -229,6 +232,23 @@ planMissing :: Set Text -> [StorePath] -> [StorePath] planMissing remote = filter (\sp -> not (spHash sp `Set.member` remote)) +-- | Whether a store path is a derivation: a recipe, not an output.+isDerivationPath :: StorePath -> Bool+isDerivationPath sp = derivationExtension `T.isSuffixOf` spName sp++-- | Every valid path that is not a derivation: outputs and sources alike.+-- A store registers its derivations as valid paths like anything else, but+-- a binary cache serves build outputs only: nova-cache refuses a narinfo+-- for a @.drv@ outright, and no substituter asks a cache for a recipe it+-- can rebuild from the expression. @push --all@ therefore means every+-- valid path except derivations.+outputPathsOnly :: [StorePath] -> [StorePath]+outputPathsOnly = filter (not . isDerivationPath)++-- | The suffix that marks a derivation's store path.+derivationExtension :: Text+derivationExtension = ".drv"+ -- | The basename form used in narinfo @References@ and @Deriver@ fields: -- @\<hash\>-\<name\>@ with no store dir. storePathBasename :: StorePath -> Text@@ -322,13 +342,14 @@ -- Serializes one NAR at a time (bounded memory), uploads all NARs before -- any narinfo, and finishes with a signed round-trip check. Any failure -- aborts with an error; a partial push leaves only orphaned NARs behind,--- which are invisible to clients.+-- which are invisible to clients. An interrupt is not a failure: it+-- stops the push and propagates. pushPaths :: PushConfig -> Store -> [StorePath] -> IO (Either Text PushSummary)-pushPaths cfg store roots = do- attempt <- try (runExceptT run)- pure $ case attempt of- Left (e :: SomeException) -> Left ("push failed: " <> T.pack (show e))- Right r -> r+pushPaths cfg store roots =+ -- Every synchronous exception: the push raises sqlite-simple's from the+ -- database and IO errors from the serialiser and the log, and each is a+ -- failed push to report.+ runExceptT run `catchSync` \e -> pure (Left ("push failed: " <> T.pack (show e))) where run :: ExceptT Text IO PushSummary run = do@@ -381,7 +402,7 @@ uploadNar :: HTTP.Manager -> PushConfig -> Store -> StorePath -> ExceptT Text IO NarInfo uploadNar manager cfg store sp = do let physicalPath = storePathToFilePath (stDir store) sp- narEntry <- liftIO (ExecBit.serialiseFromPath physicalPath)+ narEntry <- liftIO (ExecBit.serialiseFromPath (volumeCaseHack (stCaseSensitivity store)) physicalPath) let narBytes = NAR.serialise narEntry narHash = Hash.formatNixHash (Hash.hashBytes narBytes) narSize = BS.length narBytes@@ -468,8 +489,10 @@ -- HTTP plumbing -- --------------------------------------------------------------------------- --- | Issue an HTTP request with optional body, converting any exception--- into a push error.+-- | Issue an HTTP request with optional body, converting a failure of the+-- request into a push error. The body is read through 'withTransfer', so+-- a connection lost mid-body is an 'HTTP.HttpException' like every other+-- failure of the request, and that is the one exception caught. httpRequest :: HTTP.Manager -> BS.ByteString ->@@ -481,15 +504,17 @@ attempt <- try $ do request0 <- HTTP.parseRequest (T.unpack url) let request =- request0- { HTTP.method = method,- HTTP.requestHeaders = headers,- HTTP.requestBody = maybe (HTTP.requestBody request0) HTTP.RequestBodyBS body- }- response <- HTTP.httpLbs request manager- pure (BS.toStrict <$> response)+ withUserAgent+ request0+ { HTTP.method = method,+ HTTP.requestHeaders = headers,+ HTTP.requestBody = maybe (HTTP.requestBody request0) HTTP.RequestBodyBS body+ }+ withTransfer request manager $ \response -> do+ chunks <- HTTP.brConsume (HTTP.responseBody response)+ pure (BS.concat chunks <$ response) pure $ case attempt of- Left (e :: SomeException) -> Left ("HTTP error for " <> url <> ": " <> T.pack (show e))+ Left (e :: HTTP.HttpException) -> Left ("HTTP error for " <> url <> ": " <> T.pack (show e)) Right response -> Right response -- | Authorization headers for authenticated writes.
src/Nix/Store.hs view
@@ -25,24 +25,23 @@ -- -- == Garbage collection ----- A GC root is an explicit "keep this" marker (e.g. the current system--- profile, per-user profiles, result symlinks from @nix-build@).--- GC walks all roots, follows references transitively, and deletes--- everything not reachable. Since paths are immutable and reference--- tracking is exact, GC is safe - it never deletes something in use.+-- A GC root is an explicit "keep this" marker: the out-link+-- @build --out-link@ creates, or a file the operator drops under the+-- store's @gcroots@ directory. The collector walks every root, follows+-- references transitively, and deletes everything not reachable; a+-- single-path delete refuses a path that walk reaches. "Nix.Store.GC"+-- holds the roots model, the walk, the sweep, and the lock that keeps a+-- collection and a concurrent writer apart; "Nix.Store.Handle" holds the+-- open handle and its lease on that lock. module Nix.Store- ( -- * Store operations- Store (..),- openStore,- closeStore,-- -- * Queries+ ( -- * Queries isValid, pathExists, -- * Deletion DeleteOutcome (..), deleteStorePathRaw,+ deleteStorePathChecked, resolveDeleteTarget, -- * Store operations@@ -66,27 +65,33 @@ sinkNarEvent, finishNarUnpack, abortNarUnpack,-- -- * NAR entry-name safety- isSafeNarName,+ NarStreamFailure (..),+ sinkNarStream, -- * Link ordering (exposed for testing) orderLinks, -- * Case-hack naming (exposed for testing)+ onDiskNameKey, caseHackDiskNames, -- * Re-exports module Nix.Store.Path, module Nix.Store.DB, module Nix.Store.Lock,+ module Nix.Store.CaseSensitive,+ module Nix.Store.Handle,+ module Nix.Store.GC, ) where -import Control.Exception (IOException, SomeException, catch, throwIO, try)-import Control.Monad (unless, when)+import Control.Exception (IOException, bracket, catch, onException, throwIO, try, tryJust)+import Control.Monad (guard, join, unless, when)+import Data.Bool (bool) import qualified Data.ByteString as BS-import Data.Char (isDigit, toUpper)+import qualified Data.ByteString.Char8 as BS8+import Data.Char (toUpper)+import Data.Foldable (traverse_) import Data.IORef (IORef, newIORef, readIORef, writeIORef) import Data.List (inits) import Data.Map.Strict (Map)@@ -98,46 +103,34 @@ 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.CaseSensitive (CaseSensitivity (..), processCaseHack, trySetCaseSensitiveDir, volumeCaseHack) import Nix.Store.DB+import Nix.Store.EntryName (checkEntryName, entryNamePath, hostNameRules, linkTargetPath, shownBytes)+import Nix.Store.Exclusive (Occupant (..), directoryTakenMessage, fileTakenMessage, openNewBinaryFile, symlinkTakenMessage) import qualified Nix.Store.ExecBit as ExecBit+import Nix.Store.GC+import Nix.Store.Handle import Nix.Store.Lock import Nix.Store.Path+import Nix.Store.Symlink (WalkNode (..), classifyWalkNode, createSymlinkOfKind) import qualified NovaCache.Hash as Hash import qualified NovaCache.NAR as NAR import qualified NovaCache.NAR.Stream as Stream import System.Directory- ( copyFile,- createDirectoryIfMissing,+ ( 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+import qualified System.Directory.OsPath as OsDir+import System.FilePath ((</>))+import System.IO (Handle, IOMode (ReadMode), hClose, withBinaryFile)+import System.IO.Error (isAlreadyExistsError)+import System.OsPath (OsPath)+import qualified System.OsPath as OP -- | Check if a store path is registered as valid in the database. isValid :: Store -> StorePath -> IO Bool@@ -195,45 +188,69 @@ 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.+-- while a root keeps it alive, or while other valid paths reference it)+-- and the on-disk tree. One checked delete under its own collector+-- lock and live set; a batch shares both through 'withLiveSet' and+-- 'deleteStorePathChecked'. 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+ join <$> withLiveSet store (\live -> deleteStorePathChecked store live basename)++-- | The checked delete of one entry, given the live set, which also+-- stands for the collector lock being held. 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.+--+-- Liveness is upstream's rule for a specific delete: a path reachable+-- from a root is refused with its words. The referrer refusal that+-- follows is this tool's own in wording and in shape. Upstream refuses+-- a path with an unrooted referrer with the same still-alive line+-- unless that referrer is named in the same invocation (its+-- @pathsToDelete@ set in 2.24.9's @deleteReferrersClosure@), in which+-- case the named set deletes referrer-first; this delete removes+-- exactly the path named and lists the referrers that stand in the way,+-- so a chain deletes leaf-first in argument order. Inside the collector+-- lock the sequence also holds the target's per-path lock - the same+-- file a substituter of the path locks, as upstream's deletePath does -+-- so a holder that is not a store handle (nothing in this tool, but+-- nothing forbids one) still cannot have its delete-materialize-register+-- torn apart by this delete.+deleteStorePathChecked :: Store -> LiveSet -> Text -> IO (Either Text DeleteOutcome)+deleteStorePathChecked store live basename+ | isLive live (T.pack target) = pure (Left (stillAliveMessage (T.pack target)))+ | otherwise = deleteUnderPathLock where target = unStoreDir (stDir store) </> T.unpack basename+ deleteUnderPathLock = withLockFile (target <> lockFileSuffix) $ \_ -> do+ -- A holder never deletes a lock file (the 'Nix.Store.Lock'+ -- header), and neither does this delete. 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 here (store gc removes it,"+ <> " and any unregistered store object of this exact"+ <> " name, once "+ <> guardedPath+ <> " is not live)"+ )+ )+ _ -> deleteRowAndTree deleteRowAndTree = do rowResult <- unregisterPathRow (stDB store) (T.pack target) case rowResult of@@ -260,16 +277,6 @@ 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 -- ---------------------------------------------------------------------------@@ -317,7 +324,7 @@ -- 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+ narEntry <- ExecBit.serialiseFromPath (volumeCaseHack (stCaseSensitivity store)) destPath let narBytes = NAR.serialise narEntry pure PathRegistration@@ -386,31 +393,6 @@ 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@@ -537,162 +519,240 @@ ) -- ---------------------------------------------------------------------------+-- Exclusive creation+-- ---------------------------------------------------------------------------++-- Every regular file and directory a tree materializes is created+-- exclusively: a name already taken refuses the write instead of+-- landing on the earlier entry. 'onDiskNameKey' predicts which+-- siblings a folding volume merges, but only the volume knows its own+-- fold table, so the create is what catches a pair the key misses.+-- Upstream's restore creates the same way (@RestoreSink@ in+-- fs-sink.cc at 2.24.9: @O_CREAT | O_EXCL@ for a regular file, a+-- refused @create_directory@ for a directory), and the refusals carry+-- its words ("Nix.Store.Exclusive"). Symlinks need nothing extra:+-- @symlink(2)@ and CreateSymbolicLinkW never replace an existing name,+-- and 'createSymlinkOfKind' words that refusal as upstream does.++-- | Run a create that must find its name free, its refusal becoming the+-- failure the given action describes. Only the refusal becomes a+-- value: it is a property of the tree being written, where any other+-- I\/O failure (permissions, a full disk) is one of the machine and+-- stays an exception.+refuseTaken :: IO Text -> IO a -> IO (Either Text a)+refuseTaken describe create = do+ attempt <- tryJust (guard . isAlreadyExistsError) create+ either (const (Left <$> describe)) (pure . Right) attempt++-- | Create a regular file of a materialized tree exclusively and write+-- it through the handle, closed on the way out.+withNewTreeFile :: OsPath -> (Handle -> IO a) -> IO (Either Text a)+withNewTreeFile path write =+ bracket (openNewTreeFile path) (traverse_ hClose) (traverse write)++-- | Open a regular file of a materialized tree, created exclusively.+openNewTreeFile :: OsPath -> IO (Either Text Handle)+openNewTreeFile path = refuseTaken (fileTakenMessage <$> OP.decodeFS path) (openNewBinaryFile path)++-- | Create a directory of a materialized tree exclusively. Its parent+-- is created first if missing, which only the root can need. A+-- refusal asks what holds the name, as @create_directory@ does before+-- it chooses between returning false and throwing.+createTreeDirectory :: OsPath -> IO (Either Text ())+createTreeDirectory path = do+ OsDir.createDirectoryIfMissing True (OP.takeDirectory path)+ refuseTaken describe (OsDir.createDirectory path)+ where+ describe = do+ occupant <- bool OccupiedByOther OccupiedByDirectory <$> OsDir.doesDirectoryExist path+ directoryTakenMessage occupant <$> OP.decodeFS path++-- --------------------------------------------------------------------------- -- 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'.+-- | Unpack a NarEntry tree to a filesystem destination. Returns @Left@+-- on an entry name or link target the host cannot hold+-- ("Nix.Store.EntryName"); these can come from untrusted cache data, so+-- a typed failure is used instead of a partial 'error'. --+-- Names and targets reach the filesystem as the bytes the NAR carries,+-- as upstream's restore writes them on POSIX: the tree is walked in+-- 'OsPath's, a POSIX path being bytes. Only the destination enters as+-- a 'FilePath', spelled as every 'FilePath' API spells it.+--+-- The sensitivity is the destination volume's ('stCaseSensitivity' for+-- a store path) and decides whether case-variant siblings need the+-- case-hack ('onDiskNameKey').+-- -- 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+unpackNarEntry :: CaseSensitivity -> FilePath -> NAR.NarEntry -> IO (Either Text ())+unpackNarEntry sensitivity path entry = do+ root <- OP.encodeFS path+ walked <- unpackTree sensitivity root 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+unpackTree :: CaseSensitivity -> OsPath -> NAR.NarEntry -> IO (Either Text [(OsPath, OsPath)])+unpackTree sensitivity 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)]+ OsDir.createDirectoryIfMissing True (OP.takeDirectory path)+ written <- withNewTreeFile path (`BS.hPut` contents)+ case written of+ Left err -> pure (Left err)+ Right () -> do+ when isExec (ExecBit.markExecutableOsPath path)+ pure (Right [])+ NAR.NarSymlink target -> fmap (\targetPath -> [(path, targetPath)]) <$> linkTargetPath target NAR.NarDirectory entries -> do- createDirectoryIfMissing True path- unpackChildren path entries+ created <- createTreeDirectory path+ case created of+ Left err -> pure (Left err)+ Right () -> unpackChildren sensitivity 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 on-disk identity a NAR entry name occupies on a volume of the+-- given case sensitivity. Two sibling entries sharing a key name ONE+-- file on that volume, so the unpack keeps them apart+-- (@unpackNamedChildren@). A folding volume (the default APFS format,+-- NTFS through Win32) keys a name by its uppercase; a sensitive one+-- (Linux, a case-sensitive APFS volume) keys it byte-for-byte. The+-- fold is per-character uppercase, which keeps apart the collisions+-- real trees carry (@Makefile@\/@makefile@) but not every pair the+-- volume folds: APFS reads U+00DF and U+1E9E as one name while+-- 'toUpper' leaves U+00DF unchanged, so that pair takes two keys and+-- the exclusive create refuses the second at the write (#234), as+-- upstream's restore does. The reverse miss (U+0131 uppercases to+-- @I@, which APFS keeps distinct) only spells a name with a suffix it+-- did not need. A name that is not UTF-8 has no characters to fold+-- and keys as its bytes; no host whose volumes fold admits one+-- ("Nix.Store.EntryName"), and the create would catch what the key+-- missed. Win32 strips a trailing dot or space on create, which would+-- merge names this key keeps apart, but Windows refuses such names+-- before any is keyed.+--+-- Upstream decides this per process, not per volume: its+-- @use-case-hack@ setting defaults to on for Darwin and off elsewhere+-- (archive.cc at 2.24.9), so a case-sensitive macOS volume still has+-- @makefile@ restored as @makefile~nix~case~hack~1@ (observed from+-- @nix-store --restore@ 2.33.2). The hack exists to work around+-- folding, and a volume that does not fold needs none, so nova-nix+-- keys by the probed answer and materializes true names there. The+-- divergence is in on-disk spelling only: either tree re-serialises+-- to the same NAR.+onDiskNameKey :: CaseSensitivity -> BS.ByteString -> BS.ByteString+onDiskNameKey sensitivity name = case sensitivity of+ CaseSensitive -> name+ CaseInsensitive -> either (const name) (TE.encodeUtf8 . T.map toUpper) (TE.decodeUtf8' name) -- | 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+firstNameCollision :: CaseSensitivity -> [BS.ByteString] -> Maybe (BS.ByteString, BS.ByteString)+firstNameCollision sensitivity = go Map.empty where go !_ [] = Nothing go !seen (name : rest) =- let key = onDiskNameKey name+ let key = onDiskNameKey sensitivity 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+-- | Whether a tree on a volume of the given case sensitivity is read+-- back with the case-hack suffix stripped ('volumeCaseHack'). Where+-- it is, 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's restore accepts such a+-- name and its dump then strips the suffix (archive.cc at 2.24.9), so+-- the tree fails there as well, only later; refusing at the write+-- boundary surfaces the same failure before anything is materialized.+-- A volume that keeps names apart reads every name as spelled, so the+-- name materializes and round-trips there.+volumeStripsCaseHack :: CaseSensitivity -> Bool+volumeStripsCaseHack sensitivity = volumeCaseHack sensitivity == 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+-- | Refuse an entry name before any sibling is written: one the host+-- cannot hold ('checkEntryName'), or one carrying the case-hack suffix+-- where the destination volume's serialiser would strip it.+admitEntryName :: CaseSensitivity -> BS.ByteString -> Either Text ()+admitEntryName sensitivity name = do+ checkEntryName hostNameRules name+ when (volumeStripsCaseHack sensitivity && NAR.caseHackSuffix `BS.isInfixOf` name) $+ Left ("NAR entry name contains the case-hack suffix: " <> shownBytes name) --- | 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))+-- | The case-hacked disk name for the given occurrence of a key.+caseHackName :: BS.ByteString -> Int -> BS.ByteString+caseHackName name occurrence = name <> NAR.caseHackSuffix <> BS8.pack (show occurrence) --- | 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, [])+-- | Disk names for a sibling list on a volume of the given case+-- sensitivity, WITHOUT per-directory case sensitivity: upstream's+-- case-hack where names fold, every name as spelled where they do not.+-- The first occurrence of each key keeps its spelling; every later+-- name with the same key gains the reversible suffix and a per-name+-- counter, which the serialiser strips on the way back out+-- ('volumeCaseHack'). Order is preserved; result pairs are (NAR+-- name, on-disk name).+caseHackDiskNames :: CaseSensitivity -> [BS.ByteString] -> [(BS.ByteString, BS.ByteString)]+caseHackDiskNames sensitivity = reverse . snd . foldl' step (Map.empty, []) where step (!seen, !acc) name =- let key = onDiskNameKey name+ let key = onDiskNameKey sensitivity 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)+ in (Map.insert key next seen, (name, caseHackName name next) : 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+-- the NAR carries, and every sibling's name is admitted+-- ('admitEntryName') before the first is written, so a name the host+-- cannot hold leaves no partial directory behind it.+unpackChildren :: CaseSensitivity -> OsPath -> [(BS.ByteString, NAR.NarEntry)] -> IO (Either Text [(OsPath, OsPath)])+unpackChildren sensitivity path entries = case traverse_ (admitEntryName sensitivity . fst) entries 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)+ Right () -> unpackNamedChildren sensitivity path entries --- | Unpack decoded directory children, short-circuiting with a typed--- failure on the first unsafe entry name rather than crashing on--- untrusted input.+-- | Unpack admitted directory children. ----- 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+-- On a volume the probe reports sensitive (Linux, case-sensitive APFS)+-- no two sibling names share an on-disk identity and every entry+-- materializes as spelled. 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,+-- a folding APFS volume) the collision falls back to upstream's+-- case-hack renaming, which the volume's serialiser reverses. Either+-- way a registered path re-serialises to its NAR byte-for-byte - the+-- substituter's on-disk recheck verifies it. The name spelled on disk+-- is the one the host is asked to hold, suffix included.+unpackNamedChildren :: CaseSensitivity -> OsPath -> [(BS.ByteString, NAR.NarEntry)] -> IO (Either Text [(OsPath, OsPath)])+unpackNamedChildren sensitivity path entries = do diskNames <- resolveDiskNames- walkChildren (zip diskNames entries)+ walkChildren (zip diskNames (map snd entries)) where names = map fst entries- resolveDiskNames = case firstNameCollision names of+ resolveDiskNames = case firstNameCollision sensitivity names of Nothing -> pure names Just _ -> do- trueNames <- trySetCaseSensitiveDir path+ trueNames <- trySetCaseSensitiveDir =<< OP.decodeFS path pure ( if trueNames then names- else map snd (caseHackDiskNames names)+ else map snd (caseHackDiskNames sensitivity 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+ walkChildren ((diskName, child) : rest) = do+ spelled <- entryNamePath diskName+ case spelled of+ Left err -> pure (Left err)+ Right component -> do+ result <- unpackTree sensitivity (path OP.</> component) child case result of Left err -> pure (Left err) Right links -> do@@ -709,7 +769,7 @@ -- 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 :: [(OsPath, OsPath)] -> IO (Either Text ()) createSymlinks pending = createAll (orderLinks pending) where createAll [] = pure (Right ())@@ -726,7 +786,7 @@ -- 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 :: [(OsPath, OsPath)] -> [(OsPath, OsPath)] orderLinks pending = let indexed = zip [0 :: Int ..] pending linkByIndex = Map.fromList indexed@@ -735,7 +795,7 @@ -- 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)+ let resolved = normalisedComponents (OP.takeDirectory linkPath OP.</> 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]@@ -766,47 +826,29 @@ -- | 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))+-- link paths ('OP.splitDirectories' accepts both separator spellings+-- on Windows). A @..@ with nothing left to pop stays, matching no real+-- path.+normalisedComponents :: OsPath -> [OsPath]+normalisedComponents path = reverse (foldl' step [] (OP.splitDirectories path)) where step stack comp- | comp == "." = stack- | comp == ".." = case stack of- (top : rest) | top /= ".." -> rest+ | comp == currentDirectory = stack+ | comp == parentDirectory = case stack of+ (top : rest) | top /= parentDirectory -> rest _ -> comp : stack | otherwise = comp : stack+ currentDirectory = OP.pack [dot]+ parentDirectory = OP.pack [dot, dot]+ dot = OP.unsafeFromChar '.' --- | 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 ())+-- | Create one unpacked symlink, parents first, with the Windows flavor+-- read off the target ('createSymlinkOfKind'). A creation failure is+-- loud, and failing lets the caller fall back to a local build.+createSymlink :: OsPath -> OsPath -> 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)"- )- )+ OsDir.createDirectoryIfMissing True (OP.takeDirectory linkPath)+ createSymlinkOfKind linkPath target -- --------------------------------------------------------------------------- -- Streaming NAR unpacking@@ -816,8 +858,8 @@ -- 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)+ { ufPath :: !OsPath,+ ufSeen :: !(Map BS.ByteString Int) } -- | Mutable state behind a 'NarUnpackSink' - the same deliberate IO@@ -825,30 +867,36 @@ -- 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)]+ { -- | The destination volume's case sensitivity, keying sibling+ -- names exactly as the strict path does.+ nusCaseSensitivity :: !CaseSensitivity,+ nusFrames :: ![UnpackFrame],+ nusTargets :: ![OsPath],+ nusOpen :: !(Maybe (Handle, OsPath, Bool)),+ nusLinks :: ![(OsPath, OsPath)] } -- | 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+-- name bytes and host 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.+-- a folding volume 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. On a volume+-- the probe reports sensitive nothing collides, as in the strict path. -- 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 [])+-- | A sink for one NAR unpack under the given destination path, on a+-- volume of the given case sensitivity.+newNarUnpackSink :: CaseSensitivity -> FilePath -> IO NarUnpackSink+newNarUnpackSink sensitivity destPath = do+ root <- OP.encodeFS destPath+ NarUnpackSink <$> newIORef (NarUnpackState sensitivity [] [root] Nothing []) -- | Feed one event. On 'Left' the partial tree stays for the caller -- to remove - 'abortNarUnpack' first, so no handle stays open on it.@@ -868,9 +916,9 @@ 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)})+ OsDir.createDirectoryIfMissing True (OP.takeDirectory path)+ opened <- openNewTreeFile path+ pure (fmap (\fileHandle -> narState {nusOpen = Just (fileHandle, path, isExec)}) opened) Stream.EventRegularChunk slice -> case nusOpen narState of Nothing -> pure (Left "NAR stream sink: file contents outside an open file") Just (fileHandle, _, _) -> do@@ -880,38 +928,37 @@ Nothing -> pure (Left "NAR stream sink: file close without an open file") Just (fileHandle, path, isExec) -> do hClose fileHandle- when isExec (ExecBit.markExecutable path)+ when isExec (ExecBit.markExecutableOsPath 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}+ fmap (\target -> narState {nusLinks = (path, target) : nusLinks narState})+ <$> linkTargetPath targetBytes 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+ created <- createTreeDirectory path+ pure (narState {nusFrames = UnpackFrame path Map.empty : nusFrames narState} <$ created)+ Stream.EventEntryBegin name -> 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- }+ (frame : outer) -> case admitEntryName (nusCaseSensitivity narState) name of+ Left err -> pure (Left err)+ Right () -> do+ -- 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 (nusCaseSensitivity narState) name+ (diskName, occurrences) = case Map.lookup key (ufSeen frame) of+ Nothing -> (name, 0)+ Just seen -> (caseHackName name (seen + 1), seen + 1)+ updatedFrame = frame {ufSeen = Map.insert key occurrences (ufSeen frame)}+ spelled <- entryNamePath diskName+ pure $+ fmap+ ( \component ->+ narState+ { nusFrames = updatedFrame : outer,+ nusTargets = (ufPath frame OP.</> component) : nusTargets narState+ }+ )+ spelled Stream.EventEntryEnd -> case nusTargets narState of -- The root destination never pops; only entry-pushed paths do. (_ : rest@(_ : _)) -> pure (Right narState {nusTargets = rest})@@ -921,7 +968,7 @@ (_ : 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 :: NarUnpackState -> (OsPath -> 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")@@ -948,78 +995,58 @@ 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)+-- | Why a streamed archive stopped short of its end.+data NarStreamFailure+ = -- | The store refused what the archive holds (a name, a taken+ -- path): a property of the archive, not of how it arrived.+ NarStreamRefused !Text+ | -- | The bytes do not parse as a NAR. A truncated or torn transfer+ -- reads the same as a malformed archive.+ NarStreamMalformed !Text+ deriving (Eq, Show)++-- | Feed a chunk source through the streaming NAR parser into a sink,+-- hashing and counting exactly the bytes the parser consumes, and+-- return the archive's SHA-256 and size once its root node closes.+-- The recorded symlinks are not created yet ('finishNarUnpack'), so a+-- caller checks the digest before any link exists. On a failure the+-- sink is already aborted.+sinkNarStream :: NarUnpackSink -> IO BS.ByteString -> IO (Either NarStreamFailure (Hash.NixHash, Int))+sinkNarStream sink source = go Hash.hashInit 0 Stream.narStream `onException` abortNarUnpack sink 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'+ go !ctx !count step = case step of+ Stream.NarAwait continue -> do+ chunk <- source+ go (Hash.hashUpdate ctx chunk) (count + BS.length chunk) (continue chunk)+ Stream.NarYield event next -> do+ sunk <- sinkNarEvent sink event+ either (stopWith . NarStreamRefused) (const (go ctx count next)) sunk+ Stream.NarFail msg -> stopWith (NarStreamMalformed (T.pack msg))+ Stream.NarDone -> pure (Right (Hash.hashFinalize ctx, count))+ stopWith failure = Left failure <$ abortNarUnpack sink -- --------------------------------------------------------------------------- -- 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.+-- | Restore 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 restored from a dump+-- of its source ('restoreSource'), made read-only, and registered. A+-- 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+ -- Each source registers IMMEDIATELY after its restore (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+ -- restore 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.+ -- touches the files (rewriting a read-only tree fails on Windows and+ -- would wedge the store permanently); a failed one clears and+ -- restores. 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@@ -1033,14 +1060,75 @@ 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 []+ adoptable <- if onDisk then adoptedTreeMatches (stCaseSensitivity store) dest sp else pure False+ reg <-+ if adoptable+ then registrationFor store sp Nothing []+ else do+ when onDisk (Dir.removePathForcibly dest)+ restoreSource store (T.unpack rawPath) sp >>= either (refuse spText) pure registerPath (stDB store) reg+ refuse spText reason =+ throwIO (userError (T.unpack ("refusing to register " <> spText <> ": " <> reason))) +-- | Restore a source tree at the store path evaluation named it by,+-- from a dump of the source, as upstream's @addToStoreFromDump@+-- restores the dump it hashed (@restorePath@, local-store.cc at+-- 2.24.9) rather than copying files: the source's NAR streams out of+-- the walk, through the hash and into an unpack sink in one pass, so a+-- sibling pair the store's volume folds takes the case-hack on the way+-- in, and no file's contents are ever held whole. The source is read+-- as evaluation read it to name the path, under the process's+-- case-hack setting, and the restored tree as a store path, under its+-- volume's.+--+-- Evaluation hashed the source earlier, so the dump is held to that+-- address before any link is created: a source that changed in between+-- is refused, never registered under a hash its bytes no longer have.+-- The restored tree is then rechecked on disk, as the substituter+-- rechecks an unpacked one, since the volume decides the final+-- spelling of every name. Any failure removes the tree.+restoreSource :: Store -> FilePath -> StorePath -> IO (Either Text PathRegistration)+restoreSource store src sp = do+ outcome <- restore `onException` Dir.removePathForcibly dest+ either (\reason -> Left reason <$ Dir.removePathForcibly dest) (pure . Right) outcome+ where+ dest = storePathToFilePath (stDir store) sp+ restore = do+ sink <- newNarUnpackSink (stCaseSensitivity store) dest+ streamed <- ExecBit.withNarSource processCaseHack src (sinkNarStream sink)+ case streamed of+ Left (NarStreamRefused reason) -> pure (Left reason)+ Left (NarStreamMalformed reason) -> pure (Left ("the dump of " <> T.pack src <> " does not parse: " <> reason))+ Right (digest, size)+ | not (recursiveDigestNames sp digest) -> do+ abortNarUnpack sink+ pure (Left (T.pack src <> " changed after evaluation hashed it, so it no longer reproduces its store path; re-evaluate"))+ | otherwise -> do+ finished <- finishNarUnpack sink+ either (pure . Left) (const (sealAndRecheck digest size)) finished+ sealAndRecheck digest size = do+ setReadOnly dest+ onDisk <- ExecBit.narHashOfPath (volumeCaseHack (stCaseSensitivity store)) dest+ pure $+ if onDisk /= digest+ then Left "the restored tree does not reproduce its store path on disk"+ else+ Right+ PathRegistration+ { prPath = sp,+ prNarHash = Hash.formatNixHash digest,+ prNarSize = size,+ prDeriver = Nothing,+ prReferences = []+ }++-- | Whether a recursive NAR digest and a store path's own name derive+-- exactly that path, as a @source@ path's address is derived.+recursiveDigestNames :: StorePath -> Hash.NixHash -> Bool+recursiveDigestNames sp (Hash.NixHash digest) =+ makeFixedOutputPath (spName sp) "sha256" "recursive" digest == Right sp+ -- | 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@:@@ -1075,7 +1163,7 @@ -- 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+ reproduces <- writeReproducesPath (stCaseSensitivity store) dest sp refs mode unless reproduces $ throwIO ( userError@@ -1091,9 +1179,9 @@ -- | 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+writeReproducesPath :: CaseSensitivity -> FilePath -> StorePath -> [StorePath] -> StoreWriteMode -> IO Bool+writeReproducesPath sensitivity dest sp refs mode = case mode of+ WriteRecursive -> adoptedTreeMatches sensitivity dest sp WriteFlat -> withFileBytes (\bytes -> makeFixedOutputPath (spName sp) "sha256" "flat" (sha256Digest bytes) == Right sp) WriteText ->@@ -1105,14 +1193,15 @@ -- | 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)+-- derive exactly this path. An unreadable tree counts as a mismatch. The+-- sensitivity is the store volume's, which decides the case-hack mode the+-- tree is read back under.+adoptedTreeMatches :: CaseSensitivity -> FilePath -> StorePath -> IO Bool+adoptedTreeMatches sensitivity dest sp = do+ result <- try (ExecBit.serialiseFromPath (volumeCaseHack sensitivity) dest) pure $ case result of- Left (_ :: SomeException) -> False- Right entry ->- makeFixedOutputPath (spName sp) "sha256" "recursive" (sha256Digest (NAR.serialise entry)) == Right sp+ Left (_ :: IOException) -> False+ Right entry -> recursiveDigestNames sp (NAR.narHash entry) -- | 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@@ -1121,6 +1210,11 @@ -- self-referential link would recurse forever). On Windows without -- symlink privilege the link creation fails loudly rather than silently -- corrupting the content address.+--+-- Every entry is created exclusively, like an unpacked tree's:+-- siblings distinct on the source volume can fold together on the+-- destination's, and the copy refuses the second rather than landing+-- it on the first, raising the refusal in upstream's wording. copyPathInto :: FilePath -> FilePath -> IO () copyPathInto src dest = do isLink <- Dir.pathIsSymbolicLink src@@ -1128,18 +1222,39 @@ then do target <- Dir.getSymbolicLinkTarget src linkedDir <- doesDirectoryExist src- if linkedDir- then Dir.createDirectoryLink target dest- else Dir.createFileLink target dest+ let createLink = if linkedDir then Dir.createDirectoryLink else Dir.createFileLink+ refuseTaken (pure (symlinkTakenMessage dest target)) (createLink target dest) >>= raiseRefusal else do isDir <- doesDirectoryExist src+ destPath <- OP.encodeFS dest if isDir then do- createDirectoryIfMissing True dest+ createTreeDirectory destPath >>= raiseRefusal 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.+ copied <- withNewTreeFile destPath (\to -> withBinaryFile src ReadMode (`copyHandleBytes` to))+ raiseRefusal copied+ -- Best effort: of the permissions, the NAR records only the+ -- exec mark, which is carried on its own below (the unnamed+ -- stream alone does not hold it on Windows).+ Dir.copyPermissions src dest `catch` \(_ :: IOException) -> pure () ExecBit.copyExecMark src dest+ where+ raiseRefusal = either (throwIO . userError . T.unpack) pure++-- | Copy a readable handle's remaining bytes into a writable one, a+-- bounded chunk at a time.+copyHandleBytes :: Handle -> Handle -> IO ()+copyHandleBytes from to = go+ where+ go = do+ chunk <- BS.hGetSome from copyChunkBytes+ unless (BS.null chunk) $ do+ BS.hPut to chunk+ go++-- | The read size of 'copyHandleBytes': the block @directory@'s own+-- file copy reads, which is coreutils' @cp@ size.+copyChunkBytes :: Int+copyChunkBytes = 128 * 1024
src/Nix/Store/CaseSensitive.hs view
@@ -1,21 +1,119 @@ {-# LANGUAGE CPP #-} --- | Per-directory case sensitivity: the capability probe behind--- true-name NAR materialization on a folding filesystem.+-- | Case sensitivity of the store filesystem: the runtime probe behind+-- the store's sibling-name handling and the case-hack mode its trees+-- are read back under, and the per-directory capability behind+-- true-name NAR materialization on NTFS. ----- 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+-- A folding filesystem lands two sibling names differing only by case+-- on one file, so an unpacker there has to keep them apart on disk+-- (upstream's case-hack suffix) or make the directory sensitive (the+-- NTFS per-directory flag, the mechanism WSL uses to host Linux trees).+-- Which of those a store needs is a property of the volume holding it,+-- not of the operating system: APFS is formatted either way at volume+-- creation, so the store probes its own directory when it opens and+-- carries the answer, rather than deciding at compile time by OS name.+module Nix.Store.CaseSensitive+ ( CaseSensitivity (..),+ probeCaseSensitivity,+ trySetCaseSensitiveDir, -#ifdef mingw32_HOST_OS+ -- * Reading a tree back+ volumeCaseHack,+ processCaseHack,+ )+where +import qualified NovaCache.NAR as NAR++#if defined(darwin_HOST_OS)++import Foreign.C.String (CString)+import Foreign.C.Types (CInt (..))+import System.Posix.Internals (withFilePath)++#elif defined(mingw32_HOST_OS)+ import Foreign.C.String (CWString, withCWString) import Foreign.C.Types (CInt (..)) +#endif++-- | How the filesystem under a store compares sibling names.+data CaseSensitivity+ = -- | Names differing only by case are distinct entries.+ CaseSensitive+ | -- | Names differing only by case resolve to one entry.+ CaseInsensitive+ deriving (Eq, Show)++-- | The case-hack mode a tree on a volume of the given sensitivity is+-- read back under. A folding volume holds a case-variant sibling+-- under upstream's reversible suffix, so the serialiser strips it; a+-- volume that keeps names apart never needs the suffix, so its names+-- are read as spelled, a literal suffix included. Upstream ties this+-- to the process instead (@use-case-hack@ governs dump and restore+-- alike, archive.cc at 2.24.9), which on a case-sensitive macOS volume+-- strips a suffix nothing put there.+volumeCaseHack :: CaseSensitivity -> NAR.CaseHack+volumeCaseHack sensitivity = case sensitivity of+ CaseSensitive -> NAR.CaseHackDisabled+ CaseInsensitive -> NAR.CaseHackEnabled++-- | The case-hack mode of a path read outside any store: a source that+-- evaluation hashes. That is the process's setting, as upstream's+-- @use-case-hack@ is (archive.cc at 2.24.9, on for Darwin):+-- nova-cache's per-OS default, which turns it on for Windows as well,+-- where nova-nix's stores fold.+processCaseHack :: NAR.CaseHack+processCaseHack = NAR.defaultCaseHack++#if defined(darwin_HOST_OS)++foreign import ccall unsafe "nn_darwinfs.h nn_path_case_sensitive"+ c_nn_path_case_sensitive :: CString -> IO CInt++-- | The case sensitivity of the volume holding an EXISTING path, from+-- @pathconf(_PC_CASE_SENSITIVE)@: the default APFS format folds, a+-- volume formatted "Case-sensitive APFS" does not. No answer (the+-- path is missing, or the filesystem does not implement the query)+-- reads as folding, which is upstream's Darwin assumption and the safe+-- side: the case-hack on a sensitive volume only spells names oddly,+-- while true names on a folding volume lose files.+probeCaseSensitivity :: FilePath -> IO CaseSensitivity+probeCaseSensitivity path =+ withFilePath path (fmap classify . c_nn_path_case_sensitive)+ where+ classify answer+ | answer == probeAnswerSensitive = CaseSensitive+ | otherwise = CaseInsensitive++-- | The shim's answer for a sensitive volume (@nn_darwinfs.h@): 1, with+-- 0 for a folding one and -1 for no answer.+probeAnswerSensitive :: CInt+probeAnswerSensitive = 1++#elif defined(mingw32_HOST_OS)++-- | Win32 resolves names case-insensitively unless a directory carries+-- the NTFS per-directory flag, which the store sets itself where a+-- tree needs it ('trySetCaseSensitiveDir'), so the volume-level answer+-- is the Win32 default.+probeCaseSensitivity :: FilePath -> IO CaseSensitivity+probeCaseSensitivity _ = pure CaseInsensitive++#else++-- | Linux has no @pathconf@ name for case sensitivity and its+-- filesystems are case-sensitive by convention, the same assumption+-- upstream makes there (its case-hack is off outside Darwin).+probeCaseSensitivity :: FilePath -> IO CaseSensitivity+probeCaseSensitivity _ = pure CaseSensitive++#endif++#ifdef mingw32_HOST_OS+ foreign import ccall unsafe "nn_winfs.h nn_dir_set_case_sensitive" c_nn_dir_set_case_sensitive :: CWString -> IO CInt @@ -30,7 +128,8 @@ #else -- | Unsupported off Windows: sensitivity is a volume-level property on--- macOS (chosen at store-volume creation) and inherent on Linux.+-- macOS (chosen at volume creation, and probed above) and inherent on+-- Linux. trySetCaseSensitiveDir :: FilePath -> IO Bool trySetCaseSensitiveDir _ = pure False
src/Nix/Store/DB.hs view
@@ -37,6 +37,7 @@ registerPaths, isValidPath, queryReferences,+ queryAllReferences, queryDeriver, queryPathInfo, queryAllValidPaths,@@ -44,6 +45,7 @@ -- * Unregistration UnregisterResult (..), unregisterPathRow,+ unregisterPathRows, -- * Constants metaDirName,@@ -60,6 +62,7 @@ Query (..), close, execute,+ executeMany, execute_, open, query,@@ -295,6 +298,19 @@ rows <- query (sdbConn db) "SELECT path FROM ValidPaths ORDER BY path" () :: IO [Only Text] pure [p | Only p <- rows] +-- | Every reference edge in the store as @(referrer, reference)@ path+-- text, in one query: the collector's reachability walk is over the+-- whole graph, and one round trip beats one per valid path.+queryAllReferences :: StoreDB -> IO [(Text, Text)]+queryAllReferences db =+ query+ (sdbConn db)+ "SELECT vp1.path, vp2.path FROM Refs r \+ \JOIN ValidPaths vp1 ON r.referrer = vp1.id \+ \JOIN ValidPaths vp2 ON r.reference = vp2.id \+ \ORDER BY vp1.path, vp2.path"+ ()+ -- | Query the deriver of a registered store path. queryDeriver :: StoreDB -> StorePath -> IO (Maybe Text) queryDeriver db sp = do@@ -349,6 +365,52 @@ execute conn "DELETE FROM Refs WHERE referrer = ?" (Only pathId) execute conn "DELETE FROM ValidPaths WHERE id = ?" (Only pathId) pure RowUnregistered+ where+ conn = sdbConn db++-- | Remove a SET of rows and every edge leaving them, in one+-- transaction, keyed by exact stored path text like 'unregisterPathRow'.+-- Exists for the collector: dead paths reference each other freely+-- (cross-output references run both ways between the outputs of one+-- derivation), and no order of one-row deletes gets through a cycle,+-- while deleting every dead referrer's edges before any dead row makes+-- the order irrelevant.+--+-- Refuses, changing nothing, when a path OUTSIDE the set still+-- references a path inside it: by construction a dead path's referrers+-- are all dead, so an outside referrer means the set is not the dead set+-- of this database, and dropping its edge would under-report that+-- referrer's references. The outside referrers are returned as+-- 'RowReferenced'; 'RowAbsent' is never returned, since an absent row in+-- the set simply has nothing to remove.+--+-- The set goes through a temporary table rather than an @IN (?, ?, ...)@+-- list: SQLite caps bound variables per statement, and a dead set at+-- nixpkgs scale is larger than that cap.+unregisterPathRows :: StoreDB -> [Text] -> IO UnregisterResult+unregisterPathRows db paths = withTransaction conn $ do+ execute_ conn "CREATE TEMP TABLE IF NOT EXISTS DeadPaths (path TEXT PRIMARY KEY)"+ execute_ conn "DELETE FROM DeadPaths"+ executeMany conn "INSERT OR IGNORE INTO DeadPaths (path) VALUES (?)" (map Only paths)+ outsideReferrers <-+ query+ conn+ "SELECT DISTINCT vp1.path FROM Refs r \+ \JOIN ValidPaths vp1 ON r.referrer = vp1.id \+ \JOIN ValidPaths vp2 ON r.reference = vp2.id \+ \WHERE vp2.path IN (SELECT path FROM DeadPaths) \+ \AND vp1.path NOT IN (SELECT path FROM DeadPaths) \+ \ORDER BY vp1.path"+ () ::+ IO [Only Text]+ result <- case [p | Only p <- outsideReferrers] of+ referrers@(_ : _) -> pure (RowReferenced referrers)+ [] -> do+ execute_ conn "DELETE FROM Refs WHERE referrer IN (SELECT id FROM ValidPaths WHERE path IN (SELECT path FROM DeadPaths))"+ execute_ conn "DELETE FROM ValidPaths WHERE path IN (SELECT path FROM DeadPaths)"+ pure RowUnregistered+ execute_ conn "DELETE FROM DeadPaths"+ pure result where conn = sdbConn db
+ src/Nix/Store/EntryName.hs view
@@ -0,0 +1,185 @@+-- | Which NAR entry names and symlink targets a host's filesystem can+-- hold, and the host path that spells one.+--+-- A NAR carries names and link targets as bytes. Upstream's restore on+-- POSIX appends each name to the destination path as it finds it and+-- hands each target to @symlink@ unchanged (@RestoreSink@ in fs-sink.cc+-- at 2.24.9), refusing only what the archive grammar refuses: an empty+-- name, @.@, @..@, a @/@ or a NUL (@parse@ in archive.cc). Those+-- refusals hold on every host, through the parser's own check. Every+-- other limit belongs to a filesystem, so it is decided per host+-- ('NameRules'): a name the host cannot hold is refused before anything+-- is written, and a store path holding one does not materialize there,+-- whichever way it arrives.+--+-- The decisions are pure and take the rules as an argument, so every+-- host's answer is testable on any host. Only 'entryNamePath' and+-- 'linkTargetPath' consult the running host.+module Nix.Store.EntryName+ ( -- * Hosts+ NameRules (..),+ hostNameRules,++ -- * Decisions+ checkEntryName,+ checkLinkTarget,++ -- * Host paths+ entryNamePath,+ linkTargetPath,++ -- * Display+ shownBytes,+ )+where++import Data.Bifunctor (first)+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import Data.Char (ord)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import Data.Word (Word8)+import Nix.HostPath (hostPathFromBytes)+import qualified NovaCache.NAR.Stream as Stream+import NovaCache.SafeName (hasTrailingDotOrSpace, isReservedDeviceName)+import qualified System.Info+import System.OsPath (OsPath)++-- ---------------------------------------------------------------------------+-- Hosts+-- ---------------------------------------------------------------------------++-- | The names a family of filesystems can hold.+data NameRules+ = -- | Every name the grammar admits and every target, as bytes: Linux+ -- and the other POSIX hosts, where upstream restores names as it+ -- finds them.+ PosixNames+ | -- | A name must be UTF-8 holding no noncharacter. APFS refuses any+ -- other name at the create (@EILSEQ@, and the same for a code point+ -- outside its Unicode tables, which is left to it since those tables+ -- move with the system release); HFS+ stores one with each byte+ -- outside UTF-8, and U+FFFE and U+FFFF, rewritten into a @%XX@+ -- escape, so the tree no longer names what its NAR does. Both keep+ -- a link target's bytes as given.+ DarwinNames+ | -- | A name must have a UTF-16 form, avoid every character Win32+ -- reserves (@\<@, @>@, @:@, @\"@, @\\@, @|@, @?@, @*@ and the+ -- controls), not name a device, and not end in a dot or a space,+ -- which Win32 strips. A colon or a backslash would not even fail:+ -- one opens an alternate data stream or a drive-relative path, the+ -- other a separator, so the bytes land somewhere other than the+ -- named entry. A link target must have a UTF-16 form.+ WindowsNames+ deriving (Eq, Show)++-- | The rules of the host this process runs on. Decided per operating+-- system, not per volume: a Darwin store on APFS and one on HFS+ both+-- need a name to be UTF-8. A volume the rules do not describe (FAT+-- mounted on Linux) answers at its own create, and a tree it respells+-- fails the on-disk check that precedes registration.+hostNameRules :: NameRules+hostNameRules = case System.Info.os of+ "mingw32" -> WindowsNames+ "darwin" -> DarwinNames+ _ -> PosixNames++-- ---------------------------------------------------------------------------+-- Decisions+-- ---------------------------------------------------------------------------++-- | Refuse a directory entry name the grammar refuses, or one a host+-- under the given rules cannot hold.+checkEntryName :: NameRules -> ByteString -> Either Text ()+checkEntryName rules name = do+ first T.pack (Stream.checkEntryName Nothing name)+ case rules of+ PosixNames -> Right ()+ DarwinNames -> darwinName+ WindowsNames -> windowsName+ where+ darwinName = case TE.decodeUtf8' name of+ Left _ -> refuse "is not valid UTF-8, which a macOS filesystem cannot hold"+ Right text+ | T.any isNoncharacter text ->+ refuse "holds a Unicode noncharacter, which a macOS filesystem cannot hold"+ | otherwise -> Right ()+ windowsName = case TE.decodeUtf8' name of+ Left _ -> Left (noUtf16Form entryNameNoun name)+ Right _+ | BS.any isWin32Reserved name -> refuse "holds a character Windows does not allow in a name"+ | isReservedDeviceName name -> refuse "is a Windows device name"+ | hasTrailingDotOrSpace name -> refuse "ends in a dot or a space, which Windows strips"+ | otherwise -> Right ()+ refuse reason = Left (entryNameNoun <> " " <> shownBytes name <> " " <> reason)++-- | Refuse a symlink target a host under the given rules cannot hold.+-- POSIX and Darwin hold any bytes; a Windows link target is UTF-16.+checkLinkTarget :: NameRules -> ByteString -> Either Text ()+checkLinkTarget rules target = case (rules, TE.decodeUtf8' target) of+ (WindowsNames, Left _) -> Left (noUtf16Form linkTargetNoun target)+ _ -> Right ()++-- | How a refusal names what it refuses: a directory entry name.+entryNameNoun :: Text+entryNameNoun = "NAR directory entry name"++-- | How a refusal names what it refuses: a symlink target.+linkTargetNoun :: Text+linkTargetNoun = "NAR symlink target"++-- | The refusal of bytes with no UTF-8 reading where names are UTF-16.+noUtf16Form :: Text -> ByteString -> Text+noUtf16Form noun bytes =+ noun <> " " <> shownBytes bytes <> " is not valid UTF-8, so it has no UTF-16 form for Windows"++-- | Bytes as a refusal shows them: quoted text where they read as UTF-8,+-- an escaped literal where they do not, so the message neither drops+-- nor invents a byte.+shownBytes :: ByteString -> Text+shownBytes bytes = case TE.decodeUtf8' bytes of+ Right text -> "'" <> text <> "'"+ Left _ -> T.pack (show bytes)++-- | A Unicode noncharacter: U+FDD0 to U+FDEF, and the last two code+-- points of every plane. The set is closed by the standard, unlike the+-- unassigned code points APFS also refuses.+isNoncharacter :: Char -> Bool+isNoncharacter c =+ (code >= 0xFDD0 && code <= 0xFDEF) || code `mod` planeSize >= planeSize - 2+ where+ code = ord c+ planeSize = 0x10000++-- | A byte Win32 refuses in a name: the reserved punctuation and the+-- controls. Each is ASCII, and no byte of a multi-byte UTF-8 sequence+-- is, so the byte test is exact on the UTF-8 name.+isWin32Reserved :: Word8 -> Bool+isWin32Reserved byte = byte < 0x20 || BS.elem byte "<>:\"\\|?*"++-- ---------------------------------------------------------------------------+-- Host paths+-- ---------------------------------------------------------------------------++-- | The host path component spelling an entry name, or the running+-- host's refusal of the name ('checkEntryName' under 'hostNameRules').+entryNamePath :: ByteString -> IO (Either Text OsPath)+entryNamePath name = case checkEntryName hostNameRules name of+ Left refusal -> pure (Left refusal)+ Right () -> spell entryNameNoun name++-- | The host path a symlink is created pointing at, or the running+-- host's refusal of the target ('checkLinkTarget' under+-- 'hostNameRules').+linkTargetPath :: ByteString -> IO (Either Text OsPath)+linkTargetPath target = case checkLinkTarget hostNameRules target of+ Left refusal -> pure (Left refusal)+ Right () -> spell linkTargetNoun target++-- | Spell bytes the host accepted as a host path ('hostPathFromBytes').+-- Only Windows refuses any, and its checks admit only UTF-8, so this+-- refusal is theirs, repeated.+spell :: Text -> ByteString -> IO (Either Text OsPath)+spell noun bytes = pure (maybe (Left (noUtf16Form noun bytes)) Right (hostPathFromBytes bytes))
+ src/Nix/Store/Exclusive.hs view
@@ -0,0 +1,214 @@+{-# LANGUAGE CPP #-}++-- | Creating store entries that must not exist yet, and upstream's+-- words when one does.+--+-- A materializer that creates or truncates (@WriteMode@) trusts its own+-- bookkeeping to know that a name is free, and on a case-folding volume+-- that bookkeeping is an approximation of the volume's fold table: a+-- sibling it keeps apart but the volume folds lands on the earlier+-- file and replaces it. An exclusive create asks the volume itself,+-- which is the only party that knows, and refuses instead. Upstream's+-- restore opens every regular file this way (@O_CREAT | O_EXCL@ in+-- @RestoreSink::createRegularFile@, fs-sink.cc at 2.24.9, and+-- @CREATE_NEW@ in its Windows branch by 2.33.2).+--+-- @base@ has no exclusive open, so the platform's own call is made+-- through @unix@ or @Win32@ and the descriptor is handed to the I\/O+-- library as an ordinary 'Handle'. The path is an 'OsPath', so a POSIX+-- name reaches @open@ as the bytes it was spelled from, and an error+-- names the path as base names one it opened.+module Nix.Store.Exclusive+ ( -- * Creation+ openNewBinaryFile,++ -- * Upstream's refusals+ Occupant (..),+ fileTakenMessage,+ directoryTakenMessage,+ symlinkTakenMessage,+ )+where++import Data.Text (Text)+import qualified Data.Text as T+import System.OsPath (OsPath, decodeFS)++#if defined(mingw32_HOST_OS)++import Control.Exception (bracketOnError)+import Data.Bits ((.|.))+import Data.List (isPrefixOf)+import GHC.IO.SubSystem (IoSubSystem (..), ioSubSystem)+import System.IO (Handle)+import System.IO.Error (ioeSetFileName, modifyIOError)+import System.Win32.File+ ( cREATE_NEW,+ closeHandle,+ createFile,+ fILE_ATTRIBUTE_NORMAL,+ fILE_FLAG_OVERLAPPED,+ fILE_SHARE_READ,+ fILE_SHARE_WRITE,+ gENERIC_READ,+ gENERIC_WRITE,+ )+import System.Win32.Info (getFullPathName)+import System.Win32.Types (hANDLEToHandle)++#else++import Control.Exception (bracketOnError)+import qualified Data.ByteString.Char8 as BS8+import GHC.IO.Handle.FD (fdToHandle')+import System.IO (Handle, IOMode (WriteMode))+import System.IO.Error (ioeSetFileName, modifyIOError)+import qualified System.OsPath as OP+import System.Posix.Files (stdFileMode)+import System.Posix.IO+ ( OpenFileFlags (cloexec, creat, exclusive),+ OpenMode (WriteOnly),+ closeFd,+ defaultFileFlags,+ )+import System.Posix.IO.ByteString (openFd)+import System.Posix.Types (Fd (..))++#endif++-- ---------------------------------------------------------------------------+-- Creation+-- ---------------------------------------------------------------------------++#if defined(mingw32_HOST_OS)++-- | Create a regular file and open it for binary writing, failing with+-- an 'System.IO.Error.isAlreadyExistsError' exception when any entry+-- already answers to the name, including one the volume's fold table+-- matches to it. Access and sharing are upstream's restore on Windows+-- (2.33.2), and what @base@ requests for a write under its+-- descriptor-based I\/O manager; the overlapped flag is what the+-- native I\/O manager needs from a handle it adopts.+openNewBinaryFile :: OsPath -> IO Handle+openNewBinaryFile osPath = do+ -- A Windows 'OsPath' is UTF-16, so decoding it is exact.+ path <- decodeFS osPath+ modifyIOError (`ioeSetFileName` path) $ do+ target <- win32FilePath path+ bracketOnError+ ( createFile+ target+ (gENERIC_READ .|. gENERIC_WRITE)+ (fILE_SHARE_READ .|. fILE_SHARE_WRITE)+ Nothing+ cREATE_NEW+ attributes+ Nothing+ )+ closeHandle+ hANDLEToHandle+ where+ attributes = case ioSubSystem of+ IoPOSIX -> fILE_ATTRIBUTE_NORMAL+ IoNative -> fILE_ATTRIBUTE_NORMAL .|. fILE_FLAG_OVERLAPPED++-- | The Win32 file-namespace spelling of a path (@\\\\?\\@), which+-- CreateFileW takes past MAX_PATH. @base@ converts every path it+-- opens to this form, under either I\/O manager+-- (@__hs_create_device_name@ in GHC's utils\/fs\/fs.c), so a raw+-- CreateFileW without it would refuse long paths @base@ accepts. The+-- namespace form is taken literally, so separators, @.@ and @..@ are+-- resolved first by GetFullPathNameW, as that conversion does.+win32FilePath :: FilePath -> IO FilePath+win32FilePath path+ | any (`isPrefixOf` path) namespacePrefixes = pure path+ | otherwise = toNamespace <$> getFullPathName path+ where+ namespacePrefixes = [fileNamespace, "\\\\.\\", "\\??\\"]+ toNamespace full = case full of+ '\\' : '\\' : share -> fileNamespace <> "UNC\\" <> share+ _ -> fileNamespace <> full+ fileNamespace = "\\\\?\\"++#else++-- | Create a regular file and open it for binary writing, failing with+-- an 'System.IO.Error.isAlreadyExistsError' exception when any entry+-- already answers to the name, including one the volume's fold table+-- matches to it. The mode is @base@'s for a new file (0666, narrowed+-- by the umask), and the handle is named by the path, as @base@ names+-- the handles it opens. Close-on-exec keeps a concurrently spawned+-- builder from inheriting the descriptor, as upstream's restore does.+openNewBinaryFile :: OsPath -> IO Handle+openNewBinaryFile path = do+ shown <- decodeFS path+ modifyIOError (`ioeSetFileName` shown) $+ bracketOnError+ (openFd (rawPath path) WriteOnly newFileFlags)+ closeFd+ (\(Fd fd) -> fdToHandle' fd Nothing False shown WriteMode True)+ where+ -- A POSIX path unit is a byte, and 'OP.toChar' reads it as the+ -- character of that code, so the round trip through 'BS8' is exact.+ rawPath = BS8.pack . map OP.toChar . OP.unpack+ newFileFlags =+ defaultFileFlags+ { creat = Just stdFileMode,+ exclusive = True,+ cloexec = True+ }++#endif++-- ---------------------------------------------------------------------------+-- Upstream's refusals+-- ---------------------------------------------------------------------------++-- | What already answers to the name a directory was to take.+-- Upstream words the two apart: @create_directory@ reports an existing+-- directory by returning false and anything else by throwing.+data Occupant+ = OccupiedByDirectory+ | OccupiedByOther+ deriving (Eq, Show)++-- | Upstream's refusal of a regular file whose name is taken+-- (@RestoreSink::createRegularFile@, fs-sink.cc at 2.24.9). The path+-- reaches its formatter as a @std::filesystem::path@, whose stream+-- insertion goes through @std::quoted@: hence the double quotes inside+-- the single ones.+fileTakenMessage :: FilePath -> Text+fileTakenMessage path = "creating file '" <> cxxQuoted path <> "': " <> existsReason++-- 2.24.9 printed the C++ library's filesystem_error text for the two+-- refusals below, which is implementation-defined and differs by+-- platform; 2.25 wrapped it in the messages 2.33.2 prints, which are+-- followed.++-- | Upstream's refusal of a directory whose name is taken: its own+-- error over an existing directory (@RestoreSink::createDirectory@,+-- fs-sink.cc at 2.24.9), and 2.33.2's over anything else.+directoryTakenMessage :: Occupant -> FilePath -> Text+directoryTakenMessage occupant path = case occupant of+ OccupiedByDirectory -> "path '" <> T.pack path <> "' already exists"+ OccupiedByOther -> "creating directory '" <> T.pack path <> "': " <> existsReason++-- | Upstream's refusal of a link whose name is taken, given the link's+-- path and its target (@RestoreSink::createSymlink@, fs-sink.cc at+-- 2.33.2).+symlinkTakenMessage :: FilePath -> FilePath -> Text+symlinkTakenMessage linkPath target =+ "creating symlink from '" <> T.pack linkPath <> "' -> '" <> T.pack target <> "': " <> existsReason++-- | @strerror(EEXIST)@, the reason the refusals here end with.+existsReason :: Text+existsReason = "File exists"++-- | @std::quoted@ over a string: double quotes around it, and a double+-- quote or backslash inside escaped with a backslash.+cxxQuoted :: FilePath -> Text+cxxQuoted path = "\"" <> T.concatMap escape (T.pack path) <> "\""+ where+ escape c+ | c == '"' || c == '\\' = T.pack ['\\', c]+ | otherwise = T.singleton c
src/Nix/Store/ExecBit.hs view
@@ -10,8 +10,10 @@ module Nix.Store.ExecBit ( isExecutable, markExecutable,+ markExecutableOsPath, copyExecMark, serialiseFromPath,+ withNarSource, narHashOfPath, execStreamName, )@@ -24,7 +26,9 @@ import qualified NovaCache.Hash as Hash import qualified NovaCache.NAR as NAR import qualified System.Directory as Dir+import qualified System.Directory.OsPath as OsDir import qualified System.Info+import System.OsPath (OsPath, decodeFS, encodeFS) -- | The alternate data stream whose presence marks a file executable. -- Named for this project so it cannot collide with @Zone.Identifier@ or@@ -64,17 +68,25 @@ -- already-writable file exactly as it was, and every caller gets this -- rather than each remembering the order. markExecutable :: FilePath -> IO ()-markExecutable path+markExecutable path = markExecutableOsPath =<< encodeFS path++-- | 'markExecutable' on a platform-native path, for a writer holding a+-- name as bytes: on POSIX the mode is set on exactly those bytes.+markExecutableOsPath :: OsPath -> IO ()+markExecutableOsPath path | usesStream = do- perms <- Dir.getPermissions path+ perms <- OsDir.getPermissions path let writable = Dir.writable perms+ -- The stream is reached by name through the 'FilePath' file API;+ -- a Windows path is UTF-16, so decoding it is exact.+ streamPath <- execStreamPath <$> decodeFS path bracket_- (unless writable (Dir.setPermissions path (Dir.setOwnerWritable True perms)))- (unless writable (Dir.setPermissions path perms))- (BS8.writeFile (execStreamPath path) (BS8.pack "1"))+ (unless writable (OsDir.setPermissions path (Dir.setOwnerWritable True perms)))+ (unless writable (OsDir.setPermissions path perms))+ (BS8.writeFile streamPath (BS8.pack "1")) | otherwise = do- perms <- Dir.getPermissions path- Dir.setPermissions path (Dir.setOwnerExecutable True perms)+ perms <- OsDir.getPermissions path+ OsDir.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@@ -84,14 +96,15 @@ 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}+-- | nova-cache serialise options under a case-hack mode, 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.+serialiseOptions :: NAR.CaseHack -> NAR.SerialiseOptions+serialiseOptions mode = NAR.defaultSerialiseOptions {NAR.soCaseHack = mode, NAR.soExecBit = isExecutable} -- | 'NovaCache.NAR.serialiseFromPath', with every regular file's -- executable flag taken from 'isExecutable' rather than from the@@ -99,18 +112,26 @@ -- 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 case-hack mode is the reader's: a store path's volume decides+-- it ('Nix.Store.CaseSensitive.volumeCaseHack'), and the process does+-- for a source ('Nix.Store.CaseSensitive.processCaseHack').+serialiseFromPath :: NAR.CaseHack -> FilePath -> IO NAR.NarEntry+serialiseFromPath = NAR.serialiseFromPathOpts . serialiseOptions +-- | 'NovaCache.NAR.withNarSource' through the same exec-bit source of+-- truth: the path's NAR as a pull source of chunks, no file's contents+-- held whole.+withNarSource :: NAR.CaseHack -> FilePath -> (IO BS.ByteString -> IO a) -> IO a+withNarSource = NAR.withNarSourceOpts . serialiseOptions+ -- | 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,+-- truth 'serialiseFromPath' writes. Streams on every platform: the+-- archive is pulled in chunks with the ADS resolver answering the+-- flags, and the digest folds over them. 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 ->+narHashOfPath :: NAR.CaseHack -> FilePath -> IO Hash.NixHash+narHashOfPath mode path =+ withNarSource mode path $ \pull -> let go !ctx = do chunk <- pull if BS.null chunk
+ src/Nix/Store/GC.hs view
@@ -0,0 +1,637 @@+-- | Garbage collection: roots, the reachability walk, and the sweep.+--+-- == Roots+--+-- A root is a store path the collector keeps, together with everything+-- it references transitively. Roots live under the store's metadata+-- directory, @\<store\>\/.nova-nix\/gcroots@. Upstream keeps them under+-- its state directory (@\/nix\/var\/nix\/gcroots@); this store has no+-- state directory apart from @.nova-nix@, and roots beside the database+-- mean a store copied or moved as one directory keeps them. Two kinds:+--+-- * __Permanent roots__ are entries anywhere under @gcroots@ except+-- @auto@: a symlink whose target lies in the store roots the store+-- path it lies in (the path itself or anything beneath it, upstream's+-- @toStorePath@), and a regular file named @\<hash\>-\<name\>@ roots+-- the path of that name. Both are upstream's own forms (its+-- @findRoots@ reads a symlink's target and takes a regular file's+-- basename), and the file form is the one that works everywhere,+-- since a symlink on Windows needs Developer Mode. Nothing in+-- nova-nix creates a permanent root; they are for the operator.+--+-- * __Indirect roots__ are what @build --out-link PATH@ registers+-- ('addOutLinkRoot', upstream's @addPermRoot@): the out-link+-- @PATH -> \<store path\>@ where the user asked for it, plus a record+-- @gcroots\/auto\/\<sha256 of PATH\>@ holding PATH's absolute+-- spelling. Upstream's record is a symlink to the out-link; here it+-- is a regular file with the same content, because the record must be+-- writable on every platform the store runs on. The out-link itself+-- is a native symlink everywhere, with the Developer Mode requirement+-- the README states for symlinks. A record whose out-link no longer+-- exists is stale and removed by the next walk, as upstream removes+-- its stale @auto@ links; one whose out-link exists but no longer+-- points into the store is simply not a root.+--+-- Upstream also finds runtime roots (open files and mapped executables+-- of running processes, through @\/proc@ on Linux and @lsof@ elsewhere)+-- and per-process temporary roots. Neither exists here: the lock below+-- covers builds in flight, and a program running out of an unrooted+-- store path is not protected. Upstream's @keep-derivations@ and+-- @keep-outputs@ are not implemented either: the builder registers+-- outputs with no deriver, so a @.drv@ is kept only while something+-- references it, and an unrooted @.drv@ and the sources only it+-- references are collected.+--+-- == Reachability and the sweep+--+-- 'reachableFrom' is the pure walk over the references map: a root is+-- live, and so is everything a live path references. Every valid path+-- outside that set is dead. 'sweepPlan', also pure, turns the valid+-- set, the live set and the store directory's entries into what to+-- remove: the dead rows, and every directory entry that is neither live+-- nor the metadata directory, which is what upstream's sweep removes+-- too (a dead path's tree, a store-shaped tree with no row from a build+-- interrupted before registration, junk, and a dead path's lock file).+-- A live path's lock file stays. Upstream's holders delete their lock+-- files on release, so a leftover one is crash debris its sweep+-- reclaims; here holders never delete ("Nix.Store.Lock"), a live+-- path's lock file is its normal state, and only the path's death makes+-- the file debris. The collector may delete lock files at all because+-- under the exclusive lock no process has the store open, and every+-- per-path lock in this tool is taken by a process holding a store+-- handle, so no holder or waiter exists for a deleted file to strand.+--+-- 'collectGarbage' is the IO boundary: dead rows go in one database+-- transaction ('unregisterPathRows'), then trees one by one. Rows+-- before trees for the reason 'Nix.Store.deleteStorePathRaw' gives: a+-- tree without a row is inert debris, a row without a tree is a lie+-- existence checks believe. A dead row with no tree is unregistered+-- without a line or a count: upstream's sweep walks the directory and+-- never meets it, and what is announced and counted here is what+-- upstream announces and counts, the directory entries removed.+--+-- == The store directory's spelling+--+-- The database keys every row by the store directory's spelling at+-- registration ("Nix.Store.DB"), and the collector compares that text+-- with paths derived from the handle's directory.+-- 'Nix.Store.Handle.openStore' canonicalises the spelling, so one+-- directory named two ways keys the same rows; a store whose rows were+-- registered under another spelling (a symlinked parent, a store moved+-- and opened at its new path) would make every row look dead, so the+-- collector and the checked delete refuse when any row lies outside the+-- opened directory ('validPathsUnder') rather than sweep a rooted+-- closure over a text mismatch.+--+-- == The lock+--+-- Upstream serialises collection against writers with a store-wide+-- @gc.lock@, held exclusively by the collector and shared by every+-- writer, plus a socket through which a writer registers roots with a+-- running collector. nova-nix keeps the lock and drops the socket:+-- every open 'Store' handle holds the shared lock from 'openStore' to+-- 'closeStore' ("Nix.Store.Handle"), and 'collectGarbage' (and+-- 'withLiveSet', which the checked delete runs under) trades the+-- handle's shared lease for the exclusive lock for the duration. A+-- collection therefore runs only while no other process has the store+-- open, and a process opening the store while one runs waits for it to+-- finish. That is coarser than the per-path locks the builder and+-- substituter hold, and deliberately so: a per-path lock covers a path+-- while it is being produced, but not the window between a dependency's+-- registration and the build that consumes it, nor the writers that+-- register without one (the @.drv@ closure, the eval-time store+-- writes). Under the exclusive lock no writer is active, so the+-- collector takes no per-path lock and creates no lock file.+-- 'findRoots' alone needs only the handle's shared lease: a listing+-- changes nothing another handle reads, and upstream's @--print-roots@+-- takes no collector lock either.+module Nix.Store.GC+ ( -- * Roots+ GcRoot (..),+ findRoots,+ addOutLinkRoot,+ gcRootsDir,+ autoRootsDir,+ gcRootsDirName,+ autoRootsDirName,+ indirectRootRecordName,++ -- * Reachability (pure)+ referencesMap,+ reachableFrom,++ -- * The live set+ LiveSet,+ withLiveSet,+ isLive,++ -- * Collection+ GcResults (..),+ collectGarbage,+ SweepPlan (..),+ sweepPlan,+ showFreedBytes,+ gcSummaryLine,++ -- * Messages+ findingRootsMessage,+ stillAliveMessage,+ )+where++import Control.Exception (IOException, catch, throwIO, try)+import Control.Monad (when)+import qualified Data.ByteString as BS+import Data.List (isPrefixOf, sort)+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Maybe (catMaybes, isJust, listToMaybe, mapMaybe)+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 GHC.IO.Exception (IOErrorType (InappropriateType))+import Nix.Eval.CanonPath (canonPath)+import Nix.Hash (sha256Digest)+import Nix.Store.DB (UnregisterResult (..), isValidPath, metaDirName, queryAllReferences, queryAllValidPaths, unregisterPathRows)+import Nix.Store.Handle (Store (..), withCollectorLock)+import Nix.Store.Lock (lockedPathOf)+import Nix.Store.Path (StoreDir (..), StorePath, defaultStoreDir, parseStorePathBaseName, parseStorePathPrefix, platformStoreDir, storePathToFilePath)+import Nix.Store.Symlink (WalkNode (..), classifyWalkNode, createSymlinkOfKind)+import qualified NovaCache.Base32 as Base32+import System.Directory (createDirectoryIfMissing, getFileSize, getSymbolicLinkTarget, listDirectory, makeAbsolute, removePathForcibly, renamePath)+import System.FilePath (normalise, splitDirectories, takeDirectory, takeFileName, (</>))+import System.IO (hPutStrLn, stderr)+import System.IO.Error (ioeGetErrorType, isDoesNotExistError, isPermissionError)+import System.OsPath (encodeFS)+import Text.Printf (printf)++-- ---------------------------------------------------------------------------+-- Layout+-- ---------------------------------------------------------------------------++-- | Upstream's name for the roots directory.+gcRootsDirName :: FilePath+gcRootsDirName = "gcroots"++-- | Upstream's name for the indirect-root records under 'gcRootsDirName'.+autoRootsDirName :: FilePath+autoRootsDirName = "auto"++-- | The roots directory of a store, under its metadata directory.+gcRootsDir :: StoreDir -> FilePath+gcRootsDir dir = unStoreDir dir </> metaDirName </> gcRootsDirName++-- | The indirect-root records directory of a store.+autoRootsDir :: StoreDir -> FilePath+autoRootsDir dir = gcRootsDir dir </> autoRootsDirName++-- | The record name for an out-link: nix-base32 of the SHA-256 of the+-- link's absolute spelling, so one out-link has one record however+-- often it is re-registered. Upstream hashes with SHA-1; the digest is+-- an internal key and the store has no SHA-1 primitive to spend on it.+indirectRootRecordName :: FilePath -> FilePath+indirectRootRecordName link = T.unpack (Base32.encode (sha256Digest (TE.encodeUtf8 (T.pack link))))++-- | Where a record is written before it is renamed into @auto@: under+-- the metadata directory, which the roots walk never enters, so a+-- listing under the shared lease sees a record whole or not at all.+recordStagingPath :: StoreDir -> FilePath -> FilePath+recordStagingPath dir link = unStoreDir dir </> metaDirName </> indirectRootRecordName link <> stagingSuffix++-- | The suffix of a record still being written.+stagingSuffix :: FilePath+stagingSuffix = ".tmp"++-- ---------------------------------------------------------------------------+-- Messages+-- ---------------------------------------------------------------------------++-- | Upstream's first progress line of a collection or a checked delete.+findingRootsMessage :: String+findingRootsMessage = "finding garbage collector roots..."++-- | Upstream's progress line before the sweep.+deletingGarbageMessage :: String+deletingGarbageMessage = "deleting garbage..."++-- | Upstream's per-path line of the sweep.+deletingPathMessage :: Text -> String+deletingPathMessage path = "deleting '" <> T.unpack path <> "'"++-- | Upstream's line for an indirect-root record whose out-link is gone.+removingStaleLinkMessage :: FilePath -> FilePath -> String+removingStaleLinkMessage record outLink =+ "removing stale link from '" <> record <> "' to '" <> outLink <> "'"++-- | Upstream's line for a symlink root naming a path the database does+-- not hold, with the target as the link spells it.+skippingInvalidRootMessage :: FilePath -> FilePath -> String+skippingInvalidRootMessage link target =+ "skipping invalid root from '" <> link <> "' to '" <> target <> "'"++-- | Upstream's line for a roots-directory entry it cannot read.+unreadableRootMessage :: FilePath -> String+unreadableRootMessage path = "cannot read potential root '" <> path <> "'"++-- | Upstream's refusal to delete a path a root keeps alive. Upstream's+-- hint names @nix-store --query --roots@ and @--query --referrers@;+-- this tool has @store gc --print-roots@ for the first and lists+-- referrers in its own refusal for the second, so the hint names what+-- exists.+stillAliveMessage :: Text -> Text+stillAliveMessage path =+ "Cannot delete path '"+ <> path+ <> "' since it is still alive. To find out why, use: nova-nix store gc --print-roots"++-- | Upstream's refusal of an out-link inside the store, with this tool's+-- command in the parenthetical.+rootInsideStoreMessage :: FilePath -> Text+rootInsideStoreMessage link =+ "creating a garbage collector root ("+ <> T.pack link+ <> ") in the Nix store is forbidden (are you running nova-nix build inside the store?)"++-- | Upstream's refusal to clobber something that is not a store link.+alreadyExistsMessage :: FilePath -> Text+alreadyExistsMessage link = "cannot create symlink '" <> T.pack link <> "'; already exists"++-- | The refusal of a collection or a checked delete over a database+-- keyed under another spelling of the store directory.+storeSpellingMismatchMessage :: FilePath -> Text -> Text+storeSpellingMismatchMessage dir row =+ "the store was opened as '"+ <> T.pack dir+ <> "' but its database holds '"+ <> row+ <> "': the paths were registered under another spelling of the directory, and a collection would sweep every such path as dead"++-- ---------------------------------------------------------------------------+-- Roots+-- ---------------------------------------------------------------------------++-- | One root: the link (or record) that names it, for reporting, and the+-- store path it keeps, spelled as the database spells paths. Ordered+-- by link, then path: the order @--print-roots@ lists them in.+data GcRoot = GcRoot+ { grLink :: !FilePath,+ grPath :: !Text+ }+ deriving (Eq, Ord, Show)++-- | A root candidate before the validity check: where it was found, the+-- parsed path, and the target text to report if the database does not+-- hold the path. Upstream reports a symlink's invalid target with the+-- target as written and drops a regular file's silently, so only the+-- symlink forms carry a report.+data RootCandidate = RootCandidate+ { rcLink :: !FilePath,+ rcPath :: !StorePath,+ rcInvalidReport :: !(Maybe FilePath)+ }++-- | Every root the store has, valid paths only, each link once and+-- sorted as @--print-roots@ prints them. A symlink root naming a path+-- the database does not hold is reported and dropped, a regular-file+-- root naming one is dropped silently, both as upstream does. Stale+-- indirect-root records are removed on the way, and an entry that+-- cannot be read is reported and skipped. The handle's shared lease is+-- enough: a listing changes nothing another handle reads, and a record+-- is published by rename, so a concurrent 'addOutLinkRoot' is seen+-- whole or not at all. The collector runs this under the exclusive+-- lock as the first step of a collection.+findRoots :: Store -> IO [GcRoot]+findRoots store = do+ candidates <- walkRoots (stDir store) (gcRootsDir (stDir store))+ Set.toAscList . Set.fromList . catMaybes <$> mapM validOnly candidates+ where+ validOnly candidate = do+ valid <- isValidPath (stDB store) (rcPath candidate)+ if valid+ then pure (Just (GcRoot (rcLink candidate) (T.pack (storePathToFilePath (stDir store) (rcPath candidate)))))+ else do+ mapM_ (hPutStrLn stderr . skippingInvalidRootMessage (rcLink candidate)) (rcInvalidReport candidate)+ pure Nothing++-- | Walk the roots directory. A directory recurses; a symlink into the+-- store is a permanent root, and any other symlink is followed one hop+-- as upstream follows it; a regular file is an indirect-root record+-- under @auto@ and a basename-named permanent root elsewhere. An entry+-- that cannot be read (permission denied, vanished, not a directory+-- after all) is reported with upstream's line and contributes nothing;+-- any other failure propagates, as upstream's does.+walkRoots :: StoreDir -> FilePath -> IO [RootCandidate]+walkRoots storeDir path = walk `catch` unreadable+ where+ walk = do+ node <- classifyWalkNode path+ case node of+ WalkDirectory -> do+ entries <- listDirectory path+ concat <$> mapM (walkRoots storeDir . (path </>)) (sort entries)+ WalkSymlink -> do+ target <- getSymbolicLinkTarget path+ case parseStoreTarget storeDir target of+ Just sp -> pure [RootCandidate path sp (Just target)]+ Nothing -> followIndirect storeDir path (takeDirectory path </> target)+ WalkRegular+ | isUnderDir (autoRootsDir storeDir) path -> do+ recorded <- BS.readFile path+ followIndirect storeDir path (T.unpack (TE.decodeUtf8Lenient recorded))+ | otherwise ->+ pure [RootCandidate path sp Nothing | Just sp <- [parseStorePathBaseName (T.pack (takeFileName path))]]+ WalkAbsent -> pure []+ unreadable :: IOException -> IO [RootCandidate]+ unreadable e+ | isPermissionError e || isDoesNotExistError e || ioeGetErrorType e == InappropriateType = do+ hPutStrLn stderr (unreadableRootMessage path)+ pure []+ | otherwise = throwIO e++-- | The second hop of an indirect root: the out-link the record names.+-- A symlink into the store is the root (reported under the out-link's+-- own path, which is what the user created); an absent out-link makes+-- a record under @auto@ stale, and it is removed; anything else is not+-- a root.+followIndirect :: StoreDir -> FilePath -> FilePath -> IO [RootCandidate]+followIndirect storeDir record outLink = do+ node <- classifyWalkNode outLink+ case node of+ WalkSymlink -> do+ target <- getSymbolicLinkTarget outLink+ pure [RootCandidate outLink sp (Just target) | Just sp <- [parseStoreTarget storeDir target]]+ WalkAbsent+ | isUnderDir (autoRootsDir storeDir) record -> do+ hPutStrLn stderr (removingStaleLinkMessage record outLink)+ removePathForcibly record+ pure []+ _ -> pure []++-- | The store path a link target names: upstream's @toStorePath@ after+-- its @isInStore@, so a target that is a store path, a trailing-separator+-- spelling of one, or anything beneath one names that path. Tried under+-- the opened store, the platform store and the canonical store: the+-- three spellings a link written by this tool or by hand can carry.+parseStoreTarget :: StoreDir -> FilePath -> Maybe StorePath+parseStoreTarget storeDir target =+ listToMaybe (mapMaybe (`parseStorePathPrefix` targetText) [storeDir, platformStoreDir, defaultStoreDir])+ where+ targetText = T.pack target++-- | Whether a path is the directory or lies beneath it, by normalised+-- components: upstream's @isInDir@, without the separator games.+isUnderDir :: FilePath -> FilePath -> Bool+isUnderDir dir path = splitDirectories (normalise dir) `isPrefixOf` splitDirectories (normalise path)++-- | Register an out-link as an indirect root: upstream's @addPermRoot@.+-- The link's spelling is made absolute and collapsed without resolving+-- symlinks (upstream's @canonPath@), so a @..@ cannot spell a link into+-- the store past the refusal, and the store directory it is compared+-- with is the handle's canonical one. A link inside the store is+-- refused, as is clobbering anything that is not already a symlink into+-- the store; an existing store link is replaced. The link's parent+-- directories are created, as upstream's @makeSymlink@ creates them.+-- The link is created first and the record second, so a failed link+-- (Windows without Developer Mode) leaves no record naming it; the+-- record is written under the metadata directory and renamed into+-- @auto@, so a listing under the shared lease never reads a torn one,+-- and a record already holding the link is left alone, since its+-- content is a function of its name. Upstream replaces the link+-- atomically through a temporary name and a rename; a rename cannot+-- replace a directory symlink on Windows, so the old link is removed+-- and the new one created, one code path for every platform, and the+-- link is briefly absent rather than briefly two things.+addOutLinkRoot :: Store -> FilePath -> StorePath -> IO (Either Text FilePath)+addOutLinkRoot store rawLink sp = do+ link <- T.unpack . canonPath . T.pack <$> makeAbsolute rawLink+ if isUnderDir (unStoreDir (stDir store)) link+ then pure (Left (rootInsideStoreMessage link))+ else do+ node <- classifyWalkNode link+ replaceable <- case node of+ WalkAbsent -> pure True+ WalkSymlink -> isJust . parseStoreTarget (stDir store) <$> getSymbolicLinkTarget link+ _ -> pure False+ if not replaceable+ then pure (Left (alreadyExistsMessage link))+ else do+ when (node == WalkSymlink) (removePathForcibly link)+ createDirectoryIfMissing True (takeDirectory link)+ linkPath <- encodeFS link+ target <- encodeFS (storePathToFilePath (stDir store) sp)+ created <- createSymlinkOfKind linkPath target+ case created of+ Left err -> pure (Left err)+ Right () -> do+ publishRecord (stDir store) link+ pure (Right link)++-- | Write an out-link's record into @auto@ by rename, unless a record+-- with that content is already there.+publishRecord :: StoreDir -> FilePath -> IO ()+publishRecord dir link = do+ let record = autoRootsDir dir </> indirectRootRecordName link+ staging = recordStagingPath dir link+ content = TE.encodeUtf8 (T.pack link)+ createDirectoryIfMissing True (autoRootsDir dir)+ existing <- try (BS.readFile record) :: IO (Either IOException BS.ByteString)+ when (existing /= Right content) $ do+ BS.writeFile staging content+ renamePath staging record++-- ---------------------------------------------------------------------------+-- Reachability+-- ---------------------------------------------------------------------------++-- | The references map from the edge list 'queryAllReferences' returns.+referencesMap :: [(Text, Text)] -> Map Text [Text]+referencesMap edges = Map.fromListWith (flip (++)) [(referrer, [reference]) | (referrer, reference) <- edges]++-- | Every path reachable from the roots over the references map: the+-- roots themselves and, transitively, everything they reference. A+-- root or a reference the map does not know is still reachable (it is+-- named), just a dead end; cycles terminate on the visited set.+reachableFrom :: Map Text [Text] -> [Text] -> Set Text+reachableFrom refs = go Set.empty+ where+ go !seen [] = seen+ go !seen (path : pending)+ | Set.member path seen = go seen pending+ | otherwise = go (Set.insert path seen) (Map.findWithDefault [] path refs ++ pending)++-- ---------------------------------------------------------------------------+-- The live set+-- ---------------------------------------------------------------------------++-- | The live set at one moment under the exclusive collector lock: the+-- roots' closure over the references map. Only 'withLiveSet' makes+-- one, so a checked delete holding it holds the lock and a set the+-- delete can act on.+newtype LiveSet = LiveSet (Set Text)++-- | Whether the live set holds a path, spelled as the database spells it.+isLive :: LiveSet -> Text -> Bool+isLive (LiveSet live) path = Set.member path live++-- | Run a checked operation under the exclusive collector lock with the+-- live set as it stands, after upstream's roots line. Refused, with+-- the action not run, when the database holds a path outside the opened+-- directory ('validPathsUnder').+withLiveSet :: Store -> (LiveSet -> IO a) -> IO (Either Text a)+withLiveSet store act = withCollectorLock store $ do+ checked <- validPathsUnder store+ case checked of+ Left err -> pure (Left err)+ Right _ -> do+ hPutStrLn stderr findingRootsMessage+ live <- liveSetOf store+ Right <$> act live++-- | The roots' closure as it stands; the caller holds the collector lock.+liveSetOf :: Store -> IO LiveSet+liveSetOf store = do+ roots <- findRoots store+ refs <- referencesMap <$> queryAllReferences (stDB store)+ pure (LiveSet (reachableFrom refs (map grPath roots)))++-- | Every valid path as the database spells it, refused when one lies+-- outside the opened directory: the collector and the checked delete+-- compare row text with paths derived from the handle's directory, and+-- a row registered under another spelling of the directory would+-- compare as dead and be swept with its tree.+-- 'Nix.Store.Handle.openStore' canonicalises the spelling, so this fires+-- only for a store whose rows and directory genuinely disagree.+validPathsUnder :: Store -> IO (Either Text (Set Text))+validPathsUnder store = do+ valid <- queryAllValidPaths (stDB store)+ pure $ case filter (not . isUnderDir dir . T.unpack) valid of+ [] -> Right (Set.fromList valid)+ (stray : _) -> Left (storeSpellingMismatchMessage dir stray)+ where+ dir = unStoreDir (stDir store)++-- ---------------------------------------------------------------------------+-- Collection+-- ---------------------------------------------------------------------------++-- | What a collection removed: every store directory entry deleted,+-- which is what upstream announces and counts, and the bytes those+-- trees held. A dead row with no tree is unregistered without either.+data GcResults = GcResults+ { gcrDeleted :: ![Text],+ gcrBytesFreed :: !Integer+ }+ deriving (Eq, Show)++-- | What a sweep removes, each list sorted.+data SweepPlan = SweepPlan+ { -- | Every valid path outside the live set, to unregister in one+ -- transaction whether or not its tree exists.+ spDeadRows :: ![Text],+ -- | Every store directory entry to delete: a dead path's tree, a+ -- store-shaped tree with no row (a build interrupted before+ -- registration), junk, and the lock file of a path that is not+ -- live. The metadata directory, every live path and a live path's+ -- lock file stay.+ spTrees :: ![Text]+ }+ deriving (Eq, Show)++-- | The sweep plan over the valid set, the live set and the store+-- directory's entries. The lock-file test is the one+-- 'Nix.Store.deleteStorePathRaw' makes: a lock-shaped name with no row+-- is a lock file, since only the rows can tell one from a store object+-- that happens to be called @flake.lock@, so a dead row's tree goes+-- whatever its name, and an unregistered lock-shaped entry stays only+-- while the path it guards is live.+sweepPlan :: StoreDir -> Set Text -> Set Text -> [Text] -> SweepPlan+sweepPlan storeDir valid live entries =+ SweepPlan+ { spDeadRows = Set.toAscList (Set.difference valid live),+ spTrees = Set.toAscList (Set.fromList [fullPath entry | entry <- entries, swept entry])+ }+ where+ fullPath entry = T.pack (unStoreDir storeDir </> T.unpack entry)+ swept entry =+ entry /= T.pack metaDirName+ && not (Set.member (fullPath entry) live)+ && (Set.member (fullPath entry) valid || not (guardsLivePath entry))+ guardsLivePath entry = maybe False (\guarded -> Set.member (fullPath guarded) live) (lockedPathOf entry)++-- | Collect garbage: find the roots, walk the references, remove every+-- dead row in one transaction, then every dead or unregistered tree.+-- Holds the collector lock throughout, and is refused over a database+-- keyed under another spelling of the directory ('validPathsUnder').+-- The progress lines are upstream's, on stderr; the summary is the+-- caller's to print ('gcSummaryLine').+collectGarbage :: Store -> IO (Either Text GcResults)+collectGarbage store = withCollectorLock store $ do+ checked <- validPathsUnder store+ case checked of+ Left err -> pure (Left err)+ Right valid -> Right <$> sweep valid+ where+ sweep valid = do+ hPutStrLn stderr findingRootsMessage+ LiveSet live <- liveSetOf store+ entries <- map T.pack <$> listDirectory (unStoreDir (stDir store))+ let plan = sweepPlan (stDir store) valid live entries+ hPutStrLn stderr deletingGarbageMessage+ unregistered <- unregisterPathRows (stDB store) (spDeadRows plan)+ case unregistered of+ RowReferenced referrers ->+ -- Unreachable under the exclusive lock: a referrer outside the+ -- dead set would have made its reference live. Loud, not silent.+ throwIO (userError ("collectGarbage: live paths reference dead ones: " <> show referrers))+ _ -> pure ()+ freed <- foldlIO 0 (spTrees plan) deleteTree+ pure GcResults {gcrDeleted = spTrees plan, gcrBytesFreed = freed}+ deleteTree !acc path = do+ hPutStrLn stderr (deletingPathMessage path)+ let target = T.unpack path+ node <- classifyWalkNode target+ case node of+ WalkAbsent -> pure acc+ _ -> do+ bytes <- treeBytes target+ removePathForcibly target+ pure (acc + bytes)++-- | The bytes a tree holds, links as leaves: a regular file's size, a+-- symlink's target length (upstream counts a link's @st_size@, which is+-- that), nothing for a directory itself. Upstream skips files with+-- three or more hard links as shared with its @.links@ optimisation;+-- this store has no such optimisation, so every file counts.+treeBytes :: FilePath -> IO Integer+treeBytes path = do+ node <- classifyWalkNode path+ case node of+ WalkRegular -> getFileSize path+ WalkSymlink -> fromIntegral . BS.length . TE.encodeUtf8 . T.pack <$> getSymbolicLinkTarget path+ WalkDirectory -> do+ entries <- listDirectory path+ foldlIO 0 entries (\acc entry -> (acc +) <$> treeBytes (path </> entry))+ WalkAbsent -> pure 0++-- | Strict left fold over a list in IO: the accumulator is forced each+-- step so a sweep over a large store builds no thunk chain.+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++-- | Upstream's @showBytes@ at 2.24.9: mebibytes to two places.+showFreedBytes :: Integer -> Text+showFreedBytes bytes = T.pack (printf "%.2f MiB" (fromIntegral bytes / (1024 * 1024) :: Double))++-- | Upstream's summary line (@PrintFreed@), plural as it prints it+-- whatever the count.+gcSummaryLine :: GcResults -> Text+gcSummaryLine results =+ T.pack (show (length (gcrDeleted results)))+ <> " store paths deleted, "+ <> showFreedBytes (gcrBytesFreed results)+ <> " freed"
+ src/Nix/Store/Handle.hs view
@@ -0,0 +1,138 @@+-- | The open-store handle: the store directory, its database, and the+-- handle's lease on the store-wide garbage-collector lock.+--+-- == The lease+--+-- Every open 'Store' holds @.nova-nix\/gc.lock@ shared from 'openStore'+-- to 'closeStore'. A collector needs that lock exclusively, so a+-- collection runs only while no other process has the store open, and a+-- process opening the store while one runs waits for it to finish.+-- 'withCollectorLock' is the one place a handle trades its shared lease+-- for the exclusive lock and back; "Nix.Store.GC" states why this is the+-- protocol and what the per-path locks cannot express.+--+-- The lease is this handle's own descriptor, and the OS treats each+-- descriptor as a separate holder: a process that opens two handles on+-- one store directory and collects through either waits forever on its+-- own other lease. One handle per store per process is the rule.+--+-- == The directory's spelling+--+-- The database keys every row by the store directory's spelling+-- ("Nix.Store.DB"), so 'openStore' keys the handle by one canonical+-- spelling ('canonicalStoreDir') whatever the caller typed: relative or+-- absolute, with or without a trailing separator, through @.@ and+-- @..@. Symlinks are left unresolved, as upstream's @absPath@ leaves+-- them on a @--store@ path: a store that has always been opened through+-- a symlinked spelling has its rows keyed by that spelling, and+-- resolving it would make every one of them foreign.+module Nix.Store.Handle+ ( -- * Handle+ Store (..),+ openStore,+ closeStore,+ canonicalStoreDir,++ -- * The collector lock+ withCollectorLock,+ gcLockFileName,+ gcLockFilePath,+ )+where++import Control.Concurrent.MVar (MVar, newMVar, putMVar, takeMVar, withMVar)+import Control.Exception (SomeException, mask, onException, throwIO, try)+import qualified Data.Text as T+import Nix.Eval.CanonPath (canonPath)+import Nix.Store.CaseSensitive (CaseSensitivity, probeCaseSensitivity)+import Nix.Store.DB (StoreDB, closeStoreDB, metaDirName, openStoreDB)+import Nix.Store.Lock (LockMode (..), PathLock, acquireLockFileWith, releasePathLock, withLockFileWith)+import Nix.Store.Path (StoreDir (..))+import System.Directory (createDirectoryIfMissing, makeAbsolute)+import System.FilePath ((</>))++-- | An open store with database and configuration. 'stDir' is the+-- canonical spelling ('canonicalStoreDir'); 'stGcLease' is the handle's+-- shared hold on the collector lock, swapped, never released early, by+-- 'withCollectorLock'.+data Store = Store+ { stDir :: !StoreDir,+ stDB :: !StoreDB,+ stGcLease :: !(MVar PathLock),+ -- | How the volume holding the store compares sibling names, probed+ -- at open ('probeCaseSensitivity') and consulted by every NAR+ -- materialization into the store.+ stCaseSensitivity :: !CaseSensitivity+ }++-- | Upstream's name for the store-wide garbage-collector lock file.+gcLockFileName :: FilePath+gcLockFileName = "gc.lock"++-- | The collector lock file, beside the database under the metadata+-- directory.+gcLockFilePath :: StoreDir -> FilePath+gcLockFilePath dir = unStoreDir dir </> metaDirName </> gcLockFileName++-- | The spelling a store directory is keyed by: absolute, with @.@ and+-- @..@ collapsed, separators normalised and no trailing separator,+-- symlinks unresolved. That is upstream's @absPath@ of a @--store@+-- path; the module header says why symlinks stay.+canonicalStoreDir :: StoreDir -> IO StoreDir+canonicalStoreDir (StoreDir dir) = StoreDir . T.unpack . canonPath . T.pack <$> makeAbsolute dir++-- | Open a Nix store at the given directory, keyed by its canonical+-- spelling. Creates the store directory and database if they don't+-- exist. The shared lease is taken before the database is touched, so+-- an opener arriving during a collection reads nothing until the+-- collection is over. It waits with the collector's own line: upstream's+-- opener never waits here (it hands its roots to the running collector+-- through a socket instead), so there is no upstream line for the wait,+-- and the collector's names what is being waited for.+openStore :: StoreDir -> IO Store+openStore rawDir = do+ dir <- canonicalStoreDir rawDir+ let metaDir = unStoreDir dir </> metaDirName+ lockPath = gcLockFilePath dir+ createDirectoryIfMissing True metaDir+ lease <- acquireLockFileWith LockShared bigGcLockMessage lockPath+ db <- openStoreDB dir+ -- After the directory exists: the probe answers for a path on disk,+ -- and a fresh store's directory sits on the volume it will live on.+ sensitivity <- probeCaseSensitivity (unStoreDir dir)+ leaseVar <- newMVar lease+ pure Store {stDir = dir, stDB = db, stGcLease = leaseVar, stCaseSensitivity = sensitivity}++-- | Close the store: the database first, then the lease, so the lock+-- outlives every write this handle made. Waits for an in-progress+-- 'withCollectorLock' on another thread rather than racing its swap.+closeStore :: Store -> IO ()+closeStore store = do+ closeStoreDB (stDB store)+ withMVar (stGcLease store) releasePathLock++-- | Upstream's announcement when the collector lock is busy.+bigGcLockMessage :: String+bigGcLockMessage = "waiting for the big garbage collector lock..."++-- | Run an action holding the collector lock exclusively. The handle's+-- shared lease is released first (the OS would otherwise count it as a+-- competing holder), the exclusive lock is taken, blocking with+-- upstream's message while other handles are open, and a fresh shared+-- lease is taken back before the handle is returned to use, on every+-- exit. Asynchronous exceptions are masked around the swap so the+-- handle is never left without a lease; the action itself runs+-- unmasked. If the renewal itself fails (the lock file cannot be+-- opened again), the surrendered lease goes back instead, so the handle+-- stays closable: 'closeStore' releases it as a no-op, and the failure+-- reaches the caller, whose handle holds no lease until it is closed.+withCollectorLock :: Store -> IO a -> IO a+withCollectorLock store act = mask $ \restore -> do+ lease <- takeMVar (stGcLease store)+ releasePathLock lease+ outcome <- try (restore (withLockFileWith LockExclusive bigGcLockMessage lockPath (const act)))+ renewed <- acquireLockFileWith LockShared bigGcLockMessage lockPath `onException` putMVar (stGcLease store) lease+ putMVar (stGcLease store) renewed+ either (\e -> throwIO (e :: SomeException)) pure outcome+ where+ lockPath = gcLockFilePath (stDir store)
src/Nix/Store/Lock.hs view
@@ -37,14 +37,20 @@ -- 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).+-- and having every acquirer re-check the file it locked. Here a holder+-- never deletes its lock file: 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 or built path left beside it in+-- the store directory; the files are inert debris, invisible to path+-- queries (only exact store-path basenames resolve). The one deleter+-- is the collector ("Nix.Store.GC"), which removes a lock file once the+-- path it guards is no longer live, and may: it runs under the+-- exclusive store-wide lock, when no process has the store open, and+-- every per-path lock in this tool is taken by a process holding a+-- store handle, so no holder or waiter exists for the deleted file to+-- strand. module Nix.Store.Lock ( -- * Held locks PathLock,@@ -54,16 +60,25 @@ withPathLock, withLockFile, + -- * Directly named lock files+ LockMode (..),+ acquireLockFileWith,+ withLockFileWith,+ waitingForLockMessage,+ -- * Naming pathLockFilePath, lockFileSuffix,+ lockedPathOf, ) 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 Data.Text (Text)+import qualified Data.Text as T+import Nix.Store.Path (StoreDir, StorePath, parseStorePathBaseName, storePathToFilePath)+import System.FileLock (FileLock, SharedExclusive (..), lockFile, tryLockFile, unlockFile) import System.IO (hPutStrLn, stderr) -- | An exclusive lock held on one store path: the lock file's path and@@ -89,6 +104,17 @@ pathLockFilePath :: StoreDir -> StorePath -> FilePath pathLockFilePath dir sp = storePathToFilePath dir sp <> lockFileSuffix +-- | The store path basename a lock-shaped name would guard: the name+-- 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, since @flake.lock@ is a legal+-- store-path name; the delete and the collector make that call.+lockedPathOf :: Text -> Maybe Text+lockedPathOf basename = do+ stripped <- T.stripSuffix (T.pack lockFileSuffix) basename+ _ <- parseStorePathBaseName stripped+ pure stripped+ -- | 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@@ -104,16 +130,36 @@ -- 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+acquireLockFile lockPath = acquireLockFileWith LockExclusive (waitingForLockMessage lockPath) lockPath++-- | How a lock file is held. Every per-path lock is exclusive; the+-- store-wide garbage-collector lock is the one shared holder, taken+-- 'LockShared' by every open store handle and 'LockExclusive' by a+-- collector (see "Nix.Store.GC").+data LockMode = LockShared | LockExclusive+ deriving (Eq, Show)++-- | Take a directly named lock file in the given mode, blocking until+-- granted. The message is printed once, to stderr, when the lock is+-- busy and the wait begins: the collector announces itself in+-- upstream's words and a path lock in its own, so the caller supplies+-- the line.+acquireLockFileWith :: LockMode -> String -> FilePath -> IO PathLock+acquireLockFileWith mode busyMessage lockPath = do+ probe <- tryLockFile lockPath (toSharedExclusive mode) held <- case probe of Just granted -> pure granted Nothing -> do- hPutStrLn stderr (waitingForLockMessage lockPath)- lockFile lockPath Exclusive+ hPutStrLn stderr busyMessage+ lockFile lockPath (toSharedExclusive mode) heldRef <- newMVar (Just held) pure (PathLock lockPath heldRef) +-- | The filelock spelling of a 'LockMode'.+toSharedExclusive :: LockMode -> SharedExclusive+toSharedExclusive LockShared = Shared+toSharedExclusive LockExclusive = Exclusive+ -- | Upstream's log line for a busy path lock. waitingForLockMessage :: FilePath -> String waitingForLockMessage lockPath = "waiting for lock on '" <> lockPath <> "'..."@@ -150,3 +196,8 @@ -- on every exit. See 'acquireLockFile' for why the raw form exists. withLockFile :: FilePath -> (PathLock -> IO a) -> IO a withLockFile lockPath = bracket (acquireLockFile lockPath) releasePathLock++-- | 'withLockFile' in a chosen mode with a chosen busy message.+withLockFileWith :: LockMode -> String -> FilePath -> (PathLock -> IO a) -> IO a+withLockFileWith mode busyMessage lockPath =+ bracket (acquireLockFileWith mode busyMessage lockPath) releasePathLock
src/Nix/Store/Path.hs view
@@ -45,7 +45,9 @@ isCanonicalStoreText, storeTextToFilePath, parseStorePath,+ parseStorePathPrefix, parseStorePathBaseName,+ storePathBaseNameError, -- * Name validation StorePathNameError (..),@@ -54,6 +56,7 @@ validStorePathName, storePathNameErrorText, storePathNameReasonText,+ storePathNameUpstreamText, -- * Constants storePathHashLen,@@ -154,6 +157,18 @@ Just sp -> Just sp Nothing -> tryWithSep "\\" +-- | The store path a path inside the store belongs to: upstream's+-- @toStorePath@, which cuts at the first separator after the store+-- directory. The path itself, a trailing-separator spelling of it, and+-- anything beneath it all name it; a path outside the store, or the+-- store directory itself, names nothing. Both separators are accepted+-- after the store directory, as 'parseStorePath' accepts them.+parseStorePathPrefix :: StoreDir -> Text -> Maybe StorePath+parseStorePathPrefix (StoreDir dir) path =+ case [rest | sep <- ["/", "\\"], Just rest <- [T.stripPrefix (T.pack dir <> sep) path]] of+ (rest : _) -> parseStorePathBaseName (T.takeWhile (\c -> c /= '/' && c /= '\\') rest)+ [] -> Nothing+ -- | 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.@@ -175,6 +190,21 @@ Just (StorePath hashPart name) _ -> Nothing +-- | Why upstream refuses a base name as a store path: the @BadStorePath@+-- text its @StorePath@ constructor throws (path.cc at 2.24.9), checking+-- the length, then the hash's alphabet, then the name. 'Nothing' when+-- none of those fails, which is every base name 'parseStorePathBaseName'+-- accepts. Upstream never looks at the character after the hash, so a+-- base name wrong only there is refused here and read by upstream.+storePathBaseNameError :: Text -> Maybe Text+storePathBaseNameError base+ | T.length base < storePathHashLen + 1 = Just ("'" <> base <> "' is too short to be a valid store path")+ | Just c <- T.find (not . isNixBase32Char) (T.take storePathHashLen base) =+ Just ("store path '" <> base <> "' contains illegal base-32 character '" <> T.singleton c <> "'")+ | Left err <- checkStorePathName (T.drop (storePathHashLen + 1) base) =+ Just ("path '" <> base <> "' is not a valid store path: " <> storePathNameUpstreamText err)+ | otherwise = 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@@ -268,6 +298,20 @@ <> " maximum" NameIllegalChar c -> "contains the illegal character " <> T.pack (show c) NameDotSegment seg -> "the first dash-separated component may not be '" <> seg <> "'"++-- | Upstream's own text for a rejected name, the message of the+-- @BadStorePathName@ its @checkName@ throws (path.cc at 2.24.9), which its+-- callers embed in theirs.+storePathNameUpstreamText :: StorePathNameError -> Text+storePathNameUpstreamText (StorePathNameError name reason) = case reason of+ NameEmpty -> "name must not be empty"+ NameTooLong _ -> quoted <> " must be no longer than " <> T.pack (show maxStorePathNameLen) <> " characters"+ NameIllegalChar c -> quoted <> " contains illegal character '" <> T.singleton c <> "'"+ NameDotSegment segment+ | segment == name -> quoted <> " is not valid"+ | otherwise -> quoted <> " is not valid: first dash-separated component must not be '" <> segment <> "'"+ where+ quoted = "name '" <> name <> "'" -- | Upstream's maximum store path name length. maxStorePathNameLen :: Int
+ src/Nix/Store/Symlink.hs view
@@ -0,0 +1,89 @@+{-# LANGUAGE ScopedTypeVariables #-}++-- | Symlink-aware filesystem primitives shared by the store walks.+--+-- Two things every walk over store content needs and nothing in+-- @directory@ gives directly: classifying a node WITHOUT following a+-- link, and creating a link whose Windows flavour (file or directory)+-- is read off the target. They live in a leaf module so the collector's+-- root walk and the out-link creation ("Nix.Store.GC") share them with+-- the materializers in "Nix.Store" without importing either.+module Nix.Store.Symlink+ ( -- * Classification+ WalkNode (..),+ classifyWalkNode,++ -- * Creation+ createSymlinkOfKind,+ )+where++import Control.Exception (IOException, catch, try)+import Data.Text (Text)+import qualified Data.Text as T+import Nix.Store.Exclusive (symlinkTakenMessage)+import qualified System.Directory as Dir+import qualified System.Directory.OsPath as OsDir+import System.IO.Error (isAlreadyExistsError)+import System.OsPath (OsPath, decodeFS, takeDirectory, (</>))++-- | A node kind for store walks, classified WITHOUT following symlinks:+-- the link test runs first because 'Dir.doesDirectoryExist' and+-- 'Dir.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+ deriving (Eq, Show)++-- | Classify one path for a store walk. The walks dispatch on this (or+-- 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 <- Dir.doesDirectoryExist path+ if isDir+ then pure WalkDirectory+ else do+ isFile <- Dir.doesFileExist path+ pure (if isFile then WalkRegular else WalkAbsent)++-- | Create one symlink, choosing the Windows flavour from the target's+-- kind: the target is resolved relative to the link's directory (an+-- absolute target resolves to itself) and probed once. A creation+-- failure is loud rather than approximated: writing the target text as a+-- regular file once registered a tree whose NAR hash differed from the+-- signed narinfo's, silent store corruption that a later push refused to+-- publish. A name already taken is refused in upstream's words, as a+-- folded sibling or a raced root meets it.+-- The parent directory is not created here; a caller that wants that+-- does it first. Both paths are 'OsPath's, so on POSIX the link and+-- its target are the bytes they were spelled from.+createSymlinkOfKind :: OsPath -> OsPath -> IO (Either Text ())+createSymlinkOfKind linkPath target = do+ targetIsDir <- OsDir.doesDirectoryExist (takeDirectory linkPath </> target)+ result <-+ try $+ if targetIsDir+ then OsDir.createDirectoryLink target linkPath+ else OsDir.createFileLink target linkPath+ case result of+ Right () -> pure (Right ())+ Left (e :: IOException) -> do+ shownLink <- decodeFS linkPath+ shownTarget <- decodeFS target+ pure . Left $+ if isAlreadyExistsError e+ then symlinkTakenMessage shownLink shownTarget+ else+ "cannot create symlink "+ <> T.pack shownLink+ <> " -> "+ <> T.pack shownTarget+ <> ": "+ <> T.pack (show e)+ <> " (on Windows this needs Developer Mode or elevation)"
src/Nix/Substituter.hs view
@@ -1,5 +1,3 @@-{-# LANGUAGE ScopedTypeVariables #-}- -- | Binary substituter - download pre-built paths from remote caches. -- -- == How substitution works@@ -19,7 +17,8 @@ -- 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.+-- If the cache doesn't have it (404, or another status upstream reads+-- as the file not being there), 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@@ -44,6 +43,7 @@ ( -- * Substitution SubstResult (..), trySubstitute,+ trySubstituteWith, -- * Cache configuration CacheConfig (..),@@ -53,10 +53,15 @@ AttemptFailure (..), attemptFailureMessage, catchSync,- httpStatusFailure, compressedBodyCeiling, downloadCapFor, + -- * Verified NAR bytes+ VerifiedNar,+ verifyNar,+ verifiedNarBytes,+ verifiedNarDigest,+ -- * Pure helpers (exported for testing) maxNarInfoBody, readBodyCapped,@@ -75,7 +80,6 @@ cappedBodySource, materializeNarFromSource, consumeNarStream,- unpackNarEntry, unpackAndVerify, clearStaleDestination, parseReferences,@@ -84,8 +88,7 @@ where import Control.Applicative ((<|>))-import Control.Concurrent (threadDelay)-import Control.Exception (Exception, SomeAsyncException (..), SomeException, catch, fromException, onException, throwIO)+import Control.Exception (Exception, Handler (..), SomeException, catch, catches, onException, throwIO) import Control.Monad (void) import qualified Data.ByteString as BS import Data.IORef (newIORef, readIORef, writeIORef)@@ -99,14 +102,14 @@ 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.Http (AttemptFailure (..), FetchRetryPolicy, RetryEffects, TransferError (..), attemptFailureMessage, catchSync, defaultFetchRetryPolicy, fetchStatusFailure, ioRetryEffects, retryTransient, statusError, transferFailureHandlers, withTransfer, withUserAgent)+import Nix.Store (CaseSensitivity, NarStreamFailure (..), PathLock, Store (..), abortNarUnpack, acquirePathLock, finishNarUnpack, isValid, newNarUnpackSink, releasePathLock, setReadOnly, sinkNarStream, unpackNarEntry, volumeCaseHack) 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@@ -184,9 +187,18 @@ -- 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'.+-- Every narinfo and NAR request runs under upstream's retry policy,+-- 'defaultFetchRetryPolicy'. trySubstitute :: Store -> [CacheConfig] -> StorePath -> IO SubstResult-trySubstitute _ [] _ = pure SubstNotFound-trySubstitute store caches sp = do+trySubstitute = trySubstituteWith defaultFetchRetryPolicy ioRetryEffects++-- | 'trySubstitute' under a given retry policy and effects: a build+-- passes the policy its @builtin:fetchurl@ downloads run under+-- ('Nix.Builder'), and a test passes effects that record rather than+-- sleep.+trySubstituteWith :: FetchRetryPolicy -> RetryEffects IO -> Store -> [CacheConfig] -> StorePath -> IO SubstResult+trySubstituteWith _ _ _ [] _ = pure SubstNotFound+trySubstituteWith policy effects 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@@ -200,7 +212,7 @@ releasePathLock lock pure SubstAlreadyValid else do- result <- tryCachesWith (\cache -> tryOneCache manager store cache sp lock) (sortCaches caches)+ result <- tryCachesWith (\cache -> tryOneCache (Retrying policy effects) manager store cache sp lock) (sortCaches caches) case result of SubstSuccess _ _ -> pure result other -> do@@ -227,24 +239,32 @@ SubstNotFound -> go firstErr rest SubstError err -> go (firstErr <|> Just (ccUrl cache <> ": " <> err)) rest +-- | The retry policy and its effects, carried to every request one+-- substitution makes.+data Retrying = Retrying !FetchRetryPolicy !(RetryEffects IO)++-- | Run one request's attempt under the carried policy.+retrying :: Retrying -> IO (Either AttemptFailure a) -> IO (Either AttemptFailure a)+retrying (Retrying policy effects) = retryTransient policy effects+ -- | 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+tryOneCache :: Retrying -> HTTP.Manager -> Store -> CacheConfig -> StorePath -> PathLock -> IO SubstResult+tryOneCache retry mgr store cache sp lock =+ substituteFromCache retry 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+substituteFromCache :: Retrying -> HTTP.Manager -> Store -> CacheConfig -> StorePath -> PathLock -> IO SubstResult+substituteFromCache retry mgr store cache sp lock = do -- 1. Fetch narinfo- narInfoResult <- fetchNarInfo mgr cache sp+ narInfoResult <- fetchNarInfo retry mgr cache sp case narInfoResult of Left notFoundOrErr -> pure notFoundOrErr Right narInfo@@ -266,7 +286,7 @@ -- pass ('streamNarIntoStore'). case narInfoPreflight cache narInfo of Left err -> pure (SubstError err)- Right () -> streamWithRetry mgr store cache sp narInfo lock+ Right () -> streamWithRetry retry 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@@ -317,48 +337,94 @@ 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.+-- | NAR bytes that passed 'verifyNar': they hash to the narinfo's+-- signed NarHash and count its signed NarSize. The constructor is not+-- exported, so 'verifyNar' is the only way to obtain one and+-- 'unpackVerifiedNar', the strict pipeline's unpack sink, cannot be+-- reached with bytes that skipped the check: verify-then-unpack is a+-- type, not a statement order. The decoded digest rides along because+-- registration records its canonical spelling and the on-disk recheck+-- compares against it, so neither re-parses the narinfo's NarHash.+-- Only the strict pipeline can carry this: the streaming pipeline+-- writes to disk before the hash can be known (upstream's ordering)+-- and relies on its cleanup contract instead+-- ('materializeNarFromSource').+data VerifiedNar = VerifiedNar !BS.ByteString !Hash.NixHash+ deriving (Eq, Show)++-- | The verified bytes.+verifiedNarBytes :: VerifiedNar -> BS.ByteString+verifiedNarBytes (VerifiedNar bytes _) = bytes++-- | The digest the verified bytes hash to, decoded from the narinfo's+-- NarHash.+verifiedNarDigest :: VerifiedNar -> Hash.NixHash+verifiedNarDigest (VerifiedNar _ digest) = digest++-- | The only constructor of a 'VerifiedNar': the hash check, then the+-- size check, with the decoded declared digest carried out. 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 is caught here, before the bytes are trusted.+verifyNar :: NarInfo.NarInfo -> BS.ByteString -> Either Text VerifiedNar+verifyNar narInfo rawNar = do+ declared <- verifyNarHash narInfo rawNar+ verifyNarSize narInfo rawNar+ pure (VerifiedNar rawNar declared)++-- | Verify the NAR hash and size, deserialize, unpack to the store, and+-- set permissions: 'verifyNar', then 'unpackVerifiedNar'. 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. 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 metadata is 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+ Right (verified, refs, deriver) -> unpackVerifiedNar store sp verified refs deriver where- unpackLocked declared refs deriver narEntry lock = do+ verifiedInputs = do+ verified <- verifyNar narInfo rawNar+ refs <- parseReferences (NarInfo.niReferences narInfo)+ deriver <- parseDeriver (stDir store) (NarInfo.niDeriver narInfo)+ pure (verified, refs, deriver)++-- | The strict pipeline's unpack sink: deserialize a 'VerifiedNar',+-- materialize it at the store path, seal it read-only, and recheck the+-- tree on disk against the carried digest before building its+-- registration from the references and deriver. 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.+unpackVerifiedNar :: Store -> StorePath -> VerifiedNar -> [StorePath] -> Maybe Text -> IO SubstResult+unpackVerifiedNar store sp verified refs deriver =+ case NAR.deserialise (verifiedNarBytes verified) of+ Left err -> pure (SubstError ("NAR deserialisation failed: " <> T.pack err))+ Right narEntry -> do+ lock <- acquirePathLock (stDir store) sp+ result <- unpackLocked narEntry lock `onException` releasePathLock lock+ case result of+ SubstSuccess _ _ -> pure result+ other -> do+ releasePathLock lock+ pure other+ where+ digest = verifiedNarDigest verified+ destPath = storePathToFilePath (stDir store) sp+ unpackLocked 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+ unpackNarEntry (stCaseSensitivity store) destPath narEntry ) `catchSync` (pure . Left) case (unpackResult :: Either SomeException (Either Text ())) of@@ -367,15 +433,15 @@ Right (Right ()) -> do setReadOnly destPath -- A path registered valid must match its recorded hash ON- -- DISK, not merely in the downloaded bytes: any divergence+ -- DISK, not merely in the verified 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+ onDisk <- ExecBit.serialiseFromPath (volumeCaseHack (stCaseSensitivity store)) destPath+ if Hash.hashBytes (NAR.serialise onDisk) /= digest+ then do Dir.removePathForcibly destPath pure ( SubstError@@ -383,7 +449,7 @@ <> T.pack destPath ) )- Right _ ->+ else pure $ SubstSuccess PathRegistration@@ -391,24 +457,15 @@ -- 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,+ prNarHash = Hash.formatNixHash digest,+ -- The verified actual byte count (equal to the+ -- declared NarSize per 'verifyNarSize') - no+ -- Integer conversion that could wrap.+ prNarSize = BS.length (verifiedNarBytes verified), 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@@ -467,13 +524,6 @@ -- 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.@@ -498,85 +548,84 @@ 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+-- | Fetch a narinfo from a cache, each request under the substitution's+-- retry policy ('trySubstituteWith'). Returns @Left SubstNotFound@ when+-- the cache does not hold it, @Left (SubstError msg)@ on any other+-- failure, a transient one once the policy has spent its attempts.+fetchNarInfo :: Retrying -> HTTP.Manager -> CacheConfig -> StorePath -> IO (Either SubstResult NarInfo.NarInfo)+fetchNarInfo retry mgr cache sp = do+ fetched <- retrying retry (fetchNarInfoOnce mgr url)+ pure $ case fetched of+ Left failure -> Left (SubstError (attemptFailureMessage failure))+ Right Nothing -> Left SubstNotFound+ Right (Just narInfo) -> Right narInfo+ where+ url = ccUrl cache <> "/" <> spHash sp <> ".narinfo" --- | Base delay between NAR download attempts, in microseconds. The delay grows--- linearly with each retry (0.5s, 1s, ...).-narRetryBaseDelayMicros :: Int-narRetryBaseDelayMicros = 500000+-- | One narinfo request: @Right Nothing@ when the cache does not hold+-- it. A 'NotFound' or 'Forbidden' answer (404, 410, 401, 403, 407) is+-- the cache saying so, not a failure to retry: upstream's transfer layer+-- retries neither, and its binary cache store reads both as a missing+-- narinfo, so the path is not valid there (@HttpBinaryCacheStore::getFile@+-- at 2.24.9). A narinfo that arrived whole and does not parse, or+-- overruns 'maxNarInfoBody', is the cache's bytes and fails at once.+fetchNarInfoOnce :: HTTP.Manager -> Text -> IO (Either AttemptFailure (Maybe NarInfo.NarInfo))+fetchNarInfoOnce mgr url = fetch `catches` transferFailureHandlers url+ where+ fetch = do+ request <- withUserAgent <$> HTTP.parseRequest (T.unpack url)+ withTransfer request mgr $ \response ->+ answer (HTTP.responseStatus response) (HTTP.responseBody response)+ answer status body+ | status /= HTTP.status200 = pure $ case statusError status of+ NotFound -> Right Nothing+ Forbidden -> Right Nothing+ Misc -> Left (fetchStatusFailure url status)+ Transient -> Left (fetchStatusFailure url status)+ | otherwise = do+ capped <- readBodyCapped maxNarInfoBody body+ pure $ case capped of+ Nothing ->+ Left (FatalFailure ("narinfo body exceeds " <> T.pack (show maxNarInfoBody) <> " bytes"))+ -- 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).+ Just bytes -> case NarInfo.parseNarInfo (TE.decodeUtf8Lenient bytes) of+ Left err -> Left (FatalFailure ("narinfo parse error: " <> T.pack err))+ Right narInfo -> Right (Just narInfo) --- | Stream one substitution end to end, retrying transient failures.+-- | Stream one substitution end to end, each attempt under the+-- substitution's retry policy ('trySubstituteWith'). -- -- 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+-- errors, torn or truncated transfers, a status upstream retries,+-- anything a fresh attempt could plausibly complete - consumes retry+-- budget with upstream's backoff, while a 'FatalFailure' - a completed+-- transfer that verifies wrong, a body past its signed ceiling, a+-- status upstream never retries, a write the disk refused - ends the+-- attempt at once: retrying a deterministic failure only delays the+-- local-build fallback. 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+-- failure path, exceptions included, removes what it wrote), so a retry+-- restarts the NAR from its first byte, where upstream resumes from the+-- written offset when the server accepts ranges and otherwise retries+-- only a transfer that wrote nothing yet. A NAR the narinfo named and+-- the cache answers 'NotFound' or 'Forbidden' for is gone: one request,+-- as upstream's @SubstituteGone@ (@BinaryCacheStore::narFromPath@ at+-- 2.24.9), and an error the cache scan falls through to the next cache+-- and then to a local build, as upstream's substitution goal treats a+-- gone substitute like one that never existed. A narinfo the cache does+-- not hold is a miss handled earlier in 'fetchNarInfo' and never reaches+-- here.+streamWithRetry :: Retrying -> HTTP.Manager -> Store -> CacheConfig -> StorePath -> NarInfo.NarInfo -> PathLock -> IO SubstResult+streamWithRetry retry mgr store cache sp narInfo lock = do+ outcome <- retrying retry (streamNarIntoStore mgr cache store sp narInfo)+ pure $ case outcome of+ Right registration -> SubstSuccess registration lock+ Left failure -> SubstError (attemptFailureMessage failure) -- | Thrown inside the streaming pipeline where a chunk convention has -- no error channel (the capped body source); converted back to the@@ -586,62 +635,19 @@ 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+-- | Convert what one download attempt throws into the pipeline's+-- failure channel, so the retry budget governs it and the caller's+-- cleanup contract holds on every exit: the transfer layer's+-- classification ('transferFailureHandlers'), and a 'StreamAbort'.+-- Nothing else is caught, so an interrupt is never spent as retry+-- budget.+tryAttempt :: Text -> IO (Either AttemptFailure a) -> IO (Either AttemptFailure a)+tryAttempt url action =+ action `catches` (Handler streamAborted : transferFailureHandlers url) where- message = "NAR download failed: HTTP " <> T.pack (show code)+ -- A body past its ceiling: the cap derives from the signed NarSize,+ -- so a longer body is the server misdeclaring, not a hiccup.+ streamAborted (StreamAbort msg) = pure (Left (FatalFailure msg)) -- | One streaming substitution attempt: download, decompress, hash, -- parse, and materialize in a single bounded pass, then verify.@@ -661,16 +667,17 @@ 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)+ narUrl = ccUrl cache <> "/" <> 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+ tryAttempt narUrl $ do+ request <- withUserAgent <$> HTTP.parseRequest (T.unpack narUrl)+ withTransfer request mgr $ \response -> do+ let status = HTTP.responseStatus response+ if status /= HTTP.status200+ then pure (Left (fetchStatusFailure narUrl status))+ 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@@ -732,7 +739,7 @@ materialize = do streamed <- withDecompressedSource (NarInfo.niNarSize narInfo) (NarInfo.niCompression narInfo) source $- consumeNarStream destPath narInfo declaredDigest+ consumeNarStream (stCaseSensitivity store) destPath narInfo declaredDigest case streamed of Left err -> do Dir.removePathForcibly destPath@@ -749,7 +756,7 @@ -- 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+ onDiskDigest <- ExecBit.narHashOfPath (volumeCaseHack (stCaseSensitivity store)) destPath if onDiskDigest /= declaredDigest then do Dir.removePathForcibly destPath@@ -772,68 +779,51 @@ -- 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)+-- the tree was built from. The sensitivity is the destination+-- volume's, 'stCaseSensitivity' when the destination is a store path.+consumeNarStream :: CaseSensitivity -> FilePath -> NarInfo.NarInfo -> Hash.NixHash -> IO BS.ByteString -> IO (Either AttemptFailure Int)+consumeNarStream sensitivity destPath narInfo declaredDigest narSource = do+ sink <- newNarUnpackSink sensitivity destPath+ streamed <- sinkNarStream sink narSource+ case streamed of+ -- A name or shape the store refuses is a property of the archive,+ -- not of this transfer.+ Left (NarStreamRefused err) -> pure (Left (FatalFailure err))+ -- A truncated body and a torn transfer parse-fail the same way, so+ -- the retry budget applies.+ Left (NarStreamMalformed msg) -> pure (Left (TransientFailure ("NAR stream parse failed: " <> msg)))+ Right (digest, narBytes)+ | toInteger narBytes /= NarInfo.niNarSize narInfo -> 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)+ )+ )+ )+ | digest /= declaredDigest -> 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+ )+ )+ )+ | otherwise -> do+ finished <- finishNarUnpack sink+ pure (either (Left . FatalFailure) (const (Right narBytes)) finished) -- | Stream an HTTP body as a chunk source bounded by the download cap -- 'downloadCapFor' derived from the signed NarSize -@@ -1080,11 +1070,8 @@ -- '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+-- lock ('trySubstitute', 'unpackVerifiedNar'): 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
@@ -1,91 +0,0 @@-{-# 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/HttpFixture.hs view
@@ -0,0 +1,119 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Scoped loopback HTTP servers for the download tests: the mirrors+-- @builtin:fetchurl@ tries, and the binary cache the substituter reads.+module HttpFixture+ ( Reply (..),+ httpResponse,+ withHttpServer,+ 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++-- | How the server answers one request.+data Reply+ = -- | These bytes, a whole response, then a close.+ Send !BS.ByteString+ | -- | The headers of a body that outlives the cancellation watchdog,+ -- then that body a byte at a time; fixture cleanup stops it.+ Trickle++-- | Loopback HTTP only: no external services, fixed ports, or subprocesses.+-- The route answers each request from its target and the number of times+-- that target was requested before. The callback gets the base URL,+-- request history, and a first-request signal. Socket and server-thread+-- lifetimes are scoped to the callback.+withHttpServer :: (BS.ByteString -> Int -> Reply) -> (Text -> IO [BS.ByteString] -> IO () -> IO a) -> IO a+withHttpServer route 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"+ earlier <- atomicModifyIORef' requests (\seen -> (path : seen, length (filter (== path) seen)))+ case route path earlier of+ Trickle -> 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+ Send bytes -> do+ void (tryPutMVar requested ())+ Socket.sendAll client bytes+ 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)++-- | The mirrors the fetchurl tests name, answering the same way however+-- often they are asked.+withFetchurlServer :: (Text -> IO [BS.ByteString] -> IO () -> IO a) -> IO a+withFetchurlServer = withHttpServer (\path _ -> fetchurlReply path)+ where+ fetchurlReply path = case path of+ "/slow" -> Trickle+ "/missing" -> Send (httpResponse "404 Not Found" "missing")+ "/error" -> Send (httpResponse "500 Internal Server Error" "error")+ "/partial" -> Send "HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\nshort"+ "/bad" -> Send (httpResponse "200 OK" "wrong and longer than the expected output")+ "/good" -> Send (httpResponse "200 OK" "hello")+ _ -> Send (httpResponse "500 Internal Server Error" "unexpected request")++-- | A whole response: the status line's code and reason, then the body.+httpResponse :: BS.ByteString -> BS.ByteString -> BS.ByteString+httpResponse 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,11223 +1,16560 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE PatternSynonyms #-} {-# 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)- failed = total - passed- putStrLn $ "\n" ++ show passed ++ "/" ++ show total ++ " passed"- if failed > 0- then do- putStrLn $ show failed ++ " FAILED"- exitFailure- else do- putStrLn "All tests passed."- exitSuccess+{-# LANGUAGE TupleSections #-}++-- | 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 (asyncThreadId, cancel, waitCatch, withAsync)+import Control.Exception (AsyncException (..), ErrorCall (..), IOException, SomeAsyncException (..), SomeException, asyncExceptionToException, bracket, bracket_, catches, displayException, evaluate, finally, fromException, throw, throwIO, throwTo, toException, try)+import Control.Monad (filterM, unless, void, when)+import Data.Bits (shiftR, xor, (.&.))+import qualified Data.ByteString as BS+import qualified Data.ByteString.Builder as BB+import qualified Data.ByteString.Char8 as BS8+import qualified Data.ByteString.Lazy as BL+import qualified Data.ByteString.Unsafe as BSU+import Data.Char (isDigit)+import Data.Functor.Identity (Identity (..))+import Data.IORef (atomicModifyIORef', newIORef, readIORef)+import Data.Int (Int64)+import Data.List (intercalate, isPrefixOf, sort)+import Data.List.NonEmpty (NonEmpty (..))+import qualified Data.Map.Strict as Map+import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, 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 Data.Word (Word32, Word64, Word8)+import qualified Database.SQLite.Simple as SQL+import Foreign.C.Error (Errno (..), eACCES, eISDIR, eNOENT, eNOTDIR)+import Foreign.C.Types (CChar)+import Foreign.Marshal.Alloc (alloca)+import Foreign.Ptr (Ptr, castPtr, nullPtr)+import Foreign.StablePtr (StablePtr, castPtrToStablePtr, castStablePtrToPtr, deRefStablePtr, freeStablePtr, newStablePtr)+import Foreign.Storable (poke, sizeOf)+import qualified GHC.Foreign as GHCForeign+import GHC.IO.Encoding (getFileSystemEncoding, mkTextEncoding, setFileSystemEncoding)+import GHC.IO.Exception (IOErrorType (..), IOException (..))+import HttpFixture (Reply (..), httpResponse, withFetchurlServer, withHttpServer)+import qualified Network.HTTP.Client as HTTP+import qualified Network.HTTP.Types.Status as HTTP+import Nix.Builder (BuildConfig (..), BuildResult (..), BuilderSpawn (..), buildDerivation, buildPath, buildWithDeps, defaultBuildConfig, execWrapperConfig, execWrapperFor, fetchUrlsFromEnv, rewriteEnv, rewritePlaceholders, scrubAmbient, spawnFor, tryFetchUrlsWith, unionEnvs, verifyFetchHash)+import Nix.Builder.Unpack (UnpackLimits (..), builtinUnpackBuilder, defaultUnpackLimits, entryComponents, envSrcs, resolveLinkTarget, runBuiltinUnpack)+import Nix.Builtins (builtinEnv, isNixPathPseudoUrl, parseNixPath, rootScopeNames, splitNixPath)+import Nix.Config (NixConfig (..))+import qualified Nix.Config as Config+import qualified Nix.DependencyGraph as DepGraph+import Nix.Derivation (Derivation (..), DerivationOutput (..), Platform (..), currentPlatform, extraPlatforms, fromATerm, platformToText, textToPlatform, toATerm, toATermForHash)+import Nix.Derivation.StructuredAttrs (StructuredAttrs (..), decodeStructuredAttrs, encodeStructuredAttrs, renderAttrsShell, stringsAttr, withOutputPlaceholders)+import Nix.Eval (EvalPolicy (..), FetchCache (..), FetchGitArgs (..), MonadEval (..), NixValue (..), StringContext (..), StringContextElement (..), Thunk (..), attrSetFromMap, attrSetLookup, attrSetNull, attrSetSize, builtinNames, checkGitRef, checkGitRev, checkGitUrl, decodeFetchCache, emptyContext, emptyEnv, encodeFetchCache, eval, evaluated, fetchCacheKey, force, gitInputUrl, mkStr, readThunkValue, runPureEval, typeName, unrestrictedPolicy)+import Nix.Eval.Arena (CStatusError (..), arenaDestroy, arenaInit, arenaLive)+import Nix.Eval.AttrPath (parseAttrPath, selectAttrPath)+import Nix.Eval.CAttrSet (cattrsetFreeze, cattrsetIndex, cattrsetInsert, cattrsetKeys, cattrsetLookup, cattrsetNew, cattrsetSize, cattrsetUnion)+import Nix.Eval.CBytecode (appDeferred, attrkeyStatic, binaryAdd, captureSlots, captureWithScopes, cbcArg1, cbcArg2, cbcArg3, cbcData, cbcDataCount, cbcFlags, cbcOpCount, cbcOpcode, cbcShortArg, formalName, formalNamedSet, formalSet, reservedApply1, reservedApply2, 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.CEnv (cenvLazyScope)+import Nix.Eval.CList (clistDrop, clistFromThunks, clistIndex, clistLen, clistNew, clistThunks)+import Nix.Eval.CThunk (CThunkPtr, cthunkBool, cthunkCount, cthunkGet, cthunkGetBcIdx, cthunkGetBool, cthunkMarkBlackhole, cthunkNewBc, cthunkNewComputed, cthunkNewComputedInt, cthunkNull, cthunkPayload, cthunkSetComputed, cthunkSetPayload, cthunkState, cthunkValueTag)+import Nix.Eval.CallDepth (topLevelCallDepth)+import Nix.Eval.CanonPath (canonPath, canonPathValue)+import Nix.Eval.Compile (BcBinding (..), BcInheritedName (..), compileExpr, decodeBcBindings)+import qualified Nix.Eval.Context as Context+import Nix.Eval.IO (EvalFailure (..), EvalState (..), allowEvalPath, newEvalState, renderEvalFailure, runEvalIO, runEvalIOTraced)+import Nix.Eval.Policy (allowPathIn, forbiddenPathMessage, isAllowedPath, isAllowedUri, joinComponents, noAllowedPaths, pathComponents, uriAccess)+import Nix.Eval.Print (PrintOptions (..), printAmbiguous, printValue)+import Nix.Eval.SourceError (SourceAccess (..), sourceErrorMessage)+import Nix.Eval.StringInterp (joinCoercedList)+import Nix.Eval.Symbol (Symbol (..), symbolCount, symbolIntern, symbolInternBytes, symbolLen, symbolText)+import Nix.Eval.Types (AttrSet (..), Env (..), allocCSlots, attrSetKeys, buildCSlots, emptyCList, pattern ThunkComputed, pattern ValueBool, pattern ValueNull)+import Nix.Expr.Resolve (impureOnlyGlobalNames, staticGlobalNames, undefinedVariableMessage)+import Nix.Expr.Types+import Nix.Hash (compressHash, hashPlaceholder, makeFixedOutputPath, makeTextPath, sha256Digest)+import qualified Nix.Hash as Hash+import Nix.HostPath (hostPathFromBytes)+import Nix.Http (FetchRetryPolicy (..), RetryEffects (..), TransferError (..), defaultFetchRetryPolicy, fetchExceptionFailure, fetchStatusFailure, retryDelayMs, retryTransient, statusError, transferBodyReader, transferFailureHandlers, userAgent, withUserAgent)+import Nix.Json (Json (..), parseJson, renderJson)+import Nix.Parser (ParseError (..), SourceError (..), parseNix, parseNixWithScope)+import Nix.Parser.Lexer (Located (..), Token (..), tokenize)+import Nix.Push (PushArtifact (..), PushCompression (..), PushConfig (..), checkRecordedNarHash, computeClosure, isDerivationPath, loadApiKeyFile, mkNarInfo, mkPushArtifact, narFileName, narHashMatches, outputPathsOnly, parsePushCompression, planMissing, pushPaths, storePathBasename, stripHashPrefix)+import Nix.Store (DeleteOutcome (..), GcResults (..), GcRoot (..), Store (..), SweepPlan (..), acquirePathLock, addOutLinkRoot, addToStore, autoRootsDir, caseHackDiskNames, closeStore, collectGarbage, copyPathInto, deleteStorePathRaw, findRoots, gcLockFilePath, gcRootsDir, gcSummaryLine, indirectRootRecordName, isValid, materializeEvalSources, materializeEvalStoreWrites, onDiskNameKey, openStore, orderLinks, pathExists, reachableFrom, referencesMap, registrationFor, releasePathLock, resolveDeleteTarget, scanReferences, scanTempReferences, setReadOnly, showFreedBytes, sweepPlan, tryAcquirePathLock, unpackNarEntry, withCollectorLock, writeDrv, writeDrvClosure)+import Nix.Store.CaseSensitive (CaseSensitivity (..), probeCaseSensitivity, processCaseHack, trySetCaseSensitiveDir, volumeCaseHack)+import Nix.Store.DB (PathInfo (..), PathRegistration (..), closeStoreDB, dbFileName, isValidPath, metaDirName, openStoreDB, queryAllValidPaths, queryDeriver, queryPathInfo, queryReferences, registerPath, registerPaths)+import Nix.Store.EntryName (NameRules (..), checkEntryName, checkLinkTarget)+import qualified Nix.Store.ExecBit as ExecBit+import Nix.Store.Path (StoreDir (..), StorePath, StoreWriteMode (..), defaultStoreDir, defaultStoreDirText, isCanonicalStoreText, parseStorePath, parseStorePathPrefix, 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 (getArgs, getEnvironment, getExecutablePath, lookupEnv, setEnv, unsetEnv)+import qualified System.Environment.Blank as Blank+import System.Exit (ExitCode (..), exitFailure, exitSuccess, exitWith)+import System.FilePath (dropDrive, joinPath, makeRelative, pathSeparator, searchPathSeparator, splitDirectories, takeDirectory, takeDrive, takeFileName, (</>))+import System.IO (BufferMode (..), IOMode (..), hPutStrLn, hSetBuffering, stderr, stdout, withBinaryFile)+import System.IO.Error (ioeGetErrorString, mkIOError, resourceVanishedErrorType)+import System.IO.Unsafe (unsafePerformIO)+import qualified System.Info as SI+import System.OsPath (OsPath)+import qualified System.OsPath as OP+import qualified System.Process as Proc+import System.Timeout (timeout)+import qualified Test.QuickCheck as QC++-- ---------------------------------------------------------------------------+-- 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)++-- | The first failure of two results, or 'Pass' when both pass.+andThen :: TestResult -> TestResult -> TestResult+andThen first second = case first of+ Pass -> second+ failed -> failed++-- | 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 = assertParseIn []++-- | 'assertParse' for source whose free variables are bound around it, the+-- way @scopedImport@ binds its scope: each stays a name-based 'EVar'.+assertParseIn :: [Text] -> Text -> Text -> Expr -> TestResult+assertParseIn names label source expected =+ assertRight label (parseNixWithScope (withGlobals names) testBaseDir "<test>" source) $ \actual ->+ assertEqual label expected actual++-- | The root environment's names with more bound around them.+withGlobals :: [Text] -> Set.Set Text+withGlobals names = Set.union (Set.fromList names) staticGlobalNames++-- | 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 (sourceFailure err)+ Right expr -> runPureEval (eval (builtinEnv unrestrictedPolicy 0 []) expr)++-- | Prefix marking a parse (not eval) failure in 'evalNix' results.+parseErrorTag :: Text+parseErrorTag = "parse error: "++-- | A source failure as an 'evalNix' result: a syntax error carries+-- 'parseErrorTag'; an unbound variable reads as the evaluation error+-- upstream raises for it, before evaluating anything.+sourceFailure :: SourceError -> Text+sourceFailure (SyntaxError err) = parseErrorTag <> T.pack (show err)+sourceFailure (UndefinedVariable name) = undefinedVariableMessage name++-- | The syntax error a source fails to parse with.+syntaxErrorOf :: Text -> Either Text ParseError+syntaxErrorOf source = case parseNix testBaseDir "<test>" source of+ Left (SyntaxError err) -> Right err+ Left other -> Left ("expected a syntax error, got " <> T.pack (show other))+ Right _ -> Left "parsed unexpectedly"++-- | 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.+-- | The bytes of a string value, and nothing for any other value.+stringBytes :: NixValue -> BS.ByteString+stringBytes (VStr bytes _) = bytes+stringBytes _ = BS.empty++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 parses and fails to evaluate with exactly+-- the given message, for errors whose wording is upstream's.+assertEvalError :: Text -> Text -> Text -> TestResult+assertEvalError label source expected = case evalNix source of+ Left err+ | parseErrorTag `T.isPrefixOf` err ->+ Fail (label <> ": expected an eval error but the source did not parse: " <> err)+ | otherwise -> assertEqual label expected err+ Right val -> Fail (label <> ": expected eval failure but got: " <> T.pack (show val))++-- | 'assertEvalError' under the IO evaluator, which keeps a forced+-- thunk's value and marks one while it is being forced, as upstream's+-- evaluator does; the pure one does neither.+assertEvalErrorIO :: FilePath -> Text -> Text -> Text -> IO TestResult+assertEvalErrorIO baseDir label source expected = do+ result <- evalNixIO baseDir source+ pure $ case result of+ Left err -> assertEqual label expected err+ 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"]+ ),+ runTestM "compressHash XORs byte i into position i mod the length" $ do+ result <-+ QC.quickCheckWithResult QC.stdArgs {QC.chatty = False} $+ QC.forAll genCompressCase $ \(len, bytes) ->+ BS.unpack (compressHash len (BS.pack bytes)) QC.=== compressHashModel len bytes+ pure (if QC.isSuccess result then Pass else Fail (T.pack (QC.output result)))+ ]+ where+ -- Lengths on both sides of the input's, so the last chunk is short,+ -- whole or absent, and below one, where there is nothing to fold into.+ genCompressCase = (,) <$> QC.choose (-1, 40) <*> QC.resize 70 (QC.listOf QC.arbitrary)++-- | Upstream's @compressHash@ loop (hash.cc), one byte at a time.+compressHashModel :: Int -> [Word8] -> [Word8]+compressHashModel len bytes =+ [foldl' xor 0 [b | (i, b) <- zip [0 :: Int ..] bytes, i `mod` len == j] | j <- [0 .. len - 1]]++-- ---------------------------------------------------------------------------+-- 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, never a two's-complement wrap;+ -- the boundary itself still computes. The messages are upstream's+ -- from 2.25 on, as nix-instantiate 2.33.2 prints them (2.24.9 leaves+ -- overflow to undefined C++ signed arithmetic), except division's,+ -- which 2.24.9 checks itself and 2.25 rewords.+ runTest "add overflow fails" $+ assertEvalError "add-overflow" "9223372036854775807 + 1" "integer overflow in adding 9223372036854775807 + 1",+ runTest "sub overflow fails" $+ assertEvalError "sub-overflow" "(-9223372036854775807) - 2" "integer overflow in subtracting -9223372036854775807 - 2",+ runTest "mul overflow fails" $+ assertEvalError "mul-overflow" "builtins.mul 9223372036854775807 2" "integer overflow in multiplying 9223372036854775807 * 2",+ runTest "div minBound by -1 overflows" $+ assertEvalError "div-overflow" "((-9223372036854775807) - 1) / (-1)" "overflow in integer division",+ runTest "builtins.div minBound by -1 overflows" $+ assertEvalError "builtin-div-overflow" "builtins.div ((-9223372036854775807) - 1) (-1)" "overflow in integer division",+ runTest "negate minBound overflows as 0 - n" $+ assertEvalError "neg-overflow" "-((-9223372036854775807) - 1)" "integer overflow in subtracting 0 - -9223372036854775808",+ -- Unary minus is __sub 0 e upstream (parser.y at 2.24.9).+ runTest "negating a float zero gives positive zero" $+ assertEval "neg-zero" "builtins.toJSON [ (-(0.0)) (-(-0.0)) ]" (mkStr "[0.0,0.0]"),+ runTest "negating a non-number fails as builtins.sub does" $+ assertEvalError "neg-set" "-{ }" "expected an integer but found a set: { }",+ -- -, *, / and the arithmetic builtins are upstream's prim_sub,+ -- prim_mul, prim_div and prim_add (primops.cc at 2.24.9): forceInt+ -- on both operands, or forceFloat on both once either is a float.+ runTest "subtracting a set from an integer fails its forceInt" $+ assertEvalError "sub-set" "1 - {}" "expected an integer but found a set: { }",+ runTest "subtracting from a set fails on the left operand" $+ assertEvalError "set-sub" "{} - 1" "expected an integer but found a set: { }",+ runTest "subtracting a set from a float fails its forceFloat" $+ assertEvalError "float-sub-set" "1.0 - {}" "expected a float but found a set: { }",+ runTest "two non-numbers fail on the left one" $+ assertEvalError "set-sub-str" "{} - \"a\"" "expected an integer but found a set: { }",+ runTest "multiplying a float by a string fails its forceFloat" $+ assertEvalError "float-mul-str" "2.5 * \"a\"" "expected a float but found a string: \"a\"",+ runTest "dividing by a set fails the divisor's forceFloat" $+ assertEvalError "div-set" "1 / {}" "expected a float but found a set: { }",+ runTest "dividing a set by an integer fails its forceInt" $+ assertEvalError "set-div" "{} / 1" "expected an integer but found a set: { }",+ runTest "a zero divisor is refused before the dividend is checked" $+ assertEvalError "set-div-zero" "{} / 0" "division by zero",+ runTest "a non-number divisor is refused before the dividend" $+ assertEvalError "set-div-str" "{} / \"a\"" "expected a float but found a string: \"a\"",+ runTest "dividing by negative zero is a division by zero" $+ assertEvalError "div-neg-zero" "1 / (-0.0)" "division by zero",+ runTest "builtins.div by zero says only division by zero" $+ assertEvalError "builtin-div-zero" "builtins.div 1 0" "division by zero",+ runTest "builtins.add refuses a string through forceInt" $+ assertEvalError "builtin-add-str" "builtins.add \"a\" 1" "expected an integer but found a string: \"a\"",+ runTest "builtins.add with a float refuses a string through forceFloat" $+ assertEvalError "builtin-add-float-str" "builtins.add 1.5 \"a\"" "expected a float but found a string: \"a\"",+ runTest "builtins.sub refuses a set" $+ assertEvalError "builtin-sub-set" "builtins.sub 1 {}" "expected an integer but found a set: { }",+ runTest "builtins.bitXor refuses a float" $+ assertEvalError "bitxor-float" "builtins.bitXor 1 1.0" "expected an integer but found a float: 1",+ runTest "builtins.bitAnd fails on its left operand first" $+ assertEvalError "bitand-set-str" "builtins.bitAnd {} \"a\"" "expected an integer but found a set: { }",+ runTest "builtins.bitOr refuses a set" $+ assertEvalError "bitor-set" "builtins.bitOr {} 1" "expected an integer but found a set: { }",+ runTest "float division still divides" $+ assertEval "float-div" "1 / 4.0" (VFloat 0.25),+ 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\"",+ -- Upstream's CompareValues (primops.cc at 2.24.9); > and <= swap+ -- the operands as the parser's desugaring to __lessThan does.+ runTest "comparing different types names both" $+ assertEvalError "lt-int-set" "1 < {}" "cannot compare an integer with a set",+ runTest "gt compares the swapped operands" $+ assertEvalError "gt-int-set" "1 > {}" "cannot compare a set with an integer",+ runTest "lte compares the swapped operands" $+ assertEvalError "lte-int-set" "1 <= {}" "cannot compare a set with an integer",+ runTest "gte compares the operands in order" $+ assertEvalError "gte-int-set" "1 >= {}" "cannot compare an integer with a set",+ runTest "Booleans are incomparable" $+ assertEvalError "lt-bool" "true < false" "cannot compare a Boolean with a Boolean; values of that type are incomparable",+ runTest "a lambda and a primop are both functions, so incomparable" $+ assertEvalError "lt-fn" "(x: x) < builtins.map" "cannot compare a function with the built-in function 'map'; values of that type are incomparable",+ runTest "builtins.lessThan on nulls is incomparable" $+ assertEvalError "lessthan-null" "builtins.lessThan null null" "cannot compare null with null; values of that type are incomparable",+ runTest "list comparison reports the first unequal pair" $+ assertEvalError "lt-list" "[ 1 ] < [ \"a\" ]" "cannot compare an integer with a string",+ runTest "equal incomparable elements do not stop a list comparison" $+ assertEval "lt-list-sets" "[ {} ] < [ {} ]" (VBool False)+ ]++-- ---------------------------------------------------------------------------+-- 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),+ -- A non-set operand of // fails with upstream's evalAttrs message+ -- (eval.cc at 2.24.9), checked against nix-instantiate 2.33.2's+ -- final error line. tryEval does not catch it.+ runTest "update with a null left operand" $+ assertEvalError "update-null-left" "null // {}" "expected a set but found null: null",+ runTest "update with a null right operand" $+ assertEvalError "update-null-right" "{} // null" "expected a set but found null: null",+ runTest "update with an integer left operand" $+ assertEvalError "update-int-left" "1 // {}" "expected a set but found an integer: 1",+ runTest "update with a string right operand" $+ assertEvalError "update-string-right" "{} // \"x\"" "expected a set but found a string: \"x\"",+ -- 2.24.9's ExprOpUpdate::eval evaluates and checks the left operand+ -- before it evaluates the right; 2.33.2 does the right first.+ runTest "update with two non-set operands reports the left" $+ assertEvalError "update-both" "1 // \"x\"" "expected a set but found an integer: 1",+ runTest "update refuses a non-set left before evaluating the right" $+ assertEvalError "update-left-first" "builtins.tryEval (null // throw \"b\")" "expected a set but found null: null",+ runTest "update with a set left evaluates a throwing right" $+ assertEval "update-right-throws" "(builtins.tryEval ({} // throw \"b\")).success" (VBool False),+ runTest "update evaluates a throwing left first" $+ assertEvalError "update-left-throws" "throw \"a\" // null" "a",+ -- builtins.warn forces its message with forceString (prim_warn,+ -- primops.cc at 2.24.9); final lines as nix-instantiate 2.33.2+ -- prints them, and tryEval does not catch the refusal.+ runTest "warn refuses an integer message" $+ assertEvalError "warn-int" "builtins.warn 1 2" "expected a string but found an integer: 1",+ runTest "warn refuses a Boolean message" $+ assertEvalError "warn-bool" "builtins.warn true 2" "expected a string but found a Boolean: true",+ runTest "warn refuses a null message" $+ assertEvalError "warn-null" "builtins.warn null 2" "expected a string but found null: null",+ runTest "warn refuses a float message" $+ assertEvalError "warn-float" "builtins.warn 1.5 2" "expected a string but found a float: 1.5",+ runTest "warn's refusal is not caught by tryEval" $+ assertEvalError "warn-tryeval" "builtins.tryEval (builtins.warn 1 2)" "expected a string but found an integer: 1",+ runTest "warn with a string message returns its value" $+ assertEval "warn-string" "builtins.warn \"m\" 2" (VInt 2),+ runTest "tryEval does not catch the update error" $+ assertEvalError "update-tryEval" "builtins.tryEval (null // {})" "expected a set but found null: null"+ ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - Value printer+-- ---------------------------------------------------------------------------++testValuePrinter :: IO [Bool]+testValuePrinter = do+ putStrLn "eval/print"+ tmpBase <- getTemporaryDirectory+ let stringFound body = "expected a set but found a string: " <> body+ setFound body = "cannot coerce a set to a string: " <> body+ listFound body = "cannot coerce a list to a string: " <> body+ runOfA n = "builtins.concatStringsSep \"\" (builtins.genList (x: \"a\") " <> T.pack (show (n :: Int)) <> ")"+ elided what = " \x00AB" <> what <> " elided\x00BB"+ marker inner = "\x00AB" <> inner <> "\x00BB"+ interpolated source = "\"${" <> source <> "}\""+ deeplyForced source = "let x = " <> source <> "; in builtins.seq (builtins.deepSeq x null) \"${x}\""+ ints n = map (T.pack . show) [1 .. n :: Int]+ listOfInts n = "[ " <> T.concat (map (<> " ") (ints n)) <> "]"+ attrsOfInts n = "{ " <> T.concat [name <> " = " <> value <> "; " | (name, value) <- zip letters (ints n)] <> "}"+ letters = map T.singleton ['a' ..]+ shownInts = T.unwords [name <> " = " <> value <> ";" | (name, value) <- zip letters (ints 10)]+ paddedName i = T.pack ('a' : if i < 10 then '0' : show i else show i)+ paddedAttrs n = "{ " <> T.concat [paddedName i <> " = " <> T.pack (show i) <> "; " | i <- [1 .. n]] <> "}"+ boundedList n = bounded "[" "]" (ints (min n 10)) (n - 10) "item" "items"+ boundedAttrs n =+ bounded "{" "}" [paddedName i <> " = " <> T.pack (show i) <> ";" | i <- [1 .. min n 10]] (n - 10) "attribute" "attributes"+ bounded open close shown left single plural =+ open <> " " <> T.concat (map (<> " ") shown) <> leftOut left single plural <> close+ leftOut left single plural+ | left <= 0 = ""+ | left == 1 = marker ("1 " <> single <> " elided") <> " "+ | otherwise = marker (T.pack (show left) <> " " <> plural <> " elided") <> " "+ quickCheckPasses property = do+ result <- QC.quickCheckWithResult QC.stdArgs {QC.chatty = False} property+ pure (if QC.isSuccess result then Pass else Fail (T.pack (QC.output result)))+ sequence+ [ -- Values print as upstream's printValue prints them under+ -- errorPrintOptions (print.cc and print-options.hh at 2.24.9), each+ -- checked against nix-instantiate 2.33.2's final error line.+ runTest "a path prints bare" $+ assertEvalError "print-path" "{} // /foo/bar" "expected a set but found a path: /foo/bar",+ runTest "a float prints with six significant digits" $+ assertEvalError "print-float" "{} // 1234567.0" "expected a set but found a float: 1.23457e+06",+ runTest "a string prints quoted with the lexer's escapes" $+ assertEvalError+ "print-escapes"+ "{} // \"a\\nb\\\"c$d\\${e}\\t\\r\""+ (stringFound "\"a\\nb\\\"c$d\\${e}\\t\\r\""),+ runTest "a string of 1024 bytes prints whole" $+ assertEvalError+ "print-1024"+ ("{} // " <> runOfA 1024)+ (stringFound ("\"" <> T.replicate 1024 "a" <> "\"")),+ runTest "a longer string is cut after 1024 bytes" $+ assertEvalError+ "print-1025"+ ("{} // " <> runOfA 1025)+ (stringFound ("\"" <> T.replicate 1024 "a" <> "\"" <> elided "1 byte")),+ runTest "the bytes cut are counted" $+ assertEvalError+ "print-1100"+ ("{} // " <> runOfA 1100)+ (stringFound ("\"" <> T.replicate 1024 "a" <> "\"" <> elided "76 bytes")),+ runTest "a dollar shown last is escaped when the brace after it was cut" $+ assertEvalError+ "print-cut-dollar"+ ("{} // (" <> runOfA 1023 <> " + \"\\${\")")+ (stringFound ("\"" <> T.replicate 1023 "a" <> "\\$\"" <> elided "1 byte")),+ -- A set or list prints its entries, nothing forced, and is cut+ -- once the print has shown ten attributes or ten items in all.+ runTest "a set prints its attributes by name" $+ assertEvalError "print-set" "\"${{ b = \"s\"; a = 1; }}\"" (setFound "{ a = 1; b = \"s\"; }"),+ runTest "an empty set prints as a pair of braces" $+ assertEvalError "print-empty-set" "\"${{}}\"" (setFound "{ }"),+ runTest "_type and type print ahead of the other attributes" $+ assertEvalError+ "print-important-first"+ "\"${{ z = 1; type = 2; _type = 3; a = 4; }}\""+ (setFound "{ _type = 3; type = 2; a = 4; z = 1; }"),+ runTest "an attribute name the lexer cannot read bare prints quoted" $+ assertEvalError+ "print-quoted-names"+ "\"${{ \"a b\" = 1; \"if\" = 2; \"\" = 3; }}\""+ (setFound "{ \"\" = 3; \"a b\" = 1; \"if\" = 2; }"),+ runTest "an attribute never forced prints as upstream's thunk marker" $+ assertEvalError "print-thunk" "\"${{ a = 1 + 1; }}\"" (setFound ("{ a = " <> marker "thunk" <> "; }")),+ runTest "an eleventh attribute is counted, not shown" $+ assertEvalError+ "print-11-attrs"+ (interpolated (attrsOfInts 11))+ (setFound ("{ " <> shownInts <> " " <> marker "1 attribute elided" <> " }")),+ runTest "attributes past the tenth are counted together" $+ assertEvalError+ "print-12-attrs"+ (interpolated (attrsOfInts 12))+ (setFound ("{ " <> shownInts <> " " <> marker "2 attributes elided" <> " }")),+ runTest "an empty list prints as a pair of brackets" $+ assertEvalError "print-empty-list" "\"${[ ]}\"" (listFound "[ ]"),+ runTest "an eleventh item is counted, not shown" $+ assertEvalError+ "print-11-items"+ (interpolated (listOfInts 11))+ (listFound ("[ 1 2 3 4 5 6 7 8 9 10 " <> marker "1 item elided" <> " ]")),+ runTest "items past the tenth are counted together" $+ assertEvalError+ "print-12-items"+ (interpolated (listOfInts 12))+ (listFound ("[ 1 2 3 4 5 6 7 8 9 10 " <> marker "2 items elided" <> " ]")),+ runTestM "a list shows ten items and counts the rest" $+ quickCheckPasses $+ QC.forAll (QC.chooseInt (0, 25)) $ \n ->+ evalNix (interpolated (listOfInts n)) QC.=== Left (listFound (boundedList n)),+ runTestM "a set shows ten attributes and counts the rest" $+ quickCheckPasses $+ QC.forAll (QC.chooseInt (0, 25)) $ \n ->+ evalNix (interpolated (paddedAttrs n)) QC.=== Left (setFound (boundedAttrs n)),+ -- Forced values need the IO evaluator, which keeps them.+ runTestM "a nested set's attributes count before the attribute holding it" $+ assertEvalErrorIO+ tmpBase+ "print-nested-count"+ (deeplyForced "{ a = { b = 1; c = 2; d = 3; e = 4; f = 5; g = 6; }; h = 7; i = 8; j = 9; k = 10; l = 11; }")+ (setFound ("{ a = { b = 1; c = 2; d = 3; e = 4; f = 5; g = 6; }; h = 7; i = 8; j = 9; " <> marker "2 attributes elided" <> " }")),+ runTestM "list items count over the whole print" $+ assertEvalErrorIO+ tmpBase+ "print-items-across-lists"+ (deeplyForced "{ a = [ 1 2 3 4 5 6 7 8 9 ]; b = [ 10 11 12 ]; }")+ (setFound ("{ a = [ 1 2 3 4 5 6 7 8 9 ]; b = [ 10 " <> marker "2 items elided" <> " ]; }")),+ runTestM "a set nested ten deep prints as an ellipsis" $+ assertEvalErrorIO+ tmpBase+ "print-deep-set"+ (deeplyForced (T.replicate 12 "{ a = " <> "1" <> T.replicate 12 "; }"))+ (setFound (T.replicate 10 "{ a = " <> "{ ... }" <> T.replicate 10 "; }")),+ runTestM "a list nested ten deep prints as an ellipsis" $+ assertEvalErrorIO+ tmpBase+ "print-deep-list"+ (deeplyForced (T.replicate 12 "[ " <> "1" <> T.replicate 12 " ]"))+ (listFound (T.replicate 10 "[ " <> "[ ... ]" <> T.replicate 10 " ]")),+ runTestM "an attribute being forced prints as upstream's recursion marker" $+ assertEvalErrorIO+ tmpBase+ "print-blackhole"+ "let x = { a = \"${x}\"; }; in x.a"+ (setFound ("{ a = " <> marker "potential infinite recursion" <> "; }")),+ runTest "a primop prints as upstream's primop marker" $+ assertEvalError+ "print-primop"+ "{} // builtins.map"+ ("expected a set but found the built-in function 'map': " <> marker "primop map"),+ runTest "a partially applied primop prints as upstream's marker" $+ assertEvalError+ "print-primop-app"+ "{} // builtins.map (x: x)"+ ("expected a set but found the partially applied built-in function 'map': " <> marker "partially applied primop map"),+ -- builtins.trace's defaults bound nothing and sort only by name.+ runTest "a set prints in full by name as trace prints it"+ $ assertRight "print-full-set" (evalNix "{ type = 1; a = 2; }")+ $ assertEqual "print-full-set" "{ a = 2; type = 1; }" . printValue PrintInFull,+ runTest "a long list prints in full as trace prints it"+ $ assertRight "print-full-list" (evalNix (listOfInts 12))+ $ assertEqual "print-full-list" "[ 1 2 3 4 5 6 7 8 9 10 11 12 ]" . printValue PrintInFull,+ -- builtins.trace prints a non-string through the same printer with+ -- upstream's default options.+ runTest "a float prints in full as trace prints it" $+ assertEqual "print-full-float" "1.23457e+06" (printValue PrintInFull (VFloat 1234567.0)),+ runTestM "a string printed in full reads back as itself" $ do+ result <-+ QC.quickCheckWithResult QC.stdArgs {QC.chatty = False} $+ QC.forAll genEscapable $ \text ->+ evalNix (printValue PrintInFull (mkStr text)) QC.=== Right (mkStr text)+ pure (if QC.isSuccess result then Pass else Fail (T.pack (QC.output result)))+ ]++-- | Text built from what the string printer escapes (quotes, backslashes,+-- the escaped control characters, a dollar before a brace) among plain and+-- multi-byte characters. @${@ is a piece of its own so the escape that+-- needs lookahead comes up often.+genEscapable :: QC.Gen Text+genEscapable =+ T.concat <$> QC.listOf (QC.elements ["\"", "\\", "\n", "\r", "\t", "$", "{", "}", "${", "a", " ", "\233", "\128512"])++-- ---------------------------------------------------------------------------+-- Tests: Eval - + and string coercion errors+-- ---------------------------------------------------------------------------++-- | @+@ and string coercion fail with upstream's messages, those of+-- @ExprConcatStrings::eval@ and @coerceToString@ (eval.cc at 2.24.9): the+-- type as @showType@ names it, and after a failed coercion the value as+-- @errorPrintOptions@ prints it. Each expected message is nix-instantiate+-- 2.33.2's final error line for the same expression.+testCoercionErrors :: IO [Bool]+testCoercionErrors = do+ putStrLn "eval/coercion"+ tmpBase <- getTemporaryDirectory+ let testDir = tmpBase </> "nova-nix-test-coercion"+ setup = createDirectoryIfMissing True (testDir </> "d")+ cleanup = do+ exists <- doesDirectoryExist testDir+ when exists (removeDirectoryRecursive testDir)+ withContext = "\"${builtins.toFile \"a\" \"b\"}\""+ primopMarker = "\x00ABprimop map\x00BB"+ failsIO label source expected = runTestM label (assertEvalErrorIO testDir label source expected)+ bracket_ setup cleanup . sequence $+ [ -- An integer or float on the left makes + arithmetic, and a right+ -- operand that is no number is named.+ runTest "an integer plus a string cannot be added" $+ assertEvalError "add-int-string" "1 + \"a\"" "cannot add a string to an integer",+ runTest "a float plus a string cannot be added" $+ assertEvalError "add-float-string" "1.0 + \"a\"" "cannot add a string to a float",+ runTest "an integer plus null cannot be added" $+ assertEvalError "add-int-null" "1 + null" "cannot add null to an integer",+ failsIO "an integer plus a string with context names the context" ("1 + " <> withContext) "cannot add a string with context to an integer",+ runTest "an integer plus a primop names the primop" $+ assertEvalError "add-int-primop" "1 + builtins.map" "cannot add the built-in function 'map' to an integer",+ runTest "an integer plus a partial application names its primop" $+ assertEvalError+ "add-int-primop-app"+ "1 + builtins.map (x: x)"+ "cannot add the partially applied built-in function 'map' to an integer",+ -- Anything else on the left makes + concatenation, each operand+ -- coerced as interpolation coerces it.+ runTest "a primop on the left does not coerce" $+ assertEvalError "add-primop-int" "builtins.map + 1" ("cannot coerce the built-in function 'map' to a string: " <> primopMarker),+ failsIO "a string with context plus an integer refuses the integer" (withContext <> " + 1") "cannot coerce an integer to a string: 1",+ runTest "a Boolean on the left fails to coerce rather than to add" $+ assertEvalError "add-bool-int" "true + 1" "cannot coerce a Boolean to a string: true",+ runTest "a string plus a float refuses the float" $+ assertEvalError "add-string-float" "\"a\" + 1.5" "cannot coerce a float to a string: 1.5",+ runTest "a string plus a list refuses the list" $+ assertEvalError "add-string-list" "\"a\" + [ ]" "cannot coerce a list to a string: [ ]",+ runTest "a path plus an integer refuses the integer" $+ assertEvalError "add-path-int" "./d + 1" "cannot coerce an integer to a string: 1",+ failsIO+ "a path plus a string with context is refused"+ ("./d + " <> withContext)+ "a string that refers to a store path cannot be appended to a path",+ -- The left operand is dealt with before the right is evaluated.+ runTest "a set on the left fails before the right is evaluated" $+ assertEvalError "add-left-first" "{} + throw \"b\"" "cannot coerce a set to a string: { }",+ runTest "tryEval does not catch a failed coercion" $+ assertEvalError "add-tryEval" "builtins.tryEval ({} + throw \"b\")" "cannot coerce a set to a string: { }",+ -- Upstream copies a path into the store only when the left operand+ -- is a string, so a set's outPath on the left is its own text.+ runTestM "a set on the left contributes its path without a store copy" $ do+ result <- evalNixIO testDir "let s = { outPath = ./d; } + \"x\"; in builtins.getContext s == {} && s == toString ./d + \"x\""+ pure (assertRight "add-set-path" result (assertEqual "add-set-path" (VBool True))),+ -- Interpolation, toString and concatStringsSep coerce the same way.+ runTest "an interpolated set without outPath prints the set" $+ assertEvalError "interp-set" "\"${{ a = 1; b = \"s\"; }}\"" "cannot coerce a set to a string: { a = 1; b = \"s\"; }",+ runTest "an interpolated integer is refused" $+ assertEvalError "interp-int" "\"v${1}\"" "cannot coerce an integer to a string: 1",+ runTest "toString of a primop is refused" $+ assertEvalError "tostring-primop" "builtins.toString builtins.map" ("cannot coerce the built-in function 'map' to a string: " <> primopMarker),+ runTest "toString of a partial application is refused" $+ assertEvalError+ "tostring-primop-app"+ "builtins.toString (builtins.map (x: x))"+ "cannot coerce the partially applied built-in function 'map' to a string: \x00ABpartially applied primop map\x00BB",+ -- Upstream prints a lambda's source position after the word, which+ -- a nova-nix function value does not carry.+ runTest "toString of a lambda is refused" $+ assertEvalError "tostring-lambda" "builtins.toString (x: x)" "cannot coerce a function to a string: \x00ABlambda\x00BB",+ runTest "toString of a set without outPath is refused" $+ assertEvalError "tostring-set" "builtins.toString { }" "cannot coerce a set to a string: { }",+ runTest "concatStringsSep refuses an integer element" $+ assertEvalError "concatsep-int" "builtins.concatStringsSep \",\" [ 1 ]" "cannot coerce an integer to a string: 1",+ -- An interpolated path literal concatenates as + does with a path+ -- first, and refuses a context only after every piece coerced.+ runTest "a path segment refuses a set" $+ assertEvalError "path-segment-set" "./a${{}}" "cannot coerce a set to a string: { }",+ failsIO+ "a later path segment that fails to coerce is reported over an earlier one's context"+ ("./a${" <> withContext <> "}${1}")+ "cannot coerce an integer to a string: 1",+ -- A selection path names the type it cannot index as showType does+ -- (attr-path.cc at 2.24.9).+ runTest "a selection path through a function names it" $+ assertRight "attrpath-functions" (evalNix "{ a = builtins.map; b = builtins.map (x: x); c = x: x; }") $ \root ->+ assertEqual+ "attrpath-functions"+ ( Right+ [ Left "the expression selected by the selection path 'a.x' should be a set but is the built-in function 'map'",+ Left "the expression selected by the selection path 'b.x' should be a set but is the partially applied built-in function 'map'",+ Left "the expression selected by the selection path 'c.x' should be a set but is a function"+ ]+ )+ (runPureEval (mapM (`selectAttrPath` root) ["a.x", "b.x", "c.x"]))+ ]++-- ---------------------------------------------------------------------------+-- Tests: Eval - Result printer+-- ---------------------------------------------------------------------------++-- | What @nova-nix eval@ prints for a value. Each expected value is the+-- output of @nix-instantiate --eval --readonly-mode -E@ (Nix 2.33.2) for+-- the same expression, less the newline the command writes after it.+testResultPrinter :: IO [Bool]+testResultPrinter = do+ putStrLn "eval/print-result"+ tmpBase <- getTemporaryDirectory+ let testDir = tmpBase </> "nova-nix-test-print-result"+ notUtf8 = "a" <> BS.singleton 0xfc <> "b"+ setup = do+ createDirectoryIfMissing True testDir+ BS.writeFile (testDir </> "latin1") notUtf8+ cleanup = do+ exists <- doesDirectoryExist testDir+ when exists (removeDirectoryRecursive testDir)+ printsIO label source expected = runTestM label $ do+ result <- evalNixIO testDir source+ pure (assertRight label result (assertEqual label expected . renderResult))+ prints label source expected =+ runTest label (assertRight label (evalNix source) (assertEqual label expected . renderResult))+ floatPrints (source, expected) =+ prints ("a float prints in ostream form: " <> source) source expected+ bracket_ setup cleanup . sequence $+ [ printsIO+ "a string prints its bytes, not U+FFFD for one that is not UTF-8"+ "builtins.readFile ./latin1"+ ("\"" <> notUtf8 <> "\""),+ prints+ "a string escapes each of upstream's characters and a dollar before a brace"+ "\"\\\" \\\\ \\n \\r \\t \\${ $\""+ "\"\\\" \\\\ \\n \\r \\t \\${ $\"",+ prints "a lone dollar prints as it is" "\"$\"" "\"$\"",+ prints+ "an attribute name past ASCII prints quoted, as its bytes"+ "{ \"\233\" = 1; }"+ ("{ \"" <> BS.pack [0xc3, 0xa9] <> "\" = 1; }"),+ prints+ "an attribute name the lexer cannot read bare prints quoted"+ "{ \"a b\" = 1; \"if\" = 2; \"\" = 3; \"1x\" = 4; or = 5; \"a-b'\" = 6; _x = 7; \"-a\" = 8; \"'a\" = 9; \"x\\\"y\" = 10; }"+ "{ \"\" = 3; \"'a\" = 9; \"-a\" = 8; \"1x\" = 4; _x = 7; \"a b\" = 1; a-b' = 6; \"if\" = 2; or = 5; \"x\\\"y\" = 10; }",+ printsIO+ "nested lists and sets print their strings and names the same way"+ "let v = [ (builtins.readFile ./latin1) { \"b c\" = [ \"$\" \"\\${\" ]; } ]; in builtins.deepSeq v v"+ ("[ \"" <> notUtf8 <> "\" { \"b c\" = [ \"$\" \"\\${\" ]; } ]"),+ prints "an element never forced prints as upstream's thunk marker" "[ (1 + 1) ]" "[ <CODE> ]",+ prints "a lambda prints as upstream's marker" "x: x" "<LAMBDA>",+ prints "a primop prints as upstream's marker" "builtins.map" "<PRIMOP>",+ prints "a partially applied primop prints as upstream's marker" "builtins.map (x: x)" "<PRIMOP-APP>",+ prints "a regex builtin given its pattern is a partial application" "builtins.match \"a\"" "<PRIMOP-APP>",+ runTestM "a printed string reads back as itself" $+ quickCheckPasses $+ QC.forAll genEscapable $ \text ->+ readsBack (renderResult (mkStr text)) (mkStr text),+ runTestM "a printed attribute name reads back as itself" $+ quickCheckPasses $+ QC.forAll genAttrName $ \name ->+ let attrs = VAttrs (attrSetFromMap (Map.singleton name (evaluated (VInt 1))))+ in readsBack ("builtins.head (builtins.attrNames " <> renderResult attrs <> ")") (mkStr name)+ ]+ <> map floatPrints floatCases+ where+ renderResult = BL.toStrict . BB.toLazyByteString . printAmbiguous+ readsBack printed expected = case TE.decodeUtf8' printed of+ Left _ -> QC.counterexample "the printed bytes are not UTF-8" False+ Right source -> evalNix source QC.=== Right expected+ quickCheckPasses property = do+ result <- QC.quickCheckWithResult QC.stdArgs {QC.chatty = False} property+ pure (if QC.isSuccess result then Pass else Fail (T.pack (QC.output result)))+ -- Upstream unary minus is 0 - x, so -0.0 written as a literal is+ -- positive zero there; fromJSON yields a negative one.+ floatCases =+ [ ("1.0", "1"),+ ("1.5", "1.5"),+ ("0.1", "0.1"),+ ("1.0e20", "1e+20"),+ ("1.23456789", "1.23457"),+ ("builtins.fromJSON \"-0.0\"", "-0"),+ ("1234567.0", "1.23457e+06")+ ]++-- | Attribute names built from what decides whether one prints bare: the+-- identifier characters in and out of first place, the reserved keywords+-- and @or@, which is not one, and characters only a quoted name holds.+genAttrName :: QC.Gen Text+genAttrName =+ T.concat <$> QC.listOf (QC.elements ["a", "Z", "_", "0", "'", "-", " ", "\"", "\\", "${", ".", "\233", "if", "in", "inherit", "rec", "or"])++-- ---------------------------------------------------------------------------+-- 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),+ runTest "head forces only the first element" $+ assertEval "head-lazy" "builtins.head [ 1 (throw \"x\") ]" (VInt 1),+ runTest "tail keeps every element after the first" $+ assertEval "tail" "builtins.tail [ 1 2 3 ] == [ 2 3 ]" (VBool True),+ runTest "tail of a tail" $+ assertEval "tail-tail" "builtins.tail (builtins.tail [ 1 2 3 ]) == [ 3 ]" (VBool True),+ runTest "tail of a one-element list is empty" $+ assertEval "tail-one" "builtins.tail [ 1 ] == [ ]" (VBool True),+ runTest "tail forces none of the elements" $+ assertEval "tail-lazy" "builtins.length (builtins.tail [ (throw \"a\") (throw \"b\") ])" (VInt 1),+ runTest "head of a tail" $+ assertEval "head-tail" "builtins.head (builtins.tail [ 1 2 3 ])" (VInt 2),+ -- The messages 2.24.9 raises: head is its elemAt at index 0 and+ -- tail has its own (primops.cc), a non-list fails in forceList+ -- (eval-inline.hh), and tryEval catches neither. 2.26.0 reworded+ -- both empty-list messages, so nix-instantiate 2.33.2 prints+ -- "'builtins.head' called on an empty list" and the same for tail.+ runTest "head of an empty list" $+ assertEvalError "head-empty" "builtins.head [ ]" "list index 0 is out of bounds",+ runTest "tail of an empty list" $+ assertEvalError "tail-empty" "builtins.tail [ ]" "'tail' called on an empty list",+ runTest "head of the empty list tail returns" $+ assertEvalError "head-tail-empty" "builtins.head (builtins.tail [ 1 ])" "list index 0 is out of bounds",+ runTest "tail of the empty list tail returns" $+ assertEvalError "tail-tail-empty" "builtins.tail (builtins.tail [ 1 ])" "'tail' called on an empty list",+ runTest "head of an integer" $+ assertEvalError "head-int" "builtins.head 1" "expected a list but found an integer: 1",+ runTest "head of a string" $+ assertEvalError "head-string" "builtins.head \"x\"" "expected a list but found a string: \"x\"",+ runTest "tail of null" $+ assertEvalError "tail-null" "builtins.tail null" "expected a list but found null: null",+ runTest "tail of a Boolean" $+ assertEvalError "tail-bool" "builtins.tail true" "expected a list but found a Boolean: true",+ runTest "tryEval does not catch head of an empty list" $+ assertEvalError "head-empty-tryeval" "builtins.tryEval (builtins.head [ ])" "list index 0 is out of bounds",+ runTest "tryEval does not catch tail of a non-list" $+ assertEvalError "tail-int-tryeval" "builtins.tryEval (builtins.tail 1)" "expected a list but found an integer: 1",+ runTest "length of an integer" $+ assertEvalError "length-int" "builtins.length 1" "expected a list but found an integer: 1",+ runTest "length of null" $+ assertEvalError "length-null" "builtins.length null" "expected a list but found null: null",+ runTestM "drop and index on a C list agree with the materialized list" $ do+ result <-+ QC.quickCheckWithResult QC.stdArgs {QC.chatty = False} $+ QC.forAll genCase $ \(values, drops, index) ->+ QC.ioProperty $ do+ ptrs <- mapM cthunkNewComputedInt values+ let dropped = foldl' (flip clistDrop) (clistFromThunks ptrs) drops+ model = foldl' (flip drop) ptrs drops+ modelAt i+ | i < 0 = Nothing+ | otherwise = listToMaybe (drop i model)+ pure $+ (clistThunks dropped, clistLen dropped, clistIndex dropped index)+ QC.=== (model, length model, modelAt index)+ pure (if QC.isSuccess result then Pass else Fail (T.pack (QC.output result)))+ ]+ where+ -- Short lists and a few drops, so the drops and the index land on+ -- both sides of the bounds, negatives included.+ genCount = QC.choose (-2, 12)+ genCase = (,,) <$> QC.resize 10 (QC.listOf QC.arbitrary) <*> QC.resize 4 (QC.listOf genCount) <*> genCount++-- ---------------------------------------------------------------------------+-- 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" (parseNixWithScope (withGlobals ["a"]) 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" (parseNixWithScope (withGlobals ["a"]) 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" (parseNixWithScope (withGlobals ["a"]) 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),+ runTest "a builtin taking a string coerces a set as upstream's coerceToString" $+ assertEval "strlen-outpath" "builtins.stringLength { outPath = \"abc\"; }" (VInt 3)+ `andThen` assertEval "strlen-tostring" "builtins.stringLength { __toString = s: \"ab\"; }" (VInt 2)+ `andThen` assertEval "substring-outpath" "builtins.substring 1 1 { outPath = \"abc\"; }" (mkStr "b")+ `andThen` assertEval "discard-outpath" "builtins.unsafeDiscardStringContext { outPath = \"abc\"; }" (mkStr "abc")+ `andThen` assertEval "discard-output-outpath" "builtins.unsafeDiscardOutputDependency { outPath = \"abc\"; }" (mkStr "abc"),+ runTest "a builtin taking a string refuses what coerceToString refuses" $+ assertEvalError "strlen-int" "builtins.stringLength 1" "cannot coerce an integer to a string: 1"+ `andThen` assertEvalError "substring-int" "builtins.substring 0 1 1" "cannot coerce an integer to a string: 1"+ `andThen` assertEvalError "discard-int" "builtins.unsafeDiscardStringContext 1" "cannot coerce an integer to a string: 1"+ `andThen` assertEvalError "discard-output-int" "builtins.unsafeDiscardOutputDependency 1" "cannot coerce an integer to a string: 1"+ `andThen` assertEvalError "add-output-int" "builtins.addDrvOutputDependencies 1" "cannot coerce an integer to a string: 1",+ -- 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),+ -- Indentation stripping is upstream's stripIndentation rule for+ -- rule (parser-state.hh at 2.24.9), every case checked against+ -- nix-instantiate 2.33.2. The scan skips an escape, so the spaces+ -- after an escaped newline are never measured; the strip runs over+ -- escapes like any literal, so those spaces are stripped by the+ -- minimum measured elsewhere. An interpolation is opaque to both.+ runTest "escaped newline: following spaces are stripped, not measured" $+ assertEval "ind-esc-nl-strip" "'' a''\\n b''" (mkStr "a\nb"),+ runTest "escaped newline: a shallower line after it is not the minimum" $+ assertEval "ind-esc-nl-indent" "''\n x''\\n y\n z''" (mkStr "x\ny\nz"),+ runTest "escaped newline at line start keeps the next line stripped" $+ assertEval "ind-esc-nl-start" "''\n ''\\n b''" (mkStr "\nb"),+ runTest "interpolated newline leaves the next chunk mid-line" $+ assertEval "ind-interp-nl" "''\n ${\"\\n\"} b''" (mkStr "\n b"),+ -- The minimum starts at a sentinel, so a string with no measured+ -- line loses every leading space (#220).+ runTest "blank line then a spaces-only line strips to the newline" $+ assertEval "ind-blank-spaces" "''\n\n ''" (mkStr "\n"),+ runTest "spaces-only line strips to nothing" $+ assertEval "ind-spaces-only" "''\n ''" (mkStr ""),+ runTest "spaces between opener and closer strip to nothing" $+ assertEval "ind-spaces-same-line" "'' ''" (mkStr ""),+ -- Only a space is indentation: a tab fixes the minimum at its+ -- column and survives.+ runTest "tabs are content, not indentation" $+ assertEval "ind-tabs" "''\n\t\ta\n\t\tb\n''" (mkStr "\t\ta\n\t\tb\n"),+ runTest "a tab after the opener is content" $+ assertEval "ind-opener-tab" "''\t\n a\n''" (mkStr "\t\n a\n"),+ -- The last part sheds a final line of spaces after stripping; the+ -- rule runs on that part alone.+ runTest "closing indentation after a blank line is dropped" $+ assertEval "ind-closer-spaces" "''\n a\n\n ''" (mkStr "a\n\n"),+ runTest "closing indentation after an interpolation is dropped" $+ assertEval "ind-closer-after-interp" "''\n ${\"v\"}\n ''" (mkStr "v\n"),+ runTest "spaces after an interpolation on the last line survive" $+ assertEval "ind-interp-last-line" "''\n ${\"v\"} ''" (mkStr "v "),+ -- The opener consumes its own line's trailing spaces and newline.+ runTest "spaces before the opener's newline are not a line" $+ assertEval "ind-opener-spaces" "'' \n a\n''" (mkStr "a\n"),+ runTest "an opener line of spaces then the closer is empty" $+ assertEval "ind-opener-only" "'' \n''" (mkStr ""),+ runTest "toString int" $+ assertEval "toStr" "builtins.toString 42" (mkStr "42")+ ]++-- | A list coerced to a string, as upstream's @coerceToString@ joins one+-- under @coerceMore@ (eval.cc at 2.24.9). Each expected string is+-- nix-instantiate 2.24.9's.+testListCoercion :: IO [Bool]+testListCoercion = do+ putStrLn "eval/list-coercion"+ let joins label source expected = runTest label (assertEval label ("builtins.toString " <> source) (mkStr expected))+ bytes = BS.pack <$> QC.listOf QC.arbitrary+ quickCheckPasses property = do+ result <- QC.quickCheckWithResult QC.stdArgs {QC.chatty = False} property+ pure (if QC.isSuccess result then Pass else Fail (T.pack (QC.output result)))+ sequence+ [ joins "elements are joined with spaces" "[ 1 \"a\" 2 ]" "1 a 2",+ joins "an empty list element takes no space after it" "[ [ ] 1 ]" "1",+ joins "an empty list between two elements leaves one space" "[ 1 [ ] 2 ]" "1 2",+ joins "an element before an empty list keeps its space" "[ 1 [ ] ]" "1 ",+ joins "a list holding an empty list is not empty" "[ [ [ ] ] 1 ]" " 1",+ joins "an empty string element keeps its space" "[ \"\" 1 ]" " 1",+ joins "nested lists flatten through the same join" "[ [ 1 [ 2 3 ] ] 4 ]" "1 2 3 4",+ joins "a __toString may return a list" "{ __toString = s: [ 1 2 ]; }" "1 2",+ joins "an outPath may be a list" "{ outPath = [ 1 2 ]; }" "1 2",+ joins "an element whose __toString is an empty list keeps its space" "[ { __toString = s: [ ]; } 1 ]" " 1",+ runTest "interpolation still refuses a list" $+ assertEvalError "interp-list" "\"${[ 1 2 ]}\"" "cannot coerce a list to a string: [ 1 2 ]",+ runTestM "without empty lists the join is intercalate" $+ quickCheckPasses $+ QC.forAll (QC.listOf bytes) $ \parts ->+ joinCoercedList [(part, False) | part <- parts] QC.=== BS.intercalate " " parts,+ runTestM "an empty list before any element changes nothing" $+ quickCheckPasses $+ QC.forAll (QC.listOf bytes) $ \before ->+ QC.forAll (QC.listOf1 bytes) $ \after ->+ let marked = map (,False)+ in joinCoercedList (marked before <> [("", True)] <> marked after) QC.=== joinCoercedList (marked (before <> after))+ ]++-- ---------------------------------------------------------------------------+-- 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),+ -- The messages 2.24.9 raises (elemAt in primops.cc, forceInt in+ -- eval.cc, forceList in eval-inline.hh). The index is forced and+ -- checked before the list. 2.26.0 reworded the bounds message;+ -- 2.33.2 prints "'builtins.elemAt' called with index 5 on a list of+ -- size 2".+ runTest "elemAt past the end" $+ assertEvalError "elemAt-oob" "builtins.elemAt [ 1 2 ] 5" "list index 5 is out of bounds",+ runTest "elemAt at the length" $+ assertEvalError "elemAt-length" "builtins.elemAt [ 1 2 ] 2" "list index 2 is out of bounds",+ runTest "elemAt negative index" $+ assertEvalError "elemAt-negative" "builtins.elemAt [ 1 2 ] (-1)" "list index -1 is out of bounds",+ runTest "elemAt on an empty list" $+ assertEvalError "elemAt-empty" "builtins.elemAt [ ] 0" "list index 0 is out of bounds",+ runTest "elemAt of a non-list" $+ assertEvalError "elemAt-int" "builtins.elemAt 1 0" "expected a list but found an integer: 1",+ runTest "elemAt with a string index" $+ assertEvalError "elemAt-string-index" "builtins.elemAt [ 1 2 ] \"x\"" "expected an integer but found a string: \"x\"",+ runTest "elemAt checks the index before the list" $+ assertEvalError "elemAt-index-first" "builtins.elemAt 1 \"x\"" "expected an integer but found a string: \"x\"",+ -- 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),+ -- The opener is '' with an optional run of spaces and one newline+ -- (upstream lexer.l); a tab is not part of it.+ runTest "indented string opener consumes trailing spaces and newline" $+ assertRight "lex ind opener" (tokenize "<test>" "'' \n a''") $ \toks ->+ assertEqual "tokens" [TokIndStringOpen, TokStringLit " a", TokIndStringClose] (tokenTypes toks),+ runTest "indented string opener stops at a tab" $+ assertRight "lex ind opener tab" (tokenize "<test>" "''\t\na''") $ \toks ->+ assertEqual "tokens" [TokIndStringOpen, TokStringLit "\t\na", 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" $+ assertParseIn ["x"] "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" $+ assertParseIn+ ["name"]+ "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" $+ assertParseIn+ ["a", "b", "c"]+ "impl"+ "a -> b -> c"+ (EBinary OpImpl (EVar "a") (EBinary OpImpl (EVar "b") (EVar "c"))),+ runTest "parse concat right" $+ assertParseIn+ ["a", "b", "c"]+ "concat"+ "a ++ b ++ c"+ (EBinary OpConcat (EVar "a") (EBinary OpConcat (EVar "b") (EVar "c"))),+ runTest "parse update right" $+ assertParseIn+ ["a", "b", "c"]+ "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" $+ assertParseIn ["f", "x"] "app" "f x" (EApp (EVar "f") (EVar "x")),+ runTest "parse left-assoc application" $+ assertParseIn ["f", "x", "y"] "app left" "f x y" (EApp (EApp (EVar "f") (EVar "x")) (EVar "y")),+ runTest "parse application with parens" $+ assertParseIn+ ["f"]+ "app parens"+ "f (1 + 2)"+ (EApp (EVar "f") (EBinary OpAdd (ELit (NixInt 1)) (ELit (NixInt 2)))),+ -- Select+ runTest "parse select" $+ assertParseIn ["a"] "select" "a.b" (ESelect (EVar "a") [StaticKey "b"] Nothing),+ runTest "parse nested select" $+ assertParseIn+ ["a"]+ "nested select"+ "a.b.c"+ (ESelect (EVar "a") [StaticKey "b", StaticKey "c"] Nothing),+ runTest "parse select or default" $+ assertParseIn+ ["a"]+ "select or"+ "a.b or 1"+ (ESelect (EVar "a") [StaticKey "b"] (Just (ELit (NixInt 1)))),+ runTest "parse has-attr" $+ assertParseIn ["a"] "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 one binding per name, each holding the variable it+ -- copies, which resolution binds like any other variable.+ runTest "parse inherit" $+ assertParseIn+ ["x", "y"]+ "inherit"+ "{ inherit x y; }"+ (EAttrs False [Inherit "x" (EVar "x"), Inherit "y" (EVar "y")] NoCaptureInfo),+ runTest "parse inherit from" $+ assertParseIn+ ["a"]+ "inherit from"+ "{ inherit (a) x; }"+ (EAttrs False [InheritFrom (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" $+ assertParseIn+ ["a"]+ "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" $+ assertParseIn ["or"] "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" $+ assertRight "deep body" (syntaxErrorOf "{ a, b }:\n{\n x = 1;\n y = (;\n}") $ \err ->+ assertEqual "error line" 4 (peLine err),+ runTest "simple-lambda body error reports its own line" $+ assertRight "simple body" (syntaxErrorOf "x:\n(;") $ \err ->+ assertEqual "error line" 2 (peLine err),+ -- 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" $+ assertRight "empty formals body" (syntaxErrorOf "{ }:\n(;") $ \err ->+ assertEqual "error line" 2 (peLine err),+ -- 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" $+ assertRight "malformed formal" (syntaxErrorOf "{ a, b.c }: x") $ \err ->+ assertEqual "deeper than the attr-set stumble" True ((peLine err, peCol err) > (1, 4)),+ -- End-of-input failures carry no position and must rank as the+ -- furthest a branch can get.+ runTest "truncated lambda reports end of input" $+ assertRight "truncated lambda" (syntaxErrorOf "{ a, b }:") $ \err ->+ assertEqual "end of input" True ("end of input" `T.isInfixOf` peMessage err)+ ]++-- ---------------------------------------------------------------------------+-- 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)),+ runTest "indented string escape is its own part" $+ assertRight "ind string esc" (parseNix testBaseDir "<test>" "''a'''b''") $ \case+ EIndStr [StrLit "a", StrEsc "''", StrLit "b"] -> Pass+ other -> Fail ("expected literal, escape, literal, 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).+ -- The inherited variable resolves against the outer (lambda)+ -- scope, one level up from the let it is evaluated in.+ -- The body x resolves to level 0, index 0 (the let scope).+ ELambda _ (ELet [Inherit "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), and+ -- refused with upstream's StorePath constructor text (path.cc at+ -- 2.24.9).+ runTest "storePath rejects a non-base32 hash" $+ assertEvalError+ "storePath-badhash"+ "builtins.storePath \"/nix/store/EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE-x\""+ "store path 'EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE-x' contains illegal base-32 character 'E'",+ runTest "storePath refuses a path outside the store" $+ assertEvalError "storePath-outside" "builtins.storePath \"/x\"" "path '/x' is not in the Nix store",+ runTest "storePath refuses the store directory itself" $+ assertEvalError "storePath-storedir" "builtins.storePath \"/nix/store/\"" "path '/nix/store' is not in the Nix store",+ runTest "storePath refuses a component too short for a hash" $+ assertEvalError "storePath-short" "builtins.storePath \"/nix/store/x\"" "'x' is too short to be a valid store path",+ runTest "storePath refuses an empty name" $+ assertEvalError "storePath-empty-name" "builtins.storePath \"/nix/store/00000000000000000000000000000000-\"" "path '00000000000000000000000000000000-' is not a valid store path: name must not be empty",+ runTest "storePath refuses an illegal name" $+ assertEvalError "storePath-bad-name" "builtins.storePath \"/nix/store/00000000000000000000000000000000-a b\"" "path '00000000000000000000000000000000-a b' is not a valid store path: name 'a b' contains illegal character ' '",+ 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"+ let functionArgsError = "'functionArgs' requires a function"+ 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)),+ -- Upstream's prim_functionArgs message (primops.cc at 2.24.9), the+ -- same whatever the argument, and tryEval does not catch it.+ runTest "functionArgs of a set is upstream's error" $+ assertEvalError "funcArgs-set" "builtins.functionArgs {}" functionArgsError,+ runTest "functionArgs of a path is upstream's error" $+ assertEvalError "funcArgs-path" "builtins.functionArgs ./foo" functionArgsError,+ runTest "functionArgs of an integer is upstream's error" $+ assertEvalError "funcArgs-int" "builtins.functionArgs 42" functionArgsError,+ runTest "functionArgs of a string is upstream's error" $+ assertEvalError "funcArgs-string" "builtins.functionArgs \"s\"" functionArgsError,+ runTest "tryEval does not catch the functionArgs error" $+ assertEvalError "funcArgs-tryEval" "builtins.tryEval (builtins.functionArgs {})" functionArgsError,+ -- 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\"",+ -- The integer range as json-to-value.cc has it at 2.25 and after.+ runTest "fromJSON reads the largest int" $+ assertEval "fromJSON-int-max" "builtins.fromJSON \"9223372036854775807\"" (VInt 9223372036854775807),+ runTest "fromJSON reads the smallest int" $+ assertEval "fromJSON-int-min" "builtins.fromJSON \"-9223372036854775808\"" (VInt (-9223372036854775808)),+ runTest "fromJSON refuses one past the largest int" $+ assertEvalError "fromJSON-past-int" "builtins.fromJSON \"9223372036854775808\"" "unsigned json number 9223372036854775808 outside of Nix integer range",+ runTest "fromJSON refuses the largest unsigned" $+ assertEvalError "fromJSON-uint-max" "builtins.fromJSON \"18446744073709551615\"" "unsigned json number 18446744073709551615 outside of Nix integer range",+ runTest "fromJSON refuses an unsigned number inside a list" $+ assertEvalError "fromJSON-uint-list" "builtins.fromJSON \"[1,[18446744073709551615]]\"" "unsigned json number 18446744073709551615 outside of Nix integer range",+ runTest "fromJSON refuses an unsigned number inside a set" $+ assertEvalError "fromJSON-uint-set" "builtins.fromJSON ''{\"a\":18446744073709551615}''" "unsigned json number 18446744073709551615 outside of Nix integer range",+ runTest "fromJSON names the first such number in the document, not in key order" $+ assertEvalError "fromJSON-uint-order" "builtins.fromJSON ''{\"b\":18446744073709551615,\"a\":9223372036854775808}''" "unsigned json number 18446744073709551615 outside of Nix integer range",+ runTest "fromJSON refuses such a number ahead of a later syntax error" $+ assertEvalError "fromJSON-uint-syntax" "builtins.fromJSON \"[18446744073709551615,nonsense\"" "unsigned json number 18446744073709551615 outside of Nix integer range",+ -- nlohmann's grammar (json-to-value.cc at 2.24.9), strict RFC 8259:+ -- each case below is refused or read as nix-instantiate reads it.+ runTest "fromJSON reads the backspace and form feed escapes" $+ assertEval "fromJSON-bf" "builtins.fromJSON ''\"a\\bb\\fc\"''" (mkStr "a\bb\fc"),+ runTest "fromJSON reads a surrogate pair" $+ assertEval "fromJSON-pair" "builtins.fromJSON ''\"\\ud83d\\ude00\"''" (mkStr "\128512"),+ runTest "fromJSON skips a leading byte order mark" $+ assertEval "fromJSON-bom" "builtins.fromJSON \"\65279 1\"" (VInt 1),+ runTest "fromJSON refuses an unterminated string" $+ assertEvalError "fromJSON-unterminated" "builtins.fromJSON ''\"abc''" "unterminated string",+ runTest "fromJSON refuses a trailing comma in an array" $+ assertEvalError "fromJSON-array-comma" "builtins.fromJSON \"[1,]\"" "unexpected ']', expected a JSON value",+ runTest "fromJSON refuses a trailing comma in an object" $+ assertEvalError "fromJSON-object-comma" "builtins.fromJSON ''{\"a\":1,}''" "unexpected '}' in an object, expected a member name",+ runTest "fromJSON refuses a leading zero" $+ assertEvalError "fromJSON-leading-zero" "builtins.fromJSON \"01\"" "unexpected content after the JSON value: '1'",+ runTest "fromJSON refuses a raw control character in a string" $+ assertEvalError "fromJSON-raw-tab" "builtins.fromJSON \"\\\"a\\tb\\\"\"" "control character U+0009 must be escaped",+ runTest "fromJSON refuses a \\u escape short of four hex digits" $+ assertEvalError "fromJSON-short-u" "builtins.fromJSON ''\"\\u12\"''" "invalid \\u escape '12\"'",+ runTest "fromJSON refuses a lone high surrogate" $+ assertEvalError "fromJSON-lone-high" "builtins.fromJSON ''\"\\ud800\"''" "a high surrogate must be followed by a low surrogate",+ runTest "fromJSON refuses a lone low surrogate" $+ assertEvalError "fromJSON-lone-low" "builtins.fromJSON ''\"\\udc00\"''" "a low surrogate must follow a high surrogate",+ runTest "fromJSON refuses an unknown escape" $+ assertEvalError "fromJSON-bad-escape" "builtins.fromJSON ''\"a\\x\"''" "invalid escape \\x",+ runTest "fromJSON refuses a number past the double range" $+ assertEvalError "fromJSON-overflow" "builtins.fromJSON \"1e400\"" "number overflow parsing '1e400'",+ runTest "fromJSON refuses whitespace JSON does not define" $+ assertEvalError "fromJSON-nbsp" "builtins.fromJSON \"\160 1\"" "unexpected '\160 1', expected a JSON value",+ runTest "fromJSON refuses a vertical tab around a value" $+ assertEvalError "fromJSON-vt" "builtins.fromJSON \"\v1\"" "unexpected '\v1', expected a JSON value",+ -- 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++-- | 'evalNixIO' keeping a failure whole, its lines of context included.+evalNixIOTraced :: FilePath -> Text -> IO (Either EvalFailure NixValue)+evalNixIOTraced baseDir source = case parseNix baseDir "<test>" source of+ Left err -> pure (Left (EvalFailure [] (T.pack (show err))))+ Right expr -> do+ storeDir <- evalNixIOStoreDir+ st <- newEvalState storeDir baseDir+ runEvalIOTraced st (eval (builtinEnv unrestrictedPolicy (esTimestamp st) (esSearchPaths st)) expr)++-- | IO eval of a derivation expression to the 'Derivation' the session+-- recorded for it: forcing the expression's @drvPath@ runs derivationStrict,+-- which records the @.drv@ under that path, and the recipe is read back the+-- way the build driver reads it. Only the IO evaluator keeps the record.+evalDerivationIO :: FilePath -> Text -> IO (Either Text Derivation)+evalDerivationIO baseDir source = case parseNix baseDir "<test>" ("(" <> source <> ").drvPath") of+ Left err -> pure (Left (T.pack (show err)))+ Right expr -> do+ storeDir <- evalNixIOStoreDir+ st <- newEvalState storeDir baseDir+ runEvalIO st $ do+ pathVal <- eval (builtinEnv unrestrictedPolicy (esTimestamp st) (esSearchPaths st)) expr+ case pathVal of+ VStr pathBytes _ -> do+ recorded <- lookupSessionDrv (bytesText pathBytes)+ maybe (throwEvalError ("no .drv was recorded for " <> bytesText pathBytes)) pure recorded+ other -> throwEvalError ("expected a drvPath string, got " <> typeName other)++-- | 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)++-- | The name, builder and system of a derivation, read as upstream's+-- derivationStrict reads them (primops.cc at 2.24.9): the name with+-- forceStringNoCtx, the builder and system as their environment entries+-- coerce, each checked for presence once all are read. Every expected+-- message is nix-instantiate 2.24.9's final error line.+testDerivationFields :: IO [Bool]+testDerivationFields = do+ putStrLn "eval/derivation-fields"+ let drvWith fields = "(derivation { " <> fields <> " }).drvPath"+ plain = "system = \"x\"; builder = \"/bin/sh\";"+ withDep body = "let d = derivation { name = \"d\"; " <> plain <> " }; in " <> body+ fails label source expected = runTest label (assertEvalError label source expected)+ depOut = "/nix/store/simq0c07kvm2wyvasl0k1f619wishj4y-d"+ depDrv = "wwk71dy0w17kn5spjd8hbsrk6520xs11-d.drv"+ sequence+ [ fails "a name that is not a string is refused" (drvWith ("name = 1; " <> plain)) "expected a string but found an integer: 1",+ fails "a name with an output's context names it" (withDep (drvWith ("name = \"x${d}\"; " <> plain))) ("the string 'x" <> depOut <> "' is not allowed to refer to a store path (such as '!out!" <> depDrv <> "')"),+ fails "a name with a drvPath's context names it" (withDep (drvWith ("name = \"x${d.drvPath}\"; " <> plain))) ("the string 'x/nix/store/" <> depDrv <> "' is not allowed to refer to a store path (such as '=" <> depDrv <> "')"),+ fails "a plain path comes first among a name's context" (withDep (drvWith ("name = builtins.appendContext \"x${d}\" { \"/nix/store/00000000000000000000000000000000-a\" = { path = true; }; }; " <> plain))) ("the string 'x" <> depOut <> "' is not allowed to refer to a store path (such as '00000000000000000000000000000000-a')"),+ fails "all outputs come before one among a name's context" (withDep (drvWith ("name = \"x${d}${d.drvPath}\"; " <> plain))) ("the string 'x" <> depOut <> "/nix/store/" <> depDrv <> "' is not allowed to refer to a store path (such as '=" <> depDrv <> "')"),+ fails "an absent name is getAttr's error" (drvWith plain) "attribute 'name' missing",+ fails "an absent builder is refused" (drvWith "name = \"a\"; system = \"x\";") "required attribute 'builder' missing",+ fails "an empty builder is refused as absent" (drvWith "name = \"a\"; system = \"x\"; builder = \"\";") "required attribute 'builder' missing",+ fails "an empty system is refused as absent" (drvWith "name = \"a\"; system = \"\"; builder = \"/bin/sh\";") "required attribute 'system' missing",+ fails "the builder is checked before the system" (drvWith "name = \"a\";") "required attribute 'builder' missing",+ fails "an empty name is invalid" (drvWith ("name = \"\"; " <> plain)) "invalid derivation name: name must not be empty. Please pass a different 'name'.",+ fails "a dot name is invalid" (drvWith ("name = \".\"; " <> plain)) "invalid derivation name: name '.' is not valid. Please pass a different 'name'.",+ fails "a dot component is invalid" (drvWith ("name = \".-a\"; " <> plain)) "invalid derivation name: name '.-a' is not valid: first dash-separated component must not be '.'. Please pass a different 'name'.",+ fails "an illegal character is named" (drvWith ("name = \"a b\"; " <> plain)) "invalid derivation name: name 'a b' contains illegal character ' '. Please pass a different 'name'."+ ]++-- | A path as a derivation's builder or system is copied to the store, as+-- its environment entry is, so the .drv's builder and platform fields hold+-- the store path. Each drvPath is nix-instantiate 2.24.9's.+testDerivationPathFieldsIO :: IO [Bool]+testDerivationPathFieldsIO = do+ putStrLn "eval/derivation-path-fields-io"+ tmpBase <- getTemporaryDirectory+ let dir = tmpBase </> "nova-nix-test-derivation-path-fields"+ drvPathOf fields = "builtins.unsafeDiscardStringContext (derivation { name = \"a\"; " <> fields <> " }).drvPath"+ script = "/. + " <> nixQuotedPath (dir </> "b.sh")+ Dir.removePathForcibly dir+ createDirectoryIfMissing True dir+ BS.writeFile (dir </> "b.sh") "echo hi\n"+ results <-+ sequence+ [ runTestIO "a path builder is copied to the store" dir (drvPathOf ("system = \"x\"; builder = " <> script <> ";")) (mkStr "/nix/store/gz23n4381jlqwn1gvjqgs3411vr3w4nx-a.drv"),+ runTestIO "a path system is copied to the store" dir (drvPathOf ("system = " <> script <> "; builder = \"/bin/sh\";")) (mkStr "/nix/store/382y120pp033h5kx5zfw02q0pglf297l-a.drv")+ ]+ Dir.removePathForcibly dir+ pure results++-- | A builtin taking a path coerces its argument as upstream's+-- coerceToPath does (eval.cc at 2.24.9), and baseNameOf and dirOf as+-- their coerceToString does. Each expected value or message is+-- nix-instantiate 2.24.9's.+testPathArguments :: IO [Bool]+testPathArguments = do+ putStrLn "eval/path-arguments"+ let refuses label source = runTest label (assertEvalError label source "cannot coerce an integer to a string: 1")+ fails label source expected = runTest label (assertEvalError label source expected)+ gives label source expected = runTest label (assertEval label source expected)+ sequence+ [ refuses "readFile coerces its argument" "builtins.readFile 1",+ refuses "readDir coerces its argument" "builtins.readDir 1",+ refuses "pathExists coerces its argument" "builtins.pathExists 1",+ refuses "hashFile coerces its path" "builtins.hashFile \"sha256\" 1",+ refuses "readFileType coerces its argument" "builtins.readFileType 1",+ refuses "import coerces its argument" "import 1",+ refuses "scopedImport coerces its path" "builtins.scopedImport { } 1",+ refuses "builtins.path coerces its path" "builtins.path { path = 1; }",+ refuses "filterSource coerces its path" "builtins.filterSource (p: t: true) 1",+ refuses "toPath coerces its argument" "builtins.toPath 1",+ fails "a coerced path must be absolute" "builtins.toPath \"a\"" "string 'a' doesn't represent an absolute path",+ fails "builtins.path needs a path" "builtins.path { name = \"x\"; }" "missing required 'path' attribute in the first argument to builtins.path",+ fails "baseNameOf refuses null as coerceToString does" "builtins.baseNameOf null" "cannot coerce null to a string: null",+ fails "dirOf refuses a Boolean as coerceToString does" "builtins.dirOf true" "cannot coerce a Boolean to a string: true",+ gives "toPath gives a string" "builtins.typeOf (builtins.toPath \"/a/b\")" (mkStr "string"),+ gives "toPath canonicalizes an outPath" "builtins.toPath { outPath = \"/a//b/\"; }" (mkStr "/a/b"),+ gives "toPath keeps the context it coerced" "let d = derivation { name = \"d\"; system = \"x\"; builder = \"/bin/sh\"; }; in builtins.concatStringsSep \",\" (builtins.attrNames (builtins.getContext (builtins.toPath \"${d}/bin\")))" (mkStr "/nix/store/wwk71dy0w17kn5spjd8hbsrk6520xs11-d.drv"),+ gives "baseNameOf takes a __toString" "builtins.baseNameOf { __toString = s: \"/a/b\"; }" (mkStr "b"),+ gives "dirOf takes an outPath, giving a string" "builtins.dirOf { outPath = /a/b; }" (mkStr "/a"),+ gives "dirOf of a path stays a path" "builtins.typeOf (builtins.dirOf /a/b)" (mkStr "path")+ ]++-- | The same coercion reaching the filesystem: an outPath or a __toString+-- names the file read.+testPathArgumentsIO :: IO [Bool]+testPathArgumentsIO = do+ putStrLn "eval/path-arguments-io"+ -- readFileType refuses a symlink above its path, as upstream's does, and+ -- macOS keeps the temporary directory under /var, a link.+ tmpBase <- Dir.canonicalizePath =<< getTemporaryDirectory+ let dir = tmpBase </> "nova-nix-test-path-arguments"+ file = nixQuotedPath (dir </> "f")+ Dir.removePathForcibly dir+ createDirectoryIfMissing True dir+ BS.writeFile (dir </> "f") "contents"+ results <-+ sequence+ [ runTestIO "readFile reads an outPath" dir ("builtins.readFile { outPath = " <> file <> "; }") (mkStr "contents"),+ runTestIO "pathExists follows an outPath" dir ("builtins.pathExists { outPath = " <> file <> "; }") (VBool True),+ runTestIO "readFileType follows a __toString" dir ("builtins.readFileType { __toString = s: " <> file <> "; }") (mkStr "regular"),+ runTestIO "hashFile hashes an outPath" dir ("builtins.hashFile \"sha256\" { outPath = " <> file <> "; }") (mkStr "d1b2a59fbea7e20077af9f91b27e95e865061b270be03ff539ab3b73587882e8")+ ]+ Dir.removePathForcibly dir+ pure results++-- | The lines of context a failure gathers on its way out of+-- derivationStrict, and how the CLI reports them (#221). Only the IO+-- evaluator keeps them.+testDerivationTraceIO :: IO [Bool]+testDerivationTraceIO = do+ putStrLn "eval/derivation-trace"+ let derivationFailure attrs expected = do+ tmpBase <- getTemporaryDirectory+ result <- evalNixIOTraced tmpBase ("(derivation { builder = \"b\"; " <> attrs <> " }).drvPath")+ pure $ case result of+ Left failure -> assertEqual "derivation-failure" expected failure+ Right val -> Fail ("expected a failure, got " <> T.pack (show val))+ sequence+ [ -- A failure processing one attribute carries upstream's line naming+ -- it and its derivation (derivationStrictInternal, primops.cc at+ -- 2.24.9); the message itself is unchanged.+ runTestM+ "a failing derivation attribute is named in the failure's trace"+ ( derivationFailure+ "system = \"s\"; name = \"x\"; foo = { };"+ (EvalFailure ["while evaluating attribute 'foo' of derivation 'x'"] "cannot coerce a set to a string: { }")+ ),+ runTestM+ "a structured attribute with no JSON form is named in the failure's trace"+ ( derivationFailure+ "system = \"s\"; name = \"x\"; __structuredAttrs = true; foo = x: x;"+ (EvalFailure ["while evaluating attribute 'foo' of derivation 'x'"] "builtins.toJSON: cannot convert a function to JSON")+ ),+ -- prim_derivationStrict adds its own line outside forceStringNoCtx's.+ runTestM+ "a failing name carries both of upstream's lines"+ ( derivationFailure+ "system = \"s\"; name = throw \"n\";"+ ( EvalFailure+ [ "while evaluating the derivation attribute 'name'",+ "while evaluating the `name` attribute passed to builtins.derivationStrict"+ ]+ "n"+ )+ ),+ runTestM+ "a switch that is not a Boolean is named in the failure's trace"+ ( derivationFailure+ "system = \"s\"; name = \"x\"; __ignoreNulls = 1;"+ (EvalFailure ["while evaluating the `__ignoreNulls` attribute passed to builtins.derivationStrict"] "expected a Boolean but found an integer: 1")+ ),+ runTestM+ "a structured-attributes switch that is not a Boolean is named in the failure's trace"+ ( derivationFailure+ "system = \"s\"; name = \"x\"; __structuredAttrs = 1;"+ (EvalFailure ["while evaluating the `__structuredAttrs` attribute passed to builtins.derivationStrict"] "expected a Boolean but found an integer: 1")+ ),+ runTestM+ "a structured string field that is not a string is named in the failure's trace"+ ( derivationFailure+ "system = \"s\"; name = \"x\"; __structuredAttrs = true; outputHash = 1;"+ (EvalFailure ["while evaluating attribute 'outputHash' of derivation 'x'"] "expected a string but found an integer: 1")+ ),+ runTestM+ "a missing attribute is refused without a line naming it"+ (derivationFailure "name = \"x\";" (EvalFailure [] "required attribute 'system' missing")),+ runTestM "a failure in a dependency is traced through each derivation, outermost first" $ do+ tmpBase <- getTemporaryDirectory+ result <-+ evalNixIOTraced tmpBase $+ "let inner = derivation { name = \"inner\"; system = \"s\"; builder = \"b\"; bad = { }; };"+ <> " in (derivation { name = \"outer\"; system = \"s\"; builder = \"b\"; dep = inner; }).drvPath"+ pure $ case result of+ Left failure ->+ assertEqual+ "nested-trace"+ ["while evaluating attribute 'dep' of derivation 'outer'", "while evaluating attribute 'bad' of derivation 'inner'"]+ (failureTrace failure)+ Right val -> Fail ("expected a failure, got " <> T.pack (show val)),+ runTestM "a throw in a derivation attribute stays catchable" $ do+ tmpBase <- getTemporaryDirectory+ result <- evalNixIO tmpBase "(builtins.tryEval (derivation { name = \"x\"; system = \"s\"; builder = \"b\"; foo = throw \"t\"; }).drvPath).success"+ pure $ assertRight "trace-catchable" result (assertEqual "trace-catchable" (VBool False)),+ -- upstream's showErrorInfo and indent (error.cc at 2.24.9), checked+ -- against nix-instantiate 2.24.9's report for the same failure with+ -- its positioned lines left out.+ runTest "a failure renders as upstream reports it" $+ assertEqual+ "render-traced"+ "error:\n \x2026 while evaluating attribute 'foo' of derivation 'x'\n\n error: a\n b"+ (renderEvalFailure (EvalFailure ["while evaluating attribute 'foo' of derivation 'x'"] "a\nb")),+ runTest "a failure with no trace renders on the error line" $+ assertEqual "render-untraced" "error: a\n b" (renderEvalFailure (EvalFailure [] "a\nb"))+ ]++-- | 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++-- | Interpolating a path that is a symlink copies what it points to,+-- under the link's own name: upstream's copyPathToStore stores+-- path.resolveSymlinks() under path.baseName() (eval.cc at 2.24.9).+testInterpolatedSymlinkIO :: IO [Bool]+testInterpolatedSymlinkIO = do+ putStrLn "eval/interpolated-symlink-io"+ canLink <- symlinksAvailable+ if not canLink+ then do+ putStrLn " SKIP needs symlink privilege"+ pure []+ else testInterpolatedSymlinkBody++testInterpolatedSymlinkBody :: IO [Bool]+testInterpolatedSymlinkBody = do+ tmpBase <- getTemporaryDirectory+ let dir = tmpBase </> "nova-nix-test-interpolated-symlink"+ Dir.removePathForcibly dir+ createDirectoryIfMissing True (dir </> "sub")+ BS.writeFile (dir </> "data.txt") "linked bytes"+ BS.writeFile (dir </> "sub" </> "inner.txt") "inner"+ Dir.createFileLink "data.txt" (dir </> "filelink")+ Dir.createDirectoryLink "sub" (dir </> "dirlink")+ fileEntry <- NAR.serialiseFromPath (dir </> "data.txt")+ dirEntry <- NAR.serialiseFromPath (dir </> "sub")+ let interpolated name = evalNixIO "." ("\"${/. + " <> nixQuotedPath (dir </> name) <> "}\"")+ copiedAs name entry =+ either (const Nothing) (Just . storePathToText defaultStoreDir) (makeFixedOutputPath (T.pack name) "sha256" "recursive" (sha256Digest (NAR.serialise entry)))+ copies name entry = runTestM ("interpolating " <> T.pack name <> " copies its target") $ do+ result <- interpolated name+ pure $ case (result, copiedAs name entry) of+ (_, Nothing) -> Fail "fixture store path rejected"+ (Right (VStr got _), Just expected) -> assertEqual (T.pack name) (TE.encodeUtf8 expected) got+ (Right other, _) -> Fail ("expected a string, got " <> T.pack (show other))+ (Left err, _) -> Fail ("eval failed: " <> err)+ results <-+ sequence+ [ copies "filelink" fileEntry,+ copies "dirlink" dirEntry+ ]+ Dir.removePathForcibly dir+ pure results++-- | A failed source-path access reworded as upstream's PosixSourceAccessor+-- words it (2.24.9), from IOExceptions shaped as GHC raises them.+testSourceErrorMessage :: IO [Bool]+testSourceErrorMessage = do+ putStrLn "eval/source-error-message"+ let errnoFailure kind (Errno code) description =+ IOError Nothing kind "test" description (Just code) (Just "/d/x")+ absent = errnoFailure NoSuchThing eNOENT "No such file or directory"+ notDirectory = errnoFailure InappropriateType eNOTDIR "Not a directory"+ denied = errnoFailure PermissionDenied eACCES "Permission denied"+ isDirectory = errnoFailure InappropriateType eISDIR "Is a directory"+ -- GHC's openFile refuses a directory itself, with no errno.+ ghcDirectory = IOError Nothing InappropriateType "openFile" "is a directory" Nothing (Just "/d/dir")+ says label access path err expected =+ runTest label (assertEqual label expected (sourceErrorMessage access path err))+ sequence+ [ says "a stat of an absent path" StatPath "/d/nope" absent "path '/d/nope' does not exist",+ says "a stat through a file is absent too" StatPath "/d/file/x" notDirectory "path '/d/file/x' does not exist",+ says "a stat refused otherwise names the stat" StatPath "/d/x" denied "getting status of '/d/x': Permission denied",+ says "opening an absent file" ReadFile "/d/nope" absent "opening file '/d/nope': No such file or directory",+ says "opening a file refused" ReadFile "/d/x" denied "opening file '/d/x': Permission denied",+ says "reading a directory as a file" ReadFile "/d/dir" isDirectory "reading from file '/d/dir': Is a directory",+ says "GHC's own directory refusal reads as upstream's" ReadFile "/d/dir" ghcDirectory "reading from file '/d/dir': Is a directory",+ says "listing a file" ReadDirectory "/d/file" notDirectory "reading directory /d/file: Not a directory"+ ]++-- | The same failures through the IO evaluator, against a real tree.+-- POSIX only: Windows reports its own error text, and a path there is+-- spelled with a drive.+testSourceReadErrorsIO :: IO [Bool]+testSourceReadErrorsIO = do+ putStrLn "eval/source-read-errors-io"+ if SI.os == "mingw32"+ then do+ putStrLn " SKIP POSIX error text"+ pure []+ else testSourceReadErrorsBody++testSourceReadErrorsBody :: IO [Bool]+testSourceReadErrorsBody = do+ -- The temporary directory's own symlinks resolved (macOS keeps it under+ -- /var, a link), so the only link in play is the one made below.+ tmpBase <- Dir.canonicalizePath =<< getTemporaryDirectory+ let dir = tmpBase </> "nova-nix-test-source-read-errors"+ Dir.removePathForcibly dir+ createDirectoryIfMissing True (dir </> "sub")+ createDirectoryIfMissing True (dir </> "real")+ BS.writeFile (dir </> "file") "f"+ Dir.createDirectoryLink "real" (dir </> "link")+ let resolved = T.pack dir+ given = T.pack dir+ at name = "/. + " <> nixQuotedPath (dir </> name)+ fails label source expected = runTestM label (assertEvalErrorIO dir label source expected)+ results <-+ sequence+ [ fails "readFile of an absent file" ("builtins.readFile (" <> at "nope" <> ")") ("opening file '" <> resolved <> "/nope': No such file or directory"),+ fails "hashFile of an absent file" ("builtins.hashFile \"sha256\" (" <> at "nope" <> ")") ("opening file '" <> resolved <> "/nope': No such file or directory"),+ fails "readFile through a file" ("builtins.readFile (" <> at "file/x" <> ")") ("opening file '" <> resolved <> "/file/x': Not a directory"),+ fails "readFile of a directory" ("builtins.readFile (" <> at "sub" <> ")") ("reading from file '" <> resolved <> "/sub': Is a directory"),+ fails "readDir of an absent directory" ("builtins.readDir (" <> at "nope" <> ")") ("reading directory " <> resolved <> "/nope: No such file or directory"),+ fails "readDir of a file" ("builtins.readDir (" <> at "file" <> ")") ("reading directory " <> resolved <> "/file: Not a directory"),+ fails "readFileType of an absent path" ("builtins.readFileType (" <> at "nope" <> ")") ("path '" <> given <> "/nope' does not exist"),+ fails "interpolating an absent path" ("\"${" <> at "nope" <> "}\"") ("path '" <> resolved <> "/nope' does not exist"),+ fails "builtins.path of an absent path" ("builtins.path { path = " <> at "nope" <> "; }") ("path '" <> resolved <> "/nope' does not exist"),+ fails "importing an absent path" ("import (" <> at "nope" <> ")") ("path '" <> resolved <> "/nope' does not exist"),+ fails "importing a directory without default.nix" ("import (" <> at "sub" <> ")") ("opening file '" <> resolved <> "/sub/default.nix': No such file or directory"),+ -- Through a symlinked directory a read names the resolved path, as+ -- realisePath resolves it, and readFileType refuses the link.+ fails "readFile through a link names the resolved path" ("builtins.readFile (" <> at "link/nope" <> ")") ("opening file '" <> resolved <> "/real/nope': No such file or directory"),+ fails "readDir through a link names the resolved path" ("builtins.readDir (" <> at "link/nope" <> ")") ("reading directory " <> resolved <> "/real/nope: No such file or directory"),+ fails "import through a link names the resolved path" ("import (" <> at "link/nope" <> ")") ("path '" <> resolved <> "/real/nope' does not exist"),+ fails "readFileType through a link refuses the link" ("builtins.readFileType (" <> at "link/nope" <> ")") ("path '" <> given <> "/link' is a symlink")+ ]+ Dir.removePathForcibly dir+ pure results++-- | 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),+ -- A string is coerced to a path only when it spells an absolute+ -- one; upstream refuses a relative string outright.+ runTestIOFail "import refuses a relative string" testDir "import \"./literal.nix\"",+ -- 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\"" (mkStr "/foo/bar"),+ runTest "toPath rejects relative" $+ assertEvalFail "toPath-rel" "builtins.toPath \"foo/bar\"",+ runTest "toPath of a path gives its string" $+ assertEval "toPath-vpath" "builtins.toPath /foo/bar" (mkStr "/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: environment values as bytes+-- ---------------------------------------------------------------------------++-- | An environment value reaches the evaluator as its bytes, through+-- @builtins.getEnv@ and through @NIX_PATH@, as upstream's @getenv@ reads+-- it; each case's outcome was read off nix-instantiate 2.33.2 under the+-- same environment. A POSIX environment is bytes and a Windows one is+-- UTF-16, so each platform runs the cases its environment can express.+testEnvironmentBytes :: IO [Bool]+testEnvironmentBytes = do+ putStrLn "eval/environment-bytes"+ tmpBase <- getTemporaryDirectory+ let testDir = tmpBase </> "nova-nix-test-env-bytes"+ pkgDir = testDir </> "mypkg"+ bracket_+ ( do+ createDirectoryIfMissing True pkgDir+ BS.writeFile (pkgDir </> "default.nix") "42"+ BS.writeFile (testDir </> "name.bin") nonUtf8EnvName+ )+ (Dir.removePathForcibly testDir)+ $ do+ getEnvResults <- mapM (getEnvCase testDir) envByteCases+ nixPathResults <- mapM (nixPathCase testDir) envByteCases+ rest <-+ sequence+ [ withEnvBytes "a lookup passes a NIX_PATH entry whose prefix has no UTF-8 reading" (TE.encodeUtf8 nixPathVarName) ("\xFC=/nonexistent:mypkg=" <> TE.encodeUtf8 (T.pack pkgDir)) $+ assertEqual "lookup" (Right (VInt 42)) <$> evalNixIO testDir "import <mypkg>",+ withEnvBytes "getEnv looks a name up by its bytes" nonUtf8EnvName "hello" $+ expectBytes "name" "hello" <$> evalNixIO testDir "builtins.getEnv (builtins.readFile ./name.bin)",+ unpairedSurrogateGetEnv testDir,+ unpairedSurrogateNixPath testDir+ ]+ pure (getEnvResults ++ nixPathResults ++ rest)+ where+ getEnvCase dir (label, bytes) =+ withEnvBytes ("getEnv carries " <> label) (TE.encodeUtf8 testEnvVarName) bytes $+ expectBytes "getEnv" bytes <$> evalNixIO dir ("builtins.getEnv \"" <> testEnvVarName <> "\"")+ nixPathCase dir (label, bytes) =+ withEnvBytes ("NIX_PATH carries " <> label <> " into prefix and path") (TE.encodeUtf8 nixPathVarName) (bytes <> "=/x" <> bytes) $+ expectBytes "nixPath" (bytes <> "|/x" <> bytes) <$> evalNixIO dir "let e = builtins.head builtins.nixPath; in e.prefix + \"|\" + e.path"+ expectBytes label expected = \case+ Right (VStr actual _) -> assertEqual label expected actual+ other -> Fail (label <> ": expected a string, got " <> T.pack (show other))++-- | The values the environment cases carry: a byte with no UTF-8 reading,+-- a multibyte UTF-8 character, and nothing at all.+envByteCases :: [(Text, BS.ByteString)]+envByteCases =+ [ ("a byte with no UTF-8 reading", "\xFC"),+ ("a multibyte UTF-8 value", "caf\xC3\xA9"),+ ("an empty value", "")+ ]++testEnvVarName :: Text+testEnvVarName = "NOVA_NIX_TEST_ENV_BYTES"++nixPathVarName :: Text+nixPathVarName = "NIX_PATH"++-- | A variable name holding a byte with no UTF-8 reading.+nonUtf8EnvName :: BS.ByteString+nonUtf8EnvName = "NOVA_NIX_TEST_ENV_\xFC"++-- | Run one case with a variable set to the given bytes, restoring it+-- afterwards. A Windows environment is UTF-16 and cannot hold a byte+-- with no UTF-8 reading, so such a case skips there.+withEnvBytes :: Text -> BS.ByteString -> BS.ByteString -> IO TestResult -> IO Bool+withEnvBytes label name value check = do+ spelledName <- envSpelling name+ spelledValue <- envSpelling value+ case (,) <$> spelledName <*> spelledValue of+ Nothing -> True <$ putStrLn (" SKIP " <> T.unpack label <> ": a Windows environment cannot hold a byte with no UTF-8 reading")+ Just (nameSpelling, valueSpelling) -> runTestM label (withEnvVar nameSpelling valueSpelling check)++-- | The 'String' that base's environment setters and file functions, and+-- a spawned process's environment, write as the given bytes: on POSIX+-- they encode with the file-system encoding, whose round-trip escape+-- restores every byte its decoding escaped, and on Windows they write+-- UTF-16.+envSpelling :: BS.ByteString -> IO (Maybe String)+envSpelling bytes+ | SI.os == "mingw32" = pure (either (const Nothing) (Just . T.unpack) (TE.decodeUtf8' bytes))+ | otherwise = do+ encoding <- getFileSystemEncoding+ Just <$> BS.useAsCStringLen bytes (GHCForeign.peekCStringLen encoding)++-- | Run an action with a variable set (to an empty value too, which+-- 'setEnv' would unset), restoring what the variable held afterwards.+withEnvVar :: String -> String -> IO a -> IO a+withEnvVar name value action =+ bracket (lookupEnv name) restore (const (Blank.setEnv name value True >> action))+ where+ restore = maybe (unsetEnv name) (\saved -> Blank.setEnv name saved True)++-- | A Windows value holding an unpaired UTF-16 surrogate has no UTF-8+-- form, so getEnv refuses it rather than substituting U+FFFD.+unpairedSurrogateGetEnv :: FilePath -> IO Bool+unpairedSurrogateGetEnv dir+ | SI.os /= "mingw32" = True <$ putStrLn unpairedSurrogateSkip+ | otherwise =+ runTestM "getEnv refuses a value holding an unpaired surrogate" $+ withEnvVar (T.unpack testEnvVarName) "a\xD800" $ do+ result <- evalNixIO dir ("builtins.getEnv \"" <> testEnvVarName <> "\"")+ pure $ case result of+ Left err | unpairedSurrogateWords `T.isInfixOf` err -> Pass+ other -> Fail ("expected the unpaired-surrogate refusal, got " <> T.pack (show other))++-- | The same refusal for @NIX_PATH@, raised as the evaluation state is+-- created.+unpairedSurrogateNixPath :: FilePath -> IO Bool+unpairedSurrogateNixPath dir+ | SI.os /= "mingw32" = True <$ putStrLn unpairedSurrogateSkip+ | otherwise =+ runTestM "NIX_PATH holding an unpaired surrogate is refused" $+ withEnvVar (T.unpack nixPathVarName) "a=\xD800" $ do+ storeDir <- evalNixIOStoreDir+ outcome <- try (newEvalState storeDir dir)+ pure $ case outcome of+ Left (err :: IOException)+ | unpairedSurrogateWords `T.isInfixOf` T.pack (displayException err) -> Pass+ | otherwise -> Fail ("wrong error: " <> T.pack (displayException err))+ Right _ -> Fail "NIX_PATH was accepted"++unpairedSurrogateWords :: Text+unpairedSurrogateWords = "unpaired UTF-16 surrogate"++unpairedSurrogateSkip :: String+unpairedSurrogateSkip = " SKIP an unpaired UTF-16 surrogate exists only in a Windows environment"++-- ---------------------------------------------------------------------------+-- 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: Eval - Call depth (max-call-depth)+-- ---------------------------------------------------------------------------++-- | The issue's counter recursion to a given depth.+counterRecursion :: Int -> Text+counterRecursion n = "let f = n: if n == 0 then 0 else 1 + f (n - 1); in f " <> T.pack (show n)++-- | The identity applied to itself syntactically @n@ deep: a chain of pure+-- lambda frames with nothing else counted.+lambdaNesting :: Int -> Text+lambdaNesting n = "let f = x: x; in " <> T.replicate n "f (" <> "0" <> T.replicate n ")"++-- | Upstream's message, which the CLI prints after its own "error: ".+maxCallDepthMessage :: Text+maxCallDepthMessage = "stack overflow; max-call-depth exceeded"++-- | A result that must be the max-call-depth error, word for word.+expectMaxCallDepth :: Text -> Either Text NixValue -> TestResult+expectMaxCallDepth label result = case result of+ Left err+ | err == maxCallDepthMessage -> Pass+ | otherwise -> Fail (label <> ": wrong error: " <> err)+ Right val -> Fail (label <> ": expected the max-call-depth error but got: " <> T.pack (show val))++-- | IO evaluation under an explicit call-depth ceiling.+evalAtCeiling :: Word32 -> Text -> IO (Either Text NixValue)+evalAtCeiling limit source = case parseNix "." "<test>" source of+ Left err -> pure (Left (T.pack (show err)))+ Right expr -> do+ storeDir <- evalNixIOStoreDir+ st0 <- newEvalState storeDir "."+ let st = st0 {esCallDepth = topLevelCallDepth limit}+ runEvalIO st (eval (builtinEnv unrestrictedPolicy (esTimestamp st) (esSearchPaths st)) expr)++-- | The function-call nesting ceiling, upstream's max-call-depth (10000).+-- Every boundary here was read off nix-instantiate 2.33.2 and the 2.24.9+-- source: the count is the frames already active, checked on entry, so+-- 10001 nested lambda calls pass and 10002 fail, and the counter+-- recursion passes at 9999 and fails at 10000, its deepest frame being+-- the @n - 1@ forced inside @f 0@, a builtin call upstream's parser+-- desugars @-@ into. The cases under a ceiling of 10 pin what costs a+-- frame and what does not, each boundary taken from the same upstream.+testCallDepth :: IO [Bool]+testCallDepth = do+ putStrLn "eval/call-depth"+ sequence+ [ failsIO "the issue expression is refused with upstream's words" (counterRecursion 100000),+ runTestIO "the counter recursion one under the ceiling evaluates" "." (counterRecursion 9999) (VInt 9999),+ failsIO "the counter recursion at the ceiling is refused" (counterRecursion 10000),+ runTest "pure evaluation enforces the ceiling too" $+ assertEval "pure-under" (lambdaNesting 10001) (VInt 0),+ runTest "pure evaluation refuses past the ceiling with upstream's words" $+ expectMaxCallDepth "pure-over" (evalNix (lambdaNesting 10002)),+ -- Under a ceiling of n, n + 1 nested lambda calls pass and n + 2 fail.+ passesAt 10 "lambda nesting one past the ceiling passes" (lambdaNesting 11) (VInt 0),+ failsAt 10 "lambda nesting two past the ceiling fails" (lambdaNesting 12),+ -- The operators upstream desugars into builtin calls cost a frame+ -- while their operands are forced; its native operators cost none.+ passesAt 10 "a subtraction at the ceiling passes" (wrapped 10 "1 - 1") (VInt 0),+ failsAt 10 "a subtraction one past the ceiling fails" (wrapped 11 "1 - 1"),+ failsAt 10 "a multiplication one past the ceiling fails" (wrapped 11 "2 * 1"),+ failsAt 10 "a division one past the ceiling fails" (wrapped 11 "2 / 1"),+ failsAt 10 "a less-than one past the ceiling fails" (wrapped 11 "1 < 2"),+ failsAt 10 "a less-or-equal one past the ceiling fails" (wrapped 11 "1 <= 2"),+ failsAt 10 "a greater-than one past the ceiling fails" (wrapped 11 "2 > 1"),+ failsAt 10 "a greater-or-equal one past the ceiling fails" (wrapped 11 "2 >= 1"),+ failsAt 10 "a negation one past the ceiling fails" (wrapped 11 "-1"),+ passesAt 10 "an equality one past the ceiling passes" (wrapped 11 "1 == 1") (VBool True),+ passesAt 10 "an inequality one past the ceiling passes" (wrapped 11 "1 != 2") (VBool True),+ passesAt 10 "a not one past the ceiling passes" (wrapped 11 "!true") (VBool False),+ passesAt 10 "an addition one past the ceiling passes" (wrapped 11 "1 + 1") (VInt 2),+ passesAt 10 "a list concatenation one past the ceiling passes" (wrapped 11 "([1] ++ [2]) == [1 2]") (VBool True),+ passesAt 10 "an attrset update one past the ceiling passes" (wrapped 11 "({ a = 1; } // { b = 2; }) == { a = 1; b = 2; }") (VBool True),+ -- A builtin costs a frame even when partially applied.+ failsAt 10 "a partially applied builtin one past the ceiling fails" (wrapped 11 "builtins.add 0"),+ failsAt 10 "a builtin application one past the ceiling fails" (wrapped 11 "builtins.add 0 0"),+ -- A functor costs two frames: its inner call is a callFunction of its own.+ passesAt 10 "functor nesting of five passes" (functorNesting 5) (VInt 0),+ failsAt 10 "functor nesting of six fails" (functorNesting 6),+ -- A __toString coercion is a callFunction.+ passesAt 10 "a __toString coercion at the ceiling passes" (wrapped 10 "\"${s}\"") (mkStr "a"),+ failsAt 10 "a __toString coercion one past the ceiling fails" (wrapped 11 "\"${s}\""),+ -- A higher-order builtin's callback is applied where its result is+ -- forced, not inside the builtin's frame: upstream's prim_map returns+ -- app values. Forced by ==, which opens no frame, the callback at+ -- the ceiling passes; forced by elemAt, a frame deeper, it is+ -- elemAt's own call that fails.+ passesAt 10 "a map callback forced at the ceiling passes" (wrapped 10 "(builtins.map f [ 0 ]) == [ 0 ]") (VBool True),+ failsAt 10 "a map callback forced one past the ceiling fails" (wrapped 11 "(builtins.map f [ 0 ]) == [ 0 ]"),+ passesAt 10 "an elemAt over map under the ceiling passes" (wrapped 9 "builtins.elemAt (builtins.map f [ 0 ]) 0") (VInt 0),+ failsAt 10 "an elemAt over map at the ceiling fails" (wrapped 10 "builtins.elemAt (builtins.map f [ 0 ]) 0"),+ -- mapAttrs and zipAttrsWith apply @f name@ a frame below the value+ -- application: upstream builds two nested app values.+ passesAt 10 "a mapAttrs callback under the ceiling passes" (wrapped 9 "(builtins.mapAttrs (n: v: v) { a = 0; }).a") (VInt 0),+ failsAt 10 "a mapAttrs callback at the ceiling fails" (wrapped 10 "(builtins.mapAttrs (n: v: v) { a = 0; }).a"),+ passesAt 10 "a zipAttrsWith callback under the ceiling passes" (wrapped 9 "(builtins.zipAttrsWith (n: vs: 0) [ { a = 1; } ]).a") (VInt 0),+ failsAt 10 "a zipAttrsWith callback at the ceiling fails" (wrapped 10 "(builtins.zipAttrsWith (n: vs: 0) [ { a = 1; } ]).a"),+ -- A sort by builtins.lessThan itself opens no frame per comparison+ -- (upstream's prim_sort bypass); a lambda comparator costs its call+ -- and the < inside it.+ passesAt 10 "a sort by lessThan at the ceiling passes" (wrapped 10 "builtins.sort builtins.lessThan [ 2 1 ] == [ 1 2 ]") (VBool True),+ failsAt 10 "a sort by lessThan one past the ceiling fails" (wrapped 11 "builtins.sort builtins.lessThan [ 2 1 ] == [ 1 2 ]"),+ passesAt 10 "a sort by a lambda two under the ceiling passes" (wrapped 8 "builtins.sort (a: b: a < b) [ 2 1 ] == [ 1 2 ]") (VBool True),+ failsAt 10 "a sort by a lambda one under the ceiling fails" (wrapped 9 "builtins.sort (a: b: a < b) [ 2 1 ] == [ 1 2 ]"),+ passesAt 10 "the counter recursion to 9 passes under a ceiling of 10" (counterRecursion 9) (VInt 9),+ failsAt 10 "the counter recursion to 10 fails under a ceiling of 10" (counterRecursion 10),+ -- An eval error, not a throw: tryEval does not catch it.+ failsAt 10 "tryEval does not catch the ceiling" ("(builtins.tryEval (" <> lambdaNesting 12 <> ")).success"),+ -- A ceiling of 0 still admits one frame: the check is against the+ -- frames already active when the call is entered.+ passesAt 0 "one call passes under a ceiling of 0" "(x: x) 1" (VInt 1),+ failsAt 0 "a nested call fails under a ceiling of 0" "(x: x) ((x: x) 1)",+ passesAt 0 "a bare subtraction passes under a ceiling of 0" "1 - 1" (VInt 0),+ failsAt 0 "a subtraction inside a call fails under a ceiling of 0" "(x: x - 1) 1"+ ]+ where+ failsIO label source = evalNixIO "." source >>= runTest label . expectMaxCallDepth label+ passesAt limit label source expected = do+ result <- evalAtCeiling limit source+ runTest label $ assertRight label result $ assertEqual label expected+ failsAt limit label source = evalAtCeiling limit source >>= runTest label . expectMaxCallDepth label+ wrapped n inner = scope <> T.replicate n "f (" <> inner <> T.replicate n ")"+ functorNesting n = scope <> T.replicate n "g (" <> "0" <> T.replicate n ")"+ scope = "let f = x: x; g = { __functor = self: x: x; }; s = { __toString = self: \"a\"; }; in "++-- ---------------------------------------------------------------------------+-- 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 i686" $+ assertEqual "i686" ["i686-linux", "i686-windows"] (map platformToText [I686_Linux, I686_Windows]),+ runTest "platformToText other" $+ assertEqual "other" "riscv64-freebsd" (platformToText (OtherPlatform "riscv64-freebsd")),+ -- The i686 spellings parse to their own constructors, not to+ -- OtherPlatform, or extraPlatforms could never name them.+ runTest "textToPlatform round-trips every named platform" $+ assertEqual+ "round-trip"+ namedPlatforms+ (map (textToPlatform . platformToText) namedPlatforms),+ -- Upstream's default extra-platforms, and nothing more: the x86_64+ -- hosts run their i686 counterpart, every other host runs only itself.+ runTest "extraPlatforms is the i686 counterpart on x86_64 hosts only" $+ assertEqual+ "extra"+ [[I686_Linux], [I686_Windows], [], [], [], [], []]+ (map extraPlatforms [X86_64_Linux, X86_64_Windows, X86_64_Darwin, Aarch64_Darwin, Aarch64_Linux, I686_Linux, I686_Windows])+ ]+ where+ namedPlatforms = [X86_64_Linux, X86_64_Darwin, Aarch64_Darwin, X86_64_Windows, Aarch64_Linux, I686_Linux, I686_Windows]++-- ---------------------------------------------------------------------------+-- Tests: Batch H - derivation+-- ---------------------------------------------------------------------------++testBatchH :: IO [Bool]+testBatchH = do+ putStrLn "eval/builtins-batchH"+ let withTwoOutputs expr =+ "let D = derivation { name = \"x\"; system = \"s\"; builder = \"b\"; outputs = [ \"out\" \"dev\" ]; }; in " <> expr+ 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,+ -- The result is upstream's derivation.nix wrapper evaluated as the+ -- builtin, so its shape is the wrapper's: every row below was read+ -- from nix-instantiate 2.33.2 (#217).+ runTest "derivation result carries exactly upstream's attributes" $+ assertEval+ "drv-attrnames"+ (withTwoOutputs "builtins.attrNames D == [ \"all\" \"builder\" \"dev\" \"drvAttrs\" \"drvPath\" \"name\" \"out\" \"outPath\" \"outputName\" \"outputs\" \"system\" \"type\" ]")+ (VBool True),+ runTest "derivation outputName is the first output" $+ assertEval "drv-outputname" (withTwoOutputs "D.outputName") (mkStr "out"),+ runTest "derivation default output is the first listed, not out" $+ assertEval+ "drv-first-output"+ "(derivation { name = \"x\"; system = \"s\"; builder = \"b\"; outputs = [ \"dev\" \"out\" ]; }).outputName"+ (mkStr "dev"),+ runTest "every output attribute is a complete derivation set" $+ assertEval "drv-output-set" (withTwoOutputs "builtins.attrNames D.dev == builtins.attrNames D") (VBool True),+ runTest "an output set names its own output" $+ assertEval "drv-output-name" (withTwoOutputs "D.dev.outputName") (mkStr "dev"),+ runTest "output sets refer back to each other" $+ assertEval "drv-output-cycle" (withTwoOutputs "D.dev.out.outPath == D.outPath && D.dev.dev.out.dev.outputName == \"dev\"") (VBool True),+ runTest "derivation all lists every output set in order" $+ assertEval "drv-all" (withTwoOutputs "map (o: o.outputName) D.all == [ \"out\" \"dev\" ]") (VBool True),+ runTest "derivation drvAttrs is the argument set" $+ assertEval "drv-drvattrs" (withTwoOutputs "builtins.attrNames D.drvAttrs == [ \"builder\" \"name\" \"outputs\" \"system\" ]") (VBool True),+ runTest "derivation leaks no internal attribute" $+ assertEval "drv-no-internal" (withTwoOutputs "D ? _derivation") (VBool False),+ -- derivationStrict is reached only through outPath and drvPath, so a+ -- derivation forced to WHNF forces none of its inputs.+ runTest "forcing a derivation to WHNF forces no input" $+ assertEval+ "drv-whnf"+ "builtins.seq (derivation { name = \"x\"; system = \"s\"; builder = throw \"forced\"; }) \"whnf-ok\""+ (mkStr "whnf-ok"),+ runTest "derivationStrict returns drvPath and one path per output" $+ assertEval+ "drvstrict-shape"+ "builtins.attrNames (derivationStrict { name = \"x\"; system = \"s\"; builder = \"b\"; outputs = [ \"out\" \"dev\" ]; }) == [ \"dev\" \"drvPath\" \"out\" ]"+ (VBool True),+ runTest "derivation is a lambda with upstream's formals" $+ assertEval "drv-functionargs" "builtins.functionArgs derivation == { outputs = true; }" (VBool True)+ ]++-- ---------------------------------------------------------------------------+-- 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: structured attributes and the JSON they travel as+-- ---------------------------------------------------------------------------++-- | @__structuredAttrs@ end to end on the evaluation side, and the JSON+-- rules the evaluator and builder share. Every drvPath, rendering and+-- @.attrs.sh@ expected here was read off Nix 2.33.2 (nix-instantiate and+-- a build on aarch64-darwin), whose code for each is 2.24.9's.+testStructuredAttrs :: IO [Bool]+testStructuredAttrs = do+ putStrLn "derivation/structured-attrs"+ tmpBase <- getTemporaryDirectory+ let base = "name = \"sa\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; __structuredAttrs = true; "+ structured attrs = "(derivation { " <> base <> attrs <> " })"+ withoutContext expr = "builtins.unsafeDiscardStringContext " <> expr+ drvPathIs label attrs expected =+ runTest label (assertEval label (withoutContext (structured attrs <> ".drvPath")) (mkStr expected))+ dependency = "let d = derivation { name = \"d\"; system = \"x\"; builder = \"b\"; }; in "+ envResult <- evalDerivationIO tmpBase (structured "outputs = [ \"out\" \"dev\" \"lib\" ];")+ -- A file for the outPath that is a path, copied to the store as upstream+ -- copies it: "hello\n" named x is m5zd3qj7...-x there.+ let jsonDir = tmpBase </> "nova-nix-test-json-outpath"+ createDirectoryIfMissing True jsonDir+ BS.writeFile (jsonDir </> "x") "hello\n"+ copiedOutPath <- evalNixIO jsonDir "builtins.unsafeDiscardStringContext (builtins.toJSON { outPath = ./x; })"+ lengthOfPath <- evalNixIO jsonDir "builtins.stringLength ./x"+ substringOfPath <- evalNixIO jsonDir "builtins.substring 11 32 ./x"+ substringContext <- evalNixIO jsonDir "builtins.hasContext (builtins.substring 11 32 ./x)"+ discardedPath <- evalNixIO jsonDir "builtins.unsafeDiscardStringContext ./x"+ discardedContext <- evalNixIO jsonDir "builtins.hasContext (builtins.unsafeDiscardStringContext ./x)"+ copiedContext <- evalNixIO jsonDir "builtins.attrNames (builtins.getContext (builtins.toJSON { outPath = ./x; })) == [ \"/nix/store/m5zd3qj7c4bs4g5d8hqvs5pc5xicclfg-x\" ]"+ -- The IO evaluator, because it is the one with a store to copy into.+ uncopiedPath <- evalNixIO jsonDir "builtins.toJSON { __toString = s: ./x; }"+ uncopiedNested <- evalNixIO jsonDir "builtins.toJSON { __toString = s: { outPath = ./x; }; }"+ removeDirectoryRecursive jsonDir+ let jsonDirPath = mkStr ("\"" <> canonPathValue (T.pack (jsonDir </> "x")) <> "\"")+ sequence+ [ drvPathIs+ "a nested set and a mixed list are one JSON object"+ "nested = { a = 1; b = { c = [ \"x\" \"y\" ]; }; \"with space\" = true; }; list = [ \"a\" \"b c\" 1 2.5 null true false ];"+ "/nix/store/rkxyljjwm2miccr3ffbkvgm7fzmjqddg-sa.drv",+ drvPathIs+ "scalars render as nlohmann renders them"+ "i = 42; neg = -7; f = 1.0; f2 = 0.1; big = 1.0e20; small = 1.0e-7; b = true; n = null; s = \"str\\n\\t\\\"q\\\"\\\\\";"+ "/nix/store/hgzhpjmp6cpjj0r768xp982an303rh2q-sa.drv",+ drvPathIs+ "member names order by their bytes"+ "\"\233\" = 1; \"Z\" = 2; \"a\" = 3; \"_\" = 4; \"1x\" = 5;"+ "/nix/store/s2aaxxxqvmdh95wqq3sa75fqal0nv2vf-sa.drv",+ drvPathIs "a kept null is a JSON null" "n = null;" "/nix/store/r3880xygjwsqk8a4xiq8zrbhpanlf9a3-sa.drv",+ drvPathIs+ "__ignoreNulls leaves a null out of the object"+ "n = null; __ignoreNulls = true;"+ "/nix/store/cms7b67rf9bn7b376f64i05v1cw2chrh-sa.drv",+ drvPathIs+ "a placeholder stays a placeholder in the object"+ "here = builtins.placeholder \"out\";"+ "/nix/store/2iqn1i3rl4x9ll8a6j46k1rkk9c3fkd5-sa.drv",+ drvPathIs+ "a fixed-output derivation keeps its hash fields in the object"+ "outputHash = \"sha256-47DEQpj8HBSKTXA6xJbgU1CNMmz7W08F06XOTYP1hmM=\"; outputHashMode = \"flat\"; outputHashAlgo = \"sha256\";"+ "/nix/store/dp6sib5psffrm1krca5l9wn9nshspn7f-sa.drv",+ drvPathIs+ "a set under outputChecks is passed as it is"+ "outputChecks.out = { allowedReferences = [ ]; maxSize = 100; };"+ "/nix/store/xzkk2dxqm69akjjshlvk45s8yd6y1dbn-sa.drv",+ runTest "__structuredAttrs = false keeps one variable per attribute" $+ assertEval+ "sa-false"+ (withoutContext "(derivation { name = \"sa\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; __structuredAttrs = false; x = [ \"a\" \"b\" ]; }).drvPath")+ (mkStr "/nix/store/i9rm0fa7pbzrbrsaakninfa4232wmj7c-sa.drv"),+ runTest "every output of a structured derivation gets upstream's path" $+ assertEval+ "sa-outputs"+ (withoutContext (structured "outputs = [ \"out\" \"dev\" \"lib\" ];" <> ".dev.outPath"))+ (mkStr "/nix/store/jfnikb9k7953srg5mlp3mccmqaijvxyy-sa-dev"),+ runTest "the environment holds __json and the outputs, nothing else" $+ assertRight "sa-env" envResult $ \drv ->+ assertEqual+ "sa-env"+ [ ("__json", "{\"builder\":\"/bin/sh\",\"name\":\"sa\",\"outputs\":[\"out\",\"dev\",\"lib\"],\"system\":\"x86_64-linux\"}"),+ ("dev", "/nix/store/jfnikb9k7953srg5mlp3mccmqaijvxyy-sa-dev"),+ ("lib", "/nix/store/zpjd7d2l5jqzynv3377a36nk6rzb5il4-sa-lib"),+ ("out", "/nix/store/bs6ppbphi38qlbjfz67pfk86sx7l4vks-sa")+ ]+ (Map.toList (drvEnv drv)),+ -- toJSON's rules for a set, which the object above shares.+ runTest "toJSON of a set with outPath converts the outPath value, not its string" $+ assertEval "json-outpath-int" "builtins.toJSON { outPath = 1; }" (mkStr "1")+ `andThen` assertEval "json-outpath-set" "builtins.toJSON { outPath = { a = 1; }; }" (mkStr "{\"a\":1}")+ `andThen` assertEval "json-outpath-list" "builtins.toJSON { outPath = [ 1 { outPath = 2; } ]; }" (mkStr "[1,2]")+ `andThen` assertEval "json-outpath-null" "builtins.toJSON { outPath = null; }" (mkStr "null"),+ runTest "toJSON of a set whose outPath is a path copies it to the store, with context" $+ assertRight "json-outpath-path" copiedOutPath (assertEqual "json-outpath-path" (mkStr "\"/nix/store/m5zd3qj7c4bs4g5d8hqvs5pc5xicclfg-x\""))+ `andThen` assertRight "json-outpath-path-context" copiedContext (assertEqual "json-outpath-path-context" (VBool True)),+ runTest "a builtin taking a string copies a path to the store first" $+ assertRight "strlen-path" lengthOfPath (assertEqual "strlen-path" (VInt 45))+ `andThen` assertRight "substring-path" substringOfPath (assertEqual "substring-path-text" "m5zd3qj7c4bs4g5d8hqvs5pc5xicclfg" . stringBytes)+ `andThen` assertRight "substring-path-context" substringContext (assertEqual "substring-path-context" (VBool True))+ `andThen` assertRight "discard-path" discardedPath (assertEqual "discard-path" (mkStr "/nix/store/m5zd3qj7c4bs4g5d8hqvs5pc5xicclfg-x"))+ `andThen` assertRight "discard-path-context" discardedContext (assertEqual "discard-path-context" (VBool False)),+ runTest "toJSON of a __toString set returning a path keeps the path's own text, uncopied" $+ assertRight "json-tostring-path" uncopiedPath (assertEqual "json-tostring-path" jsonDirPath)+ `andThen` assertRight "json-tostring-nested" uncopiedNested (assertEqual "json-tostring-nested" jsonDirPath),+ runTest "toJSON of a __toString set returning a string keeps its context" $+ let json = "(builtins.toJSON { __toString = s: \"${d}\"; })"+ in assertEval "json-tostring-string" (dependency <> withoutContext json) (mkStr "\"/nix/store/74ii7m3ms7x609g9mcy8j52jfvpdizcp-d\"")+ `andThen` assertEval+ "json-tostring-context"+ (dependency <> "builtins.getContext " <> json <> " == { \"/nix/store/nzvhjdd45mdh8mkqm4vz4hdqsjrvkm8j-d.drv\" = { outputs = [ \"out\" ]; }; }")+ (VBool True),+ runTest "toJSON of a __toString set returning an integer is an error, not a coercion" $+ assertEvalFail "json-tostring-int" "builtins.toJSON { __toString = s: 1; }",+ drvPathIs+ "a structured attribute's outPath and __toString follow toJSON's rules"+ "o = { outPath = { inner = 1; }; }; i = { outPath = 1; }; t = { __toString = s: \"str\"; };"+ "/nix/store/rz7pfawzsr1a5ig11s1xssbsc2mxys9b-sa.drv",+ runTest "toJSON escapes backspace and form feed by name" $+ assertEval+ "json-bf"+ "builtins.toJSON (builtins.fromJSON \"\\\"\\\\u0008\\\\u000c\\\\u0001\\\"\")"+ (mkStr "\"\\b\\f\\u0001\""),+ runTest "__structuredAttrs must be a boolean" $+ assertEvalFail "sa-flag" "(derivation { name = \"sa\"; system = \"x\"; builder = \"b\"; __structuredAttrs = \"yes\"; }).drvPath",+ runTest "a structured system may not refer to a store path" $+ assertEvalError+ "sa-system-ctx"+ (dependency <> "(derivation { name = \"sa\"; builder = \"/bin/sh\"; __structuredAttrs = true; system = \"${d}\"; }).drvPath")+ "the string '/nix/store/74ii7m3ms7x609g9mcy8j52jfvpdizcp-d' is not allowed to refer to a store path (such as '!out!nzvhjdd45mdh8mkqm4vz4hdqsjrvkm8j-d.drv')",+ runTest "a structured output name may not refer to a store path" $+ assertEvalError+ "sa-output-ctx"+ (dependency <> structured "outputs = [ \"out\" \"${d}\" ];" <> ".drvPath")+ "the string '/nix/store/74ii7m3ms7x609g9mcy8j52jfvpdizcp-d' is not allowed to refer to a store path (such as '!out!nzvhjdd45mdh8mkqm4vz4hdqsjrvkm8j-d.drv')",+ runTest "a structured builder must be a string, not a value coerced to one" $+ assertEvalError+ "sa-builder"+ "(derivation { name = \"sa\"; system = \"x\"; __structuredAttrs = true; builder = 1; }).drvPath"+ "expected a string but found an integer: 1",+ runTest "a structured outputHash must be a string" $+ assertEvalError "sa-outputhash" (structured "outputHash = 1;" <> ".drvPath") "expected a string but found an integer: 1",+ runTest "a function cannot become JSON" $+ assertEvalFail "sa-function" (structured "f = x: x;" <> ".drvPath"),+ -- The JSON both halves share.+ runTest "JSON escapes control characters as nlohmann does" $+ assertEqual+ "json-escape"+ (TE.encodeUtf8 "\"a\\b\\f\\n\\r\\t\\u0001\\u001f\127/\\\"\\\\\233\"")+ (renderJson (JsonString "a\b\f\n\r\t\1\31\127/\"\\\233")),+ runTest "JSON numbers render as nlohmann renders them" $+ assertEqual+ "json-numbers"+ "[1.0,1e+20,1e-07,0.1,null,null,-7,18446744073709551615]"+ (renderJson (JsonArray [JsonFloat 1, JsonFloat 1e20, JsonFloat 1e-7, JsonFloat 0.1, JsonFloat (1 / 0), JsonFloat (0 / 0), JsonInt (-7), JsonInt 18446744073709551615])),+ runTest "upstream's .attrs.json reads back and renders to the same bytes" $+ assertRight "json-upstream" (decodeStructuredAttrs upstreamAttrsJson) $ \attrs ->+ assertEqual "json-upstream" (TE.encodeUtf8 upstreamAttrsJson) (encodeStructuredAttrs attrs),+ runTest "the .attrs.sh renderer writes what upstream writes" $+ assertRight "attrs-sh" (decodeStructuredAttrs upstreamAttrsJson) $ \attrs ->+ assertEqual "attrs-sh" upstreamAttrsShell (renderAttrsShell attrs),+ runTest "outputs become an object of placeholders" $+ assertEqual+ "outputs-object"+ (StructuredAttrs (Map.fromList [("outputs", JsonObject (Map.fromList [("dev", JsonString (hashPlaceholder "dev")), ("out", JsonString (hashPlaceholder "out"))]))]))+ (withOutputPlaceholders ["out", "dev"] (StructuredAttrs (Map.fromList [("outputs", JsonArray [JsonString "out", JsonString "dev"])]))),+ runTest "a null __json is an empty object, another non-object an error" $+ assertEqual "decode-shape" (Right (StructuredAttrs Map.empty)) (decodeStructuredAttrs "null")+ `andThen` assertLeft "decode-array" (decodeStructuredAttrs "[1]"),+ runTest "a list-of-strings attribute is read strictly" $+ let attrs = StructuredAttrs (Map.fromList [("good", JsonArray [JsonString "a", JsonString "b"]), ("mixed", JsonArray [JsonString "a", JsonInt 1]), ("scalar", JsonString "a b")])+ in assertEqual "strings-good" (Right (Just ["a", "b"])) (stringsAttr "good" attrs)+ `andThen` assertEqual "strings-absent" (Right Nothing) (stringsAttr "absent" attrs)+ `andThen` assertLeft "strings-mixed" (stringsAttr "mixed" attrs)+ `andThen` assertLeft "strings-scalar" (stringsAttr "scalar" attrs),+ runTest "JSON parsing decodes every escape, surrogate pairs included" $+ assertEqual+ "parse-escapes"+ (Right (JsonString "\"\\/\b\f\n\r\t\233\128512"))+ (parseJson "\"\\\"\\\\\\/\\b\\f\\n\\r\\t\\u00E9\\ud83d\\ude00\""),+ runTest "JSON parsing keeps integers apart from floats, as nlohmann's lexer does" $+ assertEqual+ "parse-numbers"+ (Right (JsonArray [JsonInt 18446744073709551615, JsonFloat 18446744073709551616, JsonInt (-9223372036854775808), JsonFloat (-9223372036854775809), JsonInt 0, JsonFloat 1, JsonFloat 100]))+ (parseJson "[18446744073709551615,18446744073709551616,-9223372036854775808,-9223372036854775809,-0,1.0,1E2]"),+ runTest "JSON parsing takes the last of a repeated name, whitespace and a byte order mark around" $+ assertEqual+ "parse-object"+ (Right (JsonObject (Map.fromList [("a", JsonArray [JsonInt 2, JsonNull])])))+ (parseJson "\65279 \n{ \"a\" : 1 , \"a\":[ 2 ,null ] }\r\n"),+ runTest "JSON parsing refuses what nlohmann refuses" $+ foldr+ (\bad acc -> assertLeft ("parse-refuses " <> bad) (parseJson bad) `andThen` acc)+ Pass+ ["\"a\tb\"", "01", "[1,]", "1e400", "{\"a\" 1}", "tru", "1 2", "\"\\ud800\"", "\"\\udc00\"", "\"\\x\"", "1.", "-", "\"unterminated", ""],+ runTestM "rendered JSON parses back to the value it came from" $ do+ result <-+ QC.quickCheckWithResult QC.stdArgs {QC.chatty = False} $+ QC.forAll (genJson 3) $ \json ->+ (parseJson =<< either (Left . T.pack . show) Right (TE.decodeUtf8' (renderJson json))) QC.=== Right json+ pure (if QC.isSuccess result then Pass else Fail (T.pack (QC.output result)))+ ]++-- | A subset of the @.attrs.json@ a Nix 2.33.2 build handed its builder+-- (the members holding no store path), byte for byte.+upstreamAttrsJson :: Text+upstreamAttrsJson =+ "{\"1x\":\"skipped\",\"badList\":[\"a\",[\"nested\"]],\"badObj\":{\"k\":{\"nested\":1}},\"big\":1099511627776,\"ctl\":\"a\\b\\f\\u0001\\u001f\127/\\\"\\\\z\",\"emptyList\":[],\"emptyObj\":{},\"f\":1.5,\"fbig\":1e+20,\"fi\":3.0,\"fl\":false,\"fltList\":[1.5],\"fltObj\":{\"a\":1,\"b\":2.5},\"fneg\":-1e+20,\"fneg2\":-2.5,\"fsmall\":0.1,\"ftrunc\":16777217.5,\"huge\":9223372036854775807,\"int\":42,\"list\":[\"a\",\"b c\",1,2.0,true,false,null],\"n\":null,\"neg\":-7,\"obj\":{\"b\":false,\"k\":\"v\",\"key with space\":1,\"q\":\"'\"},\"str\":\"it's a \\\"test\\\"\\n\",\"t\":true,\"uni\":\"\233\8364\128512\",\"with space\":\"skipped\"}"++-- | The lines of that build's @.attrs.sh@ for the members of+-- 'upstreamAttrsJson'. Missing ones (a non-integral number, a nested+-- array or object, a name bash cannot hold) are upstream's omissions;+-- @fbig@ and @fneg@ are aarch64's saturating float conversion.+upstreamAttrsShell :: Text+upstreamAttrsShell =+ "declare big=0\ndeclare ctl='a\8\12\1\31\127/\"\\z'\ndeclare -a emptyList=()\ndeclare -A emptyObj=()\ndeclare fbig=2147483647\ndeclare fi=3\ndeclare fl=\ndeclare fneg=-2147483648\ndeclare ftrunc=16777217\ndeclare huge=-1\ndeclare int=42\ndeclare -a list=('a' 'b c' 1 2 1 '' )\ndeclare n=''\ndeclare neg=-7\ndeclare -A obj=(['b']= ['k']='v' ['key with space']=1 ['q']=''\\''' )\ndeclare str='it'\\''s a \"test\"\n'\ndeclare t=1\ndeclare uni='\233\8364\128512'\n"++-- | JSON values up to a nesting depth, strings drawn from what the+-- renderer escapes and what it passes through as UTF-8, numbers across+-- both integer types and finite floats.+genJson :: Int -> QC.Gen Json+genJson depth = QC.oneof (scalars <> [nested | depth > 0])+ where+ scalars =+ [ pure JsonNull,+ JsonBool <$> QC.arbitrary,+ JsonInt . toInteger <$> (QC.arbitrary :: QC.Gen Int64),+ JsonInt . toInteger <$> (QC.arbitrary :: QC.Gen Word64),+ JsonFloat <$> (QC.arbitrary `QC.suchThat` (\d -> not (isNaN d || isInfinite d))),+ JsonString <$> genJsonText+ ]+ nested =+ QC.oneof+ [ JsonArray <$> QC.resize 4 (QC.listOf (genJson (depth - 1))),+ JsonObject . Map.fromList <$> QC.resize 4 (QC.listOf ((,) <$> genJsonText <*> genJson (depth - 1)))+ ]++-- | Text mixing the characters JSON escapes with plain and multi-byte ones.+genJsonText :: QC.Gen Text+genJsonText =+ T.pack <$> QC.listOf (QC.elements ['"', '\\', '/', '\b', '\f', '\n', '\r', '\t', '\0', '\31', '\127', 'a', ' ', '\233', '\8364', '\128512'])++-- ---------------------------------------------------------------------------+-- 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)+-- ---------------------------------------------------------------------------++-- | 'DepGraph.buildDepGraph' over a pure .drv lookup.+pureDepGraph :: (StorePath -> Either Text Derivation) -> Derivation -> StorePath -> Either Text DepGraph.DepGraph+pureDepGraph readDrv rootDrv rootPath = runIdentity (DepGraph.buildDepGraph (Identity . readDrv) rootDrv rootPath)++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 pureDepGraph 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 pureDepGraph 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 pureDepGraph 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 pureDepGraph 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 pureDepGraph 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 pureDepGraph readSingle drvB spB of+ Left _ -> Pass+ Right _ -> Fail "expected failure for missing drv",+ -- Single node topoSort+ runTest "single node topoSort" $ case pureDepGraph 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 pureDepGraph 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 pureDepGraph 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 pureDepGraph 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"+ -- Every tree this group materializes under the temp directory keys+ -- sibling names the way a store on that volume would.+ tmpSensitivity <- tempCaseSensitivity+ 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 tmpSensitivity dest (streamTestNarInfo streamTestNar) streamTestDigest source+ onDisk <- ExecBit.serialiseFromPath (volumeCaseHack tmpSensitivity) 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 tmpSensitivity (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 tmpSensitivity (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 tmpSensitivity (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 tmpSensitivity (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,+ -- 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 tmpSensitivity (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+ tmpSensitivity+ 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 -> unpackNarEntry tmpSensitivity (tmpDir </> "strict") entry+ source <- chunkReader (streamChunks 11 streamTestNar)+ streamOutcome <- Subst.consumeNarStream tmpSensitivity (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",+ -- verifyNar: the only constructor of VerifiedNar carries the bytes+ -- unchanged and the declared digest decoded, so the unpack sink+ -- and the registration read both from the witness.+ runTest "verifyNar carries the bytes and the declared digest" $+ case Subst.verifyNar (sampleNarInfo sampleNarHash) sampleNarBytes of+ Right verified+ | Subst.verifiedNarBytes verified /= sampleNarBytes -> Fail "verified bytes differ from the input"+ | CHash.formatNixHash (Subst.verifiedNarDigest verified) /= sampleNarHash -> Fail "carried digest is not the declared hash"+ | otherwise -> Pass+ Left err -> Fail ("expected acceptance, got: " <> err),+ -- verifyNar: either signed claim failing yields no witness at all.+ runTest "verifyNar rejects a hash or size mismatch" $+ let wrongHash = Subst.verifyNar (sampleNarInfo wrongNarHash) sampleNarBytes+ wrongSize = Subst.verifyNar ((sampleNarInfo sampleNarHash) {NarInfo.niNarSize = toInteger (BS.length sampleNarBytes) + 1}) sampleNarBytes+ in case (wrongHash, wrongSize) of+ (Left _, Left _) -> Pass+ other -> Fail ("expected both rejections, got: " <> T.pack (show other)),+ -- 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: what the archive grammar refuses (traversal+ -- shapes, a NUL) is refused on every host 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")]+ -- Cleared before each: the root is created exclusively, so+ -- a leftover from the previous name would refuse for a+ -- different reason than the one under test.+ unpackFresh name = do+ Subst.clearStaleDestination dest+ unpackNarEntry tmpSensitivity dest (evil name)+ results <- mapM unpackFresh ["..", ".", "", "a/b", "a\NULb"]+ Subst.clearStaleDestination dest+ pure $+ if all (\case Left err -> "NAR directory entry name" `T.isInfixOf` err; Right () -> False) results+ then Pass+ else Fail ("accepted an unsafe name: " <> T.pack (show results)),+ -- Names upstream's restore writes on POSIX as it finds them+ -- (posixOnlyTree, compared against nix-store --restore 2.33.2)+ -- materialize byte for byte through both unpack paths there, and+ -- the tree re-serialises to the NAR it came from. Windows refuses+ -- the first such name before writing any entry.+ runTestM "names upstream restores on POSIX materialize as their bytes" $ do+ tmpBase <- getTemporaryDirectory+ let refusal = "NAR directory entry name 'COM1' is a Windows device name"+ outcomes <- unpackedBothWays tmpSensitivity (tmpBase </> "nova-nix-test-unpack-posix-names") posixOnlyTree+ pure $+ if SI.os == "mingw32"+ then assertEqual "Windows refusals (strict, streamed)" (Left refusal, Left refusal) outcomes+ else assertEqual "trees read back (strict, streamed)" (Right posixOnlyTree, Right posixOnlyTree) outcomes,+ -- The same tree substituted into a temp store: the strict pipeline+ -- unpacks and seals it, then re-serialises the store path from disk+ -- against the narinfo's hash before it registers anything, so a+ -- success means the path's bytes reproduce the NAR. Windows+ -- refuses it before writing.+ runTestM "a store path holding names upstream restores on POSIX substitutes" $ do+ tmpBase <- getTemporaryDirectory+ let tmpStore = tmpBase </> "nova-nix-test-unpack-posix-store"+ sp = StorePath (T.replicate 32 "c") "posix-names"+ rawNar = NAR.serialise posixOnlyTree+ info = (streamTestNarInfo rawNar) {NarInfo.niStorePath = storePathToText defaultStoreDir sp}+ forceRemoveIfExists tmpStore+ store <- openStore (StoreDir tmpStore)+ result <- Subst.unpackAndVerify store sp info rawNar+ onDisk <- case result of+ Subst.SubstSuccess _ lock -> do+ releasePathLock lock+ Right <$> NAR.serialiseFromPath (storePathToFilePath (stDir store) sp)+ other -> pure (Left (T.pack (show other)))+ closeStore store+ forceRemoveIfExists tmpStore+ pure $ case onDisk of+ Left shown+ | SI.os == "mingw32" && "is a Windows device name" `T.isInfixOf` shown -> Pass+ _ -> assertEqual "store path read back" (Right posixOnlyTree) onDisk,+ -- A name and a link target with no UTF-8 reading. Linux holds+ -- both as bytes, as upstream restores them; a macOS volume cannot+ -- hold the name (APFS refuses it, HFS+ rewrites it) but keeps the+ -- target's bytes; Windows holds neither.+ runTestM "bytes with no UTF-8 reading materialize where the host holds them" $ do+ tmpBase <- getTemporaryDirectory+ let dest = tmpBase </> "nova-nix-test-unpack-raw-bytes"+ nameTree = NAR.NarDirectory [("caf\xE9", NAR.NarDirectory [("\xFF", NAR.NarRegular True "x")])]+ targetTree = NAR.NarDirectory [("link", NAR.NarSymlink "caf\xE9")]+ nameRefusal reason = "NAR directory entry name \"caf\\233\" is not valid UTF-8, " <> reason+ targetRefusal = "NAR symlink target \"caf\\233\" is not valid UTF-8, so it has no UTF-16 form for Windows"+ both outcome = (outcome, outcome)+ named <- unpackedBothWays tmpSensitivity dest nameTree+ targeted <- unpackedBothWays tmpSensitivity dest targetTree+ pure $ case SI.os of+ "mingw32" ->+ assertEqual+ "Windows refusals"+ (both (Left (nameRefusal "so it has no UTF-16 form for Windows")), both (Left targetRefusal))+ (named, targeted)+ "darwin" ->+ assertEqual+ "macOS refuses the name and keeps the target"+ (both (Left (nameRefusal "which a macOS filesystem cannot hold")), both (Right targetTree))+ (named, targeted)+ _ ->+ assertEqual+ "Linux keeps both"+ (both (Right nameTree), both (Right targetTree))+ (named, targeted),+ -- The bytes reach the filesystem without passing through the+ -- locale's codec: under an ASCII file-system encoding (a C locale)+ -- or a Latin-1 one a UTF-8 name still lands as its UTF-8 bytes.+ -- Spelled through 'FilePath', the first cannot encode the name and+ -- the second writes other bytes.+ runTestM "names land as their bytes whatever the file-system encoding" $ do+ tmpBase <- getTemporaryDirectory+ let dest = tmpBase </> "nova-nix-test-unpack-codec"+ tree =+ NAR.NarDirectory+ [ ("caf\xC3\xA9", NAR.NarRegular False "e"),+ ("link", NAR.NarSymlink "\xE2\x9C\x93"),+ ("\xE2\x9C\x93", NAR.NarRegular False "check")+ ]+ under encodingName = do+ encoding <- mkTextEncoding encodingName+ bracket getFileSystemEncoding setFileSystemEncoding $ \_ -> do+ setFileSystemEncoding encoding+ unpackedBothWays tmpSensitivity dest tree+ if SI.os == "mingw32"+ then Pass <$ putStrLn " SKIP a Windows path is UTF-16 and no locale codec spells it"+ else do+ outcomes <- mapM under ["ASCII//ROUNDTRIP", "ISO-8859-1//ROUNDTRIP"]+ pure (assertEqual "trees read back under ASCII and Latin-1 (strict, streamed)" (replicate 2 (Right tree, Right tree)) outcomes),+ -- 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 <- unpackNarEntry tmpSensitivity 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 <- unpackNarEntry tmpSensitivity 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 and on any volume the+ -- probe reports sensitive (Linux, case-sensitive APFS), upstream's+ -- case-hack renaming on a folding volume. The platform serialiser+ -- reverses whichever branch ran, so the tree re-serialises to its+ -- original NAR either way.+ 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 <- unpackNarEntry tmpSensitivity 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" || tmpSensitivity == CaseSensitive+ then do+ -- True names, not the hack: both spellings hold+ -- distinct contents.+ upper <- BS.readFile (dest </> "Foo")+ lower <- BS.readFile (dest </> "foo")+ pure (assertEqual "true names" ("upper", "lower") (upper, lower))+ else do+ -- The hack: the later variant carries the suffix.+ lower <- BS.readFile (dest </> "foo~nix~case~hack~1")+ pure (assertEqual "case-hacked name" "lower" lower)+ Subst.clearStaleDestination dest+ pure outcome,+ -- Every unpack creates exclusively, its root included, as+ -- upstream's restore does: a destination that already holds an+ -- entry is refused with upstream's wording and left as it was,+ -- not truncated or written into. Callers clear a stale+ -- destination first. Any volume answers this, so it is the+ -- exclusive create's check on every platform, Windows' CREATE_NEW+ -- included.+ runTestM "unpack refuses a destination that already exists" $ do+ tmpBase <- getTemporaryDirectory+ let dest = tmpBase </> "nova-nix-test-unpack-taken"+ fileTree = NAR.NarRegular False "new"+ dirTree = NAR.NarDirectory [("f", NAR.NarRegular False "new")]+ fileRefusal = "creating file '" <> quotedAsCxx dest <> "': File exists"+ dirOverFile = "creating directory '" <> T.pack dest <> "': File exists"+ dirOverDir = "path '" <> T.pack dest <> "' already exists"+ streamInto tree = do+ let raw = NAR.serialise tree+ source <- chunkReader [raw]+ outcome <- Subst.consumeNarStream tmpSensitivity dest (streamTestNarInfo raw) (CHash.hashBytes raw) source+ pure (either (Left . Subst.attemptFailureMessage) (const (Right ())) outcome)+ strictAndStreamed tree = (,) <$> unpackNarEntry tmpSensitivity dest tree <*> streamInto tree+ Subst.clearStaleDestination dest+ BS.writeFile dest "old"+ fileOnFile <- strictAndStreamed fileTree+ dirOnFile <- strictAndStreamed dirTree+ keptFile <- BS.readFile dest+ Subst.clearStaleDestination dest+ createDirectoryIfMissing True dest+ dirOnDir <- strictAndStreamed dirTree+ wroteInto <- Dir.doesPathExist (dest </> "f")+ Subst.clearStaleDestination dest+ let verdict+ | keptFile /= "old" = Fail "the existing file was written over"+ | wroteInto = Fail "the existing directory was written into"+ | otherwise =+ assertEqual+ "refusals, strict and streamed (a file over a file, a directory over a file, a directory over a directory)"+ ((Left fileRefusal, Left fileRefusal), (Left dirOverFile, Left dirOverFile), (Left dirOverDir, Left dirOverDir))+ (fileOnFile, dirOnFile, dirOnDir)+ pure verdict,+ -- std::quoted escapes a double quote and a backslash inside the+ -- path, so a name holding both pins upstream's exact text. A+ -- Windows name can hold neither.+ runTestM "the file refusal quotes its path as std::quoted does" $ do+ tmpBase <- getTemporaryDirectory+ let plain = tmpBase </> "nova-nix-test-unpack-q"+ dest = plain <> "\"b\\s"+ expected = "creating file '\"" <> T.pack plain <> "\\\"b\\\\s\"': File exists"+ tree = NAR.NarRegular False "new"+ raw = NAR.serialise tree+ if SI.os == "mingw32"+ then Pass <$ putStrLn " SKIP a Windows name cannot hold a double quote"+ else do+ Subst.clearStaleDestination dest+ BS.writeFile dest "old"+ strict <- unpackNarEntry tmpSensitivity dest tree+ source <- chunkReader [raw]+ streamed <- Subst.consumeNarStream tmpSensitivity dest (streamTestNarInfo raw) (CHash.hashBytes raw) source+ Subst.clearStaleDestination dest+ pure (assertEqual "refusals (strict, streamed)" (Left expected, Left expected) (strict, either (Left . Subst.attemptFailureMessage) (const (Right ())) streamed)),+ -- The exclusive create reaches as far as base's own open on+ -- Windows, where a raw CreateFileW stops at MAX_PATH (260+ -- characters) unless the path is spelled in the file namespace.+ -- Every component here is short; only the whole path is long.+ runTestM "unpack writes a file whose path is past MAX_PATH" $ do+ tmpBase <- getTemporaryDirectory+ let dest = tmpBase </> "nova-nix-test-unpack-long"+ component = replicate 60 'd'+ depth = 5 :: Int+ nest inner = NAR.NarDirectory [(TE.encodeUtf8 (T.pack component), inner)]+ tree = foldr (const nest) (NAR.NarDirectory [("leaf", NAR.NarRegular False "deep")]) [1 .. depth]+ leafPath = foldl' (</>) dest (replicate depth component) </> "leaf"+ raw = NAR.serialise tree+ readLeaf = either (pure . Left) (const (Right <$> BS.readFile leafPath))+ Subst.clearStaleDestination dest+ strict <- unpackNarEntry tmpSensitivity dest tree >>= readLeaf+ Subst.clearStaleDestination dest+ source <- chunkReader [raw]+ streamed <-+ Subst.consumeNarStream tmpSensitivity dest (streamTestNarInfo raw) (CHash.hashBytes raw) source+ >>= readLeaf . either (Left . Subst.attemptFailureMessage) (const (Right ()))+ Subst.clearStaleDestination dest+ pure $+ if length leafPath <= 260+ then Fail "the fixture path is not past MAX_PATH"+ else assertEqual "leaf contents (strict, streamed)" (Right "deep", Right "deep") (strict, streamed),+ -- The pure case-hack naming is a function of the probed+ -- sensitivity, not of the platform: on a folding volume later+ -- case variants gain the reversible suffix with a per-name+ -- counter, on a sensitive one every name keeps its spelling.+ runTest "caseHackDiskNames renames later case variants on a folding volume" $+ assertEqual+ "hack naming"+ [("Foo", "Foo"), ("foo", "foo~nix~case~hack~1"), ("fOO", "fOO~nix~case~hack~2"), ("bar", "bar")]+ (caseHackDiskNames CaseInsensitive ["Foo", "foo", "fOO", "bar"]),+ runTest "caseHackDiskNames keeps every name on a sensitive volume" $+ assertEqual+ "identity naming"+ [("Foo", "Foo"), ("foo", "foo"), ("fOO", "fOO"), ("bar", "bar")]+ (caseHackDiskNames CaseSensitive ["Foo", "foo", "fOO", "bar"]),+ -- The key behind both: case variants share it exactly on a+ -- folding volume.+ runTest "onDiskNameKey folds case only on a folding volume" $+ if onDiskNameKey CaseSensitive "Makefile" /= onDiskNameKey CaseSensitive "makefile"+ && onDiskNameKey CaseInsensitive "Makefile" == onDiskNameKey CaseInsensitive "makefile"+ then Pass+ else Fail "the key does not follow the probed sensitivity",+ -- A name with no UTF-8 reading has no characters to fold, so it+ -- keys as its bytes even on a folding volume.+ runTest "onDiskNameKey keys a name with no UTF-8 reading as its bytes" $+ assertEqual+ "keys of a Latin-1 and a UTF-8 spelling"+ ("caf\xE9", "CAF\xC3\x89")+ (onDiskNameKey CaseInsensitive "caf\xE9", onDiskNameKey CaseInsensitive "caf\xC3\xA9"),+ -- The probe against the filesystem's own answer: two sibling+ -- names differing only by case are two entries on a sensitive+ -- volume and one on a folding one.+ runTestM "probeCaseSensitivity agrees with the filesystem" $ do+ tmpBase <- getTemporaryDirectory+ let dir = tmpBase </> "nova-nix-test-case-probe"+ Subst.clearStaleDestination dir+ createDirectoryIfMissing True dir+ BS.writeFile (dir </> "Probe") "upper"+ BS.writeFile (dir </> "probe") "lower"+ entries <- Dir.listDirectory dir+ probed <- probeCaseSensitivity dir+ Subst.clearStaleDestination dir+ let observed = if length entries == 2 then CaseSensitive else CaseInsensitive+ pure (assertEqual "probe against the filesystem" observed probed),+ -- Where the volume's serialiser strips the suffix (a folding+ -- volume), an incoming name carrying it must reject (it could+ -- not round-trip); on a volume that keeps names apart 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 <- unpackNarEntry tmpSensitivity dest tree+ outcome <- case (tmpSensitivity, result) of+ (CaseInsensitive, Left _) -> pure Pass+ (CaseInsensitive, Right ()) -> pure (Fail "suffix-bearing name accepted where the serialiser strips")+ (CaseSensitive, Right ()) -> do+ kept <- BS.readFile (dest </> "x~nix~case~hack~1")+ pure (assertEqual "verbatim where names stay apart" "v" kept)+ (CaseSensitive, Left err) -> pure (Fail ("a volume keeping names apart 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)),+ -- The verify-fails branch of the strict pipeline: bytes that do+ -- not match the narinfo's signed NarHash or NarSize never reach+ -- the unpack sink, so nothing is written at the store path and+ -- the lock is free afterwards.+ runTestM "unpackAndVerify writes nothing for a NAR that fails verification" $ do+ tmpBase <- getTemporaryDirectory+ let tmpStore = tmpBase </> "nova-nix-test-unpack-unverified"+ forceRemoveIfExists tmpStore+ store <- openStore (StoreDir tmpStore)+ let sp = StorePath (T.replicate 32 "f") "unverified"+ rawNar = NAR.serialise (NAR.NarDirectory [("data.txt", NAR.NarRegular False "untrusted bytes")])+ consistent =+ NarInfo.NarInfo+ { NarInfo.niStorePath = storePathToText defaultStoreDir sp,+ NarInfo.niUrl = "nar/unverified.nar",+ NarInfo.niCompression = "none",+ NarInfo.niFileHash = Nothing,+ NarInfo.niFileSize = Nothing,+ NarInfo.niNarHash = CHash.formatNixHash (CHash.hashBytes rawNar),+ NarInfo.niNarSize = toInteger (BS.length rawNar),+ NarInfo.niReferences = [],+ NarInfo.niDeriver = Nothing,+ NarInfo.niSigs = [],+ NarInfo.niCA = Nothing+ }+ wrongHash = consistent {NarInfo.niNarHash = CHash.formatNixHash (CHash.hashBytes "not the nar")}+ wrongSize = consistent {NarInfo.niNarSize = toInteger (BS.length rawNar) + 1}+ destPath = storePathToFilePath (stDir store) sp+ hashResult <- Subst.unpackAndVerify store sp wrongHash rawNar+ sizeResult <- Subst.unpackAndVerify store sp wrongSize rawNar+ written <- Dir.doesPathExist destPath+ reLock <- tryAcquirePathLock (StoreDir tmpStore) sp+ mapM_ releasePathLock reLock+ closeStore store+ forceRemoveIfExists tmpStore+ pure $ case (hashResult, sizeResult, reLock) of+ (Subst.SubstError hashErr, Subst.SubstError sizeErr, Just _)+ | written -> Fail "an unverified NAR reached the store path"+ | not ("NAR hash mismatch" `T.isInfixOf` hashErr) -> Fail ("wrong hash error: " <> hashErr)+ | not ("NAR size mismatch" `T.isInfixOf` sizeErr) -> Fail ("wrong size error: " <> sizeErr)+ | otherwise -> Pass+ (_, _, Nothing) -> Fail "lock still held after a refused NAR"+ other -> Fail ("expected two SubstError results, got: " <> T.pack (show other))+ ]+ where+ chainCache url = Subst.CacheConfig url ["unused-key"] 10+ -- A tree unpacked into a fresh destination through the strict and+ -- the streaming path, each read back from disk, or the unpack's+ -- refusal. The read-back tree carries every name and link target+ -- as the bytes on disk, so equal trees serialise to the same NAR.+ unpackedBothWays sensitivity dest tree = do+ let raw = NAR.serialise tree+ streamed = do+ source <- chunkReader [raw]+ outcome <- Subst.consumeNarStream sensitivity dest (streamTestNarInfo raw) (CHash.hashBytes raw) source+ pure (either (Left . Subst.attemptFailureMessage) (const (Right ())) outcome)+ readBack unpack = do+ Subst.clearStaleDestination dest+ result <- unpack+ onDisk <- either (pure . Left) (const (Right <$> NAR.serialiseFromPath dest)) result+ Subst.clearStaleDestination dest+ pure onDisk+ strict <- readBack (unpackNarEntry sensitivity dest tree)+ viaStream <- readBack streamed+ pure (strict, viaStream)+ -- 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 processCaseHack 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 processCaseHack dir+ fromEntry <- NAR.narHash <$> ExecBit.serialiseFromPath processCaseHack 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 processCaseHack 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 processCaseHack 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)),+ -- Upstream's shape is a product token with the version; the comment+ -- after it is ours. What bot mitigation keys on is that there is one.+ runTest "the User-Agent is a nova-nix product token with a version" $+ assertEqual "ua" True ("nova-nix/" `BS.isPrefixOf` userAgent && BS.length userAgent > BS.length "nova-nix/ (+https://github.com/Novavero-AI/nova-nix)"),+ runTestM "withUserAgent sets exactly one User-Agent, replacing any present" $ do+ request0 <- HTTP.parseRequest "http://127.0.0.1/narinfo-hashes"+ let stale = request0 {HTTP.requestHeaders = [("User-Agent", "curl/0"), ("Accept", "*/*")]}+ pure (assertEqual "headers" [("User-Agent", userAgent), ("Accept", "*/*")] (HTTP.requestHeaders (withUserAgent stale))),+ -- 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 pureDepGraph 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 pureDepGraph 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 <-+ evalDerivationIO 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"+ ]+ pure $ assertRight "drv-ctx-inputs" result $ \drv ->+ if Map.null (drvInputDrvs drv)+ then Fail "expected non-empty drvInputDrvs"+ else Pass,+ -- 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 <-+ evalDerivationIO tmpBase $+ T.concat+ [ "let dep = ",+ depSrc,+ "; main = derivation { name = \"main\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; ref = dep.drvPath; }; ",+ "in main"+ ]+ pure $ case (depPathR, result) of+ (Right (VStr depPathBytes _), Right 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's 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)++-- | Names a directory on a case-sensitive volume for+-- 'testCaseSensitiveVolumeIO'. A Linux temp directory qualifies on+-- its own; on macOS a "Case-sensitive APFS" image attached with+-- hdiutil does, and the default volume does not.+caseSensitiveDirVar :: String+caseSensitiveDirVar = "NOVA_NIX_TEST_CASE_SENSITIVE_DIR"++-- | True-name materialization on a volume that does not fold: a store+-- opened there probes sensitive, and a NAR carrying @Makefile@ and+-- @makefile@ unpacks to two files under their own names through the+-- strict and the streaming substituter paths alike. A directory named+-- by 'caseSensitiveDirVar' is held to the claim: one that is missing+-- or probes folding FAILS, since the variable is how a run asserts a+-- volume (macOS CI attaches an image for it) and a typo must not read+-- as "no volume available". With the variable unset the temp+-- directory is tried, and skips where it folds.+testCaseSensitiveVolumeIO :: IO [Bool]+testCaseSensitiveVolumeIO = do+ putStrLn "store/case-sensitive-volume"+ named <- lookupEnv caseSensitiveDirVar+ case named of+ Just base -> do+ verdict <- namedDirVerdict base+ case verdict of+ Right () -> testCaseSensitiveVolumeBody base+ Left reason ->+ (: []) <$> runTest (T.pack caseSensitiveDirVar <> " names a case-sensitive directory") (Fail reason)+ Nothing -> do+ tmpBase <- getTemporaryDirectory+ sensitivity <- probeCaseSensitivity tmpBase+ case sensitivity of+ CaseInsensitive -> do+ putStrLn (" SKIP needs a case-sensitive volume (" ++ caseSensitiveDirVar ++ " names a directory on one)")+ pure []+ CaseSensitive -> testCaseSensitiveVolumeBody tmpBase+ where+ namedDirVerdict base = do+ present <- doesDirectoryExist base+ if not present+ then pure (Left (T.pack base <> " does not exist"))+ else do+ sensitivity <- probeCaseSensitivity base+ pure $ case sensitivity of+ CaseSensitive -> Right ()+ CaseInsensitive -> Left (T.pack base <> " probes " <> T.pack (show CaseInsensitive))++testCaseSensitiveVolumeBody :: FilePath -> IO [Bool]+testCaseSensitiveVolumeBody base = do+ let storeRoot = base </> "nova-nix-test-cs-store"+ storeDir = StoreDir storeRoot+ tree =+ NAR.NarDirectory+ [ ("Makefile", NAR.NarRegular False "upper"),+ ("makefile", NAR.NarRegular False "lower")+ ]+ rawNar = NAR.serialise tree+ digest = CHash.hashBytes rawNar+ narInfoOf nar sp =+ NarInfo.NarInfo+ { NarInfo.niStorePath = storePathToText defaultStoreDir sp,+ NarInfo.niUrl = "nar/case.nar",+ NarInfo.niCompression = "none",+ NarInfo.niFileHash = Nothing,+ NarInfo.niFileSize = Nothing,+ NarInfo.niNarHash = CHash.formatNixHash (CHash.hashBytes nar),+ NarInfo.niNarSize = fromIntegral (BS.length nar),+ NarInfo.niReferences = [],+ NarInfo.niDeriver = Nothing,+ NarInfo.niSigs = [],+ NarInfo.niCA = Nothing+ }+ narInfoFor = narInfoOf rawNar+ -- A name carrying the case-hack suffix beside the name it would+ -- strip to: read with the suffix stripped, the two collide.+ suffixTree =+ NAR.NarDirectory+ [ ("Makefile", NAR.NarRegular False "upper"),+ ("Makefile~nix~case~hack~1", NAR.NarRegular False "literal")+ ]+ suffixNar = NAR.serialise suffixTree+ suffixNames sp = do+ names <- sort <$> Dir.listDirectory (storePathToFilePath storeDir sp)+ pure (assertEqual "on-disk names" ["Makefile", "Makefile~nix~case~hack~1"] names)+ -- Both spellings present with their own contents, and nothing+ -- carrying the case-hack suffix.+ trueNames sp = do+ let dest = storePathToFilePath storeDir sp+ names <- Dir.listDirectory dest+ upper <- BS.readFile (dest </> "Makefile")+ lower <- BS.readFile (dest </> "makefile")+ pure $+ if sort names == ["Makefile", "makefile"] && upper == "upper" && lower == "lower"+ then Pass+ else Fail ("on disk: " <> T.pack (show names))+ forceRemoveIfExists storeRoot+ store <- openStore storeDir+ results <-+ sequence+ [ runTest "the store probes its volume as case-sensitive" $+ assertEqual "stCaseSensitivity" CaseSensitive (stCaseSensitivity store),+ runTestM "strict substitution materializes true names" $ do+ let sp = StorePath (T.replicate 32 "c") "case-strict"+ result <- Subst.unpackAndVerify store sp (narInfoFor sp) rawNar+ case result of+ Subst.SubstSuccess _ lock -> do+ releasePathLock lock+ trueNames sp+ other -> pure (Fail ("substitution failed: " <> T.pack (show other))),+ runTestM "streaming substitution materializes true names" $ do+ let sp = StorePath (T.replicate 32 "d") "case-stream"+ source <- chunkReader [rawNar]+ result <- Subst.materializeNarFromSource store sp (narInfoFor sp) digest [] Nothing source+ case result of+ Right _ -> trueNames sp+ Left err -> pure (Fail ("streaming substitution failed: " <> Subst.attemptFailureMessage err)),+ -- Nothing on a volume that keeps names apart is ever hacked, so+ -- a name carrying the suffix is a name like any other: both+ -- pipelines accept it, and their on-disk rechecks read it back+ -- as spelled (#236).+ runTestM "a name carrying the case-hack suffix substitutes verbatim, strict" $ do+ let sp = StorePath (T.replicate 32 "e") "suffix-strict"+ result <- Subst.unpackAndVerify store sp (narInfoOf suffixNar sp) suffixNar+ case result of+ Subst.SubstSuccess _ lock -> do+ releasePathLock lock+ suffixNames sp+ other -> pure (Fail ("substitution failed: " <> T.pack (show other))),+ runTestM "a name carrying the case-hack suffix substitutes verbatim, streaming" $ do+ let sp = StorePath (T.replicate 32 "f") "suffix-stream"+ source <- chunkReader [suffixNar]+ result <- Subst.materializeNarFromSource store sp (narInfoOf suffixNar sp) (CHash.hashBytes suffixNar) [] Nothing source+ case result of+ Right _ -> suffixNames sp+ Left err -> pure (Fail ("streaming substitution failed: " <> Subst.attemptFailureMessage err)),+ -- A store path already on this volume is read back as spelled+ -- too: an evaluation write holding the name reproduces its+ -- address and registers the NAR upstream dumps for it with+ -- use-case-hack off ('suffixPairNarHash').+ runTestM "a store path naming a file with the case-hack suffix registers under that name" $+ case makeFixedOutputPath "suffix-write" "sha256" "recursive" (sha256Digest suffixNar) of+ Left err -> pure (Fail ("test store path rejected: " <> T.pack (show err)))+ Right sp -> do+ let dest = storePathToFilePath storeDir sp+ createDirectoryIfMissing True dest+ BS.writeFile (dest </> "Makefile") "upper"+ BS.writeFile (dest </> "Makefile~nix~case~hack~1") "literal"+ materializeEvalStoreWrites store (Map.singleton (storePathToText defaultStoreDir sp) ([], WriteRecursive))+ info <- queryPathInfo (stDB store) sp+ pure $ case info of+ Just row -> assertEqual "registered NAR hash" suffixPairNarHash (piNarHash row)+ Nothing -> Fail "not registered",+ -- Evaluation reads a store path as the store does: copying one+ -- holding the name hashes it as spelled, where a source read+ -- would follow the process and, on macOS, strip it.+ runTestM "builtins.path reads a store path under its volume's case-hack mode" $ do+ let held = StorePath (T.replicate 32 "g") "suffix-read"+ heldPath = storePathToFilePath storeDir held+ createDirectoryIfMissing True heldPath+ BS.writeFile (heldPath </> "Makefile") "upper"+ BS.writeFile (heldPath </> "Makefile~nix~case~hack~1") "literal"+ copied <-+ evalNixIOStore+ storeDir+ "."+ ("builtins.path { path = " <> storePathToText defaultStoreDir held <> "; name = \"suffix-copy\"; }")+ let expected = storePathToText defaultStoreDir <$> makeFixedOutputPath "suffix-copy" "sha256" "recursive" (sha256Digest suffixNar)+ case (copied, expected) of+ (Right (VStr pathBytes _), Right expectedPath)+ | bytesText pathBytes /= expectedPath -> pure (Fail ("copied to " <> bytesText pathBytes <> ", expected " <> expectedPath))+ | otherwise -> maybe (pure (Fail "unparseable store path")) suffixNames (parseStorePath defaultStoreDir expectedPath)+ (Left err, _) -> pure (Fail ("eval failed: " <> err))+ other -> pure (Fail ("unexpected result: " <> T.pack (show other))),+ -- A source tree copied off this volume into one that folds the+ -- sharp-s pair: the two names are distinct here and one name+ -- there, so the copy refuses the second at the write rather+ -- than landing it on the first (#234).+ runTestM "a copy into a volume that folds a pair refuses the later name" $ do+ probed <- tempFoldsSharpS+ case probed of+ Left reason -> Pass <$ putStrLn (" SKIP " ++ reason)+ Right tmpBase -> do+ let src = base </> "nova-nix-test-cs-copy-src"+ dest = tmpBase </> "nova-nix-test-cs-copy-dest"+ contents = Map.fromList [(T.unpack sharpSUpper, "upper"), (T.unpack sharpSLower, "lower")]+ forceRemoveIfExists src+ Subst.clearStaleDestination dest+ createDirectoryIfMissing True src+ mapM_ (\(name, bytes) -> BS.writeFile (src </> name) bytes) (Map.toList contents)+ -- The copy walks the listing's order, which the volume+ -- chooses, so the refused name is whichever comes second.+ listed <- Dir.listDirectory src+ outcome <- try (copyPathInto src dest)+ landed <- traverse (\name -> (,) name <$> BS.readFile (dest </> name)) (take 1 listed)+ forceRemoveIfExists src+ Subst.clearStaleDestination dest+ pure $ case (listed, outcome) of+ ([earlier, later], Left (e :: IOException))+ | T.pack (ioeGetErrorString e) /= "creating file '" <> quotedAsCxx (dest </> later) <> "': File exists" ->+ Fail ("unexpected refusal: " <> T.pack (show e))+ | landed /= [(earlier, Map.findWithDefault "" earlier contents)] ->+ Fail ("the earlier entry was overwritten: " <> T.pack (show landed))+ | otherwise -> Pass+ (_, Right ()) -> Fail ("the copy succeeded; on disk: " <> T.pack (show landed))+ other -> Fail ("unexpected listing or outcome: " <> T.pack (show other)),+ -- A source holding a case-only pair, built against a store+ -- whose volume folds: evaluation names the two-file tree, and+ -- the store holds it as upstream's restore does, the later+ -- name case-hacked, registered under that address (#235).+ runTestM "a coerced source's case-only pair is stored case-hacked on a folding store" $+ caseOnlyPairBuild base canonPathValue,+ runTestM "builtins.path stores a case-only pair case-hacked on a folding store" $+ caseOnlyPairBuild base (\srcPath -> "builtins.path { path = " <> canonPathValue srcPath <> "; }")+ ]+ closeStore store+ forceRemoveIfExists storeRoot+ pure results++-- | What upstream records for 'caseOnlyPairBuild''s source on a+-- folding volume: nix-instantiate 2.33.2 with+-- @--store 'local?root=...'@ adds it at this path, @nix-store+-- --query --hash@ and @--size@ report this NAR hash and size, and the+-- tree holds @Makefile@ and @makefile~nix~case~hack~1@.+caseOnlyPairPath :: StorePath+caseOnlyPairPath = StorePath "1rn1vqr2y9f65qg5vflbfr5gkwllmp22" "src"++caseOnlyPairNarHash :: Text+caseOnlyPairNarHash = "sha256:04z1mpa501269w2aqjr3kq5k4y85hjzbisvfc3jgmw3dnd7va9hl"++caseOnlyPairNarSize :: Int+caseOnlyPairNarSize = 480++-- | Build a derivation whose @src@ is the given expression over a+-- source directory named @src@ holding @Makefile@ and @makefile@, on a+-- case-sensitive base, against a store in the temp directory, and+-- hold the result to what upstream stores ('caseOnlyPairPath'). Skips+-- where the temp directory's volume does not fold.+caseOnlyPairBuild :: FilePath -> (Text -> Text) -> IO TestResult+caseOnlyPairBuild base srcExpr = do+ tmpBase <- getTemporaryDirectory+ sensitivity <- probeCaseSensitivity tmpBase+ case sensitivity of+ CaseSensitive -> Pass <$ putStrLn " SKIP the temp directory's volume does not fold case"+ CaseInsensitive -> do+ shell <- findTestShell+ let srcRoot = base </> "nova-nix-test-cs-pair"+ srcDir = srcRoot </> "src"+ tmpStore = tmpBase </> "nova-nix-test-cs-pair-store"+ nixEscape = T.concatMap (\c -> if c == '\\' then "\\\\" else if c == '"' then "\\\"" else T.singleton c)+ source =+ T.concat+ [ "derivation { name = \"pair\"; system = builtins.currentSystem; ",+ "builder = \"" <> nixEscape shell <> "\"; ",+ "args = [\"-c\" \"cd $src && for f in *; do echo \\\"$f\\\"; done > $out\"]; ",+ "src = " <> srcExpr (T.pack srcDir) <> "; }"+ ]+ forceRemoveIfExists srcRoot+ forceRemoveIfExists tmpStore+ createDirectoryIfMissing True srcDir+ BS.writeFile (srcDir </> "Makefile") "upper\n"+ BS.writeFile (srcDir </> "makefile") "lower\n"+ built <- evalAndBuild (StoreDir tmpStore) source+ verdict <- case built of+ Left err -> pure (Fail err)+ Right (BuildFailure msg _, store) -> Fail msg <$ closeStore store+ Right (BuildSuccess outSP, store) -> do+ let stored = storePathToFilePath (stDir store) caseOnlyPairPath+ names <- sort <$> Dir.listDirectory stored+ contents <- traverse (BS.readFile . (stored </>)) names+ info <- queryPathInfo (stDB store) caseOnlyPairPath+ seen <- BS.readFile (storePathToFilePath (stDir store) outSP)+ closeStore store+ let hacked = ["Makefile", "makefile~nix~case~hack~1"]+ pure $ case info of+ Nothing -> Fail "the source path is not registered"+ Just row+ | names /= hacked || contents /= ["upper\n", "lower\n"] ->+ Fail ("stored as " <> T.pack (show (zip names contents)))+ | piNarHash row /= caseOnlyPairNarHash || piNarSize row /= caseOnlyPairNarSize ->+ Fail ("registered " <> piNarHash row <> " / " <> T.pack (show (piNarSize row)))+ | seen /= "Makefile\nmakefile~nix~case~hack~1\n" ->+ Fail ("the builder saw " <> T.pack (show seen))+ | otherwise -> Pass+ forceRemoveIfExists srcRoot+ forceRemoveIfExists tmpStore+ pure verdict++-- | The NAR hash of a directory holding @Makefile@ (@upper@) and+-- @Makefile~nix~case~hack~1@ (@literal@): @nix-store --option+-- use-case-hack false --dump@ 2.33.2 piped to @nix-hash --flat@. With+-- the setting at its macOS default the dump strips the suffix and+-- fails on the collision.+suffixPairNarHash :: Text+suffixPairNarHash = "sha256:1x31cqjvkihkh3wbzq00z8xplwbf7i0bpk3g9vqs403lh3f5b4gs"++-- | A path as upstream's formatter streams a @std::filesystem::path@:+-- through @std::quoted@, in double quotes with a double quote or a+-- backslash inside escaped by a backslash.+quotedAsCxx :: FilePath -> Text+quotedAsCxx path = "\"" <> T.replace "\"" "\\\"" (T.replace "\\" "\\\\" (T.pack path)) <> "\""++-- | U+1E9E (capital sharp s) and U+00DF (small sharp s): a pair APFS+-- folds to one name and 'onDiskNameKey' keeps apart, since 'toUpper'+-- leaves U+00DF as it is (#234). Escaped, as the source is ASCII.+sharpSUpper, sharpSLower :: Text+sharpSUpper = "STRA\x1E9E\&E"+sharpSLower = "stra\xDF\&e"++-- | Whether the volume holding a directory folds the sharp-s pair,+-- answered by the volume itself: one spelling is created and the other+-- looked up. The store's probe says whether a volume folds case at+-- all, not which pairs its table folds.+sharpSFolds :: FilePath -> IO Bool+sharpSFolds dir = do+ let probeDir = dir </> "nova-nix-test-sharp-s-probe"+ Subst.clearStaleDestination probeDir+ createDirectoryIfMissing True probeDir+ BS.writeFile (probeDir </> T.unpack sharpSUpper) "probe"+ folds <- Dir.doesFileExist (probeDir </> T.unpack sharpSLower)+ Subst.clearStaleDestination probeDir+ pure folds++-- | Whether the suite's temp directory folds the sharp-s pair: the+-- store's own probe first, then the volume for the pair itself.+tempFoldsSharpS :: IO (Either String FilePath)+tempFoldsSharpS = do+ tmpBase <- getTemporaryDirectory+ sensitivity <- probeCaseSensitivity tmpBase+ case sensitivity of+ CaseSensitive -> pure (Left "the temp directory's volume does not fold case")+ CaseInsensitive -> do+ folds <- sharpSFolds tmpBase+ pure $+ if folds+ then Right tmpBase+ else Left "the temp directory's volume folds case but keeps U+1E9E and U+00DF apart"++-- | A sibling pair the volume folds but 'onDiskNameKey' keeps apart+-- (#234). The exclusive create refuses the later entry at the write,+-- in upstream's wording and naming it, through both unpack paths, and+-- the earlier entry keeps its own content: nothing lands on it, and+-- two directories do not merge. A link needed no new exclusivity+-- (link creation never replaces a name); its refusal is pinned+-- alongside. Skips with the reason where the temp directory's volume+-- does not fold the pair.+testFoldingVolumeIO :: IO [Bool]+testFoldingVolumeIO = do+ putStrLn "store/folding-volume"+ probed <- tempFoldsSharpS+ case probed of+ Left reason -> do+ putStrLn (" SKIP " ++ reason)+ pure []+ Right tmpBase -> testFoldingVolumeBody (tmpBase </> "nova-nix-test-folding")++testFoldingVolumeBody :: FilePath -> IO [Bool]+testFoldingVolumeBody root = do+ let upperName = T.unpack sharpSUpper+ lowerName = T.unpack sharpSLower+ pair earlier later =+ NAR.NarDirectory [(TE.encodeUtf8 sharpSUpper, earlier), (TE.encodeUtf8 sharpSLower, later)]+ filePair = pair (NAR.NarRegular False "upper") (NAR.NarRegular False "lower")+ linkPair = pair (NAR.NarRegular False "upper") (NAR.NarSymlink "a")+ dirPair =+ pair+ (NAR.NarDirectory [("a", NAR.NarRegular False "upper")])+ (NAR.NarDirectory [("b", NAR.NarRegular False "lower")])+ dirOverFilePair = pair (NAR.NarRegular False "upper") (NAR.NarDirectory [("b", NAR.NarRegular False "lower")])+ fileRefusal dest = "creating file '" <> quotedAsCxx (dest </> lowerName) <> "': File exists"+ dirRefusal dest = "path '" <> T.pack (dest </> lowerName) <> "' already exists"+ dirOverFileRefusal dest = "creating directory '" <> T.pack (dest </> lowerName) <> "': File exists"+ linkRefusal dest = "creating symlink from '" <> T.pack (dest </> lowerName) <> "' -> 'a': File exists"+ upperFileKept dest = (== "upper") <$> BS.readFile (dest </> upperName)+ upperDirKept dest = (== ["a"]) <$> Dir.listDirectory (dest </> upperName)+ narInfoOf raw =+ NarInfo.NarInfo+ { NarInfo.niStorePath = "/nix/store/" <> T.replicate 32 "f" <> "-folding",+ NarInfo.niUrl = "nar/folding.nar",+ NarInfo.niCompression = "none",+ NarInfo.niFileHash = Nothing,+ NarInfo.niFileSize = Nothing,+ NarInfo.niNarHash = CHash.formatNixHash (CHash.hashBytes raw),+ NarInfo.niNarSize = fromIntegral (BS.length raw),+ NarInfo.niReferences = [],+ NarInfo.niDeriver = Nothing,+ NarInfo.niSigs = [],+ NarInfo.niCA = Nothing+ }+ strictly = unpackNarEntry CaseInsensitive+ streamed dest tree = do+ let raw = NAR.serialise tree+ source <- chunkReader [raw]+ outcome <- Subst.consumeNarStream CaseInsensitive dest (narInfoOf raw) (CHash.hashBytes raw) source+ pure (either (Left . Subst.attemptFailureMessage) (const (Right ())) outcome)+ refusedAtWrite label unpack tree refusal kept = runTestM label $ do+ let dest = root </> "out"+ Subst.clearStaleDestination dest+ result <- unpack dest tree+ earlierKept <- kept dest+ Subst.clearStaleDestination dest+ pure $+ if earlierKept+ then assertEqual "unpack outcome" (Left (refusal dest)) result+ else Fail ("the earlier entry was overwritten or merged into; unpack returned " <> T.pack (show result))+ forceRemoveIfExists root+ createDirectoryIfMissing True root+ results <-+ sequence+ [ refusedAtWrite "strict unpack refuses the later file of a folded pair" strictly filePair fileRefusal upperFileKept,+ refusedAtWrite "streaming unpack refuses the later file of a folded pair" streamed filePair fileRefusal upperFileKept,+ refusedAtWrite "strict unpack refuses the later directory of a folded pair" strictly dirPair dirRefusal upperDirKept,+ refusedAtWrite "streaming unpack refuses the later directory of a folded pair" streamed dirPair dirRefusal upperDirKept,+ refusedAtWrite "strict unpack refuses a directory folding onto a file" strictly dirOverFilePair dirOverFileRefusal upperFileKept,+ refusedAtWrite "streaming unpack refuses a directory folding onto a file" streamed dirOverFilePair dirOverFileRefusal upperFileKept,+ refusedAtWrite "a link folding onto an earlier file is refused at the link" strictly linkPair linkRefusal upperFileKept+ ]+ forceRemoveIfExists root+ pure results++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),+ -- The first main run of the cache job pushed a store's first valid+ -- path alphabetically, a .drv, and the cache refused its narinfo:+ -- a store registers recipes as valid paths, a cache serves outputs.+ runTest "outputPathsOnly drops derivations and keeps everything else" $+ assertEqual+ "outputs"+ ["hello", "hello.drv.tar", "src"]+ (map spName (outputPathsOnly [StorePath (T.replicate 32 "a") "hello", StorePath (T.replicate 32 "b") "hello.drv", StorePath (T.replicate 32 "c") "hello.drv.tar", StorePath (T.replicate 32 "d") "src"])),+ runTest "isDerivationPath is the .drv suffix on the name, not the hash" $+ assertEqual "drv" [True, False, False] (map isDerivationPath [StorePath (T.replicate 32 "a") "x.drv", StorePath (T.replicate 32 "b") "x", StorePath (T.replicate 32 "c") "drv"]),+ 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))+ ]++-- | The case sensitivity of the suite's temp directory, so a test+-- materializing a NAR there keys sibling names the way a store on that+-- volume would.+tempCaseSensitivity :: IO CaseSensitivity+tempCaseSensitivity = getTemporaryDirectory >>= probeCaseSensitivity++-- | 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"+ let xmlDocument body = mkStr ("<?xml version='1.0' encoding='utf-8'?>\n<expr>\n" <> body <> "</expr>\n")+ 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)"+ (xmlDocument " <float value=\"0.333333\" />\n"),+ runTest "toXML float plain decimal" $+ assertEval+ "xml-float-centi"+ "builtins.toXML 0.01"+ (xmlDocument " <float value=\"0.01\" />\n"),+ runTest "toXML integral float drops the point" $+ assertEval+ "xml-float-integral"+ "builtins.toXML 100.0"+ (xmlDocument " <float value=\"100\" />\n"),+ runTest "toXML float large exponent" $+ assertEval+ "xml-float-e21"+ "builtins.toXML 1.0e21"+ (xmlDocument " <float value=\"1e+21\" />\n"),+ runTest "toXML float small exponent" $+ assertEval+ "xml-float-e-5"+ "builtins.toXML 2.5e-5"+ (xmlDocument " <float value=\"2.5e-05\" />\n"),+ -- The document is upstream's XMLWriter output (value-to-xml.cc and+ -- xml-writer.cc at 2.24.9): every element two spaces deeper than the+ -- one around it, a newline in a value written as a character+ -- reference. Each expected document is nix-instantiate 2.24.9's and+ -- 2.33.2's (#221).+ runTest "toXML indents each element under the one around it" $+ assertEval+ "xml-nesting"+ "builtins.toXML { s = \"a<b>&\\\"q'\\n\\tz\"; l = [ 1 { } ]; }"+ ( xmlDocument+ ( " <attrs>\n <attr name=\"l\">\n <list>\n <int value=\"1\" />\n"+ <> " <attrs>\n </attrs>\n </list>\n </attr>\n"+ <> " <attr name=\"s\">\n <string value=\"a<b>&"q'
\tz\" />\n"+ <> " </attr>\n </attrs>\n"+ )+ ),+ runTest "toXML writes a lambda's argument pattern and leaves a primop unevaluated" $+ assertEval+ "xml-functions"+ "builtins.toXML [ (x: x) (args@{ z, ... }: 1) ({ b, a }: a) builtins.map (builtins.map (x: x)) ]"+ ( xmlDocument+ ( " <list>\n <function>\n <varpat name=\"x\" />\n </function>\n"+ <> " <function>\n <attrspat ellipsis=\"1\" name=\"args\">\n <attr name=\"z\" />\n"+ <> " </attrspat>\n </function>\n"+ <> " <function>\n <attrspat>\n <attr name=\"a\" />\n <attr name=\"b\" />\n"+ <> " </attrspat>\n </function>\n"+ <> " <unevaluated />\n <unevaluated />\n </list>\n"+ )+ ),+ runTest "toXML writes a derivation in full once and repeated after" $+ assertEval+ "xml-derivations"+ "builtins.toXML [ { type = \"derivation\"; drvPath = \"/p\"; outPath = \"/o\"; a = 1; } { type = \"derivation\"; drvPath = \"/p\"; } { type = \"derivation\"; outPath = 2; } ]"+ ( xmlDocument+ ( " <list>\n <derivation drvPath=\"/p\" outPath=\"/o\">\n"+ <> " <attr name=\"a\">\n <int value=\"1\" />\n </attr>\n"+ <> " <attr name=\"drvPath\">\n <string value=\"/p\" />\n </attr>\n"+ <> " <attr name=\"outPath\">\n <string value=\"/o\" />\n </attr>\n"+ <> " <attr name=\"type\">\n <string value=\"derivation\" />\n </attr>\n"+ <> " </derivation>\n"+ <> " <derivation drvPath=\"/p\">\n <repeated />\n </derivation>\n"+ <> " <derivation>\n <repeated />\n </derivation>\n </list>\n"+ )+ ),+ runTest "toXML keeps the contexts of the strings it writes" $+ assertEval+ "xml-context"+ "let d = derivation { name = \"x\"; system = \"s\"; builder = \"b\"; }; in builtins.getContext (builtins.toXML { s = \"${d}\"; }) == builtins.getContext \"${d}\""+ (VBool True),+ -- 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)),+ -- A 32-bit derivation on a 64-bit host of the same OS is native, as+ -- upstream's extra-platforms makes it, launcher or no launcher; on+ -- any other host it is as foreign as anything else.+ runTest "an i686 derivation spawns natively on its x86_64 host, whatever is configured" $+ assertEqual+ "wow64"+ [SpawnNative, SpawnNative, SpawnNative, SpawnNative]+ [ spawnFor X86_64_Windows (wrapperConfig []) (drvFor I686_Windows),+ spawnFor X86_64_Windows (wrapperConfig [("i686-windows", "/usr/bin/wine")]) (drvFor I686_Windows),+ spawnFor X86_64_Linux (wrapperConfig []) (drvFor I686_Linux),+ spawnFor X86_64_Linux (wrapperConfig [("i686-linux", "/opt/qemu")]) (drvFor I686_Linux)+ ],+ runTest "an i686 derivation elsewhere is refused or launched like any foreign system" $+ assertEqual+ "elsewhere"+ [SpawnUnsupported "i686-windows", SpawnThrough "/usr/bin/wine", SpawnUnsupported "i686-linux"]+ [ spawnFor X86_64_Linux (wrapperConfig []) (drvFor I686_Windows),+ spawnFor X86_64_Linux (wrapperConfig [("i686-windows", "/usr/bin/wine")]) (drvFor I686_Windows),+ spawnFor Aarch64_Darwin (wrapperConfig []) (drvFor I686_Linux)+ ],+ -- 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),+ -- The per-URL retry policy, with its effects injected: upstream's five+ -- attempts and doubling delay from 250 ms, spent on transient failures+ -- only.+ runTest "retry delay is upstream's 250 ms doubling, with jitter on the exponent" $+ assertEqual+ "delays"+ ([250, 500, 1000, 2000], 353, 2828)+ ( map (\retry -> retryDelayMs defaultFetchRetryPolicy retry 0) [1 .. 4],+ retryDelayMs defaultFetchRetryPolicy 1 0.5,+ retryDelayMs defaultFetchRetryPolicy 4 0.5+ ),+ runTestM "retry policy retries a transient failure with backoff until it succeeds" $ do+ calls <- newIORef (0 :: Int)+ sleeps <- newIORef []+ warnings <- newIORef []+ let effects =+ RetryEffects+ { reSleepMs = \ms -> atomicModifyIORef' sleeps (\seen -> (seen ++ [ms], ())),+ reJitter = pure 0,+ reWarn = \msg -> atomicModifyIORef' warnings (\seen -> (seen ++ [msg], ()))+ }+ attempt = do+ n <- atomicModifyIORef' calls (\c -> (c + 1, c + 1))+ pure (if n < 3 then Left (Subst.TransientFailure "HTTP 503 fetching u") else Right ("verified" :: Text))+ result <- retryTransient defaultFetchRetryPolicy effects attempt+ seenSleeps <- readIORef sleeps+ seenWarnings <- readIORef warnings+ pure+ ( assertEqual+ "two retries"+ (Right "verified", [250, 500], ["HTTP 503 fetching u; retrying in 250 ms", "HTTP 503 fetching u; retrying in 500 ms"])+ (result, seenSleeps, seenWarnings)+ ),+ runTestM "retry policy gives up after five attempts" $ do+ calls <- newIORef (0 :: Int)+ sleeps <- newIORef []+ let effects = RetryEffects {reSleepMs = \ms -> atomicModifyIORef' sleeps (\seen -> (seen ++ [ms], ())), reJitter = pure 0, reWarn = \_ -> pure ()}+ attempt = atomicModifyIORef' calls (\c -> (c + 1, ())) >> pure (Left (Subst.TransientFailure "timeout") :: Either Subst.AttemptFailure ())+ result <- retryTransient defaultFetchRetryPolicy effects attempt+ seenCalls <- readIORef calls+ seenSleeps <- readIORef sleeps+ pure (assertEqual "spent" (Left (Subst.TransientFailure "timeout"), 5, [250, 500, 1000, 2000]) (result, seenCalls, seenSleeps)),+ runTestM "retry policy does not retry a fatal failure" $ do+ calls <- newIORef (0 :: Int)+ let effects = RetryEffects {reSleepMs = \_ -> fail "slept on a fatal failure", reJitter = pure 0, reWarn = \_ -> fail "warned on a fatal failure"}+ attempt = atomicModifyIORef' calls (\c -> (c + 1, ())) >> pure (Left (Subst.FatalFailure "HTTP 404") :: Either Subst.AttemptFailure ())+ result <- retryTransient defaultFetchRetryPolicy effects attempt+ seenCalls <- readIORef calls+ pure (assertEqual "once" (Left (Subst.FatalFailure "HTTP 404"), 1) (result, seenCalls)),+ -- The classification tables, against upstream's (filetransfer.cc at+ -- 2.24.9): which statuses and which client failures a retry can outlive.+ runTest "status classification matches upstream's transfer layer" $+ let transient status = case fetchStatusFailure "u" status of+ Subst.TransientFailure _ -> True+ Subst.FatalFailure _ -> False+ in assertEqual+ "classes"+ (replicate 6 True, replicate 10 False)+ ( map transient [HTTP.status408, HTTP.status429, HTTP.status500, HTTP.status502, HTTP.status503, HTTP.status504],+ map transient [HTTP.status404, HTTP.status410, HTTP.status401, HTTP.status403, HTTP.status407, HTTP.status400, HTTP.status418, HTTP.status501, HTTP.status505, HTTP.status511]+ ),+ runTest "status verdicts follow upstream's FileTransfer::Error, in its order" $+ assertEqual+ "verdicts"+ (replicate 2 NotFound ++ replicate 3 Forbidden ++ replicate 5 Misc ++ replicate 6 Transient)+ ( map+ statusError+ [ HTTP.status404,+ HTTP.status410,+ HTTP.status401,+ HTTP.status403,+ HTTP.status407,+ HTTP.status400,+ HTTP.status418,+ HTTP.status501,+ HTTP.status505,+ HTTP.status511,+ HTTP.status408,+ HTTP.status429,+ HTTP.status500,+ HTTP.status502,+ HTTP.status503,+ HTTP.status504+ ]+ ),+ runTest "exception classification retries transport failures only" $+ let transient err = case fetchExceptionFailure "u" err of+ Subst.TransientFailure _ -> True+ Subst.FatalFailure _ -> False+ request = HTTP.defaultRequest+ in assertEqual+ "classes"+ (replicate 4 True, replicate 4 False)+ ( map+ transient+ [ HTTP.HttpExceptionRequest request HTTP.ConnectionTimeout,+ HTTP.HttpExceptionRequest request (HTTP.ResponseBodyTooShort 100 5),+ HTTP.HttpExceptionRequest request HTTP.ResponseTimeout,+ HTTP.HttpExceptionRequest request (HTTP.InternalException (toException connectionReset))+ ],+ map+ transient+ [ HTTP.InvalidUrlException "ftp://x" "Invalid scheme",+ HTTP.HttpExceptionRequest request (HTTP.InvalidDestinationHost ""),+ HTTP.HttpExceptionRequest request HTTP.TlsNotSupported,+ HTTP.HttpExceptionRequest request (HTTP.TooManyRedirects [])+ ]+ ),+ -- The attempt boundary: an IOException there is a failure writing+ -- the output, and an asynchronous exception is never caught.+ runTestM "a failure writing the download is fatal" $ do+ outcome <- throwIO (userError "disk full") `catches` transferFailureHandlers "u" :: IO (Either Subst.AttemptFailure ())+ pure $ case outcome of+ Left (Subst.FatalFailure _) -> Pass+ other -> Fail ("expected a fatal failure, got: " <> T.pack (show other)),+ runTestM "an interrupt passes through the attempt boundary untouched" $ do+ outcome <- try (throwIO ThreadKilled `catches` transferFailureHandlers "u" :: IO (Either Subst.AttemptFailure ()))+ pure $ case outcome of+ Left err | fromException err == Just ThreadKilled -> Pass+ other -> Fail ("expected ThreadKilled to propagate, got: " <> T.pack (show other)),+ -- The body reader: the HTTP client wraps a failure opening the+ -- response but not one reading its body, where a reset mid-body+ -- arrives as a bare IOException. Read through transferBodyReader it+ -- is a transport failure, retried like any other.+ runTestM "a connection reset mid-body is a transport failure, not a failed write" $ do+ let reader = transferBodyReader HTTP.defaultRequest (throwIO connectionReset)+ outcome <- (Right <$> reader) `catches` transferFailureHandlers "u"+ pure $ case outcome of+ Left (Subst.TransientFailure _) -> Pass+ other -> Fail ("expected a transient failure, got: " <> T.pack (show other)),+ runTestM "the body reader passes the client's own failures and an interrupt through unchanged" $ do+ let tooShort = HTTP.HttpExceptionRequest HTTP.defaultRequest (HTTP.ResponseBodyTooShort 100 5)+ clientFailure <- try (transferBodyReader HTTP.defaultRequest (throwIO tooShort))+ interrupt <- try (transferBodyReader HTTP.defaultRequest (throwIO ThreadKilled))+ chunk <- transferBodyReader HTTP.defaultRequest (pure "bytes")+ pure $ case (clientFailure, interrupt) of+ (Left (HTTP.HttpExceptionRequest _ (HTTP.ResponseBodyTooShort 100 5)), Left ThreadKilled)+ | chunk == "bytes" -> Pass+ other -> Fail ("unexpected body reader outcomes: " <> T.pack (show (other, chunk))),+ -- End to end against the loopback fixture, under a policy with a 1 ms+ -- base delay: a torn transfer and a server error spend all five+ -- attempts before the next mirror; a 404 and a hash mismatch spend one.+ runTestM "HTTP mirrors retry a torn transfer, skip a missing or corrupt one, and stop on success" $+ httpCase "fallback" False ["/missing", "/bad", "/partial", "/good", "/unused"] True (["/missing", "/bad"] ++ replicate 5 "/partial" ++ ["/good"]),+ runTestM "HTTP fetch retains single-url compatibility" $+ httpCase "legacy" True ["/good"] True ["/good"],+ runTestM "a server error is retried five times before the next mirror" $+ httpCase "retried" False ["/error", "/good"] True (replicate 5 "/error" ++ ["/good"]),+ runTestM "HTTP failure leaves no output or registration" $+ httpCase "errors" False ["/error", "/missing"] False (replicate 5 "/error" ++ ["/missing"]),+ runTestM "hash failure leaves no corrupt output or registration" $+ httpCase "corrupt" False ["/bad", "/partial"] False ("/bad" : replicate 5 "/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"+ -- What http-client 0.7's body reader raises when the server resets+ -- the connection mid-body.+ connectionReset = mkIOError resourceVanishedErrorType "Network.Socket.recvBuf" Nothing Nothing+ withStore label action = do+ tmpBase <- getTemporaryDirectory+ let root = tmpBase </> ("nova-nix-test-mirrors-" ++ label)+ dir = StoreDir (root </> "store")+ config = (defaultBuildConfig dir) {bcTmpDir = root </> "build", bcFetchRetry = defaultFetchRetryPolicy {frpBaseDelayMs = 1}}+ 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))))++-- | The substituter's narinfo and NAR requests under the transfer+-- layer's retry policy, against a loopback cache. The policy's effects+-- are recorded rather than slept, so each case states the requests the+-- cache saw and the backoff the policy chose.+testSubstituterRetry :: IO [Bool]+testSubstituterRetry = do+ putStrLn "substituter/retry"+ case signedNarInfo of+ Left err -> (: []) <$> runTest "substituter retry setup" (Fail (T.pack err))+ Right (publicKey, narInfoText) ->+ let narInfoOk = Send (httpResponse "200 OK" (TE.encodeUtf8 narInfoText))+ narOk = Send (httpResponse "200 OK" nar)+ unavailable = Send (httpResponse "503 Service Unavailable" "")+ substitute label narInfoReply narReply =+ withHttpServer (route narInfoReply narReply) $ \base requests _ -> do+ tmpBase <- getTemporaryDirectory+ sleeps <- newIORef []+ warnings <- newIORef []+ let root = tmpBase </> ("nova-nix-test-subst-retry-" ++ label)+ cache = Subst.CacheConfig base [publicKey] 40+ effects =+ RetryEffects+ { reSleepMs = \ms -> atomicModifyIORef' sleeps (\seen -> (seen ++ [ms], ())),+ reJitter = pure 0,+ reWarn = \msg -> atomicModifyIORef' warnings (\seen -> (seen ++ [msg], ()))+ }+ bracket_ (forceRemoveIfExists root) (forceRemoveIfExists root) $+ bracket (openStore (StoreDir root)) closeStore $ \store -> do+ result <- Subst.trySubstituteWith defaultFetchRetryPolicy effects store [cache] sp+ outcome <- case result of+ Subst.SubstSuccess _ lock -> do+ releasePathLock lock+ Right <$> BS.readFile (storePathToFilePath (stDir store) sp </> "data")+ other -> pure (Left other)+ (,,,,) base outcome <$> requests <*> readIORef sleeps <*> readIORef warnings+ in sequence $+ [ runTestM "a narinfo that fails once with a 503 is fetched on the retry" $ do+ (base, outcome, seen, sleeps, warnings) <-+ substitute "narinfo-503" (\earlier -> if earlier == 0 then unavailable else narInfoOk) (const narOk)+ pure+ ( assertEqual+ "substituted after one retry"+ (Right payload, [narInfoPath, narInfoPath, narPath], [250], ["HTTP 503 fetching " <> base <> "/" <> hash <> ".narinfo; retrying in 250 ms"])+ (outcome, seen, sleeps, warnings)+ ),+ runTestM "a narinfo that keeps failing spends upstream's five attempts and doubling delay" $ do+ (_, outcome, seen, sleeps, _) <- substitute "narinfo-down" (const unavailable) (const narOk)+ pure $ case outcome of+ Left (Subst.SubstError _) -> assertEqual "attempts" (replicate 5 narInfoPath, [250, 500, 1000, 2000]) (seen, sleeps)+ other -> Fail ("expected a substitution error, got: " <> T.pack (show other)),+ runTestM "a NAR download answering 501 stops after one request" $ do+ (_, outcome, seen, sleeps, _) <-+ substitute "nar-501" (const narInfoOk) (const (Send (httpResponse "501 Not Implemented" "")))+ pure $ case outcome of+ Left (Subst.SubstError err)+ | "HTTP 501" `T.isInfixOf` err -> assertEqual "one request" ([narInfoPath, narPath], []) (seen, sleeps)+ other -> Fail ("expected an HTTP 501 substitution error, got: " <> T.pack (show other)),+ -- One attempt follows the client's redirect limit, then stops.+ runTestM "a NAR redirect loop stops after one attempt" $ do+ (_, outcome, seen, sleeps, _) <-+ substitute "nar-redirect" (const narInfoOk) (const (Send "HTTP/1.1 302 Found\r\nLocation: /nar/retry.nar\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"))+ pure $ case outcome of+ Left (Subst.SubstError err)+ | "too many redirects" `T.isInfixOf` err ->+ assertEqual "one attempt" (narInfoPath : replicate (HTTP.redirectCount HTTP.defaultRequest + 1) narPath, []) (seen, sleeps)+ other -> Fail ("expected a redirect-loop substitution error, got: " <> T.pack (show other)),+ runTestM "a torn NAR transfer is retried from its first byte" $ do+ let torn = "HTTP/1.1 200 OK\r\nContent-Length: " <> TE.encodeUtf8 (T.pack (show (BS.length nar))) <> "\r\nConnection: close\r\n\r\n" <> BS.take 10 nar+ (base, outcome, seen, sleeps, warnings) <-+ substitute "nar-torn" (const narInfoOk) (\earlier -> if earlier == 0 then Send torn else narOk)+ let warnedTorn = case warnings of+ [warning] -> ("download error fetching " <> base <> "/nar/retry.nar: ResponseBodyTooShort") `T.isPrefixOf` warning+ _ -> False+ pure $+ if warnedTorn+ then assertEqual "substituted after one retry" (Right payload, [narInfoPath, narPath, narPath], [250]) (outcome, seen, sleeps)+ else Fail ("unexpected warnings: " <> T.pack (show warnings))+ ]+ -- Upstream's NotFound and Forbidden: a binary cache store+ -- reads both as the file not being in that cache.+ ++ [ runTestM ("a narinfo answered " <> status <> " is a miss, not retried") $ do+ (_, outcome, seen, sleeps, _) <-+ substitute ("narinfo-" ++ statusCode status) (const (Send (httpResponse (TE.encodeUtf8 status) ""))) (const narOk)+ pure (assertEqual "miss" (Left Subst.SubstNotFound, [narInfoPath], []) (outcome, seen, sleeps))+ | status <- notInCache+ ]+ -- A NAR the narinfo named and the cache no longer holds is+ -- upstream's SubstituteGone: one request, never retried.+ ++ [ runTestM ("a NAR answered " <> status <> " stops after one request") $ do+ (_, outcome, seen, sleeps, _) <-+ substitute ("nar-" ++ statusCode status) (const narInfoOk) (const (Send (httpResponse (TE.encodeUtf8 status) "")))+ pure $ case outcome of+ Left (Subst.SubstError _) -> assertEqual "one request" ([narInfoPath, narPath], []) (seen, sleeps)+ other -> Fail ("expected a substitution error, got: " <> T.pack (show other))+ | status <- notInCache+ ]+ where+ notInCache :: [Text]+ notInCache = ["404 Not Found", "410 Gone", "401 Unauthorized", "403 Forbidden", "407 Proxy Authentication Required"]+ statusCode = T.unpack . T.take 3+ hash = T.replicate 32 "b"+ sp = StorePath hash "retry"+ narInfoPath = "/" <> TE.encodeUtf8 hash <> ".narinfo"+ narPath = "/nar/retry.nar"+ payload = "retried payload\n"+ nar = NAR.serialise (NAR.NarDirectory [("data", NAR.NarRegular False payload)])+ unsigned =+ NarInfo.NarInfo+ { NarInfo.niStorePath = "/nix/store/" <> hash <> "-retry",+ NarInfo.niUrl = "nar/retry.nar",+ NarInfo.niCompression = "none",+ NarInfo.niFileHash = Nothing,+ NarInfo.niFileSize = Nothing,+ NarInfo.niNarHash = CHash.formatNixHash (CHash.hashBytes nar),+ NarInfo.niNarSize = fromIntegral (BS.length nar),+ NarInfo.niReferences = [],+ NarInfo.niDeriver = Nothing,+ NarInfo.niSigs = [],+ NarInfo.niCA = Nothing+ }+ signedNarInfo = do+ secretKey <- Signing.parseSecretKey sigTestKeyText+ publicKey <- Signing.toPublicKey secretKey+ sig <- Signing.sign secretKey unsigned+ pure (Signing.renderPublicKey publicKey, NarInfo.renderNarInfo unsigned {NarInfo.niSigs = [sig]})+ route narInfoReply narReply path earlier+ | path == narInfoPath = narInfoReply earlier+ | path == narPath = narReply earlier+ | otherwise = Send (httpResponse "500 Internal Server Error" "unexpected request")++-- | @pkgs/windows/fetchurl.nix@ evaluated from the checkout. The+-- @mirror://@ expansion is what a source derivation records, so a+-- mirrors.nix refresh or a regex edit moves every drvPath built on it;+-- these pin the list against nixpkgs' order and the eval-time errors.+testPkgsFetchurl :: IO [Bool]+testPkgsFetchurl = do+ putStrLn "pkgs/fetchurl"+ cwd <- Dir.getCurrentDirectory+ let fetchurl = "(import " <> nixQuotedPath (cwd </> "pkgs" </> "windows" </> "fetchurl.nix") <> ")"+ urlsOf source = "builtins.concatStringsSep \"\\n\" (" <> fetchurl <> " { " <> source <> " sha256 = \"" <> hash <> "\"; }).urls"+ failsWith expected result = case result of+ Left err+ | expected `T.isInfixOf` err -> Pass+ | otherwise -> Fail ("expected an error naming " <> expected <> ", got: " <> err)+ Right val -> Fail ("expected an eval error, got: " <> T.pack (show val))+ sequence+ [ runTestIO+ "mirror://gnu expands to the https hosts of mirrors.nix, in nixpkgs' order"+ cwd+ (urlsOf "url = \"mirror://gnu/hello/hello-2.12.3.tar.gz\";")+ (mkStr (T.intercalate "\n" (map (<> "hello/hello-2.12.3.tar.gz") gnuHosts))),+ runTestIO+ "a plain URL in urls passes through beside an expanded one"+ cwd+ (urlsOf "urls = [ \"https://example.invalid/sed-4.10.tar.xz\" \"mirror://gnu/sed/sed-4.10.tar.xz\" ];")+ (mkStr (T.intercalate "\n" ("https://example.invalid/sed-4.10.tar.xz" : map (<> "sed/sed-4.10.tar.xz") gnuHosts))),+ runTestM "an unknown mirror site is an eval error naming the recipe's URL" $+ failsWith "fetchurl: unknown mirror site 'nope' in 'mirror://nope/x'" <$> evalNixIO cwd (urlsOf "url = \"mirror://nope/x\";"),+ runTestM "a mirror URL with no path is an eval error" $+ failsWith "fetchurl: malformed mirror URL 'mirror://gnu'" <$> evalNixIO cwd (urlsOf "url = \"mirror://gnu\";")+ ]+ where+ hash :: Text+ hash = "0d5f60154382fee10b114a1c34e785d8b1f492073ae2d3a6f7b147687b366aa0"+ -- The gnu set of pkgs/windows/mirrors.nix without its ftp:// host.+ gnuHosts :: [Text]+ gnuHosts =+ [ "https://ftpmirror.gnu.org/",+ "https://ftp.nluug.nl/pub/gnu/",+ "https://mirrors.kernel.org/gnu/",+ "https://mirror.ibcp.fr/pub/gnu/",+ "https://mirror.dogado.de/gnu/",+ "https://mirror.tochlab.net/pub/gnu/",+ "https://ftp.gnu.org/pub/gnu/"+ ]++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 unrestrictedPolicy (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"+ -- The address the source's content derives, as evaluation+ -- would name it: a restore refuses any other.+ entry <- NAR.serialiseFromPath srcDir+ case makeFixedOutputPath "mat-locked" "sha256" "recursive" (sha256Digest (NAR.serialise entry)) of+ Left err -> pure (Fail ("test store path rejected: " <> T.pack (show err)))+ Right guarded -> do+ let 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"+ ]++-- | The collector's pure core: the reachability walk over the references+-- map, and the sweep plan over the valid set, the live set, and the store+-- directory's entries. No store is opened.+testGcPure :: IO [Bool]+testGcPure = do+ putStrLn "store/gc-pure"+ let sd = StoreDir "/s"+ at name = T.pack (unStoreDir sd </> name)+ refs = referencesMap [(at "a", at "b"), (at "b", at "c"), (at "d", at "c"), (at "e", at "e")]+ hashOf c = T.unpack (T.replicate 32 c)+ liveName = hashOf "a" <> "-gc-live"+ deadOnDisk = hashOf "b" <> "-gc-dead"+ deadNoTree = hashOf "c" <> "-gc-ghost"+ stale = hashOf "d" <> "-gc-stale"+ nix32Alphabet = "0123456789abcdfghijklmnpqrsvwxyz" :: String+ sequence+ [ runTest "reachableFrom closes over references transitively" $+ assertEqual "closure" (Set.fromList [at "a", at "b", at "c"]) (reachableFrom refs [at "a"]),+ runTest "reachableFrom leaves paths the roots do not reach out" $+ assertEqual "d alone" (Set.fromList [at "c", at "d"]) (reachableFrom refs [at "d"]),+ runTest "reachableFrom terminates on a cycle and a self-reference" $+ assertEqual+ "cycle, self"+ (Set.fromList [at "x", at "y"], Set.singleton (at "e"))+ (reachableFrom (referencesMap [(at "x", at "y"), (at "y", at "x")]) [at "x"], reachableFrom refs [at "e"]),+ runTest "reachableFrom keeps a root the map does not know as a dead end" $+ assertEqual "unknown root" (Set.singleton (at "z")) (reachableFrom refs [at "z"]),+ runTest "reachableFrom of no roots is empty" $+ assertEqual "no roots" Set.empty (reachableFrom refs []),+ -- Dead rows go whether or not a tree exists; every directory entry+ -- that is not live goes too (a dead tree, a stale build, junk, a+ -- dead path's lock file), while the live path, its lock file and+ -- the metadata directory stay.+ runTest "sweepPlan unregisters dead rows and removes every entry that is not live, keeping a live path's lock file" $+ let valid = Set.fromList [at liveName, at deadOnDisk, at deadNoTree]+ live = Set.fromList [at liveName]+ entries = map T.pack [liveName, liveName <> ".lock", deadOnDisk, deadOnDisk <> ".lock", metaDirName, stale, stale <> ".lock", "junk-file"]+ in assertEqual+ "plan"+ SweepPlan+ { spDeadRows = sort [at deadOnDisk, at deadNoTree],+ spTrees = sort [at deadOnDisk, at (deadOnDisk <> ".lock"), at stale, at (stale <> ".lock"), at "junk-file"]+ }+ (sweepPlan sd valid live entries),+ runTest "sweepPlan removes a dead registered object named like a live path's lock file" $+ let lockNamed = liveName <> ".lock"+ valid = Set.fromList [at liveName, at lockNamed]+ live = Set.fromList [at liveName]+ in assertEqual+ "plan"+ SweepPlan {spDeadRows = [at lockNamed], spTrees = [at lockNamed]}+ (sweepPlan sd valid live (map T.pack [liveName, lockNamed])),+ runTest "parseStorePathPrefix names the path for itself, a trailing separator and anything beneath, and nothing outside" $+ let sp = StorePath (T.replicate 32 "a") "gc-live"+ dirText = T.pack (unStoreDir sd)+ named = map (parseStorePathPrefix sd) [at liveName, at liveName <> "/", at liveName <> "/bin/tool", at liveName <> "\\bin"]+ unnamed = map (parseStorePathPrefix sd) [dirText, dirText <> "/", "/elsewhere/" <> T.pack liveName, dirText <> "/not-a-store-path"]+ in assertEqual "prefixes" (replicate 4 (Just sp), replicate 4 Nothing) (named, unnamed),+ runTest "showFreedBytes renders upstream's two-place MiB" $+ assertEqual "MiB" ["0.00 MiB", "1.00 MiB", "1.50 MiB"] (map showFreedBytes [0, 1048576, 1572864]),+ runTest "gcSummaryLine is upstream's PrintFreed line" $+ assertEqual "summary" "2 store paths deleted, 1.00 MiB freed" (gcSummaryLine (GcResults [at "a", at "b"] 1048576)),+ runTest "indirectRootRecordName is a nix-base32 digest keyed by the link path" $+ let name = indirectRootRecordName "/home/u/result"+ in if length name == 52 && all (`elem` nix32Alphabet) name && name /= indirectRootRecordName "/home/u/result-2"+ then Pass+ else Fail ("unexpected record name: " <> T.pack name)+ ]++-- | Garbage collection end to end against a temp store: a rooted closure+-- survives and the orphan goes, the rooted path and what it reaches+-- cannot be deleted, one directory keys one store however it is+-- spelled while a foreign spelling is refused, a removed root lets the+-- closure go, a dead cycle sweeps, a dead path's lock file goes with it,+-- a second open handle holds the collector off, a failed lease renewal+-- leaves a closable handle, and an out-link root behaves as nix-build's+-- result link does.+testStoreGC :: IO [Bool]+testStoreGC = do+ putStrLn "store/gc"+ canLink <- symlinksAvailable+ tmpBase <- getTemporaryDirectory+ let linkDir = tmpBase </> "nova-nix-test-gc-links"+ outLink = linkDir </> "result"+ Dir.removePathForcibly linkDir+ createDirectoryIfMissing True linkDir+ withTempStore $ \store -> do+ let sd = stDir store+ basenameOf sp = spHash sp <> "-" <> spName sp+ pathText sp = T.pack (storePathToFilePath sd sp)+ lockOf sp = storePathToFilePath sd sp <> ".lock"+ plant sp bytes = do+ let tree = storePathToFilePath sd sp+ createDirectoryIfMissing True tree+ BS.writeFile (tree </> "data") bytes+ dep = StorePath (T.replicate 32 "a") "gc-dep"+ top = StorePath (T.replicate 32 "b") "gc-top"+ orphan = StorePath (T.replicate 32 "c") "gc-orphan"+ rootFile = gcRootsDir sd </> T.unpack (basenameOf top)+ registerTrio = do+ mapM_ (uncurry plant) [(dep, "dep"), (top, "top!"), (orphan, "orphan bytes")]+ registerPaths+ (stDB store)+ [ PathRegistration dep "sha256:d" 1 Nothing [],+ PathRegistration top "sha256:t" 1 Nothing [dep],+ PathRegistration orphan "sha256:o" 1 Nothing []+ ]+ -- A refused collection is a failure of the test, not a result.+ collect = collectGarbage store >>= either (throwIO . userError . T.unpack) pure+ permanent <-+ sequence+ [ -- A regular file named like the path, under gcroots: upstream's+ -- own permanent-root form, the one that needs no symlink.+ runTestM "a permanent root keeps its closure and the orphan is swept" $ do+ registerTrio+ createDirectoryIfMissing True (gcRootsDir sd)+ BS.writeFile rootFile ""+ results <- collect+ validity <- mapM (isValid store) [dep, top, orphan]+ orphanGone <- not <$> Dir.doesPathExist (storePathToFilePath sd orphan)+ topKept <- Dir.doesPathExist (storePathToFilePath sd top </> "data")+ pure $+ if results == GcResults [pathText orphan] 12 && validity == [True, True, False] && orphanGone && topKept+ then Pass+ else Fail ("wrong sweep: " <> T.pack (show (results, validity, orphanGone, topKept))),+ runTestM "delete refuses the rooted path with upstream's words" $ do+ outcome <- deleteStorePathRaw store (basenameOf top)+ stillValid <- isValid store top+ pure $ case outcome of+ Left err+ | "since it is still alive" `T.isInfixOf` err, pathText top `T.isInfixOf` err, stillValid -> Pass+ | otherwise -> Fail ("wrong refusal: " <> err)+ Right removed -> Fail ("deleted a rooted path: " <> T.pack (show removed)),+ runTestM "delete refuses a path the root reaches through references" $ do+ outcome <- deleteStorePathRaw store (basenameOf dep)+ pure $ case outcome of+ Left err | "since it is still alive" `T.isInfixOf` err -> Pass+ other -> Fail ("expected the liveness refusal, got: " <> T.pack (show other)),+ runTestM "findRoots reports the permanent root under its own path" $ do+ roots <- findRoots store+ pure (assertEqual "roots" [GcRoot rootFile (pathText top)] roots),+ -- One directory keys one set of rows however it is spelled: the+ -- handle canonicalises the spelling at open, so a second handle+ -- opened through '.', '..' and a trailing separator (and, when+ -- the temp store shares the working directory's drive, through+ -- a relative path) is the same store with the same root.+ runTestM "openStore keys the store by one spelling of its directory" $ do+ cwd <- Dir.getCurrentDirectory+ let dotted = unStoreDir sd </> "." </> "zig" </> ".." <> [pathSeparator]+ relative =+ [ joinPath (replicate (length (splitDirectories cwd) - 1) "..") </> dropDrive (unStoreDir sd)+ | takeDrive cwd == takeDrive (unStoreDir sd)+ ]+ spellings = dotted : relative+ inspect spelling = do+ other <- openStore (StoreDir spelling)+ roots <- findRoots other+ closeStore other+ pure (stDir other, roots)+ outcomes <- mapM inspect spellings+ pure (assertEqual "spellings" (map (const (sd, [GcRoot rootFile (pathText top)])) spellings) outcomes),+ -- A row keyed under another spelling of the directory would+ -- compare as dead; the collector and the checked delete refuse+ -- instead of sweeping, and nothing changes.+ runTestM "a collection and a checked delete refuse a database keyed under another spelling" $ do+ let foreignRow = "/elsewhere/" <> T.replicate 32 "z" <> "-gc-foreign"+ conn <- SQL.open (unStoreDir sd </> metaDirName </> dbFileName)+ SQL.execute+ conn+ "INSERT INTO ValidPaths (path, hash, registrationTime, deriver, narSize) VALUES (?, ?, 0, NULL, 0)"+ (foreignRow, "sha256:z" :: T.Text)+ collected <- collectGarbage store+ deleted <- deleteStorePathRaw store (basenameOf dep)+ SQL.execute conn "DELETE FROM ValidPaths WHERE path = ?" (SQL.Only foreignRow)+ SQL.close conn+ validity <- mapM (isValid store) [dep, top, orphan]+ topKept <- Dir.doesPathExist (storePathToFilePath sd top </> "data")+ pure $ case (collected, deleted) of+ (Left gcErr, Left delErr)+ | "another spelling" `T.isInfixOf` gcErr,+ "another spelling" `T.isInfixOf` delErr,+ validity == [True, True, False],+ topKept ->+ Pass+ other -> Fail ("expected both refusals, got: " <> T.pack (show (other, validity, topKept))),+ -- Upstream reports an entry it cannot read and carries on; a+ -- directory with no permissions is the reproducible case, and+ -- one that lists anyway (root, or Windows, where permissions+ -- do not forbid a listing) proves nothing and is skipped.+ runTestM "an unreadable entry under gcroots is reported and skipped" $ do+ let locked = gcRootsDir sd </> "locked"+ unlock = do+ Dir.setPermissions locked (Dir.setOwnerReadable True (Dir.setOwnerSearchable True (Dir.setOwnerWritable True Dir.emptyPermissions)))+ Dir.removePathForcibly locked+ createDirectoryIfMissing True locked+ Dir.setPermissions locked Dir.emptyPermissions+ readable <- try (Dir.listDirectory locked) :: IO (Either SomeException [FilePath])+ -- Restored on every exit: a walk that throws here would+ -- otherwise leave the directory unreadable for every later+ -- root walk in the group.+ roots <- findRoots store `finally` unlock+ case readable of+ Right _ -> Pass <$ putStrLn " SKIP an empty-permission directory is readable here"+ Left _ -> pure (assertEqual "roots" [GcRoot rootFile (pathText top)] roots),+ runTestM "removing the root lets the closure go in one sweep" $ do+ Dir.removeFile rootFile+ results <- collect+ remaining <- queryAllValidPaths (stDB store)+ depGone <- not <$> Dir.doesPathExist (storePathToFilePath sd dep)+ pure $+ if gcrDeleted results == [pathText dep, pathText top] && null remaining && depGone+ then Pass+ else Fail ("closure survived: " <> T.pack (show (results, remaining, depGone))),+ -- Cross-output references run both ways between the outputs of+ -- one derivation, and no order of one-row deletes gets through+ -- that; the batch delete does.+ runTestM "a dead reference cycle is swept" $ do+ let out = StorePath (T.replicate 32 "d") "gc-cycle-out"+ dev = StorePath (T.replicate 32 "e") "gc-cycle-dev"+ mapM_ (uncurry plant) [(out, "o"), (dev, "d")]+ registerPaths+ (stDB store)+ [ PathRegistration out "sha256:x" 1 Nothing [dev],+ PathRegistration dev "sha256:y" 1 Nothing [out]+ ]+ results <- collect+ remaining <- queryAllValidPaths (stDB store)+ pure (assertEqual "swept" ([pathText out, pathText dev], []) (gcrDeleted results, remaining)),+ -- Every entry that is not live goes, a dead path's lock file+ -- with it; a live path's lock file is the one lock file the+ -- protocol keeps, and it goes once the path does.+ runTestM "a dead path's lock file is swept with it and a live path's stays until the path goes" $ do+ registerTrio+ BS.writeFile rootFile ""+ let stale = StorePath (T.replicate 32 "f") "gc-stale"+ plant stale "stale"+ BS.writeFile (lockOf stale) ""+ BS.writeFile (lockOf top) ""+ first <- collect+ staleGone <- not <$> Dir.doesPathExist (storePathToFilePath sd stale)+ staleLockGone <- not <$> Dir.doesPathExist (lockOf stale)+ topLockKept <- Dir.doesPathExist (lockOf top)+ Dir.removeFile rootFile+ second <- collect+ topLockGone <- not <$> Dir.doesPathExist (lockOf top)+ remaining <- queryAllValidPaths (stDB store)+ pure $+ if gcrDeleted first == sort [pathText orphan, pathText stale, T.pack (lockOf stale)]+ && staleGone+ && staleLockGone+ && topLockKept+ && gcrDeleted second == sort [pathText dep, pathText top, T.pack (lockOf top)]+ && topLockGone+ && null remaining+ then Pass+ else Fail ("wrong sweep: " <> T.pack (show (first, staleGone, staleLockGone, topLockKept, second, topLockGone, remaining))),+ -- Every open handle holds the collector lock shared; the+ -- collector needs it exclusive, so it waits for the other+ -- handle to close and runs the moment it does.+ runTestM "collectGarbage waits for another open handle to close" $ do+ other <- openStore sd+ done <- newEmptyMVar+ _ <- forkIO (collect >>= putMVar done)+ early <- timeout deleteLockProbeMicros (takeMVar done)+ closeStore other+ outcome <- timeout raceWatchdogMicros (takeMVar done)+ pure $ case (early, outcome) of+ (Just _, _) -> Fail "collector ignored the other handle's lease"+ (Nothing, Just _) -> Pass+ (Nothing, Nothing) -> Fail "collector never ran after the other handle closed",+ -- A renewal that fails must not leave the handle without a+ -- lease slot, or closeStore waits forever. The lock file is+ -- replaced by a directory while the exclusive lock is held, so+ -- the shared re-acquire cannot open it; Windows forbids+ -- replacing a held file, so the case is POSIX-only.+ runTestM "withCollectorLock leaves a closable handle when the lease cannot be renewed" $+ if SI.os == "mingw32"+ then Pass <$ putStrLn " SKIP a held lock file cannot be replaced on Windows"+ else do+ let renewalStore = tmpBase </> "nova-nix-test-gc-renewal"+ Dir.removePathForcibly renewalStore+ other <- openStore (StoreDir renewalStore)+ let lockPath = gcLockFilePath (stDir other)+ swapped <- try (withCollectorLock other (Dir.removeFile lockPath >> Dir.createDirectory lockPath))+ closed <- timeout raceWatchdogMicros (closeStore other)+ Dir.removePathForcibly renewalStore+ pure $ case (swapped :: Either SomeException (), closed) of+ (Left _, Just ()) -> Pass+ (Right (), _) -> Fail "renewal succeeded against a directory"+ (Left _, Nothing) -> Fail "closeStore hung after a failed renewal"+ ]+ linked <-+ if not canLink+ then do+ putStrLn " SKIP out-link roots need symlink privilege"+ pure []+ else+ sequence+ [ runTestM "an out-link root keeps the closure until the link is removed" $ do+ registerTrio+ rooted <- addOutLinkRoot store outLink top+ case rooted of+ Left err -> pure (Fail err)+ Right link -> do+ let record = autoRootsDir sd </> indirectRootRecordName link+ recordThere <- Dir.doesFileExist record+ roots <- findRoots store+ first <- collect+ Dir.removePathForcibly link+ second <- collect+ recordGone <- not <$> Dir.doesFileExist record+ remaining <- queryAllValidPaths (stDB store)+ pure $+ if roots == [GcRoot link (pathText top)]+ && recordThere+ && gcrDeleted first == [pathText orphan]+ && gcrDeleted second == [pathText dep, pathText top]+ && recordGone+ && null remaining+ then Pass+ else Fail ("out-link root misbehaved: " <> T.pack (show (roots, recordThere, first, second, recordGone, remaining))),+ -- upstream's findRoots takes toStorePath of the target, so a+ -- link into a store path's subtree, or to the path with a+ -- trailing separator, roots the path.+ runTestM "a symlink root into a store path's subtree or with a trailing separator roots the path" $ do+ plant top "top"+ registerPath (stDB store) (PathRegistration top "sha256:t" 1 Nothing [])+ let subLink = gcRootsDir sd </> "sub"+ slashLink = gcRootsDir sd </> "slash"+ Dir.createFileLink (storePathToFilePath sd top </> "data") subLink+ Dir.createDirectoryLink (storePathToFilePath sd top <> [pathSeparator]) slashLink+ roots <- findRoots store+ mapM_ Dir.removePathForcibly [subLink, slashLink]+ pure (assertEqual "roots" [GcRoot slashLink (pathText top), GcRoot subLink (pathText top)] roots),+ -- upstream dedups through a set keyed by (link, path); an+ -- operator's symlink to the out-link and the out-link's own+ -- record name the same root.+ runTestM "findRoots lists one link once when two records name it" $ do+ rooted <- addOutLinkRoot store outLink top+ case rooted of+ Left err -> pure (Fail err)+ Right link -> do+ let mine = gcRootsDir sd </> "mine"+ Dir.createFileLink link mine+ roots <- findRoots store+ mapM_ Dir.removePathForcibly [mine, link]+ pure (assertEqual "roots" [GcRoot link (pathText top)] roots),+ -- upstream's addPermRoot canonPaths the link and makeSymlink+ -- creates its parents; a '..' that lands inside the store is+ -- refused as inside.+ runTestM "addOutLinkRoot collapses .. and creates missing parents, and .. cannot spell a link into the store" $ do+ let expectedLink = T.unpack (canonPath (T.pack (linkDir </> "deep" </> "result")))+ dotted = linkDir </> "deep" </> "down" </> ".." </> "result"+ insideViaDotDot = takeDirectory (unStoreDir sd) </> takeFileName linkDir </> ".." </> takeFileName (unStoreDir sd) </> "result"+ collapsed <- addOutLinkRoot store dotted top+ target <- Dir.getSymbolicLinkTarget expectedLink+ recorded <- BS.readFile (autoRootsDir sd </> indirectRootRecordName expectedLink)+ inside <- addOutLinkRoot store insideViaDotDot top+ pure $ case (collapsed, inside) of+ (Right link, Left insideErr)+ | link == expectedLink,+ target == storePathToFilePath sd top,+ recorded == TE.encodeUtf8 (T.pack link),+ "in the Nix store is forbidden" `T.isInfixOf` insideErr ->+ Pass+ other -> Fail ("wrong outcomes: " <> T.pack (show (other, target, recorded))),+ -- upstream's addPermRoot: an existing store link is replaced,+ -- anything else at the name is refused, and a link inside+ -- the store is refused.+ runTestM "addOutLinkRoot replaces a store link and refuses a file or a link inside the store" $ do+ let replaceLink = linkDir </> "replace"+ plainFile = linkDir </> "plain"+ Dir.createFileLink (storePathToFilePath sd orphan) replaceLink+ replaced <- addOutLinkRoot store replaceLink top+ target <- Dir.getSymbolicLinkTarget replaceLink+ BS.writeFile plainFile "not a link"+ clobber <- addOutLinkRoot store plainFile top+ inside <- addOutLinkRoot store (unStoreDir sd </> "result") top+ pure $ case (replaced, clobber, inside) of+ (Right _, Left clobberErr, Left insideErr)+ | target == storePathToFilePath sd top,+ "already exists" `T.isInfixOf` clobberErr,+ "in the Nix store is forbidden" `T.isInfixOf` insideErr ->+ Pass+ other -> Fail ("wrong outcomes: " <> T.pack (show (other, target)))+ ]+ Dir.removePathForcibly linkDir+ pure (permanent ++ linked)++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 restore must be cleared+ -- and restored again, never registered as-is.+ runTestM "materializeEvalSources restores over 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),+ -- Evaluation hashes a source well before the build driver+ -- restores it. A source edited in between no longer reproduces+ -- the address evaluation gave it, so it is refused and nothing+ -- is left at the path, rather than its new bytes being sealed+ -- and registered under the old address (#235).+ runTestM "materializeEvalSources refuses a source changed since evaluation" $ do+ tmpBase <- getTemporaryDirectory+ let srcDir = tmpBase </> "nova-nix-test-changed-src"+ removeIfExists srcDir+ createDirectoryIfMissing True srcDir+ TIO.writeFile (srcDir </> "data.txt") "evaluated content"+ entry <- NAR.serialiseFromPath srcDir+ case makeFixedOutputPath "nova-nix-test-changed-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+ forceRemoveIfExists dest+ TIO.writeFile (srcDir </> "data.txt") "edited after evaluation"+ outcome <- try (materializeEvalSources store (Map.fromList [(T.pack srcDir, spText)]))+ registered <- isValid store sp+ leftBehind <- Dir.doesPathExist dest+ removeIfExists srcDir+ pure $ case outcome of+ Right () -> Fail "registered a source that changed after evaluation"+ Left (e :: IOException)+ | not ("changed after evaluation hashed it" `T.isInfixOf` T.pack (ioeGetErrorString e)) ->+ Fail ("unexpected refusal: " <> T.pack (show e))+ | registered || leftBehind ->+ Fail ("refused, but registered " <> T.pack (show registered) <> ", tree left " <> T.pack (show leftBehind))+ | otherwise -> Pass,+ -- 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"+ -- The fixtures are ASCII: one path unit per character, both ways,+ -- on every platform.+ osPath = OP.pack . map OP.unsafeFromChar+ fromOsPath = map OP.toChar . OP.unpack+ orderedLinks pending = map (fromOsPath . fst) (orderLinks [(osPath link, osPath target) | (link, target) <- pending])+ 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"]+ (orderedLinks 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"]+ (orderedLinks 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"]+ (orderedLinks 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"]+ (orderedLinks 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"]+ (orderedLinks pending),+ runTest "self-target link survives to the fallback" $+ assertEqual+ "self"+ [root </> "x"]+ (orderedLinks [(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), "l" <> show (i + 1))+ | i <- [1 :: Int .. 20000]+ ]+ ++ [(root </> "l20001", "real")]+ complete = length (orderedLinks 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),+ -- The recorded derivation populates drvOutputs. These three read the+ -- Derivation the session records, which only the IO evaluator keeps.+ runTestM "derivationStrict populates drvOutputs" $ do+ tmpBase <- getTemporaryDirectory+ result <- evalDerivationIO tmpBase "derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }"+ pure $ assertRight "drvOutputs" result $ \drv ->+ case drvOutputs drv of+ [] -> Fail "drvOutputs is empty"+ (firstOut : _) ->+ if doName firstOut == "out"+ then Pass+ else Fail ("first output name: " <> doName firstOut),+ -- The .drv lists outputs sorted by name, as upstream writes them; the+ -- declared order lives in the value's outputs and all.+ runTestM "derivationStrict multi-output" $ do+ tmpBase <- getTemporaryDirectory+ result <- evalDerivationIO tmpBase "derivation { name = \"multi\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; outputs = [\"out\" \"dev\"]; }"+ pure $ assertRight "multi-output" result $ \drv ->+ let names = map doName (drvOutputs drv)+ in if names == ["dev", "out"]+ then Pass+ else Fail ("output names: " <> T.pack (show names)),+ -- The .drv env carries the output paths ($out, ...) and the build+ -- attributes, and no "drvPath" key - matching C++ Nix, which never+ -- writes one.+ runTestM "derivationStrict populates drvEnv" $ do+ tmpBase <- getTemporaryDirectory+ result <- evalDerivationIO tmpBase "derivation { name = \"test\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; }"+ pure $ assertRight "drvEnv" result $ \drv ->+ case (Map.lookup "out" (drvEnv drv), Map.lookup "name" (drvEnv drv)) of+ (Just op, Just "test")+ | "/nix/store/" `BS.isPrefixOf` op -> Pass+ _ -> Fail ("drvEnv keys: " <> T.pack (show (Map.toList (drvEnv drv))))+ ]++-- ---------------------------------------------------------------------------+-- 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",+ -- A derivation carrying __json is built as upstream builds one: its+ -- attributes, outputs included, arrive as .attrs.json and .attrs.sh+ -- and none of them as a variable. The builder copies both files+ -- out, finding $out where a structured builder must, in the files.+ runTestM "structured attributes reach the builder as .attrs.json and .attrs.sh" $ do+ tmpBase <- getTemporaryDirectory+ let tmpStore = tmpBase </> "nova-nix-test-builder-structured"+ forceRemoveIfExists tmpStore+ store <- openStore (StoreDir tmpStore)+ let outSP = StorePath "ssssssssssssssssssssssssssssssss" "structured"+ outText = T.pack (storePathToFilePath (stDir store) outSP)+ attrs =+ "{\"greeting\":\"it's\",\"list\":[\"a\",1,true,null],\"nested\":{\"k\":\"v\"},\"outputs\":[\"out\"],\"self\":\""+ <> hashPlaceholder "out"+ <> "/bin\"}"+ script =+ T.unlines+ [ "probe=\"${out-unset}|${__json-unset}|${greeting-unset}\"",+ "out=$(sed -n \"/^declare -A outputs=/s/.*\\['out'\\]='\\([^']*\\)'.*/\\1/p\" \"$NIX_ATTRS_SH_FILE\")",+ "mkdir \"$out\" && cp \"$NIX_ATTRS_JSON_FILE\" \"$out/attrs.json\" && cp \"$NIX_ATTRS_SH_FILE\" \"$out/attrs.sh\" && printf '%s' \"$probe\" > \"$out/env\""+ ]+ drv =+ (mkTestBuildDrv shell outSP script)+ { drvEnv = Map.fromList [("__json", TE.encodeUtf8 attrs), ("out", "/nix/store/ssssssssssssssssssssssssssssssss-structured")]+ }+ config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder-structured-tmp"}+ jsonPath = "\"" <> T.replace "\\" "\\\\" outText <> "\""+ expectedJson =+ "{\"greeting\":\"it's\",\"list\":[\"a\",1,true,null],\"nested\":{\"k\":\"v\"},\"outputs\":{\"out\":"+ <> jsonPath+ <> "},\"self\":"+ <> T.dropEnd 1 jsonPath+ <> "/bin\"}"+ expectedShell =+ T.concat+ [ "declare greeting='it'\\''s'\n",+ "declare -a list=('a' 1 1 '' )\n",+ "declare -A nested=(['k']='v' )\n",+ "declare -A outputs=(['out']='" <> outText <> "' )\n",+ "declare self='" <> outText <> "/bin'\n"+ ]+ result <- buildDerivation config store drv+ ret <- case result of+ BuildSuccess _ -> do+ let outDir = storePathToFilePath (stDir store) outSP+ json <- BS.readFile (outDir </> "attrs.json")+ shellText <- BS.readFile (outDir </> "attrs.sh")+ probe <- BS.readFile (outDir </> "env")+ pure $+ assertEqual "attrs.json" (TE.encodeUtf8 expectedJson) json+ `andThen` assertEqual "attrs.sh" (TE.encodeUtf8 expectedShell) shellText+ `andThen` assertEqual "no attribute is a variable" "unset|unset|unset" probe+ BuildFailure msg code -> pure (Fail ("build failed (" <> T.pack (show code) <> "): " <> msg))+ closeStore store+ forceRemoveIfExists tmpStore+ forceRemoveIfExists (bcTmpDir config)+ pure ret,+ -- 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)+-- ---------------------------------------------------------------------------++-- | A tree holding a reserved device stem, a colon and a trailing dot,+-- names POSIX holds and Windows does not, in the order a sorted listing+-- gives them. The colon follows more than one letter: bsdtar, which+-- fetchTarball's extraction runs on macOS, drops a one-letter prefix+-- such as @a:@ as a drive letter once @--strip-components@ has exposed+-- it.+reservedNameFiles :: [(FilePath, BS.ByteString)]+reservedNameFiles = [("aux.c", "device\n"), ("dot.", "dot\n"), ("key:value", "colon\n")]++-- | Where upstream stores 'reservedNameFiles' as a coerced source+-- directory named @src@ (nix-instantiate 2.33.2, @"${./src}"@) and as+-- the tree of a fetchTarball (with @--store 'local?root=...'@, which+-- holds it under exactly these names).+reservedNameSourcePath, reservedNameTarballPath :: StorePath+reservedNameSourcePath = StorePath "42hs0hrpla9b23iz9p3nj0px0np0jv6f" "src"+reservedNameTarballPath = StorePath "rlwykawmqrmdiq4xmcr4rf2zxf6y9k09" "source"++-- | 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 unrestrictedPolicy (esTimestamp st) (esSearchPaths st)) expr+ -- What the CLI driver does: force drvPath, which runs+ -- derivationStrict (so a missing required attr surfaces as an eval+ -- error) and records the .drv the build reads back by that path.+ case val of+ VAttrs attrs | Just thunk <- attrSetLookup "drvPath" attrs -> do+ pathVal <- force thunk+ case pathVal of+ VStr pathBytes _ -> lookupSessionDrv (bytesText pathBytes)+ _ -> pure Nothing+ _ -> pure Nothing+ case evalResult of+ Left err -> pure (Left ("eval error: " <> err))+ Right Nothing -> pure (Left "result is not a derivation")+ Right (Just 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))++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),+ -- POSIX holds names Windows reserves, and upstream stores them as+ -- their bytes there (#84). A coerced source and a fetchTarball+ -- tree holding a device stem, a colon and a trailing dot restore+ -- with exactly those names, under the paths upstream computes+ -- for them ('reservedNameSourcePath'), as the raw copy they+ -- replace stored them (#235).+ runTestM "e2e sources holding names Windows reserves restore with their names" $+ if SI.os == "mingw32"+ then Pass <$ putStrLn " SKIP a Windows volume cannot hold these names"+ else do+ tmpBase <- getTemporaryDirectory+ environment <- getEnvironment+ let root = tmpBase </> "nova-nix-test-e2e-names"+ srcDir = root </> "src"+ stage = root </> "stage"+ tarball = root </> "names.tar.gz"+ tmpStore = tmpBase </> "nova-nix-test-e2e-names-store"+ nixEscape = T.concatMap (\c -> if c == '\\' then "\\\\" else if c == '"' then "\\\"" else T.singleton c)+ source =+ T.concat+ [ "derivation { name = \"names\"; system = builtins.currentSystem; ",+ "builder = \"" <> nixEscape shell <> "\"; ",+ "args = [\"-c\" \"echo ok > $out\"]; ",+ "srcs = [ " <> canonPathValue (T.pack srcDir) <> " ",+ "(builtins.fetchTarball \"file://" <> nixEscape (T.pack tarball) <> "\") ]; }"+ ]+ -- bsdtar would otherwise add AppleDouble entries for+ -- any extended attributes the files carry.+ tarProcess =+ (Proc.proc "tar" ["-czf", tarball, "-C", stage, "top"])+ { Proc.env = Just (("COPYFILE_DISABLE", "1") : environment)+ }+ writeTree dir = do+ createDirectoryIfMissing True dir+ mapM_ (\(name, bytes) -> BS.writeFile (dir </> name) bytes) reservedNameFiles+ storedAs store sp = do+ let dest = storePathToFilePath (stDir store) sp+ present <- doesDirectoryExist dest+ if not present+ then pure (Just (spName sp <> ": no tree at " <> T.pack dest))+ else do+ names <- sort <$> Dir.listDirectory dest+ contents <- traverse (BS.readFile . (dest </>)) names+ valid <- isValid store sp+ pure $+ if valid && zip names contents == reservedNameFiles+ then Nothing+ else Just (spName sp <> " valid " <> T.pack (show valid) <> ", stored as " <> T.pack (show (zip names contents)))+ forceRemoveIfExists root+ forceRemoveIfExists tmpStore+ writeTree srcDir+ writeTree (stage </> "top")+ (tarCode, _, tarErr) <- Proc.readCreateProcessWithExitCode tarProcess ""+ verdict <- case tarCode of+ ExitFailure n -> pure (Fail ("tar exited " <> T.pack (show n) <> ": " <> T.pack tarErr))+ ExitSuccess -> do+ built <- evalAndBuild (StoreDir tmpStore) source+ case built of+ Left err -> pure (Fail err)+ Right (BuildFailure msg _, store) -> Fail msg <$ closeStore store+ Right (BuildSuccess _, store) -> do+ problems <- catMaybes <$> traverse (storedAs store) [reservedNameSourcePath, reservedNameTarballPath]+ closeStore store+ pure (if null problems then Pass else Fail (T.intercalate "; " problems))+ forceRemoveIfExists root+ forceRemoveIfExists tmpStore+ pure verdict,+ -- 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 "parseNixPath channel entry" $+ let result = parseNixPath "nixpkgs=channel:nixos-24.11"+ 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 "channel:nixos-24.11" _)) -> Pass+ _ -> Fail "wrong prefix/path for channel"+ _ -> Fail ("expected one entry, got " <> T.pack (show (length result))),+ 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 a channel entry whole" $+ assertEqual+ "split-channel"+ ["channel:nixos-24.11", "/foo"]+ (splitNixPath "channel:nixos-24.11:/foo"),+ runTest "splitNixPath keeps a prefixed channel entry whole" $+ assertEqual+ "split-channel-prefixed"+ ["nixpkgs=channel:nixos-24.11", "/foo"]+ (splitNixPath "nixpkgs=channel:nixos-24.11:/foo"),+ runTest "splitNixPath keeps a flake entry whole" $+ assertEqual "split-flake" ["flake:nixpkgs", "/foo"] (splitNixPath "flake:nixpkgs:/foo"),+ runTest "splitNixPath keeps every listed scheme whole" $+ assertEqual+ "split-schemes"+ ["http://h/a", "https://h/b", "file:///c", "channel://d", "git://h/e", "s3://b/f", "ssh://h/g"]+ (splitNixPath "http://h/a:https://h/b:file:///c:channel://d:git://h/e:s3://b/f:ssh://h/g"),+ runTest "splitNixPath splits an unlisted scheme at its colon" $+ assertEqual "split-unlisted" ["foo", "//x", "/bar"] (splitNixPath "foo://x:/bar"),+ runTest "splitNixPath drops empty entries" $+ assertEqual "split-empties" ["a", "b"] (splitNixPath "a::b:"),+ runTest "splitNixPath drops leading empty entries" $+ assertEqual "split-leading-empties" ["x"] (splitNixPath "::x"),+ runTest "splitNixPath matches a scheme case-sensitively" $+ assertEqual "split-scheme-case" ["HTTP", "//x", "/y"] (splitNixPath "HTTP://x:/y"),+ runTest "splitNixPath judges the scheme after the last = of the entry" $+ assertEqual "split-last-equals" ["a=b=https://x", "/y"] (splitNixPath "a=b=https://x:/y"),+ runTest "splitNixPath splits a URL port after the host" $+ assertEqual "split-port" ["https://example.com", "8080/x"] (splitNixPath "https://example.com:8080/x"),+ runTest "splitNixPath absorbs one run after a channel entry" $+ assertEqual "split-one-run" ["channel:channel", "x"] (splitNixPath "channel:channel:x"),+ runTest "splitNixPath keeps a bare channel colon" $+ assertEqual "split-channel-bare" ["channel:", "foo"] (splitNixPath "channel::foo"),+ runTest "isNixPathPseudoUrl: channel, flake and the listed schemes are fetched" $+ assertEqual+ "pseudo"+ [True, True, True, True, True]+ (map isNixPathPseudoUrl ["channel:nixos-24.11", "channel:", "flake:nixpkgs", "https://example.com/x.tar.gz", "file:///c"]),+ runTest "isNixPathPseudoUrl: a path, an unlisted scheme, a drive and a bare word are not" $+ assertEqual "not-pseudo" [False, False, False, False] (map isNixPathPseudoUrl ["/foo", "foo://x", "C:/x", "channel"]),+ runTest "splitNixPath drops a trailing empty entry" $+ assertEqual "split-trail" ["a"] (splitNixPath "a:"),+ runTestM "splitNixPath long entry splits in linear time" $ do+ let entry = TE.encodeUtf8 (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 (TE.encodeUtf8 ("mypkg=" <> T.pack subDir))+ env = builtinEnv unrestrictedPolicy (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++-- ---------------------------------------------------------------------------+-- Arena lifecycle guard (C FFI)+-- ---------------------------------------------------------------------------++-- | The argument that turns this executable into the child of+-- 'testArenaGuard'. The child reaches the C layer with no 'arenaInit',+-- which the suite cannot do in its own process: 'main' brackets every+-- group, and a destroy-then-reinit cycle would invalidate the arena+-- pointers that process-lifetime CAFs already hold (#19).+arenaGuardChildFlag :: String+arenaGuardChildFlag = "--arena-guard-child"++-- | One way a library user reaches the C layer, with what the child must+-- report for it.+data ArenaGuardPath = ArenaGuardPath+ { -- | The child's argument.+ guardName :: !String,+ -- | The suite's name for the case.+ guardClaim :: !Text,+ -- | What the child must report.+ guardOutcome :: !GuardOutcome,+ -- | The child's body, run with no 'arenaInit' of its own.+ guardAction :: !(IO ())+ }++-- | What a guard child must report for its path.+data GuardOutcome+ = -- | 'ArenaNotInitialized', naming this C entry point.+ RefusedAt !Text+ | -- | No exception: the path needs no arena.+ Served++-- | The line a guard child prints when its path raised nothing.+servedLine :: String+servedLine = "served without an exception"++-- | The paths a library user reaches the C layer by. The first C call+-- of each of the first four lands in a different sub-arena, and the+-- fifth runs a full init .. destroy cycle first, so both ends of the+-- window are covered. The last two read the shared @null@, @true@ and+-- @false@ thunks, which must be valid in every window: were they arena+-- cells behind a process-lifetime CAF, forcing one outside the window+-- would poison it with 'ArenaNotInitialized' for every later arena, and+-- forcing one inside an arena would leave it pointing into memory the+-- next 'arenaDestroy' frees, a use-after-free the ASan job reports.+arenaGuardPaths :: [ArenaGuardPath]+arenaGuardPaths =+ [ noArena "compile" "nn_bc_emit" evalIssueProgram,+ noArena "thunk" "nn_thunk_new_computed_int" (void (evaluate (unThunk (evaluated (VInt 1))))),+ noArena "symbol" "nn_symbol_intern" (void (evaluate (unThunk (evaluated (mkStr "a"))))),+ noArena "env" "nn_env_alloc_slots" (void (evaluate (fst (buildCSlots [evaluated (VInt 1)])))),+ noArena "after-destroy" "nn_bc_emit" (bracket_ arenaInit arenaDestroy (pure ()) >> evalIssueProgram),+ ArenaGuardPath+ { guardName = "constants-outside-window",+ guardClaim = "the null, true and false thunks read correctly before arenaInit and in a later arena",+ guardOutcome = Served,+ guardAction = readConstants >> bracket_ arenaInit arenaDestroy readConstants+ },+ ArenaGuardPath+ { guardName = "constants-across-cycles",+ guardClaim = "the null, true and false thunks read correctly across two init .. destroy cycles",+ guardOutcome = Served,+ guardAction = bracket_ arenaInit arenaDestroy readConstants >> bracket_ arenaInit arenaDestroy readConstants+ }+ ]+ where+ noArena name site = ArenaGuardPath name ("no arena: " <> T.pack name <> " raises ArenaNotInitialized at " <> site) (RefusedAt site)+ -- The program from #206, forced to its result.+ evalIssueProgram = case parseNix "/tmp" "<expr>" "let x = 5; in x * 2 + 1" of+ Left err -> throwIO (ErrorCall (show err))+ Right expr -> void (evaluate (runPureEval (eval (builtinEnv unrestrictedPolicy 0 []) expr)))+ -- Read through the IO accessors: a pure read of a constant is itself+ -- a constant GHC may share between the cycles, and the second would+ -- then read nothing. A wrong cell is a broken outcome rather than the+ -- guard under test, so it leaves the child by another exception.+ readConstants = do+ found <- mapM (readCell . unThunk . evaluated) [VNull, VBool True, VBool False]+ unless (found == [(ThunkComputed, ValueNull, 0), (ThunkComputed, ValueBool, 1), (ThunkComputed, ValueBool, 0)]) $+ throwIO (ErrorCall ("the constants read back as " ++ show found))+ readCell ptr = (,,) <$> cthunkState ptr <*> cthunkValueTag ptr <*> cthunkGetBool ptr++-- | Child body: run one path with no arena and print what it raised, or+-- 'servedLine'. Exit 0 either way; a segfault, a sanitizer report or+-- any other exception type surfaces as the process's own exit code.+arenaGuardChild :: String -> IO ()+arenaGuardChild name = case [guardAction path | path <- arenaGuardPaths, guardName path == name] of+ [action] -> do+ outcome <- try action+ putStrLn $ case outcome of+ Left (err :: CStatusError) -> displayException err+ Right () -> servedLine+ _ -> hPutStrLn stderr ("unknown arena guard path: " ++ name) >> exitWith (ExitFailure 2)++-- | @builtins.storePath@ resolves a path's symlinks unless the path is+-- itself a store path (primops.cc at 2.24.9), so a link outside the store+-- into it names its target. The links dangle on a runner with no store,+-- which upstream resolves the same way; Windows needs symlink privilege.+testStorePathLinksIO :: IO [Bool]+testStorePathLinksIO = do+ putStrLn "eval/storepath-links-io"+ canLink <- symlinksAvailable+ if not canLink || SI.os == "mingw32"+ then [] <$ putStrLn " SKIP needs POSIX symlinks"+ else do+ tmpBase <- Dir.canonicalizePath =<< getTemporaryDirectory+ let dir = tmpBase </> "nova-nix-test-storepath-links"+ target = "/nix/store/00000000000000000000000000000000-x"+ through name = "builtins.storePath (/. + " <> nixQuotedPath (dir </> name) <> ")"+ Dir.removePathForcibly dir+ createDirectoryIfMissing True dir+ Dir.createFileLink target (dir </> "path")+ Dir.createFileLink (target <> "/bin") (dir </> "sub")+ results <-+ sequence+ [ runTestIO "a link into the store names the store path" dir (through "path") (VStr (TE.encodeUtf8 (T.pack target)) (Context.plainContext (StorePath "00000000000000000000000000000000" "x"))),+ runTestIO "a link to a subpath names the subpath" dir (through "sub") (VStr (TE.encodeUtf8 (T.pack (target <> "/bin"))) (Context.plainContext (StorePath "00000000000000000000000000000000" "x")))+ ]+ Dir.removePathForcibly dir+ pure results++-- | A derivation attribute carrying another's drvPath brings that .drv's+-- whole closure into inputSrcs and inputDrvs, as upstream's+-- derivationStrict does (primops.cc at 2.24.9). Each drvPath is+-- nix-instantiate 2.24.9's.+testDeepContextClosureIO :: IO [Bool]+testDeepContextClosureIO = do+ putStrLn "eval/deep-context-closure-io"+ tmpBase <- getTemporaryDirectory+ let plain name rest = "derivation { name = \"" <> name <> "\"; system = \"x\"; builder = \"/bin/sh\"; " <> rest <> "}"+ drvPathOf bindings body = "builtins.unsafeDiscardStringContext (let " <> bindings <> " in " <> body <> ").drvPath"+ a = "a = " <> plain "a" "" <> ";"+ m = "m = " <> plain "m" "dep = a; " <> ";"+ t = "t = " <> plain "t" "dep = m; " <> ";"+ sequence+ [ runTestIO "a drvPath brings its own .drv into inputSrcs" tmpBase (drvPathOf a (plain "b" "x = \"${a.drvPath}\"; ")) (mkStr "/nix/store/r3pz4ykyxg5s9f08fafcksw6bxfds40r-b.drv"),+ runTestIO "a drvPath brings the .drv files it depends on" tmpBase (drvPathOf (a <> " " <> m) (plain "b" "x = \"${m.drvPath}\"; ")) (mkStr "/nix/store/00pfjcd57v5dd5j4w4zmsp3s8lcpv6wg-b.drv"),+ runTestIO "the closure is followed to its end" tmpBase (drvPathOf (a <> " " <> m <> " " <> t) (plain "b" "x = \"${t.drvPath}\"; ")) (mkStr "/nix/store/f480ldqci72g5miaywmhljjlw1fg1y4i-b.drv")+ ]++-- | Run as a child, evaluate one expression under the IO evaluator, which+-- writes traces and warnings to stderr as it goes, then write its failure+-- to the same stream, so the parent reads them in the order they came.+evalChildFlag :: String+evalChildFlag = "--eval-child"++evalChild :: String -> IO ()+evalChild source = do+ result <- evalNixIO "." (T.pack source)+ either (\err -> hPutStrLn stderr ("error: " <> T.unpack err)) (const (pure ())) result++-- | @deepSeq@, @trace@ and @warn@ finish with their first argument before+-- they force their second (primops.cc at 2.24.9), however the second+-- arrives. Each expected stream is nix-instantiate 2.24.9's: its trace+-- and warning lines, then the final error line.+testFirstArgumentOrderIO :: IO [Bool]+testFirstArgumentOrderIO = do+ putStrLn "eval/first-argument-order-io"+ self <- getExecutablePath+ let emits label source expected = runTestM label $ do+ (_, _, err) <- Proc.readCreateProcessWithExitCode (Proc.proc self [evalChildFlag, T.unpack source]) ""+ pure (assertEqual label expected (T.lines (T.pack err)))+ sequence+ [ emits "trace prints before its second argument fails" "builtins.trace \"hi\" (throw \"b\")" ["trace: hi", "error: b"],+ emits "warn warns before its second argument fails" "builtins.warn \"w\" (throw \"b\")" ["evaluation warning: w", "error: b"],+ emits "deepSeq forces its first argument deeply before its second" "builtins.deepSeq { a = throw \"a\"; } (throw \"b\")" ["error: a"],+ emits "deepSeq applied to one argument first forces it on the second" "let f = builtins.deepSeq { a = throw \"a\"; }; in f (throw \"b\")" ["error: a"],+ emits "deepSeq mapped over a list forces its first argument first" "builtins.elemAt (map (builtins.deepSeq { a = throw \"a\"; }) [ (throw \"b\") ]) 0" ["error: a"],+ emits "trace inside deepSeq sees the forced value" "let x = { a = 1 + 1; }; in builtins.deepSeq x (builtins.trace x 1)" ["trace: { a = 2; }"]+ ]++-- | The smallest slot count whose byte size exceeds @UINT32_MAX@.+-- @nn_env_alloc_slots@ refuses it before touching a page, so inside a+-- live arena it fails without exhausting anything.+slotCountPastUInt32 :: Int+slotCountPastUInt32 = fromIntegral (maxBound :: Word32) `div` sizeOf (nullPtr :: Ptr ()) + 1++-- | One byte past the longest string the symbol table can hold.+symbolLengthPastUInt32 :: Int+symbolLengthPastUInt32 = fromIntegral (maxBound :: Word32) + 1++-- | Intern a ByteString whose length claims 'symbolLengthPastUInt32'+-- bytes over one real byte, so no 4 GiB buffer is needed. Sound only+-- because the interner refuses on the length before reading: an+-- interner that read would run out of bounds here.+internOversizedSymbol :: IO Symbol+internOversizedSymbol =+ alloca $ \byte -> do+ poke byte (0 :: CChar)+ oversized <- BSU.unsafePackCStringLen (byte, symbolLengthPastUInt32)+ symbolInternBytes oversized++-- | Reaching the C layer outside the 'arenaInit' .. 'arenaDestroy'+-- window raises 'ArenaNotInitialized' naming the entry point, where+-- #206 reported a SIGSEGV. Each such path runs in a child process.+-- The live side of the same check runs in this process, under the+-- suite's bracket: a refusal with the arena up is 'CAllocationFailed'.+testArenaGuard :: IO [Bool]+testArenaGuard = do+ putStrLn "arena/guard"+ self <- getExecutablePath+ children <-+ mapM+ ( \path -> runTestM (guardClaim path) $ do+ (code, out, err) <- Proc.readCreateProcessWithExitCode (Proc.proc self [arenaGuardChildFlag, guardName path]) ""+ let message = T.pack out+ pure $ case (code, guardOutcome path) of+ (ExitSuccess, RefusedAt site)+ | "arenaInit" `T.isInfixOf` message && site `T.isInfixOf` message -> Pass+ | otherwise -> Fail ("message does not name arenaInit and " <> site <> ": " <> message)+ (ExitSuccess, Served)+ | T.strip message == T.pack servedLine -> Pass+ | otherwise -> Fail ("expected no exception, got: " <> message)+ (ExitFailure n, _) -> Fail ("child exited " <> T.pack (show n) <> " (negative is a signal): " <> T.pack err)+ )+ arenaGuardPaths+ live <-+ sequence+ [ liveRefusal "a slot array past UINT32_MAX bytes" "nn_env_alloc_slots" (void (evaluate (allocCSlots slotCountPastUInt32))),+ -- The list's items come from the same slot allocator, so the same+ -- count is refused there and must surface as a list failure, where+ -- the NULL it used to return read as the empty list.+ liveRefusal "a list past UINT32_MAX slot bytes" "nn_list_new" (void (clistNew (fromIntegral slotCountPastUInt32))),+ -- A length the entry's uint32_t cannot hold, refused rather than+ -- truncated to alias a shorter string.+ liveRefusal "a symbol longer than UINT32_MAX bytes" "nn_symbol_intern" (void internOversizedSymbol)+ ]+ tableIntact <-+ runTestM "a refused symbol leaves the table unchanged" $ do+ before <- symbolCount+ _ <- try internOversizedSymbol :: IO (Either CStatusError Symbol)+ after <- symbolCount+ sym <- symbolIntern "after-a-refusal"+ pure $+ if before == after && symbolText sym == "after-a-refusal"+ then Pass+ else Fail ("count " <> T.pack (show before) <> " -> " <> T.pack (show after) <> ", text " <> symbolText sym)+ pure (children ++ live ++ [tableIntact])+ where+ liveRefusal what site action =+ runTestM ("live arena: " <> what <> " raises CAllocationFailed at " <> site) $ do+ alive <- arenaLive+ outcome <- try action+ pure $ case (alive, outcome) of+ (True, Left (CAllocationFailed refused _)) | refused == T.unpack site -> Pass+ (True, Left err) -> Fail ("expected CAllocationFailed at " <> site <> ", got " <> T.pack (show err))+ (True, Right ()) -> Fail "no exception raised"+ (False, _) -> Fail "arenaLive is False inside the suite's bracket"++-- ---------------------------------------------------------------------------+-- 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))+ ),+ -- Grown from the default capacity, so every doubling runs; the+ -- index of an absent key is the C side's NN_ATTRSET_NOT_FOUND.+ runTestM "index finds each key at its sorted position and none for an absent key" $ do+ set <- cattrsetNew 0+ sp <- newStablePtr (0 :: Int)+ syms <- mapM (\i -> symbolIntern ("index_" <> T.pack (show (i :: Int)))) [1 .. 100]+ mapM_ (\sym -> cattrsetInsert set sym (spToCPtr sp)) syms+ cattrsetFreeze set+ absent <- symbolIntern "index_absent"+ found <- mapM (cattrsetIndex set) (sort syms)+ missing <- cattrsetIndex set absent+ freeStablePtr sp+ pure+ ( if found == map Just [0 .. 99] && isNothing missing+ then Pass+ else Fail ("found=" <> T.pack (show found) <> " missing=" <> T.pack (show missing))+ )+ ]++-- ---------------------------------------------------------------------------+-- 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 stores into a blackhole" $ do+ pendingSp <- newStablePtr ("old" :: Text)+ ptr <- cthunkNewBc 0 (castStablePtrToPtr pendingSp)+ _ <- cthunkMarkBlackhole ptr+ computedSp <- newStablePtr ("new" :: Text)+ stored <- cthunkSetComputed ptr (castStablePtrToPtr computedSp)+ state <- cthunkState ptr+ newPayload <- cthunkPayload ptr+ newVal <- deRefStablePtr (castPtrToStablePtr newPayload) :: IO Text+ freeStablePtr pendingSp+ pure+ ( if stored && newVal == "new" && state == 1+ then Pass+ else Fail ("stored=" <> T.pack (show stored) <> " new=" <> newVal <> " state=" <> T.pack (show state))+ ),+ -- set_computed accepts PENDING (direct memoization, no blackhole step)+ runTestM "set_computed stores into a pending thunk" $ do+ sp <- newStablePtr ("x" :: Text)+ ptr <- cthunkNewBc 0 (castStablePtrToPtr sp)+ valSp <- newStablePtr ("v" :: Text)+ stored <- cthunkSetComputed ptr (castStablePtrToPtr valSp)+ freeStablePtr sp+ pure (if stored then Pass else Fail "refused a PENDING thunk"),+ runTestM "set_computed refuses a computed thunk and keeps its value" $ do+ firstSp <- newStablePtr ("first" :: Text)+ ptr <- cthunkNewComputed (castStablePtrToPtr firstSp)+ secondSp <- newStablePtr ("second" :: Text)+ stored <- cthunkSetComputed ptr (castStablePtrToPtr secondSp)+ payload <- cthunkPayload ptr+ kept <- deRefStablePtr (castPtrToStablePtr payload) :: IO Text+ freeStablePtr secondSp+ pure+ ( if not stored && kept == "first"+ then Pass+ else Fail ("stored=" <> T.pack (show stored) <> " kept=" <> kept)+ ),+ -- The shared cells are one static each for the whole process, so a+ -- transition that ran on one would change every null or boolean.+ runTestM "the shared null and boolean cells refuse every transition" $ do+ let cells = [cthunkNull, cthunkBool True, cthunkBool False]+ marked <- mapM cthunkMarkBlackhole cells+ stored <- mapM (`cthunkSetComputed` nullPtr) cells+ repointed <- mapM (`cthunkSetPayload` nullPtr) cells+ let values = map (readThunkValue . Thunk) cells+ expected = map Just [VNull, VBool True, VBool False]+ pure+ ( if not (or (marked ++ stored ++ repointed)) && values == expected+ then Pass+ else Fail ("accepted=" <> T.pack (show (marked, stored, repointed)) <> " values=" <> T.pack (show values))+ ),+ 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+ addErrorTrace _ action = action++ -- The stub keeps no call depth: it exists to answer store questions,+ -- and the ceiling is tested on the evaluators that enforce it.+ withCallFrame (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"+ resolveSymlinks = pure+ 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+ lookupSessionReferences _ = pure Nothing+ storeSourcePath = pure+ evalPolicy = pure unrestrictedPolicy+ checkUri _ = 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 unrestrictedPolicy 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),+ runTest "substring and unsafeDiscardOutputDependency keep a coerced path's context" $+ assertEval "substring-store-path" ("builtins.substring 0 10 " <> storeA <> "-x") (VStr "/nix/store" enclosingCtx)+ `andThen` assertEval+ "discard-output-store-path"+ ("builtins.unsafeDiscardOutputDependency " <> storeA <> "-x")+ (VStr (TE.encodeUtf8 (storeA <> "-x")) enclosingCtx),+ runTest "addDrvOutputDependencies judges a coerced path by its context" $+ assertEvalError+ "add-output-store-path"+ ("builtins.addDrvOutputDependencies " <> storeA <> "-x")+ ("builtins.addDrvOutputDependencies: path " <> storeA <> "-x is not a derivation"),+ -- 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),+ 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\"; }"+ tmpBase <- getTemporaryDirectory+ depDrv <- evalDerivationIO tmpBase depSrc >>= either (fail . ("dep: " <>) . T.unpack) pure+ 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"+ ]+ 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, refused before any read. POSIX only:+ -- a root path literal on Windows names a drive.+ if SI.os == "mingw32"+ then True <$ putStrLn " SKIP coercing the filesystem root errors (no base name): the root is a drive"+ else runTestM "coercing the filesystem root errors (no base name)" $ do+ result <- evalNixIO testDir "\"${/.}\""+ 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.+-- ---------------------------------------------------------------------------+-- Bytecode shared across elements+-- ---------------------------------------------------------------------------++-- | A bytecode instruction as far as the reserved instructions use them: a+-- resolved variable (level, slot), a deferred application, or anything+-- else (opcode, flags).+data CodeShape+ = SlotRef !Word32 !Word32+ | DeferredApp !CodeShape !CodeShape+ | OtherCode !Word8 !Word8+ deriving (Eq, Show)++-- | Read the code at a bytecode index back as a 'CodeShape'.+codeShape :: Word32 -> IO CodeShape+codeShape idx = do+ op <- cbcOpcode idx+ flags <- cbcFlags idx+ arg1 <- cbcArg1 idx+ arg2 <- cbcArg2 idx+ case op of+ OpResolvedVar -> pure (SlotRef arg1 arg2)+ OpApp | flags == appDeferred -> DeferredApp <$> codeShape arg1 <*> codeShape arg2+ _ -> pure (OtherCode op flags)++-- | The instructions and data words evaluating a source adds to the+-- bytecode store, which only 'arenaDestroy' frees. The source must+-- evaluate to an integer computed from everything it builds, so each+-- element has been forced when the counts are read.+bytecodeGrowth :: Text -> IO (Either Text (Word32, Word32))+bytecodeGrowth source = do+ opsBefore <- cbcOpCount+ dataBefore <- cbcDataCount+ forced <- case evalNix source of+ Right (VInt n) -> Right <$> evaluate n+ Right other -> pure (Left ("expected an integer, got " <> T.pack (show other)))+ Left err -> pure (Left err)+ opsAfter <- cbcOpCount+ dataAfter <- cbcDataCount+ pure ((opsAfter - opsBefore, dataAfter - dataBefore) <$ forced)++-- | A source builds one element or two hundred, and the bytecode it adds+-- is the same: the code each element runs is shared, never compiled per+-- element. A first run at one element is discarded, so anything compiled+-- once per process is not counted against either size.+assertNoPerElementBytecode :: (Text -> Text) -> IO TestResult+assertNoPerElementBytecode sourceFor = do+ _ <- bytecodeGrowth (sourceFor "1")+ one <- bytecodeGrowth (sourceFor "1")+ many <- bytecodeGrowth (sourceFor "200")+ pure $ case (one, many) of+ (Right atOne, Right atMany) ->+ assertEqual "instructions and data words added at 1 element and at 200" atOne atMany+ (Left err, _) -> Fail err+ (_, Left err) -> Fail err++testBytecodePerElement :: IO [Bool]+testBytecodePerElement = do+ putStrLn "bytecode/per-element"+ let summed body n = "builtins.foldl' (a: b: a + b) 0 (" <> body n <> ")"+ indices n = "builtins.genList (i: i) " <> n+ sequence+ [ runTestM "reserved instructions are the deferred applications of the slots" $ do+ apply1 <- codeShape reservedApply1+ apply2 <- codeShape reservedApply2+ apply2Function <- cbcArg1 reservedApply2+ pure $+ assertEqual "reservedApply1" (DeferredApp (SlotRef 0 0) (SlotRef 0 1)) apply1+ `andThen` assertEqual "reservedApply2" (DeferredApp (DeferredApp (SlotRef 0 0) (SlotRef 0 1)) (SlotRef 0 2)) apply2+ `andThen` assertEqual "reservedApply2's function" reservedApply1 apply2Function,+ runTestM "map adds no bytecode per element" $+ assertNoPerElementBytecode (summed (\n -> "builtins.map (x: x + 1) (" <> indices n <> ")")),+ runTestM "genList adds no bytecode per element" $+ assertNoPerElementBytecode (summed ("builtins.genList (i: i * 2) " <>)),+ runTestM "concatMap adds no bytecode per element" $+ assertNoPerElementBytecode (summed (\n -> "builtins.concatMap (x: [ x x ]) (" <> indices n <> ")")),+ runTestM "mapAttrs adds no bytecode per attribute" $+ assertNoPerElementBytecode+ ( summed+ (\n -> "builtins.attrValues (builtins.mapAttrs (name: v: v + 1) (builtins.listToAttrs (builtins.genList (i: { name = toString i; value = i; }) " <> n <> ")))")+ ),+ runTestM "zipAttrsWith adds no bytecode per attribute" $+ assertNoPerElementBytecode+ ( summed+ (\n -> "builtins.attrValues (builtins.zipAttrsWith (name: vs: builtins.length vs) (builtins.genList (i: { ${toString i} = i; }) " <> n <> "))")+ ),+ runTestM "inherit (from) in an attr set adds no bytecode per evaluation" $+ assertNoPerElementBytecode (summed ("builtins.genList (i: { inherit ({ v = i; }) v; }.v) " <>)),+ runTestM "inherit (from) in a recursive attr set adds no bytecode per evaluation" $+ assertNoPerElementBytecode (summed ("builtins.genList (i: rec { inherit ({ v = i; }) v; }.v) " <>)),+ runTestM "inherit (from) in a let adds no bytecode per evaluation" $+ assertNoPerElementBytecode (summed ("builtins.genList (i: let inherit ({ v = i; }) v; in v) " <>))+ ]++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 "\"\"") "name must not be empty",+ runTest "212-character derivation name is rejected" $+ failsWith "name-long" (drvWith ("\"" <> T.replicate 212 "a" <> "\"")) "must be no longer than 211 characters",+ runTest "derivation name '..' is rejected" $+ failsWith "name-dotdot" (drvWith "\"..\"") "name '..' is not valid",+ 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",+ -- Upstream's derivationStrict refuses these in its outputs loop: the+ -- result set already has a drvPath attribute, and a repeat would+ -- collide there too.+ runTest "derivation output named drvPath is rejected" $+ failsWith "out-drvpath" (drvWithOutputs "[ \"drvPath\" ]") "invalid derivation output name 'drvPath'",+ runTest "repeated derivation output name is rejected" $+ failsWith "out-duplicate" (drvWithOutputs "[ \"out\" \"out\" ]") "duplicate derivation output 'out'",+ -- The wrapper never calls derivationStrict with an empty list (its+ -- builtins.head fails on the empty outputsList first), so the primop+ -- is called directly.+ runTest "empty derivation output list is rejected" $+ failsWith+ "out-empty"+ "derivationStrict { name = \"p\"; system = \"x86_64-linux\"; builder = \"/bin/sh\"; outputs = [ ]; }"+ "derivation cannot have an empty set of outputs",+ -- 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"+ ]++-- | Which entry names and link targets each host's rules admit+-- ("Nix.Store.EntryName"). The decision is pure, so every host's answer+-- is checked on any host: the grammar's refusals hold everywhere, POSIX+-- holds every other name as bytes, macOS needs UTF-8 without a+-- noncharacter, and Windows refuses what Win32 reserves or rewrites.+testNarNameSafety :: IO [Bool]+testNarNameSafety = do+ putStrLn "store/entry-name-rules"+ let admitted rules names = [(name, admits rules name) | name <- names]+ admits rules name = either (const False) (const True) (checkEntryName rules name)+ allAdmit rules names = assertEqual (T.pack (show rules) <> " verdicts") [(name, True) | name <- names] (admitted rules names)+ allRefuse rules names = assertEqual (T.pack (show rules) <> " verdicts") [(name, False) | name <- names] (admitted rules names)+ everyHost = [PosixNames, DarwinNames, WindowsNames]+ grammarRefused = ["", ".", "..", "a/b", "/", "a\NULb"]+ posixOnlyNames = [name | (name, _) <- posixOnlyEntries]+ notUtf8 = ["caf\xE9", "\xFF", "\xC0\xAF", "\xED\xA0\x80", "\xF4\x90\x80\x80"]+ noncharacters = ["n\xEF\xB7\x90", "n\xEF\xB7\xAF", "n\xEF\xBF\xBE", "n\xEF\xBF\xBF", "n\xF0\x9F\xBF\xBE", "n\xF4\x8F\xBF\xBF"]+ sequence+ [ runTest "the grammar's refusals hold on every host" $+ foldr (keepFirstFail . (`allRefuse` grammarRefused)) Pass everyHost,+ runTest "POSIX holds every other name as its bytes" $+ allAdmit PosixNames (posixOnlyNames <> notUtf8 <> noncharacters <> ["caf\xC3\xA9", "a\x7F"]),+ runTest "macOS holds a name upstream restores there" $+ allAdmit DarwinNames (posixOnlyNames <> ["caf\xC3\xA9", "n\xEE\x80\x80", "n\xEF\xBF\xBD", "n\xEF\xBB\xBF"]),+ runTest "macOS refuses a name that is not UTF-8 or holds a noncharacter" $+ allRefuse DarwinNames (notUtf8 <> noncharacters),+ runTest "Windows refuses device names, trailing dots and spaces, and reserved characters" $+ allRefuse+ WindowsNames+ ( filter (not . BS.isPrefixOf "link-") posixOnlyNames+ <> ["nul.txt", "Nul .txt", "COM0.log", "com3", "lpt9", "COM\xC2\xB2", "COM\xC2\xB3", "C:evil", "a*b", "a?b", "a<b", "a>b", "a|b", "ctl\x1F"]+ ),+ runTest "Windows refuses a name with no UTF-16 form" $+ allRefuse WindowsNames notUtf8,+ runTest "Windows holds ordinary and near-miss names" $+ allAdmit+ WindowsNames+ ["a", ".hidden", "a.b", "a b", " a", "com10", "COM", "NULx", "xNUL.txt", "caf\xC3\xA9", "LPT\xE2\x81\xB9", "a~b", "a\x7F", "n\xEF\xBF\xBE"],+ runTest "a refusal names the entry and the reason" $+ assertEqual+ "refusals"+ [ Left "NAR directory entry name 'aux.c' is a Windows device name",+ Left "NAR directory entry name 'x.' ends in a dot or a space, which Windows strips",+ Left "NAR directory entry name 'a:b' holds a character Windows does not allow in a name",+ Left "NAR directory entry name \"caf\\233\" is not valid UTF-8, which a macOS filesystem cannot hold",+ Left "unsafe NAR directory entry name: \"a/b\""+ ]+ [ checkEntryName WindowsNames "aux.c",+ checkEntryName WindowsNames "x.",+ checkEntryName WindowsNames "a:b",+ checkEntryName DarwinNames "caf\xE9",+ checkEntryName PosixNames "a/b"+ ],+ runTest "a link target is bytes on POSIX and macOS and UTF-16 on Windows" $+ assertEqual+ "target verdicts"+ [Right (), Right (), Left "NAR symlink target \"caf\\233\" is not valid UTF-8, so it has no UTF-16 form for Windows", Right ()]+ [ checkLinkTarget PosixNames "caf\xE9",+ checkLinkTarget DarwinNames "caf\xE9",+ checkLinkTarget WindowsNames "caf\xE9",+ checkLinkTarget WindowsNames "../a b/c:d"+ ],+ -- The Windows rules check a name's bytes, where the check they+ -- replace read it as text; on every name both could judge (UTF-8+ -- without a character the old check let through and Windows+ -- refuses at the create) they must agree.+ runTestM "the Windows rules refuse what the text check they replace refused" $ do+ result <-+ QC.quickCheckWithResult QC.stdArgs {QC.chatty = False, QC.maxSuccess = 5000} $+ QC.forAll genDeviceLikeName $ \name ->+ either (const False) (const True) (checkEntryName WindowsNames (TE.encodeUtf8 name))+ QC.=== legacyTextNameSafety name+ pure (if QC.isSuccess result then Pass else Fail (T.pack (QC.output result)))+ ]+ where+ keepFirstFail Pass acc = acc+ keepFirstFail failure _ = failure++-- | The text-form name check the Windows rules replaced, kept verbatim as+-- the oracle their byte-form port is tested against.+legacyTextNameSafety :: Text -> Bool+legacyTextNameSafety 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+ 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+ deviceDigit c = isDigit c || c == '\185' || c == '\178' || c == '\179'++-- | Names shaped like a Windows device: a stem in mixed case or a near+-- miss, then a digit (plain, superscript, or a superscript nine that is+-- not a device digit), spaces, and nothing, an extension, or more+-- pieces, among them separators, dots and letters whose uppercase is+-- longer than themselves. About one name in twenty is refused by the+-- device rule alone.+genDeviceLikeName :: QC.Gen Text+genDeviceLikeName = do+ stem <- QC.elements ["con", "CON", "Prn", "aux", "AUX", "nul", "NuL", "com", "COM", "lpt", "LpT", "x", "\305", "\329"]+ digit <- QC.elements ["", "0", "1", "9", "10", "\185", "\178", "\179", "\8313", "\233"]+ spaces <- QC.elements ["", " ", " "]+ rest <- QC.frequency [(3, pure ""), (3, ("." <>) <$> pieces), (2, pieces)]+ pure (stem <> digit <> spaces <> rest)+ where+ pieces =+ T.concat+ <$> QC.resize 4 (QC.listOf (QC.elements [".", "..", " ", "x", "txt", "\233", "\223", "\64256", ":", "\\", "/", "\NUL", "con", "1"]))++-- | Entry names upstream's restore writes on POSIX as it finds them and+-- Windows cannot hold, each as compared against @nix-store --restore@+-- 2.33.2: device stems (one with a superscript digit), trailing dots and+-- spaces, colons (perl's man pages carry them), a backslash, the+-- punctuation Win32 reserves, a control character, and links pointing+-- at such names.+posixOnlyEntries :: [(BS.ByteString, NAR.NarEntry)]+posixOnlyEntries =+ [ ("COM1", NAR.NarRegular False "com1"),+ ("ExtUtils::MakeMaker.3", NAR.NarRegular False "man"),+ ("LPT\xC2\xB9.txt", NAR.NarRegular False "lpt"),+ ("NUL", NAR.NarRegular False "nul"),+ ("a:b", NAR.NarRegular False "colon"),+ ("a\\b", NAR.NarRegular False "backslash"),+ ("aux.c", NAR.NarRegular False "aux"),+ ("con.h", NAR.NarRegular False "con"),+ ("ctl\x01", NAR.NarRegular False "control"),+ ("dir.", NAR.NarDirectory [("prn", NAR.NarRegular True "prn")]),+ ("link-aux", NAR.NarSymlink "aux.c"),+ ("link-colon", NAR.NarSymlink "a:b"),+ ("link-dot", NAR.NarSymlink "x."),+ ("q\"*?<>|", NAR.NarRegular False "reserved"),+ ("x.", NAR.NarRegular False "dot"),+ ("y ", NAR.NarRegular False "space")+ ]++-- | 'posixOnlyEntries' as one directory.+posixOnlyTree :: NAR.NarEntry+posixOnlyTree = NAR.NarDirectory posixOnlyEntries++-- | 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"+ ]++-- | An inherited name and whether its select reads that name, as the one+-- path segment, from slot 0 of the env it runs in.+inheritedSelect :: BcInheritedName -> IO (Text, Bool)+inheritedSelect (BcInheritedName sym selectIdx) = do+ op <- cbcOpcode selectIdx+ pathLen <- cbcShortArg selectIdx+ target <- cbcArg1 selectIdx+ pathOff <- cbcArg2 selectIdx+ targetOp <- cbcOpcode target+ level <- cbcArg1 target+ slot <- cbcArg2 target+ keyTag <- cbcData pathOff+ keySym <- cbcData (pathOff + 1)+ pure+ ( symbolText (Symbol sym),+ op == OpSelect && pathLen == 1 && targetOp == OpResolvedVar && level == 0 && slot == 0 && keyTag == attrkeyStatic && keySym == sym+ )++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-from binding" $ do+ idx <-+ compileExpr+ ( EAttrs+ False+ [InheritFrom (EVar "s") ["x", "y"]]+ NoCaptureInfo+ )+ op <- cbcOpcode idx+ count <- cbcShortArg idx+ dataOff <- cbcArg1 idx+ bindings <- decodeBcBindings 1 dataOff+ case bindings of+ [BcInheritFrom _ names]+ | op == OpAttrs && count == 1 -> do+ selects <- mapM inheritedSelect names+ pure (assertEqual "inherited names and their selects" [("x", True), ("y", True)] selects)+ _ -> pure (Fail "inherit binding mismatch"),+ -- A plain inherit has no lookup of its own: it carries the variable,+ -- bound like any other, for the evaluator to read in the outer env.+ runTestM "compile plain inherit with the variable it copies" $ do+ idx <- compileExpr (EAttrs False [Inherit "x" (EResolvedVar 0 0)] NoCaptureInfo)+ dataOff <- cbcArg1 idx+ bindings <- decodeBcBindings 1 dataOff+ case bindings of+ [BcInherit sym valIdx] -> do+ valOp <- cbcOpcode valIdx+ pure $+ if symbolText (Symbol sym) == "x" && valOp == OpResolvedVar+ then Pass+ else Fail "expected x to carry the resolved variable"+ _ -> pure (Fail "expected one inherit binding"),+ 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, the precedence fold, include expansion over an in-memory+-- file map, and the file cascade. 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 -- A root the platform agrees is absolute: "/" is not one on+ -- Windows, for System.FilePath and for upstream's canonPath alike.+ root = if SI.os == "mingw32" then "C:\\" else "/"+ etcNix = root </> "etc" </> "nix"+ etcXdg = root </> "etc" </> "xdg"+ etcXdgLocal = root </> "etc" </> "xdg-local"+ configHome = root </> "home" </> "u" </> ".config"+ systemConf = etcNix </> "nix.conf"+ xdgConf dir = dir </> "nix" </> "nix.conf"+ -- The expander's one effect, served from an in-memory map: a path+ -- is in it or there is nothing there.+ readFrom files path = Identity (maybe (Left Config.ConfigFileMissing) Right (Map.lookup path files))+ resolveWith files paths env = runIdentity (Config.loadConfig (readFrom files) paths env)+ substitutersOf files paths env = ncSubstituters <$> resolveWith files paths env+ fileMap = Map.mapKeys asciiOsPath . Map.fromList+ -- The same, over the cascade a 'ConfigLocations' computes.+ substitutersAt files locations env = Config.configFilePaths locations >>= \paths -> substitutersOf files paths env+ firstPathOf locations = listToMaybe <$> Config.configFilePaths locations+ -- Successive texts as successive files, weakest first.+ resolveTexts texts =+ let paths = [asciiOsPath (root </> "src" </> (show index ++ ".conf")) | index <- [1 .. length texts]]+ in resolveWith (Map.fromList (zip paths texts)) paths Nothing+ subs = fmap ncSubstituters . resolveTexts+ keys = fmap ncTrustedPublicKeys . resolveTexts+ uris = fmap ncAllowedUris . resolveTexts+ cascade =+ Config.ConfigLocations+ { Config.clConfDir = Nothing,+ Config.clSystemConfDir = Just (asciiOsPath etcNix),+ Config.clUserConfFiles = Nothing,+ Config.clConfigHome = asciiOsPath configHome,+ Config.clConfigDirs = map asciiOsPath [etcXdg, etcXdgLocal]+ }+ cascadeFiles =+ fileMap+ [ (systemConf, "substituters = https://system"),+ (xdgConf etcXdgLocal, "substituters = https://xdg-local"),+ (xdgConf etcXdg, "substituters = https://xdg"),+ (xdgConf configHome, "substituters = https://home")+ ]+ listVar = BS8.pack . intercalate [searchPathSeparator]+ depth = fmap ncMaxCallDepth . resolveTexts+ depthOf value = depth ["max-call-depth = " <> value]+ refused = either (const True) (const False) . depthOf+ -- Bytes with no UTF-8 reading (0xE9, Latin-1's e-acute). They are+ -- a host path on POSIX and none on Windows, whose paths are UTF-16,+ -- so the cases holding them branch on whether they spell one.+ latin1 = "caf\xE9" :: BS.ByteString+ -- What a lossy decode would open instead: U+FFFD's UTF-8.+ replaced = "caf\xEF\xBF\xBD"+ nixConfName = asciiOsPath "nix.conf"+ 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 quoting the line as truncated, untrimmed" $+ let expected = "nix.conf: syntax error in configuration line ' substituters https://a ' in '" <> T.pack (root </> "src" </> "1.conf") <> "'"+ in assertEqual "syntax" (Left expected) (resolveTexts [" substituters https://a # comment"]),+ runTest "the default config is empty" $+ assertEqual "default" (Right []) (subs []),+ -- Tokens, as upstream's tokenizeString splits them: on space, tab,+ -- CR and LF only, which nix config show 2.33.2 agrees with.+ runTest "a value splits on space, tab and CR, and not on other spaces" $+ assertEqual+ "separators"+ (Right ["https://a\xA0https://b\vhttps://c\fhttps://d", "https://e", "https://f", "https://g"])+ (uris ["allowed-uris = https://a\xC2\xA0https://b\vhttps://c\fhttps://d https://e\rhttps://f\thttps://g"]),+ runTest "a CRLF line ends where its LF does" $+ assertEqual "crlf" (Right ["https://a"]) (subs ["substituters = https://a\r\n"]),+ runTest "a Boolean value ending in a no-break space is upstream's invalid-value error" $+ assertEqual "nbsp-bool" (Left "Boolean setting 'pure-eval' has invalid value 'true\xA0'") (ncPureEval <$> resolveTexts ["pure-eval = true\xC2\xA0"]),+ -- Bytes with no UTF-8 reading in a setting.+ runTest "a comment and an unknown setting holding a byte with no UTF-8 reading are never decoded" $+ assertEqual "bytes-ignored" (Right ["https://a"]) (substitutersOf Map.empty [] (Just ("trusted-users = " <> latin1 <> "\nsubstituters = https://a # " <> latin1))),+ runTest "a list setting holding a byte with no UTF-8 reading is refused under its own name" $+ let expected = "setting 'substituters' has invalid value 'https://caf\xFFFD.example': not valid UTF-8"+ in assertEqual "list-bytes" (Left expected) (substitutersOf Map.empty [] (Just ("extra-binary-caches = https://" <> latin1 <> ".example"))),+ runTest "a Boolean or integer setting holding such a byte is upstream's invalid-value error" $+ assertEqual+ "scalar-bytes"+ (Left "Boolean setting 'pure-eval' has invalid value 'true\xFFFD'", Left "setting 'max-call-depth' has invalid value '5\xFFFD'")+ (ncPureEval <$> resolveTexts ["pure-eval = true\xE9"], depthOf "5\xE9"),+ -- max-call-depth: a scalar under upstream's unsigned-integer grammar.+ runTest "max-call-depth defaults to upstream's 10000" $+ assertEqual "depth-default" (Right 10000) (depth []),+ runTest "max-call-depth parses a decimal" $+ assertEqual "depth-decimal" (Right 5) (depthOf "5"),+ runTest "a higher source replaces max-call-depth" $+ assertEqual "depth-replace" (Right 7) (depth ["max-call-depth = 5", "max-call-depth = 7"]),+ runTest "max-call-depth takes a binary unit suffix in either case" $+ assertEqual "depth-unit" (Right [10240, 5120, 1048576]) (traverse depthOf ["10K", "5k", "1M"]),+ runTest "max-call-depth takes a leading plus and leading zeros" $+ assertEqual "depth-plus" (Right [5, 7]) (traverse depthOf ["+5", "007"]),+ runTest "max-call-depth accepts the top of unsigned int" $+ assertEqual "depth-top" (Right 4294967295) (depthOf "4294967295"),+ runTest "an invalid max-call-depth is refused with upstream's words" $+ assertEqual "depth-invalid" (Left "setting 'max-call-depth' has invalid value 'abc'") (depthOf "abc"),+ runTest "max-call-depth refuses a sign, a fraction, hex, an unknown unit, an empty value and a second token" $+ assertEqual "depth-refused" (True, 6) (all refused rejects, length rejects),+ runTest "max-call-depth refuses a value past unsigned int, a unit product included" $+ assertEqual "depth-range" True (refused "4294967296" && refused "5G"),+ runTest "extra-max-call-depth is an unknown name, not an append" $+ assertEqual "depth-extra" (Right 10000) (depth ["extra-max-call-depth = 5"]),+ -- Includes.+ runTest "an include is spliced in at its line, resolved against the including file's directory" $+ let files =+ fileMap+ [ (systemConf, "substituters = https://a\ninclude site.conf\nextra-substituters = https://c"),+ (etcNix </> "site.conf", "extra-substituters = https://b")+ ]+ in assertEqual "inline" (Right ["https://a", "https://b", "https://c"]) (substitutersOf files [asciiOsPath systemConf] Nothing),+ runTest "a nested include resolves against the nested file, with .. collapsed" $+ let files =+ fileMap+ [ (systemConf, "include sub/one.conf"),+ (etcNix </> "sub" </> "one.conf", "include ../two.conf"),+ (etcNix </> "two.conf", "substituters = https://two")+ ]+ in assertEqual "nested" (Right ["https://two"]) (substitutersOf files [asciiOsPath systemConf] Nothing),+ runTest "!include of a file that cannot be read is skipped" $+ let files = fileMap [(systemConf, "substituters = https://a\n!include missing.conf\nextra-substituters = https://b")]+ in assertEqual "optional" (Right ["https://a", "https://b"]) (substitutersOf files [asciiOsPath systemConf] Nothing),+ runTest "include of a missing file is an error in upstream's words, naming both files" $+ let files = fileMap [(systemConf, "include missing.conf")]+ expected = "nix.conf: file '" <> T.pack (etcNix </> "missing.conf") <> "' included from '" <> T.pack systemConf <> "' not found"+ in assertEqual "required" (Left expected) (substitutersOf files [asciiOsPath systemConf] Nothing),+ -- A file that exists but will not open: upstream drops it, here a+ -- required include of it is an error in its own words.+ runTest "include of a file that exists but cannot be read is an error, not a silent drop" $+ let locked = etcNix </> "locked.conf"+ readLocked path = Identity (if path == asciiOsPath locked then Left Config.ConfigFileUnreadable else Right "include locked.conf")+ expected = "nix.conf: file '" <> T.pack locked <> "' included from '" <> T.pack systemConf <> "' cannot be read"+ in assertEqual "unreadable" (Left expected) (ncSubstituters <$> runIdentity (Config.loadConfig readLocked [asciiOsPath systemConf] Nothing)),+ runTest "!include of a file that exists but cannot be read is skipped" $+ let locked = etcNix </> "locked.conf"+ readLocked path = Identity (if path == asciiOsPath locked then Left Config.ConfigFileUnreadable else Right "!include locked.conf\nsubstituters = https://a")+ in assertEqual "unreadable-optional" (Right ["https://a"]) (ncSubstituters <$> runIdentity (Config.loadConfig readLocked [asciiOsPath systemConf] Nothing)),+ runTest "an include cycle is refused rather than followed" $+ let files = fileMap [(systemConf, "include site.conf"), (etcNix </> "site.conf", "include nix.conf")]+ in assertLeft "cycle" (substitutersOf files [asciiOsPath systemConf] Nothing),+ runTest "an include with no path or two paths is a syntax error" $+ assertEqual "arity" [True, True, True] (map (either (const True) (const False) . resolveTexts . pure) ["include", "include a b", "!include"]),+ runTest "a relative include from NIX_CONFIG is refused as not absolute" $+ let expected = "nix.conf: not an absolute path: '" <> T.pack ("." </> "rel.conf") <> "'"+ in assertEqual "env-relative" (Left expected) (substitutersOf Map.empty [] (Just "include rel.conf")),+ runTest "an absolute include from NIX_CONFIG is expanded" $+ let files = fileMap [(etcNix </> "x.conf", "substituters = https://x")]+ in assertEqual "env-absolute" (Right ["https://x"]) (substitutersOf files [] (Just ("include " <> BS8.pack (etcNix </> "x.conf")))),+ -- An include target is bytes from the line to the open (#244): the+ -- file its bytes name is read, never the one a lossy decode names.+ runTest "an include target with no UTF-8 reading opens the file its bytes name" $+ let target = latin1 <> ".conf"+ including = fileMap [(systemConf, "include " <> target)]+ decoy = maybe including (\name -> Map.insert (asciiOsPath etcNix OP.</> name) "substituters = https://replacement" including) (hostPathFromBytes (replaced <> ".conf"))+ in case hostPathFromBytes target of+ Just name ->+ let files = Map.insert (asciiOsPath etcNix OP.</> name) "substituters = https://latin1" decoy+ in assertEqual "include-bytes" (Right ["https://latin1"]) (substitutersOf files [asciiOsPath systemConf] Nothing)+ Nothing ->+ let expected = "nix.conf: file 'caf\xFFFD.conf' included from '" <> T.pack systemConf <> "' is not valid UTF-8, so it names no Windows file"+ in assertEqual "include-bytes-windows" (Left expected) (substitutersOf decoy [asciiOsPath systemConf] Nothing),+ runTest "!include of a target with no UTF-8 reading that names nothing goes on without it" $+ let files = fileMap [(systemConf, "!include " <> latin1 <> ".conf\nsubstituters = https://a")]+ in assertEqual "optional-bytes" (Right ["https://a"]) (substitutersOf files [asciiOsPath systemConf] Nothing),+ -- The directories a cascade names keep their bytes too, so the+ -- system file under such a NIX_CONF_DIR, and an include beside a+ -- file under such a config home, are the files those bytes name.+ runTest "NIX_CONF_DIR holding a byte with no UTF-8 reading names the system file by its bytes" $+ let confDir = BS8.pack (etcNix ++ "-") <> latin1+ located = cascade {Config.clConfDir = Just confDir, Config.clUserConfFiles = Just ""}+ in case hostPathFromBytes confDir of+ Just dir ->+ let files = Map.fromList [(dir OP.</> nixConfName, "include site.conf"), (dir OP.</> asciiOsPath "site.conf", "substituters = https://conf-dir")]+ in assertEqual "conf-dir-bytes" (Right (Just (dir OP.</> nixConfName)), Right ["https://conf-dir"]) (firstPathOf located, substitutersAt files located Nothing)+ Nothing -> assertLeft "conf-dir-bytes-windows" (firstPathOf located),+ runTest "an include beside a user file under a config home holding such a byte resolves beneath it" $+ case hostPathFromBytes (BS8.pack (configHome ++ "-") <> latin1) of+ Just home ->+ let located = cascade {Config.clSystemConfDir = Nothing, Config.clConfigHome = home, Config.clConfigDirs = []}+ userDir = home OP.</> asciiOsPath "nix"+ files = Map.fromList [(userDir OP.</> nixConfName, "include more.conf"), (userDir OP.</> asciiOsPath "more.conf", "substituters = https://home-bytes")]+ in assertEqual "home-bytes" (Right ["https://home-bytes"]) (substitutersAt files located Nothing)+ Nothing -> assertEqual "home-bytes-windows" "mingw32" SI.os,+ -- The cascade: which files, in which order, and who wins.+ runTest "the cascade is the system file, XDG_CONFIG_DIRS back to front, then the config home" $+ assertEqual "order" (Right (map asciiOsPath [systemConf, xdgConf etcXdgLocal, xdgConf etcXdg, xdgConf configHome])) (Config.configFilePaths cascade),+ runTest "the config home beats XDG_CONFIG_DIRS beats the system file" $+ assertEqual "home-wins" (Right ["https://home"]) (substitutersAt cascadeFiles cascade Nothing),+ runTest "the first XDG_CONFIG_DIRS entry beats the later ones" $+ assertEqual "xdg-first" (Right ["https://xdg"]) (substitutersAt (Map.delete (asciiOsPath (xdgConf configHome)) cascadeFiles) cascade Nothing),+ runTest "a listed file that cannot be read is absent, not an error" $+ assertEqual "absent" (Right ["https://system"]) (substitutersAt (Map.filterWithKey (\path _ -> path == asciiOsPath systemConf) cascadeFiles) cascade Nothing),+ runTest "extra- in a stronger file widens what a weaker file set" $+ let files = fileMap [(systemConf, "trusted-public-keys = k1"), (xdgConf configHome, "extra-trusted-public-keys = k2")]+ in assertEqual "widen-across-files" (Right ["k1", "k2"]) (fmap ncTrustedPublicKeys (Config.configFilePaths cascade >>= \paths -> resolveWith files paths Nothing)),+ runTest "NIX_CONFIG beats every file" $+ assertEqual "env-wins" (Right ["https://env"]) (substitutersAt cascadeFiles cascade (Just "substituters = https://env")),+ runTest "NIX_USER_CONF_FILES replaces the XDG files, first listed strongest" $+ let listed = cascade {Config.clUserConfFiles = Just (listVar [etcNix </> "a.conf", etcNix </> "b.conf"])}+ in assertEqual "listed" (Right (map asciiOsPath [systemConf, etcNix </> "b.conf", etcNix </> "a.conf"])) (Config.configFilePaths listed),+ runTest "an empty NIX_USER_CONF_FILES names no user files" $+ assertEqual "none" (Right [asciiOsPath systemConf]) (Config.configFilePaths cascade {Config.clUserConfFiles = Just ""}),+ runTest "empty NIX_USER_CONF_FILES entries are dropped" $+ assertEqual "empties" (Right (map asciiOsPath [systemConf, etcNix </> "a.conf"])) (Config.configFilePaths cascade {Config.clUserConfFiles = Just (listVar ["", etcNix </> "a.conf", ""])}),+ runTest "NIX_CONF_DIR moves the system file" $+ assertEqual "conf-dir" (Right (Just (asciiOsPath (etcXdg </> "nix.conf")))) (firstPathOf cascade {Config.clConfDir = Just (BS8.pack etcXdg)}),+ runTest "an empty NIX_CONF_DIR is unset" $+ assertEqual "conf-dir-empty" (Right (Just (asciiOsPath systemConf))) (firstPathOf cascade {Config.clConfDir = Just ""}),+ runTest "a relative NIX_CONF_DIR is refused with upstream's complaint" $+ assertEqual "conf-dir-relative" (Left "nix.conf: not an absolute path: 'rel'") (firstPathOf cascade {Config.clConfDir = Just "rel"}),+ runTest "dot and dot-dot in NIX_CONF_DIR collapse, so the system file is named canonically" $+ assertEqual "conf-dir-canonical" (Right (Just (asciiOsPath systemConf))) (firstPathOf cascade {Config.clConfDir = Just (BS8.pack (root </> "etc" </> "x" </> ".." </> "." </> "nix"))}),+ -- A UNC directory keeps its server on Windows, where System.FilePath+ -- and std::filesystem both read it as the drive; a doubled POSIX+ -- root is one root, as upstream's canonPath folds it.+ runTest "a UNC NIX_CONF_DIR keeps its root, and its include resolves beneath it" $+ let (dir, expected) =+ if SI.os == "mingw32"+ then ("\\\\server\\share", "\\\\server\\share\\nix.conf")+ else ("//server/share", "/server/share/nix.conf")+ uncConf = takeDirectory expected </> "site.conf"+ files = fileMap [(expected, "include site.conf"), (uncConf, "substituters = https://unc")]+ unc = cascade {Config.clConfDir = Just (BS8.pack dir), Config.clUserConfFiles = Just ""}+ in assertEqual "unc" (Right (Just (asciiOsPath expected)), Right ["https://unc"]) (firstPathOf unc, substitutersAt files unc Nothing),+ runTest "a platform with no machine-wide directory has no system file" $+ assertEqual "no-system-dir" (Right (map asciiOsPath [xdgConf etcXdgLocal, xdgConf etcXdg, xdgConf configHome])) (Config.configFilePaths cascade {Config.clSystemConfDir = Nothing}),+ runTest "NIX_CONF_DIR names a system file even where the platform has no directory for one" $+ assertEqual "conf-dir-no-default" (Right (Just (asciiOsPath (etcXdg </> "nix.conf")))) (firstPathOf cascade {Config.clSystemConfDir = Nothing, Config.clConfDir = Just (BS8.pack etcXdg)})+ ]+ where+ rejects = ["-1", "1.5", "0x10", "5X", "", "5 6"]++-- | A test path as the config expander names it. Test paths spelled+-- this way are ASCII, which 'OP.unsafeFromChar' narrows to one path unit+-- exactly on every platform.+asciiOsPath :: FilePath -> OsPath+asciiOsPath = OP.pack . map OP.unsafeFromChar++-- | 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+-- ---------------------------------------------------------------------------++-- | The names upstream 2.24.9's base environment binds with default+-- settings, recorded from its source: every @addConstant@ and @addPrimOp@+-- in @createBaseEnv@ (src/libexpr/primops.cc) and every @RegisterPrimOp@+-- in libexpr and libflake not gated on an experimental feature, under the+-- name it registers. @__importNative@ and @__exec@ need+-- @allow-unsafe-native-code-during-evaluation@ and are not here.+upstreamBaseEnvNames :: Set.Set Text+upstreamBaseEnvNames =+ Set.fromList+ [ "__add",+ "__addDrvOutputDependencies",+ "__addErrorContext",+ "__all",+ "__any",+ "__appendContext",+ "__attrNames",+ "__attrValues",+ "__bitAnd",+ "__bitOr",+ "__bitXor",+ "__catAttrs",+ "__ceil",+ "__compareVersions",+ "__concatLists",+ "__concatMap",+ "__concatStringsSep",+ "__convertHash",+ "__currentSystem",+ "__currentTime",+ "__deepSeq",+ "__div",+ "__elem",+ "__elemAt",+ "__fetchurl",+ "__filter",+ "__filterSource",+ "__findFile",+ "__floor",+ "__foldl'",+ "__fromJSON",+ "__functionArgs",+ "__genList",+ "__genericClosure",+ "__getAttr",+ "__getContext",+ "__getEnv",+ "__groupBy",+ "__hasAttr",+ "__hasContext",+ "__hashFile",+ "__hashString",+ "__head",+ "__intersectAttrs",+ "__isAttrs",+ "__isBool",+ "__isFloat",+ "__isFunction",+ "__isInt",+ "__isList",+ "__isPath",+ "__isString",+ "__langVersion",+ "__length",+ "__lessThan",+ "__listToAttrs",+ "__mapAttrs",+ "__match",+ "__mul",+ "__nixPath",+ "__nixVersion",+ "__parseDrvName",+ "__partition",+ "__path",+ "__pathExists",+ "__readDir",+ "__readFile",+ "__readFileType",+ "__replaceStrings",+ "__seq",+ "__sort",+ "__split",+ "__splitVersion",+ "__storeDir",+ "__storePath",+ "__stringLength",+ "__sub",+ "__substring",+ "__tail",+ "__toFile",+ "__toJSON",+ "__toPath",+ "__toXML",+ "__trace",+ "__traceVerbose",+ "__tryEval",+ "__typeOf",+ "__unsafeDiscardOutputDependency",+ "__unsafeDiscardStringContext",+ "__unsafeGetAttrPos",+ "__warn",+ "__zipAttrsWith",+ "abort",+ "baseNameOf",+ "break",+ "builtins",+ "derivation",+ "derivationStrict",+ "dirOf",+ "false",+ "fetchGit",+ "fetchMercurial",+ "fetchTarball",+ "fromTOML",+ "import",+ "isNull",+ "map",+ "null",+ "placeholder",+ "removeAttrs",+ "scopedImport",+ "throw",+ "toString",+ "true"+ ]++-- | The two @addConstant@ calls marked @impureOnly@, which upstream skips+-- under @pure-eval@.+upstreamImpureOnlyNames :: Set.Set Text+upstreamImpureOnlyNames = Set.fromList ["__currentSystem", "__currentTime"]++-- | Binding accepts exactly the names upstream's base environment holds,+-- and the root environment binds exactly the names binding accepts, under+-- each policy, every one of them to a value that forces.+testStaticGlobalsSync :: IO [Bool]+testStaticGlobalsSync = do+ putStrLn "resolve/static-globals-sync"+ let pureNames = Set.difference upstreamBaseEnvNames upstreamImpureOnlyNames+ unrestrictedRoot <- rootEnvNames unrestrictedPolicy+ pureRoot <- rootEnvNames purePolicy+ sequence+ [ runTest "binding accepts upstream's base environment" $+ assertSameNames "static globals" upstreamBaseEnvNames staticGlobalNames,+ runTest "pure evaluation drops upstream's impure-only constants" $+ assertEqual "impure-only" upstreamImpureOnlyNames impureOnlyGlobalNames+ `andThen` assertSameNames "pure scope" pureNames (rootScopeNames purePolicy),+ runTest "the root env binds exactly those names" $+ assertSameNames "root env" upstreamBaseEnvNames unrestrictedRoot+ `andThen` assertSameNames "pure root env" pureNames pureRoot,+ runTest "every root env name forces" $+ assertEqual+ "names that fail to force"+ []+ [ name+ | name <- Set.toList upstreamBaseEnvNames,+ evalNix ("builtins.seq " <> name <> " true") /= Right (VBool True)+ ]+ ]+ where+ rootEnvNames policy = case builtinEnv policy 0 [] of+ Env envPtr -> do+ scopePtr <- cenvLazyScope envPtr+ pure (Set.fromList (attrSetKeys (AttrSet (castPtr scopePtr))))+ assertSameNames label expected actual =+ assertEqual+ (label <> ": (missing, extra)")+ ([], [])+ (Set.toList (Set.difference expected actual), Set.toList (Set.difference actual expected))++-- ---------------------------------------------------------------------------+-- Tests: variables are bound before evaluation (upstream's bindVars)+-- ---------------------------------------------------------------------------++-- | Assert that binding rejects a source for the named variable, before+-- anything is evaluated.+assertUnbound :: Text -> Text -> Text -> TestResult+assertUnbound label source name = case parseNix testBaseDir "<test>" source of+ Left err -> assertEqual label (UndefinedVariable name) err+ Right expr -> Fail (label <> ": bound, got " <> T.pack (show expr))++-- | Assert that every variable in a source binds.+assertBinds :: Text -> Text -> TestResult+assertBinds label source = assertRight label (parseNix testBaseDir "<test>" source) (const Pass)++-- | Upstream rejects a variable no scope binds and no @with@ could supply+-- while binding (nixexpr.cc, ExprVar::bindVars), wherever it sits; only a+-- name under a @with@ waits for evaluation. Each expectation here is+-- nix-instantiate 2.33.2's.+testUndefinedVariables :: IO [Bool]+testUndefinedVariables = do+ putStrLn "resolve/undefined-variables"+ sequence+ [ runTest "an unbound variable in an unused let binding is rejected" $+ assertUnbound "unused-let" "let x = y; in 1" "y",+ runTest "an unbound variable on a branch not taken is rejected" $+ assertUnbound "untaken-branch" "if true then 1 else y" "y",+ runTest "tryEval has nothing to catch: binding fails first" $+ assertUnbound "tryEval" "builtins.tryEval (let x = y; in 1)" "y",+ runTest "the failure reads as upstream's" $+ assertEvalError "message" "builtins.tryEval (let x = y; in 1)" "undefined variable 'y'",+ runTest "the first unbound variable in source order is reported" $+ assertUnbound "first" "let a = b; c = d; in 1" "b",+ runTest "a name under a with binds" $+ assertBinds "with-binds" "with {}; y",+ runTest "a name no with supplies fails when evaluated" $+ assertEvalError "with-eval" "with {}; y" "undefined variable 'y'",+ runTest "an unused binding may read a name only a with could supply" $+ assertEval "with-unused" "with {}; let x = y; in 1" (VInt 1),+ runTest "a with supplies a name a let binding reads" $+ assertEval "with-let" "with { y = 3; }; let x = y; in x" (VInt 3),+ runTest "a nested let shadows the enclosing one" $+ assertEval "let-shadow" "let y = 1; in let y = 2; x = y; in x" (VInt 2),+ runTest "a nested rec reads the enclosing rec" $+ assertEval "rec-nested" "rec { a = 1; b = rec { c = a; }.c; }.b" (VInt 1),+ runTest "a rec reading a name it does not define is rejected" $+ assertUnbound "rec-missing" "rec { a = b; }" "b",+ runTest "a lambda formal shadows a with" $+ assertEval "formal-over-with" "(x: with { x = 2; }; x) 1" (VInt 1),+ runTest "a formal default reads a sibling formal" $+ assertEval "formal-default" "({ a, b ? a }: b) { a = 3; }" (VInt 3),+ runTest "an unbound formal default is rejected" $+ assertUnbound "formal-default-unbound" "{ a ? b }: a" "b",+ runTest "inherit of an unbound name in a let is rejected" $+ assertUnbound "let-inherit" "let inherit x; in 1" "x",+ runTest "inherit of an unbound name in a set is rejected" $+ assertUnbound "set-inherit" "{ inherit x; }" "x",+ runTest "inherit of an unbound name in a rec is rejected" $+ assertUnbound "rec-inherit" "rec { inherit x; }" "x",+ runTest "inherit reads a name only a with supplies" $+ assertEval "with-inherit" "with { x = 5; }; rec { inherit x; }.x" (VInt 5),+ runTest "inherit under an empty with fails only when selected" $+ assertEvalError "with-inherit-missing" "with {}; { inherit x; }.x" "undefined variable 'x'",+ runTest "inherit in a rec reads the enclosing binding, not its own" $+ assertEval "rec-inherit-outer" "(x: rec { inherit x; y = x; }.y) 4" (VInt 4),+ runTest "inherit from an unbound source is rejected" $+ assertUnbound "inherit-from-unbound" "{ inherit (s) x; }" "s",+ runTest "inherit from selects lazily" $+ assertEval "inherit-from-lazy" "let s = {}; in builtins.attrNames { inherit (s) x; } == [ \"x\" ]" (VBool True),+ runTest "inherit from fails when the attribute is selected" $+ assertEvalFail "inherit-from-missing" "let s = {}; in { inherit (s) x; }.x",+ runTest "an inherit clause cannot repeat a name" $+ assertParseFail "inherit-repeat" "let x = 1; in { inherit x x; }",+ runTest "an inherit-from clause cannot repeat a name" $+ assertParseFail "inherit-from-repeat" "let s = { x = 1; }; in { inherit (s) x x; }",+ runTest "__curPos is upstream syntax, not a variable" $+ assertEval "curpos" "if false then __curPos else 1" (VInt 1),+ runTest "inherit in a static-key rec reads the enclosing rec" $+ assertEval "rec-inherit-static" "rec { q = 0; x = 1; y = rec { a = 1; inherit x; }; }.y.x" (VInt 1),+ runTest "a __-prefixed builtin on a branch not taken binds" $+ assertEval "prefixed-untaken" "if false then __typeOf 1 else 2" (VInt 2),+ runTest "a __-prefixed builtin is the builtin" $+ assertEval "prefixed-value" "__typeOf 1 == \"int\"" (VBool True),+ runTest "a global is not shadowed by a with" $+ assertEval "prefixed-over-with" "with { __typeOf = 1; }; __typeOf 2 == \"int\"" (VBool True),+ runTest "fetchMercurial binds as a builtin" $+ assertEval "fetchMercurial-binds" "builtins.typeOf fetchMercurial == \"lambda\"" (VBool True),+ runTest "calling fetchMercurial fails as a builtin nova-nix lacks" $+ assertEvalError "fetchMercurial-call" "fetchMercurial {}" "unknown builtin 'fetchMercurial'",+ runTest "pure evaluation leaves upstream's impure-only constants unbound" $+ assertEqual "pure-currentTime" (Left "undefined variable '__currentTime'") (evalNixWith purePolicy "if false then __currentTime else 1"),+ runTest "pure evaluation binds the other globals" $+ assertEqual "pure-typeOf" (Right (VInt 1)) (evalNixWith purePolicy "if false then __typeOf else 1")+ ]++-- | The same check for files: upstream binds each file as it parses it, so+-- an import fails on an unbound variable the importing code never reaches,+-- and @scopedImport@ binds its scope's names around the file. The inherit+-- cases whose regression is a self-reference run here too.+testUndefinedVariablesIO :: IO [Bool]+testUndefinedVariablesIO = do+ putStrLn "resolve/undefined-variables-io"+ tmpBase <- getTemporaryDirectory+ let testDir = tmpBase </> "nova-nix-test-undefined-variables"+ branchFile = nixQuotedPath (testDir </> "branch.nix")+ scopedFile = nixQuotedPath (testDir </> "scoped.nix")+ scopedWithFile = nixQuotedPath (testDir </> "scoped-with.nix")+ expectError label source expected = do+ result <- evalNixIO testDir source+ runTest label $ case result of+ Left err -> assertEqual label expected err+ Right val -> Fail (label <> ": expected an error, got " <> T.pack (show val))+ bracket_+ ( do+ createDirectoryIfMissing True testDir+ TIO.writeFile (testDir </> "branch.nix") "{ flag }:\nif flag then 1 else unboundName\n"+ TIO.writeFile (testDir </> "scoped.nix") "if false then notInScope else fromScope\n"+ TIO.writeFile (testDir </> "scoped-with.nix") "with { fromScope = \"with\"; }; fromScope\n"+ )+ ( do+ exists <- doesDirectoryExist testDir+ when exists (removeDirectoryRecursive testDir)+ )+ $ sequence+ [ expectError+ "an import fails on an unbound variable on a branch not taken"+ ("(import " <> branchFile <> ") { flag = true; }")+ "undefined variable 'unboundName'",+ expectError+ "tryEval cannot catch an import that fails to bind"+ ("builtins.tryEval (import " <> branchFile <> ")")+ "undefined variable 'unboundName'",+ runTestIO+ "an import that is never forced does not fail"+ testDir+ ("let f = import " <> branchFile <> "; in 1")+ (VInt 1),+ expectError+ "scopedImport binds against its scope"+ ("builtins.scopedImport { fromScope = 7; } " <> scopedFile)+ "undefined variable 'notInScope'",+ runTestIO+ "scopedImport scope names bind"+ testDir+ ("builtins.scopedImport { fromScope = 7; notInScope = 1; } " <> scopedFile)+ (VInt 7),+ runTestIO+ "a scopedImport scope name is not shadowed by a with"+ testDir+ ("builtins.scopedImport { fromScope = 7; } " <> scopedWithFile)+ (VInt 7),+ -- Evaluated where a self-reference is caught as infinite recursion+ -- rather than looping, which is what reading the inherited name+ -- from the rec's own scope used to do.+ runTestIO+ "inherit in a dynamic-key rec reads the enclosing rec, not its own"+ testDir+ "rec { ${\"q\"} = 0; x = 1; y = rec { ${\"a\"} = 1; inherit x; }; }.y.x"+ (VInt 1),+ expectError+ "inherit of a name no with supplies is undefined, not the rec's own"+ "with {}; rec { ${\"a\"} = 1; inherit x; }.x"+ "undefined variable 'x'"+ ]++-- ---------------------------------------------------------------------------+-- 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 =+ getArgs >>= \case+ [flag, name] | flag == arenaGuardChildFlag -> arenaGuardChild name+ [flag, source] | flag == evalChildFlag -> bracket_ arenaInit arenaDestroy (evalChild source)+ _ -> withRunTempRoot (bracket_ arenaInit arenaDestroy runSuite)++-- | Give the run a temporary root of its own and point the process's+-- temporary directory at it, so every fixture the suite derives from+-- 'getTemporaryDirectory' (each under a fixed name) is this run's alone+-- and two suites running at once cannot delete each other's (#254).+-- @TMPDIR@ is what POSIX reads, @TMP@ and @TEMP@ what Windows'+-- @GetTempPath@ reads; a child process inherits them. The root is named+-- for this process, so no live run shares it, and one a crashed run left+-- under the same PID is cleared first.+withRunTempRoot :: IO a -> IO a+withRunTempRoot action = do+ base <- getTemporaryDirectory+ pid <- Proc.getCurrentPid+ let root = base </> ("nova-nix-test-" <> show pid)+ Dir.removePathForcibly root+ Dir.createDirectory root+ mapM_ (`setEnv` root) ["TMPDIR", "TMP", "TEMP"]+ action `finally` Dir.removePathForcibly root++-- | Every group, under the one arena bracket.+runSuite :: IO ()+runSuite = 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,+ testSubstituterRetry,+ testPkgsFetchurl,+ testNarKnownAnswer,+ testFetchGitTransport,+ testFetchGitShallow,+ testFetchGitPins,+ testPathInterp,+ testOutPathCoercion,+ testFetchCache,+ testExecWrapper,+ testAttrPath,+ testScratchDirs,+ testEvalLiterals,+ testEvalVariables,+ testEvalArithmetic,+ testEvalComparison,+ testEvalLogic,+ testEvalStrings,+ testEvalIfAssert,+ testEvalLet,+ testEvalAttrs,+ testValuePrinter,+ testCoercionErrors,+ testResultPrinter,+ testEvalRecAttrs,+ testEvalLists,+ testEvalLambda,+ testEvalWith,+ testEvalBuiltins,+ testFromTOML,+ testEvalErrors,+ testListCoercion,+ testEvalHigherOrder,+ testLexer,+ testParserExprs,+ testParserErrors,+ testParserIntegration,+ testStaticGlobalsSync,+ testUndefinedVariables,+ testUndefinedVariablesIO,+ testBatch1,+ testBatch2,+ testBatch3,+ testBatch4,+ testBatch5,+ testBatch6,+ testBatch7,+ testImportPure,+ testImportIO,+ testBlackholeRecoveryIO,+ testDerivationTraceIO,+ testFirstArgumentOrderIO,+ testStorePathLinksIO,+ testDeepContextClosureIO,+ testDerivationFields,+ testDerivationPathFieldsIO,+ testPathArguments,+ testPathArgumentsIO,+ testPathFilterIO,+ testPathSymlinkIO,+ testInterpolatedSymlinkIO,+ testSourceErrorMessage,+ testSourceReadErrorsIO,+ testBatchA,+ testBatchAIO,+ testEnvironmentBytes,+ testBatchB,+ testBatchC,+ testBatchCIO,+ testBlackhole,+ testCallDepth,+ testBatchD,+ testBatchE,+ testBatchEIO,+ testBatchF,+ testBatchG,+ testBatchH,+ testStringContext,+ testContextHelpers,+ testContextPropagation,+ testDrvContext,+ testStructuredAttrs,+ testDepGraph,+ testSubstituter,+ testCaseSensitiveVolumeIO,+ testFoldingVolumeIO,+ testPathLocks,+ testExecBit,+ testVerifySigs,+ testNarInfoValidation,+ testNixConfig,+ testPushPure,+ testPushClosureIO,+ testBuildOrchestrator,+ testStoreDB,+ testParseStorePath,+ testStoreOps,+ testStoreDelete,+ testGcPure,+ testStoreGC,+ testSymlinkWalksIO,+ testLinkOrdering,+ testFromATerm,+ testBuilder,+ testE2E,+ testExecWrapperCLI,+ testPhase4,+ testPhase4IO,+ testToJSONPathIO,+ testByteStringSemantics,+ testByteStringSemanticsIO,+ testSymbol,+ testCAttrSet,+ testCThunk,+ testArenaGuard,+ testBytecodeCompile,+ testBytecodeCountSpill,+ testBytecodePerElement,+ testValueCountWidths,+ testStoreNameSinks,+ testNarNameSafety,+ testStoreNameSinksIO,+ testClassIFollowups,+ testClassIFollowupsIO,+ testAbsolutePathStrings,+ testEvalPolicyRules,+ testEvalPolicyIO,+ testEvalPolicyCLI,+ testResultPrinterCLI,+ testConfigBytesCLI,+ testInterruptsPropagate+ ]+ let total = length results+ passed = length (filter id results)+ failed = total - passed+ putStrLn $ "\n" ++ show passed ++ "/" ++ show total ++ " passed"+ if failed > 0+ then do+ putStrLn $ show failed ++ " FAILED"+ exitFailure+ else do+ putStrLn "All tests passed."+ exitSuccess++-- ---------------------------------------------------------------------------+-- Tests: evaluation policy - restricted and pure modes (#75)+-- ---------------------------------------------------------------------------++-- | A string naming a relative path is refused wherever a path is coerced+-- from a string, as upstream's coerceToPath refuses it ("string '%s'+-- doesn't represent an absolute path", observed from nix-instantiate+-- 2.33.2 in every mode). Load bearing for the access policy: a relative+-- string would be checked against one location and opened at another.+testAbsolutePathStrings :: IO [Bool]+testAbsolutePathStrings = do+ putStrLn "eval/absolute-path-strings"+ tmpBase <- getTemporaryDirectory+ let testDir = tmpBase </> "nova-nix-test-abs-strings"+ relativeRefused label source =+ runTestM label $ do+ result <- evalNixIO testDir source+ pure $ case result of+ Left err+ | "string 'foo' doesn't represent an absolute path" `T.isInfixOf` err -> Pass+ | otherwise -> Fail ("expected the absolute-path refusal, got: " <> err)+ Right val -> Fail ("expected a refusal, got: " <> T.pack (show val))+ bracket_+ (createDirectoryIfMissing True testDir >> BS.writeFile (testDir </> "foo") "1\n")+ (forceRemoveIfExists testDir)+ $ sequence+ [ relativeRefused "readFile of a relative string" "builtins.readFile \"foo\"",+ relativeRefused "import of a relative string" "import \"foo\"",+ relativeRefused "pathExists of a relative string is an error, not false" "builtins.pathExists \"foo\"",+ relativeRefused "readDir of a relative string" "builtins.readDir \"foo\"",+ relativeRefused "readFileType of a relative string" "builtins.readFileType \"foo\"",+ relativeRefused "hashFile of a relative string" "builtins.hashFile \"sha256\" \"foo\"",+ relativeRefused "scopedImport of a relative string" "builtins.scopedImport { } \"foo\"",+ -- The search-path miss carries upstream's hint, byte for byte.+ runTest "a search path miss names the hint" $+ assertEqual+ "miss"+ (Left "file 'nope' was not found in the Nix search path (add it using $NIX_PATH or -I)")+ (evalNix "<nope>")+ ]++-- | The policies under test.+restrictedPolicy, purePolicy :: EvalPolicy+restrictedPolicy = unrestrictedPolicy {epRestrictEval = True}+purePolicy = unrestrictedPolicy {epPureEval = True}++-- | Parse and evaluate in 'PureEval' with the given policy's builtins+-- table, for the constants the table itself decides.+evalNixWith :: EvalPolicy -> Text -> Either Text NixValue+evalNixWith policy source = case parseNixWithScope (rootScopeNames policy) testBaseDir "<test>" source of+ Left err -> Left (sourceFailure err)+ Right expr -> runPureEval (eval (builtinEnv policy 0 []) expr)++-- | The pure rules behind the modes, each pinned to upstream's code at+-- 2.24.9: CanonPath::isAllowed over the allow list, isAllowedURI and+-- checkURI over allowed-uris, the git input's URL form, the nix.conf+-- settings, and the builtins table under pure-eval.+testEvalPolicyRules :: IO [Bool]+testEvalPolicyRules = do+ putStrLn "eval/policy-rules"+ let oneRoot = allowPathIn "/a/b" noAllowedPaths+ twoRoots = allowPathIn "/x" oneRoot+ driveRoot = allowPathIn "C:/a/b" noAllowedPaths+ github = ["https://github.com/NixOS"]+ -- Successive nix.conf texts, weakest first, served to the expander+ -- as files.+ config texts =+ let paths = [asciiOsPath ("/policy/" ++ show index ++ ".conf") | index <- [1 .. length texts]]+ files = Map.fromList (zip paths texts)+ readFrom path = Identity (maybe (Left Config.ConfigFileMissing) Right (Map.lookup path files))+ in runIdentity (Config.loadConfig readFrom paths Nothing)+ cfgPure = fmap ncPureEval . config+ cfgRestrict = fmap ncRestrictEval . config+ cfgUris = fmap ncAllowedUris . config+ sequence+ [ -- CanonPath::isAllowed+ runTest "an allowed prefix admits itself" $+ assertEqual "self" True (isAllowedPath oneRoot "/a/b"),+ runTest "an allowed prefix admits what lies within it" $+ assertEqual "within" True (isAllowedPath oneRoot "/a/b/c/d"),+ runTest "an allowed prefix admits its ancestors" $+ assertEqual "ancestor" True (isAllowedPath oneRoot "/a") `andThen` assertEqual "root" True (isAllowedPath oneRoot "/"),+ runTest "a sibling sharing a text prefix is not within" $+ assertEqual "sibling" False (isAllowedPath oneRoot "/a/bc"),+ runTest "a path beside the prefix is refused" $+ assertEqual "beside" False (isAllowedPath oneRoot "/a/c") `andThen` assertEqual "elsewhere" False (isAllowedPath twoRoots "/y"),+ runTest "nothing allowed admits nothing, the root included" $+ assertEqual "empty" False (isAllowedPath noAllowedPaths "/") `andThen` assertEqual "empty-etc" False (isAllowedPath noAllowedPaths "/etc"),+ runTest "the spelling of an allowed prefix is canonicalized" $+ assertEqual "canon" True (isAllowedPath (allowPathIn "/a//b/./" noAllowedPaths) "/a/b/c"),+ -- The rules are platform-independent, so a Windows path value+ -- (C:/..., the drive designator its first component) is decided+ -- the same on every host.+ runTest "a drive designator is the first component" $+ assertEqual "within" True (isAllowedPath driveRoot "C:/a/b/c")+ `andThen` assertEqual "self" True (isAllowedPath driveRoot "C:/a/b")+ `andThen` assertEqual "drive-root" True (isAllowedPath driveRoot "C:/")+ `andThen` assertEqual "beside" False (isAllowedPath driveRoot "C:/a/c")+ `andThen` assertEqual "sibling" False (isAllowedPath driveRoot "C:/a/bc")+ `andThen` assertEqual "other-drive" False (isAllowedPath driveRoot "D:/a/b/c")+ `andThen` assertEqual "rootless" False (isAllowedPath driveRoot "/a/b/c")+ `andThen` assertEqual "first" "C:/" (joinComponents (take 1 (pathComponents "C:/a/b")))+ `andThen` assertEqual "first-posix" "/a" (joinComponents (take 1 (pathComponents "/a/b"))),+ -- isAllowedURI+ runTest "a URI equal to a prefix is allowed" $+ assertEqual "equal" True (isAllowedUri github "https://github.com/NixOS"),+ runTest "a URI under a prefix at a slash boundary is allowed" $+ assertEqual "subpath" True (isAllowedUri github "https://github.com/NixOS/patchelf.git"),+ runTest "a prefix does not admit a longer name" $+ assertEqual "github.co" False (isAllowedUri ["https://github.co"] "https://github.com")+ `andThen` assertEqual "NixOSx" False (isAllowedUri github "https://github.com/NixOSx"),+ runTest "a prefix ending in a slash admits below it but not itself" $+ assertEqual "below" True (isAllowedUri ["https://a/dir/"] "https://a/dir/x")+ `andThen` assertEqual "itself" False (isAllowedUri ["https://a/dir/"] "https://a/dir"),+ runTest "a bare scheme prefix admits its whole scheme" $+ assertEqual "https:" True (isAllowedUri ["https:"] "https://anything/at/all")+ `andThen` assertEqual "git+file:" True (isAllowedUri ["git+file:"] "git+file:///repo?exportIgnore=1")+ `andThen` assertEqual "http:" False (isAllowedUri ["https:"] "http://x"),+ -- checkURI+ runTest "nothing is checked unless restrict-eval is on" $+ assertEqual "off" (Right ()) (uriAccess purePolicy noAllowedPaths "https://x"),+ runTest "a URI outside the list is refused with upstream's wording" $+ assertEqual "refused" (Left "access to URI 'https://x/y' is forbidden in restricted mode") (uriAccess restrictedPolicy noAllowedPaths "https://x/y"),+ runTest "a path URI is checked against the allowed paths" $+ assertEqual "allowed" (Right ()) (uriAccess restrictedPolicy oneRoot "/a/b/f")+ `andThen` assertEqual "refused" (Left "access to absolute path '/etc/passwd' is forbidden in restricted mode") (uriAccess restrictedPolicy oneRoot "/etc/passwd"),+ runTest "a file URL is checked as its path, rooted" $+ assertEqual "allowed" (Right ()) (uriAccess restrictedPolicy oneRoot "file:///a/b/f")+ `andThen` assertEqual "rooted" (Left "access to absolute path '/etc/passwd' is forbidden in restricted mode") (uriAccess restrictedPolicy oneRoot "file://etc/passwd"),+ runTest "a file URL with a drive is checked as that drive's path" $+ assertEqual "allowed" (Right ()) (uriAccess restrictedPolicy driveRoot "file://C:/a/b/f")+ `andThen` assertEqual "refused" (Left "access to absolute path 'C:/a/c' is forbidden in restricted mode") (uriAccess restrictedPolicy driveRoot "file://C:/a/c"),+ runTest "the path refusal names pure mode when it is on" $+ assertEqual "pure" "access to absolute path '/etc' is forbidden in pure evaluation mode (use '--impure' to override)" (forbiddenPathMessage purePolicy "/etc")+ `andThen` assertEqual "both" "access to absolute path '/etc' is forbidden in pure evaluation mode (use '--impure' to override)" (forbiddenPathMessage purePolicy {epRestrictEval = True} "/etc"),+ -- Input::toURLString for a git input (strings observed from+ -- nix-instantiate 2.33.2, whose renderer is 2.24.9's)+ runTest "an https url renders with git+ and exportIgnore" $+ assertEqual "https" "git+https://github.com/NixOS/patchelf.git?exportIgnore=1" (gitInputUrl (gitInput "https://github.com/NixOS/patchelf.git")),+ runTest "a ref follows exportIgnore in key order" $+ assertEqual "ref" "git+https://github.com/NixOS/patchelf.git?exportIgnore=1&ref=master" (gitInputUrl (gitInput "https://github.com/NixOS/patchelf.git") {fgaRef = Just "master"}),+ runTest "a rev renders lowercase after ref" $+ assertEqual "rev" "git+https://h/r?exportIgnore=1&ref=main&rev=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" (gitInputUrl (gitInput "https://h/r") {fgaRef = Just "main", fgaRev = Just "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}),+ runTest "shallow renders as a flag" $+ assertEqual "shallow" "git+ssh://git@github.com/NixOS/patchelf.git?exportIgnore=1&shallow=1" (gitInputUrl (gitInput "ssh://git@github.com/NixOS/patchelf.git") {fgaShallow = True}),+ runTest "submodules replaces exportIgnore" $+ assertEqual "submodules" "git+https://h/r?submodules=1" (gitInputUrl (gitInput "https://h/r") {fgaSubmodules = True}),+ runTest "a bare path becomes a file URL" $+ assertEqual "path" "git+file:///p/repo?exportIgnore=1" (gitInputUrl (gitInput "/p/repo")),+ runTest "an scp-like remote becomes ssh" $+ assertEqual "scp" "git+ssh://git@github.com/NixOS/patchelf.git?exportIgnore=1" (gitInputUrl (gitInput "git@github.com:NixOS/patchelf.git")),+ runTest "the git scheme keeps its name" $+ assertEqual "git" "git://h/r?exportIgnore=1" (gitInputUrl (gitInput "git://h/r")),+ runTest "a path is percent-encoded as upstream encodes it" $+ assertEqual "space" "git+file:///p/my%20repo?exportIgnore=1" (gitInputUrl (gitInput "/p/my repo")),+ -- nix.conf+ runTest "pure-eval and restrict-eval parse upstream's Boolean spellings" $+ assertEqual "true" (Right True) (cfgPure ["pure-eval = true"])+ `andThen` assertEqual "yes" (Right True) (cfgPure ["pure-eval = yes"])+ `andThen` assertEqual "1" (Right True) (cfgRestrict ["restrict-eval = 1"])+ `andThen` assertEqual "false" (Right False) (cfgPure ["pure-eval = true", "pure-eval = false"])+ `andThen` assertEqual "no" (Right False) (cfgRestrict ["restrict-eval = no"])+ `andThen` assertEqual "0" (Right False) (cfgRestrict ["restrict-eval = 0"]),+ runTest "a misspelled Boolean is an error with upstream's wording" $+ assertEqual "typo" (Left "Boolean setting 'pure-eval' has invalid value 'ture'") (cfgPure ["pure-eval = ture"]),+ runTest "allowed-uris is a list and extra- appends to it" $+ assertEqual "uris" (Right ["https://a", "https://b", "https://c"]) (cfgUris ["allowed-uris = https://a https://b", "extra-allowed-uris = https://c"]),+ runTest "extra- on a Boolean names no setting and is ignored" $+ assertEqual "extra-bool" (Right False) (cfgPure ["extra-pure-eval = true"]),+ runTest "the default config has both modes off" $+ assertEqual "defaults" (Right (False, False, [])) ((\c -> (ncRestrictEval c, ncPureEval c, ncAllowedUris c)) <$> config []),+ -- the builtins table+ runTest "currentTime and currentSystem are absent under pure-eval" $+ assertEqual "time" (Right (VBool False)) (evalNixWith purePolicy "builtins ? currentTime")+ `andThen` assertEqual "system" (Right (VBool False)) (evalNixWith purePolicy "builtins ? currentSystem")+ `andThen` assertEqual "time-restricted" (Right (VBool True)) (evalNixWith restrictedPolicy "builtins ? currentTime")+ `andThen` assertEqual "time-default" (Right (VBool True)) (evalNixWith unrestrictedPolicy "builtins ? currentSystem")+ ]+ where+ gitInput url =+ FetchGitArgs+ { fgaUrl = url,+ fgaName = "source",+ fgaRef = Nothing,+ fgaRev = Nothing,+ fgaSubmodules = False,+ fgaShallow = False,+ fgaAllRefs = False,+ fgaNarHash = Nothing+ }++-- | 'evalNixIO' under a policy: the roots are allowed first, and the+-- search path is what the caller passes, as the CLI would pass it.+evalPolicyIO :: EvalPolicy -> [Text] -> [Thunk] -> FilePath -> Text -> IO (Either Text NixValue)+evalPolicyIO policy roots searchPaths baseDir source = do+ storeDir <- evalNixIOStoreDir+ case parseNixWithScope (rootScopeNames policy) baseDir "<test>" source of+ Left err -> pure (Left (T.pack (show err)))+ Right expr -> do+ st0 <- newEvalState storeDir baseDir+ let st = st0 {esPolicy = policy, esSearchPaths = searchPaths}+ mapM_ (allowEvalPath st) roots+ runEvalIO st (eval (builtinEnv policy (esTimestamp st) searchPaths) expr)++-- | The battery the modes were pinned against, run through EvalIO: every+-- expression here was run through nix-instantiate under restrict-eval and+-- pure-eval, and the expected text is what upstream printed, except where+-- the oracle version (2.24.9) differs from the installed 2.33.2 and the+-- oracle's source wins (which prefix a refusal names, the fetchGit+-- wording). The fixture lives at a symlink-free root: a root behind a+-- symlinked directory is refused by upstream too, since its allow list+-- holds the spelling given and the walk resolves the link.+testEvalPolicyIO :: IO [Bool]+testEvalPolicyIO = do+ putStrLn "eval/policy-io"+ tmpBase <- getTemporaryDirectory >>= Dir.canonicalizePath+ links <- symlinksAvailable+ let root = tmpBase </> "nova-nix-test-policy"+ allowed = root </> "allowed"+ outside = root </> "outside"+ repo = allowed </> "repo"+ pathText = canonPathValue . T.pack+ forbidden policy p = "access to absolute path '" <> pathText p <> "' is forbidden " <> modeText policy+ modeText policy+ | epPureEval policy = "in pure evaluation mode (use '--impure' to override)"+ | otherwise = "in restricted mode"+ -- The first prefix the walk pushes: /tmp on Linux, /private on+ -- macOS, the drive root C:/ on Windows.+ firstComponent = joinComponents (take 1 (pathComponents (pathText allowed)))+ -- A fixture path as a path literal, relative to the base directory+ -- (allowed): the one spelling that lexes as a path on every+ -- platform, since a drive-letter path (C:/...) is a URI token to+ -- upstream's lexer and to this one.+ lit p = "../" <> pathText (makeRelative root p)+ quoted p = "\"" <> pathText p <> "\""+ run policy roots = evalPolicyIO policy roots [] allowed+ restricted = run restrictedPolicy [pathText allowed]+ pureRun = run purePolicy [pathText allowed]+ withUris uris = run restrictedPolicy {epAllowedUris = uris} [pathText allowed]+ expectLeft label expected action =+ runTestM label $ do+ result <- action+ pure $ case result of+ Left err -> assertEqual label expected err+ Right val -> Fail (label <> ": expected a refusal, got " <> T.pack (show val))+ expectRight label expected action =+ runTestM label $ assertRight label <$> action <*> pure (assertEqual label expected)+ expectRightWith label check action =+ runTestM label $ (\r -> assertRight label r check) <$> action+ repoUrl = "git+file://" <> pathText repo <> "?exportIgnore=1"+ unrestricted = run unrestrictedPolicy []+ symlinkMessage p = "path '" <> pathText p <> "' is a symlink"+ -- A case that needs a symlink on disk, or the record of why it did+ -- not run.+ linked label mk = if links then mk label else runTest (label <> " (skipped: no symlinks here)") Pass+ bracket_+ ( do+ forceRemoveIfExists root+ createDirectoryIfMissing True (allowed </> "sub")+ createDirectoryIfMissing True outside+ BS.writeFile (allowed </> "data.txt") "hello\n"+ BS.writeFile (allowed </> "sub" </> "data.txt") "inner\n"+ BS.writeFile (allowed </> "y.nix") "7\n"+ BS.writeFile (outside </> "x.nix") "42\n"+ BS.writeFile (outside </> "secret.txt") "secret\n"+ when links $ do+ Dir.createFileLink (outside </> "secret.txt") (allowed </> "link")+ Dir.createDirectoryLink outside (allowed </> "dirlink")+ Dir.createFileLink "nowhere" (allowed </> "dangling")+ createDirectoryIfMissing True (allowed </> "dir")+ Dir.createFileLink (outside </> "x.nix") (allowed </> "dir" </> "default.nix")+ createDirectoryIfMissing True repo+ _ <- fixtureGit repo ["init", "--quiet"]+ BS.writeFile (repo </> "f.txt") "in repo\n"+ _ <- fixtureGit repo ["add", "-A"]+ _ <- fixtureGit repo ["commit", "--quiet", "-m", "init"]+ pure ()+ )+ (forceRemoveIfExists root)+ $ do+ rev <- T.strip <$> fixtureGit repo ["rev-parse", "HEAD"]+ -- The fetch cache writes under XdgCache; point it at the fixture for+ -- the duration so the suite never touches the developer's cache.+ savedCacheHome <- lookupEnv "XDG_CACHE_HOME"+ setEnv "XDG_CACHE_HOME" (root </> "cache")+ results <-+ sequence+ [ -- restricted: the allow list+ expectRight "a file under an allowed root reads" (mkStr "hello\n") (restricted ("builtins.readFile " <> lit (allowed </> "data.txt"))),+ expectRight "a file deeper under an allowed root reads" (mkStr "inner\n") (restricted ("builtins.readFile " <> lit (allowed </> "sub" </> "data.txt"))),+ expectLeft "a file beside the allowed root is refused, naming the first prefix outside the list" (forbidden restrictedPolicy outside) (restricted ("builtins.readFile " <> lit (outside </> "secret.txt"))),+ expectLeft "with nothing allowed the refusal names the first component" (forbidden restrictedPolicy (T.unpack firstComponent)) (run restrictedPolicy [] ("builtins.readFile " <> lit (allowed </> "data.txt"))),+ expectLeft "a string spelling the path is refused the same way" (forbidden restrictedPolicy outside) (restricted ("builtins.readFile " <> quoted (outside </> "secret.txt"))),+ expectRight "import under the root works" (VInt 7) (restricted ("import " <> lit (allowed </> "y.nix"))),+ expectLeft "import beside the root is refused" (forbidden restrictedPolicy outside) (restricted ("import " <> lit (outside </> "x.nix"))),+ expectRight "pathExists is true under the root" (VBool True) (restricted ("builtins.pathExists " <> lit (allowed </> "data.txt"))),+ expectRight "pathExists is false beside it, not an error" (VBool False) (restricted ("builtins.pathExists " <> lit (outside </> "secret.txt"))),+ expectRightWith "readDir under the root lists" (\case VAttrs attrs -> assertEqual "entries" True (attrSetSize attrs == 1); other -> Fail (T.pack (show other))) (restricted ("builtins.readDir " <> lit (allowed </> "sub"))),+ expectLeft "readDir beside the root is refused" (forbidden restrictedPolicy outside) (restricted ("builtins.readDir " <> lit outside)),+ expectLeft "readFileType names the path asked for, no walk" (forbidden restrictedPolicy (outside </> "secret.txt")) (restricted ("builtins.readFileType " <> lit (outside </> "secret.txt"))),+ expectLeft "hashFile walks like readFile" (forbidden restrictedPolicy outside) (restricted ("builtins.hashFile \"sha256\" " <> lit (outside </> "secret.txt"))),+ expectRight "getEnv answers empty" (mkStr "") (restricted "builtins.getEnv \"PATH\""),+ expectRight "a toFile result is readable afterwards" (mkStr "y") (restricted "builtins.readFile (builtins.toFile \"x\" \"y\")"),+ expectRightWith "a source copy under the root is allowed" (\case VStr s _ -> assertEqual "store" True (BS.isPrefixOf "/nix/store/" s); other -> Fail (T.pack (show other))) (restricted "\"${./data.txt}\""),+ expectLeft "a source copy beside the root is refused" (forbidden restrictedPolicy outside) (restricted ("\"${" <> lit (outside </> "secret.txt") <> "}\"")),+ expectLeft "builtins.path beside the root is refused" (forbidden restrictedPolicy outside) (restricted ("builtins.path { path = " <> lit (outside </> "secret.txt") <> "; }")),+ expectLeft "filterSource beside the root is refused" (forbidden restrictedPolicy outside) (restricted ("builtins.filterSource (p: t: true) " <> lit outside)),+ expectLeft "tryEval does not catch a refusal" (forbidden restrictedPolicy outside) (restricted ("builtins.tryEval (builtins.readFile " <> lit (outside </> "secret.txt") <> ")")),+ linked "a symlink under the root pointing outside is refused at its target" (\label -> expectLeft label (forbidden restrictedPolicy outside) (restricted ("builtins.readFile " <> lit (allowed </> "link")))),+ linked "a directory import whose default.nix is a symlink outside the root is refused at the link's target" (\label -> expectLeft label (forbidden restrictedPolicy outside) (restricted ("import " <> lit (allowed </> "dir")))),+ linked "scopedImport of that directory is refused the same way" (\label -> expectLeft label (forbidden restrictedPolicy outside) (restricted ("builtins.scopedImport {} " <> lit (allowed </> "dir")))),+ linked "pathExists checks a symlink to a forbidden file in place: true" (\label -> expectRight label (VBool True) (restricted ("builtins.pathExists " <> lit (allowed </> "link")))),+ linked "pathExists checks a symlink to a forbidden directory in place: true" (\label -> expectRight label (VBool True) (restricted ("builtins.pathExists " <> lit (allowed </> "dirlink")))),+ linked "pathExists of a dangling symlink is true" (\label -> expectRight label (VBool True) (restricted ("builtins.pathExists " <> lit (allowed </> "dangling")))),+ linked "pathExists through a symlinked directory follows the ancestor, which is refused: false" (\label -> expectRight label (VBool False) (restricted ("builtins.pathExists " <> lit (allowed </> "dirlink" </> "secret.txt")))),+ linked "pathExists with a trailing slash follows the final link, which is refused: false" (\label -> expectRight label (VBool False) (restricted ("builtins.pathExists \"" <> pathText (allowed </> "dirlink") <> "/\""))),+ linked "readFileType through a symlinked directory is refused at the symlink" (\label -> expectLeft label (symlinkMessage (allowed </> "dirlink")) (restricted ("builtins.readFileType " <> lit (allowed </> "dirlink" </> "secret.txt")))),+ linked "readFileType through a symlinked directory is refused in the default mode too" (\label -> expectLeft label (symlinkMessage (allowed </> "dirlink")) (unrestricted ("builtins.readFileType " <> lit (allowed </> "dirlink" </> "secret.txt")))),+ linked "pathExists of a dangling symlink is true in the default mode too" (\label -> expectRight label (VBool True) (unrestricted ("builtins.pathExists " <> lit (allowed </> "dangling")))),+ linked "builtins.path resolves a symlink root and stores its target" (\label -> expectRight label (mkStr "directory") (unrestricted ("builtins.readFileType (builtins.path { path = " <> lit (allowed </> "dirlink") <> "; })"))),+ linked "filterSource resolves a symlink root and stores its target" (\label -> expectRight label (mkStr "directory") (unrestricted ("builtins.readFileType (builtins.filterSource (p: t: true) " <> lit (allowed </> "dirlink") <> ")"))),+ expectRight "pathExists with a trailing slash on a file is false" (VBool False) (unrestricted ("builtins.pathExists \"" <> pathText (allowed </> "data.txt") <> "/\"")),+ expectRight "pathExists with a trailing slash on a directory is true" (VBool True) (unrestricted ("builtins.pathExists \"" <> pathText (allowed </> "sub") <> "/\"")),+ expectRight "pathExists with a trailing slash and dot on a directory is true" (VBool True) (unrestricted ("builtins.pathExists \"" <> pathText (allowed </> "sub") <> "/.\"")),+ -- restricted: URIs+ expectLeft "a fetch of an unlisted URI is refused before any download" "access to URI 'https://example.invalid/x' is forbidden in restricted mode" (restricted "builtins.fetchurl \"https://example.invalid/x\""),+ expectLeft "a file URL beside the root is refused as its path" (forbidden restrictedPolicy (outside </> "secret.txt")) (restricted ("builtins.fetchurl \"file://" <> pathText (outside </> "secret.txt") <> "\"")),+ expectRightWith "a file URL under the root fetches" (\case VPath p -> assertEqual "store" True (T.isPrefixOf "/nix/store/" p); other -> Fail (T.pack (show other))) (restricted ("builtins.fetchurl { url = \"file://" <> pathText (allowed </> "data.txt") <> "\"; sha256 = \"5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03\"; }")),+ expectLeft "fetchTarball of an unlisted URI is refused" "access to URI 'https://example.invalid/x.tar.gz' is forbidden in restricted mode" (restricted "builtins.fetchTarball \"https://example.invalid/x.tar.gz\""),+ expectLeft "an allowed-uris prefix admits only its subpaths" "access to URI 'https://example.invalid/dir2/x' is forbidden in restricted mode" (withUris ["https://example.invalid/dir"] "builtins.fetchurl { url = \"https://example.invalid/dir2/x\"; sha256 = \"5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03\"; }"),+ runTestM "an allowed-uris prefix lets the fetch proceed to the network" $ do+ result <- withUris ["https://example.invalid/dir"] "builtins.fetchurl { url = \"https://example.invalid/dir/x\"; sha256 = \"5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03\"; }"+ pure $ case result of+ Left err | "is forbidden" `T.isInfixOf` err -> Fail ("the gate refused an allowed URI: " <> err)+ _ -> Pass,+ expectLeft "fetchGit of an allowed path is still a URI, checked in its git+file form" ("access to URI '" <> repoUrl <> "' is forbidden in restricted mode") (restricted ("builtins.fetchGit " <> quoted repo)),+ expectRight "an allowed git+file prefix admits the fetch and its tree is readable afterwards" (mkStr "in repo\n") (withUris ["git+file://" <> pathText allowed] ("builtins.readFile ((builtins.fetchGit " <> quoted repo <> ").outPath + \"/f.txt\")")),+ -- restricted: the search path+ expectRight "a search path root is where <name> resolves and reads" (VInt 7) (evalPolicyIO restrictedPolicy [pathText allowed] (parseNixPath (TE.encodeUtf8 ("allowed=" <> pathText allowed))) allowed "import <allowed/y.nix>"),+ expectLeft "a search path miss carries the NIX_PATH hint and is catchable" "file 'nope' was not found in the Nix search path (add it using $NIX_PATH or -I)" (restricted "<nope>"),+ expectRight "tryEval catches the miss" (VBool False) (restricted "(builtins.tryEval <nope>).success"),+ -- pure+ expectLeft "pure mode refuses with its own clause" (forbidden purePolicy outside) (pureRun ("builtins.readFile " <> lit (outside </> "secret.txt"))),+ expectRight "pure mode still reads under an allowed root" (mkStr "hello\n") (pureRun ("builtins.readFile " <> lit (allowed </> "data.txt"))),+ expectRight "pure mode has no currentTime" (VBool False) (pureRun "builtins ? currentTime"),+ expectLeft "pure mode words the search path miss differently" "cannot look up '<nope>' in pure evaluation mode (use '--impure' to override)" (pureRun "<nope>"),+ expectRight "the pure miss is catchable too" (VBool False) (pureRun "(builtins.tryEval <nope>).success"),+ expectLeft "storePath is refused in pure mode before its argument is read" "'builtins.storePath' is not allowed in pure evaluation mode" (pureRun "builtins.storePath 42"),+ expectLeft "fetchurl needs a hash in pure mode, checked before any download" "in pure evaluation mode, 'fetchurl' requires a 'sha256' argument" (pureRun "builtins.fetchurl \"https://example.invalid/x\""),+ expectLeft "fetchTarball needs a hash in pure mode" "in pure evaluation mode, 'fetchTarball' requires a 'sha256' argument" (pureRun "builtins.fetchTarball \"https://example.invalid/x.tar.gz\""),+ expectLeft "fetchGit without a rev is unlocked in pure mode" ("in pure evaluation mode, 'fetchGit' will not fetch unlocked input '" <> repoUrl <> "'") (pureRun ("builtins.fetchGit " <> quoted repo)),+ expectRight "fetchGit with a rev is locked and fetches in pure mode" (mkStr "in repo\n") (pureRun ("builtins.readFile ((builtins.fetchGit { url = " <> quoted repo <> "; rev = \"" <> rev <> "\"; }).outPath + \"/f.txt\")")),+ expectLeft "a home-relative literal cannot resolve in pure mode" "the path '~/x' can not be resolved in pure mode" (pureRun "~/x"),+ expectRight "pure mode's getEnv answers empty" (mkStr "") (pureRun "builtins.getEnv \"PATH\""),+ expectRight "a path literal is a value, read or not" (mkStr (pathText (outside </> "secret.txt"))) (pureRun ("builtins.toString " <> lit (outside </> "secret.txt"))),+ -- unrestricted stays unrestricted+ expectRight "the default policy reads anywhere" (mkStr "secret\n") (run unrestrictedPolicy [] ("builtins.readFile " <> lit (outside </> "secret.txt")))+ ]+ maybe (unsetEnv "XDG_CACHE_HOME") (setEnv "XDG_CACHE_HOME") savedCacheHome+ pure results++-- | The flags and settings through the binary: the modes select+-- themselves from the command line and from NIX_CONFIG, and a refusal+-- surfaces as the eval error it is.+testEvalPolicyCLI :: IO [Bool]+testEvalPolicyCLI = do+ putStrLn "cli/eval-policy"+ tmpBase <- getTemporaryDirectory >>= Dir.canonicalizePath+ ambient <- getEnvironment+ cwd <- Dir.getCurrentDirectory+ let root = tmpBase </> "nova-nix-test-cli-policy"+ -- Where a channel: entry would land if it were taken for a path.+ cwdChannelEntry = T.unpack (canonPathValue (T.pack (cwd </> "channel:nova-probe")))+ allowed = root </> "allowed"+ -- A string, since a drive-letter path (C:/...) is a URI token and+ -- a native spelling (C:\...) is no token at all; an absolute+ -- string is coerced to a path on every platform.+ outsideFile = T.unpack (canonPathValue (T.pack (root </> "outside.txt")))+ isolate extraConfig =+ ("NIX_CONFIG", "substituters =\ntrusted-public-keys =\n" <> extraConfig)+ : ("XDG_CONFIG_HOME", root </> "config")+ : filter (\(key, _) -> key `notElem` ["NIX_CONFIG", "XDG_CONFIG_HOME", "NIX_PATH"]) ambient+ runCLI extraConfig args =+ Proc.readCreateProcessWithExitCode+ ((Proc.proc "cabal" (["run", "-v0", "nova-nix", "--"] ++ args)) {Proc.env = Just (isolate extraConfig)})+ ""+ expectOut label extraConfig args expected =+ runTestM ("CLI " <> label) $ do+ (code, out, err) <- runCLI extraConfig args+ pure $ case (code, lines out) of+ (ExitSuccess, line : _) -> assertEqual label expected line+ _ -> Fail ("CLI: " <> T.pack (show code) <> "; stdout=" <> T.pack out <> "; stderr=" <> T.pack err)+ expectErr label extraConfig args needle =+ runTestM ("CLI " <> label) $ do+ (code, _, err) <- runCLI extraConfig args+ pure $+ if code /= ExitSuccess && needle `T.isInfixOf` T.pack err+ then Pass+ else Fail ("expected a failure mentioning " <> needle <> ", got " <> T.pack (show (code, err)))+ bracket_+ ( do+ forceRemoveIfExists root+ createDirectoryIfMissing True allowed+ BS.writeFile (allowed </> "data.txt") "hello\n"+ BS.writeFile (root </> "outside.txt") "no\n"+ )+ (forceRemoveIfExists root)+ $ sequence+ [ expectOut "--pure-eval hides currentTime" "" ["eval", "--pure-eval", "--expr", "builtins ? currentTime"] "false",+ expectOut "--restrict-eval empties getEnv" "" ["eval", "--restrict-eval", "--expr", "builtins.getEnv \"HOME\""] "\"\"",+ expectOut "pure-eval from NIX_CONFIG" "pure-eval = true\n" ["eval", "--expr", "builtins ? currentSystem"] "false",+ expectOut "a --nix-path root is readable under --restrict-eval" "" ["eval", "--restrict-eval", "--nix-path", "allowed=" ++ allowed, "--expr", "builtins.readFile <allowed/data.txt>"] "\"hello\\n\"",+ expectErr "a read outside the roots fails the command" "" ["eval", "--restrict-eval", "--expr", "builtins.readFile \"" ++ outsideFile ++ "\""] "is forbidden in restricted mode",+ expectErr "a channel: search path entry allows nothing" "" ["eval", "--restrict-eval", "--nix-path", "nixpkgs=channel:nova-probe", "--expr", "builtins.readFile \"" ++ cwdChannelEntry ++ "\""] "is forbidden in restricted mode",+ expectOut "build accepts the flag before its target" "" ["build", "--pure-eval", "--help"] "Usage: nova-nix [--nix-path NAME=PATH] <command>"+ ]++-- | The eval result through the binary: stdout carries the printer's bytes+-- and a bare newline, with no text encoding or newline translation between.+testResultPrinterCLI :: IO [Bool]+testResultPrinterCLI = do+ putStrLn "cli/eval-result"+ tmpBase <- getTemporaryDirectory+ ambient <- getEnvironment+ let root = tmpBase </> "nova-nix-test-cli-result"+ file = root </> "latin1"+ notUtf8 = "a" <> BS.singleton 0xfc <> "b"+ -- A string, as in testEvalPolicyCLI: a drive-letter path is no+ -- path literal.+ fileString = T.unpack (canonPathValue (T.pack file))+ isolated =+ ("XDG_CONFIG_HOME", root </> "config")+ : filter (\(key, _) -> key `notElem` ["NIX_CONFIG", "XDG_CONFIG_HOME"]) ambient+ evalProcess =+ (Proc.proc "cabal" ["run", "-v0", "nova-nix", "--", "eval", "--expr", "builtins.readFile \"" ++ fileString ++ "\""])+ { Proc.env = Just isolated,+ Proc.std_out = Proc.CreatePipe+ }+ bracket_+ (forceRemoveIfExists root >> createDirectoryIfMissing True root >> BS.writeFile file notUtf8)+ (forceRemoveIfExists root)+ $ sequence+ [ runTestM "CLI eval writes a string's bytes and a bare newline" $+ Proc.withCreateProcess evalProcess $ \_ stdoutPipe _ process -> case stdoutPipe of+ Nothing -> pure (Fail "no stdout pipe")+ Just out -> do+ printed <- BS.hGetContents out+ code <- Proc.waitForProcess process+ pure $ case code of+ ExitSuccess -> assertEqual "cli-result" ("\"" <> notUtf8 <> "\"\n") printed+ ExitFailure _ -> Fail ("CLI: " <> T.pack (show code))+ ]++-- | nix.conf bytes through the binary (#244): an include target, the+-- system directory and NIX_CONFIG reach the file system and the settings+-- as the bytes they hold. Each case reads whether pure-eval came on,+-- which hides currentSystem. A name that is not UTF-8 exists only on a+-- volume that holds one, so a case needing such a name makes it first+-- and skips, with the reason, where the temp volume refuses it: APFS+-- refuses the name, and HFS+ holds it but opens no path beneath a+-- directory so named. A Windows name or variable is UTF-16 and holds no+-- such byte at all. The decoy cases need no such name: a file named by+-- U+FFFD's UTF-8, which a lossy decode of the byte would open instead,+-- must stay unread, as it does under nix-instantiate 2.33.2.+testConfigBytesCLI :: IO [Bool]+testConfigBytesCLI = do+ putStrLn "cli/config-bytes"+ tmpBase <- getTemporaryDirectory+ ambient <- getEnvironment+ let root = tmpBase </> "nova-nix-test-cli-config-bytes"+ confDir = root </> "etc"+ noConfDir = root </> "none"+ configVars = ["NIX_CONFIG", "NIX_CONF_DIR", "NIX_USER_CONF_FILES", "XDG_CONFIG_DIRS"]+ -- An empty NIX_USER_CONF_FILES names no user files, so only the+ -- system file under NIX_CONF_DIR and NIX_CONFIG are read.+ runCLI dir nixConfig =+ Proc.readCreateProcessWithExitCode+ ( (Proc.proc "cabal" ["run", "-v0", "nova-nix", "--", "eval", "--expr", "builtins ? currentSystem"])+ { Proc.env = Just (("NIX_CONF_DIR", dir) : ("NIX_USER_CONF_FILES", "") : ("NIX_CONFIG", nixConfig) : filter ((`notElem` configVars) . fst) ambient)+ }+ )+ ""+ expectAnswer label expected dir nixConfig = do+ (code, out, err) <- runCLI dir nixConfig+ pure $ case (code, lines out) of+ (ExitSuccess, line : _) -> assertEqual label expected line+ _ -> Fail ("CLI: " <> T.pack (show code) <> "; stdout=" <> T.pack out <> "; stderr=" <> T.pack err)+ expectPure label = expectAnswer label "false"+ expectImpure label = expectAnswer label "true"+ expectRefusal nixConfig needle = do+ (code, _, err) <- runCLI noConfDir nixConfig+ pure $+ if code /= ExitSuccess && needle `T.isInfixOf` T.pack err+ then Pass+ else Fail ("expected a failure mentioning " <> needle <> ", got " <> T.pack (show (code, err)))+ -- A case whose name or value holds bytes, spelled as the+ -- platform's strings carry them.+ spelledCase label bytes check = do+ spelled <- envSpelling bytes+ case spelled of+ Nothing -> True <$ putStrLn (" SKIP " <> T.unpack label <> ": a Windows name or variable cannot hold a byte with no UTF-8 reading")+ Just spelling -> check spelling+ envCase label bytes check = spelledCase label bytes (runTestM label . check)+ -- A case over a file whose name holds bytes, made before the case+ -- runs and skipped where the temp volume refuses it.+ -- A decoy's name is UTF-8, which every volume holds.+ decoyCase label bytes make check = spelledCase label bytes $ \name -> make name >> runTestM label (check name)+ fileCase label bytes make check = spelledCase label bytes $ \name -> do+ made <- try (make name)+ case made of+ -- The error string alone: the path in the full message holds+ -- the very byte stdout's encoding cannot write.+ Left (err :: IOException) -> True <$ putStrLn (" SKIP " <> T.unpack label <> ": the temp volume cannot hold a name that is not UTF-8 (" <> ioeGetErrorString err <> ")")+ Right () -> runTestM label (check name)+ latin1 = "caf\xE9" :: BS.ByteString+ -- The name a lossy decode of latin1 spells: U+FFFD's UTF-8.+ decoy = "caf\xEF\xBF\xBD" :: BS.ByteString+ decoyDir = root </> "decoy"+ bracket_+ (forceRemoveIfExists root >> createDirectoryIfMissing True confDir >> createDirectoryIfMissing True decoyDir)+ (forceRemoveIfExists root)+ $ sequence+ [ fileCase+ "CLI an include target with no UTF-8 reading opens the file it names"+ (latin1 <> ".conf")+ ( \name -> do+ BS.writeFile (confDir </> name) "pure-eval = true\n"+ BS.writeFile (confDir </> "nix.conf") ("include " <> latin1 <> ".conf\n")+ )+ (const (expectPure "include-bytes" confDir "")),+ fileCase+ "CLI a NIX_CONF_DIR holding a byte with no UTF-8 reading names the system file"+ latin1+ ( \name -> do+ createDirectoryIfMissing True (root </> name)+ BS.writeFile (root </> name </> "nix.conf") "pure-eval = true\n"+ )+ (\name -> expectPure "conf-dir-bytes" (root </> name) ""),+ decoyCase+ "CLI an !include target with no UTF-8 reading leaves the U+FFFD-named file unread"+ (decoy <> ".conf")+ ( \name -> do+ BS.writeFile (decoyDir </> name) "pure-eval = true\n"+ BS.writeFile (decoyDir </> "nix.conf") ("!include " <> latin1 <> ".conf\n")+ )+ (const (expectImpure "include-decoy" decoyDir "")),+ spelledCase "CLI a NIX_CONF_DIR holding such a byte leaves the U+FFFD-named directory unread" latin1 $ \confName ->+ decoyCase+ "CLI a NIX_CONF_DIR holding such a byte leaves the U+FFFD-named directory unread"+ decoy+ ( \name -> do+ createDirectoryIfMissing True (decoyDir </> name)+ BS.writeFile (decoyDir </> name </> "nix.conf") "pure-eval = true\n"+ )+ (const (expectImpure "conf-dir-decoy" (decoyDir </> confName) "")),+ envCase+ "CLI NIX_CONFIG holding such a byte in a comment and an unknown setting applies the rest"+ ("trusted-users = " <> latin1 <> "\npure-eval = true # " <> latin1 <> "\n")+ (expectPure "env-bytes-ignored" noConfDir),+ envCase+ "CLI NIX_CONFIG holding such a byte in a list setting is refused"+ ("pure-eval = true\nextra-substituters = https://" <> latin1 <> ".example\n")+ (`expectRefusal` "setting 'substituters' has invalid value"),+ envCase+ "CLI a Boolean ending in a no-break space is invalid, as upstream splits the line"+ "pure-eval = true\xC2\xA0\n"+ (`expectRefusal` "Boolean setting 'pure-eval' has invalid value")+ ]++-- ---------------------------------------------------------------------------+-- Tests: an interrupt stops the operation it reaches (#251)+-- ---------------------------------------------------------------------------++-- | How long 'interruptedAfterStall' lets an action run before+-- interrupting it: far longer than any of these actions takes to reach+-- the read it stalls in.+stallMicros :: Int+stallMicros = 200000++-- | Run an action in a thread of its own, deliver a user interrupt to it+-- once it has had time to stall, and report how it ended: @Left@ the+-- exception that stopped it, or @Right@ the value it returned instead.+interruptedAfterStall :: IO a -> IO (Either SomeException a)+interruptedAfterStall action =+ withAsync action $ \running -> do+ threadDelay stallMicros+ throwTo (asyncThreadId running) UserInterrupt+ waitCatch running++-- | Pass when the interrupt is what stopped the action.+interruptPropagated :: (Show a) => Either SomeException a -> TestResult+interruptPropagated outcome = case outcome of+ Left err+ | fromException err == Just UserInterrupt -> Pass+ | otherwise -> Fail ("stopped by another exception: " <> T.pack (show err))+ Right value -> Fail ("the interrupt became a value: " <> T.pack (show value))++-- | Run an action while a FIFO sits at the path with a writer that never+-- writes, so a read of the path blocks until something interrupts the+-- reader. A FIFO with no writer at all reads as empty at once, because+-- GHC opens a file non-blocking.+withStalledFifo :: FilePath -> IO a -> IO a+withStalledFifo path action =+ bracket_ (Proc.callProcess "mkfifo" [path]) (Dir.removePathForcibly path) $+ withBinaryFile path ReadWriteMode (const action)++-- | Each of these operations caught every exception and turned an+-- interrupt into its fallback value: an unreadable file, a cache miss, a+-- failed push. An interrupt now stops it. A file read is made to stall+-- on a FIFO ('withStalledFifo') and interrupted there from another+-- thread, so those cases need POSIX; the request a push makes stalls on+-- the loopback fixture on every platform. A fetch cache write never+-- blocks, so its interrupt is raised from inside the write. The removal+-- behind a build's cleanup neither blocks nor forces anything the caller+-- supplies, so no case reaches it.+testInterruptsPropagate :: IO [Bool]+testInterruptsPropagate = do+ putStrLn "exceptions/interrupts-propagate"+ tmpBase <- getTemporaryDirectory+ let root = tmpBase </> "nova-nix-test-interrupts"+ storeDir = StoreDir (root </> "store")+ cacheHome = root </> "cache"+ inStore = storePathToFilePath storeDir+ pushTo base = PushConfig {pcCacheUrl = base, pcApiKey = Nothing, pcCompression = PushNone}+ plainDrv =+ Derivation+ { drvOutputs = [],+ drvInputDrvs = Map.empty,+ drvInputSrcs = [],+ drvPlatform = currentPlatform,+ drvBuilder = builtinUnpackBuilder,+ drvArgs = [],+ drvEnv = Map.empty+ }+ unpacking archive = plainDrv {drvEnv = Map.singleton envSrcs (TE.encodeUtf8 (storePathToText defaultStoreDir archive))}+ withCacheHome =+ bracket+ (lookupEnv "XDG_CACHE_HOME" <* setEnv "XDG_CACHE_HOME" cacheHome)+ (maybe (unsetEnv "XDG_CACHE_HOME") (setEnv "XDG_CACHE_HOME"))+ . const+ fifoCase name test+ | SI.os == "mingw32" = True <$ putStrLn (" SKIP " ++ T.unpack name ++ ": needs a FIFO")+ | otherwise = runTestM name test+ forceRemoveIfExists root+ createDirectoryIfMissing True (unStoreDir storeDir)+ results <- bracket (openStore storeDir) closeStore $ \store ->+ sequence+ [ runTestM "an interrupt during a push request stops the push" $+ withHttpServer (\_ _ -> Trickle) $ \base _ requested -> do+ outcome <- withAsync (pushPaths (pushTo base) store [StorePath (T.replicate 32 "a") "pushed"]) $ \pushing -> do+ requested+ throwTo (asyncThreadId pushing) UserInterrupt+ waitCatch pushing+ pure (interruptPropagated outcome),+ fifoCase "an interrupt serialising a path for a push stops the push" $+ withHttpServer (\_ _ -> Send (httpResponse "200 OK" "")) $ \base _ _ -> do+ let sp = StorePath (T.replicate 32 "b") "stalled-output"+ outcome <- withStalledFifo (inStore sp) (interruptedAfterStall (pushPaths (pushTo base) store [sp]))+ pure (interruptPropagated outcome),+ fifoCase "an interrupt reading the API key file is not an unreadable key" $ do+ let keyFile = root </> "stalled-key"+ outcome <- withStalledFifo keyFile (interruptedAfterStall (loadApiKeyFile keyFile))+ pure (interruptPropagated outcome),+ fifoCase "an interrupt verifying a source tree on disk is not a mismatch" $ do+ let sp = StorePath (T.replicate 32 "c") "stalled-source"+ sources = Map.singleton (T.pack (root </> "absent-source")) (storePathToText defaultStoreDir sp)+ outcome <- withStalledFifo (inStore sp) (interruptedAfterStall (materializeEvalSources store sources))+ pure (interruptPropagated outcome),+ fifoCase "an interrupt reading a store derivation is not a missing one" $ do+ st <- newEvalState storeDir root+ let sp = StorePath (T.replicate 32 "d") "stalled.drv"+ outcome <- withStalledFifo (inStore sp) (interruptedAfterStall (runEvalIO st (readStoreDerivation sp)))+ pure (interruptPropagated outcome),+ -- The entry's path is the cache's layout: one file per key, named+ -- by the key's SHA-256 (fetchCacheFile).+ fifoCase "an interrupt reading the fetch cache is not a cache miss" $+ withCacheHome $ do+ st <- newEvalState storeDir root+ let key = "stalled-entry"+ entry = cacheHome </> "nova-nix" </> "fetch" </> T.unpack (Hash.bytesToHexText (sha256Digest (TE.encodeUtf8 key)))+ createDirectoryIfMissing True (takeDirectory entry)+ outcome <- withStalledFifo entry (interruptedAfterStall (runEvalIO st (lookupFetchCache key)))+ pure (interruptPropagated outcome),+ -- The entry's text is the interrupt, raised when the write forces it.+ runTestM "an interrupt writing the fetch cache is not an unwritable cache" $+ withCacheHome $ do+ st <- newEvalState storeDir root+ outcome <- try (runEvalIO st (writeFetchCache "interrupted-entry" (throw UserInterrupt)))+ pure (interruptPropagated outcome),+ fifoCase "an interrupt unpacking an archive is not a failed unpack" $ do+ let archive = StorePath (T.replicate 32 "f") "stalled.tar"+ outcome <- withStalledFifo (inStore archive) (interruptedAfterStall (runBuiltinUnpack storeDir defaultUnpackLimits (unpacking archive) [("out", root </> "stalled-out")]))+ pure (interruptPropagated outcome),+ fifoCase "an interrupt reading an input derivation is not an unreadable one" $ do+ let input = StorePath (T.replicate 32 "g") "stalled-input.drv"+ rootDrv = plainDrv {drvInputDrvs = Map.singleton input ["out"]}+ config = (defaultBuildConfig storeDir) {bcTmpDir = root </> "build"}+ outcome <- withStalledFifo (inStore input) (interruptedAfterStall (buildWithDeps config store rootDrv (StorePath (T.replicate 32 "h") "root.drv")))+ pure (interruptPropagated outcome),+ -- The failures the unpacker does catch: the zstd decoder reports a+ -- corrupt frame with 'error', and a missing archive is an+ -- IOException. Each is the archive's failure, not an escape.+ runTestM "a corrupt zstd archive and a missing one fail the unpack" $ do+ let corrupt = StorePath (T.replicate 32 "i") "corrupt.tar.zst"+ missing = StorePath (T.replicate 32 "j") "missing.tar"+ BS.writeFile (inStore corrupt) "not a zstd frame"+ corruptOutcome <- runBuiltinUnpack storeDir defaultUnpackLimits (unpacking corrupt) [("out", root </> "corrupt-out")]+ missingOutcome <- runBuiltinUnpack storeDir defaultUnpackLimits (unpacking missing) [("out", root </> "missing-out")]+ pure $ case (corruptOutcome, missingOutcome) of+ (Left (1, corruptMsg), Left (1, missingMsg))+ | "corrupt.tar.zst" `T.isInfixOf` corruptMsg && "missing.tar" `T.isInfixOf` missingMsg -> Pass+ other -> Fail ("expected both unpacks to fail: " <> T.pack (show other))+ ]+ forceRemoveIfExists root+ pure results