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 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(¤t)?;
418 let held = windows_file_information(&self.file)?;
419 let observed = windows_file_information(¤t)?;
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 #[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 #[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 let task = match resolve_uninitialized_task_layout(session_dir, task_id) {
980 Ok(task) => task,
981 Err(_) => {
982 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
1084const METADATA_OPEN_ATTEMPTS: u32 = 8;
1089
1090fn 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 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 #[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
1883pub(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 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 #[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 #[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 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 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}