commit 85026d8ee06e55df8b3eb1ea03bff5f1f49b5554
parent a3650b8cd8a2c8d8384c15f49dca10d9b841faf8
Author: triesap <tyson@radroots.org>
Date: Wed, 5 Aug 2026 00:06:17 +0000
fix(event): enforce canonical authored photo mapping
- remove legacy typed mapping constructors from the generic event draft
- retain the codec authored-plan conversions as the sole typed mapping authority
- require one exact disjoint URL occurrence for every authored image descriptor
- cover missing repeated duplicate prefix-overlap and multibyte photo content
Diffstat:
5 files changed, 244 insertions(+), 282 deletions(-)
diff --git a/crates/event/src/draft.rs b/crates/event/src/draft.rs
@@ -1,32 +1,21 @@
#![forbid(unsafe_code)]
#[cfg(all(not(feature = "std"), not(test)))]
-use alloc::{borrow::ToOwned, string::String, vec, vec::Vec};
+use alloc::{borrow::ToOwned, string::String, vec::Vec};
#[cfg(any(feature = "std", test))]
-use std::{borrow::ToOwned, string::String, vec, vec::Vec};
+use std::{borrow::ToOwned, string::String, vec::Vec};
use crate::contract::registry_v7::{
- ContractValidationError, EventAuthoringPolicy, EventContract,
- RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION, event_contract, validate_event_contract_parts,
-};
-use crate::envelope::{
- EventEnvelope, EventEnvelopeError, EventKind, EventTags, EventTimestamp, kind::KIND_POST,
+ ContractValidationError, EventContract, RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION,
+ event_contract, validate_event_contract_parts,
};
+use crate::envelope::{EventEnvelope, EventEnvelopeError, EventKind, EventTags, EventTimestamp};
use crate::id::{EventId, EventSignature, ParseError, parse_public_key};
-use crate::post::{
- AuthoredUpdate,
- reply::{AuthoredNip10Reply, Nip10ReplyReference},
-};
use crate::wire::v1::{
CanonicalEventIdError, EventWireError, Nip01EventWire, canonical_nip01_event_id_preimage,
compute_canonical_nip01_event_id,
};
-#[cfg(feature = "serde")]
-use crate::{
- envelope::kind::KIND_PROFILE,
- profile::{AuthoredProfile, RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES},
-};
use core::fmt;
use radroots_identity::PublicKey;
@@ -219,27 +208,6 @@ pub struct EventDraft {
content: String,
expected_pubkey: PublicKey,
expected_event_id: EventId,
- #[cfg_attr(any(feature = "serde", test), serde(skip))]
- typed_authoring: Option<TypedAuthoringKind>,
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-enum TypedAuthoringKind {
- #[cfg(feature = "serde")]
- Profile,
- Update,
- Reply,
-}
-
-impl TypedAuthoringKind {
- const fn contract_id(self) -> &'static str {
- match self {
- #[cfg(feature = "serde")]
- Self::Profile => "radroots.profile.metadata.v1",
- Self::Update => "radroots.social.update.v1",
- Self::Reply => "radroots.social.reply.v1",
- }
- }
}
impl EventDraft {
@@ -290,152 +258,6 @@ impl EventDraft {
content,
expected_pubkey,
expected_event_id,
- typed_authoring: None,
- })
- }
-
- /// Freezes one strict authored root text update for the generic signer SPI.
- ///
- /// Unlike [`Self::new`], this sealed constructor retains proof that wire
- /// parts originated from the event-owned authored type. The proof is not
- /// serialized, so a serialized typed draft cannot be used to recreate
- /// typed-authoring authority.
- pub fn from_authored_update(
- update: &AuthoredUpdate,
- created_at: u64,
- expected_pubkey: impl AsRef<str>,
- ) -> Result<Self, DraftError> {
- Self::from_typed_parts(
- TypedAuthoringKind::Update,
- KIND_POST,
- created_at,
- Vec::new(),
- update.content().to_owned(),
- expected_pubkey,
- )
- }
-
- /// Freezes one strict authored marked NIP-10 reply for the signer SPI.
- pub fn from_authored_reply(
- reply: &AuthoredNip10Reply,
- created_at: u64,
- expected_pubkey: impl AsRef<str>,
- ) -> Result<Self, DraftError> {
- let parent = reply.parent();
- let mut tags = Vec::with_capacity(2 + 2 * usize::from(parent.is_some()));
- tags.push(nip10_event_tag(reply.root(), "root"));
- tags.extend(parent.map(|parent| nip10_event_tag(parent, "reply")));
- tags.push(nip10_public_key_tag(reply.root()));
- tags.extend(
- parent
- .filter(|parent| parent.author() != reply.root().author())
- .map(nip10_public_key_tag),
- );
- Self::from_typed_parts(
- TypedAuthoringKind::Reply,
- KIND_POST,
- created_at,
- tags,
- reply.content().to_owned(),
- expected_pubkey,
- )
- }
-
- /// Freezes one complete strict authored profile replacement for signing.
- #[cfg(feature = "serde")]
- pub fn from_authored_profile(
- profile: &AuthoredProfile,
- created_at: u64,
- expected_pubkey: impl AsRef<str>,
- ) -> Result<Self, DraftError> {
- #[derive(serde::Serialize)]
- struct Metadata<'a> {
- name: &'a str,
- #[serde(skip_serializing_if = "Option::is_none")]
- display_name: Option<&'a str>,
- #[serde(skip_serializing_if = "Option::is_none")]
- about: Option<&'a str>,
- #[serde(skip_serializing_if = "Option::is_none")]
- picture: Option<&'a str>,
- #[serde(skip_serializing_if = "Option::is_none")]
- banner: Option<&'a str>,
- #[serde(skip_serializing_if = "Option::is_none")]
- nip05: Option<&'a str>,
- #[serde(skip_serializing_if = "Option::is_none")]
- bot: Option<bool>,
- }
-
- let metadata = Metadata {
- name: profile.name(),
- display_name: profile.display_name(),
- about: profile.about(),
- picture: profile
- .picture()
- .map(|image| image.descriptor().url().as_str()),
- banner: profile
- .banner()
- .map(|image| image.descriptor().url().as_str()),
- nip05: profile.nip05().map(|identifier| identifier.as_str()),
- bot: profile.bot(),
- };
- let content = serde_json::to_string(&metadata)
- .expect("authored profile metadata contains only infallible JSON scalar types");
- crate::require_invariant(
- content.len() <= RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES,
- &|| {
- DraftError::Envelope(EventEnvelopeError::ContentTooLarge {
- max: RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES,
- actual: content.len(),
- })
- },
- )?;
- Self::from_typed_parts(
- TypedAuthoringKind::Profile,
- KIND_PROFILE,
- created_at,
- Vec::new(),
- content,
- expected_pubkey,
- )
- }
-
- fn from_typed_parts(
- authoring: TypedAuthoringKind,
- kind: u32,
- created_at: u64,
- tags: Vec<Vec<String>>,
- content: String,
- expected_pubkey: impl AsRef<str>,
- ) -> Result<Self, DraftError> {
- let contract = event_contract(authoring.contract_id())
- .ok_or_else(|| DraftError::UnknownContract(authoring.contract_id().to_owned()))?;
- ensure_typed_draft_authorable(contract, authoring)?;
- crate::require_invariant(contract.kind == kind, &|| {
- DraftError::ContractKindMismatch {
- contract_id: contract.id.to_owned(),
- expected_kind: contract.kind,
- actual_kind: kind,
- }
- })?;
- let expected_pubkey = parse_public_key(expected_pubkey.as_ref())?;
- let typed_tags = EventTags::new(tags)?;
- let expected_event_id = compute_nip01_event_id_for_valid_pubkey(
- expected_pubkey.to_hex().as_str(),
- created_at,
- kind,
- &typed_tags.to_vec(),
- &content,
- );
- Ok(Self {
- contract_id: contract.id.to_owned(),
- contract_registry_version: RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION,
- kind: EventKind::new(kind),
- created_at: EventTimestamp::new(created_at),
- tags: typed_tags,
- content,
- expected_pubkey,
- expected_event_id,
- typed_authoring: Some(authoring),
})
}
@@ -471,21 +293,17 @@ impl EventDraft {
actual_kind: self.kind_u32(),
}
})?;
- if let Some(authoring) = self.typed_authoring {
- ensure_typed_draft_authorable(contract, authoring)?;
- } else {
- ensure_generic_draft_authorable(contract)?;
- validate_event_contract_parts(
- self.kind_u32(),
- &self.tags_as_vec(),
- self.content(),
- contract.id,
- )
- .map_err(|error| DraftError::ContractShape {
- contract_id: contract.id.to_owned(),
- error,
- })?;
- }
+ ensure_generic_draft_authorable(contract)?;
+ validate_event_contract_parts(
+ self.kind_u32(),
+ &self.tags_as_vec(),
+ self.content(),
+ contract.id,
+ )
+ .map_err(|error| DraftError::ContractShape {
+ contract_id: contract.id.to_owned(),
+ error,
+ })?;
let expected_pubkey = self.expected_pubkey.to_hex();
let actual_event_id = compute_nip01_event_id_for_valid_pubkey(
expected_pubkey.as_str(),
@@ -571,34 +389,6 @@ fn ensure_generic_draft_authorable(contract: &EventContract) -> Result<(), Draft
})
}
-fn ensure_typed_draft_authorable(
- contract: &EventContract,
- authoring: TypedAuthoringKind,
-) -> Result<(), DraftError> {
- crate::require_invariant(
- [
- contract.id == authoring.contract_id(),
- contract.authoring_policy() == EventAuthoringPolicy::TypedOnly,
- ] == [true; 2],
- &|| DraftError::ContractNotDraftAuthorable {
- contract_id: contract.id.to_owned(),
- },
- )
-}
-
-fn nip10_event_tag(reference: &Nip10ReplyReference, marker: &str) -> Vec<String> {
- vec![
- "e".to_owned(),
- reference.event_id().to_hex(),
- reference.relay_or_empty().to_owned(),
- marker.to_owned(),
- ]
-}
-
-fn nip10_public_key_tag(reference: &Nip10ReplyReference) -> Vec<String> {
- vec!["p".to_owned(), reference.author().to_hex()]
-}
-
#[cfg(any(feature = "serde", test))]
impl<'de> serde::Deserialize<'de> for EventDraft {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
@@ -1208,50 +998,6 @@ mod tests {
}
}
- #[cfg(feature = "serde")]
- #[test]
- fn sealed_typed_drafts_preserve_exact_authored_wire_authority() {
- let author = hex_64('a');
- let update = AuthoredUpdate::new("Harvest update").expect("authored update");
- let update_draft =
- EventDraft::from_authored_update(&update, 7, &author).expect("sealed update draft");
- assert_eq!(update_draft.contract_id(), "radroots.social.update.v1");
- assert_eq!(update_draft.kind_u32(), KIND_POST);
- assert!(update_draft.tags_as_vec().is_empty());
- assert_eq!(update_draft.content(), "Harvest update");
- update_draft.validate_for_signing().expect("update proof");
-
- let root =
- Nip10ReplyReference::parse(hex_64('c'), hex_64('d'), None).expect("root reference");
- let reply = AuthoredNip10Reply::direct("Direct reply", root).expect("authored reply");
- let reply_draft =
- EventDraft::from_authored_reply(&reply, 8, &author).expect("sealed reply draft");
- assert_eq!(reply_draft.contract_id(), "radroots.social.reply.v1");
- assert_eq!(reply_draft.tags_as_vec().len(), 2);
- assert_eq!(reply_draft.tags_as_vec()[0][3], "root");
- reply_draft.validate_for_signing().expect("reply proof");
-
- let profile = AuthoredProfile::new("farm")
- .expect("authored profile")
- .with_display_name("Farm")
- .with_about("Local food")
- .with_bot(false);
- let profile_draft =
- EventDraft::from_authored_profile(&profile, 9, &author).expect("sealed profile draft");
- assert_eq!(profile_draft.contract_id(), "radroots.profile.metadata.v1");
- assert_eq!(profile_draft.kind_u32(), KIND_PROFILE);
- assert_eq!(
- profile_draft.content(),
- r#"{"name":"farm","display_name":"Farm","about":"Local food","bot":false}"#
- );
- profile_draft.validate_for_signing().expect("profile proof");
-
- let serialized = serde_json::to_value(&update_draft).expect("typed draft evidence");
- let error = serde_json::from_value::<EventDraft>(serialized)
- .expect_err("serialized fields cannot recreate typed authority");
- assert!(error.to_string().contains("not authorable"));
- }
-
#[test]
fn draft_deserialization_revalidates_registry_policy_shape_and_event_id() {
let draft = generic_draft();
diff --git a/crates/event/src/post.rs b/crates/event/src/post.rs
@@ -41,7 +41,8 @@ pub enum AuthoredPostError {
ContentTooLarge { max: usize, actual: usize },
ImageMissing,
ImageCountExceeded { max: usize, actual: usize },
- ImageUrlMissingFromContent,
+ ImageUrlOccurrenceCount { expected: usize, actual: usize },
+ ImageUrlOverlap,
DuplicateImageUrl,
ImageMediaTypeInvalid,
ImageSizeInvalid,
@@ -61,7 +62,8 @@ impl AuthoredPostError {
Self::ContentTooLarge { .. } => "post_content_too_large",
Self::ImageMissing => "photo_imeta_missing",
Self::ImageCountExceeded { .. } => "imeta_count_exceeded",
- Self::ImageUrlMissingFromContent => "imeta_url_missing_from_content",
+ Self::ImageUrlOccurrenceCount { .. } => "imeta_url_occurrence_count",
+ Self::ImageUrlOverlap => "imeta_url_overlap",
Self::DuplicateImageUrl => "duplicate_imeta_url",
Self::ImageMediaTypeInvalid => "imeta_mime_invalid",
Self::ImageSizeInvalid => "imeta_size_invalid",
@@ -94,9 +96,13 @@ impl fmt::Display for AuthoredPostError {
Self::ImageCountExceeded { max, actual } => {
write!(formatter, "authored post has {actual} images; max is {max}")
}
- Self::ImageUrlMissingFromContent => {
- formatter.write_str("each authored image URL must occur exactly in post content")
- }
+ Self::ImageUrlOccurrenceCount { expected, actual } => write!(
+ formatter,
+ "authored image URL occurrence count is {actual}; expected {expected}"
+ ),
+ Self::ImageUrlOverlap => formatter.write_str(
+ "authored image URL occurrences must not overlap another image URL occurrence",
+ ),
Self::DuplicateImageUrl => {
formatter.write_str("authored post image URLs must be unique")
}
@@ -380,16 +386,29 @@ fn validate_authored_images(
actual: images.len(),
});
}
+ let mut occurrences = Vec::with_capacity(images.len());
for (index, image) in images.iter().enumerate() {
- if !content.contains(image.url()) {
- return Err(AuthoredPostError::ImageUrlMissingFromContent);
- }
if images[..index]
.iter()
.any(|candidate| candidate.url() == image.url())
{
return Err(AuthoredPostError::DuplicateImageUrl);
}
+ let mut matches = content.match_indices(image.url());
+ let first = matches.next();
+ let actual = usize::from(first.is_some()).saturating_add(matches.count());
+ if actual != 1 {
+ return Err(AuthoredPostError::ImageUrlOccurrenceCount {
+ expected: 1,
+ actual,
+ });
+ }
+ let (start, matched) = first.expect("exactly one occurrence was established");
+ occurrences.push((start, start.saturating_add(matched.len())));
+ }
+ occurrences.sort_unstable();
+ if occurrences.windows(2).any(|pair| pair[0].1 > pair[1].0) {
+ return Err(AuthoredPostError::ImageUrlOverlap);
}
let total_tag_bytes = images.iter().fold(initial_tag_bytes, |total, image| {
total.saturating_add(imeta_tag_bytes(image.imeta_tag()))
@@ -685,7 +704,10 @@ mod tests {
.unwrap();
assert_eq!(
AuthoredPhotoUpdate::new("missing URL", vec![image.clone()]).unwrap_err(),
- AuthoredPostError::ImageUrlMissingFromContent
+ AuthoredPostError::ImageUrlOccurrenceCount {
+ expected: 1,
+ actual: 0,
+ }
);
let content = image.url().to_owned();
assert_eq!(
@@ -695,6 +717,49 @@ mod tests {
}
#[test]
+ fn authored_post_requires_one_non_overlapping_utf8_url_occurrence() {
+ let image = AuthoredPostImage::new(
+ authored_image(b"photo", "image/png", "png", "media.example"),
+ PostImageDimensions::new(1, 1).unwrap(),
+ "photo",
+ )
+ .unwrap();
+ let repeated = format!("{} 🍓 {}", image.url(), image.url());
+ assert_eq!(
+ AuthoredPhotoUpdate::new(repeated, vec![image.clone()]).unwrap_err(),
+ AuthoredPostError::ImageUrlOccurrenceCount {
+ expected: 1,
+ actual: 2,
+ }
+ );
+ assert!(
+ AuthoredPhotoUpdate::new(format!("苗 {} 🍓", image.url()), vec![image]).is_ok(),
+ "UTF-8 surrounding text must not disturb byte-boundary occurrence counting"
+ );
+
+ let bytes = b"shared-prefix";
+ let hash = Sha256::digest(bytes);
+ let short_url = format!("https://media.example/{hash}.webp");
+ let long_url = format!("{short_url}2");
+ let short = AuthoredPostImage::new(
+ authored_image_at_url(bytes, "image/webp", &short_url),
+ PostImageDimensions::new(1, 1).unwrap(),
+ "short",
+ )
+ .unwrap();
+ let long = AuthoredPostImage::new(
+ authored_image_at_url(bytes, "image/webp", &long_url),
+ PostImageDimensions::new(1, 1).unwrap(),
+ "long",
+ )
+ .unwrap();
+ assert_eq!(
+ AuthoredPhotoUpdate::new(long_url, vec![short, long]).unwrap_err(),
+ AuthoredPostError::ImageUrlOverlap
+ );
+ }
+
+ #[test]
fn authored_image_rejects_invalid_descriptor_metadata_and_bounds() {
let dimensions = PostImageDimensions::new(1, 1).unwrap();
let empty = authored_image(b"", "image/png", "png", "media.example");
@@ -796,7 +861,11 @@ mod tests {
AuthoredPostError::ContentTooLarge { max: 1, actual: 2 },
AuthoredPostError::ImageMissing,
AuthoredPostError::ImageCountExceeded { max: 1, actual: 2 },
- AuthoredPostError::ImageUrlMissingFromContent,
+ AuthoredPostError::ImageUrlOccurrenceCount {
+ expected: 1,
+ actual: 0,
+ },
+ AuthoredPostError::ImageUrlOverlap,
AuthoredPostError::DuplicateImageUrl,
AuthoredPostError::ImageMediaTypeInvalid,
AuthoredPostError::ImageSizeInvalid,
@@ -857,6 +926,24 @@ mod tests {
AuthoredImage::try_from(verified_descriptor(bytes, media_type, extension, host)).unwrap()
}
+ fn authored_image_at_url(bytes: &[u8], media_type: &str, url: &str) -> AuthoredImage {
+ let hash = Sha256::digest(bytes);
+ let media_type = MediaType::parse(media_type).unwrap();
+ let descriptor = BlobDescriptor::new(
+ BlobUrl::parse(url).unwrap(),
+ hash,
+ bytes.len() as u64,
+ media_type.clone(),
+ 1_784_347_200,
+ )
+ .unwrap()
+ .approve_reference()
+ .unwrap()
+ .verify_bytes(bytes, &media_type)
+ .unwrap();
+ AuthoredImage::try_from(descriptor).unwrap()
+ }
+
fn verified_descriptor(
bytes: &[u8],
media_type: &str,
diff --git a/crates/event/tests/source_boundary.rs b/crates/event/tests/source_boundary.rs
@@ -137,6 +137,30 @@ fn public_api_has_no_redundant_radroots_type_prefixes() {
);
}
+#[test]
+fn typed_authoring_mapping_is_not_implemented_in_the_generic_event_draft() {
+ let repo_root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .and_then(Path::parent)
+ .expect("lib repo root");
+ let draft = fs::read_to_string(repo_root.join("crates/event/src/draft.rs"))
+ .expect("read generic event draft source");
+
+ for retired in [
+ "from_authored_update",
+ "from_authored_reply",
+ "from_authored_profile",
+ "from_typed_parts",
+ "TypedAuthoringKind",
+ "typed_authoring",
+ ] {
+ assert!(
+ !draft.contains(retired),
+ "generic EventDraft retains codec-owned typed mapping `{retired}`"
+ );
+ }
+}
+
fn source_boundary_guard_files(repo_root: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
for relative_root in ["crates", "contracts"] {
diff --git a/crates/event_codec/src/authoring/typed.rs b/crates/event_codec/src/authoring/typed.rs
@@ -427,6 +427,14 @@ fn validate_post_profile(
return Err("imeta_noncanonical".to_string());
}
}
+ validate_exact_url_occurrences(
+ content,
+ images.iter().map(|tag| {
+ tag[1]
+ .strip_prefix("url ")
+ .expect("canonical imeta establishes a URL field")
+ }),
+ )?;
if tags.len() != images.len() + usize::from(expected == RadrootsPostClassification::Ask) {
return Err("post_extra_tags".to_string());
}
@@ -449,6 +457,29 @@ fn canonical_imeta_tag(tag: &[String]) -> bool {
}
#[cfg(feature = "json")]
+fn validate_exact_url_occurrences<'a>(
+ content: &str,
+ urls: impl IntoIterator<Item = &'a str>,
+) -> Result<(), String> {
+ let mut occurrences = Vec::new();
+ for url in urls {
+ let mut matches = content.match_indices(url);
+ let first = matches.next();
+ let actual = usize::from(first.is_some()).saturating_add(matches.count());
+ if actual != 1 {
+ return Err("imeta_url_occurrence_count".to_string());
+ }
+ let (start, matched) = first.expect("exactly one occurrence was established");
+ occurrences.push((start, start.saturating_add(matched.len())));
+ }
+ occurrences.sort_unstable();
+ if occurrences.windows(2).any(|pair| pair[0].1 > pair[1].0) {
+ return Err("imeta_url_overlap".to_string());
+ }
+ Ok(())
+}
+
+#[cfg(feature = "json")]
fn validate_reply(
created_at: u64,
kind: u32,
@@ -681,3 +712,27 @@ fn decimal_digits(mut value: u64) -> usize {
}
digits
}
+
+#[cfg(all(test, feature = "json"))]
+mod tests {
+ use super::validate_exact_url_occurrences;
+
+ #[test]
+ fn historical_typed_photo_urls_are_exact_disjoint_and_utf8_safe() {
+ let short = "https://media.example/abc";
+ let long = "https://media.example/abc.webp";
+ assert_eq!(
+ validate_exact_url_occurrences("missing", [short]),
+ Err("imeta_url_occurrence_count".to_owned())
+ );
+ assert_eq!(
+ validate_exact_url_occurrences(&format!("{short} then {short}"), [short]),
+ Err("imeta_url_occurrence_count".to_owned())
+ );
+ assert_eq!(
+ validate_exact_url_occurrences(long, [short, long]),
+ Err("imeta_url_overlap".to_owned())
+ );
+ assert!(validate_exact_url_occurrences(&format!("苗 {long} 🍓"), [long]).is_ok());
+ }
+}
diff --git a/crates/event_codec/tests/post.rs b/crates/event_codec/tests/post.rs
@@ -86,7 +86,7 @@ fn authored_ask_precedes_optional_media_with_one_exact_marker() {
}
#[test]
-fn authored_photo_rejects_missing_and_duplicate_content_urls() {
+fn authored_photo_requires_exact_disjoint_content_url_occurrences() {
let image = authored_image(b"leaf", "image/jpeg", "jpg");
assert_eq!(
AuthoredPhotoUpdate::new("photo", Vec::new()).unwrap_err(),
@@ -96,13 +96,40 @@ fn authored_photo_rejects_missing_and_duplicate_content_urls() {
AuthoredPhotoUpdate::new("photo", vec![image.clone()])
.unwrap_err()
.code(),
- "imeta_url_missing_from_content"
+ "imeta_url_occurrence_count"
);
let content = image.url().to_string();
assert_eq!(
AuthoredPhotoUpdate::new(content, vec![image.clone(), image]).unwrap_err(),
AuthoredPostError::DuplicateImageUrl
);
+
+ let repeated = authored_image(b"repeated", "image/jpeg", "jpg");
+ assert_eq!(
+ AuthoredPhotoUpdate::new(
+ format!("{} then {}", repeated.url(), repeated.url()),
+ vec![repeated],
+ )
+ .unwrap_err(),
+ AuthoredPostError::ImageUrlOccurrenceCount {
+ expected: 1,
+ actual: 2,
+ }
+ );
+
+ let unicode = authored_image(b"unicode", "image/webp", "webp");
+ assert!(AuthoredPhotoUpdate::new(format!("収穫 {} 🍓", unicode.url()), vec![unicode]).is_ok());
+
+ let bytes = b"prefix";
+ let hash = Sha256::digest(bytes);
+ let short_url = format!("https://media.example/{hash}.webp");
+ let long_url = format!("{short_url}2");
+ let short = authored_image_with_url(bytes, "image/webp", &short_url);
+ let long = authored_image_with_url(bytes, "image/webp", &long_url);
+ assert_eq!(
+ AuthoredPhotoUpdate::new(long_url, vec![short, long]).unwrap_err(),
+ AuthoredPostError::ImageUrlOverlap
+ );
}
#[test]
@@ -408,6 +435,29 @@ fn authored_image(bytes: &[u8], media_type: &str, extension: &str) -> AuthoredPo
.unwrap()
}
+fn authored_image_with_url(bytes: &[u8], media_type: &str, url: &str) -> AuthoredPostImage {
+ let hash = Sha256::digest(bytes);
+ let media_type = MediaType::parse(media_type).unwrap();
+ let descriptor = BlobDescriptor::new(
+ BlobUrl::parse(url).unwrap(),
+ hash,
+ bytes.len() as u64,
+ media_type.clone(),
+ 1_784_347_200,
+ )
+ .unwrap()
+ .approve_reference()
+ .unwrap()
+ .verify_bytes(bytes, &media_type)
+ .unwrap();
+ AuthoredPostImage::new(
+ AuthoredImage::try_from(descriptor).unwrap(),
+ PostImageDimensions::new(1200, 900).unwrap(),
+ "Harvest",
+ )
+ .unwrap()
+}
+
fn verified_descriptor(bytes: &[u8], media_type: &str, extension: &str) -> ByteVerifiedDescriptor {
let hash = Sha256::digest(bytes);
let media_type = MediaType::parse(media_type).unwrap();