refactor(config): parse and watch the config with kvconf
- commit
- 5adeb5330d5e0d0d831fdd9bd0b7262538b71233
- author
- brice · · 2026-10-10 03:01 UTC
- committer
- brice · · 2026-10-10 03:02 UTC
- parent
- 31f302d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +89 -240
M go.mod +1 -0
| @@ -4,6 +4,7 @@ go 1.27 | |||
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/bnema/go-wayland-bindings v0.1.0 | |
| 7 | + | github.com/bnema/kvconf v0.1.0 | |
| 7 | 8 | github.com/bnema/neferclient v0.2.0 | |
| 8 | 9 | github.com/bnema/nefergui v0.6.1 | |
| 9 | 10 | github.com/bnema/purego-pam v0.1.0 | |
| @@ -4,6 +4,7 @@ go 1.27 | |||||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/bnema/go-wayland-bindings v0.1.0 | 6 | github.com/bnema/go-wayland-bindings v0.1.0 | ||
| 7 | + | github.com/bnema/kvconf v0.1.0 | |||
| 7 | github.com/bnema/neferclient v0.2.0 | 8 | github.com/bnema/neferclient v0.2.0 | ||
| 8 | github.com/bnema/nefergui v0.6.1 | 9 | github.com/bnema/nefergui v0.6.1 | ||
| 9 | github.com/bnema/purego-pam v0.1.0 | 10 | github.com/bnema/purego-pam v0.1.0 | ||
M internal/adapters/config/config.go +35 -75
| @@ -2,15 +2,13 @@ | |||
| 2 | 2 | package config | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | - | "bufio" | |
| 5 | + | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "io" | |
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | - | "strings" | |
| 11 | - | "time" | |
| 12 | - | "unicode/utf8" | |
| 13 | 10 | ||
| 11 | + | "github.com/bnema/kvconf" | |
| 14 | 12 | "github.com/bnema/neferafk/internal/ports" | |
| 15 | 13 | ) | |
| 16 | 14 | ||
| @@ -26,13 +24,16 @@ func DefaultPath() string { | |||
| 26 | 24 | } | |
| 27 | 25 | return filepath.Join(base, "neferafk", "config") | |
| 28 | 26 | } | |
| 27 | + | ||
| 29 | 28 | func Load(path string) (ports.Config, error) { | |
| 30 | - | f, err := os.Open(path) | |
| 29 | + | data, err := kvconf.ReadFile(path, options) | |
| 31 | 30 | if err != nil { | |
| 31 | + | if errors.Is(err, kvconf.ErrTooLarge) { | |
| 32 | + | return ports.Config{}, errTooLarge | |
| 33 | + | } | |
| 32 | 34 | return ports.Config{}, err | |
| 33 | 35 | } | |
| 34 | - | defer f.Close() | |
| 35 | - | return Parse(f) | |
| 36 | + | return parse(data) | |
| 36 | 37 | } | |
| 37 | 38 | ||
| 38 | 39 | // Parse never returns partial configuration and never resolves PIN references. | |
| @@ -41,75 +42,34 @@ func Parse(r io.Reader) (ports.Config, error) { | |||
| 41 | 42 | if err != nil { | |
| 42 | 43 | return ports.Config{}, err | |
| 43 | 44 | } | |
| 44 | - | if !utf8.Valid(data) { | |
| 45 | - | return ports.Config{}, fmt.Errorf("config is not valid UTF-8") | |
| 46 | - | } | |
| 47 | - | if len(data) > MaxBytes { | |
| 48 | - | return ports.Config{}, fmt.Errorf("config exceeds 64KiB") | |
| 49 | - | } | |
| 45 | + | return parse(data) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // options: any problem rejects the whole file; "#" starts a comment anywhere | |
| 49 | + | // and values are never unquoted. | |
| 50 | + | var options = kvconf.Options{Strict: true, Comments: kvconf.CommentAny, RawValues: true, MaxBytes: MaxBytes, MaxLine: MaxLine} | |
| 51 | + | ||
| 52 | + | var errTooLarge = errors.New("config exceeds 64KiB") | |
| 53 | + | ||
| 54 | + | func parse(data []byte) (ports.Config, error) { | |
| 50 | 55 | cfg := ports.Defaults() | |
| 51 | - | seen := map[string]bool{} | |
| 52 | - | scanner := bufio.NewScanner(strings.NewReader(string(data))) | |
| 53 | - | scanner.Buffer(make([]byte, MaxLine+1), MaxLine+2) | |
| 54 | - | line := 0 | |
| 55 | - | for scanner.Scan() { | |
| 56 | - | line++ | |
| 57 | - | text := scanner.Text() | |
| 58 | - | if len(text) > MaxLine { | |
| 59 | - | return ports.Config{}, fmt.Errorf("line %d exceeds 4KiB", line) | |
| 60 | - | } | |
| 61 | - | text = strings.TrimSpace(strings.SplitN(text, "#", 2)[0]) | |
| 62 | - | if text == "" { | |
| 63 | - | continue | |
| 64 | - | } | |
| 65 | - | key, value, ok := strings.Cut(text, "=") | |
| 66 | - | key, value = strings.TrimSpace(key), strings.TrimSpace(value) | |
| 67 | - | if !ok || value == "" || seen[key] { | |
| 68 | - | return ports.Config{}, fmt.Errorf("line %d: missing value or duplicate key", line) | |
| 69 | - | } | |
| 70 | - | seen[key] = true | |
| 71 | - | var target *time.Duration | |
| 72 | - | switch key { | |
| 73 | - | case "lock.after": | |
| 74 | - | target = &cfg.LockAfter | |
| 75 | - | case "fade.after": | |
| 76 | - | target = &cfg.FadeAfter | |
| 77 | - | case "fade.duration": | |
| 78 | - | target = &cfg.FadeDuration | |
| 79 | - | case "screens.off-after": | |
| 80 | - | target = &cfg.OffAfter | |
| 81 | - | case "sleep.after": | |
| 82 | - | target = &cfg.SleepAfter | |
| 83 | - | case "lock.output": | |
| 84 | - | cfg.LockOutput = value | |
| 85 | - | case "auth.mode": | |
| 86 | - | cfg.Auth.Mode = ports.AuthMode(value) | |
| 87 | - | case "auth.pin-source": | |
| 88 | - | cfg.Auth.PINSource = ports.PINSource(value) | |
| 89 | - | case "auth.pin-env": | |
| 90 | - | cfg.Auth.PINEnv = value | |
| 91 | - | case "auth.pin-entry": | |
| 92 | - | cfg.Auth.PINEntry = value | |
| 93 | - | default: | |
| 94 | - | return ports.Config{}, fmt.Errorf("line %d: unknown key", line) | |
| 95 | - | } | |
| 96 | - | if target != nil { | |
| 97 | - | if value == "off" && key != "fade.duration" { | |
| 98 | - | *target = 0 | |
| 99 | - | continue | |
| 100 | - | } | |
| 101 | - | d, e := time.ParseDuration(value) | |
| 102 | - | if e != nil || d < 0 || d > ports.MaxDuration || d == 0 && key != "fade.duration" { | |
| 103 | - | return ports.Config{}, fmt.Errorf("line %d: invalid duration", line) | |
| 104 | - | } | |
| 105 | - | *target = d | |
| 106 | - | } | |
| 107 | - | } | |
| 108 | - | if err := scanner.Err(); err != nil { | |
| 109 | - | return ports.Config{}, fmt.Errorf("config line exceeds bound") | |
| 110 | - | } | |
| 111 | - | if err := cfg.Validate(); err != nil { | |
| 112 | - | return ports.Config{}, err | |
| 56 | + | if _, err := kvconf.Unmarshal(data, &cfg, options); err != nil { | |
| 57 | + | return ports.Config{}, reject(err) | |
| 113 | 58 | } | |
| 114 | 59 | return cfg, nil | |
| 115 | 60 | } | |
| 61 | + | ||
| 62 | + | // reject rewrites kvconf errors so they carry a line number and a fixed | |
| 63 | + | // message, never a key or value, which may be secret. | |
| 64 | + | func reject(err error) error { | |
| 65 | + | var w kvconf.Warning | |
| 66 | + | switch { | |
| 67 | + | case errors.Is(err, kvconf.ErrTooLarge): | |
| 68 | + | return errTooLarge | |
| 69 | + | case errors.Is(err, kvconf.ErrNotUTF8): | |
| 70 | + | return errors.New("config is not valid UTF-8") | |
| 71 | + | case errors.As(err, &w): | |
| 72 | + | return fmt.Errorf("line %d: %s", w.Line, w.Msg) | |
| 73 | + | } | |
| 74 | + | return err | |
| 75 | + | } | |
| @@ -2,15 +2,13 @@ | |||||
| 2 | package config | 2 | package config | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | - | "bufio" | 5 | + | "errors" |
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "io" | 7 | "io" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | - | "strings" | |||
| 11 | - | "time" | |||
| 12 | - | "unicode/utf8" | |||
| 13 | 10 | ||||
| 11 | + | "github.com/bnema/kvconf" | |||
| 14 | "github.com/bnema/neferafk/internal/ports" | 12 | "github.com/bnema/neferafk/internal/ports" | ||
| 15 | ) | 13 | ) | ||
| 16 | 14 | ||||
| @@ -26,13 +24,16 @@ func DefaultPath() string { | |||||
| 26 | } | 24 | } | ||
| 27 | return filepath.Join(base, "neferafk", "config") | 25 | return filepath.Join(base, "neferafk", "config") | ||
| 28 | } | 26 | } | ||
| 27 | + | ||||
| 29 | func Load(path string) (ports.Config, error) { | 28 | func Load(path string) (ports.Config, error) { | ||
| 30 | - | f, err := os.Open(path) | 29 | + | data, err := kvconf.ReadFile(path, options) |
| 31 | if err != nil { | 30 | if err != nil { | ||
| 31 | + | if errors.Is(err, kvconf.ErrTooLarge) { | |||
| 32 | + | return ports.Config{}, errTooLarge | |||
| 33 | + | } | |||
| 32 | return ports.Config{}, err | 34 | return ports.Config{}, err | ||
| 33 | } | 35 | } | ||
| 34 | - | defer f.Close() | 36 | + | return parse(data) |
| 35 | - | return Parse(f) | |||
| 36 | } | 37 | } | ||
| 37 | 38 | ||||
| 38 | // Parse never returns partial configuration and never resolves PIN references. | 39 | // Parse never returns partial configuration and never resolves PIN references. | ||
| @@ -41,75 +42,34 @@ func Parse(r io.Reader) (ports.Config, error) { | |||||
| 41 | if err != nil { | 42 | if err != nil { | ||
| 42 | return ports.Config{}, err | 43 | return ports.Config{}, err | ||
| 43 | } | 44 | } | ||
| 44 | - | if !utf8.Valid(data) { | 45 | + | return parse(data) |
| 45 | - | return ports.Config{}, fmt.Errorf("config is not valid UTF-8") | 46 | + | } |
| 46 | - | } | 47 | + | |
| 47 | - | if len(data) > MaxBytes { | 48 | + | // options: any problem rejects the whole file; "#" starts a comment anywhere |
| 48 | - | return ports.Config{}, fmt.Errorf("config exceeds 64KiB") | 49 | + | // and values are never unquoted. |
| 49 | - | } | 50 | + | var options = kvconf.Options{Strict: true, Comments: kvconf.CommentAny, RawValues: true, MaxBytes: MaxBytes, MaxLine: MaxLine} |
| 51 | + | ||||
| 52 | + | var errTooLarge = errors.New("config exceeds 64KiB") | |||
| 53 | + | ||||
| 54 | + | func parse(data []byte) (ports.Config, error) { | |||
| 50 | cfg := ports.Defaults() | 55 | cfg := ports.Defaults() | ||
| 51 | - | seen := map[string]bool{} | 56 | + | if _, err := kvconf.Unmarshal(data, &cfg, options); err != nil { |
| 52 | - | scanner := bufio.NewScanner(strings.NewReader(string(data))) | 57 | + | return ports.Config{}, reject(err) |
| 53 | - | scanner.Buffer(make([]byte, MaxLine+1), MaxLine+2) | |||
| 54 | - | line := 0 | |||
| 55 | - | for scanner.Scan() { | |||
| 56 | - | line++ | |||
| 57 | - | text := scanner.Text() | |||
| 58 | - | if len(text) > MaxLine { | |||
| 59 | - | return ports.Config{}, fmt.Errorf("line %d exceeds 4KiB", line) | |||
| 60 | - | } | |||
| 61 | - | text = strings.TrimSpace(strings.SplitN(text, "#", 2)[0]) | |||
| 62 | - | if text == "" { | |||
| 63 | - | continue | |||
| 64 | - | } | |||
| 65 | - | key, value, ok := strings.Cut(text, "=") | |||
| 66 | - | key, value = strings.TrimSpace(key), strings.TrimSpace(value) | |||
| 67 | - | if !ok || value == "" || seen[key] { | |||
| 68 | - | return ports.Config{}, fmt.Errorf("line %d: missing value or duplicate key", line) | |||
| 69 | - | } | |||
| 70 | - | seen[key] = true | |||
| 71 | - | var target *time.Duration | |||
| 72 | - | switch key { | |||
| 73 | - | case "lock.after": | |||
| 74 | - | target = &cfg.LockAfter | |||
| 75 | - | case "fade.after": | |||
| 76 | - | target = &cfg.FadeAfter | |||
| 77 | - | case "fade.duration": | |||
| 78 | - | target = &cfg.FadeDuration | |||
| 79 | - | case "screens.off-after": | |||
| 80 | - | target = &cfg.OffAfter | |||
| 81 | - | case "sleep.after": | |||
| 82 | - | target = &cfg.SleepAfter | |||
| 83 | - | case "lock.output": | |||
| 84 | - | cfg.LockOutput = value | |||
| 85 | - | case "auth.mode": | |||
| 86 | - | cfg.Auth.Mode = ports.AuthMode(value) | |||
| 87 | - | case "auth.pin-source": | |||
| 88 | - | cfg.Auth.PINSource = ports.PINSource(value) | |||
| 89 | - | case "auth.pin-env": | |||
| 90 | - | cfg.Auth.PINEnv = value | |||
| 91 | - | case "auth.pin-entry": | |||
| 92 | - | cfg.Auth.PINEntry = value | |||
| 93 | - | default: | |||
| 94 | - | return ports.Config{}, fmt.Errorf("line %d: unknown key", line) | |||
| 95 | - | } | |||
| 96 | - | if target != nil { | |||
| 97 | - | if value == "off" && key != "fade.duration" { | |||
| 98 | - | *target = 0 | |||
| 99 | - | continue | |||
| 100 | - | } | |||
| 101 | - | d, e := time.ParseDuration(value) | |||
| 102 | - | if e != nil || d < 0 || d > ports.MaxDuration || d == 0 && key != "fade.duration" { | |||
| 103 | - | return ports.Config{}, fmt.Errorf("line %d: invalid duration", line) | |||
| 104 | - | } | |||
| 105 | - | *target = d | |||
| 106 | - | } | |||
| 107 | - | } | |||
| 108 | - | if err := scanner.Err(); err != nil { | |||
| 109 | - | return ports.Config{}, fmt.Errorf("config line exceeds bound") | |||
| 110 | - | } | |||
| 111 | - | if err := cfg.Validate(); err != nil { | |||
| 112 | - | return ports.Config{}, err | |||
| 113 | } | 58 | } | ||
| 114 | return cfg, nil | 59 | return cfg, nil | ||
| 115 | } | 60 | } | ||
| 61 | + | ||||
| 62 | + | // reject rewrites kvconf errors so they carry a line number and a fixed | |||
| 63 | + | // message, never a key or value, which may be secret. | |||
| 64 | + | func reject(err error) error { | |||
| 65 | + | var w kvconf.Warning | |||
| 66 | + | switch { | |||
| 67 | + | case errors.Is(err, kvconf.ErrTooLarge): | |||
| 68 | + | return errTooLarge | |||
| 69 | + | case errors.Is(err, kvconf.ErrNotUTF8): | |||
| 70 | + | return errors.New("config is not valid UTF-8") | |||
| 71 | + | case errors.As(err, &w): | |||
| 72 | + | return fmt.Errorf("line %d: %s", w.Line, w.Msg) | |||
| 73 | + | } | |||
| 74 | + | return err | |||
| 75 | + | } | |||
M internal/adapters/config/watch.go +38 -158
| @@ -2,113 +2,49 @@ package config | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "encoding/binary" | |
| 6 | 5 | "errors" | |
| 7 | - | "os" | |
| 8 | - | "path/filepath" | |
| 9 | - | "strings" | |
| 10 | - | "time" | |
| 6 | + | "io/fs" | |
| 11 | 7 | ||
| 8 | + | "github.com/bnema/kvconf" | |
| 12 | 9 | "github.com/bnema/neferafk/internal/ports" | |
| 13 | - | "golang.org/x/sys/unix" | |
| 14 | 10 | ) | |
| 15 | 11 | ||
| 16 | - | func watchedFiles(path string) []string { | |
| 17 | - | files := []string{filepath.Clean(path)} | |
| 18 | - | for len(files) <= 40 { | |
| 19 | - | cur := files[len(files)-1] | |
| 20 | - | target, err := os.Readlink(cur) | |
| 21 | - | if err != nil { | |
| 22 | - | break | |
| 23 | - | } | |
| 24 | - | if !filepath.IsAbs(target) { | |
| 25 | - | target = filepath.Join(filepath.Dir(cur), target) | |
| 26 | - | } | |
| 27 | - | target = filepath.Clean(target) | |
| 28 | - | duplicate := false | |
| 29 | - | for _, p := range files { | |
| 30 | - | if p == target { | |
| 31 | - | duplicate = true | |
| 32 | - | break | |
| 33 | - | } | |
| 34 | - | } | |
| 35 | - | if duplicate { | |
| 36 | - | break | |
| 37 | - | } | |
| 38 | - | files = append(files, target) | |
| 39 | - | } | |
| 40 | - | return files | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | // watchDirectory finds the nearest existing ancestor. The missing component's | |
| 44 | - | // creation wakes inotify; the two-second retry also covers lost/overflow events. | |
| 45 | - | func watchDirectory(file string) (directory, name string, missing bool) { | |
| 46 | - | directory, name = filepath.Dir(file), filepath.Base(file) | |
| 47 | - | for { | |
| 48 | - | st, err := os.Stat(directory) | |
| 49 | - | if err == nil && st.IsDir() { | |
| 50 | - | return | |
| 51 | - | } | |
| 52 | - | missing = true | |
| 53 | - | parent := filepath.Dir(directory) | |
| 54 | - | if parent == directory { | |
| 55 | - | return | |
| 56 | - | } | |
| 57 | - | name, directory = filepath.Base(directory), parent | |
| 58 | - | } | |
| 59 | - | } | |
| 60 | - | ||
| 61 | 12 | // Watch retains last-valid state across missing files/directories and follows | |
| 62 | - | // config symlink targets. It always emits the first valid candidate, including | |
| 63 | - | // a candidate equal to defaults: this closes an app Load -> Watch startup gap. | |
| 64 | - | // Ready closes after initial watch installation, before candidate delivery. | |
| 65 | - | // No invalid/deleted candidate emits a defaults reset. Errors never echo file | |
| 66 | - | // content or paths, which may themselves contain sensitive source metadata. | |
| 13 | + | // config symlink targets (kvconf.Watch). It always emits the first valid | |
| 14 | + | // candidate, including a candidate equal to defaults: this closes an app | |
| 15 | + | // Load -> Watch startup gap. Ready closes after initial watch installation, | |
| 16 | + | // before candidate delivery. No invalid/deleted candidate emits a defaults | |
| 17 | + | // reset. Errors never echo file content or paths, which may themselves | |
| 18 | + | // contain sensitive source metadata. | |
| 67 | 19 | func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, failures chan<- error, ready chan<- struct{}) error { | |
| 68 | - | fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) | |
| 69 | - | if err != nil { | |
| 70 | - | return errors.New("initialize config watch") | |
| 71 | - | } | |
| 72 | - | defer unix.Close(fd) | |
| 73 | - | const mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | unix.IN_DELETE_SELF | unix.IN_MOVE_SELF | |
| 74 | - | names := map[int32]map[string]bool{} | |
| 75 | - | retry := time.Time{} | |
| 76 | - | rewatch := func() { | |
| 77 | - | // Keep unchanged watches, avoiding IN_IGNORED from our own removals. | |
| 78 | - | want := map[int32]map[string]bool{} | |
| 79 | - | retry = time.Time{} | |
| 80 | - | for _, file := range watchedFiles(path) { | |
| 81 | - | dir, name, missing := watchDirectory(file) | |
| 82 | - | wd, e := unix.InotifyAddWatch(fd, dir, mask) | |
| 83 | - | if e != nil { | |
| 84 | - | retry = time.Now().Add(2 * time.Second) | |
| 85 | - | continue | |
| 86 | - | } | |
| 87 | - | if missing { | |
| 88 | - | retry = time.Now().Add(2 * time.Second) | |
| 89 | - | } | |
| 90 | - | if want[int32(wd)] == nil { | |
| 91 | - | want[int32(wd)] = map[string]bool{} | |
| 92 | - | } | |
| 93 | - | want[int32(wd)][name] = true | |
| 94 | - | } | |
| 95 | - | for wd := range names { | |
| 96 | - | if want[wd] == nil { | |
| 97 | - | _, _ = unix.InotifyRmWatch(fd, uint32(wd)) | |
| 98 | - | } | |
| 20 | + | ctx, cancel := context.WithCancel(ctx) | |
| 21 | + | changed := make(chan struct{}, 1) | |
| 22 | + | installed := make(chan struct{}) | |
| 23 | + | done := make(chan struct{}) | |
| 24 | + | var watchErr error | |
| 25 | + | go func() { | |
| 26 | + | defer close(done) | |
| 27 | + | watchErr = kvconf.Watch(ctx, path, kvconf.WatchOptions{Ready: installed}, changed) | |
| 28 | + | }() | |
| 29 | + | defer func() { cancel(); <-done }() | |
| 30 | + | ||
| 31 | + | select { | |
| 32 | + | case <-installed: | |
| 33 | + | case <-done: | |
| 34 | + | if watchErr != nil { | |
| 35 | + | return errors.New("initialize config watch") | |
| 99 | 36 | } | |
| 100 | - | names = want | |
| 37 | + | return nil | |
| 101 | 38 | } | |
| 102 | - | rewatch() | |
| 103 | 39 | if ready != nil { | |
| 104 | 40 | close(ready) | |
| 105 | 41 | } | |
| 106 | 42 | var last ports.Config | |
| 107 | 43 | haveLast := false | |
| 108 | 44 | publish := func() bool { | |
| 109 | - | cfg, e := Load(path) | |
| 110 | - | if e != nil { | |
| 111 | - | if !os.IsNotExist(e) && failures != nil { | |
| 45 | + | cfg, err := Load(path) | |
| 46 | + | if err != nil { | |
| 47 | + | if !errors.Is(err, fs.ErrNotExist) && failures != nil { | |
| 112 | 48 | select { | |
| 113 | 49 | case failures <- errors.New("config reload rejected"): | |
| 114 | 50 | default: | |
| @@ -127,73 +63,17 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, fai | |||
| 127 | 63 | return false | |
| 128 | 64 | } | |
| 129 | 65 | } | |
| 130 | - | if !publish() { | |
| 131 | - | return nil | |
| 132 | - | } | |
| 133 | - | pending := time.Time{} | |
| 134 | - | buf := make([]byte, 8192) | |
| 135 | - | for ctx.Err() == nil { | |
| 136 | - | now := time.Now() | |
| 137 | - | if !retry.IsZero() && !now.Before(retry) { | |
| 138 | - | rewatch() | |
| 139 | - | pending = now | |
| 140 | - | } | |
| 141 | - | if !pending.IsZero() && !now.Before(pending) { | |
| 142 | - | pending = time.Time{} | |
| 143 | - | rewatch() | |
| 144 | - | if !publish() { | |
| 145 | - | return nil | |
| 146 | - | } | |
| 147 | - | } | |
| 148 | - | timeout := 100 // cancellation bound, no periodic reads while watches are live | |
| 149 | - | if !pending.IsZero() { | |
| 150 | - | timeout = min(timeout, max(1, int(time.Until(pending).Milliseconds()))) | |
| 151 | - | } | |
| 152 | - | fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} | |
| 153 | - | _, e := unix.Poll(fds, timeout) | |
| 154 | - | if errors.Is(e, unix.EINTR) { | |
| 155 | - | continue | |
| 156 | - | } | |
| 157 | - | if e != nil { | |
| 158 | - | return errors.New("poll config watch") | |
| 159 | - | } | |
| 160 | - | if fds[0].Revents&unix.POLLIN == 0 { | |
| 161 | - | continue | |
| 162 | - | } | |
| 163 | - | n, e := unix.Read(fd, buf) | |
| 164 | - | if errors.Is(e, unix.EAGAIN) || errors.Is(e, unix.EINTR) { | |
| 165 | - | continue | |
| 166 | - | } | |
| 167 | - | if e != nil { | |
| 168 | - | return errors.New("read config watch") | |
| 169 | - | } | |
| 170 | - | for offset := 0; offset+unix.SizeofInotifyEvent <= n; { | |
| 171 | - | b := buf[offset:] | |
| 172 | - | wd := int32(binary.NativeEndian.Uint32(b)) | |
| 173 | - | flags := binary.NativeEndian.Uint32(b[4:]) | |
| 174 | - | length := int(binary.NativeEndian.Uint32(b[12:])) | |
| 175 | - | size := unix.SizeofInotifyEvent + length | |
| 176 | - | if size > n-offset { | |
| 177 | - | break | |
| 178 | - | } | |
| 179 | - | if flags&unix.IN_Q_OVERFLOW != 0 { | |
| 180 | - | pending = time.Now().Add(100 * time.Millisecond) | |
| 181 | - | } | |
| 182 | - | if names[wd] != nil { | |
| 183 | - | if flags&(unix.IN_DELETE_SELF|unix.IN_MOVE_SELF|unix.IN_IGNORED) != 0 { | |
| 184 | - | if flags&unix.IN_IGNORED == 0 { | |
| 185 | - | _, _ = unix.InotifyRmWatch(fd, uint32(wd)) | |
| 186 | - | } | |
| 187 | - | delete(names, wd) | |
| 188 | - | pending = time.Now().Add(100 * time.Millisecond) | |
| 189 | - | retry = time.Now().Add(2 * time.Second) | |
| 190 | - | } | |
| 191 | - | name := strings.TrimRight(string(b[unix.SizeofInotifyEvent:size]), "\x00") | |
| 192 | - | if names[wd][name] { | |
| 193 | - | pending = time.Now().Add(100 * time.Millisecond) | |
| 194 | - | } | |
| 66 | + | for ok := publish(); ok; { | |
| 67 | + | select { | |
| 68 | + | case <-ctx.Done(): | |
| 69 | + | return nil | |
| 70 | + | case <-done: | |
| 71 | + | if watchErr != nil { | |
| 72 | + | return errors.New("config watch") | |
| 195 | 73 | } | |
| 196 | - | offset += size | |
| 74 | + | return nil | |
| 75 | + | case <-changed: | |
| 76 | + | ok = publish() | |
| 197 | 77 | } | |
| 198 | 78 | } | |
| 199 | 79 | return nil | |
| @@ -2,113 +2,49 @@ package config | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "encoding/binary" | |||
| 6 | "errors" | 5 | "errors" | ||
| 7 | - | "os" | 6 | + | "io/fs" |
| 8 | - | "path/filepath" | |||
| 9 | - | "strings" | |||
| 10 | - | "time" | |||
| 11 | 7 | ||||
| 8 | + | "github.com/bnema/kvconf" | |||
| 12 | "github.com/bnema/neferafk/internal/ports" | 9 | "github.com/bnema/neferafk/internal/ports" | ||
| 13 | - | "golang.org/x/sys/unix" | |||
| 14 | ) | 10 | ) | ||
| 15 | 11 | ||||
| 16 | - | func watchedFiles(path string) []string { | |||
| 17 | - | files := []string{filepath.Clean(path)} | |||
| 18 | - | for len(files) <= 40 { | |||
| 19 | - | cur := files[len(files)-1] | |||
| 20 | - | target, err := os.Readlink(cur) | |||
| 21 | - | if err != nil { | |||
| 22 | - | break | |||
| 23 | - | } | |||
| 24 | - | if !filepath.IsAbs(target) { | |||
| 25 | - | target = filepath.Join(filepath.Dir(cur), target) | |||
| 26 | - | } | |||
| 27 | - | target = filepath.Clean(target) | |||
| 28 | - | duplicate := false | |||
| 29 | - | for _, p := range files { | |||
| 30 | - | if p == target { | |||
| 31 | - | duplicate = true | |||
| 32 | - | break | |||
| 33 | - | } | |||
| 34 | - | } | |||
| 35 | - | if duplicate { | |||
| 36 | - | break | |||
| 37 | - | } | |||
| 38 | - | files = append(files, target) | |||
| 39 | - | } | |||
| 40 | - | return files | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | - | // watchDirectory finds the nearest existing ancestor. The missing component's | |||
| 44 | - | // creation wakes inotify; the two-second retry also covers lost/overflow events. | |||
| 45 | - | func watchDirectory(file string) (directory, name string, missing bool) { | |||
| 46 | - | directory, name = filepath.Dir(file), filepath.Base(file) | |||
| 47 | - | for { | |||
| 48 | - | st, err := os.Stat(directory) | |||
| 49 | - | if err == nil && st.IsDir() { | |||
| 50 | - | return | |||
| 51 | - | } | |||
| 52 | - | missing = true | |||
| 53 | - | parent := filepath.Dir(directory) | |||
| 54 | - | if parent == directory { | |||
| 55 | - | return | |||
| 56 | - | } | |||
| 57 | - | name, directory = filepath.Base(directory), parent | |||
| 58 | - | } | |||
| 59 | - | } | |||
| 60 | - | ||||
| 61 | // Watch retains last-valid state across missing files/directories and follows | 12 | // Watch retains last-valid state across missing files/directories and follows | ||
| 62 | - | // config symlink targets. It always emits the first valid candidate, including | 13 | + | // config symlink targets (kvconf.Watch). It always emits the first valid |
| 63 | - | // a candidate equal to defaults: this closes an app Load -> Watch startup gap. | 14 | + | // candidate, including a candidate equal to defaults: this closes an app |
| 64 | - | // Ready closes after initial watch installation, before candidate delivery. | 15 | + | // Load -> Watch startup gap. Ready closes after initial watch installation, |
| 65 | - | // No invalid/deleted candidate emits a defaults reset. Errors never echo file | 16 | + | // before candidate delivery. No invalid/deleted candidate emits a defaults |
| 66 | - | // content or paths, which may themselves contain sensitive source metadata. | 17 | + | // reset. Errors never echo file content or paths, which may themselves |
| 18 | + | // contain sensitive source metadata. | |||
| 67 | func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, failures chan<- error, ready chan<- struct{}) error { | 19 | func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, failures chan<- error, ready chan<- struct{}) error { | ||
| 68 | - | fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) | 20 | + | ctx, cancel := context.WithCancel(ctx) |
| 69 | - | if err != nil { | 21 | + | changed := make(chan struct{}, 1) |
| 70 | - | return errors.New("initialize config watch") | 22 | + | installed := make(chan struct{}) |
| 71 | - | } | 23 | + | done := make(chan struct{}) |
| 72 | - | defer unix.Close(fd) | 24 | + | var watchErr error |
| 73 | - | const mask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_CREATE | unix.IN_DELETE | unix.IN_MOVED_FROM | unix.IN_DELETE_SELF | unix.IN_MOVE_SELF | 25 | + | go func() { |
| 74 | - | names := map[int32]map[string]bool{} | 26 | + | defer close(done) |
| 75 | - | retry := time.Time{} | 27 | + | watchErr = kvconf.Watch(ctx, path, kvconf.WatchOptions{Ready: installed}, changed) |
| 76 | - | rewatch := func() { | 28 | + | }() |
| 77 | - | // Keep unchanged watches, avoiding IN_IGNORED from our own removals. | 29 | + | defer func() { cancel(); <-done }() |
| 78 | - | want := map[int32]map[string]bool{} | 30 | + | |
| 79 | - | retry = time.Time{} | 31 | + | select { |
| 80 | - | for _, file := range watchedFiles(path) { | 32 | + | case <-installed: |
| 81 | - | dir, name, missing := watchDirectory(file) | 33 | + | case <-done: |
| 82 | - | wd, e := unix.InotifyAddWatch(fd, dir, mask) | 34 | + | if watchErr != nil { |
| 83 | - | if e != nil { | 35 | + | return errors.New("initialize config watch") |
| 84 | - | retry = time.Now().Add(2 * time.Second) | |||
| 85 | - | continue | |||
| 86 | - | } | |||
| 87 | - | if missing { | |||
| 88 | - | retry = time.Now().Add(2 * time.Second) | |||
| 89 | - | } | |||
| 90 | - | if want[int32(wd)] == nil { | |||
| 91 | - | want[int32(wd)] = map[string]bool{} | |||
| 92 | - | } | |||
| 93 | - | want[int32(wd)][name] = true | |||
| 94 | - | } | |||
| 95 | - | for wd := range names { | |||
| 96 | - | if want[wd] == nil { | |||
| 97 | - | _, _ = unix.InotifyRmWatch(fd, uint32(wd)) | |||
| 98 | - | } | |||
| 99 | } | 36 | } | ||
| 100 | - | names = want | 37 | + | return nil |
| 101 | } | 38 | } | ||
| 102 | - | rewatch() | |||
| 103 | if ready != nil { | 39 | if ready != nil { | ||
| 104 | close(ready) | 40 | close(ready) | ||
| 105 | } | 41 | } | ||
| 106 | var last ports.Config | 42 | var last ports.Config | ||
| 107 | haveLast := false | 43 | haveLast := false | ||
| 108 | publish := func() bool { | 44 | publish := func() bool { | ||
| 109 | - | cfg, e := Load(path) | 45 | + | cfg, err := Load(path) |
| 110 | - | if e != nil { | 46 | + | if err != nil { |
| 111 | - | if !os.IsNotExist(e) && failures != nil { | 47 | + | if !errors.Is(err, fs.ErrNotExist) && failures != nil { |
| 112 | select { | 48 | select { | ||
| 113 | case failures <- errors.New("config reload rejected"): | 49 | case failures <- errors.New("config reload rejected"): | ||
| 114 | default: | 50 | default: | ||
| @@ -127,73 +63,17 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, fai | |||||
| 127 | return false | 63 | return false | ||
| 128 | } | 64 | } | ||
| 129 | } | 65 | } | ||
| 130 | - | if !publish() { | 66 | + | for ok := publish(); ok; { |
| 131 | - | return nil | 67 | + | select { |
| 132 | - | } | 68 | + | case <-ctx.Done(): |
| 133 | - | pending := time.Time{} | 69 | + | return nil |
| 134 | - | buf := make([]byte, 8192) | 70 | + | case <-done: |
| 135 | - | for ctx.Err() == nil { | 71 | + | if watchErr != nil { |
| 136 | - | now := time.Now() | 72 | + | return errors.New("config watch") |
| 137 | - | if !retry.IsZero() && !now.Before(retry) { | |||
| 138 | - | rewatch() | |||
| 139 | - | pending = now | |||
| 140 | - | } | |||
| 141 | - | if !pending.IsZero() && !now.Before(pending) { | |||
| 142 | - | pending = time.Time{} | |||
| 143 | - | rewatch() | |||
| 144 | - | if !publish() { | |||
| 145 | - | return nil | |||
| 146 | - | } | |||
| 147 | - | } | |||
| 148 | - | timeout := 100 // cancellation bound, no periodic reads while watches are live | |||
| 149 | - | if !pending.IsZero() { | |||
| 150 | - | timeout = min(timeout, max(1, int(time.Until(pending).Milliseconds()))) | |||
| 151 | - | } | |||
| 152 | - | fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}} | |||
| 153 | - | _, e := unix.Poll(fds, timeout) | |||
| 154 | - | if errors.Is(e, unix.EINTR) { | |||
| 155 | - | continue | |||
| 156 | - | } | |||
| 157 | - | if e != nil { | |||
| 158 | - | return errors.New("poll config watch") | |||
| 159 | - | } | |||
| 160 | - | if fds[0].Revents&unix.POLLIN == 0 { | |||
| 161 | - | continue | |||
| 162 | - | } | |||
| 163 | - | n, e := unix.Read(fd, buf) | |||
| 164 | - | if errors.Is(e, unix.EAGAIN) || errors.Is(e, unix.EINTR) { | |||
| 165 | - | continue | |||
| 166 | - | } | |||
| 167 | - | if e != nil { | |||
| 168 | - | return errors.New("read config watch") | |||
| 169 | - | } | |||
| 170 | - | for offset := 0; offset+unix.SizeofInotifyEvent <= n; { | |||
| 171 | - | b := buf[offset:] | |||
| 172 | - | wd := int32(binary.NativeEndian.Uint32(b)) | |||
| 173 | - | flags := binary.NativeEndian.Uint32(b[4:]) | |||
| 174 | - | length := int(binary.NativeEndian.Uint32(b[12:])) | |||
| 175 | - | size := unix.SizeofInotifyEvent + length | |||
| 176 | - | if size > n-offset { | |||
| 177 | - | break | |||
| 178 | - | } | |||
| 179 | - | if flags&unix.IN_Q_OVERFLOW != 0 { | |||
| 180 | - | pending = time.Now().Add(100 * time.Millisecond) | |||
| 181 | - | } | |||
| 182 | - | if names[wd] != nil { | |||
| 183 | - | if flags&(unix.IN_DELETE_SELF|unix.IN_MOVE_SELF|unix.IN_IGNORED) != 0 { | |||
| 184 | - | if flags&unix.IN_IGNORED == 0 { | |||
| 185 | - | _, _ = unix.InotifyRmWatch(fd, uint32(wd)) | |||
| 186 | - | } | |||
| 187 | - | delete(names, wd) | |||
| 188 | - | pending = time.Now().Add(100 * time.Millisecond) | |||
| 189 | - | retry = time.Now().Add(2 * time.Second) | |||
| 190 | - | } | |||
| 191 | - | name := strings.TrimRight(string(b[unix.SizeofInotifyEvent:size]), "\x00") | |||
| 192 | - | if names[wd][name] { | |||
| 193 | - | pending = time.Now().Add(100 * time.Millisecond) | |||
| 194 | - | } | |||
| 195 | } | 73 | } | ||
| 196 | - | offset += size | 74 | + | return nil |
| 75 | + | case <-changed: | |||
| 76 | + | ok = publish() | |||
| 197 | } | 77 | } | ||
| 198 | } | 78 | } | ||
| 199 | return nil | 79 | return nil | ||
M internal/ports/config.go +15 -7
| @@ -11,14 +11,21 @@ import ( | |||
| 11 | 11 | const MaxDuration = 24 * time.Hour | |
| 12 | 12 | ||
| 13 | 13 | // Zero After disables only that action. No deadline implies another action. | |
| 14 | + | // | |
| 15 | + | // The kv tags describe the config file keys (decoded by adapters/config). A | |
| 16 | + | // zero duration is written "off", except fade.duration where 0s is a value. | |
| 17 | + | // max:"24h" mirrors MaxDuration (kept for the line number; Validate also checks). | |
| 14 | 18 | type Config struct { | |
| 15 | - | LockAfter, FadeAfter, OffAfter, SleepAfter time.Duration | |
| 16 | - | FadeDuration time.Duration | |
| 17 | - | Auth AuthConfig | |
| 19 | + | LockAfter time.Duration `kv:"lock.after,off" min:"1ns" max:"24h"` | |
| 20 | + | FadeAfter time.Duration `kv:"fade.after,off" min:"1ns" max:"24h"` | |
| 21 | + | OffAfter time.Duration `kv:"screens.off-after,off" min:"1ns" max:"24h"` | |
| 22 | + | SleepAfter time.Duration `kv:"sleep.after,off" min:"1ns" max:"24h"` | |
| 23 | + | FadeDuration time.Duration `kv:"fade.duration" min:"0s" max:"24h"` | |
| 24 | + | Auth AuthConfig `kv:"auth"` | |
| 18 | 25 | // LockOutput names the only output (wl_output name, e.g. DP-2) that | |
| 19 | 26 | // shows the lock prompt while it is connected. Empty, or absent: every | |
| 20 | 27 | // output shows it. A change applies from the next lock. | |
| 21 | - | LockOutput string | |
| 28 | + | LockOutput string `kv:"lock.output,nonempty"` | |
| 22 | 29 | } | |
| 23 | 30 | ||
| 24 | 31 | // MaxOutputName bounds LockOutput: neferclient truncates longer wl_output | |
| @@ -43,9 +50,10 @@ const ( | |||
| 43 | 50 | // PIN mode always falls back to PAM when its selected source is unavailable or | |
| 44 | 51 | // invalid. A wrong PIN is authentication failure, never a fallback trigger. | |
| 45 | 52 | type AuthConfig struct { | |
| 46 | - | Mode AuthMode | |
| 47 | - | PINSource PINSource | |
| 48 | - | PINEnv, PINEntry string | |
| 53 | + | Mode AuthMode `kv:"mode" oneof:"password|pin"` | |
| 54 | + | PINSource PINSource `kv:"pin-source" oneof:"env|pass"` | |
| 55 | + | PINEnv string `kv:"pin-env,nonempty"` | |
| 56 | + | PINEntry string `kv:"pin-entry,nonempty"` | |
| 49 | 57 | } | |
| 50 | 58 | ||
| 51 | 59 | func Defaults() Config { | |
| @@ -11,14 +11,21 @@ import ( | |||||
| 11 | const MaxDuration = 24 * time.Hour | 11 | const MaxDuration = 24 * time.Hour | ||
| 12 | 12 | ||||
| 13 | // Zero After disables only that action. No deadline implies another action. | 13 | // Zero After disables only that action. No deadline implies another action. | ||
| 14 | + | // | |||
| 15 | + | // The kv tags describe the config file keys (decoded by adapters/config). A | |||
| 16 | + | // zero duration is written "off", except fade.duration where 0s is a value. | |||
| 17 | + | // max:"24h" mirrors MaxDuration (kept for the line number; Validate also checks). | |||
| 14 | type Config struct { | 18 | type Config struct { | ||
| 15 | - | LockAfter, FadeAfter, OffAfter, SleepAfter time.Duration | 19 | + | LockAfter time.Duration `kv:"lock.after,off" min:"1ns" max:"24h"` |
| 16 | - | FadeDuration time.Duration | 20 | + | FadeAfter time.Duration `kv:"fade.after,off" min:"1ns" max:"24h"` |
| 17 | - | Auth AuthConfig | 21 | + | OffAfter time.Duration `kv:"screens.off-after,off" min:"1ns" max:"24h"` |
| 22 | + | SleepAfter time.Duration `kv:"sleep.after,off" min:"1ns" max:"24h"` | |||
| 23 | + | FadeDuration time.Duration `kv:"fade.duration" min:"0s" max:"24h"` | |||
| 24 | + | Auth AuthConfig `kv:"auth"` | |||
| 18 | // LockOutput names the only output (wl_output name, e.g. DP-2) that | 25 | // LockOutput names the only output (wl_output name, e.g. DP-2) that | ||
| 19 | // shows the lock prompt while it is connected. Empty, or absent: every | 26 | // shows the lock prompt while it is connected. Empty, or absent: every | ||
| 20 | // output shows it. A change applies from the next lock. | 27 | // output shows it. A change applies from the next lock. | ||
| 21 | - | LockOutput string | 28 | + | LockOutput string `kv:"lock.output,nonempty"` |
| 22 | } | 29 | } | ||
| 23 | 30 | ||||
| 24 | // MaxOutputName bounds LockOutput: neferclient truncates longer wl_output | 31 | // MaxOutputName bounds LockOutput: neferclient truncates longer wl_output | ||
| @@ -43,9 +50,10 @@ const ( | |||||
| 43 | // PIN mode always falls back to PAM when its selected source is unavailable or | 50 | // PIN mode always falls back to PAM when its selected source is unavailable or | ||
| 44 | // invalid. A wrong PIN is authentication failure, never a fallback trigger. | 51 | // invalid. A wrong PIN is authentication failure, never a fallback trigger. | ||
| 45 | type AuthConfig struct { | 52 | type AuthConfig struct { | ||
| 46 | - | Mode AuthMode | 53 | + | Mode AuthMode `kv:"mode" oneof:"password|pin"` |
| 47 | - | PINSource PINSource | 54 | + | PINSource PINSource `kv:"pin-source" oneof:"env|pass"` |
| 48 | - | PINEnv, PINEntry string | 55 | + | PINEnv string `kv:"pin-env,nonempty"` |
| 56 | + | PINEntry string `kv:"pin-entry,nonempty"` | |||
| 49 | } | 57 | } | ||
| 50 | 58 | ||||
| 51 | func Defaults() Config { | 59 | func Defaults() Config { | ||