// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // 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 . package config import ( "errors" "fmt" "math" "path/filepath" "strings" "unicode" "git.stormux.org/storm/cthulhu/internal/datetime" "git.stormux.org/storm/cthulhu/internal/input" ) // Validate checks the complete configuration without touching the filesystem. func Validate(settings Settings) error { return validate(settings) } func validate(settings Settings) error { if settings.OCR.Layout != 3 && settings.OCR.Layout != 6 && settings.OCR.Layout != 11 || settings.OCR.Scale < 1 || settings.OCR.Scale > 4 || settings.OCR.Threshold < 0 || settings.OCR.Threshold > 255 || settings.OCR.RefreshInterval < 500 || settings.OCR.RefreshInterval > 60000 || len(settings.OCR.Language) > 1024 || strings.ContainsAny(settings.OCR.Language, "\x00\r\n\t ") { return errors.New("validate configuration: invalid OCR settings") } if settings.Braille.Verbosity < 0 || settings.Braille.Verbosity > 1 || settings.Braille.FlashTime < 0 || settings.Braille.FlashTime > 60000 { return errors.New("validate configuration: invalid braille settings") } if len(settings.Pronunciations) > 256 { return errors.New("too many pronunciation entries") } words := make(map[string]bool, len(settings.Pronunciations)) for _, entry := range settings.Pronunciations { word := strings.ToLower(entry.Word) if word == "" || strings.TrimSpace(entry.Word) != entry.Word || len(entry.Word) > 128 || len(entry.Replacement) > 512 || strings.ContainsAny(entry.Word, "\x00\r\n\t ") || strings.ContainsAny(entry.Replacement, "\x00\r\n") || words[word] { return errors.New("pronunciation entries need unique, single words and valid replacements") } words[word] = true for _, character := range entry.Word { if !unicode.IsLetter(character) && !unicode.IsNumber(character) && character != '_' { return errors.New("pronunciation words may contain only letters, numbers, or underscores") } } } if settings.TextAttributes.Indicator != 0 && settings.TextAttributes.Indicator != 0x40 && settings.TextAttributes.Indicator != 0x80 && settings.TextAttributes.Indicator != 0xc0 { return errors.New("invalid text attribute braille indicator") } if len(settings.TextAttributes.Choices) != len(TextAttributeKeys) { return errors.New("text attributes list must include every supported attribute") } allowed, seen := make(map[string]bool), make(map[string]bool) for _, key := range TextAttributeKeys { allowed[key] = true } for _, choice := range settings.TextAttributes.Choices { if !allowed[choice.Key] || seen[choice.Key] { return errors.New("invalid or duplicate text attribute") } seen[choice.Key] = true } for _, indicator := range []int{settings.Braille.SelectionIndicator, settings.Braille.LinkIndicator} { if indicator != 0 && indicator != 0x40 && indicator != 0x80 && indicator != 0xc0 { return errors.New("validate configuration: invalid braille indicator") } } if len(settings.BrailleContractionTable) > 256 || strings.ContainsAny(settings.BrailleContractionTable, "\x00\r\n,") { return errors.New("validate configuration: invalid braille contraction table") } if !datetime.ValidTime(settings.TimeFormat) { return errors.New("choose a valid time format") } if !datetime.ValidDate(settings.DateFormat) { return errors.New("choose a valid date format") } if settings.SoundVolume < 0 || settings.SoundVolume > 1 || math.IsNaN(settings.SoundVolume) || math.IsInf(settings.SoundVolume, 0) { return errors.New("validate configuration: soundVolume must be between 0 and 1") } switch settings.SoundSink { case "auto", "pipewire", "pulse", "alsa": default: return fmt.Errorf("validate configuration: unknown soundSink %q", settings.SoundSink) } if settings.SoundTheme == "" || filepath.Base(settings.SoundTheme) != settings.SoundTheme || settings.SoundTheme == "." || settings.SoundTheme == ".." { return errors.New("validate configuration: invalid soundTheme") } switch settings.ProgressBar.OutputMode { case "off", "beep", "speak", "both": default: return errors.New("choose a valid progress bar output mode") } if settings.ProgressBar.BeepPercentageInterval < 1 || settings.ProgressBar.BeepPercentageInterval > 100 || settings.ProgressBar.SpeechPercentageInterval < 1 || settings.ProgressBar.SpeechPercentageInterval > 100 { return errors.New("progress bar percentage intervals must be between 1 and 100") } switch settings.RoleSoundPresentation { case "sound_and_speech", "speech_only", "sound_only": default: return errors.New("validate configuration: invalid roleSoundPresentation") } if settings.SpeechServerFactory != "speechdispatcherfactory" && settings.SpeechServerFactory != "cthulhu.speechdispatcherfactory" && settings.SpeechServerFactory != "hardwarefactory" { return fmt.Errorf( "validate configuration: speechServerFactory %q is not supported by the preview", settings.SpeechServerFactory, ) } if len(settings.HardwareSpeechDevice) > 1024 || strings.ContainsAny(settings.HardwareSpeechDevice, "\x00\r\n") { return errors.New("invalid hardware speech device") } switch settings.HardwareSpeechBaudRate { case 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200: default: return errors.New("choose a supported hardware speech baud rate") } if settings.SpeechServerFactory == "hardwarefactory" { if settings.UseCustomEchoVoice && settings.UseCustomEchoSpeechServer { return errors.New("a separate echo synthesizer requires Speech Dispatcher; disable that option for hardware speech") } switch settings.SpeechOutputModule { case "litetalk", "doubletalk", "tripletalk", "dectalk": default: return errors.New("choose a hardware speech synthesizer") } if !filepath.IsAbs(settings.HardwareSpeechDevice) { return errors.New("choose an explicit serial device path for hardware speech") } } switch settings.SpeechVerbosityLevel { case 0, 1: default: return fmt.Errorf("validate configuration: speechVerbosityLevel %d is not 0 or 1", settings.SpeechVerbosityLevel) } if settings.SayAllStyle != "line" && settings.SayAllStyle != "sentence" { return fmt.Errorf("validate configuration: say-all-style %q is invalid", settings.SayAllStyle) } if settings.SpeechHistoryLimit < MinSpeechHistoryLimit || settings.SpeechHistoryLimit > MaxSpeechHistoryLimit { return fmt.Errorf("validate configuration: speech history limit %d must be between %d and %d", settings.SpeechHistoryLimit, MinSpeechHistoryLimit, MaxSpeechHistoryLimit) } if settings.VerbalizePunctuationStyle < 0 || settings.VerbalizePunctuationStyle > 3 { return fmt.Errorf( "validate configuration: verbalizePunctuationStyle %d is outside 0 through 3", settings.VerbalizePunctuationStyle, ) } switch settings.CapitalizationStyle { case "none", "spell", "icon": default: return fmt.Errorf("validate configuration: capitalizationStyle %q is invalid", settings.CapitalizationStyle) } if err := input.ValidateBindingOverrides(settings.KeyBindings); err != nil { return fmt.Errorf("key bindings: %w", err) } if settings.KeyboardLayout != 1 && settings.KeyboardLayout != 2 { return fmt.Errorf("validate configuration: keyboardLayout %d is not 1 or 2", settings.KeyboardLayout) } for _, key := range settings.CthulhuModifierKeys { if err := validateCommandValue("cthulhuModifierKeys", key); err != nil { return err } } if strings.ContainsAny(settings.SpeechOutputModule, "\r\n\x00") { return errors.New("invalid speech output module") } if strings.ContainsAny(settings.EchoSpeechServer, "\r\n\x00") { return fmt.Errorf("invalid echo speech synthesizer") } if err := validateVoice(settings.EchoVoice, true); err != nil { return fmt.Errorf("echo voice: %w", err) } if err := validateVoice(settings.DefaultVoice); err != nil { return err } for _, voice := range []Voice{settings.UppercaseVoice, settings.HyperlinkVoice, settings.SystemVoice} { if err := validateVoice(voice); err != nil { return err } } switch settings.Reading.IndentationPresentationMode { case "", "speech", "beeps", "both": default: return errors.New("invalid indentation presentation mode") } return nil } func validateVoice(voice Voice, echo ...bool) error { values := []struct { name string value *float64 maximum float64 }{ {name: "rate", value: voice.Rate, maximum: 99}, {name: "average-pitch", value: voice.AveragePitch, maximum: 9}, {name: "gain", value: voice.Gain, maximum: 9}, } if len(echo) != 0 && echo[0] { values[0].maximum = 100 values[1].maximum = 10 values[2].maximum = 10 } for _, field := range values { name, value, maximum := field.name, field.value, field.maximum if value == nil { continue } if *value < 0 || *value > maximum || math.IsNaN(*value) || math.IsInf(*value, 0) { return fmt.Errorf("validate configuration: default voice %s %.2f is outside 0 through %.0f", name, *value, maximum) } } if voice.Family == nil { return nil } familyValues := []struct { name string value string }{ {name: "name", value: voice.Family.Name}, {name: "lang", value: voice.Family.Lang}, {name: "dialect", value: voice.Family.Dialect}, {name: "variant", value: voice.Family.Variant}, } for _, field := range familyValues { if err := validateCommandValue("default voice family "+field.name, field.value); err != nil { return err } } return nil } func validateCommandValue(name, value string) error { if strings.ContainsAny(value, "\r\n\x00") { return fmt.Errorf("validate configuration: %s contains a control character", name) } return nil }