feat: replace revision counters with domain state contracts

Use content digests, key fingerprints, binding and trust identities, immutable mutation receipts, and observation tokens instead of state-update ordinals across API, storage, Runtime, and Web contracts.

Preserve historical replay and secret material through frozen migrations; preflight uncertain removals before cutover, resume provisioning receipts, validate encrypted migration candidates, and align fresh/upgraded schemas. Include reviewed regression fixes, generated artifacts, and validation evidence.
This commit is contained in:
2026-10-10 15:55:20 +09:00
parent 72ce817de4
commit c78f7f7d80
358 changed files with 25965 additions and 12069 deletions
+54 -71
View File
@@ -2,9 +2,11 @@
//! blocking thread, never on an async executor. SQL IMMEDIATE transactions are
//! the cross-process authority for uploads, tree changes, reads, and collection.
mod blob;
mod migrations;
use migrations::MIGRATIONS;
use feature_storage::{
FeatureDatabase, FeatureMigration, FeatureRegistration, FeatureStorageError, RegisteredFeature,
FeatureDatabase, FeatureRegistration, FeatureStorageError, RegisteredFeature,
ScopedFeatureStorage,
};
use rusqlite::{Connection, OptionalExtension, Transaction, params};
@@ -25,9 +27,9 @@ pub const MAX_NAME_BYTES: usize = 255;
pub enum Error {
#[error("invalid Drive request: {0}")]
Invalid(String),
#[error("Drive node or revision no longer exists")]
#[error("Drive node no longer exists")]
NotFound,
#[error("Drive revision, name, or request identity conflict")]
#[error("Drive node changed, name is taken, or request identity conflicts")]
Conflict,
#[error("Drive access denied")]
Denied,
@@ -72,7 +74,6 @@ macro_rules! decimal_id {
};
}
decimal_id!(NodeId);
decimal_id!(Revision);
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -88,7 +89,8 @@ pub struct Node {
pub parent_id: Option<NodeId>,
pub name: String,
pub kind: Kind,
pub revision: Revision,
/// Request ID of the last committed mutation; empty only for the immutable root.
pub last_mutation_id: String,
pub size: u64,
pub content_type: Option<String>,
pub updated_by: String,
@@ -110,19 +112,19 @@ pub enum Mutation {
},
Update {
id: NodeId,
expected_revision: Revision,
expected_mutation_id: String,
content_type: String,
bytes: Vec<u8>,
},
Relocate {
id: NodeId,
expected_revision: Revision,
expected_mutation_id: String,
parent: NodeId,
name: String,
},
Delete {
id: NodeId,
expected_revision: Revision,
expected_mutation_id: String,
},
}
@@ -151,7 +153,7 @@ pub struct Page {
#[derive(Debug, Serialize, Deserialize)]
pub struct ReadChunk {
pub id: NodeId,
pub revision: Revision,
pub last_mutation_id: String,
pub offset: u64,
pub bytes: Vec<u8>,
pub eof: bool,
@@ -159,7 +161,7 @@ pub struct ReadChunk {
#[derive(Debug, Serialize, Deserialize)]
pub struct TextChunk {
pub id: NodeId,
pub revision: Revision,
pub last_mutation_id: String,
pub offset: u64,
pub text: String,
pub eof: bool,
@@ -187,35 +189,6 @@ pub struct Drive {
_storage: ScopedFeatureStorage,
}
static MIGRATIONS: &[FeatureMigration] =
&[FeatureMigration::new(1, "drive_tree_and_receipts", migrate)];
fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
tx.execute_batch("\
CREATE TABLE drive_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1),
workspace_id TEXT NOT NULL UNIQUE, deleting INTEGER NOT NULL DEFAULT 0 CHECK(deleting IN (0,1)));
CREATE TABLE drive_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id TEXT NOT NULL REFERENCES drive_state(workspace_id),
parent_id INTEGER,
name TEXT NOT NULL CHECK(length(CAST(name AS BLOB)) <= 255),
kind TEXT NOT NULL CHECK(kind IN ('file','directory')),
revision INTEGER NOT NULL CHECK(revision > 0),
blob_key TEXT, size INTEGER NOT NULL CHECK(size >= 0 AND size <= 16777216),
content_type TEXT, updated_by TEXT NOT NULL, updated_at_ms INTEGER NOT NULL,
UNIQUE(workspace_id,id),
FOREIGN KEY(workspace_id,parent_id) REFERENCES drive_nodes(workspace_id,id),
CHECK((kind='directory' AND blob_key IS NULL AND size=0 AND content_type IS NULL)
OR (kind='file' AND blob_key IS NOT NULL AND content_type IS NOT NULL)));
CREATE UNIQUE INDEX drive_sibling_name ON drive_nodes(workspace_id,ifnull(parent_id,0),name);
CREATE INDEX drive_children ON drive_nodes(parent_id,id);
CREATE INDEX drive_blob_reference ON drive_nodes(blob_key) WHERE blob_key IS NOT NULL;
CREATE UNIQUE INDEX drive_single_root ON drive_nodes(workspace_id) WHERE parent_id IS NULL;
CREATE TABLE drive_receipts (request_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL,
result_json TEXT NOT NULL, committed_at_ms INTEGER NOT NULL);
")?;
Ok(())
}
impl Drive {
pub fn register(storage: &ScopedFeatureStorage) -> feature_storage::Result<RegisteredFeature> {
storage.register(FeatureRegistration::new("workspace-drive", MIGRATIONS))
@@ -232,8 +205,8 @@ impl Drive {
tx.execute("INSERT OR IGNORE INTO drive_state(singleton,workspace_id) VALUES (1,?1)", [&workspace_id])?;
let stored: String = tx.query_row("SELECT workspace_id FROM drive_state WHERE singleton=1", [], |r| r.get(0))?;
if stored != workspace_id { return Err(Error::Invalid("metadata belongs to another Workspace".into())); }
tx.execute("INSERT OR IGNORE INTO drive_nodes(workspace_id,parent_id,name,kind,revision,size,updated_by,updated_at_ms)
VALUES (?1,NULL,'','directory',1,0,'server',?2)", params![workspace_id, now_ms()])?;
tx.execute("INSERT OR IGNORE INTO drive_nodes(workspace_id,parent_id,name,kind,last_mutation_id,size,updated_by,updated_at_ms)
VALUES (?1,NULL,'','directory','',0,'server',?2)", params![workspace_id, now_ms()])?;
Ok(())
})?;
Ok(Self {
@@ -356,6 +329,9 @@ impl Drive {
})
}
/// Recover the committed result by request identity, including pre-cutover
/// receipts whose payload cannot be verified for replay. Committed status is
/// not proof that a different actor/payload matches that request.
pub fn request_status(&self, request_id: &str) -> Result<RequestStatus> {
validate_identity(request_id, "request_id", 128)?;
self.database.try_transaction(|c| {
@@ -379,6 +355,9 @@ impl Drive {
/// DB commit is publication. The blob and receipt are never independently
/// treated as a successful mutation. Successful request IDs are retained for
/// the Workspace lifetime; reuse with a different actor/payload is conflict.
/// Pre-cutover replay checks the original typed-payload digest using proven
/// receipt correspondence. If that evidence is unavailable, returns Conflict
/// without executing; recover the committed result through request_status.
pub fn mutate(
&self,
request_id: &str,
@@ -396,7 +375,9 @@ impl Drive {
self.authorize(tx, true)?;
if let Some((old_fingerprint,json)) = tx.query_row("SELECT fingerprint,result_json FROM drive_receipts WHERE request_id=?1",
[request_id], |r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?))).optional()? {
if old_fingerprint != fingerprint { return Err(Error::Conflict); }
if old_fingerprint != fingerprint && !migrations::matches_legacy_replay(tx, request_id, actor, &mutation, &old_fingerprint)? {
return Err(Error::Conflict);
}
return Ok(serde_json::from_str(&json)?);
}
accepting(tx)?;
@@ -404,40 +385,40 @@ impl Drive {
let result = match mutation {
Mutation::CreateFolder { parent,name } => {
directory(tx,parent)?;
insert(tx,&self.workspace_id,parent,&name,Kind::Directory,None,0,None,actor,at)?
insert(tx,&self.workspace_id,parent,&name,Kind::Directory,None,0,None,actor,at,request_id)?
}
Mutation::CreateFile { parent,name,content_type,bytes } => {
directory(tx,parent)?;
let key = Uuid::now_v7().to_string();
self.blobs.put(&key,&bytes)?;
insert(tx,&self.workspace_id,parent,&name,Kind::File,Some(&key),bytes.len(),Some(&content_type),actor,at)?
insert(tx,&self.workspace_id,parent,&name,Kind::File,Some(&key),bytes.len(),Some(&content_type),actor,at,request_id)?
}
Mutation::Update { id,expected_revision,content_type,bytes } => {
let old = changeable(tx,id,expected_revision)?;
Mutation::Update { id,expected_mutation_id,content_type,bytes } => {
let old = changeable(tx,id,&expected_mutation_id)?;
if old.kind != Kind::File { return Err(Error::Invalid("cannot write a directory".into())); }
let key = Uuid::now_v7().to_string();
self.blobs.put(&key,&bytes)?;
cas(tx.execute("UPDATE drive_nodes SET blob_key=?1,size=?2,content_type=?3,revision=revision+1,updated_by=?4,updated_at_ms=?5
WHERE id=?6 AND revision=?7", params![key,bytes.len(),content_type,actor,at,id.0,expected_revision.0])?)?;
cas(tx.execute("UPDATE drive_nodes SET blob_key=?1,size=?2,content_type=?3,last_mutation_id=?7,updated_by=?4,updated_at_ms=?5
WHERE id=?6", params![key,bytes.len(),content_type,actor,at,id.0,request_id])?)?;
MutationResult { node: node(tx,id)?, deleted:false }
}
Mutation::Relocate { id,expected_revision,parent,name } => {
changeable(tx,id,expected_revision)?;
Mutation::Relocate { id,expected_mutation_id,parent,name } => {
changeable(tx,id,&expected_mutation_id)?;
directory(tx,parent)?;
let cyclic: bool = tx.query_row("WITH RECURSIVE ancestors(id,parent_id) AS (
SELECT id,parent_id FROM drive_nodes WHERE id=?1 UNION ALL
SELECT n.id,n.parent_id FROM drive_nodes n JOIN ancestors a ON n.id=a.parent_id)
SELECT EXISTS(SELECT 1 FROM ancestors WHERE id=?2)",params![parent.0,id.0],|r|r.get(0))?;
if cyclic { return Err(Error::Invalid("move would create a cycle".into())); }
unique(tx.execute("UPDATE drive_nodes SET parent_id=?1,name=?2,revision=revision+1,updated_by=?3,updated_at_ms=?4
WHERE id=?5 AND revision=?6",params![parent.0,name,actor,at,id.0,expected_revision.0]))?;
unique(tx.execute("UPDATE drive_nodes SET parent_id=?1,name=?2,last_mutation_id=?6,updated_by=?3,updated_at_ms=?4
WHERE id=?5",params![parent.0,name,actor,at,id.0,request_id]))?;
MutationResult { node:node(tx,id)?,deleted:false }
}
Mutation::Delete { id,expected_revision } => {
let old = changeable(tx,id,expected_revision)?;
Mutation::Delete { id,expected_mutation_id } => {
let old = changeable(tx,id,&expected_mutation_id)?;
let children: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM drive_nodes WHERE parent_id=?1)",[id.0],|r|r.get(0))?;
if children { return Err(Error::Conflict); }
cas(tx.execute("DELETE FROM drive_nodes WHERE id=?1 AND revision=?2",params![id.0,expected_revision.0])?)?;
cas(tx.execute("DELETE FROM drive_nodes WHERE id=?1",params![id.0])?)?;
MutationResult { node:old,deleted:true }
}
};
@@ -447,13 +428,13 @@ impl Drive {
})
}
/// Each chunk requires the originally selected revision. Old revisions are
/// Each chunk requires the originally observed committed mutation. Old blobs are
/// not retained: after update/delete return conflict/not-found, never switch
/// silently to the latest blob. GC cannot run while this read holds SQL lock.
pub fn read(
&self,
id: NodeId,
revision: Revision,
expected_mutation_id: &str,
offset: u64,
length: usize,
) -> Result<ReadChunk> {
@@ -462,7 +443,7 @@ impl Drive {
self.authorize(tx, false)?;
accepting(tx)?;
let selected = node(tx, id)?;
if selected.revision != revision {
if selected.last_mutation_id != expected_mutation_id {
return Err(Error::Conflict);
}
if selected.kind != Kind::File {
@@ -486,7 +467,7 @@ impl Drive {
}
Ok(ReadChunk {
id,
revision,
last_mutation_id: selected.last_mutation_id,
offset,
bytes,
eof: end == selected.size,
@@ -499,17 +480,17 @@ impl Drive {
pub fn read_text(
&self,
id: NodeId,
revision: Revision,
expected_mutation_id: &str,
offset: u64,
length: usize,
) -> Result<TextChunk> {
let chunk = self.read(id, revision, offset, length)?;
let chunk = self.read(id, expected_mutation_id, offset, length)?;
let text = String::from_utf8(chunk.bytes).map_err(|_| {
Error::Invalid("range is not valid UTF-8; select character-aligned offsets".into())
})?;
Ok(TextChunk {
id,
revision,
last_mutation_id: chunk.last_mutation_id,
offset,
text,
eof: chunk.eof,
@@ -693,9 +674,9 @@ fn accepting(c: &Connection) -> Result<()> {
Ok(())
}
fn node(c: &Connection, id: NodeId) -> Result<Node> {
c.query_row("SELECT id,workspace_id,parent_id,name,kind,revision,size,content_type,updated_by,updated_at_ms FROM drive_nodes WHERE id=?1",[id.0],|r| {
c.query_row("SELECT id,workspace_id,parent_id,name,kind,last_mutation_id,size,content_type,updated_by,updated_at_ms FROM drive_nodes WHERE id=?1",[id.0],|r| {
Ok(Node {id:NodeId(r.get(0)?),workspace_id:r.get(1)?,parent_id:r.get::<_,Option<i64>>(2)?.map(NodeId),name:r.get(3)?,
kind:if r.get::<_,String>(4)?=="file" {Kind::File} else {Kind::Directory},revision:Revision(r.get(5)?),
kind:if r.get::<_,String>(4)?=="file" {Kind::File} else {Kind::Directory},last_mutation_id:r.get(5)?,
size:r.get::<_,i64>(6)? as u64,content_type:r.get(7)?,updated_by:r.get(8)?,updated_at_ms:r.get(9)?})
}).optional()?.ok_or(Error::NotFound)
}
@@ -706,17 +687,18 @@ fn directory(c: &Connection, id: NodeId) -> Result<()> {
Ok(())
}
}
fn changeable(c: &Connection, id: NodeId, revision: Revision) -> Result<Node> {
// Called only inside the same IMMEDIATE transaction that publishes the mutation.
// Compare the last successful request, not time: even identical bytes or a move
// away and back cannot revive an old request. Receipts prevent request ID reuse.
fn changeable(c: &Transaction<'_>, id: NodeId, expected_mutation_id: &str) -> Result<Node> {
let n = node(c, id)?;
if n.parent_id.is_none() {
return Err(Error::Invalid("root is immutable".into()));
}
if n.revision != revision {
validate_identity(expected_mutation_id, "expected_mutation_id", 128)?;
if n.last_mutation_id != expected_mutation_id {
return Err(Error::Conflict);
}
if revision.0 == i64::MAX {
return Err(Error::Invalid("revision exhausted".into()));
}
Ok(n)
}
fn unique(result: rusqlite::Result<usize>) -> Result<usize> {
@@ -748,9 +730,10 @@ fn insert(
content_type: Option<&str>,
actor: &str,
at: i64,
request_id: &str,
) -> Result<MutationResult> {
unique(tx.execute("INSERT INTO drive_nodes(workspace_id,parent_id,name,kind,revision,blob_key,size,content_type,updated_by,updated_at_ms)
VALUES (?1,?2,?3,?4,1,?5,?6,?7,?8,?9)",params![workspace,parent.0,name,if kind==Kind::File {"file"} else {"directory"},key,size,content_type,actor,at]))?;
unique(tx.execute("INSERT INTO drive_nodes(workspace_id,parent_id,name,kind,last_mutation_id,blob_key,size,content_type,updated_by,updated_at_ms)
VALUES (?1,?2,?3,?4,?10,?5,?6,?7,?8,?9)",params![workspace,parent.0,name,if kind==Kind::File {"file"} else {"directory"},key,size,content_type,actor,at,request_id]))?;
Ok(MutationResult {
node: node(tx, NodeId(tx.last_insert_rowid()))?,
deleted: false,
+665
View File
@@ -0,0 +1,665 @@
//! Frozen schema upgrades. Old column/JSON names occur only to preserve existing data.
use crate::{Mutation, NodeId};
use feature_storage::FeatureMigration;
use rusqlite::{OptionalExtension, Transaction};
use serde::Serialize;
use sha2::{Digest, Sha256};
pub(super) static MIGRATIONS: &[FeatureMigration] = &[
FeatureMigration::new(1, "drive_tree_and_receipts", create_original_schema),
FeatureMigration::new(
2,
"bind_nodes_to_committed_requests",
bind_committed_requests,
),
];
fn create_original_schema(tx: &Transaction<'_>) -> feature_storage::Result<()> {
tx.execute_batch("\
CREATE TABLE drive_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1),
workspace_id TEXT NOT NULL UNIQUE, deleting INTEGER NOT NULL DEFAULT 0 CHECK(deleting IN (0,1)));
CREATE TABLE drive_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id TEXT NOT NULL REFERENCES drive_state(workspace_id),
parent_id INTEGER,
name TEXT NOT NULL CHECK(length(CAST(name AS BLOB)) <= 255),
kind TEXT NOT NULL CHECK(kind IN ('file','directory')),
revision INTEGER NOT NULL CHECK(revision > 0),
blob_key TEXT, size INTEGER NOT NULL CHECK(size >= 0 AND size <= 16777216),
content_type TEXT, updated_by TEXT NOT NULL, updated_at_ms INTEGER NOT NULL,
UNIQUE(workspace_id,id),
FOREIGN KEY(workspace_id,parent_id) REFERENCES drive_nodes(workspace_id,id),
CHECK((kind='directory' AND blob_key IS NULL AND size=0 AND content_type IS NULL)
OR (kind='file' AND blob_key IS NOT NULL AND content_type IS NOT NULL)));
CREATE UNIQUE INDEX drive_sibling_name ON drive_nodes(workspace_id,ifnull(parent_id,0),name);
CREATE INDEX drive_children ON drive_nodes(parent_id,id);
CREATE INDEX drive_blob_reference ON drive_nodes(blob_key) WHERE blob_key IS NOT NULL;
CREATE UNIQUE INDEX drive_single_root ON drive_nodes(workspace_id) WHERE parent_id IS NULL;
CREATE TABLE drive_receipts (request_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL,
result_json TEXT NOT NULL, committed_at_ms INTEGER NOT NULL);
")?;
Ok(())
}
fn bind_committed_requests(tx: &Transaction<'_>) -> feature_storage::Result<()> {
// Materialize the original receipt correspondence once. In particular, a
// deletion receipt contains the pre-delete node, so it refers to the earlier
// create/update request, not to the deletion request itself. Equal timestamps
// do not matter. Missing/ambiguous evidence fails constraints and rolls back.
// Keep only proven predecessor correspondence for legacy digest verification.
// No request payload is stored: fingerprints remain untouched. A missing
// predecessor leaves replay unavailable, but the migrated result recoverable
// through request_status. This evidence never authorizes a new mutation.
tx.execute_batch("\
CREATE TEMP TABLE drive_observed_requests (
node_id INTEGER NOT NULL, previous_counter INTEGER NOT NULL,
request_id TEXT NOT NULL, matches INTEGER NOT NULL CHECK(matches=1),
PRIMARY KEY(node_id,previous_counter));
INSERT INTO drive_observed_requests
SELECT CAST(json_extract(result_json, '$.node.id') AS INTEGER),
CAST(json_extract(result_json, '$.node.revision') AS INTEGER),
min(request_id), count(*) FROM drive_receipts
WHERE json_extract(result_json, '$.deleted') = 0 GROUP BY 1,2;
CREATE TABLE drive_legacy_replays (
request_id TEXT PRIMARY KEY REFERENCES drive_receipts(request_id) ON DELETE CASCADE,
expected_mutation_id TEXT NOT NULL, expected_revision TEXT NOT NULL);
INSERT INTO drive_legacy_replays
SELECT receipt.request_id, observed.request_id, CAST(observed.previous_counter AS TEXT)
FROM drive_receipts AS receipt JOIN drive_observed_requests AS observed
ON observed.node_id = CAST(json_extract(receipt.result_json, '$.node.id') AS INTEGER)
AND observed.previous_counter = CAST(json_extract(receipt.result_json, '$.node.revision') AS INTEGER)
- CASE WHEN json_extract(receipt.result_json, '$.deleted') = 1 THEN 0 ELSE 1 END;
ALTER TABLE drive_nodes ADD COLUMN last_mutation_id TEXT NOT NULL DEFAULT '';
UPDATE drive_nodes SET last_mutation_id = (
SELECT request_id FROM drive_observed_requests
WHERE node_id = drive_nodes.id AND previous_counter = drive_nodes.revision
) WHERE parent_id IS NOT NULL;
UPDATE drive_receipts SET result_json = (
SELECT json_remove(json_set(result_json, '$.node.last_mutation_id', observed.request_id), '$.node.revision')
FROM drive_observed_requests AS observed
WHERE observed.node_id = CAST(json_extract(result_json, '$.node.id') AS INTEGER)
AND observed.previous_counter = CAST(json_extract(result_json, '$.node.revision') AS INTEGER)
);
ALTER TABLE drive_nodes DROP COLUMN revision;
DROP TABLE drive_observed_requests;
")?;
Ok(())
}
// Frozen pre-cutover typed payload encoding, including field order and decimal
// strings. Never reconstruct a fingerprint from the result: compare the caller's
// complete actor/payload against the original digest. Historical counters below
// are replay-only evidence and must never participate in live node CAS.
#[derive(Serialize)]
#[serde(tag = "operation", rename_all = "snake_case")]
enum LegacyMutation<'a> {
Update {
id: NodeId,
expected_revision: &'a str,
content_type: &'a str,
bytes: &'a [u8],
},
Relocate {
id: NodeId,
expected_revision: &'a str,
parent: NodeId,
name: &'a str,
},
Delete {
id: NodeId,
expected_revision: &'a str,
},
}
pub(super) fn matches_legacy_replay(
tx: &Transaction<'_>,
request_id: &str,
actor: &str,
mutation: &Mutation,
fingerprint: &str,
) -> crate::Result<bool> {
let evidence = tx
.query_row(
"SELECT expected_mutation_id,expected_revision FROM drive_legacy_replays WHERE request_id=?1",
[request_id],
|r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
)
.optional()?;
let Some((expected_request, counter)) = evidence else {
// Missing prior receipt: do not guess. request_status still recovers the
// committed result, while mutate rejects this identity without execution.
return Ok(false);
};
let (expected, legacy) = match mutation {
Mutation::Update {
id,
expected_mutation_id,
content_type,
bytes,
} => (
expected_mutation_id,
LegacyMutation::Update {
id: *id,
expected_revision: &counter,
content_type,
bytes,
},
),
Mutation::Relocate {
id,
expected_mutation_id,
parent,
name,
} => (
expected_mutation_id,
LegacyMutation::Relocate {
id: *id,
expected_revision: &counter,
parent: *parent,
name,
},
),
Mutation::Delete {
id,
expected_mutation_id,
} => (
expected_mutation_id,
LegacyMutation::Delete {
id: *id,
expected_revision: &counter,
},
),
Mutation::CreateFolder { .. } | Mutation::CreateFile { .. } => return Ok(false),
};
if expected != &expected_request {
return Ok(false);
}
let candidate: String = Sha256::digest(serde_json::to_vec(&(actor, legacy))?)
.iter()
.map(|b| format!("{b:02x}"))
.collect();
Ok(candidate == fingerprint)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Drive, Mutation, NodeId, RequestStatus};
use feature_storage::{FeatureRegistration, FeatureStorage};
use rusqlite::params;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::path::Path;
fn seed_old(path: &Path, copies_of_current_receipt: usize) {
let storage = FeatureStorage::new(path).workspace("ws").unwrap();
let registration = storage
.register(FeatureRegistration::new(
"workspace-drive",
&MIGRATIONS[..1],
))
.unwrap();
let database = storage.open(&registration).unwrap();
let key = "00000000-0000-0000-0000-000000000004";
crate::blob::BlobStore::open(&path.parent().unwrap().join("blobs"))
.unwrap()
.put(key, b"persisted-file")
.unwrap();
database.try_transaction::<_, feature_storage::FeatureStorageError>(|tx| {
tx.execute_batch("INSERT INTO drive_state(singleton,workspace_id) VALUES(1,'ws');
INSERT INTO drive_nodes(id,workspace_id,parent_id,name,kind,revision,size,updated_by,updated_at_ms)
VALUES(1,'ws',NULL,'','directory',1,0,'server',42),
(2,'ws',1,'after','directory',2,0,'actor',42),
(3,'ws',1,'deleted','directory',1,0,'actor',42);
DELETE FROM drive_nodes WHERE id=3;")?;
let receipt = |request: &str, id: &str, name: &str, number: i64, deleted: bool| -> feature_storage::Result<()> {
// Literal frozen typed JSON keeps this fixture independent of the
// production legacy serializer (field order is part of the digest).
let payload = if deleted {
format!(r#"["actor",{{"operation":"delete","id":"{id}","expected_revision":"{number}"}}]"#)
} else if number > 1 {
format!(r#"["actor",{{"operation":"relocate","id":"{id}","expected_revision":"{}","parent":"1","name":"{name}"}}]"#, number - 1)
} else {
format!(r#"["actor",{{"operation":"create_folder","parent":"1","name":"{name}"}}]"#)
};
let fingerprint = digest(payload.as_bytes());
let result = json!({"node":{"id":id,"workspace_id":"ws","parent_id":"1","name":name,
"kind":"directory","revision":number.to_string(),"size":0,"content_type":null,
"updated_by":"actor","updated_at_ms":42},"deleted":deleted});
tx.execute("INSERT INTO drive_receipts VALUES(?1,?2,?3,42)", params![request,fingerprint,result.to_string()])?;
Ok(())
};
receipt("create", "2", "before", 1, false)?;
for index in 0..copies_of_current_receipt {
receipt(&format!("move-{index}"), "2", "after", 2, false)?;
}
receipt("create-deleted", "3", "deleted", 1, false)?;
receipt("delete", "3", "deleted", 1, true)?;
tx.execute("INSERT INTO drive_nodes(id,workspace_id,parent_id,name,kind,revision,blob_key,size,content_type,updated_by,updated_at_ms)
VALUES(4,'ws',1,'file','file',1,?1,14,'text/plain','actor',42)", [key])?;
let file_result = json!({"node":{"id":"4","workspace_id":"ws","parent_id":"1","name":"file",
"kind":"file","revision":"1","size":14,"content_type":"text/plain",
"updated_by":"actor","updated_at_ms":42},"deleted":false});
let create_file = Mutation::CreateFile { parent: NodeId(1), name: "file".into(), content_type: "text/plain".into(), bytes: b"persisted-file".to_vec() };
let fingerprint = digest(&serde_json::to_vec(&("actor", create_file)).unwrap());
tx.execute("INSERT INTO drive_receipts VALUES('file-create',?1,?2,42)", params![fingerprint,file_result.to_string()])?;
Ok(())
}).unwrap();
}
#[test]
fn upgrade_uses_receipts_not_timestamps_and_preserves_replay_and_deleted_results() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("metadata");
seed_old(&path, 1);
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
let registration = Drive::register(&storage).unwrap();
let drive = Drive::open(&storage, &registration, &temp.path().join("blobs")).unwrap();
assert_eq!(drive.database.schema_version().unwrap(), 2);
assert_eq!(drive.root().unwrap().last_mutation_id, "");
let file = drive.metadata(NodeId(4)).unwrap();
assert_eq!(file.last_mutation_id, "file-create");
assert_eq!(
drive
.read(file.id, &file.last_mutation_id, 0, 100)
.unwrap()
.bytes,
b"persisted-file"
);
let current = drive.metadata(NodeId(2)).unwrap();
assert_eq!(current.name, "after");
assert_eq!(current.last_mutation_id, "move-0");
// Original create fingerprint is unchanged, and replay never overwrites the live node.
let replay = drive
.mutate(
"create",
"actor",
Mutation::CreateFolder {
parent: NodeId(1),
name: "before".into(),
},
)
.unwrap();
assert_eq!(replay.node.name, "before");
assert_eq!(replay.node.last_mutation_id, "create");
assert_eq!(drive.metadata(NodeId(2)).unwrap(), current);
let RequestStatus::Committed { result } = drive.request_status("delete").unwrap() else {
panic!("lost deletion receipt");
};
assert!(result.deleted);
assert_eq!(result.node.last_mutation_id, "create-deleted");
assert_eq!(result.node.id, NodeId(3));
assert!(matches!(
drive.metadata(NodeId(3)),
Err(crate::Error::NotFound)
));
let next = drive
.mutate(
"new",
"actor",
Mutation::CreateFolder {
parent: NodeId(1),
name: "new".into(),
},
)
.unwrap();
assert!(
next.node.id > NodeId(3),
"deleted IDs must not be reused after migration"
);
drive
.database
.try_with_connection::<_, feature_storage::FeatureStorageError>(|c| {
let count: i64 = c.query_row(
"SELECT count(*) FROM pragma_table_info('drive_nodes') WHERE name='revision'",
[],
|r| r.get(0),
)?;
assert_eq!(count, 0, "old counter must not remain in the live schema");
Ok(())
})
.unwrap();
}
fn digest(payload: &[u8]) -> String {
Sha256::digest(payload)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn seed_update(path: &Path, missing_predecessors: bool) {
seed_old(path, 1);
let storage = FeatureStorage::new(path).workspace("ws").unwrap();
let old = storage
.register(FeatureRegistration::new(
"workspace-drive",
&MIGRATIONS[..1],
))
.unwrap();
let database = storage.open(&old).unwrap();
database.try_transaction::<_, feature_storage::FeatureStorageError>(|tx| {
let result = json!({"node":{"id":"4","workspace_id":"ws","parent_id":"1","name":"file",
"kind":"file","revision":"2","size":14,"content_type":"text/plain",
"updated_by":"actor","updated_at_ms":42},"deleted":false});
let payload = br#"["actor",{"operation":"update","id":"4","expected_revision":"1","content_type":"text/plain","bytes":[112,101,114,115,105,115,116,101,100,45,102,105,108,101]}]"#;
tx.execute("INSERT INTO drive_receipts VALUES('file-update',?1,?2,42)", params![digest(payload), result.to_string()])?;
tx.execute("UPDATE drive_nodes SET revision=2 WHERE id=4", [])?;
if missing_predecessors {
tx.execute("DELETE FROM drive_receipts WHERE request_id IN ('file-create','create')", [])?;
}
Ok(())
}).unwrap();
}
fn changed_replays() -> Vec<(&'static str, Mutation)> {
vec![
(
"file-update",
Mutation::Update {
id: NodeId(4),
expected_mutation_id: "file-create".into(),
content_type: "text/plain".into(),
bytes: b"persisted-file".to_vec(),
},
),
(
"move-0",
Mutation::Relocate {
id: NodeId(2),
expected_mutation_id: "create".into(),
parent: NodeId(1),
name: "after".into(),
},
),
(
"delete",
Mutation::Delete {
id: NodeId(3),
expected_mutation_id: "create-deleted".into(),
},
),
]
}
fn fingerprints(database: &feature_storage::FeatureDatabase) -> Vec<(String, String)> {
database
.try_with_connection::<_, feature_storage::FeatureStorageError>(|c| {
let mut stmt = c.prepare(
"SELECT request_id,fingerprint FROM drive_receipts ORDER BY request_id",
)?;
Ok(stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<rusqlite::Result<Vec<_>>>()?)
})
.unwrap()
}
#[test]
fn upgrade_preserves_original_digests_and_never_uses_old_counters_for_new_mutations() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("metadata");
seed_update(&path, false);
let original = {
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
let old = storage
.register(FeatureRegistration::new(
"workspace-drive",
&MIGRATIONS[..1],
))
.unwrap();
fingerprints(&storage.open(&old).unwrap())
};
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
let registration = Drive::register(&storage).unwrap();
let drive = Drive::open(&storage, &registration, &temp.path().join("blobs")).unwrap();
assert_eq!(fingerprints(&drive.database), original);
assert!(matches!(
drive.mutate(
"new-update",
"actor",
Mutation::Update {
id: NodeId(4),
expected_mutation_id: "2".into(),
content_type: "text/plain".into(),
bytes: b"changed".to_vec(),
}
),
Err(crate::Error::Conflict)
));
assert_eq!(
drive.request_status("new-update").unwrap(),
RequestStatus::Uncommitted
);
assert_eq!(
drive.metadata(NodeId(4)).unwrap().last_mutation_id,
"file-update"
);
assert_eq!(fingerprints(&drive.database), original);
}
#[test]
fn migrated_update_relocate_delete_replay_original_results_without_reexecution() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("metadata");
seed_update(&path, false);
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
let registration = Drive::register(&storage).unwrap();
let drive = Drive::open(&storage, &registration, &temp.path().join("blobs")).unwrap();
assert_eq!(drive.database.schema_version().unwrap(), 2);
// Historical replay must not overwrite a newer committed mutation.
for (id, expected) in [(NodeId(2), "move-0"), (NodeId(4), "file-update")] {
drive
.mutate(
&format!("new-{}", id.get()),
"actor",
Mutation::Relocate {
id,
expected_mutation_id: expected.into(),
parent: NodeId(1),
name: format!("new-{}", id.get()),
},
)
.unwrap();
}
let before = drive.list(NodeId(1), None, 100).unwrap().nodes;
for (request, mutation) in changed_replays() {
let RequestStatus::Committed { result } = drive.request_status(request).unwrap() else {
panic!("lost {request}")
};
assert_eq!(
drive.mutate(request, "actor", mutation).unwrap(),
result,
"{request}"
);
assert_eq!(drive.list(NodeId(1), None, 100).unwrap().nodes, before);
}
// The unchanged CreateFile encoding remains replayable as well.
let created = drive
.mutate(
"file-create",
"actor",
Mutation::CreateFile {
parent: NodeId(1),
name: "file".into(),
content_type: "text/plain".into(),
bytes: b"persisted-file".to_vec(),
},
)
.unwrap();
assert_eq!(created.node.last_mutation_id, "file-create");
assert_eq!(
drive.collect(None, 100).unwrap().removed,
0,
"replay must not write blobs"
);
drop(drive);
let drive = Drive::open(&storage, &registration, &temp.path().join("blobs")).unwrap();
assert_eq!(drive.list(NodeId(1), None, 100).unwrap().nodes, before);
assert!(matches!(
drive.metadata(NodeId(3)),
Err(crate::Error::NotFound)
));
}
#[test]
fn migrated_receipts_reject_changed_actor_payload_or_precondition_without_side_effects() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("metadata");
seed_update(&path, false);
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
let registration = Drive::register(&storage).unwrap();
let drive = Drive::open(&storage, &registration, &temp.path().join("blobs")).unwrap();
let before = drive.list(NodeId(1), None, 100).unwrap().nodes;
for (request, mutation) in changed_replays() {
let status = drive.request_status(request).unwrap();
assert!(matches!(
drive.mutate(request, "other-actor", mutation.clone()),
Err(crate::Error::Conflict)
));
let mut changed = mutation.clone();
match &mut changed {
Mutation::Update { bytes, .. } => *bytes = b"changed".to_vec(),
Mutation::Relocate { name, .. } => *name = "changed".into(),
Mutation::Delete { id, .. } => *id = NodeId(2),
_ => unreachable!(),
}
assert!(matches!(
drive.mutate(request, "actor", changed),
Err(crate::Error::Conflict)
));
let mut changed = mutation;
match &mut changed {
Mutation::Update {
expected_mutation_id,
..
}
| Mutation::Relocate {
expected_mutation_id,
..
}
| Mutation::Delete {
expected_mutation_id,
..
} => *expected_mutation_id = "wrong-request".into(),
_ => unreachable!(),
}
assert!(matches!(
drive.mutate(request, "actor", changed),
Err(crate::Error::Conflict)
));
assert_eq!(drive.request_status(request).unwrap(), status);
}
assert_eq!(drive.list(NodeId(1), None, 100).unwrap().nodes, before);
assert_eq!(drive.collect(None, 100).unwrap().removed, 0);
}
#[test]
fn missing_prior_receipt_requires_status_recovery_and_never_reexecutes_mutation() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("metadata");
seed_update(&path, true);
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
let registration = Drive::register(&storage).unwrap();
let drive = Drive::open(&storage, &registration, &temp.path().join("blobs")).unwrap();
let before = drive.list(NodeId(1), None, 100).unwrap().nodes;
for (request, mutation) in changed_replays().into_iter().take(2) {
let status = drive.request_status(request).unwrap();
assert!(matches!(status, RequestStatus::Committed { .. }));
assert!(matches!(
drive.mutate(request, "actor", mutation.clone()),
Err(crate::Error::Conflict)
));
// Even substituting the *current* guard cannot reexecute an old identity.
let mut changed = mutation;
match &mut changed {
Mutation::Update {
expected_mutation_id,
bytes,
..
} => {
*expected_mutation_id = request.into();
*bytes = b"changed".to_vec();
}
Mutation::Relocate {
expected_mutation_id,
name,
..
} => {
*expected_mutation_id = request.into();
*name = "changed".into();
}
_ => unreachable!(),
}
assert!(matches!(
drive.mutate(request, "actor", changed),
Err(crate::Error::Conflict)
));
assert_eq!(drive.request_status(request).unwrap(), status);
}
assert_eq!(drive.list(NodeId(1), None, 100).unwrap().nodes, before);
assert_eq!(drive.collect(None, 100).unwrap().removed, 0);
}
#[test]
fn migrated_receipt_evidence_does_not_prevent_workspace_purge() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("metadata");
seed_update(&path, false);
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
let registration = Drive::register(&storage).unwrap();
let drive = Drive::open(&storage, &registration, &temp.path().join("blobs")).unwrap();
drive.purge().unwrap();
drop(drive);
let drive = Drive::open(&storage, &registration, &temp.path().join("blobs")).unwrap();
assert!(drive.list(NodeId(1), None, 100).unwrap().nodes.is_empty());
for (request, _) in changed_replays() {
assert_eq!(
drive.request_status(request).unwrap(),
RequestStatus::Uncommitted
);
}
drive
.database
.try_with_connection::<_, feature_storage::FeatureStorageError>(|c| {
let remaining: i64 =
c.query_row("SELECT count(*) FROM drive_legacy_replays", [], |r| {
r.get(0)
})?;
assert_eq!(remaining, 0);
Ok(())
})
.unwrap();
}
#[test]
fn missing_or_ambiguous_receipts_abort_upgrade_without_partial_schema_or_data_changes() {
for copies in [0, 2] {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("metadata");
seed_old(&path, copies);
{
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
let registration = Drive::register(&storage).unwrap();
assert!(storage.open(&registration).is_err());
}
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
let old = storage
.register(FeatureRegistration::new(
"workspace-drive",
&MIGRATIONS[..1],
))
.unwrap();
let database = storage.open(&old).unwrap();
assert_eq!(database.schema_version().unwrap(), 1);
database.try_with_connection::<_, feature_storage::FeatureStorageError>(|c| {
let row: (String, i64) = c.query_row("SELECT name,revision FROM drive_nodes WHERE id=2", [], |r| Ok((r.get(0)?,r.get(1)?)))?;
assert_eq!(row, ("after".into(), 2));
let added: i64 = c.query_row("SELECT count(*) FROM pragma_table_info('drive_nodes') WHERE name='last_mutation_id'", [], |r| r.get(0))?;
assert_eq!(added, 0);
let receipt: String = c.query_row("SELECT result_json FROM drive_receipts WHERE request_id='create'", [], |r| r.get(0))?;
assert_eq!(serde_json::from_str::<serde_json::Value>(&receipt).unwrap()["node"]["revision"], "1");
Ok(())
}).unwrap();
}
}
}
+18 -15
View File
@@ -20,7 +20,7 @@ fn create(parent: NodeId, name: &str) -> Mutation {
fn migration_enforces_workspace_parent_and_unique_names() {
let t = tempfile::tempdir().unwrap();
let d = fixture(&t);
assert_eq!(d.database.schema_version().unwrap(), 1);
assert_eq!(d.database.schema_version().unwrap(), 2);
let root = d.root().unwrap();
d.mutate("a", "actor", create(root.id, "a")).unwrap();
assert!(matches!(
@@ -28,7 +28,7 @@ fn migration_enforces_workspace_parent_and_unique_names() {
Err(Error::Conflict)
));
let result=d.database.try_transaction::<_,Error>(|tx| {
tx.execute("INSERT INTO drive_nodes(workspace_id,parent_id,name,kind,revision,size,updated_by,updated_at_ms)
tx.execute("INSERT INTO drive_nodes(workspace_id,parent_id,name,kind,last_mutation_id,size,updated_by,updated_at_ms)
VALUES ('other',?1,'bad','directory',1,0,'actor',0)",[root.id.0])?;
Ok(())
});
@@ -66,7 +66,7 @@ fn saved_blob_before_metadata_failure_is_not_published_and_restart_collects_it()
.mutate("failed", "actor", create(root.id, "failed"))
.unwrap();
assert_eq!(
d.read(result.node.id, result.node.revision, 0, 100)
d.read(result.node.id, &result.node.last_mutation_id, 0, 100)
.unwrap()
.bytes,
b"persisted"
@@ -117,10 +117,11 @@ fn collection_cannot_remove_in_flight_upload_or_selected_read() {
.node;
let go = Arc::new(std::sync::Barrier::new(2));
let g = go.clone();
let selected = first.clone();
let reader = std::thread::spawn(move || {
g.wait();
for _ in 0..20 {
match other.read(first.id, first.revision, 0, 100) {
match other.read(selected.id, &selected.last_mutation_id, 0, 100) {
Ok(chunk) => assert_eq!(chunk.bytes, b"persisted"),
Err(Error::Conflict) => {}
e => panic!("unexpected selected read outcome: {e:?}"),
@@ -137,7 +138,7 @@ fn collection_cannot_remove_in_flight_upload_or_selected_read() {
"actor",
Mutation::Update {
id: current.id,
expected_revision: current.revision,
expected_mutation_id: current.last_mutation_id.clone(),
content_type: "text/plain".into(),
bytes: vec![i; 1024],
},
@@ -149,7 +150,9 @@ fn collection_cannot_remove_in_flight_upload_or_selected_read() {
d.collect(None, 200).unwrap();
assert_eq!(d.blobs.list(None, 200).unwrap().len(), 1);
assert_eq!(
d.read(current.id, current.revision, 0, 1024).unwrap().bytes,
d.read(current.id, &current.last_mutation_id, 0, 1024)
.unwrap()
.bytes,
vec![19; 1024]
);
}
@@ -191,7 +194,7 @@ fn attached_server_authority_serializes_deletion_with_cached_drive_mutations() {
Err(Error::Fenced)
));
assert!(matches!(
d.read(child.id, child.revision, 0, 100),
d.read(child.id, &child.last_mutation_id, 0, 100),
Err(Error::Fenced)
));
assert!(matches!(d.collect(None, 10), Err(Error::Fenced)));
@@ -228,7 +231,7 @@ fn restarted_collection_removes_crashed_staging_without_removing_referenced_hard
let d = fixture(&t);
assert_eq!(d.collect(None, 200).unwrap().removed, 2);
assert_eq!(
d.read(n.id, n.revision, 0, 100).unwrap().bytes,
d.read(n.id, &n.last_mutation_id, 0, 100).unwrap().bytes,
b"persisted"
);
assert_eq!(std::fs::read_dir(blobs).unwrap().count(), 1);
@@ -257,7 +260,7 @@ fn interrupted_full_permission_and_sync_failed_uploads_do_not_publish_metadata_o
);
let mutation = Mutation::Update {
id: original.id,
expected_revision: original.revision,
expected_mutation_id: original.last_mutation_id.clone(),
content_type: "text/plain".into(),
bytes: b"failed replacement".to_vec(),
};
@@ -281,7 +284,7 @@ fn interrupted_full_permission_and_sync_failed_uploads_do_not_publish_metadata_o
d.blobs = Arc::new(blob::BlobStore::open(&t.path().join("blobs")).unwrap());
d.collect(None, 200).unwrap();
assert_eq!(
d.read(original.id, original.revision, 0, 100)
d.read(original.id, &original.last_mutation_id, 0, 100)
.unwrap()
.bytes,
b"persisted"
@@ -292,7 +295,7 @@ fn interrupted_full_permission_and_sync_failed_uploads_do_not_publish_metadata_o
);
let replaced = d.mutate("failed", "actor", mutation).unwrap().node;
assert_eq!(
d.read(replaced.id, replaced.revision, 0, 100)
d.read(replaced.id, &replaced.last_mutation_id, 0, 100)
.unwrap()
.bytes,
b"failed replacement"
@@ -360,12 +363,12 @@ fn bound_authorizer_checks_every_read_write_and_receipt_replay_without_caching()
assert!(bound.search("file", true, None, 10).is_ok());
assert!(
bound
.read(result.node.id, result.node.revision, 0, 100)
.read(result.node.id, &result.node.last_mutation_id, 0, 100)
.is_ok()
);
assert!(
bound
.read_text(result.node.id, result.node.revision, 0, 100)
.read_text(result.node.id, &result.node.last_mutation_id, 0, 100)
.is_ok()
);
assert!(bound.request_status("request").is_ok());
@@ -388,11 +391,11 @@ fn bound_authorizer_checks_every_read_write_and_receipt_replay_without_caching()
Err(Error::Denied)
));
assert!(matches!(
clone.read(result.node.id, result.node.revision, 0, 100),
clone.read(result.node.id, &result.node.last_mutation_id, 0, 100),
Err(Error::Denied)
));
assert!(matches!(
clone.read_text(result.node.id, result.node.revision, 0, 100),
clone.read_text(result.node.id, &result.node.last_mutation_id, 0, 100),
Err(Error::Denied)
));
assert!(matches!(
+92 -29
View File
@@ -46,12 +46,14 @@ fn file(
.node
}
fn read(d: &Drive, n: &Node) -> Vec<u8> {
d.read(n.id, n.revision, 0, MAX_READ_BYTES).unwrap().bytes
d.read(n.id, &n.last_mutation_id, 0, MAX_READ_BYTES)
.unwrap()
.bytes
}
fn update(n: &Node, bytes: &[u8]) -> Mutation {
Mutation::Update {
id: n.id,
expected_revision: n.revision,
expected_mutation_id: n.last_mutation_id.clone(),
content_type: "text/markdown".into(),
bytes: bytes.to_vec(),
}
@@ -117,7 +119,7 @@ fn relocate_and_paged_list_only_use_metadata_even_when_blob_is_missing() {
"account",
Mutation::Relocate {
id: f.id,
expected_revision: f.revision,
expected_mutation_id: f.last_mutation_id.clone(),
parent: b.id,
name: "改名".into(),
},
@@ -133,7 +135,7 @@ fn relocate_and_paged_list_only_use_metadata_even_when_blob_is_missing() {
std::fs::remove_file(blobs.join(&before[0])).unwrap();
assert_eq!(d.list(b.id, None, 1).unwrap().nodes, vec![moved.clone()]);
assert!(matches!(
d.read(moved.id, moved.revision, 0, 1),
d.read(moved.id, &moved.last_mutation_id, 0, 1),
Err(Error::Blob(_))
));
let first = d.list(root.id, None, 1).unwrap();
@@ -144,7 +146,7 @@ fn relocate_and_paged_list_only_use_metadata_even_when_blob_is_missing() {
}
#[test]
fn parallel_revision_and_name_competitors_use_database_authority() {
fn parallel_last_mutation_id_and_name_competitors_use_database_authority() {
let t = tempfile::tempdir().unwrap();
let db = t.path().join("db");
let blobs = t.path().join("blobs");
@@ -215,7 +217,7 @@ fn concurrent_cycle_moves_and_parent_delete_child_create_preserve_tree() {
"account",
Mutation::Relocate {
id: b2.id,
expected_revision: b2.revision,
expected_mutation_id: b2.last_mutation_id.clone(),
parent: aa.id,
name: "b".into(),
},
@@ -227,7 +229,7 @@ fn concurrent_cycle_moves_and_parent_delete_child_create_preserve_tree() {
"account",
Mutation::Relocate {
id: a.id,
expected_revision: a.revision,
expected_mutation_id: a.last_mutation_id.clone(),
parent: b.id,
name: "a".into(),
},
@@ -255,7 +257,7 @@ fn concurrent_cycle_moves_and_parent_delete_child_create_preserve_tree() {
"account",
Mutation::Delete {
id: p.id,
expected_revision: p.revision,
expected_mutation_id: p.last_mutation_id.clone(),
},
);
let created = worker.join().unwrap();
@@ -308,7 +310,7 @@ fn receipts_survive_response_loss_and_restart_without_double_mutation() {
}
#[test]
fn deletion_recreation_does_not_reuse_id_or_silently_switch_read_revision() {
fn deletion_recreation_does_not_reuse_id_or_silently_switch_read_last_mutation_id() {
let t = tempfile::tempdir().unwrap();
let d = open(&t.path().join("db"), &t.path().join("blobs"), "ws");
let root = d.root().unwrap();
@@ -318,7 +320,7 @@ fn deletion_recreation_does_not_reuse_id_or_silently_switch_read_revision() {
.unwrap()
.node;
assert!(matches!(
d.read(old.id, old.revision, 0, 1),
d.read(old.id, &old.last_mutation_id, 0, 1),
Err(Error::Conflict)
));
d.mutate(
@@ -326,7 +328,7 @@ fn deletion_recreation_does_not_reuse_id_or_silently_switch_read_revision() {
"account",
Mutation::Delete {
id: next.id,
expected_revision: next.revision,
expected_mutation_id: next.last_mutation_id.clone(),
},
)
.unwrap();
@@ -366,11 +368,11 @@ fn bounded_search_and_read_reject_paths_oversize_and_invalid_parents() {
);
let g = file(&d, &root, "g", "needle.png", "image/png", b"needle");
assert!(matches!(
d.read(f.id, f.revision, 0, MAX_READ_BYTES + 1),
d.read(f.id, &f.last_mutation_id, 0, MAX_READ_BYTES + 1),
Err(Error::Invalid(_))
));
assert!(matches!(
d.read(f.id, f.revision, f.size + 1, 1),
d.read(f.id, &f.last_mutation_id, f.size + 1, 1),
Err(Error::Invalid(_))
));
assert!(matches!(
@@ -434,7 +436,7 @@ fn fence_purge_restart_and_workspace_boundaries_are_persistent() {
}
#[test]
fn stopped_writer_backup_restores_ids_revisions_receipts_and_sequence_with_blobs() {
fn stopped_writer_backup_restores_ids_last_mutation_ids_receipts_and_sequence_with_blobs() {
let t = tempfile::tempdir().unwrap();
let metadata = t.path().join("metadata");
let blobs = t.path().join("blobs");
@@ -454,7 +456,7 @@ fn stopped_writer_backup_restores_ids_revisions_receipts_and_sequence_with_blobs
"account",
Mutation::Delete {
id: deleted.id,
expected_revision: deleted.revision,
expected_mutation_id: deleted.last_mutation_id.clone(),
},
)
.unwrap();
@@ -486,15 +488,10 @@ fn stopped_writer_backup_restores_ids_revisions_receipts_and_sequence_with_blobs
}
#[test]
fn external_ids_and_revisions_keep_full_integer_precision() {
fn external_node_ids_keep_full_integer_precision() {
let max = "9223372036854775807";
let id: workspace_drive::NodeId = serde_json::from_str(&format!("\"{max}\"")).unwrap();
let revision: workspace_drive::Revision = serde_json::from_str(&format!("\"{max}\"")).unwrap();
assert_eq!(serde_json::to_string(&id).unwrap(), format!("\"{max}\""));
assert_eq!(
serde_json::to_string(&revision).unwrap(),
format!("\"{max}\"")
);
for bad in ["0", "-1", "01", "9223372036854775808"] {
assert!(serde_json::from_str::<workspace_drive::NodeId>(&format!("\"{bad}\"")).is_err());
}
@@ -508,9 +505,12 @@ fn text_read_is_bounded_utf8_and_referenced_size_corruption_is_diagnosed() {
let d = open(&t.path().join("db"), &blobs, "ws");
let root = d.root().unwrap();
let f = file(&d, &root, "f", "f", "text/plain", "日本語".as_bytes());
assert_eq!(d.read_text(f.id, f.revision, 0, 3).unwrap().text, "日");
assert_eq!(
d.read_text(f.id, &f.last_mutation_id, 0, 3).unwrap().text,
"日"
);
assert!(matches!(
d.read_text(f.id, f.revision, 1, 2),
d.read_text(f.id, &f.last_mutation_id, 1, 2),
Err(Error::Invalid(_))
));
let path = std::fs::read_dir(&blobs)
@@ -521,7 +521,7 @@ fn text_read_is_bounded_utf8_and_referenced_size_corruption_is_diagnosed() {
.path();
std::fs::write(&path, b"corrupt length").unwrap();
assert!(matches!(
d.read(f.id, f.revision, 0, 1),
d.read(f.id, &f.last_mutation_id, 0, 1),
Err(Error::Invalid(_))
));
assert!(matches!(
@@ -537,7 +537,7 @@ fn text_read_is_bounded_utf8_and_referenced_size_corruption_is_diagnosed() {
}
}
assert!(matches!(
d.read(empty.id, empty.revision, 0, 1),
d.read(empty.id, &empty.last_mutation_id, 0, 1),
Err(Error::Blob(_))
));
}
@@ -573,7 +573,7 @@ fn simultaneous_same_request_replays_one_create_and_folder_deletion_is_empty_onl
"account",
Mutation::Delete {
id: one.node.id,
expected_revision: one.node.revision
expected_mutation_id: one.node.last_mutation_id
}
),
Err(Error::Conflict)
@@ -585,7 +585,7 @@ fn simultaneous_same_request_replays_one_create_and_folder_deletion_is_empty_onl
"account",
Mutation::Delete {
id: root.id,
expected_revision: root.revision
expected_mutation_id: root.last_mutation_id
}
),
Err(Error::Invalid(_))
@@ -596,7 +596,7 @@ fn simultaneous_same_request_replays_one_create_and_folder_deletion_is_empty_onl
"account",
Mutation::Relocate {
id: child.id,
expected_revision: child.revision,
expected_mutation_id: child.last_mutation_id.clone(),
parent: root.id,
name: "child".into(),
},
@@ -609,10 +609,73 @@ fn simultaneous_same_request_replays_one_create_and_folder_deletion_is_empty_onl
"account",
Mutation::Delete {
id: child.id,
expected_revision: child.revision
expected_mutation_id: child.last_mutation_id
}
),
Err(Error::Conflict)
));
assert_eq!(d.metadata(changed.id).unwrap(), changed);
}
#[test]
fn identical_writes_and_move_away_back_do_not_revive_old_observations() {
let t = tempfile::tempdir().unwrap();
let d = open(&t.path().join("db"), &t.path().join("blobs"), "ws");
let root = d.root().unwrap();
let first = file(
&d,
&root,
"create-資料",
"original",
"text/markdown",
b"same",
);
assert_eq!(first.last_mutation_id, "create-資料");
let identical = d
.mutate("same-content", "account", update(&first, b"same"))
.unwrap()
.node;
assert_eq!(identical.last_mutation_id, "same-content");
assert!(matches!(
d.read(first.id, &first.last_mutation_id, 0, 4),
Err(Error::Conflict)
));
let mut current = identical;
for (request, name) in [("away", "temporary"), ("back", "original")] {
current = d
.mutate(
request,
"account",
Mutation::Relocate {
id: current.id,
expected_mutation_id: current.last_mutation_id,
parent: root.id,
name: name.into(),
},
)
.unwrap()
.node;
}
assert_eq!(current.name, first.name);
assert_eq!(current.last_mutation_id, "back");
assert!(matches!(
d.mutate(
"stale-delete",
"account",
Mutation::Delete {
id: first.id,
expected_mutation_id: first.last_mutation_id,
}
),
Err(Error::Conflict)
));
assert!(
matches!(
d.mutate("same-content", "account", update(&current, b"same")),
Err(Error::Conflict)
),
"reusing a receipt ID with another observation must not publish"
);
assert_eq!(d.metadata(current.id).unwrap(), current);
assert_eq!(read(&d, &current), b"same");
}