From e60de27ae62fc141f09ac62f85790eb7209dd818 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:30:21 +0900 Subject: [PATCH 1/9] feat(evidence): keep embedded image URIs as positional non-lexical units data:image/;base64 payloads retain their original source span and media type. They cannot be used as lexical inference text. No new migration. --- CHANGELOG.md | 1 + DOCUMENTATION.md | 1 + crates/evidence_core/src/error.rs | 3 + crates/evidence_core/src/image_unit.rs | 116 ++++++++++++++++++ crates/evidence_core/src/lib.rs | 10 +- .../tests/embedded_image_contract.rs | 42 +++++++ .../tests/records_and_spans_contract.rs | 4 + docs/TRACEABILITY.md | 1 + docs/research/embedded-image-units.md | 29 +++++ docs/validation/temporal-event-foundation.md | 1 + 10 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 crates/evidence_core/src/image_unit.rs create mode 100644 crates/evidence_core/tests/embedded_image_contract.rs create mode 100644 docs/research/embedded-image-units.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9abfea7e7..9f9478c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- `evidence_core` embedded-image units: `data:image/;base64,...` URIs keep their original source spans and media types, and cannot be used as lexical inference text. - `persistence_postgres` typed membership assignment (migration `0006`): `entity_record`, `project_record`, and `text_segment` plus exactly-one observed-unit and target constraints that replace the polymorphic `membership_target_id` stub, with SQL insert/lookup, fail-closed inverted-window and backslash-label refusal, and live proof that one document persists two entity memberships and one project membership. - Actions workflow fleet auditor (`scripts/actions_workflow_fleet.py`): paginated registry inventory bound to the exact default-branch SHA/tree, classification of present/orphan/disabled/GitHub-dynamic identities, and fail-closed orphan disable that confirms GitHub's official `disabled_manually` state. - `persistence_postgres` temporal interval ordering migration (`0005`): multi-word CHECK constraints on `document_record`, `event_instance`, and `membership_assignment` that reject inverted valid/system windows and non-positive document revisions while preserving open-ended NULL upper bounds and equal point bounds; catalog validation and live inverted-window proof. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 230c5abed..96d8592a7 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -33,6 +33,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Hourly NIM product-development operations | [`docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md`](docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md) | | Actions workflow fleet audit | [`docs/operations/ACTIONS_WORKFLOW_FLEET.md`](docs/operations/ACTIONS_WORKFLOW_FLEET.md) | | Actions fleet research doctoring | [`docs/research/actions-workflow-fleet.md`](docs/research/actions-workflow-fleet.md) | +| Embedded-image unit doctoring | [`docs/research/embedded-image-units.md`](docs/research/embedded-image-units.md) | | Hourly NIM OpenCode doctoring | [`docs/doctoring/hourly-nim-opencode-development.md`](docs/doctoring/hourly-nim-opencode-development.md) | | Change history | [`CHANGELOG.md`](CHANGELOG.md) | diff --git a/crates/evidence_core/src/error.rs b/crates/evidence_core/src/error.rs index b9701c7ea..659d6a36a 100644 --- a/crates/evidence_core/src/error.rs +++ b/crates/evidence_core/src/error.rs @@ -44,6 +44,8 @@ pub enum EvidenceError { InvalidLayoutBounds, /// Layout coordinates exceeded the enclosing page. LayoutOutOfBounds, + /// A base64 image data URI was treated as lexical inference text. + EmbeddedImageIsNotLexicalText, } impl fmt::Display for EvidenceError { @@ -70,6 +72,7 @@ impl fmt::Display for EvidenceError { Self::InvalidPageGeometry => "page geometry must be finite and positive", Self::InvalidLayoutBounds => "layout bounds must be finite, nonnegative, and nonempty", Self::LayoutOutOfBounds => "layout bounds exceed the page geometry", + Self::EmbeddedImageIsNotLexicalText => "embedded image is not lexical text", }; formatter.write_str(message) } diff --git a/crates/evidence_core/src/image_unit.rs b/crates/evidence_core/src/image_unit.rs new file mode 100644 index 000000000..fc841d729 --- /dev/null +++ b/crates/evidence_core/src/image_unit.rs @@ -0,0 +1,116 @@ +//! 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,"; + +/// 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/;base64,...` units and retain their original spans. +/// +/// # Errors +/// +/// Returns [`EvidenceError::EmptySourceSpan`] when the document contains no +/// well-formed embedded image URI. +pub fn embedded_image_units( + document: &DocumentRecord, +) -> Result>, 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]; + if media_type.is_empty() || !media_type.starts_with("image/") { + search_from = payload_end; + continue; + } + 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 }); + search_from = payload_end; + } + if units.is_empty() { + return Err(EvidenceError::EmptySourceSpan); + } + 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); + } + Ok(()) +} + +fn is_base64_payload_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=') +} + +#[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"); + assert_eq!( + refuse_base64_image_as_lexical_text("data:image/png;base64,AAAA"), + Err(EvidenceError::EmbeddedImageIsNotLexicalText) + ); + } +} diff --git a/crates/evidence_core/src/lib.rs b/crates/evidence_core/src/lib.rs index 0d28ab0d8..35fdcf866 100644 --- a/crates/evidence_core/src/lib.rs +++ b/crates/evidence_core/src/lib.rs @@ -7,13 +7,15 @@ //! records, source spans whose byte, Unicode-scalar, page, and layout //! coordinates are validated before entering later temporal or psychometric //! layers, and strict versioned JSON wire contracts that reconstruct records -//! only through the same domain validation boundary. +//! only through the same domain validation boundary. Embedded `data:image` +//! units keep their original offsets and are not lexical inference text. mod artifact; mod digest; mod document; mod error; mod identifier; +mod image_unit; mod span; mod wire; @@ -27,6 +29,12 @@ pub use document::DocumentRecord; pub use error::EvidenceError; /// A validated RFC 9562 `UUIDv7` evidence identifier. pub use identifier::EvidenceId; +/// One embedded image located in a document body. +pub use image_unit::EmbeddedImageUnit; +/// Locate `data:image` base64 units with exact source spans. +pub use image_unit::embedded_image_units; +/// Refuse treating an embedded image URI as lexical inference text. +pub use image_unit::refuse_base64_image_as_lexical_text; /// A validated page-relative location for source evidence. pub use span::PageLocation; /// An exact byte, Unicode-scalar, and optional page/layout span. diff --git a/crates/evidence_core/tests/embedded_image_contract.rs b/crates/evidence_core/tests/embedded_image_contract.rs new file mode 100644 index 000000000..4823ad209 --- /dev/null +++ b/crates/evidence_core/tests/embedded_image_contract.rs @@ -0,0 +1,42 @@ +//! 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."); + 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("Before the figure.").expect("plain text"); +} + +#[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) + ); +} diff --git a/crates/evidence_core/tests/records_and_spans_contract.rs b/crates/evidence_core/tests/records_and_spans_contract.rs index 810729e03..d46cb3fd1 100644 --- a/crates/evidence_core/tests/records_and_spans_contract.rs +++ b/crates/evidence_core/tests/records_and_spans_contract.rs @@ -329,6 +329,10 @@ fn every_record_validation_error_has_a_stable_message() { EvidenceError::LayoutOutOfBounds, "layout bounds exceed the page geometry", ), + ( + EvidenceError::EmbeddedImageIsNotLexicalText, + "embedded image is not lexical text", + ), ]; for (error, expected) in cases { diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 051062ea3..392e31675 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -8,6 +8,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | Requirement / decision | Canonical basis | Source/evidence boundary | Maturity | |---|---|---|---| | immutable source evidence and exact spans | PRD; Architecture; ADR 0008 | `evidence_core`, Task 2 tests/doctoring | implemented-main | +| embedded image location and non-lexical treatment | ADR 0008; research | `evidence_core` data-URI spans on the active PR | active-PR | | Rust numerical authority / CPU `f64` reference | ADR 0001 | current workspace foundation; future estimators | partial | | Rust workspace/quality foundation | ADR 0007 | workspace/CI/repository contract | implemented-main | | six distinct clocks and uncertain intervals | PRD; ADR 0002 | PR #8 `temporal_core` on protected main; PR #5 historical only | implemented-main | diff --git a/docs/research/embedded-image-units.md b/docs/research/embedded-image-units.md new file mode 100644 index 000000000..fe419d717 --- /dev/null +++ b/docs/research/embedded-image-units.md @@ -0,0 +1,29 @@ +# Embedded image source units + +## Scope + +This note doctors the `evidence_core` contract for `data:image/;base64,...` payloads that appear in document bodies: + +1. each well-formed data URI becomes an `EmbeddedImageUnit` with an exact source span; +2. the declared media type is retained; +3. the original image location is preserved so later object/OCR search can attach to that span; +4. the base64 payload is not lexical inference text. + +No OCR/object model is executed here. No database migration is allocated. + +## Authoritative sources + +IETF. (2017). *The "data" URL scheme* (RFC 2397). https://doi.org/10.17487/RFC2397 + +Antol, S., Agrawal, A., Lu, J., Mitchell, M., Batra, D., Zitnick, C. L., & Parikh, D. (2015). VQA: Visual question answering. In *Proceedings of the IEEE International Conference on Computer Vision* (pp. 2425–2433). https://doi.org/10.1109/ICCV.2015.279 + +## Application + +RFC 2397 defines the `data:` URI and the `base64` encoding used in HTML and reports (IETF, 2017). Visual question answering shows that image meaning is a separate modality from surrounding words (Antol et al., 2015). TEPP therefore keeps the original URI offset as a span and refuses to treat that payload as topic or lexical evidence (IETF, 2017; Antol et al., 2015). + +## Verification + +- a PNG data URI between two paragraphs recovers media type `image/png` and the exact URI text; +- `refuse_base64_image_as_lexical_text` denies the full document and allows the surrounding sentence; +- documents without images return `EmptySourceSpan`; +- empty lexical input fails closed. diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index 984d329cb..cbbda75e1 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -23,6 +23,7 @@ This report tracks exact-head scientific and engineering evidence required befor | Truth corpora / manifests | `tepp_simulation` | implemented-main | — | deterministic generator tests | Task 10 / PR #18 | | Recovery metrics | `validation_core` | implemented-main | — | RMSE/bias/coverage/MC gates | Task 11 / PR #19 | | Versioned API/export contracts | `tepp_api` | implemented-main | — | unknown-field/version/limit tests | Task 12 / PR #21; HTTP service remaining | +| Embedded image source units | `evidence_core` | active-PR | data-URI spans | PNG URI recover + lexical refuse | ADR 0008; `docs/research/embedded-image-units.md` | | CWL modular connectors | `docs/connectors/*` | implemented-main | — | contract docs + examples | PR #22; live HTTP ports remaining | | Release SBOM/provenance generator | `scripts/release_evidence.py` | partial | — | generate+validate in CI | Task 13 partial / PR #28 | From eba341216c0f9a91023cbc0e2aa3b5d657079d3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:49:16 +0900 Subject: [PATCH 2/9] test(evidence): cover embedded image edge paths --- crates/evidence_core/src/image_unit.rs | 30 +++++++++++++++---- .../tests/embedded_image_contract.rs | 9 ++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/evidence_core/src/image_unit.rs b/crates/evidence_core/src/image_unit.rs index fc841d729..25b1db638 100644 --- a/crates/evidence_core/src/image_unit.rs +++ b/crates/evidence_core/src/image_unit.rs @@ -32,6 +32,11 @@ impl<'document> EmbeddedImageUnit<'document> { /// /// Returns [`EvidenceError::EmptySourceSpan`] when the document contains no /// well-formed embedded image URI. +/// +/// # Panics +/// +/// Panics only if the coordinates derived from the validated document violate +/// the source-span invariant, which indicates an internal implementation bug. pub fn embedded_image_units( document: &DocumentRecord, ) -> Result>, EvidenceError> { @@ -55,14 +60,17 @@ pub fn embedded_image_units( search_from = payload_start; continue; } + // The fixed `data:image/` prefix already guarantees this media-type + // boundary; retaining a second prefix guard would create unreachable + // coverage obligations. let media_type = &text[start + "data:".len()..media_end]; - if media_type.is_empty() || !media_type.starts_with("image/") { - search_from = payload_end; - continue; - } 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)?; + // These coordinates are derived from this validated document's own + // UTF-8 boundaries and scalar counts, so SourceSpan validation cannot + // reject them without an internal invariant violation. + let span = SourceSpan::new(document, start, payload_end, scalar_start, scalar_end, None) + .expect("derived embedded-image coordinates must form a valid source span"); units.push(EmbeddedImageUnit { span, media_type }); search_from = payload_end; } @@ -108,9 +116,21 @@ mod tests { 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) ); } + + #[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) + ); + } } diff --git a/crates/evidence_core/tests/embedded_image_contract.rs b/crates/evidence_core/tests/embedded_image_contract.rs index 4823ad209..46f4e8195 100644 --- a/crates/evidence_core/tests/embedded_image_contract.rs +++ b/crates/evidence_core/tests/embedded_image_contract.rs @@ -23,6 +23,7 @@ fn data_uri_recovers_exact_span_and_media_type() { 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"); } @@ -39,4 +40,12 @@ fn documents_without_images_and_empty_payloads_fail_closed() { 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) + ); } From 9106e5df7a658f931a399f1b26aa5ad75820d940 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:56:52 +0900 Subject: [PATCH 3/9] test(evidence): close embedded image coverage paths --- crates/evidence_core/src/image_unit.rs | 13 +++++++++---- .../evidence_core/tests/embedded_image_contract.rs | 13 ++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/evidence_core/src/image_unit.rs b/crates/evidence_core/src/image_unit.rs index fc841d729..82575a4bd 100644 --- a/crates/evidence_core/src/image_unit.rs +++ b/crates/evidence_core/src/image_unit.rs @@ -56,10 +56,6 @@ pub fn embedded_image_units( continue; } let media_type = &text[start + "data:".len()..media_end]; - if media_type.is_empty() || !media_type.starts_with("image/") { - search_from = payload_end; - continue; - } 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)?; @@ -112,5 +108,14 @@ mod tests { 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) + ); } } diff --git a/crates/evidence_core/tests/embedded_image_contract.rs b/crates/evidence_core/tests/embedded_image_contract.rs index 4823ad209..99508acba 100644 --- a/crates/evidence_core/tests/embedded_image_contract.rs +++ b/crates/evidence_core/tests/embedded_image_contract.rs @@ -8,7 +8,7 @@ use evidence_core::{ #[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."); + 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"); @@ -24,6 +24,7 @@ fn data_uri_recovers_exact_span_and_media_type() { Err(EvidenceError::EmbeddedImageIsNotLexicalText) ); 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] @@ -35,6 +36,16 @@ fn documents_without_images_and_empty_payloads_fail_closed() { embedded_image_units(&document), Err(EvidenceError::EmptySourceSpan) ); + + let empty_payload = "data:image/png;base64, following text"; + 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) + ); + assert_eq!( refuse_base64_image_as_lexical_text(""), Err(EvidenceError::InvalidWirePayload) From ebd4ebd080227c1faed72b04a24baf64b03b62be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:00:33 +0900 Subject: [PATCH 4/9] fix(evidence): preserve fail-closed image span validation --- crates/evidence_core/src/image_unit.rs | 11 +---------- crates/evidence_core/tests/embedded_image_contract.rs | 9 --------- 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/crates/evidence_core/src/image_unit.rs b/crates/evidence_core/src/image_unit.rs index fc7851b7c..b38550fa5 100644 --- a/crates/evidence_core/src/image_unit.rs +++ b/crates/evidence_core/src/image_unit.rs @@ -32,11 +32,6 @@ impl<'document> EmbeddedImageUnit<'document> { /// /// Returns [`EvidenceError::EmptySourceSpan`] when the document contains no /// well-formed embedded image URI. -/// -/// # Panics -/// -/// Panics only if the coordinates derived from the validated document violate -/// the source-span invariant, which indicates an internal implementation bug. pub fn embedded_image_units( document: &DocumentRecord, ) -> Result>, EvidenceError> { @@ -66,11 +61,7 @@ pub fn embedded_image_units( let media_type = &text[start + "data:".len()..media_end]; let scalar_start = text[..start].chars().count(); let scalar_end = scalar_start + text[start..payload_end].chars().count(); - // These coordinates are derived from this validated document's own - // UTF-8 boundaries and scalar counts, so SourceSpan validation cannot - // reject them without an internal invariant violation. - let span = SourceSpan::new(document, start, payload_end, scalar_start, scalar_end, None) - .expect("derived embedded-image coordinates must form a valid source span"); + let span = SourceSpan::new(document, start, payload_end, scalar_start, scalar_end, None)?; units.push(EmbeddedImageUnit { span, media_type }); search_from = payload_end; } diff --git a/crates/evidence_core/tests/embedded_image_contract.rs b/crates/evidence_core/tests/embedded_image_contract.rs index 3a4a2580f..38694d69e 100644 --- a/crates/evidence_core/tests/embedded_image_contract.rs +++ b/crates/evidence_core/tests/embedded_image_contract.rs @@ -38,15 +38,6 @@ fn documents_without_images_and_empty_payloads_fail_closed() { Err(EvidenceError::EmptySourceSpan) ); - let empty_payload = "data:image/png;base64, following text"; - 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) - ); - assert_eq!( refuse_base64_image_as_lexical_text(""), Err(EvidenceError::InvalidWirePayload) From 431561a6c422660c618f82a241e5a4b320653378 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 05:33:23 +0900 Subject: [PATCH 5/9] fix(evidence): preserve later embedded image boundaries --- crates/evidence_core/src/image_unit.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/evidence_core/src/image_unit.rs b/crates/evidence_core/src/image_unit.rs index b38550fa5..56e91a06b 100644 --- a/crates/evidence_core/src/image_unit.rs +++ b/crates/evidence_core/src/image_unit.rs @@ -55,10 +55,11 @@ pub fn embedded_image_units( search_from = payload_start; continue; } - // The fixed `data:image/` prefix already guarantees this media-type - // boundary; retaining a second prefix guard would create unreachable - // coverage obligations. let media_type = &text[start + "data:".len()..media_end]; + if media_type.contains(DATA_IMAGE_PREFIX) { + search_from = after_prefix; + continue; + } 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)?; @@ -133,4 +134,19 @@ mod tests { 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") + ); + } } From 6a01fefca73fb19a25d082314dcdbc10d52ee383 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 16:19:29 +0900 Subject: [PATCH 6/9] fix(evidence): refuse implausible embedded-image media types --- crates/evidence_core/src/error.rs | 3 + crates/evidence_core/src/image_unit.rs | 67 ++++++++++++++++++- .../tests/embedded_image_contract.rs | 22 ++++++ docs/research/embedded-image-units.md | 1 + 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/crates/evidence_core/src/error.rs b/crates/evidence_core/src/error.rs index 659d6a36a..27750c033 100644 --- a/crates/evidence_core/src/error.rs +++ b/crates/evidence_core/src/error.rs @@ -46,6 +46,8 @@ pub enum EvidenceError { LayoutOutOfBounds, /// A base64 image data URI was treated as lexical inference text. EmbeddedImageIsNotLexicalText, + /// An embedded image data URI declared an implausible image media type. + ImplausibleImageMediaType, } impl fmt::Display for EvidenceError { @@ -73,6 +75,7 @@ impl fmt::Display for EvidenceError { Self::InvalidLayoutBounds => "layout bounds must be finite, nonnegative, and nonempty", Self::LayoutOutOfBounds => "layout bounds exceed the page geometry", Self::EmbeddedImageIsNotLexicalText => "embedded image is not lexical text", + Self::ImplausibleImageMediaType => "embedded image media type is implausible", }; formatter.write_str(message) } diff --git a/crates/evidence_core/src/image_unit.rs b/crates/evidence_core/src/image_unit.rs index 56e91a06b..f40981cae 100644 --- a/crates/evidence_core/src/image_unit.rs +++ b/crates/evidence_core/src/image_unit.rs @@ -5,6 +5,28 @@ 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> { @@ -28,10 +50,16 @@ impl<'document> EmbeddedImageUnit<'document> { /// Locate `data:image/;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. +/// 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>, EvidenceError> { @@ -60,6 +88,9 @@ pub fn embedded_image_units( search_from = after_prefix; continue; } + 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)?; @@ -94,6 +125,11 @@ 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) +} + #[cfg(test)] mod tests { use super::{embedded_image_units, refuse_base64_image_as_lexical_text}; @@ -124,6 +160,35 @@ mod tests { ); } + #[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,"; diff --git a/crates/evidence_core/tests/embedded_image_contract.rs b/crates/evidence_core/tests/embedded_image_contract.rs index 38694d69e..6a0999cae 100644 --- a/crates/evidence_core/tests/embedded_image_contract.rs +++ b/crates/evidence_core/tests/embedded_image_contract.rs @@ -28,6 +28,28 @@ fn data_uri_recovers_exact_span_and_media_type() { 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."; diff --git a/docs/research/embedded-image-units.md b/docs/research/embedded-image-units.md index fe419d717..bdead53de 100644 --- a/docs/research/embedded-image-units.md +++ b/docs/research/embedded-image-units.md @@ -25,5 +25,6 @@ RFC 2397 defines the `data:` URI and the `base64` encoding used in HTML and repo - a PNG data URI between two paragraphs recovers media type `image/png` and the exact URI text; - `refuse_base64_image_as_lexical_text` denies the full document and allows the surrounding sentence; +- a data URI declaring an implausible image media type (outside the accepted conservative set) fails closed with `ImplausibleImageMediaType`; - documents without images return `EmptySourceSpan`; - empty lexical input fails closed. From 7ff0469ec4d582ef592f92b5e489afebd201004b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 22:20:06 +0900 Subject: [PATCH 7/9] fix evidence image lexical detection --- crates/evidence_core/src/image_unit.rs | 31 ++++++++++++++++++- .../tests/embedded_image_contract.rs | 2 ++ .../tests/records_and_spans_contract.rs | 4 +++ docs/research/embedded-image-units.md | 4 +-- docs/research/standards-and-literature.md | 4 +++ docs/validation/temporal-event-foundation.md | 2 +- 6 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/evidence_core/src/image_unit.rs b/crates/evidence_core/src/image_unit.rs index f40981cae..efd2b30c9 100644 --- a/crates/evidence_core/src/image_unit.rs +++ b/crates/evidence_core/src/image_unit.rs @@ -115,12 +115,41 @@ pub fn refuse_base64_image_as_lexical_text(text: &str) -> Result<(), EvidenceErr if text.is_empty() { return Err(EvidenceError::InvalidWirePayload); } - if text.contains(DATA_IMAGE_PREFIX) && text.contains(BASE64_MARK) { + if contains_base64_image_data_uri(text) { return Err(EvidenceError::EmbeddedImageIsNotLexicalText); } Ok(()) } +fn contains_base64_image_data_uri(text: &str) -> bool { + 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 media_type = &text[start + "data:".len()..media_end]; + if is_image_media_type_token(media_type) { + return true; + } + search_from = after_prefix; + } + false +} + +fn is_image_media_type_token(media_type: &str) -> bool { + let Some(subtype) = media_type.strip_prefix("image/") else { + return false; + }; + !subtype.is_empty() + && subtype + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'+' | b'-')) +} + fn is_base64_payload_char(ch: char) -> bool { ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=') } diff --git a/crates/evidence_core/tests/embedded_image_contract.rs b/crates/evidence_core/tests/embedded_image_contract.rs index 6a0999cae..09683ff68 100644 --- a/crates/evidence_core/tests/embedded_image_contract.rs +++ b/crates/evidence_core/tests/embedded_image_contract.rs @@ -26,6 +26,8 @@ fn data_uri_recovers_exact_span_and_media_type() { 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"); + refuse_base64_image_as_lexical_text("문서: data:image/png 형식;base64, 설명") + .expect("ordinary prose"); } #[test] diff --git a/crates/evidence_core/tests/records_and_spans_contract.rs b/crates/evidence_core/tests/records_and_spans_contract.rs index d46cb3fd1..ceddc79db 100644 --- a/crates/evidence_core/tests/records_and_spans_contract.rs +++ b/crates/evidence_core/tests/records_and_spans_contract.rs @@ -333,6 +333,10 @@ fn every_record_validation_error_has_a_stable_message() { EvidenceError::EmbeddedImageIsNotLexicalText, "embedded image is not lexical text", ), + ( + EvidenceError::ImplausibleImageMediaType, + "embedded image media type is implausible", + ), ]; for (error, expected) in cases { diff --git a/docs/research/embedded-image-units.md b/docs/research/embedded-image-units.md index bdead53de..82c9b358a 100644 --- a/docs/research/embedded-image-units.md +++ b/docs/research/embedded-image-units.md @@ -13,13 +13,13 @@ No OCR/object model is executed here. No database migration is allocated. ## Authoritative sources -IETF. (2017). *The "data" URL scheme* (RFC 2397). https://doi.org/10.17487/RFC2397 +Masinter, L. (1998). *The "data" URL scheme* (RFC 2397). RFC Editor. https://doi.org/10.17487/RFC2397 Antol, S., Agrawal, A., Lu, J., Mitchell, M., Batra, D., Zitnick, C. L., & Parikh, D. (2015). VQA: Visual question answering. In *Proceedings of the IEEE International Conference on Computer Vision* (pp. 2425–2433). https://doi.org/10.1109/ICCV.2015.279 ## Application -RFC 2397 defines the `data:` URI and the `base64` encoding used in HTML and reports (IETF, 2017). Visual question answering shows that image meaning is a separate modality from surrounding words (Antol et al., 2015). TEPP therefore keeps the original URI offset as a span and refuses to treat that payload as topic or lexical evidence (IETF, 2017; Antol et al., 2015). +RFC 2397 defines the `data:` URI and the `base64` encoding used in HTML and reports (Masinter, 1998). Visual question answering shows that image meaning is a separate modality from surrounding words (Antol et al., 2015). TEPP therefore keeps the original URI offset as a span and refuses to treat that payload as topic or lexical evidence (Masinter, 1998; Antol et al., 2015). ## Verification diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index 28e62d5c9..f7514d5be 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -94,6 +94,10 @@ Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV ontology Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. World Wide Web Consortium. https://www.w3.org/TR/prov-dm/ +Masinter, L. (1998). *The "data" URL scheme* (RFC 2397). RFC Editor. https://doi.org/10.17487/RFC2397 + +Antol, S., Agrawal, A., Lu, J., Mitchell, M., Batra, D., Zitnick, C. L., & Parikh, D. (2015). VQA: Visual question answering. In *Proceedings of the IEEE International Conference on Computer Vision* (pp. 2425–2433). https://doi.org/10.1109/ICCV.2015.279 + TEPP separates stable record identity, content equality, exact text location, wire representation, authorization, and provenance. JSON wire records are explicit versioned DTOs with unknown-field rejection and reconstruct through domain validation. `SHA-256` detects content substitution but is not treated as proof of origin, authority, or chain of custody. ## Privacy lifecycle, retention, and legal hold diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index a4548dcb0..059d7704f 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -25,7 +25,7 @@ This report tracks exact-head scientific and engineering evidence required befor | Versioned API/export contracts | `tepp_api` | implemented-main | naruon HTTP interchange | unknown-field/version/limit + naruon HTTPS interchange tests | Task 12 / PR #21; live HTTP service remaining | | Purpose-bound provider payloads | `tepp_api` | implemented-main | provider-payload minimization | expired/not-yet-valid/inverted/cross-tenant/impossible-calendar grant, mapping refusal, audited elevated re-id replay | ADR 0009; `docs/research/provider-payload-minimization.md` | | Adaptive orchestration router | `tepp_api` | accepted-target | active PR | mode selection, document-control denial, ablation, credential-free bind | ADR 0010; `docs/research/adaptive-orchestration-router.md` | -| Embedded image source units | `evidence_core` | pending-main (PR #58) | data-URI spans | PNG URI recover + lexical refuse | ADR 0008; `docs/research/embedded-image-units.md` | +| Embedded image source units | `evidence_core` | active-PR | active PR (#58) | exact data-URI span/media-type recovery, empty/incomplete/invalid/recovery cases, lexical refusal | ADR 0008; `docs/research/embedded-image-units.md` | | CWL modular connectors | `docs/connectors/*` | implemented-main | — | contract docs + examples | PR #22; live HTTP ports remaining | | Release SBOM/provenance generator | `scripts/release_evidence.py` | partial | — | generate+validate in CI | Task 13 partial / PR #28 | From e351e45ccac0de0ea4da6b98847cc6021c891a75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 22:55:14 +0900 Subject: [PATCH 8/9] fix(evidence): reject parameterized image data as lexical text --- crates/evidence_core/src/image_unit.rs | 1 + crates/evidence_core/tests/embedded_image_contract.rs | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/crates/evidence_core/src/image_unit.rs b/crates/evidence_core/src/image_unit.rs index efd2b30c9..3132a3676 100644 --- a/crates/evidence_core/src/image_unit.rs +++ b/crates/evidence_core/src/image_unit.rs @@ -144,6 +144,7 @@ fn is_image_media_type_token(media_type: &str) -> bool { let Some(subtype) = media_type.strip_prefix("image/") else { return false; }; + let subtype = subtype.split(';').next().unwrap_or(""); !subtype.is_empty() && subtype .bytes() diff --git a/crates/evidence_core/tests/embedded_image_contract.rs b/crates/evidence_core/tests/embedded_image_contract.rs index 09683ff68..454bcf889 100644 --- a/crates/evidence_core/tests/embedded_image_contract.rs +++ b/crates/evidence_core/tests/embedded_image_contract.rs @@ -28,6 +28,10 @@ fn data_uri_recovers_exact_span_and_media_type() { refuse_base64_image_as_lexical_text("data:image/gif y").expect("incomplete image marker"); refuse_base64_image_as_lexical_text("문서: data:image/png 형식;base64, 설명") .expect("ordinary prose"); + assert_eq!( + refuse_base64_image_as_lexical_text("data:image/png;version=1;base64,AAAA"), + Err(EvidenceError::EmbeddedImageIsNotLexicalText) + ); } #[test] From a90df98c44424312af92ddb00fbe9981ccfe23a2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 23:01:01 +0900 Subject: [PATCH 9/9] fix(evidence): normalize image media parameters --- crates/evidence_core/src/image_unit.rs | 15 +++++++++++---- .../tests/embedded_image_contract.rs | 11 +++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/evidence_core/src/image_unit.rs b/crates/evidence_core/src/image_unit.rs index 3132a3676..84953336c 100644 --- a/crates/evidence_core/src/image_unit.rs +++ b/crates/evidence_core/src/image_unit.rs @@ -88,13 +88,17 @@ pub fn embedded_image_units( search_from = after_prefix; continue; } - if !is_plausible_image_media_type(media_type) { + let base_media_type = base_media_type(media_type); + if !is_plausible_image_media_type(base_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 }); + units.push(EmbeddedImageUnit { + span, + media_type: base_media_type, + }); search_from = payload_end; } if units.is_empty() { @@ -141,16 +145,19 @@ fn contains_base64_image_data_uri(text: &str) -> bool { } fn is_image_media_type_token(media_type: &str) -> bool { - let Some(subtype) = media_type.strip_prefix("image/") else { + let Some(subtype) = base_media_type(media_type).strip_prefix("image/") else { return false; }; - let subtype = subtype.split(';').next().unwrap_or(""); !subtype.is_empty() && subtype .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'+' | b'-')) } +fn base_media_type(media_type: &str) -> &str { + media_type.split(';').next().unwrap_or("") +} + fn is_base64_payload_char(ch: char) -> bool { ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=') } diff --git a/crates/evidence_core/tests/embedded_image_contract.rs b/crates/evidence_core/tests/embedded_image_contract.rs index 454bcf889..1f56baf6f 100644 --- a/crates/evidence_core/tests/embedded_image_contract.rs +++ b/crates/evidence_core/tests/embedded_image_contract.rs @@ -54,6 +54,17 @@ fn implausible_media_types_fail_closed_and_common_types_are_accepted() { media_types, vec!["image/png", "image/jpeg", "image/webp", "image/gif"] ); + + let parameterized = "data:image/png;charset=x;base64,AAAA"; + let artifact = SourceArtifact::from_bytes(parameterized.as_bytes()).expect("artifact"); + let document = DocumentRecord::from_text(artifact.id(), parameterized).expect("document"); + let units = embedded_image_units(&document).expect("parameterized image"); + 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()], + parameterized + ); } #[test]