Marketplace: account credentials, token validation and fetch-error advice
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,555 @@
|
||||
//! Marketplace accounts: where a fetch credential comes from, checking a
|
||||
//! pasted token, and turning a failed fetch into advice a person can act on.
|
||||
//!
|
||||
//! Nothing here logs, returns or formats a token into an error string. A
|
||||
//! `GhHost` account stores nothing at all: its token is asked of the host's
|
||||
//! `gh` every time, so a later `gh auth refresh` or logout takes effect.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::marketplace::git::{Credential, FetchError};
|
||||
use crate::models::marketplace::{AccountMethod, MarketplaceAccount};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HostKind {
|
||||
GitHub,
|
||||
Gitea,
|
||||
GitLab,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Known by name only; Gitea (and self-hosted GitLab) are recognised by
|
||||
/// probing their API in [`validate_token`].
|
||||
pub fn host_kind(host: &str) -> HostKind {
|
||||
match host.to_ascii_lowercase().as_str() {
|
||||
"github.com" => HostKind::GitHub,
|
||||
"gitlab.com" => HostKind::GitLab,
|
||||
_ => HostKind::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// `host[:port]` characters only — also what keeps a host safe as a `gh` argument.
|
||||
///
|
||||
/// `pub(crate)` so other validators (the add-marketplace form, `gh_login`) use
|
||||
/// this same rule instead of a divergent copy (pre-flight F13).
|
||||
pub(crate) fn valid_host(host: &str) -> bool {
|
||||
!host.is_empty()
|
||||
&& host.len() <= 253
|
||||
&& !host.starts_with('-')
|
||||
&& host
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':'))
|
||||
}
|
||||
|
||||
/// The host of an `https://` marketplace URL, lowercased, with a non-default port kept.
|
||||
pub fn host_of(url: &str) -> Result<String, String> {
|
||||
// Pre-flight N17: never echo the raw URL back on a parse failure — a
|
||||
// malformed URL can carry `user:token@` and this is the one branch that
|
||||
// has not already stripped it.
|
||||
let parsed = url::Url::parse(url.trim()).map_err(|e| format!("Not a valid URL: {}", e))?;
|
||||
if parsed.scheme() != "https" {
|
||||
return Err("Only https:// marketplace URLs are supported.".to_string());
|
||||
}
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err("Put credentials in a marketplace account, not in the URL.".to_string());
|
||||
}
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| "The URL has no host".to_string())?
|
||||
.to_ascii_lowercase();
|
||||
let host = match parsed.port() {
|
||||
Some(port) => format!("{}:{}", host, port),
|
||||
None => host,
|
||||
};
|
||||
if !valid_host(&host) {
|
||||
return Err(format!("{:?} is not a supported host name", host));
|
||||
}
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
/// The username sent with the token over HTTPS.
|
||||
pub fn fetch_username(account: &MarketplaceAccount) -> String {
|
||||
if host_kind(&account.host) == HostKind::GitHub {
|
||||
return "x-access-token".to_string();
|
||||
}
|
||||
account
|
||||
.username
|
||||
.clone()
|
||||
.filter(|u| !u.trim().is_empty())
|
||||
.unwrap_or_else(|| "oauth2".to_string())
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Host `gh`
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const GH_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Run the host's `gh` with a plain argv (no shell) and return trimmed stdout.
|
||||
async fn run_gh(args: &[&str]) -> Result<String, String> {
|
||||
let mut cmd = tokio::process::Command::new("gh");
|
||||
cmd.args(args)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let output = tokio::time::timeout(GH_TIMEOUT, cmd.output())
|
||||
.await
|
||||
.map_err(|_| "gh did not answer within 15 seconds".to_string())?
|
||||
.map_err(|e| format!("Could not run gh: {}", e))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(stderr
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("gh failed")
|
||||
.trim()
|
||||
.to_string());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
pub async fn gh_host_available() -> bool {
|
||||
run_gh(&["--version"]).await.is_ok()
|
||||
}
|
||||
|
||||
fn gh_login_instructions(host: &str) -> String {
|
||||
format!(
|
||||
"gh on this computer is not logged in to {host}. Run `gh auth login --hostname {host}` \
|
||||
in a terminal, then try again.",
|
||||
host = host
|
||||
)
|
||||
}
|
||||
|
||||
/// The login name `gh` on the host is signed in as for `host`.
|
||||
pub async fn gh_host_login(host: &str) -> Result<String, String> {
|
||||
if !valid_host(host) {
|
||||
return Err(format!("{:?} is not a supported host name", host));
|
||||
}
|
||||
run_gh(&["auth", "status", "--hostname", host])
|
||||
.await
|
||||
.map_err(|_| gh_login_instructions(host))?;
|
||||
let login = run_gh(&["api", "user", "--hostname", host, "--jq", ".login"]).await?;
|
||||
if login.is_empty() {
|
||||
return Err(gh_login_instructions(host));
|
||||
}
|
||||
Ok(login)
|
||||
}
|
||||
|
||||
/// Resolve the credential for an account: `GhHost` → `gh auth token
|
||||
/// --hostname <host>`; `GhContainer`/`Token` → the keychain.
|
||||
pub async fn resolve_credential(account: &MarketplaceAccount) -> Result<Credential, String> {
|
||||
let password = match account.method {
|
||||
AccountMethod::GhHost => {
|
||||
if !valid_host(&account.host) {
|
||||
return Err(format!("{:?} is not a supported host name", account.host));
|
||||
}
|
||||
let token = run_gh(&["auth", "token", "--hostname", &account.host])
|
||||
.await
|
||||
.map_err(|_| gh_login_instructions(&account.host))?;
|
||||
if token.is_empty() {
|
||||
return Err(gh_login_instructions(&account.host));
|
||||
}
|
||||
token
|
||||
}
|
||||
AccountMethod::GhContainer | AccountMethod::Token => {
|
||||
crate::storage::secure::get_marketplace_token(&account.id)?.ok_or_else(|| {
|
||||
format!(
|
||||
"No token is stored for the account \"{}\". Remove it and sign in again.",
|
||||
account.label
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
Ok(Credential {
|
||||
username: fetch_username(account),
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Token validation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Probe {
|
||||
Login(String),
|
||||
Rejected(u16),
|
||||
NotThisKind,
|
||||
}
|
||||
|
||||
fn http_client() -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder()
|
||||
.user_agent("Triple-C")
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("Could not create an HTTP client: {}", e))
|
||||
}
|
||||
|
||||
/// One "who am I" call. `base` is the API root for GitHub
|
||||
/// (`https://api.github.com`) and the site root for Gitea/GitLab.
|
||||
async fn who_am_i(
|
||||
client: &reqwest::Client,
|
||||
kind: HostKind,
|
||||
base: &str,
|
||||
token: &str,
|
||||
) -> Result<Probe, String> {
|
||||
let (url, header, value, field) = match kind {
|
||||
HostKind::GitHub => (
|
||||
format!("{}/user", base),
|
||||
"Authorization",
|
||||
format!("Bearer {}", token),
|
||||
"login",
|
||||
),
|
||||
HostKind::Gitea => (
|
||||
format!("{}/api/v1/user", base),
|
||||
"Authorization",
|
||||
format!("token {}", token),
|
||||
"login",
|
||||
),
|
||||
HostKind::GitLab => (
|
||||
format!("{}/api/v4/user", base),
|
||||
"PRIVATE-TOKEN",
|
||||
token.to_string(),
|
||||
"username",
|
||||
),
|
||||
HostKind::Unknown => return Ok(Probe::NotThisKind),
|
||||
};
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header(header, value)
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
// reqwest's error text carries the URL, never the header.
|
||||
.map_err(|e| format!("Could not reach {}: {}", base, e.without_url()))?;
|
||||
let status = response.status().as_u16();
|
||||
match status {
|
||||
200 => {
|
||||
let json: serde_json::Value = match response.json().await {
|
||||
Ok(json) => json,
|
||||
Err(_) => return Ok(Probe::NotThisKind),
|
||||
};
|
||||
match json.get(field).and_then(|v| v.as_str()) {
|
||||
Some(login) if !login.is_empty() => Ok(Probe::Login(login.to_string())),
|
||||
_ => Ok(Probe::NotThisKind),
|
||||
}
|
||||
}
|
||||
401 | 403 => Ok(Probe::Rejected(status)),
|
||||
404 => Ok(Probe::NotThisKind),
|
||||
other => Err(format!(
|
||||
"{} answered HTTP {} when checking the token",
|
||||
base, other
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn rejected(host: &str, status: u16) -> String {
|
||||
format!(
|
||||
"{} rejected the token (HTTP {}). Check that it has not expired and can read repositories.",
|
||||
host, status
|
||||
)
|
||||
}
|
||||
|
||||
/// GitHub is asked at `github_api`; anything else is probed as Gitea, then
|
||||
/// GitLab, at `site`. `Ok(None)`: the host is neither, so the token could not
|
||||
/// be checked here — the marketplace's test fetch checks it instead.
|
||||
async fn validate_token_at(
|
||||
host: &str,
|
||||
github_api: Option<&str>,
|
||||
site: &str,
|
||||
token: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let client = http_client()?;
|
||||
if let Some(api) = github_api {
|
||||
return match who_am_i(&client, HostKind::GitHub, api, token).await? {
|
||||
Probe::Login(login) => Ok(Some(login)),
|
||||
Probe::Rejected(status) => Err(rejected(host, status)),
|
||||
Probe::NotThisKind => Err(format!("{} did not return a user for this token", host)),
|
||||
};
|
||||
}
|
||||
for kind in [HostKind::Gitea, HostKind::GitLab] {
|
||||
match who_am_i(&client, kind, site, token).await? {
|
||||
Probe::Login(login) => return Ok(Some(login)),
|
||||
Probe::Rejected(status) => return Err(rejected(host, status)),
|
||||
Probe::NotThisKind => {}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// "Who am I" check for a pasted token. `Ok(Some(login))` when the host
|
||||
/// confirmed it; `Ok(None)` when the host is not GitHub, Gitea or GitLab and
|
||||
/// the token is left to the first fetch to prove.
|
||||
pub async fn validate_token(host: &str, token: &str) -> Result<Option<String>, String> {
|
||||
if !valid_host(host) {
|
||||
return Err(format!("{:?} is not a supported host name", host));
|
||||
}
|
||||
if token.trim().is_empty() {
|
||||
return Err("Paste a token first.".to_string());
|
||||
}
|
||||
let site = format!("https://{}", host);
|
||||
match host_kind(host) {
|
||||
HostKind::GitHub => {
|
||||
validate_token_at(host, Some("https://api.github.com"), &site, token.trim()).await
|
||||
}
|
||||
_ => validate_token_at(host, None, &site, token.trim()).await,
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Fetch errors
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn who(account: Option<&MarketplaceAccount>) -> String {
|
||||
match account {
|
||||
None => "anonymously (no account)".to_string(),
|
||||
Some(a) => match &a.username {
|
||||
Some(u) if !u.is_empty() => format!("with the account \"{}\" ({})", a.label, u),
|
||||
_ => format!("with the account \"{}\"", a.label),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing message for a failed fetch, naming the account used and, for
|
||||
/// access problems, the usual organisation causes with the page that fixes each.
|
||||
pub fn describe_fetch_error(
|
||||
err: &FetchError,
|
||||
account: Option<&MarketplaceAccount>,
|
||||
url: &str,
|
||||
) -> String {
|
||||
let host = host_of(url).unwrap_or_else(|_| url.to_string());
|
||||
match err {
|
||||
FetchError::Auth { .. } | FetchError::NotFound => {
|
||||
let what = match err {
|
||||
FetchError::Auth { status } => format!("access was denied (HTTP {})", status),
|
||||
_ => "the repository was not found".to_string(),
|
||||
};
|
||||
let mut msg = format!("Could not read {} {}: {}.", url, who(account), what);
|
||||
if account.is_none() {
|
||||
msg.push_str(
|
||||
"\n• The repository may be private — choose an account that can read it.",
|
||||
);
|
||||
}
|
||||
if host_kind(&host) == HostKind::GitHub {
|
||||
msg.push_str(
|
||||
"\n• The organization may restrict third-party app access and not have approved \
|
||||
the GitHub CLI or your token: \
|
||||
https://docs.github.com/en/organizations/managing-oauth-access-to-your-organizations-data/about-oauth-app-access-restrictions\
|
||||
\n• If the organization uses SAML single sign-on, the token must be authorized for it: \
|
||||
https://github.com/settings/tokens\
|
||||
\n• A fine-grained token only reaches repositories of the owner it was created for: \
|
||||
https://github.com/settings/personal-access-tokens",
|
||||
);
|
||||
} else if account.is_some() {
|
||||
msg.push_str("\n• Check that the account's token has not expired and can read this repository.");
|
||||
}
|
||||
msg
|
||||
}
|
||||
FetchError::Network(m) => format!(
|
||||
"Could not reach {}: {}. The last fetched copy is still used.",
|
||||
host, m
|
||||
),
|
||||
FetchError::Other(m) => format!("Fetching {} failed: {}", url, m),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn account(host: &str, username: Option<&str>) -> MarketplaceAccount {
|
||||
MarketplaceAccount {
|
||||
id: "acc-1".into(),
|
||||
label: "Work".into(),
|
||||
host: host.into(),
|
||||
method: AccountMethod::Token,
|
||||
username: username.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_of_accepts_https_only() {
|
||||
assert_eq!(host_of("https://GitHub.com/a/b.git").unwrap(), "github.com");
|
||||
assert_eq!(
|
||||
host_of("https://git.example.com:8443/a/b").unwrap(),
|
||||
"git.example.com:8443"
|
||||
);
|
||||
assert!(host_of("http://github.com/a/b").is_err());
|
||||
assert!(host_of("git@github.com:a/b.git").is_err());
|
||||
assert!(host_of("file:///tmp/x").is_err());
|
||||
let err = host_of("https://user:test-token-not-real@github.com/a/b").unwrap_err();
|
||||
assert!(!err.contains("test-token-not-real"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_username_per_host() {
|
||||
assert_eq!(
|
||||
fetch_username(&account("github.com", Some("me"))),
|
||||
"x-access-token"
|
||||
);
|
||||
assert_eq!(
|
||||
fetch_username(&account("repo.example.net", Some("jk"))),
|
||||
"jk"
|
||||
);
|
||||
assert_eq!(fetch_username(&account("repo.example.net", None)), "oauth2");
|
||||
assert_eq!(
|
||||
fetch_username(&account("repo.example.net", Some(" "))),
|
||||
"oauth2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_kinds() {
|
||||
assert_eq!(host_kind("GITHUB.com"), HostKind::GitHub);
|
||||
assert_eq!(host_kind("gitlab.com"), HostKind::GitLab);
|
||||
assert_eq!(host_kind("repo.example.net"), HostKind::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_access_errors_names_account_and_org_causes() {
|
||||
let url = "https://github.com/acme/private-market.git";
|
||||
let msg = describe_fetch_error(
|
||||
&FetchError::Auth { status: 403 },
|
||||
Some(&account("github.com", Some("me"))),
|
||||
url,
|
||||
);
|
||||
assert!(msg.contains("\"Work\" (me)"), "{}", msg);
|
||||
assert!(msg.contains("HTTP 403"));
|
||||
assert!(msg.contains("third-party app access"));
|
||||
assert!(msg.contains("single sign-on"));
|
||||
assert!(msg.contains("fine-grained"));
|
||||
|
||||
let anon = describe_fetch_error(&FetchError::NotFound, None, url);
|
||||
assert!(anon.contains("anonymously"));
|
||||
assert!(anon.contains("may be private"));
|
||||
|
||||
let gitea = describe_fetch_error(
|
||||
&FetchError::Auth { status: 401 },
|
||||
Some(&account("repo.example.net", None)),
|
||||
"https://repo.example.net/o/r.git",
|
||||
);
|
||||
assert!(!gitea.contains("single sign-on"));
|
||||
assert!(gitea.contains("expired"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_network_and_other_errors() {
|
||||
let msg = describe_fetch_error(
|
||||
&FetchError::Network("dns error".into()),
|
||||
None,
|
||||
"https://github.com/a/b",
|
||||
);
|
||||
assert!(msg.contains("Could not reach github.com"));
|
||||
assert!(msg.contains("last fetched copy"));
|
||||
let msg = describe_fetch_error(
|
||||
&FetchError::Other("weird".into()),
|
||||
None,
|
||||
"https://github.com/a/b",
|
||||
);
|
||||
assert!(msg.contains("weird"));
|
||||
}
|
||||
|
||||
// ── validate_token against a local mock API ──────────────────────────────
|
||||
|
||||
const FAKE: &str = "test-token-not-real";
|
||||
|
||||
async fn serve(app: axum::Router) -> String {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
format!("http://{}", addr)
|
||||
}
|
||||
|
||||
fn authorised(headers: &axum::http::HeaderMap, name: &str, want: &str) -> bool {
|
||||
headers.get(name).and_then(|v| v.to_str().ok()) == Some(want)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn github_token_returns_login_or_is_rejected() {
|
||||
use axum::{http::HeaderMap, http::StatusCode, routing::get, Json, Router};
|
||||
let app = Router::new().route(
|
||||
"/user",
|
||||
get(|headers: HeaderMap| async move {
|
||||
if authorised(&headers, "authorization", &format!("Bearer {}", FAKE)) {
|
||||
Ok(Json(serde_json::json!({ "login": "octo" })))
|
||||
} else {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}),
|
||||
);
|
||||
let base = serve(app).await;
|
||||
assert_eq!(
|
||||
validate_token_at("github.com", Some(&base), "unused", FAKE)
|
||||
.await
|
||||
.unwrap(),
|
||||
Some("octo".to_string())
|
||||
);
|
||||
let err = validate_token_at("github.com", Some(&base), "unused", "wrong")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("HTTP 401"), "{}", err);
|
||||
assert!(
|
||||
!err.contains("wrong"),
|
||||
"the token must not appear in the error"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gitea_is_detected_first() {
|
||||
use axum::{http::HeaderMap, http::StatusCode, routing::get, Json, Router};
|
||||
let app = Router::new().route(
|
||||
"/api/v1/user",
|
||||
get(|headers: HeaderMap| async move {
|
||||
if authorised(&headers, "authorization", &format!("token {}", FAKE)) {
|
||||
Ok(Json(serde_json::json!({ "login": "jk" })))
|
||||
} else {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}),
|
||||
);
|
||||
let site = serve(app).await;
|
||||
assert_eq!(
|
||||
validate_token_at("h", None, &site, FAKE).await.unwrap(),
|
||||
Some("jk".to_string())
|
||||
);
|
||||
assert!(validate_token_at("h", None, &site, "wrong").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gitlab_is_tried_after_gitea_404() {
|
||||
use axum::{http::HeaderMap, http::StatusCode, routing::get, Json, Router};
|
||||
let app = Router::new().route(
|
||||
"/api/v4/user",
|
||||
get(|headers: HeaderMap| async move {
|
||||
if authorised(&headers, "private-token", FAKE) {
|
||||
Ok(Json(serde_json::json!({ "username": "gl-user" })))
|
||||
} else {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
}),
|
||||
);
|
||||
let site = serve(app).await;
|
||||
assert_eq!(
|
||||
validate_token_at("h", None, &site, FAKE).await.unwrap(),
|
||||
Some("gl-user".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_host_is_left_unchecked() {
|
||||
let site = serve(axum::Router::new()).await; // every path 404s
|
||||
assert_eq!(
|
||||
validate_token_at("h", None, &site, FAKE).await.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_token_refuses_bad_input_without_network() {
|
||||
assert!(validate_token("-evil", FAKE).await.is_err());
|
||||
assert!(validate_token("github.com", " ").await.is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Marketplace support — see `docs/superpowers/specs/2026-09-27-marketplace-design.md`.
|
||||
|
||||
pub mod auth;
|
||||
pub mod catalog;
|
||||
pub mod git;
|
||||
pub mod tree;
|
||||
|
||||
@@ -369,6 +369,44 @@ pub fn store_gateway_master_key(key: &str) -> Result<(), String> {
|
||||
bump_gateway_secret_version()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Marketplace account tokens (global, one entry per account)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Keychain service prefix; the account id completes it.
|
||||
const MARKETPLACE_TOKEN_SERVICE_PREFIX: &str = "triple-c-marketplace-account-";
|
||||
|
||||
/// The service name for one account. Ids are uuids; anything else is refused
|
||||
/// before a keychain entry is constructed.
|
||||
fn marketplace_token_service(account_id: &str) -> Result<String, String> {
|
||||
let ok = !account_id.is_empty()
|
||||
&& account_id.len() <= 64
|
||||
&& account_id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-');
|
||||
if !ok {
|
||||
return Err(format!("Invalid marketplace account id {:?}", account_id));
|
||||
}
|
||||
Ok(format!("{}{}", MARKETPLACE_TOKEN_SERVICE_PREFIX, account_id))
|
||||
}
|
||||
|
||||
pub fn store_marketplace_token(account_id: &str, token: &str) -> Result<(), String> {
|
||||
let service = marketplace_token_service(account_id)?;
|
||||
if token.trim().is_empty() {
|
||||
return Err("Refusing to store an empty marketplace token.".to_string());
|
||||
}
|
||||
let entry = keyring::Entry::new(&service, KEYCHAIN_ACCOUNT)
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
entry
|
||||
.set_password(token.trim())
|
||||
.map_err(|e| format!("Failed to store the marketplace account token: {}", e))
|
||||
}
|
||||
|
||||
pub fn get_marketplace_token(account_id: &str) -> Result<Option<String>, String> {
|
||||
read_entry(&marketplace_token_service(account_id)?, "the marketplace account token")
|
||||
}
|
||||
|
||||
pub fn delete_marketplace_token(account_id: &str) -> Result<(), String> {
|
||||
delete_entry(&marketplace_token_service(account_id)?, "the marketplace account token")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -426,6 +464,22 @@ mod tests {
|
||||
assert!(err.contains("brand-new-token"), "{}", err);
|
||||
}
|
||||
|
||||
/// Account ids become part of a keychain service name, so a malformed one
|
||||
/// is refused before any entry is constructed — and so before the
|
||||
/// keychain is touched, which is also what lets this run in CI.
|
||||
#[test]
|
||||
fn marketplace_token_ids_are_validated_before_the_keychain() {
|
||||
for bad in ["", "../x", "a b", "x;y", &"a".repeat(65)] {
|
||||
let err = store_marketplace_token(bad, "test-token-not-real").unwrap_err();
|
||||
assert!(err.contains("Invalid marketplace account id"), "{bad:?}: {err}");
|
||||
assert!(!err.contains("test-token-not-real"));
|
||||
assert!(get_marketplace_token(bad).is_err());
|
||||
assert!(delete_marketplace_token(bad).is_err());
|
||||
}
|
||||
let err = store_marketplace_token("0b9e6a2c-1111-4222-8333-944445555666", " ").unwrap_err();
|
||||
assert!(err.contains("empty"));
|
||||
}
|
||||
|
||||
/// The blanked-field case. `AccessSection.tsx` sends `gitToken || null`, so
|
||||
/// a cleared field arrives as `None` — and before this existed, `None` was
|
||||
/// skipped and the old secret stayed in the keychain forever.
|
||||
|
||||
Reference in New Issue
Block a user