Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/output-hygiene.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---

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.
2 changes: 1 addition & 1 deletion src/credential_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

use std::path::PathBuf;

use crate::error::sanitize_for_terminal;
use crate::output::sanitize_for_terminal;

use aes_gcm::aead::{Aead, KeyInit, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm, Nonce};
Expand Down
31 changes: 1 addition & 30 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,36 +148,7 @@ impl GwsError {
}
}

/// Returns true when stderr is connected to an interactive terminal,
/// meaning ANSI color codes will be visible to the user.
fn stderr_supports_color() -> bool {
use std::io::IsTerminal;
std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none()
}

/// Wrap `text` in ANSI bold + the given color code, resetting afterwards.
/// Returns the plain text unchanged when stderr is not a TTY.
fn colorize(text: &str, ansi_color: &str) -> String {
if stderr_supports_color() {
format!("\x1b[1;{ansi_color}m{text}\x1b[0m")
} else {
text.to_string()
}
}

/// Strip terminal control characters from `text` to prevent escape-sequence
/// injection when printing untrusted content (API responses, user input) to
/// stderr. Preserves newlines and tabs for readability.
pub(crate) fn sanitize_for_terminal(text: &str) -> String {
text.chars()
.filter(|&c| {
if c == '\n' || c == '\t' {
return true;
}
!c.is_control()
})
.collect()
}
use crate::output::{colorize, sanitize_for_terminal};

/// Format a colored error label for the given error variant.
fn error_label(err: &GwsError) -> String {
Expand Down
3 changes: 2 additions & 1 deletion src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ use serde_json::{json, Map, Value};
use tokio::io::AsyncWriteExt;

use crate::discovery::{RestDescription, RestMethod};
use crate::error::{sanitize_for_terminal, GwsError};
use crate::error::GwsError;
use crate::output::sanitize_for_terminal;

/// Tracks what authentication method was used for the request.
#[derive(Debug, Clone, PartialEq)]
Expand Down
3 changes: 2 additions & 1 deletion src/generate_skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@

use crate::commands;
use crate::discovery;
use crate::error::{sanitize_for_terminal, GwsError};
use crate::error::GwsError;
use crate::output::sanitize_for_terminal;
use crate::services;
use clap::Command;
use std::path::Path;
Expand Down
2 changes: 1 addition & 1 deletion src/helpers/events/subscribe.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use super::*;
use crate::auth::AccessTokenProvider;
use crate::error::sanitize_for_terminal;
use crate::helpers::PUBSUB_API_BASE;
use crate::output::sanitize_for_terminal;
use std::path::PathBuf;

#[derive(Debug, Clone, Default, Builder)]
Expand Down
2 changes: 1 addition & 1 deletion src/helpers/gmail/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ use triage::handle_triage;
use watch::handle_watch;

pub(super) use crate::auth;
use crate::error::sanitize_for_terminal;
pub(super) use crate::error::GwsError;
pub(super) use crate::executor;
use crate::output::sanitize_for_terminal;
pub(super) use anyhow::Context;
pub(super) use base64::{engine::general_purpose::URL_SAFE, Engine as _};
pub(super) use clap::{Arg, ArgAction, ArgMatches, Command};
Expand Down
12 changes: 5 additions & 7 deletions src/helpers/gmail/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ pub(super) async fn handle_read(
continue;
}
// Replace newlines to prevent header spoofing in the output, then sanitize.
let sanitized_value = sanitize_terminal_output(&value.replace(['\r', '\n'], " "));
let sanitized_value = sanitize_for_terminal(&value.replace(['\r', '\n'], " "));
writeln!(stdout, "{}: {}", name, sanitized_value)
.with_context(|| format!("Failed to write '{name}' header"))?;
}
Expand All @@ -87,8 +87,7 @@ pub(super) async fn handle_read(
&original.body_text
};

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

Ok(())
}
Expand All @@ -102,17 +101,16 @@ fn format_mailbox_list(mailboxes: &[Mailbox]) -> String {
.join(", ")
}

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

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_sanitize_terminal_output() {
fn test_sanitize_for_terminal() {
let malicious = "Subject: \x1b]0;MALICIOUS\x07Hello\nWorld\r\t";
let sanitized = sanitize_terminal_output(malicious);
let sanitized = sanitize_for_terminal(malicious);
// ANSI escape sequences (control chars) should be removed
assert!(!sanitized.contains('\x1b'));
assert!(!sanitized.contains('\x07'));
Expand Down
5 changes: 4 additions & 1 deletion src/helpers/gmail/triage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,10 @@ pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> {
/// Returns the human-readable "no messages" diagnostic string.
/// Extracted so the test can reference the exact same message without duplication.
fn no_messages_msg(query: &str) -> String {
format!("No messages found matching query: {query}")
format!(
"No messages found matching query: {}",
crate::output::sanitize_for_terminal(query)
)
}

#[cfg(test)]
Expand Down
19 changes: 14 additions & 5 deletions src/helpers/gmail/watch.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use super::*;
use crate::auth::AccessTokenProvider;
use crate::error::sanitize_for_terminal;
use crate::helpers::PUBSUB_API_BASE;
use crate::output::colorize;
use crate::output::sanitize_for_terminal;

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

Expand Down Expand Up @@ -100,7 +101,7 @@ pub(super) async fn handle_watch(
" --member=serviceAccount:gmail-api-push@system.gserviceaccount.com \\"
);
eprintln!(" --role=roles/pubsub.publisher");
eprintln!("Error: {body}");
eprintln!("Error: {}", sanitize_for_terminal(&body));
}

t
Expand Down Expand Up @@ -454,7 +455,11 @@ async fn fetch_and_output_messages(
}
}
Err(e) => {
eprintln!("\x1b[33m[WARNING]\x1b[0m Model Armor sanitization failed for message {msg_id}: {}", sanitize_for_terminal(&e.to_string()));
eprintln!(
"{} Model Armor sanitization failed for message {msg_id}: {}",
colorize("warning:", "33"),
sanitize_for_terminal(&e.to_string())
);
}
}
}
Expand Down Expand Up @@ -498,12 +503,16 @@ fn apply_sanitization_result(
match sanitize_config.mode {
crate::helpers::modelarmor::SanitizeMode::Block => {
eprintln!(
"\x1b[31m[BLOCKED]\x1b[0m Message {msg_id} blocked by Model Armor (match found)"
"{} Message {msg_id} blocked by Model Armor (match found)",
colorize("blocked:", "31")
);
return None;
}
crate::helpers::modelarmor::SanitizeMode::Warn => {
eprintln!("\x1b[33m[WARNING]\x1b[0m Model Armor match found in message {msg_id}");
eprintln!(
"{} Model Armor match found in message {msg_id}",
colorize("warning:", "33")
);
full_msg["_sanitization"] = serde_json::json!({
"filterMatchState": result.filter_match_state,
"filterResults": result.filter_results,
Expand Down
3 changes: 2 additions & 1 deletion src/helpers/workflows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@

use super::Helper;
use crate::auth;
use crate::error::{sanitize_for_terminal, GwsError};
use crate::error::GwsError;
use crate::output::sanitize_for_terminal;
use clap::{Arg, ArgMatches, Command};
use serde_json::{json, Value};
use std::future::Future;
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ mod generate_skills;
mod helpers;
mod logging;
mod oauth_config;
mod output;
mod schema;
mod services;
mod setup;
Expand Down
Loading
Loading