tf_registry/lib.rs
1//! A Terraform Provider and Module Registry implementation backed by GitHub Releases.
2//!
3//! This crate provides a complete implementation of both the
4//! [Terraform Provider Registry Protocol](https://developer.hashicorp.com/terraform/internals/provider-registry-protocol)
5//! and the [Terraform Module Registry Protocol](https://developer.hashicorp.com/terraform/internals/module-registry-protocol),
6//! allowing you to host Terraform providers and modules using GitHub Releases as the storage backend.
7//!
8//! # Features
9//!
10//! - **GitHub Authentication**: Supports both Personal Access Tokens and GitHub App authentication.
11//! - **GPG Signing**: Provider package verification using GPG signatures.
12//! - **Provider Registry**: Full compliance with Terraform's Provider Registry Protocol.
13//! - **Module Registry**: Full compliance with Terraform's Module Registry Protocol, supporting both public and private repositories.
14//! - **Flexible Configuration**: Builder pattern for easy setup and customization.
15//!
16//! # Quick Start
17//!
18//! ```rust,no_run
19//! use tf_registry::{Registry, EncodingKey};
20//!
21//! #[tokio::main]
22//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
23//! // Build the registry with Personal Access Token
24//! let registry = Registry::builder()
25//! .github_token("ghp_your_github_token")
26//! .gpg_signing_key(
27//! "ABCD1234EFGH5678".to_string(),
28//! EncodingKey::Pem("-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----".to_string())
29//! )
30//! .build()
31//! .await?;
32//!
33//! // Create an Axum router
34//! let app = registry.create_router();
35//!
36//! // Start the server
37//! let listener = tokio::net::TcpListener::bind("0.0.0.0:9000").await?;
38//! axum::serve(listener, app).await?;
39//!
40//! Ok(())
41//! }
42//! ```
43//!
44//! # GitHub PAT Authentication
45//!
46//! The simplest way to authenticate. Suitable for local development or single-user setups:
47//!
48//! ```rust,no_run
49//! use tf_registry::{Registry, EncodingKey};
50//!
51//! # #[tokio::main]
52//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
53//! let registry = Registry::builder()
54//! .github_token(std::env::var("GH_TOKEN")?)
55//! .gpg_signing_key(
56//! "ABCD1234EFGH5678".to_string(),
57//! EncodingKey::Pem(std::env::var("GPG_PUBLIC_KEY")?)
58//! )
59//! .build()
60//! .await?;
61//! # Ok(())
62//! # }
63//! ```
64//!
65//! # GitHub App Authentication
66//!
67//! Recommended for production deployments due to better security, higher rate limits,
68//! and fine-grained repository access control:
69//!
70//! ```rust,no_run
71//! use tf_registry::{Registry, EncodingKey};
72//!
73//! # #[tokio::main]
74//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
75//! let registry = Registry::builder()
76//! .github_app(
77//! 123456, // Your GitHub App ID
78//! EncodingKey::Base64("base64_encoded_private_key".to_string())
79//! )
80//! .gpg_signing_key(
81//! "ABCD1234EFGH5678".to_string(),
82//! EncodingKey::Pem(std::env::var("GPG_PUBLIC_KEY")?)
83//! )
84//! .build()
85//! .await?;
86//! # Ok(())
87//! # }
88//! ```
89//!
90//! # Custom Configuration
91//!
92//! ```rust,no_run
93//! # use tf_registry::{Registry, EncodingKey};
94//! # #[tokio::main]
95//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
96//! let registry = Registry::builder()
97//! .github_token("ghp_token")
98//! .gpg_signing_key("KEY_ID".to_string(), EncodingKey::Pem("...".to_string()))
99//! .providers_api_base_url("/custom/terraform/providers/v1/")
100//! .modules_api_base_url("/custom/terraform/modules/v1/")
101//! .build()
102//! .await?;
103//! # Ok(())
104//! # }
105//! ```
106//!
107//! # GitHub Release Requirements
108//!
109//! ## Providers
110//!
111//! For providers, each GitHub release must include:
112//!
113//! 1. **Provider packages**: `terraform-provider-{name}_{version}_{os}_{arch}.zip`
114//! 2. **Checksums file**: `terraform-provider-{name}_{version}_SHA256SUMS`
115//! 3. **Signature file**: `terraform-provider-{name}_{version}_SHA256SUMS.sig`
116//! 4. **Registry manifest**: A `terraform-registry-manifest.json` file in the repository root
117//!
118//! ## Modules
119//!
120//! For modules, the registry maps each GitHub Release tag to a module version. No special
121//! release assets are required — the module source code is downloaded directly from the
122//! GitHub tarball API. To access private repositories, configure the registry with a PAT or
123//! GitHub App that has read access to those repos.
124//!
125//! # Example Terraform Usage
126//!
127//! ## Provider
128//!
129//! ```hcl
130//! terraform {
131//! required_providers {
132//! myprovider = {
133//! source = "registry.example.com/myorg/myprovider"
134//! version = "1.0.0"
135//! }
136//! }
137//! }
138//! ```
139//!
140//! ## Module
141//!
142//! The module source address follows the format `<registry>/<namespace>/<name>/<system>`,
143//! where `system` is the name of the remote system the module targets (e.g. `aws`, `azurerm`,
144//! `kubernetes`). It commonly matches a provider type name but can be any keyword that makes
145//! sense for your registry's organisation.
146//!
147//! ```hcl
148//! module "mymodule" {
149//! source = "registry.example.com/myorg/mymodule/aws"
150//! version = "1.0.0"
151//! }
152//! ```
153
154pub use error::RegistryError;
155
156mod discovery;
157mod error;
158mod models;
159mod modules;
160mod providers;
161
162use axum::{Router, routing::get};
163use base64::prelude::*;
164use octocrab::Octocrab;
165use octocrab::models::AppId;
166use octocrab::service::middleware::base_uri::BaseUriLayer;
167use octocrab::service::middleware::extra_headers::ExtraHeadersLayer;
168use secrecy::SecretString;
169use std::fmt;
170use std::sync::Arc;
171use tower::ServiceBuilder;
172use tower_http::trace::{
173 DefaultMakeSpan, DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer,
174};
175use tracing::Level;
176
177/// Default base URL path for the Terraform Provider Registry API endpoints.
178///
179/// This follows the Terraform Provider Registry Protocol specification.
180const PROVIDERS_API_BASE_URL: &str = "/terraform/providers/v1/";
181
182/// Default base URL path for the Terraform Provider Registry API endpoints.
183///
184/// This follows the Terraform Provider Registry Protocol specification.
185const MODULES_API_BASE_URL: &str = "/terraform/modules/v1/";
186
187// ============================================================================
188// Registry (Main Public Struct)
189// ============================================================================
190
191/// The main Terraform Provider Registry.
192///
193/// This struct represents a configured Terraform Provider Registry that serves
194/// provider packages from GitHub Releases. It implements the complete
195/// [Terraform Provider Registry Protocol](https://developer.hashicorp.com/terraform/internals/provider-registry-protocol).
196///
197/// # Creating a Registry
198///
199/// Use the [`Registry::builder()`] method to create a new registry:
200///
201/// ```rust,no_run
202/// # use tf_registry::{Registry, EncodingKey};
203/// # #[tokio::main]
204/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
205/// let registry = Registry::builder()
206/// .github_token("ghp_your_token")
207/// .gpg_signing_key(
208/// "KEY_ID".to_string(),
209/// EncodingKey::Pem("public_key".to_string())
210/// )
211/// .build()
212/// .await?;
213/// # Ok(())
214/// # }
215/// ```
216///
217/// # Creating a Router
218///
219/// Once built, create an Axum router with [`create_router()`](Registry::create_router):
220///
221/// ```rust,no_run
222/// # use tf_registry::Registry;
223/// # async fn example(registry: Registry) {
224/// let app = registry.create_router();
225/// # }
226/// ```
227pub struct Registry {
228 state: Arc<AppState>,
229}
230
231impl fmt::Debug for Registry {
232 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233 f.debug_struct("Registry")
234 .field("state", &self.state)
235 .finish()
236 }
237}
238
239impl Registry {
240 /// Creates a new [`RegistryBuilder`] for configuring a Registry.
241 ///
242 /// This is the recommended way to create a new Registry instance.
243 ///
244 /// # Examples
245 ///
246 /// ```rust,no_run
247 /// # use tf_registry::{Registry, EncodingKey};
248 /// # #[tokio::main]
249 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
250 /// let registry = Registry::builder()
251 /// .github_token("ghp_token")
252 /// .gpg_signing_key("KEY_ID".to_string(), EncodingKey::Pem("...".to_string()))
253 /// .build()
254 /// .await?;
255 /// # Ok(())
256 /// # }
257 /// ```
258 pub fn builder() -> RegistryBuilder {
259 RegistryBuilder::default()
260 }
261
262 /// Creates an Axum [`Router`] configured with this Registry's routes and state.
263 ///
264 /// The router includes the following endpoints:
265 ///
266 /// - `/.well-known/terraform.json` - Service discovery
267 /// - `/{base_url}/{namespace}/{type}/versions` - List available provider versions
268 /// - `/{base_url}/{namespace}/{type}/{version}/download/{os}/{arch}` - Download provider package
269 ///
270 /// # Tracing
271 ///
272 /// The router includes HTTP tracing middleware that logs all requests and responses
273 /// at the `DEBUG` level, including headers.
274 ///
275 /// # Examples
276 ///
277 /// ```rust,no_run
278 /// # use tf_registry::Registry;
279 /// # async fn example(registry: Registry) -> Result<(), Box<dyn std::error::Error>> {
280 /// let app = registry.create_router();
281 ///
282 /// let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
283 /// axum::serve(listener, app).await?;
284 /// # Ok(())
285 /// # }
286 /// ```
287 pub fn create_router(&self) -> Router {
288 let middleware = ServiceBuilder::new().layer(
289 TraceLayer::new_for_http()
290 .make_span_with(
291 DefaultMakeSpan::new()
292 .include_headers(true)
293 .level(Level::DEBUG),
294 )
295 .on_request(DefaultOnRequest::new().level(Level::DEBUG))
296 .on_response(
297 DefaultOnResponse::new()
298 .include_headers(true)
299 .level(Level::DEBUG),
300 )
301 .on_failure(DefaultOnFailure::new()),
302 );
303
304 // See more https://developer.hashicorp.com/terraform/internals/provider-registry-protocol
305 let providers_api = Router::new()
306 .route(
307 "/{namespace}/{provider_type}/versions",
308 get(providers::list_provider_versions),
309 )
310 .route(
311 "/{namespace}/{provider_type}/{version}/download/{os}/{arch}",
312 get(providers::find_provider_package),
313 );
314
315 // See more https://developer.hashicorp.com/terraform/internals/module-registry-protocol
316 let modules_api = Router::new()
317 .route(
318 "/{namespace}/{name}/{system}/versions",
319 get(modules::list_module_versions),
320 )
321 .route(
322 "/{namespace}/{name}/{system}/{version}/download",
323 get(modules::download_module_version),
324 );
325
326 Router::new()
327 .route("/.well-known/terraform.json", get(discovery::discovery))
328 .nest(&self.state.providers_api_base_url, providers_api)
329 .nest(&self.state.modules_api_base_url, modules_api)
330 .layer(middleware)
331 .with_state(self.state.clone())
332 }
333}
334
335// ============================================================================
336// Internal AppState
337// ============================================================================
338
339/// Internal application state shared across handlers.
340/// This struct contains the configuration and clients needed by the registry
341/// handlers.
342#[derive(Debug)]
343struct AppState {
344 /// Main GitHub API client
345 github: Octocrab,
346 /// Custom GitHub API client configured to not follow redirects.
347 ///
348 /// This client is specifically used for downloading release assets,
349 /// where we need to extract the pre-signed download URL from the
350 /// Location header instead of following the redirect.
351 no_redirect_github: Octocrab,
352 /// The uppercase hexadecimal-formatted ID of the GPG key.
353 gpg_key_id: String,
354 /// The ASCII-armored GPG public key.
355 ///
356 /// This is the full PEM-encoded public key block used to verify
357 /// provider package signatures.
358 gpg_public_key: String,
359 /// Base URL path for the providers API routes.
360 ///
361 /// Default: "/terraform/providers/v1/"
362 providers_api_base_url: String,
363 /// Base URL path for the modules API routes.
364 ///
365 /// Default: "/terraform/modules/v1/"
366 modules_api_base_url: String,
367}
368
369// ============================================================================
370// RegistryBuilder
371// ============================================================================
372
373/// A builder for configuring and creating a [`Registry`].
374///
375/// This builder uses the builder pattern to allow flexible configuration
376/// of the registry before creation. All configuration is validated when
377/// [`build()`](RegistryBuilder::build) is called.
378///
379/// # Required Configuration
380///
381/// - GitHub authentication (via [`github_token()`](RegistryBuilder::github_token) or [`github_app()`](RegistryBuilder::github_app))
382/// - GPG signing key (via [`gpg_signing_key()`](RegistryBuilder::gpg_signing_key))
383///
384/// # Optional Configuration
385///
386/// - Custom providers API base URL via [`providers_api_base_url()`](RegistryBuilder::providers_api_base_url)
387/// - Custom modules API base URL via [`modules_api_base_url()`](RegistryBuilder::modules_api_base_url)
388/// - Custom GitHub base URI via [`github_base_uri()`](RegistryBuilder::github_base_uri) (mainly for testing)
389///
390/// # Examples
391///
392/// ## Basic configuration with Personal Access Token
393///
394/// ```rust,no_run
395/// # use tf_registry::{Registry, EncodingKey};
396/// # #[tokio::main]
397/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
398/// let registry = Registry::builder()
399/// .github_token("ghp_your_token_here")
400/// .gpg_signing_key(
401/// "ABCD1234EFGH5678".to_string(),
402/// EncodingKey::Pem(std::env::var("GPG_PUBLIC_KEY")?)
403/// )
404/// .build()
405/// .await?;
406/// # Ok(())
407/// # }
408/// ```
409///
410/// ## Configuration with GitHub App
411///
412/// ```rust,no_run
413/// # use tf_registry::{Registry, EncodingKey};
414/// # #[tokio::main]
415/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
416/// let registry = Registry::builder()
417/// .github_app(
418/// 123456,
419/// EncodingKey::Base64(std::env::var("GH_APP_PRIVATE_KEY")?)
420/// )
421/// .gpg_signing_key(
422/// "ABCD1234".to_string(),
423/// EncodingKey::Base64(std::env::var("GPG_PUBLIC_KEY")?)
424/// )
425/// .build()
426/// .await?;
427/// # Ok(())
428/// # }
429/// ```
430///
431/// ## Custom providers API URL
432///
433/// ```rust,no_run
434/// # use tf_registry::{Registry, EncodingKey};
435/// # #[tokio::main]
436/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
437/// let registry = Registry::builder()
438/// .github_token("ghp_token")
439/// .gpg_signing_key("KEY".to_string(), EncodingKey::Pem("...".to_string()))
440/// .providers_api_base_url("/custom/api/v1/")
441/// .build()
442/// .await?;
443/// # Ok(())
444/// # }
445/// ```
446///
447/// ## Custom modules API URL
448///
449/// ```rust,no_run
450/// # use tf_registry::{Registry, EncodingKey};
451/// # #[tokio::main]
452/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
453/// let registry = Registry::builder()
454/// .github_token("ghp_token")
455/// .gpg_signing_key("KEY".to_string(), EncodingKey::Pem("...".to_string()))
456/// .modules_api_base_url("/custom/api/v1/")
457/// .build()
458/// .await?;
459/// # Ok(())
460/// # }
461/// ```
462#[derive(Default)]
463pub struct RegistryBuilder {
464 base_uri: Option<String>,
465 auth: Option<GitHubAuth>,
466 gpg: Option<GPGSigningKey>,
467 providers_api_base_url: Option<String>,
468 modules_api_base_url: Option<String>,
469}
470
471impl RegistryBuilder {
472 /// Sets the base URL path for the providers API routes.
473 ///
474 /// The URL will be automatically normalized to ensure it starts and ends with '/'.
475 ///
476 /// # Default
477 ///
478 /// If not set, defaults to `"/terraform/providers/v1/"`.
479 ///
480 /// # Arguments
481 ///
482 /// * `url` - The base URL path.
483 ///
484 /// # Examples
485 ///
486 /// ```rust,no_run
487 /// # use tf_registry::Registry;
488 /// # fn example() {
489 /// // All of these are equivalent:
490 /// Registry::builder().providers_api_base_url("/custom/api/v1/");
491 /// Registry::builder().providers_api_base_url("custom/api/v1");
492 /// Registry::builder().providers_api_base_url("/custom/api/v1");
493 /// # }
494 /// ```
495 pub fn providers_api_base_url(mut self, url: impl Into<String>) -> Self {
496 self.providers_api_base_url = Some(url.into());
497 self
498 }
499
500 /// Sets the base URL path for the modules API routes.
501 ///
502 /// The URL will be automatically normalized to ensure it starts and ends with '/'.
503 ///
504 /// # Default
505 ///
506 /// If not set, defaults to `"/terraform/modules/v1/"`.
507 ///
508 /// # Arguments
509 ///
510 /// * `url` - The base URL path.
511 ///
512 /// # Examples
513 ///
514 /// ```rust,no_run
515 /// # use tf_registry::Registry;
516 /// # fn example() {
517 /// // All of these are equivalent:
518 /// Registry::builder().modules_api_base_url("/custom/api/v1/");
519 /// Registry::builder().modules_api_base_url("custom/api/v1");
520 /// Registry::builder().modules_api_base_url("/custom/api/v1");
521 /// # }
522 /// ```
523 pub fn modules_api_base_url(mut self, url: impl Into<String>) -> Self {
524 self.modules_api_base_url = Some(url.into());
525 self
526 }
527
528 /// Sets the base URI for the GitHub API client.
529 ///
530 /// This is primarily used for testing with mock GitHub API servers.
531 /// In production, you typically don't need to set this.
532 ///
533 /// # Arguments
534 ///
535 /// * `base_uri` - The base URI (e.g., "http://localhost:9000" for testing)
536 ///
537 /// # Examples
538 ///
539 /// ```rust,no_run
540 /// # use tf_registry::Registry;
541 /// # fn example() {
542 /// // For integration testing
543 /// let builder = Registry::builder()
544 /// .github_base_uri("http://localhost:9000".to_string());
545 /// # }
546 /// ```
547 pub fn github_base_uri(mut self, base_uri: String) -> Self {
548 self.base_uri = Some(base_uri);
549 self
550 }
551
552 /// Configures GitHub authentication using a Personal Access Token.
553 ///
554 /// # Requirements
555 ///
556 /// The token must have the following permissions:
557 /// - `repo` scope (to access releases and repository contents)
558 ///
559 /// # Arguments
560 ///
561 /// * `token` - GitHub Personal Access Token (typically starts with "ghp_")
562 ///
563 /// # Examples
564 ///
565 /// ```rust,no_run
566 /// # use tf_registry::Registry;
567 /// # fn example() {
568 /// let builder = Registry::builder()
569 /// .github_token("ghp_your_token_here");
570 /// # }
571 /// ```
572 ///
573 /// # Security Note
574 ///
575 /// Never hardcode tokens in your source code. Use environment variables:
576 ///
577 /// ```rust,no_run
578 /// # use tf_registry::Registry;
579 /// # fn example() -> Result<(), std::env::VarError> {
580 /// let builder = Registry::builder()
581 /// .github_token(std::env::var("GH_TOKEN")?);
582 /// # Ok(())
583 /// # }
584 /// ```
585 pub fn github_token(mut self, token: impl Into<String>) -> Self {
586 self.auth = Some(GitHubAuth::PersonalToken(token.into()));
587 self
588 }
589
590 /// Configures GitHub authentication using a GitHub App.
591 ///
592 /// This method is recommended for production deployments as it provides
593 /// better security and higher rate limits than Personal Access Tokens.
594 ///
595 /// # Requirements
596 ///
597 /// The GitHub App must:
598 /// - Be installed in the organization/account hosting the providers
599 /// - Have `Contents: Read` permission
600 /// - Have `Metadata: Read` permission
601 ///
602 /// # Assumptions
603 ///
604 /// This implementation assumes the GitHub App is installed in only one location
605 /// (organization or account) and automatically uses the first installation found.
606 ///
607 /// # Arguments
608 ///
609 /// * `app_id` - The GitHub App ID (found in app settings)
610 /// * `private_key` - The app's private key, either PEM or base64-encoded
611 ///
612 /// # Examples
613 ///
614 /// ```rust,no_run
615 /// # use tf_registry::{Registry, EncodingKey};
616 /// # fn example() -> Result<(), std::env::VarError> {
617 /// // Using PEM format
618 /// let builder = Registry::builder()
619 /// .github_app(
620 /// 123456,
621 /// EncodingKey::Pem(std::env::var("GH_APP_PRIVATE_KEY")?)
622 /// );
623 ///
624 /// // Using base64-encoded format
625 /// let builder = Registry::builder()
626 /// .github_app(
627 /// 123456,
628 /// EncodingKey::Base64(std::env::var("GH_APP_PRIVATE_KEY_B64")?)
629 /// );
630 /// # Ok(())
631 /// # }
632 /// ```
633 pub fn github_app(mut self, app_id: u64, private_key: EncodingKey) -> Self {
634 self.auth = Some(GitHubAuth::App {
635 app_id,
636 private_key,
637 });
638 self
639 }
640
641 /// Sets the GPG signing key used to verify provider packages.
642 ///
643 /// This information is returned to Terraform clients so they can verify
644 /// the authenticity of downloaded provider packages.
645 ///
646 /// # Arguments
647 ///
648 /// * `key_id` - The uppercase hexadecimal GPG key ID (e.g., "ABCD1234EFGH5678")
649 /// * `public_key` - The ASCII-armored public key, either PEM or base64-encoded
650 ///
651 /// # Examples
652 ///
653 /// ```rust,no_run
654 /// # use tf_registry::{Registry, EncodingKey};
655 /// # fn example() -> Result<(), std::env::VarError> {
656 /// // Using PEM format (ASCII-armored)
657 /// let builder = Registry::builder()
658 /// .gpg_signing_key(
659 /// "ABCD1234EFGH5678".to_string(),
660 /// EncodingKey::Pem(
661 /// "-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----".to_string()
662 /// )
663 /// );
664 ///
665 /// // Using base64-encoded format
666 /// let builder = Registry::builder()
667 /// .gpg_signing_key(
668 /// "ABCD1234EFGH5678".to_string(),
669 /// EncodingKey::Base64(std::env::var("GPG_PUBLIC_KEY_B64")?)
670 /// );
671 /// # Ok(())
672 /// # }
673 /// ```
674 pub fn gpg_signing_key(mut self, key_id: String, public_key: EncodingKey) -> Self {
675 self.gpg = Some(GPGSigningKey { key_id, public_key });
676 self
677 }
678
679 /// Builds the Registry with the configured settings.
680 ///
681 /// This method validates all configuration and creates the necessary GitHub
682 /// API clients. It will return an error if required configuration is missing
683 /// or invalid.
684 ///
685 /// # Errors
686 ///
687 /// Returns [`RegistryError`] errors, for example, GitHub authentication is not configured.
688 ///
689 /// # Examples
690 ///
691 /// ```rust,no_run
692 /// # use tf_registry::{Registry, EncodingKey};
693 /// # #[tokio::main]
694 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
695 /// let registry = Registry::builder()
696 /// .github_token("ghp_token")
697 /// .gpg_signing_key("KEY_ID".to_string(), EncodingKey::Pem("...".to_string()))
698 /// .build()
699 /// .await?;
700 /// # Ok(())
701 /// # }
702 /// ```
703 pub async fn build(self) -> Result<Registry, RegistryError> {
704 // Destructure self to take ownership of all fields
705 let Self {
706 providers_api_base_url,
707 modules_api_base_url,
708 base_uri,
709 auth,
710 gpg,
711 } = self;
712
713 // Validate required fields
714 let auth = auth.ok_or(RegistryError::MissingAuth)?;
715 let gpg = gpg.ok_or(RegistryError::MissingGPGSigningKey)?;
716
717 let providers_api_base_url =
718 Self::normalize_api_url(providers_api_base_url, PROVIDERS_API_BASE_URL)?;
719 let modules_api_base_url =
720 Self::normalize_api_url(modules_api_base_url, MODULES_API_BASE_URL)?;
721
722 // Create GitHub client based on auth configuration
723 let github = Self::create_octocrab_client(base_uri.clone(), &auth).await?;
724
725 // Create custom client for asset downloads
726 let no_redirect_github =
727 Self::create_no_redirect_octocrab_client(base_uri.clone(), &auth).await?;
728
729 // Create the state
730 let state = AppState {
731 github,
732 no_redirect_github,
733 providers_api_base_url,
734 modules_api_base_url,
735 gpg_key_id: gpg.key_id.clone(),
736 gpg_public_key: gpg.get_public_key()?,
737 };
738
739 Ok(Registry {
740 state: Arc::new(state),
741 })
742 }
743
744 // ========================================================================
745 // Helper Methods
746 // ========================================================================
747
748 /// Creates the main Octocrab client with configured GitHub authentication.
749 ///
750 /// For GitHub App authentication, this automatically retrieves an installation
751 /// token by selecting the first installation found.
752 async fn create_octocrab_client(
753 base_uri: Option<String>,
754 auth: &GitHubAuth,
755 ) -> Result<Octocrab, RegistryError> {
756 match auth {
757 GitHubAuth::PersonalToken(token) => {
758 if let Some(val) = base_uri {
759 Octocrab::builder()
760 .base_uri(val)?
761 .personal_token(token.clone())
762 .build()
763 .map_err(RegistryError::GitHubInit)
764 } else {
765 Octocrab::builder()
766 .personal_token(token.clone())
767 .build()
768 .map_err(RegistryError::GitHubInit)
769 }
770 }
771 GitHubAuth::App { app_id, .. } => {
772 let private_key = auth.get_private_key()?;
773 let jwt = jsonwebtoken::EncodingKey::from_rsa_pem(&private_key).unwrap();
774
775 let client = match base_uri {
776 Some(val) => octocrab::Octocrab::builder()
777 .base_uri(val)?
778 .app(AppId(*app_id), jwt)
779 .build()?,
780 None => octocrab::Octocrab::builder()
781 .app(AppId(*app_id), jwt)
782 .build()?,
783 };
784
785 let installations = client
786 .apps()
787 .installations()
788 .send()
789 .await
790 .unwrap()
791 .take_items();
792
793 let (client, _) = client
794 .installation_and_token(installations[0].id)
795 .await
796 .unwrap();
797
798 Ok(client)
799 }
800 }
801 }
802
803 /// Creates a custom Octocrab client configured for asset downloads.
804 ///
805 /// This client differs from the main client in that it:
806 /// - Accepts `application/octet-stream` responses
807 /// - Does not follow HTTP redirects (needed to extract Location headers)
808 ///
809 /// This is necessary because GitHub release asset downloads return a 302 redirect
810 /// to a pre-signed download URL, and we need to extract that URL rather than follow it.
811 async fn create_no_redirect_octocrab_client(
812 base_uri: Option<String>,
813 auth: &GitHubAuth,
814 ) -> Result<Octocrab, RegistryError> {
815 // Disable .https_only() during tests until: https://github.com/LukeMathWalker/wiremock-rs/issues/58 is resolved.
816 // Alternatively we can use conditional compilation to only enable this feature in tests,
817 // but it becomes rather ugly with integration tests.
818 let connector = hyper_rustls::HttpsConnectorBuilder::new()
819 .with_native_roots()
820 .unwrap()
821 .https_or_http()
822 .enable_http1()
823 .build();
824
825 let client =
826 hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
827 .build(connector);
828
829 let parsed_uri: http::Uri = base_uri
830 .unwrap_or_else(|| "https://api.github.com".to_string())
831 .parse()
832 .map_err(|_| RegistryError::InvalidConfig("invalid base URI".into()))?;
833
834 let client = tower::ServiceBuilder::new()
835 .layer(
836 TraceLayer::new_for_http()
837 .make_span_with(
838 DefaultMakeSpan::new()
839 .include_headers(true)
840 .level(Level::DEBUG),
841 )
842 .on_request(DefaultOnRequest::new().level(Level::DEBUG))
843 .on_response(
844 DefaultOnResponse::new()
845 .include_headers(true)
846 .level(Level::DEBUG),
847 )
848 .on_failure(DefaultOnFailure::new()),
849 )
850 .service(client);
851
852 let header_map = Arc::new(vec![
853 (
854 http::header::USER_AGENT,
855 "no-redirect-octocrab".parse().unwrap(),
856 ),
857 (
858 http::header::ACCEPT,
859 "application/octet-stream".parse().unwrap(),
860 ),
861 ]);
862
863 match auth {
864 GitHubAuth::PersonalToken(token) => {
865 let client = octocrab::OctocrabBuilder::new_empty()
866 .with_service(client)
867 .with_layer(&BaseUriLayer::new(parsed_uri))
868 .with_layer(&ExtraHeadersLayer::new(header_map))
869 .with_auth(octocrab::AuthState::AccessToken {
870 token: SecretString::from(token.as_str()),
871 })
872 .build()
873 .unwrap();
874
875 Ok(client)
876 }
877 GitHubAuth::App { app_id, .. } => {
878 let private_key = auth.get_private_key()?;
879 let jwt = jsonwebtoken::EncodingKey::from_rsa_pem(&private_key).unwrap();
880
881 let _client = Octocrab::builder()
882 .app(AppId(*app_id), jwt.clone())
883 .build()?;
884
885 let installations = _client
886 .apps()
887 .installations()
888 .send()
889 .await
890 .map_err(RegistryError::GitHubInit)?
891 .take_items();
892
893 let (_, token) = _client
894 .installation_and_token(installations.first().unwrap().id)
895 .await
896 .map_err(RegistryError::GitHubInit)?;
897
898 let custom_client = octocrab::OctocrabBuilder::new_empty()
899 .with_service(client)
900 .with_layer(&BaseUriLayer::new(parsed_uri.clone()))
901 .with_layer(&ExtraHeadersLayer::new(header_map))
902 .with_auth(octocrab::AuthState::AccessToken { token })
903 .build()
904 .unwrap();
905
906 Ok(custom_client)
907 }
908 }
909 }
910
911 /// Validates and normalizes an API base URL.
912 ///
913 /// Ensures the URL:
914 /// - Is not empty
915 /// - Starts with '/'
916 /// - Ends with '/'
917 fn normalize_api_url(url: Option<String>, default: &str) -> Result<String, RegistryError> {
918 let url = url.unwrap_or_else(|| default.to_string());
919
920 if url.is_empty() {
921 return Err(RegistryError::InvalidConfig(
922 "providers API base URL cannot be empty".into(),
923 ));
924 }
925
926 let url = if !url.starts_with('/') {
927 format!("/{}", url)
928 } else {
929 url
930 };
931
932 let url = if !url.ends_with('/') {
933 format!("{}/", url)
934 } else {
935 url
936 };
937
938 Ok(url)
939 }
940}
941
942// ============================================================================
943// Authentication Configuration
944// ============================================================================
945
946/// Encoding key types
947#[derive(Debug, Clone)]
948pub enum EncodingKey {
949 Pem(String),
950 Base64(String),
951}
952
953/// GitHub authentication methods supported by the registry
954#[derive(Debug, Clone)]
955enum GitHubAuth {
956 /// Personal Access Token authentication
957 PersonalToken(String),
958
959 /// GitHub App authentication.
960 ///
961 /// It auto-selects the first installation to obtain an access token
962 /// therefore assumming the GitHub app is only installed in the GitHub org
963 /// where the provider package is located.
964 App {
965 app_id: u64,
966 private_key: EncodingKey,
967 },
968}
969
970impl GitHubAuth {
971 /// Get the private key, converting from PEM or base64 if necessary
972 fn get_private_key(&self) -> Result<Vec<u8>, RegistryError> {
973 match self {
974 GitHubAuth::PersonalToken(_) => {
975 Err(RegistryError::InvalidConfig("not a GitHub App auth".into()))
976 }
977 GitHubAuth::App { private_key, .. } => match private_key {
978 EncodingKey::Pem(val) => Ok(val.clone().into_bytes()),
979 EncodingKey::Base64(val) => Ok(BASE64_STANDARD.decode(val).unwrap()),
980 },
981 }
982 }
983}
984
985// We assume that a GitHub organisation uses the same GPG key to sign all providers
986// for example, using org-wide GitHub secrets. Therefore, support only one GPG key.
987#[derive(Clone)]
988struct GPGSigningKey {
989 key_id: String,
990 public_key: EncodingKey,
991}
992
993impl GPGSigningKey {
994 ///Get the GPG public key from PEM or base64 string
995 fn get_public_key(&self) -> Result<String, RegistryError> {
996 let GPGSigningKey { public_key, .. } = self;
997 match public_key {
998 EncodingKey::Pem(val) if val.is_empty() => Err(RegistryError::InvalidConfig(
999 "pem gpg public key cannot be empty".into(),
1000 )),
1001 EncodingKey::Base64(val) if val.is_empty() => Err(RegistryError::InvalidConfig(
1002 "base64 gpg public key cannot be empty".into(),
1003 )),
1004 EncodingKey::Pem(val) => Ok(val.clone()),
1005 EncodingKey::Base64(val) => {
1006 let decoded = BASE64_STANDARD.decode(val.trim()).map_err(|_| {
1007 RegistryError::InvalidConfig("invalid base64 gpg public key".into())
1008 })?;
1009 let result = String::from_utf8(decoded).map_err(|_| {
1010 RegistryError::InvalidConfig("invalid decoded base64 gpg public key".into())
1011 })?;
1012 Ok(result)
1013 }
1014 }
1015 }
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020 use super::*;
1021
1022 // ========================================================================
1023 // EncodingKey Tests
1024 // ========================================================================
1025
1026 #[test]
1027 fn test_encoding_key_pem() {
1028 let key = EncodingKey::Pem("test-pem-key".to_string());
1029 match key {
1030 EncodingKey::Pem(s) => assert_eq!(s, "test-pem-key"),
1031 _ => panic!("Expected Pem variant"),
1032 }
1033 }
1034
1035 #[test]
1036 fn test_encoding_key_base64() {
1037 let key = EncodingKey::Base64("dGVzdC1iYXNlNjQta2V5".to_string());
1038 match key {
1039 EncodingKey::Base64(s) => assert_eq!(s, "dGVzdC1iYXNlNjQta2V5"),
1040 _ => panic!("Expected Base64 variant"),
1041 }
1042 }
1043
1044 // ========================================================================
1045 // GitHubAuth Tests
1046 // ========================================================================
1047
1048 #[test]
1049 fn test_github_auth_personal_token() {
1050 let auth = GitHubAuth::PersonalToken("ghp_test123".to_string());
1051 match auth {
1052 GitHubAuth::PersonalToken(token) => assert_eq!(token, "ghp_test123"),
1053 _ => panic!("Expected PersonalToken variant"),
1054 }
1055 }
1056
1057 #[test]
1058 fn test_github_auth_app() {
1059 let auth = GitHubAuth::App {
1060 app_id: 12345,
1061 private_key: EncodingKey::Pem("test-key".to_string()),
1062 };
1063 match auth {
1064 GitHubAuth::App { app_id, .. } => assert_eq!(app_id, 12345),
1065 _ => panic!("Expected App variant"),
1066 }
1067 }
1068
1069 #[test]
1070 fn test_github_auth_get_private_key_pem() {
1071 let auth = GitHubAuth::App {
1072 app_id: 12345,
1073 private_key: EncodingKey::Pem("test-private-key".to_string()),
1074 };
1075
1076 let key = auth.get_private_key().unwrap();
1077 assert_eq!(key, "test-private-key".as_bytes());
1078 }
1079
1080 #[test]
1081 fn test_github_auth_get_private_key_base64() {
1082 let original = "test-private-key";
1083 let encoded = BASE64_STANDARD.encode(original);
1084
1085 let auth = GitHubAuth::App {
1086 app_id: 12345,
1087 private_key: EncodingKey::Base64(encoded),
1088 };
1089
1090 let key = auth.get_private_key().unwrap();
1091 assert_eq!(key, original.as_bytes());
1092 }
1093
1094 #[test]
1095 fn test_github_auth_get_private_key_personal_token_error() {
1096 let auth = GitHubAuth::PersonalToken("token".to_string());
1097 let result = auth.get_private_key();
1098
1099 assert!(result.is_err());
1100 match result.unwrap_err() {
1101 RegistryError::InvalidConfig(msg) => assert_eq!(msg, "not a GitHub App auth"),
1102 _ => panic!("Expected InvalidConfig error"),
1103 }
1104 }
1105
1106 // ========================================================================
1107 // GPGSigningKey Tests
1108 // ========================================================================
1109
1110 #[test]
1111 fn test_gpg_signing_key_get_public_key_pem() {
1112 let gpg = GPGSigningKey {
1113 key_id: "ABCD1234".to_string(),
1114 public_key: EncodingKey::Pem("-----BEGIN PGP PUBLIC KEY BLOCK-----".to_string()),
1115 };
1116
1117 let key = gpg.get_public_key().unwrap();
1118 assert_eq!(key, "-----BEGIN PGP PUBLIC KEY BLOCK-----");
1119 }
1120
1121 #[test]
1122 fn test_gpg_signing_key_get_public_key_base64() {
1123 let original =
1124 "-----BEGIN PGP PUBLIC KEY BLOCK-----\ntest\n-----END PGP PUBLIC KEY BLOCK-----";
1125 let encoded = BASE64_STANDARD.encode(original);
1126
1127 let gpg = GPGSigningKey {
1128 key_id: "ABCD1234".to_string(),
1129 public_key: EncodingKey::Base64(encoded),
1130 };
1131
1132 let key = gpg.get_public_key().unwrap();
1133 assert_eq!(key, original);
1134 }
1135
1136 #[test]
1137 fn test_gpg_signing_key_empty_pem_error() {
1138 let gpg = GPGSigningKey {
1139 key_id: "ABCD1234".to_string(),
1140 public_key: EncodingKey::Pem("".to_string()),
1141 };
1142
1143 let result = gpg.get_public_key();
1144 assert!(result.is_err());
1145 match result.unwrap_err() {
1146 RegistryError::InvalidConfig(msg) => {
1147 assert_eq!(msg, "pem gpg public key cannot be empty")
1148 }
1149 _ => panic!("Expected InvalidConfig error"),
1150 }
1151 }
1152
1153 #[test]
1154 fn test_gpg_signing_key_empty_base64_error() {
1155 let gpg = GPGSigningKey {
1156 key_id: "ABCD1234".to_string(),
1157 public_key: EncodingKey::Base64("".to_string()),
1158 };
1159
1160 let result = gpg.get_public_key();
1161 assert!(result.is_err());
1162 match result.unwrap_err() {
1163 RegistryError::InvalidConfig(msg) => {
1164 assert_eq!(msg, "base64 gpg public key cannot be empty")
1165 }
1166 _ => panic!("Expected InvalidConfig error"),
1167 }
1168 }
1169
1170 #[test]
1171 fn test_gpg_signing_key_invalid_base64() {
1172 let gpg = GPGSigningKey {
1173 key_id: "ABCD1234".to_string(),
1174 public_key: EncodingKey::Base64("not-valid-base64!@#$".to_string()),
1175 };
1176
1177 let result = gpg.get_public_key();
1178 assert!(result.is_err());
1179 match result.unwrap_err() {
1180 RegistryError::InvalidConfig(msg) => {
1181 assert_eq!(msg, "invalid base64 gpg public key")
1182 }
1183 _ => panic!("Expected InvalidConfig error"),
1184 }
1185 }
1186
1187 #[test]
1188 fn test_gpg_signing_key_base64_with_whitespace() {
1189 let original = "test-key";
1190 let encoded = format!(" {} ", BASE64_STANDARD.encode(original));
1191
1192 let gpg = GPGSigningKey {
1193 key_id: "ABCD1234".to_string(),
1194 public_key: EncodingKey::Base64(encoded),
1195 };
1196
1197 let key = gpg.get_public_key().unwrap();
1198 assert_eq!(key, original);
1199 }
1200
1201 // ========================================================================
1202 // RegistryBuilder Tests
1203 // ========================================================================
1204
1205 #[test]
1206 fn test_registry_builder_default() {
1207 let builder = RegistryBuilder::default();
1208 assert!(builder.auth.is_none());
1209 assert!(builder.gpg.is_none());
1210 }
1211
1212 #[test]
1213 fn test_registry_builder_new() {
1214 let builder = Registry::builder();
1215 assert!(builder.auth.is_none());
1216 assert!(builder.gpg.is_none());
1217 }
1218
1219 #[test]
1220 fn test_registry_builder_github_base_uri() {
1221 let builder = Registry::builder().github_base_uri("http://localhost:9000".to_string());
1222
1223 assert!(builder.base_uri.is_some());
1224 let val = builder.base_uri.unwrap();
1225 assert_eq!(val, "http://localhost:9000")
1226 }
1227
1228 #[test]
1229 fn test_registry_builder_github_token() {
1230 let builder = Registry::builder().github_token("ghp_test123");
1231
1232 assert!(builder.auth.is_some());
1233 match builder.auth.unwrap() {
1234 GitHubAuth::PersonalToken(token) => assert_eq!(token, "ghp_test123"),
1235 _ => panic!("Expected PersonalToken"),
1236 }
1237 }
1238
1239 #[test]
1240 fn test_registry_builder_github_app() {
1241 let builder =
1242 Registry::builder().github_app(12345, EncodingKey::Pem("test-key".to_string()));
1243
1244 assert!(builder.auth.is_some());
1245 match builder.auth.unwrap() {
1246 GitHubAuth::App { app_id, .. } => assert_eq!(app_id, 12345),
1247 _ => panic!("Expected App"),
1248 }
1249 }
1250
1251 #[test]
1252 fn test_registry_builder_gpg_signing_key() {
1253 let builder = Registry::builder().gpg_signing_key(
1254 "ABCD1234".to_string(),
1255 EncodingKey::Pem("test-public-key".to_string()),
1256 );
1257
1258 assert!(builder.gpg.is_some());
1259 let gpg = builder.gpg.unwrap();
1260 assert_eq!(gpg.key_id, "ABCD1234");
1261 }
1262
1263 #[test]
1264 fn test_registry_builder_chaining() {
1265 let builder = Registry::builder()
1266 .github_token("ghp_test123")
1267 .gpg_signing_key(
1268 "ABCD1234".to_string(),
1269 EncodingKey::Pem("test-key".to_string()),
1270 );
1271
1272 assert!(builder.auth.is_some());
1273 assert!(builder.gpg.is_some());
1274 }
1275
1276 #[tokio::test]
1277 async fn test_registry_builder_github_base_uri_missing() {
1278 let builder = Registry::builder()
1279 .github_token("ghp_test123")
1280 .gpg_signing_key(
1281 "ABCD1234".to_string(),
1282 EncodingKey::Pem("test-key".to_string()),
1283 );
1284 assert!(builder.base_uri.is_none());
1285 let result = builder.build().await;
1286 assert!(result.is_ok());
1287 }
1288
1289 #[tokio::test]
1290 async fn test_registry_builder_build_missing_auth() {
1291 let builder = Registry::builder().gpg_signing_key(
1292 "ABCD1234".to_string(),
1293 EncodingKey::Pem("test-key".to_string()),
1294 );
1295
1296 let result = builder.build().await;
1297 assert!(result.is_err());
1298 match result.unwrap_err() {
1299 RegistryError::MissingAuth => {}
1300 _ => panic!("Expected MissingAuth error"),
1301 }
1302 }
1303
1304 #[tokio::test]
1305 async fn test_registry_builder_build_missing_gpg() {
1306 let builder = Registry::builder().github_token("ghp_test123");
1307
1308 let result = builder.build().await;
1309 assert!(result.is_err());
1310 match result.unwrap_err() {
1311 RegistryError::MissingGPGSigningKey => {}
1312 _ => panic!("Expected MissingGPGSigningKey error"),
1313 }
1314 }
1315
1316 #[test]
1317 fn test_normalize_api_url_default() {
1318 let result = RegistryBuilder::normalize_api_url(None, PROVIDERS_API_BASE_URL).unwrap();
1319 assert_eq!(result, PROVIDERS_API_BASE_URL);
1320 }
1321
1322 #[test]
1323 fn test_normalize_api_url_with_slashes() {
1324 let result =
1325 RegistryBuilder::normalize_api_url(Some("/custom/api/".to_string()), "").unwrap();
1326 assert_eq!(result, "/custom/api/");
1327 }
1328
1329 #[test]
1330 fn test_normalize_api_url_missing_leading_slash() {
1331 let result =
1332 RegistryBuilder::normalize_api_url(Some("custom/api/".to_string()), "").unwrap();
1333 assert_eq!(result, "/custom/api/");
1334 }
1335
1336 #[test]
1337 fn test_normalize_api_url_missing_trailing_slash() {
1338 let result =
1339 RegistryBuilder::normalize_api_url(Some("/custom/api".to_string()), "").unwrap();
1340 assert_eq!(result, "/custom/api/");
1341 }
1342
1343 #[test]
1344 fn test_normalize_api_url_missing_both_slashes() {
1345 let result =
1346 RegistryBuilder::normalize_api_url(Some("custom/api".to_string()), "").unwrap();
1347 assert_eq!(result, "/custom/api/");
1348 }
1349
1350 #[test]
1351 fn test_normalize_api_url_empty_error() {
1352 let result = RegistryBuilder::normalize_api_url(Some("".to_string()), "");
1353 assert!(result.is_err());
1354 match result.unwrap_err() {
1355 RegistryError::InvalidConfig(msg) => {
1356 assert!(msg.contains("cannot be empty"));
1357 }
1358 _ => panic!("Expected InvalidConfig error"),
1359 }
1360 }
1361
1362 #[tokio::test]
1363 async fn test_registry_builder_with_custom_providers_url() {
1364 let builder = Registry::builder()
1365 .github_token("ghp_test123")
1366 .gpg_signing_key(
1367 "ABCD1234".to_string(),
1368 EncodingKey::Pem("test-key".to_string()),
1369 )
1370 .providers_api_base_url("/custom/providers/v2/");
1371
1372 assert!(builder.providers_api_base_url.is_some());
1373 let result = builder.build().await;
1374 assert!(result.is_ok());
1375 }
1376
1377 #[tokio::test]
1378 async fn test_registry_builder_with_custom_modules_url() {
1379 let builder = Registry::builder()
1380 .github_token("ghp_test123")
1381 .gpg_signing_key(
1382 "ABCD1234".to_string(),
1383 EncodingKey::Pem("test-key".to_string()),
1384 )
1385 .modules_api_base_url("/custom/modules/v2/");
1386
1387 assert!(builder.modules_api_base_url.is_some());
1388 let result = builder.build().await;
1389 assert!(result.is_ok());
1390 }
1391}