mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Sort and look up text columns case-insensitively
Add COLLATE NOCASE on every text content column (names, owners, vendors, device types, MAC addresses, notification titles/bodies/types, event types) so DESC-by-name lists no longer order "iPad" before "Lutron". Normalize MAC addresses to lowercase at the DB layer so the now case-insensitive primary key cannot accept duplicate-looking rows. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
0304c50efb
commit
0beca44496
@@ -1,18 +1,18 @@
|
||||
CREATE TABLE devices(
|
||||
mac_address TEXT PRIMARY KEY,
|
||||
mac_address TEXT PRIMARY KEY COLLATE NOCASE,
|
||||
ipv4_address TEXT NOT NULL,
|
||||
vendor TEXT,
|
||||
vendor TEXT COLLATE NOCASE,
|
||||
last_seen TEXT NOT NULL,
|
||||
is_registered INTEGER NOT NULL,
|
||||
owner TEXT,
|
||||
device_type TEXT
|
||||
owner TEXT COLLATE NOCASE,
|
||||
device_type TEXT COLLATE NOCASE
|
||||
);
|
||||
|
||||
CREATE TABLE notifications(
|
||||
id INTEGER PRIMARY KEY,
|
||||
created_on TEXT NOT NULL,
|
||||
notification_type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT,
|
||||
notification_type TEXT NOT NULL COLLATE NOCASE,
|
||||
title TEXT NOT NULL COLLATE NOCASE,
|
||||
body TEXT COLLATE NOCASE,
|
||||
is_new INTEGER NOT NULL
|
||||
);
|
||||
|
||||
@@ -1 +1 @@
|
||||
ALTER TABLE notifications ADD COLUMN mac_address TEXT;
|
||||
ALTER TABLE notifications ADD COLUMN mac_address TEXT COLLATE NOCASE;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
CREATE TABLE device_events(
|
||||
id INTEGER PRIMARY KEY,
|
||||
mac_address TEXT NOT NULL,
|
||||
mac_address TEXT NOT NULL COLLATE NOCASE,
|
||||
created_on TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL COLLATE NOCASE,
|
||||
ipv4_address TEXT NOT NULL,
|
||||
vendor TEXT NOT NULL
|
||||
vendor TEXT NOT NULL COLLATE NOCASE
|
||||
);
|
||||
|
||||
@@ -1 +1 @@
|
||||
ALTER TABLE devices ADD COLUMN name TEXT;
|
||||
ALTER TABLE devices ADD COLUMN name TEXT COLLATE NOCASE;
|
||||
|
||||
@@ -5,14 +5,16 @@ use log::{debug, error};
|
||||
use rusqlite::{params, params_from_iter};
|
||||
|
||||
use crate::model::device_events::DeviceEvent;
|
||||
use crate::utils::network::normalize_mac;
|
||||
|
||||
pub fn insert(event: DeviceEvent) -> Result<i64, DbError> {
|
||||
let conn = db::get_db_connection();
|
||||
let mac_address = normalize_mac(&event.mac_address);
|
||||
|
||||
match conn.execute(
|
||||
"INSERT INTO device_events (mac_address, created_on, event_type, ipv4_address, vendor) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
event.mac_address,
|
||||
mac_address,
|
||||
event.created_on.to_rfc3339_opts(chrono::SecondsFormat::Nanos, false),
|
||||
event.event_type,
|
||||
event.ipv4_address,
|
||||
@@ -46,6 +48,7 @@ pub fn list(
|
||||
let mut params: Vec<rusqlite::types::Value> = Vec::new();
|
||||
|
||||
if let Some(mac) = mac_address {
|
||||
let mac = normalize_mac(&mac);
|
||||
debug!("Adding filter mac_address={}", mac);
|
||||
sql_statement.push_str(" AND mac_address=?");
|
||||
params.push(mac.into());
|
||||
|
||||
+191
-1
@@ -5,6 +5,7 @@ use rusqlite::{params, params_from_iter};
|
||||
use crate::{
|
||||
db::{self, error::DbError},
|
||||
model::devices::{Device, DeviceSummary},
|
||||
utils::network::normalize_mac,
|
||||
};
|
||||
|
||||
// Whitelist of columns allowed for `sort_by`. Anything outside this list falls back to the
|
||||
@@ -124,6 +125,7 @@ pub fn list_devices(
|
||||
// Read device from its MAC address
|
||||
pub fn read(mac_address: String) -> Option<Device> {
|
||||
let conn = db::get_db_connection();
|
||||
let mac_address = normalize_mac(&mac_address);
|
||||
|
||||
let result: Result<Device, rusqlite::Error> = conn.query_one(
|
||||
"SELECT mac_address, ipv4_address, vendor, last_seen, is_registered, owner, device_type, name FROM devices WHERE mac_address=?1",
|
||||
@@ -158,10 +160,11 @@ pub fn read(mac_address: String) -> Option<Device> {
|
||||
|
||||
pub fn insert(device: Device) -> Result<(), DbError> {
|
||||
let conn = db::get_db_connection();
|
||||
let mac_address = normalize_mac(&device.mac_address);
|
||||
|
||||
match conn.execute(
|
||||
"INSERT INTO devices (mac_address, ipv4_address, vendor, last_seen, is_registered, owner, device_type, name) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![device.mac_address, device.ipv4_address, device.vendor, device.last_seen.to_rfc3339_opts(chrono::SecondsFormat::Nanos, false), device.is_registered, device.owner, device.device_type, device.name]) {
|
||||
params![mac_address, device.ipv4_address, device.vendor, device.last_seen.to_rfc3339_opts(chrono::SecondsFormat::Nanos, false), device.is_registered, device.owner, device.device_type, device.name]) {
|
||||
Ok(_) => {
|
||||
debug!("Device inserted into database: {}", device);
|
||||
Ok(())
|
||||
@@ -225,6 +228,7 @@ pub fn seen(
|
||||
name: Option<String>,
|
||||
) -> Result<(), DbError> {
|
||||
let conn = db::get_db_connection();
|
||||
let mac_address = normalize_mac(&mac_address);
|
||||
|
||||
let mut sql = "UPDATE devices SET ipv4_address=?, last_seen=?".to_string();
|
||||
let mut params: Vec<rusqlite::types::Value> = vec![
|
||||
@@ -278,6 +282,7 @@ pub fn update(
|
||||
name: Option<String>,
|
||||
) -> Result<(), DbError> {
|
||||
let conn = db::get_db_connection();
|
||||
let mac_address = normalize_mac(&mac_address);
|
||||
|
||||
match conn.execute(
|
||||
"UPDATE devices SET owner=?1, device_type=?2, vendor=?3, name=?4 WHERE mac_address=?5",
|
||||
@@ -301,6 +306,7 @@ pub fn register(
|
||||
name: Option<String>,
|
||||
) -> Result<(), DbError> {
|
||||
let conn = db::get_db_connection();
|
||||
let mac_address = normalize_mac(&mac_address);
|
||||
|
||||
// Only write the name column when supplied, so a user registering without typing a name
|
||||
// never wipes a hostname previously stored by the mDNS scanner.
|
||||
@@ -327,6 +333,7 @@ pub fn register(
|
||||
|
||||
pub fn unregister(mac_address: String) -> Result<(), DbError> {
|
||||
let conn = db::get_db_connection();
|
||||
let mac_address = normalize_mac(&mac_address);
|
||||
|
||||
match conn.execute(
|
||||
"UPDATE devices SET is_registered=0, owner='' WHERE mac_address=?1",
|
||||
@@ -1175,4 +1182,187 @@ mod tests {
|
||||
device.mac_address
|
||||
);
|
||||
}
|
||||
|
||||
// Case-insensitive sorting: with NOCASE on the `name` column, mixed-case values must sort
|
||||
// as if they were all the same case.
|
||||
#[tokio::test]
|
||||
async fn test_list_sorting_is_case_insensitive() {
|
||||
tests_common::setup().await;
|
||||
|
||||
let now = Utc::now();
|
||||
let mut a = Device::new(
|
||||
"10:00:00:00:00:01".to_string(),
|
||||
"10.0.0.1".to_string(),
|
||||
"Vendor".to_string(),
|
||||
now,
|
||||
);
|
||||
a.name = Some("iPad".to_string());
|
||||
a.owner = "alice".to_string();
|
||||
a.device_type = "phone".to_string();
|
||||
insert(a).unwrap();
|
||||
|
||||
let mut b = Device::new(
|
||||
"10:00:00:00:00:02".to_string(),
|
||||
"10.0.0.2".to_string(),
|
||||
"Vendor".to_string(),
|
||||
now,
|
||||
);
|
||||
b.name = Some("Lutron".to_string());
|
||||
b.owner = "Bob".to_string();
|
||||
b.device_type = "Light".to_string();
|
||||
insert(b).unwrap();
|
||||
|
||||
let mut c = Device::new(
|
||||
"10:00:00:00:00:03".to_string(),
|
||||
"10.0.0.3".to_string(),
|
||||
"Vendor".to_string(),
|
||||
now,
|
||||
);
|
||||
c.name = Some("apple-tv".to_string());
|
||||
c.owner = "carol".to_string();
|
||||
c.device_type = "TV".to_string();
|
||||
insert(c).unwrap();
|
||||
|
||||
let mut d = Device::new(
|
||||
"10:00:00:00:00:04".to_string(),
|
||||
"10.0.0.4".to_string(),
|
||||
"Vendor".to_string(),
|
||||
now,
|
||||
);
|
||||
d.name = Some("Zebra".to_string());
|
||||
d.owner = "Dave".to_string();
|
||||
d.device_type = "printer".to_string();
|
||||
insert(d).unwrap();
|
||||
|
||||
let extract_names = |list: &[Device]| -> Vec<String> {
|
||||
list.iter()
|
||||
.filter_map(|item| item.name.clone())
|
||||
.filter(|name| ["iPad", "Lutron", "apple-tv", "Zebra"].contains(&name.as_str()))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let by_name_asc = list_devices(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("name".to_string()),
|
||||
Some("asc".to_string()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
extract_names(&by_name_asc),
|
||||
vec!["apple-tv", "iPad", "Lutron", "Zebra"],
|
||||
"name asc should be case-insensitive"
|
||||
);
|
||||
|
||||
let by_name_desc = list_devices(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("name".to_string()),
|
||||
Some("desc".to_string()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
extract_names(&by_name_desc),
|
||||
vec!["Zebra", "Lutron", "iPad", "apple-tv"],
|
||||
"name desc should be case-insensitive"
|
||||
);
|
||||
|
||||
let extract_owners = |list: &[Device]| -> Vec<String> {
|
||||
list.iter()
|
||||
.map(|item| item.owner.clone())
|
||||
.filter(|owner| ["alice", "Bob", "carol", "Dave"].contains(&owner.as_str()))
|
||||
.collect()
|
||||
};
|
||||
let by_owner_asc = list_devices(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("owner".to_string()),
|
||||
Some("asc".to_string()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
extract_owners(&by_owner_asc),
|
||||
vec!["alice", "Bob", "carol", "Dave"],
|
||||
"owner asc should be case-insensitive"
|
||||
);
|
||||
|
||||
let extract_types = |list: &[Device]| -> Vec<String> {
|
||||
list.iter()
|
||||
.map(|item| item.device_type.clone())
|
||||
.filter(|t| ["phone", "Light", "TV", "printer"].contains(&t.as_str()))
|
||||
.collect()
|
||||
};
|
||||
let by_type_asc = list_devices(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("device_type".to_string()),
|
||||
Some("asc".to_string()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
extract_types(&by_type_asc),
|
||||
vec!["Light", "phone", "printer", "TV"],
|
||||
"device_type asc should be case-insensitive"
|
||||
);
|
||||
}
|
||||
|
||||
// MAC normalization: rows inserted with one casing are readable with any casing, and the
|
||||
// stored value is canonical lowercase.
|
||||
#[tokio::test]
|
||||
async fn test_mac_address_is_normalized_to_lowercase() {
|
||||
tests_common::setup().await;
|
||||
|
||||
insert(Device::new(
|
||||
"20:AA:BB:CC:DD:01".to_string(),
|
||||
"10.0.1.1".to_string(),
|
||||
"Vendor".to_string(),
|
||||
Utc::now(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let by_upper = read("20:AA:BB:CC:DD:01".to_string()).unwrap();
|
||||
assert_eq!(by_upper.mac_address, "20:aa:bb:cc:dd:01");
|
||||
|
||||
let by_lower = read("20:aa:bb:cc:dd:01".to_string()).unwrap();
|
||||
assert_eq!(by_lower.mac_address, "20:aa:bb:cc:dd:01");
|
||||
|
||||
let by_mixed = read("20:Aa:Bb:Cc:Dd:01".to_string()).unwrap();
|
||||
assert_eq!(by_mixed.mac_address, "20:aa:bb:cc:dd:01");
|
||||
|
||||
// Inserting the same MAC in different casing must conflict with the existing primary key.
|
||||
let duplicate = insert(Device::new(
|
||||
"20:aa:bb:cc:dd:01".to_string(),
|
||||
"10.0.1.2".to_string(),
|
||||
"Vendor".to_string(),
|
||||
Utc::now(),
|
||||
));
|
||||
assert!(
|
||||
duplicate.is_err(),
|
||||
"Insert with same MAC (different case) must fail the case-insensitive PK"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use log::{debug, error};
|
||||
use rusqlite::{params, params_from_iter};
|
||||
|
||||
use crate::model::notifications::Notification;
|
||||
use crate::utils::network::normalize_mac;
|
||||
|
||||
pub fn list(
|
||||
is_new: Option<bool>,
|
||||
@@ -63,10 +64,11 @@ pub fn list(
|
||||
|
||||
pub fn insert(notification: Notification) -> Result<i64, DbError> {
|
||||
let conn = db::get_db_connection();
|
||||
let mac_address = notification.mac_address.as_deref().map(normalize_mac);
|
||||
|
||||
match conn.execute(
|
||||
"INSERT INTO notifications (created_on, notification_type, title, body, is_new, mac_address) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![notification.created_on.to_rfc3339_opts(chrono::SecondsFormat::Nanos, false), notification.notification_type, notification.title, notification.body, notification.is_new, notification.mac_address]) {
|
||||
params![notification.created_on.to_rfc3339_opts(chrono::SecondsFormat::Nanos, false), notification.notification_type, notification.title, notification.body, notification.is_new, mac_address]) {
|
||||
Ok(_) => {
|
||||
debug!("Notification inserted into database: {}", notification);
|
||||
Ok(conn.last_insert_rowid())
|
||||
|
||||
@@ -138,6 +138,13 @@ fn probe_mac(target_ip: Ipv4Addr, interface: Option<String>, timeout: Duration)
|
||||
None
|
||||
}
|
||||
|
||||
/// Normalize a MAC address to a canonical lowercase form so DB rows and lookups are
|
||||
/// case-insensitive in practice (the `mac_address` column is NOCASE, but we still store a single
|
||||
/// representation so callers comparing strings outside SQL see consistent values).
|
||||
pub fn normalize_mac(mac: &str) -> String {
|
||||
mac.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Return whether a MAC address is locally administered, i.e. the second-least-significant bit of
|
||||
/// its first octet is set. Randomized/private MACs (e.g. Apple "Private WiFi Address") are locally
|
||||
/// administered and have no real OUI. Returns `false` for malformed input.
|
||||
@@ -273,4 +280,12 @@ mod tests {
|
||||
assert!(!is_locally_administered(""));
|
||||
assert!(!is_locally_administered("zz:00:00:00:00:00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_mac_lowercases_input() {
|
||||
assert_eq!(normalize_mac("AA:BB:CC:DD:EE:FF"), "aa:bb:cc:dd:ee:ff");
|
||||
assert_eq!(normalize_mac("aa:bb:cc:dd:ee:ff"), "aa:bb:cc:dd:ee:ff");
|
||||
assert_eq!(normalize_mac("Aa:Bb:Cc:Dd:Ee:Ff"), "aa:bb:cc:dd:ee:ff");
|
||||
assert_eq!(normalize_mac(""), "");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user