From c9803ea7f2e04f661c7f4aaa3b98d60c6ed3a402 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Tue, 19 May 2026 15:37:02 +0200 Subject: [PATCH] Track CAS providers --- AGENTS.md | 5 ++- README.md | 9 +++-- crates/geth-cli/src/lib.rs | 14 +++++++ crates/geth-control/src/lib.rs | 35 +++++++++++++++++ crates/geth-node/src/lib.rs | 24 +++++++++++- crates/geth-store/src/lib.rs | 72 ++++++++++++++++++++++++++++++++++ docs/architecture.md | 8 ++-- docs/roadmap.md | 5 ++- 8 files changed, 161 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bf7ef07..3869a0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,8 +127,9 @@ Roadmap items should be actionable and checkable: - Local CAS supports pin/unpin metadata, surfaced through `cas list`, and `cas cleanup` evicts unpinned blobs while retaining pinned blobs. The daemon can fetch CAS blobs from an imported signed peer card over Iroh when the peer - grants `cas.fetch` on `resource:cas:local`; full `iroh-blobs` provider - integration is still roadmap work. + grants `cas.fetch` on `resource:cas:local`; successful fetches record local + provider metadata visible through `geth cas providers `. Full + `iroh-blobs` provider integration is still roadmap work. - The CAS crate can build deterministic tree objects for local file trees and store those manifests as CAS blobs. The daemon can register and scan local file roots, reporting create/update/delete/rename changes without writing back diff --git a/README.md b/README.md index 8e9ba9d..75e8b24 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ The bootstrap implementation provides: - `geth auth grant [--grant-id ]` - `geth auth revoke ` - local filesystem CAS commands: `add`, `get`, `fetch`, `hash`, `has`, `pin`, - `unpin`, `cleanup`, `list` + `unpin`, `cleanup`, `providers`, `list` - local CAS tree objects describe file trees and are stored as CAS blobs - local file-root commands: `geth cas root add/list/scan` - local file conflict metadata commands: @@ -136,9 +136,10 @@ card binds the actual Iroh EndpointID before reducing resource-local auth ops. `geth cas fetch ` uses the same protected Iroh control path to request a blob from a peer. The remote daemon only returns bytes when the caller has `cas.fetch` on `resource:cas:local`, and the caller verifies that the bytes -hash to the requested BLAKE3 CAS hash before storing them locally. This is the -bootstrap transfer path; future work will move provider/fetch behavior to -`iroh-blobs`. +hash to the requested BLAKE3 CAS hash before storing them locally. Successful +fetches record the serving peer as a local provider, visible with +`geth cas providers `. This is the bootstrap transfer path; future work +will move provider/fetch behavior to `iroh-blobs`. `geth ssh cert sync ` requires `ssh_cert.sync` on `resource:ssh:certs` at the peer. `geth ssh revocation sync ` requires `ssh_revocation.sync` on `resource:ssh:revocations`. Both commands merge diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index fe7798f..282123d 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -243,6 +243,9 @@ pub enum CasCommand { #[arg(long)] dry_run: bool, }, + Providers { + hash: String, + }, List, Root { #[command(subcommand)] @@ -593,6 +596,7 @@ fn request_for_command(command: Command) -> Result { CasCommand::Pin { hash } => ControlRequest::CasPin { hash: hash.into() }, CasCommand::Unpin { hash } => ControlRequest::CasUnpin { hash: hash.into() }, CasCommand::Cleanup { dry_run } => ControlRequest::CasCleanup { dry_run }, + CasCommand::Providers { hash } => ControlRequest::CasProviders { hash: hash.into() }, CasCommand::List => ControlRequest::CasList, CasCommand::Root { command } => match command { CasRootCommand::Add { name, path } => ControlRequest::CasRootAdd { name, path }, @@ -979,6 +983,16 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("{}\t{} bytes\t{}", blob.hash, blob.size_bytes, pin); } } + ControlResponse::CasProviders { hash, providers } => { + println!("hash: {hash}"); + println!("providers: {}", providers.len()); + for provider in providers { + println!( + "{}\t{}\t{}", + provider.peer_node_id, provider.endpoint_id, provider.last_seen_ms + ); + } + } ControlResponse::CasRootAdded { root } => { println!("added file root: {}", root.name); println!("id: {}", root.id); diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 07824a5..8f57d80 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -67,6 +67,9 @@ pub enum ControlRequest { CasCleanup { dry_run: bool, }, + CasProviders { + hash: BlobHash, + }, CasList, CasRootAdd { name: String, @@ -324,6 +327,10 @@ pub enum ControlResponse { CasList { blobs: Vec, }, + CasProviders { + hash: BlobHash, + providers: Vec, + }, CasRootAdded { root: FileRoot, }, @@ -569,6 +576,13 @@ pub struct CasBlob { pub pinned: bool, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CasProvider { + pub peer_node_id: String, + pub endpoint_id: String, + pub last_seen_ms: i64, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "kebab-case")] pub enum PeerControlRequest { @@ -881,6 +895,27 @@ mod tests { request ); + let request = ControlRequest::CasProviders { + hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(), + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); + + let response = ControlResponse::CasProviders { + hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(), + providers: vec![CasProvider { + peer_node_id: "node:peer".to_owned(), + endpoint_id: "endpoint:peer".to_owned(), + last_seen_ms: 42, + }], + }; + assert_eq!( + decode_response(&encode_response(&response).expect("encode")).expect("decode"), + response + ); + let request = ControlRequest::SshRevocationExport { out: PathBuf::from("revocations.krl-spec"), format: "openssh-krl-spec".to_owned(), diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index fdaa362..5080926 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -8,7 +8,7 @@ use geth_cas::{ }; use geth_config::{GethConfig, GethPaths, RelayMode}; use geth_control::{ - CasBlob, ControlRequest, ControlResponse, KeychainStatusResponse, NodeIdResponse, + CasBlob, CasProvider, ControlRequest, ControlResponse, KeychainStatusResponse, NodeIdResponse, PeerControlRequest, PeerControlResponse, StatusResponse, SyncWatermark, }; use geth_crypto::AgentKey; @@ -799,6 +799,7 @@ async fn cas_fetch_from_peer( info.size_bytes, &info.path.to_string_lossy(), )?; + store.record_cas_provider(info.hash.as_str(), &node_id, &endpoint_id)?; Ok(ControlResponse::CasFetched { peer_node_id: node_id, peer_agent_id: agent_id, @@ -2592,6 +2593,21 @@ pub fn handle_request( dry_run, }) } + ControlRequest::CasProviders { hash } => { + geth_cas::validate_hash(&hash)?; + Ok(ControlResponse::CasProviders { + providers: store + .list_cas_providers(hash.as_str())? + .into_iter() + .map(|provider| CasProvider { + peer_node_id: provider.peer_node_id, + endpoint_id: provider.endpoint_id, + last_seen_ms: provider.last_seen_ms, + }) + .collect(), + hash, + }) + } ControlRequest::CasList => { let cas = LocalCas::new(node.paths.cas_dir()); let blobs = cas @@ -4651,6 +4667,12 @@ mod tests { } other => panic!("unexpected allowed CAS fetch response: {other:?}"), } + let providers = Store::open(&left_paths.metadata_db()) + .expect("open left after cas fetch") + .list_cas_providers(right_blob.hash.as_str()) + .expect("list cas providers"); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].peer_node_id, right.node_id); let kv_synced = handle_request_async( &left, diff --git a/crates/geth-store/src/lib.rs b/crates/geth-store/src/lib.rs index 5ffd354..59ddb1d 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -100,6 +100,13 @@ impl Store { hash TEXT PRIMARY KEY, pinned_at_ms INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS cas_providers ( + hash TEXT NOT NULL, + peer_node_id TEXT NOT NULL, + endpoint_id TEXT NOT NULL, + last_seen_ms INTEGER NOT NULL, + PRIMARY KEY(hash, peer_node_id) + ); CREATE TABLE IF NOT EXISTS kv_stores ( kv_id TEXT PRIMARY KEY, resource_id TEXT NOT NULL, @@ -746,6 +753,39 @@ impl Store { .map_err(StoreError::from) } + pub fn record_cas_provider( + &self, + hash: &str, + peer_node_id: &str, + endpoint_id: &str, + ) -> Result<(), StoreError> { + self.conn.execute( + r#"INSERT OR REPLACE INTO cas_providers(hash, peer_node_id, endpoint_id, last_seen_ms) + VALUES (?1, ?2, ?3, ?4)"#, + params![hash, peer_node_id, endpoint_id, now_ms()], + )?; + Ok(()) + } + + pub fn list_cas_providers(&self, hash: &str) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + r#"SELECT hash, peer_node_id, endpoint_id, last_seen_ms + FROM cas_providers + WHERE hash = ?1 + ORDER BY last_seen_ms DESC, peer_node_id"#, + )?; + let rows = stmt.query_map(params![hash], |row| { + Ok(CasProvider { + hash: row.get(0)?, + peer_node_id: row.get(1)?, + endpoint_id: row.get(2)?, + last_seen_ms: row.get(3)?, + }) + })?; + rows.collect::, _>>() + .map_err(StoreError::from) + } + pub fn upsert_peer_card(&self, peer_card: &StoredPeerCard) -> Result<(), StoreError> { self.conn.execute( "INSERT OR REPLACE INTO peer_cards(peer_id, card_json, updated_at_ms) VALUES (?1, ?2, ?3)", @@ -1132,6 +1172,14 @@ pub struct StoredResource { pub status: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CasProvider { + pub hash: String, + pub peer_node_id: String, + pub endpoint_id: String, + pub last_seen_ms: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct StoredDbResource { pub db_id: String, @@ -1452,6 +1500,30 @@ mod tests { assert!(store.list_cas_pins().expect("pins").is_empty()); } + #[test] + fn cas_providers_roundtrip_by_hash() { + let store = Store::open_memory().expect("open"); + let hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + store + .record_cas_provider(hash, "node:laptop", "endpoint:laptop") + .expect("record provider"); + + let providers = store.list_cas_providers(hash).expect("list providers"); + + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].hash, hash); + assert_eq!(providers[0].peer_node_id, "node:laptop"); + assert_eq!(providers[0].endpoint_id, "endpoint:laptop"); + assert!( + store + .list_cas_providers( + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + ) + .expect("list missing") + .is_empty() + ); + } + #[test] fn deleting_cas_object_removes_pin_metadata() { let store = Store::open_memory().expect("open"); diff --git a/docs/architecture.md b/docs/architecture.md index d9e8623..e429f13 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -130,9 +130,11 @@ imported signed peer card over the daemon-owned Iroh control ALPN. The serving daemon validates the caller's peer-card signature and observed Iroh EndpointID, then reduces local auth ops and requires `cas.fetch` on `resource:cas:local` before returning blob bytes. The requester verifies that the returned bytes hash -to the requested BLAKE3 CAS hash before storing them. Iroh-blobs, provider -tracking, encrypted blobs, richer cache policies, cross-node file roots, and -automatic conflict detection are future work. +to the requested BLAKE3 CAS hash before storing them. Successful fetches update +durable local provider metadata keyed by CAS hash and peer node, which can be +inspected through `geth cas providers `. Iroh-blobs, encrypted blobs, +richer cache policies, cross-node file roots, and automatic conflict detection +are future work. `geth-db` currently registers local SQLite paths as DB resources and reports local-only sync status plus a read-only SQLite schema summary/hash. It also diff --git a/docs/roadmap.md b/docs/roadmap.md index 0dd3731..a8191c0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -248,9 +248,12 @@ authorization and durable-state boundaries clear. - `[x]` The requester verifies returned bytes against the requested BLAKE3 CAS hash before storing them locally. - `[x]` Local add/get/hash/has/list behavior remains backward compatible. + - `[x]` Successful fetches record the serving peer as a local provider for + the CAS hash. + - `[x]` `geth cas providers ` lists locally known providers. + - `[x]` Tests cover local provider metadata storage. - `[ ]` Replace the bootstrap control-ALPN transfer with `iroh-blobs` provider/fetch behavior. - - `[ ]` Provider tracking is recorded locally. - `[x]` CAS pin and cache policy. Acceptance criteria: