package main import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "path" "strings" "time" ) // FileEntry is one row in the per-user manifest — the truth for what a file // looked like at snapshot time. Mtime is the file's last-modified at the // client's push moment, NOT the upload time on the server. Used as the // authoritative timestamp during conflict resolution. type FileEntry struct { SHA256 string `json:"sha256"` Size int64 `json:"size"` Mtime time.Time `json:"mtime"` } // Manifest is one snapshot's per-file map. Persisted as .manifest.json // next to each tarball, plus a copy at user/manifest.json for the latest snapshot. type Manifest struct { SnapshotID string `json:"snapshot_id"` CreatedAt time.Time `json:"created_at"` Files map[string]FileEntry `json:"files"` } // Validate checks structural invariants. Returns the first violation found. func (m *Manifest) Validate() error { if m.SnapshotID == "" { return errors.New("snapshot_id is empty") } if m.CreatedAt.IsZero() { return errors.New("created_at is zero") } for p, e := range m.Files { if err := validatePath(p); err != nil { return fmt.Errorf("file path %q: %w", p, err) } if len(e.SHA256) != 64 { return fmt.Errorf("file %q: sha256 must be 64 hex chars, got %d", p, len(e.SHA256)) } if _, err := hex.DecodeString(e.SHA256); err != nil { return fmt.Errorf("file %q: sha256 not hex: %w", p, err) } if e.Size < 0 { return fmt.Errorf("file %q: negative size %d", p, e.Size) } if e.Mtime.IsZero() { return fmt.Errorf("file %q: mtime is zero", p) } } return nil } // validatePath rejects empty paths, absolute paths, and any path component of // ".." or "." — defense against tar entries trying to escape the user's // scope. Forward-slash POSIX paths only. func validatePath(p string) error { if p == "" { return errors.New("empty") } if strings.HasPrefix(p, "/") { return errors.New("must be relative") } if strings.ContainsRune(p, '\\') { return errors.New("backslash not allowed; use forward slashes") } cleaned := path.Clean(p) if cleaned != p { return fmt.Errorf("not in canonical form (got %q, clean %q)", p, cleaned) } for _, part := range strings.Split(p, "/") { if part == "" || part == "." || part == ".." { return fmt.Errorf("path component %q not allowed", part) } } return nil } // ReadManifest decodes a manifest from r. Convenience for handlers + tests. func ReadManifest(r io.Reader) (*Manifest, error) { var m Manifest dec := json.NewDecoder(r) dec.DisallowUnknownFields() if err := dec.Decode(&m); err != nil { return nil, fmt.Errorf("decode manifest: %w", err) } if err := m.Validate(); err != nil { return nil, fmt.Errorf("invalid manifest: %w", err) } return &m, nil } // HashBytes is a small helper for tests; returns hex sha256. func HashBytes(b []byte) string { sum := sha256.Sum256(b) return hex.EncodeToString(sum[:]) }