@synopackageland/templates 0.9.5 → 0.9.7
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/README.md +27 -4
- package/package.json +2 -2
- package/templates/_shared/AGENTS.md +95 -18
- package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
- package/templates/native-go/backend/cmd/app/main.go +8 -1
- package/templates/native-go/backend/cmd/background-worker/main.go +5 -1
- package/templates/native-go/backend/go.mod +1 -1
- package/templates/native-go/backend/go.sum +2 -2
- package/templates/native-go/backend/internal/app/background.go +19 -8
- package/templates/native-go/backend/internal/app/background_owner_test.go +2 -2
- package/templates/native-go/backend/internal/app/background_test.go +24 -0
- package/templates/native-go/backend/internal/app/background_work.go +94 -0
- package/templates/native-go/backend/internal/app/background_work_test.go +95 -0
- package/templates/native-go/backend/internal/app/uninstall.go +19 -0
- package/templates/native-go/backend/internal/app/uninstall_test.go +33 -0
- package/templates/native-go/dsm-package.yaml +18 -0
- package/templates/native-go-delegate/backend/cmd/background-worker/main.go +1 -1
- package/templates/native-go-jobs/backend/internal/app/background_routes_test.go +4 -0
- package/templates/native-go-jobs/frontend/test/background-page.test.ts +21 -0
- package/templates/native-go-vue/frontend/package-lock.json +4 -4
- package/templates/native-go-vue/frontend/package.json +1 -1
- package/templates/native-rust/README.md +381 -0
- package/templates/native-rust/backend/Cargo.lock +125 -0
- package/templates/native-rust/backend/Cargo.toml +24 -0
- package/templates/native-rust/backend/THIRD_PARTY_NOTICES.txt +207 -0
- package/templates/native-rust/backend/permissions.json +5 -0
- package/templates/native-rust/backend/rust-toolchain.toml +4 -0
- package/templates/native-rust/backend/src/app/background/mod.rs +424 -0
- package/templates/native-rust/backend/src/app/background/owner.rs +77 -0
- package/templates/native-rust/backend/src/app/background/owner_tests.rs +174 -0
- package/templates/native-rust/backend/src/app/background/schedule.rs +126 -0
- package/templates/native-rust/backend/src/app/background/schedule_tests.rs +108 -0
- package/templates/native-rust/backend/src/app/background/tests.rs +505 -0
- package/templates/native-rust/backend/src/app/background/work.rs +78 -0
- package/templates/native-rust/backend/src/app/background/work_tests.rs +123 -0
- package/templates/native-rust/backend/src/app/background_routes.rs +22 -0
- package/templates/native-rust/backend/src/app/delegate_config.rs +188 -0
- package/templates/native-rust/backend/src/app/delegate_routes.rs +22 -0
- package/templates/native-rust/backend/src/app/identity.rs +56 -0
- package/templates/native-rust/backend/src/app/migrations.rs +79 -0
- package/templates/native-rust/backend/src/app/mod.rs +30 -0
- package/templates/native-rust/backend/src/app/portal.rs +317 -0
- package/templates/native-rust/backend/src/app/root_routes.rs +23 -0
- package/templates/native-rust/backend/src/app/routes.rs +315 -0
- package/templates/native-rust/backend/src/app/session_files.rs +19 -0
- package/templates/native-rust/backend/src/app/shared_folder.rs +383 -0
- package/templates/native-rust/backend/src/app/test_support.rs +200 -0
- package/templates/native-rust/backend/src/app/uninstall.rs +38 -0
- package/templates/native-rust/backend/src/bin/api.rs +72 -0
- package/templates/native-rust/backend/src/bin/background-worker.rs +37 -0
- package/templates/native-rust/backend/src/build_env.rs +155 -0
- package/templates/native-rust/backend/src/lib.rs +6 -0
- package/templates/native-rust/backend/src/permissions.rs +54 -0
- package/templates/native-rust/backend/src/process.rs +91 -0
- package/templates/native-rust/dsm-package.yaml +89 -0
- package/templates/native-rust/gitignore +2 -0
- package/templates/native-rust/i18n/glossary.json +663 -0
- package/templates/native-rust/i18n/messages/de.json +29 -0
- package/templates/native-rust/i18n/messages/en.json +29 -0
- package/templates/native-rust/i18n/messages/es.json +29 -0
- package/templates/native-rust/i18n/messages/fr.json +29 -0
- package/templates/native-rust/i18n/messages/it.json +29 -0
- package/templates/native-rust/i18n/messages/ja.json +29 -0
- package/templates/native-rust/i18n/messages/ko.json +29 -0
- package/templates/native-rust/i18n/messages/zh-CN.json +29 -0
- package/templates/native-rust/i18n/messages/zh-TW.json +29 -0
- package/templates/native-rust/ui/app.css +45 -0
- package/templates/native-rust/ui/app.js +111 -0
- package/templates/native-rust-delegate/backend/src/app/delegate_routes.rs +92 -0
- package/templates/native-rust-delegate/backend/src/bin/background-worker.rs +52 -0
- package/templates/native-rust-jobs/backend/src/app/background_routes.rs +109 -0
- package/templates/native-rust-root-ops/backend/src/app/root_routes.rs +475 -0
- package/templates/native-rust-vue/README.md +55 -0
- package/templates/native-rust-vue/gitignore +3 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
use std::sync::{Arc, Mutex};
|
|
2
|
+
|
|
3
|
+
use serde_json::Value;
|
|
4
|
+
use syno_pkg_land_sdk::BoxError;
|
|
5
|
+
use syno_pkg_land_sdk::api::{self, Caller, Server};
|
|
6
|
+
use syno_pkg_land_sdk::background::{
|
|
7
|
+
JOB_TIMEOUT, JobResult, MAX_QUEUED, Outcome, State, Store, WorkerInfo,
|
|
8
|
+
};
|
|
9
|
+
use syno_pkg_land_sdk::ctx::Context;
|
|
10
|
+
use syno_pkg_land_sdk::filestation::{Entry, Page};
|
|
11
|
+
use syno_pkg_land_sdk::http::Request;
|
|
12
|
+
use syno_pkg_land_sdk::time::Timestamp;
|
|
13
|
+
|
|
14
|
+
use super::*;
|
|
15
|
+
use crate::app::test_support::{Stub, TempDir, alice, api_request, bob, nobody, respond, rule};
|
|
16
|
+
|
|
17
|
+
const POLICY: &[u8] = br#"{"schema":1,"default":"administrators","methods":{}}"#;
|
|
18
|
+
|
|
19
|
+
#[derive(Default)]
|
|
20
|
+
pub(super) struct Seen {
|
|
21
|
+
pub calls: usize,
|
|
22
|
+
pub folder: String,
|
|
23
|
+
pub rel: String,
|
|
24
|
+
pub offset: i64,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// File Station stand-in that records what the routes asked of it.
|
|
28
|
+
pub(super) struct FakeFiles {
|
|
29
|
+
pub data: Mutex<Vec<u8>>,
|
|
30
|
+
pub err: Mutex<Option<api::Error>>,
|
|
31
|
+
pub page: Mutex<Page>,
|
|
32
|
+
pub seen: Mutex<Seen>,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
impl FakeFiles {
|
|
36
|
+
fn answer<T>(&self, ok: T) -> Result<T, BoxError> {
|
|
37
|
+
match self.err.lock().unwrap().clone() {
|
|
38
|
+
Some(e) => Err(e.into()),
|
|
39
|
+
None => Ok(ok),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
pub fn calls(&self) -> usize {
|
|
44
|
+
self.seen.lock().unwrap().calls
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
impl BackgroundFiles for FakeFiles {
|
|
49
|
+
fn browse(
|
|
50
|
+
&self,
|
|
51
|
+
_: &Context,
|
|
52
|
+
_: &Caller,
|
|
53
|
+
_: &Request,
|
|
54
|
+
folder: &str,
|
|
55
|
+
offset: i64,
|
|
56
|
+
) -> Result<Page, BoxError> {
|
|
57
|
+
{
|
|
58
|
+
let mut seen = self.seen.lock().unwrap();
|
|
59
|
+
seen.calls += 1;
|
|
60
|
+
seen.folder = folder.into();
|
|
61
|
+
seen.offset = offset;
|
|
62
|
+
}
|
|
63
|
+
self.answer(self.page.lock().unwrap().clone())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fn read_file(
|
|
67
|
+
&self,
|
|
68
|
+
_: &Context,
|
|
69
|
+
_: &Caller,
|
|
70
|
+
_: &Request,
|
|
71
|
+
folder: &str,
|
|
72
|
+
rel: &str,
|
|
73
|
+
) -> Result<Vec<u8>, BoxError> {
|
|
74
|
+
{
|
|
75
|
+
let mut seen = self.seen.lock().unwrap();
|
|
76
|
+
seen.calls += 1;
|
|
77
|
+
seen.folder = folder.into();
|
|
78
|
+
seen.rel = rel.into();
|
|
79
|
+
}
|
|
80
|
+
self.answer(self.data.lock().unwrap().clone())
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type Lookup = Arc<dyn Fn(&str) -> String + Send + Sync>;
|
|
85
|
+
|
|
86
|
+
pub(super) struct Env {
|
|
87
|
+
pub store: Arc<Store>,
|
|
88
|
+
pub files: Arc<FakeFiles>,
|
|
89
|
+
uid: Arc<Mutex<Lookup>>,
|
|
90
|
+
_dir: TempDir,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
impl Env {
|
|
94
|
+
pub fn new() -> Self {
|
|
95
|
+
let dir = TempDir::new();
|
|
96
|
+
let files = Arc::new(FakeFiles {
|
|
97
|
+
data: Mutex::new(b"hello".to_vec()),
|
|
98
|
+
err: Mutex::new(None),
|
|
99
|
+
page: Mutex::new(Page::default()),
|
|
100
|
+
seen: Mutex::new(Seen::default()),
|
|
101
|
+
});
|
|
102
|
+
Env {
|
|
103
|
+
store: Arc::new(Store::new(dir.path().join("background"))),
|
|
104
|
+
files,
|
|
105
|
+
uid: Arc::new(Mutex::new(Arc::new(|_: &str| "1024".to_string()))),
|
|
106
|
+
_dir: dir,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// Changes what the next request resolves DSM user names to.
|
|
111
|
+
pub fn set_uid(&self, lookup: impl Fn(&str) -> String + Send + Sync + 'static) {
|
|
112
|
+
*self.uid.lock().unwrap() = Arc::new(lookup);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
pub fn api(&self) -> Arc<BackgroundApi> {
|
|
116
|
+
let uid = self.uid.clone();
|
|
117
|
+
BackgroundApi::new(
|
|
118
|
+
self.store.clone(),
|
|
119
|
+
self.files.clone(),
|
|
120
|
+
Arc::new(move |name| (uid.lock().unwrap().clone())(name)),
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
pub fn call(&self, auth: &Stub, method: &str, params: &str) -> (String, Value) {
|
|
125
|
+
call_routes(self.api().routes(), auth, method, params)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
pub(super) fn call_routes(
|
|
130
|
+
routes: Vec<api::Route>,
|
|
131
|
+
auth: &Stub,
|
|
132
|
+
method: &str,
|
|
133
|
+
params: &str,
|
|
134
|
+
) -> (String, Value) {
|
|
135
|
+
let server = Server::new_with_permissions(auth.clone(), POLICY, routes)
|
|
136
|
+
.expect("the background routes and policy are valid");
|
|
137
|
+
respond(&server, &mut api_request(method, params))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
pub(super) fn data(out: &Value) -> &Value {
|
|
141
|
+
&out["data"]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
pub(super) fn id_params(id: &str) -> String {
|
|
145
|
+
format!(r#"{{"id":"{id}"}}"#)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
fn zeros() -> String {
|
|
149
|
+
"0".repeat(32)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
pub(super) fn sample_params(method: &str) -> String {
|
|
153
|
+
let id = id_params(&"a".repeat(32));
|
|
154
|
+
match method {
|
|
155
|
+
"bg.schedules.list" | "bg.status" | "bg.jobs.list" => "{}".into(),
|
|
156
|
+
"bg.schedules.create" => {
|
|
157
|
+
r#"{"key":"schedule","jobId":"missing","intervalSeconds":3600,"consent":true}"#.into()
|
|
158
|
+
}
|
|
159
|
+
"bg.schedules.disable" | "bg.schedules.delete" => r#"{"id":"missing"}"#.into(),
|
|
160
|
+
"bg.schedules.enable" => r#"{"id":"missing","consent":true}"#.into(),
|
|
161
|
+
"bg.jobs.purge" => r#"{"owner":{"user":"bob","uid":"1025"}}"#.into(),
|
|
162
|
+
"bg.browse" => r#"{"folder":"/share","offset":0}"#.into(),
|
|
163
|
+
"bg.jobs.submit" => r#"{"key":"k1","folder":"/share","relative":"a.txt"}"#.into(),
|
|
164
|
+
"bg.jobs.get" | "bg.jobs.cancel" | "bg.jobs.pause" | "bg.jobs.resume"
|
|
165
|
+
| "bg.jobs.result" => id,
|
|
166
|
+
other => panic!("no sample for {other}"),
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#[test]
|
|
171
|
+
fn sdk_policies_authenticate_every_route() {
|
|
172
|
+
let env = Env::new();
|
|
173
|
+
let routes = env.api().routes();
|
|
174
|
+
assert_eq!(routes.len(), 15);
|
|
175
|
+
for route in &routes {
|
|
176
|
+
let method = route.method();
|
|
177
|
+
assert_ne!(rule(route), "public", "{method} must not be public");
|
|
178
|
+
let params = sample_params(method);
|
|
179
|
+
let (code, _) = env.call(&bob(), method, ¶ms);
|
|
180
|
+
if method == "bg.jobs.purge" || method == "bg.schedules.enable" {
|
|
181
|
+
assert_eq!(code, "admin_required", "{method} as ordinary user");
|
|
182
|
+
} else {
|
|
183
|
+
assert!(
|
|
184
|
+
!["admin_required", "unsupported", "disabled"].contains(&code.as_str()),
|
|
185
|
+
"{method} unexpectedly blocked: {code}"
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
assert_eq!(
|
|
189
|
+
env.call(&nobody(), method, ¶ms).0,
|
|
190
|
+
"session_required",
|
|
191
|
+
"{method} without a session"
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#[test]
|
|
197
|
+
fn status_reports_limits_and_worker() {
|
|
198
|
+
let env = Env::new();
|
|
199
|
+
let (code, out) = env.call(&alice(), "bg.status", "{}");
|
|
200
|
+
let d = data(&out);
|
|
201
|
+
assert_eq!(code, "", "{out}");
|
|
202
|
+
assert_eq!(d["algorithm"], "sha256-v1");
|
|
203
|
+
assert!(d["worker"].is_null());
|
|
204
|
+
let limits = &d["limits"];
|
|
205
|
+
assert_eq!(limits["maxInputBytes"], 512 * 1024);
|
|
206
|
+
assert_eq!(limits["maxQueued"], 16);
|
|
207
|
+
assert_eq!(limits["maxTotalBytes"], 16 << 20);
|
|
208
|
+
assert_eq!(limits["jobTimeoutSeconds"], JOB_TIMEOUT.as_secs());
|
|
209
|
+
env.store
|
|
210
|
+
.heartbeat(
|
|
211
|
+
&Context::background(),
|
|
212
|
+
WorkerInfo {
|
|
213
|
+
pid: 9,
|
|
214
|
+
uid: 255853,
|
|
215
|
+
username: "pkg".into(),
|
|
216
|
+
..WorkerInfo::default()
|
|
217
|
+
},
|
|
218
|
+
)
|
|
219
|
+
.unwrap();
|
|
220
|
+
let (_, out) = env.call(&alice(), "bg.status", "{}");
|
|
221
|
+
let worker = &data(&out)["worker"];
|
|
222
|
+
assert_eq!(worker["alive"], true);
|
|
223
|
+
assert_eq!(worker["uid"], 255853);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
#[test]
|
|
227
|
+
fn submit_is_available_on_a_fresh_store() {
|
|
228
|
+
let env = Env::new();
|
|
229
|
+
let (code, _) = env.call(&alice(), "bg.jobs.submit", &sample_params("bg.jobs.submit"));
|
|
230
|
+
assert_eq!((code.as_str(), env.files.calls()), ("", 1));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#[test]
|
|
234
|
+
fn submit_imports_the_selected_file_and_is_idempotent() {
|
|
235
|
+
let env = Env::new();
|
|
236
|
+
let params = r#"{"key":"k1","folder":"/share","relative":"docs/a.txt"}"#;
|
|
237
|
+
let (code, out) = env.call(&alice(), "bg.jobs.submit", params);
|
|
238
|
+
let job = &data(&out)["job"];
|
|
239
|
+
assert_eq!(code, "", "{out}");
|
|
240
|
+
assert_eq!(job["state"], "queued");
|
|
241
|
+
assert_eq!(job["name"], "a.txt");
|
|
242
|
+
assert_eq!(data(&out)["duplicate"], false);
|
|
243
|
+
{
|
|
244
|
+
let seen = env.files.seen.lock().unwrap();
|
|
245
|
+
assert_eq!(
|
|
246
|
+
(seen.folder.as_str(), seen.rel.as_str()),
|
|
247
|
+
("/share", "docs/a.txt")
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
assert_eq!(job["submitter"]["user"], "alice");
|
|
251
|
+
assert_eq!(job["submitter"]["uid"], "1024");
|
|
252
|
+
let (_, again) = env.call(&alice(), "bg.jobs.submit", params);
|
|
253
|
+
assert_eq!(data(&again)["duplicate"], true);
|
|
254
|
+
assert_eq!(data(&again)["job"]["id"], job["id"]);
|
|
255
|
+
*env.files.data.lock().unwrap() = b"other".to_vec();
|
|
256
|
+
assert_eq!(env.call(&alice(), "bg.jobs.submit", params).0, "conflict");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#[test]
|
|
260
|
+
fn submit_passes_adapter_refusals_through() {
|
|
261
|
+
let env = Env::new();
|
|
262
|
+
*env.files.err.lock().unwrap() = Some(api::Error::new("limit-exceeded", "x"));
|
|
263
|
+
let (code, _) = env.call(&alice(), "bg.jobs.submit", &sample_params("bg.jobs.submit"));
|
|
264
|
+
assert_eq!(code, "limit-exceeded");
|
|
265
|
+
let jobs = env.store.list(&Context::background()).unwrap();
|
|
266
|
+
assert!(
|
|
267
|
+
jobs.is_empty(),
|
|
268
|
+
"a failed import must never enter the queue"
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#[test]
|
|
273
|
+
fn submit_validates_params() {
|
|
274
|
+
let env = Env::new();
|
|
275
|
+
for params in [
|
|
276
|
+
r#"{}"#,
|
|
277
|
+
r#"{"key":"k","folder":"/share","relative":"a","extra":1}"#,
|
|
278
|
+
r#"{"key":"bad key","folder":"/share","relative":"a"}"#,
|
|
279
|
+
r#"{"key":"k","folder":"/share","relative":""}"#,
|
|
280
|
+
] {
|
|
281
|
+
assert_eq!(
|
|
282
|
+
env.call(&alice(), "bg.jobs.submit", params).0,
|
|
283
|
+
"bad_request",
|
|
284
|
+
"{params}"
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#[test]
|
|
290
|
+
fn queue_full_has_a_stable_code() {
|
|
291
|
+
let env = Env::new();
|
|
292
|
+
for i in 0..MAX_QUEUED {
|
|
293
|
+
env.set_uid(move |_| (1024 + i / 2).to_string());
|
|
294
|
+
let params = format!(
|
|
295
|
+
r#"{{"key":"k{}","folder":"/share","relative":"a.txt"}}"#,
|
|
296
|
+
(b'a' + i as u8) as char
|
|
297
|
+
);
|
|
298
|
+
let (code, _) = env.call(&alice(), "bg.jobs.submit", ¶ms);
|
|
299
|
+
assert_eq!(code, "", "{i}");
|
|
300
|
+
}
|
|
301
|
+
env.set_uid(|_| "9999".into());
|
|
302
|
+
let params = r#"{"key":"more","folder":"/share","relative":"a.txt"}"#;
|
|
303
|
+
assert_eq!(env.call(&alice(), "bg.jobs.submit", params).0, "queue-full");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
fn submitted_id(env: &Env) -> String {
|
|
307
|
+
let (_, out) = env.call(
|
|
308
|
+
&alice(),
|
|
309
|
+
"bg.jobs.submit",
|
|
310
|
+
r#"{"key":"k1","folder":"/share","relative":"a.txt"}"#,
|
|
311
|
+
);
|
|
312
|
+
data(&out)["job"]["id"].as_str().unwrap().into()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#[test]
|
|
316
|
+
fn list_get_cancel_and_result() {
|
|
317
|
+
let env = Env::new();
|
|
318
|
+
let id = submitted_id(&env);
|
|
319
|
+
let params = id_params(&id);
|
|
320
|
+
|
|
321
|
+
let (_, out) = env.call(&alice(), "bg.jobs.list", "{}");
|
|
322
|
+
assert_eq!(data(&out)["jobs"].as_array().unwrap().len(), 1);
|
|
323
|
+
let (code, out) = env.call(&alice(), "bg.jobs.get", ¶ms);
|
|
324
|
+
assert_eq!(
|
|
325
|
+
(code.as_str(), &data(&out)["job"]["id"]),
|
|
326
|
+
("", &Value::from(id.clone()))
|
|
327
|
+
);
|
|
328
|
+
assert_eq!(
|
|
329
|
+
env.call(&alice(), "bg.jobs.result", ¶ms).0,
|
|
330
|
+
"not_ready",
|
|
331
|
+
"result of a queued job"
|
|
332
|
+
);
|
|
333
|
+
let ctx = Context::background();
|
|
334
|
+
env.store.claim(&ctx).unwrap();
|
|
335
|
+
let sum = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
|
336
|
+
env.store
|
|
337
|
+
.finish(
|
|
338
|
+
&ctx,
|
|
339
|
+
&id,
|
|
340
|
+
Outcome {
|
|
341
|
+
state: State::Succeeded,
|
|
342
|
+
result: Some(JobResult {
|
|
343
|
+
sha256: sum.into(),
|
|
344
|
+
bytes: 5,
|
|
345
|
+
}),
|
|
346
|
+
..Outcome::default()
|
|
347
|
+
},
|
|
348
|
+
)
|
|
349
|
+
.unwrap();
|
|
350
|
+
let (code, out) = env.call(&alice(), "bg.jobs.result", ¶ms);
|
|
351
|
+
let result = &data(&out)["result"];
|
|
352
|
+
assert_eq!(code, "", "{out}");
|
|
353
|
+
assert_eq!(result["sha256"], sum);
|
|
354
|
+
assert_eq!(result["bytes"], 5);
|
|
355
|
+
assert_eq!(result["worker"], "sha256-v1");
|
|
356
|
+
assert_eq!(result["jobId"], id.as_str());
|
|
357
|
+
assert_eq!(
|
|
358
|
+
env.call(&alice(), "bg.jobs.cancel", ¶ms).0,
|
|
359
|
+
"conflict",
|
|
360
|
+
"cancel after completion"
|
|
361
|
+
);
|
|
362
|
+
for method in ["bg.jobs.get", "bg.jobs.cancel", "bg.jobs.result"] {
|
|
363
|
+
assert_eq!(
|
|
364
|
+
env.call(&alice(), method, &id_params(&zeros())).0,
|
|
365
|
+
"not_found",
|
|
366
|
+
"{method} unknown id"
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#[test]
|
|
372
|
+
fn cancel_a_queued_job() {
|
|
373
|
+
let env = Env::new();
|
|
374
|
+
let id = submitted_id(&env);
|
|
375
|
+
let (code, out) = env.call(&alice(), "bg.jobs.cancel", &id_params(&id));
|
|
376
|
+
assert_eq!(code, "", "{out}");
|
|
377
|
+
assert_eq!(data(&out)["job"]["state"], "canceled");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
#[test]
|
|
381
|
+
fn pause_and_resume_a_queued_job() {
|
|
382
|
+
let env = Env::new();
|
|
383
|
+
let params = id_params(&submitted_id(&env));
|
|
384
|
+
for (method, state) in [("bg.jobs.pause", "paused"), ("bg.jobs.resume", "queued")] {
|
|
385
|
+
let (code, out) = env.call(&alice(), method, ¶ms);
|
|
386
|
+
assert_eq!(code, "", "{method}: {out}");
|
|
387
|
+
assert_eq!(data(&out)["job"]["state"], state, "{method}");
|
|
388
|
+
}
|
|
389
|
+
assert_eq!(env.call(&alice(), "bg.jobs.cancel", ¶ms).0, "");
|
|
390
|
+
for method in ["bg.jobs.pause", "bg.jobs.resume"] {
|
|
391
|
+
assert_eq!(
|
|
392
|
+
env.call(&alice(), method, ¶ms).0,
|
|
393
|
+
"conflict",
|
|
394
|
+
"{method} after cancel"
|
|
395
|
+
);
|
|
396
|
+
let unknown = id_params(&"0".repeat(32));
|
|
397
|
+
assert_eq!(
|
|
398
|
+
env.call(&alice(), method, &unknown).0,
|
|
399
|
+
"not_found",
|
|
400
|
+
"{method} unknown id"
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
#[test]
|
|
406
|
+
fn worker_alive_expires() {
|
|
407
|
+
let at = |ago: i128| WorkerInfo {
|
|
408
|
+
at: Timestamp::now().add_nanos(-ago * 1_000_000_000),
|
|
409
|
+
..WorkerInfo::default()
|
|
410
|
+
};
|
|
411
|
+
assert!(!worker_alive(&at(60), Timestamp::now()));
|
|
412
|
+
assert!(worker_alive(&at(0), Timestamp::now()));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
#[test]
|
|
416
|
+
fn browse_lists_through_the_callers_session() {
|
|
417
|
+
let env = Env::new();
|
|
418
|
+
*env.files.page.lock().unwrap() = Page {
|
|
419
|
+
files: vec![Entry {
|
|
420
|
+
name: "a.txt".into(),
|
|
421
|
+
path: "/share/a.txt".into(),
|
|
422
|
+
..Entry::default()
|
|
423
|
+
}],
|
|
424
|
+
total: 1,
|
|
425
|
+
..Page::default()
|
|
426
|
+
};
|
|
427
|
+
let (code, out) = env.call(&alice(), "bg.browse", r#"{"folder":"/share","offset":100}"#);
|
|
428
|
+
assert_eq!(code, "", "{out}");
|
|
429
|
+
{
|
|
430
|
+
let seen = env.files.seen.lock().unwrap();
|
|
431
|
+
assert_eq!((seen.folder.as_str(), seen.offset), ("/share", 100));
|
|
432
|
+
}
|
|
433
|
+
let files = data(&out)["files"].as_array().unwrap();
|
|
434
|
+
assert_eq!(files.len(), 1);
|
|
435
|
+
assert_eq!(files[0]["path"], "/share/a.txt");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
#[test]
|
|
439
|
+
fn browse_without_a_folder_lists_the_shares() {
|
|
440
|
+
let env = Env::new();
|
|
441
|
+
let (code, out) = env.call(&alice(), "bg.browse", "{}");
|
|
442
|
+
assert_eq!(code, "", "{out}");
|
|
443
|
+
let seen = env.files.seen.lock().unwrap();
|
|
444
|
+
assert_eq!((seen.folder.as_str(), seen.offset), ("", 0));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
#[test]
|
|
448
|
+
fn browse_validates_params_and_passes_refusals_through() {
|
|
449
|
+
let env = Env::new();
|
|
450
|
+
for params in [
|
|
451
|
+
r#"{"offset":-1}"#,
|
|
452
|
+
r#"{"folder":"/share","extra":1}"#,
|
|
453
|
+
r#"{"offset":"1"}"#,
|
|
454
|
+
] {
|
|
455
|
+
assert_eq!(
|
|
456
|
+
env.call(&alice(), "bg.browse", params).0,
|
|
457
|
+
"bad_request",
|
|
458
|
+
"{params}"
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
assert_eq!(
|
|
462
|
+
env.files.calls(),
|
|
463
|
+
0,
|
|
464
|
+
"invalid params must not reach the NAS"
|
|
465
|
+
);
|
|
466
|
+
*env.files.err.lock().unwrap() = Some(api::Error::new("denied", "no"));
|
|
467
|
+
assert_eq!(
|
|
468
|
+
env.call(&alice(), "bg.browse", r#"{"folder":"/share"}"#).0,
|
|
469
|
+
"denied"
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#[test]
|
|
474
|
+
fn list_without_jobs_is_an_empty_array() {
|
|
475
|
+
let env = Env::new();
|
|
476
|
+
let (_, out) = env.call(&alice(), "bg.jobs.list", "{}");
|
|
477
|
+
assert_eq!(data(&out)["jobs"], serde_json::json!([]));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
#[test]
|
|
481
|
+
fn base_name_follows_gos_path_base() {
|
|
482
|
+
for (path, name) in [
|
|
483
|
+
("docs/a.txt", "a.txt"),
|
|
484
|
+
("a.txt", "a.txt"),
|
|
485
|
+
("a/b/", "b"),
|
|
486
|
+
("", "."),
|
|
487
|
+
("///", "/"),
|
|
488
|
+
] {
|
|
489
|
+
assert_eq!(base_name(path), name, "{path:?}");
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
#[test]
|
|
494
|
+
fn background_error_maps_the_spool_and_keeps_context_errors() {
|
|
495
|
+
let code = |err: BoxError| {
|
|
496
|
+
api::find_error::<api::Error>(&*background_error(err))
|
|
497
|
+
.map(|e| e.code.clone())
|
|
498
|
+
.unwrap_or_default()
|
|
499
|
+
};
|
|
500
|
+
assert_eq!(code(Box::new(StoreError::Busy)), "jobs_busy");
|
|
501
|
+
assert_eq!(code(Box::new(StoreError::NotRunning)), "unavailable");
|
|
502
|
+
assert_eq!(code("disk on fire".into()), "unavailable");
|
|
503
|
+
let canceled = background_error(Box::new(CtxError::Canceled));
|
|
504
|
+
assert!(api::find_error::<CtxError>(&*canceled).is_some());
|
|
505
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
//! The App's own sha256-v1 Work, resumable after a pause or a package stop.
|
|
2
|
+
|
|
3
|
+
use std::io::{self, ErrorKind, Read};
|
|
4
|
+
|
|
5
|
+
use serde::{Deserialize, Serialize};
|
|
6
|
+
use syno_pkg_land_sdk::BoxError;
|
|
7
|
+
use syno_pkg_land_sdk::background::{JobResult, Progress, Task};
|
|
8
|
+
use syno_pkg_land_sdk::ctx::Context;
|
|
9
|
+
use syno_pkg_land_sdk::sha256::{Sha256, hex};
|
|
10
|
+
|
|
11
|
+
/// Spaces [`hash_work`]'s checkpoints; a resumed job repeats at most this
|
|
12
|
+
/// much hashing. A Work over larger input saves less often.
|
|
13
|
+
pub(super) const HASH_CHECKPOINT_EVERY: i64 = 64 << 10;
|
|
14
|
+
|
|
15
|
+
/// The checkpoint: the bytes already hashed and the hash state after them.
|
|
16
|
+
#[derive(Serialize, Deserialize)]
|
|
17
|
+
struct HashState {
|
|
18
|
+
offset: i64,
|
|
19
|
+
state: String,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Reports progress as it reads and checkpoints the hash state, so a job
|
|
23
|
+
/// paused by its owner or by a package stop resumes where it left off
|
|
24
|
+
/// instead of from the start.
|
|
25
|
+
pub fn hash_work(ctx: &Context, t: &mut Task<'_>) -> Result<JobResult, BoxError> {
|
|
26
|
+
let (mut h, mut done) = resume_hash(&t.job.checkpoint, t.input)?;
|
|
27
|
+
let (total, mut saved) = (t.job.input_bytes, done);
|
|
28
|
+
let mut buf = vec![0; 32 * 1024];
|
|
29
|
+
loop {
|
|
30
|
+
if let Some(e) = ctx.err() {
|
|
31
|
+
return Err(Box::new(e));
|
|
32
|
+
}
|
|
33
|
+
let n = match t.input.read(&mut buf) {
|
|
34
|
+
Ok(n) => n,
|
|
35
|
+
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
|
|
36
|
+
Err(e) => return Err(Box::new(e)),
|
|
37
|
+
};
|
|
38
|
+
h.update(&buf[..n]);
|
|
39
|
+
done += n as i64;
|
|
40
|
+
t.report(Progress {
|
|
41
|
+
bytes: done,
|
|
42
|
+
total_bytes: total,
|
|
43
|
+
..Progress::default()
|
|
44
|
+
});
|
|
45
|
+
if n == 0 {
|
|
46
|
+
return Ok(JobResult {
|
|
47
|
+
sha256: hex(&h.finish()),
|
|
48
|
+
bytes: done,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
// The hash state saves only after whole blocks; a short read waits for the next.
|
|
52
|
+
if done - saved >= HASH_CHECKPOINT_EVERY
|
|
53
|
+
&& let Some(state) = save_hash(&h, done)
|
|
54
|
+
{
|
|
55
|
+
t.checkpoint(&state)?;
|
|
56
|
+
saved = done;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
pub(super) fn save_hash(h: &Sha256, offset: i64) -> Option<String> {
|
|
62
|
+
let state = h.save()?;
|
|
63
|
+
serde_json::to_string(&HashState { offset, state }).ok()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Continues the hash a checkpoint saved, past the input it covered.
|
|
67
|
+
fn resume_hash(checkpoint: &str, input: &mut dyn Read) -> Result<(Sha256, i64), BoxError> {
|
|
68
|
+
if checkpoint.is_empty() {
|
|
69
|
+
return Ok((Sha256::new(), 0));
|
|
70
|
+
}
|
|
71
|
+
let saved: HashState = serde_json::from_str(checkpoint)?;
|
|
72
|
+
let h = Sha256::restore(&saved.state).ok_or("checkpoint: invalid hash state")?;
|
|
73
|
+
let offset = u64::try_from(saved.offset).map_err(|_| "checkpoint: negative offset")?;
|
|
74
|
+
if io::copy(&mut input.take(offset), &mut io::sink())? != offset {
|
|
75
|
+
return Err("checkpoint: input shorter than its offset".into());
|
|
76
|
+
}
|
|
77
|
+
Ok((h, saved.offset))
|
|
78
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
use std::io::{Read, empty};
|
|
2
|
+
use std::sync::Arc;
|
|
3
|
+
use std::time::Duration;
|
|
4
|
+
|
|
5
|
+
use syno_pkg_land_sdk::background::{
|
|
6
|
+
Job, Runner, State, Store, SubmitRequest, Submitter, Task, WorkerInfo,
|
|
7
|
+
};
|
|
8
|
+
use syno_pkg_land_sdk::ctx::{Cancel, Context};
|
|
9
|
+
use syno_pkg_land_sdk::sha256::{Sha256, hex_digest};
|
|
10
|
+
|
|
11
|
+
use super::work::{HASH_CHECKPOINT_EVERY, hash_work, save_hash};
|
|
12
|
+
use crate::app::test_support::TempDir;
|
|
13
|
+
|
|
14
|
+
#[test]
|
|
15
|
+
fn hash_work_resumes_from_its_checkpoint() {
|
|
16
|
+
let (head, tail) = (vec![b'h'; 1024], vec![b't'; 3000]);
|
|
17
|
+
let mut h = Sha256::new();
|
|
18
|
+
h.update(&head);
|
|
19
|
+
let saved = save_hash(&h, head.len() as i64).unwrap();
|
|
20
|
+
// The input's first bytes differ from what the checkpoint hashed: only a
|
|
21
|
+
// Work that skips them and continues the saved hash reaches this digest.
|
|
22
|
+
let mut input: &[u8] = &[vec![b'x'; head.len()], tail.clone()].concat();
|
|
23
|
+
let job = Job {
|
|
24
|
+
checkpoint: saved,
|
|
25
|
+
..Job::default()
|
|
26
|
+
};
|
|
27
|
+
let got = hash_work(&Context::background(), &mut Task::new(job, &mut input)).unwrap();
|
|
28
|
+
assert_eq!(got.sha256, hex_digest(&[head, tail].concat()));
|
|
29
|
+
assert_eq!(got.bytes, 4024);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#[test]
|
|
33
|
+
fn hash_work_refuses_a_foreign_checkpoint() {
|
|
34
|
+
for saved in [
|
|
35
|
+
"{",
|
|
36
|
+
r#"{"offset":-1,"state":""}"#,
|
|
37
|
+
r#"{"offset":3,"state":"00"}"#,
|
|
38
|
+
] {
|
|
39
|
+
let job = Job {
|
|
40
|
+
checkpoint: saved.into(),
|
|
41
|
+
..Job::default()
|
|
42
|
+
};
|
|
43
|
+
let mut input: &[u8] = b"abc";
|
|
44
|
+
let got = hash_work(&Context::background(), &mut Task::new(job, &mut input));
|
|
45
|
+
assert!(got.is_err(), "{saved}: accepted");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Ends the worker's context once `left` bytes have been read, like a
|
|
50
|
+
/// package stop in the middle of a job.
|
|
51
|
+
struct StopAfter<'a> {
|
|
52
|
+
inner: &'a mut dyn Read,
|
|
53
|
+
left: usize,
|
|
54
|
+
stop: Arc<Cancel>,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
impl Read for StopAfter<'_> {
|
|
58
|
+
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
|
59
|
+
let n = self.inner.read(buf)?;
|
|
60
|
+
self.left = self.left.saturating_sub(n);
|
|
61
|
+
if self.left == 0 {
|
|
62
|
+
self.stop.cancel();
|
|
63
|
+
}
|
|
64
|
+
Ok(n)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#[test]
|
|
69
|
+
fn hash_work_reports_progress_and_checkpoints_before_a_stop() {
|
|
70
|
+
let dir = TempDir::new();
|
|
71
|
+
let store = Arc::new(Store::new(dir.path().join("background")));
|
|
72
|
+
let ctx = Context::background();
|
|
73
|
+
let data = b"0123456789abcdef".repeat(16 << 10);
|
|
74
|
+
let (job, _) = store
|
|
75
|
+
.submit(
|
|
76
|
+
&ctx,
|
|
77
|
+
SubmitRequest {
|
|
78
|
+
key: "k".into(),
|
|
79
|
+
name: "a.bin".into(),
|
|
80
|
+
data: data.clone(),
|
|
81
|
+
submitter: Submitter {
|
|
82
|
+
user: "alice".into(),
|
|
83
|
+
uid: "1024".into(),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
.unwrap();
|
|
88
|
+
let (worker, stop) = ctx.with_cancel();
|
|
89
|
+
let stop = Arc::new(stop);
|
|
90
|
+
let mut runner = Runner {
|
|
91
|
+
store: store.clone(),
|
|
92
|
+
work: Arc::new(move |ctx: &Context, t: &mut Task<'_>| {
|
|
93
|
+
// The task's input lives as long as the task; the test leaks its wrapper.
|
|
94
|
+
let inner = std::mem::replace(&mut t.input, Box::leak(Box::new(empty())));
|
|
95
|
+
t.input = Box::leak(Box::new(StopAfter {
|
|
96
|
+
inner,
|
|
97
|
+
left: 3 * HASH_CHECKPOINT_EVERY as usize / 2,
|
|
98
|
+
stop: stop.clone(),
|
|
99
|
+
}));
|
|
100
|
+
hash_work(ctx, t)
|
|
101
|
+
}),
|
|
102
|
+
timeout: Duration::from_secs(60),
|
|
103
|
+
cancel_poll: Duration::from_millis(10),
|
|
104
|
+
..Runner::new(dir.path().join("background"), WorkerInfo::default())
|
|
105
|
+
};
|
|
106
|
+
let claimed = store.claim(&ctx).unwrap().unwrap();
|
|
107
|
+
runner.process(&worker, &claimed);
|
|
108
|
+
let paused = store.get(&ctx, &job.id).unwrap();
|
|
109
|
+
let progress = paused.progress.clone().unwrap();
|
|
110
|
+
assert_eq!(paused.state, State::Paused, "{paused:?}");
|
|
111
|
+
assert!(!paused.checkpoint.is_empty());
|
|
112
|
+
assert!(progress.bytes >= HASH_CHECKPOINT_EVERY, "{progress:?}");
|
|
113
|
+
assert_eq!(progress.total_bytes, data.len() as i64);
|
|
114
|
+
|
|
115
|
+
store.recover(&ctx).unwrap();
|
|
116
|
+
let claimed = store.claim(&ctx).unwrap().unwrap();
|
|
117
|
+
assert_eq!(claimed.checkpoint, paused.checkpoint);
|
|
118
|
+
runner.work = Arc::new(hash_work);
|
|
119
|
+
runner.process(&ctx, &claimed);
|
|
120
|
+
let done = store.get(&ctx, &job.id).unwrap();
|
|
121
|
+
assert_eq!(done.state, State::Succeeded, "{done:?}");
|
|
122
|
+
assert_eq!(done.progress.unwrap().bytes, data.len() as i64);
|
|
123
|
+
}
|