baikai-kit-0.2.0.0: src/Baikai/Kit/Sidecar.hs
module Baikai.Kit.Sidecar
( SidecarMeta (..),
computeKitHash,
hashEntries,
newSidecarMeta,
sidecarPath,
readSidecar,
)
where
import Baikai.AgentAssets (AgentAssetProvider, agentTargetPath, skillTargetPath)
import Baikai.Interactive (InteractiveScope (InteractiveProjectScope))
import Baikai.Kit.Error (KitError (..))
import Baikai.Kit.Manifest (KitItem, KitItemKind (..), itemKind, itemName, itemVersion)
import Baikai.Kit.Path (safeSourcePath)
import Baikai.Prelude
import Control.Exception (IOException, try)
import Crypto.Hash (Digest, SHA256)
import Crypto.Hash qualified as Hash
import Data.Aeson (eitherDecodeFileStrict')
import Data.Binary.Put (putWord64be, runPut)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.List (sortOn)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text.Encoding
import Data.Time.Clock (getCurrentTime)
import Data.Time.Format (defaultTimeLocale, formatTime)
import System.Directory (doesFileExist)
import System.FilePath (dropExtension, (</>))
import System.IO (hPutStrLn, stderr)
-- | The metadata written beside each installed asset.
--
-- @installedFiles@ and @installedHash@ describe what this tool wrote
-- for one provider — the file names relative to that provider's target
-- directory, and the hash of exactly those bytes — so @kit update@ can
-- tell a file the user edited from one it installed. Both are 'Nothing'
-- in sidecars written before those fields existed, and such an item is
-- updated without the check.
data SidecarMeta = SidecarMeta
{ name :: !Text,
kind :: !Text,
version :: !(Maybe Text),
hash :: !Text,
installedAt :: !Text,
installedFiles :: !(Maybe [Text]),
installedHash :: !(Maybe Text)
}
deriving stock (Eq, Generic, Show)
deriving anyclass (FromJSON, ToJSON)
-- | Content hash of the listed files, which lie at @base '</>' file@
-- below the kit checkout @root@.
--
-- The hashed bytes are, per file sorted by name: the file name relative
-- to @base@, NUL, the big-endian length, the content, NUL — unchanged
-- from earlier releases, so existing sidecars keep matching. Every file
-- is resolved through 'safeSourcePath' first, so a symbolic link
-- anywhere below @root@ refuses the hash instead of being read through.
computeKitHash :: FilePath -> FilePath -> [FilePath] -> IO (Either KitError Text)
computeKitHash root base relFiles = do
results <- traverse readOne relFiles
pure (hashEntries <$> sequence results)
where
readOne :: FilePath -> IO (Either KitError (FilePath, BS.ByteString))
readOne rel = do
resolved <- safeSourcePath root (base </> rel)
case resolved of
Left err -> pure (Left err)
Right path -> do
content <- try @IOException (BS.readFile path)
pure $ case content of
Left e -> Left (KitSourceUnreadable path (Text.pack (show e)))
Right bytes -> Right (rel, bytes)
-- | The pure core: hash already-read (relative name, bytes) pairs.
hashEntries :: [(FilePath, BS.ByteString)] -> Text
hashEntries entries = "sha256:" <> Text.pack (show digest)
where
digest = Hash.hash (BS.concat (map chunk (sortOn fst entries))) :: Digest SHA256
chunk (rel, content) =
BS.concat
[ Text.Encoding.encodeUtf8 (Text.pack rel),
BS.singleton 0x00,
LBS.toStrict (runPut (putWord64be (fromIntegral (BS.length content)))),
content,
BS.singleton 0x00
]
sidecarPath :: AgentAssetProvider -> KitItemKind -> Text -> FilePath -> Text -> FilePath
sidecarPath provider SkillKind itemName' targetBase sidecarName =
targetBase
</> skillTargetPath provider InteractiveProjectScope (Text.unpack itemName')
</> Text.unpack sidecarName
sidecarPath provider AgentKind itemName' targetBase sidecarName =
targetBase
</> dropExtension (agentTargetPath provider InteractiveProjectScope (Text.unpack itemName'))
<> Text.unpack sidecarName
readSidecar :: FilePath -> IO (Maybe SidecarMeta)
readSidecar p = do
exists <- doesFileExist p
if not exists
then pure Nothing
else do
result <- eitherDecodeFileStrict' p
case result of
Right meta -> pure (Just meta)
Left err -> do
hPutStrLn stderr $ "Warning: failed to parse sidecar " <> p <> ": " <> err
pure Nothing
-- | Build the sidecar for one provider: the upstream content hash, the
-- names this install writes for that provider, and the hash of the
-- bytes it writes.
newSidecarMeta :: KitItem -> Text -> [Text] -> Text -> IO SidecarMeta
newSidecarMeta item hashStr writtenFiles writtenHash = do
now <- getCurrentTime
let stamp = Text.pack (formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" now)
pure
SidecarMeta
{ name = itemName item,
kind = itemKind item,
version = itemVersion item,
hash = hashStr,
installedAt = stamp,
installedFiles = Just writtenFiles,
installedHash = Just writtenHash
}