Skip to main content

lovely/
runtime.rs

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}