golang.org/x/exp@v0.0.0-20240506185415-9bf2ced13842/cmd/txtar/txtar.go (about)

     1  // Copyright 2020 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  // The txtar command writes or extracts a text-based file archive in the format
     6  // provided by the golang.org/x/tools/txtar package.
     7  //
     8  // The default behavior is to read a comment from stdin and write the archive
     9  // file containing the recursive contents of the named files and directories,
    10  // including hidden files, to stdout. Any non-flag arguments to the command name
    11  // the files and/or directories to include, with the contents of directories
    12  // included recursively. An empty argument list is equivalent to ".".
    13  //
    14  // The --extract (or -x) flag instructs txtar to instead read the archive file
    15  // from stdin and extract all of its files to corresponding locations relative
    16  // to the current, writing the archive's comment to stdout.
    17  //
    18  // The --list flag instructs txtar to instead read the archive file from stdin
    19  // and list all of its files to stdout. Note that shell variables in paths are
    20  // not expanded in this mode.
    21  //
    22  // Archive files are by default extracted only to the current directory or its
    23  // subdirectories. To allow extracting outside the current directory, use the
    24  // --unsafe flag.
    25  //
    26  // When extracting, shell variables in paths are expanded (using os.Expand) if
    27  // the corresponding variable is set in the process environment. When writing an
    28  // archive, the variables (before expansion) are preserved in the archived paths.
    29  //
    30  // Example usage:
    31  //
    32  //	txtar *.go <README >testdata/example.txt
    33  //
    34  //	txtar --extract <playground_example.txt >main.go
    35  package main
    36  
    37  import (
    38  	"bytes"
    39  	"flag"
    40  	"fmt"
    41  	"io"
    42  	"os"
    43  	"path"
    44  	"path/filepath"
    45  	"regexp"
    46  	"strings"
    47  	"time"
    48  
    49  	"golang.org/x/tools/txtar"
    50  )
    51  
    52  var (
    53  	extractFlag = flag.Bool("extract", false, "if true, extract files from the archive instead of writing to it")
    54  	listFlag    = flag.Bool("list", false, "if true, list files from the archive instead of writing to it")
    55  	unsafeFlag  = flag.Bool("unsafe", false, "allow extraction of files outside the current directory")
    56  )
    57  
    58  func init() {
    59  	flag.BoolVar(extractFlag, "x", *extractFlag, "short alias for --extract")
    60  }
    61  
    62  func main() {
    63  	flag.Parse()
    64  
    65  	var err error
    66  	switch {
    67  	case *extractFlag:
    68  		if len(flag.Args()) > 0 {
    69  			fmt.Fprintln(os.Stderr, "Usage: txtar --extract <archive.txt")
    70  			os.Exit(2)
    71  		}
    72  		err = extract()
    73  	case *listFlag:
    74  		if len(flag.Args()) > 0 {
    75  			fmt.Fprintln(os.Stderr, "Usage: txtar --list <archive.txt")
    76  			os.Exit(2)
    77  		}
    78  		err = list()
    79  	default:
    80  		paths := flag.Args()
    81  		if len(paths) == 0 {
    82  			paths = []string{"."}
    83  		}
    84  		err = archive(paths)
    85  	}
    86  
    87  	if err != nil {
    88  		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
    89  		os.Exit(1)
    90  	}
    91  }
    92  
    93  func extract() (err error) {
    94  	b, err := io.ReadAll(os.Stdin)
    95  	if err != nil {
    96  		return err
    97  	}
    98  
    99  	ar := txtar.Parse(b)
   100  
   101  	if !*unsafeFlag {
   102  		// Check that no files are extracted outside the current directory
   103  		wd, err := os.Getwd()
   104  		if err != nil {
   105  			return err
   106  		}
   107  		// Add trailing separator to terminate wd.
   108  		// This prevents extracting to outside paths which prefix wd,
   109  		// e.g. extracting to /home/foobar when wd is /home/foo
   110  		if !strings.HasSuffix(wd, string(filepath.Separator)) {
   111  			wd += string(filepath.Separator)
   112  		}
   113  
   114  		for _, f := range ar.Files {
   115  			fileName := filepath.Clean(expand(f.Name))
   116  
   117  			if strings.HasPrefix(fileName, "..") ||
   118  				(filepath.IsAbs(fileName) && !strings.HasPrefix(fileName, wd)) {
   119  				return fmt.Errorf("file path '%s' is outside the current directory", f.Name)
   120  			}
   121  		}
   122  	}
   123  
   124  	for _, f := range ar.Files {
   125  		fileName := filepath.FromSlash(path.Clean(expand(f.Name)))
   126  		if err := os.MkdirAll(filepath.Dir(fileName), 0777); err != nil {
   127  			return err
   128  		}
   129  		if err := os.WriteFile(fileName, f.Data, 0666); err != nil {
   130  			return err
   131  		}
   132  	}
   133  
   134  	if len(ar.Comment) > 0 {
   135  		os.Stdout.Write(ar.Comment)
   136  	}
   137  	return nil
   138  }
   139  
   140  func list() (err error) {
   141  	b, err := io.ReadAll(os.Stdin)
   142  	if err != nil {
   143  		return err
   144  	}
   145  
   146  	ar := txtar.Parse(b)
   147  	for _, f := range ar.Files {
   148  		fmt.Println(f.Name)
   149  	}
   150  	return nil
   151  }
   152  
   153  func archive(paths []string) (err error) {
   154  	txtarHeader := regexp.MustCompile(`(?m)^-- .* --$`)
   155  
   156  	ar := new(txtar.Archive)
   157  	for _, p := range paths {
   158  		root := filepath.Clean(expand(p))
   159  		prefix := root + string(filepath.Separator)
   160  		err := filepath.Walk(root, func(fileName string, info os.FileInfo, err error) error {
   161  			if err != nil || info.IsDir() {
   162  				return err
   163  			}
   164  
   165  			suffix := ""
   166  			if fileName != root {
   167  				suffix = strings.TrimPrefix(fileName, prefix)
   168  			}
   169  			name := filepath.ToSlash(filepath.Join(p, suffix))
   170  
   171  			data, err := os.ReadFile(fileName)
   172  			if err != nil {
   173  				return err
   174  			}
   175  			if txtarHeader.Match(data) {
   176  				return fmt.Errorf("cannot archive %s: file contains a txtar header", name)
   177  			}
   178  
   179  			ar.Files = append(ar.Files, txtar.File{Name: name, Data: data})
   180  			return nil
   181  		})
   182  		if err != nil {
   183  			return err
   184  		}
   185  	}
   186  
   187  	// After we have read all of the source files, read the comment from stdin.
   188  	//
   189  	// Wait until the read has been blocked for a while before prompting the user
   190  	// to enter it: if they are piping the comment in from some other file, the
   191  	// read should complete very quickly and there is no need for a prompt.
   192  	// (200ms is typically long enough to read a reasonable comment from the local
   193  	// machine, but short enough that humans don't notice it.)
   194  	//
   195  	// Don't prompt until we have successfully read the other files:
   196  	// if we encountered an error, we don't need to ask for a comment.
   197  	timer := time.AfterFunc(200*time.Millisecond, func() {
   198  		fmt.Fprintln(os.Stderr, "Enter comment:")
   199  	})
   200  	comment, err := io.ReadAll(os.Stdin)
   201  	timer.Stop()
   202  	if err != nil {
   203  		return fmt.Errorf("reading comment from %s: %v", os.Stdin.Name(), err)
   204  	}
   205  	ar.Comment = bytes.TrimSpace(comment)
   206  
   207  	_, err = os.Stdout.Write(txtar.Format(ar))
   208  	return err
   209  }
   210  
   211  // expand is like os.ExpandEnv, but preserves unescaped variables (instead
   212  // of escaping them to the empty string) if the variable is not set.
   213  func expand(p string) string {
   214  	return os.Expand(p, func(key string) string {
   215  		v, ok := os.LookupEnv(key)
   216  		if !ok {
   217  			return "$" + key
   218  		}
   219  		return v
   220  	})
   221  }