use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tokens {
pub full: String,
pub readonly: String,
}
impl Tokens {
pub fn fresh() -> Tokens {
Tokens {
full: super::new_token(),
readonly: super::new_token(),
}
}
pub fn none() -> Tokens {
Tokens {
full: String::new(),
readonly: String::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Loaded {
Reused,
Created,
}
const MAX_FILE_BYTES: u64 = 4096;
pub fn load_or_create(path: &Path, rotate: bool) -> anyhow::Result<(Tokens, Loaded)> {
if rotate {
match std::fs::remove_file(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => anyhow::bail!("could not remove {} to rotate it: {e}", path.display()),
}
}
if let Some(tokens) = read(path)? {
return Ok((tokens, Loaded::Reused));
}
match create(path)? {
Some(tokens) => Ok((tokens, Loaded::Created)),
None => read(path)?
.map(|tokens| (tokens, Loaded::Reused))
.ok_or_else(|| anyhow::anyhow!("{} vanished while it was read", path.display())),
}
}
fn read(path: &Path) -> anyhow::Result<Option<Tokens>> {
let file = match OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
{
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) if e.raw_os_error() == Some(libc::ELOOP) => anyhow::bail!(
"{} is a symlink, and a token file must be the file itself — \
a link can be repointed between the check and the read.\n{}",
path.display(),
REMEDY
),
Err(e) => anyhow::bail!("could not open {}: {e}", path.display()),
};
let meta = file.metadata()?;
let me = unsafe { libc::geteuid() };
if let Some(why) = objection(meta.is_file(), meta.mode(), meta.uid(), me) {
anyhow::bail!("refusing {}: {why}\n{}", path.display(), REMEDY);
}
let mut text = String::new();
file.take(MAX_FILE_BYTES)
.read_to_string(&mut text)
.map_err(|e| anyhow::anyhow!("could not read {}: {e}", path.display()))?;
parse(&text)
.map(Some)
.map_err(|why| anyhow::anyhow!("refusing {}: {why}\n{}", path.display(), REMEDY))
}
const REMEDY: &str = "Delete it, or pass --rotate-token, and cctop writes a new one.";
fn objection(is_file: bool, mode: u32, owner: u32, me: u32) -> Option<String> {
if !is_file {
return Some("it is not a regular file".to_string());
}
if owner != me {
return Some(format!(
"it belongs to uid {owner}, not you (uid {me}) — whoever owns it \
can rewrite it, and a token someone else chose is one they know"
));
}
if mode & 0o077 != 0 {
return Some(format!(
"its mode is {:o}, so other users can read or change it and the tokens \
in it are no longer a secret — it must be 600",
mode & 0o777
));
}
None
}
fn create(path: &Path) -> anyhow::Result<Option<Tokens>> {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(parent)
.map_err(|e| anyhow::anyhow!("could not create {}: {e}", parent.display()))?;
}
let mut file: File = match OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
{
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => return Ok(None),
Err(e) => anyhow::bail!("could not create {}: {e}", path.display()),
};
let tokens = Tokens::fresh();
let written = file
.write_all(render(&tokens).as_bytes())
.and_then(|()| file.sync_all());
if let Err(e) = written {
let _ = std::fs::remove_file(path);
anyhow::bail!("could not write {}: {e}", path.display());
}
Ok(Some(tokens))
}
fn render(tokens: &Tokens) -> String {
format!(
"# cctop serve --token-file. Whoever reads the `full` line can type at your agents.\n\
full {}\nreadonly {}\n",
tokens.full, tokens.readonly
)
}
fn parse(text: &str) -> Result<Tokens, String> {
let mut tokens = Tokens::none();
for line in text.lines().map(str::trim) {
if line.is_empty() || line.starts_with('#') {
continue;
}
let (scope, token) = line
.split_once(char::is_whitespace)
.map(|(s, t)| (s, t.trim()))
.ok_or_else(|| format!("the line `{line}` is not `scope token`"))?;
let slot = match scope {
"full" => &mut tokens.full,
"readonly" => &mut tokens.readonly,
"metrics" => continue,
other => return Err(format!("`{other}` is not a token scope")),
};
if token.len() != super::TOKEN_BYTES * 2 || !token.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(format!(
"the {scope} token is not {} hex characters",
super::TOKEN_BYTES * 2
));
}
*slot = token.to_string();
}
for (scope, token) in [("full", &tokens.full), ("readonly", &tokens.readonly)] {
if token.is_empty() {
return Err(format!("it has no {scope} token"));
}
}
if tokens.full == tokens.readonly {
return Err("its two scopes share a token, which would grant the wider one".into());
}
Ok(tokens)
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn a_token_file_is_written_private_and_reused_by_the_next_run() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cctop").join("serve-tokens");
let (first, how) = load_or_create(&path, false).unwrap();
assert_eq!(how, Loaded::Created);
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "written as {mode:o}");
let (second, how) = load_or_create(&path, false).unwrap();
assert_eq!(how, Loaded::Reused);
assert_eq!(first, second, "a restart minted new tokens");
}
#[test]
fn rotating_replaces_every_token() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("serve-tokens");
let (old, _) = load_or_create(&path, false).unwrap();
let (new, how) = load_or_create(&path, true).unwrap();
assert_eq!(how, Loaded::Created);
assert_ne!(old.full, new.full);
assert_ne!(old.readonly, new.readonly);
assert_eq!(load_or_create(&path, false).unwrap().0, new);
}
#[test]
fn a_file_other_users_can_read_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("serve-tokens");
load_or_create(&path, false).unwrap();
for mode in [0o640, 0o604, 0o660] {
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
let err = load_or_create(&path, false).unwrap_err().to_string();
assert!(err.contains("no longer a secret"), "{mode:o}: {err}");
assert!(err.contains("--rotate-token"), "{mode:o}: {err}");
}
load_or_create(&path, true).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn a_symlinked_token_file_is_refused() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real");
load_or_create(&real, false).unwrap();
let link = dir.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let err = load_or_create(&link, false).unwrap_err().to_string();
assert!(err.contains("symlink"), "{err}");
}
#[test]
fn a_file_owned_by_someone_else_is_refused() {
assert!(
objection(true, 0o100600, 1001, 1000)
.unwrap()
.contains("uid 1001")
);
assert!(objection(false, 0o040700, 1000, 1000).is_some());
assert_eq!(objection(true, 0o100600, 1000, 1000), None);
assert_eq!(objection(true, 0o100400, 1000, 1000), None);
}
#[test]
fn a_malformed_file_is_refused_rather_than_half_used() {
let t = |c: char| c.to_string().repeat(super::super::TOKEN_BYTES * 2);
let good = format!("full {}\nreadonly {}\n", t('a'), t('b'));
let tokens = parse(&good).unwrap();
assert_eq!((tokens.full, tokens.readonly), (t('a'), t('b')));
let missing = format!("full {}\n", t('a'));
assert!(parse(&missing).unwrap_err().contains("readonly"));
let shared = format!("full {}\nreadonly {}\n", t('a'), t('a'));
assert!(parse(&shared).unwrap_err().contains("share"));
let short = format!("full abc\nreadonly {}\n", t('b'));
assert!(parse(&short).is_err());
assert!(parse("admin 00\n").is_err());
}
#[test]
fn the_file_round_trips_both_scopes() {
let tokens = Tokens::fresh();
let text = render(&tokens);
assert_eq!(parse(&text).unwrap(), tokens);
assert!(!text.contains("metrics"), "{text}");
let legacy = format!("{text}metrics {}\n", super::super::new_token());
assert_eq!(parse(&legacy).unwrap(), tokens);
}
}