diff --git a/.github/images/unpath.svg b/.github/images/unpath.svg new file mode 100644 index 0000000..ae05ba9 --- /dev/null +++ b/.github/images/unpath.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..c9782eb --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,27 @@ +name: Test + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + go-version: + - '1.22.x' + steps: + - uses: actions/checkout@v4 + - name: Setup Go ${{ matrix.go-version }} + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + - run: go test + - run: make dist + if: startsWith(github.ref, 'refs/tags/') + - run: ls dist + if: startsWith(github.ref, 'refs/tags/') + - name: Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + files: dist/* diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..cade7da --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 3v0k4 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1afd14e --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +.PHONY: dist + +PLATFORMS = linux darwin +ARCHITECTURES = amd64 arm64 + +dist: + @for platform in $(PLATFORMS); do \ + for arch in $(ARCHITECTURES); do \ + GOOS=$$platform GOARCH=$$arch go build -trimpath -o dist/unpath-$$platform-$$arch main.go; \ + done \ + done diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e2a1fd --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Unpath + +
+ +
+ +```bash +Usage: unpath UNCMD CMD + +unpath runs CMD with a modified PATH that does not contain UNCMD. + +Arguments: + UNCMD the command to hide from PATH + CMD the command to run with the modified PATH + +Examples: + unpath cat ./script script-arg + + unpath cat CMD subcmd-arg + + unpath cat unpath env CMD +``` + +## Installation + +You can install unpath with Go: + +```bash +go install github.com/3v0k4/unpath +``` + +Or fetch the executable from GitHub: + +```bash +# PLATFORM {linux,darwin} +# ARCHITECTURE {amd64,arm64} +curl https://github.com/3v0k4/unpath/releases/download/v0.1.0/unpath-PLATFORM-ARCH --output unpath +chmod +x unpath +./unpath +``` + +## Usage + +```bash +unpath cat ./script script-arg + +unpath cat command command-arg +``` + +To show all the options: + +```bash +unpath +``` + +## Development + +Unpath is dependency-free (it only uses the Go standard library), so there are no prerequisites. + +```bash +go test +``` + +To release a new version add a tag and push: + +```bash +git tag vX.Y.Z +git push --tags +``` + +## Contributing + +Bug reports and pull requests are welcome on [GitHub](https://github.com/3v0k4/unpath). + +## License + +The module is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..510a13b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/3v0k4/unpath + +go 1.22.3 diff --git a/main.go b/main.go new file mode 100644 index 0000000..feddcb5 --- /dev/null +++ b/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "cmp" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "sync" +) + +type program struct { + args []string + stdout io.Writer + stderr io.Writer +} + +func newProgram(args []string, stdout, stderr io.Writer) *program { + return &program{args: args, stdout: stdout, stderr: stderr} +} + +func main() { + program := newProgram(os.Args, os.Stdout, os.Stderr) + status := program.main() + os.Exit(status) +} + +func (p *program) main() int { + uncmd, cmd, status := p.parse() + if status > 0 { + return status + } + path, status := p.unpath(uncmd) + if status > 0 { + return status + } + return p.run(cmd, path) +} + +func (p *program) parse() (string, []string, int) { + if len(p.args) < 3 { + err := `Usage: {PROGRAM} UNCMD CMD + +unpath runs CMD with a modified PATH that does not contain UNCMD. + +Arguments: + UNCMD the command to hide from PATH + CMD the command to run with the modified PATH + +Examples: + unpath cat ./script script-arg + + unpath cat CMD subcmd-arg + + unpath cat unpath env CMD` + err = strings.ReplaceAll(err, "{PROGRAM}", p.args[0]) + fmt.Fprintf(p.stderr, fmt.Sprintln(err)) + return "", nil, 1 + } + return p.args[1], p.args[2:], 0 +} + +type result struct { + dir string + status int +} + +func (p *program) unpath(cmd string) (string, int) { + path, _ := os.LookupEnv("PATH") + dirs := strings.Split(path, ":") + newDirs := make([]result, len(dirs)) + var wg sync.WaitGroup + for i, dir := range dirs { + wg.Add(1) + go func(i int, dir string) { + entries, _ := os.ReadDir(dir) // ignore errors caused by empty dirs in PATH + n, found := slices.BinarySearchFunc(entries, cmd, func(a fs.DirEntry, b string) int { + return cmp.Compare(a.Name(), b) + }) + if found { + dir, status := p.unpathEntry(dir, entries, n) + newDirs[i] = result{dir, status} + } else { + newDirs[i] = result{dir, 0} + } + wg.Done() + }(i, dir) + } + wg.Wait() + for i, result := range newDirs { + if result.status > 0 { + return "", result.status + } + dirs[i] = result.dir + } + return strings.Join(dirs, ":"), 0 +} + +func (p *program) unpathEntry(dir string, entries []fs.DirEntry, entriesIndex int) (string, int) { + tmpdir, err := os.MkdirTemp("", filepath.Base(dir)) + if err != nil { + fmt.Fprintln(p.stderr, err) + return "", 1 + } + + for i, entry := range entries { + if i == entriesIndex { + continue + } + err := os.Symlink(filepath.Join(dir, entry.Name()), filepath.Join(tmpdir, entry.Name())) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return "", 1 + } + } + + return tmpdir, 0 +} + +func (p *program) run(cmd []string, path string) int { + arg := append([]string{"-P", path}, cmd...) + subcmd := exec.Command("env", arg...) + subcmd.Env = append(subcmd.Environ(), fmt.Sprintf("PATH=%s", path)) + subcmd.Stdout = p.stdout + subcmd.Stderr = p.stderr + if subcmd.Run() == nil { + return 0 + } else { + return 1 + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..a761be0 --- /dev/null +++ b/main_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestUnpathsNonExistingCommand(t *testing.T) { + var stdout, stderr bytes.Buffer + status := newProgram([]string{"unpath", "not-cat", "cat", "main_test.go"}, &stdout, &stderr).main() + if status != 0 { + t.Fatal(stderr) + } +} + +func TestUnpathsCommand(t *testing.T) { + var stdout, stderr bytes.Buffer + status := newProgram([]string{"unpath", "cat", "cat", "main_test.go"}, &stdout, &stderr).main() + if status == 0 { + t.Errorf("got: %d; want: %d", status, 0) + } + message := "env: cat: No such file or directory" + if !strings.Contains(stderr.String(), message) { + t.Errorf("got: %s; want: %s", stderr.String(), message) + } +} + +func TestUnpathsNonExistingCommandThroughScript(t *testing.T) { + script := createScript("#!/usr/bin/env bash\ncat $1", t.Fatal) + var stdout, stderr bytes.Buffer + status := newProgram([]string{"unpath", "not-cat", script.Name(), "main_test.go"}, &stdout, &stderr).main() + if status != 0 { + t.Fatal(stderr.String()) + } +} + +func TestUnpathsCommandThroughScript(t *testing.T) { + script := createScript("#!/usr/bin/env bash\ncat $1", t.Fatal) + var stdout, stderr bytes.Buffer + status := newProgram([]string{"unpath", "cat", script.Name(), "main_test.go"}, &stdout, &stderr).main() + if status == 0 { + t.Fatal(stderr) + } + message := "cat: command not found" + if !strings.Contains(stderr.String(), message) { + t.Errorf("got: %s; want: %s", stderr.String(), message) + } +} + +func Test_e2e_UnpathsSiblingCommand(t *testing.T) { + dir := createDir(t.Fatal) + command := createScriptIn(dir, "#!/usr/bin/env bash\ncat $1", t.Fatal) + command_ := filepath.Base(command.Name()) + sibling := createScriptIn(dir, "#!/usr/bin/env bash\ncat $1", t.Fatal) + sibling_ := filepath.Base(sibling.Name()) + script := createScriptIn(dir, fmt.Sprintf("#!/usr/bin/env bash\n%s $1", command_), t.Fatal) + script_ := filepath.Base(script.Name()) + + path, _ := os.LookupEnv("PATH") + path = fmt.Sprintf("%s:%s", dir, path) + + arg := []string{"go", "run", "main.go", sibling_, script_, "main_test.go"} + arg = append([]string{"-P", path}, arg...) + cmd := exec.Command("env", arg...) + cmd.Env = append(cmd.Environ(), fmt.Sprintf("PATH=%s", path)) + err := cmd.Run() + if err != nil { + t.Fatal(err) + } +} + +func Test_e2e_UnpathsCommand(t *testing.T) { + dir := createDir(t.Fatal) + command := createScriptIn(dir, "#!/usr/bin/env bash\ncat $1", t.Fatal) + command_ := filepath.Base(command.Name()) + script := createScriptIn(dir, fmt.Sprintf("#!/usr/bin/env bash\n%s $1", command_), t.Fatal) + script_ := filepath.Base(script.Name()) + + path, _ := os.LookupEnv("PATH") + path = fmt.Sprintf("%s:%s", dir, path) + + arg := []string{"go", "run", "main.go", command_, script_, "main_test.go"} + arg = append([]string{"-P", path}, arg...) + cmd := exec.Command("env", arg...) + cmd.Env = append(cmd.Environ(), fmt.Sprintf("PATH=%s", path)) + var stderr bytes.Buffer + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + t.Fatal(err) + } + message := fmt.Sprintf("%s: command not found", command_) + if !strings.Contains(stderr.String(), message) { + t.Errorf("got: %s; want: %s", stderr.String(), message) + } +} + +func Test_e2e_UnpathsCommandsRecursively(t *testing.T) { + script := createScript("#!/usr/bin/env bash\ncat $1", t.Fatal) + cmd := exec.Command("go", "run", "main.go", "non-cat", "go", "run", "main.go", "cat", script.Name(), "main_test.go") + var stderr bytes.Buffer + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + t.Fatal(err) + } + message := fmt.Sprintf("cat: command not found") + if !strings.Contains(stderr.String(), message) { + t.Errorf("got: %s; want: %s", stderr.String(), message) + } +} + +func createScript(content string, fatal func(args ...any)) *os.File { + dir := createDir(fatal) + return createScriptIn(dir, content, fatal) +} + +func createDir(fatal func(args ...any)) string { + dir, err := os.MkdirTemp("", "bin") + if err != nil { + fatal(err) + } + return dir +} + +func createScriptIn(dir, content string, fatal func(args ...any)) *os.File { + file, err := os.CreateTemp(dir, "script") + if err != nil { + fatal(err) + } + err = os.Chmod(file.Name(), 0777) + if err != nil { + fatal(err) + } + fmt.Fprintf(file, content) + + return file +}