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 850edc6..94ba2c2 100644 Binary files a/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin and b/crates/nuncio-proto/proto/nuncio/v1/descriptor.bin differ 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");