diff --git a/Cargo.toml b/Cargo.toml index 1772fb99..eace073d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ futures = "0.3" getset = "0.1" hex = "0.4" indicatif = "0.18" +keyring = "3.6" intaglio = "1.10" ipnetwork = { version = "0.21.0", features = ["serde"] } jsonwebtoken = "9.3" @@ -87,6 +88,7 @@ tracing = "0.1" tracing-subscriber = "0.3" typed-builder = "0.23" typed-path = "0.12" +url = "2.5" uuid = { version = "1.11", features = ["v4"] } uzers = "0.12" walkdir = "2.4" diff --git a/docs/references/cli.md b/docs/references/cli.md index bd42a52d..564ae935 100644 --- a/docs/references/cli.md +++ b/docs/references/cli.md @@ -646,6 +646,15 @@ msb pull [--image] [--image-group] [options] **Examples:** ```bash +# Pull a private image using env credentials (token) +export MSB_REGISTRY_TOKEN=token123 +msb pull registry.example.com/org/app:1.0 + +# Pull a private image using env credentials (username/password) +export MSB_REGISTRY_USERNAME=user +export MSB_REGISTRY_PASSWORD=pass +msb pull registry.example.com/org/app:1.0 + # Pull an image msb pull --image python:3.11 @@ -686,6 +695,60 @@ msb push myapp:latest === +==- `msb login` +Set registry credentials (persisted in the system keyring). + +```bash +msb login [registry] [--username ] [--password-stdin] [--token ] +``` + +| Option | Description | +| ----------------- | ---------------------------------- | +| `--username` | Registry username | +| `--password-stdin`| Read password from stdin | +| `--token` | Registry access token (bearer) | + +**Examples:** + +```bash +# Provide a token (bearer auth) +msb login registry.example.com --token token123 + +# Provide username and password via stdin +echo "pass" | msb login docker.io --username user --password-stdin + +# GHCR: use basic auth (username + PAT) instead of bearer token +echo "$GITHUB_PAT" | msb login ghcr.io --username "$GITHUB_USER" --password-stdin +``` + +!!!note +For registries like `ghcr.io`, prefer basic auth (`--username` + `--password-stdin` with a GitHub PAT). +Using `--token` sends bearer auth and may return `invalid token` on GHCR. +!!! + +!!!warning Security +Credentials are stored in the system keyring under service names like `microsandbox:`. +Protect your local keychain/session and avoid exposing secrets in shell history. +!!! + +=== + +==- `msb logout` +Remove stored registry credentials. + +```bash +msb logout [registry] +``` + +**Examples:** + +```bash +# Remove credentials for a registry +msb logout ghcr.io +``` + +=== + --- ### Maintenance diff --git a/microsandbox-cli/bin/msb/handlers.rs b/microsandbox-cli/bin/msb/handlers.rs index 4c6bf680..6eac4600 100644 --- a/microsandbox-cli/bin/msb/handlers.rs +++ b/microsandbox-cli/bin/msb/handlers.rs @@ -8,11 +8,12 @@ use microsandbox_core::{ config::{self, Component, ComponentType, SandboxConfig}, home, menv, orchestra, sandbox, toolchain, }, - oci::Reference, + oci::{Reference, resolve_explicit_credentials}, }; use microsandbox_server::MicrosandboxServerResult; -use microsandbox_utils::{PROJECTS_SUBDIR, env}; +use microsandbox_utils::{CredentialStore, MsbRegistryAuth, PROJECTS_SUBDIR, env}; use std::{collections::HashMap, path::PathBuf}; +use tokio::io::{self, AsyncRead, AsyncReadExt}; use typed_path::Utf8UnixPathBuf; //-------------------------------------------------------------------------------------------------- @@ -436,10 +437,7 @@ pub async fn server_ssh_subcommand(_sandbox: bool, _name: String) -> Microsandbo pub async fn self_subcommand(action: SelfAction) -> MicrosandboxCliResult<()> { match action { SelfAction::Upgrade => { - println!( - "{} upgrade functionality is not yet implemented", - "error:".error() - ); + tracing::error!("upgrade functionality is not yet implemented"); return Ok(()); } SelfAction::Uninstall => { @@ -611,19 +609,58 @@ pub async fn server_status_subcommand( Ok(()) } -pub async fn login_subcommand() -> MicrosandboxCliResult<()> { - println!( - "{} login functionality is not yet implemented", - "error:".error() +/// Handle `msb login` by resolving credentials and persisting them for a registry. +/// +/// The registry is resolved from CLI input first, then environment defaults. +/// Credentials can come from CLI flags or environment variables and are stored +/// without remote validation. +pub async fn login_subcommand( + registry: Option, + username: Option, + password_stdin: bool, + token: Option, +) -> MicrosandboxCliResult<()> { + let registry = resolve_registry_host(registry); + let cli_password = if password_stdin { + Some(read_password_from_stdin().await?) + } else { + None + }; + let stored_credentials = resolve_explicit_credentials(username, cli_password, token)?; + let saved_message = match &stored_credentials { + MsbRegistryAuth::Basic { .. } => "credentials", + MsbRegistryAuth::Token { .. } => "token", + }; + + CredentialStore::store_registry_credentials(®istry, stored_credentials) + .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; + tracing::info!( + "{} saved for registry {} (not validated)", + saved_message, + registry ); + + Ok(()) +} + +/// Handle `msb logout` by removing stored registry credentials. +/// +/// Only credentials for the resolved registry host are deleted. +pub async fn logout_subcommand(registry: Option) -> MicrosandboxCliResult<()> { + let registry = resolve_registry_host(registry); + let removed = CredentialStore::remove_registry_credentials(®istry) + .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; + if removed { + tracing::info!("removed stored credentials for registry {}", registry); + } else { + tracing::info!("no stored credentials found for registry {}", registry); + } + Ok(()) } pub async fn push_subcommand(_image: bool, _name: String) -> MicrosandboxCliResult<()> { - println!( - "{} push functionality is not yet implemented", - "error:".error() - ); + tracing::error!("push functionality is not yet implemented"); Ok(()) } @@ -681,6 +718,58 @@ fn parse_name_and_script(name_and_script: &str) -> (&str, Option<&str>) { (name, script) } +//-------------------------------------------------------------------------------------------------- +// Functions: Login Helpers +//-------------------------------------------------------------------------------------------------- + +/// Resolve the effective registry host from CLI and environment configuration. +/// +/// Resolution order is: explicit `registry` argument, `MSB_REGISTRY_HOST`, +/// then the default OCI registry. The returned host is normalized. +fn resolve_registry_host(registry: Option) -> String { + let host = registry.unwrap_or_else(env::get_oci_registry); + normalize_registry_host(&host) +} + +/// Normalize a given host url string +/// +/// This for avoiding common user input issues like including protocol or trailing slashes. +pub fn normalize_registry_host(host: &str) -> String { + let mut normalized = host.trim().to_lowercase(); + + if let Some(stripped) = normalized.strip_prefix("https://") { + normalized = stripped.to_string(); + } else if let Some(stripped) = normalized.strip_prefix("http://") { + normalized = stripped.to_string(); + } + + normalized.trim_end_matches('/').to_string() +} + +/// Read a password from stdin and trim trailing newlines. +/// +/// Returns an error when stdin is empty after trimming. +async fn read_password_from_stdin() -> MicrosandboxCliResult { + let mut stdin = io::stdin(); + read_password_from_reader(&mut stdin).await +} + +/// Read a password from any async reader and trim trailing newlines. +async fn read_password_from_reader(reader: &mut R) -> MicrosandboxCliResult +where + R: AsyncRead + Unpin, +{ + let mut input = String::new(); + reader.read_to_string(&mut input).await?; + let password = input.trim_end_matches(&['\n', '\r'][..]).to_string(); + if password.is_empty() { + return Err(MicrosandboxCliError::InvalidArgument( + "password provided via stdin is empty".to_string(), + )); + } + Ok(password) +} + /// Parse a file path into project path and config file name. /// /// If the file path is a directory, it is treated as the project path. @@ -819,3 +908,191 @@ fn validate_build_sandbox_conflict( .exit(); } } + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + use tokio::io::AsyncWriteExt; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn lock_env() -> std::sync::MutexGuard<'static, ()> { + ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner()) + } + + struct EnvGuard { + key: &'static str, + prev: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: impl Into) -> Self { + let prev = std::env::var_os(key); + let value: std::ffi::OsString = value.into(); + unsafe { std::env::set_var(key, &value) }; + Self { key, prev } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = self.prev.take() { + unsafe { std::env::set_var(self.key, value) }; + } else { + unsafe { std::env::remove_var(self.key) }; + } + } + } + + #[test] + fn resolve_registry_host_prefers_cli_value() { + let _lock = lock_env(); + let _host = EnvGuard::set(env::MSB_REGISTRY_HOST_ENV_VAR, "env.example.com"); + let resolved = resolve_registry_host(Some("cli.example.com".to_string())); + assert_eq!(resolved, "cli.example.com"); + } + + #[test] + fn resolve_registry_host_uses_env_when_cli_missing() { + let _lock = lock_env(); + let _host = EnvGuard::set(env::MSB_REGISTRY_HOST_ENV_VAR, "https://Env.Example.com/"); + let resolved = resolve_registry_host(None); + assert_eq!(resolved, "env.example.com"); + } + + #[test] + fn resolve_explicit_auth_prefers_token() { + let creds = resolve_explicit_credentials(None, None, Some("cli-token".to_string())) + .expect("resolve creds"); + assert!(matches!( + creds, + MsbRegistryAuth::Token { token } if token == "cli-token" + )); + } + + #[test] + fn resolve_explicit_auth_accepts_basic_auth() { + let creds = + resolve_explicit_credentials(Some("user".to_string()), Some("pass".to_string()), None) + .expect("resolve creds"); + assert!(matches!( + creds, + MsbRegistryAuth::Basic { username, password } + if username == "user" && password == "pass" + )); + } + + #[test] + fn resolve_explicit_auth_errors_for_conflicting_inputs() { + let result = resolve_explicit_credentials( + Some("user".to_string()), + Some("pass".to_string()), + Some("token".to_string()), + ); + assert!(matches!( + result, + Err(microsandbox_core::MicrosandboxError::InvalidArgument(_)) + )); + } + + #[test] + fn resolve_explicit_auth_errors_when_missing() { + let result = resolve_explicit_credentials(None, None, None); + assert!(matches!( + result, + Err(microsandbox_core::MicrosandboxError::InvalidArgument(_)) + )); + } + + #[tokio::test] + async fn read_password_from_stdin_trims_trailing_newline() { + let (mut writer, mut reader) = tokio::io::duplex(64); + writer.write_all(b"secret\n").await.expect("write"); + drop(writer); + + let password = read_password_from_reader(&mut reader) + .await + .expect("password"); + assert_eq!(password, "secret"); + } + + #[tokio::test] + async fn read_password_from_stdin_errors_on_empty_input() { + let (mut writer, mut reader) = tokio::io::duplex(64); + writer.write_all(b"\n").await.expect("write"); + drop(writer); + + let result = read_password_from_reader(&mut reader).await; + assert!(matches!( + result, + Err(MicrosandboxCliError::InvalidArgument(_)) + )); + } + + #[tokio::test] + async fn read_password_from_stdin_trims_trailing_carriage_return() { + let (mut writer, mut reader) = tokio::io::duplex(64); + writer.write_all(b"secret\r").await.expect("write"); + drop(writer); + + let password = read_password_from_reader(&mut reader) + .await + .expect("password"); + assert_eq!(password, "secret"); + } + + #[tokio::test] + async fn read_password_from_stdin_trims_trailing_crlf() { + let (mut writer, mut reader) = tokio::io::duplex(64); + writer.write_all(b"secret\r\n").await.expect("write"); + drop(writer); + + let password = read_password_from_reader(&mut reader) + .await + .expect("password"); + assert_eq!(password, "secret"); + } + + #[tokio::test] + async fn read_password_from_stdin_trims_multiple_trailing_newlines() { + let (mut writer, mut reader) = tokio::io::duplex(64); + writer.write_all(b"secret\n\n\r\n").await.expect("write"); + drop(writer); + + let password = read_password_from_reader(&mut reader) + .await + .expect("password"); + assert_eq!(password, "secret"); + } + + #[tokio::test] + async fn read_password_from_stdin_errors_on_empty_input_with_carriage_return() { + let (mut writer, mut reader) = tokio::io::duplex(64); + writer.write_all(b"\r").await.expect("write"); + drop(writer); + + let result = read_password_from_reader(&mut reader).await; + assert!(matches!( + result, + Err(MicrosandboxCliError::InvalidArgument(_)) + )); + } + + #[tokio::test] + async fn read_password_from_stdin_errors_on_empty_input_with_crlf() { + let (mut writer, mut reader) = tokio::io::duplex(64); + writer.write_all(b"\r\n").await.expect("write"); + drop(writer); + + let result = read_password_from_reader(&mut reader).await; + assert!(matches!( + result, + Err(MicrosandboxCliError::InvalidArgument(_)) + )); + } +} diff --git a/microsandbox-cli/bin/msb/main.rs b/microsandbox-cli/bin/msb/main.rs index db1de2c8..341d807f 100644 --- a/microsandbox-cli/bin/msb/main.rs +++ b/microsandbox-cli/bin/msb/main.rs @@ -250,8 +250,16 @@ async fn main() -> MicrosandboxCliResult<()> { handlers::server_ssh_subcommand(sandbox, name).await?; } }, - Some(MicrosandboxSubcommand::Login) => { - handlers::login_subcommand().await?; + Some(MicrosandboxSubcommand::Login { + registry, + username, + password_stdin, + token, + }) => { + handlers::login_subcommand(registry, username, password_stdin, token).await?; + } + Some(MicrosandboxSubcommand::Logout { registry }) => { + handlers::logout_subcommand(registry).await?; } Some(MicrosandboxSubcommand::Push { image, name }) => { handlers::push_subcommand(image, name).await?; diff --git a/microsandbox-cli/lib/args/msb.rs b/microsandbox-cli/lib/args/msb.rs index d2806ccc..305d1230 100644 --- a/microsandbox-cli/lib/args/msb.rs +++ b/microsandbox-cli/lib/args/msb.rs @@ -520,7 +520,29 @@ pub enum MicrosandboxSubcommand { /// Login to a registry #[command(name = "login")] - Login, + Login { + /// Registry host (defaults to OCI_REGISTRY_DOMAIN or docker.io) + registry: Option, + + /// Registry username + #[arg(short, long)] + username: Option, + + /// Read password from stdin + #[arg(long)] + password_stdin: bool, + + /// Registry token + #[arg(long)] + token: Option, + }, + + /// Logout from a registry + #[command(name = "logout")] + Logout { + /// Registry host (defaults to OCI_REGISTRY_DOMAIN or docker.io) + registry: Option, + }, /// Push image to a registry #[command(name = "push")] diff --git a/microsandbox-core/lib/management/mod.rs b/microsandbox-core/lib/management/mod.rs index c65d8d75..2d491fb4 100644 --- a/microsandbox-core/lib/management/mod.rs +++ b/microsandbox-core/lib/management/mod.rs @@ -7,7 +7,6 @@ //! //! Key components: //! - `db`: Database management for storing container and sandbox metadata -//! - `image`: Container image handling and registry operations //! - `menv`: Microsandbox environment management //! - `rootfs`: Root filesystem operations for containers //! - `sandbox`: Sandbox creation and management diff --git a/microsandbox-core/lib/oci/image.rs b/microsandbox-core/lib/oci/image.rs index edd75b2a..f1356696 100644 --- a/microsandbox-core/lib/oci/image.rs +++ b/microsandbox-core/lib/oci/image.rs @@ -38,7 +38,7 @@ use crate::{ use futures::future; #[cfg(feature = "cli")] use microsandbox_utils::term::{self}; -use microsandbox_utils::{LAYERS_SUBDIR, OCI_DB_FILENAME, env}; +use microsandbox_utils::{CredentialStore, LAYERS_SUBDIR, OCI_DB_FILENAME, env}; use oci_spec::image::{Digest, Os, Platform}; use std::{path::PathBuf, sync::Arc}; use tempfile::tempdir; @@ -147,7 +147,7 @@ impl Image { let mut platform = Platform::default(); platform.set_os(Os::Linux); - Registry::new(db.clone(), platform, layer_cache) + Registry::::new(db.clone(), platform, layer_cache, CredentialStore) .await? .pull_image(&image) .await diff --git a/microsandbox-core/lib/oci/mocks.rs b/microsandbox-core/lib/oci/mocks.rs index 1ee4a629..afd43509 100644 --- a/microsandbox-core/lib/oci/mocks.rs +++ b/microsandbox-core/lib/oci/mocks.rs @@ -1,3 +1,4 @@ +use microsandbox_utils::CredentialStore; use oci_spec::image::Platform; use sqlx::{Pool, Sqlite}; @@ -23,7 +24,7 @@ pub(crate) async fn mock_registry_and_db() -> (Registry, Pool::new(db.clone(), platform, layer_ops, CredentialStore) .await .unwrap(); (registry, db, temp_dir) diff --git a/microsandbox-core/lib/oci/mod.rs b/microsandbox-core/lib/oci/mod.rs index 07d8d9a5..e4ef1a6a 100644 --- a/microsandbox-core/lib/oci/mod.rs +++ b/microsandbox-core/lib/oci/mod.rs @@ -23,4 +23,5 @@ pub(crate) use global_cache::*; pub use image::*; pub(crate) use layer::*; pub use reference::*; +pub use registry::resolve_explicit_credentials; pub(crate) use registry::*; diff --git a/microsandbox-core/lib/oci/reference.rs b/microsandbox-core/lib/oci/reference.rs index 7a457279..5626b12c 100644 --- a/microsandbox-core/lib/oci/reference.rs +++ b/microsandbox-core/lib/oci/reference.rs @@ -26,6 +26,11 @@ impl Reference { pub(crate) fn as_db_key(&self) -> String { self.reference.to_string() } + + /// Resolve the effective registry host for this image reference. + pub fn resolve_registry(&self) -> &str { + self.reference.resolve_registry() + } } impl Deref for Reference { @@ -67,3 +72,30 @@ impl fmt::Display for Reference { write!(f, "{}", self.reference) } } + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_registry_uses_explicit_registry_host() { + let reference: Reference = "ghcr.io/org/app:1.0".parse().unwrap(); + assert_eq!(reference.resolve_registry(), "ghcr.io"); + } + + #[test] + fn resolve_registry_uses_index_docker_io_when_host_missing() { + let reference: Reference = "org/app:1.0".parse().unwrap(); + assert_eq!(reference.resolve_registry(), "index.docker.io"); + } + + #[test] + fn resolve_registry_reflects_upstream_normalization_for_index_docker_io() { + let reference: Reference = "index.docker.io/library/nginx:latest".parse().unwrap(); + assert_eq!(reference.resolve_registry(), "index.docker.io"); + } +} diff --git a/microsandbox-core/lib/oci/registry.rs b/microsandbox-core/lib/oci/registry.rs index a6d2fba6..e1ea5103 100644 --- a/microsandbox-core/lib/oci/registry.rs +++ b/microsandbox-core/lib/oci/registry.rs @@ -20,6 +20,8 @@ use tokio::{ io::AsyncWriteExt, }; +use microsandbox_utils::{CredentialStore, MsbRegistryAuth, env}; + use crate::{ MicrosandboxError, MicrosandboxResult, management::db, @@ -46,28 +48,92 @@ const DOWNLOAD_LAYER_MSG: &str = "Download layers"; pub(crate) const DOCKER_REFERENCE_TYPE_ANNOTATION: &str = "vnd.docker.reference.type"; +enum ParsedCredentials { + Present(MsbRegistryAuth), + Missing, + Incomplete, +} + +pub trait RegistryCredentialStoreOps: Send + Sync { + fn load_registry_credentials(&self, host: &str) -> MicrosandboxResult>; +} + +impl RegistryCredentialStoreOps for CredentialStore { + fn load_registry_credentials(&self, host: &str) -> MicrosandboxResult> { + CredentialStore::load_registry_credentials(host).map_err(Into::into) + } +} + +fn parse_credential_inputs( + input_username: Option, + input_password: Option, + input_token: Option, +) -> MicrosandboxResult { + if input_token.is_some() && (input_username.is_some() || input_password.is_some()) { + return Err(MicrosandboxError::InvalidArgument( + "token cannot be combined with username/password".to_string(), + )); + } + + if let Some(token) = input_token { + return Ok(ParsedCredentials::Present(MsbRegistryAuth::Token { token })); + } + + match (input_username, input_password) { + (Some(username), Some(password)) => { + Ok(ParsedCredentials::Present(MsbRegistryAuth::Basic { + username, + password, + })) + } + (None, None) => Ok(ParsedCredentials::Missing), + _ => Ok(ParsedCredentials::Incomplete), + } +} + +/// Resolve explicit registry credentials from user-provided inputs only. +/// +/// This function does not read environment variables nor stored credentials. +pub fn resolve_explicit_credentials( + username: Option, + password: Option, + token: Option, +) -> MicrosandboxResult { + match parse_credential_inputs(username, password, token)? { + ParsedCredentials::Present(credentials) => Ok(credentials), + ParsedCredentials::Missing => Err(MicrosandboxError::InvalidArgument( + "no credentials provided; explicitly pass --token or --username with --password-stdin" + .to_string(), + )), + ParsedCredentials::Incomplete => Err(MicrosandboxError::InvalidArgument( + "both username and password are required".to_string(), + )), + } +} + /// Registry is an abstraction over the logic for fetching images from a registry, /// and storing them in a local cache. /// /// For fetching image, it uses `oci_client` crate which implements the [OCI Distribution Spec]. /// /// [OCI Distribution Spec]: https://distribution.github.io/distribution/spec/manifest-v2-2/#image-manifest-version-2-schema-2 -pub struct Registry { +pub struct Registry { client: OciClient, - /// TODO (333): Support varying auth methods. - auth: RegistryAuth, - /// The database where image configurations, and manifests are stored. db: Pool, /// Abstraction for interacting with the global microsandbox cache. global_cache: C, + + /// Abstraction for interacting with the global credential_store + credential_store: S, } -impl Registry +impl Registry where O: GlobalCacheOps + Send + Sync, + S: RegistryCredentialStoreOps, { /// Creates a new Docker Registry client with the specified image download path and OCI database path. /// @@ -76,10 +142,12 @@ where /// * `db` - The database where image configurations, and manifests are stored /// * `platform` - The platform for which the image is being downloaded /// * `global_cache` - The global layer cache + /// * `credential_store` - The global credential store pub async fn new( db: Pool, platform: Platform, global_cache: O, + credential_store: S, ) -> MicrosandboxResult { let config = OciClientConfig { platform_resolver: Some(Box::new(move |manifests| { @@ -90,12 +158,52 @@ where Ok(Self { client: OciClient::new(config), - auth: RegistryAuth::Anonymous, db, global_cache, + credential_store, }) } + /// Resolve registry auth for a given reference. + /// + /// Priority: + /// 1) Environment variables + /// 2) Stored credentials (msb login) + /// 3) Anonymous + pub(crate) fn resolve_auth_with_store( + reference: &Reference, + credential_store: &S, + ) -> MicrosandboxResult { + // TODO: If Bearer auth fails during registry interaction, retry with Basic auth + // for registries like GHCR that may require username/password fallback. + let registry = reference.resolve_registry().to_string(); + + match parse_credential_inputs( + env::get_registry_username(), + env::get_registry_password(), + env::get_registry_token(), + )? { + ParsedCredentials::Present(credentials) => return Ok(credentials.into()), + ParsedCredentials::Incomplete => { + tracing::warn!( + "registry credentials provided via env are incomplete; falling back to stored or anonymous" + ); + } + ParsedCredentials::Missing => {} + } + + if let Some(stored) = credential_store.load_registry_credentials(®istry)? { + return Ok(stored.into()); + } + + Ok(RegistryAuth::Anonymous) + } + + /// Wrapper for allowing to test this behavior without complicated mocks + pub fn resolve_auth(&self, reference: &Reference) -> MicrosandboxResult { + Self::resolve_auth_with_store(reference, &self.credential_store) + } + /// Returns the global layer cache. pub fn global_cache(&self) -> &O { &self.global_cache @@ -258,13 +366,17 @@ where tracing::info!(?reference, "Image was already extracted"); return Ok(()); } + let auth = self.resolve_auth(reference)?; + self.client + .store_auth_if_needed(reference.resolve_registry(), &auth) + .await; // Calculate total size and save image record #[cfg(feature = "cli")] let fetch_details_sp = term::create_spinner(FETCH_IMAGE_DETAILS_MSG.to_string(), None, None); - let index = self.fetch_index(reference).await?; + let index = self.fetch_index(reference, &auth).await?; let size = match index { OciManifest::Image(m) => m.config.size, OciManifest::ImageIndex(m) => m.manifests.iter().map(|m| m.size).sum(), @@ -272,7 +384,7 @@ where let image_id = db::save_or_update_image(&self.db, &reference.as_db_key(), size).await?; // Fetch and save manifest - let (manifest, config) = self.fetch_manifest_and_config(reference).await?; + let (manifest, config) = self.fetch_manifest_and_config(reference, &auth).await?; let manifest_id = db::save_manifest(&self.db, image_id, &manifest).await?; db::save_config(&self.db, manifest_id, &config).await?; @@ -336,8 +448,9 @@ where pub(crate) async fn fetch_index( &self, reference: &Reference, + auth: &RegistryAuth, ) -> MicrosandboxResult { - let (index, _) = self.client.pull_manifest(reference, &self.auth).await?; + let (index, _) = self.client.pull_manifest(reference, auth).await?; Ok(index) } @@ -349,10 +462,11 @@ where pub(crate) async fn fetch_manifest_and_config( &self, reference: &Reference, + auth: &RegistryAuth, ) -> MicrosandboxResult<(OciImageManifest, OciConfigFile)> { let (manifest, _, config) = self .client - .pull_manifest_and_config(reference, &self.auth) + .pull_manifest_and_config(reference, auth) .await?; let config = OciConfig::oci_v1(config.as_bytes().to_vec(), manifest.annotations.clone()); @@ -403,3 +517,278 @@ where Ok(stream.stream.map(|r| r.map_err(Into::into)).boxed()) } } + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::{collections::HashMap, sync::Mutex}; + + use crate::oci::GlobalCache; + use microsandbox_utils::{CredentialStore, MsbRegistryAuth}; + use tempfile::TempDir; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct MockCredentialStore; + + impl RegistryCredentialStoreOps for MockCredentialStore { + fn load_registry_credentials( + &self, + host: &str, + ) -> MicrosandboxResult> { + let entries = HashMap::from([( + "registry.test.invalid", + MsbRegistryAuth::Token { + token: "mock-token".to_string(), + }, + )]); + Ok(entries.get(host).cloned()) + } + } + + fn lock_env() -> std::sync::MutexGuard<'static, ()> { + ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner()) + } + + fn keyring_roundtrip_available() -> bool { + let probe = MsbRegistryAuth::Token { + token: "probe-token".to_string(), + }; + if CredentialStore::store_registry_credentials("registry.test.invalid", probe.clone()) + .is_err() + { + return false; + } + match CredentialStore::load_registry_credentials("registry.test.invalid") { + Ok(Some(MsbRegistryAuth::Token { token })) => token == "probe-token", + _ => false, + } + } + + struct EnvGuard { + key: &'static str, + prev: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: impl Into) -> Self { + let prev = std::env::var_os(key); + let value: std::ffi::OsString = value.into(); + unsafe { std::env::set_var(key, &value) }; + Self { key, prev } + } + + fn remove(key: &'static str) -> Self { + let prev = std::env::var_os(key); + unsafe { std::env::remove_var(key) }; + Self { key, prev } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(value) = self.prev.take() { + unsafe { std::env::set_var(self.key, value) }; + } else { + unsafe { std::env::remove_var(self.key) }; + } + } + } + + #[test] + fn resolve_explicit_credentials_prefers_token() { + let auth = resolve_explicit_credentials(None, None, Some("token-123".to_string())).unwrap(); + assert!(matches!( + auth, + MsbRegistryAuth::Token { token } if token == "token-123" + )); + } + + #[test] + fn resolve_explicit_credentials_accepts_basic() { + let auth = + resolve_explicit_credentials(Some("user".to_string()), Some("pass".to_string()), None) + .unwrap(); + assert!(matches!( + auth, + MsbRegistryAuth::Basic { username, password } + if username == "user" && password == "pass" + )); + } + + #[tokio::test] + async fn resolve_auth_prefers_env_token() { + let _lock = lock_env(); + let _token = EnvGuard::set(env::MSB_REGISTRY_TOKEN_ENV_VAR, "env-token"); + let _user = EnvGuard::remove(env::MSB_REGISTRY_USERNAME_ENV_VAR); + let _pass = EnvGuard::remove(env::MSB_REGISTRY_PASSWORD_ENV_VAR); + + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + let _ = CredentialStore::remove_registry_credentials("registry.test.invalid"); + CredentialStore::store_registry_credentials( + "registry.test.invalid", + MsbRegistryAuth::Token { + token: "stored-token".to_string(), + }, + ) + .expect("store"); + + let reference: Reference = "registry.test.invalid/org/app:1.0".parse().unwrap(); + let auth = Registry::::resolve_auth_with_store( + &reference, + &CredentialStore, + ) + .expect("resolve auth"); + assert!(matches!(auth, RegistryAuth::Bearer(t) if t == "env-token")); + } + + #[tokio::test] + async fn resolve_auth_prefers_env_basic() { + let _lock = lock_env(); + let _token = EnvGuard::remove(env::MSB_REGISTRY_TOKEN_ENV_VAR); + let _user = EnvGuard::set(env::MSB_REGISTRY_USERNAME_ENV_VAR, "env-user"); + let _pass = EnvGuard::set(env::MSB_REGISTRY_PASSWORD_ENV_VAR, "env-pass"); + + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + let _ = CredentialStore::remove_registry_credentials("registry.test.invalid"); + CredentialStore::store_registry_credentials( + "registry.test.invalid", + MsbRegistryAuth::Token { + token: "stored-token".to_string(), + }, + ) + .expect("store"); + + let reference: Reference = "registry.test.invalid/org/app:1.0".parse().unwrap(); + let auth = Registry::::resolve_auth_with_store( + &reference, + &CredentialStore, + ) + .expect("resolve auth"); + assert!(matches!(auth, RegistryAuth::Basic(u, p) if u == "env-user" && p == "env-pass")); + } + + #[tokio::test] + async fn resolve_auth_uses_stored_credentials_when_env_missing() { + let _lock = lock_env(); + let _token = EnvGuard::remove(env::MSB_REGISTRY_TOKEN_ENV_VAR); + let _user = EnvGuard::remove(env::MSB_REGISTRY_USERNAME_ENV_VAR); + let _pass = EnvGuard::remove(env::MSB_REGISTRY_PASSWORD_ENV_VAR); + + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + let _ = CredentialStore::remove_registry_credentials("registry.test.invalid"); + if !keyring_roundtrip_available() { + eprintln!("skipping: keyring backend does not support roundtrip in this environment"); + return; + } + CredentialStore::store_registry_credentials( + "registry.test.invalid", + MsbRegistryAuth::Token { + token: "stored-token".to_string(), + }, + ) + .expect("store"); + + let reference: Reference = "registry.test.invalid/org/app:1.0".parse().unwrap(); + let auth = Registry::::resolve_auth_with_store( + &reference, + &CredentialStore, + ) + .expect("resolve auth"); + assert!(matches!(auth, RegistryAuth::Bearer(t) if t == "stored-token")); + } + + #[tokio::test] + async fn resolve_auth_falls_back_to_stored_when_env_is_incomplete() { + let _lock = lock_env(); + let _token = EnvGuard::remove(env::MSB_REGISTRY_TOKEN_ENV_VAR); + let _user = EnvGuard::set(env::MSB_REGISTRY_USERNAME_ENV_VAR, "env-user"); + let _pass = EnvGuard::remove(env::MSB_REGISTRY_PASSWORD_ENV_VAR); + + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + let _ = CredentialStore::remove_registry_credentials("registry.test.invalid"); + if !keyring_roundtrip_available() { + eprintln!("skipping: keyring backend does not support roundtrip in this environment"); + return; + } + CredentialStore::store_registry_credentials( + "registry.test.invalid", + MsbRegistryAuth::Token { + token: "stored-token".to_string(), + }, + ) + .expect("store"); + + let reference: Reference = "registry.test.invalid/org/app:1.0".parse().unwrap(); + let auth = Registry::::resolve_auth_with_store( + &reference, + &CredentialStore, + ) + .expect("resolve auth"); + assert!(matches!(auth, RegistryAuth::Bearer(t) if t == "stored-token")); + } + + #[tokio::test] + async fn resolve_auth_returns_anonymous_when_missing() { + let _lock = lock_env(); + let _token = EnvGuard::remove(env::MSB_REGISTRY_TOKEN_ENV_VAR); + let _user = EnvGuard::remove(env::MSB_REGISTRY_USERNAME_ENV_VAR); + let _pass = EnvGuard::remove(env::MSB_REGISTRY_PASSWORD_ENV_VAR); + + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + let _ = CredentialStore::remove_registry_credentials("registry.test.invalid"); + + let reference: Reference = "registry.test.invalid/org/app:1.0".parse().unwrap(); + let auth = Registry::::resolve_auth_with_store( + &reference, + &CredentialStore, + ) + .expect("resolve auth"); + assert!(matches!(auth, RegistryAuth::Anonymous)); + } + + #[tokio::test] + async fn resolve_auth_errors_on_token_and_basic() { + let _lock = lock_env(); + let _token = EnvGuard::set(env::MSB_REGISTRY_TOKEN_ENV_VAR, "env-token"); + let _user = EnvGuard::set(env::MSB_REGISTRY_USERNAME_ENV_VAR, "env-user"); + let _pass = EnvGuard::set(env::MSB_REGISTRY_PASSWORD_ENV_VAR, "env-pass"); + + let msb_home = TempDir::new().expect("temp msb home"); + let _msb_home = EnvGuard::set(env::MICROSANDBOX_HOME_ENV_VAR, msb_home.path()); + let _ = CredentialStore::remove_registry_credentials("registry.test.invalid"); + + let reference: Reference = "registry.test.invalid/org/app:1.0".parse().unwrap(); + let err = Registry::::resolve_auth_with_store( + &reference, + &CredentialStore, + ) + .expect_err("expected error"); + assert!(matches!(err, crate::MicrosandboxError::InvalidArgument(_))); + } + + #[tokio::test] + async fn resolve_auth_can_use_mock_credential_store() { + let _lock = lock_env(); + let _token = EnvGuard::remove(env::MSB_REGISTRY_TOKEN_ENV_VAR); + let _user = EnvGuard::remove(env::MSB_REGISTRY_USERNAME_ENV_VAR); + let _pass = EnvGuard::remove(env::MSB_REGISTRY_PASSWORD_ENV_VAR); + + let reference: Reference = "registry.test.invalid/org/app:1.0".parse().unwrap(); + let auth = Registry::::resolve_auth_with_store( + &reference, + &MockCredentialStore, + ) + .expect("resolve auth"); + assert!(matches!(auth, RegistryAuth::Bearer(t) if t == "mock-token")); + } +} diff --git a/microsandbox-core/lib/oci/tests.rs b/microsandbox-core/lib/oci/tests.rs index 46770118..fcdf59e5 100644 --- a/microsandbox-core/lib/oci/tests.rs +++ b/microsandbox-core/lib/oci/tests.rs @@ -9,7 +9,7 @@ use crate::{ }; use futures::StreamExt; -use oci_client::manifest::OciManifest; +use oci_client::{manifest::OciManifest, secrets::RegistryAuth}; use oci_spec::image::{Digest, DigestAlgorithm, Os}; use sqlx::Row; use tokio::{fs, io::AsyncWriteExt, test}; @@ -89,8 +89,9 @@ async fn test_docker_pull_image() -> anyhow::Result<()> { async fn test_docker_fetch_index() -> anyhow::Result<()> { let (registry, _, _) = mock_registry_and_db().await; let reference = Reference::from_str("alpine:latest").unwrap(); + let auth = RegistryAuth::Anonymous; - let result = registry.fetch_index(&reference).await; + let result = registry.fetch_index(&reference, &auth).await; let OciManifest::ImageIndex(index) = result.unwrap() else { panic!("alpine image should be image index"); }; @@ -120,8 +121,9 @@ async fn test_docker_fetch_index() -> anyhow::Result<()> { async fn test_docker_fetch_manifest_and_config() -> anyhow::Result<()> { let (registry, _, _) = mock_registry_and_db().await; let reference = Reference::from_str("alpine:latest").unwrap(); + let auth = RegistryAuth::Anonymous; let (manifest, config) = registry - .fetch_manifest_and_config(&reference) + .fetch_manifest_and_config(&reference, &auth) .await .unwrap(); @@ -165,9 +167,12 @@ async fn test_docker_fetch_manifest_and_config() -> anyhow::Result<()> { async fn test_docker_fetch_image_blob() -> anyhow::Result<()> { let (registry, _, _) = mock_registry_and_db().await; let reference = Reference::from_str("alpine:latest").unwrap(); + let auth = RegistryAuth::Anonymous; // Get a layer digest from manifest - let (manifest, _) = registry.fetch_manifest_and_config(&reference).await?; + let (manifest, _) = registry + .fetch_manifest_and_config(&reference, &auth) + .await?; let layer = manifest.layers.first().unwrap(); let digest = Digest::try_from(layer.digest.clone()).unwrap(); let mut stream = registry diff --git a/microsandbox-utils/Cargo.toml b/microsandbox-utils/Cargo.toml index e5ada7cd..35de0c46 100644 --- a/microsandbox-utils/Cargo.toml +++ b/microsandbox-utils/Cargo.toml @@ -14,17 +14,31 @@ path = "lib/lib.rs" [dependencies] anyhow.workspace = true async-trait.workspace = true +base64.workspace = true console.workspace = true dirs.workspace = true futures.workspace = true indicatif.workspace = true +keyring.workspace = true libc.workspace = true nix = { workspace = true, features = ["fs", "process", "signal", "term"] } +oci-client.workspace = true pretty-error-debug.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true typed-path.workspace = true +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { workspace = true, features = ["apple-native"] } + +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { workspace = true, features = ["linux-native"] } + +#TODO add windows feature if needed + [dev-dependencies] tempfile.workspace = true diff --git a/microsandbox-utils/lib/credential_store.rs b/microsandbox-utils/lib/credential_store.rs new file mode 100644 index 00000000..80d08564 --- /dev/null +++ b/microsandbox-utils/lib/credential_store.rs @@ -0,0 +1,151 @@ +//! Registry auth persistence helpers. +//! +//! Credentials are persisted in the platform secure credential store via `keyring`. +//! +//! # Examples +//! ```no_run +//! use microsandbox_utils::{CredentialStore, MsbRegistryAuth}; +//! +//! CredentialStore::store_registry_credentials( +//! "ghcr.io", +//! MsbRegistryAuth::Token { +//! token: "token-123".to_string(), +//! }, +//! )?; +//! +//! let creds = CredentialStore::load_registry_credentials("ghcr.io")? +//! .expect("missing credentials"); +//! +//! match creds { +//! MsbRegistryAuth::Token { token } => { +//! assert_eq!(token, "token-123"); +//! } +//! _ => unreachable!("expected token credentials"), +//! } +//! # Ok::<(), microsandbox_utils::MicrosandboxUtilsError>(()) +//! ``` +//! +//! # Keyring Backend Availability (Tests/CI) +//! ```no_run +//! use microsandbox_utils::{CredentialStore, MsbRegistryAuth}; +//! +//! let probe = MsbRegistryAuth::Token { +//! token: "probe-token".to_string(), +//! }; +//! +//! // Persist to the platform secure store. +//! CredentialStore::store_registry_credentials("ghcr.io", probe)?; +//! +//! // Some sandboxed/CI environments do not provide a fully functional keyring backend. +//! // In those cases, reads may return None even after a successful store. +//! let roundtrip_ok = matches!( +//! CredentialStore::load_registry_credentials("ghcr.io")?, +//! Some(MsbRegistryAuth::Token { ref token }) if token == "probe-token" +//! ); +//! +//! if !roundtrip_ok { +//! // Treat as environment limitation (skip/inconclusive), not legacy-file fallback. +//! } +//! # Ok::<(), microsandbox_utils::MicrosandboxUtilsError>(()) +//! ``` + +use keyring::Entry; +use oci_client::secrets::RegistryAuth; +use serde::{Deserialize, Serialize}; + +use crate::MicrosandboxUtilsResult; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Stored credentials for a registry host. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum MsbRegistryAuth { + /// Basic auth using username + password. + #[serde(rename = "basic")] + Basic { + /// Registry username. + username: String, + /// Registry password. + password: String, + }, + /// Bearer token. + #[serde(rename = "token")] + Token { + /// Registry token. + token: String, + }, +} + +impl From for RegistryAuth { + fn from(value: MsbRegistryAuth) -> Self { + match value { + MsbRegistryAuth::Basic { username, password } => { + RegistryAuth::Basic(username, password) + } + MsbRegistryAuth::Token { token } => RegistryAuth::Bearer(token), + } + } +} + +/// Persistence API for registry credentials. +#[derive(Debug, Clone, Copy, Default)] +pub struct CredentialStore; + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +impl CredentialStore { + /// Load stored registry credentials for a host, if present. + /// + /// Returns `Ok(None)` when no credential exists in the platform secure store. + pub fn load_registry_credentials( + host: &str, + ) -> MicrosandboxUtilsResult> { + let entry = Self::entry(host)?; + match entry.get_password() { + Ok(raw) => Ok(Some(serde_json::from_str(&raw)?)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(err) => Err(err.into()), + } + } + + /// Store registry credentials for a host (overwrites existing entry). + /// + /// Returns an error if the secure store backend rejects persistence or retrieval. + pub fn store_registry_credentials( + host: &str, + credentials: MsbRegistryAuth, + ) -> MicrosandboxUtilsResult<()> { + let entry = Self::entry(host)?; + let serialized = serde_json::to_string(&credentials)?; + entry.set_password(&serialized)?; + // Ensure credentials were persisted and are retrievable from secure storage. + match entry.get_password() { + Ok(_) => {} + Err(err) => return Err(err.into()), + } + Ok(()) + } + + /// Remove stored registry credentials for a host. + /// + /// Returns `Ok(false)` when there is no credential entry for `host`. + pub fn remove_registry_credentials(host: &str) -> MicrosandboxUtilsResult { + let entry = Self::entry(host)?; + let removed = match entry.delete_credential() { + Ok(()) => true, + Err(keyring::Error::NoEntry) => false, + Err(err) => return Err(err.into()), + }; + Ok(removed) + } + + /// Build the platform keyring entry for a registry host. + fn entry(host: &str) -> MicrosandboxUtilsResult { + Entry::new(&format!("microsandbox:{}", host), "registry-auth").map_err(Into::into) + } +} diff --git a/microsandbox-utils/lib/env.rs b/microsandbox-utils/lib/env.rs index 1796f53d..3cf38341 100644 --- a/microsandbox-utils/lib/env.rs +++ b/microsandbox-utils/lib/env.rs @@ -11,8 +11,17 @@ use crate::{DEFAULT_MICROSANDBOX_HOME, DEFAULT_OCI_REGISTRY}; /// Environment variable for the microsandbox home directory pub const MICROSANDBOX_HOME_ENV_VAR: &str = "MICROSANDBOX_HOME"; -/// Environment variable for the OCI registry domain -pub const OCI_REGISTRY_ENV_VAR: &str = "OCI_REGISTRY_DOMAIN"; +/// Environment variable for registry host (CLI fallback) +pub const MSB_REGISTRY_HOST_ENV_VAR: &str = "MSB_REGISTRY_HOST"; + +/// Environment variable for registry username +pub const MSB_REGISTRY_USERNAME_ENV_VAR: &str = "MSB_REGISTRY_USERNAME"; + +/// Environment variable for registry password +pub const MSB_REGISTRY_PASSWORD_ENV_VAR: &str = "MSB_REGISTRY_PASSWORD"; + +/// Environment variable for registry token +pub const MSB_REGISTRY_TOKEN_ENV_VAR: &str = "MSB_REGISTRY_TOKEN"; /// Environment variable for the msbrun binary path pub const MSBRUN_EXE_ENV_VAR: &str = "MSBRUN_EXE"; @@ -20,6 +29,12 @@ pub const MSBRUN_EXE_ENV_VAR: &str = "MSBRUN_EXE"; /// Environment variable for the msbserver binary path pub const MSBSERVER_EXE_ENV_VAR: &str = "MSBSERVER_EXE"; +/// Environment variable for the minimum port in the sandbox port range +pub const MICROSANDBOX_PORT_MIN_ENV_VAR: &str = "MICROSANDBOX_PORT_MIN"; + +/// Environment variable for the maximum port in the sandbox port range +pub const MICROSANDBOX_PORT_MAX_ENV_VAR: &str = "MICROSANDBOX_PORT_MAX"; + //-------------------------------------------------------------------------------------------------- // Functions //-------------------------------------------------------------------------------------------------- @@ -36,12 +51,27 @@ pub fn get_microsandbox_home_path() -> PathBuf { } /// Returns the domain for the OCI registry. -/// If the OCI_REGISTRY_DOMAIN environment variable is set, returns that value. +/// If the MSB_REGISTRY_HOST_ENV_VAR environment variable is set, returns that value. /// Otherwise, returns the default OCI registry domain. pub fn get_oci_registry() -> String { - if let Ok(oci_registry_domain) = std::env::var(OCI_REGISTRY_ENV_VAR) { + if let Ok(oci_registry_domain) = std::env::var(MSB_REGISTRY_HOST_ENV_VAR) { oci_registry_domain } else { DEFAULT_OCI_REGISTRY.to_string() } } + +/// Returns the registry username from environment, if set. +pub fn get_registry_username() -> Option { + std::env::var(MSB_REGISTRY_USERNAME_ENV_VAR).ok() +} + +/// Returns the registry password from environment, if set. +pub fn get_registry_password() -> Option { + std::env::var(MSB_REGISTRY_PASSWORD_ENV_VAR).ok() +} + +/// Returns the registry token from environment, if set. +pub fn get_registry_token() -> Option { + std::env::var(MSB_REGISTRY_TOKEN_ENV_VAR).ok() +} diff --git a/microsandbox-utils/lib/error.rs b/microsandbox-utils/lib/error.rs index de7c0656..aeb525e8 100644 --- a/microsandbox-utils/lib/error.rs +++ b/microsandbox-utils/lib/error.rs @@ -37,6 +37,14 @@ pub enum MicrosandboxUtilsError { #[error("nix error: {0}")] NixError(#[from] nix::Error), + /// An error that occurred during a Serde JSON operation + #[error("serde json error: {0}")] + SerdeJson(#[from] serde_json::Error), + + /// An error that occurred while accessing the secure credential store. + #[error("keyring error: {0}")] + Keyring(#[from] keyring::Error), + /// Custom error. #[error("Custom error: {0}")] Custom(#[from] AnyError), diff --git a/microsandbox-utils/lib/lib.rs b/microsandbox-utils/lib/lib.rs index afeba142..1c5b7bb5 100644 --- a/microsandbox-utils/lib/lib.rs +++ b/microsandbox-utils/lib/lib.rs @@ -3,6 +3,7 @@ #![warn(missing_docs)] #![allow(clippy::module_inception)] +pub mod credential_store; pub mod defaults; pub mod env; pub mod error; @@ -16,6 +17,7 @@ pub mod term; // Exports //-------------------------------------------------------------------------------------------------- +pub use credential_store::*; pub use defaults::*; pub use env::*; pub use error::*;