use std::time::Duration;
const WORKER_URL: &str = "https://tokensave-counter.enzinol.workers.dev";
const GITHUB_RELEASES_URL: &str =
"https://api.github.com/repos/aovestdipaperino/tokensave/releases/latest";
const GITHUB_RELEASES_LIST_URL: &str =
"https://api.github.com/repos/aovestdipaperino/tokensave/releases?per_page=10";
const FLUSH_TIMEOUT: Duration = Duration::from_secs(2);
const FETCH_TIMEOUT: Duration = Duration::from_secs(1);
#[derive(serde::Deserialize)]
struct WorkerResponse {
total: u64,
}
#[cfg(all(unix, not(target_vendor = "apple"), not(target_os = "android")))]
fn platform_roots_available() -> bool {
static AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*AVAILABLE.get_or_init(probe_platform_roots)
}
#[cfg(not(all(unix, not(target_vendor = "apple"), not(target_os = "android"))))]
fn platform_roots_available() -> bool {
true
}
#[cfg(all(unix, not(target_vendor = "apple"), not(target_os = "android")))]
#[doc(hidden)]
pub fn probe_platform_roots() -> bool {
!rustls_native_certs::load_native_certs().certs.is_empty()
}
fn root_certs() -> ureq::tls::RootCerts {
use ureq::tls::RootCerts;
static WARNED: std::sync::Once = std::sync::Once::new();
if platform_roots_available() {
return RootCerts::PlatformVerifier;
}
WARNED.call_once(|| {
eprintln!(
" \x1b[33m⚠\x1b[0m No CA certificates found in the system trust store; \
using tokensave's bundled roots.\n Install `ca-certificates` if you need \
a certificate your OS trusts (e.g. a TLS-inspecting proxy's) to be honoured."
);
});
RootCerts::WebPki
}
pub fn agent_with_timeout(timeout: Duration) -> ureq::Agent {
use ureq::tls::TlsConfig;
ureq::Agent::config_builder()
.timeout_global(Some(timeout))
.tls_config(TlsConfig::builder().root_certs(root_certs()).build())
.build()
.into()
}
pub const UPLOAD_INTERVAL_SECS: i64 = 24 * 60 * 60;
pub const FAILED_ATTEMPT_COOLDOWN_SECS: i64 = 60;
pub fn upload_is_due(config: &crate::user_config::UserConfig, now: i64) -> bool {
if config.pending_upload == 0 || !config.upload_enabled {
return false;
}
if config.last_flush_attempt_at > config.last_upload_at
&& now - config.last_flush_attempt_at < FAILED_ATTEMPT_COOLDOWN_SECS
{
return false;
}
now - config.last_upload_at >= UPLOAD_INTERVAL_SECS
}
pub fn flush_pending(amount: u64) -> Option<u64> {
if amount == 0 {
return None;
}
let body = serde_json::json!({ "amount": amount });
let agent = agent_with_timeout(FLUSH_TIMEOUT);
let parsed: WorkerResponse = agent
.post(&format!("{WORKER_URL}/increment"))
.send_json(&body)
.ok()?
.body_mut()
.read_json()
.ok()?;
Some(parsed.total)
}
pub fn fetch_worldwide_total() -> Option<u64> {
let agent = agent_with_timeout(FETCH_TIMEOUT);
let parsed: WorkerResponse = agent
.get(&format!("{WORKER_URL}/total"))
.call()
.ok()?
.body_mut()
.read_json()
.ok()?;
Some(parsed.total)
}
#[derive(serde::Deserialize)]
struct CountriesResponse {
flags: Vec<String>,
}
pub fn fetch_country_flags() -> Vec<String> {
let agent = agent_with_timeout(Duration::from_millis(500));
let Ok(mut resp) = agent.get(&format!("{WORKER_URL}/countries")).call() else {
return Vec::new();
};
let Ok(parsed): Result<CountriesResponse, _> = resp.body_mut().read_json() else {
return Vec::new();
};
parsed.flags
}
#[derive(serde::Deserialize)]
struct GitHubRelease {
tag_name: String,
#[serde(default)]
prerelease: bool,
#[serde(default)]
assets: Vec<GitHubAsset>,
}
#[derive(serde::Deserialize)]
struct GitHubAsset {
name: String,
}
pub(crate) fn current_platform() -> &'static str {
if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") {
"aarch64-macos"
} else if cfg!(target_os = "macos") && cfg!(target_arch = "x86_64") {
"x86_64-macos"
} else if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") {
"x86_64-linux"
} else if cfg!(target_os = "linux") && cfg!(target_arch = "aarch64") {
"aarch64-linux"
} else if cfg!(target_os = "windows") {
"x86_64-windows"
} else {
"unknown"
}
}
pub(crate) fn asset_name(version: &str, is_beta: bool) -> String {
let prefix = if is_beta {
"tokensave-beta"
} else {
"tokensave"
};
let platform = current_platform();
let ext = if cfg!(windows) { "zip" } else { "tar.gz" };
format!("{prefix}-v{version}-{platform}.{ext}")
}
fn release_has_current_platform_asset(release: &GitHubRelease) -> bool {
let version = release.tag_name.trim_start_matches('v');
let expected = asset_name(version, release.prerelease);
release.assets.iter().any(|a| a.name == expected)
}
pub fn update_check_enabled() -> bool {
update_check_enabled_from(std::env::var("TOKENSAVE_UPDATE_CHECK").ok().as_deref())
}
fn update_check_enabled_from(value: Option<&str>) -> bool {
match value {
Some(v) => !matches!(
v.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "no" | "off" | "disable" | "disabled"
),
None => true,
}
}
pub fn fetch_latest_version_passive() -> Option<String> {
if !update_check_enabled() {
return None;
}
fetch_latest_version()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VersionCheckError {
Unreachable {
detail: String,
},
NoAssetForPlatform {
version: String,
platform: String,
expected: String,
available: Vec<String>,
},
NoRelease {
channel: &'static str,
},
}
impl std::fmt::Display for VersionCheckError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unreachable { detail } => write!(f, "could not reach GitHub ({detail})"),
Self::NoAssetForPlatform {
version,
platform,
expected,
available,
} => {
write!(
f,
"v{version} has no asset for {platform} (expected {expected})"
)?;
if available.is_empty() {
write!(f, "; that release publishes no assets at all")
} else {
write!(f, "; published assets: {}", available.join(", "))
}
}
Self::NoRelease { channel } => write!(f, "no {channel} release has been published"),
}
}
}
impl std::error::Error for VersionCheckError {}
pub type VersionResult = std::result::Result<String, VersionCheckError>;
pub fn fetch_latest_version() -> Option<String> {
try_fetch_latest_version().ok()
}
pub fn try_fetch_latest_version() -> VersionResult {
if is_beta() {
try_fetch_latest_beta_version()
} else {
try_fetch_latest_stable_version()
}
}
pub fn fetch_latest_stable_version() -> Option<String> {
try_fetch_latest_stable_version().ok()
}
pub fn try_fetch_latest_stable_version() -> VersionResult {
let agent = agent_with_timeout(FETCH_TIMEOUT);
let release: GitHubRelease = agent
.get(GITHUB_RELEASES_URL)
.header("User-Agent", "tokensave")
.call()
.map_err(unreachable)?
.body_mut()
.read_json()
.map_err(unreachable)?;
select_stable(&release)
}
pub fn fetch_latest_beta_version() -> Option<String> {
try_fetch_latest_beta_version().ok()
}
pub fn try_fetch_latest_beta_version() -> VersionResult {
let agent = agent_with_timeout(FETCH_TIMEOUT);
let releases: Vec<GitHubRelease> = agent
.get(GITHUB_RELEASES_LIST_URL)
.header("User-Agent", "tokensave")
.call()
.map_err(unreachable)?
.body_mut()
.read_json()
.map_err(unreachable)?;
select_beta(releases)
}
fn select_stable(release: &GitHubRelease) -> VersionResult {
if release_has_current_platform_asset(release) {
return Ok(release.tag_name.trim_start_matches('v').to_string());
}
Err(no_asset_error(release))
}
fn select_beta(releases: Vec<GitHubRelease>) -> VersionResult {
let mut newest: Option<GitHubRelease> = None;
for release in releases.into_iter().filter(|r| r.prerelease) {
if release_has_current_platform_asset(&release) {
return Ok(release.tag_name.trim_start_matches('v').to_string());
}
if newest.is_none() {
newest = Some(release);
}
}
match newest {
Some(release) => Err(no_asset_error(&release)),
None => Err(VersionCheckError::NoRelease { channel: "beta" }),
}
}
fn no_asset_error(release: &GitHubRelease) -> VersionCheckError {
let version = release.tag_name.trim_start_matches('v').to_string();
VersionCheckError::NoAssetForPlatform {
expected: asset_name(&version, release.prerelease),
platform: current_platform().to_string(),
available: release.assets.iter().map(|a| a.name.clone()).collect(),
version,
}
}
fn unreachable(error: impl std::fmt::Display) -> VersionCheckError {
VersionCheckError::Unreachable {
detail: error.to_string(),
}
}
pub fn is_beta() -> bool {
env!("CARGO_PKG_VERSION").contains('-')
}
fn parse_version(v: &str) -> Option<(u64, u64, u64, Option<&str>)> {
let (base, pre) = match v.split_once('-') {
Some((b, p)) => (b, Some(p)),
None => (v, None),
};
let mut parts = base.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
Some((major, minor, patch, pre))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BumpKind {
None,
Patch,
Minor,
Major,
}
pub fn bump_kind(old: &str, new: &str) -> BumpKind {
let Some((nm, nn, np, npre)) = parse_version(new) else {
return BumpKind::None;
};
let Some((om, on, op, opre)) = parse_version(old) else {
return if npre.is_none() {
BumpKind::Major
} else {
BumpKind::None
};
};
if opre.is_some() != npre.is_some() {
return BumpKind::None;
}
if !is_newer_version(old, new) {
return BumpKind::None;
}
if nm != om {
BumpKind::Major
} else if nn != on {
BumpKind::Minor
} else if np != op {
BumpKind::Patch
} else {
BumpKind::Patch
}
}
pub fn is_newer_version(current: &str, latest: &str) -> bool {
let parse = parse_version;
match (parse(current), parse(latest)) {
(Some((cm, cn, cp, cpre)), Some((lm, ln, lp, lpre))) => {
if cpre.is_some() != lpre.is_some() {
return false;
}
let c_base = (cm, cn, cp);
let l_base = (lm, ln, lp);
if l_base != c_base {
return l_base > c_base;
}
match (cpre, lpre) {
(Some(a), Some(b)) => b > a,
_ => false,
}
}
_ => false,
}
}
pub fn is_newer_minor_version(current: &str, latest: &str) -> bool {
fn parse(v: &str) -> Option<(u64, u64)> {
let base = v.split_once('-').map_or(v, |(b, _)| b);
let mut parts = base.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
Some((major, minor))
}
is_newer_version(current, latest)
&& match (parse(current), parse(latest)) {
(Some(c), Some(l)) => l > c,
_ => true,
}
}
pub enum InstallMethod {
Cargo,
Brew,
Scoop,
Unknown,
}
pub fn detect_install_method() -> InstallMethod {
let Ok(exe) = std::env::current_exe() else {
return InstallMethod::Unknown;
};
let path = exe.to_string_lossy();
if path.contains(".cargo/bin") || path.contains(".cargo\\bin") {
InstallMethod::Cargo
} else if path.contains("/homebrew/") || path.contains("/Cellar/") {
InstallMethod::Brew
} else if path.contains("\\scoop\\") || path.contains("/scoop/") {
InstallMethod::Scoop
} else {
InstallMethod::Unknown
}
}
pub fn upgrade_command(_method: &InstallMethod) -> &'static str {
"tokensave upgrade"
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn agent_with_timeout_builds_with_platform_roots() {
let _ = agent_with_timeout(Duration::from_secs(1));
}
#[test]
fn both_root_certificate_sources_build_an_agent() {
use ureq::tls::{RootCerts, TlsConfig};
for roots in [RootCerts::PlatformVerifier, RootCerts::WebPki] {
let _agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(1)))
.tls_config(TlsConfig::builder().root_certs(roots).build())
.build()
.into();
}
}
#[test]
fn the_fallback_narrows_trust_rather_than_disabling_it() {
let chosen = root_certs();
assert!(
matches!(chosen, ureq::tls::RootCerts::PlatformVerifier)
|| matches!(chosen, ureq::tls::RootCerts::WebPki),
"root_certs must pick a verifying source, never a disabled one"
);
}
#[test]
fn the_platform_root_probe_is_stable() {
assert_eq!(
platform_roots_available(),
platform_roots_available(),
"the probe is cached; repeated calls must agree"
);
}
fn cfg(
pending: u64,
last_upload_at: i64,
last_attempt_at: i64,
) -> crate::user_config::UserConfig {
crate::user_config::UserConfig {
upload_enabled: true,
pending_upload: pending,
last_upload_at,
last_flush_attempt_at: last_attempt_at,
..crate::user_config::UserConfig::default()
}
}
const DAY: i64 = UPLOAD_INTERVAL_SECS;
#[test]
fn upload_is_due_only_once_a_day() {
let now = 10 * DAY;
assert!(
!upload_is_due(&cfg(500, now - 60, 0), now),
"an upload a minute ago must not trigger another"
);
assert!(
!upload_is_due(&cfg(500, now - (DAY - 1), 0), now),
"one second short of a day is not due"
);
assert!(
upload_is_due(&cfg(500, now - DAY, now - DAY), now),
"a full day since the last success is due"
);
}
#[test]
fn upload_is_due_on_a_machine_that_has_never_uploaded() {
assert!(upload_is_due(&cfg(500, 0, 0), 10 * DAY));
}
#[test]
fn nothing_to_send_or_opted_out_is_never_due() {
let now = 10 * DAY;
assert!(
!upload_is_due(&cfg(0, 0, 0), now),
"no pending tokens means no request"
);
let mut opted_out = cfg(500, 0, 0);
opted_out.upload_enabled = false;
assert!(!upload_is_due(&opted_out, now), "opt-out is honored");
}
#[test]
fn a_failed_attempt_backs_off_briefly_then_retries() {
let now = 10 * DAY;
let just_failed = cfg(500, now - 5 * DAY, now - 1);
assert!(!upload_is_due(&just_failed, now));
let failed_a_while_ago = cfg(500, now - 5 * DAY, now - FAILED_ATTEMPT_COOLDOWN_SECS);
assert!(
upload_is_due(&failed_a_while_ago, now),
"the cooldown must expire, or a single failure would wedge uploads for a day"
);
}
#[test]
fn update_check_defaults_on_and_honors_off_values() {
assert!(update_check_enabled_from(None));
assert!(update_check_enabled_from(Some("on")));
assert!(update_check_enabled_from(Some("1")));
assert!(update_check_enabled_from(Some("please")));
for off in [
"off", "OFF", " Off ", "false", "0", "no", "disable", "disabled",
] {
assert!(
!update_check_enabled_from(Some(off)),
"{off} should disable"
);
}
}
fn release(tag: &str, prerelease: bool, asset_names: &[&str]) -> GitHubRelease {
GitHubRelease {
tag_name: tag.to_string(),
prerelease,
assets: asset_names
.iter()
.map(|n| GitHubAsset {
name: (*n).to_string(),
})
.collect(),
}
}
#[test]
fn skips_release_with_no_assets() {
let r = release("v9.9.9", false, &[]);
assert!(!release_has_current_platform_asset(&r));
}
#[test]
fn skips_release_missing_current_platform_asset() {
let r = release(
"v9.9.9",
false,
&[
"tokensave-v9.9.9-some-other-platform.tar.gz",
"tokensave-v9.9.9-yet-another-platform.tar.gz",
],
);
assert!(!release_has_current_platform_asset(&r));
}
#[test]
fn accepts_release_with_matching_asset() {
let expected = asset_name("9.9.9", false);
let r = release("v9.9.9", false, &[&expected]);
assert!(release_has_current_platform_asset(&r));
}
#[test]
fn accepts_beta_release_with_matching_beta_asset() {
let expected = asset_name("9.9.9-beta.1", true);
let r = release("v9.9.9-beta.1", true, &[&expected]);
assert!(release_has_current_platform_asset(&r));
}
#[test]
fn stable_selection_reports_missing_asset_not_unreachable() {
let mine = asset_name("7.11.1", false);
let published: Vec<String> = [
"tokensave-v7.11.1-aarch64-linux.tar.gz",
"tokensave-v7.11.1-aarch64-macos.tar.gz",
"tokensave-v7.11.1-x86_64-linux.tar.gz",
]
.iter()
.map(|n| (*n).to_string())
.filter(|n| *n != mine)
.collect();
let names: Vec<&str> = published.iter().map(String::as_str).collect();
let r = release("v7.11.1", false, &names);
let err = select_stable(&r).expect_err("no asset for this platform");
match &err {
VersionCheckError::NoAssetForPlatform {
version, available, ..
} => {
assert_eq!(version, "7.11.1");
assert_eq!(available.len(), names.len());
}
other => panic!("expected NoAssetForPlatform, got {other:?}"),
}
let msg = err.to_string();
assert!(msg.contains("7.11.1"), "{msg}");
assert!(msg.contains(current_platform()), "{msg}");
assert!(
!msg.contains("could not reach"),
"a missing asset must not be reported as a network fault: {msg}"
);
}
#[test]
fn stable_selection_returns_version_when_asset_present() {
let r = release("v9.9.9", false, &[&asset_name("9.9.9", false)]);
assert_eq!(select_stable(&r).unwrap(), "9.9.9");
}
#[test]
fn beta_selection_skips_incomplete_release_for_older_complete_one() {
let releases = vec![
release("v9.9.9-beta.2", true, &[]),
release("v9.9.9-beta.1", true, &[&asset_name("9.9.9-beta.1", true)]),
];
assert_eq!(select_beta(releases).unwrap(), "9.9.9-beta.1");
}
#[test]
fn beta_selection_reports_newest_prerelease_when_none_installable() {
let releases = vec![
release(
"v9.9.9-beta.2",
true,
&["tokensave-beta-v9.9.9-beta.2-other.tar.gz"],
),
release("v9.9.8", false, &[&asset_name("9.9.8", false)]),
];
match select_beta(releases).expect_err("no installable beta") {
VersionCheckError::NoAssetForPlatform { version, .. } => {
assert_eq!(version, "9.9.9-beta.2");
}
other => panic!("expected NoAssetForPlatform, got {other:?}"),
}
}
#[test]
fn beta_selection_reports_no_release_when_channel_is_empty() {
let releases = vec![release("v9.9.8", false, &[&asset_name("9.9.8", false)])];
assert!(matches!(
select_beta(releases).expect_err("no betas at all"),
VersionCheckError::NoRelease { channel: "beta" }
));
}
#[test]
fn unreachable_keeps_the_network_wording_and_carries_the_cause() {
let err = VersionCheckError::Unreachable {
detail: "http status: 503".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("could not reach GitHub"), "{msg}");
assert!(
msg.contains("503"),
"the underlying cause is worth surfacing: {msg}"
);
}
#[test]
fn rejects_stable_named_asset_on_beta_release() {
let stable_name = asset_name("9.9.9-beta.1", false);
let r = release("v9.9.9-beta.1", true, &[&stable_name]);
assert!(!release_has_current_platform_asset(&r));
}
}