{- | The advisory lookup capability: answer CVE questions about a package
version from a local, already-synced @osv.db@ artifact, never the network.

The handle is deliberately dumb data access over one artifact file. Rule
semantics live in pure predicates over what it returns
('insideAffectedRange'), because SQLite's text collation cannot order
versions; only 'Ecluse.Core.Version.compareVersions' can. The one deliberate
exception is 'cveRemediationProbe': a fixed bound in the artifact is a single
canonical version string, so exact-fix matching is plain string equality and
rides the @(package_name, fixed_version)@ index in one traversal. A fix
published under a non-canonical version string misses the probe and simply
waits out the ordinary quarantine; the operator workaround is an explicit
'Ecluse.Core.Rules.Types.AllowByIdentity' rule.

An artifact is accepted or rejected at 'openCveDb' (epoch stamp, table shape,
ecosystem), with rejection as a value: the caller keeps its last known-good
handle and alarms. See "Ecluse.Core.Cve.Internal" for the hardening detail.

__Ownership is split at the type level__: 'openCveDb' yields a 'CveDb', the
owning resource whose holder alone may 'cveDbClose'; consumers are handed only
its 'CveLookup' view, so nothing evaluating rules can release a shared
connection. A lexically-scoped use (a test, a one-shot check) brackets with
'withCveDb'; a dynamically-scoped owner (the background sync's shadow-swap,
which retires an artifact only when no evaluation still reads it) holds the
'CveDb' and closes explicitly.
-}
module Ecluse.Core.Cve (
    -- * The owning resource
    CveDb (..),
    openCveDb,
    withCveDb,

    -- * The consumer view
    CveLookup (..),

    -- * What a lookup returns
    AdvisoryRange (..),

    -- * Rejection
    CveDbRejected (..),

    -- * Pure range matching
    insideAffectedRange,
) where

import UnliftIO.Exception (finally, onException)

import Ecluse.Core.Cve.Internal (AdvisoryRange (..), CveDbRejected (..), advisoriesQuery, openHardenedConnection, probeQuery, provenanceQuery)
import Ecluse.Core.Ecosystem (Ecosystem)
import Ecluse.Core.Version (compareVersions, mkVersion)

import Database.SQLite.Simple (Connection, close)

{- | Advisory questions about one ecosystem's artifact -- the read-only view a
consumer (a rule evaluation) is handed. It deliberately cannot release the
underlying connection; that is the owning 'CveDb''s capability.

Names and versions are the artifact's own vocabulary: the OSV wire package
name (scope inline, e.g. @\@scope\/name@) and verbatim version text. Callers
render their domain values to that form at the boundary.
-}
data CveLookup = CveLookup
    { CveLookup -> Text -> Text -> IO Bool
cveRemediationProbe :: Text -> Text -> IO Bool
    {- ^ Does any advisory for this package name this exact version string as
    a fixed bound? One indexed B-tree traversal.
    -}
    , CveLookup -> Text -> IO [AdvisoryRange]
cveAdvisoriesFor :: Text -> IO [AdvisoryRange]
    {- ^ Every advisory range recorded against a package name; rule predicates
    interpret them.
    -}
    }

{- | One opened artifact: the consumer view plus the owner's close. Whoever
holds this owns the connection's lifetime; hand consumers 'cveDbLookup' only.
-}
data CveDb = CveDb
    { CveDb -> CveLookup
cveDbLookup :: CveLookup
    -- ^ The view consumers query through.
    , CveDb -> IO ()
cveDbClose :: IO ()
    {- ^ Release the artifact's connection. Owner-only; the artifact must no
    longer be read through this handle's view afterwards.
    -}
    , CveDb -> [(Text, Text)]
cveDbMeta :: [(Text, Text)]
    {- ^ The artifact's @meta@ provenance rows (Pilot version, ecosystem, build
    timestamp, source URL, row count), snapshotted at open, key-sorted. The
    audit surface that ties this handle's decisions to the exact database that
    produced them.
    -}
    }

{- | Open an @osv.db@ artifact and build the owning handle over it, or reject
the artifact ('CveDbRejected') with its connection already closed. Throws on
faults below the acceptance contract (an unreadable file, a malformed
provenance row), and then too the connection is already closed: an exception
never leaks it.
-}
openCveDb :: Ecosystem -> FilePath -> IO (Either CveDbRejected CveDb)
openCveDb :: Ecosystem -> FilePath -> IO (Either CveDbRejected CveDb)
openCveDb Ecosystem
eco FilePath
dbFile =
    Ecosystem -> FilePath -> IO (Either CveDbRejected Connection)
openHardenedConnection Ecosystem
eco FilePath
dbFile IO (Either CveDbRejected Connection)
-> (Either CveDbRejected Connection
    -> IO (Either CveDbRejected CveDb))
-> IO (Either CveDbRejected CveDb)
forall a b. IO a -> (a -> IO b) -> IO b
forall (m :: * -> *) a b. Monad m => m a -> (a -> m b) -> m b
>>= \case
        Left CveDbRejected
rejection -> Either CveDbRejected CveDb -> IO (Either CveDbRejected CveDb)
forall a. a -> IO a
forall (f :: * -> *) a. Applicative f => a -> f a
pure (CveDbRejected -> Either CveDbRejected CveDb
forall a b. a -> Either a b
Left CveDbRejected
rejection)
        Right Connection
conn -> do
            -- Until the handle is handed over, this side owns the connection:
            -- acceptance decodes only the ecosystem row, so a further meta row
            -- can still fail to decode here, and that failure must close the
            -- connection rather than leak it.
            meta <- Connection -> IO [(Text, Text)]
provenanceQuery Connection
conn IO [(Text, Text)] -> IO () -> IO [(Text, Text)]
forall (m :: * -> *) a b. MonadUnliftIO m => m a -> m b -> m a
`onException` Connection -> IO ()
close Connection
conn
            pure (Right (mkCveDb conn meta))

mkCveDb :: Connection -> [(Text, Text)] -> CveDb
mkCveDb :: Connection -> [(Text, Text)] -> CveDb
mkCveDb Connection
conn [(Text, Text)]
meta =
    CveDb
        { cveDbLookup :: CveLookup
cveDbLookup =
            CveLookup
                { cveRemediationProbe :: Text -> Text -> IO Bool
cveRemediationProbe = Connection -> Text -> Text -> IO Bool
probeQuery Connection
conn
                , cveAdvisoriesFor :: Text -> IO [AdvisoryRange]
cveAdvisoriesFor = Connection -> Text -> IO [AdvisoryRange]
advisoriesQuery Connection
conn
                }
        , cveDbClose :: IO ()
cveDbClose = Connection -> IO ()
close Connection
conn
        , cveDbMeta :: [(Text, Text)]
cveDbMeta = [(Text, Text)]
meta
        }

{- | Bracket a lexically-scoped use of an artifact: open, hand the consumer
view to the action, and close on any exit. A rejected artifact short-circuits
('Left') without running the action; its connection is already closed.
-}
withCveDb :: Ecosystem -> FilePath -> (CveLookup -> IO a) -> IO (Either CveDbRejected a)
withCveDb :: forall a.
Ecosystem
-> FilePath -> (CveLookup -> IO a) -> IO (Either CveDbRejected a)
withCveDb Ecosystem
eco FilePath
dbFile CveLookup -> IO a
use =
    Ecosystem -> FilePath -> IO (Either CveDbRejected CveDb)
openCveDb Ecosystem
eco FilePath
dbFile IO (Either CveDbRejected CveDb)
-> (Either CveDbRejected CveDb -> IO (Either CveDbRejected a))
-> IO (Either CveDbRejected a)
forall a b. IO a -> (a -> IO b) -> IO b
forall (m :: * -> *) a b. Monad m => m a -> (a -> m b) -> m b
>>= \case
        Left CveDbRejected
rejection -> Either CveDbRejected a -> IO (Either CveDbRejected a)
forall a. a -> IO a
forall (f :: * -> *) a. Applicative f => a -> f a
pure (CveDbRejected -> Either CveDbRejected a
forall a b. a -> Either a b
Left CveDbRejected
rejection)
        Right CveDb
db -> a -> Either CveDbRejected a
forall a b. b -> Either a b
Right (a -> Either CveDbRejected a)
-> IO a -> IO (Either CveDbRejected a)
forall (f :: * -> *) a b. Functor f => (a -> b) -> f a -> f b
<$> (CveLookup -> IO a
use (CveDb -> CveLookup
cveDbLookup CveDb
db) IO a -> IO () -> IO a
forall (m :: * -> *) a b. MonadUnliftIO m => m a -> m b -> m a
`finally` CveDb -> IO ()
cveDbClose CveDb
db)

{- | Is this version inside the advisory range's affected interval,
@introduced <= v < fixed@, under the ecosystem's version ordering?

__Fail-closed for the allow direction.__ This predicate guards the
remediation fast lane (a fixed version must not fast-track while it sits
inside another advisory's affected range), so every unprovable comparison,
an unparseable bound, an unparseable version, counts as __inside__: trust is
only ever granted on evidence. A future deny-direction consumer wants the
same polarity for its own reason (cannot prove safe, assume affected), but
must not reuse this documentation's rationale blindly if its needs diverge.
-}
insideAffectedRange :: Ecosystem -> Text -> AdvisoryRange -> Bool
insideAffectedRange :: Ecosystem -> Text -> AdvisoryRange -> Bool
insideAffectedRange Ecosystem
eco Text
versionText AdvisoryRange
ar = Bool
atOrAboveIntroduced Bool -> Bool -> Bool
&& Bool
belowFixed
  where
    v :: Version
v = Ecosystem -> Text -> Version
mkVersion Ecosystem
eco Text
versionText

    atOrAboveIntroduced :: Bool
atOrAboveIntroduced = case AdvisoryRange -> Maybe Text
arIntroduced AdvisoryRange
ar of
        -- No introduced bound: the range starts at the beginning.
        Maybe Text
Nothing -> Bool
True
        Just Text
i -> case Version -> Version -> Maybe Ordering
compareVersions Version
v (Ecosystem -> Text -> Version
mkVersion Ecosystem
eco Text
i) of
            Just Ordering
LT -> Bool
False
            Just Ordering
_ -> Bool
True
            Maybe Ordering
Nothing -> Bool
True

    belowFixed :: Bool
belowFixed = case AdvisoryRange -> Maybe Text
arFixed AdvisoryRange
ar of
        -- No fix known: the range never ends.
        Maybe Text
Nothing -> Bool
True
        Just Text
f -> case Version -> Version -> Maybe Ordering
compareVersions Version
v (Ecosystem -> Text -> Version
mkVersion Ecosystem
eco Text
f) of
            Just Ordering
LT -> Bool
True
            Just Ordering
_ -> Bool
False
            Maybe Ordering
Nothing -> Bool
True