packages feed

nova-nix-0.4.0.0: src/Nix/Builder/Unpack.hs

{-# LANGUAGE ScopedTypeVariables #-}

-- | __builtin:unpack__ — in-process archive extraction for the bootstrap seed.
--
-- == Why this is a builtin
--
-- The Windows stdenv bootstrap fetches pre-built toolchain tarballs (MSYS2
-- @.pkg.tar.zst@ packages) into the store before any toolchain exists there.
-- Nothing in the store can unpack them — the unpacker is the thing being
-- bootstrapped — and ambient @tar.exe@ is unpinned and may lack zstd support.
-- So, like 'Nix.Builder.runBuiltinFetchurl', extraction runs in-process:
-- a derivation whose @builder@ is @builtin:unpack@ is handled by this module
-- instead of being spawned as a subprocess.
--
-- == Derivation interface
--
-- * @srcs@ — whitespace-separated archive store paths, extracted in order
--   into the single @out@ output.  MSYS2 packages share a top-level
--   @mingw64\/@ prefix, so extracting a package set into one output yields a
--   working toolchain root directly (no separate union step).
-- * @out@ — the merged tree.
--
-- == Determinism
--
-- Extraction is a pure function of the archive bytes: entries are written in
-- archive order, a file appearing in two archives is an error (pacman
-- enforces the same no-conflict invariant), and pacman's per-package
-- metadata entries (@.PKGINFO@, @.MTREE@, …) are skipped — they are not part
-- of the installed tree and would otherwise collide across packages.
--
-- Symlink and hardlink entries are materialized by __copying__ their target:
-- symlinks on Windows require elevation or Developer Mode, so a link in the
-- store would make the output machine-dependent.  Copying is deterministic
-- everywhere at a small size cost.
module Nix.Builder.Unpack
  ( -- * Builder name
    builtinUnpackBuilder,

    -- * Derivation environment keys
    envSrcs,

    -- * Running
    runBuiltinUnpack,
  )
where

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.Monad (when)
import Data.Bits ((.&.))
import qualified Data.ByteString.Lazy as BL
import Data.Char (toLower)
import Data.List (isPrefixOf, isSuffixOf)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import Nix.Derivation (Derivation (..))
import System.Directory
  ( copyFile,
    createDirectoryIfMissing,
    doesDirectoryExist,
    doesFileExist,
    doesPathExist,
    getPermissions,
    listDirectory,
    setOwnerExecutable,
    setPermissions,
  )
import System.FilePath (isAbsolute, joinPath, splitDirectories, takeDirectory, (</>))
import qualified System.Info

-- ---------------------------------------------------------------------------
-- Named constants
-- ---------------------------------------------------------------------------

-- | The magic builder string for the built-in archive extractor.  A
-- derivation with this builder is not executed as a process — the Builder
-- extracts its @srcs@ archives into @$out@ (see 'runBuiltinUnpack').
builtinUnpackBuilder :: Text
builtinUnpackBuilder = "builtin:unpack"

-- | Derivation environment key holding the whitespace-separated archive
-- store paths to extract.  Store paths never contain whitespace (store-name
-- validation rejects it), so splitting on words is unambiguous.
envSrcs :: Text
envSrcs = "srcs"

-- | Name of the output the merged tree is extracted into.
unpackOutputName :: Text
unpackOutputName = "out"

-- | Owner-execute bit of a tar header's mode field (octal @0o100@).
ownerExecuteMode :: TarEntry.Permissions
ownerExecuteMode = 0o100

-- | Pax per-file extended header ('x') and global header ('g') type codes.
-- 'Tar.decodeLongNames' already applies pax path\/linkpath overrides; any
-- header entries still present carry only metadata we do not record (mtime,
-- xattrs), so they are skipped rather than rejected.
paxPerFileCode, paxGlobalCode :: Char
paxPerFileCode = 'x'
paxGlobalCode = 'g'

-- | True on a Windows host.  NTFS has no executable bit (PATHEXT decides),
-- so tar mode bits are only materialized on Unix.
isWindowsHost :: Bool
isWindowsHost = System.Info.os == "mingw32"

-- ---------------------------------------------------------------------------
-- Entry types after long-name decoding
-- ---------------------------------------------------------------------------

-- | Entries as produced by 'Tar.decodeLongNames': GNU\/pax long names are
-- resolved, so paths and link targets are plain 'FilePath's.
type DecodedEntries =
  Tar.GenEntries BL.ByteString FilePath FilePath DecodeError

-- | A single decoded entry.
type DecodedEntry = Tar.GenEntry BL.ByteString FilePath FilePath

-- | Format errors from 'Tar.read' or long-name decoding.
type DecodeError = Either Tar.FormatError Tar.DecodeLongNamesError

-- ---------------------------------------------------------------------------
-- Entry point
-- ---------------------------------------------------------------------------

-- | Run a @builtin:unpack@ derivation: extract every archive listed in its
-- @srcs@ env into the @out@ output directory.  Returns the same
-- @Either (exit, msg) ()@ shape as the process runner, so the shared
-- output-validation and registration path in "Nix.Builder" is reused
-- unchanged.
runBuiltinUnpack :: Derivation -> [(Text, FilePath)] -> IO (Either (Int, Text) ())
runBuiltinUnpack drv outputDirs =
  case (Map.lookup envSrcs (drvEnv drv), lookup unpackOutputName outputDirs) of
    (Nothing, _) -> failure "derivation has no 'srcs'"
    (Just srcs, _)
      | null (sourcePaths srcs) -> failure "'srcs' is empty"
    (_, Nothing) -> failure "derivation defines no 'out' output"
    (Just srcs, Just outDir) -> do
      createDirectoryIfMissing True outDir
      result <- unpackAll outDir (sourcePaths srcs)
      pure $ case result of
        Left msg -> Left (1, "builtin:unpack: " <> msg)
        Right () -> Right ()
  where
    failure msg = pure (Left (1, "builtin:unpack: " <> msg))
    sourcePaths = map T.unpack . T.words

-- | Extract archives in order, stopping at the first failure.
unpackAll :: FilePath -> [FilePath] -> IO (Either Text ())
unpackAll _ [] = pure (Right ())
unpackAll outDir (archive : rest) = do
  result <- unpackArchive outDir archive
  case result of
    Left err -> pure (Left err)
    Right () -> unpackAll outDir rest

-- | Unpack one archive into @outDir@.  The decompressor is chosen by file
-- extension; the tar stream is decoded (GNU + pax long names) and extracted
-- entry by entry.  Decompression errors surface lazily mid-stream, so the
-- whole extraction is exception-wrapped into a clean failure.
unpackArchive :: FilePath -> FilePath -> IO (Either Text ())
unpackArchive outDir archivePath = do
  attempt <- try run
  pure $ case attempt of
    Left (e :: SomeException) ->
      Left (T.pack archivePath <> ": " <> T.pack (show e))
    Right result -> result
  where
    run = case decoderFor archivePath of
      Nothing -> pure (Left ("unsupported archive format: " <> T.pack archivePath))
      Just decoder -> do
        raw <- BL.readFile archivePath
        extractEntries outDir (Tar.decodeLongNames (Tar.read (decoder raw)))

-- | Choose a decompressor from the archive file name.  @.tar.zst@ (MSYS2
-- packages) and plain @.tar@ are supported.  MSYS2's zstd frames do not
-- pledge a content size, so the lazy (streaming) zstd decoder is required —
-- the strict single-shot API rejects them.
decoderFor :: FilePath -> Maybe (BL.ByteString -> BL.ByteString)
decoderFor path
  | ".tar.zst" `isSuffixOf` lowered = Just Zstd.decompress
  | ".tar" `isSuffixOf` lowered = Just id
  | otherwise = Nothing
  where
    lowered = map toLower path

-- ---------------------------------------------------------------------------
-- Entry extraction
-- ---------------------------------------------------------------------------

-- | Walk the entry stream, extracting each entry under @outDir@.
extractEntries :: FilePath -> DecodedEntries -> IO (Either Text ())
extractEntries outDir = go
  where
    go stream = case stream of
      Tar.Done -> pure (Right ())
      Tar.Fail err -> pure (Left ("malformed archive: " <> T.pack (show err)))
      Tar.Next entry rest -> do
        result <- extractEntry outDir entry
        case result of
          Left err -> pure (Left err)
          Right () -> go rest

-- | Extract a single entry, after validating its path stays inside the
-- archive root and skipping pacman package metadata.
extractEntry :: FilePath -> DecodedEntry -> IO (Either Text ())
extractEntry outDir entry =
  case entryComponents (TarEntry.entryTarPath entry) of
    Left err -> pure (Left err)
    -- The archive root itself (an entry for "." or "./").
    Right [] -> pure (Right ())
    Right comps
      | isPackageMetadata comps -> pure (Right ())
      | otherwise -> extractContent outDir comps entry

-- | Extract a path-validated entry's content.  @comps@ is non-empty (the
-- empty case is consumed by 'extractEntry').
extractContent :: FilePath -> [FilePath] -> DecodedEntry -> IO (Either Text ())
extractContent outDir comps entry =
  case TarEntry.entryContent entry of
    Tar.Directory -> do
      createDirectoryIfMissing True dest
      pure (Right ())
    Tar.NormalFile bytes _size -> do
      fresh <- freshDestination dest
      case fresh of
        Left err -> pure (Left err)
        Right () -> do
          createDirectoryIfMissing True (takeDirectory dest)
          BL.writeFile dest bytes
          when (executableEntry entry && not isWindowsHost) (markExecutable dest)
          pure (Right ())
    -- A symlink target is relative to the link's own directory.
    Tar.SymbolicLink target ->
      copyLinkTarget "symlink" (resolveLinkTarget parentComps target)
    -- A hardlink target is relative to the archive root.
    Tar.HardLink target ->
      copyLinkTarget "hardlink" (entryComponents target)
    Tar.OtherEntryType code _ _
      | code == paxPerFileCode || code == paxGlobalCode -> pure (Right ())
      | otherwise ->
          pure (Left ("unsupported tar entry type '" <> T.singleton code <> "': " <> pathText))
    Tar.CharacterDevice _ _ -> unsupportedSpecial "character device"
    Tar.BlockDevice _ _ -> unsupportedSpecial "block device"
    Tar.NamedPipe -> unsupportedSpecial "named pipe"
  where
    dest = outDir </> joinPath comps
    parentComps = take (length comps - 1) comps
    pathText = T.pack (TarEntry.entryTarPath entry)
    unsupportedSpecial kind =
      pure (Left ("unsupported " <> kind <> " entry: " <> pathText))
    copyLinkTarget kind resolved = case resolved of
      Left err -> pure (Left err)
      Right targetComps -> do
        let targetPath = outDir </> joinPath targetComps
        isFile <- doesFileExist targetPath
        isDir <- doesDirectoryExist targetPath
        copyTarget kind targetPath isFile isDir
    copyTarget kind targetPath isFile isDir
      | isFile = do
          fresh <- freshDestination dest
          case fresh of
            Left err -> pure (Left err)
            Right () -> do
              createDirectoryIfMissing True (takeDirectory dest)
              copyFile targetPath dest
              pure (Right ())
      | isDir = do
          copyTree targetPath dest
          pure (Right ())
      | otherwise =
          pure
            ( Left
                ( kind
                    <> " target not present (links must follow their targets in the archive): "
                    <> pathText
                    <> " -> "
                    <> T.pack targetPath
                )
            )

-- ---------------------------------------------------------------------------
-- Path validation
-- ---------------------------------------------------------------------------

-- | Split a tar entry path into validated components: relative, no @..@, no
-- @:@ (drive letters, NTFS alternate data streams).  Tar paths use @/@;
-- 'splitDirectories' accepts both separators on Windows.  @.@ components are
-- dropped, so @./mingw64@ and @mingw64@ agree.
entryComponents :: FilePath -> Either Text [FilePath]
entryComponents raw
  | isAbsolute raw = Left ("absolute entry path: " <> T.pack raw)
  | ".." `elem` comps = Left ("entry path escapes archive root: " <> T.pack raw)
  | any (elem ':') comps = Left ("entry path contains ':': " <> T.pack raw)
  | otherwise = Right comps
  where
    comps = filter (/= ".") (splitDirectories raw)

-- | Resolve a symlink target (relative to the link's parent directory)
-- against the archive root.  @..@ components are allowed but must not climb
-- above the root.
resolveLinkTarget :: [FilePath] -> FilePath -> Either Text [FilePath]
resolveLinkTarget parentComps target
  | isAbsolute target = Left ("absolute symlink target: " <> T.pack target)
  | otherwise = walk (reverse parentComps) targetComps
  where
    targetComps = filter (/= ".") (splitDirectories target)
    walk stack remaining = case (stack, remaining) of
      (_, []) -> Right (reverse stack)
      ([], ".." : _) ->
        Left ("symlink target escapes archive root: " <> T.pack target)
      (_ : popped, ".." : rest) -> walk popped rest
      (_, comp : rest)
        | ':' `elem` comp -> Left ("symlink target contains ':': " <> T.pack target)
        | otherwise -> walk (comp : stack) rest

-- | pacman package metadata at the archive root (@.PKGINFO@, @.BUILDINFO@,
-- @.MTREE@, @.INSTALL@): describes the package to pacman, is not part of the
-- installed tree, and collides across packages when several archives merge
-- into one output — so root-level dotfile entries are skipped.
isPackageMetadata :: [FilePath] -> Bool
isPackageMetadata comps = case comps of
  [name] -> "." `isPrefixOf` name
  _ -> False

-- ---------------------------------------------------------------------------
-- Filesystem helpers
-- ---------------------------------------------------------------------------

-- | Guard that nothing was already extracted at @dest@.  Two archives in one
-- seed providing the same file is a packaging error worth failing loudly on
-- (pacman enforces the same no-conflict invariant between its packages).
freshDestination :: FilePath -> IO (Either Text ())
freshDestination dest = do
  exists <- doesPathExist dest
  pure $
    if exists
      then Left ("file collision between archives: " <> T.pack dest)
      else Right ()

-- | True when the entry's tar mode has the owner-execute bit.
executableEntry :: DecodedEntry -> Bool
executableEntry entry = TarEntry.entryPermissions entry .&. ownerExecuteMode /= 0

-- | Materialize the executable bit on Unix hosts.
markExecutable :: FilePath -> IO ()
markExecutable path = do
  perms <- getPermissions path
  setPermissions path (setOwnerExecutable True perms)

-- | Recursively copy a directory tree (used to materialize directory
-- symlinks, which cannot be store-portable links on Windows).
copyTree :: FilePath -> FilePath -> IO ()
copyTree src dest = do
  createDirectoryIfMissing True dest
  names <- listDirectory src
  mapM_ copyOne names
  where
    copyOne name = do
      let from = src </> name
          to = dest </> name
      isDir <- doesDirectoryExist from
      if isDir
        then copyTree from to
        else copyFile from to