From aabbadfaf1e956a3daaa6d59d0a2213d4cf78ffe Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 7 Sep 2026 19:32:30 -0700 Subject: [PATCH] feat: retain verified external media in offline recovery caches Download declared external payloads without blocking local edits, then cache exact bytes and SHA-256 transactionally. Reject changed identities, stale references, and damaged cached data while preserving the edit queue. Include media in read-only recovery exports. Migrate existing live caches to schema 5 and retain original schema-4 archive compatibility. Verify actual legacy cache migration, concurrent download/edit behavior, abrupt process exit, live Samba downloads followed by offline reopen, full workspace regressions, and iOS device/simulator linking. Assisted-by: gpt-6-astra --- Cargo.lock | 2 + corpus/offline-v4/README.md | 15 + corpus/offline-v4/live.sqlite | Bin 0 -> 114688 bytes corpus/offline-v4/producer.rs | 33 ++ corpus/offline-v4/provenance.json | 20 ++ corpus/offline-v4/recovery.sqlite | Bin 0 -> 114688 bytes crates/onestore-offline/Cargo.toml | 4 +- crates/onestore-offline/README.md | 24 +- crates/onestore-offline/src/assets.rs | 134 +++++++ crates/onestore-offline/src/lib.rs | 7 +- crates/onestore-offline/src/recovery.rs | 21 +- crates/onestore-offline/src/schema.rs | 12 + crates/onestore-offline/tests/assets.rs | 449 ++++++++++++++++++++++++ crates/onestore-offline/tests/cache.rs | 2 +- crates/onestore-offline/tests/sync.rs | 6 +- 15 files changed, 722 insertions(+), 7 deletions(-) create mode 100644 corpus/offline-v4/README.md create mode 100644 corpus/offline-v4/live.sqlite create mode 100644 corpus/offline-v4/producer.rs create mode 100644 corpus/offline-v4/provenance.json create mode 100644 corpus/offline-v4/recovery.sqlite create mode 100644 crates/onestore-offline/src/assets.rs create mode 100644 crates/onestore-offline/tests/assets.rs diff --git a/Cargo.lock b/Cargo.lock index 7aac8ee77b097d29ed8c4008224c767104b79f2c..c5e413c84686efa193d7964110b4e1c634b479ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -537,10 +537,12 @@ name = "onestore-offline" version = "0.1.0" dependencies = [ "onestore", + "onestore-notebook", "onestore-smb", "rusqlite", "serde", "serde_json", + "sha2", "tempfile", "thiserror", ] diff --git a/corpus/offline-v4/README.md b/corpus/offline-v4/README.md new file mode 100644 index 0000000000000000000000000000000000000000..efdba39aca39aa6d6e002e1d3289860e16678fec --- /dev/null +++ b/corpus/offline-v4/README.md @@ -0,0 +1,15 @@ +# Original schema-4 offline state + +These databases were produced by `onestore-offline` at revision `2244711b`, +before the external-media table and schema-5 migration existed. Tests copy the +live database before opening it; the archive remains read-only. + +[`producer.rs`](producer.rs) records the original producer. Run it against that +revision's core/offline crates, from a checkout whose `corpus/offline-v4` does +not exist. It uses the public native external-asset fixture, queues a text edit, +simulates a publication with a lost response, queues a dependent text edit, and +exports the complete recovery archive. Generated revision IDs vary between runs. + +[`provenance.json`](provenance.json) records the original source/database hashes +and the retained uncertain and pending edit IDs. No credentials or personal +notebook content are included. diff --git a/corpus/offline-v4/live.sqlite b/corpus/offline-v4/live.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..130c341ba39987df0dfdc217633989160c2f23f6 GIT binary patch literal 114688 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 +impl Remote for LostReply { + fn read(&mut self) -> io::Result> { Ok(self.0.clone()) } + fn publish(&mut self, edit: &PreparedEdit<'_>) -> Result<(), CommitError> { + self.0 = edit.as_bytes().to_vec(); + Err(CommitError { state: CommitState::Unknown, error: io::ErrorKind::ConnectionReset.into() }) + } + fn confirm(&mut self, _: &[u8]) -> Result<(), CommitError> { panic!("No confirmation is expected") } +} +fn main() -> Result<(), Box> { + let root = Path::new("corpus/offline-v4"); fs::create_dir(root)?; + let source = fs::read("corpus/native-external-assets/notebook/synthetic.one")?; + let store = Store::parse(&source)?; let index = RevisionIndex::parse(&store)?; let document = Document::parse(&index)?; + let (space, object) = document.spaces.iter().find_map(|(sid, space)| { + let view = &space.revisions[&space.contexts[&ExGuid::default()]]; + view.nodes.iter().find_map(|(oid,node)| matches!(&node.kind,Kind::RichText { text, .. } if text == "Native before 🦀").then_some((*sid,*oid))) + }).unwrap(); + let cache = Replica::create(root.join("live.sqlite"), &source)?; + let first = cache.edit_text(&source, space, object, 0..0, "queued ")?.unwrap(); + assert!(cache.sync_once(&mut LostReply(source.clone())).is_err()); + let working = cache.snapshot()?; + let second = cache.edit_text(&working, space, object, 0..0, "dependent ")?.unwrap(); + cache.export_recovery(root.join("recovery.sqlite"))?; + let recovery = Recovery::open(root.join("recovery.sqlite"))?; + assert_eq!(cache.pending()?, recovery.pending()?); + assert_eq!(recovery.summary()?.queued_edits, 2); + assert_eq!(recovery.summary()?.uncertain_edits, 1); + println!("Original schema-4 producer: IDs {first}, {second}; {:?}", recovery.summary()?); + Ok(()) +} diff --git a/corpus/offline-v4/provenance.json b/corpus/offline-v4/provenance.json new file mode 100644 index 0000000000000000000000000000000000000000..a6c712b2debcba0482044d9da4e8a5ba8a33f585 --- /dev/null +++ b/corpus/offline-v4/provenance.json @@ -0,0 +1,20 @@ +{ + "producer_revision": "2244711b", + "producer": "corpus/offline-v4/producer.rs", + "source_sha256": { + "crates/onestore-offline/src/lib.rs": "f4b6bdc9b8269e43cc8c1d69f5becb3140905f520438da53fb96ce1663352f22", + "crates/onestore-offline/src/schema.rs": "b20d6c127b32dc378c54ef8179583782d37b79496c4056899d053881034b9963", + "crates/onestore-offline/src/sync.rs": "3967d304c42550350c7fe2659317d665b8c4d7cdfd42f3eb013180ca91177442", + "crates/onestore-offline/src/recovery.rs": "1301b649085670e847b4593bc308f9a1e25713a99b5333d4fd217014720d29c3" + }, + "files": { + "recovery.sqlite": "2946f7b2f2636b4dc80b2d3fd930dfe1d5bdf3cafd591d5c33d50be209685a76", + "live.sqlite": "ff0b2ad1141398c10913d3f573ba909aa7cc6e7b3fb809ecd3d9869b689ff970" + }, + "queued_ids": [ + 1, + 2 + ], + "uncertain_id": 1, + "archive_version": 4 +} diff --git a/corpus/offline-v4/recovery.sqlite b/corpus/offline-v4/recovery.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..67f06892d762d34917c2cf6b0520ea303c5c18e1 GIT binary patch literal 114688 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 Result>> { + let key = key(filename)?; + let connection = self + .connection + .lock() + .map_err(|_| io::Error::other("Cache owner panicked"))?; + cached(&connection, &key, limit) + } + + /// Fetches a declared external payload and durably retains it without changing the edit queue. + /// Different bytes for an already cached identity return `AssetChanged`, preserving the cache. + /// Network I/O does not hold the cache mutex; a stale reference fails before local publication. + pub fn fetch_asset( + &self, + source: &mut impl onestore_notebook::Source, + section: &str, + filename: &str, + limit: usize, + ) -> Result> { + let key = key(filename)?; + { + let connection = self + .connection + .lock() + .map_err(|_| io::Error::other("Cache owner panicked"))?; + if !referenced(&connection, &key)? { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "The retained document images do not reference this external payload", + ) + .into()); + } + } + let bytes = onestore_notebook::read_external_asset(source, section, filename, limit)?; + let mut connection = self + .connection + .lock() + .map_err(|_| io::Error::other("Cache owner panicked"))?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + if !referenced(&transaction, &key)? { + return Err(io::Error::new( + io::ErrorKind::ResourceBusy, + "The external payload reference changed during download", + ) + .into()); + } + let previous = match cached(&transaction, &key, bytes.len()) { + Err(Error::Io(error)) if error.kind() == io::ErrorKind::FileTooLarge => { + return Err(Error::AssetChanged); + } + other => other?, + }; + if let Some(previous) = previous { + if previous != bytes { + return Err(Error::AssetChanged); + } + } else { + transaction.execute( + "INSERT INTO assets(name,data,sha256) VALUES (?1,?2,?3)", + params![key, &bytes, &Sha256::digest(&bytes)[..]], + )?; + } + transaction.commit()?; + Ok(bytes) + } +} + +pub(crate) fn key(filename: &str) -> Result { + format!("{filename}").parse::()?; + Ok(filename.to_ascii_lowercase()) +} + +fn referenced(connection: &Connection, key: &str) -> Result { + for column in ["working", "base"] { + let image: Vec = connection.query_row( + &format!("SELECT {column} FROM replica WHERE id=1"), + [], + |row| row.get(0), + )?; + let store = Store::parse(&image)?; + let index = RevisionIndex::parse(&store)?; + let document = Document::parse(&index)?; + if document.spaces.values().flat_map(|space| space.revisions.values()) + .flat_map(|revision| revision.nodes.values()).any(|node| { + matches!(&node.kind, Kind::File { reference: FileDataReference::External(name), .. } if name.eq_ignore_ascii_case(key)) + }) { + return Ok(true); + } + } + Ok(false) +} + +pub(crate) fn cached(connection: &Connection, key: &str, limit: usize) -> Result>> { + let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; + if version < 5 { + return Ok(None); + } + let length: Option = connection + .query_row( + "SELECT length(data) FROM assets WHERE name=?1", + [key], + |row| row.get(0), + ) + .optional()?; + let Some(length) = length else { + return Ok(None); + }; + let length = + usize::try_from(length).map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?; + if length > limit { + return Err(io::Error::from(io::ErrorKind::FileTooLarge).into()); + } + let (bytes, expected): (Vec, Vec) = connection.query_row( + "SELECT data,sha256 FROM assets WHERE name=?1", + [key], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + if bytes.len() != length || Sha256::digest(&bytes)[..] != expected { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Cached external payload checksum mismatch", + ) + .into()); + } + Ok(Some(bytes)) +} diff --git a/crates/onestore-offline/src/lib.rs b/crates/onestore-offline/src/lib.rs index 5186c411f1053e8e2646ed2eab27c8549f613157..a21ba1afca74f36ba8932b5c338e82f725192808 100644 --- a/crates/onestore-offline/src/lib.rs +++ b/crates/onestore-offline/src/lib.rs @@ -8,6 +8,7 @@ use onestore::{ use rusqlite::{Connection, OpenFlags, TransactionBehavior, params}; use std::{fs::OpenOptions, io, ops::Range, path::Path, sync::Mutex, time::Duration}; +mod assets; mod formatting; mod rebase; mod recovery; @@ -35,12 +36,16 @@ pub enum Error { Remote(#[from] onestore::CommitError), #[error(transparent)] RemoteIo(io::Error), + #[error(transparent)] + Notebook(#[from] onestore_notebook::Error), + #[error("External payload identity now refers to different bytes")] + AssetChanged, } type Result = std::result::Result; const APPLICATION_ID: u32 = 0x4f4e454f; -const SCHEMA_VERSION: u32 = 4; +const SCHEMA_VERSION: u32 = 5; /// Text and its observed precondition, retained across cache reopen and rebasing. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] diff --git a/crates/onestore-offline/src/recovery.rs b/crates/onestore-offline/src/recovery.rs index 4530d2d1de1aeeebc49aebeb545d2192884af82e..5b87b1c733d53acaba966f24c4fe60ba5df548e7 100644 --- a/crates/onestore-offline/src/recovery.rs +++ b/crates/onestore-offline/src/recovery.rs @@ -13,6 +13,8 @@ pub struct RecoverySummary { pub published_receipts: u64, pub working_bytes: u64, pub remote_bytes: u64, + pub cached_assets: u64, + pub cached_asset_bytes: u64, } /// Read-only recovery evidence; it cannot publish or acknowledge an edit. @@ -29,7 +31,7 @@ impl Recovery { let application: u32 = connection.pragma_query_value(None, "application_id", |row| row.get(0))?; let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; - if application != RECOVERY_ID || version != SCHEMA_VERSION { + if application != RECOVERY_ID || !(4..=SCHEMA_VERSION).contains(&version) { return Err(io::Error::new( io::ErrorKind::InvalidData, "Unrecognized recovery archive or unsupported schema version", @@ -73,6 +75,11 @@ impl Recovery { pub fn receipts(&self) -> Result> { receipts(&self.connection) } + + /// Reads a previously downloaded external payload without accessing its former server. + pub fn cached_asset(&self, filename: &str, limit: usize) -> Result>> { + assets::cached(&self.connection, &assets::key(filename)?, limit) + } } impl Replica { @@ -131,6 +138,16 @@ impl Replica { } fn summary(connection: &Connection) -> Result { + let version: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; + let (cached_assets, cached_asset_bytes) = if version < 5 { + (0, 0) + } else { + connection.query_row( + "SELECT count(*),coalesce(sum(length(data)),0) FROM assets", + [], + |row| Ok((unsigned(row, 0)?, unsigned(row, 1)?)), + )? + }; Ok(connection.query_row( "SELECT (SELECT count(*) FROM edits), (SELECT count(*) FROM conflicts), (SELECT count(*) FROM attempt), (SELECT count(*) FROM receipts), @@ -144,6 +161,8 @@ fn summary(connection: &Connection) -> Result { published_receipts: unsigned(row, 3)?, working_bytes: unsigned(row, 4)?, remote_bytes: unsigned(row, 5)?, + cached_assets, + cached_asset_bytes, }) }, )?) diff --git a/crates/onestore-offline/src/schema.rs b/crates/onestore-offline/src/schema.rs index a65c19446f13f76bfac63adaed10061da7bfbed8..32b8f454dc8f969a687d9412dd5cd01f74238b56 100644 --- a/crates/onestore-offline/src/schema.rs +++ b/crates/onestore-offline/src/schema.rs @@ -6,6 +6,12 @@ const CONFLICTS: &str = "CREATE TABLE conflicts ( kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 3) ) STRICT;"; +const ASSETS: &str = "CREATE TABLE assets ( + name TEXT PRIMARY KEY NOT NULL, + data BLOB NOT NULL, + sha256 BLOB NOT NULL CHECK(length(sha256)=32) +) STRICT;"; + pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> { transaction.execute_batch( "CREATE TABLE edits ( @@ -24,16 +30,22 @@ pub(crate) fn create(transaction: &Transaction<'_>) -> Result<()> { ) STRICT;", )?; transaction.execute_batch(CONFLICTS)?; + transaction.execute_batch(ASSETS)?; Ok(()) } pub(crate) fn migrate(transaction: &Transaction<'_>, version: u32) -> Result<()> { + if version == 4 { + transaction.execute_batch(ASSETS)?; + return Ok(()); + } if version == 3 { transaction.execute_batch("ALTER TABLE conflicts RENAME TO old_conflicts;")?; transaction.execute_batch(CONFLICTS)?; transaction.execute_batch( "INSERT INTO conflicts SELECT * FROM old_conflicts; DROP TABLE old_conflicts;", )?; + transaction.execute_batch(ASSETS)?; return Ok(()); } diff --git a/crates/onestore-offline/tests/assets.rs b/crates/onestore-offline/tests/assets.rs new file mode 100644 index 0000000000000000000000000000000000000000..3186efe6def0171e62db0640966f925ca9ec0f11 --- /dev/null +++ b/crates/onestore-offline/tests/assets.rs @@ -0,0 +1,449 @@ +use onestore_offline::{EditStatus, Error, Operation, Recovery, Replica}; +use std::{ + fs, io, + path::Path, + sync::{Barrier, mpsc}, + time::Duration, +}; + +const FIXTURE: &str = "../../corpus/native-external-assets/notebook"; +const LEGACY: &str = "../../corpus/offline-v4/live.sqlite"; + +fn copied_cache(root: &Path) -> Replica { + let path = root.join("cache.sqlite"); + assert!(!path.exists()); + fs::copy(LEGACY, &path).unwrap(); + Replica::open(path).unwrap() +} + +fn payload(size: usize) -> (String, Vec) { + fs::read_dir(Path::new(FIXTURE).join("synthetic_onefiles")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find_map(|path| { + let bytes = fs::read(&path).unwrap(); + (bytes.len() == size) + .then(|| (path.file_name().unwrap().to_str().unwrap().into(), bytes)) + }) + .unwrap() +} + +struct Payload(Option>>); +impl onestore_notebook::Source for Payload { + fn entries(&mut self, _: &str, _: usize) -> io::Result> { + panic!("Unexpected enumeration") + } + fn read(&mut self, _: &str, _: usize) -> io::Result> { + self.0.take().expect("Unexpected repeated payload read") + } +} + +#[test] +fn downloaded_media_survives_migration_reopen_and_recovery_with_the_queue_intact() { + let directory = tempfile::tempdir().unwrap(); + let original = fs::read(LEGACY).unwrap(); + let cache = copied_cache(directory.path()); + let working = cache.snapshot().unwrap(); + let remote = cache.remote_snapshot().unwrap(); + let pending = cache.pending().unwrap(); + assert_eq!( + pending.iter().map(|edit| edit.id).collect::>(), + [1, 2] + ); + let uncertain = cache.status(1).unwrap(); + assert!(matches!( + uncertain, + Some(EditStatus::AwaitingConfirmation { .. }) + )); + let mut source = onestore_notebook::Local::open(FIXTURE).unwrap(); + for size in [0, 1024] { + let (name, bytes) = payload(size); + assert!(cache.cached_asset(&name, size).unwrap().is_none()); + assert_eq!( + cache + .fetch_asset(&mut source, "synthetic.one", &name, size) + .unwrap(), + bytes + ); + assert_eq!( + cache + .cached_asset(&name.to_ascii_lowercase(), size) + .unwrap(), + Some(bytes) + ); + } + let summary = cache.recovery_summary().unwrap(); + assert_eq!( + (summary.cached_assets, summary.cached_asset_bytes), + (2, 1024) + ); + assert_eq!(cache.snapshot().unwrap(), working); + assert_eq!(cache.remote_snapshot().unwrap(), remote); + assert_eq!(cache.pending().unwrap(), pending); + assert_eq!(cache.status(1).unwrap(), uncertain); + cache + .export_recovery(directory.path().join("recovery.sqlite")) + .unwrap(); + drop(cache); + let cache = Replica::open(directory.path().join("cache.sqlite")).unwrap(); + let recovery = Recovery::open(directory.path().join("recovery.sqlite")).unwrap(); + assert_eq!(recovery.summary().unwrap(), summary); + assert_eq!(recovery.pending().unwrap(), pending); + assert_eq!(recovery.status(1).unwrap(), uncertain); + assert!(recovery.receipts().unwrap().is_empty()); + for size in [0, 1024] { + let (name, bytes) = payload(size); + assert_eq!( + cache.cached_asset(&name, size).unwrap(), + Some(bytes.clone()) + ); + assert_eq!(recovery.cached_asset(&name, size).unwrap(), Some(bytes)); + } + assert_eq!(fs::read(LEGACY).unwrap(), original); +} + +#[test] +fn an_original_version_four_archive_remains_read_only_and_has_no_cached_media() { + let path = "../../corpus/offline-v4/recovery.sqlite"; + let before = fs::read(path).unwrap(); + let archive = Recovery::open(path).unwrap(); + assert_eq!(archive.pending().unwrap().len(), 2); + assert!(matches!( + archive.status(1).unwrap(), + Some(EditStatus::AwaitingConfirmation { .. }) + )); + assert_eq!(archive.summary().unwrap().cached_assets, 0); + assert_eq!(archive.summary().unwrap().cached_asset_bytes, 0); + assert!(archive.cached_asset(&payload(0).0, 0).unwrap().is_none()); + drop(archive); + assert_eq!(fs::read(path).unwrap(), before); +} + +#[test] +fn unsuccessful_refreshes_and_changed_identity_data_preserve_the_downloaded_payload() { + let directory = tempfile::tempdir().unwrap(); + let cache = copied_cache(directory.path()); + let (name, bytes) = payload(1024); + let mut source = Payload(Some(Ok(bytes.clone()))); + cache + .fetch_asset(&mut source, "synthetic.one", &name, 1024) + .unwrap(); + let before = fs::read(directory.path().join("cache.sqlite")).unwrap(); + let mut unavailable = Payload(Some(Err(io::ErrorKind::ConnectionReset.into()))); + assert!( + matches!(cache.fetch_asset(&mut unavailable, "synthetic.one", &name, 1024), + Err(Error::Notebook(onestore_notebook::Error::Io { error, .. })) if error.kind() == io::ErrorKind::ConnectionReset) + ); + for size in [0, 1024, 2048] { + let mut changed = Payload(Some(Ok(vec![9; size]))); + assert!(matches!( + cache.fetch_asset(&mut changed, "synthetic.one", &name, 2048), + Err(Error::AssetChanged) + )); + } + assert!( + matches!(cache.cached_asset(&name, 1023), Err(Error::Io(error)) if error.kind() == io::ErrorKind::FileTooLarge) + ); + assert_eq!(cache.cached_asset(&name, 1024).unwrap(), Some(bytes)); + assert_eq!( + fs::read(directory.path().join("cache.sqlite")).unwrap(), + before + ); +} + +#[test] +fn unreferenced_payloads_are_rejected_before_io_or_local_changes() { + let directory = tempfile::tempdir().unwrap(); + let cache = copied_cache(directory.path()); + let mut unused = Payload(None); + assert!( + matches!(cache.fetch_asset(&mut unused, "synthetic.one", "00000000-0000-0000-0000-000000000001.onebin", 100), + Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidInput) + ); + assert!( + cache + .fetch_asset(&mut unused, "synthetic.one", "../payload.onebin", 100) + .is_err() + ); + assert_eq!(cache.recovery_summary().unwrap().cached_assets, 0); +} + +#[test] +fn download_network_wait_does_not_block_local_edits() { + struct Waiting { + entered: mpsc::Sender<()>, + released: mpsc::Receiver<()>, + bytes: Vec, + } + impl onestore_notebook::Source for Waiting { + fn entries(&mut self, _: &str, _: usize) -> io::Result> { + panic!("Unexpected enumeration") + } + fn read(&mut self, _: &str, _: usize) -> io::Result> { + self.entered.send(()).unwrap(); + self.released.recv_timeout(Duration::from_secs(5)).unwrap(); + Ok(self.bytes.clone()) + } + } + let directory = tempfile::tempdir().unwrap(); + let cache = copied_cache(directory.path()); + let (name, bytes) = payload(1024); + let (entered, waiting) = mpsc::channel(); + let (release, released) = mpsc::channel(); + let mut source = Waiting { + entered, + released, + bytes: bytes.clone(), + }; + std::thread::scope(|scope| { + let download = scope.spawn(|| { + cache + .fetch_asset(&mut source, "synthetic.one", &name, 1024) + .unwrap() + }); + waiting.recv_timeout(Duration::from_secs(5)).unwrap(); + let pending = cache.pending().unwrap(); + let Operation::Text(edit) = &pending[0].operation else { + panic!() + }; + let id = cache + .edit_text( + &cache.snapshot().unwrap(), + pending[0].space, + edit.object, + 0..0, + "during download ", + ) + .unwrap() + .unwrap(); + release.send(()).unwrap(); + assert_eq!(download.join().unwrap(), bytes); + assert_eq!(cache.status(id).unwrap(), Some(EditStatus::Pending)); + }); +} + +#[test] +fn concurrent_downloads_publish_one_immutable_cache_entry() { + let directory = tempfile::tempdir().unwrap(); + let cache = copied_cache(directory.path()); + let (name, bytes) = payload(1024); + let ready = Barrier::new(8); + std::thread::scope(|scope| { + for _ in 0..8 { + scope.spawn(|| { + let mut source = onestore_notebook::Local::open(FIXTURE).unwrap(); + ready.wait(); + assert_eq!( + cache + .fetch_asset(&mut source, "synthetic.one", &name, 1024) + .unwrap(), + bytes + ); + }); + } + }); + assert_eq!(cache.recovery_summary().unwrap().cached_assets, 1); +} + +#[test] +fn a_native_refresh_removing_the_reference_rejects_an_inflight_download() { + struct Native; + impl onestore_offline::Remote for Native { + fn read(&mut self) -> io::Result> { + fs::read("../../corpus/native-external-assets/native/synthetic.one") + } + fn publish(&mut self, _: &onestore::PreparedEdit<'_>) -> Result<(), onestore::CommitError> { + panic!("Unexpected publication") + } + fn confirm(&mut self, _: &[u8]) -> Result<(), onestore::CommitError> { + panic!("Unexpected confirmation") + } + } + struct Refresh<'a>(&'a Replica); + impl onestore_notebook::Source for Refresh<'_> { + fn entries(&mut self, _: &str, _: usize) -> io::Result> { + panic!("Unexpected enumeration") + } + fn read(&mut self, _: &str, _: usize) -> io::Result> { + assert_eq!(self.0.sync_once(&mut Native).unwrap(), None); + Ok(payload(1024).1) + } + } + let root = tempfile::tempdir().unwrap(); + let source = fs::read(Path::new(FIXTURE).join("synthetic.one")).unwrap(); + let cache = Replica::create(root.path().join("cache.sqlite"), &source).unwrap(); + let (name, _) = payload(1024); + assert!( + matches!(cache.fetch_asset(&mut Refresh(&cache), "synthetic.one", &name, 1024), + Err(Error::Io(error)) if error.kind() == io::ErrorKind::ResourceBusy) + ); + assert!(cache.cached_asset(&name, 1024).unwrap().is_none()); + assert_eq!( + cache.snapshot().unwrap(), + fs::read("../../corpus/native-external-assets/native/synthetic.one").unwrap() + ); +} + +#[test] +fn local_failure_and_bad_cached_bytes_never_become_successful_downloads() { + let directory = tempfile::tempdir().unwrap(); + drop(copied_cache(directory.path())); + let path = directory.path().join("cache.sqlite"); + let connection = rusqlite::Connection::open(&path).unwrap(); + connection.execute_batch("CREATE TRIGGER fail_asset BEFORE INSERT ON assets BEGIN SELECT RAISE(ABORT,'Test asset failure'); END").unwrap(); + drop(connection); + let (name, bytes) = payload(1024); + let cache = Replica::open(&path).unwrap(); + let mut source = onestore_notebook::Local::open(FIXTURE).unwrap(); + assert!(matches!( + cache.fetch_asset(&mut source, "synthetic.one", &name, 1024), + Err(Error::Database(_)) + )); + assert!(cache.cached_asset(&name, 1024).unwrap().is_none()); + assert_eq!(cache.pending().unwrap().len(), 2); + drop(cache); + let connection = rusqlite::Connection::open(&path).unwrap(); + connection.execute_batch("DROP TRIGGER fail_asset").unwrap(); + drop(connection); + let cache = Replica::open(&path).unwrap(); + cache + .fetch_asset(&mut source, "synthetic.one", &name, 1024) + .unwrap(); + drop(cache); + let connection = rusqlite::Connection::open(&path).unwrap(); + let mut damaged = bytes; + damaged[0] ^= 1; + connection + .execute("UPDATE assets SET data=?1", [damaged]) + .unwrap(); + drop(connection); + let cache = Replica::open(&path).unwrap(); + assert!( + matches!(cache.cached_asset(&name, 1024), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidData) + ); + assert!( + matches!(cache.fetch_asset(&mut source, "synthetic.one", &name, 1024), Err(Error::Io(error)) if error.kind() == io::ErrorKind::InvalidData) + ); + cache + .export_recovery(directory.path().join("damaged.sqlite")) + .unwrap(); + let archive = Recovery::open(directory.path().join("damaged.sqlite")).unwrap(); + assert!(archive.cached_asset(&name, 1024).is_err()); + assert_eq!(archive.pending().unwrap().len(), 2); +} + +#[test] +fn abrupt_process_exit_retains_only_completed_downloads_and_archives() { + const CHILD: &str = "ONESTORE_ASSET_EXIT_CASE"; + if let Ok(phase) = std::env::var(CHILD) { + struct ExitDuringRead; + impl onestore_notebook::Source for ExitDuringRead { + fn entries(&mut self, _: &str, _: usize) -> io::Result> { + panic!("Unexpected enumeration") + } + fn read(&mut self, _: &str, _: usize) -> io::Result> { + std::process::exit(83) + } + } + let root = std::env::var("ONESTORE_ASSET_EXIT_ROOT").unwrap(); + let cache = copied_cache(Path::new(&root)); + let (name, bytes) = payload(1024); + if phase == "download" { + cache + .fetch_asset(&mut ExitDuringRead, "synthetic.one", &name, bytes.len()) + .unwrap(); + panic!("Read returned after process exit"); + } + cache + .fetch_asset( + &mut onestore_notebook::Local::open(FIXTURE).unwrap(), + "synthetic.one", + &name, + bytes.len(), + ) + .unwrap(); + if phase == "archive" { + cache + .export_recovery(Path::new(&root).join("recovery.sqlite")) + .unwrap(); + } + std::process::exit(83); + } + for phase in ["download", "cached", "archive"] { + let root = tempfile::tempdir().unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "abrupt_process_exit_retains_only_completed_downloads_and_archives", + ]) + .env(CHILD, phase) + .env("ONESTORE_ASSET_EXIT_ROOT", root.path()) + .output() + .unwrap(); + assert_eq!( + output.status.code(), + Some(83), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let cache = Replica::open(root.path().join("cache.sqlite")).unwrap(); + let (name, bytes) = payload(1024); + let expected = (phase != "download").then_some(bytes); + assert_eq!(cache.cached_asset(&name, 1024).unwrap(), expected); + assert_eq!(cache.pending().unwrap().len(), 2); + assert!(matches!( + cache.status(1).unwrap(), + Some(EditStatus::AwaitingConfirmation { .. }) + )); + if phase == "archive" { + let recovery = Recovery::open(root.path().join("recovery.sqlite")).unwrap(); + assert_eq!(recovery.cached_asset(&name, 1024).unwrap(), expected); + assert_eq!(recovery.pending().unwrap(), cache.pending().unwrap()); + assert_eq!(recovery.status(1).unwrap(), cache.status(1).unwrap()); + } + } +} + +#[test] +#[cfg(feature = "smb")] +#[ignore = "requires a disposable Samba mirror at ONESTORE_SMB_NOTEBOOK"] +fn live_smb_downloads_survive_disconnect_and_cache_reopen() { + let root = tempfile::tempdir().unwrap(); + let cache = copied_cache(root.path()); + let client = onestore_smb::Client::connect( + &std::env::var("ONESTORE_SMB_LAB").unwrap(), + "agent", + onestore_smb::Credentials::default(), + Duration::from_secs(5), + ) + .unwrap(); + let notebook = std::env::var("ONESTORE_SMB_NOTEBOOK").unwrap(); + let mut source = onestore_notebook::Smb::new(&client, ¬ebook).unwrap(); + for size in [0, 771, 1024] { + let (name, bytes) = payload(size); + assert_eq!( + cache + .fetch_asset(&mut source, "synthetic.one", &name, size) + .unwrap(), + bytes + ); + } + drop(client); + cache + .export_recovery(root.path().join("recovery.sqlite")) + .unwrap(); + drop(cache); + let cache = Replica::open(root.path().join("cache.sqlite")).unwrap(); + let recovery = Recovery::open(root.path().join("recovery.sqlite")).unwrap(); + for size in [0, 771, 1024] { + let (name, bytes) = payload(size); + assert_eq!( + cache.cached_asset(&name, size).unwrap(), + Some(bytes.clone()) + ); + assert_eq!(recovery.cached_asset(&name, size).unwrap(), Some(bytes)); + } + assert_eq!(cache.pending().unwrap(), recovery.pending().unwrap()); + assert_eq!(cache.status(1).unwrap(), recovery.status(1).unwrap()); + assert_eq!(cache.recovery_summary().unwrap().cached_assets, 3); +} diff --git a/crates/onestore-offline/tests/cache.rs b/crates/onestore-offline/tests/cache.rs index 7e762d412e19449e22aa147f432f5ef0adde3858..31f727a97413e28cc255bcae111b8ef8bc8f580a 100644 --- a/crates/onestore-offline/tests/cache.rs +++ b/crates/onestore-offline/tests/cache.rs @@ -734,7 +734,7 @@ fn unrecognized_persisted_operations_are_rejected_without_dropping_fields() { db.execute("UPDATE edits SET operation=?1", [value.to_string()]) .unwrap(); if operation != "Format" { - db.execute_batch("DROP TABLE conflicts; CREATE TABLE conflicts (edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 2)) STRICT; PRAGMA user_version=3;").unwrap(); + db.execute_batch("DROP TABLE assets; DROP TABLE conflicts; CREATE TABLE conflicts (edit_id INTEGER PRIMARY KEY REFERENCES edits(id) ON DELETE CASCADE, kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 2)) STRICT; PRAGMA user_version=3;").unwrap(); } drop(db); let before = fs::read(&path).unwrap(); diff --git a/crates/onestore-offline/tests/sync.rs b/crates/onestore-offline/tests/sync.rs index c6f52ae896486d9f64cf93d52ba334f75e2180b5..5128b17829f84f71916c3c548d437ee866feca4b 100644 --- a/crates/onestore-offline/tests/sync.rs +++ b/crates/onestore-offline/tests/sync.rs @@ -197,6 +197,8 @@ fn recovery_archive_preserves_typed_queue_uncertainty_and_receipts_without_becom published_receipts: 1, working_bytes: working.len() as u64, remote_bytes: remote.len() as u64, + cached_assets: 0, + cached_asset_bytes: 0, }; assert_eq!(cache.recovery_summary().unwrap(), summary); cache.export_recovery(&archive_path).unwrap(); @@ -712,7 +714,7 @@ fn version_one_cache_migration_preserves_images_intents_and_local_ids() { drop(cache); let db = rusqlite::Connection::open(&path).unwrap(); db.execute_batch( - "DROP TABLE attempt; DROP TABLE conflicts; DROP TABLE receipts; DROP TABLE edits; + "DROP TABLE attempt; DROP TABLE conflicts; DROP TABLE receipts; DROP TABLE edits; DROP TABLE assets; CREATE TABLE edits ( id INTEGER PRIMARY KEY AUTOINCREMENT CHECK(id>0), space TEXT NOT NULL, object TEXT NOT NULL, before_text TEXT NOT NULL, @@ -737,7 +739,7 @@ fn version_one_cache_migration_preserves_images_intents_and_local_ids() { assert_eq!( db.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) .unwrap(), - 4 + 5 ); } -- 2.54.0