Skip to main content

arcbox_docker_tools/
lockfile.rs

1//! Parser for the `[[tools]]` section of `assets.lock`.
2//!
3//! These entries pin **host-side macOS binaries only**, installed to
4//! `~/.arcbox/runtime/bin/`. Every URL in [`crate::registry`] resolves to a
5//! darwin artifact (`download.docker.com/mac/...`, `*.darwin-arm64`,
6//! `bin/darwin/...`), so nothing here can run in the Linux guest.
7//!
8//! The guest's `dockerd`/`containerd`/`runc` come from the boot manifest
9//! pinned under `[boot]`, which the daemon stages into the generation-scoped
10//! `<data_dir>/runtime/<boot version>/bin`. **The host CLI and the guest
11//! daemon are therefore two independent pins and can skew**: bumping the
12//! `docker` tool here does not move guest dockerd, and vice versa. The Docker
13//! CLI negotiates down to an older daemon's API, so a skew is tolerable, but
14//! it is neither prevented nor detected — keep the two in step by hand when
15//! bumping either (`assets.lock`'s own header says the same).
16
17use std::collections::HashMap;
18
19use serde::Deserialize;
20
21/// Top-level lockfile structure (only the parts we care about).
22#[derive(Debug, Deserialize)]
23pub struct AssetsLock {
24    #[serde(default)]
25    pub tools: Vec<ToolEntry>,
26}
27
28/// Tool installation group.
29///
30/// New groups must be added here even if this crate does not consume them —
31/// `parse_tools()` deserializes the entire `[[tools]]` array before filtering,
32/// so an unknown group value would fail parsing for every caller. The
33/// `Desktop` variant is consumed by `arcbox-desktop` (host-only binaries
34/// embedded in the app bundle) and is inert for docker/kubernetes callers.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum ToolGroup {
38    Docker,
39    Kubernetes,
40    Desktop,
41}
42
43/// Per-architecture metadata for a tool.
44#[derive(Debug, Clone, Deserialize)]
45pub struct ArchEntry {
46    pub sha256: String,
47}
48
49/// A single `[[tools]]` entry.
50#[derive(Debug, Clone, Deserialize)]
51pub struct ToolEntry {
52    pub name: String,
53    pub group: ToolGroup,
54    pub version: String,
55    #[serde(default)]
56    pub arch: HashMap<String, ArchEntry>,
57}
58
59impl ToolEntry {
60    /// Returns the SHA-256 checksum for the given architecture, if present.
61    #[must_use]
62    pub fn sha256_for_arch(&self, arch: &str) -> Option<&str> {
63        // Accept common aliases.
64        let key = match arch {
65            "aarch64" => "arm64",
66            "amd64" => "x86_64",
67            _ => arch,
68        };
69        self.arch.get(key).map(|e| e.sha256.as_str())
70    }
71}
72
73/// Parse the `[[tools]]` entries from `assets.lock` TOML content.
74pub fn parse_tools(lock_toml: &str) -> Result<Vec<ToolEntry>, toml::de::Error> {
75    let lock: AssetsLock = toml::from_str(lock_toml)?;
76    Ok(lock.tools)
77}
78
79/// Parse the `[[tools]]` entries for a specific tool group.
80pub fn parse_tools_for_group(
81    lock_toml: &str,
82    group: ToolGroup,
83) -> Result<Vec<ToolEntry>, toml::de::Error> {
84    Ok(parse_tools(lock_toml)?
85        .into_iter()
86        .filter(|tool| tool.group == group)
87        .collect())
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    const SAMPLE: &str = r#"
95[boot]
96version = "0.5.1"
97cdn = "https://boot.arcboxcdn.com"
98
99[[tools]]
100name = "docker"
101group = "docker"
102version = "29.3.1"
103arch.arm64.sha256 = "aaa"
104arch.x86_64.sha256 = "bbb"
105
106[[tools]]
107name = "docker-buildx"
108group = "docker"
109version = "0.21.1"
110arch.arm64.sha256 = "ccc"
111
112[[tools]]
113name = "kubectl"
114group = "kubernetes"
115version = "1.35.3"
116arch.arm64.sha256 = "ddd"
117
118[[tools]]
119name = "pstramp"
120group = "desktop"
121version = "0.2.0"
122arch.arm64.sha256 = "eee"
123"#;
124
125    #[test]
126    fn parse_tool_entries() {
127        let tools = parse_tools(SAMPLE).unwrap();
128        assert_eq!(tools.len(), 4);
129        assert_eq!(tools[0].name, "docker");
130        assert_eq!(tools[0].group, ToolGroup::Docker);
131        assert_eq!(tools[0].version, "29.3.1");
132        assert_eq!(tools[0].sha256_for_arch("arm64"), Some("aaa"));
133        assert_eq!(tools[0].sha256_for_arch("x86_64"), Some("bbb"));
134        assert_eq!(tools[1].sha256_for_arch("x86_64"), None);
135    }
136
137    #[test]
138    fn parse_tools_for_specific_group() {
139        let docker_tools = parse_tools_for_group(SAMPLE, ToolGroup::Docker).unwrap();
140        assert_eq!(docker_tools.len(), 2);
141        assert!(
142            docker_tools
143                .iter()
144                .all(|tool| tool.group == ToolGroup::Docker)
145        );
146
147        let kubernetes_tools = parse_tools_for_group(SAMPLE, ToolGroup::Kubernetes).unwrap();
148        assert_eq!(kubernetes_tools.len(), 1);
149        assert_eq!(kubernetes_tools[0].name, "kubectl");
150
151        // Desktop group should parse cleanly even though this crate does not
152        // consume it — docker/kubernetes flows must coexist with desktop tools.
153        let desktop_tools = parse_tools_for_group(SAMPLE, ToolGroup::Desktop).unwrap();
154        assert_eq!(desktop_tools.len(), 1);
155        assert_eq!(desktop_tools[0].name, "pstramp");
156    }
157
158    #[test]
159    fn arch_aliases() {
160        let tools = parse_tools(SAMPLE).unwrap();
161        // "aarch64" should resolve to "arm64"
162        assert_eq!(tools[0].sha256_for_arch("aarch64"), Some("aaa"));
163        // "amd64" should resolve to "x86_64"
164        assert_eq!(tools[0].sha256_for_arch("amd64"), Some("bbb"));
165    }
166}