From f3dcb9dc5a1e53bc2bb42f168324c64b73f63909 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 16:50:46 +0200 Subject: [PATCH] mesh-cli: carry what a pipe or a file gives standard input with the line, at most 64 KiB So a secret reaches the controller's terminal without standing on a command line: mesh-cli secret accept --from - < file. A terminal is never read, more than the bound is refused before anything is asked, and nothing carried is printed. mesh-sdk moves to f047d0a. --- modules/mesh-cli/cmd/mesh-cli/main.go | 37 +++++++++++++++- modules/mesh-cli/cmd/mesh-cli/main_test.go | 49 ++++++++++++++++++++++ modules/mesh-cli/go.mod | 2 +- modules/mesh-cli/go.sum | 4 +- 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/modules/mesh-cli/cmd/mesh-cli/main.go b/modules/mesh-cli/cmd/mesh-cli/main.go index f5019e5e..f77d1933 100644 --- a/modules/mesh-cli/cmd/mesh-cli/main.go +++ b/modules/mesh-cli/cmd/mesh-cli/main.go @@ -23,12 +23,42 @@ import ( // socket is where the node-engine answers; a variable so a test can name its own. var socket = cli.Socket +// standardInput is mesh-cli's own standard input, and whether it is a terminal (or a device such as /dev/null), +// which is never read; a variable so a test can say. +var standardInput = func() (io.Reader, bool) { + info, err := os.Stdin.Stat() + if err != nil { + return os.Stdin, true + } + return os.Stdin, info.Mode()&os.ModeCharDevice != 0 +} + +// carried is what mesh-cli carries of its standard input for the line: what a pipe or a file held, at most +// cli.MaxStdin, for the controller's terminal alone (a secret given with `secret accept … --from -`). A terminal is +// never read: the line is asked without. More than the bound is refused, and nothing is asked. +func carried() ([]byte, error) { + in, terminal := standardInput() + if terminal { + return nil, nil + } + body, err := io.ReadAll(io.LimitReader(in, cli.MaxStdin+1)) + if err != nil { + return nil, fmt.Errorf("standard input could not be read (%v); nothing was asked", err) + } + if len(body) > cli.MaxStdin { + return nil, fmt.Errorf("standard input is more than %d bytes, which mesh-cli does not carry; nothing was asked", cli.MaxStdin) + } + return body, nil +} + const usage = `mesh-cli — the controller's command lines, from this node (novox/hq ADR 0272) mesh-cli e.g. mesh-cli status, mesh-cli settings show zsh --node nox the same, in interactive zsh Run it as yourself, never under sudo: the node-engine tells the controller which account asks. +What a pipe or a file gives its standard input (at most 64 KiB) goes with the line to the controller's +terminal alone, e.g. a secret: mesh-cli secret accept --from - < file On the control-node, from its operator's account, the line runs as the controller's terminal. Anywhere else it is an ordinary call, and a terminal-only change is refused with the reason. 'mesh-cli help' asks the controller for its own list of command lines. @@ -52,7 +82,12 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { "Nothing was asked\n", verb) return 1 } - a, err := cli.Ask(ctx, socket, args) + in, err := carried() + if err != nil { + fmt.Fprintf(stderr, "mesh-cli: %v\n", err) + return 1 + } + a, err := cli.AskWith(ctx, socket, cli.Request{Line: args, Stdin: in}) if err != nil { if errors.Is(ctx.Err(), context.Canceled) { fmt.Fprintln(stderr, "mesh-cli: stopped waiting; the line may still be running on the controller") diff --git a/modules/mesh-cli/cmd/mesh-cli/main_test.go b/modules/mesh-cli/cmd/mesh-cli/main_test.go index a38238fe..123a5f5f 100644 --- a/modules/mesh-cli/cmd/mesh-cli/main_test.go +++ b/modules/mesh-cli/cmd/mesh-cli/main_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "io" "net" "path/filepath" "strings" @@ -11,6 +12,13 @@ import ( "git.novox.be/novox/mesh-sdk/go/cli" ) +// The tests' own standard input is the runner's, which may be a pipe nobody closes: read as a terminal unless a test +// says otherwise. +func init() { standardInput = func() (io.Reader, bool) { return nil, true } } + +// lastStdin is the standard input the engine was last given. +var lastStdin []byte + // engine answers one connection on a socket of the test's own as the node-engine would, and says what it was asked. func engine(t *testing.T, answer cli.Answer) <-chan []string { t.Helper() @@ -24,6 +32,7 @@ func engine(t *testing.T, answer cli.Answer) <-chan []string { socket = path t.Cleanup(func() { socket = before }) asked := make(chan []string, 1) + lastStdin = nil go func() { conn, err := l.Accept() if err != nil { @@ -34,6 +43,7 @@ func engine(t *testing.T, answer cli.Answer) <-chan []string { if err != nil { return } + lastStdin = req.Stdin asked <- req.Line _ = cli.WriteAnswer(conn, answer) }() @@ -104,3 +114,42 @@ func TestAnOutcomeNotKnownIsSaid(t *testing.T) { t.Fatalf("exit %d, said %q", code, errOut.String()) } } + +// A pipe's or a file's standard input goes with the line, whole; a terminal's is never read; more than the bound is +// refused and nothing is asked (novox/hq ADR 0259 §10). +func TestStandardInputGoesWithTheLineAndATerminalIsNeverRead(t *testing.T) { + t.Setenv("MESH_VERB", "") + was := standardInput + t.Cleanup(func() { standardInput = was }) + secret := "123456789:the-bot-token\n" + + standardInput = func() (io.Reader, bool) { return strings.NewReader(secret), false } + asked := engine(t, cli.Answer{Terminal: true}) + var out, errOut bytes.Buffer + if code := run(context.Background(), []string{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "-"}, + &out, &errOut); code != 0 { + t.Fatalf("exit %d: %s", code, errOut.String()) + } + <-asked + if string(lastStdin) != secret { + t.Fatalf("the engine was given %q", lastStdin) + } + if strings.Contains(out.String()+errOut.String(), "bot-token") { + t.Fatal("mesh-cli printed what it carried") + } + + standardInput = func() (io.Reader, bool) { return strings.NewReader("typed"), true } + asked = engine(t, cli.Answer{}) + _ = run(context.Background(), []string{"status"}, &out, &errOut) + <-asked + if len(lastStdin) != 0 { + t.Fatalf("a terminal's input was read: %q", lastStdin) + } + + standardInput = func() (io.Reader, bool) { return bytes.NewReader(make([]byte, cli.MaxStdin+1)), false } + errOut.Reset() + if code := run(context.Background(), []string{"secret", "accept", "a", "b", "c", "--from", "-"}, &out, &errOut); code == 0 || + !strings.Contains(errOut.String(), "nothing was asked") { + t.Fatalf("more than the bound was asked: %d %s", code, errOut.String()) + } +} diff --git a/modules/mesh-cli/go.mod b/modules/mesh-cli/go.mod index 54ca3371..c83ec036 100644 --- a/modules/mesh-cli/go.mod +++ b/modules/mesh-cli/go.mod @@ -2,4 +2,4 @@ module mesh-cli go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.9 +require git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 diff --git a/modules/mesh-cli/go.sum b/modules/mesh-cli/go.sum index 069f9d0c..f2fa3d95 100644 --- a/modules/mesh-cli/go.sum +++ b/modules/mesh-cli/go.sum @@ -1,2 +1,2 @@ -git.novox.be/novox/mesh-sdk/go v0.1.9 h1:2zq9tbwoXEYi+dDBxPoeNmn39KdRRKRp8CK33F4LWd0= -git.novox.be/novox/mesh-sdk/go v0.1.9/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI= +git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=