1pub mod plan;
23pub mod state;
24
25use std::{
26 collections::BTreeMap,
27 fmt,
28 path::{Path, PathBuf},
29 time::UNIX_EPOCH,
30};
31
32use kernoid_client::{
33 Client, FILENODE,
34 e2e::E2e,
35 files::{self, FileNode},
36};
37use serde_json::{Value, json};
38use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
39
40use plan::{Action, Base, Local, RelPath, Remote};
41use state::{Node, Pieces, State};
42
43const MAX_CHANGES: u64 = 500;
45const PARTIAL: &str = ".kernoid-part";
47pub const PIECE: u64 = 8 << 20;
49const PIECES_KEPT: i64 = 20 * 3600;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct Names {
55 pub windows: bool,
58 pub ignore_case: bool,
61}
62
63impl Names {
64 pub fn of_this_system() -> Names {
66 Names { windows: cfg!(windows), ignore_case: cfg!(any(windows, target_os = "macos", target_os = "ios")) }
67 }
68
69 pub fn usable(&self, name: &str) -> bool {
71 if name.is_empty() || name == "." || name == ".." || name.len() > 255 {
72 return false;
73 }
74 if name.contains(['/', '\\', '\0']) || name.ends_with(PARTIAL) {
75 return false;
76 }
77 if self.windows {
78 if name.chars().any(|c| c < ' ' || "<>:\"|?*".contains(c)) || name.ends_with(['.', ' ']) {
79 return false;
80 }
81 let stem = name.split('.').next().unwrap_or_default().trim_end().to_ascii_uppercase();
82 let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
83 || ((stem.starts_with("COM") || stem.starts_with("LPT"))
84 && stem.len() == 4
85 && stem.as_bytes()[3].is_ascii_digit()
86 && stem.as_bytes()[3] != b'0');
87 if reserved {
88 return false;
89 }
90 }
91 true
92 }
93}
94
95impl Default for Names {
96 fn default() -> Names {
97 Names::of_this_system()
98 }
99}
100
101#[derive(Debug)]
102pub enum Error {
103 Client(kernoid_client::Error),
105 State(String),
107 Io(std::io::Error),
109}
110
111impl fmt::Display for Error {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 match self {
114 Self::Client(error) => write!(f, "{error}"),
115 Self::State(error) => write!(f, "the local state: {error}"),
116 Self::Io(error) => write!(f, "{error}"),
117 }
118 }
119}
120
121impl std::error::Error for Error {}
122
123impl From<kernoid_client::Error> for Error {
124 fn from(error: kernoid_client::Error) -> Self {
125 Self::Client(error)
126 }
127}
128
129impl From<std::io::Error> for Error {
130 fn from(error: std::io::Error) -> Self {
131 Self::Io(error)
132 }
133}
134
135pub type Result<T> = std::result::Result<T, Error>;
136
137#[derive(Debug, Clone, Default, PartialEq, Eq)]
139pub struct Report {
140 pub downloaded: usize,
141 pub uploaded: usize,
142 pub deleted_here: usize,
143 pub deleted_there: usize,
144 pub directories: usize,
145 pub moved: usize,
147 pub conflicts: Vec<RelPath>,
149 pub failed: Vec<(RelPath, String)>,
151 pub skipped: Vec<String>,
153 pub locked: Vec<String>,
155}
156
157#[derive(Debug)]
159pub struct Folder {
160 root: PathBuf,
161 store: String,
162 state: State,
163 names: Names,
164}
165
166impl Folder {
167 pub fn open(root: impl Into<PathBuf>, store: &str, state: &Path) -> Result<Folder> {
169 let root = root.into();
170 std::fs::create_dir_all(&root)?;
171 let state = State::open(state)?;
172 match state.meta("store")? {
173 Some(known) if known != store => {
174 return Err(Error::State(format!("this state belongs to the store {known}, not {store}")));
175 }
176 Some(_) => {}
177 None => state.set_meta("store", store)?,
178 }
179 Ok(Folder { root, store: store.to_owned(), state, names: Names::default() })
180 }
181
182 pub fn set_names(&mut self, names: Names) {
184 self.names = names;
185 }
186
187 pub fn root(&self) -> &Path {
188 &self.root
189 }
190
191 pub async fn sync(&mut self, client: &Client) -> Result<Report> {
193 self.sync_with(client, None).await
194 }
195
196 pub async fn sync_with(&mut self, client: &Client, mut e2e: Option<&mut E2e>) -> Result<Report> {
198 let mut report = Report::default();
199 let mut nodes = self.read_store(client, e2e.as_deref_mut()).await?;
200 let remote = tree_with(&nodes, self.names, &mut report.skipped);
201 let mut local = scan(&self.root)?;
202 let mut base = self.state.base()?;
203 let frozen = frozen(&nodes, &base, &mut report.locked);
206 let kept: BTreeMap<RelPath, Base> = base.extract_if(.., |path, _| inside(path, &frozen)).collect();
207 local.retain(|path, _| !inside(path, &frozen));
208 let actions = plan::plan(&base, &paths(&remote), &local);
209 let mut trash = None;
210 let mut touched: Vec<RelPath> = Vec::new();
212 let mut downloading = Vec::new();
213 for action in actions {
214 let path = action_path(&action).to_owned();
215 if let Action::Download { path, .. } = &action {
216 downloading.push(path.clone());
217 }
218 let result =
219 self.run(client, e2e.as_deref_mut(), &action, &remote, &mut base, &mut trash, &mut touched).await;
220 match result {
221 Ok(()) => count(&mut report, &action),
222 Err(error) => report.failed.push((path, error.to_string())),
223 }
224 }
225 for path in self.state.downloads()? {
227 if !downloading.contains(&path) {
228 let _ = std::fs::remove_file(partial_of(&self.local(&path)));
229 self.state.set_download(&path, None)?;
230 }
231 }
232 if !touched.is_empty() {
235 nodes = self.read_store(client, e2e).await?;
236 let after = tree_with(&nodes, self.names, &mut Vec::new());
237 for path in touched {
238 if let (Some((node, _)), Some(entry)) = (after.get(&path), base.get_mut(&path)) {
239 entry.remote = remote_of(node);
240 }
241 }
242 }
243 base.extend(kept);
244 self.state.set_base(&base)?;
245 Ok(report)
246 }
247
248 async fn read_store(&mut self, client: &Client, e2e: Option<&mut E2e>) -> Result<BTreeMap<String, Node>> {
251 let mut nodes = self.state.remote()?;
252 let mut since = self.state.meta("state")?.filter(|state| !state.is_empty());
253 let state = loop {
254 let Some(state) = since.clone() else {
255 let snapshot = files::snapshot(client, &self.store).await?;
256 nodes = snapshot.nodes.iter().map(|node| (node.id.clone(), node_of(node))).collect();
257 break snapshot.state;
258 };
259 match files::changes(client, &self.store, &state, MAX_CHANGES).await {
260 Ok(changes) => {
261 for id in &changes.destroyed {
262 nodes.remove(id);
263 }
264 for node in &changes.changed {
265 nodes.insert(node.id.clone(), node_of(node));
266 }
267 if !changes.has_more_changes {
268 break changes.new_state;
269 }
270 self.state.set_remote(&nodes, &changes.new_state)?;
271 since = Some(changes.new_state);
272 }
273 Err(kernoid_client::Error::Method { kind, .. }) if kind == "cannotCalculateChanges" => since = None,
274 Err(error) => return Err(error.into()),
275 }
276 };
277 if let Some(e2e) = e2e {
278 for node in nodes.values_mut().filter(|node| node.locked) {
279 let (Some(vault), Some(sealed)) = (node.vault.clone(), node.sealed_name.clone()) else { continue };
280 if let Some(name) = e2e.open_name(client, &self.store, &vault, &sealed).await {
281 node.name = name;
282 node.locked = false;
283 }
284 }
285 }
286 self.state.set_remote(&nodes, &state)?;
287 Ok(nodes)
288 }
289
290 #[allow(clippy::too_many_arguments)]
291 async fn run(
292 &self,
293 client: &Client,
294 e2e: Option<&mut E2e>,
295 action: &Action,
296 remote: &BTreeMap<RelPath, (Node, Remote)>,
297 base: &mut BTreeMap<RelPath, Base>,
298 trash: &mut Option<Option<String>>,
299 touched: &mut Vec<RelPath>,
300 ) -> Result<()> {
301 match action {
302 Action::KeepLocalCopy(path) => {
303 let from = self.local(path);
304 tokio::fs::rename(&from, conflict_copy(&from)).await?;
305 base.remove(path);
306 }
307 Action::DeleteLocal(path) => {
308 let at = self.local(path);
309 match tokio::fs::symlink_metadata(&at).await {
310 Ok(meta) if meta.is_dir() => tokio::fs::remove_dir_all(&at).await?,
311 Ok(_) => tokio::fs::remove_file(&at).await?,
312 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
313 Err(error) => return Err(error.into()),
314 }
315 forget(base, path);
316 }
317 Action::MakeLocalDirectory(path) => {
318 let at = self.local(path);
319 tokio::fs::create_dir_all(&at).await?;
320 let (_, node) = remote.get(path).ok_or_else(|| gone(path))?;
321 base.insert(path.clone(), Base { remote: node.clone(), local: stat(&at)? });
322 }
323 Action::Download { path, id, blob } => {
324 let (node, known) = remote.get(path).filter(|(n, _)| &n.id == id).ok_or_else(|| gone(path))?;
325 let at = self.local(path);
326 match (&node.vault, e2e) {
327 (Some(vault), Some(e2e)) => self.download_sealed(client, e2e, vault, blob, &at).await?,
328 (Some(_), None) => return Err(locked()),
329 (None, _) => self.download(client, path, blob, &at).await?,
330 }
331 base.insert(path.clone(), Base { remote: known.clone(), local: stat(&at)? });
332 }
333 Action::MakeRemoteDirectory(path) => {
334 let (parent, name) = self.parent_and_name(path, remote, base)?;
335 let vault = vault_of(plan::parent(path), remote, base);
336 let id = match (&vault, &parent, e2e) {
337 (Some(vault), Some(parent), Some(e2e)) => {
338 e2e.make_directory_in(client, &self.store, vault, parent, &name).await?
339 }
340 (Some(_), _, _) => return Err(locked()),
341 (None, _, _) => {
342 let create =
343 json!({ "create": { "d": { "@type": "Directory", "parentId": parent, "name": name } } });
344 let result = client.set(&[FILENODE], "FileNode", &self.store, create, json!({})).await?;
345 created_id(&result)?
346 }
347 };
348 let local = stat(&self.local(path))?;
349 let remote = Remote { id, directory: true, blob: None, size: 0, vault };
350 base.insert(path.clone(), Base { remote, local });
351 touched.push(path.clone());
352 }
353 Action::Upload { path, id } => {
354 let at = self.local(path);
355 let local = stat(&at)?;
357 let name = path.rsplit('/').next().unwrap_or(path).to_owned();
358 let kind = files::media_type(&name);
359 let vault = vault_of(plan::parent(path), remote, base);
360 let mut e2e = e2e;
361 let upload = match (&vault, e2e.as_deref_mut()) {
362 (Some(vault), Some(e2e)) => {
363 let data = tokio::fs::read(&at).await?;
364 let sealed = e2e.seal_content(client, &self.store, vault, data).await?;
365 let size = sealed.len() as u64;
366 client
367 .upload(
368 &self.store,
369 "application/octet-stream",
370 std::io::Cursor::new(sealed),
371 size,
372 |_, _| {},
373 )
374 .await?
375 }
376 (Some(_), None) => return Err(locked()),
377 (None, _) => self.upload(client, path, &at, &local, kind).await?,
378 };
379 let node_id = match id {
380 Some(id) => {
381 let update =
382 json!({ "update": { id.as_str(): { "blobId": upload.blob_id, "mediaType": kind } } });
383 client.set(&[FILENODE], "FileNode", &self.store, update, json!({})).await?;
384 id.clone()
385 }
386 None => {
387 let (parent, _) = self.parent_and_name(path, remote, base)?;
388 let mut create = json!({
389 "@type": "File", "parentId": parent, "name": name, "blobId": upload.blob_id,
390 "mediaType": kind,
391 });
392 if let (Some(vault), Some(e2e)) = (&vault, e2e) {
393 let (opaque, sealed) = e2e.name_in(client, &self.store, vault, &name).await?;
394 create["name"] = json!(opaque);
395 create["kernoid:encryptedName"] = json!(sealed);
396 }
397 let create = json!({ "create": { "f": create } });
398 let result = client.set(&[FILENODE], "FileNode", &self.store, create, json!({})).await?;
399 created_id(&result)?
400 }
401 };
402 let remote =
403 Remote { id: node_id, directory: false, blob: Some(upload.blob_id), size: upload.size, vault };
404 base.insert(path.clone(), Base { remote, local });
405 touched.push(path.clone());
406 }
407 Action::DeleteRemote { path, id } => {
408 if trash.is_none() {
409 *trash = Some(files::trash(client, &self.store).await?.map(|node| node.id));
410 }
411 match trash.as_ref().and_then(Option::as_ref) {
412 Some(trash) => files::move_to(client, &self.store, id, Some(trash)).await?,
413 None => files::destroy(client, &self.store, id).await?,
414 }
415 forget(base, path);
416 }
417 Action::Adopt(path) => {
418 let (_, node) = remote.get(path).ok_or_else(|| gone(path))?;
419 base.insert(path.clone(), Base { remote: node.clone(), local: stat(&self.local(path))? });
420 }
421 Action::MoveLocal { from, to } => {
422 let target = self.local(to);
423 if let Some(parent) = target.parent() {
424 tokio::fs::create_dir_all(parent).await?;
425 }
426 tokio::fs::rename(self.local(from), &target).await?;
427 plan::rename(base, from, to);
428 }
429 Action::MoveRemote { from, to, id } => {
430 let (parent, name) = self.parent_and_name(to, remote, base)?;
431 let vault = base.get(from).and_then(|entry| entry.remote.vault.clone());
432 match (&vault, &parent, e2e) {
433 (Some(vault), parent, Some(e2e)) if vault == id => {
435 let (opaque, sealed) = e2e.name_in(client, &self.store, vault, &name).await?;
436 let update = json!({ "update": { id.as_str(): {
437 "parentId": parent, "name": opaque, "kernoid:encryptedName": sealed,
438 } } });
439 client.set(&[FILENODE], "FileNode", &self.store, update, json!({})).await?;
440 }
441 (Some(vault), Some(parent), Some(e2e)) => {
442 e2e.move_in(client, &self.store, vault, id, parent, &name).await?;
443 }
444 (Some(_), _, _) => return Err(locked()),
445 (None, _, _) => {
446 let update = json!({ "update": { id.as_str(): { "parentId": parent, "name": name } } });
447 client.set(&[FILENODE], "FileNode", &self.store, update, json!({})).await?;
448 }
449 }
450 plan::rename(base, from, to);
451 touched.push(to.clone());
452 }
453 }
454 Ok(())
455 }
456
457 async fn download(&self, client: &Client, path: &str, blob: &str, at: &Path) -> Result<()> {
459 let partial = partial_of(at);
460 let resume = self.state.download(path)?.as_deref() == Some(blob);
461 let from = if resume { tokio::fs::metadata(&partial).await.map(|m| m.len()).unwrap_or(0) } else { 0 };
462 let mut file = if from > 0 {
463 tokio::fs::OpenOptions::new().append(true).open(&partial).await?
464 } else {
465 tokio::fs::File::create(&partial).await?
466 };
467 self.state.set_download(path, Some(blob))?;
468 let name = path.rsplit('/').next().unwrap_or(path);
469 let result = client
470 .download_from(&self.store, blob, name, "application/octet-stream", from, &mut file, &mut |_, _| {})
471 .await;
472 match result {
473 Ok(_) => {}
474 Err(error @ kernoid_client::Error::Status(..)) if from > 0 => {
477 drop(file);
478 let _ = tokio::fs::remove_file(&partial).await;
479 self.state.set_download(path, None)?;
480 return Err(error.into());
481 }
482 Err(error) => {
483 let _ = file.flush().await;
485 return Err(error.into());
486 }
487 }
488 file.sync_all().await?;
489 drop(file);
490 tokio::fs::rename(&partial, at).await?;
491 self.state.set_download(path, None)?;
492 Ok(())
493 }
494
495 async fn download_sealed(&self, client: &Client, e2e: &mut E2e, vault: &str, blob: &str, at: &Path) -> Result<()> {
497 let mut sealed = Vec::new();
498 client
499 .download(&self.store, blob, "encrypted", "application/octet-stream", &mut sealed, &mut |_, _| {})
500 .await?;
501 let data = e2e.open_content(client, &self.store, vault, sealed).await?;
502 let partial = partial_of(at);
503 let mut file = tokio::fs::File::create(&partial).await?;
504 file.write_all(&data).await?;
505 file.sync_all().await?;
506 drop(file);
507 tokio::fs::rename(&partial, at).await?;
508 Ok(())
509 }
510
511 async fn upload(
514 &self,
515 client: &Client,
516 path: &str,
517 at: &Path,
518 local: &Local,
519 kind: &str,
520 ) -> Result<kernoid_client::Upload> {
521 if local.size <= PIECE {
522 let file = tokio::fs::File::open(at).await?;
523 return Ok(client.upload(&self.store, kind, file, local.size, |_, _| {}).await?);
524 }
525 let now = chrono::Utc::now().timestamp();
526 let mut pieces = self
527 .state
528 .upload(path)?
529 .filter(|p| p.size == local.size && p.modified == local.modified && now - p.started < PIECES_KEPT)
530 .unwrap_or(Pieces { size: local.size, modified: local.modified, blobs: Vec::new(), started: now });
531 let mut file = tokio::fs::File::open(at).await?;
532 let count = local.size.div_ceil(PIECE);
533 while (pieces.blobs.len() as u64) < count {
534 let start = pieces.blobs.len() as u64 * PIECE;
535 let length = PIECE.min(local.size - start);
536 file.seek(std::io::SeekFrom::Start(start)).await?;
537 let mut piece = Vec::with_capacity(length as usize);
538 (&mut file).take(length).read_to_end(&mut piece).await?;
539 if piece.len() as u64 != length {
540 return Err(Error::Io(std::io::Error::other("the file changed while it was sent")));
541 }
542 let sent = client
543 .upload(&self.store, "application/octet-stream", std::io::Cursor::new(piece), length, |_, _| {})
544 .await?;
545 pieces.blobs.push(sent.blob_id);
546 self.state.set_upload(path, Some(&pieces))?;
547 }
548 let joined = client.join(&self.store, kind, &pieces.blobs).await;
549 self.state.set_upload(path, None)?;
551 Ok(joined?)
552 }
553
554 fn local(&self, path: &str) -> PathBuf {
555 path.split('/').fold(self.root.clone(), |at, part| at.join(part))
556 }
557
558 fn parent_and_name(
560 &self,
561 path: &str,
562 remote: &BTreeMap<RelPath, (Node, Remote)>,
563 base: &BTreeMap<RelPath, Base>,
564 ) -> Result<(Option<String>, String)> {
565 let Some((parent, name)) = path.rsplit_once('/') else { return Ok((None, path.to_owned())) };
566 let id = base
567 .get(parent)
568 .map(|entry| entry.remote.id.clone())
569 .or_else(|| remote.get(parent).map(|(node, _)| node.id.clone()))
570 .ok_or_else(|| gone(parent))?;
571 Ok((Some(id), name.to_owned()))
572 }
573}
574
575fn gone(path: &str) -> Error {
576 Error::State(format!("{path}: not in the store (any more)"))
577}
578
579fn locked() -> Error {
580 Error::Client(kernoid_client::Error::E2e("the vault's key is not at hand".into()))
581}
582
583fn partial_of(at: &Path) -> PathBuf {
584 PathBuf::from(format!("{}{PARTIAL}", at.display()))
585}
586
587fn vault_of(
589 mut path: &str,
590 remote: &BTreeMap<RelPath, (Node, Remote)>,
591 base: &BTreeMap<RelPath, Base>,
592) -> Option<String> {
593 while !path.is_empty() {
594 if let Some(entry) = base.get(path) {
595 return entry.remote.vault.clone();
596 }
597 if let Some((_, node)) = remote.get(path) {
598 return node.vault.clone();
599 }
600 path = plan::parent(path);
601 }
602 None
603}
604
605fn frozen(nodes: &BTreeMap<String, Node>, base: &BTreeMap<RelPath, Base>, locked: &mut Vec<String>) -> Vec<RelPath> {
607 let mut vaults: Vec<&str> =
608 nodes.values().filter(|node| node.locked).filter_map(|node| node.vault.as_deref()).collect();
609 vaults.sort_unstable();
610 vaults.dedup();
611 locked.extend(vaults.iter().map(|vault| (*vault).to_owned()));
612 base.iter().filter(|(_, entry)| vaults.contains(&entry.remote.id.as_str())).map(|(path, _)| path.clone()).collect()
614}
615
616fn inside(path: &str, roots: &[RelPath]) -> bool {
618 roots.iter().any(|root| path == root || path.strip_prefix(root.as_str()).is_some_and(|rest| rest.starts_with('/')))
619}
620
621fn action_path(action: &Action) -> &str {
622 match action {
623 Action::MakeLocalDirectory(path)
624 | Action::MakeRemoteDirectory(path)
625 | Action::DeleteLocal(path)
626 | Action::KeepLocalCopy(path)
627 | Action::Adopt(path) => path,
628 Action::Download { path, .. } | Action::Upload { path, .. } | Action::DeleteRemote { path, .. } => path,
629 Action::MoveLocal { to, .. } | Action::MoveRemote { to, .. } => to,
630 }
631}
632
633fn count(report: &mut Report, action: &Action) {
634 match action {
635 Action::Download { .. } => report.downloaded += 1,
636 Action::Upload { .. } => report.uploaded += 1,
637 Action::DeleteLocal(_) => report.deleted_here += 1,
638 Action::DeleteRemote { .. } => report.deleted_there += 1,
639 Action::MakeLocalDirectory(_) | Action::MakeRemoteDirectory(_) => report.directories += 1,
640 Action::KeepLocalCopy(path) => report.conflicts.push(path.clone()),
641 Action::MoveLocal { .. } | Action::MoveRemote { .. } => report.moved += 1,
642 Action::Adopt(_) => {}
643 }
644}
645
646fn forget(base: &mut BTreeMap<RelPath, Base>, path: &str) {
648 let prefix = format!("{path}/");
649 base.retain(|known, _| known != path && !known.starts_with(&prefix));
650}
651
652fn created_id(result: &Value) -> Result<String> {
653 result
654 .get("created")
655 .and_then(Value::as_object)
656 .and_then(|created| created.values().next())
657 .and_then(|node| node.get("id"))
658 .and_then(Value::as_str)
659 .map(str::to_owned)
660 .ok_or_else(|| Error::Client(kernoid_client::Error::Protocol("no id for what was made".into())))
661}
662
663fn node_of(node: &FileNode) -> Node {
664 Node {
665 id: node.id.clone(),
666 parent: node.parent_id.clone(),
667 name: node.name.clone(),
668 directory: node.is_directory(),
669 blob: node.blob_id.clone(),
670 size: node.size.unwrap_or_default(),
671 role: node.role.clone(),
672 vault: node.vault.clone(),
673 sealed_name: node.encrypted_name.clone(),
674 locked: node.vault.is_some() && node.encrypted_name.is_some(),
676 }
677}
678
679fn remote_of(node: &Node) -> Remote {
680 Remote {
681 id: node.id.clone(),
682 directory: node.directory,
683 blob: node.blob.clone(),
684 size: node.size,
685 vault: node.vault.clone(),
686 }
687}
688
689pub fn tree(nodes: &BTreeMap<String, Node>, skipped: &mut Vec<String>) -> BTreeMap<RelPath, (Node, Remote)> {
692 tree_with(nodes, Names::default(), skipped)
693}
694
695pub fn tree_with(
697 nodes: &BTreeMap<String, Node>,
698 names: Names,
699 skipped: &mut Vec<String>,
700) -> BTreeMap<RelPath, (Node, Remote)> {
701 let mut taken: BTreeMap<(Option<&str>, String), &str> = BTreeMap::new();
704 if names.ignore_case {
705 for node in nodes.values() {
706 taken.entry((node.parent.as_deref(), node.name.to_lowercase())).or_insert(node.id.as_str());
707 }
708 }
709 let fits = |node: &Node| {
710 names.usable(&node.name)
711 && (!names.ignore_case
712 || taken.get(&(node.parent.as_deref(), node.name.to_lowercase())) == Some(&node.id.as_str()))
713 };
714 let mut out = BTreeMap::new();
715 for node in nodes.values() {
716 let mut parts = Vec::new();
717 let mut at = Some(node);
718 let mut ok = true;
719 while let Some(current) = at {
720 if current.role.as_deref() == Some("trash") || current.locked || !fits(current) || parts.len() > 64 {
721 ok = false;
722 if !current.locked && !fits(current) && current.id == node.id {
723 skipped.push(node.name.clone());
724 }
725 break;
726 }
727 parts.push(current.name.as_str());
728 at = current.parent.as_ref().and_then(|parent| nodes.get(parent));
729 if at.is_none() && current.parent.is_some() {
730 ok = false;
732 }
733 }
734 if ok {
735 parts.reverse();
736 out.insert(parts.join("/"), (node.clone(), remote_of(node)));
737 }
738 }
739 out
740}
741
742fn paths(tree: &BTreeMap<RelPath, (Node, Remote)>) -> BTreeMap<RelPath, Remote> {
743 tree.iter().map(|(path, (_, remote))| (path.clone(), remote.clone())).collect()
744}
745
746fn stat(path: &Path) -> Result<Local> {
747 let meta = std::fs::symlink_metadata(path)?;
748 let modified = meta
749 .modified()
750 .ok()
751 .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
752 .map_or(0, |since| since.as_nanos() as i128);
753 Ok(Local {
754 directory: meta.is_dir(),
755 size: if meta.is_dir() { 0 } else { meta.len() },
756 modified,
757 file: file_id(&meta),
758 })
759}
760
761#[cfg(unix)]
763fn file_id(meta: &std::fs::Metadata) -> u64 {
764 std::os::unix::fs::MetadataExt::ino(meta)
765}
766
767#[cfg(not(unix))]
768fn file_id(_: &std::fs::Metadata) -> u64 {
769 0
770}
771
772pub fn scan(root: &Path) -> Result<BTreeMap<RelPath, Local>> {
774 let mut out = BTreeMap::new();
775 let mut stack = vec![(root.to_path_buf(), String::new())];
776 while let Some((dir, prefix)) = stack.pop() {
777 for entry in std::fs::read_dir(&dir)? {
778 let entry = entry?;
779 let Some(name) = entry.file_name().to_str().map(str::to_owned) else { continue };
780 if name.is_empty() || name.ends_with(PARTIAL) {
781 continue;
782 }
783 let kind = entry.file_type()?;
784 if !kind.is_dir() && !kind.is_file() {
785 continue;
786 }
787 let path = if prefix.is_empty() { name } else { format!("{prefix}/{name}") };
788 out.insert(path.clone(), stat(&entry.path())?);
789 if kind.is_dir() {
790 stack.push((entry.path(), path));
791 }
792 }
793 }
794 Ok(out)
795}
796
797pub fn conflict_copy(path: &Path) -> PathBuf {
799 let stem = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
800 let extension = path.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
801 let when = chrono::Local::now().format("%Y-%m-%d %H%M");
802 let mut candidate = path.with_file_name(format!("{stem} (conflict {when}){extension}"));
803 let mut n = 2;
804 while candidate.exists() {
805 candidate = path.with_file_name(format!("{stem} (conflict {when} {n}){extension}"));
806 n += 1;
807 }
808 candidate
809}
810
811#[cfg(test)]
812mod tests {
813 use super::*;
814
815 fn node(id: &str, parent: Option<&str>, name: &str, directory: bool) -> (String, Node) {
816 (
817 id.into(),
818 Node {
819 id: id.into(),
820 parent: parent.map(Into::into),
821 name: name.into(),
822 directory,
823 blob: (!directory).then(|| format!("b-{id}")),
824 size: 1,
825 role: None,
826 vault: None,
827 sealed_name: None,
828 locked: false,
829 },
830 )
831 }
832
833 #[test]
834 fn a_round_runs_on_any_thread() {
835 fn send<T: Send>(_: T) {}
836 fn shared<T: Send + Sync>() {}
837 shared::<Folder>();
838 let client: Option<&Client> = None;
839 if let Some(client) = client {
840 let mut folder = Folder::open(std::env::temp_dir(), "x", Path::new("unused.db")).unwrap();
841 send(async move { folder.sync_with(client, None).await });
842 }
843 }
844
845 #[test]
846 fn the_tree_by_path_without_the_trash_and_bad_names() {
847 let mut nodes: BTreeMap<String, Node> = [
848 node("1", None, "Projects", true),
849 node("2", Some("1"), "plan.txt", false),
850 node("3", None, "Trash", true),
851 node("4", Some("3"), "old.txt", false),
852 node("5", None, "..", false),
853 node("6", Some("9"), "orphan.txt", false),
854 ]
855 .into_iter()
856 .collect();
857 nodes.get_mut("3").unwrap().role = Some("trash".into());
858 let mut skipped = Vec::new();
859 let tree = tree_with(&nodes, Names { windows: false, ignore_case: false }, &mut skipped);
860 assert_eq!(tree.keys().collect::<Vec<_>>(), ["Projects", "Projects/plan.txt"]);
861 assert_eq!(skipped, [".."]);
862 }
863
864 #[test]
865 fn names_a_file_system_cannot_hold_are_left_out() {
866 let nodes: BTreeMap<String, Node> = [
867 node("1", None, "Notes.txt", false),
868 node("2", None, "notes.TXT", false),
869 node("3", None, "a:b.txt", false),
870 node("4", None, "CON.txt", false),
871 node("5", None, "trailing.", false),
872 node("6", None, "COM0", false),
873 ]
874 .into_iter()
875 .collect();
876 let mut skipped = Vec::new();
877 let unix = tree_with(&nodes, Names { windows: false, ignore_case: false }, &mut skipped);
878 assert_eq!(unix.len(), 6);
879 let mut skipped = Vec::new();
880 let windows = tree_with(&nodes, Names { windows: true, ignore_case: true }, &mut skipped);
881 assert_eq!(windows.keys().collect::<Vec<_>>(), ["COM0", "Notes.txt"]);
882 assert_eq!(skipped, ["notes.TXT", "a:b.txt", "CON.txt", "trailing."]);
883 let names = Names { windows: true, ignore_case: true };
884 assert!(!names.usable("lpt1") && !names.usable("aux.tar.gz") && !names.usable("a\u{1}b"));
885 assert!(names.usable("COM10") && names.usable("console.txt"));
886 }
887
888 #[test]
889 fn a_locked_vault_is_left_out() {
890 let mut nodes: BTreeMap<String, Node> =
891 [node("1", None, "k-1", true), node("2", Some("1"), "k-2", false), node("3", None, "open.txt", false)]
892 .into_iter()
893 .collect();
894 for id in ["1", "2"] {
895 let node = nodes.get_mut(id).unwrap();
896 node.vault = Some("1".into());
897 node.locked = true;
898 }
899 let tree = tree_with(&nodes, Names { windows: false, ignore_case: false }, &mut Vec::new());
900 assert_eq!(tree.keys().collect::<Vec<_>>(), ["open.txt"]);
901 let base = BTreeMap::from([(
902 "Vault".to_owned(),
903 Base {
904 remote: Remote { id: "1".into(), directory: true, blob: None, size: 0, vault: Some("1".into()) },
905 local: Local { directory: true, size: 0, modified: 0, file: 0 },
906 },
907 )]);
908 let mut locked = Vec::new();
909 assert_eq!(frozen(&nodes, &base, &mut locked), ["Vault"]);
910 assert_eq!(locked, ["1"]);
911 assert!(inside("Vault/a", &["Vault".into()]) && !inside("Vaults", &["Vault".into()]));
912 }
913
914 #[test]
915 fn a_folder_scanned_and_a_conflict_copy_named() {
916 let root = std::env::temp_dir().join(format!("kernoid-sync-scan-{}", std::process::id()));
917 std::fs::create_dir_all(root.join("D")).unwrap();
918 std::fs::write(root.join("D/a.txt"), "abc").unwrap();
919 std::fs::write(root.join(format!("b.txt{PARTIAL}")), "half").unwrap();
920 let found = scan(&root).unwrap();
921 assert_eq!(found.keys().collect::<Vec<_>>(), ["D", "D/a.txt"]);
922 assert_eq!((found["D/a.txt"].size, found["D"].directory), (3, true));
923 let copy = conflict_copy(&root.join("D/a.txt"));
924 let name = copy.file_name().unwrap().to_string_lossy().into_owned();
925 assert!(name.starts_with("a (conflict ") && name.ends_with(").txt"), "{name}");
926 std::fs::remove_dir_all(&root).unwrap();
927 }
928}