use std::collections::BTreeMap;
use std::fmt;
use std::fmt::Write as _;
use base64::Engine as _;
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::{MetadataKey, NativeId, SourceError};
const EXTENSIONS: [(&str, AssetContentType); 5] = [
("png", AssetContentType::Png),
("jpg", AssetContentType::Jpeg),
("jpeg", AssetContentType::Jpeg),
("gif", AssetContentType::Gif),
("webp", AssetContentType::Webp),
];
const REFERENCE_PREFIX: &str = "./";
#[derive(
Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(try_from = "String", into = "String")]
pub struct AssetName(String);
impl AssetName {
pub fn new(name: impl Into<String>) -> Result<Self, String> {
let name = name.into();
let refuse = |why: &str| {
Err(format!(
"the asset name {name:?} {why}; an asset is a bare file name ending in .png, \
.jpg, .jpeg, .gif or .webp"
))
};
if name.is_empty() {
return refuse("is empty");
}
if name.contains('/') || name.contains('\\') {
return refuse("has a directory in it");
}
if name.contains("..") {
return refuse("contains `..`");
}
if name
.chars()
.any(|character| character.is_whitespace() || character.is_control())
{
return refuse("contains whitespace or a control character");
}
if name.contains(['(', ')', '<', '>']) {
return refuse("contains a parenthesis or an angle bracket");
}
if content_type_of(&name).is_none() {
return refuse("does not end in an accepted image extension after a non-empty stem");
}
Ok(Self(name))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn content_type(&self) -> AssetContentType {
content_type_of(&self.0).expect("an `AssetName` ends in an accepted extension")
}
}
impl fmt::Display for AssetName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl TryFrom<String> for AssetName {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<AssetName> for String {
fn from(value: AssetName) -> Self {
value.0
}
}
fn content_type_of(name: &str) -> Option<AssetContentType> {
let (stem, extension) = name.rsplit_once('.')?;
if stem.is_empty() {
return None;
}
EXTENSIONS
.iter()
.find(|(accepted, _)| extension.eq_ignore_ascii_case(accepted))
.map(|(_, content_type)| *content_type)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub enum AssetContentType {
#[serde(rename = "image/png")]
Png,
#[serde(rename = "image/jpeg")]
Jpeg,
#[serde(rename = "image/gif")]
Gif,
#[serde(rename = "image/webp")]
Webp,
}
impl AssetContentType {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Png => "image/png",
Self::Jpeg => "image/jpeg",
Self::Gif => "image/gif",
Self::Webp => "image/webp",
}
}
}
#[must_use]
pub fn is_sha256(digest: &str) -> bool {
digest.len() == 64
&& digest
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
#[must_use]
pub fn asset_sha256(bytes: &[u8]) -> String {
let mut hex = String::with_capacity(64);
for byte in Sha256::digest(bytes) {
let _ = write!(hex, "{byte:02x}");
}
hex
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct Asset {
pub name: AssetName,
pub sha256: String,
pub content_type: AssetContentType,
pub path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct AssetPayload {
pub name: AssetName,
pub sha256: String,
pub content_type: AssetContentType,
#[serde(
default,
skip_serializing_if = "Option::is_none",
serialize_with = "base64_out",
deserialize_with = "base64_in"
)]
#[schemars(with = "String", extend("contentEncoding" = "base64"))]
pub bytes: Option<Vec<u8>>,
}
impl AssetPayload {
pub fn checked(&self) -> Result<(), SourceError> {
if self.content_type != self.name.content_type() {
return Err(SourceError::Refused {
message: format!(
"the asset {} is sent as {}, which is not the content type its name gives \
it, {}",
self.name,
self.content_type.as_str(),
self.name.content_type().as_str()
),
});
}
if !is_sha256(&self.sha256) {
return Err(SourceError::Refused {
message: format!(
"the asset {} carries the sha256 {:?}, which is not a lowercase hex SHA-256",
self.name, self.sha256
),
});
}
if let Some(bytes) = &self.bytes
&& asset_sha256(bytes) != self.sha256
{
return Err(SourceError::Refused {
message: format!(
"the asset {}'s bytes do not hash to the sha256 {} it carries; next: send \
the bytes that digest names",
self.name, self.sha256
),
});
}
Ok(())
}
#[must_use]
pub fn of(name: AssetName, bytes: Vec<u8>) -> Self {
Self {
sha256: asset_sha256(&bytes),
content_type: name.content_type(),
name,
bytes: Some(bytes),
}
}
}
#[allow(clippy::ref_option)]
fn base64_out<S: Serializer>(bytes: &Option<Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error> {
match bytes {
Some(bytes) => {
serializer.serialize_str(&base64::engine::general_purpose::STANDARD.encode(bytes))
}
None => serializer.serialize_none(),
}
}
fn base64_in<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error> {
let encoded = String::deserialize(deserializer)?;
base64::engine::general_purpose::STANDARD
.decode(encoded.as_bytes())
.map(Some)
.map_err(|error| serde::de::Error::custom(format!("asset bytes are not base64: {error}")))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct AssetUpload {
pub sha256: String,
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(transparent)]
pub struct AssetUploads(pub BTreeMap<AssetName, AssetUpload>);
impl AssetUploads {
pub fn read(metadata: &BTreeMap<String, Value>) -> Result<Option<Self>, String> {
let Some(value) = metadata.get(MetadataKey::ASSETS_KEY) else {
return Ok(None);
};
let uploads: Self = serde_json::from_value(value.clone()).map_err(|error| {
format!(
"{} holds {value}, which is not an object of asset names to \
{{\"sha256\", \"url\"}}: {error}",
MetadataKey::ASSETS_KEY
)
})?;
if let Some((name, upload)) = uploads
.0
.iter()
.find(|(_, upload)| !is_sha256(&upload.sha256) || upload.url.is_empty())
{
return Err(format!(
"{} records {name} with sha256 {:?} and url {:?}; a record is a lowercase hex \
SHA-256 and a non-empty url",
MetadataKey::ASSETS_KEY,
upload.sha256,
upload.url
));
}
Ok(Some(uploads))
}
#[must_use]
pub fn to_value(&self) -> Value {
serde_json::to_value(self).expect("an asset record is plain JSON")
}
#[must_use]
pub fn reusable(&self, name: &AssetName, sha256: &str) -> Option<&str> {
self.0
.get(name)
.filter(|upload| upload.sha256 == sha256)
.map(|upload| upload.url.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
pub struct AssetWrite {
pub assets: Vec<AssetPayload>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "present_object"
)]
#[schemars(with = "AssetUploads")]
pub recorded_assets: Option<AssetUploads>,
}
fn present_object<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Option<AssetUploads>, D::Error> {
AssetUploads::deserialize(deserializer).map(Some)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[schemars(transform = content_required)]
pub struct AssetsWritten {
pub id: NativeId,
#[serde(deserialize_with = "present_content")]
pub content: Option<String>,
}
fn present_content<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
Option::<String>::deserialize(deserializer)
}
fn content_required(schema: &mut schemars::Schema) {
if let Some(serde_json::Value::Array(required)) = schema.get_mut("required") {
required.push(serde_json::Value::from("content"));
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Reference {
target: std::ops::Range<usize>,
name: AssetName,
}
fn code(content: &str) -> Vec<std::ops::Range<usize>> {
use pulldown_cmark::{Event, Options, Parser, Tag};
Parser::new_ext(content, Options::empty())
.into_offset_iter()
.filter_map(|(event, range)| match event {
Event::Code(_) | Event::Start(Tag::CodeBlock(_)) => Some(range),
_ => None,
})
.collect()
}
fn escaped(content: &str, at: usize) -> bool {
content.as_bytes()[..at]
.iter()
.rev()
.take_while(|byte| **byte == b'\\')
.count()
% 2
== 1
}
fn closes_image(rest: &str) -> bool {
let blank = |character: char| character == ' ' || character == '\t';
let after = rest.trim_start_matches(blank);
let after = match after.chars().next() {
Some(')') => return true,
Some(open @ ('"' | '\'' | '(')) if after.len() < rest.len() => {
let close = if open == '(' { ')' } else { open };
let title = &after[1..];
let mut end = None;
let mut previous_escape = false;
for (at, character) in title.char_indices() {
if character == '\n' || (open == '(' && character == '(' && !previous_escape) {
return false;
}
if character == close && !previous_escape {
end = Some(at);
break;
}
previous_escape = character == '\\' && !previous_escape;
}
let Some(end) = end else {
return false;
};
&title[end + close.len_utf8()..]
}
_ => return false,
};
after.trim_start_matches(blank).starts_with(')')
}
fn references(content: &str) -> Vec<Reference> {
let mut found = Vec::new();
if !content.contains("![") {
return found;
}
let code = code(content);
let in_code = |at: usize| code.iter().any(|range| range.contains(&at));
let mut from = 0;
while let Some(at) = content[from..].find("![") {
let start = from + at;
from = start + 2;
if escaped(content, start) || in_code(start) {
continue;
}
let Some(alt_end) = content[from..].find(']').map(|end| from + end) else {
break;
};
if content[from..alt_end].contains('\n') {
continue;
}
let open = alt_end + 1;
if content.as_bytes().get(open) != Some(&b'(') {
continue;
}
let target_start = open + 1;
let target_end = content[target_start..]
.find(|character: char| character == ')' || character.is_whitespace())
.map_or(content.len(), |end| target_start + end);
if target_end >= content.len() {
continue;
}
let closes = closes_image(&content[target_end..]);
let target = &content[target_start..target_end];
if in_code(target_start) {
continue;
}
if let (true, Some(name)) = (closes, target.strip_prefix(REFERENCE_PREFIX))
&& let Ok(name) = AssetName::new(name)
{
found.push(Reference {
target: target_start..target_end,
name,
});
from = target_end;
}
}
found
}
#[must_use]
pub fn asset_references(content: &str) -> Vec<AssetName> {
let mut names: Vec<AssetName> = Vec::new();
for reference in references(content) {
if !names.contains(&reference.name) {
names.push(reference.name);
}
}
names
}
#[must_use]
pub fn rewrite_asset_references(content: &str, served: &BTreeMap<AssetName, String>) -> String {
let mut rewritten = String::with_capacity(content.len());
let mut copied = 0;
for reference in references(content) {
if let Some(url) = served.get(&reference.name) {
rewritten.push_str(&content[copied..reference.target.start]);
rewritten.push_str(url);
copied = reference.target.end;
}
}
rewritten.push_str(&content[copied..]);
rewritten
}
#[must_use]
pub fn serve_asset_references(
content: &str,
metadata: &mut BTreeMap<String, Value>,
uploads: &AssetUploads,
) -> String {
let served = uploads
.0
.iter()
.map(|(name, upload)| (name.clone(), upload.url.clone()))
.collect();
let rewritten = rewrite_asset_references(content, &served);
if rewritten != content
&& let Some(Value::Object(entry)) = metadata.get_mut(MetadataKey::TEMPLATE_KEY)
&& entry.get("body_digest").and_then(Value::as_str) == Some(&body_digest(content))
{
entry.insert(
"body_digest".to_owned(),
Value::String(body_digest(&rewritten)),
);
}
if uploads.0.is_empty() {
metadata.remove(MetadataKey::ASSETS_KEY);
} else {
metadata.insert(MetadataKey::ASSETS_KEY.to_owned(), uploads.to_value());
}
rewritten
}
#[must_use]
pub fn body_digest(content: &str) -> String {
format!("sha256:{}", asset_sha256(content.as_bytes()))
}
#[must_use]
pub fn assetless(kind: &str) -> SourceError {
SourceError::Refused {
message: format!("the {kind} plugin cannot store image assets"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn name(name: &str) -> AssetName {
AssetName::new(name).expect("a valid name")
}
#[test]
fn only_a_dot_slash_image_of_an_accepted_bare_name_is_a_reference() {
let content = "  \
   \
[f](./five.png) [g](./notes.txt)  \
  ,
vec![name("one.png"), name("six.JpEg")]
);
}
#[test]
fn only_a_title_may_sit_between_the_target_and_the_closing_parenthesis() {
let content = "   \
) \n\
 \n\
 )) ";
assert_eq!(
asset_references(content),
vec![
name("a.png"),
name("b.png"),
name("c.png"),
name("d.png"),
name("e.png")
]
);
}
#[test]
fn an_escaped_image_and_image_syntax_inside_code_are_not_references() {
let content = "\\ `` ````\n\
\n```\n\n```\n\n~~~md\n\n~~~\n\n \
\n\n- item\n\n ```\n \n ```\n\n\
> ```\n> \n> ```\n\n \\\\\n\
`";
assert_eq!(
asset_references(content),
vec![name("real.png"), name("i.png")]
);
let served = BTreeMap::from([
(name("a.png"), "https://h/a".to_owned()),
(name("d.png"), "https://h/d".to_owned()),
(name("real.png"), "https://h/real".to_owned()),
]);
assert_eq!(
rewrite_asset_references(content, &served),
content.replace("(./real.png)", "(https://h/real)")
);
assert!(
asset_references("```\n\n").is_empty(),
"unclosed fence"
);
assert_eq!(
asset_references(" text\n"),
vec![name("a.png")]
);
assert_eq!(
asset_references("para\n "),
vec![name("a.png")]
);
}
#[test]
fn a_rewrite_touches_the_targets_it_maps_and_nothing_else() {
let content = "see  and  and [c](./one.png)";
let served = BTreeMap::from([(name("one.png"), "https://h/1".to_owned())]);
assert_eq!(
rewrite_asset_references(content, &served),
"see  and  and [c](./one.png)"
);
}
#[test]
fn names_are_refused_by_what_is_wrong_with_them() {
for bad in [
"", "a/b.png", "a\\b.png", "..png", "a..b.png", "a b.png", ".png", "a.txt",
] {
assert!(AssetName::new(bad).is_err(), "{bad:?} was accepted");
}
assert_eq!(name("X.PNG").content_type(), AssetContentType::Png);
assert_eq!(name("x.JpEg").content_type(), AssetContentType::Jpeg);
assert_eq!(name("x.jpg").content_type(), AssetContentType::Jpeg);
assert_eq!(name("x.gif").content_type(), AssetContentType::Gif);
assert_eq!(name("x.webp").content_type(), AssetContentType::Webp);
}
#[test]
fn bytes_cross_as_base64_and_are_omitted_when_absent() {
let payload = AssetPayload::of(name("a.png"), vec![0, 1, 2, 255]);
let value = serde_json::to_value(&payload).expect("serializes");
assert_eq!(value["bytes"], "AAEC/w==");
let back: AssetPayload = serde_json::from_value(value).expect("deserializes");
assert_eq!(back, payload);
let reused = AssetPayload {
bytes: None,
..payload
};
let value = serde_json::to_value(&reused).expect("serializes");
assert!(value.get("bytes").is_none());
}
#[test]
fn a_written_answer_must_carry_content_and_it_may_be_null() {
for content in [Some("".to_owned()), None] {
let written = AssetsWritten {
id: NativeId::from("D-1"),
content,
};
let value = serde_json::to_value(&written).expect("serializes");
assert!(value.get("content").is_some(), "{value}");
let back: AssetsWritten = serde_json::from_value(value).expect("deserializes");
assert_eq!(back, written);
}
let missing = serde_json::from_value::<AssetsWritten>(serde_json::json!({"id": "D-1"}))
.expect_err("an answer without content is refused");
assert!(missing.to_string().contains("content"), "{missing}");
let schema = serde_json::to_value(schemars::schema_for!(AssetsWritten)).expect("a schema");
assert_eq!(schema["required"], serde_json::json!(["id", "content"]));
assert_eq!(
schema["properties"]["content"]["type"],
serde_json::json!(["string", "null"])
);
}
#[test]
fn a_record_whose_digest_is_not_one_is_refused_where_it_is_read() {
let metadata = BTreeMap::from([(
MetadataKey::ASSETS_KEY.to_owned(),
serde_json::json!({"a.png": {"sha256": "not hex", "url": "https://h/a"}}),
)]);
let refused = AssetUploads::read(&metadata).expect_err("refused");
assert!(
refused.contains("a.png") && refused.contains("not hex"),
"{refused}"
);
assert!(is_sha256(&asset_sha256(b"x")));
assert!(!is_sha256(&asset_sha256(b"x").to_uppercase()));
}
#[test]
fn serving_restamps_a_rendering_that_still_matches_and_leaves_a_hand_edit_alone() {
let content = "";
let uploads = AssetUploads(BTreeMap::from([(
name("one.png"),
AssetUpload {
sha256: "00".to_owned(),
url: "https://h/1".to_owned(),
},
)]));
let entry = |digest: String| {
serde_json::json!({
"template": "t", "digest": "sha256:aa", "body_digest": digest,
"answers_digest": "sha256:bb"
})
};
let mut metadata = BTreeMap::from([(
MetadataKey::TEMPLATE_KEY.to_owned(),
entry(body_digest(content)),
)]);
let rewritten = serve_asset_references(content, &mut metadata, &uploads);
assert_eq!(rewritten, "");
assert_eq!(
metadata[MetadataKey::TEMPLATE_KEY],
entry(body_digest(&rewritten))
);
assert_eq!(metadata[MetadataKey::ASSETS_KEY], uploads.to_value());
let mut edited = BTreeMap::from([(
MetadataKey::TEMPLATE_KEY.to_owned(),
entry("sha256:00".to_owned()),
)]);
let _ = serve_asset_references(content, &mut edited, &uploads);
assert_eq!(
edited[MetadataKey::TEMPLATE_KEY],
entry("sha256:00".to_owned())
);
}
}