From 5695e46d1e0bd77a95920afbae4f55bafdf58a74 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sat, 31 Jan 2026 20:26:04 -0600 Subject: [PATCH 01/28] feat(env): add environment variables for Docker registry configuration --- microsandbox-utils/lib/env.rs | 55 +++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/microsandbox-utils/lib/env.rs b/microsandbox-utils/lib/env.rs index 1796f53d..4b3547c6 100644 --- a/microsandbox-utils/lib/env.rs +++ b/microsandbox-utils/lib/env.rs @@ -14,12 +14,30 @@ 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"; /// 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 //-------------------------------------------------------------------------------------------------- @@ -45,3 +63,40 @@ pub fn get_oci_registry() -> String { DEFAULT_OCI_REGISTRY.to_string() } } + +/// Returns the registry host from environment, if set. +pub fn get_registry_host() -> Option { + std::env::var(MSB_REGISTRY_HOST_ENV_VAR).ok() +} + +/// 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() +} + +/// Returns the port range for sandbox port allocation. +/// If both MICROSANDBOX_PORT_MIN and MICROSANDBOX_PORT_MAX are set, +/// returns Some((min, max)). Otherwise, returns None for dynamic allocation. +pub fn get_sandbox_port_range() -> Option<(u16, u16)> { + let min = std::env::var(MICROSANDBOX_PORT_MIN_ENV_VAR) + .ok() + .and_then(|v| v.parse::().ok()); + let max = std::env::var(MICROSANDBOX_PORT_MAX_ENV_VAR) + .ok() + .and_then(|v| v.parse::().ok()); + + match (min, max) { + (Some(min_val), Some(max_val)) if min_val <= max_val => Some((min_val, max_val)), + _ => None, + } +} From 844c4680901c28b19581da761be9074fbe7e90b2 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sat, 31 Jan 2026 20:26:23 -0600 Subject: [PATCH 02/28] feat(error): add Serde JSON error variant to error handling --- microsandbox-utils/lib/error.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/microsandbox-utils/lib/error.rs b/microsandbox-utils/lib/error.rs index de7c0656..f26e5929 100644 --- a/microsandbox-utils/lib/error.rs +++ b/microsandbox-utils/lib/error.rs @@ -37,6 +37,10 @@ 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), + /// Custom error. #[error("Custom error: {0}")] Custom(#[from] AnyError), From dae356661fe30294ac75a63993ddf01084b7a99d Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sat, 31 Jan 2026 20:29:13 -0600 Subject: [PATCH 03/28] feat(auth): implement login and logout functionality for Docker registry credentials --- microsandbox-cli/bin/msb/handlers.rs | 156 +++++++++++++++++++++++++-- 1 file changed, 150 insertions(+), 6 deletions(-) diff --git a/microsandbox-cli/bin/msb/handlers.rs b/microsandbox-cli/bin/msb/handlers.rs index 08201c9d..9e1d2f99 100644 --- a/microsandbox-cli/bin/msb/handlers.rs +++ b/microsandbox-cli/bin/msb/handlers.rs @@ -11,9 +11,13 @@ use microsandbox_core::{ oci::Reference, }; use microsandbox_server::MicrosandboxServerResult; -use microsandbox_utils::{NAMESPACES_SUBDIR, env}; +use microsandbox_utils::{ + NAMESPACES_SUBDIR, StoredRegistryCredentials, env, remove_registry_credentials, + store_registry_credentials, clear_registry_credentials, +}; use std::{collections::HashMap, path::PathBuf}; use typed_path::Utf8UnixPathBuf; +use tokio::io::{self, AsyncReadExt}; //-------------------------------------------------------------------------------------------------- // Constants @@ -671,11 +675,57 @@ pub async fn server_status_subcommand( Ok(()) } -pub async fn login_subcommand() -> MicrosandboxCliResult<()> { - println!( - "{} login functionality is not yet implemented", - "error:".error() - ); +pub async fn login_subcommand( + registry: Option, + username: Option, + password_stdin: bool, + token: Option, +) -> MicrosandboxCliResult<()> { + let registry = resolve_registry_host(registry); + let creds = resolve_login_credentials(username, password_stdin, token).await?; + + match creds { + LoginCredentials::Basic { username, password } => { + store_registry_credentials( + ®istry, + StoredRegistryCredentials::Basic { username, password }, + ) + .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; + println!("info: credentials saved for registry {}", registry); + } + LoginCredentials::Token { token } => { + store_registry_credentials( + ®istry, + StoredRegistryCredentials::Token { token }, + ) + .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; + println!("info: token saved for registry {}", registry); + } + } + + Ok(()) +} + +pub async fn logout_subcommand( + registry: Option, + all: bool, +) -> MicrosandboxCliResult<()> { + if all { + clear_registry_credentials() + .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; + println!("info: cleared all stored registry credentials"); + return Ok(()); + } + + let registry = resolve_registry_host(registry); + let removed = remove_registry_credentials(®istry) + .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; + if removed { + println!("info: removed stored credentials for registry {}", registry); + } else { + println!("info: no stored credentials found for registry {}", registry); + } + Ok(()) } @@ -741,6 +791,100 @@ fn parse_name_and_script(name_and_script: &str) -> (&str, Option<&str>) { (name, script) } +//-------------------------------------------------------------------------------------------------- +// Functions: Login Helpers +//-------------------------------------------------------------------------------------------------- + +enum LoginCredentials { + Basic { username: String, password: String }, + Token { token: String }, +} + +fn resolve_registry_host(registry: Option) -> String { + registry + .or_else(env::get_registry_host) + .unwrap_or_else(env::get_oci_registry) +} + +async fn resolve_login_credentials( + username: Option, + password_stdin: bool, + token: Option, +) -> MicrosandboxCliResult { + let cli_password = if password_stdin { + Some(read_password_from_stdin().await?) + } else { + None + }; + + let cli_provided = token.is_some() || username.is_some() || password_stdin; + if cli_provided { + let cli_result = if token.is_some() && (username.is_some() || cli_password.is_some()) { + Err(MicrosandboxCliError::InvalidArgument( + "token cannot be combined with username/password".to_string(), + )) + } else if let Some(token) = token { + Ok(LoginCredentials::Token { token }) + } else { + match (username, cli_password) { + (Some(username), Some(password)) => { + Ok(LoginCredentials::Basic { username, password }) + } + (None, None) => Err(MicrosandboxCliError::InvalidArgument( + "no credentials provided; use flags or environment variables".to_string(), + )), + _ => Err(MicrosandboxCliError::InvalidArgument( + "both username and password are required".to_string(), + )), + } + }; + + if let Ok(creds) = cli_result { + return Ok(creds); + } + + // CLI was provided but invalid; attempt env fallback. + tracing::debug!("login: CLI credentials invalid, falling back to environment variables"); + } + + let env_token = env::get_registry_token(); + let env_username = env::get_registry_username(); + let env_password = env::get_registry_password(); + + if env_token.is_some() && (env_username.is_some() || env_password.is_some()) { + return Err(MicrosandboxCliError::InvalidArgument( + "token cannot be combined with username/password".to_string(), + )); + } + + if let Some(token) = env_token { + return Ok(LoginCredentials::Token { token }); + } + + match (env_username, env_password) { + (Some(username), Some(password)) => Ok(LoginCredentials::Basic { username, password }), + (None, None) => Err(MicrosandboxCliError::InvalidArgument( + "no credentials provided; use flags or environment variables".to_string(), + )), + _ => Err(MicrosandboxCliError::InvalidArgument( + "both username and password are required".to_string(), + )), + } +} + +async fn read_password_from_stdin() -> MicrosandboxCliResult { + let mut input = String::new(); + let mut stdin = io::stdin(); + stdin.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. From db2169ea05148cf993012116e10ca5cbd1108f2c Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sat, 31 Jan 2026 20:30:47 -0600 Subject: [PATCH 04/28] feat(docker): add modules for Docker configuration and registry authentication --- microsandbox-utils/lib/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/microsandbox-utils/lib/lib.rs b/microsandbox-utils/lib/lib.rs index afeba142..9b10d039 100644 --- a/microsandbox-utils/lib/lib.rs +++ b/microsandbox-utils/lib/lib.rs @@ -4,8 +4,10 @@ #![allow(clippy::module_inception)] pub mod defaults; +pub mod docker_config; pub mod env; pub mod error; +pub mod registry_auth; pub mod log; pub mod path; pub mod runtime; @@ -17,8 +19,10 @@ pub mod term; //-------------------------------------------------------------------------------------------------- pub use defaults::*; +pub use docker_config::*; pub use env::*; pub use error::*; +pub use registry_auth::*; pub use log::*; pub use path::*; pub use runtime::*; From 55cfe31a709745ed56444ca264db241fd2d571e8 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sat, 31 Jan 2026 20:31:06 -0600 Subject: [PATCH 05/28] feat(auth): enhance login and logout commands with registry and credentials handling --- microsandbox-cli/bin/msb/main.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/microsandbox-cli/bin/msb/main.rs b/microsandbox-cli/bin/msb/main.rs index 29e62282..34283834 100644 --- a/microsandbox-cli/bin/msb/main.rs +++ b/microsandbox-cli/bin/msb/main.rs @@ -259,8 +259,16 @@ async fn main() -> MicrosandboxCliResult<()> { handlers::server_ssh_subcommand(namespace, 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, all }) => { + handlers::logout_subcommand(registry, all).await?; } Some(MicrosandboxSubcommand::Push { image, name }) => { handlers::push_subcommand(image, name).await?; From 999f340274903c39505e3f0c8bcba78cb412dea1 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sat, 31 Jan 2026 20:31:26 -0600 Subject: [PATCH 06/28] feat(docker): update registry initialization to support anonymous authentication --- microsandbox-core/lib/oci/mocks.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/microsandbox-core/lib/oci/mocks.rs b/microsandbox-core/lib/oci/mocks.rs index 1ee4a629..d4e39490 100644 --- a/microsandbox-core/lib/oci/mocks.rs +++ b/microsandbox-core/lib/oci/mocks.rs @@ -23,8 +23,13 @@ pub(crate) async fn mock_registry_and_db() -> (Registry, Pool Date: Sat, 31 Jan 2026 20:31:45 -0600 Subject: [PATCH 07/28] feat(auth): extend login and logout commands with registry options and credential management --- microsandbox-cli/lib/args/msb.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/microsandbox-cli/lib/args/msb.rs b/microsandbox-cli/lib/args/msb.rs index 33d27f52..9bfc7840 100644 --- a/microsandbox-cli/lib/args/msb.rs +++ b/microsandbox-cli/lib/args/msb.rs @@ -520,7 +520,33 @@ 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, + + /// Remove all stored registry credentials + #[arg(long)] + all: bool, + }, /// Push image to a registry #[command(name = "push")] From 3220bac0b0d4cec97648c7e934579ad5c85e263c Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sat, 31 Jan 2026 20:32:04 -0600 Subject: [PATCH 08/28] feat(docker): integrate registry authentication into OciClient configuration --- microsandbox-core/lib/oci/registry.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/microsandbox-core/lib/oci/registry.rs b/microsandbox-core/lib/oci/registry.rs index a6d2fba6..c086e952 100644 --- a/microsandbox-core/lib/oci/registry.rs +++ b/microsandbox-core/lib/oci/registry.rs @@ -80,6 +80,7 @@ where db: Pool, platform: Platform, global_cache: O, + auth: RegistryAuth, ) -> MicrosandboxResult { let config = OciClientConfig { platform_resolver: Some(Box::new(move |manifests| { @@ -90,7 +91,7 @@ where Ok(Self { client: OciClient::new(config), - auth: RegistryAuth::Anonymous, + auth, db, global_cache, }) From aa1b7851986a58e2b1b40c20651088d78913e5c5 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sat, 31 Jan 2026 20:32:38 -0600 Subject: [PATCH 09/28] feat(docker): update CLI documentation for private image pulling and registry authentication commands --- docs/references/cli.md | 65 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/docs/references/cli.md b/docs/references/cli.md index bd42a52d..624842c8 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,62 @@ msb push myapp:latest === +==- `msb login` +Set registry credentials (persisted in microsandbox home). + +```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 +msb login ghcr.io --token token123 + +# Provide username and password via stdin +echo "pass" | msb login docker.io --username user --password-stdin + +# Use env fallback if CLI is invalid +export MSB_REGISTRY_TOKEN=token123 +msb login ghcr.io --username user --password-stdin +``` + +!!!warning Security +Credentials are stored in `~/.microsandbox/registry_auth.json`. Restrict file permissions and avoid sharing it. +!!! + +=== + +==- `msb logout` +Remove stored registry credentials. + +```bash +msb logout [registry] [--all] +``` + +| Option | Description | +| --------- | ---------------------------------------- | +| `--all` | Remove all stored registry credentials | + +**Examples:** + +```bash +# Remove credentials for a registry +msb logout ghcr.io + +# Remove all stored credentials +msb logout --all +``` + +=== + --- ### Maintenance From e56a596db582808d0b517d440223745c918f48c1 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sat, 31 Jan 2026 20:44:11 -0600 Subject: [PATCH 10/28] feat(docker): implement Docker config reader for registry authentication --- microsandbox-utils/lib/docker_config.rs | 396 ++++++++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 microsandbox-utils/lib/docker_config.rs diff --git a/microsandbox-utils/lib/docker_config.rs b/microsandbox-utils/lib/docker_config.rs new file mode 100644 index 00000000..ccb1e857 --- /dev/null +++ b/microsandbox-utils/lib/docker_config.rs @@ -0,0 +1,396 @@ +//! Docker config reader utilities for registry authentication. + +use std::{ + collections::HashMap, + io::Write, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use serde::Deserialize; + +//-------------------------------------------------------------------------------------------------- +// Constants +//-------------------------------------------------------------------------------------------------- + +const DOCKER_CONFIG_ENV_VAR: &str = "DOCKER_CONFIG"; +const DOCKER_CONFIG_FILENAME: &str = "config.json"; +const DOCKER_IO_LEGACY_KEY: &str = "https://index.docker.io/v1/"; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Credentials loaded from Docker config. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DockerAuthCredentials { + /// Basic auth using username + password. + Basic { + /// Registry username. + username: String, + /// Registry password. + password: String, + }, + /// Token-based auth (identity token). + Token { + /// Registry token. + token: String, + }, +} + +/// Errors that can occur while reading Docker config. +#[derive(Debug, thiserror::Error)] +pub enum DockerConfigError { + /// IO error while reading config file. + #[error("io error: {0}")] + Io(#[from] std::io::Error), + /// JSON parse error. + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + /// Base64 decode error. + #[error("base64 error: {0}")] + Base64(#[from] base64::DecodeError), + /// Invalid auth entry. + #[error("invalid auth entry: {0}")] + InvalidAuth(String), +} + +#[derive(Debug, Deserialize)] +struct DockerConfig { + auths: Option>, + #[allow(dead_code)] + #[serde(rename = "credsStore")] + creds_store: Option, + #[allow(dead_code)] + #[serde(rename = "credHelpers")] + cred_helpers: Option>, +} + +#[derive(Debug, Deserialize)] +struct DockerAuthEntry { + auth: Option, + identitytoken: Option, + username: Option, + password: Option, +} + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +/// Loads credentials for a registry host from Docker config if present. +/// +/// TODO: Support credsStore / credHelpers by invoking docker-credential helpers. +pub fn load_docker_registry_credentials( + host: &str, +) -> Result, DockerConfigError> { + let config_path = match docker_config_path() { + Some(path) => path, + None => return Ok(None), + }; + if !config_path.exists() { + return Ok(None); + } + + let config = read_config(&config_path)?; + if let Some(creds) = load_from_helpers(host, &config)? { + return Ok(Some(creds)); + } + + if let Some(auths) = config.auths { + for key in candidate_registry_keys(host) { + if let Some(entry) = auths.get(key) { + return parse_auth_entry(entry).map(Some); + } + } + } + + Ok(None) +} + +fn docker_config_path() -> Option { + if let Ok(path) = std::env::var(DOCKER_CONFIG_ENV_VAR) { + let path = PathBuf::from(path); + return Some(if path.is_dir() { + path.join(DOCKER_CONFIG_FILENAME) + } else { + path + }); + } + + let home = dirs::home_dir()?; + Some(home.join(".docker").join(DOCKER_CONFIG_FILENAME)) +} + +fn read_config(path: &Path) -> Result { + let contents = std::fs::read_to_string(path)?; + Ok(serde_json::from_str::(&contents)?) +} + +fn candidate_registry_keys(host: &str) -> Vec<&str> { + if host == "docker.io" { + vec![host, DOCKER_IO_LEGACY_KEY] + } else { + vec![host] + } +} + +fn parse_auth_entry(entry: &DockerAuthEntry) -> Result { + if let Some(token) = entry.identitytoken.as_ref() { + if token.is_empty() { + return Err(DockerConfigError::InvalidAuth( + "identitytoken is empty".to_string(), + )); + } + return Ok(DockerAuthCredentials::Token { + token: token.to_string(), + }); + } + + if let (Some(username), Some(password)) = (entry.username.as_ref(), entry.password.as_ref()) { + if username.is_empty() || password.is_empty() { + return Err(DockerConfigError::InvalidAuth( + "username/password is empty".to_string(), + )); + } + return Ok(DockerAuthCredentials::Basic { + username: username.to_string(), + password: password.to_string(), + }); + } + + if let Some(encoded) = entry.auth.as_ref() { + if encoded.is_empty() { + return Err(DockerConfigError::InvalidAuth( + "auth is empty".to_string(), + )); + } + let decoded = BASE64_STANDARD.decode(encoded)?; + let decoded = String::from_utf8_lossy(&decoded); + let (username, password) = decoded + .split_once(':') + .ok_or_else(|| DockerConfigError::InvalidAuth("auth missing ':'".to_string()))?; + if username.is_empty() || password.is_empty() { + return Err(DockerConfigError::InvalidAuth( + "auth username/password is empty".to_string(), + )); + } + return Ok(DockerAuthCredentials::Basic { + username: username.to_string(), + password: password.to_string(), + }); + } + + Err(DockerConfigError::InvalidAuth( + "no supported auth fields".to_string(), + )) +} + +fn load_from_helpers( + host: &str, + config: &DockerConfig, +) -> Result, DockerConfigError> { + let helper = match select_credential_helper(host, config) { + Some(helper) => helper, + None => return Ok(None), + }; + + for key in candidate_registry_keys(host) { + if let Some(creds) = run_credential_helper(&helper, key)? { + return Ok(Some(creds)); + } + } + + Ok(None) +} + +fn select_credential_helper(host: &str, config: &DockerConfig) -> Option { + if let Some(helpers) = config.cred_helpers.as_ref() { + if let Some(helper) = helpers.get(host) { + return Some(helper.to_string()); + } + } + + config.creds_store.as_ref().map(|v| v.to_string()) +} + +fn run_credential_helper( + helper: &str, + server_url: &str, +) -> Result, DockerConfigError> { + let helper_bin = format!("docker-credential-{}", helper); + let mut child = match Command::new(&helper_bin) + .arg("get") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(DockerConfigError::Io(err)), + }; + + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(server_url.as_bytes())?; + stdin.write_all(b"\n")?; + } + + let output = child.wait_with_output()?; + if !output.status.success() { + return Ok(None); + } + + let creds = parse_credential_helper_output(&output.stdout)?; + Ok(Some(creds)) +} + +fn parse_credential_helper_output( + raw: &[u8], +) -> Result { + #[derive(Deserialize)] + struct HelperOutput { + #[allow(dead_code)] + #[serde(rename = "ServerURL")] + server_url: Option, + #[serde(rename = "Username")] + username: String, + #[serde(rename = "Secret")] + secret: String, + } + + let output: HelperOutput = serde_json::from_slice(raw)?; + if output.secret.is_empty() { + return Err(DockerConfigError::InvalidAuth( + "credential helper secret is empty".to_string(), + )); + } + + if output.username.is_empty() { + return Ok(DockerAuthCredentials::Token { + token: output.secret, + }); + } + + Ok(DockerAuthCredentials::Basic { + username: output.username, + password: output.secret, + }) +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + struct EnvGuard { + key: &'static str, + prev: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: String) -> Self { + let prev = std::env::var_os(key); + 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) }; + } + } + } + + fn write_config(temp_dir: &TempDir, contents: &str) -> PathBuf { + let path = temp_dir.path().join("config.json"); + fs::write(&path, contents).expect("write config"); + path + } + + #[test] + fn load_auth_from_basic_auth_field() { + let dir = TempDir::new().expect("temp dir"); + let encoded = BASE64_STANDARD.encode("user:pass"); + let config = format!( + r#"{{ + "auths": {{ + "registry.example.com": {{ "auth": "{}" }} + }} +}}"#, + encoded + ); + let path = write_config(&dir, &config); + let _guard = EnvGuard::set(DOCKER_CONFIG_ENV_VAR, path.to_string_lossy().to_string()); + + let creds = load_docker_registry_credentials("registry.example.com") + .expect("load creds") + .expect("creds"); + + assert_eq!( + creds, + DockerAuthCredentials::Basic { + username: "user".to_string(), + password: "pass".to_string() + } + ); + } + + #[test] + fn load_auth_from_identity_token() { + let dir = TempDir::new().expect("temp dir"); + let config = r#"{ + "auths": { + "registry.example.com": { "identitytoken": "token-123" } + } +}"#; + let path = write_config(&dir, config); + let _guard = EnvGuard::set(DOCKER_CONFIG_ENV_VAR, path.to_string_lossy().to_string()); + + let creds = load_docker_registry_credentials("registry.example.com") + .expect("load creds") + .expect("creds"); + + assert_eq!( + creds, + DockerAuthCredentials::Token { + token: "token-123".to_string() + } + ); + } + + #[test] + fn parse_helper_output_basic() { + let raw = br#"{"ServerURL":"ghcr.io","Username":"user","Secret":"pat"}"#; + let creds = parse_credential_helper_output(raw).expect("parse helper output"); + assert_eq!( + creds, + DockerAuthCredentials::Basic { + username: "user".to_string(), + password: "pat".to_string() + } + ); + } + + #[test] + fn parse_helper_output_token() { + let raw = br#"{"ServerURL":"ghcr.io","Username":"","Secret":"token"}"#; + let creds = parse_credential_helper_output(raw).expect("parse helper output"); + assert_eq!( + creds, + DockerAuthCredentials::Token { + token: "token".to_string() + } + ); + } +} From 7532ab83bc94d3e3e21233142d857e90c1fd0ea6 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sat, 31 Jan 2026 20:44:36 -0600 Subject: [PATCH 11/28] feat(docker): add registry authentication persistence helpers for storing and managing credentials --- microsandbox-utils/lib/registry_auth.rs | 126 ++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 microsandbox-utils/lib/registry_auth.rs diff --git a/microsandbox-utils/lib/registry_auth.rs b/microsandbox-utils/lib/registry_auth.rs new file mode 100644 index 00000000..da88d355 --- /dev/null +++ b/microsandbox-utils/lib/registry_auth.rs @@ -0,0 +1,126 @@ +//! Registry auth persistence helpers. + +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::{env, MicrosandboxUtilsResult}; + +//-------------------------------------------------------------------------------------------------- +// Types +//-------------------------------------------------------------------------------------------------- + +/// Stored credentials for a registry host. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum StoredRegistryCredentials { + /// 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, + }, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct RegistryAuthFile { + auths: HashMap, +} + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +/// Load stored registry credentials for a host, if present. +pub fn load_stored_registry_credentials( + host: &str, +) -> MicrosandboxUtilsResult> { + let path = registry_auth_path(); + if !path.exists() { + return Ok(None); + } + + let data = fs::read_to_string(&path)?; + let auth_file: RegistryAuthFile = serde_json::from_str(&data)?; + Ok(auth_file.auths.get(host).cloned()) +} + +/// Store registry credentials for a host (overwrites existing entry). +pub fn store_registry_credentials( + host: &str, + credentials: StoredRegistryCredentials, +) -> MicrosandboxUtilsResult<()> { + let path = registry_auth_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let mut auth_file = if path.exists() { + let data = fs::read_to_string(&path)?; + serde_json::from_str::(&data)? + } else { + RegistryAuthFile::default() + }; + + auth_file.auths.insert(host.to_string(), credentials); + let json = serde_json::to_string_pretty(&auth_file)?; + fs::write(&path, json)?; + + set_permissions_restrictive(&path)?; + Ok(()) +} + +/// Remove stored registry credentials for a host. +pub fn remove_registry_credentials(host: &str) -> MicrosandboxUtilsResult { + let path = registry_auth_path(); + if !path.exists() { + return Ok(false); + } + + let data = fs::read_to_string(&path)?; + let mut auth_file: RegistryAuthFile = serde_json::from_str(&data)?; + let removed = auth_file.auths.remove(host).is_some(); + + let json = serde_json::to_string_pretty(&auth_file)?; + fs::write(&path, json)?; + set_permissions_restrictive(&path)?; + + Ok(removed) +} + +/// Remove all stored registry credentials. +pub fn clear_registry_credentials() -> MicrosandboxUtilsResult<()> { + let path = registry_auth_path(); + if path.exists() { + fs::remove_file(path)?; + } + Ok(()) +} + +fn registry_auth_path() -> PathBuf { + env::get_microsandbox_home_path().join("registry_auth.json") +} + +fn set_permissions_restrictive(path: &Path) -> MicrosandboxUtilsResult<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = fs::metadata(path)?; + let mut perms = metadata.permissions(); + perms.set_mode(0o600); + fs::set_permissions(path, perms)?; + } + Ok(()) +} From a8a6d3ad2d4e4017b9edf7b40c8e61ba5045eab0 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sat, 31 Jan 2026 20:44:58 -0600 Subject: [PATCH 12/28] feat(docker): add integration tests for registry authentication with environment variable overrides --- .../tests/registry_auth_integration.rs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 microsandbox-core/tests/registry_auth_integration.rs diff --git a/microsandbox-core/tests/registry_auth_integration.rs b/microsandbox-core/tests/registry_auth_integration.rs new file mode 100644 index 00000000..b62f4765 --- /dev/null +++ b/microsandbox-core/tests/registry_auth_integration.rs @@ -0,0 +1,84 @@ +use std::sync::Mutex; + +use microsandbox_core::{management::image::resolve_registry_auth, oci::Reference}; +use microsandbox_utils::env; +use microsandbox_utils::{clear_registry_credentials, store_registry_credentials, StoredRegistryCredentials}; +use tempfile::TempDir; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +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 resolves_stored_credentials_when_env_missing() { + let _lock = ENV_LOCK.lock().unwrap(); + 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()); + clear_registry_credentials().expect("clear"); + store_registry_credentials( + "ghcr.io", + StoredRegistryCredentials::Token { + token: "stored-token".to_string(), + }, + ) + .expect("store"); + + let reference: Reference = "ghcr.io/aurial-rocks/python313:0.1.2".parse().unwrap(); + let auth = resolve_registry_auth(&reference).expect("resolve auth"); + assert!(matches!(auth, oci_client::secrets::RegistryAuth::Bearer(t) if t == "stored-token")); +} + +#[test] +fn env_overrides_stored_credentials() { + let _lock = ENV_LOCK.lock().unwrap(); + 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()); + clear_registry_credentials().expect("clear"); + store_registry_credentials( + "ghcr.io", + StoredRegistryCredentials::Token { + token: "stored-token".to_string(), + }, + ) + .expect("store"); + + let reference: Reference = "ghcr.io/aurial-rocks/python313:0.1.2".parse().unwrap(); + let auth = resolve_registry_auth(&reference).expect("resolve auth"); + assert!(matches!(auth, oci_client::secrets::RegistryAuth::Bearer(t) if t == "env-token")); +} From a3698d7c7a0ec2396129b72786977ae333764676 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sat, 31 Jan 2026 20:47:26 -0600 Subject: [PATCH 13/28] feat(docker): add usage examples for Docker config and registry auth utilities --- microsandbox-utils/lib/docker_config.rs | 12 ++++++++++++ microsandbox-utils/lib/registry_auth.rs | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/microsandbox-utils/lib/docker_config.rs b/microsandbox-utils/lib/docker_config.rs index ccb1e857..e007036f 100644 --- a/microsandbox-utils/lib/docker_config.rs +++ b/microsandbox-utils/lib/docker_config.rs @@ -1,4 +1,16 @@ //! Docker config reader utilities for registry authentication. +//! +//! # Examples +//! ```no_run +//! use microsandbox_utils::load_docker_registry_credentials; +//! +//! let creds = load_docker_registry_credentials("ghcr.io") +//! .map_err(|err| microsandbox_utils::MicrosandboxUtilsError::custom(err))?; +//! if let Some(creds) = creds { +//! println!("loaded docker credentials: {:?}", creds); +//! } +//! # Ok::<(), microsandbox_utils::MicrosandboxUtilsError>(()) +//! ``` use std::{ collections::HashMap, diff --git a/microsandbox-utils/lib/registry_auth.rs b/microsandbox-utils/lib/registry_auth.rs index da88d355..4bdfecd6 100644 --- a/microsandbox-utils/lib/registry_auth.rs +++ b/microsandbox-utils/lib/registry_auth.rs @@ -1,4 +1,27 @@ //! Registry auth persistence helpers. +//! +//! # Examples +//! ```no_run +//! use microsandbox_utils::{store_registry_credentials, load_stored_registry_credentials, StoredRegistryCredentials}; +//! +//! store_registry_credentials( +//! "ghcr.io", +//! StoredRegistryCredentials::Token { +//! token: "token-123".to_string(), +//! }, +//! )?; +//! +//! let creds = load_stored_registry_credentials("ghcr.io")? +//! .expect("missing credentials"); +//! +//! match creds { +//! StoredRegistryCredentials::Token { token } => { +//! assert_eq!(token, "token-123"); +//! } +//! _ => unreachable!("expected token credentials"), +//! } +//! # Ok::<(), microsandbox_utils::MicrosandboxUtilsError>(()) +//! ``` use std::{ collections::HashMap, From c179ffd9c6cc317853556069366c8ea3e59acfd1 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sat, 31 Jan 2026 20:48:11 -0600 Subject: [PATCH 14/28] feat(docker): update logging for saved registry credentials to indicate non-validation --- microsandbox-cli/bin/msb/handlers.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/microsandbox-cli/bin/msb/handlers.rs b/microsandbox-cli/bin/msb/handlers.rs index 9e1d2f99..2abdae8c 100644 --- a/microsandbox-cli/bin/msb/handlers.rs +++ b/microsandbox-cli/bin/msb/handlers.rs @@ -691,7 +691,10 @@ pub async fn login_subcommand( StoredRegistryCredentials::Basic { username, password }, ) .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; - println!("info: credentials saved for registry {}", registry); + println!( + "info: credentials saved for registry {} (not validated)", + registry + ); } LoginCredentials::Token { token } => { store_registry_credentials( @@ -699,7 +702,7 @@ pub async fn login_subcommand( StoredRegistryCredentials::Token { token }, ) .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; - println!("info: token saved for registry {}", registry); + println!("info: token saved for registry {} (not validated)", registry); } } From 80972fc1bb4e337c89b31f1bffa66638ad1f1c0a Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sat, 31 Jan 2026 20:52:51 -0600 Subject: [PATCH 15/28] feat(docker): remove TODO for supporting credsStore and credHelpers in registry credentials loading --- microsandbox-utils/lib/docker_config.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/microsandbox-utils/lib/docker_config.rs b/microsandbox-utils/lib/docker_config.rs index e007036f..34c262d1 100644 --- a/microsandbox-utils/lib/docker_config.rs +++ b/microsandbox-utils/lib/docker_config.rs @@ -92,8 +92,6 @@ struct DockerAuthEntry { //-------------------------------------------------------------------------------------------------- /// Loads credentials for a registry host from Docker config if present. -/// -/// TODO: Support credsStore / credHelpers by invoking docker-credential helpers. pub fn load_docker_registry_credentials( host: &str, ) -> Result, DockerConfigError> { From aadfbc4d973fa3ca609c9948a22970055ac10035 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sat, 31 Jan 2026 20:54:44 -0600 Subject: [PATCH 16/28] feat(docker): clean up code formatting and improve readability in configuration and handler files --- microsandbox-cli/bin/msb/handlers.rs | 28 ++++++++++++------------- microsandbox-utils/lib/docker_config.rs | 16 ++++++-------- microsandbox-utils/lib/lib.rs | 4 ++-- 3 files changed, 22 insertions(+), 26 deletions(-) diff --git a/microsandbox-cli/bin/msb/handlers.rs b/microsandbox-cli/bin/msb/handlers.rs index 2abdae8c..583a0e99 100644 --- a/microsandbox-cli/bin/msb/handlers.rs +++ b/microsandbox-cli/bin/msb/handlers.rs @@ -12,12 +12,12 @@ use microsandbox_core::{ }; use microsandbox_server::MicrosandboxServerResult; use microsandbox_utils::{ - NAMESPACES_SUBDIR, StoredRegistryCredentials, env, remove_registry_credentials, - store_registry_credentials, clear_registry_credentials, + NAMESPACES_SUBDIR, StoredRegistryCredentials, clear_registry_credentials, env, + remove_registry_credentials, store_registry_credentials, }; use std::{collections::HashMap, path::PathBuf}; -use typed_path::Utf8UnixPathBuf; use tokio::io::{self, AsyncReadExt}; +use typed_path::Utf8UnixPathBuf; //-------------------------------------------------------------------------------------------------- // Constants @@ -697,22 +697,19 @@ pub async fn login_subcommand( ); } LoginCredentials::Token { token } => { - store_registry_credentials( - ®istry, - StoredRegistryCredentials::Token { token }, - ) - .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; - println!("info: token saved for registry {} (not validated)", registry); + store_registry_credentials(®istry, StoredRegistryCredentials::Token { token }) + .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; + println!( + "info: token saved for registry {} (not validated)", + registry + ); } } Ok(()) } -pub async fn logout_subcommand( - registry: Option, - all: bool, -) -> MicrosandboxCliResult<()> { +pub async fn logout_subcommand(registry: Option, all: bool) -> MicrosandboxCliResult<()> { if all { clear_registry_credentials() .map_err(|err| MicrosandboxCliError::ConfigError(err.to_string()))?; @@ -726,7 +723,10 @@ pub async fn logout_subcommand( if removed { println!("info: removed stored credentials for registry {}", registry); } else { - println!("info: no stored credentials found for registry {}", registry); + println!( + "info: no stored credentials found for registry {}", + registry + ); } Ok(()) diff --git a/microsandbox-utils/lib/docker_config.rs b/microsandbox-utils/lib/docker_config.rs index 34c262d1..7a22a4f5 100644 --- a/microsandbox-utils/lib/docker_config.rs +++ b/microsandbox-utils/lib/docker_config.rs @@ -172,9 +172,7 @@ fn parse_auth_entry(entry: &DockerAuthEntry) -> Result Option { - if let Some(helpers) = config.cred_helpers.as_ref() { - if let Some(helper) = helpers.get(host) { - return Some(helper.to_string()); - } + if let Some(helpers) = config.cred_helpers.as_ref() + && let Some(helper) = helpers.get(host) + { + return Some(helper.to_string()); } config.creds_store.as_ref().map(|v| v.to_string()) @@ -256,9 +254,7 @@ fn run_credential_helper( Ok(Some(creds)) } -fn parse_credential_helper_output( - raw: &[u8], -) -> Result { +fn parse_credential_helper_output(raw: &[u8]) -> Result { #[derive(Deserialize)] struct HelperOutput { #[allow(dead_code)] diff --git a/microsandbox-utils/lib/lib.rs b/microsandbox-utils/lib/lib.rs index 9b10d039..043ca1e6 100644 --- a/microsandbox-utils/lib/lib.rs +++ b/microsandbox-utils/lib/lib.rs @@ -7,9 +7,9 @@ pub mod defaults; pub mod docker_config; pub mod env; pub mod error; -pub mod registry_auth; pub mod log; pub mod path; +pub mod registry_auth; pub mod runtime; pub mod seekable; pub mod term; @@ -22,9 +22,9 @@ pub use defaults::*; pub use docker_config::*; pub use env::*; pub use error::*; -pub use registry_auth::*; pub use log::*; pub use path::*; +pub use registry_auth::*; pub use runtime::*; pub use seekable::*; pub use term::*; From 6aed59b932c42b054da4cb5d77100d38fbb62371 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sat, 31 Jan 2026 20:54:59 -0600 Subject: [PATCH 17/28] feat(docker): add note about credential storage and validation for msb login --- docs/references/cli.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/references/cli.md b/docs/references/cli.md index 624842c8..c06616ae 100644 --- a/docs/references/cli.md +++ b/docs/references/cli.md @@ -722,6 +722,10 @@ export MSB_REGISTRY_TOKEN=token123 msb login ghcr.io --username user --password-stdin ``` +!!!note +`msb login` stores credentials locally but does not validate them against the registry. +!!! + !!!warning Security Credentials are stored in `~/.microsandbox/registry_auth.json`. Restrict file permissions and avoid sharing it. !!! From 7f2d36f3ffd9af5bbfc0746de0e295abbce3e26a Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sat, 31 Jan 2026 20:55:09 -0600 Subject: [PATCH 18/28] feat(docker): reorder imports for improved organization in registry authentication files --- microsandbox-core/tests/registry_auth_integration.rs | 4 +++- microsandbox-utils/lib/registry_auth.rs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/microsandbox-core/tests/registry_auth_integration.rs b/microsandbox-core/tests/registry_auth_integration.rs index b62f4765..81de8b6d 100644 --- a/microsandbox-core/tests/registry_auth_integration.rs +++ b/microsandbox-core/tests/registry_auth_integration.rs @@ -2,7 +2,9 @@ use std::sync::Mutex; use microsandbox_core::{management::image::resolve_registry_auth, oci::Reference}; use microsandbox_utils::env; -use microsandbox_utils::{clear_registry_credentials, store_registry_credentials, StoredRegistryCredentials}; +use microsandbox_utils::{ + StoredRegistryCredentials, clear_registry_credentials, store_registry_credentials, +}; use tempfile::TempDir; static ENV_LOCK: Mutex<()> = Mutex::new(()); diff --git a/microsandbox-utils/lib/registry_auth.rs b/microsandbox-utils/lib/registry_auth.rs index 4bdfecd6..06b04586 100644 --- a/microsandbox-utils/lib/registry_auth.rs +++ b/microsandbox-utils/lib/registry_auth.rs @@ -31,7 +31,7 @@ use std::{ use serde::{Deserialize, Serialize}; -use crate::{env, MicrosandboxUtilsResult}; +use crate::{MicrosandboxUtilsResult, env}; //-------------------------------------------------------------------------------------------------- // Types From c706f7df8df9fcd3d9e7936c368d81b24f6ec9bf Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sun, 1 Feb 2026 11:43:04 -0600 Subject: [PATCH 19/28] feat(docker): reorganize imports for better clarity in image and registry authentication files --- microsandbox-core/tests/registry_auth_integration.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/microsandbox-core/tests/registry_auth_integration.rs b/microsandbox-core/tests/registry_auth_integration.rs index 81de8b6d..cb12ae36 100644 --- a/microsandbox-core/tests/registry_auth_integration.rs +++ b/microsandbox-core/tests/registry_auth_integration.rs @@ -1,9 +1,8 @@ use std::sync::Mutex; use microsandbox_core::{management::image::resolve_registry_auth, oci::Reference}; -use microsandbox_utils::env; use microsandbox_utils::{ - StoredRegistryCredentials, clear_registry_credentials, store_registry_credentials, + StoredRegistryCredentials, clear_registry_credentials, env, store_registry_credentials, }; use tempfile::TempDir; From 7fb695eaf4bda83ace66bddcb3cc25d0abbfce71 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Sun, 1 Feb 2026 11:43:24 -0600 Subject: [PATCH 20/28] feat(docker): add base64, serde, and serde_json dependencies to Cargo.toml --- microsandbox-utils/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/microsandbox-utils/Cargo.toml b/microsandbox-utils/Cargo.toml index e5ada7cd..d9110df6 100644 --- a/microsandbox-utils/Cargo.toml +++ b/microsandbox-utils/Cargo.toml @@ -14,6 +14,7 @@ path = "lib/lib.rs" [dependencies] anyhow.workspace = true async-trait.workspace = true +base64.workspace = true console.workspace = true dirs.workspace = true futures.workspace = true @@ -21,6 +22,8 @@ indicatif.workspace = true libc.workspace = true nix = { workspace = true, features = ["fs", "process", "signal", "term"] } pretty-error-debug.workspace = true +serde.workspace = true +serde_json.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true From ad8207255d62d8b0a37485ce56c5c8bd5007c0a6 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Mon, 9 Feb 2026 15:36:11 -0600 Subject: [PATCH 21/28] feat(auth): implement registry authentication resolution and normalization functions --- microsandbox-core/lib/oci/auth.rs | 250 ++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 microsandbox-core/lib/oci/auth.rs diff --git a/microsandbox-core/lib/oci/auth.rs b/microsandbox-core/lib/oci/auth.rs new file mode 100644 index 00000000..36fd0aff --- /dev/null +++ b/microsandbox-core/lib/oci/auth.rs @@ -0,0 +1,250 @@ +use oci_client::secrets::RegistryAuth; + +use microsandbox_utils::{ + StoredRegistryCredentials, env, load_stored_registry_credentials, +}; + +use crate::{MicrosandboxError, MicrosandboxResult, oci::Reference}; + +//-------------------------------------------------------------------------------------------------- +// Functions +//-------------------------------------------------------------------------------------------------- + +/// Normalize a registry host for consistent lookups. +/// +/// This ensures we store and resolve credentials under the same key. +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 = normalized.trim_end_matches('/').to_string(); + + if normalized == "index.docker.io" { + "docker.io".to_string() + } else { + normalized + } +} + +/// Resolve the registry host for an image reference. +pub fn registry_host_for_reference(reference: &Reference) -> String { + let raw = reference.to_string(); + let mut parts = raw.split('/'); + let first = parts.next().unwrap_or(""); + + let host = if first.contains('.') || first.contains(':') || first == "localhost" { + first.to_string() + } else { + env::get_oci_registry() + }; + + normalize_registry_host(&host) +} + +/// Resolve registry auth for a given reference. +/// +/// Priority: +/// 1) Environment variables +/// 2) Stored credentials (msb login) +/// 3) Anonymous +pub fn resolve_registry_auth(reference: &Reference) -> MicrosandboxResult { + let registry = registry_host_for_reference(reference); + + let env_token = env::get_registry_token(); + let env_username = env::get_registry_username(); + let env_password = env::get_registry_password(); + + if env_token.is_some() && (env_username.is_some() || env_password.is_some()) { + return Err(MicrosandboxError::InvalidArgument( + "token cannot be combined with username/password".to_string(), + )); + } + + if let Some(token) = env_token { + return Ok(RegistryAuth::Bearer(token)); + } + + match (env_username, env_password) { + (Some(username), Some(password)) => { + return Ok(RegistryAuth::Basic(username, password)); + } + (Some(_), None) | (None, Some(_)) => { + tracing::warn!( + "registry credentials provided via env are incomplete; falling back to stored or anonymous" + ); + } + (None, None) => {} + } + + if let Some(stored) = load_stored_registry_credentials(®istry)? { + return Ok(match stored { + StoredRegistryCredentials::Basic { username, password } => { + RegistryAuth::Basic(username, password) + } + StoredRegistryCredentials::Token { token } => RegistryAuth::Bearer(token), + }); + } + + Ok(RegistryAuth::Anonymous) +} + +//-------------------------------------------------------------------------------------------------- +// Tests +//-------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + use microsandbox_utils::{StoredRegistryCredentials, clear_registry_credentials, store_registry_credentials}; + use tempfile::TempDir; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + 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 normalize_registry_host_maps_index_docker_io() { + assert_eq!(normalize_registry_host("index.docker.io"), "docker.io"); + } + + #[test] + fn normalize_registry_host_strips_scheme_and_slash() { + assert_eq!(normalize_registry_host("https://Docker.IO/"), "docker.io"); + } + + #[test] + fn resolve_registry_auth_prefers_env_token() { + let _lock = ENV_LOCK.lock().unwrap(); + 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()); + clear_registry_credentials().expect("clear"); + store_registry_credentials( + "ghcr.io", + StoredRegistryCredentials::Token { + token: "stored-token".to_string(), + }, + ) + .expect("store"); + + let reference: Reference = "ghcr.io/org/app:1.0".parse().unwrap(); + let auth = resolve_registry_auth(&reference).expect("resolve auth"); + assert!(matches!(auth, RegistryAuth::Bearer(t) if t == "env-token")); + } + + #[test] + fn resolve_registry_auth_prefers_env_basic() { + let _lock = ENV_LOCK.lock().unwrap(); + 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()); + clear_registry_credentials().expect("clear"); + store_registry_credentials( + "ghcr.io", + StoredRegistryCredentials::Token { + token: "stored-token".to_string(), + }, + ) + .expect("store"); + + let reference: Reference = "ghcr.io/org/app:1.0".parse().unwrap(); + let auth = resolve_registry_auth(&reference).expect("resolve auth"); + assert!(matches!(auth, RegistryAuth::Basic(u, p) if u == "env-user" && p == "env-pass")); + } + + #[test] + fn resolve_registry_auth_falls_back_to_stored() { + let _lock = ENV_LOCK.lock().unwrap(); + 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()); + clear_registry_credentials().expect("clear"); + store_registry_credentials( + "ghcr.io", + StoredRegistryCredentials::Token { + token: "stored-token".to_string(), + }, + ) + .expect("store"); + + let reference: Reference = "ghcr.io/org/app:1.0".parse().unwrap(); + let auth = resolve_registry_auth(&reference).expect("resolve auth"); + assert!(matches!(auth, RegistryAuth::Bearer(t) if t == "stored-token")); + } + + #[test] + fn resolve_registry_auth_returns_anonymous_when_missing() { + let _lock = ENV_LOCK.lock().unwrap(); + 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()); + clear_registry_credentials().expect("clear"); + + let reference: Reference = "ghcr.io/org/app:1.0".parse().unwrap(); + let auth = resolve_registry_auth(&reference).expect("resolve auth"); + assert!(matches!(auth, RegistryAuth::Anonymous)); + } + + #[test] + fn resolve_registry_auth_errors_on_token_and_basic() { + let _lock = ENV_LOCK.lock().unwrap(); + 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()); + clear_registry_credentials().expect("clear"); + + let reference: Reference = "ghcr.io/org/app:1.0".parse().unwrap(); + let err = resolve_registry_auth(&reference).expect_err("expected error"); + assert!(matches!(err, MicrosandboxError::InvalidArgument(_))); + } +} From 5294dfa67ca130462161e60ee97486485e90758c Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Mon, 9 Feb 2026 15:36:19 -0600 Subject: [PATCH 22/28] feat(auth): add compatibility re-exports for registry authentication resolution --- microsandbox-core/lib/management/image.rs | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 microsandbox-core/lib/management/image.rs diff --git a/microsandbox-core/lib/management/image.rs b/microsandbox-core/lib/management/image.rs new file mode 100644 index 00000000..326dc157 --- /dev/null +++ b/microsandbox-core/lib/management/image.rs @@ -0,0 +1,3 @@ +//! Compatibility re-exports for registry auth resolution. + +pub use crate::oci::resolve_registry_auth; From e755eff9097f8973a8993c08820f26bf6aca3862 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Mon, 9 Feb 2026 15:36:59 -0600 Subject: [PATCH 23/28] feat(auth): normalize registry host in resolve_registry_host function --- microsandbox-cli/bin/msb/handlers.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/microsandbox-cli/bin/msb/handlers.rs b/microsandbox-cli/bin/msb/handlers.rs index 583a0e99..ba65492e 100644 --- a/microsandbox-cli/bin/msb/handlers.rs +++ b/microsandbox-cli/bin/msb/handlers.rs @@ -8,7 +8,7 @@ use microsandbox_core::{ config::{self, Component, ComponentType, SandboxConfig}, home, menv, orchestra, sandbox, toolchain, }, - oci::Reference, + oci::{Reference, normalize_registry_host}, }; use microsandbox_server::MicrosandboxServerResult; use microsandbox_utils::{ @@ -804,9 +804,11 @@ enum LoginCredentials { } fn resolve_registry_host(registry: Option) -> String { - registry + let host = registry .or_else(env::get_registry_host) - .unwrap_or_else(env::get_oci_registry) + .unwrap_or_else(env::get_oci_registry); + + normalize_registry_host(&host) } async fn resolve_login_credentials( From ea79fbab358235508a26b7989956df909b7e0c84 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Mon, 9 Feb 2026 15:37:30 -0600 Subject: [PATCH 24/28] feat(auth): integrate registry authentication resolution in image handling --- microsandbox-core/lib/oci/image.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/microsandbox-core/lib/oci/image.rs b/microsandbox-core/lib/oci/image.rs index edd75b2a..01f72e03 100644 --- a/microsandbox-core/lib/oci/image.rs +++ b/microsandbox-core/lib/oci/image.rs @@ -33,7 +33,7 @@ use crate::{ MicrosandboxResult, management::db::{self}, - oci::{GlobalCache, LayerDependencies, LayerOps, Reference, Registry}, + oci::{GlobalCache, LayerDependencies, LayerOps, Reference, Registry, resolve_registry_auth}, }; use futures::future; #[cfg(feature = "cli")] @@ -147,7 +147,8 @@ impl Image { let mut platform = Platform::default(); platform.set_os(Os::Linux); - Registry::new(db.clone(), platform, layer_cache) + let auth = resolve_registry_auth(&image)?; + Registry::new(db.clone(), platform, layer_cache, auth) .await? .pull_image(&image) .await From 2364a5bff213f085b03a98049531df699e44502d Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Mon, 9 Feb 2026 15:37:44 -0600 Subject: [PATCH 25/28] feat(image): add image module to the project --- microsandbox-core/lib/management/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/microsandbox-core/lib/management/mod.rs b/microsandbox-core/lib/management/mod.rs index c65d8d75..cfa8e8c8 100644 --- a/microsandbox-core/lib/management/mod.rs +++ b/microsandbox-core/lib/management/mod.rs @@ -22,6 +22,7 @@ pub mod config; pub mod db; pub mod home; +pub mod image; pub mod menv; pub mod orchestra; pub mod rootfs; From f86fa1b342242d643e0c06e4df7a36b1b8f1b46e Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Mon, 9 Feb 2026 15:38:00 -0600 Subject: [PATCH 26/28] feat(auth): expose registry authentication functions in the module --- microsandbox-core/lib/oci/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/microsandbox-core/lib/oci/mod.rs b/microsandbox-core/lib/oci/mod.rs index 07d8d9a5..b009dfbc 100644 --- a/microsandbox-core/lib/oci/mod.rs +++ b/microsandbox-core/lib/oci/mod.rs @@ -6,6 +6,7 @@ //! - Managing image manifests, configurations, and layers mod global_cache; +mod auth; mod image; mod layer; #[cfg(test)] @@ -20,6 +21,7 @@ mod tests; //-------------------------------------------------------------------------------------------------- pub(crate) use global_cache::*; +pub use auth::{normalize_registry_host, registry_host_for_reference, resolve_registry_auth}; pub use image::*; pub(crate) use layer::*; pub use reference::*; From 7a5e91c0a895f2c6b21522b6e7607d33f2fae0d9 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Mon, 9 Feb 2026 15:38:17 -0600 Subject: [PATCH 27/28] feat(cli): clarify credential handling and environment variable precedence --- docs/references/cli.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/references/cli.md b/docs/references/cli.md index c06616ae..162ee738 100644 --- a/docs/references/cli.md +++ b/docs/references/cli.md @@ -724,6 +724,7 @@ msb login ghcr.io --username user --password-stdin !!!note `msb login` stores credentials locally but does not validate them against the registry. +When pulling images, environment variables take priority over stored credentials. !!! !!!warning Security From ae43443a1cb3641dcd6d1320729fdd7fcfbbe6f4 Mon Sep 17 00:00:00 2001 From: Ivan Yael Garcia Perez Date: Mon, 9 Feb 2026 16:09:20 -0600 Subject: [PATCH 28/28] fix(auth): clean up import formatting in auth and mod files --- microsandbox-core/lib/oci/auth.rs | 8 ++++---- microsandbox-core/lib/oci/mod.rs | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/microsandbox-core/lib/oci/auth.rs b/microsandbox-core/lib/oci/auth.rs index 36fd0aff..1207e6a5 100644 --- a/microsandbox-core/lib/oci/auth.rs +++ b/microsandbox-core/lib/oci/auth.rs @@ -1,8 +1,6 @@ use oci_client::secrets::RegistryAuth; -use microsandbox_utils::{ - StoredRegistryCredentials, env, load_stored_registry_credentials, -}; +use microsandbox_utils::{StoredRegistryCredentials, env, load_stored_registry_credentials}; use crate::{MicrosandboxError, MicrosandboxResult, oci::Reference}; @@ -102,7 +100,9 @@ mod tests { use super::*; use std::sync::Mutex; - use microsandbox_utils::{StoredRegistryCredentials, clear_registry_credentials, store_registry_credentials}; + use microsandbox_utils::{ + StoredRegistryCredentials, clear_registry_credentials, store_registry_credentials, + }; use tempfile::TempDir; static ENV_LOCK: Mutex<()> = Mutex::new(()); diff --git a/microsandbox-core/lib/oci/mod.rs b/microsandbox-core/lib/oci/mod.rs index b009dfbc..fa95c319 100644 --- a/microsandbox-core/lib/oci/mod.rs +++ b/microsandbox-core/lib/oci/mod.rs @@ -5,8 +5,8 @@ //! - Parsing and validating image references (tags and digests) //! - Managing image manifests, configurations, and layers -mod global_cache; mod auth; +mod global_cache; mod image; mod layer; #[cfg(test)] @@ -20,8 +20,8 @@ mod tests; // Exports //-------------------------------------------------------------------------------------------------- -pub(crate) use global_cache::*; pub use auth::{normalize_registry_host, registry_host_for_reference, resolve_registry_auth}; +pub(crate) use global_cache::*; pub use image::*; pub(crate) use layer::*; pub use reference::*;