| new file mode 100644 |
| index 000000000..5d6d83521 |
| --- /dev/null |
| +++ b/internal/maintenance/result_test.go |
| @@ -0,0 +1,195 @@ |
| +package maintenance |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ecdh" |
| + "crypto/rand" |
| + "os" |
| + "strconv" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func resultPipe(t *testing.T) (*os.File, *os.File) { |
| + t.Helper() |
| + r, w, err := os.Pipe() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { _ = r.Close(); _ = w.Close() }) |
| + return r, w |
| +} |
| +func resultContext(t *testing.T) context.Context { |
| + t.Helper() |
| + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + t.Cleanup(cancel) |
| + return ctx |
| +} |
| + |
| +func TestCaptureResultCompleteBoundaries(t *testing.T) { |
| + for _, n := range []int{0, 1, MaxResultBytes} { |
| + t.Run(strconv.Itoa(n), func(t *testing.T) { |
| + r, w := resultPipe(t) |
| + body := bytes.Repeat([]byte{'x'}, n) |
| + done := make(chan error, 1) |
| + go func() { _, e := w.Write(body); _ = w.Close(); done <- e }() |
| + got, err := CaptureResult(resultContext(t), r) |
| + if err != nil || !bytes.Equal(got, body) { |
| + t.Fatalf("size%d capture failed: %v", n, err) |
| + } |
| + if err := <-done; err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := r.Stat(); err == nil { |
| + t.Fatal("capture retained its owned read pipe") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestCaptureResultOverflowWithoutEOF(t *testing.T) { |
| + r, w := resultPipe(t) |
| + done := make(chan error, 1) |
| + go func() { _, err := w.Write(bytes.Repeat([]byte{'x'}, MaxResultBytes+1)); done <- err }() |
| + body, err := CaptureResult(resultContext(t), r) |
| + if body != nil || err != ErrResultOverflow { |
| + t.Fatalf("overflow = %d bytes, %v", len(body), err) |
| + } |
| + if err := <-done; err != nil { |
| + t.Fatal(err) |
| + } |
| + // The write end is deliberately still open: overflow cannot require EOF. |
| + if _, err := w.Stat(); err != nil { |
| + t.Fatal("writer closed before overflow observation") |
| + } |
| +} |
| + |
| +func TestCaptureResultCancellationDiscardsPartialBytes(t *testing.T) { |
| + r, w := resultPipe(t) |
| + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + defer cancel() |
| + type answer struct { |
| + body []byte |
| + err error |
| + } |
| + done := make(chan answer, 1) |
| + go func() { b, e := CaptureResult(ctx, r); done <- answer{b, e} }() |
| + if _, err := w.Write([]byte("synthetic-private-fragment")); err != nil { |
| + t.Fatal(err) |
| + } |
| + cancel() |
| + select { |
| + case got := <-done: |
| + if got.body != nil || got.err != ErrResult { |
| + t.Fatal("cancel returned partial bytes or success") |
| + } |
| + case <-time.After(time.Second): |
| + t.Fatal("cancellation did not unblock the actual pipe") |
| + } |
| + if _, err := r.Stat(); err == nil { |
| + t.Fatal("cancel retained read descriptor") |
| + } |
| + if _, err := w.Stat(); err != nil { |
| + t.Fatal("capture claimed to terminate independent writer") |
| + } |
| +} |
| + |
| +func TestCaptureResultRequiresEOFBeforeDeadline(t *testing.T) { |
| + r, _ := resultPipe(t) |
| + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) |
| + defer cancel() |
| + done := make(chan error, 1) |
| + go func() { |
| + b, e := CaptureResult(ctx, r) |
| + if b != nil { |
| + done <- nil |
| + return |
| + } |
| + done <- e |
| + }() |
| + select { |
| + case err := <-done: |
| + if err != ErrResult { |
| + t.Fatal("open writer accepted without EOF") |
| + } |
| + case <-time.After(time.Second): |
| + t.Fatal("deadline did not close pipe") |
| + } |
| +} |
| + |
| +func TestCaptureResultRefusesUnboundedOrNonpipeInput(t *testing.T) { |
| + long, cancelLong := context.WithTimeout(context.Background(), MaxExecutionDuration+time.Minute) |
| + defer cancelLong() |
| + expired, cancelExpired := context.WithCancel(resultContext(t)) |
| + cancelExpired() |
| + for _, ctx := range []context.Context{nil, context.Background(), long, expired} { |
| + r, _ := resultPipe(t) |
| + if b, e := CaptureResult(ctx, r); b != nil || e != ErrResult { |
| + t.Fatal("invalid context accepted") |
| + } |
| + if _, e := r.Stat(); e == nil { |
| + t.Fatal("refusal retained read pipe") |
| + } |
| + } |
| + f, e := os.CreateTemp(t.TempDir(), "regular") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if b, e := CaptureResult(resultContext(t), f); b != nil || e != ErrResult { |
| + t.Fatal("regular file accepted as private stdout") |
| + } |
| + if b, e := CaptureResult(resultContext(t), nil); b != nil || e != ErrResult { |
| + t.Fatal("nil pipe accepted") |
| + } |
| +} |
| + |
| +func TestResultEnvelopePurposeAndLimits(t *testing.T) { |
| + key, err := ecdh.P256().GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fingerprint, err := RecipientFingerprint(key.PublicKey()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + binding := Binding{Version: 1, Direction: DirectionResult, Account: "account", App: "home", Host: "test.example", RequestID: "request", AttemptID: "attempt", Recipient: fingerprint} |
| + for _, n := range []int{0, 1, MaxResultBytes} { |
| + body := bytes.Repeat([]byte{'r'}, n) |
| + sealed, err := SealResult(key.PublicKey(), binding, body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := OpenResult(key, binding, sealed) |
| + if err != nil || !bytes.Equal(body, got) { |
| + t.Fatalf("result roundtrip size%d: %v", n, err) |
| + } |
| + } |
| + tooLarge := bytes.Repeat([]byte{'r'}, MaxResultBytes+1) |
| + if _, err := SealResult(key.PublicKey(), binding, tooLarge); err != ErrResult { |
| + t.Fatal("oversized seal accepted") |
| + } |
| + // Generic crypto accepts this size. Base64's rounding also fits the result |
| + // encoded-length bound, so the separate decoded-length check must reject it. |
| + sealed, err := Seal(key.PublicKey(), binding, tooLarge) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if b, err := OpenResult(key, binding, sealed); b != nil || err != ErrResult { |
| + t.Fatal("oversized decoded result accepted") |
| + } |
| + for _, direction := range []Direction{DirectionInput, DirectionEnvironment} { |
| + wrong := binding |
| + wrong.Direction = direction |
| + if _, err := SealResult(key.PublicKey(), wrong, []byte("ok")); err != ErrResult { |
| + t.Fatal("wrong seal purpose accepted") |
| + } |
| + sealed, err := Seal(key.PublicKey(), wrong, []byte("ok")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if b, err := OpenResult(key, wrong, sealed); b != nil || err != ErrResult { |
| + t.Fatal("wrong open purpose accepted") |
| + } |
| + } |
| +} |