packages feed

canontra-0.1.0.0: src/Canontra/Repository/MerkleDAG.hs

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

{- |
Module      : Canontra.Repository.MerkleDAG
Description : Isomorphic Incremental Merkle DAG Repository Engine.

Constructs an explicit hierarchical Merkle Directed Acyclic Graph (DAG) for repository
file trees. Enables O(1) subtree skipping during incremental fingerprinting and
lightning-fast structural repository diffing by evaluating directory-level hash digests.
-}
module Canontra.Repository.MerkleDAG
  ( MerkleDAGNode (..)
  , buildMerkleDAG
  , merkleDAGRootHash
  , diffMerkleDAG
  , flattenMerkleDAG
  , dagNodeCount
  , hotUpdateMerkleDAG
  , removeMerkleDAGLeaf
  , updateMerkleDAGLeaf
  ) where

import Control.DeepSeq (NFData)
import qualified Crypto.Hash.SHA256 as SHA256
import qualified Data.ByteString as BS
import Data.List (groupBy, partition, sortOn)
import qualified Data.Map.Strict as Map
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import GHC.Generics (Generic)
import Text.Printf (printf)

import Canontra.Types (Fingerprint (..), FingerprintBundle (..))

-- | Node in the hierarchical Merkle DAG.
data MerkleDAGNode
  = MerkleFile !FilePath !FingerprintBundle
  | MerkleDirectory !FilePath !Fingerprint ![MerkleDAGNode]
  deriving stock (Eq, Show, Generic)
  deriving anyclass (NFData)

-- | Total number of nodes in the DAG.
dagNodeCount :: MerkleDAGNode -> Int
dagNodeCount (MerkleFile _ _) = 1
dagNodeCount (MerkleDirectory _ _ children) = 1 + sum (map dagNodeCount children)

-- | Get the root fingerprint of any DAG node.
merkleDAGRootHash :: MerkleDAGNode -> Fingerprint
merkleDAGRootHash (MerkleFile _ bundle) = f4Composite bundle
merkleDAGRootHash (MerkleDirectory _ fp _) = fp

-- | Build a hierarchical Merkle DAG from a list of sorted relative file paths and bundles.
buildMerkleDAG :: [(FilePath, FingerprintBundle)] -> MerkleDAGNode
buildMerkleDAG [] = MerkleDirectory "" (Fingerprint "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") []
buildMerkleDAG entries =
  let parsedEntries = [(splitPathSegments (normalizePosix p), p, b) | (p, b) <- entries]
  in buildDirDAG "" parsedEntries

-- | Build a directory DAG recursively by segment grouping.
buildDirDAG :: FilePath -> [([String], FilePath, FingerprintBundle)] -> MerkleDAGNode
buildDirDAG dirPath items =
  let directFiles = [MerkleFile origPath b | ([_fileName], origPath, b) <- items]
      nestedItems = [(seg, (rest, origPath, b)) | (seg : rest@(_ : _), origPath, b) <- items]
      groupedNested = groupBy (\(s1, _) (s2, _) -> s1 == s2) (sortOn fst nestedItems)
      subDirs =
        [ let segName = fst (head grp)
              subDirPath = if null dirPath then segName else dirPath ++ "/" ++ segName
              childItems = map snd grp
          in buildDirDAG subDirPath childItems
        | grp <- groupedNested
        ]
      allChildren = sortOn nodePath (directFiles ++ subDirs)
      dirDigest = computeDirDigest allChildren
  in MerkleDirectory dirPath dirDigest allChildren

nodePath :: MerkleDAGNode -> FilePath
nodePath (MerkleFile p _) = p
nodePath (MerkleDirectory p _ _) = p

-- | Compute directory digest by hashing sorted children (name + child digest).
computeDirDigest :: [MerkleDAGNode] -> Fingerprint
computeDirDigest children =
  let childBytes = mconcat
        [ let nameBS = TE.encodeUtf8 (T.pack (nodePath child))
              (Fingerprint digestTxt) = merkleDAGRootHash child
              digestBS = TE.encodeUtf8 digestTxt
          in nameBS <> ":" <> digestBS <> "\n"
        | child <- children
        ]
      digest = SHA256.hash childBytes
      hexStr = concatMap (printf "%02x") (BS.unpack digest)
  in Fingerprint (T.pack hexStr)

-- | O(k) structural diff between two Merkle DAGs, pruning identical subtrees instantly.
diffMerkleDAG :: MerkleDAGNode -> MerkleDAGNode -> [FilePath]
diffMerkleDAG n1 n2
  | merkleDAGRootHash n1 == merkleDAGRootHash n2 = []
  | otherwise = case (n1, n2) of
      (MerkleFile p1 _, MerkleFile p2 _) ->
        if p1 == p2 then [p1] else [p1, p2]
      (MerkleFile p1 _, MerkleDirectory _ _ _) -> [p1]
      (MerkleDirectory _ _ _, MerkleFile p2 _) -> [p2]
      (MerkleDirectory _ _ c1, MerkleDirectory _ _ c2) ->
        let m1 = Map.fromList [(nodePath c, c) | c <- c1]
            m2 = Map.fromList [(nodePath c, c) | c <- c2]
        in concatMap (\k -> case (Map.lookup k m1, Map.lookup k m2) of
            (Just child1, Just child2) -> diffMerkleDAG child1 child2
            (Just child1, Nothing)     -> map fst (flattenMerkleDAG child1)
            (Nothing, Just child2)     -> map fst (flattenMerkleDAG child2)
            (Nothing, Nothing)         -> []
          ) (Map.keys m1 ++ [k | k <- Map.keys m2, not (Map.member k m1)])

-- | Flatten all file entries in a DAG.
flattenMerkleDAG :: MerkleDAGNode -> [(FilePath, FingerprintBundle)]
flattenMerkleDAG (MerkleFile p b) = [(p, b)]
flattenMerkleDAG (MerkleDirectory _ _ children) = concatMap flattenMerkleDAG children

normalizePosix :: FilePath -> FilePath
normalizePosix = map (\c -> if c == '\\' then '/' else c)

splitPathSegments :: FilePath -> [String]
splitPathSegments p = filter (not . null) (splitOnChar '/' p)

splitOnChar :: Char -> String -> [String]
splitOnChar _ "" = []
splitOnChar delim str =
  let (before, rest) = break (== delim) str
  in before : case rest of
       [] -> []
       (_:after) -> splitOnChar delim after

-- | In-place hot mutation of a Merkle DAG leaf node in O(log N) / O(depth) time.
-- Traverses solely along the ancestor path to the root, updating directory digests,
-- leaving all sibling branches untouched. By Theorem 5, the resulting root hash is
-- strictly bit-identical to rebuilding the entire Merkle DAG from scratch.
hotUpdateMerkleDAG :: MerkleDAGNode -> FilePath -> FingerprintBundle -> MerkleDAGNode
hotUpdateMerkleDAG dag path bundle = updateMerkleDAGLeaf dag path (Just bundle)

-- | In-place removal of a Merkle DAG leaf node in O(log N) / O(depth) time.
removeMerkleDAGLeaf :: MerkleDAGNode -> FilePath -> MerkleDAGNode
removeMerkleDAGLeaf dag path = updateMerkleDAGLeaf dag path Nothing

-- | General leaf mutation (insert, update, or delete).
updateMerkleDAGLeaf :: MerkleDAGNode -> FilePath -> Maybe FingerprintBundle -> MerkleDAGNode
updateMerkleDAGLeaf root path mBundle =
  let normPath = normalizePosix path
      segments = splitPathSegments normPath
  in case root of
       MerkleFile p _ ->
         case mBundle of
           Just b  -> MerkleFile p b
           Nothing -> MerkleDirectory "" (Fingerprint "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") []
       MerkleDirectory dirPath _ children ->
         updateDir segments dirPath children
  where
    updateDir [] curDirPath children =
      let newDigest = computeDirDigest children
      in MerkleDirectory curDirPath newDigest children

    updateDir [fileName] curDirPath children =
      let origPath = if null curDirPath then fileName else curDirPath ++ "/" ++ fileName
          newChildren = case mBundle of
            Just b ->
              let updatedFile = MerkleFile origPath b
                  otherChildren = filter (\c -> nodePath c /= origPath) children
              in sortOn nodePath (updatedFile : otherChildren)
            Nothing ->
              filter (\c -> nodePath c /= origPath) children
          newDigest = computeDirDigest newChildren
      in MerkleDirectory curDirPath newDigest newChildren

    updateDir (seg : restSegs) curDirPath children =
      let subDirPath = if null curDirPath then seg else curDirPath ++ "/" ++ seg
          (existingSubDir, otherChildren) = partition (\c -> nodePath c == subDirPath) children
          updatedSubDir = case existingSubDir of
            (MerkleDirectory _ _ subChildren : _) ->
              updateDir restSegs subDirPath subChildren
            _ ->
              case mBundle of
                Just _  -> updateDir restSegs subDirPath []
                Nothing -> MerkleDirectory subDirPath (Fingerprint "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") []
          newChildren = case updatedSubDir of
            MerkleDirectory _ _ [] | mBundle == Nothing ->
              otherChildren
            _ ->
              sortOn nodePath (updatedSubDir : otherChildren)
          newDigest = computeDirDigest newChildren
      in MerkleDirectory curDirPath newDigest newChildren