Skip to content

Commit 2e909ae

Browse files
refactor: consolidate output hygiene into output.rs (#527)
* refactor: consolidate output hygiene into output.rs Introduce src/output.rs that consolidates: - sanitize_for_terminal (upgraded: now also strips bidi overrides, zero-width chars, directional isolates, line/paragraph separators) - reject_dangerous_chars (renamed from reject_control_chars) - is_dangerous_unicode predicate - colorize + stderr_supports_color (NO_COLOR + TTY detection) - status/warn/info stderr helpers (auto-sanitize) Migrate existing callers via re-exports from error.rs and validate.rs. Fix watch.rs: - Sanitize raw API error body in eprintln (was high-risk injection vector) - Replace 3 inline ANSI escape codes with colorize() for NO_COLOR support Fix triage.rs: - Sanitize user --query string in no_messages_msg output * refactor: remove re-export indirection, import directly from output Update all 10 caller files to import sanitize_for_terminal directly from crate::output instead of going through crate::error re-exports. Remove pub(crate) re-exports from error.rs and validate.rs. * fix: use char::is_control() in reject_dangerous_chars for C1 coverage Address PR review: the manual (c as u32) < 0x20 check missed C1 control characters (U+0080-U+009F), including CSI (U+009B) which can inject terminal escape sequences. Using char::is_control() covers both C0 and C1 ranges. Add test for CSI rejection. * fix: validate ansi_color in colorize() to prevent injection Defense-in-depth: only emit ANSI escape codes when ansi_color contains exclusively ASCII digits. Falls back to plain text if an invalid color code is passed. --------- Co-authored-by: jpoehnelt-bot <[email protected]>
1 parent 6e4daaf commit 2e909ae

File tree

16 files changed

+311
-88
lines changed

16 files changed

+311
-88
lines changed

.changeset/output-hygiene.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@googleworkspace/cli": patch
3+
---
4+
5+
Consolidate terminal sanitization, coloring, and output helpers into a new `output.rs` module. Fixes raw ANSI escape codes in `watch.rs` that bypassed `NO_COLOR` and TTY detection, upgrades `sanitize_for_terminal` to also strip dangerous Unicode characters (bidi overrides, zero-width spaces, directional isolates), and sanitizes previously raw API error body and user query outputs.

src/credential_store.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
use std::path::PathBuf;
1616

17-
use crate::error::sanitize_for_terminal;
17+
use crate::output::sanitize_for_terminal;
1818

1919
use aes_gcm::aead::{Aead, KeyInit, OsRng};
2020
use aes_gcm::{AeadCore, Aes256Gcm, Nonce};

src/error.rs

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -148,36 +148,7 @@ impl GwsError {
148148
}
149149
}
150150

151-
/// Returns true when stderr is connected to an interactive terminal,
152-
/// meaning ANSI color codes will be visible to the user.
153-
fn stderr_supports_color() -> bool {
154-
use std::io::IsTerminal;
155-
std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none()
156-
}
157-
158-
/// Wrap `text` in ANSI bold + the given color code, resetting afterwards.
159-
/// Returns the plain text unchanged when stderr is not a TTY.
160-
fn colorize(text: &str, ansi_color: &str) -> String {
161-
if stderr_supports_color() {
162-
format!("\x1b[1;{ansi_color}m{text}\x1b[0m")
163-
} else {
164-
text.to_string()
165-
}
166-
}
167-
168-
/// Strip terminal control characters from `text` to prevent escape-sequence
169-
/// injection when printing untrusted content (API responses, user input) to
170-
/// stderr. Preserves newlines and tabs for readability.
171-
pub(crate) fn sanitize_for_terminal(text: &str) -> String {
172-
text.chars()
173-
.filter(|&c| {
174-
if c == '\n' || c == '\t' {
175-
return true;
176-
}
177-
!c.is_control()
178-
})
179-
.collect()
180-
}
151+
use crate::output::{colorize, sanitize_for_terminal};
181152

182153
/// Format a colored error label for the given error variant.
183154
fn error_label(err: &GwsError) -> String {

src/executor.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ use serde_json::{json, Map, Value};
2828
use tokio::io::AsyncWriteExt;
2929

3030
use crate::discovery::{RestDescription, RestMethod};
31-
use crate::error::{sanitize_for_terminal, GwsError};
31+
use crate::error::GwsError;
32+
use crate::output::sanitize_for_terminal;
3233

3334
/// Tracks what authentication method was used for the request.
3435
#[derive(Debug, Clone, PartialEq)]

src/generate_skills.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
1919
use crate::commands;
2020
use crate::discovery;
21-
use crate::error::{sanitize_for_terminal, GwsError};
21+
use crate::error::GwsError;
22+
use crate::output::sanitize_for_terminal;
2223
use crate::services;
2324
use clap::Command;
2425
use std::path::Path;

src/helpers/events/subscribe.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use super::*;
22
use crate::auth::AccessTokenProvider;
3-
use crate::error::sanitize_for_terminal;
43
use crate::helpers::PUBSUB_API_BASE;
4+
use crate::output::sanitize_for_terminal;
55
use std::path::PathBuf;
66

77
#[derive(Debug, Clone, Default, Builder)]

src/helpers/gmail/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ use triage::handle_triage;
2828
use watch::handle_watch;
2929

3030
pub(super) use crate::auth;
31-
use crate::error::sanitize_for_terminal;
3231
pub(super) use crate::error::GwsError;
3332
pub(super) use crate::executor;
33+
use crate::output::sanitize_for_terminal;
3434
pub(super) use anyhow::Context;
3535
pub(super) use base64::{engine::general_purpose::URL_SAFE, Engine as _};
3636
pub(super) use clap::{Arg, ArgAction, ArgMatches, Command};

src/helpers/gmail/read.rs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ pub(super) async fn handle_read(
7070
continue;
7171
}
7272
// Replace newlines to prevent header spoofing in the output, then sanitize.
73-
let sanitized_value = sanitize_terminal_output(&value.replace(['\r', '\n'], " "));
73+
let sanitized_value = sanitize_for_terminal(&value.replace(['\r', '\n'], " "));
7474
writeln!(stdout, "{}: {}", name, sanitized_value)
7575
.with_context(|| format!("Failed to write '{name}' header"))?;
7676
}
@@ -87,8 +87,7 @@ pub(super) async fn handle_read(
8787
&original.body_text
8888
};
8989

90-
writeln!(stdout, "{}", sanitize_terminal_output(body))
91-
.context("Failed to write message body")?;
90+
writeln!(stdout, "{}", sanitize_for_terminal(body)).context("Failed to write message body")?;
9291

9392
Ok(())
9493
}
@@ -102,17 +101,16 @@ fn format_mailbox_list(mailboxes: &[Mailbox]) -> String {
102101
.join(", ")
103102
}
104103

105-
/// Re-export the crate-wide terminal sanitizer for use in this module.
106-
use crate::error::sanitize_for_terminal as sanitize_terminal_output;
104+
use crate::output::sanitize_for_terminal;
107105

108106
#[cfg(test)]
109107
mod tests {
110108
use super::*;
111109

112110
#[test]
113-
fn test_sanitize_terminal_output() {
111+
fn test_sanitize_for_terminal() {
114112
let malicious = "Subject: \x1b]0;MALICIOUS\x07Hello\nWorld\r\t";
115-
let sanitized = sanitize_terminal_output(malicious);
113+
let sanitized = sanitize_for_terminal(malicious);
116114
// ANSI escape sequences (control chars) should be removed
117115
assert!(!sanitized.contains('\x1b'));
118116
assert!(!sanitized.contains('\x07'));

src/helpers/gmail/triage.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,10 @@ pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> {
181181
/// Returns the human-readable "no messages" diagnostic string.
182182
/// Extracted so the test can reference the exact same message without duplication.
183183
fn no_messages_msg(query: &str) -> String {
184-
format!("No messages found matching query: {query}")
184+
format!(
185+
"No messages found matching query: {}",
186+
crate::output::sanitize_for_terminal(query)
187+
)
185188
}
186189

187190
#[cfg(test)]

src/helpers/gmail/watch.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
use super::*;
22
use crate::auth::AccessTokenProvider;
3-
use crate::error::sanitize_for_terminal;
43
use crate::helpers::PUBSUB_API_BASE;
4+
use crate::output::colorize;
5+
use crate::output::sanitize_for_terminal;
56

67
const GMAIL_API_BASE: &str = "https://gmail.googleapis.com/gmail/v1";
78

@@ -100,7 +101,7 @@ pub(super) async fn handle_watch(
100101
" --member=serviceAccount:[email protected] \\"
101102
);
102103
eprintln!(" --role=roles/pubsub.publisher");
103-
eprintln!("Error: {body}");
104+
eprintln!("Error: {}", sanitize_for_terminal(&body));
104105
}
105106

106107
t
@@ -454,7 +455,11 @@ async fn fetch_and_output_messages(
454455
}
455456
}
456457
Err(e) => {
457-
eprintln!("\x1b[33m[WARNING]\x1b[0m Model Armor sanitization failed for message {msg_id}: {}", sanitize_for_terminal(&e.to_string()));
458+
eprintln!(
459+
"{} Model Armor sanitization failed for message {msg_id}: {}",
460+
colorize("warning:", "33"),
461+
sanitize_for_terminal(&e.to_string())
462+
);
458463
}
459464
}
460465
}
@@ -498,12 +503,16 @@ fn apply_sanitization_result(
498503
match sanitize_config.mode {
499504
crate::helpers::modelarmor::SanitizeMode::Block => {
500505
eprintln!(
501-
"\x1b[31m[BLOCKED]\x1b[0m Message {msg_id} blocked by Model Armor (match found)"
506+
"{} Message {msg_id} blocked by Model Armor (match found)",
507+
colorize("blocked:", "31")
502508
);
503509
return None;
504510
}
505511
crate::helpers::modelarmor::SanitizeMode::Warn => {
506-
eprintln!("\x1b[33m[WARNING]\x1b[0m Model Armor match found in message {msg_id}");
512+
eprintln!(
513+
"{} Model Armor match found in message {msg_id}",
514+
colorize("warning:", "33")
515+
);
507516
full_msg["_sanitization"] = serde_json::json!({
508517
"filterMatchState": result.filter_match_state,
509518
"filterResults": result.filter_results,

0 commit comments

Comments
 (0)