Skip to main content

a3s_box_runtime/sandbox/
controller.rs

1//! Durable bundle creation and `crun` process startup.
2
3#[cfg(target_os = "linux")]
4use std::fs::File;
5use std::fs::OpenOptions;
6#[cfg(target_os = "linux")]
7use std::io::{Read, Seek, SeekFrom};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10#[cfg(target_os = "linux")]
11use std::process::Stdio;
12#[cfg(target_os = "linux")]
13use std::time::{Duration, Instant};
14
15use a3s_box_core::error::{BoxError, Result};
16use a3s_box_core::execution::ResolvedExecutionPlan;
17use a3s_box_core::log::LogConfig;
18#[cfg(target_os = "linux")]
19use a3s_box_core::log::{SandboxLogWorkerSpec, SANDBOX_LOG_WORKER_SCHEMA};
20use oci_spec::runtime::Spec;
21use serde::Serialize;
22
23use super::capability::{CertifiedCrun, SandboxCapabilitySnapshot};
24use super::handler::CrunHandler;
25#[cfg(target_os = "linux")]
26use super::handler::{CrunHandlerSpec, CrunState};
27
28#[cfg(target_os = "linux")]
29const EXEC_LISTENER_FD: i32 = 3;
30#[cfg(target_os = "linux")]
31const PTY_LISTENER_FD: i32 = 4;
32#[cfg(target_os = "linux")]
33const INIT_LOG_FD: i32 = 5;
34#[cfg(target_os = "linux")]
35const PRESERVED_FD_COUNT: usize = 3;
36#[cfg(target_os = "linux")]
37const START_TIMEOUT: Duration = Duration::from_secs(10);
38#[cfg(target_os = "linux")]
39const START_FAILURE_LOG_LIMIT_BYTES: u64 = 4 * 1024;
40
41/// Files and sockets required to launch a generated OCI bundle.
42pub struct SandboxLaunchSpec {
43    pub container_id: String,
44    pub bundle_dir: PathBuf,
45    pub runtime_root: PathBuf,
46    pub runtime_record: PathBuf,
47    pub exec_socket_path: PathBuf,
48    pub pty_socket_path: PathBuf,
49    pub stdout_path: PathBuf,
50    pub stderr_path: PathBuf,
51    pub init_log_path: PathBuf,
52    pub log_config: LogConfig,
53    pub log_worker_path: PathBuf,
54    pub log_worker_log_path: PathBuf,
55    pub log_worker_ready_path: PathBuf,
56}
57
58#[cfg(target_os = "linux")]
59#[derive(Debug, Serialize)]
60struct SandboxRuntimeRecord<'a> {
61    schema: &'static str,
62    container_id: &'a str,
63    runtime_path: &'a Path,
64    runtime_root: &'a Path,
65    bundle_dir: &'a Path,
66    init_pid: u32,
67    log_worker_pid: u32,
68    log_worker_pid_start_time: u64,
69}
70
71/// Controller pinned to one already-verified `crun` artifact.
72pub struct CrunController {
73    runtime: CertifiedCrun,
74}
75
76impl CrunController {
77    pub fn new(runtime: CertifiedCrun) -> Self {
78        Self { runtime }
79    }
80
81    /// Refuse to overwrite a live runtime generation with the same ID.
82    pub fn require_absent(&self, runtime_root: &Path, container_id: &str) -> Result<()> {
83        match std::fs::symlink_metadata(runtime_root) {
84            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
85            Err(error) => {
86                return Err(BoxError::BoxBootError {
87                    message: format!(
88                        "Failed to inspect Sandbox runtime root {}: {error}",
89                        runtime_root.display()
90                    ),
91                    hint: None,
92                })
93            }
94            Ok(metadata) if !metadata.file_type().is_dir() => {
95                return Err(BoxError::BoxBootError {
96                    message: format!(
97                        "Sandbox runtime root is not a directory: {}",
98                        runtime_root.display()
99                    ),
100                    hint: None,
101                })
102            }
103            Ok(_) => {}
104        }
105
106        // `crun state --root <missing>` materializes the root even when the
107        // container is absent. The metadata gate above keeps this safety probe
108        // side-effect free before image pulls and bundle preparation begin.
109        match CrunHandler::query_state_at(&self.runtime.path, runtime_root, container_id)? {
110            Some(state) if state.status == "stopped" => {
111                let output = Command::new(&self.runtime.path)
112                    .arg("--root")
113                    .arg(runtime_root)
114                    .arg("delete")
115                    .arg("--force")
116                    .arg(container_id)
117                    .env("LC_ALL", "C")
118                    .output()
119                    .map_err(|error| BoxError::BoxBootError {
120                        message: format!("Failed to delete stopped Sandbox generation: {error}"),
121                        hint: None,
122                    })?;
123                if !output.status.success() {
124                    return Err(BoxError::BoxBootError {
125                        message: format!(
126                            "Failed to delete stopped Sandbox generation: {}",
127                            String::from_utf8_lossy(&output.stderr).trim()
128                        ),
129                        hint: None,
130                    });
131                }
132                Ok(())
133            }
134            Some(state) => Err(BoxError::BoxBootError {
135                message: format!(
136                    "Sandbox runtime ID {container_id} already exists in state {}",
137                    state.status
138                ),
139                hint: Some(
140                    "Reconcile or stop the existing Sandbox before restarting it".to_string(),
141                ),
142            }),
143            None => Ok(()),
144        }
145    }
146
147    #[cfg(target_os = "linux")]
148    pub async fn start(&self, launch: SandboxLaunchSpec) -> Result<CrunHandler> {
149        use std::os::fd::AsRawFd;
150        use std::os::unix::process::CommandExt;
151
152        self.require_absent(&launch.runtime_root, &launch.container_id)?;
153        create_private_dir(&launch.runtime_root)?;
154        let exec_listener = bind_control_listener(&launch.exec_socket_path)?;
155        let pty_listener = bind_control_listener(&launch.pty_socket_path)?;
156        let stdout = open_log(&launch.stdout_path)?;
157        let stderr = open_log(&launch.stderr_path)?;
158        let init_log = open_log(&launch.init_log_path)?;
159
160        let inherited_exec = duplicate_for_inheritance(exec_listener.as_raw_fd())?;
161        let inherited_pty = duplicate_for_inheritance(pty_listener.as_raw_fd())?;
162        let inherited_log = duplicate_for_inheritance(init_log.as_raw_fd())?;
163        let exec_fd = inherited_exec.as_raw_fd();
164        let pty_fd = inherited_pty.as_raw_fd();
165        let log_fd = inherited_log.as_raw_fd();
166
167        let mut command = Command::new(&self.runtime.path);
168        command
169            .arg("--root")
170            .arg(&launch.runtime_root)
171            .arg("run")
172            .arg("--bundle")
173            .arg(&launch.bundle_dir)
174            .arg("--preserve-fds")
175            .arg(PRESERVED_FD_COUNT.to_string())
176            .arg(&launch.container_id)
177            .env("LC_ALL", "C")
178            .stdin(Stdio::null())
179            .stdout(Stdio::from(stdout))
180            .stderr(Stdio::from(stderr));
181
182        // The duplicated source descriptors are all >= 10, so the three dup2
183        // operations cannot clobber one another. dup2 clears CLOEXEC on 3/4/5.
184        unsafe {
185            command.pre_exec(move || {
186                for (source, destination) in [
187                    (exec_fd, EXEC_LISTENER_FD),
188                    (pty_fd, PTY_LISTENER_FD),
189                    (log_fd, INIT_LOG_FD),
190                ] {
191                    if libc::dup2(source, destination) < 0 {
192                        return Err(std::io::Error::last_os_error());
193                    }
194                }
195                Ok(())
196            });
197        }
198
199        let mut child = command.spawn().map_err(|error| BoxError::BoxBootError {
200            message: format!("Failed to start certified crun runtime: {error}"),
201            hint: None,
202        })?;
203        drop((inherited_exec, inherited_pty, inherited_log));
204        // `crun` and the container own duplicated descriptors now. The parent
205        // listener copies must close so socket EOF/lifetime is not extended.
206        drop((exec_listener, pty_listener, init_log));
207
208        let deadline = Instant::now() + START_TIMEOUT;
209        let init_pid = loop {
210            let child_status = match child.try_wait() {
211                Ok(status) => status,
212                Err(error) => {
213                    cleanup_failed_start(&self.runtime.path, &launch);
214                    let _ = child.kill();
215                    let _ = child.wait();
216                    return Err(BoxError::IoError(error));
217                }
218            };
219            if let Some(status) = child_status {
220                let diagnostics = start_failure_diagnostics(&launch);
221                cleanup_failed_start(&self.runtime.path, &launch);
222                return Err(BoxError::BoxBootError {
223                    message: format!(
224                        "crun run exited before the Sandbox was running: {status}{diagnostics}"
225                    ),
226                    hint: None,
227                });
228            }
229            let runtime_state = match CrunHandler::query_state_at(
230                &self.runtime.path,
231                &launch.runtime_root,
232                &launch.container_id,
233            ) {
234                Ok(state) => state,
235                Err(error) => {
236                    cleanup_failed_start(&self.runtime.path, &launch);
237                    let _ = child.kill();
238                    let _ = child.wait();
239                    return Err(error);
240                }
241            };
242            if let Some(CrunState { status, pid }) = runtime_state {
243                if status == "running" && pid > 0 {
244                    break pid;
245                }
246                if status == "stopped" {
247                    let diagnostics = start_failure_diagnostics(&launch);
248                    cleanup_failed_start(&self.runtime.path, &launch);
249                    return Err(BoxError::BoxBootError {
250                        message: format!("Sandbox stopped during OCI startup{diagnostics}"),
251                        hint: None,
252                    });
253                }
254            }
255            if Instant::now() >= deadline {
256                let diagnostics = start_failure_diagnostics(&launch);
257                cleanup_failed_start(&self.runtime.path, &launch);
258                let _ = child.kill();
259                let _ = child.wait();
260                return Err(BoxError::BoxBootError {
261                    message: format!(
262                        "Timed out waiting for the Sandbox OCI state to become running{diagnostics}"
263                    ),
264                    hint: None,
265                });
266            }
267            tokio::time::sleep(Duration::from_millis(25)).await;
268        };
269
270        let watched_pid = child.id();
271        let watched_pid_start_time = match crate::process::pid_start_time(watched_pid) {
272            Some(start_time) => start_time,
273            None => {
274                cleanup_failed_start(&self.runtime.path, &launch);
275                let _ = child.kill();
276                let _ = child.wait();
277                return Err(BoxError::BoxBootError {
278                    message: "Failed to capture crun wrapper process identity for Sandbox logs"
279                        .to_string(),
280                    hint: None,
281                });
282            }
283        };
284        let mut log_worker = match start_log_worker(&launch, watched_pid, watched_pid_start_time) {
285            Ok(worker) => worker,
286            Err(error) => {
287                cleanup_failed_start(&self.runtime.path, &launch);
288                let _ = child.kill();
289                let _ = child.wait();
290                return Err(error);
291            }
292        };
293        let log_worker_pid = log_worker.id();
294        let log_worker_pid_start_time = match crate::process::pid_start_time(log_worker_pid) {
295            Some(start_time) => start_time,
296            None => {
297                cleanup_failed_start(&self.runtime.path, &launch);
298                reap_failed_log_worker(&mut log_worker);
299                let _ = child.kill();
300                let _ = child.wait();
301                return Err(BoxError::BoxBootError {
302                    message: "Failed to capture Sandbox log worker process identity".to_string(),
303                    hint: None,
304                });
305            }
306        };
307
308        let record = SandboxRuntimeRecord {
309            schema: "a3s.box.sandbox-runtime.v1",
310            container_id: &launch.container_id,
311            runtime_path: &self.runtime.path,
312            runtime_root: &launch.runtime_root,
313            bundle_dir: &launch.bundle_dir,
314            init_pid,
315            log_worker_pid,
316            log_worker_pid_start_time,
317        };
318        if let Err(error) = write_json_atomic(&launch.runtime_record, &record) {
319            cleanup_failed_start(&self.runtime.path, &launch);
320            reap_failed_log_worker(&mut log_worker);
321            let _ = child.kill();
322            let _ = child.wait();
323            return Err(error);
324        }
325
326        Ok(CrunHandler::from_child(
327            CrunHandlerSpec::new(
328                self.runtime.path.clone(),
329                launch.runtime_root,
330                launch.container_id,
331                init_pid,
332                launch.bundle_dir,
333                launch.runtime_record,
334            ),
335            child,
336            log_worker,
337            log_worker_pid_start_time,
338        ))
339    }
340
341    #[cfg(not(target_os = "linux"))]
342    pub async fn start(&self, _launch: SandboxLaunchSpec) -> Result<CrunHandler> {
343        Err(BoxError::BoxBootError {
344            message: "Sandbox execution requires Linux".to_string(),
345            hint: Some("Run this workload on an A3S OS Sandbox host".to_string()),
346        })
347    }
348}
349
350/// Persist generated artifacts without accepting user-supplied OCI JSON.
351pub fn write_bundle(
352    bundle_dir: &Path,
353    spec: &Spec,
354    execution_plan: &ResolvedExecutionPlan,
355    capabilities: &SandboxCapabilitySnapshot,
356) -> Result<()> {
357    create_private_dir(bundle_dir)?;
358    write_json_atomic(&bundle_dir.join("config.json"), spec)?;
359    write_json_atomic(&bundle_dir.join("execution-plan.json"), execution_plan)?;
360    write_json_atomic(&bundle_dir.join("capabilities.json"), capabilities)?;
361    Ok(())
362}
363
364fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<()> {
365    use std::io::Write;
366    #[cfg(unix)]
367    use std::os::unix::fs::OpenOptionsExt;
368
369    let parent = path.parent().ok_or_else(|| {
370        BoxError::ConfigError(format!(
371            "Sandbox artifact has no parent: {}",
372            path.display()
373        ))
374    })?;
375    create_private_dir(parent)?;
376    let temporary = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
377    let bytes = serde_json::to_vec_pretty(value).map_err(|error| {
378        BoxError::SerializationError(format!("Failed to encode Sandbox artifact: {error}"))
379    })?;
380    let mut options = OpenOptions::new();
381    options.create_new(true).write(true);
382    #[cfg(unix)]
383    options.mode(0o600);
384    let mut file = options.open(&temporary).map_err(BoxError::IoError)?;
385    file.write_all(&bytes).map_err(BoxError::IoError)?;
386    file.write_all(b"\n").map_err(BoxError::IoError)?;
387    file.sync_all().map_err(BoxError::IoError)?;
388    std::fs::rename(&temporary, path).map_err(BoxError::IoError)?;
389    Ok(())
390}
391
392fn create_private_dir(path: &Path) -> Result<()> {
393    std::fs::create_dir_all(path).map_err(BoxError::IoError)?;
394    #[cfg(unix)]
395    {
396        use std::os::unix::fs::PermissionsExt;
397        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
398            .map_err(BoxError::IoError)?;
399    }
400    Ok(())
401}
402
403#[cfg(target_os = "linux")]
404fn open_log(path: &Path) -> Result<File> {
405    #[cfg(unix)]
406    use std::os::unix::fs::OpenOptionsExt;
407
408    let parent = path.parent().ok_or_else(|| {
409        BoxError::ConfigError(format!("Sandbox log has no parent: {}", path.display()))
410    })?;
411    create_private_dir(parent)?;
412    let mut options = OpenOptions::new();
413    options.create(true).truncate(true).write(true);
414    #[cfg(unix)]
415    options.mode(0o600);
416    options.open(path).map_err(BoxError::IoError)
417}
418
419#[cfg(target_os = "linux")]
420fn start_log_worker(
421    launch: &SandboxLaunchSpec,
422    watched_pid: u32,
423    watched_pid_start_time: u64,
424) -> Result<std::process::Child> {
425    let _ = std::fs::remove_file(&launch.log_worker_ready_path);
426    let worker_spec = SandboxLogWorkerSpec {
427        schema: SANDBOX_LOG_WORKER_SCHEMA.to_string(),
428        box_id: launch.container_id.clone(),
429        console_log: launch.stdout_path.clone(),
430        log_config: launch.log_config.clone(),
431        watched_pid,
432        watched_pid_start_time,
433        ready_file: launch.log_worker_ready_path.clone(),
434    };
435    let config = serde_json::to_string(&worker_spec).map_err(|error| {
436        BoxError::SerializationError(format!(
437            "Failed to encode Sandbox log worker configuration: {error}"
438        ))
439    })?;
440    let stdout = open_log(&launch.log_worker_log_path)?;
441    let stderr = stdout.try_clone().map_err(BoxError::IoError)?;
442    let mut worker = Command::new(&launch.log_worker_path)
443        .arg("--sandbox-log-worker-config")
444        .arg(config)
445        .env("LC_ALL", "C")
446        .stdin(Stdio::null())
447        .stdout(Stdio::from(stdout))
448        .stderr(Stdio::from(stderr))
449        .spawn()
450        .map_err(|error| BoxError::BoxBootError {
451            message: format!("Failed to start Sandbox log worker: {error}"),
452            hint: None,
453        })?;
454
455    let deadline = Instant::now() + Duration::from_secs(3);
456    loop {
457        if launch.log_worker_ready_path.is_file() {
458            return Ok(worker);
459        }
460        match worker.try_wait() {
461            Ok(Some(status)) => {
462                let diagnostics =
463                    read_log_tail(&launch.log_worker_log_path, START_FAILURE_LOG_LIMIT_BYTES)
464                        .map(|excerpt| format!(": {excerpt}"))
465                        .unwrap_or_default();
466                return Err(BoxError::BoxBootError {
467                    message: format!(
468                        "Sandbox log worker exited before readiness with {status}{diagnostics}"
469                    ),
470                    hint: None,
471                });
472            }
473            Ok(None) => {}
474            Err(error) => return Err(BoxError::IoError(error)),
475        }
476        if Instant::now() >= deadline {
477            reap_failed_log_worker(&mut worker);
478            return Err(BoxError::BoxBootError {
479                message: "Timed out waiting for Sandbox log worker readiness".to_string(),
480                hint: None,
481            });
482        }
483        std::thread::sleep(Duration::from_millis(5));
484    }
485}
486
487#[cfg(target_os = "linux")]
488fn reap_failed_log_worker(worker: &mut std::process::Child) {
489    let deadline = Instant::now() + Duration::from_secs(1);
490    loop {
491        match worker.try_wait() {
492            Ok(Some(_)) => return,
493            Ok(None) if Instant::now() < deadline => {
494                std::thread::sleep(Duration::from_millis(10));
495            }
496            _ => break,
497        }
498    }
499    let _ = worker.kill();
500    let _ = worker.wait();
501}
502
503#[cfg(target_os = "linux")]
504fn bind_control_listener(path: &Path) -> Result<std::os::unix::net::UnixListener> {
505    use std::os::unix::fs::{FileTypeExt, PermissionsExt};
506
507    let parent = path.parent().ok_or_else(|| {
508        BoxError::ConfigError(format!("Sandbox socket has no parent: {}", path.display()))
509    })?;
510    create_private_dir(parent)?;
511    match std::fs::symlink_metadata(path) {
512        Ok(metadata) if metadata.file_type().is_socket() => {
513            std::fs::remove_file(path).map_err(BoxError::IoError)?;
514        }
515        Ok(_) => {
516            return Err(BoxError::BoxBootError {
517                message: format!(
518                    "Refusing to replace non-socket Sandbox control path {}",
519                    path.display()
520                ),
521                hint: None,
522            })
523        }
524        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
525        Err(error) => return Err(BoxError::IoError(error)),
526    }
527    let listener = std::os::unix::net::UnixListener::bind(path).map_err(BoxError::IoError)?;
528    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
529        .map_err(BoxError::IoError)?;
530    Ok(listener)
531}
532
533#[cfg(target_os = "linux")]
534fn duplicate_for_inheritance(fd: i32) -> Result<std::os::fd::OwnedFd> {
535    use std::os::fd::{FromRawFd, OwnedFd};
536    let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) };
537    if duplicate < 0 {
538        return Err(BoxError::IoError(std::io::Error::last_os_error()));
539    }
540    // SAFETY: F_DUPFD_CLOEXEC returned a new descriptor owned by this process.
541    Ok(unsafe { OwnedFd::from_raw_fd(duplicate) })
542}
543
544#[cfg(target_os = "linux")]
545fn start_failure_diagnostics(launch: &SandboxLaunchSpec) -> String {
546    let diagnostics = [
547        ("crun stderr", &launch.stderr_path),
548        ("guest-init log", &launch.init_log_path),
549        ("Sandbox log worker", &launch.log_worker_log_path),
550    ]
551    .into_iter()
552    .filter_map(|(label, path)| {
553        read_log_tail(path, START_FAILURE_LOG_LIMIT_BYTES)
554            .map(|excerpt| format!("{label}: {excerpt}"))
555    })
556    .collect::<Vec<_>>();
557
558    if diagnostics.is_empty() {
559        String::new()
560    } else {
561        format!(" ({})", diagnostics.join("; "))
562    }
563}
564
565#[cfg(target_os = "linux")]
566fn read_log_tail(path: &Path, limit: u64) -> Option<String> {
567    let mut file = File::open(path).ok()?;
568    let length = file.metadata().ok()?.len();
569    let offset = length.saturating_sub(limit);
570    file.seek(SeekFrom::Start(offset)).ok()?;
571
572    let mut bytes = Vec::with_capacity((length - offset) as usize);
573    file.take(limit).read_to_end(&mut bytes).ok()?;
574    let excerpt = String::from_utf8_lossy(&bytes).trim().to_string();
575    if excerpt.is_empty() {
576        None
577    } else if offset > 0 {
578        Some(format!("...{excerpt}"))
579    } else {
580        Some(excerpt)
581    }
582}
583
584#[cfg(target_os = "linux")]
585fn cleanup_failed_start(runtime_path: &Path, launch: &SandboxLaunchSpec) {
586    let _ = Command::new(runtime_path)
587        .arg("--root")
588        .arg(&launch.runtime_root)
589        .arg("delete")
590        .arg("--force")
591        .arg(&launch.container_id)
592        .env("LC_ALL", "C")
593        .output();
594    let _ = std::fs::remove_file(&launch.runtime_record);
595    let _ = std::fs::remove_dir_all(&launch.runtime_root);
596}
597
598#[cfg(all(test, target_os = "linux"))]
599mod tests {
600    use super::*;
601
602    fn controller_with_runtime(path: PathBuf) -> CrunController {
603        CrunController::new(CertifiedCrun {
604            path,
605            version: "1.28".to_string(),
606            sha256: "test-digest".to_string(),
607            features: vec!["+CAP".to_string(), "+SECCOMP".to_string()],
608        })
609    }
610
611    #[test]
612    fn absent_runtime_root_is_not_materialized_by_state_probe() {
613        let temporary = tempfile::tempdir().unwrap();
614        let runtime_root = temporary.path().join("missing-runtime-root");
615        let controller = controller_with_runtime(temporary.path().join("must-not-run"));
616
617        controller
618            .require_absent(&runtime_root, "internal-execution-id")
619            .unwrap();
620
621        assert!(!runtime_root.exists());
622    }
623
624    #[test]
625    fn runtime_root_symlink_is_rejected_before_executing_crun() {
626        use std::os::unix::fs::symlink;
627
628        let temporary = tempfile::tempdir().unwrap();
629        let target = temporary.path().join("target");
630        let runtime_root = temporary.path().join("runtime-root");
631        std::fs::create_dir(&target).unwrap();
632        symlink(&target, &runtime_root).unwrap();
633        let controller = controller_with_runtime(temporary.path().join("must-not-run"));
634
635        let error = controller
636            .require_absent(&runtime_root, "internal-execution-id")
637            .unwrap_err();
638
639        assert!(error.to_string().contains("not a directory"));
640        assert!(target.read_dir().unwrap().next().is_none());
641    }
642
643    #[test]
644    fn startup_log_excerpt_is_bounded_and_keeps_the_tail() {
645        let temporary = tempfile::tempdir().unwrap();
646        let path = temporary.path().join("crun.stderr.log");
647        let mut contents = "x".repeat(START_FAILURE_LOG_LIMIT_BYTES as usize + 512);
648        contents.push_str("\nseccomp unknown architecture `NATIVE`\n");
649        std::fs::write(&path, contents).unwrap();
650
651        let excerpt = read_log_tail(&path, START_FAILURE_LOG_LIMIT_BYTES).unwrap();
652        assert!(excerpt.starts_with("..."));
653        assert!(excerpt.contains("seccomp unknown architecture `NATIVE`"));
654        assert!(excerpt.len() <= START_FAILURE_LOG_LIMIT_BYTES as usize + 3);
655    }
656}