Skip to content
228 linesCodeBlameRaw
1//! The sandbox's images: fetched from the release they belong to, checked
2//! against the signed manifest, and kept by version in the state folder.
3//!
4//! The trust model is the runner's own (update.rs): `latest.json` is only
5//! believed when its Ed25519 signature is g1t's release key's, built into
6//! this program, and a file is only used when its SHA-256 is the one the
7//! signed manifest names. A file already on disk is checked again before
8//! each use. The runner's image inside the sandbox is named by digest in
9//! the same manifest, so the engine that pulls it checks it too.
10//!
11//! A build without the release key (a developer's) cannot check anything,
12//! so it fetches nothing: `--image-file` gives it a local image instead,
13//! and it says loudly that it is unchecked.
14
15use std::collections::BTreeMap;
16use std::io::{Read, Write};
17use std::path::{Path, PathBuf};
18
19use anyhow::{Context, Result, anyhow, bail};
20use sha2::{Digest, Sha256};
21
22use crate::selfhosted::update::{self, SandboxFile};
23
24/// The images a sandbox needs, on disk and checked.
25#[derive(Clone, Debug, Default)]
26pub struct Local {
27 pub version: String,
28 /// By kind (`wsl-x64`, `vm-arm64-disk`…).
29 pub files: BTreeMap<String, PathBuf>,
30 /// The runner's image, by digest.
31 pub runner_image: Option<String>,
32}
33
34/// This machine's architecture, as releases name it.
35pub fn arch() -> &'static str {
36 if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" }
37}
38
39/// SHA-256 of a file, read in pieces.
40pub fn sha256_file(path: &Path) -> Result<String> {
41 let mut file = std::fs::File::open(path).with_context(|| format!("could not open {}", path.display()))?;
42 let mut hasher = Sha256::new();
43 let mut buffer = vec![0u8; 1 << 20];
44 loop {
45 let read = file.read(&mut buffer)?;
46 if read == 0 {
47 break;
48 }
49 hasher.update(&buffer[..read]);
50 }
51 Ok(hex::encode(hasher.finalize()))
52}
53
54/// Whether a release's sandbox has every file of `keys`, and the newest
55/// signed release's version.
56pub fn latest(site: &str) -> Result<update::Manifest> {
57 update::latest_signed_at(site)?.ok_or_else(|| {
58 anyhow!("this build of g1t-runner has no release key, so it cannot check the sandbox's images: pass --image-file with an image you built (scripts/sandbox-image.mjs)")
59 })
60}
61
62/// Fetches (or finds) and checks the files of `keys` from the signed
63/// release `manifest`.
64pub fn obtain(site: &str, manifest: &update::Manifest, keys: &[String], images: &Path, progress: &dyn Fn(&str)) -> Result<Local> {
65 let sandbox = manifest.sandbox.as_ref().ok_or_else(|| anyhow!("release {} has no sandbox images", manifest.version))?;
66 let dir = images.join(&manifest.version);
67 std::fs::create_dir_all(&dir).with_context(|| format!("could not make {}", dir.display()))?;
68 let mut files = BTreeMap::new();
69 for key in keys {
70 let file = sandbox.files.get(key).ok_or_else(|| anyhow!("release {} has no sandbox image {key} for this machine", manifest.version))?;
71 let path = dir.join(safe_name(&file.name)?);
72 if !(path.exists() && sha256_file(&path)? == file.sha256) {
73 progress(&format!("Fetching the sandbox's {key} image ({} MB)", file.size.div_ceil(1_000_000)));
74 download_checked(&format!("{}/{}/{}", update::downloads_at(site), manifest.version, file.name), &path, file, &|percent| {
75 progress(&said_progress(key, percent, file.size));
76 })?;
77 }
78 files.insert(key.clone(), path);
79 }
80 Ok(Local { version: manifest.version.clone(), files, runner_image: sandbox.runner_image.clone() })
81}
82
83/// How far a download is, as a log line the desktop app reads for its
84/// progress bar: `Fetching the sandbox's wsl-x64 image: 40% of 612 MB`.
85pub fn said_progress(key: &str, percent: u64, size: u64) -> String {
86 format!("Fetching the sandbox's {key} image: {percent}% of {} MB", size.div_ceil(1_000_000))
87}
88
89/// A file name from a manifest, which must not name a folder.
90fn safe_name(name: &str) -> Result<&str> {
91 if name.is_empty() || name.contains(['/', '\\']) || name.starts_with('.') {
92 bail!("the release names a sandbox image {name:?}, which is not a file name");
93 }
94 Ok(name)
95}
96
97/// Downloads to `path`, hashing as it goes; the file only takes its name
98/// once its SHA-256 is the manifest's. Says how far it is at each tenth
99/// (`progress`, in percent), when the manifest gives the file's size.
100fn download_checked(url: &str, path: &Path, want: &SandboxFile, progress: &dyn Fn(u64)) -> Result<()> {
101 let response = ureq::get(url).timeout(std::time::Duration::from_secs(3600)).call().map_err(|e| anyhow!("could not fetch {url}: {e}"))?;
102 let part = path.with_extension("part");
103 let mut out = std::fs::File::create(&part).with_context(|| format!("could not write {}", part.display()))?;
104 let mut reader = response.into_reader();
105 let mut hasher = Sha256::new();
106 let mut buffer = vec![0u8; 1 << 20];
107 let (mut done, mut said) = (0u64, 0u64);
108 loop {
109 let read = reader.read(&mut buffer).with_context(|| format!("the download of {url} broke off"))?;
110 if read == 0 {
111 break;
112 }
113 hasher.update(&buffer[..read]);
114 out.write_all(&buffer[..read])?;
115 done += read as u64;
116 let tenth = tenths(done, want.size);
117 if tenth > said {
118 said = tenth;
119 progress(tenth * 10);
120 }
121 }
122 out.sync_all()?;
123 drop(out);
124 if hex::encode(hasher.finalize()) != want.sha256 {
125 let _ = std::fs::remove_file(&part);
126 bail!("the sandbox image {}'s SHA-256 is not the release's; not using it", want.name);
127 }
128 std::fs::rename(&part, path)?;
129 Ok(())
130}
131
132/// How many whole tenths of `size` are `done`, below ten: the last is
133/// said by the download ending. None of a file of no known size.
134fn tenths(done: u64, size: u64) -> u64 {
135 done.saturating_mul(10).checked_div(size).map_or(0, |tenths| tenths.min(9))
136}
137
138/// A local image (`--image-file`), unchecked: for a build of the sandbox
139/// that is not released.
140pub fn local(keys: &[String], given: &Path, runner_image: Option<String>) -> Result<Local> {
141 if !given.exists() {
142 bail!("{} does not exist", given.display());
143 }
144 let mut files = BTreeMap::new();
145 if keys.len() == 1 {
146 files.insert(keys[0].clone(), given.to_path_buf());
147 } else {
148 // A folder holding the VM's files, named as a release names them.
149 for key in keys {
150 let suffix = key.rsplit('-').next().unwrap_or(key);
151 let found = std::fs::read_dir(given)?
152 .flatten()
153 .map(|e| e.path())
154 .find(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.contains(suffix)))
155 .ok_or_else(|| anyhow!("{} has no {key} file", given.display()))?;
156 files.insert(key.clone(), found);
157 }
158 }
159 Ok(Local { version: "local".into(), files, runner_image })
160}
161
162/// Removes every version but `keep`.
163pub fn prune(images: &Path, keep: &str) {
164 let Ok(entries) = std::fs::read_dir(images) else { return };
165 for entry in entries.flatten() {
166 if entry.file_name().to_string_lossy() != keep {
167 let _ = std::fs::remove_dir_all(entry.path());
168 }
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn a_files_hash_is_read_in_pieces() {
178 let dir = std::env::temp_dir().join(format!("g1t-sandbox-image-{}", std::process::id()));
179 std::fs::create_dir_all(&dir).unwrap();
180 let path = dir.join("x");
181 std::fs::write(&path, b"abc").unwrap();
182 assert_eq!(sha256_file(&path).unwrap(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
183 let _ = std::fs::remove_dir_all(&dir);
184 }
185
186 #[test]
187 fn a_manifest_cannot_name_a_folder() {
188 assert!(safe_name("g1t-sandbox-wsl-x64.tar.gz").is_ok());
189 for bad in ["../x", "a/b", "a\\b", ".hidden", ""] {
190 assert!(safe_name(bad).is_err(), "{bad}");
191 }
192 }
193
194 #[test]
195 fn a_release_without_the_sandbox_is_refused() {
196 let manifest: update::Manifest = serde_json::from_str(r#"{"version":"0.2.0","files":{}}"#).unwrap();
197 let refused = obtain("https://g1t.sh", &manifest, &["wsl-x64".into()], Path::new("."), &|_| {}).unwrap_err();
198 assert!(refused.to_string().contains("no sandbox images"));
199 let manifest: update::Manifest =
200 serde_json::from_str(r#"{"version":"0.3.0","files":{},"sandbox":{"runner_image":"r@sha256:1","files":{"wsl-x64":{"name":"w.tar.gz","sha256":"00"}}}}"#).unwrap();
201 let refused = obtain("https://g1t.sh", &manifest, &["vm-arm64-disk".into()], &std::env::temp_dir(), &|_| {}).unwrap_err();
202 assert!(refused.to_string().contains("no sandbox image vm-arm64-disk"));
203 }
204
205 #[test]
206 fn a_download_says_how_far_it_is_by_tenths() {
207 assert_eq!(tenths(0, 600), 0);
208 assert_eq!(tenths(59, 600), 0);
209 assert_eq!(tenths(60, 600), 1);
210 assert_eq!(tenths(599, 600), 9);
211 assert_eq!(tenths(600, 600), 9);
212 assert_eq!(tenths(5, 0), 0);
213 assert_eq!(said_progress("wsl-x64", 40, 612_000_000), "Fetching the sandbox's wsl-x64 image: 40% of 612 MB");
214 }
215
216 #[test]
217 fn a_local_vm_image_is_found_by_its_parts() {
218 let dir = std::env::temp_dir().join(format!("g1t-sandbox-local-{}", std::process::id()));
219 std::fs::create_dir_all(&dir).unwrap();
220 for name in ["g1t-sandbox-vm-arm64-kernel", "g1t-sandbox-vm-arm64-initrd", "g1t-sandbox-vm-arm64-disk.raw.gz"] {
221 std::fs::write(dir.join(name), b"x").unwrap();
222 }
223 let keys: Vec<String> = ["kernel", "initrd", "disk"].iter().map(|k| format!("vm-arm64-{k}")).collect();
224 let found = local(&keys, &dir, None).unwrap();
225 assert!(found.files["vm-arm64-disk"].to_string_lossy().ends_with("disk.raw.gz"));
226 let _ = std::fs::remove_dir_all(&dir);
227 }
228}