use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub mod claim;
pub mod drain;
pub mod inbox;
pub mod listener;
pub mod retry;
pub mod serve;
#[cfg(test)]
#[path = "tests.rs"]
mod receive_tests;
#[cfg(test)]
#[path = "drain_tests.rs"]
mod drain_tests;
pub use claim::{Claim, ClaimError, ClaimOutcome, entry_is_still_linked};
pub use drain::{
DeliveryProcessor, Disposition, DrainFailure, DrainPolicy, DrainReport, FailureOutcome,
ProcessFailure, drain_once,
};
pub use inbox::{Inbox, InboxError, Ownership, held_count};
pub use listener::{
DEFAULT_DRAIN_INTERVAL, ListenerError, WebhookListener, run_until_signal,
run_until_signal_with_processor,
};
pub use retry::{
AttemptRecord, DEFAULT_MAX_ATTEMPTS, PROCESSED_DIR_NAME, PROCESSED_RETENTION,
QUARANTINE_DIR_NAME, is_processed, mark_processed, quarantine_dir, quarantined_count,
};
pub use serve::{
DeliverySink, LISTENER_SHUTDOWN_FLUSH, ServeOptions, Served, SinkRejection, dispatch_frame,
serve_until,
};
pub const RELAY_METHOD: &str = "webhook.deliver";
pub const REVIEW_SOURCE: &str = "review";
pub const ANALYZE_SOURCE: &str = "analyze";
pub const REVIEW_SOCKET_FILE: &str = "trusty-review-webhook.sock";
pub const ANALYZE_SOCKET_FILE: &str = "trusty-analyze-webhook.sock";
#[cfg(feature = "uds")]
pub fn review_socket_path() -> std::path::PathBuf {
crate::uds::scratch_socket_dir().join(REVIEW_SOCKET_FILE)
}
#[cfg(feature = "uds")]
pub fn analyze_socket_path() -> std::path::PathBuf {
crate::uds::scratch_socket_dir().join(ANALYZE_SOCKET_FILE)
}
pub const INBOX_DIR_NAME: &str = "webhook-inbox";
pub fn inbox_app_name(source: &str) -> Option<&'static str> {
match source {
REVIEW_SOURCE => Some("trusty-review"),
ANALYZE_SOURCE => Some("trusty-analyze"),
_ => None,
}
}
pub fn inbox_root_for(source: &str) -> Option<anyhow::Result<std::path::PathBuf>> {
let app = inbox_app_name(source)?;
Some(crate::resolve_data_dir(app).map(|dir| dir.join(INBOX_DIR_NAME)))
}
#[cfg(feature = "uds")]
pub fn socket_path_for(source: &str) -> Option<std::path::PathBuf> {
match source {
REVIEW_SOURCE => Some(review_socket_path()),
ANALYZE_SOURCE => Some(analyze_socket_path()),
_ => None,
}
}
pub const JSONRPC_VERSION: &str = "2.0";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Provenance {
pub algorithm: String,
pub key_id: String,
pub verified: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct RelayFrame<'a> {
pub jsonrpc: &'static str,
pub method: &'static str,
pub id: &'a str,
pub params: RelayParams<'a>,
}
#[derive(Debug, Clone, Serialize)]
pub struct RelayParams<'a> {
pub delivery_id: &'a str,
pub source: &'a str,
pub event: &'a str,
pub headers: &'a BTreeMap<String, String>,
pub body_b64: &'a str,
pub provenance: &'a Provenance,
pub received_at_unix_ms: u64,
pub attempts: u32,
}
impl<'a> RelayFrame<'a> {
#[allow(clippy::too_many_arguments)]
pub fn new(
delivery_id: &'a str,
source: &'a str,
event: &'a str,
headers: &'a BTreeMap<String, String>,
body_b64: &'a str,
provenance: &'a Provenance,
received_at_unix_ms: u64,
attempts: u32,
) -> Self {
Self {
jsonrpc: JSONRPC_VERSION,
method: RELAY_METHOD,
id: delivery_id,
params: RelayParams {
delivery_id,
source,
event,
headers,
body_b64,
provenance,
received_at_unix_ms,
attempts,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct RelayRequest {
pub jsonrpc: String,
pub method: String,
pub id: String,
pub params: RelayDelivery,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RelayDelivery {
pub delivery_id: String,
pub source: String,
pub event: String,
pub headers: BTreeMap<String, String>,
pub body_b64: String,
pub provenance: Provenance,
pub received_at_unix_ms: u64,
#[serde(default)]
pub attempts: u32,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RelayResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<RelayResult>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<RelayRpcError>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RelayResult {
#[serde(default)]
pub ack: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RelayRpcError {
#[serde(default)]
pub code: i64,
#[serde(default)]
pub message: String,
}
impl RelayResponse {
pub fn ack() -> Self {
Self {
result: Some(RelayResult {
ack: true,
detail: None,
}),
error: None,
}
}
pub fn refuse(code: i64, message: impl Into<String>) -> Self {
Self {
result: None,
error: Some(RelayRpcError {
code,
message: message.into(),
}),
}
}
pub fn is_ack(&self) -> bool {
self.error.is_none() && self.result.as_ref().is_some_and(|r| r.ack)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn provenance() -> Provenance {
Provenance {
algorithm: "hmac-sha256".to_string(),
key_id: "GITHUB_WEBHOOK_SECRET".to_string(),
verified: true,
}
}
#[test]
fn relay_frame_round_trips_through_the_owned_request() {
let headers = BTreeMap::from([("x-github-event".to_string(), "pull_request".to_string())]);
let prov = provenance();
let frame = RelayFrame::new(
"abc-123",
"review",
"pull_request",
&headers,
"eyJhIjoxfQ==",
&prov,
1_700_000_000_000,
2,
);
let bytes = serde_json::to_vec(&frame).expect("serialize frame");
let got: RelayRequest = serde_json::from_slice(&bytes).expect("deserialize frame");
assert_eq!(got.jsonrpc, JSONRPC_VERSION);
assert_eq!(got.method, RELAY_METHOD);
assert_eq!(got.id, "abc-123");
assert_eq!(got.params.delivery_id, "abc-123");
assert_eq!(got.params.source, "review");
assert_eq!(got.params.event, "pull_request");
assert_eq!(got.params.headers, headers);
assert_eq!(got.params.body_b64, "eyJhIjoxfQ==");
assert_eq!(got.params.provenance, prov);
assert_eq!(got.params.received_at_unix_ms, 1_700_000_000_000);
assert_eq!(got.params.attempts, 2);
}
#[test]
fn relay_response_without_ack_is_not_an_ack() {
for raw in [r#"{"result":{}}"#, r#"{}"#, r#"{"result":{"ack":false}}"#] {
let resp: RelayResponse = serde_json::from_str(raw).expect("parse");
assert!(!resp.is_ack(), "{raw} must not read as an acknowledgement");
}
}
#[test]
fn relay_response_ack() {
let resp = RelayResponse::ack();
assert!(resp.is_ack());
let round: RelayResponse =
serde_json::from_slice(&serde_json::to_vec(&resp).expect("ser")).expect("de");
assert!(round.is_ack());
}
#[test]
fn relay_response_refuse() {
let resp = RelayResponse::refuse(-32000, "dedup store locked");
assert!(!resp.is_ack());
assert_eq!(
resp.error.as_ref().map(|e| e.message.as_str()),
Some("dedup store locked")
);
}
#[test]
fn relay_response_with_both_halves_is_not_an_ack() {
let resp: RelayResponse =
serde_json::from_str(r#"{"result":{"ack":true},"error":{"code":-1,"message":"x"}}"#)
.expect("parse");
assert!(!resp.is_ack());
}
}