1package launcher
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "sort"
10 "strconv"
11 "strings"
12 "syscall"
13
14 "github.com/bnema/neferwl/internal/ports"
15 "github.com/bnema/zerowrap"
16)
17
18type Launcher struct {
19 // Security is the optional defensive admission gate. Set before Run.
20 Security ports.SessionSecurity
21 env []string
22 log zerowrap.Logger
23}
24
25func New(env []string, log zerowrap.Logger) *Launcher {
26 return &Launcher{env: append([]string(nil), env...), log: log}
27}
28
29func ChildEnv(base []string, waylandDisplay, runtimeDir string, cursorSize int) []string {
30 values := map[string]string{}
31 for _, entry := range base {
32 key, value, ok := strings.Cut(entry, "=")
33 if !ok {
34 continue
35 }
36 switch {
37 case key == "HOME", key == "USER", key == "LOGNAME", key == "SHELL", key == "PATH", key == "LANG", key == "DBUS_SESSION_BUS_ADDRESS", strings.HasPrefix(key, "LC_"), strings.HasPrefix(key, "XDG_"), strings.HasPrefix(key, "XCURSOR_"):
38 values[key] = value
39 }
40 }
41 values["WAYLAND_DISPLAY"] = waylandDisplay
42 values["XDG_RUNTIME_DIR"] = runtimeDir
43 values["XDG_CURRENT_DESKTOP"] = "neferwl"
44 values["XDG_SESSION_TYPE"] = "wayland"
45 // X11 apps (through Xwayland) and toolkits pick their own cursor size:
46 // give them the compositor's.
47 values["XCURSOR_SIZE"] = strconv.Itoa(cursorSize)
48 keys := make([]string, 0, len(values))
49 for key := range values {
50 keys = append(keys, key)
51 }
52 sort.Strings(keys)
53 env := make([]string, 0, len(keys))
54 for _, key := range keys {
55 env = append(env, key+"="+values[key])
56 }
57 return env
58}
59
60func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) error {
61 for {
62 select {
63 case <-ctx.Done():
64 return nil
65 case req, ok := <-reqs:
66 if !ok {
67 return nil
68 }
69 if !l.admitted(req) {
70 continue
71 }
72 if len(req.Argv) == 0 {
73 l.log.Warn().Msg("empty spawn argv")
74 continue
75 }
76 path, err := childLookPath(req.Argv[0], l.env)
77 if err != nil {
78 l.log.Warn().Err(err).Str("binary", req.Argv[0]).Msg("spawn failed")
79 continue
80 }
81 cmd := exec.Command(path, req.Argv[1:]...)
82 cmd.Env = append(append([]string(nil), l.env...), req.Env...)
83 cmd.Stdin = nil
84 cmd.Stdout = nil
85 cmd.Stderr = nil
86 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
87 // Path lookup and environment construction may race a transition.
88 // Recheck at the native process-start boundary using the original
89 // owner epoch, never relabel a delayed request.
90 if !l.admitted(req) {
91 continue
92 }
93 if err := cmd.Start(); err != nil {
94 l.log.Warn().Err(err).Str("binary", req.Argv[0]).Msg("spawn failed")
95 continue
96 }
97 // Children have independent sessions and can outlive Run; reaping continues until process exit.
98 go func() { l.log.Debug().Err(cmd.Wait()).Str("binary", req.Argv[0]).Msg("child exited") }()
99 }
100 }
101}
102
103func (l *Launcher) admitted(req ports.SpawnRequest) bool {
104 if l.Security == nil {
105 return true
106 }
107 state := l.Security.Snapshot()
108 return !state.Protected && req.Security == state
109}
110
111func childLookPath(name string, env []string) (string, error) {
112 if strings.Contains(name, "/") {
113 return name, nil
114 }
115 path := ""
116 for _, entry := range env {
117 if strings.HasPrefix(entry, "PATH=") {
118 path = strings.TrimPrefix(entry, "PATH=")
119 }
120 }
121 for _, dir := range filepath.SplitList(path) {
122 if dir == "" {
123 dir = "."
124 }
125 candidate := filepath.Join(dir, name)
126 info, err := os.Stat(candidate)
127 if err == nil && !info.IsDir() && info.Mode().Perm()&0111 != 0 {
128 return candidate, nil
129 }
130 }
131 return "", fmt.Errorf("executable %q not found in child PATH", name)
132}