@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 @@
1
+ {"name":"native-dsm-better-auth","private":true,"type":"module","scripts":{"build":"node build.mjs","test":"node --test test/*.test.mjs"},"dependencies":{"better-auth":"1.7.7","@better-auth/memory-adapter":"1.7.7"},"devDependencies":{"esbuild":"0.25.12"},"engines":{"node":">=22"}}
@@ -0,0 +1,31 @@
1
+ import {test} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {makeApp} from '../app.mjs';
4
+ const p1={name:'normal1',uid:'1001',gid:'100'},p2={name:'normal2',uid:'1002',gid:'100'};
5
+ const state=()=>({secret:'fixture-secret-'.repeat(4),db:{user:[],session:[],account:[],verification:[]},bindings:{}});
6
+ const cookie=r=>r.cookies.map(v=>v.split(';')[0]).join('; ');
7
+ test('single-use native tickets bind Better Auth to a freshly authenticated principal',async()=>{
8
+ const store=state(),app=makeApp({packageID:'Alpha',store,ticket:'one',principal:p1});
9
+ const login=await app.handle('login','one');assert.equal(login.status,200);assert.equal(login.body.user,'normal1');
10
+ const repeated=await app.handle('login','one');assert.equal(repeated.status,401);
11
+ const session=cookie(login);assert.ok(session);
12
+ const fresh=makeApp({packageID:'Alpha',store,ticket:'two',principal:p1});
13
+ assert.equal((await fresh.handle('current','two',session)).status,200);
14
+ const crossed=makeApp({packageID:'Alpha',store,ticket:'three',principal:p2});
15
+ assert.equal((await crossed.handle('current','three',session)).body.error,'principal_mismatch');
16
+ });
17
+ test('store survives a CGI restart and refuses expired or foreign-package sessions',async()=>{
18
+ const store=state(),a=makeApp({packageID:'Alpha',store,ticket:'one',principal:p1});
19
+ const login=await a.handle('login','one'),session=cookie(login);
20
+ const restored=JSON.parse(JSON.stringify(store));
21
+ assert.equal((await makeApp({packageID:'Alpha',store:restored,ticket:'two',principal:p1}).handle('current','two',session)).status,200);
22
+ assert.equal((await makeApp({packageID:'Beta',store:restored,ticket:'three',principal:p1}).handle('current','three',session)).status,401);
23
+ restored.db.session[0].expiresAt=new Date(0).toISOString();
24
+ assert.equal((await makeApp({packageID:'Alpha',store:restored,ticket:'four',principal:p1}).handle('current','four',session)).status,401);
25
+ });
26
+ test('logout revokes app session and does not create a verified placeholder identity',async()=>{
27
+ const store=state();const login=await makeApp({packageID:'Alpha',store,ticket:'one',principal:p1}).handle('login','one');const session=cookie(login);
28
+ assert.equal(store.db.user[0].emailVerified,false);
29
+ assert.equal((await makeApp({packageID:'Alpha',store,ticket:'two',principal:p1}).handle('logout','two',session)).status,200);
30
+ assert.equal((await makeApp({packageID:'Alpha',store,ticket:'three',principal:p1}).handle('current','three',session)).status,401);
31
+ });
@@ -0,0 +1,6 @@
1
+ package app
2
+
3
+ import "dsmapp/internal/api"
4
+
5
+ // The Go-only integration has no Node process, dependency or identity route.
6
+ func (cfg Config) identityRoutes() []api.Route { return nil }
@@ -0,0 +1,203 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "errors"
9
+ "io"
10
+ "io/fs"
11
+ "os"
12
+ "os/user"
13
+ "syscall"
14
+ "unicode/utf8"
15
+
16
+ "dsmapp/internal/api"
17
+ "dsmapp/internal/helper"
18
+ )
19
+
20
+ // This example stores one synthetic note per authenticated local account in
21
+ // package-owned data. It is not a shared-folder grant or a NAS file gateway.
22
+ // The client may send an opaque ID, never a user name, UID or filesystem path.
23
+ var personalLookup = user.Lookup
24
+ var integrationHealth = func(ctx context.Context, cfg Config) helper.State {
25
+ return helper.NewClient(cfg.HelperInventory).Status(ctx)
26
+ }
27
+
28
+ func (cfg Config) integrationRoutes() []api.Route {
29
+ if cfg.HelperInventory.Protocol != 1 {
30
+ return nil
31
+ }
32
+ return append([]api.Route{api.User("personal.list", cfg.personalList), api.User("personal.read", cfg.personalRead), api.User("personal.write", cfg.personalWrite)}, cfg.identityRoutes()...)
33
+ }
34
+ func (cfg Config) personalID(ctx context.Context, caller api.Caller) (string, error) {
35
+ if !caller.Authenticated() {
36
+ return "", &api.Error{Code: "session_required", Message: "Sign in to DSM again"}
37
+ }
38
+ if integrationHealth(ctx, cfg).State != "ready" {
39
+ return "", &api.Error{Code: "integration_unavailable", Message: "Ask an administrator to activate or recover integration"}
40
+ }
41
+ // Re-resolve the authenticated name on every request. No browser-supplied
42
+ // identity is used; directory users and account lifecycle need M2 validation.
43
+ u, err := personalLookup(caller.User())
44
+ if err != nil || u.Username != caller.User() || u.Uid == "" || u.Gid == "" {
45
+ return "", &api.Error{Code: "identity_unavailable", Message: "Local account identity is unavailable"}
46
+ }
47
+ digest := sha256.Sum256([]byte(cfg.PackageID + "\x00" + caller.User() + "\x00" + u.Uid + "\x00" + u.Gid))
48
+ return hex.EncodeToString(digest[:]), nil
49
+ }
50
+ func (cfg Config) personalList(ctx context.Context, caller api.Caller, params json.RawMessage) (any, error) {
51
+ if err := decodeParams(params, &struct{}{}); err != nil {
52
+ return nil, err
53
+ }
54
+ id, err := cfg.personalID(ctx, caller)
55
+ if err != nil {
56
+ return nil, err
57
+ }
58
+ return map[string]any{"items": []map[string]string{{"id": id, "name": "My sample note"}}, "scope": "package-owned synthetic data"}, nil
59
+ }
60
+ func (cfg Config) personalRead(ctx context.Context, caller api.Caller, params json.RawMessage) (any, error) {
61
+ var in struct {
62
+ ID string `json:"id"`
63
+ }
64
+ if err := decodeParams(params, &in); err != nil {
65
+ return nil, err
66
+ }
67
+ id, err := cfg.personalID(ctx, caller)
68
+ if err != nil {
69
+ return nil, err
70
+ }
71
+ if in.ID != id {
72
+ return nil, personalDenied()
73
+ }
74
+ text, err := cfg.personalFile(id, nil)
75
+ if err != nil {
76
+ return nil, personalUnavailable()
77
+ }
78
+ if text == nil {
79
+ return map[string]string{"id": id, "text": "Private synthetic sample for " + caller.User()}, nil
80
+ }
81
+ return map[string]string{"id": id, "text": *text}, nil
82
+ }
83
+ func (cfg Config) personalWrite(ctx context.Context, caller api.Caller, params json.RawMessage) (any, error) {
84
+ var in struct {
85
+ ID string `json:"id"`
86
+ Text string `json:"text"`
87
+ }
88
+ if err := decodeParams(params, &in); err != nil {
89
+ return nil, err
90
+ }
91
+ if len(in.Text) > 4096 || !utf8.ValidString(in.Text) {
92
+ return nil, api.BadRequest("text must be valid UTF-8, at most 4096 bytes")
93
+ }
94
+ id, err := cfg.personalID(ctx, caller)
95
+ if err != nil {
96
+ return nil, err
97
+ }
98
+ if in.ID != id {
99
+ return nil, personalDenied()
100
+ }
101
+ if _, err := cfg.personalFile(id, &in.Text); err != nil {
102
+ return nil, personalUnavailable()
103
+ }
104
+ return map[string]string{"id": id, "text": in.Text}, nil
105
+ }
106
+ func personalDenied() error {
107
+ return &api.Error{Code: "access_denied", Message: "This sample belongs to another account"}
108
+ }
109
+ func personalUnavailable() error {
110
+ return &api.Error{Code: "personal_unavailable", Message: "Sample storage is unavailable; ask an administrator to check package data"}
111
+ }
112
+
113
+ // Root confines every lookup to package data; no paths come from the client.
114
+ // O_NOFOLLOW rejects final symlinks. Shared/exclusive flock covers reads/writes
115
+ // across separate CGI processes; a write is bounded and fsynced before success.
116
+ func (cfg Config) personalFile(id string, text *string) (*string, error) {
117
+ root, err := os.OpenRoot(cfg.DataDir)
118
+ if err != nil {
119
+ return nil, err
120
+ }
121
+ defer root.Close()
122
+ if err = root.Mkdir("personal", 0700); err != nil && !errors.Is(err, fs.ErrExist) {
123
+ return nil, err
124
+ }
125
+ info, err := root.Lstat("personal")
126
+ if err != nil || !info.IsDir() || info.Mode().Perm()&0077 != 0 {
127
+ return nil, errors.New("unsafe directory")
128
+ }
129
+ dir, err := root.OpenRoot("personal")
130
+ if err != nil {
131
+ return nil, err
132
+ }
133
+ defer dir.Close()
134
+ lock, err := dir.OpenFile(id+".lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW, 0600)
135
+ if err != nil {
136
+ return nil, err
137
+ }
138
+ defer lock.Close()
139
+ if err = personalRegular(lock); err != nil {
140
+ return nil, err
141
+ }
142
+ mode := syscall.LOCK_SH
143
+ if text != nil {
144
+ mode = syscall.LOCK_EX
145
+ }
146
+ if err = syscall.Flock(int(lock.Fd()), mode); err != nil {
147
+ return nil, err
148
+ }
149
+ defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
150
+ flags := os.O_RDONLY | syscall.O_NOFOLLOW
151
+ if text != nil {
152
+ flags = os.O_CREATE | os.O_RDWR | syscall.O_NOFOLLOW
153
+ }
154
+ file, err := dir.OpenFile(id+".json", flags, 0600)
155
+ if text == nil && errors.Is(err, fs.ErrNotExist) {
156
+ return nil, nil
157
+ }
158
+ if err != nil {
159
+ return nil, err
160
+ }
161
+ defer file.Close()
162
+ if err = personalRegular(file); err != nil {
163
+ return nil, err
164
+ }
165
+ if text != nil {
166
+ bytes, err := json.Marshal(map[string]string{"text": *text})
167
+ if err != nil {
168
+ return nil, err
169
+ }
170
+ if err = file.Truncate(0); err != nil {
171
+ return nil, err
172
+ }
173
+ if _, err = file.Write(bytes); err != nil {
174
+ return nil, err
175
+ }
176
+ if err = file.Sync(); err != nil {
177
+ return nil, err
178
+ }
179
+ return text, nil
180
+ }
181
+ bytes, err := io.ReadAll(io.LimitReader(file, 32769))
182
+ if err != nil || len(bytes) > 32768 {
183
+ return nil, errors.New("oversized note")
184
+ }
185
+ var note struct {
186
+ Text string `json:"text"`
187
+ }
188
+ if err = json.Unmarshal(bytes, &note); err != nil {
189
+ return nil, err
190
+ }
191
+ return &note.Text, nil
192
+ }
193
+ func personalRegular(f *os.File) error {
194
+ info, err := f.Stat()
195
+ if err != nil {
196
+ return err
197
+ }
198
+ st, ok := info.Sys().(*syscall.Stat_t)
199
+ if !ok || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || st.Uid != uint32(os.Getuid()) || st.Nlink != 1 {
200
+ return errors.New("unsafe file")
201
+ }
202
+ return nil
203
+ }
@@ -0,0 +1,119 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "dsmapp/internal/api"
6
+ "dsmapp/internal/helper"
7
+ "encoding/json"
8
+ "os"
9
+ "os/user"
10
+ "path/filepath"
11
+ "strings"
12
+ "testing"
13
+ )
14
+
15
+ func personalFixture(t *testing.T) Config {
16
+ oldLookup, oldHealth := personalLookup, integrationHealth
17
+ personalLookup = func(name string) (*user.User, error) {
18
+ return &user.User{Username: name, Uid: map[string]string{"normal1": "1001", "normal2": "1002", "alice": "1000"}[name], Gid: "100"}, nil
19
+ }
20
+ integrationHealth = func(context.Context, Config) helper.State { return helper.State{State: "ready"} }
21
+ t.Cleanup(func() { personalLookup, integrationHealth = oldLookup, oldHealth })
22
+ cfg := testConfig(t)
23
+ cfg.HelperInventory = helper.Inventory{Package: cfg.PackageID, Protocol: 1}
24
+ return cfg
25
+ }
26
+ func personalCall(t *testing.T, cfg Config, name, method, params string) (string, map[string]any) {
27
+ return call(t, cfg, stubAuth{identity: api.Identity{User: name}}, method, params)
28
+ }
29
+ func personalHandle(t *testing.T, cfg Config, name string) string {
30
+ code, out := personalCall(t, cfg, name, "personal.list", `{}`)
31
+ if code != "" {
32
+ t.Fatal(code, out)
33
+ }
34
+ return out["data"].(map[string]any)["items"].([]any)[0].(map[string]any)["id"].(string)
35
+ }
36
+ func TestPersonalUsersReadWriteAndMutuallyRefuse(t *testing.T) {
37
+ cfg := personalFixture(t)
38
+ first, second := personalHandle(t, cfg, "normal1"), personalHandle(t, cfg, "normal2")
39
+ if first == second {
40
+ t.Fatal("handles must bind caller")
41
+ }
42
+ for _, pair := range [][2]string{{"normal1", first}, {"normal2", second}} {
43
+ data, _ := json.Marshal(map[string]string{"id": pair[1], "text": "Private " + pair[0]})
44
+ code, out := personalCall(t, cfg, pair[0], "personal.write", string(data))
45
+ if code != "" {
46
+ t.Fatal(code, out)
47
+ }
48
+ code, out = personalCall(t, cfg, pair[0], "personal.read", `{"id":"`+pair[1]+`"}`)
49
+ if code != "" || out["data"].(map[string]any)["text"] != "Private "+pair[0] {
50
+ t.Fatal(code, out)
51
+ }
52
+ }
53
+ for _, pair := range [][2]string{{"normal1", second}, {"normal2", first}} {
54
+ for _, method := range []string{"personal.read", "personal.write"} {
55
+ params := `{"id":"` + pair[1] + `"`
56
+ if method == "personal.write" {
57
+ params += `,"text":"attack"`
58
+ }
59
+ params += `}`
60
+ code, _ := personalCall(t, cfg, pair[0], method, params)
61
+ if code != "access_denied" {
62
+ t.Fatal(method, code)
63
+ }
64
+ }
65
+ }
66
+ // Administrator privilege also gives no authority over another user's note.
67
+ if code, _ := call(t, cfg, alice, "personal.read", `{"id":"`+first+`"}`); code != "access_denied" {
68
+ t.Fatal(code)
69
+ }
70
+ }
71
+ func TestPersonalRejectsForgedIdentityAndPaths(t *testing.T) {
72
+ cfg := personalFixture(t)
73
+ id := personalHandle(t, cfg, "normal1")
74
+ for _, params := range []string{`{"user":"normal2"}`, `{"path":"../normal2"}`, `{"id":"` + id + `","user":"normal2"}`} {
75
+ if code, _ := personalCall(t, cfg, "normal1", "personal.list", params); code != "bad_request" {
76
+ t.Fatal(code)
77
+ }
78
+ }
79
+ for _, id := range []string{"../normal2", "/etc/passwd", "", "unknown"} {
80
+ if code, _ := personalCall(t, cfg, "normal1", "personal.read", `{"id":"`+id+`"}`); code != "access_denied" {
81
+ t.Fatal(code)
82
+ }
83
+ }
84
+ if code, _ := call(t, cfg, nobody, "personal.list", `{}`); code != "session_required" {
85
+ t.Fatal(code)
86
+ }
87
+ integrationHealth = func(context.Context, Config) helper.State { return helper.State{State: "stopped"} }
88
+ if code, _ := personalCall(t, cfg, "normal1", "personal.list", `{}`); code != "integration_unavailable" {
89
+ t.Fatal(code)
90
+ }
91
+ }
92
+ func TestPersonalRefusesSymlinkAndOversizedData(t *testing.T) {
93
+ cfg := personalFixture(t)
94
+ id := personalHandle(t, cfg, "normal1")
95
+ outside := filepath.Join(t.TempDir(), "victim")
96
+ os.WriteFile(outside, []byte("unchanged"), 0600)
97
+ os.Mkdir(filepath.Join(cfg.DataDir, "personal"), 0700)
98
+ if err := os.Symlink(outside, filepath.Join(cfg.DataDir, "personal", id+".json")); err != nil {
99
+ t.Fatal(err)
100
+ }
101
+ for _, method := range []string{"personal.read", "personal.write"} {
102
+ params := `{"id":"` + id + `"`
103
+ if method == "personal.write" {
104
+ params += `,"text":"attack"`
105
+ }
106
+ params += `}`
107
+ if code, _ := personalCall(t, cfg, "normal1", method, params); code != "personal_unavailable" {
108
+ t.Fatal(method, code)
109
+ }
110
+ }
111
+ b, _ := os.ReadFile(outside)
112
+ if string(b) != "unchanged" {
113
+ t.Fatal("escaped")
114
+ }
115
+ data, _ := json.Marshal(map[string]string{"id": id, "text": strings.Repeat("x", 4097)})
116
+ if code, _ := personalCall(t, cfg, "normal1", "personal.write", string(data)); code != "bad_request" {
117
+ t.Fatal(code)
118
+ }
119
+ }
@@ -0,0 +1,107 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "io"
8
+ "io/fs"
9
+ "os"
10
+ "path/filepath"
11
+ "regexp"
12
+ "syscall"
13
+
14
+ "dsmapp/internal/api"
15
+ )
16
+
17
+ // Root operations (root/<name>.sh) run as DSM Task Scheduler tasks that a DSM
18
+ // administrator approved in the package window. Each one writes its standard
19
+ // output to /run/synopkgland-<package>/<name>.out, a root-owned folder this
20
+ // package can read but not write. This package never runs anything as root
21
+ // itself; it only reads those results.
22
+
23
+ var rootOpName = regexp.MustCompile(`^[a-z][a-z0-9_]{0,31}$`)
24
+
25
+ const maxRootOutput = 64 << 10
26
+
27
+ // rootRunDir and rootOwner are variables only so tests can point them at a
28
+ // temporary folder owned by the test user.
29
+ var (
30
+ rootRunDir = "/run"
31
+ rootOwner = uint32(0)
32
+ )
33
+
34
+ func (cfg Config) rootRoutes() []api.Route {
35
+ return []api.Route{
36
+ api.Admin("root.output", cfg.rootOutput),
37
+ }
38
+ }
39
+
40
+ type rootResult struct {
41
+ Op string `json:"op"`
42
+ Output *string `json:"output"`
43
+ Modified int64 `json:"modified"`
44
+ Truncated bool `json:"truncated,omitempty"`
45
+ }
46
+
47
+ func (cfg Config) rootOutput(_ context.Context, _ api.Caller, params json.RawMessage) (any, error) {
48
+ var in struct {
49
+ Op string `json:"op"`
50
+ }
51
+ if err := decodeParams(params, &in); err != nil {
52
+ return nil, err
53
+ }
54
+ if !rootOpName.MatchString(in.Op) {
55
+ return nil, api.BadRequest("op must be the name of a root operation")
56
+ }
57
+ dir := filepath.Join(rootRunDir, "synopkgland-"+cfg.PackageID)
58
+ if err := requireRootOwned(dir, true); err != nil {
59
+ if errors.Is(err, fs.ErrNotExist) {
60
+ return rootResult{Op: in.Op}, nil
61
+ }
62
+ return nil, err
63
+ }
64
+ file, err := os.OpenFile(filepath.Join(dir, in.Op+".out"), os.O_RDONLY|syscall.O_NOFOLLOW, 0)
65
+ if errors.Is(err, fs.ErrNotExist) {
66
+ return rootResult{Op: in.Op}, nil
67
+ }
68
+ if err != nil {
69
+ return nil, err
70
+ }
71
+ defer file.Close()
72
+ info, err := file.Stat()
73
+ if err != nil {
74
+ return nil, err
75
+ }
76
+ if err := requireRootOwnedInfo(info, false); err != nil {
77
+ return nil, err
78
+ }
79
+ data, err := io.ReadAll(io.LimitReader(file, maxRootOutput+1))
80
+ if err != nil {
81
+ return nil, err
82
+ }
83
+ text := string(data[:min(len(data), maxRootOutput)])
84
+ return rootResult{Op: in.Op, Output: &text, Modified: info.ModTime().Unix(), Truncated: len(data) > maxRootOutput}, nil
85
+ }
86
+
87
+ // requireRootOwned refuses anything the package account, or anyone but root,
88
+ // could have put there: symlinks, other owners, group- or world-writable.
89
+ func requireRootOwned(path string, dir bool) error {
90
+ info, err := os.Lstat(path)
91
+ if err != nil {
92
+ return err
93
+ }
94
+ return requireRootOwnedInfo(info, dir)
95
+ }
96
+
97
+ func requireRootOwnedInfo(info fs.FileInfo, dir bool) error {
98
+ st, ok := info.Sys().(*syscall.Stat_t)
99
+ wantType := info.Mode().IsRegular()
100
+ if dir {
101
+ wantType = info.IsDir()
102
+ }
103
+ if !ok || !wantType || st.Uid != rootOwner || info.Mode().Perm()&0o022 != 0 {
104
+ return errors.New("root output is not a root-owned " + map[bool]string{true: "folder", false: "file"}[dir])
105
+ }
106
+ return nil
107
+ }
@@ -0,0 +1,124 @@
1
+ package app
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func init() { sampleParams["root.output"] = `{"op":"whoami"}` }
11
+
12
+ // rootFixture points the reader at a temporary run folder owned by the test
13
+ // user, standing in for root's /run/synopkgland-<package>.
14
+ func rootFixture(t *testing.T) (Config, string) {
15
+ t.Helper()
16
+ base := t.TempDir()
17
+ rootRunDir, rootOwner = base, uint32(os.Getuid())
18
+ t.Cleanup(func() { rootRunDir, rootOwner = "/run", 0 })
19
+ cfg := testConfig(t)
20
+ return cfg, filepath.Join(base, "synopkgland-"+cfg.PackageID)
21
+ }
22
+
23
+ func writeResult(t *testing.T, dir, name, text string) {
24
+ t.Helper()
25
+ if err := os.MkdirAll(dir, 0o755); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(text), 0o644); err != nil {
29
+ t.Fatal(err)
30
+ }
31
+ }
32
+
33
+ func TestRootOutputIsForAdministratorsOnly(t *testing.T) {
34
+ cfg, dir := rootFixture(t)
35
+ writeResult(t, dir, "whoami.out", `{"euid":0}`)
36
+ if code, _ := call(t, cfg, bob, "root.output", `{"op":"whoami"}`); code != "admin_required" {
37
+ t.Fatalf("non-admin: %q", code)
38
+ }
39
+ if code, _ := call(t, cfg, nobody, "root.output", `{"op":"whoami"}`); code != "session_required" {
40
+ t.Fatalf("no session: %q", code)
41
+ }
42
+ }
43
+
44
+ func TestRootOutputIsEmptyBeforeTheFirstRun(t *testing.T) {
45
+ cfg, _ := rootFixture(t)
46
+ code, out := call(t, cfg, alice, "root.output", `{"op":"whoami"}`)
47
+ data := out["data"].(map[string]any)
48
+ if code != "" || data["output"] != nil || data["modified"] != float64(0) {
49
+ t.Fatalf("%q %v", code, out)
50
+ }
51
+ }
52
+
53
+ func TestRootOutputReturnsTheLatestResult(t *testing.T) {
54
+ cfg, dir := rootFixture(t)
55
+ writeResult(t, dir, "whoami.out", "{\"euid\":0}\n")
56
+ code, out := call(t, cfg, alice, "root.output", `{"op":"whoami"}`)
57
+ data := out["data"].(map[string]any)
58
+ if code != "" || data["output"] != "{\"euid\":0}\n" || data["modified"].(float64) <= 0 || data["truncated"] != nil {
59
+ t.Fatalf("%q %v", code, out)
60
+ }
61
+ }
62
+
63
+ func TestRootOutputOnlyReadsOperationNames(t *testing.T) {
64
+ cfg, dir := rootFixture(t)
65
+ writeResult(t, dir, "whoami.out", "x")
66
+ for _, op := range []string{"", "../whoami", "Whoami", "who/ami", "whoami.out", strings.Repeat("a", 33)} {
67
+ if code, _ := call(t, cfg, alice, "root.output", `{"op":"`+op+`"}`); code != "bad_request" {
68
+ t.Errorf("op %q: %q", op, code)
69
+ }
70
+ }
71
+ }
72
+
73
+ func TestRootOutputRefusesAnythingTheRootTaskDidNotWrite(t *testing.T) {
74
+ cases := map[string]func(t *testing.T, dir string){
75
+ "symlinked result": func(t *testing.T, dir string) {
76
+ writeResult(t, dir, "elsewhere", "x")
77
+ if err := os.Symlink(filepath.Join(dir, "elsewhere"), filepath.Join(dir, "whoami.out")); err != nil {
78
+ t.Fatal(err)
79
+ }
80
+ },
81
+ "symlinked folder": func(t *testing.T, dir string) {
82
+ real := dir + "-real"
83
+ writeResult(t, real, "whoami.out", "x")
84
+ if err := os.Symlink(real, dir); err != nil {
85
+ t.Fatal(err)
86
+ }
87
+ },
88
+ "writable folder": func(t *testing.T, dir string) {
89
+ writeResult(t, dir, "whoami.out", "x")
90
+ if err := os.Chmod(dir, 0o777); err != nil {
91
+ t.Fatal(err)
92
+ }
93
+ },
94
+ "writable result": func(t *testing.T, dir string) {
95
+ writeResult(t, dir, "whoami.out", "x")
96
+ if err := os.Chmod(filepath.Join(dir, "whoami.out"), 0o666); err != nil {
97
+ t.Fatal(err)
98
+ }
99
+ },
100
+ "another owner": func(t *testing.T, dir string) {
101
+ writeResult(t, dir, "whoami.out", "x")
102
+ rootOwner = uint32(os.Getuid()) + 1
103
+ },
104
+ }
105
+ for name, arrange := range cases {
106
+ t.Run(name, func(t *testing.T) {
107
+ cfg, dir := rootFixture(t)
108
+ arrange(t, dir)
109
+ if code, out := call(t, cfg, alice, "root.output", `{"op":"whoami"}`); code != "internal" {
110
+ t.Fatalf("%q %v", code, out)
111
+ }
112
+ })
113
+ }
114
+ }
115
+
116
+ func TestRootOutputIsBounded(t *testing.T) {
117
+ cfg, dir := rootFixture(t)
118
+ writeResult(t, dir, "whoami.out", strings.Repeat("x", maxRootOutput+10))
119
+ _, out := call(t, cfg, alice, "root.output", `{"op":"whoami"}`)
120
+ data := out["data"].(map[string]any)
121
+ if len(data["output"].(string)) != maxRootOutput || data["truncated"] != true {
122
+ t.Fatalf("not bounded: %d %v", len(data["output"].(string)), data["truncated"])
123
+ }
124
+ }
@@ -0,0 +1,6 @@
1
+ #!/bin/sh
2
+ # A root operation: runs as root only when a DSM administrator clicks Run in
3
+ # the package window, after approving this exact text with their password.
4
+ # Its standard output is the result the window shows. Keep root operations
5
+ # small and fixed; every change needs the administrator's approval again.
6
+ printf '{"euid":%s}\n' "$(id -u)"