From 211d1dca427f913b4523a6c2abad198692d84aa4 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Sat, 10 Jan 2026 10:09:07 -0800 Subject: [PATCH 1/2] Introduce private client interfaces and adapters --- archive.go | 9 --------- client.go | 19 ++++++++++--------- contracts.go | 36 ++++++++++++++++++++++++++++++++++++ image.go | 8 ++++---- internal/archive/adapter.go | 33 +++++++++++++++++++++++++++++++++ internal/registry/adapter.go | 16 ++++++++++++++++ internal/safepath/adapter.go | 16 ++++++++++++++++ registry.go | 3 --- safepath.go | 6 ------ 9 files changed, 115 insertions(+), 31 deletions(-) create mode 100644 contracts.go create mode 100644 internal/archive/adapter.go create mode 100644 internal/registry/adapter.go create mode 100644 internal/safepath/adapter.go delete mode 100644 safepath.go diff --git a/archive.go b/archive.go index 098b5b2..deb0ebc 100644 --- a/archive.go +++ b/archive.go @@ -7,15 +7,6 @@ type ( // BuildResult contains the output of building an eStargz blob. BuildResult = core.BuildResult - // ArchiveBuilder creates eStargz blobs from files. - ArchiveBuilder = core.ArchiveBuilder - - // ArchiveReader reads eStargz blobs. - ArchiveReader = core.ArchiveReader - - // Extractor extracts eStargz archives to the filesystem. - Extractor = core.Extractor - // TOC represents the table of contents of an eStargz blob. TOC = core.TOC diff --git a/client.go b/client.go index 0448280..2025e18 100644 --- a/client.go +++ b/client.go @@ -21,10 +21,10 @@ import ( // Client provides operations against OCI registries. type Client struct { - registry Registry - builder ArchiveBuilder - reader ArchiveReader - validator PathValidator + registry registry + builder archiveBuilder + reader archiveReader + validator pathValidator logger *slog.Logger // configuration passed to registry @@ -89,14 +89,15 @@ func NewClient(opts ...ClientOption) (*Client, error) { regOpts = append(regOpts, registry.WithDescriptorCache(true)) } - c.registry = registry.New(regOpts...) - c.builder = archive.NewBuilder(c.logger) - c.reader = archive.NewReader() - c.validator = safepath.NewValidator() + registryAdapter := registry.NewAdapter(regOpts...) + c.registry = registryAdapter + c.builder = archive.NewBuilderAdapter(c.logger) + c.reader = archive.NewReaderAdapter() + c.validator = safepath.NewAdapter() // Initialize cache if configured if c.cacheDir != "" { - cacheInstance, err := cache.New(c.cacheDir, c.registry, c.logger) + cacheInstance, err := cache.New(c.cacheDir, registryAdapter, c.logger) if err != nil { return nil, fmt.Errorf("create cache: %w", err) } diff --git a/contracts.go b/contracts.go new file mode 100644 index 0000000..ffeca41 --- /dev/null +++ b/contracts.go @@ -0,0 +1,36 @@ +package blobber + +import ( + "context" + "io" + "io/fs" + + "github.com/meigma/blobber/core" +) + +type registry interface { + Push(ctx context.Context, ref string, layer io.Reader, opts *core.RegistryPushOptions) (string, error) + ResolveLayer(ctx context.Context, ref string) (core.LayerDescriptor, error) + FetchBlob(ctx context.Context, ref string, desc core.LayerDescriptor) (io.ReadCloser, error) + FetchBlobRange(ctx context.Context, ref string, desc core.LayerDescriptor, offset, length int64) (io.ReadCloser, error) + PushReferrer(ctx context.Context, ref, subjectDigest string, data []byte, opts *core.ReferrerPushOptions) (string, error) + FetchReferrers(ctx context.Context, ref, subjectDigest, artifactType string) ([]core.Referrer, error) + FetchReferrer(ctx context.Context, ref, referrerDigest string) ([]byte, error) + FetchManifest(ctx context.Context, ref string) ([]byte, string, error) + ListTags(ctx context.Context, repository string) ([]string, error) +} + +type archiveBuilder interface { + Build(ctx context.Context, src fs.FS, compression core.Compression) (*core.BuildResult, error) +} + +type archiveReader interface { + ReadTOC(ra io.ReaderAt, size int64) (*core.TOC, error) + OpenFile(ra io.ReaderAt, size int64, entry core.TOCEntry) (io.Reader, error) +} + +type pathValidator interface { + ValidatePath(path string) error + ValidateExtraction(destDir string, entries []core.TOCEntry, limits core.ExtractLimits) error + ValidateSymlink(destDir, linkPath, target string) error +} diff --git a/image.go b/image.go index d851bc4..cb32a3a 100644 --- a/image.go +++ b/image.go @@ -33,7 +33,7 @@ type Image struct { blobSize int64 // size of the blob esr *estargz.Reader // cached estargz reader - validator PathValidator + validator pathValidator logger *slog.Logger } @@ -42,11 +42,11 @@ type Image struct { // // This is a low-level constructor primarily for testing. Most users should // use Client.OpenImage instead. -func NewImageFromBlob(ref string, blob io.Reader, size int64, validator PathValidator, logger *slog.Logger) (*Image, error) { +func NewImageFromBlob(ref string, blob io.Reader, size int64, validator pathValidator, logger *slog.Logger) (*Image, error) { return newImageFromBlobWithDigest(ref, blob, size, "", validator, logger) } -func newImageFromBlobWithDigest(ref string, blob io.Reader, size int64, expectedDigest string, validator PathValidator, logger *slog.Logger) (*Image, error) { +func newImageFromBlobWithDigest(ref string, blob io.Reader, size int64, expectedDigest string, validator pathValidator, logger *slog.Logger) (*Image, error) { // Create temp file for blob storage f, err := os.CreateTemp("", "blobber-image-*") if err != nil { @@ -115,7 +115,7 @@ func newImageFromBlobWithDigest(ref string, blob io.Reader, size int64, expected // // This is a low-level constructor primarily for cache integration. Most users // should use Client.OpenImage instead. -func NewImageFromHandle(ref string, handle BlobHandle, validator PathValidator, logger *slog.Logger) (*Image, error) { +func NewImageFromHandle(ref string, handle BlobHandle, validator pathValidator, logger *slog.Logger) (*Image, error) { size := handle.Size() // Create estargz reader directly from the handle (which implements io.ReaderAt) diff --git a/internal/archive/adapter.go b/internal/archive/adapter.go new file mode 100644 index 0000000..2fd469d --- /dev/null +++ b/internal/archive/adapter.go @@ -0,0 +1,33 @@ +package archive + +import ( + "log/slog" + + "github.com/meigma/blobber/core" +) + +// BuilderAdapter provides the archive builder surface for blobber clients. +type BuilderAdapter struct { + *Builder +} + +// Compile-time interface implementation check. +var _ core.ArchiveBuilder = (*BuilderAdapter)(nil) + +// NewBuilderAdapter creates a new adapter for the archive builder. +func NewBuilderAdapter(logger *slog.Logger) *BuilderAdapter { + return &BuilderAdapter{Builder: NewBuilder(logger)} +} + +// ReaderAdapter provides the archive reader surface for blobber clients. +type ReaderAdapter struct { + *Reader +} + +// Compile-time interface implementation check. +var _ core.ArchiveReader = (*ReaderAdapter)(nil) + +// NewReaderAdapter creates a new adapter for the archive reader. +func NewReaderAdapter() *ReaderAdapter { + return &ReaderAdapter{Reader: NewReader()} +} diff --git a/internal/registry/adapter.go b/internal/registry/adapter.go new file mode 100644 index 0000000..0533737 --- /dev/null +++ b/internal/registry/adapter.go @@ -0,0 +1,16 @@ +package registry + +import "github.com/meigma/blobber/core" + +// Adapter provides a narrow registry surface for blobber clients. +type Adapter struct { + *orasRegistry +} + +// Compile-time interface implementation check. +var _ core.Registry = (*Adapter)(nil) + +// NewAdapter creates a new adapter backed by the ORAS registry implementation. +func NewAdapter(opts ...Option) *Adapter { + return &Adapter{orasRegistry: New(opts...)} +} diff --git a/internal/safepath/adapter.go b/internal/safepath/adapter.go new file mode 100644 index 0000000..4becbd0 --- /dev/null +++ b/internal/safepath/adapter.go @@ -0,0 +1,16 @@ +package safepath + +import "github.com/meigma/blobber/core" + +// Adapter provides the path validation surface for blobber clients. +type Adapter struct { + *Validator +} + +// Compile-time interface implementation check. +var _ core.PathValidator = (*Adapter)(nil) + +// NewAdapter creates a new adapter for path validation. +func NewAdapter() *Adapter { + return &Adapter{Validator: NewValidator()} +} diff --git a/registry.go b/registry.go index de35328..f728045 100644 --- a/registry.go +++ b/registry.go @@ -4,9 +4,6 @@ import "github.com/meigma/blobber/core" // Type aliases re-exported from core package. type ( - // Registry handles OCI registry operations. - Registry = core.Registry - // RegistryPushOptions contains metadata for push operations. RegistryPushOptions = core.RegistryPushOptions ) diff --git a/safepath.go b/safepath.go deleted file mode 100644 index c8a5921..0000000 --- a/safepath.go +++ /dev/null @@ -1,6 +0,0 @@ -package blobber - -import "github.com/meigma/blobber/core" - -// Type alias re-exported from core package. -type PathValidator = core.PathValidator From 74f558f502b0a84669815b4766776e8cf75d118c Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Sat, 10 Jan 2026 10:58:18 -0800 Subject: [PATCH 2/2] refactor!: move internal interfaces to internal/contracts Move implementation interfaces from core/types.go (public API) to internal/contracts/contracts.go to avoid exposing internal contracts to external consumers. Changes: - Create internal/contracts package with Registry, ArchiveBuilder, ArchiveReader, BlobHandle, BlobSource, PathValidator, and Extractor - Remove adapter pattern from internal packages (archive, registry, safepath) - implementations now satisfy contracts directly - Remove contracts.go from root package (unexported interface aliases) - Remove unused BlobHandle/BlobSource type aliases from cache.go - Make NewImageFromBlob/NewImageFromHandle unexported (internal only) - Update image_test.go to package blobber for internal function access - Clean up core/types.go to contain only public data types BREAKING CHANGE: Remove public interfaces (Registry, ArchiveBuilder, ArchiveReader, PathValidator, BlobHandle, BlobSource, Extractor) from core/types.go and type aliases from root package. Remove exported functions NewImageFromBlob and NewImageFromHandle. Co-Authored-By: Claude Opus 4.5 --- cache.go | 16 +- client.go | 25 +-- contracts.go | 36 ---- core/types.go | 280 +++++++------------------- docs/docs/explanation/architecture.md | 13 +- image.go | 27 +-- image_test.go | 35 ++-- internal/archive/adapter.go | 33 --- internal/archive/build.go | 3 +- internal/archive/extract.go | 5 +- internal/archive/reader.go | 5 +- internal/cache/cache.go | 15 +- internal/cache/cache_test.go | 2 +- internal/cache/handle.go | 6 +- internal/cache/lazy_handle.go | 5 +- internal/contracts/contracts.go | 133 ++++++++++++ internal/registry/adapter.go | 16 -- internal/registry/registry.go | 3 +- internal/registry/registry_test.go | 2 +- internal/safepath/adapter.go | 16 -- internal/safepath/safepath.go | 5 +- 21 files changed, 288 insertions(+), 393 deletions(-) delete mode 100644 contracts.go delete mode 100644 internal/archive/adapter.go create mode 100644 internal/contracts/contracts.go delete mode 100644 internal/registry/adapter.go delete mode 100644 internal/safepath/adapter.go diff --git a/cache.go b/cache.go index 7e395ca..e708284 100644 --- a/cache.go +++ b/cache.go @@ -2,16 +2,6 @@ package blobber import "github.com/meigma/blobber/core" -// Type aliases re-exported from core package for cache operations. -type ( - // BlobHandle provides random access to a cached or remote blob. - // Implements io.ReaderAt for estargz compatibility. - BlobHandle = core.BlobHandle - - // LayerDescriptor captures the resolved layer metadata plus platform context. - // Used as the cache key and for blob retrieval operations. - LayerDescriptor = core.LayerDescriptor - - // BlobSource provides access to blobs, either from cache or network. - BlobSource = core.BlobSource -) +// LayerDescriptor captures the resolved layer metadata plus platform context. +// Used as the cache key and for blob retrieval operations. +type LayerDescriptor = core.LayerDescriptor diff --git a/client.go b/client.go index 2025e18..c73efa7 100644 --- a/client.go +++ b/client.go @@ -15,16 +15,17 @@ import ( "github.com/meigma/blobber/core" "github.com/meigma/blobber/internal/archive" "github.com/meigma/blobber/internal/cache" + "github.com/meigma/blobber/internal/contracts" "github.com/meigma/blobber/internal/registry" "github.com/meigma/blobber/internal/safepath" ) // Client provides operations against OCI registries. type Client struct { - registry registry - builder archiveBuilder - reader archiveReader - validator pathValidator + registry contracts.Registry + builder contracts.ArchiveBuilder + reader contracts.ArchiveReader + validator contracts.PathValidator logger *slog.Logger // configuration passed to registry @@ -89,15 +90,15 @@ func NewClient(opts ...ClientOption) (*Client, error) { regOpts = append(regOpts, registry.WithDescriptorCache(true)) } - registryAdapter := registry.NewAdapter(regOpts...) - c.registry = registryAdapter - c.builder = archive.NewBuilderAdapter(c.logger) - c.reader = archive.NewReaderAdapter() - c.validator = safepath.NewAdapter() + registryClient := registry.New(regOpts...) + c.registry = registryClient + c.builder = archive.NewBuilder(c.logger) + c.reader = archive.NewReader() + c.validator = safepath.NewValidator() // Initialize cache if configured if c.cacheDir != "" { - cacheInstance, err := cache.New(c.cacheDir, registryAdapter, c.logger) + cacheInstance, err := cache.New(c.cacheDir, registryClient, c.logger) if err != nil { return nil, fmt.Errorf("create cache: %w", err) } @@ -185,7 +186,7 @@ func (c *Client) openImageCached(ctx context.Context, ref string) (*Image, error } // Get blob handle from cache - var handle BlobHandle + var handle contracts.BlobHandle if c.lazyLoading { // Lazy loading: fetch bytes on-demand via ReadAt handle, err = c.cache.OpenLazy(ctx, ref, desc) @@ -203,7 +204,7 @@ func (c *Client) openImageCached(ctx context.Context, ref string) (*Image, error } // Create Image from the cached handle - img, err := NewImageFromHandle(ref, handle, c.validator, c.logger) + img, err := newImageFromHandle(ref, handle, c.validator, c.logger) if err != nil { handle.Close() return nil, fmt.Errorf("open image %s: %w", ref, err) diff --git a/contracts.go b/contracts.go deleted file mode 100644 index ffeca41..0000000 --- a/contracts.go +++ /dev/null @@ -1,36 +0,0 @@ -package blobber - -import ( - "context" - "io" - "io/fs" - - "github.com/meigma/blobber/core" -) - -type registry interface { - Push(ctx context.Context, ref string, layer io.Reader, opts *core.RegistryPushOptions) (string, error) - ResolveLayer(ctx context.Context, ref string) (core.LayerDescriptor, error) - FetchBlob(ctx context.Context, ref string, desc core.LayerDescriptor) (io.ReadCloser, error) - FetchBlobRange(ctx context.Context, ref string, desc core.LayerDescriptor, offset, length int64) (io.ReadCloser, error) - PushReferrer(ctx context.Context, ref, subjectDigest string, data []byte, opts *core.ReferrerPushOptions) (string, error) - FetchReferrers(ctx context.Context, ref, subjectDigest, artifactType string) ([]core.Referrer, error) - FetchReferrer(ctx context.Context, ref, referrerDigest string) ([]byte, error) - FetchManifest(ctx context.Context, ref string) ([]byte, string, error) - ListTags(ctx context.Context, repository string) ([]string, error) -} - -type archiveBuilder interface { - Build(ctx context.Context, src fs.FS, compression core.Compression) (*core.BuildResult, error) -} - -type archiveReader interface { - ReadTOC(ra io.ReaderAt, size int64) (*core.TOC, error) - OpenFile(ra io.ReaderAt, size int64, entry core.TOCEntry) (io.Reader, error) -} - -type pathValidator interface { - ValidatePath(path string) error - ValidateExtraction(destDir string, entries []core.TOCEntry, limits core.ExtractLimits) error - ValidateSymlink(destDir, linkPath, target string) error -} diff --git a/core/types.go b/core/types.go index 90b5410..c3a86fd 100644 --- a/core/types.go +++ b/core/types.go @@ -1,13 +1,9 @@ -// Package core provides the shared types and interfaces for blobber. -// -// This package exists to break import cycles between the root blobber package -// and internal implementation packages. The blobber package re-exports all -// public types from this package, so external users should import blobber -// directly, not blobber/core. +// Package core provides shared data types and errors for blobber. +// Interfaces that define internal contracts live in internal/contracts to avoid +// exposing them as part of the public API. package core import ( - "context" "errors" "io" "io/fs" @@ -49,12 +45,11 @@ var ( ErrNoSignature = errors.New("blobber: no signature found") ) -// ExtractLimits defines safety limits for extraction. -type ExtractLimits struct { - MaxFiles int // Maximum number of files (0 = no limit) - MaxTotalSize int64 // Maximum total extracted size (0 = no limit) - MaxFileSize int64 // Maximum single file size (0 = no limit) -} +// Compression provides compression/decompression for eStargz blobs. +// This is a type alias for estargz.Compression, allowing custom implementations. +// +// Use GzipCompression() or ZstdCompression() for built-in implementations. +type Compression = estargz.Compression // BuildResult contains the output of building an eStargz blob. type BuildResult struct { @@ -71,31 +66,11 @@ type BuildResult struct { BlobSize int64 } -// TOC represents the table of contents of an eStargz blob. -type TOC struct { - Entries []TOCEntry -} - -// TOCEntry represents a file in the TOC. -type TOCEntry struct { - Name string - Type string // "reg", "dir", "symlink", etc. - Size int64 - Mode int64 - Offset int64 // Byte offset in the blob - LinkName string // Target for symlinks - ChunkSize int64 - ChunkCount int -} - -// ToFileEntry converts a TOCEntry to a FileEntry. -func (e *TOCEntry) ToFileEntry() FileEntry { - return FileEntry{ - FilePath: e.Name, - FileSize: e.Size, - //nolint:gosec // G115: Mode from trusted estargz TOC entry - FileMode: fs.FileMode(e.Mode), - } +// ExtractLimits defines safety limits for extraction. +type ExtractLimits struct { + MaxFiles int // Maximum number of files (0 = no limit) + MaxTotalSize int64 // Maximum total extracted size (0 = no limit) + MaxFileSize int64 // Maximum single file size (0 = no limit) } // FileEntry represents a file in a remote image. @@ -129,19 +104,32 @@ func (f FileEntry) Info() (fs.FileInfo, error) { return fileInfo{f}, nil } -// fileInfo implements fs.FileInfo for FileEntry. -// It wraps a FileEntry to satisfy the fs.FileInfo interface returned by -// FileEntry.Info(), enabling FileEntry to fully implement fs.DirEntry. -type fileInfo struct { - entry FileEntry +// LayerDescriptor captures the resolved layer metadata plus platform context. +// Used as the cache key and for blob retrieval operations. +type LayerDescriptor struct { + // Digest is the SHA256 digest of the compressed blob (sha256:...). + // This is the primary cache key. + Digest string + // Size is the total blob size in bytes. + Size int64 + // MediaType is the OCI media type of the layer. + MediaType string + // ManifestDigest is the digest of the manifest that contained this layer. + // Used for tag drift detection during partial caching. + ManifestDigest string + // Platform is the target platform in os/arch[/variant] format. + Platform string } -func (fi fileInfo) Name() string { return fi.entry.FilePath } -func (fi fileInfo) Size() int64 { return fi.entry.FileSize } -func (fi fileInfo) Mode() fs.FileMode { return fi.entry.FileMode } -func (fi fileInfo) ModTime() time.Time { return time.Time{} } -func (fi fileInfo) IsDir() bool { return fi.entry.FileMode.IsDir() } -func (fi fileInfo) Sys() any { return nil } +// Referrer represents an OCI referrer artifact (e.g., signature, attestation). +type Referrer struct { + // Digest is the referrer manifest digest. + Digest string + // ArtifactType identifies the referrer type (e.g., "application/vnd.dev.sigstore.bundle.v0.3+json"). + ArtifactType string + // Annotations are optional key-value metadata. + Annotations map[string]string +} // RegistryPushOptions contains metadata for push operations. type RegistryPushOptions struct { @@ -158,169 +146,51 @@ type RegistryPushOptions struct { BlobSize int64 } -// Referrer represents an OCI referrer artifact (e.g., signature, attestation). -type Referrer struct { - // Digest is the referrer manifest digest. - Digest string - // ArtifactType identifies the referrer type (e.g., "application/vnd.dev.sigstore.bundle.v0.3+json"). - ArtifactType string - // Annotations are optional key-value metadata. - Annotations map[string]string -} - -// ReferrerPushOptions configures how a referrer artifact is pushed. -type ReferrerPushOptions struct { - // ArtifactType is the OCI artifact type (required). - ArtifactType string - // Annotations are optional manifest annotations. - Annotations map[string]string -} - -// Registry handles OCI registry operations. -// This interface is implemented by internal/registry. -type Registry interface { - // Push uploads a blob and creates a manifest. - // Returns the manifest digest. - Push(ctx context.Context, ref string, layer io.Reader, opts *RegistryPushOptions) (string, error) - - // Pull returns a reader for the image's layer blob and its size. - Pull(ctx context.Context, ref string) (io.ReadCloser, int64, error) - - // PullRange fetches a byte range from the layer blob. - // Used for selective file retrieval from eStargz. - PullRange(ctx context.Context, ref string, offset, length int64) (io.ReadCloser, error) - - // ResolveLayer resolves a reference to its layer descriptor. - // Returns the layer metadata without fetching the blob content. - // Used by the cache to check for hits before downloading. - ResolveLayer(ctx context.Context, ref string) (LayerDescriptor, error) - - // FetchBlob fetches a blob by its descriptor. - // Unlike Pull, this uses a known digest rather than resolving a ref. - FetchBlob(ctx context.Context, ref string, desc LayerDescriptor) (io.ReadCloser, error) - - // FetchBlobRange fetches a byte range from a blob by its descriptor. - // Used for resuming partial downloads and selective file access. - // Returns ErrRangeNotSupported if the registry doesn't support range requests. - FetchBlobRange(ctx context.Context, ref string, desc LayerDescriptor, offset, length int64) (io.ReadCloser, error) - - // PushReferrer pushes a referrer artifact that references the subject digest. - // The data is stored as a single layer in an OCI manifest with the subject field set. - // Returns the referrer manifest digest. - PushReferrer(ctx context.Context, ref string, subjectDigest string, data []byte, opts *ReferrerPushOptions) (string, error) - - // FetchReferrers returns all referrers for a subject digest, optionally filtered by artifact type. - // Uses the OCI 1.1 referrers API (GET /v2//referrers/?artifactType=...). - // Pass empty artifactType to fetch all referrers. - FetchReferrers(ctx context.Context, ref string, subjectDigest string, artifactType string) ([]Referrer, error) - - // FetchReferrer fetches the content of a specific referrer by its digest. - // Returns the first layer's content (the signature/attestation data). - FetchReferrer(ctx context.Context, ref string, referrerDigest string) ([]byte, error) - - // FetchManifest fetches the raw manifest bytes for a reference. - // Returns the manifest JSON and its digest. - FetchManifest(ctx context.Context, ref string) ([]byte, string, error) - - // ListTags returns all tags for a repository. - // The repository should be in the format "registry/namespace/repo" (e.g., "ghcr.io/org/repo"). - ListTags(ctx context.Context, repository string) ([]string, error) -} - -// ArchiveBuilder creates eStargz blobs from files. -// This interface is implemented by internal/archive. -type ArchiveBuilder interface { - // Build creates an eStargz blob from the given filesystem. - // Returns a BuildResult containing the blob, TOC digest, blob digest, and size. - Build(ctx context.Context, src fs.FS, compression Compression) (*BuildResult, error) -} - -// ArchiveReader reads eStargz blobs. -// This interface is implemented by internal/archive. -// -// Note: The default implementation re-parses the eStargz archive on each call -// to ReadTOC or OpenFile. For efficient repeated access to the same archive, -// use [Client.OpenImage] which caches the parsed archive in an [Image]. -type ArchiveReader interface { - // ReadTOC extracts the TOC from an eStargz blob. - // The size parameter is the total blob size (needed for footer location). - ReadTOC(r io.ReaderAt, size int64) (*TOC, error) - - // OpenFile returns a reader for a specific file within an eStargz blob. - // The size parameter is the total blob size (needed for estargz.Open). - // The caller obtains size from the registry pull operation. - // - // Note: Each call re-parses the archive. For multiple file access, - // prefer [Client.OpenImage] which caches the parsed state. - OpenFile(r io.ReaderAt, size int64, entry TOCEntry) (io.Reader, error) +// TOC represents the table of contents of an eStargz blob. +type TOC struct { + Entries []TOCEntry } -// Extractor extracts eStargz archives to the filesystem. -// This interface is implemented by internal/archive. -type Extractor interface { - // Extract extracts an eStargz blob to the destination directory. - Extract(ctx context.Context, r io.Reader, destDir string, validator PathValidator, limits ExtractLimits) error +// TOCEntry represents a file in the TOC. +type TOCEntry struct { + Name string + Type string // "reg", "dir", "symlink", etc. + Size int64 + Mode int64 + Offset int64 // Byte offset in the blob + LinkName string // Target for symlinks + ChunkSize int64 + ChunkCount int } -// PathValidator validates paths for security concerns. -// This interface is implemented by internal/safepath. -type PathValidator interface { - // ValidatePath checks if a path is safe (no traversal, valid characters). - // Returns ErrPathTraversal if the path is unsafe. - ValidatePath(path string) error - - // ValidateExtraction checks if extracting the given entries to destDir is safe. - // Returns ErrPathTraversal for unsafe paths, ErrExtractLimits if limits exceeded. - ValidateExtraction(destDir string, entries []TOCEntry, limits ExtractLimits) error - - // ValidateSymlink checks if a symlink target is safe (stays within destDir). - // Returns ErrPathTraversal if the symlink would escape. - ValidateSymlink(destDir, linkPath, target string) error +// ToFileEntry converts a TOCEntry to a FileEntry. +func (e *TOCEntry) ToFileEntry() FileEntry { + return FileEntry{ + FilePath: e.Name, + FileSize: e.Size, + //nolint:gosec // G115: Mode from trusted estargz TOC entry + FileMode: fs.FileMode(e.Mode), + } } -// Compression provides compression/decompression for eStargz blobs. -// This is a type alias for estargz.Compression, allowing custom implementations. -// -// Use GzipCompression() or ZstdCompression() for built-in implementations. -type Compression = estargz.Compression - -// LayerDescriptor captures the resolved layer metadata plus platform context. -// Used as the cache key and for blob retrieval operations. -type LayerDescriptor struct { - // Digest is the SHA256 digest of the compressed blob (sha256:...). - // This is the primary cache key. - Digest string - // Size is the total blob size in bytes. - Size int64 - // MediaType is the OCI media type of the layer. - MediaType string - // ManifestDigest is the digest of the manifest that contained this layer. - // Used for tag drift detection during partial caching. - ManifestDigest string - // Platform is the target platform in os/arch[/variant] format. - Platform string +// fileInfo implements fs.FileInfo for FileEntry. +// It wraps a FileEntry to satisfy the fs.FileInfo interface returned by +// FileEntry.Info(), enabling FileEntry to fully implement fs.DirEntry. +type fileInfo struct { + entry FileEntry } -// BlobHandle provides random access to a cached or remote blob. -// Implements io.ReaderAt for estargz compatibility. -type BlobHandle interface { - io.ReaderAt - io.Closer - // Size returns the total blob size in bytes. - Size() int64 - // Complete reports whether the entire blob is available locally. - Complete() bool -} +func (fi fileInfo) Name() string { return fi.entry.FilePath } +func (fi fileInfo) Size() int64 { return fi.entry.FileSize } +func (fi fileInfo) Mode() fs.FileMode { return fi.entry.FileMode } +func (fi fileInfo) ModTime() time.Time { return time.Time{} } +func (fi fileInfo) IsDir() bool { return fi.entry.FileMode.IsDir() } +func (fi fileInfo) Sys() any { return nil } -// BlobSource provides access to blobs, either from cache or network. -// The cache implementation wraps a fallback source (typically the registry). -type BlobSource interface { - // Open returns a BlobHandle for random access to the blob. - // The ref parameter is used for fetching missing ranges during resume. - // The handle must be closed when done. - Open(ctx context.Context, ref string, desc LayerDescriptor) (BlobHandle, error) - // OpenStream returns a streaming reader for the blob. - // The ref parameter is used for fetching missing ranges during resume. - // More efficient for sequential reads like extraction. - OpenStream(ctx context.Context, ref string, desc LayerDescriptor) (io.ReadCloser, error) +// ReferrerPushOptions configures how a referrer artifact is pushed. +type ReferrerPushOptions struct { + // ArtifactType is the OCI artifact type (required). + ArtifactType string + // Annotations are optional manifest annotations. + Annotations map[string]string } diff --git a/docs/docs/explanation/architecture.md b/docs/docs/explanation/architecture.md index 41c91c7..fb3cb9d 100644 --- a/docs/docs/explanation/architecture.md +++ b/docs/docs/explanation/architecture.md @@ -26,19 +26,22 @@ blobber/ ├── compression.go # Compression type constructors ├── entry.go # FileEntry type ├── errors.go # Sentinel errors -├── registry.go # Registry interface -├── archive.go # Archive interfaces -├── safepath.go # Path validation interface +├── archive.go # Type aliases for archive types +├── cache.go # Cache-related data types +├── registry.go # Registry option types └── internal/ + ├── contracts/ # Internal interfaces shared across components ├── registry/ # ORAS-backed registry implementation ├── archive/ # eStargz build/read implementation ├── safepath/ # Path security validation └── cache/ # Blob caching ``` -## Core Interfaces +## Internal Contracts -Blobber defines interfaces that abstract the underlying implementations: +Blobber keeps its internal interfaces in `internal/contracts` so they are not +part of the public API. These contracts are used to decouple components +without exposing them to external users. ### Registry diff --git a/image.go b/image.go index cb32a3a..bbd6292 100644 --- a/image.go +++ b/image.go @@ -13,6 +13,8 @@ import ( "github.com/containerd/stargz-snapshotter/estargz" "github.com/containerd/stargz-snapshotter/estargz/zstdchunked" "github.com/opencontainers/go-digest" + + "github.com/meigma/blobber/internal/contracts" ) // Compile-time interface check. @@ -28,25 +30,16 @@ type Image struct { closed bool ref string - blobFile *os.File // temp file with blob data (non-cached path) - blobHandle BlobHandle // cached blob handle (cached path) - blobSize int64 // size of the blob - esr *estargz.Reader // cached estargz reader + blobFile *os.File // temp file with blob data (non-cached path) + blobHandle contracts.BlobHandle // cached blob handle (cached path) + blobSize int64 // size of the blob + esr *estargz.Reader // cached estargz reader - validator pathValidator + validator contracts.PathValidator logger *slog.Logger } -// NewImageFromBlob creates a new Image from a blob reader. -// The blob is written to a temp file for random access. -// -// This is a low-level constructor primarily for testing. Most users should -// use Client.OpenImage instead. -func NewImageFromBlob(ref string, blob io.Reader, size int64, validator pathValidator, logger *slog.Logger) (*Image, error) { - return newImageFromBlobWithDigest(ref, blob, size, "", validator, logger) -} - -func newImageFromBlobWithDigest(ref string, blob io.Reader, size int64, expectedDigest string, validator pathValidator, logger *slog.Logger) (*Image, error) { +func newImageFromBlobWithDigest(ref string, blob io.Reader, size int64, expectedDigest string, validator contracts.PathValidator, logger *slog.Logger) (*Image, error) { // Create temp file for blob storage f, err := os.CreateTemp("", "blobber-image-*") if err != nil { @@ -110,12 +103,12 @@ func newImageFromBlobWithDigest(ref string, blob io.Reader, size int64, expected }, nil } -// NewImageFromHandle creates a new Image from a BlobHandle. +// newImageFromHandle creates a new Image from a BlobHandle. // Used when opening images from cache, where the blob is already on disk. // // This is a low-level constructor primarily for cache integration. Most users // should use Client.OpenImage instead. -func NewImageFromHandle(ref string, handle BlobHandle, validator pathValidator, logger *slog.Logger) (*Image, error) { +func newImageFromHandle(ref string, handle contracts.BlobHandle, validator contracts.PathValidator, logger *slog.Logger) (*Image, error) { size := handle.Size() // Create estargz reader directly from the handle (which implements io.ReaderAt) diff --git a/image_test.go b/image_test.go index 67d317f..1a81624 100644 --- a/image_test.go +++ b/image_test.go @@ -1,4 +1,4 @@ -package blobber_test +package blobber import ( "bytes" @@ -13,7 +13,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/meigma/blobber" "github.com/meigma/blobber/internal/archive" "github.com/meigma/blobber/internal/safepath" ) @@ -23,7 +22,7 @@ func buildTestBlob(t *testing.T, fsys fstest.MapFS) (data []byte, size int64) { t.Helper() builder := archive.NewBuilder(nil) - result, err := builder.Build(context.Background(), fsys, blobber.GzipCompression()) + result, err := builder.Build(context.Background(), fsys, GzipCompression()) require.NoError(t, err, "Build() failed") defer result.Blob.Close() @@ -46,8 +45,8 @@ func TestImageList(t *testing.T) { data, size := buildTestBlob(t, testFS) - img, err := blobber.NewImageFromBlob("test:latest", bytes.NewReader(data), size, safepath.NewValidator(), slog.New(slog.DiscardHandler)) - require.NoError(t, err, "NewImageFromBlob() failed") + img, err := newImageFromBlobWithDigest("test:latest", bytes.NewReader(data), size, "", safepath.NewValidator(), slog.New(slog.DiscardHandler)) + require.NoError(t, err, "newImageFromBlobWithDigest() failed") defer img.Close() entries, err := img.List() @@ -76,8 +75,8 @@ func TestImageOpen(t *testing.T) { data, size := buildTestBlob(t, testFS) - img, err := blobber.NewImageFromBlob("test:latest", bytes.NewReader(data), size, safepath.NewValidator(), slog.New(slog.DiscardHandler)) - require.NoError(t, err, "NewImageFromBlob() failed") + img, err := newImageFromBlobWithDigest("test:latest", bytes.NewReader(data), size, "", safepath.NewValidator(), slog.New(slog.DiscardHandler)) + require.NoError(t, err, "newImageFromBlobWithDigest() failed") defer img.Close() rc, err := img.Open("test.txt") @@ -99,13 +98,13 @@ func TestImageOpenNotFound(t *testing.T) { data, size := buildTestBlob(t, testFS) - img, err := blobber.NewImageFromBlob("test:latest", bytes.NewReader(data), size, safepath.NewValidator(), slog.New(slog.DiscardHandler)) - require.NoError(t, err, "NewImageFromBlob() failed") + img, err := newImageFromBlobWithDigest("test:latest", bytes.NewReader(data), size, "", safepath.NewValidator(), slog.New(slog.DiscardHandler)) + require.NoError(t, err, "newImageFromBlobWithDigest() failed") defer img.Close() _, err = img.Open("does-not-exist.txt") require.Error(t, err, "Open() should return error for non-existent file") - assert.ErrorIs(t, err, blobber.ErrNotFound) + assert.ErrorIs(t, err, ErrNotFound) } func TestImageWalk(t *testing.T) { @@ -120,8 +119,8 @@ func TestImageWalk(t *testing.T) { data, size := buildTestBlob(t, testFS) - img, err := blobber.NewImageFromBlob("test:latest", bytes.NewReader(data), size, safepath.NewValidator(), slog.New(slog.DiscardHandler)) - require.NoError(t, err, "NewImageFromBlob() failed") + img, err := newImageFromBlobWithDigest("test:latest", bytes.NewReader(data), size, "", safepath.NewValidator(), slog.New(slog.DiscardHandler)) + require.NoError(t, err, "newImageFromBlobWithDigest() failed") defer img.Close() var paths []string @@ -155,8 +154,8 @@ func TestImageClose(t *testing.T) { data, size := buildTestBlob(t, testFS) - img, err := blobber.NewImageFromBlob("test:latest", bytes.NewReader(data), size, safepath.NewValidator(), slog.New(slog.DiscardHandler)) - require.NoError(t, err, "NewImageFromBlob() failed") + img, err := newImageFromBlobWithDigest("test:latest", bytes.NewReader(data), size, "", safepath.NewValidator(), slog.New(slog.DiscardHandler)) + require.NoError(t, err, "newImageFromBlobWithDigest() failed") // Close the image require.NoError(t, img.Close(), "Close() failed") @@ -186,8 +185,8 @@ func TestImageConcurrentAccess(t *testing.T) { data, size := buildTestBlob(t, testFS) - img, err := blobber.NewImageFromBlob("test:latest", bytes.NewReader(data), size, safepath.NewValidator(), slog.New(slog.DiscardHandler)) - require.NoError(t, err, "NewImageFromBlob() failed") + img, err := newImageFromBlobWithDigest("test:latest", bytes.NewReader(data), size, "", safepath.NewValidator(), slog.New(slog.DiscardHandler)) + require.NoError(t, err, "newImageFromBlobWithDigest() failed") defer img.Close() // Run concurrent operations @@ -252,8 +251,8 @@ func TestImageMultipleOpens(t *testing.T) { data, size := buildTestBlob(t, testFS) - img, err := blobber.NewImageFromBlob("test:latest", bytes.NewReader(data), size, safepath.NewValidator(), slog.New(slog.DiscardHandler)) - require.NoError(t, err, "NewImageFromBlob() failed") + img, err := newImageFromBlobWithDigest("test:latest", bytes.NewReader(data), size, "", safepath.NewValidator(), slog.New(slog.DiscardHandler)) + require.NoError(t, err, "newImageFromBlobWithDigest() failed") defer img.Close() // Open multiple files - should all work diff --git a/internal/archive/adapter.go b/internal/archive/adapter.go deleted file mode 100644 index 2fd469d..0000000 --- a/internal/archive/adapter.go +++ /dev/null @@ -1,33 +0,0 @@ -package archive - -import ( - "log/slog" - - "github.com/meigma/blobber/core" -) - -// BuilderAdapter provides the archive builder surface for blobber clients. -type BuilderAdapter struct { - *Builder -} - -// Compile-time interface implementation check. -var _ core.ArchiveBuilder = (*BuilderAdapter)(nil) - -// NewBuilderAdapter creates a new adapter for the archive builder. -func NewBuilderAdapter(logger *slog.Logger) *BuilderAdapter { - return &BuilderAdapter{Builder: NewBuilder(logger)} -} - -// ReaderAdapter provides the archive reader surface for blobber clients. -type ReaderAdapter struct { - *Reader -} - -// Compile-time interface implementation check. -var _ core.ArchiveReader = (*ReaderAdapter)(nil) - -// NewReaderAdapter creates a new adapter for the archive reader. -func NewReaderAdapter() *ReaderAdapter { - return &ReaderAdapter{Reader: NewReader()} -} diff --git a/internal/archive/build.go b/internal/archive/build.go index 33da119..effade6 100644 --- a/internal/archive/build.go +++ b/internal/archive/build.go @@ -15,6 +15,7 @@ import ( "github.com/opencontainers/go-digest" "github.com/meigma/blobber/core" + "github.com/meigma/blobber/internal/contracts" ) // digestingWriter computes digest and size while writing. @@ -44,7 +45,7 @@ func (d *digestingWriter) Digest() digest.Digest { return d.digester.Digest() } func (d *digestingWriter) Size() int64 { return d.size } // Compile-time interface implementation check. -var _ core.ArchiveBuilder = (*Builder)(nil) +var _ contracts.ArchiveBuilder = (*Builder)(nil) // Builder creates eStargz blobs from filesystems. type Builder struct { diff --git a/internal/archive/extract.go b/internal/archive/extract.go index 62641da..feb47c9 100644 --- a/internal/archive/extract.go +++ b/internal/archive/extract.go @@ -18,6 +18,7 @@ import ( "github.com/klauspost/compress/zstd" "github.com/meigma/blobber/core" + "github.com/meigma/blobber/internal/contracts" ) // Extract extracts an eStargz blob to the destination directory. @@ -28,7 +29,7 @@ import ( // // Note: This implementation uses best-effort TOCTOU prevention via Lstat // checks and O_EXCL flags. Full openat(2) safety is not yet implemented. -func Extract(ctx context.Context, r io.Reader, destDir string, validator core.PathValidator, limits core.ExtractLimits) error { +func Extract(ctx context.Context, r io.Reader, destDir string, validator contracts.PathValidator, limits core.ExtractLimits) error { // Auto-detect compression decompReader, err := detectAndDecompress(r) if err != nil { @@ -82,7 +83,7 @@ type extractState struct { } // processEntry handles a single tar entry. -func processEntry(ctx context.Context, destDir string, header *tar.Header, tr *tar.Reader, validator core.PathValidator, state *extractState) error { +func processEntry(ctx context.Context, destDir string, header *tar.Header, tr *tar.Reader, validator contracts.PathValidator, state *extractState) error { // Skip TOC entry (eStargz stores TOC as stargz.index.json) if header.Name == "stargz.index.json" { return nil diff --git a/internal/archive/reader.go b/internal/archive/reader.go index bab1eee..e2b91a5 100644 --- a/internal/archive/reader.go +++ b/internal/archive/reader.go @@ -8,10 +8,11 @@ import ( "github.com/containerd/stargz-snapshotter/estargz/zstdchunked" "github.com/meigma/blobber/core" + "github.com/meigma/blobber/internal/contracts" ) // Compile-time interface implementation check. -var _ core.ArchiveReader = (*Reader)(nil) +var _ contracts.ArchiveReader = (*Reader)(nil) // Reader reads eStargz blobs. type Reader struct{} @@ -65,7 +66,7 @@ func (r *Reader) ReadTOC(ra io.ReaderAt, size int64) (*core.TOC, error) { // OpenFile returns a reader for a specific file within an eStargz blob. // The size parameter is the total blob size (needed for estargz.Open). // -//nolint:gocritic // hugeParam: entry passed by value to match core.ArchiveReader interface +//nolint:gocritic // hugeParam: entry passed by value to match contracts.ArchiveReader interface func (r *Reader) OpenFile(ra io.ReaderAt, size int64, entry core.TOCEntry) (io.Reader, error) { sr := io.NewSectionReader(ra, 0, size) diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 83a266e..f448b32 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -16,16 +16,17 @@ import ( "time" "github.com/meigma/blobber/core" + "github.com/meigma/blobber/internal/contracts" ) // jsonExt is the file extension for JSON metadata files. const jsonExt = ".json" -// Cache implements core.BlobSource with disk-based caching. +// Cache implements contracts.BlobSource with disk-based caching. // It stores complete blobs keyed by their SHA256 digest. type Cache struct { path string - fallback core.Registry + fallback contracts.Registry logger *slog.Logger verifyOnRead bool @@ -34,7 +35,7 @@ type Cache struct { // New creates a new cache at the given path. // The fallback registry is used to fetch blobs not in the cache. -func New(path string, fallback core.Registry, logger *slog.Logger) (*Cache, error) { +func New(path string, fallback contracts.Registry, logger *slog.Logger) (*Cache, error) { if logger == nil { logger = slog.New(slog.DiscardHandler) } @@ -71,7 +72,7 @@ func (c *Cache) SetVerifyOnRead(enabled bool) { // // If the cached blob file is missing or corrupt, the entry is evicted // and the blob is re-downloaded (self-healing). -func (c *Cache) Open(ctx context.Context, ref string, desc core.LayerDescriptor) (core.BlobHandle, error) { +func (c *Cache) Open(ctx context.Context, ref string, desc core.LayerDescriptor) (contracts.BlobHandle, error) { entry, blobPath, entryPath := c.LoadCompleteEntry(desc.Digest) if entry != nil { c.logger.Debug("cache hit", "digest", desc.Digest) @@ -427,7 +428,7 @@ func (c *Cache) selfHealEvict(digest string, err error) { // openCachedBlob opens a cached blob file as a BlobHandle. // Returns an error if the file is missing or has unexpected size (truncated/expanded). -func (c *Cache) openCachedBlob(blobPath string, entry *Entry) (core.BlobHandle, error) { +func (c *Cache) openCachedBlob(blobPath string, entry *Entry) (contracts.BlobHandle, error) { if err := ensureCacheFile(blobPath); err != nil { return nil, fmt.Errorf("open cached blob: %w", err) } @@ -780,7 +781,7 @@ func (c *Cache) savePartialProgress(entryPath string, entry *Entry, ranges []Ran // // If the cached blob file is missing or corrupt, the entry is evicted // and lazy loading starts fresh (self-healing). -func (c *Cache) OpenLazy(ctx context.Context, ref string, desc core.LayerDescriptor) (core.BlobHandle, error) { +func (c *Cache) OpenLazy(ctx context.Context, ref string, desc core.LayerDescriptor) (contracts.BlobHandle, error) { if c.verifyOnRead { return nil, errors.New("cache verify on read is incompatible with lazy loading") } @@ -806,7 +807,7 @@ func (c *Cache) openLazyHandle( ref string, desc core.LayerDescriptor, blobPath, entryPath string, -) (core.BlobHandle, error) { +) (contracts.BlobHandle, error) { c.mu.Lock() defer c.mu.Unlock() diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index c141d48..39c0cec 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -16,7 +16,7 @@ import ( "github.com/meigma/blobber/core" ) -// mockRegistry is a test double for core.Registry. +// mockRegistry is a test double for contracts.Registry. type mockRegistry struct { blobs map[string][]byte rangeSupport bool diff --git a/internal/cache/handle.go b/internal/cache/handle.go index 31ced45..533202b 100644 --- a/internal/cache/handle.go +++ b/internal/cache/handle.go @@ -3,13 +3,13 @@ package cache import ( "os" - "github.com/meigma/blobber/core" + "github.com/meigma/blobber/internal/contracts" ) // Compile-time interface check. -var _ core.BlobHandle = (*fileHandle)(nil) +var _ contracts.BlobHandle = (*fileHandle)(nil) -// fileHandle implements core.BlobHandle for a cached file. +// fileHandle implements contracts.BlobHandle for a cached file. type fileHandle struct { file *os.File size int64 diff --git a/internal/cache/lazy_handle.go b/internal/cache/lazy_handle.go index 777de7a..beea582 100644 --- a/internal/cache/lazy_handle.go +++ b/internal/cache/lazy_handle.go @@ -10,12 +10,13 @@ import ( "sync" "github.com/meigma/blobber/core" + "github.com/meigma/blobber/internal/contracts" ) // Compile-time interface check. -var _ core.BlobHandle = (*lazyHandle)(nil) +var _ contracts.BlobHandle = (*lazyHandle)(nil) -// lazyHandle implements core.BlobHandle with on-demand range fetching. +// lazyHandle implements contracts.BlobHandle with on-demand range fetching. // It fetches only the byte ranges that are actually read, caching them // to disk for future access. This enables efficient selective file access // from eStargz archives without downloading the entire blob. diff --git a/internal/contracts/contracts.go b/internal/contracts/contracts.go new file mode 100644 index 0000000..3a49121 --- /dev/null +++ b/internal/contracts/contracts.go @@ -0,0 +1,133 @@ +// Package contracts defines internal interfaces shared across blobber components. +// These interfaces are intentionally internal to avoid exposing implementation +// contracts as part of the public API. +package contracts + +import ( + "context" + "io" + "io/fs" + + "github.com/meigma/blobber/core" +) + +// ArchiveBuilder creates eStargz blobs from files. +type ArchiveBuilder interface { + // Build creates an eStargz blob from the given filesystem. + // Returns a BuildResult containing the blob, TOC digest, blob digest, and size. + Build(ctx context.Context, src fs.FS, compression core.Compression) (*core.BuildResult, error) +} + +// ArchiveReader reads eStargz blobs. +// +// Note: The default implementation re-parses the eStargz archive on each call +// to ReadTOC or OpenFile. For efficient repeated access to the same archive, +// use Client.OpenImage which caches the parsed archive in an Image. +type ArchiveReader interface { + // ReadTOC extracts the TOC from an eStargz blob. + // The size parameter is the total blob size (needed for footer location). + ReadTOC(r io.ReaderAt, size int64) (*core.TOC, error) + + // OpenFile returns a reader for a specific file within an eStargz blob. + // The size parameter is the total blob size (needed for estargz.Open). + // The caller obtains size from the registry pull operation. + // + // Note: Each call re-parses the archive. For multiple file access, + // prefer Client.OpenImage which caches the parsed state. + OpenFile(r io.ReaderAt, size int64, entry core.TOCEntry) (io.Reader, error) +} + +// BlobHandle provides random access to a cached or remote blob. +// Implements io.ReaderAt for estargz compatibility. +type BlobHandle interface { + io.ReaderAt + io.Closer + // Size returns the total blob size in bytes. + Size() int64 + // Complete reports whether the entire blob is available locally. + Complete() bool +} + +// BlobSource provides access to blobs, either from cache or network. +// The cache implementation wraps a fallback source (typically the registry). +type BlobSource interface { + // Open returns a BlobHandle for random access to the blob. + // The ref parameter is used for fetching missing ranges during resume. + // The handle must be closed when done. + Open(ctx context.Context, ref string, desc core.LayerDescriptor) (BlobHandle, error) + // OpenStream returns a streaming reader for the blob. + // The ref parameter is used for fetching missing ranges during resume. + // More efficient for sequential reads like extraction. + OpenStream(ctx context.Context, ref string, desc core.LayerDescriptor) (io.ReadCloser, error) +} + +// Extractor extracts eStargz archives to the filesystem. +type Extractor interface { + // Extract extracts an eStargz blob to the destination directory. + Extract(ctx context.Context, r io.Reader, destDir string, validator PathValidator, limits core.ExtractLimits) error +} + +// PathValidator validates paths for security concerns. +type PathValidator interface { + // ValidatePath checks if a path is safe (no traversal, valid characters). + // Returns ErrPathTraversal if the path is unsafe. + ValidatePath(path string) error + + // ValidateExtraction checks if extracting the given entries to destDir is safe. + // Returns ErrPathTraversal for unsafe paths, ErrExtractLimits if limits exceeded. + ValidateExtraction(destDir string, entries []core.TOCEntry, limits core.ExtractLimits) error + + // ValidateSymlink checks if a symlink target is safe (stays within destDir). + // Returns ErrPathTraversal if the symlink would escape. + ValidateSymlink(destDir, linkPath, target string) error +} + +// Registry handles OCI registry operations. +type Registry interface { + // Push uploads a blob and creates a manifest. + // Returns the manifest digest. + Push(ctx context.Context, ref string, layer io.Reader, opts *core.RegistryPushOptions) (string, error) + + // Pull returns a reader for the image's layer blob and its size. + Pull(ctx context.Context, ref string) (io.ReadCloser, int64, error) + + // PullRange fetches a byte range from the layer blob. + // Used for selective file retrieval from eStargz. + PullRange(ctx context.Context, ref string, offset, length int64) (io.ReadCloser, error) + + // ResolveLayer resolves a reference to its layer descriptor. + // Returns the layer metadata without fetching the blob content. + // Used by the cache to check for hits before downloading. + ResolveLayer(ctx context.Context, ref string) (core.LayerDescriptor, error) + + // FetchBlob fetches a blob by its descriptor. + // Unlike Pull, this uses a known digest rather than resolving a ref. + FetchBlob(ctx context.Context, ref string, desc core.LayerDescriptor) (io.ReadCloser, error) + + // FetchBlobRange fetches a byte range from a blob by its descriptor. + // Used for resuming partial downloads and selective file access. + // Returns ErrRangeNotSupported if the registry doesn't support range requests. + FetchBlobRange(ctx context.Context, ref string, desc core.LayerDescriptor, offset, length int64) (io.ReadCloser, error) + + // PushReferrer pushes a referrer artifact that references the subject digest. + // The data is stored as a single layer in an OCI manifest with the subject field set. + // Returns the referrer manifest digest. + PushReferrer(ctx context.Context, ref string, subjectDigest string, data []byte, opts *core.ReferrerPushOptions) (string, error) + + // FetchReferrers returns all referrers for a subject digest, optionally filtered by artifact type. + // Uses the OCI 1.1 referrers API (GET /v2//referrers/?artifactType=...). + // Pass empty artifactType to fetch all referrers. + FetchReferrers(ctx context.Context, ref string, subjectDigest string, artifactType string) ([]core.Referrer, error) + + // FetchReferrer fetches the content of a specific referrer by its digest. + // Returns the first layer's content (the signature/attestation data). + FetchReferrer(ctx context.Context, ref string, referrerDigest string) ([]byte, error) + + // FetchManifest fetches the raw manifest bytes for a reference. + // Returns the manifest JSON and its digest. + FetchManifest(ctx context.Context, ref string) ([]byte, string, error) + + // ListTags returns all tags for a repository. + // The repository should be in the format "registry/namespace/repo" (e.g., "ghcr.io/org/repo"). + ListTags(ctx context.Context, repository string) ([]string, error) +} diff --git a/internal/registry/adapter.go b/internal/registry/adapter.go deleted file mode 100644 index 0533737..0000000 --- a/internal/registry/adapter.go +++ /dev/null @@ -1,16 +0,0 @@ -package registry - -import "github.com/meigma/blobber/core" - -// Adapter provides a narrow registry surface for blobber clients. -type Adapter struct { - *orasRegistry -} - -// Compile-time interface implementation check. -var _ core.Registry = (*Adapter)(nil) - -// NewAdapter creates a new adapter backed by the ORAS registry implementation. -func NewAdapter(opts ...Option) *Adapter { - return &Adapter{orasRegistry: New(opts...)} -} diff --git a/internal/registry/registry.go b/internal/registry/registry.go index b7be1b1..9bf19eb 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -25,10 +25,11 @@ import ( "oras.land/oras-go/v2/registry/remote/retry" "github.com/meigma/blobber/core" + "github.com/meigma/blobber/internal/contracts" ) // Compile-time interface implementation check. -var _ core.Registry = (*orasRegistry)(nil) +var _ contracts.Registry = (*orasRegistry)(nil) // Option configures an orasRegistry. type Option func(*orasRegistry) diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 5473292..0505e88 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -78,7 +78,7 @@ func TestOrasRegistry_InterfaceCompliance(t *testing.T) { t.Parallel() // This test verifies the compile-time interface check works. - // The actual check is: var _ core.Registry = (*orasRegistry)(nil) + // The actual check is: var _ contracts.Registry = (*orasRegistry)(nil) // If the interface isn't satisfied, the code won't compile. r := New() assert.NotNil(t, r) diff --git a/internal/safepath/adapter.go b/internal/safepath/adapter.go deleted file mode 100644 index 4becbd0..0000000 --- a/internal/safepath/adapter.go +++ /dev/null @@ -1,16 +0,0 @@ -package safepath - -import "github.com/meigma/blobber/core" - -// Adapter provides the path validation surface for blobber clients. -type Adapter struct { - *Validator -} - -// Compile-time interface implementation check. -var _ core.PathValidator = (*Adapter)(nil) - -// NewAdapter creates a new adapter for path validation. -func NewAdapter() *Adapter { - return &Adapter{Validator: NewValidator()} -} diff --git a/internal/safepath/safepath.go b/internal/safepath/safepath.go index 36e84fd..586b1f5 100644 --- a/internal/safepath/safepath.go +++ b/internal/safepath/safepath.go @@ -11,12 +11,13 @@ import ( "strings" "github.com/meigma/blobber/core" + "github.com/meigma/blobber/internal/contracts" ) // Compile-time interface implementation check. -var _ core.PathValidator = (*Validator)(nil) +var _ contracts.PathValidator = (*Validator)(nil) -// Validator implements blobber.PathValidator. +// Validator implements contracts.PathValidator. type Validator struct{} // NewValidator creates a new PathValidator.