Add authorized file-root sync

This commit is contained in:
Eric Wendland 2026-05-20 13:22:40 +02:00
commit 1caaedda90
7 changed files with 489 additions and 11 deletions

View file

@ -286,9 +286,20 @@ pub enum CasCommand {
#[derive(Debug, Subcommand)]
pub enum CasRootCommand {
Add { name: String, path: PathBuf },
Add {
name: String,
path: PathBuf,
},
List,
Scan { name: String },
Scan {
name: String,
},
Sync {
node: String,
name: String,
#[arg(long)]
bearer_secret: Option<String>,
},
}
#[derive(Debug, Subcommand)]
@ -731,6 +742,15 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
CasRootCommand::Add { name, path } => ControlRequest::CasRootAdd { name, path },
CasRootCommand::List => ControlRequest::CasRootList,
CasRootCommand::Scan { name } => ControlRequest::CasRootScan { name },
CasRootCommand::Sync {
node,
name,
bearer_secret,
} => ControlRequest::CasRootSync {
node,
name,
bearer_secret,
},
},
CasCommand::Conflict { command } => match command {
CasConflictCommand::Record {
@ -1244,6 +1264,37 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
}
println!("note: {}", scan.note);
}
ControlResponse::CasRootSynced {
peer_node_id,
peer_agent_id,
endpoint_id,
name,
root,
tree_bytes_imported,
allowed,
reason,
note,
} => {
if let Some(root) = root {
println!("synced file root: {name}");
println!("peer: {peer_node_id}");
println!("path: {}", root.path);
println!(
"tree: {}",
root.latest_tree
.map(|hash| hash.to_string())
.unwrap_or_else(|| "unscanned".to_owned())
);
println!("tree_bytes_imported: {tree_bytes_imported}");
} else {
println!("file root sync denied by {peer_node_id}");
}
println!("agent: {peer_agent_id}");
println!("endpoint: {endpoint_id}");
println!("allowed: {allowed}");
println!("reason: {reason}");
println!("note: {note}");
}
ControlResponse::CasConflictRecorded { conflict } => {
println!("recorded conflict: {}", conflict.id);
print_file_conflict(&conflict);

View file

@ -81,6 +81,11 @@ pub enum ControlRequest {
CasRootScan {
name: String,
},
CasRootSync {
node: String,
name: String,
bearer_secret: Option<String>,
},
CasConflictRecord {
root: String,
path: String,
@ -387,6 +392,17 @@ pub enum ControlResponse {
CasRootScanned {
scan: FileRootScan,
},
CasRootSynced {
peer_node_id: String,
peer_agent_id: String,
endpoint_id: String,
name: String,
root: Option<FileRoot>,
tree_bytes_imported: bool,
allowed: bool,
reason: String,
note: String,
},
CasConflictRecorded {
conflict: FileConflict,
},
@ -689,6 +705,12 @@ pub enum PeerControlRequest {
nonce: String,
bearer_proof: Option<BearerProof>,
},
CasRootSync {
peer_card: PeerCard,
name: String,
nonce: String,
bearer_proof: Option<BearerProof>,
},
SshCertSync {
peer_card: PeerCard,
since_ms: i64,
@ -803,6 +825,20 @@ pub enum PeerControlResponse {
nonce: String,
note: String,
},
CasRootSynced {
node_id: String,
agent_id: String,
endpoint_id: String,
remote_endpoint_id: String,
name: String,
root: Option<FileRoot>,
tree_content_base64: Option<String>,
allowed: bool,
reason: String,
evaluated_ops: usize,
nonce: String,
note: String,
},
SshCertSynced {
node_id: String,
agent_id: String,
@ -1382,6 +1418,32 @@ mod tests {
request
);
let request = ControlRequest::CasRootSync {
node: "node:peer".to_owned(),
name: "notes".to_owned(),
bearer_secret: None,
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
let response = ControlResponse::CasRootSynced {
peer_node_id: "node:peer".to_owned(),
peer_agent_id: "agent:peer".to_owned(),
endpoint_id: "endpoint:peer".to_owned(),
name: "notes".to_owned(),
root: None,
tree_bytes_imported: false,
allowed: false,
reason: "no grant".to_owned(),
note: "file-root sync".to_owned(),
};
assert_eq!(
decode_response(&encode_response(&response).expect("encode")).expect("decode"),
response
);
let request = ControlRequest::CasConflictResolve {
conflict_id: "file-conflict:notes:1".to_owned(),
resolution: "keep-local".to_owned(),
@ -1488,6 +1550,26 @@ mod tests {
response
);
let response = PeerControlResponse::CasRootSynced {
node_id: "node:peer".to_owned(),
agent_id: "agent:peer".to_owned(),
endpoint_id: "endpoint:peer".to_owned(),
remote_endpoint_id: "endpoint:caller".to_owned(),
name: "notes".to_owned(),
root: None,
tree_content_base64: None,
allowed: false,
reason: "no grant".to_owned(),
evaluated_ops: 0,
nonce: "nonce".to_owned(),
note: "file-root sync".to_owned(),
};
assert_eq!(
decode_peer_response(&encode_peer_response(&response).expect("encode"))
.expect("decode"),
response
);
let request = PeerControlRequest::DbSync {
peer_card: PeerCard {
node_id: "node:caller".into(),

View file

@ -257,6 +257,11 @@ pub async fn handle_request_async(
hash,
bearer_secret,
} => cas_fetch_from_peer(node, &peer_node, hash, bearer_secret).await,
ControlRequest::CasRootSync {
node: peer_node,
name,
bearer_secret,
} => cas_root_sync_from_peer(node, &peer_node, &name, bearer_secret).await,
ControlRequest::SshCertSync {
node: peer_node,
bearer_secret,
@ -581,6 +586,7 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
)),
PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::CasFetched { .. }
| PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. }
@ -697,6 +703,7 @@ async fn peer_auth_check(
)),
PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::CasFetched { .. }
| PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. }
@ -845,6 +852,7 @@ async fn cas_fetch_from_peer(
PeerControlResponse::Pong { .. }
| PeerControlResponse::AuthChecked { .. }
| PeerControlResponse::SyncStatus { .. }
| PeerControlResponse::CasRootSynced { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. }
@ -860,6 +868,137 @@ async fn cas_fetch_from_peer(
}
}
async fn cas_root_sync_from_peer(
node: &LocalNode,
peer_node: &str,
name: &str,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> {
geth_cas::validate_file_root_name(name)?;
let response = request_peer_control(node, peer_node, "cas-root-sync", |peer_card, nonce| {
PeerControlRequest::CasRootSync {
peer_card,
name: name.to_owned(),
nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
&format!("resource:cas-tree:{name}"),
"cas.fetch",
&nonce,
),
}
})
.await?;
match response {
PeerControlResponse::CasRootSynced {
node_id,
agent_id,
endpoint_id,
name: response_name,
root,
tree_content_base64,
allowed,
reason,
note,
..
} if response_name == name => {
if !allowed {
return Ok(ControlResponse::CasRootSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
name: response_name,
root: None,
tree_bytes_imported: false,
allowed,
reason,
note,
});
}
let store = Store::open(&node.paths.metadata_db())?;
let mut tree_bytes_imported = false;
let root = root
.map(|remote_root| {
let stored_name = remote_file_root_name(&node_id, &remote_root.name);
let stored_path = format!("remote:{node_id}:{}", remote_root.name);
if let Some(existing) = store.get_file_root_by_name(&stored_name)? {
if existing.path != stored_path {
return Err(NodeError::IrohPeer(format!(
"refusing to overwrite existing file root {} at {}",
existing.name, existing.path
)));
}
}
if let (Some(hash), Some(content)) = (
remote_root.latest_tree.clone(),
tree_content_base64.as_ref(),
) {
let decoded = base64::engine::general_purpose::STANDARD
.decode(content)
.map_err(|error| NodeError::IrohPeer(error.to_string()))?;
let info = LocalCas::new(node.paths.cas_dir()).add_bytes(&decoded)?;
if info.hash != hash {
return Err(NodeError::IrohPeer(format!(
"peer returned file-root tree bytes hash {} for announced {}",
info.hash, hash
)));
}
store.record_cas_object(
info.hash.as_str(),
info.size_bytes,
&info.path.to_string_lossy(),
)?;
tree_bytes_imported = true;
}
let stored = StoredFileRoot {
root_id: format!("file-root:remote:{node_id}:{}", remote_root.name),
resource_id: remote_root.resource.clone(),
name: stored_name,
path: stored_path,
latest_tree_hash: remote_root.latest_tree.as_ref().map(ToString::to_string),
latest_tree_json: None,
updated_at_ms: remote_root.updated_at_ms,
};
store.upsert_file_root(&stored)?;
Ok(file_root_from_stored(&stored))
})
.transpose()?;
Ok(ControlResponse::CasRootSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
name: response_name,
root,
tree_bytes_imported,
allowed,
reason,
note,
})
}
PeerControlResponse::CasRootSynced { .. } => Err(NodeError::IrohPeer(
"peer file-root sync response did not match request".to_owned(),
)),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to file-root sync".to_owned(),
)),
}
}
fn remote_file_root_name(peer_node_id: &str, remote_name: &str) -> String {
let peer = peer_node_id
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
ch
} else {
'-'
}
})
.collect::<String>();
format!("remote-{peer}-{remote_name}")
}
async fn ssh_cert_sync_from_peer(
node: &LocalNode,
peer_node: &str,
@ -1932,6 +2071,10 @@ async fn request_peer_control(
nonce: response_nonce,
..
}
| PeerControlResponse::CasRootSynced {
nonce: response_nonce,
..
}
| PeerControlResponse::SshProxyConnected {
nonce: response_nonce,
..
@ -2253,6 +2396,73 @@ async fn handle_iroh_control_connection(
}
}
}
PeerControlRequest::CasRootSync {
peer_card,
name,
nonce,
bearer_proof,
} => {
geth_cas::validate_file_root_name(&name)?;
peer_card.validate_candidate()?;
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
let discovered = DiscoveredPeer::candidate(
peer_card.clone(),
UnixMillis(geth_store::now_ms()),
DiscoverySource::PeerExchange,
)?;
let store = Store::open(&node.paths.metadata_db())?;
store.upsert_peer_card(&StoredPeerCard {
peer_id: peer_card.node_id.to_string(),
card_json: serde_json::to_string(&peer_card)?,
updated_at_ms: discovered.discovered_at.0,
})?;
let resource = format!("resource:cas-tree:{name}");
let capability = "cas.fetch".to_owned();
let explanation = explain_peer_or_bearer(
&store,
peer_card.node_id.as_str(),
&resource,
&capability,
&nonce,
bearer_proof.as_ref(),
)?;
let (root, tree_content_base64) = if explanation.allowed {
if let Some(root) = store.get_file_root_by_name(&name)? {
let root_view = file_root_from_stored(&root);
let tree_content_base64 = root
.latest_tree_hash
.as_ref()
.map(|hash| -> Result<Option<String>, NodeError> {
let hash = BlobHash::new(hash.clone());
let content = LocalCas::new(node.paths.cas_dir()).read_bytes(&hash)?;
Ok(Some(
base64::engine::general_purpose::STANDARD.encode(content),
))
})
.transpose()?
.flatten();
(Some(root_view), tree_content_base64)
} else {
(None, None)
}
} else {
(None, None)
};
PeerControlResponse::CasRootSynced {
node_id: node.node_id.clone(),
agent_id: node.agent_id.clone(),
endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(),
remote_endpoint_id,
name,
root,
tree_content_base64,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "file-root sync authenticated endpoint/card binding and required cas.fetch on the remote cas-tree resource; it imports tree metadata and CAS tree bytes but never writes files".to_owned(),
}
}
PeerControlRequest::SshCertSync {
peer_card,
since_ms,
@ -2952,6 +3162,7 @@ pub fn handle_request(
ControlRequest::PeerPing { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::PeerAuthCheck { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::CasFetch { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::CasRootSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::SshCertSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::SshRevocationSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::KvSync { .. } => Err(NodeError::IrohEndpointUnavailable),
@ -5246,6 +5457,39 @@ mod tests {
let right_blob = LocalCas::new(right.paths.cas_dir())
.add_bytes(b"remote cas bytes")
.expect("right cas add");
let left_file_root = left_home.path().join("files");
std::fs::create_dir_all(&left_file_root).expect("create left file root");
std::fs::write(left_file_root.join("note.txt"), b"local tree").expect("write left file");
handle_request(
&left,
ControlRequest::CasRootAdd {
name: "shared".to_owned(),
path: left_file_root.clone(),
},
)
.expect("left file root add");
let right_file_root = right_home.path().join("files");
std::fs::create_dir_all(&right_file_root).expect("create right file root");
std::fs::write(right_file_root.join("note.txt"), b"synced tree").expect("write right file");
handle_request(
&right,
ControlRequest::CasRootAdd {
name: "shared".to_owned(),
path: right_file_root,
},
)
.expect("right file root add");
let right_tree_hash = match handle_request(
&right,
ControlRequest::CasRootScan {
name: "shared".to_owned(),
},
)
.expect("right file root scan")
{
ControlResponse::CasRootScanned { scan } => scan.tree.hash,
other => panic!("unexpected right file root scan response: {other:?}"),
};
let right_pubkey = right_home.path().join("request.pub");
std::fs::write(
&right_pubkey,
@ -5423,6 +5667,30 @@ mod tests {
other => panic!("unexpected denied CAS fetch response: {other:?}"),
}
let denied_root_sync = handle_request_async(
&left,
ControlRequest::CasRootSync {
node: right_card.node_id.to_string(),
name: "shared".to_owned(),
bearer_secret: None,
},
)
.await
.expect("denied file root sync");
match denied_root_sync {
ControlResponse::CasRootSynced {
allowed,
root,
reason,
..
} => {
assert!(!allowed);
assert!(root.is_none());
assert!(reason.contains("no active direct or group grant"));
}
other => panic!("unexpected denied file root sync response: {other:?}"),
}
let denied_kv_sync = handle_request_async(
&left,
ControlRequest::KvSync {
@ -5672,6 +5940,16 @@ mod tests {
},
)
.expect("grant left peer");
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:cas-tree:shared".to_owned(),
capability: "cas.fetch".to_owned(),
grant_id: Some("grant:left-cas-tree-fetch".to_owned()),
},
)
.expect("grant left file root fetch");
handle_request(
&right,
ControlRequest::AuthGrant {
@ -5817,6 +6095,56 @@ mod tests {
assert_eq!(providers.len(), 1);
assert_eq!(providers[0].peer_node_id, right.node_id);
let root_sync = handle_request_async(
&left,
ControlRequest::CasRootSync {
node: right_card.node_id.to_string(),
name: "shared".to_owned(),
bearer_secret: None,
},
)
.await
.expect("allowed file root sync");
match root_sync {
ControlResponse::CasRootSynced {
allowed,
root,
tree_bytes_imported,
reason,
note,
..
} => {
assert!(allowed);
assert!(tree_bytes_imported);
let root = root.expect("synced root");
assert_ne!(root.name, "shared");
assert!(root.name.starts_with("remote-"));
assert!(root.name.ends_with("-shared"));
assert_eq!(root.latest_tree, Some(right_tree_hash.clone()));
assert!(root.path.starts_with("remote:"));
assert!(reason.contains("direct grant"));
assert!(note.contains("never writes files"));
}
other => panic!("unexpected allowed file root sync response: {other:?}"),
}
assert!(
LocalCas::new(left.paths.cas_dir())
.has(&right_tree_hash)
.expect("left has synced tree object")
);
let local_shared = match handle_request(
&left,
ControlRequest::CasRootScan {
name: "shared".to_owned(),
},
)
.expect("left local shared root remains scannable")
{
ControlResponse::CasRootScanned { scan } => scan.root,
other => panic!("unexpected left local file root scan response: {other:?}"),
};
assert_eq!(local_shared.path, left_file_root.to_string_lossy());
let kv_synced = handle_request_async(
&left,
ControlRequest::KvSync {