summaryrefslogtreecommitdiffstats
path: root/internal/cmd/edit.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/cmd/edit.go')
-rw-r--r--internal/cmd/edit.go485
1 files changed, 485 insertions, 0 deletions
diff --git a/internal/cmd/edit.go b/internal/cmd/edit.go
new file mode 100644
index 0000000..707e80c
--- /dev/null
+++ b/internal/cmd/edit.go
@@ -0,0 +1,485 @@
+// Copyright 2022 Google LLC All Rights Reserved.
+//
+// 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 cmd
+
+import (
+ "archive/tar"
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/google/go-containerregistry/internal/editor"
+ "github.com/google/go-containerregistry/internal/verify"
+ "github.com/google/go-containerregistry/pkg/crane"
+ "github.com/google/go-containerregistry/pkg/name"
+ v1 "github.com/google/go-containerregistry/pkg/v1"
+ "github.com/google/go-containerregistry/pkg/v1/mutate"
+ "github.com/google/go-containerregistry/pkg/v1/remote"
+ "github.com/google/go-containerregistry/pkg/v1/static"
+ "github.com/google/go-containerregistry/pkg/v1/tarball"
+ "github.com/google/go-containerregistry/pkg/v1/types"
+ "github.com/spf13/cobra"
+)
+
+// NewCmdEdit creates a new cobra.Command for the edit subcommand.
+//
+// This is currently hidden until we're happy with the interface and can test
+// it on different operating systems and editors.
+func NewCmdEdit(options *[]crane.Option) *cobra.Command {
+ cmd := &cobra.Command{
+ Hidden: true,
+ Use: "edit",
+ Short: "Edit the contents of an image.",
+ Args: cobra.ExactArgs(1),
+ Run: func(cmd *cobra.Command, _ []string) {
+ cmd.Usage()
+ },
+ }
+ cmd.AddCommand(NewCmdEditManifest(options), NewCmdEditConfig(options), NewCmdEditFs(options))
+
+ return cmd
+}
+
+// NewCmdConfig creates a new cobra.Command for the config subcommand.
+func NewCmdEditConfig(options *[]crane.Option) *cobra.Command {
+ var dst string
+ cmd := &cobra.Command{
+ Use: "config",
+ Short: "Edit an image's config file.",
+ Example: ` # Edit ubuntu's config file
+ crane edit config ubuntu
+
+ # Overwrite ubuntu's config file with '{}'
+ echo '{}' | crane edit config ubuntu`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ref, err := editConfig(cmd.InOrStdin(), cmd.OutOrStdout(), args[0], dst, *options...)
+ if err != nil {
+ return fmt.Errorf("editing config: %w", err)
+ }
+ fmt.Println(ref.String())
+ return nil
+ },
+ }
+ cmd.Flags().StringVarP(&dst, "tag", "t", "", "New tag reference to apply to mutated image. If not provided, uses original tag or pushes a new digest.")
+
+ return cmd
+}
+
+// NewCmdManifest creates a new cobra.Command for the manifest subcommand.
+func NewCmdEditManifest(options *[]crane.Option) *cobra.Command {
+ var (
+ dst string
+ mt string
+ )
+ cmd := &cobra.Command{
+ Use: "manifest",
+ Short: "Edit an image's manifest.",
+ Example: ` # Edit ubuntu's manifest
+ crane edit manifest ubuntu`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ref, err := editManifest(cmd.InOrStdin(), cmd.OutOrStdout(), args[0], dst, mt, *options...)
+ if err != nil {
+ return fmt.Errorf("editing manifest: %w", err)
+ }
+ fmt.Println(ref.String())
+ return nil
+ },
+ }
+ cmd.Flags().StringVarP(&dst, "tag", "t", "", "New tag reference to apply to mutated image. If not provided, uses original tag or pushes a new digest.")
+ cmd.Flags().StringVarP(&mt, "media-type", "m", "", "Override the mediaType used as the Content-Type for PUT")
+
+ return cmd
+}
+
+// NewCmdExport creates a new cobra.Command for the export subcommand.
+func NewCmdEditFs(options *[]crane.Option) *cobra.Command {
+ var dst, name string
+ cmd := &cobra.Command{
+ Use: "fs IMAGE",
+ Short: "Edit the contents of an image's filesystem.",
+ Example: ` # Edit motd-news using $EDITOR
+ crane edit fs ubuntu -f /etc/default/motd-news
+
+ # Overwrite motd-news with 'ENABLED=0'
+ echo 'ENABLED=0' | crane edit fs ubuntu -f /etc/default/motd-news`,
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ref, err := editFile(cmd.InOrStdin(), cmd.OutOrStdout(), args[0], name, dst, *options...)
+ if err != nil {
+ return fmt.Errorf("editing file: %w", err)
+ }
+ fmt.Println(ref.String())
+ return nil
+ },
+ }
+ cmd.Flags().StringVarP(&name, "filename", "f", "", "Edit the given filename")
+ cmd.Flags().StringVarP(&dst, "tag", "t", "", "New tag reference to apply to mutated image. If not provided, uses original tag or pushes a new digest.")
+ cmd.MarkFlagRequired("filename")
+
+ return cmd
+}
+
+func interactive(in io.Reader, out io.Writer) bool {
+ return interactiveFile(in) && interactiveFile(out)
+}
+
+func interactiveFile(i any) bool {
+ f, ok := i.(*os.File)
+ if !ok {
+ return false
+ }
+ stat, err := f.Stat()
+ if err != nil {
+ return false
+ }
+ return (stat.Mode() & os.ModeCharDevice) != 0
+}
+
+func editConfig(in io.Reader, out io.Writer, src, dst string, options ...crane.Option) (name.Reference, error) {
+ o := crane.GetOptions(options...)
+
+ img, err := crane.Pull(src, options...)
+ if err != nil {
+ return nil, err
+ }
+
+ mt, err := img.MediaType()
+ if err != nil {
+ return nil, err
+ }
+
+ // We want to omit Layers in certain situations, so we don't use v1.Image.Manifest() here.
+ // Instead, we treat the manifest as a map[string]any and just manipulate the config desc.
+ mb, err := img.RawManifest()
+ if err != nil {
+ return nil, err
+ }
+
+ jsonMap := map[string]any{}
+ if err := json.Unmarshal(mb, &jsonMap); err != nil {
+ return nil, err
+ }
+
+ cv, ok := jsonMap["config"]
+ if !ok {
+ return nil, fmt.Errorf("config missing")
+ }
+ cb, err := json.Marshal(cv)
+ if err != nil {
+ return nil, fmt.Errorf("json.Marshal config: %w", err)
+ }
+
+ config := v1.Descriptor{}
+ if err := json.Unmarshal(cb, &config); err != nil {
+ return nil, fmt.Errorf("json.Unmarshal config: %w", err)
+ }
+
+ var edited []byte
+ if interactive(in, out) {
+ rcf, err := img.RawConfigFile()
+ if err != nil {
+ return nil, err
+ }
+ edited, err = editor.Edit(bytes.NewReader(rcf), ".json")
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ b, err := io.ReadAll(in)
+ if err != nil {
+ return nil, err
+ }
+ edited = b
+ }
+
+ // this has to happen before we modify the descriptor (so we can use verify.Descriptor to validate whether m.Config.Data matches m.Config.Digest/Size)
+ if config.Data != nil && verify.Descriptor(config) == nil {
+ // https://github.com/google/go-containerregistry/issues/1552#issuecomment-1452653875
+ // "if data is non-empty and correct, we should update it"
+ config.Data = edited
+ }
+
+ l := static.NewLayer(edited, config.MediaType)
+ layerDigest, err := l.Digest()
+ if err != nil {
+ return nil, err
+ }
+
+ config.Digest = layerDigest
+ config.Size = int64(len(edited))
+
+ jsonMap["config"] = config
+ b, err := json.Marshal(jsonMap)
+ if err != nil {
+ return nil, err
+ }
+ rm := &rawManifest{
+ body: b,
+ mediaType: mt,
+ }
+
+ digest, _, _ := v1.SHA256(bytes.NewReader(b))
+
+ if dst == "" {
+ dst = src
+ ref, err := name.ParseReference(src, o.Name...)
+ if err != nil {
+ return nil, err
+ }
+ if _, ok := ref.(name.Digest); ok {
+ dst = ref.Context().Digest(digest.String()).String()
+ }
+ }
+
+ dstRef, err := name.ParseReference(dst, o.Name...)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := remote.WriteLayer(dstRef.Context(), l, o.Remote...); err != nil {
+ return nil, err
+ }
+
+ if err := remote.Put(dstRef, rm, o.Remote...); err != nil {
+ return nil, err
+ }
+
+ return dstRef, nil
+}
+
+func editManifest(in io.Reader, out io.Writer, src string, dst string, mt string, options ...crane.Option) (name.Reference, error) {
+ o := crane.GetOptions(options...)
+
+ ref, err := name.ParseReference(src, o.Name...)
+ if err != nil {
+ return nil, err
+ }
+
+ desc, err := remote.Get(ref, o.Remote...)
+ if err != nil {
+ return nil, err
+ }
+
+ var edited []byte
+ if interactive(in, out) {
+ edited, err = editor.Edit(bytes.NewReader(desc.Manifest), ".json")
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ b, err := io.ReadAll(in)
+ if err != nil {
+ return nil, err
+ }
+ edited = b
+ }
+
+ digest, _, err := v1.SHA256(bytes.NewReader(edited))
+ if err != nil {
+ return nil, err
+ }
+
+ if dst == "" {
+ dst = src
+ if _, ok := ref.(name.Digest); ok {
+ dst = ref.Context().Digest(digest.String()).String()
+ }
+ }
+ dstRef, err := name.ParseReference(dst, o.Name...)
+ if err != nil {
+ return nil, err
+ }
+
+ if mt == "" {
+ // If --media-type is unset, use Content-Type by default.
+ mt = string(desc.MediaType)
+
+ // If document contains mediaType, default to that.
+ wmt := withMediaType{}
+ if err := json.Unmarshal(edited, &wmt); err == nil {
+ if wmt.MediaType != "" {
+ mt = wmt.MediaType
+ }
+ }
+ }
+
+ rm := &rawManifest{
+ body: edited,
+ mediaType: types.MediaType(mt),
+ }
+
+ if err := remote.Put(dstRef, rm, o.Remote...); err != nil {
+ return nil, err
+ }
+
+ return dstRef, nil
+}
+
+func editFile(in io.Reader, out io.Writer, src, file, dst string, options ...crane.Option) (name.Reference, error) {
+ o := crane.GetOptions(options...)
+
+ img, err := crane.Pull(src, options...)
+ if err != nil {
+ return nil, err
+ }
+
+ // If stdin has content, read it in and use that for the file.
+ // Otherwise, scran through the image and open that file in an editor.
+ var (
+ edited []byte
+ header *tar.Header
+ )
+ if interactive(in, out) {
+ f, h, err := findFile(img, file)
+ if err != nil {
+ return nil, err
+ }
+ ext := filepath.Ext(h.Name)
+ if strings.Contains(ext, "..") {
+ return nil, fmt.Errorf("this is impossible but this check satisfies CWE-22 for file name %q", h.Name)
+ }
+ edited, err = editor.Edit(f, ext)
+ if err != nil {
+ return nil, err
+ }
+ header = h
+ } else {
+ b, err := io.ReadAll(in)
+ if err != nil {
+ return nil, err
+ }
+ edited = b
+ header = blankHeader(file)
+ }
+
+ buf := bytes.NewBuffer(nil)
+ buf.Grow(len(edited))
+ tw := tar.NewWriter(buf)
+
+ header.Size = int64(len(edited))
+ if err := tw.WriteHeader(header); err != nil {
+ return nil, err
+ }
+ if _, err := io.Copy(tw, bytes.NewReader(edited)); err != nil {
+ return nil, err
+ }
+ if err := tw.Close(); err != nil {
+ return nil, err
+ }
+
+ fileBytes := buf.Bytes()
+ fileLayer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) {
+ return io.NopCloser(bytes.NewBuffer(fileBytes)), nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ img, err = mutate.Append(img, mutate.Addendum{
+ Layer: fileLayer,
+ History: v1.History{
+ Author: "crane",
+ CreatedBy: strings.Join(os.Args, " "),
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ digest, err := img.Digest()
+ if err != nil {
+ return nil, err
+ }
+
+ if dst == "" {
+ dst = src
+ ref, err := name.ParseReference(src, o.Name...)
+ if err != nil {
+ return nil, err
+ }
+ if _, ok := ref.(name.Digest); ok {
+ dst = ref.Context().Digest(digest.String()).String()
+ }
+ }
+
+ dstRef, err := name.ParseReference(dst, o.Name...)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := crane.Push(img, dst, options...); err != nil {
+ return nil, err
+ }
+
+ return dstRef, nil
+}
+
+func findFile(img v1.Image, name string) (io.Reader, *tar.Header, error) {
+ name = normalize(name)
+ tr := tar.NewReader(mutate.Extract(img))
+ for {
+ header, err := tr.Next()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ return nil, nil, fmt.Errorf("reading tar: %w", err)
+ }
+ if normalize(header.Name) == name {
+ return tr, header, nil
+ }
+ }
+
+ // If we don't find the file, we should create a new one.
+ return bytes.NewBufferString(""), blankHeader(name), nil
+}
+
+func blankHeader(name string) *tar.Header {
+ return &tar.Header{
+ Name: name,
+ Typeflag: tar.TypeReg,
+ // Use a fixed Mode, so that this isn't sensitive to the directory and umask
+ // under which it was created. Additionally, windows can only set 0222,
+ // 0444, or 0666, none of which are executable.
+ Mode: 0555,
+ }
+}
+
+func normalize(name string) string {
+ return filepath.Clean("/" + name)
+}
+
+type withMediaType struct {
+ MediaType string `json:"mediaType,omitempty"`
+}
+
+type rawManifest struct {
+ body []byte
+ mediaType types.MediaType
+}
+
+func (r *rawManifest) RawManifest() ([]byte, error) {
+ return r.body, nil
+}
+
+func (r *rawManifest) MediaType() (types.MediaType, error) {
+ return r.mediaType, nil
+}