use std::path::{Path, PathBuf};
use std::process::Command;
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use crate::{KeyError, Result};
pub(super) struct LocatedDb {
pub db_bytes: Vec<u8>,
pub device_root: Option<PathBuf>,
}
pub(super) fn find_kobo_db() -> Result<LocatedDb> {
#[cfg(target_os = "windows")]
suppress_removable_drive_dialogs();
for root in mount_roots() {
let db = root.join(".kobo").join("KoboReader.sqlite");
if db.is_file() {
let db_bytes = read_db(&db)?;
return Ok(LocatedDb {
db_bytes,
device_root: Some(root),
});
}
}
if let Some(db) = desktop_db_path() {
if db.is_file() {
let db_bytes = read_db(&db)?;
return Ok(LocatedDb {
db_bytes,
device_root: None,
});
}
}
Err(KeyError::NotFound(
"no Kobo database found (mount a Kobo device or install Kobo Desktop)".into(),
))
}
fn read_db(path: &Path) -> Result<Vec<u8>> {
std::fs::read(path).map_err(|e| KeyError::Invalid(format!("read {}: {e}", path.display())))
}
fn desktop_db_path() -> Option<PathBuf> {
#[cfg(target_os = "macos")]
{
let home = std::env::var_os("HOME")?;
Some(
Path::new(&home)
.join("Library/Application Support/Kobo/Kobo Desktop Edition/Kobo.sqlite"),
)
}
#[cfg(target_os = "windows")]
{
let local = std::env::var_os("LOCALAPPDATA")?;
Some(Path::new(&local).join(r"Kobo\Kobo Desktop Edition\Kobo.sqlite"))
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
None
}
}
fn mount_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
#[cfg(target_os = "macos")]
{
collect_subdirs(Path::new("/Volumes"), &mut roots);
}
#[cfg(target_os = "linux")]
{
if let Some(user) = std::env::var_os("USER") {
collect_subdirs(&Path::new("/media").join(&user), &mut roots);
collect_subdirs(&Path::new("/run/media").join(&user), &mut roots);
}
collect_subdirs(Path::new("/media"), &mut roots);
collect_subdirs(Path::new("/mnt"), &mut roots);
}
#[cfg(target_os = "windows")]
{
for letter in b'D'..=b'Z' {
roots.push(PathBuf::from(format!("{}:\\", letter as char)));
}
}
roots
}
#[cfg(target_os = "windows")]
fn suppress_removable_drive_dialogs() {
const SEM_FAILCRITICALERRORS: u32 = 0x0001;
#[link(name = "kernel32")]
extern "system" {
fn SetThreadErrorMode(new_mode: u32, old_mode: *mut u32) -> i32;
}
unsafe {
SetThreadErrorMode(SEM_FAILCRITICALERRORS, std::ptr::null_mut());
}
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn collect_subdirs(dir: &Path, out: &mut Vec<PathBuf>) {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
out.push(entry.path());
}
}
}
}
pub(super) fn device_serial(root: &Path) -> Option<String> {
let xml =
std::fs::read_to_string(root.join(".adobe-digital-editions").join("device.xml")).ok()?;
parse_device_serial(&xml)
}
pub(super) fn parse_device_serial(xml: &str) -> Option<String> {
let mut reader = Reader::from_reader(xml.as_bytes());
let mut buf = Vec::new();
let mut capturing = false;
let mut serial = String::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) if e.local_name().as_ref() == b"deviceSerial" => {
capturing = true;
serial.clear();
}
Ok(Event::Text(ref e)) if capturing => {
if let Ok(chunk) = e.unescape() {
serial.push_str(&chunk);
}
}
Ok(Event::CData(ref e)) if capturing => {
serial.push_str(&String::from_utf8_lossy(e));
}
Ok(Event::End(ref e)) if e.local_name().as_ref() == b"deviceSerial" => {
let trimmed = serial.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
capturing = false;
}
Ok(Event::Eof) | Err(_) => return None,
_ => {}
}
buf.clear();
}
}
pub(super) fn enumerate_macaddrs() -> Vec<String> {
let output = macaddr_command();
match output {
Some(text) => parse_macaddrs(&text),
None => Vec::new(),
}
}
fn macaddr_command() -> Option<String> {
for (bin, args) in candidate_nic_commands() {
if let Ok(out) = Command::new(bin).args(&args).output() {
if out.status.success() {
let text = String::from_utf8_lossy(&out.stdout).into_owned();
if !text.trim().is_empty() {
return Some(text);
}
}
}
}
None
}
fn candidate_nic_commands() -> Vec<(&'static str, Vec<&'static str>)> {
#[cfg(target_os = "macos")]
{
vec![("/sbin/ifconfig", vec!["-a"])]
}
#[cfg(target_os = "linux")]
{
vec![
("ip", vec!["-br", "link"]),
("/sbin/ifconfig", vec!["-a"]),
("ifconfig", vec!["-a"]),
]
}
#[cfg(target_os = "windows")]
{
vec![("getmac", vec![])]
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
Vec::new()
}
}
pub(super) fn parse_macaddrs(text: &str) -> Vec<String> {
let mut macs: Vec<String> = Vec::new();
for raw in text.split(|c: char| c.is_whitespace() || c == '(' || c == ')') {
if let Some(mac) = normalize_mac(raw) {
if !macs.contains(&mac) {
macs.push(mac);
}
}
}
macs
}
fn normalize_mac(token: &str) -> Option<String> {
let sep = if token.contains(':') {
':'
} else if token.contains('-') {
'-'
} else {
return None;
};
let octets: Vec<&str> = token.split(sep).collect();
if octets.len() != 6 {
return None;
}
if octets
.iter()
.any(|o| o.len() != 2 || !o.bytes().all(|b| b.is_ascii_hexdigit()))
{
return None;
}
Some(octets.join(":").to_uppercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_ifconfig_macs() {
let sample = "\
en0: flags=8863<UP,BROADCAST> mtu 1500
ether a4:83:e7:1b:2c:3d
inet 192.168.1.5 netmask 0xffffff00
lo0: flags=8049<UP,LOOPBACK>
en1: flags=8863 ether 00:11:22:33:44:55";
let macs = parse_macaddrs(sample);
assert_eq!(
macs,
vec![
"A4:83:E7:1B:2C:3D".to_string(),
"00:11:22:33:44:55".to_string()
]
);
}
#[test]
fn parses_getmac_dash_form_and_dedups() {
let sample = "Physical Address Transport Name
=================== ==========================================
A4-83-E7-1B-2C-3D \\Device\\Tcpip_{GUID}
A4-83-E7-1B-2C-3D \\Device\\Tcpip_{GUID}";
let macs = parse_macaddrs(sample);
assert_eq!(macs, vec!["A4:83:E7:1B:2C:3D".to_string()]);
}
#[test]
fn ignores_non_mac_tokens() {
let sample = "inet6 fe80::1 12:34:56 netmask 0xffffff00 aa:bb:cc";
assert!(parse_macaddrs(sample).is_empty());
}
#[test]
fn parses_device_serial_namespaced() {
let xml = r#"<?xml version="1.0"?>
<adept:deviceInfo xmlns:adept="http://ns.adobe.com/adept">
<adept:deviceSerial>N1234567890ABC</adept:deviceSerial>
<adept:deviceName>Kobo</adept:deviceName>
</adept:deviceInfo>"#;
assert_eq!(parse_device_serial(xml), Some("N1234567890ABC".to_string()));
}
#[test]
fn parses_device_serial_unprefixed() {
let xml = "<deviceInfo><deviceSerial>ABC123</deviceSerial></deviceInfo>";
assert_eq!(parse_device_serial(xml), Some("ABC123".to_string()));
}
#[test]
fn no_device_serial_returns_none() {
let xml = "<deviceInfo><deviceName>Kobo</deviceName></deviceInfo>";
assert_eq!(parse_device_serial(xml), None);
}
#[test]
fn ignores_device_serial_inside_a_comment() {
let xml = "<deviceInfo><!-- <deviceSerial>FAKE</deviceSerial> -->\
<deviceSerial>REAL123</deviceSerial></deviceInfo>";
assert_eq!(parse_device_serial(xml), Some("REAL123".to_string()));
}
#[test]
fn self_closing_device_serial_has_no_text() {
let xml = "<deviceInfo><deviceSerial/><deviceSerial>REAL</deviceSerial></deviceInfo>";
assert_eq!(parse_device_serial(xml), Some("REAL".to_string()));
}
}