Files
RedBear-OS/local/recipes/system/redbear-btctl/source/src/bond_store.rs
T
2026-04-16 12:44:51 +01:00

292 lines
8.3 KiB
Rust

use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const DEFAULT_BOND_STORE_ROOT: &str = "/var/lib/bluetooth";
pub const STUB_BOND_SOURCE: &str = "stub-cli";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BondRecord {
pub bond_id: String,
pub alias: Option<String>,
pub created_at_epoch: u64,
pub source: String,
}
#[derive(Clone, Debug)]
pub struct BondStore {
root: PathBuf,
}
impl BondStore {
pub fn from_env() -> Self {
Self {
root: env::var_os("REDBEAR_BTCTL_BOND_STORE_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_BOND_STORE_ROOT)),
}
}
#[cfg(test)]
pub fn new(root: PathBuf) -> Self {
Self { root }
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn adapter_bonds_dir(&self, adapter: &str) -> PathBuf {
debug_assert!(validate_adapter_name(adapter).is_ok());
self.root.join(adapter).join("bonds")
}
pub fn load(&self, adapter: &str) -> io::Result<Vec<BondRecord>> {
validate_adapter_name(adapter)?;
let dir = self.adapter_bonds_dir(adapter);
if !dir.exists() {
return Ok(Vec::new());
}
let mut bonds = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
bonds.push(parse_record(&path)?);
}
bonds.sort_by(|left, right| left.bond_id.cmp(&right.bond_id));
Ok(bonds)
}
pub fn add_stub(
&self,
adapter: &str,
bond_id: &str,
alias: Option<&str>,
) -> io::Result<BondRecord> {
validate_adapter_name(adapter)?;
validate_component("bond_id", bond_id)?;
validate_optional_field("alias", alias)?;
let record = BondRecord {
bond_id: bond_id.to_string(),
alias: alias.map(str::to_string),
created_at_epoch: current_epoch_seconds(),
source: STUB_BOND_SOURCE.to_string(),
};
let dir = self.adapter_bonds_dir(adapter);
fs::create_dir_all(&dir)?;
fs::write(
self.record_path(adapter, bond_id),
serialize_record(&record),
)?;
Ok(record)
}
pub fn remove(&self, adapter: &str, bond_id: &str) -> io::Result<bool> {
validate_adapter_name(adapter)?;
validate_component("bond_id", bond_id)?;
let path = self.record_path(adapter, bond_id);
if !path.exists() {
return Ok(false);
}
fs::remove_file(path)?;
Ok(true)
}
fn record_path(&self, adapter: &str, bond_id: &str) -> PathBuf {
self.adapter_bonds_dir(adapter)
.join(format!("{}.bond", hex_encode(bond_id.as_bytes())))
}
}
fn current_epoch_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn validate_adapter_name(adapter: &str) -> io::Result<()> {
validate_component("adapter", adapter)?;
if adapter == "." || adapter == ".." {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"adapter cannot be a dot path segment",
));
}
Ok(())
}
fn validate_component(name: &str, value: &str) -> io::Result<()> {
if value.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{name} cannot be empty"),
));
}
if value.contains('/') || value.contains('\\') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{name} cannot contain path separators"),
));
}
if value.contains('\n') || value.contains('\r') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{name} cannot contain newlines"),
));
}
Ok(())
}
fn validate_optional_field(name: &str, value: Option<&str>) -> io::Result<()> {
if let Some(value) = value {
if value.contains('\n') || value.contains('\r') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{name} cannot contain newlines"),
));
}
}
Ok(())
}
fn hex_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}
fn serialize_record(record: &BondRecord) -> String {
let mut lines = vec![format!("bond_id={}", record.bond_id)];
if let Some(alias) = &record.alias {
lines.push(format!("alias={alias}"));
}
lines.push(format!("created_at_epoch={}", record.created_at_epoch));
lines.push(format!("source={}", record.source));
format!("{}\n", lines.join("\n"))
}
fn parse_record(path: &Path) -> io::Result<BondRecord> {
let content = fs::read_to_string(path)?;
let mut bond_id = None;
let mut alias = None;
let mut created_at_epoch = None;
let mut source = None;
for line in content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
if let Some(value) = line.strip_prefix("bond_id=") {
bond_id = Some(value.to_string());
} else if let Some(value) = line.strip_prefix("alias=") {
alias = Some(value.to_string());
} else if let Some(value) = line.strip_prefix("created_at_epoch=") {
created_at_epoch = value.parse::<u64>().ok();
} else if let Some(value) = line.strip_prefix("source=") {
source = Some(value.to_string());
}
}
let Some(bond_id) = bond_id else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("missing bond_id in {}", path.display()),
));
};
let Some(created_at_epoch) = created_at_epoch else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("missing created_at_epoch in {}", path.display()),
));
};
let Some(source) = source else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("missing source in {}", path.display()),
));
};
Ok(BondRecord {
bond_id,
alias,
created_at_epoch,
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
fn temp_path(name: &str) -> PathBuf {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
env::temp_dir().join(format!("{name}-{stamp}"))
}
#[test]
fn stub_bond_records_round_trip_through_files() {
let root = temp_path("rbos-bond-store");
let store = BondStore::new(root.clone());
let first = store
.add_stub("hci0", "AA:BB:CC:DD:EE:FF", Some("demo-sensor"))
.unwrap();
let second = store.add_stub("hci0", "11:22:33:44:55:66", None).unwrap();
let bonds = store.load("hci0").unwrap();
assert_eq!(bonds.len(), 2);
assert_eq!(bonds[0].bond_id, second.bond_id);
assert_eq!(bonds[0].alias, None);
assert_eq!(bonds[1].bond_id, first.bond_id);
assert_eq!(bonds[1].alias.as_deref(), Some("demo-sensor"));
assert!(store.remove("hci0", "AA:BB:CC:DD:EE:FF").unwrap());
assert!(!store.remove("hci0", "AA:BB:CC:DD:EE:FF").unwrap());
assert_eq!(store.load("hci0").unwrap().len(), 1);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn invalid_components_are_rejected() {
let root = temp_path("rbos-bond-store-invalid");
let store = BondStore::new(root.clone());
let err = store.add_stub("hci0", "demo/bond", None).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
fs::remove_dir_all(root).ok();
}
#[test]
fn load_rejects_invalid_adapter_component() {
let root = temp_path("rbos-bond-store-invalid-load");
let store = BondStore::new(root.clone());
let err = store.load("../escape").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
let err = store.load("..").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
fs::remove_dir_all(root).ok();
}
}