From 18d5572bbb6d0cf794ac93648bdc3a5a48a4ddff Mon Sep 17 00:00:00 2001 From: Oliver Slapinski Date: Sat, 15 Aug 2026 16:42:40 -0400 Subject: [PATCH 1/2] patchwork: support configurable user agent Some Patchwork instances may reject unidentified API clients. Allow each API policy to provide a User-Agent and send it on both lookup and check requests. Cover the transport behavior with a deterministic localhost test. Signed-off-by: Oliver Slapinski --- docs/configuration.md | 2 + docs/examples/email_policy.toml | 1 + src/email_policy.rs | 8 +++ src/email_router.rs | 4 ++ src/patchwork.rs | 90 +++++++++++++++++++++++++++++++++ src/worker/patchwork.rs | 33 +++++++----- 6 files changed, 124 insertions(+), 14 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index e16d0088a..6c4254cb0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -251,6 +251,7 @@ fields as `[defaults]`, plus: | `patchwork.enabled` | bool | `false` | Enable Patchwork integration for this subsystem. | | `patchwork.api_url` | string | -- | Patchwork REST API URL (e.g. `https://patchwork.kernel.org/api/1.3`). Trailing slashes are stripped automatically. Invalid schemes are rejected with a warning. | | `patchwork.token` | string | -- | Patchwork API token. Can also be set via `SASHIKO_PATCHWORK_TOKEN` env var (fills in where token is omitted in TOML). | +| `patchwork.user_agent` | string | -- | HTTP User-Agent sent with Patchwork API lookup and check requests. Set this when the instance requires an identifiable client. | | `patchwork.email` | string | -- | Email address for email-based Patchwork notifications. | | `patchwork.min_severity` | string | -- | Minimum finding severity to include in patchwork checks. Findings below this threshold are excluded. Accepts: `Low`, `Medium`, `High`, `Critical` (case-insensitive). Default: all findings included. | | `patchwork.fail_severity` | string | `High` | Minimum severity of NEW findings that triggers the `fail` check state instead of `warning`. New findings at or above this threshold produce `fail`; below it produce `warning`. Pre-existing findings never affect the check state. | @@ -295,6 +296,7 @@ permissions (state changes, delegation, etc.), not just check access. enabled = true api_url = "https://patchwork.kernel.org/api/1.3" token = "your-api-token" # or set SASHIKO_PATCHWORK_TOKEN env var +user_agent = "your-service/1.0 (contact@example.org)" ``` **Email mode** sends a structured notification email to a bot address. diff --git a/docs/examples/email_policy.toml b/docs/examples/email_policy.toml index 60ea53d98..7ffc57595 100644 --- a/docs/examples/email_policy.toml +++ b/docs/examples/email_policy.toml @@ -50,6 +50,7 @@ send_positive_review = false # enabled = true # api_url = "https://patchwork.kernel.org/api/1.3" # token = "your-api-token" +# user_agent = "your-service/1.0 (contact@example.org)" # Email mode: sends a structured notification email that a local script # (e.g. pw_tools) can parse and use to post the check. diff --git a/src/email_policy.rs b/src/email_policy.rs index 21893efa5..1fc09ca5c 100644 --- a/src/email_policy.rs +++ b/src/email_policy.rs @@ -9,6 +9,8 @@ pub struct PatchworkPolicy { pub enabled: bool, pub api_url: Option, pub token: Option, + /// HTTP User-Agent sent to the Patchwork API. + pub user_agent: Option, pub email: Option, /// Minimum finding severity to include in patchwork checks. /// Findings below this threshold are excluded from the check @@ -35,6 +37,7 @@ impl Default for PatchworkPolicy { enabled: false, api_url: None, token: None, + user_agent: None, email: None, min_severity: None, fail_severity: default_fail_severity(), @@ -208,6 +211,7 @@ mod tests { [subsystems.net.patchwork] enabled = true api_url = "https://patchwork.kernel.org/api/1.2" + user_agent = "sashiko-test/1.0 (test@example.org)" "#; let mut file = NamedTempFile::new().unwrap(); @@ -242,6 +246,10 @@ mod tests { net_policy.patchwork.api_url.as_deref(), Some("https://patchwork.kernel.org/api/1.2") ); + assert_eq!( + net_policy.patchwork.user_agent.as_deref(), + Some("sashiko-test/1.0 (test@example.org)") + ); } #[test] diff --git a/src/email_router.rs b/src/email_router.rs index b515af674..e92c55570 100644 --- a/src/email_router.rs +++ b/src/email_router.rs @@ -590,6 +590,7 @@ mod tests { enabled: true, api_url: Some("https://patchwork.kernel.org/api".to_string()), token: Some("token_mm".to_string()), + user_agent: None, email: Some("notify@kernel.org".to_string()), min_severity: Some("Medium".to_string()), fail_severity: "High".to_string(), @@ -607,6 +608,7 @@ mod tests { enabled: true, api_url: Some("https://patchwork.kernel.org/api".to_string()), token: Some("token_bpf".to_string()), + user_agent: None, email: None, min_severity: Some("High".to_string()), fail_severity: "Critical".to_string(), @@ -624,6 +626,7 @@ mod tests { enabled: true, api_url: None, token: None, + user_agent: None, email: Some("notify@kernel.org".to_string()), min_severity: None, // most inclusive fail_severity: "High".to_string(), @@ -639,6 +642,7 @@ mod tests { enabled: false, api_url: None, token: None, + user_agent: None, email: None, min_severity: None, fail_severity: "High".to_string(), diff --git a/src/patchwork.rs b/src/patchwork.rs index d2f0dd627..7235ff332 100644 --- a/src/patchwork.rs +++ b/src/patchwork.rs @@ -167,6 +167,7 @@ pub async fn post_patchwork_check( client: &Client, api_url: &str, token: Option<&str>, + user_agent: Option<&str>, msgid: &str, status: &str, description: &str, @@ -187,6 +188,9 @@ pub async fn post_patchwork_check( if let Some(token) = token { get_req = get_req.header(header::AUTHORIZATION, format!("Token {}", token)); } + if let Some(user_agent) = user_agent { + get_req = get_req.header(header::USER_AGENT, user_agent); + } let resp = get_req .send() @@ -234,6 +238,9 @@ pub async fn post_patchwork_check( if let Some(token) = token { post_req = post_req.header(header::AUTHORIZATION, format!("Token {}", token)); } + if let Some(user_agent) = user_agent { + post_req = post_req.header(header::USER_AGENT, user_agent); + } let post_resp = post_req .send() @@ -276,6 +283,8 @@ pub fn compose_patchwork_email( mod tests { use super::*; use crate::email_policy::PatchworkPolicy; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; fn finding(severity: &str, preexisting: bool) -> Value { serde_json::json!({"severity": severity, "problem": "test", "preexisting": preexisting}) @@ -293,6 +302,40 @@ mod tests { PatchworkPolicy::default() } + async fn capture_request(listener: &TcpListener, response: &str) -> String { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + + loop { + let bytes_read = stream.read(&mut buffer).await.unwrap(); + assert!(bytes_read > 0, "connection closed before request headers"); + request.extend_from_slice(&buffer[..bytes_read]); + + if let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = request_header(&headers, "content-length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + if request.len() >= header_end + 4 + content_length { + break; + } + } + } + + stream.write_all(response.as_bytes()).await.unwrap(); + String::from_utf8(request).unwrap() + } + + fn request_header<'a>(request: &'a str, name: &str) -> Option<&'a str> { + request.lines().skip(1).find_map(|line| { + let (header_name, value) = line.split_once(':')?; + header_name + .eq_ignore_ascii_case(name) + .then_some(value.trim()) + }) + } + // -- PatchworkCheckResult tests -- #[test] @@ -461,6 +504,53 @@ mod tests { assert!(result.description.contains("\u{00b7}")); // middle dot } + #[tokio::test] + async fn test_patchwork_user_agent_sent_on_lookup_and_check() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let lookup = capture_request( + &listener, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 10\r\nConnection: close\r\n\r\n[{\"id\":7}]", + ) + .await; + let check = capture_request( + &listener, + "HTTP/1.1 201 Created\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + (lookup, check) + }); + + let api_url = format!("http://{address}/api/1.3"); + post_patchwork_check( + &Client::new(), + &api_url, + Some("test-token"), + Some("sashiko-test/1.0 (test@example.org)"), + "", + "success", + "No regressions", + "https://sashiko.dev/review/7", + ) + .await + .unwrap(); + + let (lookup, check) = server.await.unwrap(); + assert!(lookup.starts_with("GET /api/1.3/patches/?msgid=patch%40example.org HTTP/1.1")); + assert!(check.starts_with("POST /api/1.3/patches/7/checks/ HTTP/1.1")); + for request in [&lookup, &check] { + assert_eq!( + request_header(request, "user-agent"), + Some("sashiko-test/1.0 (test@example.org)") + ); + assert_eq!( + request_header(request, "authorization"), + Some("Token test-token") + ); + } + } + // -- compose_patchwork_email tests -- #[test] diff --git a/src/worker/patchwork.rs b/src/worker/patchwork.rs index 0eb9d1ea0..4faceb98b 100644 --- a/src/worker/patchwork.rs +++ b/src/worker/patchwork.rs @@ -13,7 +13,7 @@ // limitations under the License. use crate::db::Database; -use crate::email_policy::EmailPolicyConfig; +use crate::email_policy::{EmailPolicyConfig, PatchworkPolicy}; use std::sync::Arc; use std::time::Duration; use tokio::time::sleep; @@ -34,19 +34,19 @@ impl PatchworkWorker { } } - /// Resolve the patchwork API token for a given api_url by loading - /// the email policy config and matching against subsystem policies. - /// Tokens are never stored in the database -- they are resolved - /// from the config file (and SASHIKO_PATCHWORK_TOKEN env var) at - /// delivery time. - fn resolve_token(&self, api_url: &str) -> Option { + /// Resolve the Patchwork API policy for a given api_url. + /// + /// Credentials and request identification are deliberately resolved + /// from the config at delivery time instead of being stored in the + /// outbox. + fn resolve_api_policy(&self, api_url: &str) -> Option { let config = EmailPolicyConfig::load(&self.email_policy_path) - .expect("Failed to load email policy for token resolution"); + .expect("Failed to load email policy for Patchwork delivery"); // Check subsystem policies for a matching api_url for sub in config.subsystems.values() { if sub.patchwork.enabled && sub.patchwork.api_url.as_deref() == Some(api_url) { - return sub.patchwork.token.clone(); + return Some(sub.patchwork.clone()); } } @@ -54,7 +54,7 @@ impl PatchworkWorker { if config.defaults.patchwork.enabled && config.defaults.patchwork.api_url.as_deref() == Some(api_url) { - return config.defaults.patchwork.token.clone(); + return Some(config.defaults.patchwork); } None @@ -84,14 +84,19 @@ impl PatchworkWorker { entry.id, entry.patch_msg_id ); - // Resolve the token from config at delivery time, - // not from the database row. - let token = self.resolve_token(&entry.api_url); + let api_policy = self.resolve_api_policy(&entry.api_url); + let token = api_policy + .as_ref() + .and_then(|policy| policy.token.as_deref()); + let user_agent = api_policy + .as_ref() + .and_then(|policy| policy.user_agent.as_deref()); match crate::patchwork::post_patchwork_check( &client, &entry.api_url, - token.as_deref(), + token, + user_agent, &entry.patch_msg_id, &entry.check_state, &entry.description, From a144cd14b0a0d23418af595753145b7ddbf93c28 Mon Sep 17 00:00:00 2001 From: Oliver Slapinski Date: Sat, 15 Aug 2026 16:48:55 -0400 Subject: [PATCH 2/2] patchwork: group API identity headers Signed-off-by: Oliver Slapinski --- src/patchwork.rs | 24 ++++++++++++++++-------- src/worker/patchwork.rs | 3 +-- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/patchwork.rs b/src/patchwork.rs index 7235ff332..8eee9e035 100644 --- a/src/patchwork.rs +++ b/src/patchwork.rs @@ -158,6 +158,13 @@ struct PatchworkCheckRequest { context: String, } +/// Headers that identify Sashiko to a Patchwork API instance. +#[derive(Clone, Copy, Debug, Default)] +pub struct PatchworkApiIdentity<'a> { + pub token: Option<&'a str>, + pub user_agent: Option<&'a str>, +} + /// Post a check result to the Patchwork REST API for a given patch. /// /// Looks up the patch by message-ID, then POSTs the check. Returns Ok @@ -166,8 +173,7 @@ struct PatchworkCheckRequest { pub async fn post_patchwork_check( client: &Client, api_url: &str, - token: Option<&str>, - user_agent: Option<&str>, + identity: PatchworkApiIdentity<'_>, msgid: &str, status: &str, description: &str, @@ -185,10 +191,10 @@ pub async fn post_patchwork_check( debug!("Fetching Patchwork patch by msgid: {}", clean_msgid); let mut get_req = client.get(patches_url); - if let Some(token) = token { + if let Some(token) = identity.token { get_req = get_req.header(header::AUTHORIZATION, format!("Token {}", token)); } - if let Some(user_agent) = user_agent { + if let Some(user_agent) = identity.user_agent { get_req = get_req.header(header::USER_AGENT, user_agent); } @@ -235,10 +241,10 @@ pub async fn post_patchwork_check( debug!("Posting check to Patchwork: {} {:?}", check_url, payload); let mut post_req = client.post(&check_url).json(&payload); - if let Some(token) = token { + if let Some(token) = identity.token { post_req = post_req.header(header::AUTHORIZATION, format!("Token {}", token)); } - if let Some(user_agent) = user_agent { + if let Some(user_agent) = identity.user_agent { post_req = post_req.header(header::USER_AGENT, user_agent); } @@ -526,8 +532,10 @@ mod tests { post_patchwork_check( &Client::new(), &api_url, - Some("test-token"), - Some("sashiko-test/1.0 (test@example.org)"), + PatchworkApiIdentity { + token: Some("test-token"), + user_agent: Some("sashiko-test/1.0 (test@example.org)"), + }, "", "success", "No regressions", diff --git a/src/worker/patchwork.rs b/src/worker/patchwork.rs index 4faceb98b..f6797e6f7 100644 --- a/src/worker/patchwork.rs +++ b/src/worker/patchwork.rs @@ -95,8 +95,7 @@ impl PatchworkWorker { match crate::patchwork::post_patchwork_check( &client, &entry.api_url, - token, - user_agent, + crate::patchwork::PatchworkApiIdentity { token, user_agent }, &entry.patch_msg_id, &entry.check_state, &entry.description,