@synopackageland/templates 0.35.4 → 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 (42) hide show
  1. package/README.md +53 -17
  2. package/package.json +1 -1
  3. package/templates/_shared/e2e.config.ts +3 -0
  4. package/templates/_shared/package.json +14 -0
  5. package/templates/_shared/tests/e2e/smoke.e2e.ts +5 -0
  6. package/templates/binary/gitignore +4 -0
  7. package/templates/compose/dsm-package.yaml +1 -1
  8. package/templates/compose/gitignore +5 -0
  9. package/templates/native-go/README.md +2 -2
  10. package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
  11. package/templates/native-go/backend/go.mod +1 -1
  12. package/templates/native-go/backend/go.sum +2 -2
  13. package/templates/native-go/backend/internal/app/background.go +10 -12
  14. package/templates/native-go/backend/internal/app/background_inbox_test.go +37 -0
  15. package/templates/native-go/backend/internal/app/background_owner.go +14 -0
  16. package/templates/native-go/backend/internal/app/background_owner_test.go +2 -2
  17. package/templates/native-go/backend/internal/app/background_test.go +27 -8
  18. package/templates/native-go/gitignore +4 -0
  19. package/templates/native-go-vue/frontend/package-lock.json +5 -5
  20. package/templates/native-go-vue/frontend/package.json +1 -1
  21. package/templates/native-go-vue/gitignore +4 -0
  22. package/templates/native-node/README.md +2 -2
  23. package/templates/native-node/backend/package-lock.json +2 -2
  24. package/templates/native-node/dsm-package.yaml +1 -1
  25. package/templates/native-node/gitignore +4 -0
  26. package/templates/native-node-delegate/backend/src/delegate.ts +20 -8
  27. package/templates/native-node-delegate/backend/test/delegate.test.ts +18 -9
  28. package/templates/native-node-vue/gitignore +4 -0
  29. package/templates/native-python/README.md +2 -2
  30. package/templates/native-python/dsm-package.yaml +1 -1
  31. package/templates/native-python/gitignore +4 -0
  32. package/templates/native-python-delegate/backend/app/delegate.py +19 -7
  33. package/templates/native-python-delegate/backend/tests/test_delegate.py +18 -8
  34. package/templates/native-python-vue/gitignore +4 -0
  35. package/templates/native-rust/README.md +2 -2
  36. package/templates/native-rust/backend/Cargo.lock +2 -2
  37. package/templates/native-rust/backend/Cargo.toml +1 -1
  38. package/templates/native-rust/gitignore +4 -0
  39. package/templates/native-rust-delegate/backend/Cargo.lock +203 -0
  40. package/templates/native-rust-delegate/backend/Cargo.toml +17 -0
  41. package/templates/native-rust-delegate/backend/src/delegate.rs +78 -23
  42. package/templates/native-rust-vue/gitignore +4 -0
@@ -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 () => {
@@ -2,3 +2,7 @@ dist/
2
2
  node_modules/
3
3
  backend/build/
4
4
  backend/app/*.js
5
+
6
+ # Private NAS smoke sessions and credentials
7
+ .e2e/
8
+ .env
@@ -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.
@@ -1,2 +1,6 @@
1
1
  dist/
2
2
  __pycache__/
3
+
4
+ # Private NAS smoke sessions and credentials
5
+ .e2e/
6
+ .env
@@ -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"}]:
@@ -1,3 +1,7 @@
1
1
  dist/
2
2
  __pycache__/
3
3
  node_modules/
4
+
5
+ # Private NAS smoke sessions and credentials
6
+ .e2e/
7
+ .env
@@ -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.
@@ -1,2 +1,6 @@
1
1
  dist/
2
2
  backend/target/
3
+
4
+ # Private NAS smoke sessions and credentials
5
+ .e2e/
6
+ .env
@@ -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]
@@ -1,3 +1,7 @@
1
1
  dist/
2
2
  node_modules/
3
3
  backend/target/
4
+
5
+ # Private NAS smoke sessions and credentials
6
+ .e2e/
7
+ .env