diff --git a/crates/gl/src/agent.rs b/crates/gl/src/agent.rs index d72c230a..54edbbe2 100644 --- a/crates/gl/src/agent.rs +++ b/crates/gl/src/agent.rs @@ -89,7 +89,7 @@ async fn cmd_list(node: String, capability: Option) -> Result<()> { let short = did .split(':') .next_back() - .map(|s| &s[..s.len().min(16)]) + .map(|s| crate::text::truncate(s, 16)) .unwrap_or("?"); let trust = agent["trust_score"].as_f64().unwrap_or(0.0); let caps = agent["capabilities"] diff --git a/crates/gl/src/bounty.rs b/crates/gl/src/bounty.rs index ccc658a1..61980e55 100644 --- a/crates/gl/src/bounty.rs +++ b/crates/gl/src/bounty.rs @@ -247,7 +247,7 @@ async fn cmd_list( let title = b["title"].as_str().unwrap_or("?"); let amount = b["amount"].as_i64().unwrap_or(0); let st = b["status"].as_str().unwrap_or("?"); - let short_id = &id[..8.min(id.len())]; + let short_id = crate::text::truncate(id, 8); println!("{short_id} {st:<10} {amount:>12} $GITLAWB {title}"); } } diff --git a/crates/gl/src/cert.rs b/crates/gl/src/cert.rs index 87ad5aec..fd5b0281 100644 --- a/crates/gl/src/cert.rs +++ b/crates/gl/src/cert.rs @@ -120,7 +120,10 @@ async fn cmd_list(repo: String, node: String, dir: Option) -> Result<() let id = cert["id"].as_str().unwrap_or("?"); let ref_name = cert["ref_name"].as_str().unwrap_or("?"); let new_sha = cert["new_sha"].as_str().unwrap_or("?"); - let issued_at = cert["issued_at"].as_str().map(|s| &s[..19]).unwrap_or("?"); + let issued_at = cert["issued_at"] + .as_str() + .map(|s| crate::text::truncate(s, 19)) + .unwrap_or("?"); println!(" {id:.8} {issued_at} {ref_name} {new_sha:.12}"); } Ok(()) diff --git a/crates/gl/src/changelog.rs b/crates/gl/src/changelog.rs index 87368944..d9e60eb9 100644 --- a/crates/gl/src/changelog.rs +++ b/crates/gl/src/changelog.rs @@ -83,14 +83,14 @@ pub async fn run(args: ChangelogArgs) -> Result<()> { for event in &events { let ts = event["timestamp"].as_str().unwrap_or("?"); - let date = &ts[..ts.len().min(10)]; + let date = crate::text::truncate(ts, 10); match event["type"].as_str().unwrap_or("") { "commit" => { let sha = event["sha"].as_str().unwrap_or("?"); let msg = event["message"].as_str().unwrap_or("?"); let first_line = msg.lines().next().unwrap_or(msg); - let short_sha = &sha[..sha.len().min(8)]; + let short_sha = crate::text::truncate(sha, 8); println!(" {date} commit {short_sha} {first_line}"); } "pr_merged" => { diff --git a/crates/gl/src/ipfs_cmd.rs b/crates/gl/src/ipfs_cmd.rs index 93ca5511..cdff4af4 100644 --- a/crates/gl/src/ipfs_cmd.rs +++ b/crates/gl/src/ipfs_cmd.rs @@ -123,11 +123,7 @@ async fn cmd_list(node: String, dir: Option) -> Result<()> { let sha = pin["sha256_hex"].as_str().unwrap_or("?"); let pinned_at = pin["pinned_at"].as_str().unwrap_or("?"); // Trim pinned_at to date+time without subseconds - let ts = if pinned_at.len() >= 19 { - &pinned_at[..19] - } else { - pinned_at - }; + let ts = crate::text::truncate(pinned_at, 19); println!(" {cid}"); println!(" sha256: {sha}"); println!(" pinned: {ts}"); diff --git a/crates/gl/src/issue.rs b/crates/gl/src/issue.rs index 57bd6944..6171e9a8 100644 --- a/crates/gl/src/issue.rs +++ b/crates/gl/src/issue.rs @@ -243,7 +243,7 @@ async fn cmd_list(repo: String, node: String, dir: Option) -> Result<() let status = issue["status"].as_str().unwrap_or("?"); let created = issue["created_at"] .as_str() - .map(|s| &s[..10]) + .map(|s| crate::text::truncate(s, 10)) .unwrap_or("?"); let icon = match status { "open" => "○", @@ -374,10 +374,13 @@ async fn cmd_issue_comments( let author_short = author .split(':') .next_back() - .map(|s| &s[..s.len().min(8)]) + .map(|s| crate::text::truncate(s, 8)) .unwrap_or("?"); let cbody = c["body"].as_str().unwrap_or(""); - let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?"); + let created = c["created_at"] + .as_str() + .map(|s| crate::text::truncate(s, 10)) + .unwrap_or("?"); println!(" · {author_short} ({created})"); println!(" {cbody}"); println!(); @@ -448,6 +451,34 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn test_cmd_list_survives_malformed_timestamps() { + let dir = TempDir::new().unwrap(); + write_identity(&dir); + + let mut server = mockito::Server::new_async().await; + // A short timestamp used to panic on `&s[..10]`; a timestamp whose + // byte 10 is mid-char panics even with a len() guard. + let _m = server + .mock( + "GET", + mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/issues$".to_string()), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"issues":[{"id":"a","title":"short ts","status":"open","created_at":"2026"},{"id":"b","title":"mb ts","status":"open","created_at":"2026-08-1é5T00:00:00Z"},{"id":"c","title":"empty ts","status":"open","created_at":""}]}"#) + .create_async() + .await; + + cmd_list( + "myrepo".to_string(), + server.url(), + Some(dir.path().to_path_buf()), + ) + .await + .unwrap(); + } + #[tokio::test] async fn test_cmd_create_success() { let dir = TempDir::new().unwrap(); diff --git a/crates/gl/src/main.rs b/crates/gl/src/main.rs index 1c1a50d1..85875ec0 100644 --- a/crates/gl/src/main.rs +++ b/crates/gl/src/main.rs @@ -30,6 +30,7 @@ mod star; mod status; mod sync; mod task; +mod text; mod ucan_cmd; mod visibility; mod webhook; diff --git a/crates/gl/src/node.rs b/crates/gl/src/node.rs index 367ba576..6b759416 100644 --- a/crates/gl/src/node.rs +++ b/crates/gl/src/node.rs @@ -401,7 +401,7 @@ async fn cmd_status(node: String, dir: Option) -> Result<()> { let ref_name = ev["ref"].as_str().unwrap_or("?"); let ts = ev["timestamp"] .as_str() - .map(|s| &s[..10.min(s.len())]) + .map(|s| crate::text::truncate(s, 10)) .unwrap_or("?"); println!(" {ts} {repo} {ref_name}"); } diff --git a/crates/gl/src/peer.rs b/crates/gl/src/peer.rs index aa420f2e..a7939456 100644 --- a/crates/gl/src/peer.rs +++ b/crates/gl/src/peer.rs @@ -86,7 +86,7 @@ async fn cmd_list(node: String) -> Result<()> { let reachable = peer["reachable"].as_bool().unwrap_or(false); let last_seen = peer["last_seen"] .as_str() - .map(|s| &s[..10]) + .map(|s| crate::text::truncate(s, 10)) .unwrap_or("never"); let status = if reachable { "✓" } else { "✗" }; println!(" {status} {url}"); diff --git a/crates/gl/src/pr.rs b/crates/gl/src/pr.rs index 8e0c4b72..4f9f5472 100644 --- a/crates/gl/src/pr.rs +++ b/crates/gl/src/pr.rs @@ -278,7 +278,7 @@ async fn cmd_list(repo: String, node: String, dir: Option) -> Result<() let author_short = author .split(':') .next_back() - .map(|s| &s[..s.len().min(8)]) + .map(|s| crate::text::truncate(s, 8)) .unwrap_or("?"); let status_icon = match status { "open" => "○", @@ -337,7 +337,7 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option) let reviewer_short = reviewer .split(':') .next_back() - .map(|s| &s[..s.len().min(8)]) + .map(|s| crate::text::truncate(s, 8)) .unwrap_or("?"); let rstatus = r["status"].as_str().unwrap_or("?"); let rbody = r["body"].as_str().unwrap_or(""); @@ -370,10 +370,13 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option) let author_short = author .split(':') .next_back() - .map(|s| &s[..s.len().min(8)]) + .map(|s| crate::text::truncate(s, 8)) .unwrap_or("?"); let cbody = c["body"].as_str().unwrap_or(""); - let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?"); + let created = c["created_at"] + .as_str() + .map(|s| crate::text::truncate(s, 10)) + .unwrap_or("?"); println!(" · {author_short} ({created})"); println!(" {cbody}"); } @@ -425,7 +428,7 @@ async fn cmd_merge(repo: String, number: u64, node: String, dir: Option let sha = result["merge_sha"].as_str().unwrap_or("?"); println!("✓ Merged PR #{number}"); - println!(" Merge commit: {}", &sha[..sha.len().min(12)]); + println!(" Merge commit: {}", crate::text::truncate(sha, 12)); Ok(()) } @@ -527,10 +530,13 @@ async fn cmd_comments(repo: String, number: u64, node: String, dir: Option) -> Result<()> { let name = r["name"].as_str().unwrap_or("?"); let desc = r["description"].as_str().unwrap_or(""); let public = r["is_public"].as_bool().unwrap_or(true); - let updated = r["updated_at"].as_str().map(|s| &s[..10]).unwrap_or("?"); + let updated = r["updated_at"] + .as_str() + .map(|s| crate::text::truncate(s, 10)) + .unwrap_or("?"); let vis = if public { "public" } else { "private" }; println!(" {name:<24} {vis:<8} {updated} {desc}"); } @@ -495,7 +498,7 @@ async fn cmd_replicas(repo: String, node: String, dir: Option) -> Resul let url = r["replica_url"].as_str().unwrap_or("?"); let registered = r["registered_at"] .as_str() - .map(|s| &s[..10.min(s.len())]) + .map(|s| crate::text::truncate(s, 10)) .unwrap_or("?"); println!(" {registered} {did} → {url}"); } @@ -542,7 +545,7 @@ pub(crate) async fn cmd_commits( .or_else(|| c["sha"].as_str()) .or_else(|| c["oid"].as_str()) .unwrap_or("?"); - let short_sha = &sha[..sha.len().min(10)]; + let short_sha = crate::text::truncate(sha, 10); let msg = c["message"].as_str().unwrap_or("(no message)"); let first_line = msg.lines().next().unwrap_or(msg); let author = c["author_name"] @@ -552,7 +555,7 @@ pub(crate) async fn cmd_commits( let date = c["date"] .as_str() .or_else(|| c["committer_date"].as_str()) - .map(|s| &s[..10.min(s.len())]) + .map(|s| crate::text::truncate(s, 10)) .unwrap_or("?"); println!(" {short_sha} {date} {first_line} ({author})"); } diff --git a/crates/gl/src/text.rs b/crates/gl/src/text.rs new file mode 100644 index 00000000..bf3b3710 --- /dev/null +++ b/crates/gl/src/text.rs @@ -0,0 +1,39 @@ +//! Display helpers for strings that come off the wire unchecked. + +/// Truncate `s` to at most `max` bytes, cutting at a char boundary. +/// +/// `&s[..max]` panics when `s` is shorter than `max` bytes and when `max` +/// lands inside a multi-byte char; `s.len().min(max)` guards only the first +/// case. Node-supplied timestamps and ids hit both. +pub(crate) fn truncate(s: &str, max: usize) -> &str { + let mut end = max.min(s.len()); + while !s.is_char_boundary(end) { + end -= 1; + } + &s[..end] +} + +#[cfg(test)] +mod tests { + use super::truncate; + + #[test] + fn cuts_ascii_at_max() { + assert_eq!(truncate("2026-08-15T12:34:56Z", 10), "2026-08-15"); + } + + #[test] + fn short_and_empty_inputs_return_whole() { + assert_eq!(truncate("2026", 10), "2026"); + assert_eq!(truncate("", 10), ""); + } + + #[test] + fn multibyte_cut_does_not_panic() { + // Byte index 10 lands inside 'é'; the cut must land on the boundary + // before it (9 bytes) rather than panic or exceed the byte limit. + assert_eq!(truncate("2026-08-1\u{e9}5T12:34:56Z", 10), "2026-08-1"); + // A string that is entirely multi-byte and shorter than max. + assert_eq!(truncate("\u{e9}\u{e9}", 10), "\u{e9}\u{e9}"); + } +}