diff --git a/src/cli/config_parser.rs b/src/cli/config_parser.rs index 92252191..0fc987ab 100644 --- a/src/cli/config_parser.rs +++ b/src/cli/config_parser.rs @@ -1284,6 +1284,13 @@ pub struct FieldPolicy { pub length: Option, #[serde(skip_serializing_if = "Option::is_none")] pub min_length: Option, + /// Longest value accepted, in characters. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_length: Option, + /// 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, #[serde(skip_serializing_if = "Vec::is_empty")] pub values: Vec, #[serde(skip_serializing_if = "Option::is_none")] @@ -1317,6 +1324,10 @@ struct RawFieldPolicy { #[serde(default)] min_length: Option, #[serde(default)] + max_length: Option, + #[serde(default)] + pattern: Option, + #[serde(default)] values: Vec, #[serde(default)] signing_key: Option, @@ -1353,6 +1364,36 @@ 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, @@ -1360,6 +1401,8 @@ impl<'de> Deserialize<'de> for FieldPolicy { 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, @@ -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, @@ -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, @@ -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() @@ -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() diff --git a/src/console/commands/cli/init.rs b/src/console/commands/cli/init.rs index def07c74..c7423fad 100644 --- a/src/console/commands/cli/init.rs +++ b/src/console/commands/cli/init.rs @@ -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}" )) @@ -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, @@ -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, @@ -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, @@ -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"})), diff --git a/tests/config_contract_pattern.rs b/tests/config_contract_pattern.rs new file mode 100644 index 00000000..ffa95045 --- /dev/null +++ b/tests/config_contract_pattern.rs @@ -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 { + 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}"); +} diff --git a/tests/contracts/marketplace-field-policy.contract.json b/tests/contracts/marketplace-field-policy.contract.json index 332468ec..edee0f53 100644 --- a/tests/contracts/marketplace-field-policy.contract.json +++ b/tests/contracts/marketplace-field-policy.contract.json @@ -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", @@ -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": [ @@ -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 } } } ] }