Initial commit

This commit is contained in:
Maurus Decimus
2026-05-29 18:02:15 +02:00
commit 576073f8c9
263 changed files with 78638 additions and 0 deletions
+631
View File
@@ -0,0 +1,631 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use std::io::Write;
use rusqlite::Connection;
use serde_json::Value;
use super::{blob_summary, format_count};
use crate::error::Error;
use crate::types::ObjectType;
pub fn write_list(
conn: &Connection,
ty: ObjectType,
limit: Option<usize>,
offset: usize,
out: &mut impl Write,
) -> Result<(), Error> {
let table = crate::sync::table_name(ty);
let total: i64 = conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))?;
let total = total as u64;
write_header(out, ty, total, limit, offset)?;
if total == 0 {
writeln!(out, "(no {} in archive)", ty.token())?;
return Ok(());
}
let names = Names::load(conn, ty)?;
let mut written = 0u64;
match ty {
ObjectType::Identity => write_identities(conn, limit, offset, out, &mut written)?,
ObjectType::Email => write_emails(conn, &names, limit, offset, out, &mut written)?,
ObjectType::SieveScript => write_sieves(conn, limit, offset, out, &mut written)?,
ObjectType::AddressBook => write_address_books(conn, limit, offset, out, &mut written)?,
ObjectType::ContactCard => {
write_contact_cards(conn, &names, limit, offset, out, &mut written)?
}
ObjectType::Calendar => write_calendars(conn, limit, offset, out, &mut written)?,
ObjectType::CalendarEvent => {
write_calendar_events(conn, &names, limit, offset, out, &mut written)?
}
ObjectType::ParticipantIdentity => {
write_participant_identities(conn, limit, offset, out, &mut written)?
}
ObjectType::Mailbox | ObjectType::FileNode => unreachable!("tree types do not list"),
}
if let Some(lim) = limit {
let next_offset = (offset as u64) + written;
if next_offset < total {
writeln!(
out,
"... ({} more; --offset {} --limit {} to continue)",
format_count(total - next_offset),
next_offset,
lim
)?;
}
}
Ok(())
}
fn write_header(
out: &mut impl Write,
ty: ObjectType,
total: u64,
limit: Option<usize>,
offset: usize,
) -> Result<(), Error> {
match limit {
None if offset == 0 => writeln!(out, "{} ({})", ty.jmap_name(), format_count(total))?,
None => writeln!(
out,
"{} ({}, skipping first {})",
ty.jmap_name(),
format_count(total),
format_count(offset as u64)
)?,
Some(lim) => writeln!(
out,
"{} ({} total, showing up to {} from offset {})",
ty.jmap_name(),
format_count(total),
format_count(lim as u64),
format_count(offset as u64)
)?,
}
writeln!(out)?;
Ok(())
}
pub(crate) struct Names {
mailboxes: HashMap<i64, String>,
address_books: HashMap<i64, String>,
calendars: HashMap<i64, String>,
}
impl Names {
pub(crate) fn load(conn: &Connection, ty: ObjectType) -> Result<Self, Error> {
let mailboxes = if matches!(ty, ObjectType::Email) {
load_name_map(conn, "SELECT id, name FROM mailboxes")?
} else {
HashMap::new()
};
let address_books = if matches!(ty, ObjectType::ContactCard) {
load_name_map(conn, "SELECT id, name FROM address_books")?
} else {
HashMap::new()
};
let calendars = if matches!(ty, ObjectType::CalendarEvent) {
load_name_map(conn, "SELECT id, name FROM calendars")?
} else {
HashMap::new()
};
Ok(Names {
mailboxes,
address_books,
calendars,
})
}
fn render(&self, kind: ObjectType, ids: &[i64]) -> String {
let map = match kind {
ObjectType::Mailbox => &self.mailboxes,
ObjectType::AddressBook => &self.address_books,
ObjectType::Calendar => &self.calendars,
_ => return format!("{ids:?}"),
};
let parts: Vec<String> = ids
.iter()
.map(|id| match map.get(id) {
Some(name) => format!("{name:?}"),
None => format!("#{id} (missing)"),
})
.collect();
format!("[{}]", parts.join(", "))
}
}
fn load_name_map(conn: &Connection, sql: &str) -> Result<HashMap<i64, String>, Error> {
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
let mut out = HashMap::new();
for row in rows {
let (id, name) = row?;
out.insert(id, name);
}
Ok(out)
}
fn limit_clause(limit: Option<usize>, offset: usize) -> String {
match limit {
Some(n) => format!(" LIMIT {n} OFFSET {offset}"),
None if offset > 0 => format!(" LIMIT -1 OFFSET {offset}"),
None => String::new(),
}
}
fn parse_local_ids(text: &str) -> Vec<i64> {
serde_json::from_str::<Value>(text)
.ok()
.and_then(|v| {
v.as_array()
.map(|arr| arr.iter().filter_map(|x| x.as_i64()).collect::<Vec<_>>())
})
.unwrap_or_default()
}
fn json_pretty_or_raw(text: &str) -> String {
match serde_json::from_str::<Value>(text) {
Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_else(|_| text.to_owned()),
Err(_) => text.to_owned(),
}
}
fn keywords_summary(text: &str) -> String {
let ids = serde_json::from_str::<Vec<String>>(text).unwrap_or_default();
if ids.is_empty() {
"(none)".to_owned()
} else {
format!("[{}]", ids.join(", "))
}
}
fn opt_or_null(s: Option<String>) -> String {
s.unwrap_or_else(|| "(null)".to_owned())
}
fn bool_str(b: bool) -> String {
(if b { "true" } else { "false" }).to_owned()
}
fn write_record(out: &mut impl Write, title: &str, fields: &[(&str, String)]) -> Result<(), Error> {
writeln!(out, "{title}")?;
let max_label = fields.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
let pad = " ".repeat(max_label + 4);
for (label, value) in fields {
for (i, line) in value.split('\n').enumerate() {
if i == 0 {
writeln!(out, " {label:<max_label$} {line}")?;
} else {
writeln!(out, "{pad}{line}")?;
}
}
}
writeln!(out)?;
Ok(())
}
fn write_identities(
conn: &Connection,
limit: Option<usize>,
offset: usize,
out: &mut impl Write,
written: &mut u64,
) -> Result<(), Error> {
let sql = format!(
"SELECT id, name, email, reply_to, bcc, text_signature, html_signature
FROM identities ORDER BY id{}",
limit_clause(limit, offset)
);
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let id: i64 = row.get(0)?;
let fields = vec![
("name", row.get::<_, String>(1)?),
("email", row.get::<_, String>(2)?),
("reply_to", opt_or_null(row.get(3)?)),
("bcc", opt_or_null(row.get(4)?)),
("text_signature", row.get::<_, String>(5)?),
("html_signature", row.get::<_, String>(6)?),
];
write_record(out, &format!("identity #{id}"), &fields)?;
*written += 1;
}
Ok(())
}
fn write_emails(
conn: &Connection,
names: &Names,
limit: Option<usize>,
offset: usize,
out: &mut impl Write,
written: &mut u64,
) -> Result<(), Error> {
let sql = format!(
"SELECT id, blob_id, received_at, mailbox_ids, keywords, message_match
FROM emails ORDER BY id{}",
limit_clause(limit, offset)
);
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let id: i64 = row.get(0)?;
let blob_id: i64 = row.get(1)?;
let mailbox_ids: String = row.get(3)?;
let keywords: String = row.get(4)?;
let message_match: String = row.get(5)?;
let locals = parse_local_ids(&mailbox_ids);
let fields = vec![
("blob", blob_summary(conn, blob_id)?),
("received_at", row.get::<_, String>(2)?),
("mailboxes", names.render(ObjectType::Mailbox, &locals)),
("keywords", keywords_summary(&keywords)),
("message_match", json_pretty_or_raw(&message_match)),
];
write_record(out, &format!("email #{id}"), &fields)?;
*written += 1;
}
Ok(())
}
fn write_sieves(
conn: &Connection,
limit: Option<usize>,
offset: usize,
out: &mut impl Write,
written: &mut u64,
) -> Result<(), Error> {
let sql = format!(
"SELECT id, name, is_active, blob_id FROM sieve_scripts ORDER BY id{}",
limit_clause(limit, offset)
);
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let id: i64 = row.get(0)?;
let name: Option<String> = row.get(1)?;
let active: i64 = row.get(2)?;
let blob_id: i64 = row.get(3)?;
let fields = vec![
("name", opt_or_null(name)),
("is_active", bool_str(active != 0)),
("blob", blob_summary(conn, blob_id)?),
];
write_record(out, &format!("sievescript #{id}"), &fields)?;
*written += 1;
}
Ok(())
}
fn write_address_books(
conn: &Connection,
limit: Option<usize>,
offset: usize,
out: &mut impl Write,
written: &mut u64,
) -> Result<(), Error> {
let sql = format!(
"SELECT id, name, description, sort_order, is_default, is_subscribed
FROM address_books ORDER BY id{}",
limit_clause(limit, offset)
);
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let id: i64 = row.get(0)?;
let fields = vec![
("name", row.get::<_, String>(1)?),
("description", opt_or_null(row.get(2)?)),
("sort_order", row.get::<_, i64>(3)?.to_string()),
("is_default", bool_str(row.get::<_, i64>(4)? != 0)),
("is_subscribed", bool_str(row.get::<_, i64>(5)? != 0)),
];
write_record(out, &format!("addressbook #{id}"), &fields)?;
*written += 1;
}
Ok(())
}
fn write_contact_cards(
conn: &Connection,
names: &Names,
limit: Option<usize>,
offset: usize,
out: &mut impl Write,
written: &mut u64,
) -> Result<(), Error> {
let sql = format!(
"SELECT id, uid, address_book_ids, data FROM contact_cards ORDER BY id{}",
limit_clause(limit, offset)
);
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let id: i64 = row.get(0)?;
let uid: String = row.get(1)?;
let ab_ids: String = row.get(2)?;
let data: String = row.get(3)?;
let locals = parse_local_ids(&ab_ids);
let fields = vec![
("uid", uid),
(
"address_books",
names.render(ObjectType::AddressBook, &locals),
),
("data", redact_blobs_in_json(&data)),
];
write_record(out, &format!("contactcard #{id}"), &fields)?;
*written += 1;
}
Ok(())
}
fn write_calendars(
conn: &Connection,
limit: Option<usize>,
offset: usize,
out: &mut impl Write,
written: &mut u64,
) -> Result<(), Error> {
let sql = format!(
"SELECT id, name, description, color, sort_order, is_subscribed, is_visible, is_default,
include_in_availability, default_alerts_with_time, default_alerts_without_time,
time_zone
FROM calendars ORDER BY id{}",
limit_clause(limit, offset)
);
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let id: i64 = row.get(0)?;
let fields = vec![
("name", row.get::<_, String>(1)?),
("description", opt_or_null(row.get(2)?)),
("color", opt_or_null(row.get(3)?)),
("sort_order", row.get::<_, i64>(4)?.to_string()),
("is_subscribed", bool_str(row.get::<_, i64>(5)? != 0)),
("is_visible", bool_str(row.get::<_, i64>(6)? != 0)),
("is_default", bool_str(row.get::<_, i64>(7)? != 0)),
("include_in_availability", row.get::<_, String>(8)?),
("default_alerts_with_time", opt_or_null(row.get(9)?)),
("default_alerts_without_time", opt_or_null(row.get(10)?)),
("time_zone", opt_or_null(row.get(11)?)),
];
write_record(out, &format!("calendar #{id}"), &fields)?;
*written += 1;
}
Ok(())
}
fn write_calendar_events(
conn: &Connection,
names: &Names,
limit: Option<usize>,
offset: usize,
out: &mut impl Write,
written: &mut u64,
) -> Result<(), Error> {
let sql = format!(
"SELECT id, calendar_ids, is_draft, use_default_alerts, data
FROM calendar_events ORDER BY id{}",
limit_clause(limit, offset)
);
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let id: i64 = row.get(0)?;
let cal_ids: String = row.get(1)?;
let data: String = row.get(4)?;
let locals = parse_local_ids(&cal_ids);
let fields = vec![
("calendars", names.render(ObjectType::Calendar, &locals)),
("is_draft", bool_str(row.get::<_, i64>(2)? != 0)),
("use_default_alerts", bool_str(row.get::<_, i64>(3)? != 0)),
("data", redact_blobs_in_json(&data)),
];
write_record(out, &format!("calendarevent #{id}"), &fields)?;
*written += 1;
}
Ok(())
}
fn write_participant_identities(
conn: &Connection,
limit: Option<usize>,
offset: usize,
out: &mut impl Write,
written: &mut u64,
) -> Result<(), Error> {
let sql = format!(
"SELECT id, name, calendar_address, is_default
FROM participant_identities ORDER BY id{}",
limit_clause(limit, offset)
);
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let id: i64 = row.get(0)?;
let fields = vec![
("name", row.get::<_, String>(1)?),
("calendar_address", row.get::<_, String>(2)?),
("is_default", bool_str(row.get::<_, i64>(3)? != 0)),
];
write_record(out, &format!("participantidentity #{id}"), &fields)?;
*written += 1;
}
Ok(())
}
fn redact_blobs_in_json(text: &str) -> String {
let mut value = match serde_json::from_str::<Value>(text) {
Ok(v) => v,
Err(_) => return text.to_owned(),
};
redact_blobs(&mut value);
serde_json::to_string_pretty(&value).unwrap_or_else(|_| text.to_owned())
}
fn redact_blobs(value: &mut Value) {
match value {
Value::Object(map) => {
if let Some(bid) = map.get("@blob").and_then(|v| v.as_i64()) {
map.clear();
map.insert(
"@blob".to_owned(),
Value::String(format!("local #{bid} (omitted)")),
);
return;
}
for v in map.values_mut() {
redact_blobs(v);
}
}
Value::Array(arr) => {
for v in arr.iter_mut() {
redact_blobs(v);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::{blobs, init};
use rusqlite::params;
fn mem() -> Connection {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
c
}
fn add_mailbox(c: &Connection, name: &str) -> i64 {
c.execute("INSERT INTO mailboxes (name) VALUES (?1)", params![name])
.unwrap();
c.last_insert_rowid()
}
#[test]
fn empty_table_prints_none_message() {
let c = mem();
let mut buf = Vec::new();
write_list(&c, ObjectType::Identity, None, 0, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("Identity (0)"));
assert!(s.contains("(no identity in archive)"));
}
#[test]
fn identity_record_uses_aligned_fields_and_redacts_nothing() {
let c = mem();
c.execute(
"INSERT INTO identities (name, email, text_signature) VALUES ('Alice', '[email protected]', 'best,A')",
[],
)
.unwrap();
let mut buf = Vec::new();
write_list(&c, ObjectType::Identity, None, 0, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("identity #1"));
assert!(s.contains(" name Alice"), "got:\n{s}");
assert!(s.contains(" email [email protected]"));
assert!(s.contains(" bcc (null)"));
}
#[test]
fn email_resolves_mailbox_names_and_summarises_blob() {
let c = mem();
let inbox = add_mailbox(&c, "Inbox");
let other = add_mailbox(&c, "Personal");
let blob = blobs::intern_blob(&c, b"From: [email protected]\r\nSubject: hi\r\n\r\nhello").unwrap();
c.execute(
"INSERT INTO emails (blob_id, received_at, mailbox_ids, keywords)
VALUES (?1, '2024-01-01T00:00:00Z', ?2, ?3)",
params![
blob,
format!("[{inbox},{other}]"),
"[\"$seen\",\"Important\"]"
],
)
.unwrap();
let mut buf = Vec::new();
write_list(&c, ObjectType::Email, None, 0, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("email #1"));
assert!(s.contains("blob "));
assert!(s.contains("blake3="));
assert!(s.contains("mailboxes "));
assert!(s.contains("\"Inbox\""), "got:\n{s}");
assert!(s.contains("\"Personal\""));
assert!(s.contains("keywords "));
assert!(s.contains("[$seen, Important]"));
}
#[test]
fn contact_card_redacts_blob_sentinel_in_data() {
let c = mem();
c.execute("INSERT INTO address_books (name) VALUES ('Personal')", [])
.unwrap();
c.execute(
"INSERT INTO contact_cards (uid, address_book_ids, data) VALUES (?1, ?2, ?3)",
params![
"urn:uuid:1",
"[1]",
r#"{"name":{"full":"Alice"},"photos":{"p":{"@blob":17}}}"#
],
)
.unwrap();
let mut buf = Vec::new();
write_list(&c, ObjectType::ContactCard, None, 0, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("contactcard #1"));
assert!(s.contains("\"Personal\""));
assert!(s.contains("local #17 (omitted)"));
assert!(!s.contains("\"@blob\": 17"));
}
#[test]
fn pagination_writes_continuation_hint_when_more_remain() {
let c = mem();
for i in 0..5 {
c.execute(
"INSERT INTO identities (name, email) VALUES (?1, ?2)",
params![format!("u{i}"), format!("u{i}@x.test")],
)
.unwrap();
}
let mut buf = Vec::new();
write_list(&c, ObjectType::Identity, Some(2), 0, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("Identity (5 total, showing up to 2 from offset 0)"));
assert!(s.contains("identity #1"));
assert!(s.contains("identity #2"));
assert!(!s.contains("identity #3"));
assert!(s.contains("... (3 more; --offset 2 --limit 2 to continue)"));
}
#[test]
fn pagination_at_end_does_not_print_continuation() {
let c = mem();
for i in 0..3 {
c.execute(
"INSERT INTO identities (name, email) VALUES (?1, ?2)",
params![format!("u{i}"), format!("u{i}@x.test")],
)
.unwrap();
}
let mut buf = Vec::new();
write_list(&c, ObjectType::Identity, Some(5), 0, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(!s.contains("more;"), "got:\n{s}");
}
}
+285
View File
@@ -0,0 +1,285 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use rusqlite::{Connection, OptionalExtension, params};
use crate::db::init;
use crate::error::Error;
use crate::types::ObjectType;
pub mod list;
pub mod tree;
pub struct InspectConfig {
pub archive: PathBuf,
pub target: Option<ObjectType>,
pub limit: Option<usize>,
pub offset: usize,
}
pub fn run(config: InspectConfig) -> Result<(), Error> {
let conn = init::open(&config.archive)?;
let stdout = io::stdout();
let mut out = stdout.lock();
dispatch(&conn, &config, &mut out)?;
Ok(())
}
fn dispatch(conn: &Connection, cfg: &InspectConfig, out: &mut impl Write) -> Result<(), Error> {
match cfg.target {
None => write_summary(conn, &cfg.archive, out),
Some(ObjectType::Mailbox) => {
warn_if_paged(out, cfg, "mailbox")?;
tree::write_mailboxes(conn, out)
}
Some(ObjectType::FileNode) => {
warn_if_paged(out, cfg, "filenode")?;
tree::write_file_nodes(conn, out)
}
Some(ty) => list::write_list(conn, ty, cfg.limit, cfg.offset, out),
}
}
fn warn_if_paged(out: &mut impl Write, cfg: &InspectConfig, token: &str) -> Result<(), Error> {
if cfg.limit.is_some() || cfg.offset > 0 {
writeln!(
out,
"note: --limit/--offset are ignored for {token} (tree view always shows the full hierarchy)"
)?;
}
Ok(())
}
fn write_summary(conn: &Connection, archive: &Path, out: &mut impl Write) -> Result<(), Error> {
writeln!(out, "Archive: {}", archive.display())?;
match read_source(conn)? {
Some(src) => {
let name = src.account_name.unwrap_or_else(|| "(unnamed)".to_owned());
writeln!(
out,
"Source: {} {} (account {} / {})",
src.kind, src.session_url, src.account_id, name
)?;
}
None => writeln!(out, "Source: (no source recorded)")?,
}
writeln!(out)?;
let counts = read_type_counts(conn)?;
let label_width = ObjectType::ALL
.iter()
.map(|t| t.token().len())
.max()
.unwrap_or(0);
let count_width = counts
.values()
.map(|n| format_count(*n).len())
.max()
.unwrap_or(1)
.max("blobs".len());
for ty in ObjectType::ALL {
let n = counts.get(&ty).copied().unwrap_or(0);
writeln!(
out,
" {:<lw$} {:>cw$}",
ty.token(),
format_count(n),
lw = label_width,
cw = count_width
)?;
}
let (blob_count, blob_bytes) = read_blob_totals(conn)?;
writeln!(out)?;
writeln!(
out,
" {:<lw$} {:>cw$} ({})",
"blobs",
format_count(blob_count),
format_bytes(blob_bytes),
lw = label_width,
cw = count_width
)?;
Ok(())
}
struct SourceRow {
kind: String,
session_url: String,
account_id: String,
account_name: Option<String>,
}
fn read_source(conn: &Connection) -> Result<Option<SourceRow>, Error> {
let row = conn
.query_row(
"SELECT kind, session_url, account_id, account_name FROM sources LIMIT 1",
[],
|r| {
Ok(SourceRow {
kind: r.get(0)?,
session_url: r.get(1)?,
account_id: r.get(2)?,
account_name: r.get(3)?,
})
},
)
.optional()?;
Ok(row)
}
fn read_type_counts(conn: &Connection) -> Result<HashMap<ObjectType, u64>, Error> {
let mut out = HashMap::new();
for ty in ObjectType::ALL {
let sql = format!("SELECT COUNT(*) FROM {}", crate::sync::table_name(ty));
let n: i64 = conn.query_row(&sql, [], |r| r.get(0))?;
out.insert(ty, n as u64);
}
Ok(out)
}
fn read_blob_totals(conn: &Connection) -> Result<(u64, u64), Error> {
let (count, bytes): (i64, i64) = conn.query_row(
"SELECT COUNT(*), COALESCE(SUM(length(data)), 0) FROM blobs",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
Ok((count as u64, bytes.max(0) as u64))
}
pub(crate) fn format_count(n: u64) -> String {
let s = n.to_string();
let bytes = s.as_bytes();
let len = bytes.len();
let mut out = String::with_capacity(len + len / 3);
for (i, ch) in bytes.iter().enumerate() {
if i > 0 && (len - i).is_multiple_of(3) {
out.push(',');
}
out.push(*ch as char);
}
out
}
pub(crate) fn format_bytes(n: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
if n < 1024 {
return format!("{n} B");
}
let mut value = n as f64;
let mut i = 0;
while value >= 1024.0 && i + 1 < UNITS.len() {
value /= 1024.0;
i += 1;
}
format!("{value:.1} {}", UNITS[i])
}
pub(crate) fn blob_summary(conn: &Connection, blob_id: i64) -> Result<String, Error> {
let row = conn
.query_row(
"SELECT length(data), substr(lower(hex(hash)), 1, 12) FROM blobs WHERE id = ?1",
params![blob_id],
|r| {
let len: i64 = r.get(0)?;
let hex: String = r.get(1)?;
Ok((len, hex))
},
)
.optional()?;
match row {
Some((len, hex)) => Ok(format!("{} blake3={hex}", format_bytes(len.max(0) as u64))),
None => Ok(format!("(missing blob #{blob_id})")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::init;
fn mem() -> Connection {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
c
}
#[test]
fn format_count_inserts_grouping_commas() {
assert_eq!(format_count(0), "0");
assert_eq!(format_count(42), "42");
assert_eq!(format_count(999), "999");
assert_eq!(format_count(1_000), "1,000");
assert_eq!(format_count(12_345), "12,345");
assert_eq!(format_count(1_234_567_890), "1,234,567,890");
}
#[test]
fn format_bytes_picks_appropriate_unit() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(512), "512 B");
assert_eq!(format_bytes(1024), "1.0 KB");
assert_eq!(format_bytes(1_536), "1.5 KB");
assert_eq!(format_bytes(10 * 1024 * 1024), "10.0 MB");
}
#[test]
fn blob_summary_reports_size_and_short_hash() {
let c = mem();
let id = crate::db::blobs::intern_blob(&c, b"hello world").unwrap();
let s = blob_summary(&c, id).unwrap();
assert!(s.starts_with("11 B blake3="), "got {s}");
assert!(s.len() > "11 B blake3=".len());
}
#[test]
fn blob_summary_handles_missing_id() {
let c = mem();
let s = blob_summary(&c, 9_999).unwrap();
assert_eq!(s, "(missing blob #9999)");
}
#[test]
fn summary_lists_every_type_and_blob_totals() {
let c = mem();
crate::db::sources::upsert_source(
&c,
&crate::db::sources::SourceKey {
kind: "jmap".to_owned(),
session_url: "https://example.test/jmap".to_owned(),
account_id: "u-1".to_owned(),
},
Some("[email protected]"),
"[email protected]",
)
.unwrap();
c.execute("INSERT INTO mailboxes (name) VALUES (?1)", params!["Inbox"])
.unwrap();
crate::db::blobs::intern_blob(&c, b"some bytes").unwrap();
let mut buf = Vec::new();
write_summary(&c, Path::new("test.sqlite"), &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("Archive: test.sqlite"));
assert!(s.contains("Source: jmap https://example.test/jmap"));
assert!(s.contains("[email protected]"));
for ty in ObjectType::ALL {
assert!(s.contains(ty.token()), "missing {} in:\n{s}", ty.token());
}
assert!(s.contains("blobs"));
assert!(s.contains("10 B"));
}
#[test]
fn summary_without_source_reports_none() {
let c = mem();
let mut buf = Vec::new();
write_summary(&c, Path::new("x.sqlite"), &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("(no source recorded)"));
}
}
+327
View File
@@ -0,0 +1,327 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: Apache-2.0 OR MIT
*/
use std::collections::HashMap;
use std::io::Write;
use rusqlite::Connection;
use super::{blob_summary, format_count};
use crate::error::Error;
struct MailboxNode {
id: i64,
parent_id: Option<i64>,
name: String,
role: Option<String>,
sort_order: i64,
msg_count: u64,
}
pub fn write_mailboxes(conn: &Connection, out: &mut impl Write) -> Result<(), Error> {
let nodes = load_mailboxes(conn)?;
writeln!(out, "Mailboxes ({})", format_count(nodes.len() as u64))?;
if nodes.is_empty() {
writeln!(out, "(none)")?;
return Ok(());
}
let by_id: HashMap<i64, &MailboxNode> = nodes.iter().map(|n| (n.id, n)).collect();
let mut by_parent: HashMap<Option<i64>, Vec<i64>> = HashMap::new();
for n in &nodes {
by_parent.entry(n.parent_id).or_default().push(n.id);
}
for ids in by_parent.values_mut() {
ids.sort_by(|a, b| {
let na = by_id[a];
let nb = by_id[b];
na.sort_order
.cmp(&nb.sort_order)
.then_with(|| na.name.cmp(&nb.name))
});
}
let roots: Vec<i64> = by_parent.get(&None).cloned().unwrap_or_default();
let last = roots.len().saturating_sub(1);
for (i, id) in roots.iter().enumerate() {
write_mailbox(out, &by_id, &by_parent, *id, "", i == last)?;
}
Ok(())
}
fn write_mailbox(
out: &mut impl Write,
by_id: &HashMap<i64, &MailboxNode>,
by_parent: &HashMap<Option<i64>, Vec<i64>>,
id: i64,
prefix: &str,
is_last: bool,
) -> Result<(), Error> {
let node = by_id[&id];
let connector = if is_last { "└── " } else { "├── " };
let mut tags: Vec<String> = Vec::new();
if let Some(role) = &node.role {
tags.push(format!("role={role}"));
}
tags.push(format!("{} msgs", format_count(node.msg_count)));
writeln!(
out,
"{prefix}{connector}{} [{}]",
node.name,
tags.join(", ")
)?;
let child_prefix = format!("{prefix}{}", if is_last { " " } else { "│ " });
if let Some(children) = by_parent.get(&Some(id)) {
let last = children.len() - 1;
for (i, child) in children.iter().enumerate() {
write_mailbox(out, by_id, by_parent, *child, &child_prefix, i == last)?;
}
}
Ok(())
}
fn load_mailboxes(conn: &Connection) -> Result<Vec<MailboxNode>, Error> {
let mut stmt = conn.prepare(
"SELECT m.id, m.parent_id, m.name, m.role, m.sort_order,
(SELECT COUNT(*) FROM emails e
WHERE EXISTS (SELECT 1 FROM json_each(e.mailbox_ids) j
WHERE CAST(j.value AS INTEGER) = m.id))
FROM mailboxes m",
)?;
let rows = stmt.query_map([], |r| {
Ok(MailboxNode {
id: r.get(0)?,
parent_id: r.get(1)?,
name: r.get(2)?,
role: r.get(3)?,
sort_order: r.get(4)?,
msg_count: r.get::<_, i64>(5)? as u64,
})
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
struct FileNodeRow {
id: i64,
parent_id: Option<i64>,
node_type: String,
blob_id: Option<i64>,
name: String,
media_type: Option<String>,
target: Option<String>,
child_count: u64,
}
pub fn write_file_nodes(conn: &Connection, out: &mut impl Write) -> Result<(), Error> {
let nodes = load_file_nodes(conn)?;
writeln!(out, "File nodes ({})", format_count(nodes.len() as u64))?;
if nodes.is_empty() {
writeln!(out, "(none)")?;
return Ok(());
}
let by_id: HashMap<i64, &FileNodeRow> = nodes.iter().map(|n| (n.id, n)).collect();
let mut by_parent: HashMap<Option<i64>, Vec<i64>> = HashMap::new();
for n in &nodes {
by_parent.entry(n.parent_id).or_default().push(n.id);
}
for ids in by_parent.values_mut() {
ids.sort_by(|a, b| {
let na = by_id[a];
let nb = by_id[b];
(na.node_type != "directory")
.cmp(&(nb.node_type != "directory"))
.then_with(|| na.name.cmp(&nb.name))
});
}
let roots: Vec<i64> = by_parent.get(&None).cloned().unwrap_or_default();
let last = roots.len().saturating_sub(1);
for (i, id) in roots.iter().enumerate() {
write_file_node(conn, out, &by_id, &by_parent, *id, "", i == last)?;
}
Ok(())
}
fn write_file_node(
conn: &Connection,
out: &mut impl Write,
by_id: &HashMap<i64, &FileNodeRow>,
by_parent: &HashMap<Option<i64>, Vec<i64>>,
id: i64,
prefix: &str,
is_last: bool,
) -> Result<(), Error> {
let node = by_id[&id];
let connector = if is_last { "└── " } else { "├── " };
let display = match node.node_type.as_str() {
"directory" => format!("{}/", node.name),
_ => node.name.clone(),
};
let detail = match node.node_type.as_str() {
"directory" => format!("({} children)", format_count(node.child_count)),
"symlink" => {
let target = node.target.as_deref().unwrap_or("[]");
format!("symlink -> {target}")
}
_ => {
let media = node
.media_type
.as_deref()
.unwrap_or("application/octet-stream");
match node.blob_id {
Some(bid) => format!("{media}, {}", blob_summary(conn, bid)?),
None => format!("{media}, (no blob)"),
}
}
};
writeln!(out, "{prefix}{connector}{display} {detail}")?;
let child_prefix = format!("{prefix}{}", if is_last { " " } else { "│ " });
if let Some(children) = by_parent.get(&Some(id)) {
let last = children.len() - 1;
for (i, child) in children.iter().enumerate() {
write_file_node(
conn,
out,
by_id,
by_parent,
*child,
&child_prefix,
i == last,
)?;
}
}
Ok(())
}
fn load_file_nodes(conn: &Connection) -> Result<Vec<FileNodeRow>, Error> {
let mut stmt = conn.prepare(
"SELECT f.id, f.parent_id, f.node_type, f.blob_id, f.name, f.media_type, f.target,
(SELECT COUNT(*) FROM file_nodes c WHERE c.parent_id = f.id)
FROM file_nodes f",
)?;
let rows = stmt.query_map([], |r| {
Ok(FileNodeRow {
id: r.get(0)?,
parent_id: r.get(1)?,
node_type: r.get(2)?,
blob_id: r.get(3)?,
name: r.get(4)?,
media_type: r.get(5)?,
target: r.get(6)?,
child_count: r.get::<_, i64>(7)? as u64,
})
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::{blobs, init};
use rusqlite::params;
fn mem() -> Connection {
let c = Connection::open_in_memory().unwrap();
init::apply_schema(&c).unwrap();
c
}
fn insert_mailbox(
c: &Connection,
name: &str,
parent: Option<i64>,
role: Option<&str>,
sort: i64,
) -> i64 {
c.execute(
"INSERT INTO mailboxes (name, parent_id, role, sort_order, is_subscribed)
VALUES (?1, ?2, ?3, ?4, 1)",
params![name, parent, role, sort],
)
.unwrap();
c.last_insert_rowid()
}
fn insert_email_in(c: &Connection, mailbox_id: i64) {
let blob = blobs::intern_blob(c, format!("seed-{mailbox_id}").as_bytes()).unwrap();
c.execute(
"INSERT INTO emails (blob_id, received_at, mailbox_ids, keywords)
VALUES (?1, '2024-01-01T00:00:00Z', ?2, '[]')",
params![blob, format!("[{mailbox_id}]")],
)
.unwrap();
}
#[test]
fn mailbox_tree_draws_box_characters_and_msg_counts() {
let c = mem();
let inbox = insert_mailbox(&c, "Inbox", None, Some("inbox"), 0);
let _archived = insert_mailbox(&c, "Archived", Some(inbox), None, 0);
let _y2024 = insert_mailbox(&c, "2024", Some(inbox), None, 1);
let _sent = insert_mailbox(&c, "Sent", None, Some("sent"), 1);
insert_email_in(&c, inbox);
insert_email_in(&c, inbox);
let mut buf = Vec::new();
write_mailboxes(&c, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("Mailboxes (4)"), "header missing: {s}");
assert!(s.contains("├── Inbox [role=inbox, 2 msgs]"), "got:\n{s}");
assert!(s.contains("│ ├── Archived"), "got:\n{s}");
assert!(s.contains("│ └── 2024"), "got:\n{s}");
assert!(s.contains("└── Sent [role=sent, 0 msgs]"), "got:\n{s}");
}
#[test]
fn mailbox_tree_empty_says_none() {
let c = mem();
let mut buf = Vec::new();
write_mailboxes(&c, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("Mailboxes (0)"));
assert!(s.contains("(none)"));
}
#[test]
fn file_node_tree_distinguishes_dirs_files_symlinks() {
let c = mem();
let docs: i64 = {
c.execute(
"INSERT INTO file_nodes (parent_id, node_type, name, created)
VALUES (NULL, 'directory', 'Documents', '2024-01-01T00:00:00Z')",
[],
)
.unwrap();
c.last_insert_rowid()
};
let blob = blobs::intern_blob(&c, b"hello world").unwrap();
c.execute(
"INSERT INTO file_nodes (parent_id, node_type, blob_id, name, media_type, created)
VALUES (?1, 'file', ?2, 'note.txt', 'text/plain', '2024-01-01T00:00:00Z')",
params![docs, blob],
)
.unwrap();
c.execute(
"INSERT INTO file_nodes (parent_id, node_type, target, name, created)
VALUES (?1, 'symlink', '[\"..\",\"etc\"]', 'shortcut', '2024-01-01T00:00:00Z')",
params![docs],
)
.unwrap();
let mut buf = Vec::new();
write_file_nodes(&c, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("File nodes (3)"));
assert!(s.contains("Documents/"));
assert!(s.contains("(2 children)"));
assert!(s.contains("note.txt"));
assert!(s.contains("text/plain, 11 B blake3="));
assert!(s.contains("symlink -> [\"..\",\"etc\"]"));
}
}