//! The gateway's own database: printers an operator added from the console. //! //! Why the gateway keeps these itself and not the cloud: a printer's access //! code is a credential for a machine on someone's LAN, and the platform's //! rule is that those never rest in the cloud. The console sends the code //! once, over the uplink, and this is where it lands. It also means the //! fleet survives the API being unreachable - the gateway starts, connects //! its printers and keeps reporting locally from this alone. //! //! SQLite, one file. The file holds credentials, so on Unix it is created //! owner-only (0600) before SQLite touches it; SQLite gives its journal files //! the same mode as the main one. //! //! `printers.toml` still works and is read alongside this; see `main.rs`. use std::path::Path; use std::sync::Mutex; use anyhow::Context; use rusqlite::{Connection, params}; use crate::fleet::PrinterEntry; pub struct Store { // rusqlite's `Connection` is not `Sync`, so it cannot be shared between // tasks directly. Wrapping it in a `Mutex` is the plain fix: one caller // at a time, which is plenty for a few writes a day. conn: Mutex, } impl Store { pub fn open(path: &Path) -> anyhow::Result { if let Some(dir) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) { std::fs::create_dir_all(dir).with_context(|| format!("creating {dir:?} for the local store"))?; } create_private(path)?; let conn = Connection::open(path).with_context(|| format!("opening the local store at {path:?}"))?; // Each printer is a JSON document keyed by its id. The id is the // gateway's own printer id - the one telemetry is reported under - // so "which printer" has a single answer everywhere. conn.execute_batch( "CREATE TABLE IF NOT EXISTS printers ( id TEXT PRIMARY KEY, json TEXT NOT NULL );", )?; Ok(Self { conn: Mutex::new(conn) }) } /// Adds a printer, or replaces the one with the same id. pub fn save_printer(&self, entry: &PrinterEntry) -> anyhow::Result<()> { let json = serde_json::to_string(entry)?; let conn = self.conn.lock().expect("store lock poisoned"); conn.execute( "INSERT INTO printers (id, json) VALUES (?1, ?2) ON CONFLICT(id) DO UPDATE SET json = excluded.json", params![entry.id, json], )?; Ok(()) } /// Forgets a printer. True if there was one. pub fn remove_printer(&self, id: &str) -> anyhow::Result { let conn = self.conn.lock().expect("store lock poisoned"); Ok(conn.execute("DELETE FROM printers WHERE id = ?1", params![id])? > 0) } /// Every stored printer, oldest first. pub fn printers(&self) -> anyhow::Result> { let conn = self.conn.lock().expect("store lock poisoned"); let mut statement = conn.prepare("SELECT json FROM printers ORDER BY rowid")?; let rows = statement.query_map([], |row| row.get::<_, String>(0))?; let mut out = Vec::new(); for json in rows { out.push(serde_json::from_str(&json?).context("a stored printer no longer parses")?); } Ok(out) } } /// Creates the file empty and owner-only if it does not exist yet. #[cfg(unix)] fn create_private(path: &Path) -> anyhow::Result<()> { use std::os::unix::fs::OpenOptionsExt; std::fs::OpenOptions::new() .write(true) .create(true) .truncate(false) .mode(0o600) .open(path) .with_context(|| format!("creating the local store {path:?}"))?; Ok(()) } /// Windows: the file inherits its folder's permissions, which for the /// per-user data directory is already that user's. Nothing to set here. #[cfg(not(unix))] fn create_private(_path: &Path) -> anyhow::Result<()> { Ok(()) } #[cfg(test)] mod tests { use super::*; fn entry(id: &str, access_code: &str) -> PrinterEntry { PrinterEntry { id: id.into(), name: format!("Printer {id}"), vendor: "bambu".into(), host: Some("192.168.0.50".into()), access_code: Some(access_code.into()), sn: Some("01P00A000000000".into()), api_key: None, port: None, baud_rate: 115_200, ca_cert_path: None, require_valid_cert: true, } } /// A throwaway directory, removed when dropped. Hand-rolled rather than /// pulling in a crate for four tests. struct TempDir(std::path::PathBuf); impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn temp_db() -> (TempDir, std::path::PathBuf) { use std::sync::atomic::{AtomicU32, Ordering}; static NEXT: AtomicU32 = AtomicU32::new(0); let dir = std::env::temp_dir().join(format!( "continuum-store-test-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed) )); let path = dir.join("nested").join("continuum.db"); (TempDir(dir), path) } #[test] fn what_was_saved_comes_back_after_reopening() { let (_dir, path) = temp_db(); Store::open(&path).unwrap().save_printer(&entry("a", "11111111")).unwrap(); let reopened = Store::open(&path).unwrap(); let printers = reopened.printers().unwrap(); assert_eq!(printers.len(), 1); assert_eq!(printers[0].id, "a"); assert_eq!(printers[0].access_code.as_deref(), Some("11111111")); assert_eq!(printers[0].sn.as_deref(), Some("01P00A000000000")); } #[test] fn saving_the_same_id_replaces_it_and_order_is_oldest_first() { let (_dir, path) = temp_db(); let store = Store::open(&path).unwrap(); store.save_printer(&entry("a", "old")).unwrap(); store.save_printer(&entry("b", "bbb")).unwrap(); store.save_printer(&entry("a", "new")).unwrap(); let printers = store.printers().unwrap(); let ids: Vec<_> = printers.iter().map(|p| p.id.as_str()).collect(); assert_eq!(ids, ["a", "b"]); assert_eq!(printers[0].access_code.as_deref(), Some("new")); } #[test] fn removing_reports_whether_anything_was_there() { let (_dir, path) = temp_db(); let store = Store::open(&path).unwrap(); store.save_printer(&entry("a", "x")).unwrap(); assert!(store.remove_printer("a").unwrap()); assert!(!store.remove_printer("a").unwrap()); assert!(store.printers().unwrap().is_empty()); } #[cfg(unix)] #[test] fn the_file_holding_credentials_is_owner_only() { use std::os::unix::fs::PermissionsExt; let (_dir, path) = temp_db(); let store = Store::open(&path).unwrap(); store.save_printer(&entry("a", "secret")).unwrap(); let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600); } #[test] fn a_corrupt_file_is_an_error_not_an_empty_fleet() { let (_dir, path) = temp_db(); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, b"this is not a sqlite database at all, just text").unwrap(); assert!(Store::open(&path).is_err()); } }