use std::{
collections::{BTreeMap, BTreeSet, HashSet},
env,
ffi::OsString,
fs,
io::{self, Write},
os::unix::fs::{OpenOptionsExt, PermissionsExt},
path::{Path, PathBuf},
time::Duration,
};
use async_trait::async_trait;
use semver::Version;
use serde::{
Deserialize,
de::{DeserializeSeed, IgnoredAny, MapAccess, Visitor},
};
use uuid::Uuid;
use crate::{
config::{
app::{ProviderId, ToolConfig},
paths::AppPaths,
},
dependencies::{
command::{CommandOutput, CommandRunner, CommandSpec},
download::ReqwestDownloader,
installer::{
InstallerFilesystem, RenamePurpose, SystemInstallerFilesystem, VerifiedInstaller,
},
node::ManagedNode,
npm::{ManagedNodeRuntime, ManagedNpm, PI_PACKAGE},
platform::Platform,
resolver::{
CLAUDE_VERSION_POLICY, ExecutableRequirement, ExecutableResolver, ExecutableSource,
NODE_VERSION_POLICY, PI_VERSION_POLICY, VersionPolicy,
},
},
domain::errors::{AgentError, AgentResult, ErrorCode},
pairing::{ClientStore, FileClientStore, PairingStoreError, ServerIdentity},
providers::{
descriptor::descriptor,
probe::{
PiAuthMetadata, PiAuthState, ProviderProbe, ProviderProbeResult, ProviderProbeRuntime,
},
},
setup::readiness::{CheckId, CheckResult, CheckStatus},
};
pub(crate) fn check_pairing_readiness(paths: &AppPaths) -> CheckResult {
let result = (|| {
let identity = ServerIdentity::load_or_create(&paths.server_key_file)?;
let store = FileClientStore::new(&paths.clients_file, identity);
ClientStore::list(&store)?;
Ok::<(), PairingStoreError>(())
})();
match result {
Ok(()) => stage_check(CheckId::Pairing, CheckStatus::Ready, None),
Err(error) => stage_check(
CheckId::Pairing,
CheckStatus::ActionRequired,
Some(pairing_readiness_action(error).to_owned()),
),
}
}
fn pairing_readiness_action(error: PairingStoreError) -> &'static str {
match error {
PairingStoreError::IdentityStorage | PairingStoreError::Randomness => {
"server identity storage is invalid or unavailable"
}
PairingStoreError::Corrupt | PairingStoreError::Duplicate => {
"approved browser storage is malformed or unsupported"
}
PairingStoreError::Storage | PairingStoreError::CommitUncertain => {
"approved browser storage is unavailable"
}
PairingStoreError::InvalidLabel
| PairingStoreError::ClientLimit
| PairingStoreError::BindingMismatch
| PairingStoreError::InvalidBinding => "approved browser storage operation failed",
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SystemProbeResult {
pub(crate) check: CheckResult,
pub(crate) managed_install_supported: bool,
}
impl SystemProbeResult {
pub(crate) fn ready(managed_install_supported: bool) -> Self {
Self {
check: stage_check(CheckId::System, CheckStatus::Ready, None),
managed_install_supported,
}
}
pub(crate) fn action_required(
action: impl Into<String>,
managed_install_supported: bool,
) -> Self {
Self {
check: stage_check(
CheckId::System,
CheckStatus::ActionRequired,
Some(action.into()),
),
managed_install_supported,
}
}
}
#[async_trait]
pub(crate) trait SystemProbe: Send + Sync {
async fn check(&self) -> AgentResult<SystemProbeResult>;
}
pub(crate) trait SystemProbeOperations: Send + Sync {
fn check_private_paths(&self, paths: &AppPaths) -> Result<(), ()>;
fn check_durable_replace(&self, directory: &std::path::Path) -> Result<(), ()>;
fn check_atomic_activation(&self, directory: &std::path::Path) -> Result<(), ()>;
fn check_rustls_client(&self) -> Result<(), ()>;
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct LocalSystemOperations;
impl SystemProbeOperations for LocalSystemOperations {
fn check_private_paths(&self, paths: &AppPaths) -> Result<(), ()> {
let config = match paths.config_file.parent() {
Some(parent) if parent.as_os_str().is_empty() => Path::new("."),
Some(parent) => parent,
None => return Err(()),
};
for (directory, require_private) in [
(config, false),
(paths.data_dir.as_path(), true),
(paths.cache_dir.as_path(), true),
] {
let created = match fs::symlink_metadata(directory) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
return Err(());
}
Ok(_) => false,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
fs::create_dir_all(directory).map_err(|_| ())?;
true
}
Err(_) => return Err(()),
};
if created {
fs::set_permissions(directory, fs::Permissions::from_mode(0o700))
.map_err(|_| ())?;
}
let metadata = fs::symlink_metadata(directory).map_err(|_| ())?;
if metadata.file_type().is_symlink()
|| !metadata.is_dir()
|| (require_private && metadata.permissions().mode() & 0o077 != 0)
{
return Err(());
}
}
Ok(())
}
fn check_durable_replace(&self, directory: &Path) -> Result<(), ()> {
let nonce = Uuid::new_v4();
let source = directory.join(format!(".setup-write-{nonce}.tmp"));
let target = directory.join(format!(".setup-rename-{nonce}.tmp"));
let result = (|| {
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&source)
.map_err(|_| ())?;
file.write_all(b"regy setup durability probe")
.map_err(|_| ())?;
file.sync_all().map_err(|_| ())?;
fs::rename(&source, &target).map_err(|_| ())?;
fs::File::open(directory)
.and_then(|directory| directory.sync_all())
.map_err(|_| ())?;
fs::remove_file(&target).map_err(|_| ())?;
fs::File::open(directory)
.and_then(|directory| directory.sync_all())
.map_err(|_| ())?;
Ok(())
})();
let _ = fs::remove_file(&source);
let _ = fs::remove_file(&target);
result
}
fn check_atomic_activation(&self, directory: &Path) -> Result<(), ()> {
check_atomic_activation_with_writer(directory, &SystemAtomicProbeWriter)
}
fn check_rustls_client(&self) -> Result<(), ()> {
reqwest::Client::builder()
.use_rustls_tls()
.build()
.map(|_| ())
.map_err(|_| ())
}
}
pub(crate) trait AtomicProbeWriter {
fn write_and_sync(&self, file: &mut fs::File, contents: &[u8]) -> io::Result<()>;
}
struct SystemAtomicProbeWriter;
impl AtomicProbeWriter for SystemAtomicProbeWriter {
fn write_and_sync(&self, file: &mut fs::File, contents: &[u8]) -> io::Result<()> {
file.write_all(contents)?;
file.sync_all()
}
}
pub(crate) fn check_atomic_activation_with_writer(
directory: &Path,
writer: &dyn AtomicProbeWriter,
) -> Result<(), ()> {
let nonce = Uuid::new_v4();
let source = directory.join(format!(".setup-atomic-source-{nonce}.tmp"));
let absent = directory.join(format!(".setup-atomic-absent-{nonce}.tmp"));
let existing = directory.join(format!(".setup-atomic-existing-{nonce}.tmp"));
let filesystem = SystemInstallerFilesystem;
let mut owned = BTreeSet::new();
let result = (|| {
write_atomic_probe_file(&mut owned, writer, &source, b"source")?;
filesystem
.rename_noreplace(&source, &absent, RenamePurpose::TargetCommit)
.map_err(|_| ())?;
owned.remove(&source);
owned.insert(absent.clone());
if fs::read(&absent).map_err(|_| ())? != b"source" {
return Err(());
}
write_atomic_probe_file(&mut owned, writer, &source, b"blocked")?;
write_atomic_probe_file(&mut owned, writer, &existing, b"existing")?;
let error =
match filesystem.rename_noreplace(&source, &existing, RenamePurpose::TargetCommit) {
Ok(()) => return Err(()),
Err(error) => error,
};
if error.kind() != io::ErrorKind::AlreadyExists
|| fs::read(&source).map_err(|_| ())? != b"blocked"
|| fs::read(&existing).map_err(|_| ())? != b"existing"
{
return Err(());
}
filesystem
.rename_exchange(&source, &existing, RenamePurpose::TargetCommit)
.map_err(|_| ())?;
if fs::read(&source).map_err(|_| ())? != b"existing"
|| fs::read(&existing).map_err(|_| ())? != b"blocked"
{
return Err(());
}
fs::File::open(directory)
.and_then(|directory| directory.sync_all())
.map_err(|_| ())
})();
let cleanup = cleanup_atomic_probe_files(directory, &owned);
result.and(cleanup)
}
fn write_atomic_probe_file(
owned: &mut BTreeSet<PathBuf>,
writer: &dyn AtomicProbeWriter,
path: &Path,
contents: &[u8],
) -> Result<(), ()> {
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.map_err(|_| ())?;
owned.insert(path.to_path_buf());
writer.write_and_sync(&mut file, contents).map_err(|_| ())
}
fn cleanup_atomic_probe_files(directory: &Path, owned: &BTreeSet<PathBuf>) -> Result<(), ()> {
let mut cleanup_failed = false;
for path in owned {
match fs::remove_file(path) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(_) => cleanup_failed = true,
}
}
if fs::File::open(directory)
.and_then(|directory| directory.sync_all())
.is_err()
{
cleanup_failed = true;
}
if cleanup_failed { Err(()) } else { Ok(()) }
}
pub(crate) struct LocalSystemProbe<'a> {
platform: Platform,
paths: AppPaths,
operations: &'a dyn SystemProbeOperations,
}
impl<'a> LocalSystemProbe<'a> {
pub(crate) fn with_operations(
platform: Platform,
paths: AppPaths,
operations: &'a dyn SystemProbeOperations,
) -> Self {
Self {
platform,
paths,
operations,
}
}
}
#[async_trait]
impl SystemProbe for LocalSystemProbe<'_> {
async fn check(&self) -> AgentResult<SystemProbeResult> {
let managed = self.platform.supports_managed_install();
if self.operations.check_private_paths(&self.paths).is_err() {
return Ok(SystemProbeResult::action_required(
"System HOME/XDG paths must be usable private directories.",
managed,
));
}
if self
.operations
.check_durable_replace(&self.paths.data_dir)
.is_err()
{
return Ok(SystemProbeResult::action_required(
"System write, fsync, rename, and directory fsync check failed.",
managed,
));
}
if managed
&& self
.operations
.check_atomic_activation(&self.paths.data_dir)
.is_err()
{
return Ok(SystemProbeResult::action_required(
"Managed installation requires atomic no-replace and exchange renames on the data filesystem.",
false,
));
}
if self.operations.check_rustls_client().is_err() {
return Ok(SystemProbeResult::action_required(
"The rustls HTTP client could not be constructed.",
managed,
));
}
Ok(SystemProbeResult::ready(managed))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct VerifiedToolCommands {
pub(crate) node: Vec<String>,
pub(crate) pi: Vec<String>,
pub(crate) claude: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PiProbeResult {
pub(crate) check: CheckResult,
pub(crate) commands: Option<VerifiedToolCommands>,
}
impl PiProbeResult {
pub(crate) fn ready(commands: VerifiedToolCommands) -> Self {
Self {
check: stage_check(CheckId::Pi, CheckStatus::Ready, None),
commands: Some(commands),
}
}
pub(crate) fn action_required(action: impl Into<String>) -> Self {
Self {
check: stage_check(
CheckId::Pi,
CheckStatus::ActionRequired,
Some(action.into()),
),
commands: None,
}
}
}
#[async_trait]
pub(crate) trait PiProbe: Send + Sync {
async fn check(&self, configured: &ToolConfig) -> AgentResult<PiProbeResult>;
async fn install(&self) -> AgentResult<PiProbeResult>;
}
#[async_trait]
pub(crate) trait PiSetupOperations: Send + Sync {
async fn resolve(&self, configured: &ToolConfig) -> AgentResult<Option<VerifiedToolCommands>>;
async fn install(&self) -> AgentResult<VerifiedToolCommands>;
}
pub(crate) struct LocalPiSetupOperations<'a> {
paths: AppPaths,
platform: Platform,
runner: &'a dyn CommandRunner,
path: Option<OsString>,
}
impl<'a> LocalPiSetupOperations<'a> {
pub(crate) fn new(paths: AppPaths, platform: Platform, runner: &'a dyn CommandRunner) -> Self {
let path = if cfg!(test) {
None
} else {
env::var_os("PATH")
};
Self::with_path(paths, platform, runner, path)
}
pub(crate) fn with_path(
paths: AppPaths,
platform: Platform,
runner: &'a dyn CommandRunner,
path: Option<OsString>,
) -> Self {
Self {
paths,
platform,
runner,
path,
}
}
async fn resolve_commands(
&self,
configured: &ToolConfig,
) -> AgentResult<Option<VerifiedToolCommands>> {
let tools = self.paths.data_dir.join("tools");
let managed_node = tools.join("node-current/bin/node");
let node = ExecutableResolver::with_path(self.runner, self.path.clone())
.resolve(ExecutableRequirement {
name: "node",
explicit: single_program(&configured.node_command),
managed: managed_node.clone(),
trusted_managed_explicit: is_trusted_managed_node(
&configured.node_command,
&managed_node,
&tools,
),
version_args: &["--version"],
version_policy: NODE_VERSION_POLICY,
})
.await?;
let Some(node) = node else {
return Ok(None);
};
let cwd = fs::canonicalize(&self.paths.data_dir).map_err(|_| pi_setup_failed())?;
let managed_pi =
tools.join("pi-current/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js");
let pi = self
.resolve_command_vector(
"pi",
configured.pi_command.as_ref(),
&node.path,
&managed_pi,
PI_VERSION_POLICY,
&cwd,
false,
node.source == ExecutableSource::Managed
&& is_trusted_managed_command(
configured.pi_command.as_ref(),
&node.path,
&managed_pi,
&tools,
),
)
.await?;
let Some(pi) = pi else {
return Ok(None);
};
let claude = self
.resolve_command_vector(
"claude",
configured.claude_command.as_ref(),
&node.path,
&tools.join(
"claude-current/lib/node_modules/@anthropic-ai/claude-code/bin/claude.js",
),
CLAUDE_VERSION_POLICY,
&cwd,
true,
false,
)
.await?;
Ok(Some(VerifiedToolCommands {
node: paths_to_strings(&[node.path])?,
pi: paths_to_strings(&pi)?,
claude: claude.map(|paths| paths_to_strings(&paths)).transpose()?,
}))
}
#[allow(clippy::too_many_arguments)]
async fn resolve_command_vector(
&self,
name: &'static str,
explicit: Option<&Vec<String>>,
node: &Path,
managed_script: &Path,
policy: VersionPolicy,
cwd: &Path,
managed_claude: bool,
trusted_managed_explicit: bool,
) -> AgentResult<Option<Vec<PathBuf>>> {
let mut candidates = Vec::new();
if let Some(explicit) = explicit.filter(|command| !command.is_empty()) {
candidates.push((
if trusted_managed_explicit {
ExecutableSource::Managed
} else {
ExecutableSource::Explicit
},
explicit.iter().map(PathBuf::from).collect::<Vec<PathBuf>>(),
));
}
if let Some(path) = &self.path {
candidates.extend(
env::split_paths(path)
.filter(|directory| !directory.as_os_str().is_empty())
.map(|directory| (ExecutableSource::Path, vec![directory.join(name)])),
);
}
candidates.push((
ExecutableSource::Managed,
vec![node.to_path_buf(), managed_script.to_path_buf()],
));
let mut visited = HashSet::new();
for (source, candidate) in candidates {
let Some(command) = canonical_command(&candidate) else {
continue;
};
if !visited.insert(command.clone()) {
continue;
}
let mut args = command[1..]
.iter()
.map(|path| path.as_os_str().to_os_string())
.collect::<Vec<_>>();
args.push(OsString::from("--version"));
let env = if managed_claude && command.len() == 2 {
BTreeMap::from([(OsString::from("DISABLE_AUTOUPDATER"), OsString::from("1"))])
} else {
BTreeMap::new()
};
let spec = CommandSpec {
program: command[0].clone(),
args,
env,
cwd: Some(cwd.to_path_buf()),
timeout: Duration::from_secs(5),
};
let Ok(output) = self.runner.output(&spec).await else {
continue;
};
if output.status == 0 && compatible_output(&output, policy) {
return Ok(Some(if source == ExecutableSource::Managed {
candidate
} else {
command
}));
}
}
Ok(None)
}
}
#[async_trait]
impl PiSetupOperations for LocalPiSetupOperations<'_> {
async fn resolve(&self, configured: &ToolConfig) -> AgentResult<Option<VerifiedToolCommands>> {
self.resolve_commands(configured).await
}
async fn install(&self) -> AgentResult<VerifiedToolCommands> {
if !self.platform.supports_managed_install() {
return Err(pi_setup_failed());
}
let downloader = ReqwestDownloader::new();
let installer = VerifiedInstaller::new(self.paths.clone(), &downloader, self.runner);
let installed_node = ManagedNode::for_platform(self.platform)?
.install(&installer)
.await?;
let node = installed_node.current.join("bin/node");
let npm = installed_node.current.join("bin/npm");
let tools = self.paths.data_dir.join("tools");
let installed_pi = ManagedNpm::new(ManagedNodeRuntime::new(&node, npm, tools), self.runner)
.install(&PI_PACKAGE)
.await?;
let pi = installed_pi.executable;
let cwd = fs::canonicalize(&self.paths.data_dir).map_err(|_| pi_setup_failed())?;
let command = vec![node.clone(), pi];
if !probe_vector(self.runner, &command, PI_VERSION_POLICY, &cwd).await? {
return Err(pi_setup_failed());
}
Ok(VerifiedToolCommands {
node: paths_to_strings(&[node])?,
pi: paths_to_strings(&command)?,
claude: None,
})
}
}
pub(crate) struct LocalPiProbe<'a> {
managed_install_supported: bool,
operations: &'a dyn PiSetupOperations,
}
impl<'a> LocalPiProbe<'a> {
pub(crate) fn new(
managed_install_supported: bool,
operations: &'a dyn PiSetupOperations,
) -> Self {
Self {
managed_install_supported,
operations,
}
}
}
#[async_trait]
impl PiProbe for LocalPiProbe<'_> {
async fn check(&self, configured: &ToolConfig) -> AgentResult<PiProbeResult> {
Ok(match self.operations.resolve(configured).await? {
Some(commands) => PiProbeResult::ready(commands),
None => PiProbeResult::action_required(if self.managed_install_supported {
"Locate a compatible Pi executable or approve the managed installation."
} else {
"Configure an existing compatible Pi executable; managed installation is unavailable on this platform."
}),
})
}
async fn install(&self) -> AgentResult<PiProbeResult> {
if !self.managed_install_supported {
return Ok(PiProbeResult::action_required(
"Configure an existing compatible Pi executable; managed installation is unavailable on this platform.",
));
}
Ok(PiProbeResult::ready(self.operations.install().await?))
}
}
fn single_program(command: &Option<Vec<String>>) -> Option<PathBuf> {
command
.as_ref()
.filter(|command| command.len() == 1)
.map(|command| PathBuf::from(&command[0]))
}
fn is_trusted_managed_node(command: &Option<Vec<String>>, managed: &Path, tools: &Path) -> bool {
matches!(command.as_deref(), Some([program]) if Path::new(program) == managed)
&& is_canonically_contained(managed, tools)
}
fn is_trusted_managed_command(
command: Option<&Vec<String>>,
node: &Path,
script: &Path,
tools: &Path,
) -> bool {
matches!(command.map(Vec::as_slice), Some([program, argument])
if Path::new(program) == node && Path::new(argument) == script)
&& is_canonically_contained(node, tools)
&& is_canonically_contained(script, tools)
}
fn is_canonically_contained(path: &Path, tools: &Path) -> bool {
let Ok(canonical_tools) = fs::canonicalize(tools) else {
return false;
};
fs::canonicalize(path).is_ok_and(|canonical| canonical.starts_with(canonical_tools))
}
fn canonical_command(command: &[PathBuf]) -> Option<Vec<PathBuf>> {
if !(command.len() == 1 || command.len() == 2) {
return None;
}
let canonical = command
.iter()
.map(fs::canonicalize)
.collect::<Result<Vec<_>, _>>()
.ok()?;
for (index, path) in canonical.iter().enumerate() {
let metadata = fs::metadata(path).ok()?;
if !metadata.is_file() || (index == 0 && metadata.permissions().mode() & 0o111 == 0) {
return None;
}
}
Some(canonical)
}
fn paths_to_strings(paths: &[PathBuf]) -> AgentResult<Vec<String>> {
paths
.iter()
.map(|path| path.to_str().map(str::to_owned).ok_or_else(pi_setup_failed))
.collect()
}
async fn probe_vector(
runner: &dyn CommandRunner,
command: &[PathBuf],
policy: VersionPolicy,
cwd: &Path,
) -> AgentResult<bool> {
let command = canonical_command(command).ok_or_else(pi_setup_failed)?;
let mut args = command[1..]
.iter()
.map(|path| path.as_os_str().to_os_string())
.collect::<Vec<_>>();
args.push(OsString::from("--version"));
let output = runner
.output(&CommandSpec {
program: command[0].clone(),
args,
env: BTreeMap::new(),
cwd: Some(cwd.to_path_buf()),
timeout: Duration::from_secs(5),
})
.await?;
Ok(output.status == 0 && compatible_output(&output, policy))
}
fn compatible_output(output: &CommandOutput, policy: VersionPolicy) -> bool {
const MAX_VERSION_BYTES: usize = 1024 * 1024;
if output.stdout.len() > MAX_VERSION_BYTES || output.stderr.len() > MAX_VERSION_BYTES {
return false;
}
let Ok(stdout) = std::str::from_utf8(&output.stdout) else {
return false;
};
let Ok(stderr) = std::str::from_utf8(&output.stderr) else {
return false;
};
let Some(minimum) = Version::parse(policy.minimum_inclusive).ok() else {
return false;
};
stdout
.split_whitespace()
.chain(stderr.split_whitespace())
.filter_map(parse_version_token)
.any(|version| version.pre.is_empty() && version >= minimum)
}
fn parse_version_token(token: &str) -> Option<Version> {
let token = token.trim_matches(|character: char| {
!character.is_ascii_alphanumeric() && !matches!(character, '.' | '-' | '+')
});
let token = token
.strip_prefix('v')
.or_else(|| token.strip_prefix('V'))
.unwrap_or(token);
Version::parse(token).ok()
}
fn pi_setup_failed() -> AgentError {
AgentError::new(
ErrorCode::InvalidMessage,
"managed Pi setup could not verify compatible tools",
)
}
fn auth_metadata_failed() -> AgentError {
AgentError::new(
ErrorCode::InvalidMessage,
"Pi authentication metadata could not be read safely",
)
}
#[async_trait]
pub(crate) trait ProvidersProbe: Send + Sync {
async fn check(&self, selected: &[ProviderId]) -> AgentResult<Vec<ProviderProbeResult>>;
async fn install_extensions(&self, selected: &[ProviderId]) -> AgentResult<()>;
async fn login(&self, provider: ProviderId) -> AgentResult<ProviderProbeResult>;
}
pub(crate) trait ProviderRuntimeSource: Send + Sync {
fn runtime(&self) -> AgentResult<ProviderProbeRuntime>;
}
#[async_trait]
pub(crate) trait ProviderExtensionInstaller: Send + Sync {
async fn install(&self, provider: ProviderId) -> AgentResult<()>;
}
#[async_trait]
pub(crate) trait ProviderLogin: Send + Sync {
async fn login(&self, provider: ProviderId) -> AgentResult<ProviderProbeResult>;
}
pub(crate) struct LocalProvidersProbe<'a> {
runtime: &'a dyn ProviderRuntimeSource,
runner: &'a dyn CommandRunner,
pi_auth: &'a dyn PiAuthMetadata,
extensions: &'a dyn ProviderExtensionInstaller,
login: &'a dyn ProviderLogin,
}
pub(crate) struct FilePiAuthMetadata {
path: PathBuf,
now_epoch_ms: u64,
}
impl std::fmt::Debug for FilePiAuthMetadata {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("FilePiAuthMetadata")
}
}
impl FilePiAuthMetadata {
pub(crate) fn new(path: impl Into<PathBuf>) -> Self {
let now_epoch_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(u64::MAX);
Self::with_now(path, now_epoch_ms)
}
pub(crate) fn with_now(path: impl Into<PathBuf>, now_epoch_ms: u64) -> Self {
Self {
path: path.into(),
now_epoch_ms,
}
}
}
#[async_trait]
impl PiAuthMetadata for FilePiAuthMetadata {
async fn state(&self, provider: ProviderId) -> AgentResult<PiAuthState> {
const MAX_AUTH_BYTES: u64 = 1024 * 1024;
let metadata = match fs::symlink_metadata(&self.path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(PiAuthState::Absent);
}
Err(_) => return Err(auth_metadata_failed()),
};
if metadata.file_type().is_symlink()
|| !metadata.is_file()
|| metadata.len() > MAX_AUTH_BYTES
{
return Err(auth_metadata_failed());
}
let bytes = fs::read(&self.path).map_err(|_| auth_metadata_failed())?;
let mut deserializer = serde_json::Deserializer::from_slice(&bytes);
let entry = SelectedAuthSeed {
provider: descriptor(provider).pi_provider,
}
.deserialize(&mut deserializer)
.map_err(|_| auth_metadata_failed())?;
drop(bytes);
Ok(match entry {
None => PiAuthState::Absent,
Some(entry)
if entry
.expires
.is_some_and(|expires| expires <= self.now_epoch_ms) =>
{
PiAuthState::Expired
}
Some(_) => PiAuthState::Present,
})
}
}
struct SelectedAuthSeed<'a> {
provider: &'a str,
}
impl<'de> DeserializeSeed<'de> for SelectedAuthSeed<'_> {
type Value = Option<AuthEntryMetadata>;
fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_map(SelectedAuthVisitor {
provider: self.provider,
})
}
}
struct SelectedAuthVisitor<'a> {
provider: &'a str,
}
impl<'de> Visitor<'de> for SelectedAuthVisitor<'_> {
type Value = Option<AuthEntryMetadata>;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("a Pi authentication metadata object")
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
let mut selected = None;
while let Some(key) = map.next_key::<String>()? {
if key == self.provider {
selected = Some(map.next_value::<AuthEntryMetadata>()?);
} else {
map.next_value::<IgnoredAny>()?;
}
}
Ok(selected)
}
}
#[derive(Deserialize)]
struct AuthEntryMetadata {
#[serde(default)]
expires: Option<u64>,
}
impl<'a> LocalProvidersProbe<'a> {
pub(crate) fn new(
runtime: &'a dyn ProviderRuntimeSource,
runner: &'a dyn CommandRunner,
pi_auth: &'a dyn PiAuthMetadata,
extensions: &'a dyn ProviderExtensionInstaller,
login: &'a dyn ProviderLogin,
) -> Self {
Self {
runtime,
runner,
pi_auth,
extensions,
login,
}
}
}
#[async_trait]
impl ProvidersProbe for LocalProvidersProbe<'_> {
async fn check(&self, selected: &[ProviderId]) -> AgentResult<Vec<ProviderProbeResult>> {
let probe = ProviderProbe::new(self.runtime.runtime()?, self.runner, self.pi_auth);
let mut results = Vec::with_capacity(selected.len());
for provider in selected {
results.push(probe.probe(*provider).await?);
}
Ok(results)
}
async fn install_extensions(&self, selected: &[ProviderId]) -> AgentResult<()> {
for provider in selected {
self.extensions.install(*provider).await?;
}
Ok(())
}
async fn login(&self, provider: ProviderId) -> AgentResult<ProviderProbeResult> {
self.login.login(provider).await
}
}
fn stage_check(id: CheckId, status: CheckStatus, action: Option<String>) -> CheckResult {
CheckResult {
id,
status,
required: true,
detail: None,
action,
actions: Vec::new(),
}
}