Skip to main content

rustferry_remote/
redaction.rs

1use std::{collections::BTreeMap, error::Error, fmt};
2
3use serde_json::{Map, Value};
4
5use crate::secret::{Secret, SecretBytes};
6
7/// Stable marker used in sanitized command output and protocol diagnostics.
8pub const REDACTION_MARKER: &str = "<redacted>";
9
10/// One logical command-output stream.
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum OutputStream {
13    /// Standard output.
14    Stdout,
15    /// Standard error.
16    Stderr,
17}
18
19/// Failure to configure the central redactor.
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum RedactionError {
22    /// Empty patterns cannot be redacted safely.
23    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
36/// Central exact-value and structured-field redactor.
37///
38/// Registered patterns are held in [`SecretBytes`] and are therefore neither
39/// debug-formatted nor serialized. Register every secret before starting the
40/// child process or provider request that may emit it.
41pub struct SecretRedactor {
42    secrets: Vec<SecretBytes>,
43}
44
45impl SecretRedactor {
46    /// Create a redactor with no registered values.
47    pub fn new() -> Self {
48        Self {
49            secrets: Vec::new(),
50        }
51    }
52
53    /// Register a UTF-8 secret without taking ownership from its caller.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`RedactionError::EmptySecret`] for an empty value.
58    pub fn register_secret(&mut self, secret: &Secret) -> Result<(), RedactionError> {
59        self.register_bytes(secret.expose_secret().as_bytes())
60    }
61
62    /// Register arbitrary secret bytes without taking ownership from its caller.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`RedactionError::EmptySecret`] for an empty value.
67    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    /// Create an independent chunk-safe stream redactor.
86    pub fn stream(&self) -> StreamingRedactor<'_> {
87        StreamingRedactor {
88            redactor: self,
89            pending: Vec::new(),
90            finished: false,
91        }
92    }
93
94    /// Create independent stdout and stderr redaction state.
95    pub fn command_output(&self) -> CommandOutputRedactor<'_> {
96        CommandOutputRedactor {
97            stdout: self.stream(),
98            stderr: self.stream(),
99        }
100    }
101
102    /// Redact registered values from one complete byte buffer.
103    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    /// Redact registered values from one complete UTF-8 string.
111    pub fn redact_text(&self, input: &str) -> String {
112        String::from_utf8_lossy(&self.redact_bytes(input.as_bytes())).into_owned()
113    }
114
115    /// Redact command arguments by exact value and by sensitive option name.
116    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    /// Redact environment values by exact value and by sensitive variable name.
143    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    /// Recursively redact known values and structurally sensitive JSON fields.
161    ///
162    /// Secret-reference fields remain visible because they carry opaque,
163    /// validated identifiers rather than secret material.
164    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
197/// Chunk-safe redaction state for one byte stream.
198///
199/// The stream withholds a possible secret prefix until more input arrives or
200/// [`StreamingRedactor::finish`] is called. It intentionally does not implement
201/// `Debug`: the pending buffer may contain secret bytes.
202pub struct StreamingRedactor<'a> {
203    redactor: &'a SecretRedactor,
204    pending: Vec<u8>,
205    finished: bool,
206}
207
208impl StreamingRedactor<'_> {
209    /// Redact one chunk, returning only bytes safe to emit immediately.
210    ///
211    /// # Panics
212    ///
213    /// Panics when called after [`StreamingRedactor::finish`]. Create a new
214    /// stream for subsequent process output.
215    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    /// Flush the final buffered bytes and mark the stream complete.
225    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
281/// Independent chunk-safe state for stdout and stderr.
282pub struct CommandOutputRedactor<'a> {
283    stdout: StreamingRedactor<'a>,
284    stderr: StreamingRedactor<'a>,
285}
286
287impl CommandOutputRedactor<'_> {
288    /// Redact one chunk from the selected stream.
289    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    /// Finish one selected stream.
297    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}