blob: 078b3c178093c1a8b2214fe4c9bcad6bf75cb04e [file]
//! What a web launcher over a root leaves in `state/web.json`: the token that is its
//! master key, and where the running one listens and its sessions start. Owner-only,
//! enforced on every write; a file found readable by others is not trusted, and its token
//! is replaced. Paired browsers are not here: their keys live as long as the server.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// Relative to the root.
pub const FILE: &str = "state/web.json";
/// The file's contents.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct WebState {
/// The master key: the command line holds it, and with it mints pairing codes.
pub token: String,
/// Where the running server listens, `http://127.0.0.1:4200`; absent once it stopped.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// Where its sessions start.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workdir: Option<String>,
}
fn path(root: &Path) -> PathBuf {
root.join(FILE)
}
#[cfg(unix)]
fn too_open(p: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p).is_ok_and(|m| m.permissions().mode() & 0o077 != 0)
}
#[cfg(not(unix))]
fn too_open(_: &Path) -> bool {
false
}
/// Where a session's launcher writes its status, relative to the root.
pub const STATUS_DIR: &str = "state/web";
/// The status file for a web session id; `None` for anything that is not one of the
/// ids this server makes, 32 hex digits, so no id becomes a path anywhere else.
#[must_use]
pub fn status_file(root: &Path, id: &str) -> Option<std::path::PathBuf> {
(id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit()))
.then(|| root.join(STATUS_DIR).join(format!("{id}.json")))
}
/// What [`load`] found.
#[derive(Debug)]
pub struct Loaded {
/// The state to go on with: the file's, or a fresh one with a new token.
pub state: WebState,
/// Why it is fresh when the file was there, for the operator.
pub why: Option<String>,
/// The file's own state when others could read it: its token is exposed, and a
/// server may still be running with it.
pub exposed: Option<WebState>,
}
/// The state, or a fresh one with a new token when there is no file, it does not parse,
/// or others could read it.
///
/// # Errors
///
/// When the file is there and cannot be read, which is not the same as not there, or
/// when there is no randomness for a token.
pub fn load(root: &Path) -> Result<Loaded, std::io::Error> {
let p = path(root);
let fresh =
|why: Option<String>, exposed: Option<WebState>| -> Result<Loaded, std::io::Error> {
Ok(Loaded {
state: WebState {
token: crate::access::new_token()?,
..WebState::default()
},
why,
exposed,
})
};
let text = match std::fs::read_to_string(&p) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return fresh(None, None),
Err(e) => {
return Err(std::io::Error::new(
e.kind(),
format!("{}: {e}", p.display()),
))
}
};
let parsed = serde_json::from_str::<WebState>(&text)
.ok()
.filter(|s| s.token.len() >= 32);
if too_open(&p) {
return fresh(
Some(format!(
"{} was readable by others; its token is replaced",
p.display()
)),
parsed,
);
}
match parsed {
Some(state) => Ok(Loaded {
state,
why: None,
exposed: None,
}),
None => fresh(
Some(format!("{} did not read; a new token", p.display())),
None,
),
}
}
/// Write the state readable by its owner only: through a file made that way and renamed
/// over the old one, so a file that was too open is replaced rather than kept.
///
/// # Errors
///
/// When the file cannot be written.
pub fn save(root: &Path, state: &WebState) -> std::io::Result<()> {
let p = path(root);
let dir = p.parent().unwrap_or(root);
std::fs::create_dir_all(dir)?;
let tmp = dir.join(format!(".web.json.{}", std::process::id()));
let mut o = std::fs::OpenOptions::new();
o.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
o.mode(0o600);
}
{
let mut f = o.open(&tmp)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// a leftover temporary file keeps its old mode through open; set it outright
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
std::io::Write::write_all(&mut f, serde_json::to_string_pretty(state)?.as_bytes())?;
}
std::fs::rename(&tmp, &p)?;
// a filesystem that keeps no mode bits, a Windows drive under WSL say, shows the file
// open to everyone whatever was asked; say so rather than mint a token every run
if too_open(&p) {
return Err(std::io::Error::other(format!(
"{} cannot be kept owner-only on this filesystem (a Windows drive under WSL keeps \
no mode bits); put the root on a Linux filesystem",
p.display()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_state_round_trips_and_a_missing_file_is_a_fresh_token() {
let dir = tempfile::tempdir().unwrap();
let l = load(dir.path()).unwrap();
let (s, why) = (l.state, l.why);
assert!(why.is_none());
assert_eq!(s.token.len(), 64);
let s = WebState {
url: Some("http://127.0.0.1:4200".into()),
workdir: Some("/w".into()),
..s
};
save(dir.path(), &s).unwrap();
let again = load(dir.path()).unwrap();
assert_eq!((again.state, again.why), (s, None));
}
#[cfg(unix)]
#[test]
fn a_file_others_can_read_is_not_trusted_and_is_replaced_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let s = load(dir.path()).unwrap().state;
save(dir.path(), &s).unwrap();
let p = dir.path().join(FILE);
assert_eq!(
std::fs::metadata(&p).unwrap().permissions().mode() & 0o777,
0o600
);
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
let l = load(dir.path()).unwrap();
let fresh = l.state;
assert_ne!(
fresh.token, s.token,
"a token others could read is not reused"
);
assert!(l.why.unwrap().contains("readable by others"));
assert_eq!(
l.exposed.unwrap().token,
s.token,
"the exposed one is handed back, to see whether a server still holds it"
);
save(dir.path(), &fresh).unwrap();
assert_eq!(
std::fs::metadata(&p).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[cfg(unix)]
#[test]
fn a_leftover_temporary_file_others_could_read_does_not_carry_its_mode_over() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let state_dir = dir.path().join("state");
std::fs::create_dir_all(&state_dir).unwrap();
let leftover = state_dir.join(format!(".web.json.{}", std::process::id()));
std::fs::write(&leftover, "old").unwrap();
std::fs::set_permissions(&leftover, std::fs::Permissions::from_mode(0o644)).unwrap();
let s = load(dir.path()).unwrap().state;
save(dir.path(), &s).unwrap();
let mode = std::fs::metadata(dir.path().join(FILE))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
#[cfg(unix)]
#[test]
fn a_file_that_cannot_be_read_is_an_error_not_a_fresh_token() {
let dir = tempfile::tempdir().unwrap();
// a directory where the file should be: there, and unreadable as a file
std::fs::create_dir_all(dir.path().join(FILE)).unwrap();
assert!(load(dir.path()).is_err());
}
}