@synopackageland/cli 0.3.9 → 0.4.0

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 (286) hide show
  1. package/README.md +20 -9
  2. package/dist/bin.js +1 -2
  3. package/dist/build-spk/generator.d.ts +0 -3
  4. package/dist/build-spk/generator.js +135 -287
  5. package/dist/build-spk/native-lifecycle.js +0 -3
  6. package/dist/build-spk/native-payload.js +0 -1
  7. package/dist/cli.js +171 -151
  8. package/dist/compose/analyze.d.ts +1 -2
  9. package/dist/compose/analyze.js +5 -48
  10. package/dist/contract/unsupported-types.d.ts +10 -0
  11. package/dist/contract/unsupported-types.js +49 -0
  12. package/dist/contract/validate.js +25 -2
  13. package/dist/contract/web-launch.js +0 -2
  14. package/dist/deploy/deploy.d.ts +1 -4
  15. package/dist/deploy/deploy.js +57 -156
  16. package/dist/deploy/homes-values.js +1 -1
  17. package/dist/deploy/plan.d.ts +18 -0
  18. package/dist/deploy/plan.js +54 -0
  19. package/dist/deploy/port-preflight.d.ts +21 -0
  20. package/dist/deploy/port-preflight.js +42 -0
  21. package/dist/deploy/spk-version.d.ts +2 -14
  22. package/dist/deploy/spk-version.js +25 -111
  23. package/dist/dev.d.ts +1 -3
  24. package/dist/dev.js +14 -28
  25. package/dist/error-registry.js +810 -857
  26. package/dist/host/dsm-client.js +23 -263
  27. package/dist/host/dsm-port-profile.d.ts +47 -0
  28. package/dist/host/dsm-port-profile.js +206 -0
  29. package/dist/host/port-info-lookup.d.ts +9 -0
  30. package/dist/host/port-info-lookup.js +87 -0
  31. package/dist/host/port-info.d.ts +33 -0
  32. package/dist/host/port-info.js +66 -0
  33. package/dist/host/types.d.ts +6 -78
  34. package/dist/index.d.ts +0 -9
  35. package/dist/index.js +0 -6
  36. package/dist/info.d.ts +0 -1
  37. package/dist/info.js +3 -10
  38. package/dist/init.d.ts +11 -1
  39. package/dist/init.js +112 -4
  40. package/dist/log.js +2 -3
  41. package/dist/native/validate.js +0 -1
  42. package/dist/standalone/binary.d.ts +9 -0
  43. package/dist/standalone/binary.js +67 -0
  44. package/dist/standalone/build.d.ts +25 -0
  45. package/dist/standalone/build.js +108 -0
  46. package/dist/standalone/dsm-window.d.ts +26 -0
  47. package/dist/standalone/dsm-window.js +251 -0
  48. package/dist/standalone/helper.d.ts +31 -0
  49. package/dist/standalone/helper.js +104 -0
  50. package/dist/standalone/i18n.d.ts +28 -0
  51. package/dist/standalone/i18n.js +161 -0
  52. package/dist/standalone/inspect-spk.d.ts +21 -0
  53. package/dist/standalone/inspect-spk.js +160 -0
  54. package/dist/standalone/manifest.d.ts +71 -0
  55. package/dist/standalone/manifest.js +276 -0
  56. package/dist/standalone/root-messages.d.ts +7 -0
  57. package/dist/standalone/root-messages.js +250 -0
  58. package/dist/standalone/root-ops.d.ts +42 -0
  59. package/dist/standalone/root-ops.js +101 -0
  60. package/dist/standalone/root-window.d.ts +35 -0
  61. package/dist/standalone/root-window.js +290 -0
  62. package/dist/standalone/spk.d.ts +22 -0
  63. package/dist/standalone/spk.js +205 -0
  64. package/dist/templates/binary/synology-app.yaml +7 -0
  65. package/dist/templates/hello-files/compose.yaml +0 -1
  66. package/dist/templates/hello-web/compose.yaml +13 -10
  67. package/dist/templates/hello-web/html/app.js +1 -23
  68. package/dist/templates/hello-web/html/index.html +1 -3
  69. package/dist/templates/hello-web-page/compose.yaml +13 -10
  70. package/dist/templates/hello-web-page/html/app.js +1 -23
  71. package/dist/templates/hello-web-page/html/index.html +1 -3
  72. package/dist/templates/native-go/README.md +268 -0
  73. package/dist/templates/native-go/backend/cmd/app/main.go +49 -0
  74. package/dist/templates/native-go/backend/cmd/helper/main.go +38 -0
  75. package/dist/templates/native-go/backend/go.mod +3 -0
  76. package/dist/templates/native-go/backend/internal/api/errors.go +33 -0
  77. package/dist/templates/native-go/backend/internal/api/registry.go +94 -0
  78. package/dist/templates/native-go/backend/internal/api/registry_test.go +329 -0
  79. package/dist/templates/native-go/backend/internal/api/server.go +155 -0
  80. package/dist/templates/native-go/backend/internal/app/root_none.go +9 -0
  81. package/dist/templates/native-go/backend/internal/app/routes.go +127 -0
  82. package/dist/templates/native-go/backend/internal/app/routes_test.go +167 -0
  83. package/dist/templates/native-go/backend/internal/dsm/auth.go +118 -0
  84. package/dist/templates/native-go/backend/internal/dsm/auth_test.go +139 -0
  85. package/dist/templates/native-go/backend/internal/helper/control.go +219 -0
  86. package/dist/templates/native-go/backend/internal/helper/control_test.go +242 -0
  87. package/dist/templates/native-go/backend/internal/helper/peer_linux.go +44 -0
  88. package/dist/templates/native-go/backend/internal/helper/peer_other.go +7 -0
  89. package/dist/templates/native-go/backend/internal/helper/process_linux.go +167 -0
  90. package/dist/templates/native-go/backend/internal/helper/process_other.go +5 -0
  91. package/dist/templates/native-go/backend/internal/helper/server_linux.go +83 -0
  92. package/dist/templates/native-go/backend/internal/helper/status.go +271 -0
  93. package/dist/templates/native-go/backend/internal/helper/status_test.go +205 -0
  94. package/dist/templates/native-go/backend/internal/helper/supervision.go +84 -0
  95. package/dist/templates/native-go/backend/internal/helper/supervision_test.go +124 -0
  96. package/dist/templates/native-go/backend/internal/helper/supervisor_linux.go +192 -0
  97. package/dist/templates/native-go/backend/internal/helper/supervisor_linux_test.go +302 -0
  98. package/dist/templates/native-go/dsm-package.yaml +58 -0
  99. package/dist/templates/native-go/gitignore +1 -0
  100. package/dist/templates/native-go/i18n/glossary.json +662 -0
  101. package/dist/templates/native-go/i18n/messages/de.json +28 -0
  102. package/dist/templates/native-go/i18n/messages/en.json +28 -0
  103. package/dist/templates/native-go/i18n/messages/es.json +28 -0
  104. package/dist/templates/native-go/i18n/messages/fr.json +28 -0
  105. package/dist/templates/native-go/i18n/messages/it.json +28 -0
  106. package/dist/templates/native-go/i18n/messages/ja.json +28 -0
  107. package/dist/templates/native-go/i18n/messages/ko.json +28 -0
  108. package/dist/templates/native-go/i18n/messages/zh-CN.json +28 -0
  109. package/dist/templates/native-go/i18n/messages/zh-TW.json +28 -0
  110. package/dist/templates/native-go/ui/app.css +35 -0
  111. package/dist/templates/native-go/ui/app.js +94 -0
  112. package/dist/templates/native-go-better-auth/backend/internal/app/identity.go +208 -0
  113. package/dist/templates/native-go-better-auth/backend/internal/app/identity_test.go +113 -0
  114. package/dist/templates/native-go-better-auth/node/app.mjs +72 -0
  115. package/dist/templates/native-go-better-auth/node/build.mjs +2 -0
  116. package/dist/templates/native-go-better-auth/node/package-lock.json +862 -0
  117. package/dist/templates/native-go-better-auth/node/package.json +1 -0
  118. package/dist/templates/native-go-better-auth/node/test/auth.test.mjs +31 -0
  119. package/dist/templates/native-go-integration/backend/internal/app/identity_none.go +6 -0
  120. package/dist/templates/native-go-integration/backend/internal/app/personal.go +203 -0
  121. package/dist/templates/native-go-integration/backend/internal/app/personal_test.go +119 -0
  122. package/dist/templates/native-go-root/backend/internal/app/root.go +107 -0
  123. package/dist/templates/native-go-root/backend/internal/app/root_test.go +124 -0
  124. package/dist/templates/native-go-root/root/whoami.sh +6 -0
  125. package/dist/templates/native-go-vue/README.md +110 -0
  126. package/dist/templates/native-go-vue/frontend/DESIGN.md +78 -0
  127. package/dist/templates/native-go-vue/frontend/package-lock.json +2507 -0
  128. package/dist/templates/native-go-vue/frontend/package.json +6 -0
  129. package/dist/templates/native-go-vue/frontend/src/App.vue +132 -0
  130. package/dist/templates/native-go-vue/frontend/src/context.ts +17 -0
  131. package/dist/templates/native-go-vue/frontend/src/main.ts +26 -0
  132. package/dist/templates/native-go-vue/frontend/src/style.css +14 -0
  133. package/dist/templates/native-go-vue/frontend/test/apps.test.ts +148 -0
  134. package/dist/templates/native-go-vue/frontend/tsconfig.json +1 -0
  135. package/dist/templates/native-go-vue/frontend/vite.config.ts +8 -0
  136. package/dist/templates/native-go-vue/gitignore +2 -0
  137. package/dist/types.d.ts +1 -1
  138. package/dist/validate.js +11 -7
  139. package/package.json +3 -7
  140. package/schema/synology-app.schema.json +36 -37
  141. package/skills/SKILL.md +4 -48
  142. package/skills/reference/create.md +11 -83
  143. package/skills/reference/deploy.md +7 -78
  144. package/skills/reference/diagnose.md +4 -101
  145. package/skills/reference/files.md +5 -41
  146. package/skills/reference/identity.md +3 -29
  147. package/skills/reference/wrap.md +0 -2
  148. package/templates/binary/synology-app.yaml +7 -0
  149. package/templates/hello-files/compose.yaml +0 -1
  150. package/templates/hello-web/compose.yaml +13 -10
  151. package/templates/hello-web/html/app.js +1 -23
  152. package/templates/hello-web/html/index.html +1 -3
  153. package/templates/hello-web-page/compose.yaml +13 -10
  154. package/templates/hello-web-page/html/app.js +1 -23
  155. package/templates/hello-web-page/html/index.html +1 -3
  156. package/templates/native-go/README.md +268 -0
  157. package/templates/native-go/backend/cmd/app/main.go +49 -0
  158. package/templates/native-go/backend/cmd/helper/main.go +38 -0
  159. package/templates/native-go/backend/go.mod +3 -0
  160. package/templates/native-go/backend/internal/api/errors.go +33 -0
  161. package/templates/native-go/backend/internal/api/registry.go +94 -0
  162. package/templates/native-go/backend/internal/api/registry_test.go +329 -0
  163. package/templates/native-go/backend/internal/api/server.go +155 -0
  164. package/templates/native-go/backend/internal/app/root_none.go +9 -0
  165. package/templates/native-go/backend/internal/app/routes.go +127 -0
  166. package/templates/native-go/backend/internal/app/routes_test.go +167 -0
  167. package/templates/native-go/backend/internal/dsm/auth.go +118 -0
  168. package/templates/native-go/backend/internal/dsm/auth_test.go +139 -0
  169. package/templates/native-go/backend/internal/helper/control.go +219 -0
  170. package/templates/native-go/backend/internal/helper/control_test.go +242 -0
  171. package/templates/native-go/backend/internal/helper/peer_linux.go +44 -0
  172. package/templates/native-go/backend/internal/helper/peer_other.go +7 -0
  173. package/templates/native-go/backend/internal/helper/process_linux.go +167 -0
  174. package/templates/native-go/backend/internal/helper/process_other.go +5 -0
  175. package/templates/native-go/backend/internal/helper/server_linux.go +83 -0
  176. package/templates/native-go/backend/internal/helper/status.go +271 -0
  177. package/templates/native-go/backend/internal/helper/status_test.go +205 -0
  178. package/templates/native-go/backend/internal/helper/supervision.go +84 -0
  179. package/templates/native-go/backend/internal/helper/supervision_test.go +124 -0
  180. package/templates/native-go/backend/internal/helper/supervisor_linux.go +192 -0
  181. package/templates/native-go/backend/internal/helper/supervisor_linux_test.go +302 -0
  182. package/templates/native-go/dsm-package.yaml +58 -0
  183. package/templates/native-go/gitignore +1 -0
  184. package/templates/native-go/i18n/glossary.json +662 -0
  185. package/templates/native-go/i18n/messages/de.json +28 -0
  186. package/templates/native-go/i18n/messages/en.json +28 -0
  187. package/templates/native-go/i18n/messages/es.json +28 -0
  188. package/templates/native-go/i18n/messages/fr.json +28 -0
  189. package/templates/native-go/i18n/messages/it.json +28 -0
  190. package/templates/native-go/i18n/messages/ja.json +28 -0
  191. package/templates/native-go/i18n/messages/ko.json +28 -0
  192. package/templates/native-go/i18n/messages/zh-CN.json +28 -0
  193. package/templates/native-go/i18n/messages/zh-TW.json +28 -0
  194. package/templates/native-go/ui/app.css +35 -0
  195. package/templates/native-go/ui/app.js +94 -0
  196. package/templates/native-go-better-auth/backend/internal/app/identity.go +208 -0
  197. package/templates/native-go-better-auth/backend/internal/app/identity_test.go +113 -0
  198. package/templates/native-go-better-auth/node/app.mjs +72 -0
  199. package/templates/native-go-better-auth/node/build.mjs +2 -0
  200. package/templates/native-go-better-auth/node/package-lock.json +862 -0
  201. package/templates/native-go-better-auth/node/package.json +1 -0
  202. package/templates/native-go-better-auth/node/test/auth.test.mjs +31 -0
  203. package/templates/native-go-integration/backend/internal/app/identity_none.go +6 -0
  204. package/templates/native-go-integration/backend/internal/app/personal.go +203 -0
  205. package/templates/native-go-integration/backend/internal/app/personal_test.go +119 -0
  206. package/templates/native-go-root/backend/internal/app/root.go +107 -0
  207. package/templates/native-go-root/backend/internal/app/root_test.go +124 -0
  208. package/templates/native-go-root/root/whoami.sh +6 -0
  209. package/templates/native-go-vue/README.md +110 -0
  210. package/templates/native-go-vue/frontend/DESIGN.md +78 -0
  211. package/templates/native-go-vue/frontend/package-lock.json +2507 -0
  212. package/templates/native-go-vue/frontend/package.json +6 -0
  213. package/templates/native-go-vue/frontend/src/App.vue +132 -0
  214. package/templates/native-go-vue/frontend/src/context.ts +17 -0
  215. package/templates/native-go-vue/frontend/src/main.ts +26 -0
  216. package/templates/native-go-vue/frontend/src/style.css +14 -0
  217. package/templates/native-go-vue/frontend/test/apps.test.ts +148 -0
  218. package/templates/native-go-vue/frontend/tsconfig.json +1 -0
  219. package/templates/native-go-vue/frontend/vite.config.ts +8 -0
  220. package/templates/native-go-vue/gitignore +2 -0
  221. package/dist/attestation/app-package.d.ts +0 -86
  222. package/dist/attestation/app-package.js +0 -206
  223. package/dist/attestation/install-on-host.d.ts +0 -14
  224. package/dist/attestation/install-on-host.js +0 -52
  225. package/dist/attestation/keyring.d.ts +0 -21
  226. package/dist/attestation/keyring.js +0 -168
  227. package/dist/attestation/platform-service.d.ts +0 -70
  228. package/dist/attestation/platform-service.js +0 -250
  229. package/dist/build-spk/broker-glue.d.ts +0 -16
  230. package/dist/build-spk/broker-glue.js +0 -40
  231. package/dist/deploy/catalog-publish.d.ts +0 -3
  232. package/dist/deploy/catalog-publish.js +0 -7
  233. package/dist/doctor.d.ts +0 -22
  234. package/dist/doctor.js +0 -721
  235. package/dist/error-collector.d.ts +0 -2
  236. package/dist/error-collector.js +0 -46
  237. package/dist/host/broker-paths.d.ts +0 -1
  238. package/dist/host/broker-paths.js +0 -1
  239. package/dist/host/broker-upgrade-preflight.d.ts +0 -61
  240. package/dist/host/broker-upgrade-preflight.js +0 -156
  241. package/dist/host/digests.d.ts +0 -5
  242. package/dist/host/digests.js +0 -63
  243. package/dist/host/fake-nas.d.ts +0 -41
  244. package/dist/host/fake-nas.js +0 -174
  245. package/dist/mock-broker/bootstrap.d.ts +0 -2
  246. package/dist/mock-broker/bootstrap.js +0 -8
  247. package/dist/mock-broker/files.d.ts +0 -60
  248. package/dist/mock-broker/files.js +0 -408
  249. package/dist/mock-broker/oidc.d.ts +0 -31
  250. package/dist/mock-broker/oidc.js +0 -161
  251. package/dist/mock-broker/path.d.ts +0 -9
  252. package/dist/mock-broker/path.js +0 -50
  253. package/dist/mock-broker/server.d.ts +0 -19
  254. package/dist/mock-broker/server.js +0 -350
  255. package/dist/publish/build-ledger.d.ts +0 -22
  256. package/dist/publish/build-ledger.js +0 -26
  257. package/dist/publish/community-path.d.ts +0 -6
  258. package/dist/publish/community-path.js +0 -41
  259. package/dist/publish/curated-release.d.ts +0 -78
  260. package/dist/publish/curated-release.js +0 -883
  261. package/dist/publish/file-build-ledger.d.ts +0 -18
  262. package/dist/publish/file-build-ledger.js +0 -268
  263. package/dist/publish/github-pr.d.ts +0 -25
  264. package/dist/publish/github-pr.js +0 -85
  265. package/dist/publish/github-repo.d.ts +0 -8
  266. package/dist/publish/github-repo.js +0 -21
  267. package/dist/publish/platform-service-release.d.ts +0 -60
  268. package/dist/publish/platform-service-release.js +0 -333
  269. package/dist/publish/preflight.d.ts +0 -24
  270. package/dist/publish/preflight.js +0 -138
  271. package/dist/publish/publish.d.ts +0 -27
  272. package/dist/publish/publish.js +0 -136
  273. package/dist/publish/sync-definition.d.ts +0 -4
  274. package/dist/publish/sync-definition.js +0 -48
  275. package/dist/release-bin.d.ts +0 -2
  276. package/dist/release-bin.js +0 -23
  277. package/dist/templates/hello-web/html/sdk.js +0 -68
  278. package/dist/templates/hello-web-page/html/sdk.js +0 -68
  279. package/dist/templates/native/synology-app.yaml +0 -7
  280. package/dist/test-host.d.ts +0 -15
  281. package/dist/test-host.js +0 -37
  282. package/templates/hello-web/html/sdk.js +0 -68
  283. package/templates/hello-web-page/html/sdk.js +0 -68
  284. package/templates/native/synology-app.yaml +0 -7
  285. /package/dist/templates/{native → binary}/native.yaml +0 -0
  286. /package/templates/{native → binary}/native.yaml +0 -0
@@ -0,0 +1,271 @@
1
+ // Package helper implements the opt-in per-package lifecycle contract. It has
2
+ // no file/grant/job API: all unimplemented operations fail closed.
3
+ package helper
4
+
5
+ import (
6
+ "context"
7
+ "crypto/rand"
8
+ "crypto/sha256"
9
+ "encoding/hex"
10
+ "encoding/json"
11
+ "errors"
12
+ "io"
13
+ "io/fs"
14
+ "net"
15
+ "os"
16
+ "path/filepath"
17
+ "regexp"
18
+ "syscall"
19
+ "time"
20
+ )
21
+
22
+ type Inventory struct {
23
+ Package string `json:"package"`
24
+ Version string `json:"version"`
25
+ Protocol int `json:"protocol"`
26
+ PayloadDigest string `json:"payloadDigest"`
27
+ RegistryDigest string `json:"registryDigest"`
28
+ }
29
+
30
+ var digestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
31
+ var packagePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9]{1,63}$`)
32
+ var versionPattern = regexp.MustCompile(`^\d+(\.\d+){0,3}-\d{4}$`)
33
+
34
+ func (i Inventory) Valid() bool {
35
+ return packagePattern.MatchString(i.Package) && versionPattern.MatchString(i.Version) && i.Protocol > 0 && digestPattern.MatchString(i.PayloadDigest) && digestPattern.MatchString(i.RegistryDigest)
36
+ }
37
+
38
+ type Record struct {
39
+ Inventory Inventory `json:"inventory"`
40
+ Enabled bool `json:"enabled"`
41
+ Residual bool `json:"residual,omitempty"`
42
+ }
43
+ type Request struct {
44
+ Inventory Inventory `json:"inventory"`
45
+ Op string `json:"op"`
46
+ Nonce string `json:"nonce"`
47
+ }
48
+ type Response struct {
49
+ Inventory Inventory `json:"inventory"`
50
+ Nonce string `json:"nonce"`
51
+ OK bool `json:"ok"`
52
+ Error string `json:"error,omitempty"`
53
+ }
54
+
55
+ func Respond(i Inventory, disabled bool, req Request) Response {
56
+ r := Response{Inventory: i, Nonce: req.Nonce}
57
+ switch {
58
+ case disabled:
59
+ r.Error = "disabled"
60
+ case !i.Valid() || req.Inventory != i:
61
+ r.Error = "incompatible"
62
+ case req.Op != "health":
63
+ r.Error = "unsupported"
64
+ default:
65
+ r.OK = true
66
+ }
67
+ return r
68
+ }
69
+
70
+ // State is safe to expose to any authenticated DSM user. Paths, PIDs, worker
71
+ // internals, scheduler definitions and raw errors never enter this envelope.
72
+ type State struct {
73
+ State string `json:"state"`
74
+ Reason string `json:"reason"`
75
+ PackageRunning string `json:"packageRunning"`
76
+ Scheduler string `json:"scheduler"`
77
+ Cleanup string `json:"cleanup,omitempty"`
78
+ }
79
+
80
+ type Client struct {
81
+ Expected Inventory
82
+ StateDir, RunDir string
83
+ // Production uses UID 0 and RootPeer. Tests inject only local fixture identity.
84
+ Owner uint32
85
+ Peer func(*net.UnixConn) error
86
+ }
87
+
88
+ func NewClient(i Inventory) Client {
89
+ return Client{Expected: i, StateDir: "/var/lib/synopkgland-" + i.Package, RunDir: "/run/synopkgland-helper-" + i.Package, Peer: RootPeer}
90
+ }
91
+
92
+ // LocalClient is a nonprivileged diagnostic entry point for package hooks.
93
+ // Hashing the local executable establishes only the expected build inventory;
94
+ // readiness still requires the protected record and root socket handshake.
95
+ func LocalClient(i Inventory) (Client, error) {
96
+ exe, err := os.Executable()
97
+ if err != nil {
98
+ return Client{}, err
99
+ }
100
+ f, err := os.Open(exe)
101
+ if err != nil {
102
+ return Client{}, err
103
+ }
104
+ defer f.Close()
105
+ h := sha256.New()
106
+ if _, err := io.Copy(h, io.LimitReader(f, (64<<20)+1)); err != nil {
107
+ return Client{}, err
108
+ }
109
+ i.PayloadDigest = hex.EncodeToString(h.Sum(nil))
110
+ if !i.Valid() {
111
+ return Client{}, errTrust
112
+ }
113
+ return NewClient(i), nil
114
+ }
115
+
116
+ func (c Client) CheckStopped(ctx context.Context) error {
117
+ s := c.Status(ctx)
118
+ if s.Cleanup != "" || (s.State != "not-enabled" && s.State != "stopped" && s.State != "needs-approval") {
119
+ return errors.New("approved helper teardown required")
120
+ }
121
+ for _, file := range []string{"process.json", "helper.sock", "supervisor.sock"} {
122
+ if _, err := os.Lstat(filepath.Join(c.RunDir, file)); !errors.Is(err, fs.ErrNotExist) {
123
+ return errors.New("helper residue; approved teardown required")
124
+ }
125
+ }
126
+ return nil
127
+ }
128
+
129
+ var errTrust = errors.New("untrusted helper state")
130
+
131
+ func protected(path string, owner uint32, directory bool) error {
132
+ st, err := os.Lstat(path)
133
+ if err != nil {
134
+ return err
135
+ }
136
+ s, ok := st.Sys().(*syscall.Stat_t)
137
+ if !ok || s.Uid != owner || st.Mode().Perm()&0022 != 0 || (directory && !st.IsDir()) || (!directory && !st.Mode().IsRegular()) {
138
+ return errTrust
139
+ }
140
+ return nil
141
+ }
142
+
143
+ func readRecord(dir string, owner uint32) (Record, error) {
144
+ var r Record
145
+ if err := protected(dir, owner, true); err != nil {
146
+ return r, err
147
+ }
148
+ f, err := os.OpenFile(filepath.Join(dir, "approval.json"), os.O_RDONLY|syscall.O_NOFOLLOW, 0)
149
+ if err != nil {
150
+ return r, err
151
+ }
152
+ defer f.Close()
153
+ st, err := f.Stat()
154
+ if err != nil {
155
+ return r, err
156
+ }
157
+ s, ok := st.Sys().(*syscall.Stat_t)
158
+ if !ok || s.Uid != owner || st.Mode().Perm()&0022 != 0 || !st.Mode().IsRegular() || st.Size() > 4096 {
159
+ return r, errTrust
160
+ }
161
+ d := json.NewDecoder(io.LimitReader(f, 4097))
162
+ d.DisallowUnknownFields()
163
+ var decoded struct {
164
+ Inventory Inventory `json:"inventory"`
165
+ Enabled *bool `json:"enabled"`
166
+ Residual bool `json:"residual,omitempty"`
167
+ }
168
+ if d.Decode(&decoded) != nil || d.Decode(new(any)) != io.EOF || !decoded.Inventory.Valid() || decoded.Enabled == nil {
169
+ return r, errTrust
170
+ }
171
+ r = Record{Inventory: decoded.Inventory, Enabled: *decoded.Enabled, Residual: decoded.Residual}
172
+ return r, nil
173
+ }
174
+
175
+ func (c Client) Status(ctx context.Context) State {
176
+ result := func(state, reason string) State {
177
+ return State{State: state, Reason: reason, PackageRunning: "unknown", Scheduler: "not-probed"}
178
+ }
179
+ if c.Expected.Package == "" {
180
+ return result("not-enabled", "not-requested")
181
+ }
182
+ if !c.Expected.Valid() {
183
+ return result("incompatible", "invalid-inventory")
184
+ }
185
+ r, err := readRecord(c.StateDir, c.Owner)
186
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
187
+ return result("incompatible", "untrusted-approval")
188
+ }
189
+ if err == nil && !r.Enabled {
190
+ s := result("not-enabled", "disabled")
191
+ if r.Residual {
192
+ s.Cleanup = "required"
193
+ }
194
+ return s
195
+ }
196
+ if err == nil && r.Inventory != c.Expected {
197
+ return result("incompatible", "inventory-mismatch")
198
+ }
199
+ if err == nil && r.Residual {
200
+ s := result("failed", "incomplete-transition")
201
+ s.Cleanup = "required"
202
+ return s
203
+ }
204
+ // Conflict has precedence over missing approval. Even an unapproved leftover
205
+ // socket is probed before claiming that authorization alone can fix it.
206
+ if e := protected(c.RunDir, c.Owner, true); e != nil && !errors.Is(e, fs.ErrNotExist) {
207
+ return result("incompatible", "untrusted-runtime")
208
+ }
209
+ socket := filepath.Join(c.RunDir, "helper.sock")
210
+ st, e := os.Lstat(socket)
211
+ if e == nil {
212
+ s, ok := st.Sys().(*syscall.Stat_t)
213
+ if !ok || s.Uid != c.Owner || st.Mode()&os.ModeSocket == 0 || st.Mode().Perm()&0002 != 0 {
214
+ return result("incompatible", "untrusted-socket")
215
+ }
216
+ state, reason := c.probe(ctx, socket)
217
+ if state != "ready" {
218
+ return result(state, reason)
219
+ }
220
+ } else if !errors.Is(e, fs.ErrNotExist) {
221
+ return result("failed", "runtime-unreadable")
222
+ }
223
+ if err != nil {
224
+ return result("needs-approval", "approval-missing")
225
+ }
226
+ if e != nil {
227
+ return result("stopped", "runtime-missing")
228
+ }
229
+ return result("ready", "live-handshake")
230
+ }
231
+
232
+ func (c Client) probe(ctx context.Context, socket string) (string, string) {
233
+ deadline := time.Now().Add(time.Second)
234
+ if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
235
+ deadline = d
236
+ }
237
+ dialer := net.Dialer{Deadline: deadline}
238
+ conn, err := dialer.DialContext(ctx, "unix", socket)
239
+ if err != nil {
240
+ if errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, fs.ErrNotExist) {
241
+ return "stopped", "runtime-not-listening"
242
+ }
243
+ return "failed", "probe-failed"
244
+ }
245
+ defer conn.Close()
246
+ if c.Peer == nil || c.Peer(conn.(*net.UnixConn)) != nil {
247
+ return "incompatible", "untrusted-peer"
248
+ }
249
+ conn.SetDeadline(deadline)
250
+ var nonce [32]byte
251
+ if _, err := rand.Read(nonce[:]); err != nil {
252
+ return "failed", "probe-failed"
253
+ }
254
+ req := Request{c.Expected, "health", hex.EncodeToString(nonce[:])}
255
+ if json.NewEncoder(conn).Encode(req) != nil {
256
+ return "failed", "probe-failed"
257
+ }
258
+ var res Response
259
+ d := json.NewDecoder(io.LimitReader(conn, 4097))
260
+ d.DisallowUnknownFields()
261
+ if d.Decode(&res) != nil {
262
+ return "failed", "invalid-handshake"
263
+ }
264
+ if res.Nonce != req.Nonce || res.Inventory != c.Expected || res.Error == "incompatible" {
265
+ return "incompatible", "handshake-mismatch"
266
+ }
267
+ if !res.OK || res.Error != "" {
268
+ return "failed", "health-refused"
269
+ }
270
+ return "ready", "live-handshake"
271
+ }
@@ -0,0 +1,205 @@
1
+ package helper
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "net"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ "testing"
11
+ )
12
+
13
+ func fixture(t *testing.T) Client {
14
+ t.Helper()
15
+ i := Inventory{Package: "Fixture", Version: "1.0.0-0001", Protocol: 1, PayloadDigest: strings.Repeat("a", 64), RegistryDigest: strings.Repeat("b", 64)}
16
+ run, err := os.MkdirTemp("/tmp", "m1a-")
17
+ if err != nil {
18
+ t.Fatal(err)
19
+ }
20
+ t.Cleanup(func() { os.RemoveAll(run) })
21
+ return Client{Expected: i, StateDir: t.TempDir(), RunDir: run, Owner: uint32(os.Getuid()), Peer: func(*net.UnixConn) error { return nil }}
22
+ }
23
+
24
+ func record(t *testing.T, c Client, r Record) {
25
+ t.Helper()
26
+ b, _ := json.Marshal(r)
27
+ if err := os.WriteFile(filepath.Join(c.StateDir, "approval.json"), b, 0644); err != nil {
28
+ t.Fatal(err)
29
+ }
30
+ }
31
+
32
+ func live(t *testing.T, c Client, i Inventory, reply func(Request) Response) {
33
+ t.Helper()
34
+ l, err := net.ListenUnix("unix", &net.UnixAddr{Name: filepath.Join(c.RunDir, "helper.sock"), Net: "unix"})
35
+ if err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ t.Cleanup(func() { l.Close() })
39
+ go func() {
40
+ for {
41
+ conn, err := l.AcceptUnix()
42
+ if err != nil {
43
+ return
44
+ }
45
+ go func() {
46
+ defer conn.Close()
47
+ var req Request
48
+ if json.NewDecoder(conn).Decode(&req) != nil {
49
+ return
50
+ }
51
+ r := Respond(i, false, req)
52
+ if reply != nil {
53
+ r = reply(req)
54
+ }
55
+ json.NewEncoder(conn).Encode(r)
56
+ }()
57
+ }
58
+ }()
59
+ }
60
+
61
+ func TestSixStatesAndPriority(t *testing.T) {
62
+ c := fixture(t)
63
+ check := func(want string) {
64
+ t.Helper()
65
+ s := c.Status(context.Background())
66
+ if s.State != want {
67
+ t.Fatalf("want %s, got %+v", want, s)
68
+ }
69
+ }
70
+ c.Expected = Inventory{}
71
+ check("not-enabled")
72
+ c.Expected = fixture(t).Expected
73
+ check("needs-approval")
74
+ record(t, c, Record{Inventory: c.Expected, Enabled: true})
75
+ check("stopped")
76
+ live(t, c, c.Expected, nil)
77
+ check("ready")
78
+ old := c.Expected
79
+ old.Protocol++
80
+ record(t, c, Record{Inventory: old, Enabled: true})
81
+ check("incompatible")
82
+ record(t, c, Record{Inventory: old, Enabled: false})
83
+ check("not-enabled")
84
+ }
85
+
86
+ func TestLiveProbeCannotBeReplacedByApprovalOrHistoricalOutput(t *testing.T) {
87
+ c := fixture(t)
88
+ record(t, c, Record{Inventory: c.Expected, Enabled: true})
89
+ if err := os.WriteFile(filepath.Join(c.RunDir, "helper.sock"), []byte("ready"), 0644); err != nil {
90
+ t.Fatal(err)
91
+ }
92
+ if got := c.Status(context.Background()).State; got != "incompatible" {
93
+ t.Fatal(got)
94
+ }
95
+ }
96
+
97
+ func TestHandshakeFailuresAreRedacted(t *testing.T) {
98
+ for _, kind := range []string{"nonce", "protocol", "payload", "worker", "version", "peer"} {
99
+ t.Run(kind, func(t *testing.T) {
100
+ c := fixture(t)
101
+ record(t, c, Record{Inventory: c.Expected, Enabled: true})
102
+ live(t, c, c.Expected, func(req Request) Response {
103
+ r := Respond(c.Expected, false, req)
104
+ switch kind {
105
+ case "nonce":
106
+ r.Nonce = "replay"
107
+ case "protocol":
108
+ r.Inventory.Protocol++
109
+ case "payload":
110
+ r.Inventory.PayloadDigest = "foreign"
111
+ case "worker":
112
+ r.Inventory.RegistryDigest = "foreign"
113
+ case "version":
114
+ r.Inventory.Version = "old"
115
+ }
116
+ return r
117
+ })
118
+ if kind == "peer" {
119
+ c.Peer = func(*net.UnixConn) error { return os.ErrPermission }
120
+ }
121
+ s := c.Status(context.Background())
122
+ if s.State != "incompatible" {
123
+ t.Fatalf("%+v", s)
124
+ }
125
+ b, _ := json.Marshal(s)
126
+ if strings.Contains(string(b), c.StateDir) || strings.Contains(string(b), "foreign") {
127
+ t.Fatal(string(b))
128
+ }
129
+ })
130
+ }
131
+ c := fixture(t)
132
+ record(t, c, Record{Inventory: c.Expected, Enabled: true})
133
+ live(t, c, c.Expected, func(req Request) Response { return Response{Inventory: c.Expected, Nonce: req.Nonce, Error: "failed"} })
134
+ if c.Status(context.Background()).State != "failed" {
135
+ t.Fatal("health failure must be failed")
136
+ }
137
+ }
138
+
139
+ func TestUntrustedPersistentStateFailsClosed(t *testing.T) {
140
+ for _, kind := range []string{"writable", "symlink", "malformed"} {
141
+ t.Run(kind, func(t *testing.T) {
142
+ c := fixture(t)
143
+ record(t, c, Record{Inventory: c.Expected, Enabled: true})
144
+ p := filepath.Join(c.StateDir, "approval.json")
145
+ switch kind {
146
+ case "writable":
147
+ os.Chmod(p, 0666)
148
+ case "symlink":
149
+ os.Rename(p, p+".real")
150
+ os.Symlink(p+".real", p)
151
+ case "malformed":
152
+ os.WriteFile(p, []byte(`{"enabled":false}`), 0644)
153
+ }
154
+ if c.Status(context.Background()).State != "incompatible" {
155
+ t.Fatal("untrusted state accepted")
156
+ }
157
+ })
158
+ }
159
+ }
160
+
161
+ func TestOldHelperRejectsNewInventoryAndAllUnimplementedWork(t *testing.T) {
162
+ i := fixture(t).Expected
163
+ for _, field := range []string{"protocol", "payload", "workers", "version", "package"} {
164
+ new := i
165
+ switch field {
166
+ case "protocol":
167
+ new.Protocol++
168
+ case "payload":
169
+ new.PayloadDigest = strings.Repeat("c", 64)
170
+ case "workers":
171
+ new.RegistryDigest = strings.Repeat("c", 64)
172
+ case "version":
173
+ new.Version = "2.0.0-0001"
174
+ case "package":
175
+ new.Package = "Other"
176
+ }
177
+ r := Respond(i, false, Request{Inventory: new, Op: "work", Nonce: "test"})
178
+ if r.Error != "incompatible" || r.OK {
179
+ t.Fatalf("%s: %+v", field, r)
180
+ }
181
+ }
182
+ if r := Respond(i, true, Request{Inventory: i, Op: "health"}); r.OK || r.Error != "disabled" {
183
+ t.Fatal(r)
184
+ }
185
+ if r := Respond(i, false, Request{Inventory: i, Op: "work"}); r.OK || r.Error != "unsupported" {
186
+ t.Fatal(r)
187
+ }
188
+ }
189
+
190
+ func TestMissingEnableFieldIsNotARevocationRecord(t *testing.T) {
191
+ c := fixture(t)
192
+ b, _ := json.Marshal(map[string]any{"inventory": c.Expected})
193
+ os.WriteFile(filepath.Join(c.StateDir, "approval.json"), b, 0644)
194
+ if c.Status(context.Background()).State != "incompatible" {
195
+ t.Fatal("incomplete approval treated as trusted disable")
196
+ }
197
+ }
198
+
199
+ func TestProcessRecordIsNotReadableByOrdinaryOSUsers(t *testing.T) {
200
+ c, _ := controlFixture(t)
201
+ s, err := os.Stat(filepath.Join(c.RunDir, "process.json"))
202
+ if err != nil || s.Mode().Perm() != 0600 {
203
+ t.Fatalf("process record permissions: %v %v", s, err)
204
+ }
205
+ }
@@ -0,0 +1,84 @@
1
+ package helper
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "io"
8
+ "syscall"
9
+ "time"
10
+ )
11
+
12
+ // Also runs after exec: the parent can die between fork's prctl and exec.
13
+ func armParentDeath(expected int, set func() error, parent func() int) error {
14
+ if expected <= 1 || parent() != expected {
15
+ return errTrust
16
+ }
17
+ if err := set(); err != nil {
18
+ return err
19
+ }
20
+ if parent() != expected {
21
+ return errTrust
22
+ }
23
+ return nil
24
+ }
25
+
26
+ type supervisedChild interface {
27
+ exited() (bool, error)
28
+ signal(syscall.Signal) error
29
+ }
30
+
31
+ // Caller is the sole reaper and signal owner. A zombie retains its PID until
32
+ // exited() reaps it; a background Wait goroutine would invalidate this proof.
33
+ func stopChild(ctx context.Context, child supervisedChild, grace time.Duration) error {
34
+ if err := ctx.Err(); err != nil {
35
+ return err
36
+ }
37
+ done, err := child.exited()
38
+ if err != nil || done {
39
+ return err
40
+ }
41
+ if err := child.signal(syscall.SIGTERM); err != nil {
42
+ return err
43
+ }
44
+ escalate := time.Now().Add(grace)
45
+ killed := false
46
+ for {
47
+ if err := ctx.Err(); err != nil {
48
+ return err
49
+ }
50
+ done, err := child.exited()
51
+ if err != nil || done {
52
+ return err
53
+ }
54
+ if !killed && !time.Now().Before(escalate) {
55
+ if err := child.signal(syscall.SIGKILL); err != nil {
56
+ return err
57
+ }
58
+ killed = true
59
+ }
60
+ timer := time.NewTimer(time.Millisecond)
61
+ select {
62
+ case <-ctx.Done():
63
+ timer.Stop()
64
+ return ctx.Err()
65
+ case <-timer.C:
66
+ }
67
+ }
68
+ }
69
+
70
+ // Read to actual connection EOF. A LimitReader's synthetic EOF at the size
71
+ // cap can never certify supervisor exit, so reaching that cap is a refusal.
72
+ func readSupervisorReply(reader io.Reader, want Inventory, nonce string) error {
73
+ b, err := io.ReadAll(io.LimitReader(reader, 4097))
74
+ if err != nil || len(b) > 4096 {
75
+ return errTrust
76
+ }
77
+ var res Response
78
+ d := json.NewDecoder(bytes.NewReader(b))
79
+ d.DisallowUnknownFields()
80
+ if d.Decode(&res) != nil || d.Decode(new(any)) != io.EOF || !res.OK || res.Error != "" || res.Inventory != want || res.Nonce != nonce {
81
+ return errTrust
82
+ }
83
+ return nil
84
+ }
@@ -0,0 +1,124 @@
1
+ package helper
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "strings"
8
+ "syscall"
9
+ "testing"
10
+ "time"
11
+ )
12
+
13
+ func TestParentDeathSetupChecksBeforeAndAfterPrctl(t *testing.T) {
14
+ for _, tc := range []struct {
15
+ before, after int
16
+ setErr error
17
+ ok bool
18
+ }{
19
+ {42, 42, nil, true}, {1, 1, nil, false}, {42, 1, nil, false}, {42, 42, syscall.EPERM, false},
20
+ } {
21
+ calls, sets := 0, 0
22
+ err := armParentDeath(42, func() error { sets++; return tc.setErr }, func() int {
23
+ calls++
24
+ if calls == 1 {
25
+ return tc.before
26
+ }
27
+ return tc.after
28
+ })
29
+ if (err == nil) != tc.ok {
30
+ t.Fatalf("%+v: %v", tc, err)
31
+ }
32
+ if tc.before != 42 && sets != 0 {
33
+ t.Fatal("already reparented child armed")
34
+ }
35
+ if tc.ok && calls != 2 {
36
+ t.Fatal("parent race not checked")
37
+ }
38
+ }
39
+ }
40
+
41
+ type childFixture struct {
42
+ reaped bool
43
+ err error
44
+ signals []syscall.Signal
45
+ exitOn syscall.Signal
46
+ }
47
+
48
+ func (c *childFixture) exited() (bool, error) { return c.reaped, c.err }
49
+ func (c *childFixture) signal(s syscall.Signal) error {
50
+ if c.reaped {
51
+ panic("signalled a reaped child")
52
+ }
53
+ c.signals = append(c.signals, s)
54
+ if s == c.exitOn {
55
+ c.reaped = true
56
+ }
57
+ return nil
58
+ }
59
+ func TestOnlyUnreapedOwnedChildCanBeSignalled(t *testing.T) {
60
+ for _, tc := range []struct {
61
+ reaped bool
62
+ err error
63
+ exit syscall.Signal
64
+ count int
65
+ ok bool
66
+ }{
67
+ {true, nil, 0, 0, true}, {false, syscall.ECHILD, 0, 0, false}, {false, nil, syscall.SIGTERM, 1, true}, {false, nil, syscall.SIGKILL, 2, true}, {false, nil, 0, 2, false},
68
+ } {
69
+ c := &childFixture{reaped: tc.reaped, err: tc.err, exitOn: tc.exit}
70
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
71
+ err := stopChild(ctx, c, time.Millisecond)
72
+ cancel()
73
+ if (err == nil) != tc.ok || len(c.signals) != tc.count {
74
+ t.Fatalf("%+v: err=%v signals=%v", tc, err, c.signals)
75
+ }
76
+ if c.reaped {
77
+ n := len(c.signals)
78
+ if stopChild(context.Background(), c, time.Millisecond) != nil || len(c.signals) != n {
79
+ t.Fatal("reaped PID reused")
80
+ }
81
+ }
82
+ }
83
+ }
84
+ func TestCancelledStopCannotSignal(t *testing.T) {
85
+ ctx, cancel := context.WithCancel(context.Background())
86
+ cancel()
87
+ c := &childFixture{}
88
+ if stopChild(ctx, c, time.Millisecond) == nil || len(c.signals) != 0 {
89
+ t.Fatal("cancelled operation signalled")
90
+ }
91
+ }
92
+
93
+ func TestSupervisorReplyRequiresRealEOFAndStrictBoundedAcknowledgement(t *testing.T) {
94
+ i := fixture(t).Expected
95
+ nonce := strings.Repeat("c", 64)
96
+ good, _ := json.Marshal(Response{Inventory: i, Nonce: nonce, OK: true})
97
+ for _, tc := range []struct {
98
+ data []byte
99
+ ok bool
100
+ }{
101
+ {good, true}, {append(append([]byte{}, good...), []byte(" {}")...), false},
102
+ {bytes.Replace(good, []byte(nonce), []byte(strings.Repeat("d", 64)), 1), false},
103
+ {append(append([]byte{}, good[:len(good)-1]...), []byte(",\"unknown\":true}")...), false},
104
+ {append(append([]byte{}, good...), bytes.Repeat([]byte(" "), 4097)...), false},
105
+ } {
106
+ if (readSupervisorReply(bytes.NewReader(tc.data), i, nonce) == nil) != tc.ok {
107
+ t.Fatalf("reply accepted=%v expected=%v", !tc.ok, tc.ok)
108
+ }
109
+ }
110
+ if readSupervisorReply(&brokenReplyReader{data: good}, i, nonce) == nil {
111
+ t.Fatal("read error treated as cooperative exit")
112
+ }
113
+ }
114
+
115
+ type brokenReplyReader struct{ data []byte }
116
+
117
+ func (r *brokenReplyReader) Read(p []byte) (int, error) {
118
+ if len(r.data) == 0 {
119
+ return 0, syscall.ECONNRESET
120
+ }
121
+ n := copy(p, r.data)
122
+ r.data = r.data[n:]
123
+ return n, nil
124
+ }