Skip to main content

systemprompt_config/bootstrap/secrets/vault/
error.rs

1//! Failure modes of the Vault / `OpenBao` KV v2 secrets provider.
2//!
3//! No variant carries a response body, a token, or a secret value. The only
4//! upstream text that reaches an operator is Vault's own `errors[]` array,
5//! truncated to [`MAX_VAULT_ERROR_CHARS`], because that array is documented to
6//! hold policy diagnostics rather than secret material.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use systemprompt_traits::BoxedSource;
12
13pub(super) const MAX_VAULT_ERROR_CHARS: usize = 200;
14
15#[derive(Debug, thiserror::Error)]
16#[non_exhaustive]
17pub enum VaultError {
18    #[error("vault address is not a permitted outbound URL: {0}")]
19    Address(#[source] BoxedSource),
20
21    #[error("vault TLS CA certificate at {path} could not be loaded: {source}")]
22    CaCertificate {
23        path: String,
24        #[source]
25        source: BoxedSource,
26    },
27
28    #[error("vault HTTP client could not be built: {0}")]
29    ClientBuild(#[source] reqwest::Error),
30
31    #[error(
32        "vault credential is missing: {name}. Provide it in the process environment; never write \
33         a token into profile YAML."
34    )]
35    MissingCredential { name: String },
36
37    #[error("vault credential file {path} could not be read: {source}")]
38    CredentialFile {
39        path: String,
40        #[source]
41        source: std::io::Error,
42    },
43
44    #[error("vault credential file {path} could not be read: file is empty")]
45    EmptyCredentialFile { path: String },
46
47    #[error("vault {method} login was rejected with HTTP {status}{detail}")]
48    Auth {
49        method: &'static str,
50        status: u16,
51        detail: String,
52    },
53
54    #[error("vault denied access to {mount}/{path} (HTTP 403){detail}")]
55    Forbidden {
56        mount: String,
57        path: String,
58        detail: String,
59    },
60
61    #[error("vault has no secret at {mount}/{path}")]
62    NotFound { mount: String, path: String },
63
64    #[error("vault returned HTTP {status} for {mount}/{path}{detail}")]
65    Http {
66        status: u16,
67        mount: String,
68        path: String,
69        detail: String,
70    },
71
72    #[error("vault response body could not be read: {0}")]
73    Body(#[source] reqwest::Error),
74
75    #[error("vault response was not the expected KV v2 shape: {context}: {source}")]
76    Malformed {
77        context: String,
78        #[source]
79        source: serde_json::Error,
80    },
81
82    #[error(
83        "vault response was not the expected KV v2 shape: {location} has no field '{field}' for \
84         override key '{key}'"
85    )]
86    MissingField {
87        location: String,
88        field: String,
89        key: String,
90    },
91
92    #[error("vault request failed: {0}")]
93    Transport(#[source] reqwest::Error),
94
95    #[error("vault was unreachable after {attempts} attempt(s): {last}")]
96    Exhausted {
97        attempts: u32,
98        last: VaultAttemptFailure,
99    },
100}
101
102/// Why the final retryable Vault attempt failed.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
104pub enum VaultAttemptFailure {
105    #[error("HTTP {0}")]
106    Status(u16),
107
108    #[error("request timed out")]
109    Timeout,
110
111    #[error("could not connect")]
112    Connect,
113}
114
115#[must_use]
116pub(super) fn truncate_detail(raw: &str) -> String {
117    let trimmed = raw.trim();
118    if trimmed.is_empty() {
119        return String::new();
120    }
121    let clipped: String = trimmed.chars().take(MAX_VAULT_ERROR_CHARS).collect();
122    format!(": {clipped}")
123}