From 1bdecae561a08f7043ecad2fd09774da4fb5f778 Mon Sep 17 00:00:00 2001 From: Cedric Lamalle Date: Sun, 4 Oct 2026 15:52:01 +0000 Subject: [PATCH 1/7] =?UTF-8?q?Redigir=20tokens=20na=20sa=C3=ADda=20do=20c?= =?UTF-8?q?ompletion=20TG-273?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O Cobra relata erro de completion com CompErrorln, que escreve direto em os.Stderr e no arquivo de BASH_COMP_DEBUG_FILE, fora dos writers do App. A mensagem só junta texto fixo, nomes de comando e flag e as palavras digitadas; o __complete passa a receber essas palavras já redigidas e com controles e bidi escapados. Um guarda estrutural impede funções de completion e chamadas cobra.Comp* no CLI. --- README.md | 2 + docs/guia.md | 2 + internal/cli/completion.go | 41 ++++++++++++ internal/cli/completion_test.go | 110 ++++++++++++++++++++++++++++++++ internal/cli/present_test.go | 15 +++++ internal/cli/root.go | 6 +- 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 internal/cli/completion.go create mode 100644 internal/cli/completion_test.go diff --git a/README.md b/README.md index a88a23f..f81705c 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,8 @@ Without a terminal on stdout, `taiga` prints JSON; on a terminal it prints text. Every curated command (all but `taiga api`, which prints Taiga's answer as is) hides the value of token parameters in everything it prints, on stdout and on stderr (errors and warnings), `--dry-run` plans included, keeping the keys and the rest of the URL (only the printout changes: a real write sends the values as given and as stored): user photos (`photo`, `big_photo` in `owner_extra_info`, `assigned_to_extra_info`, the comment author), project logos and attachment links are signed media URLs that open the file without authentication while the token lasts. `story get` shows `"photo": "https://…/media/user/…/photo.png?token=…"`. +Shell completion (`taiga completion bash|zsh|fish|powershell`) never calls Taiga, so it only completes command and flag names. When it cannot (an unknown command, an unknown flag, a bad flag value), the error it writes to stderr and, with `BASH_COMP_DEBUG_FILE` set, to that file has the typed words with token values hidden and control and bidi characters escaped (`\u202e`); a word redacted whole no longer reads as a flag, so no error is printed for it. The completion script itself also logs the command line to `BASH_COMP_DEBUG_FILE`: that is shell code and logs what was typed as is. + | Exit | Meaning | |---|---| | 0 | OK | diff --git a/docs/guia.md b/docs/guia.md index b68c9a0..4da2723 100644 --- a/docs/guia.md +++ b/docs/guia.md @@ -132,6 +132,8 @@ taiga story update 246 --replace-epic 15 --confirm-delete Toda saída dos comandos curados (todos menos `taiga api`, que mostra a resposta do Taiga como veio) esconde o valor dos parâmetros de token em tudo o que imprime, na saída e nos erros e avisos (stderr), inclusive nos planos de `--dry-run`, mantendo as chaves e o resto da URL. Só a impressão muda: a escrita de verdade envia os valores como vieram e como estão guardados. As fotos de usuário (`photo` e `big_photo` em `owner_extra_info`, `assigned_to_extra_info` e no autor dos comentários), o logo do projeto e os links de anexo são URLs assinadas de mídia que abrem o arquivo sem autenticação enquanto o token vale. Em `story get`, a foto sai como `"photo": "https://…/media/user/…?token=…"`. +O completion do shell (`taiga completion bash|zsh|fish|powershell`) nunca chama o Taiga: completa só nomes de comandos e flags. Quando não consegue (comando ou flag desconhecido, valor de flag inválido), o erro que escreve no stderr e, com `BASH_COMP_DEBUG_FILE` definida, nesse arquivo traz as palavras digitadas com o valor de token escondido e controles e bidi escapados (`\u202e`); a palavra redigida inteira deixa de ser flag, e então nenhum erro sai. O script de completion também registra a linha de comando no `BASH_COMP_DEBUG_FILE`: isso é código shell e registra o que foi digitado como veio. + ## Anexos ```sh diff --git a/internal/cli/completion.go b/internal/cli/completion.go new file mode 100644 index 0000000..d8b2c8f --- /dev/null +++ b/internal/cli/completion.go @@ -0,0 +1,41 @@ +package cli + +import ( + "strings" + + "github.com/spf13/cobra" +) + +// completeRedacted makes Cobra's hidden __complete command see the command line redacted and +// escaped, as presented elsewhere. +// +// Cobra reports a completion error (unknown command, unknown flag, bad flag value) with +// CompErrorln, which writes to os.Stderr and appends to $BASH_COMP_DEBUG_FILE on its own: no +// writer of the command reaches it, so the lineRedactor of wireOutput never sees that text. +// The message only joins fixed text, the names of commands and flags and the arguments typed, +// so redacting the arguments before Cobra reads them is what keeps a token or a control +// character out of both. Completion never calls Taiga, and TestOutputGoesThroughPresent keeps +// completion functions (which could print server text) out of the CLI. +// +// An argument changes only when it carries a token or an unsafe rune, and such an argument +// never names a command or a flag, so the candidates stay the same; the escape drops Quote's +// outer quotes so that `--flag…` still reads as a flag. +func completeRedacted(cmd *cobra.Command) { + run := cmd.Run + cmd.Run = func(c *cobra.Command, args []string) { + safe := make([]string, len(args)) + for i, s := range args { + safe[i] = completionArg(s) + } + run(c, safe) + } +} + +// completionArg is s redacted, with each unsafe rune escaped and no quotes around it. +func completionArg(s string) string { + p := presented(s) + if p != s && strings.HasPrefix(p, `"`) && strings.ContainsFunc(s, unsafeRune) { + return p[1 : len(p)-1] + } + return p +} diff --git a/internal/cli/completion_test.go b/internal/cli/completion_test.go new file mode 100644 index 0000000..5e6ff23 --- /dev/null +++ b/internal/cli/completion_test.go @@ -0,0 +1,110 @@ +package cli + +import ( + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +// runComplete runs `taiga req args...` (req is __complete or __completeNoDesc) the way the +// shell does, with BASH_COMP_DEBUG_FILE set, and returns stdout, everything written to stderr +// (App.Err and the process stderr, where Cobra's CompErrorln writes) and the debug file. +func runComplete(t *testing.T, env map[string]string, req string, args ...string) (out, stderr, debug string) { + t.Helper() + path := filepath.Join(t.TempDir(), "comp.log") + t.Setenv("BASH_COMP_DEBUG_FILE", path) + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + saved := os.Stderr + os.Stderr = w + read := make(chan string) + go func() { + b, _ := io.ReadAll(r) + read <- string(b) + }() + stdout, appErr, code := runIn(t, env, "", append([]string{req}, args...)...) + os.Stderr = saved + _ = w.Close() + process := <-read + if code != 0 { + t.Fatalf("__complete %q: exit %d: %s%s", args, code, appErr, process) + } + b, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + return stdout, appErr + process, string(b) +} + +// Completion errors echo the command line (an unknown command, a flag name, a flag value): +// stderr and the debug file get them redacted and escaped, like every other command. +func TestCompletionHidesTokens(t *testing.T) { + for _, c := range []struct { + args []string + want string // what still identifies the error + }{ + {[]string{"nope?token=SWEEPSECRET", ""}, "unable to find a command"}, + {[]string{"nope", sweepURL, ""}, "unable to find a command"}, + {[]string{"story", "list", "--bogus?access_token=SWEEPSECRET=1", ""}, "unknown flag"}, + {[]string{"story", "list", "--url", "x", "--bogus=" + sweepURL, ""}, "unknown flag"}, + {[]string{"attachment", "upload", "--task=token=SWEEPSECRET", ""}, "invalid argument"}, + // Redacted whole, the argument is no flag any more: no error, and nothing printed. + {[]string{"story", "list", "--access_token%3DSWEEPSECRET="}, ""}, + {[]string{"story", "list", "--a\u202eb="}, `does not support flag 'a\u202eb'`}, + {[]string{"story", "list", "--a\u202eb=1", ""}, `--a\u202eb`}, + {[]string{"nope\nforged", ""}, `nope\nforged`}, + } { + for _, req := range []string{"__complete", "__completeNoDesc"} { + out, stderr, debug := runComplete(t, nil, req, c.args...) + for name, text := range map[string]string{"stdout": out, "stderr": stderr, "debug file": debug} { + if strings.Contains(text, "SWEEPSECRET") || strings.ContainsAny(text, "\u202e") || strings.Contains(text, "\nforged") { + t.Errorf("%s %q: %s leaked: %q", req, c.args, name, text) + } + } + if !strings.Contains(stderr, c.want) || !strings.Contains(debug, c.want) { + t.Errorf("%s %q: the error is gone (want %q)\nstderr: %q\ndebug: %q", req, c.args, c.want, stderr, debug) + } + } + } +} + +// Completion stays what Cobra computes: redaction only touches arguments that carry a token +// or an unsafe rune, which never name a command or a flag. +func TestCompletionCandidatesUnchanged(t *testing.T) { + for _, c := range []struct { + args []string + want []string + }{ + {[]string{"story", "l"}, []string{"list\t", ":4"}}, + {[]string{"story", "list", "--url", sweepURL, "--outp"}, []string{"--output\t", ":4"}}, + {[]string{"story", "list", "--url=" + sweepURL, "--outp"}, []string{"--output\t", ":4"}}, + {[]string{"story", "list", "--a\u202eb", ""}, []string{":0"}}, + } { + out, _, _ := runComplete(t, nil, "__complete", c.args...) + for _, w := range c.want { + if !strings.Contains(out, w) { + t.Errorf("%q: want %q in %q", c.args, w, out) + } + } + } +} + +// Completion never calls Taiga, so no server text can reach it: nothing in the CLI registers +// a completion function (TestOutputGoesThroughPresent keeps it that way). +func TestCompletionNeverCallsTaiga(t *testing.T) { + srv, calls := fakeTaiga(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[{"id": 1, "name": "` + sweepURL + `", "slug": "` + sweepURL + `"}]`)) + }) + env := map[string]string{"TAIGA_URL": srv.URL, "TAIGA_TOKEN": "tok", "TAIGA_PROJECT": "infra-2025"} + for _, args := range [][]string{{""}, {"story", "get", ""}, {"story", "list", "--project", ""}, {"epic", "link", ""}, {"task", "close", ""}} { + out, stderr, debug := runComplete(t, env, "__complete", args...) + if len(*calls) != 0 || strings.Contains(out+stderr+debug, "SWEEPSECRET") { + t.Fatalf("%q: %d calls\n%s%s%s", args, len(*calls), out, stderr, debug) + } + } +} diff --git a/internal/cli/present_test.go b/internal/cli/present_test.go index bde8796..a692289 100644 --- a/internal/cli/present_test.go +++ b/internal/cli/present_test.go @@ -25,6 +25,10 @@ var outputRule = map[string][]string{ var cobraWriters = map[string]bool{"OutOrStdout": true, "OutOrStderr": true, "ErrOrStderr": true, "SetOut": true, "SetErr": true, "SetOutput": true, "Print": true, "Println": true, "Printf": true, "PrintErr": true, "PrintErrln": true, "PrintErrf": true} +// completionHooks would let completion print text of its own (server names, errors) through +// CompErrorln, which completeRedacted cannot redact. +var completionHooks = map[string]bool{"ValidArgsFunction": true, "RegisterFlagCompletionFunc": true} + func allowed(rule, file string) bool { for _, f := range outputRule[rule] { if file == f { @@ -67,6 +71,11 @@ func outputViolations(file string, src []byte) ([]string, error) { if id, ok := x.Fun.(*ast.Ident); ok && (id.Name == "print" || id.Name == "println") { add(x, "builtin "+id.Name+" writes to stderr") } + case *ast.Ident: + // A field (ValidArgsFunction: f), a method or a selector: every use of the name. + if completionHooks[x.Name] { + add(x, x.Name+": completion errors go to os.Stderr unredacted (see completion.go)") + } case *ast.SelectorExpr: name := x.Sel.Name if pkg, ok := x.X.(*ast.Ident); ok && imports[pkg.Name] != "" { @@ -75,6 +84,8 @@ func outputViolations(file string, src []byte) ([]string, error) { add(x, "os."+name+" outside the constructor") case imports[pkg.Name] == "fmt" && strings.HasPrefix(name, "Print"): add(x, "fmt."+name+" writes to stdout") + case imports[pkg.Name] == "github.com/spf13/cobra" && strings.HasPrefix(name, "Comp"): + add(x, "cobra."+name+" writes to os.Stderr and $BASH_COMP_DEBUG_FILE") } return true } @@ -138,6 +149,10 @@ func TestOutputGuardCatchesBypasses(t *testing.T) { `package cli; func f(a *App) { a.emitFields(nil) }`, `package cli; func f(a *App) { _ = a.rawOut() }`, `package cli; func f(a *App) error { return a.writeError(nil) }; var _ = (*App).emitJSON`, + `package cli; import "github.com/spf13/cobra"; func f() { cobra.CompErrorln("x") }`, + `package cli; import c "github.com/spf13/cobra"; func f() { c.CompDebug("x", true) }`, + `package cli; import "github.com/spf13/cobra"; func f() { _ = &cobra.Command{ValidArgsFunction: nil} }`, + `package cli; import "github.com/spf13/cobra"; func f(cmd *cobra.Command) { _ = cmd.RegisterFlagCompletionFunc("x", nil) }`, } { v, err := outputViolations("review_bypass.go", []byte(src)) if err != nil { diff --git a/internal/cli/root.go b/internal/cli/root.go index d867fba..c4bdfea 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -48,7 +48,11 @@ func (a *App) root() *cobra.Command { Short: "Command line client for the Taiga REST API", SilenceErrors: true, SilenceUsage: true, - PersistentPreRunE: func(*cobra.Command, []string) error { + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + if cmd.Name() == cobra.ShellCompRequestCmd { + completeRedacted(cmd) + return nil + } // Flags parsed: from here on errors come from the command, not from cobra. a.ran = true _, err := output.DetectMode(a.output, a.OutTTY) From ce2525442be478f90ff75bd15fed69705343781c Mon Sep 17 00:00:00 2001 From: Cedric Lamalle Date: Sun, 4 Oct 2026 15:59:10 +0000 Subject: [PATCH 2/7] =?UTF-8?q?Corrigir=20achados=20da=20revis=C3=A3o=20ad?= =?UTF-8?q?versarial=20TG-273?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit completionArg só tira as aspas que o Quote pôs: um valor de token redigido leva o caractere de controle junto, e a palavra não pode virar flag nem perder bytes. O guarda pega import com ponto do cobra e deixa de barrar CompletionOptions. Docs mais exatas sobre o que o completion completa. --- README.md | 2 +- docs/guia.md | 2 +- internal/cli/completion.go | 15 ++++++++------- internal/cli/completion_test.go | 10 ++++++++++ internal/cli/present_test.go | 12 ++++++++++-- 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f81705c..bfb3a3c 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ Without a terminal on stdout, `taiga` prints JSON; on a terminal it prints text. Every curated command (all but `taiga api`, which prints Taiga's answer as is) hides the value of token parameters in everything it prints, on stdout and on stderr (errors and warnings), `--dry-run` plans included, keeping the keys and the rest of the URL (only the printout changes: a real write sends the values as given and as stored): user photos (`photo`, `big_photo` in `owner_extra_info`, `assigned_to_extra_info`, the comment author), project logos and attachment links are signed media URLs that open the file without authentication while the token lasts. `story get` shows `"photo": "https://…/media/user/…/photo.png?token=…"`. -Shell completion (`taiga completion bash|zsh|fish|powershell`) never calls Taiga, so it only completes command and flag names. When it cannot (an unknown command, an unknown flag, a bad flag value), the error it writes to stderr and, with `BASH_COMP_DEBUG_FILE` set, to that file has the typed words with token values hidden and control and bidi characters escaped (`\u202e`); a word redacted whole no longer reads as a flag, so no error is printed for it. The completion script itself also logs the command line to `BASH_COMP_DEBUG_FILE`: that is shell code and logs what was typed as is. +Shell completion (`taiga completion bash|zsh|fish|powershell`) never calls Taiga: it completes command and flag names and leaves arguments and flag values to the shell (file names). When it cannot (an unknown command, an unknown flag, a bad flag value), the error it writes to stderr and, with `BASH_COMP_DEBUG_FILE` set, to that file has the typed words with token values hidden and control and bidi characters escaped (`\u202e`); a flag redacted whole (`[redacted: the text carries a credential]`) no longer reads as a flag, so no flag error is printed for it. The completion script itself also logs the command line to `BASH_COMP_DEBUG_FILE`: that is shell code and logs what was typed as is. | Exit | Meaning | |---|---| diff --git a/docs/guia.md b/docs/guia.md index 4da2723..8f72bdb 100644 --- a/docs/guia.md +++ b/docs/guia.md @@ -132,7 +132,7 @@ taiga story update 246 --replace-epic 15 --confirm-delete Toda saída dos comandos curados (todos menos `taiga api`, que mostra a resposta do Taiga como veio) esconde o valor dos parâmetros de token em tudo o que imprime, na saída e nos erros e avisos (stderr), inclusive nos planos de `--dry-run`, mantendo as chaves e o resto da URL. Só a impressão muda: a escrita de verdade envia os valores como vieram e como estão guardados. As fotos de usuário (`photo` e `big_photo` em `owner_extra_info`, `assigned_to_extra_info` e no autor dos comentários), o logo do projeto e os links de anexo são URLs assinadas de mídia que abrem o arquivo sem autenticação enquanto o token vale. Em `story get`, a foto sai como `"photo": "https://…/media/user/…?token=…"`. -O completion do shell (`taiga completion bash|zsh|fish|powershell`) nunca chama o Taiga: completa só nomes de comandos e flags. Quando não consegue (comando ou flag desconhecido, valor de flag inválido), o erro que escreve no stderr e, com `BASH_COMP_DEBUG_FILE` definida, nesse arquivo traz as palavras digitadas com o valor de token escondido e controles e bidi escapados (`\u202e`); a palavra redigida inteira deixa de ser flag, e então nenhum erro sai. O script de completion também registra a linha de comando no `BASH_COMP_DEBUG_FILE`: isso é código shell e registra o que foi digitado como veio. +O completion do shell (`taiga completion bash|zsh|fish|powershell`) nunca chama o Taiga: completa nomes de comandos e flags e deixa argumentos e valores de flag para o shell (nomes de arquivo). Quando não consegue (comando ou flag desconhecido, valor de flag inválido), o erro que escreve no stderr e, com `BASH_COMP_DEBUG_FILE` definida, nesse arquivo traz as palavras digitadas com o valor de token escondido e controles e bidi escapados (`\u202e`); uma flag redigida inteira (`[redacted: the text carries a credential]`) deixa de ser flag, e então nenhum erro de flag sai. O script de completion também registra a linha de comando no `BASH_COMP_DEBUG_FILE`: isso é código shell e registra o que foi digitado como veio. ## Anexos diff --git a/internal/cli/completion.go b/internal/cli/completion.go index d8b2c8f..9ddb14d 100644 --- a/internal/cli/completion.go +++ b/internal/cli/completion.go @@ -1,8 +1,8 @@ package cli import ( - "strings" - + "github.com/BasisTI/taiga-cli/internal/output" + "github.com/BasisTI/taiga-cli/internal/taiga" "github.com/spf13/cobra" ) @@ -31,11 +31,12 @@ func completeRedacted(cmd *cobra.Command) { } } -// completionArg is s redacted, with each unsafe rune escaped and no quotes around it. +// completionArg is s redacted, with each unsafe rune escaped and no quotes around it. Only +// what Quote quoted loses its quotes: a redacted value can take the unsafe rune with it. func completionArg(s string) string { - p := presented(s) - if p != s && strings.HasPrefix(p, `"`) && strings.ContainsFunc(s, unsafeRune) { - return p[1 : len(p)-1] + r := taiga.RedactTokens(s) + if q := output.Quote(r); q != r { + return q[1 : len(q)-1] } - return p + return r } diff --git a/internal/cli/completion_test.go b/internal/cli/completion_test.go index 5e6ff23..65ea91b 100644 --- a/internal/cli/completion_test.go +++ b/internal/cli/completion_test.go @@ -55,6 +55,10 @@ func TestCompletionHidesTokens(t *testing.T) { {[]string{"attachment", "upload", "--task=token=SWEEPSECRET", ""}, "invalid argument"}, // Redacted whole, the argument is no flag any more: no error, and nothing printed. {[]string{"story", "list", "--access_token%3DSWEEPSECRET="}, ""}, + // The control character goes with the token value: nothing is quoted, nothing unquoted. + {[]string{"nope", "\"x?token=SWEEP\x01SECRET", ""}, `[nope "x?token=…]`}, + // A word that starts with a quote stays a word, not a flag: no error, as without redaction. + {[]string{"story", "list", "\"--x?token=SWEEP\x01SECRET"}, ""}, {[]string{"story", "list", "--a\u202eb="}, `does not support flag 'a\u202eb'`}, {[]string{"story", "list", "--a\u202eb=1", ""}, `--a\u202eb`}, {[]string{"nope\nforged", ""}, `nope\nforged`}, @@ -66,6 +70,12 @@ func TestCompletionHidesTokens(t *testing.T) { t.Errorf("%s %q: %s leaked: %q", req, c.args, name, text) } } + if c.want == "" { + if strings.Contains(stderr+debug, "[Error]") { + t.Errorf("%s %q: printed an error\nstderr: %q\ndebug: %q", req, c.args, stderr, debug) + } + continue + } if !strings.Contains(stderr, c.want) || !strings.Contains(debug, c.want) { t.Errorf("%s %q: the error is gone (want %q)\nstderr: %q\ndebug: %q", req, c.args, c.want, stderr, debug) } diff --git a/internal/cli/present_test.go b/internal/cli/present_test.go index a692289..09a0d5d 100644 --- a/internal/cli/present_test.go +++ b/internal/cli/present_test.go @@ -29,6 +29,9 @@ var cobraWriters = map[string]bool{"OutOrStdout": true, "OutOrStderr": true, "Er // CompErrorln, which completeRedacted cannot redact. var completionHooks = map[string]bool{"ValidArgsFunction": true, "RegisterFlagCompletionFunc": true} +// compWriters are Cobra's completion printers: os.Stderr and $BASH_COMP_DEBUG_FILE, unredacted. +var compWriters = map[string]bool{"CompDebug": true, "CompDebugln": true, "CompError": true, "CompErrorln": true} + func allowed(rule, file string) bool { for _, f := range outputRule[rule] { if file == f { @@ -54,7 +57,7 @@ func outputViolations(file string, src []byte) ([]string, error) { if imp.Name != nil { name = imp.Name.Name } - if name == "." && (path == "os" || path == "fmt") { + if name == "." && (path == "os" || path == "fmt" || path == "github.com/spf13/cobra") { out = append(out, "dot import of "+path) } if path == "log" { @@ -84,7 +87,7 @@ func outputViolations(file string, src []byte) ([]string, error) { add(x, "os."+name+" outside the constructor") case imports[pkg.Name] == "fmt" && strings.HasPrefix(name, "Print"): add(x, "fmt."+name+" writes to stdout") - case imports[pkg.Name] == "github.com/spf13/cobra" && strings.HasPrefix(name, "Comp"): + case imports[pkg.Name] == "github.com/spf13/cobra" && compWriters[name]: add(x, "cobra."+name+" writes to os.Stderr and $BASH_COMP_DEBUG_FILE") } return true @@ -151,6 +154,7 @@ func TestOutputGuardCatchesBypasses(t *testing.T) { `package cli; func f(a *App) error { return a.writeError(nil) }; var _ = (*App).emitJSON`, `package cli; import "github.com/spf13/cobra"; func f() { cobra.CompErrorln("x") }`, `package cli; import c "github.com/spf13/cobra"; func f() { c.CompDebug("x", true) }`, + `package cli; import . "github.com/spf13/cobra"; func f() { CompErrorln("x") }`, `package cli; import "github.com/spf13/cobra"; func f() { _ = &cobra.Command{ValidArgsFunction: nil} }`, `package cli; import "github.com/spf13/cobra"; func f(cmd *cobra.Command) { _ = cmd.RegisterFlagCompletionFunc("x", nil) }`, } { @@ -162,6 +166,10 @@ func TestOutputGuardCatchesBypasses(t *testing.T) { t.Errorf("not caught: %s", src) } } + // Completion options and types are no printers. + if v, _ := outputViolations("root.go", []byte(`package cli; import "github.com/spf13/cobra"; var _ = cobra.CompletionOptions{}; var _ cobra.Completion`)); len(v) != 0 { + t.Errorf("root.go: %v", v) + } // What present.go and the constructor may do stays allowed. if v, _ := outputViolations("present.go", []byte(`package cli; func f(a *App) { _ = a.Out; _ = a.Err; a.emitFields(nil); a.root().SetOut(a.Out) }`)); len(v) != 0 { t.Errorf("present.go: %v", v) From aa339681c9df8980dc0809fc6f056f27c07e3e10 Mon Sep 17 00:00:00 2001 From: Cedric Lamalle Date: Sun, 4 Oct 2026 18:24:31 +0000 Subject: [PATCH 3/7] Gerar scripts de completion sem o debug do shell TG-273 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Os scripts do Cobra gravam no BASH_COMP_DEBUG_FILE a linha digitada, token incluído. O comando completion mantém ajuda e flags do Cobra, mas gera os quatro scripts sem o escritor de debug e sem as chamadas a ele, trocadas por no-op. O debug do processo Go, já redigido, continua. --- README.md | 2 +- docs/guia.md | 2 +- internal/cli/completion.go | 91 +++++++++++ internal/cli/completion_script_test.go | 217 +++++++++++++++++++++++++ internal/cli/present.go | 7 + internal/cli/root.go | 1 + 6 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 internal/cli/completion_script_test.go diff --git a/README.md b/README.md index bfb3a3c..a0e131b 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ Without a terminal on stdout, `taiga` prints JSON; on a terminal it prints text. Every curated command (all but `taiga api`, which prints Taiga's answer as is) hides the value of token parameters in everything it prints, on stdout and on stderr (errors and warnings), `--dry-run` plans included, keeping the keys and the rest of the URL (only the printout changes: a real write sends the values as given and as stored): user photos (`photo`, `big_photo` in `owner_extra_info`, `assigned_to_extra_info`, the comment author), project logos and attachment links are signed media URLs that open the file without authentication while the token lasts. `story get` shows `"photo": "https://…/media/user/…/photo.png?token=…"`. -Shell completion (`taiga completion bash|zsh|fish|powershell`) never calls Taiga: it completes command and flag names and leaves arguments and flag values to the shell (file names). When it cannot (an unknown command, an unknown flag, a bad flag value), the error it writes to stderr and, with `BASH_COMP_DEBUG_FILE` set, to that file has the typed words with token values hidden and control and bidi characters escaped (`\u202e`); a flag redacted whole (`[redacted: the text carries a credential]`) no longer reads as a flag, so no flag error is printed for it. The completion script itself also logs the command line to `BASH_COMP_DEBUG_FILE`: that is shell code and logs what was typed as is. +Shell completion (`taiga completion bash|zsh|fish|powershell`) never calls Taiga: it completes command and flag names and leaves arguments and flag values to the shell (file names). When it cannot (an unknown command, an unknown flag, a bad flag value), the error it writes to stderr and, with `BASH_COMP_DEBUG_FILE` set, to that file has the typed words with token values hidden and control and bidi characters escaped (`\u202e`); a flag redacted whole (`[redacted: the text carries a credential]`) no longer reads as a flag, so no flag error is printed for it. The scripts that `taiga completion` prints have no debug log of their own (Cobra's scripts append the typed command line, tokens included, to `BASH_COMP_DEBUG_FILE`); with the variable set, the file only gets the binary's redacted errors. Everything else in the scripts, and what they complete, is Cobra's. | Exit | Meaning | |---|---| diff --git a/docs/guia.md b/docs/guia.md index 8f72bdb..d30a20c 100644 --- a/docs/guia.md +++ b/docs/guia.md @@ -132,7 +132,7 @@ taiga story update 246 --replace-epic 15 --confirm-delete Toda saída dos comandos curados (todos menos `taiga api`, que mostra a resposta do Taiga como veio) esconde o valor dos parâmetros de token em tudo o que imprime, na saída e nos erros e avisos (stderr), inclusive nos planos de `--dry-run`, mantendo as chaves e o resto da URL. Só a impressão muda: a escrita de verdade envia os valores como vieram e como estão guardados. As fotos de usuário (`photo` e `big_photo` em `owner_extra_info`, `assigned_to_extra_info` e no autor dos comentários), o logo do projeto e os links de anexo são URLs assinadas de mídia que abrem o arquivo sem autenticação enquanto o token vale. Em `story get`, a foto sai como `"photo": "https://…/media/user/…?token=…"`. -O completion do shell (`taiga completion bash|zsh|fish|powershell`) nunca chama o Taiga: completa nomes de comandos e flags e deixa argumentos e valores de flag para o shell (nomes de arquivo). Quando não consegue (comando ou flag desconhecido, valor de flag inválido), o erro que escreve no stderr e, com `BASH_COMP_DEBUG_FILE` definida, nesse arquivo traz as palavras digitadas com o valor de token escondido e controles e bidi escapados (`\u202e`); uma flag redigida inteira (`[redacted: the text carries a credential]`) deixa de ser flag, e então nenhum erro de flag sai. O script de completion também registra a linha de comando no `BASH_COMP_DEBUG_FILE`: isso é código shell e registra o que foi digitado como veio. +O completion do shell (`taiga completion bash|zsh|fish|powershell`) nunca chama o Taiga: completa nomes de comandos e flags e deixa argumentos e valores de flag para o shell (nomes de arquivo). Quando não consegue (comando ou flag desconhecido, valor de flag inválido), o erro que escreve no stderr e, com `BASH_COMP_DEBUG_FILE` definida, nesse arquivo traz as palavras digitadas com o valor de token escondido e controles e bidi escapados (`\u202e`); uma flag redigida inteira (`[redacted: the text carries a credential]`) deixa de ser flag, e então nenhum erro de flag sai. Os scripts que `taiga completion` imprime não têm debug próprio (os do Cobra gravam no `BASH_COMP_DEBUG_FILE` a linha digitada, token incluído); com a variável definida, o arquivo só recebe os erros já redigidos do binário. O resto dos scripts, e o que eles completam, é o do Cobra. ## Anexos diff --git a/internal/cli/completion.go b/internal/cli/completion.go index 9ddb14d..a9c07ee 100644 --- a/internal/cli/completion.go +++ b/internal/cli/completion.go @@ -1,6 +1,10 @@ package cli import ( + "bytes" + "fmt" + "strings" + "github.com/BasisTI/taiga-cli/internal/output" "github.com/BasisTI/taiga-cli/internal/taiga" "github.com/spf13/cobra" @@ -40,3 +44,90 @@ func completionArg(s string) string { } return r } + +// completionCmd gives Cobra's `completion bash|zsh|fish|powershell` (its help and flags kept) +// scripts without the shell's own debug log. That log appends the command line as typed, +// tokens included, to $BASH_COMP_DEBUG_FILE; the binary's log there stays, redacted by +// completeRedacted. +func (a *App) completionCmd(root *cobra.Command) { + root.InitDefaultCompletionCmd() + for _, c := range root.Commands() { + if c.Name() != "completion" { + continue + } + for _, sh := range c.Commands() { + shell := sh.Name() + sh.RunE = func(cmd *cobra.Command, _ []string) error { + noDesc, _ := cmd.Flags().GetBool("no-descriptions") + s, err := completionScript(cmd.Root(), shell, !noDesc) + if err != nil { + return err + } + return a.writeScript(cmd, s) + } + } + } +} + +// completionScript is Cobra's script for shell without its debug writer and the calls to it. +func completionScript(root *cobra.Command, shell string, desc bool) (string, error) { + var b bytes.Buffer + var err error + switch shell { + case "bash": + err = root.GenBashCompletionV2(&b, desc) + case "zsh": + if desc { + err = root.GenZshCompletion(&b) + } else { + err = root.GenZshCompletionNoDesc(&b) + } + case "fish": + err = root.GenFishCompletion(&b, desc) + case "powershell": + if desc { + err = root.GenPowerShellCompletionWithDesc(&b) + } else { + err = root.GenPowerShellCompletion(&b) + } + default: + return "", fmt.Errorf("no completion script for %s", shell) + } + if err != nil { + return "", err + } + return withoutShellDebug(b.String(), "__"+root.Name()+"_debug", shellNoop[shell]) +} + +// shellNoop replaces each call to the debug writer, so a block never ends up empty and the +// status stays 0, as the writer's own when the variable is unset. PowerShell needs none. +var shellNoop = map[string]string{"bash": ":", "zsh": ":", "fish": "true", "powershell": ""} + +// withoutShellDebug drops the definition of writer (from its first line to the "}" or "end" +// that closes it at the margin) and turns each call, always a line of its own, into noop. +// Anything left that names the writer or the variable is an error, never a script. +func withoutShellDebug(script, writer, noop string) (string, error) { + var b strings.Builder + inWriter := false + for _, line := range strings.SplitAfter(script, "\n") { + text := strings.TrimRight(line, "\n") + trimmed := strings.TrimSpace(text) + switch { + case inWriter: + inWriter = text != "}" && text != "end" + case text == writer+"()" || text == "function "+writer || text == "function "+writer+" {": + inWriter = true + case trimmed == writer || strings.HasPrefix(trimmed, writer+" "): + if noop != "" { + b.WriteString(text[:len(text)-len(strings.TrimLeft(text, " \t"))] + noop + "\n") + } + default: + b.WriteString(line) + } + } + out := b.String() + if inWriter || strings.Contains(out, writer) || strings.Contains(out, "BASH_COMP_DEBUG_FILE") { + return "", fmt.Errorf("the %s completion script still logs to BASH_COMP_DEBUG_FILE", writer) + } + return out, nil +} diff --git a/internal/cli/completion_script_test.go b/internal/cli/completion_script_test.go new file mode 100644 index 0000000..25a9dc0 --- /dev/null +++ b/internal/cli/completion_script_test.go @@ -0,0 +1,217 @@ +package cli + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" +) + +var completionShells = []string{"bash", "zsh", "fish", "powershell"} + +// cobraScript is the script Cobra itself generates for shell, debug log included. +func cobraScript(t *testing.T, shell string, desc bool) string { + t.Helper() + root := (&App{}).root() + var b bytes.Buffer + var err error + switch shell { + case "bash": + err = root.GenBashCompletionV2(&b, desc) + case "zsh": + if desc { + err = root.GenZshCompletion(&b) + } else { + err = root.GenZshCompletionNoDesc(&b) + } + case "fish": + err = root.GenFishCompletion(&b, desc) + case "powershell": + if desc { + err = root.GenPowerShellCompletionWithDesc(&b) + } else { + err = root.GenPowerShellCompletion(&b) + } + } + if err != nil { + t.Fatal(err) + } + return b.String() +} + +// generatedScript is what `taiga completion shell` prints. +func generatedScript(t *testing.T, shell string, desc bool) string { + t.Helper() + args := []string{"completion", shell} + if !desc { + args = append(args, "--no-descriptions") + } + out, stderr, code := runIn(t, nil, "", args...) + if code != 0 { + t.Fatalf("%v: exit %d: %s", args, code, stderr) + } + return out +} + +var ( + debugCall = regexp.MustCompile(`^\s*__taiga_debug( .*)?$`) + noopLine = regexp.MustCompile(`^\s*(:|true)?$`) +) + +// withoutDebug drops the shell's debug writer, its calls and the lines a no-op or blank, so +// two scripts compare on everything else. +func withoutDebug(script string) []string { + out := []string{} + writer := false + for _, line := range strings.Split(script, "\n") { + switch { + case line == "__taiga_debug()" || strings.HasPrefix(line, "function __taiga_debug"): + writer = true + case writer: + writer = line != "}" && line != "end" + case debugCall.MatchString(line), noopLine.MatchString(line): + default: + out = append(out, line) + } + } + return out +} + +// The scripts carry no debug log of their own: no writer, no call, no BASH_COMP_DEBUG_FILE. +// Everything else is Cobra's script, line for line. +func TestCompletionScriptsHaveNoShellDebug(t *testing.T) { + for _, shell := range completionShells { + for _, desc := range []bool{true, false} { + got := generatedScript(t, shell, desc) + if strings.Contains(got, "__taiga_debug") || strings.Contains(got, "BASH_COMP_DEBUG_FILE") { + t.Errorf("%s (descriptions %v) still logs to BASH_COMP_DEBUG_FILE", shell, desc) + } + want := withoutDebug(cobraScript(t, shell, desc)) + if g := withoutDebug(got); strings.Join(g, "\n") != strings.Join(want, "\n") { + t.Errorf("%s (descriptions %v) differs from Cobra's beyond the debug log", shell, desc) + } + } + } +} + +// buildTaiga builds the binary the scripts call, as `taiga` in a directory of its own. +func buildTaiga(t *testing.T) string { + t.Helper() + dir := t.TempDir() + cmd := exec.Command("go", "build", "-o", filepath.Join(dir, "taiga"), "../../cmd/taiga") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("go build: %v\n%s", err, out) + } + return dir +} + +// No word is a glob: zsh and fish refuse an unmatched one before calling the binary. +// +// Each harness loads the script, completes `taiga WORDS...` through the shell's own entry +// point and prints the candidates. $SCRIPT is the script, $BIN the binary; WORDS are the +// arguments, the last one being completed (empty after a space). +var completionHarness = map[string]struct { + shell string + args func(harness string) []string + code string +}{ + "bash": {"bash", func(h string) []string { return []string{"--norc", "--noprofile", "-c", h, "bash"} }, ` +ARGS=("$@") +source "$SCRIPT" +_init_completion() { words=("$BIN" "${ARGS[@]}"); cword=$(( ${#words[@]} - 1 )); cur=${words[cword]}; prev=${words[cword-1]}; } +compopt() { :; } +COMP_TYPE=9 +__start_taiga +printf '%s\n' "${COMPREPLY[@]}" +`}, + "zsh": {"zsh", func(h string) []string { return []string{"-f", "-c", h, "zsh"} }, ` +ARGS=("$@") +compdef() { :; } +source "$SCRIPT" +compadd() { print -r -- "compadd $*"; } +_describe() { print -r -- "describe $* :: ${(j:|:)completions}"; return 0; } +_arguments() { print -r -- "arguments $*"; } +words=("$BIN" "${ARGS[@]}") +CURRENT=${#words} +_taiga +`}, + "fish": {"fish", func(h string) []string { return []string{"--no-config", "-c", h, "--"} }, ` +source $SCRIPT +set -l words (string escape -- $argv) +if test -z "$argv[-1]" + set words $words[1..-2] '' +end +complete -C (string join ' ' -- taiga $words) +`}, + "powershell": {"pwsh", func(h string) []string { return []string{"-NoProfile", "-NonInteractive", "-Command", h} }, ` +. $env:SCRIPT +$line = 'taiga ' + ($env:WORDS -split "\n" -join ' ') +(TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches | ForEach-Object { $_.CompletionText } +`}, +} + +// complete runs the harness of shell over script with words and returns the candidates and +// the debug file. +func complete(t *testing.T, shell, bin, script string, words ...string) (out, debug string) { + t.Helper() + h := completionHarness[shell] + dir := t.TempDir() + path, log := filepath.Join(dir, "script"), filepath.Join(dir, "debug.log") + if shell == "powershell" { + path += ".ps1" // dot-sourcing runs only a .ps1 + } + if err := os.WriteFile(path, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + args := h.args(h.code) + if shell != "powershell" { + args = append(args, words...) + } + cmd := exec.Command(h.shell, args...) + cmd.Dir = t.TempDir() // empty: file completion lists neither the script nor the log + cmd.Env = append(os.Environ(), "SCRIPT="+path, "BIN="+filepath.Join(bin, "taiga"), "BASH_COMP_DEBUG_FILE="+log, + "WORDS="+strings.Join(words, "\n"), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "LC_ALL=C.UTF-8") + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("%s %q: %v\n%s%s", shell, words, err, stdout.String(), stderr.String()) + } + b, _ := os.ReadFile(log) + return stdout.String(), string(b) +} + +// With BASH_COMP_DEBUG_FILE set, completing a command line with a token, a control and a bidi +// character leaves only the binary's redacted log in the file; the candidates are those of +// Cobra's own script. A shell that is not installed is skipped. +func TestCompletionScriptsKeepTokensOutOfTheDebugFile(t *testing.T) { + bin := buildTaiga(t) + for _, shell := range completionShells { + t.Run(shell, func(t *testing.T) { + if _, err := exec.LookPath(completionHarness[shell].shell); err != nil { + if shell == "bash" { + t.Fatal("bash is required") + } + t.Skipf("%s is not installed", completionHarness[shell].shell) + } + script := generatedScript(t, shell, true) + _, debug := complete(t, shell, bin, script, "nope", "token=SWEEPSECRET", "a\u202eb", "x\x01y", "") + if strings.Contains(debug, "SWEEPSECRET") || strings.ContainsAny(debug, "\u202e\x01") { + t.Errorf("debug file leaked:\n%q", debug) + } + if !strings.Contains(debug, "[Debug] [Error] unable to find a command for arguments: [nope token=…") { + t.Errorf("the binary's own log is gone:\n%q", debug) + } + for _, words := range [][]string{{"st"}, {"story", ""}, {"story", "l"}, {"story", "list", "--outp"}, {"story", "list", "--"}, {"completion", ""}, {"attachment", "upload", "--t"}} { + got, _ := complete(t, shell, bin, script, words...) + want, _ := complete(t, shell, bin, cobraScript(t, shell, true), words...) + t.Logf("%q: %q", words, got) + if got != want || got == "" { + t.Errorf("%q: candidates %q, Cobra's script gives %q", words, got, want) + } + } + }) + } +} diff --git a/internal/cli/present.go b/internal/cli/present.go index c6661cb..ea97e44 100644 --- a/internal/cli/present.go +++ b/internal/cli/present.go @@ -76,6 +76,13 @@ func (l *lineRedactor) flush() { } } +// writeScript prints a generated completion script on Cobra's stdout, the lineRedactor that +// carried Cobra's own script. +func (a *App) writeScript(cmd *cobra.Command, s string) error { + _, err := io.WriteString(cmd.OutOrStdout(), s) + return err +} + // emitJSON, emitFields, emitOut and emitErr write presented content, without redacting again. func (a *App) emitJSON(v any) error { return output.WriteJSON(a.Out, v) } diff --git a/internal/cli/root.go b/internal/cli/root.go index c4bdfea..553019e 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -85,6 +85,7 @@ func (a *App) root() *cobra.Command { root.AddCommand(a.userCmd()) root.AddCommand(a.milestoneCmd()) root.AddCommand(a.epicCmd()) + a.completionCmd(root) return root } From bba6a3336564202b51e9c4921dc4e7245e12343a Mon Sep 17 00:00:00 2001 From: Cedric Lamalle Date: Sun, 4 Oct 2026 18:33:41 +0000 Subject: [PATCH 4/7] =?UTF-8?q?Corrigir=20achados=20da=20revis=C3=A3o=20da?= =?UTF-8?q?=20rodada=201=20TG-273?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O filtro falha fechado quando a chamada ao escritor divide a linha com outra instrução. O teste estático exige o no-op exato no lugar de cada chamada, os scripts passam pelo parser de cada shell instalado e o harness roda também sem descrições. Docs pedem para gerar de novo um script salvo por versão anterior. --- README.md | 2 +- docs/guia.md | 2 +- internal/cli/completion.go | 12 +++- internal/cli/completion_script_test.go | 96 +++++++++++++++++++++----- 4 files changed, 89 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index a0e131b..538f31e 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ Without a terminal on stdout, `taiga` prints JSON; on a terminal it prints text. Every curated command (all but `taiga api`, which prints Taiga's answer as is) hides the value of token parameters in everything it prints, on stdout and on stderr (errors and warnings), `--dry-run` plans included, keeping the keys and the rest of the URL (only the printout changes: a real write sends the values as given and as stored): user photos (`photo`, `big_photo` in `owner_extra_info`, `assigned_to_extra_info`, the comment author), project logos and attachment links are signed media URLs that open the file without authentication while the token lasts. `story get` shows `"photo": "https://…/media/user/…/photo.png?token=…"`. -Shell completion (`taiga completion bash|zsh|fish|powershell`) never calls Taiga: it completes command and flag names and leaves arguments and flag values to the shell (file names). When it cannot (an unknown command, an unknown flag, a bad flag value), the error it writes to stderr and, with `BASH_COMP_DEBUG_FILE` set, to that file has the typed words with token values hidden and control and bidi characters escaped (`\u202e`); a flag redacted whole (`[redacted: the text carries a credential]`) no longer reads as a flag, so no flag error is printed for it. The scripts that `taiga completion` prints have no debug log of their own (Cobra's scripts append the typed command line, tokens included, to `BASH_COMP_DEBUG_FILE`); with the variable set, the file only gets the binary's redacted errors. Everything else in the scripts, and what they complete, is Cobra's. +Shell completion (`taiga completion bash|zsh|fish|powershell`) never calls Taiga: it completes command and flag names and leaves arguments and flag values to the shell (file names). When it cannot (an unknown command, an unknown flag, a bad flag value), the error it writes to stderr and, with `BASH_COMP_DEBUG_FILE` set, to that file has the typed words with token values hidden and control and bidi characters escaped (`\u202e`); a flag redacted whole (`[redacted: the text carries a credential]`) no longer reads as a flag, so no flag error is printed for it. The scripts that `taiga completion` prints have no debug log of their own (Cobra's scripts append the typed command line, tokens included, to `BASH_COMP_DEBUG_FILE`); with the variable set, the file only gets the binary's redacted errors. Everything else in the scripts, and what they complete, is Cobra's. A script saved by an earlier version still logs the command line: generate it again. | Exit | Meaning | |---|---| diff --git a/docs/guia.md b/docs/guia.md index d30a20c..9db400a 100644 --- a/docs/guia.md +++ b/docs/guia.md @@ -132,7 +132,7 @@ taiga story update 246 --replace-epic 15 --confirm-delete Toda saída dos comandos curados (todos menos `taiga api`, que mostra a resposta do Taiga como veio) esconde o valor dos parâmetros de token em tudo o que imprime, na saída e nos erros e avisos (stderr), inclusive nos planos de `--dry-run`, mantendo as chaves e o resto da URL. Só a impressão muda: a escrita de verdade envia os valores como vieram e como estão guardados. As fotos de usuário (`photo` e `big_photo` em `owner_extra_info`, `assigned_to_extra_info` e no autor dos comentários), o logo do projeto e os links de anexo são URLs assinadas de mídia que abrem o arquivo sem autenticação enquanto o token vale. Em `story get`, a foto sai como `"photo": "https://…/media/user/…?token=…"`. -O completion do shell (`taiga completion bash|zsh|fish|powershell`) nunca chama o Taiga: completa nomes de comandos e flags e deixa argumentos e valores de flag para o shell (nomes de arquivo). Quando não consegue (comando ou flag desconhecido, valor de flag inválido), o erro que escreve no stderr e, com `BASH_COMP_DEBUG_FILE` definida, nesse arquivo traz as palavras digitadas com o valor de token escondido e controles e bidi escapados (`\u202e`); uma flag redigida inteira (`[redacted: the text carries a credential]`) deixa de ser flag, e então nenhum erro de flag sai. Os scripts que `taiga completion` imprime não têm debug próprio (os do Cobra gravam no `BASH_COMP_DEBUG_FILE` a linha digitada, token incluído); com a variável definida, o arquivo só recebe os erros já redigidos do binário. O resto dos scripts, e o que eles completam, é o do Cobra. +O completion do shell (`taiga completion bash|zsh|fish|powershell`) nunca chama o Taiga: completa nomes de comandos e flags e deixa argumentos e valores de flag para o shell (nomes de arquivo). Quando não consegue (comando ou flag desconhecido, valor de flag inválido), o erro que escreve no stderr e, com `BASH_COMP_DEBUG_FILE` definida, nesse arquivo traz as palavras digitadas com o valor de token escondido e controles e bidi escapados (`\u202e`); uma flag redigida inteira (`[redacted: the text carries a credential]`) deixa de ser flag, e então nenhum erro de flag sai. Os scripts que `taiga completion` imprime não têm debug próprio (os do Cobra gravam no `BASH_COMP_DEBUG_FILE` a linha digitada, token incluído); com a variável definida, o arquivo só recebe os erros já redigidos do binário. O resto dos scripts, e o que eles completam, é o do Cobra. Um script salvo por uma versão anterior continua gravando a linha digitada: gere-o de novo. ## Anexos diff --git a/internal/cli/completion.go b/internal/cli/completion.go index a9c07ee..e3acef3 100644 --- a/internal/cli/completion.go +++ b/internal/cli/completion.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "fmt" + "regexp" "strings" "github.com/BasisTI/taiga-cli/internal/output" @@ -103,8 +104,12 @@ func completionScript(root *cobra.Command, shell string, desc bool) (string, err // status stays 0, as the writer's own when the variable is unset. PowerShell needs none. var shellNoop = map[string]string{"bash": ":", "zsh": ":", "fish": "true", "powershell": ""} +// debugArgs is the one double-quoted argument of a call to the debug writer, and nothing after. +var debugArgs = regexp.MustCompile(`^\s+"(?:[^"\\]|\\.)*"$`) + // withoutShellDebug drops the definition of writer (from its first line to the "}" or "end" -// that closes it at the margin) and turns each call, always a line of its own, into noop. +// that closes it at the margin) and turns each call into noop. A call must be a line of its +// own with at most one quoted argument: anything after it would go with the line. // Anything left that names the writer or the variable is an error, never a script. func withoutShellDebug(script, writer, noop string) (string, error) { var b strings.Builder @@ -117,7 +122,10 @@ func withoutShellDebug(script, writer, noop string) (string, error) { inWriter = text != "}" && text != "end" case text == writer+"()" || text == "function "+writer || text == "function "+writer+" {": inWriter = true - case trimmed == writer || strings.HasPrefix(trimmed, writer+" "): + case trimmed == writer || strings.HasPrefix(trimmed, writer+" ") || strings.HasPrefix(trimmed, writer+"\t"): + if trimmed != writer && !debugArgs.MatchString(trimmed[len(writer):]) { + return "", fmt.Errorf("the %s completion script calls %s next to another statement: %q", writer, writer, trimmed) + } if noop != "" { b.WriteString(text[:len(text)-len(strings.TrimLeft(text, " \t"))] + noop + "\n") } diff --git a/internal/cli/completion_script_test.go b/internal/cli/completion_script_test.go index 25a9dc0..676b67c 100644 --- a/internal/cli/completion_script_test.go +++ b/internal/cli/completion_script_test.go @@ -56,32 +56,36 @@ func generatedScript(t *testing.T, shell string, desc bool) string { return out } -var ( - debugCall = regexp.MustCompile(`^\s*__taiga_debug( .*)?$`) - noopLine = regexp.MustCompile(`^\s*(:|true)?$`) -) +var debugCall = regexp.MustCompile(`^(\s*)__taiga_debug( .*)?$`) + +// testNoop is what each call to the debug writer must become: a no-op where a block could end +// up empty, nothing in PowerShell. +var testNoop = map[string]string{"bash": ":", "zsh": ":", "fish": "true", "powershell": ""} -// withoutDebug drops the shell's debug writer, its calls and the lines a no-op or blank, so -// two scripts compare on everything else. -func withoutDebug(script string) []string { +// withoutDebug is Cobra's script without the debug writer, each call turned into the no-op. +func withoutDebug(shell, script string) string { out := []string{} writer := false for _, line := range strings.Split(script, "\n") { + m := debugCall.FindStringSubmatch(line) switch { case line == "__taiga_debug()" || strings.HasPrefix(line, "function __taiga_debug"): writer = true case writer: writer = line != "}" && line != "end" - case debugCall.MatchString(line), noopLine.MatchString(line): + case m != nil: + if testNoop[shell] != "" { + out = append(out, m[1]+testNoop[shell]) + } default: out = append(out, line) } } - return out + return strings.Join(out, "\n") } // The scripts carry no debug log of their own: no writer, no call, no BASH_COMP_DEBUG_FILE. -// Everything else is Cobra's script, line for line. +// Everything else is Cobra's script, line for line, with a no-op where each call was. func TestCompletionScriptsHaveNoShellDebug(t *testing.T) { for _, shell := range completionShells { for _, desc := range []bool{true, false} { @@ -89,14 +93,65 @@ func TestCompletionScriptsHaveNoShellDebug(t *testing.T) { if strings.Contains(got, "__taiga_debug") || strings.Contains(got, "BASH_COMP_DEBUG_FILE") { t.Errorf("%s (descriptions %v) still logs to BASH_COMP_DEBUG_FILE", shell, desc) } - want := withoutDebug(cobraScript(t, shell, desc)) - if g := withoutDebug(got); strings.Join(g, "\n") != strings.Join(want, "\n") { + if want := withoutDebug(shell, cobraScript(t, shell, desc)); got != want { t.Errorf("%s (descriptions %v) differs from Cobra's beyond the debug log", shell, desc) } } } } +// A call that shares its line with another statement, or a writer left open, fails closed: +// dropping the line would drop the statement too. +func TestCompletionScriptFilterFailsClosed(t *testing.T) { + for _, script := range []string{ + "f() {\n __taiga_debug \"x\"; return 3\n}\n", + "f() {\n __taiga_debug \"x\" && return 3\n}\n", + "function f\n __taiga_debug \"y\"; or return 1\nend\n", + "case $a in\n a) __taiga_debug a ;;\nesac\n", + "__taiga_debug()\n{\n echo \"$*\"\n", + "g() { echo $BASH_COMP_DEBUG_FILE; }\n", + } { + if got, err := withoutShellDebug(script, "__taiga_debug", ":"); err == nil { + t.Errorf("%q: no error, got %q", script, got) + } + } + got, err := withoutShellDebug("f() {\n __taiga_debug \"a \\\"b\\\" ${c}\"\n __taiga_debug\n}\n", "__taiga_debug", ":") + if err != nil || got != "f() {\n :\n :\n}\n" { + t.Errorf("got %q, %v", got, err) + } +} + +// parsers check a script without running it. +var parsers = map[string][]string{ + "bash": {"bash", "-n"}, + "zsh": {"zsh", "-n"}, + "fish": {"fish", "--no-execute"}, + "powershell": {"pwsh", "-NoProfile", "-NonInteractive", "-Command", "$e = $null; $null = [System.Management.Automation.Language.Parser]::ParseFile($env:PARSE_FILE, [ref]$null, [ref]$e); if ($e) { $e; exit 1 }"}, +} + +// Each generated script parses in its shell; a shell that is not installed is skipped. +func TestCompletionScriptsParse(t *testing.T) { + for _, shell := range completionShells { + t.Run(shell, func(t *testing.T) { + p := parsers[shell] + if _, err := exec.LookPath(p[0]); err != nil { + t.Skipf("%s is not installed", p[0]) + } + for _, desc := range []bool{true, false} { + path := filepath.Join(t.TempDir(), "script.ps1") + if err := os.WriteFile(path, []byte(generatedScript(t, shell, desc)), 0o600); err != nil { + t.Fatal(err) + } + cmd := exec.Command(p[0], append(p[1:], path)...) // pwsh -Command ignores the path and reads PARSE_FILE + cmd.Env = append(os.Environ(), "PARSE_FILE="+path) + if out, err := cmd.CombinedOutput(); err != nil { + t.Errorf("descriptions %v: %v\n%s", desc, err, out) + } + } + }) + } +} + // buildTaiga builds the binary the scripts call, as `taiga` in a directory of its own. func buildTaiga(t *testing.T) string { t.Helper() @@ -196,14 +251,17 @@ func TestCompletionScriptsKeepTokensOutOfTheDebugFile(t *testing.T) { } t.Skipf("%s is not installed", completionHarness[shell].shell) } - script := generatedScript(t, shell, true) - _, debug := complete(t, shell, bin, script, "nope", "token=SWEEPSECRET", "a\u202eb", "x\x01y", "") - if strings.Contains(debug, "SWEEPSECRET") || strings.ContainsAny(debug, "\u202e\x01") { - t.Errorf("debug file leaked:\n%q", debug) - } - if !strings.Contains(debug, "[Debug] [Error] unable to find a command for arguments: [nope token=…") { - t.Errorf("the binary's own log is gone:\n%q", debug) + for _, desc := range []bool{true, false} { + script := generatedScript(t, shell, desc) + _, debug := complete(t, shell, bin, script, "nope", "token=SWEEPSECRET", "a\u202eb", "x\x01y", "") + if strings.Contains(debug, "SWEEPSECRET") || strings.ContainsAny(debug, "\u202e\x01") { + t.Errorf("descriptions %v: debug file leaked:\n%q", desc, debug) + } + if !strings.Contains(debug, "[Debug] [Error] unable to find a command for arguments: [nope token=…") { + t.Errorf("descriptions %v: the binary's own log is gone:\n%q", desc, debug) + } } + script := generatedScript(t, shell, true) for _, words := range [][]string{{"st"}, {"story", ""}, {"story", "l"}, {"story", "list", "--outp"}, {"story", "list", "--"}, {"completion", ""}, {"attachment", "upload", "--t"}} { got, _ := complete(t, shell, bin, script, words...) want, _ := complete(t, shell, bin, cobraScript(t, shell, true), words...) From ecf41bda81f4e1a190ca1d86fd051c3dc5cccd54 Mon Sep 17 00:00:00 2001 From: Cedric Lamalle Date: Sun, 4 Oct 2026 18:52:25 +0000 Subject: [PATCH 5/7] Recusar escritor de debug sem fechamento na margem TG-273 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Um fechamento indentado do escritor fazia o filtro seguir até o fim da função seguinte e apagá-la sem erro. Agora qualquer linha na margem dentro do escritor, fora o fechamento, recusa a geração. Os candidatos são comparados com e sem descrições, e a CI instala zsh e fish e exige os quatro shells no teste dos scripts. --- .github/workflows/ci.yml | 6 ++- internal/cli/completion.go | 15 ++++++-- internal/cli/completion_script_test.go | 53 ++++++++++++++++++-------- 3 files changed, 53 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 834b428..ebc40ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,9 +14,13 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod - - run: sudo apt-get update && sudo apt-get install -y dbus + - run: sudo apt-get update && sudo apt-get install -y dbus zsh fish - run: go vet ./... - run: go test -race ./... + # The completion scripts run in each shell; a missing one fails instead of skipping. + - run: go test -v -count=1 -run '^TestCompletionScripts' ./internal/cli + env: + TAIGA_TEST_SHELLS: bash,zsh,fish,pwsh - uses: golangci/golangci-lint-action@v8 with: version: v2.14.0 diff --git a/internal/cli/completion.go b/internal/cli/completion.go index e3acef3..390e8b5 100644 --- a/internal/cli/completion.go +++ b/internal/cli/completion.go @@ -107,10 +107,12 @@ var shellNoop = map[string]string{"bash": ":", "zsh": ":", "fish": "true", "powe // debugArgs is the one double-quoted argument of a call to the debug writer, and nothing after. var debugArgs = regexp.MustCompile(`^\s+"(?:[^"\\]|\\.)*"$`) -// withoutShellDebug drops the definition of writer (from its first line to the "}" or "end" -// that closes it at the margin) and turns each call into noop. A call must be a line of its -// own with at most one quoted argument: anything after it would go with the line. -// Anything left that names the writer or the variable is an error, never a script. +// withoutShellDebug drops the definition of writer, from its first line to the "}" or "end" +// that closes it at the margin, and turns each call into noop. The body in between is +// indented: any other line at the margin is refused, so a writer closed otherwise never takes +// the next function with it. A call must be a line of its own with at most one quoted +// argument: anything after it would go with the line. Anything left that names the writer or +// the variable is an error, never a script. func withoutShellDebug(script, writer, noop string) (string, error) { var b strings.Builder inWriter := false @@ -119,6 +121,11 @@ func withoutShellDebug(script, writer, noop string) (string, error) { trimmed := strings.TrimSpace(text) switch { case inWriter: + // The body is indented: a line at the margin closes the writer or is not its own. + margin := text != "" && text == strings.TrimLeft(text, " \t") + if margin && text != "{" && text != "}" && text != "end" { + return "", fmt.Errorf("the %s writer of the completion script has no closing line at the margin", writer) + } inWriter = text != "}" && text != "end" case text == writer+"()" || text == "function "+writer || text == "function "+writer+" {": inWriter = true diff --git a/internal/cli/completion_script_test.go b/internal/cli/completion_script_test.go index 676b67c..6f5c86a 100644 --- a/internal/cli/completion_script_test.go +++ b/internal/cli/completion_script_test.go @@ -115,12 +115,38 @@ func TestCompletionScriptFilterFailsClosed(t *testing.T) { t.Errorf("%q: no error, got %q", script, got) } } + // A writer closed with an indent (a change of Cobra's format) is refused, not run on into + // the next function. + for _, m := range []struct{ shell, from, to string }{ + {"bash", "\n}\n", "\n }\n"}, {"zsh", "\n}\n", "\n }\n"}, {"fish", "\nend\n", "\n end\n"}, {"powershell", "\n}\n", "\n }\n"}, + } { + script := strings.Replace(cobraScript(t, m.shell, true), m.from, m.to, 1) + if got, err := withoutShellDebug(script, "__taiga_debug", testNoop[m.shell]); err == nil { + t.Errorf("%s with the writer closed by %q: no error, got %d bytes", m.shell, m.to, len(got)) + } + } got, err := withoutShellDebug("f() {\n __taiga_debug \"a \\\"b\\\" ${c}\"\n __taiga_debug\n}\n", "__taiga_debug", ":") if err != nil || got != "f() {\n :\n :\n}\n" { t.Errorf("got %q, %v", got, err) } } +// requireShell skips the test when exe is not installed, unless exe is bash or listed in +// TAIGA_TEST_SHELLS (comma-separated, as the CI sets it): then it fails, so no shell goes +// untested in silence. +func requireShell(t *testing.T, exe string) { + t.Helper() + if _, err := exec.LookPath(exe); err == nil { + return + } + for _, want := range append([]string{"bash"}, strings.Split(os.Getenv("TAIGA_TEST_SHELLS"), ",")...) { + if strings.TrimSpace(want) == exe { + t.Fatalf("%s is required and not installed", exe) + } + } + t.Skipf("%s is not installed", exe) +} + // parsers check a script without running it. var parsers = map[string][]string{ "bash": {"bash", "-n"}, @@ -134,9 +160,7 @@ func TestCompletionScriptsParse(t *testing.T) { for _, shell := range completionShells { t.Run(shell, func(t *testing.T) { p := parsers[shell] - if _, err := exec.LookPath(p[0]); err != nil { - t.Skipf("%s is not installed", p[0]) - } + requireShell(t, p[0]) for _, desc := range []bool{true, false} { path := filepath.Join(t.TempDir(), "script.ps1") if err := os.WriteFile(path, []byte(generatedScript(t, shell, desc)), 0o600); err != nil { @@ -245,12 +269,7 @@ func TestCompletionScriptsKeepTokensOutOfTheDebugFile(t *testing.T) { bin := buildTaiga(t) for _, shell := range completionShells { t.Run(shell, func(t *testing.T) { - if _, err := exec.LookPath(completionHarness[shell].shell); err != nil { - if shell == "bash" { - t.Fatal("bash is required") - } - t.Skipf("%s is not installed", completionHarness[shell].shell) - } + requireShell(t, completionHarness[shell].shell) for _, desc := range []bool{true, false} { script := generatedScript(t, shell, desc) _, debug := complete(t, shell, bin, script, "nope", "token=SWEEPSECRET", "a\u202eb", "x\x01y", "") @@ -261,13 +280,15 @@ func TestCompletionScriptsKeepTokensOutOfTheDebugFile(t *testing.T) { t.Errorf("descriptions %v: the binary's own log is gone:\n%q", desc, debug) } } - script := generatedScript(t, shell, true) - for _, words := range [][]string{{"st"}, {"story", ""}, {"story", "l"}, {"story", "list", "--outp"}, {"story", "list", "--"}, {"completion", ""}, {"attachment", "upload", "--t"}} { - got, _ := complete(t, shell, bin, script, words...) - want, _ := complete(t, shell, bin, cobraScript(t, shell, true), words...) - t.Logf("%q: %q", words, got) - if got != want || got == "" { - t.Errorf("%q: candidates %q, Cobra's script gives %q", words, got, want) + for _, desc := range []bool{true, false} { + script, cobra := generatedScript(t, shell, desc), cobraScript(t, shell, desc) + for _, words := range [][]string{{"st"}, {"story", ""}, {"story", "l"}, {"story", "list", "--outp"}, {"story", "list", "--"}, {"completion", ""}, {"attachment", "upload", "--t"}} { + got, _ := complete(t, shell, bin, script, words...) + want, _ := complete(t, shell, bin, cobra, words...) + t.Logf("descriptions %v, %q: %q", desc, words, got) + if got != want || got == "" { + t.Errorf("descriptions %v, %q: candidates %q, Cobra's script gives %q", desc, words, got, want) + } } } }) From 96ec12ac2594ac2a21b0cf206c9e70b271a17b8d Mon Sep 17 00:00:00 2001 From: Cedric Lamalle Date: Sun, 4 Oct 2026 19:09:59 +0000 Subject: [PATCH 6/7] =?UTF-8?q?Conferir=20a=20sa=C3=ADda=20do=20filtro=20d?= =?UTF-8?q?e=20completion=20contra=20o=20script=20do=20Cobra=20TG-273?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O corte do debug segue o texto e uma mudança de formato do Cobra podia fazê-lo levar a função seguinte. Agora a saída é conferida sem depender do formato: toda função declarada, fora o escritor, continua declarada, e só mudam o trecho do escritor e as chamadas trocadas por no-op. Qualquer outra diferença recusa a geração. --- internal/cli/completion.go | 106 +++++++++++++++++++++++-- internal/cli/completion_script_test.go | 81 +++++++++++++++++-- 2 files changed, 173 insertions(+), 14 deletions(-) diff --git a/internal/cli/completion.go b/internal/cli/completion.go index 390e8b5..8136136 100644 --- a/internal/cli/completion.go +++ b/internal/cli/completion.go @@ -107,13 +107,28 @@ var shellNoop = map[string]string{"bash": ":", "zsh": ":", "fish": "true", "powe // debugArgs is the one double-quoted argument of a call to the debug writer, and nothing after. var debugArgs = regexp.MustCompile(`^\s+"(?:[^"\\]|\\.)*"$`) -// withoutShellDebug drops the definition of writer, from its first line to the "}" or "end" -// that closes it at the margin, and turns each call into noop. The body in between is -// indented: any other line at the margin is refused, so a writer closed otherwise never takes -// the next function with it. A call must be a line of its own with at most one quoted -// argument: anything after it would go with the line. Anything left that names the writer or -// the variable is an error, never a script. +// withoutShellDebug is script without the debug writer and with each call to it turned into +// noop, or an error and no script. Cutting goes by the text (dropShellDebug), which a change +// of Cobra's format could fool; what comes out is then checked against script without +// relying on that format (onlyDebugDropped): every function but the writer is still +// declared, and nothing else changed. A script that fails the check is refused. func withoutShellDebug(script, writer, noop string) (string, error) { + out, err := dropShellDebug(script, writer, noop) + if err == nil { + err = onlyDebugDropped(script, out, writer, noop) + } + if err != nil { + return "", err + } + return out, nil +} + +// dropShellDebug drops the definition of writer, from its first line to the "}" or "end" +// that closes it at the margin, and turns each call into noop. A line at the margin inside +// the writer (other than its braces) is refused; a call must be a line of its own with at +// most one quoted argument, since anything after it would go with the line. Anything left +// that names the writer or the variable is an error. +func dropShellDebug(script, writer, noop string) (string, error) { var b strings.Builder inWriter := false for _, line := range strings.SplitAfter(script, "\n") { @@ -146,3 +161,82 @@ func withoutShellDebug(script, writer, noop string) (string, error) { } return out, nil } + +// declaration finds a function declared on a line, in any of the four scripts and at any +// indent: "name()" and "function name" (bash, zsh), "function name" (fish), "function name", +// "filter name" and "[scriptblock]${name} =" (PowerShell). +var declaration = regexp.MustCompile(`^\s*(?:(?:function|filter)\s+([^\s(){}]+)|([A-Za-z_][\w:.-]*)\s*\(\)|\[scriptblock\]\$\{([^}]+)\}\s*=)`) + +// declared is the function a line declares, or "". +func declared(line string) string { + m := declaration.FindStringSubmatch(line) + if m == nil { + return "" + } + return m[1] + m[2] + m[3] +} + +// onlyDebugDropped checks out against script, line by line: each line is kept as is, or is a +// call to writer that became noop (dropped when noop is ""), or belongs to one run of lines +// that starts where writer is declared, ends on "}" or "end" and declares nothing else. It +// also checks that every function script declares, writer aside, is still declared in out. +func onlyDebugDropped(script, out, writer, noop string) error { + fail := func(format string, args ...any) error { + return fmt.Errorf("the completion script changed beyond its debug log: "+format, args...) + } + count := func(text string) map[string]int { + names := map[string]int{} + for _, line := range strings.Split(text, "\n") { + if name := declared(line); name != "" { + names[name]++ + } + } + return names + } + want, got := count(script), count(out) + delete(want, writer) + for name, n := range want { + if got[name] != n { + return fail("%s is declared %d times, %d before", name, got[name], n) + } + } + if len(got) != len(want) { + return fail("it declares %d functions, %d before", len(got), len(want)) + } + o, f := strings.Split(script, "\n"), strings.Split(out, "\n") + i, j, runs := 0, 0, 0 + for i < len(o) { + trimmed := strings.TrimSpace(o[i]) + switch { + case j < len(f) && o[i] == f[j]: + i, j = i+1, j+1 + case trimmed == writer || strings.HasPrefix(trimmed, writer+" ") || strings.HasPrefix(trimmed, writer+"\t"): + if noop != "" { + if j >= len(f) || f[j] != o[i][:len(o[i])-len(strings.TrimLeft(o[i], " \t"))]+noop { + return fail("line %d: a call to %s did not become %q", i+1, writer, noop) + } + j++ + } + i++ + case declared(o[i]) == writer && runs == 0: + runs++ + k := i + 1 + for k < len(o) && (j >= len(f) || o[k] != f[j]) { + if name := declared(o[k]); name != "" { + return fail("line %d: %s went with the writer", k+1, name) + } + k++ + } + if last := strings.TrimSpace(o[k-1]); last != "}" && last != "end" { + return fail("line %d: the writer does not end on a closing line", k) + } + i = k + default: + return fail("line %d (%q) is missing", i+1, o[i]) + } + } + if j != len(f) { + return fail("%d lines were added", len(f)-j) + } + return nil +} diff --git a/internal/cli/completion_script_test.go b/internal/cli/completion_script_test.go index 6f5c86a..7dec39c 100644 --- a/internal/cli/completion_script_test.go +++ b/internal/cli/completion_script_test.go @@ -115,14 +115,22 @@ func TestCompletionScriptFilterFailsClosed(t *testing.T) { t.Errorf("%q: no error, got %q", script, got) } } - // A writer closed with an indent (a change of Cobra's format) is refused, not run on into - // the next function. - for _, m := range []struct{ shell, from, to string }{ - {"bash", "\n}\n", "\n }\n"}, {"zsh", "\n}\n", "\n }\n"}, {"fish", "\nend\n", "\n end\n"}, {"powershell", "\n}\n", "\n }\n"}, - } { - script := strings.Replace(cobraScript(t, m.shell, true), m.from, m.to, 1) - if got, err := withoutShellDebug(script, "__taiga_debug", testNoop[m.shell]); err == nil { - t.Errorf("%s with the writer closed by %q: no error, got %d bytes", m.shell, m.to, len(got)) + // Changes of Cobra's format that would make the filter take the next function with the + // writer are refused, with no script: the writer closed with an indent (round 2), and also + // every line up to the next function's closing one, its declaration included (round 3). + for _, shell := range completionShells { + end := "\n}\n" + if shell == "fish" { + end = "\nend\n" + } + cobra := cobraScript(t, shell, true) + for name, script := range map[string]string{ + "writer closed with an indent": strings.Replace(cobra, end, "\n "+end[1:], 1), + "next function indented as well": indentFollowing(t, cobra, end), + } { + if got, err := withoutShellDebug(script, "__taiga_debug", testNoop[shell]); err == nil || got != "" { + t.Errorf("%s, %s: error %v, got %d bytes", shell, name, err, len(got)) + } } } got, err := withoutShellDebug("f() {\n __taiga_debug \"a \\\"b\\\" ${c}\"\n __taiga_debug\n}\n", "__taiga_debug", ":") @@ -147,6 +155,63 @@ func requireShell(t *testing.T, exe string) { t.Skipf("%s is not installed", exe) } +// The check on the output holds for the real scripts and refuses any other change: a +// function gone, a line gone or added, a call left without its no-op. +func TestCompletionScriptCheckRefusesOtherChanges(t *testing.T) { + for _, shell := range completionShells { + cobra, out := cobraScript(t, shell, true), generatedScript(t, shell, true) + if err := onlyDebugDropped(cobra, out, "__taiga_debug", testNoop[shell]); err != nil { + t.Fatalf("%s: %v", shell, err) + } + lines := strings.Split(out, "\n") + without := func(i int) string { + return strings.Join(append(append([]string{}, lines[:i]...), lines[i+1:]...), "\n") + } + mutations := map[string]string{"a line added": out + "echo x\n"} + for i, line := range lines { + if name := declared(line); name != "" && mutations["a declaration gone"] == "" { + mutations["a declaration gone"] = without(i) + } + if strings.TrimSpace(line) == "" { + continue + } + if strings.TrimSpace(line) == testNoop[shell] && mutations["a no-op gone"] == "" { + mutations["a no-op gone"] = without(i) + } else if declared(line) == "" { + mutations["a line gone"] = without(i) // the last plain line + } + } + for name, mutated := range mutations { + if err := onlyDebugDropped(cobra, mutated, "__taiga_debug", testNoop[shell]); err == nil { + t.Errorf("%s, %s: accepted", shell, name) + } + } + } +} + +// indentFollowing indents the writer's closing line (the first end at the margin) and every +// line after it up to the next one, which closes the next function and stays at the margin. +func indentFollowing(t *testing.T, script, end string) string { + t.Helper() + start := strings.Index(script, end) + 1 + stop := start + len(end) - 1 + next := strings.Index(script[stop:], end) + if start == 0 || next < 0 { + t.Fatalf("no closing line %q", end) + } + next += stop + 1 + var b strings.Builder + b.WriteString(script[:start]) + for _, line := range strings.SplitAfter(script[start:next], "\n") { + if strings.TrimSpace(line) != "" { + b.WriteString(" ") + } + b.WriteString(line) + } + b.WriteString(script[next:]) + return b.String() +} + // parsers check a script without running it. var parsers = map[string][]string{ "bash": {"bash", "-n"}, From 8eb826f12e741fec96299a5e8f731ed4f6613c01 Mon Sep 17 00:00:00 2001 From: Cedric Lamalle Date: Sun, 4 Oct 2026 19:36:07 +0000 Subject: [PATCH 7/7] =?UTF-8?q?Silenciar=20o=20debug=20do=20shell=20por=20?= =?UTF-8?q?redefini=C3=A7=C3=A3o=20em=20vez=20de=20filtro=20TG-273?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O filtro textual voltava a consumir código quando o formato do Cobra mudava. O comando completion agora só acrescenta ao script do Cobra uma redefinição do escritor de debug que não faz nada, logo depois da definição dele e antes de qualquer completion (o zsh por autoload roda o arquivo no primeiro uso). Sem a declaração do escritor, a geração é recusada. Saem o filtro, a invariante, a guarda de margem e os testes deles; entram o teste do zsh por fpath e o do status do escritor. --- README.md | 2 +- docs/guia.md | 2 +- internal/cli/completion.go | 165 +++++-------------- internal/cli/completion_script_test.go | 219 ++++++++++--------------- 4 files changed, 130 insertions(+), 258 deletions(-) diff --git a/README.md b/README.md index 538f31e..436b267 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ Without a terminal on stdout, `taiga` prints JSON; on a terminal it prints text. Every curated command (all but `taiga api`, which prints Taiga's answer as is) hides the value of token parameters in everything it prints, on stdout and on stderr (errors and warnings), `--dry-run` plans included, keeping the keys and the rest of the URL (only the printout changes: a real write sends the values as given and as stored): user photos (`photo`, `big_photo` in `owner_extra_info`, `assigned_to_extra_info`, the comment author), project logos and attachment links are signed media URLs that open the file without authentication while the token lasts. `story get` shows `"photo": "https://…/media/user/…/photo.png?token=…"`. -Shell completion (`taiga completion bash|zsh|fish|powershell`) never calls Taiga: it completes command and flag names and leaves arguments and flag values to the shell (file names). When it cannot (an unknown command, an unknown flag, a bad flag value), the error it writes to stderr and, with `BASH_COMP_DEBUG_FILE` set, to that file has the typed words with token values hidden and control and bidi characters escaped (`\u202e`); a flag redacted whole (`[redacted: the text carries a credential]`) no longer reads as a flag, so no flag error is printed for it. The scripts that `taiga completion` prints have no debug log of their own (Cobra's scripts append the typed command line, tokens included, to `BASH_COMP_DEBUG_FILE`); with the variable set, the file only gets the binary's redacted errors. Everything else in the scripts, and what they complete, is Cobra's. A script saved by an earlier version still logs the command line: generate it again. +Shell completion (`taiga completion bash|zsh|fish|powershell`) never calls Taiga: it completes command and flag names and leaves arguments and flag values to the shell (file names). When it cannot (an unknown command, an unknown flag, a bad flag value), the error it writes to stderr and, with `BASH_COMP_DEBUG_FILE` set, to that file has the typed words with token values hidden and control and bidi characters escaped (`\u202e`); a flag redacted whole (`[redacted: the text carries a credential]`) no longer reads as a flag, so no flag error is printed for it. The scripts that `taiga completion` prints are Cobra's, with one block added: Cobra's debug writer (`__taiga_debug`, which appends the typed command line, tokens included, to `BASH_COMP_DEBUG_FILE`) is redefined, before any completion runs, to do nothing. With the variable set, the file only gets the binary's redacted errors; what the scripts complete is Cobra's. A script saved by an earlier version still logs the command line: generate it again. | Exit | Meaning | |---|---| diff --git a/docs/guia.md b/docs/guia.md index 9db400a..d49879c 100644 --- a/docs/guia.md +++ b/docs/guia.md @@ -132,7 +132,7 @@ taiga story update 246 --replace-epic 15 --confirm-delete Toda saída dos comandos curados (todos menos `taiga api`, que mostra a resposta do Taiga como veio) esconde o valor dos parâmetros de token em tudo o que imprime, na saída e nos erros e avisos (stderr), inclusive nos planos de `--dry-run`, mantendo as chaves e o resto da URL. Só a impressão muda: a escrita de verdade envia os valores como vieram e como estão guardados. As fotos de usuário (`photo` e `big_photo` em `owner_extra_info`, `assigned_to_extra_info` e no autor dos comentários), o logo do projeto e os links de anexo são URLs assinadas de mídia que abrem o arquivo sem autenticação enquanto o token vale. Em `story get`, a foto sai como `"photo": "https://…/media/user/…?token=…"`. -O completion do shell (`taiga completion bash|zsh|fish|powershell`) nunca chama o Taiga: completa nomes de comandos e flags e deixa argumentos e valores de flag para o shell (nomes de arquivo). Quando não consegue (comando ou flag desconhecido, valor de flag inválido), o erro que escreve no stderr e, com `BASH_COMP_DEBUG_FILE` definida, nesse arquivo traz as palavras digitadas com o valor de token escondido e controles e bidi escapados (`\u202e`); uma flag redigida inteira (`[redacted: the text carries a credential]`) deixa de ser flag, e então nenhum erro de flag sai. Os scripts que `taiga completion` imprime não têm debug próprio (os do Cobra gravam no `BASH_COMP_DEBUG_FILE` a linha digitada, token incluído); com a variável definida, o arquivo só recebe os erros já redigidos do binário. O resto dos scripts, e o que eles completam, é o do Cobra. Um script salvo por uma versão anterior continua gravando a linha digitada: gere-o de novo. +O completion do shell (`taiga completion bash|zsh|fish|powershell`) nunca chama o Taiga: completa nomes de comandos e flags e deixa argumentos e valores de flag para o shell (nomes de arquivo). Quando não consegue (comando ou flag desconhecido, valor de flag inválido), o erro que escreve no stderr e, com `BASH_COMP_DEBUG_FILE` definida, nesse arquivo traz as palavras digitadas com o valor de token escondido e controles e bidi escapados (`\u202e`); uma flag redigida inteira (`[redacted: the text carries a credential]`) deixa de ser flag, e então nenhum erro de flag sai. Os scripts que `taiga completion` imprime são os do Cobra com um bloco a mais: o escritor de debug do Cobra (`__taiga_debug`, que grava no `BASH_COMP_DEBUG_FILE` a linha digitada, token incluído) é redefinido, antes de qualquer completion, para não fazer nada. Com a variável definida, o arquivo só recebe os erros já redigidos do binário; o que os scripts completam é o do Cobra. Um script salvo por uma versão anterior continua gravando a linha digitada: gere-o de novo. ## Anexos diff --git a/internal/cli/completion.go b/internal/cli/completion.go index 8136136..2143d97 100644 --- a/internal/cli/completion.go +++ b/internal/cli/completion.go @@ -47,7 +47,7 @@ func completionArg(s string) string { } // completionCmd gives Cobra's `completion bash|zsh|fish|powershell` (its help and flags kept) -// scripts without the shell's own debug log. That log appends the command line as typed, +// scripts whose own debug log writes nothing. That log appends the command line as typed, // tokens included, to $BASH_COMP_DEBUG_FILE; the binary's log there stays, redacted by // completeRedacted. func (a *App) completionCmd(root *cobra.Command) { @@ -70,7 +70,7 @@ func (a *App) completionCmd(root *cobra.Command) { } } -// completionScript is Cobra's script for shell without its debug writer and the calls to it. +// completionScript is Cobra's script for shell with its debug writer redefined to do nothing. func completionScript(root *cobra.Command, shell string, desc bool) (string, error) { var b bytes.Buffer var err error @@ -97,75 +97,17 @@ func completionScript(root *cobra.Command, shell string, desc bool) (string, err if err != nil { return "", err } - return withoutShellDebug(b.String(), "__"+root.Name()+"_debug", shellNoop[shell]) + return withQuietDebug(b.String(), "__"+root.Name()+"_debug", shell) } -// shellNoop replaces each call to the debug writer, so a block never ends up empty and the -// status stays 0, as the writer's own when the variable is unset. PowerShell needs none. -var shellNoop = map[string]string{"bash": ":", "zsh": ":", "fish": "true", "powershell": ""} +// quietWriter redefines the debug writer (%s) as a function that does nothing and returns 0, +// as the writer itself does when the variable is unset. +var quietWriter = map[string]string{"bash": "%s() { :; }", "zsh": "%s() { :; }", "fish": "function %s; end", "powershell": "function %s { }"} -// debugArgs is the one double-quoted argument of a call to the debug writer, and nothing after. -var debugArgs = regexp.MustCompile(`^\s+"(?:[^"\\]|\\.)*"$`) - -// withoutShellDebug is script without the debug writer and with each call to it turned into -// noop, or an error and no script. Cutting goes by the text (dropShellDebug), which a change -// of Cobra's format could fool; what comes out is then checked against script without -// relying on that format (onlyDebugDropped): every function but the writer is still -// declared, and nothing else changed. A script that fails the check is refused. -func withoutShellDebug(script, writer, noop string) (string, error) { - out, err := dropShellDebug(script, writer, noop) - if err == nil { - err = onlyDebugDropped(script, out, writer, noop) - } - if err != nil { - return "", err - } - return out, nil -} - -// dropShellDebug drops the definition of writer, from its first line to the "}" or "end" -// that closes it at the margin, and turns each call into noop. A line at the margin inside -// the writer (other than its braces) is refused; a call must be a line of its own with at -// most one quoted argument, since anything after it would go with the line. Anything left -// that names the writer or the variable is an error. -func dropShellDebug(script, writer, noop string) (string, error) { - var b strings.Builder - inWriter := false - for _, line := range strings.SplitAfter(script, "\n") { - text := strings.TrimRight(line, "\n") - trimmed := strings.TrimSpace(text) - switch { - case inWriter: - // The body is indented: a line at the margin closes the writer or is not its own. - margin := text != "" && text == strings.TrimLeft(text, " \t") - if margin && text != "{" && text != "}" && text != "end" { - return "", fmt.Errorf("the %s writer of the completion script has no closing line at the margin", writer) - } - inWriter = text != "}" && text != "end" - case text == writer+"()" || text == "function "+writer || text == "function "+writer+" {": - inWriter = true - case trimmed == writer || strings.HasPrefix(trimmed, writer+" ") || strings.HasPrefix(trimmed, writer+"\t"): - if trimmed != writer && !debugArgs.MatchString(trimmed[len(writer):]) { - return "", fmt.Errorf("the %s completion script calls %s next to another statement: %q", writer, writer, trimmed) - } - if noop != "" { - b.WriteString(text[:len(text)-len(strings.TrimLeft(text, " \t"))] + noop + "\n") - } - default: - b.WriteString(line) - } - } - out := b.String() - if inWriter || strings.Contains(out, writer) || strings.Contains(out, "BASH_COMP_DEBUG_FILE") { - return "", fmt.Errorf("the %s completion script still logs to BASH_COMP_DEBUG_FILE", writer) - } - return out, nil -} - -// declaration finds a function declared on a line, in any of the four scripts and at any -// indent: "name()" and "function name" (bash, zsh), "function name" (fish), "function name", -// "filter name" and "[scriptblock]${name} =" (PowerShell). -var declaration = regexp.MustCompile(`^\s*(?:(?:function|filter)\s+([^\s(){}]+)|([A-Za-z_][\w:.-]*)\s*\(\)|\[scriptblock\]\$\{([^}]+)\}\s*=)`) +// declaration finds a function declared on a line, at any indent: "name()" and "function +// name" (bash, zsh), "function name" (fish), "function name", "filter name" and +// "[scriptblock]${name} =" (PowerShell). +var declaration = regexp.MustCompile(`^\s*(?:(?:function|filter)\s+([^\s(){};]+)|([A-Za-z_][\w:.-]*)\s*\(\)|\[scriptblock\]\$\{([^}]+)\}\s*=)`) // declared is the function a line declares, or "". func declared(line string) string { @@ -176,67 +118,38 @@ func declared(line string) string { return m[1] + m[2] + m[3] } -// onlyDebugDropped checks out against script, line by line: each line is kept as is, or is a -// call to writer that became noop (dropped when noop is ""), or belongs to one run of lines -// that starts where writer is declared, ends on "}" or "end" and declares nothing else. It -// also checks that every function script declares, writer aside, is still declared in out. -func onlyDebugDropped(script, out, writer, noop string) error { - fail := func(format string, args ...any) error { - return fmt.Errorf("the completion script changed beyond its debug log: "+format, args...) - } - count := func(text string) map[string]int { - names := map[string]int{} - for _, line := range strings.Split(text, "\n") { - if name := declared(line); name != "" { - names[name]++ - } +// withQuietDebug is script with writer redefined to do nothing, or an error and no script. +// Nothing of Cobra's script is removed or changed: the redefinition is added before the first +// function declared at the margin after writer's (last) declaration, above the comment lines +// that go with that function. Writer's body has ended there, and nothing has run yet: zsh +// autoloaded from fpath runs the file on first use and calls the completion at its end, and +// fish completes once while loading, so the end of the script would be too late. Every call +// then goes to the redefinition, PowerShell's included (same scope as the script's own +// functions). Without writer's declaration, or with no declaration after it, the script is +// refused. +func withQuietDebug(script, writer, shell string) (string, error) { + lines := strings.SplitAfter(script, "\n") + last := -1 + for i, line := range lines { + if declared(strings.TrimRight(line, "\n")) == writer { + last = i } - return names } - want, got := count(script), count(out) - delete(want, writer) - for name, n := range want { - if got[name] != n { - return fail("%s is declared %d times, %d before", name, got[name], n) - } + if last < 0 { + return "", fmt.Errorf("the %s completion script does not declare %s", shell, writer) } - if len(got) != len(want) { - return fail("it declares %d functions, %d before", len(got), len(want)) - } - o, f := strings.Split(script, "\n"), strings.Split(out, "\n") - i, j, runs := 0, 0, 0 - for i < len(o) { - trimmed := strings.TrimSpace(o[i]) - switch { - case j < len(f) && o[i] == f[j]: - i, j = i+1, j+1 - case trimmed == writer || strings.HasPrefix(trimmed, writer+" ") || strings.HasPrefix(trimmed, writer+"\t"): - if noop != "" { - if j >= len(f) || f[j] != o[i][:len(o[i])-len(strings.TrimLeft(o[i], " \t"))]+noop { - return fail("line %d: a call to %s did not become %q", i+1, writer, noop) - } - j++ - } - i++ - case declared(o[i]) == writer && runs == 0: - runs++ - k := i + 1 - for k < len(o) && (j >= len(f) || o[k] != f[j]) { - if name := declared(o[k]); name != "" { - return fail("line %d: %s went with the writer", k+1, name) - } - k++ - } - if last := strings.TrimSpace(o[k-1]); last != "}" && last != "end" { - return fail("line %d: the writer does not end on a closing line", k) - } - i = k - default: - return fail("line %d (%q) is missing", i+1, o[i]) + for i := last + 1; i < len(lines); i++ { + text := strings.TrimRight(lines[i], "\n") + if declared(text) == "" || text != strings.TrimLeft(text, " \t") { + continue } + for i > last+1 && strings.HasPrefix(lines[i-1], "#") { + i-- // above the comment that goes with that function + } + quiet := "# taiga: " + writer + " above appends the command line as typed, tokens included, to\n" + + "# $BASH_COMP_DEBUG_FILE. Redefined here, before any completion runs, it writes nothing.\n" + + fmt.Sprintf(quietWriter[shell], writer) + "\n\n" + return strings.Join(lines[:i], "") + quiet + strings.Join(lines[i:], ""), nil } - if j != len(f) { - return fail("%d lines were added", len(f)-j) - } - return nil + return "", fmt.Errorf("the %s completion script declares nothing after %s", shell, writer) } diff --git a/internal/cli/completion_script_test.go b/internal/cli/completion_script_test.go index 7dec39c..7ef84d3 100644 --- a/internal/cli/completion_script_test.go +++ b/internal/cli/completion_script_test.go @@ -5,7 +5,6 @@ import ( "os" "os/exec" "path/filepath" - "regexp" "strings" "testing" ) @@ -56,86 +55,80 @@ func generatedScript(t *testing.T, shell string, desc bool) string { return out } -var debugCall = regexp.MustCompile(`^(\s*)__taiga_debug( .*)?$`) - -// testNoop is what each call to the debug writer must become: a no-op where a block could end -// up empty, nothing in PowerShell. -var testNoop = map[string]string{"bash": ":", "zsh": ":", "fish": "true", "powershell": ""} - -// withoutDebug is Cobra's script without the debug writer, each call turned into the no-op. -func withoutDebug(shell, script string) string { - out := []string{} - writer := false - for _, line := range strings.Split(script, "\n") { - m := debugCall.FindStringSubmatch(line) - switch { - case line == "__taiga_debug()" || strings.HasPrefix(line, "function __taiga_debug"): - writer = true - case writer: - writer = line != "}" && line != "end" - case m != nil: - if testNoop[shell] != "" { - out = append(out, m[1]+testNoop[shell]) - } - default: - out = append(out, line) - } - } - return strings.Join(out, "\n") +// quietBlock is what `taiga completion shell` adds to Cobra's script, and all it adds. +func quietBlock(shell string) string { + def := map[string]string{"bash": "__taiga_debug() { :; }", "zsh": "__taiga_debug() { :; }", + "fish": "function __taiga_debug; end", "powershell": "function __taiga_debug { }"}[shell] + return "# taiga: __taiga_debug above appends the command line as typed, tokens included, to\n" + + "# $BASH_COMP_DEBUG_FILE. Redefined here, before any completion runs, it writes nothing.\n" + def + "\n\n" } -// The scripts carry no debug log of their own: no writer, no call, no BASH_COMP_DEBUG_FILE. -// Everything else is Cobra's script, line for line, with a no-op where each call was. -func TestCompletionScriptsHaveNoShellDebug(t *testing.T) { +// The scripts are Cobra's with one block added after the debug writer's definition, which +// redefines the writer to do nothing; nothing else is added, removed or changed. +func TestCompletionScriptsOnlyAddTheQuietWriter(t *testing.T) { for _, shell := range completionShells { for _, desc := range []bool{true, false} { - got := generatedScript(t, shell, desc) - if strings.Contains(got, "__taiga_debug") || strings.Contains(got, "BASH_COMP_DEBUG_FILE") { - t.Errorf("%s (descriptions %v) still logs to BASH_COMP_DEBUG_FILE", shell, desc) + got, cobra := generatedScript(t, shell, desc), cobraScript(t, shell, desc) + at := strings.Index(got, quietBlock(shell)) + if at < 0 || got[:at]+got[at+len(quietBlock(shell)):] != cobra { + t.Errorf("%s (descriptions %v) is not Cobra's script plus the quiet writer", shell, desc) + continue } - if want := withoutDebug(shell, cobraScript(t, shell, desc)); got != want { - t.Errorf("%s (descriptions %v) differs from Cobra's beyond the debug log", shell, desc) + if first := strings.Index(cobra, "__taiga_debug \""); at > first || at < strings.Index(cobra, "__taiga_debug") { + t.Errorf("%s (descriptions %v): the quiet writer at %d is not between the writer and its first call (%d)", shell, desc, at, first) } } } } -// A call that shares its line with another statement, or a writer left open, fails closed: -// dropping the line would drop the statement too. -func TestCompletionScriptFilterFailsClosed(t *testing.T) { - for _, script := range []string{ - "f() {\n __taiga_debug \"x\"; return 3\n}\n", - "f() {\n __taiga_debug \"x\" && return 3\n}\n", - "function f\n __taiga_debug \"y\"; or return 1\nend\n", - "case $a in\n a) __taiga_debug a ;;\nesac\n", - "__taiga_debug()\n{\n echo \"$*\"\n", - "g() { echo $BASH_COMP_DEBUG_FILE; }\n", +// Without the writer's declaration under the expected name, or with nothing declared after +// it, the script is refused: an error and no script. +func TestCompletionScriptRefusedWithoutTheWriter(t *testing.T) { + cobra := cobraScript(t, "bash", true) + for name, script := range map[string]string{ + "renamed writer": strings.ReplaceAll(cobra, "__taiga_debug", "__taiga_log"), + "no writer": "_start() { :; }\n", + "nothing after": "__taiga_debug()\n{\n :\n}\n", + "declared inside": "f() {\n __taiga_debug() { :; }\n}\n", } { - if got, err := withoutShellDebug(script, "__taiga_debug", ":"); err == nil { - t.Errorf("%q: no error, got %q", script, got) + if got, err := withQuietDebug(script, "__taiga_debug", "bash"); err == nil || got != "" { + t.Errorf("%s: error %v, got %q", name, err, got) } } - // Changes of Cobra's format that would make the filter take the next function with the - // writer are refused, with no script: the writer closed with an indent (round 2), and also - // every line up to the next function's closing one, its declaration included (round 3). +} + +// statusCheck loads $SCRIPT in a shell, calls the writer after a failure and prints the status: +// the redefinition returns 0, as the writer itself. +var statusCheck = map[string][]string{ + "bash": {"bash", "--norc", "--noprofile", "-c", `source "$SCRIPT"; false; __taiga_debug "x"; echo "status=$?"`}, + "zsh": {"zsh", "-f", "-c", `compdef() { :; }; source "$SCRIPT"; false; __taiga_debug "x"; echo "status=$?"`}, + "fish": {"fish", "--no-config", "-c", `source $SCRIPT; false; __taiga_debug "x"; echo "status=$status"`}, + "powershell": {"pwsh", "-NoProfile", "-NonInteractive", "-Command", `. $env:SCRIPT; $out = __taiga_debug "x"; if ($? -and $null -eq $out) { "status=0" } else { "status=1" }`}, +} + +// With BASH_COMP_DEBUG_FILE set, the redefined writer returns 0, prints nothing and writes +// nothing. +func TestCompletionScriptsQuietWriter(t *testing.T) { for _, shell := range completionShells { - end := "\n}\n" - if shell == "fish" { - end = "\nend\n" - } - cobra := cobraScript(t, shell, true) - for name, script := range map[string]string{ - "writer closed with an indent": strings.Replace(cobra, end, "\n "+end[1:], 1), - "next function indented as well": indentFollowing(t, cobra, end), - } { - if got, err := withoutShellDebug(script, "__taiga_debug", testNoop[shell]); err == nil || got != "" { - t.Errorf("%s, %s: error %v, got %d bytes", shell, name, err, len(got)) + t.Run(shell, func(t *testing.T) { + c := statusCheck[shell] + requireShell(t, c[0]) + dir := t.TempDir() + path, log := filepath.Join(dir, "script.ps1"), filepath.Join(dir, "debug.log") + if err := os.WriteFile(path, []byte(generatedScript(t, shell, true)), 0o600); err != nil { + t.Fatal(err) } - } - } - got, err := withoutShellDebug("f() {\n __taiga_debug \"a \\\"b\\\" ${c}\"\n __taiga_debug\n}\n", "__taiga_debug", ":") - if err != nil || got != "f() {\n :\n :\n}\n" { - t.Errorf("got %q, %v", got, err) + cmd := exec.Command(c[0], c[1:]...) + cmd.Dir = t.TempDir() + cmd.Env = append(os.Environ(), "SCRIPT="+path, "BASH_COMP_DEBUG_FILE="+log) + out, err := cmd.Output() + if err != nil || string(out) != "status=0\n" { + t.Errorf("%v: %q", err, out) + } + if b, err := os.ReadFile(log); err == nil { + t.Errorf("the writer wrote %q", b) + } + }) } } @@ -155,63 +148,6 @@ func requireShell(t *testing.T, exe string) { t.Skipf("%s is not installed", exe) } -// The check on the output holds for the real scripts and refuses any other change: a -// function gone, a line gone or added, a call left without its no-op. -func TestCompletionScriptCheckRefusesOtherChanges(t *testing.T) { - for _, shell := range completionShells { - cobra, out := cobraScript(t, shell, true), generatedScript(t, shell, true) - if err := onlyDebugDropped(cobra, out, "__taiga_debug", testNoop[shell]); err != nil { - t.Fatalf("%s: %v", shell, err) - } - lines := strings.Split(out, "\n") - without := func(i int) string { - return strings.Join(append(append([]string{}, lines[:i]...), lines[i+1:]...), "\n") - } - mutations := map[string]string{"a line added": out + "echo x\n"} - for i, line := range lines { - if name := declared(line); name != "" && mutations["a declaration gone"] == "" { - mutations["a declaration gone"] = without(i) - } - if strings.TrimSpace(line) == "" { - continue - } - if strings.TrimSpace(line) == testNoop[shell] && mutations["a no-op gone"] == "" { - mutations["a no-op gone"] = without(i) - } else if declared(line) == "" { - mutations["a line gone"] = without(i) // the last plain line - } - } - for name, mutated := range mutations { - if err := onlyDebugDropped(cobra, mutated, "__taiga_debug", testNoop[shell]); err == nil { - t.Errorf("%s, %s: accepted", shell, name) - } - } - } -} - -// indentFollowing indents the writer's closing line (the first end at the margin) and every -// line after it up to the next one, which closes the next function and stays at the margin. -func indentFollowing(t *testing.T, script, end string) string { - t.Helper() - start := strings.Index(script, end) + 1 - stop := start + len(end) - 1 - next := strings.Index(script[stop:], end) - if start == 0 || next < 0 { - t.Fatalf("no closing line %q", end) - } - next += stop + 1 - var b strings.Builder - b.WriteString(script[:start]) - for _, line := range strings.SplitAfter(script[start:next], "\n") { - if strings.TrimSpace(line) != "" { - b.WriteString(" ") - } - b.WriteString(line) - } - b.WriteString(script[next:]) - return b.String() -} - // parsers check a script without running it. var parsers = map[string][]string{ "bash": {"bash", "-n"}, @@ -281,6 +217,23 @@ _arguments() { print -r -- "arguments $*"; } words=("$BIN" "${ARGS[@]}") CURRENT=${#words} _taiga +`}, + // zsh as installed: the script is _taiga in fpath, autoloaded, and the file itself runs on + // the first completion. + "zsh-autoload": {"zsh", func(h string) []string { return []string{"-f", "-c", h, "zsh"} }, ` +ARGS=("$@") +dir=$(mktemp -d) +cp "$SCRIPT" "$dir/_taiga" +fpath=("$dir" $fpath) +compdef() { :; } +compadd() { print -r -- "compadd $*"; } +_describe() { print -r -- "describe $* :: ${(j:|:)completions}"; return 0; } +_arguments() { print -r -- "arguments $*"; } +autoload -U _taiga +words=("$BIN" "${ARGS[@]}") +CURRENT=${#words} +_taiga +rm -r "$dir" `}, "fish": {"fish", func(h string) []string { return []string{"--no-config", "-c", h, "--"} }, ` source $SCRIPT @@ -328,19 +281,25 @@ func complete(t *testing.T, shell, bin, script string, words ...string) (out, de } // With BASH_COMP_DEBUG_FILE set, completing a command line with a token, a control and a bidi -// character leaves only the binary's redacted log in the file; the candidates are those of +// character leaves only the binary's redacted log in the file, no line of the shell's; the candidates are those of // Cobra's own script. A shell that is not installed is skipped. func TestCompletionScriptsKeepTokensOutOfTheDebugFile(t *testing.T) { bin := buildTaiga(t) - for _, shell := range completionShells { - t.Run(shell, func(t *testing.T) { - requireShell(t, completionHarness[shell].shell) + for _, harness := range []string{"bash", "zsh", "zsh-autoload", "fish", "powershell"} { + shell := strings.TrimSuffix(harness, "-autoload") + t.Run(harness, func(t *testing.T) { + requireShell(t, completionHarness[harness].shell) for _, desc := range []bool{true, false} { script := generatedScript(t, shell, desc) - _, debug := complete(t, shell, bin, script, "nope", "token=SWEEPSECRET", "a\u202eb", "x\x01y", "") + _, debug := complete(t, harness, bin, script, "nope", "token=SWEEPSECRET", "a\u202eb", "x\x01y", "") if strings.Contains(debug, "SWEEPSECRET") || strings.ContainsAny(debug, "\u202e\x01") { t.Errorf("descriptions %v: debug file leaked:\n%q", desc, debug) } + for _, line := range strings.Split(strings.TrimSuffix(debug, "\n"), "\n") { + if !strings.HasPrefix(line, "[Debug] ") { + t.Errorf("descriptions %v: the shell wrote %q", desc, line) + } + } if !strings.Contains(debug, "[Debug] [Error] unable to find a command for arguments: [nope token=…") { t.Errorf("descriptions %v: the binary's own log is gone:\n%q", desc, debug) } @@ -348,8 +307,8 @@ func TestCompletionScriptsKeepTokensOutOfTheDebugFile(t *testing.T) { for _, desc := range []bool{true, false} { script, cobra := generatedScript(t, shell, desc), cobraScript(t, shell, desc) for _, words := range [][]string{{"st"}, {"story", ""}, {"story", "l"}, {"story", "list", "--outp"}, {"story", "list", "--"}, {"completion", ""}, {"attachment", "upload", "--t"}} { - got, _ := complete(t, shell, bin, script, words...) - want, _ := complete(t, shell, bin, cobra, words...) + got, _ := complete(t, harness, bin, script, words...) + want, _ := complete(t, harness, bin, cobra, words...) t.Logf("descriptions %v, %q: %q", desc, words, got) if got != want || got == "" { t.Errorf("descriptions %v, %q: candidates %q, Cobra's script gives %q", desc, words, got, want)