mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-11 17:27:03 +01:00
implemented share_access, POSIX read-only mode
This commit is contained in:
1 parent
32817870a5
commit
295bbeadac
22 files changed
+3055
-146
No files matched your search
@@ -81,6 +81,7 @@ const REPORTED_TABLES: &[&str] = &[
|
||||
"pending_op",
|
||||
"activity",
|
||||
"local_files",
|
||||
"share_access",
|
||||
];
|
||||
|
||||
impl Db {
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// Current schema version. Bump on every forward migration added below.
|
||||
pub(super) const SCHEMA_VERSION: i64 = 16;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 17;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -89,6 +89,9 @@ impl Db {
|
||||
if current < 16 {
|
||||
tx.execute_batch(MIGRATION_V16)?;
|
||||
}
|
||||
if current < 17 {
|
||||
tx.execute_batch(MIGRATION_V17)?;
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -411,3 +414,14 @@ CREATE INDEX IF NOT EXISTS idx_local_files_name_nocase
|
||||
ON local_files(name COLLATE NOCASE);
|
||||
INSERT INTO local_fts(local_fts) VALUES('rebuild');
|
||||
";
|
||||
|
||||
/// Schema v17: effective access for roots shared with this account. The role is
|
||||
/// persisted separately from `nodes.node_json` so a cold/offline mount can
|
||||
/// enforce the last known permission even when the SDK cannot refresh metadata.
|
||||
const MIGRATION_V17: &str = "
|
||||
CREATE TABLE share_access (
|
||||
root_uid TEXT PRIMARY KEY,
|
||||
access TEXT NOT NULL
|
||||
CHECK (access IN ('owner', 'editor', 'viewer', 'unknown'))
|
||||
);
|
||||
";
|
||||
@@ -31,6 +31,7 @@ mod nodes;
|
||||
mod ops;
|
||||
mod photos;
|
||||
mod pins;
|
||||
mod share_access;
|
||||
mod state;
|
||||
mod sync;
|
||||
mod trash;
|
||||
@@ -43,7 +44,7 @@ pub use maintenance::{DbStats, VacuumOutcome};
|
||||
pub use nodes::{SearchHit, StoredNode};
|
||||
pub use ops::{
|
||||
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
|
||||
PendingCounts, PendingOp, op_supersedes,
|
||||
PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
||||
};
|
||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
|
||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||
|
||||
@@ -148,6 +148,32 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically retire a landed trash operation and its retained authority.
|
||||
///
|
||||
/// Queued trash keeps the node row so drain-time permission checks can
|
||||
/// resolve its shared-tree access. Removing the op first could resurrect
|
||||
/// that row after a crash; removing the row first could strand a now
|
||||
/// unauthorizable op. One transaction closes both windows.
|
||||
pub fn complete_trash_op(&self, op_id: i64, uid: &NodeUid) -> Result<()> {
|
||||
let uid = uid.to_string();
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let rowid: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT rowid FROM nodes WHERE uid = ?1",
|
||||
params![uid],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![uid])?;
|
||||
if let Some(rowid) = rowid {
|
||||
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
|
||||
}
|
||||
tx.execute("DELETE FROM pending_op WHERE id = ?1", params![op_id])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a folder node has any non-trashed children in the database.
|
||||
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
|
||||
let uid_str = parent_uid.to_string();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! order, so a child never drains before the parent that gives it a real uid.
|
||||
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
@@ -35,6 +36,16 @@ pub const OP_MKDIR: &str = "mkdir";
|
||||
/// that folder's own drain, exactly as for [`OP_CREATE`].
|
||||
pub const OP_RENAME: &str = "rename";
|
||||
|
||||
/// Drain-time authority retained by a queued rename.
|
||||
///
|
||||
/// The optimistic local move rewrites the node's persisted ancestry to the
|
||||
/// destination immediately. Keeping the original parent here prevents that
|
||||
/// rewrite from erasing the shared tree that admitted the move.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RenameMeta {
|
||||
pub original_parent_uid: String,
|
||||
}
|
||||
|
||||
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about
|
||||
/// (offline.md Phase 3b).
|
||||
///
|
||||
@@ -127,6 +138,32 @@ pub struct AttachedBlob {
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Whether a specific desired-state operation is still queued for a node.
|
||||
pub fn has_pending_op(&self, uid: &str, kind: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM pending_op WHERE uid = ?1 AND kind = ?2
|
||||
)",
|
||||
params![uid, kind],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Read an existing operation's metadata before a superseding enqueue.
|
||||
pub fn pending_op_meta(&self, uid: &str, kind: &str) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock();
|
||||
conn.query_row(
|
||||
"SELECT meta_json FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
||||
params![uid, kind],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map(|value| value.flatten())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
@@ -167,6 +204,50 @@ impl Db {
|
||||
Ok((id, superseded))
|
||||
}
|
||||
|
||||
/// Atomically replace every queued operation for `uid` and its queued
|
||||
/// descendants with one remote trash intent.
|
||||
///
|
||||
/// Staged blob paths are returned only after the transaction commits. A
|
||||
/// failed trash insert therefore rolls the deletions back and leaves every
|
||||
/// prior operation owning its bytes.
|
||||
pub fn replace_ops_with_trash(
|
||||
&self,
|
||||
uid: &str,
|
||||
name: &str,
|
||||
created_at: i64,
|
||||
) -> Result<(i64, Vec<String>)> {
|
||||
const SUBTREE: &str = "
|
||||
WITH RECURSIVE doomed(uid) AS (
|
||||
SELECT ?1
|
||||
UNION
|
||||
SELECT p.uid FROM pending_op p JOIN doomed d ON p.parent_uid = d.uid
|
||||
)";
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let blobs: Vec<String> = {
|
||||
let mut stmt = tx.prepare(&format!(
|
||||
"{SUBTREE}
|
||||
SELECT blob_path FROM pending_op
|
||||
WHERE uid IN (SELECT uid FROM doomed) AND blob_path IS NOT NULL"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![uid], |row| row.get::<_, String>(0))?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
};
|
||||
tx.execute(
|
||||
&format!("{SUBTREE} DELETE FROM pending_op WHERE uid IN (SELECT uid FROM doomed)"),
|
||||
params![uid],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO pending_op
|
||||
(kind, uid, parent_uid, name, blob_path, meta_json, created_at, next_attempt_at)
|
||||
VALUES (?1, ?2, NULL, ?3, NULL, NULL, ?4, 0)",
|
||||
params![OP_TRASH, uid, name, created_at],
|
||||
)?;
|
||||
let id = tx.last_insert_rowid();
|
||||
tx.commit()?;
|
||||
Ok((id, blobs))
|
||||
}
|
||||
|
||||
/// Point a queued create at the bytes that were just written to it, returning
|
||||
/// any blob it previously held so the caller can discard it.
|
||||
///
|
||||
@@ -509,4 +590,23 @@ impl Db {
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Defer a due operation without recording a failed remote attempt.
|
||||
///
|
||||
/// Access checks happen before the drain calls Proton, so a locally denied
|
||||
/// operation has not failed remotely and must not consume a retry. Preserve
|
||||
/// the long-lived transient-create sentinel if this is ever called for one.
|
||||
pub fn defer_op_without_attempt(&self, id: i64, next_attempt_at: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE pending_op
|
||||
SET next_attempt_at = CASE
|
||||
WHEN next_attempt_at >= ?3 THEN next_attempt_at
|
||||
ELSE ?2
|
||||
END
|
||||
WHERE id = ?1",
|
||||
params![id, next_attempt_at, PARK_UNTIL],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Persisted effective access for roots shared with this account.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use crate::{Access, Error, Result};
|
||||
|
||||
impl Db {
|
||||
/// Load the complete, deliberately small shared-root authority map.
|
||||
///
|
||||
/// Mount state keeps this resident so ordinary node interning and subtree
|
||||
/// propagation never turn into one SQLite query per inode. The database
|
||||
/// remains the restart/offline authority and every explicit role change is
|
||||
/// written through before this cache is updated.
|
||||
pub fn all_share_access(&self) -> Result<HashMap<NodeUid, Access>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare("SELECT root_uid, access FROM share_access")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?;
|
||||
let mut access = HashMap::new();
|
||||
for row in rows {
|
||||
let (uid, value) = row?;
|
||||
let (volume, link) = uid.split_once('~').ok_or_else(|| {
|
||||
Error::Other(format!(
|
||||
"invalid shared-root uid {uid:?} stored in database"
|
||||
))
|
||||
})?;
|
||||
let access_value = Access::from_db_str(&value).ok_or_else(|| {
|
||||
Error::Other(format!("invalid share access {value:?} stored for {uid}"))
|
||||
})?;
|
||||
access.insert(
|
||||
NodeUid::new(VolumeId::from(volume), LinkId::from(link)),
|
||||
access_value,
|
||||
);
|
||||
}
|
||||
Ok(access)
|
||||
}
|
||||
|
||||
/// Read the effective access recorded for a shared-tree root.
|
||||
pub fn share_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||
let conn = self.conn.lock();
|
||||
let value = conn
|
||||
.query_row(
|
||||
"SELECT access FROM share_access WHERE root_uid = ?1",
|
||||
[uid.to_string()],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
value
|
||||
.map(|value| {
|
||||
Access::from_db_str(&value).ok_or_else(|| {
|
||||
Error::Other(format!("invalid share access {value:?} stored for {uid}"))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Insert or replace the effective access for a shared-tree root.
|
||||
pub fn set_share_access(&self, uid: &NodeUid, access: Access) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO share_access (root_uid, access) VALUES (?1, ?2)
|
||||
ON CONFLICT(root_uid) DO UPDATE SET access = excluded.access",
|
||||
params![uid.to_string(), access.as_db_str()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget a shared-tree root's effective access.
|
||||
pub fn delete_share_access(&self, uid: &NodeUid) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM share_access WHERE root_uid = ?1",
|
||||
[uid.to_string()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fail closed every persisted shared-tree authority.
|
||||
///
|
||||
/// Owned and device trees have no row in `share_access`, so this only
|
||||
/// affects roots already identified as shared with this account.
|
||||
pub fn downgrade_all_share_access(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
Ok(conn.execute(
|
||||
"UPDATE share_access SET access = 'viewer' WHERE access != 'viewer'",
|
||||
[],
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Resolve a persisted node's effective access through its nearest recorded
|
||||
/// share root. `None` means the node itself is not persisted, which callers
|
||||
/// must not treat as owned: stale handles need to fail closed.
|
||||
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||
let conn = self.conn.lock();
|
||||
let exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
|
||||
[uid.to_string()],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !exists {
|
||||
return Ok(None);
|
||||
}
|
||||
let value = conn
|
||||
.query_row(
|
||||
"WITH RECURSIVE ancestors(uid, parent_uid, depth, path) AS (
|
||||
SELECT uid, parent_uid, 0, char(31) || uid || char(31)
|
||||
FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT a.parent_uid, n.parent_uid, a.depth + 1,
|
||||
a.path || a.parent_uid || char(31)
|
||||
FROM ancestors a
|
||||
LEFT JOIN nodes n ON n.uid = a.parent_uid
|
||||
WHERE a.parent_uid IS NOT NULL
|
||||
AND instr(
|
||||
a.path,
|
||||
char(31) || a.parent_uid || char(31)
|
||||
) = 0
|
||||
)
|
||||
SELECT sa.access
|
||||
FROM ancestors a JOIN share_access sa ON sa.root_uid = a.uid
|
||||
ORDER BY a.depth ASC
|
||||
LIMIT 1",
|
||||
[uid.to_string()],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
value
|
||||
.map(|value| {
|
||||
Access::from_db_str(&value).ok_or_else(|| {
|
||||
Error::Other(format!(
|
||||
"invalid effective share access {value:?} for {uid}"
|
||||
))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
.map(|access| Some(access.unwrap_or(Access::Owner)))
|
||||
}
|
||||
}
|
||||
@@ -350,6 +350,34 @@ fn delete_node_removes_row() {
|
||||
assert!(db.load_all().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completing_trash_atomically_removes_the_op_and_retained_node() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let node = file("trash-me", "parent", "trash-me.txt", 4);
|
||||
let uid = node.uid.clone();
|
||||
db.upsert_node(&node).unwrap();
|
||||
db.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_TRASH.to_string(),
|
||||
uid: uid.to_string(),
|
||||
parent_uid: None,
|
||||
name: Some(node.name.clone()),
|
||||
blob_path: None,
|
||||
meta_json: None,
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
let op = db.pending_ops().unwrap().remove(0);
|
||||
|
||||
db.complete_trash_op(op.id, &uid).unwrap();
|
||||
|
||||
assert!(db.node_by_uid(&uid.to_string()).unwrap().is_none());
|
||||
assert!(db.pending_ops().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn children_if_listed_gated_on_flag() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
@@ -784,6 +812,154 @@ fn opens_and_migrates() {
|
||||
assert_eq!(version, SCHEMA_VERSION.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_access_round_trips_updates_and_deletes() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let root = uid("shared-root");
|
||||
|
||||
assert_eq!(db.share_access(&root).unwrap(), None);
|
||||
for access in [
|
||||
crate::Access::Owner,
|
||||
crate::Access::Editor,
|
||||
crate::Access::Viewer,
|
||||
crate::Access::Unknown,
|
||||
] {
|
||||
db.set_share_access(&root, access).unwrap();
|
||||
assert_eq!(db.share_access(&root).unwrap(), Some(access));
|
||||
assert_eq!(db.all_share_access().unwrap().get(&root), Some(&access));
|
||||
}
|
||||
db.delete_share_access(&root).unwrap();
|
||||
assert_eq!(db.share_access(&root).unwrap(), None);
|
||||
assert!(db.all_share_access().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_node_access_inherits_from_the_persisted_share_root() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&folder("shared", Some("root"), "Shared"))
|
||||
.unwrap();
|
||||
db.upsert_node(&folder("nested", Some("shared"), "Nested"))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("child", "nested", "child.txt", 1))
|
||||
.unwrap();
|
||||
db.set_share_access(&uid("shared"), crate::Access::Viewer)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
db.effective_node_access(&uid("child")).unwrap(),
|
||||
Some(crate::Access::Viewer)
|
||||
);
|
||||
assert_eq!(
|
||||
db.effective_node_access(&uid("root")).unwrap(),
|
||||
Some(crate::Access::Owner)
|
||||
);
|
||||
assert_eq!(db.effective_node_access(&uid("missing")).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_node_access_uses_the_nearest_nested_share_root() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&folder("outer", Some("root"), "Outer"))
|
||||
.unwrap();
|
||||
db.upsert_node(&folder("inner", Some("outer"), "Inner"))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("child", "inner", "child.txt", 1))
|
||||
.unwrap();
|
||||
db.set_share_access(&uid("outer"), crate::Access::Editor)
|
||||
.unwrap();
|
||||
db.set_share_access(&uid("inner"), crate::Access::Viewer)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
db.effective_node_access(&uid("child")).unwrap(),
|
||||
Some(crate::Access::Viewer)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_node_access_keeps_a_missing_share_root_tombstone_authoritative() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&folder("shared", Some("root"), "Shared"))
|
||||
.unwrap();
|
||||
db.upsert_node(&folder("nested", Some("shared"), "Nested"))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("child", "nested", "child.txt", 1))
|
||||
.unwrap();
|
||||
db.set_share_access(&uid("shared"), crate::Access::Viewer)
|
||||
.unwrap();
|
||||
db.delete_node(&uid("shared")).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
db.effective_node_access(&uid("child")).unwrap(),
|
||||
Some(crate::Access::Viewer)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downgrade_all_share_access_leaves_unrecorded_owned_nodes_owned() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&folder("shared", Some("root"), "Shared"))
|
||||
.unwrap();
|
||||
db.set_share_access(&uid("shared"), crate::Access::Editor)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(db.downgrade_all_share_access().unwrap(), 1);
|
||||
assert_eq!(
|
||||
db.effective_node_access(&uid("shared")).unwrap(),
|
||||
Some(crate::Access::Viewer)
|
||||
);
|
||||
assert_eq!(
|
||||
db.effective_node_access(&uid("root")).unwrap(),
|
||||
Some(crate::Access::Owner)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v17_adds_share_access_to_a_v16_fixture() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"pdfs-db-v16-fixture-{}-{}.db",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
{
|
||||
// V17 only adds `share_access`, so removing it and restoring the version
|
||||
// stamp produces the exact schema a released V16 database had.
|
||||
let db = Db::open(&path).unwrap();
|
||||
db.set_state_str("fixture_value", "survives").unwrap();
|
||||
}
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
conn.execute_batch(
|
||||
"DROP TABLE share_access;
|
||||
UPDATE sync_state SET value = '16' WHERE key = 'schema_version';",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let db = Db::open(&path).unwrap();
|
||||
assert_eq!(
|
||||
db.state_str("fixture_value").unwrap().as_deref(),
|
||||
Some("survives")
|
||||
);
|
||||
let root = uid("offline-share");
|
||||
db.set_share_access(&root, crate::Access::Viewer).unwrap();
|
||||
assert_eq!(db.share_access(&root).unwrap(), Some(crate::Access::Viewer));
|
||||
assert_eq!(
|
||||
db.state_str("schema_version").unwrap().as_deref(),
|
||||
Some("17")
|
||||
);
|
||||
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_schema_newer_than_the_running_build() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
@@ -935,6 +1111,82 @@ fn failed_superseding_insert_keeps_the_old_pending_op() {
|
||||
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/original"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_trash_replacement_returns_owned_blobs_after_commit() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let root = uid("trash-root").to_string();
|
||||
let child = uid("trash-child").to_string();
|
||||
let revision = |uid: &str, parent_uid: Option<&str>, blob: &str| PendingOp {
|
||||
id: 0,
|
||||
kind: OP_REVISION.to_string(),
|
||||
uid: uid.to_string(),
|
||||
parent_uid: parent_uid.map(str::to_string),
|
||||
name: None,
|
||||
blob_path: Some(blob.to_string()),
|
||||
meta_json: Some("{}".to_string()),
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
};
|
||||
db.enqueue_op(&revision(&root, None, "/staging/root"))
|
||||
.unwrap();
|
||||
db.enqueue_op(&revision(&child, Some(&root), "/staging/child"))
|
||||
.unwrap();
|
||||
|
||||
let (trash_id, mut blobs) = db.replace_ops_with_trash(&root, "removed.txt", 2).unwrap();
|
||||
blobs.sort();
|
||||
assert_eq!(blobs, vec!["/staging/child", "/staging/root"]);
|
||||
|
||||
let ops = db.pending_ops().unwrap();
|
||||
assert_eq!(ops.len(), 1);
|
||||
assert_eq!(ops[0].id, trash_id);
|
||||
assert_eq!(ops[0].kind, OP_TRASH);
|
||||
assert_eq!(ops[0].uid, root);
|
||||
assert_eq!(ops[0].name.as_deref(), Some("removed.txt"));
|
||||
assert!(ops[0].blob_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_atomic_trash_insert_keeps_prior_revision_and_blob_ownership() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let node = uid("atomic-trash").to_string();
|
||||
db.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_REVISION.to_string(),
|
||||
uid: node.clone(),
|
||||
parent_uid: None,
|
||||
name: None,
|
||||
blob_path: Some("/staging/still-owned".to_string()),
|
||||
meta_json: Some("{}".to_string()),
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
db.with_conn(|conn| {
|
||||
conn.execute_batch(
|
||||
"CREATE TRIGGER reject_trash_insert BEFORE INSERT ON pending_op
|
||||
WHEN NEW.kind = 'trash'
|
||||
BEGIN SELECT RAISE(ABORT, 'injected trash insert failure'); END;",
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(db.replace_ops_with_trash(&node, "removed.txt", 2).is_err());
|
||||
let ops = db.pending_ops().unwrap();
|
||||
assert_eq!(ops.len(), 1);
|
||||
assert_eq!(ops[0].kind, OP_REVISION);
|
||||
assert_eq!(ops[0].uid, node);
|
||||
assert_eq!(
|
||||
ops[0].blob_path.as_deref(),
|
||||
Some("/staging/still-owned"),
|
||||
"the rolled-back revision still owns its staged bytes"
|
||||
);
|
||||
}
|
||||
|
||||
/// Deleting a folder that was created offline must take the ops queued
|
||||
/// underneath it with it: they name a placeholder parent that will now never
|
||||
/// become real, so nothing could ever drain them and nothing is left to
|
||||
@@ -1202,6 +1454,63 @@ fn a_failed_op_stays_queued_with_backoff() {
|
||||
assert_eq!(db.pending_op_counts().unwrap().uploads, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_access_blocked_op_is_deferred_without_counting_an_attempt() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_REVISION.to_string(),
|
||||
uid: uid("blocked").to_string(),
|
||||
parent_uid: None,
|
||||
name: None,
|
||||
blob_path: Some("/staging/blocked".to_string()),
|
||||
meta_json: Some("{}".to_string()),
|
||||
created_at: 1,
|
||||
attempts: 3,
|
||||
last_error: Some("earlier network failure".to_string()),
|
||||
next_attempt_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
let before = db.pending_ops().unwrap();
|
||||
let id = before[0].id;
|
||||
let count = db.pending_op_counts().unwrap();
|
||||
|
||||
db.defer_op_without_attempt(id, 5_000).unwrap();
|
||||
|
||||
let after = db.pending_ops().unwrap();
|
||||
assert_eq!(after.len(), 1);
|
||||
let after_count = db.pending_op_counts().unwrap();
|
||||
assert_eq!(after_count.uploads, count.uploads);
|
||||
assert_eq!(after_count.changes, count.changes);
|
||||
assert_eq!(after[0].attempts, before[0].attempts);
|
||||
assert_eq!(after[0].last_error, before[0].last_error);
|
||||
assert_eq!(after[0].next_attempt_at, 5_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_deferral_does_not_unpark_a_transient_create() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_CREATE.to_string(),
|
||||
uid: uid("transient").to_string(),
|
||||
parent_uid: Some(uid("parent").to_string()),
|
||||
name: Some(".part".to_string()),
|
||||
blob_path: Some("/staging/transient".to_string()),
|
||||
meta_json: Some("{}".to_string()),
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: PARK_UNTIL,
|
||||
})
|
||||
.unwrap();
|
||||
let op = db.pending_ops().unwrap().remove(0);
|
||||
|
||||
db.defer_op_without_attempt(op.id, 5_000).unwrap();
|
||||
|
||||
assert_eq!(db.pending_ops().unwrap()[0].next_attempt_at, PARK_UNTIL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_is_idempotent() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
@@ -1846,6 +2155,7 @@ fn test_db_has_children_and_trashed_filtering() {
|
||||
is_shared: false,
|
||||
is_shared_publicly: false,
|
||||
signature_email: None,
|
||||
membership: None,
|
||||
verification: Default::default(),
|
||||
};
|
||||
db.upsert_node(&parent_node).unwrap();
|
||||
@@ -1869,6 +2179,7 @@ fn test_db_has_children_and_trashed_filtering() {
|
||||
is_shared: false,
|
||||
is_shared_publicly: false,
|
||||
signature_email: None,
|
||||
membership: None,
|
||||
verification: Default::default(),
|
||||
};
|
||||
db.upsert_node(&child_node).unwrap();
|
||||
|
||||
Reference in new issue
Block a user