currently, every command that doesn't have a completion spec is assumed to be a cobra cli and it gets ran with a `__complete` argument. for non cobra clis this can cause problems if those non-cobra clis have sideeffects even when ran with the `__complete` argument, and there is no way to disable this. one of these problems was solved at #111, but a full solution would probably require actual sandboxing which is i think is an overkill just for attempting to get suggestions for unrecognized commands. instead of sandboxing, this pr adds a config option for allowing only selected commands to be probed and disallowing the rest. currently the default is still allowing all (`["*"]`) so default behavior is the same, but it might be better to just have a long list of known cobra clis as the default instead. here's a demo of a side effect caused by the probing, in this case "deleting" a file with rmtrash without actually trying to run the command: https://github.com/user-attachments/assets/5256d363-5291-42e8-bb4f-cf7467927246 --------- Co-authored-by: shemishtamesh <shemishtamail@gmail.com>
198 lines
4.7 KiB
Go
198 lines
4.7 KiB
Go
package spec
|
|
|
|
import (
|
|
"context"
|
|
"debug/buildinfo"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/versenilvis/iris/internal/config"
|
|
)
|
|
|
|
const cobraModulePath = "github.com/spf13/cobra"
|
|
|
|
type cobraCacheEntry struct {
|
|
suggestions []Suggestion
|
|
}
|
|
|
|
var (
|
|
cobraCache = map[string]cobraCacheEntry{}
|
|
cobraCacheMu sync.Mutex
|
|
)
|
|
|
|
func cobraBinKey(binName string) string {
|
|
path, err := exec.LookPath(binName)
|
|
if err != nil {
|
|
return binName
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return binName
|
|
}
|
|
return binName + "|" + info.ModTime().String()
|
|
}
|
|
|
|
// parseCobraOutput parses output from `<cmd> __complete <args>`.
|
|
// each line is "value\tdesc", last line is ":N" (ShellCompDirective bitmask).
|
|
// returns nil if output is not Cobra-style.
|
|
func parseCobraOutput(raw string, prefix string) []Suggestion {
|
|
lines := strings.Split(strings.TrimRight(raw, "\n"), "\n")
|
|
if len(lines) == 0 {
|
|
return nil
|
|
}
|
|
lastLine := lines[len(lines)-1]
|
|
if !strings.HasPrefix(lastLine, ":") {
|
|
return nil
|
|
}
|
|
directive, err := strconv.Atoi(lastLine[1:])
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
// ShellCompDirectiveError = 1
|
|
if directive&1 != 0 {
|
|
return nil
|
|
}
|
|
|
|
candidates := lines[:len(lines)-1]
|
|
results := make([]Suggestion, 0, len(candidates))
|
|
for _, line := range candidates {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
value, desc, _ := strings.Cut(line, "\t")
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
cmd := value
|
|
if prefix != "" {
|
|
cmd = prefix + " " + value
|
|
}
|
|
results = append(results, Suggestion{
|
|
Cmd: cmd,
|
|
Desc: desc,
|
|
Source: "spec-inferred",
|
|
Confidence: 50,
|
|
Priority: 30,
|
|
})
|
|
}
|
|
return results
|
|
}
|
|
|
|
func buildCobraCacheKey(binKey string, args []string, partial string) string {
|
|
var sb strings.Builder
|
|
sb.WriteString(binKey)
|
|
for _, arg := range args {
|
|
sb.WriteByte('\x00')
|
|
sb.WriteString(arg)
|
|
}
|
|
sb.WriteByte('\x00')
|
|
sb.WriteString(partial)
|
|
return sb.String()
|
|
}
|
|
|
|
// isLikelyCobraBinary reports whether binName is a Go binary linking Cobra.
|
|
// only does static analysis so can produce false negatives and positives.
|
|
func isLikelyCobraBinary(binName string) bool {
|
|
path, err := exec.LookPath(binName)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
info, err := buildinfo.ReadFile(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, dep := range info.Deps {
|
|
if dep.Path == cobraModulePath {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// newProbeCmd builds and isolates the `__complete` probe command.
|
|
// starts the child in its own session so it has no controlling terminal
|
|
// and therefore won't affect the user's tty in the case of programs that
|
|
// don't respect `__complete`.
|
|
func newProbeCmd(ctx context.Context, binName string, args []string) *exec.Cmd {
|
|
cmd := exec.CommandContext(ctx, binName, args...)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
|
return cmd
|
|
}
|
|
|
|
// QueryCobraComplete calls `binName __complete <args> <partial>` and returns
|
|
// structured suggestions cached per binary mtime, args, and partial.
|
|
// returns nil if the binary is not Cobra-based or times out.
|
|
func QueryCobraComplete(binName string, args []string, partial string) []Suggestion {
|
|
if strings.ContainsAny(binName, `/\`) {
|
|
return nil
|
|
}
|
|
if !config.Get().Core.CobraProbeEnabled {
|
|
return nil
|
|
}
|
|
|
|
binKey := cobraBinKey(binName)
|
|
argKey := buildCobraCacheKey(binKey, args, partial)
|
|
|
|
cobraCacheMu.Lock()
|
|
if entry, ok := cobraCache[argKey]; ok {
|
|
cobraCacheMu.Unlock()
|
|
return filterByPartial(entry.suggestions, partial)
|
|
}
|
|
cobraCacheMu.Unlock()
|
|
|
|
if !isLikelyCobraBinary(binName) {
|
|
return nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
|
defer cancel()
|
|
|
|
cmdArgs := append([]string{"__complete"}, args...)
|
|
cmdArgs = append(cmdArgs, partial)
|
|
probe := newProbeCmd(ctx, binName, cmdArgs)
|
|
out, err := probe.Output()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
prefixParts := append([]string{binName}, args...)
|
|
prefix := strings.Join(prefixParts, " ")
|
|
suggestions := parseCobraOutput(string(out), prefix)
|
|
|
|
cobraCacheMu.Lock()
|
|
cobraCache[argKey] = cobraCacheEntry{suggestions: suggestions}
|
|
cobraCacheMu.Unlock()
|
|
|
|
return filterByPartial(suggestions, partial)
|
|
}
|
|
|
|
func filterByPartial(suggestions []Suggestion, partial string) []Suggestion {
|
|
if partial == "" {
|
|
return suggestions
|
|
}
|
|
filtered := make([]Suggestion, 0, len(suggestions))
|
|
for _, s := range suggestions {
|
|
lastWord := s.Cmd
|
|
if idx := strings.LastIndex(s.Cmd, " "); idx >= 0 {
|
|
lastWord = s.Cmd[idx+1:]
|
|
}
|
|
if HasPrefix(lastWord, partial) {
|
|
filtered = append(filtered, s)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// ResetCobraCache clears the completion cache — use in tests only
|
|
func ResetCobraCache() {
|
|
cobraCacheMu.Lock()
|
|
cobraCache = map[string]cobraCacheEntry{}
|
|
cobraCacheMu.Unlock()
|
|
}
|