packages feed

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

-- | Which NAR entry names and symlink targets a host's filesystem can
-- hold, and the host path that spells one.
--
-- A NAR carries names and link targets as bytes.  Upstream's restore on
-- POSIX appends each name to the destination path as it finds it and
-- hands each target to @symlink@ unchanged (@RestoreSink@ in fs-sink.cc
-- at 2.24.9), refusing only what the archive grammar refuses: an empty
-- name, @.@, @..@, a @/@ or a NUL (@parse@ in archive.cc).  Those
-- refusals hold on every host, through the parser's own check.  Every
-- other limit belongs to a filesystem, so it is decided per host
-- ('NameRules'): a name the host cannot hold is refused before anything
-- is written, and a store path holding one does not materialize there,
-- whichever way it arrives.
--
-- The decisions are pure and take the rules as an argument, so every
-- host's answer is testable on any host.  Only 'entryNamePath' and
-- 'linkTargetPath' consult the running host.
module Nix.Store.EntryName
  ( -- * Hosts
    NameRules (..),
    hostNameRules,

    -- * Decisions
    checkEntryName,
    checkLinkTarget,

    -- * Host paths
    entryNamePath,
    linkTargetPath,

    -- * Display
    shownBytes,
  )
where

import Data.Bifunctor (first)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import Data.Char (ord)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Word (Word8)
import Nix.HostPath (hostPathFromBytes)
import qualified NovaCache.NAR.Stream as Stream
import NovaCache.SafeName (hasTrailingDotOrSpace, isReservedDeviceName)
import qualified System.Info
import System.OsPath (OsPath)

-- ---------------------------------------------------------------------------
-- Hosts
-- ---------------------------------------------------------------------------

-- | The names a family of filesystems can hold.
data NameRules
  = -- | Every name the grammar admits and every target, as bytes: Linux
    -- and the other POSIX hosts, where upstream restores names as it
    -- finds them.
    PosixNames
  | -- | A name must be UTF-8 holding no noncharacter.  APFS refuses any
    -- other name at the create (@EILSEQ@, and the same for a code point
    -- outside its Unicode tables, which is left to it since those tables
    -- move with the system release); HFS+ stores one with each byte
    -- outside UTF-8, and U+FFFE and U+FFFF, rewritten into a @%XX@
    -- escape, so the tree no longer names what its NAR does.  Both keep
    -- a link target's bytes as given.
    DarwinNames
  | -- | A name must have a UTF-16 form, avoid every character Win32
    -- reserves (@\<@, @>@, @:@, @\"@, @\\@, @|@, @?@, @*@ and the
    -- controls), not name a device, and not end in a dot or a space,
    -- which Win32 strips.  A colon or a backslash would not even fail:
    -- one opens an alternate data stream or a drive-relative path, the
    -- other a separator, so the bytes land somewhere other than the
    -- named entry.  A link target must have a UTF-16 form.
    WindowsNames
  deriving (Eq, Show)

-- | The rules of the host this process runs on.  Decided per operating
-- system, not per volume: a Darwin store on APFS and one on HFS+ both
-- need a name to be UTF-8.  A volume the rules do not describe (FAT
-- mounted on Linux) answers at its own create, and a tree it respells
-- fails the on-disk check that precedes registration.
hostNameRules :: NameRules
hostNameRules = case System.Info.os of
  "mingw32" -> WindowsNames
  "darwin" -> DarwinNames
  _ -> PosixNames

-- ---------------------------------------------------------------------------
-- Decisions
-- ---------------------------------------------------------------------------

-- | Refuse a directory entry name the grammar refuses, or one a host
-- under the given rules cannot hold.
checkEntryName :: NameRules -> ByteString -> Either Text ()
checkEntryName rules name = do
  first T.pack (Stream.checkEntryName Nothing name)
  case rules of
    PosixNames -> Right ()
    DarwinNames -> darwinName
    WindowsNames -> windowsName
  where
    darwinName = case TE.decodeUtf8' name of
      Left _ -> refuse "is not valid UTF-8, which a macOS filesystem cannot hold"
      Right text
        | T.any isNoncharacter text ->
            refuse "holds a Unicode noncharacter, which a macOS filesystem cannot hold"
        | otherwise -> Right ()
    windowsName = case TE.decodeUtf8' name of
      Left _ -> Left (noUtf16Form entryNameNoun name)
      Right _
        | BS.any isWin32Reserved name -> refuse "holds a character Windows does not allow in a name"
        | isReservedDeviceName name -> refuse "is a Windows device name"
        | hasTrailingDotOrSpace name -> refuse "ends in a dot or a space, which Windows strips"
        | otherwise -> Right ()
    refuse reason = Left (entryNameNoun <> " " <> shownBytes name <> " " <> reason)

-- | Refuse a symlink target a host under the given rules cannot hold.
-- POSIX and Darwin hold any bytes; a Windows link target is UTF-16.
checkLinkTarget :: NameRules -> ByteString -> Either Text ()
checkLinkTarget rules target = case (rules, TE.decodeUtf8' target) of
  (WindowsNames, Left _) -> Left (noUtf16Form linkTargetNoun target)
  _ -> Right ()

-- | How a refusal names what it refuses: a directory entry name.
entryNameNoun :: Text
entryNameNoun = "NAR directory entry name"

-- | How a refusal names what it refuses: a symlink target.
linkTargetNoun :: Text
linkTargetNoun = "NAR symlink target"

-- | The refusal of bytes with no UTF-8 reading where names are UTF-16.
noUtf16Form :: Text -> ByteString -> Text
noUtf16Form noun bytes =
  noun <> " " <> shownBytes bytes <> " is not valid UTF-8, so it has no UTF-16 form for Windows"

-- | Bytes as a refusal shows them: quoted text where they read as UTF-8,
-- an escaped literal where they do not, so the message neither drops
-- nor invents a byte.
shownBytes :: ByteString -> Text
shownBytes bytes = case TE.decodeUtf8' bytes of
  Right text -> "'" <> text <> "'"
  Left _ -> T.pack (show bytes)

-- | A Unicode noncharacter: U+FDD0 to U+FDEF, and the last two code
-- points of every plane.  The set is closed by the standard, unlike the
-- unassigned code points APFS also refuses.
isNoncharacter :: Char -> Bool
isNoncharacter c =
  (code >= 0xFDD0 && code <= 0xFDEF) || code `mod` planeSize >= planeSize - 2
  where
    code = ord c
    planeSize = 0x10000

-- | A byte Win32 refuses in a name: the reserved punctuation and the
-- controls.  Each is ASCII, and no byte of a multi-byte UTF-8 sequence
-- is, so the byte test is exact on the UTF-8 name.
isWin32Reserved :: Word8 -> Bool
isWin32Reserved byte = byte < 0x20 || BS.elem byte "<>:\"\\|?*"

-- ---------------------------------------------------------------------------
-- Host paths
-- ---------------------------------------------------------------------------

-- | The host path component spelling an entry name, or the running
-- host's refusal of the name ('checkEntryName' under 'hostNameRules').
entryNamePath :: ByteString -> IO (Either Text OsPath)
entryNamePath name = case checkEntryName hostNameRules name of
  Left refusal -> pure (Left refusal)
  Right () -> spell entryNameNoun name

-- | The host path a symlink is created pointing at, or the running
-- host's refusal of the target ('checkLinkTarget' under
-- 'hostNameRules').
linkTargetPath :: ByteString -> IO (Either Text OsPath)
linkTargetPath target = case checkLinkTarget hostNameRules target of
  Left refusal -> pure (Left refusal)
  Right () -> spell linkTargetNoun target

-- | Spell bytes the host accepted as a host path ('hostPathFromBytes').
-- Only Windows refuses any, and its checks admit only UTF-8, so this
-- refusal is theirs, repeated.
spell :: Text -> ByteString -> IO (Either Text OsPath)
spell noun bytes = pure (maybe (Left (noUtf16Form noun bytes)) Right (hostPathFromBytes bytes))