use std::fmt;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const SPAWN_TTL_SECS: u64 = 60;
const MAX_SPAWN_BYTES: u64 = 64 * 1024;
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpawnFile {
pub nonce: String,
pub parent_socket: PathBuf,
pub user_pubkey: String,
pub user_key_id: String,
pub project_id: String,
pub root: PathBuf,
pub expires_unix: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container: Option<ContainerTransport>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContainerTransport {
pub engine: String,
pub container_id: String,
pub guest_parent_socket: PathBuf,
pub guest_runtime_root: PathBuf,
pub guest_trust_root: PathBuf,
pub guest_project_root: PathBuf,
pub host_child_socket: PathBuf,
}
impl fmt::Debug for SpawnFile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SpawnFile")
.field("nonce", &"<redacted>")
.field("parent_socket", &self.parent_socket)
.field("user_key_id", &self.user_key_id)
.field("project_id", &self.project_id)
.field("root", &self.root)
.field("expires_unix", &self.expires_unix)
.field(
"project_token",
&self.project_token.as_ref().map(|_| "<redacted>"),
)
.field("container", &self.container)
.finish()
}
}
#[derive(Debug, thiserror::Error)]
pub enum SpawnError {
#[error("{path}: {reason}")]
Unreadable { path: PathBuf, reason: String },
#[error("{path}: {reason}")]
Insecure { path: PathBuf, reason: String },
#[error("spawn.json expired {age_secs} s ago; project kernels are started by the user daemon")]
Expired { age_secs: u64 },
#[error("spawn.json: {0}")]
Invalid(String),
}
impl SpawnFile {
#[allow(clippy::too_many_arguments)]
pub fn new(
nonce: String,
parent_socket: PathBuf,
user_pubkey: String,
user_key_id: String,
project_id: String,
root: PathBuf,
project_token: Option<String>,
now_unix: u64,
) -> Self {
Self {
nonce,
parent_socket,
user_pubkey,
user_key_id,
project_id,
root,
expires_unix: now_unix.saturating_add(SPAWN_TTL_SECS),
project_token,
container: None,
}
}
pub fn validate(&self) -> Result<(), SpawnError> {
let bad = |m: &str| Err(SpawnError::Invalid(m.to_owned()));
let hex = |s: &str, n: usize| {
s.len() == n && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
};
if !(hex(&self.nonce, 32) || hex(&self.nonce, 64)) {
return bad("`nonce` must be 32 or 64 lowercase hex characters");
}
if !hex(&self.user_pubkey, 64) {
return bad("`user_pubkey` must be 64 lowercase hex characters");
}
if !hex(&self.user_key_id, 32) {
return bad("`user_key_id` must be 32 lowercase hex characters");
}
if super::validate_id(&self.project_id).is_err() {
return bad("`project_id` is not a canonical ULID");
}
if !self.root.is_absolute() || !self.parent_socket.is_absolute() {
return bad("`root` and `parent_socket` must be absolute paths");
}
if let Some(c) = &self.container
&& (!matches!(c.engine.as_str(), "docker" | "podman")
|| c.container_id.len() != 64
|| !c.container_id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|| ![&c.guest_parent_socket, &c.guest_runtime_root, &c.guest_trust_root,
&c.guest_project_root, &c.host_child_socket].iter().all(|p| p.is_absolute()))
{
return bad("invalid container identity or transport paths");
}
Ok(())
}
pub fn write(&self, path: &Path) -> std::io::Result<()> {
let dir = path
.parent()
.filter(|d| !d.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let tmp = dir.join(format!(".spawn.{}.tmp", std::process::id()));
let _ = std::fs::remove_file(&tmp);
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let body = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?;
let res = (|| {
let mut f = opts.open(&tmp)?;
f.write_all(&body)?;
f.sync_all()?;
std::fs::rename(&tmp, path)
})();
if res.is_err() {
let _ = std::fs::remove_file(&tmp);
}
res
}
pub fn read_and_consume(path: &Path, now_unix: u64) -> Result<Self, SpawnError> {
let unreadable = |e: &dyn fmt::Display| SpawnError::Unreadable {
path: path.to_path_buf(),
reason: e.to_string(),
};
let insecure = |r: &str| SpawnError::Insecure {
path: path.to_path_buf(),
reason: r.to_owned(),
};
let mut opts = std::fs::OpenOptions::new();
opts.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.custom_flags(libc::O_NOFOLLOW);
}
let file = opts.open(path).map_err(|e| unreadable(&e))?;
let meta = file.metadata().map_err(|e| unreadable(&e))?;
if !meta.is_file() {
return Err(insecure("not a regular file"));
}
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let me = unsafe { libc::geteuid() };
if meta.uid() != me {
return Err(insecure("not owned by this user"));
}
let mode = meta.permissions().mode();
if mode & 0o077 != 0 {
let _ = std::fs::remove_file(path);
return Err(insecure(&format!(
"mode {:04o} lets other users read the spawn nonce and token; it must be 0600",
mode & 0o7777
)));
}
}
if meta.len() > MAX_SPAWN_BYTES {
return Err(insecure("larger than 64 KiB"));
}
let mut text = String::new();
std::io::Read::read_to_string(&mut std::io::Read::take(&file, MAX_SPAWN_BYTES), &mut text)
.map_err(|e| unreadable(&e))?;
let _ = std::fs::remove_file(path);
let spawn: Self = serde_json::from_str(&text).map_err(|e| unreadable(&e))?;
spawn.validate()?;
if now_unix >= spawn.expires_unix {
return Err(SpawnError::Expired {
age_secs: now_unix - spawn.expires_unix,
});
}
Ok(spawn)
}
}
#[cfg(test)]
mod tests {
use super::*;
const ID: &str = "01JB8Z3Q0V6X9KQ4M2N7T5R1WD";
fn sample(now: u64) -> SpawnFile {
SpawnFile::new(
"ab".repeat(16),
"/h/.weftos/run/kernel.sock".into(),
"cd".repeat(32),
"ef".repeat(16),
ID.into(),
"/work/p".into(),
Some("wft_secret".into()),
now,
)
}
#[test]
fn write_then_consume_round_trips_and_deletes() {
let t = tempfile::tempdir().unwrap();
let p = t.path().join("spawn.json");
let s = sample(1000);
s.write(&p).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&p).unwrap().permissions().mode() & 0o777,
0o600
);
}
assert_eq!(SpawnFile::read_and_consume(&p, 1059).unwrap(), s);
assert!(!p.exists(), "single use");
assert!(matches!(
SpawnFile::read_and_consume(&p, 1059),
Err(SpawnError::Unreadable { .. })
));
}
#[test]
fn container_transport_requires_immutable_id_and_absolute_host_guest_paths() {
let mut s = sample(1000);
s.container = Some(ContainerTransport {
engine: "docker".into(), container_id: "a".repeat(64),
guest_parent_socket: "/weftos/parent/kernel.sock".into(),
guest_runtime_root: format!("/weftos/run/{ID}").into(),
guest_trust_root: "/weftos/trust".into(),
guest_project_root: "/weftos/project".into(),
host_child_socket: format!("/run/{ID}/guest/kernel.sock").into(),
});
s.validate().unwrap();
let round: SpawnFile = serde_json::from_slice(&serde_json::to_vec(&s).unwrap()).unwrap();
assert_eq!(round, s);
s.container.as_mut().unwrap().container_id = "a".repeat(12);
assert!(s.validate().is_err());
s.container.as_mut().unwrap().container_id = "b".repeat(64);
s.container.as_mut().unwrap().host_child_socket = "relative.sock".into();
assert!(s.validate().is_err());
}
#[test]
fn expired_file_is_refused_and_removed() {
let t = tempfile::tempdir().unwrap();
let p = t.path().join("spawn.json");
sample(1000).write(&p).unwrap();
let e = SpawnFile::read_and_consume(&p, 1000 + SPAWN_TTL_SECS + 5).unwrap_err();
assert!(matches!(e, SpawnError::Expired { age_secs: 5 }), "{e}");
assert!(!p.exists());
}
#[cfg(unix)]
#[test]
fn group_or_world_readable_file_is_refused() {
use std::os::unix::fs::PermissionsExt;
let t = tempfile::tempdir().unwrap();
let p = t.path().join("spawn.json");
sample(1000).write(&p).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o640)).unwrap();
let e = SpawnFile::read_and_consume(&p, 1001).unwrap_err();
assert!(matches!(e, SpawnError::Insecure { .. }), "{e}");
assert!(e.to_string().contains("0640"));
}
#[cfg(unix)]
#[test]
fn symlink_is_refused() {
let t = tempfile::tempdir().unwrap();
let real = t.path().join("real.json");
sample(1000).write(&real).unwrap();
let link = t.path().join("spawn.json");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert!(SpawnFile::read_and_consume(&link, 1001).is_err());
assert!(real.exists(), "the target is not consumed through a link");
}
#[test]
fn malformed_fields_are_refused() {
let mut s = sample(1000);
s.nonce = "XYZ".into();
assert!(matches!(s.validate(), Err(SpawnError::Invalid(_))));
let mut s = sample(1000);
s.project_id = "../etc".into();
assert!(s.validate().is_err());
let mut s = sample(1000);
s.root = "relative".into();
assert!(s.validate().is_err());
}
#[test]
fn unknown_keys_are_ignored_and_debug_redacts() {
let mut v = serde_json::to_value(sample(1000)).unwrap();
v["future_field"] = serde_json::json!(1);
let s: SpawnFile = serde_json::from_value(v).unwrap();
let d = format!("{s:?}");
assert!(!d.contains("wft_secret") && !d.contains(&"ab".repeat(16)));
}
}