Add Bluetooth subsystem
Red Bear OS Team
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user