hecks 3.11.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driving/cli_runner/resolution.rb +6 -3
  3. data/lib/hecks/adapters/driving/cli_runner.rb +1 -0
  4. data/lib/hecks/adapters/driving/newcomer.rb +50 -0
  5. data/lib/hecks/bluebook/assembly/contracts/read_models.rb +1 -1
  6. data/lib/hecks/bluebook/assembly/marks.rb +3 -15
  7. data/lib/hecks/bluebook/assembly/scalars.rb +39 -0
  8. data/lib/hecks/bluebook/meta_validator/readings.rb +6 -0
  9. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +6 -3
  10. data/lib/hecks/bluebook.rb +1 -0
  11. data/lib/hecks/deploy/bluebook/deploy.bluebook +16 -2
  12. data/lib/hecks/gate/stages.yml +32 -0
  13. data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +5 -1
  14. data/lib/hecks/hecks/hecks.world +3 -0
  15. data/lib/hecks/language/bluebook/bluebook.behaviors +23 -0
  16. data/lib/hecks/language/bluebook/policy.behaviors +19 -0
  17. data/lib/hecks/language/bluebook/vocabulary.bluebook +52 -0
  18. data/lib/hecks/projection_files/expression_tables.rb +7 -2
  19. data/lib/hecks/projections/deploy/box/compose.rb +6 -4
  20. data/lib/hecks/projections/deploy/box/edge.rb +62 -0
  21. data/lib/hecks/projections/deploy/box/logging.rb +99 -0
  22. data/lib/hecks/projections/deploy/box/roll_scripts.rb +16 -8
  23. data/lib/hecks/projections/deploy/box/settings/cdn.rb +40 -0
  24. data/lib/hecks/projections/deploy/box/settings/logs.rb +40 -0
  25. data/lib/hecks/projections/deploy/box/settings/patterns.rb +11 -0
  26. data/lib/hecks/projections/deploy/box/settings/types.rb +16 -1
  27. data/lib/hecks/projections/deploy/box/settings.rb +6 -2
  28. data/lib/hecks/projections/deploy/box/stack_yaml.rb +5 -2
  29. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +16 -0
  30. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +8 -4
  31. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -0
  32. data/lib/hecks/projections/deploy/box/templates/edge.mk.tmpl +17 -0
  33. data/lib/hecks/projections/deploy/box/templates/edge.yaml.tmpl +65 -0
  34. data/lib/hecks/projections/deploy/box/templates/log-group.yaml.tmpl +12 -0
  35. data/lib/hecks/projections/deploy/box/templates/log-policy.yaml.tmpl +7 -0
  36. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +5 -4
  37. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +5 -4
  38. data/lib/hecks/projections/deploy/box.rb +15 -3
  39. data/lib/hecks/projections/site/site_root/workflow.rb +5 -0
  40. data/lib/hecks/projector/cli_audience.rb +20 -0
  41. data/lib/hecks/projector/cli_projector/usage.rb +15 -4
  42. data/lib/hecks/rust_build/host.rb +11 -4
  43. data/lib/hecks/rust_build/prebuilt_binary.rb +101 -0
  44. data/lib/hecks/tools/ci_gate_decision.rb +35 -0
  45. data/lib/hecks/tools/ci_gates.rb +20 -1
  46. data/lib/hecks/tools/lanes/files.rb +8 -0
  47. data/lib/hecks/tools/lanes/prebuilt_workflow.rb +81 -0
  48. data/lib/hecks/tools/lanes.rb +1 -0
  49. data/lib/hecks/tools/release_only.rb +111 -0
  50. data/lib/hecks/version.rb +1 -1
  51. data/lib/hecks/vocabulary.rb +15 -1
  52. data/prebuilt/9ee54e2f0d4d6d3fe046827779b221e02de665fba911a048ae5dd8b8fbfb7d90.marshal +0 -0
  53. data/prebuilt/d74a00e996222c1d8b676d5959092effc46b4dc59e0555f3bf9554ac58e3094a.marshal +0 -0
  54. data/prebuilt/{verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json → verdicts-df19f35868d3284a70f55e42dcef87ef4e65e6a678e5135f72e1f6cff4c1357b.json} +1 -1
  55. data/rust/host/HECKS_RELEASE +1 -1
  56. data/rust/host/src/extension.rs +13 -1
  57. data/rust/host/src/lib.rs +1 -0
  58. data/rust/host/src/platform_signin/attack_tests.rs +1100 -0
  59. data/rust/host/src/platform_signin/config.rs +512 -0
  60. data/rust/host/src/platform_signin/cookie.rs +237 -0
  61. data/rust/host/src/platform_signin/extension.rs +190 -0
  62. data/rust/host/src/platform_signin/fetcher.rs +322 -0
  63. data/rust/host/src/platform_signin/handler.rs +251 -0
  64. data/rust/host/src/platform_signin/handler_tests.rs +554 -0
  65. data/rust/host/src/platform_signin/jti.rs +196 -0
  66. data/rust/host/src/platform_signin/jwks.rs +542 -0
  67. data/rust/host/src/platform_signin/jws.rs +249 -0
  68. data/rust/host/src/platform_signin/known_answer_tests.rs +237 -0
  69. data/rust/host/src/platform_signin/mapping_tests.rs +254 -0
  70. data/rust/host/src/platform_signin/mod.rs +75 -0
  71. data/rust/host/src/platform_signin/mode_tests.rs +382 -0
  72. data/rust/host/src/platform_signin/pages.rs +204 -0
  73. data/rust/host/src/platform_signin/query.rs +233 -0
  74. data/rust/host/src/platform_signin/session.rs +151 -0
  75. data/rust/host/src/platform_signin/settings.rs +385 -0
  76. data/rust/host/src/platform_signin/strict_json.rs +351 -0
  77. data/rust/host/src/platform_signin/test_support.rs +235 -0
  78. data/rust/host/src/platform_signin/ttl_tests.rs +94 -0
  79. data/rust/host/src/platform_signin/verify.rs +604 -0
  80. data/rust/host/src/platform_signin/wiring_support.rs +319 -0
  81. data/rust/host/src/web.rs +50 -6
  82. data/rust/parser/src/parse/value_object.rs +22 -10
  83. metadata +42 -4
  84. data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
  85. data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
@@ -0,0 +1,512 @@
1
+ //! What a site must know to verify a platform sign-in token, validated before anything uses it.
2
+ //!
3
+ //! The values are the issuer's address, the address of its key set, this site's reference, and the
4
+ //! environment this site runs as. A token is accepted only if it names exactly these. Validation
5
+ //! happens when the value is built, so a mistake (an `http` issuer, a trailing slash that would
6
+ //! make
7
+ //! every token fail the exact `iss` comparison, an environment typo) stops the boot rather than
8
+ //! silently locking everyone out or, worse, letting something through.
9
+ //!
10
+ //! Nothing here reads the process environment: [`PlatformSigninConfig::from_env_map`] takes a map
11
+ //! the caller built, and an error names the variable and the kind of mistake but never the value,
12
+ //! because a mistyped value is often a pasted secret.
13
+
14
+ use std::collections::HashMap;
15
+ use std::fmt;
16
+
17
+ /// The variable holding the issuer's address, which is also the exact `iss` every token must carry.
18
+ pub const ISSUER_URL_VARIABLE: &str = "PLATFORM_ISSUER_URL";
19
+ /// The variable holding the address of the issuer's key set (optional; derived from the issuer).
20
+ pub const JWKS_URL_VARIABLE: &str = "PLATFORM_JWKS_URL";
21
+ /// The variable holding this site's reference, which is the exact `aud` every token must carry.
22
+ pub const SITE_ID_VARIABLE: &str = "PLATFORM_SITE_ID";
23
+ /// The variable holding the environment this site runs as, the exact `env` every token must carry.
24
+ pub const ENVIRONMENT_VARIABLE: &str = "PLATFORM_ENVIRONMENT";
25
+
26
+ /// Seconds of clock difference tolerated on `nbf` and `exp`.
27
+ pub const DEFAULT_SKEW_SECONDS: i64 = 30;
28
+ /// The longest a token may live, `exp - iat`, in seconds.
29
+ pub const DEFAULT_MAX_LIFETIME_SECONDS: i64 = 60;
30
+ /// The longest token accepted, in bytes; longer ones are refused before they are parsed.
31
+ pub const DEFAULT_MAX_TOKEN_BYTES: usize = 4096;
32
+ /// The path, under the issuer, where its key set is served when no address is configured.
33
+ pub const DEFAULT_JWKS_PATH: &str = "/.well-known/jwks.json";
34
+
35
+ /// The environments an issuer signs tokens for.
36
+ pub const ENVIRONMENTS: [&str; 2] = ["production", "staging"];
37
+
38
+ /// The pinned facts a verifier checks every token against.
39
+ #[derive(Debug, Clone, PartialEq, Eq)]
40
+ pub struct PlatformSigninConfig {
41
+ pub issuer_url: String,
42
+ pub jwks_url: String,
43
+ pub site_id: String,
44
+ pub environment: String,
45
+ pub skew_seconds: i64,
46
+ pub max_lifetime_seconds: i64,
47
+ pub max_token_bytes: usize,
48
+ }
49
+
50
+ /// What was wrong with a configured value, without the value.
51
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
52
+ pub enum ConfigProblem {
53
+ Missing,
54
+ NotHttps,
55
+ CredentialsInUrl,
56
+ PathQueryOrFragment,
57
+ BadHost,
58
+ UpperCase,
59
+ BadShape,
60
+ WrongHostForIssuer,
61
+ NotAnEnvironment,
62
+ }
63
+
64
+ impl ConfigProblem {
65
+ fn describe(self) -> &'static str {
66
+ match self {
67
+ ConfigProblem::Missing => "is not set",
68
+ ConfigProblem::NotHttps => "must be an https URL",
69
+ ConfigProblem::CredentialsInUrl => "must not carry credentials",
70
+ ConfigProblem::PathQueryOrFragment => {
71
+ "must not carry a path, query or fragment (no trailing slash)"
72
+ }
73
+ ConfigProblem::BadHost => "must name a plain lowercase host and an optional port",
74
+ ConfigProblem::UpperCase => "must be lowercase",
75
+ ConfigProblem::BadShape => "has an invalid shape",
76
+ ConfigProblem::WrongHostForIssuer => "must be on the issuer's host",
77
+ ConfigProblem::NotAnEnvironment => "must be production or staging",
78
+ }
79
+ }
80
+ }
81
+
82
+ /// A refused configuration: which variable, and what kind of mistake.
83
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
84
+ pub struct ConfigError {
85
+ pub variable: &'static str,
86
+ pub problem: ConfigProblem,
87
+ }
88
+
89
+ impl fmt::Display for ConfigError {
90
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91
+ write!(f, "{} {}", self.variable, self.problem.describe())
92
+ }
93
+ }
94
+
95
+ impl std::error::Error for ConfigError {}
96
+
97
+ impl PlatformSigninConfig {
98
+ /// Builds a configuration from explicit values, with the default skew, lifetime and size cap.
99
+ ///
100
+ /// The values are taken as given and not validated: this is the constructor for tests and for
101
+ /// callers that have already validated. Use [`PlatformSigninConfig::from_env_map`] for input
102
+ /// that came from outside.
103
+ pub fn new(issuer_url: &str, jwks_url: &str, site_id: &str, environment: &str) -> Self {
104
+ Self {
105
+ issuer_url: issuer_url.to_string(),
106
+ jwks_url: jwks_url.to_string(),
107
+ site_id: site_id.to_string(),
108
+ environment: environment.to_string(),
109
+ skew_seconds: DEFAULT_SKEW_SECONDS,
110
+ max_lifetime_seconds: DEFAULT_MAX_LIFETIME_SECONDS,
111
+ max_token_bytes: DEFAULT_MAX_TOKEN_BYTES,
112
+ }
113
+ }
114
+
115
+ /// Builds a validated configuration from a map of variable names to values.
116
+ ///
117
+ /// # Errors
118
+ ///
119
+ /// Returns a [`ConfigError`] naming the first variable that is missing or malformed. The error
120
+ /// never contains the value.
121
+ pub fn from_env_map(vars: &HashMap<String, String>) -> Result<Self, ConfigError> {
122
+ let issuer_url = required(vars, ISSUER_URL_VARIABLE)?;
123
+ let issuer_authority =
124
+ issuer_authority(issuer_url).map_err(|problem| fail(ISSUER_URL_VARIABLE, problem))?;
125
+ let jwks_url = match optional(vars, JWKS_URL_VARIABLE) {
126
+ Some(url) => check_jwks_url(url, issuer_authority)
127
+ .map_err(|problem| fail(JWKS_URL_VARIABLE, problem))?,
128
+ None => format!("{issuer_url}{DEFAULT_JWKS_PATH}"),
129
+ };
130
+ let site_id = required(vars, SITE_ID_VARIABLE)?;
131
+ check_site_id(site_id).map_err(|problem| fail(SITE_ID_VARIABLE, problem))?;
132
+ let environment = required(vars, ENVIRONMENT_VARIABLE)?;
133
+ check_environment(environment).map_err(|problem| fail(ENVIRONMENT_VARIABLE, problem))?;
134
+ Ok(Self::new(issuer_url, &jwks_url, site_id, environment))
135
+ }
136
+ }
137
+
138
+ fn fail(variable: &'static str, problem: ConfigProblem) -> ConfigError {
139
+ ConfigError { variable, problem }
140
+ }
141
+
142
+ fn optional<'a>(vars: &'a HashMap<String, String>, name: &str) -> Option<&'a str> {
143
+ vars.get(name)
144
+ .map(String::as_str)
145
+ .filter(|value| !value.is_empty())
146
+ }
147
+
148
+ fn required<'a>(
149
+ vars: &'a HashMap<String, String>,
150
+ name: &'static str,
151
+ ) -> Result<&'a str, ConfigError> {
152
+ optional(vars, name).ok_or_else(|| fail(name, ConfigProblem::Missing))
153
+ }
154
+
155
+ const HTTPS: &str = "https://";
156
+
157
+ /// Splits an `https` URL into its authority and whatever follows it.
158
+ fn split_https(url: &str) -> Result<(&str, &str), ConfigProblem> {
159
+ let rest = url.strip_prefix(HTTPS).ok_or(ConfigProblem::NotHttps)?;
160
+ let end = rest.find(['/', '?', '#', '\\']).unwrap_or(rest.len());
161
+ Ok(rest.split_at(end))
162
+ }
163
+
164
+ /// Validates the issuer URL, which is both an address and the exact `iss` string, and returns its
165
+ /// authority.
166
+ fn issuer_authority(url: &str) -> Result<&str, ConfigProblem> {
167
+ let (authority, remainder) = split_https(url)?;
168
+ if !remainder.is_empty() {
169
+ return Err(ConfigProblem::PathQueryOrFragment);
170
+ }
171
+ check_authority(authority)?;
172
+ Ok(authority)
173
+ }
174
+
175
+ fn check_authority(authority: &str) -> Result<(), ConfigProblem> {
176
+ if authority.contains('@') {
177
+ return Err(ConfigProblem::CredentialsInUrl);
178
+ }
179
+ if authority.bytes().any(|b| b.is_ascii_uppercase()) {
180
+ return Err(ConfigProblem::UpperCase);
181
+ }
182
+ let (host, port) = match authority.split_once(':') {
183
+ Some((host, port)) => (host, Some(port)),
184
+ None => (authority, None),
185
+ };
186
+ let port_ok = port.is_none_or(|p| {
187
+ !p.is_empty()
188
+ && p.bytes().all(|b| b.is_ascii_digit())
189
+ && p.parse::<u16>().is_ok_and(|n| n > 0)
190
+ });
191
+ if port_ok && valid_host(host) {
192
+ Ok(())
193
+ } else {
194
+ Err(ConfigProblem::BadHost)
195
+ }
196
+ }
197
+
198
+ fn valid_host(host: &str) -> bool {
199
+ host.len() <= 253 && host.split('.').all(valid_label)
200
+ }
201
+
202
+ fn valid_label(label: &str) -> bool {
203
+ !label.is_empty()
204
+ && label.len() <= 63
205
+ && !label.starts_with('-')
206
+ && !label.ends_with('-')
207
+ && label
208
+ .bytes()
209
+ .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
210
+ }
211
+
212
+ /// Validates the key set URL: https, on the issuer's own host and port, with a plain path.
213
+ fn check_jwks_url(url: &str, issuer_authority: &str) -> Result<String, ConfigProblem> {
214
+ let (authority, remainder) = split_https(url)?;
215
+ if authority.contains('@') {
216
+ return Err(ConfigProblem::CredentialsInUrl);
217
+ }
218
+ if authority != issuer_authority {
219
+ return Err(ConfigProblem::WrongHostForIssuer);
220
+ }
221
+ if remainder.contains(['?', '#']) {
222
+ return Err(ConfigProblem::PathQueryOrFragment);
223
+ }
224
+ let printable = remainder.bytes().all(|b| b.is_ascii_graphic());
225
+ if remainder.starts_with('/') && printable && !remainder.contains('\\') {
226
+ Ok(url.to_string())
227
+ } else {
228
+ Err(ConfigProblem::BadShape)
229
+ }
230
+ }
231
+
232
+ /// A site reference is lowercase letters, digits and hyphens, 1 to 63 long, not starting with a
233
+ /// hyphen.
234
+ fn check_site_id(site: &str) -> Result<(), ConfigProblem> {
235
+ let mut bytes = site.bytes();
236
+ let first_ok = bytes
237
+ .next()
238
+ .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
239
+ let rest_ok = bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
240
+ if first_ok && rest_ok && site.len() <= 63 {
241
+ Ok(())
242
+ } else {
243
+ Err(ConfigProblem::BadShape)
244
+ }
245
+ }
246
+
247
+ fn check_environment(environment: &str) -> Result<(), ConfigProblem> {
248
+ if ENVIRONMENTS.contains(&environment) {
249
+ Ok(())
250
+ } else {
251
+ Err(ConfigProblem::NotAnEnvironment)
252
+ }
253
+ }
254
+
255
+ #[cfg(test)]
256
+ mod tests {
257
+ use super::*;
258
+
259
+ fn good() -> HashMap<String, String> {
260
+ [
261
+ (ISSUER_URL_VARIABLE, "https://issuer.example"),
262
+ (SITE_ID_VARIABLE, "site-a"),
263
+ (ENVIRONMENT_VARIABLE, "production"),
264
+ ]
265
+ .into_iter()
266
+ .map(|(k, v)| (k.to_string(), v.to_string()))
267
+ .collect()
268
+ }
269
+
270
+ fn with(name: &str, value: &str) -> HashMap<String, String> {
271
+ let mut vars = good();
272
+ vars.insert(name.to_string(), value.to_string());
273
+ vars
274
+ }
275
+
276
+ fn refusal(vars: &HashMap<String, String>) -> ConfigError {
277
+ PlatformSigninConfig::from_env_map(vars).expect_err("must be refused")
278
+ }
279
+
280
+ #[test]
281
+ fn explicit_values_get_the_default_windows() {
282
+ let c = PlatformSigninConfig::new(
283
+ "https://issuer.example",
284
+ "https://issuer.example/k",
285
+ "site-a",
286
+ "staging",
287
+ );
288
+ assert_eq!(
289
+ (c.skew_seconds, c.max_lifetime_seconds, c.max_token_bytes),
290
+ (30, 60, 4096)
291
+ );
292
+ assert_eq!(
293
+ (
294
+ c.issuer_url.as_str(),
295
+ c.site_id.as_str(),
296
+ c.environment.as_str()
297
+ ),
298
+ ("https://issuer.example", "site-a", "staging")
299
+ );
300
+ }
301
+
302
+ #[test]
303
+ fn a_good_map_builds_and_derives_the_key_set_address() {
304
+ let c = PlatformSigninConfig::from_env_map(&good()).expect("valid");
305
+ assert_eq!(c.issuer_url, "https://issuer.example");
306
+ assert_eq!(c.jwks_url, "https://issuer.example/.well-known/jwks.json");
307
+ assert_eq!(
308
+ (c.site_id.as_str(), c.environment.as_str()),
309
+ ("site-a", "production")
310
+ );
311
+ }
312
+
313
+ #[test]
314
+ fn an_explicit_key_set_address_on_the_issuers_host_is_kept() {
315
+ let vars = with(
316
+ JWKS_URL_VARIABLE,
317
+ "https://issuer.example/keys/current.json",
318
+ );
319
+ assert_eq!(
320
+ PlatformSigninConfig::from_env_map(&vars).unwrap().jwks_url,
321
+ "https://issuer.example/keys/current.json"
322
+ );
323
+ }
324
+
325
+ #[test]
326
+ fn a_port_on_the_issuer_is_allowed_and_must_match_on_the_key_set() {
327
+ let mut vars = with(ISSUER_URL_VARIABLE, "https://issuer.example:8443");
328
+ assert!(PlatformSigninConfig::from_env_map(&vars).is_ok());
329
+ vars.insert(JWKS_URL_VARIABLE.into(), "https://issuer.example/k".into());
330
+ assert_eq!(refusal(&vars).problem, ConfigProblem::WrongHostForIssuer);
331
+ }
332
+
333
+ #[test]
334
+ fn each_required_variable_missing_is_named() {
335
+ for name in [ISSUER_URL_VARIABLE, SITE_ID_VARIABLE, ENVIRONMENT_VARIABLE] {
336
+ let mut vars = good();
337
+ vars.remove(name);
338
+ let error = refusal(&vars);
339
+ assert_eq!(
340
+ (error.variable, error.problem),
341
+ (name, ConfigProblem::Missing)
342
+ );
343
+ assert!(error.to_string().starts_with(name));
344
+ }
345
+ }
346
+
347
+ #[test]
348
+ fn an_empty_value_counts_as_missing() {
349
+ assert_eq!(
350
+ refusal(&with(SITE_ID_VARIABLE, "")).problem,
351
+ ConfigProblem::Missing
352
+ );
353
+ }
354
+
355
+ #[test]
356
+ fn bad_issuer_urls_name_the_issuer_variable() {
357
+ let cases = [
358
+ ("http://issuer.example", ConfigProblem::NotHttps),
359
+ ("issuer.example", ConfigProblem::NotHttps),
360
+ ("HTTPS://issuer.example", ConfigProblem::NotHttps),
361
+ (
362
+ "https://issuer.example/",
363
+ ConfigProblem::PathQueryOrFragment,
364
+ ),
365
+ (
366
+ "https://issuer.example/path",
367
+ ConfigProblem::PathQueryOrFragment,
368
+ ),
369
+ (
370
+ "https://issuer.example?x=1",
371
+ ConfigProblem::PathQueryOrFragment,
372
+ ),
373
+ (
374
+ "https://issuer.example#frag",
375
+ ConfigProblem::PathQueryOrFragment,
376
+ ),
377
+ (
378
+ "https://issuer.example\\evil",
379
+ ConfigProblem::PathQueryOrFragment,
380
+ ),
381
+ (
382
+ "https://user:pw@issuer.example",
383
+ ConfigProblem::CredentialsInUrl,
384
+ ),
385
+ (
386
+ "https://issuer.example@evil.example",
387
+ ConfigProblem::CredentialsInUrl,
388
+ ),
389
+ ("https://Issuer.example", ConfigProblem::UpperCase),
390
+ ("https://", ConfigProblem::BadHost),
391
+ ("https://issuer .example", ConfigProblem::BadHost),
392
+ ("https://issuer.example:", ConfigProblem::BadHost),
393
+ ("https://issuer.example:0", ConfigProblem::BadHost),
394
+ ("https://issuer.example:99999", ConfigProblem::BadHost),
395
+ ("https://[::1]", ConfigProblem::BadHost),
396
+ ("https://-issuer.example", ConfigProblem::BadHost),
397
+ ("https://issuer..example", ConfigProblem::BadHost),
398
+ ("https://iss\u{fc}er.example", ConfigProblem::BadHost),
399
+ (" https://issuer.example", ConfigProblem::NotHttps),
400
+ ];
401
+ for (value, problem) in cases {
402
+ let error = refusal(&with(ISSUER_URL_VARIABLE, value));
403
+ assert_eq!(error.variable, ISSUER_URL_VARIABLE, "{value:?}");
404
+ assert_eq!(error.problem, problem, "{value:?}");
405
+ }
406
+ }
407
+
408
+ #[test]
409
+ fn bad_key_set_urls_name_the_key_set_variable() {
410
+ let cases = [
411
+ ("http://issuer.example/k", ConfigProblem::NotHttps),
412
+ ("https://other.example/k", ConfigProblem::WrongHostForIssuer),
413
+ (
414
+ "https://issuer.example.evil.example/k",
415
+ ConfigProblem::WrongHostForIssuer,
416
+ ),
417
+ (
418
+ "https://user@issuer.example/k",
419
+ ConfigProblem::CredentialsInUrl,
420
+ ),
421
+ (
422
+ "https://issuer.example/k?x=1",
423
+ ConfigProblem::PathQueryOrFragment,
424
+ ),
425
+ (
426
+ "https://issuer.example/k#f",
427
+ ConfigProblem::PathQueryOrFragment,
428
+ ),
429
+ ("https://issuer.example", ConfigProblem::BadShape),
430
+ ("https://issuer.example/a b", ConfigProblem::BadShape),
431
+ ];
432
+ for (value, problem) in cases {
433
+ let error = refusal(&with(JWKS_URL_VARIABLE, value));
434
+ assert_eq!(error.variable, JWKS_URL_VARIABLE, "{value:?}");
435
+ assert_eq!(error.problem, problem, "{value:?}");
436
+ }
437
+ }
438
+
439
+ #[test]
440
+ fn bad_site_references_name_the_site_variable() {
441
+ for value in [
442
+ "Site-A",
443
+ "-site",
444
+ "site_a",
445
+ "site a",
446
+ "a/b",
447
+ &"a".repeat(64),
448
+ "sité",
449
+ ] {
450
+ let error = refusal(&with(SITE_ID_VARIABLE, value));
451
+ assert_eq!(
452
+ (error.variable, error.problem),
453
+ (SITE_ID_VARIABLE, ConfigProblem::BadShape),
454
+ "{value:?}"
455
+ );
456
+ }
457
+ assert!(
458
+ PlatformSigninConfig::from_env_map(&with(SITE_ID_VARIABLE, &"a".repeat(63))).is_ok()
459
+ );
460
+ assert!(PlatformSigninConfig::from_env_map(&with(SITE_ID_VARIABLE, "0-site")).is_ok());
461
+ }
462
+
463
+ #[test]
464
+ fn only_production_and_staging_are_environments() {
465
+ for value in ["Production", "prod", "dev", "production ", "production\n"] {
466
+ let error = refusal(&with(ENVIRONMENT_VARIABLE, value));
467
+ assert_eq!(
468
+ (error.variable, error.problem),
469
+ (ENVIRONMENT_VARIABLE, ConfigProblem::NotAnEnvironment),
470
+ "{value:?}"
471
+ );
472
+ }
473
+ assert!(PlatformSigninConfig::from_env_map(&with(ENVIRONMENT_VARIABLE, "staging")).is_ok());
474
+ }
475
+
476
+ #[test]
477
+ fn no_error_ever_echoes_the_value() {
478
+ let marker = "very-secret-marker-9137";
479
+ let values = [
480
+ (
481
+ ISSUER_URL_VARIABLE,
482
+ format!("http://user:{marker}@{marker}.example/{marker}"),
483
+ ),
484
+ (
485
+ ISSUER_URL_VARIABLE,
486
+ format!("https://{marker}.example/{marker}"),
487
+ ),
488
+ (
489
+ JWKS_URL_VARIABLE,
490
+ format!("https://{marker}.example/{marker}?{marker}"),
491
+ ),
492
+ (SITE_ID_VARIABLE, format!("Bad {marker}")),
493
+ (ENVIRONMENT_VARIABLE, marker.to_string()),
494
+ ];
495
+ for (name, value) in values {
496
+ let error = refusal(&with(name, &value));
497
+ let shown = format!("{error} {error:?}");
498
+ assert!(!shown.contains(marker), "{name}: {shown}");
499
+ assert!(shown.contains(name));
500
+ }
501
+ }
502
+
503
+ #[test]
504
+ fn the_first_problem_found_follows_a_fixed_order() {
505
+ let mut vars = HashMap::new();
506
+ assert_eq!(refusal(&vars).variable, ISSUER_URL_VARIABLE);
507
+ vars.insert(ISSUER_URL_VARIABLE.into(), "https://issuer.example".into());
508
+ assert_eq!(refusal(&vars).variable, SITE_ID_VARIABLE);
509
+ vars.insert(SITE_ID_VARIABLE.into(), "site-a".into());
510
+ assert_eq!(refusal(&vars).variable, ENVIRONMENT_VARIABLE);
511
+ }
512
+ }