@synopackageland/cli 0.7.0 → 0.7.2
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.
- package/CHANGELOG.md +301 -1
- package/assets/default-icon/README.md +19 -0
- package/assets/default-icon/default-icon-small.svg +9 -0
- package/assets/default-icon/default-icon.svg +10 -0
- package/assets/default-icon/icon_16.png +0 -0
- package/assets/default-icon/icon_24.png +0 -0
- package/assets/default-icon/icon_256.png +0 -0
- package/assets/default-icon/icon_32.png +0 -0
- package/assets/default-icon/icon_48.png +0 -0
- package/assets/default-icon/icon_64.png +0 -0
- package/assets/default-icon/icon_72.png +0 -0
- package/dist/add.d.ts +4 -1
- package/dist/add.js +14 -9
- package/dist/build-spk/dsm-icons.d.ts +31 -2
- package/dist/build-spk/dsm-icons.js +135 -56
- package/dist/build-spk/dsm-platforms.json +4 -1
- package/dist/build-spk/gateway.d.ts +1 -1
- package/dist/build-spk/gateway.js +8 -4
- package/dist/build-spk/generator.js +17 -3
- package/dist/build-spk/go-toolchain.d.ts +20 -0
- package/dist/build-spk/go-toolchain.js +44 -0
- package/dist/build-spk/png.d.ts +14 -0
- package/dist/build-spk/png.js +161 -0
- package/dist/build-spk/ui-texts.d.ts +20 -0
- package/dist/build-spk/ui-texts.js +42 -0
- package/dist/cli.js +39 -9
- package/dist/contract/i18n.d.ts +5 -0
- package/dist/contract/i18n.js +45 -0
- package/dist/contract/icon.d.ts +5 -0
- package/dist/contract/icon.js +27 -0
- package/dist/contract/validate.js +4 -0
- package/dist/contract/web-launch.js +6 -2
- package/dist/deploy/native-install-values.d.ts +17 -0
- package/dist/deploy/native-install-values.js +32 -0
- package/dist/deploy/plan.d.ts +6 -2
- package/dist/deploy/plan.js +57 -6
- package/dist/error-registry.js +15 -6
- package/dist/host/dsm-client.js +53 -17
- package/dist/host/dsm-http.js +24 -2
- package/dist/host/types.d.ts +1 -0
- package/dist/info.js +28 -9
- package/dist/init.js +1 -1
- package/dist/runtime/dsm-version.d.ts +6 -0
- package/dist/runtime/dsm-version.js +8 -0
- package/dist/runtime/executor-build.d.ts +1 -1
- package/dist/runtime/executor-build.js +8 -5
- package/dist/standalone/daemon.d.ts +2 -1
- package/dist/standalone/daemon.js +10 -6
- package/dist/standalone/dsm-window.d.ts +2 -0
- package/dist/standalone/dsm-window.js +26 -40
- package/dist/standalone/i18n.d.ts +12 -0
- package/dist/standalone/i18n.js +30 -0
- package/dist/standalone/inspect-spk.d.ts +5 -0
- package/dist/standalone/inspect-spk.js +41 -2
- package/dist/standalone/manifest.d.ts +5 -6
- package/dist/standalone/manifest.js +21 -34
- package/dist/standalone/native-authorization-window.d.ts +1 -1
- package/dist/standalone/native-authorization-window.js +7 -1
- package/dist/standalone/native-layout.d.ts +2 -0
- package/dist/standalone/native-layout.js +22 -1
- package/dist/standalone/native-root-window.d.ts +4 -2
- package/dist/standalone/native-root-window.js +33 -18
- package/dist/standalone/root-window.d.ts +4 -1
- package/dist/standalone/root-window.js +143 -33
- package/dist/standalone/spk.js +7 -4
- package/dist/templates/_daemon/go.mod +1 -1
- package/dist/templates/_daemon/main.go +6 -3
- package/dist/templates/_daemon/main_test.go +21 -1
- package/dist/templates/_daemon/testdata/child.go +3 -0
- package/dist/templates/_executor/README.md +4 -1
- package/dist/templates/_executor/anchor.go +10 -0
- package/dist/templates/_executor/anchor_test.go +40 -5
- package/dist/templates/_executor/digest_review_test.go +2 -2
- package/dist/templates/_executor/dsm.go +40 -3
- package/dist/templates/_executor/go.mod +2 -0
- package/dist/templates/_executor/main.go +11 -5
- package/dist/templates/_executor/managed.go +3 -3
- package/dist/templates/_executor/managed_test.go +3 -3
- package/dist/templates/_executor/path_test.go +18 -0
- package/dist/templates/_executor/pid.go +37 -0
- package/dist/templates/_executor/pid_test.go +113 -0
- package/dist/templates/_executor/platform_other.go +8 -0
- package/dist/templates/_executor/purge_log.go +18 -0
- package/dist/templates/_executor/tasks.go +45 -7
- package/dist/templates/_executor/tasks_dsm71_test.go +114 -0
- package/dist/templates/_executor/tasks_test.go +58 -1
- package/dist/templates/_executor/upgrade_watch_test.go +13 -5
- package/dist/templates/_gateway/go.mod +2 -0
- package/dist/templates/binary/dsm-package.yaml +3 -0
- package/dist/templates/compose/synology-app.yaml +3 -0
- package/dist/templates/native-go/backend/go.mod +1 -1
- package/dist/templates/native-go/backend/internal/filestation/client.go +39 -3
- package/dist/templates/native-go/backend/internal/filestation/download_dsm71_test.go +82 -0
- package/dist/templates/native-go/backend/internal/identity/README.md +5 -1
- package/dist/templates/native-go/backend/internal/identity/dsm.go +24 -5
- package/dist/templates/native-go/backend/internal/identity/http.go +1 -1
- package/dist/templates/native-go/backend/internal/identity/identity_test.go +48 -1
- package/dist/templates/native-go/backend/internal/identity/service.go +1 -1
- package/dist/templates/native-go/dsm-package.yaml +3 -0
- package/dist/templates/native-go-delegate/frontend/test/delegate.test.ts +15 -0
- package/dist/templates/native-go-root-ops/backend/internal/app/executor.go +30 -2
- package/dist/templates/native-go-root-ops/backend/internal/app/executor_test.go +64 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/ExecutorSettings.vue +3 -2
- package/dist/templates/native-go-vue/frontend/src/delegate/executor-settings.test.ts +23 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/executor-setup.ts +5 -1
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/de.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/en.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/es.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/fr.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/it.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/ja.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/ko.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/zh-CN.json +1 -0
- package/dist/templates/native-go-vue/frontend/src/delegate/i18n/zh-TW.json +1 -0
- package/package.json +2 -1
- package/schema/synology-app.schema.json +22 -0
- package/templates/_daemon/go.mod +1 -1
- package/templates/_daemon/main.go +6 -3
- package/templates/_daemon/main_test.go +21 -1
- package/templates/_daemon/testdata/child.go +3 -0
- package/templates/_executor/README.md +4 -1
- package/templates/_executor/anchor.go +10 -0
- package/templates/_executor/anchor_test.go +40 -5
- package/templates/_executor/digest_review_test.go +2 -2
- package/templates/_executor/dsm.go +40 -3
- package/templates/_executor/go.mod +2 -0
- package/templates/_executor/main.go +11 -5
- package/templates/_executor/managed.go +3 -3
- package/templates/_executor/managed_test.go +3 -3
- package/templates/_executor/path_test.go +18 -0
- package/templates/_executor/pid.go +37 -0
- package/templates/_executor/pid_test.go +113 -0
- package/templates/_executor/platform_other.go +8 -0
- package/templates/_executor/purge_log.go +18 -0
- package/templates/_executor/tasks.go +45 -7
- package/templates/_executor/tasks_dsm71_test.go +114 -0
- package/templates/_executor/tasks_test.go +58 -1
- package/templates/_executor/upgrade_watch_test.go +13 -5
- package/templates/_gateway/go.mod +2 -0
- package/templates/binary/dsm-package.yaml +3 -0
- package/templates/compose/synology-app.yaml +3 -0
- package/templates/native-go/backend/go.mod +1 -1
- package/templates/native-go/backend/internal/filestation/client.go +39 -3
- package/templates/native-go/backend/internal/filestation/download_dsm71_test.go +82 -0
- package/templates/native-go/backend/internal/identity/README.md +5 -1
- package/templates/native-go/backend/internal/identity/dsm.go +24 -5
- package/templates/native-go/backend/internal/identity/http.go +1 -1
- package/templates/native-go/backend/internal/identity/identity_test.go +48 -1
- package/templates/native-go/backend/internal/identity/service.go +1 -1
- package/templates/native-go/dsm-package.yaml +3 -0
- package/templates/native-go-delegate/frontend/test/delegate.test.ts +15 -0
- package/templates/native-go-root-ops/backend/internal/app/executor.go +30 -2
- package/templates/native-go-root-ops/backend/internal/app/executor_test.go +64 -0
- package/templates/native-go-vue/frontend/src/delegate/ExecutorSettings.vue +3 -2
- package/templates/native-go-vue/frontend/src/delegate/executor-settings.test.ts +23 -0
- package/templates/native-go-vue/frontend/src/delegate/executor-setup.ts +5 -1
- package/templates/native-go-vue/frontend/src/delegate/i18n/de.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/en.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/es.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/fr.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/it.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/ja.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/ko.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/zh-CN.json +1 -0
- package/templates/native-go-vue/frontend/src/delegate/i18n/zh-TW.json +1 -0
|
@@ -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
|
|
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
|
-
|
|
444
|
+
logf("task cleanup skipped: protected inventory unavailable: package=" + pkg)
|
|
444
445
|
} else {
|
|
445
|
-
return purgeWithInventory(ctx, d, pkg, inv, state, runtime, socket,
|
|
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
|
-
|
|
519
|
-
|
|
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
|
-
|
|
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 }
|
|
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 }
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 :=
|
|
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
|
-
|
|
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 {
|
|
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() {
|
|
@@ -5,6 +5,9 @@ version: 0.1.0
|
|
|
5
5
|
build: 1
|
|
6
6
|
displayname: __DISPLAY_NAME__
|
|
7
7
|
description: A DSM package with a prebuilt binary CGI and DSM window UI.
|
|
8
|
+
# Package Center and DSM window icon: a square PNG of at least 256x256, or a
|
|
9
|
+
# folder of icon_<size>.png. Omit to use the SDK default icon.
|
|
10
|
+
# icon: assets/icon.png
|
|
8
11
|
maintainer: Your Name
|
|
9
12
|
# Optional INFO/website metadata. Omit fields you do not need.
|
|
10
13
|
# metadata:
|