diff --git a/Cargo.lock b/Cargo.lock index 3aca6dd5..72f6ef3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3449,6 +3449,7 @@ dependencies = [ "thiserror", "time", "tokio", + "unicode-segmentation", "url", ] diff --git a/crates/vibescrobble-cli/Cargo.toml b/crates/vibescrobble-cli/Cargo.toml index 5a9440d6..98e632a5 100644 --- a/crates/vibescrobble-cli/Cargo.toml +++ b/crates/vibescrobble-cli/Cargo.toml @@ -25,6 +25,7 @@ sha2.workspace = true thiserror.workspace = true time.workspace = true tokio.workspace = true +unicode-segmentation.workspace = true url.workspace = true [lints] diff --git a/crates/vibescrobble-cli/src/main.rs b/crates/vibescrobble-cli/src/main.rs index 23ea5aa3..9e610700 100644 --- a/crates/vibescrobble-cli/src/main.rs +++ b/crates/vibescrobble-cli/src/main.rs @@ -470,6 +470,7 @@ fn status_settled(did: &str, wait: std::time::Duration) -> (String, u8) { async fn post(args: &[String]) -> Result<(), String> { let text = flag(args, "--text").ok_or("post needs --text ")?; let emoji = flag(args, "--emoji").ok_or("post needs --emoji ")?; + check_scrobble(&text, &emoji)?; let http = http(args)?; let (name, did) = account(&http, args).await?; @@ -508,6 +509,34 @@ async fn post(args: &[String]) -> Result<(), String> { Ok(()) } +/// Refuses what the lexicon refuses, while the agent is still here to be +/// told. The server takes a record of a lexicon it does not know as it is, +/// and the index drops the whole of one that is over a limit, so a scrobble +/// that is not checked here is published and never drawn. +fn check_scrobble(text: &str, emoji: &str) -> Result<(), String> { + use unicode_segmentation::UnicodeSegmentation; + /// The lexicon's `text` limits, in graphemes and in bytes. + const TEXT_GRAPHEMES: usize = 256; + const TEXT_BYTES: usize = 2560; + /// The lexicon's `emoji` limit, in bytes; it is one grapheme. + const EMOJI_BYTES: usize = 64; + let graphemes = text.graphemes(true).count(); + if text.is_empty() { + return Err("--text is empty; say what you are doing".to_owned()); + } + if graphemes > TEXT_GRAPHEMES || text.len() > TEXT_BYTES { + return Err(format!( + "--text is {graphemes} characters; a scrobble takes up to {TEXT_GRAPHEMES}" + )); + } + if emoji.graphemes(true).count() != 1 || emoji.len() > EMOJI_BYTES { + return Err(format!( + "--emoji is `{emoji}`; a scrobble takes exactly one emoji" + )); + } + Ok(()) +} + /// Forgets the session kept for an account. Nothing on its server changes. async fn logout(args: &[String]) -> Result<(), String> { let (name, did) = account(&http(args)?, args).await?; @@ -1242,6 +1271,21 @@ mod tests { assert_eq!(stub.rotations.load(Ordering::SeqCst), 2); } + /// What the index would drop is refused before anything is sent. + #[test] + fn a_scrobble_the_lexicon_refuses_is_not_posted() { + assert!(check_scrobble("fixing the lock", "🧲").is_ok()); + // One grapheme of several code points is one emoji. + assert!(check_scrobble("fixing the lock", "👩‍💻").is_ok()); + assert!(check_scrobble(&"x".repeat(256), "🧲").is_ok()); + + assert!(check_scrobble("", "🧲").is_err()); + assert!(check_scrobble(&"x".repeat(257), "🧲").is_err()); + assert!(check_scrobble(&"é".repeat(1281), "🧲").is_err()); + assert!(check_scrobble("fixing the lock", "🧲🔧").is_err()); + assert!(check_scrobble("fixing the lock", "").is_err()); + } + /// A write whose answer is lost is sent again under the same key, and /// finding the record under that key is what tells this it landed: one /// record, and the post reports it rather than the refusal. diff --git a/crates/vibescrobble-cli/src/registration.rs b/crates/vibescrobble-cli/src/registration.rs index 25adb1e6..361804a2 100644 --- a/crates/vibescrobble-cli/src/registration.rs +++ b/crates/vibescrobble-cli/src/registration.rs @@ -6,6 +6,7 @@ //! entry into `parent`, so a reader of the firehose can place it without //! another fetch. +use atproto_identity::validation::{is_valid_did_method_plc, is_valid_did_method_web}; use serde::Deserialize; use crate::record; @@ -30,22 +31,25 @@ struct Answer { /// The parent of `did`, read from its registration on `pds`. /// -/// `None` when the account has no registration record, or when its -/// `lineage` is empty because the operator created it directly. +/// `None` when the account has no registration record, when its `lineage` +/// is empty because the operator created it directly, or when the last +/// entry is not a DID the lexicon would take. pub async fn parent(http: &Http, pds: &str, did: &str) -> Result, String> { let Some(body) = record::get(http, pds, did, COLLECTION, RKEY).await? else { return Ok(None); }; let answer: Answer = serde_json::from_str(&body) .map_err(|err| format!("{pds} did not answer a registration for {did}: {err}"))?; - Ok(answer - .value - .lineage - .into_iter() - .last() - .filter(|parent| parent.starts_with("did:"))) + Ok(answer.value.lineage.into_iter().last().filter(|parent| { + parent.len() <= PARENT_MAX + && (is_valid_did_method_plc(parent) || is_valid_did_method_web(parent, false)) + })) } +/// The lexicon's limit on `parent`, in bytes. The index refuses a whole +/// scrobble whose `parent` is longer. +const PARENT_MAX: usize = 512; + #[cfg(test)] mod tests { use axum::http::StatusCode; @@ -115,6 +119,20 @@ mod tests { assert_eq!(found, None); } + /// A last entry the lexicon would refuse is left off, rather than sent + /// to make the index drop the whole scrobble. + #[tokio::test] + async fn a_parent_that_is_not_a_did_is_left_off() { + let long = format!("did:web:{}.example", "a".repeat(600)); + for bad in ["did:", "did:web:", "did:nope:x", long.as_str()] { + let pds = serve(StatusCode::OK, registration(Some(&[bad]))).await; + let found = parent(&http(), &pds, "did:web:child.pds.example") + .await + .unwrap(); + assert_eq!(found, None, "{bad:?}"); + } + } + /// An account on a server that writes no registration has no parent to /// copy, and that is not an error. #[tokio::test]