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 }