Skip to content

Commit 895dc1b

Browse files
committed
fix(gl): truncate node-supplied display strings at a char boundary (#350)
Every timestamp/id/SHA the CLI prints was cut with &s[..N] or &s[..N.min(s.len())]. The first panics on a short value, the second still panics when byte N is inside a multi-byte char. A shared text::truncate cuts at a char boundary and covers all sites, including two the issue did not name (node events, agent did) and the same shape in bounty, changelog, ipfs pin listing, and the merge-commit print.
1 parent bfc44f9 commit 895dc1b

12 files changed

Lines changed: 107 additions & 26 deletions

File tree

‎crates/gl/src/agent.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ async fn cmd_list(node: String, capability: Option<String>) -> Result<()> {
8989
let short = did
9090
.split(':')
9191
.next_back()
92-
.map(|s| &s[..s.len().min(16)])
92+
.map(|s| crate::text::truncate(s, 16))
9393
.unwrap_or("?");
9494
let trust = agent["trust_score"].as_f64().unwrap_or(0.0);
9595
let caps = agent["capabilities"]

‎crates/gl/src/bounty.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ async fn cmd_list(
247247
let title = b["title"].as_str().unwrap_or("?");
248248
let amount = b["amount"].as_i64().unwrap_or(0);
249249
let st = b["status"].as_str().unwrap_or("?");
250-
let short_id = &id[..8.min(id.len())];
250+
let short_id = crate::text::truncate(id, 8);
251251
println!("{short_id} {st:<10} {amount:>12} $GITLAWB {title}");
252252
}
253253
}

‎crates/gl/src/cert.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,10 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
120120
let id = cert["id"].as_str().unwrap_or("?");
121121
let ref_name = cert["ref_name"].as_str().unwrap_or("?");
122122
let new_sha = cert["new_sha"].as_str().unwrap_or("?");
123-
let issued_at = cert["issued_at"].as_str().map(|s| &s[..19]).unwrap_or("?");
123+
let issued_at = cert["issued_at"]
124+
.as_str()
125+
.map(|s| crate::text::truncate(s, 19))
126+
.unwrap_or("?");
124127
println!(" {id:.8} {issued_at} {ref_name} {new_sha:.12}");
125128
}
126129
Ok(())

‎crates/gl/src/changelog.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,14 @@ pub async fn run(args: ChangelogArgs) -> Result<()> {
8383

8484
for event in &events {
8585
let ts = event["timestamp"].as_str().unwrap_or("?");
86-
let date = &ts[..ts.len().min(10)];
86+
let date = crate::text::truncate(ts, 10);
8787

8888
match event["type"].as_str().unwrap_or("") {
8989
"commit" => {
9090
let sha = event["sha"].as_str().unwrap_or("?");
9191
let msg = event["message"].as_str().unwrap_or("?");
9292
let first_line = msg.lines().next().unwrap_or(msg);
93-
let short_sha = &sha[..sha.len().min(8)];
93+
let short_sha = crate::text::truncate(sha, 8);
9494
println!(" {date} commit {short_sha} {first_line}");
9595
}
9696
"pr_merged" => {

‎crates/gl/src/ipfs_cmd.rs‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,7 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> Result<()> {
123123
let sha = pin["sha256_hex"].as_str().unwrap_or("?");
124124
let pinned_at = pin["pinned_at"].as_str().unwrap_or("?");
125125
// Trim pinned_at to date+time without subseconds
126-
let ts = if pinned_at.len() >= 19 {
127-
&pinned_at[..19]
128-
} else {
129-
pinned_at
130-
};
126+
let ts = crate::text::truncate(pinned_at, 19);
131127
println!(" {cid}");
132128
println!(" sha256: {sha}");
133129
println!(" pinned: {ts}");

‎crates/gl/src/issue.rs‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
243243
let status = issue["status"].as_str().unwrap_or("?");
244244
let created = issue["created_at"]
245245
.as_str()
246-
.map(|s| &s[..10])
246+
.map(|s| crate::text::truncate(s, 10))
247247
.unwrap_or("?");
248248
let icon = match status {
249249
"open" => "○",
@@ -374,10 +374,13 @@ async fn cmd_issue_comments(
374374
let author_short = author
375375
.split(':')
376376
.next_back()
377-
.map(|s| &s[..s.len().min(8)])
377+
.map(|s| crate::text::truncate(s, 8))
378378
.unwrap_or("?");
379379
let cbody = c["body"].as_str().unwrap_or("");
380-
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
380+
let created = c["created_at"]
381+
.as_str()
382+
.map(|s| crate::text::truncate(s, 10))
383+
.unwrap_or("?");
381384
println!(" · {author_short} ({created})");
382385
println!(" {cbody}");
383386
println!();
@@ -448,6 +451,34 @@ mod tests {
448451
.unwrap();
449452
}
450453

454+
#[tokio::test]
455+
async fn test_cmd_list_survives_malformed_timestamps() {
456+
let dir = TempDir::new().unwrap();
457+
write_identity(&dir);
458+
459+
let mut server = mockito::Server::new_async().await;
460+
// A short timestamp used to panic on `&s[..10]`; a timestamp whose
461+
// byte 10 is mid-char panics even with a len() guard.
462+
let _m = server
463+
.mock(
464+
"GET",
465+
mockito::Matcher::Regex(r"^/api/v1/repos/[^/]+/myrepo/issues$".to_string()),
466+
)
467+
.with_status(200)
468+
.with_header("content-type", "application/json")
469+
.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":""}]}"#)
470+
.create_async()
471+
.await;
472+
473+
cmd_list(
474+
"myrepo".to_string(),
475+
server.url(),
476+
Some(dir.path().to_path_buf()),
477+
)
478+
.await
479+
.unwrap();
480+
}
481+
451482
#[tokio::test]
452483
async fn test_cmd_create_success() {
453484
let dir = TempDir::new().unwrap();

‎crates/gl/src/main.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ mod star;
3030
mod status;
3131
mod sync;
3232
mod task;
33+
mod text;
3334
mod ucan_cmd;
3435
mod visibility;
3536
mod webhook;

‎crates/gl/src/node.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ async fn cmd_status(node: String, dir: Option<PathBuf>) -> Result<()> {
401401
let ref_name = ev["ref"].as_str().unwrap_or("?");
402402
let ts = ev["timestamp"]
403403
.as_str()
404-
.map(|s| &s[..10.min(s.len())])
404+
.map(|s| crate::text::truncate(s, 10))
405405
.unwrap_or("?");
406406
println!(" {ts} {repo} {ref_name}");
407407
}

‎crates/gl/src/peer.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ async fn cmd_list(node: String) -> Result<()> {
8686
let reachable = peer["reachable"].as_bool().unwrap_or(false);
8787
let last_seen = peer["last_seen"]
8888
.as_str()
89-
.map(|s| &s[..10])
89+
.map(|s| crate::text::truncate(s, 10))
9090
.unwrap_or("never");
9191
let status = if reachable { "✓" } else { "✗" };
9292
println!(" {status} {url}");

‎crates/gl/src/pr.rs‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
278278
let author_short = author
279279
.split(':')
280280
.next_back()
281-
.map(|s| &s[..s.len().min(8)])
281+
.map(|s| crate::text::truncate(s, 8))
282282
.unwrap_or("?");
283283
let status_icon = match status {
284284
"open" => "○",
@@ -337,7 +337,7 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
337337
let reviewer_short = reviewer
338338
.split(':')
339339
.next_back()
340-
.map(|s| &s[..s.len().min(8)])
340+
.map(|s| crate::text::truncate(s, 8))
341341
.unwrap_or("?");
342342
let rstatus = r["status"].as_str().unwrap_or("?");
343343
let rbody = r["body"].as_str().unwrap_or("");
@@ -370,10 +370,13 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
370370
let author_short = author
371371
.split(':')
372372
.next_back()
373-
.map(|s| &s[..s.len().min(8)])
373+
.map(|s| crate::text::truncate(s, 8))
374374
.unwrap_or("?");
375375
let cbody = c["body"].as_str().unwrap_or("");
376-
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
376+
let created = c["created_at"]
377+
.as_str()
378+
.map(|s| crate::text::truncate(s, 10))
379+
.unwrap_or("?");
377380
println!(" · {author_short} ({created})");
378381
println!(" {cbody}");
379382
}
@@ -425,7 +428,7 @@ async fn cmd_merge(repo: String, number: u64, node: String, dir: Option<PathBuf>
425428

426429
let sha = result["merge_sha"].as_str().unwrap_or("?");
427430
println!("✓ Merged PR #{number}");
428-
println!(" Merge commit: {}", &sha[..sha.len().min(12)]);
431+
println!(" Merge commit: {}", crate::text::truncate(sha, 12));
429432
Ok(())
430433
}
431434

@@ -527,10 +530,13 @@ async fn cmd_comments(repo: String, number: u64, node: String, dir: Option<PathB
527530
let author_short = author
528531
.split(':')
529532
.next_back()
530-
.map(|s| &s[..s.len().min(8)])
533+
.map(|s| crate::text::truncate(s, 8))
531534
.unwrap_or("?");
532535
let cbody = c["body"].as_str().unwrap_or("");
533-
let created = c["created_at"].as_str().map(|s| &s[..10]).unwrap_or("?");
536+
let created = c["created_at"]
537+
.as_str()
538+
.map(|s| crate::text::truncate(s, 10))
539+
.unwrap_or("?");
534540
println!(" · {author_short} ({created})");
535541
println!(" {cbody}");
536542
println!();

0 commit comments

Comments
 (0)