Skip to main content

onetaskgraph_plugin_api/
asset.rs

1//! Image assets: the one Markdown convention that references one, the shapes a source reads and
2//! writes them in, and the record a hosted source keeps of what it uploaded.
3//!
4//! # The convention
5//!
6//! In a task's or a document's content, an asset is referenced by a Markdown image whose
7//! target is `./<name>` — `![<alt>](./<name>)` — where `<name>` is an [`AssetName`]: a bare
8//! file name with no `/`, no `\` and no `..`, ending, case-insensitively, in `.png`, `.jpg`,
9//! `.jpeg`, `.gif` or `.webp`. Nothing else is an asset reference: every other link, absolute
10//! or relative, an image whose target has a directory or no `./`, and a plain link to
11//! `./<name>.png`, is left exactly as written. Only an image outside code is a reference: one
12//! whose `!` is escaped with a backslash, and image syntax inside an inline code span or a
13//! fenced or indented code block — as CommonMark decides what is code — is text like any
14//! other, so a record whose only image syntax is of those kinds holds no asset. [`asset_references`] is the one reader of the
15//! convention and [`rewrite_asset_references`] the one writer, so the engine and every plugin
16//! agree about which text is a reference.
17//!
18//! # What crosses the seam
19//!
20//! A record's assets are read as [`Asset`]s — name, SHA-256, content type and, for a source
21//! that keeps them on this machine, the path holding the bytes — and their bytes one asset at
22//! a time. They are written as an [`AssetWrite`] beside the record's own write: one
23//! [`AssetPayload`] per asset the content references, and the destination record's existing
24//! [`AssetUploads`] when it has one. A payload whose SHA-256 equals the one recorded there
25//! carries no bytes, because the destination already serves exactly those.
26
27use 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
39/// The extensions an asset may end in, lower-cased, each with the content type it is stored
40/// and served as.
41const 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
49/// The prefix an asset reference's target carries before the name.
50const REFERENCE_PREFIX: &str = "./";
51
52/// One asset's name: a bare file name ending in an accepted image extension.
53///
54/// Validated wherever one is built, deserialized included: non-empty, no `/`, no `\`, no
55/// `..`, no whitespace, control character or parenthesis — none of which a bare Markdown link
56/// target can hold — and ending, case-insensitively, in `.png`, `.jpg`, `.jpeg`, `.gif` or
57/// `.webp` after a non-empty stem. The name is kept exactly as written, case included: it is
58/// the text a reference names and the file name a source stores the bytes under.
59#[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    /// One name, once it is established that it is one.
67    ///
68    /// # Errors
69    ///
70    /// A message naming the name and what is wrong with it, and what to write instead.
71    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    /// The name, as written.
104    #[must_use]
105    pub fn as_str(&self) -> &str {
106        &self.0
107    }
108
109    /// The content type its extension stores and serves it as.
110    #[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
136/// The content type of a name's extension, when it has an accepted one after a non-empty
137/// stem.
138fn 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/// The content type an asset is stored and served as, decided by its name's extension alone.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
151pub enum AssetContentType {
152    /// `.png`.
153    #[serde(rename = "image/png")]
154    Png,
155    /// `.jpg` and `.jpeg`.
156    #[serde(rename = "image/jpeg")]
157    Jpeg,
158    /// `.gif`.
159    #[serde(rename = "image/gif")]
160    Gif,
161    /// `.webp`.
162    #[serde(rename = "image/webp")]
163    Webp,
164}
165
166impl AssetContentType {
167    /// The media type, as it is serialized.
168    #[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/// Whether `digest` is a SHA-256 as this contract spells one: 64 lowercase hex digits.
180#[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/// The lowercase hex SHA-256 of `bytes`: what [`Asset::sha256`], [`AssetPayload::sha256`] and
189/// [`AssetUpload::sha256`] hold.
190#[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        // Writing to a `String` never fails.
195        let _ = write!(hex, "{byte:02x}");
196    }
197    hex
198}
199
200/// One asset a record holds, as a read reports it.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
202pub struct Asset {
203    /// Its name, which the record's content references as `./<name>`.
204    pub name: AssetName,
205    /// The lowercase hex SHA-256 of its bytes.
206    // llmlint: ignore[invalid_states_unrepresentable] This member's JSON shape — a lowercase hex string — is the asset contract `docs/plugin-protocol.md` §4.9a states, and plugins in other crates and other languages read and write it as exactly that. The digest is computed by `asset_sha256` wherever it is made, and every source that receives bytes refuses them unless they hash to it before storing anything; a recorded one is checked as a digest where `AssetUploads::read` reads it.
207    pub sha256: String,
208    /// The content type its extension gives it.
209    // llmlint: ignore[invalid_states_unrepresentable] `show --json` prints `content_type` beside the name, as the README states, and a source only ever builds an `Asset` from `AssetName::content_type`; nothing receives one from outside to trust, so there is no boundary a contradictory pair could enter through.
210    pub content_type: AssetContentType,
211    /// The absolute path holding its bytes on this machine, for a source that keeps them
212    /// here; `null` for a hosted source.
213    // llmlint: ignore[invalid_states_unrepresentable] This member is an absolute path string or null in what `show --json` prints, as the README states. Only a source reports it, from the path it wrote the bytes to — local-md's canonicalized record path joined with the asset's validated name — and nothing reads it back to decide anything.
214    pub path: Option<String>,
215}
216
217/// One asset a create or an update carries to a destination.
218///
219/// `bytes` is base64 over the out-of-process protocol and the raw bytes in process. It is
220/// absent exactly when the destination's existing [`AssetUploads`] records this name with
221/// this `sha256`: the destination already serves those bytes, so nothing is sent again.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
223pub struct AssetPayload {
224    /// Its name, which the record's content references as `./<name>`.
225    pub name: AssetName,
226    /// The lowercase hex SHA-256 of its bytes.
227    // llmlint: ignore[invalid_states_unrepresentable] This member's JSON shape — a lowercase hex string — is the asset contract `docs/plugin-protocol.md` §4.9a states, and plugins in other crates and other languages read and write it as exactly that. The digest is computed by `asset_sha256` wherever it is made, and every source that receives bytes refuses them unless they hash to it before storing anything; a recorded one is checked as a digest where `AssetUploads::read` reads it.
228    pub sha256: String,
229    /// The content type its extension gives it.
230    // llmlint: ignore[invalid_states_unrepresentable] The contract `docs/plugin-protocol.md` §4.9a states carries the content type beside the name on the wire, for a plugin that does not derive it; every source receiving a payload refuses one whose type is not its name's through `AssetPayload::checked` before storing anything, and `AssetPayload::of` is the one constructor that derives it.
231    pub content_type: AssetContentType,
232    /// Its bytes, or absent when the destination already records them by `sha256`.
233    #[serde(
234        default,
235        skip_serializing_if = "Option::is_none",
236        serialize_with = "base64_out",
237        deserialize_with = "base64_in"
238    )]
239    // Described as the string it is when present: absent is the reuse, and `null` is refused.
240    #[schemars(with = "String", extend("contentEncoding" = "base64"))]
241    pub bytes: Option<Vec<u8>>,
242}
243
244impl AssetPayload {
245    /// Refuse a payload whose content type is not the one its name's extension gives it, or
246    /// whose bytes, when it carries them, do not hash to its digest — the check every source
247    /// receiving one makes before it stores anything.
248    ///
249    /// # Errors
250    ///
251    /// [`SourceError::Refused`] naming the asset and what disagrees.
252    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    /// A payload carrying `bytes`, its digest and content type taken from them and from the
287    /// name.
288    #[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// llmlint: ignore[suppressions_justified] serde's `serialize_with` hands the field by
300// reference to the field's own type, so the signature is fixed by serde rather than chosen.
301#[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
311/// Present bytes are a base64 string; only an absent member — `#[serde(default)]` — is reuse,
312/// so an explicit `null` is refused rather than read as one.
313fn 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/// What a hosted source uploaded for one asset of one record: the bytes' SHA-256 and the URL
322/// it serves them at.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
324pub struct AssetUpload {
325    /// The lowercase hex SHA-256 of the bytes uploaded.
326    // llmlint: ignore[invalid_states_unrepresentable] This member's JSON shape — a lowercase hex string — is the asset contract `docs/plugin-protocol.md` §4.9a states, and plugins in other crates and other languages read and write it as exactly that. The digest is computed by `asset_sha256` wherever it is made, and every source that receives bytes refuses them unless they hash to it before storing anything; a recorded one is checked as a digest where `AssetUploads::read` reads it.
327    pub sha256: String,
328    /// Where the destination serves them.
329    // llmlint: ignore[invalid_states_unrepresentable] `onetaskgraph.assets` is `{"sha256": <hex>, "url": <string>}` in the contract `docs/plugin-protocol.md` §4.9a states. A URL is whatever the destination serves the bytes at — its own scheme, as `in-memory://` shows — so no narrower type says more than a string; nothing here dereferences one.
330    pub url: String,
331}
332
333/// The value of [`MetadataKey::ASSETS_KEY`] on a destination record: what its hosted source
334/// uploaded, by asset name.
335///
336/// Written by the hosted plugin in the same write that lands the record, and read back by the
337/// next copy onto it: an asset whose SHA-256 equals the one recorded here reuses the recorded
338/// URL and is not uploaded again.
339#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
340#[serde(transparent)]
341pub struct AssetUploads(pub BTreeMap<AssetName, AssetUpload>);
342
343impl AssetUploads {
344    /// What `metadata` records under [`MetadataKey::ASSETS_KEY`], or `None` when it records
345    /// nothing there.
346    ///
347    /// # Errors
348    ///
349    /// A message naming the key when what it holds is not this shape.
350    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    /// The value this record is written under [`MetadataKey::ASSETS_KEY`] as.
378    #[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    /// The URL recorded for `name`, when it was recorded with exactly `sha256`.
384    #[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/// The assets a create or an update of one task or document carries.
394///
395/// Over the out-of-process protocol its two members sit in the write's `params` beside
396/// `write` — `docs/plugin-protocol.md` §4.9 — and in process it is the argument the asset
397/// writes of [`TaskSource`](crate::TaskSource) take beside the record's own write. One type for
398/// both, so the two cannot disagree.
399#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
400pub struct AssetWrite {
401    /// One payload per asset the record's content references, in the order it first
402    /// references them — the record's whole asset set once the write lands, so an asset the
403    /// destination holds and this does not name is removed.
404    pub assets: Vec<AssetPayload>,
405    /// The destination record's existing [`MetadataKey::ASSETS_KEY`], when it has one.
406    // Described as the object it is when present: absent is no record, and `null` is refused.
407    #[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
416/// A record that, when present, is one: absent reads as `None` through `#[serde(default)]`, and
417/// an explicit `null` is refused as the shape the contract does not have.
418fn present_object<'de, D: Deserializer<'de>>(
419    deserializer: D,
420) -> Result<Option<AssetUploads>, D::Error> {
421    AssetUploads::deserialize(deserializer).map(Some)
422}
423
424/// What an asset-carrying write answers with: where the record now is, and the content the
425/// destination stored.
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
427#[schemars(transform = content_required)]
428pub struct AssetsWritten {
429    /// The id the destination holds the record under.
430    pub id: NativeId,
431    /// The content as stored — each `./<name>` reference pointed at where the destination
432    /// serves that asset, for a source that serves them at a URL; unchanged for one that keeps
433    /// them beside the record.
434    // Required and nullable, as docs/plugin-protocol.md §4.9a states it: an answer that leaves
435    // it out says nothing about what landed, so it is malformed rather than read as `null`.
436    #[serde(deserialize_with = "present_content")]
437    pub content: Option<String>,
438}
439
440/// A `content` that may be `null` and may not be absent.
441///
442/// Serde reads an absent `Option` member as `None` of its own accord; naming a deserializer
443/// takes that away, so an answer that leaves the member out is refused.
444fn present_content<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
445    Option::<String>::deserialize(deserializer)
446}
447
448/// Mark `content` required in [`AssetsWritten`]'s schema.
449///
450/// `#[schemars(required)]` on an `Option` member drops `null` from its type, which would
451/// declare the `null` the protocol allows invalid; required-and-nullable is what the member
452/// is, so it is added to the list after the members are described.
453fn 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/// One asset reference in a content: where its target sits and the name it names.
460#[derive(Debug, Clone, PartialEq, Eq)]
461struct Reference {
462    /// The byte range of the target — `./<name>` — inside the content.
463    target: std::ops::Range<usize>,
464    /// The name it references.
465    name: AssetName,
466}
467
468/// The byte ranges of `content` that are code: every code span, and every code block, fenced
469/// or indented, fences included — read as CommonMark reads them, in whatever container they
470/// sit.
471fn 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
482/// Whether the byte at `at` is escaped: preceded by an odd number of backslashes.
483fn 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
493/// Whether `rest`, what follows an image's target, closes the image on its own line as
494/// CommonMark does: blanks, then an optional title in `"…"`, `'…'` or `(…)`, then blanks and
495/// `)`. Anything else after the target makes the syntax no image at all.
496fn 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
526/// Every asset reference in `content`, in order, duplicates included.
527///
528/// An image whose `!` is escaped, or whose `!` or target sits inside code, is not one.
529/// Content holding no image syntax at all, which is most of it, is not parsed.
530fn 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/// The assets `content` references, each once, in the order it first references them.
580#[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/// `content` with the target of each reference to an asset `served` names replaced by the URL
592/// it maps to, and every other byte as it was.
593#[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/// Point a record's asset references at where a hosted source serves them, and record what it
609/// uploaded: the one way a plugin that declares assets `native` and serves them at a URL
610/// finishes the record it is about to write.
611///
612/// `content` is rewritten by [`rewrite_asset_references`] over `uploads`, and `metadata` gains
613/// `uploads` under [`MetadataKey::ASSETS_KEY`] — or loses that key, when `uploads` is empty,
614/// so a record that holds no asset records nothing about assets. When `metadata` records a template rendering
615/// under [`MetadataKey::TEMPLATE_KEY`] whose `body_digest` is still the digest of `content`,
616/// that entry's `body_digest` is re-recorded as the digest of the rewritten content and its
617/// `template`, `digest` and `answers_digest` are carried as they were — the rule a copy that
618/// rewrites references follows (README, "Creating and regenerating from a template"). Any other
619/// entry, a hand edit the rendering no longer vouches for included, is carried verbatim.
620#[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/// `sha256:` and the lowercase hex SHA-256 of `content`: what a rendering's `body_digest`
650/// records.
651#[must_use]
652pub fn body_digest(content: &str) -> String {
653    format!("sha256:{}", asset_sha256(content.as_bytes()))
654}
655
656/// The refusal a source that keeps no assets answers an asset write with.
657///
658/// Spelled once beside [`unwritable`](crate::unwritable), for that function's reason: every
659/// source without assets refuses in the same words, and the engine's own refusal of a copy
660/// into one names the source, the record and the asset before any source is asked.
661#[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 = "![a](./one.png) ![b](https://example.invalid/x.png) \
679                       ![c](./img/two.png) ![d](../three.png) ![e](four.png) \
680                       [f](./five.png) [g](./notes.txt) ![h](./six.JpEg \"title\") \
681                       ![i](./one.png) ![j](./seven.txt) ![k](./eight.gif";
682        assert_eq!(
683            asset_references(content),
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 = "![a](./a.png  ) ![b](./b.png \"t\") ![c](./c.png 't' ) \
691                       ![d](./d.png (t)) ![e](./e.png \"t \\\" t\")\n\
692                       ![x](./x.png arbitrary text) ![y](./y.png \"unterminated)\n\
693                       ![z](./z.png \"t\" more) ![w](./w.png (a(b))) ![v](./v.png\t'open)";
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 = "\\![escaped](./a.png) `![span](./b.png)` ``![x](./c.png)``\n\
709                       \n```\n![fenced](./d.png)\n```\n\n~~~md\n![tilde](./e.png)\n~~~\n\n    \
710                       ![indented](./f.png)\n\n- item\n\n  ```\n  ![listed](./g.png)\n  ```\n\n\
711                       > ```\n> ![quoted](./h.png)\n> ```\n\n![real](./real.png) \\\\![after](./i.png)\n\
712                       ![a`](./j.png)`";
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![a](./a.png)\n").is_empty(),
728            "unclosed fence"
729        );
730        assert_eq!(
731            asset_references("    text\n![a](./a.png)"),
732            vec![name("a.png")]
733        );
734        assert_eq!(
735            asset_references("para\n    ![a](./a.png)"),
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 ![a](./one.png) and ![b](./two.webp \"t\") 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 ![a](https://h/1) and ![b](./two.webp \"t\") 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("![a](https://h/a.png)".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 = "![a](./one.png)";
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, "![a](https://h/1)");
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}