@synopackageland/templates 0.35.5 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +34 -17
  2. package/package.json +1 -1
  3. package/templates/compose/dsm-package.yaml +1 -1
  4. package/templates/native-go/README.md +2 -2
  5. package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
  6. package/templates/native-go/backend/go.mod +1 -1
  7. package/templates/native-go/backend/go.sum +2 -2
  8. package/templates/native-go/backend/internal/app/background.go +10 -12
  9. package/templates/native-go/backend/internal/app/background_inbox_test.go +37 -0
  10. package/templates/native-go/backend/internal/app/background_owner.go +14 -0
  11. package/templates/native-go/backend/internal/app/background_owner_test.go +2 -2
  12. package/templates/native-go/backend/internal/app/background_test.go +27 -8
  13. package/templates/native-go-vue/frontend/package-lock.json +5 -5
  14. package/templates/native-go-vue/frontend/package.json +1 -1
  15. package/templates/native-node/README.md +2 -2
  16. package/templates/native-node/backend/package-lock.json +2 -2
  17. package/templates/native-node/dsm-package.yaml +1 -1
  18. package/templates/native-node-delegate/backend/src/delegate.ts +20 -8
  19. package/templates/native-node-delegate/backend/test/delegate.test.ts +18 -9
  20. package/templates/native-python/README.md +2 -2
  21. package/templates/native-python/dsm-package.yaml +1 -1
  22. package/templates/native-python-delegate/backend/app/delegate.py +19 -7
  23. package/templates/native-python-delegate/backend/tests/test_delegate.py +18 -8
  24. package/templates/native-rust/README.md +2 -2
  25. package/templates/native-rust/backend/Cargo.lock +2 -2
  26. package/templates/native-rust/backend/Cargo.toml +1 -1
  27. package/templates/native-rust-delegate/backend/Cargo.lock +203 -0
  28. package/templates/native-rust-delegate/backend/Cargo.toml +17 -0
  29. package/templates/native-rust-delegate/backend/src/delegate.rs +78 -23
package/README.md CHANGED
@@ -22,8 +22,8 @@ daemon:
22
22
  window_seconds: 300
23
23
  ```
24
24
 
25
- Templates 0.35.3 pin UI 0.16.1 and SDK 0.38.2 in native and Compose projects.
26
- Use CLI 0.41.2. The Vue `AppShell` shows a
25
+ Templates 0.36.0 pin UI 0.16.1 and SDK 0.40.0 in native and Compose projects.
26
+ Use a CLI that supports executor manifest format 1. The Vue `AppShell` shows a
27
27
  non-blocking **Reload** notice when an upgrade replaced the UI an open page
28
28
  loaded. Go Apps select
29
29
  their controller from their Go SDK requirement; an identity gateway selects it
@@ -33,8 +33,9 @@ override does not waive that version check.
33
33
 
34
34
  The SDK owns backoff, the sliding retry cap and stop cancellation. Keep existing
35
35
  stop-before-upgrade/uninstall hooks; do not add retry loops in App scripts.
36
- Clean exits do not restart; initial startup retains its original fail-fast
37
- deadline. Compose keeps its own supervision. An App backend may map SDK
36
+ Clean exits do not restart; failures before the first successful startup use
37
+ the same bounded recovery policy as later exits. Compose keeps its own supervision.
38
+ An App backend may map SDK
38
39
  `status-json` state/reason to its authenticated status UI; no new status panel is
39
40
  generated. See the CLI's [daemon policy guide](https://github.com/synopackageland/syno-pkg-land-cli/blob/main/docs/daemon.md)
40
41
  for declaration bounds, SDK selection, build/inspect checks and diagnostics.
@@ -81,20 +82,21 @@ calls each one from the generated script of the same name (`synopkgland skill sh
81
82
  The placeholders `__PACKAGE__` (also the Go module path), `__DISPLAY_NAME__` and
82
83
  `__BACKGROUND_ENABLED__` are substituted by `init`.
83
84
 
84
- `backend/go.mod` requires `github.com/synopackageland/syno-pkg-land-sdk v0.38.2`; that line is
85
+ `backend/go.mod` requires `github.com/synopackageland/syno-pkg-land-sdk v0.40.0`; that line is
85
86
  the single source of the SDK version and `frontend/package.json` pins
86
87
  `@synopackageland/sdk` to the same version. Python and Node.js name it in
87
88
  `backend.sdk`, Rust in the `syno-pkg-land-app` tag of `backend/Cargo.toml`
88
- (and `Cargo.lock`); all of them pin 0.38.2. The Vue frontend pins
89
- `@synopackageland/ui` 0.16.1. SDK 0.38.2 accepts UI `>=0.10.1 <0.18.0`,
89
+ (and `Cargo.lock`); all of them pin 0.40.0. The Vue frontend pins
90
+ `@synopackageland/ui` 0.16.1. SDK 0.40.0 accepts UI `>=0.10.1 <0.19.0`,
90
91
  so no UI peer override is needed. Tests use jsdom 30 for UI floating-layer styles.
91
92
 
92
93
  Interactive NAS file operations use same-origin browser calls.
93
94
  Custom file handlers use the SDK's
94
- [session transport migration](https://github.com/synopackageland/syno-pkg-land-sdk/blob/v0.38.2/docs/dsm-session-transport.md).
95
- CLI 0.41.5 reads SDK 0.38.2's release manifest, which names immutable
96
- executor/v0.38.2 and its executor digests. Its `~0.35.3` templates
97
- dependency selects this release.
95
+ [session transport migration](https://github.com/synopackageland/syno-pkg-land-sdk/blob/v0.40.0/docs/dsm-session-transport.md).
96
+ The CLI reads SDK 0.40.0's release manifest, which names immutable
97
+ executor/v0.39.0 and its executor digests. CLI distributions must resolve
98
+ templates 0.36.x before default scaffolds use this release; a `~0.35.3`
99
+ templates dependency remains on the older minor.
98
100
 
99
101
  Every App method must appear in the permissions table, including methods whose
100
102
  permission equals `default: administrators`. Dynamic method names and custom
@@ -108,7 +110,7 @@ Keep punctuation between sentences and intentional loading ellipses.
108
110
 
109
111
  ## Request deadlines and background work
110
112
 
111
- SDK/App 0.38.2 defaults to 300 seconds for cooperative requests and 302 seconds
113
+ SDK/App 0.40.0 defaults to 300 seconds for cooperative requests and 302 seconds
112
114
  for the TS client and generated DSM window. The CLI reads the selected SDK's
113
115
  timeout spec; `backend.timeout_seconds` explicitly overrides it for native
114
116
  backends, and an earlier parent deadline still wins. Go assembly should fall
@@ -140,7 +142,7 @@ caps, approved root policy/program budgets and credential lifetimes retain
140
142
  their existing limits; long root work uses `root.start` / `root.jobs.*`.
141
143
  The independent ctl transport keeps its 62-second default: use an explicit
142
144
  `--timeout` for long synchronous calls, or an existing nonblocking job flow.
143
- See the SDK's [request deadline contract](https://github.com/synopackageland/syno-pkg-land-sdk/blob/v0.38.2/docs/request-deadlines.md).
145
+ See the SDK's [request deadline contract](https://github.com/synopackageland/syno-pkg-land-sdk/blob/v0.40.0/docs/request-deadlines.md).
144
146
 
145
147
  ## Hyper Backup
146
148
 
@@ -286,9 +288,12 @@ Native Node Apps use the official [`Node.js_v22`](https://www.synology.com/en-us
286
288
 
287
289
  The jobs overlays submit files the App's own producer has already imported into
288
290
  its private data directory. Submit `{key, name, input}` through `bg.jobs.submit`
289
- (Go) or `hash.submit` (Python, Node.js and Rust); `input` is the absolute path of
290
- that App-owned file. The Go handler and the bridge runtime confine reads to the
291
- App data directory and keep the SDK's bounded private-copy input contract.
291
+ (Go) or `hash.submit` (Python, Node.js and Rust). In Go, a trusted producer writes
292
+ into `BackgroundInbox(dataDir, background.Submitter{User: verifiedName, UID: verifiedUID})`;
293
+ `input` must be an absolute path inside that exact owner's inbox, including for
294
+ administrators. The name and UID both scope the inbox, so a recreated account
295
+ gets another directory. Symlinks cannot escape it. Jobs and schedules retain a
296
+ bounded private copy. Bridge-language job producers retain their SDK input contract.
292
297
  There is no NAS browse/import example or automatic file submission in either UI shell.
293
298
 
294
299
  Keep the job controls and package-account workers for progress, cancellation and
@@ -299,7 +304,7 @@ The bridge examples expose submission, lookup and cancellation through
299
304
 
300
305
  ## External access
301
306
 
302
- With CLI 0.40.0 and SDK 0.38.2, a gateway App may declare `identity_gateway.external_access: {connect: optional, origin_change: confirm, single_origin: true}` for an upstream that signs in on one fixed URL. The policy is forwarded to the packaged gateway. A single-origin App's `manual_origin` accepts an explicit HTTPS port; the `external-origin` installation setting remains restricted to port 443. The DSM launcher selects a provider destination, probes `/_spl/ping` for up to eight seconds, and explains failures before offering **Open anyway**. A configured single-origin App always opens its primary external origin.
307
+ With a compatible CLI and SDK 0.40.0, a gateway App may declare `identity_gateway.external_access: {connect: optional, origin_change: confirm, single_origin: true}` for an upstream that signs in on one fixed URL. The policy is forwarded to the packaged gateway. A single-origin App's `manual_origin` accepts an explicit HTTPS port; the `external-origin` installation setting remains restricted to port 443. The DSM launcher selects a provider destination, probes `/_spl/ping` for up to eight seconds, and explains failures before offering **Open anyway**. A configured single-origin App always opens its primary external origin.
303
308
 
304
309
 
305
310
  Compose templates declare an app-named dedicated shared folder by default. DSM
@@ -332,3 +337,15 @@ marker with their own. Use `entry` only for a configured entry override. An
332
337
  entry declared upstream-authenticated in the manifest also needs `publicLogin`
333
338
  to identify its own login page. A DSM-gated entry cannot opt out of the gate.
334
339
  This smoke checks opening only; it does not install packages or change accounts.
340
+
341
+
342
+ The delegate report example scopes staging to
343
+ `owners/<sha256(username + NUL + delegationID)>/` and scopes its executor job ID
344
+ to SHA-256 of username, NUL, delegation ID, NUL and its producer job ID, followed
345
+ by a hyphen and the first 32 hex characters of the owner hash. The SDK verifies
346
+ this owner-and-grant suffix on admission. Repeated
347
+ saves by the same owner and grant reuse their original file; other owners and
348
+ grants get different sources. A newly created report refused with
349
+ `DELEGATION_DENIED` is removed. An ambiguous transport failure keeps the source
350
+ for retry because the executor may already have admitted it. Other settled
351
+ sources use the SDK's explicit discard operation when the App deletes the job.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/templates",
3
- "version": "0.35.5",
3
+ "version": "0.36.0",
4
4
  "description": "Project templates for synopkgland init: native-go, native-python, native-node and native-rust (plain or Vue, with jobs, delegate and root-ops overlays), binary and compose.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,7 +23,7 @@ compose:
23
23
  web: {service: web, port: 8080, health: {path: /, status: 200}}
24
24
  network: {egress: isolated}
25
25
  identity_gateway:
26
- sdk: 0.38.2
26
+ sdk: 0.40.0
27
27
  mode: none
28
28
  listen: {http: 28000}
29
29
  # Web Apps default to new-page through the shared app-launch resolver.
@@ -17,7 +17,7 @@ DSM main menu → DSM window (ui/app.js)
17
17
 
18
18
  ## Requests and long work
19
19
 
20
- SDK/App 0.38.2 defaults to 300-second requests and a 302-second browser wait.
20
+ SDK/App 0.40.0 defaults to 300-second requests and a 302-second browser wait.
21
21
  Explicit `backend.timeout_seconds` and earlier parent deadlines still apply;
22
22
  rebuild the App to use the new SDK. For long NAS work, enable the existing jobs
23
23
  capability: return admission and the original job ID promptly, execute in the
@@ -27,7 +27,7 @@ last queued/running state intact. After a lost admission reply, reuse the same
27
27
  idempotency key and input instead of replaying a mutation with a new key.
28
28
  Jobs default to 30 minutes; approved executor limits remain separate. ctl keeps
29
29
  its 62-second transport default; use an explicit `--timeout` or an existing
30
- nonblocking job. See the SDK's [request deadline contract](https://github.com/synopackageland/syno-pkg-land-sdk/blob/v0.38.2/docs/request-deadlines.md).
30
+ nonblocking job. See the SDK's [request deadline contract](https://github.com/synopackageland/syno-pkg-land-sdk/blob/v0.40.0/docs/request-deadlines.md).
31
31
 
32
32
  ## Quick start
33
33
 
@@ -1,6 +1,6 @@
1
1
  Native Go package build dependencies
2
2
 
3
- github.com/synopackageland/syno-pkg-land-sdk v0.38.2 (MIT)
3
+ github.com/synopackageland/syno-pkg-land-sdk v0.40.0 (MIT)
4
4
 
5
5
  Copyright (c) 2026 synopkgland
6
6
 
@@ -4,6 +4,6 @@ go 1.25.0
4
4
 
5
5
  toolchain go1.26.10
6
6
 
7
- require github.com/synopackageland/syno-pkg-land-sdk v0.38.2
7
+ require github.com/synopackageland/syno-pkg-land-sdk v0.40.0
8
8
 
9
9
  require golang.org/x/sys v0.44.0 // indirect
@@ -1,4 +1,4 @@
1
- github.com/synopackageland/syno-pkg-land-sdk v0.38.2 h1:UJh6tbUxhmcRdy8Y6qqJYHcxZtYkShE9UxtXjbN5vV8=
2
- github.com/synopackageland/syno-pkg-land-sdk v0.38.2/go.mod h1:8GwREGbczc8mAk4Gv8FUHaukXXCUstjzkovQrNV59Cc=
1
+ github.com/synopackageland/syno-pkg-land-sdk v0.40.0 h1:3PBmIbiMkoEc4W3blLeRHdOeuF54hD2lGEQu3Mt0SJ4=
2
+ github.com/synopackageland/syno-pkg-land-sdk v0.40.0/go.mod h1:8GwREGbczc8mAk4Gv8FUHaukXXCUstjzkovQrNV59Cc=
3
3
  golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
4
4
  golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
@@ -10,7 +10,6 @@ import (
10
10
  "os"
11
11
  "os/user"
12
12
  "path/filepath"
13
- "strings"
14
13
  "syscall"
15
14
  "time"
16
15
 
@@ -125,7 +124,7 @@ func (b backgroundAPI) submit(ctx context.Context, _ api.Caller, _ *http.Request
125
124
  if !access.Owner.Valid() {
126
125
  return nil, &api.Error{Code: "identity_unavailable", Message: "DSM user UID could not be resolved"}
127
126
  }
128
- data, err := b.readInput(in.Input)
127
+ data, err := b.readInput(in.Input, access.Owner)
129
128
  if err != nil {
130
129
  return nil, err
131
130
  }
@@ -138,20 +137,19 @@ func (b backgroundAPI) submit(ctx context.Context, _ api.Caller, _ *http.Request
138
137
  return map[string]any{"job": job, "duplicate": duplicate}, nil
139
138
  }
140
139
 
141
- // readInput accepts only an existing regular file inside the App data directory,
142
- // outside the job spool.
143
- // The App's producer imports it first; jobs and schedules retain a private copy.
144
- func (b backgroundAPI) readInput(input string) ([]byte, error) {
145
- bad := api.BadRequest("input must be a regular file in the App data directory")
146
- rel, err := filepath.Rel(b.dataDir, input)
147
- if err != nil || !filepath.IsAbs(input) || !filepath.IsLocal(rel) {
140
+ // readInput opens only the authenticated owner's producer inbox. os.Root
141
+ // confines symlinks as well as lexical paths to that inbox, including for admins.
142
+ func (b backgroundAPI) readInput(input string, owner background.Submitter) ([]byte, error) {
143
+ bad := api.BadRequest("input must be a regular file in your App inbox")
144
+ inbox, err := BackgroundInbox(b.dataDir, owner)
145
+ if err != nil {
148
146
  return nil, bad
149
147
  }
150
- // The job spool keeps other owners' private copies; it is never an input.
151
- if first, _, _ := strings.Cut(filepath.ToSlash(rel), "/"); first == "background" {
148
+ rel, err := filepath.Rel(inbox, input)
149
+ if err != nil || !filepath.IsAbs(input) || !filepath.IsLocal(rel) {
152
150
  return nil, bad
153
151
  }
154
- root, err := os.OpenRoot(b.dataDir)
152
+ root, err := os.OpenRoot(inbox)
155
153
  if err != nil {
156
154
  return nil, bad
157
155
  }
@@ -0,0 +1,37 @@
1
+ package app
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "path/filepath"
7
+ "testing"
8
+
9
+ "github.com/synopackageland/syno-pkg-land-sdk/background"
10
+ )
11
+
12
+ func TestBackgroundSubmitRefusesOtherOwnerInboxAndAppFiles(t *testing.T) {
13
+ e := ordinaryEnv(t)
14
+ private := filepath.Join(e.dataDir, "private-settings")
15
+ if err := os.WriteFile(private, []byte("private"), 0600); err != nil {
16
+ t.Fatal(err)
17
+ }
18
+ for _, input := range []string{private, e.inputPath("alice")} {
19
+ p, _ := json.Marshal(map[string]string{"key": "stolen", "name": "copy", "input": input})
20
+ if code, _ := e.call(bob, "bg.jobs.submit", string(p)); code != "bad_request" {
21
+ t.Fatal("cross-owner import accepted", code)
22
+ }
23
+ }
24
+ own := e.inputPath("bob")
25
+ if err := os.Symlink(private, filepath.Join(filepath.Dir(own), "escape")); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ p, _ := json.Marshal(map[string]string{"key": "link", "name": "copy", "input": filepath.Join(filepath.Dir(own), "escape")})
29
+ if code, _ := e.call(bob, "bg.jobs.submit", string(p)); code != "bad_request" {
30
+ t.Fatal("inbox symlink escaped", code)
31
+ }
32
+ old, _ := BackgroundInbox(e.dataDir, background.Submitter{User: "bob", UID: "1025"})
33
+ current, _ := BackgroundInbox(e.dataDir, background.Submitter{User: "bob", UID: "2048"})
34
+ if old == current {
35
+ t.Fatal("recreated user retained its inbox")
36
+ }
37
+ }
@@ -2,10 +2,13 @@ package app
2
2
 
3
3
  import (
4
4
  "context"
5
+ "crypto/sha256"
6
+ "encoding/hex"
5
7
  "encoding/json"
6
8
  "github.com/synopackageland/syno-pkg-land-sdk/api"
7
9
  "github.com/synopackageland/syno-pkg-land-sdk/background"
8
10
  "net/http"
11
+ "path/filepath"
9
12
  )
10
13
 
11
14
  type backgroundAccessKey struct{}
@@ -36,3 +39,14 @@ func (b backgroundAPI) purge(ctx context.Context, c api.Caller, _ *http.Request,
36
39
  }
37
40
  return audit, nil
38
41
  }
42
+
43
+ // BackgroundInbox is the private input directory a trusted App producer fills
44
+ // for an authenticated owner. Both account name and UID are part of its key.
45
+ func BackgroundInbox(dataDir string, owner background.Submitter) (string, error) {
46
+ if !owner.Valid() {
47
+ return "", api.BadRequest("exact owner is required")
48
+ }
49
+ raw, _ := json.Marshal([2]string{owner.User, owner.UID})
50
+ key := sha256.Sum256(raw)
51
+ return filepath.Join(dataDir, "inbox", hex.EncodeToString(key[:])), nil
52
+ }
@@ -28,7 +28,7 @@ var carol = stubAuth{identity: api.Identity{User: "carol"}}
28
28
 
29
29
  func ownerSubmit(t *testing.T, e *bgEnv, a api.Authenticator, key string) string {
30
30
  t.Helper()
31
- code, out := e.call(a, "bg.jobs.submit", e.submitParams(key))
31
+ code, out := e.call(a, "bg.jobs.submit", e.submitParams(key, a.(stubAuth).identity.User))
32
32
  if code != "" {
33
33
  t.Fatalf("submit %s: %s %v", key, code, out)
34
34
  }
@@ -103,7 +103,7 @@ func TestBackgroundPersonalQuotaAndAdminPurge(t *testing.T) {
103
103
  }
104
104
  ownerSubmit(t, e, bob, "one")
105
105
  ownerSubmit(t, e, bob, "two")
106
- if code, _ := e.call(bob, "bg.jobs.submit", e.submitParams("three")); code != "owner-queue-full" {
106
+ if code, _ := e.call(bob, "bg.jobs.submit", e.submitParams("three", "bob")); code != "owner-queue-full" {
107
107
  t.Fatal(code)
108
108
  }
109
109
  ownerSubmit(t, e, carol, "one")
@@ -28,10 +28,9 @@ func newBgEnv(t *testing.T) *bgEnv {
28
28
  t.Cleanup(func() { lookupSubmitterUID = osLookupUID })
29
29
  dir := t.TempDir()
30
30
  store := background.NewStore(filepath.Join(dir, "background"))
31
- if err := os.WriteFile(filepath.Join(dir, "input.txt"), []byte("hello"), 0600); err != nil {
32
- t.Fatal(err)
33
- }
34
- return &bgEnv{t: t, store: store, dataDir: dir, srv: map[string]*api.Server{}}
31
+ e := &bgEnv{t: t, store: store, dataDir: dir, srv: map[string]*api.Server{}}
32
+ e.inputPath("alice")
33
+ return e
35
34
  }
36
35
 
37
36
  func (e *bgEnv) call(auth api.Authenticator, method, params string) (string, map[string]any) {
@@ -128,8 +127,28 @@ func TestBackgroundStatusReportsLimitsAndWorker(t *testing.T) {
128
127
  }
129
128
  }
130
129
 
131
- func (e *bgEnv) submitParams(key string) string {
132
- p, err := json.Marshal(map[string]string{"key": key, "name": "a.txt", "input": filepath.Join(e.dataDir, "input.txt")})
130
+ func (e *bgEnv) inputPath(user string) string {
131
+ inbox, err := BackgroundInbox(e.dataDir, background.Submitter{User: user, UID: lookupSubmitterUID(user)})
132
+ if err != nil {
133
+ e.t.Fatal(err)
134
+ }
135
+ if err = os.MkdirAll(inbox, 0700); err != nil {
136
+ e.t.Fatal(err)
137
+ }
138
+ input := filepath.Join(inbox, "input.txt")
139
+ if _, err = os.Stat(input); os.IsNotExist(err) {
140
+ if err = os.WriteFile(input, []byte("hello"), 0600); err != nil {
141
+ e.t.Fatal(err)
142
+ }
143
+ }
144
+ return input
145
+ }
146
+ func (e *bgEnv) submitParams(key string, users ...string) string {
147
+ user := "alice"
148
+ if len(users) > 0 {
149
+ user = users[0]
150
+ }
151
+ p, err := json.Marshal(map[string]string{"key": key, "name": "a.txt", "input": e.inputPath(user)})
133
152
  if err != nil {
134
153
  e.t.Fatal(err)
135
154
  }
@@ -155,7 +174,7 @@ func TestBackgroundSubmitImportsAppInputAndIsIdempotent(t *testing.T) {
155
174
  if data(again)["duplicate"] != true || data(again)["job"].(map[string]any)["id"] != job["id"] {
156
175
  t.Fatal(again)
157
176
  }
158
- if err := os.WriteFile(filepath.Join(e.dataDir, "input.txt"), []byte("other"), 0600); err != nil {
177
+ if err := os.WriteFile(e.inputPath("alice"), []byte("other"), 0600); err != nil {
159
178
  t.Fatal(err)
160
179
  }
161
180
  if code, _ := e.call(alice, "bg.jobs.submit", params); code != "conflict" {
@@ -189,7 +208,7 @@ func TestBackgroundSubmitRejectsExternalAndOversizedInput(t *testing.T) {
189
208
  t.Fatalf("%s: %s", input, code)
190
209
  }
191
210
  }
192
- if err := os.WriteFile(filepath.Join(e.dataDir, "input.txt"), make([]byte, background.MaxInputBytes+1), 0600); err != nil {
211
+ if err := os.WriteFile(e.inputPath("alice"), make([]byte, background.MaxInputBytes+1), 0600); err != nil {
193
212
  t.Fatal(err)
194
213
  }
195
214
  if code, _ := e.call(alice, "bg.jobs.submit", e.submitParams("big")); code != "limit-exceeded" {
@@ -7,7 +7,7 @@
7
7
  "name": "native-dsm-vue-app",
8
8
  "dependencies": {
9
9
  "@lucide/vue": "1.48.0",
10
- "@synopackageland/sdk": "0.38.2",
10
+ "@synopackageland/sdk": "0.40.0",
11
11
  "@synopackageland/ui": "0.16.1",
12
12
  "vue": "3.5.43"
13
13
  },
@@ -1152,13 +1152,13 @@
1152
1152
  }
1153
1153
  },
1154
1154
  "node_modules/@synopackageland/sdk": {
1155
- "version": "0.38.2",
1156
- "resolved": "https://registry.npmjs.org/@synopackageland/sdk/-/sdk-0.38.2.tgz",
1157
- "integrity": "sha512-ZSZKn5T8TbOpUF+PYMgq0IU+czw9HG4O9C4JuCeaT8Xoa3gMNiHbv6N1+VdRfxm46GtbtFNHzdQBoZ3mbmvKKg==",
1155
+ "version": "0.40.0",
1156
+ "resolved": "https://registry.npmjs.org/@synopackageland/sdk/-/sdk-0.40.0.tgz",
1157
+ "integrity": "sha512-eV/CvRqxHSIMQm38EJI7dYxqzLEKVt3ExFb69W8w2eLMEvCH6PfwCJbari9Fg2W8zV1Q5vvPsH5/THJF/rJGJA==",
1158
1158
  "license": "MIT",
1159
1159
  "peerDependencies": {
1160
1160
  "@lucide/vue": "^1.48.0",
1161
- "@synopackageland/ui": ">=0.10.1 <0.18.0",
1161
+ "@synopackageland/ui": ">=0.10.1 <0.19.0",
1162
1162
  "vue": "^3.5.0"
1163
1163
  },
1164
1164
  "peerDependenciesMeta": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "@lucide/vue": "1.48.0",
11
- "@synopackageland/sdk": "0.38.2",
11
+ "@synopackageland/sdk": "0.40.0",
12
12
  "@synopackageland/ui": "0.16.1",
13
13
  "vue": "3.5.43"
14
14
  },
@@ -17,7 +17,7 @@ DSM main menu → DSM window (ui/app.js)
17
17
 
18
18
  ## Requests and long work
19
19
 
20
- SDK/App 0.38.2 defaults to 300-second requests and a 302-second browser wait.
20
+ SDK/App 0.40.0 defaults to 300-second requests and a 302-second browser wait.
21
21
  Explicit `backend.timeout_seconds` and earlier parent deadlines still apply;
22
22
  rebuild the App to use the new SDK. For long NAS work, enable the existing jobs
23
23
  capability: return admission and the original job ID promptly, execute in the
@@ -27,7 +27,7 @@ last queued/running state intact. After a lost admission reply, reuse the same
27
27
  idempotency key and input instead of replaying a mutation with a new key.
28
28
  Jobs default to 30 minutes; approved executor limits remain separate. ctl keeps
29
29
  its 62-second transport default; use an explicit `--timeout` or an existing
30
- nonblocking job. See the SDK's [request deadline contract](https://github.com/synopackageland/syno-pkg-land-sdk/blob/v0.38.2/docs/request-deadlines.md).
30
+ nonblocking job. See the SDK's [request deadline contract](https://github.com/synopackageland/syno-pkg-land-sdk/blob/v0.40.0/docs/request-deadlines.md).
31
31
 
32
32
  ## Quick start
33
33
 
@@ -49,10 +49,10 @@
49
49
  "license": "MIT"
50
50
  },
51
51
  "vendor/@synopackageland/app": {
52
- "version": "0.0.0",
52
+ "version": "0.40.0",
53
53
  "license": "MIT",
54
54
  "engines": {
55
- "node": ">=18"
55
+ "node": ">=22"
56
56
  }
57
57
  }
58
58
  }
@@ -49,7 +49,7 @@ backend:
49
49
  runtime: node
50
50
  dir: backend/app
51
51
  entry: main.js
52
- sdk: 0.38.2
52
+ sdk: 0.40.0
53
53
  node: { package: Node.js_v22 }
54
54
 
55
55
  ui:
@@ -2,10 +2,11 @@
2
2
  //
3
3
  // The browser picks the destination with the SDK's Background saves page and
4
4
  // sends its delegation ID; the executor checks that it is the caller's grant
5
- // and writes the file as the caller, never as root. A save that failed may be
6
- // repeated with the same job ID: the same file is sent again, and the executor
7
- // copies it at most once.
8
- import { mkdirSync, statSync, writeFileSync } from 'node:fs'
5
+ // and writes the file as the caller, never as root. An admitted or uncertain
6
+ // save retries the same file and job ID; the executor copies it at most once.
7
+ // A new source rejected because its delegation is denied is removed.
8
+ import { createHash } from 'node:crypto'
9
+ import { mkdirSync, statSync, writeFileSync, unlinkSync } from 'node:fs'
9
10
  import { join } from 'node:path'
10
11
  import { AppError, type App, type Context } from '@synopackageland/app'
11
12
  import { strings } from './params.js'
@@ -18,18 +19,29 @@ export function register(app: App, cfg: Config): void {
18
19
  app.method('report.save', (ctx, params) => save(cfg, ctx, params))
19
20
  }
20
21
 
21
- function save(cfg: Config, ctx: Context, params: unknown) {
22
+ async function save(cfg: Config, ctx: Context, params: unknown) {
22
23
  const { delegation_id, job_id } = strings(params, 'delegation_id', 'job_id')
23
- if (!JOB_ID.test(job_id)) throw AppError.badRequest("job_id must be 1 to 128 letters, digits, '.', '_' or '-'")
24
+ if (!JOB_ID.test(job_id) || !JOB_ID.test(delegation_id)) throw AppError.badRequest("job_id must be 1 to 128 letters, digits, '.', '_' or '-'")
24
25
  const name = `report-${job_id}.txt`
25
26
  // The runtime's delegate service moves files from here.
26
- const staging = join(cfg.dataDir, 'executor-staging')
27
+ const owner = createHash('sha256').update(`${ctx.user}\0${delegation_id}`).digest('hex')
28
+ const scopedJob = createHash('sha256').update(`${ctx.user}\0${delegation_id}\0${job_id}`).digest('hex') + `-${owner.slice(0, 32)}`
29
+ const relative = `owners/${owner}/${name}`
30
+ const staging = join(cfg.dataDir, 'executor-staging', 'owners', owner)
27
31
  mkdirSync(staging, { recursive: true, mode: 0o700 })
28
32
  const path = join(staging, name)
33
+ let created = false
29
34
  try {
30
35
  writeFileSync(path, `Report for ${ctx.user}, ${new Date().toISOString().replace(/\.\d+Z$/, 'Z')}\n`, { flag: 'wx', mode: 0o600 })
36
+ created = true
31
37
  } catch (e) {
32
38
  if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
33
39
  }
34
- return service(ctx, 'delegate.move_from_staging', { delegation_id, job_id, staging: name, name, size: statSync(path).size })
40
+ try {
41
+ return await service(ctx, 'delegate.move_from_staging', { delegation_id, job_id: scopedJob, staging: relative, name, size: statSync(path).size })
42
+ } catch (e) {
43
+ // A transport failure may already have been admitted; keep its source.
44
+ if (created && e instanceof AppError && e.code === 'DELEGATION_DENIED') unlinkSync(path)
45
+ throw e
46
+ }
35
47
  }
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict'
2
- import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, readdirSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { afterEach, beforeEach, test } from 'node:test'
@@ -23,23 +23,32 @@ const save = (user = 'alice', move: Services[string] = () => SAVED) =>
23
23
  test('report.save moves a private report to the caller\'s destination', async () => {
24
24
  const reply = await save()
25
25
  assert.deepEqual(reply.data, SAVED)
26
- const path = join(staging, 'report-job-1.txt')
26
+ const path = join(staging, (reply.services[0][1] as {staging: string}).staging)
27
27
  assert.match(readFileSync(path, 'utf8'), /alice/)
28
28
  assert.equal(statSync(path).mode & 0o777, 0o600)
29
- const move = { delegation_id: 'd1', job_id: 'job-1', staging: 'report-job-1.txt', name: 'report-job-1.txt', size: statSync(path).size }
29
+ const move = { delegation_id: 'd1', job_id: (reply.services[0][1] as {job_id: string}).job_id, staging: (reply.services[0][1] as {staging: string}).staging, name: 'report-job-1.txt', size: statSync(path).size }
30
30
  assert.deepEqual(reply.services, [['delegate.move_from_staging', move]])
31
+ assert.ok(move.job_id.endsWith(`-${move.staging.split('/')[1].slice(0, 32)}`))
31
32
  })
32
33
 
33
- test('a repeated report.save sends the same file again', async () => {
34
+ test('retries reuse only the same owner and grant source', async () => {
34
35
  const first = (await save()).services
35
- const report = readFileSync(join(staging, 'report-job-1.txt'), 'utf8')
36
- assert.deepEqual((await save('bob')).services, first)
37
- assert.equal(readFileSync(join(staging, 'report-job-1.txt'), 'utf8'), report)
36
+ const params = first[0][1] as {staging: string; job_id: string}
37
+ const report = readFileSync(join(staging, params.staging), 'utf8')
38
+ assert.deepEqual((await save()).services, first)
39
+ const other = (await save('bob')).services[0][1] as {staging: string; job_id: string}
40
+ assert.notEqual(other.staging, params.staging)
41
+ assert.notEqual(other.job_id, params.job_id)
42
+ assert.match(readFileSync(join(staging, other.staging), 'utf8'), /bob/)
43
+ assert.equal(readFileSync(join(staging, params.staging), 'utf8'), report)
38
44
  })
39
45
 
40
- test('report.save passes on the executor\'s refusal', async () => {
41
- const reply = await save('alice', () => { throw new Refusal('DELEGATION_DENIED', 'The destination is no longer allowed.') })
46
+ test('definitive rejection removes new source but a timeout keeps it', async () => {
47
+ const reply = await save('alice', () => { throw new Refusal('DELEGATION_DENIED', 'The destination is no longer allowed') })
42
48
  assert.equal(reply.error?.code, 'DELEGATION_DENIED')
49
+ assert.equal(readdirSync(staging, { recursive: true }).filter(p => String(p).endsWith('.txt')).length, 0)
50
+ const unknown = await save('alice', () => { throw new Refusal('EXECUTOR_TIMEOUT', 'The outcome is unknown') })
51
+ assert.equal(existsSync(join(staging, (unknown.services[0][1] as {staging: string}).staging)), true)
43
52
  })
44
53
 
45
54
  test('report.save refuses invalid params before writing', async () => {
@@ -17,7 +17,7 @@ DSM main menu → DSM window (ui/app.js)
17
17
 
18
18
  ## Requests and long work
19
19
 
20
- SDK/App 0.38.2 defaults to 300-second requests and a 302-second browser wait.
20
+ SDK/App 0.40.0 defaults to 300-second requests and a 302-second browser wait.
21
21
  Explicit `backend.timeout_seconds` and earlier parent deadlines still apply;
22
22
  rebuild the App to use the new SDK. For long NAS work, enable the existing jobs
23
23
  capability: return admission and the original job ID promptly, execute in the
@@ -27,7 +27,7 @@ last queued/running state intact. After a lost admission reply, reuse the same
27
27
  idempotency key and input instead of replaying a mutation with a new key.
28
28
  Jobs default to 30 minutes; approved executor limits remain separate. ctl keeps
29
29
  its 62-second transport default; use an explicit `--timeout` or an existing
30
- nonblocking job. See the SDK's [request deadline contract](https://github.com/synopackageland/syno-pkg-land-sdk/blob/v0.38.2/docs/request-deadlines.md).
30
+ nonblocking job. See the SDK's [request deadline contract](https://github.com/synopackageland/syno-pkg-land-sdk/blob/v0.40.0/docs/request-deadlines.md).
31
31
 
32
32
  ## Quick start
33
33
 
@@ -47,7 +47,7 @@ backend:
47
47
  runtime: python
48
48
  dir: backend/app
49
49
  entry: main.py
50
- sdk: 0.38.2
50
+ sdk: 0.40.0
51
51
 
52
52
  ui:
53
53
  # Non-UI-SDK frontends open a full page; Portal is a separate opt-in.
@@ -2,11 +2,12 @@
2
2
 
3
3
  The browser picks the destination with the SDK's Background saves page and
4
4
  sends its delegation ID; the executor checks that it is the caller's grant
5
- and writes the file as the caller, never as root. A save that failed may be
6
- repeated with the same job ID: the same file is sent again, and the executor
7
- copies it at most once.
5
+ and writes the file as the caller, never as root. An admitted or uncertain
6
+ save retries the same file and job ID; the executor copies it at most once.
7
+ A new source rejected because its delegation is denied is removed.
8
8
  """
9
9
 
10
+ import hashlib
10
11
  import os
11
12
  import re
12
13
  import time
@@ -24,19 +25,30 @@ def register(app, cfg):
24
25
 
25
26
  def save(cfg, ctx, params):
26
27
  p = fields(params, delegation_id=str, job_id=str)
27
- if not JOB_ID.match(p["job_id"]):
28
+ if not JOB_ID.match(p["job_id"]) or not JOB_ID.match(p["delegation_id"]):
28
29
  raise Error.bad_request("job_id must be 1 to 128 letters, digits, '.', '_' or '-'")
29
30
  name = "report-%s.txt" % p["job_id"]
30
31
  # The runtime's delegate service moves files from here.
31
- staging = os.path.join(cfg.data_dir, "executor-staging")
32
+ owner = hashlib.sha256((ctx.user + "\0" + p["delegation_id"]).encode("utf-8")).hexdigest()
33
+ job_id = hashlib.sha256((ctx.user + "\0" + p["delegation_id"] + "\0" + p["job_id"]).encode("utf-8")).hexdigest() + "-" + owner[:32]
34
+ relative = "owners/%s/%s" % (owner, name)
35
+ staging = os.path.join(cfg.data_dir, "executor-staging", "owners", owner)
32
36
  os.makedirs(staging, mode=0o700, exist_ok=True)
33
37
  path = os.path.join(staging, name)
38
+ created = False
34
39
  try:
35
40
  fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
36
41
  except FileExistsError:
37
42
  pass
38
43
  else:
44
+ created = True
39
45
  with os.fdopen(fd, "w", encoding="utf-8") as f:
40
46
  f.write("Report for %s, %s\n" % (ctx.user, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())))
41
- move = {"delegation_id": p["delegation_id"], "job_id": p["job_id"], "staging": name, "name": name, "size": os.path.getsize(path)}
42
- return ctx.runtime("delegate.move_from_staging", move)
47
+ move = {"delegation_id": p["delegation_id"], "job_id": job_id, "staging": relative, "name": name, "size": os.path.getsize(path)}
48
+ try:
49
+ return ctx.runtime("delegate.move_from_staging", move)
50
+ except Error as err:
51
+ # A transport failure may already have been admitted; keep its source.
52
+ if created and err.code == "DELEGATION_DENIED":
53
+ os.unlink(path)
54
+ raise
@@ -24,27 +24,37 @@ class DelegateTest(unittest.TestCase):
24
24
  def test_save_moves_a_private_report_to_the_callers_destination(self):
25
25
  reply = self.save()
26
26
  self.assertEqual(reply.data, SAVED)
27
- path = os.path.join(self.staging, "report-job-1.txt")
27
+ path = os.path.join(self.staging, reply.services[0][1]["staging"])
28
28
  with open(path, encoding="utf-8") as f:
29
29
  self.assertIn("alice", f.read())
30
30
  self.assertEqual(stat.S_IMODE(os.stat(path).st_mode), 0o600)
31
- move = {"delegation_id": "d1", "job_id": "job-1", "staging": "report-job-1.txt", "name": "report-job-1.txt", "size": os.path.getsize(path)}
31
+ move = {"delegation_id": "d1", "job_id": reply.services[0][1]["job_id"], "staging": reply.services[0][1]["staging"], "name": "report-job-1.txt", "size": os.path.getsize(path)}
32
32
  self.assertEqual(reply.services, [("delegate.move_from_staging", move)])
33
+ self.assertTrue(move["job_id"].endswith("-" + move["staging"].split("/")[1][:32]))
33
34
 
34
- def test_a_repeated_save_sends_the_same_file_again(self):
35
+ def test_retries_reuse_only_the_same_owners_file(self):
35
36
  first = self.save().services
36
- path = os.path.join(self.staging, "report-job-1.txt")
37
+ path = os.path.join(self.staging, first[0][1]["staging"])
37
38
  with open(path, encoding="utf-8") as f:
38
39
  report = f.read()
39
- self.assertEqual(self.save(user="bob").services, first)
40
+ self.assertEqual(self.save().services, first)
41
+ other = self.save(user="bob").services
42
+ self.assertNotEqual(other[0][1]["staging"], first[0][1]["staging"])
43
+ self.assertNotEqual(other[0][1]["job_id"], first[0][1]["job_id"])
44
+ with open(os.path.join(self.staging, other[0][1]["staging"]), encoding="utf-8") as f:
45
+ self.assertIn("bob", f.read())
40
46
  with open(path, encoding="utf-8") as f:
41
47
  self.assertEqual(f.read(), report)
42
48
 
43
- def test_save_passes_on_the_executors_refusal(self):
49
+ def test_definitive_refusal_cleans_new_source_but_timeout_keeps_retry(self):
44
50
  def denied(params):
45
- raise Error("DELEGATION_DENIED", "The destination is no longer allowed.")
46
-
51
+ raise Error("DELEGATION_DENIED", "The destination is no longer allowed")
47
52
  self.assertEqual(self.save(move=denied).error["code"], "DELEGATION_DENIED")
53
+ self.assertEqual([f for _, _, files in os.walk(self.staging) for f in files], [])
54
+ def timeout(params):
55
+ raise Error("EXECUTOR_TIMEOUT", "The outcome is unknown")
56
+ reply = self.save(move=timeout)
57
+ self.assertTrue(os.path.isfile(os.path.join(self.staging, reply.services[0][1]["staging"])))
48
58
 
49
59
  def test_save_refuses_invalid_params_before_writing(self):
50
60
  for params in [None, {"delegation_id": "d1"}, {"delegation_id": "d1", "job_id": "../x"}, {"delegation_id": "d1", "job_id": ""}, {"delegation_id": 1, "job_id": "j"}]:
@@ -17,7 +17,7 @@ DSM main menu → DSM window (ui/app.js)
17
17
 
18
18
  ## Requests and long work
19
19
 
20
- SDK/App 0.38.2 defaults to 300-second requests and a 302-second browser wait.
20
+ SDK/App 0.40.0 defaults to 300-second requests and a 302-second browser wait.
21
21
  Explicit `backend.timeout_seconds` and earlier parent deadlines still apply;
22
22
  rebuild the App to use the new SDK. For long NAS work, enable the existing jobs
23
23
  capability: return admission and the original job ID promptly, execute in the
@@ -27,7 +27,7 @@ last queued/running state intact. After a lost admission reply, reuse the same
27
27
  idempotency key and input instead of replaying a mutation with a new key.
28
28
  Jobs default to 30 minutes; approved executor limits remain separate. ctl keeps
29
29
  its 62-second transport default; use an explicit `--timeout` or an existing
30
- nonblocking job. See the SDK's [request deadline contract](https://github.com/synopackageland/syno-pkg-land-sdk/blob/v0.38.2/docs/request-deadlines.md).
30
+ nonblocking job. See the SDK's [request deadline contract](https://github.com/synopackageland/syno-pkg-land-sdk/blob/v0.40.0/docs/request-deadlines.md).
31
31
 
32
32
  ## Quick start
33
33
 
@@ -104,8 +104,8 @@ dependencies = [
104
104
 
105
105
  [[package]]
106
106
  name = "syno-pkg-land-app"
107
- version = "0.38.2"
108
- source = "git+https://github.com/synopackageland/syno-pkg-land-sdk?tag=v0.38.2#103014ec9ec31e91062053d7678b4d2b115be71e"
107
+ version = "0.40.0"
108
+ source = "git+https://github.com/synopackageland/syno-pkg-land-sdk?tag=v0.40.0#e96f3f0eb6ec5bbb171fb2205f6669d3c75e3913"
109
109
  dependencies = [
110
110
  "libc",
111
111
  "serde",
@@ -9,7 +9,7 @@ libc = "0.2"
9
9
  serde = { version = "1", features = ["derive"] }
10
10
  serde_json = "1"
11
11
  # The SDK version is the tag; `synopkgland check` reads it from Cargo.lock.
12
- syno-pkg-land-app = { git = "https://github.com/synopackageland/syno-pkg-land-sdk", tag = "v0.38.2" }
12
+ syno-pkg-land-app = { git = "https://github.com/synopackageland/syno-pkg-land-sdk", tag = "v0.40.0" }
13
13
 
14
14
  [profile.release]
15
15
  # A handler panic becomes an "internal" reply; abort would end the App without one.
@@ -0,0 +1,203 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "app"
7
+ version = "0.1.0"
8
+ dependencies = [
9
+ "libc",
10
+ "serde",
11
+ "serde_json",
12
+ "sha2",
13
+ "syno-pkg-land-app",
14
+ ]
15
+
16
+ [[package]]
17
+ name = "block-buffer"
18
+ version = "0.10.4"
19
+ source = "registry+https://github.com/rust-lang/crates.io-index"
20
+ checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
21
+ dependencies = [
22
+ "generic-array",
23
+ ]
24
+
25
+ [[package]]
26
+ name = "cfg-if"
27
+ version = "1.0.5"
28
+ source = "registry+https://github.com/rust-lang/crates.io-index"
29
+ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
30
+
31
+ [[package]]
32
+ name = "cpufeatures"
33
+ version = "0.2.17"
34
+ source = "registry+https://github.com/rust-lang/crates.io-index"
35
+ checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
36
+ dependencies = [
37
+ "libc",
38
+ ]
39
+
40
+ [[package]]
41
+ name = "crypto-common"
42
+ version = "0.1.7"
43
+ source = "registry+https://github.com/rust-lang/crates.io-index"
44
+ checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
45
+ dependencies = [
46
+ "generic-array",
47
+ "typenum",
48
+ ]
49
+
50
+ [[package]]
51
+ name = "digest"
52
+ version = "0.10.7"
53
+ source = "registry+https://github.com/rust-lang/crates.io-index"
54
+ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
55
+ dependencies = [
56
+ "block-buffer",
57
+ "crypto-common",
58
+ ]
59
+
60
+ [[package]]
61
+ name = "generic-array"
62
+ version = "0.14.7"
63
+ source = "registry+https://github.com/rust-lang/crates.io-index"
64
+ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
65
+ dependencies = [
66
+ "typenum",
67
+ "version_check",
68
+ ]
69
+
70
+ [[package]]
71
+ name = "itoa"
72
+ version = "1.0.18"
73
+ source = "registry+https://github.com/rust-lang/crates.io-index"
74
+ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
75
+
76
+ [[package]]
77
+ name = "libc"
78
+ version = "0.2.190"
79
+ source = "registry+https://github.com/rust-lang/crates.io-index"
80
+ checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
81
+
82
+ [[package]]
83
+ name = "memchr"
84
+ version = "2.8.3"
85
+ source = "registry+https://github.com/rust-lang/crates.io-index"
86
+ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
87
+
88
+ [[package]]
89
+ name = "proc-macro2"
90
+ version = "1.0.107"
91
+ source = "registry+https://github.com/rust-lang/crates.io-index"
92
+ checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
93
+ dependencies = [
94
+ "unicode-ident",
95
+ ]
96
+
97
+ [[package]]
98
+ name = "quote"
99
+ version = "1.0.47"
100
+ source = "registry+https://github.com/rust-lang/crates.io-index"
101
+ checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
102
+ dependencies = [
103
+ "proc-macro2",
104
+ ]
105
+
106
+ [[package]]
107
+ name = "serde"
108
+ version = "1.0.229"
109
+ source = "registry+https://github.com/rust-lang/crates.io-index"
110
+ checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
111
+ dependencies = [
112
+ "serde_core",
113
+ "serde_derive",
114
+ ]
115
+
116
+ [[package]]
117
+ name = "serde_core"
118
+ version = "1.0.229"
119
+ source = "registry+https://github.com/rust-lang/crates.io-index"
120
+ checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
121
+ dependencies = [
122
+ "serde_derive",
123
+ ]
124
+
125
+ [[package]]
126
+ name = "serde_derive"
127
+ version = "1.0.229"
128
+ source = "registry+https://github.com/rust-lang/crates.io-index"
129
+ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
130
+ dependencies = [
131
+ "proc-macro2",
132
+ "quote",
133
+ "syn",
134
+ ]
135
+
136
+ [[package]]
137
+ name = "serde_json"
138
+ version = "1.0.154"
139
+ source = "registry+https://github.com/rust-lang/crates.io-index"
140
+ checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
141
+ dependencies = [
142
+ "itoa",
143
+ "memchr",
144
+ "serde",
145
+ "serde_core",
146
+ "zmij",
147
+ ]
148
+
149
+ [[package]]
150
+ name = "sha2"
151
+ version = "0.10.9"
152
+ source = "registry+https://github.com/rust-lang/crates.io-index"
153
+ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
154
+ dependencies = [
155
+ "cfg-if",
156
+ "cpufeatures",
157
+ "digest",
158
+ ]
159
+
160
+ [[package]]
161
+ name = "syn"
162
+ version = "3.0.9"
163
+ source = "registry+https://github.com/rust-lang/crates.io-index"
164
+ checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
165
+ dependencies = [
166
+ "proc-macro2",
167
+ "quote",
168
+ "unicode-ident",
169
+ ]
170
+
171
+ [[package]]
172
+ name = "syno-pkg-land-app"
173
+ version = "0.40.0"
174
+ source = "git+https://github.com/synopackageland/syno-pkg-land-sdk?tag=v0.40.0#e96f3f0eb6ec5bbb171fb2205f6669d3c75e3913"
175
+ dependencies = [
176
+ "libc",
177
+ "serde",
178
+ "serde_json",
179
+ ]
180
+
181
+ [[package]]
182
+ name = "typenum"
183
+ version = "1.20.1"
184
+ source = "registry+https://github.com/rust-lang/crates.io-index"
185
+ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
186
+
187
+ [[package]]
188
+ name = "unicode-ident"
189
+ version = "1.0.27"
190
+ source = "registry+https://github.com/rust-lang/crates.io-index"
191
+ checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
192
+
193
+ [[package]]
194
+ name = "version_check"
195
+ version = "0.9.5"
196
+ source = "registry+https://github.com/rust-lang/crates.io-index"
197
+ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
198
+
199
+ [[package]]
200
+ name = "zmij"
201
+ version = "1.0.23"
202
+ source = "registry+https://github.com/rust-lang/crates.io-index"
203
+ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,17 @@
1
+ [package]
2
+ name = "app"
3
+ version = "0.1.0"
4
+ edition = "2024"
5
+ publish = false
6
+
7
+ [dependencies]
8
+ sha2 = "0.10"
9
+ libc = "0.2"
10
+ serde = { version = "1", features = ["derive"] }
11
+ serde_json = "1"
12
+ # The SDK version is the tag; `synopkgland check` reads it from Cargo.lock.
13
+ syno-pkg-land-app = { git = "https://github.com/synopackageland/syno-pkg-land-sdk", tag = "v0.40.0" }
14
+
15
+ [profile.release]
16
+ # A handler panic becomes an "internal" reply; abort would end the App without one.
17
+ panic = "unwind"
@@ -6,6 +6,7 @@
6
6
  use crate::routes::{Config, internal, utc_now};
7
7
  use serde::Deserialize;
8
8
  use serde_json::{Value, json};
9
+ use sha2::{Digest, Sha256};
9
10
  use std::fs::OpenOptions;
10
11
  use std::io::{ErrorKind, Write};
11
12
  use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
@@ -31,12 +32,25 @@ fn valid_job_id(id: &str) -> bool {
31
32
 
32
33
  fn save(cfg: &Config, ctx: &Context, params: &Params) -> Result<Value, Error> {
33
34
  let p: SaveReport = params.decode()?;
34
- if !valid_job_id(&p.job_id) {
35
+ if !valid_job_id(&p.job_id) || !valid_job_id(&p.delegation_id) {
35
36
  return Err(Error::bad_request(
36
37
  "job_id must be 1 to 128 letters, digits, '.', '_' or '-'",
37
38
  ));
38
39
  }
39
- let staging = cfg.data_dir.join("executor-staging");
40
+ let owner = format!(
41
+ "{:x}",
42
+ Sha256::digest(format!("{}\0{}", ctx.user, p.delegation_id).as_bytes())
43
+ );
44
+ let job_id = format!(
45
+ "{:x}-{}",
46
+ Sha256::digest(format!("{}\0{}\0{}", ctx.user, p.delegation_id, p.job_id).as_bytes()),
47
+ &owner[..32]
48
+ );
49
+ let staging = cfg
50
+ .data_dir
51
+ .join("executor-staging")
52
+ .join("owners")
53
+ .join(&owner);
40
54
  std::fs::DirBuilder::new()
41
55
  .recursive(true)
42
56
  .mode(0o700)
@@ -44,7 +58,8 @@ fn save(cfg: &Config, ctx: &Context, params: &Params) -> Result<Value, Error> {
44
58
  .map_err(internal)?;
45
59
  let name = format!("report-{}.txt", p.job_id);
46
60
  let path = staging.join(&name);
47
- // A retried save sends the file it wrote the first time.
61
+ // An admitted or uncertain save retries the original source.
62
+ let mut created = false;
48
63
  match OpenOptions::new()
49
64
  .write(true)
50
65
  .create_new(true)
@@ -52,6 +67,7 @@ fn save(cfg: &Config, ctx: &Context, params: &Params) -> Result<Value, Error> {
52
67
  .open(&path)
53
68
  {
54
69
  Ok(mut file) => {
70
+ created = true;
55
71
  let report = format!("Report for {}, {}\n", ctx.user, utc_now());
56
72
  file.write_all(report.as_bytes()).map_err(internal)?;
57
73
  }
@@ -59,10 +75,20 @@ fn save(cfg: &Config, ctx: &Context, params: &Params) -> Result<Value, Error> {
59
75
  Err(e) => return Err(internal(e)),
60
76
  }
61
77
  let size = std::fs::metadata(&path).map_err(internal)?.len();
62
- ctx.runtime(
78
+ let result: Result<Value, Error> = ctx.runtime(
63
79
  "delegate.move_from_staging",
64
- &json!({"delegation_id": p.delegation_id, "job_id": p.job_id, "staging": name, "name": name, "size": size}),
65
- )
80
+ &json!({"delegation_id": p.delegation_id, "job_id": job_id, "staging": format!("owners/{owner}/{name}"), "name": name, "size": size}),
81
+ );
82
+ // A transport failure may already have been admitted; keep its source.
83
+ if created
84
+ && result
85
+ .as_ref()
86
+ .err()
87
+ .is_some_and(|e| e.code == "DELEGATION_DENIED")
88
+ {
89
+ std::fs::remove_file(&path).map_err(internal)?;
90
+ }
91
+ result
66
92
  }
67
93
 
68
94
  #[cfg(test)]
@@ -106,11 +132,26 @@ mod tests {
106
132
  let (_dir, cfg) = setup();
107
133
  let reply = save(&cfg, "alice", &moved());
108
134
  assert_eq!(reply.data, json!({"state": "queued"}));
109
- let path = cfg.data_dir.join("executor-staging/report-job-1.txt");
135
+ let path = cfg
136
+ .data_dir
137
+ .join("executor-staging")
138
+ .join(reply.services[0].1["staging"].as_str().unwrap());
110
139
  assert!(std::fs::read_to_string(&path).unwrap().contains("alice"));
111
140
  let meta = std::fs::metadata(&path).unwrap();
112
141
  assert_eq!(meta.permissions().mode() & 0o777, 0o600);
113
- let params = json!({"delegation_id": "d1", "job_id": "job-1", "staging": "report-job-1.txt", "name": "report-job-1.txt", "size": meta.len()});
142
+ let owner = reply.services[0].1["staging"]
143
+ .as_str()
144
+ .unwrap()
145
+ .split('/')
146
+ .nth(1)
147
+ .unwrap();
148
+ assert!(
149
+ reply.services[0].1["job_id"]
150
+ .as_str()
151
+ .unwrap()
152
+ .ends_with(&format!("-{}", &owner[..32]))
153
+ );
154
+ let params = json!({"delegation_id": "d1", "job_id": reply.services[0].1["job_id"], "staging": reply.services[0].1["staging"], "name": "report-job-1.txt", "size": meta.len()});
114
155
  assert_eq!(
115
156
  reply.services,
116
157
  vec![("delegate.move_from_staging".to_string(), params)]
@@ -118,29 +159,43 @@ mod tests {
118
159
  }
119
160
 
120
161
  #[test]
121
- fn a_repeated_save_sends_the_same_file_again() {
162
+ fn retries_reuse_only_the_same_owner_and_grant_source() {
122
163
  let (_dir, cfg) = setup();
123
164
  let first = save(&cfg, "alice", &moved()).services;
124
- let path = cfg.data_dir.join("executor-staging/report-job-1.txt");
165
+ let path = cfg
166
+ .data_dir
167
+ .join("executor-staging")
168
+ .join(first[0].1["staging"].as_str().unwrap());
125
169
  let report = std::fs::read_to_string(&path).unwrap();
126
- assert_eq!(save(&cfg, "bob", &moved()).services, first);
170
+ assert_eq!(save(&cfg, "alice", &moved()).services, first);
171
+ let other = save(&cfg, "bob", &moved()).services;
172
+ assert_ne!(other[0].1["staging"], first[0].1["staging"]);
173
+ assert_ne!(other[0].1["job_id"], first[0].1["job_id"]);
174
+ let other_path = cfg
175
+ .data_dir
176
+ .join("executor-staging")
177
+ .join(other[0].1["staging"].as_str().unwrap());
178
+ assert!(std::fs::read_to_string(other_path).unwrap().contains("bob"));
127
179
  assert_eq!(std::fs::read_to_string(&path).unwrap(), report);
128
180
  }
129
181
 
130
182
  #[test]
131
- fn save_passes_on_the_executors_refusal() {
183
+ fn rejection_removes_new_source_but_timeout_keeps_retry() {
132
184
  let (_dir, cfg) = setup();
133
- let services: Services = [(
134
- "delegate.move_from_staging",
135
- Box::new(|_: &Value| {
136
- Err(Error::new(
137
- "DELEGATION_DENIED",
138
- "The destination is no longer allowed.",
139
- ))
140
- }) as _,
141
- )]
142
- .into();
143
- assert_eq!(save(&cfg, "alice", &services).code(), "DELEGATION_DENIED");
185
+ for (code, retained) in [("DELEGATION_DENIED", false), ("EXECUTOR_TIMEOUT", true)] {
186
+ let services: Services = [(
187
+ "delegate.move_from_staging",
188
+ Box::new(move |_: &Value| Err(Error::new(code, "Save failed"))) as _,
189
+ )]
190
+ .into();
191
+ let reply = save(&cfg, "alice", &services);
192
+ assert_eq!(reply.code(), code);
193
+ let path = cfg
194
+ .data_dir
195
+ .join("executor-staging")
196
+ .join(reply.services[0].1["staging"].as_str().unwrap());
197
+ assert_eq!(path.exists(), retained);
198
+ }
144
199
  }
145
200
 
146
201
  #[test]