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...@@ -6,6 +6,21 @@ pub use onestore::{confirm_file, place_file, read_file, read_file_limited, super
6#[cfg(not(target_arch = "wasm32"))]6#[cfg(not(target_arch = "wasm32"))]
7pub use std::{fs::*, path::absolute, process::id as process_id};7pub 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
9/// `Transaction::commit_file` through this file system.24/// `Transaction::commit_file` through this file system.
10#[cfg(not(target_arch = "wasm32"))]25#[cfg(not(target_arch = "wasm32"))]
11pub fn commit_file(26pub 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> {...@@ -414,14 +414,13 @@ pub fn copy(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<u64> {
414 Ok(bytes.len() as u64)414 Ok(bytes.len() as u64)
415}415}
416416
417/// A copy, as nothing here writes a file under two names at once: a link is made to put a417/// Moves the file `from` to `to`, refusing to replace a file there.
418/// finished file in place, then the first name removed.418pub fn rename_new(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
419pub fn hard_link(original: impl AsRef<Path>, link: impl AsRef<Path>) -> io::Result<()> {419 let bytes = read(&from)?;
420 let bytes = read(original)?;420 let to = normal(to.as_ref());
421 let link = normal(link.as_ref());421 let data = with(|files| files.create(to))?;
422 let data = with(|files| files.create(link))?;
423 data.borrow_mut().bytes = bytes;422 data.borrow_mut().bytes = bytes;
424 Ok(())423 remove_file(from)
425}424}
426425
427pub fn create_dir(path: impl AsRef<Path>) -> io::Result<()> {426pub fn create_dir(path: impl AsRef<Path>) -> io::Result<()> {
crates/notebook/src/lib.rs+3-3
...@@ -171,7 +171,7 @@ impl Replica {...@@ -171,7 +171,7 @@ impl Replica {
171 /// `create` without opening the cache it made, as for an offline copy.171 /// `create` without opening the cache it made, as for an offline copy.
172 pub(crate) fn seed(path: &Path, source: &[u8], key: Option<&Key>) -> Result<()> {172 pub(crate) fn seed(path: &Path, source: &[u8], key: Option<&Key>) -> Result<()> {
173 validate(source, key)?;173 validate(source, key)?;
174 // Built under a name of its own and linked into place whole, so that a cache that174 // Built under a name of its own and moved into place whole, so that a cache that
175 // exists is complete, and two threads making the same one never share a build.175 // exists is complete, and two threads making the same one never share a build.
176 static BUILDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);176 static BUILDS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
177 let build = BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);177 let build = BUILDS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
...@@ -179,13 +179,13 @@ impl Replica {...@@ -179,13 +179,13 @@ impl Replica {
179 building.push(format!(".creating-{}-{build}", fs::process_id()));179 building.push(format!(".creating-{}-{build}", fs::process_id()));
180 let building = PathBuf::from(building);180 let building = PathBuf::from(building);
181 let built = Self::build(&building, source);181 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)?));
183 for suffix in ["", "-wal", "-shm"] {183 for suffix in ["", "-wal", "-shm"] {
184 let mut file = building.as_os_str().to_owned();184 let mut file = building.as_os_str().to_owned();
185 file.push(suffix);185 file.push(suffix);
186 let _ = fs::remove_file(file);186 let _ = fs::remove_file(file);
187 }187 }
188 linked188 placed
189 }189 }
190190
191 fn build(path: &Path, source: &[u8]) -> Result<()> {191 fn build(path: &Path, source: &[u8]) -> Result<()> {