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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum TokenScope {
21 Read,
23 Write,
25}
26
27impl TokenScope {
28 #[must_use]
30 pub const fn allows_read(self) -> bool {
31 matches!(self, Self::Read | Self::Write)
32 }
33
34 #[must_use]
36 pub const fn allows_write(self) -> bool {
37 matches!(self, Self::Write)
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum RepositoryProvider {
44 GitHub,
46 Gitea,
48 GitLab,
50 Codeberg,
52 Generic,
54}
55
56impl RepositoryProvider {
57 #[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 s.to_ascii_lowercase()
82 .parse()
83 .map_err(serde::de::Error::custom)
84 }
85}
86
87#[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#[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 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 #[must_use]
145 pub const fn provider(&self) -> RepositoryProvider {
146 self.provider
147 }
148
149 #[must_use]
151 pub fn owner(&self) -> &str {
152 &self.owner
153 }
154
155 #[must_use]
157 pub fn name(&self) -> &str {
158 &self.name
159 }
160
161 #[must_use]
163 pub fn revision(&self) -> Option<&str> {
164 self.revision.as_deref()
165 }
166}
167
168#[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 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 #[must_use]
205 pub fn issuer(&self) -> &str {
206 &self.issuer
207 }
208
209 #[must_use]
211 pub fn subject(&self) -> &str {
212 &self.subject
213 }
214
215 #[must_use]
217 pub const fn scope(&self) -> TokenScope {
218 self.scope
219 }
220
221 #[must_use]
223 pub const fn repository(&self) -> &RepositoryScope {
224 &self.repository
225 }
226
227 #[must_use]
229 pub const fn expires_at_unix_seconds(&self) -> u64 {
230 self.expires_at_unix_seconds
231 }
232}
233
234#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
236pub enum TokenClaimsError {
237 #[error("token issuer must not be empty")]
239 EmptyIssuer,
240 #[error("token subject must not be empty")]
242 EmptySubject,
243 #[error("token repository owner must not be empty")]
245 EmptyRepositoryOwner,
246 #[error("token repository name must not be empty")]
248 EmptyRepositoryName,
249 #[error("token revision must not be empty when provided")]
251 EmptyRevision,
252 #[error("token components must not contain control characters")]
254 ControlCharacter,
255 #[error("token component exceeded supported length")]
257 TooLong,
258}
259
260const MIN_SIGNING_KEY_BYTES: usize = 32;
262
263#[derive(Debug, Error)]
265pub enum TokenCodecError {
266 #[error("token signing key must not be empty: {0}")]
268 EmptySigningKey(String),
269 #[error("token signing key must be at least {MIN_SIGNING_KEY_BYTES} bytes, got {actual_bytes}")]
271 SigningKeyTooShort { actual_bytes: usize },
272 #[error("token json operation failed")]
274 Json(#[from] JsonError),
275 #[error("token format was invalid")]
277 InvalidFormat,
278 #[error("token hex segment was invalid")]
280 InvalidHex(#[from] hex::FromHexError),
281 #[error("token signature was invalid")]
283 InvalidSignature,
284 #[error("token has expired")]
286 Expired,
287 #[error("token claims were invalid")]
289 Claims(#[from] TokenClaimsError),
290}
291
292#[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 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 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 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 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 #[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 #[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 #[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 #[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 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; let claims = TokenClaims::new(
974 "issuer",
975 "subject",
976 TokenScope::Read,
977 repository,
978 far_future,
979 )
980 .unwrap();
981 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 #[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 #[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 #[test]
1021 fn repository_provider_parse_error_debug_non_empty() {
1022 let debug = format!("{:?}", RepositoryProviderParseError);
1023 assert!(!debug.is_empty());
1024 }
1025
1026 #[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}