use std::io::{BufRead, IsTerminal, Write};
use serde::Serialize;
use super::class::Class;
use super::target::{parse_target, Target};
pub(crate) const EXIT_REFUSED: u8 = 4;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct Policy {
pub(crate) allow_write: bool,
pub(crate) yes: bool,
}
pub(crate) trait Confirm {
fn interactive(&self) -> bool;
fn ask(&mut self, prompt: &str) -> Option<String>;
}
pub(crate) struct Stdin;
impl Confirm for Stdin {
fn interactive(&self) -> bool {
std::io::stdin().is_terminal()
}
fn ask(&mut self, prompt: &str) -> Option<String> {
let mut stderr = std::io::stderr().lock();
let _ = write!(stderr, "{prompt}");
let _ = stderr.flush();
let mut line = String::new();
match std::io::stdin().lock().read_line(&mut line) {
Ok(0) | Err(_) => None,
Ok(_) => Some(line),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Reason {
AllowWriteRequired,
YesRequired,
NotConfirmed,
Unclassified,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct Refusal {
error: &'static str,
pub(crate) reason: Reason,
pub(crate) command: String,
pub(crate) class: Option<Class>,
pub(crate) missing: Vec<&'static str>,
pub(crate) message: String,
}
impl Refusal {
fn new(
reason: Reason,
command: &str,
class: Option<Class>,
missing: Vec<&'static str>,
) -> Self {
let what = class.map_or("an unclassified".to_string(), |class| {
format!("a {}", class.name())
});
let message = match reason {
Reason::AllowWriteRequired | Reason::YesRequired => {
let why = if missing.contains(&"--yes") {
" (stdin is not a terminal, so it can't be confirmed by typing)"
} else {
""
};
format!(
"{command} is {what} command and needs {}{why}; nothing was sent",
missing.join(" and ")
)
}
Reason::NotConfirmed => format!("{command} was not confirmed; nothing was sent"),
Reason::Unclassified => format!("{command} is {what} command; nothing was sent"),
};
Self {
error: "refused",
reason,
command: command.to_string(),
class,
missing,
message,
}
}
pub(crate) fn to_json(&self) -> String {
serde_json::to_string(self).expect("a refusal serializes")
}
}
fn confirmation(target: &str) -> (String, String) {
match parse_target(target) {
Ok(Target::Instance(instance) | Target::Routed { instance, .. }) => (
format!("the device instance ({instance})"),
instance.to_string(),
),
_ => (format!("the target ({target})"), target.to_string()),
}
}
pub(crate) fn check(
command: &str,
class: Option<Class>,
target: Option<&str>,
policy: Policy,
confirm: &mut dyn Confirm,
) -> Result<(), Refusal> {
let refuse = |reason, class, missing| Err(Refusal::new(reason, command, class, missing));
let Some(class) = class else {
return refuse(Reason::Unclassified, None, vec![]);
};
match class {
Class::Read => Ok(()),
Class::Write if policy.allow_write => Ok(()),
Class::Write => refuse(
Reason::AllowWriteRequired,
Some(class),
vec!["--allow-write"],
),
Class::DeviceManagement => {
let interactive = confirm.interactive();
if !policy.allow_write {
let mut missing = vec!["--allow-write"];
if !policy.yes && !interactive {
missing.push("--yes");
}
return refuse(Reason::AllowWriteRequired, Some(class), missing);
}
if policy.yes {
return Ok(());
}
if !interactive {
return refuse(Reason::YesRequired, Some(class), vec!["--yes"]);
}
let Some(target) = target else {
return refuse(Reason::NotConfirmed, Some(class), vec![]);
};
let (what, expected) = confirmation(target);
let prompt = format!(
"{command} is a device-management command. Type {what} and Enter to send: "
);
match confirm.ask(&prompt) {
Some(answer) if answer.trim() == expected => Ok(()),
_ => refuse(Reason::NotConfirmed, Some(class), vec![]),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn check(
command: &str,
target: Option<&str>,
policy: Policy,
confirm: &mut dyn Confirm,
) -> Result<(), Refusal> {
super::check(
command,
crate::core::class::of(command),
target,
policy,
confirm,
)
}
struct Fake {
interactive: bool,
answers: Vec<&'static str>,
prompts: Vec<String>,
}
impl Fake {
fn new(interactive: bool, answers: &[&'static str]) -> Self {
Self {
interactive,
answers: answers.iter().rev().copied().collect(),
prompts: vec![],
}
}
}
impl Confirm for Fake {
fn interactive(&self) -> bool {
self.interactive
}
fn ask(&mut self, prompt: &str) -> Option<String> {
self.prompts.push(prompt.to_string());
self.answers.pop().map(str::to_string)
}
}
const NONE: Policy = Policy {
allow_write: false,
yes: false,
};
const ALLOW: Policy = Policy {
allow_write: true,
yes: false,
};
const BOTH: Policy = Policy {
allow_write: true,
yes: true,
};
fn reason(result: Result<(), Refusal>) -> Option<(Reason, Vec<&'static str>)> {
result.err().map(|r| (r.reason, r.missing))
}
#[test]
fn reads_need_nothing_and_writes_need_allow_write() {
for interactive in [false, true] {
let mut tty = Fake::new(interactive, &[]);
assert_eq!(check("read", None, NONE, &mut tty), Ok(()));
assert_eq!(check("subscribe", None, NONE, &mut tty), Ok(()));
for write in [
"write",
"wp",
"writem",
"file-write",
"time-sync",
"unregister",
] {
assert_eq!(
reason(check(write, None, NONE, &mut tty)),
Some((Reason::AllowWriteRequired, vec!["--allow-write"])),
"{write}"
);
assert_eq!(check(write, None, ALLOW, &mut tty), Ok(()), "{write}");
}
assert!(tty.prompts.is_empty(), "a write never prompts");
}
}
#[test]
fn device_management_off_a_terminal_needs_yes() {
let mut pipe = Fake::new(false, &["1234"]);
for command in ["control", "dcc", "reinit"] {
assert_eq!(
reason(check(command, Some("1234"), NONE, &mut pipe)),
Some((Reason::AllowWriteRequired, vec!["--allow-write", "--yes"]))
);
assert_eq!(
reason(check(command, Some("1234"), ALLOW, &mut pipe)),
Some((Reason::YesRequired, vec!["--yes"]))
);
assert_eq!(check(command, Some("1234"), BOTH, &mut pipe), Ok(()));
}
assert!(pipe.prompts.is_empty(), "nothing is asked off a terminal");
}
#[test]
fn device_management_at_a_terminal_types_the_device_instance() {
let mut tty = Fake::new(true, &["1234"]);
assert_eq!(
reason(check("reinit", Some("1234"), NONE, &mut tty)),
Some((Reason::AllowWriteRequired, vec!["--allow-write"]))
);
assert!(tty.prompts.is_empty());
assert_eq!(check("reinit", Some("1234"), ALLOW, &mut tty), Ok(()));
assert_eq!(
tty.prompts,
["reinit is a device-management command. Type the device instance (1234) and Enter to send: "]
);
let mut tty = Fake::new(true, &["77\n", "10.0.0.5:47808\n"]);
assert_eq!(check("dcc", Some("2:77"), ALLOW, &mut tty), Ok(()));
assert_eq!(
check("dcc", Some("10.0.0.5:47808"), ALLOW, &mut tty),
Ok(())
);
let mut tty = Fake::new(true, &["123"]);
for _ in 0..2 {
assert_eq!(
reason(check("reinit", Some("1234"), ALLOW, &mut tty)),
Some((Reason::NotConfirmed, vec![]))
);
}
let mut tty = Fake::new(true, &[]);
assert_eq!(check("reinit", Some("1234"), BOTH, &mut tty), Ok(()));
assert!(tty.prompts.is_empty());
}
#[test]
fn an_unknown_command_is_refused() {
let mut tty = Fake::new(true, &[]);
assert_eq!(
reason(check("frobnicate", None, BOTH, &mut tty)),
Some((Reason::Unclassified, vec![]))
);
}
#[test]
fn the_json_refusal_shape_is_stable() {
let mut pipe = Fake::new(false, &[]);
let refusal = check("reinit", Some("1234"), NONE, &mut pipe).unwrap_err();
assert_eq!(
refusal.to_json(),
concat!(
r#"{"error":"refused","reason":"allow-write-required","command":"reinit","#,
r#""class":"device-management","missing":["--allow-write","--yes"],"#,
r#""message":"reinit is a device-management command and needs --allow-write "#,
r#"and --yes (stdin is not a terminal, so it can't be confirmed by typing); "#,
r#"nothing was sent"}"#
)
);
let refusal = check("write", None, NONE, &mut pipe).unwrap_err();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&refusal.to_json()).unwrap(),
serde_json::json!({
"error": "refused",
"reason": "allow-write-required",
"command": "write",
"class": "write",
"missing": ["--allow-write"],
"message": "write is a write command and needs --allow-write; nothing was sent",
})
);
let reasons = [
check("reinit", Some("1"), ALLOW, &mut pipe).unwrap_err(),
check("reinit", None, ALLOW, &mut Fake::new(true, &[])).unwrap_err(),
check("frobnicate", None, NONE, &mut pipe).unwrap_err(),
]
.map(|r| serde_json::to_value(&r).unwrap());
assert_eq!(reasons[0]["reason"], "yes-required");
assert_eq!(reasons[1]["reason"], "not-confirmed");
assert_eq!(reasons[2]["reason"], "unclassified");
assert_eq!(reasons[2]["class"], serde_json::Value::Null);
}
}