mod script;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use super::{Exit, OnLine, ProcessError, ProcessRunner, ProcessSpec, capture};
use crate::BoxFuture;
use crate::events::Stream;
const POLL: Duration = Duration::from_millis(200);
const PROMPT_TIMEOUT: Duration = Duration::from_secs(300);
const MAY_MANAGE: [&str; 2] = ["S-1-16-12288", "S-1-5-32-578"];
#[derive(Debug, Clone, PartialEq, Eq)]
enum Session {
Unchecked,
Direct,
Broker,
Failed(String),
}
pub(crate) struct Elevated {
inner: Arc<dyn ProcessRunner>,
dir: PathBuf,
session: Mutex<Session>,
next: AtomicU64,
forwarded: Vec<(String, String)>,
}
impl std::fmt::Debug for Elevated {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Elevated").field("dir", &self.dir).finish_non_exhaustive()
}
}
impl Elevated {
pub(crate) fn new(inner: Arc<dyn ProcessRunner>, dir: PathBuf, own: impl IntoIterator<Item = (String, String)>) -> Elevated {
Elevated {
inner,
dir,
session: Mutex::new(Session::Unchecked),
next: AtomicU64::new(1),
forwarded: forwarded(own),
}
}
fn session(&self) -> Session {
self.session.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
fn set(&self, s: Session) {
*self.session.lock().unwrap_or_else(PoisonError::into_inner) = s;
}
async fn run_spec(&self, spec: &ProcessSpec, on_line: OnLine<'_>) -> Result<Exit, ProcessError> {
if self.session() == Session::Unchecked {
let s = self.open().await;
self.set(s);
}
match self.session() {
Session::Direct | Session::Unchecked => self.inner.run(spec, on_line).await,
Session::Failed(reason) => Err(ProcessError::Elevation { reason }),
Session::Broker => self.request(spec, on_line).await,
}
}
async fn open(&self) -> Session {
let whoami = ProcessSpec::new("whoami", ["/groups", "/fo", "csv", "/nh"]);
if let Ok(out) = capture(self.inner.as_ref(), &whoami).await
&& may_manage(&out.stdout.join("\n"))
{
return Session::Direct;
}
if let Err(e) = crate::store::private_dir(&self.dir) {
return Session::Failed(format!("{}: {e}", self.dir.display()));
}
let launcher = script::launcher(&script::broker(&self.dir, std::process::id()));
let start = ProcessSpec::new("powershell", ["-NoProfile".to_owned(), "-NonInteractive".into(), "-Command".into(), launcher]);
match capture(self.inner.as_ref(), &start).await {
Ok(out) if out.exit.is_some_and(Exit::success) => {}
Ok(out) => return Session::Failed(declined(&out.stderr.join("\n"))),
Err(e) => return Session::Failed(e.to_string()),
}
if wait_for(&self.dir.join("ready"), PROMPT_TIMEOUT).await {
Session::Broker
} else {
Session::Failed("the elevated session didn't start".into())
}
}
async fn request(&self, spec: &ProcessSpec, on_line: OnLine<'_>) -> Result<Exit, ProcessError> {
if let Some(arg) = spec.args.iter().find(|a| !script::passes_intact(a)) {
return Err(ProcessError::Refused {
program: spec.program.clone(),
arg: arg.escape_debug().to_string(),
});
}
let n = self.next.fetch_add(1, Ordering::SeqCst);
let io = |source| ProcessError::Io {
program: spec.program.clone(),
source,
};
crate::store::write_private(&self.dir.join(format!("req-{n}.json")), request(spec, &self.forwarded).as_bytes()).map_err(io)?;
let mut guard = Cancel {
dir: &self.dir,
armed: true,
session: &self.session,
};
let mut tail = Tail::default();
let (log, exit) = (self.dir.join(format!("out-{n}.log")), self.dir.join(format!("exit-{n}")));
loop {
let done = std::fs::read_to_string(&exit).ok();
tail.read(&log, on_line).map_err(io)?;
if let Some(code) = done {
tail.finish(on_line);
guard.armed = false;
return Ok(Exit::new(code.trim().parse().ok()));
}
tokio::time::sleep(POLL).await;
}
}
}
impl ProcessRunner for Elevated {
fn run<'a>(&'a self, spec: &'a ProcessSpec, on_line: OnLine<'a>) -> BoxFuture<'a, Result<Exit, ProcessError>> {
Box::pin(self.run_spec(spec, on_line))
}
}
impl Drop for Elevated {
fn drop(&mut self) {
if self.session() == Session::Broker {
signal(&self.dir, "done");
}
}
}
struct Cancel<'a> {
dir: &'a Path,
armed: bool,
session: &'a Mutex<Session>,
}
impl Drop for Cancel<'_> {
fn drop(&mut self) {
if self.armed {
signal(self.dir, "cancel");
*self.session.lock().unwrap_or_else(PoisonError::into_inner) = Session::Failed("the elevated session was ended".into());
}
}
}
fn signal(dir: &Path, what: &str) {
if let Err(e) = std::fs::write(dir.join(what), "") {
tracing::warn!(error = %e, signal = what, "the elevated session couldn't be told");
}
}
#[derive(Debug, Default)]
struct Tail {
offset: u64,
partial: Vec<u8>,
}
impl Tail {
fn read(&mut self, log: &Path, on_line: OnLine<'_>) -> std::io::Result<()> {
let mut f = match std::fs::File::open(log) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
std::io::Seek::seek(&mut f, std::io::SeekFrom::Start(self.offset))?;
let mut bytes = Vec::new();
let read = f.read_to_end(&mut bytes)?;
self.offset += u64::try_from(read).unwrap_or(0);
self.partial.extend(bytes);
while let Some(end) = self.partial.iter().position(|b| *b == b'\n') {
let line: Vec<u8> = self.partial.drain(..=end).collect();
on_line(Stream::Stdout, String::from_utf8_lossy(&line).trim_end_matches(['\r', '\n']));
}
Ok(())
}
fn finish(&mut self, on_line: OnLine<'_>) {
if !self.partial.is_empty() {
on_line(
Stream::Stdout,
String::from_utf8_lossy(&std::mem::take(&mut self.partial)).trim_end_matches('\r'),
);
}
}
}
fn may_manage(groups: &str) -> bool {
groups.lines().any(|l| MAY_MANAGE.iter().any(|sid| l.contains(&format!("\"{sid}\""))))
}
fn forwarded(own: impl IntoIterator<Item = (String, String)>) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut profile = None;
for (k, v) in own {
let upper = k.to_ascii_uppercase();
if upper == "USERPROFILE" {
profile = Some(v.clone());
}
if upper == "PATH" || upper.starts_with("VAGRANT_") {
out.push((k, v));
}
}
if !out.iter().any(|(k, _)| k.eq_ignore_ascii_case("VAGRANT_HOME"))
&& let Some(p) = profile
{
out.push(("VAGRANT_HOME".into(), format!("{}\\.vagrant.d", p.trim_end_matches(['\\', '/']))));
}
out
}
fn request(spec: &ProcessSpec, forwarded: &[(String, String)]) -> String {
let mut env: Vec<(String, String)> = forwarded.to_vec();
for (k, v) in &spec.env {
env.retain(|(o, _)| !o.eq_ignore_ascii_case(k));
env.push((k.clone(), v.clone()));
}
serde_json::json!({
"program": spec.program,
"args": spec.args,
"cwd": spec.cwd.as_ref().map_or_else(|| ".".into(), |c| c.display().to_string()),
"env": env.iter().map(|(k, v)| serde_json::json!({ "k": k, "v": v })).collect::<Vec<_>>(),
})
.to_string()
}
fn declined(stderr: &str) -> String {
if stderr.contains("canceled by the user") || stderr.contains("cancelled by the user") {
"the Windows permission prompt was declined".into()
} else {
format!("the elevated session didn't start: {}", stderr.trim())
}
}
async fn wait_for(file: &Path, limit: Duration) -> bool {
let deadline = tokio::time::Instant::now() + limit;
while tokio::time::Instant::now() < deadline {
if file.exists() {
return true;
}
tokio::time::sleep(POLL).await;
}
file.exists()
}
#[cfg(test)]
mod tests;