Skip to main content

shardline_protocol/
token.rs

1use std::{fmt, str::FromStr};
2
3use hmac::{Hmac, Mac};
4use serde::{Deserialize, Serialize};
5use serde_json::{Error as JsonError, from_slice, to_vec};
6use subtle::ConstantTimeEq;
7use thiserror::Error;
8
9use crate::{SecretBytes, unix_now_seconds_lossy};
10
11type TokenMac = Hmac<sha2::Sha256>;
12
13const MAX_TOKEN_COMPONENT_BYTES: usize = 512;
14/// Maximum accepted encoded bearer-token length.
15pub const MAX_TOKEN_STRING_BYTES: usize = 16_384;
16const TOKEN_SIGNATURE_HEX_BYTES: usize = 64;
17const MAX_TOKEN_PAYLOAD_HEX_BYTES: usize = MAX_TOKEN_STRING_BYTES - 2;
18
19/// CAS token scope.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub enum TokenScope {
22    /// Read-only CAS access.
23    Read,
24    /// Write access, including the read behavior required by upload clients.
25    Write,
26}
27
28impl TokenScope {
29    /// Returns true when this scope can perform read operations.
30    #[must_use]
31    pub const fn allows_read(self) -> bool {
32        matches!(self, Self::Read | Self::Write)
33    }
34
35    /// Returns true when this scope can perform write operations.
36    #[must_use]
37    pub const fn allows_write(self) -> bool {
38        matches!(self, Self::Write)
39    }
40}
41
42/// Provider family encoded into a scoped token.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum RepositoryProvider {
45    /// GitHub repository scope.
46    GitHub,
47    /// Gitea repository scope.
48    Gitea,
49    /// GitLab repository scope.
50    GitLab,
51    /// Codeberg (Gitea-based) repository scope.
52    Codeberg,
53    /// Generic Git forge repository scope.
54    Generic,
55}
56
57impl RepositoryProvider {
58    /// Returns the stable lowercase provider name used in persisted metadata.
59    #[must_use]
60    pub const fn as_str(self) -> &'static str {
61        match self {
62            Self::GitHub => "github",
63            Self::Gitea => "gitea",
64            Self::GitLab => "gitlab",
65            Self::Codeberg => "codeberg",
66            Self::Generic => "generic",
67        }
68    }
69}
70
71impl Serialize for RepositoryProvider {
72    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
73        serializer.serialize_str(self.as_str())
74    }
75}
76
77impl<'de> Deserialize<'de> for RepositoryProvider {
78    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
79        let s = String::deserialize(deserializer)?;
80        // Accept case-insensitive input for backward compatibility with any
81        // data serialized by the default derive (which used the Rust variant name).
82        s.to_ascii_lowercase()
83            .parse()
84            .map_err(serde::de::Error::custom)
85    }
86}
87
88/// Repository provider parse failure.
89#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
90#[error("repository provider was invalid")]
91pub struct RepositoryProviderParseError;
92
93impl FromStr for RepositoryProvider {
94    type Err = RepositoryProviderParseError;
95
96    fn from_str(value: &str) -> Result<Self, Self::Err> {
97        match value {
98            "github" => Ok(Self::GitHub),
99            "gitea" => Ok(Self::Gitea),
100            "gitlab" => Ok(Self::GitLab),
101            "codeberg" => Ok(Self::Codeberg),
102            "generic" => Ok(Self::Generic),
103            _other => Err(RepositoryProviderParseError),
104        }
105    }
106}
107
108/// Repository and revision scope encoded into a token.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct RepositoryScope {
111    provider: RepositoryProvider,
112    owner: String,
113    name: String,
114    revision: Option<String>,
115}
116
117impl RepositoryScope {
118    /// Creates a repository scope.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`TokenClaimsError`] when the owner, name, or revision contain invalid
123    /// values.
124    pub fn new(
125        provider: RepositoryProvider,
126        owner: &str,
127        name: &str,
128        revision: Option<&str>,
129    ) -> Result<Self, TokenClaimsError> {
130        validate_component(owner, TokenClaimsError::EmptyRepositoryOwner)?;
131        validate_component(name, TokenClaimsError::EmptyRepositoryName)?;
132        if let Some(value) = revision {
133            validate_component(value, TokenClaimsError::EmptyRevision)?;
134        }
135
136        Ok(Self {
137            provider,
138            owner: owner.to_owned(),
139            name: name.to_owned(),
140            revision: revision.map(ToOwned::to_owned),
141        })
142    }
143
144    /// Returns the scoped provider family.
145    #[must_use]
146    pub const fn provider(&self) -> RepositoryProvider {
147        self.provider
148    }
149
150    /// Returns the scoped repository owner or namespace.
151    #[must_use]
152    pub fn owner(&self) -> &str {
153        &self.owner
154    }
155
156    /// Returns the scoped repository name.
157    #[must_use]
158    pub fn name(&self) -> &str {
159        &self.name
160    }
161
162    /// Returns the scoped revision, when one is required.
163    #[must_use]
164    pub fn revision(&self) -> Option<&str> {
165        self.revision.as_deref()
166    }
167}
168
169/// Signed token claims used by the Shardline API.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct TokenClaims {
172    issuer: String,
173    subject: String,
174    scope: TokenScope,
175    repository: RepositoryScope,
176    expires_at_unix_seconds: u64,
177}
178
179impl TokenClaims {
180    /// Creates token claims.
181    ///
182    /// # Errors
183    ///
184    /// Returns [`TokenClaimsError`] when the issuer, subject, or repository scope are
185    /// invalid.
186    pub fn new(
187        issuer: &str,
188        subject: &str,
189        scope: TokenScope,
190        repository: RepositoryScope,
191        expires_at_unix_seconds: u64,
192    ) -> Result<Self, TokenClaimsError> {
193        validate_component(issuer, TokenClaimsError::EmptyIssuer)?;
194        validate_component(subject, TokenClaimsError::EmptySubject)?;
195        Ok(Self {
196            issuer: issuer.to_owned(),
197            subject: subject.to_owned(),
198            scope,
199            repository,
200            expires_at_unix_seconds,
201        })
202    }
203
204    /// Returns the token issuer identity.
205    #[must_use]
206    pub fn issuer(&self) -> &str {
207        &self.issuer
208    }
209
210    /// Returns the authenticated subject.
211    #[must_use]
212    pub fn subject(&self) -> &str {
213        &self.subject
214    }
215
216    /// Returns the granted scope.
217    #[must_use]
218    pub const fn scope(&self) -> TokenScope {
219        self.scope
220    }
221
222    /// Returns the scoped repository identity.
223    #[must_use]
224    pub const fn repository(&self) -> &RepositoryScope {
225        &self.repository
226    }
227
228    /// Returns the token expiration timestamp as Unix seconds.
229    #[must_use]
230    pub const fn expires_at_unix_seconds(&self) -> u64 {
231        self.expires_at_unix_seconds
232    }
233}
234
235/// Token claim validation failure.
236#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
237pub enum TokenClaimsError {
238    /// The issuer was empty.
239    #[error("token issuer must not be empty")]
240    EmptyIssuer,
241    /// The subject was empty.
242    #[error("token subject must not be empty")]
243    EmptySubject,
244    /// The repository owner was empty.
245    #[error("token repository owner must not be empty")]
246    EmptyRepositoryOwner,
247    /// The repository name was empty.
248    #[error("token repository name must not be empty")]
249    EmptyRepositoryName,
250    /// The revision was empty.
251    #[error("token revision must not be empty when provided")]
252    EmptyRevision,
253    /// A token component contained control characters.
254    #[error("token components must not contain control characters")]
255    ControlCharacter,
256    /// A token component exceeded the supported metadata bound.
257    #[error("token component exceeded supported length")]
258    TooLong,
259}
260
261/// Minimum signing key length in bytes.
262const MIN_SIGNING_KEY_BYTES: usize = 32;
263
264/// Token signing or verification failure.
265#[derive(Debug, Error)]
266pub enum TokenCodecError {
267    /// The signing key was empty.
268    #[error("token signing key must not be empty: {0}")]
269    EmptySigningKey(String),
270    /// The signing key is too short.
271    #[error("token signing key must be at least {MIN_SIGNING_KEY_BYTES} bytes, got {actual_bytes}")]
272    SigningKeyTooShort { actual_bytes: usize },
273    /// The token payload could not be serialized or deserialized.
274    #[error("token json operation failed")]
275    Json(#[from] JsonError),
276    /// The token string did not match the expected format.
277    #[error("token format was invalid")]
278    InvalidFormat,
279    /// A hex-encoded token segment was malformed.
280    #[error("token hex segment was invalid")]
281    InvalidHex(#[from] hex::FromHexError),
282    /// The token signature did not verify.
283    #[error("token signature was invalid")]
284    InvalidSignature,
285    /// The token has expired.
286    #[error("token has expired")]
287    Expired,
288    /// The token payload contained invalid claims.
289    #[error("token claims were invalid")]
290    Claims(#[from] TokenClaimsError),
291}
292
293/// Local token signer and verifier.
294#[derive(Clone)]
295pub struct TokenSigner {
296    signing_key: SecretBytes,
297}
298
299impl fmt::Debug for TokenSigner {
300    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
301        formatter
302            .debug_struct("TokenSigner")
303            .field("signing_key", &"***")
304            .finish()
305    }
306}
307
308impl TokenSigner {
309    /// Creates a token signer from raw key bytes.
310    ///
311    /// # Errors
312    ///
313    /// Returns [`TokenCodecError::EmptySigningKey`] when the signing key is empty.
314    pub fn new(signing_key: &[u8]) -> Result<Self, TokenCodecError> {
315        if signing_key.is_empty() {
316            return Err(TokenCodecError::EmptySigningKey(
317                "provided key is empty".to_owned(),
318            ));
319        }
320        if signing_key.len() < MIN_SIGNING_KEY_BYTES {
321            return Err(TokenCodecError::SigningKeyTooShort {
322                actual_bytes: signing_key.len(),
323            });
324        }
325
326        Ok(Self {
327            signing_key: SecretBytes::from_slice(signing_key),
328        })
329    }
330
331    /// Signs token claims into an opaque bearer token string.
332    ///
333    /// # Errors
334    ///
335    /// Returns [`TokenCodecError`] when the claims cannot be serialized.
336    pub fn sign(&self, claims: &TokenClaims) -> Result<String, TokenCodecError> {
337        let payload = encode_token_claims(claims)?;
338        let signature = self.signature(&payload)?;
339        format_signed_token(&payload, &signature)
340    }
341
342    /// Verifies a token against the supplied current Unix timestamp.
343    ///
344    /// # Errors
345    ///
346    /// Returns [`TokenCodecError`] when the token does not parse, does not verify, or
347    /// has expired.
348    pub fn verify_at(
349        &self,
350        token: &str,
351        current_unix_seconds: u64,
352    ) -> Result<TokenClaims, TokenCodecError> {
353        let (payload_hex, signature_hex) = split_token(token)?;
354        if signature_hex.len() != TOKEN_SIGNATURE_HEX_BYTES {
355            return Err(TokenCodecError::InvalidFormat);
356        }
357        let payload = hex::decode(payload_hex)?;
358        let signature = hex::decode(signature_hex)?;
359        let expected_signature = self.signature(&payload)?;
360        if expected_signature.ct_eq(signature.as_slice()).unwrap_u8() != 1 {
361            return Err(TokenCodecError::InvalidSignature);
362        }
363        decode_and_validate_claims(&payload, current_unix_seconds)
364    }
365
366    /// Verifies a token against the current wall clock.
367    ///
368    /// # Errors
369    ///
370    /// Returns [`TokenCodecError`] when the token is invalid or expired.
371    pub fn verify_now(&self, token: &str) -> Result<TokenClaims, TokenCodecError> {
372        self.verify_at(token, unix_now_seconds_lossy())
373    }
374
375    fn signature(&self, payload: &[u8]) -> Result<Vec<u8>, TokenCodecError> {
376        let mut mac = TokenMac::new_from_slice(self.signing_key.expose_secret())
377            .map_err(|err| TokenCodecError::EmptySigningKey(err.to_string()))?;
378        mac.update(payload);
379        Ok(mac.finalize().into_bytes().to_vec())
380    }
381}
382
383/// Splits a token string `payload_hex.signature_hex` into its two hex segments.
384///
385/// # Errors
386///
387/// Returns [`TokenCodecError::InvalidFormat`] when the token exceeds the maximum
388/// length, does not contain exactly one separator, or has an empty segment.
389pub fn split_token(token: &str) -> Result<(&str, &str), TokenCodecError> {
390    if token.len() > MAX_TOKEN_STRING_BYTES {
391        return Err(TokenCodecError::InvalidFormat);
392    }
393    let Some((payload_hex, signature_hex)) = token.split_once('.') else {
394        return Err(TokenCodecError::InvalidFormat);
395    };
396    if payload_hex.is_empty()
397        || payload_hex.len() > MAX_TOKEN_PAYLOAD_HEX_BYTES
398        || signature_hex.is_empty()
399        || signature_hex.contains('.')
400    {
401        return Err(TokenCodecError::InvalidFormat);
402    }
403    Ok((payload_hex, signature_hex))
404}
405
406/// Serializes validated token claims into the canonical signed payload.
407///
408/// # Errors
409///
410/// Returns [`TokenCodecError::Json`] when serialization fails.
411pub fn encode_token_claims(claims: &TokenClaims) -> Result<Vec<u8>, TokenCodecError> {
412    Ok(to_vec(claims)?)
413}
414
415/// Formats a payload and signature using Shardline's canonical hex token envelope.
416///
417/// # Errors
418///
419/// Returns [`TokenCodecError::InvalidFormat`] when the encoded token would exceed
420/// [`MAX_TOKEN_STRING_BYTES`] or either segment is empty.
421pub fn format_signed_token(payload: &[u8], signature: &[u8]) -> Result<String, TokenCodecError> {
422    if payload.is_empty() || signature.is_empty() {
423        return Err(TokenCodecError::InvalidFormat);
424    }
425    let encoded_len = payload
426        .len()
427        .checked_mul(2)
428        .and_then(|length| length.checked_add(1))
429        .and_then(|length| {
430            signature
431                .len()
432                .checked_mul(2)
433                .and_then(|value| length.checked_add(value))
434        })
435        .ok_or(TokenCodecError::InvalidFormat)?;
436    if encoded_len > MAX_TOKEN_STRING_BYTES {
437        return Err(TokenCodecError::InvalidFormat);
438    }
439    Ok(format!(
440        "{}.{}",
441        hex::encode(payload),
442        hex::encode(signature)
443    ))
444}
445
446/// Decodes and validates token claims from raw JSON payload bytes.
447///
448/// # Errors
449///
450/// Returns [`TokenCodecError`] when the payload cannot be deserialized, the
451/// claims contain invalid components, or the token has expired.
452pub fn decode_and_validate_claims(
453    payload: &[u8],
454    current_unix_seconds: u64,
455) -> Result<TokenClaims, TokenCodecError> {
456    let claims = from_slice::<TokenClaims>(payload)?;
457    validate_component(claims.issuer(), TokenClaimsError::EmptyIssuer)?;
458    validate_component(claims.subject(), TokenClaimsError::EmptySubject)?;
459    validate_component(
460        claims.repository().owner(),
461        TokenClaimsError::EmptyRepositoryOwner,
462    )?;
463    validate_component(
464        claims.repository().name(),
465        TokenClaimsError::EmptyRepositoryName,
466    )?;
467    if let Some(revision) = claims.repository().revision() {
468        validate_component(revision, TokenClaimsError::EmptyRevision)?;
469    }
470    if claims.expires_at_unix_seconds() < current_unix_seconds {
471        return Err(TokenCodecError::Expired);
472    }
473    Ok(claims)
474}
475
476fn validate_component(value: &str, empty_error: TokenClaimsError) -> Result<(), TokenClaimsError> {
477    if value.trim().is_empty() {
478        return Err(empty_error);
479    }
480
481    if value.len() > MAX_TOKEN_COMPONENT_BYTES {
482        return Err(TokenClaimsError::TooLong);
483    }
484
485    if value.chars().any(char::is_control) {
486        return Err(TokenClaimsError::ControlCharacter);
487    }
488
489    Ok(())
490}
491
492#[cfg(test)]
493mod tests {
494    use super::{
495        MAX_TOKEN_COMPONENT_BYTES, MAX_TOKEN_PAYLOAD_HEX_BYTES, MAX_TOKEN_STRING_BYTES,
496        RepositoryProvider, RepositoryProviderParseError, RepositoryScope,
497        TOKEN_SIGNATURE_HEX_BYTES, TokenClaims, TokenClaimsError, TokenCodecError, TokenScope,
498        TokenSigner, format_signed_token, split_token,
499    };
500
501    #[test]
502    fn write_token_allows_read_and_write() {
503        assert!(TokenScope::Write.allows_read());
504        assert!(TokenScope::Write.allows_write());
505    }
506
507    #[test]
508    fn read_token_does_not_allow_write() {
509        assert!(TokenScope::Read.allows_read());
510        assert!(!TokenScope::Read.allows_write());
511    }
512
513    #[test]
514    fn repository_provider_parses_stable_names() {
515        assert_eq!("github".parse(), Ok(RepositoryProvider::GitHub));
516        assert_eq!("gitea".parse(), Ok(RepositoryProvider::Gitea));
517        assert_eq!("gitlab".parse(), Ok(RepositoryProvider::GitLab));
518        assert_eq!("codeberg".parse(), Ok(RepositoryProvider::Codeberg));
519        assert_eq!("generic".parse(), Ok(RepositoryProvider::Generic));
520        assert_eq!(
521            "bitbucket".parse::<RepositoryProvider>(),
522            Err(RepositoryProviderParseError)
523        );
524    }
525
526    #[test]
527    fn token_signer_debug_redacts_signing_key_material() {
528        let signer = TokenSigner::new(&[1; 32]);
529        assert!(signer.is_ok());
530        let Ok(signer) = signer else {
531            return;
532        };
533
534        let rendered = format!("{signer:?}");
535
536        assert!(!rendered.contains("[1, 2, 3, 4]"));
537        assert!(rendered.contains("***"));
538    }
539
540    #[test]
541    fn token_claims_reject_empty_subject() {
542        let repository =
543            RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
544        assert!(repository.is_ok());
545        let Ok(repository) = repository else {
546            return;
547        };
548        let claims = TokenClaims::new("issuer", " ", TokenScope::Read, repository, 42);
549
550        assert_eq!(claims, Err(TokenClaimsError::EmptySubject));
551    }
552
553    #[test]
554    fn repository_scope_rejects_empty_owner() {
555        let scope = RepositoryScope::new(RepositoryProvider::GitHub, "", "assets", Some("main"));
556
557        assert_eq!(scope, Err(TokenClaimsError::EmptyRepositoryOwner));
558    }
559
560    #[test]
561    fn repository_scope_rejects_oversized_components() {
562        let oversized = "o".repeat(MAX_TOKEN_COMPONENT_BYTES + 1);
563        let scope = RepositoryScope::new(RepositoryProvider::GitHub, &oversized, "assets", None);
564
565        assert_eq!(scope, Err(TokenClaimsError::TooLong));
566    }
567
568    #[test]
569    fn repository_scope_accepts_all_providers() {
570        for provider in [
571            RepositoryProvider::GitHub,
572            RepositoryProvider::Gitea,
573            RepositoryProvider::GitLab,
574            RepositoryProvider::Codeberg,
575            RepositoryProvider::Generic,
576        ] {
577            let scope = RepositoryScope::new(provider, "owner", "repo", Some("main"));
578            assert!(scope.is_ok(), "failed for {provider:?}");
579            if let Ok(scope) = scope {
580                assert_eq!(scope.provider(), provider);
581                assert_eq!(scope.owner(), "owner");
582                assert_eq!(scope.name(), "repo");
583                assert_eq!(scope.revision(), Some("main"));
584            }
585        }
586    }
587
588    #[test]
589    fn repository_scope_accepts_missing_revision() {
590        let scope = RepositoryScope::new(RepositoryProvider::GitHub, "owner", "repo", None);
591        assert!(scope.is_ok());
592        if let Ok(scope) = scope {
593            assert_eq!(scope.revision(), None);
594        }
595    }
596
597    #[test]
598    fn repository_scope_rejects_empty_revision() {
599        let scope = RepositoryScope::new(RepositoryProvider::GitHub, "owner", "repo", Some(""));
600        assert_eq!(scope, Err(TokenClaimsError::EmptyRevision));
601    }
602
603    #[test]
604    fn repository_scope_rejects_empty_name() {
605        let scope = RepositoryScope::new(RepositoryProvider::GitHub, "owner", "", None);
606        assert_eq!(scope, Err(TokenClaimsError::EmptyRepositoryName));
607    }
608
609    #[test]
610    fn repository_scope_rejects_control_characters_in_owner() {
611        let scope = RepositoryScope::new(RepositoryProvider::GitHub, "own\ner", "repo", None);
612        assert_eq!(scope, Err(TokenClaimsError::ControlCharacter));
613    }
614
615    #[test]
616    fn repository_scope_rejects_control_characters_in_name() {
617        let scope = RepositoryScope::new(RepositoryProvider::GitHub, "owner", "rep\0o", None);
618        assert_eq!(scope, Err(TokenClaimsError::ControlCharacter));
619    }
620
621    #[test]
622    fn repository_scope_rejects_control_characters_in_revision() {
623        let scope = RepositoryScope::new(
624            RepositoryProvider::GitHub,
625            "owner",
626            "repo",
627            Some("main\x00"),
628        );
629        assert_eq!(scope, Err(TokenClaimsError::ControlCharacter));
630    }
631
632    #[test]
633    fn token_claims_serialization_roundtrips() {
634        let repository =
635            RepositoryScope::new(RepositoryProvider::GitLab, "group", "project", Some("dev"));
636        assert!(repository.is_ok());
637        let Ok(repository) = repository else {
638            return;
639        };
640        let claims = TokenClaims::new(
641            "shardline-server",
642            "ci-user",
643            TokenScope::Write,
644            repository,
645            2_000_000_000,
646        );
647        assert!(claims.is_ok());
648        let Ok(claims) = claims else {
649            return;
650        };
651
652        let serialized = serde_json::to_vec(&claims);
653        assert!(serialized.is_ok());
654        let Ok(serialized) = serialized else {
655            return;
656        };
657        let deserialized: Result<TokenClaims, _> = serde_json::from_slice(&serialized);
658        assert!(deserialized.is_ok());
659        let Ok(deserialized) = deserialized else {
660            return;
661        };
662
663        assert_eq!(deserialized, claims);
664        assert_eq!(deserialized.issuer(), "shardline-server");
665        assert_eq!(deserialized.subject(), "ci-user");
666        assert_eq!(deserialized.scope(), TokenScope::Write);
667        assert_eq!(deserialized.expires_at_unix_seconds(), 2_000_000_000);
668    }
669
670    #[test]
671    fn token_claims_reject_oversized_subject() {
672        let repository =
673            RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
674        assert!(repository.is_ok());
675        let Ok(repository) = repository else {
676            return;
677        };
678        let oversized = "s".repeat(MAX_TOKEN_COMPONENT_BYTES + 1);
679        let claims = TokenClaims::new("issuer", &oversized, TokenScope::Read, repository, 42);
680
681        assert_eq!(claims, Err(TokenClaimsError::TooLong));
682    }
683
684    #[test]
685    fn token_roundtrips_through_sign_and_verify() {
686        let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!");
687        assert!(signer.is_ok());
688        let Ok(signer) = signer else {
689            return;
690        };
691        let repository =
692            RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
693        assert!(repository.is_ok());
694        let Ok(repository) = repository else {
695            return;
696        };
697        let claims = TokenClaims::new(
698            "issuer",
699            "provider-user-1",
700            TokenScope::Write,
701            repository,
702            120,
703        );
704        assert!(claims.is_ok());
705        let Ok(claims) = claims else {
706            return;
707        };
708
709        let token = signer.sign(&claims);
710        assert!(token.is_ok());
711        let Ok(token) = token else {
712            return;
713        };
714        let verified = signer.verify_at(&token, 119);
715
716        assert!(verified.is_ok());
717        let Ok(verified) = verified else {
718            return;
719        };
720        assert_eq!(verified, claims);
721    }
722
723    #[test]
724    fn token_verify_rejects_tampering() {
725        let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!");
726        assert!(signer.is_ok());
727        let Ok(signer) = signer else {
728            return;
729        };
730        let repository =
731            RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
732        assert!(repository.is_ok());
733        let Ok(repository) = repository else {
734            return;
735        };
736        let claims = TokenClaims::new(
737            "issuer",
738            "provider-user-1",
739            TokenScope::Read,
740            repository,
741            120,
742        );
743        assert!(claims.is_ok());
744        let Ok(claims) = claims else {
745            return;
746        };
747        let token = signer.sign(&claims);
748        assert!(token.is_ok());
749        let Ok(token) = token else {
750            return;
751        };
752        let Some((payload, _signature)) = token.split_once('.') else {
753            return;
754        };
755        let tampered = format!("{payload}.{}", "00".repeat(32));
756
757        assert!(matches!(
758            signer.verify_at(&tampered, 119),
759            Err(TokenCodecError::InvalidSignature)
760        ));
761    }
762
763    #[test]
764    fn token_verify_rejects_oversized_token_before_hex_decoding() {
765        let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!");
766        assert!(signer.is_ok());
767        let Ok(signer) = signer else {
768            return;
769        };
770        let token = format!(
771            "{}.{}",
772            "a".repeat(MAX_TOKEN_PAYLOAD_HEX_BYTES + 1),
773            "0".repeat(TOKEN_SIGNATURE_HEX_BYTES)
774        );
775
776        assert!(matches!(
777            signer.verify_at(&token, 119),
778            Err(TokenCodecError::InvalidFormat)
779        ));
780    }
781
782    #[test]
783    fn token_verify_rejects_oversized_signature_before_hex_decoding() {
784        let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!");
785        assert!(signer.is_ok());
786        let Ok(signer) = signer else {
787            return;
788        };
789        let token = format!("{}.{}", "7b7d", "0".repeat(TOKEN_SIGNATURE_HEX_BYTES + 1));
790
791        assert!(matches!(
792            signer.verify_at(&token, 119),
793            Err(TokenCodecError::InvalidFormat)
794        ));
795    }
796
797    #[test]
798    fn token_verify_rejects_expired_tokens() {
799        let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!");
800        assert!(signer.is_ok());
801        let Ok(signer) = signer else {
802            return;
803        };
804        let repository =
805            RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
806        assert!(repository.is_ok());
807        let Ok(repository) = repository else {
808            return;
809        };
810        let claims = TokenClaims::new(
811            "issuer",
812            "provider-user-1",
813            TokenScope::Read,
814            repository,
815            120,
816        );
817        assert!(claims.is_ok());
818        let Ok(claims) = claims else {
819            return;
820        };
821        let token = signer.sign(&claims);
822        assert!(token.is_ok());
823        let Ok(token) = token else {
824            return;
825        };
826
827        assert!(matches!(
828            signer.verify_at(&token, 121),
829            Err(TokenCodecError::Expired)
830        ));
831    }
832
833    #[test]
834    fn token_claims_error_display_all_variants() {
835        let cases: &[(TokenClaimsError, &str)] = &[
836            (TokenClaimsError::EmptyIssuer, "empty"),
837            (TokenClaimsError::EmptySubject, "empty"),
838            (TokenClaimsError::EmptyRepositoryOwner, "empty"),
839            (TokenClaimsError::EmptyRepositoryName, "empty"),
840            (TokenClaimsError::EmptyRevision, "empty"),
841            (TokenClaimsError::ControlCharacter, "control"),
842            (TokenClaimsError::TooLong, "length"),
843        ];
844        for (error, substring) in cases {
845            let msg = error.to_string();
846            assert!(!msg.is_empty(), "empty display for {error:?}");
847            assert!(
848                msg.contains(substring),
849                "expected '{substring}' in '{msg}' from {error:?}"
850            );
851        }
852    }
853
854    #[test]
855    fn token_codec_error_display_variants() {
856        let msg = TokenCodecError::EmptySigningKey("test".to_owned()).to_string();
857        assert!(!msg.is_empty());
858        assert!(msg.contains("empty"));
859
860        let msg = TokenCodecError::SigningKeyTooShort { actual_bytes: 4 }.to_string();
861        assert!(!msg.is_empty());
862        assert!(msg.contains("4"));
863
864        let msg = TokenCodecError::InvalidFormat.to_string();
865        assert!(!msg.is_empty());
866        assert!(msg.contains("format"));
867
868        let msg = TokenCodecError::InvalidSignature.to_string();
869        assert!(!msg.is_empty());
870        assert!(msg.contains("signature"));
871
872        let msg = TokenCodecError::Expired.to_string();
873        assert!(!msg.is_empty());
874        assert!(msg.contains("expired"));
875
876        let json_err = serde_json::from_str::<serde_json::Value>("bad").unwrap_err();
877        let msg = TokenCodecError::Json(json_err).to_string();
878        assert!(!msg.is_empty());
879        assert!(msg.contains("json"));
880
881        let msg = TokenCodecError::InvalidHex(hex::FromHexError::InvalidStringLength).to_string();
882        assert!(!msg.is_empty());
883        assert!(msg.contains("hex") || !msg.is_empty());
884    }
885
886    #[test]
887    fn token_claims_reject_empty_issuer() {
888        let repository =
889            RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"));
890        assert!(repository.is_ok());
891        let Ok(repository) = repository else {
892            return;
893        };
894        let claims = TokenClaims::new("", "subject", TokenScope::Read, repository, 42);
895
896        assert_eq!(claims, Err(TokenClaimsError::EmptyIssuer));
897    }
898
899    #[test]
900    fn token_signer_rejects_short_key() {
901        let short_key = [0u8; 4];
902        let signer = TokenSigner::new(&short_key);
903        assert!(matches!(
904            signer,
905            Err(TokenCodecError::SigningKeyTooShort { actual_bytes: 4 })
906        ));
907    }
908
909    #[test]
910    fn token_signer_rejects_empty_key() {
911        let signer = TokenSigner::new(&[]);
912        assert!(matches!(signer, Err(TokenCodecError::EmptySigningKey(_))));
913    }
914
915    #[test]
916    fn repository_provider_as_str_returns_expected_values() {
917        assert_eq!(RepositoryProvider::GitHub.as_str(), "github");
918        assert_eq!(RepositoryProvider::Gitea.as_str(), "gitea");
919        assert_eq!(RepositoryProvider::GitLab.as_str(), "gitlab");
920        assert_eq!(RepositoryProvider::Codeberg.as_str(), "codeberg");
921        assert_eq!(RepositoryProvider::Generic.as_str(), "generic");
922    }
923
924    #[test]
925    fn repository_provider_parse_error_display() {
926        let msg = RepositoryProviderParseError.to_string();
927        assert!(!msg.is_empty());
928        assert!(msg.contains("provider"));
929    }
930
931    // ── TokenClaims field accessors ──────────────────────────────────────
932
933    #[test]
934    fn token_claims_field_accessors() {
935        let repository =
936            RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"))
937                .unwrap();
938        let claims =
939            TokenClaims::new("issuer", "subject", TokenScope::Write, repository, 100).unwrap();
940        assert_eq!(claims.issuer(), "issuer");
941        assert_eq!(claims.subject(), "subject");
942        assert_eq!(claims.scope(), TokenScope::Write);
943        assert_eq!(claims.expires_at_unix_seconds(), 100);
944        assert_eq!(claims.repository().owner(), "team");
945    }
946
947    // ── TokenScope exhaustive ────────────────────────────────────────────
948
949    #[test]
950    fn token_scope_read_allows_read_not_write() {
951        assert!(TokenScope::Read.allows_read());
952        assert!(!TokenScope::Read.allows_write());
953    }
954
955    #[test]
956    fn token_scope_write_allows_both() {
957        assert!(TokenScope::Write.allows_read());
958        assert!(TokenScope::Write.allows_write());
959    }
960
961    // ── TokenClaims::new validation ──────────────────────────────────────
962
963    #[test]
964    fn token_claims_rejects_control_characters_in_issuer() {
965        let repository =
966            RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"))
967                .unwrap();
968        let claims = TokenClaims::new("issuer\x00", "subject", TokenScope::Read, repository, 100);
969        assert_eq!(claims, Err(TokenClaimsError::ControlCharacter));
970    }
971
972    #[test]
973    fn token_claims_rejects_control_characters_in_subject() {
974        let repository =
975            RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"))
976                .unwrap();
977        let claims = TokenClaims::new("issuer", "sub\nject", TokenScope::Read, repository, 100);
978        assert_eq!(claims, Err(TokenClaimsError::ControlCharacter));
979    }
980
981    #[test]
982    fn token_claims_rejects_oversized_issuer() {
983        let repository =
984            RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"))
985                .unwrap();
986        let oversized = "i".repeat(MAX_TOKEN_COMPONENT_BYTES + 1);
987        let claims = TokenClaims::new(&oversized, "subject", TokenScope::Read, repository, 100);
988        assert_eq!(claims, Err(TokenClaimsError::TooLong));
989    }
990
991    // ── TokenSigner verification edge cases ──────────────────────────────
992
993    #[test]
994    fn token_signer_verify_rejects_empty_token() {
995        let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!").unwrap();
996        let result = signer.verify_at("", 100);
997        assert!(matches!(result, Err(TokenCodecError::InvalidFormat)));
998    }
999
1000    #[test]
1001    fn token_signer_verify_rejects_token_without_dot() {
1002        let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!").unwrap();
1003        let result = signer.verify_at("justhexwithoutdot", 100);
1004        assert!(matches!(result, Err(TokenCodecError::InvalidFormat)));
1005    }
1006
1007    #[test]
1008    fn split_token_rejects_empty_signature_and_extra_separator() {
1009        assert!(matches!(
1010            split_token("aa."),
1011            Err(TokenCodecError::InvalidFormat)
1012        ));
1013        assert!(matches!(
1014            split_token("aa.bb.cc"),
1015            Err(TokenCodecError::InvalidFormat)
1016        ));
1017    }
1018
1019    #[test]
1020    fn format_signed_token_enforces_shared_length_limit() {
1021        let maximum_payload_bytes = (MAX_TOKEN_STRING_BYTES - 1 - 2) / 2;
1022        let token = format_signed_token(&vec![1_u8; maximum_payload_bytes], &[2_u8]);
1023        assert!(token.is_ok());
1024        assert!(token.unwrap().len() <= MAX_TOKEN_STRING_BYTES);
1025
1026        let oversized = format_signed_token(&vec![1_u8; maximum_payload_bytes + 1], &[2_u8]);
1027        assert!(matches!(oversized, Err(TokenCodecError::InvalidFormat)));
1028    }
1029
1030    #[test]
1031    fn format_signed_token_rejects_empty_segments() {
1032        assert!(matches!(
1033            format_signed_token(&[], &[1_u8]),
1034            Err(TokenCodecError::InvalidFormat)
1035        ));
1036        assert!(matches!(
1037            format_signed_token(&[1_u8], &[]),
1038            Err(TokenCodecError::InvalidFormat)
1039        ));
1040    }
1041
1042    #[test]
1043    fn token_signer_verify_rejects_too_long_token() {
1044        let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!").unwrap();
1045        let long_token = format!(
1046            "{}.{}",
1047            "a".repeat(MAX_TOKEN_PAYLOAD_HEX_BYTES),
1048            "b".repeat(TOKEN_SIGNATURE_HEX_BYTES + 1)
1049        );
1050        let result = signer.verify_at(&long_token, 100);
1051        assert!(matches!(result, Err(TokenCodecError::InvalidFormat)));
1052    }
1053
1054    #[test]
1055    fn token_signer_verify_rejects_invalid_hex_payload() {
1056        let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!").unwrap();
1057        let result = signer.verify_at(
1058            "zzzz.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1059            100,
1060        );
1061        assert!(matches!(result, Err(TokenCodecError::InvalidHex(_))));
1062    }
1063
1064    #[test]
1065    fn token_signer_sign_and_verify_now() {
1066        // verify_now uses the current wall clock — sign with a future expiry
1067        let signer = TokenSigner::new(b"test-signing-key-32-bytes-long!!").unwrap();
1068        let repository =
1069            RepositoryScope::new(RepositoryProvider::GitHub, "team", "assets", Some("main"))
1070                .unwrap();
1071        let far_future = 2_000_000_000; // well past 2025
1072        let claims = TokenClaims::new(
1073            "issuer",
1074            "subject",
1075            TokenScope::Read,
1076            repository,
1077            far_future,
1078        )
1079        .unwrap();
1080        // verify_now should succeed since expiration is far in the future
1081        // (the current unix timestamp is around 1.7-1.8 billion as of 2025)
1082        let token = signer.sign(&claims).unwrap();
1083        let verified = signer.verify_now(&token);
1084        assert!(verified.is_ok(), "verify_now failed: {:?}", verified.err());
1085    }
1086
1087    // ── RepositoryProvider as_str exhaustive ─────────────────────────────
1088
1089    #[test]
1090    fn repository_provider_as_str_all_variants() {
1091        assert_eq!(RepositoryProvider::GitHub.as_str(), "github");
1092        assert_eq!(RepositoryProvider::Gitea.as_str(), "gitea");
1093        assert_eq!(RepositoryProvider::GitLab.as_str(), "gitlab");
1094        assert_eq!(RepositoryProvider::Codeberg.as_str(), "codeberg");
1095        assert_eq!(RepositoryProvider::Generic.as_str(), "generic");
1096    }
1097
1098    // ── RepositoryScope accessors ────────────────────────────────────────
1099
1100    #[test]
1101    fn repository_scope_accessors() {
1102        let scope =
1103            RepositoryScope::new(RepositoryProvider::GitLab, "group", "project", Some("dev"))
1104                .unwrap();
1105        assert_eq!(scope.provider(), RepositoryProvider::GitLab);
1106        assert_eq!(scope.owner(), "group");
1107        assert_eq!(scope.name(), "project");
1108        assert_eq!(scope.revision(), Some("dev"));
1109    }
1110
1111    #[test]
1112    fn repository_scope_allows_missing_revision() {
1113        let scope = RepositoryScope::new(RepositoryProvider::Gitea, "owner", "repo", None).unwrap();
1114        assert_eq!(scope.revision(), None);
1115    }
1116
1117    // ── RepositoryProvider parse error ───────────────────────────────────
1118
1119    #[test]
1120    fn repository_provider_parse_error_debug_non_empty() {
1121        let debug = format!("{:?}", RepositoryProviderParseError);
1122        assert!(!debug.is_empty());
1123    }
1124
1125    // ── TokenCodecError derivation ───────────────────────────────────────
1126
1127    #[test]
1128    fn token_codec_error_from_json_error() {
1129        let json_err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
1130        let err = TokenCodecError::Json(json_err);
1131        let msg = err.to_string();
1132        assert!(!msg.is_empty());
1133    }
1134
1135    #[test]
1136    fn token_codec_error_from_hex_error() {
1137        let hex_err = hex::FromHexError::InvalidStringLength;
1138        let err = TokenCodecError::InvalidHex(hex_err);
1139        let msg = err.to_string();
1140        assert!(!msg.is_empty());
1141    }
1142
1143    #[test]
1144    fn token_codec_error_empty_signing_key_display() {
1145        let msg = TokenCodecError::EmptySigningKey("test".to_owned()).to_string();
1146        assert!(msg.contains("empty"));
1147    }
1148
1149    #[test]
1150    fn token_codec_error_signing_key_too_short_display() {
1151        let msg = TokenCodecError::SigningKeyTooShort { actual_bytes: 4 }.to_string();
1152        assert!(msg.contains("4"));
1153        assert!(msg.contains("32"));
1154    }
1155
1156    #[test]
1157    fn token_codec_error_claims_display() {
1158        let claims_err = TokenClaimsError::ControlCharacter;
1159        let err = TokenCodecError::Claims(claims_err);
1160        let msg = err.to_string();
1161        assert!(
1162            msg.contains("invalid"),
1163            "expected 'invalid' in Claims display, got: {msg}"
1164        );
1165    }
1166}