use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Mutex;
use std::sync::OnceLock;
use reqwest::StatusCode;
use secrecy::ExposeSecret;
use secrecy::SecretString;
use serde::Deserialize;
use serde::Serialize;
use super::Address;
use super::Provider;
use super::ProviderCredentials;
use super::ProviderUrl;
use super::credential_or_env;
use super::join_slash_path;
use crate::MonosecretError;
use crate::Result;
use crate::config::NativeAddress;
const DEFAULT_PATH: &str = "/monosecret";
const DEFAULT_HOST: &str = "app.infisical.com";
const CLIENT_ID: &str = "client_id";
const CLIENT_SECRET: &str = "client_secret";
const TOKEN: &str = "token";
const INFISICAL_CLIENT_ID_ENV: &str = "INFISICAL_CLIENT_ID";
const INFISICAL_CLIENT_SECRET_ENV: &str = "INFISICAL_CLIENT_SECRET";
const INFISICAL_TOKEN_ENV: &str = "INFISICAL_TOKEN";
const INFISICAL_DOMAIN_ENVS: [&str; 2] = ["INFISICAL_DOMAIN", "INFISICAL_API_URL"];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InfisicalConfig {
pub endpoint: String,
pub project_id: String,
pub environment: Option<String>,
pub path: String,
}
impl Default for InfisicalConfig {
fn default() -> Self {
Self {
endpoint: format!("https://{DEFAULT_HOST}"),
project_id: String::new(),
environment: None,
path: DEFAULT_PATH.to_string(),
}
}
}
impl InfisicalConfig {
fn endpoint_from_domain(var: &str, domain: &str, http_scheme: &str) -> Result<String> {
let domain = domain.trim_end_matches('/');
let domain = domain.strip_suffix("/api").unwrap_or(domain);
let absolute = if domain.starts_with("http://") || domain.starts_with("https://") {
domain.to_string()
} else {
format!("{http_scheme}://{domain}")
};
let parsed = url::Url::parse(&absolute).map_err(|e| {
MonosecretError::ProviderOperationFailed(format!("Invalid {var} '{domain}': {e}"))
})?;
if !parsed.path().trim_matches('/').is_empty() {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Invalid {var} '{domain}': it names a path. Infisical's \
API is addressed at the host, e.g. https://vault.example.com."
)));
}
Ok(absolute)
}
fn env_domain() -> Option<(&'static str, String)> {
Self::pick_domain(|var| std::env::var(var).ok())
}
fn pick_domain(lookup: impl Fn(&str) -> Option<String>) -> Option<(&'static str, String)> {
INFISICAL_DOMAIN_ENVS.iter().find_map(|var| {
let value = lookup(var)?;
let value = value.trim().to_string();
(!value.is_empty()).then_some((*var, value))
})
}
}
impl TryFrom<&ProviderUrl> for InfisicalConfig {
type Error = MonosecretError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
let scheme = url.scheme();
if scheme != "infisical" {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Invalid scheme '{scheme}' for infisical provider. Expected 'infisical'."
)));
}
let use_tls = url
.query_pairs()
.find(|(k, _)| k == "tls")
.map(|(_, v)| {
match v.as_ref() {
"true" | "1" => Ok(true),
"false" | "0" => Ok(false),
other => {
Err(MonosecretError::ProviderOperationFailed(format!(
"Unknown tls value '{other}'. Expected 'true' or 'false'."
)))
}
}
})
.transpose()?
.unwrap_or(true);
let http_scheme = if use_tls { "https" } else { "http" };
let endpoint = match url.host().filter(|s| !s.is_empty()) {
Some(host) => {
match url.port() {
Some(port) => format!("{http_scheme}://{host}:{port}"),
None => format!("{http_scheme}://{host}"),
}
}
None => {
match Self::env_domain() {
Some((var, domain)) => Self::endpoint_from_domain(var, &domain, http_scheme)?,
None => format!("{http_scheme}://{DEFAULT_HOST}"),
}
}
};
let project_id = url.path().trim_matches('/').to_string();
if project_id.is_empty() {
return Err(MonosecretError::ProviderOperationFailed(
"No Infisical project given. Name the project UUID in the URI, e.g. \
infisical://app.infisical.com/7e2f1a4c-.... Infisical's API takes the \
project's UUID (Project Settings -> Project ID), not its slug."
.to_string(),
));
}
if project_id.contains('/') {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Invalid Infisical project '{project_id}': expected a single project UUID. \
The folder holding the secrets is set with `?path=` instead."
)));
}
let environment = url.query_value("env").filter(|s| !s.is_empty());
let path = match url.query_value("path").filter(|s| !s.is_empty()) {
Some(p) => {
let trimmed = p.trim_end_matches('/');
if trimmed.starts_with('/') {
trimmed.to_string()
} else {
format!("/{trimmed}")
}
}
None => DEFAULT_PATH.to_string(),
};
Ok(Self {
endpoint,
project_id,
environment,
path,
})
}
}
#[derive(Debug)]
struct Location {
environment: String,
secret_path: String,
key: String,
}
enum SecretRead {
Response(Option<SecretString>),
NotFound,
}
pub struct InfisicalProvider {
config: InfisicalConfig,
credentials: ProviderCredentials,
token: tokio::sync::OnceCell<SecretString>,
http: OnceLock<reqwest::Client>,
profile: Mutex<Option<String>>,
}
crate::register_provider! {
struct: InfisicalProvider,
config: InfisicalConfig,
metadata: &super::catalog::INFISICAL,
}
impl InfisicalProvider {
pub fn new(config: InfisicalConfig) -> Self {
Self {
config,
credentials: ProviderCredentials::new(),
token: tokio::sync::OnceCell::new(),
http: OnceLock::new(),
profile: Mutex::new(None),
}
}
fn http(&self) -> &reqwest::Client {
self.http.get_or_init(reqwest::Client::new)
}
fn session_profile(&self) -> Option<String> {
self.profile
.lock()
.ok()?
.as_deref()
.map(str::trim)
.filter(|profile| !profile.is_empty())
.map(str::to_string)
}
fn missing_environment_error(&self, environment: &str) -> MonosecretError {
let source = if self.config.environment.is_some() {
"The provider URI selected it with `?env=`."
} else {
"The active Monosecret profile selected it because the provider URI does not pin an \
environment with `?env=`."
};
MonosecretError::ProviderOperationFailed(format!(
"Infisical could not find environment '{}' in project {} (or the project itself is \
unavailable). {source}",
environment, self.config.project_id
))
}
fn render_uri(&self, environment: Option<&str>, path: Option<&str>) -> String {
let plain_http = self.config.endpoint.starts_with("http://");
let host = self
.config
.endpoint
.trim_start_matches("https://")
.trim_start_matches("http://");
let mut uri = format!("infisical://{host}/{}", self.config.project_id);
let mut query = Vec::new();
if let Some(env) = environment {
query.push(format!("env={}", ProviderUrl::encode_query(env)));
}
if let Some(path) = path.filter(|path| *path != DEFAULT_PATH) {
query.push(format!("path={}", ProviderUrl::encode_query(path)));
}
if plain_http {
query.push("tls=false".to_string());
}
if !query.is_empty() {
uri.push('?');
uri.push_str(&query.join("&"));
}
uri
}
fn locate(&self, addr: Address<'_>) -> Result<Location> {
let coords = self.resolve_coords(addr)?;
let environment = match (&self.config.environment, addr) {
(Some(env), _) => env.clone(),
(None, Address::Convention { profile, .. }) => profile.to_string(),
(None, Address::Native(_)) => {
self.session_profile().ok_or_else(|| {
MonosecretError::ProviderOperationFailed(
"No Infisical environment for this ref. Name one in the provider URI, \
e.g. infisical://app.infisical.com/{project-id}?env=prod. A provider \
credential is resolved without a profile, so its ref always needs one."
.to_string(),
)
})?
}
};
let (secret_path, key) = match coords.item.rsplit_once('/') {
Some((folder, key)) => {
let folder = folder.trim_end_matches('/');
let secret_path = match folder {
"" => "/".to_string(),
relative if !relative.starts_with('/') => {
join_slash_path(&self.config.path, relative)
}
absolute => absolute.to_string(),
};
(secret_path, key.to_string())
}
None => (self.config.path.clone(), coords.item.clone()),
};
if key.is_empty() {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Invalid Infisical ref '{}': it names a folder, not a secret.",
coords.item
)));
}
Ok(Location {
environment,
secret_path,
key,
})
}
async fn resolve_token(&self) -> Result<&SecretString> {
self.token
.get_or_try_init(|| {
async {
match credential_or_env(&self.credentials, TOKEN, INFISICAL_TOKEN_ENV) {
Some(token) => Ok(SecretString::new(token.into())),
None => self.login().await,
}
}
})
.await
}
async fn login(&self) -> Result<SecretString> {
let client_id = credential_or_env(&self.credentials, CLIENT_ID, INFISICAL_CLIENT_ID_ENV);
let client_secret = credential_or_env(
&self.credentials,
CLIENT_SECRET,
INFISICAL_CLIENT_SECRET_ENV,
);
let (client_id, client_secret) = match (client_id, client_secret) {
(Some(id), Some(secret)) => (id, secret),
(None, None) => {
return Err(MonosecretError::ProviderOperationFailed(format!(
"No Infisical credentials found. Configure the {CLIENT_ID} and \
{CLIENT_SECRET} provider credentials (or a ready-made {TOKEN}), or set \
{INFISICAL_CLIENT_ID_ENV} and {INFISICAL_CLIENT_SECRET_ENV} (or \
{INFISICAL_TOKEN_ENV})."
)));
}
(Some(_), None) => {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Infisical {CLIENT_SECRET} is missing. Configure the {CLIENT_SECRET} \
provider credential, or set {INFISICAL_CLIENT_SECRET_ENV}."
)));
}
(None, Some(_)) => {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Infisical {CLIENT_ID} is missing. Configure the {CLIENT_ID} provider \
credential, or set {INFISICAL_CLIENT_ID_ENV}."
)));
}
};
let url = format!("{}/api/v1/auth/universal-auth/login", self.config.endpoint);
let body = serde_json::json!({
"clientId": client_id,
"clientSecret": client_secret,
});
let auth_client = reqwest::Client::new();
let response = auth_client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to connect to Infisical at {}: {}",
self.config.endpoint,
crate::error::display_error_chain(&e)
))
})?;
if !response.status().is_success() {
let status = response.status();
let body = Self::response_body(response).await?;
return Err(MonosecretError::ProviderOperationFailed(format!(
"Infisical login returned HTTP {status}: {}",
Self::error_message(&body)
)));
}
let parsed: serde_json::Value = response.json().await.map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to parse Infisical login response: {}",
crate::error::display_error_chain(&e)
))
})?;
let token = parsed
.get("accessToken")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
MonosecretError::ProviderOperationFailed(
"Infisical login response missing accessToken".to_string(),
)
})?;
Ok(SecretString::new(token.to_string().into()))
}
fn error_message(body: &str) -> String {
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| {
v.get("message")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
.unwrap_or_else(|| body.to_string())
}
async fn response_body(response: reqwest::Response) -> Result<String> {
let status = response.status();
response.text().await.map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to read Infisical HTTP {status} response body: {}",
crate::error::display_error_chain(&e)
))
})
}
fn read_query<'q>(
&'q self,
environment: &'q str,
secret_path: &'q str,
) -> Vec<(&'static str, &'q str)> {
vec![
("projectId", self.config.project_id.as_str()),
("environment", environment),
("secretPath", secret_path),
("expandSecretReferences", "true"),
("viewSecretValue", "true"),
]
}
fn environment_probe_query<'q>(&'q self, environment: &'q str) -> Vec<(&'static str, &'q str)> {
vec![
("projectId", self.config.project_id.as_str()),
("environment", environment),
("secretPath", "/"),
("expandSecretReferences", "false"),
("viewSecretValue", "false"),
]
}
fn secret_value(secret: &serde_json::Value, key: &str) -> Result<Option<SecretString>> {
if secret["secretValueHidden"].as_bool() == Some(true) {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Infisical withheld the value of '{key}': this identity may see that the \
secret exists but not read it. Grant it permission to read secret values."
)));
}
Ok(secret["secretValue"]
.as_str()
.map(|v| SecretString::new(v.to_string().into())))
}
fn secret_url(&self, key: &str) -> Result<String> {
let mut url = url::Url::parse(&self.config.endpoint).map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Invalid Infisical endpoint '{}': {e}",
self.config.endpoint
))
})?;
url.path_segments_mut()
.map_err(|()| {
MonosecretError::ProviderOperationFailed(format!(
"Invalid Infisical endpoint '{}': not a base URL",
self.config.endpoint
))
})?
.extend(["api", "v4", "secrets", key]);
Ok(url.into())
}
async fn get_async(&self, loc: &Location, version: Option<&str>) -> Result<SecretRead> {
let url = self.secret_url(&loc.key)?;
let mut query = self.read_query(&loc.environment, &loc.secret_path);
if let Some(version) = version {
query.push(("version", version));
}
let response = self.send(reqwest::Method::GET, &url, &query, None).await?;
let status = response.status();
let body = Self::response_body(response).await?;
match status {
StatusCode::OK => {
let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to parse Infisical response: {e}"
))
})?;
let secret = parsed.get("secret").unwrap_or(&serde_json::Value::Null);
Self::secret_value(secret, &loc.key).map(SecretRead::Response)
}
StatusCode::NOT_FOUND => Ok(SecretRead::NotFound),
_ => Err(self.http_error(status, &body, "reading a secret")),
}
}
fn merge_imports(
parsed: &serde_json::Value,
listed: &mut HashMap<String, SecretString>,
) -> Result<()> {
let Some(imports) = parsed["imports"].as_array() else {
return Ok(());
};
for import in imports.iter().rev() {
let Some(secrets) = import["secrets"].as_array() else {
continue;
};
for secret in secrets {
let Some(key) = secret["secretKey"].as_str() else {
continue;
};
if listed.contains_key(key) {
continue;
}
if let Some(value) = Self::secret_value(secret, key)? {
listed.insert(key.to_string(), value);
}
}
}
Ok(())
}
async fn list_async(
&self,
environment: &str,
secret_path: &str,
) -> Result<Option<HashMap<String, SecretString>>> {
let url = format!("{}/api/v4/secrets", self.config.endpoint);
let query = self.read_query(environment, secret_path);
let response = self.send(reqwest::Method::GET, &url, &query, None).await?;
let status = response.status();
let body = Self::response_body(response).await?;
match status {
StatusCode::OK => {
let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to parse Infisical response: {e}"
))
})?;
let secrets = parsed
.get("secrets")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
MonosecretError::ProviderOperationFailed(
"Infisical list response missing `secrets`".to_string(),
)
})?;
let mut listed = HashMap::new();
for secret in secrets {
let Some(key) = secret["secretKey"].as_str() else {
continue;
};
if let Some(value) = Self::secret_value(secret, key)? {
listed.insert(key.to_string(), value);
}
}
Self::merge_imports(&parsed, &mut listed)?;
Ok(Some(listed))
}
StatusCode::NOT_FOUND => Ok(None),
_ => Err(self.http_error(status, &body, "listing secrets")),
}
}
async fn ensure_environment_exists_async(&self, environment: &str) -> Result<()> {
let url = format!("{}/api/v4/secrets", self.config.endpoint);
let query = self.environment_probe_query(environment);
let response = self.send(reqwest::Method::GET, &url, &query, None).await?;
let status = response.status();
match status {
StatusCode::OK => Ok(()),
StatusCode::NOT_FOUND => Err(self.missing_environment_error(environment)),
_ => {
let body = Self::response_body(response).await?;
Err(self.http_error(status, &body, "checking an environment"))
}
}
}
async fn set_async(&self, loc: &Location, value: &SecretString) -> Result<()> {
if self
.write_secret(reqwest::Method::PATCH, loc, value)
.await?
{
return Ok(());
}
if self.write_secret(reqwest::Method::POST, loc, value).await? {
return Ok(());
}
if loc.secret_path == "/" {
return Err(self.missing_environment_error(&loc.environment));
}
let refused = self.create_folder(loc).await?;
if self.write_secret(reqwest::Method::POST, loc, value).await? {
return Ok(());
}
Err(MonosecretError::ProviderOperationFailed(format!(
"Infisical could not find the folder '{}' in environment '{}' even after \
creating it{}",
loc.secret_path,
loc.environment,
match refused {
Some(reason) => format!(", which reported: {reason}"),
None => ".".to_string(),
}
)))
}
async fn write_secret(
&self,
method: reqwest::Method,
loc: &Location,
value: &SecretString,
) -> Result<bool> {
let url = self.secret_url(&loc.key)?;
let body = serde_json::json!({
"projectId": self.config.project_id,
"environment": loc.environment,
"secretPath": loc.secret_path,
"secretValue": value.expose_secret(),
});
let response = self.send(method, &url, &[], Some(body)).await?;
let status = response.status();
if status == StatusCode::NOT_FOUND {
return Ok(false);
}
let body = Self::response_body(response).await?;
if status.is_success() {
Self::written(&body, &loc.key)?;
return Ok(true);
}
Err(self.http_error(status, &body, "writing a secret"))
}
fn written(body: &str, key: &str) -> Result<()> {
let parsed: serde_json::Value = match serde_json::from_str(body) {
Ok(parsed) => parsed,
Err(_) => return Ok(()),
};
let Some(approval) = parsed.get("approval") else {
return Ok(());
};
let status = approval["status"].as_str().unwrap_or("open");
Err(MonosecretError::ProviderOperationFailed(format!(
"Infisical did not store '{key}': this project has an approval policy, so the \
write opened a change request ({status}) instead. The value lands once the \
request is approved in Infisical."
)))
}
async fn create_folder(&self, loc: &Location) -> Result<Option<String>> {
let (parent, name) = loc
.secret_path
.rsplit_once('/')
.map(|(parent, name)| {
let parent = if parent.is_empty() { "/" } else { parent };
(parent.to_string(), name.to_string())
})
.ok_or_else(|| {
MonosecretError::ProviderOperationFailed(format!(
"Invalid Infisical folder '{}': paths are absolute, e.g. /monosecret.",
loc.secret_path
))
})?;
let url = format!("{}/api/v2/folders", self.config.endpoint);
let body = serde_json::json!({
"projectId": self.config.project_id,
"environment": loc.environment,
"path": parent,
"name": name,
});
let response = self
.send(reqwest::Method::POST, &url, &[], Some(body))
.await?;
let status = response.status();
if status.is_success() {
return Ok(None);
}
let body = Self::response_body(response).await?;
if status == StatusCode::BAD_REQUEST {
return Ok(Some(Self::error_message(&body)));
}
Err(self.http_error(status, &body, "creating a folder"))
}
async fn send(
&self,
method: reqwest::Method,
url: &str,
query: &[(&str, &str)],
body: Option<serde_json::Value>,
) -> Result<reqwest::Response> {
let token = self.resolve_token().await?;
let mut request = self
.http()
.request(method, url)
.bearer_auth(token.expose_secret())
.query(query);
if let Some(body) = body {
request = request.json(&body);
}
request.send().await.map_err(|e| {
MonosecretError::ProviderOperationFailed(format!(
"Failed to connect to Infisical at {}: {}",
self.config.endpoint,
crate::error::display_error_chain(&e)
))
})
}
fn http_error(&self, status: StatusCode, body: &str, action: &str) -> MonosecretError {
let message = Self::error_message(body);
match status {
StatusCode::UNAUTHORIZED => {
MonosecretError::ProviderOperationFailed(format!(
"Infisical authentication failed (401) while {action}: {message}. \
Check the machine identity's {CLIENT_ID}/{CLIENT_SECRET}."
))
}
StatusCode::FORBIDDEN => {
MonosecretError::ProviderOperationFailed(format!(
"Infisical denied access (403) while {action}: {message}. Check the machine \
identity's permissions on project {}.",
self.config.project_id
))
}
StatusCode::TOO_MANY_REQUESTS => {
MonosecretError::ProviderOperationFailed(format!(
"Infisical rate limit exceeded (429) while {action}: {message}."
))
}
_ => {
MonosecretError::ProviderOperationFailed(format!(
"Infisical returned HTTP {status} while {action}: {message}"
))
}
}
}
}
impl Provider for InfisicalProvider {
fn set_profile(&self, profile: &str) {
if let Ok(mut held) = self.profile.lock() {
*held = Some(profile.to_string());
}
}
fn describe_write_target(&self, addr: Address<'_>) -> Result<String> {
let loc = self.locate(addr)?;
Ok(format!(
"environment {}, {}/{}",
loc.environment,
loc.secret_path.trim_end_matches('/'),
loc.key
))
}
fn convention_address(&self, project: &str, profile: &str, key: &str) -> Result<NativeAddress> {
for (label, value) in [("project", project), ("profile", profile), ("key", key)] {
if value.is_empty() {
return Err(MonosecretError::ProviderOperationFailed(format!(
"{label} cannot be empty"
)));
}
}
if key.contains('/') {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Invalid key '{key}': Infisical addresses folders by path, so a '/' would \
move the secret to another folder."
)));
}
for (label, value) in [("Project", project), ("Profile", profile)] {
if !value
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(MonosecretError::ProviderOperationFailed(format!(
"{label} '{value}' cannot name an Infisical folder: only letters, digits, \
dashes and underscores are allowed. Rename it in monosecret.toml, or \
address these secrets by their own coordinates with \
ref = {{ item = \"/folder/KEY\" }}."
)));
}
}
Ok(NativeAddress {
item: format!("{}/{project}/{profile}/{key}", self.config.path),
..Default::default()
})
}
fn entry_coordinates<'a>(
&self,
addr: Address<'a>,
) -> Result<std::borrow::Cow<'a, NativeAddress>> {
let loc = self.locate(addr)?;
let version = match addr {
Address::Native(native) => native.version.clone(),
Address::Convention { .. } => None,
};
let item = format!(
"environment={}&path={}&key={}",
ProviderUrl::encode_query(&loc.environment),
ProviderUrl::encode_query(&loc.secret_path),
ProviderUrl::encode_query(&loc.key),
);
Ok(std::borrow::Cow::Owned(NativeAddress {
item,
version,
..Default::default()
}))
}
fn with_credentials(&mut self, credentials: ProviderCredentials) {
self.credentials = credentials;
}
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
self.render_uri(
self.config.environment.as_deref(),
Some(self.config.path.as_str()),
)
}
fn entry_container_identity(&self) -> String {
self.render_uri(None, None)
}
fn supported_coords(&self) -> &'static [&'static str] {
&["version"]
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
let loc = self.locate(addr)?;
let version = match addr {
Address::Native(native) => native.version.as_deref(),
Address::Convention { .. } => None,
};
super::block_on(async {
let read = self.get_async(&loc, version).await?;
if matches!(&read, SecretRead::NotFound) {
self.ensure_environment_exists_async(&loc.environment)
.await?;
}
Ok(match read {
SecretRead::Response(value) => value,
SecretRead::NotFound => None,
})
})
}
fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result<HashMap<String, SecretString>> {
let (versioned, listable): (Vec<_>, Vec<_>) = requests.iter().partition(
|(_, addr)| matches!(addr, Address::Native(native) if native.version.is_some()),
);
let versioned: Vec<(&str, Address<'_>)> = versioned.into_iter().copied().collect();
let versioned_misses = Mutex::new(HashSet::new());
let mut resolved = if versioned.is_empty() {
HashMap::new()
} else {
super::get_each_with(&versioned, |addr| {
let loc = self.locate(addr)?;
let version = match addr {
Address::Native(native) => native.version.as_deref(),
Address::Convention { .. } => None,
};
match super::block_on(self.get_async(&loc, version))? {
SecretRead::Response(value) => Ok(value),
SecretRead::NotFound => {
versioned_misses
.lock()
.map_err(|_| {
MonosecretError::ProviderOperationFailed(
"Infisical batch diagnostic state was poisoned".to_string(),
)
})?
.insert(loc.environment);
Ok(None)
}
}
})?
};
let versioned_environments = versioned_misses.into_inner().map_err(|_| {
MonosecretError::ProviderOperationFailed(
"Infisical batch diagnostic state was poisoned".to_string(),
)
})?;
super::block_on(async {
let mut folders: HashMap<(String, String), Vec<(&str, String)>> = HashMap::new();
for (name, addr) in &listable {
let loc = self.locate(*addr)?;
folders
.entry((loc.environment, loc.secret_path))
.or_default()
.push((name, loc.key));
}
let mut known_environments = HashSet::new();
let mut uncertain_environments = versioned_environments;
for ((environment, secret_path), wanted) in folders {
match self.list_async(&environment, &secret_path).await? {
Some(listed) => {
known_environments.insert(environment);
for (name, key) in wanted {
if let Some(value) = listed.get(&key) {
resolved.insert(name.to_string(), value.clone());
}
}
}
None if secret_path == "/" => {
return Err(self.missing_environment_error(&environment));
}
None => {
uncertain_environments.insert(environment);
}
}
}
if resolved.is_empty() {
let to_probe: Vec<String> = uncertain_environments
.difference(&known_environments)
.cloned()
.collect();
for environment in to_probe {
self.ensure_environment_exists_async(&environment).await?;
}
}
Ok(resolved)
})
}
fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> {
self.check_writable(addr)?;
let loc = self.locate(addr)?;
super::block_on(self.set_async(&loc, value))
}
fn check_writable(&self, addr: Address<'_>) -> Result<()> {
match addr {
Address::Native(native) if native.version.is_some() => {
Err(MonosecretError::ProviderOperationFailed(
"infisical refs pinning a `version` are read-only: a past version cannot \
be rewritten. Drop `version` to write the secret's latest value."
.to_string(),
))
}
_ => Ok(()),
}
}
}
#[cfg(test)]
#[allow(clippy::indexing_slicing)] mod tests {
use std::io::BufRead;
use std::io::BufReader;
use std::io::Read;
use std::io::Write;
use std::net::SocketAddr;
use std::net::TcpListener;
use std::net::TcpStream;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use std::time::Instant;
use url::Url;
use super::*;
const PROJECT: &str = "7e2f1a4c-0000-0000-0000-000000000000";
fn config(s: &str) -> InfisicalConfig {
InfisicalConfig::try_from(&ProviderUrl::new(Url::parse(s).unwrap())).unwrap()
}
fn provider(s: &str) -> InfisicalProvider {
InfisicalProvider::new(config(s))
}
fn read_request(reader: &mut BufReader<TcpStream>) -> Option<String> {
let mut request_line = String::new();
if reader.read_line(&mut request_line).ok()? == 0 {
return None;
}
let mut content_length = 0;
loop {
let mut line = String::new();
reader.read_line(&mut line).ok()?;
if line == "\r\n" || line.is_empty() {
break;
}
if let Some((name, value)) = line.split_once(':')
&& name.eq_ignore_ascii_case("content-length")
{
content_length = value.trim().parse().ok()?;
}
}
if content_length > 0 {
let mut body = vec![0; content_length];
reader.read_exact(&mut body).ok()?;
}
Some(request_line.trim_end().to_string())
}
fn connection_recording_server() -> (SocketAddr, thread::JoinHandle<Vec<(usize, String)>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let endpoint = listener.local_addr().unwrap();
let (sender, receiver) = mpsc::channel();
let request_count = Arc::new(AtomicUsize::new(0));
let server = thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(5);
let mut workers = Vec::new();
let mut next_connection_id = 0;
while request_count.load(Ordering::Acquire) < 2 && Instant::now() < deadline {
match listener.accept() {
Ok((stream, _)) => {
let connection_id = next_connection_id;
next_connection_id += 1;
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let sender = sender.clone();
let request_count = Arc::clone(&request_count);
workers.push(thread::spawn(move || {
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut writer = stream;
while let Some(request) = read_request(&mut reader) {
let seen = request_count.fetch_add(1, Ordering::AcqRel) + 1;
sender.send((connection_id, request.clone())).unwrap();
let body = if request
.starts_with("POST /api/v1/auth/universal-auth/login ")
{
r#"{"accessToken":"test-token"}"#
} else if request
.starts_with("GET /api/v4/secrets/DATABASE_HOST?")
{
r#"{"secret":{"secretKey":"DATABASE_HOST","secretValue":"db.internal","secretValueHidden":false}}"#
} else {
r#"{"message":"unexpected request"}"#
};
write!(
writer,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: keep-alive\r\n\r\n{body}",
body.len()
)
.unwrap();
writer.flush().unwrap();
if seen >= 2 {
break;
}
}
}));
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(5));
}
Err(error) => panic!("accept failed: {error}"),
}
}
for worker in workers {
worker.join().unwrap();
}
drop(sender);
receiver.into_iter().collect()
});
(endpoint, server)
}
#[test]
fn universal_auth_and_secret_read_use_distinct_tcp_connections() {
let (endpoint, server) = connection_recording_server();
let mut provider = provider(&format!(
"infisical://{endpoint}/{PROJECT}?tls=false&env=development"
));
provider.with_credentials(ProviderCredentials::from([
(
CLIENT_ID.to_string(),
SecretString::new("test-client-id".to_string().into()),
),
(
CLIENT_SECRET.to_string(),
SecretString::new("test-client-secret".to_string().into()),
),
]));
let address = NativeAddress {
item: "/DATABASE_HOST".into(),
..Default::default()
};
let value = provider
.get(Address::Native(&address))
.expect("the secret read must succeed")
.expect("the fixture must return DATABASE_HOST");
assert_eq!(value.expose_secret(), "db.internal");
let requests = server.join().unwrap();
assert_eq!(requests.len(), 2, "{requests:#?}");
assert!(
requests[0]
.1
.starts_with("POST /api/v1/auth/universal-auth/login ")
);
assert!(
requests[1]
.1
.starts_with("GET /api/v4/secrets/DATABASE_HOST?")
);
assert_ne!(
requests[0].0, requests[1].0,
"Universal Auth and secret reads must use different TCP connections"
);
}
fn provider_with_token(endpoint: SocketAddr, environment: Option<&str>) -> InfisicalProvider {
let environment =
environment.map_or_else(String::new, |environment| format!("&env={environment}"));
let mut provider = provider(&format!(
"infisical://{endpoint}/{PROJECT}?tls=false{environment}"
));
provider.with_credentials(ProviderCredentials::from([(
TOKEN.to_string(),
SecretString::new("test-token".to_string().into()),
)]));
provider
}
fn response_server(
responses: Vec<(&'static str, &'static str)>,
) -> (SocketAddr, thread::JoinHandle<Vec<String>>) {
response_server_with_lengths(
responses
.into_iter()
.map(|(status, body)| (status, body, body.len()))
.collect(),
)
}
fn response_server_with_lengths(
responses: Vec<(&'static str, &'static str, usize)>,
) -> (SocketAddr, thread::JoinHandle<Vec<String>>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let endpoint = listener.local_addr().unwrap();
let server = thread::spawn(move || {
let mut requests = Vec::new();
for (status, body, content_length) in responses {
let (mut stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(&mut stream);
let mut request_line = String::new();
reader.read_line(&mut request_line).unwrap();
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
if line == "\r\n" || line.is_empty() {
break;
}
}
requests.push(request_line.trim_end().to_string());
write!(
stream,
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: \
{content_length}\r\nConnection: close\r\n\r\n{body}"
)
.unwrap();
}
requests
});
(endpoint, server)
}
fn one_convention_request() -> [(&'static str, Address<'static>); 1] {
[(
"API_KEY",
Address::convention("myapp", "development", "API_KEY"),
)]
}
#[test]
fn uri_defaults_to_infisical_cloud() {
let c = config(&format!("infisical:///{PROJECT}"));
assert_eq!(c.endpoint, "https://app.infisical.com");
assert_eq!(c.project_id, PROJECT);
assert_eq!(c.path, DEFAULT_PATH);
assert_eq!(c.environment, None);
}
#[test]
fn an_all_missing_batch_diagnoses_a_missing_profile_environment() {
let (endpoint, server) = response_server(vec![
("404 Not Found", r#"{"message":"not found"}"#),
("404 Not Found", r#"{"message":"not found"}"#),
]);
let provider = provider_with_token(endpoint, None);
let error = provider
.get_many(&one_convention_request())
.expect_err("the absent environment must be reported")
.to_string();
assert!(error.contains("environment 'development'"), "{error}");
assert!(error.contains(PROJECT), "{error}");
assert!(error.contains("active Monosecret profile"), "{error}");
let requests = server.join().unwrap();
assert_eq!(requests.len(), 2);
assert!(requests[0].contains("environment=development"));
assert!(requests[1].contains("secretPath=%2F"));
}
#[test]
fn a_missing_folder_in_an_existing_environment_stays_unset() {
let (endpoint, server) = response_server(vec![
("404 Not Found", r#"{"message":"not found"}"#),
("200 OK", r#"{"secrets":[]}"#),
]);
let provider = provider_with_token(endpoint, None);
assert!(
provider
.get_many(&one_convention_request())
.expect("the environment exists")
.is_empty()
);
assert_eq!(server.join().unwrap().len(), 2);
}
#[test]
fn versioned_batch_misses_probe_the_environment_once_without_values() {
let first_body = r#"{"message":"first version not found"}"#;
let second_body = r#"{"message":"second version not found"}"#;
let root_body = r#"{"secrets":[{"secretValue":"unrelated plaintext"}]}"#;
let (endpoint, server) = response_server_with_lengths(vec![
("404 Not Found", first_body, first_body.len()),
("404 Not Found", second_body, second_body.len()),
("200 OK", root_body, root_body.len() + 1024),
]);
let provider = provider_with_token(endpoint, Some("development"));
let first = NativeAddress {
item: "/infra/FIRST".into(),
version: Some("1".into()),
..Default::default()
};
let second = NativeAddress {
item: "/infra/SECOND".into(),
version: Some("2".into()),
..Default::default()
};
assert!(
provider
.get_many(&[
("FIRST", Address::Native(&first)),
("SECOND", Address::Native(&second)),
])
.expect("the environment exists")
.is_empty()
);
let requests = server.join().unwrap();
assert_eq!(requests.len(), 3);
let probes: Vec<_> = requests
.iter()
.filter(|request| request.starts_with("GET /api/v4/secrets?"))
.collect();
assert_eq!(probes.len(), 1, "{requests:#?}");
assert!(probes[0].contains("secretPath=%2F"), "{}", probes[0]);
assert!(
probes[0].contains("expandSecretReferences=false"),
"{}",
probes[0]
);
assert!(probes[0].contains("viewSecretValue=false"), "{}", probes[0]);
assert!(!probes[0].contains("viewSecretValue=true"), "{}", probes[0]);
}
#[test]
fn a_missing_environment_names_how_it_was_selected() {
let implicit = provider(&format!("infisical://app.infisical.com/{PROJECT}"));
let implicit_error = implicit
.missing_environment_error("development")
.to_string();
assert!(implicit_error.contains("active Monosecret profile"));
let pinned = provider(&format!(
"infisical://app.infisical.com/{PROJECT}?env=shared"
));
let pinned_error = pinned.missing_environment_error("shared").to_string();
assert!(pinned_error.contains("provider URI selected it with `?env=`"));
}
#[test]
fn uri_carries_host_environment_and_path() {
let c = config(&format!(
"infisical://localhost:8080/{PROJECT}?env=prod&path=/team&tls=false"
));
assert_eq!(c.endpoint, "http://localhost:8080");
assert_eq!(c.environment.as_deref(), Some("prod"));
assert_eq!(c.path, "/team");
}
#[test]
fn uri_without_a_project_is_rejected() {
let err = InfisicalConfig::try_from(&ProviderUrl::new(
Url::parse("infisical://app.infisical.com").unwrap(),
))
.unwrap_err();
assert!(err.to_string().contains("UUID"), "{err}");
}
#[test]
fn every_profile_gets_its_own_folder() {
let p = provider(&format!("infisical://app.infisical.com/{PROJECT}?env=dev"));
let dev = p.convention_address("myapp", "dev", "API_KEY").unwrap();
let prod = p.convention_address("myapp", "prod", "API_KEY").unwrap();
assert_eq!(dev.item, "/monosecret/myapp/dev/API_KEY");
assert_ne!(dev.item, prod.item);
}
#[test]
fn profile_names_the_environment() {
let p = provider(&format!("infisical://app.infisical.com/{PROJECT}"));
let loc = p
.locate(Address::convention("myapp", "prod", "API_KEY"))
.unwrap();
assert_eq!(loc.environment, "prod");
assert_eq!(loc.secret_path, "/monosecret/myapp/prod");
assert_eq!(loc.key, "API_KEY");
let pinned = provider(&format!("infisical://app.infisical.com/{PROJECT}?env=dev"));
let loc = pinned
.locate(Address::convention("myapp", "prod", "API_KEY"))
.unwrap();
assert_eq!(loc.environment, "dev");
assert_eq!(loc.secret_path, "/monosecret/myapp/prod");
}
#[test]
fn keys_are_stored_verbatim() {
let p = provider(&format!("infisical://app.infisical.com/{PROJECT}"));
for key in ["lower_case", "SECTION__KEY", "with.dot", "with-dash"] {
let addr = p.convention_address("myapp", "dev", key).unwrap();
assert_eq!(addr.item, format!("/monosecret/myapp/dev/{key}"));
}
}
#[test]
fn unspellable_folder_names_are_refused() {
let p = provider(&format!("infisical://app.infisical.com/{PROJECT}"));
let err = p.convention_address("my.app", "dev", "KEY").unwrap_err();
assert!(
err.to_string().contains("cannot name an Infisical folder"),
"{err}"
);
let err = p
.convention_address("myapp", "my.profile", "KEY")
.unwrap_err();
assert!(
err.to_string().contains("cannot name an Infisical folder"),
"{err}"
);
}
#[test]
fn native_address_rejects_field() {
let p = provider(&format!("infisical://app.infisical.com/{PROJECT}?env=dev"));
let addr = NativeAddress {
item: "/infra/DB_PASSWORD".into(),
field: Some("password".into()),
..Default::default()
};
let err = p.get(Address::Native(&addr)).unwrap_err();
assert!(err.to_string().contains("`field`"), "{err}");
}
#[test]
fn native_address_needs_an_environment() {
let p = provider(&format!("infisical://app.infisical.com/{PROJECT}"));
let addr = NativeAddress {
item: "/infra/DB_PASSWORD".into(),
..Default::default()
};
let err = p.get(Address::Native(&addr)).unwrap_err();
assert!(err.to_string().contains("?env="), "{err}");
}
#[test]
fn native_address_splits_folder_from_key() {
let p = provider(&format!("infisical://app.infisical.com/{PROJECT}?env=dev"));
let addr = NativeAddress {
item: "/infra/shared/DB_PASSWORD".into(),
..Default::default()
};
let loc = p.locate(Address::Native(&addr)).unwrap();
assert_eq!(loc.secret_path, "/infra/shared");
assert_eq!(loc.key, "DB_PASSWORD");
}
#[test]
fn versioned_refs_are_read_only() {
let p = provider(&format!("infisical://app.infisical.com/{PROJECT}?env=dev"));
let addr = NativeAddress {
item: "/infra/DB_PASSWORD".into(),
version: Some("3".into()),
..Default::default()
};
let refusal = p.check_writable(Address::Native(&addr)).unwrap_err();
assert!(refusal.to_string().contains("read-only"), "{refusal}");
let err = p
.set(Address::Native(&addr), &SecretString::new("v".into()))
.unwrap_err();
assert_eq!(err.to_string(), refusal.to_string());
}
#[test]
fn a_withheld_value_is_refused() {
let hidden = serde_json::json!({
"secretKey": "API_KEY",
"secretValue": "<hidden-by-infisical>",
"secretValueHidden": true,
});
let err = InfisicalProvider::secret_value(&hidden, "API_KEY").unwrap_err();
assert!(err.to_string().contains("withheld"), "{err}");
assert!(err.to_string().contains("API_KEY"), "{err}");
}
#[test]
fn a_readable_value_is_returned() {
let visible = serde_json::json!({
"secretKey": "API_KEY",
"secretValue": "s3cret",
"secretValueHidden": false,
});
let value = InfisicalProvider::secret_value(&visible, "API_KEY")
.unwrap()
.expect("a readable value");
assert_eq!(value.expose_secret(), "s3cret");
}
fn import(path: &str, key: &str, value: &str) -> serde_json::Value {
serde_json::json!({
"secretPath": path,
"environment": "prod",
"secrets": [{
"secretKey": key,
"secretValue": value,
"secretValueHidden": false,
}],
})
}
fn merged(parsed: &serde_json::Value, direct: &[(&str, &str)]) -> HashMap<String, String> {
let mut listed: HashMap<String, SecretString> = direct
.iter()
.map(|(k, v)| (k.to_string(), SecretString::new((*v).into())))
.collect();
InfisicalProvider::merge_imports(parsed, &mut listed).expect("merge");
listed
.into_iter()
.map(|(k, v)| (k, v.expose_secret().to_string()))
.collect()
}
#[test]
fn imported_secrets_are_merged_into_a_listing() {
let parsed = serde_json::json!({
"secrets": [],
"imports": [import("/shared", "DB_HOST", "db.internal")],
});
let merged = merged(&parsed, &[]);
assert_eq!(
merged.get("DB_HOST").map(String::as_str),
Some("db.internal")
);
}
#[test]
fn a_direct_secret_beats_an_import() {
let parsed = serde_json::json!({
"secrets": [],
"imports": [import("/shared", "DB_HOST", "imported")],
});
let merged = merged(&parsed, &[("DB_HOST", "direct")]);
assert_eq!(merged.get("DB_HOST").map(String::as_str), Some("direct"));
}
#[test]
fn a_later_import_beats_an_earlier_one() {
let parsed = serde_json::json!({
"secrets": [],
"imports": [
import("/base", "DB_HOST", "from-base"),
import("/override", "DB_HOST", "from-override"),
],
});
let merged = merged(&parsed, &[]);
assert_eq!(
merged.get("DB_HOST").map(String::as_str),
Some("from-override"),
);
}
#[test]
fn a_withheld_imported_value_is_refused() {
let parsed = serde_json::json!({
"secrets": [],
"imports": [serde_json::json!({
"secretPath": "/shared",
"environment": "prod",
"secrets": [{
"secretKey": "DB_HOST",
"secretValue": "<hidden-by-infisical>",
"secretValueHidden": true,
}],
})],
});
let mut listed = HashMap::new();
let err = InfisicalProvider::merge_imports(&parsed, &mut listed)
.expect_err("a withheld import must not pass through");
assert!(err.to_string().contains("DB_HOST"), "{err}");
}
#[test]
fn a_listing_without_imports_is_unchanged() {
let parsed = serde_json::json!({ "secrets": [] });
let merged = merged(&parsed, &[("API_KEY", "kept")]);
assert_eq!(merged.len(), 1);
assert_eq!(merged.get("API_KEY").map(String::as_str), Some("kept"));
}
#[test]
fn a_write_held_for_approval_is_not_a_write() {
let held = serde_json::json!({
"approval": { "id": "8f2c", "status": "open", "hasMerged": false },
});
let err = InfisicalProvider::written(&held.to_string(), "API_KEY").unwrap_err();
assert!(err.to_string().contains("approval policy"), "{err}");
assert!(err.to_string().contains("API_KEY"), "{err}");
}
#[test]
fn an_ordinary_write_reports_success() {
let stored = serde_json::json!({
"secret": { "id": "1a2b", "secretKey": "API_KEY", "version": 1 },
});
InfisicalProvider::written(&stored.to_string(), "API_KEY")
.expect("a stored secret is not an approval");
InfisicalProvider::written("", "API_KEY").expect("an unparseable 200 stays a success");
}
#[test]
fn uri_round_trips() {
for spec in [
format!("infisical://app.infisical.com/{PROJECT}"),
format!("infisical://app.infisical.com/{PROJECT}?env=prod"),
format!("infisical://app.infisical.com/{PROJECT}?env=prod&path=/team"),
format!("infisical://localhost:8080/{PROJECT}?tls=false"),
format!("infisical://localhost:8080/{PROJECT}?env=dev&tls=false"),
] {
let rendered = provider(&spec).uri();
assert_eq!(rendered, spec, "uri() must render what parsing read");
assert_eq!(
config(&rendered).endpoint,
config(&spec).endpoint,
"re-reading uri() must reach the same instance"
);
}
}
#[test]
fn unreadable_tls_is_rejected() {
let err = InfisicalConfig::try_from(&ProviderUrl::new(
Url::parse(&format!("infisical://host/{PROJECT}?tls=banana")).unwrap(),
))
.unwrap_err();
assert!(err.to_string().contains("tls value 'banana'"), "{err}");
}
#[test]
fn a_ref_without_env_reads_the_profile_environment() {
let p = provider(&format!("infisical://app.infisical.com/{PROJECT}"));
p.set_profile("prod");
let addr = NativeAddress {
item: "/DB_PASSWORD".into(),
..Default::default()
};
let loc = p.locate(Address::Native(&addr)).unwrap();
assert_eq!(loc.environment, "prod");
assert_eq!(loc.secret_path, "/");
assert_eq!(loc.key, "DB_PASSWORD");
let conv = p
.locate(Address::convention("myapp", "prod", "DB_PASSWORD"))
.unwrap();
assert_eq!(conv.environment, loc.environment);
}
#[test]
fn an_explicit_env_outranks_the_profile() {
let p = provider(&format!(
"infisical://app.infisical.com/{PROJECT}?env=shared"
));
p.set_profile("prod");
let addr = NativeAddress {
item: "/DB_PASSWORD".into(),
..Default::default()
};
assert_eq!(
p.locate(Address::Native(&addr)).unwrap().environment,
"shared"
);
assert_eq!(
p.locate(Address::convention("myapp", "prod", "DB_PASSWORD"))
.unwrap()
.environment,
"shared"
);
}
#[test]
fn a_ref_without_env_or_profile_still_explains_itself() {
let addr = NativeAddress {
item: "/DB_PASSWORD".into(),
..Default::default()
};
for profile in [None, Some(""), Some(" ")] {
let p = provider(&format!("infisical://app.infisical.com/{PROJECT}"));
if let Some(profile) = profile {
p.set_profile(profile);
}
let err = p.locate(Address::Native(&addr)).unwrap_err().to_string();
assert!(
err.contains("No Infisical environment for this ref"),
"{err}"
);
}
}
#[test]
fn the_profile_does_not_change_the_uri() {
let p = provider(&format!("infisical://app.infisical.com/{PROJECT}"));
let before = p.uri();
p.set_profile("prod");
assert_eq!(p.uri(), before);
assert_eq!(p.storage_identity(), before);
}
#[test]
fn entry_identity_resolves_the_profile_environment() {
let implicit = provider(&format!("infisical://app.infisical.com/{PROJECT}"));
implicit.set_profile("prod");
let pinned_prod = provider(&format!("infisical://app.infisical.com/{PROJECT}?env=prod"));
let pinned_dev = provider(&format!("infisical://app.infisical.com/{PROJECT}?env=dev"));
let addr = NativeAddress {
item: "/DB_PASSWORD".into(),
..Default::default()
};
assert_ne!(implicit.storage_identity(), pinned_prod.storage_identity());
assert!(
implicit
.same_entries(Address::Native(&addr), &pinned_prod, Address::Native(&addr),)
.unwrap(),
"profile prod and ?env=prod reach the same Infisical secret"
);
assert!(
!implicit
.same_entries(Address::Native(&addr), &pinned_dev, Address::Native(&addr),)
.unwrap(),
"different Infisical environments must remain distinct"
);
}
#[test]
fn entry_identity_uses_the_resolved_absolute_path() {
let left = provider(&format!(
"infisical://app.infisical.com/{PROJECT}?env=prod&path=/left"
));
let right = provider(&format!(
"infisical://app.infisical.com/{PROJECT}?env=prod&path=/right"
));
let addr = NativeAddress {
item: "/shared/DB_PASSWORD".into(),
..Default::default()
};
assert!(
left.same_entries(Address::Native(&addr), &right, Address::Native(&addr),)
.unwrap(),
"absolute refs that resolve to the same API path are one entry"
);
}
#[test]
fn entry_identity_resolves_relative_refs_under_the_configured_path() {
let left = provider(&format!(
"infisical://app.infisical.com/{PROJECT}?env=prod&path=/left"
));
let right = provider(&format!(
"infisical://app.infisical.com/{PROJECT}?env=prod&path=/right"
));
let addr = NativeAddress {
item: "shared/DB_PASSWORD".into(),
..Default::default()
};
assert!(
!left
.same_entries(Address::Native(&addr), &right, Address::Native(&addr),)
.unwrap(),
"relative refs under different configured paths are distinct entries"
);
}
#[test]
fn set_profile_reaches_the_provider_through_arc() {
let p = Arc::new(provider(&format!(
"infisical://app.infisical.com/{PROJECT}"
)));
Provider::set_profile(&p, "prod");
let addr = NativeAddress {
item: "/DB_PASSWORD".into(),
..Default::default()
};
assert_eq!(
p.locate(Address::Native(&addr)).unwrap().environment,
"prod"
);
}
#[test]
fn a_root_ref_stays_at_the_root() {
let p = provider(&format!("infisical://app.infisical.com/{PROJECT}?env=dev"));
let root = NativeAddress {
item: "/DB_PASSWORD".into(),
..Default::default()
};
let loc = p.locate(Address::Native(&root)).unwrap();
assert_eq!(loc.secret_path, "/");
assert_eq!(loc.key, "DB_PASSWORD");
let bare = NativeAddress {
item: "DB_PASSWORD".into(),
..Default::default()
};
let loc = p.locate(Address::Native(&bare)).unwrap();
assert_eq!(loc.secret_path, DEFAULT_PATH);
assert_eq!(loc.key, "DB_PASSWORD");
}
#[test]
fn a_relative_ref_is_read_under_the_prefix() {
let p = provider(&format!(
"infisical://app.infisical.com/{PROJECT}?env=dev&path=/myapp"
));
let relative = NativeAddress {
item: "team/DB_PASSWORD".into(),
..Default::default()
};
let loc = p.locate(Address::Native(&relative)).unwrap();
assert_eq!(loc.secret_path, "/myapp/team");
let absolute = NativeAddress {
item: "/team/DB_PASSWORD".into(),
..Default::default()
};
let loc = p.locate(Address::Native(&absolute)).unwrap();
assert_eq!(loc.secret_path, "/team");
let rooted = provider(&format!(
"infisical://app.infisical.com/{PROJECT}?env=dev&path=/"
));
let loc = rooted.locate(Address::Native(&relative)).unwrap();
assert_eq!(loc.secret_path, "/team");
}
#[test]
fn a_domain_may_carry_the_api_suffix() {
let var = INFISICAL_DOMAIN_ENVS[0];
assert_eq!(
InfisicalConfig::endpoint_from_domain(var, "https://vault.example.com/api", "https")
.unwrap(),
"https://vault.example.com"
);
assert_eq!(
InfisicalConfig::endpoint_from_domain(var, "vault.example.com:8080", "https").unwrap(),
"https://vault.example.com:8080"
);
let err =
InfisicalConfig::endpoint_from_domain(var, "https://example.com/infisical", "https")
.unwrap_err();
assert!(err.to_string().contains("names a path"), "{err}");
}
#[test]
fn an_invalid_domain_names_the_variable_that_set_it() {
let err =
InfisicalConfig::endpoint_from_domain("INFISICAL_API_URL", "https://e.com/x", "https")
.unwrap_err();
assert!(err.to_string().contains("INFISICAL_API_URL"), "{err}");
}
#[test]
fn the_legacy_domain_variable_is_honoured() {
let picked = InfisicalConfig::pick_domain(|var| {
(var == "INFISICAL_API_URL").then(|| "https://eu.infisical.com".to_string())
});
assert_eq!(
picked,
Some(("INFISICAL_API_URL", "https://eu.infisical.com".to_string()))
);
}
#[test]
fn the_current_domain_variable_supersedes_the_legacy_one() {
let picked = InfisicalConfig::pick_domain(|var| {
Some(
match var {
"INFISICAL_DOMAIN" => "https://current.example.com",
_ => "https://legacy.example.com",
}
.to_string(),
)
});
assert_eq!(
picked,
Some((
"INFISICAL_DOMAIN",
"https://current.example.com".to_string()
))
);
}
#[test]
fn a_blank_domain_variable_falls_through() {
let picked = InfisicalConfig::pick_domain(|var| {
Some(
match var {
"INFISICAL_DOMAIN" => " ",
_ => "https://legacy.example.com",
}
.to_string(),
)
});
assert_eq!(
picked,
Some((
"INFISICAL_API_URL",
"https://legacy.example.com".to_string()
))
);
}
#[test]
fn no_domain_variable_is_not_an_error() {
assert_eq!(InfisicalConfig::pick_domain(|_| None), None);
}
}