diff --git a/src/Nix/Eval.hs b/src/Nix/Eval.hs index 3767e3a..a9cb8eb 100644 --- a/src/Nix/Eval.hs +++ b/src/Nix/Eval.hs @@ -98,7 +98,7 @@ import Nix.Eval.CBytecode (cbcArg1, cbcArg2, cbcArg3, cbcCountedPayload, cbcData import Nix.Eval.CEnv (cenvPushWith) import Nix.Eval.CList (CList (..), clistGet) import Nix.Eval.CThunk (CThunkPtr) -import Nix.Eval.CanonPath (canonBaseName, canonDirName, canonPath) +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) @@ -360,7 +360,7 @@ evalAddWithCoercion left right = case (left, right) of -- 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 (canonPath (a <> b))) + (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 @@ -371,7 +371,7 @@ evalAddWithCoercion left right = case (left, right) of if rightCtx == emptyContext then do appended <- decodedText "path concatenation" rightStr - pure (VPath (canonPath (a <> appended))) + 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 @@ -3215,7 +3215,7 @@ builtinToPath (VStr rawBytes _) = do case T.uncons s of Nothing -> throwEvalError "builtins.toPath: empty path" -- Canonicalized like every other path production site, as upstream. - Just ('/', _) -> pure (VPath (canonPath s)) + 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) @@ -3309,14 +3309,14 @@ findFirst [] name = 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 = canonPath (if T.null suffix then path else path <> "/" <> suffix) + 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 = canonPath (path <> "/" <> name) + let candidate = canonPathValue (path <> "/" <> name) in do exists <- doesPathExist candidate if exists @@ -3494,7 +3494,7 @@ builtinFetchGit (VStr rawUrl _) = do (code, _, errOut) <- runProcess "git" (gitTransportConfig ++ ["clone", "--depth", "1", "--", allowedUrl, cloneDir]) "" case code of - 0 -> pure (VPath cloneDir) + 0 -> pure (VPath (canonPathValue cloneDir)) _ -> do removeScratchDir cloneDir throwEvalError ("builtins.fetchGit: git clone failed: " <> errOut) @@ -4849,7 +4849,7 @@ builtinPath (VAttrs attrs) = 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 (extractBaseName pathStr) nameOverride + let name = fromMaybe (canonBaseName pathStr) nameOverride pinSubject = "builtins.path: " <> pathStr storePathText <- case attrSetLookup "filter" attrs of Nothing -> copyPathToStore pathStr name (fmap (pinSubject,) expectedDigest) @@ -4919,16 +4919,6 @@ filteredSourceNar filterFn rootPath = do VBool b -> pure b other -> throwEvalError ("builtins.path: the filter function must return a Boolean, got " <> typeName other) --- | Extract the last path component from a path string. -extractBaseName :: Text -> Text -extractBaseName path = - let stripped = T.dropWhileEnd (\c -> c == '/' || c == '\\') path - in case T.breakOnEnd "/" stripped of - ("", _) -> case T.breakOnEnd "\\" stripped of - ("", _) -> stripped - (_, name) -> name - (_, name) -> name - -- --------------------------------------------------------------------------- -- Builtin implementations - filterSource -- --------------------------------------------------------------------------- @@ -4948,7 +4938,7 @@ builtinFilterSource _ other = filterSourceInto :: (MonadEval m) => NixValue -> Text -> m NixValue filterSourceInto filterFn path = do narBytes <- filteredSourceNar filterFn path - storePathText <- addSourceNar (extractBaseName path) narBytes + storePathText <- addSourceNar (canonBaseName path) narBytes pure (sourceResultString storePathText) -- --------------------------------------------------------------------------- diff --git a/src/Nix/Eval/CanonPath.hs b/src/Nix/Eval/CanonPath.hs index 17d14d1..4bba13c 100644 --- a/src/Nix/Eval/CanonPath.hs +++ b/src/Nix/Eval/CanonPath.hs @@ -20,6 +20,7 @@ -- and a fully-collapsed relative path is @.@. module Nix.Eval.CanonPath ( canonPath, + canonPathValue, canonBaseName, canonDirName, ) @@ -31,6 +32,28 @@ import Data.Text (Text) import qualified Data.Text as T import System.FilePath (isPathSeparator, pathSeparator) +-- | The producer gate for path VALUES: a path value's text is its +-- absolute path spelled with forward slashes; on Windows a drive +-- designator precedes the root. Platform spelling exists only at the +-- filesystem boundary. (The same split git's object model makes: tree +-- identity is slash-canonical, the working-tree boundary converts.) +-- +-- Folding is by 'isPathSeparator', so it is platform-correct with no +-- conditional: on POSIX a backslash is an ordinary file-name character +-- and passes through untouched - upstream's semantics - while on +-- Windows it is a separator and folds to @/@. Every site that +-- produces a 'VPath' from platform-tainted text (literal resolution, +-- base-dir joins, fetcher scratch dirs, search-path entries) goes +-- through this gate; store-path text is canonical by construction. +canonPathValue :: Text -> Text +canonPathValue t = canonPath (if T.any needsFold t then T.map foldSeparator t else t) + where + -- Copy only when a non-'/' separator is present: on POSIX + -- 'isPathSeparator' is '/' alone, so this is never, and the + -- common already-canonical path shares its text on Windows too. + needsFold c = isPathSeparator c && c /= '/' + foldSeparator c = if needsFold c then '/' else c + -- | Canonicalize a path's text form. See the module comment for the -- algorithm and the separator-preservation guarantee. canonPath :: Text -> Text diff --git a/src/Nix/Eval/IO.hs b/src/Nix/Eval/IO.hs index 692b8cf..1b45462 100644 --- a/src/Nix/Eval/IO.hs +++ b/src/Nix/Eval/IO.hs @@ -49,7 +49,7 @@ import Nix.Derivation (fromATerm) import Nix.Eval (eval) import Nix.Eval.CList (CList (..)) import Nix.Eval.CThunk (CThunkPtr, cthunkGetAttrs, cthunkGetBcIdx, cthunkGetBool, cthunkGetCtxStr, cthunkGetFloat, cthunkGetInt, cthunkGetLambda, cthunkGetList, cthunkGetPath, cthunkGetStr, cthunkMarkBlackhole, cthunkMarkPending, cthunkPayload, cthunkSetComputed, cthunkSetComputedAttrs, cthunkSetComputedBool, cthunkSetComputedCtxStr, cthunkSetComputedFloat, cthunkSetComputedInt, cthunkSetComputedLambda, cthunkSetComputedList, cthunkSetComputedNull, cthunkSetComputedPath, cthunkSetComputedStr, cthunkState, cthunkValueTag) -import Nix.Eval.CanonPath (canonBaseName, canonPath) +import Nix.Eval.CanonPath (canonBaseName, canonPath, canonPathValue) import Nix.Eval.Symbol (Symbol (..), symbolBytes, symbolIntern, symbolInternBytes, symbolText) import Nix.Eval.Types (AttrSet (..), Env (..), MonadEval (..), NixValue (..), Thunk (..), attrSetSize, emptyContext, marshalLambda, marshalStringContext, storePathOrThrow, unmarshalLambdaValue, unmarshalStringContext, pattern ValueAttrs, pattern ValueBool, pattern ValueCtxStr, pattern ValueFloat, pattern ValueInt, pattern ValueLambda, pattern ValueList, pattern ValueNull, pattern ValuePath, pattern ValueStr) import Nix.Expr.Types (AttrKey (..), Binding (..), Expr (..), Formal (..), Formals (..), NixAtom (..), StringPart (..)) @@ -425,18 +425,17 @@ instance MonadEval EvalIO where baseDir <- EvalIO (asks esBaseDir) -- ~/x resolves against the home directory (upstream lexes HPATH and -- expands it at eval); everything else relative joins the base dir. - -- Both end in lexical canonicalization: no dot segment or repeated - -- separator survives into the path value. Native separators are - -- PRESERVED (CanonPath's style rule), so a native base dir yields a - -- native-spelled value - path-value consumers split separator-aware - -- ('canonBaseName' \/ 'canonDirName'), never on '/' alone. + -- Both end at the producer gate ('canonPathValue'): the value is + -- absolute, lexically canonical, and slash-spelled regardless of + -- the base dir's native spelling - platform separators exist only + -- at the filesystem boundary. expanded <- case T.stripPrefix "~/" path of Just below -> do home <- wrapIO Dir.getHomeDirectory pure (home T.unpack below) Nothing -> pure (T.unpack path) let absolute = if isRelative expanded then baseDir expanded else expanded - pure (canonPath (T.pack absolute)) + pure (canonPathValue (T.pack absolute)) forceThunk evalFn (Thunk ptr) = do -- Force protocol: PENDING to BLACKHOLE to COMPUTED with memoization. diff --git a/src/Nix/Eval/Types.hs b/src/Nix/Eval/Types.hs index 6c82c64..21e182f 100644 --- a/src/Nix/Eval/Types.hs +++ b/src/Nix/Eval/Types.hs @@ -134,7 +134,7 @@ import Nix.Eval.CEnv (NnEnv, cenvAllocSlots, cenvAllocWithScopes, cenvEmpty, cen import Nix.Eval.CLambda (clambdaAllowExtra, clambdaBody, clambdaEntryDefault, clambdaEntryHasDefault, clambdaEntryName, clambdaEnv, clambdaFormalCount, clambdaFormalsType, clambdaNameSym, clambdaNew, clambdaSetEntry) import Nix.Eval.CList (CList (..), clistFromThunks, clistLen, clistThunks, emptyCList) import Nix.Eval.CThunk (CThunkPtr, cthunkGetAttrs, cthunkGetBcIdx, cthunkGetBool, cthunkGetCtxStr, cthunkGetFloat, cthunkGetInt, cthunkGetList, cthunkGetPath, cthunkGetStr, cthunkNewBc, cthunkNewComputed, cthunkNewComputedAttrs, cthunkNewComputedBool, cthunkNewComputedCtxStr, cthunkNewComputedFloat, cthunkNewComputedInt, cthunkNewComputedLambda, cthunkNewComputedList, cthunkNewComputedNull, cthunkNewComputedPath, cthunkNewComputedStr, cthunkPayload, cthunkState, cthunkValueTag) -import Nix.Eval.CanonPath (canonPath) +import Nix.Eval.CanonPath (canonPathValue) import Nix.Eval.Compile (compileExpr, compileFormalsToEval) import Nix.Eval.EvalFormals (EvalFormal (..), EvalFormals (..)) import Nix.Eval.Symbol (Symbol (..), symbolBytes, symbolIntern, symbolInternBytes, symbolText) @@ -1318,7 +1318,7 @@ instance MonadEval PureEval where -- 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 . canonPath + resolvePathLiteral = pure . canonPathValue forceThunk evalFn (Thunk ptr) = -- Read the C thunk via unsafePerformIO - safe because reads are -- idempotent and PureEval never writes back (no memoization). diff --git a/src/Nix/Store/DB.hs b/src/Nix/Store/DB.hs index e026264..d533b8b 100644 --- a/src/Nix/Store/DB.hs +++ b/src/Nix/Store/DB.hs @@ -195,6 +195,12 @@ registerPaths db regs = withTransaction (sdbConn db) $ do -- | Insert (or refresh) a single ValidPaths row. insertPathRow :: StoreDB -> PathRegistration -> IO () insertPathRow db reg = do + -- DB rows key store paths in PLATFORM spelling (storePathToFilePath): + -- the database is host-local state describing this host's store tree, + -- every writer and reader in this module uses the same spelling, and + -- the store dir itself is host configuration. Identity artifacts + -- (drv ATerm, narinfo, eval-visible store-path strings) spell + -- canonically; the DB is deliberately not one of them. let pathText = T.pack (storePathToFilePath (sdbDir db) (prPath reg)) execute (sdbConn db) @@ -255,6 +261,13 @@ isValidPath db sp = do -- | Query the references of a registered store path. -- Returns the full path strings of referenced store paths. +-- +-- Reads return the stored text without re-parsing: every row was +-- written from a validated 'StorePath' inside this module's +-- transactions, so this is trust-on-read of host-local state the +-- module itself wrote (the delete path documents the same stance for +-- its raw-basename key). Callers that need a 'StorePath' back parse +-- at their own boundary. queryReferences :: StoreDB -> StorePath -> IO [Text] queryReferences db sp = do let pathText = T.pack (storePathToFilePath (sdbDir db) sp) diff --git a/test/Main.hs b/test/Main.hs index 21b1744..3363bec 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -36,7 +36,7 @@ import Nix.Eval.Arena (arenaDestroy, arenaInit) import Nix.Eval.CAttrSet (cattrsetFreeze, cattrsetInsert, cattrsetKeys, cattrsetLookup, cattrsetNew, cattrsetSize, cattrsetUnion) import Nix.Eval.CBytecode (binaryAdd, captureSlots, captureWithScopes, cbcArg1, cbcArg2, cbcArg3, cbcData, cbcFlags, cbcOpCount, cbcOpcode, cbcShortArg, formalName, formalNamedSet, formalSet, strpartInterp, strpartLit, unaryNegate, pattern OpApp, pattern OpAssert, pattern OpAttrs, pattern OpBinary, pattern OpHasAttr, pattern OpIf, pattern OpIndStr, pattern OpLambda, pattern OpLet, pattern OpList, pattern OpLitBool, pattern OpLitFloat, pattern OpLitInt, pattern OpLitNull, pattern OpLitPath, pattern OpLitUri, pattern OpResolvedVar, pattern OpSelect, pattern OpStr, pattern OpUnary, pattern OpVar, pattern OpWith, pattern OpWithVar) import Nix.Eval.CThunk (CThunkPtr, cthunkCount, cthunkGet, cthunkGetBcIdx, cthunkMarkBlackhole, cthunkNewBc, cthunkNewComputed, cthunkPayload, cthunkSetComputed, cthunkState) -import Nix.Eval.CanonPath (canonPath) +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) @@ -1501,6 +1501,24 @@ testBatch1 = do | 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" @@ -2349,7 +2367,7 @@ testBatchCIO = do <> nixQuotedPath nixpkgsDir <> "; } ] \"nixpkgs\"" ) - (VPath (T.pack nixpkgsDir)), + (VPath (canonPathValue (T.pack nixpkgsDir))), runTestIOFail "findFile no match" testDir @@ -7298,7 +7316,7 @@ testClassIFollowupsIO = do runTestM "tilde path literal expands against the home directory" $ do home <- Dir.getHomeDirectory result <- evalNixIO testDir "builtins.toString ~/nova-tilde-probe" - let expected = canonPath (T.pack (home "nova-tilde-probe")) + let expected = canonPathValue (T.pack (home "nova-tilde-probe")) pure $ assertRight "tilde" result $ \val -> assertEqual "tilde-expanded" (mkStr expected) val, -- a search-path match is returned CANONICALIZED