1pub use object_store;
29
30pub mod boot;
31pub mod disk;
32pub mod lease;
33pub mod manifest;
34pub mod restore;
35pub mod segment;
36pub mod store;
37pub mod worker;
38
39pub use boot::{restore_on_boot, BootError, BootLease, BootRestore};
40pub use lease::{BucketLease, Exclusion, LeaseError};
41pub use manifest::{FileEntry, Manifest, SegmentEntry, MANIFEST_VERSION};
42pub use restore::{
43 restore_from_s3, restore_from_store, RestoreS3Error, RestoreS3Outcome, RestoreTarget,
44};
45pub use store::{build_store, BackupConfig, S3Location};
46pub use worker::{
47 BackupCounters, BackupHandle, BackupTrigger, BackupWorker, WalSource, SIDE_FILES,
48};
49
50#[derive(Debug)]
52pub enum BackupError {
53 Config(String),
55 Store(object_store::Error),
57 Io(std::io::Error),
59 Manifest(String),
61 Inconsistent(String),
63 Timeout(String),
65 Lease(String),
68 Lineage(String),
71 Conflict(String),
73}
74
75impl std::fmt::Display for BackupError {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 match self {
78 BackupError::Config(m) => write!(f, "backup configuration: {m}"),
79 BackupError::Store(e) => write!(f, "object store: {}", redact(&e.to_string())),
82 BackupError::Timeout(what) => write!(f, "{what} did not finish within the deadline"),
83 BackupError::Io(e) => write!(f, "reading the store: {e}"),
84 BackupError::Manifest(m) => write!(f, "manifest: {m}"),
85 BackupError::Inconsistent(m) => write!(f, "{m}"),
86 BackupError::Lease(m) => write!(f, "bucket lease: {m}"),
87 BackupError::Lineage(m) | BackupError::Conflict(m) => write!(f, "{m}"),
88 }
89 }
90}
91
92impl std::error::Error for BackupError {}
93
94impl From<object_store::Error> for BackupError {
95 fn from(e: object_store::Error) -> Self {
96 BackupError::Store(e)
97 }
98}
99
100impl From<std::io::Error> for BackupError {
101 fn from(e: std::io::Error) -> Self {
102 BackupError::Io(e)
103 }
104}
105
106const REDACTED_ELEMENTS: [&str; 7] = [
109 "AWSAccessKeyId",
110 "AccessKeyId",
111 "SignatureProvided",
112 "StringToSign",
113 "StringToSignBytes",
114 "CanonicalRequest",
115 "CanonicalRequestBytes",
116];
117
118const REDACTED_FIELDS: [&str; 6] = [
120 "X-Amz-Credential=",
121 "X-Amz-Signature=",
122 "X-Amz-Security-Token=",
123 "Credential=",
124 "Signature=",
125 "x-amz-security-token:",
126];
127
128pub fn redact(text: &str) -> String {
134 let mut out = text.to_string();
135 for el in REDACTED_ELEMENTS {
136 let (open, close) = (format!("<{el}>"), format!("</{el}>"));
137 let mut from = 0;
138 while let Some(i) = out[from..].find(&open).map(|i| i + from + open.len()) {
139 let Some(j) = out[i..].find(&close).map(|j| j + i) else {
140 break;
141 };
142 out.replace_range(i..j, "[redacted]");
143 from = i + "[redacted]".len() + close.len();
144 }
145 }
146 for field in REDACTED_FIELDS {
147 let mut from = 0;
148 while let Some(i) = out[from..].find(field).map(|i| i + from + field.len()) {
149 let end = out[i..]
150 .find(|c: char| c == '&' || c == ',' || c == '"' || c == '<' || c.is_whitespace())
151 .map_or(out.len(), |j| j + i);
152 out.replace_range(i..end, "[redacted]");
153 from = i + "[redacted]".len();
154 }
155 }
156 redact_key_ids(&out)
157}
158
159fn redact_key_ids(text: &str) -> String {
160 const PREFIXES: [&str; 6] = ["AKIA", "ASIA", "AIDA", "AROA", "AGPA", "ANPA"];
161 let bytes = text.as_bytes();
162 let mut out = String::with_capacity(text.len());
163 let mut i = 0;
164 while i < bytes.len() {
165 let boundary_before = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
166 let candidate = bytes.get(i..i + 20).filter(|w| {
167 PREFIXES.iter().any(|p| w.starts_with(p.as_bytes()))
168 && w.iter()
169 .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
170 });
171 let boundary_after = bytes.get(i + 20).is_none_or(|b| !b.is_ascii_alphanumeric());
172 if boundary_before && boundary_after && candidate.is_some() {
173 out.push_str("[redacted]");
174 i += 20;
175 } else {
176 let ch = text[i..].chars().next().expect("in bounds");
177 out.push(ch);
178 i += ch.len_utf8();
179 }
180 }
181 out
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 const BODY: &str = "<?xml version=\"1.0\"?><Error><Code>SignatureDoesNotMatch</Code>\
189 <AWSAccessKeyId>AKIAIOSFODNN7EXAMPLE</AWSAccessKeyId>\
190 <StringToSign>AWS4-HMAC-SHA256\n20261020T000000Z</StringToSign>\
191 <SignatureProvided>abc123</SignatureProvided><RequestId>R1</RequestId></Error>";
192
193 #[test]
194 fn an_s3_refusal_body_loses_its_credentials() {
195 let r = redact(BODY);
196 for secret in ["AKIAIOSFODNN7EXAMPLE", "abc123", "AWS4-HMAC-SHA256"] {
197 assert!(!r.contains(secret), "{secret} survived: {r}");
198 }
199 assert!(
200 r.contains("SignatureDoesNotMatch"),
201 "the useful part stays: {r}"
202 );
203 assert!(r.contains("<RequestId>R1</RequestId>"));
204 }
205
206 #[test]
207 fn signing_parameters_and_bare_key_ids_are_redacted() {
208 let r = redact(
209 "GET /b?X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F2026&X-Amz-Signature=deadbeef ok \
210 Authorization: AWS4-HMAC-SHA256 Credential=ASIAABCDEFGHIJKLMNOP/x, Signature=ff",
211 );
212 for secret in [
213 "AKIAIOSFODNN7EXAMPLE",
214 "deadbeef",
215 "ASIAABCDEFGHIJKLMNOP",
216 "=ff",
217 ] {
218 assert!(!r.contains(secret), "{secret} survived: {r}");
219 }
220 assert!(r.contains(" ok "));
221 assert_eq!(redact("key AKIAIOSFODNN7EXAMPLE."), "key [redacted].");
223 assert_eq!(redact("AKIAIOSFODNN7EXAMPLEXX"), "AKIAIOSFODNN7EXAMPLEXX");
224 }
225
226 #[test]
227 fn a_store_error_displays_redacted() {
228 let e = BackupError::Store(object_store::Error::Generic {
229 store: "S3",
230 source: Box::new(std::io::Error::other(BODY)),
231 });
232 let shown = e.to_string();
233 assert!(!shown.contains("AKIAIOSFODNN7EXAMPLE"), "{shown}");
234 assert!(shown.contains("[redacted]"));
235 }
236}