Locked files (--lock): repo-authoritative entries. Checkpoint skips them (preserves repo version). Status reports "drifted" instead of "modified". Restore always overwrites if hash differs, no prompt. Use case: system-managed files the OS overwrites. Directory-only entries (--dir): track directory itself without recursing. Restore ensures directory exists with correct permissions. Use case: directories that must exist but contents are managed elsewhere. Add refactored to use AddOptions struct (Encrypt, Lock, DirOnly) instead of variadic bools. Proto: ManifestEntry gains locked field. convert.go updated. 7 new tests. ARCHITECTURE.md and README.md updated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
117 lines
3.4 KiB
Go
117 lines
3.4 KiB
Go
package manifest
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Entry represents a single tracked file, directory, or symlink.
|
|
type Entry struct {
|
|
Path string `yaml:"path"`
|
|
Hash string `yaml:"hash,omitempty"`
|
|
PlaintextHash string `yaml:"plaintext_hash,omitempty"`
|
|
Encrypted bool `yaml:"encrypted,omitempty"`
|
|
Locked bool `yaml:"locked,omitempty"`
|
|
Type string `yaml:"type"`
|
|
Mode string `yaml:"mode,omitempty"`
|
|
Target string `yaml:"target,omitempty"`
|
|
Updated time.Time `yaml:"updated"`
|
|
}
|
|
|
|
// KekSlot describes a single KEK source that can unwrap the DEK.
|
|
type KekSlot struct {
|
|
Type string `yaml:"type"` // "passphrase" or "fido2"
|
|
Argon2Time int `yaml:"argon2_time,omitempty"` // passphrase only
|
|
Argon2Memory int `yaml:"argon2_memory,omitempty"` // passphrase only (KiB)
|
|
Argon2Threads int `yaml:"argon2_threads,omitempty"` // passphrase only
|
|
CredentialID string `yaml:"credential_id,omitempty"` // fido2 only (base64)
|
|
Salt string `yaml:"salt"` // base64-encoded
|
|
WrappedDEK string `yaml:"wrapped_dek"` // base64-encoded
|
|
}
|
|
|
|
// Encryption holds the encryption configuration embedded in the manifest.
|
|
type Encryption struct {
|
|
Algorithm string `yaml:"algorithm"`
|
|
KekSlots map[string]*KekSlot `yaml:"kek_slots"`
|
|
}
|
|
|
|
// Manifest is the top-level manifest describing all tracked entries.
|
|
type Manifest struct {
|
|
Version int `yaml:"version"`
|
|
Created time.Time `yaml:"created"`
|
|
Updated time.Time `yaml:"updated"`
|
|
Message string `yaml:"message,omitempty"`
|
|
Files []Entry `yaml:"files"`
|
|
Encryption *Encryption `yaml:"encryption,omitempty"`
|
|
}
|
|
|
|
// New creates a new empty manifest with Version 1 and timestamps set to now.
|
|
func New() *Manifest {
|
|
return NewWithTime(time.Now().UTC())
|
|
}
|
|
|
|
// NewWithTime creates a new empty manifest with the given timestamp.
|
|
func NewWithTime(now time.Time) *Manifest {
|
|
return &Manifest{
|
|
Version: 1,
|
|
Created: now,
|
|
Updated: now,
|
|
Files: []Entry{},
|
|
}
|
|
}
|
|
|
|
// Load reads a manifest from the given file path.
|
|
func Load(path string) (*Manifest, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading manifest: %w", err)
|
|
}
|
|
|
|
var m Manifest
|
|
if err := yaml.Unmarshal(data, &m); err != nil {
|
|
return nil, fmt.Errorf("parsing manifest: %w", err)
|
|
}
|
|
|
|
return &m, nil
|
|
}
|
|
|
|
// Save writes the manifest to the given file path using an atomic
|
|
// write: data is marshalled to a temporary file in the same directory,
|
|
// then renamed to the target path. This prevents corruption if the
|
|
// process crashes mid-write.
|
|
func (m *Manifest) Save(path string) error {
|
|
data, err := yaml.Marshal(m)
|
|
if err != nil {
|
|
return fmt.Errorf("marshalling manifest: %w", err)
|
|
}
|
|
|
|
dir := filepath.Dir(path)
|
|
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp.*")
|
|
if err != nil {
|
|
return fmt.Errorf("creating temp file: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
_ = os.Remove(tmpName)
|
|
return fmt.Errorf("writing temp file: %w", err)
|
|
}
|
|
|
|
if err := tmp.Close(); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return fmt.Errorf("closing temp file: %w", err)
|
|
}
|
|
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
_ = os.Remove(tmpName)
|
|
return fmt.Errorf("renaming temp file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|