nova-nix 0.1.7.1 → 0.1.8.0
raw patch · 8 files changed
+115/−45 lines, 8 filesdep ~crypton
Dependency ranges changed: crypton
Files
- CHANGELOG.md +10/−0
- README.md +2/−2
- app/Main.hs +5/−5
- nova-nix.cabal +2/−2
- src/Nix/Builder.hs +21/−13
- src/Nix/Store.hs +30/−19
- src/Nix/Store/Path.hs +10/−4
- test/Main.hs +35/−0
CHANGELOG.md view
@@ -1,5 +1,15 @@ # Changelog +## 0.1.8.0 — 2026-03-08++### Builder Correctness, Windows Store Paths, Hackage Fix++- **Fix #1: builder no longer pre-creates output paths** — `buildDerivationInner` no longer calls `createDirectoryIfMissing` for `$out`, `$dev`, etc. before running the builder. The builder is now responsible for creating its own outputs, matching real Nix semantics. Builds fail with `"builder succeeded but outputs missing"` if the builder exits successfully but expected outputs don't exist.+- **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 ### Hackage Build Fix
README.md view
@@ -260,7 +260,7 @@ **Key numbers:** - **24 modules** — all implemented-- **524 tests** — hand-rolled harness, no framework dependencies+- **526 tests** — hand-rolled harness, no framework dependencies - **Zero partial functions** — total by construction, `T.uncons` over `T.head`/`T.tail` - **Strict by default** — bang patterns on all data fields (except Thunk's Env, which is lazy for knot-tying) @@ -306,7 +306,7 @@ ```bash cabal build # Build library + CLI-cabal test # Run all 524 tests+cabal test # Run all 526 tests cabal build --ghc-options="-Werror" # Warnings as errors (CI default) cabal haddock # Generate API docs ```
app/Main.hs view
@@ -27,7 +27,7 @@ import Nix.Eval.IO (EvalState (..), newEvalState, runEvalIO) import Nix.Parser (parseNix, readFileAutoEncoding) import Nix.Store (Store, closeStore, openStore, writeDrv)-import Nix.Store.Path (StorePath, defaultStoreDir, parseStorePath, storePathToFilePath)+import Nix.Store.Path (StorePath, defaultStoreDir, parseStorePath, platformStoreDir, storePathToFilePath) import Paths_nova_nix (getDataDir) import System.Directory (getCurrentDirectory, getTemporaryDirectory) import System.Environment (getArgs)@@ -165,12 +165,12 @@ exitFailure Right val -> do (drv, drvSP) <- extractDerivation val- store <- openStore defaultStoreDir+ store <- openStore platformStoreDir buildResult <- buildAndRegister store drv drvSP closeStore store case buildResult of BuildSuccess sp ->- TIO.putStrLn (T.pack (storePathToFilePath defaultStoreDir sp))+ TIO.putStrLn (T.pack (storePathToFilePath platformStoreDir sp)) BuildFailure msg code -> do TIO.hPutStrLn stderr ("build failed (exit " <> T.pack (show code) <> "): " <> msg) exitFailure@@ -219,7 +219,7 @@ -- Build with dependency resolution tmpDir <- getTemporaryDirectory let config =- (defaultBuildConfig defaultStoreDir)+ (defaultBuildConfig platformStoreDir) { bcTmpDir = tmpDir } buildWithDeps config store drv drvSP@@ -272,7 +272,7 @@ prettyValue (VCompiledRegex _) = "«compiled-regex»" prettyValue (VDerivation drv) = case drvOutputs drv of- (out : _) -> "«derivation " <> T.pack (storePathToFilePath defaultStoreDir (doPath out)) <> "»"+ (out : _) -> "«derivation " <> T.pack (storePathToFilePath platformStoreDir (doPath out)) <> "»" [] -> "«derivation»" -- | Pretty-print a thunk. After deep-forcing, all thunks should be
nova-nix.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: nova-nix-version: 0.1.7.1+version: 0.1.8.0 synopsis: Windows-native Nix implementation in pure Haskell description: A pure Haskell implementation of the Nix package manager that runs natively@@ -63,7 +63,7 @@ , array >= 0.5 && < 0.6 , bytestring >= 0.11 && < 0.13 , containers >= 0.6 && < 0.8- , crypton >= 1.0 && < 2+ , crypton >= 1.0 && < 1.1 , directory >= 1.3 && < 1.4 , filepath >= 1.4 && < 1.6 , http-client >= 0.7 && < 0.8
src/Nix/Builder.hs view
@@ -43,7 +43,7 @@ where import Control.Exception (SomeException, try)-import Control.Monad (when)+import Control.Monad (filterM, when) import Data.Char (toLower) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map@@ -56,7 +56,7 @@ import Nix.Store (Store (..), addToStore, isValid, scanReferences) import Nix.Store.Path (StoreDir (..), StorePath (..), storePathToFilePath) import Nix.Substituter (CacheConfig, SubstResult (..), trySubstitute)-import System.Directory (createDirectoryIfMissing, doesDirectoryExist, removeDirectoryRecursive)+import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesPathExist, removeDirectoryRecursive) import qualified System.Environment import System.Exit (ExitCode (..)) import System.FilePath (takeDirectory, takeFileName, (</>))@@ -155,9 +155,9 @@ let buildDir = computeBuildDir config drv createDirectoryIfMissing True buildDir - -- 3. Create output directories inside build dir+ -- 3. Compute output paths (but do NOT pre-create them — the builder+ -- is responsible for creating $out, $dev, etc.) let outputDirs = [(doName out, buildDir </> T.unpack (doName out)) | out <- drvOutputs drv]- mapM_ (createDirectoryIfMissing True . snd) outputDirs -- 4. Set up environment let builderPath = T.unpack (drvBuilder drv)@@ -172,11 +172,19 @@ cleanupBuildDir buildDir pure (BuildFailure ("builder failed: " <> stderrText) exitCode) Right () -> do- -- 7. Success: register each output- registerResult <- registerOutputs config store drv buildDir outputDirs- -- Clean up build dir- cleanupBuildDir buildDir- pure registerResult+ -- 7. Success: validate that the builder created all expected outputs.+ -- Outputs may be files or directories — both are valid (real Nix+ -- allows $out to be a single file, a directory tree, or a symlink).+ missing <- filterM (fmap not . doesPathExist . snd) outputDirs+ case missing of+ [] -> do+ registerResult <- registerOutputs config store drv buildDir outputDirs+ cleanupBuildDir buildDir+ pure registerResult+ _ -> do+ cleanupBuildDir buildDir+ let names = T.intercalate ", " (map fst missing)+ pure (BuildFailure ("builder succeeded but outputs missing: " <> names) 1) -- --------------------------------------------------------------------------- -- Input validation@@ -398,15 +406,15 @@ registerSingleOutput config store candidates drvPathText (output, (_outName, outDir)) = do let targetSP = doPath output targetPath = storePathToFilePath (bcStoreDir config) targetSP- -- Check if output directory exists (builder should have created it)- exists <- doesDirectoryExist outDir+ -- Check if output exists (file or directory — builder creates it)+ exists <- doesPathExist outDir if not exists- then pure (Left ("output directory missing: " <> T.pack outDir))+ then pure (Left ("output missing: " <> T.pack outDir)) else do -- Scan for references in the output refs <- scanReferences (bcStoreDir config) candidates outDir -- Check if already in store (e.g. from a previous build)- alreadyExists <- doesDirectoryExist targetPath+ alreadyExists <- doesPathExist targetPath if alreadyExists then pure (Right ()) else do
src/Nix/Store.hs view
@@ -69,8 +69,10 @@ createDirectoryIfMissing, doesDirectoryExist, doesFileExist,+ doesPathExist, listDirectory, removeDirectoryRecursive,+ removeFile, renamePath, setPermissions, )@@ -99,18 +101,18 @@ isValid :: Store -> StorePath -> IO Bool isValid = isValidPath . stDB --- | Check if a store path directory exists on disk (regardless of DB).+-- | Check if a store path exists on disk (file or directory, regardless of DB). pathExists :: Store -> StorePath -> IO Bool-pathExists store sp = doesDirectoryExist (storePathToFilePath (stDir store) sp)+pathExists store sp = doesPathExist (storePathToFilePath (stDir store) sp) -- --------------------------------------------------------------------------- -- Store operations -- --------------------------------------------------------------------------- --- | Move an output directory to the store path, set read-only, and register.+-- | Move a build output (file or directory) to the store path, set read-only,+-- and register. ----- If @renameDirectory@ fails (cross-device move), falls back to--- recursive copy + remove.+-- If @renamePath@ fails (cross-device move), falls back to copy + remove. addToStore :: Store -> FilePath ->@@ -118,10 +120,10 @@ Maybe Text -> [StorePath] -> IO ()-addToStore store srcDir sp deriver refs = do+addToStore store srcPath sp deriver refs = do let destPath = storePathToFilePath (stDir store) sp -- Move (or copy) source to store- moveDirectory srcDir destPath+ moveOutput srcPath destPath -- Set read-only permissions setReadOnly destPath -- Register in database@@ -135,13 +137,19 @@ prReferences = refs } --- | Cross-device safe directory move.+-- | Cross-device safe move for files or directories. -- Tries 'renamePath' first; on IOException falls back to copy + remove.-moveDirectory :: FilePath -> FilePath -> IO ()-moveDirectory src dest =+moveOutput :: FilePath -> FilePath -> IO ()+moveOutput src dest = renamePath src dest `catch` \(_ :: IOException) -> do- copyDirectoryRecursive src dest- removeDirectoryRecursive src+ isDir <- doesDirectoryExist src+ if isDir+ then do+ copyDirectoryRecursive src dest+ removeDirectoryRecursive src+ else do+ copyFile src dest+ removeFile src -- | Recursively copy a directory tree. copyDirectoryRecursive :: FilePath -> FilePath -> IO ()@@ -176,15 +184,18 @@ pure (scanBytes prefixBytes prefixLen hashLen contents acc) pure [sp | (h, sp) <- Set.toList candidateSet, Set.member h foundHashes] --- | Collect all regular files under a directory, recursively.+-- | Collect all regular files under a path, recursively.+-- If the path is itself a regular file, returns it directly. collectRegularFiles :: FilePath -> IO [FilePath]-collectRegularFiles dir = do- exists <- doesDirectoryExist dir- if not exists- then pure []+collectRegularFiles path = do+ isDir <- doesDirectoryExist path+ if isDir+ then do+ entries <- listDirectory path+ concat <$> mapM (classifyAndCollect path) entries else do- entries <- listDirectory dir- concat <$> mapM (classifyAndCollect dir) entries+ isFile <- doesFileExist path+ pure [path | isFile] where classifyAndCollect parent name = do let fullPath = parent </> name
src/Nix/Store/Path.hs view
@@ -33,6 +33,7 @@ StoreDir (..), defaultStoreDir, defaultStoreDirText,+ platformStoreDir, platformStoreDirText, windowsStoreDir, @@ -64,15 +65,20 @@ defaultStoreDirText :: Text defaultStoreDirText = T.pack (unStoreDir defaultStoreDir) +-- | Platform-appropriate store directory.+-- Returns @C:\\nix\\store@ on Windows, @\/nix\/store@ on Unix.+-- Use this for filesystem operations and user-facing output.+-- Use 'defaultStoreDir' only for Nix-internal canonical paths (ATerm hashing).+platformStoreDir :: StoreDir+platformStoreDir = case System.Info.os of+ "mingw32" -> windowsStoreDir+ _ -> defaultStoreDir+ -- | Platform-appropriate store directory as 'Text'. -- Returns @C:\\nix\\store@ on Windows, @\/nix\/store@ on Unix. -- Used for user-facing values like @builtins.storeDir@. platformStoreDirText :: Text platformStoreDirText = T.pack (unStoreDir platformStoreDir)- where- platformStoreDir = case System.Info.os of- "mingw32" -> windowsStoreDir- _ -> defaultStoreDir -- | Default store directory on Windows: @C:\\nix\\store@. windowsStoreDir :: StoreDir
test/Main.hs view
@@ -3037,6 +3037,41 @@ forceRemoveIfExists tmpStore forceRemoveIfExists (bcTmpDir config) pure ret,+ -- Builder succeeds but doesn't create $out -> build fails+ runTestM "missing output fails build" $ do+ tmpBase <- getTemporaryDirectory+ let tmpStore = tmpBase </> "nova-nix-test-builder-noout"+ forceRemoveIfExists tmpStore+ store <- openStore (StoreDir tmpStore)+ let outSP = StorePath "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii" "nooutput"+ drv = mkTestBuildDrv shell outSP "echo 'forgot to create output'"+ config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder-noout-tmp"}+ result <- buildDerivation config store drv+ closeStore store+ forceRemoveIfExists tmpStore+ forceRemoveIfExists (bcTmpDir config)+ pure $ case result of+ BuildFailure msg _ ->+ if T.isInfixOf "outputs missing" msg+ then Pass+ else Fail ("expected 'outputs missing' error, got: " <> msg)+ BuildSuccess _ -> Fail "expected failure when builder doesn't create $out",+ -- File output (not directory) should succeed+ runTestM "file output succeeds" $ do+ tmpBase <- getTemporaryDirectory+ let tmpStore = tmpBase </> "nova-nix-test-builder-fileout"+ forceRemoveIfExists tmpStore+ store <- openStore (StoreDir tmpStore)+ let outSP = StorePath "jjjjjjjjjjjjjjjjjjjjjjjjjjjjjj" "fileout"+ drv = mkTestBuildDrv shell outSP "echo 'I am a file output' > $out"+ config = (defaultBuildConfig (stDir store)) {bcTmpDir = tmpBase </> "nova-nix-test-builder-fileout-tmp"}+ result <- buildDerivation config store drv+ closeStore store+ forceRemoveIfExists tmpStore+ forceRemoveIfExists (bcTmpDir config)+ pure $ case result of+ BuildSuccess sp -> assertEqual "file output path" outSP sp+ BuildFailure msg code -> Fail ("file output build failed (" <> T.pack (show code) <> "): " <> msg), -- Cleanup after failure runTestM "cleanup after failure" $ do tmpBase <- getTemporaryDirectory