mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-04 01:06:55 +00:00
First version of pds migration
This commit is contained in:
+8
-6
@@ -1,4 +1,5 @@
|
||||
use cid::Cid;
|
||||
use iroh_car::CarHeader;
|
||||
use std::io::Write;
|
||||
|
||||
pub fn write_varint<W: Write>(mut writer: W, mut value: u64) -> std::io::Result<()> {
|
||||
@@ -23,10 +24,11 @@ pub fn ld_write<W: Write>(mut writer: W, data: &[u8]) -> std::io::Result<()> {
|
||||
}
|
||||
|
||||
pub fn encode_car_header(root_cid: &Cid) -> Vec<u8> {
|
||||
let header = serde_ipld_dagcbor::to_vec(&serde_json::json!({
|
||||
"version": 1u64,
|
||||
"roots": [root_cid.to_bytes()]
|
||||
}))
|
||||
.unwrap_or_default();
|
||||
header
|
||||
let header = CarHeader::new_v1(vec![root_cid.clone()]);
|
||||
let header_cbor = header.encode().unwrap_or_default();
|
||||
|
||||
let mut result = Vec::new();
|
||||
write_varint(&mut result, header_cbor.len() as u64).unwrap();
|
||||
result.extend_from_slice(&header_cbor);
|
||||
result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
use bytes::Bytes;
|
||||
use cid::Cid;
|
||||
use ipld_core::ipld::Ipld;
|
||||
use iroh_car::CarReader;
|
||||
use serde_json::Value as JsonValue;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use thiserror::Error;
|
||||
use tracing::debug;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ImportError {
|
||||
#[error("CAR parsing error: {0}")]
|
||||
CarParse(String),
|
||||
#[error("Expected exactly one root in CAR file")]
|
||||
InvalidRootCount,
|
||||
#[error("Block not found: {0}")]
|
||||
BlockNotFound(String),
|
||||
#[error("Invalid CBOR: {0}")]
|
||||
InvalidCbor(String),
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error("Block store error: {0}")]
|
||||
BlockStore(String),
|
||||
#[error("Import size limit exceeded")]
|
||||
SizeLimitExceeded,
|
||||
#[error("Repo not found")]
|
||||
RepoNotFound,
|
||||
#[error("Concurrent modification detected")]
|
||||
ConcurrentModification,
|
||||
#[error("Invalid commit structure: {0}")]
|
||||
InvalidCommit(String),
|
||||
#[error("Verification failed: {0}")]
|
||||
VerificationFailed(#[from] super::verify::VerifyError),
|
||||
#[error("DID mismatch: CAR is for {car_did}, but authenticated as {auth_did}")]
|
||||
DidMismatch { car_did: String, auth_did: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlobRef {
|
||||
pub cid: String,
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn parse_car(data: &[u8]) -> Result<(Cid, HashMap<Cid, Bytes>), ImportError> {
|
||||
let cursor = Cursor::new(data);
|
||||
let mut reader = CarReader::new(cursor)
|
||||
.await
|
||||
.map_err(|e| ImportError::CarParse(e.to_string()))?;
|
||||
|
||||
let header = reader.header();
|
||||
let roots = header.roots();
|
||||
|
||||
if roots.len() != 1 {
|
||||
return Err(ImportError::InvalidRootCount);
|
||||
}
|
||||
|
||||
let root = roots[0];
|
||||
let mut blocks = HashMap::new();
|
||||
|
||||
while let Ok(Some((cid, block))) = reader.next_block().await {
|
||||
blocks.insert(cid, Bytes::from(block));
|
||||
}
|
||||
|
||||
if !blocks.contains_key(&root) {
|
||||
return Err(ImportError::BlockNotFound(root.to_string()));
|
||||
}
|
||||
|
||||
Ok((root, blocks))
|
||||
}
|
||||
|
||||
pub fn find_blob_refs_ipld(value: &Ipld, depth: usize) -> Vec<BlobRef> {
|
||||
if depth > 32 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
match value {
|
||||
Ipld::List(arr) => arr
|
||||
.iter()
|
||||
.flat_map(|v| find_blob_refs_ipld(v, depth + 1))
|
||||
.collect(),
|
||||
Ipld::Map(obj) => {
|
||||
if let Some(Ipld::String(type_str)) = obj.get("$type") {
|
||||
if type_str == "blob" {
|
||||
if let Some(Ipld::Link(link_cid)) = obj.get("ref") {
|
||||
let mime = obj
|
||||
.get("mimeType")
|
||||
.and_then(|v| if let Ipld::String(s) = v { Some(s.clone()) } else { None });
|
||||
return vec![BlobRef {
|
||||
cid: link_cid.to_string(),
|
||||
mime_type: mime,
|
||||
}];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
obj.values()
|
||||
.flat_map(|v| find_blob_refs_ipld(v, depth + 1))
|
||||
.collect()
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_blob_refs(value: &JsonValue, depth: usize) -> Vec<BlobRef> {
|
||||
if depth > 32 {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
match value {
|
||||
JsonValue::Array(arr) => arr
|
||||
.iter()
|
||||
.flat_map(|v| find_blob_refs(v, depth + 1))
|
||||
.collect(),
|
||||
JsonValue::Object(obj) => {
|
||||
if let Some(JsonValue::String(type_str)) = obj.get("$type") {
|
||||
if type_str == "blob" {
|
||||
if let Some(JsonValue::Object(ref_obj)) = obj.get("ref") {
|
||||
if let Some(JsonValue::String(link)) = ref_obj.get("$link") {
|
||||
let mime = obj
|
||||
.get("mimeType")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
return vec![BlobRef {
|
||||
cid: link.clone(),
|
||||
mime_type: mime,
|
||||
}];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
obj.values()
|
||||
.flat_map(|v| find_blob_refs(v, depth + 1))
|
||||
.collect()
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_links(value: &Ipld, links: &mut Vec<Cid>) {
|
||||
match value {
|
||||
Ipld::Link(cid) => {
|
||||
links.push(*cid);
|
||||
}
|
||||
Ipld::Map(map) => {
|
||||
for v in map.values() {
|
||||
extract_links(v, links);
|
||||
}
|
||||
}
|
||||
Ipld::List(arr) => {
|
||||
for v in arr {
|
||||
extract_links(v, links);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportedRecord {
|
||||
pub collection: String,
|
||||
pub rkey: String,
|
||||
pub cid: Cid,
|
||||
pub blob_refs: Vec<BlobRef>,
|
||||
}
|
||||
|
||||
pub fn walk_mst(
|
||||
blocks: &HashMap<Cid, Bytes>,
|
||||
root_cid: &Cid,
|
||||
) -> Result<Vec<ImportedRecord>, ImportError> {
|
||||
let mut records = Vec::new();
|
||||
let mut stack = vec![*root_cid];
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
|
||||
while let Some(cid) = stack.pop() {
|
||||
if visited.contains(&cid) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(cid);
|
||||
|
||||
let block = blocks
|
||||
.get(&cid)
|
||||
.ok_or_else(|| ImportError::BlockNotFound(cid.to_string()))?;
|
||||
|
||||
let value: Ipld = serde_ipld_dagcbor::from_slice(block)
|
||||
.map_err(|e| ImportError::InvalidCbor(e.to_string()))?;
|
||||
|
||||
if let Ipld::Map(ref obj) = value {
|
||||
if let Some(Ipld::List(entries)) = obj.get("e") {
|
||||
for entry in entries {
|
||||
if let Ipld::Map(entry_obj) = entry {
|
||||
let key = entry_obj.get("k").and_then(|k| {
|
||||
if let Ipld::Bytes(b) = k {
|
||||
String::from_utf8(b.clone()).ok()
|
||||
} else if let Ipld::String(s) = k {
|
||||
Some(s.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let record_cid = entry_obj.get("v").and_then(|v| {
|
||||
if let Ipld::Link(cid) = v {
|
||||
Some(*cid)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let (Some(key), Some(record_cid)) = (key, record_cid) {
|
||||
if let Some(record_block) = blocks.get(&record_cid) {
|
||||
if let Ok(record_value) =
|
||||
serde_ipld_dagcbor::from_slice::<Ipld>(record_block)
|
||||
{
|
||||
let blob_refs = find_blob_refs_ipld(&record_value, 0);
|
||||
|
||||
let parts: Vec<&str> = key.split('/').collect();
|
||||
if parts.len() >= 2 {
|
||||
let collection = parts[..parts.len() - 1].join("/");
|
||||
let rkey = parts[parts.len() - 1].to_string();
|
||||
|
||||
records.push(ImportedRecord {
|
||||
collection,
|
||||
rkey,
|
||||
cid: record_cid,
|
||||
blob_refs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") {
|
||||
stack.push(*tree_cid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(Ipld::Link(left_cid)) = obj.get("l") {
|
||||
stack.push(*left_cid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
pub struct CommitInfo {
|
||||
pub rev: Option<String>,
|
||||
pub prev: Option<String>,
|
||||
}
|
||||
|
||||
fn extract_commit_info(commit: &Ipld) -> Result<(Cid, CommitInfo), ImportError> {
|
||||
let obj = match commit {
|
||||
Ipld::Map(m) => m,
|
||||
_ => return Err(ImportError::InvalidCommit("Commit must be a map".to_string())),
|
||||
};
|
||||
|
||||
let data_cid = obj
|
||||
.get("data")
|
||||
.and_then(|d| if let Ipld::Link(cid) = d { Some(*cid) } else { None })
|
||||
.ok_or_else(|| ImportError::InvalidCommit("Missing data field".to_string()))?;
|
||||
|
||||
let rev = obj.get("rev").and_then(|r| {
|
||||
if let Ipld::String(s) = r {
|
||||
Some(s.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let prev = obj.get("prev").and_then(|p| {
|
||||
if let Ipld::Link(cid) = p {
|
||||
Some(cid.to_string())
|
||||
} else if let Ipld::Null = p {
|
||||
None
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Ok((data_cid, CommitInfo { rev, prev }))
|
||||
}
|
||||
|
||||
pub async fn apply_import(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
root: Cid,
|
||||
blocks: HashMap<Cid, Bytes>,
|
||||
max_blocks: usize,
|
||||
) -> Result<Vec<ImportedRecord>, ImportError> {
|
||||
if blocks.len() > max_blocks {
|
||||
return Err(ImportError::SizeLimitExceeded);
|
||||
}
|
||||
|
||||
let root_block = blocks
|
||||
.get(&root)
|
||||
.ok_or_else(|| ImportError::BlockNotFound(root.to_string()))?;
|
||||
let commit: Ipld = serde_ipld_dagcbor::from_slice(root_block)
|
||||
.map_err(|e| ImportError::InvalidCbor(e.to_string()))?;
|
||||
|
||||
let (data_cid, _commit_info) = extract_commit_info(&commit)?;
|
||||
|
||||
let records = walk_mst(&blocks, &data_cid)?;
|
||||
|
||||
debug!(
|
||||
"Importing {} blocks and {} records for user {}",
|
||||
blocks.len(),
|
||||
records.len(),
|
||||
user_id
|
||||
);
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let repo = sqlx::query!(
|
||||
"SELECT repo_root_cid FROM repos WHERE user_id = $1 FOR UPDATE NOWAIT",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let sqlx::Error::Database(ref db_err) = e {
|
||||
if db_err.code().as_deref() == Some("55P03") {
|
||||
return ImportError::ConcurrentModification;
|
||||
}
|
||||
}
|
||||
ImportError::Database(e)
|
||||
})?;
|
||||
|
||||
if repo.is_none() {
|
||||
return Err(ImportError::RepoNotFound);
|
||||
}
|
||||
|
||||
let block_chunks: Vec<Vec<(&Cid, &Bytes)>> = blocks
|
||||
.iter()
|
||||
.collect::<Vec<_>>()
|
||||
.chunks(100)
|
||||
.map(|c| c.to_vec())
|
||||
.collect();
|
||||
|
||||
for chunk in block_chunks {
|
||||
for (cid, data) in chunk {
|
||||
let cid_bytes = cid.to_bytes();
|
||||
sqlx::query!(
|
||||
"INSERT INTO blocks (cid, data) VALUES ($1, $2) ON CONFLICT (cid) DO NOTHING",
|
||||
&cid_bytes,
|
||||
data.as_ref()
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let root_str = root.to_string();
|
||||
sqlx::query!(
|
||||
"UPDATE repos SET repo_root_cid = $1, updated_at = NOW() WHERE user_id = $2",
|
||||
root_str,
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DELETE FROM records WHERE repo_id = $1", user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for record in &records {
|
||||
let record_cid_str = record.cid.to_string();
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO records (repo_id, collection, rkey, record_cid)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (repo_id, collection, rkey) DO UPDATE SET record_cid = $4
|
||||
"#,
|
||||
user_id,
|
||||
record.collection,
|
||||
record.rkey,
|
||||
record_cid_str
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
debug!(
|
||||
"Successfully imported {} blocks and {} records",
|
||||
blocks.len(),
|
||||
records.len()
|
||||
);
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_find_blob_refs() {
|
||||
let record = serde_json::json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Hello world",
|
||||
"embed": {
|
||||
"$type": "app.bsky.embed.images",
|
||||
"images": [
|
||||
{
|
||||
"alt": "Test image",
|
||||
"image": {
|
||||
"$type": "blob",
|
||||
"ref": {
|
||||
"$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
|
||||
},
|
||||
"mimeType": "image/jpeg",
|
||||
"size": 12345
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
let blob_refs = find_blob_refs(&record, 0);
|
||||
assert_eq!(blob_refs.len(), 1);
|
||||
assert_eq!(
|
||||
blob_refs[0].cid,
|
||||
"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
|
||||
);
|
||||
assert_eq!(blob_refs[0].mime_type, Some("image/jpeg".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_blob_refs_no_blobs() {
|
||||
let record = serde_json::json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": "Hello world"
|
||||
});
|
||||
|
||||
let blob_refs = find_blob_refs(&record, 0);
|
||||
assert!(blob_refs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_blob_refs_depth_limit() {
|
||||
fn deeply_nested(depth: usize) -> JsonValue {
|
||||
if depth == 0 {
|
||||
serde_json::json!({
|
||||
"$type": "blob",
|
||||
"ref": { "$link": "bafkreitest" },
|
||||
"mimeType": "image/png"
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "nested": deeply_nested(depth - 1) })
|
||||
}
|
||||
}
|
||||
|
||||
let deep = deeply_nested(40);
|
||||
let blob_refs = find_blob_refs(&deep, 0);
|
||||
assert!(blob_refs.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,17 @@ pub mod commit;
|
||||
pub mod crawl;
|
||||
pub mod firehose;
|
||||
pub mod frame;
|
||||
pub mod import;
|
||||
pub mod listener;
|
||||
pub mod relay_client;
|
||||
pub mod repo;
|
||||
pub mod subscribe_repos;
|
||||
pub mod util;
|
||||
pub mod verify;
|
||||
|
||||
pub use blob::{get_blob, list_blobs};
|
||||
pub use commit::{get_latest_commit, get_repo_status, list_repos};
|
||||
pub use crawl::{notify_of_update, request_crawl};
|
||||
pub use repo::{get_blocks, get_repo, get_record};
|
||||
pub use subscribe_repos::subscribe_repos;
|
||||
pub use verify::{CarVerifier, VerifiedCar, VerifyError};
|
||||
|
||||
+12
-18
@@ -7,6 +7,7 @@ use axum::{
|
||||
Json,
|
||||
};
|
||||
use cid::Cid;
|
||||
use ipld_core::ipld::Ipld;
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
@@ -165,8 +166,8 @@ pub async fn get_repo(
|
||||
writer.write_all(&block).unwrap();
|
||||
car_bytes.extend_from_slice(&writer);
|
||||
|
||||
if let Ok(value) = serde_ipld_dagcbor::from_slice::<serde_json::Value>(&block) {
|
||||
extract_links_json(&value, &mut stack);
|
||||
if let Ok(value) = serde_ipld_dagcbor::from_slice::<Ipld>(&block) {
|
||||
extract_links_ipld(&value, &mut stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,26 +180,19 @@ pub async fn get_repo(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn extract_links_json(value: &serde_json::Value, stack: &mut Vec<Cid>) {
|
||||
fn extract_links_ipld(value: &Ipld, stack: &mut Vec<Cid>) {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
if let Some(serde_json::Value::String(s)) = map.get("/") {
|
||||
if let Ok(cid) = Cid::from_str(s) {
|
||||
stack.push(cid);
|
||||
}
|
||||
} else if let Some(serde_json::Value::String(s)) = map.get("$link") {
|
||||
if let Ok(cid) = Cid::from_str(s) {
|
||||
stack.push(cid);
|
||||
}
|
||||
} else {
|
||||
for v in map.values() {
|
||||
extract_links_json(v, stack);
|
||||
}
|
||||
Ipld::Link(cid) => {
|
||||
stack.push(*cid);
|
||||
}
|
||||
Ipld::Map(map) => {
|
||||
for v in map.values() {
|
||||
extract_links_ipld(v, stack);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
Ipld::List(arr) => {
|
||||
for v in arr {
|
||||
extract_links_json(v, stack);
|
||||
extract_links_ipld(v, stack);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
use bytes::Bytes;
|
||||
use cid::Cid;
|
||||
use jacquard::common::types::crypto::PublicKey;
|
||||
use jacquard::common::types::did_doc::DidDocument;
|
||||
use jacquard::common::IntoStatic;
|
||||
use jacquard_repo::commit::Commit;
|
||||
use reqwest::Client;
|
||||
use std::collections::HashMap;
|
||||
use thiserror::Error;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum VerifyError {
|
||||
#[error("Invalid commit: {0}")]
|
||||
InvalidCommit(String),
|
||||
#[error("DID mismatch: commit has {commit_did}, expected {expected_did}")]
|
||||
DidMismatch {
|
||||
commit_did: String,
|
||||
expected_did: String,
|
||||
},
|
||||
#[error("Failed to resolve DID: {0}")]
|
||||
DidResolutionFailed(String),
|
||||
#[error("No signing key found in DID document")]
|
||||
NoSigningKey,
|
||||
#[error("Invalid signature")]
|
||||
InvalidSignature,
|
||||
#[error("MST validation failed: {0}")]
|
||||
MstValidationFailed(String),
|
||||
#[error("Block not found: {0}")]
|
||||
BlockNotFound(String),
|
||||
#[error("Invalid CBOR: {0}")]
|
||||
InvalidCbor(String),
|
||||
}
|
||||
|
||||
pub struct CarVerifier {
|
||||
http_client: Client,
|
||||
}
|
||||
|
||||
impl Default for CarVerifier {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CarVerifier {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
http_client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_car(
|
||||
&self,
|
||||
expected_did: &str,
|
||||
root_cid: &Cid,
|
||||
blocks: &HashMap<Cid, Bytes>,
|
||||
) -> Result<VerifiedCar, VerifyError> {
|
||||
let root_block = blocks
|
||||
.get(root_cid)
|
||||
.ok_or_else(|| VerifyError::BlockNotFound(root_cid.to_string()))?;
|
||||
|
||||
let commit = Commit::from_cbor(root_block)
|
||||
.map_err(|e| VerifyError::InvalidCommit(e.to_string()))?;
|
||||
|
||||
let commit_did = commit.did().as_str();
|
||||
if commit_did != expected_did {
|
||||
return Err(VerifyError::DidMismatch {
|
||||
commit_did: commit_did.to_string(),
|
||||
expected_did: expected_did.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let pubkey = self.resolve_did_signing_key(commit_did).await?;
|
||||
|
||||
commit
|
||||
.verify(&pubkey)
|
||||
.map_err(|_| VerifyError::InvalidSignature)?;
|
||||
|
||||
debug!("Commit signature verified for DID {}", commit_did);
|
||||
|
||||
let data_cid = commit.data();
|
||||
self.verify_mst_structure(data_cid, blocks)?;
|
||||
|
||||
debug!("MST structure verified for DID {}", commit_did);
|
||||
|
||||
Ok(VerifiedCar {
|
||||
did: commit_did.to_string(),
|
||||
rev: commit.rev().to_string(),
|
||||
data_cid: *data_cid,
|
||||
prev: commit.prev().cloned(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_did_signing_key(&self, did: &str) -> Result<PublicKey<'static>, VerifyError> {
|
||||
let did_doc = self.resolve_did_document(did).await?;
|
||||
|
||||
did_doc
|
||||
.atproto_public_key()
|
||||
.map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?
|
||||
.ok_or(VerifyError::NoSigningKey)
|
||||
}
|
||||
|
||||
async fn resolve_did_document(&self, did: &str) -> Result<DidDocument<'static>, VerifyError> {
|
||||
if did.starts_with("did:plc:") {
|
||||
self.resolve_plc_did(did).await
|
||||
} else if did.starts_with("did:web:") {
|
||||
self.resolve_web_did(did).await
|
||||
} else {
|
||||
Err(VerifyError::DidResolutionFailed(format!(
|
||||
"Unsupported DID method: {}",
|
||||
did
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_plc_did(&self, did: &str) -> Result<DidDocument<'static>, VerifyError> {
|
||||
let plc_url = std::env::var("PLC_DIRECTORY_URL")
|
||||
.unwrap_or_else(|_| "https://plc.directory".to_string());
|
||||
let url = format!("{}/{}", plc_url, urlencoding::encode(did));
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(VerifyError::DidResolutionFailed(format!(
|
||||
"PLC directory returned {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?;
|
||||
|
||||
let doc: DidDocument<'_> = serde_json::from_str(&body)
|
||||
.map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?;
|
||||
|
||||
Ok(doc.into_static())
|
||||
}
|
||||
|
||||
async fn resolve_web_did(&self, did: &str) -> Result<DidDocument<'static>, VerifyError> {
|
||||
let domain = did
|
||||
.strip_prefix("did:web:")
|
||||
.ok_or_else(|| VerifyError::DidResolutionFailed("Invalid did:web format".to_string()))?;
|
||||
|
||||
let domain_decoded = urlencoding::decode(domain)
|
||||
.map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?;
|
||||
|
||||
let url = if domain_decoded.contains(':') || domain_decoded.contains('/') {
|
||||
format!("https://{}/.well-known/did.json", domain_decoded)
|
||||
} else {
|
||||
format!("https://{}/.well-known/did.json", domain_decoded)
|
||||
};
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(VerifyError::DidResolutionFailed(format!(
|
||||
"did:web resolution returned {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?;
|
||||
|
||||
let doc: DidDocument<'_> = serde_json::from_str(&body)
|
||||
.map_err(|e| VerifyError::DidResolutionFailed(e.to_string()))?;
|
||||
|
||||
Ok(doc.into_static())
|
||||
}
|
||||
|
||||
fn verify_mst_structure(
|
||||
&self,
|
||||
data_cid: &Cid,
|
||||
blocks: &HashMap<Cid, Bytes>,
|
||||
) -> Result<(), VerifyError> {
|
||||
use ipld_core::ipld::Ipld;
|
||||
|
||||
let mut stack = vec![*data_cid];
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
let mut node_count = 0;
|
||||
const MAX_NODES: usize = 100_000;
|
||||
|
||||
while let Some(cid) = stack.pop() {
|
||||
if visited.contains(&cid) {
|
||||
continue;
|
||||
}
|
||||
visited.insert(cid);
|
||||
node_count += 1;
|
||||
|
||||
if node_count > MAX_NODES {
|
||||
return Err(VerifyError::MstValidationFailed(
|
||||
"MST exceeds maximum node count".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let block = blocks
|
||||
.get(&cid)
|
||||
.ok_or_else(|| VerifyError::BlockNotFound(cid.to_string()))?;
|
||||
|
||||
let node: Ipld = serde_ipld_dagcbor::from_slice(block)
|
||||
.map_err(|e| VerifyError::InvalidCbor(e.to_string()))?;
|
||||
|
||||
if let Ipld::Map(ref obj) = node {
|
||||
if let Some(Ipld::Link(left_cid)) = obj.get("l") {
|
||||
if !blocks.contains_key(left_cid) {
|
||||
return Err(VerifyError::BlockNotFound(format!(
|
||||
"MST left pointer {} not in CAR",
|
||||
left_cid
|
||||
)));
|
||||
}
|
||||
stack.push(*left_cid);
|
||||
}
|
||||
|
||||
if let Some(Ipld::List(entries)) = obj.get("e") {
|
||||
let mut last_full_key: Vec<u8> = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
if let Ipld::Map(entry_obj) = entry {
|
||||
let prefix_len = entry_obj.get("p").and_then(|p| match p {
|
||||
Ipld::Integer(i) => Some(*i as usize),
|
||||
_ => None,
|
||||
}).unwrap_or(0);
|
||||
|
||||
let key_suffix = entry_obj.get("k").and_then(|k| match k {
|
||||
Ipld::Bytes(b) => Some(b.clone()),
|
||||
Ipld::String(s) => Some(s.as_bytes().to_vec()),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
if let Some(suffix) = key_suffix {
|
||||
let mut full_key = Vec::new();
|
||||
if prefix_len > 0 && prefix_len <= last_full_key.len() {
|
||||
full_key.extend_from_slice(&last_full_key[..prefix_len]);
|
||||
}
|
||||
full_key.extend_from_slice(&suffix);
|
||||
|
||||
if !last_full_key.is_empty() && full_key <= last_full_key {
|
||||
return Err(VerifyError::MstValidationFailed(
|
||||
"MST keys not in sorted order".to_string(),
|
||||
));
|
||||
}
|
||||
last_full_key = full_key;
|
||||
}
|
||||
|
||||
if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") {
|
||||
if !blocks.contains_key(tree_cid) {
|
||||
return Err(VerifyError::BlockNotFound(format!(
|
||||
"MST subtree {} not in CAR",
|
||||
tree_cid
|
||||
)));
|
||||
}
|
||||
stack.push(*tree_cid);
|
||||
}
|
||||
|
||||
if let Some(Ipld::Link(value_cid)) = entry_obj.get("v") {
|
||||
if !blocks.contains_key(value_cid) {
|
||||
warn!(
|
||||
"Record block {} referenced in MST not in CAR (may be expected for partial export)",
|
||||
value_cid
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"MST validation complete: {} nodes, {} blocks visited",
|
||||
node_count,
|
||||
visited.len()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VerifiedCar {
|
||||
pub did: String,
|
||||
pub rev: String,
|
||||
pub data_cid: Cid,
|
||||
pub prev: Option<Cid>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn make_cid(data: &[u8]) -> Cid {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
let hash = hasher.finalize();
|
||||
let multihash = multihash::Multihash::wrap(0x12, &hash).unwrap();
|
||||
Cid::new_v1(0x71, multihash)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verifier_creation() {
|
||||
let _verifier = CarVerifier::new();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_error_display() {
|
||||
let err = VerifyError::DidMismatch {
|
||||
commit_did: "did:plc:abc".to_string(),
|
||||
expected_did: "did:plc:xyz".to_string(),
|
||||
};
|
||||
assert!(err.to_string().contains("did:plc:abc"));
|
||||
assert!(err.to_string().contains("did:plc:xyz"));
|
||||
|
||||
let err = VerifyError::InvalidSignature;
|
||||
assert!(err.to_string().contains("signature"));
|
||||
|
||||
let err = VerifyError::NoSigningKey;
|
||||
assert!(err.to_string().contains("signing key"));
|
||||
|
||||
let err = VerifyError::MstValidationFailed("test error".to_string());
|
||||
assert!(err.to_string().contains("test error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mst_validation_missing_root_block() {
|
||||
let verifier = CarVerifier::new();
|
||||
let blocks: HashMap<Cid, Bytes> = HashMap::new();
|
||||
|
||||
let fake_cid = make_cid(b"fake data");
|
||||
let result = verifier.verify_mst_structure(&fake_cid, &blocks);
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, VerifyError::BlockNotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mst_validation_invalid_cbor() {
|
||||
let verifier = CarVerifier::new();
|
||||
|
||||
let bad_cbor = Bytes::from(vec![0xFF, 0xFF, 0xFF]);
|
||||
let cid = make_cid(&bad_cbor);
|
||||
|
||||
let mut blocks = HashMap::new();
|
||||
blocks.insert(cid, bad_cbor);
|
||||
|
||||
let result = verifier.verify_mst_structure(&cid, &blocks);
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, VerifyError::InvalidCbor(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mst_validation_empty_node() {
|
||||
let verifier = CarVerifier::new();
|
||||
|
||||
let empty_node = serde_ipld_dagcbor::to_vec(&serde_json::json!({
|
||||
"e": []
|
||||
})).unwrap();
|
||||
let cid = make_cid(&empty_node);
|
||||
|
||||
let mut blocks = HashMap::new();
|
||||
blocks.insert(cid, Bytes::from(empty_node));
|
||||
|
||||
let result = verifier.verify_mst_structure(&cid, &blocks);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mst_validation_missing_left_pointer() {
|
||||
use ipld_core::ipld::Ipld;
|
||||
|
||||
let verifier = CarVerifier::new();
|
||||
|
||||
let missing_left_cid = make_cid(b"missing left");
|
||||
let node = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("l".to_string(), Ipld::Link(missing_left_cid)),
|
||||
("e".to_string(), Ipld::List(vec![])),
|
||||
]));
|
||||
let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
|
||||
let cid = make_cid(&node_bytes);
|
||||
|
||||
let mut blocks = HashMap::new();
|
||||
blocks.insert(cid, Bytes::from(node_bytes));
|
||||
|
||||
let result = verifier.verify_mst_structure(&cid, &blocks);
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, VerifyError::BlockNotFound(_)));
|
||||
assert!(err.to_string().contains("left pointer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mst_validation_missing_subtree() {
|
||||
use ipld_core::ipld::Ipld;
|
||||
|
||||
let verifier = CarVerifier::new();
|
||||
|
||||
let missing_subtree_cid = make_cid(b"missing subtree");
|
||||
let record_cid = make_cid(b"record");
|
||||
|
||||
let entry = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("k".to_string(), Ipld::Bytes(b"key1".to_vec())),
|
||||
("v".to_string(), Ipld::Link(record_cid)),
|
||||
("p".to_string(), Ipld::Integer(0)),
|
||||
("t".to_string(), Ipld::Link(missing_subtree_cid)),
|
||||
]));
|
||||
|
||||
let node = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("e".to_string(), Ipld::List(vec![entry])),
|
||||
]));
|
||||
let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
|
||||
let cid = make_cid(&node_bytes);
|
||||
|
||||
let mut blocks = HashMap::new();
|
||||
blocks.insert(cid, Bytes::from(node_bytes));
|
||||
|
||||
let result = verifier.verify_mst_structure(&cid, &blocks);
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, VerifyError::BlockNotFound(_)));
|
||||
assert!(err.to_string().contains("subtree"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mst_validation_unsorted_keys() {
|
||||
use ipld_core::ipld::Ipld;
|
||||
|
||||
let verifier = CarVerifier::new();
|
||||
|
||||
let record_cid = make_cid(b"record");
|
||||
|
||||
let entry1 = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("k".to_string(), Ipld::Bytes(b"zzz".to_vec())),
|
||||
("v".to_string(), Ipld::Link(record_cid)),
|
||||
("p".to_string(), Ipld::Integer(0)),
|
||||
]));
|
||||
|
||||
let entry2 = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("k".to_string(), Ipld::Bytes(b"aaa".to_vec())),
|
||||
("v".to_string(), Ipld::Link(record_cid)),
|
||||
("p".to_string(), Ipld::Integer(0)),
|
||||
]));
|
||||
|
||||
let node = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("e".to_string(), Ipld::List(vec![entry1, entry2])),
|
||||
]));
|
||||
let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
|
||||
let cid = make_cid(&node_bytes);
|
||||
|
||||
let mut blocks = HashMap::new();
|
||||
blocks.insert(cid, Bytes::from(node_bytes));
|
||||
|
||||
let result = verifier.verify_mst_structure(&cid, &blocks);
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, VerifyError::MstValidationFailed(_)));
|
||||
assert!(err.to_string().contains("sorted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mst_validation_sorted_keys_ok() {
|
||||
use ipld_core::ipld::Ipld;
|
||||
|
||||
let verifier = CarVerifier::new();
|
||||
|
||||
let record_cid = make_cid(b"record");
|
||||
|
||||
let entry1 = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("k".to_string(), Ipld::Bytes(b"aaa".to_vec())),
|
||||
("v".to_string(), Ipld::Link(record_cid)),
|
||||
("p".to_string(), Ipld::Integer(0)),
|
||||
]));
|
||||
|
||||
let entry2 = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("k".to_string(), Ipld::Bytes(b"bbb".to_vec())),
|
||||
("v".to_string(), Ipld::Link(record_cid)),
|
||||
("p".to_string(), Ipld::Integer(0)),
|
||||
]));
|
||||
|
||||
let entry3 = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("k".to_string(), Ipld::Bytes(b"zzz".to_vec())),
|
||||
("v".to_string(), Ipld::Link(record_cid)),
|
||||
("p".to_string(), Ipld::Integer(0)),
|
||||
]));
|
||||
|
||||
let node = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("e".to_string(), Ipld::List(vec![entry1, entry2, entry3])),
|
||||
]));
|
||||
let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
|
||||
let cid = make_cid(&node_bytes);
|
||||
|
||||
let mut blocks = HashMap::new();
|
||||
blocks.insert(cid, Bytes::from(node_bytes));
|
||||
|
||||
let result = verifier.verify_mst_structure(&cid, &blocks);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mst_validation_with_valid_left_pointer() {
|
||||
use ipld_core::ipld::Ipld;
|
||||
|
||||
let verifier = CarVerifier::new();
|
||||
|
||||
let left_node = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("e".to_string(), Ipld::List(vec![])),
|
||||
]));
|
||||
let left_node_bytes = serde_ipld_dagcbor::to_vec(&left_node).unwrap();
|
||||
let left_cid = make_cid(&left_node_bytes);
|
||||
|
||||
let root_node = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("l".to_string(), Ipld::Link(left_cid)),
|
||||
("e".to_string(), Ipld::List(vec![])),
|
||||
]));
|
||||
let root_node_bytes = serde_ipld_dagcbor::to_vec(&root_node).unwrap();
|
||||
let root_cid = make_cid(&root_node_bytes);
|
||||
|
||||
let mut blocks = HashMap::new();
|
||||
blocks.insert(root_cid, Bytes::from(root_node_bytes));
|
||||
blocks.insert(left_cid, Bytes::from(left_node_bytes));
|
||||
|
||||
let result = verifier.verify_mst_structure(&root_cid, &blocks);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mst_validation_cycle_detection() {
|
||||
let verifier = CarVerifier::new();
|
||||
|
||||
let node = serde_ipld_dagcbor::to_vec(&serde_json::json!({
|
||||
"e": []
|
||||
})).unwrap();
|
||||
let cid = make_cid(&node);
|
||||
|
||||
let mut blocks = HashMap::new();
|
||||
blocks.insert(cid, Bytes::from(node));
|
||||
|
||||
let result = verifier.verify_mst_structure(&cid, &blocks);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unsupported_did_method() {
|
||||
let verifier = CarVerifier::new();
|
||||
let result = verifier.resolve_did_document("did:unknown:test").await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, VerifyError::DidResolutionFailed(_)));
|
||||
assert!(err.to_string().contains("Unsupported"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mst_validation_with_prefix_compression() {
|
||||
use ipld_core::ipld::Ipld;
|
||||
|
||||
let verifier = CarVerifier::new();
|
||||
let record_cid = make_cid(b"record");
|
||||
|
||||
let entry1 = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("k".to_string(), Ipld::Bytes(b"app.bsky.feed.post/abc".to_vec())),
|
||||
("v".to_string(), Ipld::Link(record_cid)),
|
||||
("p".to_string(), Ipld::Integer(0)),
|
||||
]));
|
||||
|
||||
let entry2 = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("k".to_string(), Ipld::Bytes(b"def".to_vec())),
|
||||
("v".to_string(), Ipld::Link(record_cid)),
|
||||
("p".to_string(), Ipld::Integer(19)),
|
||||
]));
|
||||
|
||||
let entry3 = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("k".to_string(), Ipld::Bytes(b"xyz".to_vec())),
|
||||
("v".to_string(), Ipld::Link(record_cid)),
|
||||
("p".to_string(), Ipld::Integer(19)),
|
||||
]));
|
||||
|
||||
let node = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("e".to_string(), Ipld::List(vec![entry1, entry2, entry3])),
|
||||
]));
|
||||
let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
|
||||
let cid = make_cid(&node_bytes);
|
||||
|
||||
let mut blocks = HashMap::new();
|
||||
blocks.insert(cid, Bytes::from(node_bytes));
|
||||
|
||||
let result = verifier.verify_mst_structure(&cid, &blocks);
|
||||
assert!(result.is_ok(), "Prefix-compressed keys should be validated correctly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mst_validation_prefix_compression_unsorted() {
|
||||
use ipld_core::ipld::Ipld;
|
||||
|
||||
let verifier = CarVerifier::new();
|
||||
let record_cid = make_cid(b"record");
|
||||
|
||||
let entry1 = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("k".to_string(), Ipld::Bytes(b"app.bsky.feed.post/xyz".to_vec())),
|
||||
("v".to_string(), Ipld::Link(record_cid)),
|
||||
("p".to_string(), Ipld::Integer(0)),
|
||||
]));
|
||||
|
||||
let entry2 = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("k".to_string(), Ipld::Bytes(b"abc".to_vec())),
|
||||
("v".to_string(), Ipld::Link(record_cid)),
|
||||
("p".to_string(), Ipld::Integer(19)),
|
||||
]));
|
||||
|
||||
let node = Ipld::Map(std::collections::BTreeMap::from([
|
||||
("e".to_string(), Ipld::List(vec![entry1, entry2])),
|
||||
]));
|
||||
let node_bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
|
||||
let cid = make_cid(&node_bytes);
|
||||
|
||||
let mut blocks = HashMap::new();
|
||||
blocks.insert(cid, Bytes::from(node_bytes));
|
||||
|
||||
let result = verifier.verify_mst_structure(&cid, &blocks);
|
||||
assert!(result.is_err(), "Unsorted prefix-compressed keys should fail validation");
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, VerifyError::MstValidationFailed(_)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user