CH-J Toolsservera pārvaldība virs SSH
Izvēlne
Publicētais avots

CH-J Tools

Pārlūkot mapes un failus konkrētai lietojumprogrammas izdošanai.

Lejupielādēt avota ZIP
CH-J / Server Manager 1704 faili
0.1.2-beta Pievienots: · Būvēts:
spec.rs Pievienots: Datums nav reģistrēts 29,4 KB · 643 rindas
Kods Lejupielādēt failu
1//! What a synchronization should do, checked before anything starts, and the rsync options that follow from it.
2//! Nothing here runs a process: the module's input becomes either an error or a fixed list of arguments.
4use crate::core::error::{CoreError, CoreResult};
5use once_cell::sync::Lazy;
6use regex::Regex;
7use serde_json::Value;
9pub const VERSIONS_DIR: &str = ".chj-versions";
11fn invalid(message: impl Into<String>) -> CoreError {
12 CoreError::new("RSYNC_SPEC_INVALID", message)
15#[derive(Clone, Debug, PartialEq)]
16pub enum Mode {
17 Copy,
18 Mirror,
19 Versioned,
20 Snapshot,
21 Compare,
24impl Mode {
25 pub fn name(&self) -> &'static str {
26 match self {
27 Mode::Copy => "copy",
28 Mode::Mirror => "mirror",
29 Mode::Versioned => "versioned",
30 Mode::Snapshot => "snapshot",
31 Mode::Compare => "compare",
32 }
33 }
36#[derive(Clone, Debug, PartialEq)]
37pub enum Place {
38 Local,
39 Remote { session_id: String },
42#[derive(Clone, Debug)]
43pub struct Endpoint {
44 pub place: Place,
45 pub path: String,
46 pub privileged: bool,
49/// How two different servers reach each other.
50#[derive(Clone, Debug, PartialEq)]
51pub enum Transport {
52 /// The client carries the data between two sessions. Neither server needs a login to the other.
53 Relay,
54 /// The source logs in to the target by itself.
55 Direct { host: String, port: u16, username: String, method: String, key_path: String, accept_new_host_key: bool },
58#[derive(Clone, Debug, Default)]
59pub struct Options {
60 pub permissions: bool,
61 pub times: bool,
62 pub owner: bool,
63 pub group: bool,
64 pub acls: bool,
65 pub xattrs: bool,
66 pub devices: bool,
67 pub symlinks: String,
68 pub hardlinks: bool,
69 pub compress: bool,
70 pub checksum: bool,
71 pub size_only: bool,
72 pub resume: bool,
73 pub inplace: bool,
74 pub one_file_system: bool,
75 pub numeric_ids: bool,
76 pub update_only: bool,
77 pub existing_only: bool,
78 pub ignore_existing: bool,
79 pub delete_excluded: bool,
80 pub bandwidth_kib: u64,
81 pub timeout_seconds: u64,
82 pub max_size: String,
83 pub min_size: String,
84 pub excludes: Vec<String>,
85 pub includes: Vec<String>,
86 pub extra: Vec<String>,
87 pub keep_days: u64,
88 pub keep_count: u64,
91#[derive(Clone, Debug)]
92pub struct Spec {
93 pub source: Endpoint,
94 pub target: Endpoint,
95 pub mode: Mode,
96 pub contents_only: bool,
97 pub options: Options,
98 pub transport: Transport,
101/// What the rsync that will run can do. openrsync on macOS knows far less than rsync 3.
102#[derive(Clone, Debug, Default, PartialEq)]
103pub struct Capabilities {
104 pub real_rsync: bool,
105 pub major: u32,
106 pub minor: u32,
107 pub acls: bool,
108 pub xattrs: bool,
111impl Capabilities {
112 /// `rsync version 3.2.7 protocol version 31`, `openrsync: protocol version 29`.
113 pub fn parse(version_line: &str, acls: bool, xattrs: bool) -> Self {
114 static VERSION: Lazy<Regex> = Lazy::new(|| Regex::new(r"rsync\s+version\s+v?(\d+)\.(\d+)").unwrap());
115 match VERSION.captures(version_line) {
116 Some(found) => Capabilities { real_rsync: true, major: found[1].parse().unwrap_or(0), minor: found[2].parse().unwrap_or(0), acls, xattrs },
117 None => Capabilities::default(),
118 }
119 }
121 pub fn overall_progress(&self) -> bool {
122 self.real_rsync && (self.major, self.minor) >= (3, 1)
123 }
126/// Places where deleting according to another directory destroys a system or a whole home.
127const PROTECTED: &[&str] = &["/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib64", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp", "/usr", "/var", "/var/lib", "/var/log", "/var/www", "/Users", "/Library", "/System", "/Applications"];
129pub fn normalize(path: &str) -> String {
130 let trimmed = path.trim_end_matches('/');
131 if trimmed.is_empty() { "/".into() } else { trimmed.to_string() }
134pub fn is_protected(path: &str) -> bool {
135 let path = normalize(path);
136 PROTECTED.contains(&path.as_str()) || Regex::new(r"^/(home|Users)/[^/]+$").unwrap().is_match(&path)
139fn check_path(path: &str, what: &str) -> CoreResult<String> {
140 if !path.starts_with('/') {
141 return Err(invalid(format!("The {what} must be an absolute path.")));
142 }
143 if path.len() > 4096 || path.chars().any(|c| c.is_control()) {
144 return Err(invalid(format!("The {what} contains characters that cannot be used.")));
145 }
146 // rsync expands these on the sending side, so the transfer would not be limited to the path shown.
147 if path.chars().any(|c| matches!(c, '*' | '?' | '[')) {
148 return Err(invalid(format!("The {what} must not contain wildcards. Use the filters.")));
149 }
150 if path.split('/').any(|part| part == ".." || part == ".") || path.contains("//") {
151 return Err(invalid(format!("The {what} must not contain . or .. parts or repeated slashes.")));
152 }
153 Ok(path.to_string())
156/// The session name of a target only the source server logs in to.
157pub const DIRECT_TARGET: &str = "direct-target";
159fn endpoint(value: &Value, what: &str) -> CoreResult<Endpoint> {
160 let place = match value["kind"].as_str() {
161 Some("local") => Place::Local,
162 Some("ssh") => {
163 let session_id = value["sessionId"].as_str().unwrap_or("");
164 if session_id.is_empty() {
165 return Err(invalid(format!("Choose the server of the {what}.")));
166 }
167 Place::Remote { session_id: session_id.to_string() }
168 }
169 _ => return Err(invalid(format!("Choose the {what}."))),
170 };
171 let path = check_path(value["path"].as_str().unwrap_or(""), what)?;
172 Ok(Endpoint { privileged: value["privileged"] == true && place != Place::Local, place, path })
175fn number(value: &Value, name: &str, maximum: u64) -> CoreResult<u64> {
176 match &value[name] {
177 Value::Null => Ok(0),
178 Value::Number(n) if n.as_u64().is_some_and(|n| n <= maximum) => Ok(n.as_u64().unwrap()),
179 _ => Err(invalid(format!("{name} must be a whole number up to {maximum}."))),
180 }
183fn size(value: &Value, name: &str) -> CoreResult<String> {
184 let text = value[name].as_str().unwrap_or("").trim();
185 if text.is_empty() || Regex::new(r"^\d{1,15}[KkMmGg]?$").unwrap().is_match(text) {
186 Ok(text.to_string())
187 } else {
188 Err(invalid(format!("{name} must be a size such as 100M.")))
189 }
192fn patterns(value: &Value, name: &str) -> CoreResult<Vec<String>> {
193 let list: Vec<String> = value[name].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::trim).filter(|text| !text.is_empty()).map(str::to_string).collect();
194 if list.len() > 200 || list.iter().any(|text| text.len() > 1024 || text.chars().any(|c| c.is_control())) {
195 return Err(invalid(format!("{name}: at most 200 patterns on single lines.")));
196 }
197 Ok(list)
200/// Options that only change how files are compared or stored. Everything that names a program or a
201/// file of the server (`-e`, `--rsync-path`, `--log-file`, `--files-from`…) stays outside the advanced field.
202static EXTRA_ALLOWED: Lazy<Regex> = Lazy::new(|| {
203 Regex::new(r"^(--(append|append-verify|sparse|preallocate|fuzzy|delay-updates|prune-empty-dirs|omit-dir-times|omit-link-times|no-implied-dirs|safe-links|copy-unsafe-links|keep-dirlinks|modify-window=-?\d+|block-size=\d+|compress-level=\d|skip-compress=[A-Za-z0-9/.-]+|chmod=[A-Za-z0-9,=+-]+|chown=[A-Za-z0-9_.:-]+|usermap=[A-Za-z0-9_.:*,-]+|groupmap=[A-Za-z0-9_.:*,-]+|ignore-times|no-whole-file|whole-file|cvs-exclude|fake-super|super|no-perms|no-times|no-owner|no-group)|-[zSxHuc]+)$").unwrap()
204});
206pub fn check_extra(option: &str) -> CoreResult<String> {
207 if EXTRA_ALLOWED.is_match(option) {
208 Ok(option.to_string())
209 } else {
210 Err(invalid(format!("The option {option} is not allowed in the advanced field.")))
211 }
214pub fn parse_options(value: &Value) -> CoreResult<Options> {
215 let on = |name: &str, default: bool| value.get(name).and_then(Value::as_bool).unwrap_or(default);
216 let symlinks = value["symlinks"].as_str().unwrap_or("preserve");
217 if !["preserve", "follow", "skip"].contains(&symlinks) {
218 return Err(invalid("Unknown way of handling symbolic links."));
219 }
220 let options = Options {
221 permissions: on("permissions", true),
222 times: on("times", true),
223 owner: on("owner", false),
224 group: on("group", false),
225 acls: on("acls", false),
226 xattrs: on("xattrs", false),
227 devices: on("devices", false),
228 symlinks: symlinks.to_string(),
229 hardlinks: on("hardlinks", false),
230 compress: on("compress", false),
231 checksum: on("checksum", false),
232 size_only: on("sizeOnly", false),
233 resume: on("resume", true),
234 inplace: on("inplace", false),
235 one_file_system: on("oneFileSystem", false),
236 numeric_ids: on("numericIds", false),
237 update_only: on("updateOnly", false),
238 existing_only: on("existingOnly", false),
239 ignore_existing: on("ignoreExisting", false),
240 delete_excluded: on("deleteExcluded", false),
241 bandwidth_kib: number(value, "bandwidthKiB", 100_000_000)?,
242 timeout_seconds: number(value, "timeoutSeconds", 86_400)?,
243 max_size: size(value, "maxSize")?,
244 min_size: size(value, "minSize")?,
245 excludes: patterns(value, "excludes")?,
246 includes: patterns(value, "includes")?,
247 extra: value["extra"].as_array().into_iter().flatten().filter_map(Value::as_str).map(check_extra).collect::<CoreResult<_>>()?,
248 keep_days: number(value, "keepDays", 36_500)?,
249 keep_count: number(value, "keepCount", 100_000)?,
250 };
251 if options.checksum && options.size_only {
252 return Err(invalid("Choose either checksum or size comparison."));
253 }
254 if options.update_only && options.ignore_existing {
255 return Err(invalid("Update-only and ignore-existing exclude each other."));
256 }
257 if options.resume && options.inplace {
258 return Err(invalid("Resuming keeps partial files aside and cannot be combined with writing in place."));
259 }
260 Ok(options)
263static SHELL_SAFE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9._/+-]+$").unwrap());
265fn transport(value: &Value) -> CoreResult<Transport> {
266 match value["kind"].as_str() {
267 None | Some("relay") => Ok(Transport::Relay),
268 Some("direct") => {
269 static HOST: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9][A-Za-z0-9.:-]{0,252}$").unwrap());
270 static USER: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$").unwrap());
271 let host = value["host"].as_str().unwrap_or("").trim().to_string();
272 let username = value["username"].as_str().unwrap_or("").trim().to_string();
273 if !HOST.is_match(&host) || !USER.is_match(&username) {
274 return Err(invalid("Enter the host and user the source server logs in to."));
275 }
276 let method = value["method"].as_str().unwrap_or("").to_string();
277 if !["password", "key", "agent"].contains(&method.as_str()) {
278 return Err(invalid("Choose a password or a key for the login between the servers."));
279 }
280 let key_path = if method == "key" { value["keyPath"].as_str().unwrap_or("").trim().to_string() } else { String::new() };
281 if method == "key" {
282 // It ends up in the command line of ssh on the server.
283 if !SHELL_SAFE.is_match(&key_path) {
284 return Err(invalid("The key path may contain only letters, digits and . _ - + / characters."));
285 }
286 check_path(&key_path, "key path")?;
287 }
288 let port = value["port"].as_u64().unwrap_or(22);
289 if !(1..=65535).contains(&port) {
290 return Err(invalid("The SSH port is not valid."));
291 }
292 Ok(Transport::Direct { host, port: port as u16, username, method, key_path, accept_new_host_key: value["acceptNewHostKey"] == true })
293 }
294 _ => Err(invalid("Unknown connection between the servers.")),
295 }
298pub fn parse(value: &Value) -> CoreResult<Spec> {
299 let mode = match value["mode"].as_str() {
300 Some("copy") | None => Mode::Copy,
301 Some("mirror") => Mode::Mirror,
302 Some("versioned") => Mode::Versioned,
303 Some("snapshot") => Mode::Snapshot,
304 Some("compare") => Mode::Compare,
305 Some(_) => return Err(invalid("Unknown synchronization mode.")),
306 };
307 let source = endpoint(&value["source"], "source")?;
308 let mut target_value = value["target"].clone();
309 if value["transport"]["kind"] == "direct" {
310 target_value["kind"] = serde_json::json!("ssh");
311 target_value["sessionId"] = serde_json::json!(DIRECT_TARGET);
312 }
313 let target = endpoint(&target_value, "target")?;
314 let spec = Spec {
315 source,
316 target,
317 mode,
318 contents_only: value["contentsOnly"].as_bool().unwrap_or(true),
319 options: parse_options(&value["options"])?,
320 transport: transport(&value["transport"])?,
321 };
322 if spec.source.place == spec.target.place {
323 check_overlap(&spec)?;
324 }
325 if matches!(spec.transport, Transport::Direct { .. }) && (spec.source.place == Place::Local || spec.target.place == Place::Local) {
326 return Err(invalid("A direct login between servers needs two servers."));
327 }
328 if matches!(spec.transport, Transport::Direct { .. }) {
329 // ssh hands the path to the login shell of the other server, unless a new rsync quotes it.
330 if !SHELL_SAFE.is_match(&spec.target.path) {
331 return Err(invalid("With a direct login, the target path may contain only letters, digits and . _ - + / characters."));
332 }
333 // Finishing a snapshot and removing old versions happen on the target, and no session reaches it.
334 if spec.mode == Mode::Snapshot {
335 return Err(invalid("Snapshots need a session on the target. Use the transfer through this application."));
336 }
337 if spec.options.keep_days > 0 || spec.options.keep_count > 0 {
338 return Err(invalid("Removing old versions needs a session on the target. Use the transfer through this application."));
339 }
340 }
341 Ok(spec)
344/// Two paths that lie inside each other on one machine would feed a transfer into itself.
345pub fn check_overlap(spec: &Spec) -> CoreResult<()> {
346 let (from, to) = (normalize(&spec.source.path), normalize(&spec.target.path));
347 let inside = |outer: &str, inner: &str| outer == "/" || inner == outer || inner.starts_with(&format!("{outer}/"));
348 // Restoring puts an older version of a backup back into it.
349 static VERSION: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(?:/\.chj-versions)?/\d{8}T\d{6}Z$").unwrap());
350 let restoring = spec.mode == Mode::Copy && from.strip_prefix(to.as_str()).is_some_and(|rest| VERSION.is_match(rest));
351 if !restoring && (inside(&from, &to) || inside(&to, &from)) {
352 return Err(invalid("The source and the target must not lie inside each other."));
353 }
354 Ok(())
357/// Does this mode remove files from the target?
358pub fn deletes(mode: &Mode) -> bool {
359 matches!(mode, Mode::Mirror | Mode::Compare)
362/// Where the snapshot or the old versions live.
363pub fn snapshot_dir(spec: &Spec, stamp: &str) -> String {
364 format!("{}/{stamp}/", normalize(&spec.target.path))
367pub fn source_argument(spec: &Spec) -> String {
368 let path = normalize(&spec.source.path);
369 if spec.contents_only && path != "/" { format!("{path}/") } else if path == "/" { "/".into() } else { path }
372pub fn target_argument(spec: &Spec, stamp: &str) -> String {
373 if spec.mode == Mode::Snapshot { snapshot_dir(spec, stamp) } else { format!("{}/", normalize(&spec.target.path)) }
376/// The options of a run. `allowed_deletes` is what the preview showed and the user accepted: rsync stops beyond it.
377pub fn flags(spec: &Spec, capabilities: &Capabilities, dry_run: bool, stamp: &str, allowed_deletes: Option<u64>) -> CoreResult<Vec<String>> {
378 let o = &spec.options;
379 let mut args: Vec<String> = vec!["--recursive".into(), "--out-format=%i\t%l\t%n".into(), "--stats".into()];
380 let mut push = |condition: bool, flag: &str| {
381 if condition {
382 args.push(flag.to_string());
383 }
384 };
385 push(dry_run, "--dry-run");
386 push(o.permissions, "--perms");
387 push(o.times, "--times");
388 push(o.owner, "--owner");
389 push(o.group, "--group");
390 push(o.devices, "--devices");
391 push(o.devices, "--specials");
392 push(o.hardlinks, "--hard-links");
393 push(o.compress, "--compress");
394 push(o.checksum, "--checksum");
395 push(o.size_only, "--size-only");
396 push(o.inplace, "--inplace");
397 push(o.one_file_system, "--one-file-system");
398 push(o.numeric_ids, "--numeric-ids");
399 push(o.update_only, "--update");
400 push(o.existing_only, "--existing");
401 push(o.ignore_existing, "--ignore-existing");
402 match o.symlinks.as_str() {
403 "follow" => args.push("--copy-links".into()),
404 "skip" => {}
405 _ => args.push("--links".into()),
406 }
407 if o.acls {
408 if !capabilities.acls {
409 return Err(CoreError::new("RSYNC_OPTION_UNSUPPORTED", "The rsync that runs this job does not support ACLs."));
410 }
411 args.push("--acls".into());
412 }
413 if o.xattrs {
414 if !capabilities.xattrs {
415 return Err(CoreError::new("RSYNC_OPTION_UNSUPPORTED", "The rsync that runs this job does not support extended attributes."));
416 }
417 args.push("--xattrs".into());
418 }
419 if o.resume && !dry_run {
420 args.push(if capabilities.real_rsync { "--partial-dir=.chj-partial".to_string() } else { "--partial".to_string() });
421 }
422 if o.bandwidth_kib > 0 {
423 args.push(format!("--bwlimit={}", o.bandwidth_kib));
424 }
425 if o.timeout_seconds > 0 {
426 args.push(format!("--timeout={}", o.timeout_seconds));
427 }
428 if !o.max_size.is_empty() {
429 args.push(format!("--max-size={}", o.max_size));
430 }
431 if !o.min_size.is_empty() {
432 args.push(format!("--min-size={}", o.min_size));
433 }
434 if !dry_run && matches!(spec.transport, Transport::Direct { .. }) && capabilities.real_rsync && (capabilities.major, capabilities.minor) >= (3, 2) {
435 args.push("--mkpath".into());
436 }
437 if !dry_run && capabilities.overall_progress() {
438 args.push("--info=progress2".into());
439 }
440 // Earlier versions of files never count as a difference or get transferred.
441 args.push(format!("--exclude=/{VERSIONS_DIR}/"));
442 for pattern in &o.includes {
443 args.push(format!("--include={pattern}"));
444 }
445 for pattern in &o.excludes {
446 args.push(format!("--exclude={pattern}"));
447 }
448 match spec.mode {
449 Mode::Copy => {}
450 Mode::Compare => {
451 args.push("--delete".into());
452 }
453 Mode::Mirror => {
454 let accepted = if dry_run { None } else { Some(allowed_deletes.ok_or_else(|| invalid("Mirroring needs the preview first."))?) };
455 if accepted != Some(0) {
456 args.push("--delete".into());
457 args.push("--delete-after".into());
458 if let Some(limit) = accepted {
459 args.push(format!("--max-delete={limit}"));
460 }
461 if o.delete_excluded {
462 args.push("--delete-excluded".into());
463 args.push(format!("--filter=protect /{VERSIONS_DIR}/"));
464 }
465 }
466 }
467 Mode::Versioned => {
468 args.push("--backup".into());
469 args.push(format!("--backup-dir={}/{VERSIONS_DIR}/{stamp}", normalize(&spec.target.path)));
470 // Removed files move among the versions, still only as many as were accepted.
471 let accepted = if dry_run { None } else { Some(allowed_deletes.ok_or_else(|| invalid("A versioned backup needs the preview first."))?) };
472 if accepted != Some(0) {
473 args.push("--delete".into());
474 args.push("--delete-after".into());
475 if let Some(limit) = accepted {
476 args.push(format!("--max-delete={limit}"));
477 }
478 }
479 }
480 Mode::Snapshot => {
481 args.push(format!("--link-dest={}/latest/", normalize(&spec.target.path)));
482 }
483 }
484 for option in &o.extra {
485 args.push(option.clone());
486 }
487 Ok(args)
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use serde_json::json;
495 fn base() -> Value {
496 json!({ "mode": "copy", "source": { "kind": "local", "path": "/data/www" }, "target": { "kind": "ssh", "sessionId": "s1", "path": "/srv/www" } })
497 }
499 fn real() -> Capabilities {
500 Capabilities { real_rsync: true, major: 3, minor: 2, acls: true, xattrs: true }
501 }
503 #[test]
504 fn the_trailing_slash_is_an_explicit_choice() {
505 let mut value = base();
506 let spec = parse(&value).unwrap();
507 assert_eq!(source_argument(&spec), "/data/www/");
508 value["contentsOnly"] = json!(false);
509 assert_eq!(source_argument(&parse(&value).unwrap()), "/data/www");
510 value["source"]["path"] = json!("/data/www/");
511 assert_eq!(source_argument(&parse(&value).unwrap()), "/data/www");
512 }
514 #[test]
515 fn unsafe_paths_are_refused() {
516 for path in ["relative", "/a/../b", "/a/./b", "/a/*", "/a?", "/a[1]", "/a\nb", "", "/var//www"] {
517 let mut value = base();
518 value["target"]["path"] = json!(path);
519 assert_eq!(parse(&value).unwrap_err().code, "RSYNC_SPEC_INVALID", "{path}");
520 }
521 }
523 #[test]
524 fn nested_source_and_target_are_refused() {
525 for (from, to) in [("/a", "/a/b"), ("/a/b", "/a"), ("/a", "/a/"), ("/", "/a")] {
526 let value = json!({ "source": { "kind": "local", "path": from }, "target": { "kind": "local", "path": to } });
527 assert!(parse(&value).is_err(), "{from} {to}");
528 }
529 assert!(parse(&json!({ "source": { "kind": "local", "path": "/a" }, "target": { "kind": "local", "path": "/ab" } })).is_ok());
530 assert!(parse(&json!({ "mode": "copy", "source": { "kind": "local", "path": "/b/.chj-versions/20261102T000000Z" }, "target": { "kind": "local", "path": "/b" } })).is_ok());
531 assert!(parse(&json!({ "mode": "copy", "source": { "kind": "local", "path": "/b/20261102T000000Z" }, "target": { "kind": "local", "path": "/b" } })).is_ok());
532 for (mode, from) in [("mirror", "/b/.chj-versions/20261102T000000Z"), ("copy", "/b/.chj-versions/other"), ("copy", "/b/sub")] {
533 assert!(parse(&json!({ "mode": mode, "source": { "kind": "local", "path": from }, "target": { "kind": "local", "path": "/b" } })).is_err(), "{mode} {from}");
534 }
535 // The same path on two servers is a normal mirror.
536 assert!(parse(&json!({ "source": { "kind": "ssh", "sessionId": "a", "path": "/a" }, "target": { "kind": "ssh", "sessionId": "b", "path": "/a" } })).is_ok());
537 }
539 #[test]
540 fn deleting_starts_only_from_an_accepted_preview() {
541 let mut value = base();
542 value["mode"] = json!("mirror");
543 let spec = parse(&value).unwrap();
544 assert_eq!(flags(&spec, &real(), false, "S", None).unwrap_err().code, "RSYNC_SPEC_INVALID");
545 let accepted = flags(&spec, &real(), false, "S", Some(3)).unwrap();
546 assert!(accepted.contains(&"--delete".to_string()) && accepted.contains(&"--max-delete=3".to_string()));
547 let none = flags(&spec, &real(), false, "S", Some(0)).unwrap();
548 assert!(!none.iter().any(|flag| flag.starts_with("--delete") || flag.starts_with("--max-delete")), "{none:?}");
549 let preview = flags(&spec, &real(), true, "S", None).unwrap();
550 assert!(preview.contains(&"--dry-run".to_string()) && preview.contains(&"--delete".to_string()));
551 let copy = parse(&base()).unwrap();
552 assert!(!flags(&copy, &real(), false, "S", None).unwrap().iter().any(|flag| flag.contains("delete")));
553 }
555 #[test]
556 fn modes_keep_older_versions_where_they_promise_to() {
557 let mut value = base();
558 value["mode"] = json!("versioned");
559 let versioned = flags(&parse(&value).unwrap(), &real(), false, "20261102T000000Z", Some(2)).unwrap();
560 assert!(versioned.contains(&"--backup".to_string()));
561 assert!(versioned.contains(&"--max-delete=2".to_string()), "{versioned:?}");
562 let none = flags(&parse(&value).unwrap(), &real(), false, "20261102T000000Z", Some(0)).unwrap();
563 assert!(!none.iter().any(|flag| flag.starts_with("--delete")), "{none:?}");
564 assert!(versioned.contains(&"--backup-dir=/srv/www/.chj-versions/20261102T000000Z".to_string()));
565 value["mode"] = json!("snapshot");
566 let spec = parse(&value).unwrap();
567 assert!(flags(&spec, &real(), false, "S", None).unwrap().contains(&"--link-dest=/srv/www/latest/".to_string()));
568 assert_eq!(target_argument(&spec, "S"), "/srv/www/S/");
569 }
571 #[test]
572 fn options_adapt_to_the_rsync_that_runs() {
573 let open = Capabilities::parse("openrsync: protocol version 29", false, false);
574 assert!(!open.real_rsync);
575 let spec = parse(&base()).unwrap();
576 let args = flags(&spec, &open, false, "S", None).unwrap();
577 assert!(!args.iter().any(|flag| flag.starts_with("--info")), "{args:?}");
578 assert!(args.contains(&"--partial".to_string()));
579 assert!(flags(&spec, &real(), false, "S", None).unwrap().contains(&"--info=progress2".to_string()));
580 let mut value = base();
581 value["options"] = json!({ "acls": true });
582 assert_eq!(flags(&parse(&value).unwrap(), &open, false, "S", None).unwrap_err().code, "RSYNC_OPTION_UNSUPPORTED");
583 assert!(Capabilities::parse("rsync version 3.4.1 protocol version 32", true, true).overall_progress());
584 assert!(!Capabilities::parse("rsync version 3.0.9 protocol version 30", true, true).overall_progress());
585 }
587 #[test]
588 fn the_advanced_field_cannot_run_programs_or_touch_other_files() {
589 for option in ["-e", "--rsh=sh", "-e=sh", "--rsync-path=sh", "--server", "--sender", "--daemon", "--log-file=/etc/passwd", "--files-from=/etc/shadow", "--password-file=x", "--write-batch=x", "--read-batch=x", "--backup-dir=/x", "--remove-source-files", "--delete", "--config=x", "-az", "--chmod=a;b", "--include-from=x", "--force", "--ignore-errors", "--delete-before", "--delete-during", "--delete-delay", "--delete-after", "--del", "--human-readable"] {
590 assert!(check_extra(option).is_err(), "{option}");
591 }
592 for option in ["--sparse", "--compress-level=9", "--chmod=D755,F644", "-z", "--modify-window=2"] {
593 assert!(check_extra(option).is_ok(), "{option}");
594 }
595 }
597 #[test]
598 fn protected_targets_are_recognized() {
599 for path in ["/", "/etc", "/etc/", "/home", "/home/josef", "/Users/josef", "/var/www"] {
600 assert!(is_protected(path), "{path}");
601 }
602 for path in ["/srv/backup", "/home/josef/backup", "/var/www/site"] {
603 assert!(!is_protected(path), "{path}");
604 }
605 }
607 #[test]
608 fn conflicting_options_and_bad_sizes_are_refused() {
609 for options in [json!({ "checksum": true, "sizeOnly": true }), json!({ "updateOnly": true, "ignoreExisting": true }), json!({ "inplace": true }), json!({ "maxSize": "1 GB" }), json!({ "bandwidthKiB": -1 }), json!({ "symlinks": "x" }), json!({ "extra": ["--rsh=x"] })] {
610 let mut value = base();
611 value["options"] = options.clone();
612 assert!(parse(&value).is_err(), "{options}");
613 }
614 }
616 #[test]
617 fn a_direct_login_is_checked() {
618 let mut value = json!({ "source": { "kind": "ssh", "sessionId": "a", "path": "/a" }, "target": { "kind": "ssh", "sessionId": "b", "path": "/b" }, "transport": { "kind": "direct", "host": "h.example", "username": "ai", "method": "key", "keyPath": "/home/ai/.ssh/id" } });
619 assert!(matches!(parse(&value).unwrap().transport, Transport::Direct { port: 22, .. }));
620 assert_eq!(parse(&value).unwrap().target.place, Place::Remote { session_id: DIRECT_TARGET.into() });
621 value["transport"]["keyPath"] = json!("/with space/key");
622 assert!(parse(&value).is_err());
623 value["transport"] = json!({ "kind": "direct", "host": "-oProxyCommand=x", "username": "ai", "method": "password" });
624 assert!(parse(&value).is_err());
625 value["transport"] = json!({ "kind": "direct", "host": "h", "username": "-l", "method": "password" });
626 assert!(parse(&value).is_err());
627 // A key path is only for the key login, and it cannot carry ssh options.
628 value["transport"] = json!({ "kind": "direct", "host": "h", "username": "u", "method": "agent", "keyPath": "/x -oProxyCommand=touch" });
629 assert!(matches!(parse(&value).unwrap().transport, Transport::Direct { ref key_path, .. } if key_path.is_empty()));
630 value["transport"] = json!({ "kind": "direct", "host": "h", "username": "u", "method": "key", "keyPath": "/x;id" });
631 assert!(parse(&value).is_err());
632 // The login shell of the target would read these.
633 for path in ["/backup/$(touch x)", "/backup/`id`", "/a b", "/a;b", "/a&b"] {
634 let unsafe_target = json!({ "source": { "kind": "ssh", "sessionId": "a", "path": "/a" }, "target": { "path": path }, "transport": { "kind": "direct", "host": "h", "username": "u", "method": "password" } });
635 assert!(parse(&unsafe_target).is_err(), "{path}");
636 }
637 for extra in [json!({ "mode": "snapshot" }), json!({ "options": { "keepCount": 2 } })] {
638 let mut job = json!({ "source": { "kind": "ssh", "sessionId": "a", "path": "/a" }, "target": { "path": "/b" }, "transport": { "kind": "direct", "host": "h", "username": "u", "method": "password" } });
639 for (key, item) in extra.as_object().unwrap() { job[key] = item.clone(); }
640 assert!(parse(&job).is_err(), "{extra}");
641 }
642 }
SHA-25677b3c8c9be1a9271e50f63abb29f8ef715b261c79a4fff7fbb44d9503237d867

CH-J Proprietary Software License 1.14 · Avots ir sniegts saskaņā ar CH-J Proprietary Software License 1.14. Tā pieejamība nemaina licences noteikumus un nepiešķir papildu tiesības.

Arhīvs SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Faili