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(
|
CREATE TABLE devices(
|
||||||
mac_address TEXT PRIMARY KEY,
|
mac_address TEXT PRIMARY KEY COLLATE NOCASE,
|
||||||
ipv4_address TEXT NOT NULL,
|
ipv4_address TEXT NOT NULL,
|
||||||
vendor TEXT,
|
vendor TEXT COLLATE NOCASE,
|
||||||
last_seen TEXT NOT NULL,
|
last_seen TEXT NOT NULL,
|
||||||
is_registered INTEGER NOT NULL,
|
is_registered INTEGER NOT NULL,
|
||||||
owner TEXT,
|
owner TEXT COLLATE NOCASE,
|
||||||
device_type TEXT
|
device_type TEXT COLLATE NOCASE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE notifications(
|
CREATE TABLE notifications(
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
created_on TEXT NOT NULL,
|
created_on TEXT NOT NULL,
|
||||||
notification_type TEXT NOT NULL,
|
notification_type TEXT NOT NULL COLLATE NOCASE,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL COLLATE NOCASE,
|
||||||
body TEXT,
|
body TEXT COLLATE NOCASE,
|
||||||
is_new INTEGER NOT NULL
|
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(
|
CREATE TABLE device_events(
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
mac_address TEXT NOT NULL,
|
mac_address TEXT NOT NULL COLLATE NOCASE,
|
||||||
created_on TEXT NOT NULL,
|
created_on TEXT NOT NULL,
|
||||||
event_type TEXT NOT NULL,
|
event_type TEXT NOT NULL COLLATE NOCASE,
|
||||||
ipv4_address TEXT NOT NULL,
|
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 rusqlite::{params, params_from_iter};
|
||||||
|
|
||||||
use crate::model::device_events::DeviceEvent;
|
use crate::model::device_events::DeviceEvent;
|
||||||
|
use crate::utils::network::normalize_mac;
|
||||||
|
|
||||||
pub fn insert(event: DeviceEvent) -> Result<i64, DbError> {
|
pub fn insert(event: DeviceEvent) -> Result<i64, DbError> {
|
||||||
let conn = db::get_db_connection();
|
let conn = db::get_db_connection();
|
||||||
|
let mac_address = normalize_mac(&event.mac_address);
|
||||||
|
|
||||||
match conn.execute(
|
match conn.execute(
|
||||||
"INSERT INTO device_events (mac_address, created_on, event_type, ipv4_address, vendor) VALUES (?1, ?2, ?3, ?4, ?5)",
|
"INSERT INTO device_events (mac_address, created_on, event_type, ipv4_address, vendor) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
params![
|
params![
|
||||||
event.mac_address,
|
mac_address,
|
||||||
event.created_on.to_rfc3339_opts(chrono::SecondsFormat::Nanos, false),
|
event.created_on.to_rfc3339_opts(chrono::SecondsFormat::Nanos, false),
|
||||||
event.event_type,
|
event.event_type,
|
||||||
event.ipv4_address,
|
event.ipv4_address,
|
||||||
@@ -46,6 +48,7 @@ pub fn list(
|
|||||||
let mut params: Vec<rusqlite::types::Value> = Vec::new();
|
let mut params: Vec<rusqlite::types::Value> = Vec::new();
|
||||||
|
|
||||||
if let Some(mac) = mac_address {
|
if let Some(mac) = mac_address {
|
||||||
|
let mac = normalize_mac(&mac);
|
||||||
debug!("Adding filter mac_address={}", mac);
|
debug!("Adding filter mac_address={}", mac);
|
||||||
sql_statement.push_str(" AND mac_address=?");
|
sql_statement.push_str(" AND mac_address=?");
|
||||||
params.push(mac.into());
|
params.push(mac.into());
|
||||||
|
|||||||
+191
-1
@@ -5,6 +5,7 @@ use rusqlite::{params, params_from_iter};
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::{self, error::DbError},
|
db::{self, error::DbError},
|
||||||
model::devices::{Device, DeviceSummary},
|
model::devices::{Device, DeviceSummary},
|
||||||
|
utils::network::normalize_mac,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Whitelist of columns allowed for `sort_by`. Anything outside this list falls back to the
|
// 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
|
// Read device from its MAC address
|
||||||
pub fn read(mac_address: String) -> Option<Device> {
|
pub fn read(mac_address: String) -> Option<Device> {
|
||||||
let conn = db::get_db_connection();
|
let conn = db::get_db_connection();
|
||||||
|
let mac_address = normalize_mac(&mac_address);
|
||||||
|
|
||||||
let result: Result<Device, rusqlite::Error> = conn.query_one(
|
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",
|
"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> {
|
pub fn insert(device: Device) -> Result<(), DbError> {
|
||||||
let conn = db::get_db_connection();
|
let conn = db::get_db_connection();
|
||||||
|
let mac_address = normalize_mac(&device.mac_address);
|
||||||
|
|
||||||
match conn.execute(
|
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)",
|
"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(_) => {
|
Ok(_) => {
|
||||||
debug!("Device inserted into database: {}", device);
|
debug!("Device inserted into database: {}", device);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -225,6 +228,7 @@ pub fn seen(
|
|||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
) -> Result<(), DbError> {
|
) -> Result<(), DbError> {
|
||||||
let conn = db::get_db_connection();
|
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 sql = "UPDATE devices SET ipv4_address=?, last_seen=?".to_string();
|
||||||
let mut params: Vec<rusqlite::types::Value> = vec![
|
let mut params: Vec<rusqlite::types::Value> = vec![
|
||||||
@@ -278,6 +282,7 @@ pub fn update(
|
|||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
) -> Result<(), DbError> {
|
) -> Result<(), DbError> {
|
||||||
let conn = db::get_db_connection();
|
let conn = db::get_db_connection();
|
||||||
|
let mac_address = normalize_mac(&mac_address);
|
||||||
|
|
||||||
match conn.execute(
|
match conn.execute(
|
||||||
"UPDATE devices SET owner=?1, device_type=?2, vendor=?3, name=?4 WHERE mac_address=?5",
|
"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>,
|
name: Option<String>,
|
||||||
) -> Result<(), DbError> {
|
) -> Result<(), DbError> {
|
||||||
let conn = db::get_db_connection();
|
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
|
// 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.
|
// 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> {
|
pub fn unregister(mac_address: String) -> Result<(), DbError> {
|
||||||
let conn = db::get_db_connection();
|
let conn = db::get_db_connection();
|
||||||
|
let mac_address = normalize_mac(&mac_address);
|
||||||
|
|
||||||
match conn.execute(
|
match conn.execute(
|
||||||
"UPDATE devices SET is_registered=0, owner='' WHERE mac_address=?1",
|
"UPDATE devices SET is_registered=0, owner='' WHERE mac_address=?1",
|
||||||
@@ -1175,4 +1182,187 @@ mod tests {
|
|||||||
device.mac_address
|
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 rusqlite::{params, params_from_iter};
|
||||||
|
|
||||||
use crate::model::notifications::Notification;
|
use crate::model::notifications::Notification;
|
||||||
|
use crate::utils::network::normalize_mac;
|
||||||
|
|
||||||
pub fn list(
|
pub fn list(
|
||||||
is_new: Option<bool>,
|
is_new: Option<bool>,
|
||||||
@@ -63,10 +64,11 @@ pub fn list(
|
|||||||
|
|
||||||
pub fn insert(notification: Notification) -> Result<i64, DbError> {
|
pub fn insert(notification: Notification) -> Result<i64, DbError> {
|
||||||
let conn = db::get_db_connection();
|
let conn = db::get_db_connection();
|
||||||
|
let mac_address = notification.mac_address.as_deref().map(normalize_mac);
|
||||||
|
|
||||||
match conn.execute(
|
match conn.execute(
|
||||||
"INSERT INTO notifications (created_on, notification_type, title, body, is_new, mac_address) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
"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(_) => {
|
Ok(_) => {
|
||||||
debug!("Notification inserted into database: {}", notification);
|
debug!("Notification inserted into database: {}", notification);
|
||||||
Ok(conn.last_insert_rowid())
|
Ok(conn.last_insert_rowid())
|
||||||
|
|||||||
@@ -138,6 +138,13 @@ fn probe_mac(target_ip: Ipv4Addr, interface: Option<String>, timeout: Duration)
|
|||||||
None
|
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
|
/// 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
|
/// 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.
|
/// 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(""));
|
||||||
assert!(!is_locally_administered("zz:00:00:00:00:00"));
|
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