@synopackageland/cli 0.5.0 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +1 -1
  2. package/dist/authorization-sdk.d.ts +5 -0
  3. package/dist/authorization-sdk.js +5 -0
  4. package/dist/build-spk/delete-app-data.d.ts +1 -1
  5. package/dist/build-spk/uninstall-wizard.d.ts +1 -1
  6. package/dist/build-spk/uninstall-wizard.js +1 -1
  7. package/dist/cli.js +2 -2
  8. package/dist/error-registry.js +3 -3
  9. package/dist/host/dsm-port-profile.d.ts +1 -1
  10. package/dist/host/dsm-port-profile.js +1 -1
  11. package/dist/init.js +25 -4
  12. package/dist/standalone/authorization-window.d.ts +2 -0
  13. package/dist/standalone/authorization-window.js +28 -0
  14. package/dist/standalone/authorization.d.ts +48 -0
  15. package/dist/standalone/authorization.js +116 -0
  16. package/dist/standalone/build.js +18 -6
  17. package/dist/standalone/dsm-window.js +8 -1
  18. package/dist/standalone/helper.js +1 -1
  19. package/dist/standalone/inspect-helper.js +12 -2
  20. package/dist/standalone/inspect-spk.js +11 -0
  21. package/dist/standalone/lifecycle-helper.d.ts +1 -1
  22. package/dist/standalone/lifecycle-helper.js +2 -2
  23. package/dist/standalone/lifecycle.d.ts +1 -1
  24. package/dist/standalone/lifecycle.js +3 -3
  25. package/dist/standalone/root-ops.d.ts +2 -0
  26. package/dist/standalone/root-ops.js +10 -0
  27. package/dist/standalone/root-window.js +1 -1
  28. package/dist/standalone/spk.d.ts +2 -0
  29. package/dist/standalone/spk.js +14 -0
  30. package/dist/templates/_authorization/main.go +104 -0
  31. package/dist/templates/_authorization/main_test.go +35 -0
  32. package/dist/templates/native-go/README.md +25 -1
  33. package/dist/templates/native-go/backend/cmd/app/main.go +8 -6
  34. package/dist/templates/native-go/backend/cmd/app/sdk_main.go +8 -6
  35. package/dist/templates/native-go/backend/cmd/helper/main.go +18 -1
  36. package/dist/templates/native-go/backend/internal/app/authorization_none.go +5 -0
  37. package/dist/templates/native-go/backend/internal/app/routes.go +5 -2
  38. package/dist/templates/native-go/backend/internal/app/sdk_routes.go +5 -2
  39. package/dist/templates/native-go/backend/internal/helper/management.go +8 -1
  40. package/dist/templates/native-go/backend/internal/helper/purge.go +115 -0
  41. package/dist/templates/native-go/backend/internal/helper/purge_test.go +59 -0
  42. package/dist/templates/native-go/backend/internal/helper/revoke.go +109 -0
  43. package/dist/templates/native-go/backend/internal/helper/revoke_test.go +121 -0
  44. package/dist/templates/native-go/backend/internal/helper/sdk_server_linux.go +11 -2
  45. package/dist/templates/native-go/backend/internal/helper/server_linux.go +11 -2
  46. package/dist/templates/native-go/dsm-package.yaml +1 -1
  47. package/dist/templates/native-go-authorization/backend/internal/app/authorization.go +298 -0
  48. package/dist/templates/native-go-authorization/backend/internal/app/authorization_test.go +96 -0
  49. package/dist/templates/native-go-authorization/frontend/src/authorization/AuthorizationGate.vue +89 -0
  50. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/de.json +36 -0
  51. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/en.json +36 -0
  52. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/es.json +36 -0
  53. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/fr.json +36 -0
  54. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/index.ts +15 -0
  55. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/it.json +36 -0
  56. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/ja.json +36 -0
  57. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/ko.json +36 -0
  58. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/zh-CN.json +36 -0
  59. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/zh-TW.json +36 -0
  60. package/dist/templates/native-go-authorization/frontend/test/authorization-preview.html +14 -0
  61. package/dist/templates/native-go-authorization/frontend/test/authorization.test.ts +59 -0
  62. package/dist/templates/native-go-vue/frontend/src/context.ts +3 -0
  63. package/dist/types.d.ts +1 -1
  64. package/package.json +5 -2
  65. package/skills/reference/create.md +2 -2
  66. package/templates/_authorization/main.go +104 -0
  67. package/templates/_authorization/main_test.go +35 -0
  68. package/templates/native-go/README.md +25 -1
  69. package/templates/native-go/backend/cmd/app/main.go +8 -6
  70. package/templates/native-go/backend/cmd/app/sdk_main.go +8 -6
  71. package/templates/native-go/backend/cmd/helper/main.go +18 -1
  72. package/templates/native-go/backend/internal/app/authorization_none.go +5 -0
  73. package/templates/native-go/backend/internal/app/routes.go +5 -2
  74. package/templates/native-go/backend/internal/app/sdk_routes.go +5 -2
  75. package/templates/native-go/backend/internal/helper/management.go +8 -1
  76. package/templates/native-go/backend/internal/helper/purge.go +115 -0
  77. package/templates/native-go/backend/internal/helper/purge_test.go +59 -0
  78. package/templates/native-go/backend/internal/helper/revoke.go +109 -0
  79. package/templates/native-go/backend/internal/helper/revoke_test.go +121 -0
  80. package/templates/native-go/backend/internal/helper/sdk_server_linux.go +11 -2
  81. package/templates/native-go/backend/internal/helper/server_linux.go +11 -2
  82. package/templates/native-go/dsm-package.yaml +1 -1
  83. package/templates/native-go-authorization/backend/internal/app/authorization.go +298 -0
  84. package/templates/native-go-authorization/backend/internal/app/authorization_test.go +96 -0
  85. package/templates/native-go-authorization/frontend/src/authorization/AuthorizationGate.vue +89 -0
  86. package/templates/native-go-authorization/frontend/src/authorization/i18n/de.json +36 -0
  87. package/templates/native-go-authorization/frontend/src/authorization/i18n/en.json +36 -0
  88. package/templates/native-go-authorization/frontend/src/authorization/i18n/es.json +36 -0
  89. package/templates/native-go-authorization/frontend/src/authorization/i18n/fr.json +36 -0
  90. package/templates/native-go-authorization/frontend/src/authorization/i18n/index.ts +15 -0
  91. package/templates/native-go-authorization/frontend/src/authorization/i18n/it.json +36 -0
  92. package/templates/native-go-authorization/frontend/src/authorization/i18n/ja.json +36 -0
  93. package/templates/native-go-authorization/frontend/src/authorization/i18n/ko.json +36 -0
  94. package/templates/native-go-authorization/frontend/src/authorization/i18n/zh-CN.json +36 -0
  95. package/templates/native-go-authorization/frontend/src/authorization/i18n/zh-TW.json +36 -0
  96. package/templates/native-go-authorization/frontend/test/authorization-preview.html +14 -0
  97. package/templates/native-go-authorization/frontend/test/authorization.test.ts +59 -0
  98. package/templates/native-go-vue/frontend/src/context.ts +3 -0
  99. package/dist/templates/hello-web/compose.yaml +0 -24
  100. package/dist/templates/hello-web/html/app.js +0 -1
  101. package/dist/templates/hello-web/html/index.html +0 -13
  102. package/dist/templates/hello-web/synology-app.yaml +0 -16
  103. package/dist/templates/hello-web-page/compose.yaml +0 -24
  104. package/dist/templates/hello-web-page/html/app.js +0 -1
  105. package/dist/templates/hello-web-page/html/index.html +0 -13
  106. package/dist/templates/hello-web-page/synology-app.yaml +0 -16
  107. package/templates/hello-web/compose.yaml +0 -24
  108. package/templates/hello-web/html/app.js +0 -1
  109. package/templates/hello-web/html/index.html +0 -13
  110. package/templates/hello-web/synology-app.yaml +0 -16
  111. package/templates/hello-web-page/compose.yaml +0 -24
  112. package/templates/hello-web-page/html/app.js +0 -1
  113. package/templates/hello-web-page/html/index.html +0 -13
  114. package/templates/hello-web-page/synology-app.yaml +0 -16
@@ -0,0 +1,104 @@
1
+ // CLI-owned root bootstrap: accepts only compiled fixed operations. The native
2
+ // task copies and pins this executable before it can run with root authority.
3
+ package main
4
+
5
+ import (
6
+ "crypto/sha256"
7
+ "encoding/base64"
8
+ "encoding/hex"
9
+ "encoding/json"
10
+ "errors"
11
+ "fmt"
12
+ "io"
13
+ "os"
14
+ "os/exec"
15
+ )
16
+
17
+ var inventoryBase64 string
18
+
19
+ type inventory struct {
20
+ Schema int `json:"schema"`
21
+ Package string `json:"package"`
22
+ Version string `json:"version"`
23
+ Operations []struct {
24
+ Name string `json:"name"`
25
+ Body string `json:"body"`
26
+ } `json:"operations"`
27
+ Files []struct {
28
+ Path string `json:"path"`
29
+ Size int64 `json:"size"`
30
+ Hash string `json:"sha256"`
31
+ } `json:"files"`
32
+ }
33
+
34
+ func run(args []string) error {
35
+ if os.Geteuid() != 0 || len(args) != 1 {
36
+ return errors.New("root and one fixed operation required")
37
+ }
38
+ var i inventory
39
+ raw, decode := base64.StdEncoding.DecodeString(inventoryBase64)
40
+ if decode != nil || json.Unmarshal(raw, &i) != nil || i.Schema != 2 || len(i.Operations) == 0 {
41
+ return errors.New("invalid compiled inventory")
42
+ }
43
+ var body string
44
+ for _, op := range i.Operations {
45
+ if op.Name == args[0] {
46
+ body = op.Body
47
+ }
48
+ }
49
+ if body == "" {
50
+ return errors.New("unknown operation")
51
+ }
52
+ // Disable/teardown/repair retain an escape path when UI payload is corrupt;
53
+ // they still execute only compiled, hash-pinned cleanup code.
54
+ cleanup := args[0] == "helper_disable" || args[0] == "helper_teardown" || args[0] == "helper_repair" || args[0] == "helper_purge"
55
+ if !cleanup {
56
+ if e := verifyFiles("/var/packages/"+i.Package+"/target", raw); e != nil {
57
+ return e
58
+ }
59
+ }
60
+ cmd := exec.Command("/bin/sh", "-eu", "-c", body)
61
+ cmd.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin"}
62
+ cmd.Stdout = os.Stdout
63
+ cmd.Stderr = os.Stderr
64
+ return cmd.Run()
65
+ }
66
+ func verifyFiles(dir string, raw []byte) error {
67
+ var i inventory
68
+ if json.Unmarshal(raw, &i) != nil || i.Schema != 2 {
69
+ return errors.New("invalid inventory")
70
+ }
71
+ root, e := os.OpenRoot(dir)
72
+ if e != nil {
73
+ return e
74
+ }
75
+ defer root.Close()
76
+ for _, p := range i.Files {
77
+ if p.Size < 0 || p.Size > 64<<20 {
78
+ return errors.New("invalid size")
79
+ }
80
+ f, e := root.Open(p.Path)
81
+ if e != nil {
82
+ return e
83
+ }
84
+ st, e := f.Stat()
85
+ if e != nil || !st.Mode().IsRegular() || st.Size() != p.Size {
86
+ f.Close()
87
+ return errors.New("untrusted payload")
88
+ }
89
+ h := sha256.New()
90
+ _, e = io.Copy(h, io.LimitReader(f, p.Size+1))
91
+ f.Close()
92
+ if e != nil || hex.EncodeToString(h.Sum(nil)) != p.Hash {
93
+ return errors.New("untrusted payload")
94
+ }
95
+ }
96
+ return nil
97
+ }
98
+
99
+ func main() {
100
+ if e := run(os.Args[1:]); e != nil {
101
+ fmt.Fprintln(os.Stderr, "authorization bootstrap refused:", e)
102
+ os.Exit(1)
103
+ }
104
+ }
@@ -0,0 +1,35 @@
1
+ package main
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "testing"
9
+ )
10
+
11
+ func TestInventoryRejectsReplacementAndSymlinkEscape(t *testing.T) {
12
+ dir := t.TempDir()
13
+ body := []byte("approved bytes")
14
+ os.WriteFile(filepath.Join(dir, "file"), body, 0600)
15
+ raw := fmt.Sprintf(`{"schema":2,"files":[{"path":"file","size":%d,"sha256":"%x"}]}`, len(body), sha256.Sum256(body))
16
+ if e := verifyFiles(dir, []byte(raw)); e != nil {
17
+ t.Fatal(e)
18
+ }
19
+ os.WriteFile(filepath.Join(dir, "file"), []byte("altered bytes!"), 0600)
20
+ if e := verifyFiles(dir, []byte(raw)); e == nil {
21
+ t.Fatal("accepted replacement")
22
+ }
23
+ os.Remove(filepath.Join(dir, "file"))
24
+ outside := filepath.Join(t.TempDir(), "file")
25
+ os.WriteFile(outside, body, 0600)
26
+ os.Symlink(outside, filepath.Join(dir, "file"))
27
+ if e := verifyFiles(dir, []byte(raw)); e == nil {
28
+ t.Fatal("accepted external symlink")
29
+ }
30
+ }
31
+ func TestUnknownOperationNeverRuns(t *testing.T) {
32
+ if e := run([]string{"arbitrary"}); e == nil {
33
+ t.Fatal("accepted unknown operation")
34
+ }
35
+ }
@@ -39,7 +39,7 @@ greeting in the package's data folder.
39
39
  | `ui/app.js`, `ui/app.css` | The app inside the DSM window | Yes — your UI |
40
40
  | `ui/…` | Any other assets; served from `/webman/3rdparty/__PACKAGE__/app/` | Yes |
41
41
  | `i18n/messages/<locale>.json` | UI text in nine languages | Yes |
42
- | `i18n/glossary.json` | Synology's official DSM terms, and which messages must use them | Add terms |
42
+ | `i18n/glossary.json` | DSM's own UI terms, and which messages must use them | Add terms |
43
43
  | `backend/internal/app/` | Your API: routes and handlers | Yes — your backend |
44
44
  | `backend/internal/api/` | The CGI front door: guards, auth order, route policies | Rarely |
45
45
  | `backend/internal/dsm/` | DSM session check and administrators lookup | Rarely |
@@ -411,3 +411,27 @@ drive the same flow from its own UI.
411
411
  | Target CPUs | x86_64 and aarch64 DSM models (one SPK each) |
412
412
  | DSM | 7.1 or later declared (`os_min_ver`, change it with `init --min-dsm` or `build-spk --min-dsm`). Installed and tested on DSM 7.4 (x86_64). aarch64 SPKs are built the same way but have not been run on a NAS. |
413
413
  | Not supported | 32-bit ARM, DSM 6, running anything as root without an administrator's approval |
414
+
415
+ ### Shared root authorization (explicit opt-in)
416
+
417
+ `init -t native-go --privilege root` adds `authorization.status`,
418
+ `authorization.request` and `authorization.revoke` to the same package CGI URL.
419
+ The Vue shell includes a centered authorization gate. Request/revoke return fixed
420
+ plans; the host completes DSM PasswordConfirm/scheduler operations and reads status
421
+ back before showing success. No password is sent to the package CGI.
422
+
423
+ Each root SPK contains a SHA-256-pinned `authorization/bootstrap` and a v2 build
424
+ inventory. The disabled native task copies bootstrap into a root-private directory
425
+ and verifies the copied bytes before running a compiled fixed operation. The
426
+ bootstrap verifies UI build hashes; helper control scripts retain their helper pins.
427
+ Changing root code, helper or UI requires rebuilding and fresh authorization.
428
+ `inspect --spk` checks bootstrap/CGI/window/inventory bindings; missing pins fail.
429
+ The default `privilege: none` has no bootstrap or authorization routes, and all
430
+ lifecycle scripts and CGI continue to use the package account.
431
+
432
+ Before upgrading a helper integration, revoke/disable its current grant and verify
433
+ cleanup. The existing pre-upgrade guard refuses a live helper; a new build never
434
+ activates root automatically. Plain UI keeps its per-operation result controls.
435
+ A non-Go prebuilt CGI must implement its own adapter and cannot use this generated
436
+ root authorization contract. Native-layout products such as Reclaim retain their
437
+ own privileged runtime until they explicitly migrate their adapters.
@@ -22,9 +22,10 @@ import (
22
22
 
23
23
  // Set by `synopkgland build-spk` through -ldflags -X; never read from a request.
24
24
  var (
25
- packageID = "dev"
26
- version = "0.0.0"
27
- helperInventory = ""
25
+ rootAuthorization = ""
26
+ packageID = "dev"
27
+ version = "0.0.0"
28
+ helperInventory = ""
28
29
  // "package" when the project has an integration but no privileged helper.
29
30
  integrationMode = ""
30
31
  // "session" when the project opted into the interactive File Station adapter.
@@ -39,9 +40,10 @@ func main() {
39
40
  os.Exit(2)
40
41
  }
41
42
  cfg := app.Config{
42
- PackageID: packageID,
43
- Version: version,
44
- DataDir: "/var/packages/" + packageID + "/var",
43
+ RootAuthorization: rootAuthorization,
44
+ PackageID: packageID,
45
+ Version: version,
46
+ DataDir: "/var/packages/" + packageID + "/var",
45
47
 
46
48
  PackageIntegration: integrationMode == "package",
47
49
  }
@@ -20,9 +20,10 @@ import (
20
20
 
21
21
  // Set by `synopkgland build-spk` through -ldflags -X; never read from a request.
22
22
  var (
23
- packageID = "dev"
24
- version = "0.0.0"
25
- helperInventory = ""
23
+ rootAuthorization = ""
24
+ packageID = "dev"
25
+ version = "0.0.0"
26
+ helperInventory = ""
26
27
  // "session" when the project opted into the interactive File Station adapter.
27
28
  fileStation = ""
28
29
  )
@@ -35,9 +36,10 @@ func main() {
35
36
  os.Exit(2)
36
37
  }
37
38
  cfg := app.Config{
38
- PackageID: packageID,
39
- Version: version,
40
- DataDir: "/var/packages/" + packageID + "/var",
39
+ RootAuthorization: rootAuthorization,
40
+ PackageID: packageID,
41
+ Version: version,
42
+ DataDir: "/var/packages/" + packageID + "/var",
41
43
  }
42
44
  if helperInventory != "" {
43
45
  if json.Unmarshal([]byte(helperInventory), &cfg.HelperInventory) != nil || cfg.HelperInventory.Package != packageID || cfg.HelperInventory.Version != version {
@@ -16,7 +16,7 @@ func main() {
16
16
  if len(os.Args) != 2 || json.Unmarshal([]byte(helperInventory), &i) != nil {
17
17
  os.Exit(2)
18
18
  }
19
- if os.Args[1] == "check-stopped" || os.Args[1] == "diagnose" || os.Args[1] == "diagnose-startup" {
19
+ if os.Args[1] == "revoke" || os.Args[1] == "check-stopped" || os.Args[1] == "diagnose" || os.Args[1] == "diagnose-startup" {
20
20
  c, err := helper.LocalClient(i)
21
21
  if err != nil {
22
22
  os.Exit(1)
@@ -34,11 +34,28 @@ func main() {
34
34
  json.NewEncoder(os.Stdout).Encode(c.Status(context.Background()))
35
35
  return
36
36
  }
37
+ if os.Args[1] == "revoke" {
38
+ if c.Revoke(context.Background()) != nil {
39
+ os.Exit(1)
40
+ }
41
+ return
42
+ }
37
43
  if c.CheckStopped(context.Background()) != nil {
38
44
  os.Exit(1)
39
45
  }
40
46
  return
41
47
  }
48
+ if os.Args[1] == "purge" {
49
+ if os.Geteuid() != 0 {
50
+ os.Exit(1)
51
+ }
52
+ c, err := helper.LocalClient(i)
53
+ if err != nil || c.Purge() != nil {
54
+ os.Exit(1)
55
+ }
56
+ fmt.Println(`{"success":true}`)
57
+ return
58
+ }
42
59
  if err := helper.RunLinux(i, os.Args[1]); err != nil {
43
60
  fmt.Fprintln(os.Stderr, "helper lifecycle refused")
44
61
  os.Exit(1)
@@ -0,0 +1,5 @@
1
+ package app
2
+
3
+ import "dsmapp/internal/api"
4
+
5
+ func (Config) authorizationRoutes() []api.Route { return nil }
@@ -27,6 +27,8 @@ import (
27
27
 
28
28
  // Config is fixed at build time and startup, never taken from a request.
29
29
  type Config struct {
30
+ RootAuthorization string
31
+
30
32
  // SessionFiles is the interactive File Station adapter; nil keeps legacy refusal.
31
33
  SessionFiles *filestation.Service
32
34
  PackageID string
@@ -45,13 +47,14 @@ type Config struct {
45
47
  }
46
48
 
47
49
  func Routes(cfg Config) []api.Route {
48
- routes := []api.Route{
50
+ routes := cfg.authorizationRoutes()
51
+ routes = append(routes, []api.Route{
49
52
  api.User("integration.status", cfg.integrationStatus),
50
53
  api.BoundedRead(cfg.binaryRead),
51
54
  api.User("app.status", cfg.status),
52
55
  api.User("greeting.get", cfg.getGreeting),
53
56
  api.Admin("greeting.set", cfg.setGreeting),
54
- }
57
+ }...)
55
58
  routes = append(routes, cfg.SessionFiles.Routes()...)
56
59
  routes = append(routes, cfg.backgroundRoutes()...)
57
60
  return append(routes, cfg.rootRoutes()...)
@@ -25,6 +25,8 @@ import (
25
25
 
26
26
  // Config is fixed at build time and startup, never taken from a request.
27
27
  type Config struct {
28
+ RootAuthorization string
29
+
28
30
  // SessionFiles is the interactive File Station adapter; nil keeps legacy refusal.
29
31
  SessionFiles *filestation.Service
30
32
  SDKJobs func(context.Context, string, helper.SessionCarriers, json.RawMessage) (json.RawMessage, helper.Code)
@@ -44,13 +46,14 @@ type Config struct {
44
46
  }
45
47
 
46
48
  func Routes(cfg Config) []api.Route {
47
- routes := []api.Route{
49
+ routes := cfg.authorizationRoutes()
50
+ routes = append(routes, []api.Route{
48
51
  api.User("integration.status", cfg.integrationStatus),
49
52
  api.BoundedRead(cfg.binaryRead),
50
53
  api.User("app.status", cfg.status),
51
54
  api.User("greeting.get", cfg.getGreeting),
52
55
  api.Admin("greeting.set", cfg.setGreeting),
53
- }
56
+ }...)
54
57
  routes = append(routes, cfg.sdkJobRoutes()...)
55
58
  routes = append(routes, cfg.SessionFiles.Routes()...)
56
59
  routes = append(routes, cfg.backgroundRoutes()...)
@@ -16,7 +16,14 @@ import (
16
16
  // starts/signals a process. Private completion contents stay private to root.
17
17
  func (c Client) staleManagementStopped() bool {
18
18
  r, err := readRecord(c.StateDir, c.Owner)
19
- if err != nil || !c.Expected.Valid() || r.Inventory.Package != c.Expected.Package || r.Schema != 2 || !r.Enabled || r.Residual || r.Lifecycle == nil || r.Lifecycle.Phase != "stopped" || r.Lifecycle.Completion == "" || r.Lifecycle.Lease == nil {
19
+ if err != nil || !r.Enabled {
20
+ return false
21
+ }
22
+ return c.managementStopped(r)
23
+ }
24
+
25
+ func (c Client) managementStopped(r Record) bool {
26
+ if !c.Expected.Valid() || r.Inventory.Package != c.Expected.Package || r.Schema != 2 || r.Residual || r.Lifecycle == nil || r.Lifecycle.Phase != "stopped" || r.Lifecycle.Completion == "" || r.Lifecycle.Lease == nil {
20
27
  return false
21
28
  }
22
29
  if !c.canonicalApproval(r) || !c.completionPublished(r) {
@@ -0,0 +1,115 @@
1
+ package helper
2
+
3
+ import (
4
+ "io/fs"
5
+ "os"
6
+ "path/filepath"
7
+ "regexp"
8
+ "syscall"
9
+ )
10
+
11
+ var terminalFile = regexp.MustCompile(`^(completion-[a-f0-9]{64}\.json|lifetime-[a-f0-9]{64}\.lock)$`)
12
+ var cachedPayload = regexp.MustCompile(`^payload-[a-f0-9]{64}$`)
13
+
14
+ // Purge is a separate fixed, administrator-approved operation. Never expose
15
+ // deletion through the package socket. Unknown files (including data/grants)
16
+ // are retained and cause refusal; only terminal lifecycle metadata is removed.
17
+ func (c Client) Purge() error {
18
+ r, err := readRecord(c.StateDir, c.Owner)
19
+ if err != nil || r.Enabled || r.Inventory != c.Expected || !c.managementStopped(r) {
20
+ return errTrust
21
+ }
22
+ locks := []*os.File{}
23
+ defer func() {
24
+ for _, f := range locks {
25
+ f.Close()
26
+ }
27
+ }()
28
+ lock := func(name string) error {
29
+ f, err := os.OpenFile(filepath.Join(c.StateDir, name), os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0600)
30
+ if err != nil {
31
+ return err
32
+ }
33
+ locks = append(locks, f)
34
+ if err := protected(f.Name(), c.Owner, false); err != nil {
35
+ return err
36
+ }
37
+ if syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) != nil {
38
+ return errBusy
39
+ }
40
+ return nil
41
+ }
42
+ for _, name := range []string{"control.lock", "policy.lock"} {
43
+ if err := lock(name); err != nil {
44
+ return err
45
+ }
46
+ }
47
+ // Recheck after serialization against enable and the terminal publisher.
48
+ r, err = readRecord(c.StateDir, c.Owner)
49
+ if err != nil || r.Enabled || r.Inventory != c.Expected || !c.managementStopped(r) {
50
+ return errTrust
51
+ }
52
+ entries, err := os.ReadDir(c.StateDir)
53
+ if err != nil || len(entries) > 512 {
54
+ return errTrust
55
+ }
56
+ for _, entry := range entries {
57
+ name := entry.Name()
58
+ mode := fs.FileMode(0600)
59
+ switch {
60
+ case name == "approval.json":
61
+ mode = 0644
62
+ case cachedPayload.MatchString(name):
63
+ mode = 0700
64
+ case terminalFile.MatchString(name), name == "control.lock", name == "policy.lock", name == "observer-diagnostic.json":
65
+ default:
66
+ return errTrust
67
+ }
68
+ st, err := os.Lstat(filepath.Join(c.StateDir, name))
69
+ if err != nil {
70
+ return err
71
+ }
72
+ stat, ok := st.Sys().(*syscall.Stat_t)
73
+ if !ok || !st.Mode().IsRegular() || st.Mode().Perm() != mode || stat.Uid != c.Owner || stat.Nlink != 1 {
74
+ return errTrust
75
+ }
76
+ if filepath.Ext(name) == ".lock" && name != "control.lock" && name != "policy.lock" {
77
+ if err := lock(name); err != nil {
78
+ return err
79
+ }
80
+ }
81
+ }
82
+ // No recursive removal. A socket/process/foreign file prevents all deletion.
83
+ if err := protected(c.RunDir, c.Owner, true); err != nil {
84
+ return err
85
+ }
86
+ runtime, err := os.ReadDir(c.RunDir)
87
+ if err != nil || len(runtime) != 0 {
88
+ return errTrust
89
+ }
90
+ // If an I/O failure interrupts cleanup, retain a durable disabled gate and
91
+ // cleanup-required diagnostic. Remove that record only after other files.
92
+ r.Residual = true
93
+ if err := writeProtected(c.StateDir, "approval.json", r); err != nil {
94
+ return err
95
+ }
96
+ for _, entry := range entries {
97
+ if entry.Name() == "approval.json" {
98
+ continue
99
+ }
100
+ if err := os.Remove(filepath.Join(c.StateDir, entry.Name())); err != nil {
101
+ return err
102
+ }
103
+ }
104
+ if err := os.Remove(c.RunDir); err != nil {
105
+ return err
106
+ }
107
+ if err := os.Remove(filepath.Join(c.StateDir, "approval.json")); err != nil {
108
+ return err
109
+ }
110
+ if err := os.Remove(c.StateDir); err != nil {
111
+ _ = writeProtected(c.StateDir, "approval.json", r)
112
+ return err
113
+ }
114
+ return nil
115
+ }
@@ -0,0 +1,59 @@
1
+ package helper
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+ )
8
+
9
+ func terminalPurgeFixture(t *testing.T) Client {
10
+ c, f := controlFixture(t)
11
+ l := testLifecycle("fenced")
12
+ if err := createLifetime(c.Client, l); err != nil {
13
+ t.Fatal(err)
14
+ }
15
+ record(t, c.Client, Record{Schema: 2, Inventory: c.Expected, Enabled: false, Residual: true, Lifecycle: l})
16
+ if err := cleanRuntime(c.Client, true); err != nil {
17
+ t.Fatal(err)
18
+ }
19
+ if err := completeRecord(c.Client, l.Epoch, f.current); err != nil {
20
+ t.Fatal(err)
21
+ }
22
+ return c.Client
23
+ }
24
+ func TestPurgeRemovesOnlyVerifiedDisabledTerminalState(t *testing.T) {
25
+ c := terminalPurgeFixture(t)
26
+ if err := c.Purge(); err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ for _, path := range []string{c.StateDir, c.RunDir} {
30
+ if _, err := os.Lstat(path); !os.IsNotExist(err) {
31
+ t.Fatalf("residue %s: %v", path, err)
32
+ }
33
+ }
34
+ }
35
+ func TestPurgeRefusesLiveStateUnknownEntriesAndLinks(t *testing.T) {
36
+ for _, which := range []string{"live", "unknown", "link", "proof"} {
37
+ t.Run(which, func(t *testing.T) {
38
+ c := terminalPurgeFixture(t)
39
+ switch which {
40
+ case "live":
41
+ r, _ := readRecord(c.StateDir, c.Owner)
42
+ r.Enabled = true
43
+ record(t, c, r)
44
+ case "unknown":
45
+ os.WriteFile(filepath.Join(c.StateDir, "user-data"), []byte("retain"), 0600)
46
+ case "link":
47
+ os.Symlink("/etc/passwd", filepath.Join(c.StateDir, "payload-"+c.Expected.PayloadDigest))
48
+ case "proof":
49
+ os.Remove(filepath.Join(c.StateDir, "completion-"+testLifecycle("stopped").Epoch+".json"))
50
+ }
51
+ if c.Purge() == nil {
52
+ t.Fatal("unsafe purge accepted")
53
+ }
54
+ if _, err := os.Stat(filepath.Join(c.StateDir, "approval.json")); err != nil {
55
+ t.Fatal("refusal deleted approval")
56
+ }
57
+ })
58
+ }
59
+ }
@@ -0,0 +1,109 @@
1
+ package helper
2
+
3
+ import (
4
+ "context"
5
+ "crypto/rand"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "errors"
9
+ "io"
10
+ "io/fs"
11
+ "net"
12
+ "os"
13
+ "path/filepath"
14
+ "syscall"
15
+ "time"
16
+ )
17
+
18
+ // Called by the already approved root child after SO_PEERCRED authorization.
19
+ // This endpoint can only withdraw the exact installed build's authority. It
20
+ // cannot start a process, select a path, signal a PID, or replace an inventory.
21
+ func packageRevoke(c Client, req Request) error {
22
+ if req.Op != "revoke" || req.Inventory != c.Expected || !c.Expected.Valid() || !digestPattern.MatchString(req.Nonce) {
23
+ return errTrust
24
+ }
25
+ if err := protected(c.StateDir, c.Owner, true); err != nil {
26
+ return err
27
+ }
28
+ f, err := os.OpenFile(filepath.Join(c.StateDir, "control.lock"), os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0600)
29
+ if err != nil {
30
+ return err
31
+ }
32
+ defer f.Close()
33
+ if err := protected(f.Name(), c.Owner, false); err != nil {
34
+ return err
35
+ }
36
+ if syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) != nil {
37
+ return errBusy
38
+ }
39
+ defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
40
+ return policyUpdate(c, func(r *Record) error {
41
+ if r.Inventory != c.Expected || r.Lifecycle == nil || r.Lifecycle.Phase != "running" || !r.Enabled {
42
+ return errTrust
43
+ }
44
+ r.Enabled = false
45
+ r.Residual = true
46
+ r.Lifecycle.Phase = "fenced"
47
+ r.Lifecycle.Completion = ""
48
+ r.Lifecycle.Reason = "package-revoke"
49
+ return nil
50
+ })
51
+ }
52
+
53
+ // Revoke runs as the package UID. A socket acknowledgement proves only durable
54
+ // withdrawal; success additionally requires the owning supervisor's sealed
55
+ // terminal publication and absent runtime. No missing-socket shortcut.
56
+ func (c Client) Revoke(ctx context.Context) error {
57
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
58
+ defer cancel()
59
+ r, err := readRecord(c.StateDir, c.Owner)
60
+ if errors.Is(err, fs.ErrNotExist) {
61
+ return c.CheckStopped(ctx)
62
+ }
63
+ // The new build may manage the old disabled terminal publication without
64
+ // acquiring its authority or mutating it. This keeps uninstall possible
65
+ // before the user has approved the upgraded build.
66
+ if err == nil && !r.Enabled && c.managementStopped(r) {
67
+ return nil
68
+ }
69
+ if err != nil || r.Inventory != c.Expected {
70
+ return errTrust
71
+ }
72
+ if !r.Enabled {
73
+ if c.managementStopped(r) {
74
+ return nil
75
+ }
76
+ return errTrust
77
+ }
78
+ if r.Lifecycle == nil || r.Lifecycle.Phase != "running" {
79
+ return errTrust
80
+ }
81
+ dialer := net.Dialer{Timeout: time.Second}
82
+ conn, err := dialer.DialContext(ctx, "unix", filepath.Join(c.RunDir, "helper.sock"))
83
+ if err != nil {
84
+ return err
85
+ }
86
+ defer conn.Close()
87
+ if c.Peer == nil || c.Peer(conn.(*net.UnixConn)) != nil {
88
+ return errTrust
89
+ }
90
+ conn.SetDeadline(time.Now().Add(time.Second))
91
+ var nonce [32]byte
92
+ if _, err := rand.Read(nonce[:]); err != nil {
93
+ return err
94
+ }
95
+ req := Request{Inventory: c.Expected, Op: "revoke", Nonce: hex.EncodeToString(nonce[:])}
96
+ if err := json.NewEncoder(conn).Encode(req); err != nil {
97
+ return err
98
+ }
99
+ var res Response
100
+ d := json.NewDecoder(io.LimitReader(conn, 4097))
101
+ d.DisallowUnknownFields()
102
+ if d.Decode(&res) != nil || res.Inventory != c.Expected || res.Nonce != req.Nonce || !res.OK || res.Error != "" {
103
+ return errTrust
104
+ }
105
+ return waitHealthy(ctx, func(context.Context) bool {
106
+ r, err := readRecord(c.StateDir, c.Owner)
107
+ return err == nil && r.Inventory == c.Expected && !r.Enabled && c.managementStopped(r)
108
+ })
109
+ }