use std::io::IsTerminal;
use recall_hooks::client::{self, Client};
use recall_hooks::config::Source;
use recall_hooks::credentials::{self, Credentials};
use recall_hooks::exit;
use crate::project as proj;
pub async fn connect(url: &str) -> anyhow::Result<i32> {
if proj::remote_session() {
eprintln!(
"recall connect: this is a remote session, and anything saved here is \
discarded with the container."
);
eprintln!(
" Set RECALL_URL and RECALL_TOKEN on the cloud environment instead — \
its variables are a secret store, and they are what Recall reads there."
);
return Ok(exit::CONFIG);
}
let url = credentials::normalize_url(url);
if !has_host(&url) {
eprintln!("recall connect: {url:?} is not a server URL (e.g. https://recall.example.com)");
return Ok(exit::CONFIG);
}
if url.starts_with("http://") && !is_loopback(&url) {
eprintln!(
"recall connect: warning — {url} is plain http, so the token crosses the \
network unencrypted on every request"
);
}
let here = proj::resolve();
let Some(home) = credentials::home(here.env.lookup()) else {
eprintln!("recall connect: no home directory to save into — set RECALL_HOME");
return Ok(exit::CONFIG);
};
let path = credentials::file(&home);
let mut saved = match credentials::load(&path) {
Ok(saved) => saved.unwrap_or_default(),
Err(e) => {
eprintln!("recall connect: {e}");
eprintln!(" Nothing was changed. Move it aside and run recall connect again.");
return Ok(exit::CONFIG);
}
};
if !std::io::stdin().is_terminal() {
eprintln!(
"recall connect: reads the token from a terminal, without echoing it, and \
there is no terminal here."
);
eprintln!(" Where something else holds the secret, set RECALL_TOKEN instead.");
return Ok(exit::CONFIG);
}
let token = match rpassword::prompt_password(format!("Token for {url}: ")) {
Ok(t) => t.trim().to_string(),
Err(e) => {
eprintln!("recall connect: could not read the token: {e}");
return Ok(exit::CONFIG);
}
};
if token.is_empty() {
eprintln!("recall connect: no token entered — nothing was saved");
return Ok(exit::CONFIG);
}
let client = Client::new(&url, &token)?;
if let Err(e) = client.health().await {
eprintln!("recall connect: could not reach {url}: {e}");
eprintln!(" Nothing was saved.");
return Ok(exit::CONFIG);
}
match client.check_token().await {
Ok(()) => {}
Err(client::Error::Status {
code: 401 | 403, ..
}) => {
eprintln!("recall connect: {url} is up, and it rejected that token.");
eprintln!(" Nothing was saved.");
return Ok(exit::CONFIG);
}
Err(e) => {
eprintln!("recall connect: {url} answered /health but not an authenticated call: {e}");
eprintln!(" Nothing was saved.");
return Ok(exit::CONFIG);
}
}
saved.insert(&url, &token);
if let Err(e) = credentials::save(&path, &saved) {
eprintln!("recall connect: the token is valid, but saving it failed: {e}");
return Ok(exit::CONFIG);
}
println!("Connected to {url}.");
println!("Token saved to {} (readable by you only).", path.display());
report_environment_overrides(&here, &url);
Ok(exit::OK)
}
pub fn disconnect(url: Option<&str>) -> anyhow::Result<i32> {
let here = proj::resolve();
let Some(home) = credentials::home(here.env.lookup()) else {
eprintln!("recall disconnect: no home directory, so nothing is saved — set RECALL_HOME");
return Ok(exit::CONFIG);
};
let path = credentials::file(&home);
let mut saved = match credentials::load(&path) {
Ok(Some(saved)) => saved,
Ok(None) => Credentials::default(),
Err(e) => {
eprintln!("recall disconnect: {e}");
eprintln!(" Nothing was changed.");
return Ok(exit::CONFIG);
}
};
let target = match url {
Some(u) => Some(credentials::normalize_url(u)),
None => saved.default.clone().or_else(|| {
(saved.servers.len() == 1)
.then(|| saved.servers.keys().next().cloned())
.flatten()
}),
};
match target {
None if saved.servers.is_empty() => {
println!("No token is saved in {}.", path.display());
}
None => {
eprintln!("recall disconnect: more than one server is saved; name one:");
for u in saved.servers.keys() {
eprintln!(" recall disconnect {u}");
}
return Ok(exit::CONFIG);
}
Some(target) => {
if saved.remove(&target) {
let result = if saved.servers.is_empty() {
credentials::delete(&path)
} else {
credentials::save(&path, &saved)
};
if let Err(e) = result {
eprintln!(
"recall disconnect: could not update {}: {e}",
path.display()
);
return Ok(exit::CONFIG);
}
println!("Removed the token for {target} from {}.", path.display());
println!(
"The token itself still works on the server. To revoke it, rotate \
RECALL_TOKEN there and reconnect every machine."
);
} else {
println!("No token for {target} is saved in {}.", path.display());
}
}
}
let cfg = here.config();
if cfg.token_source == Source::Environment {
println!();
println!("{}", environment_token_origin(&here, "still supplies"));
}
Ok(exit::OK)
}
fn report_environment_overrides(here: &proj::Resolved, connected: &str) {
let cfg = here.config();
if cfg.url_source == Source::Environment && credentials::normalize_url(&cfg.url) != connected {
println!();
println!(
"Note: RECALL_URL is set to {}, so this machine keeps talking to that server. \
Unset it to use {connected}.",
cfg.url
);
}
if cfg.token_source == Source::Environment {
println!();
println!(
"{} It wins over the saved one; remove it to finish the move.",
environment_token_origin(here, "also supplies")
);
}
}
fn environment_token_origin(here: &proj::Resolved, verb: &str) -> String {
match here.env.declared(&["RECALL_TOKEN"]).into_iter().next() {
Some(d) => format!("{} {verb} RECALL_TOKEN.", d.file),
None => format!(
"Your shell {verb} RECALL_TOKEN — Recall can see the value but not which \
file exported it, so check your shell profile."
),
}
}
fn has_host(url: &str) -> bool {
["https://", "http://"].iter().any(|scheme| {
url.strip_prefix(scheme)
.is_some_and(|rest| !rest.is_empty())
})
}
fn is_loopback(url: &str) -> bool {
let authority = url
.trim_start_matches("http://")
.split('/')
.next()
.unwrap_or_default();
let host = match authority.strip_prefix('[') {
Some(v6) => v6.split(']').next().unwrap_or_default(),
None => authority.split(':').next().unwrap_or_default(),
};
matches!(host, "localhost" | "127.0.0.1" | "::1")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_url_needs_a_scheme_and_a_host() {
assert!(has_host("https://recall.example.com"));
assert!(has_host("http://localhost:8787"));
for bad in ["recall.example.com", "https://", "ftp://x", ""] {
assert!(!has_host(bad), "{bad:?}");
}
}
#[test]
fn only_this_machine_is_exempt_from_the_plain_http_warning() {
for local in [
"http://localhost:8787",
"http://127.0.0.1:8787",
"http://[::1]:8787",
] {
assert!(is_loopback(local), "{local}");
}
for remote in ["http://recall.example.com", "http://10.0.0.5:8787"] {
assert!(!is_loopback(remote), "{remote}");
}
}
}