Files

126 lines
3.8 KiB
Go

package mapper
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
ccontent "github.com/containerd/containerd/v2/core/content"
"github.com/containerd/containerd/v2/core/remotes"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors"
"hauler.dev/go/hauler/v2/pkg/content"
)
// NewMapperFileStore creates a new file store that uses mapper functions for each detected descriptor.
//
// This extends content.OCI, and differs in that it allows much more functionality into how each descriptor is written.
func NewMapperFileStore(root string, mapper map[string]Fn) (*store, error) {
fs, err := content.NewOCI(root)
if err != nil {
return nil, err
}
return &store{
OCI: fs,
mapper: mapper,
}, nil
}
func (s *store) Pusher(ctx context.Context, ref string) (remotes.Pusher, error) {
var tag, hash string
parts := strings.SplitN(ref, "@", 2)
if len(parts) > 0 {
tag = parts[0]
}
if len(parts) > 1 {
hash = parts[1]
}
return &pusher{
store: s.OCI,
tag: tag,
ref: hash,
mapper: s.mapper,
}, nil
}
type store struct {
*content.OCI
mapper map[string]Fn
}
func (s *pusher) Push(ctx context.Context, desc ocispec.Descriptor) (ccontent.Writer, error) {
// For manifests and indexes (which have AnnotationRefName), discard them
// They're metadata and don't need to be extracted
if _, ok := content.ResolveName(desc); ok {
// Discard manifests/indexes, they're just metadata
return content.NewIoContentWriter(&nopCloser{io.Discard}, content.WithOutputHash(desc.Digest.String())), nil
}
// Check if this descriptor has a mapper for its media type
mapperFn, hasMapper := s.mapper[desc.MediaType]
if !hasMapper {
// Fall back to catch-all sentinel, then discard
mapperFn, hasMapper = s.mapper[DefaultCatchAll]
}
if !hasMapper {
// No mapper for this media type, discard it (config blobs, etc.)
return content.NewIoContentWriter(&nopCloser{io.Discard}, content.WithOutputHash(desc.Digest.String())), nil
}
// Get the filename from the mapper function.
// An empty filename means the mapper explicitly declined this descriptor (e.g. a
// config blob that has no title annotation... treat it the same as no mapper.
filename, err := mapperFn(desc)
if err != nil {
return nil, err
}
if filename == "" {
return content.NewIoContentWriter(&nopCloser{io.Discard}, content.WithOutputHash(desc.Digest.String())), nil
}
// Get the destination directory and create the full path.
// Use absolute paths so the traversal check works even when destDir is relative (e.g. ".").
destDir, err := filepath.Abs(s.store.ResolvePath(""))
if err != nil {
return nil, errors.Wrap(err, "resolving destination dir")
}
fullFileName := filepath.Join(destDir, filename)
// Guard against path traversal (e.g. "../"). filepath.Rel handles this
// correctly even when destDir is "/", unlike a plain prefix check.
rel, err := filepath.Rel(destDir, fullFileName)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return nil, fmt.Errorf("path_traversal_disallowed: %q resolves outside destination dir", filename)
}
// Create parent directories (e.g. when filename is "subdir/file.txt")
if err := os.MkdirAll(filepath.Dir(fullFileName), 0755); err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("creating directory for %s", fullFileName))
}
// Create the file
f, err := os.OpenFile(fullFileName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("creating file %s", fullFileName))
}
w := content.NewIoContentWriter(f, content.WithOutputHash(desc.Digest.String()))
return w, nil
}
type nopCloser struct {
io.Writer
}
func (*nopCloser) Close() error { return nil }
type pusher struct {
store *content.OCI
tag string
ref string
mapper map[string]Fn
}