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;
14pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub enum TokenScope {
22 Read,
24 Write,
26}
27
28impl TokenScope {
29 #[must_use]
31 pub const fn allows_read(self) -> bool {
32 matches!(self, Self::Read | Self::Write)
33 }
34
35 #[must_use]
37 pub const fn allows_write(self) -> bool {
38 matches!(self, Self::Write)
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum RepositoryProvider {
45 GitHub,
47 Gitea,
49 GitLab,
51 Codeberg,
53 Generic,
55}
56
57impl RepositoryProvider {
58 #[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 s.to_ascii_lowercase()
83 .parse()
84 .map_err(serde::de::Error::custom)
85 }
86}
87
88#[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#[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 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 #[must_use]
146 pub const fn provider(&self) -> RepositoryProvider {
147 self.provider
148 }
149
150 #[must_use]
152 pub fn owner(&self) -> &str {
153 &self.owner
154 }
155
156 #[must_use]
158 pub fn name(&self) -> &str {
159 &self.name
160 }
161
162 #[must_use]
164 pub fn revision(&self) -> Option<&str> {
165 self.revision.as_deref()
166 }
167}
168
169#[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 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 #[must_use]
206 pub fn issuer(&self) -> &str {
207 &self.issuer
208 }
209
210 #[must_use]
212 pub fn subject(&self) -> &str {
213 &self.subject
214 }
215
216 #[must_use]
218 pub const fn scope(&self) -> TokenScope {
219 self.scope
220 }
221
222 #[must_use]
224 pub const fn repository(&self) -> &RepositoryScope {
225 &self.repository
226 }
227
228 #[must_use]
230 pub const fn expires_at_unix_seconds(&self) -> u64 {
231 self.expires_at_unix_seconds
232 }
233}
234
235#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
237pub enum TokenClaimsError {
238 #[error("token issuer must not be empty")]
240 EmptyIssuer,
241 #[error("token subject must not be empty")]
243 EmptySubject,
244 #[error("token repository owner must not be empty")]
246 EmptyRepositoryOwner,
247 #[error("token repository name must not be empty")]
249 EmptyRepositoryName,
250 #[error("token revision must not be empty when provided")]
252 EmptyRevision,
253 #[error("token components must not contain control characters")]
255 ControlCharacter,
256 #[error("token component exceeded supported length")]
258 TooLong,
259}
260
261const MIN_SIGNING_KEY_BYTES: usize = 32;
263
264#[derive(Debug, Error)]
266pub enum TokenCodecError {
267 #[error("token signing key must not be empty: {0}")]
269 EmptySigningKey(String),
270 #[error("token signing key must be at least {MIN_SIGNING_KEY_BYTES} bytes, got {actual_bytes}")]
272 SigningKeyTooShort { actual_bytes: usize },
273 #[error("token json operation failed")]
275 Json(#[from] JsonError),
276 #[error("token format was invalid")]
278 InvalidFormat,
279 #[error("token hex segment was invalid")]
281 InvalidHex(#[from] hex::FromHexError),
282 #[error("token signature was invalid")]
284 InvalidSignature,
285 #[error("token has expired")]
287 Expired,
288 #[error("token claims were invalid")]
290 Claims(#[from] TokenClaimsError),
291}
292
293#[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 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 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 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 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
383pub 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
406pub fn encode_token_claims(claims: &TokenClaims) -> Result<Vec<u8>, TokenCodecError> {
412 Ok(to_vec(claims)?)
413}
414
415pub 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
446pub 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 #[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 #[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 #[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 #[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 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; let claims = TokenClaims::new(
1073 "issuer",
1074 "subject",
1075 TokenScope::Read,
1076 repository,
1077 far_future,
1078 )
1079 .unwrap();
1080 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 #[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 #[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 #[test]
1120 fn repository_provider_parse_error_debug_non_empty() {
1121 let debug = format!("{:?}", RepositoryProviderParseError);
1122 assert!(!debug.is_empty());
1123 }
1124
1125 #[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}