1// Package captureallow reads the screen-capture allowlist: the executables
2// that may capture the screen. The file is /etc/neferwl/capture-allow, one
3// absolute executable path per line; it is root-owned and fixed, never read
4// from the user config that a program of the same user could edit.
5package captureallow
6
7import (
8 "bufio"
9 "errors"
10 "fmt"
11 "io"
12 "maps"
13 "path/filepath"
14 "strings"
15)
16
17// DefaultPath is where the allowlist lives.
18const DefaultPath = "/etc/neferwl/capture-allow"
19
20// maxSize bounds what is read: a bigger file is invalid.
21const maxSize = 1 << 20
22
23// Mode is how a policy decides.
24type Mode int
25
26const (
27 // ModeList allows the listed executables only.
28 ModeList Mode = iota
29 // ModeDisabled allows every client: a `*` line.
30 ModeDisabled
31 // ModeClosed allows no client: the file is unreadable or invalid.
32 ModeClosed
33)
34
35// Policy is an immutable allowlist. The zero value allows nothing.
36type Policy struct {
37 mode Mode
38 exes map[string]struct{}
39}
40
41// Builtin returns the built-in list policy, used when the file is missing. A
42// file replaces the list entirely. The list is built on each call, so no
43// package state can be changed.
44func Builtin() *Policy {
45 builtin := [...]string{
46 "/usr/bin/grim",
47 "/usr/bin/nefercap",
48 "/usr/lib/xdg-desktop-portal-wlr",
49 "/usr/libexec/xdg-desktop-portal-wlr",
50 }
51 p := &Policy{mode: ModeList, exes: make(map[string]struct{}, len(builtin))}
52 for _, e := range builtin {
53 p.exes[e] = struct{}{}
54 }
55 return p
56}
57
58// Disabled returns the policy that lets every client capture.
59func Disabled() *Policy { return &Policy{mode: ModeDisabled} }
60
61// Closed returns the policy that lets no client capture.
62func Closed() *Policy { return &Policy{mode: ModeClosed} }
63
64// Mode reports how the policy decides.
65func (p *Policy) Mode() Mode { return p.mode }
66
67// Len is the number of listed executables.
68func (p *Policy) Len() int { return len(p.exes) }
69
70func (p *Policy) equal(o *Policy) bool {
71 return p.mode == o.mode && maps.Equal(p.exes, o.exes)
72}
73
74// Allows reports whether the executable at path may capture. The comparison
75// is exact, on the cleaned absolute path.
76func (p *Policy) Allows(exe string) bool {
77 switch p.mode {
78 case ModeDisabled:
79 return true
80 case ModeClosed:
81 return false
82 }
83 if !filepath.IsAbs(exe) {
84 return false
85 }
86 _, ok := p.exes[filepath.Clean(exe)]
87 return ok
88}
89
90// ParseError names the offending line of an invalid file.
91type ParseError struct {
92 Line int
93 Text string
94 Msg string
95}
96
97func (e *ParseError) Error() string {
98 return fmt.Sprintf("line %d: %s: %q", e.Line, e.Msg, e.Text)
99}
100
101// Parse reads the file format:
102//
103// - blank lines and lines starting with `#` are ignored;
104// - a line `*` alone disables the check;
105// - any other line is an absolute executable path, taken whole after
106// trimming surrounding white space (a `#` inside a line is part of the
107// path).
108//
109// Any other content is an error naming its line; the policy is then nil.
110func Parse(r io.Reader) (*Policy, error) {
111 p := &Policy{mode: ModeList, exes: map[string]struct{}{}}
112 all := false
113 sc := bufio.NewScanner(io.LimitReader(r, maxSize+1))
114 sc.Buffer(make([]byte, 0, 4096), maxSize+1)
115 n, read := 0, 0
116 for sc.Scan() {
117 n++
118 raw := sc.Text()
119 read += len(raw) + 1
120 if read > maxSize {
121 return nil, &ParseError{Line: n, Text: clip(raw), Msg: "file too large"}
122 }
123 line := strings.TrimSpace(raw)
124 switch {
125 case line == "" || strings.HasPrefix(line, "#"):
126 case line == "*":
127 all = true
128 case !filepath.IsAbs(line) || strings.ContainsRune(line, 0) || strings.HasSuffix(line, "/"):
129 return nil, &ParseError{Line: n, Text: clip(line), Msg: "want an absolute executable path"}
130 default:
131 p.exes[filepath.Clean(line)] = struct{}{}
132 }
133 }
134 if err := sc.Err(); err != nil {
135 if errors.Is(err, bufio.ErrTooLong) {
136 return nil, &ParseError{Line: n + 1, Msg: "file too large"}
137 }
138 return nil, err
139 }
140 if all {
141 return Disabled(), nil
142 }
143 return p, nil
144}
145
146func clip(s string) string {
147 if len(s) > 120 {
148 return s[:120] + "..."
149 }
150 return s
151}