A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

add backlinks to tags

evan.jarrett.net ce7160cd 5d520071

verified
+371 -29
+7 -2
lexicons/io/atcr/tag.json
··· 8 8 "key": "any", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["repository", "tag", "manifestDigest", "createdAt"], 11 + "required": ["repository", "tag", "createdAt"], 12 12 "properties": { 13 13 "repository": { 14 14 "type": "string", ··· 20 20 "description": "Tag name (e.g., 'latest', 'v1.0.0', '12-slim')", 21 21 "maxLength": 128 22 22 }, 23 + "manifest": { 24 + "type": "string", 25 + "format": "at-uri", 26 + "description": "AT-URI of the manifest this tag points to (e.g., 'at://did:plc:xyz/io.atcr.manifest/abc123'). Preferred over manifestDigest for new records." 27 + }, 23 28 "manifestDigest": { 24 29 "type": "string", 25 - "description": "Digest of the manifest this tag points to (e.g., 'sha256:...')" 30 + "description": "DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead." 26 31 }, 27 32 "createdAt": { 28 33 "type": "string",
+7 -1
pkg/appview/jetstream/backfill.go
··· 400 400 return fmt.Errorf("failed to unmarshal tag: %w", err) 401 401 } 402 402 403 + // Extract digest from tag record (tries manifest field first, falls back to manifestDigest) 404 + manifestDigest, err := tagRecord.GetManifestDigest() 405 + if err != nil { 406 + return fmt.Errorf("failed to get manifest digest from tag record: %w", err) 407 + } 408 + 403 409 // Insert or update tag 404 410 return db.UpsertTag(b.db, &db.Tag{ 405 411 DID: did, 406 412 Repository: tagRecord.Repository, 407 413 Tag: tagRecord.Tag, 408 - Digest: tagRecord.ManifestDigest, 414 + Digest: manifestDigest, 409 415 CreatedAt: tagRecord.UpdatedAt, 410 416 }) 411 417 }
+7 -1
pkg/appview/jetstream/worker.go
··· 560 560 return nil 561 561 } 562 562 563 + // Extract digest from tag record (tries manifest field first, falls back to manifestDigest) 564 + manifestDigest, err := tagRecord.GetManifestDigest() 565 + if err != nil { 566 + return fmt.Errorf("failed to get manifest digest from tag record: %w", err) 567 + } 568 + 563 569 // Insert or update tag 564 570 return db.UpsertTag(w.db, &db.Tag{ 565 571 DID: commit.DID, 566 572 Repository: tagRecord.Repository, 567 573 Tag: tagRecord.Tag, 568 - Digest: tagRecord.ManifestDigest, 574 + Digest: manifestDigest, 569 575 CreatedAt: tagRecord.UpdatedAt, 570 576 }) 571 577 }
+1 -1
pkg/appview/middleware/registry.go
··· 310 310 HoldDID: holdDID, 311 311 PDSEndpoint: pdsEndpoint, 312 312 Repository: repositoryName, 313 - ServiceToken: serviceToken, // Cached service token from middleware validation 313 + ServiceToken: serviceToken, // Cached service token from middleware validation 314 314 ATProtoClient: atprotoClient, 315 315 Database: nr.database, 316 316 Authorizer: nr.authorizer,
+5
pkg/atproto/client.go
··· 661 661 662 662 return &didDoc, nil 663 663 } 664 + 665 + // DID returns the DID associated with this client 666 + func (c *Client) DID() string { 667 + return c.did 668 + }
+80 -9
pkg/atproto/lexicon.go
··· 241 241 // Tag is the tag name (e.g., "latest", "v1.0.0") 242 242 Tag string `json:"tag"` 243 243 244 - // ManifestDigest is the digest of the manifest this tag points to 245 - ManifestDigest string `json:"manifestDigest"` 244 + // Manifest is the AT-URI of the manifest this tag points to 245 + // Format: at://did:plc:xyz/io.atcr.manifest/abc123 246 + // Preferred over ManifestDigest for new records 247 + Manifest string `json:"manifest,omitempty"` 248 + 249 + // ManifestDigest is the digest of the manifest this tag points to (DEPRECATED) 250 + // Kept for backward compatibility with old records 251 + // New records should use Manifest field instead 252 + ManifestDigest string `json:"manifestDigest,omitempty"` 246 253 247 254 // UpdatedAt timestamp 248 255 UpdatedAt time.Time `json:"updatedAt"` 249 256 } 250 257 251 - // NewTagRecord creates a new tag record 252 - func NewTagRecord(repository, tag, manifestDigest string) *TagRecord { 258 + // NewTagRecord creates a new tag record with manifest AT-URI 259 + // did: The DID of the user (e.g., "did:plc:xyz123") 260 + // repository: The repository name (e.g., "myapp") 261 + // tag: The tag name (e.g., "latest", "v1.0.0") 262 + // manifestDigest: The manifest digest (e.g., "sha256:abc123...") 263 + func NewTagRecord(did, repository, tag, manifestDigest string) *TagRecord { 264 + // Build AT-URI for the manifest 265 + // Format: at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix> 266 + manifestURI := BuildManifestURI(did, manifestDigest) 267 + 253 268 return &TagRecord{ 254 - Type: TagCollection, 255 - Repository: repository, 256 - Tag: tag, 257 - ManifestDigest: manifestDigest, 258 - UpdatedAt: time.Now(), 269 + Type: TagCollection, 270 + Repository: repository, 271 + Tag: tag, 272 + Manifest: manifestURI, 273 + // Note: ManifestDigest is not set for new records (only for backward compat with old records) 274 + UpdatedAt: time.Now(), 259 275 } 260 276 } 261 277 ··· 410 426 // isDID checks if a string is a DID (starts with "did:") 411 427 func isDID(s string) bool { 412 428 return len(s) > 4 && s[:4] == "did:" 429 + } 430 + 431 + // BuildManifestURI creates an AT-URI for a manifest record 432 + // did: The DID of the user (e.g., "did:plc:xyz123") 433 + // manifestDigest: The manifest digest (e.g., "sha256:abc123...") 434 + // Returns: AT-URI in format "at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>" 435 + func BuildManifestURI(did, manifestDigest string) string { 436 + // Remove the "sha256:" prefix from the digest to get the rkey 437 + rkey := strings.TrimPrefix(manifestDigest, "sha256:") 438 + return fmt.Sprintf("at://%s/%s/%s", did, ManifestCollection, rkey) 439 + } 440 + 441 + // ParseManifestURI extracts the digest from a manifest AT-URI 442 + // manifestURI: AT-URI in format "at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>" 443 + // Returns: Full digest with "sha256:" prefix (e.g., "sha256:abc123...") 444 + func ParseManifestURI(manifestURI string) (string, error) { 445 + // Expected format: at://did:plc:xyz/io.atcr.manifest/<rkey> 446 + if !strings.HasPrefix(manifestURI, "at://") { 447 + return "", fmt.Errorf("invalid AT-URI format: must start with 'at://'") 448 + } 449 + 450 + // Remove "at://" prefix 451 + remainder := strings.TrimPrefix(manifestURI, "at://") 452 + 453 + // Split by "/" 454 + parts := strings.Split(remainder, "/") 455 + if len(parts) != 3 { 456 + return "", fmt.Errorf("invalid AT-URI format: expected 3 parts (did/collection/rkey), got %d", len(parts)) 457 + } 458 + 459 + // Validate collection 460 + if parts[1] != ManifestCollection { 461 + return "", fmt.Errorf("invalid AT-URI: expected collection %s, got %s", ManifestCollection, parts[1]) 462 + } 463 + 464 + // The rkey is the digest without the "sha256:" prefix 465 + // Add it back to get the full digest 466 + rkey := parts[2] 467 + return "sha256:" + rkey, nil 468 + } 469 + 470 + // GetManifestDigest extracts the digest from a TagRecord, preferring the manifest field 471 + // Returns the digest with "sha256:" prefix (e.g., "sha256:abc123...") 472 + func (t *TagRecord) GetManifestDigest() (string, error) { 473 + // Prefer the new manifest field 474 + if t.Manifest != "" { 475 + return ParseManifestURI(t.Manifest) 476 + } 477 + 478 + // Fall back to the legacy manifestDigest field 479 + if t.ManifestDigest != "" { 480 + return t.ManifestDigest, nil 481 + } 482 + 483 + return "", fmt.Errorf("tag record has neither manifest nor manifestDigest field") 413 484 } 414 485 415 486 // =============================================================================
+163 -3
pkg/atproto/lexicon_test.go
··· 267 267 } 268 268 269 269 func TestNewTagRecord(t *testing.T) { 270 + did := "did:plc:test123" 270 271 before := time.Now() 271 - record := NewTagRecord("myapp", "latest", "sha256:abc123") 272 + record := NewTagRecord(did, "myapp", "latest", "sha256:abc123") 272 273 after := time.Now() 273 274 274 275 if record.Type != TagCollection { ··· 283 284 t.Errorf("Tag = %v, want latest", record.Tag) 284 285 } 285 286 286 - if record.ManifestDigest != "sha256:abc123" { 287 - t.Errorf("ManifestDigest = %v, want sha256:abc123", record.ManifestDigest) 287 + // New records should have manifest field (AT-URI) 288 + expectedURI := "at://did:plc:test123/io.atcr.manifest/abc123" 289 + if record.Manifest != expectedURI { 290 + t.Errorf("Manifest = %v, want %v", record.Manifest, expectedURI) 291 + } 292 + 293 + // New records should NOT have manifestDigest field 294 + if record.ManifestDigest != "" { 295 + t.Errorf("ManifestDigest should be empty for new records, got %v", record.ManifestDigest) 288 296 } 289 297 290 298 if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) { 291 299 t.Errorf("UpdatedAt = %v, want between %v and %v", record.UpdatedAt, before, after) 300 + } 301 + } 302 + 303 + func TestBuildManifestURI(t *testing.T) { 304 + tests := []struct { 305 + name string 306 + did string 307 + manifestDigest string 308 + want string 309 + }{ 310 + { 311 + name: "standard digest", 312 + did: "did:plc:abc123", 313 + manifestDigest: "sha256:def456", 314 + want: "at://did:plc:abc123/io.atcr.manifest/def456", 315 + }, 316 + { 317 + name: "long digest", 318 + did: "did:web:hold.example.com", 319 + manifestDigest: "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", 320 + want: "at://did:web:hold.example.com/io.atcr.manifest/abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", 321 + }, 322 + } 323 + 324 + for _, tt := range tests { 325 + t.Run(tt.name, func(t *testing.T) { 326 + got := BuildManifestURI(tt.did, tt.manifestDigest) 327 + if got != tt.want { 328 + t.Errorf("BuildManifestURI() = %v, want %v", got, tt.want) 329 + } 330 + }) 331 + } 332 + } 333 + 334 + func TestParseManifestURI(t *testing.T) { 335 + tests := []struct { 336 + name string 337 + manifestURI string 338 + want string 339 + wantErr bool 340 + }{ 341 + { 342 + name: "valid URI", 343 + manifestURI: "at://did:plc:abc123/io.atcr.manifest/def456", 344 + want: "sha256:def456", 345 + wantErr: false, 346 + }, 347 + { 348 + name: "valid URI with did:web", 349 + manifestURI: "at://did:web:hold.example.com/io.atcr.manifest/xyz789", 350 + want: "sha256:xyz789", 351 + wantErr: false, 352 + }, 353 + { 354 + name: "invalid prefix", 355 + manifestURI: "https://example.com/manifest", 356 + want: "", 357 + wantErr: true, 358 + }, 359 + { 360 + name: "wrong collection", 361 + manifestURI: "at://did:plc:abc123/io.atcr.tag/def456", 362 + want: "", 363 + wantErr: true, 364 + }, 365 + { 366 + name: "too few parts", 367 + manifestURI: "at://did:plc:abc123/io.atcr.manifest", 368 + want: "", 369 + wantErr: true, 370 + }, 371 + { 372 + name: "too many parts", 373 + manifestURI: "at://did:plc:abc123/io.atcr.manifest/def456/extra", 374 + want: "", 375 + wantErr: true, 376 + }, 377 + } 378 + 379 + for _, tt := range tests { 380 + t.Run(tt.name, func(t *testing.T) { 381 + got, err := ParseManifestURI(tt.manifestURI) 382 + if (err != nil) != tt.wantErr { 383 + t.Errorf("ParseManifestURI() error = %v, wantErr %v", err, tt.wantErr) 384 + return 385 + } 386 + if got != tt.want { 387 + t.Errorf("ParseManifestURI() = %v, want %v", got, tt.want) 388 + } 389 + }) 390 + } 391 + } 392 + 393 + func TestTagRecord_GetManifestDigest(t *testing.T) { 394 + tests := []struct { 395 + name string 396 + record TagRecord 397 + want string 398 + wantErr bool 399 + }{ 400 + { 401 + name: "new record with manifest field", 402 + record: TagRecord{ 403 + Manifest: "at://did:plc:test123/io.atcr.manifest/abc123", 404 + }, 405 + want: "sha256:abc123", 406 + wantErr: false, 407 + }, 408 + { 409 + name: "old record with manifestDigest field", 410 + record: TagRecord{ 411 + ManifestDigest: "sha256:def456", 412 + }, 413 + want: "sha256:def456", 414 + wantErr: false, 415 + }, 416 + { 417 + name: "prefers manifest over manifestDigest", 418 + record: TagRecord{ 419 + Manifest: "at://did:plc:test123/io.atcr.manifest/abc123", 420 + ManifestDigest: "sha256:def456", 421 + }, 422 + want: "sha256:abc123", 423 + wantErr: false, 424 + }, 425 + { 426 + name: "no fields set", 427 + record: TagRecord{}, 428 + want: "", 429 + wantErr: true, 430 + }, 431 + { 432 + name: "invalid manifest URI", 433 + record: TagRecord{ 434 + Manifest: "invalid-uri", 435 + }, 436 + want: "", 437 + wantErr: true, 438 + }, 439 + } 440 + 441 + for _, tt := range tests { 442 + t.Run(tt.name, func(t *testing.T) { 443 + got, err := tt.record.GetManifestDigest() 444 + if (err != nil) != tt.wantErr { 445 + t.Errorf("GetManifestDigest() error = %v, wantErr %v", err, tt.wantErr) 446 + return 447 + } 448 + if got != tt.want { 449 + t.Errorf("GetManifestDigest() = %v, want %v", got, tt.want) 450 + } 451 + }) 292 452 } 293 453 } 294 454
+1 -1
pkg/atproto/manifest_store.go
··· 184 184 for _, option := range options { 185 185 if tagOpt, ok := option.(distribution.WithTagOption); ok { 186 186 tag := tagOpt.Tag 187 - tagRecord := NewTagRecord(s.repository, tag, dgst.String()) 187 + tagRecord := NewTagRecord(s.client.DID(), s.repository, tag, dgst.String()) 188 188 tagRKey := repositoryTagToRKey(s.repository, tag) 189 189 _, err = s.client.PutRecord(ctx, TagCollection, tagRKey, tagRecord) 190 190 if err != nil {
+21 -4
pkg/atproto/tag_store.go
··· 40 40 return distribution.Descriptor{}, fmt.Errorf("failed to unmarshal tag record: %w", err) 41 41 } 42 42 43 + // Extract manifest digest (tries manifest field first, falls back to manifestDigest) 44 + manifestDigest, err := tagRecord.GetManifestDigest() 45 + if err != nil { 46 + return distribution.Descriptor{}, fmt.Errorf("failed to get manifest digest from tag record: %w", err) 47 + } 48 + 43 49 // Parse manifest digest 44 - dgst, err := digest.Parse(tagRecord.ManifestDigest) 50 + dgst, err := digest.Parse(manifestDigest) 45 51 if err != nil { 46 52 return distribution.Descriptor{}, fmt.Errorf("invalid manifest digest in tag record: %w", err) 47 53 } ··· 55 61 56 62 // Tag associates a tag with a descriptor (manifest digest) 57 63 func (s *TagStore) Tag(ctx context.Context, tag string, desc distribution.Descriptor) error { 58 - // Create tag record 59 - tagRecord := NewTagRecord(s.repository, tag, desc.Digest.String()) 64 + // Create tag record with manifest AT-URI 65 + tagRecord := NewTagRecord(s.client.DID(), s.repository, tag, desc.Digest.String()) 60 66 61 67 // Store in ATProto 62 68 rkey := repositoryTagToRKey(s.repository, tag) ··· 116 122 } 117 123 118 124 // Only include tags for this repository that match the digest 119 - if tagRecord.Repository == s.repository && tagRecord.ManifestDigest == desc.Digest.String() { 125 + if tagRecord.Repository != s.repository { 126 + continue 127 + } 128 + 129 + // Extract digest from tag record (tries manifest field first, falls back to manifestDigest) 130 + manifestDigest, err := tagRecord.GetManifestDigest() 131 + if err != nil { 132 + // Skip records with invalid manifest references 133 + continue 134 + } 135 + 136 + if manifestDigest == desc.Digest.String() { 120 137 tags = append(tags, tagRecord.Tag) 121 138 } 122 139 }
+74 -2
pkg/atproto/tag_store_test.go
··· 128 128 } 129 129 } 130 130 131 + // TestTagStore_Get_BackwardCompatibility tests reading old tag records with only manifestDigest field 132 + func TestTagStore_Get_BackwardCompatibility(t *testing.T) { 133 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 134 + // Old tag record with only manifestDigest (no manifest field) 135 + response := `{ 136 + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", 137 + "cid": "bafytest", 138 + "value": { 139 + "$type": "io.atcr.tag", 140 + "repository": "myapp", 141 + "tag": "latest", 142 + "manifestDigest": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", 143 + "updatedAt": "2025-01-01T00:00:00Z" 144 + } 145 + }` 146 + w.WriteHeader(http.StatusOK) 147 + w.Write([]byte(response)) 148 + })) 149 + defer server.Close() 150 + 151 + client := NewClient(server.URL, "did:plc:test123", "test-token") 152 + store := NewTagStore(client, "myapp") 153 + 154 + desc, err := store.Get(context.Background(), "latest") 155 + if err != nil { 156 + t.Fatalf("Get() error = %v, should handle old records", err) 157 + } 158 + 159 + if desc.Digest.String() != "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" { 160 + t.Errorf("Digest = %v, want sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", desc.Digest.String()) 161 + } 162 + } 163 + 164 + // TestTagStore_Get_NewManifestField tests reading new tag records with manifest field 165 + func TestTagStore_Get_NewManifestField(t *testing.T) { 166 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 167 + // New tag record with manifest field 168 + response := `{ 169 + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", 170 + "cid": "bafytest", 171 + "value": { 172 + "$type": "io.atcr.tag", 173 + "repository": "myapp", 174 + "tag": "latest", 175 + "manifest": "at://did:plc:test123/io.atcr.manifest/fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321", 176 + "updatedAt": "2025-01-01T00:00:00Z" 177 + } 178 + }` 179 + w.WriteHeader(http.StatusOK) 180 + w.Write([]byte(response)) 181 + })) 182 + defer server.Close() 183 + 184 + client := NewClient(server.URL, "did:plc:test123", "test-token") 185 + store := NewTagStore(client, "myapp") 186 + 187 + desc, err := store.Get(context.Background(), "latest") 188 + if err != nil { 189 + t.Fatalf("Get() error = %v", err) 190 + } 191 + 192 + if desc.Digest.String() != "sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321" { 193 + t.Errorf("Digest = %v, want sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321", desc.Digest.String()) 194 + } 195 + } 196 + 131 197 // TestTagStore_Tag tests creating/updating a tag 132 198 func TestTagStore_Tag(t *testing.T) { 133 199 tests := []struct { ··· 226 292 if sentTagRecord.Tag != tt.tag { 227 293 t.Errorf("Tag = %v, want %v", sentTagRecord.Tag, tt.tag) 228 294 } 229 - if sentTagRecord.ManifestDigest != tt.digest.String() { 230 - t.Errorf("ManifestDigest = %v, want %v", sentTagRecord.ManifestDigest, tt.digest.String()) 295 + // New records should have manifest field 296 + expectedURI := BuildManifestURI("did:plc:test123", tt.digest.String()) 297 + if sentTagRecord.Manifest != expectedURI { 298 + t.Errorf("Manifest = %v, want %v", sentTagRecord.Manifest, expectedURI) 299 + } 300 + // New records should NOT have manifestDigest field 301 + if sentTagRecord.ManifestDigest != "" { 302 + t.Errorf("ManifestDigest should be empty for new records, got %v", sentTagRecord.ManifestDigest) 231 303 } 232 304 } 233 305 })
+1 -1
pkg/auth/session.go
··· 1 1 package auth 2 2 3 3 import ( 4 + "atcr.io/pkg/atproto" 4 5 "bytes" 5 6 "context" 6 7 "crypto/sha256" ··· 11 12 "net/http" 12 13 "sync" 13 14 "time" 14 - "atcr.io/pkg/atproto" 15 15 16 16 "github.com/bluesky-social/indigo/atproto/identity" 17 17 "github.com/bluesky-social/indigo/atproto/syntax"
+3 -3
pkg/auth/token/cache.go
··· 140 140 } 141 141 142 142 return map[string]interface{}{ 143 - "total_entries": len(globalServiceTokens), 144 - "valid_tokens": validCount, 145 - "expired_tokens": expiredCount, 143 + "total_entries": len(globalServiceTokens), 144 + "valid_tokens": validCount, 145 + "expired_tokens": expiredCount, 146 146 } 147 147 } 148 148
+1 -1
pkg/hold/oci/xrpc_test.go
··· 11 11 "strconv" 12 12 "testing" 13 13 14 - "atcr.io/pkg/hold/pds" 15 14 "atcr.io/pkg/atproto" 15 + "atcr.io/pkg/hold/pds" 16 16 "atcr.io/pkg/s3" 17 17 "github.com/distribution/distribution/v3/registry/storage/driver/factory" 18 18 _ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem"