use std::time::{Duration, Instant};
use crate::audit::AuditLog;
use crate::budget::{self, Budget};
use crate::checkpoint::{self, Checkpoint, Checkpointer, RestoreReport};
use crate::error::{Error, Result};
use crate::exec::ExecResult;
use crate::framing::{self, Markers};
use crate::policy::Policy;
use crate::redact::Redactor;
use crate::transport::{self, local::LocalPty, Transport};
pub struct Session {
io: Box<dyn Transport>,
policy: Option<Policy>,
audit: Option<AuditLog>,
timeout: Duration,
max_output: usize,
output_budget: Option<Budget>,
poisoned: bool,
checkpointer: Option<Checkpointer>,
redactor: Redactor,
}
impl Session {
pub fn local() -> Result<Self> {
Self::local_shell("bash", &["--norc", "--noprofile"])
}
#[doc(hidden)]
pub fn local_shell(shell: &str, args: &[&str]) -> Result<Self> {
let pty = LocalPty::spawn_with_env(shell, args, &[("HISTFILE", None)])?;
Self::from_transport(Box::new(pty), false)
}
#[cfg(feature = "ssh")]
pub fn ssh(config: crate::transport::ssh::SshConfig) -> Result<Self> {
let t = crate::transport::ssh::SshTransport::connect(config)?;
Self::from_transport(Box::new(t), true)
}
pub fn docker(container: &str) -> Result<Self> {
if !is_valid_container_ref(container) {
return Err(Error::Transport("invalid docker container name/id".into()));
}
let t = crate::transport::docker::DockerExec::spawn(container, &unique_token())?;
Self::from_transport(Box::new(t), true)
}
fn from_transport(mut io: Box<dyn Transport>, remote: bool) -> Result<Self> {
transport::shell_init(io.as_mut())?;
let token = unique_token();
let checkpointer = remote.then(|| Checkpointer::new(&token, true, None, vec![".".into()]));
Ok(Self {
io,
policy: None,
audit: None,
timeout: Duration::from_secs(30),
max_output: 100_000,
output_budget: None,
poisoned: false,
checkpointer,
redactor: Redactor::default(),
})
}
pub fn with_policy(mut self, policy: Policy) -> Self {
self.policy = Some(policy);
self
}
pub fn with_audit(mut self, audit: AuditLog) -> Self {
self.audit = Some(audit);
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn set_timeout(&mut self, timeout: Duration) {
self.timeout = timeout;
}
pub fn with_max_output(mut self, max: usize) -> Self {
self.max_output = max;
self
}
pub fn with_output_budget(mut self, budget: Budget) -> Self {
self.output_budget = Some(budget);
self
}
pub fn is_poisoned(&self) -> bool {
self.poisoned
}
#[doc(hidden)]
pub fn checkpoint_shadow_repo_name(&self) -> Option<String> {
self.checkpointer
.as_ref()
.map(Checkpointer::shadow_repo_name)
}
pub fn exec(&mut self, command: &str) -> Result<ExecResult> {
self.exec_with_timeout(command, None, self.timeout)
}
pub fn exec_budgeted(&mut self, command: &str, budget: &Budget) -> Result<ExecResult> {
self.exec_with_timeout(command, Some(budget), self.timeout)
}
pub fn exec_with_timeout(
&mut self,
command: &str,
budget: Option<&Budget>,
timeout: Duration,
) -> Result<ExecResult> {
let budget = match budget {
Some(b) => b.clone(),
None => self.output_budget.clone().unwrap_or_default(),
};
self.exec_inner(command, &budget, timeout)
}
fn exec_inner(
&mut self,
command: &str,
budget: &Budget,
timeout: Duration,
) -> Result<ExecResult> {
if self.poisoned {
return Err(Error::SessionPoisoned);
}
self.redactor.learn_from_command(command);
if let Some(g) = &budget.grep {
budget::compile_grep(&g.pattern)?;
}
if let Some(p) = &self.policy {
if let Err(reason) = p.check(command) {
return Err(Error::PolicyDenied(reason));
}
}
self.maybe_auto_snapshot(command);
if self.poisoned {
return Err(Error::SessionPoisoned);
}
let started = Instant::now();
let acc_cap = if *budget != Budget::default() {
BUDGET_ACC_CAP
} else {
self.default_acc_cap()
};
let f = self.run_framed_for(command, timeout, acc_cap)?;
let (stdout, rep_out, cap_out) =
budget::apply(&self.redactor.redact(&f.stdout), budget, self.max_output)?;
let (stderr, rep_err, cap_err) =
budget::apply(&self.redactor.redact(&f.stderr), budget, self.max_output)?;
let report = if *budget != Budget::default() {
Some(crate::budget::BudgetReport {
stdout: rep_out.clone(),
stderr: rep_err.clone(),
})
} else {
None
};
let result = ExecResult {
command: self.redactor.redact(command),
stdout,
stderr,
exit_code: f.exit_code,
duration_ms: started.elapsed().as_millis() as u64,
cwd: f.cwd,
truncated: cap_out
|| cap_err
|| rep_out.lines_kept < rep_out.lines_total
|| rep_err.lines_kept < rep_err.lines_total
|| f.overflowed,
budget: report,
timed_out: f.timed_out,
};
if let Some(a) = &self.audit {
if let Err(e) = a.record(&result) {
eprintln!("execkit: audit write failed: {e}");
}
}
Ok(result)
}
pub fn redact_command(&self, cmd: &str) -> String {
self.redactor.redact_command(cmd)
}
fn maybe_auto_snapshot(&mut self, command: &str) {
let should = matches!(&self.checkpointer,
Some(cp) if cp.auto && !cp.git_unavailable && cp.workspace.is_some())
&& !checkpoint::is_read_only(command);
if !should {
return;
}
match self.ensure_init() {
Ok(()) => {}
Err(_) => return, }
let root = self.cp_root();
let cmd = self
.checkpointer
.as_ref()
.unwrap()
.snapshot_cmd(&root, "auto");
if let Ok(f) = self.run_framed(&cmd) {
if let Some(sha) = checkpoint::parse_sha(&f.stdout) {
self.checkpointer.as_mut().unwrap().last = Some(sha);
}
}
}
pub fn with_auto_snapshot(mut self, on: bool) -> Self {
if let Some(cp) = &mut self.checkpointer {
cp.auto = on;
}
self
}
pub fn with_workspace(mut self, root: impl Into<String>) -> Self {
if let Some(cp) = &mut self.checkpointer {
cp.workspace = Some(root.into());
}
self
}
pub fn with_checkpoint_paths<I, S>(mut self, paths: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
if let Some(cp) = &mut self.checkpointer {
let v: Vec<String> = paths.into_iter().map(Into::into).collect();
if !v.is_empty() {
cp.set_paths(v);
}
}
self
}
pub fn with_checkpoint_ignores<I, S>(mut self, ignores: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
if let Some(cp) = &mut self.checkpointer {
cp.set_ignores(ignores.into_iter().map(Into::into).collect());
}
self
}
pub fn checkpoint(&mut self, label: Option<&str>) -> Result<crate::CheckpointId> {
self.require_workspace()?;
self.ensure_init()?;
let label = label.unwrap_or("checkpoint").to_string();
let root = self.cp_root();
let cmd = self
.checkpointer
.as_ref()
.unwrap()
.snapshot_cmd(&root, &label);
let f = self.run_framed(&cmd)?;
let sha = checkpoint::parse_sha(&f.stdout)
.ok_or_else(|| Error::Transport(format!("checkpoint failed: {}", f.stderr.trim())))?;
self.checkpointer.as_mut().unwrap().last = Some(sha.clone());
Ok(crate::CheckpointId(sha))
}
pub fn checkpoints(&mut self) -> Result<Vec<Checkpoint>> {
self.require_remote()?;
let cp = self.checkpointer.as_ref().unwrap();
if cp.workspace.is_none() || !cp.initialized {
return Ok(vec![]);
}
let root = self.cp_root();
let cmd = self.checkpointer.as_ref().unwrap().list_cmd(&root);
let f = self.run_framed(&cmd)?;
Ok(checkpoint::parse_log(&f.stdout))
}
pub fn restore(&mut self, id: &crate::CheckpointId) -> Result<RestoreReport> {
self.require_workspace()?;
if !self.checkpointer.as_ref().unwrap().initialized {
return Err(Error::Unsupported(
"no checkpoints yet in this session".into(),
));
}
if !is_valid_checkpoint_id(&id.0) {
return Err(Error::Unsupported("invalid checkpoint id".into()));
}
let root = self.cp_root();
let diff_cmd = self
.checkpointer
.as_ref()
.unwrap()
.diff_count_cmd(&root, &id.0);
let changed = self
.run_framed(&diff_cmd)
.ok()
.and_then(|f| f.stdout.trim().parse::<usize>().ok())
.unwrap_or(0);
let cmd = self
.checkpointer
.as_ref()
.unwrap()
.restore_cmd(&root, &id.0);
let f = self.run_framed(&cmd)?;
if f.exit_code != 0 {
return Err(Error::Transport(format!(
"restore failed: {}",
f.stderr.trim()
)));
}
Ok(RestoreReport {
restored_to: id.0.clone(),
files_changed: changed,
})
}
pub fn restore_last(&mut self) -> Result<RestoreReport> {
self.require_workspace()?;
let last = self
.checkpointer
.as_ref()
.unwrap()
.last
.clone()
.ok_or_else(|| Error::Unsupported("no checkpoint to restore".into()))?;
self.restore(&crate::CheckpointId(last))
}
fn require_remote(&self) -> Result<()> {
match &self.checkpointer {
Some(_) => Ok(()),
None => Err(Error::Unsupported(
"checkpoints are available only for remote sessions".into(),
)),
}
}
fn require_workspace(&self) -> Result<()> {
self.require_remote()?;
match &self.checkpointer {
Some(cp) if cp.workspace.is_some() => Ok(()),
_ => Err(Error::Unsupported(
"checkpoints require an explicit workspace; set it with with_workspace() \
(library) or the 'workspace' param (MCP)"
.into(),
)),
}
}
fn cp_root(&self) -> String {
self.checkpointer
.as_ref()
.unwrap()
.root
.clone()
.unwrap_or_else(|| ".".into())
}
fn ensure_init(&mut self) -> Result<()> {
let cp = self.checkpointer.as_ref().unwrap();
if cp.initialized {
return Ok(());
}
if cp.git_unavailable {
return Err(Error::Unsupported(
"checkpoints need git on the remote host - install it (e.g. apt/apk/yum install git)"
.into(),
));
}
let probe = self.run_framed("command -v git >/dev/null 2>&1 && echo OK || echo NO")?;
if probe.stdout.trim() != "OK" {
self.checkpointer.as_mut().unwrap().git_unavailable = true;
return Err(Error::Unsupported(
"checkpoints need git on the remote host - install it (e.g. apt/apk/yum install git)"
.into(),
));
}
let root = self
.checkpointer
.as_ref()
.unwrap()
.workspace
.clone()
.ok_or_else(|| {
Error::Unsupported(
"checkpoints require an explicit workspace; set it with \
with_workspace() (library) or the 'workspace' param (MCP)"
.into(),
)
})?;
let init = self.checkpointer.as_ref().unwrap().init_cmd(&root);
let f = self.run_framed(&init)?;
if f.exit_code != 0 {
return Err(Error::Transport(format!(
"checkpoint init failed: {}",
f.stderr.trim()
)));
}
let cp = self.checkpointer.as_mut().unwrap();
cp.root = Some(root);
cp.initialized = true;
Ok(())
}
fn run_framed(&mut self, command: &str) -> Result<Framed> {
let acc_cap = self.default_acc_cap();
let f = self.run_framed_for(command, self.timeout, acc_cap)?;
if f.timed_out {
return Err(Error::Transport(format!(
"internal command timed out after {}s and was interrupted",
self.timeout.as_secs_f64()
)));
}
Ok(f)
}
fn default_acc_cap(&self) -> usize {
self.max_output.saturating_mul(2).max(65_536)
}
fn run_framed_for(
&mut self,
command: &str,
timeout: Duration,
acc_cap: usize,
) -> Result<Framed> {
let token = framing::new_token();
let markers = Markers::new(&token);
self.io
.write_all(framing::build_payload(command, &token).as_bytes())?;
let mut acc = framing::Accumulator::new(acc_cap);
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return self.interrupt(&acc, &markers, timeout);
}
let chunk = match self.io.recv_timeout(remaining) {
Some(c) => c,
None if Instant::now() >= deadline => {
return self.interrupt(&acc, &markers, timeout);
}
None => {
self.poisoned = true;
return Err(Error::ShellExited);
}
};
if let Some(p) = acc.push(&chunk, &markers) {
return Ok(Framed {
stdout: p.stdout,
stderr: p.stderr,
exit_code: p.exit_code,
cwd: p.cwd,
overflowed: acc.overflowed(),
timed_out: false,
});
}
}
}
fn interrupt(
&mut self,
acc: &framing::Accumulator,
markers: &Markers,
timeout: Duration,
) -> Result<Framed> {
let (out, overflowed) = (acc.partial_stdout(markers), acc.overflowed());
let Ok((cwd, late_stderr)) = self.resync() else {
self.poisoned = true;
return Err(Error::StillRunning);
};
Ok(Framed {
stdout: crate::output::clean(&String::from_utf8_lossy(out)),
stderr: format!(
"{late_stderr}{sep}execkit: timed out after {}s; sent Ctrl-C. The shell \
session is intact (cwd/env kept). For long jobs run them in the \
background: nohup CMD > /tmp/job.log 2>&1 & then poll the log.",
timeout.as_secs_f64(),
sep = if late_stderr.is_empty() || late_stderr.ends_with('\n') {
""
} else {
"\n"
},
),
exit_code: 124,
cwd,
overflowed,
timed_out: true,
})
}
fn resync(&mut self) -> Result<(String, String)> {
const SAVE: &[u8] = b"{ __ek_x=$__ek_f; } 2>/dev/null\n";
const RESYNC: &str = "__ek_n=$(wc -c 2>/dev/null <\"$__ek_x\"); \
tail -c 16384 \"$__ek_x\" >&2 2>/dev/null; \
printf '\\n%s#' \"$__ek_n\" >&2; \
rm -f \"$__ek_x\" 2>/dev/null; unset __ek_x __ek_n";
const CAP: usize = 65_536;
self.io.write_all(b"\x03")?;
self.io.write_all(SAVE)?;
let token = framing::new_token();
let markers = Markers::new(&token);
self.io
.write_all(framing::build_payload(RESYNC, &token).as_bytes())?;
let deadline = Instant::now() + Duration::from_secs(5);
let mut acc: Vec<u8> = Vec::new();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(Error::StillRunning);
}
let chunk = self.io.recv_timeout(remaining).ok_or(Error::StillRunning)?;
acc.extend_from_slice(&chunk);
if acc.len() > CAP {
acc.drain(..acc.len() - CAP / 2);
}
if let Some(p) = framing::parse(&acc, &markers) {
return Ok((p.cwd, stderr_tail(&p.stderr, 16_384)));
}
}
}
}
fn stderr_tail(framed: &str, cap: u64) -> String {
let Some(body) = framed.strip_suffix('#') else {
return framed.to_string();
};
let (tail, size) = body.rsplit_once('\n').unwrap_or(("", body));
let cut = size.trim().parse::<u64>().is_ok_and(|n| n > cap);
if !cut {
return tail.to_string();
}
tail.split_once('\n')
.map_or("", |(_, rest)| rest)
.to_string()
}
impl Drop for Session {
fn drop(&mut self) {
if self.poisoned {
return;
}
let Some(cmd) = self
.checkpointer
.as_ref()
.filter(|cp| cp.initialized)
.map(Checkpointer::cleanup_cmd)
else {
return;
};
let acc_cap = self.default_acc_cap();
let _ = self.run_framed_for(&cmd, Duration::from_secs(5), acc_cap);
}
}
const BUDGET_ACC_CAP: usize = 8 * 1024 * 1024;
struct Framed {
stdout: String,
stderr: String,
exit_code: i32,
cwd: String,
overflowed: bool,
timed_out: bool,
}
fn is_valid_container_ref(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphanumeric() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
}
fn is_valid_checkpoint_id(s: &str) -> bool {
let n = s.len();
(4..=40).contains(&n) && s.bytes().all(|b| b.is_ascii_hexdigit())
}
fn unique_token() -> String {
framing::new_token()
}
#[cfg(test)]
mod checkpoint_api_tests {
use crate::error::Error;
use crate::Session;
#[test]
fn checkpoints_unsupported_on_local() {
let mut s = Session::local().unwrap();
assert!(matches!(s.checkpoint(None), Err(Error::Unsupported(_))));
assert!(matches!(s.restore_last(), Err(Error::Unsupported(_))));
assert!(matches!(s.checkpoints(), Err(Error::Unsupported(_))));
}
}
#[cfg(test)]
mod tests {
use super::{is_valid_checkpoint_id, is_valid_container_ref, stderr_tail};
#[test]
fn stderr_tail_drops_the_partial_first_line_only_when_cut() {
assert_eq!(stderr_tail("boom\n\n5#", 16), "boom\n");
assert_eq!(stderr_tail("0#", 16), "");
assert_eq!(stderr_tail("ret=abc\nline 2\n\n 40#", 16), "line 2\n");
assert_eq!(stderr_tail("no newline at all\n40#", 16), "");
assert_eq!(stderr_tail("ret=abc\nline 2\n\n#", 16), "ret=abc\nline 2\n");
assert_eq!(stderr_tail("odd", 16), "odd");
}
#[test]
fn checkpoint_id_validation() {
assert!(is_valid_checkpoint_id("deadbeef"));
assert!(is_valid_checkpoint_id("0a1b"));
assert!(is_valid_checkpoint_id(
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
));
assert!(!is_valid_checkpoint_id("--output=/tmp/pwn/victim"));
assert!(!is_valid_checkpoint_id("-d"));
assert!(!is_valid_checkpoint_id(""));
assert!(!is_valid_checkpoint_id("abc")); assert!(!is_valid_checkpoint_id(&"a".repeat(41))); assert!(!is_valid_checkpoint_id("dead beef")); assert!(!is_valid_checkpoint_id("HEAD~1")); assert!(!is_valid_checkpoint_id("zzzz")); }
#[test]
fn container_ref_validation() {
assert!(is_valid_container_ref("my_app"));
assert!(is_valid_container_ref("web-1.test"));
assert!(is_valid_container_ref("0a1b2c3d4e5f"));
assert!(!is_valid_container_ref(""));
assert!(!is_valid_container_ref("-it"));
assert!(!is_valid_container_ref("--privileged"));
assert!(!is_valid_container_ref("a b"));
assert!(!is_valid_container_ref("a;rm -rf /"));
assert!(!is_valid_container_ref("a$(whoami)"));
assert!(!is_valid_container_ref("a\nrm")); assert!(!is_valid_container_ref("..")); assert!(!is_valid_container_ref("alpine")); }
}