| 1 | //! Where section replicas live in the app's cache: a folder per notebook location, then a |
| 2 | //! file per section identity. OneNote lets a notebook folder be copied with its sections |
| 3 | //! keeping their identities, so identity alone cannot tell a copy's replica from the |
| 4 | //! original's; the location keeps each copy's queue its own. |
| 5 | //! |
| 6 | //! A location is a string naming where a notebook or lone section is reached: the canonical |
| 7 | //! path of a local folder or file (`local`), or `smb` for one on a share. |
| 8 | |
| 9 | use crate::fs; |
| 10 | use onestore::{Header, Stamp}; |
| 11 | use std::{ |
| 12 | io, |
| 13 | path::{Path, PathBuf}, |
| 14 | }; |
| 15 | |
| 16 | /// The file in a replica folder naming the location its replicas belong to. |
| 17 | const LOCATION: &str = "location"; |
| 18 | |
| 19 | /// The folder in `cache` holding the replicas of the sections at `location`. |
| 20 | pub fn folder(cache: &Path, location: &str) -> PathBuf { |
| 21 | cache |
| 22 | .join("replicas") |
| 23 | .join(hex(&<sha2::Sha256 as sha2::Digest>::digest(location)[..16])) |
| 24 | } |
| 25 | |
| 26 | /// The location of the local folder or file at `path`, which may have just been moved away: |
| 27 | /// its canonical path, or its folder's with its name. |
| 28 | pub fn local(path: &Path) -> io::Result<String> { |
| 29 | let canonical = |
| 30 | fs::canonicalize(path).or_else(|error| match (path.parent(), path.file_name()) { |
| 31 | (Some(parent), Some(name)) => Ok(fs::canonicalize(parent)?.join(name)), |
| 32 | _ => Err(error), |
| 33 | })?; |
| 34 | Ok(canonical.to_string_lossy().into_owned()) |
| 35 | } |
| 36 | |
| 37 | /// The location of the notebook folder `root` in `share` on the server at `address`, as its |
| 38 | /// host names them. |
| 39 | pub fn smb(address: &str, share: &str, root: &str) -> String { |
| 40 | let root = root.replace('\\', "/"); |
| 41 | format!("smb://{address}/{share}/{}", root.trim_matches('/')) |
| 42 | } |
| 43 | |
| 44 | /// Moves the replicas of the sections at `from` to `to`, as when the app renames or moves a |
| 45 | /// notebook; each replica `to` already holds for the same section stays. Every replica moved |
| 46 | /// must be closed. |
| 47 | pub fn moved(cache: &Path, from: &str, to: &str) -> io::Result<()> { |
| 48 | let old = folder(cache, from); |
| 49 | if fs::metadata(&old).is_err() { |
| 50 | return Ok(()); |
| 51 | } |
| 52 | let new = claimed(cache, to)?; |
| 53 | for entry in fs::read_dir(&old)? { |
| 54 | let name = entry?.file_name(); |
| 55 | if Path::new(&name).extension() == Some("sqlite".as_ref()) |
| 56 | && fs::metadata(new.join(&name)).is_err() |
| 57 | { |
| 58 | adopt(&old.join(&name), &new.join(&name))?; |
| 59 | } |
| 60 | } |
| 61 | tidy(&old); |
| 62 | Ok(()) |
| 63 | } |
| 64 | |
| 65 | /// Deletes what the cache keeps of the notebook at `location`, as when the app deletes the |
| 66 | /// notebook: its replicas, edits waiting in them included, its sections' copies' and its |
| 67 | /// listing. Every replica must be closed. |
| 68 | pub fn forget(cache: &Path, location: &str) -> io::Result<()> { |
| 69 | let within = format!("{location}/"); |
| 70 | if let Ok(entries) = fs::read_dir(cache.join("replicas")) { |
| 71 | for entry in entries { |
| 72 | let folder = entry?.path(); |
| 73 | let ours = fs::read_to_string(folder.join(LOCATION)) |
| 74 | .is_ok_and(|label| label == location || label.starts_with(&within)); |
| 75 | if ours { |
| 76 | fs::remove_dir_all(&folder)?; |
| 77 | } |
| 78 | } |
| 79 | } |
| 80 | match fs::remove_file(crate::session::listing(cache, location)) { |
| 81 | Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error), |
| 82 | _ => Ok(()), |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | /// The folder for `location`, made and labelled with it where it was not. |
| 87 | fn claimed(cache: &Path, location: &str) -> io::Result<PathBuf> { |
| 88 | let folder = folder(cache, location); |
| 89 | let label = folder.join(LOCATION); |
| 90 | if fs::metadata(&label).is_err() { |
| 91 | fs::create_dir_all(&folder)?; |
| 92 | fs::write(&label, location)?; |
| 93 | } |
| 94 | Ok(folder) |
| 95 | } |
| 96 | |
| 97 | /// The folder for `location`, having moved into it a replica for each of `sections` it lacks |
| 98 | /// from where one was left behind: |
| 99 | /// |
| 100 | /// - the cache's top or its `smb` folder, where replicas were named by identity alone before |
| 101 | /// locations named folders: taken by the first location to open the section, as that |
| 102 | /// replica served whichever copy opened then; |
| 103 | /// - the folder of a local location that no longer exists, as a notebook moved outside the |
| 104 | /// app leaves: taken only when the section file, as `stamp` reads it, stands as the |
| 105 | /// replica's base or has moved on from it (a later generation). A replica whose base is |
| 106 | /// newer than the file, or a different state of the same generation, belongs to another |
| 107 | /// copy of the section and stays. |
| 108 | /// |
| 109 | /// A replica another process holds stays where it is. |
| 110 | pub(crate) fn claim( |
| 111 | cache: &Path, |
| 112 | location: &str, |
| 113 | sections: &[[u8; 16]], |
| 114 | stamp: impl Fn(&[u8; 16]) -> Option<Stamp>, |
| 115 | ) -> io::Result<PathBuf> { |
| 116 | let folder = claimed(cache, location)?; |
| 117 | let missing: Vec<&[u8; 16]> = sections |
| 118 | .iter() |
| 119 | .filter(|identity| fs::metadata(crate::session::replica_file(&folder, identity)).is_err()) |
| 120 | .collect(); |
| 121 | if missing.is_empty() { |
| 122 | return Ok(folder); |
| 123 | } |
| 124 | let orphans = orphans(cache); |
| 125 | for identity in missing { |
| 126 | let legacy = [cache.to_owned(), cache.join("smb")] |
| 127 | .into_iter() |
| 128 | .map(|legacy| (crate::session::replica_file(&legacy, identity), true)); |
| 129 | let left = orphans |
| 130 | .iter() |
| 131 | .map(|orphan| (crate::session::replica_file(orphan, identity), false)); |
| 132 | for (replica, legacy) in legacy.chain(left) { |
| 133 | if fs::metadata(&replica).is_err() { |
| 134 | continue; |
| 135 | } |
| 136 | let adoptable = match crate::closed(&replica).and_then(|held| crate::peek(&held)) { |
| 137 | Ok((base, _)) => { |
| 138 | legacy || stamp(identity).is_some_and(|file| follows(&file, &base)) |
| 139 | } |
| 140 | Err(error) if error.busy() => false, |
| 141 | // An older schema, which opening converts, predates locations. |
| 142 | Err(_) => legacy, |
| 143 | }; |
| 144 | if adoptable { |
| 145 | adopt(&replica, &crate::session::replica_file(&folder, identity))?; |
| 146 | if let Some(parent) = replica.parent().filter(|_| !legacy) { |
| 147 | tidy(parent); |
| 148 | } |
| 149 | break; |
| 150 | } |
| 151 | } |
| 152 | } |
| 153 | Ok(folder) |
| 154 | } |
| 155 | |
| 156 | /// The replica folders of local locations that no longer exist. |
| 157 | fn orphans(cache: &Path) -> Vec<PathBuf> { |
| 158 | let Ok(entries) = fs::read_dir(cache.join("replicas")) else { |
| 159 | return Vec::new(); |
| 160 | }; |
| 161 | entries |
| 162 | .filter_map(|entry| { |
| 163 | let folder = entry.ok()?.path(); |
| 164 | let location = fs::read_to_string(folder.join(LOCATION)).ok()?; |
| 165 | let path = Path::new(&location); |
| 166 | (path.is_absolute() && fs::metadata(path).is_err()).then_some(folder) |
| 167 | }) |
| 168 | .collect() |
| 169 | } |
| 170 | |
| 171 | /// Whether the section file standing as `file` may take a replica whose base is `base`. |
| 172 | fn follows(file: &Stamp, base: &Stamp) -> bool { |
| 173 | file == base |
| 174 | || matches!( |
| 175 | (Header::parse(&file.header), Header::parse(&base.header)), |
| 176 | (Ok(file), Ok(base)) if file.generation > base.generation |
| 177 | ) |
| 178 | } |
| 179 | |
| 180 | /// Moves the replica at `from`, with the write-ahead log a crash may have left, to `to`. |
| 181 | fn adopt(from: &Path, to: &Path) -> io::Result<()> { |
| 182 | for suffix in ["-wal", "-shm", ""] { |
| 183 | let (mut source, mut target) = (from.as_os_str().to_owned(), to.as_os_str().to_owned()); |
| 184 | source.push(suffix); |
| 185 | target.push(suffix); |
| 186 | match fs::rename(&source, &target) { |
| 187 | Err(error) if !suffix.is_empty() && error.kind() == io::ErrorKind::NotFound => {} |
| 188 | moved => moved?, |
| 189 | } |
| 190 | } |
| 191 | Ok(()) |
| 192 | } |
| 193 | |
| 194 | /// Removes a replica folder that holds no replica. |
| 195 | fn tidy(folder: &Path) { |
| 196 | let empty = fs::read_dir(folder).is_ok_and(|mut entries| { |
| 197 | entries.all(|entry| entry.is_ok_and(|entry| entry.file_name() == LOCATION)) |
| 198 | }); |
| 199 | if empty { |
| 200 | let _ = fs::remove_file(folder.join(LOCATION)); |
| 201 | let _ = fs::remove_dir(folder); |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | fn hex(bytes: &[u8]) -> String { |
| 206 | bytes.iter().map(|byte| format!("{byte:02x}")).collect() |
| 207 | } |