use std::path::{Path, PathBuf}; use std::time::Duration; use std::sync::Arc; use http_body_util::{BodyExt, Empty}; use hyper::Request; use url::Url; use super::{ Access, AcquisitionContext, MAX_ARTIFACT_SIZE, RemoteArtifact, RemoteEntry, artifact_key, atomic_write, checksum, read_artifact, valid_checksum, verify, }; /// Failures fetching remote modules over HTTP(S). #[derive(Clone, Debug, thiserror::Error)] pub enum HttpError { #[error("Invalid HTTP module URL {name}: {source}")] InvalidHttpModuleUrl { name: String, source: url::ParseError, }, #[error("Expected an HTTP(S) module URL: {name}")] ExpectedHttpModuleUrl { name: String }, #[error("HTTP module credentials are not supported: {name}")] HttpModuleCredentials { name: String }, #[error("Invalid remote source checksum")] InvalidRemoteChecksum, #[error("Invalid HTTP cache metadata for {name}: {source}")] InvalidHttpCacheMetadata { name: String, source: Arc, }, #[error("Offline HTTP source cache miss: {name}")] OfflineHttpCacheMiss { name: String }, #[error("Too many HTTP redirects: {name}")] TooManyHttpRedirects { name: String }, #[error("HTTP redirect has no valid Location: {current}")] HttpRedirectNoLocation { current: String }, #[error("HTTP server returned {status} for {current}")] HttpStatus { status: String, current: String }, #[error("HTTP server returned HTML instead of a module: {current}")] HttpHtmlModule { current: String }, #[error("HTTP source is too large: {name}")] HttpSourceTooLarge { name: String }, #[error("{detail}")] HttpRequest { detail: String }, #[error("HTTP request timed out: {name}")] HttpTimeout { name: String }, #[error("Remote final URL mismatch: {name}")] RemoteFinalUrlMismatch { name: String }, #[error("{name}: Package integrity mismatch")] VerifyMismatch { name: String }, #[error("{detail}")] WorkerJoin { detail: String }, #[error("Module is not UTF-8: {name}")] ModuleNotUtf8 { name: String }, #[error(transparent)] Hyper(Arc), #[error(transparent)] Registry(Box), #[error(transparent)] Caps(#[from] crate::caps::CapabilitiesError), #[error(transparent)] Url(#[from] url::ParseError), #[error(transparent)] Io(std::sync::Arc), #[error(transparent)] Json(std::sync::Arc), } pub(super) fn http_url(name: &str) -> Result { let url = Url::parse(name) .map_err(|source| HttpError::InvalidHttpModuleUrl { name: name.to_owned(), source })?; if !matches!(url.scheme(), "http" | "https") || url.host().is_none() { return Err(HttpError::ExpectedHttpModuleUrl { name: name.to_owned() }); } if !url.username().is_empty() || url.password().is_some() { return Err(HttpError::HttpModuleCredentials { name: name.to_owned() }); } Ok(url) } pub(super) fn validate_remote_entry(entry: &RemoteEntry) -> Result<(), HttpError> { if !valid_checksum(&entry.checksum) { return Err(HttpError::InvalidRemoteChecksum); } http_url(&entry.url)?; Ok(()) } pub(super) fn remote_blob(cache: &Path, checksum: &str) -> PathBuf { cache.join("http").join("blobs").join(checksum) } pub(super) fn remote_vendor_path(name: &str, base: &str) -> Result { let url = http_url(base)?; let extension = Path::new(url.path()) .extension() .and_then(|ext| ext.to_str()) .filter(|ext| { !ext.is_empty() && ext.len() <= 16 && ext.bytes().all(|ch| ch.is_ascii_alphanumeric()) }) .unwrap_or("js"); Ok(PathBuf::from("http").join(format!("{}.{extension}", artifact_key(name)))) } pub(super) async fn acquire_remote( context: AcquisitionContext, name: String, url: Url, expected: Option, access: Access, ) -> Result { let AcquisitionContext { client, options, authority, progress, } = context; access.check(authority.as_ref())?; let key = artifact_key(&name); let metadata_path = options.cache.join("http").join("metadata").join(&key); let lock_path = options.cache.join("locks").join(format!("http-{key}")); std::fs::create_dir_all(lock_path.parent().unwrap())?; let _guard = tokio::task::spawn_blocking(move || { let mut guard = fslock::LockFile::open(&lock_path)?; guard.lock()?; Ok::<_, HttpError>(guard) }) .await .map_err(|detail| HttpError::WorkerJoin { detail: detail.to_string() })??; access.check(authority.as_ref())?; let refresh = options.update && !options.offline; let expected = if refresh { None } else { expected }; let mut entry = expected.clone(); if !refresh { if entry.is_none() && let Some(bytes) = read_artifact(&metadata_path)? { entry = Some( serde_json::from_slice::(&bytes).map_err(|source| { HttpError::InvalidHttpCacheMetadata { name: name.to_owned(), source: Arc::new(source), } })?, ); } if let Some(entry) = &entry { validate_remote_entry(entry)?; let vendored = match &options.vendor { Some(vendor) => { read_artifact(&vendor.join(remote_vendor_path(&name, &entry.url)?))? } None => None, }; let bytes = match vendored { Some(bytes) => Some(bytes), None => read_artifact(&remote_blob(&options.cache, &entry.checksum))?, }; if let Some(bytes) = bytes { verify(&bytes, &entry.checksum) .map_err(|_| HttpError::VerifyMismatch { name: name.to_owned() })?; access.check(authority.as_ref())?; if let Some(authority) = authority.as_ref() { authority.check_remote_final(&entry.url)?; } return Ok(RemoteArtifact { bytes, entry: entry.clone() }); } } } if options.offline { return Err(HttpError::OfflineHttpCacheMiss { name: name.to_owned() }); } let download = async { let mut current = url; // Execute keeps existing redirect semantics: only the requested URL // needs authority. Read checks the network for every actual // destination, including each redirect target. let check_current = |current: &Url| -> Result<(), HttpError> { access.check(authority.as_ref())?; if let Some(authority) = authority.as_ref() { authority.check_remote_final(current.as_str())?; } Ok(()) }; for redirects in 0..=10 { http_url(current.as_str())?; check_current(¤t)?; if let Some(sink) = &progress { sink(&super::ProgressEvent::SourceDownload { url: current.to_string() }); check_current(¤t)?; } let mut request_url = current.clone(); request_url.set_fragment(None); let request = Request::get(request_url.as_str()) .header( "Accept", "application/typescript, application/javascript, */*", ) .header("User-Agent", concat!("avril/", env!("CARGO_PKG_VERSION"))) .body(Empty::new()) .map_err(|detail| HttpError::HttpRequest { detail: detail.to_string() })?; let response = client .request(request) .await .map_err(|detail| HttpError::HttpRequest { detail: detail.to_string() })?; check_current(¤t)?; if matches!(response.status().as_u16(), 301 | 302 | 303 | 307 | 308) { if redirects == 10 { return Err(HttpError::TooManyHttpRedirects { name: name.to_owned() }); } let location = response .headers() .get("location") .and_then(|value| value.to_str().ok()) .ok_or_else(|| HttpError::HttpRedirectNoLocation { current: current.to_string(), })?; current = current.join(location)?; continue; } if !response.status().is_success() { return Err(HttpError::HttpStatus { status: response.status().to_string(), current: current.to_string(), }); } if response .headers() .get("content-type") .and_then(|value| value.to_str().ok()) .is_some_and(|value| { matches!( value .split(';') .next() .unwrap_or("") .trim() .to_ascii_lowercase() .as_str(), "text/html" | "application/xhtml+xml" ) }) { return Err(HttpError::HttpHtmlModule { current: current.to_string() }); } let mut body = response.into_body(); let mut bytes = Vec::new(); while let Some(frame) = body.frame().await { if let Ok(data) = frame .map_err(|error| HttpError::Hyper(Arc::new(error)))? .into_data() { if bytes.len() + data.len() > MAX_ARTIFACT_SIZE { return Err(HttpError::HttpSourceTooLarge { name: name.to_owned() }); } bytes.extend_from_slice(&data); } } let source = std::str::from_utf8(&bytes) .map_err(|_| HttpError::ModuleNotUtf8 { name: name.to_owned() })?; let prefix: String = source .trim_start_matches(['\u{feff}', ' ', '\t', '\r', '\n']) .chars() .take(32) .collect(); let prefix = prefix.to_ascii_lowercase(); if prefix.starts_with(" for HttpError { fn from(error: std::io::Error) -> Self { Self::Io(std::sync::Arc::new(error)) } } impl From for HttpError { fn from(error: serde_json::Error) -> Self { Self::Json(std::sync::Arc::new(error)) } } impl From for HttpError { fn from(error: super::RegistryError) -> Self { Self::Registry(Box::new(error)) } }