1use std::collections::BTreeMap;
28use std::fmt;
29use std::fmt::Write as _;
30
31use base64::Engine as _;
32use schemars::JsonSchema;
33use serde::{Deserialize, Deserializer, Serialize, Serializer};
34use serde_json::Value;
35use sha2::{Digest, Sha256};
36
37use crate::{MetadataKey, NativeId, SourceError};
38
39const EXTENSIONS: [(&str, AssetContentType); 5] = [
42 ("png", AssetContentType::Png),
43 ("jpg", AssetContentType::Jpeg),
44 ("jpeg", AssetContentType::Jpeg),
45 ("gif", AssetContentType::Gif),
46 ("webp", AssetContentType::Webp),
47];
48
49const REFERENCE_PREFIX: &str = "./";
51
52#[derive(
60 Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
61)]
62#[serde(try_from = "String", into = "String")]
63pub struct AssetName(String);
64
65impl AssetName {
66 pub fn new(name: impl Into<String>) -> Result<Self, String> {
72 let name = name.into();
73 let refuse = |why: &str| {
74 Err(format!(
75 "the asset name {name:?} {why}; an asset is a bare file name ending in .png, \
76 .jpg, .jpeg, .gif or .webp"
77 ))
78 };
79 if name.is_empty() {
80 return refuse("is empty");
81 }
82 if name.contains('/') || name.contains('\\') {
83 return refuse("has a directory in it");
84 }
85 if name.contains("..") {
86 return refuse("contains `..`");
87 }
88 if name
89 .chars()
90 .any(|character| character.is_whitespace() || character.is_control())
91 {
92 return refuse("contains whitespace or a control character");
93 }
94 if name.contains(['(', ')', '<', '>']) {
95 return refuse("contains a parenthesis or an angle bracket");
96 }
97 if content_type_of(&name).is_none() {
98 return refuse("does not end in an accepted image extension after a non-empty stem");
99 }
100 Ok(Self(name))
101 }
102
103 #[must_use]
105 pub fn as_str(&self) -> &str {
106 &self.0
107 }
108
109 #[must_use]
111 pub fn content_type(&self) -> AssetContentType {
112 content_type_of(&self.0).expect("an `AssetName` ends in an accepted extension")
113 }
114}
115
116impl fmt::Display for AssetName {
117 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
118 formatter.write_str(&self.0)
119 }
120}
121
122impl TryFrom<String> for AssetName {
123 type Error = String;
124
125 fn try_from(value: String) -> Result<Self, Self::Error> {
126 Self::new(value)
127 }
128}
129
130impl From<AssetName> for String {
131 fn from(value: AssetName) -> Self {
132 value.0
133 }
134}
135
136fn content_type_of(name: &str) -> Option<AssetContentType> {
139 let (stem, extension) = name.rsplit_once('.')?;
140 if stem.is_empty() {
141 return None;
142 }
143 EXTENSIONS
144 .iter()
145 .find(|(accepted, _)| extension.eq_ignore_ascii_case(accepted))
146 .map(|(_, content_type)| *content_type)
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
151pub enum AssetContentType {
152 #[serde(rename = "image/png")]
154 Png,
155 #[serde(rename = "image/jpeg")]
157 Jpeg,
158 #[serde(rename = "image/gif")]
160 Gif,
161 #[serde(rename = "image/webp")]
163 Webp,
164}
165
166impl AssetContentType {
167 #[must_use]
169 pub fn as_str(self) -> &'static str {
170 match self {
171 Self::Png => "image/png",
172 Self::Jpeg => "image/jpeg",
173 Self::Gif => "image/gif",
174 Self::Webp => "image/webp",
175 }
176 }
177}
178
179#[must_use]
181pub fn is_sha256(digest: &str) -> bool {
182 digest.len() == 64
183 && digest
184 .bytes()
185 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
186}
187
188#[must_use]
191pub fn asset_sha256(bytes: &[u8]) -> String {
192 let mut hex = String::with_capacity(64);
193 for byte in Sha256::digest(bytes) {
194 let _ = write!(hex, "{byte:02x}");
196 }
197 hex
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
202pub struct Asset {
203 pub name: AssetName,
205 pub sha256: String,
208 pub content_type: AssetContentType,
211 pub path: Option<String>,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
223pub struct AssetPayload {
224 pub name: AssetName,
226 pub sha256: String,
229 pub content_type: AssetContentType,
232 #[serde(
234 default,
235 skip_serializing_if = "Option::is_none",
236 serialize_with = "base64_out",
237 deserialize_with = "base64_in"
238 )]
239 #[schemars(with = "String", extend("contentEncoding" = "base64"))]
241 pub bytes: Option<Vec<u8>>,
242}
243
244impl AssetPayload {
245 pub fn checked(&self) -> Result<(), SourceError> {
253 if self.content_type != self.name.content_type() {
254 return Err(SourceError::Refused {
255 message: format!(
256 "the asset {} is sent as {}, which is not the content type its name gives \
257 it, {}",
258 self.name,
259 self.content_type.as_str(),
260 self.name.content_type().as_str()
261 ),
262 });
263 }
264 if !is_sha256(&self.sha256) {
265 return Err(SourceError::Refused {
266 message: format!(
267 "the asset {} carries the sha256 {:?}, which is not a lowercase hex SHA-256",
268 self.name, self.sha256
269 ),
270 });
271 }
272 if let Some(bytes) = &self.bytes
273 && asset_sha256(bytes) != self.sha256
274 {
275 return Err(SourceError::Refused {
276 message: format!(
277 "the asset {}'s bytes do not hash to the sha256 {} it carries; next: send \
278 the bytes that digest names",
279 self.name, self.sha256
280 ),
281 });
282 }
283 Ok(())
284 }
285
286 #[must_use]
289 pub fn of(name: AssetName, bytes: Vec<u8>) -> Self {
290 Self {
291 sha256: asset_sha256(&bytes),
292 content_type: name.content_type(),
293 name,
294 bytes: Some(bytes),
295 }
296 }
297}
298
299#[allow(clippy::ref_option)]
302fn base64_out<S: Serializer>(bytes: &Option<Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error> {
303 match bytes {
304 Some(bytes) => {
305 serializer.serialize_str(&base64::engine::general_purpose::STANDARD.encode(bytes))
306 }
307 None => serializer.serialize_none(),
308 }
309}
310
311fn base64_in<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error> {
314 let encoded = String::deserialize(deserializer)?;
315 base64::engine::general_purpose::STANDARD
316 .decode(encoded.as_bytes())
317 .map(Some)
318 .map_err(|error| serde::de::Error::custom(format!("asset bytes are not base64: {error}")))
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
324pub struct AssetUpload {
325 pub sha256: String,
328 pub url: String,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
340#[serde(transparent)]
341pub struct AssetUploads(pub BTreeMap<AssetName, AssetUpload>);
342
343impl AssetUploads {
344 pub fn read(metadata: &BTreeMap<String, Value>) -> Result<Option<Self>, String> {
351 let Some(value) = metadata.get(MetadataKey::ASSETS_KEY) else {
352 return Ok(None);
353 };
354 let uploads: Self = serde_json::from_value(value.clone()).map_err(|error| {
355 format!(
356 "{} holds {value}, which is not an object of asset names to \
357 {{\"sha256\", \"url\"}}: {error}",
358 MetadataKey::ASSETS_KEY
359 )
360 })?;
361 if let Some((name, upload)) = uploads
362 .0
363 .iter()
364 .find(|(_, upload)| !is_sha256(&upload.sha256) || upload.url.is_empty())
365 {
366 return Err(format!(
367 "{} records {name} with sha256 {:?} and url {:?}; a record is a lowercase hex \
368 SHA-256 and a non-empty url",
369 MetadataKey::ASSETS_KEY,
370 upload.sha256,
371 upload.url
372 ));
373 }
374 Ok(Some(uploads))
375 }
376
377 #[must_use]
379 pub fn to_value(&self) -> Value {
380 serde_json::to_value(self).expect("an asset record is plain JSON")
381 }
382
383 #[must_use]
385 pub fn reusable(&self, name: &AssetName, sha256: &str) -> Option<&str> {
386 self.0
387 .get(name)
388 .filter(|upload| upload.sha256 == sha256)
389 .map(|upload| upload.url.as_str())
390 }
391}
392
393#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
400pub struct AssetWrite {
401 pub assets: Vec<AssetPayload>,
405 #[serde(
408 default,
409 skip_serializing_if = "Option::is_none",
410 deserialize_with = "present_object"
411 )]
412 #[schemars(with = "AssetUploads")]
413 pub recorded_assets: Option<AssetUploads>,
414}
415
416fn present_object<'de, D: Deserializer<'de>>(
419 deserializer: D,
420) -> Result<Option<AssetUploads>, D::Error> {
421 AssetUploads::deserialize(deserializer).map(Some)
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
427#[schemars(transform = content_required)]
428pub struct AssetsWritten {
429 pub id: NativeId,
431 #[serde(deserialize_with = "present_content")]
437 pub content: Option<String>,
438}
439
440fn present_content<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
445 Option::<String>::deserialize(deserializer)
446}
447
448fn content_required(schema: &mut schemars::Schema) {
454 if let Some(serde_json::Value::Array(required)) = schema.get_mut("required") {
455 required.push(serde_json::Value::from("content"));
456 }
457}
458
459#[derive(Debug, Clone, PartialEq, Eq)]
461struct Reference {
462 target: std::ops::Range<usize>,
464 name: AssetName,
466}
467
468fn code(content: &str) -> Vec<std::ops::Range<usize>> {
472 use pulldown_cmark::{Event, Options, Parser, Tag};
473 Parser::new_ext(content, Options::empty())
474 .into_offset_iter()
475 .filter_map(|(event, range)| match event {
476 Event::Code(_) | Event::Start(Tag::CodeBlock(_)) => Some(range),
477 _ => None,
478 })
479 .collect()
480}
481
482fn escaped(content: &str, at: usize) -> bool {
484 content.as_bytes()[..at]
485 .iter()
486 .rev()
487 .take_while(|byte| **byte == b'\\')
488 .count()
489 % 2
490 == 1
491}
492
493fn closes_image(rest: &str) -> bool {
497 let blank = |character: char| character == ' ' || character == '\t';
498 let after = rest.trim_start_matches(blank);
499 let after = match after.chars().next() {
500 Some(')') => return true,
501 Some(open @ ('"' | '\'' | '(')) if after.len() < rest.len() => {
502 let close = if open == '(' { ')' } else { open };
503 let title = &after[1..];
504 let mut end = None;
505 let mut previous_escape = false;
506 for (at, character) in title.char_indices() {
507 if character == '\n' || (open == '(' && character == '(' && !previous_escape) {
508 return false;
509 }
510 if character == close && !previous_escape {
511 end = Some(at);
512 break;
513 }
514 previous_escape = character == '\\' && !previous_escape;
515 }
516 let Some(end) = end else {
517 return false;
518 };
519 &title[end + close.len_utf8()..]
520 }
521 _ => return false,
522 };
523 after.trim_start_matches(blank).starts_with(')')
524}
525
526fn references(content: &str) -> Vec<Reference> {
531 let mut found = Vec::new();
532 if !content.contains("![") {
533 return found;
534 }
535 let code = code(content);
536 let in_code = |at: usize| code.iter().any(|range| range.contains(&at));
537 let mut from = 0;
538 while let Some(at) = content[from..].find("![") {
539 let start = from + at;
540 from = start + 2;
541 if escaped(content, start) || in_code(start) {
542 continue;
543 }
544 let Some(alt_end) = content[from..].find(']').map(|end| from + end) else {
545 break;
546 };
547 if content[from..alt_end].contains('\n') {
548 continue;
549 }
550 let open = alt_end + 1;
551 if content.as_bytes().get(open) != Some(&b'(') {
552 continue;
553 }
554 let target_start = open + 1;
555 let target_end = content[target_start..]
556 .find(|character: char| character == ')' || character.is_whitespace())
557 .map_or(content.len(), |end| target_start + end);
558 if target_end >= content.len() {
559 continue;
560 }
561 let closes = closes_image(&content[target_end..]);
562 let target = &content[target_start..target_end];
563 if in_code(target_start) {
564 continue;
565 }
566 if let (true, Some(name)) = (closes, target.strip_prefix(REFERENCE_PREFIX))
567 && let Ok(name) = AssetName::new(name)
568 {
569 found.push(Reference {
570 target: target_start..target_end,
571 name,
572 });
573 from = target_end;
574 }
575 }
576 found
577}
578
579#[must_use]
581pub fn asset_references(content: &str) -> Vec<AssetName> {
582 let mut names: Vec<AssetName> = Vec::new();
583 for reference in references(content) {
584 if !names.contains(&reference.name) {
585 names.push(reference.name);
586 }
587 }
588 names
589}
590
591#[must_use]
594pub fn rewrite_asset_references(content: &str, served: &BTreeMap<AssetName, String>) -> String {
595 let mut rewritten = String::with_capacity(content.len());
596 let mut copied = 0;
597 for reference in references(content) {
598 if let Some(url) = served.get(&reference.name) {
599 rewritten.push_str(&content[copied..reference.target.start]);
600 rewritten.push_str(url);
601 copied = reference.target.end;
602 }
603 }
604 rewritten.push_str(&content[copied..]);
605 rewritten
606}
607
608#[must_use]
621pub fn serve_asset_references(
622 content: &str,
623 metadata: &mut BTreeMap<String, Value>,
624 uploads: &AssetUploads,
625) -> String {
626 let served = uploads
627 .0
628 .iter()
629 .map(|(name, upload)| (name.clone(), upload.url.clone()))
630 .collect();
631 let rewritten = rewrite_asset_references(content, &served);
632 if rewritten != content
633 && let Some(Value::Object(entry)) = metadata.get_mut(MetadataKey::TEMPLATE_KEY)
634 && entry.get("body_digest").and_then(Value::as_str) == Some(&body_digest(content))
635 {
636 entry.insert(
637 "body_digest".to_owned(),
638 Value::String(body_digest(&rewritten)),
639 );
640 }
641 if uploads.0.is_empty() {
642 metadata.remove(MetadataKey::ASSETS_KEY);
643 } else {
644 metadata.insert(MetadataKey::ASSETS_KEY.to_owned(), uploads.to_value());
645 }
646 rewritten
647}
648
649#[must_use]
652pub fn body_digest(content: &str) -> String {
653 format!("sha256:{}", asset_sha256(content.as_bytes()))
654}
655
656#[must_use]
662pub fn assetless(kind: &str) -> SourceError {
663 SourceError::Refused {
664 message: format!("the {kind} plugin cannot store image assets"),
665 }
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671
672 fn name(name: &str) -> AssetName {
673 AssetName::new(name).expect("a valid name")
674 }
675
676 #[test]
677 fn only_a_dot_slash_image_of_an_accepted_bare_name_is_a_reference() {
678 let content = "  \
679    \
680 [f](./five.png) [g](./notes.txt)  \
681   ,
684 vec![name("one.png"), name("six.JpEg")]
685 );
686 }
687
688 #[test]
689 fn only_a_title_may_sit_between_the_target_and_the_closing_parenthesis() {
690 let content = "   \
691 ) \n\
692  \n\
693  )) ";
694 assert_eq!(
695 asset_references(content),
696 vec![
697 name("a.png"),
698 name("b.png"),
699 name("c.png"),
700 name("d.png"),
701 name("e.png")
702 ]
703 );
704 }
705
706 #[test]
707 fn an_escaped_image_and_image_syntax_inside_code_are_not_references() {
708 let content = "\\ `` ````\n\
709 \n```\n\n```\n\n~~~md\n\n~~~\n\n \
710 \n\n- item\n\n ```\n \n ```\n\n\
711 > ```\n> \n> ```\n\n \\\\\n\
712 `";
713 assert_eq!(
714 asset_references(content),
715 vec![name("real.png"), name("i.png")]
716 );
717 let served = BTreeMap::from([
718 (name("a.png"), "https://h/a".to_owned()),
719 (name("d.png"), "https://h/d".to_owned()),
720 (name("real.png"), "https://h/real".to_owned()),
721 ]);
722 assert_eq!(
723 rewrite_asset_references(content, &served),
724 content.replace("(./real.png)", "(https://h/real)")
725 );
726 assert!(
727 asset_references("```\n\n").is_empty(),
728 "unclosed fence"
729 );
730 assert_eq!(
731 asset_references(" text\n"),
732 vec![name("a.png")]
733 );
734 assert_eq!(
735 asset_references("para\n "),
736 vec![name("a.png")]
737 );
738 }
739
740 #[test]
741 fn a_rewrite_touches_the_targets_it_maps_and_nothing_else() {
742 let content = "see  and  and [c](./one.png)";
743 let served = BTreeMap::from([(name("one.png"), "https://h/1".to_owned())]);
744 assert_eq!(
745 rewrite_asset_references(content, &served),
746 "see  and  and [c](./one.png)"
747 );
748 }
749
750 #[test]
751 fn names_are_refused_by_what_is_wrong_with_them() {
752 for bad in [
753 "", "a/b.png", "a\\b.png", "..png", "a..b.png", "a b.png", ".png", "a.txt",
754 ] {
755 assert!(AssetName::new(bad).is_err(), "{bad:?} was accepted");
756 }
757 assert_eq!(name("X.PNG").content_type(), AssetContentType::Png);
758 assert_eq!(name("x.JpEg").content_type(), AssetContentType::Jpeg);
759 assert_eq!(name("x.jpg").content_type(), AssetContentType::Jpeg);
760 assert_eq!(name("x.gif").content_type(), AssetContentType::Gif);
761 assert_eq!(name("x.webp").content_type(), AssetContentType::Webp);
762 }
763
764 #[test]
765 fn bytes_cross_as_base64_and_are_omitted_when_absent() {
766 let payload = AssetPayload::of(name("a.png"), vec![0, 1, 2, 255]);
767 let value = serde_json::to_value(&payload).expect("serializes");
768 assert_eq!(value["bytes"], "AAEC/w==");
769 let back: AssetPayload = serde_json::from_value(value).expect("deserializes");
770 assert_eq!(back, payload);
771 let reused = AssetPayload {
772 bytes: None,
773 ..payload
774 };
775 let value = serde_json::to_value(&reused).expect("serializes");
776 assert!(value.get("bytes").is_none());
777 }
778
779 #[test]
780 fn a_written_answer_must_carry_content_and_it_may_be_null() {
781 for content in [Some("".to_owned()), None] {
782 let written = AssetsWritten {
783 id: NativeId::from("D-1"),
784 content,
785 };
786 let value = serde_json::to_value(&written).expect("serializes");
787 assert!(value.get("content").is_some(), "{value}");
788 let back: AssetsWritten = serde_json::from_value(value).expect("deserializes");
789 assert_eq!(back, written);
790 }
791 let missing = serde_json::from_value::<AssetsWritten>(serde_json::json!({"id": "D-1"}))
792 .expect_err("an answer without content is refused");
793 assert!(missing.to_string().contains("content"), "{missing}");
794
795 let schema = serde_json::to_value(schemars::schema_for!(AssetsWritten)).expect("a schema");
796 assert_eq!(schema["required"], serde_json::json!(["id", "content"]));
797 assert_eq!(
798 schema["properties"]["content"]["type"],
799 serde_json::json!(["string", "null"])
800 );
801 }
802
803 #[test]
804 fn a_record_whose_digest_is_not_one_is_refused_where_it_is_read() {
805 let metadata = BTreeMap::from([(
806 MetadataKey::ASSETS_KEY.to_owned(),
807 serde_json::json!({"a.png": {"sha256": "not hex", "url": "https://h/a"}}),
808 )]);
809 let refused = AssetUploads::read(&metadata).expect_err("refused");
810 assert!(
811 refused.contains("a.png") && refused.contains("not hex"),
812 "{refused}"
813 );
814 assert!(is_sha256(&asset_sha256(b"x")));
815 assert!(!is_sha256(&asset_sha256(b"x").to_uppercase()));
816 }
817
818 #[test]
819 fn serving_restamps_a_rendering_that_still_matches_and_leaves_a_hand_edit_alone() {
820 let content = "";
821 let uploads = AssetUploads(BTreeMap::from([(
822 name("one.png"),
823 AssetUpload {
824 sha256: "00".to_owned(),
825 url: "https://h/1".to_owned(),
826 },
827 )]));
828 let entry = |digest: String| {
829 serde_json::json!({
830 "template": "t", "digest": "sha256:aa", "body_digest": digest,
831 "answers_digest": "sha256:bb"
832 })
833 };
834 let mut metadata = BTreeMap::from([(
835 MetadataKey::TEMPLATE_KEY.to_owned(),
836 entry(body_digest(content)),
837 )]);
838 let rewritten = serve_asset_references(content, &mut metadata, &uploads);
839 assert_eq!(rewritten, "");
840 assert_eq!(
841 metadata[MetadataKey::TEMPLATE_KEY],
842 entry(body_digest(&rewritten))
843 );
844 assert_eq!(metadata[MetadataKey::ASSETS_KEY], uploads.to_value());
845
846 let mut edited = BTreeMap::from([(
847 MetadataKey::TEMPLATE_KEY.to_owned(),
848 entry("sha256:00".to_owned()),
849 )]);
850 let _ = serve_asset_references(content, &mut edited, &uploads);
851 assert_eq!(
852 edited[MetadataKey::TEMPLATE_KEY],
853 entry("sha256:00".to_owned())
854 );
855 }
856}