diff --git a/AGENTS.md b/AGENTS.md index b2aeb43..adeea58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,8 @@ Roadmap items should be actionable and checkable: `keychain status`. SSH signature capture/verification is still roadmap work. - Local CAS supports pin/unpin metadata, surfaced through `cas list`, and `cas cleanup` evicts unpinned blobs while retaining pinned blobs. +- DB resources can be registered locally and report local-only status. cr-sqlite + loading, change extraction, and sync 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/Cargo.lock b/Cargo.lock index cf176ab..5590d96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1115,6 +1115,7 @@ name = "geth-control" version = "0.1.0" dependencies = [ "geth-auth", + "geth-db", "geth-keychain", "geth-resource", "geth-ssh-identity", @@ -1145,6 +1146,7 @@ version = "0.1.0" dependencies = [ "geth-types", "serde", + "thiserror 2.0.18", ] [[package]] @@ -1205,6 +1207,7 @@ dependencies = [ "geth-config", "geth-control", "geth-crypto", + "geth-db", "geth-iroh", "geth-keychain", "geth-resource", diff --git a/README.md b/README.md index 8fd63a5..fae7682 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ The bootstrap implementation provides: - `geth auth revoke ` - local filesystem CAS commands: `add`, `get`, `hash`, `has`, `pin`, `unpin`, `cleanup`, `list` +- local DB resource registration: `geth db add ` and + `geth db status ` - SSH certificate flow metadata: - `geth ssh cert request --public-key --principal ` - `geth ssh cert requests` @@ -91,7 +93,7 @@ The bootstrap implementation provides: - `geth ssh revocation list` - `geth ssh revocation export --out ` -Other command groups exist as explicit stubs: `db`, `kv`, `pipe`, `document`, +Other command groups exist as explicit stubs: `kv`, `pipe`, `document`, `pubsub`, `secret`, and `ssh`. ## Resource Modules diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 95a023c..e3b85cd 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -419,9 +419,9 @@ fn request_for_command(command: Command) -> Result { module: "pipe".to_owned(), command: format!("{command:?}"), }, - Command::Db { command } => ControlRequest::ModuleStub { - module: "db".to_owned(), - command: format!("{command:?}"), + Command::Db { command } => match command { + DbCommand::Add { name, path } => ControlRequest::DbAdd { name, path }, + DbCommand::Status { name } => ControlRequest::DbStatus { name }, }, Command::Document { command } => ControlRequest::ModuleStub { module: "document".to_owned(), @@ -744,6 +744,28 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { ControlResponse::SshRevocationExported { out, count } => { println!("exported {count} ssh revocations to {}", out.display()); } + ControlResponse::DbAdded { db } => { + println!("registered db: {}", db.name); + println!("id: {}", db.id); + println!("resource: {}", db.resource); + println!("path: {}", db.path); + println!("sync_status: {}", db.sync_status); + } + ControlResponse::DbStatus { db } => { + println!("db: {}", db.name); + println!("id: {}", db.id); + println!("resource: {}", db.resource); + println!("path: {}", db.path); + println!("path_exists: {}", db.path_exists); + println!( + "size_bytes: {}", + db.size_bytes + .map(|size| size.to_string()) + .unwrap_or_else(|| "unknown".to_owned()) + ); + println!("schema_metadata: {}", db.schema_metadata); + println!("sync_status: {}", db.sync_status); + } ControlResponse::NotImplemented { module, command } => { println!("{module} {command}: not implemented yet"); } diff --git a/crates/geth-control/Cargo.toml b/crates/geth-control/Cargo.toml index bc062f9..0b61ec1 100644 --- a/crates/geth-control/Cargo.toml +++ b/crates/geth-control/Cargo.toml @@ -10,6 +10,7 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true geth-auth = { path = "../geth-auth" } +geth-db = { path = "../geth-db" } geth-keychain = { path = "../geth-keychain" } geth-resource = { path = "../geth-resource" } geth-ssh-identity = { path = "../geth-ssh-identity" } diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 194596c..fee7115 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -1,4 +1,5 @@ use geth_auth::{AuthExplanation, AuthOp}; +use geth_db::DbResource; use geth_keychain::KeychainOp; use geth_resource::ResourceDescriptor; use geth_ssh_identity::{ @@ -90,6 +91,13 @@ pub enum ControlRequest { SshRevocationExport { out: PathBuf, }, + DbAdd { + name: String, + path: PathBuf, + }, + DbStatus { + name: String, + }, ModuleStub { module: String, command: String, @@ -169,6 +177,12 @@ pub enum ControlResponse { out: PathBuf, count: usize, }, + DbAdded { + db: DbResource, + }, + DbStatus { + db: DbResource, + }, NotImplemented { module: String, command: String, diff --git a/crates/geth-db/Cargo.toml b/crates/geth-db/Cargo.toml index 2fab618..b7a1365 100644 --- a/crates/geth-db/Cargo.toml +++ b/crates/geth-db/Cargo.toml @@ -7,4 +7,5 @@ license.workspace = true [dependencies] serde.workspace = true +thiserror.workspace = true geth-types = { path = "../geth-types" } diff --git a/crates/geth-db/src/lib.rs b/crates/geth-db/src/lib.rs index d120ffe..16f940e 100644 --- a/crates/geth-db/src/lib.rs +++ b/crates/geth-db/src/lib.rs @@ -5,11 +5,47 @@ use serde::{Deserialize, Serialize}; pub struct DbResource { pub id: DbId, pub resource: ResourceId, + pub name: String, pub path: String, + pub path_exists: bool, + pub size_bytes: Option, + pub schema_metadata: String, pub sync_status: String, } +#[derive(Debug, thiserror::Error)] +pub enum DbError { + #[error("invalid db resource name: {0}")] + InvalidName(String), +} + +pub fn validate_db_name(name: &str) -> Result<(), DbError> { + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(DbError::InvalidName(name.to_owned())); + } + Ok(()) +} + #[must_use] pub fn crsqlite_sync_roadmap() -> &'static str { "future db sync reads crsql_changes, exchanges changes over Iroh, and applies through crsql_changes" } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn db_name_validation_rejects_paths_and_empty_names() { + assert!(validate_db_name("notes").is_ok()); + assert!(validate_db_name("notes.v1").is_ok()); + assert!(validate_db_name("").is_err()); + assert!(validate_db_name("../notes").is_err()); + assert!(validate_db_name("notes/main").is_err()); + assert!(validate_db_name("notes main").is_err()); + } +} diff --git a/crates/geth-node/Cargo.toml b/crates/geth-node/Cargo.toml index a2cd1d5..f4b5b0b 100644 --- a/crates/geth-node/Cargo.toml +++ b/crates/geth-node/Cargo.toml @@ -15,6 +15,7 @@ geth-cas = { path = "../geth-cas" } geth-config = { path = "../geth-config" } geth-control = { path = "../geth-control" } geth-crypto = { path = "../geth-crypto" } +geth-db = { path = "../geth-db" } geth-iroh = { path = "../geth-iroh" } geth-keychain = { path = "../geth-keychain" } geth-resource = { path = "../geth-resource" } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 230919e..a6f5003 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -8,6 +8,7 @@ use geth_control::{ StatusResponse, }; use geth_crypto::AgentKey; +use geth_db::DbResource; use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}; use geth_keychain::{KeychainOp, KeychainOpKind}; use geth_resource::ResourceDescriptor; @@ -17,7 +18,7 @@ use geth_ssh_identity::{ certificate_id, revocation_id, ssh_public_key_fingerprint, }; use geth_store::{ - Store, StoredAuthOp, StoredKeychainOp, StoredResource, StoredSshCertRequest, + Store, StoredAuthOp, StoredDbResource, StoredKeychainOp, StoredResource, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation, }; use geth_types::{ @@ -46,6 +47,12 @@ pub enum NodeError { Io(#[from] std::io::Error), #[error("invalid resource kind: {0}")] InvalidResourceKind(String), + #[error("invalid db resource name: {0}")] + InvalidDbName(String), + #[error("db path does not exist or is not a file: {0}")] + InvalidDbPath(String), + #[error("db resource not found: {0}")] + DbNotFound(String), #[error("invalid ssh certificate kind: {0}")] InvalidSshCertKind(String), #[error("invalid ssh certificate request status: {0}")] @@ -550,6 +557,41 @@ pub fn handle_request( count: revocations.len(), }) } + ControlRequest::DbAdd { name, path } => { + geth_db::validate_db_name(&name).map_err(|_| NodeError::InvalidDbName(name.clone()))?; + if !path.is_file() { + return Err(NodeError::InvalidDbPath(path.display().to_string())); + } + let path = std::fs::canonicalize(path)?; + let resource_id = format!("resource:db:{name}"); + let db_id = format!("db:{name}"); + let resource = StoredResource { + resource_id: resource_id.clone(), + kind: ResourceKind::Db.to_string(), + name: name.clone(), + status: "active".to_owned(), + }; + store.insert_resource(&resource)?; + let stored = StoredDbResource { + db_id, + resource_id, + name, + path: path.display().to_string(), + }; + store.insert_db_resource(&stored)?; + Ok(ControlResponse::DbAdded { + db: db_resource_from_stored(&stored)?, + }) + } + ControlRequest::DbStatus { name } => { + geth_db::validate_db_name(&name).map_err(|_| NodeError::InvalidDbName(name.clone()))?; + let stored = store + .get_db_resource_by_name(&name)? + .ok_or_else(|| NodeError::DbNotFound(name.clone()))?; + Ok(ControlResponse::DbStatus { + db: db_resource_from_stored(&stored)?, + }) + } ControlRequest::ModuleStub { module, command } => { Ok(ControlResponse::NotImplemented { module, command }) } @@ -568,6 +610,21 @@ fn stored_resource_to_descriptor(stored: StoredResource) -> Result Result { + let path = Path::new(&stored.path); + let metadata = path.metadata().ok(); + Ok(DbResource { + id: stored.db_id.clone().into(), + resource: stored.resource_id.clone().into(), + name: stored.name.clone(), + path: stored.path.clone(), + path_exists: metadata.as_ref().is_some_and(std::fs::Metadata::is_file), + size_bytes: metadata.map(|metadata| metadata.len()), + schema_metadata: "not-inspected-until-crsqlite-integration".to_owned(), + sync_status: "local-only".to_owned(), + }) +} + fn store_auth_op(store: &Store, op: &AuthOp) -> Result<(), NodeError> { store.insert_auth_op(&StoredAuthOp { op_id: op.id.to_string(), diff --git a/crates/geth-store/src/lib.rs b/crates/geth-store/src/lib.rs index 121d708..38a34a2 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -222,6 +222,59 @@ impl Store { Ok(()) } + pub fn get_resource_by_kind_name( + &self, + kind: &str, + name: &str, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + "SELECT resource_id, kind, name, status FROM resources WHERE kind = ?1 AND name = ?2", + )?; + let mut rows = stmt.query(params![kind, name])?; + if let Some(row) = rows.next()? { + Ok(Some(StoredResource { + resource_id: row.get(0)?, + kind: row.get(1)?, + name: row.get(2)?, + status: row.get(3)?, + })) + } else { + Ok(None) + } + } + + pub fn insert_db_resource(&self, db: &StoredDbResource) -> Result<(), StoreError> { + self.conn.execute( + r#"INSERT OR REPLACE INTO db_resources(db_id, resource_id, path) + VALUES (?1, ?2, ?3)"#, + params![db.db_id, db.resource_id, db.path], + )?; + Ok(()) + } + + pub fn get_db_resource_by_name( + &self, + name: &str, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + r#"SELECT db.db_id, db.resource_id, resources.name, db.path + FROM db_resources db + JOIN resources ON resources.resource_id = db.resource_id + WHERE resources.kind = 'db' AND resources.name = ?1"#, + )?; + let mut rows = stmt.query(params![name])?; + if let Some(row) = rows.next()? { + Ok(Some(StoredDbResource { + db_id: row.get(0)?, + resource_id: row.get(1)?, + name: row.get(2)?, + path: row.get(3)?, + })) + } else { + Ok(None) + } + } + pub fn record_cas_object( &self, hash: &str, @@ -561,6 +614,14 @@ pub struct StoredResource { pub status: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StoredDbResource { + pub db_id: String, + pub resource_id: String, + pub name: String, + pub path: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct CasObject { pub hash: String, @@ -788,4 +849,38 @@ mod tests { assert!(store.list_cas_objects().expect("objects").is_empty()); assert!(store.list_cas_pins().expect("pins").is_empty()); } + + #[test] + fn db_resource_roundtrip_by_name() { + let store = Store::open_memory().expect("open"); + let resource = StoredResource { + resource_id: "resource:db:notes".to_owned(), + kind: "db".to_owned(), + name: "notes".to_owned(), + status: "active".to_owned(), + }; + store.insert_resource(&resource).expect("insert resource"); + store + .insert_db_resource(&StoredDbResource { + db_id: "db:notes".to_owned(), + resource_id: resource.resource_id.clone(), + name: resource.name.clone(), + path: "/tmp/notes.sqlite".to_owned(), + }) + .expect("insert db"); + + assert_eq!( + store + .get_resource_by_kind_name("db", "notes") + .expect("get resource"), + Some(resource) + ); + assert_eq!( + store + .get_db_resource_by_name("notes") + .expect("get db") + .map(|db| db.path), + Some("/tmp/notes.sqlite".to_owned()) + ); + } } diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index b90a58e..bcd9b9d 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -387,6 +387,73 @@ fn keychain_init_and_status_use_local_keychain_log() { } } +#[test] +fn db_add_and_status_register_local_db_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 db_path = home.path().join("notes.sqlite"); + std::fs::write(&db_path, b"sqlite placeholder").expect("write db"); + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::DbAdd { + name: "notes".to_owned(), + path: db_path.clone(), + }, + ) + .expect("add db"); + match response { + geth_control::ControlResponse::DbAdded { db } => { + assert_eq!(db.name, "notes"); + assert_eq!(db.sync_status, "local-only"); + assert!(db.path_exists); + assert_eq!(db.size_bytes, Some(18)); + } + other => panic!("unexpected response: {other:?}"), + } + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::DbStatus { + name: "notes".to_owned(), + }, + ) + .expect("db status"); + match response { + geth_control::ControlResponse::DbStatus { db } => { + assert_eq!(db.name, "notes"); + assert!(db.path.ends_with("notes.sqlite")); + assert_eq!( + db.schema_metadata, + "not-inspected-until-crsqlite-integration" + ); + } + other => panic!("unexpected response: {other:?}"), + } + + assert!( + geth_node::handle_request( + &node, + geth_control::ControlRequest::DbAdd { + name: "../bad".to_owned(), + path: db_path, + }, + ) + .is_err() + ); + assert!( + geth_node::handle_request( + &node, + geth_control::ControlRequest::DbAdd { + name: "missing".to_owned(), + path: home.path().join("missing.sqlite"), + }, + ) + .is_err() + ); +} + #[test] fn ssh_cert_request_approval_and_revocation_export_use_local_state() { let home = tempfile::tempdir().expect("tempdir"); diff --git a/docs/architecture.md b/docs/architecture.md index 4d89a6a..7a9efc1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,8 +93,12 @@ storage. Local pin/unpin metadata is tracked in SQLite and surfaced in blobs. Iroh-blobs, providers, encrypted blobs, richer cache policies, 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. +`geth-db` currently registers local SQLite paths as DB resources and reports +local-only sync status plus placeholder schema metadata. cr-sqlite loading, +change extraction, and DB sync are future work. + +`geth-kv`, `geth-document`, `geth-pubsub`, `geth-pipe`, and `geth-ssh-proxy` +currently define types, command shape, and roadmap stubs. `geth-ssh-identity` defines SSH trust namespaces plus certificate request, approval, certificate import, and revocation-list data models. The bootstrap diff --git a/docs/roadmap.md b/docs/roadmap.md index ff8b14a..1e48eb4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -273,7 +273,7 @@ Goal: add authorized stream-oriented management workflows over Iroh. Goal: add durable synchronized data structures for SQLite/cr-sqlite and Automerge documents. -- `[ ]` DB resource registration. +- `[x]` DB resource registration. Acceptance criteria: - `geth db add ` records DB metadata. - `geth db status ` reports local path, schema metadata, and sync state.