Skip to main content

hara_native/
asset.rs

1//! Local versioned asset collection tooling.
2
3use crate::kernel::{parse, Form};
4use crate::project;
5use sha2::{Digest, Sha256};
6use std::fs;
7use std::path::{Component, Path, PathBuf};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct AssetCollection {
11    pub root: PathBuf,
12    pub coordinate: String,
13    pub version: String,
14    pub entries: Vec<AssetEntry>,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct AssetEntry {
19    pub path: String,
20    pub media_type: String,
21}
22
23pub fn run(args: &[String]) -> Result<(), String> {
24    match args.first().map(String::as_str) {
25        None | Some("--help" | "-h") => {
26            usage();
27            Ok(())
28        }
29        Some("check") => {
30            let collection = read_collection(path_arg(args, 1))?;
31            verify_files(&collection)?;
32            println!(
33                "asset check: {} {} files={}",
34                collection.coordinate,
35                collection.version,
36                collection.entries.len()
37            );
38            Ok(())
39        }
40        Some("build") => {
41            let collection = read_collection(path_arg(args, 1))?;
42            let output = option(args, "--output")
43                .map(PathBuf::from)
44                .unwrap_or_else(|| collection.root.join("target/asset-manifest.edn"));
45            let manifest = build_manifest(&collection)?;
46            if let Some(parent) = output.parent() {
47                fs::create_dir_all(parent).map_err(io)?;
48            }
49            fs::write(&output, manifest).map_err(io)?;
50            println!("asset build: {}", output.display());
51            Ok(())
52        }
53        Some("inspect") => {
54            let input = args.get(1).ok_or("asset inspect requires MANIFEST")?;
55            print!("{}", fs::read_to_string(input).map_err(io)?);
56            Ok(())
57        }
58        Some("publish" | "status" | "search" | "info" | "pull" | "sync" | "yank") => Err(format!(
59            "unavailable: hara asset {} requires the packages.hara-lang.org registry client",
60            args[0]
61        )),
62        Some(command) => Err(format!("unknown asset command: {command}")),
63    }
64}
65
66pub fn read_collection(input: &Path) -> Result<AssetCollection, String> {
67    let descriptor = if input.is_dir() {
68        input.join("asset.edn")
69    } else {
70        input.to_owned()
71    };
72    let root = descriptor
73        .parent()
74        .unwrap_or_else(|| Path::new("."))
75        .to_path_buf();
76    let source = fs::read_to_string(&descriptor)
77        .map_err(|error| format!("cannot read {}: {error}", descriptor.display()))?;
78    let form = parse(&source).map_err(|error| format!("{}: {error}", descriptor.display()))?;
79    let map = as_map(&form, "asset.edn must be an EDN map")?;
80    match required(map, "asset/format")? {
81        Form::String(version) if version == "0.0.0-alpha" => {}
82        _ => return Err("asset.edn requires alpha asset format".into()),
83    }
84    let coordinate = project::normalize_coordinate(&string(
85        required(map, "asset/coordinate")?,
86        ":asset/coordinate",
87    )?)?;
88    let version = string(required(map, "asset/version")?, ":asset/version")?;
89    semver::Version::parse(&version)
90        .map_err(|error| format!("asset.edn :asset/version: {error}"))?;
91    let entries = vector(required(map, "asset/entries")?, ":asset/entries")?
92        .iter()
93        .map(|entry| {
94            let entry = as_map(entry, "asset entry must be a map")?;
95            Ok(AssetEntry {
96                path: safe_path(&string(required(entry, "entry/path")?, ":entry/path")?)?,
97                media_type: string(required(entry, "entry/media-type")?, ":entry/media-type")?,
98            })
99        })
100        .collect::<Result<Vec<_>, String>>()?;
101    if entries.is_empty() {
102        return Err("asset.edn :asset/entries must not be empty".into());
103    }
104    let mut names = std::collections::BTreeSet::new();
105    if entries.iter().any(|entry| !names.insert(&entry.path)) {
106        return Err("asset.edn contains duplicate :entry/path values".into());
107    }
108    Ok(AssetCollection {
109        root,
110        coordinate,
111        version,
112        entries,
113    })
114}
115
116pub fn build_manifest(collection: &AssetCollection) -> Result<String, String> {
117    verify_files(collection)?;
118    let mut entries = collection.entries.clone();
119    entries.sort_by(|left, right| left.path.cmp(&right.path));
120    let mut output = format!(
121        "{{:asset/format \"0.0.0-alpha\"\n :asset/coordinate {}\n :asset/version {}\n :asset/entries [\n",
122        edn_string(&collection.coordinate),
123        edn_string(&collection.version)
124    );
125    for entry in entries {
126        let bytes = fs::read(collection.root.join(&entry.path)).map_err(io)?;
127        output.push_str(&format!(
128            "  {{:entry/path {} :entry/media-type {} :entry/size {} :entry/sha256 \"sha256:{}\"}}\n",
129            edn_string(&entry.path),
130            edn_string(&entry.media_type),
131            bytes.len(),
132            sha256(&bytes)
133        ));
134    }
135    output.push_str(" ]}\n");
136    Ok(output)
137}
138
139fn verify_files(collection: &AssetCollection) -> Result<(), String> {
140    for entry in &collection.entries {
141        let path = collection.root.join(&entry.path);
142        if !path.is_file() {
143            return Err(format!("asset entry does not exist: {}", path.display()));
144        }
145    }
146    Ok(())
147}
148
149fn safe_path(value: &str) -> Result<String, String> {
150    let path = Path::new(value);
151    if value.is_empty()
152        || path.is_absolute()
153        || path
154            .components()
155            .any(|part| !matches!(part, Component::Normal(_)))
156    {
157        return Err(format!("unsafe asset path: {value}"));
158    }
159    Ok(value.replace('\\', "/"))
160}
161
162fn as_map<'a>(form: &'a Form, message: &str) -> Result<&'a [(Form, Form)], String> {
163    match form {
164        Form::Map(values) => Ok(values),
165        _ => Err(message.into()),
166    }
167}
168
169fn required<'a>(values: &'a [(Form, Form)], key: &str) -> Result<&'a Form, String> {
170    values
171        .iter()
172        .find_map(|(candidate, value)| {
173            matches!(candidate, Form::Keyword(name) if name == key).then_some(value)
174        })
175        .ok_or_else(|| format!("asset.edn is missing :{key}"))
176}
177
178fn string(form: &Form, label: &str) -> Result<String, String> {
179    match form {
180        Form::String(value) => Ok(value.clone()),
181        Form::Symbol(value) => Ok(value.clone()),
182        _ => Err(format!("{label} must be a string or symbol")),
183    }
184}
185
186fn vector<'a>(form: &'a Form, label: &str) -> Result<&'a [Form], String> {
187    match form {
188        Form::Vector(values) => Ok(values),
189        _ => Err(format!("{label} must be a vector")),
190    }
191}
192
193fn sha256(bytes: &[u8]) -> String {
194    let mut digest = Sha256::new();
195    digest.update(bytes);
196    digest
197        .finalize()
198        .iter()
199        .map(|byte| format!("{byte:02x}"))
200        .collect()
201}
202
203fn path_arg(args: &[String], index: usize) -> &Path {
204    args.get(index)
205        .filter(|value| !value.starts_with('-'))
206        .map(Path::new)
207        .unwrap_or_else(|| Path::new("."))
208}
209
210fn option<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
211    args.iter()
212        .position(|value| value == name)
213        .and_then(|index| args.get(index + 1))
214        .map(String::as_str)
215}
216
217fn edn_string(value: &str) -> String {
218    format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
219}
220
221fn io(error: std::io::Error) -> String {
222    error.to_string()
223}
224
225fn usage() {
226    println!("hara asset check [PATH]");
227    println!("hara asset build [PATH] [--output PATH]");
228    println!("hara asset inspect MANIFEST");
229    println!("hara asset <publish|status|search|info|pull|sync|yank>");
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use std::time::{SystemTime, UNIX_EPOCH};
236
237    #[test]
238    fn builds_a_stable_digest_manifest_and_rejects_unsafe_paths() {
239        let root = std::env::temp_dir().join(format!(
240            "hara-assets-{}",
241            SystemTime::now()
242                .duration_since(UNIX_EPOCH)
243                .unwrap()
244                .as_nanos()
245        ));
246        fs::create_dir_all(root.join("images")).unwrap();
247        fs::write(root.join("images/hero.png"), b"png").unwrap();
248        fs::write(
249            root.join("asset.edn"),
250            "{:asset/format \"0.0.0-alpha\" :asset/coordinate \"alice/gallery\" :asset/version \"1.0.0\" :asset/entries [{:entry/path \"images/hero.png\" :entry/media-type \"image/png\"}]}\n",
251        )
252        .unwrap();
253        let collection = read_collection(&root).unwrap();
254        let first = build_manifest(&collection).unwrap();
255        let second = build_manifest(&collection).unwrap();
256        assert_eq!(first, second);
257        assert!(first.contains(":asset/coordinate \"hara:alice/gallery\""));
258        assert!(first.contains("sha256:"));
259        fs::write(
260            root.join("asset.edn"),
261            "{:asset/format \"0.0.0-alpha\" :asset/coordinate \"alice/gallery\" :asset/version \"1.0.0\" :asset/entries [{:entry/path \"../escape\" :entry/media-type \"application/octet-stream\"}]}\n",
262        )
263        .unwrap();
264        assert!(read_collection(&root)
265            .unwrap_err()
266            .contains("unsafe asset path"));
267        fs::remove_dir_all(root).unwrap();
268    }
269}