Refuse a scan whose root has gone, instead of reporting it empty
FR-PLAT-AND-2, and a silent failure on both platforms. `dr_sync::scan` stepped over a NotFound or PermissionDenied the way it does for a child that vanished mid-walk -- correct for a child, wrong for the root, where it ended the walk, returned Ok with nothing in it, and reported a successful scan of a library that was no longer there. A lost root is now its own error. The images under it are marked Availability::Offline per FR-CAT-9 and no catalog row is deleted; `library::persist` clears the mark per file as each one is listed again, so a root that comes back needs no repair step. Partly satisfied rather than closed, and the gap is worth stating. The recovery half is real and reachable on Android today, because `map_status` turns Nextcloud's 403 and 404 into it and Nextcloud is how a phone actually gets a library in this build. The causes the requirement names -- revocation, reinstall, a removed card -- are properties of a persisted tree permission, and there is none: SAF does not exist here, `SourceRef::Document` is constructed only in test modules, and `LocalStorage` rejects the variant outright. When SAF lands it becomes a third producer of this error and nothing above it changes, which is why the discovery belongs in the connector. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -432,7 +432,7 @@ fn bump_generation(conn: &Connection, root: RootId, now: i64) -> Result<i64, Cat
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-9
|
||||
/// TRACES: FR-CAT-9 | FR-PLAT-AND-2
|
||||
/// Mark every image under a root as unreachable.
|
||||
///
|
||||
/// The other half of FR-CAT-9's distinction: a source *proven absent* may leave
|
||||
@@ -445,14 +445,38 @@ fn bump_generation(conn: &Connection, root: RootId, now: i64) -> Result<i64, Cat
|
||||
/// the root now claims something about the files that is no longer known to be
|
||||
/// true, and only reading the directories again can settle it. Pruning would
|
||||
/// skip them all and leave a plugged-in library showing as offline forever.
|
||||
fn mark_root_offline(conn: &Connection, root: RootId) -> Result<(), CatalogError> {
|
||||
///
|
||||
/// # Why the ETag goes with the mtime
|
||||
///
|
||||
/// The three columns are the same fact told by three kinds of storage: a local
|
||||
/// directory proves it is unchanged with its mtime and entry count, and a
|
||||
/// remote one proves it with a propagating ETag (ARCH §6.6). Clearing two of
|
||||
/// them and leaving the third would disarm the re-listing on exactly the
|
||||
/// libraries this is most likely to be called for — a remote scan prunes on
|
||||
/// the ETag alone, so a root that came back would be walked, found unchanged
|
||||
/// at every level, pruned whole, and left with every row still marked offline
|
||||
/// and nothing that would ever clear the mark.
|
||||
///
|
||||
/// # Public, because losing a root is not only the local walk's business
|
||||
///
|
||||
/// This began as the private end of [`scan_root`]'s root-failure branches,
|
||||
/// which is the only route a library reached through [`Storage`] can take.
|
||||
/// The application does not currently take that route at all: it opens
|
||||
/// libraries through `dr-sync`'s connectors, so the discovery happens in a
|
||||
/// crate that cannot see this one's internals, and the correct response is
|
||||
/// identical (FR-PLAT-AND-2). Exported rather than reimplemented beside the
|
||||
/// caller that found out — a second copy would be a second thing to remember
|
||||
/// when the ETag rule below changes.
|
||||
///
|
||||
/// [`Storage`]: dr_plat::Storage
|
||||
pub fn mark_root_offline(conn: &Connection, root: RootId) -> Result<(), CatalogError> {
|
||||
let root_id = root.0 as i64;
|
||||
conn.execute(
|
||||
"UPDATE images SET availability = ?1 WHERE root_id = ?2 AND availability != ?1",
|
||||
rusqlite::params![availability_code(Availability::Offline), root_id],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE folders SET mtime = NULL, entry_count = NULL WHERE root_id = ?1",
|
||||
"UPDATE folders SET mtime = NULL, entry_count = NULL, etag = NULL WHERE root_id = ?1",
|
||||
[root_id],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -995,6 +1019,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-PLAT-AND-2 | FR-CAT-9
|
||||
#[test]
|
||||
fn marking_a_root_offline_forgets_the_remote_validator_too() {
|
||||
// The half of the marking that only a remote library can notice, and
|
||||
// the reason it has to be here rather than beside the connector: a
|
||||
// remote scan prunes on the propagating ETag alone (ARCH §6.6). Clear
|
||||
// the local mtime and leave the ETag standing and a library that came
|
||||
// back would be walked, found unchanged at every level, pruned whole,
|
||||
// and left with every row still marked offline — with nothing that
|
||||
// would ever clear the mark, because clearing it is something only a
|
||||
// listing can do.
|
||||
//
|
||||
// Written directly because this module never writes an ETag; it is
|
||||
// `ui/dr-ui/src/library.rs`'s scan that does, against the same table.
|
||||
let lib = Library::new("etag-forgotten");
|
||||
lib.file("2026/IMG.CR3", b"raw");
|
||||
lib.scan();
|
||||
lib.conn()
|
||||
.execute(
|
||||
"UPDATE folders SET etag = 'e1' WHERE root_id = ?1",
|
||||
[lib.root.0 as i64],
|
||||
)
|
||||
.expect("etag");
|
||||
assert!(lib.count("SELECT COUNT(*) FROM folders WHERE etag IS NOT NULL") > 0);
|
||||
|
||||
mark_root_offline(lib.conn(), lib.root).expect("mark");
|
||||
|
||||
assert_eq!(
|
||||
lib.count("SELECT COUNT(*) FROM folders WHERE etag IS NOT NULL"),
|
||||
0,
|
||||
"an unreachable library must be re-listed, not pruned as unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_that_comes_back_is_available_again() {
|
||||
// The other half: a drive plugged back in must return the library to
|
||||
|
||||
Reference in New Issue
Block a user