1use 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}