1use crate::fsutil;
2use crate::{LovelyError, Result};
3use std::collections::BTreeMap;
4use std::fs;
5use std::io::Read;
6use std::path::{Path, PathBuf};
7
8pub const DEFAULT_CHANNEL: &str = "love-11-plus";
9pub const MANIFEST_FILE: &str = "runtime.txt";
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct RuntimeManifest {
13 pub target: String,
14 pub channel: String,
15 pub kind: RuntimeKind,
16 pub source: String,
17 pub sha256: String,
18 pub path: PathBuf,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum RuntimeKind {
23 File,
24 Directory,
25}
26
27impl RuntimeManifest {
28 pub fn parse(text: &str) -> Result<Self> {
29 let mut values = BTreeMap::new();
30 for (index, raw_line) in text.lines().enumerate() {
31 let line = raw_line.trim();
32 if line.is_empty() || line.starts_with('#') {
33 continue;
34 }
35 let Some((key, value)) = line.split_once('=') else {
36 return Err(LovelyError::Config(format!(
37 "runtime manifest line {} is not a key/value pair",
38 index + 1
39 )));
40 };
41 values.insert(key.trim().to_string(), unquote(value.trim()));
42 }
43
44 let kind = match take(&values, "kind")?.as_str() {
45 "file" => RuntimeKind::File,
46 "directory" => RuntimeKind::Directory,
47 other => {
48 return Err(LovelyError::Config(format!(
49 "unsupported runtime kind {other:?}"
50 )));
51 }
52 };
53
54 Ok(Self {
55 target: take(&values, "target")?,
56 channel: take(&values, "channel")?,
57 kind,
58 source: take(&values, "source")?,
59 sha256: take(&values, "sha256")?,
60 path: PathBuf::from(take(&values, "path")?),
61 })
62 }
63
64 pub fn to_text(&self) -> String {
65 format!(
66 r#"# Generated by Lovely. Describes one cached runtime artifact.
67target = "{target}"
68channel = "{channel}"
69kind = "{kind}"
70source = "{source}"
71sha256 = "{sha256}"
72path = "{path}"
73"#,
74 target = escape(&self.target),
75 channel = escape(&self.channel),
76 kind = match self.kind {
77 RuntimeKind::File => "file",
78 RuntimeKind::Directory => "directory",
79 },
80 source = escape(&self.source),
81 sha256 = escape(&self.sha256),
82 path = escape(&fsutil::normalize_slashes(&self.path)),
83 )
84 }
85}
86
87#[derive(Debug, Clone)]
88pub struct RuntimeRegistry {
89 root: PathBuf,
90}
91
92impl RuntimeRegistry {
93 pub fn new() -> Self {
94 Self {
95 root: cache_dir().join("runtimes"),
96 }
97 }
98
99 #[cfg(test)]
100 pub fn at(root: PathBuf) -> Self {
101 Self { root }
102 }
103
104 pub fn root(&self) -> &Path {
105 &self.root
106 }
107
108 pub fn install_local(
109 &self,
110 target: &str,
111 channel: &str,
112 source: &Path,
113 expected_sha256: Option<&str>,
114 ) -> Result<RuntimeManifest> {
115 validate_target(target)?;
116 if source.to_string_lossy().starts_with("http://")
117 || source.to_string_lossy().starts_with("https://")
118 {
119 return Err(LovelyError::Command(
120 "URL runtime fetching is not implemented yet; download the upstream artifact and pass a local path".to_string(),
121 ));
122 }
123 if !source.exists() {
124 return Err(LovelyError::Command(format!(
125 "runtime source does not exist: {}",
126 source.display()
127 )));
128 }
129
130 let kind = if source.is_dir() {
131 RuntimeKind::Directory
132 } else {
133 RuntimeKind::File
134 };
135 let sha256 = hash_path(source)?;
136 if let Some(expected) = expected_sha256
137 && !expected.eq_ignore_ascii_case(&sha256)
138 {
139 return Err(LovelyError::Command(format!(
140 "runtime checksum mismatch for {}: expected {}, got {}",
141 source.display(),
142 expected,
143 sha256
144 )));
145 }
146
147 let target_dir = self.target_dir(channel, target);
148 if target_dir.exists() {
149 fs::remove_dir_all(&target_dir).map_err(|err| LovelyError::io(&target_dir, err))?;
150 }
151 fsutil::ensure_dir(&target_dir)?;
152
153 let relative_path = match kind {
154 RuntimeKind::File => {
155 let file_name = source
156 .file_name()
157 .ok_or_else(|| LovelyError::Command("runtime file has no name".to_string()))?;
158 let destination = target_dir.join(file_name);
159 fsutil::copy_file(source, &destination)?;
160 PathBuf::from(file_name)
161 }
162 RuntimeKind::Directory => {
163 let destination = target_dir.join("files");
164 fsutil::copy_dir_contents(source, &destination)?;
165 PathBuf::from("files")
166 }
167 };
168
169 let manifest = RuntimeManifest {
170 target: target.to_string(),
171 channel: channel.to_string(),
172 kind,
173 source: source.display().to_string(),
174 sha256,
175 path: relative_path,
176 };
177 fsutil::write_string(&target_dir.join(MANIFEST_FILE), &manifest.to_text())?;
178 Ok(manifest)
179 }
180
181 pub fn find(&self, target: &str, channel: &str) -> Result<Option<CachedRuntime>> {
182 let manifest_path = self.target_dir(channel, target).join(MANIFEST_FILE);
183 if !manifest_path.is_file() {
184 return Ok(None);
185 }
186 let manifest = RuntimeManifest::parse(&fsutil::read_to_string(&manifest_path)?)?;
187 let path = self.target_dir(channel, target).join(&manifest.path);
188 Ok(Some(CachedRuntime { manifest, path }))
189 }
190
191 pub fn list(&self) -> Result<Vec<CachedRuntime>> {
192 let mut out = Vec::new();
193 if !self.root.exists() {
194 return Ok(out);
195 }
196 for channel in fs::read_dir(&self.root).map_err(|err| LovelyError::io(&self.root, err))? {
197 let channel = channel.map_err(LovelyError::plain_io)?;
198 if !channel.file_type().map_err(LovelyError::plain_io)?.is_dir() {
199 continue;
200 }
201 for target in fs::read_dir(channel.path()).map_err(LovelyError::plain_io)? {
202 let target = target.map_err(LovelyError::plain_io)?;
203 if !target.file_type().map_err(LovelyError::plain_io)?.is_dir() {
204 continue;
205 }
206 let manifest_path = target.path().join(MANIFEST_FILE);
207 if manifest_path.is_file() {
208 let manifest =
209 RuntimeManifest::parse(&fsutil::read_to_string(&manifest_path)?)?;
210 let path = target.path().join(&manifest.path);
211 out.push(CachedRuntime { manifest, path });
212 }
213 }
214 }
215 out.sort_by(|a, b| {
216 (&a.manifest.channel, &a.manifest.target)
217 .cmp(&(&b.manifest.channel, &b.manifest.target))
218 });
219 Ok(out)
220 }
221
222 fn target_dir(&self, channel: &str, target: &str) -> PathBuf {
223 self.root.join(channel).join(target)
224 }
225}
226
227impl Default for RuntimeRegistry {
228 fn default() -> Self {
229 Self::new()
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct CachedRuntime {
235 pub manifest: RuntimeManifest,
236 pub path: PathBuf,
237}
238
239pub fn cache_dir() -> PathBuf {
240 if let Some(path) = std::env::var_os("LOVELY_CACHE_DIR") {
241 return PathBuf::from(path);
242 }
243 if let Some(home) = std::env::var_os("HOME") {
244 return PathBuf::from(home).join(".cache/lovely");
245 }
246 PathBuf::from(".lovely/cache")
247}
248
249pub fn validate_target(target: &str) -> Result<()> {
250 match target {
251 "web" | "windows" | "macos" | "linux" => Ok(()),
252 _ => Err(LovelyError::Command(format!(
253 "unknown runtime target {target:?}; expected web, windows, macos, or linux"
254 ))),
255 }
256}
257
258pub fn hash_path(path: &Path) -> Result<String> {
259 if path.is_dir() {
260 hash_directory(path)
261 } else {
262 hash_file(path)
263 }
264}
265
266pub fn hash_file(path: &Path) -> Result<String> {
267 let mut file = fs::File::open(path).map_err(|err| LovelyError::io(path, err))?;
268 let mut sha = Sha256::new();
269 let mut buffer = [0u8; 64 * 1024];
270 loop {
271 let read = file.read(&mut buffer).map_err(LovelyError::plain_io)?;
272 if read == 0 {
273 break;
274 }
275 sha.update(&buffer[..read]);
276 }
277 Ok(sha.finish_hex())
278}
279
280fn hash_directory(path: &Path) -> Result<String> {
281 let mut sha = Sha256::new();
282 sha.update(b"lovely-directory-runtime-v1\0");
283 for file in fsutil::collect_files(path)? {
284 let rel = fsutil::relative_path(path, &file)?;
285 let name = fsutil::normalize_slashes(&rel);
286 sha.update(name.as_bytes());
287 sha.update(b"\0");
288 let mut bytes = fs::File::open(&file).map_err(|err| LovelyError::io(&file, err))?;
289 let mut buffer = [0u8; 64 * 1024];
290 loop {
291 let read = bytes.read(&mut buffer).map_err(LovelyError::plain_io)?;
292 if read == 0 {
293 break;
294 }
295 sha.update(&buffer[..read]);
296 }
297 sha.update(b"\0");
298 }
299 Ok(sha.finish_hex())
300}
301
302fn take(values: &BTreeMap<String, String>, key: &str) -> Result<String> {
303 values
304 .get(key)
305 .cloned()
306 .ok_or_else(|| LovelyError::Config(format!("runtime manifest missing {key}")))
307}
308
309fn unquote(value: &str) -> String {
310 if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
311 value[1..value.len() - 1].replace("\\\"", "\"")
312 } else {
313 value.to_string()
314 }
315}
316
317fn escape(input: &str) -> String {
318 input.replace('\\', "\\\\").replace('"', "\\\"")
319}
320
321struct Sha256 {
322 state: [u32; 8],
323 length_bits: u64,
324 buffer: Vec<u8>,
325}
326
327impl Sha256 {
328 fn new() -> Self {
329 Self {
330 state: [
331 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
332 0x5be0cd19,
333 ],
334 length_bits: 0,
335 buffer: Vec::with_capacity(64),
336 }
337 }
338
339 fn update(&mut self, input: &[u8]) {
340 self.length_bits = self.length_bits.wrapping_add((input.len() as u64) * 8);
341 self.buffer.extend_from_slice(input);
342 while self.buffer.len() >= 64 {
343 let mut block = [0u8; 64];
344 block.copy_from_slice(&self.buffer[..64]);
345 self.compress(&block);
346 self.buffer.drain(..64);
347 }
348 }
349
350 fn finish_hex(mut self) -> String {
351 self.buffer.push(0x80);
352 while self.buffer.len() % 64 != 56 {
353 self.buffer.push(0);
354 }
355 self.buffer
356 .extend_from_slice(&self.length_bits.to_be_bytes());
357
358 let blocks = self.buffer.clone();
359 for block in blocks.chunks(64) {
360 let mut fixed = [0u8; 64];
361 fixed.copy_from_slice(block);
362 self.compress(&fixed);
363 }
364
365 self.state
366 .iter()
367 .map(|word| format!("{word:08x}"))
368 .collect::<Vec<_>>()
369 .join("")
370 }
371
372 fn compress(&mut self, block: &[u8; 64]) {
373 const K: [u32; 64] = [
374 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
375 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
376 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
377 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
378 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
379 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
380 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
381 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
382 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
383 0xc67178f2,
384 ];
385
386 let mut w = [0u32; 64];
387 for (i, chunk) in block.chunks_exact(4).take(16).enumerate() {
388 w[i] = u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
389 }
390 for i in 16..64 {
391 let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
392 let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
393 w[i] = w[i - 16]
394 .wrapping_add(s0)
395 .wrapping_add(w[i - 7])
396 .wrapping_add(s1);
397 }
398
399 let mut a = self.state[0];
400 let mut b = self.state[1];
401 let mut c = self.state[2];
402 let mut d = self.state[3];
403 let mut e = self.state[4];
404 let mut f = self.state[5];
405 let mut g = self.state[6];
406 let mut h = self.state[7];
407
408 for i in 0..64 {
409 let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
410 let ch = (e & f) ^ ((!e) & g);
411 let temp1 = h
412 .wrapping_add(s1)
413 .wrapping_add(ch)
414 .wrapping_add(K[i])
415 .wrapping_add(w[i]);
416 let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
417 let maj = (a & b) ^ (a & c) ^ (b & c);
418 let temp2 = s0.wrapping_add(maj);
419
420 h = g;
421 g = f;
422 f = e;
423 e = d.wrapping_add(temp1);
424 d = c;
425 c = b;
426 b = a;
427 a = temp1.wrapping_add(temp2);
428 }
429
430 self.state[0] = self.state[0].wrapping_add(a);
431 self.state[1] = self.state[1].wrapping_add(b);
432 self.state[2] = self.state[2].wrapping_add(c);
433 self.state[3] = self.state[3].wrapping_add(d);
434 self.state[4] = self.state[4].wrapping_add(e);
435 self.state[5] = self.state[5].wrapping_add(f);
436 self.state[6] = self.state[6].wrapping_add(g);
437 self.state[7] = self.state[7].wrapping_add(h);
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
446 fn sha256_known_value() {
447 let mut sha = Sha256::new();
448 sha.update(b"abc");
449 assert_eq!(
450 sha.finish_hex(),
451 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
452 );
453 }
454
455 #[test]
456 fn manifest_round_trips() {
457 let manifest = RuntimeManifest {
458 target: "web".to_string(),
459 channel: DEFAULT_CHANNEL.to_string(),
460 kind: RuntimeKind::Directory,
461 source: "/tmp/runtime".to_string(),
462 sha256: "abc".to_string(),
463 path: PathBuf::from("files"),
464 };
465 assert_eq!(
466 RuntimeManifest::parse(&manifest.to_text()).unwrap(),
467 manifest
468 );
469 }
470}