-
Notifications
You must be signed in to change notification settings - Fork 0
feat(evidence): keep embedded image URIs as positional non-lexical units #58
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 9 commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
e60de27
feat(evidence): keep embedded image URIs as positional non-lexical units
seonghobae e98ada4
Merge origin/main into embedded image units
seonghobae eba3412
test(evidence): cover embedded image edge paths
seonghobae 9106e5d
test(evidence): close embedded image coverage paths
seonghobae 8b623a3
Merge remote-tracking branch 'origin/agent/evidence-embedded-image-un…
seonghobae ebd4ebd
fix(evidence): preserve fail-closed image span validation
seonghobae 05870ce
Merge origin/main into PR #58
seonghobae 431561a
fix(evidence): preserve later embedded image boundaries
seonghobae 6a01fef
fix(evidence): refuse implausible embedded-image media types
seonghobae 7ff0469
fix evidence image lexical detection
seonghobae 1c0654f
Merge remote-tracking branch 'origin/main' into agent/evidence-embedd…
seonghobae e351e45
fix(evidence): reject parameterized image data as lexical text
seonghobae a90df98
fix(evidence): normalize image media parameters
seonghobae 7335a64
Merge remote-tracking branch 'origin/main' into fix/pr58
seonghobae 337e14a
merge: reconcile embedded image units with protected main
seonghobae 14febe9
Merge remote-tracking branch 'origin/main' into rebase/58
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| //! Embedded `data:image` units that keep their original source location. | ||
|
|
||
| use crate::{DocumentRecord, EvidenceError, SourceSpan}; | ||
|
|
||
| const DATA_IMAGE_PREFIX: &str = "data:image/"; | ||
| const BASE64_MARK: &str = ";base64,"; | ||
|
|
||
| /// Image media types accepted as plausible by [`embedded_image_units`]. | ||
| /// | ||
| /// The set is deliberately conservative and tracks widely registered or | ||
| /// de facto standard image subtypes; anything else fails closed instead of | ||
| /// yielding a bogus embedded-image unit. | ||
| const PLAUSIBLE_IMAGE_MEDIA_TYPES: [&str; 14] = [ | ||
| "image/apng", | ||
| "image/avif", | ||
| "image/bmp", | ||
| "image/gif", | ||
| "image/heic", | ||
| "image/heif", | ||
| "image/jpeg", | ||
| "image/jpg", | ||
| "image/png", | ||
| "image/svg+xml", | ||
| "image/tiff", | ||
| "image/vnd.microsoft.icon", | ||
| "image/webp", | ||
| "image/x-icon", | ||
| ]; | ||
|
|
||
| /// One embedded image located in a document body. | ||
| #[derive(Clone, Copy, Debug, PartialEq)] | ||
| pub struct EmbeddedImageUnit<'document> { | ||
| span: SourceSpan, | ||
| media_type: &'document str, | ||
| } | ||
|
|
||
| impl<'document> EmbeddedImageUnit<'document> { | ||
| /// Exact source span of the data URI, including the `data:image/` prefix. | ||
| #[must_use] | ||
| pub const fn span(self) -> SourceSpan { | ||
| self.span | ||
| } | ||
|
|
||
| /// Declared image media type (`image/png`, `image/jpeg`, …). | ||
| #[must_use] | ||
| pub const fn media_type(self) -> &'document str { | ||
| self.media_type | ||
| } | ||
| } | ||
|
|
||
| /// Locate `data:image/<type>;base64,...` units and retain their original spans. | ||
| /// | ||
| /// Only plausible image media types are accepted: a candidate URI whose | ||
| /// declared media type is not in [`PLAUSIBLE_IMAGE_MEDIA_TYPES`] fails the | ||
| /// whole parse so malformed bodies cannot produce bogus units. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`EvidenceError::EmptySourceSpan`] when the document contains no | ||
| /// well-formed embedded image URI, and | ||
| /// [`EvidenceError::ImplausibleImageMediaType`] when a candidate URI | ||
| /// declares an implausible image media type. | ||
| pub fn embedded_image_units( | ||
| document: &DocumentRecord, | ||
| ) -> Result<Vec<EmbeddedImageUnit<'_>>, EvidenceError> { | ||
| let text = document.text(); | ||
| let mut units = Vec::new(); | ||
| let mut search_from = 0usize; | ||
| while let Some(relative) = text[search_from..].find(DATA_IMAGE_PREFIX) { | ||
| let start = search_from + relative; | ||
| let after_prefix = start + DATA_IMAGE_PREFIX.len(); | ||
| let Some(mark_rel) = text[after_prefix..].find(BASE64_MARK) else { | ||
| search_from = after_prefix; | ||
| continue; | ||
| }; | ||
| let media_end = after_prefix + mark_rel; | ||
| let payload_start = media_end + BASE64_MARK.len(); | ||
| let payload_end = payload_start | ||
| + text[payload_start..] | ||
| .find(|ch: char| !is_base64_payload_char(ch)) | ||
| .unwrap_or(text.len() - payload_start); | ||
| if payload_end == payload_start { | ||
| search_from = payload_start; | ||
| continue; | ||
| } | ||
| let media_type = &text[start + "data:".len()..media_end]; | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
| if media_type.contains(DATA_IMAGE_PREFIX) { | ||
| search_from = after_prefix; | ||
| continue; | ||
| } | ||
|
seonghobae marked this conversation as resolved.
|
||
| if !is_plausible_image_media_type(media_type) { | ||
| return Err(EvidenceError::ImplausibleImageMediaType); | ||
| } | ||
| let scalar_start = text[..start].chars().count(); | ||
| let scalar_end = scalar_start + text[start..payload_end].chars().count(); | ||
| let span = SourceSpan::new(document, start, payload_end, scalar_start, scalar_end, None)?; | ||
| units.push(EmbeddedImageUnit { span, media_type }); | ||
|
seonghobae marked this conversation as resolved.
Outdated
|
||
| search_from = payload_end; | ||
| } | ||
| if units.is_empty() { | ||
| return Err(EvidenceError::EmptySourceSpan); | ||
|
seonghobae marked this conversation as resolved.
|
||
| } | ||
| Ok(units) | ||
| } | ||
|
|
||
| /// Refuse using a document body that still contains an embedded image as | ||
| /// lexical inference text. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`EvidenceError::InvalidWirePayload`] for empty input and | ||
| /// [`EvidenceError::EmbeddedImageIsNotLexicalText`] when a `data:image` | ||
| /// base64 URI is present. | ||
| pub fn refuse_base64_image_as_lexical_text(text: &str) -> Result<(), EvidenceError> { | ||
| if text.is_empty() { | ||
| return Err(EvidenceError::InvalidWirePayload); | ||
| } | ||
| if text.contains(DATA_IMAGE_PREFIX) && text.contains(BASE64_MARK) { | ||
| return Err(EvidenceError::EmbeddedImageIsNotLexicalText); | ||
|
seonghobae marked this conversation as resolved.
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| fn is_base64_payload_char(ch: char) -> bool { | ||
| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=') | ||
| } | ||
|
|
||
| /// Report whether a declared media type is a plausible image media type. | ||
| fn is_plausible_image_media_type(media_type: &str) -> bool { | ||
| PLAUSIBLE_IMAGE_MEDIA_TYPES.contains(&media_type) | ||
| } | ||
|
seonghobae marked this conversation as resolved.
|
||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::{embedded_image_units, refuse_base64_image_as_lexical_text}; | ||
| use crate::{DocumentRecord, EvidenceError, SourceArtifact}; | ||
|
|
||
| #[test] | ||
| fn jpeg_uri_and_incomplete_prefix_are_classified() { | ||
| let text = "x data:image/jpeg;base64,/9j/4AA= y data:image/gif y"; | ||
| let artifact = SourceArtifact::from_bytes(text.as_bytes()).expect("artifact"); | ||
| let document = DocumentRecord::from_text(artifact.id(), text).expect("document"); | ||
| let units = embedded_image_units(&document).expect("jpeg"); | ||
| assert_eq!(units.len(), 1); | ||
| assert_eq!(units[0].media_type(), "image/jpeg"); | ||
| refuse_base64_image_as_lexical_text("plain note").expect("plain"); | ||
| refuse_base64_image_as_lexical_text("data:image/png").expect("incomplete image"); | ||
| assert_eq!( | ||
| refuse_base64_image_as_lexical_text("data:image/png;base64,AAAA"), | ||
| Err(EvidenceError::EmbeddedImageIsNotLexicalText) | ||
| ); | ||
|
|
||
| let empty_text = "data:image/png;base64, following text"; | ||
| let empty_artifact = SourceArtifact::from_bytes(empty_text.as_bytes()).expect("artifact"); | ||
| let empty_document = | ||
| DocumentRecord::from_text(empty_artifact.id(), empty_text).expect("document"); | ||
| assert_eq!( | ||
| embedded_image_units(&empty_document), | ||
| Err(EvidenceError::EmptySourceSpan) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn implausible_media_types_fail_closed() { | ||
| let text = "data:image/not-a-type;base64,AAAA"; | ||
| let artifact = SourceArtifact::from_bytes(text.as_bytes()).expect("artifact"); | ||
| let document = DocumentRecord::from_text(artifact.id(), text).expect("document"); | ||
| assert_eq!( | ||
| embedded_image_units(&document), | ||
| Err(EvidenceError::ImplausibleImageMediaType) | ||
| ); | ||
| assert_eq!( | ||
| refuse_base64_image_as_lexical_text(text), | ||
| Err(EvidenceError::EmbeddedImageIsNotLexicalText) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn common_raster_media_types_are_accepted() { | ||
| let text = "a data:image/png;base64,AAAA b data:image/jpeg;base64,BBBB \ | ||
| c data:image/webp;base64,CCCC d data:image/gif;base64,DDDD e"; | ||
| let artifact = SourceArtifact::from_bytes(text.as_bytes()).expect("artifact"); | ||
| let document = DocumentRecord::from_text(artifact.id(), text).expect("document"); | ||
| let units = embedded_image_units(&document).expect("units"); | ||
| let media_types: Vec<&str> = units.iter().map(|unit| unit.media_type()).collect(); | ||
| assert_eq!( | ||
| media_types, | ||
| vec!["image/png", "image/jpeg", "image/webp", "image/gif"] | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn empty_payload_is_not_an_image_unit() { | ||
| let text = "data:image/png;base64,"; | ||
| let artifact = SourceArtifact::from_bytes(text.as_bytes()).expect("artifact"); | ||
| let document = DocumentRecord::from_text(artifact.id(), text).expect("document"); | ||
| assert_eq!( | ||
| embedded_image_units(&document), | ||
| Err(EvidenceError::EmptySourceSpan) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn malformed_image_prefix_does_not_swallow_later_valid_image() { | ||
| let text = "data:image/gif then data:image/png;base64,AAAA"; | ||
| let artifact = SourceArtifact::from_bytes(text.as_bytes()).expect("artifact"); | ||
| let document = DocumentRecord::from_text(artifact.id(), text).expect("document"); | ||
|
|
||
| let units = embedded_image_units(&document).expect("png"); | ||
| assert_eq!(units.len(), 1); | ||
| assert_eq!(units[0].media_type(), "image/png"); | ||
| assert_eq!( | ||
| units[0].span().byte_start(), | ||
| text.find("data:image/png").expect("png start") | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| //! Embedded base64 images keep their original location and are not lexical text. | ||
|
|
||
| use evidence_core::{ | ||
| DocumentRecord, EvidenceError, SourceArtifact, embedded_image_units, | ||
| refuse_base64_image_as_lexical_text, | ||
| }; | ||
|
|
||
| #[test] | ||
| fn data_uri_recovers_exact_span_and_media_type() { | ||
| let uri = "data:image/png;base64,iVBORw0KGgo="; | ||
| let text = format!("Before the figure.\n\n{uri}\n\nAfter the figure. data:image/gif y"); | ||
| let artifact = SourceArtifact::from_bytes(text.as_bytes()).expect("artifact"); | ||
| let document = DocumentRecord::from_text(artifact.id(), &text).expect("document"); | ||
|
|
||
| let units = embedded_image_units(&document).expect("units"); | ||
| assert_eq!(units.len(), 1); | ||
| assert_eq!(units[0].media_type(), "image/png"); | ||
| assert_eq!( | ||
| &document.text()[units[0].span().byte_start()..units[0].span().byte_end()], | ||
| uri | ||
| ); | ||
| assert_eq!( | ||
| refuse_base64_image_as_lexical_text(document.text()), | ||
| Err(EvidenceError::EmbeddedImageIsNotLexicalText) | ||
| ); | ||
| refuse_base64_image_as_lexical_text("data:image/png").expect("incomplete image"); | ||
| refuse_base64_image_as_lexical_text("Before the figure.").expect("plain text"); | ||
| refuse_base64_image_as_lexical_text("data:image/gif y").expect("incomplete image marker"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn implausible_media_types_fail_closed_and_common_types_are_accepted() { | ||
| let malformed = "data:image/not-a-type;base64,AAAA"; | ||
| let artifact = SourceArtifact::from_bytes(malformed.as_bytes()).expect("artifact"); | ||
| let document = DocumentRecord::from_text(artifact.id(), malformed).expect("document"); | ||
| assert_eq!( | ||
| embedded_image_units(&document), | ||
| Err(EvidenceError::ImplausibleImageMediaType) | ||
| ); | ||
|
|
||
| let text = "a data:image/png;base64,AAAA b data:image/jpeg;base64,BBBB \ | ||
| c data:image/webp;base64,CCCC d data:image/gif;base64,DDDD e"; | ||
| let artifact = SourceArtifact::from_bytes(text.as_bytes()).expect("artifact"); | ||
| let document = DocumentRecord::from_text(artifact.id(), text).expect("document"); | ||
| let units = embedded_image_units(&document).expect("units"); | ||
| let media_types: Vec<&str> = units.iter().map(|unit| unit.media_type()).collect(); | ||
| assert_eq!( | ||
| media_types, | ||
| vec!["image/png", "image/jpeg", "image/webp", "image/gif"] | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn documents_without_images_and_empty_payloads_fail_closed() { | ||
| let text = "No figures in this note."; | ||
| let artifact = SourceArtifact::from_bytes(text.as_bytes()).expect("artifact"); | ||
| let document = DocumentRecord::from_text(artifact.id(), text).expect("document"); | ||
| assert_eq!( | ||
| embedded_image_units(&document), | ||
| Err(EvidenceError::EmptySourceSpan) | ||
| ); | ||
|
|
||
| assert_eq!( | ||
| refuse_base64_image_as_lexical_text(""), | ||
| Err(EvidenceError::InvalidWirePayload) | ||
| ); | ||
| let empty_payload = "data:image/png;base64,"; | ||
| let empty_artifact = SourceArtifact::from_bytes(empty_payload.as_bytes()).expect("artifact"); | ||
| let empty_document = | ||
| DocumentRecord::from_text(empty_artifact.id(), empty_payload).expect("document"); | ||
| assert_eq!( | ||
| embedded_image_units(&empty_document), | ||
| Err(EvidenceError::EmptySourceSpan) | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.