返回 DeepSeek-Reasonix
main_linux.go
根目录 / desktop / cmd / update-helper / main_linux.go
1 //go:build linux
2
3 // Command reasonix-update-helper (Linux) installs a verified .deb under Polkit.
4 // It is invoked only via pkexec with fixed argv and re-validates every input as
5 // root before calling apt-get. The unprivileged desktop process never runs apt.
6 package main
7
8 import (
9 "bytes"
10 "encoding/json"
11 "errors"
12 "flag"
13 "fmt"
14 "io"
15 "os"
16 "os/exec"
17 "path/filepath"
18 "regexp"
19 "runtime"
20 "strconv"
21 "strings"
22 "syscall"
23
24 "reasonix/desktop/internal/update"
25 )
26
27 // Stable exit codes observed by the desktop installer.
28 const (
29 exitOK = 0
30 exitUsage = 2
31 exitNotRoot = 10
32 exitBadInput = 11
33 exitVerifyFailed = 12
34 exitPackageRejected = 13
35 exitBusy = 14
36 exitInstallFailed = 15
37 exitPostVerify = 16
38 )
39
40 const (
41 packageName = "reasonix-desktop"
42 dpkgDebPath = "/usr/bin/dpkg-deb"
43 dpkgQueryPath = "/usr/bin/dpkg-query"
44 dpkgPath = "/usr/bin/dpkg"
45 aptGetPath = "/usr/bin/apt-get"
46
47 // maxInputBytes bounds untrusted package/signature files before they are
48 // copied into the root temp directory (desktop .deb + minisig).
49 maxInputBytes = 512 << 20 // 512 MiB
50
51 // phasePrefix is a single-line protocol the desktop parses from stderr so
52 // the UI can leave "authorizing" once Polkit has launched this helper and
53 // validation finished, before apt-get starts.
54 phasePrefix = "REASONIX_UPDATE_PHASE="
55 )
56
57 type helperResult struct {
58 OK bool `json:"ok"`
59 Version string `json:"version,omitempty"`
60 Error string `json:"error,omitempty"`
61 Code string `json:"code,omitempty"`
62 }
63
64 // installDeps holds the privileged install seams. Production uses realDeps();
65 // tests inject fakes so every branch is deterministic without root/apt.
66 type installDeps struct {
67 geteuid func() int
68 getenv func(string) string
69 mkTempDir func() (string, error)
70 removeAll func(string) error
71 copyOwnedRegular func(src, dst string, mode os.FileMode, ownerUID int, maxBytes int64) error
72 readFile func(string) ([]byte, error)
73 verify func(data, sig []byte) error
74 inspectDeb func(path string) (debIdentity, error)
75 installedVersion func() (string, error)
76 compareVersions func(a, b string) (int, error)
77 aptInstall func(pkgPath string, allowDowngrade bool) error
78 verifyInstalled func(want string) error
79 writePhase func(phase string)
80 writeResult func(helperResult)
81 goArch string
82 maxInputBytes int64
83 }
84
85 func realDeps() installDeps {
86 return installDeps{
87 geteuid: os.Geteuid,
88 getenv: os.Getenv,
89 mkTempDir: func() (string, error) { return os.MkdirTemp("", "reasonix-update-*") },
90 removeAll: os.RemoveAll,
91 copyOwnedRegular: copyOwnedRegularFile,
92 readFile: os.ReadFile,
93 verify: update.Verify,
94 inspectDeb: inspectDeb,
95 installedVersion: installedPackageVersion,
96 compareVersions: compareDebVersions,
97 aptInstall: aptInstallOnlyUpgrade,
98 verifyInstalled: verifyInstalled,
99 writePhase: writePhaseLine,
100 writeResult: writeResultJSON,
101 goArch: runtime.GOARCH,
102 maxInputBytes: maxInputBytes,
103 }
104 }
105
106 func main() {
107 os.Exit(run(os.Args[1:]))
108 }
109
110 func run(args []string) int {
111 return runWith(realDeps(), args)
112 }
113
114 func runWith(d installDeps, args []string) int {
115 if len(args) == 0 {
116 d.writeResult(helperResult{OK: false, Error: "missing command", Code: "usage"})
117 return exitUsage
118 }
119 switch args[0] {
120 case "install":
121 return runInstall(d, args[1:])
122 default:
123 d.writeResult(helperResult{OK: false, Error: "unknown command", Code: "usage"})
124 return exitUsage
125 }
126 }
127
128 func runInstall(d installDeps, args []string) int {
129 fs := flag.NewFlagSet("install", flag.ContinueOnError)
130 fs.SetOutput(io.Discard)
131 var packagePath, signaturePath string
132 fs.StringVar(&packagePath, "package", "", "path to the verified .deb")
133 fs.StringVar(&signaturePath, "signature", "", "path to the detached .minisig")
134 if err := fs.Parse(args); err != nil {
135 d.writeResult(helperResult{OK: false, Error: "invalid arguments", Code: "usage"})
136 return exitUsage
137 }
138 if packagePath == "" || signaturePath == "" || fs.NArg() != 0 {
139 d.writeResult(helperResult{OK: false, Error: "install requires --package and --signature", Code: "usage"})
140 return exitUsage
141 }
142
143 if d.geteuid() != 0 {
144 d.writeResult(helperResult{OK: false, Error: "helper must run as root", Code: "not_root"})
145 return exitNotRoot
146 }
147 pkUID, err := strconv.Atoi(strings.TrimSpace(d.getenv("PKEXEC_UID")))
148 if err != nil || pkUID < 0 {
149 d.writeResult(helperResult{OK: false, Error: "missing or invalid PKEXEC_UID", Code: "not_root"})
150 return exitNotRoot
151 }
152
153 tmpDir, err := d.mkTempDir()
154 if err != nil {
155 d.writeResult(helperResult{OK: false, Error: "create temp dir failed", Code: "install_failed"})
156 return exitInstallFailed
157 }
158 // Ensure root-only access before any untrusted bytes land here.
159 if err := os.Chmod(tmpDir, 0o700); err != nil {
160 _ = d.removeAll(tmpDir)
161 d.writeResult(helperResult{OK: false, Error: "secure temp dir failed", Code: "install_failed"})
162 return exitInstallFailed
163 }
164 defer func() { _ = d.removeAll(tmpDir) }()
165
166 maxBytes := d.maxInputBytes
167 if maxBytes <= 0 {
168 maxBytes = maxInputBytes
169 }
170 pkgCopy := filepath.Join(tmpDir, "package.deb")
171 sigCopy := filepath.Join(tmpDir, "package.deb.minisig")
172 if err := d.copyOwnedRegular(packagePath, pkgCopy, 0o600, pkUID, maxBytes); err != nil {
173 d.writeResult(helperResult{OK: false, Error: "invalid package input", Code: "bad_input"})
174 return exitBadInput
175 }
176 if err := d.copyOwnedRegular(signaturePath, sigCopy, 0o600, pkUID, maxBytes); err != nil {
177 d.writeResult(helperResult{OK: false, Error: "invalid signature input", Code: "bad_input"})
178 return exitBadInput
179 }
180
181 pkgData, err := d.readFile(pkgCopy)
182 if err != nil {
183 d.writeResult(helperResult{OK: false, Error: "read package failed", Code: "bad_input"})
184 return exitBadInput
185 }
186 sigData, err := d.readFile(sigCopy)
187 if err != nil {
188 d.writeResult(helperResult{OK: false, Error: "read signature failed", Code: "bad_input"})
189 return exitBadInput
190 }
191 // Re-verify as root; never trust the unprivileged process's prior check.
192 if err := d.verify(pkgData, sigData); err != nil {
193 d.writeResult(helperResult{OK: false, Error: "signature verification failed", Code: "verify_failed"})
194 return exitVerifyFailed
195 }
196
197 candidate, err := d.inspectDeb(pkgCopy)
198 if err != nil {
199 d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_rejected"})
200 return exitPackageRejected
201 }
202 if err := acceptDebIdentity(candidate, d.goArch); err != nil {
203 d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_rejected"})
204 return exitPackageRejected
205 }
206
207 installed, err := d.installedVersion()
208 if err != nil {
209 d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_rejected"})
210 return exitPackageRejected
211 }
212 cmp, err := d.compareVersions(candidate.Version, installed)
213 if err != nil {
214 d.writeResult(helperResult{OK: false, Error: "version compare failed", Code: "package_rejected"})
215 return exitPackageRejected
216 }
217 allowDowngrade, err := acceptVersionTransition(cmp, candidate.Version, installed)
218 if err != nil {
219 d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_rejected"})
220 return exitPackageRejected
221 }
222
223 // Polkit already authorized this process; validation is complete. Tell the
224 // desktop to leave "authorizing" before the long apt-get call.
225 d.writePhase("installing")
226
227 if err := d.aptInstall(pkgCopy, allowDowngrade); err != nil {
228 code := "install_failed"
229 exit := exitInstallFailed
230 if isPackageManagerBusy(err) {
231 code = "package_manager_busy"
232 exit = exitBusy
233 }
234 d.writeResult(helperResult{OK: false, Error: sanitizeHelperError(err), Code: code})
235 return exit
236 }
237
238 if err := d.verifyInstalled(candidate.Version); err != nil {
239 d.writeResult(helperResult{OK: false, Error: err.Error(), Code: "package_verify_failed"})
240 return exitPostVerify
241 }
242
243 d.writeResult(helperResult{OK: true, Version: candidate.Version})
244 return exitOK
245 }
246
247 type debIdentity struct {
248 Package string
249 Version string
250 Arch string
251 }
252
253 // acceptDebIdentity enforces package name and architecture. Pure for tests.
254 func acceptDebIdentity(id debIdentity, goArch string) error {
255 if id.Package != packageName {
256 return errors.New("package name rejected")
257 }
258 wantArch := goArch
259 if wantArch == "386" {
260 wantArch = "i386"
261 }
262 if id.Arch != wantArch && id.Arch != "all" {
263 return errors.New("package architecture rejected")
264 }
265 if id.Version == "" {
266 return errors.New("package version missing")
267 }
268 return nil
269 }
270
271 var (
272 stableDebVersionRE = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`)
273 previewDebVersionRE = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)~preview\.(0|[1-9][0-9]*)$`)
274 )
275
276 // acceptVersionTransition permits ordinary upgrades and the one intentional
277 // downgrade: replacing a public Preview package with a public Stable package
278 // after the user switches channels.
279 func acceptVersionTransition(cmp int, candidate, installed string) (bool, error) {
280 switch {
281 case cmp > 0:
282 return false, nil
283 case cmp == 0:
284 return false, errors.New("candidate version is not strictly newer")
285 case stableDebVersionRE.MatchString(candidate) && previewDebVersionRE.MatchString(installed):
286 return true, nil
287 default:
288 return false, errors.New("candidate downgrade is not an allowed preview-to-stable transition")
289 }
290 }
291
292 // aptInstallArgv is the fixed absolute apt-get argv (never shell). Pure for tests.
293 func aptInstallArgv(pkgPath string, allowDowngrade bool) []string {
294 argv := []string{
295 aptGetPath,
296 "install",
297 "--assume-yes",
298 "--only-upgrade",
299 "--no-remove",
300 }
301 if allowDowngrade {
302 argv = append(argv, "--allow-downgrades")
303 }
304 return append(argv, pkgPath)
305 }
306
307 func inspectDeb(path string) (debIdentity, error) {
308 pkg, err := dpkgDebField(path, "Package")
309 if err != nil {
310 return debIdentity{}, errors.New("dpkg-deb inspection failed")
311 }
312 ver, err := dpkgDebField(path, "Version")
313 if err != nil {
314 return debIdentity{}, errors.New("dpkg-deb inspection failed")
315 }
316 arch, err := dpkgDebField(path, "Architecture")
317 if err != nil {
318 return debIdentity{}, errors.New("dpkg-deb inspection failed")
319 }
320 return debIdentity{Package: pkg, Version: ver, Arch: arch}, nil
321 }
322
323 func dpkgDebField(path, field string) (string, error) {
324 out, err := exec.Command(dpkgDebPath, "-f", path, field).Output()
325 if err != nil {
326 return "", err
327 }
328 return strings.TrimSpace(string(out)), nil
329 }
330
331 func installedPackageVersion() (string, error) {
332 out, err := exec.Command(dpkgQueryPath, "-W", "-f=${Version}", packageName).Output()
333 if err != nil {
334 return "", errors.New("installed package not found")
335 }
336 v := strings.TrimSpace(string(out))
337 if v == "" {
338 return "", errors.New("installed package version empty")
339 }
340 return v, nil
341 }
342
343 // compareDebVersions returns >0 when a > b using dpkg --compare-versions.
344 func compareDebVersions(a, b string) (int, error) {
345 if err := exec.Command(dpkgPath, "--compare-versions", a, "gt", b).Run(); err == nil {
346 return 1, nil
347 }
348 if err := exec.Command(dpkgPath, "--compare-versions", a, "eq", b).Run(); err == nil {
349 return 0, nil
350 }
351 if err := exec.Command(dpkgPath, "--compare-versions", a, "lt", b).Run(); err == nil {
352 return -1, nil
353 }
354 return 0, errors.New("compare-versions failed")
355 }
356
357 func aptInstallOnlyUpgrade(pkgPath string, allowDowngrade bool) error {
358 argv := aptInstallArgv(pkgPath, allowDowngrade)
359 cmd := exec.Command(argv[0], argv[1:]...)
360 // Fixed absolute argv only — never shell.
361 cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
362 var stderr bytes.Buffer
363 cmd.Stdout = io.Discard
364 cmd.Stderr = &stderr
365 if err := cmd.Run(); err != nil {
366 return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
367 }
368 return nil
369 }
370
371 func verifyInstalled(wantVersion string) error {
372 out, err := exec.Command(dpkgQueryPath, "-W", "-f=${Status}\n${Version}", packageName).Output()
373 if err != nil {
374 return errors.New("post-install dpkg-query failed")
375 }
376 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
377 if len(lines) < 2 {
378 return errors.New("post-install package state incomplete")
379 }
380 if strings.TrimSpace(lines[0]) != "install ok installed" {
381 return errors.New("package not in install ok installed state")
382 }
383 if strings.TrimSpace(lines[1]) != wantVersion {
384 return errors.New("installed version mismatch")
385 }
386 return nil
387 }
388
389 // copyOwnedRegularFile opens src with O_NOFOLLOW only (fail closed — no Lstat/Open
390 // fallback), requires a regular file owned by ownerUID, enforces maxBytes, and
391 // writes a root-owned copy at dst. Ownership is checked on the opened fd.
392 func copyOwnedRegularFile(src, dst string, mode os.FileMode, ownerUID int, maxBytes int64) error {
393 f, err := os.OpenFile(src, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
394 if err != nil {
395 // Fail closed: never fall back to a followable open (TOCTOU).
396 return fmt.Errorf("open without following links: %w", err)
397 }
398 defer f.Close()
399
400 info, err := f.Stat()
401 if err != nil {
402 return err
403 }
404 if !info.Mode().IsRegular() {
405 return errors.New("not a regular file")
406 }
407 if maxBytes > 0 && info.Size() > maxBytes {
408 return errors.New("input exceeds size bound")
409 }
410 st, ok := info.Sys().(*syscall.Stat_t)
411 if !ok {
412 return errors.New("stat owner unavailable")
413 }
414 if int(st.Uid) != ownerUID {
415 return errors.New("input owner does not match PKEXEC_UID")
416 }
417
418 out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_EXCL, mode)
419 if err != nil {
420 return err
421 }
422 defer out.Close()
423 // Cap the copy even if size grew after Stat (regular files can still be
424 // truncated/appended by the owner on some filesystems; bound the bytes we take).
425 limited := io.LimitReader(f, maxBytes+1)
426 n, err := io.Copy(out, limited)
427 if err != nil {
428 return err
429 }
430 if maxBytes > 0 && n > maxBytes {
431 return errors.New("input exceeds size bound")
432 }
433 return out.Close()
434 }
435
436 func isPackageManagerBusy(err error) bool {
437 if err == nil {
438 return false
439 }
440 low := strings.ToLower(err.Error())
441 return strings.Contains(low, "could not get lock") ||
442 strings.Contains(low, "unable to acquire the dpkg frontend lock") ||
443 strings.Contains(low, "is another process using it") ||
444 strings.Contains(low, "dpkg frontend lock")
445 }
446
447 // sanitizeHelperError strips absolute paths from helper diagnostics so the
448 // desktop UI never surfaces user home directories.
449 func sanitizeHelperError(err error) string {
450 if err == nil {
451 return "install failed"
452 }
453 msg := err.Error()
454 // Drop anything that looks like an absolute path segment.
455 fields := strings.Fields(msg)
456 for i, f := range fields {
457 if strings.HasPrefix(f, "/") {
458 fields[i] = "<path>"
459 }
460 }
461 out := strings.Join(fields, " ")
462 if out == "" {
463 return "install failed"
464 }
465 if len(out) > 240 {
466 out = out[:240]
467 }
468 return out
469 }
470
471 func writePhaseLine(phase string) {
472 // Single line, no user paths — desktop parses this while the helper runs.
473 fmt.Fprintf(os.Stderr, "%s%s\n", phasePrefix, phase)
474 }
475
476 func writeResultJSON(r helperResult) {
477 enc := json.NewEncoder(os.Stdout)
478 _ = enc.Encode(r)
479 }
480
481 // parsePhaseLine extracts a progress phase from a helper stderr line.
482 func parsePhaseLine(line string) (phase string, ok bool) {
483 line = strings.TrimSpace(line)
484 if !strings.HasPrefix(line, phasePrefix) {
485 return "", false
486 }
487 phase = strings.TrimSpace(strings.TrimPrefix(line, phasePrefix))
488 return phase, phase != ""
489 }
490
490 lines GO