use super::{config::Seat, tools::Identity};
use serde::{Deserialize, Serialize};
use std::{
fs::{File, OpenOptions},
io::{Read as _, Write as _},
os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _},
path::{Path, PathBuf},
};
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Debt {
issuer: String,
agent: String,
proof: String,
bound: usize,
}
pub(super) struct Owed {
path: PathBuf,
debt: Debt,
lock: File,
}
impl Owed {
pub fn begin(seat: &Seat, identity: &Identity) -> anyhow::Result<Self> {
let path = companion(&seat.path, "revoke-owed")?;
let lock_path = companion(&seat.path, "revoke-lock")?;
let lock = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.custom_flags(i32::try_from(rustix::fs::OFlags::NOFOLLOW.bits())?)
.open(lock_path)?;
private(&lock)?;
rustix::fs::flock(&lock, rustix::fs::FlockOperation::NonBlockingLockExclusive)
.map_err(|_| anyhow::anyhow!("assistant_seat_already_running"))?;
match OpenOptions::new()
.read(true)
.custom_flags(i32::try_from(rustix::fs::OFlags::NOFOLLOW.bits())?)
.open(&path)
{
Ok(file) => {
private(&file)?;
let limit = seat
.harness
.issuer_response_bytes
.checked_mul(2)
.and_then(|n| n.checked_add(4096))
.ok_or_else(|| anyhow::anyhow!("revoke_marker_bound_overflow"))?;
let mut bytes = Vec::new();
file.take(u64::try_from(limit)?.saturating_add(1))
.read_to_end(&mut bytes)?;
anyhow::ensure!(bytes.len() <= limit, "revoke_marker_exceeds_bound");
let debt: Debt = serde_json::from_slice(&bytes)
.map_err(|_| anyhow::anyhow!("revoke_marker_invalid"))?;
debt.send()?;
remove(&path)?;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
let debt = Debt {
issuer: super::tools::origin(&seat.issuer)?.to_owned(),
agent: identity.agent.clone(),
proof: identity.launch.clone(),
bound: seat.harness.issuer_response_bytes,
};
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)?;
file.write_all(&serde_json::to_vec(&debt)?)?;
sync_file(&file)?;
sync_file(&parent(&path)?)?;
Ok(Self { path, debt, lock })
}
pub async fn close(self) -> anyhow::Result<()> {
tokio::task::spawn_blocking(move || {
self.debt.send()?;
remove(&self.path)?;
drop(self.lock);
Ok(())
})
.await
.map_err(|_| anyhow::anyhow!("revoke_worker_failed"))?
}
}
impl Debt {
fn send(&self) -> anyhow::Result<()> {
let issuer = super::tools::origin(&self.issuer)?;
anyhow::ensure!(
!self.agent.is_empty()
&& !matches!(self.agent.as_str(), "." | "..")
&& self.agent.len() <= 128
&& self
.agent
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')),
"revoke_agent_invalid"
);
anyhow::ensure!(
self.bound > 0
&& !self.proof.is_empty()
&& self.proof.len() <= self.bound
&& self.proof.is_ascii()
&& !self.proof.bytes().any(|byte| byte.is_ascii_control()),
"revoke_proof_invalid"
);
let mut proof = ureq::http::HeaderValue::from_str(&self.proof)
.map_err(|_| anyhow::anyhow!("revoke_proof_invalid"))?;
proof.set_sensitive(true);
let mut response = super::tools::client()
.post(format!("{issuer}/api/agents/{}/pass/revoke", self.agent))
.header("lys-agent-pass", proof)
.header("content-type", "application/json")
.send(br#"{"audience":"haematite"}"#.as_slice())
.map_err(|_| anyhow::anyhow!("assistant_revoke_unreachable"))?;
let status = response.status().as_u16();
let body = response
.body_mut()
.with_config()
.limit(u64::try_from(self.bound)?)
.read_to_vec()
.map_err(|_| {
anyhow::anyhow!("assistant_revoke_response_exceeds_bound_or_unreadable")
})?;
anyhow::ensure!(
matches!(status, 200 | 204),
"assistant_revoke_refused: HTTP {status}"
);
drop(body);
Ok(())
}
}
fn private(file: &File) -> anyhow::Result<()> {
let meta = file.metadata()?;
anyhow::ensure!(
meta.is_file()
&& meta.uid() == rustix::process::geteuid().as_raw()
&& meta.permissions().mode() & 0o7777 == 0o600,
"revoke_marker_requires_owner_and_mode_0600"
);
Ok(())
}
fn parent(path: &Path) -> anyhow::Result<File> {
Ok(File::open(path.parent().ok_or_else(|| {
anyhow::anyhow!("revoke_marker_parent_absent")
})?)?)
}
fn remove(path: &Path) -> anyhow::Result<()> {
std::fs::remove_file(path)?;
sync_file(&parent(path)?)?;
Ok(())
}
fn companion(path: &Path, suffix: &str) -> anyhow::Result<PathBuf> {
let name = path
.file_name()
.ok_or_else(|| anyhow::anyhow!("seat_config_name_absent"))?;
let mut name = name.to_os_string();
name.push(format!(".{suffix}"));
Ok(path.with_file_name(name))
}
fn sync_file(file: &File) -> anyhow::Result<()> {
file.sync_all()?;
Ok(())
}