Export document reading with Remote lease safeguards
This commit is contained in:
1 parent
228271b43b
commit
622d2f96a1
11 files changed
+299
-17
No files matched your search
@@ -220,7 +220,7 @@ func presentDocumentCaretResult(logger *slog.Logger, speech speechEnqueuer, resu
|
||||
}
|
||||
text = "blank"
|
||||
}
|
||||
request := speechRequest{Text: text, Role: result.DocumentPosition.Snapshot.Role, Cause: "document-caret"}
|
||||
request := speechRequest{origin: document_speech_origin(result.DocumentPosition.Snapshot, result.DocumentCaretContext), Text: text, Role: result.DocumentPosition.Snapshot.Role, Cause: "document-caret"}
|
||||
request.PhoneticHint = result.DocumentCaretCommand == review.CaretNextCharacter || result.DocumentCaretCommand == review.CaretPreviousCharacter
|
||||
request.Context = result.DocumentCaretContext
|
||||
if len(request.Context) > 0 && request.Context[0].ID() == result.DocumentPosition.Snapshot.ID() {
|
||||
@@ -333,6 +333,7 @@ func presentDocumentResult(logger *slog.Logger, speech speechEnqueuer, result re
|
||||
"braille_read_error", result.DocumentBrailleErr)
|
||||
}
|
||||
text, fallbackText, role := "", "", "system"
|
||||
origin := speechOriginUnknown
|
||||
var spoken, fallbackSpeech presentation.Speech
|
||||
var cues []sound.Cue
|
||||
switch {
|
||||
@@ -393,6 +394,10 @@ func presentDocumentResult(logger *slog.Logger, speech speechEnqueuer, result re
|
||||
text = "No more large objects."
|
||||
}
|
||||
default:
|
||||
origin = document_speech_origin(result.DocumentPosition.Snapshot, result.DocumentCaretContext)
|
||||
if link := result.DocumentPosition.LinkedHeading; link != nil && accessibility_speech_origin(*link) == speechOriginSecret {
|
||||
origin = speechOriginSecret
|
||||
}
|
||||
if link := result.DocumentPosition.LinkedHeading; link != nil {
|
||||
roleOnly := *link
|
||||
roleOnly.Name, roleOnly.Text = "", ""
|
||||
@@ -462,5 +467,5 @@ func presentDocumentResult(logger *slog.Logger, speech speechEnqueuer, result re
|
||||
if len(ancestors) > 0 && ancestors[0].ID() == result.DocumentPosition.Snapshot.ID() {
|
||||
ancestors = ancestors[1:]
|
||||
}
|
||||
speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, FallbackText: fallbackText, FallbackChunks: fallbackSpeech.WithText(fallbackText).Chunks, Role: role, Sound: cues, Cause: "document-navigation", Context: ancestors, VoiceType: voiceType})
|
||||
speech.Enqueue(speechRequest{origin: origin, Text: text, Chunks: spoken.WithText(text).Chunks, FallbackText: fallbackText, FallbackChunks: fallbackSpeech.WithText(fallbackText).Chunks, Role: role, Sound: cues, Cause: "document-navigation", Context: ancestors, VoiceType: voiceType})
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func (handler *eventReviewHandler) handle(result review.Result) {
|
||||
if text == "" && len(cues) == 0 {
|
||||
return
|
||||
}
|
||||
speech.Enqueue(speechRequest{Text: text, Chunks: spoken.Chunks, FallbackText: fallback.Text, FallbackChunks: fallback.Chunks, Sound: cues, Role: "text", Cause: "document-caret-line", Line: document_speech_line(result.DocumentLineData), Context: result.DocumentLineData.Context, VoiceType: document_line_voice(result.DocumentLineData)})
|
||||
speech.Enqueue(speechRequest{origin: document_line_speech_origin(result.DocumentLineData), Text: text, Chunks: spoken.Chunks, FallbackText: fallback.Text, FallbackChunks: fallback.Chunks, Sound: cues, Role: "text", Cause: "document-caret-line", Line: document_speech_line(result.DocumentLineData), Context: result.DocumentLineData.Context, VoiceType: document_line_voice(result.DocumentLineData)})
|
||||
return
|
||||
}
|
||||
if result.DocumentTarget != 0 || result.DocumentCaretCommand != 0 || result.DocumentFragment {
|
||||
|
||||
@@ -19,11 +19,13 @@ func presentReadAllResult(logger *slog.Logger, speech speechEnqueuer, result rev
|
||||
return
|
||||
}
|
||||
text := ""
|
||||
origin := speechOriginUnknown
|
||||
var parts []readAllPart
|
||||
if result.Err != nil {
|
||||
logger.Warn("read all failed", "error", result.Err)
|
||||
text = "Could not read from current location."
|
||||
} else {
|
||||
origin = speechOriginLocal
|
||||
// Keep semantic roles audible in continuous reading, without firing all
|
||||
// the page's sound cues before the speech has reached those objects.
|
||||
options.RoleSoundPresentation = "sound_and_speech"
|
||||
@@ -38,6 +40,12 @@ func presentReadAllResult(logger *slog.Logger, speech speechEnqueuer, result rev
|
||||
options.ReadFullRowInGUITable = false
|
||||
for index, line := range result.ReadAllLines {
|
||||
if spoken := presentation.DocumentLineSpeechWithOptions(line, options); spoken.Text != "" {
|
||||
// One request owns the original observation ticket. Conservatively
|
||||
// keep the entire reading private if any spoken line is sensitive
|
||||
// or lacks source metadata, including sentence-mode continuations.
|
||||
if source := document_line_speech_origin(line); source != speechOriginLocal {
|
||||
origin = source
|
||||
}
|
||||
var began func()
|
||||
if result.ReadAllProgress != nil {
|
||||
began = func() { result.ReadAllProgress(index) }
|
||||
@@ -61,7 +69,7 @@ func presentReadAllResult(logger *slog.Logger, speech speechEnqueuer, result rev
|
||||
}
|
||||
}
|
||||
if text != "" || len(parts) != 0 {
|
||||
speech.Enqueue(speechRequest{Text: text, ReadAll: parts, Role: "system", Cause: "read-all", context: result.ReadAllContext})
|
||||
speech.Enqueue(speechRequest{origin: origin, Text: text, ReadAll: parts, Role: "system", Cause: "read-all", context: result.ReadAllContext})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
|
||||
delivery := request
|
||||
delivery.VoiceType, delivery.CustomEcho = part.VoiceType, part.VoiceType == "echo"
|
||||
worker.logDelivery(ctx, delivery, text, index)
|
||||
worker.observe_speech(ctx, delivery, text, false)
|
||||
if worker.history != nil && !request.ExcludeFromHistory && request.Cause != "speech-history" {
|
||||
worker.history.add(text, request.Role)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/presentation"
|
||||
"git.stormux.org/storm/cthulhu/internal/review"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
||||
)
|
||||
|
||||
func document_observation_request(t *testing.T, kind string, result review.Result) speechRequest {
|
||||
t.Helper()
|
||||
queue := &fakeSpeechQueue{requests: make(chan speechRequest, 1), spoken: make(chan string, 1)}
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
options := presentation.DefaultOptions()
|
||||
options.SayAllStyle = "sentence"
|
||||
switch kind {
|
||||
case "caret":
|
||||
presentDocumentCaretResult(logger, queue, result, options)
|
||||
case "navigation":
|
||||
presentDocumentResult(logger, queue, result, options)
|
||||
case "read-all":
|
||||
presentReadAllResult(logger, queue, result, options)
|
||||
}
|
||||
select {
|
||||
case request := <-queue.requests:
|
||||
return request
|
||||
default:
|
||||
t.Fatal("document producer did not enqueue speech")
|
||||
return speechRequest{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteDocumentSpeechSourcePrivacy(t *testing.T) {
|
||||
for _, kind := range []string{"caret", "navigation", "read-all"} {
|
||||
for _, source := range []string{"ordinary", "password", "ancestor", "snapshot-ancestor"} {
|
||||
t.Run(kind+"/"+source, func(t *testing.T) {
|
||||
snapshot := atspi.Snapshot{Path: "/text", Role: "text", Name: "Reading. Next sentence."}
|
||||
ancestors := []atspi.Snapshot{}
|
||||
switch source {
|
||||
case "password":
|
||||
snapshot.Role = "password text"
|
||||
case "ancestor":
|
||||
// Same identity is removed from caret/navigation context.
|
||||
ancestors = []atspi.Snapshot{{Path: "/text", Role: "password text"}}
|
||||
case "snapshot-ancestor":
|
||||
snapshot.SpeechContext = []atspi.Snapshot{{Role: "password text"}}
|
||||
}
|
||||
result := review.Result{Moved: true, DocumentCaretText: "Reading. Next sentence.",
|
||||
DocumentCaretCommand: review.CaretNextLine, DocumentCaretContext: ancestors,
|
||||
DocumentPosition: review.DocumentPosition{Snapshot: snapshot}, ReadAllContext: t.Context(),
|
||||
ReadAllLines: []atspi.DocumentLine{{Context: ancestors, Contents: []atspi.DocumentContent{{Snapshot: snapshot, Text: "Reading. Next sentence."}}}}}
|
||||
request := document_observation_request(t, kind, result)
|
||||
if exportable_speech(request) != (source == "ordinary") {
|
||||
t.Fatalf("source %s produced exportable=%v", source, exportable_speech(request))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, line := range []atspi.DocumentLine{
|
||||
{},
|
||||
{Contents: []atspi.DocumentContent{{Text: "text without source metadata"}}},
|
||||
{Contents: []atspi.DocumentContent{{Snapshot: atspi.Snapshot{Role: "text"}}, {Snapshot: atspi.Snapshot{Role: "password text"}}}},
|
||||
} {
|
||||
if document_line_speech_origin(line) == speechOriginLocal {
|
||||
t.Fatal("unclassified or mixed private line was exportable")
|
||||
}
|
||||
}
|
||||
for _, kind := range []string{"caret", "navigation", "read-all"} {
|
||||
request := document_observation_request(t, kind, review.Result{Err: errors.New("fixture failure"), ReadAllContext: t.Context()})
|
||||
if exportable_speech(request) {
|
||||
t.Fatal("local failure status was exported", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (speaker *observationSpeaker) SpeakAndWait(ctx context.Context, text string, began func()) error {
|
||||
if began != nil {
|
||||
began()
|
||||
}
|
||||
return speaker.Speak(ctx, text)
|
||||
}
|
||||
|
||||
func TestRemoteReadAllObservationResolvedChunksAndPrivateBatch(t *testing.T) {
|
||||
worker, session, _, _ := observation_fixture(t)
|
||||
worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") }
|
||||
line := atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: atspi.Snapshot{Role: "text"}, Text: "First colour. Second colour."}}}
|
||||
request := document_observation_request(t, "read-all", review.Result{ReadAllContext: t.Context(), ReadAllLines: []atspi.DocumentLine{line}})
|
||||
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"First color.", "Second color."} {
|
||||
value := observed_speech(t, session)
|
||||
if value.Kind != "speak" || len(value.Parts) != 2 || value.Parts[0].Text != want || !value.Parts[1].End {
|
||||
t.Fatal("Say All did not export resolved sentence boundaries", value)
|
||||
}
|
||||
}
|
||||
private := line
|
||||
private.Context = []atspi.Snapshot{{Role: "password text"}}
|
||||
request = document_observation_request(t, "read-all", review.Result{ReadAllContext: t.Context(), ReadAllLines: []atspi.DocumentLine{line, private, line}})
|
||||
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
worker.InterruptForUser()
|
||||
if value := observed_speech(t, session); value.Kind != "cancel" {
|
||||
t.Fatal("mixed private Say All batch was exported", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteReadAllObservationKeepsOriginalLease(t *testing.T) {
|
||||
worker, session, _, _ := observation_fixture(t)
|
||||
speaker := &continuousSpeaker{started: make(chan string, 2), finish: make(chan struct{}, 2)}
|
||||
worker.speaker = speaker // No requests have been admitted yet.
|
||||
worker.Enqueue(speechRequest{origin: speechOriginLocal, ReadAll: []readAllPart{{Text: "First"}, {Text: "Second"}}})
|
||||
select {
|
||||
case <-speaker.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Say All did not start")
|
||||
}
|
||||
if value := observed_speech(t, session); value.Parts[0].Text != "First" {
|
||||
t.Fatal("first line missing", value)
|
||||
}
|
||||
session.RevokeRemote()
|
||||
var event tentaclewire.SessionEvent
|
||||
if json.Unmarshal(observation_call(t, session, "event"), &event) != nil || event.Kind != "remote.revoked" {
|
||||
t.Fatal("missing revocation")
|
||||
}
|
||||
approval, err := session.BeginRemoteApproval("controlled", []string{"output-observe"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := session.AuthorizeRemoteWithApproval(approval, "controlled", []string{"output-observe"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
observation_call(t, session, "open")
|
||||
speaker.finish <- struct{}{}
|
||||
select {
|
||||
case text := <-speaker.started:
|
||||
if text != "Second" {
|
||||
t.Fatal("local reading changed", text)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("local reading stalled across reconnection")
|
||||
}
|
||||
speaker.finish <- struct{}{}
|
||||
if err := worker.EnqueueAndWait(t.Context(), speechRequest{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
worker.InterruptForUser()
|
||||
if value := observed_speech(t, session); value.Kind != "cancel" {
|
||||
t.Fatal("old Say All continued under replacement consent", value)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,36 @@ func accessibility_speech_origin(source atspi.Snapshot) speechOrigin {
|
||||
return speechOriginLocal
|
||||
}
|
||||
|
||||
// Document producers may remove the current object from their presentation
|
||||
// context. Inspect the original sources before that filtering, including every
|
||||
// content object in an assembled line. A mixed sensitive line stays private.
|
||||
func document_speech_origin(source atspi.Snapshot, ancestors []atspi.Snapshot) speechOrigin {
|
||||
if accessibility_speech_origin(source) == speechOriginSecret {
|
||||
return speechOriginSecret
|
||||
}
|
||||
for _, ancestor := range ancestors {
|
||||
if accessibility_speech_origin(ancestor) == speechOriginSecret {
|
||||
return speechOriginSecret
|
||||
}
|
||||
}
|
||||
if source.Role == "" {
|
||||
return speechOriginUnknown
|
||||
}
|
||||
return speechOriginLocal
|
||||
}
|
||||
|
||||
func document_line_speech_origin(line atspi.DocumentLine) speechOrigin {
|
||||
if len(line.Contents) == 0 {
|
||||
return speechOriginUnknown
|
||||
}
|
||||
for _, content := range line.Contents {
|
||||
if origin := document_speech_origin(content.Snapshot, line.Context); origin != speechOriginLocal {
|
||||
return origin
|
||||
}
|
||||
}
|
||||
return speechOriginLocal
|
||||
}
|
||||
|
||||
func exportable_speech(request speechRequest) bool {
|
||||
if request.origin != speechOriginLocal || request.Role == "password text" ||
|
||||
request.ExcludeFromHistory || request.Confirmation || request.Uninterruptible || request.ForceSpeech {
|
||||
|
||||
@@ -22,8 +22,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
"git.stormux.org/storm/cthulhu/internal/interaction"
|
||||
"git.stormux.org/storm/cthulhu/internal/review"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
||||
)
|
||||
@@ -308,7 +310,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
||||
runtime, forms, shortcut := remote_command_fixture(t)
|
||||
if role == "controlled" {
|
||||
worker := runtime.output.speech
|
||||
worker.speaker = &fakeSpeaker{spoken: make(chan string, 16)}
|
||||
worker.speaker = &observationSpeaker{fakeSpeaker{spoken: make(chan string, 16)}}
|
||||
worker.textTransform = func(text string) string { return strings.ReplaceAll(text, "colour", "color") }
|
||||
if err := worker.Start(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -438,6 +440,14 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
request := document_observation_request(t, "read-all", review.Result{
|
||||
ReadAllContext: t.Context(), ReadAllLines: []atspi.DocumentLine{{Contents: []atspi.DocumentContent{{
|
||||
Snapshot: atspi.Snapshot{Role: "text"}, Text: "First colour. Second colour.",
|
||||
}}}},
|
||||
})
|
||||
if err := worker.EnqueueAndWait(t.Context(), request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
worker.InterruptForUser()
|
||||
var received strings.Builder
|
||||
ends, frames := 0, 0
|
||||
@@ -473,7 +483,7 @@ func TestRemoteConnectionCommandPrivateRelay(t *testing.T) {
|
||||
t.Fatal("reader output did not reach the relay in order")
|
||||
}
|
||||
}
|
||||
if received.String() != "color "+remoteText || frames < 2 || ends != 1 {
|
||||
if received.String() != "color "+remoteText+"First color.Second color." || frames < 4 || ends != 3 {
|
||||
t.Fatal("observation leaked private text or lost resolved output/boundaries")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user