diff --git a/src/commands/session.rs b/src/commands/session.rs index ffe024c..7ea9055 100644 --- a/src/commands/session.rs +++ b/src/commands/session.rs @@ -1,7 +1,10 @@ //! Signing in and out, and the saved logins. +use std::collections::BTreeMap; + use anyhow::{bail, Result}; use imogen_sdk::OAuthClient; +use serde::Serialize; use serde_json::json; use crate::auth; @@ -150,29 +153,25 @@ pub fn profiles(global: &GlobalArgs, args: &ProfilesArgs) -> Result<()> { } if out.is_json() { - return out.json(&config); + return out.json(&profiles_view(&config)); } if config.profiles.is_empty() { out.note("No saved logins. Run: imogen login --server https://photos.example.com"); return Ok(()); } - let current = config.default_profile_name(); + let current = current_profile(&config); let rows: Vec> = config .profiles .iter() .map(|(name, profile)| { vec![ - if *name == current { + if Some(name.as_str()) == current { out.paint(name, YELLOW) } else { name.clone() }, profile.server.clone(), - if profile.client_id.is_some() { - "browser".into() - } else { - "token".into() - }, + signed_in_via(profile).to_string(), profile.scope.clone(), ] }) @@ -180,3 +179,204 @@ pub fn profiles(global: &GlobalArgs, args: &ProfilesArgs) -> Result<()> { out.table(&["PROFILE", "SERVER", "SIGNED IN VIA", "SCOPES"], &rows); Ok(()) } + +/// What `--json` says about the saved logins: the four things the table already shows, +/// and nothing else. +/// +/// A view struct rather than `#[serde(skip_serializing)]` on `Profile`'s token fields, +/// because this one names everything it prints. A skip attribute has to be remembered by +/// whoever adds the next credential to the config; a struct that has to be extended on +/// purpose cannot leak one by omission. +#[derive(Serialize)] +struct ProfilesView<'a> { + current: Option<&'a str>, + profiles: BTreeMap<&'a str, ProfileView<'a>>, +} + +/// camelCase like every other key this program synthesises — `capturedAt`, `faceIds`, +/// `loggedOut` — rather than the snake_case these fields happen to wear on disk. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProfileView<'a> { + server: &'a str, + signed_in_via: &'static str, + scope: &'a str, + /// Unix milliseconds. Absent for a pasted token, which carries no expiry at all. + #[serde(skip_serializing_if = "Option::is_none")] + expires_at: Option, +} + +fn profiles_view(config: &Config) -> ProfilesView<'_> { + ProfilesView { + current: current_profile(config), + profiles: config + .profiles + .iter() + .map(|(name, profile)| { + ( + name.as_str(), + ProfileView { + server: &profile.server, + signed_in_via: signed_in_via(profile), + scope: &profile.scope, + expires_at: expires_at(profile), + }, + ) + }) + .collect(), + } +} + +/// The profile a command would reach for, which is what both audiences are told. +/// +/// The same answer as `Config::default_profile_name`, on purpose: a `current` naming a +/// profile that is no longer saved is reported as it stands rather than quietly repaired, +/// because that is the name every other command will try and fail on. Repairing it here +/// would have `profiles` point at one login while `ls` reached for another. +/// +/// The one departure is the empty config, where `default_profile_name` invents “default”: +/// that is the right name for a profile about to be written and the wrong description of +/// what exists. +fn current_profile(config: &Config) -> Option<&str> { + config + .current + .as_deref() + .or_else(|| config.profiles.keys().next().map(String::as_str)) +} + +fn signed_in_via(profile: &Profile) -> &'static str { + if profile.client_id.is_some() { + "browser" + } else { + "token" + } +} + +fn expires_at(profile: &Profile) -> Option { + if profile.expires_in <= 0 || profile.obtained_at == 0 { + return None; + } + Some(profile.obtained_at + profile.expires_in as u128 * 1000) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn browser_profile() -> Profile { + Profile { + server: "https://photos.example.com".into(), + client_id: Some("client-1".into()), + access_token: Some("at-SECRET-ACCESS".into()), + refresh_token: Some("rt-SECRET-REFRESH".into()), + expires_in: 3600, + obtained_at: 1_700_000_000_000, + scope: "library:read".into(), + } + } + + fn saved() -> Config { + let mut config = Config::default(); + config.set("home", browser_profile()); + config.set( + "family", + Profile::from_token( + "https://family.example.com".into(), + "at-SECRET-PASTED".into(), + ), + ); + config + } + + /// A grep of the serialised bytes rather than a field-by-field check: a credential + /// added to `Profile` later would reintroduce the leak silently, and only looking at + /// the whole string catches it. + #[test] + fn the_json_answer_carries_no_credentials() { + let config = saved(); + let json = serde_json::to_string(&profiles_view(&config)).unwrap(); + + for secret in ["at-SECRET-ACCESS", "rt-SECRET-REFRESH", "at-SECRET-PASTED"] { + assert!(!json.contains(secret), "{secret} reached stdout: {json}"); + } + // Every key, not every byte: “token” is a legitimate *value* of `signedInVia`. + // Case-insensitive because `rename_all = "camelCase"` means the next credential + // would arrive as `accessToken` rather than the `access_token` it wears on disk. + let view = serde_json::to_value(profiles_view(&config)).unwrap(); + for key in keys(&view) { + let key = key.to_lowercase(); + for word in ["token", "secret", "password", "credential"] { + assert!(!key.contains(word), "“{key}” is a credential field: {json}"); + } + } + } + + fn keys(value: &serde_json::Value) -> Vec { + match value { + serde_json::Value::Object(map) => map + .iter() + .flat_map(|(key, nested)| std::iter::once(key.clone()).chain(keys(nested))) + .collect(), + serde_json::Value::Array(items) => items.iter().flat_map(keys).collect(), + _ => Vec::new(), + } + } + + #[test] + fn the_json_answer_describes_what_the_table_shows() { + let config = saved(); + let json: serde_json::Value = serde_json::to_value(profiles_view(&config)).unwrap(); + + assert_eq!( + json["current"], "home", + "the first profile saved is the default" + ); + assert_eq!( + json["profiles"]["home"]["server"], + "https://photos.example.com" + ); + assert_eq!(json["profiles"]["home"]["signedInVia"], "browser"); + assert_eq!(json["profiles"]["home"]["scope"], "library:read"); + assert_eq!( + json["profiles"]["home"]["expiresAt"], + 1_700_000_000_000u64 + 3_600_000 + ); + + assert_eq!(json["profiles"]["family"]["signedInVia"], "token"); + assert!( + json["profiles"]["family"]["expiresAt"].is_null(), + "a pasted token has no expiry to report" + ); + } + + #[test] + fn nothing_is_current_until_something_is_saved() { + let json = serde_json::to_value(profiles_view(&Config::default())).unwrap(); + assert!(json["current"].is_null()); + } + + /// A `current` naming a profile that is no longer saved — a hand-edited file, or one + /// written by an older version — is reported as it stands, because that is the name + /// every other command will try. Reporting a repaired one would say `profiles` and + /// `ls` agree when they do not. + #[test] + fn a_stale_current_is_reported_as_the_commands_will_read_it() { + let mut config = saved(); + config.current = Some("gone".into()); + + assert_eq!(config.default_profile_name(), "gone"); + let json = serde_json::to_value(profiles_view(&config)).unwrap(); + assert_eq!(json["current"], "gone"); + } + + /// With no `current` recorded, both audiences fall to the first profile by name. + #[test] + fn with_nothing_chosen_the_first_profile_by_name_is_current() { + let mut config = saved(); + config.current = None; + + assert_eq!(config.default_profile_name(), "family"); + let json = serde_json::to_value(profiles_view(&config)).unwrap(); + assert_eq!(json["current"], "family"); + } +}