1use std::borrow::Cow;
4use std::fmt;
5use std::str::FromStr;
6
7use rand::Rng;
8use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
9use secrecy::{ExposeSecret, SecretString};
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11
12pub const INVALID_OR_UNAVAILABLE_PIONEER_CODE: &str = "invalid or unavailable pioneer code";
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct PioneerCodeError;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
21#[serde(rename_all = "snake_case")]
22pub enum PioneerCodeStatus {
23 Active,
24 Revoked,
25}
26
27impl PioneerCodeStatus {
28 pub fn as_str(self) -> &'static str {
30 match self {
31 Self::Active => "active",
32 Self::Revoked => "revoked",
33 }
34 }
35
36 pub fn from_storage_value(value: &str) -> Option<Self> {
38 match value {
39 "active" => Some(Self::Active),
40 "revoked" => Some(Self::Revoked),
41 _ => None,
42 }
43 }
44}
45
46impl fmt::Display for PioneerCodeStatus {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 f.write_str(self.as_str())
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
55#[serde(rename_all = "snake_case")]
56pub enum PioneerCodeUseKind {
57 HumanGithubSignIn,
58 AgentApi,
59}
60
61impl PioneerCodeUseKind {
62 pub fn as_str(self) -> &'static str {
64 match self {
65 Self::HumanGithubSignIn => "human_github_sign_in",
66 Self::AgentApi => "agent_api",
67 }
68 }
69
70 pub fn from_storage_value(value: &str) -> Option<Self> {
72 match value {
73 "human_github_sign_in" => Some(Self::HumanGithubSignIn),
74 "agent_api" => Some(Self::AgentApi),
75 _ => None,
76 }
77 }
78}
79
80impl fmt::Display for PioneerCodeUseKind {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 f.write_str(self.as_str())
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
89#[serde(rename_all = "snake_case")]
90pub enum PioneerCodeSubjectKind {
91 Human,
92 Agent,
93}
94
95impl PioneerCodeSubjectKind {
96 pub fn as_str(self) -> &'static str {
98 match self {
99 Self::Human => "human",
100 Self::Agent => "agent",
101 }
102 }
103
104 pub fn from_storage_value(value: &str) -> Option<Self> {
106 match value {
107 "human" => Some(Self::Human),
108 "agent" => Some(Self::Agent),
109 _ => None,
110 }
111 }
112}
113
114impl fmt::Display for PioneerCodeSubjectKind {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117 f.write_str(self.as_str())
118 }
119}
120
121impl fmt::Display for PioneerCodeError {
122 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 f.write_str(
125 "pioneer_code must be 8 lowercase hex chars or <label>-<8 lowercase hex chars>; label may use lowercase letters, digits, or _ and must be at most 6 chars",
126 )
127 }
128}
129
130impl std::error::Error for PioneerCodeError {}
131
132#[derive(Clone)]
134pub struct PioneerCode(SecretString);
135
136impl PioneerCode {
137 pub fn try_new(value: impl Into<String>) -> Result<Self, PioneerCodeError> {
139 let value = value.into();
140 validate_pioneer_code(&value)?;
141 Ok(Self(SecretString::from(value)))
142 }
143
144 pub fn generate(label: Option<&str>) -> Result<Self, PioneerCodeError> {
146 let mut bytes = [0u8; 4];
147 rand::rng().fill_bytes(&mut bytes);
148 let random_hex = hex::encode(bytes);
149 let code = match label {
150 Some(label) => {
151 validate_pioneer_label(label)?;
152 format!("{label}-{random_hex}")
153 }
154 None => random_hex,
155 };
156 Self::try_new(code)
157 }
158
159 pub fn expose_secret(&self) -> &str {
161 self.0.expose_secret()
162 }
163
164 pub fn label(&self) -> Option<&str> {
166 self.expose_secret()
167 .split_once('-')
168 .map(|(label, _random)| label)
169 }
170}
171
172#[derive(Clone)]
174pub struct PioneerCodeInput(SecretString);
175
176impl PioneerCodeInput {
177 pub fn try_new(value: impl Into<String>) -> Result<Self, PioneerCodeError> {
179 Ok(Self(SecretString::from(value.into())))
180 }
181
182 pub fn expose_secret(&self) -> &str {
184 self.0.expose_secret()
185 }
186}
187
188impl fmt::Debug for PioneerCodeInput {
189 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191 f.write_str("PioneerCodeInput([redacted])")
192 }
193}
194
195impl Serialize for PioneerCodeInput {
196 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
198 where
199 S: Serializer,
200 {
201 serializer.serialize_str(self.expose_secret())
202 }
203}
204
205impl<'de> Deserialize<'de> for PioneerCodeInput {
206 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
208 where
209 D: Deserializer<'de>,
210 {
211 let value = String::deserialize(deserializer)?;
212 Self::try_new(value).map_err(serde::de::Error::custom)
213 }
214}
215
216impl JsonSchema for PioneerCodeInput {
217 fn inline_schema() -> bool {
219 true
220 }
221
222 fn schema_name() -> Cow<'static, str> {
224 "PioneerCodeInput".into()
225 }
226
227 fn json_schema(_: &mut SchemaGenerator) -> Schema {
229 json_schema!({ "type": "string" })
230 }
231}
232
233impl fmt::Debug for PioneerCode {
234 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236 f.write_str("PioneerCode([redacted])")
237 }
238}
239
240impl fmt::Display for PioneerCode {
241 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243 f.write_str("[redacted pioneer code]")
244 }
245}
246
247impl FromStr for PioneerCode {
248 type Err = PioneerCodeError;
249
250 fn from_str(value: &str) -> Result<Self, Self::Err> {
252 Self::try_new(value.to_string())
253 }
254}
255
256impl Serialize for PioneerCode {
257 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
259 where
260 S: Serializer,
261 {
262 serializer.serialize_str(self.expose_secret())
263 }
264}
265
266impl<'de> Deserialize<'de> for PioneerCode {
267 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
269 where
270 D: Deserializer<'de>,
271 {
272 let value = String::deserialize(deserializer)?;
273 Self::try_new(value).map_err(serde::de::Error::custom)
274 }
275}
276
277impl JsonSchema for PioneerCode {
278 fn inline_schema() -> bool {
280 true
281 }
282
283 fn schema_name() -> Cow<'static, str> {
285 "PioneerCode".into()
286 }
287
288 fn json_schema(_: &mut SchemaGenerator) -> Schema {
290 json_schema!({
291 "type": "string",
292 "pattern": "^([a-z0-9_]{1,6}-)?[0-9a-f]{8}$"
293 })
294 }
295}
296
297fn validate_pioneer_code(value: &str) -> Result<(), PioneerCodeError> {
299 if let Some((label, random_hex)) = value.split_once('-') {
300 if random_hex.contains('-') {
301 return Err(PioneerCodeError);
302 }
303 validate_pioneer_label(label)?;
304 validate_random_hex(random_hex)?;
305 } else {
306 validate_random_hex(value)?;
307 }
308 Ok(())
309}
310
311fn validate_pioneer_label(label: &str) -> Result<(), PioneerCodeError> {
313 if label.is_empty() || label.len() > 6 {
314 return Err(PioneerCodeError);
315 }
316 if !label
317 .bytes()
318 .all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_'))
319 {
320 return Err(PioneerCodeError);
321 }
322 Ok(())
323}
324
325fn validate_random_hex(random_hex: &str) -> Result<(), PioneerCodeError> {
327 if random_hex.len() != 8 {
328 return Err(PioneerCodeError);
329 }
330 if !random_hex
331 .bytes()
332 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
333 {
334 return Err(PioneerCodeError);
335 }
336 Ok(())
337}
338
339#[cfg(test)]
340mod tests {
341 use super::PioneerCode;
342
343 #[test]
345 fn accepts_plain_and_labeled_codes() {
346 let plain = PioneerCode::try_new("deadbeef").expect("plain code should parse");
347 assert_eq!(plain.expose_secret(), "deadbeef");
348 assert_eq!(plain.label(), None);
349
350 let labeled = PioneerCode::try_new("jack_1-deadbeef").expect("labeled code should parse");
351 assert_eq!(labeled.expose_secret(), "jack_1-deadbeef");
352 assert_eq!(labeled.label(), Some("jack_1"));
353 }
354
355 #[test]
357 fn rejects_invalid_codes() {
358 for value in [
359 "",
360 "DEADBEEF",
361 "deadbee",
362 "deadbeef00",
363 "labeltoolong-deadbeef",
364 "bad-label-deadbeef",
365 "bad!-deadbeef",
366 "-deadbeef",
367 "jack-DEADBEEF",
368 "jack-deadbee!",
369 ] {
370 assert!(PioneerCode::try_new(value).is_err(), "{value}");
371 }
372 }
373
374 #[test]
376 fn generated_labeled_code_keeps_label() {
377 let code = PioneerCode::generate(Some("jack")).expect("generated code should be valid");
378 assert_eq!(code.label(), Some("jack"));
379 assert!(code.expose_secret().starts_with("jack-"));
380 }
381
382 #[test]
384 fn serde_uses_string_wire_shape() {
385 let code: PioneerCode =
386 serde_json::from_str("\"deadbeef\"").expect("valid code should deserialize");
387 assert_eq!(code.expose_secret(), "deadbeef");
388 assert_eq!(
389 serde_json::to_string(&code).expect("code should serialize"),
390 "\"deadbeef\""
391 );
392 }
393}