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