1use 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 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('@') && (reference.starts_with("sha256:") || reference.starts_with("vm-version:"));
38 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
75fn 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#[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 #[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}