@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
@@ -0,0 +1,37 @@
1
+ package main
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
7
+ "syscall"
8
+ )
9
+
10
+ // approvedExecutable reports whether a /proc/<pid>/exe link and the inode it
11
+ // runs (stat of that link) belong to an executor root installed in runtime.
12
+ // The inode decides, not the name: kernel 3.10 (DSM 7.1) names a
13
+ // rename-replaced inode after the rename source, so a monitor whose executor
14
+ // was replaced reads as .executor (control.sh) or .executor-* (installAnchored)
15
+ // "(deleted)". Only root writes runtime (root:root 0700), and a replaced inode
16
+ // must be unlinked on the runtime's device.
17
+ func approvedExecutable(link string, running os.FileInfo, runtime string, trusted func(os.FileInfo) bool) bool {
18
+ name, deleted := strings.CutSuffix(link, " (deleted)")
19
+ base := filepath.Base(name)
20
+ if filepath.Dir(name) != runtime || (base != "executor" && !strings.HasPrefix(base, ".executor")) {
21
+ return false
22
+ }
23
+ if !running.Mode().IsRegular() || !trusted(running) {
24
+ return false
25
+ }
26
+ if !deleted {
27
+ current, err := os.Lstat(filepath.Join(runtime, "executor"))
28
+ return err == nil && os.SameFile(running, current)
29
+ }
30
+ dir, err := os.Stat(runtime)
31
+ if err != nil {
32
+ return false
33
+ }
34
+ st, ok := running.Sys().(*syscall.Stat_t)
35
+ dt, dirOK := dir.Sys().(*syscall.Stat_t)
36
+ return ok && dirOK && st.Nlink == 0 && st.Dev == dt.Dev
37
+ }
@@ -0,0 +1,113 @@
1
+ package main
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+ )
8
+
9
+ // runningExecutor installs runtime/executor and keeps it open, as a live monitor.
10
+ func runningExecutor(t *testing.T) (string, *os.File) {
11
+ t.Helper()
12
+ runtime := t.TempDir()
13
+ path := filepath.Join(runtime, "executor")
14
+ if err := os.WriteFile(path, []byte("old"), 0700); err != nil {
15
+ t.Fatal(err)
16
+ }
17
+ f, err := os.Open(path)
18
+ if err != nil {
19
+ t.Fatal(err)
20
+ }
21
+ t.Cleanup(func() { f.Close() })
22
+ return runtime, f
23
+ }
24
+
25
+ // replaceExecutor renames a new copy over runtime/executor, as control.sh and
26
+ // installAnchored do; the running inode is unlinked.
27
+ func replaceExecutor(t *testing.T, runtime, staged string) {
28
+ t.Helper()
29
+ if err := os.WriteFile(filepath.Join(runtime, staged), []byte("new"), 0700); err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ if err := os.Rename(filepath.Join(runtime, staged), filepath.Join(runtime, "executor")); err != nil {
33
+ t.Fatal(err)
34
+ }
35
+ }
36
+
37
+ func stat(t *testing.T, f *os.File) os.FileInfo {
38
+ t.Helper()
39
+ info, err := f.Stat()
40
+ if err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ return info
44
+ }
45
+
46
+ func TestApprovedExecutableAcceptsTheLiveRuntimeExecutor(t *testing.T) {
47
+ runtime, f := runningExecutor(t)
48
+ if !approvedExecutable(filepath.Join(runtime, "executor"), stat(t, f), runtime, trustAll) {
49
+ t.Fatal("live runtime executor refused")
50
+ }
51
+ }
52
+
53
+ // Kernel 3.10 (DSM 7.1 armv7) names a rename-replaced inode after the rename
54
+ // source: control.sh stages .executor, installAnchored stages .executor-*.
55
+ func TestApprovedExecutableAcceptsAReplacedExecutorUnderItsSwappedName(t *testing.T) {
56
+ for _, staged := range []string{".executor", ".executor-123456"} {
57
+ runtime, f := runningExecutor(t)
58
+ replaceExecutor(t, runtime, staged)
59
+ for _, name := range []string{staged, "executor"} {
60
+ link := filepath.Join(runtime, name) + " (deleted)"
61
+ if !approvedExecutable(link, stat(t, f), runtime, trustAll) {
62
+ t.Fatalf("replaced executor refused: %s", link)
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ func TestApprovedExecutableRefusesAnotherInode(t *testing.T) {
69
+ runtime, _ := runningExecutor(t)
70
+ other := filepath.Join(runtime, ".executor")
71
+ if err := os.WriteFile(other, []byte("other"), 0700); err != nil {
72
+ t.Fatal(err)
73
+ }
74
+ f, err := os.Open(other)
75
+ if err != nil {
76
+ t.Fatal(err)
77
+ }
78
+ defer f.Close()
79
+ running := stat(t, f)
80
+ // Still linked: not the installed executor, whatever the link claims.
81
+ for _, link := range []string{other, other + " (deleted)", filepath.Join(runtime, "executor"), filepath.Join(runtime, "executor") + " (deleted)"} {
82
+ if approvedExecutable(link, running, runtime, trustAll) {
83
+ t.Fatalf("linked non-executor accepted as %s", link)
84
+ }
85
+ }
86
+ }
87
+
88
+ func TestApprovedExecutableRefusesAnUnlinkedInodeOutsideTheRuntime(t *testing.T) {
89
+ runtime, f := runningExecutor(t)
90
+ replaceExecutor(t, runtime, ".executor")
91
+ running := stat(t, f)
92
+ for _, link := range []string{
93
+ filepath.Join(t.TempDir(), "executor") + " (deleted)",
94
+ filepath.Join(runtime, "sh") + " (deleted)",
95
+ filepath.Join(runtime, ".executor"), // unlinked yet not reported deleted
96
+ } {
97
+ if approvedExecutable(link, running, runtime, trustAll) {
98
+ t.Fatalf("unlinked inode accepted as %s", link)
99
+ }
100
+ }
101
+ }
102
+
103
+ func TestApprovedExecutableRefusesAnUntrustedInode(t *testing.T) {
104
+ runtime, f := runningExecutor(t)
105
+ refuse := func(os.FileInfo) bool { return false }
106
+ if approvedExecutable(filepath.Join(runtime, "executor"), stat(t, f), runtime, refuse) {
107
+ t.Fatal("untrusted live executor accepted")
108
+ }
109
+ replaceExecutor(t, runtime, ".executor")
110
+ if approvedExecutable(filepath.Join(runtime, ".executor")+" (deleted)", stat(t, f), runtime, refuse) {
111
+ t.Fatal("untrusted replaced executor accepted")
112
+ }
113
+ }
@@ -8,6 +8,7 @@ import (
8
8
  "net"
9
9
  "os"
10
10
  "os/exec"
11
+ "path/filepath"
11
12
  "strings"
12
13
  "syscall"
13
14
  )
@@ -19,7 +20,14 @@ var errLinux = errors.New("executor requires Linux")
19
20
  func ownedByRoot(os.FileInfo) bool { return false }
20
21
  func configureProcess(*exec.Cmd) {}
21
22
  func peerUID(net.Conn) (int, error) { return -1, errLinux }
23
+
24
+ // Like Linux openBeneath, refuse a symlink in any component, not only the last.
22
25
  func openRegular(path string) (*os.File, error) {
26
+ for dir := filepath.Dir(path); dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
27
+ if info, err := os.Lstat(dir); err != nil || info.Mode()&os.ModeSymlink != 0 {
28
+ return nil, errors.New("path")
29
+ }
30
+ }
23
31
  info, err := os.Lstat(path)
24
32
  if err != nil {
25
33
  return nil, err
@@ -0,0 +1,18 @@
1
+ package main
2
+
3
+ import (
4
+ "log"
5
+ "log/syslog"
6
+ )
7
+
8
+ // Purge removes executor.log with the protected state, so its diagnostics
9
+ // also go to the system log, where they survive the uninstall.
10
+ func purgeLogger() func(string) {
11
+ system, err := syslog.New(syslog.LOG_NOTICE|syslog.LOG_DAEMON, "synopkgland-executor")
12
+ return func(s string) {
13
+ log.Print(s)
14
+ if err == nil {
15
+ _ = system.Notice(s)
16
+ }
17
+ }
18
+ }
@@ -6,10 +6,12 @@ import (
6
6
  "encoding/hex"
7
7
  "encoding/json"
8
8
  "errors"
9
+ "fmt"
9
10
  "os"
10
11
  "path/filepath"
11
12
  "strconv"
12
13
  "synopkgland/executor/internal/taskscript"
14
+ "time"
13
15
  )
14
16
 
15
17
  type approvedTask struct {
@@ -51,8 +53,7 @@ func taskReference(ctx context.Context, d *liveDSM, task approvedTask, expected
51
53
  boot := task.Kind == "boot"
52
54
  if !boot {
53
55
  api = "SYNO.Core.TaskScheduler"
54
- version = "4"
55
- b, err := d.call(ctx, api, "list", "3", "", map[string]any{"start": 0, "limit": 500})
56
+ b, err := listControlTasks(ctx, d)
56
57
  if err != nil {
57
58
  return "", "", nil, err
58
59
  }
@@ -83,7 +84,14 @@ func taskReference(ctx context.Context, d *liveDSM, task approvedTask, expected
83
84
  }
84
85
  ref = map[string]any{"id": id, "real_owner": "root"}
85
86
  }
86
- b, err := d.call(ctx, api, "get", version, "", ref)
87
+ var b json.RawMessage
88
+ var err error
89
+ if boot {
90
+ b, err = d.call(ctx, api, "get", version, "", ref)
91
+ } else {
92
+ b, err = onUnsupportedVersion(func() (json.RawMessage, error) { return d.call(ctx, api, "get", "4", "", ref) },
93
+ func() (json.RawMessage, error) { return d.call(ctx, api, "get", "3", "", ref) })
94
+ }
87
95
  if err != nil {
88
96
  return "", "", nil, err
89
97
  }
@@ -225,20 +233,24 @@ func purgeTasks(ctx context.Context, d *liveDSM, pkg string, inv taskInventory,
225
233
  expected = canonical.Script
226
234
  }
227
235
  }
236
+ started := time.Now()
228
237
  api, version, ref, err := taskReference(ctx, d, task, expected)
229
238
  if err != nil {
230
239
  log("task cleanup skipped: " + task.Kind + " definition mismatch or unavailable")
231
240
  continue
232
241
  }
233
- if _, err = d.call(ctx, api, "delete", version, "", ref); err != nil {
242
+ looked := time.Now()
243
+ if err = d.mutate(ctx, api, "delete", version, ref); err != nil {
234
244
  log("task cleanup unconfirmed: " + task.Kind)
235
245
  }
246
+ deleted := time.Now()
236
247
  // The scheduler has no conditional delete. Accept the admin edit window,
237
248
  // delete exactly once, and observe the name afterward without another delete.
238
249
  if taskRemains(ctx, d, task) {
239
250
  log("task cleanup: " + task.Kind + " same-named task remains after delete")
240
251
  }
241
-
252
+ log(fmt.Sprintf("task cleanup timing: package=%s kind=%s lookup_ms=%d delete_ms=%d verify_ms=%d", pkg, task.Kind,
253
+ looked.Sub(started).Milliseconds(), deleted.Sub(looked).Milliseconds(), time.Since(deleted).Milliseconds()))
242
254
  }
243
255
  }
244
256
 
@@ -246,8 +258,12 @@ func purgeWithInventory(ctx context.Context, d *liveDSM, pkg string, inv taskInv
246
258
  if runtime != filepath.Join(state, "runtime") {
247
259
  return errors.New("runtime outside package protected state")
248
260
  }
261
+ started := time.Now()
249
262
  purgeTasks(ctx, d, pkg, inv, log)
250
- return finishControl(state, runtime, socket, true)
263
+ cleaned := time.Now()
264
+ err := finishControl(state, runtime, socket, true)
265
+ log(fmt.Sprintf("purge timing: package=%s tasks_ms=%d files_ms=%d", pkg, cleaned.Sub(started).Milliseconds(), time.Since(cleaned).Milliseconds()))
266
+ return err
251
267
  }
252
268
 
253
269
  func taskRemains(ctx context.Context, d *liveDSM, task approvedTask) bool {
@@ -261,7 +277,7 @@ func taskRemains(ctx context.Context, d *liveDSM, task approvedTask) bool {
261
277
  }
262
278
  return json.Unmarshal(b, &got) == nil && got.Name == task.Name
263
279
  }
264
- b, err := d.call(ctx, "SYNO.Core.TaskScheduler", "list", "3", "", map[string]any{"start": 0, "limit": 500})
280
+ b, err := listControlTasks(ctx, d)
265
281
  if err != nil {
266
282
  return false
267
283
  }
@@ -280,3 +296,25 @@ func taskRemains(ctx context.Context, d *liveDSM, task approvedTask) bool {
280
296
  }
281
297
  return false
282
298
  }
299
+
300
+ // DSM 7.1 has TaskScheduler list v2 (offset) and get v3 only. A read falls back
301
+ // solely when DSM reports the method version unsupported; any other refusal stands.
302
+ func onUnsupportedVersion(current, legacy func() (json.RawMessage, error)) (json.RawMessage, error) {
303
+ b, err := current()
304
+ var refused *dsmError
305
+ if errors.As(err, &refused) && versionUnsupported(refused.Code) {
306
+ return legacy()
307
+ }
308
+ return b, err
309
+ }
310
+
311
+ // DSM 7.1.1 returns 104 for a version above maxVersion (get v4) and 103 for a
312
+ // method absent at an existing version (list v3).
313
+ func versionUnsupported(code int) bool { return code == 103 || code == 104 }
314
+ func listControlTasks(ctx context.Context, d *liveDSM) (json.RawMessage, error) {
315
+ return onUnsupportedVersion(func() (json.RawMessage, error) {
316
+ return d.call(ctx, "SYNO.Core.TaskScheduler", "list", "3", "", map[string]any{"start": 0, "limit": 500})
317
+ }, func() (json.RawMessage, error) {
318
+ return d.call(ctx, "SYNO.Core.TaskScheduler", "list", "2", "", map[string]any{"offset": 0, "limit": 500})
319
+ })
320
+ }
@@ -0,0 +1,114 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+ "testing"
9
+ )
10
+
11
+ // dsm71Scheduler answers like DSM 7.1.1-42962 (vt-62): TaskScheduler stops at
12
+ // list v2 (offset/limit) and get v3. t-0234 measured the refusals: a version
13
+ // above maxVersion 3 returns 104; a method absent at an existing version, 103.
14
+ type dsm71Scheduler struct {
15
+ recordingScheduler
16
+ refusal string
17
+ }
18
+
19
+ func (s *dsm71Scheduler) Exec(ctx context.Context, path string, args []string) ([]byte, error) {
20
+ api, method, version := schedulerCall(args)
21
+ s.calls = append(s.calls, api+" "+method+" v"+version)
22
+ if api == "SYNO.Core.TaskScheduler" {
23
+ if method == "list" && version == "3" && s.refusal != "" {
24
+ return []byte(`{"success":false,"error":{"code":` + s.refusal + `}}`), nil
25
+ }
26
+ supported := map[string]string{"list": "2", "get": "3", "delete": "2", "run": "2"}
27
+ if supported[method] != version {
28
+ if version > "3" {
29
+ return []byte(`{"success":false,"error":{"code":104}}`), nil
30
+ }
31
+ return []byte(`{"success":false,"error":{"code":103}}`), nil
32
+ }
33
+ if method == "list" && (!contains(args, "offset=0") || !contains(args, "limit=500")) {
34
+ return []byte(`{"success":false,"error":{"code":101}}`), nil
35
+ }
36
+ }
37
+ return s.schedulerFixture.Exec(ctx, path, args)
38
+ }
39
+
40
+ func purgeWith(t *testing.T, cmd Command) {
41
+ t.Helper()
42
+ inventory := taskInventory{Package: "TestApp", PolicyDigest: strings.Repeat("b", 64), Tasks: []approvedTask{{Kind: "boot", Name: "TestApp executor boot", ScriptDigest: canonicalTaskDigest(t, "boot")}, {Kind: "control", Name: "TestApp executor control", ScriptDigest: canonicalTaskDigest(t, "control")}}}
43
+ state := filepath.Join(t.TempDir(), "state")
44
+ socket := filepath.Join(t.TempDir(), "socket")
45
+ os.MkdirAll(filepath.Join(state, "runtime"), 0700)
46
+ os.MkdirAll(socket, 0700)
47
+ if err := purgeWithInventory(context.Background(), &liveDSM{Command: cmd}, "TestApp", inventory, state, filepath.Join(state, "runtime"), socket, func(string) {}); err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ }
51
+
52
+ func TestPurgeFindsAndDeletesBothTasksOnDSM71(t *testing.T) {
53
+ cmd := &dsm71Scheduler{}
54
+ purgeWith(t, cmd)
55
+ if strings.Join(cmd.deleted, ",") != "SYNO.Core.EventScheduler,SYNO.Core.TaskScheduler" {
56
+ t.Fatal("DSM 7.1 tasks left behind", cmd.deleted, cmd.calls)
57
+ }
58
+ }
59
+
60
+ func TestSchedulerReadFallsBackOnlyOnUnsupportedVersion(t *testing.T) {
61
+ for _, code := range []string{"105", "119", "102", "3121"} {
62
+ t.Run(code, func(t *testing.T) {
63
+ cmd := &dsm71Scheduler{refusal: code}
64
+ purgeWith(t, cmd)
65
+ for _, call := range cmd.calls {
66
+ if call == "SYNO.Core.TaskScheduler list v2" || call == "SYNO.Core.TaskScheduler get v3" {
67
+ t.Fatal("fell back after code", code, cmd.calls)
68
+ }
69
+ }
70
+ if contains(cmd.deleted, "SYNO.Core.TaskScheduler") {
71
+ t.Fatal("deleted without exact readback", cmd.deleted)
72
+ }
73
+ })
74
+ }
75
+ }
76
+
77
+ func TestModernSchedulerNeverUsesLegacyVersions(t *testing.T) {
78
+ cmd := &recordingScheduler{}
79
+ purgeWith(t, cmd)
80
+ for _, call := range cmd.calls {
81
+ if call == "SYNO.Core.TaskScheduler list v2" || call == "SYNO.Core.TaskScheduler get v3" {
82
+ t.Fatal("modern DSM used a legacy version", cmd.calls)
83
+ }
84
+ }
85
+ if len(cmd.deleted) != 2 {
86
+ t.Fatal(cmd.deleted)
87
+ }
88
+ }
89
+
90
+ type recordingScheduler struct {
91
+ schedulerFixture
92
+ calls []string
93
+ }
94
+
95
+ func (s *recordingScheduler) Exec(ctx context.Context, path string, args []string) ([]byte, error) {
96
+ api, method, version := schedulerCall(args)
97
+ s.calls = append(s.calls, api+" "+method+" v"+version)
98
+ return s.schedulerFixture.Exec(ctx, path, args)
99
+ }
100
+
101
+ func schedulerCall(args []string) (api, method, version string) {
102
+ for _, a := range args {
103
+ k, v, _ := strings.Cut(a, "=")
104
+ switch k {
105
+ case "api":
106
+ api = v
107
+ case "method":
108
+ method = v
109
+ case "version":
110
+ version = v
111
+ }
112
+ }
113
+ return
114
+ }
@@ -6,6 +6,7 @@ import (
6
6
  "errors"
7
7
  "os"
8
8
  "path/filepath"
9
+ "regexp"
9
10
  "strings"
10
11
  "synopkgland/executor/internal/taskscript"
11
12
  "testing"
@@ -14,7 +15,9 @@ import (
14
15
  type schedulerFixture struct {
15
16
  changed, foreignOwner, duplicate bool
16
17
  script string
17
- deleted []string
18
+ // deleteReply replaces the delete envelope, as DSM sends it.
19
+ deleteReply string
20
+ deleted []string
18
21
  }
19
22
 
20
23
  func (s *schedulerFixture) Exec(_ context.Context, _ string, args []string) ([]byte, error) {
@@ -48,6 +51,9 @@ func (s *schedulerFixture) Exec(_ context.Context, _ string, args []string) ([]b
48
51
  if api == "SYNO.Core.TaskScheduler" && !contains(args, `tasks=[{"id":7,"real_owner":"root"}]`) {
49
52
  return nil, errors.New("wrong delete reference")
50
53
  }
54
+ if s.deleteReply != "" {
55
+ return []byte(s.deleteReply), nil
56
+ }
51
57
  data = map[string]any{}
52
58
  } else if api == "SYNO.Core.EventScheduler" {
53
59
  data = map[string]any{"task_name": "TestApp executor boot", "owner": map[string]string{"0": owner}, "operation": script, "operation_type": "script", "event": "bootup", "enable": true}
@@ -164,3 +170,54 @@ func canonicalTaskDigest(t *testing.T, kind string) string {
164
170
  t.Fatal("task kind")
165
171
  return ""
166
172
  }
173
+
174
+ // t-0229: preuninst took 15-23 s; time each scheduler phase and the file
175
+ // removal separately, naming the package, so a device log shows which waits.
176
+ func TestPurgeLogsPhaseTimingWithPackageName(t *testing.T) {
177
+ cmd := &schedulerFixture{}
178
+ inventory := taskInventory{Package: "TestApp", PolicyDigest: strings.Repeat("b", 64), Tasks: []approvedTask{{Kind: "boot", Name: "TestApp executor boot", ScriptDigest: canonicalTaskDigest(t, "boot")}, {Kind: "control", Name: "TestApp executor control", ScriptDigest: canonicalTaskDigest(t, "control")}}}
179
+ state := filepath.Join(t.TempDir(), "state")
180
+ socket := filepath.Join(t.TempDir(), "socket")
181
+ os.MkdirAll(filepath.Join(state, "runtime"), 0700)
182
+ os.MkdirAll(socket, 0700)
183
+ logs := []string{}
184
+ if err := purgeWithInventory(context.Background(), &liveDSM{Command: cmd}, "TestApp", inventory, state, filepath.Join(state, "runtime"), socket, func(s string) { logs = append(logs, s) }); err != nil {
185
+ t.Fatal(err)
186
+ }
187
+ task := regexp.MustCompile(`^task cleanup timing: package=TestApp kind=(boot|control) lookup_ms=\d+ delete_ms=\d+ verify_ms=\d+$`)
188
+ total := regexp.MustCompile(`^purge timing: package=TestApp tasks_ms=\d+ files_ms=\d+$`)
189
+ kinds := []string{}
190
+ for _, line := range logs {
191
+ if m := task.FindStringSubmatch(line); m != nil {
192
+ kinds = append(kinds, m[1])
193
+ }
194
+ }
195
+ if strings.Join(kinds, ",") != "boot,control" || len(logs) == 0 || !total.MatchString(logs[len(logs)-1]) {
196
+ t.Fatal(logs)
197
+ }
198
+ }
199
+
200
+ // t-0237: EventScheduler delete v1 and TaskScheduler delete v2 answer a success
201
+ // without data; that is a confirmed delete, while a refusal stays unconfirmed.
202
+ func TestPurgeConfirmsDatalessDeleteSuccess(t *testing.T) {
203
+ for reply, unconfirmed := range map[string]bool{
204
+ `{"success":true}`: false,
205
+ `{"httpd_restart":false,"success":true}`: false,
206
+ `{"success":false,"error":{"code":4800}}`: true,
207
+ `{"success":true,"error":{"code":4800}}`: true,
208
+ } {
209
+ cmd := &schedulerFixture{deleteReply: reply}
210
+ inventory := taskInventory{Package: "TestApp", PolicyDigest: strings.Repeat("b", 64), Tasks: []approvedTask{{Kind: "boot", Name: "TestApp executor boot", ScriptDigest: canonicalTaskDigest(t, "boot")}, {Kind: "control", Name: "TestApp executor control", ScriptDigest: canonicalTaskDigest(t, "control")}}}
211
+ logs := []string{}
212
+ purgeTasks(context.Background(), &liveDSM{Command: cmd}, "TestApp", inventory, func(s string) { logs = append(logs, s) })
213
+ found := 0
214
+ for _, line := range logs {
215
+ if strings.HasPrefix(line, "task cleanup unconfirmed: ") {
216
+ found++
217
+ }
218
+ }
219
+ if (found == 2) != unconfirmed || (found != 0 && found != 2) || len(cmd.deleted) != 2 {
220
+ t.Fatal(reply, logs)
221
+ }
222
+ }
223
+ }
@@ -2,6 +2,7 @@ package main
2
2
 
3
3
  import (
4
4
  "context"
5
+ "errors"
5
6
  "net"
6
7
  "os"
7
8
  "path/filepath"
@@ -70,13 +71,20 @@ func TestUpgradeGapPreservesRuntimeAndResumesOnlyApprovedPins(t *testing.T) {
70
71
  }
71
72
  opened.Add(1)
72
73
  return func() { listener.Close(); closed.Add(1) }, nil
73
- }, func(ctx context.Context) bool { return samePackagePins(ctx, target, ed, pd) }, func() error {
74
+ }, func(ctx context.Context) bool {
75
+ // The production decision: only differing pins ask for approval.
76
+ pins := packagePins(ctx, target, info, func(os.FileInfo) bool { return true }, ed, pd)
77
+ return approvedPins(pins, func() error { return errors.New("no anchored upgrade here") }, func() error { return nil }, func(message string) {
78
+ if strings.Contains(message, "administrator approval") {
79
+ select {
80
+ case approval <- struct{}{}:
81
+ default:
82
+ }
83
+ }
84
+ })
85
+ }, func() error {
74
86
  purged.Add(1)
75
87
  return finishControl(state, filepath.Join(state, "runtime"), sockDir, true)
76
- }, func(message string) {
77
- if strings.Contains(message, "administrator approval") {
78
- approval <- struct{}{}
79
- }
80
88
  })
81
89
  }()
82
90
  t.Cleanup(func() {
@@ -1,3 +1,5 @@
1
1
  module synopkgland-gateway
2
2
 
3
3
  go 1.24
4
+
5
+ toolchain go1.26.9
@@ -2,7 +2,7 @@ module dsmapp
2
2
 
3
3
  go 1.25.0
4
4
 
5
- toolchain go1.26.8
5
+ toolchain go1.26.9
6
6
 
7
7
  require github.com/godbus/dbus/v5 v5.2.3-0.20260924123156-0ea052abeb82
8
8
 
@@ -21,6 +21,9 @@ const MaxBytes = 512 * 1024
21
21
  const maxMetadata = 1024 * 1024
22
22
  const PageSize = 100
23
23
 
24
+ // downloadPeek bounds how much of a DSM 7.1 download is inspected for an error envelope.
25
+ const downloadPeek = 4096
26
+
24
27
  // Client has no cookie jar or credentials. The destination is startup-owned,
25
28
  // literal loopback only; proxies, redirects and automatic retries are disabled.
26
29
  type Client struct {
@@ -269,15 +272,48 @@ func (c *Client) DownloadStream(ctx context.Context, r *http.Request, p string)
269
272
  if e != nil {
270
273
  return Download{}, e
271
274
  }
272
- disposition, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Disposition"))
273
- if disposition != "attachment" {
275
+ body := resp.Body
276
+ raw := resp.Header.Values("Content-Disposition")
277
+ if len(raw) == 0 {
278
+ // DSM 7.1 sends no Content-Disposition at all. Only a short body that is
279
+ // a complete DSM envelope is an error; everything else is the file.
280
+ if body, e = unlessEnvelope(resp); e != nil {
281
+ return Download{}, e
282
+ }
283
+ } else if disposition, _, _ := mime.ParseMediaType(raw[0]); len(raw) != 1 || disposition != "attachment" {
274
284
  e = decode(resp, nil)
275
285
  if e == nil {
276
286
  e = fail("unavailable")
277
287
  }
278
288
  return Download{}, e
279
289
  }
280
- return Download{Body: resp.Body, ContentType: resp.Header.Get("Content-Type"), ContentDisposition: resp.Header.Get("Content-Disposition"), ContentLength: resp.ContentLength}, nil
290
+ return Download{Body: body, ContentType: resp.Header.Get("Content-Type"), ContentDisposition: resp.Header.Get("Content-Disposition"), ContentLength: resp.ContentLength}, nil
291
+ }
292
+
293
+ // unlessEnvelope returns the whole body as a stream unless it fits in the peek
294
+ // and decodes as a DSM envelope, which is refused and never becomes content.
295
+ func unlessEnvelope(resp *http.Response) (io.ReadCloser, error) {
296
+ head, e := io.ReadAll(io.LimitReader(resp.Body, downloadPeek+1))
297
+ if e != nil {
298
+ resp.Body.Close()
299
+ return nil, fail("read_failed")
300
+ }
301
+ if len(head) <= downloadPeek {
302
+ var envelope struct {
303
+ Success *bool `json:"success"`
304
+ }
305
+ if json.Unmarshal(head, &envelope) == nil && envelope.Success != nil {
306
+ resp.Body = io.NopCloser(bytes.NewReader(head))
307
+ if e = decode(resp, nil); e == nil {
308
+ e = fail("unavailable")
309
+ }
310
+ return nil, e
311
+ }
312
+ }
313
+ return struct {
314
+ io.Reader
315
+ io.Closer
316
+ }{io.MultiReader(bytes.NewReader(head), resp.Body), resp.Body}, nil
281
317
  }
282
318
 
283
319
  // Read is the bounded package-private job import, using the same stream.