use crate::error::Result;
use crate::types::Scope;
use crate::uri::{SECRET_STORE_SCHEME, SecretUri, normalize_team};
use core::fmt;
use core::str::FromStr;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const SECRET_SCHEME: &str = "secret://";
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
pub struct SecretRef(String);
impl SecretRef {
pub fn try_new(raw: impl Into<String>) -> core::result::Result<Self, SecretRefParseError> {
let raw = raw.into();
if !raw.starts_with(SECRET_SCHEME) {
return Err(SecretRefParseError::MissingScheme);
}
if raw.len() == SECRET_SCHEME.len() {
return Err(SecretRefParseError::EmptyPath);
}
if env_segment_of(&raw).is_empty() {
return Err(SecretRefParseError::EmptyEnvSegment);
}
Ok(Self(raw))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn env_segment(&self) -> &str {
env_segment_of(&self.0)
}
pub fn to_store_uri(&self) -> Result<SecretUri> {
let mut flipped = String::with_capacity(self.0.len() + 1);
flipped.push_str(SECRET_STORE_SCHEME);
flipped.push_str(&self.0[SECRET_SCHEME.len()..]);
let parsed = SecretUri::parse(&flipped)?;
let scope = Scope::new(
parsed.scope().env(),
parsed.scope().tenant(),
normalize_team(parsed.scope().team()),
)?;
let mut uri = SecretUri::new(scope, parsed.category(), parsed.name())?;
if let Some(version) = parsed.version() {
uri = uri.with_version(Some(version))?;
}
Ok(uri)
}
pub fn from_store_uri(uri: &SecretUri) -> core::result::Result<Self, SecretRefParseError> {
let store = uri.to_string();
let body = store.strip_prefix(SECRET_STORE_SCHEME).unwrap_or(&store);
let mut raw = String::with_capacity(SECRET_SCHEME.len() + body.len());
raw.push_str(SECRET_SCHEME);
raw.push_str(body);
Self::try_new(raw)
}
}
fn env_segment_of(raw: &str) -> &str {
let after_scheme = &raw[SECRET_SCHEME.len()..];
match after_scheme.find('/') {
Some(idx) => &after_scheme[..idx],
None => after_scheme,
}
}
impl fmt::Display for SecretRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for SecretRef {
type Err = SecretRefParseError;
fn from_str(s: &str) -> core::result::Result<Self, Self::Err> {
Self::try_new(s)
}
}
impl TryFrom<String> for SecretRef {
type Error = SecretRefParseError;
fn try_from(value: String) -> core::result::Result<Self, Self::Error> {
Self::try_new(value)
}
}
impl From<SecretRef> for String {
fn from(value: SecretRef) -> Self {
value.0
}
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum SecretRefParseError {
#[error("secret-ref must start with `secret://`")]
MissingScheme,
#[error("secret-ref path is empty")]
EmptyPath,
#[error("secret-ref must carry an env segment: `secret://<env>/<path>`")]
EmptyEnvSegment,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_store_aligned_ref() {
let r = SecretRef::try_new("secret://dev/demo/_/messaging-slack/api_key").unwrap();
assert_eq!(r.env_segment(), "dev");
assert_eq!(r.as_str(), "secret://dev/demo/_/messaging-slack/api_key");
}
#[test]
fn rejects_missing_scheme() {
assert_eq!(
SecretRef::try_new("dev/demo").unwrap_err(),
SecretRefParseError::MissingScheme
);
}
#[test]
fn rejects_empty_path() {
assert_eq!(
SecretRef::try_new("secret://").unwrap_err(),
SecretRefParseError::EmptyPath
);
}
#[test]
fn rejects_empty_env_segment() {
assert_eq!(
SecretRef::try_new("secret:///demo/_/c/n").unwrap_err(),
SecretRefParseError::EmptyEnvSegment
);
}
#[test]
fn to_store_uri_flips_scheme_and_normalizes_team() {
let underscore = SecretRef::try_new("secret://dev/demo/_/messaging-slack/api_key")
.unwrap()
.to_store_uri()
.unwrap();
let defaulted = SecretRef::try_new("secret://dev/demo/default/messaging-slack/api_key")
.unwrap()
.to_store_uri()
.unwrap();
assert_eq!(
underscore.to_string(),
"secrets://dev/demo/_/messaging-slack/api_key"
);
assert_eq!(underscore, defaulted);
}
#[test]
fn to_store_uri_preserves_real_team() {
let uri = SecretRef::try_new("secret://dev/demo/legal/configs/url")
.unwrap()
.to_store_uri()
.unwrap();
assert_eq!(uri.to_string(), "secrets://dev/demo/legal/configs/url");
}
#[test]
fn non_store_aligned_ref_errors_rather_than_misconverting() {
assert!(
SecretRef::try_new("secret://dev/my-bundle/my-pack/question")
.unwrap()
.to_store_uri()
.is_err()
);
}
#[test]
fn store_uri_round_trip() {
let store = SecretUri::parse("secrets://dev/demo/_/messaging-slack/api_key").unwrap();
let secret_ref = SecretRef::from_store_uri(&store).unwrap();
assert_eq!(
secret_ref.as_str(),
"secret://dev/demo/_/messaging-slack/api_key"
);
assert_eq!(secret_ref.to_store_uri().unwrap(), store);
}
#[cfg(feature = "serde")]
#[test]
fn serde_round_trips_through_string() {
let r = SecretRef::try_new("secret://dev/demo/_/messaging-slack/api_key").unwrap();
let json = serde_json::to_string(&r).unwrap();
assert_eq!(json, "\"secret://dev/demo/_/messaging-slack/api_key\"");
let back: SecretRef = serde_json::from_str(&json).unwrap();
assert_eq!(back, r);
}
}