use std::process::ExitCode;
use link_assistant_router::cli::{AuthOp, ImportProvider};
use link_assistant_router::credential_acceptance::AcceptedCredential as ValidatedCandidate;
#[cfg(test)]
use link_assistant_router::credential_acceptance::catalog_base_for_candidate;
use link_assistant_router::subscription::{
ImportSource, InstallDocumentResult, InstallMode, SubscriptionProvider, SubscriptionReader,
};
#[path = "auth_import_result.rs"]
mod import_result;
#[path = "auth_import_resume.rs"]
mod import_resume;
use import_result::{ImportExecution, ImportFailure, ImportOutcome, ImportPhase, finish};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
enum CredentialProbe {
Accepted,
Rejected,
Unverified,
}
#[derive(Debug, Clone, Copy, Default)]
struct ImportPolicy {
if_absent: bool,
capability_asserted: bool,
router_owned_candidate: bool,
sharing: CredentialSharing,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum CredentialSharing {
#[default]
PreferFollow,
RequireFollow,
Snapshot,
}
pub async fn run_import(
config: &link_assistant_router::config::Config,
op: &AuthOp,
) -> Option<ExitCode> {
let json = matches!(op, AuthOp::Import { json: true, .. });
if let Some(failure) = refuse_a_remote_import(op).await {
return Some(finish(&[ImportExecution::failed(None, failure)], json));
}
let policy = match op {
AuthOp::Import {
if_absent,
force,
follow,
snapshot,
..
} => ImportPolicy {
if_absent: *if_absent,
capability_asserted: *force,
router_owned_candidate: false,
sharing: match (*follow, *snapshot) {
(true, _) => CredentialSharing::RequireFollow,
(_, true) => CredentialSharing::Snapshot,
_ => CredentialSharing::PreferFollow,
},
},
_ => ImportPolicy::default(),
};
let requested: Vec<(
ImportProvider,
String,
Option<import_resume::ResumeCandidate>,
)> = match op {
AuthOp::Claude {
from_claude_home: Some(source),
..
} => vec![(ImportProvider::Claude, source.clone(), None)],
AuthOp::Codex {
from_codex_home: Some(source),
..
} => vec![(ImportProvider::Codex, source.clone(), None)],
AuthOp::Import {
resume: Some(transaction_id),
..
} => match import_resume::resolve_claimed(&config.data_dir, transaction_id).await {
Ok(candidate) => vec![(
candidate.provider,
candidate.source.clone(),
Some(candidate),
)],
Err(failure) => {
return Some(finish(&[ImportExecution::failed(None, failure)], json));
}
},
AuthOp::Import { all: true, .. } => [
ImportProvider::Claude,
ImportProvider::Codex,
ImportProvider::Gemini,
ImportProvider::Qwen,
ImportProvider::Gh,
]
.into_iter()
.map(|provider| (provider, String::new(), None))
.collect(),
AuthOp::Import {
provider: Some(provider),
dir,
..
} => vec![(*provider, dir.clone().unwrap_or_default(), None)],
_ => return None,
};
let adopting_everything = matches!(op, AuthOp::Import { all: true, .. });
let mut executions = Vec::with_capacity(requested.len());
for (provider, source, resumed) in requested {
let outcome = match provider {
ImportProvider::Gh if policy.if_absent => Err(ImportFailure::not_attempted(
"--if-absent is supported only for Claude, Codex, Gemini, and Qwen; GitHub import keeps its existing replacement behavior",
)),
ImportProvider::Gh => import_github(&config.data_dir, &source)
.map(|messages| ImportExecution::promoted("github", messages))
.map_err(ImportFailure::not_attempted),
other => {
let Some(subscription) = subscription_of(other) else {
continue;
};
let mut provider_policy = policy;
provider_policy.router_owned_candidate = resumed.is_some();
if resumed.as_ref().is_some_and(|candidate| {
destination_has_receipt(config, subscription, &candidate.transaction_id)
}) {
Ok(ImportExecution::promoted(
subscription.to_string(),
vec![format!(
"{subscription:<8} promotion was already committed; completing retained transaction cleanup"
)],
))
} else {
import_provider(
config,
subscription,
&source,
provider_policy,
resumed
.as_ref()
.map(|candidate| candidate.transaction_id.as_str()),
)
.await
}
}
};
let outcome = if let Some(candidate) = resumed.as_ref() {
reconcile_resumed_import(candidate, outcome)
} else {
outcome
};
let execution = match outcome {
Ok(execution) => execution,
Err(failure) => {
let absent = adopting_everything && failure.error.starts_with("no ");
let message = absent.then(|| {
format!(
"{}: nothing to adopt ({})",
provider_label(provider),
failure.error
)
});
let execution = ImportExecution::failed(Some(provider_label(provider)), failure);
if let Some(message) = message {
execution.ignore_failure(message)
} else {
execution
}
}
};
executions.push(execution);
}
Some(finish(&executions, json))
}
fn reconcile_resumed_import(
candidate: &import_resume::ResumeCandidate,
outcome: Result<ImportExecution, ImportFailure>,
) -> Result<ImportExecution, ImportFailure> {
match outcome {
Ok(mut execution) if execution.is_promoted() => {
if let Err(warning) = import_resume::retire(candidate) {
execution.mark_cleanup_pending(
candidate.transaction_id.clone(),
format!("{warning}; retry this transaction ID to complete cleanup"),
);
}
Ok(execution)
}
Ok(_) => Err(ImportFailure::retained(
ImportPhase::Preflight,
candidate.transaction_id.clone(),
format!(
"the destination is already present; retained import transaction {} still requires recovery",
candidate.transaction_id
),
)),
Err(failure)
if matches!(
failure.outcome,
ImportOutcome::ExchangeUncertain
| ImportOutcome::PersistenceUncertain
| ImportOutcome::SuccessorRetained
) =>
{
let _retired_predecessor = import_resume::retire(candidate);
Err(failure)
}
Err(failure) => Err(ImportFailure::retained(
failure.phase,
candidate.transaction_id.clone(),
format!(
"{}; retained import transaction {} still requires recovery",
failure.error, candidate.transaction_id
),
)),
}
}
async fn refuse_a_remote_import(op: &AuthOp) -> Option<ImportFailure> {
if !op.may_be_remote() {
return None;
}
let AuthOp::Import { target, .. } = op else {
return None;
};
let server = match link_assistant_router::auth_remote::target_for(
target.local,
target.managed,
target.server.as_deref(),
target.management_server.as_deref(),
)
.await
{
Ok(Some(server)) => server,
Ok(None) => return None,
Err(error) => {
return Some(ImportFailure::not_attempted(error));
}
};
let destination = match op {
AuthOp::Import {
provider: Some(provider),
..
} => {
link_assistant_router::auth_remote::credential_home(&server, provider_label(*provider))
.await
}
_ => None,
};
let error = link_assistant_router::auth_remote::remote_import_refusal(
&server.base_url,
destination.as_deref(),
)
.join("\n");
Some(ImportFailure::not_attempted(error))
}
const fn subscription_of(provider: ImportProvider) -> Option<SubscriptionProvider> {
match provider {
ImportProvider::Claude => Some(SubscriptionProvider::Claude),
ImportProvider::Codex => Some(SubscriptionProvider::Codex),
ImportProvider::Gemini => Some(SubscriptionProvider::Gemini),
ImportProvider::Qwen => Some(SubscriptionProvider::Qwen),
ImportProvider::Gh => None,
}
}
const fn provider_label(provider: ImportProvider) -> &'static str {
match provider {
ImportProvider::Claude => "claude",
ImportProvider::Codex => "codex",
ImportProvider::Gemini => "gemini",
ImportProvider::Qwen => "qwen",
ImportProvider::Gh => "github",
}
}
fn import_github(data_dir: &std::path::Path, source: &str) -> Result<Vec<String>, String> {
use link_assistant_router::github_proxy;
let directory = Some(source)
.filter(|source| !source.trim().is_empty())
.map(std::path::PathBuf::from)
.or_else(github_proxy::gh_config_directory)
.ok_or_else(|| {
String::from("no gh configuration directory; name one, or set GH_CONFIG_DIR")
})?;
let host = github_proxy::configured_credential_host()
.ok_or_else(|| String::from("GITHUB_PROXY_BASE_URL does not name a valid GitHub host"))?;
let token = github_proxy::token_from_gh_config(&directory, &host).ok_or_else(|| {
format!(
"no GitHub credential for {host} in {}; run `gh auth login --hostname {host}` there first",
directory.display(),
)
})?;
let path = github_proxy::store_credential(data_dir, &token)?;
let mut messages = vec![format!(
"github imported {} from {}",
path.display(),
directory.display()
)];
messages.push(
"github note: the GitHub routes are mounted at startup; restart to serve them"
.to_string(),
);
Ok(messages)
}
async fn import_provider(
config: &link_assistant_router::config::Config,
provider: SubscriptionProvider,
source: &str,
policy: ImportPolicy,
resumed_transaction_id: Option<&str>,
) -> Result<ImportExecution, ImportFailure> {
let user_home = config.client_home.to_string_lossy().into_owned();
let destination_home = provider_home(config, provider, &user_home);
let catalog_base_url_override = import_catalog_base_url_override();
import_provider_with_paths(
&config.data_dir,
&user_home,
&destination_home,
provider,
source,
policy,
resumed_transaction_id,
catalog_base_url_override.as_deref(),
)
.await
}
fn import_catalog_base_url_override() -> Option<String> {
#[cfg(debug_assertions)]
{
std::env::var("LINK_ASSISTANT_ROUTER_TEST_CATALOG_BASE_URL")
.ok()
.filter(|value| !value.trim().is_empty())
}
#[cfg(not(debug_assertions))]
{
None
}
}
#[allow(clippy::too_many_arguments)]
async fn import_provider_with_paths(
data_dir: &std::path::Path,
user_home: &str,
destination_home: &std::path::Path,
provider: SubscriptionProvider,
source: &str,
policy: ImportPolicy,
resumed_transaction_id: Option<&str>,
catalog_base_url_override: Option<&str>,
) -> Result<ImportExecution, ImportFailure> {
let source_home = if source.trim().is_empty() {
provider.conventional_home(user_home)
} else {
std::path::PathBuf::from(source)
};
if same_credential_home(&source_home, destination_home) {
return Err(ImportFailure::not_attempted(format!(
"{provider} is already read from {}, so there is nothing to adopt",
destination_home.display()
)));
}
let destination = SubscriptionReader::new(provider, destination_home);
if policy.if_absent && destination.has_platform_store_credential() {
return Ok(ImportExecution::already_present(
provider.to_string(),
vec![format!(
"{provider:<8} already present in the platform credential store; the candidate was not read, validated, or installed"
)],
));
}
if policy.if_absent
&& let Some(path) = destination
.existing_document_locked(
data_dir,
link_assistant_router::credential_recovery_store::PRIMARY_ACCOUNT,
)
.await
.map_err(ImportFailure::not_attempted)?
{
return Ok(ImportExecution::already_present(
provider.to_string(),
vec![format!(
"{provider:<8} already present at {}; the candidate was not read, validated, or installed",
path.display()
)],
));
}
let from = SubscriptionReader::new(provider, &source_home);
let source_credential = from.read_document_for_import().map_err(|error| {
ImportFailure::not_attempted(match error {
link_assistant_router::subscription::SubscriptionError::NoCredentials(message) => {
format!("no {provider} credential to import: {message}")
}
other => format!("invalid {provider} candidate credential: {other}"),
})
})?;
let ImportSource {
document,
token: _,
origin,
path,
} = source_credential;
let where_from = match origin {
link_assistant_router::platform_keychain::Origin::Keychain => {
link_assistant_router::platform_keychain::service_name(provider).map_or_else(
|| String::from("the platform keychain"),
|service| format!("keychain {service:?}"),
)
}
link_assistant_router::platform_keychain::Origin::File
| link_assistant_router::platform_keychain::Origin::ExternalFile
| link_assistant_router::platform_keychain::Origin::AdoptedFile => {
path.as_ref().map_or_else(
|| source_home.display().to_string(),
|path| path.display().to_string(),
)
}
};
let external_source =
if policy.router_owned_candidate || policy.sharing == CredentialSharing::Snapshot {
None
} else {
match prepare_external_source(provider, origin, path.as_deref(), &destination) {
Ok(source) => Some(source),
Err(failure) if policy.sharing != CredentialSharing::RequireFollow => {
eprintln!(
"warning: {provider} is imported as a copy rather than followed: {}. \
The vendor client will rotate past this copy, and it will then need \
re-importing; `--follow` refuses instead of copying.",
failure.error
);
None
}
Err(failure) => return Err(failure),
}
};
let validated = validate_candidate_at(
data_dir,
provider,
&document,
policy.router_owned_candidate,
None,
catalog_base_url_override,
)
.await?;
let report = describe_credential(validated.token());
if (policy.if_absent && destination.has_platform_store_credential())
|| (!policy.if_absent
&& destination.candidate_is_shadowed_by_platform_store(validated.token()))
{
let error = format!(
"the {provider} platform credential remains authoritative; the external source was not installed"
);
if policy.router_owned_candidate {
return Err(retain_validated_candidate(
validated,
ImportPhase::Promotion,
error,
));
}
drop(validated);
return Err(ImportFailure::safe_failure(ImportPhase::Promotion, error));
}
let receipt_id = resumed_transaction_id
.unwrap_or_else(|| validated.transaction_id())
.to_string();
let promotion_document = match external_source.as_ref().map_or_else(
|| {
link_assistant_router::subscription::mark_promotion_receipt(
validated.document(),
&receipt_id,
)
},
|source| {
link_assistant_router::subscription::reference_external_credential(source, &receipt_id)
},
) {
Ok(document) => document,
Err(error) => {
if policy.router_owned_candidate {
return Err(retain_validated_candidate(
validated,
ImportPhase::Promotion,
error,
));
}
drop(validated);
return Err(ImportFailure::safe_failure(ImportPhase::Promotion, error));
}
};
let promotion = install_candidate(
&destination,
data_dir,
&promotion_document,
CredentialProbe::Accepted,
policy,
)
.await;
let installed = match promotion {
Ok(installed) => installed,
Err(_error) if destination_has_receipt_at(&destination, &receipt_id) => {
let path = destination.discover_credential_path().ok_or_else(|| {
ImportFailure::safe_failure(
ImportPhase::Promotion,
format!("the committed {provider} credential could not be located"),
)
})?;
InstallDocumentResult::Installed(path)
}
Err(error) => {
if policy.router_owned_candidate {
return Err(retain_validated_candidate(
validated,
ImportPhase::Promotion,
error,
));
}
drop(validated);
return Err(ImportFailure::safe_failure(ImportPhase::Promotion, error));
}
};
let execution = match installed {
InstallDocumentResult::Installed(path) => {
let messages = vec![
format!(
"{provider:<8} imported {} from {where_from}",
path.display()
),
external_source.as_ref().map_or_else(
|| format!(
"{provider:<8} candidate {report}, Router-owned refresh chain validated and promoted"
),
|source| format!(
"{provider:<8} candidate {report}, current access accepted without OAuth exchange; Router and the vendor client share the authoritative file at {}",
source.display()
),
),
];
drop(validated);
ImportExecution::promoted(provider.to_string(), messages)
}
InstallDocumentResult::AlreadyPresent(path) => {
if !policy.router_owned_candidate {
drop(validated);
return Ok(ImportExecution::already_present(
provider.to_string(),
vec![format!(
"{provider:<8} already present at {}; the external candidate from {where_from} was validated but not installed",
path.display()
)],
));
}
return Err(retain_validated_candidate(
validated,
ImportPhase::Promotion,
format!(
"a credential appeared at {}; the validated {provider} successor from {where_from} was retained for recovery",
path.display()
),
));
}
};
Ok(execution)
}
fn prepare_external_source(
provider: SubscriptionProvider,
origin: link_assistant_router::platform_keychain::Origin,
path: Option<&std::path::Path>,
destination: &SubscriptionReader,
) -> Result<std::path::PathBuf, ImportFailure> {
if origin == link_assistant_router::platform_keychain::Origin::Keychain {
return Err(ImportFailure::not_attempted(format!(
"the selected {provider} credential exists only in the platform keychain; let the vendor client expose a current writable credential file before import"
)));
}
if origin == link_assistant_router::platform_keychain::Origin::ExternalFile {
return Err(ImportFailure::not_attempted(format!(
"the selected {provider} credential is externally owned but does not identify its authoritative writable source"
)));
}
let path = path.ok_or_else(|| {
ImportFailure::not_attempted(format!(
"the selected {provider} credential has no authoritative writable source file"
))
})?;
let source = std::fs::canonicalize(path).map_err(|_| {
ImportFailure::not_attempted(format!(
"the selected {provider} credential source is not a readable file"
))
})?;
if destination
.credential_paths()
.iter()
.filter_map(|candidate| std::fs::canonicalize(candidate).ok())
.any(|candidate| candidate == source)
{
return Err(ImportFailure::not_attempted(format!(
"the selected {provider} source is also a Router destination credential"
)));
}
let parent = source.parent().ok_or_else(|| {
ImportFailure::not_attempted(format!(
"the selected {provider} credential source has no writable directory"
))
})?;
let probe = tempfile::Builder::new()
.prefix(".router-import-write-check-")
.tempfile_in(parent)
.map_err(|_| {
ImportFailure::not_attempted(format!(
"the selected {provider} credential source cannot be replaced atomically; make its directory writable before import"
))
})?;
probe.as_file().sync_all().map_err(|_| {
ImportFailure::not_attempted(format!(
"the selected {provider} credential source cannot be persisted durably"
))
})?;
probe.close().map_err(|_| {
ImportFailure::not_attempted(format!(
"the selected {provider} credential source write check could not be cleaned up"
))
})?;
Ok(source)
}
fn destination_has_receipt(
config: &link_assistant_router::config::Config,
provider: SubscriptionProvider,
transaction_id: &str,
) -> bool {
let user_home = config.client_home.to_string_lossy().into_owned();
let destination =
SubscriptionReader::new(provider, provider_home(config, provider, &user_home));
destination_has_receipt_at(&destination, transaction_id)
}
fn destination_has_receipt_at(destination: &SubscriptionReader, transaction_id: &str) -> bool {
destination
.discover_credential_path()
.and_then(|path| std::fs::read_to_string(path).ok())
.is_some_and(|document| {
link_assistant_router::subscription::has_promotion_receipt(&document, transaction_id)
})
}
async fn validate_candidate_at(
data_dir: &std::path::Path,
provider: SubscriptionProvider,
document: &str,
rotate_candidate: bool,
token_url_override: Option<&str>,
catalog_base_url_override: Option<&str>,
) -> Result<ValidatedCandidate, ImportFailure> {
let accepted = if rotate_candidate {
link_assistant_router::credential_acceptance::accept_candidate(
data_dir,
provider,
document,
token_url_override,
catalog_base_url_override,
)
.await
} else {
link_assistant_router::credential_acceptance::accept_external_candidate(
data_dir,
provider,
document,
catalog_base_url_override,
)
.await
};
accepted.map_err(|failure| ImportFailure::from_acceptance(&failure))
}
fn retain_validated_candidate(
validated: ValidatedCandidate,
phase: ImportPhase,
error: String,
) -> ImportFailure {
let transaction_id = validated.transaction_id().to_string();
std::mem::forget(validated);
ImportFailure::retained(phase, transaction_id, error)
}
fn same_credential_home(source: &std::path::Path, destination: &std::path::Path) -> bool {
if source == destination {
return true;
}
match (
std::fs::canonicalize(source),
std::fs::canonicalize(destination),
) {
(Ok(source), Ok(destination)) => source == destination,
_ => false,
}
}
#[cfg(test)]
async fn validate_candidate_with(
data_dir: &std::path::Path,
provider: SubscriptionProvider,
document: &str,
token_url_override: Option<&str>,
catalog_base_url_override: Option<&str>,
) -> Result<ValidatedCandidate, ImportFailure> {
validate_candidate_at(
data_dir,
provider,
document,
true,
token_url_override,
catalog_base_url_override,
)
.await
}
async fn install_candidate(
destination: &SubscriptionReader,
data_dir: &std::path::Path,
document: &str,
probe: CredentialProbe,
policy: ImportPolicy,
) -> Result<InstallDocumentResult, String> {
if policy.capability_asserted {
tracing::debug!("caller asserted safe-refresh-chain-import-v1");
}
let refusal = (probe != CredentialProbe::Accepted).then(|| {
format!(
"{} candidate was not accepted by the vendor and cannot be installed",
destination.provider()
)
});
if !policy.if_absent
&& let Some(error) = refusal
{
return Err(error);
}
let mode = if policy.if_absent {
InstallMode::IfAbsent
} else {
InstallMode::Replace
};
destination
.install_document_locked_with_refusal(
data_dir,
link_assistant_router::credential_recovery_store::PRIMARY_ACCOUNT,
document,
mode,
refusal,
)
.await
}
pub fn describe_credential(
token: &link_assistant_router::subscription::SubscriptionToken,
) -> String {
let now = chrono::Utc::now().timestamp_millis();
let expiry = token.expires_at_ms.map_or_else(
|| String::from("no recorded expiry"),
|expires_at| {
let minutes = (expires_at - now) / 60_000;
if expires_at <= now {
format!("EXPIRED {} ago", humanize_minutes(-minutes))
} else {
format!("expires in {}", humanize_minutes(minutes))
}
},
);
let refresh = if token.refresh_token.is_some() {
"refresh token present"
} else {
"NO refresh token, so it cannot be renewed"
};
format!("{expiry}, {refresh}")
}
pub fn humanize_minutes(minutes: i64) -> String {
if minutes < 90 {
return format!("{minutes} minutes");
}
let hours = minutes / 60;
if hours < 48 {
return format!("{hours} hours");
}
format!("{} days", hours / 24)
}
pub fn provider_home(
config: &link_assistant_router::config::Config,
provider: SubscriptionProvider,
_user_home: &str,
) -> std::path::PathBuf {
config.credential_home(provider)
}
#[cfg(test)]
#[path = "auth_import_tests.rs"]
mod tests;