1752ef05a6
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.
105 lines
2.9 KiB
Go
105 lines
2.9 KiB
Go
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[:])
|
|
}
|