Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
374 linesCodeBlameRaw
1//! `g1t-runner` itself, kept in the app's data folder and run as a child
2//! of the app: for the Docker and On this computer backends, and for the
3//! sandbox launcher, which is the same program.
4//!
5//! The app installs and updates it from the server's releases: the
6//! newest one's manifest is `<server>/downloads/runner/latest.json`, which
7//! names each platform's file and its SHA-256, and `latest.json.sig` is
8//! its Ed25519 signature, made with g1t's release key
9//! (scripts/runner-release.mjs). The app trusts a manifest only when that
10//! signature checks out against the key built into it, as the runner does
11//! with its own updates (crates/runner/src/selfhosted/update.rs), and
12//! throws away a download whose SHA-256 is not the manifest's. The
13//! runner's own updating is turned off (`G1T_RUNNER_NO_AUTO_UPDATE`), so
14//! only the app replaces it, and only while it runs nothing.
15
16use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18use std::process::{Child, Stdio};
19use std::time::Duration;
20
21use base64::Engine;
22use base64::engine::general_purpose::STANDARD;
23use serde::Deserialize;
24use sha2::{Digest, Sha256};
25use url::Url;
26
27use super::backend::quiet;
28
29/// g1t's release key, as base64 of its 32 bytes: the public half of the
30/// key that signs every runner release (the repository variable
31/// RUNNER_RELEASE_PUBLIC_KEY, which the runner's own builds take as
32/// G1T_RUNNER_RELEASE_KEY). It is public, so it is kept here, where every
33/// build has it, development builds included; someone who builds the app
34/// for a g1t of their own, with their own release key, sets
35/// G1T_RUNNER_RELEASE_KEY when they build it.
36const RELEASE_KEY: &str = match option_env!("G1T_RUNNER_RELEASE_KEY") {
37 Some(key) => key,
38 None => "C8TxhitVxZpx+VLr3gtu0nq7I46q2awBsPZISwDzIgI=",
39};
40
41/// Why the releases couldn't be had.
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub enum Fault {
44 /// The manifest isn't signed with g1t's release key: nothing in it is used.
45 Unsigned(String),
46 /// It couldn't be fetched or read, or a download didn't check out.
47 Failed(String),
48}
49
50impl std::fmt::Display for Fault {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 match self {
53 Fault::Unsigned(why) | Fault::Failed(why) => f.write_str(why),
54 }
55 }
56}
57
58#[derive(Deserialize)]
59pub struct File {
60 pub name: String,
61 pub sha256: String,
62}
63
64#[derive(Deserialize)]
65pub struct Manifest {
66 pub version: String,
67 pub files: BTreeMap<String, File>,
68 /// The sandbox's images, which `sandbox update` takes: none in
69 /// releases from before them, or published without them.
70 #[serde(default)]
71 pub sandbox: Option<SandboxRelease>,
72}
73
74#[derive(Default, Deserialize)]
75pub struct SandboxRelease {
76 #[serde(default)]
77 pub files: BTreeMap<String, serde::de::IgnoredAny>,
78}
79
80impl Manifest {
81 /// Whether the release has the sandbox's images, so `sandbox update` has something to take.
82 pub fn has_images(&self) -> bool {
83 self.sandbox.as_ref().is_some_and(|sandbox| !sandbox.files.is_empty())
84 }
85}
86
87/// This computer's platform, as releases name it.
88pub fn platform() -> String {
89 let os = match std::env::consts::OS {
90 "macos" => "macos",
91 "windows" => "windows",
92 _ => "linux",
93 };
94 let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" };
95 format!("{os}-{arch}")
96}
97
98/// Where the program is kept.
99pub fn binary(folder: &Path) -> PathBuf {
100 folder.join("bin").join(if cfg!(windows) { "g1t-runner.exe" } else { "g1t-runner" })
101}
102
103/// The version installed, as the manifest named it.
104pub fn installed(folder: &Path) -> Option<String> {
105 binary(folder).exists().then(|| std::fs::read_to_string(folder.join("bin").join("version")).ok()).flatten().map(|v| v.trim().to_owned())
106}
107
108/// `a.b.c` newer than `x.y.z`.
109pub fn newer(candidate: &str, current: &str) -> bool {
110 let parts = |v: &str| -> Vec<u64> { v.trim_start_matches('v').split(['.', '-']).take(3).map(|p| p.parse().unwrap_or(0)).collect() };
111 parts(candidate) > parts(current)
112}
113
114pub fn sha256(bytes: &[u8]) -> String {
115 Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect()
116}
117
118fn downloads(server: &Url) -> String {
119 format!("{}downloads/runner", server.as_str())
120}
121
122fn get(url: &str) -> Result<Vec<u8>, String> {
123 tauri::async_runtime::block_on(async {
124 let client = reqwest::Client::builder().timeout(Duration::from_secs(300)).build().map_err(|e| e.to_string())?;
125 let response = client.get(url).send().await.map_err(|e| e.to_string())?;
126 if !response.status().is_success() {
127 return Err(format!("{url} answered {}", response.status()));
128 }
129 response.bytes().await.map(|bytes| bytes.to_vec()).map_err(|e| e.to_string())
130 })
131}
132
133/// Whether `signature` (base64) is the release key's over `message`.
134pub fn verify(message: &[u8], signature: &str, key: &str) -> Result<(), Fault> {
135 let unsigned = |why: &str| Fault::Unsigned(why.to_owned());
136 let key: [u8; 32] = STANDARD
137 .decode(key.trim())
138 .ok()
139 .and_then(|bytes| bytes.try_into().ok())
140 .ok_or_else(|| unsigned("the release key built into g1t doesn't read"))?;
141 let signature: [u8; 64] = STANDARD
142 .decode(signature.trim())
143 .ok()
144 .and_then(|bytes| bytes.try_into().ok())
145 .ok_or_else(|| unsigned("the release's signature doesn't read"))?;
146 let key = ed25519_dalek::VerifyingKey::from_bytes(&key).map_err(|_| unsigned("the release key built into g1t isn't a key"))?;
147 key.verify_strict(message, &ed25519_dalek::Signature::from_bytes(&signature))
148 .map_err(|_| unsigned("the release's signature isn't g1t's"))
149}
150
151/// A manifest, once its signature checks out against `key`.
152fn signed(manifest: &[u8], signature: &str, key: &str) -> Result<Manifest, Fault> {
153 verify(manifest, signature, key)?;
154 serde_json::from_slice(manifest).map_err(|e| Fault::Failed(format!("the runner's release manifest doesn't read: {e}")))
155}
156
157/// The newest release's manifest, signed by g1t. One without a
158/// signature, or whose signature doesn't check out, is refused.
159pub fn latest(server: &Url) -> Result<Manifest, Fault> {
160 let base = downloads(server);
161 let manifest = get(&format!("{base}/latest.json")).map_err(Fault::Failed)?;
162 let signature = get(&format!("{base}/latest.json.sig")).map_err(|why| if why.contains("404") { Fault::Unsigned("the release has no signature".into()) } else { Fault::Failed(why) })?;
163 signed(&manifest, &String::from_utf8_lossy(&signature), RELEASE_KEY)
164}
165
166/// Downloads a release's program for this platform and checks it against
167/// the manifest's SHA-256.
168pub fn download(server: &Url, manifest: &Manifest) -> Result<Vec<u8>, String> {
169 let file = manifest.files.get(&platform()).ok_or_else(|| format!("g1t-runner {} has no build for {}", manifest.version, platform()))?;
170 let bytes = get(&format!("{}/{}/{}", downloads(server), manifest.version, file.name))?;
171 if sha256(&bytes) != file.sha256.to_ascii_lowercase() {
172 return Err("the download's SHA-256 isn't the release's, so it was thrown away".into());
173 }
174 Ok(bytes)
175}
176
177/// Puts a checked program in place. The runner it replaces must not be
178/// running, but a short launcher command may be (a `sandbox status` the
179/// app is asking at that moment): the program there is moved aside first,
180/// under a name of its own, since Windows won't overwrite a program that
181/// is running but lets it be renamed. Those left from earlier updates are
182/// removed here, once nothing runs them.
183pub fn put(folder: &Path, bytes: &[u8], version: &str) -> Result<(), String> {
184 let path = binary(folder);
185 let dir = path.parent().unwrap_or(folder);
186 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
187 for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
188 if entry.file_name().to_string_lossy().ends_with(".old") {
189 let _ = std::fs::remove_file(entry.path());
190 }
191 }
192 let new = path.with_extension("new");
193 std::fs::write(&new, bytes).map_err(|e| e.to_string())?;
194 #[cfg(unix)]
195 {
196 use std::os::unix::fs::PermissionsExt;
197 std::fs::set_permissions(&new, std::fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())?;
198 }
199 let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |since| since.as_nanos());
200 let old = dir.join(format!("g1t-runner-{stamp}.old"));
201 let moved = path.exists() && std::fs::rename(&path, &old).is_ok();
202 if let Err(error) = std::fs::rename(&new, &path) {
203 if moved {
204 let _ = std::fs::rename(&old, &path);
205 }
206 return Err(format!("could not put g1t-runner in place: {error}"));
207 }
208 std::fs::write(dir.join("version"), version).map_err(|e| e.to_string())
209}
210
211/// Starts `register-and-run`, which registers the first time (from
212/// `G1T_RUNNER_*` in the environment the caller sets) and then runs.
213pub fn spawn(binary: &Path, dir: &Path, envs: &[(&str, String)]) -> std::io::Result<Child> {
214 let mut command = quiet(binary);
215 command.arg("register-and-run").arg("--dir").arg(dir);
216 // What the app's own environment may have set for another runner is not this one's.
217 for (name, _) in std::env::vars_os() {
218 if name.to_string_lossy().starts_with("G1T_RUNNER_") {
219 command.env_remove(&name);
220 }
221 }
222 command.envs(envs.iter().map(|(name, value)| (*name, value.as_str())));
223 command.stdout(Stdio::piped()).stderr(Stdio::piped());
224 // A group of its own, so stopping it reaches what it started.
225 #[cfg(unix)]
226 std::os::unix::process::CommandExt::process_group(&mut command, 0);
227 command.spawn()
228}
229
230/// Unregisters the runner whose configuration is in `dir`, and forgets it.
231pub fn forget(binary: &Path, dir: &Path) {
232 if binary.exists() && dir.join("config.json").exists() {
233 let _ = quiet(binary).arg("remove").arg("--dir").arg(dir).stdout(Stdio::null()).stderr(Stdio::null()).status();
234 }
235 let _ = std::fs::remove_file(dir.join("config.json"));
236}
237
238/// Stops the runner and what it started: the job it ran in host mode
239/// included. A job cut short this way fails on g1t.
240pub fn stop(child: &mut Child) {
241 let pid = child.id().to_string();
242 if cfg!(windows) {
243 let _ = quiet("taskkill").args(["/T", "/F", "/PID", &pid]).stdout(Stdio::null()).stderr(Stdio::null()).status();
244 } else {
245 let _ = quiet("kill").args(["-TERM", &format!("-{pid}")]).stdout(Stdio::null()).stderr(Stdio::null()).status();
246 for _ in 0..40 {
247 if matches!(child.try_wait(), Ok(Some(_))) {
248 return;
249 }
250 std::thread::sleep(Duration::from_millis(250));
251 }
252 }
253 let _ = child.kill();
254 let _ = child.wait();
255}
256
257/// Removes the containers, networks and volumes of the jobs of the runner
258/// registered in `dir`, as `g1t-runner` does itself when it starts: each
259/// carries the label `sh.g1t.runner=<its id>`.
260pub fn sweep(dir: &Path) {
261 #[derive(Deserialize)]
262 struct Registered {
263 runner: String,
264 }
265 let Some(registered) = std::fs::read(dir.join("config.json")).ok().and_then(|bytes| serde_json::from_slice::<Registered>(&bytes).ok()) else { return };
266 let label = format!("label=sh.g1t.runner={}", registered.runner);
267 for (list, remove) in [
268 (vec!["ps", "-aq", "--filter", &label], vec!["rm", "-f", "-v"]),
269 (vec!["network", "ls", "-q", "--filter", &label], vec!["network", "rm"]),
270 (vec!["volume", "ls", "-q", "--filter", &label], vec!["volume", "rm", "-f"]),
271 ] {
272 let Ok(out) = quiet("docker").args(&list).stderr(Stdio::null()).output() else { return };
273 let ids: Vec<String> = String::from_utf8_lossy(&out.stdout).split_whitespace().map(str::to_owned).collect();
274 if !ids.is_empty() {
275 let _ = quiet("docker").args(&remove).args(&ids).stdout(Stdio::null()).stderr(Stdio::null()).status();
276 }
277 }
278}
279
280/// A runner the app started before and did not stop (it was ended
281/// without quitting): stopped, if that process is still `g1t-runner`.
282pub fn reap(pid: u32) {
283 let pid = pid.to_string();
284 if cfg!(windows) {
285 let _ = quiet("taskkill")
286 .args(["/T", "/F", "/FI", "IMAGENAME eq g1t-runner.exe", "/PID", &pid])
287 .stdout(Stdio::null())
288 .stderr(Stdio::null())
289 .status();
290 } else {
291 let named = quiet("ps").args(["-p", &pid, "-o", "comm="]).output().is_ok_and(|out| String::from_utf8_lossy(&out.stdout).trim().ends_with("g1t-runner"));
292 if named {
293 let _ = quiet("kill").args(["-TERM", &pid]).status();
294 }
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn releases_are_named_and_compared_as_the_runner_does() {
304 assert!(["linux-x64", "linux-arm64", "macos-arm64", "macos-x64", "windows-x64", "windows-arm64"].contains(&platform().as_str()));
305 assert!(newer("0.10.0", "0.9.9"));
306 assert!(!newer("0.2.0", "0.2.0"));
307 assert_eq!(sha256(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
308 let manifest: Manifest = serde_json::from_str(r#"{"version":"0.2.3","agent_image":null,"files":{"windows-x64":{"name":"g1t-runner-windows-x64.exe","sha256":"ab"}}}"#).unwrap();
309 assert_eq!(manifest.files["windows-x64"].name, "g1t-runner-windows-x64.exe");
310 assert_eq!(downloads(&Url::parse("https://g1t.sh/").unwrap()), "https://g1t.sh/downloads/runner");
311 }
312
313 /// A key pair of the tests' own: what it signs checks out, and nothing else does.
314 #[test]
315 fn only_a_manifest_signed_with_the_release_key_is_trusted() {
316 use ed25519_dalek::Signer;
317 let signing = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
318 let key = STANDARD.encode(signing.verifying_key().to_bytes());
319 let manifest = br#"{"version":"0.3.2","files":{"windows-x64":{"name":"g1t-runner-windows-x64.exe","sha256":"ab"}}}"#;
320 let signature = STANDARD.encode(signing.sign(manifest).to_bytes());
321 let trusted = signed(manifest, &signature, &key).unwrap();
322 assert_eq!(trusted.version, "0.3.2");
323 assert!(!trusted.has_images());
324 // Changed after it was signed: a newer version slipped in.
325 let changed = br#"{"version":"9.9.9","files":{"windows-x64":{"name":"g1t-runner-windows-x64.exe","sha256":"ab"}}}"#;
326 assert_eq!(signed(changed, &signature, &key).err(), Some(Fault::Unsigned("the release's signature isn't g1t's".into())));
327 // Signed with another key.
328 let other = STANDARD.encode(ed25519_dalek::SigningKey::from_bytes(&[8u8; 32]).verifying_key().to_bytes());
329 assert!(matches!(signed(manifest, &signature, &other), Err(Fault::Unsigned(_))));
330 // No signature, or not one.
331 assert!(matches!(signed(manifest, "", &key), Err(Fault::Unsigned(_))));
332 assert!(matches!(signed(manifest, "not base64!", &key), Err(Fault::Unsigned(_))));
333 // Signed, yet not a manifest: that is a failure, not a refusal.
334 let junk = b"not json";
335 assert!(matches!(signed(junk, &STANDARD.encode(signing.sign(junk).to_bytes()), &key), Err(Fault::Failed(_))));
336 }
337
338 /// Release 0.3.0's manifest and signature, as g1t.sh published them:
339 /// the key built in is the one g1t's releases are signed with.
340 #[test]
341 fn g1ts_published_release_checks_out_against_the_key_built_in() {
342 let manifest = include_bytes!("testdata/latest-0.3.0.json");
343 let signature = include_str!("testdata/latest-0.3.0.json.sig");
344 let release = signed(manifest, signature, RELEASE_KEY).unwrap();
345 assert_eq!(release.version, "0.3.0");
346 assert!(release.has_images());
347 let mut changed = manifest.to_vec();
348 let at = changed.windows(5).position(|five| five == b"0.3.0").unwrap();
349 changed[at + 4] = b'9';
350 assert!(matches!(signed(&changed, signature, RELEASE_KEY), Err(Fault::Unsigned(_))));
351 }
352
353 #[test]
354 fn a_new_program_takes_the_old_ones_place_and_its_version() {
355 let dir = std::env::temp_dir().join(format!("g1t-desktop-put-{}", std::process::id()));
356 let _ = std::fs::remove_dir_all(&dir);
357 put(&dir, b"one", "0.3.1").unwrap();
358 put(&dir, b"two", "0.3.2").unwrap();
359 assert_eq!(std::fs::read(binary(&dir)).unwrap(), b"two");
360 assert_eq!(installed(&dir).as_deref(), Some("0.3.2"));
361 // The one moved aside goes with the next update, once nothing runs it.
362 put(&dir, b"three", "0.3.3").unwrap();
363 let old = std::fs::read_dir(dir.join("bin")).unwrap().flatten().filter(|e| e.file_name().to_string_lossy().ends_with(".old")).count();
364 assert_eq!(old, 1);
365 let _ = std::fs::remove_dir_all(&dir);
366 }
367
368 #[test]
369 fn nothing_is_installed_in_an_empty_folder() {
370 let dir = std::env::temp_dir().join(format!("g1t-desktop-runner-{}", std::process::id()));
371 assert_eq!(installed(&dir), None);
372 assert!(binary(&dir).ends_with(if cfg!(windows) { "bin/g1t-runner.exe" } else { "bin/g1t-runner" }));
373 }
374}