use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::error::SailError;
const MAX_SECRET_NAME_LEN: usize = 128;
const MAX_SECRET_VALUE_BYTES: usize = 64 * 1024;
const MAX_POLICY_RULES: usize = 100;
const MAX_POLICY_NAME_CHARS: usize = 128;
const MAX_RULE_VALUE_BYTES: usize = 512;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SecretInfo {
pub name: String,
#[serde(with = "crate::rfc3339_micros")]
pub created_at: OffsetDateTime,
#[serde(with = "crate::rfc3339_micros")]
pub updated_at: OffsetDateTime,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InjectionTargetKind {
Header,
QueryParam,
Other(String),
}
impl InjectionTargetKind {
pub fn as_str(&self) -> &str {
match self {
InjectionTargetKind::Header => "header",
InjectionTargetKind::QueryParam => "query_param",
InjectionTargetKind::Other(s) => s,
}
}
}
impl From<&str> for InjectionTargetKind {
fn from(s: &str) -> InjectionTargetKind {
match s {
"header" => InjectionTargetKind::Header,
"query_param" => InjectionTargetKind::QueryParam,
other => InjectionTargetKind::Other(other.to_string()),
}
}
}
impl std::fmt::Display for InjectionTargetKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for InjectionTargetKind {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for InjectionTargetKind {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<InjectionTargetKind, D::Error> {
Ok(InjectionTargetKind::from(
String::deserialize(deserializer)?.as_str(),
))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InjectionTarget {
#[serde(rename = "type")]
pub kind: InjectionTargetKind,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InjectionRule {
pub host: String,
pub target: InjectionTarget,
pub value: String,
}
impl InjectionRule {
pub fn header(
host: impl Into<String>,
name: impl Into<String>,
value: impl Into<String>,
) -> InjectionRule {
InjectionRule {
host: host.into(),
target: InjectionTarget {
kind: InjectionTargetKind::Header,
name: name.into(),
},
value: value.into(),
}
}
pub fn query_param(
host: impl Into<String>,
name: impl Into<String>,
value: impl Into<String>,
) -> InjectionRule {
InjectionRule {
host: host.into(),
target: InjectionTarget {
kind: InjectionTargetKind::QueryParam,
name: name.into(),
},
value: value.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CredentialInjectionPolicyInfo {
pub id: String,
pub name: String,
pub rules: Vec<InjectionRule>,
#[serde(with = "crate::rfc3339_micros")]
pub created_at: OffsetDateTime,
#[serde(with = "crate::rfc3339_micros")]
pub updated_at: OffsetDateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CredentialInjectionPolicySummary {
pub id: String,
pub name: String,
pub rule_count: i64,
pub referenced_secret_names: Vec<String>,
pub attachment_count: i64,
#[serde(with = "crate::rfc3339_micros")]
pub created_at: OffsetDateTime,
#[serde(with = "crate::rfc3339_micros")]
pub updated_at: OffsetDateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CredentialInjectionPolicyPage {
#[serde(rename = "data")]
pub items: Vec<CredentialInjectionPolicySummary>,
pub limit: i64,
pub offset: i64,
pub total: i64,
pub has_more: bool,
}
#[derive(Debug, Clone)]
pub struct ListCredentialInjectionPoliciesQuery {
pub search: Option<String>,
pub limit: i64,
pub offset: i64,
}
impl Default for ListCredentialInjectionPoliciesQuery {
fn default() -> Self {
ListCredentialInjectionPoliciesQuery {
search: None,
limit: crate::sailbox::types::DEFAULT_LIST_LIMIT,
offset: 0,
}
}
}
#[doc(hidden)]
pub fn validate_secret_name(name: &str) -> Result<(), SailError> {
let mut chars = name.chars();
let valid_first = chars.next().is_some_and(|c| c.is_ascii_alphanumeric());
let valid_rest = chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if !valid_first || !valid_rest || name.len() > MAX_SECRET_NAME_LEN {
return Err(SailError::InvalidArgument {
message: "secret name must start with a letter or number and use only letters, \
numbers, underscores, and dashes (at most 128 characters)"
.to_string(),
});
}
Ok(())
}
pub(crate) fn validate_secret_value(value: &str) -> Result<(), SailError> {
if value.is_empty() {
return Err(SailError::InvalidArgument {
message: "secret value must not be empty".to_string(),
});
}
if value.len() > MAX_SECRET_VALUE_BYTES {
return Err(SailError::InvalidArgument {
message: "secret value must be at most 64 KiB".to_string(),
});
}
if value.bytes().any(|b| b < 0x20 || b == 0x7f) {
return Err(SailError::InvalidArgument {
message: "secret value must not contain ASCII control characters".to_string(),
});
}
Ok(())
}
pub(crate) fn validate_policy_name(name: &str) -> Result<(), SailError> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(SailError::InvalidArgument {
message: "policy name must not be empty".to_string(),
});
}
if trimmed.chars().count() > MAX_POLICY_NAME_CHARS {
return Err(SailError::InvalidArgument {
message: format!("policy name must be at most {MAX_POLICY_NAME_CHARS} characters"),
});
}
if trimmed.chars().any(char::is_control) {
return Err(SailError::InvalidArgument {
message: "policy name must not contain control characters".to_string(),
});
}
Ok(())
}
pub(crate) fn validate_rules(rules: &[InjectionRule]) -> Result<(), SailError> {
let invalid = |message: String| Err(SailError::InvalidArgument { message });
if rules.is_empty() {
return invalid("rules must contain at least one rule".to_string());
}
if rules.len() > MAX_POLICY_RULES {
return invalid(format!(
"rules must contain at most {MAX_POLICY_RULES} items"
));
}
for (i, rule) in rules.iter().enumerate() {
if rule.host.trim().is_empty() {
return invalid(format!("rules[{i}]: host is required"));
}
if matches!(rule.target.kind, InjectionTargetKind::Other(_)) {
return invalid(format!(
"rules[{i}]: target kind must be header or query_param"
));
}
if rule.target.name.trim().is_empty() {
return invalid(format!("rules[{i}]: target name is required"));
}
if rule.value.len() > MAX_RULE_VALUE_BYTES {
return invalid(format!(
"rules[{i}]: value must be at most {MAX_RULE_VALUE_BYTES} bytes"
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn rule_serializes_to_the_wire_shape() {
let rule = InjectionRule::header("api.github.com", "Authorization", "Bearer ${secrets.T}");
assert_eq!(
serde_json::to_value(&rule).unwrap(),
json!({
"host": "api.github.com",
"target": {"type": "header", "name": "Authorization"},
"value": "Bearer ${secrets.T}",
})
);
let query = InjectionRule::query_param("maps.example.com", "key", "${secrets.K}");
assert_eq!(
serde_json::to_value(&query).unwrap()["target"]["type"],
json!("query_param")
);
}
#[test]
fn unknown_target_kind_round_trips() {
let wire = json!({
"host": "api.example.com",
"target": {"type": "path_segment", "name": "token"},
"value": "v",
});
let rule: InjectionRule = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(
rule.target.kind,
InjectionTargetKind::Other("path_segment".to_string())
);
assert_eq!(serde_json::to_value(&rule).unwrap(), wire);
}
#[test]
fn secret_name_rule_matches_the_service() {
for valid in ["A", "GITHUB_TOKEN", "0token-x_y", &"a".repeat(128)] {
assert!(validate_secret_name(valid).is_ok(), "{valid:?}");
}
for invalid in [
"",
"_leading",
"-leading",
"has space",
"has.dot",
&"a".repeat(129),
] {
assert!(validate_secret_name(invalid).is_err(), "{invalid:?}");
}
}
#[test]
fn secret_value_rule_matches_the_service() {
assert!(validate_secret_value("ok value").is_ok());
assert!(validate_secret_value("").is_err());
assert!(validate_secret_value("has\nnewline").is_err());
assert!(validate_secret_value(&"v".repeat(64 * 1024 + 1)).is_err());
}
#[test]
fn rules_validation_rejects_the_obvious_failures() {
let good = InjectionRule::header("h.example.com", "X-Key", "v");
assert!(validate_rules(std::slice::from_ref(&good)).is_ok());
assert!(validate_rules(&[]).is_err());
assert!(validate_rules(&vec![good.clone(); 101]).is_err());
assert!(validate_rules(&[InjectionRule::header("", "X-Key", "v")]).is_err());
assert!(validate_rules(&[InjectionRule::header("h.example.com", "", "v")]).is_err());
assert!(validate_rules(&[InjectionRule::header(
"h.example.com",
"X-Key",
"v".repeat(513)
)])
.is_err());
let unknown = InjectionRule {
host: "h.example.com".to_string(),
target: InjectionTarget {
kind: InjectionTargetKind::Other("path_segment".to_string()),
name: "token".to_string(),
},
value: "v".to_string(),
};
assert!(validate_rules(&[unknown]).is_err());
}
#[test]
fn policy_page_decodes_the_wire_envelope() {
let page: CredentialInjectionPolicyPage = serde_json::from_value(json!({
"data": [{
"id": "cip_1",
"name": "github",
"rule_count": 2,
"referenced_secret_names": ["GITHUB_TOKEN"],
"attachment_count": 1,
"created_at": "2026-07-01T00:00:00.123456789Z",
"updated_at": "2026-07-02T00:00:00Z",
}],
"limit": 50,
"offset": 0,
"total": 1,
"has_more": false,
}))
.unwrap();
assert_eq!(page.items.len(), 1);
assert_eq!(page.items[0].referenced_secret_names, ["GITHUB_TOKEN"]);
assert!(!page.has_more);
}
}