initial: Steam-Cloud-style per-user state sync skeleton
CI / validate (push) Successful in 26s
CI / docker (push) Failing after 8s

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:
2026-06-02 18:52:25 +02:00
commit 1752ef05a6
16 changed files with 2039 additions and 0 deletions
+104
View File
@@ -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[:])
}