Compare commits

...
Author SHA1 Message Date
beardthelion 7f9018f9de fix(gl): bound the bounty leaderboard did suffix at a char boundary 2026-11-06 13:44:15 -06:00
beardthelion 9ffe1e926a Merge remote-tracking branch 'origin/main' into fix/350-timestamp-slice-panics 2026-11-06 12:21:47 -06:00
beardthelion d67c981f7e fix(gl): bound truncate by bytes, not chars (#460)
Address PR review feedback: char_indices().nth(max) capped characters,
so a multibyte char near the cut could exceed the byte limit. Start at
max.min(len) and walk back to the nearest char boundary instead.
2026-09-15 11:47:46 -05:00
beardthelion 895dc1b830 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.
2026-09-15 10:37:32 -05:00
12 changed files with 130 additions and 27 deletions

No files matched your search

+1 -1
View File
@@ -88,7 +88,7 @@ async fn cmd_list(node: String, capability: Option<String>) -> 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"]
+2 -2
View File
@@ -275,7 +275,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}");
}
}
@@ -403,7 +403,7 @@ async fn cmd_stats(node: String) -> Result<()> {
let did = entry["did"].as_str().unwrap_or("?");
let count = entry["completed"].as_i64().unwrap_or(0);
let earned = entry["total_earned"].as_i64().unwrap_or(0);
let short = &did[did.len().saturating_sub(8)..];
let short = crate::text::truncate_suffix(did, 8);
println!(
" {}. ...{short} {count} bounties {earned} $GITLAWB",
i + 1
+4 -1
View File
@@ -117,7 +117,10 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> 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(())
+2 -2
View File
@@ -75,14 +75,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" => {
+1 -5
View File
@@ -131,11 +131,7 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> 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}");
+34 -3
View File
@@ -251,7 +251,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> 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" => "○",
@@ -365,10 +365,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!();
@@ -439,6 +442,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();
+1
View File
@@ -30,6 +30,7 @@ mod star;
mod status;
mod sync;
mod task;
mod text;
mod ucan_cmd;
mod visibility;
mod webhook;
+1 -1
View File
@@ -413,7 +413,7 @@ async fn cmd_status(node: String, dir: Option<PathBuf>) -> 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}");
}
+1 -1
View File
@@ -97,7 +97,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}");
+13 -7
View File
@@ -304,7 +304,7 @@ async fn cmd_list(repo: String, node: String, dir: Option<PathBuf>) -> 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" => "○",
@@ -374,7 +374,7 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
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("");
@@ -414,10 +414,13 @@ async fn cmd_view(repo: String, number: u64, node: String, dir: Option<PathBuf>)
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}");
}
@@ -464,7 +467,7 @@ async fn cmd_merge(repo: String, number: u64, node: String, dir: Option<PathBuf>
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(())
}
@@ -555,10 +558,13 @@ async fn cmd_comments(repo: String, number: u64, node: String, dir: Option<PathB
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!();
+7 -4
View File
@@ -337,7 +337,10 @@ async fn cmd_list(node: String, dir: Option<PathBuf>) -> 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}");
}
@@ -514,7 +517,7 @@ async fn cmd_replicas(repo: String, node: String, dir: Option<PathBuf>) -> 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}");
}
@@ -556,7 +559,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"]
@@ -566,7 +569,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})");
}
+63
View File
@@ -0,0 +1,63 @@
//! 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]
}
/// Keep at most the last `max` bytes of `s`, cutting at a char boundary.
///
/// `&s[s.len().saturating_sub(max)..]` panics when the start index lands
/// inside a multi-byte char; node-supplied ids hit that case.
pub(crate) fn truncate_suffix(s: &str, max: usize) -> &str {
let mut start = s.len().saturating_sub(max);
while !s.is_char_boundary(start) {
start += 1;
}
&s[start..]
}
#[cfg(test)]
mod tests {
use super::{truncate, truncate_suffix};
#[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}");
}
#[test]
fn suffix_cut_does_not_panic() {
// 'é' is two bytes; a start index of len-8 lands inside it.
assert_eq!(truncate_suffix("did:key:\u{e9}1234567", 8), "1234567");
}
#[test]
fn suffix_short_input_returns_whole() {
assert_eq!(truncate_suffix("short", 8), "short");
assert_eq!(truncate_suffix("", 8), "");
}
}