Skip to main content

runner_manager_domain/
execution.rs

1//! Non-secret execution configuration and immutable attempt allocation.
2//!
3//! Provider selection never treats `Auto` as permission to start a native
4//! process. Each isolated backend must resolve to a platform provider.
5
6use serde::de::Error as _;
7use serde::{Deserialize, Serialize};
8
9use crate::model::TargetScope;
10use crate::workspace::WorkspacePolicy;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum Backend {
15    Auto,
16    Oci,
17    WindowsHyperVContainer,
18    VirtualMachine,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct ImageReference {
24    reference: String,
25}
26
27impl ImageReference {
28    /// A pinned, provider-specific reference. No mutable tag or credential is
29    /// accepted as an image identity.
30    pub fn new(reference: impl Into<String>) -> Result<Self, ExecutionError> {
31        let reference = reference.into();
32        let valid = !reference.is_empty()
33            && reference.len() <= 512
34            && reference.trim() == reference
35            && !reference.chars().any(char::is_whitespace)
36            && !reference.contains('@') // OCI digest syntax is checked below.
37            && (reference.starts_with("sha256:") || reference.starts_with("vm-version:"));
38        // OCI references may include a registry and repository before a digest,
39        // but no userinfo or mutable tag is allowed.
40        let oci = reference
41            .rsplit_once("@sha256:")
42            .is_some_and(|(name, digest)| {
43                !name.is_empty()
44                    && !name.contains('@')
45                    && !name.contains("://")
46                    && !name.chars().any(char::is_whitespace)
47                    && oci_name_is_safe(name)
48                    && digest.len() == 64
49                    && digest.bytes().all(|byte| byte.is_ascii_hexdigit())
50            });
51        let digest = reference.strip_prefix("sha256:").is_some_and(|digest| {
52            digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit())
53        });
54        let version = reference
55            .strip_prefix("vm-version:")
56            .is_some_and(|version| {
57                !version.is_empty()
58                    && version.len() <= 128
59                    && version
60                        .bytes()
61                        .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
62            });
63        if !(oci || (valid && (digest || version))) {
64            return Err(ExecutionError::InvalidImage);
65        }
66        Ok(Self { reference })
67    }
68
69    #[must_use]
70    pub fn as_str(&self) -> &str {
71        &self.reference
72    }
73}
74
75/// A colon is only legal as a numeric port in the registry component. This
76/// excludes userinfo-looking references and mutable tags before the digest.
77fn oci_name_is_safe(name: &str) -> bool {
78    let mut segments = name.split('/');
79    let Some(registry_or_image) = segments.next() else {
80        return false;
81    };
82    let has_path = name.contains('/');
83    let first = if let Some((host, port)) = registry_or_image.rsplit_once(':') {
84        has_path
85            && !host.is_empty()
86            && !port.is_empty()
87            && port.bytes().all(|byte| byte.is_ascii_digit())
88            && safe_oci_segment(host)
89    } else {
90        safe_oci_segment(registry_or_image)
91    };
92    first && segments.all(safe_oci_segment)
93}
94
95fn safe_oci_segment(segment: &str) -> bool {
96    !segment.is_empty()
97        && segment
98            .bytes()
99            .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct ResourceLimits {
105    pub cpu_millis: u32,
106    pub memory_mib: u32,
107    pub disk_mib: u32,
108}
109
110impl ResourceLimits {
111    pub fn validate(self) -> Result<Self, ExecutionError> {
112        if self.cpu_millis < 100 || self.memory_mib < 256 || self.disk_mib < 1024 {
113            return Err(ExecutionError::InvalidResources);
114        }
115        Ok(self)
116    }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
120#[serde(tag = "mode", rename_all = "snake_case")]
121pub enum ExecutionPolicy {
122    Native,
123    Isolated {
124        backend: Backend,
125        image: ImageReference,
126        resources: ResourceLimits,
127    },
128}
129
130impl<'de> Deserialize<'de> for ExecutionPolicy {
131    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
132        let value = serde_json::Value::deserialize(deserializer)?;
133        let object = value
134            .as_object()
135            .ok_or_else(|| D::Error::custom("invalid execution policy shape"))?;
136        match object.get("mode").and_then(serde_json::Value::as_str) {
137            Some("native") if object.len() == 1 => Ok(Self::Native),
138            Some("isolated")
139                if object.len() == 4
140                    && object.contains_key("backend")
141                    && object.contains_key("image")
142                    && object.contains_key("resources") =>
143            {
144                let backend = serde_json::from_value(object["backend"].clone())
145                    .map_err(|_| D::Error::custom("invalid execution backend"))?;
146                let image = serde_json::from_value(object["image"].clone())
147                    .map_err(|_| D::Error::custom("invalid execution image shape"))?;
148                let resources = serde_json::from_value(object["resources"].clone())
149                    .map_err(|_| D::Error::custom("invalid execution resource shape"))?;
150                Ok(Self::Isolated {
151                    backend,
152                    image,
153                    resources,
154                })
155            }
156            _ => Err(D::Error::custom("invalid execution policy shape")),
157        }
158    }
159}
160
161impl ExecutionPolicy {
162    pub fn validate(
163        &self,
164        scope: TargetScope,
165        workspace: &WorkspacePolicy,
166    ) -> Result<(), ExecutionError> {
167        if let Self::Isolated {
168            backend,
169            image,
170            resources,
171        } = self
172        {
173            if scope != TargetScope::Repository {
174                return Err(ExecutionError::IsolatedRequiresRepository);
175            }
176            if workspace.is_persistent() {
177                return Err(ExecutionError::IsolatedRequiresEphemeral);
178            }
179            resources.validate()?;
180            ImageReference::new(image.as_str())?;
181            if matches!(backend, Backend::Oci | Backend::WindowsHyperVContainer)
182                && !image.as_str().contains("sha256:")
183            {
184                return Err(ExecutionError::BackendImageMismatch);
185            }
186        }
187        Ok(())
188    }
189
190    #[must_use]
191    pub const fn is_native(&self) -> bool {
192        matches!(self, Self::Native)
193    }
194}
195
196/// The execution identity belongs to exactly one provider kind. The native
197/// start token is retained in the existing durable runtime sidecar; the
198/// process ID here matches the journal's legacy `process_id` column.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
200#[serde(tag = "kind", rename_all = "snake_case")]
201pub enum AttemptExecution {
202    Native {
203        process_id: Option<u32>,
204    },
205    Isolated {
206        provider_kind: Backend,
207        environment_id: Option<String>,
208        resolved_image: ImageReference,
209        generation: String,
210    },
211}
212
213impl<'de> Deserialize<'de> for AttemptExecution {
214    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
215        let value = serde_json::Value::deserialize(deserializer)?;
216        let object = value
217            .as_object()
218            .ok_or_else(|| D::Error::custom("invalid attempt execution shape"))?;
219        match object.get("kind").and_then(serde_json::Value::as_str) {
220            Some("native") if object.len() == 2 && object.contains_key("process_id") => {
221                let process_id = serde_json::from_value(object["process_id"].clone())
222                    .map_err(|_| D::Error::custom("invalid native process identity"))?;
223                Ok(Self::Native { process_id })
224            }
225            Some("isolated")
226                if object.len() == 5
227                    && object.contains_key("provider_kind")
228                    && object.contains_key("environment_id")
229                    && object.contains_key("resolved_image")
230                    && object.contains_key("generation") =>
231            {
232                let provider_kind = serde_json::from_value(object["provider_kind"].clone())
233                    .map_err(|_| D::Error::custom("invalid provider kind"))?;
234                let environment_id = serde_json::from_value(object["environment_id"].clone())
235                    .map_err(|_| D::Error::custom("invalid environment identity"))?;
236                let resolved_image = serde_json::from_value(object["resolved_image"].clone())
237                    .map_err(|_| D::Error::custom("invalid resolved image shape"))?;
238                let generation = serde_json::from_value(object["generation"].clone())
239                    .map_err(|_| D::Error::custom("invalid generation"))?;
240                Ok(Self::Isolated {
241                    provider_kind,
242                    environment_id,
243                    resolved_image,
244                    generation,
245                })
246            }
247            _ => Err(D::Error::custom("invalid attempt execution shape")),
248        }
249    }
250}
251
252impl AttemptExecution {
253    /// The provider allocation and generation cannot change after the first
254    /// journal write. Native PID and isolated environment ID may be filled
255    /// once, then only repeated with the same value.
256    #[must_use]
257    pub fn can_advance_to(&self, next: &Self) -> bool {
258        match (self, next) {
259            (Self::Native { process_id: old }, Self::Native { process_id: new }) => {
260                old.is_none() || old == new
261            }
262            (
263                Self::Isolated {
264                    provider_kind: old_provider,
265                    environment_id: old_id,
266                    resolved_image: old_image,
267                    generation: old_generation,
268                },
269                Self::Isolated {
270                    provider_kind: new_provider,
271                    environment_id: new_id,
272                    resolved_image: new_image,
273                    generation: new_generation,
274                },
275            ) => {
276                old_provider == new_provider
277                    && old_image == new_image
278                    && old_generation == new_generation
279                    && (old_id.is_none() || old_id == new_id)
280            }
281            _ => false,
282        }
283    }
284
285    pub fn validate(&self) -> Result<(), ExecutionError> {
286        if let Self::Isolated {
287            provider_kind,
288            environment_id,
289            resolved_image,
290            generation,
291        } = self
292        {
293            if matches!(provider_kind, Backend::Auto) {
294                return Err(ExecutionError::UnresolvedProvider);
295            }
296            ImageReference::new(resolved_image.as_str())?;
297            if matches!(
298                provider_kind,
299                Backend::Oci | Backend::WindowsHyperVContainer
300            ) && !resolved_image.as_str().contains("sha256:")
301            {
302                return Err(ExecutionError::BackendImageMismatch);
303            }
304            if !non_secret_id(generation)
305                || environment_id
306                    .as_deref()
307                    .is_some_and(|id| !non_secret_id(id))
308            {
309                return Err(ExecutionError::InvalidEnvironmentIdentity);
310            }
311        }
312        Ok(())
313    }
314
315    #[must_use]
316    pub const fn is_native(&self) -> bool {
317        matches!(self, Self::Native { .. })
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    fn pinned_oci() -> ImageReference {
326        ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
327            .expect("pinned image")
328    }
329
330    fn isolated() -> ExecutionPolicy {
331        ExecutionPolicy::Isolated {
332            backend: Backend::Oci,
333            image: pinned_oci(),
334            resources: ResourceLimits {
335                cpu_millis: 1000,
336                memory_mib: 1024,
337                disk_mib: 4096,
338            },
339        }
340    }
341
342    #[test]
343    fn isolated_configuration_rejects_persistent_and_non_repository_workspaces() {
344        let policy = isolated();
345        assert_eq!(
346            policy.validate(TargetScope::Organization, &WorkspacePolicy::Ephemeral),
347            Err(ExecutionError::IsolatedRequiresRepository)
348        );
349        let persistent = WorkspacePolicy::Persistent {
350            root: crate::path::LocalAbsolutePath::new(if cfg!(windows) {
351                "C:\\runner-work"
352            } else {
353                "/runner-work"
354            })
355            .expect("valid path"),
356        };
357        assert_eq!(
358            policy.validate(TargetScope::Repository, &persistent),
359            Err(ExecutionError::IsolatedRequiresEphemeral)
360        );
361        assert!(
362            policy
363                .validate(TargetScope::Repository, &WorkspacePolicy::Ephemeral)
364                .is_ok()
365        );
366    }
367
368    #[test]
369    fn mutable_credentials_and_unknown_provider_shapes_fail_closed() {
370        for reference in [
371            "runner:latest",
372            "https://user:token@registry.example/runner",
373            "registry.example/runner@sha256:short",
374            "registry.example/runner @sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
375        ] {
376            assert_eq!(
377                ImageReference::new(reference),
378                Err(ExecutionError::InvalidImage)
379            );
380        }
381        let digest = "a".repeat(64);
382        for reference in [
383            format!("user:password@sha256:{digest}"),
384            format!("registry.example/runner:latest@sha256:{digest}"),
385            format!("user:password/runner@sha256:{digest}"),
386        ] {
387            assert_eq!(
388                ImageReference::new(reference),
389                Err(ExecutionError::InvalidImage)
390            );
391        }
392        assert!(
393            ImageReference::new(format!("registry.example:5000/runner@sha256:{digest}")).is_ok()
394        );
395        assert!(serde_json::from_str::<ExecutionPolicy>(
396            r#"{"mode":"isolated","backend":"unknown","image":{"reference":"x"},"resources":{"cpu_millis":1000,"memory_mib":1024,"disk_mib":4096}}"#
397        ).is_err());
398        assert!(
399            serde_json::from_str::<ExecutionPolicy>(r#"{"mode":"native","backend":"oci"}"#)
400                .is_err()
401        );
402        let malformed: ExecutionPolicy = serde_json::from_str(
403            r#"{"mode":"isolated","backend":"oci","image":{"reference":"runner:latest"},"resources":{"cpu_millis":1000,"memory_mib":1024,"disk_mib":4096}}"#
404        ).expect("raw shape parses; validation rejects the image");
405        assert_eq!(
406            malformed.validate(TargetScope::Repository, &WorkspacePolicy::Ephemeral),
407            Err(ExecutionError::InvalidImage)
408        );
409    }
410
411    #[test]
412    fn isolated_attempt_identity_is_non_secret_and_concrete() {
413        let valid = AttemptExecution::Isolated {
414            provider_kind: Backend::Oci,
415            environment_id: Some("env-123".into()),
416            resolved_image: pinned_oci(),
417            generation: "gen-123".into(),
418        };
419        assert!(valid.validate().is_ok());
420        let unknown = AttemptExecution::Isolated {
421            provider_kind: Backend::Auto,
422            environment_id: Some("env-123".into()),
423            resolved_image: pinned_oci(),
424            generation: "gen-123".into(),
425        };
426        assert_eq!(unknown.validate(), Err(ExecutionError::UnresolvedProvider));
427    }
428
429    #[test]
430    fn container_backends_require_digest_images_for_policy_and_journal() {
431        let version = ImageReference::new("vm-version:macos-15.0").expect("pinned VM version");
432        for backend in [Backend::Oci, Backend::WindowsHyperVContainer] {
433            let policy = ExecutionPolicy::Isolated {
434                backend,
435                image: version.clone(),
436                resources: ResourceLimits {
437                    cpu_millis: 1000,
438                    memory_mib: 1024,
439                    disk_mib: 4096,
440                },
441            };
442            assert_eq!(
443                policy.validate(TargetScope::Repository, &WorkspacePolicy::Ephemeral),
444                Err(ExecutionError::BackendImageMismatch)
445            );
446            let attempt = AttemptExecution::Isolated {
447                provider_kind: backend,
448                environment_id: None,
449                resolved_image: version.clone(),
450                generation: "gen-123".into(),
451            };
452            assert_eq!(
453                attempt.validate(),
454                Err(ExecutionError::BackendImageMismatch)
455            );
456        }
457        let vm = ExecutionPolicy::Isolated {
458            backend: Backend::VirtualMachine,
459            image: version,
460            resources: ResourceLimits {
461                cpu_millis: 1000,
462                memory_mib: 1024,
463                disk_mib: 4096,
464            },
465        };
466        assert!(
467            vm.validate(TargetScope::Repository, &WorkspacePolicy::Ephemeral)
468                .is_ok()
469        );
470    }
471}
472
473fn non_secret_id(value: &str) -> bool {
474    !value.is_empty()
475        && value.len() <= 128
476        && value
477            .bytes()
478            .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
479}
480
481#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
482pub enum ExecutionError {
483    #[error("isolated execution requires a repository profile")]
484    IsolatedRequiresRepository,
485    #[error("isolated execution requires an ephemeral workspace")]
486    IsolatedRequiresEphemeral,
487    #[error("the image must be a pinned, credential-free digest or version")]
488    InvalidImage,
489    #[error("the image identity is incompatible with the selected backend")]
490    BackendImageMismatch,
491    #[error("isolated CPU, memory and disk limits are below the required floor")]
492    InvalidResources,
493    #[error("an attempt must record a concrete provider, not auto")]
494    UnresolvedProvider,
495    #[error("the environment identity or generation is malformed")]
496    InvalidEnvironmentIdentity,
497    #[error("the attempt execution identity does not match its recorded process ID")]
498    AttemptIdentityMismatch,
499    #[error("an isolated environment cannot be cleaned without provider absence proof")]
500    IsolatedCleanupUnproven,
501}