Skip to main content

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}