feat(grpc): Export + Audit services over authenticated gRPC
Add authenticated Export (ExportMailbox -> mbox/eml-zip/json/jsonl written to a local file, backed by the real ExportEngine + a scoped message query) and Audit (ListRecords, VerifyChain over the WORM ledger with a structured report) gRPC services, each mounted with the bearer interceptor like the others. Wire CLI 'mail export' and 'system audit list/verify'. Audit records expose the record MAC, never the signing key. Offline tests cover export content/counts, chain verify (intact + tampered), and auth rejection. Refs #172
This commit is contained in:
8 files changed
+1763
-24
No files matched your search
@@ -302,6 +302,35 @@ pub enum MailSubcommand {
|
||||
#[arg(long, help = "Mark the message as unread", conflicts_with = "read")]
|
||||
unread: bool,
|
||||
},
|
||||
/// Export mailbox messages to a portable file format (backlog story
|
||||
/// 2.B, GH #172): MBOX, an EML zip archive, JSON, or JSON Lines.
|
||||
Export {
|
||||
/// Export format (mbox, eml, json, or jsonl).
|
||||
#[arg(
|
||||
short = 'f',
|
||||
long,
|
||||
default_value = "mbox",
|
||||
help = "Export format (mbox, eml, json, jsonl)"
|
||||
)]
|
||||
format: String,
|
||||
/// Destination output file path, on the daemon's host filesystem.
|
||||
#[arg(short = 'o', long, help = "Destination output file path")]
|
||||
out: String,
|
||||
/// Restrict the export to a single account's messages.
|
||||
#[arg(
|
||||
long,
|
||||
help = "Restrict export to a single account ID",
|
||||
conflicts_with = "folder"
|
||||
)]
|
||||
account: Option<String>,
|
||||
/// Restrict the export to a single folder's messages.
|
||||
#[arg(
|
||||
long,
|
||||
help = "Restrict export to a single folder ID",
|
||||
conflicts_with = "account"
|
||||
)]
|
||||
folder: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Folder subcommands (`nuncio folder <verb>`).
|
||||
@@ -328,6 +357,31 @@ pub enum CalSubcommand {
|
||||
pub enum SystemSubcommand {
|
||||
/// Display system, daemon, and event bus status.
|
||||
Status,
|
||||
/// WORM (Write Once, Read Many) tamper-evident audit ledger operations
|
||||
/// (`nuncio system audit <verb>`, backlog story 2.B, GH #172).
|
||||
Audit {
|
||||
#[command(subcommand)]
|
||||
action: AuditSubcommand,
|
||||
},
|
||||
}
|
||||
|
||||
/// Audit subcommands (`nuncio system audit <verb>`, backlog story 2.B,
|
||||
/// GH #172).
|
||||
#[derive(Subcommand, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AuditSubcommand {
|
||||
/// List persisted WORM audit ledger records, sequence ascending.
|
||||
List {
|
||||
/// Max records to fetch (0 = server default).
|
||||
#[arg(short, long, default_value_t = 50, help = "Max records to fetch")]
|
||||
limit: u32,
|
||||
/// Pagination offset.
|
||||
#[arg(short, long, default_value_t = 0, help = "Pagination offset")]
|
||||
offset: u32,
|
||||
},
|
||||
/// Verify the WORM audit ledger's cryptographic HMAC hash-chain
|
||||
/// integrity.
|
||||
Verify,
|
||||
}
|
||||
|
||||
/// Filter subcommands (`nuncio filter <verb>`).
|
||||
@@ -499,6 +553,110 @@ mod tests {
|
||||
assert!(conflict.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pure_noun_verb_mail_export_command() {
|
||||
let cli_export = Cli::parse_from([
|
||||
"nuncio",
|
||||
"mail",
|
||||
"export",
|
||||
"--format",
|
||||
"json",
|
||||
"--out",
|
||||
"/tmp/out.json",
|
||||
]);
|
||||
assert_eq!(
|
||||
cli_export.command,
|
||||
Commands::Mail {
|
||||
action: MailSubcommand::Export {
|
||||
format: "json".to_string(),
|
||||
out: "/tmp/out.json".to_string(),
|
||||
account: None,
|
||||
folder: None,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let cli_export_scoped = Cli::parse_from([
|
||||
"nuncio",
|
||||
"mail",
|
||||
"export",
|
||||
"--format",
|
||||
"jsonl",
|
||||
"--out",
|
||||
"/tmp/scoped.jsonl",
|
||||
"--account",
|
||||
"acct-1",
|
||||
]);
|
||||
assert_eq!(
|
||||
cli_export_scoped.command,
|
||||
Commands::Mail {
|
||||
action: MailSubcommand::Export {
|
||||
format: "jsonl".to_string(),
|
||||
out: "/tmp/scoped.jsonl".to_string(),
|
||||
account: Some("acct-1".to_string()),
|
||||
folder: None,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// `--account` and `--folder` are mutually exclusive at the Clap
|
||||
// level -- an export scope is either "one account", "one folder",
|
||||
// or (neither flag) "everything".
|
||||
let conflict = Cli::try_parse_from([
|
||||
"nuncio",
|
||||
"mail",
|
||||
"export",
|
||||
"--out",
|
||||
"/tmp/out.json",
|
||||
"--account",
|
||||
"acct-1",
|
||||
"--folder",
|
||||
"inbox",
|
||||
]);
|
||||
assert!(conflict.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pure_noun_verb_system_audit_commands() {
|
||||
let cli_list = Cli::parse_from(["nuncio", "system", "audit", "list"]);
|
||||
assert_eq!(
|
||||
cli_list.command,
|
||||
Commands::System {
|
||||
action: SystemSubcommand::Audit {
|
||||
action: AuditSubcommand::List {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let cli_list_paged = Cli::parse_from([
|
||||
"nuncio", "system", "audit", "list", "--limit", "10", "--offset", "5",
|
||||
]);
|
||||
assert_eq!(
|
||||
cli_list_paged.command,
|
||||
Commands::System {
|
||||
action: SystemSubcommand::Audit {
|
||||
action: AuditSubcommand::List {
|
||||
limit: 10,
|
||||
offset: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let cli_verify = Cli::parse_from(["nuncio", "system", "audit", "verify"]);
|
||||
assert_eq!(
|
||||
cli_verify.command,
|
||||
Commands::System {
|
||||
action: SystemSubcommand::Audit {
|
||||
action: AuditSubcommand::Verify
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pure_noun_verb_update_commands() {
|
||||
let cli_check = Cli::parse_from(["nuncio", "update", "check"]);
|
||||
|
||||
@@ -8,8 +8,8 @@ use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::args::{
|
||||
AccountSubcommand, CalSubcommand, Commands, ContactSubcommand, FilterSubcommand,
|
||||
FolderSubcommand, MailSubcommand, SystemSubcommand, UpdateSubcommand,
|
||||
AccountSubcommand, AuditSubcommand, CalSubcommand, Commands, ContactSubcommand,
|
||||
FilterSubcommand, FolderSubcommand, MailSubcommand, SystemSubcommand, UpdateSubcommand,
|
||||
};
|
||||
|
||||
use crate::output::{format_json, format_json_error};
|
||||
@@ -57,6 +57,34 @@ fn message_proto_to_json(message: &nuncio_proto::v1::Message) -> serde_json::Val
|
||||
})
|
||||
}
|
||||
|
||||
/// Maps a `nuncio_core::export::ExportFormat` onto its wire-format
|
||||
/// `nuncio.v1.ExportFormat` enum value, mirroring `nunciod::grpc`'s
|
||||
/// server-side mapping (backlog story 2.B, GH #172).
|
||||
fn map_export_format_to_proto(format: nuncio_core::ExportFormat) -> nuncio_proto::v1::ExportFormat {
|
||||
match format {
|
||||
nuncio_core::ExportFormat::Mbox => nuncio_proto::v1::ExportFormat::Mbox,
|
||||
nuncio_core::ExportFormat::EmlZip => nuncio_proto::v1::ExportFormat::EmlZip,
|
||||
nuncio_core::ExportFormat::Json => nuncio_proto::v1::ExportFormat::Json,
|
||||
nuncio_core::ExportFormat::JsonLines => nuncio_proto::v1::ExportFormat::Jsonl,
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a `nuncio.v1.AuditRecord` (as returned by the daemon's `Audit`
|
||||
/// gRPC service, backlog story 2.B / GH #172) into the JSON shape used by
|
||||
/// `system audit list`'s `--json` output. `record_hmac` is a verification
|
||||
/// MAC output, never secret key material, so it is safe to include here.
|
||||
fn audit_record_proto_to_json(record: &nuncio_proto::v1::AuditRecord) -> serde_json::Value {
|
||||
json!({
|
||||
"sequence": record.sequence,
|
||||
"timestamp_ns": record.timestamp_ns,
|
||||
"actor": record.actor,
|
||||
"action": record.action,
|
||||
"data_hash": record.data_hash,
|
||||
"previous_block_hash": record.previous_block_hash,
|
||||
"record_hmac": record.record_hmac,
|
||||
})
|
||||
}
|
||||
|
||||
/// Renders a `nuncio.v1.FilterRule` (as returned by the daemon's `Filters`
|
||||
/// gRPC service, backlog story 2.A / GH #171) into the JSON shape used by
|
||||
/// `filter list`/`filter create`'s `--json` output.
|
||||
@@ -236,6 +264,21 @@ impl HeadlessRunner {
|
||||
MailSubcommand::Mark { id, read, unread } => {
|
||||
self.handle_mark_read(id, *read, *unread, json_mode).await
|
||||
}
|
||||
MailSubcommand::Export {
|
||||
format,
|
||||
out,
|
||||
account,
|
||||
folder,
|
||||
} => {
|
||||
self.handle_mail_export(
|
||||
format,
|
||||
out,
|
||||
account.as_deref(),
|
||||
folder.as_deref(),
|
||||
json_mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
},
|
||||
Commands::Banner => {
|
||||
crate::output::print_splash_banner();
|
||||
@@ -319,6 +362,12 @@ impl HeadlessRunner {
|
||||
},
|
||||
Commands::System { action } => match action {
|
||||
SystemSubcommand::Status => self.handle_system_status(json_mode).await,
|
||||
SystemSubcommand::Audit { action } => match action {
|
||||
AuditSubcommand::List { limit, offset } => {
|
||||
self.handle_audit_list(*limit, *offset, json_mode).await
|
||||
}
|
||||
AuditSubcommand::Verify => self.handle_audit_verify(json_mode).await,
|
||||
},
|
||||
},
|
||||
Commands::Contact { action } => match action {
|
||||
ContactSubcommand::List => {
|
||||
@@ -941,6 +990,191 @@ impl HeadlessRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/// `mail export`: a real thin gRPC client of the running `nunciod`
|
||||
/// daemon's `nuncio.v1.Export` API (backlog story 2.B, GH #172). The
|
||||
/// daemon writes the export to `out` on ITS OWN host filesystem (the
|
||||
/// same host this CLI runs on, in this local deployment) using the real
|
||||
/// `nuncio_core::export::ExportEngine`, and reports back the real
|
||||
/// message/byte counts it actually wrote -- never a fabricated
|
||||
/// summary. `account`/`folder` are mutually exclusive (enforced both by
|
||||
/// Clap's `conflicts_with` and the daemon's own oneof `scope`); leaving
|
||||
/// both unset exports every message.
|
||||
async fn handle_mail_export(
|
||||
&self,
|
||||
format: &str,
|
||||
out: &str,
|
||||
account: Option<&str>,
|
||||
folder: Option<&str>,
|
||||
json_mode: bool,
|
||||
) -> String {
|
||||
let export_format = match format.parse::<nuncio_core::ExportFormat>() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
|
||||
let mut client = match self.connect_export_client().await {
|
||||
Ok(client) => client,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
|
||||
let scope = if let Some(account_id) = account {
|
||||
Some(nuncio_proto::v1::export_request::Scope::AccountId(
|
||||
account_id.to_string(),
|
||||
))
|
||||
} else {
|
||||
folder.map(|folder_id| {
|
||||
nuncio_proto::v1::export_request::Scope::FolderId(folder_id.to_string())
|
||||
})
|
||||
};
|
||||
|
||||
match client
|
||||
.export_mailbox(nuncio_proto::v1::ExportRequest {
|
||||
scope,
|
||||
format: map_export_format_to_proto(export_format).into(),
|
||||
output_path: out.to_string(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let resp = response.into_inner();
|
||||
if json_mode {
|
||||
format_json(&json!({
|
||||
"output_path": resp.output_path,
|
||||
"message_count": resp.message_count,
|
||||
"bytes_written": resp.bytes_written,
|
||||
}))
|
||||
} else {
|
||||
format!(
|
||||
"Exported {} message(s) to '{}' ({} bytes)",
|
||||
resp.message_count, resp.output_path, resp.bytes_written
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(status) => Self::render_error(
|
||||
&format!("nunciod daemon rejected export_mailbox: {status}"),
|
||||
json_mode,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the gRPC bearer token from the injected vault and dials the
|
||||
/// `nuncio.v1.Export` service at `self.grpc_addr` (backlog story 2.B,
|
||||
/// GH #172), shared by [`Self::handle_mail_export`].
|
||||
async fn connect_export_client(
|
||||
&self,
|
||||
) -> Result<nuncio_proto::client::AuthenticatedExportClient, String> {
|
||||
let token_bytes = self
|
||||
.secrets
|
||||
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
|
||||
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
|
||||
let token = hex::encode(token_bytes);
|
||||
|
||||
nuncio_proto::client::connect_export(&self.grpc_addr, &token)
|
||||
.await
|
||||
.map_err(|e| format!("nunciod daemon unreachable at {}: {e}", self.grpc_addr))
|
||||
}
|
||||
|
||||
/// `system audit list`: a real thin gRPC client of the running
|
||||
/// `nunciod` daemon's `nuncio.v1.Audit` API (backlog story 2.B,
|
||||
/// GH #172). Lists a page of the daemon's real, persisted WORM audit
|
||||
/// ledger, sequence ascending.
|
||||
async fn handle_audit_list(&self, limit: u32, offset: u32, json_mode: bool) -> String {
|
||||
let mut client = match self.connect_audit_client().await {
|
||||
Ok(client) => client,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
|
||||
match client
|
||||
.list_records(nuncio_proto::v1::ListRecordsRequest { limit, offset })
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let records = response.into_inner().records;
|
||||
if json_mode {
|
||||
let records_json: Vec<serde_json::Value> =
|
||||
records.iter().map(audit_record_proto_to_json).collect();
|
||||
format_json(&json!({ "records": records_json }))
|
||||
} else if records.is_empty() {
|
||||
"No audit records recorded.".to_string()
|
||||
} else {
|
||||
let mut out = String::from(
|
||||
"SEQ ACTOR ACTION TIMESTAMP_NS\n",
|
||||
);
|
||||
for r in records {
|
||||
out.push_str(&format!(
|
||||
"{:<4} {:<20} {:<20} {}\n",
|
||||
r.sequence, r.actor, r.action, r.timestamp_ns
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
Err(status) => Self::render_error(
|
||||
&format!("nunciod daemon rejected list_records: {status}"),
|
||||
json_mode,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `system audit verify`: a real thin gRPC client of the running
|
||||
/// `nunciod` daemon's `nuncio.v1.Audit` API (backlog story 2.B,
|
||||
/// GH #172). Re-verifies the ENTIRE persisted WORM audit ledger's
|
||||
/// HMAC hash-chain integrity server-side, using the ledger's real WORM
|
||||
/// HMAC key -- never fabricates a `valid` verdict.
|
||||
async fn handle_audit_verify(&self, json_mode: bool) -> String {
|
||||
let mut client = match self.connect_audit_client().await {
|
||||
Ok(client) => client,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
|
||||
match client
|
||||
.verify_chain(nuncio_proto::v1::VerifyChainRequest {})
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let resp = response.into_inner();
|
||||
if json_mode {
|
||||
format_json(&json!({
|
||||
"valid": resp.valid,
|
||||
"record_count": resp.record_count,
|
||||
"first_broken_seq": resp.first_broken_seq,
|
||||
}))
|
||||
} else if resp.valid {
|
||||
format!("Audit chain OK: {} record(s) verified.", resp.record_count)
|
||||
} else {
|
||||
format!(
|
||||
"Audit chain TAMPERED: first broken at sequence {}.",
|
||||
resp.first_broken_seq
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(status) => Self::render_error(
|
||||
&format!("nunciod daemon rejected verify_chain: {status}"),
|
||||
json_mode,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the gRPC bearer token from the injected vault and dials the
|
||||
/// `nuncio.v1.Audit` service at `self.grpc_addr` (backlog story 2.B,
|
||||
/// GH #172), shared by [`Self::handle_audit_list`] /
|
||||
/// [`Self::handle_audit_verify`].
|
||||
async fn connect_audit_client(
|
||||
&self,
|
||||
) -> Result<nuncio_proto::client::AuthenticatedAuditClient, String> {
|
||||
let token_bytes = self
|
||||
.secrets
|
||||
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
|
||||
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
|
||||
let token = hex::encode(token_bytes);
|
||||
|
||||
nuncio_proto::client::connect_audit(&self.grpc_addr, &token)
|
||||
.await
|
||||
.map_err(|e| format!("nunciod daemon unreachable at {}: {e}", self.grpc_addr))
|
||||
}
|
||||
|
||||
/// `filter list`: a real thin gRPC client of the running `nunciod`
|
||||
/// daemon's `nuncio.v1.Filters` API (backlog story 2.A, GH #171). Lists
|
||||
/// every persisted filter rule from the daemon's real, persistent
|
||||
@@ -2447,6 +2681,310 @@ mod tests {
|
||||
assert!(out.contains("unreachable"));
|
||||
}
|
||||
|
||||
/// Reference-client proof for backlog story 2.B (GH #172): boots a stub
|
||||
/// `nuncio.v1.Export` gRPC server (mirroring
|
||||
/// `system_status_reports_live_daemon_status_over_grpc_when_reachable`'s
|
||||
/// `StubSystem` pattern above), recording the exact `ExportRequest` it
|
||||
/// received, and drives the real `HeadlessRunner`'s `mail export` gRPC
|
||||
/// client path against it. Real message/byte counts and file writing
|
||||
/// are proven end-to-end by `nunciod`'s own `grpc::tests`; this test
|
||||
/// exists purely to prove the CLI's connect + call + scope-mapping +
|
||||
/// JSON-format happy path.
|
||||
#[tokio::test]
|
||||
async fn mail_export_round_trips_over_grpc_to_a_stub_daemon() {
|
||||
use nuncio_proto::v1::export_server::{Export as ExportService, ExportServer};
|
||||
use nuncio_proto::v1::{ExportRequest, ExportResponse};
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
struct StubExport {
|
||||
last_request: Arc<Mutex<Option<ExportRequest>>>,
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl ExportService for StubExport {
|
||||
async fn export_mailbox(
|
||||
&self,
|
||||
request: tonic::Request<ExportRequest>,
|
||||
) -> Result<tonic::Response<ExportResponse>, tonic::Status> {
|
||||
let req = request.into_inner();
|
||||
let output_path = req.output_path.clone();
|
||||
*self.last_request.lock().unwrap_or_else(|e| e.into_inner()) = Some(req);
|
||||
Ok(tonic::Response::new(ExportResponse {
|
||||
output_path,
|
||||
message_count: 7,
|
||||
bytes_written: 4096,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
let last_request = Arc::new(Mutex::new(None));
|
||||
let stub = StubExport {
|
||||
last_request: last_request.clone(),
|
||||
};
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind ephemeral loopback port");
|
||||
let addr = listener.local_addr().expect("listener has local addr");
|
||||
tokio::spawn(async move {
|
||||
let _ = tonic::transport::Server::builder()
|
||||
.add_service(ExportServer::new(stub))
|
||||
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
|
||||
.await;
|
||||
});
|
||||
|
||||
let runner =
|
||||
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
|
||||
.await
|
||||
.expect("ephemeral runner initializes");
|
||||
|
||||
let out = runner
|
||||
.execute_command(
|
||||
&Commands::Mail {
|
||||
action: MailSubcommand::Export {
|
||||
format: "jsonl".to_string(),
|
||||
out: "/tmp/stub-export.jsonl".to_string(),
|
||||
account: Some("acct-stub-1".to_string()),
|
||||
folder: None,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(out.contains(r#""message_count":7"#));
|
||||
assert!(out.contains(r#""bytes_written":4096"#));
|
||||
assert!(out.contains("stub-export.jsonl"));
|
||||
|
||||
let recorded = last_request
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
.expect("stub daemon received an export_mailbox request");
|
||||
assert_eq!(recorded.output_path, "/tmp/stub-export.jsonl");
|
||||
assert_eq!(recorded.format(), nuncio_proto::v1::ExportFormat::Jsonl);
|
||||
assert_eq!(
|
||||
recorded.scope,
|
||||
Some(nuncio_proto::v1::export_request::Scope::AccountId(
|
||||
"acct-stub-1".to_string()
|
||||
))
|
||||
);
|
||||
|
||||
let text_out = runner
|
||||
.execute_command(
|
||||
&Commands::Mail {
|
||||
action: MailSubcommand::Export {
|
||||
format: "mbox".to_string(),
|
||||
out: "/tmp/stub-export-2.mbox".to_string(),
|
||||
account: None,
|
||||
folder: Some("inbox".to_string()),
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(text_out.contains("Exported 7 message(s)"));
|
||||
let recorded2 = last_request
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
.expect("stub daemon received a second export_mailbox request");
|
||||
assert_eq!(
|
||||
recorded2.scope,
|
||||
Some(nuncio_proto::v1::export_request::Scope::FolderId(
|
||||
"inbox".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mail_export_rejects_an_unknown_format_before_dialing_the_daemon() {
|
||||
// An unreachable address deliberately proves the format is
|
||||
// validated BEFORE the daemon is ever dialed -- if this connected
|
||||
// first, the error would be a transport failure instead of the
|
||||
// expected format-parsing error.
|
||||
let addr = reserve_unreachable_addr().await;
|
||||
let runner = HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr)
|
||||
.await
|
||||
.expect("ephemeral runner initializes");
|
||||
|
||||
let out = runner
|
||||
.execute_command(
|
||||
&Commands::Mail {
|
||||
action: MailSubcommand::Export {
|
||||
format: "not-a-real-format".to_string(),
|
||||
out: "/tmp/out.json".to_string(),
|
||||
account: None,
|
||||
folder: None,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(out.contains(r#""status":"error""#));
|
||||
assert!(out.contains("Unknown export format"));
|
||||
}
|
||||
|
||||
/// Reference-client proof for backlog story 2.B (GH #172): boots a stub
|
||||
/// `nuncio.v1.Audit` gRPC server and drives the real `HeadlessRunner`'s
|
||||
/// `system audit list` / `system audit verify` gRPC client paths
|
||||
/// against it. Real ledger seeding, chain verification, and the
|
||||
/// tampered-chain case are proven end-to-end by `nunciod`'s own
|
||||
/// `grpc::tests`; this test exists purely to prove the CLI's connect +
|
||||
/// call + JSON-format happy path.
|
||||
#[tokio::test]
|
||||
async fn system_audit_list_and_verify_round_trip_over_grpc_to_a_stub_daemon() {
|
||||
use nuncio_proto::v1::audit_server::{Audit as AuditService, AuditServer};
|
||||
use nuncio_proto::v1::{
|
||||
AuditRecord, ListRecordsRequest, ListRecordsResponse, VerifyChainRequest,
|
||||
VerifyChainResponse,
|
||||
};
|
||||
|
||||
struct StubAudit;
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl AuditService for StubAudit {
|
||||
async fn list_records(
|
||||
&self,
|
||||
_request: tonic::Request<ListRecordsRequest>,
|
||||
) -> Result<tonic::Response<ListRecordsResponse>, tonic::Status> {
|
||||
Ok(tonic::Response::new(ListRecordsResponse {
|
||||
records: vec![AuditRecord {
|
||||
sequence: 1,
|
||||
timestamp_ns: 1_700_000_000_000_000_000,
|
||||
actor: "system.test".to_string(),
|
||||
action: "data.export".to_string(),
|
||||
data_hash: "deadbeef".to_string(),
|
||||
previous_block_hash: "GENESIS".to_string(),
|
||||
record_hmac: "cafebabe".to_string(),
|
||||
}],
|
||||
}))
|
||||
}
|
||||
|
||||
async fn verify_chain(
|
||||
&self,
|
||||
_request: tonic::Request<VerifyChainRequest>,
|
||||
) -> Result<tonic::Response<VerifyChainResponse>, tonic::Status> {
|
||||
Ok(tonic::Response::new(VerifyChainResponse {
|
||||
valid: false,
|
||||
record_count: 3,
|
||||
first_broken_seq: Some(2),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind ephemeral loopback port");
|
||||
let addr = listener.local_addr().expect("listener has local addr");
|
||||
tokio::spawn(async move {
|
||||
let _ = tonic::transport::Server::builder()
|
||||
.add_service(AuditServer::new(StubAudit))
|
||||
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
|
||||
.await;
|
||||
});
|
||||
|
||||
let runner =
|
||||
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
|
||||
.await
|
||||
.expect("ephemeral runner initializes");
|
||||
|
||||
let list_out = runner
|
||||
.execute_command(
|
||||
&Commands::System {
|
||||
action: SystemSubcommand::Audit {
|
||||
action: AuditSubcommand::List {
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(list_out.contains(r#""action":"data.export""#));
|
||||
assert!(list_out.contains(r#""sequence":1"#));
|
||||
|
||||
let verify_out = runner
|
||||
.execute_command(
|
||||
&Commands::System {
|
||||
action: SystemSubcommand::Audit {
|
||||
action: AuditSubcommand::Verify,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(verify_out.contains(r#""valid":false"#));
|
||||
assert!(verify_out.contains(r#""first_broken_seq":2"#));
|
||||
|
||||
let verify_text = runner
|
||||
.execute_command(
|
||||
&Commands::System {
|
||||
action: SystemSubcommand::Audit {
|
||||
action: AuditSubcommand::Verify,
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(verify_text.contains("TAMPERED"));
|
||||
assert!(verify_text.contains("sequence 2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mail_export_and_system_audit_report_honest_error_when_daemon_unreachable() {
|
||||
let addr = reserve_unreachable_addr().await;
|
||||
let runner = HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr)
|
||||
.await
|
||||
.expect("ephemeral runner initializes");
|
||||
|
||||
let export_out = runner
|
||||
.execute_command(
|
||||
&Commands::Mail {
|
||||
action: MailSubcommand::Export {
|
||||
format: "json".to_string(),
|
||||
out: "/tmp/out.json".to_string(),
|
||||
account: None,
|
||||
folder: None,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(export_out.contains(r#""status":"error""#));
|
||||
assert!(export_out.contains("unreachable"));
|
||||
|
||||
let audit_list_out = runner
|
||||
.execute_command(
|
||||
&Commands::System {
|
||||
action: SystemSubcommand::Audit {
|
||||
action: AuditSubcommand::List {
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(audit_list_out.contains(r#""status":"error""#));
|
||||
assert!(audit_list_out.contains("unreachable"));
|
||||
|
||||
let audit_verify_out = runner
|
||||
.execute_command(
|
||||
&Commands::System {
|
||||
action: SystemSubcommand::Audit {
|
||||
action: AuditSubcommand::Verify,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(audit_verify_out.contains(r#""status":"error""#));
|
||||
assert!(audit_verify_out.contains("unreachable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_error_display() {
|
||||
let err = RunnerError::InitFailed("failed to open database".to_string());
|
||||
|
||||
@@ -581,3 +581,144 @@ message PreviewRuleResponse {
|
||||
// Human-readable step-by-step match traces.
|
||||
repeated string condition_traces = 7;
|
||||
}
|
||||
|
||||
// Export exposes the daemon's real mailbox export engine
|
||||
// (`nuncio_core::export`, via `DatabaseEngine::export_messages_to_file`)
|
||||
// over authenticated gRPC (backlog story 2.B, GH #172). The daemon and its
|
||||
// caller run on the SAME host (a local, single-machine deployment), so
|
||||
// writing the export to a filesystem path on that shared host -- rather
|
||||
// than streaming potentially very large export bytes back over the RPC
|
||||
// itself -- is both simpler and honest about where the data actually ends
|
||||
// up.
|
||||
//
|
||||
// SECURITY (GH #165): this service MUST be mounted behind the SAME
|
||||
// `BearerAuthInterceptor` that guards `System`/`Accounts`/`Mail`/`Filters`
|
||||
// -- every gRPC service `nunciod` exposes MUST be authenticated, with no
|
||||
// exceptions carved out for "just one more" unauthenticated service. See
|
||||
// `nunciod::grpc::serve_on_listener`.
|
||||
service Export {
|
||||
// ExportMailbox writes every message in `scope` to `output_path` in
|
||||
// `format`, using the real `nuncio_core::export::ExportEngine`. Returns
|
||||
// real message/byte counts read back from the file actually written --
|
||||
// never fabricated. Also appends a WORM audit record for the export (see
|
||||
// `Audit`).
|
||||
rpc ExportMailbox(ExportRequest) returns (ExportResponse);
|
||||
}
|
||||
|
||||
// Mirrors `nuncio_core::export::ExportFormat`.
|
||||
enum ExportFormat {
|
||||
EXPORT_FORMAT_UNSPECIFIED = 0;
|
||||
// RFC 4155 MBOX format (.mbox).
|
||||
EXPORT_FORMAT_MBOX = 1;
|
||||
// Zip archive of individual .eml MIME files (.zip).
|
||||
EXPORT_FORMAT_EML_ZIP = 2;
|
||||
// Structured JSON array (.json).
|
||||
EXPORT_FORMAT_JSON = 3;
|
||||
// JSON Lines, one message per line (.jsonl).
|
||||
EXPORT_FORMAT_JSONL = 4;
|
||||
}
|
||||
|
||||
// ExportRequest. `scope` selects which messages to export -- absent (no
|
||||
// oneof case set) means every message across every account and folder;
|
||||
// setting `account_id` or `folder_id` narrows the export to just that
|
||||
// account's or that folder's messages. These are the only scopes the store
|
||||
// can currently filter export by -- there is deliberately no broader query
|
||||
// scope here (e.g. an arbitrary NSQL filter) since the underlying store has
|
||||
// no such filtered read path to back one honestly.
|
||||
message ExportRequest {
|
||||
oneof scope {
|
||||
// Restrict the export to a single account's messages.
|
||||
string account_id = 1;
|
||||
// Restrict the export to a single folder's messages.
|
||||
string folder_id = 2;
|
||||
}
|
||||
// Export output format.
|
||||
ExportFormat format = 3;
|
||||
// Absolute filesystem path to write the export to, on the SAME host the
|
||||
// `nunciod` daemon runs on (see `Export`'s doc comment).
|
||||
string output_path = 4;
|
||||
}
|
||||
|
||||
// ExportResponse reports the REAL result of writing `output_path` -- never
|
||||
// a fabricated count.
|
||||
message ExportResponse {
|
||||
// The path written to (echoes `ExportRequest.output_path`).
|
||||
string output_path = 1;
|
||||
// Number of messages actually written.
|
||||
uint64 message_count = 2;
|
||||
// Total bytes actually written to `output_path`.
|
||||
uint64 bytes_written = 3;
|
||||
}
|
||||
|
||||
// Audit exposes the daemon's real WORM (Write Once, Read Many) HMAC-chained
|
||||
// tamper-evident audit ledger (`nuncio_core::worm_audit`, via
|
||||
// `DatabaseEngine`'s `worm_audit_records` table) over authenticated gRPC
|
||||
// (backlog story 2.B, GH #172). This is a read/verify-only surface --
|
||||
// there is deliberately no RPC to create or mutate records here, since the
|
||||
// ledger is written internally by the daemon as a side effect of other
|
||||
// operations (e.g. `Export.ExportMailbox`) and the underlying SQLite table
|
||||
// itself rejects `UPDATE`/`DELETE` at the schema level.
|
||||
//
|
||||
// SECURITY (GH #165): this service MUST be mounted behind the SAME
|
||||
// `BearerAuthInterceptor` that guards every other service `nunciod`
|
||||
// exposes -- see `nunciod::grpc::serve_on_listener`.
|
||||
service Audit {
|
||||
// ListRecords returns a page of the real, persisted WORM audit ledger,
|
||||
// sequence ascending.
|
||||
rpc ListRecords(ListRecordsRequest) returns (ListRecordsResponse);
|
||||
// VerifyChain re-verifies the ENTIRE persisted WORM audit ledger's
|
||||
// HMAC hash-chain integrity, using the ledger's real WORM HMAC key
|
||||
// (provisioned once from the OS keyring vault at daemon startup). Never
|
||||
// fabricates `valid: true`.
|
||||
rpc VerifyChain(VerifyChainRequest) returns (VerifyChainResponse);
|
||||
}
|
||||
|
||||
// AuditRecord mirrors `nuncio_core::WormAuditRecord` field-for-field.
|
||||
// `record_hmac` is this record's verification MAC (a derived signature
|
||||
// output), NOT secret key material -- the WORM HMAC signing key itself is
|
||||
// never exposed over this or any other RPC.
|
||||
message AuditRecord {
|
||||
// Monotonically increasing sequence index.
|
||||
uint64 sequence = 1;
|
||||
// UTC timestamp in nanoseconds since Unix epoch.
|
||||
int64 timestamp_ns = 2;
|
||||
// Subsystem or actor that performed the audited action.
|
||||
string actor = 3;
|
||||
// Action identifier (e.g. "data.export").
|
||||
string action = 4;
|
||||
// SHA-256 hash of the audited payload.
|
||||
string data_hash = 5;
|
||||
// Previous record's HMAC signature ("GENESIS" for sequence 1).
|
||||
string previous_block_hash = 6;
|
||||
// HMAC-SHA256 signature sealing this record.
|
||||
string record_hmac = 7;
|
||||
}
|
||||
|
||||
// ListRecordsRequest. `limit` caps the number of records returned
|
||||
// (sequence ascending); a value of 0 means "use the server's default
|
||||
// limit".
|
||||
message ListRecordsRequest {
|
||||
uint32 limit = 1;
|
||||
uint32 offset = 2;
|
||||
}
|
||||
|
||||
// ListRecordsResponse.
|
||||
message ListRecordsResponse {
|
||||
repeated AuditRecord records = 1;
|
||||
}
|
||||
|
||||
// VerifyChainRequest currently carries no parameters.
|
||||
message VerifyChainRequest {}
|
||||
|
||||
// VerifyChainResponse reports the REAL result of re-verifying the entire
|
||||
// persisted WORM audit ledger's hash chain -- never a fabricated `valid`.
|
||||
message VerifyChainResponse {
|
||||
// Whether every persisted record's HMAC AND inter-record chain linkage
|
||||
// verified.
|
||||
bool valid = 1;
|
||||
// Total number of records checked.
|
||||
uint64 record_count = 2;
|
||||
// Present only when `valid` is false: the sequence number of the first
|
||||
// record whose HMAC or chain linkage failed to verify.
|
||||
optional uint64 first_broken_seq = 3;
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
//! server-side interceptor in `nunciod::grpc`.
|
||||
|
||||
use crate::v1::accounts_client::AccountsClient;
|
||||
use crate::v1::audit_client::AuditClient;
|
||||
use crate::v1::export_client::ExportClient;
|
||||
use crate::v1::filters_client::FiltersClient;
|
||||
use crate::v1::mail_client::MailClient;
|
||||
use crate::v1::system_client::SystemClient;
|
||||
@@ -100,6 +102,16 @@ pub type AuthenticatedMailClient = MailClient<InterceptedService<Channel, Bearer
|
||||
pub type AuthenticatedFiltersClient =
|
||||
FiltersClient<InterceptedService<Channel, BearerTokenInterceptor>>;
|
||||
|
||||
/// The authenticated `nuncio.v1.Export` client type returned by
|
||||
/// [`connect_export`].
|
||||
pub type AuthenticatedExportClient =
|
||||
ExportClient<InterceptedService<Channel, BearerTokenInterceptor>>;
|
||||
|
||||
/// The authenticated `nuncio.v1.Audit` client type returned by
|
||||
/// [`connect_audit`].
|
||||
pub type AuthenticatedAuditClient =
|
||||
AuditClient<InterceptedService<Channel, BearerTokenInterceptor>>;
|
||||
|
||||
/// Dials `addr` (a `host:port` pair, e.g. `127.0.0.1:9420`) over plain HTTP
|
||||
/// (the loopback gRPC transport is never TLS-wrapped; auth is via bearer
|
||||
/// token instead) and returns the connected [`Channel`], shared by every
|
||||
@@ -189,6 +201,43 @@ pub async fn connect_filters(
|
||||
Ok(FiltersClient::with_interceptor(channel, interceptor))
|
||||
}
|
||||
|
||||
/// Dials the `nuncio.v1.Export` gRPC endpoint at `addr` and returns a
|
||||
/// client that injects `authorization: Bearer <token>` metadata on every
|
||||
/// call (backlog story 2.B, GH #172).
|
||||
///
|
||||
/// `Export` is guarded by the exact same `BearerAuthInterceptor` as every
|
||||
/// other service on the server side (see `nunciod::grpc::serve_on_listener`,
|
||||
/// GH #165), so this shares [`BearerTokenInterceptor`] and [`dial`] with
|
||||
/// [`connect_system`] / [`connect_accounts`] / [`connect_mail`] /
|
||||
/// [`connect_filters`] rather than hand-rolling yet another auth handshake.
|
||||
pub async fn connect_export(
|
||||
addr: &str,
|
||||
token: &str,
|
||||
) -> Result<AuthenticatedExportClient, ConnectError> {
|
||||
let interceptor = BearerTokenInterceptor::new(token)?;
|
||||
let channel = dial(addr).await?;
|
||||
Ok(ExportClient::with_interceptor(channel, interceptor))
|
||||
}
|
||||
|
||||
/// Dials the `nuncio.v1.Audit` gRPC endpoint at `addr` and returns a
|
||||
/// client that injects `authorization: Bearer <token>` metadata on every
|
||||
/// call (backlog story 2.B, GH #172).
|
||||
///
|
||||
/// `Audit` is guarded by the exact same `BearerAuthInterceptor` as every
|
||||
/// other service on the server side (see `nunciod::grpc::serve_on_listener`,
|
||||
/// GH #165), so this shares [`BearerTokenInterceptor`] and [`dial`] with
|
||||
/// [`connect_system`] / [`connect_accounts`] / [`connect_mail`] /
|
||||
/// [`connect_filters`] / [`connect_export`] rather than hand-rolling yet
|
||||
/// another auth handshake.
|
||||
pub async fn connect_audit(
|
||||
addr: &str,
|
||||
token: &str,
|
||||
) -> Result<AuthenticatedAuditClient, ConnectError> {
|
||||
let interceptor = BearerTokenInterceptor::new(token)?;
|
||||
let channel = dial(addr).await?;
|
||||
Ok(AuditClient::with_interceptor(channel, interceptor))
|
||||
}
|
||||
|
||||
/// Opens the `nuncio.v1.System/Subscribe` server-streaming RPC on an already
|
||||
/// authenticated client (as returned by [`connect_system`]), reusing the
|
||||
/// bearer-token interceptor already attached to `client` rather than
|
||||
@@ -314,6 +363,40 @@ mod tests {
|
||||
assert!(err.to_string().contains("127.0.0.1:1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_export_fails_closed_on_invalid_token() {
|
||||
let err = connect_export("127.0.0.1:0", "tok\ntoken")
|
||||
.await
|
||||
.expect_err("invalid token must fail before dialing");
|
||||
assert!(matches!(err, ConnectError::InvalidToken(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_export_reports_transport_error_when_daemon_unreachable() {
|
||||
let err = connect_export("127.0.0.1:1", "abc123")
|
||||
.await
|
||||
.expect_err("connecting to an unreachable daemon must fail");
|
||||
assert!(matches!(err, ConnectError::Transport { .. }));
|
||||
assert!(err.to_string().contains("127.0.0.1:1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_audit_fails_closed_on_invalid_token() {
|
||||
let err = connect_audit("127.0.0.1:0", "tok\ntoken")
|
||||
.await
|
||||
.expect_err("invalid token must fail before dialing");
|
||||
assert!(matches!(err, ConnectError::InvalidToken(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_audit_reports_transport_error_when_daemon_unreachable() {
|
||||
let err = connect_audit("127.0.0.1:1", "abc123")
|
||||
.await
|
||||
.expect_err("connecting to an unreachable daemon must fail");
|
||||
assert!(matches!(err, ConnectError::Transport { .. }));
|
||||
assert!(err.to_string().contains("127.0.0.1:1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_system_reports_invalid_endpoint_for_a_malformed_address() {
|
||||
// An embedded space is not a legal URI authority character, so
|
||||
|
||||
@@ -115,6 +115,20 @@ fn resolve_engine_keys(
|
||||
Ok((storage_key, worm_key, ledger_key))
|
||||
}
|
||||
|
||||
/// Structured result of re-verifying the entire persisted WORM audit
|
||||
/// ledger's hash chain (backlog story 2.B, GH #172). See
|
||||
/// [`DatabaseEngine::verify_worm_audit_chain_report`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WormChainReport {
|
||||
/// Whether every persisted record's HMAC and inter-record chain linkage
|
||||
/// verified.
|
||||
pub valid: bool,
|
||||
/// Total number of records checked.
|
||||
pub record_count: usize,
|
||||
/// Sequence number of the first record that failed to verify, if any.
|
||||
pub first_broken_seq: Option<u64>,
|
||||
}
|
||||
|
||||
impl DatabaseEngine {
|
||||
/// Maximum concurrent read/write pool size.
|
||||
pub const MAX_CONNECTIONS: u32 = 16;
|
||||
@@ -1276,6 +1290,81 @@ impl DatabaseEngine {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Query messages across the WHOLE store for export purposes (backlog
|
||||
/// story 2.B, GH #172), optionally narrowed to a single account or a
|
||||
/// single folder. Passing `None` for both returns every message in the
|
||||
/// store. Ordered by `id` ascending, mirroring [`Self::get_message_chunk`]'s
|
||||
/// deterministic keyset ordering.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub async fn list_messages_for_export(
|
||||
&self,
|
||||
account_id: Option<&str>,
|
||||
folder_id: Option<&str>,
|
||||
) -> Result<Vec<nuncio_core::model::Email>, DatabaseError> {
|
||||
let mut builder: sqlx::QueryBuilder<'_, sqlx::Sqlite> = sqlx::QueryBuilder::new(
|
||||
"SELECT id, account_id, folder_id, subject, sender, recipient, received_at, read_flag, body_plain, body_html FROM messages"
|
||||
);
|
||||
|
||||
let mut has_filter = false;
|
||||
if let Some(account_id) = account_id {
|
||||
builder.push(" WHERE account_id = ");
|
||||
builder.push_bind(account_id);
|
||||
has_filter = true;
|
||||
}
|
||||
if let Some(folder_id) = folder_id {
|
||||
builder.push(if has_filter {
|
||||
" AND folder_id = "
|
||||
} else {
|
||||
" WHERE folder_id = "
|
||||
});
|
||||
builder.push_bind(folder_id);
|
||||
}
|
||||
builder.push(" ORDER BY id ASC");
|
||||
|
||||
let query = builder.build_query_as::<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)>();
|
||||
|
||||
let rows = query
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(DatabaseError::Query)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let dec_plain = r.8.map(|p| {
|
||||
crate::cipher::PayloadCipher::decrypt_text_at_rest(&self.storage_key, &p)
|
||||
});
|
||||
let dec_html = r.9.map(|h| {
|
||||
crate::cipher::PayloadCipher::decrypt_text_at_rest(&self.storage_key, &h)
|
||||
});
|
||||
nuncio_core::model::Email {
|
||||
id: r.0,
|
||||
account_id: r.1,
|
||||
folder_id: r.2,
|
||||
subject: r.3,
|
||||
sender: r.4,
|
||||
recipient: r.5,
|
||||
received_at: r.6,
|
||||
read: r.7 != 0,
|
||||
body_plain: dec_plain,
|
||||
body_html: dec_html,
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Append a new immutable WORM audit record to the log ledger, signed with the WORM
|
||||
/// HMAC key provisioned for this engine from the secret vault.
|
||||
pub async fn append_worm_audit_record(
|
||||
@@ -1365,6 +1454,37 @@ impl DatabaseEngine {
|
||||
.map_err(|e| DatabaseError::ChainIntegrityFailed(e.to_string()))
|
||||
}
|
||||
|
||||
/// Re-verify the entire WORM audit ledger and report a structured
|
||||
/// result (backlog story 2.B, GH #172), rather than the opaque
|
||||
/// pass/fail `Result` [`Self::verify_worm_audit_chain`] returns -- so
|
||||
/// callers (e.g. the `Audit/VerifyChain` gRPC RPC) can honestly report
|
||||
/// exactly how many records were checked and, if the chain is broken,
|
||||
/// exactly which sequence broke it. `first_broken_seq` is populated ONLY
|
||||
/// when the underlying failure is a genuine
|
||||
/// `WormAuditError::TamperingDetected` (a specific record's HMAC or
|
||||
/// chain linkage did not verify) -- never guessed. Any other failure
|
||||
/// (e.g. a signing error) fails closed as `Err`, never reported as a
|
||||
/// fabricated `valid: false`.
|
||||
pub async fn verify_worm_audit_chain_report(&self) -> Result<WormChainReport, DatabaseError> {
|
||||
let records = self.list_worm_audit_records(100_000, 0).await?;
|
||||
let record_count = records.len();
|
||||
match nuncio_core::verify_worm_chain(&records, &self.worm_key) {
|
||||
Ok(()) => Ok(WormChainReport {
|
||||
valid: true,
|
||||
record_count,
|
||||
first_broken_seq: None,
|
||||
}),
|
||||
Err(nuncio_core::WormAuditError::TamperingDetected { sequence, .. }) => {
|
||||
Ok(WormChainReport {
|
||||
valid: false,
|
||||
record_count,
|
||||
first_broken_seq: Some(sequence),
|
||||
})
|
||||
}
|
||||
Err(e) => Err(DatabaseError::ChainIntegrityFailed(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Export a collection of email messages to a target output path in the requested portable format.
|
||||
pub async fn export_messages_to_file(
|
||||
&self,
|
||||
@@ -2043,4 +2163,158 @@ mod tests {
|
||||
"an independently-vaulted engine must not validate another vault's ledger chain"
|
||||
);
|
||||
}
|
||||
|
||||
fn export_test_email(id: &str, account_id: &str, folder_id: &str) -> nuncio_core::model::Email {
|
||||
nuncio_core::model::Email {
|
||||
id: id.to_string(),
|
||||
account_id: account_id.to_string(),
|
||||
folder_id: folder_id.to_string(),
|
||||
subject: format!("Subject {id}"),
|
||||
sender: "alice@nuncio.mx".to_string(),
|
||||
recipient: "bob@nuncio.mx".to_string(),
|
||||
received_at: 1_700_000_000,
|
||||
read: false,
|
||||
body_plain: Some(format!("Body {id}")),
|
||||
body_html: None,
|
||||
attachments: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_messages_for_export_with_no_filters_returns_every_message() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
engine
|
||||
.save_email(&export_test_email("msg-1", "acct-a", "inbox"))
|
||||
.await
|
||||
.unwrap();
|
||||
engine
|
||||
.save_email(&export_test_email("msg-2", "acct-b", "archive"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let all = engine.list_messages_for_export(None, None).await.unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
assert_eq!(all[0].id, "msg-1");
|
||||
assert_eq!(all[1].id, "msg-2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_messages_for_export_filters_by_account_id() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
engine
|
||||
.save_email(&export_test_email("msg-1", "acct-a", "inbox"))
|
||||
.await
|
||||
.unwrap();
|
||||
engine
|
||||
.save_email(&export_test_email("msg-2", "acct-b", "inbox"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let scoped = engine
|
||||
.list_messages_for_export(Some("acct-a"), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(scoped.len(), 1);
|
||||
assert_eq!(scoped[0].id, "msg-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_messages_for_export_filters_by_folder_id() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
engine
|
||||
.save_email(&export_test_email("msg-1", "acct-a", "inbox"))
|
||||
.await
|
||||
.unwrap();
|
||||
engine
|
||||
.save_email(&export_test_email("msg-2", "acct-a", "archive"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let scoped = engine
|
||||
.list_messages_for_export(None, Some("archive"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(scoped.len(), 1);
|
||||
assert_eq!(scoped[0].id, "msg-2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_messages_for_export_filters_by_account_and_folder_together() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
engine
|
||||
.save_email(&export_test_email("msg-1", "acct-a", "inbox"))
|
||||
.await
|
||||
.unwrap();
|
||||
engine
|
||||
.save_email(&export_test_email("msg-2", "acct-a", "archive"))
|
||||
.await
|
||||
.unwrap();
|
||||
engine
|
||||
.save_email(&export_test_email("msg-3", "acct-b", "inbox"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let scoped = engine
|
||||
.list_messages_for_export(Some("acct-a"), Some("inbox"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(scoped.len(), 1);
|
||||
assert_eq!(scoped[0].id, "msg-1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_worm_audit_chain_report_reports_valid_on_an_intact_chain() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
engine
|
||||
.append_worm_audit_record("system.test", "action.one", b"payload-1")
|
||||
.await
|
||||
.unwrap();
|
||||
engine
|
||||
.append_worm_audit_record("system.test", "action.two", b"payload-2")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let report = engine.verify_worm_audit_chain_report().await.unwrap();
|
||||
assert!(report.valid);
|
||||
assert_eq!(report.record_count, 2);
|
||||
assert_eq!(report.first_broken_seq, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_worm_audit_chain_report_reports_empty_chain_as_valid() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
let report = engine.verify_worm_audit_chain_report().await.unwrap();
|
||||
assert!(report.valid);
|
||||
assert_eq!(report.record_count, 0);
|
||||
assert_eq!(report.first_broken_seq, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_worm_audit_chain_report_reports_first_broken_seq_on_a_vault_key_mismatch() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let db_path = dir.path().join("worm_report_mismatch_test.sqlite");
|
||||
|
||||
let secrets_a = crate::vault::SecretManager::mock();
|
||||
let engine_a = DatabaseEngine::connect_file(&db_path, &secrets_a)
|
||||
.await
|
||||
.expect("engine_a connects");
|
||||
engine_a
|
||||
.append_worm_audit_record("system.test", "action.one", b"payload-1")
|
||||
.await
|
||||
.expect("record signed with engine_a's vault key");
|
||||
engine_a.close().await;
|
||||
|
||||
let secrets_b = crate::vault::SecretManager::mock();
|
||||
let engine_b = DatabaseEngine::connect_file(&db_path, &secrets_b)
|
||||
.await
|
||||
.expect("engine_b connects to the same file with an independent vault");
|
||||
|
||||
let report = engine_b
|
||||
.verify_worm_audit_chain_report()
|
||||
.await
|
||||
.expect("verification query succeeds (an invalid chain is a normal result)");
|
||||
assert!(!report.valid);
|
||||
assert_eq!(report.record_count, 1);
|
||||
assert_eq!(report.first_broken_seq, Some(1));
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ pub mod search;
|
||||
pub mod vault;
|
||||
|
||||
pub use cipher::{CipherError, PayloadCipher};
|
||||
pub use db::{DatabaseEngine, DatabaseError};
|
||||
pub use db::{DatabaseEngine, DatabaseError, WormChainReport};
|
||||
pub use recovery::{
|
||||
is_sqlite_corruption_error, CorruptedBackupManager, RecoverySummary, SqliteRecoveryEngine,
|
||||
};
|
||||
|
||||
+564
-19
@@ -11,23 +11,27 @@ use nuncio_core::{CoreCommand, CoreEvent, EventBus};
|
||||
use nuncio_filter::{FilterEngine, NsqlParser, NsqlValidator, ValidationOptions};
|
||||
use nuncio_mail::{MailBackend, MessageSender};
|
||||
use nuncio_proto::v1::accounts_server::{Accounts, AccountsServer};
|
||||
use nuncio_proto::v1::audit_server::{Audit, AuditServer};
|
||||
use nuncio_proto::v1::event::Kind;
|
||||
use nuncio_proto::v1::export_server::{Export, ExportServer};
|
||||
use nuncio_proto::v1::filters_server::{Filters, FiltersServer};
|
||||
use nuncio_proto::v1::mail_server::{Mail, MailServer};
|
||||
use nuncio_proto::v1::system_server::{System, SystemServer};
|
||||
use nuncio_proto::v1::{
|
||||
AccountConfig as AccountConfigProto, AccountProtocol as AccountProtocolProto,
|
||||
AddAccountRequest, AddAccountResponse, Attachment as AttachmentProto, BatchFilterProgress,
|
||||
CreateRuleRequest, CreateRuleResponse, DatabaseRecovered, DeleteRuleRequest,
|
||||
DeleteRuleResponse, Event, EventError, FilterExecuted, FilterRule as FilterRuleProto,
|
||||
Folder as FolderProto, GetMessageRequest, GetMessageResponse, GetStatusRequest,
|
||||
GetStatusResponse, ListAccountsRequest, ListAccountsResponse, ListFoldersRequest,
|
||||
ListFoldersResponse, ListMessagesRequest, ListMessagesResponse, ListRulesRequest,
|
||||
ListRulesResponse, MarkReadRequest, MarkReadResponse, Message as MessageProto,
|
||||
MessageFlagsChanged, MessageSearchHit, PreviewRuleRequest, PreviewRuleResponse,
|
||||
SearchMessagesRequest, SearchMessagesResponse, SendMessageRequest, SendMessageResponse,
|
||||
ShuttingDown, SubscribeRequest, SyncCompleted, SyncRequest, SyncResponse, SyncStarted,
|
||||
TlsMode as TlsModeProto, UpdateAvailable, ValidateRuleRequest, ValidateRuleResponse,
|
||||
export_request, AccountConfig as AccountConfigProto, AccountProtocol as AccountProtocolProto,
|
||||
AddAccountRequest, AddAccountResponse, Attachment as AttachmentProto,
|
||||
AuditRecord as AuditRecordProto, BatchFilterProgress, CreateRuleRequest, CreateRuleResponse,
|
||||
DatabaseRecovered, DeleteRuleRequest, DeleteRuleResponse, Event, EventError,
|
||||
ExportFormat as ExportFormatProto, ExportRequest, ExportResponse, FilterExecuted,
|
||||
FilterRule as FilterRuleProto, Folder as FolderProto, GetMessageRequest, GetMessageResponse,
|
||||
GetStatusRequest, GetStatusResponse, ListAccountsRequest, ListAccountsResponse,
|
||||
ListFoldersRequest, ListFoldersResponse, ListMessagesRequest, ListMessagesResponse,
|
||||
ListRecordsRequest, ListRecordsResponse, ListRulesRequest, ListRulesResponse, MarkReadRequest,
|
||||
MarkReadResponse, Message as MessageProto, MessageFlagsChanged, MessageSearchHit,
|
||||
PreviewRuleRequest, PreviewRuleResponse, SearchMessagesRequest, SearchMessagesResponse,
|
||||
SendMessageRequest, SendMessageResponse, ShuttingDown, SubscribeRequest, SyncCompleted,
|
||||
SyncRequest, SyncResponse, SyncStarted, TlsMode as TlsModeProto, UpdateAvailable,
|
||||
ValidateRuleRequest, ValidateRuleResponse, VerifyChainRequest, VerifyChainResponse,
|
||||
};
|
||||
use nuncio_store::db::DatabaseEngine;
|
||||
use nuncio_store::search::SearchEngine;
|
||||
@@ -906,6 +910,157 @@ impl Filters for FiltersGrpcService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a wire-format `nuncio.v1.ExportFormat` enum value back onto
|
||||
/// `nuncio_core::ExportFormat`. `Unspecified` is rejected rather than
|
||||
/// silently defaulting to a format the caller never asked for.
|
||||
fn map_export_format_from_proto(
|
||||
format: ExportFormatProto,
|
||||
) -> Result<nuncio_core::ExportFormat, Status> {
|
||||
match format {
|
||||
ExportFormatProto::Mbox => Ok(nuncio_core::ExportFormat::Mbox),
|
||||
ExportFormatProto::EmlZip => Ok(nuncio_core::ExportFormat::EmlZip),
|
||||
ExportFormatProto::Json => Ok(nuncio_core::ExportFormat::Json),
|
||||
ExportFormatProto::Jsonl => Ok(nuncio_core::ExportFormat::JsonLines),
|
||||
ExportFormatProto::Unspecified => {
|
||||
Err(Status::invalid_argument("export format is required"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `nuncio.v1.Export` gRPC service implementation backed by the daemon's
|
||||
/// live [`DatabaseEngine`] (backlog story 2.B, GH #172): loads messages via
|
||||
/// [`DatabaseEngine::list_messages_for_export`] (scoped by `account_id`,
|
||||
/// `folder_id`, or unscoped for "every message"), then writes them to
|
||||
/// `output_path` on the local host via
|
||||
/// [`DatabaseEngine::export_messages_to_file`] -- the SAME real
|
||||
/// `nuncio_core::export::ExportEngine` `nuncio-cli`'s previous local-only
|
||||
/// export path used, now reachable over the authenticated gRPC API against
|
||||
/// the daemon's real, persistent store. `export_messages_to_file` also
|
||||
/// appends a WORM audit record for the export as a side effect, so every
|
||||
/// export is itself auditable via `Audit`.
|
||||
struct ExportGrpcService {
|
||||
db: Arc<DatabaseEngine>,
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl Export for ExportGrpcService {
|
||||
async fn export_mailbox(
|
||||
&self,
|
||||
request: Request<ExportRequest>,
|
||||
) -> Result<Response<ExportResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
if req.output_path.trim().is_empty() {
|
||||
return Err(Status::invalid_argument("output_path is required"));
|
||||
}
|
||||
let format = map_export_format_from_proto(req.format())?;
|
||||
|
||||
let messages = match req.scope {
|
||||
Some(export_request::Scope::AccountId(account_id)) => {
|
||||
self.db
|
||||
.list_messages_for_export(Some(&account_id), None)
|
||||
.await
|
||||
}
|
||||
Some(export_request::Scope::FolderId(folder_id)) => {
|
||||
self.db
|
||||
.list_messages_for_export(None, Some(&folder_id))
|
||||
.await
|
||||
}
|
||||
None => self.db.list_messages_for_export(None, None).await,
|
||||
}
|
||||
.map_err(|e| Status::internal(format!("failed to load messages for export: {e}")))?;
|
||||
|
||||
let output_path = std::path::PathBuf::from(&req.output_path);
|
||||
let summary = self
|
||||
.db
|
||||
.export_messages_to_file(&messages, format, &output_path)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("export failed: {e}")))?;
|
||||
|
||||
Ok(Response::new(ExportResponse {
|
||||
output_path: summary.output_path,
|
||||
message_count: summary.message_count as u64,
|
||||
bytes_written: summary.bytes_written,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Default cap on records returned by `Audit/ListRecords` when the caller
|
||||
/// supplies `limit: 0` ("use the server default").
|
||||
const DEFAULT_LIST_RECORDS_LIMIT: u32 = 100;
|
||||
|
||||
/// Maps a `nuncio_core::WormAuditRecord` onto its wire-format
|
||||
/// `nuncio.v1.AuditRecord` representation. `record_hmac` is a verification
|
||||
/// MAC output, never the WORM HMAC signing key itself -- see the message's
|
||||
/// doc comment in `proto/nuncio/v1/nuncio.proto`.
|
||||
fn map_audit_record_to_proto(record: nuncio_core::WormAuditRecord) -> AuditRecordProto {
|
||||
AuditRecordProto {
|
||||
sequence: record.sequence,
|
||||
timestamp_ns: record.timestamp_ns,
|
||||
actor: record.actor,
|
||||
action: record.action,
|
||||
data_hash: record.data_hash,
|
||||
previous_block_hash: record.previous_block_hash,
|
||||
record_hmac: record.record_hmac,
|
||||
}
|
||||
}
|
||||
|
||||
/// `nuncio.v1.Audit` gRPC service implementation backed by the daemon's
|
||||
/// live [`DatabaseEngine`] WORM audit ledger (backlog story 2.B, GH #172).
|
||||
/// A read/verify-only surface -- there is no RPC here to create or mutate
|
||||
/// records, since the ledger is written internally by the daemon as a side
|
||||
/// effect of other operations (see `ExportGrpcService`).
|
||||
struct AuditGrpcService {
|
||||
db: Arc<DatabaseEngine>,
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl Audit for AuditGrpcService {
|
||||
async fn list_records(
|
||||
&self,
|
||||
request: Request<ListRecordsRequest>,
|
||||
) -> Result<Response<ListRecordsResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
let limit = if req.limit == 0 {
|
||||
DEFAULT_LIST_RECORDS_LIMIT
|
||||
} else {
|
||||
req.limit
|
||||
};
|
||||
|
||||
let records = self
|
||||
.db
|
||||
.list_worm_audit_records(limit, req.offset)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("failed to list audit records: {e}")))?
|
||||
.into_iter()
|
||||
.map(map_audit_record_to_proto)
|
||||
.collect();
|
||||
|
||||
Ok(Response::new(ListRecordsResponse { records }))
|
||||
}
|
||||
|
||||
/// Re-verifies the ENTIRE persisted WORM audit ledger via
|
||||
/// [`DatabaseEngine::verify_worm_audit_chain_report`], using the ledger's
|
||||
/// real WORM HMAC key. Never fabricates `valid: true`; a genuine
|
||||
/// signing/crypto failure (as opposed to a specific broken record) is
|
||||
/// surfaced as `Status::internal` rather than a false `valid: false`.
|
||||
async fn verify_chain(
|
||||
&self,
|
||||
_request: Request<VerifyChainRequest>,
|
||||
) -> Result<Response<VerifyChainResponse>, Status> {
|
||||
let report = self
|
||||
.db
|
||||
.verify_worm_audit_chain_report()
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("failed to verify audit chain: {e}")))?;
|
||||
|
||||
Ok(Response::new(VerifyChainResponse {
|
||||
valid: report.valid,
|
||||
record_count: report.record_count as u64,
|
||||
first_broken_seq: report.first_broken_seq,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Bearer-token authentication interceptor for the loopback `nuncio.v1` gRPC
|
||||
/// server.
|
||||
///
|
||||
@@ -953,9 +1108,9 @@ impl tonic::service::Interceptor for BearerAuthInterceptor {
|
||||
}
|
||||
|
||||
/// Binds a loopback TCP listener at `addr` and serves the `nuncio.v1.System`,
|
||||
/// `nuncio.v1.Accounts`, `nuncio.v1.Mail`, and `nuncio.v1.Filters` gRPC
|
||||
/// services on it, all authenticated by `token`, until the transport server
|
||||
/// errors.
|
||||
/// `nuncio.v1.Accounts`, `nuncio.v1.Mail`, `nuncio.v1.Filters`,
|
||||
/// `nuncio.v1.Export`, and `nuncio.v1.Audit` gRPC services on it, all
|
||||
/// authenticated by `token`, until the transport server errors.
|
||||
///
|
||||
/// `addr` MUST be a loopback address (e.g. `127.0.0.1:PORT`); callers are
|
||||
/// responsible for passing loopback-only addresses (see
|
||||
@@ -977,8 +1132,9 @@ pub async fn serve(
|
||||
serve_on_listener(listener, event_bus, db, filter_engine, secrets, token).await
|
||||
}
|
||||
|
||||
/// Serves the `nuncio.v1.System`, `nuncio.v1.Accounts`, `nuncio.v1.Mail`, and
|
||||
/// `nuncio.v1.Filters` gRPC services on an already-bound [`TcpListener`].
|
||||
/// Serves the `nuncio.v1.System`, `nuncio.v1.Accounts`, `nuncio.v1.Mail`,
|
||||
/// `nuncio.v1.Filters`, `nuncio.v1.Export`, and `nuncio.v1.Audit` gRPC
|
||||
/// services on an already-bound [`TcpListener`].
|
||||
///
|
||||
/// # Security (GH #165)
|
||||
///
|
||||
@@ -1063,15 +1219,36 @@ pub async fn serve_on_listener_with_overrides(
|
||||
// `BearerAuthInterceptor`, exactly like `System`/`Accounts`/`Mail` above
|
||||
// -- see the hard invariant documented on this function's doc comment
|
||||
// (GH #165).
|
||||
let filters_service = FiltersGrpcService { db, filter_engine };
|
||||
let filters_interceptor = BearerAuthInterceptor::new(token);
|
||||
let filters_service = FiltersGrpcService {
|
||||
db: db.clone(),
|
||||
filter_engine,
|
||||
};
|
||||
let filters_interceptor = BearerAuthInterceptor::new(token.clone());
|
||||
let filters_svc = FiltersServer::with_interceptor(filters_service, filters_interceptor);
|
||||
|
||||
// Export (backlog story 2.B, GH #172): mounted behind its own
|
||||
// `BearerAuthInterceptor`, exactly like every other service above --
|
||||
// see the hard invariant documented on this function's doc comment
|
||||
// (GH #165).
|
||||
let export_service = ExportGrpcService { db: db.clone() };
|
||||
let export_interceptor = BearerAuthInterceptor::new(token.clone());
|
||||
let export_svc = ExportServer::with_interceptor(export_service, export_interceptor);
|
||||
|
||||
// Audit (backlog story 2.B, GH #172): mounted behind its own
|
||||
// `BearerAuthInterceptor`, exactly like every other service above --
|
||||
// see the hard invariant documented on this function's doc comment
|
||||
// (GH #165).
|
||||
let audit_service = AuditGrpcService { db };
|
||||
let audit_interceptor = BearerAuthInterceptor::new(token);
|
||||
let audit_svc = AuditServer::with_interceptor(audit_service, audit_interceptor);
|
||||
|
||||
Server::builder()
|
||||
.add_service(system_svc)
|
||||
.add_service(accounts_svc)
|
||||
.add_service(mail_svc)
|
||||
.add_service(filters_svc)
|
||||
.add_service(export_svc)
|
||||
.add_service(audit_svc)
|
||||
.serve_with_incoming(TcpListenerStream::new(listener))
|
||||
.await
|
||||
.map_err(GrpcServeError::Transport)
|
||||
@@ -2845,4 +3022,372 @@ mod tests {
|
||||
.expect_err("invalid NSQL must be rejected");
|
||||
assert_eq!(err.code(), Code::InvalidArgument);
|
||||
}
|
||||
|
||||
// ---- Export (backlog story 2.B, GH #172) ----
|
||||
|
||||
use nuncio_proto::v1::export_client::ExportClient;
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_rpcs_reject_missing_bearer_token() {
|
||||
// Confirms `Export` is mounted behind its own `BearerAuthInterceptor`
|
||||
// exactly like every other service (GH #165: no un-intercepted
|
||||
// service).
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
|
||||
|
||||
let mut client = ExportClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
let err = client
|
||||
.export_mailbox(ExportRequest {
|
||||
scope: None,
|
||||
format: ExportFormatProto::Json.into(),
|
||||
output_path: "irrelevant.json".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("missing bearer token must be rejected");
|
||||
assert_eq!(err.code(), Code::Unauthenticated);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_mailbox_rejects_empty_output_path() {
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
|
||||
|
||||
let mut client = ExportClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
let err = client
|
||||
.export_mailbox(authed_bearer_request(ExportRequest {
|
||||
scope: None,
|
||||
format: ExportFormatProto::Json.into(),
|
||||
output_path: String::new(),
|
||||
}))
|
||||
.await
|
||||
.expect_err("empty output_path must be rejected");
|
||||
assert_eq!(err.code(), Code::InvalidArgument);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_mailbox_rejects_unspecified_format() {
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
|
||||
|
||||
let mut client = ExportClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
let err = client
|
||||
.export_mailbox(authed_bearer_request(ExportRequest {
|
||||
scope: None,
|
||||
format: ExportFormatProto::Unspecified.into(),
|
||||
output_path: "irrelevant.json".to_string(),
|
||||
}))
|
||||
.await
|
||||
.expect_err("unspecified format must be rejected");
|
||||
assert_eq!(err.code(), Code::InvalidArgument);
|
||||
}
|
||||
|
||||
/// Backlog story 2.B (GH #172): seeds the daemon's real store with
|
||||
/// messages directly via `DatabaseEngine::save_email` (the same write
|
||||
/// path `Mail`'s own tests use), then proves `ExportMailbox` writes a
|
||||
/// REAL file to `output_path` with the expected message/byte counts,
|
||||
/// and that the file's actual content contains the seeded messages --
|
||||
/// never a fabricated summary.
|
||||
#[tokio::test]
|
||||
async fn export_mailbox_writes_a_real_file_with_the_expected_seeded_messages() {
|
||||
let (db, _dir) = DatabaseEngine::connect_ephemeral()
|
||||
.await
|
||||
.expect("connect ephemeral test db");
|
||||
db.save_email(&sample_email(
|
||||
"msg-export-1",
|
||||
"inbox",
|
||||
"Export Subject One",
|
||||
"Export body one",
|
||||
))
|
||||
.await
|
||||
.expect("seed message 1");
|
||||
db.save_email(&sample_email(
|
||||
"msg-export-2",
|
||||
"archive",
|
||||
"Export Subject Two",
|
||||
"Export body two",
|
||||
))
|
||||
.await
|
||||
.expect("seed message 2");
|
||||
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let secrets = Arc::new(SecretManager::mock());
|
||||
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
|
||||
let (addr, _handle) = spawn_test_server_with(
|
||||
event_bus,
|
||||
Arc::new(db),
|
||||
filter_engine,
|
||||
secrets,
|
||||
"correct-token",
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut client = ExportClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
|
||||
let out_dir = tempfile::tempdir().expect("create temp export dir");
|
||||
let out_path = out_dir.path().join("export.json");
|
||||
|
||||
let response = client
|
||||
.export_mailbox(authed_bearer_request(ExportRequest {
|
||||
scope: None,
|
||||
format: ExportFormatProto::Json.into(),
|
||||
output_path: out_path.to_string_lossy().to_string(),
|
||||
}))
|
||||
.await
|
||||
.expect("export_mailbox succeeds")
|
||||
.into_inner();
|
||||
|
||||
assert_eq!(response.message_count, 2);
|
||||
assert!(response.bytes_written > 0);
|
||||
assert_eq!(response.output_path, out_path.to_string_lossy().to_string());
|
||||
|
||||
let written = std::fs::read_to_string(&out_path).expect("export file was written");
|
||||
assert!(written.contains("Export Subject One"));
|
||||
assert!(written.contains("Export Subject Two"));
|
||||
assert_eq!(written.len() as u64, response.bytes_written);
|
||||
}
|
||||
|
||||
/// Proves `ExportRequest.scope`'s `account_id` case narrows the export
|
||||
/// to just that account's messages -- the daemon's `Export` gRPC layer
|
||||
/// wiring for `DatabaseEngine::list_messages_for_export`'s account
|
||||
/// filter, not just its own already-covered unit tests.
|
||||
#[tokio::test]
|
||||
async fn export_mailbox_scopes_by_account_id() {
|
||||
let (db, _dir) = DatabaseEngine::connect_ephemeral()
|
||||
.await
|
||||
.expect("connect ephemeral test db");
|
||||
let mut msg_a = sample_email("msg-acct-a", "inbox", "From Account A", "Body A");
|
||||
msg_a.account_id = "acct-a".to_string();
|
||||
db.save_email(&msg_a).await.expect("seed account a message");
|
||||
let mut msg_b = sample_email("msg-acct-b", "inbox", "From Account B", "Body B");
|
||||
msg_b.account_id = "acct-b".to_string();
|
||||
db.save_email(&msg_b).await.expect("seed account b message");
|
||||
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let secrets = Arc::new(SecretManager::mock());
|
||||
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
|
||||
let (addr, _handle) = spawn_test_server_with(
|
||||
event_bus,
|
||||
Arc::new(db),
|
||||
filter_engine,
|
||||
secrets,
|
||||
"correct-token",
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut client = ExportClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
|
||||
let out_dir = tempfile::tempdir().expect("create temp export dir");
|
||||
let out_path = out_dir.path().join("scoped.jsonl");
|
||||
|
||||
let response = client
|
||||
.export_mailbox(authed_bearer_request(ExportRequest {
|
||||
scope: Some(export_request::Scope::AccountId("acct-a".to_string())),
|
||||
format: ExportFormatProto::Jsonl.into(),
|
||||
output_path: out_path.to_string_lossy().to_string(),
|
||||
}))
|
||||
.await
|
||||
.expect("export_mailbox succeeds")
|
||||
.into_inner();
|
||||
|
||||
assert_eq!(response.message_count, 1);
|
||||
let written = std::fs::read_to_string(&out_path).expect("export file was written");
|
||||
assert!(written.contains("From Account A"));
|
||||
assert!(!written.contains("From Account B"));
|
||||
}
|
||||
|
||||
// ---- Audit (backlog story 2.B, GH #172) ----
|
||||
|
||||
use nuncio_proto::v1::audit_client::AuditClient;
|
||||
|
||||
#[tokio::test]
|
||||
async fn audit_rpcs_reject_missing_bearer_token() {
|
||||
// Confirms `Audit` is mounted behind its own `BearerAuthInterceptor`
|
||||
// exactly like every other service (GH #165: no un-intercepted
|
||||
// service).
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
|
||||
|
||||
let mut client = AuditClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
|
||||
let err = client
|
||||
.list_records(ListRecordsRequest {
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
})
|
||||
.await
|
||||
.expect_err("missing bearer token must be rejected");
|
||||
assert_eq!(err.code(), Code::Unauthenticated);
|
||||
|
||||
let err = client
|
||||
.verify_chain(VerifyChainRequest {})
|
||||
.await
|
||||
.expect_err("missing bearer token must be rejected");
|
||||
assert_eq!(err.code(), Code::Unauthenticated);
|
||||
}
|
||||
|
||||
/// Seeds the daemon's real WORM audit ledger directly via
|
||||
/// `DatabaseEngine::append_worm_audit_record`, then proves
|
||||
/// `Audit/ListRecords` returns the REAL seeded records, sequence
|
||||
/// ascending, with a non-empty `record_hmac` -- never a fabricated
|
||||
/// list.
|
||||
#[tokio::test]
|
||||
async fn list_records_returns_seeded_worm_audit_records_in_sequence_order() {
|
||||
let (db, _dir) = DatabaseEngine::connect_ephemeral()
|
||||
.await
|
||||
.expect("connect ephemeral test db");
|
||||
db.append_worm_audit_record("system.test", "test.action.one", b"payload-one")
|
||||
.await
|
||||
.expect("append first audit record");
|
||||
db.append_worm_audit_record("system.test", "test.action.two", b"payload-two")
|
||||
.await
|
||||
.expect("append second audit record");
|
||||
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let secrets = Arc::new(SecretManager::mock());
|
||||
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
|
||||
let (addr, _handle) = spawn_test_server_with(
|
||||
event_bus,
|
||||
Arc::new(db),
|
||||
filter_engine,
|
||||
secrets,
|
||||
"correct-token",
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut client = AuditClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
|
||||
let response = client
|
||||
.list_records(authed_bearer_request(ListRecordsRequest {
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
}))
|
||||
.await
|
||||
.expect("list_records succeeds")
|
||||
.into_inner();
|
||||
|
||||
assert_eq!(response.records.len(), 2);
|
||||
assert_eq!(response.records[0].sequence, 1);
|
||||
assert_eq!(response.records[0].action, "test.action.one");
|
||||
assert_eq!(response.records[0].previous_block_hash, "GENESIS");
|
||||
assert!(!response.records[0].record_hmac.is_empty());
|
||||
assert_eq!(response.records[1].sequence, 2);
|
||||
assert_eq!(response.records[1].action, "test.action.two");
|
||||
}
|
||||
|
||||
/// Proves `Audit/VerifyChain` reports `valid: true` and the real
|
||||
/// record count on an intact, honestly-signed chain -- never a
|
||||
/// fabricated `valid`.
|
||||
#[tokio::test]
|
||||
async fn verify_chain_reports_valid_true_on_an_intact_chain() {
|
||||
let (db, _dir) = DatabaseEngine::connect_ephemeral()
|
||||
.await
|
||||
.expect("connect ephemeral test db");
|
||||
db.append_worm_audit_record("system.test", "test.action.one", b"payload-one")
|
||||
.await
|
||||
.expect("append first audit record");
|
||||
db.append_worm_audit_record("system.test", "test.action.two", b"payload-two")
|
||||
.await
|
||||
.expect("append second audit record");
|
||||
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let secrets = Arc::new(SecretManager::mock());
|
||||
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
|
||||
let (addr, _handle) = spawn_test_server_with(
|
||||
event_bus,
|
||||
Arc::new(db),
|
||||
filter_engine,
|
||||
secrets,
|
||||
"correct-token",
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut client = AuditClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
|
||||
let response = client
|
||||
.verify_chain(authed_bearer_request(VerifyChainRequest {}))
|
||||
.await
|
||||
.expect("verify_chain succeeds")
|
||||
.into_inner();
|
||||
|
||||
assert!(response.valid);
|
||||
assert_eq!(response.record_count, 2);
|
||||
assert_eq!(response.first_broken_seq, None);
|
||||
}
|
||||
|
||||
/// Proves `Audit/VerifyChain` honestly reports `valid: false` (with the
|
||||
/// exact broken sequence) when the persisted ledger was signed with a
|
||||
/// DIFFERENT WORM HMAC key than the one this engine instance was
|
||||
/// provisioned with -- the same cross-vault mismatch scenario
|
||||
/// `nuncio_store::db`'s own `worm_audit_key_is_vault_sourced_not_a_shared_default`
|
||||
/// / `verify_worm_audit_chain_report_reports_first_broken_seq_on_a_vault_key_mismatch`
|
||||
/// tests prove at the `DatabaseEngine` layer, exercised here end-to-end
|
||||
/// over the authenticated gRPC API. The ledger's `UPDATE`/`DELETE`
|
||||
/// triggers make it impossible to tamper with an existing record
|
||||
/// in-place, so a mismatched vault key is the realistic way a
|
||||
/// persisted chain becomes "invalid" without bypassing the store's own
|
||||
/// immutability guarantees.
|
||||
#[tokio::test]
|
||||
async fn verify_chain_reports_invalid_and_first_broken_seq_on_a_vault_key_mismatch() {
|
||||
let dir = tempfile::tempdir().expect("create persistent temp dir");
|
||||
let db_path = dir.path().join("audit_mismatch_test.db");
|
||||
|
||||
let secrets_a = Arc::new(SecretManager::mock());
|
||||
let db_a = DatabaseEngine::connect_file(&db_path, &secrets_a)
|
||||
.await
|
||||
.expect("open db with vault a");
|
||||
db_a.append_worm_audit_record("system.test", "test.action.one", b"payload-one")
|
||||
.await
|
||||
.expect("append audit record signed with vault a's key");
|
||||
db_a.close().await;
|
||||
|
||||
// "Restart" against the SAME on-disk database file, but with a
|
||||
// DIFFERENT mock vault -- so the WORM HMAC key resolved for this
|
||||
// second engine instance does not match the key that actually
|
||||
// signed the persisted record.
|
||||
let secrets_b = Arc::new(SecretManager::mock());
|
||||
let db_b = DatabaseEngine::connect_file(&db_path, &secrets_b)
|
||||
.await
|
||||
.expect("open db with vault b");
|
||||
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
|
||||
let (addr, _handle) = spawn_test_server_with(
|
||||
event_bus,
|
||||
Arc::new(db_b),
|
||||
filter_engine,
|
||||
secrets_b,
|
||||
"correct-token",
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut client = AuditClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
|
||||
let response = client
|
||||
.verify_chain(authed_bearer_request(VerifyChainRequest {}))
|
||||
.await
|
||||
.expect("verify_chain succeeds (a broken chain is a normal result, not an RPC error)")
|
||||
.into_inner();
|
||||
|
||||
assert!(!response.valid);
|
||||
assert_eq!(response.record_count, 1);
|
||||
assert_eq!(response.first_broken_seq, Some(1));
|
||||
}
|
||||
}
|
||||
@@ -380,8 +380,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let grpc_token = hex::encode(grpc_token_bytes);
|
||||
let grpc_addr = nunciod::grpc::grpc_addr_from_env();
|
||||
tracing::info!(
|
||||
"nunciod gRPC (nuncio.v1.System, nuncio.v1.Accounts, nuncio.v1.Mail, nuncio.v1.Filters) \
|
||||
starting on {} (loopback only)",
|
||||
"nunciod gRPC (nuncio.v1.System, nuncio.v1.Accounts, nuncio.v1.Mail, nuncio.v1.Filters, \
|
||||
nuncio.v1.Export, nuncio.v1.Audit) starting on {} (loopback only)",
|
||||
grpc_addr
|
||||
);
|
||||
let grpc_event_bus = event_bus.clone();
|
||||
|
||||
Reference in new issue
Block a user