packages feed

nova-nix-0.9.0.0: src/Nix/Store/Exclusive.hs

{-# LANGUAGE CPP #-}

-- | Creating store entries that must not exist yet, and upstream's
-- words when one does.
--
-- A materializer that creates or truncates (@WriteMode@) trusts its own
-- bookkeeping to know that a name is free, and on a case-folding volume
-- that bookkeeping is an approximation of the volume's fold table: a
-- sibling it keeps apart but the volume folds lands on the earlier
-- file and replaces it.  An exclusive create asks the volume itself,
-- which is the only party that knows, and refuses instead.  Upstream's
-- restore opens every regular file this way (@O_CREAT | O_EXCL@ in
-- @RestoreSink::createRegularFile@, fs-sink.cc at 2.24.9, and
-- @CREATE_NEW@ in its Windows branch by 2.33.2).
--
-- @base@ has no exclusive open, so the platform's own call is made
-- through @unix@ or @Win32@ and the descriptor is handed to the I\/O
-- library as an ordinary 'Handle'.  The path is an 'OsPath', so a POSIX
-- name reaches @open@ as the bytes it was spelled from, and an error
-- names the path as base names one it opened.
module Nix.Store.Exclusive
  ( -- * Creation
    openNewBinaryFile,

    -- * Upstream's refusals
    Occupant (..),
    fileTakenMessage,
    directoryTakenMessage,
    symlinkTakenMessage,
  )
where

import Data.Text (Text)
import qualified Data.Text as T
import System.OsPath (OsPath, decodeFS)

#if defined(mingw32_HOST_OS)

import Control.Exception (bracketOnError)
import Data.Bits ((.|.))
import Data.List (isPrefixOf)
import GHC.IO.SubSystem (IoSubSystem (..), ioSubSystem)
import System.IO (Handle)
import System.IO.Error (ioeSetFileName, modifyIOError)
import System.Win32.File
  ( cREATE_NEW,
    closeHandle,
    createFile,
    fILE_ATTRIBUTE_NORMAL,
    fILE_FLAG_OVERLAPPED,
    fILE_SHARE_READ,
    fILE_SHARE_WRITE,
    gENERIC_READ,
    gENERIC_WRITE,
  )
import System.Win32.Info (getFullPathName)
import System.Win32.Types (hANDLEToHandle)

#else

import Control.Exception (bracketOnError)
import qualified Data.ByteString.Char8 as BS8
import GHC.IO.Handle.FD (fdToHandle')
import System.IO (Handle, IOMode (WriteMode))
import System.IO.Error (ioeSetFileName, modifyIOError)
import qualified System.OsPath as OP
import System.Posix.Files (stdFileMode)
import System.Posix.IO
  ( OpenFileFlags (cloexec, creat, exclusive),
    OpenMode (WriteOnly),
    closeFd,
    defaultFileFlags,
  )
import System.Posix.IO.ByteString (openFd)
import System.Posix.Types (Fd (..))

#endif

-- ---------------------------------------------------------------------------
-- Creation
-- ---------------------------------------------------------------------------

#if defined(mingw32_HOST_OS)

-- | Create a regular file and open it for binary writing, failing with
-- an 'System.IO.Error.isAlreadyExistsError' exception when any entry
-- already answers to the name, including one the volume's fold table
-- matches to it.  Access and sharing are upstream's restore on Windows
-- (2.33.2), and what @base@ requests for a write under its
-- descriptor-based I\/O manager; the overlapped flag is what the
-- native I\/O manager needs from a handle it adopts.
openNewBinaryFile :: OsPath -> IO Handle
openNewBinaryFile osPath = do
  -- A Windows 'OsPath' is UTF-16, so decoding it is exact.
  path <- decodeFS osPath
  modifyIOError (`ioeSetFileName` path) $ do
    target <- win32FilePath path
    bracketOnError
      ( createFile
          target
          (gENERIC_READ .|. gENERIC_WRITE)
          (fILE_SHARE_READ .|. fILE_SHARE_WRITE)
          Nothing
          cREATE_NEW
          attributes
          Nothing
      )
      closeHandle
      hANDLEToHandle
  where
    attributes = case ioSubSystem of
      IoPOSIX -> fILE_ATTRIBUTE_NORMAL
      IoNative -> fILE_ATTRIBUTE_NORMAL .|. fILE_FLAG_OVERLAPPED

-- | The Win32 file-namespace spelling of a path (@\\\\?\\@), which
-- CreateFileW takes past MAX_PATH.  @base@ converts every path it
-- opens to this form, under either I\/O manager
-- (@__hs_create_device_name@ in GHC's utils\/fs\/fs.c), so a raw
-- CreateFileW without it would refuse long paths @base@ accepts.  The
-- namespace form is taken literally, so separators, @.@ and @..@ are
-- resolved first by GetFullPathNameW, as that conversion does.
win32FilePath :: FilePath -> IO FilePath
win32FilePath path
  | any (`isPrefixOf` path) namespacePrefixes = pure path
  | otherwise = toNamespace <$> getFullPathName path
  where
    namespacePrefixes = [fileNamespace, "\\\\.\\", "\\??\\"]
    toNamespace full = case full of
      '\\' : '\\' : share -> fileNamespace <> "UNC\\" <> share
      _ -> fileNamespace <> full
    fileNamespace = "\\\\?\\"

#else

-- | Create a regular file and open it for binary writing, failing with
-- an 'System.IO.Error.isAlreadyExistsError' exception when any entry
-- already answers to the name, including one the volume's fold table
-- matches to it.  The mode is @base@'s for a new file (0666, narrowed
-- by the umask), and the handle is named by the path, as @base@ names
-- the handles it opens.  Close-on-exec keeps a concurrently spawned
-- builder from inheriting the descriptor, as upstream's restore does.
openNewBinaryFile :: OsPath -> IO Handle
openNewBinaryFile path = do
  shown <- decodeFS path
  modifyIOError (`ioeSetFileName` shown) $
    bracketOnError
      (openFd (rawPath path) WriteOnly newFileFlags)
      closeFd
      (\(Fd fd) -> fdToHandle' fd Nothing False shown WriteMode True)
  where
    -- A POSIX path unit is a byte, and 'OP.toChar' reads it as the
    -- character of that code, so the round trip through 'BS8' is exact.
    rawPath = BS8.pack . map OP.toChar . OP.unpack
    newFileFlags =
      defaultFileFlags
        { creat = Just stdFileMode,
          exclusive = True,
          cloexec = True
        }

#endif

-- ---------------------------------------------------------------------------
-- Upstream's refusals
-- ---------------------------------------------------------------------------

-- | What already answers to the name a directory was to take.
-- Upstream words the two apart: @create_directory@ reports an existing
-- directory by returning false and anything else by throwing.
data Occupant
  = OccupiedByDirectory
  | OccupiedByOther
  deriving (Eq, Show)

-- | Upstream's refusal of a regular file whose name is taken
-- (@RestoreSink::createRegularFile@, fs-sink.cc at 2.24.9).  The path
-- reaches its formatter as a @std::filesystem::path@, whose stream
-- insertion goes through @std::quoted@: hence the double quotes inside
-- the single ones.
fileTakenMessage :: FilePath -> Text
fileTakenMessage path = "creating file '" <> cxxQuoted path <> "': " <> existsReason

-- 2.24.9 printed the C++ library's filesystem_error text for the two
-- refusals below, which is implementation-defined and differs by
-- platform; 2.25 wrapped it in the messages 2.33.2 prints, which are
-- followed.

-- | Upstream's refusal of a directory whose name is taken: its own
-- error over an existing directory (@RestoreSink::createDirectory@,
-- fs-sink.cc at 2.24.9), and 2.33.2's over anything else.
directoryTakenMessage :: Occupant -> FilePath -> Text
directoryTakenMessage occupant path = case occupant of
  OccupiedByDirectory -> "path '" <> T.pack path <> "' already exists"
  OccupiedByOther -> "creating directory '" <> T.pack path <> "': " <> existsReason

-- | Upstream's refusal of a link whose name is taken, given the link's
-- path and its target (@RestoreSink::createSymlink@, fs-sink.cc at
-- 2.33.2).
symlinkTakenMessage :: FilePath -> FilePath -> Text
symlinkTakenMessage linkPath target =
  "creating symlink from '" <> T.pack linkPath <> "' -> '" <> T.pack target <> "': " <> existsReason

-- | @strerror(EEXIST)@, the reason the refusals here end with.
existsReason :: Text
existsReason = "File exists"

-- | @std::quoted@ over a string: double quotes around it, and a double
-- quote or backslash inside escaped with a backslash.
cxxQuoted :: FilePath -> Text
cxxQuoted path = "\"" <> T.concatMap escape (T.pack path) <> "\""
  where
    escape c
      | c == '"' || c == '\\' = T.pack ['\\', c]
      | otherwise = T.singleton c