lib.rs (23627B)
1 use anyhow::{Context, Result}; 2 use clap::{Args, Parser}; 3 use console::style; 4 use similar::{ChangeTag, TextDiff}; 5 use std::collections::{BTreeSet, HashSet}; 6 use std::fs; 7 use std::io::Write; 8 use std::path::{Path, PathBuf}; 9 use std::process::{Command, ExitCode}; 10 11 /// dam (diff-and-merge) — sync line-oriented text files between a local 12 /// directory and a remote host over rsync. 13 /// 14 /// dam merges files as the de-duplicated union of their local and remote 15 /// lines: local lines keep their original order and remote-only lines are 16 /// appended. It is intended for list-like data (allow-lists, hosts files, 17 /// playlists, word lists), not for arbitrary documents — blank lines and 18 /// duplicate lines are not preserved by the merge. 19 /// 20 /// --local and --remote may be directories or single files. When --local is 21 /// an existing directory dam syncs the whole tree (recursing into 22 /// subdirectories); otherwise it treats both paths as a single file. To mirror 23 /// a directory into a new local location, create that directory first. 24 /// 25 /// Because the merge is a union, dam never removes a line: a line deleted on 26 /// one side reappears from the other. Removing data is a manual edit on both 27 /// sides, or a one-shot rsync. 28 /// 29 /// dam has a plan/apply workflow, with three mutually exclusive modes: 30 /// 31 /// --plan (default) Show the changes each direction would make. Writes 32 /// nothing. 33 /// --pull Apply the merge to the local directory (remote → local). 34 /// --push Apply the merge to the remote host (local → remote). 35 /// 36 /// Progress and the summary are written to stderr and the plan diff to stdout, 37 /// so `dam --plan > changes.diff` captures just the differences. 38 /// 39 /// Examples: 40 /// # See what would change on either side 41 /// dam --host myserver --local ~/lists --remote /srv/lists 42 /// 43 /// # Bring the local directory up to date 44 /// dam --host myserver --local ~/lists --remote /srv/lists --pull 45 /// 46 /// # Bring the remote up to date 47 /// dam --host myserver --local ~/lists --remote /srv/lists --push 48 /// 49 /// # Fail (exit 1) if the two sides have drifted — handy in CI 50 /// dam --host myserver --local ~/lists --remote /srv/lists --plan --exit-code 51 #[derive(Parser, Debug)] 52 #[command( 53 author, 54 version, 55 about = "Diff-and-merge line-oriented text files between a local directory and a remote host over rsync", 56 arg_required_else_help = true, 57 verbatim_doc_comment 58 )] 59 pub struct Cli { 60 /// SSH host alias (as configured in ~/.ssh/config) to sync with. 61 #[arg(long, required = true)] 62 host: String, 63 64 /// Local directory (or single file) containing the line-oriented text. 65 #[arg(long, required = true)] 66 local: PathBuf, 67 68 /// Remote directory (or single file) to sync from/to. 69 #[arg(long, required = true)] 70 remote: PathBuf, 71 72 #[command(flatten)] 73 mode: ModeFlags, 74 75 #[command(flatten)] 76 elevate: ElevateFlags, 77 78 /// Exit with status 1 if --plan finds pending changes. Processing errors 79 /// always exit with status 2. 80 #[arg(long)] 81 exit_code: bool, 82 83 /// I/O timeout in seconds handed to rsync (0 disables the timeout). 84 #[arg(long, default_value_t = 30)] 85 timeout: u64, 86 } 87 88 /// The mutually exclusive plan/pull/push flags. `--plan` (preview) is the 89 /// default when none is given. 90 #[derive(Args, Debug)] 91 #[group(required = false, multiple = false)] 92 struct ModeFlags { 93 /// Show the changes each direction would make, without writing anything 94 /// (the default). 95 #[arg(long)] 96 plan: bool, 97 98 /// Apply the merge to the local directory (remote → local). 99 #[arg(long)] 100 pull: bool, 101 102 /// Apply the merge to the remote host (local → remote). 103 #[arg(long)] 104 push: bool, 105 } 106 107 impl ModeFlags { 108 fn mode(&self) -> Mode { 109 if self.push { 110 Mode::Push 111 } else if self.pull { 112 Mode::Pull 113 } else { 114 Mode::Plan 115 } 116 } 117 } 118 119 /// The optional privilege-elevation flags. The remote rsync runs unprivileged 120 /// unless one is given; the local rsync is never elevated. 121 #[derive(Args, Debug)] 122 #[group(required = false, multiple = false)] 123 struct ElevateFlags { 124 /// Elevate the remote rsync with `sudo` (via --rsync-path). 125 #[arg(long)] 126 sudo: bool, 127 128 /// Elevate the remote rsync with `doas` (via --rsync-path). 129 #[arg(long)] 130 doas: bool, 131 } 132 133 impl ElevateFlags { 134 /// The command to prefix rsync with, if any. 135 fn command(&self) -> Option<&'static str> { 136 if self.sudo { 137 Some("sudo") 138 } else if self.doas { 139 Some("doas") 140 } else { 141 None 142 } 143 } 144 } 145 146 #[derive(Clone, Copy, PartialEq, Eq, Debug)] 147 enum Mode { 148 Plan, 149 Pull, 150 Push, 151 } 152 153 #[derive(Clone, Copy, PartialEq, Eq, Debug)] 154 enum FileStatus { 155 Created, 156 Updated, 157 Unchanged, 158 } 159 160 #[derive(Debug, Default)] 161 struct Tally { 162 created: u32, 163 updated: u32, 164 unchanged: u32, 165 errors: u32, 166 } 167 168 impl Tally { 169 fn record(&mut self, status: FileStatus) { 170 match status { 171 FileStatus::Created => self.created += 1, 172 FileStatus::Updated => self.updated += 1, 173 FileStatus::Unchanged => self.unchanged += 1, 174 } 175 } 176 177 /// Number of files that would change (or did change). 178 fn changed(&self) -> u32 { 179 self.created + self.updated 180 } 181 182 fn print(&self, header: &str) { 183 eprintln!("\n{header}:"); 184 eprintln!(" Created: {}", self.created); 185 eprintln!(" Updated: {}", self.updated); 186 eprintln!(" Unchanged: {}", self.unchanged); 187 if self.errors > 0 { 188 eprintln!(" Errors: {}", self.errors); 189 } 190 } 191 } 192 193 struct FileSyncWorker { 194 host_alias: String, 195 local_path: PathBuf, 196 remote_path: PathBuf, 197 mode: Mode, 198 timeout: u64, 199 elevate: Option<&'static str>, 200 } 201 202 /// One file to reconcile: where it lives locally, where its downloaded remote 203 /// copy is, and where a push stages its merged result. `label` is used in 204 /// progress and diff output. 205 struct Pair { 206 label: String, 207 local: PathBuf, 208 remote: PathBuf, 209 staging: PathBuf, 210 } 211 212 impl FileSyncWorker { 213 /// Single-file mode when --local is not an existing directory; otherwise 214 /// the whole directory tree is synced. 215 fn single_file(&self) -> bool { 216 !self.local_path.is_dir() 217 } 218 219 fn run(&self) -> Result<Tally> { 220 let single_file = self.single_file(); 221 222 // Always pull the remote into a temp dir so we have both sides to 223 // compare; nothing is uploaded unless --push has changes to apply. 224 let remote_tmp = tempfile::tempdir().context("Failed to create temporary directory")?; 225 let remote_dir = remote_tmp.path(); 226 self.download(remote_dir, single_file)?; 227 228 let action = match self.mode { 229 Mode::Plan => "Planning", 230 Mode::Pull => "Pulling", 231 Mode::Push => "Pushing", 232 }; 233 let unit = if single_file { "file" } else { "files" }; 234 eprintln!("{action} {unit} for {}", self.host_alias); 235 236 // Staging dir holds the files we will upload when pushing. 237 let staging_tmp = tempfile::tempdir().context("Failed to create staging directory")?; 238 let staging = staging_tmp.path(); 239 240 let mut tally = Tally::default(); 241 for pair in self.build_pairs(remote_dir, staging, single_file)? { 242 match self.process(&pair) { 243 Ok(status) => tally.record(status), 244 Err(e) => { 245 eprintln!("Error processing {}: {e:#}", pair.label); 246 tally.errors += 1; 247 } 248 } 249 } 250 251 let header = match self.mode { 252 Mode::Plan => "Plan summary", 253 Mode::Pull => "Pull complete", 254 Mode::Push => "Push complete", 255 }; 256 tally.print(header); 257 258 if self.mode == Mode::Push && tally.changed() > 0 { 259 self.upload(staging, single_file)?; 260 } 261 262 Ok(tally) 263 } 264 265 /// Enumerate the file pair(s) to process: one per file in single-file 266 /// mode, or one per file present in either directory tree otherwise. 267 fn build_pairs( 268 &self, 269 remote_dir: &Path, 270 staging: &Path, 271 single_file: bool, 272 ) -> Result<Vec<Pair>> { 273 if single_file { 274 let name = self.remote_file_name()?; 275 return Ok(vec![Pair { 276 label: self.local_path.display().to_string(), 277 local: self.local_path.clone(), 278 remote: remote_dir.join(name), 279 staging: staging.join(name), 280 }]); 281 } 282 283 let mut names = BTreeSet::new(); 284 collect_files(&self.local_path, &self.local_path, &mut names)?; 285 collect_files(remote_dir, remote_dir, &mut names)?; 286 Ok(names 287 .into_iter() 288 .map(|rel| Pair { 289 label: rel.display().to_string(), 290 local: self.local_path.join(&rel), 291 remote: remote_dir.join(&rel), 292 staging: staging.join(&rel), 293 }) 294 .collect()) 295 } 296 297 fn process(&self, pair: &Pair) -> Result<FileStatus> { 298 let local = read_optional(&pair.local)?; 299 let remote = read_optional(&pair.remote)?; 300 let merged = merge(local.as_deref(), remote.as_deref()); 301 302 match self.mode { 303 Mode::Plan => Ok(plan( 304 &pair.label, 305 local.as_deref(), 306 remote.as_deref(), 307 &merged, 308 )), 309 Mode::Pull => apply(&pair.label, local.as_deref(), &merged, &pair.local, "local"), 310 Mode::Push => apply( 311 &pair.label, 312 remote.as_deref(), 313 &merged, 314 &pair.staging, 315 "remote", 316 ), 317 } 318 } 319 320 /// The file name component of --remote, required in single-file mode. 321 fn remote_file_name(&self) -> Result<&std::ffi::OsStr> { 322 self.remote_path 323 .file_name() 324 .context("--remote must name a file in single-file mode") 325 } 326 327 fn download(&self, dest: &Path, single_file: bool) -> Result<()> { 328 // A trailing slash copies a directory's *contents*; without one rsync 329 // copies the single file into the temp dir under its own name. 330 let source = if single_file { 331 format!("{}:{}", self.host_alias, self.remote_path.display()) 332 } else { 333 format!("{}:{}/", self.host_alias, self.remote_path.display()) 334 }; 335 self.rsync(&source, &dest.to_string_lossy()) 336 } 337 338 fn upload(&self, staging: &Path, single_file: bool) -> Result<()> { 339 let (source, dest) = if single_file { 340 let name = self.remote_file_name()?; 341 ( 342 staging.join(name).to_string_lossy().into_owned(), 343 format!("{}:{}", self.host_alias, self.remote_path.display()), 344 ) 345 } else { 346 ( 347 format!("{}/", staging.display()), 348 format!("{}:{}/", self.host_alias, self.remote_path.display()), 349 ) 350 }; 351 eprintln!("Uploading to {dest}"); 352 self.rsync(&source, &dest) 353 } 354 355 /// Run rsync with non-interactive SSH so it fails fast in automation 356 /// instead of blocking on a password or host-key prompt. When elevation 357 /// is requested it is applied to the *remote* rsync via --rsync-path; the 358 /// local rsync always runs unprivileged. 359 fn rsync(&self, source: &str, dest: &str) -> Result<()> { 360 let mut cmd = Command::new("rsync"); 361 cmd.arg("-az").arg("-e").arg("ssh -o BatchMode=yes"); 362 if let Some(elevate) = self.elevate { 363 cmd.arg(format!("--rsync-path={elevate} rsync")); 364 } 365 if self.timeout > 0 { 366 cmd.arg(format!("--timeout={}", self.timeout)); 367 } 368 cmd.arg(source).arg(dest); 369 370 let status = cmd 371 .status() 372 .context("Failed to execute rsync (is it installed?)")?; 373 if !status.success() { 374 anyhow::bail!("rsync exited with status: {status}"); 375 } 376 Ok(()) 377 } 378 } 379 380 /// Which end of the sync a planned change applies to. 381 #[derive(Clone, Copy)] 382 enum Side { 383 Local, 384 Remote, 385 } 386 387 impl Side { 388 /// The tag printed in front of this side's diff, colour-coded so local and 389 /// remote are easy to tell apart at a glance. 390 fn tag(self) -> console::StyledObject<&'static str> { 391 match self { 392 Side::Local => style(" local ").black().on_cyan().bold(), 393 Side::Remote => style(" remote ").black().on_magenta().bold(), 394 } 395 } 396 } 397 398 /// Preview the changes both directions would make to a single file, printing 399 /// a per-side diff for each side that has pending changes. Writes nothing. 400 fn plan(label: &str, local: Option<&str>, remote: Option<&str>, merged: &str) -> FileStatus { 401 let local_current = local.unwrap_or(""); 402 let remote_current = remote.unwrap_or(""); 403 let local_changes = local_current != merged; 404 let remote_changes = remote_current != merged; 405 406 if !local_changes && !remote_changes { 407 return FileStatus::Unchanged; 408 } 409 410 println!("{}", style(label).bold().underlined()); 411 if local_changes { 412 print_side_diff(Side::Local, local_current, merged); 413 } 414 if remote_changes { 415 print_side_diff(Side::Remote, remote_current, merged); 416 } 417 println!(); 418 FileStatus::Updated 419 } 420 421 /// Apply the merged content to one side, writing `dest` atomically. `current` 422 /// is that side's existing content (`None` if the file is absent there). 423 fn apply( 424 label: &str, 425 current: Option<&str>, 426 merged: &str, 427 dest: &Path, 428 side: &str, 429 ) -> Result<FileStatus> { 430 let exists = current.is_some(); 431 if current.unwrap_or("") == merged { 432 return Ok(FileStatus::Unchanged); 433 } 434 435 // Never create an empty new file. 436 if !merged.is_empty() || exists { 437 write_atomic(dest, merged)?; 438 } 439 440 if exists { 441 eprintln!("Updating {label} on {side}"); 442 Ok(FileStatus::Updated) 443 } else { 444 eprintln!("Creating {label} on {side}"); 445 Ok(FileStatus::Created) 446 } 447 } 448 449 /// Recursively collect the paths of regular files under `dir`, expressed 450 /// relative to `root`. Symlinks are skipped to avoid traversal loops. 451 fn collect_files(root: &Path, dir: &Path, out: &mut BTreeSet<PathBuf>) -> Result<()> { 452 for entry in fs::read_dir(dir).with_context(|| format!("Failed to read {}", dir.display()))? { 453 let entry = entry?; 454 let path = entry.path(); 455 let file_type = entry.file_type()?; 456 if file_type.is_dir() { 457 collect_files(root, &path, out)?; 458 } else if file_type.is_file() { 459 let rel = path 460 .strip_prefix(root) 461 .with_context(|| format!("{} is not under {}", path.display(), root.display()))?; 462 out.insert(rel.to_path_buf()); 463 } 464 } 465 Ok(()) 466 } 467 468 /// Read a file, returning `None` if it does not exist. 469 fn read_optional(path: &Path) -> Result<Option<String>> { 470 match fs::read_to_string(path) { 471 Ok(s) => Ok(Some(s)), 472 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), 473 Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())), 474 } 475 } 476 477 /// Write `contents` to `path` atomically: stage in a temp file in the same 478 /// directory and rename into place, so a crash mid-write can't truncate or 479 /// corrupt the target. 480 fn write_atomic(path: &Path, contents: &str) -> Result<()> { 481 let dir = path 482 .parent() 483 .with_context(|| format!("{} has no parent directory", path.display()))?; 484 fs::create_dir_all(dir).with_context(|| format!("Failed to create {}", dir.display()))?; 485 486 let mut tmp = tempfile::NamedTempFile::new_in(dir) 487 .with_context(|| format!("Failed to create temp file in {}", dir.display()))?; 488 tmp.write_all(contents.as_bytes()) 489 .with_context(|| format!("Failed to write {}", path.display()))?; 490 tmp.persist(path) 491 .map_err(|e| e.error) 492 .with_context(|| format!("Failed to persist {}", path.display()))?; 493 Ok(()) 494 } 495 496 /// Merge two sides into the file content dam would write. 497 /// 498 /// When the file exists on both sides the result is the de-duplicated union 499 /// of their non-empty lines: the local lines in their original order, then 500 /// the lines only the remote has. Order is never changed, so pushing a 501 /// reordered file propagates that order instead of fighting it, and merging 502 /// an already-merged file is a no-op. When the file exists on only one side 503 /// that side's content is propagated verbatim. 504 fn merge(local: Option<&str>, remote: Option<&str>) -> String { 505 match (local, remote) { 506 (Some(l), Some(r)) => { 507 let merged = union_lines(l, r); 508 if merged.is_empty() { 509 String::new() 510 } else { 511 format!("{}\n", merged.join("\n")) 512 } 513 } 514 (Some(only), None) | (None, Some(only)) => only.to_string(), 515 (None, None) => String::new(), 516 } 517 } 518 519 /// De-duplicated union of the non-blank lines of `a` and `b`, in first-seen 520 /// order: all of `a`'s lines, then the lines only `b` has. A trailing 521 /// carriage return is stripped so CRLF and LF inputs compare equal. 522 fn union_lines(a: &str, b: &str) -> Vec<String> { 523 let mut seen = HashSet::new(); 524 a.lines() 525 .chain(b.lines()) 526 .map(|line| line.trim_end_matches('\r')) 527 .filter(|line| !line.trim().is_empty()) 528 .filter(|line| seen.insert(*line)) 529 .map(str::to_string) 530 .collect() 531 } 532 533 /// Print one side's planned change to stdout: a tagged header naming the side 534 /// with its added/removed counts, followed by only the changed lines. 535 /// 536 /// `current` is that side's existing content and `merged` the proposed result, 537 /// so `+` lines are gained and `-` lines lost. Unchanged lines are omitted to 538 /// keep the plan focused on the delta. 539 fn print_side_diff(side: Side, current: &str, merged: &str) { 540 // Diff the lines themselves rather than the raw text: `lines()` drops the 541 // terminators, so a missing trailing newline never shows up as a change. 542 let current: Vec<&str> = current.lines().collect(); 543 let merged: Vec<&str> = merged.lines().collect(); 544 let diff = TextDiff::from_slices(¤t, &merged); 545 546 // `true` marks an inserted line, `false` a deleted one; equal lines are 547 // dropped here so the body below is purely the delta. 548 let mut changes: Vec<(bool, &str)> = Vec::new(); 549 let (mut added, mut removed) = (0u32, 0u32); 550 for change in diff.iter_all_changes() { 551 match change.tag() { 552 ChangeTag::Insert => added += 1, 553 ChangeTag::Delete => removed += 1, 554 ChangeTag::Equal => continue, 555 } 556 changes.push((change.tag() == ChangeTag::Insert, change.value())); 557 } 558 559 println!( 560 " {} {} {}", 561 side.tag(), 562 style(format!("+{added}")).green(), 563 style(format!("-{removed}")).red(), 564 ); 565 for (inserted, line) in changes { 566 if inserted { 567 println!(" {}", style(format!("+{line}")).green()); 568 } else { 569 println!(" {}", style(format!("-{line}")).red()); 570 } 571 } 572 } 573 574 /// Parse the command line and run the configured sync, returning the process 575 /// exit code. 576 /// 577 /// # Errors 578 /// 579 /// Returns an error when the sync cannot run at all: rsync fails or is 580 /// missing, or a temporary directory cannot be created. Per-file processing 581 /// failures do not abort the run; they are reported on stderr and mapped to 582 /// exit code 2. 583 pub fn run() -> Result<ExitCode> { 584 let cli = Cli::parse(); 585 586 let mode = cli.mode.mode(); 587 588 let worker = FileSyncWorker { 589 host_alias: cli.host, 590 local_path: cli.local, 591 remote_path: cli.remote, 592 mode, 593 timeout: cli.timeout, 594 elevate: cli.elevate.command(), 595 }; 596 let tally = worker.run()?; 597 598 let code = if tally.errors > 0 { 599 ExitCode::from(2) 600 } else if cli.exit_code && mode == Mode::Plan && tally.changed() > 0 { 601 ExitCode::from(1) 602 } else { 603 ExitCode::SUCCESS 604 }; 605 Ok(code) 606 } 607 608 #[cfg(test)] 609 mod tests { 610 use super::*; 611 612 #[test] 613 fn union_dedups_and_drops_blank_lines() { 614 let got = union_lines("banana\napple\n\napple", "cherry\napple"); 615 assert_eq!(got, vec!["banana", "apple", "cherry"]); 616 } 617 618 #[test] 619 fn union_keeps_local_order_and_appends_remote_extras() { 620 let got = union_lines("file10\nfile2\nfile1", "file0\nfile2"); 621 assert_eq!(got, vec!["file10", "file2", "file1", "file0"]); 622 } 623 624 #[test] 625 fn union_normalises_crlf() { 626 let got = union_lines("a\r\nb\r\n", "b\r\nc\r"); 627 assert_eq!(got, vec!["a", "b", "c"]); 628 } 629 630 #[test] 631 fn merge_unions_when_both_present() { 632 assert_eq!(merge(Some("b\na"), Some("c\na")), "b\na\nc\n"); 633 } 634 635 #[test] 636 fn merge_keeps_local_order_when_sides_hold_the_same_lines() { 637 // A reordered playlist: pushing must propagate the local order, so a 638 // follow-up plan sees the local side as unchanged (no push/pull loop). 639 let merged = merge(Some("t3\nt1\nt2\n"), Some("t1\nt2\nt3\n")); 640 assert_eq!(merged, "t3\nt1\nt2\n"); 641 assert_eq!( 642 plan("f", Some("t3\nt1\nt2\n"), Some(&merged), &merged), 643 FileStatus::Unchanged 644 ); 645 } 646 647 #[test] 648 fn merge_is_idempotent() { 649 // Once both sides hold the merged content, further runs change nothing. 650 let merged = merge(Some("z\nm\na\n"), Some("m\nq\n")); 651 assert_eq!(merge(Some(&merged), Some(&merged)), merged); 652 } 653 654 #[test] 655 fn merge_propagates_single_side_verbatim() { 656 // Order is preserved and nothing is sorted when only one side has it. 657 assert_eq!(merge(Some("z\na\n"), None), "z\na\n"); 658 assert_eq!(merge(None, Some("z\na\n")), "z\na\n"); 659 } 660 661 #[test] 662 fn merge_is_empty_when_neither_present() { 663 assert_eq!(merge(None, None), ""); 664 } 665 666 #[test] 667 fn merge_of_blank_only_content_is_empty() { 668 assert_eq!(merge(Some("\n\n"), Some(" ")), ""); 669 } 670 671 #[test] 672 fn plan_reports_unchanged_when_sides_match() { 673 let merged = merge(Some("a\nb\n"), Some("a\nb\n")); 674 assert_eq!( 675 plan("f", Some("a\nb\n"), Some("a\nb\n"), &merged), 676 FileStatus::Unchanged 677 ); 678 } 679 680 #[test] 681 fn plan_reports_change_when_sides_differ() { 682 let merged = merge(Some("a"), Some("b")); 683 assert_eq!( 684 plan("f", Some("a"), Some("b"), &merged), 685 FileStatus::Updated 686 ); 687 } 688 689 #[test] 690 fn apply_creates_updates_and_skips() { 691 let dir = tempfile::tempdir().unwrap(); 692 let dest = dir.path().join("f"); 693 694 // Absent target with content -> Created and written. 695 let created = apply("f", None, "a\nb\n", &dest, "local").unwrap(); 696 assert_eq!(created, FileStatus::Created); 697 assert_eq!(fs::read_to_string(&dest).unwrap(), "a\nb\n"); 698 699 // Existing target, new content -> Updated. 700 let updated = apply("f", Some("a\nb\n"), "a\nb\nc\n", &dest, "local").unwrap(); 701 assert_eq!(updated, FileStatus::Updated); 702 assert_eq!(fs::read_to_string(&dest).unwrap(), "a\nb\nc\n"); 703 704 // No difference -> Unchanged, no write needed. 705 let same = apply("f", Some("x"), "x", &dest, "local").unwrap(); 706 assert_eq!(same, FileStatus::Unchanged); 707 } 708 }