1use std::collections::BTreeMap;
46
47use serde::{Deserialize, Serialize};
48
49pub const PROTOCOL_VERSION: &str = "1.0";
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct PluginRpcRequest {
63 pub jsonrpc: JsonRpcVersion,
64 pub id: u64,
65 #[serde(flatten)]
66 pub call: PluginRequest,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct PluginRpcResponse {
73 pub jsonrpc: JsonRpcVersion,
74 pub id: u64,
75 #[serde(flatten)]
76 pub outcome: RpcOutcome,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "lowercase")]
84pub enum RpcOutcome {
85 Result(PluginResponse),
86 Error(PluginError),
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct JsonRpcVersion(String);
93
94impl JsonRpcVersion {
95 pub fn current() -> Self {
96 Self("2.0".into())
97 }
98 pub fn as_str(&self) -> &str {
99 &self.0
100 }
101 pub fn is_supported(&self) -> bool {
102 self.0 == "2.0"
103 }
104}
105
106impl Default for JsonRpcVersion {
107 fn default() -> Self {
108 Self::current()
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(tag = "method", content = "params", rename_all = "snake_case")]
121pub enum PluginRequest {
122 #[serde(rename = "secret_source.init")]
127 Init(InitParams),
128 #[serde(rename = "secret_source.is_available")]
130 IsAvailable,
131 #[serde(rename = "secret_source.get")]
134 Get(GetParams),
135 #[serde(rename = "secret_source.list")]
137 List,
138 #[serde(rename = "secret_source.validate")]
140 Validate(ValidateParams),
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct InitParams {
145 pub source_name: String,
149 pub config: BTreeMap<String, serde_json::Value>,
152 pub protocol_version: String,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct GetParams {
159 pub reference: String,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct ValidateParams {
164 pub reference: String,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(untagged)]
176pub enum PluginResponse {
177 Init(InitResult),
178 IsAvailable(IsAvailableResult),
179 Get(GetResult),
180 List(ListResult),
181 Validate(ValidateResult),
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub struct InitResult {
186 pub source_name: String,
189 pub capabilities_bits: u32,
192 pub plugin_version: String,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct IsAvailableResult {
199 pub status: IsAvailableStatus,
200 pub detail: Option<String>,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
210#[serde(rename_all = "kebab-case")]
211pub enum IsAvailableStatus {
212 Available,
213 Unavailable,
214 NeedsCredential,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct GetResult {
222 pub value: String,
227 pub lease_seconds: Option<u64>,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct ListResult {
234 pub entries: Vec<RemoteRefDto>,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238pub struct RemoteRefDto {
239 pub reference: String,
240 pub display: Option<String>,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct ValidateResult {
245 pub ok: bool,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
260#[serde(tag = "kind", rename_all = "kebab-case")]
261pub enum PluginError {
262 #[error("source unavailable: {detail}")]
264 Unavailable { detail: String },
265 #[error("source does not support capability: {capability}")]
268 UnsupportedCapability { capability: String },
269 #[error("source rejected reference `{reference}`: {reason}")]
271 BadReference { reference: String, reason: String },
272 #[error("source needs credential: {detail}")]
275 NeedsCredential { detail: String },
276 #[error("source error: {detail}")]
280 Other { detail: String },
281}
282
283#[cfg(test)]
288mod tests {
289 use super::*;
290 use serde_json::json;
291
292 fn req(call: PluginRequest, id: u64) -> PluginRpcRequest {
293 PluginRpcRequest {
294 jsonrpc: JsonRpcVersion::current(),
295 id,
296 call,
297 }
298 }
299
300 fn ok(id: u64, resp: PluginResponse) -> PluginRpcResponse {
301 PluginRpcResponse {
302 jsonrpc: JsonRpcVersion::current(),
303 id,
304 outcome: RpcOutcome::Result(resp),
305 }
306 }
307
308 #[test]
311 fn jsonrpc_version_constant_is_v2() {
312 assert_eq!(JsonRpcVersion::current().as_str(), "2.0");
313 assert!(JsonRpcVersion::current().is_supported());
314 }
315
316 #[test]
317 fn jsonrpc_version_rejects_anything_other_than_v2() {
318 let v = JsonRpcVersion("1.0".into());
319 assert!(!v.is_supported());
320 }
321
322 #[test]
325 fn init_request_round_trips_through_json() {
326 let mut config = BTreeMap::new();
327 config.insert("address".into(), json!("https://vault.example.invalid"));
328 let r = req(
329 PluginRequest::Init(InitParams {
330 source_name: "prod-vault".into(),
331 config,
332 protocol_version: PROTOCOL_VERSION.into(),
333 }),
334 1,
335 );
336 let line = serde_json::to_string(&r).unwrap();
337 assert!(line.contains("\"method\":\"secret_source.init\""));
338 assert!(line.contains("\"prod-vault\""));
339 let back: PluginRpcRequest = serde_json::from_str(&line).unwrap();
340 assert_eq!(back, r);
341 }
342
343 #[test]
344 fn is_available_request_has_no_params() {
345 let r = req(PluginRequest::IsAvailable, 7);
346 let line = serde_json::to_string(&r).unwrap();
347 assert!(line.contains("\"method\":\"secret_source.is_available\""));
348 let back: PluginRpcRequest = serde_json::from_str(&line).unwrap();
349 assert_eq!(back, r);
350 }
351
352 #[test]
353 fn get_request_carries_reference_only() {
354 let r = req(
355 PluginRequest::Get(GetParams {
356 reference: "secret/data/team/jira".into(),
357 }),
358 42,
359 );
360 let line = serde_json::to_string(&r).unwrap();
361 assert!(line.contains("\"method\":\"secret_source.get\""));
362 assert!(line.contains("\"reference\":\"secret/data/team/jira\""));
363 let back: PluginRpcRequest = serde_json::from_str(&line).unwrap();
364 assert_eq!(back, r);
365 }
366
367 #[test]
368 fn list_request_has_no_params() {
369 let r = req(PluginRequest::List, 99);
370 let line = serde_json::to_string(&r).unwrap();
371 assert!(line.contains("\"method\":\"secret_source.list\""));
372 let back: PluginRpcRequest = serde_json::from_str(&line).unwrap();
373 assert_eq!(back, r);
374 }
375
376 #[test]
377 fn validate_request_round_trips() {
378 let r = req(
379 PluginRequest::Validate(ValidateParams {
380 reference: "op://Private/jira".into(),
381 }),
382 123,
383 );
384 let line = serde_json::to_string(&r).unwrap();
385 assert!(line.contains("\"method\":\"secret_source.validate\""));
386 let back: PluginRpcRequest = serde_json::from_str(&line).unwrap();
387 assert_eq!(back, r);
388 }
389
390 #[test]
393 fn init_result_round_trips() {
394 let resp = ok(
395 1,
396 PluginResponse::Init(InitResult {
397 source_name: "prod-vault".into(),
398 capabilities_bits: 0b0000_0011,
399 plugin_version: "0.1.0".into(),
400 }),
401 );
402 let line = serde_json::to_string(&resp).unwrap();
403 let back: PluginRpcResponse = serde_json::from_str(&line).unwrap();
404 assert_eq!(back, resp);
405 }
406
407 #[test]
408 fn get_result_round_trips_with_lease() {
409 let resp = ok(
410 42,
411 PluginResponse::Get(GetResult {
412 value: "test-value-not-secret".into(),
413 lease_seconds: Some(3600),
414 }),
415 );
416 let line = serde_json::to_string(&resp).unwrap();
417 let back: PluginRpcResponse = serde_json::from_str(&line).unwrap();
418 assert_eq!(back, resp);
419 }
420
421 #[test]
422 fn list_result_round_trips_empty_and_populated() {
423 let resp = ok(99, PluginResponse::List(ListResult { entries: vec![] }));
424 let line = serde_json::to_string(&resp).unwrap();
425 let back: PluginRpcResponse = serde_json::from_str(&line).unwrap();
426 assert_eq!(back, resp);
427
428 let resp2 = ok(
429 100,
430 PluginResponse::List(ListResult {
431 entries: vec![RemoteRefDto {
432 reference: "secret/data/team/jira".into(),
433 display: Some("Jira API token".into()),
434 }],
435 }),
436 );
437 let line2 = serde_json::to_string(&resp2).unwrap();
438 let back2: PluginRpcResponse = serde_json::from_str(&line2).unwrap();
439 assert_eq!(back2, resp2);
440 }
441
442 #[test]
443 fn is_available_status_strings_are_pinned() {
444 assert_eq!(
445 serde_json::to_value(IsAvailableStatus::Available).unwrap(),
446 json!("available")
447 );
448 assert_eq!(
449 serde_json::to_value(IsAvailableStatus::NeedsCredential).unwrap(),
450 json!("needs-credential")
451 );
452 assert_eq!(
453 serde_json::to_value(IsAvailableStatus::Unavailable).unwrap(),
454 json!("unavailable")
455 );
456 }
457
458 #[test]
461 fn error_response_round_trips_each_kind() {
462 for err in [
463 PluginError::Unavailable {
464 detail: "vault sealed".into(),
465 },
466 PluginError::UnsupportedCapability {
467 capability: "list".into(),
468 },
469 PluginError::BadReference {
470 reference: "garbage".into(),
471 reason: "not a vault path".into(),
472 },
473 PluginError::NeedsCredential {
474 detail: "op signin required".into(),
475 },
476 PluginError::Other {
477 detail: "transport timeout".into(),
478 },
479 ] {
480 let envelope = PluginRpcResponse {
481 jsonrpc: JsonRpcVersion::current(),
482 id: 1,
483 outcome: RpcOutcome::Error(err),
484 };
485 let line = serde_json::to_string(&envelope).unwrap();
486 let back: PluginRpcResponse = serde_json::from_str(&line).unwrap();
487 assert_eq!(back, envelope);
488 }
489 }
490
491 #[test]
492 fn rpc_outcome_distinguishes_result_from_error_at_parse_time() {
493 let line_ok = r#"{"jsonrpc":"2.0","id":1,"result":{"value":"v","lease_seconds":null}}"#;
494 let parsed: PluginRpcResponse = serde_json::from_str(line_ok).unwrap();
495 assert!(matches!(parsed.outcome, RpcOutcome::Result(_)));
496
497 let line_err = r#"{"jsonrpc":"2.0","id":1,"error":{"kind":"unavailable","detail":"x"}}"#;
498 let parsed: PluginRpcResponse = serde_json::from_str(line_err).unwrap();
499 assert!(matches!(parsed.outcome, RpcOutcome::Error(_)));
500 }
501}