initial: Steam-Cloud-style per-user state sync skeleton
HTTP API + on-disk storage + auth-service token verification + dev mode. 31 tests pass, vet clean. See DESIGN.md for the architecture and README.md for the operator surface. Pending: pg-backed per-user quota override, snapshot retention / blob GC, tarball-vs-manifest content cross-check, end-to-end deploy on john.
This commit is contained in:
+104
@@ -0,0 +1,104 @@
|
||||
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 <snapshot_id>.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[:])
|
||||
}
|
||||
Reference in New Issue
Block a user