Fail a bare invocation when stdout is not a terminal
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/integration Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline failed

Puppet's generate() reads stdout through a pipe with combine: true, so help
text on a zero exit lands in the catalog. Help now requires an interactive
stdout; a piped one exits non-zero with nothing on stdout.

- gate the no-args help path on an isTerminal(stdout) check
- reject an explicitly empty --config instead of resolving it
- ignore a relative XDG_CONFIG_HOME, per the XDG spec
- give the integration subprocess a hermetic HOME/XDG_CONFIG_HOME
- pass []string{} to run(), so cobra cannot fall back to os.Args
This commit is contained in:
2026-09-22 22:50:05 +10:00
parent f7c8ca5fba
commit 4ff55f052a
7 changed files with 219 additions and 22 deletions
+39 -7
View File
@@ -26,7 +26,11 @@ func main() {
// it writes nothing to stderr: Puppet's generate() runs this binary with
// combine: true, which splices stderr into the stdout that parsejson() reads.
func run(args []string, stdout, stderr io.Writer) int {
cmd := rootCmd()
return runWith(args, stdout, stderr, isTerminal(stdout))
}
func runWith(args []string, stdout, stderr io.Writer, interactive bool) int {
cmd := rootCmd(interactive)
cmd.SetArgs(args)
cmd.SetOut(stdout)
cmd.SetErr(stderr)
@@ -38,7 +42,27 @@ func run(args []string, stdout, stderr io.Writer) int {
return 0
}
func rootCmd() *cobra.Command {
// isTerminal reports whether w is a terminal. Puppet's generate() captures
// stdout through a pipe, so anything else is a machine caller.
func isTerminal(w io.Writer) bool {
f, ok := w.(*os.File)
if !ok {
return false
}
info, err := f.Stat()
return err == nil && info.Mode()&os.ModeCharDevice != 0
}
// resolveConfig turns the --config value into a path. An explicitly empty
// --config is a malformed call, not a request to resolve.
func resolveConfig(cmd *cobra.Command, explicit string) (string, error) {
if explicit == "" && cmd.Flags().Changed("config") {
return "", fmt.Errorf("--config requires a path")
}
return config.Resolve(explicit), nil
}
func rootCmd(interactive bool) *cobra.Command {
var (
cfgFile string
altNames string
@@ -57,15 +81,19 @@ func rootCmd() *cobra.Command {
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
// A bare invocation is someone asking what this is; flags
// without a common name is a malformed call and must fail.
if cmd.Flags().NFlag() == 0 {
// Help only for a human at a terminal: under generate() an
// argv that lost its common name must fail the compile.
if interactive && cmd.Flags().NFlag() == 0 {
return cmd.Help()
}
return fmt.Errorf("a common name is required")
}
cfg, err := config.Load(config.Resolve(cfgFile))
path, err := resolveConfig(cmd, cfgFile)
if err != nil {
return err
}
cfg, err := config.Load(path)
if err != nil {
return err
}
@@ -116,7 +144,11 @@ func caCmd(cfgFile *string) *cobra.Command {
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.LoadCA(config.Resolve(*cfgFile))
path, err := resolveConfig(cmd, *cfgFile)
if err != nil {
return err
}
cfg, err := config.LoadCA(path)
if err != nil {
return err
}
+95 -4
View File
@@ -772,11 +772,11 @@ func TestRunCAFailures(t *testing.T) {
}
}
// A bare invocation is a human at a terminal, not Puppet: print help and exit
// zero rather than failing on the missing positional.
func TestRunNoArgsPrintsHelp(t *testing.T) {
// A bare invocation at a terminal is a human asking what this is: print help
// and exit zero rather than failing on the missing positional.
func TestRunNoArgsOnATerminalPrintsHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
if code := run(nil, &stdout, &stderr); code != 0 {
if code := runWith([]string{}, &stdout, &stderr, true); code != 0 {
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "Usage:") {
@@ -790,6 +790,97 @@ func TestRunNoArgsPrintsHelp(t *testing.T) {
}
}
// generate() splices stderr into stdout and reads the result as JSON, so an
// argv that lost its common name must fail loudly with nothing on stdout.
func TestRunNoArgsWithoutATerminalFails(t *testing.T) {
var stdout, stderr bytes.Buffer
code := runWith([]string{}, &stdout, &stderr, false)
if code == 0 {
t.Fatalf("exit code = 0, want non-zero (stdout %q)", stdout.String())
}
if stdout.Len() != 0 {
t.Errorf("stdout = %q, want empty", stdout.String())
}
if !strings.Contains(stderr.String(), "a common name is required") {
t.Errorf("stderr = %q, want it to name the missing common name", stderr.String())
}
}
// run() decides interactivity from the real stdout, so a piped stdout must not
// take the help path.
func TestRunNoArgsPipedStdoutFails(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
defer func() { _ = r.Close() }()
var stderr bytes.Buffer
code := run([]string{}, w, &stderr)
if err := w.Close(); err != nil {
t.Fatalf("close: %v", err)
}
out, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read: %v", err)
}
if code == 0 {
t.Fatalf("exit code = 0, want non-zero (stdout %q)", out)
}
if len(out) != 0 {
t.Errorf("stdout = %q, want empty", out)
}
}
func TestIsTerminal(t *testing.T) {
var buf bytes.Buffer
if isTerminal(&buf) {
t.Error("a bytes.Buffer is not a terminal")
}
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
defer func() { _ = r.Close() }()
defer func() { _ = w.Close() }()
if isTerminal(w) {
t.Error("a pipe is not a terminal")
}
tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0)
if err != nil {
t.Skipf("no controlling terminal: %v", err)
}
defer func() { _ = tty.Close() }()
if !isTerminal(tty) {
t.Error("/dev/tty is a terminal")
}
}
// --config with an empty value is a malformed call, not a request to resolve.
func TestRunRejectsEmptyConfigFlag(t *testing.T) {
srv, _ := newVaultStub(t, stubOptions{})
seedXDGConfig(t, srv.URL)
for _, args := range [][]string{
{"host.example.com", "--config", "", "--json"},
{"ca", "--config", ""},
} {
var stdout, stderr bytes.Buffer
code := run(args, &stdout, &stderr)
if code == 0 {
t.Fatalf("%v: exit code = 0, want non-zero (stdout %q)", args, stdout.String())
}
if stdout.Len() != 0 {
t.Errorf("%v: stdout = %q, want empty", args, stdout.String())
}
if !strings.Contains(stderr.String(), "--config requires a path") {
t.Errorf("%v: stderr = %q", args, stderr.String())
}
}
}
// seedXDGConfig writes a config where DefaultPath() looks first.
func seedXDGConfig(t *testing.T, addr string) {
t.Helper()