Skip to content

Commit 75daed8

Browse files
committed
Add extract command to retrieve files from image layers
Adds a new "extract" subcommand that extracts file contents from container image layers. Layers are searched in reverse order so the result matches the effective filesystem of a running container. Supports writing to stdout (default) or to a directory via --output_dir.
1 parent 5540809 commit 75daed8

1 file changed

Lines changed: 134 additions & 0 deletions

File tree

main.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import (
55
"fmt"
66
"io"
77
"os"
8+
"path/filepath"
89
"sort"
910
"strconv"
11+
"strings"
1012
"text/tabwriter"
1113

1214
"github.com/dustin/go-humanize"
@@ -61,6 +63,27 @@ func main() {
6163
},
6264
},
6365
},
66+
{
67+
Name: "extract",
68+
Usage: "extract files from an image and print to stdout",
69+
Action: func(c *cli.Context) error {
70+
cfg := &config{
71+
ref: c.Args().First(),
72+
files: c.Args().Tail(),
73+
outputDir: c.String("output_dir"),
74+
}
75+
if err := extract(cfg); err != nil {
76+
return cli.Exit(c.Command.Name+": "+err.Error(), 1)
77+
}
78+
return nil
79+
},
80+
Flags: []cli.Flag{
81+
&cli.StringFlag{
82+
Name: "output_dir",
83+
Usage: "Write extracted files to this directory instead of stdout",
84+
},
85+
},
86+
},
6487
},
6588
}
6689

@@ -75,6 +98,10 @@ type config struct {
7598
layerIDs []string
7699
// sort is true if the output should be sorted by size.
77100
sort bool
101+
// files is the list of file paths to extract.
102+
files []string
103+
// outputDir is the directory to write extracted files to.
104+
outputDir string
78105
}
79106

80107
// makeOptions returns the options for crane.
@@ -271,3 +298,110 @@ func files(cfg *config, layer v1.Layer) error {
271298

272299
return tw.Flush()
273300
}
301+
302+
// extract extracts files from an image and writes them to stdout or a directory.
303+
func extract(cfg *config) error {
304+
if len(cfg.files) == 0 {
305+
return fmt.Errorf("no files specified")
306+
}
307+
308+
image, err := getImage(cfg.ref)
309+
if err != nil {
310+
return err
311+
}
312+
313+
layers, err := image.Layers()
314+
if err != nil {
315+
return fmt.Errorf("getting layers: %w", err)
316+
}
317+
318+
// Build a set of wanted files for quick lookup.
319+
// Normalize by stripping leading slash.
320+
wanted := make(map[string]bool, len(cfg.files))
321+
for _, f := range cfg.files {
322+
wanted[strings.TrimPrefix(f, "/")] = true
323+
}
324+
325+
found := make(map[string]bool, len(cfg.files))
326+
327+
// Search layers in reverse order (last wins) to match container runtime behavior.
328+
for i := len(layers) - 1; i >= 0; i-- {
329+
layer := layers[i]
330+
331+
uncompressed, err := layer.Uncompressed()
332+
if err != nil {
333+
return fmt.Errorf("getting layer: %w", err)
334+
}
335+
336+
tarReader := tar.NewReader(uncompressed)
337+
for {
338+
header, err := tarReader.Next()
339+
if err == io.EOF {
340+
break
341+
}
342+
if err != nil {
343+
uncompressed.Close()
344+
return fmt.Errorf("reading tar: %w", err)
345+
}
346+
347+
name := strings.TrimPrefix(header.Name, "./")
348+
name = strings.TrimPrefix(name, "/")
349+
350+
if !wanted[name] || found[name] {
351+
continue
352+
}
353+
354+
if err := extractFile(cfg, name, tarReader); err != nil {
355+
uncompressed.Close()
356+
return err
357+
}
358+
found[name] = true
359+
360+
// Stop early if all files found.
361+
if len(found) == len(wanted) {
362+
uncompressed.Close()
363+
return nil
364+
}
365+
}
366+
uncompressed.Close()
367+
}
368+
369+
// Report any files not found.
370+
var missing []string
371+
for _, f := range cfg.files {
372+
name := strings.TrimPrefix(f, "/")
373+
if !found[name] {
374+
missing = append(missing, f)
375+
}
376+
}
377+
if len(missing) > 0 {
378+
return fmt.Errorf("files not found: %s", strings.Join(missing, ", "))
379+
}
380+
381+
return nil
382+
}
383+
384+
// extractFile writes the contents of a tar entry to stdout or to a file under outputDir.
385+
func extractFile(cfg *config, name string, r io.Reader) error {
386+
if cfg.outputDir == "" {
387+
_, err := io.Copy(os.Stdout, r)
388+
return err
389+
}
390+
391+
outPath := filepath.Join(cfg.outputDir, name)
392+
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
393+
return fmt.Errorf("creating directory for %s: %w", name, err)
394+
}
395+
396+
f, err := os.Create(outPath)
397+
if err != nil {
398+
return fmt.Errorf("creating file %s: %w", outPath, err)
399+
}
400+
defer f.Close()
401+
402+
if _, err := io.Copy(f, r); err != nil {
403+
return fmt.Errorf("writing file %s: %w", outPath, err)
404+
}
405+
406+
return nil
407+
}

0 commit comments

Comments
 (0)