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
+}