nova-nix 0.2.0.0 → 0.3.0.0
raw patch · 11 files changed
+543/−236 lines, 11 filesPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
- Nix.Eval.StringInterp: evalIndStringParts :: MonadEval m => Eval m -> Force m -> Apply m -> Env -> [StringPart] -> m (Text, StringContext)
- Nix.Eval.StringInterp: evalStringParts :: MonadEval m => Eval m -> Force m -> Apply m -> Env -> [StringPart] -> m (Text, StringContext)
- Nix.Eval.StringInterp: stripIndentation :: Text -> Text
+ Nix.Derivation: toATermForHash :: Bool -> Maybe [(Text, [Text])] -> Derivation -> Text
+ Nix.Eval: cacheDrvHash :: MonadEval m => Text -> Text -> m ()
+ Nix.Eval: lookupDrvHash :: MonadEval m => Text -> m (Maybe Text)
+ Nix.Eval: storeSourcePath :: MonadEval m => Text -> m Text
+ Nix.Eval.IO: [esDrvModuloCache] :: EvalState -> !IORef (Map Text Text)
+ Nix.Eval.IO: [esSourcePathCache] :: EvalState -> !IORef (Map Text Text)
+ Nix.Eval.StringInterp: stripIndentedChunks :: [(Bool, Text, StringContext)] -> (Text, StringContext)
+ Nix.Eval.Types: cacheDrvHash :: MonadEval m => Text -> Text -> m ()
+ Nix.Eval.Types: lookupDrvHash :: MonadEval m => Text -> m (Maybe Text)
+ Nix.Eval.Types: storeSourcePath :: MonadEval m => Text -> m Text
+ Nix.Hash: bytesToHexText :: ByteString -> Text
+ Nix.Hash: compressHash :: Int -> [Word8] -> [Word8]
+ Nix.Hash: hashPlaceholder :: Text -> Text
+ Nix.Hash: makeFixedOutputPath :: Text -> Text -> Text -> ByteString -> StorePath
+ Nix.Hash: makeOutputPath :: Text -> ByteString -> Text -> StorePath
+ Nix.Hash: makeStorePath :: StoreDir -> Text -> ByteString -> Text -> StorePath
+ Nix.Hash: makeTextPath :: Text -> ByteString -> [StorePath] -> StorePath
+ Nix.Hash: sha256Digest :: ByteString -> ByteString
- Nix.Eval.IO: EvalState :: !IORef (Map FilePath NixValue) -> !FilePath -> !FilePath -> !Int64 -> ![Thunk] -> EvalState
+ Nix.Eval.IO: EvalState :: !IORef (Map FilePath NixValue) -> !IORef (Map Text Text) -> !IORef (Map Text Text) -> !FilePath -> !FilePath -> !Int64 -> ![Thunk] -> EvalState
Files
- CHANGELOG.md +12/−6
- README.md +4/−4
- app/Main.hs +40/−3
- nova-nix.cabal +4/−4
- src/Nix/Derivation.hs +55/−20
- src/Nix/Eval.hs +197/−91
- src/Nix/Eval/IO.hs +36/−2
- src/Nix/Eval/StringInterp.hs +58/−95
- src/Nix/Eval/Types.hs +22/−0
- src/Nix/Hash.hs +104/−1
- test/Main.hs +11/−10
CHANGELOG.md view
@@ -1,5 +1,17 @@ # Changelog +## 0.3.0.0 — 2026-06-06++### Milestone: Derivation-Hash Parity with Upstream Nix++`(import <nixpkgs> {}).hello.drvPath`, and all 253 derivations in its closure, now hash byte-for-byte identically to `nix-instantiate`, verified in CI on the same nixpkgs tree. A new `eval --aterm` mode dumps a derivation's ATerm for diffing against `nix derivation show`.++- **Fix: indented-string indentation is stripped per literal string-part, before interpolation** — the common indentation was removed from the fully-interpolated string, so a multi-line interpolated value (e.g. `${commonPreHook}` in the stdenv `preHook`) could lower the computed minimum indent to zero and leave the literal lines indented. Indentation is now computed from the literal parts only — interpolated values are opaque content — matching C++ Nix.+- **Fix: path literals interpolated into strings are copied to the store** — `"${./foo}"` left the raw source path in the result; it now copies the file to the store and yields its store path, with the path added to the string context so it lands in the derivation's `inputSrcs`. `builtins.toString` still does not copy, per Nix semantics.+- **Fix: `builtins.placeholder`** — now returns `/` followed by the full SHA-256 of `nix-output:<name>` in Nix base-32, matching Nix's `hashPlaceholder`. It previously truncated the hash and wrapped it as a `/nix/store` path, so any derivation using `${placeholder "out"}` (e.g. perl's `configureFlags`) diverged.+- **Fix: a derivation's default output is its first declared output** — a bare reference to a multi-output derivation now resolves to `outputs[0]` (both name and path), not a hardcoded `out`. This affected packages whose first output is not `out`, such as `xz` (first output `bin`).+- **Fix: `builtins.attrNames` is sorted lexicographically** — names were returned in interned-symbol order rather than sorted by key, and inconsistently with `builtins.attrValues`. Surfaced in `fetchurl`'s `impureEnvVars` (the generated `NIX_MIRRORS_*` list).+ ## 0.2.0.0 — 2026-06-05 ### Milestone: `import <nixpkgs> {}` Evaluates@@ -7,7 +19,6 @@ - **`import <nixpkgs> {}` now evaluates to WHNF** — the top-level package set resolves and enumerates ~23,000 attributes, and a package evaluates through the full stdenv bootstrap down to a derivation: `(import <nixpkgs> {}).hello.drvPath` yields a `/nix/store/…-hello-2.12.1.drv` path. (Derivation-hash parity with upstream Nix and on-Windows building are the next fronts — this milestone is evaluation, not yet building.) - **Fix: `inherit <name>;` de Bruijn level in positional let/rec blocks** — `inherit x;` (without `from`) in a positional `let`/`rec` block desugars to `x = x;` with the right-hand side resolved against the *outer* scope, so it refers to the enclosing `x` rather than the binding being defined. But the desugared thunk is evaluated at runtime in the *inner* (let/rec) env, which adds one parent-chain level the outer resolution did not count — so every resolved level was short by one. This produced `nn_env_lookup_resolved: idx out of bounds` whenever an outer *lexical* variable was inherited inside a positional block — first triggered by perl's `inherit version;` (argument-set slot 7) nested in a 4-binding `let`. Fixed by shifting the desugared `inherit` RHS up one de Bruijn level in `Nix.Expr.Resolve`. (`inherit (from) …` was already correct: its RHS resolves against the inner scope.) - **Fix: Hackage build — ship C headers in the sdist** — the `cbits/*.h` headers are `#include`d by the C sources (via `include-dirs: cbits`) but were never listed in the cabal file, so `cabal sdist` omitted them and the Hackage build failed with `nn_*.h: No such file or directory`. Added `extra-source-files: cbits/*.h`. A regression introduced in 0.1.9.0 (the first release to ship the C data layer); CI never caught it because CI builds the working tree, not the sdist. Verified by building from a freshly-extracted sdist tarball in isolation.-- 593 tests, `-Werror` clean, ormolu clean, hlint clean ## 0.1.9.0 — 2026-06-05 @@ -23,7 +34,6 @@ - **Dead code removal** — Removed unused `cattrsetIntersect` Haskell wrapper, `nn_attrset_intersect`, and `nn_attrset_values_ptr` C functions. - **New: `nn_assert.h`** — Debug-mode bounds checking macro. Compiles to nothing under `NDEBUG`. - **Performance** — Stress test: 6.25 MB max residency, 56.3% GC productivity (down from 69.7 MB / 1.6% pre-C-data-layer).-- 109 builtins, 593 tests, `-Werror` clean, ormolu clean, hlint clean ## 0.1.8.0 — 2026-03-08 @@ -33,7 +43,6 @@ - **File outputs supported** — `$out` can now be a regular file, not just a directory. `registerSingleOutput`, `addToStore`, `scanReferences`, and `pathExists` all use `doesPathExist` instead of `doesDirectoryExist`. `moveOutput` (renamed from `moveDirectory`) handles both files and directories in cross-device fallback. `collectRegularFiles` accepts file paths directly for reference scanning. - **Fix #2: Windows store paths use native separators** — CLI now uses `platformStoreDir` (`C:\nix\store` on Windows, `/nix/store` on Unix) for filesystem operations and display. Evaluator internals keep canonical `/nix/store` for ATerm hash compatibility. - **Fix: `crypton < 1.1` in .cabal file** — Previous `crypton` pin was only in `cabal.project` (which Hackage ignores). Moved to `.cabal` so the Hackage solver respects it. `crypton >= 1.1` switched from `memory` to `ram` for `ByteArrayAccess`, breaking `http-client-tls`.-- 108 builtins, 526 tests, -Werror clean, ormolu clean, hlint clean ## 0.1.7.1 — 2026-03-07 @@ -51,7 +60,6 @@ - **Fix: UTF-16 auto-detection** — New `readFileAutoEncoding` detects UTF-16 LE/BE and UTF-8 BOM at the byte level before decoding. PowerShell's `>` operator writes UTF-16 LE by default — a Windows-first Nix implementation must handle this at the input boundary. Wired into all 5 file-read sites (Parser, Eval/IO, Main). - **nova-cache `>= 0.3.0`** — Bumped lower bound to track nova-cache 0.3.x series. - **`crypton < 1.1` pin** — `http-client-tls` still uses `memory`'s `ByteArrayAccess`; `crypton >= 1.1` switched to `ram`, causing instance mismatches. Pinned in `cabal.project` until `http-client-tls` migrates.-- 108 builtins, 524 tests, -Werror clean, ormolu clean, hlint clean ## 0.1.6.0 — 2026-02-26 @@ -63,7 +71,6 @@ - **Inherit desugaring** — `inherit x;` is desugared to `x = x;` in the resolution pass so inherited lambda formals resolve to `EResolvedVar` (lambda slots have no names at runtime). - **Heap savings** — Eliminates 29.9M Map.Bin nodes (1.37 GB) from lambda formals. Replaced by SmallArray (one heap object per env, O(1) index) + scope chain parent pointers. - `primitive` dependency added for `Data.Primitive.SmallArray`-- 108 builtins, 511 tests, -Werror clean, ormolu clean, hlint clean ## 0.1.5.0 — 2026-02-26 @@ -77,7 +84,6 @@ - **`fetchTarball` fix** — downloads and extracts via curl|tar pipeline (was returning raw .tar.gz) - **`MonadEval`** — added `traceMessage`, `copyPathToStore` methods - CI: switched to `haskell-actions/run-ormolu@v16` (matching all Novavero repos)-- 108 builtins, 511 tests, -Werror clean, ormolu clean, hlint clean ## 0.1.4.0 — 2026-02-26
README.md view
@@ -19,10 +19,10 @@ ```console $ NIX_PATH=nixpkgs=/path/to/nixpkgs \ nova-nix eval --expr '(import <nixpkgs> { system = "x86_64-linux"; }).hello.drvPath'-"/nix/store/azg0gjls29w3sii2kjp4c1v85ka5alnp-hello-2.12.1.drv"+"/nix/store/gciipqhqkdlqqn803zd4a389v86ran45-hello-2.12.1.drv" ``` -It targets the Nix 2.24 language. Evaluation is the milestone reached so far; confirming derivation-hash parity with upstream Nix, and *building* on Windows, are the work ahead.+That `drvPath`, and the 253-derivation closure behind it, byte-matches upstream `nix-instantiate` — verified in CI on the same nixpkgs tree. It targets the Nix 2.24 language. Evaluation and derivation-hash parity are done; *building* on Windows is the work ahead. ## Quickstart @@ -69,12 +69,12 @@ ## Roadmap -**Done** — parser, lazy bytecode evaluator, the Nix `builtins` set, the C99 data layer, content-addressed store, derivation builder, binary-cache substituter, and `import <nixpkgs> {}` evaluation through to `hello.drvPath`.+**Done** — parser, lazy bytecode evaluator, the Nix `builtins` set, the C99 data layer, content-addressed store, derivation builder, binary-cache substituter, `import <nixpkgs> {}` evaluation, and derivation-hash parity with upstream Nix (`hello`'s 253-derivation closure byte-matches `nix-instantiate`). **Next** -- Derivation-hash parity — confirm computed `drvPath`/`outPath` byte-match upstream Nix across nixpkgs. - Windows stdenv — MinGW GCC + MSYS2 coreutils bootstrap for native builds.+- Parity across more of nixpkgs — extend the byte-match check beyond `hello`'s closure. - Substituter — XZ decompression and real NAR hashing (currently uncompressed-only, with a placeholder hash). ## Library usage
app/Main.hs view
@@ -22,7 +22,7 @@ import qualified Data.Text.IO as TIO import Nix.Builder (BuildConfig (..), BuildResult (..), buildWithDeps, defaultBuildConfig) import Nix.Builtins (builtinEnv, parseNixPath)-import Nix.Derivation (Derivation (..), DerivationOutput (..))+import Nix.Derivation (Derivation (..), DerivationOutput (..), toATerm) import Nix.Eval (MonadEval, NixValue (..), Thunk (..), attrSetFromMap, attrSetLookup, attrSetToAscList, attrSetToMap, eval, evaluated, force, readThunkValue) import Nix.Eval.Arena (arenaInit) import Nix.Eval.IO (EvalState (..), newEvalState, runEvalIO)@@ -45,6 +45,7 @@ data CliOpts = CliOpts { optNixPaths :: ![T.Text], optStrict :: !Bool,+ optAterm :: !Bool, optCommand :: !Command } @@ -55,13 +56,15 @@ | CmdHelp parseArgs :: [String] -> CliOpts-parseArgs = go (CliOpts [] False CmdHelp)+parseArgs = go (CliOpts [] False False CmdHelp) where go opts [] = opts go opts ("--nix-path" : val : rest) = go (opts {optNixPaths = optNixPaths opts ++ [T.pack val]}) rest go opts ("--strict" : rest) = go (opts {optStrict = True}) rest+ go opts ("--aterm" : rest) =+ go (opts {optAterm = True}) rest go opts ("eval" : rest) = goEval opts rest go opts ("build" : path : rest) = go (opts {optCommand = CmdBuild path}) rest@@ -69,6 +72,7 @@ -- Sub-parser for eval: handles --strict and --expr interleaved with the file arg. goEval opts [] = opts goEval opts ("--strict" : rest) = goEval (opts {optStrict = True}) rest+ goEval opts ("--aterm" : rest) = goEval (opts {optAterm = True}) rest goEval opts ("--nix-path" : val : rest) = goEval (opts {optNixPaths = optNixPaths opts ++ [T.pack val]}) rest goEval opts ("--expr" : expr : rest) =@@ -96,7 +100,9 @@ let opts = parseArgs args case optCommand opts of CmdEvalFile filePath -> evalFile (optStrict opts) (optNixPaths opts) dataDir filePath- CmdEvalExpr expr -> evalExpr (optStrict opts) (optNixPaths opts) dataDir expr+ CmdEvalExpr expr+ | optAterm opts -> evalExprAterm (optNixPaths opts) dataDir expr+ | otherwise -> evalExpr (optStrict opts) (optNixPaths opts) dataDir expr CmdBuild filePath -> buildFile (optNixPaths opts) dataDir filePath CmdHelp -> do hPutStrLn stderr "Usage: nova-nix [--nix-path NAME=PATH] <command>"@@ -108,6 +114,7 @@ hPutStrLn stderr "" hPutStrLn stderr "Flags:" hPutStrLn stderr " --strict Deep-force all thunks before printing (warning: OOM on large results)"+ hPutStrLn stderr " --aterm With eval --expr, print the derivation's .drv ATerm" hPutStrLn stderr " --nix-path NAME=PATH Add search path (repeatable, merged with NIX_PATH)" exitFailure @@ -152,6 +159,36 @@ TIO.hPutStrLn stderr ("error: " <> err) exitFailure Right forced -> TIO.putStrLn (prettyValue forced)++-- | Evaluate an inline expression to a derivation and print its ATerm (.drv+-- contents), for diffing nova-nix's serialization against upstream Nix.+evalExprAterm :: [T.Text] -> FilePath -> T.Text -> IO ()+evalExprAterm extraPaths dataDir source =+ case parseNix "<expr>" source of+ Left err -> do+ hPutStrLn stderr ("parse error: " ++ show err)+ exitFailure+ Right expr -> do+ cwd <- getCurrentDirectory+ st0 <- newEvalState cwd+ let searchPaths = mergeSearchPaths extraPaths dataDir (esSearchPaths st0)+ st = st0 {esSearchPaths = searchPaths}+ result <- runEvalIO st $ do+ val <- eval (builtinEnv (esTimestamp st) searchPaths) expr+ case val of+ VAttrs attrs ->+ mapM_+ (\k -> maybe (pure ()) (void . force) (attrSetLookup k attrs))+ ["_derivation", "drvPath"]+ _ -> pure ()+ pure val+ case result of+ Left err -> do+ TIO.hPutStrLn stderr ("error: " <> err)+ exitFailure+ Right val -> do+ (drv, _) <- extractDerivation val+ TIO.putStrLn (toATerm drv) -- | Parse, evaluate, extract derivation, build, and print result. buildFile :: [T.Text] -> FilePath -> FilePath -> IO ()
nova-nix.cabal view
@@ -1,15 +1,15 @@ cabal-version: 3.0 name: nova-nix-version: 0.2.0.0+version: 0.3.0.0 synopsis: Windows-native Nix implementation in Haskell and C99 description: A from-scratch implementation of the Nix package manager that runs natively on Windows, macOS, and Linux — no WSL or Cygwin. A Haskell evaluator handles parsing and lazy evaluation, backed by a C99 data layer that keeps evaluation data off the GHC heap. It evaluates- Nix expressions (including real nixpkgs @lib@), computes derivations and- content-addressed store paths, and includes a derivation builder and- binary-cache substituter.+ real Nix expressions (including nixpkgs), computes derivations and+ content-addressed store paths that byte-match upstream Nix, and includes+ a derivation builder and binary-cache substituter. Built on @nova-cache@ for NAR serialization, narinfo handling, and Ed25519-signed binary substitution.
src/Nix/Derivation.hs view
@@ -55,6 +55,7 @@ -- * ATerm serialization toATerm,+ toATermForHash, fromATerm, -- * Platform@@ -69,6 +70,7 @@ import Data.List (sortBy) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map+import qualified Data.Set as Set import Data.Text (Text) import qualified Data.Text as T import Nix.Store.Path (StorePath (..))@@ -167,11 +169,27 @@ -- -- Format: @Derive([outputs],[inputDrvs],[inputSrcs],platform,builder,[args],[env])@ toATerm :: Derivation -> Text-toATerm drv =+toATerm = toATermForHash False Nothing++-- | Serialize a derivation for HASHING, with the two knobs the Nix+-- derivation-hash algorithm needs:+--+-- * @maskOutputs@: render every output path as the empty string. Used+-- when computing a derivation's own output paths (not yet known) via+-- @hashDerivationModulo@.+-- * @inputSubst@: when @Just subs@, render the input-derivations section+-- from @subs@ — pairs of @(moduloHashHex, outputNames)@ — instead of+-- from 'drvInputDrvs'. Each input derivation's store path is replaced+-- by its own modulo hash, so two derivations differing only in an+-- input's path spelling (not its content) hash identically.+--+-- @toATermForHash False Nothing@ is exactly 'toATerm'.+toATermForHash :: Bool -> Maybe [(Text, [Text])] -> Derivation -> Text+toATermForHash maskOutputs inputSubst drv = "Derive("- <> atermOutputs (drvOutputs drv)+ <> atermOutputsWith maskOutputs (drvOutputs drv) <> ","- <> atermInputDrvs (drvInputDrvs drv)+ <> inputDrvsSection <> "," <> atermInputSrcs (drvInputSrcs drv) <> ","@@ -183,26 +201,43 @@ <> "," <> atermEnv (drvEnv drv) <> ")"+ where+ inputDrvsSection = case inputSubst of+ Nothing -> atermInputDrvs (drvInputDrvs drv)+ Just subs -> atermInputDrvsSubst subs --- | Serialize outputs: @[(name,path,hashAlgo,hash)]@--- Sorted by output name for deterministic ATerm hashing.-atermOutputs :: [DerivationOutput] -> Text-atermOutputs outs =+-- | Serialize outputs: @[(name,path,hashAlgo,hash)]@, sorted by output name.+-- When @maskOutputs@ is set, every path is rendered as @\"\"@ (used by the+-- masked modulo hash, where output paths aren't known yet).+atermOutputsWith :: Bool -> [DerivationOutput] -> Text+atermOutputsWith maskOutputs outs = let sorted = sortBy (compare `on` doName) outs- in "[" <> T.intercalate "," (map atermOutput sorted) <> "]"+ in "[" <> T.intercalate "," (map render sorted) <> "]"+ where+ render out =+ "("+ <> atermString (doName out)+ <> ","+ <> atermString (if maskOutputs then "" else SP.storePathToText SP.defaultStoreDir (doPath out))+ <> ","+ <> atermString (doHashAlgo out)+ <> ","+ <> atermString (doHash out)+ <> ")" -atermOutput :: DerivationOutput -> Text-atermOutput out =- "("- <> atermString (doName out)- <> ","- <> atermString (SP.storePathToText SP.defaultStoreDir (doPath out))- <> ","- <> atermString (doHashAlgo out)- <> ","- <> atermString (doHash out)- <> ")"+-- | Input-derivations serializer for the modulo substitution: keys are the+-- modulo-hash hex strings (sorted), output-name lists sorted and deduplicated.+atermInputDrvsSubst :: [(Text, [Text])] -> Text+atermInputDrvsSubst subs =+ let sorted = sortBy (compare `on` fst) subs+ in "[" <> T.intercalate "," (map render sorted) <> "]"+ where+ render (key, outs) = "(" <> atermString key <> "," <> atermStringList (sortNubText outs) <> ")" +-- | Sort and deduplicate output names (matches C++ @std::set<string>@).+sortNubText :: [Text] -> [Text]+sortNubText = Set.toAscList . Set.fromList+ -- | Serialize input derivations: @[(drvPath,[outName1,outName2])]@ -- Sorted by store path for determinism. atermInputDrvs :: Map StorePath [Text] -> Text@@ -215,7 +250,7 @@ "(" <> atermString (SP.storePathToText SP.defaultStoreDir sp) <> ","- <> atermStringList outs+ <> atermStringList (sortNubText outs) <> ")" -- | Serialize input sources: @[path1,path2,...]@
src/Nix/Eval.hs view
@@ -73,7 +73,7 @@ import Data.Char (chr, digitToInt, isAlpha, isDigit, isHexDigit, isOctDigit, ord) import Data.IORef (IORef, atomicModifyIORef', newIORef) import Data.Int (Int64)-import Data.List (find, foldl')+import Data.List (find, foldl', sort) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, maybeToList)@@ -86,7 +86,7 @@ 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)+import Nix.Derivation (Derivation (..), DerivationOutput (..), textToPlatform, toATerm, toATermForHash) import Nix.Eval.CBytecode (cbcArg1, cbcArg2, cbcArg3, cbcData, cbcFlags, cbcOpcode, cbcShortArg) import Nix.Eval.CEnv (cenvPushWith) import Nix.Eval.CList (CList (..), clistGet)@@ -94,7 +94,7 @@ import Nix.Eval.Compile (BcAttrKey (..), BcBinding (..), compileExpr, decodeBcBindings, decodeBcCaptureInfo, decodeBcFormals, reassembleDouble, reassembleInt64) import Nix.Eval.Context (extractInputDrvs, extractInputSrcs, plainContext) import Nix.Eval.Operator (evalBinary, evalUnary, nixCompare, nixEqual)-import Nix.Eval.StringInterp (coerceToString, formatNixFloat, stripIndentation)+import Nix.Eval.StringInterp (coerceToString, formatNixFloat, stripIndentedChunks) import Nix.Eval.Symbol (Symbol (..), symbolText) import Nix.Eval.Types ( AttrSet (..),@@ -156,8 +156,8 @@ NixAtom (..), UnaryOp (..), )-import Nix.Hash (byteToHex, sha256Hex, truncatedBase32)-import Nix.Store.Path (StorePath (..), defaultStoreDir, defaultStoreDirText, parseStorePath, storePathToFilePath)+import Nix.Hash (byteToHex, hashPlaceholder, makeFixedOutputPath, makeOutputPath, makeTextPath, sha256Digest, sha256Hex)+import Nix.Store.Path (StorePath (..), defaultStoreDir, defaultStoreDirText, parseStorePath, storePathToFilePath, storePathToText) import qualified NovaCache.Base32 as Nix32 import qualified NovaCache.Base64 as B64 import System.IO.Unsafe (unsafePerformIO)@@ -382,32 +382,35 @@ let count = fromIntegral (unsafePerformIO (cbcShortArg bcIdx0)) dataOff = unsafePerformIO (cbcArg1 bcIdx0) chunks <- evalBcStringParts env count dataOff- let (texts, ctxs) = unzip chunks- pure (VStr (T.concat texts) (mconcat ctxs))+ pure (VStr (T.concat [t | (_, t, _) <- chunks]) (mconcat [c | (_, _, c) <- chunks])) --- | Evaluate an indented string literal from bytecode data buffer.+-- | Evaluate an indented string literal from bytecode data buffer. The common+-- indentation is stripped from the LITERAL chunks before concatenation, so an+-- interpolated multi-line value cannot drag the computed indent down — matching+-- C++ Nix. evalBcIndStr :: (MonadEval m) => Env -> Word32 -> m NixValue evalBcIndStr env bcIdx0 = do let count = fromIntegral (unsafePerformIO (cbcShortArg bcIdx0)) dataOff = unsafePerformIO (cbcArg1 bcIdx0) chunks <- evalBcStringParts env count dataOff- let (texts, ctxs) = unzip chunks- raw = T.concat texts- pure (VStr (stripIndentation raw) (mconcat ctxs))+ let (text, ctx) = stripIndentedChunks chunks+ pure (VStr text ctx) --- | Evaluate string parts from the bytecode data buffer.--- Each part is two words: (tag, value).--- tag=0 -> StrLit (value = symbol), tag=1 -> StrInterp (value = bc_idx).-evalBcStringParts :: (MonadEval m) => Env -> Int -> Word32 -> m [(Text, StringContext)]+-- | Evaluate string parts from the bytecode data buffer. Each part is two+-- words: (tag, value). tag=0 -> literal (value = symbol), tag=1 ->+-- interpolation (value = bc_idx). The 'Bool' marks literal (@True@) vs+-- interpolated (@False@) so indented strings strip only the literals.+evalBcStringParts :: (MonadEval m) => Env -> Int -> Word32 -> m [(Bool, Text, StringContext)] evalBcStringParts _ 0 _ = pure [] evalBcStringParts env n off = do let tag = unsafePerformIO (cbcData off) val = unsafePerformIO (cbcData (off + 1)) chunk <- case tag of- 0 -> pure (symbolText (Symbol val), emptyContext)+ 0 -> pure (True, symbolText (Symbol val), emptyContext) _ -> do v <- evalBytecode env val- coerceToString force applyValue v+ (txt, ctx) <- coerceToStringInterp v+ pure (False, txt, ctx) rest <- evalBcStringParts env (n - 1) (off + 2) pure (chunk : rest) @@ -1461,8 +1464,10 @@ builtinAttrNames :: (MonadEval m) => NixValue -> m NixValue builtinAttrNames (VAttrs attrs) =- -- Zero thunk allocation: attrSetKeys reads symbol names from C arrays.- let thunks = map (evaluated . mkStr) (attrSetKeys 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)@@ -1887,6 +1892,35 @@ coerceToStringPermissive val coerceToStringPermissive other = coerceToString force applyValue other +-- | Coerce a value to a string for a DERIVATION field (an env value or an+-- arg). Like 'coerceToStringPermissive', but a path literal is copied into+-- the store: it becomes its source store path, with that path added to the+-- string context so it lands in the derivation's @inputSrcs@ — matching C+++-- Nix's copy-to-store coercion of paths in derivation arguments/environment.+coerceToStoreString :: (MonadEval m) => NixValue -> m (Text, StringContext)+coerceToStoreString (VPath p) = do+ spText <- storeSourcePath p+ case parseStorePath defaultStoreDir spText of+ Just sp -> pure (spText, StringContext (Set.singleton (SCPlain sp)))+ Nothing -> pure (spText, mempty)+coerceToStoreString (VList cl) = do+ let thunks = map Thunk (clistThunks cl)+ parts <- mapM (force >=> coerceToStoreString) thunks+ pure (T.intercalate " " (map fst parts), mconcat (map snd parts))+coerceToStoreString other = coerceToStringPermissive other++-- | Coerce a value for string interpolation (@"${...}"@). Like+-- 'coerceToString', but a path literal is copied into the store and replaced by+-- its source store path (with context) — matching C++ Nix, where interpolation+-- uses @copyToStore = true@, unlike 'builtins.toString', which does not copy.+coerceToStringInterp :: (MonadEval m) => NixValue -> m (Text, StringContext)+coerceToStringInterp (VPath p) = do+ spText <- storeSourcePath p+ case parseStorePath defaultStoreDir spText of+ Just sp -> pure (spText, StringContext (Set.singleton (SCPlain sp)))+ Nothing -> pure (spText, mempty)+coerceToStringInterp other = coerceToString force applyValue other+ -- | The current system platform string. currentSystemStr :: Text currentSystemStr = case (System.Info.arch, System.Info.os) of@@ -2927,10 +2961,7 @@ -- --------------------------------------------------------------------------- builtinPlaceholder :: (MonadEval m) => NixValue -> m NixValue-builtinPlaceholder (VStr outputName _) =- let preimage = "nix-output:" <> outputName- hashText = truncatedBase32 (TE.encodeUtf8 preimage)- in pure (mkStr (storeDirPrefix <> hashText <> "-" <> outputName))+builtinPlaceholder (VStr outputName _) = pure (mkStr (hashPlaceholder outputName)) builtinPlaceholder other = throwEvalError ("builtins.placeholder: expected a string, got " <> typeName other) @@ -3151,8 +3182,8 @@ builtinDerivationStrict (VAttrs attrs) = do -- Extract required attributes drvName <- forceAttrStr "derivation" "name" attrs- system <- forceAttrStr "derivation" "system" attrs- builder <- forceAttrStr "derivation" "builder" attrs+ system <- forceAttrStr ("derivation \"" <> drvName <> "\"") "system" attrs+ builder <- forceAttrStr ("derivation \"" <> drvName <> "\"") "builder" attrs -- Extract optional outputs (default ["out"]) outputNames <- case attrSetLookup "outputs" attrs of@@ -3163,88 +3194,112 @@ VList cl -> mapM (forceToText . Thunk) (clistThunks cl) _ -> throwEvalError "derivation: 'outputs' must be a list of strings" - -- Extract optional args (default [])- builderArgs <- case attrSetLookup "args" attrs of- Nothing -> 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 -> mapM (forceToText . Thunk) (clistThunks cl)+ VList cl -> do+ parts <- mapM (\t -> force (Thunk t) >>= coerceToStoreString) (clistThunks cl)+ pure (map fst parts, mconcat (map snd parts)) _ -> throwEvalError "derivation: 'args' must be a list of strings" -- Materialize once, reuse for both env collection and result merge let materialized = attrSetToMap attrs - -- Collect all string-coercible attrs into env WITH their contexts- (drvEnvPairs, envContext) <- collectDrvEnvWithContext materialized-- -- Extract input derivations and input sources from the merged context- let inputDrvs = extractInputDrvs envContext- inputSrcs = extractInputSrcs envContext-- -- Build the platform- let platform = textToPlatform system+ -- Collect string-coercible attrs into the build env, EXCLUDING "args"+ -- (C++ Nix puts args in the Derive() args field, never the env). The+ -- per-output env vars ($out, …) are added below. Carries merged context.+ (drvEnvPairs, envContext) <- collectDrvEnvWithContext (Map.delete "__ignoreNulls" (Map.delete "args" materialized)) - -- Build the derivation with populated inputs for hashing- let envMap = Map.fromList drvEnvPairs- drv =+ let fullContext = envContext <> argsContext+ inputDrvs = extractInputDrvs fullContext+ inputSrcs = extractInputSrcs fullContext+ platform = textToPlatform system+ baseEnv = Map.fromList drvEnvPairs+ drvRefs = inputSrcs ++ Map.keys inputDrvs+ drvFileName = drvName <> ".drv"+ -- Build a Derivation sharing this call's inputs/platform/builder/args.+ mkDrv outs env = Derivation- { drvOutputs = [],+ { drvOutputs = outs, drvInputDrvs = inputDrvs, drvInputSrcs = inputSrcs, drvPlatform = platform, drvBuilder = builder, drvArgs = builderArgs,- drvEnv = envMap+ drvEnv = env }-- -- Serialize to ATerm and hash for drvPath- let aterm = toATerm drv- storeRef = ":" <> defaultStoreDirText <> ":"- drvPathHash = truncatedBase32 (TE.encodeUtf8 ("text:sha256:" <> sha256Hex (TE.encodeUtf8 aterm) <> storeRef <> drvName <> ".drv"))- drvPathText = storeDirPrefix <> drvPathHash <> "-" <> drvName <> ".drv"+ -- Output carrying only its name; the path is masked at render time.+ maskedOutput name = DerivationOutput name (StorePath "" "") "" ""+ -- Resolve an input derivation's modulo hash (hex) from the cache,+ -- populated bottom-up by earlier 'builtinDerivationStrict' calls.+ resolveInputModulo (sp, outs) = do+ let inputPathText = storePathToText defaultStoreDir sp+ cached <- lookupDrvHash inputPathText+ case cached of+ Just hex -> pure (hex, outs)+ Nothing -> do+ -- Eval is bottom-up, so inputs evaluated this session are always+ -- cached by the time a dependent hashes. A miss means an input+ -- referenced but not evaluated here (a pure-eval synthetic context,+ -- or a pre-built store drv): fall back to its store hash so+ -- evaluation still produces a value, and warn for visibility.+ traceMessage+ ("derivation: input modulo hash not cached, using store hash for " <> inputPathText)+ pure (spHash sp, outs) - -- Parse drvPath as a StorePath for context- let drvSP = case parseStorePath defaultStoreDir drvPathText of- Just sp -> sp- Nothing -> StorePath (T.take 32 (T.drop (T.length storeDirPrefix) drvPathText)) (drvName <> ".drv")+ -- 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 - -- Compute output paths- let computeOutPath outName =- let nameSuffix = if outName == "out" then "" else "-" <> outName- preimage = "output:" <> outName <> ":sha256:" <> sha256Hex (TE.encodeUtf8 aterm) <> storeRef <> drvName <> nameSuffix- outHash = truncatedBase32 (TE.encodeUtf8 preimage)- in storeDirPrefix <> outHash <> "-" <> drvName <> nameSuffix+ (drvPathText, drvSP, outPaths, completeDrv) <- case mFixed of+ Just (foAlgo, foMode, foDigest) -> do+ let foPath = makeFixedOutputPath drvName foAlgo foMode foDigest+ foPathText = storePathToText defaultStoreDir foPath+ algoField = (if foMode == "recursive" then "r:" else "") <> foAlgo+ foHashHex = bytesToHex foDigest+ foModulo =+ sha256Digest+ (TE.encodeUtf8 ("fixed:out:" <> algoField <> ":" <> foHashHex <> ":" <> foPathText))+ contents =+ mkDrv+ [DerivationOutput "out" foPath algoField foHashHex]+ (Map.insert "out" foPathText baseEnv)+ drvSp = makeTextPath drvFileName (sha256Digest (TE.encodeUtf8 (toATerm contents))) drvRefs+ drvText = storePathToText defaultStoreDir drvSp+ cacheDrvHash drvText (bytesToHex 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 → this derivation's own output paths.+ moduloMasked = sha256Digest (TE.encodeUtf8 (toATermForHash True (Just inputSubst) maskedDrv))+ outStorePaths = [(n, makeOutputPath n moduloMasked drvName) | n <- outputNames]+ outPathTexts = [(n, storePathToText defaultStoreDir sp) | (n, sp) <- outStorePaths]+ realEnv = foldr (\(n, t) e -> Map.insert n t e) baseEnv outPathTexts+ contents = mkDrv [DerivationOutput n sp "" "" | (n, sp) <- outStorePaths] realEnv+ -- Unmasked modulo hash (real outputs, inputs substituted) cached for+ -- when this derivation is itself an input to another.+ moduloUnmasked = sha256Digest (TE.encodeUtf8 (toATermForHash False (Just inputSubst) contents))+ drvSp = makeTextPath drvFileName (sha256Digest (TE.encodeUtf8 (toATerm contents))) drvRefs+ drvText = storePathToText defaultStoreDir drvSp+ cacheDrvHash drvText (bytesToHex moduloUnmasked)+ pure (drvText, drvSp, outPathTexts, contents) - let outPaths = [(outName, computeOutPath outName) | outName <- outputNames]- mainOutPath = case outPaths of+ let mainOutPath = case outPaths of ((_, p) : _) -> p [] -> ""-- -- Build DerivationOutput records for the Derivation value- let drvOutputsList =- [ DerivationOutput- { doName = outName,- doPath = case parseStorePath defaultStoreDir outP of- Just sp -> sp- -- Fallback: construct manually from the path string- Nothing -> StorePath (T.take 32 (T.drop (T.length storeDirPrefix) outP)) outName,- doHashAlgo = "",- doHash = ""- }- | (outName, outP) <- outPaths- ]-- -- Build the complete Derivation with populated outputs and env.- -- The hash was computed from the pre-output drv (drvOutputs = []),- -- so adding output paths and drvPath to drvEnv here does not affect- -- the content address. Real Nix .drv files include these in their- -- env section — builders read $out etc. from the environment.- let completeEnv =- Map.union- (Map.fromList (("drvPath", drvPathText) : outPaths))- envMap- completeDrv = drv {drvOutputs = drvOutputsList, drvEnv = completeEnv}+ -- 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@@ -3267,7 +3322,7 @@ Map.fromList $ [ ("type", evaluated (mkStr "derivation")), ("drvPath", evaluated (VStr drvPathText drvPathCtx)),- ("outPath", evaluated (VStr mainOutPath (outPathCtx "out"))),+ ("outPath", evaluated (VStr mainOutPath (outPathCtx mainOutName))), ("name", evaluated (mkStr drvName)), ("system", evaluated (mkStr system)), ("builder", evaluated (mkStr builder)),@@ -3281,6 +3336,50 @@ builtinDerivationStrict other = throwEvalError ("derivation: expected a set, got " <> typeName other) +-- | Detect a fixed-output derivation. Returns @Just (algo, mode, rawDigest)@+-- when @outputHash@ is present and non-empty (fetchurl, fetchgit, …), else+-- 'Nothing' for an ordinary input-addressed derivation. @mode@ is+-- @\"flat\"@ or @\"recursive\"@; @algo@ is e.g. @\"sha256\"@.+detectFixedOutput :: (MonadEval m) => AttrSet -> m (Maybe (Text, Text, BS.ByteString))+detectFixedOutput attrs =+ case attrSetLookup "outputHash" attrs of+ Nothing -> pure Nothing+ Just thunk -> do+ val <- force thunk+ case val of+ VStr ohash _+ | not (T.null ohash) -> do+ ohAlgo <- optDrvStrAttr "outputHashAlgo" attrs+ ohMode <- optDrvStrAttr "outputHashMode" attrs+ (algo, digest) <- normalizeFixedHash ohash ohAlgo+ let mode = if ohMode == "recursive" then "recursive" else "flat"+ pure (Just (algo, mode, digest))+ _ -> pure Nothing++-- | Read an optional string attribute, defaulting to @\"\"@ when absent or+-- not a string.+optDrvStrAttr :: (MonadEval m) => Text -> AttrSet -> m Text+optDrvStrAttr key attrs =+ case attrSetLookup key attrs of+ Nothing -> pure ""+ Just thunk -> do+ val <- force thunk+ case val of+ VStr s _ -> pure s+ _ -> pure ""++-- | Decode a fixed-output hash (SRI @algo-base64@, @algo:hash@, or a bare+-- hash plus a separate algorithm) to its algorithm name and raw bytes.+normalizeFixedHash :: (MonadEval m) => Text -> Text -> m (Text, BS.ByteString)+normalizeFixedHash ohash ohAlgo+ | Just (algo, b64) <- parseSRI ohash = do+ bytes <- decodeBase64E "derivation" b64+ pure (algo, bytes)+ | Just (algo, rest) <- parseAlgoPrefix ohash = decodeWithAlgo algo rest+ | not (T.null ohAlgo) = decodeWithAlgo ohAlgo ohash+ | otherwise =+ throwEvalError ("derivation: cannot determine outputHash algorithm for " <> ohash)+ -- | Lazy @derivation@ wrapper — mirrors C++ Nix's @corepkgs/derivation.nix@. -- Returns a WHNF attrset whose @drvPath@/@outPath@/output-path/@_derivation@ -- attrs are LAZY thunks that defer to 'builtinDerivationStrict'. Forcing a@@ -3351,7 +3450,7 @@ case val of VNull -> pure Nothing _ -> do- result <- catchEvalError (coerceToStringPermissive val)+ result <- catchEvalError (coerceToStoreString val) case result of Right (s, ctx) -> pure (Just (key, s, ctx)) Left _ -> pure Nothing@@ -3490,9 +3589,16 @@ -- | Decode base64 text to bytes (pure). decodeBase64Pure :: Text -> Either Text BS.ByteString decodeBase64Pure t =- case B64.decode (T.filter (/= '=') (T.filter (/= '\n') (T.filter (/= '\r') t))) of- Right bytes -> Right bytes- Left _ -> Left "invalid base64"+ -- 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
src/Nix/Eval/IO.hs view
@@ -50,13 +50,14 @@ import Nix.Eval.Symbol (Symbol (..), symbolIntern, symbolText) import Nix.Eval.Types (AttrSet (..), Env (..), MonadEval (..), NixValue (..), Thunk (..), attrSetSize, emptyContext, marshalLambda, marshalStringContext, unmarshalLambdaValue, unmarshalStringContext) import Nix.Expr.Types (AttrKey (..), Binding (..), Expr (..), Formal (..), Formals (..), NixAtom (..), StringPart (..))-import Nix.Hash (sha256Hex, truncatedBase32)+import Nix.Hash (makeFixedOutputPath, sha256Digest, sha256Hex, truncatedBase32) import Nix.Parser (parseNix, readFileAutoEncoding) 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 (isRelative, takeDirectory, takeFileName, (</>)) import System.IO (hPutStrLn, stderr) import qualified System.Process as Proc @@ -93,6 +94,13 @@ -- @builtins.nixPath@. data EvalState = EvalState { esImportCache :: !(IORef (Map FilePath NixValue)),+ -- | Cache of derivation modulo-hashes (drv store path → hex), populated+ -- bottom-up by 'builtinDerivationStrict' so input derivations can be+ -- substituted by their content hashes when computing output paths.+ esDrvModuloCache :: !(IORef (Map Text Text)),+ -- | Cache of source path → its store path (recursive NAR hash), so a path+ -- literal used across many derivations is hashed only once.+ esSourcePathCache :: !(IORef (Map Text Text)), esBaseDir :: !FilePath, esStoreDir :: !FilePath, esTimestamp :: !Int64,@@ -104,6 +112,8 @@ newEvalState :: FilePath -> IO EvalState newEvalState baseDir = do cache <- newIORef Map.empty+ drvCache <- newIORef Map.empty+ srcCache <- newIORef Map.empty now <- floor <$> getPOSIXTime :: IO Int64 nixPathStr <- lookupEnvText "NIX_PATH" let searchPaths = case nixPathStr of@@ -112,6 +122,8 @@ pure EvalState { esImportCache = cache,+ esDrvModuloCache = drvCache,+ esSourcePathCache = srcCache, esBaseDir = baseDir, esStoreDir = T.unpack SP.platformStoreDirText, esTimestamp = now,@@ -198,6 +210,28 @@ getEnvVar name = wrapIO $ do mval <- lookupEnvText (T.unpack name) pure (maybe "" T.pack mval)++ lookupDrvHash key = EvalIO $ do+ ref <- asks esDrvModuloCache+ liftIO (Map.lookup key <$> readIORef ref)++ cacheDrvHash key val = EvalIO $ do+ ref <- asks esDrvModuloCache+ liftIO (modifyIORef' ref (Map.insert key val))++ storeSourcePath rawPath = do+ ref <- EvalIO (asks esSourcePathCache)+ cached <- EvalIO (liftIO (Map.lookup rawPath <$> readIORef ref))+ case cached of+ Just hit -> pure hit+ Nothing -> do+ entry <- wrapIO (NAR.serialiseFromPath (T.unpack rawPath))+ let narDigest = sha256Digest (NAR.serialise entry)+ name = T.pack (takeFileName (T.unpack rawPath))+ sp = makeFixedOutputPath name "sha256" "recursive" narDigest+ spText = SP.storePathToText SP.defaultStoreDir sp+ EvalIO (liftIO (modifyIORef' ref (Map.insert rawPath spText)))+ pure spText getCurrentTime = EvalIO (asks esTimestamp)
src/Nix/Eval/StringInterp.hs view
@@ -1,57 +1,80 @@--- | String interpolation evaluation for Nix.+-- | String coercion and indented-string whitespace stripping. ----- Handles both regular strings (double-quoted) and indented strings--- (double single-quoted).--- Takes the evaluator as a parameter to break the import cycle with--- @Nix.Eval@.+-- 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@. module Nix.Eval.StringInterp- ( evalStringParts,- evalIndStringParts,+ ( stripIndentedChunks, coerceToString, formatNixFloat,- stripIndentation, ) where +import Data.List (foldl') import Data.Text (Text) import qualified Data.Text as T-import Nix.Eval.Context (concatStrings)-import Nix.Eval.Types (Env, MonadEval (..), NixValue (..), StringContext, Thunk, attrSetLookup, emptyContext, typeName)-import Nix.Expr.Types (Expr, StringPart (..))+import Nix.Eval.Types (MonadEval (..), NixValue (..), StringContext, Thunk, attrSetLookup, emptyContext, typeName) import Numeric (showFFloat) --- | The evaluator function, passed as a parameter to avoid cyclic imports.-type Eval m = Env -> Expr -> m NixValue- -- | Force a thunk to a value. type Force m = Thunk -> m NixValue -- | Apply a function value to an argument value. type Apply m = NixValue -> NixValue -> m NixValue --- | Evaluate the parts of a regular string (double-quoted).--- Returns the concatenated text and the merged context from all parts.-evalStringParts :: (MonadEval m) => Eval m -> Force m -> Apply m -> Env -> [StringPart] -> m (Text, StringContext)-evalStringParts evalFn forceFn applyFn env parts = do- chunks <- mapM (evalOnePart evalFn forceFn applyFn env) parts- pure (concatStrings chunks)+-- | Strip the common indentation from already-evaluated indented-string chunks.+-- Each chunk is @(isLiteral, text, context)@. Indentation is computed and+-- stripped from the LITERAL chunks only — interpolated chunks are opaque content+-- — the single leading newline is dropped, and the trailing newline is kept.+-- This matches C++ Nix, which strips at the string-part level (so a multi-line+-- interpolated value cannot drag the common indent down).+stripIndentedChunks :: [(Bool, Text, StringContext)] -> (Text, StringContext)+stripIndentedChunks chunks =+ let stripped = dropLeadingNL (chunksStrip (chunksMinIndent chunks) chunks)+ in (T.concat (map snd stripped), mconcat [c | (_, _, c) <- chunks])+ where+ dropLeadingNL ((True, t) : rest) =+ (True, case T.uncons t of { Just ('\n', r) -> r; _ -> t }) : rest+ dropLeadingNL other = other --- | Evaluate the parts of an indented string (double single-quoted).------ After interpolation, strips the common leading whitespace from all--- non-empty lines (the standard Nix indented-string semantics).--- Context is preserved through indentation stripping.-evalIndStringParts :: (MonadEval m) => Eval m -> Force m -> Apply m -> Env -> [StringPart] -> m (Text, StringContext)-evalIndStringParts evalFn forceFn applyFn env parts = do- (raw, ctx) <- evalStringParts evalFn forceFn applyFn env parts- pure (stripIndentation raw, ctx)+-- | Common indentation across the LITERAL chunks. An interpolation at line+-- start fixes that line's indent at the preceding literal whitespace and counts+-- as content; whitespace-only lines do not contribute.+chunksMinIndent :: [(Bool, Text, StringContext)] -> Int+chunksMinIndent = result . foldl' stepChunk (True, 0, Nothing)+ where+ result (_, _, Nothing) = 0+ result (_, _, Just m) = m+ stepChunk (atStart, cur, mi) (isLit, t, _)+ | not isLit = if atStart then (False, cur, bump mi cur) else (False, cur, mi)+ | otherwise = T.foldl' stepChar (atStart, cur, mi) t+ 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) --- | Evaluate a single string part, returning its text and context.-evalOnePart :: (MonadEval m) => Eval m -> Force m -> Apply m -> Env -> StringPart -> m (Text, StringContext)-evalOnePart _ _ _ _ (StrLit txt) = pure (txt, emptyContext)-evalOnePart evalFn forceFn applyFn env (StrInterp expr) = do- val <- evalFn env expr- coerceToString forceFn applyFn val+-- | Strip @n@ columns of leading indentation from each line of the literal+-- chunks; interpolated chunks are emitted verbatim and reset the line position.+chunksStrip :: Int -> [(Bool, Text, StringContext)] -> [(Bool, Text)]+chunksStrip n = go True 0+ where+ go _ _ [] = []+ go _ _ ((False, t, _) : rest) = (False, t) : go False 0 rest+ go atStart dropped ((True, t, _) : rest) =+ let (acc, atStart', dropped') = T.foldl' stepC ([], atStart, dropped) t+ in (True, T.pack (reverse acc)) : go atStart' dropped' rest+ 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) -- | Coerce a Nix value to a string for interpolation. --@@ -99,63 +122,3 @@ | otherwise = s dropDot ('.' : rest) = rest dropDot xs = xs---- ------------------------------------------------------------------------------ Indented string whitespace stripping--- ------------------------------------------------------------------------------- | Strip the common leading whitespace from an indented string.------ Algorithm (matching C++ Nix):--- 1. Split into lines.--- 2. Find the minimum indentation of all non-empty lines (excluding--- the first line, which has no leading whitespace in double single-quoted).--- 3. Strip that many spaces/tabs from the front of each line.--- 4. Drop a single leading newline if present.--- 5. Drop a single trailing newline if present.-stripIndentation :: Text -> Text-stripIndentation raw- | T.null raw = raw- | otherwise =- let withLeadingStripped = stripLeadingNewline raw- lns = T.splitOn "\n" withLeadingStripped- minIndent = minimumIndent lns- stripped = map (stripPrefix minIndent) lns- joined = T.intercalate "\n" stripped- in stripTrailingNewline joined---- | Count leading spaces on a line (tabs count as one space).-countIndent :: Text -> Int-countIndent = T.length . T.takeWhile (\c -> c == ' ' || c == '\t')---- | Find the minimum indentation across all non-blank lines.--- Whitespace-only lines are treated as blank (infinite indent) per Nix semantics.-minimumIndent :: [Text] -> Int-minimumIndent lns =- let nonBlank = filter (\t -> not (T.null t) && not (T.all isSpace t)) lns- indents = map countIndent nonBlank- in case indents of- [] -> 0- xs -> minimum xs- where- isSpace c = c == ' ' || c == '\t'---- | Strip up to @n@ leading whitespace characters from a line.-stripPrefix :: Int -> Text -> Text-stripPrefix 0 t = t-stripPrefix n t = case T.uncons t of- Just (c, rest)- | c == ' ' || c == '\t' -> stripPrefix (n - 1) rest- _ -> t---- | Drop a single leading newline.-stripLeadingNewline :: Text -> Text-stripLeadingNewline t = case T.uncons t of- Just ('\n', rest) -> rest- _ -> t---- | Drop a single trailing newline.-stripTrailingNewline :: Text -> Text-stripTrailingNewline t = case T.unsnoc t of- Just (prefix, '\n') -> prefix- _ -> t
src/Nix/Eval/Types.hs view
@@ -1049,6 +1049,22 @@ -- the Eval.Types → Eval circular dependency). forceThunk :: (Env -> Word32 -> m NixValue) -> Thunk -> m NixValue + -- | Look up a cached derivation modulo-hash (hex) by its @.drv@ store+ -- path. Populated bottom-up as each derivation is computed; used by the+ -- derivation-hash algorithm to substitute input derivations. Pure+ -- evaluators have no cache and always return 'Nothing'.+ lookupDrvHash :: Text -> m (Maybe Text)++ -- | Cache a derivation's modulo hash (hex) under its @.drv@ store path.+ -- A no-op in pure evaluators (no memoization).+ cacheDrvHash :: Text -> Text -> m ()++ -- | Compute the store path a source file/directory gets when copied into+ -- the store (recursive NAR sha256 → @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+ -- | Pure evaluation monad — wraps 'Either Text'. -- IO builtins ('readFile', 'import') are unavailable; -- everything else evaluates identically to the IO version.@@ -1072,6 +1088,12 @@ runProcess _ _ _ = throwEvalError "runProcess: not available in pure evaluation" copyPathToStore _ _ = throwEvalError "builtins.path: not available in pure evaluation" traceMessage _ = pure ()+ lookupDrvHash _ = pure Nothing+ cacheDrvHash _ _ = pure ()++ -- Pure eval cannot read files: a path coerces to itself (no store copy);+ -- the real copy-to-store happens only under 'EvalIO'.+ storeSourcePath = pure resolvePathLiteral = pure forceThunk evalFn (Thunk ptr) = -- Read the C thunk via unsafePerformIO — safe because reads are
src/Nix/Hash.hs view
@@ -27,7 +27,8 @@ -- -- nova-cache already handles output hashes and file hashes. This module -- adds input hash (derivation hash) computation for the evaluator, plus--- shared hashing utilities used by the evaluator and IO layer.+-- the @makeStorePath@ family that constructs content-addressed store paths+-- exactly as C++ Nix does. module Nix.Hash ( -- * Derivation hashing DrvHash (..),@@ -36,8 +37,18 @@ -- * Shared hashing utilities sha256Hex, truncatedBase32,+ hashPlaceholder, byteToHex, + -- * Store path construction (Nix @makeStorePath@ family)+ makeStorePath,+ makeTextPath,+ makeFixedOutputPath,+ makeOutputPath,+ compressHash,+ sha256Digest,+ bytesToHexText,+ -- * Re-exports from nova-cache hashBytes, formatNixHash,@@ -50,10 +61,12 @@ import qualified Data.ByteArray as BA import qualified Data.ByteString as BS import Data.List (foldl')+import qualified Data.Set as Set import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Data.Word (Word8)+import Nix.Store.Path (StoreDir (..), StorePath (..), defaultStoreDir, storePathToText) import NovaCache.Base32 (encode) import NovaCache.Hash (formatNixHash, hashBytes, parseNixHash) @@ -93,6 +106,13 @@ compressed = compressHash 20 allBytes in encode (BS.pack compressed) +-- | Nix's output placeholder for @builtins.placeholder@: @\/@ followed by the+-- full SHA-256 of @"nix-output:" <> name@ in Nix base-32 (no truncation, no+-- store-dir prefix). Matches C++ Nix @hashPlaceholder@.+hashPlaceholder :: Text -> Text+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]@@ -111,3 +131,86 @@ | n < 10 = toEnum (fromEnum '0' + n) | otherwise = toEnum (fromEnum 'a' + n - 10) in [hexDigit hi, hexDigit lo]++-- ---------------------------------------------------------------------------+-- Store path construction — the Nix @makeStorePath@ family+--+-- These mirror C++ Nix exactly so nova-nix's computed store paths byte-match+-- @nix-instantiate@. All hashing is done against the canonical @\/nix\/store@+-- directory ('defaultStoreDir') so paths are host-independent: the same+-- derivation hashes identically on Windows, Linux, and macOS.+-- ---------------------------------------------------------------------------++-- | Length in bytes a store-path hash is compressed to (160 bits → 32 base-32+-- characters).+storePathHashBytes :: Int+storePathHashBytes = 20++-- | Raw 32-byte SHA-256 digest of a ByteString (not hex-encoded).+sha256Digest :: BS.ByteString -> BS.ByteString+sha256Digest bs = BA.convert (CH.hash bs :: CH.Digest CH.SHA256)++-- | Lowercase base-16 of raw bytes (two hex characters per byte).+bytesToHexText :: BS.ByteString -> Text+bytesToHexText = T.pack . concatMap byteToHex . BS.unpack++-- | The core store-path construction primitive. Given a @type@ string, the+-- inner content digest (raw SHA-256 bytes), and a name, produce the store+-- path. Mirrors C++ Nix @makeStorePath@:+--+-- @+-- s = type ":sha256:" hex(innerDigest) ":" storeDir ":" name+-- hash = compressHash(sha256(s), 20)+-- path = storeDir "/" base32(hash) "-" name+-- @+--+-- The @type@ string varies by caller: @\"text\"@ (+ references) for @.drv@+-- and @toFile@ paths, @\"output:<id>\"@ for derivation outputs, @\"source\"@+-- for recursive fixed-output paths.+makeStorePath :: StoreDir -> Text -> BS.ByteString -> Text -> StorePath+makeStorePath (StoreDir dir) typ innerDigest name =+ let preimage =+ typ+ <> ":sha256:"+ <> bytesToHexText innerDigest+ <> ":"+ <> T.pack dir+ <> ":"+ <> name+ compressed = compressHash storePathHashBytes (BS.unpack (sha256Digest (encodeUtf8 preimage)))+ in StorePath (encode (BS.pack compressed)) name++-- | Construct a text store path (used for @.drv@ files and @builtins.toFile@).+-- The references are embedded in the @type@ string — @\"text\"@ followed by+-- each referenced store path — which is why a derivation's @.drv@ path depends+-- on the paths of all its inputs. @contentsDigest@ is the SHA-256 of the file+-- contents (the ATerm, for a @.drv@).+makeTextPath :: Text -> BS.ByteString -> [StorePath] -> StorePath+makeTextPath name contentsDigest refs =+ let sortedRefs = Set.toAscList (Set.fromList refs)+ typ = "text" <> T.concat [":" <> storePathToText defaultStoreDir r | r <- sortedRefs]+ in makeStorePath defaultStoreDir typ contentsDigest name++-- | Construct a fixed-output store path. @foHashDigest@ is the raw bytes of+-- the EXPECTED output hash (e.g. a tarball's SHA-256). @mode@ is @\"flat\"@+-- or @\"recursive\"@. Mirrors C++ Nix @makeFixedOutputPath@:+--+-- * @sha256@ + @recursive@ → @makeStorePath \"source\" foHash name@+-- * otherwise → @makeStorePath \"output:out\" sha256(\"fixed:out:\" prefix algo \":\" hex \":\") name@+makeFixedOutputPath :: Text -> Text -> Text -> BS.ByteString -> StorePath+makeFixedOutputPath name algo mode foHashDigest+ | algo == "sha256" && mode == "recursive" =+ makeStorePath defaultStoreDir "source" foHashDigest name+ | otherwise =+ let prefix = if mode == "recursive" then "r:" else ""+ inner = "fixed:out:" <> prefix <> algo <> ":" <> bytesToHexText foHashDigest <> ":"+ innerDigest = sha256Digest (encodeUtf8 inner)+ in makeStorePath defaultStoreDir "output:out" innerDigest name++-- | Construct an input-addressed output store path. @moduloDigest@ is the raw+-- bytes of @hashDerivationModulo@ (masked). Mirrors C++ Nix @makeOutputPath@:+-- the path name gets an @-<output>@ suffix for non-@out@ outputs.+makeOutputPath :: Text -> BS.ByteString -> Text -> StorePath+makeOutputPath outName moduloDigest drvName =+ let pathName = if outName == "out" then drvName else drvName <> "-" <> outName+ in makeStorePath defaultStoreDir ("output:" <> outName) moduloDigest pathName
test/Main.hs view
@@ -1604,10 +1604,10 @@ putStrLn "eval/builtins-batchB" sequence [ -- placeholder- runTest "placeholder out starts with /nix/store/" $- assertRight "placeholder-prefix" (evalNix "builtins.placeholder \"out\"") $ \val ->+ runTest "placeholder out matches Nix hashPlaceholder" $+ assertRight "placeholder-out" (evalNix "builtins.placeholder \"out\"") $ \val -> case val of- VStr p _ -> if "/nix/store/" `T.isPrefixOf` p then Pass else Fail ("bad prefix: " <> p)+ 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 ->@@ -2752,7 +2752,7 @@ drvInputDrvs = Map.fromList [ (StorePath "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" "dep1.drv", ["out"]),- (StorePath "ffffffffffffffffffffffffffffffff" "dep2.drv", ["out", "lib"])+ (StorePath "ffffffffffffffffffffffffffffffff" "dep2.drv", ["lib", "out"]) ], drvInputSrcs = [StorePath "gggggggggggggggggggggggggggggggg" "source.tar.gz"], drvPlatform = Aarch64_Darwin,@@ -2858,18 +2858,19 @@ then Pass else Fail ("output names: " <> T.pack (show names)) _ -> Fail ("expected VDerivation, got " <> T.pack (show val)),- -- builtinDerivation populates drvEnv with drvPath and output paths+ -- 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 dp <- Map.lookup "drvPath" (drvEnv drv),- "/nix/store/" `T.isPrefixOf` dp,- ".drv" `T.isSuffixOf` dp,- Just op <- Map.lookup "out" (drvEnv drv),- "/nix/store/" `T.isPrefixOf` op ->+ | Just op <- Map.lookup "out" (drvEnv drv),+ "/nix/store/" `T.isPrefixOf` op,+ Just nm <- Map.lookup "name" (drvEnv drv),+ nm == "test" -> Pass | otherwise -> Fail ("drvEnv keys: " <> T.pack (show (Map.toList (drvEnv drv))))