lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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:
Mcrates/event/src/draft.rs | 286+++++--------------------------------------------------------------------------
Mcrates/event/src/post.rs | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/event/tests/source_boundary.rs | 24++++++++++++++++++++++++
Mcrates/event_codec/src/authoring/typed.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/tests/post.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++--
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();