1/*
2Copyright 2016 The Kubernetes Authors.
3
4Licensed under the Apache License, Version 2.0 (the "License");
5you may not use this file except in compliance with the License.
6You may obtain a copy of the License at
7
8    http://www.apache.org/licenses/LICENSE-2.0
9
10Unless required by applicable law or agreed to in writing, software
11distributed under the License is distributed on an "AS IS" BASIS,
12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13See the License for the specific language governing permissions and
14limitations under the License.
15*/
16
17package cp
18
19import (
20	"archive/tar"
21	"bytes"
22	"errors"
23	"fmt"
24	"io"
25	"io/ioutil"
26	"os"
27	"path"
28	"path/filepath"
29	"strings"
30
31	"github.com/lithammer/dedent"
32	"github.com/spf13/cobra"
33
34	"k8s.io/cli-runtime/pkg/genericclioptions"
35	"k8s.io/client-go/kubernetes"
36	restclient "k8s.io/client-go/rest"
37	"k8s.io/kubectl/pkg/cmd/exec"
38	"k8s.io/kubectl/pkg/cmd/get"
39	cmdutil "k8s.io/kubectl/pkg/cmd/util"
40	"k8s.io/kubectl/pkg/util/i18n"
41	"k8s.io/kubectl/pkg/util/templates"
42)
43
44var (
45	cpExample = templates.Examples(i18n.T(`
46		# !!!Important Note!!!
47		# Requires that the 'tar' binary is present in your container
48		# image.  If 'tar' is not present, 'kubectl cp' will fail.
49		#
50		# For advanced use cases, such as symlinks, wildcard expansion or
51		# file mode preservation, consider using 'kubectl exec'.
52
53		# Copy /tmp/foo local file to /tmp/bar in a remote pod in namespace <some-namespace>
54		tar cf - /tmp/foo | kubectl exec -i -n <some-namespace> <some-pod> -- tar xf - -C /tmp/bar
55
56		# Copy /tmp/foo from a remote pod to /tmp/bar locally
57		kubectl exec -n <some-namespace> <some-pod> -- tar cf - /tmp/foo | tar xf - -C /tmp/bar
58
59		# Copy /tmp/foo_dir local directory to /tmp/bar_dir in a remote pod in the default namespace
60		kubectl cp /tmp/foo_dir <some-pod>:/tmp/bar_dir
61
62		# Copy /tmp/foo local file to /tmp/bar in a remote pod in a specific container
63		kubectl cp /tmp/foo <some-pod>:/tmp/bar -c <specific-container>
64
65		# Copy /tmp/foo local file to /tmp/bar in a remote pod in namespace <some-namespace>
66		kubectl cp /tmp/foo <some-namespace>/<some-pod>:/tmp/bar
67
68		# Copy /tmp/foo from a remote pod to /tmp/bar locally
69		kubectl cp <some-namespace>/<some-pod>:/tmp/foo /tmp/bar`))
70
71	cpUsageStr = dedent.Dedent(`
72		expected 'cp <file-spec-src> <file-spec-dest> [-c container]'.
73		<file-spec> is:
74		[namespace/]pod-name:/file/path for a remote file
75		/file/path for a local file`)
76)
77
78// CopyOptions have the data required to perform the copy operation
79type CopyOptions struct {
80	Container  string
81	Namespace  string
82	NoPreserve bool
83
84	ClientConfig      *restclient.Config
85	Clientset         kubernetes.Interface
86	ExecParentCmdName string
87
88	genericclioptions.IOStreams
89}
90
91// NewCopyOptions creates the options for copy
92func NewCopyOptions(ioStreams genericclioptions.IOStreams) *CopyOptions {
93	return &CopyOptions{
94		IOStreams: ioStreams,
95	}
96}
97
98// NewCmdCp creates a new Copy command.
99func NewCmdCp(f cmdutil.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command {
100	o := NewCopyOptions(ioStreams)
101
102	cmd := &cobra.Command{
103		Use:                   "cp <file-spec-src> <file-spec-dest>",
104		DisableFlagsInUseLine: true,
105		Short:                 i18n.T("Copy files and directories to and from containers"),
106		Long:                  i18n.T("Copy files and directories to and from containers."),
107		Example:               cpExample,
108		ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
109			var comps []string
110			if len(args) == 0 {
111				if strings.IndexAny(toComplete, "/.~") == 0 {
112					// Looks like a path, do nothing
113				} else if strings.Index(toComplete, ":") != -1 {
114					// TODO: complete remote files in the pod
115				} else if idx := strings.Index(toComplete, "/"); idx > 0 {
116					// complete <namespace>/<pod>
117					namespace := toComplete[:idx]
118					template := "{{ range .items }}{{ .metadata.namespace }}/{{ .metadata.name }}: {{ end }}"
119					comps = get.CompGetFromTemplate(&template, f, namespace, cmd, []string{"pod"}, toComplete)
120				} else {
121					// Complete namespaces followed by a /
122					for _, ns := range get.CompGetResource(f, cmd, "namespace", toComplete) {
123						comps = append(comps, fmt.Sprintf("%s/", ns))
124					}
125					// Complete pod names followed by a :
126					for _, pod := range get.CompGetResource(f, cmd, "pod", toComplete) {
127						comps = append(comps, fmt.Sprintf("%s:", pod))
128					}
129
130					// Finally, provide file completion if we need to.
131					// We only do this if:
132					// 1- There are other completions found (if there are no completions,
133					//    the shell will do file completion itself)
134					// 2- If there is some input from the user (or else we will end up
135					//    listing the entire content of the current directory which could
136					//    be too many choices for the user)
137					if len(comps) > 0 && len(toComplete) > 0 {
138						if files, err := ioutil.ReadDir("."); err == nil {
139							for _, file := range files {
140								filename := file.Name()
141								if strings.HasPrefix(filename, toComplete) {
142									if file.IsDir() {
143										filename = fmt.Sprintf("%s/", filename)
144									}
145									// We are completing a file prefix
146									comps = append(comps, filename)
147								}
148							}
149						}
150					} else if len(toComplete) == 0 {
151						// If the user didn't provide any input to complete,
152						// we provide a hint that a path can also be used
153						comps = append(comps, "./", "/")
154					}
155				}
156			}
157			return comps, cobra.ShellCompDirectiveNoSpace
158		},
159		Run: func(cmd *cobra.Command, args []string) {
160			cmdutil.CheckErr(o.Complete(f, cmd))
161			cmdutil.CheckErr(o.Run(args))
162		},
163	}
164	cmdutil.AddContainerVarFlags(cmd, &o.Container, o.Container)
165	cmd.Flags().BoolVarP(&o.NoPreserve, "no-preserve", "", false, "The copied file/directory's ownership and permissions will not be preserved in the container")
166
167	return cmd
168}
169
170type fileSpec struct {
171	PodNamespace string
172	PodName      string
173	File         string
174}
175
176var (
177	errFileSpecDoesntMatchFormat = errors.New("filespec must match the canonical format: [[namespace/]pod:]file/path")
178	errFileCannotBeEmpty         = errors.New("filepath can not be empty")
179)
180
181func extractFileSpec(arg string) (fileSpec, error) {
182	i := strings.Index(arg, ":")
183
184	if i == -1 {
185		return fileSpec{File: arg}, nil
186	}
187	// filespec starting with a semicolon is invalid
188	if i == 0 {
189		return fileSpec{}, errFileSpecDoesntMatchFormat
190	}
191
192	pod, file := arg[:i], arg[i+1:]
193	pieces := strings.Split(pod, "/")
194	switch len(pieces) {
195	case 1:
196		return fileSpec{
197			PodName: pieces[0],
198			File:    file,
199		}, nil
200	case 2:
201		return fileSpec{
202			PodNamespace: pieces[0],
203			PodName:      pieces[1],
204			File:         file,
205		}, nil
206	default:
207		return fileSpec{}, errFileSpecDoesntMatchFormat
208	}
209}
210
211// Complete completes all the required options
212func (o *CopyOptions) Complete(f cmdutil.Factory, cmd *cobra.Command) error {
213	if cmd.Parent() != nil {
214		o.ExecParentCmdName = cmd.Parent().CommandPath()
215	}
216
217	var err error
218	o.Namespace, _, err = f.ToRawKubeConfigLoader().Namespace()
219	if err != nil {
220		return err
221	}
222
223	o.Clientset, err = f.KubernetesClientSet()
224	if err != nil {
225		return err
226	}
227
228	o.ClientConfig, err = f.ToRESTConfig()
229	if err != nil {
230		return err
231	}
232	return nil
233}
234
235// Validate makes sure provided values for CopyOptions are valid
236func (o *CopyOptions) Validate(cmd *cobra.Command, args []string) error {
237	if len(args) != 2 {
238		return cmdutil.UsageErrorf(cmd, cpUsageStr)
239	}
240	return nil
241}
242
243// Run performs the execution
244func (o *CopyOptions) Run(args []string) error {
245	if len(args) < 2 {
246		return fmt.Errorf("source and destination are required")
247	}
248	srcSpec, err := extractFileSpec(args[0])
249	if err != nil {
250		return err
251	}
252	destSpec, err := extractFileSpec(args[1])
253	if err != nil {
254		return err
255	}
256
257	if len(srcSpec.PodName) != 0 && len(destSpec.PodName) != 0 {
258		return fmt.Errorf("one of src or dest must be a local file specification")
259	}
260
261	if len(srcSpec.PodName) != 0 {
262		return o.copyFromPod(srcSpec, destSpec)
263	}
264	if len(destSpec.PodName) != 0 {
265		return o.copyToPod(srcSpec, destSpec, &exec.ExecOptions{})
266	}
267	return fmt.Errorf("one of src or dest must be a remote file specification")
268}
269
270// checkDestinationIsDir receives a destination fileSpec and
271// determines if the provided destination path exists on the
272// pod. If the destination path does not exist or is _not_ a
273// directory, an error is returned with the exit code received.
274func (o *CopyOptions) checkDestinationIsDir(dest fileSpec) error {
275	options := &exec.ExecOptions{
276		StreamOptions: exec.StreamOptions{
277			IOStreams: genericclioptions.IOStreams{
278				Out:    bytes.NewBuffer([]byte{}),
279				ErrOut: bytes.NewBuffer([]byte{}),
280			},
281
282			Namespace: dest.PodNamespace,
283			PodName:   dest.PodName,
284		},
285
286		Command:  []string{"test", "-d", dest.File},
287		Executor: &exec.DefaultRemoteExecutor{},
288	}
289
290	return o.execute(options)
291}
292
293func (o *CopyOptions) copyToPod(src, dest fileSpec, options *exec.ExecOptions) error {
294	if len(src.File) == 0 || len(dest.File) == 0 {
295		return errFileCannotBeEmpty
296	}
297	if _, err := os.Stat(src.File); err != nil {
298		return fmt.Errorf("%s doesn't exist in local filesystem", src.File)
299	}
300	reader, writer := io.Pipe()
301
302	// strip trailing slash (if any)
303	if dest.File != "/" && strings.HasSuffix(string(dest.File[len(dest.File)-1]), "/") {
304		dest.File = dest.File[:len(dest.File)-1]
305	}
306
307	if err := o.checkDestinationIsDir(dest); err == nil {
308		// If no error, dest.File was found to be a directory.
309		// Copy specified src into it
310		dest.File = dest.File + "/" + path.Base(src.File)
311	}
312
313	go func() {
314		defer writer.Close()
315		cmdutil.CheckErr(makeTar(src.File, dest.File, writer))
316	}()
317	var cmdArr []string
318
319	// TODO: Improve error messages by first testing if 'tar' is present in the container?
320	if o.NoPreserve {
321		cmdArr = []string{"tar", "--no-same-permissions", "--no-same-owner", "-xmf", "-"}
322	} else {
323		cmdArr = []string{"tar", "-xmf", "-"}
324	}
325	destDir := path.Dir(dest.File)
326	if len(destDir) > 0 {
327		cmdArr = append(cmdArr, "-C", destDir)
328	}
329
330	options.StreamOptions = exec.StreamOptions{
331		IOStreams: genericclioptions.IOStreams{
332			In:     reader,
333			Out:    o.Out,
334			ErrOut: o.ErrOut,
335		},
336		Stdin: true,
337
338		Namespace: dest.PodNamespace,
339		PodName:   dest.PodName,
340	}
341
342	options.Command = cmdArr
343	options.Executor = &exec.DefaultRemoteExecutor{}
344	return o.execute(options)
345}
346
347func (o *CopyOptions) copyFromPod(src, dest fileSpec) error {
348	if len(src.File) == 0 || len(dest.File) == 0 {
349		return errFileCannotBeEmpty
350	}
351
352	reader, outStream := io.Pipe()
353	options := &exec.ExecOptions{
354		StreamOptions: exec.StreamOptions{
355			IOStreams: genericclioptions.IOStreams{
356				In:     nil,
357				Out:    outStream,
358				ErrOut: o.Out,
359			},
360
361			Namespace: src.PodNamespace,
362			PodName:   src.PodName,
363		},
364
365		// TODO: Improve error messages by first testing if 'tar' is present in the container?
366		Command:  []string{"tar", "cf", "-", src.File},
367		Executor: &exec.DefaultRemoteExecutor{},
368	}
369
370	go func() {
371		defer outStream.Close()
372		cmdutil.CheckErr(o.execute(options))
373	}()
374	prefix := getPrefix(src.File)
375	prefix = path.Clean(prefix)
376	// remove extraneous path shortcuts - these could occur if a path contained extra "../"
377	// and attempted to navigate beyond "/" in a remote filesystem
378	prefix = stripPathShortcuts(prefix)
379	return o.untarAll(src, reader, dest.File, prefix)
380}
381
382// stripPathShortcuts removes any leading or trailing "../" from a given path
383func stripPathShortcuts(p string) string {
384	newPath := path.Clean(p)
385	trimmed := strings.TrimPrefix(newPath, "../")
386
387	for trimmed != newPath {
388		newPath = trimmed
389		trimmed = strings.TrimPrefix(newPath, "../")
390	}
391
392	// trim leftover {".", ".."}
393	if newPath == "." || newPath == ".." {
394		newPath = ""
395	}
396
397	if len(newPath) > 0 && string(newPath[0]) == "/" {
398		return newPath[1:]
399	}
400
401	return newPath
402}
403
404func makeTar(srcPath, destPath string, writer io.Writer) error {
405	// TODO: use compression here?
406	tarWriter := tar.NewWriter(writer)
407	defer tarWriter.Close()
408
409	srcPath = path.Clean(srcPath)
410	destPath = path.Clean(destPath)
411	return recursiveTar(path.Dir(srcPath), path.Base(srcPath), path.Dir(destPath), path.Base(destPath), tarWriter)
412}
413
414func recursiveTar(srcBase, srcFile, destBase, destFile string, tw *tar.Writer) error {
415	srcPath := path.Join(srcBase, srcFile)
416	matchedPaths, err := filepath.Glob(srcPath)
417	if err != nil {
418		return err
419	}
420	for _, fpath := range matchedPaths {
421		stat, err := os.Lstat(fpath)
422		if err != nil {
423			return err
424		}
425		if stat.IsDir() {
426			files, err := ioutil.ReadDir(fpath)
427			if err != nil {
428				return err
429			}
430			if len(files) == 0 {
431				//case empty directory
432				hdr, _ := tar.FileInfoHeader(stat, fpath)
433				hdr.Name = destFile
434				if err := tw.WriteHeader(hdr); err != nil {
435					return err
436				}
437			}
438			for _, f := range files {
439				if err := recursiveTar(srcBase, path.Join(srcFile, f.Name()), destBase, path.Join(destFile, f.Name()), tw); err != nil {
440					return err
441				}
442			}
443			return nil
444		} else if stat.Mode()&os.ModeSymlink != 0 {
445			//case soft link
446			hdr, _ := tar.FileInfoHeader(stat, fpath)
447			target, err := os.Readlink(fpath)
448			if err != nil {
449				return err
450			}
451
452			hdr.Linkname = target
453			hdr.Name = destFile
454			if err := tw.WriteHeader(hdr); err != nil {
455				return err
456			}
457		} else {
458			//case regular file or other file type like pipe
459			hdr, err := tar.FileInfoHeader(stat, fpath)
460			if err != nil {
461				return err
462			}
463			hdr.Name = destFile
464
465			if err := tw.WriteHeader(hdr); err != nil {
466				return err
467			}
468
469			f, err := os.Open(fpath)
470			if err != nil {
471				return err
472			}
473			defer f.Close()
474
475			if _, err := io.Copy(tw, f); err != nil {
476				return err
477			}
478			return f.Close()
479		}
480	}
481	return nil
482}
483
484func (o *CopyOptions) untarAll(src fileSpec, reader io.Reader, destDir, prefix string) error {
485	symlinkWarningPrinted := false
486	// TODO: use compression here?
487	tarReader := tar.NewReader(reader)
488	for {
489		header, err := tarReader.Next()
490		if err != nil {
491			if err != io.EOF {
492				return err
493			}
494			break
495		}
496
497		// All the files will start with the prefix, which is the directory where
498		// they were located on the pod, we need to strip down that prefix, but
499		// if the prefix is missing it means the tar was tempered with.
500		// For the case where prefix is empty we need to ensure that the path
501		// is not absolute, which also indicates the tar file was tempered with.
502		if !strings.HasPrefix(header.Name, prefix) {
503			return fmt.Errorf("tar contents corrupted")
504		}
505
506		// basic file information
507		mode := header.FileInfo().Mode()
508		destFileName := filepath.Join(destDir, header.Name[len(prefix):])
509
510		if !isDestRelative(destDir, destFileName) {
511			fmt.Fprintf(o.IOStreams.ErrOut, "warning: file %q is outside target destination, skipping\n", destFileName)
512			continue
513		}
514
515		baseName := filepath.Dir(destFileName)
516		if err := os.MkdirAll(baseName, 0755); err != nil {
517			return err
518		}
519		if header.FileInfo().IsDir() {
520			if err := os.MkdirAll(destFileName, 0755); err != nil {
521				return err
522			}
523			continue
524		}
525
526		if mode&os.ModeSymlink != 0 {
527			if !symlinkWarningPrinted && len(o.ExecParentCmdName) > 0 {
528				fmt.Fprintf(o.IOStreams.ErrOut, "warning: skipping symlink: %q -> %q (consider using \"%s exec -n %q %q -- tar cf - %q | tar xf -\")\n", destFileName, header.Linkname, o.ExecParentCmdName, src.PodNamespace, src.PodName, src.File)
529				symlinkWarningPrinted = true
530				continue
531			}
532			fmt.Fprintf(o.IOStreams.ErrOut, "warning: skipping symlink: %q -> %q\n", destFileName, header.Linkname)
533			continue
534		}
535		outFile, err := os.Create(destFileName)
536		if err != nil {
537			return err
538		}
539		defer outFile.Close()
540		if _, err := io.Copy(outFile, tarReader); err != nil {
541			return err
542		}
543		if err := outFile.Close(); err != nil {
544			return err
545		}
546	}
547
548	return nil
549}
550
551// isDestRelative returns true if dest is pointing outside the base directory,
552// false otherwise.
553func isDestRelative(base, dest string) bool {
554	relative, err := filepath.Rel(base, dest)
555	if err != nil {
556		return false
557	}
558	return relative == "." || relative == stripPathShortcuts(relative)
559}
560
561func getPrefix(file string) string {
562	// tar strips the leading '/' if it's there, so we will too
563	return strings.TrimLeft(file, "/")
564}
565
566func (o *CopyOptions) execute(options *exec.ExecOptions) error {
567	if len(options.Namespace) == 0 {
568		options.Namespace = o.Namespace
569	}
570
571	if len(o.Container) > 0 {
572		options.ContainerName = o.Container
573	}
574
575	options.Config = o.ClientConfig
576	options.PodClient = o.Clientset.CoreV1()
577
578	if err := options.Validate(); err != nil {
579		return err
580	}
581
582	if err := options.Run(); err != nil {
583		return err
584	}
585	return nil
586}
587