@@ -8,10 +8,12 @@ import (
88 "errors"
99 "fmt"
1010 "io"
11+ "io/fs"
1112 "maps"
1213 "net/http"
1314 "os"
1415 "path/filepath"
16+ "strings"
1517
1618 "github.com/containers/common/libimage"
1719 "github.com/containers/image/v5/manifest"
@@ -254,6 +256,161 @@ func (as ArtifactStore) Add(ctx context.Context, dest string, paths []string, op
254256 return & artifactManifestDigest , nil
255257}
256258
259+ // Inspect an artifact in a local store
260+ func (as ArtifactStore ) Extract (ctx context.Context , nameOrDigest string , target string , options * libartTypes.ExtractOptions ) error {
261+ if len (options .Digest ) > 0 && len (options .Title ) > 0 {
262+ return errors .New ("cannot specify both digest and title" )
263+ }
264+ if len (nameOrDigest ) == 0 {
265+ return ErrEmptyArtifactName
266+ }
267+
268+ artifacts , err := as .getArtifacts (ctx , nil )
269+ if err != nil {
270+ return err
271+ }
272+
273+ arty , nameIsDigest , err := artifacts .GetByNameOrDigest (nameOrDigest )
274+ if err != nil {
275+ return err
276+ }
277+ name := nameOrDigest
278+ if nameIsDigest {
279+ name = arty .Name
280+ }
281+
282+ if len (arty .Manifest .Layers ) == 0 {
283+ return fmt .Errorf ("the artifact has no blobs, nothing to extract" )
284+ }
285+
286+ ir , err := layout .NewReference (as .storePath , name )
287+ if err != nil {
288+ return err
289+ }
290+ imgSrc , err := ir .NewImageSource (ctx , as .SystemContext )
291+ if err != nil {
292+ return err
293+ }
294+ defer imgSrc .Close ()
295+
296+ // check if dest is a dir to know if we can copy more than one blob
297+ destIsFile := true
298+ stat , err := os .Stat (target )
299+ if err == nil {
300+ destIsFile = ! stat .IsDir ()
301+ } else if ! errors .Is (err , fs .ErrNotExist ) {
302+ return err
303+ }
304+
305+ if destIsFile {
306+ var digest digest.Digest
307+ if len (arty .Manifest .Layers ) > 1 {
308+ if len (options .Digest ) == 0 && len (options .Title ) == 0 {
309+ return fmt .Errorf ("the artifact consists of several blobs and the target %q is not a directory and neither digest or title was specified to only copy a single blob" , target )
310+ }
311+ digest , err = findDigest (arty , options )
312+ if err != nil {
313+ return err
314+ }
315+ } else {
316+ digest = arty .Manifest .Layers [0 ].Digest
317+ }
318+
319+ return copyImageBlobToFile (ctx , imgSrc , digest , target )
320+ }
321+
322+ if len (options .Digest ) > 0 || len (options .Title ) > 0 {
323+ digest , err := findDigest (arty , options )
324+ if err != nil {
325+ return err
326+ }
327+ // In case the digest is set we always use it as target name
328+ // so we do not have to get the actual title annotation form the blob.
329+ // Passing options.Title is enough because we know it is empty when digest
330+ // is set as we only allow either one.
331+ filename , err := generateArtifactBlobName (options .Title , digest )
332+ if err != nil {
333+ return err
334+ }
335+ return copyImageBlobToFile (ctx , imgSrc , digest , filepath .Join (target , filename ))
336+ }
337+
338+ for _ , l := range arty .Manifest .Layers {
339+ title := l .Annotations [specV1 .AnnotationTitle ]
340+ filename , err := generateArtifactBlobName (title , l .Digest )
341+ if err != nil {
342+ return err
343+ }
344+ err = copyImageBlobToFile (ctx , imgSrc , l .Digest , filepath .Join (target , filename ))
345+ if err != nil {
346+ return err
347+ }
348+ }
349+
350+ return nil
351+ }
352+
353+ func generateArtifactBlobName (title string , digest digest.Digest ) (string , error ) {
354+ if len (title ) > 0 {
355+ // Important: A potentially malicious artifact could contain a title name with "/"
356+ // and could try via relative paths such as "../" try to overwrite files on the host
357+ // the user did not intend. As there is no use for directories in this path we
358+ // disallow all of them and not try to "make it safe" via securejoin or others.
359+ if strings .ContainsRune (title , os .PathSeparator ) {
360+ return "" , fmt .Errorf ("invalid name: title %q cannot contain %c" , title , os .PathSeparator )
361+ }
362+ return title , nil
363+ }
364+ // No filename given, use the digest. But because ":" is not a valid path char
365+ // on all platforms replace it with "-".
366+ return strings .ReplaceAll (digest .String (), ":" , "-" ), nil
367+ }
368+
369+ func findDigest (arty * libartifact.Artifact , options * libartTypes.ExtractOptions ) (digest.Digest , error ) {
370+ var digest digest.Digest
371+ for _ , l := range arty .Manifest .Layers {
372+ if options .Digest == l .Digest .String () {
373+ if len (digest .String ()) > 0 {
374+ return digest , fmt .Errorf ("more than one match for the digest %q" , options .Digest )
375+ }
376+ digest = l .Digest
377+ }
378+ if len (options .Title ) > 0 {
379+ if val , ok := l .Annotations [specV1 .AnnotationTitle ]; ok &&
380+ val == options .Title {
381+ if len (digest .String ()) > 0 {
382+ return digest , fmt .Errorf ("more than one match for the title %q" , options .Title )
383+ }
384+ digest = l .Digest
385+ }
386+ }
387+ }
388+ if len (digest .String ()) == 0 {
389+ if len (options .Title ) > 0 {
390+ return digest , fmt .Errorf ("no blob with the title %q" , options .Title )
391+ }
392+ return digest , fmt .Errorf ("no blob with the digest %q" , options .Digest )
393+ }
394+ return digest , nil
395+ }
396+
397+ func copyImageBlobToFile (ctx context.Context , imgSrc types.ImageSource , digest digest.Digest , target string ) error {
398+ src , _ , err := imgSrc .GetBlob (ctx , types.BlobInfo {Digest : digest }, nil )
399+ if err != nil {
400+ return fmt .Errorf ("failed to get artifact file: %w" , err )
401+ }
402+ defer src .Close ()
403+ dest , err := os .Create (target )
404+ if err != nil {
405+ return fmt .Errorf ("failed to create target file: %w" , err )
406+ }
407+ defer dest .Close ()
408+
409+ // TODO use reflink is possible
410+ _ , err = io .Copy (dest , src )
411+ return err
412+ }
413+
257414// readIndex is currently unused but I want to keep this around until
258415// the artifact code is more mature.
259416func (as ArtifactStore ) readIndex () (* specV1.Index , error ) { //nolint:unused
0 commit comments