authorclover caruso <git@paperclover.net> 2026-10-15 05:10:09-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 06:30:07-07:00
logee54ecdfad5603988e09516439764799c94202e3
tree56fd8dd70ec818c9fd3dd5cc9acffdca5d1a5aab
parentc830e2971fbbcfeb96cfb5286d43d88e0edff1d5
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix(notebook): make section replicas where hard links are forbidden

A new replica was built under a name of its own and hard-linked into place, which Android refuses apps, so no section could open there. It now moves into place with a rename that refuses to replace a file, as tempfile persists without clobbering. Refs #24 Assisted-by: claude-opus-5.5

3 files changed, 24 insertions(+), 10 deletions(-)

crates/notebook/src/fs.rs+15
......@@ -6,6 +6,21 @@ pub use onestore::{confirm_file, place_file, read_file, read_file_limited, super
66#[cfg(not(target_arch = "wasm32"))]
77pub use std::{fs::*, path::absolute, process::id as process_id};
88
9/// Moves the file `from` to `to`, refusing to replace a file there. Android forbids apps hard
10/// links, the usual way to do this.
11#[cfg(not(target_arch = "wasm32"))]
12pub fn rename_new(
13 from: impl AsRef<std::path::Path>,
14 to: impl AsRef<std::path::Path>,
15) -> std::io::Result<()> {
16 tempfile::TempPath::try_from_path(from.as_ref())?
17 .persist_noclobber(to)
18 .map_err(|error| {
19 let _ = error.path.keep();
20 error.error
21 })
22}
23
924/// `Transaction::commit_file` through this file system.
1025#[cfg(not(target_arch = "wasm32"))]
1126pub fn commit_file(
crates/notebook/src/fs/web.rs+6-7
......@@ -414,14 +414,13 @@ pub fn copy(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<u64> {
414414 Ok(bytes.len() as u64)
415415}
416416
417/// A copy, as nothing here writes a file under two names at once: a link is made to put a
418/// finished file in place, then the first name removed.
419pub fn hard_link(original: impl AsRef<Path>, link: impl AsRef<Path>) -> io::Result<()> {
420 let bytes = read(original)?;
421 let link = normal(link.as_ref());
422 let data = with(|files| files.create(link))?;
417/// Moves the file `from` to `to`, refusing to replace a file there.
418pub fn rename_new(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
419 let bytes = read(&from)?;
420 let to = normal(to.as_ref());
421 let data = with(|files| files.create(to))?;
423422 data.borrow_mut().bytes = bytes;
424 Ok(())
423 remove_file(from)
425424}
426425
427426pub fn create_dir(path: impl AsRef<Path>) -> io::Result<()> {
crates/notebook/src/lib.rs+3-3
......@@ -171,7 +171,7 @@ impl Replica {
171171 /// `create` without opening the cache it made, as for an offline copy.
172172 pub(crate) fn seed(path: &Path, source: &[u8], key: Option<&Key>) -> Result<()> {
173173 validate(source, key)?;
174 // Built under a name of its own and linked into place whole, so that a cache that
174 // Built under a name of its own and moved into place whole, so that a cache that
175175 // exists is complete, and two threads making the same one never share a build.
176176 static BUILDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
177177 let build = BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
......@@ -179,13 +179,13 @@ impl Replica {
179179 building.push(format!(".creating-{}-{build}", fs::process_id()));
180180 let building = PathBuf::from(building);
181181 let built = Self::build(&building, source);
182 let linked = built.and_then(|()| Ok(fs::hard_link(&building, path)?));
182 let placed = built.and_then(|()| Ok(fs::rename_new(&building, path)?));
183183 for suffix in ["", "-wal", "-shm"] {
184184 let mut file = building.as_os_str().to_owned();
185185 file.push(suffix);
186186 let _ = fs::remove_file(file);
187187 }
188 linked
188 placed
189189 }
190190
191191 fn build(path: &Path, source: &[u8]) -> Result<()> {