Files
fenrir/internal/console/source_linux_test.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")
}
}