Skip to main content

gatehouse_runtime_protocol/lifecycle_v2/
installation.rs

1//! Held installation bytes, not executable trust, dispatch or lease authority.
2use super::{
3    plan_record::PlanRecord,
4    profile::{Profile, SlotIdentity, PRODUCT, PROTOCOL, VERSION},
5    protected_context::{protected, Identity, ProtectedContext, CONTEXT_SCHEMA},
6};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::{
10    fs::{self, File, OpenOptions},
11    os::unix::fs::{FileExt, MetadataExt, OpenOptionsExt},
12    path::{Path, PathBuf},
13};
14type Result<T> = std::result::Result<T, &'static str>;
15fn io<T>(r: std::io::Result<T>) -> Result<T> {
16    r.map_err(|_| "installation filesystem failure")
17}
18fn digest(b: &[u8]) -> String {
19    format!("{:x}", Sha256::digest(b))
20}
21#[derive(Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23struct Host {
24    schema: String,
25    installation_path: String,
26    installation_sha256: String,
27    config_path: String,
28    config_sha256: String,
29}
30#[derive(Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32struct Installation {
33    schema: String,
34    product: String,
35    version: String,
36    protocol: String,
37    executable_path: String,
38    executable_sha256: String,
39    config_schema: String,
40}
41fn parse<T: serde::de::DeserializeOwned + Serialize>(bytes: &[u8]) -> Result<T> {
42    // Typed duplicate checks + exact struct-order roundtrip; no unordered maps.
43    let value: T = serde_json::from_slice(bytes).map_err(|_| "installation fields rejected")?;
44    if serde_json::to_vec(&value).map_err(|_| "installation encode")? != bytes {
45        return Err("noncanonical installation");
46    }
47    Ok(value)
48}
49struct Snapshot {
50    path: PathBuf,
51    file: File,
52    identity: Identity,
53    bytes: Vec<u8>,
54}
55impl Snapshot {
56    fn open(path: &Path, executable: bool) -> Result<Self> {
57        protected(path)?;
58        let file = io(OpenOptions::new()
59            .read(true)
60            .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
61            .open(path))?;
62        let m = io(file.metadata())?;
63        let uid = unsafe { libc::geteuid() };
64        let mode = m.mode() & 0o7777;
65        let mode_ok = if executable {
66            if m.uid() == 0 {
67                matches!(mode, 0o555 | 0o755)
68            } else {
69                m.uid() == uid && matches!(mode, 0o500 | 0o700)
70            }
71        } else {
72            m.uid() == uid && matches!(mode, 0o400 | 0o600)
73        };
74        let limit = if executable {
75            128 * 1024 * 1024
76        } else {
77            1024 * 1024
78        };
79        if !m.is_file() || m.nlink() != 1 || !mode_ok || m.len() > limit {
80            return Err("unprotected installation member");
81        }
82        let bytes = read(&file, limit)?;
83        if executable && !bytes.starts_with(b"\x7fELF") {
84            return Err("ELF executable required");
85        }
86        let held = Self {
87            path: path.into(),
88            file,
89            identity: Identity::of(&m),
90            bytes,
91        };
92        held.check()?;
93        Ok(held)
94    }
95    fn check(&self) -> Result<()> {
96        protected(&self.path)?;
97        if Identity::of(&io(self.file.metadata())?) != self.identity
98            || Identity::of(&io(fs::symlink_metadata(&self.path))?) != self.identity
99        {
100            return Err("installation member changed");
101        }
102        if read(&self.file, self.bytes.len() as u64)? != self.bytes {
103            return Err("installation bytes changed");
104        }
105        if Identity::of(&io(self.file.metadata())?) != self.identity
106            || Identity::of(&io(fs::symlink_metadata(&self.path))?) != self.identity
107        {
108            return Err("installation member changed during read");
109        }
110        Ok(())
111    }
112}
113fn read(file: &File, limit: u64) -> Result<Vec<u8>> {
114    let mut bytes = Vec::new();
115    let mut buf = [0; 8192];
116    while bytes.len() as u64 <= limit {
117        let n = io(file.read_at(&mut buf, bytes.len() as u64))?;
118        if n == 0 {
119            break;
120        }
121        bytes.extend_from_slice(&buf[..n]);
122    }
123    if bytes.len() as u64 > limit {
124        return Err("installation byte limit");
125    }
126    Ok(bytes)
127}
128/// Holds every verified file; fields cannot be supplied by a caller or disk record.
129pub struct VerifiedPodmanInstallation {
130    host: Snapshot,
131    installation: Snapshot,
132    config: Snapshot,
133    executable: Snapshot,
134    context: ProtectedContext,
135    identity: SlotIdentity,
136}
137impl VerifiedPodmanInstallation {
138    pub fn load(host_path: &Path) -> Result<Self> {
139        let host = Snapshot::open(host_path, false)?;
140        let h: Host = parse(&host.bytes)?;
141        if h.schema != "gate-runtime/podman-host-v1" {
142            return Err("Podman host schema required");
143        }
144        let installation = Snapshot::open(Path::new(&h.installation_path), false)?;
145        if digest(&installation.bytes) != h.installation_sha256 {
146            return Err("installation digest mismatch");
147        }
148        let i: Installation = parse(&installation.bytes)?;
149        if i.schema != "gate-runtime/podman-installation-v1"
150            || i.product != PRODUCT
151            || i.version != VERSION
152            || i.protocol != PROTOCOL
153            || i.config_schema != CONTEXT_SCHEMA
154        {
155            return Err("Podman installation tuple required");
156        }
157        let config = Snapshot::open(Path::new(&h.config_path), false)?;
158        if digest(&config.bytes) != h.config_sha256 {
159            return Err("config digest mismatch");
160        }
161        let context =
162            ProtectedContext::from_held_versioned(&config.path, io(config.file.try_clone())?)?;
163        if context.snapshot()?.1 != config.bytes {
164            return Err("held config snapshot mismatch");
165        }
166        let executable = Snapshot::open(Path::new(&i.executable_path), true)?;
167        if digest(&executable.bytes) != i.executable_sha256 {
168            return Err("executable digest mismatch");
169        }
170        let identity = SlotIdentity {
171            profile: Profile {
172                protocol: PROTOCOL.into(),
173                product: PRODUCT.into(),
174                version: VERSION.into(),
175                engine: "podman".into(),
176            },
177            installation_sha256: digest(&installation.bytes),
178            config_sha256: digest(&config.bytes),
179            executable_sha256: digest(&executable.bytes),
180        };
181        identity.validate()?;
182        let result = Self {
183            host,
184            installation,
185            config,
186            executable,
187            context,
188            identity,
189        };
190        result.revalidate()?;
191        Ok(result)
192    }
193    pub fn revalidate(&self) -> Result<()> {
194        self.host.check()?;
195        self.installation.check()?;
196        self.config.check()?;
197        self.executable.check()?;
198        self.context.revalidate()
199    }
200    pub fn context(&self) -> Result<&ProtectedContext> {
201        self.revalidate()?;
202        Ok(&self.context)
203    }
204    pub fn identity(&self) -> Result<&SlotIdentity> {
205        self.revalidate()?;
206        Ok(&self.identity)
207    }
208    /// Borrow for future held-FD execution; this does not spawn or prove entrypoint ABI.
209    pub fn executable_file(&self) -> Result<&File> {
210        self.revalidate()?;
211        Ok(&self.executable.file)
212    }
213    pub fn capture_expected(
214        &self,
215        request: &super::capture_wire::CaptureRequest,
216    ) -> Result<super::capture_wire::CaptureExpected> {
217        self.revalidate()?;
218        super::capture_wire::CaptureExpected::bind(
219            request,
220            &self.identity,
221            digest(&self.host.bytes),
222            self.config.path.to_str().ok_or("config path UTF8")?.into(),
223        )
224    }
225    pub fn config_file(&self) -> Result<&File> {
226        self.revalidate()?;
227        Ok(&self.config.file)
228    }
229    pub fn match_record<'a>(
230        &'a self,
231        record: &'a PlanRecord,
232    ) -> Result<RecordInstallationMatch<'a>> {
233        let matched = RecordInstallationMatch {
234            installation: self,
235            record,
236        };
237        matched.revalidate()?;
238        Ok(matched)
239    }
240}
241/// Live borrowed comparison only. Cannot be serialized or recovered from disk.
242pub struct RecordInstallationMatch<'a> {
243    installation: &'a VerifiedPodmanInstallation,
244    record: &'a PlanRecord,
245}
246impl RecordInstallationMatch<'_> {
247    pub fn revalidate(&self) -> Result<()> {
248        self.installation.revalidate()?;
249        self.record.match_installation_snapshot(
250            &self.installation.identity,
251            &self.installation.config.bytes,
252        )
253    }
254}