nova-cache-0.5.0.0: src/NovaCache/Validate.hs
-- | Pure protocol validation for narinfo and NAR content.
--
-- Validates narinfo field semantics (sizes, store paths, hash formats),
-- NAR/file content hashes against declared values, and Ed25519 signatures.
-- All functions are pure - no IO, no side effects.
module NovaCache.Validate
( ValidationError (..),
validateNarInfo,
validateNarHash,
validateFileHash,
validateSignature,
validateFull,
)
where
import Data.ByteString (ByteString)
import Data.Text (Text)
import qualified Data.Text as T
import NovaCache.Hash (formatNixHash, hashBytes, parseNixHash)
import NovaCache.NarInfo (NarInfo (..))
import NovaCache.Signing (PublicKey, verify)
import NovaCache.StorePath (defaultStoreDir, parseAbsoluteStorePath, parseStorePathBaseName)
-- ---------------------------------------------------------------------------
-- Types
-- ---------------------------------------------------------------------------
-- | Validation errors for narinfo and NAR content.
data ValidationError
= -- | FileSize field is negative.
NegativeFileSize !Integer
| -- | NarSize field is negative.
NegativeNarSize !Integer
| -- | StorePath field does not parse. (raw value, parse error)
InvalidStorePath !Text !String
| -- | FileHash field does not parse. (raw value, parse error)
InvalidFileHash !Text !String
| -- | NarHash field does not parse. (raw value, parse error)
InvalidNarHash !Text !String
| -- | A reference does not parse as a store path basename. (raw value, parse error)
InvalidReference !Text !String
| -- | Computed NAR hash differs from declared. (expected, actual)
NarHashMismatch !Text !Text
| -- | Computed file hash differs from declared. (expected, actual)
FileHashMismatch !Text !Text
| -- | A signature line failed verification.
SignatureInvalid !Text
| -- | The narinfo has zero signatures.
NoSignatures
| -- | StorePath is a derivation (.drv) - binary caches serve build outputs only.
DerivationStorePath !Text
deriving (Eq, Show)
-- ---------------------------------------------------------------------------
-- NarInfo field validation
-- ---------------------------------------------------------------------------
-- | Validate narinfo field semantics: sizes non-negative, store path parses,
-- hash fields parse, references parse. Collects all errors (not short-circuit).
-- Returns the 'NarInfo' unchanged on success for composition.
validateNarInfo :: NarInfo -> Either [ValidationError] NarInfo
validateNarInfo ni =
case concat [sizeErrors, drvErrors, storePathErrors, fileHashErrors, narHashErrors, refErrors] of
[] -> Right ni
errs -> Left errs
where
sizeErrors =
[NegativeFileSize declared | Just declared <- [niFileSize ni], declared < 0]
++ [NegativeNarSize (niNarSize ni) | niNarSize ni < 0]
drvErrors =
[DerivationStorePath (niStorePath ni) | ".drv" `T.isSuffixOf` niStorePath ni]
-- The wire format fixes each field's spelling: StorePath is absolute,
-- References and Deriver are basenames. Accepting the other spelling
-- would sign narinfos (bare StorePath, absolute references) that every
-- real Nix client rejects at parse time - and a bare StorePath also
-- derives an empty store dir in the signed fingerprint.
storePathErrors = case parseAbsoluteStorePath defaultStoreDir (niStorePath ni) of
Left err -> [InvalidStorePath (niStorePath ni) err]
Right _ -> []
fileHashErrors = case niFileHash ni of
Nothing -> []
Just declared -> case parseNixHash declared of
Left err -> [InvalidFileHash declared err]
Right _ -> []
narHashErrors = case parseNixHash (niNarHash ni) of
Left err -> [InvalidNarHash (niNarHash ni) err]
Right _ -> []
refErrors = concatMap checkRef (niReferences ni)
checkRef ref = case parseStorePathBaseName ref of
Left err -> [InvalidReference ref err]
Right _ -> []
-- ---------------------------------------------------------------------------
-- Content hash validation
-- ---------------------------------------------------------------------------
-- | Validate that the SHA-256 hash of raw uncompressed NAR bytes matches
-- the declared 'niNarHash'.
validateNarHash :: NarInfo -> ByteString -> Either ValidationError ()
validateNarHash ni narBytes =
-- Compare DECODED hash bytes, not re-formatted strings. Only the
-- canonical sha256:<nix-base32> spelling parses - deliberately strict,
-- since the fingerprint signs the NarHash TEXT verbatim; accepting other
-- encodings on the read side arrives with the foreign-cache substitution
-- feature that needs them.
case parseNixHash (niNarHash ni) of
Left err -> Left (InvalidNarHash (niNarHash ni) err)
Right declared
| declared == hashBytes narBytes -> Right ()
| otherwise -> Left (NarHashMismatch (niNarHash ni) (formatNixHash (hashBytes narBytes)))
-- | Validate that the SHA-256 hash of compressed file bytes matches
-- the declared 'niFileHash'. An absent FileHash declares nothing to
-- check (upstream treats the field as optional); integrity then rests on
-- the always-required NarHash.
validateFileHash :: NarInfo -> ByteString -> Either ValidationError ()
validateFileHash ni fileBytes = case niFileHash ni of
Nothing -> Right ()
Just declaredText -> case parseNixHash declaredText of
Left err -> Left (InvalidFileHash declaredText err)
Right declared
| declared == hashBytes fileBytes -> Right ()
| otherwise -> Left (FileHashMismatch declaredText (formatNixHash (hashBytes fileBytes)))
-- ---------------------------------------------------------------------------
-- Signature validation
-- ---------------------------------------------------------------------------
-- | Validate signatures against a trusted public key. At least one signature
-- must verify (matches Nix behaviour - any trusted key suffices).
-- Returns 'Left [NoSignatures]' when there are no signatures at all.
validateSignature :: PublicKey -> NarInfo -> Either [ValidationError] ()
validateSignature pk ni
| null sigs = Left [NoSignatures]
| any (verify pk ni) sigs = Right ()
| otherwise = Left (map SignatureInvalid sigs)
where
sigs = niSigs ni
-- ---------------------------------------------------------------------------
-- Full validation
-- ---------------------------------------------------------------------------
-- | Compose all validation stages: narinfo fields, NAR hash, file hash,
-- and signatures. Collects all errors from all stages.
validateFull ::
PublicKey ->
NarInfo ->
ByteString ->
ByteString ->
Either [ValidationError] ()
validateFull pk ni narBytes fileBytes =
case concat [narInfoErrors, narHashErrors, fileHashErrors, sigErrors] of
[] -> Right ()
errs -> Left errs
where
narInfoErrors = case validateNarInfo ni of
Left errs -> errs
Right _ -> []
narHashErrors = case validateNarHash ni narBytes of
Left err -> [err]
Right _ -> []
fileHashErrors = case validateFileHash ni fileBytes of
Left err -> [err]
Right _ -> []
sigErrors = case validateSignature pk ni of
Left errs -> errs
Right _ -> []