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`.
|
//! Marketplace support — see `docs/superpowers/specs/2026-09-27-marketplace-design.md`.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod tree;
|
pub mod tree;
|
||||||
|
|||||||
@@ -369,6 +369,44 @@ pub fn store_gateway_master_key(key: &str) -> Result<(), String> {
|
|||||||
bump_gateway_secret_version()
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -426,6 +464,22 @@ mod tests {
|
|||||||
assert!(err.contains("brand-new-token"), "{}", err);
|
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
|
/// The blanked-field case. `AccessSection.tsx` sends `gitToken || null`, so
|
||||||
/// a cleared field arrives as `None` — and before this existed, `None` was
|
/// a cleared field arrives as `None` — and before this existed, `None` was
|
||||||
/// skipped and the old secret stayed in the keychain forever.
|
/// skipped and the old secret stayed in the keychain forever.
|
||||||
|
|||||||
Reference in New Issue
Block a user