diff --git a/coreutils.go b/coreutils.go index b774e7c..7c623e3 100644 --- a/coreutils.go +++ b/coreutils.go @@ -1,6 +1,7 @@ package coreutils import ( + "context" "errors" "io" "sync" @@ -18,9 +19,10 @@ func Register(name string, r Runnable) { cmds[name] = r } -type Runnable func(ctx Ctx, args ...string) error +type Runnable func(ctx Context, args ...string) error -type Ctx struct { +type Context struct { + context.Context Dir string GetEnv func(string) string Stdin io.Reader @@ -28,7 +30,7 @@ type Ctx struct { Stderr io.Writer } -func Run(ctx Ctx, name string, args ...string) error { +func Run(ctx Context, name string, args ...string) error { cmdsMu.Lock() fn := cmds[name] cmdsMu.Unlock() diff --git a/rm/cmd.go b/rm/cmd.go new file mode 100644 index 0000000..ac28a8a --- /dev/null +++ b/rm/cmd.go @@ -0,0 +1,189 @@ +package rm + +import ( + "errors" + "fmt" + "io" + + coreutils "github.com/ericlagergren/go-coreutils" + flag "github.com/spf13/pflag" +) + +func init() { + coreutils.Register("rm", run) +} + +// Sentinal flags for default values or flags with single-character options and +// without multi-character options. (e.g., if we want -i but not --i.) +const ( + uniNonChar = 0xFDD0 + interDefault = string(uniNonChar + 1) + bad1 = string(uniNonChar + 2) + bad2 = string(uniNonChar + 3) + bad3 = string(uniNonChar + 4) +) + +func newCommand() *cmd { + var c cmd + c.f.BoolVarP(&c.force, "force", "f", false, "ignore non-existent files and arguments; never prompt prior to removal") + c.f.BoolVarP(&c.moreInter, bad1, "i", false, "prompt before each removal") + c.f.BoolVarP(&c.lessInter, bad2, "I", false, "prompt (once) prior to removing more than three files or when removing recursively") + c.f.StringVar(&c.interactive, "interactive", interDefault, "prompt: 'never', 'once' (-i), 'always' (-I)") + c.f.BoolVar(&c.oneFileSystem, "one-file-system", false, "when recursing, skip directories that are on a different filesystem") + c.f.BoolVar(&c.preserveRoot, "preserve-root", true, "do not remove '/'") + c.f.BoolVar(&c.noPreserveRoot, "no-preserve-root", false, "do not special-case '/'") + c.f.BoolVarP(&c.recursive, "recursive", "r", false, "remove directories and their contents recursively") + c.f.BoolVarP(&c.recursive, bad3, "R", false, "remove directories and their contents recursively") + c.f.BoolVarP(&c.rmdir, "dir", "d", false, "remove empty directories") + c.f.BoolVarP(&c.verbose, "verbose", "v", false, "explain what's occurring") + c.f.BoolVar(&c.version, "version", false, "print version information and exit") + return &c +} + +type cmd struct { + f flag.FlagSet + force bool + moreInter, lessInter bool + interactive string + preserveRoot bool + noPreserveRoot bool + oneFileSystem bool + recursive bool + rmdir bool + verbose bool + version bool +} + +func run(ctx coreutils.Context, args ...string) error { + c := newCommand() + if err := c.f.Parse(args); err != nil { + return err + } + + if c.version { + fmt.Fprintf(ctx.Stdout, "rm (go-coreutils) 1.0") + return nil + } + + var opts RemoveOption + if c.noPreserveRoot && !c.preserveRoot { + opts |= NoPreserveRoot + } + if c.force { + opts |= Force + } + if c.recursive { + opts |= Recursive + } + if c.rmdir { + opts |= RemoveEmpty + } + if c.oneFileSystem { + opts |= OneFileSystem + } + if c.verbose { + opts |= Verbose + } + switch c.interactive { + case interDefault: + if c.moreInter { + opts |= PromptAlways + c.lessInter = false + } + case "never", "no", "none": + opts &= PromptAlways + case "once": + c.lessInter = true + opts &= IgnoreMissing + case "always", "yes", "": + opts |= PromptAlways + opts &= IgnoreMissing + default: + return errors.New("unknown interactive option: " + c.interactive) + } + + if c.lessInter && (opts&Recursive != 0 || c.f.NArg() >= 3) { + n := c.f.NArg() + arg := "arguments" + adj := "" + if opts&Recursive != 0 { + adj = " recursively " + if n == 1 { + arg = "argument" + } + } + fmt.Fprintf(ctx.Stderr, "rm: remove %d %s%s? ", n, arg, adj) + switch yes, err := getYesNo(ctx.Stdin); { + case err != nil: + return err + case !yes: + return nil + } + } + + r := NewRemover(opts) + + if r.opts&PromptAlways != 0 { + r.Prompt = func(name string, opts PromptOption) bool { + + wp := " " + if opts&WriteProtected != 0 { + wp = " write-protected " + } + + msg := "rm: remove%s%s %q? " + typ := "file" + if opts&(Descend|Directory) != 0 { + typ = "directory" + if opts&Descend != 0 { + msg = "rm: descend into%s%s %q? " + } + } + + fmt.Fprintf(ctx.Stderr, msg, wp, typ, name) + yes, err := getYesNo(ctx.Stdin) + return yes && err == nil + } + } + + if r.Log != nil { + defer close(r.Log) + go func() { + for msg := range r.Log { + fmt.Fprintln(ctx.Stdout, msg) + } + }() + } + + var nerrs int + for _, name := range c.f.Args() { + switch err := r.Remove(name); err.(type) { + case nil: + // OK + case rmError: + fmt.Fprintf(ctx.Stderr, "rm: %v\n", err) + default: + fmt.Fprintf(ctx.Stderr, "rm: %v\n", err) + return err + } + } + if nerrs > 0 { + return errNonFatal + } + return nil +} + +var errNonFatal = errors.New("at least one non-fatal error occurred") + +func getYesNo(r io.Reader) (yes bool, err error) { + var resp string + fmt.Fscanln(r, &resp) + switch resp { + case "yes", "Y", "y": + return true, nil + case "no", "N", "n": + return false, nil + default: + return false, errors.New("unknown response (must be 'yes' or 'no')") + } +} diff --git a/rm/internal/sys/sys_unix.go b/rm/internal/sys/sys_unix.go new file mode 100644 index 0000000..c78dcf2 --- /dev/null +++ b/rm/internal/sys/sys_unix.go @@ -0,0 +1,25 @@ +// +build !windows + +package sys + +import ( + "os" + "syscall" +) + +var root *syscall.Stat_t + +func init() { + if info, err := os.Lstat("/"); err == nil { + root = info.Sys().(*syscall.Stat_t) + } +} + +func IsRoot(info os.FileInfo) bool { + stat := info.Sys().(*syscall.Stat_t) + return root.Ino == stat.Ino && root.Dev == stat.Dev +} + +func DiffFS(orig, test os.FileInfo) bool { + return orig.Sys().(*syscall.Stat_t).Dev != test.Sys().(*syscall.Stat_t).Dev +} diff --git a/rm/internal/sys/sys_windows.go b/rm/internal/sys/sys_windows.go new file mode 100644 index 0000000..d97cab9 --- /dev/null +++ b/rm/internal/sys/sys_windows.go @@ -0,0 +1,8 @@ +// +build windows + +package sys + +import "os" + +func IsRoot(_ os.FileInfo) bool { return false } +func DiffFS(_, _ os.FileInfo) bool { return false } diff --git a/rm/rm.go b/rm/rm.go index 29c198b..4787c12 100644 --- a/rm/rm.go +++ b/rm/rm.go @@ -1,94 +1,235 @@ -/* - Go rm - prints the current working directory. - Copyright (C) 2015 Robert Deusser - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -/* - Written by Robert Deusser -*/ - -package main +package rm import ( + "errors" "fmt" "os" + "path/filepath" + "runtime" - flag "github.com/ogier/pflag" + "github.com/ericlagergren/go-coreutils/rm/internal/sys" + "golang.org/x/sys/unix" ) +type RemoveOption uint8 + const ( - Help = ` -Usage: rm [OPTION]... FILE... -Remove (unlink) the FILE(s). - - -f, --force ignore nonexistent files and arguments, never prompt - -i prompt before every removal - -I prompt once before removing more than three files, or - when removing recursively; less intrusive than -i, - while still giving protection against most mistakes - --interactive[=WHEN] prompt according to WHEN: never, once (-I), or - always (-i); without WHEN, prompt always - --one-file-system when removing a hierarchy recursively, skip any - directory that is on a file system different from - that of the corresponding command line argument - --no-preserve-root do not treat '/' specially - --preserve-root do not remove '/' (default) - -r, -R, --recursive remove directories and their contents recursively - -d, --dir remove empty directories - -v, --verbose explain what is being done - --help display this help and exit - --version output version information and exit - -By default, rm does not remove directories. Use the --recursive (-r or -R) -option to remove each listed directory, too, along with all of its contents. - -To remove a file whose name starts with a '-', for example '-foo', -use one of these commands: - rm -- -foo - - rm ./-foo - -Note that if you use rm to remove a file, it might be possible to recover -some of its contents, given sufficient expertise and/or time. For greater -assurance that the contents are truly unrecoverable, consider using shred. - -` - Version = ` -rm (Go coreutils) 0.1 -Copyright (C) 2015 Robert Deusser -License GPLv3+: GNU GPL version 3 or later . -This is free software: you are free to change and redistribute it. -There is NO WARRANTY, to the extent permitted by law. - -` + NoPreserveRoot = 1 << iota + Force + Recursive + RemoveEmpty + IgnoreMissing + OneFileSystem + Verbose + PromptAlways ) -var version = flag.BoolP("version", "v", false, "") +type PromptOption uint8 + +const ( + // WriteProtected signals that the object to be removed or descended upon + // has write protection. + WriteProtected PromptOption = 1 << iota + // Directory indicates the action is on a directory. + Directory + // Descend indicates the action is to descend into an object. + Descend + // Remove indicates the action is to remove an object. + Remove +) -func main() { - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "%s", Help) - os.Exit(1) +func NewRemover(opts RemoveOption) *Remover { + r := Remover{opts: opts} + if opts&Verbose != 0 { + r.Log = make(chan string) } - flag.Parse() + return &r +} + +type Remover struct { + opts RemoveOption + root os.FileInfo + + // Prompt, if non-nil, will be called depending on the Remover's configured + // options. If it returns true, the action continues, otherwise it stops. + Prompt func(name string, opts PromptOption) bool - switch { - case *version: - fmt.Fprintf(os.Stdout, "%s", Version) - os.Exit(0) + Log chan string + + stack []node +} + +type node struct { + path string + info os.FileInfo + kids int +} + +func (r *Remover) Remove(path string) (err error) { + r.root, err = os.Lstat(path) + if err != nil { + return err + } + + if r.opts&Recursive == 0 || !r.root.Mode().IsDir() { + if err := r.remove(path, r.root); err != nil && err != errRefused { + return err + } + return nil + } + + // GNU rm uses a DFS that, once it reaches a leaf node (doesn't contain any + // further directories), clears out all files and "walks back" to the most + // recently seen non-leaf node. This is typicall DFS behavior, but the + // walking back is important: it allows the prompt for interactive usage to + // look like this: + // + // $ mkdir a/b/c + // $ touch a/b/c/d.txt + // $ rm a/ + // rm: descend into 'a'? + // rm: descend into 'a/b'? + // rm: descend into 'a/b/c'? + // rm: remove file 'a/b/c/d.txt'? + // rm: remove directory 'a/b/c'? + // rm: remove directory 'a/b'? + // rm: remove directory 'a'? + // + // Unfortunately, filepath.Walk doesn't allow us to walk back, so we're + // forced to do a little state management ourselves. We push each directory + // we encounter onto a stack. Once we hit a leaf node, we manually work our + // way back by popping every consecutive leaf node off the stack, removing + // it as we go. Since filepath.Walk doesn't work backwards, this works. + // + // A major downside is the requirement of determining how many objects are + // in a directory. This means Stat will be called twice for each directory: + // once for filepath.Walk, once for us. Same goes for Readdirnames. + return filepath.Walk(path, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + if !r.prompt(path, Descend) { + return filepath.SkipDir + } + dir, err := os.Open(path) + if err != nil { + return err + } + files, err := dir.Readdirnames(-1) + if err != nil { + return err + } + r.stack = append(r.stack, node{path: path, info: info, kids: len(files)}) + return dir.Close() + } + + err = r.remove(path, info) + + // Work our way down the r.stack. + for i := len(r.stack) - 1; i >= 0; i-- { + s := &r.stack[i] + s.kids-- + if s.kids != 0 { + r.stack = r.stack[:i+1] + break + } + if err := r.remove(s.path, s.info); err != nil && err != errRefused { + return err + } + } + + if err != nil && err != errRefused { + return err + } + return nil + }) +} + +var errRefused = errors.New("user refused prompt") + +type rmError struct{ msg string } + +func (r rmError) Error() string { return r.msg } + +func (r *Remover) prompt(name string, opts PromptOption) bool { + if r.opts&PromptAlways != 0 && r.Prompt != nil { + return r.Prompt(name, opts) + } + return true +} + +func (r *Remover) rm(name string, dir bool) (err error) { + opts := Remove + if dir { + opts |= Directory + } + if !r.prompt(name, opts) { + return errRefused + } + + switch runtime.GOOS { + case "windows", "plan9": + err = os.Remove(name) default: - for _, v := range flag.Args() { - os.Remove(v) + // For unix systems, os.Remove is a call to Unlink followed by a call to + // Rmdir. Since os.Remove doesn't know whether the object is a file or + // directory, this provides better performance in the common case. But, + // since we know the type of the object ahead of time, we can simply call + // the proper syscall. + if !dir { + err = unix.Unlink(name) + } else { + err = unix.Rmdir(name) + } + if err != nil { + err = &os.PathError{Op: "remove", Path: name, Err: err} } } + if err != nil && (r.opts&IgnoreMissing == 0 || !os.IsNotExist(err)) { + return err + } + if r.opts&Verbose != 0 { + if dir { + r.Log <- fmt.Sprintf("removed directory %s") + } else { + r.Log <- fmt.Sprintf("removed %s") + } + } + return nil +} + +func (r *Remover) remove(path string, info os.FileInfo) error { + if info.Mode()&os.ModeDir != 0 { + switch info.Name() { + // POSIX doesn't let us do anything with . or .. + case ".", "..": + return rmError{msg: "cannot remove '.' or '..'"} + case "/": + return rmError{msg: "cannot remove root directory"} + default: + if r.opts&NoPreserveRoot == 0 && sys.IsRoot(info) { + return rmError{msg: "cannot remove root directory"} + } + } + if r.opts&Recursive == 0 && (r.opts&RemoveEmpty == 0 || isEmpty(path)) { + return rmError{msg: fmt.Sprintf("cannot remove directory: %q", path)} + } + return r.rm(path, true) + } + if r.opts&OneFileSystem != 0 && sys.DiffFS(r.root, info) { + return rmError{msg: "cannot recurse into a different filesystem"} + } + return r.rm(path, false) +} + +func isEmpty(path string) bool { + file, err := os.Open(path) + if err != nil { + return false + } + defer file.Close() + names, err := file.Readdirnames(1) + return len(names) != 0 && err == nil } diff --git a/wc/cmd.go b/wc/cmd.go index 9857d76..aac4bbc 100644 --- a/wc/cmd.go +++ b/wc/cmd.go @@ -31,6 +31,7 @@ func newCommand() *cmd { If F is - then read names from standard input`) c.f.Int64VarP(&c.tabWidth, "tab", "t", 8, "change the tab width") c.f.BoolVarP(&c.unicode, "unicode-version", "u", false, "display unicode version and exit") + c.f.BoolVar(&c.version, "version", false, "display version information and exit") return &c } @@ -40,19 +41,23 @@ type cmd struct { filesFrom string tabWidth int64 unicode bool + version bool } var errMixedArgs = errors.New("file operands cannot be combined with --files0-from") -func run(ctx coreutils.Ctx, args ...string) error { +func run(ctx coreutils.Context, args ...string) error { c := newCommand() - // TODO(eric): usage - if err := c.f.Parse(args); err != nil { return err } + if c.version { + fmt.Fprintf(ctx.Stdout, "wc (go-coreutils) 1.0") + return nil + } + if c.unicode { fmt.Fprintf(ctx.Stdout, "Unicode version: %s\n", unicode.Version) return nil