use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
const SPEC_SUBDIR: &str = "launch-specs";
#[cfg(unix)]
const DIR_MODE: u32 = 0o700;
#[cfg(unix)]
const FILE_MODE: u32 = 0o600;
const STARTED_SUFFIX: &str = ".started";
const LAUNCH_POINTER_SUFFIX: &str = ".launch";
const ORPHAN_TTL: std::time::Duration = std::time::Duration::from_secs(600);
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum LaunchSpecError {
#[error("launch-spec directory unavailable: home directory could not be resolved")]
NoRoot,
#[error("launch-spec directory {path} could not be prepared: {source}")]
Dir {
path: PathBuf,
source: std::io::Error,
},
#[error("launch-spec {path} could not be written: {source}")]
Write {
path: PathBuf,
source: std::io::Error,
},
#[error("launch-spec {path} could not be read: {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error("launch-spec {path} is corrupt: {source}")]
Parse {
path: PathBuf,
source: serde_json::Error,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchSpec {
pub session_id: String,
#[serde(default)]
pub launch_id: String,
pub cwd: PathBuf,
pub program: String,
pub args: Vec<String>,
pub env_unset: Vec<String>,
pub env_set: Vec<(String, String)>,
}
impl LaunchSpec {
pub fn root() -> Option<PathBuf> {
trusty_common::crate_config::crate_config_dir(crate::core::trusty_tools_config::CRATE_NAME)
.map(|dir| dir.join(SPEC_SUBDIR))
}
pub fn root_at(crate_config_root: &Path) -> PathBuf {
crate_config_root.join(SPEC_SUBDIR)
}
pub fn write(&self) -> Result<PathBuf, LaunchSpecError> {
let dir = Self::root().ok_or(LaunchSpecError::NoRoot)?;
self.write_in(&dir)
}
pub fn write_in(&self, dir: &Path) -> Result<PathBuf, LaunchSpecError> {
ensure_owner_only_dir(dir).map_err(|source| LaunchSpecError::Dir {
path: dir.to_path_buf(),
source,
})?;
reap_orphans_in(dir, ORPHAN_TTL);
let path = dir.join(format!("{}.json", uuid::Uuid::new_v4()));
let json = serde_json::to_vec(self).map_err(|e| LaunchSpecError::Write {
path: path.clone(),
source: std::io::Error::other(e),
})?;
create_owner_only(&path, &json).map_err(|source| LaunchSpecError::Write {
path: path.clone(),
source,
})?;
Ok(path)
}
pub fn consume(path: &Path) -> Result<Self, LaunchSpecError> {
let bytes = std::fs::read(path).map_err(|source| LaunchSpecError::Read {
path: path.to_path_buf(),
source,
})?;
if let Err(err) = std::fs::remove_file(path) {
tracing::warn!(
spec = %path.display(),
"launch spec could not be deleted after reading it — it still holds this \
session's credentials and will only go away when a later launch reaps it \
(#8233): {err}"
);
}
serde_json::from_slice(&bytes).map_err(|source| LaunchSpecError::Parse {
path: path.to_path_buf(),
source,
})
}
pub fn started_marker_in(dir: &Path, launch_id: &str) -> PathBuf {
dir.join(format!("{launch_id}{STARTED_SUFFIX}"))
}
pub fn launch_pointer_in(dir: &Path, session_id: &str) -> PathBuf {
dir.join(format!("{session_id}{LAUNCH_POINTER_SUFFIX}"))
}
pub fn write_launch_pointer_in(&self, dir: &Path) -> Result<PathBuf, LaunchSpecError> {
let path = Self::launch_pointer_in(dir, &self.session_id);
std::fs::write(&path, self.launch_id.as_bytes()).map_err(|source| {
LaunchSpecError::Write {
path: path.clone(),
source,
}
})?;
Ok(path)
}
pub fn read_launch_pointer_in(dir: &Path, session_id: &str) -> Option<String> {
let raw = std::fs::read_to_string(Self::launch_pointer_in(dir, session_id)).ok()?;
let id = raw.trim().to_owned();
(!id.is_empty()).then_some(id)
}
pub fn mark_started(spec_path: &Path, launch_id: &str) {
if launch_id.is_empty() {
return;
}
let dir = spec_path.parent().unwrap_or(Path::new("."));
let marker = Self::started_marker_in(dir, launch_id);
if let Err(err) = std::fs::write(&marker, b"") {
tracing::warn!(
marker = %marker.display(),
"could not write the launch-started sentinel; the daemon will read this \
launch as one the pane never ran (#8233): {err}"
);
}
}
pub fn verify(path: &Path) -> Result<Self, LaunchSpecError> {
let bytes = std::fs::read(path).map_err(|source| LaunchSpecError::Read {
path: path.to_path_buf(),
source,
})?;
serde_json::from_slice(&bytes).map_err(|source| LaunchSpecError::Parse {
path: path.to_path_buf(),
source,
})
}
pub fn to_command(&self) -> std::process::Command {
let mut cmd = std::process::Command::new(&self.program);
cmd.args(&self.args).current_dir(&self.cwd);
for name in &self.env_unset {
cmd.env_remove(name);
}
for (name, value) in &self.env_set {
cmd.env(name, value);
}
if !self.session_id.is_empty() {
cmd.env(
crate::core::harness_root::MANAGED_SESSION_ID_ENV,
&self.session_id,
);
}
crate::core::alt_screen::apply_default_to_command(&mut cmd);
cmd
}
}
pub fn reap_orphans() {
if let Some(dir) = LaunchSpec::root() {
reap_orphans_in(&dir, ORPHAN_TTL);
}
}
fn reap_orphans_in(dir: &Path, ttl: std::time::Duration) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let stale = entry
.metadata()
.and_then(|m| m.modified())
.map(|t| t.elapsed().map(|age| age > ttl).unwrap_or(false))
.unwrap_or(false);
if !stale {
continue;
}
match std::fs::remove_file(&path) {
Ok(()) => tracing::warn!(
orphan = %path.display(),
"reaped an abandoned launch spec — its pane never consumed it, so a \
launch was lost and its credentials sat on disk until now (#8233)"
),
Err(err) => tracing::warn!(
orphan = %path.display(),
"abandoned launch spec could not be reaped (#8233): {err}"
),
}
}
}
fn ensure_owner_only_dir(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(DIR_MODE))?;
}
Ok(())
}
fn create_owner_only(path: &Path, data: &[u8]) -> std::io::Result<()> {
use std::io::Write as _;
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(FILE_MODE);
}
let mut file = options.open(path)?;
file.write_all(data)
}
#[cfg(test)]
#[path = "launch_spec_tests.rs"]
mod tests;