Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,232 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package ocr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func capture_request() CaptureRequest {
|
||||
return CaptureRequest{Kind: ObjectSource, Bounds: image.Rect(-20, 10, 30, 50), Foreground: 123, Generation: 7}
|
||||
}
|
||||
|
||||
func capture_frame(t *testing.T, header CaptureHeader, pixels image.Image) []byte {
|
||||
t.Helper()
|
||||
var output bytes.Buffer
|
||||
if err := WriteCaptureResponse(&output, header, pixels); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return output.Bytes()
|
||||
}
|
||||
|
||||
func TestCaptureRequestRejectsUnownedOrInvalidSources(t *testing.T) {
|
||||
base := capture_request()
|
||||
if err := base.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mutations := []func(*CaptureRequest){
|
||||
func(r *CaptureRequest) { r.Foreground = 0 },
|
||||
func(r *CaptureRequest) { r.Foreground = 1 << 32 },
|
||||
func(r *CaptureRequest) { r.Generation = 0 },
|
||||
func(r *CaptureRequest) { r.Kind = "unknown" },
|
||||
func(r *CaptureRequest) { r.Bounds = image.Rectangle{} },
|
||||
func(r *CaptureRequest) { r.Bounds.Min.X = math.MinInt32 },
|
||||
func(r *CaptureRequest) { r.Bounds = image.Rect(0, 0, MaxPixels+1, 1) },
|
||||
func(r *CaptureRequest) { r.Window = 42 },
|
||||
func(r *CaptureRequest) { r.Kind = WindowSource },
|
||||
}
|
||||
for _, mutate := range mutations {
|
||||
request := base
|
||||
mutate(&request)
|
||||
if !errors.Is(request.Validate(), ErrSource) {
|
||||
t.Fatalf("invalid request accepted: %+v", request)
|
||||
}
|
||||
}
|
||||
for _, kind := range []SourceKind{WindowSource, DesktopSource, ClipboardSource} {
|
||||
request := base
|
||||
request.Kind, request.Bounds = kind, image.Rectangle{}
|
||||
if err := request.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
data, _ := json.Marshal(base)
|
||||
request, err := ReadCaptureRequest(bytes.NewReader(data))
|
||||
if err != nil || request != base {
|
||||
t.Fatalf("request roundtrip: %+v, %v", request, err)
|
||||
}
|
||||
for _, invalid := range [][]byte{nil, []byte("{}"), append(append([]byte(nil), data...), data...), []byte(`{"Private":"generic"}`), bytes.Repeat([]byte{' '}, maxCaptureHeader+1)} {
|
||||
if _, err := ReadCaptureRequest(bytes.NewReader(invalid)); err == nil {
|
||||
t.Fatal("invalid private request accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureFrameClippingAndGeneration(t *testing.T) {
|
||||
request := capture_request()
|
||||
header := CaptureHeader{Request: request, SourceWindow: request.Foreground,
|
||||
RootBounds: image.Rect(0, 0, 100, 100), SourceBounds: image.Rect(-10, 0, 80, 80), ScreenBounds: image.Rect(0, 10, 30, 50)}
|
||||
pixels := image.NewRGBA(image.Rect(0, 0, 30, 40))
|
||||
pixels.Set(1, 2, color.RGBA{12, 34, 56, 255})
|
||||
frame := capture_frame(t, header, pixels)
|
||||
result, err := decode_capture(frame, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if color.RGBAModel.Convert(result.Pixels.At(1, 2)) != pixels.At(1, 2) {
|
||||
t.Fatal("pixels changed")
|
||||
}
|
||||
if _, err := decode_capture(append(append([]byte(nil), frame...), 'x'), request); !errors.Is(err, ErrCapture) {
|
||||
t.Fatalf("trailing payload accepted: %v", err)
|
||||
}
|
||||
mapped, ok := result.Transform.ScreenRectangle(image.Rect(1, 2, 3, 4))
|
||||
if !ok || mapped != image.Rect(1, 12, 3, 14) {
|
||||
t.Fatalf("mapping: %v, %v", mapped, ok)
|
||||
}
|
||||
request.Generation++
|
||||
if _, err := decode_capture(frame, request); !errors.Is(err, ErrCapture) {
|
||||
t.Fatalf("old generation accepted: %v", err)
|
||||
}
|
||||
request.Generation--
|
||||
for _, change := range []func(*CaptureHeader){
|
||||
func(h *CaptureHeader) { h.ScreenBounds.Min.X = -1 },
|
||||
func(h *CaptureHeader) { h.SourceBounds = image.Rect(50, 50, 80, 80) },
|
||||
func(h *CaptureHeader) { h.SourceWindow++ },
|
||||
func(h *CaptureHeader) { h.ScreenBounds.Max.X++ },
|
||||
} {
|
||||
bad := header
|
||||
change(&bad)
|
||||
if _, err := decode_capture(capture_frame(t, bad, pixels), request); err == nil {
|
||||
t.Fatalf("invalid mapping accepted: %+v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureClipboardDoesNotMapToScreen(t *testing.T) {
|
||||
request := CaptureRequest{Kind: ClipboardSource, Foreground: 123, Generation: 1}
|
||||
header := CaptureHeader{Request: request}
|
||||
pixels := image.NewRGBA(image.Rect(0, 0, 10, 10))
|
||||
result, err := decode_capture(capture_frame(t, header, pixels), request)
|
||||
if err != nil || result.Transform.HasScreenMapping {
|
||||
t.Fatalf("clipboard: %+v, %v", result, err)
|
||||
}
|
||||
header.ScreenBounds = image.Rect(0, 0, 10, 10)
|
||||
if _, err := decode_capture(capture_frame(t, header, pixels), request); err == nil {
|
||||
t.Fatal("clipboard gained coordinates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureProtocolErrorsAndPixelLimit(t *testing.T) {
|
||||
request := capture_request()
|
||||
for status, want := range map[string]error{"unavailable": ErrCapture, "source": ErrSource, "changed": ErrSourceChanged, "limit": ErrLimit, "Private diagnostics": ErrCapture} {
|
||||
frame := capture_frame(t, CaptureHeader{Request: request, Status: status}, nil)
|
||||
_, err := decode_capture(frame, request)
|
||||
if !errors.Is(err, want) || strings.Contains(err.Error(), "Private") {
|
||||
t.Fatalf("status %q: %v", status, err)
|
||||
}
|
||||
if _, err := decode_capture(append(frame, 'x'), request); !errors.Is(err, ErrCapture) {
|
||||
t.Fatal("failure with payload accepted")
|
||||
}
|
||||
}
|
||||
clipboard := CaptureRequest{Kind: ClipboardSource, Foreground: 123, Generation: 1}
|
||||
frame := capture_frame(t, CaptureHeader{Request: clipboard}, image.NewRGBA(image.Rect(0, 0, 1, 1)))
|
||||
start := bytes.IndexByte(frame, '\n') + 1
|
||||
// A valid CRC and malicious IHDR are rejected before allocating pixel data.
|
||||
binary.BigEndian.PutUint32(frame[start+16:start+20], 10000)
|
||||
binary.BigEndian.PutUint32(frame[start+20:start+24], 10000)
|
||||
binary.BigEndian.PutUint32(frame[start+29:start+33], crc32.ChecksumIEEE(frame[start+12:start+29]))
|
||||
if _, err := decode_capture(frame, clipboard); !errors.Is(err, ErrLimit) {
|
||||
t.Fatalf("oversize PNG: %v", err)
|
||||
}
|
||||
for _, invalid := range [][]byte{nil, []byte("{}\nnot a png"), bytes.Repeat([]byte{'x'}, maxCaptureHeader+1)} {
|
||||
if _, err := decode_capture(invalid, request); !errors.Is(err, ErrCapture) {
|
||||
t.Fatalf("invalid response: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturerMissingHelperAndCancellation(t *testing.T) {
|
||||
request := capture_request()
|
||||
if _, err := NewCapturer("/nonexistent-ocr-capture-helper").Capture(context.Background(), request); !errors.Is(err, ErrCapture) {
|
||||
t.Fatalf("missing helper: %v", err)
|
||||
}
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
readyRead, readyWrite, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer readyRead.Close()
|
||||
defer readyWrite.Close()
|
||||
capturer := NewCapturer(executable)
|
||||
capturer.command = func(ctx context.Context, args ...string) *exec.Cmd {
|
||||
command := exec.CommandContext(ctx, executable, "-test.run=^TestCaptureChildProcess$", "--", args[0])
|
||||
command.Env = append(os.Environ(), "CTHULHU_CAPTURE_TEST_CHILD=1")
|
||||
command.ExtraFiles = []*os.File{readyWrite}
|
||||
return command
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
completed := make(chan error, 1)
|
||||
go func() { _, err := capturer.Capture(ctx, request); completed <- err }()
|
||||
_ = readyRead.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
data := make([]byte, 64)
|
||||
count, err := readyRead.Read(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data[:count])))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitContext, waitCancel := context.WithCancel(context.Background())
|
||||
waitCancel()
|
||||
if _, err := capturer.Capture(waitContext, request); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("waiting cancellation: %v", err)
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case err := <-completed:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("running cancellation: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("capture did not join cancelled helper")
|
||||
}
|
||||
if err := syscall.Kill(pid, 0); !errors.Is(err, syscall.ESRCH) {
|
||||
t.Fatalf("helper still alive or unreaped: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureChildProcess(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_CAPTURE_TEST_CHILD") != "1" {
|
||||
return
|
||||
}
|
||||
if _, err := io.ReadAll(os.Stdin); err != nil {
|
||||
os.Exit(20)
|
||||
}
|
||||
ready := os.NewFile(3, "ready")
|
||||
_, _ = ready.WriteString(strconv.Itoa(os.Getpid()) + "\n")
|
||||
_ = ready.Close()
|
||||
time.Sleep(time.Hour)
|
||||
os.Exit(0)
|
||||
}
|
||||
Reference in new issue
Block a user