// manifest.go — collector 本機 manifest 讀寫(SDD ingest-hash-trigger design §2)。 // manifest 只由 collector 寫入,雲端不得回寫(design §6-4)。 package main import ( "crypto/rand" "encoding/json" "errors" "fmt" "os" "path/filepath" ) // ManifestEntry 是單一檔案在 manifest 裡的狀態。 // - mtime 只決定「要不要重算 hash」(fast-path),絕不作為內容變更判準。 // - ingested_hash 與 content_hash 分開存=可表達「已變更但尚未成功 ingest」; // 上傳失敗不更新 ingested_hash,天然可重試。本階段(不接網路)永不寫 ingested_hash, // 留給之後的上傳/webhook task 在成功後回寫。 type ManifestEntry struct { ContentHash string `json:"content_hash"` Size int64 `json:"size"` Mtime int64 `json:"mtime"` IngestedHash string `json:"ingested_hash,omitempty"` IngestedAt int64 `json:"ingested_at,omitempty"` } // Manifest 對應一個被勾選的資料夾。 type Manifest struct { FolderID string `json:"folder_id"` Root string `json:"root"` Entries map[string]*ManifestEntry `json:"entries"` } // newUUID 產生 RFC 4122 v4 UUID(純 stdlib)。 func newUUID() (string, error) { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return "", err } b[6] = (b[6] & 0x0f) | 0x40 // version 4 b[8] = (b[8] & 0x3f) | 0x80 // variant 10 return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil } // LoadManifest 讀 manifest 檔;不存在=新資料夾,生成 folder_id。 func LoadManifest(path, root string) (*Manifest, error) { data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { id, uerr := newUUID() if uerr != nil { return nil, uerr } return &Manifest{FolderID: id, Root: root, Entries: map[string]*ManifestEntry{}}, nil } return nil, err } var m Manifest if err := json.Unmarshal(data, &m); err != nil { return nil, fmt.Errorf("manifest %s 解析失敗(不覆寫、直接報錯): %w", path, err) } if m.Entries == nil { m.Entries = map[string]*ManifestEntry{} } if m.FolderID == "" { id, uerr := newUUID() if uerr != nil { return nil, uerr } m.FolderID = id } if root != "" { m.Root = root } return &m, nil } // Save 原子寫入(temp + rename),避免掃描中斷留半個 JSON。 func (m *Manifest) Save(path string) error { data, err := json.MarshalIndent(m, "", " ") if err != nil { return err } dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return err } tmp, err := os.CreateTemp(dir, ".manifest-*.tmp") if err != nil { return err } tmpName := tmp.Name() if _, err := tmp.Write(append(data, '\n')); err != nil { tmp.Close() os.Remove(tmpName) return err } if err := tmp.Close(); err != nil { os.Remove(tmpName) return err } return os.Rename(tmpName, path) }