@synopackageland/cli 0.7.0 → 0.7.1

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 (126) hide show
  1. package/CHANGELOG.md +234 -1
  2. package/dist/add.d.ts +4 -1
  3. package/dist/add.js +14 -9
  4. package/dist/build-spk/dsm-platforms.json +4 -1
  5. package/dist/build-spk/gateway.d.ts +1 -1
  6. package/dist/build-spk/gateway.js +8 -4
  7. package/dist/build-spk/go-toolchain.d.ts +20 -0
  8. package/dist/build-spk/go-toolchain.js +44 -0
  9. package/dist/cli.js +13 -7
  10. package/dist/deploy/native-install-values.d.ts +17 -0
  11. package/dist/deploy/native-install-values.js +32 -0
  12. package/dist/deploy/plan.d.ts +6 -2
  13. package/dist/deploy/plan.js +57 -6
  14. package/dist/error-registry.js +12 -6
  15. package/dist/host/dsm-client.js +53 -17
  16. package/dist/host/dsm-http.js +24 -2
  17. package/dist/host/types.d.ts +1 -0
  18. package/dist/info.js +28 -9
  19. package/dist/init.js +1 -1
  20. package/dist/runtime/dsm-version.d.ts +6 -0
  21. package/dist/runtime/dsm-version.js +8 -0
  22. package/dist/runtime/executor-build.d.ts +1 -1
  23. package/dist/runtime/executor-build.js +8 -5
  24. package/dist/standalone/daemon.d.ts +2 -1
  25. package/dist/standalone/daemon.js +10 -6
  26. package/dist/standalone/dsm-window.js +6 -34
  27. package/dist/standalone/inspect-spk.js +5 -1
  28. package/dist/standalone/native-authorization-window.d.ts +1 -1
  29. package/dist/standalone/native-authorization-window.js +7 -1
  30. package/dist/standalone/native-root-window.d.ts +4 -2
  31. package/dist/standalone/native-root-window.js +33 -18
  32. package/dist/standalone/root-window.d.ts +4 -1
  33. package/dist/standalone/root-window.js +143 -33
  34. package/dist/templates/_daemon/go.mod +1 -1
  35. package/dist/templates/_daemon/main.go +6 -3
  36. package/dist/templates/_daemon/main_test.go +21 -1
  37. package/dist/templates/_daemon/testdata/child.go +3 -0
  38. package/dist/templates/_executor/README.md +4 -1
  39. package/dist/templates/_executor/anchor.go +10 -0
  40. package/dist/templates/_executor/anchor_test.go +40 -5
  41. package/dist/templates/_executor/digest_review_test.go +2 -2
  42. package/dist/templates/_executor/dsm.go +40 -3
  43. package/dist/templates/_executor/go.mod +2 -0
  44. package/dist/templates/_executor/main.go +11 -5
  45. package/dist/templates/_executor/managed.go +3 -3
  46. package/dist/templates/_executor/managed_test.go +3 -3
  47. package/dist/templates/_executor/path_test.go +18 -0
  48. package/dist/templates/_executor/pid.go +37 -0
  49. package/dist/templates/_executor/pid_test.go +113 -0
  50. package/dist/templates/_executor/platform_other.go +8 -0
  51. package/dist/templates/_executor/purge_log.go +18 -0
  52. package/dist/templates/_executor/tasks.go +45 -7
  53. package/dist/templates/_executor/tasks_dsm71_test.go +114 -0
  54. package/dist/templates/_executor/tasks_test.go +58 -1
  55. package/dist/templates/_executor/upgrade_watch_test.go +13 -5
  56. package/dist/templates/_gateway/go.mod +2 -0
  57. package/dist/templates/native-go/backend/go.mod +1 -1
  58. package/dist/templates/native-go/backend/internal/filestation/client.go +39 -3
  59. package/dist/templates/native-go/backend/internal/filestation/download_dsm71_test.go +82 -0
  60. package/dist/templates/native-go/backend/internal/identity/README.md +5 -1
  61. package/dist/templates/native-go/backend/internal/identity/dsm.go +24 -5
  62. package/dist/templates/native-go/backend/internal/identity/http.go +1 -1
  63. package/dist/templates/native-go/backend/internal/identity/identity_test.go +48 -1
  64. package/dist/templates/native-go/backend/internal/identity/service.go +1 -1
  65. package/dist/templates/native-go-delegate/frontend/test/delegate.test.ts +15 -0
  66. package/dist/templates/native-go-root-ops/backend/internal/app/executor.go +30 -2
  67. package/dist/templates/native-go-root-ops/backend/internal/app/executor_test.go +64 -0
  68. package/dist/templates/native-go-vue/frontend/src/delegate/ExecutorSettings.vue +3 -2
  69. package/dist/templates/native-go-vue/frontend/src/delegate/executor-settings.test.ts +23 -0
  70. package/dist/templates/native-go-vue/frontend/src/delegate/executor-setup.ts +5 -1
  71. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/de.json +1 -0
  72. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/en.json +1 -0
  73. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/es.json +1 -0
  74. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/fr.json +1 -0
  75. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/it.json +1 -0
  76. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/ja.json +1 -0
  77. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/ko.json +1 -0
  78. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/zh-CN.json +1 -0
  79. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/zh-TW.json +1 -0
  80. package/package.json +1 -1
  81. package/templates/_daemon/go.mod +1 -1
  82. package/templates/_daemon/main.go +6 -3
  83. package/templates/_daemon/main_test.go +21 -1
  84. package/templates/_daemon/testdata/child.go +3 -0
  85. package/templates/_executor/README.md +4 -1
  86. package/templates/_executor/anchor.go +10 -0
  87. package/templates/_executor/anchor_test.go +40 -5
  88. package/templates/_executor/digest_review_test.go +2 -2
  89. package/templates/_executor/dsm.go +40 -3
  90. package/templates/_executor/go.mod +2 -0
  91. package/templates/_executor/main.go +11 -5
  92. package/templates/_executor/managed.go +3 -3
  93. package/templates/_executor/managed_test.go +3 -3
  94. package/templates/_executor/path_test.go +18 -0
  95. package/templates/_executor/pid.go +37 -0
  96. package/templates/_executor/pid_test.go +113 -0
  97. package/templates/_executor/platform_other.go +8 -0
  98. package/templates/_executor/purge_log.go +18 -0
  99. package/templates/_executor/tasks.go +45 -7
  100. package/templates/_executor/tasks_dsm71_test.go +114 -0
  101. package/templates/_executor/tasks_test.go +58 -1
  102. package/templates/_executor/upgrade_watch_test.go +13 -5
  103. package/templates/_gateway/go.mod +2 -0
  104. package/templates/native-go/backend/go.mod +1 -1
  105. package/templates/native-go/backend/internal/filestation/client.go +39 -3
  106. package/templates/native-go/backend/internal/filestation/download_dsm71_test.go +82 -0
  107. package/templates/native-go/backend/internal/identity/README.md +5 -1
  108. package/templates/native-go/backend/internal/identity/dsm.go +24 -5
  109. package/templates/native-go/backend/internal/identity/http.go +1 -1
  110. package/templates/native-go/backend/internal/identity/identity_test.go +48 -1
  111. package/templates/native-go/backend/internal/identity/service.go +1 -1
  112. package/templates/native-go-delegate/frontend/test/delegate.test.ts +15 -0
  113. package/templates/native-go-root-ops/backend/internal/app/executor.go +30 -2
  114. package/templates/native-go-root-ops/backend/internal/app/executor_test.go +64 -0
  115. package/templates/native-go-vue/frontend/src/delegate/ExecutorSettings.vue +3 -2
  116. package/templates/native-go-vue/frontend/src/delegate/executor-settings.test.ts +23 -0
  117. package/templates/native-go-vue/frontend/src/delegate/executor-setup.ts +5 -1
  118. package/templates/native-go-vue/frontend/src/delegate/i18n/de.json +1 -0
  119. package/templates/native-go-vue/frontend/src/delegate/i18n/en.json +1 -0
  120. package/templates/native-go-vue/frontend/src/delegate/i18n/es.json +1 -0
  121. package/templates/native-go-vue/frontend/src/delegate/i18n/fr.json +1 -0
  122. package/templates/native-go-vue/frontend/src/delegate/i18n/it.json +1 -0
  123. package/templates/native-go-vue/frontend/src/delegate/i18n/ja.json +1 -0
  124. package/templates/native-go-vue/frontend/src/delegate/i18n/ko.json +1 -0
  125. package/templates/native-go-vue/frontend/src/delegate/i18n/zh-CN.json +1 -0
  126. package/templates/native-go-vue/frontend/src/delegate/i18n/zh-TW.json +1 -0
@@ -1,8 +1,10 @@
1
1
  package app
2
2
 
3
3
  import (
4
+ "net"
4
5
  "os"
5
6
  "path/filepath"
7
+ "strings"
6
8
  "testing"
7
9
  )
8
10
 
@@ -51,3 +53,65 @@ func TestExecutorRootRunRequiresCurrentDSMCookie(t *testing.T) {
51
53
  t.Fatal("root.run accepted absent DSM cookie")
52
54
  }
53
55
  }
56
+
57
+ // The executor closes its listener while paused (package stopped, or pins
58
+ // awaiting administrator approval). Status then answers, not "internal".
59
+ func TestExecutorStatusReportsAPausedExecutor(t *testing.T) {
60
+ cfg := testConfig(t)
61
+ previous := executorSocketBase
62
+ // A short base: unix socket paths are limited to about 100 bytes.
63
+ base, err := os.MkdirTemp("/tmp", "exec")
64
+ if err != nil {
65
+ t.Fatal(err)
66
+ }
67
+ executorSocketBase = base
68
+ defer func() { executorSocketBase = previous; os.RemoveAll(base) }()
69
+ check := func(name string) {
70
+ t.Helper()
71
+ code, envelope := call(t, cfg, alice, "executor.status", `{}`)
72
+ out, _ := envelope["data"].(map[string]any)
73
+ if code != "" || out["ready"] != false || out["state"] != "paused" || out["reason"] != "executor_not_listening" || out["next"] == "" || out["next"] == nil {
74
+ t.Fatalf("%s: %s %v", name, code, out)
75
+ }
76
+ }
77
+ check("no socket")
78
+ if code, _ := call(t, cfg, bob, "executor.status", `{}`); code != "" {
79
+ t.Fatal("authenticated user cannot read a paused status", code)
80
+ }
81
+ dir := filepath.Join(executorSocketBase, cfg.PackageID)
82
+ os.MkdirAll(dir, 0700)
83
+ ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: filepath.Join(dir, "executor.sock"), Net: "unix"})
84
+ if err != nil {
85
+ t.Fatal(err)
86
+ }
87
+ ln.SetUnlinkOnClose(false)
88
+ ln.Close()
89
+ check("closed listener")
90
+ }
91
+
92
+ // next follows the last lifecycle request: a running package needs approval
93
+ // again, not a start.
94
+ func TestExecutorStatusNextFollowsTheLifecycleRequest(t *testing.T) {
95
+ cfg := testConfig(t)
96
+ previous := executorSocketBase
97
+ executorSocketBase = t.TempDir()
98
+ defer func() { executorSocketBase = previous }()
99
+ next := func() string {
100
+ t.Helper()
101
+ _, envelope := call(t, cfg, alice, "executor.status", `{}`)
102
+ out, _ := envelope["data"].(map[string]any)
103
+ s, _ := out["next"].(string)
104
+ return s
105
+ }
106
+ if !strings.HasPrefix(next(), "Start the package.") {
107
+ t.Fatal("no request:", next())
108
+ }
109
+ mailbox := filepath.Join(cfg.DataDir, "executor")
110
+ os.MkdirAll(mailbox, 0700)
111
+ for request, start := range map[string]bool{"stop\n": true, "start\n": false, "start": false} {
112
+ os.WriteFile(filepath.Join(mailbox, "control"), []byte(request), 0600)
113
+ if got := next(); strings.HasPrefix(got, "Start the package") != start || got == "" {
114
+ t.Fatalf("%q: %s", request, got)
115
+ }
116
+ }
117
+ }
@@ -47,16 +47,17 @@ async function authorize(){
47
47
  catch{if(!disposed)error.value=true}
48
48
  finally{if(!disposed)busy.value=false}
49
49
  }
50
- onMounted(()=>void refresh());onBeforeUnmount(()=>{disposed=true;password.value=''})
50
+ onMounted(()=>{setup.subscribe?.(next=>{if(!disposed)status.value=next});void refresh()});onBeforeUnmount(()=>{disposed=true;password.value=''})
51
51
  </script>
52
52
  <template>
53
53
  <SettingsSection v-if="admin" :title="copy.setupTitle" :collapsible="false" :aria-busy="busy">
54
54
  <p>{{ copy.setupHelp }}</p>
55
55
  <Callout v-if="error" tone="danger" role="alert">{{ copy.setupError }}</Callout>
56
56
  <p role="status">{{ status?.state==='ready'?(needsScope?copy.scopeNeeds:copy.ready):status?.state==='needs-admin-approval'?copy.needs:copy.blocked }}</p>
57
+ <p v-if="status?.reason&&(status.state==='blocked'||status.state==='unsupported')">{{ copy.details }}: {{ status.reason }}</p>
57
58
  <p v-if="scope?.selection">{{ copy.protectedScope }}: {{ scope.selection.root }}</p>
58
59
  <Callout v-if="scope?.mismatch" tone="warning">{{ copy.scopeMismatch }} {{ copy.preselection }}: {{ scope.preselection }} · {{ copy.protectedScope }}: {{ scope.selection?.root }}</Callout>
59
- <Form v-if="status?.state==='needs-admin-approval'||dynamic&&status?.state==='ready'" @submit.prevent="authorize">
60
+ <Form v-if="status?.state==='needs-admin-approval'||status?.retry||dynamic&&status?.state==='ready'" @submit.prevent="authorize">
60
61
  <template v-if="dynamic">
61
62
  <p>{{ scope?.selection?copy.scopeChangeHelp:copy.preselectionHelp }}</p>
62
63
  <FormItem :label="copy.scopeLabel" label-for="delegate-scope"><Select id="delegate-scope" v-model="chosen" :options="options" :disabled="busy||error" /></FormItem>
@@ -0,0 +1,23 @@
1
+ // @vitest-environment jsdom
2
+ import {expect,test} from 'vitest'
3
+ import {createApp,h,nextTick} from 'vue'
4
+ import {AppRoot} from '@synopackageland/ui'
5
+ import ExecutorSettings from './ExecutorSettings.vue'
6
+ import {ExecutorSetupKey,type CapabilityStatus} from './executor-setup'
7
+ const settle=async()=>{for(let i=0;i<10;i++){await nextTick();await new Promise(r=>setTimeout(r))}}
8
+ // t-0237: a wrong password left only "Check status"; a retryable result keeps the form.
9
+ test('a retryable blocked approval keeps the password form for another try',async()=>{
10
+ const blocked:CapabilityStatus&{retry:boolean}={state:'blocked',reason:'dsm_3121: DSM rejected the administrator password. Nothing was created.',retry:true}
11
+ const setup={async status():Promise<CapabilityStatus>{return {state:'needs-admin-approval'}},async authorize(){return blocked},async control(){return blocked}}
12
+ const root=document.createElement('div');document.body.append(root)
13
+ const app=createApp({render:()=>h(AppRoot,{},()=>h(ExecutorSettings,{admin:true,locale:'en'}))})
14
+ app.provide(ExecutorSetupKey,setup)
15
+ try{
16
+ app.mount(root);await settle()
17
+ const input=root.querySelector<HTMLInputElement>('#delegate-password')!
18
+ input.value='wrong';input.dispatchEvent(new Event('input'));await settle()
19
+ root.querySelector('form')!.dispatchEvent(new Event('submit'));await settle()
20
+ expect(root.textContent).toContain('dsm_3121')
21
+ expect(root.querySelector('#delegate-password')).not.toBeNull()
22
+ }finally{app.unmount();root.remove()}
23
+ })
@@ -2,13 +2,17 @@ import {inject,type InjectionKey} from 'vue'
2
2
  export type CapabilityState='unsupported'|'needs-admin-approval'|'ready'|'blocked'|'incompatible'
3
3
  // requested: a scope change whose root task may still land; it applied once
4
4
  // status() reports that scope with a generation above this one.
5
- export interface CapabilityStatus {state:CapabilityState;reason?:string;requested?:{scope:string;generation:number}}
5
+ // retry: approving again may succeed: nothing of this approval is left behind, or
6
+ // the approved tasks remain and approving again restarts the executor.
7
+ export interface CapabilityStatus {state:CapabilityState;reason?:string;requested?:{scope:string;generation:number};retry?:boolean}
6
8
  // p2-setup supplies the implementation through app.provide(ExecutorSetupKey, setup).
7
9
  // Passwords belong only to this call and must never be persisted or logged.
8
10
  export interface ExecutorSetup {
9
11
  status():Promise<CapabilityStatus>
10
12
  authorize(password:string,selectedScope?:string):Promise<CapabilityStatus>
11
13
  control(action:'start'|'stop'|'purge'):Promise<CapabilityStatus>
14
+ // Results of calls made elsewhere (the DSM window panel) on the same access.
15
+ subscribe?(listener:(status:CapabilityStatus)=>void):void
12
16
  }
13
17
  export const ExecutorSetupKey:InjectionKey<ExecutorSetup>=Symbol('executor-setup')
14
18
  export function useExecutorSetup():ExecutorSetup {
@@ -10,6 +10,7 @@
10
10
  "ready": "Der Dateizugriff im Hintergrund ist bereit.",
11
11
  "needs": "Ein Administrator muss den Dateizugriff im Hintergrund genehmigen.",
12
12
  "blocked": "Der Dateizugriff im Hintergrund ist nicht verfügbar. Bitten Sie einen Administrator, die App-Einrichtung zu prüfen.",
13
+ "details": "Details",
13
14
  "refresh": "Aktualisieren",
14
15
  "revoke": "Widerrufen",
15
16
  "cancel": "Auftrag abbrechen",
@@ -10,6 +10,7 @@
10
10
  "ready": "Background file access is ready.",
11
11
  "needs": "An administrator must approve background file access.",
12
12
  "blocked": "Background file access is unavailable. Ask an administrator to check the app setup.",
13
+ "details": "Details",
13
14
  "refresh": "Refresh",
14
15
  "revoke": "Revoke",
15
16
  "cancel": "Cancel job",
@@ -10,6 +10,7 @@
10
10
  "ready": "El acceso a archivos en segundo plano está listo.",
11
11
  "needs": "Un administrador debe aprobar el acceso a archivos en segundo plano.",
12
12
  "blocked": "El acceso a archivos en segundo plano no está disponible. Pida a un administrador que revise la configuración de la aplicación.",
13
+ "details": "Detalles",
13
14
  "refresh": "Actualizar",
14
15
  "revoke": "Revocar",
15
16
  "cancel": "Cancelar tarea",
@@ -10,6 +10,7 @@
10
10
  "ready": "L’accès aux fichiers en arrière-plan est prêt.",
11
11
  "needs": "Un administrateur doit approuver l’accès aux fichiers en arrière-plan.",
12
12
  "blocked": "L’accès aux fichiers en arrière-plan n’est pas disponible. Demandez à un administrateur de vérifier la configuration de l’application.",
13
+ "details": "Détails",
13
14
  "refresh": "Actualiser",
14
15
  "revoke": "Révoquer",
15
16
  "cancel": "Annuler la tâche",
@@ -10,6 +10,7 @@
10
10
  "ready": "L’accesso ai file in background è pronto.",
11
11
  "needs": "Un amministratore deve approvare l’accesso ai file in background.",
12
12
  "blocked": "L’accesso ai file in background non è disponibile. Chiedere a un amministratore di controllare la configurazione dell’app.",
13
+ "details": "Dettagli",
13
14
  "refresh": "Aggiorna",
14
15
  "revoke": "Revoca",
15
16
  "cancel": "Annulla processo",
@@ -10,6 +10,7 @@
10
10
  "ready": "バックグラウンドのファイルアクセスを利用できます。",
11
11
  "needs": "バックグラウンドのファイルアクセスには管理者の承認が必要です。",
12
12
  "blocked": "バックグラウンドのファイルアクセスを利用できません。アプリの設定を確認するよう管理者に依頼してください。",
13
+ "details": "詳細",
13
14
  "refresh": "最新の情報に更新",
14
15
  "revoke": "取り消す",
15
16
  "cancel": "ジョブをキャンセル",
@@ -10,6 +10,7 @@
10
10
  "ready": "백그라운드 파일 액세스를 사용할 수 있습니다.",
11
11
  "needs": "백그라운드 파일 액세스는 관리자의 승인이 필요합니다.",
12
12
  "blocked": "백그라운드 파일 액세스를 사용할 수 없습니다. 관리자에게 앱 설정을 확인하도록 요청하십시오.",
13
+ "details": "세부 사항",
13
14
  "refresh": "새로 고침",
14
15
  "revoke": "취소",
15
16
  "cancel": "작업 취소",
@@ -10,6 +10,7 @@
10
10
  "ready": "后台文件访问已就绪。",
11
11
  "needs": "需要管理员批准后台文件访问。",
12
12
  "blocked": "后台文件访问不可用,请管理员检查 App 设置。",
13
+ "details": "详情",
13
14
  "refresh": "刷新",
14
15
  "revoke": "撤销",
15
16
  "cancel": "取消任务",
@@ -10,6 +10,7 @@
10
10
  "ready": "背景檔案存取已就緒。",
11
11
  "needs": "需要管理員核准背景檔案存取。",
12
12
  "blocked": "背景檔案存取無法使用,請管理員檢查 App 設定。",
13
+ "details": "詳情",
13
14
  "refresh": "重新整理",
14
15
  "revoke": "撤銷",
15
16
  "cancel": "取消工作",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,4 +2,4 @@ module synopkgland-daemon
2
2
 
3
3
  go 1.24
4
4
 
5
- toolchain go1.26.8
5
+ toolchain go1.26.9
@@ -28,6 +28,9 @@ type deadlines struct {
28
28
  Start int `json:"start"`
29
29
  Stop int `json:"stop"`
30
30
  Status int `json:"status"`
31
+ // Uninstall bounds uninstall_args separately from Stop: a revoke on a
32
+ // loaded NAS can take longer than stopping the daemon.
33
+ Uninstall int `json:"uninstall"`
31
34
  }
32
35
  type config struct {
33
36
  Package string `json:"package"`
@@ -88,7 +91,7 @@ func load(dest, data string) (*controller, error) {
88
91
  if cfg.Exec == "" || filepath.IsAbs(cfg.Exec) || filepath.Clean(cfg.Exec) != cfg.Exec || strings.HasPrefix(cfg.Exec, "../") {
89
92
  return nil, errors.New("invalid fixed executable")
90
93
  }
91
- if cfg.Timeouts.Start < 2 || cfg.Timeouts.Start > 120 || cfg.Timeouts.Stop < 2 || cfg.Timeouts.Stop > 120 || cfg.Timeouts.Status < 1 || cfg.Timeouts.Status > 10 {
94
+ if cfg.Timeouts.Start < 2 || cfg.Timeouts.Start > 120 || cfg.Timeouts.Stop < 2 || cfg.Timeouts.Stop > 120 || cfg.Timeouts.Status < 1 || cfg.Timeouts.Status > 10 || cfg.Timeouts.Uninstall < 2 || cfg.Timeouts.Uninstall > 120 {
92
95
  return nil, errors.New("invalid daemon deadlines")
93
96
  }
94
97
  hash := sha256.Sum256(raw)
@@ -162,7 +165,7 @@ func (c *controller) run(action string) (int, error) {
162
165
  budget += c.Timeouts.Start
163
166
  }
164
167
  if action == "uninstall" {
165
- budget += c.Timeouts.Stop + 1
168
+ budget += c.Timeouts.Uninstall + 2
166
169
  }
167
170
  if action != "start" && action != "stop" && action != "status" && action != "restart" && action != "uninstall" {
168
171
  return 1, errors.New("unsupported daemon action")
@@ -414,7 +417,7 @@ func (c *controller) uninstall() error {
414
417
  select {
415
418
  case err := <-done:
416
419
  return err
417
- case <-time.After(time.Duration(c.Timeouts.Stop-1) * time.Second):
420
+ case <-time.After(time.Duration(c.Timeouts.Uninstall) * time.Second):
418
421
  cmd.Process.Signal(syscall.SIGTERM)
419
422
  }
420
423
  select {
@@ -50,7 +50,7 @@ func fixture(t *testing.T, mode string) (string, string, string) {
50
50
  if raw, err := exec.Command(app, "cleanup").CombinedOutput(); err != nil {
51
51
  t.Fatalf("payload warm-up: %v %s", err, raw)
52
52
  }
53
- cfg := config{Package: "DaemonFixture", Exec: "app", Args: []string{"${SYNOPKG_PKGVAR}/a space", "$(touch BAD)'", "${SYNOPKG_PKGDEST}"}, Env: map[string]string{"APP_MODE": mode}, Timeouts: deadlines{Start: 2, Stop: 2, Status: 1}}
53
+ cfg := config{Package: "DaemonFixture", Exec: "app", Args: []string{"${SYNOPKG_PKGVAR}/a space", "$(touch BAD)'", "${SYNOPKG_PKGDEST}"}, Env: map[string]string{"APP_MODE": mode}, Timeouts: deadlines{Start: 2, Stop: 2, Status: 1, Uninstall: 2}}
54
54
  if os.Geteuid() == 0 {
55
55
  cfg.Package = "nobody"
56
56
  } else {
@@ -250,6 +250,26 @@ func TestUninstallCleanup(t *testing.T) {
250
250
  }
251
251
  }
252
252
 
253
+ // A revoke on uninstall can outlast the stop deadline on a loaded NAS; it has
254
+ // its own budget instead of Stop-1.
255
+ func TestUninstallCommandHasItsOwnBudget(t *testing.T) {
256
+ control, dest, data := fixture(t, "normal")
257
+ path := filepath.Join(dest, "daemon/config.json")
258
+ raw, _ := os.ReadFile(path)
259
+ var cfg config
260
+ json.Unmarshal(raw, &cfg)
261
+ cfg.UninstallArgs = []string{"cleanup-slow"}
262
+ cfg.Timeouts.Uninstall = 5
263
+ raw, _ = json.Marshal(cfg)
264
+ os.WriteFile(path, raw, 0644)
265
+ if rc, out := runControl(control, "start", dest, data); rc != 0 {
266
+ t.Fatalf("start: %d %s", rc, out)
267
+ }
268
+ if rc, out := runControl(control, "uninstall", dest, data); rc != 0 {
269
+ t.Fatalf("slow uninstall within its budget failed: %d %s", rc, out)
270
+ }
271
+ }
272
+
253
273
  func TestPackageAccountIdentity(t *testing.T) {
254
274
  control, dest, data := fixture(t, "normal")
255
275
  path := filepath.Join(dest, "daemon/config.json")
@@ -14,6 +14,9 @@ func main() {
14
14
  switch os.Args[1] {
15
15
  case "cleanup":
16
16
  os.Exit(0)
17
+ case "cleanup-slow":
18
+ time.Sleep(2500 * time.Millisecond)
19
+ os.Exit(0)
17
20
  case "cleanup-fail":
18
21
  os.Exit(2)
19
22
  case "cleanup-hang":
@@ -174,7 +174,10 @@ root-owned `/var/packages/<package>/INFO`, the root monitor copies it into
174
174
  `runtime/`, verifies the copy and runs root `control start`, which replaces the
175
175
  monitor without reapproval. Any other change stays paused for administrator
176
176
  approval. Only root's pinned boot/control path or this anchored install can
177
- replace a monitor or runtime.
177
+ replace a monitor or runtime. To exercise this path on a NAS without a real
178
+ executor change, the repository's `scripts/test/executor-bump.py` makes a
179
+ build+1 SPK whose executor differs by one byte and rewrites the INFO anchor,
180
+ `executor/setup.json` and the window script's embedded pins together.
178
181
 
179
182
  At start, scheduler readback must exactly match the canonical lifecycle scripts
180
183
  regenerated from the package identity and approved policy pin. Package
@@ -84,6 +84,11 @@ func installAnchored(ctx context.Context, source, info string, trusted func(os.F
84
84
  if err != nil {
85
85
  return err
86
86
  }
87
+ // DSM links the package target to the volume; openRegular refuses links.
88
+ source, err = filepath.EvalSymlinks(source)
89
+ if err != nil {
90
+ return err
91
+ }
87
92
  src, err := openRegular(filepath.Join(source, "executor"))
88
93
  if err != nil {
89
94
  return err
@@ -129,7 +134,12 @@ func startInstalled(runtime, pkg string) error {
129
134
  return cmd.Process.Release()
130
135
  }
131
136
 
137
+ // An anchored upgrade is handed to root start, which replaces this monitor:
138
+ // only differing pins are an approval request, and only they say so.
132
139
  func approvedPins(state pinState, install, start func() error, log func(string)) bool {
140
+ if state == pinsDiffer {
141
+ log("executor paused: package pins require administrator approval")
142
+ }
133
143
  if state != pinsAnchored {
134
144
  return state == pinsMatch
135
145
  }
@@ -5,6 +5,7 @@ import (
5
5
  "errors"
6
6
  "os"
7
7
  "path/filepath"
8
+ "reflect"
8
9
  "strings"
9
10
  "testing"
10
11
  )
@@ -101,6 +102,8 @@ func TestInstallAnchoredReplacesOnlyTheProtectedExecutorWithVerifiedBytes(t *tes
101
102
  }
102
103
  }
103
104
 
105
+ // The pause message names administrator approval only when the pins differ:
106
+ // an anchored handover is a finished upgrade, not a request for approval.
104
107
  func TestApprovedPinsHandOverAnAnchoredUpgradeToRootStart(t *testing.T) {
105
108
  for _, tc := range []struct {
106
109
  name string
@@ -109,18 +112,50 @@ func TestApprovedPinsHandOverAnAnchoredUpgradeToRootStart(t *testing.T) {
109
112
  open bool
110
113
  installs int
111
114
  starts int
115
+ logged string
112
116
  }{
113
- {"match", pinsMatch, nil, true, 0, 0},
114
- {"differ", pinsDiffer, nil, false, 0, 0},
115
- {"anchored", pinsAnchored, nil, false, 1, 1},
116
- {"install failed", pinsAnchored, errors.New("digest"), false, 1, 0},
117
+ {"match", pinsMatch, nil, true, 0, 0, ""},
118
+ {"differ", pinsDiffer, nil, false, 0, 0, "executor paused: package pins require administrator approval"},
119
+ {"anchored", pinsAnchored, nil, false, 1, 1, "executor upgrade: INFO-anchored executor installed under the approved policy"},
120
+ {"install failed", pinsAnchored, errors.New("digest"), false, 1, 0, "executor upgrade refused: INFO-anchored executor could not be installed"},
117
121
  } {
118
122
  t.Run(tc.name, func(t *testing.T) {
119
123
  installs, starts := 0, 0
120
- open := approvedPins(tc.state, func() error { installs++; return tc.installErr }, func() error { starts++; return nil }, func(string) {})
124
+ var logged []string
125
+ open := approvedPins(tc.state, func() error { installs++; return tc.installErr }, func() error { starts++; return nil }, func(s string) { logged = append(logged, s) })
121
126
  if open != tc.open || installs != tc.installs || starts != tc.starts {
122
127
  t.Fatal("handover", open, installs, starts)
123
128
  }
129
+ if want := []string{tc.logged}; tc.logged == "" {
130
+ if len(logged) != 0 {
131
+ t.Fatal("matching pins logged", logged)
132
+ }
133
+ } else if !reflect.DeepEqual(logged, want) {
134
+ t.Fatal("log", logged, want)
135
+ }
124
136
  })
125
137
  }
126
138
  }
139
+
140
+ // DSM: /var/packages/<pkg>/target links to /volume1/@appstore/<pkg>.
141
+ func TestInstallAnchoredFollowsTheDSMTargetLink(t *testing.T) {
142
+ ctx := context.Background()
143
+ anchor := hashBytes([]byte("new executor"))
144
+ appstore, info := anchoredPackage(t, "new executor", "approved policy", anchor)
145
+ link := filepath.Join(filepath.Dir(info), "linked-target")
146
+ if err := os.Symlink(filepath.Dir(appstore), link); err != nil {
147
+ t.Fatal(err)
148
+ }
149
+ runtime := realTestPath(t, t.TempDir())
150
+ os.WriteFile(filepath.Join(runtime, "executor"), []byte("approved executor"), 0700)
151
+ target := filepath.Join(link, "executor")
152
+ if state := packagePins(ctx, target, info, trustAll, hashBytes([]byte("approved executor")), hashBytes([]byte("approved policy"))); state != pinsAnchored {
153
+ t.Fatal("pin state", state)
154
+ }
155
+ if err := installAnchored(ctx, target, info, trustAll, runtime); err != nil {
156
+ t.Fatal("anchored executor behind the target link refused", err)
157
+ }
158
+ if b, _ := os.ReadFile(filepath.Join(runtime, "executor")); string(b) != "new executor" {
159
+ t.Fatal("installed", string(b))
160
+ }
161
+ }
@@ -32,7 +32,7 @@ func TestReviewLargePackageHashDoesNotBlockShutdown(t *testing.T) {
32
32
  done <- manageService(ctx, make(chan string), func() (func(), error) { return func() {}, nil }, func(ctx context.Context) bool {
33
33
  close(entered)
34
34
  return samePackagePins(ctx, dir, strings.Repeat("a", 64), strings.Repeat("b", 64))
35
- }, func() error { return nil }, func(string) {})
35
+ }, func() error { return nil })
36
36
  }()
37
37
  <-entered
38
38
  time.Sleep(20 * time.Millisecond)
@@ -93,7 +93,7 @@ func TestLifecycleCancelsPendingPinCheck(t *testing.T) {
93
93
  actions := make(chan string)
94
94
  done := make(chan error, 1)
95
95
  go func() {
96
- done <- manageService(ctx, actions, func() (func(), error) { t.Error("opened during canceled check"); return func() {}, nil }, func(ctx context.Context) bool { close(entered); <-ctx.Done(); close(canceled); return false }, func() error { return nil }, func(string) {})
96
+ done <- manageService(ctx, actions, func() (func(), error) { t.Error("opened during canceled check"); return func() {}, nil }, func(ctx context.Context) bool { close(entered); <-ctx.Done(); close(canceled); return false }, func() error { return nil })
97
97
  }()
98
98
  <-entered
99
99
  if action == "shutdown" {
@@ -78,11 +78,32 @@ type envelope struct {
78
78
  Data json.RawMessage `json:"data"`
79
79
  }
80
80
 
81
+ // dsmError keeps the DSM error code of a refused call; the message stays generic.
82
+ type dsmError struct{ Code int }
83
+
84
+ func (*dsmError) Error() string { return "DSM refused" }
85
+
81
86
  func decodeEnvelope(b []byte) (json.RawMessage, error) {
87
+ data, err := decodeSuccess(b)
88
+ if err == nil && (len(data) == 0 || string(data) == "null") {
89
+ return nil, &dsmError{}
90
+ }
91
+ return data, err
92
+ }
93
+
94
+ // decodeSuccess accepts a success without data: scheduler deletes answer
95
+ // {"success":true}, sometimes with httpd_restart, and that confirms the delete.
96
+ func decodeSuccess(b []byte) (json.RawMessage, error) {
82
97
  var r envelope
83
98
  d := json.NewDecoder(bytes.NewReader(b))
84
- if uniqueValue(d, false) != nil || hasDSMError(b) || json.Unmarshal(b, &r) != nil || !r.Success || len(r.Data) == 0 || string(r.Data) == "null" {
85
- return nil, errors.New("DSM refused")
99
+ if uniqueValue(d, false) != nil || hasDSMError(b) || json.Unmarshal(b, &r) != nil || !r.Success {
100
+ var refused struct {
101
+ Error struct {
102
+ Code int `json:"code"`
103
+ } `json:"error"`
104
+ }
105
+ json.Unmarshal(b, &refused)
106
+ return nil, &dsmError{Code: refused.Error.Code}
86
107
  }
87
108
  return r.Data, nil
88
109
  }
@@ -117,6 +138,22 @@ func (d *liveDSM) Verify(ctx context.Context, cookie, token string) (string, err
117
138
  return out.Username, nil
118
139
  }
119
140
  func (d *liveDSM) call(ctx context.Context, api, method, version, runner string, params map[string]any) (json.RawMessage, error) {
141
+ b, err := d.exec(ctx, api, method, version, runner, params)
142
+ if err != nil {
143
+ return nil, err
144
+ }
145
+ return decodeEnvelope(b)
146
+ }
147
+
148
+ // mutate runs a call whose success carries no data, such as a scheduler delete.
149
+ func (d *liveDSM) mutate(ctx context.Context, api, method, version string, params map[string]any) error {
150
+ b, err := d.exec(ctx, api, method, version, "", params)
151
+ if err == nil {
152
+ _, err = decodeSuccess(b)
153
+ }
154
+ return err
155
+ }
156
+ func (d *liveDSM) exec(ctx context.Context, api, method, version, runner string, params map[string]any) ([]byte, error) {
120
157
  args := []string{"--exec", "api=" + api, "method=" + method, "version=" + version}
121
158
  if runner != "" {
122
159
  args = append(args, "runner="+runner)
@@ -140,7 +177,7 @@ func (d *liveDSM) call(ctx context.Context, api, method, version, runner string,
140
177
  }
141
178
  return nil, errors.New("DSM command")
142
179
  }
143
- return decodeEnvelope(b)
180
+ return b, nil
144
181
  }
145
182
  func (d *liveDSM) Account(ctx context.Context, name string) (Subject, error) {
146
183
  s, err := d.Accounts.Lookup(name)
@@ -1,3 +1,5 @@
1
1
  module synopkgland/executor
2
2
 
3
3
  go 1.23
4
+
5
+ toolchain go1.26.9
@@ -360,7 +360,7 @@ func serve(e *Executor, uid, gid int, revived bool) error {
360
360
  purge := func() error {
361
361
  return purgeProtected(ctx, d, e.Policy.Package, filepath.Join(stateBase, e.Policy.Package), runtime, sockDir)
362
362
  }
363
- return manageService(ctx, actions, open, samePins, purge, func(s string) { log.Print(s) })
363
+ return manageService(ctx, actions, open, samePins, purge)
364
364
  }
365
365
 
366
366
  func openService(parent context.Context, e *Executor, uid, gid int, sock string) (func(), error) {
@@ -438,11 +438,12 @@ func openService(parent context.Context, e *Executor, uid, gid int, sock string)
438
438
  }, nil
439
439
  }
440
440
  func purgeProtected(ctx context.Context, d *liveDSM, pkg, state, runtime, socket string) error {
441
+ logf := purgeLogger()
441
442
  inv, err := readTaskInventory(runtime)
442
443
  if err != nil {
443
- log.Print("task cleanup skipped: protected inventory unavailable")
444
+ logf("task cleanup skipped: protected inventory unavailable: package=" + pkg)
444
445
  } else {
445
- return purgeWithInventory(ctx, d, pkg, inv, state, runtime, socket, func(s string) { log.Print(s) })
446
+ return purgeWithInventory(ctx, d, pkg, inv, state, runtime, socket, logf)
446
447
  }
447
448
  return finishControl(state, runtime, socket, true)
448
449
  }
@@ -515,8 +516,13 @@ func verifiedPID(f *os.File, runtime string) (int, error) {
515
516
  if err != nil || pid <= 1 {
516
517
  return 0, errors.New("pid")
517
518
  }
518
- actual, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
519
- if err != nil || strings.TrimSuffix(actual, " (deleted)") != filepath.Join(runtime, "executor") {
519
+ exe := fmt.Sprintf("/proc/%d/exe", pid)
520
+ link, err := os.Readlink(exe)
521
+ if err != nil {
522
+ return 0, errors.New("pid executable mismatch")
523
+ }
524
+ running, err := os.Stat(exe)
525
+ if err != nil || !approvedExecutable(link, running, runtime, rootOwnedReadOnly) {
520
526
  return 0, errors.New("pid executable mismatch")
521
527
  }
522
528
  return pid, nil
@@ -22,8 +22,9 @@ func samePackagePins(ctx context.Context, dir, executorDigest, policyDigest stri
22
22
  }
23
23
 
24
24
  // The process and monitor outlive its listener. A package start can only expose
25
- // the already-approved runtime when the shipped bytes still match both pins.
26
- func manageService(ctx context.Context, actions <-chan string, open func() (func(), error), samePins func(context.Context) bool, purge func() error, log func(string)) error {
25
+ // the already-approved runtime when the shipped bytes still match both pins;
26
+ // samePins reports why it refused.
27
+ func manageService(ctx context.Context, actions <-chan string, open func() (func(), error), samePins func(context.Context) bool, purge func() error) error {
27
28
  var closeService func()
28
29
  var checking <-chan bool
29
30
  var cancelCheck context.CancelFunc
@@ -62,7 +63,6 @@ func manageService(ctx context.Context, actions <-chan string, open func() (func
62
63
  return nil
63
64
  }
64
65
  if !matches {
65
- log("executor paused: package pins require administrator approval")
66
66
  continue
67
67
  }
68
68
  var err error
@@ -15,7 +15,7 @@ import (
15
15
  func TestPauseResumeOnlyApprovedPinsAndPurgeWhilePaused(t *testing.T) {
16
16
  for _, changed := range []bool{false, true} {
17
17
  t.Run(map[bool]string{false: "same pins", true: "different policy"}[changed], func(t *testing.T) {
18
- dir := t.TempDir()
18
+ dir := realTestPath(t, t.TempDir())
19
19
  os.WriteFile(filepath.Join(dir, "executor"), []byte("approved executable"), 0600)
20
20
  os.WriteFile(filepath.Join(dir, "policy.json"), []byte("approved policy"), 0600)
21
21
  ed, _ := digestFile(context.Background(), filepath.Join(dir, "executor"), maxExecutorBytes)
@@ -34,7 +34,7 @@ func TestPauseResumeOnlyApprovedPinsAndPurgeWhilePaused(t *testing.T) {
34
34
  }
35
35
  opened.Add(1)
36
36
  return func() { listener.Close(); closed.Add(1) }, nil
37
- }, func(ctx context.Context) bool { return samePackagePins(ctx, dir, ed, pd) }, func() error { purged.Add(1); return nil }, func(string) {})
37
+ }, func(ctx context.Context) bool { return samePackagePins(ctx, dir, ed, pd) }, func() error { purged.Add(1); return nil })
38
38
  }()
39
39
  await := func(v *atomic.Int32, want int32) {
40
40
  t.Helper()
@@ -105,7 +105,7 @@ func TestRootControlSignalsPauseThenPurgeWithoutMailbox(t *testing.T) {
105
105
  var opened, closed, purged atomic.Int32
106
106
  done := make(chan error, 1)
107
107
  go func() {
108
- done <- manageService(ctx, actions, func() (func(), error) { opened.Add(1); return func() { closed.Add(1) }, nil }, func(context.Context) bool { return true }, func() error { purged.Add(1); return nil }, func(string) {})
108
+ done <- manageService(ctx, actions, func() (func(), error) { opened.Add(1); return func() { closed.Add(1) }, nil }, func(context.Context) bool { return true }, func() error { purged.Add(1); return nil })
109
109
  }()
110
110
  wait := func(value *atomic.Int32) {
111
111
  t.Helper()
@@ -15,3 +15,21 @@ func realTestPath(t *testing.T, p string) string {
15
15
  return p
16
16
  }
17
17
  func makeTestDir(p string) error { return os.Mkdir(p, 0700) }
18
+
19
+ // DSM links /var/packages/<pkg>/target to the volume. Every platform must
20
+ // refuse a symlinked ancestor so tests catch callers that skip EvalSymlinks.
21
+ func TestOpenRegularRefusesASymlinkedAncestor(t *testing.T) {
22
+ dir := realTestPath(t, t.TempDir())
23
+ os.MkdirAll(filepath.Join(dir, "appstore"), 0755)
24
+ os.WriteFile(filepath.Join(dir, "appstore", "executor"), []byte("x"), 0755)
25
+ os.Symlink(filepath.Join(dir, "appstore"), filepath.Join(dir, "target"))
26
+ if f, err := openRegular(filepath.Join(dir, "target", "executor")); err == nil {
27
+ f.Close()
28
+ t.Fatal("symlinked ancestor accepted")
29
+ }
30
+ f, err := openRegular(filepath.Join(dir, "appstore", "executor"))
31
+ if err != nil {
32
+ t.Fatal(err)
33
+ }
34
+ f.Close()
35
+ }