Merge pull request 'refactor(config): parse and watch the config with kvconf' (#2) from refactor/kvconf into main
7 files · +264 -231
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.2 | |
| 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.2 | |||
| 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 go.sum +2 -0
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | github.com/bnema/go-wayland-bindings v0.1.0 h1:mi2vNa8BB5ZCo/hq/9d6Af01yhqY6QXAZ6pkljFI1xk= | |
| 2 | 2 | github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA= | |
| 3 | + | github.com/bnema/kvconf v0.1.2 h1:1hDKL9jkQSBdR9dSKbwTONhUUBfJgAdh5vu+yFHOb70= | |
| 4 | + | github.com/bnema/kvconf v0.1.2/go.mod h1:+P2X9I/sLx+Lfir45s9a7WA0GnC22WgKbZWE9LK6lcU= | |
| 3 | 5 | github.com/bnema/neferclient v0.2.0 h1:zgcWfjfQ5wJ2b+tfM3kcX3YFutBchNffR98gc3Ui9uM= | |
| 4 | 6 | github.com/bnema/neferclient v0.2.0/go.mod h1:CzZflJIvinLP8u0rYPjdmAnZjl4Ec2cvdnEGvqFO4Yw= | |
| 5 | 7 | github.com/bnema/nefergui v0.6.1 h1:PkEccPjc5SVXfaGvclS4wO2l15d4TJPSinZzgIWbYmM= | |
| @@ -1,5 +1,7 @@ | |||||
| 1 | github.com/bnema/go-wayland-bindings v0.1.0 h1:mi2vNa8BB5ZCo/hq/9d6Af01yhqY6QXAZ6pkljFI1xk= | 1 | github.com/bnema/go-wayland-bindings v0.1.0 h1:mi2vNa8BB5ZCo/hq/9d6Af01yhqY6QXAZ6pkljFI1xk= | ||
| 2 | github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA= | 2 | github.com/bnema/go-wayland-bindings v0.1.0/go.mod h1:eWOgjp9vzZEqxm059q1G9F5NXplmv9UAmrDJiivxVjA= | ||
| 3 | + | github.com/bnema/kvconf v0.1.2 h1:1hDKL9jkQSBdR9dSKbwTONhUUBfJgAdh5vu+yFHOb70= | |||
| 4 | + | github.com/bnema/kvconf v0.1.2/go.mod h1:+P2X9I/sLx+Lfir45s9a7WA0GnC22WgKbZWE9LK6lcU= | |||
| 3 | github.com/bnema/neferclient v0.2.0 h1:zgcWfjfQ5wJ2b+tfM3kcX3YFutBchNffR98gc3Ui9uM= | 5 | github.com/bnema/neferclient v0.2.0 h1:zgcWfjfQ5wJ2b+tfM3kcX3YFutBchNffR98gc3Ui9uM= | ||
| 4 | github.com/bnema/neferclient v0.2.0/go.mod h1:CzZflJIvinLP8u0rYPjdmAnZjl4Ec2cvdnEGvqFO4Yw= | 6 | github.com/bnema/neferclient v0.2.0/go.mod h1:CzZflJIvinLP8u0rYPjdmAnZjl4Ec2cvdnEGvqFO4Yw= | ||
| 5 | github.com/bnema/nefergui v0.6.1 h1:PkEccPjc5SVXfaGvclS4wO2l15d4TJPSinZzgIWbYmM= | 7 | github.com/bnema/nefergui v0.6.1 h1:PkEccPjc5SVXfaGvclS4wO2l15d4TJPSinZzgIWbYmM= | ||
M internal/adapters/config/config.go +51 -71
| @@ -2,15 +2,14 @@ | |||
| 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 | 10 | "strings" | |
| 11 | - | "time" | |
| 12 | - | "unicode/utf8" | |
| 13 | 11 | ||
| 12 | + | "github.com/bnema/kvconf" | |
| 14 | 13 | "github.com/bnema/neferafk/internal/ports" | |
| 15 | 14 | ) | |
| 16 | 15 | ||
| @@ -26,13 +25,16 @@ func DefaultPath() string { | |||
| 26 | 25 | } | |
| 27 | 26 | return filepath.Join(base, "neferafk", "config") | |
| 28 | 27 | } | |
| 28 | + | ||
| 29 | 29 | func Load(path string) (ports.Config, error) { | |
| 30 | - | f, err := os.Open(path) | |
| 30 | + | data, err := kvconf.ReadFile(path, options) | |
| 31 | 31 | if err != nil { | |
| 32 | + | if errors.Is(err, kvconf.ErrTooLarge) { | |
| 33 | + | return ports.Config{}, errTooLarge | |
| 34 | + | } | |
| 32 | 35 | return ports.Config{}, err | |
| 33 | 36 | } | |
| 34 | - | defer f.Close() | |
| 35 | - | return Parse(f) | |
| 37 | + | return parse(data) | |
| 36 | 38 | } | |
| 37 | 39 | ||
| 38 | 40 | // Parse never returns partial configuration and never resolves PIN references. | |
| @@ -41,75 +43,53 @@ func Parse(r io.Reader) (ports.Config, error) { | |||
| 41 | 43 | if err != nil { | |
| 42 | 44 | return ports.Config{}, err | |
| 43 | 45 | } | |
| 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") | |
| 46 | + | return parse(data) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // options: values are never unquoted, and parse cuts each one at its first | |
| 50 | + | // "#", even inside quotes. parse rejects the whole file on any problem. | |
| 51 | + | var options = kvconf.Options{Comments: kvconf.CommentLine, RawValues: true, MaxBytes: MaxBytes, MaxLine: MaxLine} | |
| 52 | + | ||
| 53 | + | var errTooLarge = errors.New("config exceeds 64KiB") | |
| 54 | + | ||
| 55 | + | // parse fails on the first problem in line order. Strict mode would report | |
| 56 | + | // a line without "=" or a duplicate before an earlier bad value, so the file | |
| 57 | + | // is decoded leniently and its problems compared; a syntax problem wins a tie. | |
| 58 | + | func parse(data []byte) (ports.Config, error) { | |
| 59 | + | entries, syntax, err := kvconf.ParseBytes(data, options) | |
| 60 | + | if err != nil { | |
| 61 | + | return ports.Config{}, reject(err) | |
| 49 | 62 | } | |
| 50 | - | 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 | |
| 63 | + | for i := range entries { | |
| 64 | + | if v, _, cut := strings.Cut(entries[i].Value, "#"); cut { | |
| 65 | + | entries[i].Value = strings.TrimSpace(v) | |
| 106 | 66 | } | |
| 107 | 67 | } | |
| 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 { | |
| 68 | + | cfg := ports.Defaults() | |
| 69 | + | values, err := kvconf.Decode(entries, &cfg, options) | |
| 70 | + | switch { | |
| 71 | + | case len(syntax) > 0 && (len(values) == 0 || syntax[0].Line <= values[0].Line): | |
| 72 | + | return ports.Config{}, lineError(syntax[0]) | |
| 73 | + | case len(values) > 0: | |
| 74 | + | return ports.Config{}, lineError(values[0]) | |
| 75 | + | case err != nil: | |
| 112 | 76 | return ports.Config{}, err | |
| 113 | 77 | } | |
| 114 | 78 | return cfg, nil | |
| 115 | 79 | } | |
| 80 | + | ||
| 81 | + | func reject(err error) error { | |
| 82 | + | switch { | |
| 83 | + | case errors.Is(err, kvconf.ErrTooLarge): | |
| 84 | + | return errTooLarge | |
| 85 | + | case errors.Is(err, kvconf.ErrNotUTF8): | |
| 86 | + | return errors.New("config is not valid UTF-8") | |
| 87 | + | } | |
| 88 | + | return err | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // lineError carries a line number and a fixed message, never the key or | |
| 92 | + | // value, which may be secret. | |
| 93 | + | func lineError(w kvconf.Warning) error { | |
| 94 | + | return fmt.Errorf("line %d: %s", w.Line, w.Msg) | |
| 95 | + | } | |
| @@ -2,15 +2,14 @@ | |||||
| 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" | 10 | "strings" | ||
| 11 | - | "time" | |||
| 12 | - | "unicode/utf8" | |||
| 13 | 11 | ||||
| 12 | + | "github.com/bnema/kvconf" | |||
| 14 | "github.com/bnema/neferafk/internal/ports" | 13 | "github.com/bnema/neferafk/internal/ports" | ||
| 15 | ) | 14 | ) | ||
| 16 | 15 | ||||
| @@ -26,13 +25,16 @@ func DefaultPath() string { | |||||
| 26 | } | 25 | } | ||
| 27 | return filepath.Join(base, "neferafk", "config") | 26 | return filepath.Join(base, "neferafk", "config") | ||
| 28 | } | 27 | } | ||
| 28 | + | ||||
| 29 | func Load(path string) (ports.Config, error) { | 29 | func Load(path string) (ports.Config, error) { | ||
| 30 | - | f, err := os.Open(path) | 30 | + | data, err := kvconf.ReadFile(path, options) |
| 31 | if err != nil { | 31 | if err != nil { | ||
| 32 | + | if errors.Is(err, kvconf.ErrTooLarge) { | |||
| 33 | + | return ports.Config{}, errTooLarge | |||
| 34 | + | } | |||
| 32 | return ports.Config{}, err | 35 | return ports.Config{}, err | ||
| 33 | } | 36 | } | ||
| 34 | - | defer f.Close() | 37 | + | return parse(data) |
| 35 | - | return Parse(f) | |||
| 36 | } | 38 | } | ||
| 37 | 39 | ||||
| 38 | // Parse never returns partial configuration and never resolves PIN references. | 40 | // Parse never returns partial configuration and never resolves PIN references. | ||
| @@ -41,75 +43,53 @@ func Parse(r io.Reader) (ports.Config, error) { | |||||
| 41 | if err != nil { | 43 | if err != nil { | ||
| 42 | return ports.Config{}, err | 44 | return ports.Config{}, err | ||
| 43 | } | 45 | } | ||
| 44 | - | if !utf8.Valid(data) { | 46 | + | return parse(data) |
| 45 | - | return ports.Config{}, fmt.Errorf("config is not valid UTF-8") | 47 | + | } |
| 46 | - | } | 48 | + | |
| 47 | - | if len(data) > MaxBytes { | 49 | + | // options: values are never unquoted, and parse cuts each one at its first |
| 48 | - | return ports.Config{}, fmt.Errorf("config exceeds 64KiB") | 50 | + | // "#", even inside quotes. parse rejects the whole file on any problem. |
| 51 | + | var options = kvconf.Options{Comments: kvconf.CommentLine, RawValues: true, MaxBytes: MaxBytes, MaxLine: MaxLine} | |||
| 52 | + | ||||
| 53 | + | var errTooLarge = errors.New("config exceeds 64KiB") | |||
| 54 | + | ||||
| 55 | + | // parse fails on the first problem in line order. Strict mode would report | |||
| 56 | + | // a line without "=" or a duplicate before an earlier bad value, so the file | |||
| 57 | + | // is decoded leniently and its problems compared; a syntax problem wins a tie. | |||
| 58 | + | func parse(data []byte) (ports.Config, error) { | |||
| 59 | + | entries, syntax, err := kvconf.ParseBytes(data, options) | |||
| 60 | + | if err != nil { | |||
| 61 | + | return ports.Config{}, reject(err) | |||
| 49 | } | 62 | } | ||
| 50 | - | cfg := ports.Defaults() | 63 | + | for i := range entries { |
| 51 | - | seen := map[string]bool{} | 64 | + | if v, _, cut := strings.Cut(entries[i].Value, "#"); cut { |
| 52 | - | scanner := bufio.NewScanner(strings.NewReader(string(data))) | 65 | + | entries[i].Value = strings.TrimSpace(v) |
| 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 | } | 66 | } | ||
| 107 | } | 67 | } | ||
| 108 | - | if err := scanner.Err(); err != nil { | 68 | + | cfg := ports.Defaults() |
| 109 | - | return ports.Config{}, fmt.Errorf("config line exceeds bound") | 69 | + | values, err := kvconf.Decode(entries, &cfg, options) |
| 110 | - | } | 70 | + | switch { |
| 111 | - | if err := cfg.Validate(); err != nil { | 71 | + | case len(syntax) > 0 && (len(values) == 0 || syntax[0].Line <= values[0].Line): |
| 72 | + | return ports.Config{}, lineError(syntax[0]) | |||
| 73 | + | case len(values) > 0: | |||
| 74 | + | return ports.Config{}, lineError(values[0]) | |||
| 75 | + | case err != nil: | |||
| 112 | return ports.Config{}, err | 76 | return ports.Config{}, err | ||
| 113 | } | 77 | } | ||
| 114 | return cfg, nil | 78 | return cfg, nil | ||
| 115 | } | 79 | } | ||
| 80 | + | ||||
| 81 | + | func reject(err error) error { | |||
| 82 | + | switch { | |||
| 83 | + | case errors.Is(err, kvconf.ErrTooLarge): | |||
| 84 | + | return errTooLarge | |||
| 85 | + | case errors.Is(err, kvconf.ErrNotUTF8): | |||
| 86 | + | return errors.New("config is not valid UTF-8") | |||
| 87 | + | } | |||
| 88 | + | return err | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // lineError carries a line number and a fixed message, never the key or | |||
| 92 | + | // value, which may be secret. | |||
| 93 | + | func lineError(w kvconf.Warning) error { | |||
| 94 | + | return fmt.Errorf("line %d: %s", w.Line, w.Msg) | |||
| 95 | + | } | |||
M internal/adapters/config/config_test.go +62 -0
| @@ -72,3 +72,65 @@ func TestRejectInvalidUTF8BeforeParsingAndDirectMetadata(t *testing.T) { | |||
| 72 | 72 | t.Fatal("direct invalid UTF-8 metadata admitted") | |
| 73 | 73 | } | |
| 74 | 74 | } | |
| 75 | + | ||
| 76 | + | func TestParseReportsFirstProblemInLineOrder(t *testing.T) { | |
| 77 | + | long := strings.Repeat("#", MaxLine+1) | |
| 78 | + | for _, tc := range []struct{ name, text, want string }{ | |
| 79 | + | {"bad value before missing =", "lock.after=bad\nfoo", "line 1: "}, | |
| 80 | + | {"bad value before duplicate", "lock.after=bad\nlock.after=1s", "line 1: "}, | |
| 81 | + | {"unknown key before missing =", "unknown=x\nnoeq", "line 1: unknown key"}, | |
| 82 | + | {"bad value before long line", "lock.after=bad\n" + long, "line 1: "}, | |
| 83 | + | {"missing = before bad value", "noeq\nlock.after=bad", "line 1: expected key = value"}, | |
| 84 | + | {"duplicate before later bad value", "lock.after=1s\nlock.after=2s\nsleep.after=bad", "line 2: overrides line 1"}, | |
| 85 | + | {"duplicate wins tie with its bad value", "lock.after=1s\nlock.after=bad", "line 2: overrides line 1"}, | |
| 86 | + | } { | |
| 87 | + | t.Run(tc.name, func(t *testing.T) { | |
| 88 | + | c, err := Parse(strings.NewReader(tc.text)) | |
| 89 | + | if err == nil || !strings.HasPrefix(err.Error(), tc.want) || c != (ports.Config{}) { | |
| 90 | + | t.Fatalf("got %v, %+v; want error starting %q", err, c, tc.want) | |
| 91 | + | } | |
| 92 | + | }) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestParseHashStartsCommentEvenInQuotes(t *testing.T) { | |
| 97 | + | for _, tc := range []struct{ name, text, want string }{ | |
| 98 | + | {"plain", "lock.output = DP-2 # left", "DP-2"}, | |
| 99 | + | {"no space", "lock.output = DP-2#left", "DP-2"}, | |
| 100 | + | {"inside double quotes", `lock.output = "DP#2"`, `"DP`}, | |
| 101 | + | {"after unbalanced quote", `lock.output = DP-2 "# note`, `DP-2 "`}, | |
| 102 | + | {"inside single quotes", "lock.output = 'DP#2'", "'DP"}, | |
| 103 | + | } { | |
| 104 | + | t.Run(tc.name, func(t *testing.T) { | |
| 105 | + | c, err := Parse(strings.NewReader(tc.text)) | |
| 106 | + | if err != nil || c.LockOutput != tc.want { | |
| 107 | + | t.Fatalf("got %q, %v; want %q", c.LockOutput, err, tc.want) | |
| 108 | + | } | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | if _, err := Parse(strings.NewReader("lock.output = # only a comment")); err == nil { | |
| 112 | + | t.Fatal("value made empty by a comment accepted") | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // The kv tags on ports.Config repeat MaxDuration and the auth constants. | |
| 117 | + | func TestTagsMatchPortsConstants(t *testing.T) { | |
| 118 | + | max := ports.MaxDuration.String() | |
| 119 | + | for _, key := range []string{"lock.after", "fade.after", "screens.off-after", "sleep.after", "fade.duration"} { | |
| 120 | + | if _, err := Parse(strings.NewReader(key + "=" + max)); err != nil { | |
| 121 | + | t.Errorf("%s=%s rejected: %v", key, max, err) | |
| 122 | + | } | |
| 123 | + | if _, err := Parse(strings.NewReader(key + "=" + (ports.MaxDuration + 1).String())); err == nil { | |
| 124 | + | t.Errorf("%s above %s accepted", key, max) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | for _, text := range []string{ | |
| 128 | + | "auth.mode=" + string(ports.AuthModePassword), | |
| 129 | + | "auth.mode=" + string(ports.AuthModePIN) + "\nauth.pin-source=" + string(ports.PINSourceEnv) + "\nauth.pin-env=PIN", | |
| 130 | + | "auth.pin-source=" + string(ports.PINSourcePass) + "\nauth.pin-entry=a/b", | |
| 131 | + | } { | |
| 132 | + | if _, err := Parse(strings.NewReader(text)); err != nil { | |
| 133 | + | t.Errorf("%q rejected: %v", text, err) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | } | |
| @@ -72,3 +72,65 @@ func TestRejectInvalidUTF8BeforeParsingAndDirectMetadata(t *testing.T) { | |||||
| 72 | t.Fatal("direct invalid UTF-8 metadata admitted") | 72 | t.Fatal("direct invalid UTF-8 metadata admitted") | ||
| 73 | } | 73 | } | ||
| 74 | } | 74 | } | ||
| 75 | + | ||||
| 76 | + | func TestParseReportsFirstProblemInLineOrder(t *testing.T) { | |||
| 77 | + | long := strings.Repeat("#", MaxLine+1) | |||
| 78 | + | for _, tc := range []struct{ name, text, want string }{ | |||
| 79 | + | {"bad value before missing =", "lock.after=bad\nfoo", "line 1: "}, | |||
| 80 | + | {"bad value before duplicate", "lock.after=bad\nlock.after=1s", "line 1: "}, | |||
| 81 | + | {"unknown key before missing =", "unknown=x\nnoeq", "line 1: unknown key"}, | |||
| 82 | + | {"bad value before long line", "lock.after=bad\n" + long, "line 1: "}, | |||
| 83 | + | {"missing = before bad value", "noeq\nlock.after=bad", "line 1: expected key = value"}, | |||
| 84 | + | {"duplicate before later bad value", "lock.after=1s\nlock.after=2s\nsleep.after=bad", "line 2: overrides line 1"}, | |||
| 85 | + | {"duplicate wins tie with its bad value", "lock.after=1s\nlock.after=bad", "line 2: overrides line 1"}, | |||
| 86 | + | } { | |||
| 87 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 88 | + | c, err := Parse(strings.NewReader(tc.text)) | |||
| 89 | + | if err == nil || !strings.HasPrefix(err.Error(), tc.want) || c != (ports.Config{}) { | |||
| 90 | + | t.Fatalf("got %v, %+v; want error starting %q", err, c, tc.want) | |||
| 91 | + | } | |||
| 92 | + | }) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestParseHashStartsCommentEvenInQuotes(t *testing.T) { | |||
| 97 | + | for _, tc := range []struct{ name, text, want string }{ | |||
| 98 | + | {"plain", "lock.output = DP-2 # left", "DP-2"}, | |||
| 99 | + | {"no space", "lock.output = DP-2#left", "DP-2"}, | |||
| 100 | + | {"inside double quotes", `lock.output = "DP#2"`, `"DP`}, | |||
| 101 | + | {"after unbalanced quote", `lock.output = DP-2 "# note`, `DP-2 "`}, | |||
| 102 | + | {"inside single quotes", "lock.output = 'DP#2'", "'DP"}, | |||
| 103 | + | } { | |||
| 104 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 105 | + | c, err := Parse(strings.NewReader(tc.text)) | |||
| 106 | + | if err != nil || c.LockOutput != tc.want { | |||
| 107 | + | t.Fatalf("got %q, %v; want %q", c.LockOutput, err, tc.want) | |||
| 108 | + | } | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 111 | + | if _, err := Parse(strings.NewReader("lock.output = # only a comment")); err == nil { | |||
| 112 | + | t.Fatal("value made empty by a comment accepted") | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // The kv tags on ports.Config repeat MaxDuration and the auth constants. | |||
| 117 | + | func TestTagsMatchPortsConstants(t *testing.T) { | |||
| 118 | + | max := ports.MaxDuration.String() | |||
| 119 | + | for _, key := range []string{"lock.after", "fade.after", "screens.off-after", "sleep.after", "fade.duration"} { | |||
| 120 | + | if _, err := Parse(strings.NewReader(key + "=" + max)); err != nil { | |||
| 121 | + | t.Errorf("%s=%s rejected: %v", key, max, err) | |||
| 122 | + | } | |||
| 123 | + | if _, err := Parse(strings.NewReader(key + "=" + (ports.MaxDuration + 1).String())); err == nil { | |||
| 124 | + | t.Errorf("%s above %s accepted", key, max) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | for _, text := range []string{ | |||
| 128 | + | "auth.mode=" + string(ports.AuthModePassword), | |||
| 129 | + | "auth.mode=" + string(ports.AuthModePIN) + "\nauth.pin-source=" + string(ports.PINSourceEnv) + "\nauth.pin-env=PIN", | |||
| 130 | + | "auth.pin-source=" + string(ports.PINSourcePass) + "\nauth.pin-entry=a/b", | |||
| 131 | + | } { | |||
| 132 | + | if _, err := Parse(strings.NewReader(text)); err != nil { | |||
| 133 | + | t.Errorf("%q rejected: %v", text, err) | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | } | |||
M internal/adapters/config/watch.go +63 -153
| @@ -2,113 +2,79 @@ package config | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "encoding/binary" | |
| 6 | 5 | "errors" | |
| 7 | - | "os" | |
| 8 | - | "path/filepath" | |
| 9 | - | "strings" | |
| 6 | + | "io/fs" | |
| 10 | 7 | "time" | |
| 11 | 8 | ||
| 9 | + | "github.com/bnema/kvconf" | |
| 12 | 10 | "github.com/bnema/neferafk/internal/ports" | |
| 13 | - | "golang.org/x/sys/unix" | |
| 14 | 11 | ) | |
| 15 | 12 | ||
| 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 | - | } | |
| 13 | + | // fallbackPoll is the polling interval used when native watching fails. | |
| 14 | + | var fallbackPoll = 2 * time.Second | |
| 42 | 15 | ||
| 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 | - | } | |
| 16 | + | // kvWatch is kvconf.Watch; tests replace it to make native watching fail. | |
| 17 | + | var kvWatch = kvconf.Watch | |
| 60 | 18 | ||
| 61 | 19 | // 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. | |
| 20 | + | // config symlink targets (kvconf.Watch). It always emits the first valid | |
| 21 | + | // candidate, including a candidate equal to defaults: this closes an app | |
| 22 | + | // Load -> Watch startup gap. Ready closes after initial watch installation, | |
| 23 | + | // before candidate delivery. No invalid/deleted candidate emits a defaults | |
| 24 | + | // reset. Errors never echo file content or paths, which may themselves | |
| 25 | + | // contain sensitive source metadata. When the OS refuses to watch (inotify | |
| 26 | + | // limits, permissions), Watch reports it on failures and polls instead: a | |
| 27 | + | // watch error must never stop the daemon and with it idle locking. | |
| 67 | 28 | 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 | |
| 29 | + | ctx, cancel := context.WithCancel(ctx) | |
| 30 | + | changed := make(chan struct{}, 1) | |
| 31 | + | installed := make(chan struct{}) | |
| 32 | + | done := make(chan struct{}) | |
| 33 | + | var watchErr error | |
| 34 | + | go func() { | |
| 35 | + | defer close(done) | |
| 36 | + | err := kvWatch(ctx, path, kvconf.WatchOptions{Ready: installed}, changed) | |
| 37 | + | if err == nil || ctx.Err() != nil { | |
| 38 | + | return | |
| 94 | 39 | } | |
| 95 | - | for wd := range names { | |
| 96 | - | if want[wd] == nil { | |
| 97 | - | _, _ = unix.InotifyRmWatch(fd, uint32(wd)) | |
| 40 | + | if failures != nil { | |
| 41 | + | select { | |
| 42 | + | case failures <- errors.New("config watch failed; polling"): | |
| 43 | + | default: | |
| 98 | 44 | } | |
| 99 | 45 | } | |
| 100 | - | names = want | |
| 46 | + | opts := kvconf.WatchOptions{Poll: fallbackPoll} | |
| 47 | + | select { | |
| 48 | + | case <-installed: // closed by the native watch before it failed | |
| 49 | + | default: | |
| 50 | + | opts.Ready = installed | |
| 51 | + | } | |
| 52 | + | // Changes made while nothing watched are seen by this reload. | |
| 53 | + | select { | |
| 54 | + | case changed <- struct{}{}: | |
| 55 | + | default: | |
| 56 | + | } | |
| 57 | + | watchErr = kvWatch(ctx, path, opts, changed) | |
| 58 | + | }() | |
| 59 | + | defer func() { cancel(); <-done }() | |
| 60 | + | ||
| 61 | + | select { | |
| 62 | + | case <-installed: | |
| 63 | + | case <-done: | |
| 64 | + | if watchErr != nil { | |
| 65 | + | return errors.New("initialize config watch") | |
| 66 | + | } | |
| 67 | + | return nil | |
| 101 | 68 | } | |
| 102 | - | rewatch() | |
| 103 | 69 | if ready != nil { | |
| 104 | 70 | close(ready) | |
| 105 | 71 | } | |
| 106 | 72 | var last ports.Config | |
| 107 | 73 | haveLast := false | |
| 108 | 74 | publish := func() bool { | |
| 109 | - | cfg, e := Load(path) | |
| 110 | - | if e != nil { | |
| 111 | - | if !os.IsNotExist(e) && failures != nil { | |
| 75 | + | cfg, err := Load(path) | |
| 76 | + | if err != nil { | |
| 77 | + | if !errors.Is(err, fs.ErrNotExist) && failures != nil { | |
| 112 | 78 | select { | |
| 113 | 79 | case failures <- errors.New("config reload rejected"): | |
| 114 | 80 | default: | |
| @@ -127,73 +93,17 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, fai | |||
| 127 | 93 | return false | |
| 128 | 94 | } | |
| 129 | 95 | } | |
| 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 | - | } | |
| 96 | + | for ok := publish(); ok; { | |
| 97 | + | select { | |
| 98 | + | case <-ctx.Done(): | |
| 99 | + | return nil | |
| 100 | + | case <-done: | |
| 101 | + | if watchErr != nil { | |
| 102 | + | return errors.New("config watch") | |
| 195 | 103 | } | |
| 196 | - | offset += size | |
| 104 | + | return nil | |
| 105 | + | case <-changed: | |
| 106 | + | ok = publish() | |
| 197 | 107 | } | |
| 198 | 108 | } | |
| 199 | 109 | return nil | |
| @@ -2,113 +2,79 @@ 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" | 7 | "time" | ||
| 11 | 8 | ||||
| 9 | + | "github.com/bnema/kvconf" | |||
| 12 | "github.com/bnema/neferafk/internal/ports" | 10 | "github.com/bnema/neferafk/internal/ports" | ||
| 13 | - | "golang.org/x/sys/unix" | |||
| 14 | ) | 11 | ) | ||
| 15 | 12 | ||||
| 16 | - | func watchedFiles(path string) []string { | 13 | + | // fallbackPoll is the polling interval used when native watching fails. |
| 17 | - | files := []string{filepath.Clean(path)} | 14 | + | var fallbackPoll = 2 * time.Second |
| 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 | 15 | ||||
| 43 | - | // watchDirectory finds the nearest existing ancestor. The missing component's | 16 | + | // kvWatch is kvconf.Watch; tests replace it to make native watching fail. |
| 44 | - | // creation wakes inotify; the two-second retry also covers lost/overflow events. | 17 | + | var kvWatch = kvconf.Watch |
| 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 | 18 | ||||
| 61 | // Watch retains last-valid state across missing files/directories and follows | 19 | // Watch retains last-valid state across missing files/directories and follows | ||
| 62 | - | // config symlink targets. It always emits the first valid candidate, including | 20 | + | // 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. | 21 | + | // candidate, including a candidate equal to defaults: this closes an app |
| 64 | - | // Ready closes after initial watch installation, before candidate delivery. | 22 | + | // Load -> Watch startup gap. Ready closes after initial watch installation, |
| 65 | - | // No invalid/deleted candidate emits a defaults reset. Errors never echo file | 23 | + | // before candidate delivery. No invalid/deleted candidate emits a defaults |
| 66 | - | // content or paths, which may themselves contain sensitive source metadata. | 24 | + | // reset. Errors never echo file content or paths, which may themselves |
| 25 | + | // contain sensitive source metadata. When the OS refuses to watch (inotify | |||
| 26 | + | // limits, permissions), Watch reports it on failures and polls instead: a | |||
| 27 | + | // watch error must never stop the daemon and with it idle locking. | |||
| 67 | func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, failures chan<- error, ready chan<- struct{}) error { | 28 | 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) | 29 | + | ctx, cancel := context.WithCancel(ctx) |
| 69 | - | if err != nil { | 30 | + | changed := make(chan struct{}, 1) |
| 70 | - | return errors.New("initialize config watch") | 31 | + | installed := make(chan struct{}) |
| 71 | - | } | 32 | + | done := make(chan struct{}) |
| 72 | - | defer unix.Close(fd) | 33 | + | 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 | 34 | + | go func() { |
| 74 | - | names := map[int32]map[string]bool{} | 35 | + | defer close(done) |
| 75 | - | retry := time.Time{} | 36 | + | err := kvWatch(ctx, path, kvconf.WatchOptions{Ready: installed}, changed) |
| 76 | - | rewatch := func() { | 37 | + | if err == nil || ctx.Err() != nil { |
| 77 | - | // Keep unchanged watches, avoiding IN_IGNORED from our own removals. | 38 | + | return |
| 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 | } | 39 | } | ||
| 95 | - | for wd := range names { | 40 | + | if failures != nil { |
| 96 | - | if want[wd] == nil { | 41 | + | select { |
| 97 | - | _, _ = unix.InotifyRmWatch(fd, uint32(wd)) | 42 | + | case failures <- errors.New("config watch failed; polling"): |
| 43 | + | default: | |||
| 98 | } | 44 | } | ||
| 99 | } | 45 | } | ||
| 100 | - | names = want | 46 | + | opts := kvconf.WatchOptions{Poll: fallbackPoll} |
| 47 | + | select { | |||
| 48 | + | case <-installed: // closed by the native watch before it failed | |||
| 49 | + | default: | |||
| 50 | + | opts.Ready = installed | |||
| 51 | + | } | |||
| 52 | + | // Changes made while nothing watched are seen by this reload. | |||
| 53 | + | select { | |||
| 54 | + | case changed <- struct{}{}: | |||
| 55 | + | default: | |||
| 56 | + | } | |||
| 57 | + | watchErr = kvWatch(ctx, path, opts, changed) | |||
| 58 | + | }() | |||
| 59 | + | defer func() { cancel(); <-done }() | |||
| 60 | + | ||||
| 61 | + | select { | |||
| 62 | + | case <-installed: | |||
| 63 | + | case <-done: | |||
| 64 | + | if watchErr != nil { | |||
| 65 | + | return errors.New("initialize config watch") | |||
| 66 | + | } | |||
| 67 | + | return nil | |||
| 101 | } | 68 | } | ||
| 102 | - | rewatch() | |||
| 103 | if ready != nil { | 69 | if ready != nil { | ||
| 104 | close(ready) | 70 | close(ready) | ||
| 105 | } | 71 | } | ||
| 106 | var last ports.Config | 72 | var last ports.Config | ||
| 107 | haveLast := false | 73 | haveLast := false | ||
| 108 | publish := func() bool { | 74 | publish := func() bool { | ||
| 109 | - | cfg, e := Load(path) | 75 | + | cfg, err := Load(path) |
| 110 | - | if e != nil { | 76 | + | if err != nil { |
| 111 | - | if !os.IsNotExist(e) && failures != nil { | 77 | + | if !errors.Is(err, fs.ErrNotExist) && failures != nil { |
| 112 | select { | 78 | select { | ||
| 113 | case failures <- errors.New("config reload rejected"): | 79 | case failures <- errors.New("config reload rejected"): | ||
| 114 | default: | 80 | default: | ||
| @@ -127,73 +93,17 @@ func Watch(ctx context.Context, path string, out chan<- ports.ConfigChanged, fai | |||||
| 127 | return false | 93 | return false | ||
| 128 | } | 94 | } | ||
| 129 | } | 95 | } | ||
| 130 | - | if !publish() { | 96 | + | for ok := publish(); ok; { |
| 131 | - | return nil | 97 | + | select { |
| 132 | - | } | 98 | + | case <-ctx.Done(): |
| 133 | - | pending := time.Time{} | 99 | + | return nil |
| 134 | - | buf := make([]byte, 8192) | 100 | + | case <-done: |
| 135 | - | for ctx.Err() == nil { | 101 | + | if watchErr != nil { |
| 136 | - | now := time.Now() | 102 | + | 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 | } | 103 | } | ||
| 196 | - | offset += size | 104 | + | return nil |
| 105 | + | case <-changed: | |||
| 106 | + | ok = publish() | |||
| 197 | } | 107 | } | ||
| 198 | } | 108 | } | ||
| 199 | return nil | 109 | return nil | ||
M internal/adapters/config/watch_test.go +70 -0
| @@ -2,6 +2,8 @@ package config | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 6 | + | "github.com/bnema/kvconf" | |
| 5 | 7 | "github.com/bnema/neferafk/internal/ports" | |
| 6 | 8 | "os" | |
| 7 | 9 | "path/filepath" | |
| @@ -197,3 +199,71 @@ func TestWatchFirstCandidateClosesLoadStartupGapAndMissingCancellation(t *testin | |||
| 197 | 199 | t.Fatal(err) | |
| 198 | 200 | } | |
| 199 | 201 | } | |
| 202 | + | ||
| 203 | + | // A watch the OS refuses must fall back to polling, never stop the daemon. | |
| 204 | + | func TestWatchFallsBackToPolling(t *testing.T) { | |
| 205 | + | orig, origPoll := kvWatch, fallbackPoll | |
| 206 | + | t.Cleanup(func() { kvWatch, fallbackPoll = orig, origPoll }) | |
| 207 | + | fallbackPoll = 20 * time.Millisecond | |
| 208 | + | for _, tc := range []struct { | |
| 209 | + | name string | |
| 210 | + | setup func(t *testing.T, dir string) | |
| 211 | + | }{ | |
| 212 | + | {"native watch fails at startup", func(t *testing.T, _ string) { | |
| 213 | + | kvWatch = func(ctx context.Context, path string, opts kvconf.WatchOptions, out chan<- struct{}) error { | |
| 214 | + | if opts.Poll == 0 { | |
| 215 | + | return errors.New("no space left on device") | |
| 216 | + | } | |
| 217 | + | return orig(ctx, path, opts, out) | |
| 218 | + | } | |
| 219 | + | }}, | |
| 220 | + | {"native watch fails after install", func(t *testing.T, _ string) { | |
| 221 | + | kvWatch = func(ctx context.Context, path string, opts kvconf.WatchOptions, out chan<- struct{}) error { | |
| 222 | + | if opts.Poll == 0 { | |
| 223 | + | close(opts.Ready) | |
| 224 | + | return errors.New("no space left on device") | |
| 225 | + | } | |
| 226 | + | return orig(ctx, path, opts, out) | |
| 227 | + | } | |
| 228 | + | }}, | |
| 229 | + | {"directory not readable", func(t *testing.T, dir string) { | |
| 230 | + | if os.Geteuid() == 0 { | |
| 231 | + | t.Skip("root ignores directory permissions") | |
| 232 | + | } | |
| 233 | + | kvWatch = orig | |
| 234 | + | if err := os.Chmod(dir, 0300); err != nil { | |
| 235 | + | t.Fatal(err) | |
| 236 | + | } | |
| 237 | + | t.Cleanup(func() { os.Chmod(dir, 0700) }) | |
| 238 | + | }}, | |
| 239 | + | } { | |
| 240 | + | t.Run(tc.name, func(t *testing.T) { | |
| 241 | + | dir := t.TempDir() | |
| 242 | + | path := filepath.Join(dir, "config") | |
| 243 | + | save(t, path, "lock.after=1m\n") | |
| 244 | + | tc.setup(t, dir) | |
| 245 | + | out := make(chan ports.ConfigChanged, 4) | |
| 246 | + | errs := make(chan error, 4) | |
| 247 | + | ready := make(chan struct{}) | |
| 248 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 249 | + | defer cancel() | |
| 250 | + | done := make(chan error, 1) | |
| 251 | + | go func() { done <- Watch(ctx, path, out, errs, ready) }() | |
| 252 | + | recv(t, ready) | |
| 253 | + | if err := recv(t, errs); err.Error() != "config watch failed; polling" { | |
| 254 | + | t.Fatal(err) | |
| 255 | + | } | |
| 256 | + | if got := recv(t, out); got.Config.LockAfter != time.Minute { | |
| 257 | + | t.Fatal(got) | |
| 258 | + | } | |
| 259 | + | save(t, path, "lock.after=2m\n") | |
| 260 | + | if got := recv(t, out); got.Config.LockAfter != 2*time.Minute { | |
| 261 | + | t.Fatal(got) | |
| 262 | + | } | |
| 263 | + | cancel() | |
| 264 | + | if err := recv(t, done); err != nil { | |
| 265 | + | t.Fatal(err) | |
| 266 | + | } | |
| 267 | + | }) | |
| 268 | + | } | |
| 269 | + | } | |
| @@ -2,6 +2,8 @@ package config | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 6 | + | "github.com/bnema/kvconf" | |||
| 5 | "github.com/bnema/neferafk/internal/ports" | 7 | "github.com/bnema/neferafk/internal/ports" | ||
| 6 | "os" | 8 | "os" | ||
| 7 | "path/filepath" | 9 | "path/filepath" | ||
| @@ -197,3 +199,71 @@ func TestWatchFirstCandidateClosesLoadStartupGapAndMissingCancellation(t *testin | |||||
| 197 | t.Fatal(err) | 199 | t.Fatal(err) | ||
| 198 | } | 200 | } | ||
| 199 | } | 201 | } | ||
| 202 | + | ||||
| 203 | + | // A watch the OS refuses must fall back to polling, never stop the daemon. | |||
| 204 | + | func TestWatchFallsBackToPolling(t *testing.T) { | |||
| 205 | + | orig, origPoll := kvWatch, fallbackPoll | |||
| 206 | + | t.Cleanup(func() { kvWatch, fallbackPoll = orig, origPoll }) | |||
| 207 | + | fallbackPoll = 20 * time.Millisecond | |||
| 208 | + | for _, tc := range []struct { | |||
| 209 | + | name string | |||
| 210 | + | setup func(t *testing.T, dir string) | |||
| 211 | + | }{ | |||
| 212 | + | {"native watch fails at startup", func(t *testing.T, _ string) { | |||
| 213 | + | kvWatch = func(ctx context.Context, path string, opts kvconf.WatchOptions, out chan<- struct{}) error { | |||
| 214 | + | if opts.Poll == 0 { | |||
| 215 | + | return errors.New("no space left on device") | |||
| 216 | + | } | |||
| 217 | + | return orig(ctx, path, opts, out) | |||
| 218 | + | } | |||
| 219 | + | }}, | |||
| 220 | + | {"native watch fails after install", func(t *testing.T, _ string) { | |||
| 221 | + | kvWatch = func(ctx context.Context, path string, opts kvconf.WatchOptions, out chan<- struct{}) error { | |||
| 222 | + | if opts.Poll == 0 { | |||
| 223 | + | close(opts.Ready) | |||
| 224 | + | return errors.New("no space left on device") | |||
| 225 | + | } | |||
| 226 | + | return orig(ctx, path, opts, out) | |||
| 227 | + | } | |||
| 228 | + | }}, | |||
| 229 | + | {"directory not readable", func(t *testing.T, dir string) { | |||
| 230 | + | if os.Geteuid() == 0 { | |||
| 231 | + | t.Skip("root ignores directory permissions") | |||
| 232 | + | } | |||
| 233 | + | kvWatch = orig | |||
| 234 | + | if err := os.Chmod(dir, 0300); err != nil { | |||
| 235 | + | t.Fatal(err) | |||
| 236 | + | } | |||
| 237 | + | t.Cleanup(func() { os.Chmod(dir, 0700) }) | |||
| 238 | + | }}, | |||
| 239 | + | } { | |||
| 240 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 241 | + | dir := t.TempDir() | |||
| 242 | + | path := filepath.Join(dir, "config") | |||
| 243 | + | save(t, path, "lock.after=1m\n") | |||
| 244 | + | tc.setup(t, dir) | |||
| 245 | + | out := make(chan ports.ConfigChanged, 4) | |||
| 246 | + | errs := make(chan error, 4) | |||
| 247 | + | ready := make(chan struct{}) | |||
| 248 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 249 | + | defer cancel() | |||
| 250 | + | done := make(chan error, 1) | |||
| 251 | + | go func() { done <- Watch(ctx, path, out, errs, ready) }() | |||
| 252 | + | recv(t, ready) | |||
| 253 | + | if err := recv(t, errs); err.Error() != "config watch failed; polling" { | |||
| 254 | + | t.Fatal(err) | |||
| 255 | + | } | |||
| 256 | + | if got := recv(t, out); got.Config.LockAfter != time.Minute { | |||
| 257 | + | t.Fatal(got) | |||
| 258 | + | } | |||
| 259 | + | save(t, path, "lock.after=2m\n") | |||
| 260 | + | if got := recv(t, out); got.Config.LockAfter != 2*time.Minute { | |||
| 261 | + | t.Fatal(got) | |||
| 262 | + | } | |||
| 263 | + | cancel() | |||
| 264 | + | if err := recv(t, done); err != nil { | |||
| 265 | + | t.Fatal(err) | |||
| 266 | + | } | |||
| 267 | + | }) | |||
| 268 | + | } | |||
| 269 | + | } | |||
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 { | ||