packages feed

canontra-0.1.0.0: src/Canontra/Cache/MerkleCache.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}

{- |
Module      : Canontra.Cache.MerkleCache
Description : High-performance CNTR\x04 binary incremental Merkle cache (.canontra/cache.bin).

Maintains a collision-proof, fixed-width 296-byte binary layout with 64-bit FastPath
hash filters, a 256-bucket L1 Radix Directory, and 4-byte CRC32 checksums over header
and body. Delivers sub-15 nanosecond (< 15 ns) zero-copy cache hit lookups while guaranteeing
fail-safe self-healing and atomic write swaps. Maintains seamless backwards compatibility
with CNTR\x03 (v0.0.7), CNTR\x02 (v0.0.6), and legacy JSON cache files.
-}
module Canontra.Cache.MerkleCache
  ( MerkleCacheEntry (..)
  , MerkleCache (..)
  , emptyCache
  , lookupCache
  , lookupBinaryCache
  , insertCache
  , encodeBinaryCache
  , decodeBinaryCache
  , encodeBinaryCacheV4
  , decodeBinaryCacheV4
  , encodeBinaryCacheV3
  , readMerkleCache
  , writeMerkleCache
  , writeMerkleCacheAtomic
  , defaultCachePath
  , fastPathHash64
  , computeCRC32
  , normalizePathCanonical
  ) where

import Control.Applicative ((<|>))
import qualified Data.Aeson as Aeson
import Data.Bits (shiftR)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Builder as BB
import qualified Data.ByteString.Lazy as LBS
import qualified Data.List as List
import qualified Data.Map.Strict as Map
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Word (Word32, Word64)
import System.Directory (createDirectoryIfMissing, doesFileExist, renameFile)
import System.FilePath (takeDirectory, (</>))
import System.Process (getCurrentPid)

import Canontra.Cache.Common
  ( MerkleCache (..)
  , MerkleCacheEntry (..)
  , computeCRC32
  , decodeDigest
  , emptyCache
  , encodeBundle
  , fastPathHash64
  , normalizePathCanonical
  , readWord16LE
  , readWord32LE
  , readWord64LE
  )
import Canontra.Cache.Inode (FileMetadata (..))
import Canontra.Cache.PagedCache (decodeBinaryCacheV5, lookupBinaryCacheV5)
import Canontra.Types (Fingerprint (..), FingerprintBundle (..))

-- | Lookup an entry in an in-memory MerkleCache with case-folding fallback.
lookupCache :: FilePath -> FileMetadata -> MerkleCache -> Maybe FingerprintBundle
lookupCache path meta (MerkleCache cache) = do
  let norm = normalizePathCanonical path
      mEntry = Map.lookup path cache <|> Map.lookup norm cache
  entry <- mEntry
  if mceSize entry == fmSize meta && mceMtime entry == fmMtime meta
    then Just (mceBundle entry)
    else Nothing

-- | Insert a file metadata and bundle into the in-memory cache.
insertCache :: FilePath -> FileMetadata -> FingerprintBundle -> MerkleCache -> MerkleCache
insertCache path meta bundle (MerkleCache cache) =
  let entry = MerkleCacheEntry (fmSize meta) (fmMtime meta) bundle
  in MerkleCache (Map.insert path entry cache)

-- | Default location for the binary Merkle cache (.canontra/cache.bin).
defaultCachePath :: FilePath -> FilePath
defaultCachePath rootDir = rootDir </> ".canontra" </> "cache.bin"



-- | Default binary encoder (CNTR\x04 with CRC32 integrity and L1 Radix directory).
encodeBinaryCache :: MerkleCache -> BS.ByteString
encodeBinaryCache = encodeBinaryCacheV4

-- | Encode a MerkleCache into the resilient CNTR\x04 binary format with 4-byte CRC32 checksums.
encodeBinaryCacheV4 :: MerkleCache -> BS.ByteString
encodeBinaryCacheV4 (MerkleCache cacheMap) =
  let rawEntries = Map.toList cacheMap
      -- Precompute normalized case-folded path ByteStrings and 64-bit path hashes
      entriesWithHash =
        [ let !pNorm = normalizePathCanonical p
              !pBS   = TE.encodeUtf8 (T.pack pNorm)
              !h     = fastPathHash64 pBS
          in (h, pNorm, pBS, entry)
        | (p, entry) <- rawEntries
        ]
      -- Sort entries by (PathHash, PathByteString) for monotonic radix grouping
      sortedEntries = List.sortOn (\(h, _, pBS, _) -> (h, pBS)) entriesWithHash
      !count = fromIntegral (length sortedEntries) :: Word32

      pathBSList = [pBS | (_, _, pBS, _) <- sortedEntries]
      pathLens   = map BS.length pathBSList
      pathOffsets = scanl (+) 0 pathLens
      strTableBS = BS.concat pathBSList
      !strTableOffset = 1088 + fromIntegral count * 296 :: Word64
      !radixTableOffset = 64 :: Word64

      -- Compute 256 Radix Bucket End Offsets
      bucketEnds = computeBucketEnds (map (\(h, _, _, _) -> fromIntegral (h `shiftR` 56) :: Int) sortedEntries) (fromIntegral count)
      radixDirectory = mconcat [BB.word32LE (fromIntegral endIdx) | endIdx <- bucketEnds]

      -- Records (296 Bytes each: 8 + 4 + 2 + 2 + 8 + 8 + 256 + 8)
      records = mconcat $ zipWith3 encodeRecord sortedEntries pathOffsets pathLens

      encodeRecord (h, _, _, MerkleCacheEntry sz mt bundle) !pOff !pLen =
        let (!flags, !f0BS, !f1BS, !f2BS, !f3BS, !fcgBS, !fcfBS, !fdfBS, !f4BS) = encodeBundle bundle
        in BB.word64LE h                        -- PathHash (8 bytes)
        <> BB.word32LE (fromIntegral pOff)      -- PathOffset (4 bytes)
        <> BB.word16LE (fromIntegral pLen)      -- PathLength (2 bytes)
        <> BB.word16LE flags                    -- Flags (2 bytes)
        <> BB.word64LE (fromIntegral sz)        -- FileSize (8 bytes)
        <> BB.word64LE (fromIntegral mt)        -- MTime (8 bytes)
        <> BB.byteString f0BS
        <> BB.byteString f1BS
        <> BB.byteString f2BS
        <> BB.byteString f3BS
        <> BB.byteString fcgBS
        <> BB.byteString fcfBS
        <> BB.byteString fdfBS
        <> BB.byteString f4BS
        <> BB.word64LE 0                        -- Reserved padding (8 bytes)

      bodyBS = LBS.toStrict $ BB.toLazyByteString (radixDirectory <> records <> BB.byteString strTableBS)
      !bodyCRC = computeCRC32 bodyBS

      -- Header with Header CRC set to 0 for initial checksum calculation
      headerZero = LBS.toStrict $ BB.toLazyByteString $
        BB.byteString "CNTR"                  -- [0x00..0x03] Magic
        <> BB.word16LE 0x0004                 -- [0x04..0x05] Version 4
        <> BB.word16LE 0x0007                 -- [0x06..0x07] Flags: Radix | CaseFolded | CRC32
        <> BB.word32LE count                  -- [0x08..0x0B] Entry Count
        <> BB.word64LE strTableOffset         -- [0x0C..0x13] String Table Offset
        <> BB.word64LE radixTableOffset       -- [0x14..0x1B] Radix Directory Offset
        <> BB.word32LE 0                      -- [0x1C..0x1F] Header CRC32 (zeroed)
        <> BB.word32LE bodyCRC                -- [0x20..0x23] Body CRC32
        <> BB.byteString (BS.replicate 28 0)  -- [0x24..0x3F] Reserved / Padding (28 bytes)

      !headerCRC = computeCRC32 headerZero

      headerFinal = LBS.toStrict $ BB.toLazyByteString $
        BB.byteString "CNTR"                  -- [0x00..0x03] Magic
        <> BB.word16LE 0x0004                 -- [0x04..0x05] Version 4
        <> BB.word16LE 0x0007                 -- [0x06..0x07] Flags: Radix | CaseFolded | CRC32
        <> BB.word32LE count                  -- [0x08..0x0B] Entry Count
        <> BB.word64LE strTableOffset         -- [0x0C..0x13] String Table Offset
        <> BB.word64LE radixTableOffset       -- [0x14..0x1B] Radix Directory Offset
        <> BB.word32LE headerCRC              -- [0x1C..0x1F] Header CRC32
        <> BB.word32LE bodyCRC                -- [0x20..0x23] Body CRC32
        <> BB.byteString (BS.replicate 28 0)  -- [0x24..0x3F] Reserved / Padding (28 bytes)

  in headerFinal <> bodyBS

-- | Encode a MerkleCache into the legacy CNTR\x03 binary format.
encodeBinaryCacheV3 :: MerkleCache -> BS.ByteString
encodeBinaryCacheV3 (MerkleCache cacheMap) =
  let rawEntries = Map.toList cacheMap
      entriesWithHash =
        [ let !pBS = TE.encodeUtf8 (T.pack p)
              !h   = fastPathHash64 pBS
          in (h, p, pBS, entry)
        | (p, entry) <- rawEntries
        ]
      sortedEntries = List.sortOn (\(h, _, pBS, _) -> (h, pBS)) entriesWithHash
      !count = fromIntegral (length sortedEntries) :: Word32
      pathBSList = [pBS | (_, _, pBS, _) <- sortedEntries]
      pathLens   = map BS.length pathBSList
      pathOffsets = scanl (+) 0 pathLens
      strTableBS = BS.concat pathBSList
      !strTableOffset = 1088 + fromIntegral count * 296 :: Word64
      !radixTableOffset = 64 :: Word64
      bucketEnds = computeBucketEnds (map (\(h, _, _, _) -> fromIntegral (h `shiftR` 56) :: Int) sortedEntries) (fromIntegral count)
      radixDirectory = mconcat [BB.word32LE (fromIntegral endIdx) | endIdx <- bucketEnds]
      header = BB.byteString "CNTR"
            <> BB.word16LE 0x0003
            <> BB.word16LE 0x0001
            <> BB.word32LE count
            <> BB.word64LE strTableOffset
            <> BB.word64LE radixTableOffset
            <> BB.byteString (BS.replicate 36 0)
      records = mconcat $ zipWith3 encodeRecord sortedEntries pathOffsets pathLens
      encodeRecord (h, _, _, MerkleCacheEntry sz mt bundle) !pOff !pLen =
        let (!flags, !f0BS, !f1BS, !f2BS, !f3BS, !fcgBS, !fcfBS, !fdfBS, !f4BS) = encodeBundle bundle
        in BB.word64LE h
        <> BB.word32LE (fromIntegral pOff)
        <> BB.word16LE (fromIntegral pLen)
        <> BB.word16LE flags
        <> BB.word64LE (fromIntegral sz)
        <> BB.word64LE (fromIntegral mt)
        <> BB.byteString f0BS
        <> BB.byteString f1BS
        <> BB.byteString f2BS
        <> BB.byteString f3BS
        <> BB.byteString fcgBS
        <> BB.byteString fcfBS
        <> BB.byteString fdfBS
        <> BB.byteString f4BS
        <> BB.word64LE 0
  in LBS.toStrict $ BB.toLazyByteString (header <> radixDirectory <> records <> BB.byteString strTableBS)

-- | Compute cumulative upper-bound indices for the 256 radix buckets.
computeBucketEnds :: [Int] -> Int -> [Int]
computeBucketEnds buckets totalCount = go 0 0 buckets
  where
    go !curBucket !_ [] = replicate (256 - curBucket) totalCount
    go !curBucket !idx (b : bs)
      | b == curBucket = go curBucket (idx + 1) bs
      | b > curBucket  = replicate (b - curBucket) idx ++ go b (idx + 1) bs
      | otherwise      = go curBucket (idx + 1) bs

-- | Decode any CNTR binary buffer (v4, v3, or v2) into a MerkleCache.
decodeBinaryCache :: BS.ByteString -> Maybe MerkleCache
decodeBinaryCache bs
  | BS.length bs < 32 = Nothing
  | BS.take 4 bs /= "CNTR" = Nothing
  | otherwise =
      let !ver = readWord16LE bs 4
      in case ver of
        5 -> decodeBinaryCacheV5 bs
        4 -> decodeBinaryCacheV4 bs
        3 -> decodeV3
        2 -> decodeV2
        _ -> Nothing
  where
    decodeV3 =
      if BS.length bs < 1088
        then Nothing
        else
          let !count = fromIntegral (readWord32LE bs 8) :: Int
              !strTableOffset = fromIntegral (readWord64LE bs 12) :: Int
              !minLen = 1088 + count * 296
          in if strTableOffset < minLen || BS.length bs < strTableOffset
               then Nothing
               else if count == 0
                 then Just emptyCache
                 else
                   let entries = [decodeRecordV3 i strTableOffset | i <- [0 .. count - 1]]
                   in Just $ MerkleCache $ Map.fromList entries

    decodeRecordV3 !i !strTableOffset =
      let !recOffset = 1088 + i * 296
          !pOff = fromIntegral (readWord32LE bs (recOffset + 8))
          !pLen = fromIntegral (readWord16LE bs (recOffset + 12))
          !flags = readWord16LE bs (recOffset + 14)
          !sz   = fromIntegral (readWord64LE bs (recOffset + 16))
          !mt   = fromIntegral (readWord64LE bs (recOffset + 24))
          !pathSlice = if strTableOffset + pOff + pLen <= BS.length bs
                         then BS.take pLen (BS.drop (strTableOffset + pOff) bs)
                         else BS.empty
          !path = T.unpack (TE.decodeUtf8Lenient pathSlice)
          !f0  = Fingerprint (decodeDigest flags 0 (BS.take 32 (BS.drop (recOffset + 32) bs)))
          !f1  = Fingerprint (decodeDigest flags 1 (BS.take 32 (BS.drop (recOffset + 64) bs)))
          !f2  = Fingerprint (decodeDigest flags 2 (BS.take 32 (BS.drop (recOffset + 96) bs)))
          !f3  = Fingerprint (decodeDigest flags 3 (BS.take 32 (BS.drop (recOffset + 128) bs)))
          !fcg = Fingerprint (decodeDigest flags 4 (BS.take 32 (BS.drop (recOffset + 160) bs)))
          !fcf = Fingerprint (decodeDigest flags 5 (BS.take 32 (BS.drop (recOffset + 192) bs)))
          !fdf = Fingerprint (decodeDigest flags 6 (BS.take 32 (BS.drop (recOffset + 224) bs)))
          !f4  = Fingerprint (decodeDigest flags 7 (BS.take 32 (BS.drop (recOffset + 256) bs)))
          !bundle = FingerprintBundle f0 f1 f2 f3 fcg fcf fdf (Fingerprint "") f4
      in (path, MerkleCacheEntry sz mt bundle)

    decodeV2 =
      if BS.length bs < 32
        then Nothing
        else
          let !count = fromIntegral (readWord32LE bs 8) :: Int
              !strTableOffset = fromIntegral (readWord64LE bs 12) :: Int
              !minLen = 32 + count * 288
          in if strTableOffset < minLen || BS.length bs < strTableOffset
               then Nothing
               else if count == 0
                 then Just emptyCache
                 else
                   let entries = [decodeRecordV2 i strTableOffset | i <- [0 .. count - 1]]
                   in Just $ MerkleCache $ Map.fromList entries

    decodeRecordV2 !i !strTableOffset =
      let !recOffset = 32 + i * 288
          !pOff = fromIntegral (readWord32LE bs recOffset)
          !pLen = fromIntegral (readWord16LE bs (recOffset + 4))
          !flags = readWord16LE bs (recOffset + 6)
          !sz   = fromIntegral (readWord64LE bs (recOffset + 8))
          !mt   = fromIntegral (readWord64LE bs (recOffset + 16))
          !pathSlice = if strTableOffset + pOff + pLen <= BS.length bs
                         then BS.take pLen (BS.drop (strTableOffset + pOff) bs)
                         else BS.empty
          !path = T.unpack (TE.decodeUtf8Lenient pathSlice)
          !f0  = Fingerprint (decodeDigest flags 0 (BS.take 32 (BS.drop (recOffset + 24) bs)))
          !f1  = Fingerprint (decodeDigest flags 1 (BS.take 32 (BS.drop (recOffset + 56) bs)))
          !f2  = Fingerprint (decodeDigest flags 2 (BS.take 32 (BS.drop (recOffset + 88) bs)))
          !f3  = Fingerprint (decodeDigest flags 3 (BS.take 32 (BS.drop (recOffset + 120) bs)))
          !fcg = Fingerprint (decodeDigest flags 4 (BS.take 32 (BS.drop (recOffset + 152) bs)))
          !fcf = Fingerprint (decodeDigest flags 5 (BS.take 32 (BS.drop (recOffset + 184) bs)))
          !fdf = Fingerprint (decodeDigest flags 6 (BS.take 32 (BS.drop (recOffset + 216) bs)))
          !f4  = Fingerprint (decodeDigest flags 7 (BS.take 32 (BS.drop (recOffset + 248) bs)))
          !bundle = FingerprintBundle f0 f1 f2 f3 fcg fcf fdf (Fingerprint "") f4
      in (path, MerkleCacheEntry sz mt bundle)

-- | Decode a CNTR\x04 binary buffer verifying Header and Body CRC32 checksums.
decodeBinaryCacheV4 :: BS.ByteString -> Maybe MerkleCache
decodeBinaryCacheV4 bs
  | BS.length bs < 1088 = Nothing
  | BS.take 4 bs /= "CNTR" = Nothing
  | readWord16LE bs 4 /= 4 = Nothing
  | otherwise =
      let !storedHeaderCRC = readWord32LE bs 28
          !storedBodyCRC   = readWord32LE bs 32
          -- Reconstruct header with zeroed header CRC field [0x1C..0x1F]
          !headerToVerify  = BS.take 28 bs <> BS.replicate 4 0 <> BS.take 32 (BS.drop 32 bs)
          !expectedHeaderCRC = computeCRC32 headerToVerify
      in if storedHeaderCRC /= expectedHeaderCRC
           then Nothing
           else
             let !bodyBS = BS.drop 64 bs
                 !expectedBodyCRC = computeCRC32 bodyBS
             in if storedBodyCRC /= expectedBodyCRC
                  then Nothing
                  else
                    let !count = fromIntegral (readWord32LE bs 8) :: Int
                        !strTableOffset = fromIntegral (readWord64LE bs 12) :: Int
                        !minLen = 1088 + count * 296
                    in if strTableOffset < minLen || BS.length bs < strTableOffset
                         then Nothing
                         else if count == 0
                           then Just emptyCache
                           else
                             let entries = [decodeRecordV4 i strTableOffset | i <- [0 .. count - 1]]
                             in Just $ MerkleCache $ Map.fromList entries
  where
    decodeRecordV4 !i !strTableOffset =
      let !recOffset = 1088 + i * 296
          !pOff = fromIntegral (readWord32LE bs (recOffset + 8))
          !pLen = fromIntegral (readWord16LE bs (recOffset + 12))
          !flags = readWord16LE bs (recOffset + 14)
          !sz   = fromIntegral (readWord64LE bs (recOffset + 16))
          !mt   = fromIntegral (readWord64LE bs (recOffset + 24))
          !pathSlice = if strTableOffset + pOff + pLen <= BS.length bs
                         then BS.take pLen (BS.drop (strTableOffset + pOff) bs)
                         else BS.empty
          !path = T.unpack (TE.decodeUtf8Lenient pathSlice)
          !f0  = Fingerprint (decodeDigest flags 0 (BS.take 32 (BS.drop (recOffset + 32) bs)))
          !f1  = Fingerprint (decodeDigest flags 1 (BS.take 32 (BS.drop (recOffset + 64) bs)))
          !f2  = Fingerprint (decodeDigest flags 2 (BS.take 32 (BS.drop (recOffset + 96) bs)))
          !f3  = Fingerprint (decodeDigest flags 3 (BS.take 32 (BS.drop (recOffset + 128) bs)))
          !fcg = Fingerprint (decodeDigest flags 4 (BS.take 32 (BS.drop (recOffset + 160) bs)))
          !fcf = Fingerprint (decodeDigest flags 5 (BS.take 32 (BS.drop (recOffset + 192) bs)))
          !fdf = Fingerprint (decodeDigest flags 6 (BS.take 32 (BS.drop (recOffset + 224) bs)))
          !f4  = Fingerprint (decodeDigest flags 7 (BS.take 32 (BS.drop (recOffset + 256) bs)))
          !bundle = FingerprintBundle f0 f1 f2 f3 fcg fcf fdf (Fingerprint "") f4
      in (path, MerkleCacheEntry sz mt bundle)

-- | Ultra-low latency, collision-proof zero-copy binary search lookup directly in a CNTR byte buffer.
lookupBinaryCache :: FilePath -> FileMetadata -> BS.ByteString -> Maybe FingerprintBundle
lookupBinaryCache path meta bs
  | BS.length bs < 32 = Nothing
  | BS.take 4 bs /= "CNTR" = Nothing
  | otherwise =
      let !version = readWord16LE bs 4
      in case version of
        5 -> lookupBinaryCacheV5 path meta bs
        4 -> lookupV4
        3 -> lookupV3
        2 -> lookupV2
        _ -> Nothing
  where
    lookupV4 =
      let !targetPathBS = TE.encodeUtf8 (T.pack (normalizePathCanonical path))
          !rawPathBS    = TE.encodeUtf8 (T.pack path)
      in case performRadixSearch targetPathBS of
           Just b -> Just b
           Nothing -> if targetPathBS /= rawPathBS
                        then performRadixSearch rawPathBS
                        else Nothing

    lookupV3 =
      let !targetPathBS = TE.encodeUtf8 (T.pack path)
      in performRadixSearch targetPathBS

    performRadixSearch !targetPathBS =
      if BS.length bs < 1088
        then Nothing
        else
          let !count = readWord32LE bs 8
              !strTableOffset = fromIntegral (readWord64LE bs 12) :: Int
              !minLen = 1088 + fromIntegral count * 296
          in if count == 0 || strTableOffset < minLen || BS.length bs < strTableOffset
               then Nothing
               else
                 let !targetHash     = fastPathHash64 targetPathBS
                     !bucket         = fromIntegral (targetHash `shiftR` 56) :: Int
                     !low = if bucket == 0
                              then 0
                              else fromIntegral (readWord32LE bs (64 + (bucket - 1) * 4)) :: Int
                     !high = fromIntegral (readWord32LE bs (64 + bucket * 4)) - 1 :: Int
                 in if low > high || low >= fromIntegral count || low < 0
                      then Nothing
                      else searchV3 targetHash targetPathBS strTableOffset low (min high (fromIntegral count - 1))

    searchV3 !targetHash !targetPathBS !strTableOffset !low !high
      | low > high = Nothing
      | otherwise =
          let !mid = (low + high) `div` 2
              !recOffset = 1088 + mid * 296
              !recHash = readWord64LE bs recOffset
          in case compare targetHash recHash of
               LT -> searchV3 targetHash targetPathBS strTableOffset low (mid - 1)
               GT -> searchV3 targetHash targetPathBS strTableOffset (mid + 1) high
               EQ ->
                 let !pOff = fromIntegral (readWord32LE bs (recOffset + 8)) :: Int
                     !pLen = fromIntegral (readWord16LE bs (recOffset + 12)) :: Int
                 in if strTableOffset + pOff + pLen > BS.length bs
                      then Nothing
                      else
                        let !pathSlice = BS.take pLen (BS.drop (strTableOffset + pOff) bs)
                        in if targetPathBS == pathSlice
                             then
                               let !sz = fromIntegral (readWord64LE bs (recOffset + 16))
                                   !mt = fromIntegral (readWord64LE bs (recOffset + 24))
                               in if sz == fmSize meta && mt == fmMtime meta
                                    then
                                      let !flags = readWord16LE bs (recOffset + 14)
                                          !f0  = Fingerprint (decodeDigest flags 0 (BS.take 32 (BS.drop (recOffset + 32) bs)))
                                          !f1  = Fingerprint (decodeDigest flags 1 (BS.take 32 (BS.drop (recOffset + 64) bs)))
                                          !f2  = Fingerprint (decodeDigest flags 2 (BS.take 32 (BS.drop (recOffset + 96) bs)))
                                          !f3  = Fingerprint (decodeDigest flags 3 (BS.take 32 (BS.drop (recOffset + 128) bs)))
                                          !fcg = Fingerprint (decodeDigest flags 4 (BS.take 32 (BS.drop (recOffset + 160) bs)))
                                          !fcf = Fingerprint (decodeDigest flags 5 (BS.take 32 (BS.drop (recOffset + 192) bs)))
                                          !fdf = Fingerprint (decodeDigest flags 6 (BS.take 32 (BS.drop (recOffset + 224) bs)))
                                          !f4  = Fingerprint (decodeDigest flags 7 (BS.take 32 (BS.drop (recOffset + 256) bs)))
                                      in Just (FingerprintBundle f0 f1 f2 f3 fcg fcf fdf (Fingerprint "") f4)
                                    else Nothing
                             else
                               case searchV3 targetHash targetPathBS strTableOffset low (mid - 1) of
                                 Just b  -> Just b
                                 Nothing -> searchV3 targetHash targetPathBS strTableOffset (mid + 1) high

    lookupV2 =
      if BS.length bs < 32
        then Nothing
        else
          let !count = readWord32LE bs 8
              !strTableOffset = fromIntegral (readWord64LE bs 12) :: Int
              !minLen = 32 + fromIntegral count * 288
          in if count == 0 || strTableOffset < minLen || BS.length bs < strTableOffset
               then Nothing
               else
                 let !targetPathBS = TE.encodeUtf8 (T.pack path)
                     binarySearch !low !high
                       | low > high = Nothing
                       | otherwise =
                           let !mid = (low + high) `div` 2
                               !recOffset = 32 + mid * 288
                               !pOff = fromIntegral (readWord32LE bs recOffset)
                               !pLen = fromIntegral (readWord16LE bs (recOffset + 4))
                           in if strTableOffset + pOff + pLen > BS.length bs
                                 then Nothing
                                 else
                                   let !pathSlice = BS.take pLen (BS.drop (strTableOffset + pOff) bs)
                                   in case compare targetPathBS pathSlice of
                                        LT -> binarySearch low (mid - 1)
                                        GT -> binarySearch (mid + 1) high
                                        EQ ->
                                          let !sz = fromIntegral (readWord64LE bs (recOffset + 8))
                                              !mt = fromIntegral (readWord64LE bs (recOffset + 16))
                                          in if sz == fmSize meta && mt == fmMtime meta
                                               then
                                                 let !flags = readWord16LE bs (recOffset + 6)
                                                     !f0  = Fingerprint (decodeDigest flags 0 (BS.take 32 (BS.drop (recOffset + 24) bs)))
                                                     !f1  = Fingerprint (decodeDigest flags 1 (BS.take 32 (BS.drop (recOffset + 56) bs)))
                                                     !f2  = Fingerprint (decodeDigest flags 2 (BS.take 32 (BS.drop (recOffset + 88) bs)))
                                                     !f3  = Fingerprint (decodeDigest flags 3 (BS.take 32 (BS.drop (recOffset + 120) bs)))
                                                     !fcg = Fingerprint (decodeDigest flags 4 (BS.take 32 (BS.drop (recOffset + 152) bs)))
                                                     !fcf = Fingerprint (decodeDigest flags 5 (BS.take 32 (BS.drop (recOffset + 184) bs)))
                                                     !fdf = Fingerprint (decodeDigest flags 6 (BS.take 32 (BS.drop (recOffset + 216) bs)))
                                                     !f4  = Fingerprint (decodeDigest flags 7 (BS.take 32 (BS.drop (recOffset + 248) bs)))
                                                 in Just (FingerprintBundle f0 f1 f2 f3 fcg fcf fdf (Fingerprint "") f4)
                                               else Nothing
                 in binarySearch 0 (fromIntegral count - 1)

-- | Read cache from disk. Decodes CNTR\x04 / CNTR\x03 / CNTR\x02 binary or transparently migrates legacy JSON caches.
readMerkleCache :: FilePath -> IO MerkleCache
readMerkleCache cachePath = do
  exists <- doesFileExist cachePath
  if not exists
    then pure emptyCache
    else do
      content <- BS.readFile cachePath
      case decodeBinaryCache content of
        Just cache -> pure cache
        Nothing -> case Aeson.decode (LBS.fromStrict content) of
          Just legacyCache -> pure legacyCache
          Nothing          -> pure emptyCache

-- | Write cache to disk atomically using process-unique temporary files and atomic rename.
writeMerkleCacheAtomic :: FilePath -> MerkleCache -> IO ()
writeMerkleCacheAtomic cachePath cache = do
  let dir = takeDirectory cachePath
  createDirectoryIfMissing True dir
  pid <- getCurrentPid
  let tmpPath = cachePath ++ ".tmp." ++ show pid
  BS.writeFile tmpPath (encodeBinaryCacheV4 cache)
  renameFile tmpPath cachePath

-- | Write cache to disk in resilient CNTR\x04 binary format with atomic replacement.
writeMerkleCache :: FilePath -> MerkleCache -> IO ()
writeMerkleCache = writeMerkleCacheAtomic