diff --git a/AGENTS.md b/AGENTS.md index ac0b926..48b5a41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,8 @@ Roadmap items should be actionable and checkable: enforcement are still roadmap work. - The daemon persists local keychain init/admin-key ops and reduces them for `keychain status`. SSH signature capture/verification is still roadmap work. +- Local CAS supports pin/unpin metadata, surfaced through `cas list`. Cache + cleanup and eviction policy are still roadmap work. - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite, iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future diff --git a/README.md b/README.md index 181d18b..624846d 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,8 @@ The bootstrap implementation provides: - `geth auth explain ` - `geth auth grant [--grant-id ]` - `geth auth revoke ` -- local filesystem CAS commands: `add`, `get`, `hash`, `has`, `list` +- local filesystem CAS commands: `add`, `get`, `hash`, `has`, `pin`, `unpin`, + `list` - SSH certificate flow metadata: - `geth ssh cert request --public-key --principal ` - `geth ssh cert requests` diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 5786187..aaa51c6 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -179,6 +179,12 @@ pub enum CasCommand { Has { hash: String, }, + Pin { + hash: String, + }, + Unpin { + hash: String, + }, List, } @@ -392,6 +398,8 @@ fn request_for_command(command: Command) -> Result { }, CasCommand::Hash { path } => ControlRequest::CasHash { path }, CasCommand::Has { hash } => ControlRequest::CasHas { hash: hash.into() }, + CasCommand::Pin { hash } => ControlRequest::CasPin { hash: hash.into() }, + CasCommand::Unpin { hash } => ControlRequest::CasUnpin { hash: hash.into() }, CasCommand::List => ControlRequest::CasList, }, Command::Kv { command } => ControlRequest::ModuleStub { @@ -585,9 +593,13 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { } ControlResponse::CasHash { hash } => println!("{hash}"), ControlResponse::CasHas { hash, present } => println!("{hash}: {present}"), + ControlResponse::CasPinned { hash, pinned } => { + println!("{hash}: pinned={pinned}"); + } ControlResponse::CasList { blobs } => { for blob in blobs { - println!("{}\t{} bytes", blob.hash, blob.size_bytes); + let pin = if blob.pinned { "pinned" } else { "unpinned" }; + println!("{}\t{} bytes\t{}", blob.hash, blob.size_bytes, pin); } } ControlResponse::KeychainStatus(status) => { diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index ce63961..31a497e 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -31,6 +31,12 @@ pub enum ControlRequest { CasHas { hash: BlobHash, }, + CasPin { + hash: BlobHash, + }, + CasUnpin { + hash: BlobHash, + }, CasList, KeychainInit { admin_key_path: Option, @@ -114,6 +120,10 @@ pub enum ControlResponse { hash: BlobHash, present: bool, }, + CasPinned { + hash: BlobHash, + pinned: bool, + }, CasList { blobs: Vec, }, @@ -193,6 +203,7 @@ pub struct KeychainStatusResponse { pub struct CasBlob { pub hash: BlobHash, pub size_bytes: u64, + pub pinned: bool, } #[derive(Debug, thiserror::Error)] diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 397be5e..eb8f2fd 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -225,18 +225,38 @@ pub fn handle_request( let present = cas.has(&hash)?; Ok(ControlResponse::CasHas { hash, present }) } + ControlRequest::CasPin { hash } => { + let cas = LocalCas::new(node.paths.cas_dir()); + if !cas.has(&hash)? { + return Err(NodeError::Cas(geth_cas::CasError::NotFound( + hash.to_string(), + ))); + } + store.pin_cas_object(hash.as_str())?; + Ok(ControlResponse::CasPinned { hash, pinned: true }) + } + ControlRequest::CasUnpin { hash } => { + geth_cas::validate_hash(&hash)?; + store.unpin_cas_object(hash.as_str())?; + Ok(ControlResponse::CasPinned { + hash, + pinned: false, + }) + } ControlRequest::CasList => { let cas = LocalCas::new(node.paths.cas_dir()); - Ok(ControlResponse::CasList { - blobs: cas - .list()? - .into_iter() - .map(|blob| CasBlob { + let blobs = cas + .list()? + .into_iter() + .map(|blob| { + Ok(CasBlob { + pinned: store.is_cas_object_pinned(blob.hash.as_str())?, hash: blob.hash, size_bytes: blob.size_bytes, }) - .collect(), - }) + }) + .collect::, NodeError>>()?; + Ok(ControlResponse::CasList { blobs }) } ControlRequest::KeychainInit { admin_key_path } => { let mut ops = Vec::new(); diff --git a/crates/geth-store/src/lib.rs b/crates/geth-store/src/lib.rs index a129b7e..0a6434f 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -250,6 +250,43 @@ impl Store { .map_err(StoreError::from) } + pub fn pin_cas_object(&self, hash: &str) -> Result<(), StoreError> { + self.conn.execute( + "INSERT OR REPLACE INTO cas_pins(hash, pinned_at_ms) VALUES (?1, ?2)", + params![hash, now_ms()], + )?; + Ok(()) + } + + pub fn unpin_cas_object(&self, hash: &str) -> Result<(), StoreError> { + self.conn + .execute("DELETE FROM cas_pins WHERE hash = ?1", params![hash])?; + Ok(()) + } + + pub fn is_cas_object_pinned(&self, hash: &str) -> Result { + let count: i64 = self.conn.query_row( + "SELECT COUNT(*) FROM cas_pins WHERE hash = ?1", + params![hash], + |row| row.get(0), + )?; + Ok(count > 0) + } + + pub fn list_cas_pins(&self) -> Result, StoreError> { + let mut stmt = self + .conn + .prepare("SELECT hash, pinned_at_ms FROM cas_pins ORDER BY pinned_at_ms, hash")?; + let rows = stmt.query_map([], |row| { + Ok(CasPin { + hash: row.get(0)?, + pinned_at_ms: row.get(1)?, + }) + })?; + 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)", @@ -523,6 +560,12 @@ pub struct CasObject { pub path: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CasPin { + pub hash: String, + pub pinned_at_ms: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct StoredPeerCard { pub peer_id: String, @@ -708,4 +751,18 @@ mod tests { vec![first, second] ); } + + #[test] + fn cas_pins_roundtrip() { + let store = Store::open_memory().expect("open"); + let hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + assert!(!store.is_cas_object_pinned(hash).expect("unpinned")); + store.pin_cas_object(hash).expect("pin"); + assert!(store.is_cas_object_pinned(hash).expect("pinned")); + assert_eq!(store.list_cas_pins().expect("pins").len(), 1); + store.unpin_cas_object(hash).expect("unpin"); + assert!(!store.is_cas_object_pinned(hash).expect("unpinned again")); + assert!(store.list_cas_pins().expect("pins").is_empty()); + } } diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 4412251..bc44778 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -122,6 +122,52 @@ fn initialized_node_can_roundtrip_cas_blob() { ); } +#[test] +fn cas_pin_unpin_updates_local_pin_metadata() { + let home = tempfile::tempdir().expect("tempdir"); + let paths = geth_config::GethPaths::from_home(home.path()); + let node = geth_node::init_node(&paths).expect("init node"); + let blob_path = home.path().join("blob.txt"); + std::fs::write(&blob_path, b"pinned bytes").expect("write blob"); + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::CasAdd { path: blob_path }, + ) + .expect("add cas blob"); + let hash = match response { + geth_control::ControlResponse::CasAdded { hash, .. } => hash, + other => panic!("unexpected response: {other:?}"), + }; + + geth_node::handle_request( + &node, + geth_control::ControlRequest::CasPin { hash: hash.clone() }, + ) + .expect("pin blob"); + let response = geth_node::handle_request(&node, geth_control::ControlRequest::CasList) + .expect("list blobs"); + match response { + geth_control::ControlResponse::CasList { blobs } => { + assert_eq!(blobs.len(), 1); + assert!(blobs[0].pinned); + } + other => panic!("unexpected response: {other:?}"), + } + + geth_node::handle_request(&node, geth_control::ControlRequest::CasUnpin { hash }) + .expect("unpin blob"); + let response = geth_node::handle_request(&node, geth_control::ControlRequest::CasList) + .expect("list blobs"); + match response { + geth_control::ControlResponse::CasList { blobs } => { + assert_eq!(blobs.len(), 1); + assert!(!blobs[0].pinned); + } + other => panic!("unexpected response: {other:?}"), + } +} + #[test] fn auth_explain_distinguishes_discovered_peer_candidates() { let home = tempfile::tempdir().expect("tempdir"); diff --git a/docs/architecture.md b/docs/architecture.md index fe70c9f..7b12618 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -88,8 +88,9 @@ Resource kinds: ## Module Overview `geth-cas` is implemented locally first using BLAKE3 hashes and filesystem blob -storage. Iroh-blobs, providers, encrypted blobs, manifests, and file sync trees -are future work. +storage. Local pin/unpin metadata is tracked in SQLite and surfaced in +`cas list`. Iroh-blobs, providers, encrypted blobs, cache cleanup, manifests, +and file sync trees are future work. `geth-kv`, `geth-db`, `geth-document`, `geth-pubsub`, `geth-pipe`, and `geth-ssh-proxy` currently define types, command shape, and roadmap stubs. diff --git a/docs/roadmap.md b/docs/roadmap.md index 56802ed..c64c5bf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -200,11 +200,14 @@ authorization and durable-state boundaries clear. - Provider tracking is recorded locally. - Local add/get/hash/has/list behavior remains backward compatible. -- `[ ]` CAS pin and cache policy. +- `[~]` CAS pin and cache policy. Acceptance criteria: - - Pinned blobs are retained across cache cleanup. - - Unpinned cached blobs can be evicted by policy. - - Tests cover pin, unpin, list, and attempted eviction of pinned data. + - `[x]` `geth cas pin ` persists local pin metadata. + - `[x]` `geth cas unpin ` removes local pin metadata. + - `[x]` `geth cas list` reports pinned versus unpinned blobs. + - `[ ]` Pinned blobs are retained across cache cleanup. + - `[ ]` Unpinned cached blobs can be evicted by policy. + - `[ ]` Tests cover attempted eviction of pinned data. - `[ ]` Encrypted private blobs. Acceptance criteria: