mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-15 04:53:00 +08:00
407 lines
14 KiB
Go
407 lines
14 KiB
Go
package openwrt
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/matthewlu070111/smart-srun/core/internal/domain"
|
|
)
|
|
|
|
func codeOf(t *testing.T, err error) domain.ErrorCode {
|
|
t.Helper()
|
|
code, ok := domain.CodeOf(err)
|
|
if !ok {
|
|
t.Fatalf("error carries no code: %v", err)
|
|
}
|
|
return code
|
|
}
|
|
|
|
func TestBoundedInputUsesPipeAndClosesIt(t *testing.T) {
|
|
program, args := helperCommand(t, "stdin", "public-argument")
|
|
input := "private-value ; $(never-run) ' \\\n"
|
|
result, err := (Runner{}).RunInput(t.Context(), program, input, args...)
|
|
if err != nil || string(result.Stdout) != "public-argument\n"+input {
|
|
t.Fatalf("stdin transport failed: %v", err)
|
|
}
|
|
_, err = (Runner{}).RunInput(t.Context(), program, strings.Repeat("x", DefaultMaxOutput+1), args...)
|
|
if codeOf(t, err) != domain.CodeInvalidArgument {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// T28 -- the injection case, against a real execve.
|
|
//
|
|
// Everything this package runs can be handed a value the user typed: an SSID, a
|
|
// wireless key, an interface name. If any of it were assembled into a command
|
|
// string, a value like `; rm -rf /` would become a second command. This runs a
|
|
// real process and reads back the argv the kernel delivered.
|
|
func TestAnArgumentIsOneArgumentNoMatterWhatIsInIt(t *testing.T) {
|
|
nasty := []string{
|
|
"; rm -rf /tmp/nothing",
|
|
"$(whoami)",
|
|
"`id`",
|
|
"a | b && c",
|
|
"two words",
|
|
"it's",
|
|
"back\\slash",
|
|
"new\nline",
|
|
"tab\there",
|
|
"--looks-like-a-flag",
|
|
"南昌 校园网",
|
|
"'quoted'",
|
|
`"double"`,
|
|
"*",
|
|
"$HOME",
|
|
">/tmp/redirected",
|
|
}
|
|
|
|
program, args := helperCommand(t, append([]string{"echo"}, nasty...)...)
|
|
result, err := Runner{}.Run(t.Context(), program, args...)
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
|
|
// The helper prints one argument per line, so an argument containing a
|
|
// newline occupies two -- which is why this compares the whole block rather
|
|
// than counting lines.
|
|
want := strings.Join(nasty, "\n") + "\n"
|
|
if got := string(result.Stdout); got != want {
|
|
t.Errorf("the arguments did not arrive intact\n got %q\nwant %q", got, want)
|
|
}
|
|
}
|
|
|
|
// The other half of the same guarantee: there is no shell to reach for.
|
|
func TestTheRunnerRefusesToStartAShell(t *testing.T) {
|
|
for _, shell := range []string{"sh", "bash", "ash", "/bin/sh", "/usr/bin/bash"} {
|
|
t.Run(shell, func(t *testing.T) {
|
|
_, err := Runner{}.Run(t.Context(), shell, "-c", "echo hello")
|
|
if err == nil {
|
|
t.Fatal("the runner started a shell")
|
|
}
|
|
if code := codeOf(t, err); code != domain.CodeInvalidArgument {
|
|
t.Errorf("code = %s, want InvalidArgument", code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A tool this firmware does not ship is a capability that is unavailable, not a
|
|
// failure to retry. Spec 03 has a code for exactly this and the M04 card
|
|
// requires it.
|
|
func TestAMissingToolIsReportedAsAnUnsupportedCapability(t *testing.T) {
|
|
runner := Runner{SearchPath: []string{t.TempDir()}}
|
|
|
|
_, err := runner.Run(t.Context(), "iwinfo", "info")
|
|
if err == nil {
|
|
t.Fatal("running a tool that does not exist succeeded")
|
|
}
|
|
if code := codeOf(t, err); code != domain.CodeUnsupportedCapability {
|
|
t.Errorf("code = %s, want UnsupportedCapability", code)
|
|
}
|
|
if !strings.Contains(err.Error(), "iwinfo") {
|
|
t.Errorf("the message does not name the missing tool: %v", err)
|
|
}
|
|
}
|
|
|
|
// A directory named like a tool, or a file without the execute bit, is the tool
|
|
// being absent -- not a failure at the moment the feature is used.
|
|
func TestSomethingThatIsNotAProgramCountsAsMissing(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("permission bits do not decide executability here")
|
|
}
|
|
dir := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(dir, "ubus"), 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "uci"), []byte("data"), 0o644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
runner := Runner{SearchPath: []string{dir}}
|
|
for _, tool := range []string{"ubus", "uci"} {
|
|
if _, err := runner.Resolve(tool); err == nil {
|
|
t.Errorf("%s resolved to something that cannot be run", tool)
|
|
} else if code := codeOf(t, err); code != domain.CodeUnsupportedCapability {
|
|
t.Errorf("%s: code = %s, want UnsupportedCapability", tool, code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A tool that does not return has to stop being this program's problem. On a
|
|
// router `wifi reload` and the package managers can block for a long time, and
|
|
// a daemon that waits for one of them stops answering the interface.
|
|
func TestAToolThatDoesNotReturnIsKilled(t *testing.T) {
|
|
program, args := helperCommand(t, "sleep", "60000")
|
|
runner := Runner{Timeout: 300 * time.Millisecond}
|
|
|
|
started := time.Now()
|
|
_, err := runner.Run(t.Context(), program, args...)
|
|
elapsed := time.Since(started)
|
|
|
|
if err == nil {
|
|
t.Fatal("a command that never returns was reported as successful")
|
|
}
|
|
if code := codeOf(t, err); code != domain.CodeDeadlineExceeded {
|
|
t.Errorf("code = %s, want DeadlineExceeded", code)
|
|
}
|
|
if elapsed > 5*time.Second {
|
|
t.Errorf("waited %v for a 300ms timeout", elapsed)
|
|
}
|
|
}
|
|
|
|
// Cancellation has to be prompt and has to be reported as cancellation. Spec 04
|
|
// gives user actions 500ms to take effect; a command that ignores the context
|
|
// would hold that up for its whole timeout.
|
|
func TestCancellingStopsTheToolAndSaysSo(t *testing.T) {
|
|
program, args := helperCommand(t, "sleep", "60000")
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
go func() {
|
|
time.Sleep(100 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
|
|
started := time.Now()
|
|
_, err := Runner{Timeout: 30 * time.Second}.Run(ctx, program, args...)
|
|
elapsed := time.Since(started)
|
|
|
|
if err == nil {
|
|
t.Fatal("a cancelled command was reported as successful")
|
|
}
|
|
if code := codeOf(t, err); code != domain.CodeCancelled {
|
|
t.Errorf("code = %s, want Cancelled", code)
|
|
}
|
|
if elapsed > 5*time.Second {
|
|
t.Errorf("cancellation took %v", elapsed)
|
|
}
|
|
}
|
|
|
|
// The reaping that matters: killing the tool must kill what the tool started.
|
|
//
|
|
// Signalling only the process named on the command line leaves its children
|
|
// running, still holding the pipe, so the timeout that was supposed to bound
|
|
// the call does not.
|
|
func TestKillingAToolKillsWhatItStarted(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("process groups are a Unix concept")
|
|
}
|
|
pidFile := filepath.Join(t.TempDir(), "grandchild.pid")
|
|
program, args := helperCommand(t, "group", pidFile, "60000")
|
|
|
|
_, err := Runner{Timeout: 700 * time.Millisecond}.Run(t.Context(), program, args...)
|
|
if err == nil {
|
|
t.Fatal("expected the timeout")
|
|
}
|
|
|
|
data, err := os.ReadFile(pidFile)
|
|
if err != nil {
|
|
t.Fatalf("the helper never recorded its child: %v", err)
|
|
}
|
|
pid := 0
|
|
if _, err := fmt.Sscanf(string(data), "%d", &pid); err != nil || pid <= 0 {
|
|
t.Fatalf("unreadable pid %q", data)
|
|
}
|
|
|
|
// The kill is asynchronous, so give it a bounded moment before concluding
|
|
// the grandchild survived.
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if !processExists(pid) {
|
|
return
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Errorf("process %d outlived the tool that started it; the timeout only "+
|
|
"killed the parent", pid)
|
|
}
|
|
|
|
// A tool that exits but leaves a child holding the pipe must not wedge the
|
|
// caller. Waiting for the pipe rather than for the process is the classic way a
|
|
// daemon stops responding after `wifi reload`.
|
|
func TestAToolThatLeavesAChildHoldingThePipeDoesNotHangTheCaller(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
// The grandchild keeps the test binary open and Windows refuses to
|
|
// delete a running image, so the toolchain's own cleanup fails and the
|
|
// run reports a failure that has nothing to do with the guarantee. The
|
|
// target is OpenWrt and this is verified there.
|
|
t.Skip("a lingering grandchild blocks removal of the test binary here")
|
|
}
|
|
program, args := helperCommand(t, "orphan", "30000")
|
|
|
|
started := time.Now()
|
|
_, err := Runner{Timeout: 30 * time.Second}.Run(t.Context(), program, args...)
|
|
elapsed := time.Since(started)
|
|
|
|
if elapsed > 10*time.Second {
|
|
t.Fatalf("the call took %v; it waited for the grandchild", elapsed)
|
|
}
|
|
if err == nil {
|
|
t.Error("the output may be incomplete, so this is not a success")
|
|
}
|
|
}
|
|
|
|
// Output has to be bounded. A tool that produces without end would otherwise be
|
|
// read into memory on a router that has 128 MiB of it.
|
|
//
|
|
// The limit is deliberately not a multiple of the helper's write size, so the
|
|
// write that reaches it straddles the boundary. An earlier version used a round
|
|
// number, every write landed exactly on the limit, and the branch that decides
|
|
// how much of a straddling chunk to keep was never run.
|
|
func TestOutputIsCappedAndTheTruncationIsReported(t *testing.T) {
|
|
program, args := helperCommand(t, "spew", "400000")
|
|
runner := Runner{MaxOutput: 8000}
|
|
|
|
result, err := runner.Run(t.Context(), program, args...)
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if len(result.Stdout) != runner.MaxOutput {
|
|
t.Errorf("kept %d bytes, want exactly the limit of %d",
|
|
len(result.Stdout), runner.MaxOutput)
|
|
}
|
|
if !result.StdoutTruncated {
|
|
t.Error("the output was cut but not reported as truncated; a parser " +
|
|
"would read half a document as a whole one")
|
|
}
|
|
}
|
|
|
|
// The buffer itself, at the boundary.
|
|
//
|
|
// A tool writes in whatever size it likes, so the chunk that reaches the limit
|
|
// almost always straddles it. Two things have to hold on that write: exactly
|
|
// the remaining room is kept, and the whole chunk is reported as consumed --
|
|
// a short count would make the copier fail with "short write", turning "the
|
|
// output was long" into a command that appears to have gone wrong.
|
|
func TestTheOutputBufferStopsExactlyAtItsLimit(t *testing.T) {
|
|
buffer := &boundedBuffer{limit: 10}
|
|
|
|
written, err := buffer.Write([]byte("abcd"))
|
|
if err != nil || written != 4 {
|
|
t.Fatalf("Write = (%d, %v), want (4, nil)", written, err)
|
|
}
|
|
if buffer.truncated {
|
|
t.Error("reported as truncated before the limit was reached")
|
|
}
|
|
|
|
// This one straddles: 6 bytes of room left, 8 offered.
|
|
written, err = buffer.Write([]byte("efghijkl"))
|
|
if err != nil || written != 8 {
|
|
t.Fatalf("Write = (%d, %v), want (8, nil) so the copier does not fail",
|
|
written, err)
|
|
}
|
|
if got := string(buffer.Bytes()); got != "abcdefghij" {
|
|
t.Errorf("buffer = %q, want the first 10 bytes", got)
|
|
}
|
|
if !buffer.truncated {
|
|
t.Error("the chunk that crossed the limit was not reported as truncating")
|
|
}
|
|
|
|
// And once full, further writes are dropped rather than accumulated.
|
|
if _, err := buffer.Write([]byte("mnopq")); err != nil {
|
|
t.Fatalf("Write after the limit: %v", err)
|
|
}
|
|
if len(buffer.Bytes()) != 10 {
|
|
t.Errorf("buffer grew to %d bytes past its limit", len(buffer.Bytes()))
|
|
}
|
|
if buffer.dropped != 7 {
|
|
t.Errorf("dropped = %d, want 7", buffer.dropped)
|
|
}
|
|
}
|
|
|
|
// A non-zero exit is an answer, not an error to swallow: `uci get` uses status 1
|
|
// for "not set". It is still returned as an error so that ignoring it takes a
|
|
// deliberate line of code, and the output comes back either way.
|
|
func TestANonZeroExitIsReportedWithItsOutput(t *testing.T) {
|
|
program, args := helperCommand(t, "exit", "3")
|
|
|
|
result, err := Runner{}.Run(t.Context(), program, args...)
|
|
if err == nil {
|
|
t.Fatal("a failing command was reported as successful")
|
|
}
|
|
var exit *ExitError
|
|
if !errors.As(err, &exit) {
|
|
t.Fatalf("err = %T, want *ExitError", err)
|
|
}
|
|
if exit.Code != 3 {
|
|
t.Errorf("Code = %d, want 3", exit.Code)
|
|
}
|
|
if result.ExitCode != 3 {
|
|
t.Errorf("result.ExitCode = %d, want 3", result.ExitCode)
|
|
}
|
|
if !strings.Contains(string(result.Stdout), "stdout before exiting") {
|
|
t.Errorf("stdout was discarded: %q", result.Stdout)
|
|
}
|
|
if !strings.Contains(string(result.Stderr), "stderr before exiting") {
|
|
t.Errorf("stderr was discarded: %q", result.Stderr)
|
|
}
|
|
}
|
|
|
|
// The error must not carry the output.
|
|
//
|
|
// An argument can be a wireless key, and a tool that fails often echoes what it
|
|
// was given. An error travels further than a Result -- into logs, into an RPC
|
|
// envelope, onto a status page -- so it carries no text from the child.
|
|
func TestAFailedCommandDoesNotPutItsOutputInTheError(t *testing.T) {
|
|
program, args := helperCommand(t, "exit", "1")
|
|
result, err := Runner{}.Run(t.Context(), program, args...)
|
|
if err == nil {
|
|
t.Fatal("expected a failure")
|
|
}
|
|
if len(result.Stderr) == 0 {
|
|
t.Fatal("the helper produced no stderr, so this proves nothing")
|
|
}
|
|
if strings.Contains(err.Error(), "stderr before exiting") {
|
|
t.Errorf("the error repeats the child's output: %v", err)
|
|
}
|
|
}
|
|
|
|
// The child gets a fixed environment.
|
|
//
|
|
// Tools translate their messages, and a parser written against the English ones
|
|
// silently mis-reads a router set to anything else. Inheriting the daemon's
|
|
// environment would also hand a system query whatever proxy or PATH the daemon
|
|
// happened to be started with.
|
|
func TestTheChildGetsAFixedEnvironment(t *testing.T) {
|
|
t.Setenv("SMART_SRUN_LEAK_CHECK", "should-not-be-visible")
|
|
t.Setenv("LC_ALL", "zh_CN.UTF-8")
|
|
|
|
program, args := helperCommand(t, "env")
|
|
result, err := Runner{}.Run(t.Context(), program, args...)
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
|
|
seen := map[string]string{}
|
|
for line := range strings.SplitSeq(string(result.Stdout), "\n") {
|
|
if name, value, found := strings.Cut(line, "="); found {
|
|
seen[name] = value
|
|
}
|
|
}
|
|
if _, leaked := seen["SMART_SRUN_LEAK_CHECK"]; leaked {
|
|
t.Error("the daemon's environment reached the child")
|
|
}
|
|
if seen["LC_ALL"] != "C" {
|
|
t.Errorf("LC_ALL = %q, want C; tool output must not be translated",
|
|
seen["LC_ALL"])
|
|
}
|
|
}
|
|
|
|
// An argument containing a NUL cannot be passed to execve at all, so it is
|
|
// refused with a reason rather than reaching the syscall.
|
|
func TestAnArgumentWithANulByteIsRefused(t *testing.T) {
|
|
program, _ := helperCommand(t)
|
|
_, err := Runner{}.Run(t.Context(), program, helperFlag, "echo", "bad\x00value")
|
|
if err == nil {
|
|
t.Fatal("an argument containing NUL was accepted")
|
|
}
|
|
if code := codeOf(t, err); code != domain.CodeInvalidArgument {
|
|
t.Errorf("code = %s, want InvalidArgument", code)
|
|
}
|
|
}
|