fasttree-sys 0.2.0

Surowe bindingi FFI (extern "C") do biblioteki FastTree (Nim). Nie używaj bezpośrednio — patrz crate `fasttree`.
import std/[os, algorithm, json, options, tables, sets]
import ./hashing
import ./chunker
import ./store

export hashing  # Hash, `$`, hashFromHex itd. — część publicznego API manifestu

const FastTreeFormatVersion* = 1
  ## Wersja formatu manifestu na dysku. ZASADA: to pole rośnie WYŁĄCZNIE przy
  ## zmianie niekompatybilnej wstecz (np. inny layout pól, inny sposób
  ## liczenia root-hash). Dodanie nowego opcjonalnego pola JSON nie wymaga
  ## bumpa. Każdy czytelnik (Nim, przyszły Rust wrapper) MUSI odrzucić
  ## manifest z formatVersion > FastTreeFormatVersion zamiast zgadywać.
  ##
  ## Historia:
  ##   1 (obecna) — pierwszy zamrożony format: entries[] posortowane po path,
  ##     root = hashChildren(entryHash(e) for e in entries), entryHash(e) =
  ##     hashChildren([hash(path|mode), chunks...]). chunker{min,avg,max}
  ##     oraz hashAlgo zapisane informacyjnie (nie wchodzą do liczenia root-hash).

type
  FileMode* = enum
    fmRegular, fmExecutable, fmSymlink, fmDirectory

  FileEntry* = object
    path*: string             # ścieżka względna w drzewie
    mode*: FileMode
    size*: int64
    chunks*: seq[Hash]        # puste dla katalogów/symlinków
    linkTarget*: string       # tylko dla symlinków

  ChunkerInfo* = object
    ## Kopia parametrów chunkera użytych przy budowie manifestu — zapisana
    ## informacyjnie. Nie wpływa na root-hash (root liczony z już powstałych
    ## chunków), ale bez tego nie da się odtworzyć/zweryfikować, dlaczego
    ## dwa manifesty tej samej zawartości mają różne granice chunków.
    minSize*, avgSize*, maxSize*: int

  Manifest* = object
    formatVersion*: int             # patrz FastTreeFormatVersion powyżej
    hashAlgo*: string               # "blake3" | "sha256-fallback" — patrz hashing.AlgoName
    chunker*: ChunkerInfo
    root*: Hash                     # hash calego drzewa (dla szybkiego diff)
    entries*: seq[FileEntry]        # posortowane po path

proc modeToStr(m: FileMode): string =
  case m
  of fmRegular: "regular"
  of fmExecutable: "executable"
  of fmSymlink: "symlink"
  of fmDirectory: "directory"

proc strToMode(s: string): FileMode =
  case s
  of "regular": fmRegular
  of "executable": fmExecutable
  of "symlink": fmSymlink
  of "directory": fmDirectory
  else: raise newException(ValueError, "nieznany tryb pliku: " & s)

proc entryHash(e: FileEntry): Hash =
  ## Hash pojedynczego wpisu = hash(path | mode | chunks...) — pozwala
  ## wykryć zmianę metadanych nawet bez zmiany zawartości.
  var parts: seq[Hash] = @[hashBytes(cast[seq[byte]](e.path & "|" & modeToStr(e.mode)))]
  parts.add e.chunks
  hashChildren(parts)

proc buildManifest*(sourceDir: string, s: Store, cfg: ChunkerConfig = DefaultConfig): Manifest =
  ## Przechodzi po katalogu źródłowym, dzieli pliki na chunki (FastCDC),
  ## zapisuje chunki do store'a (dedup automatyczny — identyczne chunki
  ## między plikami/wersjami trafiają do tego samego obiektu) i buduje manifest.
  var entries: seq[FileEntry] = @[]

  for path in walkDirRec(sourceDir, yieldFilter = {pcFile, pcDir, pcLinkToFile, pcLinkToDir}):
    let rel = path.relativePath(sourceDir)
    if rel == ".": continue
    let info = getFileInfo(path, followSymlink = false)
    case info.kind
    of pcDir:
      entries.add FileEntry(path: rel, mode: fmDirectory, size: 0)
    of pcLinkToFile, pcLinkToDir:
      entries.add FileEntry(path: rel, mode: fmSymlink, linkTarget: expandSymlink(path))
    else:
      let content = readFile(path)
      var buf = newSeq[byte](content.len)
      if content.len > 0:
        copyMem(addr buf[0], unsafeAddr content[0], content.len)
      let chunks = chunkBuffer(buf, cfg)
      var hashes: seq[Hash] = @[]
      for c in chunks:
        hashes.add s.put(c.data)
      let isExec = (info.permissions.contains(fpUserExec))
      entries.add FileEntry(
        path: rel,
        mode: (if isExec: fmExecutable else: fmRegular),
        size: info.size,
        chunks: hashes)

  entries.sort(proc(a, b: FileEntry): int = cmp(a.path, b.path))

  var entryHashes: seq[Hash] = @[]
  for e in entries:
    entryHashes.add entryHash(e)

  Manifest(
    formatVersion: FastTreeFormatVersion,
    hashAlgo: AlgoName,
    chunker: ChunkerInfo(minSize: cfg.minSize, avgSize: cfg.avgSize, maxSize: cfg.maxSize),
    root: hashChildren(entryHashes),
    entries: entries)

proc toJson*(m: Manifest): JsonNode =
  result = %*{
    "formatVersion": m.formatVersion,
    "hashAlgo": m.hashAlgo,
    "chunker": {"minSize": m.chunker.minSize, "avgSize": m.chunker.avgSize, "maxSize": m.chunker.maxSize},
    "root": $m.root,
    "entries": newJArray()
  }
  for e in m.entries:
    var chunksJson = newJArray()
    for c in e.chunks: chunksJson.add %($c)
    result["entries"].add %*{
      "path": e.path,
      "mode": modeToStr(e.mode),
      "size": e.size,
      "chunks": chunksJson,
      "linkTarget": e.linkTarget
    }

type ManifestFormatError* = object of CatchableError

proc manifestFromJson*(j: JsonNode): Manifest =
  ## Reguła kompatybilności: manifest z formatVersion nieznanym tej wersji
  ## FastTree (nowszym niż FastTreeFormatVersion) jest odrzucany jawnym
  ## błędem, zamiast próby "zgadywania" pól, które mogły zmienić znaczenie.
  ## Brak pola formatVersion (bardzo stare/obce pliki) traktujemy jako 0.
  let formatVersion = j{"formatVersion"}.getInt(0)
  if formatVersion > FastTreeFormatVersion:
    raise newException(ManifestFormatError,
      "Manifest ma formatVersion=" & $formatVersion &
      ", ta wersja FastTree obsługuje do " & $FastTreeFormatVersion &
      ". Zaktualizuj fasttree.")
  if formatVersion == 0:
    raise newException(ManifestFormatError,
      "Manifest bez pola 'formatVersion' — nierozpoznany/przedwersyjny format.")

  let hashAlgo = j{"hashAlgo"}.getStr("")
  if hashAlgo.len > 0 and hashAlgo != AlgoName:
    # Nie jest to twardy błąd (root-hash i tak się nie zgodzi przy realnym
    # mismatchu treści), ale ostrzeżenie ratuje przed cichym pomieszaniem
    # repozytoriów zbudowanych blake3 vs fallback sha256.
    stderr.writeLine("[fasttree] UWAGA: manifest policzony algorytmem '" &
      hashAlgo & "', bieżąca kompilacja używa '" & AlgoName & "'.")

  var chunkerInfo = ChunkerInfo()
  if j.hasKey("chunker"):
    let jc = j["chunker"]
    chunkerInfo = ChunkerInfo(
      minSize: jc{"minSize"}.getInt(DefaultConfig.minSize),
      avgSize: jc{"avgSize"}.getInt(DefaultConfig.avgSize),
      maxSize: jc{"maxSize"}.getInt(DefaultConfig.maxSize))

  var entries: seq[FileEntry] = @[]
  for je in j["entries"]:
    var chunks: seq[Hash] = @[]
    for jc in je["chunks"]:
      chunks.add hashFromHex(jc.getStr)
    entries.add FileEntry(
      path: je["path"].getStr,
      mode: strToMode(je["mode"].getStr),
      size: je["size"].getBiggestInt,
      chunks: chunks,
      linkTarget: je{"linkTarget"}.getStr(""))
  Manifest(
    formatVersion: formatVersion,
    hashAlgo: (if hashAlgo.len > 0: hashAlgo else: AlgoName),
    chunker: chunkerInfo,
    root: hashFromHex(j["root"].getStr),
    entries: entries)

type
  DiffKind* = enum dkAdded, dkRemoved, dkModified
  DiffEntry* = object
    kind*: DiffKind
    path*: string
    oldChunks*, newChunks*: int   # do statystyki: ile chunków zmienionych
    reusedChunks*: int            # ile chunków wspólnych ze starą wersją (dedup w akcji)

proc diff*(old, new: Manifest): seq[DiffEntry] =
  ## Porównanie na poziomie plików i chunków — to jest "łatwy wywód
  ## różnicowy" z Twojego opisu: zamiast diffować bajty, porównujemy
  ## listy skrótów, więc koszt jest O(liczba wpisów), nie O(rozmiar danych).
  result = @[]
  var oldByPath = initTable[string, FileEntry]()
  for e in old.entries: oldByPath[e.path] = e
  var seen = initHashSet[string]()

  for ne in new.entries:
    seen.incl ne.path
    if ne.path notin oldByPath:
      result.add DiffEntry(kind: dkAdded, path: ne.path, newChunks: ne.chunks.len)
    else:
      let oe = oldByPath[ne.path]
      if entryHash(oe) != entryHash(ne):
        var oldSet = initHashSet[string]()
        for c in oe.chunks: oldSet.incl $c
        var reused = 0
        for c in ne.chunks:
          if $c in oldSet: inc reused
        result.add DiffEntry(kind: dkModified, path: ne.path,
                              oldChunks: oe.chunks.len, newChunks: ne.chunks.len,
                              reusedChunks: reused)

  for oe in old.entries:
    if oe.path notin seen:
      result.add DiffEntry(kind: dkRemoved, path: oe.path, oldChunks: oe.chunks.len)