mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 05:07:09 +00:00
271 lines
8.0 KiB
Go
271 lines
8.0 KiB
Go
package scanner
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// extractLayers extracts all image layers from storage to a temporary directory
|
|
// Returns the directory path and a cleanup function
|
|
func (w *Worker) extractLayers(ctx context.Context, job *ScanJob) (string, func(), error) {
|
|
// Create temp directory for extraction
|
|
// Use the database directory as the base (since we're in a scratch container with no /tmp)
|
|
scanTmpBase := filepath.Join(w.config.Database.Path, "scanner-tmp")
|
|
if err := os.MkdirAll(scanTmpBase, 0755); err != nil {
|
|
return "", nil, fmt.Errorf("failed to create scanner temp base: %w", err)
|
|
}
|
|
|
|
tmpDir, err := os.MkdirTemp(scanTmpBase, "scan-*")
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("failed to create temp directory: %w", err)
|
|
}
|
|
|
|
cleanup := func() {
|
|
if err := os.RemoveAll(tmpDir); err != nil {
|
|
slog.Warn("Failed to clean up temp directory", "dir", tmpDir, "error", err)
|
|
}
|
|
}
|
|
|
|
// Create image directory structure
|
|
imageDir := filepath.Join(tmpDir, "image")
|
|
if err := os.MkdirAll(imageDir, 0755); err != nil {
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("failed to create image directory: %w", err)
|
|
}
|
|
|
|
// Download and extract config blob
|
|
slog.Info("Downloading config blob", "digest", job.Config.Digest)
|
|
configPath := filepath.Join(imageDir, "config.json")
|
|
if err := w.downloadBlob(ctx, job.Config.Digest, configPath); err != nil {
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("failed to download config blob: %w", err)
|
|
}
|
|
|
|
// Validate config is valid JSON
|
|
configData, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("failed to read config: %w", err)
|
|
}
|
|
var configObj map[string]interface{}
|
|
if err := json.Unmarshal(configData, &configObj); err != nil {
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("invalid config JSON: %w", err)
|
|
}
|
|
|
|
// Create layers directory for extracted content
|
|
layersDir := filepath.Join(imageDir, "layers")
|
|
if err := os.MkdirAll(layersDir, 0755); err != nil {
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("failed to create layers directory: %w", err)
|
|
}
|
|
|
|
// Download and extract each layer in order (creating overlayfs-style filesystem)
|
|
rootfsDir := filepath.Join(imageDir, "rootfs")
|
|
if err := os.MkdirAll(rootfsDir, 0755); err != nil {
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("failed to create rootfs directory: %w", err)
|
|
}
|
|
|
|
for i, layer := range job.Layers {
|
|
slog.Info("Extracting layer", "index", i, "digest", layer.Digest, "size", layer.Size)
|
|
|
|
// Download layer blob to temp file
|
|
layerPath := filepath.Join(layersDir, fmt.Sprintf("layer-%d.tar.gz", i))
|
|
if err := w.downloadBlob(ctx, layer.Digest, layerPath); err != nil {
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("failed to download layer %d: %w", i, err)
|
|
}
|
|
|
|
// Extract layer on top of rootfs (overlayfs style)
|
|
if err := w.extractTarGz(layerPath, rootfsDir); err != nil {
|
|
cleanup()
|
|
return "", nil, fmt.Errorf("failed to extract layer %d: %w", i, err)
|
|
}
|
|
|
|
// Remove layer tar.gz to save space
|
|
os.Remove(layerPath)
|
|
}
|
|
|
|
// Check what was extracted
|
|
entries, err := os.ReadDir(rootfsDir)
|
|
if err != nil {
|
|
slog.Warn("Failed to read rootfs directory", "error", err)
|
|
} else {
|
|
slog.Info("Successfully extracted image",
|
|
"layers", len(job.Layers),
|
|
"rootfs", rootfsDir,
|
|
"topLevelEntries", len(entries),
|
|
"sampleEntries", func() []string {
|
|
var samples []string
|
|
for i, e := range entries {
|
|
if i >= 10 {
|
|
break
|
|
}
|
|
samples = append(samples, e.Name())
|
|
}
|
|
return samples
|
|
}())
|
|
}
|
|
|
|
return rootfsDir, cleanup, nil
|
|
}
|
|
|
|
// downloadBlob downloads a blob from storage to a local file
|
|
func (w *Worker) downloadBlob(ctx context.Context, digest, destPath string) error {
|
|
// Convert digest to storage path using distribution's sharding scheme
|
|
// Format: /docker/registry/v2/blobs/sha256/47/4734bc89.../data
|
|
// where 47 is the first 2 characters of the hash for directory sharding
|
|
blobPath := blobPathForDigest(digest)
|
|
|
|
// Open blob from storage driver
|
|
reader, err := w.driver.Reader(ctx, blobPath, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open blob %s: %w", digest, err)
|
|
}
|
|
defer reader.Close()
|
|
|
|
// Create destination file
|
|
dest, err := os.Create(destPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create destination file: %w", err)
|
|
}
|
|
defer dest.Close()
|
|
|
|
// Copy blob data to file
|
|
if _, err := io.Copy(dest, reader); err != nil {
|
|
return fmt.Errorf("failed to copy blob data: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// extractTarGz extracts a tar.gz file to a destination directory (overlayfs style)
|
|
func (w *Worker) extractTarGz(tarGzPath, destDir string) error {
|
|
// Open tar.gz file
|
|
file, err := os.Open(tarGzPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open tar.gz: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
// Create gzip reader
|
|
gzr, err := gzip.NewReader(file)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create gzip reader: %w", err)
|
|
}
|
|
defer gzr.Close()
|
|
|
|
// Create tar reader
|
|
tr := tar.NewReader(gzr)
|
|
|
|
// Extract each file
|
|
for {
|
|
header, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read tar header: %w", err)
|
|
}
|
|
|
|
// Build target path (clean to prevent path traversal)
|
|
target := filepath.Join(destDir, filepath.Clean(header.Name))
|
|
|
|
// Ensure target is within destDir (security check)
|
|
if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) {
|
|
slog.Warn("Skipping path outside destination", "path", header.Name)
|
|
continue
|
|
}
|
|
|
|
switch header.Typeflag {
|
|
case tar.TypeDir:
|
|
// Create directory
|
|
if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil {
|
|
return fmt.Errorf("failed to create directory %s: %w", target, err)
|
|
}
|
|
|
|
case tar.TypeReg:
|
|
// Create parent directory
|
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
|
return fmt.Errorf("failed to create parent directory: %w", err)
|
|
}
|
|
|
|
// Create file
|
|
outFile, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create file %s: %w", target, err)
|
|
}
|
|
|
|
// Copy file contents
|
|
if _, err := io.Copy(outFile, tr); err != nil {
|
|
outFile.Close()
|
|
return fmt.Errorf("failed to write file %s: %w", target, err)
|
|
}
|
|
outFile.Close()
|
|
|
|
case tar.TypeSymlink:
|
|
// Create symlink
|
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
|
return fmt.Errorf("failed to create parent directory for symlink: %w", err)
|
|
}
|
|
|
|
// Remove existing file/symlink if it exists
|
|
os.Remove(target)
|
|
|
|
if err := os.Symlink(header.Linkname, target); err != nil {
|
|
slog.Warn("Failed to create symlink", "target", target, "link", header.Linkname, "error", err)
|
|
}
|
|
|
|
case tar.TypeLink:
|
|
// Create hard link
|
|
linkTarget := filepath.Join(destDir, filepath.Clean(header.Linkname))
|
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
|
return fmt.Errorf("failed to create parent directory for hardlink: %w", err)
|
|
}
|
|
|
|
// Remove existing file if it exists
|
|
os.Remove(target)
|
|
|
|
if err := os.Link(linkTarget, target); err != nil {
|
|
slog.Warn("Failed to create hardlink", "target", target, "link", linkTarget, "error", err)
|
|
}
|
|
|
|
default:
|
|
slog.Debug("Skipping unsupported tar entry type", "type", header.Typeflag, "name", header.Name)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// blobPathForDigest converts a digest to a storage path using distribution's sharding scheme
|
|
// Format: /docker/registry/v2/blobs/sha256/47/4734bc89.../data
|
|
// where 47 is the first 2 characters of the hash for directory sharding
|
|
func blobPathForDigest(digest string) string {
|
|
// Split digest into algorithm and hash
|
|
parts := strings.SplitN(digest, ":", 2)
|
|
if len(parts) != 2 {
|
|
// Fallback for malformed digest
|
|
return fmt.Sprintf("/docker/registry/v2/blobs/%s/data", digest)
|
|
}
|
|
|
|
algorithm := parts[0]
|
|
hash := parts[1]
|
|
|
|
// Use first 2 characters for sharding
|
|
if len(hash) < 2 {
|
|
return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/data", algorithm, hash)
|
|
}
|
|
|
|
return fmt.Sprintf("/docker/registry/v2/blobs/%s/%s/%s/data", algorithm, hash[:2], hash)
|
|
}
|