Skip to main content

onelf_sysroot/
platform.rs

1//! The GL build a sysroot names for hosts that have none.
2//!
3//! A sysroot may carry `etc/onelf/platform.toml`:
4//!
5//! ```toml
6//! label = "platform-1"
7//!
8//! [gl]
9//! url = "https://example.com/platform-1/gl.onelf"
10//! blake3 = "<64 hex characters>"
11//! ```
12//!
13//! A package built on the sysroot pins those three values, so the
14//! publisher who built on the sysroot is the trust root and nothing has
15//! to be signed.
16
17use std::io;
18use std::path::Path;
19
20/// Where the pin sits under a sysroot root.
21pub const PLATFORM_FILE: &str = "etc/onelf/platform.toml";
22
23/// A GL build pinned by hash.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Pin {
26    pub label: String,
27    pub url: String,
28    /// Lowercase hex BLAKE3 of the build's onelf package.
29    pub blake3: String,
30}
31
32fn invalid(root: &Path, what: &str) -> io::Error {
33    io::Error::new(
34        io::ErrorKind::InvalidData,
35        format!("{}: {PLATFORM_FILE}: {what}", root.display()),
36    )
37}
38
39/// The pin the sysroot at `root` names, or `None` when it names none.
40pub fn read(root: &Path) -> io::Result<Option<Pin>> {
41    let path = root.join(PLATFORM_FILE);
42    let text = match std::fs::read_to_string(&path) {
43        Ok(t) => t,
44        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
45        Err(e) => return Err(io::Error::new(e.kind(), format!("{}: {e}", path.display()))),
46    };
47    parse(&text).map(Some).map_err(|what| invalid(root, &what))
48}
49
50/// Parse the file's text. Public so a recipe override can be validated
51/// with the same rules.
52pub fn parse(text: &str) -> Result<Pin, String> {
53    let value: toml::Value = toml::from_str(text).map_err(|e| e.to_string())?;
54    let label = value
55        .get("label")
56        .and_then(toml::Value::as_str)
57        .ok_or("no label")?
58        .trim()
59        .to_string();
60    check_label(&label)?;
61    let gl = value.get("gl").ok_or("no [gl] table")?;
62    let url = gl
63        .get("url")
64        .and_then(toml::Value::as_str)
65        .ok_or("no gl.url")?
66        .to_string();
67    let blake3 = gl
68        .get("blake3")
69        .and_then(toml::Value::as_str)
70        .ok_or("no gl.blake3")?
71        .to_ascii_lowercase();
72    check_hash(&blake3)?;
73    check_url(&url)?;
74    Ok(Pin { label, url, blake3 })
75}
76
77/// A label names the build's directory in the runtime's store, so it is a
78/// plain directory name: not empty, no slash, no leading dot.
79pub fn check_label(label: &str) -> Result<(), String> {
80    if label.is_empty() || label.contains('/') || label.starts_with('.') {
81        return Err(format!(
82            "{label:?}: a label is a non-empty name without slashes or a leading dot"
83        ));
84    }
85    Ok(())
86}
87
88/// A BLAKE3 hash is 64 hex characters, nothing else.
89pub fn check_hash(hash: &str) -> Result<(), String> {
90    if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
91        return Err(format!("{hash}: not a BLAKE3 hash (64 hex characters)"));
92    }
93    Ok(())
94}
95
96/// A build is executable code, so it comes over TLS or from a local
97/// file, never plain HTTP.
98pub fn check_url(url: &str) -> Result<(), String> {
99    if url.starts_with("https://") || url.starts_with("file://") {
100        Ok(())
101    } else {
102        Err(format!(
103            "{url}: the GL build must be an https:// or file:// URL"
104        ))
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::db::fixture::temp_root;
112
113    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
114
115    #[test]
116    fn reads_a_complete_file() {
117        let root = temp_root("platform");
118        std::fs::create_dir_all(root.join("etc/onelf")).unwrap();
119        std::fs::write(
120            root.join(PLATFORM_FILE),
121            format!("label = \"platform-1\"\n\n[gl]\nurl = \"https://example.com/gl.onelf\"\nblake3 = \"{}\"\n", HASH.to_ascii_uppercase()),
122        )
123        .unwrap();
124        let pin = read(&root).unwrap().unwrap();
125        assert_eq!(pin.label, "platform-1");
126        assert_eq!(pin.url, "https://example.com/gl.onelf");
127        assert_eq!(pin.blake3, HASH, "hash is normalized to lowercase");
128    }
129
130    #[test]
131    fn an_absent_file_is_no_pin() {
132        let root = temp_root("noplatform");
133        assert_eq!(read(&root).unwrap(), None);
134    }
135
136    #[test]
137    fn a_truncated_file_names_the_sysroot() {
138        let root = temp_root("badplatform");
139        std::fs::create_dir_all(root.join("etc/onelf")).unwrap();
140        std::fs::write(
141            root.join(PLATFORM_FILE),
142            "label = \"x\"\n[gl]\nurl = \"https://e/gl\"\nblake3 = \"012",
143        )
144        .unwrap();
145        let err = read(&root).unwrap_err();
146        assert!(err.to_string().contains("etc/onelf/platform.toml"), "{err}");
147    }
148
149    #[test]
150    fn schemes_and_hashes_are_checked() {
151        assert!(check_url("http://example.com/gl.onelf").is_err());
152        assert!(check_url("file:///srv/gl.onelf").is_ok());
153        assert!(check_hash("abc").is_err());
154        assert!(check_hash(HASH).is_ok());
155        let text = format!("label = \"a/b\"\n[gl]\nurl = \"https://e/gl\"\nblake3 = \"{HASH}\"\n");
156        assert!(parse(&text).is_err(), "a label is a directory name");
157        assert!(check_label(".hidden").is_err());
158        assert!(check_label("platform-1.tar.zst").is_ok());
159    }
160}