use super::{Address, Provider, ProviderCredentials, ProviderUrl, credential_or_env};
use crate::config::NativeAddress;
use crate::{Result, SecretSpecError};
use reqwest::StatusCode;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::OnceLock;
const DEFAULT_PATH: &str = "/secretspec";
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| {
SecretSpecError::ProviderOperationFailed(format!("Invalid {var} '{domain}': {e}"))
})?;
if !parsed.path().trim_matches('/').is_empty() {
return Err(SecretSpecError::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 = SecretSpecError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
let scheme = url.scheme();
if scheme != "infisical" {
return Err(SecretSpecError::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(SecretSpecError::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(SecretSpecError::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(SecretSpecError::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,
})
}
}
struct Location {
environment: String,
secret_path: String,
key: String,
}
pub struct InfisicalProvider {
config: InfisicalConfig,
credentials: ProviderCredentials,
token: tokio::sync::OnceCell<SecretString>,
http: OnceLock<reqwest::Client>,
}
crate::register_provider! {
struct: InfisicalProvider,
config: InfisicalConfig,
name: "infisical",
description: "Infisical secret management",
schemes: ["infisical"],
examples: ["infisical://app.infisical.com/{project-id}"],
credential_names: [CLIENT_ID, CLIENT_SECRET, TOKEN],
}
impl InfisicalProvider {
pub fn new(config: InfisicalConfig) -> Self {
Self {
config,
credentials: ProviderCredentials::new(),
token: tokio::sync::OnceCell::new(),
http: OnceLock::new(),
}
}
fn http(&self) -> &reqwest::Client {
self.http.get_or_init(reqwest::Client::new)
}
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(_)) => {
return Err(SecretSpecError::ProviderOperationFailed(
"No Infisical environment for this ref. Name one in the provider URI, \
e.g. infisical://app.infisical.com/{project-id}?env=prod."
.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('/') => {
format!("{}/{relative}", self.config.path.trim_end_matches('/'))
}
absolute => absolute.to_string(),
};
(secret_path, key.to_string())
}
None => (self.config.path.clone(), coords.item.clone()),
};
if key.is_empty() {
return Err(SecretSpecError::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(SecretSpecError::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(SecretSpecError::ProviderOperationFailed(format!(
"Infisical {CLIENT_SECRET} is missing. Configure the {CLIENT_SECRET} \
provider credential, or set {INFISICAL_CLIENT_SECRET_ENV}."
)));
}
(None, Some(_)) => {
return Err(SecretSpecError::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 response = self
.http()
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to connect to Infisical at {}: {e}",
self.config.endpoint
))
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Infisical login returned HTTP {status}: {}",
Self::error_message(&body)
)));
}
let parsed: serde_json::Value = response.json().await.map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to parse Infisical login response: {e}"
))
})?;
let token = parsed["accessToken"].as_str().ok_or_else(|| {
SecretSpecError::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["message"].as_str().map(str::to_string))
.unwrap_or_else(|| body.to_string())
}
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 secret_value(secret: &serde_json::Value, key: &str) -> Result<Option<SecretString>> {
if secret["secretValueHidden"].as_bool() == Some(true) {
return Err(SecretSpecError::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| {
SecretSpecError::ProviderOperationFailed(format!(
"Invalid Infisical endpoint '{}': {e}",
self.config.endpoint
))
})?;
url.path_segments_mut()
.map_err(|_| {
SecretSpecError::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<Option<SecretString>> {
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 = response.text().await.unwrap_or_default();
match status {
StatusCode::OK => {
let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to parse Infisical response: {e}"
))
})?;
Self::secret_value(&parsed["secret"], &loc.key)
}
StatusCode::NOT_FOUND => Ok(None),
_ => 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<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 = response.text().await.unwrap_or_default();
match status {
StatusCode::OK => {
let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to parse Infisical response: {e}"
))
})?;
let secrets = parsed["secrets"].as_array().ok_or_else(|| {
SecretSpecError::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(listed)
}
StatusCode::NOT_FOUND => Ok(HashMap::new()),
_ => Err(self.http_error(status, &body, "listing secrets")),
}
}
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(SecretSpecError::ProviderOperationFailed(format!(
"Infisical has no environment '{}' in project {}. Environments are \
SecretSpec profiles here unless the provider URI pins one with `?env=`.",
loc.environment, self.config.project_id
)));
}
let refused = self.create_folder(loc).await?;
if self.write_secret(reqwest::Method::POST, loc, value).await? {
return Ok(());
}
Err(SecretSpecError::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 = response.text().await.unwrap_or_default();
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(SecretSpecError::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(|| {
SecretSpecError::ProviderOperationFailed(format!(
"Invalid Infisical folder '{}': paths are absolute, e.g. /secretspec.",
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 = response.text().await.unwrap_or_default();
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| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to connect to Infisical at {}: {e}",
self.config.endpoint
))
})
}
fn http_error(&self, status: StatusCode, body: &str, action: &str) -> SecretSpecError {
let message = Self::error_message(body);
match status {
StatusCode::UNAUTHORIZED => SecretSpecError::ProviderOperationFailed(format!(
"Infisical authentication failed (401) while {action}: {message}. \
Check the machine identity's {CLIENT_ID}/{CLIENT_SECRET}."
)),
StatusCode::FORBIDDEN => SecretSpecError::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 => SecretSpecError::ProviderOperationFailed(format!(
"Infisical rate limit exceeded (429) while {action}: {message}."
)),
_ => SecretSpecError::ProviderOperationFailed(format!(
"Infisical returned HTTP {status} while {action}: {message}"
)),
}
}
}
impl Provider for InfisicalProvider {
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(SecretSpecError::ProviderOperationFailed(format!(
"{label} cannot be empty"
)));
}
}
if key.contains('/') {
return Err(SecretSpecError::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(SecretSpecError::ProviderOperationFailed(format!(
"{label} '{value}' cannot name an Infisical folder: only letters, digits, \
dashes and underscores are allowed. Rename it in secretspec.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 with_credentials(&mut self, credentials: ProviderCredentials) {
self.credentials = credentials;
}
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> 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) = &self.config.environment {
query.push(format!("env={}", ProviderUrl::encode_query(env)));
}
if self.config.path != DEFAULT_PATH {
query.push(format!(
"path={}",
ProviderUrl::encode_query(&self.config.path)
));
}
if plain_http {
query.push("tls=false".to_string());
}
if !query.is_empty() {
uri.push('?');
uri.push_str(&query.join("&"));
}
uri
}
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(self.get_async(&loc, version))
}
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 mut resolved = if versioned.is_empty() {
HashMap::new()
} else {
super::get_each(self, &versioned)?
};
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));
}
for ((environment, secret_path), wanted) in folders {
let listed = self.list_async(&environment, &secret_path).await?;
for (name, key) in wanted {
if let Some(value) = listed.get(&key) {
resolved.insert(name.to_string(), value.clone());
}
}
}
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(SecretSpecError::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)]
mod tests {
use super::*;
use url::Url;
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))
}
#[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 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, "/secretspec/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, "/secretspec/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, "/secretspec/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!("/secretspec/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_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);
}
}