From 60e5863b02fd2336fc543992bddc288f6c825efe Mon Sep 17 00:00:00 2001 From: botandbang <337765928+botandbang@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:18:14 -0500 Subject: [PATCH] feat(locallibrary): open a snapshot as a read-only folder with store browse store browse lays a snapshot out as its drive tree in a folder of links into the store, with nothing copied, and removes it when the command stops. store views lists what's open and store unbrowse removes what a crash or --keep left. Files in the store are now read-only: a write through a link would leave a file under a hash that isn't its content. Older files change when a view links them, so no store is walked to make one view. STORE_FORMAT stays 1. --- apps/locallibrary/GLOSSARY.md | 7 + apps/locallibrary/README.md | 1 + apps/locallibrary/changes/store-browse.md | 7 + apps/locallibrary/docs/commands.md | 1 + apps/locallibrary/docs/plugins.md | 3 +- apps/locallibrary/docs/store.md | 33 ++ apps/locallibrary/src/main.rs | 30 +- apps/locallibrary/src/store_cmd.rs | 110 ++++- apps/locallibrary/tests/it/drives/store.rs | 12 + apps/locallibrary/tests/it/main.rs | 2 + .../tests/it/props_cli_changes.rs | 1 + apps/locallibrary/tests/it/props_cli_view.rs | 278 +++++++++++++ crates/locallibrary/src/fsutil.rs | 20 + crates/locallibrary/src/lib.rs | 1 + crates/locallibrary/src/offsite.rs | 5 +- crates/locallibrary/src/peers/fetch.rs | 5 +- crates/locallibrary/src/retention.rs | 1 + crates/locallibrary/src/store.rs | 24 +- crates/locallibrary/src/store/tests.rs | 5 +- crates/locallibrary/src/view.rs | 265 ++++++++++++ crates/locallibrary/tests/common/mod.rs | 1 + crates/locallibrary/tests/it/main.rs | 2 + crates/locallibrary/tests/it/peers.rs | 1 + crates/locallibrary/tests/it/props_offsite.rs | 1 + crates/locallibrary/tests/it/props_peers.rs | 1 + crates/locallibrary/tests/it/props_store.rs | 2 + crates/locallibrary/tests/it/props_view.rs | 382 ++++++++++++++++++ crates/locallibrary/tests/it/store/copy.rs | 1 + crates/loclib-plugin/examples/echo/plugin.rs | 6 +- 29 files changed, 1187 insertions(+), 21 deletions(-) create mode 100644 apps/locallibrary/changes/store-browse.md create mode 100644 apps/locallibrary/tests/it/props_cli_view.rs create mode 100644 crates/locallibrary/src/view.rs create mode 100644 crates/locallibrary/tests/it/props_view.rs diff --git a/apps/locallibrary/GLOSSARY.md b/apps/locallibrary/GLOSSARY.md index 5278baa3c..4f43b8ee4 100644 --- a/apps/locallibrary/GLOSSARY.md +++ b/apps/locallibrary/GLOSSARY.md @@ -3,6 +3,13 @@ The command line tool, terminal player and sidecar that keep rekordbox USB drives in sync, backed up and edited, and the plugins that add to them. +## Store + +**View**: +A temporary, read-only folder of links into the store, laid out as one snapshot's drive tree, with +nothing copied. +_Avoid_: mount, browse folder, checkout + ## Plugins **Built-in plugin**: diff --git a/apps/locallibrary/README.md b/apps/locallibrary/README.md index c491698b4..14c963158 100644 --- a/apps/locallibrary/README.md +++ b/apps/locallibrary/README.md @@ -98,6 +98,7 @@ Every change shows what it will do and asks first. It ends with the commands you | You want to check your drives for a particular player | `locallibrary check --players CDJ-3000X,CDJ-2000NXS2` | | You changed your mind about the last change | `locallibrary undo` | | You want to browse and listen | `locallibrary` | +| You want a past version of a drive as a folder, nothing copied | `locallibrary store browse ` | | You want to know what a command does | `locallibrary help sync` | | Something went wrong | `locallibrary bug` (reports it, with your version and system filled in) | diff --git a/apps/locallibrary/changes/store-browse.md b/apps/locallibrary/changes/store-browse.md new file mode 100644 index 000000000..17c77c1a1 --- /dev/null +++ b/apps/locallibrary/changes/store-browse.md @@ -0,0 +1,7 @@ +### Added + +- `store browse` opens a past version of a drive as a folder of read-only links into the store, with nothing copied, and removes it on Ctrl-C. `--to` chooses the folder, `--keep` leaves it open, `store views` lists what's open and `store unbrowse` removes it. Not on Windows. + +### Changed + +- Files in the store are read-only from now on, so a write to one fails instead of leaving it under the wrong hash. Older ones change when a view shows them. diff --git a/apps/locallibrary/docs/commands.md b/apps/locallibrary/docs/commands.md index 23ff5432f..bda2fbbd3 100644 --- a/apps/locallibrary/docs/commands.md +++ b/apps/locallibrary/docs/commands.md @@ -132,6 +132,7 @@ its drives. | `locallibrary store index-limit [MIB\|default]` | The largest fingerprint index `mix` keeps for a drive (512 MiB unless set), see [Mixes](sets.md#mixes) | | `locallibrary store serve` / `store fetch ` | Copy snapshots between computers over the network: one serves its store, the other fetches what it lacks, see [From another computer](store.md#from-another-computer) | | `locallibrary store copy ` / `store copies` | Copy the store somewhere else (a NAS, another disk, a cloud-synced folder), only what's new; where copies were made, see [Off-site copies](store.md#off-site-copies) | +| `locallibrary store browse [--to DIR] [--keep]` / `store views` / `store unbrowse [DIR]` | Open a past version of a drive as a folder of read-only links into the store (macOS and Linux), see [Browsing a snapshot](store.md#browsing-a-snapshot) | | `locallibrary store copy --to PLUGIN:DESTINATION [--dry-run]` / `store restore --from PLUGIN:DESTINATION [--library L]... [--dry-run]` | Copy the store to a plugin's store destination, and bring back what a copy there has that this store lacks (nothing here is replaced), see [Plugins](plugins.md) | | `locallibrary diff [--playlist P]` | Playlist entries added, removed and moved between libraries (a library's newest drive plugged in, else its latest snapshot), drives or `snapshot:`s | | `locallibrary diff --summary` | Which drive is newer, the evidence, and the differences (in both databases) | diff --git a/apps/locallibrary/docs/plugins.md b/apps/locallibrary/docs/plugins.md index 5ae13b0cf..a405f2eed 100644 --- a/apps/locallibrary/docs/plugins.md +++ b/apps/locallibrary/docs/plugins.md @@ -378,7 +378,8 @@ A store destination keeps a copy of the store, as a copy in a folder does (`stor some, `--dry-run`), after losing this computer say, and `locallibrary drive verify DRIVE --from PLUGIN:DESTINATION --repair` rewrites damaged files from it when nothing nearer has them. Every file that comes back is checked against its BLAKE3 hash before it goes in the store, and only -locallibrary writes the store. +locallibrary writes the store. Stored files reach a plugin read-only on macOS and Linux, and a +system copy call keeps that, so a plugin that may write its copy again copies the bytes. A set destination publishes a set's recording with its tracklist (tracks marked unnamed, tracks your profile hides and those `--hide 3,7` names go without their names, notes stay private): diff --git a/apps/locallibrary/docs/store.md b/apps/locallibrary/docs/store.md index 5b48ee50b..e73a32c8c 100644 --- a/apps/locallibrary/docs/store.md +++ b/apps/locallibrary/docs/store.md @@ -107,6 +107,39 @@ The plan is shown first, and exactly that is removed (a pull or label in between again); history still runs through what was removed (`library log` skips it), and a removed snapshot's id says when it was pruned. +## Browsing a snapshot + +`store browse` opens a past version of a drive as an ordinary folder, laid out as the drive was, +with nothing copied: + +```bash +locallibrary store browse "Friday warmup" # in a temporary folder, until Ctrl-C +locallibrary store browse 3f9a2c --to ~/Desktop/friday # in a new or empty folder of your choice +locallibrary store browse 3f9a2c --keep # stays after the command ends +locallibrary store views # what's open, and any broken links +locallibrary store unbrowse # remove them (or name one) +``` + +It prints where the folder is. The files in it are links into the store, so they take no room: +hard links when the folder is on the store's disk, symlinks otherwise. Hard links open and play like +any file. A symlink leads to a stored file named by its hash, so on macOS a double-click opens the +text editor: drag it into a player, or use Open With. + +The view is read-only, since a stored file is named after what it holds. Writing to one fails with +"permission denied", and so does saving a new file there, so an edit can't vanish with the view. +Stored files are read-only from this version on; older ones become read-only when a view shows them. +An app that sets the permissions back can still get through, as can one that follows a symlink and +replaces the stored file itself: `store verify` finds what changed. + +A view goes when the command stops, on Ctrl-C or when its terminal closes. One a crash left stays +listed in `store views` and warned of by `store info` and `store browse` until `store unbrowse` +removes it. `store unbrowse` only removes what the view made, so a file someone added to it stays and +is reported. Pruning a view's snapshot warns first and goes ahead. Hard links keep working, and the +space their files take isn't freed until the view is removed. Symlinks to files nothing else uses +break, and the rest keep working. + +Views aren't available on Windows, where links take administrator rights. + ## From another computer ```bash diff --git a/apps/locallibrary/src/main.rs b/apps/locallibrary/src/main.rs index 91ec02b05..2c9e77ef0 100644 --- a/apps/locallibrary/src/main.rs +++ b/apps/locallibrary/src/main.rs @@ -695,6 +695,25 @@ enum StoreCommand { }, /// Where the store has been copied, when, and whether each copy is up to date Copies, + /// Open a past version of a drive as a folder of read-only links into the store, nothing + /// copied: it's there until Ctrl-C, then removed (macOS and Linux) + Browse { + /// Snapshot id (prefix) or label + snapshot: String, + /// Make it in this new or empty folder [default: a temporary one] + #[arg(long, value_name = "DIR")] + to: Option, + /// Leave it there until `store unbrowse` instead of waiting for Ctrl-C + #[arg(long)] + keep: bool, + }, + /// The folders `store browse` has open, and any broken links in them + Views, + /// Remove a folder `store browse` left open [default: all of them] + Unbrowse { + /// The folder + path: Option, + }, /// Bring back what a copy of the store at a plugin's destination has that this store lacks /// (after losing a computer, say): nothing this store has is replaced Restore { @@ -1391,7 +1410,11 @@ fn run(cli: Cli, out: Output, contributed: Option) -> R // Reads about the store, and changes to what it holds already, never make one. let existing = || diff_cmd::store_to_read(&cli.store); match command.unwrap_or(StoreCommand::Info) { - StoreCommand::Info => out.emit(&api::store_info(&existing()?)?, render::store_info), + StoreCommand::Info => { + let store = existing()?; + out.emit(&api::store_info(&store)?, render::store_info); + store_cmd::views_open(&store)?; + } StoreCommand::Gc { dry_run } => { let store = existing()?; let plan = api::store_gc_plan(&store)?; @@ -1437,6 +1460,11 @@ fn run(cli: Cli, out: Output, contributed: Option) -> R }; } StoreCommand::Copies => return store_cmd::copies(&cli.store, out), + StoreCommand::Browse { snapshot, to, keep } => { + return store_cmd::browse(&existing()?, &snapshot, to.as_deref(), keep, out, &ctx); + } + StoreCommand::Views => return store_cmd::views(&cli.store, out), + StoreCommand::Unbrowse { path } => return store_cmd::unbrowse(&existing()?, path, out), StoreCommand::IndexLimit { limit } => return store_cmd::index_limit(&cli.store, limit, out), StoreCommand::Restore { from, libraries, dry_run } => { let options = locallibrary::offsite::RestoreOptions { libraries, dry_run }; diff --git a/apps/locallibrary/src/store_cmd.rs b/apps/locallibrary/src/store_cmd.rs index 26d86098c..fecec5fdf 100644 --- a/apps/locallibrary/src/store_cmd.rs +++ b/apps/locallibrary/src/store_cmd.rs @@ -1,7 +1,8 @@ -//! `pull` and the store's upkeep: `store label|unlabel|prune|copy|copies|restore|serve|fetch|index-limit`. +//! `pull` and the store's upkeep: +//! `store label|unlabel|prune|copy|copies|browse|views|unbrowse|restore|serve|fetch|index-limit`. use std::io::IsTerminal; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::ExitCode; use anyhow::Result; @@ -12,6 +13,7 @@ use locallibrary::catalog::{FetchReport, LibraryOutcome}; use locallibrary::local_time; use locallibrary::mix::prefs::{DEFAULT_INDEX_LIMIT_MIB, MixPrefs}; use locallibrary::progress::Ctx; +use locallibrary::view; use serde::Serialize; use crate::output::{self, Outcome, Output}; @@ -354,6 +356,110 @@ pub fn fetch( Ok(fetched(&r, "the other computer", "on both computers", out)) } +/// `store browse`: a view of the snapshot, there until Ctrl-C (or until another `store unbrowse` +/// removes it) unless `keep`. +pub fn browse( + store: &Store, + snapshot: &str, + to: Option<&Path>, + keep: bool, + out: Output, + ctx: &Ctx<'_>, +) -> Result { + let id = store.resolve_snapshot(snapshot)?; + views_open(store)?; + let opened = view::open(store, &id, to)?; + let show = |o: &view::Opened, until: &str| { + output::ok(format!( + "view of snapshot {}: {} file(s), {}, nothing copied", + o.view.snapshot.short(), + o.files, + output::bytes(o.bytes) + )); + say!(" {}", style(o.view.path.display()).bold()); + for path in &o.missing { + output::warn(format!("{path} isn't in the store, so its link is broken (see `store verify`)")); + } + for path in &o.skipped { + output::warn(format!("{path} was left out: an unsafe path, or one differing from another only in case")); + } + let pruned = match o.symlinks { + 0 => "pruning this snapshot leaves its space in use until the view is removed", + _ => "they break if this snapshot is pruned", + }; + output::hint(format!("Read-only links into the store: {pruned}. {until}")); + }; + if keep { + return Ok(out.done(&opened, |o| show(o, "Remove it with `locallibrary store unbrowse`."))); + } + match out.json { + true => say!("{}", output::with_schema(serde_json::to_value(&opened).expect("serialisable"))), + false => show(&opened, "It's removed on Ctrl-C."), + } + while ctx.check().is_ok() && view::is_open(store, &opened.view.path)? { + std::thread::sleep(std::time::Duration::from_millis(100)); + } + let closed = match view::is_open(store, &opened.view.path)? { + true => view::close(store, &opened.view.path)?, + false => view::Closed { path: opened.view.path, left: 0 }, + }; + Ok(out.done(&closed, say_closed)) +} + +/// `store views`. +pub fn views(store: &Option, out: Output) -> Result { + let views = crate::diff_cmd::read_or_empty(store, view::list)?; + out.emit(&views, |views| { + if views.is_empty() { + say!("No views open. Make one with `locallibrary store browse `."); + } + for v in views { + let when = local_time(v.view.created, "%Y-%m-%d %H:%M"); + say!("{}", style(v.view.path.display()).bold()); + say!(" {}", output::dim(format!("snapshot {}, opened {when}", v.view.snapshot.short()))); + if v.broken > 0 { + output::warn(format!("{} broken link(s): the store no longer has their files", v.broken)); + } + } + }); + Ok(ExitCode::SUCCESS) +} + +/// `store unbrowse`. +pub fn unbrowse(store: &Store, path: Option, out: Output) -> Result { + let paths = match path { + Some(path) => vec![path], + None => view::list(store)?.into_iter().map(|v| v.view.path).collect(), + }; + let closed = paths.iter().map(|p| view::close(store, p)).collect::>>()?; + Ok(out.done(&closed, |closed| { + if closed.is_empty() { + say!("No views open."); + } + closed.iter().for_each(say_closed); + })) +} + +fn say_closed(c: &view::Closed) { + match c.left { + 0 => output::ok(format!("removed the view at {}", c.path.display())), + n => output::warn(format!("left {n} file(s) in {} that aren't links", c.path.display())), + } +} + +/// Warn of views still open: `store browse` removes its own, so one here is in use, kept or left +/// by a crash. +pub fn views_open(store: &Store) -> Result<()> { + for v in view::list(store)? { + output::warn(format!( + "a view of snapshot {} is open at {}; `locallibrary store unbrowse` removes it", + v.view.snapshot.short(), + v.view.path.display() + )); + } + Ok(()) +} + /// `store restore --from PLUGIN:DESTINATION`. pub fn restore( store: &Store, diff --git a/apps/locallibrary/tests/it/drives/store.rs b/apps/locallibrary/tests/it/drives/store.rs index 554ce3bec..f44fa1a16 100644 --- a/apps/locallibrary/tests/it/drives/store.rs +++ b/apps/locallibrary/tests/it/drives/store.rs @@ -185,6 +185,7 @@ fn the_store_says_what_it_holds() { .flat_map(|d| std::fs::read_dir(d.unwrap().path()).unwrap()) .map(|f| f.unwrap().path()) .collect(); + std::fs::remove_file(&objects[0]).unwrap(); std::fs::write(&objects[0], "damaged").unwrap(); std::fs::remove_file(&objects[1]).unwrap(); let r = env.run(&["store", "verify"]); @@ -249,3 +250,14 @@ fn a_store_that_wont_open_is_named() { assert!(err.contains(&named) && err.contains("newer than this version"), "{args:?}: {err}"); } } + +/// Windows has no view to give: links there take administrator rights. +#[cfg(windows)] +#[test] +fn windows_says_views_arent_available() { + let env = Env::new(); + env.pulled("Main"); + let id = env.ok(&["log", "Main"]).lines().last().unwrap().split_whitespace().next().unwrap().to_string(); + let err = env.fails(1, &["store", "browse", &id]); + assert!(err.contains("isn't available on Windows"), "{err}"); +} diff --git a/apps/locallibrary/tests/it/main.rs b/apps/locallibrary/tests/it/main.rs index de9dc99f5..8ab428b98 100644 --- a/apps/locallibrary/tests/it/main.rs +++ b/apps/locallibrary/tests/it/main.rs @@ -57,6 +57,8 @@ mod props_cli_sets; mod props_cli_smart; mod props_cli_terminal; mod props_cli_tracklist; +#[cfg(unix)] +mod props_cli_view; mod props_rpc; mod props_rpc_every; mod props_rpc_flows; diff --git a/apps/locallibrary/tests/it/props_cli_changes.rs b/apps/locallibrary/tests/it/props_cli_changes.rs index 39db45464..7694c3a51 100644 --- a/apps/locallibrary/tests/it/props_cli_changes.rs +++ b/apps/locallibrary/tests/it/props_cli_changes.rs @@ -64,6 +64,7 @@ fn by_hand(fx: &Env, step: &str) { // A file in the store changes on disk. "!corrupt-store" => { if let Some(f) = same_in_both(&fx.store.join("objects")) { + std::fs::remove_file(&f).unwrap(); std::fs::write(f, "not what was stored").unwrap(); } } diff --git a/apps/locallibrary/tests/it/props_cli_view.rs b/apps/locallibrary/tests/it/props_cli_view.rs new file mode 100644 index 000000000..d2cd81269 --- /dev/null +++ b/apps/locallibrary/tests/it/props_cli_view.rs @@ -0,0 +1,278 @@ +//! `store browse`, `store views` and `store unbrowse` as commands: a view opens where it says, is +//! listed until it's gone, and removes itself on Ctrl-C, and a run killed outright is still found. + +use std::io::{BufRead, BufReader, ErrorKind}; +use std::path::{Path, PathBuf}; +use std::process::{ChildStdout, Stdio}; + +use hegel::generators as gs; +use hegel::{HealthCheck, TestCase}; +use serde_json::Value; + +use crate::support::{Env, Running, line, path}; + +/// The newest snapshot of drive A, by the id `log` shows. +fn snapshot(env: &Env) -> String { + env.ok(&["log", "A"]).lines().last().unwrap().split_whitespace().next().unwrap().to_string() +} + +/// The file tree under `dir`, each file with its size through its link. +fn tree(dir: &Path) -> Vec<(String, u64)> { + let mut found: Vec<(String, u64)> = walkdir::WalkDir::new(dir) + .into_iter() + .map(Result::unwrap) + .filter(|e| !e.file_type().is_dir()) + .map(|e| { + let key = e.path().strip_prefix(dir).unwrap().to_str().unwrap().to_string(); + (key, std::fs::metadata(e.path()).unwrap().len()) + }) + .collect(); + found.sort(); + found +} + +/// The store's manifest of `snapshot`. +fn manifest(env: &Env, snapshot: &str) -> PathBuf { + std::fs::read_dir(env.store.join("snapshots")) + .unwrap() + .map(|e| e.unwrap().path()) + .find(|p| p.file_name().unwrap().to_str().unwrap().starts_with(snapshot)) + .unwrap() +} + +/// What the store's manifest of `snapshot` lists, as [`tree`] does. +fn listed(env: &Env, snapshot: &str) -> Vec<(String, u64)> { + let manifest: Value = serde_json::from_slice(&std::fs::read(manifest(env, snapshot)).unwrap()).unwrap(); + let files = manifest["files"].as_array().unwrap().iter(); + let mut files: Vec<(String, u64)> = + files.map(|f| (f["path"].as_str().unwrap().to_string(), f["size"].as_u64().unwrap())).collect(); + files.sort(); + files +} + +/// `store browse` with a terminal's output gone to a pipe, until its first line of `--json`. +fn browsing(env: &Env, args: &[&str]) -> (Running, BufReader, PathBuf) { + let temp = env.outside("temp"); + std::fs::create_dir_all(&temp).unwrap(); + let mut command = env.command(); + command.args(["--json", "store", "browse"]).args(args).env("TMPDIR", &temp).stdout(Stdio::piped()); + let mut child = command.spawn().unwrap(); + let mut out = BufReader::new(child.stdout.take().unwrap()); + let mut first = String::new(); + out.read_line(&mut first).unwrap(); + let opened: Value = serde_json::from_str(&first).unwrap(); + (Running(child), out, PathBuf::from(opened["path"].as_str().unwrap())) +} + +fn signal(child: &Running, signal: i32) { + // SAFETY: a signal to the child this test started, which hasn't been waited for yet. + assert_eq!(unsafe { libc::kill(i32::try_from(child.id()).unwrap(), signal) }, 0); +} + +/// `--keep` leaves a view that says where it is and how it goes. It's the snapshot's tree, `store +/// views` and `store info` say it's there, and `store unbrowse` removes it, by name or with the +/// rest. A file in the view can't be written. +#[hegel::test(test_cases = 5, suppress_health_check = [HealthCheck::TooSlow])] +fn a_kept_view_is_said_listed_and_removed(tc: TestCase) { + let env = Env::from_template(); + let id = snapshot(&env); + let to = env.outside("view"); + let chosen: bool = tc.draw(gs::booleans()); + let mut args = vec!["store", "browse", &id, "--keep"]; + if chosen { + args.extend(["--to", path(&to)]); + } + let out = env.ok(&args); + assert!(out.contains(&format!("view of snapshot {id}")) && out.contains("nothing copied"), "{out}"); + let at = PathBuf::from(line(&out, "/").trim()); + assert_eq!(at == to, chosen, "{out}"); + let run = env.run(&["store", "browse", &id, "--keep", "--to", path(&env.outside("second"))]); + assert_eq!(run.code, 0, "{}", run.err); + assert!(run.out.contains("Remove it with `locallibrary store unbrowse`."), "{}", run.out); + assert!(run.out.contains("pruning this snapshot leaves its space in use"), "{}", run.out); + assert!(run.out.contains(&format!("a view of snapshot {id} is open at {}", at.display())), "{}", run.out); + assert_eq!(tree(&at), listed(&env, &id)); + + let views = env.ok(&["store", "views"]); + assert!(views.contains(path(&at)) && views.contains(&format!("snapshot {id}, opened ")), "{views}"); + let views: Value = serde_json::from_str(&env.ok(&["--json", "store", "views"])).unwrap(); + assert_eq!(views.as_array().unwrap().len(), 2); + assert!(env.ok(&["store", "info"]).contains("is open at")); + + let by_name: bool = tc.draw(gs::booleans()); + if by_name { + let out = env.ok(&["store", "unbrowse", path(&at)]); + assert!(out.contains(&format!("removed the view at {}", at.display())), "{out}"); + assert!(!at.exists()); + } + assert!(env.ok(&["store", "unbrowse"]).contains("removed the view at")); + assert!(!at.exists() && !env.outside("second").exists()); + assert!(env.ok(&["store", "views"]).contains("No views open.")); + assert!(env.ok(&["store", "unbrowse"]).contains("No views open.")); + let err = env.fails(1, &["store", "unbrowse", path(&at)]); + assert!(err.contains(&format!("a view at {}", at.display())), "{err}"); + assert!(!env.ok(&["store", "info"]).contains("is open at")); +} + +/// Without `--keep` the view is there until Ctrl-C, SIGTERM, a closed terminal, or a `store +/// unbrowse` elsewhere, and goes with it, also when that leaves a file someone put there. It's made +/// in the temporary folder unless told otherwise. +#[hegel::test(test_cases = 3, suppress_health_check = [HealthCheck::TooSlow])] +fn a_view_goes_when_the_command_stops(tc: TestCase) { + let env = Env::from_template(); + let id = snapshot(&env); + for how in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP, 0] { + let (mut child, mut out, at) = browsing(&env, &[&id]); + assert!(at.starts_with(env.outside("temp")), "{}", at.display()); + assert_eq!(tree(&at), listed(&env, &id)); + let theirs = at.join("mine.txt"); + let left: bool = how == 0 && tc.draw(gs::booleans()); + if left { + std::fs::set_permissions(&at, std::os::unix::fs::PermissionsExt::from_mode(0o700)).unwrap(); + std::fs::write(&theirs, "mine").unwrap(); + } + match how { + 0 => _ = env.ok(&["store", "unbrowse", path(&at)]), + how => signal(&child, how), + } + let mut rest = String::new(); + std::io::Read::read_to_string(&mut out, &mut rest).unwrap(); + assert!(child.wait().unwrap().success(), "{rest}"); + assert_eq!(theirs.exists(), left, "{}", at.display()); + assert_eq!(at.exists(), left, "{}", at.display()); + let closed: Value = serde_json::from_str(&rest).unwrap(); + assert_eq!(closed["report"]["path"], path(&at), "{rest}"); + assert!(env.ok(&["store", "views"]).contains("No views open.")); + } +} + +/// A run killed outright can't tidy up, so its view is said until it's removed, and pruning its +/// snapshot says what that leaves. +#[hegel::test(test_cases = 3, suppress_health_check = [HealthCheck::TooSlow])] +fn a_view_left_by_a_killed_run_is_still_found(_tc: TestCase) { + let env = Env::from_template(); + let id = snapshot(&env); + let listed_when_opened = listed(&env, &id); + let (mut child, _out, at) = browsing(&env, &[&id, "--to", path(&env.outside("left"))]); + signal(&child, libc::SIGKILL); + child.wait().unwrap(); + assert!(at.exists()); + let info = env.ok(&["store", "info"]); + assert!(info.contains(&format!("is open at {}", at.display())), "{info}"); + + env.ok(&["--yes", "playlist", "new", "A", "Later"]); + env.ok(&["pull", "A"]); + let plan = env.ok(&["store", "prune", "--keep-last", "1", "--dry-run"]); + let said = format!("the view at {} shows a snapshot this removes: the space its files take", at.display()); + assert!(plan.contains(&said), "{plan}"); + env.ok(&["--yes", "store", "prune", "--keep-last", "1"]); + assert_eq!(tree(&at), listed_when_opened, "a hard link keeps its file"); + assert!(!env.ok(&["store", "views"]).contains("broken link(s)")); + assert!(env.ok(&["store", "unbrowse"]).contains("removed the view at")); + assert!(!at.exists()); +} + +/// In words, a view says where it is and how it goes, and names a file it left out: here one whose +/// path would lead out of the view, as a damaged or crafted manifest could. +#[hegel::test(test_cases = 2, suppress_health_check = [HealthCheck::TooSlow])] +fn a_view_says_where_it_is_and_what_it_left_out(_tc: TestCase) { + let env = Env::from_template(); + let id = snapshot(&env); + let file = manifest(&env, &id); + let mut snapshot: Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap(); + let mut outside = snapshot["files"][0].clone(); + outside["path"] = "../outside.mp3".into(); + snapshot["files"].as_array_mut().unwrap().push(outside); + std::fs::write(&file, snapshot.to_string()).unwrap(); + + let temp = env.outside("temp"); + std::fs::create_dir_all(&temp).unwrap(); + let mut command = env.command(); + command.args(["store", "browse", &id]).env("TMPDIR", &temp).stdout(Stdio::piped()); + let mut child = Running(command.spawn().unwrap()); + let mut out = BufReader::new(child.stdout.take().unwrap()); + let mut said = String::new(); + while !said.contains("It's removed on Ctrl-C.") { + assert_ne!(out.read_line(&mut said).unwrap(), 0, "{said}"); + } + assert!(said.contains(&format!("view of snapshot {id}")), "{said}"); + assert!(said.contains("../outside.mp3 was left out: an unsafe path"), "{said}"); + assert!(!temp.join("outside.mp3").exists()); + + signal(&child, libc::SIGINT); + let mut rest = String::new(); + std::io::Read::read_to_string(&mut out, &mut rest).unwrap(); + assert!(child.wait().unwrap().success(), "{rest}"); + assert!(rest.contains("removed the view at"), "{rest}"); + assert_eq!(std::fs::read_dir(&temp).unwrap().count(), 0, "nothing is left in the temporary folder"); +} + +/// A file the store has lost is said and shows as a broken symlink. A symlink leads to the store by +/// its full path, whatever path the store was named by, so it works from anywhere once the file is +/// back, and the view warns that it breaks when pruned. +#[hegel::test(test_cases = 2, suppress_health_check = [HealthCheck::TooSlow])] +fn a_lost_file_is_a_symlink_that_leads_to_the_store(_tc: TestCase) { + let env = Env::from_template(); + let id = snapshot(&env); + let shard = std::fs::read_dir(env.store.join("objects")).unwrap().flatten().find(|e| e.path().is_dir()).unwrap(); + let object = std::fs::read_dir(shard.path()).unwrap().flatten().next().unwrap().path(); + let bytes = std::fs::read(&object).unwrap(); + std::fs::remove_file(&object).unwrap(); + + let to = env.outside("view"); + let mut command = env.command(); + command.current_dir(env.store.parent().unwrap()).env("LOCALLIBRARY_STORE", "store"); + let run = command.args(["store", "browse", &id, "--keep", "--to", path(&to)]).output().unwrap(); + assert!(run.status.success()); + let out = String::from_utf8(run.stdout).unwrap(); + assert!(out.contains("isn't in the store, so its link is broken"), "{out}"); + assert!(out.contains("they break if this snapshot is pruned"), "{out}"); + let views = env.ok(&["store", "views"]); + assert!(views.contains("broken link(s): the store no longer has their files"), "{views}"); + + std::fs::write(&object, bytes).unwrap(); + assert_eq!(tree(&to), listed(&env, &id)); + env.ok(&["store", "unbrowse"]); +} + +/// Hard links are real files, so a view reads like a drive, but no write gets in: not a command +/// pointed at it, nor a write to one of its files. The store's files stay as they were. +#[hegel::test(test_cases = 2, suppress_health_check = [HealthCheck::TooSlow])] +fn nothing_writes_into_a_view(tc: TestCase) { + let env = Env::from_template(); + let id = snapshot(&env); + let view = env.outside("view"); + env.ok(&["store", "browse", &id, "--keep", "--to", path(&view)]); + let stored = || { + let objects = walkdir::WalkDir::new(env.store.join("objects")).sort_by_file_name(); + let files = objects.into_iter().map(Result::unwrap).filter(|e| e.file_type().is_file()); + files.map(|e| std::fs::read(e.path()).unwrap()).collect::>() + }; + let before = stored(); + + let args: &[&str] = match tc.draw(gs::booleans()) { + true => &["--yes", "adopt", path(&view)], + false => &["--yes", "playlist", "new", path(&view), "Later"], + }; + let run = env.run(args); + assert_ne!(run.code, 0, "{args:?}\n{}", run.out); + assert!(run.err.to_lowercase().contains("permission denied"), "{args:?}\n{}", run.err); + + let file = view.join(&listed(&env, &id)[0].0); + assert_eq!(std::fs::write(&file, b"edited").unwrap_err().kind(), ErrorKind::PermissionDenied); + assert_eq!(stored(), before); + env.ok(&["store", "unbrowse"]); +} + +/// What a view can't be made of says so, and a chosen folder with something in it is refused. +#[hegel::test(test_cases = 3, suppress_health_check = [HealthCheck::TooSlow])] +fn a_view_that_cant_be_made_says_why(_tc: TestCase) { + let env = Env::from_template(); + let err = env.fails(1, &["store", "browse", "feedface"]); + assert!(err.contains("snapshot feedface"), "{err}"); + let taken = env.outside("taken"); + std::fs::create_dir(&taken).unwrap(); + std::fs::write(taken.join("mine.txt"), "mine").unwrap(); + let err = env.run(&["store", "browse", &snapshot(&env), "--keep", "--to", path(&taken)]).err; + assert!(err.contains("isn't empty; choose another folder"), "{err}"); +} diff --git a/crates/locallibrary/src/fsutil.rs b/crates/locallibrary/src/fsutil.rs index 9443f09d1..df602e12d 100644 --- a/crates/locallibrary/src/fsutil.rs +++ b/crates/locallibrary/src/fsutil.rs @@ -243,6 +243,26 @@ pub fn absolute(path: &Path) -> PathBuf { pub use loclib_model::paths::resolved; +/// Take the write permission off `path` for everyone, and say whether it's read-only now: some +/// disks accept this and keep nothing. Being renamed over or deleted still works, since that +/// writes its folder. Not on Windows, where a read-only file can't be replaced or deleted. +pub(crate) fn seal(path: &Path) -> std::io::Result { + #[cfg(unix)] + { + let mut permissions = fs::metadata(path)?.permissions(); + if !permissions.readonly() { + permissions.set_readonly(true); + fs::set_permissions(path, permissions)?; + } + Ok(fs::metadata(path)?.permissions().readonly()) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(false) + } +} + /// Delete a file if it exists. Returns whether it did. pub fn remove_if_exists(path: &Path) -> Result { match fs::remove_file(path) { diff --git a/crates/locallibrary/src/lib.rs b/crates/locallibrary/src/lib.rs index 1a17d368c..769c0e0dd 100644 --- a/crates/locallibrary/src/lib.rs +++ b/crates/locallibrary/src/lib.rs @@ -130,6 +130,7 @@ mod timeline; mod track_files; pub mod tree; pub mod upgrade; +pub mod view; pub mod watch; mod workers; diff --git a/crates/locallibrary/src/offsite.rs b/crates/locallibrary/src/offsite.rs index b5e0bca0d..528206f91 100644 --- a/crates/locallibrary/src/offsite.rs +++ b/crates/locallibrary/src/offsite.rs @@ -753,8 +753,5 @@ pub(crate) fn fetch_object(store: &Store, dest: &dyn Destination, hash: &Hash, c message: format!("a stored file came back damaged ({})", hash.short()), }); } - let dst = store.object_path(hash); - let parent = dst.parent().expect("object path has parent"); - fs::create_dir_all(parent).at(parent)?; - fsutil::replace(tmp.into_temp_path(), &dst) + store.keep_object(tmp.into_temp_path(), hash) } diff --git a/crates/locallibrary/src/peers/fetch.rs b/crates/locallibrary/src/peers/fetch.rs index b0bb8ed8f..6be002737 100644 --- a/crates/locallibrary/src/peers/fetch.rs +++ b/crates/locallibrary/src/peers/fetch.rs @@ -156,8 +156,5 @@ async fn get_object(conn: &Connection, store: &Store, hash: Hash, size: u64) -> if n != size || got != hash { return Err(Error::Peer(format!("a stored file came back damaged ({})", hash.short()))); } - let dst = store.object_path(&hash); - let parent = dst.parent().expect("object path has parent"); - std::fs::create_dir_all(parent).at(parent)?; - fsutil::replace(tmp.into_temp_path(), &dst) + store.keep_object(tmp.into_temp_path(), &hash) } diff --git a/crates/locallibrary/src/retention.rs b/crates/locallibrary/src/retention.rs index 4c83bc616..a54e92920 100644 --- a/crates/locallibrary/src/retention.rs +++ b/crates/locallibrary/src/retention.rs @@ -176,6 +176,7 @@ pub fn plan(store: &Store, opts: &PruneOptions, ctx: &Ctx<'_>) -> Result/ //! store.json format marker + store id //! objects/ab/cdef... file contents, addressed by BLAKE3 hash (deduplicated across -//! snapshots, libraries and drives) +//! snapshots, libraries and drives), read-only since a write through +//! a link to one would leave it under the wrong name (see crate::view; +//! older objects stay as they were until a view links them) //! snapshots/.json one manifest per snapshot: every file's path/size/mtime/hash, the //! parent snapshot(s), library summary and drive lineage //! libraries.json named libraries and their head snapshot //! labels.json names given to snapshots (`pull --label`) //! pruned.json what's left of snapshots `store prune` removed (their parents), so //! history still walks through them +//! views.json the folders of links `store browse` has open (see crate::view) +//! views.lock held while views.json is read and written back //! drives.json drives seen so far (id -> name, last mount point, library) //! drives.lock held while drives.json is read and written back (see //! Store::update_drives) @@ -456,17 +460,25 @@ impl Store { let mut tmp = fsutil::temp_in(tempfile::Builder::new().prefix("import-"), &tmp_dir)?; let tmp_path = tmp.path().to_path_buf(); let (h, size) = hash::copy_hashing(src, tmp.as_file_mut(), &tmp_path, ctx, Phase::Copy)?; - let dst = self.object_path(&h); if self.holds(&h, size) { return Ok((h, false)); } - let parent = dst.parent().expect("object path has parent"); - fs::create_dir_all(parent).at(parent)?; - fsutil::replace(tmp.into_temp_path(), &dst)?; + self.keep_object(tmp.into_temp_path(), &h)?; Ok((h, true)) }) } + /// Keep the finished temp file `tmp` as the object `h`, read-only before it's there to be + /// linked to. Best effort: FAT keeps no permissions and Linux refuses to set them there, and + /// `store browse`, the one thing that relies on them, checks for itself. + pub(crate) fn keep_object(&self, tmp: tempfile::TempPath, h: &Hash) -> Result<()> { + let _ = fsutil::seal(&tmp); + let dst = self.object_path(h); + let parent = dst.parent().expect("object path has parent"); + fs::create_dir_all(parent).at(parent)?; + fsutil::replace(tmp, &dst) + } + /// Make imported objects durable. pub fn flush_objects(&self, hashes: &[Hash]) -> Result<()> { fsutil::flush(&hashes.iter().map(|h| self.object_path(h)).collect::>()) @@ -475,7 +487,7 @@ impl Store { pub fn import_bytes(&self, bytes: &[u8]) -> Result { let h = Hash::of(bytes); if !self.holds(&h, bytes.len() as u64) { - self.writing(|| fsutil::write_atomic(&self.object_path(&h), bytes))?; + self.writing(|| self.keep_object(fsutil::stage_bytes(&self.object_path(&h), bytes)?.into_temp_path(), &h))?; } Ok(h) } diff --git a/crates/locallibrary/src/store/tests.rs b/crates/locallibrary/src/store/tests.rs index dc90bb8ac..88a338d06 100644 --- a/crates/locallibrary/src/store/tests.rs +++ b/crates/locallibrary/src/store/tests.rs @@ -135,7 +135,10 @@ fn imports_replace_an_object_cut_short() { let src = dir.path().join("a.bin"); fs::write(&src, b"the whole file").unwrap(); let (h, _) = store.import_file(&src, None, &Ctx::silent()).unwrap(); - let cut = |store: &Store| fs::write(store.object_path(&h), b"the wh").unwrap(); + let cut = |store: &Store| { + fs::remove_file(store.object_path(&h)).unwrap(); + fs::write(store.object_path(&h), b"the wh").unwrap(); + }; let whole = |store: &Store| fs::read(store.object_path(&h)).unwrap() == b"the whole file"; cut(&store); diff --git a/crates/locallibrary/src/view.rs b/crates/locallibrary/src/view.rs new file mode 100644 index 000000000..c63f29d09 --- /dev/null +++ b/crates/locallibrary/src/view.rs @@ -0,0 +1,265 @@ +//! Views of snapshots (`store browse`): a folder of links into the store, laid out as the +//! snapshot's drive tree, so a past version of a drive can be opened, played or dragged into +//! another app with nothing copied. +//! +//! Files are hard links where the view is on the store's volume and symlinks elsewhere. An app +//! treats a hard link by its own name, extension and all, where macOS follows a symlink to the +//! stored file, whose name is a hash, and opens it as text. A hard link also survives its snapshot +//! being pruned, holding the file's space until the view goes, where a symlink breaks. +//! +//! A write through either would land in a stored file, whose name is the hash of what it held. So +//! every object a view links is read-only before the link is made (new objects already are, and +//! older ones are changed here and nowhere else, so a store isn't walked to make one view). The +//! view's folders are read-only too: an app that saves through a temp file and a rename fails +//! there, instead of leaving an edit that goes when the view does. +//! +//! Open views are listed in `views.json`, so one a crash left is still found and can be removed. +//! That file stays on this computer: copies of the store don't take it. + +use std::fs; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use rustc_hash::FxHashSet; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::error::{IoContext, Reason}; +use crate::fsutil; +use crate::hash::Hash; +use crate::paths; +use crate::store::{Snapshot, Store}; +use crate::{Error, Result}; + +/// An open view, as `views.json` lists it. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct View { + pub path: PathBuf, + pub snapshot: Hash, + pub created: DateTime, +} + +/// A view [`open`] made. +#[derive(Debug, Clone, Serialize)] +pub struct Opened { + #[serde(flatten)] + pub view: View, + /// Files linked, and their size. + pub files: usize, + pub bytes: u64, + /// Files linked by symlink, which break if their snapshot is pruned. + pub symlinks: usize, + /// Files the store has lost: shown anyway, as broken symlinks. + pub missing: Vec, + /// Files left out: a path that would leave the view, one differing from another only in case + /// (they're one file on a drive), or one with no hash. + pub skipped: Vec, +} + +/// A view as [`list`] finds it. +#[derive(Debug, Clone, Serialize)] +pub struct OpenView { + #[serde(flatten)] + pub view: View, + pub symlinks: usize, + /// Symlinks whose stored file is gone: nothing the store keeps uses it any more. + pub broken: usize, +} + +/// What [`close`] did. +#[derive(Debug, Clone, Serialize)] +pub struct Closed { + pub path: PathBuf, + /// Files in it that aren't the view's, which it left there (and the folders holding them). + pub left: usize, +} + +/// Make a view of `id` in `to` (a new or empty folder) or a temporary folder. +pub fn open(store: &Store, id: &Hash, to: Option<&Path>) -> Result { + if !cfg!(unix) { + return Err(Error::invalid("`store browse` isn't available on Windows, where links take administrator rights")); + } + let snapshot = store.load_snapshot(id)?; + let view = View { path: folder_for(id, to)?, snapshot: *id, created: Utc::now() }; + let path = view.path.clone(); + let opened = update(store, |views| views.push(view.clone())).and_then(|()| fill(store, &snapshot, view)); + if opened.is_err() { + let _ = remove(store, path); + } + opened +} + +/// `to` once it's there and empty, or a new temporary folder. +fn folder_for(id: &Hash, to: Option<&Path>) -> Result { + let Some(to) = to else { + let temp = std::env::temp_dir(); + let name = format!("locallibrary-view-{}-", id.short()); + return Ok(tempfile::Builder::new().prefix(&name).tempdir_in(&temp).at(&temp)?.keep()); + }; + let to = fsutil::absolute(to); + if fs::read_dir(&to).is_ok_and(|mut entries| entries.next().is_some()) { + return Err(Error::conflict(Reason::Exists, format!("{} isn't empty; choose another folder", to.display())) + .with("path", &to)); + } + fs::create_dir_all(&to).at(&to)?; + Ok(to) +} + +/// Link `snapshot`'s files under the view's folder, then make its folders read-only. +fn fill(store: &Store, snapshot: &Snapshot, view: View) -> Result { + let mut opened = Opened { view, files: 0, bytes: 0, symlinks: 0, missing: Vec::new(), skipped: Vec::new() }; + let mut taken = FxHashSet::default(); + for f in &snapshot.files { + let unique = paths::is_safe_key(&f.path) && taken.insert(f.path.to_lowercase()); + let (Some(h), true) = (f.hash, unique) else { + opened.skipped.push(f.path.clone()); + continue; + }; + let object = fsutil::absolute(&store.object_path(&h)); + match fsutil::seal(&object) { + Ok(true) => {} + Ok(false) => { + return Err(Error::invalid(format!( + "the store at {} is on a disk that can't keep files read-only, so a write through a view would \ + reach it", + store.root().display() + ))); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => opened.missing.push(f.path.clone()), + Err(e) => return Err(Error::Io { path: object, source: e }), + } + let at = opened.view.path.join(&f.path); + let folder = at.parent().expect("a file in a view has a folder"); + fs::create_dir_all(folder).at(folder)?; + link(&object, &at).at(&at)?; + opened.files += 1; + opened.bytes += f.size; + } + for e in walkdir::WalkDir::new(&opened.view.path).into_iter().flatten().filter(|e| e.file_type().is_dir()) { + set_folder(e.path(), false).at(e.path())?; + } + opened.symlinks = symlinks(&opened.view.path).count(); + Ok(opened) +} + +/// A hard link at `at` to `target`, or a symlink where there can't be one: another volume, a disk +/// without them, or a `target` the store has lost. +fn link(target: &Path, at: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + fs::hard_link(target, at).or_else(|_| std::os::unix::fs::symlink(target, at)) + } + #[cfg(not(unix))] + { + let _ = (target, at); + Err(std::io::ErrorKind::Unsupported.into()) + } +} + +/// Let its owner change what's in `dir`, or not. Nobody else gets in: a view lists a library. +fn set_folder(dir: &Path, writable: bool) -> std::io::Result<()> { + #[cfg(unix)] + { + let mode = if writable { 0o700 } else { 0o500 }; + fs::set_permissions(dir, std::os::unix::fs::PermissionsExt::from_mode(mode)) + } + #[cfg(not(unix))] + { + let _ = (dir, writable); + Ok(()) + } +} + +/// The symlinks under `dir`. +fn symlinks(dir: &Path) -> impl Iterator { + walkdir::WalkDir::new(dir).into_iter().flatten().filter(walkdir::DirEntry::path_is_symlink) +} + +/// The open views, without those whose folder has gone (a restart clears temporary folders). +pub fn list(store: &Store) -> Result> { + let open = recorded(store)?.into_iter().filter(|v| v.path.exists()).map(|view| { + let links: Vec = symlinks(&view.path).collect(); + let broken = links.iter().filter(|e| !e.path().exists()).count(); + OpenView { view, symlinks: links.len(), broken } + }); + Ok(open.collect()) +} + +/// Whether there's still a view at `path`: another `store unbrowse` may have closed it, whatever it +/// left there. +pub fn is_open(store: &Store, path: &Path) -> Result { + Ok(path.exists() && recorded(store)?.iter().any(|v| v.path == path)) +} + +/// Remove the view at `path`. Anything else is refused: this deletes. +pub fn close(store: &Store, path: &Path) -> Result { + let path = fsutil::absolute(path); + if !is_open(store, &path)? { + return Err(Error::NotFound(format!("a view at {}", path.display()))); + } + remove(store, path) +} + +/// Delete the view at `path` and its entry. +fn remove(store: &Store, path: PathBuf) -> Result { + let left = remove_links(&path)?; + update(store, |views| views.retain(|v| v.path != path))?; + Ok(Closed { path, left }) +} + +/// Delete the links and folders under `dir`, and `dir` if that empties it. A hard link looks like +/// any file, and may be the last of its stored file by now, but a view's are read-only, and it +/// holds nothing else unless someone made its folders writable to put something there. That +/// stays. Returns how many such files there are. +fn remove_links(dir: &Path) -> Result { + set_folder(dir, true).at(dir)?; + let mut left = 0; + for e in fs::read_dir(dir).at(dir)? { + let e = e.at(dir)?; + let path = e.path(); + let kind = e.file_type().at(&path)?; + if kind.is_dir() { + left += remove_links(&path)?; + } else if kind.is_symlink() || e.metadata().at(&path)?.permissions().readonly() { + fs::remove_file(&path).at(&path)?; + } else { + left += 1; + } + } + if left == 0 { + fs::remove_dir(dir).at(dir)?; + } + Ok(left) +} + +/// What to warn of before the snapshots in `removed` go: of a view showing one, symlinks to files +/// nothing else uses break, and hard links keep those files' space from being freed. +pub(crate) fn breaking(store: &Store, removed: &FxHashSet) -> Result> { + let open = list(store)?.into_iter().filter(|v| removed.contains(&v.view.snapshot)); + let warning = |v: OpenView| { + let outcome = match v.symlinks { + 0 => "the space its files take stays in use until the view is removed", + _ => "links to files nothing else uses will break", + }; + format!("the view at {} shows a snapshot this removes: {outcome}", v.view.path.display()) + }; + Ok(open.map(warning).collect()) +} + +fn views_file(store: &Store) -> PathBuf { + store.root().join("views.json") +} + +fn recorded(store: &Store) -> Result> { + Ok(fsutil::read_json_opt(&views_file(store))?.unwrap_or_default()) +} + +/// Change the list of views under a lock of its own (views live longer than the store's lock +/// should be held), dropping those whose folder has gone. +fn update(store: &Store, change: impl FnOnce(&mut Vec)) -> Result<()> { + let _lock = fsutil::FileLock::wait(&store.root().join("views.lock"))?; + let mut views = recorded(store)?; + views.retain(|v| v.path.exists()); + change(&mut views); + fsutil::write_json_atomic(&views_file(store), &views) +} diff --git a/crates/locallibrary/tests/common/mod.rs b/crates/locallibrary/tests/common/mod.rs index a9e51d7db..1d9126593 100644 --- a/crates/locallibrary/tests/common/mod.rs +++ b/crates/locallibrary/tests/common/mod.rs @@ -64,6 +64,7 @@ pub fn rot(path: &Path) { let mut bytes = fs::read(path).unwrap(); let mid = bytes.len() / 2; bytes[mid] ^= 0xFF; + fs::remove_file(path).unwrap(); fs::write(path, bytes).unwrap(); filetime::set_file_mtime(path, mtime).unwrap(); } diff --git a/crates/locallibrary/tests/it/main.rs b/crates/locallibrary/tests/it/main.rs index 8ca27bec0..387723a10 100644 --- a/crates/locallibrary/tests/it/main.rs +++ b/crates/locallibrary/tests/it/main.rs @@ -182,6 +182,8 @@ mod props_undo; mod props_untested; mod props_upgrade; mod props_upgrade_lead; +#[cfg(unix)] +mod props_view; mod props_watch; mod props_xml; mod publish; diff --git a/crates/locallibrary/tests/it/peers.rs b/crates/locallibrary/tests/it/peers.rs index d47423cd0..24c02c14a 100644 --- a/crates/locallibrary/tests/it/peers.rs +++ b/crates/locallibrary/tests/it/peers.rs @@ -163,6 +163,7 @@ fn a_damaged_file_is_refused_and_nothing_is_recorded() { let object = laptop.object_path(&file.hash.unwrap()); let mut bytes = std::fs::read(&object).unwrap(); bytes[0] ^= 0xff; + std::fs::remove_file(&object).unwrap(); std::fs::write(&object, bytes).unwrap(); let serving = serve(&laptop); diff --git a/crates/locallibrary/tests/it/props_offsite.rs b/crates/locallibrary/tests/it/props_offsite.rs index f5bb5a72c..3adb7ea92 100644 --- a/crates/locallibrary/tests/it/props_offsite.rs +++ b/crates/locallibrary/tests/it/props_offsite.rs @@ -174,6 +174,7 @@ fn a_copy_holds_the_store_and_catches_up_incrementally(tc: TestCase) { // A file damaged in the store isn't copied: its snapshots are left out, the rest go. let (h, _) = *objects(&store).iter().next().unwrap(); let fresh = dir.path().join("Disk"); + fs::remove_file(store.object_path(&h)).unwrap(); fs::write(store.object_path(&h), b"rotten").unwrap(); let r = offsite::copy(&store, &fresh, &CopyOptions::default(), &Ctx::silent()).unwrap(); assert!(!r.complete && !r.problems.is_empty()); diff --git a/crates/locallibrary/tests/it/props_peers.rs b/crates/locallibrary/tests/it/props_peers.rs index f8162e158..7802652ff 100644 --- a/crates/locallibrary/tests/it/props_peers.rs +++ b/crates/locallibrary/tests/it/props_peers.rs @@ -260,6 +260,7 @@ fn what_isnt_right_is_refused_and_records_nothing(tc: TestCase) { let mut bytes = std::fs::read(&object).unwrap(); let at = tc.draw(gs::integers::().max_value(bytes.len() - 1)); bytes[at] ^= 0xff; + std::fs::remove_file(&object).unwrap(); std::fs::write(&object, bytes).unwrap(); } } diff --git a/crates/locallibrary/tests/it/props_store.rs b/crates/locallibrary/tests/it/props_store.rs index 68e6dc447..325636303 100644 --- a/crates/locallibrary/tests/it/props_store.rs +++ b/crates/locallibrary/tests/it/props_store.rs @@ -256,6 +256,7 @@ impl StoreMachine { let Some(h) = Self::pick(&tc, &self.on_disk()) else { return }; let mut bytes = self.objects[&h].clone(); bytes.push(b'!'); + fs::remove_file(self.store.object_path(&h)).unwrap(); fs::write(self.store.object_path(&h), bytes).unwrap(); self.tampered.insert(h); } @@ -601,6 +602,7 @@ fn verify_reports_exactly_the_damaged_objects(tc: TestCase) { Some(b) => *b ^= 1 << tc.draw(gs::integers::().max_value(7)), None => changed.push(0), } + fs::remove_file(store.object_path(h)).unwrap(); fs::write(store.object_path(h), changed).unwrap(); corrupt.insert(*h); } diff --git a/crates/locallibrary/tests/it/props_view.rs b/crates/locallibrary/tests/it/props_view.rs new file mode 100644 index 000000000..d5fc710d9 --- /dev/null +++ b/crates/locallibrary/tests/it/props_view.rs @@ -0,0 +1,382 @@ +//! `store browse`'s views, on real folders from drawn snapshots: a view is the snapshot's tree, no +//! write through it reaches a stored file, and closing it leaves nothing behind. + +use crate::common; + +use indexmap::IndexMap; +use std::fs; +use std::io::ErrorKind; +use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use hegel::generators as gs; +use hegel::{HealthCheck, TestCase}; +use locallibrary::Hash; +use locallibrary::device::Device; +use locallibrary::model::LibrarySummary; +use locallibrary::paths::FileClass; +use locallibrary::progress::Ctx; +use locallibrary::retention::{self, PruneOptions}; +use locallibrary::store::{Snapshot, Store}; +use locallibrary::sync::{self, PullOptions}; +use locallibrary::tree::FileEntry; +use locallibrary::view; +use tempfile::TempDir; +use uuid::Uuid; + +/// Paths a snapshot may list, and whether a view shows them when listed in this order: the second +/// `one.mp3` is the first on a drive that ignores case, and the rest leave the view or go where +/// locallibrary keeps its own files. +const KEYS: [(&str, bool); 7] = [ + ("Contents/Artist/one.mp3", true), + ("Contents/Artist/ONE.mp3", false), + ("Contents/Artist/two.flac", true), + ("PIONEER/rekordbox/export.pdb", true), + ("../outside.mp3", false), + ("Contents/../../outside.mp3", false), + (".locallibrary/state.json", false), +]; + +fn fixture() -> (TempDir, Store) { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open_or_init(dir.path().join("store")).unwrap(); + (dir, store) +} + +/// A snapshot of `library` listing `files`, kept in the store. +fn snapshot(store: &Store, library: Uuid, files: &[(String, Vec)]) -> Snapshot { + let files = files.iter().map(|(path, bytes)| FileEntry { + path: path.clone(), + size: bytes.len() as u64, + mtime: 0, + class: FileClass::Audio, + hash: Some(store.import_bytes(bytes).unwrap()), + }); + let snapshot = Snapshot { + id: Hash::of(b"ignored"), + library_id: library, + parents: Vec::new(), + created: Utc::now(), + message: None, + source: None, + fingerprint: Hash::of(format!("{library}{files:?}").as_bytes()), + summary: LibrarySummary::default(), + lineage: Vec::new(), + files: files.collect(), + }; + store.save_snapshot(snapshot).unwrap() +} + +/// Every file under `dir`, read through its link. +fn tree(dir: &Path) -> IndexMap> { + let files = walkdir::WalkDir::new(dir).into_iter().map(Result::unwrap).filter(|e| !e.file_type().is_dir()); + files + .map(|e| { + let key = e.path().strip_prefix(dir).unwrap().to_str().unwrap().to_string(); + (key, fs::read(e.path()).unwrap()) + }) + .collect() +} + +fn hash_of(store: &Store, h: &Hash) -> Hash { + locallibrary::hash::hash_file(&store.object_path(h), &Ctx::silent(), locallibrary::progress::Phase::Verify).unwrap() +} + +fn mode(path: &Path) -> u32 { + fs::metadata(path).unwrap().permissions().mode() & 0o777 +} + +fn make_writable(path: &Path) { + fs::set_permissions(path, fs::Permissions::from_mode(0o644)).unwrap(); +} + +/// What an older version left: objects anyone with the right could write. +fn as_an_older_version_left(store: &Store, hashes: impl IntoIterator) { + hashes.into_iter().for_each(|h| make_writable(&store.object_path(&h))); +} + +/// In-place writes of every kind fail, and so does anything that would change the folder. +fn assert_no_write_gets_through(store: &Store, snapshot: &Snapshot, view: &Path, shown: &IndexMap>) { + let open = |link: &Path, how: &mut fs::OpenOptions| how.open(link).unwrap_err().kind(); + let scratch = store.root().with_file_name("scratch"); + fs::write(&scratch, b"edited").unwrap(); + for key in shown.keys() { + let link = view.join(key); + let entry = snapshot.files.iter().find(|f| &f.path == key).unwrap(); + let h = entry.hash.unwrap(); + assert_eq!(open(&link, fs::File::options().write(true)), ErrorKind::PermissionDenied, "{key}"); + assert_eq!(open(&link, fs::File::options().append(true)), ErrorKind::PermissionDenied, "{key}"); + assert_eq!(open(&link, fs::File::options().write(true).truncate(true)), ErrorKind::PermissionDenied, "{key}"); + assert_eq!(fs::write(&link, b"edited").unwrap_err().kind(), ErrorKind::PermissionDenied, "{key}"); + // Saving through a temp file and a rename fails too, rather than leaving an edit there. + let beside = link.with_extension("tmp"); + assert_eq!(fs::write(&beside, b"edited").unwrap_err().kind(), ErrorKind::PermissionDenied, "{key}"); + assert_eq!(fs::rename(&scratch, &link).unwrap_err().kind(), ErrorKind::PermissionDenied, "{key}"); + assert_eq!(fs::remove_file(&link).unwrap_err().kind(), ErrorKind::PermissionDenied, "{key}"); + assert_eq!(hash_of(store, &h), h, "{key}"); + } +} + +/// A view shows exactly what the snapshot lists (safe paths, once each), every file a link to an +/// object nothing can write, older objects included, while objects it doesn't show stay as they +/// were. Closing it removes every trace and changes no object. +#[hegel::test(test_cases = 30, suppress_health_check = [HealthCheck::TooSlow])] +fn a_view_is_the_snapshot_and_nothing_writes_through_it(tc: TestCase) { + let (dir, store) = fixture(); + let keys: Vec<(&str, bool)> = tc.draw(gs::subsequences(KEYS.to_vec())); + let files: Vec<(String, Vec)> = keys + .iter() + .map(|(key, _)| { + let bytes: Vec = tc.draw(gs::vecs(gs::integers::().max_value(2)).max_size(4)); + (key.to_string(), bytes) + }) + .collect(); + let snap = snapshot(&store, Uuid::new_v4(), &files); + let unshown = store.import_bytes(b"in no snapshot").unwrap(); + let older: bool = tc.draw(gs::booleans()); + if older { + as_an_older_version_left(&store, snap.files.iter().filter_map(|f| f.hash).chain([unshown])); + } + let to = tc.draw(gs::booleans()).then(|| dir.path().join("chosen")); + if to.is_some() && tc.draw(gs::booleans()) { + fs::create_dir(to.as_ref().unwrap()).unwrap(); + } + + let opened = view::open(&store, &snap.id, to.as_deref()).unwrap(); + + let path = opened.view.path.clone(); + let one = keys.iter().any(|(key, _)| key.ends_with("one.mp3")); + let shown = |key: &str, safe: bool| safe || (key.ends_with("ONE.mp3") && !one); + let want: IndexMap> = + keys.iter().zip(&files).filter(|((key, safe), _)| shown(key, *safe)).map(|(_, f)| f.clone()).collect(); + assert_eq!(tree(&path), want); + assert_eq!(opened.files, want.len()); + assert_eq!(opened.bytes, want.values().map(|b| b.len() as u64).sum::()); + assert_eq!(opened.skipped.len(), files.len() - want.len(), "{:?}", opened.skipped); + assert_eq!(opened.missing, Vec::::new()); + assert_eq!(opened.view.snapshot, snap.id); + match &to { + Some(to) => assert_eq!(&path, to), + None => assert!(path.starts_with(std::env::temp_dir()), "{}", path.display()), + } + assert_eq!(opened.symlinks, 0, "the view is on the store's volume"); + for key in want.keys() { + let entry = snap.files.iter().find(|f| &f.path == key).unwrap(); + let object = store.object_path(&entry.hash.unwrap()); + let link = fs::symlink_metadata(path.join(key)).unwrap(); + assert!(link.file_type().is_file(), "{key}"); + assert_eq!(link.ino(), fs::metadata(&object).unwrap().ino(), "{key}"); + assert_eq!(link.mode() & 0o222, 0, "{key}"); + } + assert_eq!(mode(&store.object_path(&unshown)) & 0o200 != 0, older, "an object the view doesn't show is left alone"); + assert_no_write_gets_through(&store, &snap, &path, &want); + let listed = view::list(&store).unwrap(); + assert_eq!(listed.iter().map(|v| (&v.view.path, v.symlinks, v.broken)).collect::>(), [(&path, 0, 0)]); + + let closed = view::close(&store, &path).unwrap(); + assert_eq!((closed.path, closed.left), (path.clone(), 0)); + assert!(fs::symlink_metadata(&path).is_err(), "{}", path.display()); + assert!(view::list(&store).unwrap().is_empty()); + assert_eq!(store.verify(&Ctx::silent()).unwrap().corrupt, Vec::::new()); +} + +/// New objects are read-only however they came, also one replacing an object a crash cut short, +/// and what's pulled from them onto a drive isn't: only the store's files are. +#[hegel::test(test_cases = 10, suppress_health_check = [HealthCheck::TooSlow])] +fn objects_are_read_only_from_the_start(tc: TestCase) { + let (dir, store) = fixture(); + let bytes: Vec = tc.draw(gs::binary().min_size(2).max_size(16)); + let src = dir.path().join("track.mp3"); + fs::write(&src, &bytes).unwrap(); + let by_file: bool = tc.draw(gs::booleans()); + let import = || match by_file { + true => store.import_file(&src, None, &Ctx::silent()).unwrap().0, + false => store.import_bytes(&bytes).unwrap(), + }; + let h = import(); + let object = store.object_path(&h); + assert_eq!(mode(&object) & 0o222, 0, "{:o}", mode(&object)); + assert_eq!(fs::write(&object, b"x").unwrap_err().kind(), ErrorKind::PermissionDenied); + + fs::remove_file(&object).unwrap(); + fs::write(&object, &bytes[..1]).unwrap(); + assert_eq!(import(), h); + assert_eq!(fs::read(&object).unwrap(), bytes); + assert_eq!(mode(&object) & 0o222, 0, "{:o}", mode(&object)); + + let copied = dir.path().join("drive").join("track.mp3"); + locallibrary::fsutil::copy_file(&object, &copied, None, Some(h), false, &Ctx::silent()).unwrap(); + assert_ne!(mode(&copied) & 0o200, 0, "{:o}", mode(&copied)); +} + +/// Pruning a view's snapshot is warned of and doesn't stop it. Hard links keep their files, whose +/// space stays in use. A symlink (here, to a file lost when the view was made and brought back) +/// breaks, and works again, read-only, if its file comes back. Closing removes all of them. +#[hegel::test(test_cases = 6, suppress_health_check = [HealthCheck::TooSlow])] +fn pruning_a_views_snapshot_says_so_and_goes_ahead(tc: TestCase) { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open_or_init(dir.path().join("store")).unwrap(); + let root = common::bigger_drive(dir.path(), "Main"); + Device::open_export(&root).unwrap().adopt(Some("Main")).unwrap(); + let pull = || { + sync::pull(&store, &mut Device::open_export(&root).unwrap(), &PullOptions::default(), &Ctx::silent()).unwrap(); + store.load_snapshot(&store.libraries().unwrap()[0].head.unwrap()).unwrap() + }; + let old = pull(); + common::export_new_track(&root, "New", "Later"); + let latest = pull(); + let kept: Vec> = latest.files.iter().map(|f| f.hash).collect(); + let (shared, only_here): (Vec<&FileEntry>, Vec<&FileEntry>) = + old.files.iter().partition(|f| kept.contains(&f.hash)); + assert!(!shared.is_empty() && !only_here.is_empty()); + let first = store.object_path(&only_here[0].hash.unwrap()); + let bytes = fs::read(&first).unwrap(); + let symlinked: bool = tc.draw(gs::booleans()); + if symlinked { + fs::remove_file(&first).unwrap(); + } + let path = view::open(&store, &old.id, None).unwrap().view.path; + if symlinked { + store.import_bytes(&bytes).unwrap(); + } + let linked = path.join(&only_here[0].path); + assert_eq!(fs::read(&linked).unwrap(), bytes); + + let options = PruneOptions { keep_last: Some(1), ..Default::default() }; + let plan = retention::plan(&store, &options, &Ctx::silent()).unwrap(); + assert_eq!(plan.removed_snapshots, 1); + let outcome = match symlinked { + true => "links to files nothing else uses will break", + false => "the space its files take stays in use until the view is removed", + }; + assert_eq!(plan.warnings, [format!("the view at {} shows a snapshot this removes: {outcome}", path.display())]); + retention::apply(&store, &plan).unwrap(); + + assert!(fs::symlink_metadata(&first).is_err(), "the store let it go"); + assert_eq!(view::list(&store).unwrap()[0].broken, usize::from(symlinked)); + for f in shared.iter().chain(&only_here[1..]) { + assert_eq!(fs::metadata(path.join(&f.path)).unwrap().len(), f.size, "{}", f.path); + } + match symlinked { + true => { + assert_eq!(fs::read(&linked).unwrap_err().kind(), ErrorKind::NotFound); + if tc.draw(gs::booleans()) { + store.import_bytes(&bytes).unwrap(); + assert_eq!(fs::read(&linked).unwrap(), bytes); + } + } + false => assert_eq!(fs::read(&linked).unwrap(), bytes), + } + if fs::metadata(&linked).is_ok() { + assert_eq!(fs::write(&linked, b"edited").unwrap_err().kind(), ErrorKind::PermissionDenied); + } + assert_eq!(view::close(&store, &path).unwrap().left, 0); + assert!(fs::symlink_metadata(&path).is_err()); +} + +/// A file the store has lost is shown as a broken symlink, and said: the rest of the drive is there +/// to see. +#[hegel::test(test_cases = 6, suppress_health_check = [HealthCheck::TooSlow])] +fn a_file_the_store_lost_is_said_and_its_link_broken(tc: TestCase) { + let (_dir, store) = fixture(); + let files: Vec<(String, Vec)> = (0..3).map(|i| (format!("Contents/{i}.mp3"), vec![i])).collect(); + let snap = snapshot(&store, Uuid::new_v4(), &files); + let lost: Vec = tc.draw(gs::subsequences(vec![0, 1, 2])); + for i in &lost { + fs::remove_file(store.object_path(&snap.files[*i].hash.unwrap())).unwrap(); + } + let opened = view::open(&store, &snap.id, None).unwrap(); + let said: Vec = lost.iter().map(|i| format!("Contents/{i}.mp3")).collect(); + assert_eq!(opened.missing, said); + assert_eq!((opened.files, opened.symlinks), (3, lost.len())); + assert_eq!(view::list(&store).unwrap()[0].broken, lost.len()); + for i in &lost { + let target = fs::read_link(opened.view.path.join(format!("Contents/{i}.mp3"))).unwrap(); + assert!(target.is_absolute() && target.starts_with(store.root().join("objects")), "{}", target.display()); + } + assert_eq!(view::close(&store, &opened.view.path).unwrap().left, 0); +} + +/// A folder holding anything is refused and left as it was, as is a path that can't be a folder, a +/// snapshot that isn't there makes nothing, and a failure part way removes what it made. +#[hegel::test(test_cases = 6, suppress_health_check = [HealthCheck::TooSlow])] +fn a_view_that_cant_be_made_leaves_nothing(tc: TestCase) { + let (dir, store) = fixture(); + let files = [("Contents/a.mp3".to_string(), b"a".to_vec())]; + let snap = snapshot(&store, Uuid::new_v4(), &files); + + let taken = dir.path().join("taken"); + fs::create_dir(&taken).unwrap(); + fs::write(taken.join("mine.txt"), b"mine").unwrap(); + let e = view::open(&store, &snap.id, Some(&taken)).unwrap_err(); + assert_eq!(e.to_string(), format!("{} isn't empty; choose another folder", taken.display())); + assert_eq!(fs::read(taken.join("mine.txt")).unwrap(), b"mine"); + + let a_file = taken.join("mine.txt"); + view::open(&store, &snap.id, Some(&a_file)).unwrap_err(); + assert_eq!(fs::read(&a_file).unwrap(), b"mine"); + + let unknown = Hash::of(b"no such snapshot"); + let to = dir.path().join("for-nothing"); + assert!(view::open(&store, &unknown, Some(&to)).unwrap_err().to_string().contains("snapshot")); + assert!(!to.exists()); + + // A file and a folder of one name: the links before it are made by then. + let folder: bool = tc.draw(gs::booleans()); + let mut clash: Vec<(String, Vec)> = vec![("Contents/x".to_string(), b"file".to_vec())]; + clash.insert(usize::from(folder), ("Contents/x/y.mp3".to_string(), b"inside".to_vec())); + let clash = snapshot(&store, Uuid::new_v4(), &clash); + let to = dir.path().join("clash"); + view::open(&store, &clash.id, Some(&to)).unwrap_err(); + assert!(!to.exists()); + assert!(view::list(&store).unwrap().is_empty()); +} + +/// Only a recorded view can be closed: this deletes. What someone put in a view is theirs, and +/// stays, as does the stored file a hard link shared; one deleted by hand drops off the list. +#[hegel::test(test_cases = 6, suppress_health_check = [HealthCheck::TooSlow])] +fn closing_deletes_only_a_views_own_links(tc: TestCase) { + let (dir, store) = fixture(); + let files = [("Contents/Artist/a.mp3".to_string(), b"a".to_vec())]; + let snap = snapshot(&store, Uuid::new_v4(), &files); + + let elsewhere: PathBuf = dir.path().join("elsewhere"); + fs::create_dir(&elsewhere).unwrap(); + let e = view::close(&store, &elsewhere).unwrap_err(); + assert_eq!(e.to_string(), format!("not found: a view at {}", elsewhere.display())); + assert!(elsewhere.is_dir()); + + let to = dir.path().join("view"); + let path = view::open(&store, &snap.id, Some(&to)).unwrap().view.path; + assert_eq!(mode(&path), 0o500); + if tc.draw(gs::booleans()) { + for e in walkdir::WalkDir::new(&path).into_iter().map(Result::unwrap).filter(|e| e.file_type().is_dir()) { + make_folder_writable(e.path()); + } + fs::remove_dir_all(&path).unwrap(); + assert!(view::list(&store).unwrap().is_empty()); + let again = view::open(&store, &snap.id, None).unwrap().view.path; + assert_eq!(view::list(&store).unwrap().len(), 1, "the one deleted by hand is gone from the list"); + assert_eq!(view::close(&store, &again).unwrap().left, 0); + // A folder made where it was isn't that view, and closing deletes. + fs::create_dir(&path).unwrap(); + assert!(view::list(&store).unwrap().is_empty()); + assert!(view::close(&store, &path).is_err()); + assert!(path.is_dir()); + return; + } + let folder = path.join("Contents/Artist"); + make_folder_writable(&folder); + fs::write(folder.join("mine.txt"), b"mine").unwrap(); + let closed = view::close(&store, &path).unwrap(); + assert_eq!(closed.left, 1); + assert_eq!(fs::read(folder.join("mine.txt")).unwrap(), b"mine"); + assert!(fs::symlink_metadata(folder.join("a.mp3")).is_err()); + assert!(view::list(&store).unwrap().is_empty(), "it's no longer a view"); + assert_eq!(fs::read(store.object_path(&snap.files[0].hash.unwrap())).unwrap(), b"a"); +} + +fn make_folder_writable(path: &Path) { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); +} diff --git a/crates/locallibrary/tests/it/store/copy.rs b/crates/locallibrary/tests/it/store/copy.rs index 2dd22c5bc..1fb59256f 100644 --- a/crates/locallibrary/tests/it/store/copy.rs +++ b/crates/locallibrary/tests/it/store/copy.rs @@ -270,6 +270,7 @@ fn a_damaged_file_leaves_its_snapshot_out() { common::export_new_track(&a, "New", "Track"); let second = pull(&store, &a); let pdb = store.load_snapshot(&second).unwrap().files.into_iter().find(|f| f.path == paths::EXPORT_PDB).unwrap(); + std::fs::remove_file(store.object_path(&pdb.hash.unwrap())).unwrap(); std::fs::write(store.object_path(&pdb.hash.unwrap()), b"damaged").unwrap(); let r = copy(&store, &dest, &CopyOptions { mirror: true, ..Default::default() }).unwrap(); diff --git a/crates/loclib-plugin/examples/echo/plugin.rs b/crates/loclib-plugin/examples/echo/plugin.rs index 73fd33f9b..4aa342307 100644 --- a/crates/loclib-plugin/examples/echo/plugin.rs +++ b/crates/loclib-plugin/examples/echo/plugin.rs @@ -152,10 +152,12 @@ fn io(e: std::io::Error) -> RpcError { RpcError::failed("io", e.to_string()) } -/// Copies `from` to `to`, making `to`'s folder first. +/// Copies `from` to `to`, making `to`'s folder first. Bytes, not `fs::copy`, which would carry over +/// a stored file's read-only permissions and refuse the next write to `to`. fn copy(from: &Path, to: &Path) -> Result<(), RpcError> { std::fs::create_dir_all(to.parent().unwrap_or(Path::new("."))).map_err(io)?; - std::fs::copy(from, to).map(drop).map_err(io) + let mut from = std::fs::File::open(from).map_err(io)?; + std::io::copy(&mut from, &mut std::fs::File::create(to).map_err(io)?).map(drop).map_err(io) } /// The request's scratch folder.