| 1 | package blob |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "path" |
| 9 | "strings" |
| 10 | "time" |
| 11 | "unicode" |
| 12 | "unicode/utf8" |
| 13 | ) |
| 14 | |
| 15 | const maxClaimMetadataBytes = 4096 |
| 16 | |
| 17 | var ( |
| 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. |
| 26 | func (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 | |
| 64 | func 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 | |
| 74 | func 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 | |
| 93 | func hasControl(value string) bool { |
| 94 | for _, r := range value { |
| 95 | if unicode.IsControl(r) { |
| 96 | return true |
| 97 | } |
| 98 | } |
| 99 | return false |
| 100 | } |
| 101 | |
| 102 | func 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 | |
| 121 | func (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 | |
| 134 | func (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 | |
| 153 | func nowUnix() int64 { return time.Now().UTC().Unix() } |