From 49119a4ce1f2885e18915e31105944e20e7f6e12 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 31 Jul 2026 15:38:59 -0500 Subject: [PATCH 1/4] refactor(core): remove dead EventBus ReloadFilters/unread_count arms (#344) CoreCommand::ReloadFilters was a silent no-op: nothing in the workspace sends it, since filter reload actually happens via FiltersGrpcService::reload_engine_from_store. AppState::unread_count was written by MarkRead but never read by any consumer -- GetStatusResponse only carries engine_status and version, so it never reached the wire. Refs #324 Co-authored-by: James Maes --- crates/nuncio-core/src/lib.rs | 37 +++++++++-------------------------- 1 file changed, 9 insertions(+), 28 deletions(-) diff --git a/crates/nuncio-core/src/lib.rs b/crates/nuncio-core/src/lib.rs index 80000df..4c0ca72 100644 --- a/crates/nuncio-core/src/lib.rs +++ b/crates/nuncio-core/src/lib.rs @@ -52,8 +52,6 @@ pub enum CoreCommand { /// Human-readable error description. message: String, }, - /// Reload filter rules cache in memory. - ReloadFilters, /// Request graceful shutdown of the core engine. Shutdown, } @@ -153,8 +151,6 @@ pub struct AppState { pub status: EngineStatus, /// Total number of loaded accounts. pub accounts_loaded: usize, - /// Total unread message count across loaded accounts. - pub unread_count: usize, /// Last error message, if any. pub last_error: Option, } @@ -259,20 +255,12 @@ impl EventBus { }); } CoreCommand::MarkRead { message_id, read } => { - self.update_state(|s| { - if read { - s.unread_count = s.unread_count.saturating_sub(1); - } else { - s.unread_count = s.unread_count.saturating_add(1); - } - }); self.publish_event(CoreEvent::MessageFlagsChanged { message_id, read }); } CoreCommand::ReportError { message } => { self.update_state(|s| s.last_error = Some(message.clone())); self.publish_event(CoreEvent::Error { message }); } - CoreCommand::ReloadFilters => {} CoreCommand::Shutdown => { self.update_state(|s| s.status = EngineStatus::ShuttingDown); self.publish_event(CoreEvent::ShuttingDown); @@ -308,7 +296,6 @@ mod tests { let seed = AppState { status: EngineStatus::Idle, accounts_loaded: 3, - unread_count: 5, last_error: None, }; let bus = EventBus::with_initial_state(seed.clone()); @@ -400,18 +387,14 @@ mod tests { } #[tokio::test] - async fn process_mark_read_adjusts_unread_count_both_directions() { - let bus = EventBus::with_initial_state(AppState { - unread_count: 1, - ..AppState::default() - }); + async fn process_mark_read_emits_flags_changed_for_each_call() { + let bus = EventBus::new(); let mut events = bus.subscribe_events(); bus.process_command(CoreCommand::MarkRead { message_id: "msg-1".to_string(), read: false, }); - assert_eq!(bus.current_state().unread_count, 2); assert_eq!( events.recv().await.expect("event received"), CoreEvent::MessageFlagsChanged { @@ -424,15 +407,13 @@ mod tests { message_id: "msg-1".to_string(), read: true, }); - bus.process_command(CoreCommand::MarkRead { - message_id: "msg-1".to_string(), - read: true, - }); - bus.process_command(CoreCommand::MarkRead { - message_id: "msg-1".to_string(), - read: true, - }); - assert_eq!(bus.current_state().unread_count, 0); + assert_eq!( + events.recv().await.expect("event received"), + CoreEvent::MessageFlagsChanged { + message_id: "msg-1".to_string(), + read: true + } + ); } #[tokio::test] -- 2.54.0 From c660ed6d065d86d6e4462a482ecea83f27922a8f Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 31 Jul 2026 15:52:07 -0500 Subject: [PATCH 2/4] feat(cal): add CalendarEvent::to_ical serializer (#345) Adds the CalDAV write-back inverse of IcalParserAdapter::parse_ical: a ToIcal extension trait wraps a single VEVENT (UID, DTSTAMP, DTSTART, DTEND, SUMMARY, LOCATION, RRULE) in a VCALENDAR shell, escaping TEXT values per RFC 5545 3.3.11. Refs #310 Co-authored-by: James Maes --- crates/nuncio-cal/src/ical.rs | 182 ++++++++++++++++++++++++++++++++++ crates/nuncio-cal/src/lib.rs | 2 + 2 files changed, 184 insertions(+) create mode 100644 crates/nuncio-cal/src/ical.rs diff --git a/crates/nuncio-cal/src/ical.rs b/crates/nuncio-cal/src/ical.rs new file mode 100644 index 0000000..77d104f --- /dev/null +++ b/crates/nuncio-cal/src/ical.rs @@ -0,0 +1,182 @@ +//! iCalendar (RFC 5545) VEVENT serializer -- the write-back inverse of +//! [`crate::parser::IcalParserAdapter`]. +//! +//! [`CalendarEvent`] stores `start_time`/`end_time` as resolved UTC unix timestamps (the parser +//! already collapses any `TZID`/floating/`Z` form down to a single UTC instant), so this +//! serializer always emits `Z`-suffixed UTC `DATE-TIME` values. That is a lossless encoding of +//! what the model actually holds: re-parsing the emitted text yields the same UTC instant no +//! matter which form the original payload used. + +use chrono::{DateTime, Utc}; +use nuncio_core::model::CalendarEvent; + +/// Adds RFC 5545 serialization to [`CalendarEvent`]. +/// +/// `CalendarEvent` is defined in `nuncio-core`, so this is an extension trait rather than an +/// inherent impl -- Rust forbids inherent impls on types owned by another crate. Importing the +/// trait makes `event.to_ical()` read identically to an inherent method at call sites. +pub trait ToIcal { + /// Serialize this event as a complete iCalendar payload: a single `VEVENT` wrapped in a + /// `VCALENDAR`, the shape a CalDAV `PUT` request body expects. + fn to_ical(&self) -> String; +} + +impl ToIcal for CalendarEvent { + fn to_ical(&self) -> String { + let dtstamp = format_utc_datetime(Utc::now().timestamp()); + let dtstart = format_utc_datetime(self.start_time); + let dtend = format_utc_datetime(self.end_time); + + let mut vevent = String::new(); + vevent.push_str("BEGIN:VEVENT\r\n"); + vevent.push_str(&format!("UID:{}\r\n", escape_text(&self.id))); + vevent.push_str(&format!("DTSTAMP:{}\r\n", dtstamp)); + vevent.push_str(&format!("DTSTART:{}\r\n", dtstart)); + vevent.push_str(&format!("DTEND:{}\r\n", dtend)); + vevent.push_str(&format!("SUMMARY:{}\r\n", escape_text(&self.summary))); + if let Some(location) = &self.location { + vevent.push_str(&format!("LOCATION:{}\r\n", escape_text(location))); + } + if let Some(rrule) = &self.rrule { + // RRULE is a structured value (RFC 5545 3.3.10), not TEXT -- it must not go through + // TEXT escaping, and the parser reads it back verbatim via `properties().get`. + vevent.push_str(&format!("RRULE:{}\r\n", rrule)); + } + vevent.push_str("END:VEVENT\r\n"); + + let mut vcalendar = String::new(); + vcalendar.push_str("BEGIN:VCALENDAR\r\n"); + vcalendar.push_str("VERSION:2.0\r\n"); + vcalendar.push_str("PRODID:-//Nuncio//Calendar Engine//EN\r\n"); + vcalendar.push_str(&vevent); + vcalendar.push_str("END:VCALENDAR\r\n"); + vcalendar + } +} + +/// Format a UTC unix timestamp as an RFC 5545 `DATE-TIME` in UTC form (`YYYYMMDDTHHMMSSZ`). +fn format_utc_datetime(unix_ts: i64) -> String { + DateTime::::from_timestamp(unix_ts, 0) + .map(|dt| dt.format("%Y%m%dT%H%M%SZ").to_string()) + // `from_timestamp` only returns `None` for a timestamp outside chrono's representable + // range; falling back to the Unix epoch keeps this a total function without panicking, + // since a corrupt in-memory timestamp is not something this serializer can repair. + .unwrap_or_else(|| "19700101T000000Z".to_string()) +} + +/// Escape a `TEXT` value per RFC 5545 3.3.11: backslash, semicolon, and comma are escaped with a +/// leading backslash, and embedded newlines become the two-character literal `\n`. Order matters +/// -- backslash must be escaped first so the escaping added for the other characters is not +/// itself re-escaped. +fn escape_text(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '\\' => escaped.push_str("\\\\"), + ';' => escaped.push_str("\\;"), + ',' => escaped.push_str("\\,"), + '\n' => escaped.push_str("\\n"), + '\r' => {} + other => escaped.push(other), + } + } + escaped +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::IcalParserAdapter; + + fn event( + summary: &str, + start_time: i64, + end_time: i64, + rrule: Option<&str>, + location: Option<&str>, + ) -> CalendarEvent { + CalendarEvent { + id: "evt-roundtrip".to_string(), + account_id: "acct-1".to_string(), + calendar_id: "cal-1".to_string(), + summary: summary.to_string(), + start_time, + end_time, + rrule: rrule.map(str::to_string), + location: location.map(str::to_string), + } + } + + fn roundtrip(original: &CalendarEvent) -> CalendarEvent { + let ics = original.to_ical(); + IcalParserAdapter::parse_ical( + &original.id, + &original.account_id, + &original.calendar_id, + &ics, + ) + .expect("serialized iCalendar must parse back") + } + + /// Proves the serializer is the true inverse of [`IcalParserAdapter::parse_ical`] across the + /// event shapes CalDAV write-back must support: a plain timed event, an all-day event + /// (whole-day UTC window), an event whose UTC instant originated from a `TZID` source (the + /// model has already resolved it to UTC, so round-tripping through `to_ical` must preserve + /// that same instant), and a recurring event carrying an `RRULE`. + #[test] + fn to_ical_roundtrips_through_parse_ical_for_all_event_shapes() { + let timed = event( + "Standup", + 1_709_294_400, + 1_709_298_000, + None, + Some("Room 5"), + ); + assert_eq!(roundtrip(&timed), timed); + + // Whole-day window: 2024-03-01T00:00:00Z .. 2024-03-01T23:59:59Z, matching the parser's + // own DATE-only convention (start of day / end of day). + let all_day = event("Conference", 1_709_251_200, 1_709_337_599, None, None); + assert_eq!(roundtrip(&all_day), all_day); + + // 2024-07-15 09:00 America/New_York (EDT, UTC-4) resolves to this UTC instant; the model + // only ever stores the resolved UTC epoch, so serializing and re-parsing must land on + // exactly the same instant even though the TZID itself is not retained. + let from_tzid = event("Standup EDT", 1_721_048_400, 1_721_052_000, None, None); + assert_eq!(roundtrip(&from_tzid), from_tzid); + + let recurring = event( + "Weekly Sync", + 1_709_294_400, + 1_709_298_000, + Some("FREQ=WEEKLY;BYDAY=MO"), + Some("Conference Room 101"), + ); + assert_eq!(roundtrip(&recurring), recurring); + } + + #[test] + fn to_ical_escapes_rfc5545_special_characters_in_text_fields() { + let special = event( + "Comma, semicolon; backslash\\ and\nnewline", + 1_709_294_400, + 1_709_298_000, + None, + Some("Loc; with, chars"), + ); + let ics = special.to_ical(); + assert!(ics.contains("SUMMARY:Comma\\, semicolon\\; backslash\\\\ and\\nnewline\r\n")); + assert!(ics.contains("LOCATION:Loc\\; with\\, chars\r\n")); + assert_eq!(roundtrip(&special), special); + } + + #[test] + fn to_ical_wraps_a_single_vevent_in_a_vcalendar_shell() { + let plain = event("Solo Event", 1_709_294_400, 1_709_298_000, None, None); + let ics = plain.to_ical(); + assert!(ics.starts_with("BEGIN:VCALENDAR\r\n")); + assert!(ics.contains("VERSION:2.0\r\n")); + assert!(ics.trim_end().ends_with("END:VCALENDAR")); + assert_eq!(ics.matches("BEGIN:VEVENT").count(), 1); + } +} diff --git a/crates/nuncio-cal/src/lib.rs b/crates/nuncio-cal/src/lib.rs index d9a72bf..5943a0a 100644 --- a/crates/nuncio-cal/src/lib.rs +++ b/crates/nuncio-cal/src/lib.rs @@ -5,6 +5,7 @@ pub mod backend; pub mod caldav; +pub mod ical; pub mod mock; pub mod nlp; pub mod parser; @@ -13,6 +14,7 @@ pub mod scheduling; pub use backend::CalendarBackend; pub use caldav::{CalDavAccountConfig, CalDavClient}; +pub use ical::ToIcal; pub use mock::MockCalendarBackend; pub use nlp::{NaturalLanguageScheduler, NlpError, ParsedSchedulingIntent}; pub use parser::{CalendarError, IcalParserAdapter}; -- 2.54.0 From 885c13e4cc4f3ee90b6c4761ecbda44ca9b37ef0 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 31 Jul 2026 15:55:13 -0500 Subject: [PATCH 3/4] feat(nunciod): single-instance advisory lock on the DB path (#346) Acquire an exclusive OS advisory lock on a sidecar file beside the database path (.lock) before opening the store, so a second nunciod pointed at the same database fails fast with an actionable error instead of both instances writing to the same SQLite file. Previously only an incidental gRPC port-bind collision offered any protection against this. Uses fs4 (flock on Unix, LockFileEx on Windows) since std has no portable file-lock primitive. The guard is held for the process lifetime and dropped at clean shutdown, releasing the lock; the OS also releases it automatically if the process dies uncleanly, so there is no PID-file staleness to reason about. Refs #291 Co-authored-by: James Maes --- Cargo.lock | 11 +++ Cargo.toml | 1 + crates/nunciod/Cargo.toml | 1 + crates/nunciod/src/lib.rs | 1 + crates/nunciod/src/lock.rs | 180 +++++++++++++++++++++++++++++++++++++ crates/nunciod/src/main.rs | 8 ++ 6 files changed, 202 insertions(+) create mode 100644 crates/nunciod/src/lock.rs diff --git a/Cargo.lock b/Cargo.lock index f94cbed..ad112c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1314,6 +1314,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -2636,6 +2646,7 @@ dependencies = [ "arc-swap", "async-trait", "bytes", + "fs4", "hex", "nuncio-cal", "nuncio-cli", diff --git a/Cargo.toml b/Cargo.toml index e2e4e76..a0ca121 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,6 +84,7 @@ prost = "0.14" tonic-prost-build = "0.14" protoc-bin-vendored = "3.2" subtle = "2.6" +fs4 = "0.13" diff --git a/crates/nunciod/Cargo.toml b/crates/nunciod/Cargo.toml index a43ef22..d1ee17f 100644 --- a/crates/nunciod/Cargo.toml +++ b/crates/nunciod/Cargo.toml @@ -34,6 +34,7 @@ subtle.workspace = true hex.workspace = true bytes.workspace = true zeroize.workspace = true +fs4.workspace = true [dev-dependencies] nuncio-cli = { path = "../nuncio-cli" } diff --git a/crates/nunciod/src/lib.rs b/crates/nunciod/src/lib.rs index 9a10ad9..0f3ef2d 100644 --- a/crates/nunciod/src/lib.rs +++ b/crates/nunciod/src/lib.rs @@ -6,6 +6,7 @@ pub mod calendar_sync; pub mod contacts_sync; pub mod grpc; pub mod lifecycle; +pub mod lock; pub mod orchestrator; pub mod outbox; pub mod send; diff --git a/crates/nunciod/src/lock.rs b/crates/nunciod/src/lock.rs new file mode 100644 index 0000000..130eea5 --- /dev/null +++ b/crates/nunciod/src/lock.rs @@ -0,0 +1,180 @@ +//! Exclusive, cross-platform advisory lock on the daemon's database path. +//! +//! Nothing about starting a second `nunciod` process today prevents it from +//! opening the same SQLite database another instance already has open -- +//! the only accidental protection is a port-bind collision on the gRPC +//! listener, and that only fires if both instances also happen to pick the +//! same port. Two writers on one SQLite file corrupt state. +//! +//! [`InstanceLock::acquire`] takes an OS-level exclusive lock (`flock` on +//! Unix, `LockFileEx` on Windows via the `fs4` crate) on a sidecar file next +//! to the database, `.lock`. The lock is scoped to the OS file +//! handle, not tracked by a PID written into the file, so it cannot go +//! stale: if the holding process dies for any reason (crash, kill -9), the +//! OS releases the lock the moment the file descriptor/handle closes. + +use std::fs::{File, OpenOptions}; +use std::path::{Path, PathBuf}; + +use fs4::fs_std::FileExt; + +/// Failure to acquire the single-instance lock. +#[derive(Debug, thiserror::Error)] +pub enum InstanceLockError { + /// The lock is already held by another live process. The caller must + /// treat this as fatal and refuse to open the store. + #[error("another nunciod instance is already running for this database at {0}")] + AlreadyRunning(PathBuf), + + /// The lock file itself could not be created/opened/locked, e.g. a + /// permissions problem or an unwritable parent directory. + #[error("failed to acquire instance lock at {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +/// Holds the exclusive lock on the database path for as long as this guard +/// is alive. Drop it (or let it fall out of scope at process exit) to +/// release the lock; a later call to [`InstanceLock::acquire`] for the same +/// path then succeeds again. +#[derive(Debug)] +pub struct InstanceLock { + file: File, + path: PathBuf, +} + +impl InstanceLock { + /// Acquires the exclusive lock for `db_path`, creating the sidecar lock + /// file (`.lock`) if it does not already exist. + /// + /// Returns [`InstanceLockError::AlreadyRunning`] if another process + /// already holds it -- callers must fail startup on this rather than + /// proceed to open the store, since a second writer on the same SQLite + /// file is exactly the corruption this guards against. + pub fn acquire(db_path: &Path) -> Result { + let lock_path = lock_path_for(db_path); + + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent).map_err(|source| InstanceLockError::Io { + path: lock_path.clone(), + source, + })?; + } + + let file = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&lock_path) + .map_err(|source| InstanceLockError::Io { + path: lock_path.clone(), + source, + })?; + + let acquired = file + .try_lock_exclusive() + .map_err(|source| InstanceLockError::Io { + path: lock_path.clone(), + source, + })?; + + if !acquired { + return Err(InstanceLockError::AlreadyRunning(db_path.to_path_buf())); + } + + Ok(Self { + file, + path: lock_path, + }) + } +} + +impl Drop for InstanceLock { + fn drop(&mut self) { + // The OS releases a `flock`/`LockFileEx` lock automatically when the + // underlying handle closes, so a failure here is not a leaked lock + // (the process exiting, or `self.file` being dropped right after, + // clears it regardless) -- it is only logged so an operator sees + // anything unexpected in the unlock call itself. + if let Err(err) = self.file.unlock() { + tracing::warn!( + path = %self.path.display(), + error = %err, + "failed to explicitly release nunciod instance lock; it will still be \ + released when the process exits and the underlying file handle closes" + ); + } + } +} + +fn lock_path_for(db_path: &Path) -> PathBuf { + let mut file_name = db_path.as_os_str().to_owned(); + file_name.push(".lock"); + PathBuf::from(file_name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn second_acquire_for_same_path_fails_while_first_guard_is_held() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("nuncio.db"); + + let first = InstanceLock::acquire(&db_path).unwrap(); + + let second = InstanceLock::acquire(&db_path); + assert!( + matches!(second, Err(InstanceLockError::AlreadyRunning(ref p)) if p == &db_path), + "expected AlreadyRunning({db_path:?}), got {second:?}" + ); + + drop(first); + } + + #[test] + fn acquire_succeeds_again_after_the_first_guard_is_dropped() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("nuncio.db"); + + let first = InstanceLock::acquire(&db_path).unwrap(); + drop(first); + + let second = InstanceLock::acquire(&db_path); + assert!( + second.is_ok(), + "acquiring after the prior guard dropped must succeed, got {:?}", + second.err() + ); + } + + #[test] + fn error_message_names_the_database_path() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("nuncio.db"); + + let first = InstanceLock::acquire(&db_path).unwrap(); + let second = InstanceLock::acquire(&db_path).unwrap_err(); + + let message = second.to_string(); + assert!(message.contains("already running")); + assert!(message.contains(&db_path.display().to_string())); + + drop(first); + } + + #[test] + fn lock_file_is_created_beside_the_database_path() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("nuncio.db"); + + let guard = InstanceLock::acquire(&db_path).unwrap(); + assert!(dir.path().join("nuncio.db.lock").exists()); + + drop(guard); + } +} diff --git a/crates/nunciod/src/main.rs b/crates/nunciod/src/main.rs index d50f90b..29bb348 100644 --- a/crates/nunciod/src/main.rs +++ b/crates/nunciod/src/main.rs @@ -46,6 +46,14 @@ async fn main() -> Result<(), Box> { .map(std::path::PathBuf::from) .unwrap_or_else(|_| nunciod::default_db_path()); + // Exclusive single-instance lock on the database path, acquired BEFORE + // the store is opened. Held for the entire process lifetime (dropped at + // the end of `main`, releasing it on any clean exit) so a second + // `nunciod` pointed at the same database fails fast instead of the two + // instances corrupting SQLite state between them. + let _instance_lock = + nunciod::lock::InstanceLock::acquire(&db_path).map_err(|e| format!("{e}"))?; + let orchestrator = nunciod::SelfHealingSyncOrchestrator::new(&db_path, event_bus.clone()); let (db, _summary) = orchestrator.initialize_and_recover().await?; -- 2.54.0 From b194e8a107f16de960a22739dfb587eb39ed466f Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 31 Jul 2026 15:57:42 -0500 Subject: [PATCH 4/4] feat(proto): unify time fields on Timestamp/Duration Message.received_at, CalendarEvent.start_time/end_time, CalendarSyncRequest/ListEventsRequest.start_window/end_window, FilterRule.created_at/updated_at, FilterExecutionLog.matched_at, AccountConfig.sync_interval_secs, PreviewRuleResponse.execution_time_us, and AuditRecord.timestamp_ns all mixed unix-seconds and nanosecond int64 scalars, leaving the unit ambiguous at the wire level. Move every wall-clock field to google.protobuf.Timestamp and every duration to google.protobuf.Duration, add prost-types as the standard well-known-types crate, and add nuncio_proto::time as the single conversion point between the store's native integer instants and the wire types (including a lossless unix-nanos split/join for the WORM audit ledger's sub-second precision). Update the daemon's gRPC mappers and the CLI's request construction/rendering accordingly, and regenerate the descriptor golden. Refs #274 --- Cargo.lock | 1 + Cargo.toml | 1 + crates/nuncio-cli/src/runner.rs | 150 +++++++++++---- crates/nuncio-proto/Cargo.toml | 1 + .../proto/nuncio/v1/descriptor.bin | Bin 13517 -> 14437 bytes .../nuncio-proto/proto/nuncio/v1/nuncio.proto | 48 ++--- crates/nuncio-proto/src/lib.rs | 1 + crates/nuncio-proto/src/time.rs | 138 ++++++++++++++ crates/nunciod/src/grpc.rs | 176 ++++++++++++++---- .../tests/accounts_lifecycle_e2e_test.rs | 2 +- .../tests/caldav_production_e2e_test.rs | 18 +- crates/nunciod/tests/calendar_e2e_test.rs | 12 +- crates/nunciod/tests/spine_e2e_test.rs | 2 +- crates/nunciod/tests/typed_errors_e2e_test.rs | 4 +- 14 files changed, 440 insertions(+), 114 deletions(-) create mode 100644 crates/nuncio-proto/src/time.rs diff --git a/Cargo.lock b/Cargo.lock index f94cbed..d17b94f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2596,6 +2596,7 @@ version = "0.1.0" dependencies = [ "bytes", "prost", + "prost-types", "protoc-bin-vendored", "thiserror 1.0.69", "tokio", diff --git a/Cargo.toml b/Cargo.toml index e2e4e76..1f448de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,7 @@ zip = { version = "8.6", default-features = false, features = ["deflate"] } tonic = "0.14" tonic-prost = "0.14" prost = "0.14" +prost-types = "0.14" tonic-prost-build = "0.14" protoc-bin-vendored = "3.2" subtle = "2.6" diff --git a/crates/nuncio-cli/src/runner.rs b/crates/nuncio-cli/src/runner.rs index 912f6b7..9bf5dbe 100644 --- a/crates/nuncio-cli/src/runner.rs +++ b/crates/nuncio-cli/src/runner.rs @@ -53,6 +53,48 @@ fn parse_account_protocol(protocol: &str) -> Result) -> String { + ts.as_ref() + .and_then(|ts| { + u32::try_from(ts.nanos) + .ok() + .map(|nanos| (ts.seconds, nanos)) + }) + .and_then(|(seconds, nanos)| chrono::DateTime::from_timestamp(seconds, nanos)) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| "unknown".to_string()) +} + +/// Extracts the exact unix-nanosecond instant a wire-format `Timestamp` +/// carries, for callers that need the audit ledger's full sub-second +/// precision rather than the RFC 3339 rendering. +fn timestamp_unix_nanos(ts: &Option) -> i64 { + ts.as_ref() + .map(nuncio_proto::time::timestamp_to_unix_nanos) + .unwrap_or(0) +} + +/// Renders a wire-format `google.protobuf.Duration` as whole seconds for +/// human-readable and `--json` output (e.g. `AccountConfig.sync_interval`). +fn duration_secs(d: &Option) -> u64 { + d.as_ref() + .map(nuncio_proto::time::duration_to_secs) + .unwrap_or(0) +} + +/// Renders a wire-format `google.protobuf.Duration` as whole microseconds +/// for human-readable and `--json` output (e.g. +/// `PreviewRuleResponse.execution_time`). +fn duration_micros(d: &Option) -> u64 { + d.as_ref() + .map(nuncio_proto::time::duration_to_micros) + .unwrap_or(0) +} + /// Renders a `nuncio.v1.Message` (as returned by the daemon's `Mail` gRPC /// service) into the JSON shape used by `mail list`/`mail read`'s /// `--json` output. @@ -64,7 +106,7 @@ fn message_proto_to_json(message: &nuncio_proto::v1::Message) -> serde_json::Val "subject": message.subject, "sender": message.sender, "recipient": message.recipient, - "received_at": message.received_at, + "received_at": format_timestamp(&message.received_at), "read": message.read, "body_plain": message.body_plain, "body_html": message.body_html, @@ -80,8 +122,8 @@ fn calendar_event_proto_to_json(event: &nuncio_proto::v1::CalendarEvent) -> serd "account_id": event.account_id, "calendar_id": event.calendar_id, "summary": event.summary, - "start_time": event.start_time, - "end_time": event.end_time, + "start_time": format_timestamp(&event.start_time), + "end_time": format_timestamp(&event.end_time), "rrule": event.rrule, "location": event.location, }) @@ -150,7 +192,8 @@ fn map_export_format_to_proto(format: nuncio_core::ExportFormat) -> nuncio_proto fn audit_record_proto_to_json(record: &nuncio_proto::v1::AuditRecord) -> serde_json::Value { json!({ "sequence": record.sequence, - "timestamp_ns": record.timestamp_ns, + "timestamp": format_timestamp(&record.timestamp), + "timestamp_ns": timestamp_unix_nanos(&record.timestamp), "actor": record.actor, "action": record.action, "data_hash": record.data_hash, @@ -171,8 +214,8 @@ fn filter_rule_proto_to_json(rule: &nuncio_proto::v1::FilterRule) -> serde_json: "enabled": rule.enabled, "nsql_text": rule.nsql_text, "actions": rule.actions, - "created_at": rule.created_at, - "updated_at": rule.updated_at, + "created_at": format_timestamp(&rule.created_at), + "updated_at": format_timestamp(&rule.updated_at), }) } @@ -184,7 +227,7 @@ fn filter_execution_log_proto_to_json( "rule_id": log.rule_id, "message_id": log.message_id, "action_taken": log.action_taken, - "matched_at": log.matched_at, + "matched_at": format_timestamp(&log.matched_at), "prev_hash": log.prev_hash, "hash": log.hash, }) @@ -882,7 +925,10 @@ impl HeadlessRunner { } else { format!( "Message {}: Subject: '{}', From: {}, Date: {}", - msg.id, msg.subject, msg.sender, msg.received_at + msg.id, + msg.subject, + msg.sender, + format_timestamp(&msg.received_at) ) } } @@ -1066,13 +1112,15 @@ impl HeadlessRunner { } else if records.is_empty() { "No audit records recorded.".to_string() } else { - let mut out = String::from( - "SEQ ACTOR ACTION TIMESTAMP_NS\n", - ); + let mut out = + String::from("SEQ ACTOR ACTION TIMESTAMP\n"); for r in records { out.push_str(&format!( "{:<4} {:<20} {:<20} {}\n", - r.sequence, r.actor, r.action, r.timestamp_ns + r.sequence, + r.actor, + r.action, + format_timestamp(&r.timestamp) )); } out @@ -1348,13 +1396,15 @@ impl HeadlessRunner { "matched_rule_id": preview.matched_rule_id, "matched_rule_name": preview.matched_rule_name, "actions_evaluated": preview.actions_evaluated, - "execution_time_us": preview.execution_time_us, + "execution_time_us": duration_micros(&preview.execution_time), "condition_traces": preview.condition_traces, })) } else { format!( "Dry-run evaluation result: matched={}, actions={:?}, elapsed={}us", - preview.matched, preview.actions_evaluated, preview.execution_time_us + preview.matched, + preview.actions_evaluated, + duration_micros(&preview.execution_time) ) } } @@ -1545,7 +1595,11 @@ impl HeadlessRunner { for l in logs { out.push_str(&format!( "{:<4} {:<10} {:<10} {:<12} {}\n", - l.id, l.rule_id, l.message_id, l.action_taken, l.matched_at + l.id, + l.rule_id, + l.message_id, + l.action_taken, + format_timestamp(&l.matched_at) )); } out @@ -1733,7 +1787,7 @@ impl HeadlessRunner { imap_tls_mode: map_tls_mode_to_proto(imap_tls_mode).into(), smtp_tls_mode: map_tls_mode_to_proto(smtp_tls_mode).into(), keyring_secret_key: keyring_key.clone(), - sync_interval_secs: 300, + sync_interval: Some(nuncio_proto::time::duration_from_secs(300)), smtp_host: if is_caldav { String::new() } else { @@ -1837,7 +1891,7 @@ impl HeadlessRunner { "imap_tls_mode": a.imap_tls_mode().as_str_name(), "smtp_tls_mode": a.smtp_tls_mode().as_str_name(), "keyring_secret_key": a.keyring_secret_key, - "sync_interval_secs": a.sync_interval_secs, + "sync_interval_secs": duration_secs(&a.sync_interval), "collection_url": a.collection_url, }) }) @@ -1932,7 +1986,7 @@ impl HeadlessRunner { "imap_tls_mode": account.imap_tls_mode().as_str_name(), "smtp_tls_mode": account.smtp_tls_mode().as_str_name(), "keyring_secret_key": account.keyring_secret_key, - "sync_interval_secs": account.sync_interval_secs, + "sync_interval_secs": duration_secs(&account.sync_interval), })) } else { format!( @@ -2190,8 +2244,8 @@ impl HeadlessRunner { .list_events(nuncio_proto::v1::ListEventsRequest { account_id: account.to_string(), calendar_id: calendar.to_string(), - start_window: start, - end_window: end, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(start)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(end)), }) .await { @@ -2241,8 +2295,8 @@ impl HeadlessRunner { .sync(nuncio_proto::v1::CalendarSyncRequest { account_id: account.to_string(), calendar_id: calendar.to_string(), - start_window: start, - end_window: end, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(start)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(end)), }) .await { @@ -2933,7 +2987,7 @@ mod tests { imap_tls_mode: TlsModeProto::ImplicitTls.into(), smtp_tls_mode: TlsModeProto::ImplicitTls.into(), keyring_secret_key: "nuncio/acct-stub-1".to_string(), - sync_interval_secs: 300, + sync_interval: Some(nuncio_proto::time::duration_from_secs(300)), smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, collection_url: String::new(), @@ -3145,7 +3199,7 @@ mod tests { subject: "Stub Subject".to_string(), sender: "alice@nuncio.mx".to_string(), recipient: "bob@nuncio.mx".to_string(), - received_at: 1_700_000_000, + received_at: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_000_000)), read: false, body_plain: Some("Stub body text".to_string()), body_html: None, @@ -3598,8 +3652,8 @@ mod tests { account_id: "acct-stub-1".to_string(), calendar_id: "cal-stub".to_string(), summary: "Stub Meeting".to_string(), - start_time: 1_700_000_000, - end_time: 1_700_003_600, + start_time: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_000_000)), + end_time: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_003_600)), rrule: None, location: Some("Room 1".to_string()), } @@ -3707,8 +3761,14 @@ mod tests { .expect("stub daemon received a list_events request"); assert_eq!(recorded_list.account_id, "acct-stub-1"); assert_eq!(recorded_list.calendar_id, "cal-stub"); - assert_eq!(recorded_list.start_window, 0); - assert_eq!(recorded_list.end_window, i64::MAX); + assert_eq!( + recorded_list.start_window, + Some(nuncio_proto::time::timestamp_from_unix_secs(0)) + ); + assert_eq!( + recorded_list.end_window, + Some(nuncio_proto::time::timestamp_from_unix_secs(i64::MAX)) + ); // `cal sync`: proves the CLI is a real gRPC client of // `Calendar/Sync` and reports the daemon's real `synced_count`, @@ -4024,8 +4084,12 @@ mod tests { enabled: true, nsql_text: req.nsql, actions: vec!["MARK READ".to_string()], - created_at: 1_700_000_000, - updated_at: 1_700_000_000, + created_at: Some(nuncio_proto::time::timestamp_from_unix_secs( + 1_700_000_000, + )), + updated_at: Some(nuncio_proto::time::timestamp_from_unix_secs( + 1_700_000_000, + )), }), })) } @@ -4043,8 +4107,12 @@ mod tests { enabled: true, nsql_text: "WHERE subject CONTAINS 'Urgent' ACTION MARK READ".to_string(), actions: vec!["MARK READ".to_string()], - created_at: 1_700_000_000, - updated_at: 1_700_000_000, + created_at: Some(nuncio_proto::time::timestamp_from_unix_secs( + 1_700_000_000, + )), + updated_at: Some(nuncio_proto::time::timestamp_from_unix_secs( + 1_700_000_000, + )), }], })) } @@ -4086,7 +4154,7 @@ mod tests { matched_rule_id: Some("rule-stub-1".to_string()), matched_rule_name: Some("Stub Rule".to_string()), actions_evaluated: vec!["MARK READ".to_string()], - execution_time_us: 42, + execution_time: Some(nuncio_proto::time::duration_from_micros(42)), condition_traces: vec!["Rule 'Stub Rule': MATCH".to_string()], })) } @@ -4111,8 +4179,12 @@ mod tests { .nsql .unwrap_or_else(|| "WHERE subject CONTAINS 'Urgent'".to_string()), actions: vec!["MARK READ".to_string()], - created_at: 1_700_000_000, - updated_at: 1_700_000_001, + created_at: Some(nuncio_proto::time::timestamp_from_unix_secs( + 1_700_000_000, + )), + updated_at: Some(nuncio_proto::time::timestamp_from_unix_secs( + 1_700_000_001, + )), }), })) } @@ -4153,7 +4225,9 @@ mod tests { rule_id: "rule-stub-1".to_string(), message_id: "msg-stub-1".to_string(), action_taken: "MARK READ".to_string(), - matched_at: 1_700_000_002, + matched_at: Some(nuncio_proto::time::timestamp_from_unix_secs( + 1_700_000_002, + )), prev_hash: "prev-hash".to_string(), hash: "hash".to_string(), }], @@ -4640,7 +4714,9 @@ mod tests { Ok(tonic::Response::new(ListRecordsResponse { records: vec![AuditRecord { sequence: 1, - timestamp_ns: 1_700_000_000_000_000_000, + timestamp: Some(nuncio_proto::time::timestamp_from_unix_nanos( + 1_700_000_000_000_000_000, + )), actor: "system.test".to_string(), action: "data.export".to_string(), data_hash: "deadbeef".to_string(), diff --git a/crates/nuncio-proto/Cargo.toml b/crates/nuncio-proto/Cargo.toml index 71359e1..1012c13 100644 --- a/crates/nuncio-proto/Cargo.toml +++ b/crates/nuncio-proto/Cargo.toml @@ -13,6 +13,7 @@ workspace = true tonic.workspace = true tonic-prost.workspace = true prost.workspace = true +prost-types.workspace = true bytes.workspace = true thiserror.workspace = true diff --git a/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin b/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin index 850edc681e2a0d676fc2fee02bcd32376c5c1d8b..94ba2c20c5f91ea86c5d255886932d3c91912fcc 100644 GIT binary patch delta 1476 zcmcgsON-M`809ARb=%HhW|XwfRH+X{Asss?*5acbaS%71Fj90=()3z`$-N;>rw$51 zL=Xf;B0ipr;abWLY$YG3UH)KoOp zN7wXX6YgGcU$v@J-vIb-9^3P&b^%VH+eqv?UslVR7#okuBpVW(=B&BB&nd8JeqisF zY;%gBi#NT#Vo~O-Yq`f1cxQgycgKmuUT#hIJVa9Yl#6x@FehOW;m)}EZN|VSY^+Pm z!I-A?W#o>|9L`(#P2()gcuocq?vMdXN43GHU3}ApI|Fju_~c!Ys01sW+vs$=}dFk7-wu|*j0%?Z|hR+O2E@z&&v;hf9P2#Y2lXzm=H6#CU#klXRmn(A3<-lH?4CrL166 zjb!?m7~3Wvl&dxXN*9+T7L|ZalmJTyae_raMhQuBair#@KqNtSF;8A1AIms>GLu5M znIabtL~D6wUP^ws1T#oJh#M>x4i*A=38WpWfCX&8aRqnAsgwB?L&-E?^CiVRHpUl| i9W}I-fKC8~1klRdg7~~*2_}dW;9`Eoo0n=F;0FLh;+R$d diff --git a/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto b/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto index 171b418..368af82 100644 --- a/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto +++ b/crates/nuncio-proto/proto/nuncio/v1/nuncio.proto @@ -2,6 +2,9 @@ syntax = "proto3"; package nuncio.v1; +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; + // ErrorReason is the stable, machine-readable taxonomy of failure conditions // the daemon reports to clients. It is carried inside an `ErrorInfo` in a // failed RPC's gRPC status-details trailer so a client can branch on a @@ -272,8 +275,8 @@ message AccountConfig { TlsMode smtp_tls_mode = 9; // Keyring entry key used to retrieve OS vault credentials. string keyring_secret_key = 10; - // Background sync polling interval in seconds (minimum 10s). - uint64 sync_interval_secs = 11; + // Background sync polling interval (minimum 10s). + google.protobuf.Duration sync_interval = 11; // SMTP server hostname or IP address, used for outbound mail. Distinct // from `server_host` because a mail provider's inbound (IMAP/JMAP) and // outbound (SMTP) endpoints commonly differ. @@ -435,8 +438,8 @@ message Message { string sender = 5; // Recipient address. string recipient = 6; - // Unix timestamp of message arrival. - int64 received_at = 7; + // Timestamp of message arrival. + google.protobuf.Timestamp received_at = 7; // Read/unread flag status. bool read = 8; // Plaintext message body (already decrypted by the store). @@ -663,10 +666,10 @@ message FilterRule { // Rendered NSQL action clauses (e.g. "MOVE TO 'Archive'"), derived from // the parsed AST. repeated string actions = 7; - // Creation timestamp (unix seconds). - int64 created_at = 8; - // Last update timestamp (unix seconds). - int64 updated_at = 9; + // Creation timestamp. + google.protobuf.Timestamp created_at = 8; + // Last update timestamp. + google.protobuf.Timestamp updated_at = 9; } // CreateRuleRequest carries a new rule's display name, full NSQL source @@ -750,8 +753,8 @@ message PreviewRuleResponse { optional string matched_rule_name = 4; // NSQL-rendered actions that would be executed on a real match. repeated string actions_evaluated = 5; - // Evaluation duration in microseconds. - uint64 execution_time_us = 6; + // Evaluation duration. + google.protobuf.Duration execution_time = 6; // Human-readable step-by-step match traces. repeated string condition_traces = 7; } @@ -825,8 +828,8 @@ message FilterExecutionLog { string message_id = 3; // Action description string. string action_taken = 4; - // Timestamp of execution (unix seconds). - int64 matched_at = 5; + // Timestamp of execution. + google.protobuf.Timestamp matched_at = 5; // Hash of the preceding log record in the hash-chain ledger. string prev_hash = 6; // HMAC-SHA256 hash of this record. @@ -973,10 +976,10 @@ message CalendarEvent { string calendar_id = 3; // Event title or summary. string summary = 4; - // Unix timestamp for event start. - int64 start_time = 5; - // Unix timestamp for event end. - int64 end_time = 6; + // Timestamp for event start. + google.protobuf.Timestamp start_time = 5; + // Timestamp for event end. + google.protobuf.Timestamp end_time = 6; // Recurrence rule string (RFC 5545 RRULE format). optional string rrule = 7; // Location text. @@ -989,8 +992,8 @@ message CalendarEvent { message CalendarSyncRequest { string account_id = 1; string calendar_id = 2; - int64 start_window = 3; - int64 end_window = 4; + google.protobuf.Timestamp start_window = 3; + google.protobuf.Timestamp end_window = 4; } // CalendarSyncResponse reports how many events were synchronized. @@ -1002,8 +1005,8 @@ message CalendarSyncResponse { message ListEventsRequest { string account_id = 1; string calendar_id = 2; - int64 start_window = 3; - int64 end_window = 4; + google.protobuf.Timestamp start_window = 3; + google.protobuf.Timestamp end_window = 4; } // ListEventsResponse. @@ -1197,8 +1200,9 @@ service Audit { message AuditRecord { // Monotonically increasing sequence index. uint64 sequence = 1; - // UTC timestamp in nanoseconds since Unix epoch. - int64 timestamp_ns = 2; + // UTC timestamp, preserving the audit ledger's nanosecond precision in + // the `nanos` field. + google.protobuf.Timestamp timestamp = 2; // Subsystem or actor that performed the audited action. string actor = 3; // Action identifier (e.g. "data.export"). diff --git a/crates/nuncio-proto/src/lib.rs b/crates/nuncio-proto/src/lib.rs index 6709375..4ccefe7 100644 --- a/crates/nuncio-proto/src/lib.rs +++ b/crates/nuncio-proto/src/lib.rs @@ -23,6 +23,7 @@ pub mod addr; pub mod client; pub mod errors; +pub mod time; pub use addr::{grpc_addr_from_env, DEFAULT_GRPC_ADDR, GRPC_ADDR_ENV_VAR}; diff --git a/crates/nuncio-proto/src/time.rs b/crates/nuncio-proto/src/time.rs new file mode 100644 index 0000000..6702ddd --- /dev/null +++ b/crates/nuncio-proto/src/time.rs @@ -0,0 +1,138 @@ +//! Conversions between the store's native integer instants/durations and the +//! well-known `google.protobuf.Timestamp`/`google.protobuf.Duration` wire +//! types used at the `nuncio.v1` proto boundary. +//! +//! The daemon's core/store models keep unix-seconds `i64`/`u64` instants and +//! plain scalar durations; these helpers are the single place that maps them +//! to/from the well-known types so every RPC mapper does it the same way. +//! Re-exporting `Timestamp`/`Duration` here lets callers name the wire types +//! without depending on `prost-types` directly. + +pub use prost_types::{Duration, Timestamp}; + +/// Converts a unix-seconds instant into a `Timestamp` with a zero `nanos` +/// component. Use this for store fields that only ever carry second +/// granularity (e.g. message/event/rule timestamps). +#[must_use] +pub fn timestamp_from_unix_secs(unix_secs: i64) -> Timestamp { + Timestamp { + seconds: unix_secs, + nanos: 0, + } +} + +/// Extracts the unix-seconds instant from a `Timestamp`, truncating any +/// sub-second `nanos` component. Use this for fields mapped via +/// [`timestamp_from_unix_secs`], where `nanos` is always zero. +#[must_use] +pub fn timestamp_to_unix_secs(ts: &Timestamp) -> i64 { + ts.seconds +} + +/// Converts a unix-nanoseconds instant into a `Timestamp`, splitting it +/// losslessly into whole seconds plus a sub-second `nanos` remainder. Use +/// this for fields that carry sub-second precision (e.g. the WORM audit +/// ledger's `timestamp_ns`). +#[must_use] +pub fn timestamp_from_unix_nanos(unix_nanos: i64) -> Timestamp { + let seconds = unix_nanos.div_euclid(1_000_000_000); + let nanos = unix_nanos.rem_euclid(1_000_000_000); + Timestamp { + seconds, + nanos: nanos as i32, + } +} + +/// Recovers the exact unix-nanoseconds instant a `Timestamp` was built from +/// via [`timestamp_from_unix_nanos`]. +#[must_use] +pub fn timestamp_to_unix_nanos(ts: &Timestamp) -> i64 { + ts.seconds + .saturating_mul(1_000_000_000) + .saturating_add(i64::from(ts.nanos)) +} + +/// Converts a whole-second duration into a `Duration` with a zero `nanos` +/// component (e.g. `AccountConfig`'s sync polling interval). +#[must_use] +pub fn duration_from_secs(secs: u64) -> Duration { + Duration { + seconds: secs as i64, + nanos: 0, + } +} + +/// Extracts a whole-second duration from a `Duration`, truncating any +/// sub-second `nanos` component. A negative `seconds` (never produced by +/// this crate's own mappers) reads back as zero rather than panicking. +#[must_use] +pub fn duration_to_secs(d: &Duration) -> u64 { + u64::try_from(d.seconds).unwrap_or(0) +} + +/// Converts a microsecond duration into a `Duration`, splitting it +/// losslessly into whole seconds plus a sub-second `nanos` remainder (e.g. +/// filter rule preview evaluation timing). +#[must_use] +pub fn duration_from_micros(micros: u64) -> Duration { + let seconds = micros / 1_000_000; + let nanos = (micros % 1_000_000) * 1_000; + Duration { + seconds: seconds as i64, + nanos: nanos as i32, + } +} + +/// Recovers the exact microsecond duration a `Duration` was built from via +/// [`duration_from_micros`]. +#[must_use] +pub fn duration_to_micros(d: &Duration) -> u64 { + let seconds = u64::try_from(d.seconds).unwrap_or(0); + let nanos = u64::try_from(d.nanos).unwrap_or(0); + seconds + .saturating_mul(1_000_000) + .saturating_add(nanos / 1_000) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seconds_round_trip_through_timestamp() { + let ts = timestamp_from_unix_secs(1_700_000_000); + assert_eq!(ts.nanos, 0); + assert_eq!(timestamp_to_unix_secs(&ts), 1_700_000_000); + } + + #[test] + fn nanos_round_trip_through_timestamp() { + let original_ns = 1_700_000_000_123_456_789_i64; + let ts = timestamp_from_unix_nanos(original_ns); + assert_eq!(timestamp_to_unix_nanos(&ts), original_ns); + } + + #[test] + fn negative_epoch_nanos_round_trip() { + // Regression: pre-epoch instants must still split into a + // non-negative `nanos` remainder per the Timestamp wire contract. + let original_ns = -1_500_000_000_i64; + let ts = timestamp_from_unix_nanos(original_ns); + assert!(ts.nanos >= 0); + assert_eq!(timestamp_to_unix_nanos(&ts), original_ns); + } + + #[test] + fn secs_round_trip_through_duration() { + let d = duration_from_secs(300); + assert_eq!(d.nanos, 0); + assert_eq!(duration_to_secs(&d), 300); + } + + #[test] + fn micros_round_trip_through_duration() { + let original_us = 1_234_567_u64; + let d = duration_from_micros(original_us); + assert_eq!(duration_to_micros(&d), original_us); + } +} diff --git a/crates/nunciod/src/grpc.rs b/crates/nunciod/src/grpc.rs index cb25ab5..7ac4718 100644 --- a/crates/nunciod/src/grpc.rs +++ b/crates/nunciod/src/grpc.rs @@ -285,7 +285,9 @@ fn map_account_config_to_proto(config: nuncio_core::AccountConfig) -> AccountCon imap_tls_mode: map_tls_mode_to_proto(config.imap_tls_mode).into(), smtp_tls_mode: map_tls_mode_to_proto(config.smtp_tls_mode).into(), keyring_secret_key: config.keyring_secret_key, - sync_interval_secs: config.sync_interval_secs, + sync_interval: Some(nuncio_proto::time::duration_from_secs( + config.sync_interval_secs, + )), smtp_host: config.smtp_host, smtp_port: u32::from(config.smtp_port), collection_url: config.collection_url, @@ -305,6 +307,9 @@ fn map_account_config_from_proto( .map_err(|_| Status::invalid_argument("server_port must be in range 1..=65535"))?; let smtp_port = u16::try_from(config.smtp_port) .map_err(|_| Status::invalid_argument("smtp_port must be in range 1..=65535"))?; + let sync_interval = config + .sync_interval + .ok_or_else(|| Status::invalid_argument("sync_interval is required"))?; Ok(nuncio_core::AccountConfig { id: config.id, @@ -319,7 +324,7 @@ fn map_account_config_from_proto( imap_tls_mode, smtp_tls_mode, keyring_secret_key: config.keyring_secret_key, - sync_interval_secs: config.sync_interval_secs, + sync_interval_secs: nuncio_proto::time::duration_to_secs(&sync_interval), collection_url: config.collection_url, }) } @@ -777,7 +782,9 @@ fn map_email_to_proto(email: nuncio_core::model::Email) -> MessageProto { subject: email.subject, sender: email.sender, recipient: email.recipient, - received_at: email.received_at, + received_at: Some(nuncio_proto::time::timestamp_from_unix_secs( + email.received_at, + )), read: email.read, body_plain: email.body_plain, body_html: email.body_html, @@ -812,8 +819,10 @@ fn map_calendar_event_to_proto(event: nuncio_core::model::CalendarEvent) -> Cale account_id: event.account_id, calendar_id: event.calendar_id, summary: event.summary, - start_time: event.start_time, - end_time: event.end_time, + start_time: Some(nuncio_proto::time::timestamp_from_unix_secs( + event.start_time, + )), + end_time: Some(nuncio_proto::time::timestamp_from_unix_secs(event.end_time)), rrule: event.rrule, location: event.location, } @@ -858,6 +867,17 @@ fn map_contact_to_proto(contact: nuncio_contacts::Contact) -> ContactProto { } } +/// Extracts a required window bound (`start_window`/`end_window`) from its +/// wire-format `Timestamp`, rejecting an absent field with +/// `Status::invalid_argument` rather than silently defaulting to the epoch. +fn require_window_bound( + ts: Option, + field: &str, +) -> Result { + let ts = ts.ok_or_else(|| Status::invalid_argument(format!("{field} is required")))?; + Ok(nuncio_proto::time::timestamp_to_unix_secs(&ts)) +} + /// Combines an account's in-window events with its recurring masters into the final event /// list for a `ListEvents` query window, expanding each recurring master into its real /// occurrences via [`nuncio_cal::RecurrenceEngine::expand_occurrences`] rather than trusting @@ -956,6 +976,8 @@ impl CalendarGrpcService { async fn sync_configured_caldav_accounts( &self, req: &CalendarSyncRequest, + start_window: i64, + end_window: i64, ) -> Result { let caldav_accounts: Vec = self .db @@ -1004,8 +1026,8 @@ impl CalendarGrpcService { &account, &password, &req.calendar_id, - req.start_window, - req.end_window, + start_window, + end_window, ) .await .map_err(|e| { @@ -1027,18 +1049,23 @@ impl Calendar for CalendarGrpcService { request: Request, ) -> Result, Status> { let req = request.into_inner(); + let start_window = require_window_bound(req.start_window, "start_window")?; + let end_window = require_window_bound(req.end_window, "end_window")?; let synced = match &self.overrides.calendar_backend { Some(backend) => crate::calendar_sync::sync_with_backend( &self.db, backend.as_ref(), &req.calendar_id, - req.start_window, - req.end_window, + start_window, + end_window, ) .await .map_err(|e| Status::internal(format!("calendar sync failed: {e}")))?, - None => self.sync_configured_caldav_accounts(&req).await?, + None => { + self.sync_configured_caldav_accounts(&req, start_window, end_window) + .await? + } }; Ok(Response::new(CalendarSyncResponse { @@ -1054,10 +1081,12 @@ impl Calendar for CalendarGrpcService { if req.account_id.is_empty() { return Err(Status::invalid_argument("account_id is required")); } + let start_window = require_window_bound(req.start_window, "start_window")?; + let end_window = require_window_bound(req.end_window, "end_window")?; let all_in_window = self .db - .list_calendar_events(&req.account_id, req.start_window, req.end_window) + .list_calendar_events(&req.account_id, start_window, end_window) .await .map_err(|e| Status::internal(format!("failed to list calendar events: {e}")))?; @@ -1068,7 +1097,7 @@ impl Calendar for CalendarGrpcService { .map_err(|e| Status::internal(format!("failed to list recurring events: {e}")))?; let mut events = - expand_events_for_window(all_in_window, recurring, req.start_window, req.end_window); + expand_events_for_window(all_in_window, recurring, start_window, end_window); events.retain(|e| req.calendar_id.is_empty() || e.calendar_id == req.calendar_id); @@ -1574,8 +1603,12 @@ fn map_filter_rule_to_proto(rule: nuncio_filter::FilterRule) -> FilterRuleProto enabled: rule.enabled, nsql_text: rule.nsql_text, actions: rule.actions.iter().map(|a| a.to_nsql()).collect(), - created_at: rule.created_at, - updated_at: rule.updated_at, + created_at: Some(nuncio_proto::time::timestamp_from_unix_secs( + rule.created_at, + )), + updated_at: Some(nuncio_proto::time::timestamp_from_unix_secs( + rule.updated_at, + )), } } @@ -1589,7 +1622,7 @@ fn map_filter_execution_log_to_proto( rule_id: log.rule_id, message_id: log.message_id, action_taken: log.action_taken, - matched_at: log.matched_at, + matched_at: Some(nuncio_proto::time::timestamp_from_unix_secs(log.matched_at)), prev_hash: log.prev_hash, hash: log.hash, } @@ -1608,7 +1641,9 @@ fn map_preview_result_to_proto(preview: nuncio_filter::FilterPreviewResult) -> P .iter() .map(|a| a.to_nsql()) .collect(), - execution_time_us: preview.execution_time_us, + execution_time: Some(nuncio_proto::time::duration_from_micros( + preview.execution_time_us, + )), condition_traces: preview.condition_traces, } } @@ -2152,7 +2187,9 @@ const DEFAULT_LIST_RECORDS_LIMIT: u32 = 100; fn map_audit_record_to_proto(record: nuncio_core::WormAuditRecord) -> AuditRecordProto { AuditRecordProto { sequence: record.sequence, - timestamp_ns: record.timestamp_ns, + timestamp: Some(nuncio_proto::time::timestamp_from_unix_nanos( + record.timestamp_ns, + )), actor: record.actor, action: record.action, data_hash: record.data_hash, @@ -2575,6 +2612,73 @@ mod tests { use nuncio_proto::v1::system_client::SystemClient; use tonic::Code; + /// Proves a stored second-granularity instant survives the proto + /// boundary exactly: `map_email_to_proto`'s `received_at` decodes back + /// to the identical unix-seconds value via `timestamp_to_unix_secs`. + #[test] + fn map_email_to_proto_round_trips_received_at_exactly() { + let original_secs = 1_700_000_123_i64; + let email = nuncio_core::model::Email { + id: "msg-1".to_string(), + account_id: "acct-1".to_string(), + folder_id: "inbox".to_string(), + subject: "s".to_string(), + sender: "a@b.com".to_string(), + recipient: "c@d.com".to_string(), + received_at: original_secs, + read: false, + body_plain: None, + body_html: None, + attachments: Vec::new(), + remote_id: "remote-1".to_string(), + uid_validity: "1".to_string(), + }; + + let proto = map_email_to_proto(email); + let ts = proto.received_at.expect("received_at is always populated"); + assert_eq!( + nuncio_proto::time::timestamp_to_unix_secs(&ts), + original_secs + ); + } + + /// Proves the WORM audit ledger's sub-second precision survives the + /// proto boundary losslessly: `map_audit_record_to_proto`'s `timestamp` + /// decodes back to the exact original `timestamp_ns`, not just the + /// truncated seconds component. + #[test] + fn map_audit_record_to_proto_preserves_nanosecond_precision() { + let original_ns = 1_700_000_123_987_654_321_i64; + let record = nuncio_core::WormAuditRecord { + sequence: 1, + timestamp_ns: original_ns, + actor: "system".to_string(), + action: "test.action".to_string(), + data_hash: "hash".to_string(), + previous_block_hash: "GENESIS".to_string(), + record_hmac: "mac".to_string(), + }; + + let proto = map_audit_record_to_proto(record); + let ts = proto.timestamp.expect("timestamp is always populated"); + assert_eq!( + nuncio_proto::time::timestamp_to_unix_nanos(&ts), + original_ns + ); + } + + /// Proves an account's sync interval survives the proto `Duration` + /// boundary in both directions: to-proto then from-proto reconstructs + /// the exact original whole-second value. + #[test] + fn account_sync_interval_round_trips_through_duration() { + let mut config = sample_account_config_proto("acct-rt", "nuncio/acct-rt"); + config.sync_interval = Some(nuncio_proto::time::duration_from_secs(600)); + + let core_config = map_account_config_from_proto(config).expect("valid config maps back"); + assert_eq!(core_config.sync_interval_secs, 600); + } + /// Spawns a test server backed by a fresh ephemeral database, a fresh /// empty [`FilterEngine`], and a fresh [`SecretManager::mock`] vault -- /// the right default for every test that only exercises @@ -3137,7 +3241,7 @@ mod tests { imap_tls_mode: TlsModeProto::ImplicitTls.into(), smtp_tls_mode: TlsModeProto::ImplicitTls.into(), keyring_secret_key: keyring_secret_key.to_string(), - sync_interval_secs: 60, + sync_interval: Some(nuncio_proto::time::duration_from_secs(60)), collection_url: String::new(), smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, @@ -3708,8 +3812,8 @@ mod tests { .list_events(ListEventsRequest { account_id: "acct-1".to_string(), calendar_id: "cal-1".to_string(), - start_window: 0, - end_window: i64::MAX, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(i64::MAX)), }) .await .expect_err("missing bearer token must be rejected"); @@ -3727,8 +3831,8 @@ mod tests { .sync(CalendarSyncRequest { account_id: "acct-1".to_string(), calendar_id: "cal-1".to_string(), - start_window: 0, - end_window: i64::MAX, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(i64::MAX)), }) .await .expect_err("missing bearer token must be rejected"); @@ -5539,8 +5643,8 @@ mod tests { .list_events(authed_bearer_request(ListEventsRequest { account_id: "acct-cal-1".to_string(), calendar_id: "cal-work".to_string(), - start_window: 1_699_999_000, - end_window: 1_700_004_000, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_699_999_000)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_004_000)), })) .await .expect("list_events succeeds") @@ -5598,8 +5702,8 @@ mod tests { .list_events(authed_bearer_request(ListEventsRequest { account_id: "acct-cal-1".to_string(), calendar_id: "cal-work".to_string(), - start_window: 1_699_999_000, - end_window: 1_700_004_000, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_699_999_000)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_004_000)), })) .await .expect("list_events succeeds") @@ -5624,8 +5728,8 @@ mod tests { .list_events(authed_bearer_request(ListEventsRequest { account_id: String::new(), calendar_id: "cal-work".to_string(), - start_window: 0, - end_window: i64::MAX, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(i64::MAX)), })) .await .expect_err("empty account_id must be rejected"); @@ -5717,8 +5821,8 @@ mod tests { .sync(authed_bearer_request(CalendarSyncRequest { account_id: "acct-cal-no-config".to_string(), calendar_id: "cal-work".to_string(), - start_window: 0, - end_window: i64::MAX, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(i64::MAX)), })) .await .expect_err("sync with no injected backend and no CalDAV config must fail"); @@ -5775,8 +5879,8 @@ mod tests { .sync(authed_bearer_request(CalendarSyncRequest { account_id: "acct-cal-injected".to_string(), calendar_id: "cal-work".to_string(), - start_window: 1_699_999_000, - end_window: 1_700_004_000, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_699_999_000)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_004_000)), })) .await .expect("sync succeeds") @@ -5788,8 +5892,8 @@ mod tests { .list_events(authed_bearer_request(ListEventsRequest { account_id: "acct-cal-injected".to_string(), calendar_id: "cal-work".to_string(), - start_window: 1_699_999_000, - end_window: 1_700_004_000, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_699_999_000)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_004_000)), })) .await .expect("list_events succeeds") @@ -6296,8 +6400,8 @@ mod tests { .list_events(ListEventsRequest { account_id: "acct-1".to_string(), calendar_id: "cal-1".to_string(), - start_window: 0, - end_window: i64::MAX, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(i64::MAX)), }) .await .expect_err("Calendar must reject an unauthenticated call"); diff --git a/crates/nunciod/tests/accounts_lifecycle_e2e_test.rs b/crates/nunciod/tests/accounts_lifecycle_e2e_test.rs index 10c5b42..8da6b0d 100644 --- a/crates/nunciod/tests/accounts_lifecycle_e2e_test.rs +++ b/crates/nunciod/tests/accounts_lifecycle_e2e_test.rs @@ -86,7 +86,7 @@ fn sample_account_config() -> AccountConfig { imap_tls_mode: TlsMode::StartTls.into(), smtp_tls_mode: TlsMode::Plain.into(), keyring_secret_key: KEYRING_KEY.to_string(), - sync_interval_secs: 60, + sync_interval: Some(nuncio_proto::time::duration_from_secs(60)), smtp_host: "127.0.0.1".to_string(), smtp_port: 25, collection_url: String::new(), diff --git a/crates/nunciod/tests/caldav_production_e2e_test.rs b/crates/nunciod/tests/caldav_production_e2e_test.rs index dd0b89e..51a9d1f 100644 --- a/crates/nunciod/tests/caldav_production_e2e_test.rs +++ b/crates/nunciod/tests/caldav_production_e2e_test.rs @@ -165,7 +165,7 @@ async fn caldav_production_sync_builds_real_client_and_persists_events() { imap_tls_mode: TlsModeProto::ImplicitTls.into(), smtp_tls_mode: TlsModeProto::ImplicitTls.into(), keyring_secret_key: KEYRING_KEY.to_string(), - sync_interval_secs: 300, + sync_interval: Some(nuncio_proto::time::duration_from_secs(300)), smtp_host: String::new(), smtp_port: 0, collection_url: collection_url.clone(), @@ -183,8 +183,8 @@ async fn caldav_production_sync_builds_real_client_and_persists_events() { .sync(CalendarSyncRequest { account_id: ACCOUNT_ID.to_string(), calendar_id: CALENDAR_ID.to_string(), - start_window: 1_700_000_000, - end_window: 1_800_000_000, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_000_000)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_800_000_000)), }) .await .expect("production CalDAV sync succeeds") @@ -196,8 +196,8 @@ async fn caldav_production_sync_builds_real_client_and_persists_events() { .list_events(ListEventsRequest { account_id: ACCOUNT_ID.to_string(), calendar_id: CALENDAR_ID.to_string(), - start_window: 1_700_000_000, - end_window: 1_800_000_000, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_000_000)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_800_000_000)), }) .await .expect("list_events succeeds") @@ -216,8 +216,8 @@ async fn caldav_production_sync_builds_real_client_and_persists_events() { .sync(CalendarSyncRequest { account_id: "acct-does-not-exist".to_string(), calendar_id: CALENDAR_ID.to_string(), - start_window: 1_700_000_000, - end_window: 1_800_000_000, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_000_000)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_800_000_000)), }) .await .expect_err("a sync targeting an unconfigured account must fail honestly"); @@ -232,8 +232,8 @@ async fn caldav_production_sync_builds_real_client_and_persists_events() { .list_events(ListEventsRequest { account_id: ACCOUNT_ID.to_string(), calendar_id: CALENDAR_ID.to_string(), - start_window: 0, - end_window: i64::MAX, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(i64::MAX)), }) .await .expect_err("missing bearer token must be rejected"); diff --git a/crates/nunciod/tests/calendar_e2e_test.rs b/crates/nunciod/tests/calendar_e2e_test.rs index 72b534d..3c3ea63 100644 --- a/crates/nunciod/tests/calendar_e2e_test.rs +++ b/crates/nunciod/tests/calendar_e2e_test.rs @@ -138,8 +138,8 @@ async fn calendar_sync_then_list_and_get_round_trip_over_authenticated_grpc() { .sync(CalendarSyncRequest { account_id: ACCOUNT_ID.to_string(), calendar_id: CALENDAR_ID.to_string(), - start_window: 1_699_999_000, - end_window: 1_700_020_000, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_699_999_000)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_020_000)), }) .await .expect("sync succeeds") @@ -152,8 +152,8 @@ async fn calendar_sync_then_list_and_get_round_trip_over_authenticated_grpc() { .list_events(ListEventsRequest { account_id: ACCOUNT_ID.to_string(), calendar_id: CALENDAR_ID.to_string(), - start_window: 1_699_999_000, - end_window: 1_700_020_000, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_699_999_000)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(1_700_020_000)), }) .await .expect("list_events succeeds") @@ -187,8 +187,8 @@ async fn calendar_sync_then_list_and_get_round_trip_over_authenticated_grpc() { .list_events(ListEventsRequest { account_id: ACCOUNT_ID.to_string(), calendar_id: CALENDAR_ID.to_string(), - start_window: 0, - end_window: i64::MAX, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(i64::MAX)), }) .await .expect_err("missing bearer token must be rejected"); diff --git a/crates/nunciod/tests/spine_e2e_test.rs b/crates/nunciod/tests/spine_e2e_test.rs index 187ef0d..42f786c 100644 --- a/crates/nunciod/tests/spine_e2e_test.rs +++ b/crates/nunciod/tests/spine_e2e_test.rs @@ -65,7 +65,7 @@ fn sample_account_config() -> AccountConfig { imap_tls_mode: TlsMode::ImplicitTls.into(), smtp_tls_mode: TlsMode::ImplicitTls.into(), keyring_secret_key: KEYRING_KEY.to_string(), - sync_interval_secs: 60, + sync_interval: Some(nuncio_proto::time::duration_from_secs(60)), smtp_host: "smtp.nuncio.mx".to_string(), smtp_port: 465, collection_url: String::new(), diff --git a/crates/nunciod/tests/typed_errors_e2e_test.rs b/crates/nunciod/tests/typed_errors_e2e_test.rs index 436f49c..6e36958 100644 --- a/crates/nunciod/tests/typed_errors_e2e_test.rs +++ b/crates/nunciod/tests/typed_errors_e2e_test.rs @@ -140,8 +140,8 @@ async fn typed_error_reasons_decode_from_status_details_over_the_wire() { .sync(CalendarSyncRequest { account_id: String::new(), calendar_id: "personal".to_string(), - start_window: 0, - end_window: 0, + start_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)), + end_window: Some(nuncio_proto::time::timestamp_from_unix_secs(0)), }) .await .expect_err("syncing with no CalDAV account configured must fail"); -- 2.54.0