tinyrelayfile
tinyrelay/internal/blob/metadata.go/

internal/blob/metadata.go raw
1package blob
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "path"
9 "strings"
10 "time"
11 "unicode"
12 "unicode/utf8"
13)
14
15const maxClaimMetadataBytes = 4096
16
17var (
18 errInvalidClaimName = errors.New("invalid: filename must be a single path component")
19 errInvalidClaimPath = errors.New("invalid: upload path must be a safe relative path")
20 errMismatchedClaimName = errors.New("invalid: filename must match the upload path basename")
21)
22
23// SetClaimMetadata records logical file metadata without changing blob bytes.
24// It is used after an encrypted root is uploaded; decryption keys never enter
25// relay storage. The caller must already have authenticated the uploader.
26func (s *Service) SetClaimMetadata(ctx context.Context, uploader, sha, name, rawPath, purpose, typ string, size int64) error {
27 if !shaPattern.MatchString(sha) || uploader == "" {
28 return errors.New("invalid: blob metadata target")
29 }
30 if purpose != "file" && purpose != "chunk" {
31 return errors.New("invalid: blob metadata purpose")
32 }
33 if size < 0 || size > 1<<40 {
34 return errors.New("invalid: logical file size")
35 }
36 name, rawPath, err := claimMetadata(name, rawPath)
37 if err != nil {
38 return err
39 }
40 if purpose == "file" && name == "" {
41 return errors.New("invalid: logical file name is required")
42 }
43 if typ == "" {
44 typ = "application/octet-stream"
45 }
46 if len(typ) > 256 || !strings.Contains(typ, "/") || hasControl(typ) {
47 return errors.New("invalid: logical file type")
48 }
49 return s.store.WithTx(ctx, func(tx *sql.Tx) error {
50 var found int
51 if err := tx.QueryRowContext(ctx, "SELECT 1 FROM blob_claims WHERE sha256=? AND uploader=?", sha, uploader).Scan(&found); err != nil {
52 if errors.Is(err, sql.ErrNoRows) {
53 return errors.New("not found: uploader blob claim")
54 }
55 return err
56 }
57 _, err := tx.ExecContext(ctx, `INSERT INTO blob_claim_metadata(sha256,uploader,name,path,purpose,logical_size,logical_type,updated_at)
58 VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(sha256,uploader,path) DO UPDATE SET name=excluded.name,purpose=excluded.purpose,logical_size=excluded.logical_size,logical_type=excluded.logical_type,updated_at=excluded.updated_at`,
59 sha, uploader, name, rawPath, purpose, size, typ, nowUnix())
60 return err
61 })
62}
63
64func validateClaimName(name string) error {
65 if name == "" {
66 return nil
67 }
68 if !utf8.ValidString(name) || len(name) > maxClaimMetadataBytes || strings.ContainsAny(name, "/\\") || name == "." || name == ".." || hasControl(name) {
69 return errInvalidClaimName
70 }
71 return nil
72}
73
74func validateClaimPath(raw string) (string, error) {
75 if raw == "" {
76 return "", nil
77 }
78 if !utf8.ValidString(raw) || len(raw) > maxClaimMetadataBytes || strings.Contains(raw, "\\") || strings.HasPrefix(raw, "/") || strings.HasSuffix(raw, "/") || hasControl(raw) {
79 return "", errInvalidClaimPath
80 }
81 clean := path.Clean(raw)
82 if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || clean != raw {
83 return "", errInvalidClaimPath
84 }
85 for _, part := range strings.Split(raw, "/") {
86 if part == "" || part == "." || part == ".." {
87 return "", errInvalidClaimPath
88 }
89 }
90 return clean, nil
91}
92
93func hasControl(value string) bool {
94 for _, r := range value {
95 if unicode.IsControl(r) {
96 return true
97 }
98 }
99 return false
100}
101
102func claimMetadata(name, rawPath string) (string, string, error) {
103 if err := validateClaimName(name); err != nil {
104 return "", "", err
105 }
106 cleanPath, err := validateClaimPath(rawPath)
107 if err != nil {
108 return "", "", err
109 }
110 if name == "" && cleanPath != "" {
111 name = path.Base(cleanPath)
112 }
113 if cleanPath == "" {
114 cleanPath = name
115 } else if name != "" && path.Base(cleanPath) != name {
116 return "", "", errMismatchedClaimName
117 }
118 return name, cleanPath, nil
119}
120
121func (s *Service) saveClaimMetadata(ctx context.Context, tx *sql.Tx, sha, uploader, name, metadataPath, purpose string) error {
122 if uploader == "" || (name == "" && metadataPath == "" && purpose == "") {
123 return nil
124 }
125 _, err := tx.ExecContext(ctx, `INSERT INTO blob_claim_metadata(sha256,uploader,name,path,purpose,updated_at)
126 VALUES(?,?,?,?,?,?) ON CONFLICT(sha256,uploader,path) DO UPDATE SET name=excluded.name,purpose=excluded.purpose,updated_at=excluded.updated_at`,
127 sha, uploader, name, metadataPath, purpose, nowUnix())
128 if err != nil {
129 return fmt.Errorf("record blob claim metadata: %w", err)
130 }
131 return nil
132}
133
134func (s *Service) ListClaimMetadata(ctx context.Context, uploader string) ([]BlobClaimMetadata, error) {
135 rows, err := s.store.DB().QueryContext(ctx, `SELECT c.sha256,c.uploader,COALESCE(m.name,''),COALESCE(m.path,''),COALESCE(m.purpose,''),COALESCE(m.logical_size,0),COALESCE(m.logical_type,''),COALESCE(m.updated_at,c.claimed_at)
136 FROM blob_claims c LEFT JOIN blob_claim_metadata m ON m.sha256=c.sha256 AND m.uploader=c.uploader
137 JOIN blobs b ON b.sha256=c.sha256 WHERE c.uploader=? ORDER BY COALESCE(m.updated_at,c.claimed_at) DESC,c.sha256 DESC`, uploader)
138 if err != nil {
139 return nil, fmt.Errorf("list blob claim metadata: %w", err)
140 }
141 defer rows.Close()
142 entries := make([]BlobClaimMetadata, 0)
143 for rows.Next() {
144 var entry BlobClaimMetadata
145 if err := rows.Scan(&entry.SHA256, &entry.Uploader, &entry.Name, &entry.Path, &entry.Purpose, &entry.Size, &entry.Type, &entry.UpdatedAt); err != nil {
146 return nil, fmt.Errorf("scan blob claim metadata: %w", err)
147 }
148 entries = append(entries, entry)
149 }
150 return entries, rows.Err()
151}
152
153func nowUnix() int64 { return time.Now().UTC().Unix() }

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs