1use std::collections::{HashMap, HashSet};
29use std::ffi::{OsStr, OsString};
30use std::io::{self, Read, Write};
31use std::path::{Path, PathBuf};
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard};
34use std::time::{Duration, Instant};
35
36use crate::backend::mps::Broker;
37use crate::backend::rpc_types::{
38 FsOpChoice, FsOpConflict, FsOpEvent, FsOpEventState, FsOpKind, FsOpResult, FsOpStartReq, FsOpStartResult,
39};
40
41use super::platform::{self, display_path};
42use super::{path_key, resolve_entry_path, resolve_request_path, ProtectedPaths};
43
44pub const EVENT_FILES_OP: &str = "files:op";
47
48const PROGRESS_INTERVAL: Duration = Duration::from_millis(100);
51
52const MAX_KEEP_BOTH: u32 = 10_000;
54
55const MAX_RACE_RETRIES: usize = 8;
58
59pub type Emit = Arc<dyn Fn(&FsOpEvent) + Send + Sync>;
62
63type ChunkHook = Arc<dyn Fn(&OpControl, u64) + Send + Sync>;
66
67pub fn broker_emitter(broker: Arc<Broker>, block_id: String) -> Emit {
69 let scope = format!("block:{block_id}");
70 Arc::new(move |event: &FsOpEvent| {
71 broker.publish_event::<crate::backend::mps_events::FilesOp>(event, vec![scope.clone()], 0);
72 })
73}
74
75pub struct JobConfig {
79 pub chunk_size: usize,
82 pub max_concurrent: usize,
84 pub conflict_wait_limit: Duration,
88 protect: Option<ProtectedPaths>,
89 assume_cross_volume: bool,
92 after_chunk: Option<ChunkHook>,
93}
94
95impl Default for JobConfig {
96 fn default() -> Self {
97 Self {
98 chunk_size: 1 << 20,
99 max_concurrent: 4,
100 conflict_wait_limit: Duration::from_secs(60 * 60),
101 protect: None,
102 assume_cross_volume: false,
103 after_chunk: None,
104 }
105 }
106}
107
108#[derive(Default)]
110pub struct OpControl {
111 canceled: AtomicBool,
112 question: Mutex<Question>,
113 answered: Condvar,
114}
115
116#[derive(Default)]
117struct Question {
118 waiting: bool,
120 answer: Option<(FsOpChoice, bool)>,
121}
122
123impl OpControl {
124 fn is_canceled(&self) -> bool {
125 self.canceled.load(Ordering::SeqCst)
126 }
127
128 pub fn cancel(&self) {
132 self.canceled.store(true, Ordering::SeqCst);
133 let _question = lock(&self.question);
134 self.answered.notify_all();
135 }
136}
137
138fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
142 m.lock().unwrap_or_else(|e| e.into_inner())
143}
144
145pub struct Jobs {
147 config: JobConfig,
148 ops: Mutex<HashMap<String, Arc<OpControl>>>,
149 active: Mutex<usize>,
150 slot_freed: Condvar,
151}
152
153pub static JOBS: LazyLock<Arc<Jobs>> = LazyLock::new(|| Arc::new(Jobs::new(JobConfig::default())));
156
157impl Jobs {
158 pub fn new(config: JobConfig) -> Self {
159 Self { config, ops: Mutex::new(HashMap::new()), active: Mutex::new(0), slot_freed: Condvar::new() }
160 }
161
162 fn protected(&self) -> ProtectedPaths {
163 self.config.protect.clone().unwrap_or_else(ProtectedPaths::current)
164 }
165
166 pub fn start(self: &Arc<Self>, req: &FsOpStartReq, emit: Emit) -> Result<FsOpStartResult, String> {
169 let plan = plan(req, &self.protected())?;
170 let op_id = uuid::Uuid::new_v4().to_string();
171 let control = Arc::new(OpControl::default());
172 lock(&self.ops).insert(op_id.clone(), control.clone());
173
174 let jobs = self.clone();
175 let id = op_id.clone();
176 let spawned = std::thread::Builder::new()
177 .name("fs-op".to_string())
178 .spawn(move || jobs.run(id, plan, control, emit));
179 if let Err(e) = spawned {
180 lock(&self.ops).remove(&op_id);
181 return Err(format!("Couldn't start the operation: {e}"));
182 }
183 Ok(FsOpStartResult { op_id })
184 }
185
186 pub(super) fn start_host(
192 self: &Arc<Self>,
193 kind: FsOpKind,
194 requested: usize,
195 dest: String,
196 emit: Emit,
197 work: Box<dyn FnOnce(&mut HostJob<'_, '_>) -> Result<(), HostStop> + Send>,
198 ) -> Result<FsOpStartResult, String> {
199 let op_id = uuid::Uuid::new_v4().to_string();
200 let control = Arc::new(OpControl::default());
201 lock(&self.ops).insert(op_id.clone(), control.clone());
202 let jobs = self.clone();
203 let id = op_id.clone();
204 let spawned = std::thread::Builder::new().name("fs-op-host".to_string()).spawn(move || {
205 let _registered = Registered { jobs: &jobs, op_id: &id };
206 let mut job = Job::new(&jobs.config, id.clone(), kind, control.clone(), emit);
207 job.emit_now(FsOpEventState::Running);
208 let ending = match jobs.take_slot(&control) {
209 None => Ending::Canceled,
210 Some(_slot) => {
211 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
212 let mut host = HostJob { job: &mut job };
213 work(&mut host)
214 }));
215 match result {
216 Ok(Ok(())) => Ending::Done,
217 Ok(Err(HostStop::Canceled)) => Ending::Canceled,
218 Err(_) => {
219 tracing::error!(op_id = %id, "fs.op: a host transfer panicked");
220 Ending::Failed("The operation stopped unexpectedly.".to_string())
221 }
222 }
223 }
224 };
225 job.finish(ending, requested, &dest);
226 });
227 if let Err(e) = spawned {
228 lock(&self.ops).remove(&op_id);
229 return Err(format!("Couldn't start the operation: {e}"));
230 }
231 Ok(FsOpStartResult { op_id })
232 }
233
234 pub fn resolve(&self, op_id: &str, choice: FsOpChoice, apply_to_all: bool) -> Result<(), String> {
238 let control = lock(&self.ops)
239 .get(op_id)
240 .cloned()
241 .ok_or_else(|| "That operation has already finished.".to_string())?;
242 let mut question = lock(&control.question);
243 if !question.waiting || question.answer.is_some() {
244 return Err("That operation isn't waiting for an answer.".to_string());
245 }
246 question.answer = Some((choice, apply_to_all));
247 control.answered.notify_all();
248 Ok(())
249 }
250
251 pub fn cancel(&self, op_id: &str) {
254 let Some(control) = lock(&self.ops).get(op_id).cloned() else {
255 return;
256 };
257 control.cancel();
258 let _active = lock(&self.active);
260 self.slot_freed.notify_all();
261 }
262
263 #[cfg(test)]
264 fn op_count(&self) -> usize {
265 lock(&self.ops).len()
266 }
267
268 fn take_slot(&self, control: &OpControl) -> Option<Slot<'_>> {
270 let mut active = lock(&self.active);
271 loop {
272 if control.is_canceled() {
273 return None;
274 }
275 if *active < self.config.max_concurrent {
276 *active += 1;
277 return Some(Slot { jobs: self });
278 }
279 active = self.slot_freed.wait(active).unwrap_or_else(|e| e.into_inner());
280 }
281 }
282
283 fn run(self: Arc<Self>, op_id: String, plan: Plan, control: Arc<OpControl>, emit: Emit) {
286 let _registered = Registered { jobs: &self, op_id: &op_id };
287 let mut job = Job::new(&self.config, op_id.clone(), plan.kind, control.clone(), emit);
288 job.emit_now(FsOpEventState::Running);
290 let ending = match self.take_slot(&control) {
291 None => Ending::Canceled,
292 Some(_slot) => {
293 job.protect = self.protected();
294 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| job.execute(&plan))).unwrap_or_else(|_| {
295 tracing::error!(op_id, "fs.op: the operation panicked");
296 Ending::Failed("The operation stopped unexpectedly.".to_string())
297 })
298 }
299 };
300 job.finish(ending, plan.requested, &display_path(&plan.dest_dir));
301 }
302}
303
304pub(super) enum HostStop {
306 Canceled,
308}
309
310pub(super) struct HostJob<'j, 'c> {
313 job: &'j mut Job<'c>,
314}
315
316impl HostJob<'_, '_> {
317 pub fn is_canceled(&self) -> bool {
318 self.job.control.is_canceled()
319 }
320
321 pub fn chunk_size(&self) -> usize {
323 self.job.config.chunk_size
324 }
325
326 pub fn add_total(&mut self, items: u64, bytes: u64) {
328 self.job.total_items += items;
329 self.job.total_bytes += bytes;
330 self.job.emit_now(FsOpEventState::Running);
331 }
332
333 pub fn set_current(&mut self, path: &str) {
334 self.job.current = Some(path.to_string());
335 }
336
337 pub fn advance(&mut self, items: u64, bytes: u64) {
338 self.job.advance(Measure { items, bytes });
339 self.job.tick();
340 }
341
342 pub fn fail(&mut self, path: &str, error: String) {
343 tracing::debug!(op_id = %self.job.op_id, path = %path, error = %error, "fs.op: an item failed");
344 self.job.failures.push(FsOpResult { path: path.to_string(), ok: false, error: Some(error) });
345 }
346
347 pub fn ask(&mut self, src: &str, src_side: EntrySide, dest: &str, dest_side: EntrySide) -> Option<FsOpChoice> {
350 self.job.ask_about(src, src_side, dest, dest_side)
351 }
352}
353
354struct Registered<'a> {
356 jobs: &'a Jobs,
357 op_id: &'a str,
358}
359
360impl Drop for Registered<'_> {
361 fn drop(&mut self) {
362 lock(&self.jobs.ops).remove(self.op_id);
363 }
364}
365
366struct Slot<'a> {
368 jobs: &'a Jobs,
369}
370
371impl Drop for Slot<'_> {
372 fn drop(&mut self) {
373 *lock(&self.jobs.active) -= 1;
374 self.jobs.slot_freed.notify_all();
377 }
378}
379
380struct Plan {
383 kind: FsOpKind,
384 dest_dir: PathBuf,
385 items: Vec<PlanItem>,
386 requested: usize,
388}
389
390struct PlanItem {
391 src: PathBuf,
392 dest: PathBuf,
393 duplicate: bool,
397}
398
399fn verb(kind: FsOpKind) -> &'static str {
400 match kind {
401 FsOpKind::Copy => "copy",
402 FsOpKind::Move => "move",
403 }
404}
405
406fn plan(req: &FsOpStartReq, protect: &ProtectedPaths) -> Result<Plan, String> {
407 let verb = verb(req.kind);
408 if req.sources.is_empty() {
409 return Err(format!("Nothing was given to {verb}."));
410 }
411 let dest_dir = resolve_request_path(&req.dest_dir)?.canonicalize().map_err(|e| match e.kind() {
412 io::ErrorKind::NotFound => "The destination folder doesn't exist.".to_string(),
413 io::ErrorKind::PermissionDenied => "You don't have permission to open the destination folder.".to_string(),
414 _ => format!("Couldn't open the destination folder: {e}"),
415 })?;
416 if !std::fs::metadata(&dest_dir).is_ok_and(|m| m.is_dir()) {
417 return Err("The destination isn't a folder.".to_string());
418 }
419 let dest_key = path_key(&dest_dir);
420
421 let mut items = Vec::with_capacity(req.sources.len());
422 for raw in &req.sources {
423 let src = resolve_entry_path(raw)?;
424 let name = src.file_name().ok_or_else(|| format!("“{raw}” isn't a valid path."))?.to_os_string();
425 let shown = name.to_string_lossy().into_owned();
426 std::fs::symlink_metadata(&src).map_err(|e| match e.kind() {
427 io::ErrorKind::NotFound => format!("“{shown}” doesn't exist anymore."),
428 io::ErrorKind::PermissionDenied => format!("You don't have permission to {verb} “{shown}”."),
429 _ => format!("Couldn't read “{shown}”: {e}"),
430 })?;
431 let src_key = path_key(&src);
435 if dest_key.starts_with(&src_key) {
436 return Err(format!("Can't {verb} a folder into itself."));
437 }
438 let in_dest_already = src.parent().map(path_key).as_ref() == Some(&dest_key);
439 if in_dest_already && req.kind == FsOpKind::Move {
440 continue;
442 }
443 let dest = dest_dir.join(&name);
444 protect.check(&dest)?;
445 if req.kind == FsOpKind::Move {
446 protect.check(&src)?;
447 }
448 items.push(PlanItem { src, dest, duplicate: in_dest_already });
449 }
450
451 let landing: HashSet<OsString> = items
456 .iter()
457 .filter(|i| !i.duplicate)
458 .filter_map(|i| path_key(&i.dest).file_name().map(OsStr::to_os_string))
459 .collect();
460 for item in &items {
461 let src_key = path_key(&item.src);
462 let Ok(rel) = src_key.strip_prefix(&dest_key) else { continue };
463 let mut parts = rel.components();
464 if let (Some(first), Some(_)) = (parts.next(), parts.next()) {
465 if landing.contains(first.as_os_str()) {
466 return Err(format!(
467 "Can't {verb} there: “{}” would land on the folder that holds “{}”.",
468 first.as_os_str().to_string_lossy(),
469 file_name_lossy(&item.src)
470 ));
471 }
472 }
473 }
474
475 Ok(Plan { kind: req.kind, dest_dir, items, requested: req.sources.len() })
476}
477
478fn file_name_lossy(path: &Path) -> String {
479 path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| display_path(path))
480}
481
482#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486enum Outcome {
487 Done,
489 NotDone,
492 Stopped,
494}
495
496struct Raced;
499
500#[derive(Debug, Clone, Copy, PartialEq, Eq)]
502enum How {
503 New,
505 KeepBoth,
507 Replace(std::fs::FileType),
509}
510
511enum Ending {
512 Done,
513 Canceled,
514 Failed(String),
515}
516
517#[derive(Debug, Clone, Copy, Default)]
520pub(super) struct EntrySide {
521 pub is_dir: bool,
522 pub size: Option<u64>,
524 pub mtime: Option<u64>,
525}
526
527impl EntrySide {
528 fn of(meta: &std::fs::Metadata) -> Self {
529 Self { is_dir: meta.is_dir(), size: meta.is_file().then_some(meta.len()), mtime: mtime_ms(meta) }
530 }
531}
532
533#[derive(Debug, Clone, Copy, Default)]
535struct Measure {
536 items: u64,
537 bytes: u64,
538}
539
540impl Measure {
541 fn of(meta: &std::fs::Metadata) -> Self {
542 Self { items: 1, bytes: if meta.is_file() { meta.len() } else { 0 } }
543 }
544}
545
546enum CopyError {
547 Stopped,
548 Failed(String),
549}
550
551struct Job<'a> {
552 config: &'a JobConfig,
553 op_id: String,
554 kind: FsOpKind,
555 control: Arc<OpControl>,
556 emit: Emit,
557 protect: ProtectedPaths,
558 done_items: u64,
559 total_items: u64,
560 done_bytes: u64,
561 total_bytes: u64,
562 current: Option<String>,
563 failures: Vec<FsOpResult>,
564 sticky: Option<FsOpChoice>,
566 stop_reason: Option<String>,
568 last_running: Option<Instant>,
569 buf: Vec<u8>,
570}
571
572impl<'a> Job<'a> {
573 fn new(config: &'a JobConfig, op_id: String, kind: FsOpKind, control: Arc<OpControl>, emit: Emit) -> Self {
574 Self {
575 config,
576 op_id,
577 kind,
578 control,
579 emit,
580 protect: ProtectedPaths::default(),
581 done_items: 0,
582 total_items: 0,
583 done_bytes: 0,
584 total_bytes: 0,
585 current: None,
586 failures: Vec::new(),
587 sticky: None,
588 stop_reason: None,
589 last_running: None,
590 buf: Vec::new(),
591 }
592 }
593
594 fn verb(&self) -> &'static str {
595 verb(self.kind)
596 }
597
598 fn event(&self, state: FsOpEventState) -> FsOpEvent {
601 FsOpEvent {
602 op_id: self.op_id.clone(),
603 kind: self.kind,
604 state,
605 done_items: self.done_items,
606 total_items: self.total_items,
607 done_bytes: self.done_bytes,
608 total_bytes: self.total_bytes,
609 current: self.current.clone(),
610 conflict: None,
611 error: None,
612 failures: None,
613 }
614 }
615
616 fn emit_now(&mut self, state: FsOpEventState) {
617 if state == FsOpEventState::Running {
618 self.last_running = Some(Instant::now());
619 }
620 (self.emit)(&self.event(state));
621 }
622
623 fn tick(&mut self) {
627 if self.last_running.is_none_or(|t| t.elapsed() >= PROGRESS_INTERVAL) {
628 self.emit_now(FsOpEventState::Running);
629 }
630 }
631
632 fn set_current(&mut self, src: &Path) {
633 self.current = Some(display_path(src));
634 }
635
636 fn advance(&mut self, m: Measure) {
637 self.done_items += m.items;
638 self.done_bytes += m.bytes;
639 }
640
641 fn fail(&mut self, src: &Path, error: String) {
642 tracing::debug!(op_id = %self.op_id, path = %display_path(src), error = %error, "fs.op: an item failed");
643 self.failures.push(FsOpResult { path: display_path(src), ok: false, error: Some(error) });
644 }
645
646 fn pass_over(&mut self, src: &Path, meta: &std::fs::Metadata, hint: Option<Measure>) {
650 let m = match hint {
651 Some(m) => m,
652 None if meta.is_dir() => self.measure(src).unwrap_or_default(),
653 None => Measure::of(meta),
654 };
655 self.advance(m);
656 }
657
658 fn finish(&mut self, ending: Ending, requested: usize, dest: &str) {
659 let (state, error, outcome) = match ending {
660 Ending::Done => (FsOpEventState::Done, None, "done"),
661 Ending::Canceled => (FsOpEventState::Canceled, self.stop_reason.clone(), "canceled"),
662 Ending::Failed(e) => (FsOpEventState::Failed, Some(e), "failed"),
663 };
664 tracing::info!(
665 op = "fs.op",
666 op_id = %self.op_id,
667 kind = self.verb(),
668 sources = requested,
669 dest = %dest,
670 outcome,
671 done_items = self.done_items,
672 total_items = self.total_items,
673 failures = self.failures.len(),
674 error = error.as_deref().unwrap_or(""),
675 "fs mutation"
676 );
677 let mut event = self.event(state);
678 event.current = None;
679 event.error = error;
680 event.failures = (!self.failures.is_empty()).then(|| self.failures.clone());
681 (self.emit)(&event);
682 }
683
684 fn execute(&mut self, plan: &Plan) -> Ending {
687 if !std::fs::symlink_metadata(&plan.dest_dir).is_ok_and(|m| m.is_dir()) {
689 return Ending::Failed("The destination folder doesn't exist anymore.".to_string());
690 }
691 self.buf = vec![0; self.config.chunk_size.max(1)];
692
693 let mut measures = Vec::with_capacity(plan.items.len());
695 for item in &plan.items {
696 let Some(m) = self.measure(&item.src) else { return Ending::Canceled };
697 self.total_items += m.items;
698 self.total_bytes += m.bytes;
699 measures.push(m);
700 }
701 self.emit_now(FsOpEventState::Running);
702
703 for (item, m) in plan.items.iter().zip(measures) {
704 let outcome = self.transfer(&item.src, &item.dest, item.duplicate, Some(m), false);
705 if outcome == Outcome::Stopped {
706 return Ending::Canceled;
707 }
708 }
709 Ending::Done
710 }
711
712 fn measure(&self, path: &Path) -> Option<Measure> {
716 let mut m = Measure::default();
717 let Ok(root) = std::fs::symlink_metadata(path) else { return Some(Measure { items: 1, bytes: 0 }) };
718 let mut stack = vec![(path.to_path_buf(), root)];
719 while let Some((p, meta)) = stack.pop() {
720 if self.control.is_canceled() {
721 return None;
722 }
723 let own = Measure::of(&meta);
724 m.items += own.items;
725 m.bytes += own.bytes;
726 if meta.is_dir() {
727 let Ok(entries) = std::fs::read_dir(&p) else { continue };
728 for entry in entries.flatten() {
730 if let Ok(meta) = entry.metadata() {
731 stack.push((entry.path(), meta));
732 }
733 }
734 }
735 }
736 Some(m)
737 }
738
739 fn transfer(&mut self, src: &Path, dest: &Path, duplicate: bool, hint: Option<Measure>, cross: bool) -> Outcome {
744 if self.control.is_canceled() {
745 return Outcome::Stopped;
746 }
747 self.set_current(src);
748 self.tick();
749
750 let meta = match std::fs::symlink_metadata(src) {
751 Ok(meta) => meta,
752 Err(e) => {
753 let message = match e.kind() {
754 io::ErrorKind::NotFound => format!("“{}” doesn't exist anymore.", file_name_lossy(src)),
755 _ => describe(&e, "read", src),
756 };
757 self.fail(src, message);
758 self.advance(hint.unwrap_or(Measure { items: 1, bytes: 0 }));
759 return Outcome::NotDone;
760 }
761 };
762 let checked = self.protect.check(dest).and_then(|()| match self.kind {
766 FsOpKind::Move => self.protect.check(src),
767 FsOpKind::Copy => Ok(()),
768 });
769 if let Err(message) = checked {
770 self.fail(src, message);
771 self.pass_over(src, &meta, hint);
772 return Outcome::NotDone;
773 }
774
775 for _ in 0..MAX_RACE_RETRIES {
776 let how = match std::fs::symlink_metadata(dest) {
777 Err(e) if e.kind() == io::ErrorKind::NotFound => How::New,
778 Err(e) => {
779 self.fail(src, format!("Couldn't check the destination of “{}”: {e}", file_name_lossy(src)));
780 self.pass_over(src, &meta, hint);
781 return Outcome::NotDone;
782 }
783 Ok(_) if duplicate => How::KeepBoth,
784 Ok(dest_meta) if meta.is_dir() && dest_meta.is_dir() => return self.merge(src, dest, cross),
787 Ok(dest_meta) => match self.ask(src, &meta, dest, &dest_meta) {
788 None => return Outcome::Stopped,
789 Some(FsOpChoice::Skip) => {
790 self.pass_over(src, &meta, hint);
791 return Outcome::NotDone;
792 }
793 Some(FsOpChoice::KeepBoth) => How::KeepBoth,
794 Some(FsOpChoice::Replace) => How::Replace(dest_meta.file_type()),
795 },
796 };
797 match self.place(src, &meta, dest, how, hint, cross) {
798 Ok(outcome) => return outcome,
799 Err(Raced) => continue,
800 }
801 }
802 self.fail(src, format!("“{}” kept changing at the destination, so it was left alone.", file_name_lossy(src)));
803 self.pass_over(src, &meta, hint);
804 Outcome::NotDone
805 }
806
807 fn ask(&mut self, src: &Path, meta: &std::fs::Metadata, dest: &Path, dest_meta: &std::fs::Metadata) -> Option<FsOpChoice> {
810 self.ask_about(&display_path(src), EntrySide::of(meta), &display_path(dest), EntrySide::of(dest_meta))
811 }
812
813 fn ask_about(&mut self, src: &str, meta: EntrySide, dest: &str, dest_meta: EntrySide) -> Option<FsOpChoice> {
816 if let Some(choice) = self.sticky {
817 return Some(choice);
818 }
819 {
822 let mut question = lock(&self.control.question);
823 question.waiting = true;
824 question.answer = None;
825 }
826 let mut event = self.event(FsOpEventState::Conflict);
827 event.conflict = Some(FsOpConflict {
828 source: src.to_string(),
829 dest: dest.to_string(),
830 source_is_dir: meta.is_dir,
831 dest_is_dir: dest_meta.is_dir,
832 source_size: meta.size,
833 dest_size: dest_meta.size,
834 source_mtime: meta.mtime,
835 dest_mtime: dest_meta.mtime,
836 });
837 (self.emit)(&event);
838
839 let deadline = Instant::now() + self.config.conflict_wait_limit;
840 let answer = {
841 let mut question = lock(&self.control.question);
842 let answer = loop {
843 if self.control.is_canceled() {
844 break None;
845 }
846 if let Some(answer) = question.answer.take() {
847 break Some(answer);
848 }
849 let now = Instant::now();
850 if now >= deadline {
851 let name = src.rsplit(['/', '\\']).find(|s| !s.is_empty()).unwrap_or(src);
852 self.stop_reason = Some(format!("No one answered about “{name}”, so the operation stopped."));
853 break None;
854 }
855 question = self.control.answered.wait_timeout(question, deadline - now).unwrap_or_else(|e| e.into_inner()).0;
856 };
857 question.waiting = false;
858 answer
859 };
860 let (choice, apply_to_all) = answer?;
861 if apply_to_all {
862 self.sticky = Some(choice);
863 }
864 self.emit_now(FsOpEventState::Running);
865 Some(choice)
866 }
867
868 fn place(
870 &mut self,
871 src: &Path,
872 meta: &std::fs::Metadata,
873 dest: &Path,
874 mut how: How,
875 hint: Option<Measure>,
876 cross: bool,
877 ) -> Result<Outcome, Raced> {
878 if let How::Replace(asked) = how {
879 match std::fs::symlink_metadata(dest) {
883 Ok(now) if now.file_type() == asked => {}
884 Err(e) if e.kind() == io::ErrorKind::NotFound => how = How::New,
885 _ => return Err(Raced),
886 }
887 }
888 let mut cross = cross || self.config.assume_cross_volume;
889 if self.kind == FsOpKind::Move && !cross {
890 match self.place_by_rename(src, meta, dest, how, hint) {
891 Renamed::Finished(outcome) => return Ok(outcome),
892 Renamed::Raced => return Err(Raced),
893 Renamed::CrossVolume => cross = true,
895 }
896 }
897 if meta.is_file() {
898 self.place_file(src, meta, dest, how)
899 } else if meta.is_symlink() {
900 self.place_link(src, meta, dest, how)
901 } else if meta.is_dir() {
902 self.place_dir(src, meta, dest, how, cross)
903 } else {
904 self.fail(src, format!("“{}” isn't a file, folder or link, so it can't be copied.", file_name_lossy(src)));
907 self.advance(Measure::of(meta));
908 Ok(Outcome::NotDone)
909 }
910 }
911
912 fn place_by_rename(&mut self, src: &Path, meta: &std::fs::Metadata, dest: &Path, how: How, hint: Option<Measure>) -> Renamed {
915 let m = match hint {
917 Some(m) => m,
918 None if meta.is_dir() => self.measure(src).unwrap_or_default(),
919 None => Measure::of(meta),
920 };
921 let result = match how {
922 How::New => platform::rename_no_replace(src, dest),
923 How::KeepBoth => keep_both(dest, meta.is_dir(), &self.protect, |c| platform::rename_no_replace(src, c)).map(drop),
924 How::Replace(_) => match std::fs::symlink_metadata(dest) {
925 Ok(d) if meta.is_file() && d.is_file() => std::fs::rename(src, dest),
927 Ok(_) => self.replace_aside(src, dest, |d| platform::rename_no_replace(src, d)),
928 Err(e) if e.kind() == io::ErrorKind::NotFound => platform::rename_no_replace(src, dest),
929 Err(e) => Err(e),
930 },
931 };
932 match result {
933 Ok(()) => {
934 self.advance(m);
935 Renamed::Finished(Outcome::Done)
936 }
937 Err(e) if e.kind() == io::ErrorKind::CrossesDevices => Renamed::CrossVolume,
938 Err(e) if e.kind() == io::ErrorKind::AlreadyExists && how == How::New => Renamed::Raced,
939 Err(e) => {
940 let message = describe(&e, "move", src);
941 self.fail(src, message);
942 self.advance(m);
943 Renamed::Finished(Outcome::NotDone)
944 }
945 }
946 }
947
948 fn place_file(&mut self, src: &Path, meta: &std::fs::Metadata, dest: &Path, how: How) -> Result<Outcome, Raced> {
951 let start_bytes = self.done_bytes;
952 let tmp = match self.copy_to_temp(src, meta, dest) {
953 Ok(tmp) => tmp,
954 Err(CopyError::Stopped) => return Ok(Outcome::Stopped),
955 Err(CopyError::Failed(message)) => {
956 self.fail(src, message);
957 self.settle_file(start_bytes, meta);
958 return Ok(Outcome::NotDone);
959 }
960 };
961 let placed = match how {
962 How::New => match platform::rename_no_replace(&tmp, dest) {
963 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
964 discard(&tmp);
965 self.done_bytes = start_bytes;
966 return Err(Raced);
967 }
968 other => other,
969 },
970 How::KeepBoth => keep_both(dest, false, &self.protect, |c| platform::rename_no_replace(&tmp, c)).map(drop),
971 How::Replace(_) => match std::fs::symlink_metadata(dest) {
972 Ok(d) if d.is_file() => std::fs::rename(&tmp, dest),
975 Ok(_) => self.replace_aside(src, dest, |d| platform::rename_no_replace(&tmp, d)),
976 Err(e) if e.kind() == io::ErrorKind::NotFound => platform::rename_no_replace(&tmp, dest),
977 Err(e) => Err(e),
978 },
979 };
980 self.settle_file(start_bytes, meta);
981 if let Err(e) = placed {
982 discard(&tmp);
983 let message = describe(&e, self.verb(), src);
984 self.fail(src, message);
985 return Ok(Outcome::NotDone);
986 }
987 if self.kind == FsOpKind::Move {
988 if let Err(e) = platform::remove_file_force(src) {
990 self.fail(src, format!("Copied “{}”, but couldn't remove the original: {e}", file_name_lossy(src)));
991 return Ok(Outcome::NotDone);
992 }
993 }
994 Ok(Outcome::Done)
995 }
996
997 fn settle_file(&mut self, start_bytes: u64, meta: &std::fs::Metadata) {
1000 self.done_bytes = self.done_bytes.max(start_bytes + meta.len());
1001 self.done_items += 1;
1002 }
1003
1004 fn copy_to_temp(&mut self, src: &Path, meta: &std::fs::Metadata, dest: &Path) -> Result<PathBuf, CopyError> {
1007 let name = file_name_lossy(src);
1008 let mut from = platform::open_no_follow(src).map_err(|e| CopyError::Failed(describe(&e, "read", src)))?;
1009 match from.metadata() {
1012 Ok(m) if m.is_file() => {}
1013 Ok(_) => return Err(CopyError::Failed(format!("“{name}” changed while it was being copied."))),
1014 Err(e) => return Err(CopyError::Failed(describe(&e, "read", src))),
1015 }
1016 let tmp = sibling_temp(dest, "part");
1017 let mut to = std::fs::OpenOptions::new()
1018 .write(true)
1019 .create_new(true)
1020 .open(&tmp)
1021 .map_err(|e| CopyError::Failed(describe(&e, self.verb(), src)))?;
1022
1023 let result = self.stream(&mut from, &mut to, src).and_then(|written| {
1024 if let Ok(mtime) = meta.modified() {
1027 let _ = to.set_modified(mtime);
1028 }
1029 if self.kind == FsOpKind::Move {
1030 to.sync_all().map_err(|e| CopyError::Failed(describe(&e, "move", src)))?;
1033 let now_len = from.metadata().map(|m| m.len()).unwrap_or(u64::MAX);
1034 let copied_len = to.metadata().map(|m| m.len()).unwrap_or(u64::MAX - 1);
1035 if now_len != written || copied_len != written {
1036 return Err(CopyError::Failed(format!(
1037 "“{name}” changed while it was being moved, so the original was kept."
1038 )));
1039 }
1040 }
1041 let _ = to.set_permissions(meta.permissions());
1043 Ok(())
1044 });
1045 drop(to);
1046 match result {
1047 Ok(()) => Ok(tmp),
1048 Err(e) => {
1049 discard(&tmp);
1050 Err(e)
1051 }
1052 }
1053 }
1054
1055 fn stream(&mut self, from: &mut std::fs::File, to: &mut std::fs::File, src: &Path) -> Result<u64, CopyError> {
1058 let mut buf = std::mem::take(&mut self.buf);
1060 let result = self.stream_with(&mut buf, from, to, src);
1061 self.buf = buf;
1062 result
1063 }
1064
1065 fn stream_with(&mut self, buf: &mut [u8], from: &mut std::fs::File, to: &mut std::fs::File, src: &Path) -> Result<u64, CopyError> {
1066 let mut written = 0u64;
1067 loop {
1068 if self.control.is_canceled() {
1069 return Err(CopyError::Stopped);
1070 }
1071 let n = match from.read(buf) {
1072 Ok(0) => return Ok(written),
1073 Ok(n) => n,
1074 Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
1075 Err(e) => return Err(CopyError::Failed(describe(&e, "read", src))),
1076 };
1077 to.write_all(&buf[..n]).map_err(|e| CopyError::Failed(describe(&e, self.verb(), src)))?;
1078 written += n as u64;
1079 self.done_bytes += n as u64;
1080 self.tick();
1081 if let Some(hook) = &self.config.after_chunk {
1082 hook(&self.control, written);
1083 }
1084 }
1085 }
1086
1087 fn place_link(&mut self, src: &Path, meta: &std::fs::Metadata, dest: &Path, how: How) -> Result<Outcome, Raced> {
1088 let target = match std::fs::read_link(src) {
1089 Ok(target) => target,
1090 Err(e) => {
1091 let message = describe(&e, "read", src);
1092 self.fail(src, message);
1093 self.advance(Measure::of(meta));
1094 return Ok(Outcome::NotDone);
1095 }
1096 };
1097 let kind = meta.file_type();
1098 let make = |at: &Path| platform::create_symlink(&target, at, &kind);
1099 let made = match how {
1100 How::New => match make(dest) {
1101 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => return Err(Raced),
1102 other => other,
1103 },
1104 How::KeepBoth => keep_both(dest, false, &self.protect, make).map(drop),
1105 How::Replace(_) => self.replace_aside(src, dest, make),
1106 };
1107 self.advance(Measure::of(meta));
1108 if let Err(e) = made {
1109 let message = if platform::is_symlink_privilege_error(&e) {
1110 format!(
1111 "Windows didn't allow creating the link “{}”. Turn on Developer Mode, or run AgentMux as an administrator.",
1112 file_name_lossy(src)
1113 )
1114 } else {
1115 describe(&e, self.verb(), src)
1116 };
1117 self.fail(src, message);
1118 return Ok(Outcome::NotDone);
1119 }
1120 if self.kind == FsOpKind::Move {
1121 if let Err(e) = platform::remove_entry_no_follow(src) {
1122 self.fail(src, format!("Copied the link “{}”, but couldn't remove the original: {e}", file_name_lossy(src)));
1123 return Ok(Outcome::NotDone);
1124 }
1125 }
1126 Ok(Outcome::Done)
1127 }
1128
1129 fn place_dir(&mut self, src: &Path, meta: &std::fs::Metadata, dest: &Path, how: How, cross: bool) -> Result<Outcome, Raced> {
1130 let mut aside = None;
1131 let created = match how {
1132 How::New => match std::fs::create_dir(dest) {
1133 Ok(()) => Ok(dest.to_path_buf()),
1134 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => return Err(Raced),
1136 Err(e) => Err(e),
1137 },
1138 How::KeepBoth => keep_both(dest, true, &self.protect, |c| std::fs::create_dir(c)),
1139 How::Replace(_) => {
1142 let spot = sibling_temp(dest, "replaced");
1143 match platform::rename_no_replace(dest, &spot) {
1144 Ok(()) => {
1145 aside = Some(spot);
1146 std::fs::create_dir(dest).map(|()| dest.to_path_buf())
1147 }
1148 Err(e) if e.kind() == io::ErrorKind::NotFound => std::fs::create_dir(dest).map(|()| dest.to_path_buf()),
1149 Err(e) => Err(e),
1150 }
1151 }
1152 };
1153 let created = match created {
1154 Ok(created) => created,
1155 Err(e) => {
1156 if let Some(spot) = aside {
1157 self.restore_aside(src, &spot, dest);
1158 }
1159 let message = describe(&e, self.verb(), src);
1160 self.fail(src, message);
1161 self.pass_over(src, meta, None);
1162 return Ok(Outcome::NotDone);
1163 }
1164 };
1165 self.advance(Measure { items: 1, bytes: 0 });
1166 let outcome = self.copy_children(src, &created, cross);
1167 if cfg!(unix) && outcome == Outcome::Done {
1171 let _ = std::fs::set_permissions(&created, meta.permissions());
1172 }
1173
1174 if let Some(spot) = aside {
1175 if outcome == Outcome::Done {
1176 self.drop_aside(src, &spot);
1177 } else {
1178 match self.kind {
1182 FsOpKind::Copy => {
1183 let _ = platform::remove_entry_no_follow(&created);
1184 }
1185 FsOpKind::Move => {
1186 let _ = std::fs::remove_dir(&created);
1187 }
1188 }
1189 self.restore_aside(src, &spot, dest);
1190 }
1191 }
1192 if outcome == Outcome::Done && self.kind == FsOpKind::Move {
1193 return Ok(self.remove_moved_dir(src));
1194 }
1195 Ok(outcome)
1196 }
1197
1198 fn merge(&mut self, src: &Path, dest: &Path, cross: bool) -> Outcome {
1200 self.advance(Measure { items: 1, bytes: 0 });
1201 let outcome = self.copy_children(src, dest, cross);
1202 if outcome == Outcome::Done && self.kind == FsOpKind::Move {
1203 return self.remove_moved_dir(src);
1204 }
1205 outcome
1206 }
1207
1208 fn remove_moved_dir(&mut self, src: &Path) -> Outcome {
1211 match std::fs::remove_dir(src) {
1212 Ok(()) => Outcome::Done,
1213 Err(e) => {
1214 self.fail(src, format!("Moved what was in “{}”, but couldn't remove the folder itself: {e}", file_name_lossy(src)));
1215 Outcome::NotDone
1216 }
1217 }
1218 }
1219
1220 fn copy_children(&mut self, src: &Path, dest: &Path, cross: bool) -> Outcome {
1224 let mut names = Vec::new();
1225 let mut complete = true;
1226 match std::fs::read_dir(src) {
1227 Ok(entries) => {
1228 for entry in entries {
1229 match entry {
1230 Ok(entry) => names.push(entry.file_name()),
1231 Err(_) => complete = false,
1232 }
1233 }
1234 }
1235 Err(e) => {
1236 let message = describe(&e, "read", src);
1237 self.fail(src, message);
1238 return Outcome::NotDone;
1239 }
1240 }
1241 if !complete {
1242 self.fail(src, format!("Some of what's in “{}” couldn't be read, so it was left out.", file_name_lossy(src)));
1243 }
1244 for name in names {
1245 match self.transfer(&src.join(&name), &dest.join(&name), false, None, cross) {
1246 Outcome::Stopped => return Outcome::Stopped,
1247 Outcome::NotDone => complete = false,
1248 Outcome::Done => {}
1249 }
1250 }
1251 if complete {
1252 Outcome::Done
1253 } else {
1254 Outcome::NotDone
1255 }
1256 }
1257
1258 fn replace_aside(&mut self, src: &Path, dest: &Path, put: impl FnOnce(&Path) -> io::Result<()>) -> io::Result<()> {
1265 let spot = sibling_temp(dest, "replaced");
1266 match platform::rename_no_replace(dest, &spot) {
1267 Ok(()) => {}
1268 Err(e) if e.kind() == io::ErrorKind::NotFound => return put(dest),
1270 Err(e) => return Err(e),
1271 }
1272 match put(dest) {
1273 Ok(()) => {
1274 self.drop_aside(src, &spot);
1275 Ok(())
1276 }
1277 Err(e) => {
1278 self.restore_aside(src, &spot, dest);
1279 Err(e)
1280 }
1281 }
1282 }
1283
1284 fn drop_aside(&mut self, src: &Path, spot: &Path) {
1287 if let Err(e) = platform::remove_entry_no_follow(spot) {
1288 self.fail(
1289 src,
1290 format!(
1291 "Replaced “{}”, but couldn't remove the old one; it's still there as “{}”: {e}",
1292 file_name_lossy(src),
1293 file_name_lossy(spot)
1294 ),
1295 );
1296 }
1297 }
1298
1299 fn restore_aside(&mut self, src: &Path, spot: &Path, dest: &Path) {
1303 if platform::rename_no_replace(spot, dest).is_ok() {
1304 return;
1305 }
1306 let is_dir = std::fs::symlink_metadata(spot).is_ok_and(|m| m.is_dir());
1307 match keep_both(dest, is_dir, &self.protect, |c| platform::rename_no_replace(spot, c)) {
1308 Ok(put) => self.fail(
1309 src,
1310 format!("The original “{}” couldn't go back under its name; it's now “{}”.", file_name_lossy(dest), file_name_lossy(&put)),
1311 ),
1312 Err(e) => self.fail(
1313 src,
1314 format!("The original “{}” couldn't be put back; it's still there as “{}”: {e}", file_name_lossy(dest), file_name_lossy(spot)),
1315 ),
1316 }
1317 }
1318}
1319
1320enum Renamed {
1321 Finished(Outcome),
1322 Raced,
1323 CrossVolume,
1324}
1325
1326fn sibling_temp(dest: &Path, tag: &str) -> PathBuf {
1331 dest.with_file_name(format!(".agentmux-{tag}-{}", uuid::Uuid::new_v4().simple()))
1332}
1333
1334fn discard(tmp: &Path) {
1337 if let Err(e) = platform::remove_file_force(tmp) {
1338 tracing::warn!(path = %display_path(tmp), error = %e, "fs.op: couldn't remove a partial copy");
1339 }
1340}
1341
1342fn numbered_name(name: &OsStr, n: u32, is_dir: bool) -> OsString {
1346 let path = Path::new(name);
1347 let mut out = OsString::new();
1348 match (is_dir, path.file_stem(), path.extension()) {
1349 (false, Some(stem), Some(ext)) => {
1350 out.push(stem);
1351 out.push(format!(" ({n})."));
1352 out.push(ext);
1353 }
1354 _ => {
1355 out.push(name);
1356 out.push(format!(" ({n})"));
1357 }
1358 }
1359 out
1360}
1361
1362fn keep_both(dest: &Path, is_dir: bool, protect: &ProtectedPaths, mut put: impl FnMut(&Path) -> io::Result<()>) -> io::Result<PathBuf> {
1367 let name = dest.file_name().ok_or_else(|| io::Error::other("That path has no name."))?;
1368 for n in 2..=MAX_KEEP_BOTH {
1369 let candidate = dest.with_file_name(numbered_name(name, n, is_dir));
1370 protect.check(&candidate).map_err(io::Error::other)?;
1371 match put(&candidate) {
1372 Ok(()) => return Ok(candidate),
1373 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
1374 Err(e) => return Err(e),
1375 }
1376 }
1377 Err(io::Error::other(format!(
1378 "There's no free name left for a copy of “{}”.",
1379 name.to_string_lossy()
1380 )))
1381}
1382
1383fn describe(err: &io::Error, verb: &str, src: &Path) -> String {
1385 let name = file_name_lossy(src);
1386 if err.raw_os_error().is_none() && err.get_ref().is_some() {
1388 return err.to_string();
1389 }
1390 match err.kind() {
1391 io::ErrorKind::NotFound => format!("“{name}” or its destination doesn't exist anymore."),
1392 io::ErrorKind::PermissionDenied => format!("You don't have permission to {verb} “{name}” there."),
1393 io::ErrorKind::StorageFull => format!("There isn't enough space to {verb} “{name}”."),
1394 _ => format!("Couldn't {verb} “{name}”: {err}"),
1395 }
1396}
1397
1398fn mtime_ms(meta: &std::fs::Metadata) -> Option<u64> {
1400 meta.modified()
1401 .ok()
1402 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1403 .map(|d| d.as_millis() as u64)
1404}
1405
1406#[cfg(test)]
1407#[path = "jobs_tests.rs"]
1408mod tests;