use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry;
use crate::registry::{
circuit_open_response, nora_base_url, proxy_fetch, proxy_fetch_text, ProxyError,
};
use crate::registry_type::RegistryType;
use crate::secrets::expose_opt;
use crate::AppState;
use axum::{
body::Bytes,
extract::{Path, State},
http::{header, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
routing::get,
Router,
};
use std::time::Duration;
const UPSTREAM_DEFAULT: &str = "https://registry.terraform.io";
pub const INDEX_PATTERN: (&str, &str) = ("terraform/", ".zip");
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/terraform/.well-known/terraform.json",
get(service_discovery),
)
.route(
"/terraform/v1/providers/{ns}/{ptype}/versions",
get(provider_versions),
)
.route(
"/terraform/v1/providers/{ns}/{ptype}/{ver}/download/{os}/{arch}",
get(provider_download_meta),
)
.route(
"/terraform/v1/providers/download/{*path}",
get(provider_download_binary),
)
.route(
"/terraform/v1/modules/{ns}/{name}/{provider}/versions",
get(module_versions),
)
.route(
"/terraform/v1/modules/{ns}/{name}/{provider}/{ver}/download",
get(module_download),
)
.route(
"/terraform/v1/modules/download/{ns}/{name}/{provider}/{ver}/source",
get(module_source_download),
)
.route(
"/terraform/{hostname}/{ns}/{ptype}/index.json",
get(mirror_provider_index),
)
.route(
"/terraform/{hostname}/{ns}/{ptype}/{version_file}",
get(mirror_provider_version),
)
}
async fn service_discovery(State(state): State<AppState>) -> Response {
let base = nora_base_url(&state);
let json = serde_json::json!({
"providers.v1": format!("{}/terraform/v1/providers/", base),
"modules.v1": format!("{}/terraform/v1/modules/", base)
});
(
StatusCode::OK,
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
),
(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=300"),
),
],
serde_json::to_vec(&json).unwrap_or_default(),
)
.into_response()
}
async fn provider_versions(
State(state): State<AppState>,
Path((ns, ptype)): Path<(String, String)>,
) -> Response {
if !is_valid_name(&ns) || !is_valid_name(&ptype) {
return StatusCode::BAD_REQUEST.into_response();
}
let storage_key = format!("terraform/providers/{}/{}/versions.json", ns, ptype);
let cached_data = state.storage.get(&storage_key).await.ok();
if let Some(ref data) = cached_data {
if let Some(meta) = state.storage.stat(&storage_key).await {
if is_within_ttl(meta.modified, state.config.terraform.metadata_ttl) {
state.metrics.record_download("terraform");
state.metrics.record_cache_hit("terraform");
return with_json(data.to_vec());
}
}
}
if crate::curation::is_internal_namespace(
&state.curation().curation_engine,
crate::curation::RegistryType::Terraform,
&format!("{}/{}", ns, ptype),
) {
if let Some(ref data) = cached_data {
state.metrics.record_download("terraform");
state.metrics.record_cache_hit("terraform");
return with_json(data.to_vec());
}
return crate::curation::check_namespace_isolation(
&state.curation().curation_engine,
crate::curation::RegistryType::Terraform,
&format!("{}/{}", ns, ptype),
)
.unwrap_or_else(|| StatusCode::NOT_FOUND.into_response());
}
let proxy_url = upstream_url(&state);
let url = format!(
"{}/v1/providers/{}/{}/versions",
proxy_url.trim_end_matches('/'),
ns,
ptype
);
match proxy_fetch_text(
&state.http_client,
&url,
Duration::from_secs(state.config.terraform.proxy_timeout),
expose_opt(&state.config.terraform.proxy_auth),
None,
&state.circuit_breaker,
RegistryType::Terraform,
)
.await
{
Ok(text) => {
state.metrics.record_download("terraform");
state.metrics.record_cache_miss("terraform");
state.activity.push(ActivityEntry::new(
ActionType::ProxyFetch,
format!("{}/{}", ns, ptype),
crate::registry_type::RegistryType::Terraform,
"PROXY",
));
state
.audit
.log(AuditEntry::new("proxy_fetch", "api", "", "terraform", ""));
state.spawn_cache("terraform", storage_key, Bytes::from(text.clone()));
with_json(text.into_bytes())
}
Err(ProxyError::NotFound) => StatusCode::NOT_FOUND.into_response(),
Err(ProxyError::CircuitOpen(reg)) => circuit_open_response(®),
Err(e) => {
tracing::debug!(provider = format!("{}/{}", ns, ptype), error = ?e, "Terraform upstream error");
serve_stale_or_bad_gateway(&state, cached_data, "provider_versions")
}
}
}
async fn provider_download_meta(
State(state): State<AppState>,
headers: HeaderMap,
Path((ns, ptype, ver, os, arch)): Path<(String, String, String, String, String)>,
) -> Response {
if !is_valid_name(&ns)
|| !is_valid_name(&ptype)
|| !is_valid_version(&ver)
|| !is_valid_name(&os)
|| !is_valid_name(&arch)
{
return StatusCode::BAD_REQUEST.into_response();
}
let base_url = nora_base_url(&state);
let artifact = format!("{}/{} v{} {}/{}", ns, ptype, ver, os, arch);
let publish_date = extract_terraform_publish_date(&state, &ns, &ptype, &ver, false).await;
let internal = crate::curation::is_internal_namespace(
&state.curation().curation_engine,
crate::curation::RegistryType::Terraform,
&format!("{}/{}", ns, ptype),
);
if !internal {
if let Some(response) = crate::curation::check_download(
&state.curation().curation_engine,
state.bypass_token().as_deref(),
&headers,
crate::curation::RegistryType::Terraform,
&format!("{}/{}", ns, ptype),
Some(&ver),
publish_date,
) {
return response;
}
}
let storage_key = format!(
"terraform/providers/{}/{}/{}/{}_{}.json",
ns, ptype, ver, os, arch
);
let cached_data = state
.storage
.get(&storage_key)
.await
.ok()
.map(|d| Bytes::from(strip_nora_internal_fields(&d)));
if let Some(ref data) = cached_data {
if let Some(meta) = state.storage.stat(&storage_key).await {
if is_within_ttl(meta.modified, state.config.terraform.metadata_ttl) {
state.metrics.record_download("terraform");
state.metrics.record_cache_hit("terraform");
return with_json(data.to_vec());
}
}
}
if internal {
if let Some(ref data) = cached_data {
state.metrics.record_download("terraform");
state.metrics.record_cache_hit("terraform");
return with_json(data.to_vec());
}
return crate::curation::check_namespace_isolation(
&state.curation().curation_engine,
crate::curation::RegistryType::Terraform,
&format!("{}/{}", ns, ptype),
)
.unwrap_or_else(|| StatusCode::NOT_FOUND.into_response());
}
let proxy_url = upstream_url(&state);
let url = format!(
"{}/v1/providers/{}/{}/{}/download/{}/{}",
proxy_url.trim_end_matches('/'),
ns,
ptype,
ver,
os,
arch
);
match proxy_fetch_text(
&state.http_client,
&url,
Duration::from_secs(state.config.terraform.proxy_timeout),
expose_opt(&state.config.terraform.proxy_auth),
None,
&state.circuit_breaker,
RegistryType::Terraform,
)
.await
{
Ok(text) => {
let rewritten = rewrite_download_url(&text, &base_url, &ns, &ptype, &ver);
state.metrics.record_download("terraform");
state.metrics.record_cache_miss("terraform");
state.activity.push(ActivityEntry::new(
ActionType::ProxyFetch,
artifact,
crate::registry_type::RegistryType::Terraform,
"PROXY",
));
state
.audit
.log(AuditEntry::new("proxy_fetch", "api", "", "terraform", ""));
state.spawn_cache("terraform", storage_key, Bytes::from(rewritten.clone()));
with_json(strip_nora_internal_fields(rewritten.as_bytes()))
}
Err(ProxyError::NotFound) => StatusCode::NOT_FOUND.into_response(),
Err(ProxyError::CircuitOpen(reg)) => circuit_open_response(®),
Err(e) => {
tracing::debug!(error = ?e, "Terraform download metadata error");
serve_stale_or_bad_gateway(&state, cached_data, "provider_download_meta")
}
}
}
async fn provider_download_binary(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(path): Path<String>,
) -> Response {
if !is_safe_path(&path) {
return StatusCode::BAD_REQUEST.into_response();
}
let storage_key = format!("terraform/download/{}", path);
let already_cached = state.storage.stat(&storage_key).await.is_some();
let bin_coords: Vec<&str> = path.split('/').collect();
let publish_date = if bin_coords.len() >= 3 {
extract_terraform_publish_date(
&state,
bin_coords[0],
bin_coords[1],
bin_coords[2],
already_cached,
)
.await
} else {
None
};
if let Ok(outcome) = state.storage.get_verified(&storage_key).await {
use nora_registry::verified::{verified_body, GateOutcome};
let data = match outcome {
GateOutcome::Verified(blob) => verified_body(blob),
GateOutcome::Unpinned(blob) => blob.into_inner(),
};
state.metrics.record_download("terraform");
state.metrics.record_cache_hit("terraform");
state.activity.push(ActivityEntry::new(
ActionType::CacheHit,
path.clone(),
crate::registry_type::RegistryType::Terraform,
"CACHE",
));
let (q_mode, q_secs) = crate::digest_quarantine::resolve_global(
state.config.curation.terraform.quarantine.as_ref().or(state
.config
.curation
.quarantine
.as_ref()),
state
.config
.curation
.terraform
.quarantine_ttl
.as_deref()
.or(state.config.curation.quarantine_ttl.as_deref()),
);
if let Some(resp) = crate::digest_quarantine::proxy_gate_dated(
&state.digest_store,
"terraform",
&data,
&q_mode,
q_secs,
"cache",
publish_date,
) {
return resp;
}
if let Some(response) = range_binary(&state, &storage_key, &headers, data.len()).await {
return response;
}
return with_binary(data.to_vec());
}
let parts: Vec<&str> = path.splitn(4, '/').collect();
if parts.len() < 4 {
return StatusCode::NOT_FOUND.into_response();
}
let (ns, ptype, ver, filename) = (parts[0], parts[1], parts[2], parts[3]);
let url = resolve_upstream_download_url(&state, ns, ptype, ver, filename).await;
match proxy_fetch(
&state.http_client,
&url,
Duration::from_secs(state.config.terraform.proxy_timeout_dl),
expose_opt(&state.config.terraform.proxy_auth),
&state.circuit_breaker,
RegistryType::Terraform,
)
.await
{
Ok(bytes) => {
state.metrics.record_download("terraform");
state.metrics.record_cache_miss("terraform");
state.activity.push(ActivityEntry::new(
ActionType::ProxyFetch,
path,
crate::registry_type::RegistryType::Terraform,
"PROXY",
));
state
.audit
.log(AuditEntry::new("proxy_fetch", "api", "", "terraform", ""));
state.spawn_cache_immutable("terraform", storage_key, Bytes::from(bytes.clone()));
let (q_mode, q_secs) = crate::digest_quarantine::resolve_global(
state.config.curation.terraform.quarantine.as_ref().or(state
.config
.curation
.quarantine
.as_ref()),
state
.config
.curation
.terraform
.quarantine_ttl
.as_deref()
.or(state.config.curation.quarantine_ttl.as_deref()),
);
if let Some(resp) = crate::digest_quarantine::proxy_gate_dated(
&state.digest_store,
"terraform",
&bytes,
&q_mode,
q_secs,
&url,
publish_date,
) {
return resp;
}
with_binary(bytes)
}
Err(ProxyError::NotFound) => StatusCode::NOT_FOUND.into_response(),
Err(ProxyError::CircuitOpen(reg)) => circuit_open_response(®),
Err(e) => {
tracing::debug!(error = ?e, "Terraform binary download error");
StatusCode::BAD_GATEWAY.into_response()
}
}
}
async fn module_versions(
State(state): State<AppState>,
Path((ns, name, provider)): Path<(String, String, String)>,
) -> Response {
if !is_valid_name(&ns) || !is_valid_name(&name) || !is_valid_name(&provider) {
return StatusCode::BAD_REQUEST.into_response();
}
let storage_key = format!(
"terraform/modules/{}/{}/{}/versions.json",
ns, name, provider
);
let cached_data = state.storage.get(&storage_key).await.ok();
if let Some(ref data) = cached_data {
if let Some(meta) = state.storage.stat(&storage_key).await {
if is_within_ttl(meta.modified, state.config.terraform.metadata_ttl) {
state.metrics.record_download("terraform");
state.metrics.record_cache_hit("terraform");
return with_json(data.to_vec());
}
}
}
if crate::curation::is_internal_namespace(
&state.curation().curation_engine,
crate::curation::RegistryType::Terraform,
&format!("{}/{}/{}", ns, name, provider),
) {
if let Some(ref data) = cached_data {
state.metrics.record_download("terraform");
state.metrics.record_cache_hit("terraform");
return with_json(data.to_vec());
}
return crate::curation::check_namespace_isolation(
&state.curation().curation_engine,
crate::curation::RegistryType::Terraform,
&format!("{}/{}/{}", ns, name, provider),
)
.unwrap_or_else(|| StatusCode::NOT_FOUND.into_response());
}
let proxy_url = upstream_url(&state);
let url = format!(
"{}/v1/modules/{}/{}/{}/versions",
proxy_url.trim_end_matches('/'),
ns,
name,
provider
);
match proxy_fetch_text(
&state.http_client,
&url,
Duration::from_secs(state.config.terraform.proxy_timeout),
expose_opt(&state.config.terraform.proxy_auth),
None,
&state.circuit_breaker,
RegistryType::Terraform,
)
.await
{
Ok(text) => {
state.metrics.record_download("terraform");
state.metrics.record_cache_miss("terraform");
state.activity.push(ActivityEntry::new(
ActionType::ProxyFetch,
format!("{}/{}/{}", ns, name, provider),
crate::registry_type::RegistryType::Terraform,
"PROXY",
));
state
.audit
.log(AuditEntry::new("proxy_fetch", "api", "", "terraform", ""));
state.spawn_cache("terraform", storage_key, Bytes::from(text.clone()));
with_json(text.into_bytes())
}
Err(ProxyError::NotFound) => StatusCode::NOT_FOUND.into_response(),
Err(ProxyError::CircuitOpen(reg)) => circuit_open_response(®),
Err(e) => {
tracing::debug!(error = ?e, "Terraform module versions error");
serve_stale_or_bad_gateway(&state, cached_data, "module_versions")
}
}
}
async fn module_download(
State(state): State<AppState>,
Path((ns, name, provider, ver)): Path<(String, String, String, String)>,
) -> Response {
if !is_valid_name(&ns)
|| !is_valid_name(&name)
|| !is_valid_name(&provider)
|| !is_valid_version(&ver)
{
return StatusCode::BAD_REQUEST.into_response();
}
let base_url = nora_base_url(&state);
let source_url_key = format!(
"terraform/modules/{}/{}/{}/{}/_source_url",
ns, name, provider, ver
);
if let Ok(data) = state.storage.get(&source_url_key).await {
let original_url = String::from_utf8_lossy(&data);
let rewritten =
rewrite_module_source_url(&original_url, &base_url, &ns, &name, &provider, &ver);
state.metrics.record_download("terraform");
state.metrics.record_cache_hit("terraform");
return (
StatusCode::NO_CONTENT,
[("x-terraform-get", rewritten.as_str())],
)
.into_response();
}
let proxy_url = upstream_url(&state);
let url = format!(
"{}/v1/modules/{}/{}/{}/{}/download",
proxy_url.trim_end_matches('/'),
ns,
name,
provider,
ver
);
let client = &state.http_client;
let timeout = state.config.terraform.proxy_timeout;
let mut request = client
.get(&url)
.timeout(std::time::Duration::from_secs(timeout));
if let Some(auth) = expose_opt(&state.config.terraform.proxy_auth) {
request = request.header("Authorization", crate::config::basic_auth_header(auth));
}
match request.send().await {
Ok(response) => {
if let Some(tf_get) = response.headers().get("x-terraform-get") {
let original_url = tf_get.to_str().unwrap_or("").to_string();
state.metrics.record_download("terraform");
state.activity.push(ActivityEntry::new(
ActionType::ProxyFetch,
format!("{}/{}/{} v{}", ns, name, provider, ver),
crate::registry_type::RegistryType::Terraform,
"PROXY",
));
let rewritten = rewrite_module_source_url(
&original_url,
&base_url,
&ns,
&name,
&provider,
&ver,
);
let (_, inner_url) = strip_vcs_prefix(&original_url);
state.spawn_cache(
"terraform",
source_url_key,
Bytes::from(inner_url.to_string()),
);
return (
StatusCode::NO_CONTENT,
[("x-terraform-get", rewritten.as_str())],
)
.into_response();
}
StatusCode::NOT_FOUND.into_response()
}
Err(e) => {
tracing::debug!(error = ?e, "Terraform module download error");
StatusCode::BAD_GATEWAY.into_response()
}
}
}
async fn module_source_download(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path((ns, name, provider, ver)): Path<(String, String, String, String)>,
) -> Response {
if !is_valid_name(&ns)
|| !is_valid_name(&name)
|| !is_valid_name(&provider)
|| !is_valid_version(&ver)
{
return StatusCode::BAD_REQUEST.into_response();
}
let storage_key = format!(
"terraform/modules/{}/{}/{}/{}/source.tar.gz",
ns, name, provider, ver
);
if let Ok(outcome) = state.storage.get_verified(&storage_key).await {
use nora_registry::verified::{verified_body, GateOutcome};
let data = match outcome {
GateOutcome::Verified(blob) => verified_body(blob),
GateOutcome::Unpinned(blob) => blob.into_inner(),
};
state.metrics.record_download("terraform");
state.metrics.record_cache_hit("terraform");
let (q_mode, q_secs) = crate::digest_quarantine::resolve_global(
state.config.curation.terraform.quarantine.as_ref().or(state
.config
.curation
.quarantine
.as_ref()),
state
.config
.curation
.terraform
.quarantine_ttl
.as_deref()
.or(state.config.curation.quarantine_ttl.as_deref()),
);
if let Some(resp) = crate::digest_quarantine::proxy_gate(
&state.digest_store,
"terraform",
&data,
&q_mode,
q_secs,
"cache",
) {
return resp;
}
if let Some(response) = range_binary(&state, &storage_key, &headers, data.len()).await {
return response;
}
return with_binary(data.to_vec());
}
let source_url_key = format!(
"terraform/modules/{}/{}/{}/{}/_source_url",
ns, name, provider, ver
);
let upstream_url = match state.storage.get(&source_url_key).await {
Ok(data) => String::from_utf8_lossy(&data).to_string(),
Err(_) => {
return StatusCode::NOT_FOUND.into_response();
}
};
if !upstream_url.starts_with("http://") && !upstream_url.starts_with("https://") {
tracing::debug!(url = %upstream_url, "Module source URL is not HTTP — cannot proxy");
return StatusCode::NOT_FOUND.into_response();
}
match proxy_fetch(
&state.http_client,
&upstream_url,
Duration::from_secs(state.config.terraform.proxy_timeout_dl),
expose_opt(&state.config.terraform.proxy_auth),
&state.circuit_breaker,
RegistryType::Terraform,
)
.await
{
Ok(bytes) => {
state.metrics.record_download("terraform");
state.metrics.record_cache_miss("terraform");
state.activity.push(ActivityEntry::new(
ActionType::ProxyFetch,
format!("{}/{}/{} v{}", ns, name, provider, ver),
crate::registry_type::RegistryType::Terraform,
"PROXY",
));
state.spawn_cache_immutable("terraform", storage_key, Bytes::from(bytes.clone()));
let (q_mode, q_secs) = crate::digest_quarantine::resolve_global(
state.config.curation.terraform.quarantine.as_ref().or(state
.config
.curation
.quarantine
.as_ref()),
state
.config
.curation
.terraform
.quarantine_ttl
.as_deref()
.or(state.config.curation.quarantine_ttl.as_deref()),
);
if let Some(resp) = crate::digest_quarantine::proxy_gate(
&state.digest_store,
"terraform",
&bytes,
&q_mode,
q_secs,
&upstream_url,
) {
return resp;
}
with_binary(bytes)
}
Err(ProxyError::NotFound) => StatusCode::NOT_FOUND.into_response(),
Err(ProxyError::CircuitOpen(reg)) => circuit_open_response(®),
Err(e) => {
tracing::debug!(error = ?e, "Terraform module source download error");
StatusCode::BAD_GATEWAY.into_response()
}
}
}
async fn mirror_provider_index(
State(state): State<AppState>,
Path((hostname, ns, ptype)): Path<(String, String, String)>,
) -> Response {
if !is_valid_name(&hostname) || !is_valid_name(&ns) || !is_valid_name(&ptype) {
return StatusCode::BAD_REQUEST.into_response();
}
let versions_json = match mirror_fetch_versions(&state, &ns, &ptype).await {
Ok(v) => v,
Err(resp) => return *resp,
};
let mirror = build_mirror_index(&versions_json);
debug_assert!(
mirror
.get("versions")
.map(|v| v.is_object())
.unwrap_or(false),
"mirror index must carry a `versions` object"
);
state.metrics.record_download("terraform");
with_json(serde_json::to_vec(&mirror).unwrap_or_default())
}
async fn mirror_provider_version(
State(state): State<AppState>,
headers: HeaderMap,
Path((hostname, ns, ptype, version_file)): Path<(String, String, String, String)>,
) -> Response {
let ver = match version_file.strip_suffix(".json") {
Some(v) => v,
None => return StatusCode::NOT_FOUND.into_response(),
};
if !is_valid_name(&hostname)
|| !is_valid_name(&ns)
|| !is_valid_name(&ptype)
|| !is_valid_version(ver)
{
return StatusCode::BAD_REQUEST.into_response();
}
let versions_json = match mirror_fetch_versions(&state, &ns, &ptype).await {
Ok(v) => v,
Err(resp) => return *resp,
};
let platforms = extract_platforms(&versions_json, ver);
if platforms.is_empty() {
return StatusCode::NOT_FOUND.into_response();
}
let internal = crate::curation::is_internal_namespace(
&state.curation().curation_engine,
crate::curation::RegistryType::Terraform,
&format!("{}/{}", ns, ptype),
);
if !internal {
let publish_date = extract_terraform_publish_date(&state, &ns, &ptype, ver, false).await;
if let Some(resp) = crate::curation::check_download(
&state.curation().curation_engine,
state.bypass_token().as_deref(),
&headers,
crate::curation::RegistryType::Terraform,
&format!("{}/{}", ns, ptype),
Some(ver),
publish_date,
) {
return resp;
}
}
let base_url = nora_base_url(&state);
let (state_ref, base_ref, ns_ref, ptype_ref) =
(&state, base_url.as_str(), ns.as_str(), ptype.as_str());
let results = futures::future::join_all(platforms.iter().map(|(os, arch)| async move {
let res = mirror_fetch_archive(state_ref, base_ref, ns_ref, ptype_ref, ver, os, arch).await;
(os.clone(), arch.clone(), res)
}))
.await;
let mut archives = serde_json::Map::new();
for (os, arch, res) in results {
if let Some((url, Some(shasum))) = res {
archives.insert(
format!("{}_{}", os, arch),
serde_json::json!({ "url": url, "hashes": [format!("zh:{}", shasum)] }),
);
}
}
if archives.is_empty() {
return StatusCode::NOT_FOUND.into_response();
}
state.metrics.record_download("terraform");
with_json(serde_json::to_vec(&serde_json::json!({ "archives": archives })).unwrap_or_default())
}
async fn mirror_fetch_versions(
state: &AppState,
ns: &str,
ptype: &str,
) -> Result<serde_json::Value, Box<Response>> {
let storage_key = format!("terraform/providers/{}/{}/versions.json", ns, ptype);
let cached_data = state.storage.get(&storage_key).await.ok();
if let Some(ref data) = cached_data {
if let Some(meta) = state.storage.stat(&storage_key).await {
if is_within_ttl(meta.modified, state.config.terraform.metadata_ttl) {
state.metrics.record_cache_hit("terraform");
return parse_json(data);
}
}
}
if crate::curation::is_internal_namespace(
&state.curation().curation_engine,
crate::curation::RegistryType::Terraform,
&format!("{}/{}", ns, ptype),
) {
if let Some(ref data) = cached_data {
state.metrics.record_cache_hit("terraform");
return parse_json(data);
}
return Err(Box::new(
crate::curation::check_namespace_isolation(
&state.curation().curation_engine,
crate::curation::RegistryType::Terraform,
&format!("{}/{}", ns, ptype),
)
.unwrap_or_else(|| StatusCode::NOT_FOUND.into_response()),
));
}
let proxy_url = upstream_url(state);
let url = format!(
"{}/v1/providers/{}/{}/versions",
proxy_url.trim_end_matches('/'),
ns,
ptype
);
match proxy_fetch_text(
&state.http_client,
&url,
Duration::from_secs(state.config.terraform.proxy_timeout),
expose_opt(&state.config.terraform.proxy_auth),
None,
&state.circuit_breaker,
RegistryType::Terraform,
)
.await
{
Ok(text) => {
state.metrics.record_cache_miss("terraform");
state.spawn_cache("terraform", storage_key, Bytes::from(text.clone()));
parse_json(text.as_bytes())
}
Err(ProxyError::NotFound) => Err(Box::new(StatusCode::NOT_FOUND.into_response())),
Err(ProxyError::CircuitOpen(reg)) => Err(Box::new(circuit_open_response(®))),
Err(e) => {
tracing::debug!(provider = format!("{}/{}", ns, ptype), error = ?e, "Terraform mirror versions upstream error");
if let Some(ref data) = cached_data {
if state.config.terraform.serve_stale {
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(data) {
return Ok(v);
}
}
}
Err(Box::new(StatusCode::BAD_GATEWAY.into_response()))
}
}
}
async fn mirror_fetch_archive(
state: &AppState,
base_url: &str,
ns: &str,
ptype: &str,
ver: &str,
os: &str,
arch: &str,
) -> Option<(String, Option<String>)> {
if !is_valid_name(os) || !is_valid_name(arch) {
return None;
}
let storage_key = format!(
"terraform/providers/{}/{}/{}/{}_{}.json",
ns, ptype, ver, os, arch
);
let meta_text = match state.storage.get(&storage_key).await.ok() {
Some(data) => String::from_utf8_lossy(&data).to_string(),
None => {
let proxy_url = upstream_url(state);
let url = format!(
"{}/v1/providers/{}/{}/{}/download/{}/{}",
proxy_url.trim_end_matches('/'),
ns,
ptype,
ver,
os,
arch
);
let text = proxy_fetch_text(
&state.http_client,
&url,
Duration::from_secs(state.config.terraform.proxy_timeout),
expose_opt(&state.config.terraform.proxy_auth),
None,
&state.circuit_breaker,
RegistryType::Terraform,
)
.await
.ok()?;
let rewritten = rewrite_download_url(&text, base_url, ns, ptype, ver);
state.spawn_cache("terraform", storage_key, Bytes::from(rewritten.clone()));
rewritten
}
};
let json: serde_json::Value = serde_json::from_str(&meta_text).ok()?;
let url = json
.get("download_url")
.and_then(|v| v.as_str())?
.to_string();
let shasum = json
.get("shasum")
.and_then(|v| v.as_str())
.map(String::from);
Some((url, shasum))
}
fn build_mirror_index(versions_json: &serde_json::Value) -> serde_json::Value {
let mut map = serde_json::Map::new();
if let Some(arr) = versions_json.get("versions").and_then(|v| v.as_array()) {
for entry in arr {
if let Some(ver) = entry.get("version").and_then(|v| v.as_str()) {
map.insert(ver.to_string(), serde_json::json!({}));
}
}
}
serde_json::json!({ "versions": serde_json::Value::Object(map) })
}
fn extract_platforms(versions_json: &serde_json::Value, ver: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
if let Some(arr) = versions_json.get("versions").and_then(|v| v.as_array()) {
for entry in arr {
if entry.get("version").and_then(|v| v.as_str()) == Some(ver) {
if let Some(plats) = entry.get("platforms").and_then(|v| v.as_array()) {
for p in plats {
if let (Some(os), Some(arch)) = (
p.get("os").and_then(|v| v.as_str()),
p.get("arch").and_then(|v| v.as_str()),
) {
out.push((os.to_string(), arch.to_string()));
}
}
}
}
}
}
out
}
fn parse_json(data: &[u8]) -> Result<serde_json::Value, Box<Response>> {
serde_json::from_slice::<serde_json::Value>(data)
.map_err(|_| Box::new(StatusCode::BAD_GATEWAY.into_response()))
}
fn url_is_official_terraform(u: &str) -> bool {
u.contains("registry.terraform.io")
}
fn terraform_upstream_is_official(state: &AppState) -> bool {
url_is_official_terraform(&upstream_url(state))
}
async fn fetch_terraform_registry_date(
client: &reqwest::Client,
ns: &str,
ptype: &str,
ver: &str,
timeout_secs: u64,
) -> Option<i64> {
let url = format!(
"https://registry.terraform.io/v2/providers/{}/{}?include=provider-versions",
ns, ptype
);
let resp = client
.get(&url)
.timeout(Duration::from_secs(timeout_secs))
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let json: serde_json::Value = resp.json().await.ok()?;
let included = json.get("included")?.as_array()?;
for item in included {
let attrs = match item.get("attributes") {
Some(a) => a,
None => continue,
};
if attrs.get("version").and_then(|v| v.as_str()) == Some(ver) {
let date_str = attrs.get("published-at").and_then(|v| v.as_str())?;
return crate::curation::parse_iso8601_to_unix(date_str);
}
}
None
}
async fn extract_terraform_publish_date(
state: &AppState,
ns: &str,
ptype: &str,
ver: &str,
already_cached: bool,
) -> Option<i64> {
if state.config.terraform.proxy.is_some() {
if crate::curation::is_internal_namespace(
&state.curation().curation_engine,
crate::curation::RegistryType::Terraform,
&format!("{}/{}", ns, ptype),
) {
return None;
}
if !already_cached
&& state.config.server.trust_upstream_dates
&& terraform_upstream_is_official(state)
{
return fetch_terraform_registry_date(
&state.http_client,
ns,
ptype,
ver,
state.config.terraform.proxy_timeout,
)
.await;
}
return None;
}
for suffix in &["linux_amd64.json", "linux_arm64.json", "darwin_amd64.json"] {
let meta_key = format!("terraform/providers/{}/{}/{}/{}", ns, ptype, ver, suffix);
if let Some(ts) =
crate::curation::extract_mtime_as_publish_date(&state.storage, &meta_key).await
{
return Some(ts);
}
}
None
}
async fn resolve_upstream_download_url(
state: &AppState,
ns: &str,
ptype: &str,
ver: &str,
filename: &str,
) -> String {
let meta_field = if filename.ends_with(".sig") {
"_nora_upstream_shasums_sig_url"
} else if filename.contains("SHA256SUMS") || filename.contains("SHA512SUMS") {
"_nora_upstream_shasums_url"
} else {
"_nora_upstream_url"
};
if let Some((os, arch)) = parse_os_arch_from_filename(filename) {
let meta_key = format!(
"terraform/providers/{}/{}/{}/{}_{}.json",
ns, ptype, ver, os, arch
);
if let Ok(data) = state.storage.get(&meta_key).await {
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&data) {
if let Some(url) = json.get(meta_field).and_then(|v| v.as_str()) {
return url.to_string();
}
}
}
} else {
let prefix = format!("terraform/providers/{}/{}/{}/", ns, ptype, ver);
let keys = state.storage.list(&prefix).await.unwrap_or_default();
for key in keys {
if key.ends_with(".json") {
if let Ok(data) = state.storage.get(&key).await {
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&data) {
if let Some(url) = json.get(meta_field).and_then(|v| v.as_str()) {
return url.to_string();
}
}
}
}
}
}
format!(
"https://releases.hashicorp.com/terraform-provider-{}/{}/{}",
ptype, ver, filename
)
}
fn parse_os_arch_from_filename(filename: &str) -> Option<(&str, &str)> {
let name = filename.strip_suffix(".zip")?;
let (rest, arch) = name.rsplit_once('_')?;
let (_, os) = rest.rsplit_once('_')?;
Some((os, arch))
}
fn upstream_url(state: &AppState) -> String {
state
.config
.terraform
.proxy
.clone()
.unwrap_or_else(|| UPSTREAM_DEFAULT.to_string())
}
use crate::cache_ttl::is_within_ttl;
fn with_json(data: Vec<u8>) -> Response {
(
StatusCode::OK,
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
),
(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=60, must-revalidate"),
),
],
data,
)
.into_response()
}
fn serve_stale_or_bad_gateway(state: &AppState, cached: Option<Bytes>, endpoint: &str) -> Response {
if let Some(data) = cached {
if state.config.terraform.serve_stale {
tracing::warn!(
registry = "terraform",
endpoint,
"Upstream unreachable, serving stale cached metadata"
);
return (
StatusCode::OK,
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
),
(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=0, must-revalidate"),
),
(
axum::http::header::HeaderName::from_static("x-nora-stale"),
axum::http::header::HeaderValue::from_static("true"),
),
],
data.to_vec(),
)
.into_response();
}
}
StatusCode::BAD_GATEWAY.into_response()
}
fn with_binary(data: Vec<u8>) -> Response {
(
StatusCode::OK,
[
(
header::CONTENT_TYPE,
HeaderValue::from_static("application/zip"),
),
(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
),
(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")),
],
data,
)
.into_response()
}
async fn range_binary(
state: &AppState,
storage_key: &str,
headers: &axum::http::HeaderMap,
size: usize,
) -> Option<Response> {
crate::registry::range::range_response(
&state.storage,
&[storage_key],
headers,
size as u64,
"application/zip",
&[(
header::CACHE_CONTROL,
"public, max-age=31536000, immutable".to_string(),
)],
)
.await
}
fn rewrite_download_url(
json_text: &str,
base_url: &str,
ns: &str,
ptype: &str,
ver: &str,
) -> String {
if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(json_text) {
if let Some(obj) = json.as_object_mut() {
let download_base = format!(
"{}/terraform/v1/providers/download/{}/{}/{}",
base_url, ns, ptype, ver
);
if let Some(url_str) = obj
.get("download_url")
.and_then(|v| v.as_str())
.map(String::from)
{
obj.insert(
"_nora_upstream_url".to_string(),
serde_json::Value::String(url_str.clone()),
);
let filename = url_str.rsplit('/').next().unwrap_or("provider.zip");
obj.insert(
"download_url".to_string(),
serde_json::Value::String(format!("{}/{}", download_base, filename)),
);
}
if let Some(url_str) = obj
.get("shasums_url")
.and_then(|v| v.as_str())
.map(String::from)
{
obj.insert(
"_nora_upstream_shasums_url".to_string(),
serde_json::Value::String(url_str.clone()),
);
let filename = url_str.rsplit('/').next().unwrap_or("SHA256SUMS");
obj.insert(
"shasums_url".to_string(),
serde_json::Value::String(format!("{}/{}", download_base, filename)),
);
}
if let Some(url_str) = obj
.get("shasums_signature_url")
.and_then(|v| v.as_str())
.map(String::from)
{
obj.insert(
"_nora_upstream_shasums_sig_url".to_string(),
serde_json::Value::String(url_str.clone()),
);
let filename = url_str.rsplit('/').next().unwrap_or("SHA256SUMS.sig");
obj.insert(
"shasums_signature_url".to_string(),
serde_json::Value::String(format!("{}/{}", download_base, filename)),
);
}
}
serde_json::to_string(&json).unwrap_or_else(|_| json_text.to_string())
} else {
json_text.to_string()
}
}
fn rewrite_module_source_url(
original_url: &str,
base_url: &str,
ns: &str,
name: &str,
provider: &str,
ver: &str,
) -> String {
let (vcs_prefix, inner_url) = strip_vcs_prefix(original_url);
if inner_url.starts_with("http://") || inner_url.starts_with("https://") {
if !vcs_prefix.is_empty() {
tracing::warn!(
module = %format!("{}/{}/{}", ns, name, provider),
version = %ver,
vcs = vcs_prefix.trim_end_matches("::"),
"Module uses VCS prefix — source download via HTTP proxy may not work"
);
}
format!(
"{}/terraform/v1/modules/download/{}/{}/{}/{}/source",
base_url.trim_end_matches('/'),
ns,
name,
provider,
ver
)
} else {
original_url.to_string()
}
}
fn strip_nora_internal_fields(data: &[u8]) -> Vec<u8> {
if let Ok(mut json) = serde_json::from_slice::<serde_json::Value>(data) {
if let Some(obj) = json.as_object_mut() {
obj.retain(|k, _| !k.starts_with("_nora_"));
}
serde_json::to_vec(&json).unwrap_or_else(|_| data.to_vec())
} else {
tracing::warn!(
"strip_nora_internal_fields: failed to parse cached JSON, returning raw data"
);
data.to_vec()
}
}
fn strip_vcs_prefix(url: &str) -> (&str, &str) {
for prefix in &["git::", "hg::"] {
if let Some(inner) = url.strip_prefix(prefix) {
return (prefix, inner);
}
}
("", url)
}
fn is_valid_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 256
&& !name.contains('/')
&& !name.contains('\0')
&& !name.contains("..")
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
fn is_valid_version(version: &str) -> bool {
!version.is_empty()
&& version.len() <= 128
&& !version.contains('/')
&& !version.contains('\0')
&& !version.contains("..")
&& version
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '+')
}
fn is_safe_path(path: &str) -> bool {
!path.contains("..")
&& !path.starts_with('/')
&& !path.contains("//")
&& !path.contains('\0')
&& !path.is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_url_is_official_terraform() {
assert!(url_is_official_terraform("https://registry.terraform.io"));
assert!(url_is_official_terraform(
"https://registry.terraform.io/v2/providers"
));
assert!(!url_is_official_terraform("https://tf.internal.corp"));
assert!(!url_is_official_terraform("https://app.terraform.io")); assert!(!url_is_official_terraform(""));
}
#[test]
fn test_valid_names() {
assert!(is_valid_name("hashicorp"));
assert!(is_valid_name("aws"));
assert!(is_valid_name("google-beta"));
assert!(is_valid_name("terraform-provider-azurerm"));
}
#[test]
fn test_invalid_names() {
assert!(!is_valid_name(""));
assert!(!is_valid_name("../evil"));
assert!(!is_valid_name("foo/bar"));
assert!(!is_valid_name("foo\0bar"));
}
#[test]
fn test_valid_versions() {
assert!(is_valid_version("5.0.0"));
assert!(is_valid_version("3.67.0"));
assert!(is_valid_version("1.0.0-beta1"));
}
#[test]
fn test_rewrite_download_url() {
let input = r#"{"download_url":"https://releases.hashicorp.com/terraform-provider-aws/5.0.0/terraform-provider-aws_5.0.0_linux_amd64.zip","shasum":"abc123"}"#;
let result = rewrite_download_url(input, "https://nora:4000", "hashicorp", "aws", "5.0.0");
assert!(result.contains("https://nora:4000/terraform/v1/providers/download/hashicorp/aws/5.0.0/terraform-provider-aws_5.0.0_linux_amd64.zip"));
assert!(result.contains("_nora_upstream_url"));
assert!(result.contains("https://releases.hashicorp.com/terraform-provider-aws/5.0.0/terraform-provider-aws_5.0.0_linux_amd64.zip"));
assert!(result.contains("abc123"));
}
#[test]
fn test_rewrite_download_url_no_url() {
let input = r#"{"shasum":"abc123"}"#;
let result = rewrite_download_url(input, "http://nora:4000", "hashicorp", "aws", "5.0.0");
assert_eq!(result, input);
}
#[test]
fn test_rewrite_download_url_invalid_json() {
let input = "not json";
let result = rewrite_download_url(input, "http://nora:4000", "hashicorp", "aws", "5.0.0");
assert_eq!(result, input);
}
#[test]
fn test_safe_path() {
assert!(is_safe_path("hashicorp/aws/5.0.0/provider.zip"));
assert!(!is_safe_path("../../etc/passwd"));
assert!(!is_safe_path("/absolute/path"));
}
#[test]
fn test_rewrite_module_source_url_http() {
let result = rewrite_module_source_url(
"https://codeload.github.com/hashicorp/terraform-aws-consul/tar.gz/v0.1.0",
"http://nora:4000",
"hashicorp",
"consul",
"aws",
"0.1.0",
);
assert_eq!(
result,
"http://nora:4000/terraform/v1/modules/download/hashicorp/consul/aws/0.1.0/source"
);
assert!(!result.contains("github.com"), "upstream URL must not leak");
}
#[test]
fn test_rewrite_module_source_url_git_rewrite() {
let git_url = "git::https://example.com/module.git";
let result = rewrite_module_source_url(
git_url,
"http://nora:4000",
"hashicorp",
"consul",
"aws",
"0.1.0",
);
assert_eq!(
result,
"http://nora:4000/terraform/v1/modules/download/hashicorp/consul/aws/0.1.0/source",
"git::https:// URLs must be rewritten through NORA (air-gap)"
);
assert!(
!result.contains("example.com"),
"upstream URL must not leak"
);
assert!(!result.contains("git::"), "VCS prefix must be stripped");
}
#[test]
fn test_rewrite_module_source_url_hg_rewrite() {
let hg_url = "hg::https://example.com/module.hg";
let result = rewrite_module_source_url(
hg_url,
"http://nora:4000",
"hashicorp",
"consul",
"aws",
"0.1.0",
);
assert_eq!(
result,
"http://nora:4000/terraform/v1/modules/download/hashicorp/consul/aws/0.1.0/source",
"hg::https:// URLs must be rewritten through NORA"
);
}
#[test]
fn test_rewrite_module_source_url_s3_passthrough() {
let s3_url = "s3::https://bucket.s3.amazonaws.com/module.zip";
let result = rewrite_module_source_url(
s3_url,
"http://nora:4000",
"hashicorp",
"consul",
"aws",
"0.1.0",
);
assert_eq!(result, s3_url, "s3:: URLs should pass through unchanged");
}
#[test]
fn test_strip_nora_internal_fields() {
let input = serde_json::json!({
"download_url": "http://nora:4000/terraform/providers/download/test.zip",
"_nora_upstream_url": "https://releases.hashicorp.com/test.zip",
"_nora_upstream_shasums_url": "https://releases.hashicorp.com/SHA256SUMS",
"_nora_upstream_shasums_sig_url": "https://releases.hashicorp.com/SHA256SUMS.sig",
"shasum": "abc123"
});
let stripped = strip_nora_internal_fields(input.to_string().as_bytes());
let json: serde_json::Value = serde_json::from_slice(&stripped).unwrap();
assert!(
json.get("download_url").is_some(),
"download_url must remain"
);
assert!(json.get("shasum").is_some(), "shasum must remain");
assert!(
json.get("_nora_upstream_url").is_none(),
"_nora_upstream_url must be stripped"
);
assert!(
json.get("_nora_upstream_shasums_url").is_none(),
"shasums must be stripped"
);
assert!(
json.get("_nora_upstream_shasums_sig_url").is_none(),
"sig must be stripped"
);
}
#[test]
fn test_strip_nora_internal_fields_invalid_json() {
let input = b"not json at all";
let result = strip_nora_internal_fields(input);
assert_eq!(result, input, "invalid JSON must pass through unchanged");
}
#[test]
fn test_strip_vcs_prefix() {
assert_eq!(
strip_vcs_prefix("git::https://example.com"),
("git::", "https://example.com")
);
assert_eq!(
strip_vcs_prefix("hg::https://example.com"),
("hg::", "https://example.com")
);
assert_eq!(
strip_vcs_prefix("https://example.com"),
("", "https://example.com")
);
assert_eq!(strip_vcs_prefix("./local/path"), ("", "./local/path"));
assert_eq!(
strip_vcs_prefix("s3::https://bucket.s3.amazonaws.com/mod.zip"),
("", "s3::https://bucket.s3.amazonaws.com/mod.zip")
);
}
#[test]
fn test_build_mirror_index() {
let versions = serde_json::json!({
"versions": [
{"version": "3.2.3", "protocols": ["5.0"], "platforms": [{"os": "linux", "arch": "amd64"}]},
{"version": "3.2.2", "platforms": []}
]
});
let mirror = build_mirror_index(&versions);
let obj = mirror.get("versions").and_then(|v| v.as_object()).unwrap();
assert!(obj.contains_key("3.2.3"), "version must be a key");
assert!(obj.contains_key("3.2.2"));
assert!(obj["3.2.3"].as_object().unwrap().is_empty());
}
#[test]
fn test_build_mirror_index_empty() {
let mirror = build_mirror_index(&serde_json::json!({"versions": []}));
assert_eq!(mirror, serde_json::json!({"versions": {}}));
let mirror2 = build_mirror_index(&serde_json::json!({"nope": 1}));
assert_eq!(mirror2, serde_json::json!({"versions": {}}));
}
#[test]
fn test_extract_platforms() {
let versions = serde_json::json!({
"versions": [
{"version": "3.2.3", "platforms": [
{"os": "linux", "arch": "amd64"},
{"os": "darwin", "arch": "arm64"}
]},
{"version": "3.2.2", "platforms": [{"os": "windows", "arch": "amd64"}]}
]
});
let mut p = extract_platforms(&versions, "3.2.3");
p.sort();
assert_eq!(
p,
vec![
("darwin".to_string(), "arm64".to_string()),
("linux".to_string(), "amd64".to_string())
]
);
assert!(extract_platforms(&versions, "9.9.9").is_empty());
}
#[test]
fn test_rewrite_download_url_all_fields() {
let input = serde_json::json!({
"os": "linux",
"arch": "amd64",
"download_url": "https://releases.hashicorp.com/terraform-provider-aws/5.0.0/terraform-provider-aws_5.0.0_linux_amd64.zip",
"shasums_url": "https://releases.hashicorp.com/terraform-provider-aws/5.0.0/terraform-provider-aws_5.0.0_SHA256SUMS",
"shasums_signature_url": "https://releases.hashicorp.com/terraform-provider-aws/5.0.0/terraform-provider-aws_5.0.0_SHA256SUMS.sig",
"shasum": "abc123"
});
let result = rewrite_download_url(
&input.to_string(),
"http://nora:4000",
"hashicorp",
"aws",
"5.0.0",
);
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(
json["download_url"]
.as_str()
.unwrap()
.starts_with("http://nora:4000/terraform/"),
"download_url must point to NORA"
);
assert!(
json["shasums_url"]
.as_str()
.unwrap()
.starts_with("http://nora:4000/terraform/"),
"shasums_url must point to NORA"
);
assert!(
json["shasums_signature_url"]
.as_str()
.unwrap()
.starts_with("http://nora:4000/terraform/"),
"shasums_signature_url must point to NORA"
);
assert!(
!result.contains("releases.hashicorp.com") || result.contains("_nora_upstream"),
"upstream URL must only appear in _nora_upstream fields"
);
assert!(json.get("_nora_upstream_url").is_some());
assert!(json.get("_nora_upstream_shasums_url").is_some());
assert!(json.get("_nora_upstream_shasums_sig_url").is_some());
}
#[test]
fn test_rewrite_download_url_custom_upstream() {
let input = r#"{"download_url":"https://private.registry.corp/providers/myorg/myprovider/1.0.0/terraform-provider-myprovider_1.0.0_linux_amd64.zip"}"#;
let result =
rewrite_download_url(input, "http://nora:4000", "myorg", "myprovider", "1.0.0");
assert!(
result.contains(
"http://nora:4000/terraform/v1/providers/download/myorg/myprovider/1.0.0/"
),
"custom upstream must be rewritten to NORA"
);
assert!(
!result.contains("private.registry.corp") || result.contains("_nora_upstream"),
"custom upstream must not leak outside _nora_upstream fields"
);
}
#[test]
fn test_rewrite_module_source_url_trailing_slash() {
let result = rewrite_module_source_url(
"https://codeload.github.com/hashicorp/terraform-aws-consul/tar.gz/v0.1.0",
"http://nora:4000/",
"hashicorp",
"consul",
"aws",
"0.1.0",
);
assert!(
!result.contains("4000//terraform"),
"trailing slash must not produce double-slash: {result}"
);
assert_eq!(
result,
"http://nora:4000/terraform/v1/modules/download/hashicorp/consul/aws/0.1.0/source"
);
}
#[test]
fn test_rewrite_module_source_url_relative_passthrough() {
let result = rewrite_module_source_url(
"./modules/foo",
"http://nora:4000",
"hashicorp",
"consul",
"aws",
"0.1.0",
);
assert_eq!(
result, "./modules/foo",
"relative paths should pass through"
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod integration_tests {
use crate::test_helpers::{body_bytes, create_test_context_with_config, send};
use axum::http::{Method, StatusCode};
#[tokio::test]
async fn test_terraform_disabled_returns_404() {
let ctx = create_test_context_with_config(|cfg| {
cfg.terraform.enabled = false;
});
let resp = send(
&ctx.app,
Method::GET,
"/terraform/.well-known/terraform.json",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_terraform_service_discovery() {
let ctx = create_test_context_with_config(|cfg| {
cfg.terraform.enabled = true;
});
let resp = send(
&ctx.app,
Method::GET,
"/terraform/.well-known/terraform.json",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("providers.v1").is_some());
assert!(json.get("modules.v1").is_some());
}
#[tokio::test]
async fn test_terraform_cached_binary() {
let ctx = create_test_context_with_config(|cfg| {
cfg.terraform.enabled = true;
});
ctx.state
.storage
.put(
"terraform/download/hashicorp/aws/5.0.0/provider.zip",
b"zip-binary",
)
.await
.unwrap();
let resp = send(
&ctx.app,
Method::GET,
"/terraform/v1/providers/download/hashicorp/aws/5.0.0/provider.zip",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
assert_eq!(&body[..], b"zip-binary");
}
#[tokio::test]
async fn test_terraform_unreachable_proxy() {
let ctx = create_test_context_with_config(|cfg| {
cfg.terraform.enabled = true;
cfg.terraform.proxy = Some("http://127.0.0.1:1".to_string());
cfg.terraform.proxy_timeout = 1;
});
let resp = send(
&ctx.app,
Method::GET,
"/terraform/v1/providers/hashicorp/aws/versions",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
}
#[tokio::test]
async fn test_terraform_invalid_name_rejected() {
let ctx = create_test_context_with_config(|cfg| {
cfg.terraform.enabled = true;
});
let resp = send(
&ctx.app,
Method::GET,
"/terraform/v1/providers/../evil/versions",
"",
)
.await;
assert!(resp.status() == StatusCode::NOT_FOUND || resp.status() == StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_terraform_module_download_rewrites_cached_source_url() {
let ctx = create_test_context_with_config(|cfg| {
cfg.terraform.enabled = true;
});
ctx.state
.storage
.put(
"terraform/modules/hashicorp/consul/aws/0.1.0/_source_url",
b"https://codeload.github.com/hashicorp/terraform-aws-consul/tar.gz/v0.1.0",
)
.await
.unwrap();
let resp = send(
&ctx.app,
Method::GET,
"/terraform/v1/modules/hashicorp/consul/aws/0.1.0/download",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let tf_get = resp
.headers()
.get("x-terraform-get")
.expect("must have x-terraform-get header")
.to_str()
.unwrap();
assert!(
tf_get.contains("/terraform/v1/modules/download/"),
"X-Terraform-Get must point through NORA, got: {}",
tf_get
);
assert!(
!tf_get.contains("github.com"),
"X-Terraform-Get must not leak upstream URL, got: {}",
tf_get
);
}
#[tokio::test]
async fn test_terraform_module_source_from_cache() {
let ctx = create_test_context_with_config(|cfg| {
cfg.terraform.enabled = true;
});
ctx.state
.storage
.put(
"terraform/modules/hashicorp/consul/aws/0.1.0/source.tar.gz",
b"fake-tarball-content",
)
.await
.unwrap();
let resp = send(
&ctx.app,
Method::GET,
"/terraform/v1/modules/download/hashicorp/consul/aws/0.1.0/source",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
assert_eq!(&body[..], b"fake-tarball-content");
}
#[tokio::test]
async fn test_terraform_curation_enforce_blocks() {
use crate::test_helpers::send_with_headers;
let blocklist_dir = tempfile::TempDir::new().unwrap();
let blocklist_path = blocklist_dir.path().join("blocklist.json");
let blocklist = serde_json::json!({
"version": 1,
"rules": [{"registry": "terraform", "name": "evilcorp/backdoor", "version": "*", "reason": "compromised"}]
});
std::fs::write(&blocklist_path, serde_json::to_string(&blocklist).unwrap()).unwrap();
let bl_path = blocklist_path.to_str().unwrap().to_string();
let ctx = create_test_context_with_config(move |cfg| {
cfg.terraform.enabled = true;
cfg.terraform.proxy = Some("http://127.0.0.1:1".to_string());
cfg.terraform.proxy_timeout = 1;
cfg.curation.mode = crate::config::CurationMode::Enforce;
cfg.curation.blocklist_path = Some(bl_path);
});
let resp = send_with_headers(
&ctx.app,
Method::GET,
"/terraform/v1/providers/evilcorp/backdoor/1.0.0/download/linux/amd64",
vec![],
"",
)
.await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_serve_stale_provider_versions() {
let ctx = create_test_context_with_config(|cfg| {
cfg.terraform.enabled = true;
cfg.terraform.proxy = Some("http://127.0.0.1:1".to_string());
cfg.terraform.proxy_timeout = 1;
cfg.terraform.metadata_ttl = 0; cfg.terraform.serve_stale = true;
});
ctx.state
.storage
.put(
"terraform/providers/hashicorp/aws/versions.json",
br#"{"versions":[{"version":"5.0.0"}]}"#,
)
.await
.unwrap();
let resp = send(
&ctx.app,
Method::GET,
"/terraform/v1/providers/hashicorp/aws/versions",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get("x-nora-stale").map(|v| v.as_bytes()),
Some(b"true".as_ref()),
);
let body = body_bytes(resp).await;
assert!(String::from_utf8_lossy(&body).contains("5.0.0"));
}
#[tokio::test]
async fn test_serve_stale_disabled_returns_502() {
let ctx = create_test_context_with_config(|cfg| {
cfg.terraform.enabled = true;
cfg.terraform.proxy = Some("http://127.0.0.1:1".to_string());
cfg.terraform.proxy_timeout = 1;
cfg.terraform.metadata_ttl = 0;
cfg.terraform.serve_stale = false;
});
ctx.state
.storage
.put(
"terraform/providers/hashicorp/aws/versions.json",
br#"{"versions":[{"version":"5.0.0"}]}"#,
)
.await
.unwrap();
let resp = send(
&ctx.app,
Method::GET,
"/terraform/v1/providers/hashicorp/aws/versions",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
assert!(resp.headers().get("x-nora-stale").is_none());
}
#[tokio::test]
async fn test_no_cache_upstream_down_returns_502() {
let ctx = create_test_context_with_config(|cfg| {
cfg.terraform.enabled = true;
cfg.terraform.proxy = Some("http://127.0.0.1:1".to_string());
cfg.terraform.proxy_timeout = 1;
cfg.terraform.serve_stale = true;
});
let resp = send(
&ctx.app,
Method::GET,
"/terraform/v1/providers/hashicorp/aws/versions",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
}
#[tokio::test]
async fn test_mirror_index_from_cache() {
let ctx = create_test_context_with_config(|cfg| {
cfg.terraform.enabled = true;
});
ctx.state
.storage
.put(
"terraform/providers/hashicorp/null/versions.json",
br#"{"versions":[{"version":"3.2.3","platforms":[{"os":"linux","arch":"amd64"}]},{"version":"3.2.2","platforms":[]}]}"#,
)
.await
.unwrap();
let resp = send(
&ctx.app,
Method::GET,
"/terraform/registry.terraform.io/hashicorp/null/index.json",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let versions = json.get("versions").and_then(|v| v.as_object()).unwrap();
assert!(versions.contains_key("3.2.3"));
assert!(versions.contains_key("3.2.2"));
assert!(versions["3.2.3"].as_object().unwrap().is_empty());
}
#[tokio::test]
async fn test_mirror_version_archives_from_cache() {
let ctx = create_test_context_with_config(|cfg| {
cfg.terraform.enabled = true;
});
ctx.state
.storage
.put(
"terraform/providers/hashicorp/null/versions.json",
br#"{"versions":[{"version":"3.2.3","platforms":[{"os":"linux","arch":"amd64"}]}]}"#,
)
.await
.unwrap();
ctx.state
.storage
.put(
"terraform/providers/hashicorp/null/3.2.3/linux_amd64.json",
br#"{"download_url":"http://localhost:4000/terraform/v1/providers/download/hashicorp/null/3.2.3/terraform-provider-null_3.2.3_linux_amd64.zip","shasum":"deadbeef"}"#,
)
.await
.unwrap();
let resp = send(
&ctx.app,
Method::GET,
"/terraform/registry.terraform.io/hashicorp/null/3.2.3.json",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let arch = &json["archives"]["linux_amd64"];
assert!(
arch["url"]
.as_str()
.unwrap()
.contains("/terraform/v1/providers/download/"),
"url must route through NORA, got {arch}"
);
assert!(
!arch["url"]
.as_str()
.unwrap()
.contains("releases.hashicorp.com"),
"upstream host must not leak"
);
assert_eq!(arch["hashes"][0].as_str().unwrap(), "zh:deadbeef");
}
#[tokio::test]
async fn test_mirror_version_blocklist_enforced() {
let blocklist_dir = tempfile::TempDir::new().unwrap();
let blocklist_path = blocklist_dir.path().join("blocklist.json");
let blocklist = serde_json::json!({
"version": 1,
"rules": [{"registry": "terraform", "name": "evilcorp/backdoor", "version": "*", "reason": "compromised"}]
});
std::fs::write(&blocklist_path, serde_json::to_string(&blocklist).unwrap()).unwrap();
let bl_path = blocklist_path.to_str().unwrap().to_string();
let ctx = create_test_context_with_config(move |cfg| {
cfg.terraform.enabled = true;
cfg.curation.mode = crate::config::CurationMode::Enforce;
cfg.curation.blocklist_path = Some(bl_path);
});
ctx.state
.storage
.put(
"terraform/providers/evilcorp/backdoor/versions.json",
br#"{"versions":[{"version":"1.0.0","platforms":[{"os":"linux","arch":"amd64"}]}]}"#,
)
.await
.unwrap();
let resp = send(
&ctx.app,
Method::GET,
"/terraform/registry.terraform.io/evilcorp/backdoor/1.0.0.json",
"",
)
.await;
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"blocklisted provider must be blocked on the mirror path too"
);
}
#[tokio::test]
async fn test_mirror_internal_namespace_not_proxied() {
let ctx = create_test_context_with_config(|cfg| {
cfg.terraform.enabled = true;
cfg.terraform.proxy = Some("http://127.0.0.1:1".to_string()); cfg.terraform.proxy_timeout = 1;
cfg.curation.mode = crate::config::CurationMode::Enforce;
cfg.curation.internal_namespaces = vec!["internalcorp/**".to_string()];
});
let resp = send(
&ctx.app,
Method::GET,
"/terraform/registry.terraform.io/internalcorp/secret/index.json",
"",
)
.await;
assert!(
resp.status() == StatusCode::FORBIDDEN || resp.status() == StatusCode::NOT_FOUND,
"internal namespace must be blocked, got {}",
resp.status()
);
}
#[tokio::test]
async fn test_mirror_version_requires_json_suffix() {
let ctx = create_test_context_with_config(|cfg| {
cfg.terraform.enabled = true;
});
let resp = send(
&ctx.app,
Method::GET,
"/terraform/registry.terraform.io/hashicorp/null/3.2.3",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_mirror_disabled_returns_404() {
let ctx = create_test_context_with_config(|cfg| {
cfg.terraform.enabled = false;
});
let resp = send(
&ctx.app,
Method::GET,
"/terraform/registry.terraform.io/hashicorp/null/index.json",
"",
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_terraform_provider_binary_range_request() {
use crate::test_helpers::send_with_headers;
let ctx = create_test_context_with_config(|cfg| {
cfg.terraform.enabled = true;
});
let zip = b"0123456789";
ctx.state
.storage
.put(
"terraform/download/hashicorp/null/3.2.1/terraform-provider-null_3.2.1_linux_amd64.zip",
zip,
)
.await
.unwrap();
let url =
"/terraform/v1/providers/download/hashicorp/null/3.2.1/terraform-provider-null_3.2.1_linux_amd64.zip";
let resp =
send_with_headers(&ctx.app, Method::GET, url, vec![("range", "bytes=2-5")], "").await;
assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(
resp.headers()
.get("content-range")
.unwrap()
.to_str()
.unwrap(),
"bytes 2-5/10"
);
assert_eq!(
resp.headers()
.get("accept-ranges")
.unwrap()
.to_str()
.unwrap(),
"bytes"
);
assert_eq!(&body_bytes(resp).await[..], b"2345");
let resp =
send_with_headers(&ctx.app, Method::GET, url, vec![("range", "bytes=10-")], "").await;
assert_eq!(resp.status(), StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(
resp.headers()
.get("content-range")
.unwrap()
.to_str()
.unwrap(),
"bytes */10"
);
let resp = send(&ctx.app, Method::GET, url, "").await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("accept-ranges")
.unwrap()
.to_str()
.unwrap(),
"bytes"
);
assert_eq!(&body_bytes(resp).await[..], zip);
}
#[tokio::test]
async fn test_terraform_module_source_range_request() {
use crate::test_helpers::send_with_headers;
let ctx = create_test_context_with_config(|cfg| {
cfg.terraform.enabled = true;
});
ctx.state
.storage
.put(
"terraform/modules/acme/vpc/aws/1.0.0/source.tar.gz",
b"0123456789",
)
.await
.unwrap();
let url = "/terraform/v1/modules/download/acme/vpc/aws/1.0.0/source";
let resp =
send_with_headers(&ctx.app, Method::GET, url, vec![("range", "bytes=2-5")], "").await;
assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(
resp.headers()
.get("content-range")
.unwrap()
.to_str()
.unwrap(),
"bytes 2-5/10"
);
assert_eq!(&body_bytes(resp).await[..], b"2345");
let resp = send(&ctx.app, Method::GET, url, "").await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("accept-ranges")
.unwrap()
.to_str()
.unwrap(),
"bytes"
);
}
}