rustferry_remote/
redaction.rs1use std::{collections::BTreeMap, error::Error, fmt};
2
3use serde_json::{Map, Value};
4
5use crate::secret::{Secret, SecretBytes};
6
7pub const REDACTION_MARKER: &str = "<redacted>";
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum OutputStream {
13 Stdout,
15 Stderr,
17}
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum RedactionError {
22 EmptySecret,
24}
25
26impl fmt::Display for RedactionError {
27 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
28 match self {
29 Self::EmptySecret => formatter.write_str("cannot register an empty secret"),
30 }
31 }
32}
33
34impl Error for RedactionError {}
35
36pub struct SecretRedactor {
42 secrets: Vec<SecretBytes>,
43}
44
45impl SecretRedactor {
46 pub fn new() -> Self {
48 Self {
49 secrets: Vec::new(),
50 }
51 }
52
53 pub fn register_secret(&mut self, secret: &Secret) -> Result<(), RedactionError> {
59 self.register_bytes(secret.expose_secret().as_bytes())
60 }
61
62 pub fn register_secret_bytes(&mut self, secret: &SecretBytes) -> Result<(), RedactionError> {
68 self.register_bytes(secret.expose_secret_bytes())
69 }
70
71 fn register_bytes(&mut self, secret: &[u8]) -> Result<(), RedactionError> {
72 if secret.is_empty() {
73 return Err(RedactionError::EmptySecret);
74 }
75 if !self
76 .secrets
77 .iter()
78 .any(|known| known.expose_secret_bytes() == secret)
79 {
80 self.secrets.push(SecretBytes::new(secret.to_vec()));
81 }
82 Ok(())
83 }
84
85 pub fn stream(&self) -> StreamingRedactor<'_> {
87 StreamingRedactor {
88 redactor: self,
89 pending: Vec::new(),
90 finished: false,
91 }
92 }
93
94 pub fn command_output(&self) -> CommandOutputRedactor<'_> {
96 CommandOutputRedactor {
97 stdout: self.stream(),
98 stderr: self.stream(),
99 }
100 }
101
102 pub fn redact_bytes(&self, input: &[u8]) -> Vec<u8> {
104 let mut stream = self.stream();
105 let mut output = stream.push(input);
106 output.extend(stream.finish());
107 output
108 }
109
110 pub fn redact_text(&self, input: &str) -> String {
112 String::from_utf8_lossy(&self.redact_bytes(input.as_bytes())).into_owned()
113 }
114
115 pub fn redact_arguments(&self, arguments: &[String]) -> Vec<String> {
117 let mut redact_next = false;
118 arguments
119 .iter()
120 .map(|argument| {
121 if redact_next {
122 redact_next = false;
123 return REDACTION_MARKER.to_owned();
124 }
125
126 if let Some((option, _value)) = argument.split_once('=')
127 && is_sensitive_name(option)
128 {
129 return format!("{}={REDACTION_MARKER}", self.redact_text(option));
130 }
131
132 if argument.starts_with('-') && is_sensitive_name(argument) {
133 redact_next = true;
134 return self.redact_text(argument);
135 }
136
137 self.redact_text(argument)
138 })
139 .collect()
140 }
141
142 pub fn redact_environment(
144 &self,
145 environment: &BTreeMap<String, String>,
146 ) -> BTreeMap<String, String> {
147 environment
148 .iter()
149 .map(|(name, value)| {
150 let value = if is_sensitive_name(name) {
151 REDACTION_MARKER.to_owned()
152 } else {
153 self.redact_text(value)
154 };
155 (self.redact_text(name), value)
156 })
157 .collect()
158 }
159
160 pub fn redact_json_value(&self, value: &mut Value) {
165 match value {
166 Value::String(text) => *text = self.redact_text(text),
167 Value::Array(items) => {
168 for item in items {
169 self.redact_json_value(item);
170 }
171 }
172 Value::Object(object) => self.redact_json_object(object),
173 Value::Null | Value::Bool(_) | Value::Number(_) => {}
174 }
175 }
176
177 fn redact_json_object(&self, object: &mut Map<String, Value>) {
178 let original = std::mem::take(object);
179 for (name, mut value) in original {
180 let redacted_name = self.redact_text(&name);
181 if is_sensitive_name(&name) {
182 value = Value::String(REDACTION_MARKER.to_owned());
183 } else {
184 self.redact_json_value(&mut value);
185 }
186 object.insert(redacted_name, value);
187 }
188 }
189}
190
191impl Default for SecretRedactor {
192 fn default() -> Self {
193 Self::new()
194 }
195}
196
197pub struct StreamingRedactor<'a> {
203 redactor: &'a SecretRedactor,
204 pending: Vec<u8>,
205 finished: bool,
206}
207
208impl StreamingRedactor<'_> {
209 pub fn push(&mut self, chunk: &[u8]) -> Vec<u8> {
216 assert!(
217 !self.finished,
218 "cannot push after redaction stream finished"
219 );
220 self.pending.extend_from_slice(chunk);
221 self.drain_safe(false)
222 }
223
224 pub fn finish(&mut self) -> Vec<u8> {
226 if self.finished {
227 return Vec::new();
228 }
229 self.finished = true;
230 self.drain_safe(true)
231 }
232
233 fn drain_safe(&mut self, finishing: bool) -> Vec<u8> {
234 let mut output = Vec::new();
235 let mut consumed = 0;
236
237 while consumed < self.pending.len() {
238 let remaining = &self.pending[consumed..];
239 let is_partial_secret = self.redactor.secrets.iter().any(|secret| {
240 let secret = secret.expose_secret_bytes();
241 secret.len() > remaining.len() && secret.starts_with(remaining)
242 });
243 let must_wait = !finishing && is_partial_secret;
244 if must_wait {
245 break;
246 }
247 if finishing && is_partial_secret {
248 output.extend_from_slice(REDACTION_MARKER.as_bytes());
249 consumed += remaining.len();
250 continue;
251 }
252
253 let full_match = self
254 .redactor
255 .secrets
256 .iter()
257 .map(SecretBytes::expose_secret_bytes)
258 .filter(|secret| remaining.starts_with(secret))
259 .max_by_key(|secret| secret.len());
260 if let Some(secret) = full_match {
261 output.extend_from_slice(REDACTION_MARKER.as_bytes());
262 consumed += secret.len();
263 } else {
264 output.push(remaining[0]);
265 consumed += 1;
266 }
267 }
268
269 self.pending.drain(..consumed);
270 output
271 }
272}
273
274impl Drop for StreamingRedactor<'_> {
275 fn drop(&mut self) {
276 self.pending.fill(0);
277 let _ = std::hint::black_box(self.pending.as_mut_slice());
278 }
279}
280
281pub struct CommandOutputRedactor<'a> {
283 stdout: StreamingRedactor<'a>,
284 stderr: StreamingRedactor<'a>,
285}
286
287impl CommandOutputRedactor<'_> {
288 pub fn push(&mut self, stream: OutputStream, chunk: &[u8]) -> Vec<u8> {
290 match stream {
291 OutputStream::Stdout => self.stdout.push(chunk),
292 OutputStream::Stderr => self.stderr.push(chunk),
293 }
294 }
295
296 pub fn finish(&mut self, stream: OutputStream) -> Vec<u8> {
298 match stream {
299 OutputStream::Stdout => self.stdout.finish(),
300 OutputStream::Stderr => self.stderr.finish(),
301 }
302 }
303}
304
305fn is_sensitive_name(name: &str) -> bool {
306 let normalized: String = name
307 .trim_start_matches('-')
308 .chars()
309 .filter(char::is_ascii_alphanumeric)
310 .map(|character| character.to_ascii_lowercase())
311 .collect();
312
313 [
314 "authorization",
315 "credential",
316 "githubtoken",
317 "apikey",
318 "privatekey",
319 "password",
320 "passphrase",
321 "secret",
322 "token",
323 "jwt",
324 "p12",
325 "p8",
326 "profilebase64",
327 "signedurl",
328 "keychainpassword",
329 "sshkey",
330 ]
331 .iter()
332 .any(|sensitive| normalized.contains(sensitive))
333}