From d850aff673079266a5caaba281e9e5da58eb8489 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 21 Jul 2019 23:21:47 -0600 Subject: [PATCH] happy parser --- envpath/parse.go | 97 ++++++++++++++ envpath/parse_test.go | 56 +++++++++ go.mod | 5 + pathman.go | 229 ++++++++++++++++++++++++++++++++++ pathman_unixes.go | 23 ++++ pathman_windows.go | 23 ++++ winpath/README.md | 63 ++++++++++ winpath/native_test.go | 16 +++ winpath/winpath.go | 94 ++++++++++++++ winpath/winpath_nonwindows.go | 15 +++ winpath/winpath_test.go | 23 ++++ winpath/winpath_unsafe.go | 32 +++++ winpath/winpath_windows.go | 120 ++++++++++++++++++ 13 files changed, 796 insertions(+) create mode 100644 envpath/parse.go create mode 100644 envpath/parse_test.go create mode 100644 go.mod create mode 100644 pathman.go create mode 100644 pathman_unixes.go create mode 100644 pathman_windows.go create mode 100644 winpath/README.md create mode 100644 winpath/native_test.go create mode 100644 winpath/winpath.go create mode 100644 winpath/winpath_nonwindows.go create mode 100644 winpath/winpath_test.go create mode 100644 winpath/winpath_unsafe.go create mode 100644 winpath/winpath_windows.go diff --git a/envpath/parse.go b/envpath/parse.go new file mode 100644 index 0000000..88f625d --- /dev/null +++ b/envpath/parse.go @@ -0,0 +1,97 @@ +package envpath + +import ( + "fmt" + "strings" +) + +type Warning struct { + LineNumber int + Line string + Message string +} + +// Parse will return a list of paths from an export file +func Parse(envname string, b []byte) ([]string, []Warning) { + s := string(b) + s = strings.Replace(s, "\r\n", "\n", -1) + + badlines := []Warning{} + newlines := []string{} + entries := make(map[string]bool) + lines := strings.Split(s, "\n") + for i := range lines { + line := strings.TrimPrefix(strings.TrimSpace(lines[i]), "export ") + if "" == line { + continue + } + if "# Generated for envman. Do not edit." == line { + continue + } + + if '#' == line[0] { + badlines = append(badlines, Warning{ + LineNumber: i, + Line: line, + Message: "comment", + }) + continue + } + + index := strings.Index(line, "=") + if index < 1 { + badlines = append(badlines, Warning{ + LineNumber: i, + Line: line, + Message: "invalid assignment", + }) + continue + } + + env := line[:index] + if env != envname { + badlines = append(badlines, Warning{ + LineNumber: i, + Line: line, + Message: fmt.Sprintf("wrong name (%s != %s)", env, envname), + }) + continue + } + + val := line[index+1:] + if len(val) < 2 || '"' != val[0] || '"' != val[len(val)-1] { + badlines = append(badlines, Warning{ + LineNumber: i, + Line: line, + Message: "value not quoted", + }) + continue + } + val = val[1 : len(val)-1] + + if strings.Contains(val, `"`) { + badlines = append(badlines, Warning{ + LineNumber: i, + Line: line, + Message: "invalid quotes", + }) + + continue + } + + // TODO normalize $HOME + if entries[val] { + badlines = append(badlines, Warning{ + LineNumber: i, + Line: line, + Message: "duplicate entry", + }) + continue + } + entries[val] = true + + newlines = append(newlines, val) + } + + return newlines, badlines +} diff --git a/envpath/parse_test.go b/envpath/parse_test.go new file mode 100644 index 0000000..a8818cb --- /dev/null +++ b/envpath/parse_test.go @@ -0,0 +1,56 @@ +package envpath + +import ( + "fmt" + "strings" + "testing" +) + +const file = `# Generated for envman. Do not edit. +PATH="/foo" + + +# ignore +# ignore + +PATH="/foo" +PATH="/foo:$PATH" +PATH="/foo:$PATH" +PATH="/foo:"$PATH" +PATH="/foo:""$PATH" +PATH="" + +PATH= + +JUNK="" +JUNK= +="" += + +whatever + + +PATH="/boo:$PATH" +PATH="" + +` + +var paths = []string{ + `PATH="/foo"`, + `PATH="/foo:$PATH"`, + `PATH=""`, + `PATH="/boo:$PATH"`, +} + +func TestParse(t *testing.T) { + newlines, warnings := Parse("PATH", []byte(file)) + newfile := `PATH="` + strings.Join(newlines, "\"\n\tPATH=\"") + `"` + expfile := strings.Join(paths, "\n\t") + if newfile != expfile { + t.Errorf("\nExpected:\n\t%s\nGot:\n\t%s", expfile, newfile) + } + for i := range warnings { + w := warnings[i] + fmt.Printf("warning dropping %q from line %d: %s\n", w.Message, w.LineNumber, w.Line) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6f7798c --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.rootprojects.org/root/pathman + +go 1.12 + +require golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7 diff --git a/pathman.go b/pathman.go new file mode 100644 index 0000000..caba2ad --- /dev/null +++ b/pathman.go @@ -0,0 +1,229 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func usage() { + fmt.Fprintf(os.Stdout, "Usage: envpath [path]\n") + fmt.Fprintf(os.Stdout, "\tex: envpath list\n") + fmt.Fprintf(os.Stdout, "\tex: envpath add ~/.local/bin\n") + fmt.Fprintf(os.Stdout, "\tex: envpath remove ~/.local/bin\n") +} + +func main() { + var action string + var entry string + + if len(os.Args) < 2 { + usage() + os.Exit(1) + return + } else if len(os.Args) > 3 { + usage() + os.Exit(1) + return + } + + action = os.Args[1] + if 2 == len(os.Args) { + entry = os.Args[2] + } + + // https://superuser.com/a/69190/73857 + // https://github.com/rust-lang-nursery/rustup.rs/issues/686#issuecomment-253982841 + // exec source $HOME/.profile + shell := os.Getenv("SHELL") + switch shell { + case "": + if strings.HasSuffix(os.Getenv("COMSPEC"), "/cmd.exe") { + shell = "cmd" + } + case "fish": + // ignore + case "zsh": + // ignore + case "bash": + // ignore + default: + // warn and try anyway + fmt.Fprintf( + os.Stderr, + "%q isn't a recognized shell. Please open an issue at https://git.rootprojects.org/envpath/issues?q=%s", + shell, + shell, + ) + } + + switch action { + case "list": + if 2 == len(os.Args) { + usage() + os.Exit(1) + } + list() + case "add": + add(entry) + case "remove": + remove(entry) + } +} + +func list() { + managedpaths, err := listPaths() + if nil != err { + fmt.Fprintf(os.Stderr, "%s", err) + os.Exit(1) + } + + fmt.Println("pathman-managed PATH entries:\n") + for i := range managedpaths { + fmt.Println("\t" + managedpaths[i]) + } + if 0 == len(managedpaths) { + fmt.Println("\t(none)") + } + fmt.Println("") + + fmt.Println("other PATH entries:\n") + // All managed paths + pathsmap := map[string]bool{} + for i := range managedpaths { + // TODO normalize + pathsmap[managedpaths[i]] = true + } + + // Paths in the environment which are not managed + var hasExtras bool + envpaths := Paths() + for i := range envpaths { + // TODO normalize + path := envpaths[i] + if !pathsmap[path] { + hasExtras = true + fmt.Println("\t" + path) + } + } + if !hasExtras { + fmt.Println("\t(none)") + } + fmt.Println("") +} + +func add(entry string) { + // TODO noramlize away $HOME, %USERPROFILE%, etc + abspath, err := filepath.Abs(entry) + stat, err := os.Stat(entry) + if nil != err { + fmt.Fprintf(os.Stderr, "warning: couldn't access %q: %s\n", abspath, err) + } else if !stat.IsDir() { + fmt.Fprintf(os.Stderr, "warning: %q is not a directory", abspath) + } + + modified, err := addPath(entry) + if nil != err { + fmt.Fprintf(os.Stderr, "failed to add %q to PATH: %s", entry, err) + os.Exit(1) + } + + var msg string + if modified { + msg = "Saved PATH changes." + } else { + msg = "PATH not changed." + } + + paths := Paths() + index := indexOfPath(Paths(), entry) + if -1 == index { + // TODO is os.PathListSeparator correct in MINGW / git bash? + // generally this has no effect, but just in case this is included in a library with children processes + paths = append([]string{entry}, paths...) + err = os.Setenv(`PATH`, strings.Join(paths, string(os.PathListSeparator))) + if nil != err { + // ignore and carry on, as this is optional + fmt.Fprintf(os.Stderr, "%s", err) + } + + msg += " To set the PATH immediately, update the current session:\n\n\t" + Add(entry) + "\n" + } + + fmt.Println(msg + "\n") +} + +func remove(entry string) { + modified, err := removePath(entry) + if nil != err { + fmt.Fprintf(os.Stderr, "failed to add %q to PATH: %s", entry, err) + os.Exit(1) + } + + var msg string + if modified { + msg = "Saved PATH changes." + } else { + msg = "PATH not changed." + } + + paths := Paths() + index := indexOfPath(Paths(), entry) + if index >= 0 { + newpaths := []string{} + for i := range paths { + if i != index { + newpaths = append(newpaths, paths[i]) + } + } + // TODO is os.PathListSeparator correct in MINGW / git bash? + // generally this has no effect, but just in case this is included in a library with children processes + err = os.Setenv(`PATH`, strings.Join(newpaths, string(os.PathListSeparator))) + if nil != err { + // ignore and carry on, as this is optional + fmt.Fprintf(os.Stderr, "%s", err) + } + + msg += " To set the PATH immediately, update the current session:\n\n\t" + Remove(entry) + "\n" + } + + fmt.Println(msg + "\n") +} + +// Paths returns path entries in the current environment +func Paths() []string { + cur := os.Getenv("PATH") + if "" == cur { + // unlikely, but possible... so whatever + return nil + } + + if isCmdExe() { + //return strings.Split(cur, string(os.PathListSeparator)) + return strings.Split(cur, ";") + } + return strings.Split(cur, string(os.PathListSeparator)) +} + +// Add returns a string which can be used to add the given +// path entry to the current shell session +func Add(p string) string { + if isCmdExe() { + return fmt.Sprintf(`PATH %s;%PATH%`, p) + } + return fmt.Sprintf(`export PATH="%s:$PATH"`, p) +} + +// Remove returns a string which can be used to remove the given +// path entry from the current shell session +func Remove(entries []string) string { + if isCmdExe() { + return fmt.Sprintf(`PATH %s`, strings.Join(entries, ";")) + } + return fmt.Sprintf(`export PATH="%s"`, strings.Join(entries, ":")) +} + +func isCmdExe() { + return "" == os.Getenv("SHELL") && strings.Contains(strings.ToLower(os.Getenv("COMSPEC")), "/cmd.exe") +} diff --git a/pathman_unixes.go b/pathman_unixes.go new file mode 100644 index 0000000..fdc79c0 --- /dev/null +++ b/pathman_unixes.go @@ -0,0 +1,23 @@ +// +build windows + +package main + +import ( + "git.rootprojects.org/root/pathman/envpath" +) + +func addPath(p string) (bool, error) { + return envpath.Add(p) +} + +func removePath(p string) (bool, error) { + return envpath.Remove(p) +} + +func listPaths() ([]string, error) { + return envpath.List() +} + +func indexOfPath(cur []string, p string) int { + return envpath.IndexOf(cur, p) +} diff --git a/pathman_windows.go b/pathman_windows.go new file mode 100644 index 0000000..8cfcf67 --- /dev/null +++ b/pathman_windows.go @@ -0,0 +1,23 @@ +// +build windows + +package main + +import ( + "git.rootprojects.org/root/pathman/winpath" +) + +func addPath(p string) (bool, error) { + return winpath.Add(p) +} + +func removePath(p string) (bool, error) { + return winpath.Remove(p) +} + +func listPaths() ([]string, error) { + return winpath.List() +} + +func indexOfPath(cur []string, p string) int { + return winpath.IndexOf(cur, p) +} diff --git a/winpath/README.md b/winpath/README.md new file mode 100644 index 0000000..b37d33f --- /dev/null +++ b/winpath/README.md @@ -0,0 +1,63 @@ +# winpath + +An example of getting, setting, and broadcasting PATHs on Windows. + +This requires the `unsafe` package to use a syscall with special message poitners to update `PATH` without a reboot. +It will also build without `unsafe`. + +```bash +go build -tags unsafe -o winpath.exe +``` + +```bash +winpath show + + %USERPROFILE%\AppData\Local\Microsoft\WindowsApps + C:\Users\me\AppData\Local\Programs\Microsoft VS Code\bin + %USERPROFILE%\go\bin + C:\Users\me\AppData\Roaming\npm + C:\Users\me\AppData\Local\Keybase\ +``` + +```bash +winpath append C:\someplace\special + + Run the following for changes to take affect immediately: + PATH %PATH%;C:\someplace\special +``` + +```bash +winpath prepend C:\someplace\special + + Run the following for changes to take affect immediately: + PATH C:\someplace\special;%PATH% +``` + +```bash +winpath remove C:\someplace\special +``` + +# Special Considerations + +Giving away the secret sauce right here: + +* `HWND_BROADCAST` +* `WM_SETTINGCHANGE` + +This is essentially the snippet you need to have the HKCU and HKLM Environment registry keys propagated without rebooting: + +```go + HWND_BROADCAST := uintptr(0xffff) + WM_SETTINGCHANGE := uintptr(0x001A) + _, _, err := syscall. + NewLazyDLL("user32.dll"). + NewProc("SendMessageW"). + Call(HWND_BROADCAST, WM_SETTINGCHANGE, 0, uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("ENVIRONMENT")))) + +``` + +* `os.Getenv("COMSPEC")` +* `os.Getenv("SHELL")` + +If you check `SHELL` and it isn't empty, then you're probably in MINGW or some such. +If that's empty but `COMSPEC` isn't, you can be reasonably sure that you're in cmd.exe or Powershell. diff --git a/winpath/native_test.go b/winpath/native_test.go new file mode 100644 index 0000000..9292dbe --- /dev/null +++ b/winpath/native_test.go @@ -0,0 +1,16 @@ +// +build windows + +package winpath + +import "testing" + +func TestShow(t *testing.T) { + paths, err := Paths() + if nil != err { + t.Error(err) + } + + if len(paths) < 1 { + t.Error("should have paths") + } +} diff --git a/winpath/winpath.go b/winpath/winpath.go new file mode 100644 index 0000000..04618e6 --- /dev/null +++ b/winpath/winpath.go @@ -0,0 +1,94 @@ +// Package winpath is useful for managing PATH as part of the Environment +// in the Windows HKey Local User registry. It returns an error for most +// operations on non-Windows systems. +package winpath + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// ErrWrongPlatform indicates that this was not built for Windows +var ErrWrongPlatform = fmt.Errorf("method not implemented on this platform") + +// sendmsg uses a syscall to broadcast the registry change so that +// new shells will get the new PATH immediately, without a reboot +var sendmsg func() + +// Paths returns all PATHs according to the Windows HKLU registry +// (or nil on non-windows platforms) +func Paths() ([]string, error) { + return paths() +} + +// Add will rewrite the Windows registry HKLU Environment, +// prepending the given directory path to the user's PATH. +// It will return whether the PATH was modified and an +// error if it should have been modified, but wasn't. +func Add(p string) (bool, error) { + return add(p) +} + +// Remove will rewrite the Windows registry HKLU Environment +// without the given directory path. +// It will return whether the PATH was modified and an +// error if it should have been modified, but wasn't. +func Remove(p string) (bool, error) { + return remove(p) +} + +// NormalizePathEntry will return the given directory path relative +// from its absolute path to the %USERPROFILE% (home) directory. +func NormalizePathEntry(pathentry string) (string, string) { + home, err := os.UserHomeDir() + if nil != err { + fmt.Fprintf(os.Stderr, "Couldn't get HOME directory. That's an unrecoverable hard fail.") + panic(err) + } + + sep := string(os.PathSeparator) + absentry, _ := filepath.Abs(pathentry) + home, _ = filepath.Abs(home) + + var homeentry string + if strings.HasPrefix(strings.ToLower(absentry)+sep, strings.ToLower(home)+sep) { + // %USERPROFILE% is allowed, but only for user PATH + // https://superuser.com/a/442163/73857 + homeentry = `%USERPROFILE%` + pathentry[len(home):] + } + + if absentry == pathentry { + absentry = "" + } + if homeentry == pathentry { + homeentry = "" + } + + return absentry, homeentry +} + +// IndexOf searches the given path list for first occurence +// of the given path entry and returns the index, or -1 +func IndexOf(paths []string, p string) int { + abspath, homepath := NormalizePathEntry(p) + + index := -1 + for i := range paths { + if strings.ToLower(p) == strings.ToLower(paths[i]) { + index = i + break + } + if strings.ToLower(abspath) == strings.ToLower(paths[i]) { + index = i + break + } + if strings.ToLower(homepath) == strings.ToLower(paths[i]) { + index = i + break + } + } + + return index +} diff --git a/winpath/winpath_nonwindows.go b/winpath/winpath_nonwindows.go new file mode 100644 index 0000000..8e7118c --- /dev/null +++ b/winpath/winpath_nonwindows.go @@ -0,0 +1,15 @@ +// +build !windows + +package winpath + +func paths() ([]string, error) { + return nil, ErrWrongPlatform +} + +func add(string) (bool, error) { + return false, ErrWrongPlatform +} + +func remove(string) (bool, error) { + return false, ErrWrongPlatform +} diff --git a/winpath/winpath_test.go b/winpath/winpath_test.go new file mode 100644 index 0000000..79c3ee4 --- /dev/null +++ b/winpath/winpath_test.go @@ -0,0 +1,23 @@ +package winpath + +import ( + "fmt" + "os" + "testing" +) + +func TestNormalize(t *testing.T) { + home, _ := os.UserHomeDir() + + absexp := "" + homeexp := "%USERPROFILE%" + string(os.PathSeparator) + "foo" + abspath, homepath := NormalizePathEntry(home + string(os.PathSeparator) + "foo") + + if absexp != abspath { + t.Error(fmt.Errorf("Expected %q, but got %q", absexp, abspath)) + } + + if homeexp != homepath { + t.Error(fmt.Errorf("Expected %q, but got %q", homeexp, homepath)) + } +} diff --git a/winpath/winpath_unsafe.go b/winpath/winpath_unsafe.go new file mode 100644 index 0000000..850a8d7 --- /dev/null +++ b/winpath/winpath_unsafe.go @@ -0,0 +1,32 @@ +// +build windows,unsafe + +package winpath + +import ( + "fmt" + "os" + "syscall" + "unsafe" +) + +const ( + HWND_BROADCAST = uintptr(0xffff) + WM_SETTINGCHANGE = uintptr(0x001A) +) + +func init() { + + // WM_SETTING_CHANGE + // https://gist.github.com/microo8/c1b9525efab9bb462adf9d123e855c52 + sendmsg = func() { + //x, y, err := syscall. + _, _, err := syscall. + NewLazyDLL("user32.dll"). + NewProc("SendMessageW"). + Call(HWND_BROADCAST, WM_SETTINGCHANGE, 0, uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("ENVIRONMENT")))) + //fmt.Fprintf(os.Stderr, "%d, %d, %s\n", x, y, err) + if nil != err { + fmt.Fprintf(os.Stderr, "%s\n", err) + } + } +} diff --git a/winpath/winpath_windows.go b/winpath/winpath_windows.go new file mode 100644 index 0000000..25e81e5 --- /dev/null +++ b/winpath/winpath_windows.go @@ -0,0 +1,120 @@ +// +build windows + +package winpath + +// Needs to +// * use the registry editor directly to avoid possible PATH truncation +// ( https://stackoverflow.com/questions/9546324/adding-directory-to-path-environment-variable-in-windows ) +// ( https://superuser.com/questions/387619/overcoming-the-1024-character-limit-with-setx ) +// * explicitly send WM_SETTINGCHANGE +// ( https://github.com/golang/go/issues/18680#issuecomment-275582179 ) + +import ( + "fmt" + "os" + "strings" + + "golang.org/x/sys/windows/registry" +) + +func add(p string) (bool, error) { + cur, err := paths() + if nil != err { + return false, err + } + + index := IndexOf(cur, p) + // skip silently, successfully + if index >= 0 { + return false, nil + } + + k, err := registry.OpenKey(registry.CURRENT_USER, `Environment`, registry.SET_VALUE) + if err != nil { + return false, err + } + defer k.Close() + + cur = append([]string{p}, cur...) + err = write(cur) + if nil != err { + return false, err + } + + return true, nil +} + +func remove(p string) (bool, error) { + cur, err := paths() + if nil != err { + return false, err + } + + index := findMatch(cur, p) + // skip silently, successfully + if index < 0 { + return false, nil + } + + var newpaths []string + for i := range cur { + if i != index { + newpaths = append(newpaths, cur[i]) + } + } + + err = write(cur) + if nil != err { + return false, err + } + + return true, nil +} + +func write(cur []string) error { + // TODO --system to add to the system PATH rather than the user PATH + + k, err := registry.OpenKey(registry.CURRENT_USER, `Environment`, registry.QUERY_VALUE) + if err != nil { + return err + } + defer k.Close() + + err = k.SetStringValue(`Path`, strings.Join(cur, string(os.PathListSeparator))) + if nil != err { + return err + } + + err = k.Close() + if nil != err { + return err + } + + if nil != sendmsg { + sendmsg() + } else { + fmt.Fprintf(os.Stderr, "Warning: added PATH, but you must reboot for changes to take effect\n") + } + + return nil +} + +func paths() ([]string, error) { + // This is the canonical reference, which is actually quite nice to have. + // TBH, it's a mess to do this on *nix systems. + k, err := registry.OpenKey(registry.CURRENT_USER, `Environment`, registry.QUERY_VALUE) + if err != nil { + return nil, err + } + defer k.Close() + + // This is case insensitive on Windows. + // PATH, Path, path will all work. + s, _, err := k.GetStringValue("Path") + if err != nil { + return nil, err + } + + // ";" on Windows + return strings.Split(s, string(os.PathListSeparator)), nil +}