···11+# Security Model
22+33+## Trust Model
44+55+PLC Bundle creates an **immutable, cryptographically-chained** archive of PLC directory operations. However, the security depends on external verification.
66+77+### What the Chain Provides
88+99+✅ **Tamper Evidence**: Any modification breaks the chain
1010+✅ **Integrity Verification**: Detect corruption or tampering
1111+✅ **Reproducibility**: Anyone can recreate bundles from PLC
1212+✅ **Transparency**: All operations are publicly auditable
1313+1414+### What the Chain Does NOT Provide
1515+1616+❌ **Standalone Trust**: The chain alone isn't proof of authenticity
1717+❌ **Protection Against Total Replacement**: Someone controlling all bundles can rewrite history
+246-18
bundle/manager.go
···1010 "sort"
1111 "strconv"
1212 "strings"
1313+ "sync"
1314 "time"
14151516 "github.com/atscan/plcbundle/plc"
···173174 return bundle, nil
174175}
175176176176-// SaveBundle saves a bundle to disk and updates the index
177177func (m *Manager) SaveBundle(ctx context.Context, bundle *Bundle) error {
178178 if err := bundle.ValidateForSave(); err != nil {
179179 return fmt.Errorf("bundle validation failed: %w", err)
···194194 bundle.CompressedSize = compressedSize
195195 bundle.CreatedAt = time.Now().UTC()
196196197197+ // Calculate chain hash
198198+ prevBundle := m.index.GetLastBundle()
199199+ if prevBundle != nil {
200200+ bundle.PrevChainHash = prevBundle.ChainHash
201201+ bundle.PrevBundleHash = prevBundle.Hash
202202+ }
203203+204204+ bundle.ChainHash = m.operations.CalculateChainHash(bundle.PrevChainHash, bundle.Hash)
205205+197206 // Add to index
198207 m.index.AddBundle(bundle.ToMetadata())
199208···202211 return fmt.Errorf("failed to save index: %w", err)
203212 }
204213205205- m.logger.Printf("Saved bundle %06d (hash: %s...)", bundle.BundleNumber, bundle.Hash[:16])
214214+ m.logger.Printf("Saved bundle %06d (hash: %s..., chain: %s...)",
215215+ bundle.BundleNumber, bundle.Hash[:16], bundle.ChainHash[:16])
206216207217 // IMPORTANT: Clean up old mempool and create new one for next bundle
208218 oldMempoolFile := m.mempool.GetFilename()
···473483474484 result.ChainLength = len(bundles)
475485476476- // Verify each bundle
477486 for i, meta := range bundles {
478487 // Verify file hash
479488 vr, err := m.VerifyBundle(ctx, meta.BundleNumber)
480480- if err != nil {
481481- result.Error = fmt.Sprintf("Failed to verify bundle %d: %v", meta.BundleNumber, err)
482482- result.BrokenAt = meta.BundleNumber
483483- return result, nil
484484- }
485485-486486- if !vr.Valid {
489489+ if err != nil || !vr.Valid {
487490 result.Error = fmt.Sprintf("Bundle %d hash verification failed", meta.BundleNumber)
488491 result.BrokenAt = meta.BundleNumber
489492 return result, nil
490493 }
491494492492- // Verify chain link (prev_bundle_hash)
495495+ // Verify chain link
493496 if i > 0 {
494497 prevMeta := bundles[i-1]
498498+499499+ // Check prev_bundle_hash
495500 if meta.PrevBundleHash != prevMeta.Hash {
496501 result.Error = fmt.Sprintf("Chain broken at bundle %d: prev_hash mismatch", meta.BundleNumber)
497502 result.BrokenAt = meta.BundleNumber
498503 return result, nil
499504 }
505505+506506+ // Check chain_hash (NEW - stronger verification)
507507+ expectedChainHash := m.operations.CalculateChainHash(prevMeta.ChainHash, meta.Hash)
508508+ if meta.ChainHash != expectedChainHash {
509509+ result.Error = fmt.Sprintf("Chain broken at bundle %d: chain_hash mismatch", meta.BundleNumber)
510510+ result.BrokenAt = meta.BundleNumber
511511+ return result, nil
512512+ }
500513 }
501514502515 result.VerifiedBundles = append(result.VerifiedBundles, meta.BundleNumber)
···589602 compressedData, _ := os.ReadFile(path)
590603 compressedHash := m.operations.Hash(compressedData)
591604592592- // Determine cursor (would need previous bundle's end_time in real scenario)
593593- cursor := ""
605605+ // Get previous bundle's hashes for chain calculation
594606 prevHash := ""
607607+ prevChainHash := ""
595608 if num > 1 && len(newMetadata) > 0 {
596609 prevMeta := newMetadata[len(newMetadata)-1]
597597- cursor = prevMeta.EndTime.Format(time.RFC3339Nano)
598610 prevHash = prevMeta.Hash
611611+ prevChainHash = prevMeta.ChainHash
612612+ }
613613+614614+ // Calculate chain hash
615615+ chainHash := m.operations.CalculateChainHash(prevChainHash, uncompressedHash)
616616+617617+ // Determine cursor
618618+ cursor := ""
619619+ if num > 1 && prevHash != "" {
620620+ cursor = ops[0].CreatedAt.Format(time.RFC3339Nano)
599621 }
600622601623 meta := &BundleMetadata{
···605627 OperationCount: len(ops),
606628 DIDCount: len(dids),
607629 Hash: uncompressedHash,
630630+ ChainHash: chainHash,
608631 CompressedHash: compressedHash,
609632 CompressedSize: size,
610633 UncompressedSize: int64(len(jsonlData)),
611634 Cursor: cursor,
612635 PrevBundleHash: prevHash,
636636+ PrevChainHash: prevChainHash,
613637 CreatedAt: time.Now().UTC(),
614638 }
615639···635659 return result, nil
636660}
637661662662+// ScanDirectoryParallel scans the bundle directory in parallel and rebuilds the index
663663+func (m *Manager) ScanDirectoryParallel(workers int, progressCallback func(current, total int)) (*DirectoryScanResult, error) {
664664+ result := &DirectoryScanResult{
665665+ BundleDir: m.config.BundleDir,
666666+ }
667667+668668+ m.logger.Printf("Scanning directory (parallel, %d workers): %s", workers, m.config.BundleDir)
669669+670670+ // Find all bundle files
671671+ files, err := filepath.Glob(filepath.Join(m.config.BundleDir, "*.jsonl.zst"))
672672+ if err != nil {
673673+ return nil, fmt.Errorf("failed to scan directory: %w", err)
674674+ }
675675+676676+ if len(files) == 0 {
677677+ m.logger.Printf("No bundle files found")
678678+ return result, nil
679679+ }
680680+681681+ // Parse bundle numbers
682682+ var bundleNumbers []int
683683+ for _, file := range files {
684684+ base := filepath.Base(file)
685685+ numStr := strings.TrimSuffix(base, ".jsonl.zst")
686686+ num, err := strconv.Atoi(numStr)
687687+ if err != nil {
688688+ m.logger.Printf("Warning: skipping invalid filename: %s", base)
689689+ continue
690690+ }
691691+ bundleNumbers = append(bundleNumbers, num)
692692+ }
693693+694694+ sort.Ints(bundleNumbers)
695695+696696+ result.BundleCount = len(bundleNumbers)
697697+ if len(bundleNumbers) > 0 {
698698+ result.FirstBundle = bundleNumbers[0]
699699+ result.LastBundle = bundleNumbers[len(bundleNumbers)-1]
700700+ }
701701+702702+ // Find gaps
703703+ if len(bundleNumbers) > 1 {
704704+ for i := result.FirstBundle; i <= result.LastBundle; i++ {
705705+ found := false
706706+ for _, num := range bundleNumbers {
707707+ if num == i {
708708+ found = true
709709+ break
710710+ }
711711+ }
712712+ if !found {
713713+ result.MissingGaps = append(result.MissingGaps, i)
714714+ }
715715+ }
716716+ }
717717+718718+ m.logger.Printf("Found %d bundles (gaps: %d)", result.BundleCount, len(result.MissingGaps))
719719+720720+ // Process bundles in parallel
721721+ type bundleResult struct {
722722+ index int
723723+ meta *BundleMetadata
724724+ err error
725725+ }
726726+727727+ jobs := make(chan int, len(bundleNumbers))
728728+ results := make(chan bundleResult, len(bundleNumbers))
729729+730730+ // Start workers
731731+ var wg sync.WaitGroup
732732+ for w := 0; w < workers; w++ {
733733+ wg.Add(1)
734734+ go func() {
735735+ defer wg.Done()
736736+ for num := range jobs {
737737+ path := filepath.Join(m.config.BundleDir, fmt.Sprintf("%06d.jsonl.zst", num))
738738+739739+ // Load and process bundle
740740+ ops, err := m.operations.LoadBundle(path)
741741+ if err != nil {
742742+ results <- bundleResult{index: num, err: err}
743743+ continue
744744+ }
745745+746746+ // Calculate metadata (without chain hash yet)
747747+ meta, err := m.calculateBundleMetadataFast(num, path, ops)
748748+ if err != nil {
749749+ results <- bundleResult{index: num, err: err}
750750+ continue
751751+ }
752752+753753+ results <- bundleResult{index: num, meta: meta}
754754+ }
755755+ }()
756756+ }
757757+758758+ // Send jobs
759759+ for _, num := range bundleNumbers {
760760+ jobs <- num
761761+ }
762762+ close(jobs)
763763+764764+ // Wait for all workers to finish
765765+ go func() {
766766+ wg.Wait()
767767+ close(results)
768768+ }()
769769+770770+ // Collect results (in a map first, then sort)
771771+ metadataMap := make(map[int]*BundleMetadata)
772772+ var totalSize int64
773773+ var totalUncompressed int64 // NEW
774774+ processed := 0
775775+776776+ for result := range results {
777777+ processed++
778778+779779+ // Update progress
780780+ if progressCallback != nil {
781781+ progressCallback(processed, len(bundleNumbers))
782782+ }
783783+784784+ if result.err != nil {
785785+ m.logger.Printf("Warning: failed to process bundle %d: %v", result.index, result.err)
786786+ continue
787787+ }
788788+ metadataMap[result.index] = result.meta
789789+ totalSize += result.meta.CompressedSize
790790+ totalUncompressed += result.meta.UncompressedSize // NEW
791791+ }
792792+793793+ // Build ordered metadata slice and calculate chain hashes
794794+ var newMetadata []*BundleMetadata
795795+ var prevChainHash string
796796+797797+ for _, num := range bundleNumbers {
798798+ meta, ok := metadataMap[num]
799799+ if !ok {
800800+ continue // Skip failed bundles
801801+ }
802802+803803+ // Now calculate chain hash (must be done sequentially)
804804+ meta.ChainHash = m.operations.CalculateChainHash(prevChainHash, meta.Hash)
805805+ meta.PrevChainHash = prevChainHash
806806+807807+ // Update prev hashes for next iteration
808808+ if len(newMetadata) > 0 {
809809+ meta.PrevBundleHash = newMetadata[len(newMetadata)-1].Hash
810810+ }
811811+812812+ newMetadata = append(newMetadata, meta)
813813+ prevChainHash = meta.ChainHash
814814+ }
815815+816816+ result.TotalSize = totalSize
817817+ result.TotalUncompressed = totalUncompressed // NEW
818818+819819+ // Rebuild index
820820+ m.index.Rebuild(newMetadata)
821821+822822+ // Save index
823823+ if err := m.SaveIndex(); err != nil {
824824+ return nil, fmt.Errorf("failed to save index: %w", err)
825825+ }
826826+827827+ result.IndexUpdated = true
828828+829829+ m.logger.Printf("Index rebuilt with %d bundles", len(newMetadata))
830830+831831+ return result, nil
832832+}
833833+834834+// calculateBundleMetadataFast calculates metadata quickly (optimized for parallel processing)
835835+func (m *Manager) calculateBundleMetadataFast(bundleNumber int, path string, operations []plc.PLCOperation) (*BundleMetadata, error) {
836836+ // Calculate hashes efficiently (read file once)
837837+ compressedHash, compressedSize, uncompressedHash, uncompressedSize, err := m.operations.CalculateFileHashes(path)
838838+ if err != nil {
839839+ return nil, err
840840+ }
841841+842842+ // Extract unique DIDs (this is fast)
843843+ dids := m.operations.ExtractUniqueDIDs(operations)
844844+845845+ return &BundleMetadata{
846846+ BundleNumber: bundleNumber,
847847+ StartTime: operations[0].CreatedAt,
848848+ EndTime: operations[len(operations)-1].CreatedAt,
849849+ OperationCount: len(operations),
850850+ DIDCount: len(dids),
851851+ Hash: uncompressedHash,
852852+ CompressedHash: compressedHash,
853853+ CompressedSize: compressedSize,
854854+ UncompressedSize: uncompressedSize,
855855+ CreatedAt: time.Now().UTC(),
856856+ }, nil
857857+}
858858+638859// GetInfo returns information about the bundle manager
639860func (m *Manager) GetInfo() map[string]interface{} {
640861 stats := m.index.GetStats()
···706927 return nil, fmt.Errorf("bundle is empty")
707928 }
708929709709- // Get previous bundle hash from index
930930+ // Get previous bundle hashes from index
710931 prevHash := ""
932932+ prevChainHash := ""
711933 if bundleNumber > 1 {
712934 if prevMeta, err := m.index.GetBundle(bundleNumber - 1); err == nil {
713935 prevHash = prevMeta.Hash
936936+ prevChainHash = prevMeta.ChainHash
714937 }
715938 }
716939717717- // Calculate metadata
718718- meta, err := m.calculateBundleMetadata(bundleNumber, path, operations, prevHash)
940940+ // Calculate metadata (including chain hash)
941941+ meta, err := m.calculateBundleMetadata(bundleNumber, path, operations, prevHash, prevChainHash)
719942 if err != nil {
720943 return nil, fmt.Errorf("failed to calculate metadata: %w", err)
721944 }
···742965}
743966744967// calculateBundleMetadata calculates metadata for a bundle (internal helper)
745745-func (m *Manager) calculateBundleMetadata(bundleNumber int, path string, operations []plc.PLCOperation, prevBundleHash string) (*BundleMetadata, error) {
968968+func (m *Manager) calculateBundleMetadata(bundleNumber int, path string, operations []plc.PLCOperation, prevBundleHash string, prevChainHash string) (*BundleMetadata, error) {
746969 // Get file info
747970 info, err := os.Stat(path)
748971 if err != nil {
···763986 }
764987 compressedHash := m.operations.Hash(compressedData)
765988989989+ // Calculate chain hash
990990+ chainHash := m.operations.CalculateChainHash(prevChainHash, uncompressedHash)
991991+766992 // Determine cursor
767993 cursor := ""
768994 if bundleNumber > 1 && prevBundleHash != "" {
···7761002 OperationCount: len(operations),
7771003 DIDCount: len(dids),
7781004 Hash: uncompressedHash,
10051005+ ChainHash: chainHash,
7791006 CompressedHash: compressedHash,
7801007 CompressedSize: info.Size(),
7811008 UncompressedSize: uncompressedSize,
7821009 Cursor: cursor,
7831010 PrevBundleHash: prevBundleHash,
10111011+ PrevChainHash: prevChainHash,
7841012 CreatedAt: time.Now().UTC(),
7851013 }, nil
7861014}