299 lines
10 KiB
Go
299 lines
10 KiB
Go
// Fenrir console screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify it
|
|
// under the terms of the GNU General Public License as published by the Free
|
|
// Software Foundation, either version 3 of the License, or (at your option)
|
|
// any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
// more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License along with
|
|
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
|
|
//go:build linux && cgo
|
|
|
|
package console
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestSourceWaitsForConsoleOwner(t *testing.T) {
|
|
for _, terminal := range []int{2, 15} {
|
|
t.Run(fmt.Sprint(terminal), func(t *testing.T) {
|
|
root := t.TempDir()
|
|
activePath := filepath.Join(root, "sys/devices/virtual/tty/tty0/active")
|
|
if err := os.MkdirAll(filepath.Dir(activePath), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(activePath, fmt.Appendf(nil, "tty%d\n", terminal), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Other occupied consoles (including tty1) must not make this one
|
|
// appear occupied. There are deliberately no target device files yet.
|
|
mask := uint16(3)
|
|
stateCalls := 0
|
|
source := &Source{
|
|
root: root,
|
|
usage: func(path string) (uint16, error) {
|
|
if path != filepath.Join(root, "dev/tty1") {
|
|
t.Fatalf("usage query opened target or foreground alias: %s", path)
|
|
}
|
|
return mask, nil
|
|
},
|
|
state: func(path string) (bool, bool, bool, error) {
|
|
stateCalls++
|
|
if mask&(uint16(1)<<uint(terminal)) == 0 {
|
|
t.Fatal("opened unused console")
|
|
}
|
|
if path != filepath.Join(root, "dev", fmt.Sprintf("tty%d", terminal)) {
|
|
t.Fatal("queried wrong console", path)
|
|
}
|
|
return true, true, true, nil
|
|
},
|
|
}
|
|
for range 2 {
|
|
if number, active, _, err := source.State(); err != nil || active || number != terminal {
|
|
t.Fatal("unused console accepted input", number, active, err)
|
|
}
|
|
if snapshot, err := source.Read(); err != nil || snapshot.Active || snapshot.Terminal != terminal || len(snapshot.Cells) != 0 {
|
|
t.Fatal("unused console read screen devices", snapshot, err)
|
|
}
|
|
}
|
|
if stateCalls != 0 {
|
|
t.Fatal("probed unused console", stateCalls)
|
|
}
|
|
if err := os.Mkdir(filepath.Join(root, "dev"), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
text := "login: "
|
|
attributes := append([]byte{1, byte(len(text)), 0, 0}, make([]byte, len(text)*2)...)
|
|
if err := os.WriteFile(filepath.Join(root, "dev", fmt.Sprintf("vcsa%d", terminal)), attributes, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var data []byte
|
|
for _, value := range text {
|
|
data = binary.NativeEndian.AppendUint32(data, uint32(value))
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "dev", fmt.Sprintf("vcsu%d", terminal)), data, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A login prompt is enough; there is no authenticated user/session.
|
|
mask |= uint16(1) << uint(terminal)
|
|
if number, active, numLock, err := source.State(); err != nil || !active || !numLock || number != terminal {
|
|
t.Fatal("login prompt did not activate input", number, active, numLock, err)
|
|
}
|
|
if snapshot, err := source.Read(); err != nil || !snapshot.Active || string(snapshot.Cells) != text || !snapshot.NumLock || !snapshot.CapsLock {
|
|
t.Fatal("login prompt was not readable", snapshot, err)
|
|
}
|
|
mask = 3
|
|
if _, active, _, err := source.State(); err != nil || active {
|
|
t.Fatal("console owner disappeared but input stayed active", active, err)
|
|
}
|
|
problem := errors.New("console usage unavailable")
|
|
source.usage = func(string) (uint16, error) { return 0, problem }
|
|
if _, active, _, err := source.State(); !errors.Is(err, problem) || active {
|
|
t.Fatal("usage failure permitted a target probe", active, err)
|
|
}
|
|
source.Configure_ignored_vts([]int{terminal})
|
|
if _, active, _, err := source.State(); err != nil || active {
|
|
t.Fatal("ignored console queried usage", active, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSourcePreservesBootAndHighConsoleProbes(t *testing.T) {
|
|
for _, terminal := range []int{1, 16, 63} {
|
|
t.Run(fmt.Sprint(terminal), func(t *testing.T) {
|
|
root := t.TempDir()
|
|
activePath := filepath.Join(root, "sys/devices/virtual/tty/tty0/active")
|
|
if err := os.MkdirAll(filepath.Dir(activePath), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(activePath, fmt.Appendf(nil, "tty%d\n", terminal), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
source := &Source{
|
|
root: root,
|
|
usage: func(string) (uint16, error) {
|
|
t.Fatal("boot/high console gated on truncated bitmap")
|
|
return 0, nil
|
|
},
|
|
state: func(path string) (bool, bool, bool, error) {
|
|
if path != filepath.Join(root, "dev", fmt.Sprintf("tty%d", terminal)) {
|
|
t.Fatal("queried wrong console", path)
|
|
}
|
|
return true, false, false, nil
|
|
},
|
|
}
|
|
if number, active, _, err := source.State(); err != nil || !active || number != terminal {
|
|
t.Fatal("boot/high console lost input", number, active, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSourceRetriesUnicodeSizeChange(t *testing.T) {
|
|
for _, cols := range []int{1, 3} {
|
|
t.Run(fmt.Sprint(cols), func(t *testing.T) {
|
|
root := t.TempDir()
|
|
write := func(path string, data []byte) {
|
|
t.Helper()
|
|
if strings.HasPrefix(path, "dev/vcsa") && len(data) == 4 {
|
|
data = append(data, make([]byte, int(data[0])*int(data[1])*2)...)
|
|
}
|
|
path = filepath.Join(root, path)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write("sys/devices/virtual/tty/tty0/active", []byte("tty2\n"))
|
|
write("dev/vcsa2", []byte{1, 2, 0, 0})
|
|
write("dev/vcsu2", make([]byte, cols*4))
|
|
calls := 0
|
|
source := &Source{root: root, state: func(string) (bool, bool, bool, error) {
|
|
calls++
|
|
if calls == 2 {
|
|
write("dev/vcsa2", []byte{1, byte(cols), 0, 0})
|
|
}
|
|
return true, false, false, nil
|
|
}}
|
|
snapshot, err := source.Read()
|
|
if err != nil || snapshot.Cols != cols || calls != 3 {
|
|
t.Fatalf("resize retry: cols=%d calls=%d error=%v", snapshot.Cols, calls, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSourceUsesActiveVTAndSkipsGraphics(t *testing.T) {
|
|
root := t.TempDir()
|
|
write := func(path string, data []byte) {
|
|
t.Helper()
|
|
if strings.HasPrefix(path, "dev/vcsa") && len(data) == 4 {
|
|
data = append(data, make([]byte, int(data[0])*int(data[1])*2)...)
|
|
}
|
|
path = filepath.Join(root, path)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
write("sys/devices/virtual/tty/tty0/active", []byte("tty2\n"))
|
|
write("dev/vcsa2", []byte{1, 2, 0, 0})
|
|
data := make([]byte, 8)
|
|
binary.NativeEndian.PutUint32(data, '界')
|
|
binary.NativeEndian.PutUint32(data[4:], 'a')
|
|
write("dev/vcsu2", data)
|
|
source := &Source{root: root, state: func(path string) (bool, bool, bool, error) {
|
|
if filepath.Base(path) != "tty2" {
|
|
t.Fatalf("wrong tty %s", path)
|
|
}
|
|
return true, true, true, nil
|
|
}}
|
|
snapshot, err := source.Read()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if snapshot.Terminal != 2 || !snapshot.NumLock || !snapshot.CapsLock || string(snapshot.Cells) != "界a" {
|
|
t.Fatalf("wrong snapshot: %+v", snapshot)
|
|
}
|
|
if len(snapshot.Attributes) != 2 {
|
|
t.Fatal("source dropped attributes", snapshot.Attributes)
|
|
}
|
|
allowedState := source.state
|
|
source.Configure_ignored_vts([]int{2, 7, 8, 10})
|
|
source.state = func(string) (bool, bool, bool, error) {
|
|
t.Fatal("queried ignored console")
|
|
return true, false, false, nil
|
|
}
|
|
if number, active, _, err := source.State(); err != nil || number != 2 || active {
|
|
t.Fatal("ignored VT still accepts input", number, active, err)
|
|
}
|
|
snapshot, err = source.Read()
|
|
if err != nil || snapshot.Terminal != 2 || snapshot.Active || len(snapshot.Cells) != 0 || len(snapshot.Attributes) != 0 {
|
|
t.Fatal("ignored VT retained screen data", snapshot, err)
|
|
}
|
|
source.Configure_ignored_vts(nil)
|
|
source.state = allowedState
|
|
snapshot, err = source.Read()
|
|
if err != nil || !snapshot.Active || string(snapshot.Cells) != "界a" || !snapshot.NumLock || !snapshot.CapsLock {
|
|
t.Fatal("cleared policy did not restore console", snapshot, err)
|
|
}
|
|
source.state = func(string) (bool, bool, bool, error) { return false, false, false, nil }
|
|
if err := os.Remove(filepath.Join(root, "dev/vcsu2")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snapshot, err = source.Read()
|
|
if err != nil || snapshot.Active || len(snapshot.Cells) != 0 {
|
|
t.Fatalf("read graphical console text: %+v, %v", snapshot, err)
|
|
}
|
|
write("sys/devices/virtual/tty/tty0/active", []byte("../../unsafe"))
|
|
if _, err := source.Read(); err == nil {
|
|
t.Fatal("accepted invalid VT name")
|
|
}
|
|
}
|
|
|
|
func TestSourceRetriesResizeAndVTChange(t *testing.T) {
|
|
root := t.TempDir()
|
|
active := filepath.Join(root, "sys/devices/virtual/tty/tty0/active")
|
|
if err := os.MkdirAll(filepath.Dir(active), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(root, "dev"), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(active, []byte("tty2\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, name := range []string{"vcsa2", "vcsa3"} {
|
|
if err := os.WriteFile(filepath.Join(root, "dev", name), []byte{1, 1, 0, 0, 0, 7}, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, name := range []string{"vcsu2", "vcsu3"} {
|
|
data := make([]byte, 4)
|
|
binary.NativeEndian.PutUint32(data, 'a')
|
|
if err := os.WriteFile(filepath.Join(root, "dev", name), data, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
calls := 0
|
|
source := &Source{root: root, state: func(string) (bool, bool, bool, error) {
|
|
calls++
|
|
if calls == 1 {
|
|
if err := os.WriteFile(active, []byte("tty3\n"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return true, false, false, nil
|
|
}}
|
|
snapshot, err := source.Read()
|
|
if err != nil || snapshot.Terminal != 3 || calls < 4 {
|
|
t.Fatalf("did not retry switch: %+v, %v, %d calls", snapshot, err, calls)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "dev/vcsu3"), []byte{}, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := source.Read(); err == nil {
|
|
t.Fatal("accepted inconsistent geometry")
|
|
}
|
|
}
|