Skip to content
Merged

Dev #304

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/cli/config_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1284,6 +1284,13 @@ pub struct FieldPolicy {
pub length: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_length: Option<usize>,
/// Longest value accepted, in characters.
#[serde(skip_serializing_if = "Option::is_none")]
pub max_length: Option<usize>,
/// Regular expression the whole value must match, checked by the platform
/// (blog form and User Service) - e.g. `^sk-[A-Za-z0-9_-]+$`.
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub values: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -1317,6 +1324,10 @@ struct RawFieldPolicy {
#[serde(default)]
min_length: Option<usize>,
#[serde(default)]
max_length: Option<usize>,
#[serde(default)]
pattern: Option<String>,
#[serde(default)]
values: Vec<String>,
#[serde(default)]
signing_key: Option<String>,
Expand Down Expand Up @@ -1353,13 +1364,45 @@ impl<'de> Deserialize<'de> for FieldPolicy {
));
}

// Limits nothing could satisfy: refuse them here rather than let every
// buyer value be rejected (or a generated one fail its own check).
let too_small = |what: &str, low: usize, high: usize| {
Err(serde::de::Error::custom(format!(
"field policy {what} ({low}) is larger than the length allowed ({high})"
)))
};
if let (Some(min), Some(len)) = (raw.min_length, raw.length) {
if min > len {
return too_small("min_length", min, len);
}
}
if let (Some(min), Some(max)) = (raw.min_length, raw.max_length) {
if min > max {
return too_small("min_length", min, max);
}
}
if let (Some(len), Some(max)) = (raw.length, raw.max_length) {
if len > max {
return too_small("length", len, max);
}
}
if let Some(pattern) = &raw.pattern {
if let Err(err) = regex::Regex::new(pattern) {
return Err(serde::de::Error::custom(format!(
"field policy pattern {pattern:?} does not compile: {err}"
)));
}
}

Ok(FieldPolicy {
mutability: raw.mutability,
required: raw.required,
type_spec: raw.type_spec,
display: raw.display,
length: raw.length,
min_length: raw.min_length,
max_length: raw.max_length,
pattern: raw.pattern,
values: raw.values,
signing_key: raw.signing_key,
claims: raw.claims,
Expand All @@ -1379,6 +1422,8 @@ impl FieldPolicy {
display: None,
length: None,
min_length: None,
max_length: None,
pattern: None,
values: Vec::new(),
signing_key: None,
claims: None,
Expand All @@ -1397,6 +1442,8 @@ impl FieldPolicy {
display: None,
length: None,
min_length: Some(32),
max_length: None,
pattern: None,
values: Vec::new(),
signing_key: None,
claims: None,
Expand All @@ -1410,6 +1457,8 @@ impl FieldPolicy {
&& self.display.is_none()
&& self.length.is_none()
&& self.min_length.is_none()
&& self.max_length.is_none()
&& self.pattern.is_none()
&& self.values.is_empty()
&& self.signing_key.is_none()
&& self.claims.is_none()
Expand All @@ -1422,6 +1471,8 @@ impl FieldPolicy {
&& self.type_spec == Some(FieldType::Alphanumeric)
&& self.length.is_none()
&& self.min_length == Some(32)
&& self.max_length.is_none()
&& self.pattern.is_none()
&& self.values.is_empty()
&& self.signing_key.is_none()
&& self.claims.is_none()
Expand Down
11 changes: 10 additions & 1 deletion src/console/commands/cli/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2001,7 +2001,8 @@ pub fn generator_shell_expression(
Some(format!("openssl rand -base64 {bytes} | tr -d '\\n'"))
}
Some(FieldType::Alphanumeric) | None => {
let chars = policy.min_length.unwrap_or(32);
// `length` is the exact size; `min_length` alone gives that many.
let chars = policy.length.or(policy.min_length).unwrap_or(32);
Some(format!(
"LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c {chars}"
))
Expand Down Expand Up @@ -2942,6 +2943,8 @@ mod tests {
display: None,
length: Some(32),
min_length: None,
max_length: None,
pattern: None,
values: Vec::new(),
signing_key: None,
claims: None,
Expand Down Expand Up @@ -2969,6 +2972,8 @@ mod tests {
display: None,
length: None,
min_length: Some(20),
max_length: None,
pattern: None,
values: Vec::new(),
signing_key: None,
claims: None,
Expand All @@ -2995,6 +3000,8 @@ mod tests {
display: None,
length: None,
min_length: None,
max_length: None,
pattern: None,
values: Vec::new(),
signing_key: None,
claims: None,
Expand All @@ -3021,6 +3028,8 @@ mod tests {
display: None,
length: None,
min_length: None,
max_length: None,
pattern: None,
values: Vec::new(),
signing_key: Some("auth.JWT_SECRET".to_string()),
claims: Some(serde_json::json!({"role": "anon"})),
Expand Down
59 changes: 59 additions & 0 deletions tests/config_contract_pattern.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//! config_contract limits the platform enforces on Quick Deploy values:
//! `pattern` and `max_length`, and `length` meaning "exactly" everywhere.
//!
//! prod 2026-10-19: a buyer typed 4 characters into SUPABASE_SECRET_KEY /
//! OPENAI_API_KEY and nothing could refuse it - the contract had no way to
//! say what a key looks like. And the alphanumeric generator read min_length
//! as the exact size while ignoring length.
//! Scenarios: config shared-fixtures/features/quick-deploy-env-field-rules.feature

use serde_json::json;
use stacker::cli::config_parser::FieldPolicy;
use stacker::console::commands::cli::init::generator_shell_expression;

fn policy(value: serde_json::Value) -> Result<FieldPolicy, serde_json::Error> {
serde_json::from_value(value)
}

#[test]
fn pattern_and_max_length_are_kept() {
let p = policy(json!({
"mutability": "provided", "required": false,
"pattern": "^sk-[A-Za-z0-9_-]+$", "min_length": 40, "max_length": 256
}))
.expect("a provided field with pattern and max_length parses");
assert_eq!(p.pattern.as_deref(), Some("^sk-[A-Za-z0-9_-]+$"));
assert_eq!(p.max_length, Some(256));
assert_eq!(p.min_length, Some(40));
}

#[test]
fn a_contract_nothing_could_satisfy_is_refused() {
for (name, value) in [
("pattern that does not compile", json!({"mutability": "provided", "pattern": "^(a"})),
(
"min_length over length",
json!({"mutability": "generated", "type": "alphanumeric", "length": 16, "min_length": 32}),
),
("min_length over max_length", json!({"mutability": "provided", "min_length": 300, "max_length": 256})),
("length over max_length", json!({"mutability": "provided", "length": 300, "max_length": 256})),
] {
assert!(policy(value).is_err(), "{name} must be refused");
}
}

#[test]
fn an_alphanumeric_length_is_generated_exactly() {
let p = policy(json!({"mutability": "generated", "type": "alphanumeric", "length": 16}))
.expect("parses");
let expr = generator_shell_expression(&p).expect("alphanumeric is generated");
assert!(expr.ends_with("head -c 16"), "{expr}");
}

#[test]
fn an_alphanumeric_min_length_alone_still_gives_that_many() {
let p = policy(json!({"mutability": "generated", "type": "alphanumeric", "min_length": 32}))
.expect("parses");
let expr = generator_shell_expression(&p).expect("alphanumeric is generated");
assert!(expr.ends_with("head -c 32"), "{expr}");
}
30 changes: 28 additions & 2 deletions tests/contracts/marketplace-field-policy.contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,22 @@
"length": {
"type": "integer",
"minimum": 1,
"description": "Exact byte/char length. Used by hex/base64/uuid-adjacent fixed-length generators."
"description": "Exact length in characters (bytes for base64). Generators produce exactly this many; the platform refuses any other length."
},
"min_length": {
"type": "integer",
"minimum": 1,
"description": "Minimum length. Used by alphanumeric generators and editable string constraints."
"description": "Minimum length in characters (at least). An exact size is declared with length."
},
"max_length": {
"type": "integer",
"minimum": 1,
"description": "Maximum length in characters."
},
"pattern": {
"type": "string",
"minLength": 1,
"description": "Regular expression the whole value must match (anchor with ^ and $). Common subset of Rust regex, Python re and JavaScript RegExp: no lookaround, no backreferences. Checked by the platform (blog form and User Service /deploy/start), e.g. \"^sk-[A-Za-z0-9_-]+$\" for an OpenAI key."
},
"values": {
"type": "array",
Expand Down Expand Up @@ -120,6 +130,10 @@
"claims": { "role": "anon", "iss": "supabase" },
"alg": "HS256"
}
},
{
"OPENAI_API_KEY": { "mutability": "provided", "required": false, "pattern": "^sk-[A-Za-z0-9_-]+$", "min_length": 40, "max_length": 256 },
"REALTIME_DB_ENC_KEY": { "mutability": "generated", "type": "alphanumeric", "length": 16 }
}
],
"invalidExamples": [
Expand Down Expand Up @@ -149,6 +163,18 @@
"mutability": "generated"
}
}
},
{
"reason": "pattern does not compile",
"fields": { "BAD_PATTERN": { "mutability": "provided", "pattern": "^(a" } }
},
{
"reason": "min_length over length",
"fields": { "MIN_OVER_LENGTH": { "mutability": "generated", "type": "alphanumeric", "length": 16, "min_length": 32 } }
},
{
"reason": "min_length over max_length",
"fields": { "MIN_OVER_MAX": { "mutability": "provided", "min_length": 300, "max_length": 256 } }
}
]
}
Loading