diff --git a/.gitignore b/.gitignore index 0248ce324..50f67b535 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ code-of-conduct.md -oci-image-tool -oci-validate-examples +/oci-image-tool +/oci-validate-examples output diff --git a/cmd/oci-image-tool/autodetect.go b/cmd/oci-image-tool/autodetect.go new file mode 100644 index 000000000..32dad3c18 --- /dev/null +++ b/cmd/oci-image-tool/autodetect.go @@ -0,0 +1,103 @@ +// Copyright 2016 The Linux Foundation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/json" + "io" + "io/ioutil" + "net/http" + "os" + + "github.com/opencontainers/image-spec/schema" + "github.com/pkg/errors" +) + +// supported autodetection types +const ( + typeImageLayout = "imageLayout" + typeImage = "image" + typeManifest = "manifest" + typeManifestList = "manifestList" + typeConfig = "config" +) + +// autodetect detects the validation type for the given path +// or an error if the validation type could not be resolved. +func autodetect(path string) (string, error) { + fi, err := os.Stat(path) + if err != nil { + return "", errors.Wrapf(err, "unable to access path") // err from os.Stat includes path name + } + + if fi.IsDir() { + return typeImageLayout, nil + } + + f, err := os.Open(path) + if err != nil { + return "", errors.Wrap(err, "unable to open file") // os.Open includes the filename + } + defer f.Close() + + buf, err := ioutil.ReadAll(io.LimitReader(f, 512)) // read some initial bytes to detect content + if err != nil { + return "", errors.Wrap(err, "unable to read") + } + + mimeType := http.DetectContentType(buf) + + switch mimeType { + case "application/x-gzip": + return typeImage, nil + + case "application/octet-stream": + return typeImage, nil + + case "text/plain; charset=utf-8": + // might be a JSON file, will be handled below + + default: + return "", errors.New("unknown file type") + } + + if _, err := f.Seek(0, os.SEEK_SET); err != nil { + return "", errors.Wrap(err, "unable to seek") + } + + header := struct { + SchemaVersion int `json:"schemaVersion"` + MediaType string `json:"mediaType"` + Config interface{} `json:"config"` + }{} + + if err := json.NewDecoder(f).Decode(&header); err != nil { + return "", errors.Wrap(err, "unable to parse JSON") + } + + switch { + case header.MediaType == string(schema.MediaTypeManifest): + return typeManifest, nil + + case header.MediaType == string(schema.MediaTypeManifestList): + return typeManifestList, nil + + case header.MediaType == "" && header.SchemaVersion == 0 && header.Config != nil: + // config files don't have mediaType/schemaVersion header + return typeConfig, nil + } + + return "", errors.New("unknown media type") +} diff --git a/cmd/oci-image-tool/main.go b/cmd/oci-image-tool/main.go index 9206f28bd..ab911bb39 100644 --- a/cmd/oci-image-tool/main.go +++ b/cmd/oci-image-tool/main.go @@ -31,6 +31,8 @@ func main() { stderr := log.New(os.Stderr, "", 0) cmd.AddCommand(newValidateCmd(stdout, stderr)) + cmd.AddCommand(newUnpackCmd(stdout, stderr)) + if err := cmd.Execute(); err != nil { stderr.Println(err) os.Exit(1) diff --git a/cmd/oci-image-tool/unpack.go b/cmd/oci-image-tool/unpack.go new file mode 100644 index 000000000..ee9e67ad8 --- /dev/null +++ b/cmd/oci-image-tool/unpack.go @@ -0,0 +1,102 @@ +// Copyright 2016 The Linux Foundation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "log" + "os" + "strings" + + "github.com/opencontainers/image-spec/image" + "github.com/spf13/cobra" +) + +// supported unpack types +var unpackTypes = []string{ + typeImageLayout, + typeImage, +} + +type unpackCmd struct { + stdout *log.Logger + stderr *log.Logger + typ string // the type to validate, can be empty string + ref string +} + +func newUnpackCmd(stdout, stderr *log.Logger) *cobra.Command { + v := &unpackCmd{ + stdout: stdout, + stderr: stderr, + } + + cmd := &cobra.Command{ + Use: "unpack [src] [dest]", + Short: "Unpack an image or image source layout", + Long: `Unpack the OCI image .tar file or OCI image layout directory present at [src] to the destination directory [dest].`, + Run: v.Run, + } + + cmd.Flags().StringVar( + &v.typ, "type", "", + fmt.Sprintf( + `Type of the file to unpack. If unset, oci-image-tool will try to auto-detect the type. One of "%s"`, + strings.Join(unpackTypes, ","), + ), + ) + + cmd.Flags().StringVar( + &v.ref, "ref", "v1.0", + `The ref pointing to the manifest to be unpacked. This must be present in the "refs" subdirectory of the image.`, + ) + + return cmd +} + +func (v *unpackCmd) Run(cmd *cobra.Command, args []string) { + if len(args) != 2 { + v.stderr.Print("both src and dest must be provided") + if err := cmd.Usage(); err != nil { + v.stderr.Println(err) + } + os.Exit(1) + } + + if v.typ == "" { + typ, err := autodetect(args[0]) + if err != nil { + v.stderr.Printf("%q: autodetection failed: %v", args[0], err) + os.Exit(1) + } + v.typ = typ + } + + var err error + switch v.typ { + case typeImageLayout: + err = image.UnpackLayout(args[0], args[1], v.ref) + + case typeImage: + err = image.Unpack(args[0], args[1], v.ref) + } + + if err != nil { + v.stderr.Printf("unpacking failed: %v", err) + os.Exit(1) + } + + os.Exit(0) +} diff --git a/cmd/oci-image-tool/validate.go b/cmd/oci-image-tool/validate.go index c448eeb54..fb7a3e156 100644 --- a/cmd/oci-image-tool/validate.go +++ b/cmd/oci-image-tool/validate.go @@ -15,29 +15,18 @@ package main import ( - "encoding/json" "fmt" - "io" - "io/ioutil" "log" - "net/http" "os" "strings" + "github.com/opencontainers/image-spec/image" "github.com/opencontainers/image-spec/schema" "github.com/pkg/errors" "github.com/spf13/cobra" ) // supported validation types -const ( - typeImageLayout = "imageLayout" - typeImage = "image" - typeManifest = "manifest" - typeManifestList = "manifestList" - typeConfig = "config" -) - var validateTypes = []string{ typeImageLayout, typeImage, @@ -50,6 +39,7 @@ type validateCmd struct { stdout *log.Logger stderr *log.Logger typ string // the type to validate, can be empty string + ref string } func newValidateCmd(stdout, stderr *log.Logger) *cobra.Command { @@ -67,11 +57,16 @@ func newValidateCmd(stdout, stderr *log.Logger) *cobra.Command { cmd.Flags().StringVar( &v.typ, "type", "", fmt.Sprintf( - `Type of the file to validate. If unset, oci-image-tool will try to auto-detect the type. One of "%s"`, + `Type of the file to validate. If unset, oci-image-tool will try to auto-detect the type. One of "%s".`, strings.Join(validateTypes, ","), ), ) + cmd.Flags().StringVar( + &v.ref, "ref", "v1.0", + `The ref pointing to the manifest to be validated. This must be present in the "refs" subdirectory of the image. Only applicable if type is image or imageLayout.`, + ) + return cmd } @@ -89,7 +84,7 @@ func (v *validateCmd) Run(cmd *cobra.Command, args []string) { err := v.validatePath(arg) if err == nil { - v.stdout.Printf("file %s: OK", arg) + v.stdout.Printf("%s: OK", arg) continue } @@ -97,13 +92,13 @@ func (v *validateCmd) Run(cmd *cobra.Command, args []string) { if verr, ok := errors.Cause(err).(schema.ValidationError); ok { errs = verr.Errs } else { - v.stderr.Printf("file %s: validation failed: %v", arg, err) + v.stderr.Printf("%s: validation failed: %v", arg, err) exitcode = 1 continue } for _, err := range errs { - v.stderr.Printf("file %s: validation failed: %v", arg, err) + v.stderr.Printf("%s: validation failed: %v", arg, err) } exitcode = 1 @@ -122,6 +117,13 @@ func (v *validateCmd) validatePath(name string) error { } } + switch typ { + case typeImageLayout: + return image.ValidateLayout(name, v.ref) + case typeImage: + return image.Validate(name, v.ref) + } + f, err := os.Open(name) if err != nil { return errors.Wrap(err, "unable to open file") @@ -130,92 +132,14 @@ func (v *validateCmd) validatePath(name string) error { switch typ { case typeManifest: - if err := schema.MediaTypeManifest.Validate(f); err != nil { - return err - } + return schema.MediaTypeManifest.Validate(f) - return nil case typeManifestList: - if err := schema.MediaTypeManifestList.Validate(f); err != nil { - return err - } + return schema.MediaTypeManifestList.Validate(f) - return nil case typeConfig: - if err := schema.MediaTypeImageSerializationConfig.Validate(f); err != nil { - return err - } - - return nil + return schema.MediaTypeImageSerializationConfig.Validate(f) } return fmt.Errorf("type %q unimplemented", typ) } - -// autodetect detects the validation type for the given path -// or an error if the validation type could not be resolved. -func autodetect(path string) (string, error) { - fi, err := os.Stat(path) - if err != nil { - return "", errors.Wrapf(err, "unable to access path") // err from os.Stat includes path name - } - - if fi.IsDir() { - return typeImageLayout, nil - } - - f, err := os.Open(path) - if err != nil { - return "", errors.Wrap(err, "unable to open file") // os.Open includes the filename - } - defer f.Close() - - buf, err := ioutil.ReadAll(io.LimitReader(f, 512)) // read some initial bytes to detect content - if err != nil { - return "", errors.Wrap(err, "unable to read") - } - - mimeType := http.DetectContentType(buf) - - switch mimeType { - case "application/x-gzip": - return typeImage, nil - - case "application/octet-stream": - return typeImage, nil - - case "text/plain; charset=utf-8": - // might be a JSON file, will be handled below - - default: - return "", errors.New("unknown file type") - } - - if _, err := f.Seek(0, os.SEEK_SET); err != nil { - return "", errors.Wrap(err, "unable to seek") - } - - header := struct { - SchemaVersion int `json:"schemaVersion"` - MediaType string `json:"mediaType"` - Config interface{} `json:"config"` - }{} - - if err := json.NewDecoder(f).Decode(&header); err != nil { - return "", errors.Wrap(err, "unable to parse JSON") - } - - switch { - case header.MediaType == string(schema.MediaTypeManifest): - return typeManifest, nil - - case header.MediaType == string(schema.MediaTypeManifestList): - return typeManifestList, nil - - case header.MediaType == "" && header.SchemaVersion == 0 && header.Config != nil: - // config files don't have mediaType/schemaVersion header - return typeConfig, nil - } - - return "", errors.New("unknown media type") -} diff --git a/image/descriptor.go b/image/descriptor.go new file mode 100644 index 000000000..ca576e617 --- /dev/null +++ b/image/descriptor.go @@ -0,0 +1,115 @@ +// Copyright 2016 The Linux Foundation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package image + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/pkg/errors" +) + +type descriptor struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + Size int64 `json:"size"` +} + +func findDescriptor(w walker, name string) (*descriptor, error) { + var d descriptor + dpath := filepath.Join("refs", name) + + f := func(path string, info os.FileInfo, r io.Reader) error { + if info.IsDir() { + return nil + } + + if filepath.Clean(path) != dpath { + return nil + } + + if err := json.NewDecoder(r).Decode(&d); err != nil { + return err + } + + return errEOW + } + + switch err := w.walk(f); err { + case nil: + return nil, fmt.Errorf("%s: descriptor not found", dpath) + case errEOW: + // found, continue below + default: + return nil, err + } + + return &d, nil +} + +func (d *descriptor) validate(w walker) error { + f := func(path string, info os.FileInfo, r io.Reader) error { + if info.IsDir() { + return nil + } + + digest, err := filepath.Rel("blobs", filepath.Clean(path)) + if err != nil || d.Digest != digest { + return nil // ignore + } + + if err := d.validateContent(r); err != nil { + return err + } + + return errEOW + } + + switch err := w.walk(f); err { + case nil: + return fmt.Errorf("%s: not found", d.Digest) + case errEOW: + // found, continue below + default: + return errors.Wrapf(err, "%s: validation failed", d.Digest) + } + + return nil +} + +func (d *descriptor) validateContent(r io.Reader) error { + h := sha256.New() + n, err := io.Copy(h, r) + if err != nil { + return errors.Wrap(err, "error generating hash") + } + + digest := "sha256:" + hex.EncodeToString(h.Sum(nil)) + + if digest != d.Digest { + return errors.New("digest mismatch") + } + + if n != d.Size { + return errors.New("size mismatch") + } + + return nil +} diff --git a/image/doc.go b/image/doc.go new file mode 100644 index 000000000..fc5de0fe6 --- /dev/null +++ b/image/doc.go @@ -0,0 +1,16 @@ +// Copyright 2016 The Linux Foundation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package image defines methods for validating, and unpacking OCI images. +package image diff --git a/image/image.go b/image/image.go new file mode 100644 index 000000000..8805f590e --- /dev/null +++ b/image/image.go @@ -0,0 +1,103 @@ +// Copyright 2016 The Linux Foundation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package image + +import ( + "os" + + "github.com/pkg/errors" +) + +// ValidateLayout walks through the file tree given by src and +// validates the manifest pointed to by the given ref +// or returns an error if the validation failed. +func ValidateLayout(src, ref string) error { + return validate(newPathWalker(src), ref) +} + +// Validate walks through the given .tar file and +// validates the manifest pointed to by the given ref +// or returns an error if the validation failed. +func Validate(tarFile, ref string) error { + f, err := os.Open(tarFile) + if err != nil { + return errors.Wrap(err, "unable to open file") + } + defer f.Close() + + return validate(newTarWalker(f), ref) +} + +func validate(w walker, refName string) error { + ref, err := findDescriptor(w, refName) + if err != nil { + return err + } + + if err = ref.validate(w); err != nil { + return err + } + + m, err := findManifest(w, ref) + if err != nil { + return err + } + + return m.validate(w) +} + +// UnpackLayout walks through the file tree given given by src and +// using the layers specified in the manifest pointed to by the given ref +// and unpacks all layers in the given destination directory +// or returns an error if the unpacking failed. +func UnpackLayout(src, dest, ref string) error { + return unpack(newPathWalker(src), dest, ref) +} + +// Unpack walks through the given .tar file and +// using the layers specified in the manifest pointed to by the given ref +// and unpacks all layers in the given destination directory +// or returns an error if the unpacking failed. +func Unpack(tarFile, dest, ref string) error { + f, err := os.Open(tarFile) + if err != nil { + return errors.Wrap(err, "unable to open file") + } + defer f.Close() + + return unpack(newTarWalker(f), dest, ref) +} + +func unpack(w walker, dest, refName string) error { + ref, err := findDescriptor(w, refName) + if err != nil { + return err + } + + if err = ref.validate(w); err != nil { + return err + } + + m, err := findManifest(w, ref) + if err != nil { + return err + } + + if err = m.validate(w); err != nil { + return err + } + + return m.unpack(w, dest) +} diff --git a/image/manifest.go b/image/manifest.go new file mode 100644 index 000000000..61e76747e --- /dev/null +++ b/image/manifest.go @@ -0,0 +1,212 @@ +// Copyright 2016 The Linux Foundation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package image + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + "time" + + "github.com/opencontainers/image-spec/schema" + "github.com/pkg/errors" +) + +type manifest struct { + Config descriptor `json:"config"` + Layers []descriptor `json:"layers"` +} + +func findManifest(w walker, d *descriptor) (*manifest, error) { + var m manifest + mpath := filepath.Join("blobs", d.Digest) + + f := func(path string, info os.FileInfo, r io.Reader) error { + if info.IsDir() { + return nil + } + + if filepath.Clean(path) != mpath { + return nil + } + + buf, err := ioutil.ReadAll(r) + if err != nil { + return errors.Wrapf(err, "%s: error reading manifest", path) + } + + if err := schema.MediaTypeManifest.Validate(bytes.NewReader(buf)); err != nil { + return errors.Wrapf(err, "%s: manifest validation failed", path) + } + + if err := json.Unmarshal(buf, &m); err != nil { + return err + } + + if len(m.Layers) == 0 { + return fmt.Errorf("%s: no layers found", path) + } + + return errEOW + } + + switch err := w.walk(f); err { + case nil: + return nil, fmt.Errorf("%s: manifest not found", mpath) + case errEOW: + // found, continue below + default: + return nil, err + } + + return &m, nil +} + +func (m *manifest) validate(w walker) error { + if err := m.Config.validate(w); err != nil { + return errors.Wrap(err, "config validation failed") + } + + for _, d := range m.Layers { + if err := d.validate(w); err != nil { + return errors.Wrap(err, "layer validation failed") + } + } + + return nil +} + +func (m *manifest) unpack(w walker, dest string) error { + for _, d := range m.Layers { + if d.MediaType != string(schema.MediaTypeImageSerialization) { + continue + } + + f := func(path string, info os.FileInfo, r io.Reader) error { + if info.IsDir() { + return nil + } + + dd, err := filepath.Rel("blobs", filepath.Clean(path)) + if err != nil || d.Digest != dd { + return nil // ignore + } + + if err := unpackLayer(dest, r); err != nil { + return errors.Wrap(err, "error extracting layer") + } + + return errEOW + } + + err := w.walk(f) + if err != nil && err != errEOW { + return err + } + } + + return nil +} + +func unpackLayer(dest string, r io.Reader) error { + gz, err := gzip.NewReader(r) + if err != nil { + return errors.Wrap(err, "error creating gzip reader") + } + defer gz.Close() + + tr := tar.NewReader(gz) + +loop: + for { + hdr, err := tr.Next() + switch err { + case io.EOF: + break loop + case nil: + // success, continue below + default: + return errors.Wrapf(err, "error advancing tar stream") + } + + path := filepath.Join(dest, filepath.Clean(hdr.Name)) + info := hdr.FileInfo() + + if strings.HasPrefix(info.Name(), ".wh.") { + path = strings.Replace(path, ".wh.", "", 1) + + if err := os.RemoveAll(path); err != nil { + return errors.Wrap(err, "unable to delete whiteout path") + } + + continue loop + } + + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(path, info.Mode()); err != nil { + return errors.Wrap(err, "error creating directory") + } + + case tar.TypeReg, tar.TypeRegA: + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) + if err != nil { + return errors.Wrap(err, "unable to open file") + } + + if _, err := io.Copy(f, tr); err != nil { + f.Close() + return errors.Wrap(err, "unable to copy") + } + f.Close() + + case tar.TypeLink: + target := filepath.Join(dest, hdr.Linkname) + + if !strings.HasPrefix(target, dest) { + return fmt.Errorf("invalid hardlink %q -> %q", target, hdr.Linkname) + } + + if err := os.Link(target, path); err != nil { + return err + } + + case tar.TypeSymlink: + target := filepath.Join(filepath.Dir(path), hdr.Linkname) + + if !strings.HasPrefix(target, dest) { + return fmt.Errorf("invalid symlink %q -> %q", path, hdr.Linkname) + } + + if err := os.Symlink(hdr.Linkname, path); err != nil { + return err + } + + } + + if err := os.Chtimes(path, time.Now().UTC(), info.ModTime()); err != nil { + return errors.Wrap(err, "error changing time") + } + } + + return nil +} diff --git a/image/walker.go b/image/walker.go new file mode 100644 index 000000000..958782ccd --- /dev/null +++ b/image/walker.go @@ -0,0 +1,116 @@ +// Copyright 2016 The Linux Foundation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package image + +import ( + "archive/tar" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/pkg/errors" +) + +var ( + errEOW = fmt.Errorf("end of walk") // error to signal stop walking +) + +// walkFunc is a function type that gets called for each file or directory visited by the Walker. +type walkFunc func(path string, _ os.FileInfo, _ io.Reader) error + +// walker is the interface that walks through a file tree, +// calling walk for each file or directory in the tree. +type walker interface { + walk(walkFunc) error +} + +type tarWalker struct { + r io.ReadSeeker +} + +// newTarWalker returns a Walker that walks through .tar files. +func newTarWalker(r io.ReadSeeker) walker { + return &tarWalker{r} +} + +func (w *tarWalker) walk(f walkFunc) error { + if _, err := w.r.Seek(0, os.SEEK_SET); err != nil { + return errors.Wrapf(err, "unable to reset") + } + + tr := tar.NewReader(w.r) + +loop: + for { + hdr, err := tr.Next() + switch err { + case io.EOF: + break loop + case nil: + // success, continue below + default: + return errors.Wrapf(err, "error advancing tar stream") + } + + info := hdr.FileInfo() + if err := f(hdr.Name, info, tr); err != nil { + return err + } + } + + return nil +} + +type eofReader struct{} + +func (eofReader) Read(_ []byte) (int, error) { + return 0, io.EOF +} + +type pathWalker struct { + root string +} + +// newPathWalker returns a Walker that walks through directories +// starting at the given root path. It does not follow symlinks. +func newPathWalker(root string) walker { + return &pathWalker{root} +} + +func (w *pathWalker) walk(f walkFunc) error { + return filepath.Walk(w.root, func(path string, info os.FileInfo, err error) error { + rel, err := filepath.Rel(w.root, path) + if err != nil { + return errors.Wrap(err, "error walking path") + } + + if err != nil { + return errors.Wrap(err, "error walking path") // err from filepath.Walk includes path name + } + + if info.IsDir() { // behave like a tar reader for directories + return f(rel, info, eofReader{}) + } + + file, err := os.Open(path) + if err != nil { + return errors.Wrap(err, "unable to open file") // os.Open includes the path + } + defer file.Close() + + return f(rel, info, file) + }) +}