Skip to main content

aft/bash_background/
persistence.rs

1#[cfg(unix)]
2use std::ffi::CString;
3use std::ffi::{OsStr, OsString};
4use std::fs::{self, File, OpenOptions};
5use std::io::{self, Read, Seek, SeekFrom, Write};
6#[cfg(unix)]
7use std::os::fd::{AsRawFd, FromRawFd, RawFd};
8#[cfg(unix)]
9use std::os::unix::ffi::{OsStrExt, OsStringExt};
10#[cfg(unix)]
11use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
12#[cfg(windows)]
13use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
14#[cfg(windows)]
15use std::os::windows::io::AsRawHandle;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use serde::{Deserialize, Serialize};
21
22use crate::backup::hash_session;
23use crate::bash_permissions::PermissionAsk;
24use crate::db::bash_tasks::BashTaskRow;
25
26use super::process::LiveDescendant;
27use super::BgTaskStatus;
28
29pub const SCHEMA_VERSION: u32 = 6;
30const CONTROL_DIR: &str = "control";
31const IO_DIR: &str = "io";
32const METADATA_FILE: &str = "metadata.json";
33pub const COMMAND_FILE: &str = "command.sh";
34pub const WRAPPER_FILE: &str = "wrapper.sh";
35pub const ENVIRONMENT_FILE: &str = "environment.bin";
36pub const MANIFEST_FILE: &str = "manifest.blake3";
37pub const SANDBOX_PROFILE_FILE: &str = "sandbox-profile.json";
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum TaskLayout {
41    Flat,
42    Directory,
43}
44
45#[derive(Debug, Clone)]
46pub struct TaskPaths {
47    pub layout: TaskLayout,
48    pub task_id: String,
49    pub session_dir: PathBuf,
50    /// Root directory for this task's persisted artifacts; legacy flat-layout tasks
51    /// use the session directory instead of a per-task directory.
52    pub dir: PathBuf,
53    pub control_dir: PathBuf,
54    pub io_dir: PathBuf,
55    pub json: PathBuf,
56    pub stdout: PathBuf,
57    pub stderr: PathBuf,
58    pub exit: PathBuf,
59    pub pipeline_status: PathBuf,
60    pub pty: PathBuf,
61    pub sandbox_unavailable: PathBuf,
62    pub command: PathBuf,
63    pub wrapper: PathBuf,
64    pub environment: PathBuf,
65    pub manifest: PathBuf,
66    pub sandbox_profile: PathBuf,
67}
68
69impl TaskPaths {
70    fn directory(session_dir: PathBuf, task_id: &str) -> Self {
71        let dir = session_dir.join(task_id);
72        let control_dir = dir.join(CONTROL_DIR);
73        let io_dir = dir.join(IO_DIR);
74        Self {
75            layout: TaskLayout::Directory,
76            task_id: task_id.to_string(),
77            session_dir,
78            dir,
79            json: control_dir.join(METADATA_FILE),
80            stdout: io_dir.join(TaskArtifact::Stdout.file_name()),
81            stderr: io_dir.join(TaskArtifact::Stderr.file_name()),
82            exit: io_dir.join(TaskArtifact::Exit.file_name()),
83            pipeline_status: io_dir.join(TaskArtifact::PipelineStatus.file_name()),
84            pty: io_dir.join(TaskArtifact::Pty.file_name()),
85            sandbox_unavailable: io_dir.join(TaskArtifact::SandboxUnavailable.file_name()),
86            command: control_dir.join(COMMAND_FILE),
87            wrapper: control_dir.join(WRAPPER_FILE),
88            environment: control_dir.join(ENVIRONMENT_FILE),
89            manifest: control_dir.join(MANIFEST_FILE),
90            sandbox_profile: control_dir.join(SANDBOX_PROFILE_FILE),
91            control_dir,
92            io_dir,
93        }
94    }
95
96    fn flat(session_dir: PathBuf, task_id: &str) -> Self {
97        let prefix = |extension: &str| session_dir.join(format!("{task_id}.{extension}"));
98        Self {
99            layout: TaskLayout::Flat,
100            task_id: task_id.to_string(),
101            dir: session_dir.clone(),
102            control_dir: session_dir.clone(),
103            io_dir: session_dir.clone(),
104            json: prefix("json"),
105            stdout: prefix("stdout"),
106            stderr: prefix("stderr"),
107            exit: prefix("exit"),
108            pipeline_status: prefix("pipeline-status"),
109            pty: prefix("pty"),
110            sandbox_unavailable: prefix("sandbox-unavailable"),
111            command: prefix("sh"),
112            wrapper: prefix("wrapper.sh"),
113            environment: prefix("env"),
114            manifest: prefix("manifest"),
115            sandbox_profile: prefix("sandbox-profile.json"),
116            session_dir,
117        }
118    }
119
120    pub fn artifact_path(&self, artifact: TaskArtifact) -> &Path {
121        match artifact {
122            TaskArtifact::Stdout => &self.stdout,
123            TaskArtifact::Stderr => &self.stderr,
124            TaskArtifact::Exit => &self.exit,
125            TaskArtifact::PipelineStatus => &self.pipeline_status,
126            TaskArtifact::Pty => &self.pty,
127            TaskArtifact::SandboxUnavailable => &self.sandbox_unavailable,
128        }
129    }
130
131    fn artifact_name(&self, artifact: TaskArtifact) -> OsString {
132        match self.layout {
133            TaskLayout::Directory => OsString::from(artifact.file_name()),
134            TaskLayout::Flat => {
135                OsString::from(format!("{}.{}", self.task_id, artifact.flat_extension()))
136            }
137        }
138    }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
142pub enum TaskArtifact {
143    Stdout,
144    Stderr,
145    Exit,
146    PipelineStatus,
147    Pty,
148    SandboxUnavailable,
149}
150
151impl TaskArtifact {
152    pub const ALL: [Self; 6] = [
153        Self::Stdout,
154        Self::Stderr,
155        Self::Exit,
156        Self::PipelineStatus,
157        Self::Pty,
158        Self::SandboxUnavailable,
159    ];
160
161    pub fn file_name(self) -> &'static str {
162        match self {
163            Self::Stdout => "stdout",
164            Self::Stderr => "stderr",
165            Self::Exit => "exit",
166            Self::PipelineStatus => "pipeline-status",
167            Self::Pty => "pty",
168            Self::SandboxUnavailable => "sandbox-unavailable",
169        }
170    }
171
172    fn flat_extension(self) -> &'static str {
173        match self {
174            Self::Stdout => "stdout",
175            Self::Stderr => "stderr",
176            Self::Exit => "exit",
177            Self::PipelineStatus => "pipeline-status",
178            Self::Pty => "pty",
179            Self::SandboxUnavailable => "sandbox-unavailable",
180        }
181    }
182}
183
184#[derive(Debug)]
185pub struct PinnedDir {
186    file: File,
187    path: PathBuf,
188}
189
190impl PinnedDir {
191    pub fn open(path: &Path) -> io::Result<Self> {
192        #[cfg(unix)]
193        let file = OpenOptions::new()
194            .read(true)
195            .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
196            .open(path)?;
197        #[cfg(windows)]
198        let file = OpenOptions::new()
199            .read(true)
200            .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS)
201            .open(path)?;
202        validate_directory_handle(&file)?;
203        Ok(Self {
204            file,
205            path: path.to_path_buf(),
206        })
207    }
208
209    pub fn path(&self) -> &Path {
210        &self.path
211    }
212
213    fn modified(&self) -> io::Result<SystemTime> {
214        self.file.metadata()?.modified()
215    }
216
217    fn same_identity(&self, other: &Self) -> io::Result<bool> {
218        #[cfg(unix)]
219        {
220            let left = self.file.metadata()?;
221            let right = other.file.metadata()?;
222            Ok(left.dev() == right.dev() && left.ino() == right.ino())
223        }
224        #[cfg(windows)]
225        {
226            let left = windows_file_information(&self.file)?;
227            let right = windows_file_information(&other.file)?;
228            Ok(left.volume_serial_number == right.volume_serial_number
229                && left.file_index_high == right.file_index_high
230                && left.file_index_low == right.file_index_low)
231        }
232    }
233
234    #[cfg(unix)]
235    fn open_dir_at(&self, name: &OsStr) -> io::Result<Self> {
236        let file = openat_file(
237            self.file.as_raw_fd(),
238            name,
239            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
240            0,
241        )?;
242        validate_directory_handle(&file)?;
243        Ok(Self {
244            file,
245            path: self.path.join(name),
246        })
247    }
248
249    #[cfg(windows)]
250    fn open_dir_at(&self, name: &OsStr) -> io::Result<Self> {
251        self.ensure_current_identity()?;
252        let child = Self::open(&self.path.join(name))?;
253        self.ensure_current_identity()?;
254        Ok(child)
255    }
256
257    #[cfg(unix)]
258    fn create_dir_at(&self, name: &OsStr) -> io::Result<Self> {
259        let name = os_cstring(name)?;
260        let result = unsafe { libc::mkdirat(self.file.as_raw_fd(), name.as_ptr(), 0o700) };
261        if result != 0 {
262            return Err(io::Error::last_os_error());
263        }
264        self.open_dir_at(OsStr::from_bytes(name.as_bytes()))
265    }
266
267    #[cfg(windows)]
268    fn create_dir_at(&self, name: &OsStr) -> io::Result<Self> {
269        self.ensure_current_identity()?;
270        let path = self.path.join(name);
271        fs::create_dir(&path)?;
272        self.ensure_current_identity()?;
273        Self::open(&path)
274    }
275
276    pub fn open_new_file(&self, name: &OsStr) -> io::Result<File> {
277        #[cfg(unix)]
278        let file = openat_file(
279            self.file.as_raw_fd(),
280            name,
281            libc::O_RDWR | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC,
282            0o600,
283        )?;
284        #[cfg(windows)]
285        self.ensure_current_identity()?;
286        #[cfg(windows)]
287        let file = OpenOptions::new()
288            .read(true)
289            .write(true)
290            .create_new(true)
291            .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
292            .open(self.path.join(name))?;
293        validate_regular_handle(&file)?;
294        #[cfg(windows)]
295        self.ensure_current_identity()?;
296        Ok(file)
297    }
298
299    pub fn open_file(&self, name: &OsStr, write: bool) -> io::Result<File> {
300        #[cfg(unix)]
301        let file = openat_file(
302            self.file.as_raw_fd(),
303            name,
304            (if write {
305                libc::O_RDWR
306            } else {
307                libc::O_RDONLY | libc::O_NONBLOCK
308            }) | libc::O_NOFOLLOW
309                | libc::O_CLOEXEC,
310            0,
311        )?;
312        #[cfg(windows)]
313        self.ensure_current_identity()?;
314        #[cfg(windows)]
315        let file = OpenOptions::new()
316            .read(true)
317            .write(write)
318            .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
319            .open(self.path.join(name))?;
320        validate_regular_handle(&file)?;
321        #[cfg(unix)]
322        if !write {
323            clear_nonblocking(&file)?;
324        }
325        #[cfg(windows)]
326        self.ensure_current_identity()?;
327        Ok(file)
328    }
329
330    pub fn list_names(&self) -> io::Result<Vec<OsString>> {
331        #[cfg(unix)]
332        {
333            let dot = b".\0";
334            let fresh = unsafe {
335                libc::openat(
336                    self.file.as_raw_fd(),
337                    dot.as_ptr().cast(),
338                    libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
339                )
340            };
341            if fresh < 0 {
342                return Err(io::Error::last_os_error());
343            }
344            let directory = unsafe { libc::fdopendir(fresh) };
345            if directory.is_null() {
346                let error = io::Error::last_os_error();
347                unsafe { libc::close(fresh) };
348                return Err(error);
349            }
350            let mut names = Vec::new();
351            loop {
352                let entry = unsafe { libc::readdir(directory) };
353                if entry.is_null() {
354                    break;
355                }
356                let bytes = unsafe {
357                    std::ffi::CStr::from_ptr((*entry).d_name.as_ptr())
358                        .to_bytes()
359                        .to_vec()
360                };
361                if bytes != b"." && bytes != b".." {
362                    names.push(OsString::from_vec(bytes));
363                }
364            }
365            unsafe { libc::closedir(directory) };
366            Ok(names)
367        }
368        #[cfg(windows)]
369        {
370            self.ensure_current_identity()?;
371            let names = fs::read_dir(&self.path)?
372                .map(|entry| entry.map(|entry| entry.file_name()))
373                .collect::<io::Result<Vec<_>>>()?;
374            self.ensure_current_identity()?;
375            Ok(names)
376        }
377    }
378
379    fn rename(&self, from: &OsStr, to: &OsStr) -> io::Result<()> {
380        self.rename_to(from, self, to)
381    }
382
383    fn rename_to(&self, from: &OsStr, target: &PinnedDir, to: &OsStr) -> io::Result<()> {
384        #[cfg(unix)]
385        {
386            let from = os_cstring(from)?;
387            let to = os_cstring(to)?;
388            let result = unsafe {
389                libc::renameat(
390                    self.file.as_raw_fd(),
391                    from.as_ptr(),
392                    target.file.as_raw_fd(),
393                    to.as_ptr(),
394                )
395            };
396            if result != 0 {
397                return Err(io::Error::last_os_error());
398            }
399            Ok(())
400        }
401        #[cfg(windows)]
402        {
403            self.ensure_current_identity()?;
404            target.ensure_current_identity()?;
405            fs::rename(self.path.join(from), target.path.join(to))?;
406            self.ensure_current_identity()?;
407            target.ensure_current_identity()
408        }
409    }
410
411    #[cfg(windows)]
412    fn ensure_current_identity(&self) -> io::Result<()> {
413        let current = OpenOptions::new()
414            .read(true)
415            .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS)
416            .open(&self.path)?;
417        validate_directory_handle(&current)?;
418        let held = windows_file_information(&self.file)?;
419        let observed = windows_file_information(&current)?;
420        if held.volume_serial_number != observed.volume_serial_number
421            || held.file_index_high != observed.file_index_high
422            || held.file_index_low != observed.file_index_low
423        {
424            return Err(io::Error::new(
425                io::ErrorKind::PermissionDenied,
426                "pinned directory path identity changed",
427            ));
428        }
429        Ok(())
430    }
431
432    fn remove_file(&self, name: &OsStr) -> io::Result<()> {
433        #[cfg(unix)]
434        {
435            let name = os_cstring(name)?;
436            let result = unsafe { libc::unlinkat(self.file.as_raw_fd(), name.as_ptr(), 0) };
437            if result != 0 {
438                return Err(io::Error::last_os_error());
439            }
440            Ok(())
441        }
442        #[cfg(windows)]
443        {
444            self.ensure_current_identity()?;
445            fs::remove_file(self.path.join(name))?;
446            self.ensure_current_identity()
447        }
448    }
449}
450
451#[derive(Debug)]
452pub struct TaskDirs {
453    pub session: Arc<PinnedDir>,
454    pub task: Arc<PinnedDir>,
455    pub control: Arc<PinnedDir>,
456    pub io: Arc<PinnedDir>,
457}
458
459impl Clone for TaskDirs {
460    fn clone(&self) -> Self {
461        Self {
462            session: Arc::clone(&self.session),
463            task: Arc::clone(&self.task),
464            control: Arc::clone(&self.control),
465            io: Arc::clone(&self.io),
466        }
467    }
468}
469
470#[derive(Debug)]
471pub struct ResolvedTask {
472    pub paths: TaskPaths,
473    pub dirs: TaskDirs,
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
477#[serde(rename_all = "lowercase")]
478pub enum BgMode {
479    #[default]
480    Pipes,
481    Pty,
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
485pub struct PersistedTask {
486    pub schema_version: u32,
487    pub task_id: String,
488    pub session_id: String,
489    pub command: String,
490    /// Scanner-derived segment labels retained so completion rendering does not
491    /// need to parse the command again.
492    #[serde(default, skip_serializing_if = "Vec::is_empty")]
493    pub pipeline_segments: Vec<String>,
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub pipeline_status_unavailable: Option<String>,
496    #[serde(default)]
497    pub mode: BgMode,
498    pub workdir: PathBuf,
499    #[serde(default)]
500    pub project_root: Option<PathBuf>,
501    pub status: BgTaskStatus,
502    pub started_at: u64,
503    pub finished_at: Option<u64>,
504    pub duration_ms: Option<u64>,
505    pub timeout_ms: Option<u64>,
506    pub exit_code: Option<i32>,
507    pub child_pid: Option<u32>,
508    pub pgid: Option<i32>,
509    /// `Some` records a completed Unix sample, including an empty group. `None`
510    /// means sampling is unavailable (Windows and unsupported Unix targets).
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub live_descendants: Option<Vec<LiveDescendant>>,
513    #[serde(default, skip_serializing_if = "is_zero")]
514    pub live_descendants_omitted: usize,
515    pub completion_delivered: bool,
516    #[serde(default = "default_notify_on_completion")]
517    pub notify_on_completion: bool,
518    #[serde(default = "default_compressed")]
519    pub compressed: bool,
520    #[serde(default)]
521    pub pty_rows: Option<u16>,
522    #[serde(default)]
523    pub pty_cols: Option<u16>,
524    #[serde(default, skip_serializing_if = "Vec::is_empty")]
525    pub scanner_report: Vec<PermissionAsk>,
526    #[serde(default)]
527    pub sandbox_native: bool,
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    pub sandbox_temp_dir: Option<PathBuf>,
530    pub status_reason: Option<String>,
531}
532
533fn default_notify_on_completion() -> bool {
534    true
535}
536
537fn default_compressed() -> bool {
538    true
539}
540
541fn is_zero(value: &usize) -> bool {
542    *value == 0
543}
544
545#[derive(Debug, Clone, PartialEq, Eq)]
546pub enum ExitMarker {
547    Code(i32),
548    Killed,
549}
550
551impl PersistedTask {
552    #[allow(clippy::too_many_arguments)]
553    pub fn starting(
554        task_id: String,
555        session_id: String,
556        command: String,
557        workdir: PathBuf,
558        project_root: Option<PathBuf>,
559        timeout_ms: Option<u64>,
560        notify_on_completion: bool,
561        compressed: bool,
562    ) -> Self {
563        Self {
564            schema_version: SCHEMA_VERSION,
565            task_id,
566            session_id,
567            command,
568            pipeline_segments: Vec::new(),
569            pipeline_status_unavailable: None,
570            mode: BgMode::Pipes,
571            workdir,
572            project_root,
573            status: BgTaskStatus::Starting,
574            started_at: unix_millis(),
575            finished_at: None,
576            duration_ms: None,
577            timeout_ms,
578            exit_code: None,
579            child_pid: None,
580            pgid: None,
581            live_descendants: None,
582            live_descendants_omitted: 0,
583            completion_delivered: !notify_on_completion,
584            notify_on_completion,
585            compressed,
586            pty_rows: None,
587            pty_cols: None,
588            scanner_report: Vec::new(),
589            sandbox_native: false,
590            sandbox_temp_dir: None,
591            status_reason: None,
592        }
593    }
594
595    pub fn is_terminal(&self) -> bool {
596        self.status.is_terminal()
597    }
598
599    pub fn mark_running(&mut self, child_pid: u32, pgid: i32) {
600        self.status = BgTaskStatus::Running;
601        self.child_pid = Some(child_pid);
602        self.pgid = Some(pgid);
603    }
604
605    pub fn mark_terminal(
606        &mut self,
607        status: BgTaskStatus,
608        exit_code: Option<i32>,
609        reason: Option<String>,
610    ) {
611        let finished_at = unix_millis();
612        self.status = status;
613        self.exit_code = exit_code;
614        self.finished_at = Some(finished_at);
615        self.duration_ms = Some(finished_at.saturating_sub(self.started_at));
616        self.child_pid = None;
617        self.status_reason = reason;
618        self.completion_delivered = !self.notify_on_completion;
619    }
620
621    pub fn to_bash_task_row(
622        &self,
623        harness: &str,
624        paths: &TaskPaths,
625    ) -> Result<BashTaskRow, serde_json::Error> {
626        let project_root = self.project_root.as_deref().unwrap_or(&self.workdir);
627        let output_bytes = capture_output_bytes(&self.mode, paths);
628        let stdout_path = match self.mode {
629            BgMode::Pipes => Some(paths.stdout.display().to_string()),
630            BgMode::Pty => Some(paths.pty.display().to_string()),
631        };
632        let stderr_path = match self.mode {
633            BgMode::Pipes => Some(paths.stderr.display().to_string()),
634            BgMode::Pty => None,
635        };
636        let mut metadata = self.clone();
637        metadata.schema_version = SCHEMA_VERSION;
638        Ok(BashTaskRow {
639            harness: harness.to_string(),
640            session_id: self.session_id.clone(),
641            task_id: self.task_id.clone(),
642            project_key: crate::path_identity::project_scope_key(project_root),
643            command: self.command.clone(),
644            cwd: self.workdir.display().to_string(),
645            status: status_name(&self.status).to_string(),
646            exit_code: self.exit_code,
647            pid: self.child_pid.map(i64::from),
648            pgid: self.pgid.map(i64::from),
649            started_at: self.started_at as i64,
650            completed_at: self.finished_at.map(|value| value as i64),
651            stdout_path,
652            stderr_path,
653            compressed: self.compressed,
654            timeout_ms: self.timeout_ms.map(|value| value as i64),
655            completion_delivered: self.completion_delivered,
656            output_bytes,
657            metadata: serde_json::to_string(&metadata)?,
658        })
659    }
660}
661
662impl From<BashTaskRow> for PersistedTask {
663    fn from(row: BashTaskRow) -> Self {
664        if let Ok(task) = serde_json::from_str::<PersistedTask>(&row.metadata) {
665            return task;
666        }
667        let status = match row.status.as_str() {
668            "starting" => BgTaskStatus::Starting,
669            "running" => BgTaskStatus::Running,
670            "killing" => BgTaskStatus::Killing,
671            "completed" => BgTaskStatus::Completed,
672            "failed" => BgTaskStatus::Failed,
673            "killed" => BgTaskStatus::Killed,
674            "timed_out" => BgTaskStatus::TimedOut,
675            "fate_unknown" => BgTaskStatus::FateUnknown,
676            _ => BgTaskStatus::Failed,
677        };
678        let started_at = u64::try_from(row.started_at).unwrap_or_default();
679        let finished_at = row.completed_at.and_then(|value| u64::try_from(value).ok());
680        Self {
681            schema_version: SCHEMA_VERSION,
682            task_id: row.task_id,
683            session_id: row.session_id,
684            command: row.command,
685            pipeline_segments: Vec::new(),
686            pipeline_status_unavailable: None,
687            mode: BgMode::Pipes,
688            workdir: PathBuf::from(row.cwd),
689            project_root: None,
690            status,
691            started_at,
692            finished_at,
693            duration_ms: finished_at.map(|finished_at| finished_at.saturating_sub(started_at)),
694            timeout_ms: row.timeout_ms.and_then(|value| u64::try_from(value).ok()),
695            exit_code: row.exit_code,
696            child_pid: row.pid.and_then(|value| u32::try_from(value).ok()),
697            pgid: row.pgid.and_then(|value| i32::try_from(value).ok()),
698            live_descendants: None,
699            live_descendants_omitted: 0,
700            completion_delivered: row.completion_delivered,
701            notify_on_completion: !row.completion_delivered,
702            compressed: row.compressed,
703            pty_rows: None,
704            pty_cols: None,
705            scanner_report: Vec::new(),
706            sandbox_native: false,
707            sandbox_temp_dir: None,
708            status_reason: None,
709        }
710    }
711}
712
713fn status_name(status: &BgTaskStatus) -> &'static str {
714    match status {
715        BgTaskStatus::Starting => "starting",
716        BgTaskStatus::Running => "running",
717        BgTaskStatus::Killing => "killing",
718        BgTaskStatus::Completed => "completed",
719        BgTaskStatus::Failed => "failed",
720        BgTaskStatus::Killed => "killed",
721        BgTaskStatus::TimedOut => "timed_out",
722        BgTaskStatus::FateUnknown => "fate_unknown",
723    }
724}
725
726fn capture_output_bytes(mode: &BgMode, paths: &TaskPaths) -> Option<i64> {
727    let len = |artifact| {
728        open_task_artifact(paths, artifact)
729            .ok()
730            .and_then(|file| file.len().ok())
731    };
732    match mode {
733        BgMode::Pipes => match (len(TaskArtifact::Stdout), len(TaskArtifact::Stderr)) {
734            (Some(stdout), Some(stderr)) => Some(stdout.saturating_add(stderr) as i64),
735            (Some(bytes), None) | (None, Some(bytes)) => Some(bytes as i64),
736            (None, None) => None,
737        },
738        BgMode::Pty => len(TaskArtifact::Pty).map(|bytes| bytes as i64),
739    }
740}
741
742pub fn validate_task_id(task_id: &str) -> io::Result<()> {
743    let bytes = task_id.as_bytes();
744    if bytes.len() == 21
745        && bytes.starts_with(b"bash-")
746        && bytes[5..]
747            .iter()
748            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
749    {
750        Ok(())
751    } else {
752        Err(io::Error::new(
753            io::ErrorKind::InvalidInput,
754            "background task id must match ^bash-[0-9a-f]{16}$",
755        ))
756    }
757}
758
759pub fn session_tasks_dir(storage_dir: &Path, session_id: &str) -> PathBuf {
760    let session_hash = hash_session(session_id);
761    let direct = storage_dir.join("bash-tasks").join(&session_hash);
762    if direct.exists() {
763        return direct;
764    }
765    let mut harness_matches = ["opencode", "pi"]
766        .into_iter()
767        .map(|harness| {
768            storage_dir
769                .join(harness)
770                .join("bash-tasks")
771                .join(&session_hash)
772        })
773        .filter(|path| path.exists())
774        .collect::<Vec<_>>();
775    if harness_matches.len() == 1 {
776        return harness_matches.remove(0);
777    }
778    direct
779}
780
781pub fn task_paths(storage_dir: &Path, session_id: &str, task_id: &str) -> io::Result<TaskPaths> {
782    validate_task_id(task_id)?;
783    Ok(TaskPaths::flat(
784        session_tasks_dir(storage_dir, session_id),
785        task_id,
786    ))
787}
788
789pub fn allocate_task_layout(storage_dir: &Path, session_id: &str) -> io::Result<ResolvedTask> {
790    let session_dir = session_tasks_dir(storage_dir, session_id);
791    create_private_task_store(&session_dir)?;
792    let session = Arc::new(PinnedDir::open(&session_dir)?);
793    for _ in 0..32 {
794        let task_id = random_task_id()?;
795        match create_task_layout_from_session(Arc::clone(&session), &task_id) {
796            Ok(task) => return Ok(task),
797            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
798            Err(error) => return Err(error),
799        }
800    }
801    Err(io::Error::new(
802        io::ErrorKind::AlreadyExists,
803        "failed to allocate unique background task id after 32 attempts",
804    ))
805}
806
807pub fn create_task_layout(
808    storage_dir: &Path,
809    session_id: &str,
810    task_id: &str,
811) -> io::Result<ResolvedTask> {
812    validate_task_id(task_id)?;
813    let session_dir = session_tasks_dir(storage_dir, session_id);
814    create_private_task_store(&session_dir)?;
815    create_task_layout_from_session(Arc::new(PinnedDir::open(&session_dir)?), task_id)
816}
817
818fn create_private_task_store(session_dir: &Path) -> io::Result<()> {
819    fs::create_dir_all(session_dir)?;
820    #[cfg(unix)]
821    {
822        let parent = session_dir.parent().ok_or_else(|| {
823            io::Error::new(
824                io::ErrorKind::InvalidInput,
825                "session task directory has no bash-tasks parent",
826            )
827        })?;
828        fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
829        fs::set_permissions(session_dir, fs::Permissions::from_mode(0o700))?;
830    }
831    Ok(())
832}
833
834fn create_task_layout_from_session(
835    session: Arc<PinnedDir>,
836    task_id: &str,
837) -> io::Result<ResolvedTask> {
838    validate_task_id(task_id)?;
839    let task = session.create_dir_at(OsStr::new(task_id))?;
840    let control = task.create_dir_at(OsStr::new(CONTROL_DIR))?;
841    let io_dir = task.create_dir_at(OsStr::new(IO_DIR))?;
842    let paths = TaskPaths::directory(session.path.clone(), task_id);
843    Ok(ResolvedTask {
844        paths,
845        dirs: TaskDirs {
846            session,
847            task: Arc::new(task),
848            control: Arc::new(control),
849            io: Arc::new(io_dir),
850        },
851    })
852}
853
854pub fn resolve_task_layout(session_dir: &Path, task_id: &str) -> io::Result<ResolvedTask> {
855    let task = resolve_uninitialized_task_layout(session_dir, task_id)?;
856    let metadata = read_task_at(&task)?;
857    if metadata.task_id != task_id {
858        return Err(io::Error::new(
859            io::ErrorKind::InvalidData,
860            "background task metadata identity mismatch",
861        ));
862    }
863    Ok(task)
864}
865
866pub fn resolve_uninitialized_task_layout(
867    session_dir: &Path,
868    task_id: &str,
869) -> io::Result<ResolvedTask> {
870    validate_task_id(task_id)?;
871    let session = Arc::new(PinnedDir::open(session_dir)?);
872    let directory = session.open_dir_at(OsStr::new(task_id));
873    let flat_name = OsString::from(format!("{task_id}.json"));
874    let flat = open_metadata_through_replacement(&session, &flat_name);
875    let has_directory = match &directory {
876        Ok(_) => true,
877        Err(error) if error.kind() == io::ErrorKind::NotFound => false,
878        Err(error) => {
879            return Err(io::Error::new(
880                error.kind(),
881                format!("invalid task directory: {error}"),
882            ))
883        }
884    };
885    let has_flat = match &flat {
886        Ok(_) => true,
887        Err(error) if error.kind() == io::ErrorKind::NotFound => false,
888        Err(error) => {
889            return Err(io::Error::new(
890                error.kind(),
891                format!("invalid flat task metadata: {error}"),
892            ))
893        }
894    };
895    if has_directory && has_flat {
896        return Err(io::Error::new(
897            io::ErrorKind::InvalidData,
898            "duplicate flat and directory background task layouts",
899        ));
900    }
901    if has_directory {
902        let task = directory.expect("directory result checked");
903        let control = task.open_dir_at(OsStr::new(CONTROL_DIR))?;
904        let io_dir = task.open_dir_at(OsStr::new(IO_DIR))?;
905        let paths = TaskPaths::directory(session_dir.to_path_buf(), task_id);
906        return Ok(ResolvedTask {
907            paths,
908            dirs: TaskDirs {
909                session,
910                task: Arc::new(task),
911                control: Arc::new(control),
912                io: Arc::new(io_dir),
913            },
914        });
915    }
916    if has_flat {
917        let paths = TaskPaths::flat(session_dir.to_path_buf(), task_id);
918        return Ok(ResolvedTask {
919            paths,
920            dirs: TaskDirs {
921                session: Arc::clone(&session),
922                task: Arc::clone(&session),
923                control: Arc::clone(&session),
924                io: session,
925            },
926        });
927    }
928    Err(io::Error::new(
929        io::ErrorKind::NotFound,
930        "background task layout not found",
931    ))
932}
933
934pub fn resolve_task(
935    storage_dir: &Path,
936    session_id: &str,
937    task_id: &str,
938) -> io::Result<ResolvedTask> {
939    resolve_task_layout(&session_tasks_dir(storage_dir, session_id), task_id)
940}
941
942pub fn discover_task_ids(session_dir: &Path) -> io::Result<(Vec<String>, Vec<OsString>)> {
943    let session = PinnedDir::open(session_dir)?;
944    let mut ids = std::collections::BTreeSet::new();
945    let mut invalid = Vec::new();
946    for name in session.list_names()? {
947        let Some(text) = name.to_str() else {
948            invalid.push(name);
949            continue;
950        };
951        if validate_task_id(text).is_ok() {
952            ids.insert(text.to_string());
953            continue;
954        }
955        if let Some((task_id, _suffix)) = text.split_once('.') {
956            if validate_task_id(task_id).is_ok() {
957                ids.insert(task_id.to_string());
958            } else if task_id.starts_with("bash-") {
959                invalid.push(name);
960            }
961        } else if text.starts_with("bash-") {
962            invalid.push(name);
963        }
964    }
965    Ok((ids.into_iter().collect(), invalid))
966}
967
968pub fn uninitialized_layout_is_recent(
969    session_dir: &Path,
970    task_id: &str,
971    grace: std::time::Duration,
972) -> io::Result<bool> {
973    // A spawn creates the task directory a few syscalls before `control/` and
974    // the metadata exist, and the persisted-task GC runs concurrently with
975    // spawns (in this process since it left the replay thread, and always from
976    // sibling processes sharing the storage root). Mid-creation, the layout
977    // resolver finds nothing to resolve; the directory's own mtime still says
978    // how young it is, and a young directory must be skipped, not quarantined.
979    let task = match resolve_uninitialized_task_layout(session_dir, task_id) {
980        Ok(task) => task,
981        Err(_) => {
982            // The directory's age is an answer either way: young means a spawn
983            // in progress, old means an abandoned layout that may be reclaimed.
984            // Only a failing metadata probe (the directory is already gone)
985            // propagates as an error.
986            let modified = session_dir.join(task_id).metadata()?.modified()?;
987            let age = SystemTime::now()
988                .duration_since(modified)
989                .unwrap_or_default();
990            return Ok(age < grace);
991        }
992    };
993    let modified = match task.paths.layout {
994        TaskLayout::Directory => task.dirs.control.modified()?,
995        TaskLayout::Flat => task
996            .dirs
997            .session
998            .open_file(&task.paths.artifact_name(TaskArtifact::Exit), false)
999            .and_then(|file| file.metadata()?.modified())
1000            .or_else(|_| {
1001                task.dirs
1002                    .session
1003                    .open_file(OsStr::new(&format!("{task_id}.json")), false)
1004                    .and_then(|file| file.metadata()?.modified())
1005            })?,
1006    };
1007    Ok(SystemTime::now()
1008        .duration_since(modified)
1009        .unwrap_or_default()
1010        < grace)
1011}
1012
1013pub fn quarantine_task_layout(
1014    storage_dir: &Path,
1015    session_dir: &Path,
1016    task_id: &str,
1017    reason: &str,
1018) -> io::Result<()> {
1019    validate_task_id(task_id)?;
1020    let session = PinnedDir::open(session_dir)?;
1021    let names = session.list_names()?;
1022    let flat_prefix = format!("{task_id}.");
1023    let selected = names
1024        .into_iter()
1025        .filter(|name| {
1026            name == OsStr::new(task_id)
1027                || name
1028                    .to_str()
1029                    .is_some_and(|name| name.starts_with(&flat_prefix))
1030        })
1031        .collect::<Vec<_>>();
1032    quarantine_names(storage_dir, session_dir, &session, selected, reason)
1033}
1034
1035pub fn quarantine_invalid_entry(
1036    storage_dir: &Path,
1037    session_dir: &Path,
1038    entry: &OsStr,
1039) -> io::Result<()> {
1040    let session = PinnedDir::open(session_dir)?;
1041    quarantine_names(
1042        storage_dir,
1043        session_dir,
1044        &session,
1045        vec![entry.to_os_string()],
1046        "invalid",
1047    )
1048}
1049
1050fn quarantine_names(
1051    storage_dir: &Path,
1052    session_dir: &Path,
1053    session: &PinnedDir,
1054    names: Vec<OsString>,
1055    reason: &str,
1056) -> io::Result<()> {
1057    if names.is_empty() {
1058        return Ok(());
1059    }
1060    let session_hash = session_dir.file_name().ok_or_else(|| {
1061        io::Error::new(io::ErrorKind::InvalidInput, "session dir has no identity")
1062    })?;
1063    let quarantine_path = storage_dir.join("bash-tasks-quarantine").join(session_hash);
1064    fs::create_dir_all(&quarantine_path)?;
1065    let quarantine = PinnedDir::open(&quarantine_path)?;
1066    for name in names {
1067        let mut random = [0_u8; 8];
1068        getrandom::fill(&mut random).map_err(io::Error::other)?;
1069        let target = OsString::from(format!(
1070            "{}.{}-{}",
1071            name.to_string_lossy(),
1072            reason,
1073            hex_lower(&random)
1074        ));
1075        session.rename_to(&name, &quarantine, &target)?;
1076    }
1077    Ok(())
1078}
1079
1080fn hex_lower(bytes: &[u8]) -> String {
1081    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
1082}
1083
1084/// How many times an open of a task's metadata re-tries a concurrent
1085/// replacement before giving up. An attempt only loses if another rename lands
1086/// in the microseconds between this open and its link-count check, so a handful
1087/// of attempts outlasts even a writer republishing metadata in a loop.
1088const METADATA_OPEN_ATTEMPTS: u32 = 8;
1089
1090/// Open a task's metadata file by name, tolerating a concurrent atomic replace.
1091///
1092/// Metadata is republished by writing a temporary file and renaming it over the
1093/// old name (see `randomized_atomic_replace`), so a reader that opened the
1094/// previous file a moment earlier finds it at zero links and is told the
1095/// artifact was concurrently replaced (see the link-count semantics on
1096/// `validate_regular_handle`). The name already points at the replacement by
1097/// then, so re-opening it succeeds; the race is a single rename, not a
1098/// sustained condition.
1099///
1100/// This matters because callers use the layout resolver to decide whether a
1101/// task on disk is intact. Reporting a routine metadata write as a resolution
1102/// failure makes a healthy task look like a damaged layout, and callers that
1103/// quarantine damaged layouts — most destructively the persisted-task GC —
1104/// would then rename a live task's whole bundle away just because its metadata
1105/// was being written at that moment.
1106fn open_metadata_through_replacement(dir: &PinnedDir, name: &OsStr) -> io::Result<File> {
1107    let mut attempts = 0;
1108    loop {
1109        attempts += 1;
1110        match dir.open_file(name, false) {
1111            Err(error)
1112                if attempts < METADATA_OPEN_ATTEMPTS
1113                    && error.kind() == io::ErrorKind::Interrupted
1114                    && error.to_string().contains(ARTIFACT_CONCURRENTLY_REPLACED) => {}
1115            other => return other,
1116        }
1117    }
1118}
1119
1120pub fn read_task(path: &Path) -> io::Result<PersistedTask> {
1121    let mut file = open_validated_path(path, false)?;
1122    read_task_file(&mut file)
1123}
1124
1125pub fn read_task_at(task: &ResolvedTask) -> io::Result<PersistedTask> {
1126    let name = match task.paths.layout {
1127        TaskLayout::Directory => OsString::from(METADATA_FILE),
1128        TaskLayout::Flat => OsString::from(format!("{}.json", task.paths.task_id)),
1129    };
1130    let mut file = open_metadata_through_replacement(&task.dirs.control, &name)?;
1131    let metadata = read_task_file(&mut file)?;
1132    if metadata.task_id != task.paths.task_id {
1133        return Err(io::Error::new(
1134            io::ErrorKind::InvalidData,
1135            "background task metadata identity does not match its layout name",
1136        ));
1137    }
1138    Ok(metadata)
1139}
1140
1141fn read_task_file(file: &mut File) -> io::Result<PersistedTask> {
1142    file.seek(SeekFrom::Start(0))?;
1143    let mut content = String::new();
1144    file.read_to_string(&mut content)?;
1145    let task: PersistedTask = serde_json::from_str(&content).map_err(io::Error::other)?;
1146    if !matches!(task.schema_version, 2 | 3 | 4 | 5 | SCHEMA_VERSION) {
1147        return Err(io::Error::new(
1148            io::ErrorKind::InvalidData,
1149            format!(
1150                "unsupported background task schema_version {} (expected 2, 3, 4, 5, or {SCHEMA_VERSION})",
1151                task.schema_version
1152            ),
1153        ));
1154    }
1155    validate_task_id(&task.task_id)?;
1156    Ok(task)
1157}
1158
1159pub fn write_task(path: &Path, task: &PersistedTask) -> io::Result<()> {
1160    validate_task_id(&task.task_id)?;
1161    if let Some(parent) = path.parent() {
1162        fs::create_dir_all(parent)?;
1163    }
1164    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1165    let dir = PinnedDir::open(parent)?;
1166    let name = path
1167        .file_name()
1168        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "metadata path has no name"))?;
1169    write_task_in_dir(&dir, name, task)
1170}
1171
1172pub fn write_task_at(task: &ResolvedTask, metadata: &PersistedTask) -> io::Result<()> {
1173    if metadata.task_id != task.paths.task_id {
1174        return Err(io::Error::new(
1175            io::ErrorKind::InvalidInput,
1176            "refusing to write metadata under a different task identity",
1177        ));
1178    }
1179    let name = match task.paths.layout {
1180        TaskLayout::Directory => OsString::from(METADATA_FILE),
1181        TaskLayout::Flat => OsString::from(format!("{}.json", task.paths.task_id)),
1182    };
1183    write_task_in_dir(&task.dirs.control, &name, metadata)
1184}
1185
1186fn write_task_in_dir(dir: &PinnedDir, name: &OsStr, task: &PersistedTask) -> io::Result<()> {
1187    let mut upgraded = task.clone();
1188    upgraded.schema_version = SCHEMA_VERSION;
1189    let content = serde_json::to_vec_pretty(&upgraded).map_err(io::Error::other)?;
1190    randomized_atomic_replace(dir, name, &content)
1191}
1192
1193pub fn update_task_at<F>(task: &ResolvedTask, update: F) -> io::Result<PersistedTask>
1194where
1195    F: FnOnce(&mut PersistedTask),
1196{
1197    let mut metadata = read_task_at(task)?;
1198    let original_terminal = metadata.is_terminal();
1199    let original = metadata.clone();
1200    update(&mut metadata);
1201    metadata.schema_version = SCHEMA_VERSION;
1202    if original_terminal {
1203        let completion_delivered = metadata.completion_delivered;
1204        metadata = original;
1205        metadata.completion_delivered = completion_delivered;
1206        metadata.schema_version = SCHEMA_VERSION;
1207    }
1208    write_task_at(task, &metadata)?;
1209    Ok(metadata)
1210}
1211
1212pub fn delete_task_bundle(paths: &TaskPaths) -> io::Result<()> {
1213    validate_task_id(&paths.task_id)?;
1214    let resolved = resolve_task_layout(&paths.session_dir, &paths.task_id)?;
1215    if resolved.paths.layout != paths.layout {
1216        return Err(io::Error::new(
1217            io::ErrorKind::InvalidData,
1218            "background task layout changed before deletion",
1219        ));
1220    }
1221    delete_resolved_task(&resolved)
1222}
1223
1224pub fn delete_resolved_task(task: &ResolvedTask) -> io::Result<()> {
1225    validate_task_id(&task.paths.task_id)?;
1226    match task.paths.layout {
1227        TaskLayout::Flat => {
1228            for path in task_bundle_files(&task.paths) {
1229                let Some(name) = path.file_name() else {
1230                    continue;
1231                };
1232                match task.dirs.session.remove_file(name) {
1233                    Ok(()) => {}
1234                    Err(error) if error.kind() == io::ErrorKind::NotFound => {}
1235                    Err(error) => return Err(error),
1236                }
1237            }
1238            Ok(())
1239        }
1240        TaskLayout::Directory => remove_directory_task(task),
1241    }
1242}
1243
1244fn remove_directory_task(task: &ResolvedTask) -> io::Result<()> {
1245    let current = task
1246        .dirs
1247        .session
1248        .open_dir_at(OsStr::new(&task.paths.task_id))?;
1249    if !current.same_identity(&task.dirs.task)? {
1250        return Err(io::Error::new(
1251            io::ErrorKind::PermissionDenied,
1252            "task directory identity changed before deletion",
1253        ));
1254    }
1255    #[cfg(unix)]
1256    let tombstone = rename_task_to_tombstone(task)?;
1257    for name in task.dirs.control.list_names()? {
1258        task.dirs.control.remove_file(&name)?;
1259    }
1260    remove_tree_contents(&task.dirs.io)?;
1261    #[cfg(unix)]
1262    {
1263        remove_dir_entry(&task.dirs.task, IO_DIR)?;
1264        remove_dir_entry(&task.dirs.task, CONTROL_DIR)?;
1265        let name = os_cstring(&tombstone)?;
1266        let result = unsafe {
1267            libc::unlinkat(
1268                task.dirs.session.file.as_raw_fd(),
1269                name.as_ptr(),
1270                libc::AT_REMOVEDIR,
1271            )
1272        };
1273        if result != 0 {
1274            return Err(io::Error::last_os_error());
1275        }
1276        Ok(())
1277    }
1278    #[cfg(windows)]
1279    {
1280        task.dirs.io.ensure_current_identity()?;
1281        task.dirs.control.ensure_current_identity()?;
1282        task.dirs.session.ensure_current_identity()?;
1283        fs::remove_dir(task.dirs.io.path())?;
1284        fs::remove_dir(task.dirs.control.path())?;
1285        fs::remove_dir(&task.paths.dir)?;
1286        task.dirs.session.ensure_current_identity()
1287    }
1288}
1289
1290fn remove_tree_contents(dir: &PinnedDir) -> io::Result<()> {
1291    for name in dir.list_names()? {
1292        match dir.open_dir_at(&name) {
1293            Ok(child) => {
1294                remove_tree_contents(&child)?;
1295                #[cfg(unix)]
1296                {
1297                    let name = os_cstring(&name)?;
1298                    let result = unsafe {
1299                        libc::unlinkat(dir.file.as_raw_fd(), name.as_ptr(), libc::AT_REMOVEDIR)
1300                    };
1301                    if result != 0 {
1302                        return Err(io::Error::last_os_error());
1303                    }
1304                }
1305                #[cfg(windows)]
1306                fs::remove_dir(child.path())?;
1307            }
1308            Err(error) if error.kind() == io::ErrorKind::NotADirectory => {
1309                dir.remove_file(&name)?;
1310            }
1311            Err(error) => return Err(error),
1312        }
1313    }
1314    Ok(())
1315}
1316
1317#[cfg(unix)]
1318fn rename_task_to_tombstone(task: &ResolvedTask) -> io::Result<OsString> {
1319    for _ in 0..32 {
1320        let tombstone = random_temp_name()?;
1321        match task.dirs.session.open_dir_at(&tombstone) {
1322            Ok(_) => continue,
1323            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
1324            Err(error) => return Err(error),
1325        }
1326        task.dirs
1327            .session
1328            .rename(OsStr::new(&task.paths.task_id), &tombstone)?;
1329        let moved = task.dirs.session.open_dir_at(&tombstone)?;
1330        if !moved.same_identity(&task.dirs.task)? {
1331            return Err(io::Error::new(
1332                io::ErrorKind::PermissionDenied,
1333                "task directory identity changed during deletion",
1334            ));
1335        }
1336        return Ok(tombstone);
1337    }
1338    Err(io::Error::new(
1339        io::ErrorKind::AlreadyExists,
1340        "failed to allocate randomized task deletion name",
1341    ))
1342}
1343
1344#[cfg(unix)]
1345fn remove_dir_entry(task: &PinnedDir, child: &str) -> io::Result<()> {
1346    let child = os_cstring(OsStr::new(child))?;
1347    let result =
1348        unsafe { libc::unlinkat(task.file.as_raw_fd(), child.as_ptr(), libc::AT_REMOVEDIR) };
1349    if result != 0 {
1350        return Err(io::Error::last_os_error());
1351    }
1352    Ok(())
1353}
1354
1355pub fn task_bundle_files(paths: &TaskPaths) -> Vec<PathBuf> {
1356    if paths.layout == TaskLayout::Directory {
1357        return vec![paths.dir.clone()];
1358    }
1359    vec![
1360        paths.json.clone(),
1361        paths.stdout.clone(),
1362        paths.stderr.clone(),
1363        paths.exit.clone(),
1364        paths.pipeline_status.clone(),
1365        paths.pty.clone(),
1366        paths.sandbox_unavailable.clone(),
1367        paths.command.clone(),
1368        paths.wrapper.clone(),
1369        paths.environment.clone(),
1370        paths.manifest.clone(),
1371        paths.sandbox_profile.clone(),
1372        paths.dir.join(format!("{}.ps1", paths.task_id)),
1373        paths.dir.join(format!("{}.bat", paths.task_id)),
1374    ]
1375}
1376
1377pub fn write_kill_marker_if_absent(paths: &TaskPaths) -> io::Result<()> {
1378    // A concurrent replace (child exit write racing this kill marker) shows up
1379    // as a zero-link validated open; the replacement file is the child's real
1380    // exit marker, so re-opening resolves the race in either direction. Bounded
1381    // retries: the race is a single rename, not a sustained condition.
1382    let mut attempts = 0;
1383    loop {
1384        attempts += 1;
1385        let result = match open_task_artifact(paths, TaskArtifact::Exit) {
1386            Ok(file) if file.len()? > 0 => Ok(()),
1387            Ok(mut file) => file.replace_contents(b"killed"),
1388            Err(error) if error.kind() == io::ErrorKind::NotFound => {
1389                let resolved = resolve_task_layout(&paths.session_dir, &paths.task_id)?;
1390                randomized_atomic_replace(
1391                    &resolved.dirs.io,
1392                    &resolved.paths.artifact_name(TaskArtifact::Exit),
1393                    b"killed",
1394                )
1395            }
1396            Err(error) => Err(error),
1397        };
1398        match result {
1399            Err(error)
1400                if attempts < 3
1401                    && error.kind() == io::ErrorKind::Interrupted
1402                    && error.to_string().contains(ARTIFACT_CONCURRENTLY_REPLACED) => {}
1403            other => return other,
1404        }
1405    }
1406}
1407
1408pub fn read_exit_marker(paths: &TaskPaths) -> io::Result<Option<ExitMarker>> {
1409    let mut file = match open_task_artifact(paths, TaskArtifact::Exit) {
1410        Ok(file) => file,
1411        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1412        Err(error) => return Err(error),
1413    };
1414    let mut content = String::new();
1415    file.read_to_string(&mut content)?;
1416    let content = content.trim();
1417    if content.is_empty() {
1418        return Ok(None);
1419    }
1420    if content == "killed" {
1421        return Ok(Some(ExitMarker::Killed));
1422    }
1423    Ok(content.parse::<i32>().ok().map(ExitMarker::Code))
1424}
1425
1426pub fn randomized_atomic_replace(dir: &PinnedDir, name: &OsStr, content: &[u8]) -> io::Result<()> {
1427    for _ in 0..32 {
1428        let temporary = random_temp_name()?;
1429        let mut file = match dir.open_new_file(&temporary) {
1430            Ok(file) => file,
1431            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
1432            Err(error) => return Err(error),
1433        };
1434        let result = (|| {
1435            file.write_all(content)?;
1436            file.sync_all()?;
1437            validate_regular_handle(&file)?;
1438            dir.rename(&temporary, name)
1439        })();
1440        if result.is_err() {
1441            let _ = dir.remove_file(&temporary);
1442        }
1443        return result;
1444    }
1445    Err(io::Error::new(
1446        io::ErrorKind::AlreadyExists,
1447        "failed to allocate a randomized atomic-write name",
1448    ))
1449}
1450
1451pub fn create_control_file(dirs: &TaskDirs, name: &str, content: &[u8]) -> io::Result<File> {
1452    let mut file = dirs.control.open_new_file(OsStr::new(name))?;
1453    file.write_all(content)?;
1454    file.sync_all()?;
1455    file.seek(SeekFrom::Start(0))?;
1456    validate_regular_handle(&file)?;
1457    Ok(file)
1458}
1459
1460pub fn open_control_file(task: &ResolvedTask, name: &str) -> io::Result<File> {
1461    if name.is_empty() || name.contains('/') || name.contains('\\') || name == "." || name == ".." {
1462        return Err(io::Error::new(
1463            io::ErrorKind::InvalidInput,
1464            "invalid control file name",
1465        ));
1466    }
1467    task.dirs.control.open_file(OsStr::new(name), false)
1468}
1469
1470#[derive(Debug)]
1471pub struct ValidatedArtifact {
1472    file: File,
1473}
1474
1475impl ValidatedArtifact {
1476    fn new(file: File) -> io::Result<Self> {
1477        validate_regular_handle(&file)?;
1478        Ok(Self { file })
1479    }
1480
1481    pub fn len(&self) -> io::Result<u64> {
1482        validate_regular_handle(&self.file)?;
1483        Ok(self.file.metadata()?.len())
1484    }
1485
1486    pub fn rewind(&mut self) -> io::Result<()> {
1487        self.file.seek(SeekFrom::Start(0)).map(|_| ())
1488    }
1489
1490    pub fn tail(&mut self, max_bytes: usize) -> io::Result<(Vec<u8>, bool)> {
1491        let len = self.len()?;
1492        let read_len = len.min(max_bytes as u64);
1493        self.file
1494            .seek(SeekFrom::Start(len.saturating_sub(read_len)))?;
1495        let mut bytes = Vec::with_capacity(read_len as usize);
1496        Read::by_ref(&mut self.file)
1497            .take(read_len)
1498            .read_to_end(&mut bytes)?;
1499        Ok((bytes, len > max_bytes as u64))
1500    }
1501
1502    pub fn read_range(&mut self, start: u64, len: u64) -> io::Result<Vec<u8>> {
1503        self.file.seek(SeekFrom::Start(start))?;
1504        let mut bytes = Vec::with_capacity(len.min(usize::MAX as u64) as usize);
1505        Read::by_ref(&mut self.file)
1506            .take(len)
1507            .read_to_end(&mut bytes)?;
1508        Ok(bytes)
1509    }
1510
1511    pub fn read_all(&mut self) -> io::Result<Vec<u8>> {
1512        self.rewind()?;
1513        let mut bytes = Vec::new();
1514        self.file.read_to_end(&mut bytes)?;
1515        Ok(bytes)
1516    }
1517
1518    pub fn replace_contents(&mut self, content: &[u8]) -> io::Result<()> {
1519        validate_regular_handle(&self.file)?;
1520        self.file.set_len(0)?;
1521        self.file.seek(SeekFrom::Start(0))?;
1522        self.file.write_all(content)?;
1523        self.file.sync_all()
1524    }
1525
1526    pub fn try_clone_file(&self) -> io::Result<File> {
1527        validate_regular_handle(&self.file)?;
1528        self.file.try_clone()
1529    }
1530}
1531
1532impl Read for ValidatedArtifact {
1533    fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
1534        self.file.read(buffer)
1535    }
1536}
1537
1538impl Seek for ValidatedArtifact {
1539    fn seek(&mut self, position: SeekFrom) -> io::Result<u64> {
1540        self.file.seek(position)
1541    }
1542}
1543
1544pub fn open_task_artifact(
1545    paths: &TaskPaths,
1546    artifact: TaskArtifact,
1547) -> io::Result<ValidatedArtifact> {
1548    validate_task_id(&paths.task_id)?;
1549    let resolved = resolve_task_layout(&paths.session_dir, &paths.task_id)?;
1550    if resolved.paths.layout != paths.layout {
1551        return Err(io::Error::new(
1552            io::ErrorKind::InvalidData,
1553            "background task layout identity changed",
1554        ));
1555    }
1556    let metadata = read_task_at(&resolved)?;
1557    if metadata.task_id != paths.task_id {
1558        return Err(io::Error::new(
1559            io::ErrorKind::InvalidData,
1560            "background task metadata identity mismatch",
1561        ));
1562    }
1563    let file = resolved
1564        .dirs
1565        .io
1566        .open_file(&resolved.paths.artifact_name(artifact), false)?;
1567    ValidatedArtifact::new(file)
1568}
1569
1570pub fn replace_artifact_with_tail(
1571    paths: &TaskPaths,
1572    artifact: TaskArtifact,
1573    retain_bytes: u64,
1574) -> io::Result<u64> {
1575    let mut source = open_task_artifact(paths, artifact)?;
1576    let len = source.len()?;
1577    if len <= retain_bytes {
1578        return Ok(0);
1579    }
1580    let mut tail = source.read_range(len.saturating_sub(retain_bytes), retain_bytes)?;
1581    align_tail_start(&mut tail);
1582    let resolved = resolve_task_layout(&paths.session_dir, &paths.task_id)?;
1583    randomized_atomic_replace(
1584        &resolved.dirs.io,
1585        &resolved.paths.artifact_name(artifact),
1586        &tail,
1587    )?;
1588    Ok(len.saturating_sub(tail.len() as u64))
1589}
1590
1591#[derive(Debug)]
1592pub struct TaskIoHandles {
1593    pub dirs: TaskDirs,
1594    stdout: Option<File>,
1595    stderr: Option<File>,
1596    exit: File,
1597    pty: Option<File>,
1598    pipeline_status: Option<File>,
1599    sandbox_unavailable: File,
1600}
1601
1602impl TaskIoHandles {
1603    pub fn create(
1604        task: &ResolvedTask,
1605        mode: BgMode,
1606        capture_pipeline_status: bool,
1607    ) -> io::Result<Self> {
1608        if task.paths.layout != TaskLayout::Directory {
1609            return Err(io::Error::new(
1610                io::ErrorKind::InvalidInput,
1611                "new task output handles require the directory layout",
1612            ));
1613        }
1614        let (stdout, stderr, pty) = match mode {
1615            BgMode::Pipes => (
1616                Some(
1617                    task.dirs
1618                        .io
1619                        .open_new_file(OsStr::new(TaskArtifact::Stdout.file_name()))?,
1620                ),
1621                Some(
1622                    task.dirs
1623                        .io
1624                        .open_new_file(OsStr::new(TaskArtifact::Stderr.file_name()))?,
1625                ),
1626                None,
1627            ),
1628            BgMode::Pty => (
1629                None,
1630                None,
1631                Some(
1632                    task.dirs
1633                        .io
1634                        .open_new_file(OsStr::new(TaskArtifact::Pty.file_name()))?,
1635                ),
1636            ),
1637        };
1638        Ok(Self {
1639            dirs: task.dirs.clone(),
1640            stdout,
1641            stderr,
1642            exit: task
1643                .dirs
1644                .io
1645                .open_new_file(OsStr::new(TaskArtifact::Exit.file_name()))?,
1646            pipeline_status: capture_pipeline_status
1647                .then(|| {
1648                    task.dirs
1649                        .io
1650                        .open_new_file(OsStr::new(TaskArtifact::PipelineStatus.file_name()))
1651                })
1652                .transpose()?,
1653            pty,
1654            sandbox_unavailable: task
1655                .dirs
1656                .io
1657                .open_new_file(OsStr::new(TaskArtifact::SandboxUnavailable.file_name()))?,
1658        })
1659    }
1660
1661    pub fn clone_file(&self, artifact: TaskArtifact) -> io::Result<File> {
1662        let file = match artifact {
1663            TaskArtifact::Stdout => self.stdout.as_ref(),
1664            TaskArtifact::Stderr => self.stderr.as_ref(),
1665            TaskArtifact::Exit => Some(&self.exit),
1666            TaskArtifact::PipelineStatus => self.pipeline_status.as_ref(),
1667            TaskArtifact::Pty => self.pty.as_ref(),
1668            TaskArtifact::SandboxUnavailable => Some(&self.sandbox_unavailable),
1669        }
1670        .ok_or_else(|| {
1671            io::Error::new(io::ErrorKind::NotFound, "task artifact is not pre-opened")
1672        })?;
1673        validate_regular_handle(file)?;
1674        file.try_clone()
1675    }
1676
1677    #[cfg(unix)]
1678    pub fn inheritable_file(&self, artifact: TaskArtifact) -> io::Result<File> {
1679        let file = self.clone_file(artifact)?;
1680        set_close_on_exec(file.as_raw_fd(), false)?;
1681        Ok(file)
1682    }
1683
1684    pub fn write(&mut self, artifact: TaskArtifact, content: &[u8]) -> io::Result<()> {
1685        let file = match artifact {
1686            TaskArtifact::Stdout => self.stdout.as_mut(),
1687            TaskArtifact::Stderr => self.stderr.as_mut(),
1688            TaskArtifact::Exit => Some(&mut self.exit),
1689            TaskArtifact::PipelineStatus => self.pipeline_status.as_mut(),
1690            TaskArtifact::Pty => self.pty.as_mut(),
1691            TaskArtifact::SandboxUnavailable => Some(&mut self.sandbox_unavailable),
1692        }
1693        .ok_or_else(|| {
1694            io::Error::new(io::ErrorKind::NotFound, "task artifact is not pre-opened")
1695        })?;
1696        validate_regular_handle(file)?;
1697        file.set_len(0)?;
1698        file.seek(SeekFrom::Start(0))?;
1699        file.write_all(content)?;
1700        file.sync_all()
1701    }
1702
1703    pub fn artifact_len(&self, artifact: TaskArtifact) -> io::Result<u64> {
1704        let file = match artifact {
1705            TaskArtifact::Stdout => self.stdout.as_ref(),
1706            TaskArtifact::Stderr => self.stderr.as_ref(),
1707            TaskArtifact::Exit => Some(&self.exit),
1708            TaskArtifact::PipelineStatus => self.pipeline_status.as_ref(),
1709            TaskArtifact::Pty => self.pty.as_ref(),
1710            TaskArtifact::SandboxUnavailable => Some(&self.sandbox_unavailable),
1711        }
1712        .ok_or_else(|| {
1713            io::Error::new(io::ErrorKind::NotFound, "task artifact is not pre-opened")
1714        })?;
1715        validate_regular_handle(file)?;
1716        Ok(file.metadata()?.len())
1717    }
1718}
1719
1720pub fn repin_task_io(paths: &TaskPaths) -> io::Result<TaskDirs> {
1721    let resolved = resolve_task_layout(&paths.session_dir, &paths.task_id)?;
1722    let metadata = read_task_at(&resolved)?;
1723    if metadata.task_id != paths.task_id {
1724        return Err(io::Error::new(
1725            io::ErrorKind::InvalidData,
1726            "background task metadata identity mismatch",
1727        ));
1728    }
1729    Ok(resolved.dirs)
1730}
1731
1732pub fn unix_millis() -> u64 {
1733    SystemTime::now()
1734        .duration_since(UNIX_EPOCH)
1735        .map(|duration| duration.as_millis() as u64)
1736        .unwrap_or(0)
1737}
1738
1739fn random_task_id() -> io::Result<String> {
1740    let mut bytes = [0_u8; 8];
1741    getrandom::fill(&mut bytes).map_err(io::Error::other)?;
1742    Ok(format!(
1743        "bash-{}",
1744        bytes
1745            .iter()
1746            .map(|byte| format!("{byte:02x}"))
1747            .collect::<String>()
1748    ))
1749}
1750
1751fn random_temp_name() -> io::Result<OsString> {
1752    let mut bytes = [0_u8; 16];
1753    getrandom::fill(&mut bytes).map_err(io::Error::other)?;
1754    Ok(OsString::from(format!(
1755        ".aft-tmp-{}",
1756        bytes
1757            .iter()
1758            .map(|byte| format!("{byte:02x}"))
1759            .collect::<String>()
1760    )))
1761}
1762
1763#[cfg(test)]
1764pub(crate) fn open_unregistered_artifact(path: &Path) -> io::Result<ValidatedArtifact> {
1765    ValidatedArtifact::new(open_validated_path(path, false)?)
1766}
1767
1768#[cfg(test)]
1769pub(crate) fn replace_unregistered_with_tail(path: &Path, retain_bytes: u64) -> io::Result<u64> {
1770    let mut source = open_unregistered_artifact(path)?;
1771    let len = source.len()?;
1772    if len <= retain_bytes {
1773        return Ok(0);
1774    }
1775    let mut tail = source.read_range(len.saturating_sub(retain_bytes), retain_bytes)?;
1776    align_tail_start(&mut tail);
1777    let parent = PinnedDir::open(path.parent().unwrap_or_else(|| Path::new(".")))?;
1778    let name = path
1779        .file_name()
1780        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?;
1781    randomized_atomic_replace(&parent, name, &tail)?;
1782    Ok(len.saturating_sub(tail.len() as u64))
1783}
1784
1785fn align_tail_start(bytes: &mut Vec<u8>) {
1786    let prefix = bytes
1787        .iter()
1788        .take_while(|byte| **byte & 0xc0 == 0x80)
1789        .count();
1790    if prefix > 0 {
1791        bytes.drain(..prefix);
1792    }
1793}
1794
1795#[cfg(unix)]
1796fn clear_nonblocking(file: &File) -> io::Result<()> {
1797    let flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFL) };
1798    if flags == -1 {
1799        return Err(io::Error::last_os_error());
1800    }
1801    if flags & libc::O_NONBLOCK != 0 {
1802        let result =
1803            unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETFL, flags & !libc::O_NONBLOCK) };
1804        if result == -1 {
1805            return Err(io::Error::last_os_error());
1806        }
1807    }
1808    Ok(())
1809}
1810
1811fn open_validated_path(path: &Path, write: bool) -> io::Result<File> {
1812    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1813    let name = path
1814        .file_name()
1815        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?;
1816    PinnedDir::open(parent)?.open_file(name, write)
1817}
1818
1819#[cfg(unix)]
1820fn openat_file(dirfd: RawFd, name: &OsStr, flags: i32, mode: libc::mode_t) -> io::Result<File> {
1821    let name = os_cstring(name)?;
1822    let fd = unsafe { libc::openat(dirfd, name.as_ptr(), flags, libc::c_uint::from(mode)) };
1823    if fd < 0 {
1824        return Err(io::Error::last_os_error());
1825    }
1826    Ok(unsafe { File::from_raw_fd(fd) })
1827}
1828
1829#[cfg(unix)]
1830fn os_cstring(value: &OsStr) -> io::Result<CString> {
1831    CString::new(value.as_bytes())
1832        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"))
1833}
1834
1835fn validate_directory_handle(file: &File) -> io::Result<()> {
1836    let metadata = file.metadata()?;
1837    if !metadata.is_dir() {
1838        return Err(io::Error::new(
1839            io::ErrorKind::InvalidData,
1840            "expected a non-reparse directory handle",
1841        ));
1842    }
1843    #[cfg(windows)]
1844    validate_windows_handle(file, true)?;
1845    Ok(())
1846}
1847
1848fn validate_regular_handle(file: &File) -> io::Result<()> {
1849    let metadata = file.metadata()?;
1850    if !metadata.is_file() {
1851        return Err(io::Error::new(
1852            io::ErrorKind::InvalidData,
1853            "task artifact is not a regular file",
1854        ));
1855    }
1856    // Link-count semantics: >1 means the artifact is aliased somewhere else on
1857    // disk (tamper suspicion, refuse); exactly 0 means the file was unlinked or
1858    // atomically replaced AFTER we opened it — on Windows the child's own
1859    // temp+rename exit write races a daemon kill-marker write and leaves the
1860    // superseded handle at zero links. That is a benign concurrent replace, not
1861    // an attack; report it distinctly so callers can re-open the replacement.
1862    #[cfg(unix)]
1863    match metadata.nlink() {
1864        1 => {}
1865        0 => {
1866            return Err(io::Error::new(
1867                io::ErrorKind::Interrupted,
1868                ARTIFACT_CONCURRENTLY_REPLACED,
1869            ));
1870        }
1871        _ => {
1872            return Err(io::Error::new(
1873                io::ErrorKind::InvalidData,
1874                "task artifact has multiple hard links",
1875            ));
1876        }
1877    }
1878    #[cfg(windows)]
1879    validate_windows_handle(file, false)?;
1880    Ok(())
1881}
1882
1883/// Marker message for a validated-open that lost a race against an atomic
1884/// replacement of the same artifact (see link-count semantics above).
1885pub(crate) const ARTIFACT_CONCURRENTLY_REPLACED: &str = "task artifact was concurrently replaced";
1886
1887#[cfg(unix)]
1888pub fn set_close_on_exec(fd: RawFd, enabled: bool) -> io::Result<()> {
1889    let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
1890    if flags < 0 {
1891        return Err(io::Error::last_os_error());
1892    }
1893    let flags = if enabled {
1894        flags | libc::FD_CLOEXEC
1895    } else {
1896        flags & !libc::FD_CLOEXEC
1897    };
1898    if unsafe { libc::fcntl(fd, libc::F_SETFD, flags) } < 0 {
1899        return Err(io::Error::last_os_error());
1900    }
1901    Ok(())
1902}
1903
1904#[cfg(windows)]
1905const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
1906#[cfg(windows)]
1907const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
1908#[cfg(windows)]
1909const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
1910#[cfg(windows)]
1911const FILE_TYPE_DISK: u32 = 0x0001;
1912#[cfg(windows)]
1913const HANDLE_FLAG_INHERIT: u32 = 0x0000_0001;
1914
1915#[cfg(windows)]
1916fn windows_file_information(file: &File) -> io::Result<ByHandleFileInformation> {
1917    let mut information = std::mem::MaybeUninit::<ByHandleFileInformation>::zeroed();
1918    if unsafe { GetFileInformationByHandle(file.as_raw_handle(), information.as_mut_ptr()) } == 0 {
1919        return Err(io::Error::last_os_error());
1920    }
1921    Ok(unsafe { information.assume_init() })
1922}
1923
1924#[cfg(windows)]
1925fn validate_windows_handle(file: &File, directory: bool) -> io::Result<()> {
1926    if file.metadata()?.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
1927        return Err(io::Error::new(
1928            io::ErrorKind::InvalidData,
1929            "task path is a reparse point",
1930        ));
1931    }
1932    let handle = file.as_raw_handle();
1933    let file_type = unsafe { GetFileType(handle) };
1934    if file_type != FILE_TYPE_DISK {
1935        return Err(io::Error::new(
1936            io::ErrorKind::InvalidData,
1937            "task artifact is not a regular disk file",
1938        ));
1939    }
1940    let information = windows_file_information(file)?;
1941    if !directory && information.number_of_links == 0 {
1942        // Zero links = this handle points at a file that was unlinked or
1943        // rename-replaced after open (e.g. the child's temp+rename exit write
1944        // racing a daemon kill-marker write). Benign; caller may re-open.
1945        return Err(io::Error::new(
1946            io::ErrorKind::Interrupted,
1947            ARTIFACT_CONCURRENTLY_REPLACED,
1948        ));
1949    }
1950    if !directory && information.number_of_links > 1 {
1951        return Err(io::Error::new(
1952            io::ErrorKind::InvalidData,
1953            "task artifact has multiple hard links",
1954        ));
1955    }
1956    if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0) } == 0 {
1957        return Err(io::Error::last_os_error());
1958    }
1959    let mut flags = 0_u32;
1960    if unsafe { GetHandleInformation(handle, &mut flags) } == 0 {
1961        return Err(io::Error::last_os_error());
1962    }
1963    if flags & HANDLE_FLAG_INHERIT != 0 {
1964        return Err(io::Error::new(
1965            io::ErrorKind::PermissionDenied,
1966            "validated task handles must not be inherited",
1967        ));
1968    }
1969    Ok(())
1970}
1971
1972#[cfg(windows)]
1973#[repr(C)]
1974struct ByHandleFileInformation {
1975    file_attributes: u32,
1976    creation_time: [u32; 2],
1977    last_access_time: [u32; 2],
1978    last_write_time: [u32; 2],
1979    volume_serial_number: u32,
1980    file_size_high: u32,
1981    file_size_low: u32,
1982    number_of_links: u32,
1983    file_index_high: u32,
1984    file_index_low: u32,
1985}
1986
1987#[cfg(windows)]
1988#[link(name = "kernel32")]
1989extern "system" {
1990    fn GetFileType(file: std::os::windows::io::RawHandle) -> u32;
1991    fn GetFileInformationByHandle(
1992        file: std::os::windows::io::RawHandle,
1993        information: *mut ByHandleFileInformation,
1994    ) -> i32;
1995    fn SetHandleInformation(object: std::os::windows::io::RawHandle, mask: u32, flags: u32) -> i32;
1996    fn GetHandleInformation(object: std::os::windows::io::RawHandle, flags: *mut u32) -> i32;
1997}
1998
1999#[cfg(test)]
2000mod tests {
2001    use super::*;
2002
2003    fn valid_id(suffix: u64) -> String {
2004        format!("bash-{suffix:016x}")
2005    }
2006
2007    #[test]
2008    fn task_id_validation_is_exact() {
2009        assert!(validate_task_id("bash-0123456789abcdef").is_ok());
2010        for invalid in [
2011            "bash-0123456789abcde",
2012            "bash-0123456789abcdef0",
2013            "bash-0123456789ABCDEf",
2014            "bash-0123456789abcdeg",
2015            "../bash-0123456789abcdef",
2016        ] {
2017            assert!(validate_task_id(invalid).is_err(), "accepted {invalid}");
2018        }
2019    }
2020
2021    #[test]
2022    fn new_layout_separates_control_and_io() {
2023        let storage = tempfile::tempdir().unwrap();
2024        let task = create_task_layout(storage.path(), "session", &valid_id(1)).unwrap();
2025        assert_eq!(
2026            task.paths.json.parent(),
2027            Some(task.paths.control_dir.as_path())
2028        );
2029        assert_eq!(
2030            task.paths.stdout.parent(),
2031            Some(task.paths.io_dir.as_path())
2032        );
2033        assert_ne!(task.paths.control_dir, task.paths.io_dir);
2034    }
2035
2036    #[cfg(unix)]
2037    #[test]
2038    fn task_layout_directories_are_private() {
2039        use std::os::unix::fs::PermissionsExt;
2040
2041        let storage = tempfile::tempdir().unwrap();
2042        let task = create_task_layout(storage.path(), "session", &valid_id(5)).unwrap();
2043        for path in [
2044            &task.paths.session_dir,
2045            &task.paths.dir,
2046            &task.paths.control_dir,
2047            &task.paths.io_dir,
2048        ] {
2049            let mode = fs::metadata(path).unwrap().permissions().mode() & 0o777;
2050            assert_eq!(mode, 0o700, "unexpected permissions for {}", path.display());
2051        }
2052        let bash_tasks = task.paths.session_dir.parent().unwrap();
2053        let mode = fs::metadata(bash_tasks).unwrap().permissions().mode() & 0o777;
2054        assert_eq!(
2055            mode,
2056            0o700,
2057            "unexpected permissions for {}",
2058            bash_tasks.display()
2059        );
2060    }
2061
2062    #[test]
2063    fn resolver_refuses_duplicate_layouts() {
2064        let storage = tempfile::tempdir().unwrap();
2065        let task = create_task_layout(storage.path(), "session", &valid_id(2)).unwrap();
2066        let flat = task
2067            .paths
2068            .session_dir
2069            .join(format!("{}.json", task.paths.task_id));
2070        fs::write(flat, b"{}").unwrap();
2071        let error = resolve_task_layout(&task.paths.session_dir, &task.paths.task_id).unwrap_err();
2072        assert_eq!(error.kind(), io::ErrorKind::InvalidData);
2073    }
2074
2075    #[test]
2076    fn resolver_rejects_metadata_identity_mismatch() {
2077        let storage = tempfile::tempdir().unwrap();
2078        let task = create_task_layout(storage.path(), "session", &valid_id(30)).unwrap();
2079        let metadata = PersistedTask::starting(
2080            valid_id(31),
2081            "session".into(),
2082            "true".into(),
2083            storage.path().into(),
2084            None,
2085            None,
2086            true,
2087            false,
2088        );
2089        fs::write(&task.paths.json, serde_json::to_vec(&metadata).unwrap()).unwrap();
2090        let error = resolve_task_layout(&task.paths.session_dir, &task.paths.task_id).unwrap_err();
2091        assert_eq!(error.kind(), io::ErrorKind::InvalidData);
2092    }
2093
2094    // A task directory swapped underneath the daemon must never let deletion
2095    // touch the impostor's control content. The two platforms enforce this the
2096    // same guarantee through different mechanisms, so each is asserted against
2097    // its real mechanism rather than a shared code path.
2098    //
2099    // Unix: POSIX permits renaming a directory while a fd is held open on it, so
2100    // the swap succeeds on disk and `remove_directory_task`'s `same_identity`
2101    // check is what refuses the deletion.
2102    #[cfg(unix)]
2103    #[test]
2104    fn deletion_refuses_replaced_task_directory_without_touching_victim() {
2105        let storage = tempfile::tempdir().unwrap();
2106        let first = create_task_layout(storage.path(), "session", &valid_id(40)).unwrap();
2107        let second = create_task_layout(storage.path(), "session", &valid_id(41)).unwrap();
2108        let victim = second.paths.control_dir.join("victim");
2109        fs::write(&victim, b"victim-bytes").unwrap();
2110        let moved_first = first.paths.session_dir.join("moved-first");
2111        fs::rename(&first.paths.dir, &moved_first).unwrap();
2112        fs::rename(&second.paths.dir, &first.paths.dir).unwrap();
2113
2114        assert!(delete_resolved_task(&first).is_err());
2115        assert_eq!(
2116            fs::read(first.paths.control_dir.join("victim")).unwrap(),
2117            b"victim-bytes"
2118        );
2119    }
2120
2121    // Windows: the daemon's retained `PinnedDir` handle on the task directory
2122    // makes the OS refuse to rename it (Access denied), so the swap cannot occur
2123    // at all while the daemon is live — the impostor's content is never reachable
2124    // for deletion. Assert that structural refusal directly.
2125    #[cfg(windows)]
2126    #[test]
2127    fn deletion_refuses_replaced_task_directory_without_touching_victim() {
2128        let storage = tempfile::tempdir().unwrap();
2129        let first = create_task_layout(storage.path(), "session", &valid_id(40)).unwrap();
2130        let second = create_task_layout(storage.path(), "session", &valid_id(41)).unwrap();
2131        let victim = second.paths.control_dir.join("victim");
2132        fs::write(&victim, b"victim-bytes").unwrap();
2133
2134        // The daemon still holds `first`'s pinned directory handles, so moving
2135        // its task directory out of the way is refused by the OS.
2136        let moved_first = first.paths.session_dir.join("moved-first");
2137        let refusal = fs::rename(&first.paths.dir, &moved_first)
2138            .expect_err("open pinned-dir handle must block the task-dir rename on Windows");
2139        assert_eq!(refusal.kind(), io::ErrorKind::PermissionDenied);
2140
2141        // The victim's control content is untouched because the swap never happened.
2142        assert_eq!(fs::read(&victim).unwrap(), b"victim-bytes");
2143    }
2144
2145    #[test]
2146    fn legacy_flat_layout_is_readable_and_deleted_as_a_bundle() {
2147        let storage = tempfile::tempdir().unwrap();
2148        let task_id = valid_id(32);
2149        let paths = task_paths(storage.path(), "session", &task_id).unwrap();
2150        fs::create_dir_all(&paths.session_dir).unwrap();
2151        let metadata = PersistedTask::starting(
2152            task_id.clone(),
2153            "session".into(),
2154            "true".into(),
2155            storage.path().into(),
2156            None,
2157            None,
2158            true,
2159            false,
2160        );
2161        write_task(&paths.json, &metadata).unwrap();
2162        fs::write(&paths.stdout, b"legacy").unwrap();
2163        fs::write(&paths.stderr, b"").unwrap();
2164        assert_eq!(
2165            resolve_task_layout(&paths.session_dir, &task_id)
2166                .unwrap()
2167                .paths
2168                .layout,
2169            TaskLayout::Flat
2170        );
2171        assert_eq!(
2172            open_task_artifact(&paths, TaskArtifact::Stdout)
2173                .unwrap()
2174                .read_all()
2175                .unwrap(),
2176            b"legacy"
2177        );
2178        delete_task_bundle(&paths).unwrap();
2179        assert!(!paths.json.exists());
2180        assert!(!paths.stdout.exists());
2181    }
2182
2183    #[cfg(unix)]
2184    #[test]
2185    fn live_output_creation_and_later_writes_refuse_link_attacks() {
2186        use std::os::unix::fs::symlink;
2187
2188        let storage = tempfile::tempdir().unwrap();
2189        let task = create_task_layout(storage.path(), "session", &valid_id(33)).unwrap();
2190        let metadata = PersistedTask::starting(
2191            task.paths.task_id.clone(),
2192            "session".into(),
2193            "true".into(),
2194            storage.path().into(),
2195            None,
2196            None,
2197            true,
2198            false,
2199        );
2200        write_task_at(&task, &metadata).unwrap();
2201        let victim = storage.path().join("victim");
2202        fs::write(&victim, b"victim-bytes").unwrap();
2203
2204        symlink(&victim, &task.paths.stdout).unwrap();
2205        assert!(TaskIoHandles::create(&task, BgMode::Pipes, false).is_err());
2206        assert_eq!(fs::read(&victim).unwrap(), b"victim-bytes");
2207        fs::remove_file(&task.paths.stdout).unwrap();
2208
2209        let mut handles = TaskIoHandles::create(&task, BgMode::Pipes, false).unwrap();
2210        fs::hard_link(&task.paths.stdout, task.paths.io_dir.join("linked-stdout")).unwrap();
2211        assert!(handles
2212            .write(TaskArtifact::Stdout, b"daemon-write")
2213            .is_err());
2214        assert_eq!(fs::read(&victim).unwrap(), b"victim-bytes");
2215
2216        fs::remove_file(&task.paths.stdout).unwrap();
2217        symlink(&victim, &task.paths.stdout).unwrap();
2218        assert!(replace_artifact_with_tail(&task.paths, TaskArtifact::Stdout, 1).is_err());
2219        assert_eq!(fs::read(&victim).unwrap(), b"victim-bytes");
2220    }
2221
2222    #[test]
2223    fn registered_artifact_consumers_do_not_reopen_paths_directly() {
2224        let rust_sources = [
2225            include_str!("buffer.rs"),
2226            include_str!("registry.rs"),
2227            include_str!("process.rs"),
2228            include_str!("pty_process.rs"),
2229            include_str!("watches.rs"),
2230            include_str!("watchdog.rs"),
2231            include_str!("../commands/bash_status.rs"),
2232        ];
2233        for source in rust_sources {
2234            let production = source
2235                .split("#[cfg(test)]\nmod tests")
2236                .next()
2237                .unwrap_or(source);
2238            for forbidden in [
2239                "File::open(&task.paths",
2240                "fs::read(&task.paths",
2241                "fs::read_to_string(&task.paths",
2242                "File::open(path)?",
2243            ] {
2244                assert!(
2245                    !production.contains(forbidden),
2246                    "registered artifact consumer contains raw path read: {forbidden}"
2247                );
2248            }
2249        }
2250        for source in [
2251            include_str!("../../../../packages/opencode-plugin/src/tools/bash.ts"),
2252            include_str!("../../../../packages/opencode-plugin/src/tools/bash_watch.ts"),
2253            include_str!("../../../../packages/pi-plugin/src/tools/bash.ts"),
2254        ] {
2255            for forbidden in [
2256                "fs.readFile(outputPath)",
2257                "fs.readFile(details.output_path)",
2258                "fs.open(outputPath",
2259            ] {
2260                assert!(
2261                    !source.contains(forbidden),
2262                    "plugin artifact consumer contains raw path read: {forbidden}"
2263                );
2264            }
2265        }
2266    }
2267
2268    #[cfg(unix)]
2269    #[test]
2270    fn validated_artifact_refuses_symlink_hardlink_and_fifo() {
2271        use std::os::unix::fs::symlink;
2272
2273        let storage = tempfile::tempdir().unwrap();
2274        let task = create_task_layout(storage.path(), "session", &valid_id(3)).unwrap();
2275        let metadata = PersistedTask::starting(
2276            task.paths.task_id.clone(),
2277            "session".into(),
2278            "true".into(),
2279            storage.path().into(),
2280            None,
2281            None,
2282            true,
2283            false,
2284        );
2285        write_task_at(&task, &metadata).unwrap();
2286        let canary = storage.path().join("canary");
2287        fs::write(&canary, b"secret").unwrap();
2288
2289        symlink(&canary, &task.paths.stdout).unwrap();
2290        assert!(open_task_artifact(&task.paths, TaskArtifact::Stdout).is_err());
2291        fs::remove_file(&task.paths.stdout).unwrap();
2292
2293        fs::hard_link(&canary, &task.paths.stdout).unwrap();
2294        assert!(open_task_artifact(&task.paths, TaskArtifact::Stdout).is_err());
2295        fs::remove_file(&task.paths.stdout).unwrap();
2296
2297        let path = CString::new(task.paths.stdout.as_os_str().as_bytes()).unwrap();
2298        assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0);
2299        assert!(open_task_artifact(&task.paths, TaskArtifact::Stdout).is_err());
2300    }
2301}