Skip to content

Commit c8bb673

Browse files
committed
fix: harden terminal output and config masking
1 parent c5c1ed3 commit c8bb673

2 files changed

Lines changed: 81 additions & 31 deletions

File tree

‎src/config.rs‎

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -224,11 +224,8 @@ pub fn config_get(key: &str, raw: bool) -> Result<()> {
224224
let api_key = get_api_key()?;
225225
if raw {
226226
println!("{}", api_key);
227-
} else if api_key.len() > 8 {
228-
let masked = format!("{}***{}", &api_key[..4], &api_key[api_key.len() - 4..]);
229-
println!("{}", masked);
230227
} else {
231-
println!("lin_***");
228+
println!("{}", mask_api_key_for_display(&api_key));
232229
}
233230
}
234231
"profile" => {
@@ -258,13 +255,7 @@ pub fn show_config() -> Result<()> {
258255
if let Some(current) = &config.current {
259256
println!("Current workspace: {}", current);
260257
if let Some(workspace) = config.workspaces.get(current) {
261-
let key = &workspace.api_key;
262-
if key.len() > 12 {
263-
let masked = format!("{}...{}", &key[..8], &key[key.len() - 4..]);
264-
println!("API Key: {}", masked);
265-
} else {
266-
println!("API Key: {}", key);
267-
}
258+
println!("API Key: {}", mask_api_key_for_display(&workspace.api_key));
268259
}
269260
} else {
270261
println!("No workspace configured. Run: linear workspace add <name>");
@@ -322,12 +313,7 @@ pub fn workspace_list() -> Result<()> {
322313
for (name, workspace) in &config.workspaces {
323314
let is_current = config.current.as_ref() == Some(name);
324315
let marker = if is_current { "*" } else { " " };
325-
let key = &workspace.api_key;
326-
let masked = if key.len() > 12 {
327-
format!("{}...{}", &key[..8], &key[key.len() - 4..])
328-
} else {
329-
key.clone()
330-
};
316+
let masked = mask_api_key_for_display(&workspace.api_key);
331317
println!("{} {} ({})", marker, name, masked);
332318
}
333319

@@ -360,13 +346,7 @@ pub fn workspace_current() -> Result<()> {
360346
if let Some(current) = &config.current {
361347
println!("Current workspace: {}", current);
362348
if let Some(workspace) = config.workspaces.get(current) {
363-
let key = &workspace.api_key;
364-
if key.len() > 12 {
365-
let masked = format!("{}...{}", &key[..8], &key[key.len() - 4..]);
366-
println!("API Key: {}", masked);
367-
} else {
368-
println!("API Key: {}", key);
369-
}
349+
println!("API Key: {}", mask_api_key_for_display(&workspace.api_key));
370350
}
371351
} else {
372352
println!("No workspace selected. Run: linear workspace add <name>");
@@ -422,7 +402,17 @@ fn oauth_config_has_secrets(oauth_config: &OAuthConfig) -> bool {
422402
.refresh_token
423403
.as_ref()
424404
.map(|token| !token.is_empty())
425-
.unwrap_or(false)
405+
.unwrap_or(false)
406+
}
407+
408+
fn mask_api_key_for_display(api_key: &str) -> String {
409+
if api_key.len() > 12 {
410+
format!("{}***{}", &api_key[..4], &api_key[api_key.len() - 4..])
411+
} else if api_key.starts_with("lin_") {
412+
"lin_***".to_string()
413+
} else {
414+
"***".to_string()
415+
}
426416
}
427417

428418
#[cfg(feature = "secure-storage")]
@@ -793,4 +783,22 @@ mod tests {
793783
assert!(ws.oauth.is_some());
794784
assert_eq!(ws.oauth.as_ref().unwrap().access_token, "oauth_tok");
795785
}
786+
787+
#[test]
788+
fn test_mask_api_key_for_display_masks_short_linear_keys() {
789+
assert_eq!(mask_api_key_for_display("lin_short"), "lin_***");
790+
}
791+
792+
#[test]
793+
fn test_mask_api_key_for_display_shows_prefix_and_suffix_for_long_keys() {
794+
assert_eq!(
795+
mask_api_key_for_display("lin_api_prod123"),
796+
"lin_***d123"
797+
);
798+
}
799+
800+
#[test]
801+
fn test_mask_api_key_for_display_masks_non_linear_short_keys() {
802+
assert_eq!(mask_api_key_for_display("secret"), "***");
803+
}
796804
}

‎src/text.rs‎

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,46 @@
1+
pub fn sanitize_terminal_text(input: &str) -> String {
2+
use regex::Regex;
3+
use std::sync::OnceLock;
4+
5+
static OSC_SEQUENCE: OnceLock<Regex> = OnceLock::new();
6+
static CSI_SEQUENCE: OnceLock<Regex> = OnceLock::new();
7+
static ESC_SEQUENCE: OnceLock<Regex> = OnceLock::new();
8+
9+
let osc_sequence =
10+
OSC_SEQUENCE.get_or_init(|| Regex::new(r"\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)").unwrap());
11+
let csi_sequence = CSI_SEQUENCE.get_or_init(|| Regex::new(r"\x1B\[[0-?]*[ -/]*[@-~]").unwrap());
12+
let esc_sequence = ESC_SEQUENCE.get_or_init(|| Regex::new(r"\x1B[@-Z\\-_]").unwrap());
13+
14+
let without_osc = osc_sequence.replace_all(input, "");
15+
let without_csi = csi_sequence.replace_all(&without_osc, "");
16+
let without_esc = esc_sequence.replace_all(&without_csi, "");
17+
18+
without_esc
19+
.chars()
20+
.filter(|ch| !ch.is_control() || matches!(ch, '\n' | '\t'))
21+
.collect()
22+
}
23+
124
pub fn truncate(value: &str, max_len: Option<usize>) -> String {
25+
let sanitized = sanitize_terminal_text(value);
226
let Some(max_len) = max_len else {
3-
return value.to_string();
27+
return sanitized;
428
};
529
if max_len == 0 {
630
return String::new();
731
}
832

9-
let char_count = value.chars().count();
33+
let char_count = sanitized.chars().count();
1034
if char_count <= max_len {
11-
return value.to_string();
35+
return sanitized;
1236
}
1337

1438
if max_len <= 3 {
15-
return value.chars().take(max_len).collect();
39+
return sanitized.chars().take(max_len).collect();
1640
}
1741

1842
// Take (max_len - 3) chars and add ellipsis
19-
let truncated: String = value.chars().take(max_len - 3).collect();
43+
let truncated: String = sanitized.chars().take(max_len - 3).collect();
2044
format!("{}...", truncated)
2145
}
2246

@@ -83,7 +107,7 @@ pub fn strip_markdown(input: &str) -> String {
83107
let multi_blank = MULTI_BLANK.get_or_init(|| Regex::new(r"\n{3,}").unwrap());
84108
result = multi_blank.replace_all(&result, "\n\n").to_string();
85109

86-
result.trim().to_string()
110+
sanitize_terminal_text(result.trim())
87111
}
88112

89113
#[cfg(test)]
@@ -203,4 +227,22 @@ mod tests {
203227
fn test_strip_markdown_collapses_blank_lines() {
204228
assert_eq!(strip_markdown("a\n\n\n\nb"), "a\n\nb");
205229
}
230+
231+
#[test]
232+
fn test_truncate_strips_ansi_before_counting() {
233+
assert_eq!(truncate("\u{1b}[31mhello\u{1b}[0m", Some(5)), "hello");
234+
}
235+
236+
#[test]
237+
fn test_strip_markdown_removes_terminal_control_sequences() {
238+
assert_eq!(
239+
strip_markdown("**hello** \u{1b}]52;c;ZXZpbA==\u{7} world"),
240+
"hello world"
241+
);
242+
}
243+
244+
#[test]
245+
fn test_strip_markdown_removes_embedded_control_characters() {
246+
assert_eq!(strip_markdown("ok\u{8}\u{0c}then"), "okthen");
247+
}
206248
}

0 commit comments

Comments
 (0)