From 4f6c374a665d0fb3b9c610088fa7747e539964a1 Mon Sep 17 00:00:00 2001 From: noctuum <25441068+noctuum@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:13:40 +0500 Subject: [PATCH] feat(cli): log in to AniList and out of it A token AniList takes is kept, and the pasted token is not shown. --- Cargo.lock | 3 + crates/benshi/Cargo.toml | 1 + crates/benshi/src/main.rs | 2 + crates/cli/Cargo.toml | 6 + crates/cli/src/client.rs | 44 +- crates/cli/src/commands.rs | 733 +++++++++++++++++- crates/cli/src/lib.rs | 20 +- crates/cli/src/prompt.rs | 303 ++++++++ crates/cli/src/render.rs | 68 +- .../the_client_sees_what_the_daemon_does.rs | 32 +- crates/core/src/lib.rs | 1 + crates/core/src/token.rs | 59 ++ crates/daemon/Cargo.toml | 1 + crates/daemon/src/accounts.rs | 389 ++++++++++ crates/daemon/src/ipc.rs | 244 +++++- crates/daemon/src/lib.rs | 12 +- crates/daemon/src/protocol.rs | 62 +- crates/daemon/tests/login_live.rs | 62 ++ crates/daemon/tests/the_daemon_runs.rs | 18 + .../daemon/tests/the_session_bus_is_found.rs | 75 +- crates/sync/src/anilist/transport.rs | 16 +- crates/sync/tests/anilist_record.rs | 8 +- 22 files changed, 2099 insertions(+), 60 deletions(-) create mode 100644 crates/cli/src/prompt.rs create mode 100644 crates/core/src/token.rs create mode 100644 crates/daemon/src/accounts.rs create mode 100644 crates/daemon/tests/login_live.rs diff --git a/Cargo.lock b/Cargo.lock index 9386f30..1874d80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,6 +137,7 @@ dependencies = [ "benshi-daemon", "benshi-detect", "benshi-store", + "benshi-sync", "benshi-tray", "clap", "tempfile", @@ -154,6 +155,7 @@ dependencies = [ "benshi-store", "clap", "humantime", + "rustix", "serde", "serde_json", "socket2", @@ -179,6 +181,7 @@ name = "benshi-daemon" version = "0.1.0" dependencies = [ "anyhow", + "base64", "benshi-core", "benshi-detect", "benshi-sinks", diff --git a/crates/benshi/Cargo.toml b/crates/benshi/Cargo.toml index bb4300d..74f32da 100644 --- a/crates/benshi/Cargo.toml +++ b/crates/benshi/Cargo.toml @@ -18,6 +18,7 @@ benshi-core = { version = "0.1.0", path = "../core" } benshi-daemon = { path = "../daemon" } benshi-detect = { version = "0.1.0", path = "../detect" } benshi-store = { version = "0.1.0", path = "../store" } +benshi-sync = { path = "../sync" } benshi-tray = { path = "../tray" } clap = { version = "4.6.7", features = ["derive"] } tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread", "signal"] } diff --git a/crates/benshi/src/main.rs b/crates/benshi/src/main.rs index 2b83021..43ab9bc 100644 --- a/crates/benshi/src/main.rs +++ b/crates/benshi/src/main.rs @@ -291,6 +291,7 @@ async fn serve(voice: &Arc) -> u8 { use std::time::SystemTime; use benshi_core::clock::SystemClock; + use benshi_daemon::accounts::OnAniList; use benshi_daemon::config; use benshi_daemon::paths::{DATABASE, Role, directory}; use benshi_daemon::recognition::Recogniser; @@ -368,6 +369,7 @@ async fn serve(voice: &Arc) -> u8 { store, SystemTime::now, POLL_INTERVAL, + Arc::new(OnAniList::new(benshi_sync::anilist::transport::ANILIST)), // Said as it happens: the socket goes on answering once detection // has stopped, and the process does not end to say so. The // supervisor deals with no task that ended until this returns, so it diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index ca4412c..6b896f4 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -27,3 +27,9 @@ benshi-store = { path = "../store" } socket2 = "0.6.5" tempfile = "3.27.0" tokio = { version = "1.53.1", features = ["test-util"] } + +[target.'cfg(unix)'.dependencies] +rustix = { version = "1.1.4", features = ["termios"] } + +[target.'cfg(unix)'.dev-dependencies] +rustix = { version = "1.1.4", features = ["termios", "pty"] } diff --git a/crates/cli/src/client.rs b/crates/cli/src/client.rs index ebe49eb..8de4185 100644 --- a/crates/cli/src/client.rs +++ b/crates/cli/src/client.rs @@ -6,7 +6,9 @@ //! //! A connection waits for its first answer until [`DEADLINE`] from the moment //! it began to connect, and no longer. What follows the first answer of a -//! `watch` is a stream, which waits for as long as the user does. +//! `watch` is a stream, which waits for as long as the user does, and what +//! follows that of a `login` or a `logout` waits for as long as the daemon +//! takes, which may be as long as a person takes to unlock the secret store. use std::io; use std::path::{Path, PathBuf}; @@ -144,16 +146,36 @@ impl Daemon { .await .map_err(|_elapsed| did_not_answer(&self.socket))?; - match answered { - Ok(Some(answer)) => Ok(answer), - Ok(None) => bail!(CLOSED_WITHOUT_ANSWERING), - Err(failure) => match failure.downcast_ref::() { - Some(reset) if reset.kind() == io::ErrorKind::ConnectionReset => { - bail!("{CLOSED_WITHOUT_ANSWERING}: {reset}") - } - _ => Err(failure), - }, - } + unless_closed(answered) + } + + /// The answer that follows the first, failing when the daemon closed + /// instead. + /// + /// Waits for as long as the daemon takes, for a request whose second + /// answer may wait on a person. The daemon gives each step of such a + /// request a deadline of its own. + /// + /// # Errors + /// + /// As [`Daemon::expect_answer`], and never that the daemon did not + /// answer in time. + pub async fn expect_outcome(&mut self) -> Result { + unless_closed(self.answer().await) + } +} + +/// An answer, or that the daemon closed the connection without one. +fn unless_closed(answered: Result>) -> Result { + match answered { + Ok(Some(answer)) => Ok(answer), + Ok(None) => bail!(CLOSED_WITHOUT_ANSWERING), + Err(failure) => match failure.downcast_ref::() { + Some(reset) if reset.kind() == io::ErrorKind::ConnectionReset => { + bail!("{CLOSED_WITHOUT_ANSWERING}: {reset}") + } + _ => Err(failure), + }, } } diff --git a/crates/cli/src/commands.rs b/crates/cli/src/commands.rs index 3110b4d..af088b1 100644 --- a/crates/cli/src/commands.rs +++ b/crates/cli/src/commands.rs @@ -5,7 +5,8 @@ //! the daemon or the user ends it. `record` wants both an answer and a stream, so //! it takes a second connection for the answer: a stream is the last thing a //! connection carries. What it records goes to a file, and only a summary of -//! it to the terminal. +//! it to the terminal. `login` and `logout` are answered twice: once the +//! daemon has read the request, and with what it came to. //! //! A failure is reported here rather than returned. This is where a `Result` //! stops being something a caller can act on and becomes a line on the error @@ -13,13 +14,14 @@ use std::collections::BTreeSet; use std::fs::File; -use std::io::Write; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::time::{Duration, SystemTime}; use anyhow::{Context, Result, bail}; use benshi_core::policy::Policy; +use benshi_core::token::Token; use benshi_core::trace::{TraceHeader, header_line, snapshot_line}; use benshi_core::{AppName, PlayerId, SourceInfo}; use benshi_daemon::bus::BusEvent; @@ -28,6 +30,7 @@ use clap::{Subcommand, ValueEnum}; use humantime::format_rfc3339_seconds; use crate::client::Daemon; +use crate::prompt::Typed; use crate::render; /// What a client can ask for. @@ -75,6 +78,16 @@ pub enum Command { #[arg(long, short, value_name = "PATH")] output: PathBuf, }, + + /// Log in to AniList, keeping the token in the secret store. + /// + /// Prints the address of the page where AniList gives a token for + /// benshi, and reads the token pasted from it without showing it. The + /// daemon asks AniList whose the token is before it keeps it. + Login, + + /// Remove the AniList token from the secret store. + Logout, } /// A policy as it is typed on the command line. @@ -109,10 +122,11 @@ impl From for Policy { pub async fn run( command: Command, socket: &Path, + typed: impl FnOnce() -> io::Result, out: &mut impl Write, err: &mut impl Write, ) -> ExitCode { - match carry_out(command, socket, out).await { + match carry_out(command, socket, typed, out, err).await { Ok(()) => ExitCode::SUCCESS, Err(failure) => { // `{:#}` puts the cause on the same line as the context, so that @@ -131,7 +145,13 @@ pub async fn run( } /// Run one command, failing rather than reporting. -async fn carry_out(command: Command, socket: &Path, out: &mut impl Write) -> Result<()> { +async fn carry_out( + command: Command, + socket: &Path, + typed: impl FnOnce() -> io::Result, + out: &mut impl Write, + err: &mut impl Write, +) -> Result<()> { let mut daemon = Daemon::connect(socket).await?; match command { @@ -142,6 +162,15 @@ async fn carry_out(command: Command, socket: &Path, out: &mut impl Write) -> Res Command::Record { duration, output } => { record(&mut daemon, socket, duration, &output, out).await } + // The connection says that a daemon is there before a token is asked + // for. The login goes on one of its own, made once the token is read: + // the deadline of a connection runs from when it is made, and a + // person takes longer than that to paste. + Command::Login => { + drop(daemon); + login(socket, typed, out, err).await + } + Command::Logout => logout(&mut daemon, out).await, } } @@ -195,10 +224,80 @@ async fn sources(daemon: &mut Daemon, out: &mut impl Write) -> Result<()> { async fn subscribe(daemon: &mut Daemon) -> Result<()> { daemon.ask(&Request::Watch).await?; + taken(daemon, "a watch").await +} + +/// Read the answer that says a request is read, and that what it comes to +/// follows. `asked` names the request in a line about an answer of another +/// kind. +async fn taken(daemon: &mut Daemon, asked: &str) -> Result<()> { match daemon.expect_answer().await? { Response::Ok => Ok(()), Response::Error { message } => bail!("{message}"), - unexpected => bail!("daemon answered a watch with {unexpected:?}"), + unexpected => bail!("daemon answered {asked} with {unexpected:?}"), + } +} + +/// The page where AniList gives a token for benshi's application. +const AUTHORIZE: &str = + "https://anilist.co/api/v2/oauth/authorize?client_id=53750&response_type=token"; + +/// Ask for a token, have the daemon check and keep it, and print whose it is. +async fn login( + socket: &Path, + typed: impl FnOnce() -> io::Result, + out: &mut impl Write, + err: &mut impl Write, +) -> Result<()> { + write!( + err, + "open this address and approve benshi:\n{AUTHORIZE}\n\ + paste the token AniList shows (input is hidden): " + ) + .and_then(|()| err.flush()) + .context("prompt could not be printed")?; + // Holds the runtime while a person pastes, and nothing else is running + // on it: no connection is open. + let typed = typed(); + // Nothing typed was shown, the end of the line included. + writeln!(err).context("prompt could not be printed")?; + let token = match typed.context("token could not be read")? { + Typed::Cancelled => bail!("login cancelled"), + Typed::Line(line) => line.secret().trim().to_owned(), + }; + if token.is_empty() { + bail!("no token given"); + } + + let mut daemon = Daemon::connect(socket).await?; + daemon + .ask(&Request::Login { + token: Token::new(token), + }) + .await?; + taken(&mut daemon, "a login").await?; + + match daemon.expect_outcome().await? { + Response::LoggedIn { name, expires } => { + write!(out, "{}", render::logged_in(&name, expires)) + .context("login could not be printed") + } + Response::Error { message } => bail!("{message}"), + unexpected => bail!("daemon answered a login with {unexpected:?}"), + } +} + +/// Have the daemon remove the token, and say that it is gone. +async fn logout(daemon: &mut Daemon, out: &mut impl Write) -> Result<()> { + daemon.ask(&Request::Logout).await?; + taken(daemon, "a logout").await?; + + match daemon.expect_outcome().await? { + Response::LoggedOut => { + write!(out, "{}", render::LOGGED_OUT).context("logout could not be printed") + } + Response::Error { message } => bail!("{message}"), + unexpected => bail!("daemon answered a logout with {unexpected:?}"), } } @@ -416,19 +515,23 @@ fn append(file: &mut File, line: &str, output: &Path) -> Result<()> { mod tests { use super::{Command, run}; use crate::client::DEADLINE; + use crate::prompt::Typed; use benshi_core::clock::{Clock, TestClock}; use benshi_core::path::RawPath; use benshi_core::policy::{Policy, PolicyTable}; + use benshi_core::token::Token; use benshi_core::trace::Trace; use benshi_core::{ AppName, Capabilities, Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, SessionState, SourceInfo, }; + use benshi_daemon::accounts::{self, Accounts, Done, LoggedIn}; use benshi_daemon::bus::{BusEvent, EventBus}; use benshi_daemon::detection::Seen; use benshi_daemon::ipc::{Server, bind}; use benshi_daemon::protocol::{Request, Response}; use benshi_daemon::recognition::{Decided, Decision, Recogniser}; + use benshi_daemon::secrets; use benshi_daemon::supervisor::TaskError; use socket2::{Domain, SockAddr, Socket, Type}; use std::io::Write; @@ -577,6 +680,67 @@ mod tests { } } + /// When the token of a login runs out: 2030-03-17 17:46:40 UTC. + const EXP: u64 = 1_900_000_000; + + /// Logins and logouts that go through, or fail as they do where the + /// secret store is locked, with what each was asked. + #[derive(Debug, Default)] + struct Logins { + failing: bool, + asked: Mutex>, + } + + impl Logins { + fn failing() -> Self { + Self { + failing: true, + ..Self::default() + } + } + + fn asked(&self) -> Vec { + self.asked + .lock() + .expect("no test panics while it holds this") + .clone() + } + + fn ask(&self, what: String) { + self.asked + .lock() + .expect("no test panics while it holds this") + .push(what); + } + } + + impl Accounts for Logins { + fn login(&self, token: Token) -> Done<'_, LoggedIn> { + self.ask(format!("login {}", token.secret())); + let logged = if self.failing { + Err(accounts::Error::NotKept(secrets::Error::Locked)) + } else { + Ok(LoggedIn { + name: "someone".to_owned(), + expires: Some(EXP), + }) + }; + + Box::pin(std::future::ready(logged)) + } + + fn logout(&self) -> Done<'_, ()> { + self.ask("logout".to_owned()); + let logged_out = if self.failing { + Err(accounts::Error::NotRemoved(secrets::Error::Locked)) + } else { + Ok(()) + }; + + Box::pin(std::future::ready(logged_out)) + } + } + /// A daemon listening on a socket of its own. struct Daemon { home: TempDir, @@ -584,6 +748,7 @@ mod tests { bus: Arc, policy: Arc>, decided: Decided, + accounts: Arc, serving: JoinHandle>, } @@ -604,6 +769,20 @@ mod tests { Self::serving(home, socket, listener, sources, bus) } + /// A daemon whose logins and logouts go as `accounts` makes them. + fn logging_in(accounts: Logins) -> Self { + let (home, socket, listener) = a_socket_bound_and_not_served(); + + Self::built( + home, + socket, + listener, + Vec::new(), + EventBus::new(), + accounts, + ) + } + /// A daemon serving a socket that was bound before, as the daemon's /// is once its database is open. fn serving( @@ -612,17 +791,30 @@ mod tests { listener: UnixListener, sources: Vec, bus: EventBus, + ) -> Self { + Self::built(home, socket, listener, sources, bus, Logins::default()) + } + + fn built( + home: TempDir, + socket: PathBuf, + listener: UnixListener, + sources: Vec, + bus: EventBus, + accounts: Logins, ) -> Self { let bus = Arc::new(bus); let policy = Arc::new(RwLock::new(PolicyTable::allowing_video_players())); let seen = Seen::new(); seen.record(sources); let decided = Decided::new(); + let accounts = Arc::new(accounts); let server = Arc::new(Server::new( Arc::clone(&bus), Arc::clone(&policy), seen, decided.clone(), + Arc::clone(&accounts) as Arc, )); Self { @@ -631,6 +823,7 @@ mod tests { bus, policy, decided, + accounts, serving: tokio::spawn(server.listen(Arc::new(listener))), } } @@ -691,7 +884,7 @@ mod tests { let watching = tokio::spawn({ let mut out = out.clone(); let mut err = err.clone(); - async move { run(Command::Watch, &socket, &mut out, &mut err).await } + async move { run(Command::Watch, &socket, untyped, &mut out, &mut err).await } }); self.subscribed().await; @@ -728,7 +921,7 @@ mod tests { let running = tokio::spawn({ let mut out = out.clone(); let mut err = err.clone(); - async move { run(command, &socket, &mut out, &mut err).await } + async move { run(command, &socket, untyped, &mut out, &mut err).await } }); self.subscribed().await; @@ -897,13 +1090,69 @@ mod tests { } async fn go(command: Command, socket: &Path) -> (ExitCode, String, String) { + go_typing(command, socket, Typed::Line(Token::new(PASTED))).await + } + + /// [`go`], with `typed` typed at a prompt. + async fn go_typing( + command: Command, + socket: &Path, + typed: Typed, + ) -> (ExitCode, String, String) { let mut out = Shared::default(); let mut err = Shared::default(); - let outcome = run(command, socket, &mut out, &mut err).await; + let outcome = run(command, socket, || Ok(typed), &mut out, &mut err).await; (outcome, out.text(), err.text()) } + /// [`go`], with the moment the command's deadline runs from: for a + /// login, the moment its token is typed, since it connects for its + /// request only then, and for any other command the moment it starts. + async fn go_timed(command: Command, socket: &Path) -> (Instant, ExitCode, String, String) { + let from = Arc::new(Mutex::new(Instant::now())); + let typed_at = Arc::clone(&from); + let mut out = Shared::default(); + let mut err = Shared::default(); + let outcome = run( + command, + socket, + move || { + *typed_at.lock().expect("no test panics while it holds this") = Instant::now(); + Ok(Typed::Line(Token::new(PASTED))) + }, + &mut out, + &mut err, + ) + .await; + let from = *from.lock().expect("no test panics while it holds this"); + + (from, outcome, out.text(), err.text()) + } + + /// What a command that reads nothing typed is given to read. + fn untyped() -> std::io::Result { + unreachable!("this command reads nothing typed") + } + + /// The token pasted at a login. + const PASTED: &str = "a.pasted.token"; + + /// What a login asks before it reads a token, and the end of the line + /// the token was pasted on. + const ASKED: &str = "open this address and approve benshi:\n\ + https://anilist.co/api/v2/oauth/authorize?client_id=53750&response_type=token\n\ + paste the token AniList shows (input is hidden): \n"; + + /// What `command` says before it is answered: a login asks for a token. + fn asked_first(command: &Command) -> &'static str { + if *command == Command::Login { + ASKED + } else { + "" + } + } + #[tokio::test] async fn sources_prints_what_the_daemon_listed() { let daemon = Daemon::listening(vec![ @@ -1092,7 +1341,7 @@ mod tests { } /// Every command, with the trace of a recording beside `socket`. - fn every_command(socket: &Path) -> [Command; 5] { + fn every_command(socket: &Path) -> [Command; 7] { [ Command::Sources, Command::Watch, @@ -1105,6 +1354,8 @@ mod tests { duration: Duration::from_secs(60), output: socket.with_file_name("trace.jsonl"), }, + Command::Login, + Command::Logout, ] } @@ -1162,15 +1413,16 @@ mod tests { let (_home, socket, listener) = a_socket_bound_and_not_served(); for command in every_command(&socket) { - let began = Instant::now(); - let (outcome, shown, complained) = timeout(ENDED_BY, go(command, &socket)) + let asked = asked_first(&command); + let named = format!("{command:?}"); + let (from, outcome, shown, complained) = timeout(ENDED_BY, go_timed(command, &socket)) .await .expect("the client gave up"); - assert_eq!(began.elapsed(), DEADLINE, "gave up at another time"); + assert_eq!(from.elapsed(), DEADLINE, "{named} gave up at another time"); assert_eq!(outcome, ExitCode::FAILURE, "succeeded without an answer"); assert!(shown.is_empty(), "printed an answer it never got: {shown}"); - assert_eq!(complained, did_not_answer(&socket)); + assert_eq!(complained, format!("{asked}{}", did_not_answer(&socket))); } assert!( @@ -1308,15 +1560,16 @@ mod tests { }); for command in every_command(&socket) { - let began = Instant::now(); - let (outcome, shown, complained) = timeout(ENDED_BY, go(command, &socket)) + let asked = asked_first(&command); + let named = format!("{command:?}"); + let (from, outcome, shown, complained) = timeout(ENDED_BY, go_timed(command, &socket)) .await .expect("the client gave up"); - assert_eq!(began.elapsed(), DEADLINE, "gave up at another time"); + assert_eq!(from.elapsed(), DEADLINE, "{named} gave up at another time"); assert_eq!(outcome, ExitCode::FAILURE, "succeeded without an answer"); assert!(shown.is_empty(), "printed an answer it never got: {shown}"); - assert_eq!(complained, did_not_answer(&socket)); + assert_eq!(complained, format!("{asked}{}", did_not_answer(&socket))); } assert!( @@ -1442,13 +1695,17 @@ mod tests { let reset = std::io::Read::read(&mut asking, &mut [0]).expect_err("the end is reset"); for command in every_command(&socket) { + let asked = asked_first(&command); let (outcome, shown, complained) = timeout(PATIENCE, go(command, &socket)) .await .expect("the client ended"); assert_eq!(outcome, ExitCode::FAILURE, "succeeded without an answer"); assert!(shown.is_empty(), "printed an answer it never got: {shown}"); - assert_eq!(complained, format!("{CLOSED_WITHOUT_ANSWERING}: {reset}\n")); + assert_eq!( + complained, + format!("{asked}{CLOSED_WITHOUT_ANSWERING}: {reset}\n") + ); } assert!( @@ -1463,13 +1720,14 @@ mod tests { let (_home, socket, hanging_up) = a_daemon_that_hangs_up(true); for command in every_command(&socket) { + let asked = asked_first(&command); let (outcome, shown, complained) = timeout(PATIENCE, go(command, &socket)) .await .expect("the client ended"); assert_eq!(outcome, ExitCode::FAILURE, "succeeded without an answer"); assert!(shown.is_empty(), "printed an answer it never got: {shown}"); - assert_eq!(complained, format!("{CLOSED_WITHOUT_ANSWERING}\n")); + assert_eq!(complained, format!("{asked}{CLOSED_WITHOUT_ANSWERING}\n")); } assert!( @@ -1956,7 +2214,14 @@ mod tests { let (_home, socket) = nowhere(); let mut out = Shared::default(); - let outcome = run(Command::Sources, &socket, &mut out, &mut Unwritable).await; + let outcome = run( + Command::Sources, + &socket, + untyped, + &mut out, + &mut Unwritable, + ) + .await; assert_eq!(outcome, ExitCode::FAILURE); assert!(out.text().is_empty(), "printed {:?}", out.text()); @@ -1967,7 +2232,14 @@ mod tests { let daemon = Daemon::listening(vec![a_source(PLAYER, "mpv")]); let mut err = Shared::default(); - let outcome = run(Command::Sources, &daemon.socket, &mut Unwritable, &mut err).await; + let outcome = run( + Command::Sources, + &daemon.socket, + untyped, + &mut Unwritable, + &mut err, + ) + .await; assert_eq!(outcome, ExitCode::FAILURE); assert_eq!( @@ -1981,7 +2253,14 @@ mod tests { let daemon = Daemon::listening(Vec::new()); let mut err = Shared::default(); - let outcome = run(Command::Why, &daemon.socket, &mut Unwritable, &mut err).await; + let outcome = run( + Command::Why, + &daemon.socket, + untyped, + &mut Unwritable, + &mut err, + ) + .await; assert_eq!(outcome, ExitCode::FAILURE); assert_eq!( @@ -1997,7 +2276,7 @@ mod tests { let socket = daemon.socket.clone(); let watching = tokio::spawn({ let mut err = err.clone(); - async move { run(Command::Watch, &socket, &mut Unwritable, &mut err).await } + async move { run(Command::Watch, &socket, untyped, &mut Unwritable, &mut err).await } }); daemon.subscribed().await; @@ -2023,7 +2302,7 @@ mod tests { let socket = daemon.socket.clone(); let watching = tokio::spawn({ let mut err = err.clone(); - async move { run(Command::Watch, &socket, &mut Unflushable, &mut err).await } + async move { run(Command::Watch, &socket, untyped, &mut Unflushable, &mut err).await } }); daemon.subscribed().await; @@ -2058,7 +2337,7 @@ mod tests { let mut err = err.clone(); async move { let mut out = WritableForALine::default(); - run(command, &socket, &mut out, &mut err).await + run(command, &socket, untyped, &mut out, &mut err).await } }); daemon.subscribed().await; @@ -2092,7 +2371,13 @@ mod tests { let outcome = timeout( PATIENCE, - run(recording, &daemon.socket, &mut Unwritable, &mut err), + run( + recording, + &daemon.socket, + untyped, + &mut Unwritable, + &mut err, + ), ) .await .expect("the recording ended"); @@ -2103,4 +2388,400 @@ mod tests { "benshi: summary could not be printed: broken pipe\n" ); } + + #[tokio::test] + async fn a_login_asks_for_a_token_and_says_whose_it_is() { + let daemon = Daemon::logging_in(Logins::default()); + + let (outcome, shown, complained) = timeout(PATIENCE, go(Command::Login, &daemon.socket)) + .await + .expect("the client ended"); + + assert_eq!(outcome, ExitCode::SUCCESS, "stderr said {complained:?}"); + assert_eq!(complained, ASKED); + assert_eq!( + shown, + "logged in to AniList as someone, token valid until 2030-03-17 17:46 UTC\n" + ); + assert_eq!(daemon.accounts.asked(), [format!("login {PASTED}")]); + } + + #[tokio::test] + async fn a_token_is_sent_without_the_space_pasted_around_it() { + let daemon = Daemon::logging_in(Logins::default()); + + let (outcome, _shown, complained) = timeout( + PATIENCE, + go_typing( + Command::Login, + &daemon.socket, + Typed::Line(Token::new(format!(" \t{PASTED} \r"))), + ), + ) + .await + .expect("the client ended"); + + assert_eq!(outcome, ExitCode::SUCCESS, "stderr said {complained:?}"); + assert_eq!(daemon.accounts.asked(), [format!("login {PASTED}")]); + } + + #[tokio::test] + async fn nothing_pasted_is_no_login() { + let daemon = Daemon::logging_in(Logins::default()); + + for nothing in ["", " \t "] { + let (outcome, shown, complained) = timeout( + PATIENCE, + go_typing( + Command::Login, + &daemon.socket, + Typed::Line(Token::new(nothing)), + ), + ) + .await + .expect("the client ended"); + + assert_eq!(outcome, ExitCode::FAILURE, "{nothing:?}"); + assert!(shown.is_empty(), "{shown}"); + assert_eq!(complained, format!("{ASKED}benshi: no token given\n")); + } + assert!(daemon.accounts.asked().is_empty()); + } + + #[tokio::test] + async fn a_login_cancelled_at_the_prompt_is_no_login() { + let daemon = Daemon::logging_in(Logins::default()); + + let (outcome, shown, complained) = timeout( + PATIENCE, + go_typing(Command::Login, &daemon.socket, Typed::Cancelled), + ) + .await + .expect("the client ended"); + + assert_eq!(outcome, ExitCode::FAILURE); + assert!(shown.is_empty(), "{shown}"); + assert_eq!(complained, format!("{ASKED}benshi: login cancelled\n")); + assert!(daemon.accounts.asked().is_empty()); + } + + #[tokio::test] + async fn a_token_that_cannot_be_read_is_no_login_and_says_why() { + let daemon = Daemon::logging_in(Logins::default()); + let mut out = Shared::default(); + let mut err = Shared::default(); + + let outcome = timeout( + PATIENCE, + run( + Command::Login, + &daemon.socket, + || Err(std::io::Error::other("the terminal went away")), + &mut out, + &mut err, + ), + ) + .await + .expect("the client ended"); + + assert_eq!(outcome, ExitCode::FAILURE); + assert!(out.text().is_empty(), "{}", out.text()); + assert_eq!( + err.text(), + format!("{ASKED}benshi: token could not be read: the terminal went away\n") + ); + assert!(daemon.accounts.asked().is_empty()); + } + + #[tokio::test] + async fn a_login_the_daemon_could_not_carry_out_says_why() { + let daemon = Daemon::logging_in(Logins::failing()); + + let (outcome, shown, complained) = timeout(PATIENCE, go(Command::Login, &daemon.socket)) + .await + .expect("the client ended"); + + assert_eq!(outcome, ExitCode::FAILURE); + assert!(shown.is_empty(), "{shown}"); + assert_eq!( + complained, + format!("{ASKED}benshi: token could not be kept: secret store is locked\n") + ); + } + + /// A socket served by something that answers each request it reads with + /// the answer that it is read, and with `then` once `after` has passed. + fn a_daemon_that_answers_late( + after: Duration, + then: &Response, + ) -> (TempDir, PathBuf, JoinHandle<()>) { + let (home, socket, listener) = a_socket_bound_and_not_served(); + let first = line_of(&Response::Ok); + let then = line_of(then); + let answering = tokio::spawn(async move { + loop { + let (stream, _peer) = listener.accept().await.expect("a client arrived"); + let (reader, mut writer) = tokio::io::split(stream); + let mut requests = BufReader::new(reader).lines(); + while let Ok(Some(_request)) = requests.next_line().await { + writer.write_all(&first).await.expect("the client reads"); + tokio::time::sleep(after).await; + writer.write_all(&then).await.expect("the client reads"); + } + } + }); + + (home, socket, answering) + } + + #[tokio::test(start_paused = true)] + async fn a_login_waits_past_the_deadline_for_what_it_comes_to() { + // The secret store may ask a person to unlock it, and the daemon + // waits for them. The answer that the login is read is what comes + // within the deadline. + let (_home, socket, answering) = a_daemon_that_answers_late( + DEADLINE * 3, + &Response::LoggedIn { + name: "someone".to_owned(), + expires: None, + }, + ); + + let (outcome, shown, complained) = settled(started(Command::Login, &socket)).await; + answering.abort(); + + assert_eq!(outcome, ExitCode::SUCCESS, "stderr said {complained:?}"); + assert_eq!( + shown, + "logged in to AniList as someone; token does not say when it runs out\n" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_logout_waits_past_the_deadline_for_what_it_comes_to() { + let (_home, socket, answering) = + a_daemon_that_answers_late(DEADLINE * 3, &Response::LoggedOut); + + let (outcome, shown, complained) = settled(started(Command::Logout, &socket)).await; + answering.abort(); + + assert_eq!(outcome, ExitCode::SUCCESS, "stderr said {complained:?}"); + assert_eq!(shown, "AniList token removed\n"); + } + + #[tokio::test] + async fn a_login_or_a_logout_answered_with_another_kind_of_answer_says_so() { + for command in [Command::Login, Command::Logout] { + let asked = asked_first(&command); + let named = if command == Command::Login { + "a login" + } else { + "a logout" + }; + let (_home, socket, answering) = + a_daemon_that_answers_late(Duration::ZERO, &Response::Why(None)); + + let (outcome, shown, complained) = timeout(PATIENCE, go(command, &socket)) + .await + .expect("the client ended"); + answering.abort(); + + assert_eq!(outcome, ExitCode::FAILURE); + assert!(shown.is_empty(), "{shown}"); + assert_eq!( + complained, + format!("{asked}benshi: daemon answered {named} with Why(None)\n") + ); + } + } + + #[tokio::test] + async fn a_login_or_a_logout_refused_at_once_says_why() { + for command in [Command::Login, Command::Logout] { + let asked = asked_first(&command); + let (_home, socket, answering) = a_daemon_that_answers_with(&Response::Error { + message: "not today".to_owned(), + }); + + let (outcome, shown, complained) = timeout(PATIENCE, go(command, &socket)) + .await + .expect("the client ended"); + answering.abort(); + + assert_eq!(outcome, ExitCode::FAILURE); + assert!(shown.is_empty(), "{shown}"); + assert_eq!(complained, format!("{asked}benshi: not today\n")); + } + } + + #[tokio::test] + async fn a_logout_says_the_token_is_removed() { + let daemon = Daemon::logging_in(Logins::default()); + + let (outcome, shown, complained) = timeout(PATIENCE, go(Command::Logout, &daemon.socket)) + .await + .expect("the client ended"); + + assert_eq!(outcome, ExitCode::SUCCESS, "stderr said {complained:?}"); + assert!(complained.is_empty(), "{complained}"); + assert_eq!(shown, "AniList token removed\n"); + assert_eq!(daemon.accounts.asked(), ["logout"]); + } + + #[tokio::test] + async fn a_logout_the_daemon_could_not_carry_out_says_why() { + let daemon = Daemon::logging_in(Logins::failing()); + + let (outcome, shown, complained) = timeout(PATIENCE, go(Command::Logout, &daemon.socket)) + .await + .expect("the client ended"); + + assert_eq!(outcome, ExitCode::FAILURE); + assert!(shown.is_empty(), "{shown}"); + assert_eq!( + complained, + "benshi: token could not be removed: secret store is locked\n" + ); + } + + /// A socket served by something that answers each request it reads with + /// the answer that it is read, and closes. + fn a_daemon_that_reads_and_goes() -> (TempDir, PathBuf, JoinHandle<()>) { + let (home, socket, listener) = a_socket_bound_and_not_served(); + let first = line_of(&Response::Ok); + let answering = tokio::spawn(async move { + loop { + let (stream, _peer) = listener.accept().await.expect("a client arrived"); + let (reader, mut writer) = tokio::io::split(stream); + let mut requests = BufReader::new(reader).lines(); + if let Ok(Some(_request)) = requests.next_line().await { + writer.write_all(&first).await.expect("the client reads"); + } + } + }); + + (home, socket, answering) + } + + #[tokio::test] + async fn a_login_or_a_logout_the_daemon_reads_and_goes_away_from_says_so() { + for command in [Command::Login, Command::Logout] { + let asked = asked_first(&command); + let (_home, socket, answering) = a_daemon_that_reads_and_goes(); + + let (outcome, shown, complained) = timeout(PATIENCE, go(command, &socket)) + .await + .expect("the client ended"); + answering.abort(); + + assert_eq!(outcome, ExitCode::FAILURE); + assert!(shown.is_empty(), "{shown}"); + assert_eq!(complained, format!("{asked}{CLOSED_WITHOUT_ANSWERING}\n")); + } + } + + #[tokio::test] + async fn a_login_or_a_logout_that_cannot_be_printed_says_so() { + let daemon = Daemon::logging_in(Logins::default()); + + for (command, said) in [ + ( + Command::Login, + format!("{ASKED}benshi: login could not be printed: broken pipe\n"), + ), + ( + Command::Logout, + "benshi: logout could not be printed: broken pipe\n".to_owned(), + ), + ] { + let mut err = Shared::default(); + let outcome = timeout( + PATIENCE, + run( + command, + &daemon.socket, + || Ok(Typed::Line(Token::new(PASTED))), + &mut Unwritable, + &mut err, + ), + ) + .await + .expect("the client ended"); + + assert_eq!(outcome, ExitCode::FAILURE); + assert_eq!(err.text(), said); + } + } + + #[tokio::test] + async fn a_login_whose_prompt_cannot_be_printed_sends_nothing() { + // Nobody can be told where to find a token, so nothing pasted can be + // taken for one. + let daemon = Daemon::logging_in(Logins::default()); + + let outcome = timeout( + PATIENCE, + run( + Command::Login, + &daemon.socket, + || Ok(Typed::Line(Token::new(PASTED))), + &mut Shared::default(), + &mut Unwritable, + ), + ) + .await + .expect("the client ended"); + + assert_eq!(outcome, ExitCode::FAILURE); + assert!(daemon.accounts.asked().is_empty()); + } + + /// A socket served by something that closes the first connection it takes + /// without reading from it, and answers each request on the connections + /// after it with the answer that it is read and with `then`. + fn a_daemon_that_closes_the_first_connection( + then: &Response, + ) -> (TempDir, PathBuf, JoinHandle<()>) { + let (home, socket, listener) = a_socket_bound_and_not_served(); + let first = line_of(&Response::Ok); + let then = line_of(then); + let answering = tokio::spawn(async move { + let (unread, _peer) = listener.accept().await.expect("a client arrived"); + drop(unread); + loop { + let (stream, _peer) = listener.accept().await.expect("a client arrived"); + let (reader, mut writer) = tokio::io::split(stream); + let mut requests = BufReader::new(reader).lines(); + while let Ok(Some(_request)) = requests.next_line().await { + writer.write_all(&first).await.expect("the client reads"); + writer.write_all(&then).await.expect("the client reads"); + } + } + }); + + (home, socket, answering) + } + + #[tokio::test] + async fn a_login_sends_its_token_on_a_connection_of_its_own() { + // The first connection says that a daemon is there, and a person + // takes longer to paste than its deadline gives. This daemon closes + // it unread. + let (_home, socket, answering) = + a_daemon_that_closes_the_first_connection(&Response::LoggedIn { + name: "someone".to_owned(), + expires: None, + }); + + let (outcome, shown, complained) = timeout(PATIENCE, go(Command::Login, &socket)) + .await + .expect("the client ended"); + answering.abort(); + + assert_eq!(outcome, ExitCode::SUCCESS, "stderr said {complained:?}"); + assert_eq!( + shown, + "logged in to AniList as someone; token does not say when it runs out\n" + ); + } } diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 323497f..9b48741 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -8,7 +8,8 @@ //! read is the one the program acts on rather than a second one written for the //! occasion. `benshi why` prints the most recent recognition decision: what the //! name spelled, what was answered, and for a match which stage decided it and -//! by what. +//! by what. `benshi login` has the daemon ask AniList whose a token is and keep +//! it in the secret store, and `benshi logout` has it removed. //! //! The client renders; it never decides. Anything it shows came from the daemon //! in that answer, so two runs of `benshi sources` a second apart can differ and @@ -18,10 +19,13 @@ pub mod client; pub mod commands; +pub mod prompt; + pub mod render; use std::fmt; use std::io::{self, Write}; +use std::os::fd::AsFd; use std::path::PathBuf; use std::process::ExitCode; @@ -95,6 +99,7 @@ pub fn main(arguments: Cli) -> ExitCode { runtime.block_on(commands::run( arguments.command, &socket, + || prompt::hidden(io::stdin().as_fd()), &mut io::stdout(), &mut io::stderr(), )) @@ -158,6 +163,19 @@ mod tests { assert!(Cli::try_parse_from(["benshi", "why", "some.mkv"]).is_err()); } + #[test] + fn login_and_logout_take_no_argument() { + // A token is pasted at a prompt. One typed on the command line would + // stay in the shell's history. + let login = Cli::try_parse_from(["benshi", "login"]).expect("parses"); + let logout = Cli::try_parse_from(["benshi", "logout"]).expect("parses"); + + assert_eq!(login.command, Command::Login); + assert_eq!(logout.command, Command::Logout); + assert!(Cli::try_parse_from(["benshi", "login", "a.pasted.token"]).is_err()); + assert!(Cli::try_parse_from(["benshi", "logout", "anilist"]).is_err()); + } + #[test] fn a_recording_is_typed_as_a_span_of_time_and_a_path() { let asked = Cli::try_parse_from([ diff --git a/crates/cli/src/prompt.rs b/crates/cli/src/prompt.rs new file mode 100644 index 0000000..14477b4 --- /dev/null +++ b/crates/cli/src/prompt.rs @@ -0,0 +1,303 @@ +//! A line a person pastes at the terminal, read without being shown. + +use std::io; +use std::os::fd::BorrowedFd; + +use benshi_core::token::Token; +use rustix::io::Errno; +use rustix::termios::{ + LocalModes, OptionalActions, SpecialCodeIndex, Termios, tcgetattr, tcsetattr, +}; + +/// The byte Ctrl-C sends. +const CTRL_C: u8 = 0x03; + +/// The byte Ctrl-D sends. +const CTRL_D: u8 = 0x04; + +/// The bytes Backspace sends, by the terminal's choice. +const BACKSPACE: [u8; 2] = [0x7f, 0x08]; + +/// What was typed at a prompt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Typed { + /// A line, without its end. Where the input ended first, what came + /// before the end. A token is what a prompt reads, so it is held as one. + Line(Token), + /// Ctrl-C or Ctrl-D, at a terminal. + Cancelled, +} + +/// Read one line from `input`, which a terminal does not show. +/// +/// A terminal is read a byte at a time with its echo, its editing of lines +/// and its signals off. Ctrl-C and Ctrl-D cancel at once, Backspace takes +/// back the byte before it, and a line longer than the 4095 bytes Linux keeps +/// of a line it edits is read whole. The terminal is put back as it was +/// whatever the read returns with. Any other input is read as it is, to the +/// end of a line or of the input. +/// +/// # Errors +/// +/// What reading `input`, or setting the terminal, fails with. +pub fn hidden(input: BorrowedFd<'_>) -> io::Result { + let Ok(was) = tcgetattr(input) else { + return line(input, false); + }; + let mut quiet = was.clone(); + quiet + .local_modes + .remove(LocalModes::ECHO | LocalModes::ICANON | LocalModes::ISIG | LocalModes::IEXTEN); + quiet.special_codes[SpecialCodeIndex::VMIN] = 1; + quiet.special_codes[SpecialCodeIndex::VTIME] = 0; + tcsetattr(input, OptionalActions::Now, &quiet)?; + let _put_back = PutBack { + terminal: input, + was, + }; + + line(input, true) +} + +/// Read to the end of a line, where the bytes of keys are read as keys at a +/// terminal and as text anywhere else. +fn line(input: BorrowedFd<'_>, at_a_terminal: bool) -> io::Result { + let mut line = Vec::new(); + loop { + let mut byte = [0]; + match rustix::io::read(input, &mut byte) { + Ok(0) => break, + Ok(_) => {} + Err(Errno::INTR) => continue, + Err(failed) => return Err(failed.into()), + } + match byte[0] { + b'\n' => break, + b'\r' if at_a_terminal => break, + CTRL_C | CTRL_D if at_a_terminal => return Ok(Typed::Cancelled), + key if at_a_terminal && BACKSPACE.contains(&key) => { + line.pop(); + } + other => line.push(other), + } + } + + Ok(Typed::Line(Token::new(String::from_utf8_lossy(&line)))) +} + +/// A terminal to set back to what it was, once it is dropped. +struct PutBack<'fd> { + terminal: BorrowedFd<'fd>, + was: Termios, +} + +impl Drop for PutBack<'_> { + fn drop(&mut self) { + // The line is read or given up on, and a terminal that cannot be + // set back leaves nothing more to do. + tcsetattr(self.terminal, OptionalActions::Now, &self.was).ok(); + } +} + +#[cfg(test)] +mod tests { + use std::fs::File; + use std::io::{Read, Write}; + use std::os::fd::AsFd; + use std::sync::mpsc; + use std::thread; + use std::time::{Duration, Instant}; + + use benshi_core::token::Token; + use rustix::fs::{Mode, OFlags}; + use rustix::pty::{OpenptFlags, grantpt, openpt, ptsname, unlockpt}; + use rustix::termios::{ + InputModes, LocalModes, OptionalActions, SpecialCodeIndex, tcgetattr, tcsetattr, + }; + + use super::{Typed, hidden}; + + /// A bound on a read that ends on its own, so that one which does not + /// ends the test. + const BOUND: Duration = Duration::from_secs(5); + + /// What `hidden` reads from a pipe that carries `written` and ends. + fn read_from_a_pipe(written: &[u8]) -> Typed { + let (reading, mut writing) = std::io::pipe().expect("a pipe"); + writing.write_all(written).expect("the pipe takes it"); + drop(writing); + + hidden(reading.as_fd()).expect("a pipe is read") + } + + #[test] + fn a_line_from_a_pipe_is_read_to_its_end() { + assert_eq!( + read_from_a_pipe(b"a token\nwhat follows"), + Typed::Line(Token::new("a token")) + ); + } + + #[test] + fn a_pipe_that_ends_first_gives_what_came_before_its_end() { + assert_eq!( + read_from_a_pipe(b"a token"), + Typed::Line(Token::new("a token")) + ); + assert_eq!(read_from_a_pipe(b""), Typed::Line(Token::new(""))); + } + + #[test] + fn a_pipe_is_read_as_it_is() { + // The bytes a terminal reads as keys are text in a pipe. + assert_eq!( + read_from_a_pipe(b"a\x03b\x04c\x7fd\re\n"), + Typed::Line(Token::new("a\u{3}b\u{4}c\u{7f}d\re")) + ); + } + + /// A terminal of the test's own: the side a person types at, and the + /// side a program reads. It is no process's controlling terminal, so a + /// key typed at it signals nobody. + struct Terminal { + typed_at: File, + read_at: File, + } + + impl Terminal { + fn new() -> Self { + let typed_at = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY).expect("a terminal"); + grantpt(&typed_at).expect("the terminal is granted"); + unlockpt(&typed_at).expect("the terminal is unlocked"); + let name = ptsname(&typed_at, Vec::new()).expect("the terminal has a name"); + let read_at = rustix::fs::open( + name.as_c_str(), + OFlags::RDWR | OFlags::NOCTTY, + Mode::empty(), + ) + .expect("the terminal opens"); + + Self { + typed_at: File::from(typed_at), + read_at: File::from(read_at), + } + } + + fn modes(&self) -> LocalModes { + tcgetattr(&self.read_at).expect("a terminal").local_modes + } + + /// What `hidden` reads while `typed` is typed, once the terminal no + /// longer shows what is typed at it. + fn typing(&self, typed: &[u8]) -> Typed { + let read_at = self.read_at.try_clone().expect("the terminal is shared"); + let (answer, answered) = mpsc::channel(); + thread::spawn(move || answer.send(hidden(read_at.as_fd()))); + let began = Instant::now(); + while self.modes().contains(LocalModes::ECHO) { + assert!( + began.elapsed() < BOUND, + "the terminal went on showing what is typed" + ); + thread::sleep(Duration::from_millis(1)); + } + (&self.typed_at) + .write_all(typed) + .expect("the terminal takes keys"); + + answered + .recv_timeout(BOUND) + .expect("the line is read within five seconds") + .expect("the terminal is read") + } + + /// What the terminal shows from now until the program writes `mark`. + fn shown_until(&self, mark: &str) -> String { + (&self.read_at) + .write_all(mark.as_bytes()) + .expect("the program writes to the terminal"); + let mut typed_at = self.typed_at.try_clone().expect("the terminal is shared"); + let (answer, answered) = mpsc::channel(); + let mark = mark.to_owned(); + thread::spawn(move || { + let mut shown = Vec::new(); + let mut chunk = [0; 4096]; + while !String::from_utf8_lossy(&shown).ends_with(&mark) { + let read = typed_at.read(&mut chunk).expect("the terminal shows"); + shown.extend_from_slice(&chunk[..read]); + } + answer.send(String::from_utf8_lossy(&shown).into_owned()) + }); + + answered + .recv_timeout(BOUND) + .expect("the mark is shown within five seconds") + } + } + + #[test] + fn a_line_pasted_at_a_terminal_is_read_whole_and_shown_nowhere() { + // Longer than the 4095 bytes a terminal keeps of a line it edits. + let pasted = "t".repeat(5000); + let terminal = Terminal::new(); + let modes = terminal.modes(); + + let typed = terminal.typing(format!("{pasted}\r").as_bytes()); + + assert_eq!(typed, Typed::Line(Token::new(pasted))); + assert_eq!(terminal.shown_until("END"), "END"); + assert_eq!(terminal.modes(), modes, "the terminal was not put back"); + } + + #[test] + fn ctrl_c_at_a_terminal_cancels_at_once() { + let terminal = Terminal::new(); + let modes = terminal.modes(); + + assert_eq!(terminal.typing(b"part of a token\x03"), Typed::Cancelled); + assert_eq!(terminal.modes(), modes, "the terminal was not put back"); + } + + #[test] + fn ctrl_d_at_a_terminal_cancels_at_once() { + let terminal = Terminal::new(); + let modes = terminal.modes(); + + assert_eq!(terminal.typing(b"\x04"), Typed::Cancelled); + assert_eq!(terminal.modes(), modes, "the terminal was not put back"); + } + + #[test] + fn enter_ends_the_line_where_the_terminal_passes_it_as_a_return() { + // A terminal that does not turn the return Enter sends into a new + // line, as one a program before left in another state. + let terminal = Terminal::new(); + let mut modes = tcgetattr(&terminal.read_at).expect("a terminal"); + modes.input_modes.remove(InputModes::ICRNL); + tcsetattr(&terminal.read_at, OptionalActions::Now, &modes).expect("the terminal is set"); + + assert_eq!(terminal.typing(b"abc\r"), Typed::Line(Token::new("abc"))); + } + + #[test] + fn a_read_waits_for_a_key_where_the_terminal_was_left_not_to() { + // A terminal a program before left returning from a read at once. + let terminal = Terminal::new(); + let mut modes = tcgetattr(&terminal.read_at).expect("a terminal"); + modes.special_codes[SpecialCodeIndex::VMIN] = 0; + modes.special_codes[SpecialCodeIndex::VTIME] = 0; + tcsetattr(&terminal.read_at, OptionalActions::Now, &modes).expect("the terminal is set"); + + assert_eq!(terminal.typing(b"abc\r"), Typed::Line(Token::new("abc"))); + } + + #[test] + fn backspace_at_a_terminal_takes_back_what_was_typed_last() { + let terminal = Terminal::new(); + + assert_eq!( + terminal.typing(b"abx\x7fc\x08d\n"), + Typed::Line(Token::new("abd")) + ); + } +} diff --git a/crates/cli/src/render.rs b/crates/cli/src/render.rs index 16bc01f..5435d34 100644 --- a/crates/cli/src/render.rs +++ b/crates/cli/src/render.rs @@ -10,7 +10,7 @@ //! in. use std::fmt::Write as _; -use std::time::Duration; +use std::time::{Duration, UNIX_EPOCH}; use benshi_core::encoding::{Confidence, decode}; use benshi_core::policy::Policy; @@ -18,6 +18,7 @@ use benshi_core::recognise::parse::{Episode, Parsed}; use benshi_core::{Capabilities, Known, MediaRef, PlayState}; use benshi_daemon::bus::BusEvent; use benshi_daemon::protocol::{DecidedBy, Explanation, Outcome, SourceListing}; +use humantime::format_rfc3339_seconds; /// What is shown for a source that cannot name what it opened. /// @@ -308,9 +309,39 @@ pub fn event(of: &BusEvent) -> String { } } +/// What is shown once the token is removed. +pub const LOGGED_OUT: &str = "AniList token removed\n"; + +/// Whose the token is and until when it holds, as a login answers. +/// +/// The time is said in UTC. A time past the year 9999, for which no date is +/// written, is said as one the token does not give. +#[must_use] +pub fn logged_in(name: &str, expires: Option) -> String { + match expires.and_then(day_and_minute) { + Some(until) => format!("logged in to AniList as {name}, token valid until {until}\n"), + None => format!("logged in to AniList as {name}; token does not say when it runs out\n"), + } +} + +/// A time in seconds since the Unix epoch, as its day and minute in UTC: +/// `2030-03-17 17:46 UTC`. +fn day_and_minute(seconds: u64) -> Option { + let at = UNIX_EPOCH.checked_add(Duration::from_secs(seconds))?; + let mut written = String::new(); + // An error where the time is past the year 9999. + write!(written, "{}", format_rfc3339_seconds(at)).ok()?; + + Some(format!( + "{} {} UTC", + written.get(..10)?, + written.get(11..16)? + )) +} + #[cfg(test)] mod tests { - use super::{capabilities, event, explanation, listing, moment, state, why}; + use super::{capabilities, event, explanation, listing, logged_in, moment, state, why}; use benshi_core::clock::Timestamp; use benshi_core::path::RawPath; use benshi_core::policy::Policy; @@ -851,4 +882,37 @@ mod tests { ); } } + + #[test] + fn a_login_says_whose_the_token_is_and_until_when_in_utc() { + assert_eq!( + logged_in("someone", Some(1_900_000_000)), + "logged in to AniList as someone, token valid until 2030-03-17 17:46 UTC\n" + ); + } + + #[test] + fn a_token_that_does_not_say_when_it_runs_out_is_said_so() { + assert_eq!( + logged_in("someone", None), + "logged in to AniList as someone; token does not say when it runs out\n" + ); + } + + #[test] + fn a_time_past_the_last_date_written_is_said_as_none() { + // The last second of the year 9999 is the last time a date is + // written for. + assert_eq!( + logged_in("someone", Some(253_402_300_799)), + "logged in to AniList as someone, token valid until 9999-12-31 23:59 UTC\n" + ); + for past in [253_402_300_800, u64::MAX] { + assert_eq!( + logged_in("someone", Some(past)), + "logged in to AniList as someone; token does not say when it runs out\n", + "{past}" + ); + } + } } diff --git a/crates/cli/tests/the_client_sees_what_the_daemon_does.rs b/crates/cli/tests/the_client_sees_what_the_daemon_does.rs index 4e41897..426d45d 100644 --- a/crates/cli/tests/the_client_sees_what_the_daemon_does.rs +++ b/crates/cli/tests/the_client_sees_what_the_daemon_does.rs @@ -20,10 +20,13 @@ use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, SystemTime}; use benshi_cli::commands::{Command, PolicyArgument, run}; +use benshi_cli::prompt::Typed; use benshi_core::clock::Timestamp; use benshi_core::path::RawPath; use benshi_core::policy::PolicyTable; +use benshi_core::token::Token; use benshi_core::{AppName, Capabilities, Known, MediaRef, PlayState, PlayerId, PlayerSnapshot}; +use benshi_daemon::accounts::{Accounts, Done, LoggedIn}; use benshi_daemon::bus::{BusEvent, EventBus}; use benshi_daemon::detection::{Detection, Seen, Wiring}; use benshi_daemon::ipc::{Server, bind}; @@ -34,6 +37,25 @@ use tempfile::TempDir; use tokio::sync::broadcast::Receiver; use tokio::time::timeout; +/// Logins no test here asks for. +#[derive(Debug)] +struct NoLogins; + +impl Accounts for NoLogins { + fn login(&self, _token: Token) -> Done<'_, LoggedIn> { + unreachable!("no test here logs in") + } + + fn logout(&self) -> Done<'_, ()> { + unreachable!("no test here logs out") + } +} + +/// What a command that reads nothing typed is given to read. +fn untyped() -> std::io::Result { + unreachable!("no command here reads anything typed") +} + /// How long a test waits before calling something lost. const PATIENCE: Duration = Duration::from_secs(5); @@ -142,7 +164,13 @@ impl Daemon { let socket = home.path().join("run").join("benshi.sock"); let listener = bind(&socket).expect("the socket binds"); - let server = Arc::new(Server::new(Arc::clone(&bus), policy, seen, decided)); + let server = Arc::new(Server::new( + Arc::clone(&bus), + policy, + seen, + decided, + Arc::new(NoLogins), + )); drop(tokio::spawn(server.listen(Arc::new(listener)))); Self { @@ -156,7 +184,7 @@ impl Daemon { async fn ask(&self, command: Command) -> (ExitCode, String) { let mut out = Vec::new(); let mut err = Vec::new(); - let outcome = run(command, &self.socket, &mut out, &mut err).await; + let outcome = run(command, &self.socket, untyped, &mut out, &mut err).await; assert!( err.is_empty(), diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 9ecaa66..21c5158 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -13,6 +13,7 @@ pub mod policy; pub mod recognise; pub mod session; pub mod timeline; +pub mod token; pub mod trace; use std::fmt; diff --git a/crates/core/src/token.rs b/crates/core/src/token.rs new file mode 100644 index 0000000..daf44fd --- /dev/null +++ b/crates/core/src/token.rs @@ -0,0 +1,59 @@ +//! A token an account on a service is reached with. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// A token an account on a service is reached with. +/// +/// `{:?}` prints no part of it, so a token formatted into a message, a log +/// line or the output of a failed test is not given away there. The text is +/// read with [`Token::secret`] and serialised whole, where the token is sent +/// or kept. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Token(String); + +impl Token { + /// A token of this text. + #[must_use] + pub fn new(text: impl Into) -> Self { + Self(text.into()) + } + + /// The text of the token. + #[must_use] + pub fn secret(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for Token { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("Token(..)") + } +} + +#[cfg(test)] +mod tests { + use super::Token; + + #[test] + fn a_token_formatted_for_debugging_shows_none_of_its_text() { + let token = Token::new("header.payload.signature"); + + assert_eq!(format!("{token:?}"), "Token(..)"); + assert_eq!(format!("{:?}", Some(&token)), "Some(Token(..))"); + } + + #[test] + fn a_token_travels_as_its_text() { + let token = Token::new("header.payload.signature"); + + let text = serde_json::to_string(&token).expect("serialise"); + let back: Token = serde_json::from_str(&text).expect("deserialise"); + + assert_eq!(text, "\"header.payload.signature\""); + assert_eq!(back.secret(), "header.payload.signature"); + } +} diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index 473015d..fe63ee4 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -10,6 +10,7 @@ repository.workspace = true [dependencies] anyhow = "1.0.104" +base64 = "0.23.1" benshi-core = { path = "../core" } benshi-detect = { path = "../detect" } benshi-sinks = { path = "../sinks" } diff --git a/crates/daemon/src/accounts.rs b/crates/daemon/src/accounts.rs new file mode 100644 index 0000000..760f65c --- /dev/null +++ b/crates/daemon/src/accounts.rs @@ -0,0 +1,389 @@ +//! Logging in to AniList, and out of it. +//! +//! A login asks AniList whose a token is and keeps the token in the secret +//! store, and a logout removes it. A token AniList does not take is not kept, +//! so a mistyped token never replaces a working one. + +use std::fmt; +use std::future::Future; +use std::pin::Pin; + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use benshi_core::token::Token; +use benshi_sync::anilist::AniList; +#[cfg(target_os = "linux")] +use benshi_sync::anilist::transport::Http; +use benshi_sync::anilist::transport::Transport; +use serde_json::Value; + +use crate::secrets::{self, ANILIST, Secrets}; +#[cfg(target_os = "linux")] +use crate::secrets::{Asking, SecretService}; + +/// Whose a token is, and when it runs out. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoggedIn { + /// The name of the account on AniList. + pub name: String, + /// When the token runs out, in seconds since the Unix epoch, and none + /// where the token does not say. + pub expires: Option, +} + +/// Why a login or a logout was not carried out. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// AniList did not take the token, or could not be asked. + #[error("AniList {0}")] + Service(benshi_sync::Error), + /// The secret store did not keep the token. + #[error("token could not be kept: {0}")] + NotKept(secrets::Error), + /// The secret store did not remove the token. + #[error("token could not be removed: {0}")] + NotRemoved(secrets::Error), +} + +/// What a login or a logout comes to, once it is done. +pub type Done<'a, T> = Pin> + Send + 'a>>; + +/// Logins and logouts, as the socket asks for them. +pub trait Accounts: fmt::Debug + Send + Sync { + /// Check `token` with AniList, and keep it. + fn login(&self, token: Token) -> Done<'_, LoggedIn>; + + /// Remove the token kept. + fn logout(&self) -> Done<'_, ()>; +} + +/// Logins and logouts against AniList, with the token kept by the Secret +/// Service of the session. +/// +/// Each call is made for a command a person runs, so the store may ask them +/// to unlock it, and their answer is waited for up to +/// [`PROMPT_WAIT`](secrets::PROMPT_WAIT). +#[cfg(target_os = "linux")] +#[derive(Debug)] +pub struct OnAniList { + address: String, +} + +#[cfg(target_os = "linux")] +impl OnAniList { + /// Logins checked with the service at `address`. + #[must_use] + pub fn new(address: &str) -> Self { + Self { + address: address.to_owned(), + } + } +} + +#[cfg(target_os = "linux")] +impl Accounts for OnAniList { + fn login(&self, token: Token) -> Done<'_, LoggedIn> { + Box::pin(async move { + let secrets = asked().await.map_err(Error::NotKept)?; + let http = Http::new(&self.address, token.secret()).map_err(|unbuilt| { + Error::Service(benshi_sync::Error::Unreachable(unbuilt.into())) + })?; + + login(&AniList::new(http), &secrets, &token).await + }) + } + + fn logout(&self) -> Done<'_, ()> { + Box::pin(async { + let secrets = asked().await.map_err(Error::NotRemoved)?; + + logout(&secrets).await + }) + } +} + +/// The Secret Service of the session, for a call that may ask a person. +#[cfg(target_os = "linux")] +async fn asked() -> Result { + SecretService::connect(secrets::DEADLINE, Asking::Within(secrets::PROMPT_WAIT)).await +} + +/// Ask `service` whose `token` is, and keep the token in `secrets`. +/// +/// # Errors +/// +/// [`Error::Service`] where AniList does not take the token or cannot be +/// asked, and nothing is kept; [`Error::NotKept`] where the store does not +/// keep it. +pub async fn login( + service: &AniList, + secrets: &impl Secrets, + token: &Token, +) -> Result { + let account = service.account().await.map_err(Error::Service)?; + secrets + .write(ANILIST, token.secret()) + .await + .map_err(Error::NotKept)?; + + Ok(LoggedIn { + name: account.name, + expires: expires(token), + }) +} + +/// Remove the token kept in `secrets`. +/// +/// # Errors +/// +/// [`Error::NotRemoved`] where the store does not remove it. +pub async fn logout(secrets: &impl Secrets) -> Result<(), Error> { + secrets.remove(ANILIST).await.map_err(Error::NotRemoved) +} + +/// When `token` runs out: the `exp` of its claims, read as a JWT. +/// +/// AniList's documentation gives its tokens as JWTs. A token of another +/// shape, or whose claims hold no `exp` of whole seconds since the Unix +/// epoch, says nothing. +fn expires(token: &Token) -> Option { + let mut parts = token.secret().split('.'); + let (Some(_header), Some(claims), Some(_signature), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return None; + }; + let claims: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(claims).ok()?).ok()?; + + claims.get("exp")?.as_u64() +} + +#[cfg(test)] +mod tests { + use std::future::{self, Future}; + use std::time::Duration; + + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use benshi_core::token::Token; + use benshi_sync::anilist::AniList; + use benshi_sync::anilist::transport::{Answer, Request, Transport}; + use serde_json::{Value, json}; + + use super::{Error, LoggedIn, expires, login, logout}; + use crate::secrets::{self, ANILIST, Key, Memory, Secrets}; + + /// When the tokens of these tests run out. + const EXP: u64 = 1_900_000_000; + + /// A token shaped as AniList's are: a JWT whose claims hold `claims`. + fn a_token_claiming(claims: &Value) -> Token { + Token::new(format!( + "{}.{}.{}", + URL_SAFE_NO_PAD.encode(r#"{"typ":"JWT","alg":"RS256"}"#), + URL_SAFE_NO_PAD.encode(claims.to_string()), + URL_SAFE_NO_PAD.encode("not a signature"), + )) + } + + fn a_token() -> Token { + a_token_claiming(&json!({ "aud": "1", "sub": "2", "exp": EXP })) + } + + /// A service that answers every request with one answer. + struct Answering(Answer); + + impl Transport for Answering { + fn send( + &self, + _request: &Request, + ) -> impl Future> + Send { + future::ready(Ok(self.0.clone())) + } + } + + /// AniList, answering whose a token is. + fn anilist_taking() -> AniList { + AniList::new(Answering(Answer { + status: 200, + retry_after: None, + body: json!({ "data": { "Viewer": { "id": 2, "name": "someone" } } }).to_string(), + })) + } + + /// AniList, refusing a token as it refuses one that is not a token. + fn anilist_refusing() -> AniList { + AniList::new(Answering(Answer { + status: 400, + retry_after: None, + body: + json!({ "data": null, "errors": [{ "message": "Invalid token", "status": 400 }] }) + .to_string(), + })) + } + + /// A store that is locked and may not ask anyone. + struct Locked; + + impl Secrets for Locked { + fn read( + &self, + _key: Key, + ) -> impl Future, secrets::Error>> + Send { + future::ready(Err(secrets::Error::Locked)) + } + + fn write( + &self, + _key: Key, + _token: &str, + ) -> impl Future> + Send { + future::ready(Err(secrets::Error::Locked)) + } + + fn remove(&self, _key: Key) -> impl Future> + Send { + future::ready(Err(secrets::Error::Locked)) + } + } + + async fn kept(store: &Memory) -> Option { + store.read(ANILIST).await.expect("memory answers") + } + + #[tokio::test] + async fn a_token_anilist_takes_is_kept_and_answered_with_its_account() { + let store = Memory::default(); + let token = a_token(); + + let logged = login(&anilist_taking(), &store, &token).await; + + assert_eq!( + logged.expect("the login is done"), + LoggedIn { + name: "someone".to_owned(), + expires: Some(EXP), + } + ); + assert_eq!(kept(&store).await.as_deref(), Some(token.secret())); + } + + #[tokio::test] + async fn a_token_anilist_refuses_is_not_kept_in_place_of_the_one_before() { + let store = Memory::default(); + store + .write(ANILIST, "the token before") + .await + .expect("memory keeps it"); + + let logged = login(&anilist_refusing(), &store, &a_token()).await; + + assert!( + matches!( + &logged, + Err(Error::Service(benshi_sync::Error::Refused(said))) if said == "Invalid token" + ), + "{logged:?}" + ); + assert_eq!(kept(&store).await.as_deref(), Some("the token before")); + } + + #[tokio::test] + async fn a_token_the_store_does_not_keep_is_not_a_login() { + let logged = login(&anilist_taking(), &Locked, &a_token()).await; + + assert!( + matches!(logged, Err(Error::NotKept(secrets::Error::Locked))), + "{logged:?}" + ); + } + + #[tokio::test] + async fn a_logout_removes_the_token_kept() { + let store = Memory::default(); + store + .write(ANILIST, "a token") + .await + .expect("memory keeps it"); + + logout(&store).await.expect("the logout is done"); + + assert_eq!(kept(&store).await, None); + } + + #[tokio::test] + async fn a_logout_with_no_token_kept_is_done() { + logout(&Memory::default()) + .await + .expect("the logout is done"); + } + + #[tokio::test] + async fn a_logout_the_store_does_not_carry_out_is_said() { + let logged_out = logout(&Locked).await; + + assert!( + matches!(logged_out, Err(Error::NotRemoved(secrets::Error::Locked))), + "{logged_out:?}" + ); + } + + #[test] + fn a_token_says_when_it_runs_out_in_its_claims() { + assert_eq!(expires(&a_token()), Some(EXP)); + } + + #[test] + fn a_token_whose_claims_cannot_be_read_does_not_say_when_it_runs_out() { + let claims = URL_SAFE_NO_PAD.encode(json!({ "exp": EXP }).to_string()); + let unread = [ + Token::new("not a jwt"), + Token::new(format!("header.{claims}")), + Token::new(format!("header.{claims}.signature.more")), + Token::new("header.!!!.signature"), + Token::new(format!( + "header.{}.signature", + URL_SAFE_NO_PAD.encode("not json") + )), + a_token_claiming(&json!({ "sub": "2" })), + a_token_claiming(&json!({ "exp": "soon" })), + a_token_claiming(&json!({ "exp": 1.5 })), + a_token_claiming(&json!({ "exp": -1 })), + ]; + + for token in unread { + assert_eq!(expires(&token), None, "{}", token.secret()); + } + } + + #[test] + fn each_failure_is_said_as_decided() { + let said = [ + ( + Error::Service(benshi_sync::Error::Refused("Invalid token".to_owned())), + "AniList refused the call: Invalid token", + ), + ( + Error::Service(benshi_sync::Error::Timeout { + deadline: Duration::from_secs(15), + }), + "AniList did not answer within 15s", + ), + ( + Error::NotKept(secrets::Error::Locked), + "token could not be kept: secret store is locked", + ), + ( + Error::NotKept(secrets::Error::Dismissed), + "token could not be kept: unlocking the secret store was dismissed", + ), + ( + Error::NotRemoved(secrets::Error::Locked), + "token could not be removed: secret store is locked", + ), + ]; + + for (failure, line) in said { + assert_eq!(failure.to_string(), line); + } + } +} diff --git a/crates/daemon/src/ipc.rs b/crates/daemon/src/ipc.rs index baa6a11..2867665 100644 --- a/crates/daemon/src/ipc.rs +++ b/crates/daemon/src/ipc.rs @@ -23,6 +23,7 @@ use tokio::net::UnixListener; use tokio::sync::broadcast::error::RecvError; use tokio::task::JoinSet; +use crate::accounts::{Accounts, LoggedIn}; use crate::bus::EventBus; use crate::detection::Seen; use crate::protocol::{Explanation, Request, Response, SourceListing}; @@ -267,24 +268,28 @@ pub struct Server { policy: Arc>, seen: Seen, decided: Decided, + accounts: Arc, } impl Server { /// A server over the bus a `Watch` streams, the table a listing reads and a - /// policy is set in, and the two records each detection round leaves - /// behind: what it saw, and what it last decided. + /// policy is set in, the two records each detection round leaves behind, + /// what it saw and what it last decided, and what a login and a logout + /// are carried out by. #[must_use] pub fn new( bus: Arc, policy: Arc>, seen: Seen, decided: Decided, + accounts: Arc, ) -> Self { Self { bus, policy, seen, decided, + accounts, } } @@ -385,6 +390,26 @@ impl Server { Ok(Request::Why) => { Response::Why(self.decided.latest().as_ref().map(Explanation::of)) } + // Answered at once and again once done, since what is done + // may wait on a person. + Ok(Request::Login { token }) => { + reply(&mut writer, &Response::Ok).await?; + match self.accounts.login(token).await { + Ok(LoggedIn { name, expires }) => Response::LoggedIn { name, expires }, + Err(failed) => Response::Error { + message: failed.to_string(), + }, + } + } + Ok(Request::Logout) => { + reply(&mut writer, &Response::Ok).await?; + match self.accounts.logout().await { + Ok(()) => Response::LoggedOut, + Err(failed) => Response::Error { + message: failed.to_string(), + }, + } + } // A stream rather than an answer, so this connection reads // nothing further. Ok(Request::Watch) => return self.stream_events(&mut writer).await, @@ -472,14 +497,17 @@ fn client_left(error: &Error) -> bool { #[cfg(test)] mod tests { use super::{Fallback, NoRuntimeDirectory, Server, bind, fallback_from, socket_path_from}; + use crate::accounts::{self, Accounts, Done, LoggedIn}; use crate::bus::{BusEvent, EventBus}; use crate::detection::Seen; use crate::protocol::{Outcome, Request, Response}; use crate::recognition::{Decided, Decision, Recogniser}; + use crate::secrets; use benshi_core::clock::Timestamp; use benshi_core::path::RawPath; use benshi_core::policy::{Policy, PolicyTable}; use benshi_core::recognise::parse::Episode; + use benshi_core::token::Token; use benshi_core::{ AppName, Capabilities, Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, }; @@ -488,12 +516,13 @@ mod tests { use std::fs; use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; use std::path::{Path, PathBuf}; - use std::sync::{Arc, RwLock, mpsc}; + use std::sync::{Arc, Mutex, RwLock, mpsc}; use std::thread; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, ReadHalf, WriteHalf}; use tokio::net::UnixStream; use tokio::runtime::Handle; + use tokio::sync::Semaphore; use tokio::task::JoinHandle; use tokio::time::timeout; @@ -529,13 +558,105 @@ mod tests { } } - /// A server over a bus, a table and a record of decisions the test keeps - /// hold of. + /// When the token of a login runs out. + const EXP: u64 = 1_900_000_000; + + /// Logins and logouts that go through or fail as a test makes them, each + /// once the test lets it end. + #[derive(Debug)] + struct Scripted { + /// What each login and logout waits for before it ends. + held: Semaphore, + /// Whether a login or a logout goes through. + goes_through: bool, + /// What was asked, in order. + asked: Mutex>, + } + + impl Scripted { + fn going_through() -> Self { + Self { + held: Semaphore::new(Semaphore::MAX_PERMITS), + goes_through: true, + asked: Mutex::new(Vec::new()), + } + } + + fn failing() -> Self { + Self { + goes_through: false, + ..Self::going_through() + } + } + + /// Logins and logouts that go through once the test lets them. + fn held() -> Self { + Self { + held: Semaphore::new(0), + ..Self::going_through() + } + } + + /// Let one login or logout end. + fn let_one_end(&self) { + self.held.add_permits(1); + } + + fn asked(&self) -> Vec { + self.asked + .lock() + .expect("no test panics while it holds this") + .clone() + } + + async fn ended(&self, asked: String) { + self.asked + .lock() + .expect("no test panics while it holds this") + .push(asked); + self.held + .acquire() + .await + .expect("the semaphore is never closed") + .forget(); + } + } + + impl Accounts for Scripted { + fn login(&self, token: Token) -> Done<'_, LoggedIn> { + Box::pin(async move { + self.ended(format!("login {}", token.secret())).await; + if !self.goes_through { + return Err(accounts::Error::NotKept(secrets::Error::Locked)); + } + + Ok(LoggedIn { + name: "someone".to_owned(), + expires: Some(EXP), + }) + }) + } + + fn logout(&self) -> Done<'_, ()> { + Box::pin(async { + self.ended("logout".to_owned()).await; + if !self.goes_through { + return Err(accounts::Error::NotRemoved(secrets::Error::Locked)); + } + + Ok(()) + }) + } + } + + /// A server over a bus, a table, a record of decisions and logins the + /// test keeps hold of. struct Daemon { server: Arc, bus: Arc, policy: Arc>, decided: Decided, + accounts: Arc, } impl Daemon { @@ -545,11 +666,21 @@ mod tests { /// A daemon whose bus keeps `capacity` events for a slow reader. fn holding(capacity: usize, sources: Vec) -> Self { + Self::built(capacity, sources, Scripted::going_through()) + } + + /// A daemon whose logins and logouts go as `accounts` makes them. + fn logging_in(accounts: Scripted) -> Self { + Self::built(crate::bus::CAPACITY, Vec::new(), accounts) + } + + fn built(capacity: usize, sources: Vec, accounts: Scripted) -> Self { let bus = Arc::new(EventBus::with_capacity(capacity)); let policy = Arc::new(RwLock::new(PolicyTable::allowing_video_players())); let seen = Seen::new(); seen.record(sources); let decided = Decided::new(); + let accounts = Arc::new(accounts); Self { server: Arc::new(Server::new( @@ -557,10 +688,12 @@ mod tests { Arc::clone(&policy), seen, decided.clone(), + Arc::clone(&accounts) as Arc, )), bus, policy, decided, + accounts, } } @@ -633,6 +766,13 @@ mod tests { serde_json::from_str(&line).expect("the answer is a response") } + /// Fail where an answer arrives within a tenth of a second. + async fn answers_nothing_yet(&mut self) { + if let Ok(line) = timeout(Duration::from_millis(100), self.lines.next_line()).await { + panic!("answered before it was done: {line:?}"); + } + } + /// Close the connection and hand back what the server is doing. /// /// Both halves are dropped, because the pipe closes only when the last @@ -759,6 +899,100 @@ mod tests { assert_eq!(client.answer().await, Response::Ok); } + #[tokio::test] + async fn a_login_is_answered_once_it_is_read_and_again_once_it_is_done() { + // The secret store may ask a person to unlock it, and the login waits + // for them. The first answer is what a client waits for under its + // deadline. + let daemon = Daemon::logging_in(Scripted::held()); + let mut client = Client::to(&daemon, 4096); + + client + .ask(&Request::Login { + token: Token::new("a token"), + }) + .await; + + assert_eq!(client.answer().await, Response::Ok); + client.answers_nothing_yet().await; + assert_eq!(daemon.accounts.asked(), ["login a token"]); + daemon.accounts.let_one_end(); + assert_eq!( + client.answer().await, + Response::LoggedIn { + name: "someone".to_owned(), + expires: Some(EXP), + } + ); + } + + #[tokio::test] + async fn a_login_that_fails_is_answered_with_what_failed() { + let daemon = Daemon::logging_in(Scripted::failing()); + let mut client = Client::to(&daemon, 4096); + + client + .ask(&Request::Login { + token: Token::new("a token"), + }) + .await; + + assert_eq!(client.answer().await, Response::Ok); + assert_eq!( + client.answer().await, + Response::Error { + message: "token could not be kept: secret store is locked".to_owned(), + } + ); + } + + #[tokio::test] + async fn a_logout_is_answered_once_it_is_read_and_again_once_it_is_done() { + let daemon = Daemon::logging_in(Scripted::held()); + let mut client = Client::to(&daemon, 4096); + + client.ask(&Request::Logout).await; + + assert_eq!(client.answer().await, Response::Ok); + client.answers_nothing_yet().await; + assert_eq!(daemon.accounts.asked(), ["logout"]); + daemon.accounts.let_one_end(); + assert_eq!(client.answer().await, Response::LoggedOut); + } + + #[tokio::test] + async fn a_logout_that_fails_is_answered_with_what_failed() { + let daemon = Daemon::logging_in(Scripted::failing()); + let mut client = Client::to(&daemon, 4096); + + client.ask(&Request::Logout).await; + + assert_eq!(client.answer().await, Response::Ok); + assert_eq!( + client.answer().await, + Response::Error { + message: "token could not be removed: secret store is locked".to_owned(), + } + ); + } + + #[tokio::test] + async fn a_connection_goes_on_answering_after_a_login() { + let daemon = Daemon::logging_in(Scripted::going_through()); + let mut client = Client::to(&daemon, 4096); + + client + .ask(&Request::Login { + token: Token::new("a token"), + }) + .await; + assert_eq!(client.answer().await, Response::Ok); + let _logged_in = client.answer().await; + client.ask(&Request::Why).await; + + assert_eq!(client.answer().await, Response::Why(None)); + } + #[tokio::test] async fn a_policy_set_over_the_socket_takes_effect_at_once() { // The table the daemon reads is the table this writes, so a policy set diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index eb6fffd..180d1be 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -24,6 +24,7 @@ use benshi_store::Store; #[cfg(unix)] use tokio::net::UnixListener; +use crate::accounts::Accounts; use crate::bus::EventBus; use crate::detection::{Detection, Seen, Wiring}; #[cfg(unix)] @@ -32,6 +33,8 @@ use crate::recognition::{Decided, Recogniser}; #[cfg(unix)] use crate::supervisor::{Notice, Supervisor, TaskError, TaskRecord}; +pub mod accounts; + pub mod bus; pub mod config; @@ -83,7 +86,8 @@ pub mod voice; /// restart of detection: a detection task that is started again knows nothing /// of the one before it and goes on from what that one kept. `recogniser` is /// what a file's name is decided against, and `now` reads the time of day the -/// store writes beside what it keeps. +/// store writes beside what it keeps. `accounts` carries out a login and a +/// logout the socket is asked for. /// /// `tell` is told of a task as the task is started again and as it stops, /// whatever the other task is doing. A daemon whose detection has stopped @@ -95,6 +99,10 @@ pub mod voice; /// Returns once no task is left running, which for a daemon means every one of /// them stopped permanently. What stopped each is in its [`TaskRecord`], and /// `tell` has been told of each by then. +#[expect( + clippy::too_many_arguments, + reason = "each part is built by the caller, so that a test can give its own" +)] pub async fn run( mut start_watcher: B, listener: Arc, @@ -102,6 +110,7 @@ pub async fn run( store: Arc>, now: fn() -> SystemTime, period: Duration, + accounts: Arc, tell: T, ) -> Vec where @@ -125,6 +134,7 @@ where Arc::clone(&wiring.policy), wiring.seen.clone(), wiring.decided.clone(), + accounts, )); let mut supervisor = Supervisor::telling(tell); diff --git a/crates/daemon/src/protocol.rs b/crates/daemon/src/protocol.rs index 82b3e83..b93ae27 100644 --- a/crates/daemon/src/protocol.rs +++ b/crates/daemon/src/protocol.rs @@ -14,6 +14,7 @@ use benshi_core::policy::Policy; use benshi_core::recognise::altname::Assignment; use benshi_core::recognise::parse::{Episode, Parsed}; use benshi_core::recognise::{Recognition, Score, Stage}; +use benshi_core::token::Token; use benshi_core::{AppName, Capabilities, MediaRef, PlayState, PlayerId}; use benshi_detect::SourceInfo; use serde::{Deserialize, Serialize}; @@ -51,6 +52,24 @@ pub enum Request { /// The most recent recognition decision, and what it was decided by. Why, + + /// Check a token with AniList and keep it in the secret store. + /// + /// Answered twice: with [`Response::Ok`] once it is read, and with + /// [`Response::LoggedIn`] or [`Response::Error`] once it is done. The + /// store may ask a person to unlock it and the second answer waits for + /// them, so the first is what tells a daemon at work from one that will + /// never answer. + Login { + /// The token, as AniList's page gives it. + token: Token, + }, + + /// Remove the token kept in the secret store. + /// + /// Answered twice, as [`Request::Login`] is: with [`Response::Ok`], and + /// with [`Response::LoggedOut`] or [`Response::Error`]. + Logout, } /// One source as a listing shows it. @@ -111,7 +130,9 @@ pub enum Response { }, /// The request was carried out and has nothing to return. In answer to - /// [`Request::Watch`], that the stream is on and what follows is it. + /// [`Request::Watch`], that the stream is on and what follows is it; to + /// [`Request::Login`] and [`Request::Logout`], that the request is read + /// and what it comes to follows. Ok, /// The request could not be carried out. @@ -123,6 +144,18 @@ pub enum Response { /// The most recent decision, in answer to [`Request::Why`], and nothing /// where no reading has been decided yet. Why(Option), + + /// Whose the token is, in answer to [`Request::Login`], once it is kept. + LoggedIn { + /// The name of the account on AniList. + name: String, + /// When the token runs out, in seconds since the Unix epoch, and none + /// where the token does not say. + expires: Option, + }, + + /// The token is no longer kept, in answer to [`Request::Logout`]. + LoggedOut, } /// The most recent decision, as a client is told it. @@ -240,6 +273,7 @@ mod tests { use benshi_core::recognise::altname::Assignment; use benshi_core::recognise::parse::Episode; use benshi_core::recognise::{Ambiguity, Candidate, Match, Recognition, Refusal, Score, Stage}; + use benshi_core::token::Token; use benshi_core::{ AppName, Capabilities, Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, ShowId, }; @@ -362,6 +396,15 @@ mod tests { message: "expected value at line 1 column 1".to_owned(), }, Response::Why(None), + Response::LoggedIn { + name: "someone".to_owned(), + expires: Some(1_900_000_000), + }, + Response::LoggedIn { + name: "someone".to_owned(), + expires: None, + }, + Response::LoggedOut, ]; responses.extend( every_recognition() @@ -382,6 +425,10 @@ mod tests { policy: Policy::Allow, }, Request::Why, + Request::Login { + token: Token::new("header.payload.signature"), + }, + Request::Logout, ] } @@ -570,6 +617,19 @@ mod tests { assert_eq!(nothing.outcome, Outcome::Unrecognised { best: None }); } + #[test] + fn a_login_carries_the_token_as_its_text_and_shows_none_of_it_for_debugging() { + let asked = Request::Login { + token: Token::new("header.payload.signature"), + }; + + assert_eq!( + serde_json::to_string(&asked).expect("serialise"), + r#"{"type":"Login","body":{"token":"header.payload.signature"}}"# + ); + assert!(!format!("{asked:?}").contains("payload"), "{asked:?}"); + } + #[test] fn a_malformed_line_is_rejected_rather_than_guessed() { assert!(serde_json::from_str::("not json at all").is_err()); diff --git a/crates/daemon/tests/login_live.rs b/crates/daemon/tests/login_live.rs new file mode 100644 index 0000000..6458340 --- /dev/null +++ b/crates/daemon/tests/login_live.rs @@ -0,0 +1,62 @@ +//! A login and a logout against AniList and the secret store of the session +//! the tests run in. +//! +//! Ignored: it asks AniList whose a token is and changes the store of +//! whoever runs it. Run it by hand, in a desktop session, with the token of +//! an account made for tests: +//! +//! ```sh +//! BENSHI_TOKEN_ANILIST=... cargo test -p benshi-daemon --test login_live -- --ignored +//! ``` +//! +//! The token is kept where `benshi login` keeps it, under the label +//! `AniList token for benshi`, and removed at the end. The test does not run +//! where a token is kept there already, since it would replace that token. + +#![cfg(target_os = "linux")] + +use std::time::{SystemTime, UNIX_EPOCH}; + +use benshi_core::token::Token; +use benshi_daemon::accounts::{Accounts, OnAniList}; +use benshi_daemon::secrets::{ANILIST, Asking, DEADLINE, SecretService, Secrets}; +use benshi_sync::anilist::transport; + +#[tokio::test] +#[ignore = "speaks to AniList and writes to the secret store of the session it runs in"] +async fn a_token_is_logged_in_with_and_out_of_on_anilist() { + let token = std::env::var("BENSHI_TOKEN_ANILIST") + .expect("BENSHI_TOKEN_ANILIST holds the token of an account made for tests"); + let token = token.trim(); + let store = SecretService::connect(DEADLINE, Asking::Never) + .await + .expect("a secret store on the session bus"); + assert!( + store.read(ANILIST).await.expect("a read").is_none(), + "a token is kept for AniList already, and this test would replace it" + ); + let accounts = OnAniList::new(transport::ANILIST); + + let logged = accounts.login(Token::new(token)).await.expect("a login"); + + // Compared and never printed: a failure here would show the token. + let kept = store.read(ANILIST).await.expect("a read"); + assert!( + kept.as_deref() == Some(token), + "the token kept is not the one logged in with" + ); + assert!(!logged.name.is_empty(), "{logged:?}"); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("a clock past the epoch") + .as_secs(); + assert!( + logged.expires.is_some_and(|expires| expires > now), + "{logged:?}" + ); + accounts.logout().await.expect("a logout"); + assert!( + store.read(ANILIST).await.expect("a read").is_none(), + "the token is kept after a logout" + ); +} diff --git a/crates/daemon/tests/the_daemon_runs.rs b/crates/daemon/tests/the_daemon_runs.rs index 21609ff..236305d 100644 --- a/crates/daemon/tests/the_daemon_runs.rs +++ b/crates/daemon/tests/the_daemon_runs.rs @@ -27,9 +27,11 @@ use benshi_core::path::RawPath; use benshi_core::policy::Policy; use benshi_core::recognise::altname::Altnames; use benshi_core::recognise::corpus::Corpus; +use benshi_core::token::Token; use benshi_core::{ AppName, Capabilities, Known, MediaRef, PlayState, PlayerId, PlayerSnapshot, ShowId, }; +use benshi_daemon::accounts::{Accounts, Done, LoggedIn}; use benshi_daemon::bus::BusEvent; use benshi_daemon::ipc::bind; use benshi_daemon::protocol::{Request, Response, SourceListing}; @@ -287,6 +289,20 @@ impl Told { } } +/// Logins no test here asks for. +#[derive(Debug)] +struct NoLogins; + +impl Accounts for NoLogins { + fn login(&self, _token: Token) -> Done<'_, LoggedIn> { + unreachable!("no test here logs in") + } + + fn logout(&self) -> Done<'_, ()> { + unreachable!("no test here logs out") + } +} + /// A daemon over this platform with nothing to match a name against, as the /// binary starts one. fn a_daemon( @@ -303,6 +319,7 @@ fn a_daemon( a_store(home), SystemTime::now, PERIOD, + Arc::new(NoLogins), |_notice| {}, )) } @@ -335,6 +352,7 @@ fn a_daemon_that_tells( Arc::clone(store), the_time, PERIOD, + Arc::new(NoLogins), told.listener(), )) } diff --git a/crates/daemon/tests/the_session_bus_is_found.rs b/crates/daemon/tests/the_session_bus_is_found.rs index 520c2e3..111a6cf 100644 --- a/crates/daemon/tests/the_session_bus_is_found.rs +++ b/crates/daemon/tests/the_session_bus_is_found.rs @@ -13,10 +13,13 @@ #![cfg(target_os = "linux")] use std::env; +use std::future::Future; use std::os::unix::net::UnixListener; use std::process::Command; use std::time::Duration; +use benshi_core::token::Token; +use benshi_daemon::accounts::{self, Accounts, OnAniList}; use benshi_daemon::secrets::{Asking, Error, SecretService}; /// Set in a run a test started, and in no other. @@ -46,19 +49,39 @@ fn again(name: &str, address: &str) { assert!(said.contains("1 passed"), "{address}: {said}"); } -/// What connecting in this session answers. -fn connected() -> Result { +/// What `call` comes to in this session. +fn answered(call: impl Future) -> T { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("a runtime") .block_on(async { - tokio::time::timeout(BOUND, SecretService::connect(SHORT, Asking::Never)) + tokio::time::timeout(BOUND, call) .await - .expect("connecting gives up on its own") + .expect("the call gives up on its own") }) } +/// What connecting in this session answers. +fn connected() -> Result { + answered(SecretService::connect(SHORT, Asking::Never)) +} + +/// Logins checked with a service at a port of this machine that nobody +/// listens on, held by the socket handed back, so that no test here +/// reaches AniList. +fn accounts() -> (OnAniList, tokio::net::TcpSocket) { + let start = u16::try_from(std::process::id() % 20_000).expect("below 20000"); + for offset in 0..20_000 { + let port = 20_000 + (start + offset) % 20_000; + let socket = tokio::net::TcpSocket::new_v4().expect("a socket"); + if socket.bind(([127, 0, 0, 1], port).into()).is_ok() { + return (OnAniList::new(&format!("http://127.0.0.1:{port}/")), socket); + } + } + panic!("no port between 20000 and 40000 is free"); +} + #[test] fn a_session_with_no_bus_has_no_store_to_reach() { if env::var_os(AGAIN).is_some() { @@ -100,3 +123,47 @@ fn a_bus_that_does_not_answer_is_given_up_on_at_the_deadline() { &format!("unix:path={}", bus.display()), ); } + +#[test] +fn a_login_in_a_session_with_no_bus_says_the_token_was_not_kept() { + if env::var_os(AGAIN).is_some() { + let (accounts, _held) = accounts(); + let logged = answered(accounts.login(Token::new("a token"))); + + assert!( + matches!( + &logged, + Err(accounts::Error::NotKept(Error::Unreachable(_))) + ), + "{logged:?}" + ); + return; + } + + again( + "a_login_in_a_session_with_no_bus_says_the_token_was_not_kept", + "unix:path=/nowhere/bus", + ); +} + +#[test] +fn a_logout_in_a_session_with_no_bus_says_the_token_was_not_removed() { + if env::var_os(AGAIN).is_some() { + let (accounts, _held) = accounts(); + let logged_out = answered(accounts.logout()); + + assert!( + matches!( + &logged_out, + Err(accounts::Error::NotRemoved(Error::Unreachable(_))) + ), + "{logged_out:?}" + ); + return; + } + + again( + "a_logout_in_a_session_with_no_bus_says_the_token_was_not_removed", + "unix:path=/nowhere/bus", + ); +} diff --git a/crates/sync/src/anilist/transport.rs b/crates/sync/src/anilist/transport.rs index 48fb0a1..4a7a453 100644 --- a/crates/sync/src/anilist/transport.rs +++ b/crates/sync/src/anilist/transport.rs @@ -6,6 +6,7 @@ use std::time::Duration; +use benshi_core::token::Token; use reqwest::header::{ACCEPT, CONTENT_TYPE, RETRY_AFTER}; use serde_json::{Value, json}; @@ -58,7 +59,7 @@ pub trait Transport { pub struct Http { client: reqwest::Client, url: String, - token: String, + token: Token, } impl Http { @@ -84,7 +85,7 @@ impl Http { Ok(Self { client: reqwest::Client::builder().build()?, url: url.to_owned(), - token: token.to_owned(), + token: Token::new(token), }) } } @@ -95,7 +96,7 @@ impl Transport for Http { let response = self .client .post(&self.url) - .bearer_auth(&self.token) + .bearer_auth(self.token.secret()) .header(CONTENT_TYPE, "application/json") .header(ACCEPT, "application/json") .body(request.body()) @@ -234,6 +235,15 @@ mod tests { ); } + #[test] + fn a_transport_formatted_for_debugging_shows_none_of_its_token() { + let http = Http::new("http://127.0.0.1:20000/", TOKEN).expect("a client"); + + let shown = format!("{http:?}"); + + assert!(!shown.contains(TOKEN), "{shown}"); + } + #[tokio::test] async fn an_answer_carries_its_status_its_wait_and_its_body() { let (listener, port) = a_listener().await; diff --git a/crates/sync/tests/anilist_record.rs b/crates/sync/tests/anilist_record.rs index c876bfb..bca5cf5 100644 --- a/crates/sync/tests/anilist_record.rs +++ b/crates/sync/tests/anilist_record.rs @@ -4,7 +4,7 @@ //! test account's token in the environment: //! //! ```text -//! BENSHI_ANILIST_TOKEN=... cargo test -p benshi-sync --test anilist_record -- --ignored +//! BENSHI_TOKEN_ANILIST=... cargo test -p benshi-sync --test anilist_record -- --ignored //! ``` //! //! It files three works on the account's list, records the backend reading @@ -70,10 +70,10 @@ async fn send(http: &Http, request: &Request) -> Value { } #[tokio::test] -#[ignore = "speaks to AniList with the test account's token, from BENSHI_ANILIST_TOKEN"] +#[ignore = "speaks to AniList with the test account's token, from BENSHI_TOKEN_ANILIST"] async fn record_the_list_of_the_test_account() { - let token = std::env::var("BENSHI_ANILIST_TOKEN") - .expect("BENSHI_ANILIST_TOKEN holds the test account's token"); + let token = std::env::var("BENSHI_TOKEN_ANILIST") + .expect("BENSHI_TOKEN_ANILIST holds the test account's token"); let http = Http::new(ANILIST, token.trim()).expect("a client"); let mut filed = Vec::new();