use std::io::{BufRead, IsTerminal as _};
use std::str::FromStr as _;
use shep_core::dogs::manifest::Manifest;
use shep_core::permissions::{Grants, Permission};
use crate::commands::copy::confirm::{Answer, Context, Go, decide, read_answer};
use crate::exit::ExitCode;
use crate::output::Streams;
const PERMISSIONS_NOTICE: &str = "dog_permissions";
const PERMISSION_DROPPED_NOTICE: &str = "dog_permission_dropped";
#[derive(Debug, Clone, Copy)]
pub(super) struct Mode {
pub(super) yes: bool,
pub(super) stdin_is_terminal: bool,
}
#[derive(Debug)]
pub(crate) struct Listed {
pub(super) name: String,
pub(super) required: bool,
pub(super) reason: String,
}
#[derive(Debug)]
pub(crate) enum Review {
Undeclared,
Declared {
listed: Vec<Listed>,
dropped: Vec<String>,
events: Vec<String>,
},
}
impl Review {
pub(super) fn events(&self) -> Vec<String> {
match self {
Self::Undeclared => Vec::new(),
Self::Declared { events, .. } => events.clone(),
}
}
}
#[derive(Debug)]
pub(super) struct UnknownRequired(Vec<String>);
impl UnknownRequired {
pub(super) fn report(&self, streams: &mut Streams<'_>, name: &str, done: &str) -> ExitCode {
let names = self
.0
.iter()
.map(|name| format!("`{name}`"))
.collect::<Vec<_>>()
.join(", ");
let message = format!(
"{name} requires {names}, which this shep does not know, so it was not {done}: \
it would run half-working. Upgrade shep, or use a version of {name} that does \
not require it"
);
streams.fail(ExitCode::InvalidConfig, &message)
}
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Verdict {
Grant(Option<Grants>),
Declined,
}
pub(super) fn review(manifest: Option<&Manifest>) -> Result<Review, UnknownRequired> {
let Some(manifest) = manifest else {
return Ok(Review::Undeclared);
};
let mut listed: Vec<Listed> = Vec::new();
let mut dropped = Vec::new();
let mut unknown_required = Vec::new();
for declared in &manifest.permissions {
match (Permission::from_str(&declared.name), declared.required) {
(Ok(_), _) if listed.iter().any(|seen| seen.name == declared.name) => {}
(Ok(_), required) => listed.push(Listed {
name: declared.name.clone(),
required,
reason: declared.reason.clone(),
}),
(Err(_), true) => unknown_required.push(declared.name.clone()),
(Err(_), false) => dropped.push(declared.name.clone()),
}
}
if unknown_required.is_empty() {
let events = manifest
.events
.iter()
.map(|event| event.kind.clone())
.collect();
Ok(Review::Declared {
listed,
dropped,
events,
})
} else {
Err(UnknownRequired(unknown_required))
}
}
#[cfg(test)]
pub(crate) fn declared_review(entries: &[(&str, bool, &str)]) -> Review {
Review::Declared {
listed: entries
.iter()
.map(|&(name, required, reason)| Listed {
name: name.to_owned(),
required,
reason: reason.to_owned(),
})
.collect(),
dropped: Vec::new(),
events: Vec::new(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Prompt {
listing: String,
question: String,
grants: Option<Grants>,
}
impl Prompt {
pub(super) fn new(listing: String, question: String, grants: Option<Grants>) -> Self {
Self {
listing,
question,
grants,
}
}
pub(crate) fn for_review(
name: &str,
verb: &str,
review: &Review,
note: Option<&str>,
) -> Option<Self> {
let (grants, listing, question) = match review {
Review::Undeclared => (
None,
format!(
"{name} declares nothing, runs with full access. {}",
not_confined(name)
),
format!("{verb} {name} with full access?"),
),
Review::Declared { listed, .. } if listed.is_empty() => return None,
Review::Declared { listed, .. } => {
let lines = listed
.iter()
.map(|entry| {
let kind = if entry.required {
"required"
} else {
"optional"
};
format!(" {} ({kind}): {}", entry.name, entry.reason)
})
.collect::<Vec<_>>()
.join("\n");
(
Some(Grants::from_names(
listed.iter().map(|entry| entry.name.clone()),
)),
format!(
"{name} declares these permissions:\n{lines}\n{}",
not_confined(name)
),
format!("{verb} {name} and grant it these?"),
)
}
};
let listing = match note {
Some(note) => format!("{listing}\n{note}"),
None => listing,
};
Some(Self::new(listing, question, grants))
}
pub(crate) fn listing(&self) -> &str {
&self.listing
}
pub(crate) fn question(&self) -> &str {
&self.question
}
pub(crate) fn grants(&self) -> Option<&Grants> {
self.grants.as_ref()
}
}
#[cfg(test)]
pub(super) fn consent(
streams: &mut Streams<'_>,
name: &str,
verb: &str,
review: &Review,
note: Option<&str>,
mode: Mode,
reader: &mut dyn BufRead,
) -> Result<Verdict, ExitCode> {
consent_asking(
streams,
name,
verb,
review,
note,
mode,
&mut |streams, prompt| ask_aloud(streams, prompt, reader),
)
}
fn ask_aloud(streams: &mut Streams<'_>, prompt: &Prompt, reader: &mut dyn BufRead) -> Answer {
streams.aside("confirm", &format!("{} [y/N] ", prompt.question()));
read_answer(reader)
}
fn consent_asking(
streams: &mut Streams<'_>,
name: &str,
verb: &str,
review: &Review,
note: Option<&str>,
mode: Mode,
ask: &mut dyn FnMut(&mut Streams<'_>, &Prompt) -> Answer,
) -> Result<Verdict, ExitCode> {
if let Review::Declared { dropped, .. } = review {
for dropped in dropped {
streams.aside(
PERMISSION_DROPPED_NOTICE,
&format!(
"{name} asks for the optional permission `{dropped}`, which this shep \
does not know; it is dropped and not granted"
),
);
}
}
let Some(prompt) = Prompt::for_review(name, verb, review, note) else {
if let Some(note) = note {
streams.aside(PERMISSIONS_NOTICE, note);
}
return Ok(Verdict::Grant(Some(Grants::default())));
};
let go = decide(Context {
dry_run: false,
yes: mode.yes,
stdin_is_terminal: mode.stdin_is_terminal,
has_changes: true,
});
if go == Go::Refuse {
return Err(streams.fail(
ExitCode::Usage,
&format!(
"nothing was changed: `shep {verb}` asks before it grants a dog its \
permissions, and this run has no terminal to ask on. Pass --yes to grant \
everything {name} declares"
),
));
}
streams.aside(PERMISSIONS_NOTICE, prompt.listing());
if go == Go::Ask {
match ask(streams, &prompt) {
Answer::Yes => {}
Answer::No => return Ok(Verdict::Declined),
Answer::Failed(reason) => {
return Err(streams.fail(
ExitCode::Failure,
&format!("nothing was changed: the question could not be read ({reason})"),
));
}
}
}
Ok(Verdict::Grant(prompt.grants().cloned()))
}
pub(crate) struct Input(Source);
enum Source {
Reading {
is_terminal: bool,
reader: Box<dyn BufRead + Send>,
},
Answering(Box<dyn FnMut(&Prompt) -> bool + Send>),
}
impl Input {
pub(super) fn stdin() -> Self {
Self::reading(
std::io::stdin().is_terminal(),
std::io::BufReader::new(std::io::stdin()),
)
}
pub(super) fn reading(is_terminal: bool, reader: impl BufRead + Send + 'static) -> Self {
Self(Source::Reading {
is_terminal,
reader: Box::new(reader),
})
}
pub(crate) fn answering(answer: impl FnMut(&Prompt) -> bool + Send + 'static) -> Self {
Self(Source::Answering(Box::new(answer)))
}
pub(super) fn is_terminal(&self) -> bool {
match &self.0 {
Source::Reading { is_terminal, .. } => *is_terminal,
Source::Answering(_) => true,
}
}
pub(super) fn read_line(&mut self) -> std::io::Result<Option<String>> {
let Source::Reading { reader, .. } = &mut self.0 else {
return Ok(None);
};
let mut line = String::new();
Ok((reader.read_line(&mut line)? > 0).then_some(line))
}
pub(super) fn ask(&mut self, streams: &mut Streams<'_>, prompt: &Prompt) -> Answer {
match &mut self.0 {
Source::Reading { reader, .. } => ask_aloud(streams, prompt, reader),
Source::Answering(answer) => {
if answer(prompt) {
Answer::Yes
} else {
Answer::No
}
}
}
}
}
pub(super) fn consent_from(
streams: &mut Streams<'_>,
name: &str,
verb: &str,
review: &Review,
note: Option<&str>,
yes: bool,
input: &mut Input,
) -> Result<Verdict, ExitCode> {
let mode = Mode {
yes,
stdin_is_terminal: input.is_terminal(),
};
consent_asking(
streams,
name,
verb,
review,
note,
mode,
&mut |streams, prompt| input.ask(streams, prompt),
)
}
pub(super) fn not_confined(name: &str) -> String {
format!(
"Grants keep {name}'s requests in bounds and do not confine it: it runs as you and \
can do anything you can."
)
}