use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use sha2::{Digest as _, Sha256};
use crate::skill::SKILL_FILE;
const IGNORED: [&str; 2] = [".DS_Store", "Thumbs.db"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Record {
pub installed_by: String,
pub installed_version: String,
pub content_digest: String,
}
const DIGEST_KEY: &str = " content-digest: ";
fn blanked(skill_md: &[u8]) -> Vec<u8> {
let Ok(text) = std::str::from_utf8(skill_md) else {
return skill_md.to_vec();
};
let mut out = String::with_capacity(text.len());
let mut in_frontmatter = false;
for (index, line) in text.split_inclusive('\n').enumerate() {
let bare = line.trim_end_matches(['\n', '\r']);
if bare == "---" {
in_frontmatter = index == 0;
}
if in_frontmatter && bare.starts_with(DIGEST_KEY) {
out.push_str(DIGEST_KEY);
out.push_str("\"\"");
out.push_str(&line[bare.len()..]);
} else {
out.push_str(line);
}
}
out.into_bytes()
}
pub fn digest(files: &BTreeMap<PathBuf, Vec<u8>>) -> String {
let mut hasher = Sha256::new();
for (path, content) in files {
let content = if path == Path::new(SKILL_FILE) {
blanked(content)
} else {
content.clone()
};
hasher.update(portable(path).as_bytes());
hasher.update([0]);
hasher.update((content.len() as u64).to_be_bytes());
hasher.update(&content);
}
let hex = hasher
.finalize()
.iter()
.fold(String::with_capacity(64), |mut hex, byte| {
let _ = write!(hex, "{byte:02x}");
hex
});
format!("sha256:{hex}")
}
fn portable(path: &Path) -> String {
path.components()
.map(|part| part.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
pub fn sealed(mut files: BTreeMap<PathBuf, Vec<u8>>) -> BTreeMap<PathBuf, Vec<u8>> {
let value = digest(&files);
if let Some(skill_md) = files.get_mut(Path::new(SKILL_FILE)) {
let text = String::from_utf8_lossy(skill_md).into_owned();
let line = format!("{DIGEST_KEY}\"\"\n");
let sealed_line = format!("{DIGEST_KEY}\"{value}\"\n");
*skill_md = text.replacen(&line, &sealed_line, 1).into_bytes();
}
files
}
pub fn read_dir(root: &Path) -> io::Result<BTreeMap<PathBuf, Vec<u8>>> {
let mut files = BTreeMap::new();
let mut pending = vec![PathBuf::new()];
while let Some(relative) = pending.pop() {
for entry in fs::read_dir(root.join(&relative))? {
let entry = entry?;
let name = entry.file_name();
if IGNORED.iter().any(|ignored| name == *ignored) {
continue;
}
let path = relative.join(&name);
let kind = entry.file_type()?;
if kind.is_dir() {
pending.push(path);
} else if kind.is_file() {
files.insert(path, fs::read(entry.path())?);
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{} is neither a file nor a directory", path.display()),
));
}
}
}
Ok(files)
}
pub fn parse(skill_md: &[u8]) -> Option<Record> {
let text = std::str::from_utf8(skill_md).ok()?;
let mut lines = text.lines();
if lines.next()? != "---" {
return None;
}
let mut in_metadata = false;
let mut values = BTreeMap::new();
for line in lines {
if line == "---" {
break;
}
if !line.starts_with(' ') {
in_metadata = line == "metadata:";
continue;
}
if in_metadata && let Some((key, value)) = line.trim_start().split_once(": ") {
values.insert(key.to_string(), unquote(value)?);
}
}
Some(Record {
installed_by: values.remove("installed-by")?,
installed_version: values.remove("installed-version")?,
content_digest: values.remove("content-digest")?,
})
}
fn unquote(value: &str) -> Option<String> {
let inner = value.strip_prefix('"')?.strip_suffix('"')?;
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next()? {
'"' => out.push('"'),
'\\' => out.push('\\'),
'n' => out.push('\n'),
'r' => out.push('\r'),
't' => out.push('\t'),
'u' => {
let code: String = chars.by_ref().take(4).collect();
out.push(char::from_u32(u32::from_str_radix(&code, 16).ok()?)?);
}
_ => return None,
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn files(nested: PathBuf) -> BTreeMap<PathBuf, Vec<u8>> {
BTreeMap::from([
(
PathBuf::from(SKILL_FILE),
b"---\nname: x\n---\nbody\n".to_vec(),
),
(nested, b"# CLI\n".to_vec()),
])
}
#[test]
fn the_digest_does_not_depend_on_the_path_separator() {
let given = digest(&files(PathBuf::from("references/cli.md")));
let read_back = digest(&files(Path::new("references").join("cli.md")));
assert_eq!(given, read_back);
assert_eq!(given, GOLDEN);
}
const GOLDEN: &str = "sha256:6cc04206c47535a97a74d522b2dcc77d36d66e7cae2f3ddcae4f949e6bd5d252";
}