Files

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)
}