use std::io::Read;
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow};
use reqwest::blocking::Client;
use sha2::{Digest, Sha256};
static SHARED_CLIENT: OnceLock<Client> = OnceLock::new();
const SHARED_CLIENT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
pub fn shared_client() -> &'static Client {
SHARED_CLIENT.get_or_init(|| {
Client::builder()
.connect_timeout(SHARED_CLIENT_CONNECT_TIMEOUT)
.build()
.expect("build shared reqwest client")
})
}
static RELEASES_CACHE: OnceLock<Vec<Release>> = OnceLock::new();
static STABLE_TAGS_CACHE: OnceLock<Vec<String>> = OnceLock::new();
pub(crate) fn cached_releases() -> Result<Vec<Release>> {
cached_releases_with(shared_client())
}
fn is_shared_client(client: &Client) -> bool {
match SHARED_CLIENT.get() {
Some(singleton) => std::ptr::eq(client, singleton),
None => false,
}
}
fn cached_releases_with(client: &Client) -> Result<Vec<Release>> {
cached_releases_with_url(client, RELEASES_URL)
}
fn cached_releases_with_url(client: &Client, url: &str) -> Result<Vec<Release>> {
if !is_shared_client(client) {
return fetch_releases(client, url);
}
debug_assert!(
url == RELEASES_URL,
"cached_releases_with_url: shared_client() must use RELEASES_URL \
to avoid RELEASES_CACHE pollution — got url={url:?}, expected \
RELEASES_URL ({RELEASES_URL:?}). Tests that need URL injection \
must pass a non-singleton Client (which takes the bypass branch \
above and never touches the cache).",
);
if url != RELEASES_URL {
return fetch_releases(client, url);
}
if let Some(cached) = RELEASES_CACHE.get() {
return Ok(cached.clone());
}
let fresh = fetch_releases(client, url)?;
let _ = RELEASES_CACHE.set(fresh.clone());
Ok(fresh)
}
#[non_exhaustive]
pub struct AcquiredSource {
pub source_dir: PathBuf,
pub cache_key: String,
pub version: Option<String>,
pub kernel_source: crate::cache::KernelSource,
pub is_temp: bool,
pub is_dirty: bool,
pub is_git: bool,
}
pub fn arch_info() -> (&'static str, &'static str) {
#[cfg(target_arch = "x86_64")]
{
("x86_64", "bzImage")
}
#[cfg(target_arch = "aarch64")]
{
("aarch64", "Image")
}
}
fn major_version(version: &str) -> Result<u32> {
let major_str = version
.split('.')
.next()
.ok_or_else(|| anyhow!("invalid version: {version}"))?;
major_str
.parse::<u32>()
.with_context(|| format!("invalid major version in {version}"))
}
fn is_rc(version: &str) -> bool {
version.contains("-rc")
}
#[derive(Clone, Debug)]
pub(crate) struct Release {
pub moniker: String,
pub version: String,
}
pub(crate) fn is_skippable_release_moniker(moniker: &str) -> bool {
moniker == "linux-next"
}
fn latest_in_series(client: &Client, version: &str) -> Option<String> {
let prefix = {
let parts: Vec<&str> = version.split('.').collect();
if parts.len() >= 2 {
format!("{}.{}", parts[0], parts[1])
} else {
return None;
}
};
let releases = cached_releases_with(client).ok()?;
let mut best: Option<(String, (u32, u32, u32))> = None;
for r in &releases {
if is_skippable_release_moniker(&r.moniker) {
continue;
}
if !r.version.starts_with(&prefix) {
continue;
}
if r.version.len() != prefix.len() && r.version.as_bytes()[prefix.len()] != b'.' {
continue;
}
if let Some(tuple) = version_tuple(&r.version)
&& (best.is_none() || tuple > best.as_ref().unwrap().1)
{
best = Some((r.version.clone(), tuple));
}
}
best.map(|(v, _)| v)
}
fn version_not_found_msg(client: &Client, version: &str) -> String {
let parts: Vec<&str> = version.split('.').collect();
let prefix = if parts.len() >= 2 {
format!("{}.{}", parts[0], parts[1])
} else {
version.to_string()
};
match latest_in_series(client, version) {
Some(latest) if latest != version => {
format!("version {version} not found. latest {prefix}.x: {latest}")
}
_ => format!("version {version} not found"),
}
}
fn reject_html_response(response: &reqwest::blocking::Response, url: &str) -> Result<()> {
if let Some(ct) = response.headers().get(reqwest::header::CONTENT_TYPE)
&& let Ok(ct_str) = ct.to_str()
&& ct_str.contains("text/html")
{
anyhow::bail!(
"download {url}: server returned HTML instead of tarball (URL may be invalid)"
);
}
Ok(())
}
fn print_download_size(
response: &reqwest::blocking::Response,
url: &str,
cli_label: &str,
mp: Option<&crate::cli::FetchProgress>,
) {
let line = if let Some(len) = response.content_length() {
let mib = len as f64 / (1024.0 * 1024.0);
format!("{cli_label}: downloading {url} ({mib:.1} MiB)")
} else {
format!("{cli_label}: downloading {url}")
};
match mp {
Some(fp) => fp.println(&line),
None => eprintln!("{line}"),
}
}
const DOWNLOAD_NO_PROGRESS_TIMEOUT: Duration = Duration::from_secs(60);
struct DownloadStream<R: Read> {
inner: R,
hasher: Sha256,
bytes_total: u64,
last_progress: Instant,
no_progress_timeout: Duration,
progress: Option<indicatif::ProgressBar>,
}
impl<R: Read> DownloadStream<R> {
fn with_progress(inner: R, progress: Option<indicatif::ProgressBar>) -> Self {
Self {
inner,
hasher: Sha256::new(),
bytes_total: 0,
last_progress: Instant::now(),
no_progress_timeout: DOWNLOAD_NO_PROGRESS_TIMEOUT,
progress,
}
}
fn finalize(self) -> (String, u64) {
(hex::encode(self.hasher.finalize()), self.bytes_total)
}
}
impl<R: Read> Read for DownloadStream<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let elapsed = self.last_progress.elapsed();
if elapsed > self.no_progress_timeout {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!(
"download stalled: no body bytes for {}s after {} bytes received",
elapsed.as_secs(),
self.bytes_total,
),
));
}
match self.inner.read(buf) {
Ok(0) => {
Ok(0)
}
Ok(n) => {
self.hasher.update(&buf[..n]);
self.bytes_total += n as u64;
self.last_progress = Instant::now();
if let Some(pb) = &self.progress {
pb.inc(n as u64);
}
Ok(n)
}
Err(e) => Err(e),
}
}
}
const DOWNLOAD_REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(300);
const SHA256SUMS_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
fn sha256sums_url(major: u32) -> String {
format!("https://cdn.kernel.org/pub/linux/kernel/v{major}.x/sha256sums.asc")
}
fn fetch_sha256sums_from_url(client: &Client, url: &str) -> Result<String> {
tracing::info!(%url, "fetching kernel tarball sha256sums (requires network)");
let response = client
.get(url)
.timeout(SHA256SUMS_REQUEST_TIMEOUT)
.send()
.with_context(|| format!("fetch {url}"))?;
if !response.status().is_success() {
anyhow::bail!("fetch {url}: HTTP {}", response.status());
}
response
.text()
.with_context(|| format!("read body of {url}"))
}
fn parse_sha256_for_file(manifest: &str, target_filename: &str) -> Option<String> {
let body = manifest
.split_once("-----BEGIN PGP SIGNATURE-----")
.map(|(before, _)| before)
.unwrap_or(manifest);
for line in body.lines() {
let line = line.trim();
let mut parts = line.split_whitespace();
let Some(hash) = parts.next() else { continue };
let Some(name) = parts.next() else { continue };
if name != target_filename {
continue;
}
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
continue;
}
return Some(hash.to_ascii_lowercase());
}
None
}
fn verify_sha256(actual_hex: &str, expected_hex: &str, url: &str) -> Result<()> {
if actual_hex.eq_ignore_ascii_case(expected_hex) {
Ok(())
} else {
anyhow::bail!(
"sha256 mismatch for {url}: expected {}, got {}. \
If cdn.kernel.org updated this tarball in-place, \
retry with --skip-sha256 to bypass verification.",
expected_hex.to_ascii_lowercase(),
actual_hex.to_ascii_lowercase(),
);
}
}
fn resolve_expected_sha256(
client: &Client,
major: u32,
tarball_name: &str,
skip_sha256: bool,
) -> Option<String> {
resolve_expected_sha256_from_url(client, &sha256sums_url(major), tarball_name, skip_sha256)
}
fn resolve_expected_sha256_from_url(
client: &Client,
sha256sums_url: &str,
tarball_name: &str,
skip_sha256: bool,
) -> Option<String> {
if skip_sha256 {
tracing::warn!(
tarball = %tarball_name,
"--skip-sha256: bypassing checksum verification — the \
downloaded tarball will not be authenticated against \
cdn.kernel.org's sha256sums.asc manifest. Use only when \
upstream has updated a tarball in-place and the manifest \
is mismatched.",
);
return None;
}
match fetch_sha256sums_from_url(client, sha256sums_url) {
Ok(manifest) => match parse_sha256_for_file(&manifest, tarball_name) {
Some(hex) => Some(hex),
None => {
tracing::warn!(
tarball = %tarball_name,
"sha256sums.asc fetched but no entry for {tarball_name}; \
download will proceed without checksum verification. \
Pass --skip-sha256 to bypass the manifest fetch when \
the entry is known to be absent.",
);
None
}
},
Err(err) => {
tracing::warn!(
error = %format!("{err:#}"),
"failed to fetch sha256sums.asc; download will proceed \
without checksum verification. Pass --skip-sha256 to \
bypass the manifest fetch when the manifest is known \
to be unavailable.",
);
None
}
}
}
const STABLE_MIRROR_URL: &str = "https://github.com/gregkh/linux";
#[derive(Debug)]
struct TarballNotFound;
impl std::fmt::Display for TarballNotFound {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("stable tarball pruned from cdn.kernel.org (EOL or superseded point release)")
}
}
impl std::error::Error for TarballNotFound {}
fn download_stable_tarball(
client: &Client,
version: &str,
dest_dir: &Path,
cli_label: &str,
skip_sha256: bool,
mp: Option<&crate::cli::FetchProgress>,
) -> Result<PathBuf> {
let major = major_version(version)?;
let url = format!("https://cdn.kernel.org/pub/linux/kernel/v{major}.x/linux-{version}.tar.xz");
download_stable_tarball_from_url(client, &url, version, dest_dir, cli_label, skip_sha256, mp)
}
fn download_stable_tarball_from_url(
client: &Client,
url: &str,
version: &str,
dest_dir: &Path,
cli_label: &str,
skip_sha256: bool,
mp: Option<&crate::cli::FetchProgress>,
) -> Result<PathBuf> {
let major = major_version(version)?;
let tarball_name = format!("linux-{version}.tar.xz");
let expected_sha256 = resolve_expected_sha256(client, major, &tarball_name, skip_sha256);
tracing::info!(%url, "downloading stable kernel tarball (requires network)");
let response = client
.get(url)
.timeout(DOWNLOAD_REQUEST_READ_TIMEOUT)
.send()
.with_context(|| format!("download {url}"))?;
if !response.status().is_success() {
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(anyhow::Error::new(TarballNotFound));
}
anyhow::bail!("download {url}: HTTP {}", response.status());
}
reject_html_response(&response, url)?;
print_download_size(&response, url, cli_label, mp);
let total = response.content_length();
let status = |line: &str| match mp {
Some(fp) => fp.println(line),
None => eprintln!("{line}"),
};
status(&format!("{cli_label}: extracting tarball (xz)"));
let staging =
tempfile::TempDir::new_in(dest_dir).with_context(|| "create extraction staging dir")?;
let download_bar = mp.map(|fp| fp.download_bar(version, total));
let stream = DownloadStream::with_progress(response, download_bar.as_ref().map(|b| b.bar()));
let decoder = xz2::read::XzDecoder::new(stream);
let mut archive = tar::Archive::new(decoder);
archive
.unpack(staging.path())
.with_context(|| "extract tarball")?;
let stream = archive.into_inner().into_inner();
let (actual_hex, bytes_total) = stream.finalize();
if let Some(bar) = &download_bar {
bar.finish();
}
if let Some(expected) = expected_sha256.as_deref() {
verify_sha256(&actual_hex, expected, url)?;
status(&format!(
"{cli_label}: sha256 verified ({bytes_total} bytes, hash {actual_hex})"
));
} else if !skip_sha256 {
tracing::warn!(
url = %url,
bytes = bytes_total,
sha256 = %actual_hex,
"no expected sha256 available for {url}; computed digest \
{actual_hex} over {bytes_total} bytes is unverified",
);
}
let source_dir = promote_staged_kernel_tree(&staging, dest_dir, version)?;
Ok(source_dir)
}
fn promote_staged_kernel_tree(
staging: &tempfile::TempDir,
dest_dir: &Path,
version: &str,
) -> Result<PathBuf> {
let expected_name = format!("linux-{version}");
let mut found_inner = false;
for entry in std::fs::read_dir(staging.path()).with_context(|| "read staging dir entries")? {
let entry = entry.with_context(|| "iterate staging dir entry")?;
let name = entry.file_name();
if name == std::ffi::OsStr::new(&expected_name) {
found_inner = true;
} else {
anyhow::bail!(
"tarball contains unexpected top-level entry {name:?}; \
expected only {expected_name}/"
);
}
}
if !found_inner {
anyhow::bail!("expected directory {expected_name} after extraction");
}
let inner = staging.path().join(&expected_name);
let source_dir = dest_dir.join(&expected_name);
std::fs::rename(&inner, &source_dir)
.with_context(|| format!("rename {} -> {}", inner.display(), source_dir.display()))?;
Ok(source_dir)
}
fn promote_single_kernel_tree(
staging: &tempfile::TempDir,
dest_dir: &Path,
canonical: &str,
) -> Result<PathBuf> {
let mut entries = Vec::new();
for entry in std::fs::read_dir(staging.path()).with_context(|| "read staging dir entries")? {
entries.push(entry.with_context(|| "iterate staging dir entry")?);
}
if entries.len() != 1 {
anyhow::bail!(
"codeload archive must contain exactly one top-level entry; found {}",
entries.len()
);
}
let inner = entries[0].path();
let entry_type = entries[0]
.file_type()
.with_context(|| "stat codeload top-level entry")?;
if !entry_type.is_dir() {
anyhow::bail!(
"codeload archive top-level entry is not a plain directory: {}",
inner.display()
);
}
let source_dir = dest_dir.join(canonical);
std::fs::rename(&inner, &source_dir)
.with_context(|| format!("rename {} -> {}", inner.display(), source_dir.display()))?;
Ok(source_dir)
}
fn download_rc_tarball(
client: &Client,
version: &str,
dest_dir: &Path,
cli_label: &str,
mp: Option<&crate::cli::FetchProgress>,
) -> Result<PathBuf> {
let url = format!("https://git.kernel.org/torvalds/t/linux-{version}.tar.gz");
tracing::info!(%url, "downloading RC kernel tarball (requires network)");
let response = client
.get(&url)
.timeout(DOWNLOAD_REQUEST_READ_TIMEOUT)
.send()
.with_context(|| format!("download {url}"))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!(
"RC tarball not found: {url}\n \
RC releases are removed from git.kernel.org after the stable version ships."
);
}
if !response.status().is_success() {
anyhow::bail!("download {url}: HTTP {}", response.status());
}
reject_html_response(&response, &url)?;
print_download_size(&response, &url, cli_label, mp);
let total = response.content_length();
let status = |line: &str| match mp {
Some(fp) => fp.println(line),
None => eprintln!("{line}"),
};
status(&format!("{cli_label}: extracting tarball (gzip)"));
let staging =
tempfile::TempDir::new_in(dest_dir).with_context(|| "create extraction staging dir")?;
let download_bar = mp.map(|fp| fp.download_bar(version, total));
let stream = DownloadStream::with_progress(response, download_bar.as_ref().map(|b| b.bar()));
let decoder = flate2::read::GzDecoder::new(stream);
let mut archive = tar::Archive::new(decoder);
archive
.unpack(staging.path())
.with_context(|| "extract tarball")?;
let stream = archive.into_inner().into_inner();
let (actual_hex, bytes_total) = stream.finalize();
if let Some(bar) = &download_bar {
bar.finish();
}
tracing::warn!(
url = %url,
bytes = bytes_total,
sha256 = %actual_hex,
"no expected sha256 available for {url} (RC tarballs are \
dynamically generated by git.kernel.org and have no \
published manifest); computed digest {actual_hex} over \
{bytes_total} bytes is unverified",
);
let source_dir = promote_staged_kernel_tree(&staging, dest_dir, version)?;
Ok(source_dir)
}
pub(crate) fn download_github_archive(
client: &Client,
archive_url: &str,
git_ref: &str,
commit_hash: &str,
dest_dir: &Path,
cli_label: &str,
mp: Option<&crate::cli::FetchProgress>,
) -> Result<AcquiredSource> {
tracing::info!(%archive_url, "downloading GitHub codeload snapshot (requires network)");
let response = client
.get(archive_url)
.timeout(DOWNLOAD_REQUEST_READ_TIMEOUT)
.send()
.with_context(|| format!("download {archive_url}"))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!(
"codeload snapshot not found: {archive_url}\n \
the ref may not exist on the remote, or the repo is private"
);
}
if !response.status().is_success() {
anyhow::bail!("download {archive_url}: HTTP {}", response.status());
}
reject_html_response(&response, archive_url)?;
print_download_size(&response, archive_url, cli_label, mp);
let total = response.content_length();
let status = |line: &str| match mp {
Some(fp) => fp.println(line),
None => eprintln!("{line}"),
};
status(&format!("{cli_label}: extracting snapshot (gzip)"));
let staging =
tempfile::TempDir::new_in(dest_dir).with_context(|| "create extraction staging dir")?;
let short_hash: String = commit_hash.chars().take(7).collect();
let download_bar = mp.map(|fp| fp.download_bar(git_ref, total));
let stream = DownloadStream::with_progress(response, download_bar.as_ref().map(|b| b.bar()));
let decoder = flate2::read::GzDecoder::new(stream);
let mut archive = tar::Archive::new(decoder);
archive
.unpack(staging.path())
.with_context(|| "extract snapshot")?;
let stream = archive.into_inner().into_inner();
let (actual_hex, bytes_total) = stream.finalize();
if let Some(bar) = &download_bar {
bar.finish();
}
tracing::info!(
url = %archive_url,
bytes = bytes_total,
sha256 = %actual_hex,
"codeload snapshot extracted (unverified: codeload archives have \
no published sha256 manifest)",
);
let canonical = format!("linux-git-{short_hash}");
let source_dir = promote_single_kernel_tree(&staging, dest_dir, &canonical)?;
let version = read_makefile_version(&source_dir);
Ok(AcquiredSource {
source_dir,
cache_key: git_cache_key(git_ref, commit_hash),
version,
kernel_source: crate::cache::KernelSource::git(short_hash, git_ref),
is_temp: true,
is_dirty: false,
is_git: true,
})
}
pub fn download_tarball(
client: &Client,
version: &str,
dest_dir: &Path,
cli_label: &str,
skip_sha256: bool,
mp: Option<&crate::cli::FetchProgress>,
) -> Result<AcquiredSource> {
let (arch, _) = arch_info();
let source_dir = if is_rc(version) {
download_rc_tarball(client, version, dest_dir, cli_label, mp)?
} else {
match download_stable_tarball(client, version, dest_dir, cli_label, skip_sha256, mp) {
Ok(dir) => dir,
Err(e) if e.downcast_ref::<TarballNotFound>().is_some() => {
let tag = format!("v{version}");
let Some(commit_hash) = resolve_ref_commit(
STABLE_MIRROR_URL,
&tag,
crate::kernel_path::GitRefKind::Tag,
) else {
anyhow::bail!("{}", version_not_found_msg(client, version));
};
let archive_url = github_archive_url(STABLE_MIRROR_URL, &commit_hash)
.expect("STABLE_MIRROR_URL is a github.com URL");
let msg = format!(
"{cli_label}: {version} not on cdn.kernel.org (pruned/EOL); \
fetching gregkh mirror tag {tag}"
);
match mp {
Some(fp) => fp.println(&msg),
None => eprintln!("{msg}"),
}
download_github_archive(
client,
&archive_url,
&tag,
&commit_hash,
dest_dir,
cli_label,
mp,
)?
.source_dir
}
Err(e) => return Err(e),
}
};
Ok(AcquiredSource {
source_dir,
cache_key: format!("{version}-tarball-{arch}-kc{}", crate::cache_key_suffix()),
version: Some(version.to_string()),
kernel_source: crate::cache::KernelSource::Tarball,
is_temp: true,
is_dirty: false,
is_git: true,
})
}
fn patch_level(version: &str) -> Option<u32> {
let parts: Vec<&str> = version.split('.').collect();
match parts.len() {
2 => Some(0), 3 => parts[2].parse().ok(),
_ => None,
}
}
pub(crate) const RELEASES_URL: &str = "https://www.kernel.org/releases.json";
pub(crate) fn fetch_releases(client: &Client, url: &str) -> Result<Vec<Release>> {
tracing::info!(%url, "fetching kernel.org releases index (requires network)");
let response = client
.get(url)
.send()
.with_context(|| format!("fetch {url}"))?;
if !response.status().is_success() {
anyhow::bail!("fetch {url}: HTTP {}", response.status());
}
let body = response.text().with_context(|| "read response body")?;
parse_releases_body(&body)
}
fn parse_releases_body(body: &str) -> Result<Vec<Release>> {
let json: serde_json::Value =
serde_json::from_str(body).with_context(|| "parse releases.json")?;
let releases = json
.get("releases")
.and_then(|r| r.as_array())
.ok_or_else(|| anyhow!("releases.json: missing releases array"))?;
let input_rows = releases.len();
let parsed: Vec<Release> = releases
.iter()
.filter_map(|r| {
let moniker = r.get("moniker")?.as_str()?;
let version = r.get("version")?.as_str()?;
Some(Release {
moniker: moniker.to_string(),
version: version.to_string(),
})
})
.collect();
let dropped = input_rows - parsed.len();
if dropped > 0 {
tracing::warn!(
input_rows,
parsed_rows = parsed.len(),
dropped,
"releases.json: dropped {dropped} of {input_rows} row(s) \
missing moniker/version (or non-string values); cached \
snapshot will reflect this for the process lifetime"
);
}
Ok(parsed)
}
pub fn fetch_latest_stable_version(client: &Client, cli_label: &str) -> Result<String> {
eprintln!("{cli_label}: fetching latest kernel version");
let releases = cached_releases_with(client)?;
let mut best: Option<&str> = None;
for r in &releases {
if r.moniker != "stable" && r.moniker != "longterm" {
continue;
}
if patch_level(&r.version).unwrap_or(0) < 8 {
continue;
}
best = Some(r.version.as_str());
break;
}
let version =
best.ok_or_else(|| anyhow!("no stable kernel with patch >= 8 found in releases.json"))?;
eprintln!("{cli_label}: latest stable kernel: {version}");
Ok(version.to_string())
}
fn version_tuple(version: &str) -> Option<(u32, u32, u32)> {
let parts: Vec<&str> = version.split('.').collect();
match parts.len() {
2 => {
let major = parts[0].parse().ok()?;
let minor = parts[1].parse().ok()?;
Some((major, minor, 0))
}
3 => {
let major = parts[0].parse().ok()?;
let minor = parts[1].parse().ok()?;
let patch = parts[2].parse().ok()?;
Some((major, minor, patch))
}
_ => None,
}
}
pub fn is_major_minor_prefix(s: &str) -> bool {
s.matches('.').count() < 2 && !s.contains("-rc")
}
pub fn fetch_version_for_prefix(client: &Client, prefix: &str, cli_label: &str) -> Result<String> {
eprintln!("{cli_label}: fetching latest {prefix}.x kernel version");
let releases = cached_releases_with(client)?;
let mut best: Option<(&str, (u32, u32, u32))> = None;
for r in &releases {
if is_skippable_release_moniker(&r.moniker) {
continue;
}
if !r.version.starts_with(prefix) {
continue;
}
if r.version.len() != prefix.len() && r.version.as_bytes()[prefix.len()] != b'.' {
continue;
}
let Some(tuple) = version_tuple(&r.version) else {
continue;
};
if best.is_none() || tuple > best.unwrap().1 {
best = Some((r.version.as_str(), tuple));
}
}
if let Some((version, _)) = best {
eprintln!("{cli_label}: latest {prefix}.x kernel: {version}");
return Ok(version.to_string());
}
eprintln!(
"{cli_label}: {prefix}.x not in releases.json (EOL or unreleased series); \
resolving latest patch via the gregkh mirror tags"
);
match latest_patch_from_git_tags(STABLE_MIRROR_URL, prefix, cli_label)? {
Some(version) => Ok(version),
None => {
eprintln!(
"{cli_label}: no {prefix}.x stable point release; using {prefix} mainline base"
);
Ok(prefix.to_string())
}
}
}
fn latest_patch_from_git_tags(url: &str, prefix: &str, cli_label: &str) -> Result<Option<String>> {
eprintln!("{cli_label}: resolving {prefix}.x release tags via {url}");
let refs = ls_remote_refs(url)
.with_context(|| format!("ls-remote {url} for {prefix}.x release tags"))?;
match max_tag_patch(refs.iter().map(ref_full_name), prefix) {
Some(patch) => {
let version = format!("{prefix}.{patch}");
eprintln!("{cli_label}: latest {prefix}.x kernel (from git tags): {version}");
Ok(Some(version))
}
None => Ok(None),
}
}
fn ref_full_name(r: &gix::protocol::handshake::Ref) -> &[u8] {
use gix::protocol::handshake::Ref::{Direct, Peeled, Symbolic, Unborn};
match r {
Peeled { full_ref_name, .. }
| Direct { full_ref_name, .. }
| Symbolic { full_ref_name, .. }
| Unborn { full_ref_name, .. } => full_ref_name.as_ref(),
}
}
fn max_tag_patch<'a>(ref_names: impl Iterator<Item = &'a [u8]>, prefix: &str) -> Option<u32> {
let needle = format!("refs/tags/v{prefix}.");
let mut best: Option<u32> = None;
for name in ref_names {
let Some(rest) = name.strip_prefix(needle.as_bytes()) else {
continue;
};
let rest = rest.strip_suffix(b"^{}").unwrap_or(rest);
if let Ok(s) = std::str::from_utf8(rest)
&& let Ok(patch) = s.parse::<u32>()
{
best = Some(best.map_or(patch, |b| b.max(patch)));
}
}
best
}
pub(crate) fn cached_stable_tags() -> Option<&'static [String]> {
if let Some(tags) = STABLE_TAGS_CACHE.get() {
return Some(tags.as_slice());
}
let refs = ls_remote_refs(STABLE_MIRROR_URL)?;
let tags: Vec<String> = refs
.iter()
.filter_map(|r| {
let name = ref_full_name(r);
let v = name.strip_prefix(b"refs/tags/v")?;
let v = v.strip_suffix(b"^{}").unwrap_or(v);
std::str::from_utf8(v).ok().map(|s| s.to_string())
})
.collect();
let _ = STABLE_TAGS_CACHE.set(tags);
STABLE_TAGS_CACHE.get().map(|v| v.as_slice())
}
pub(crate) fn git_cache_key(git_ref: &str, commit_hash: &str) -> String {
let (arch, _) = arch_info();
let safe_ref: String = git_ref
.chars()
.map(|c| {
if c == '/' || c == '\\' || c == '\0' {
'_'
} else {
c
}
})
.collect();
let safe_ref = safe_ref.replace("..", "__");
let safe_ref = if safe_ref.starts_with('.') {
format!("_{safe_ref}")
} else {
safe_ref
};
format!(
"{safe_ref}-git-{commit_hash}-{arch}-kc{}",
crate::cache_key_suffix()
)
}
pub(crate) fn github_archive_url(url: &str, commit_hash: &str) -> Option<String> {
let mut path = None;
for prefix in [
"https://github.com/",
"http://github.com/",
"ssh://git@github.com/",
"ssh://github.com/",
"git://github.com/",
"git@github.com:",
] {
if url
.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
{
path = Some(&url[prefix.len()..]);
break;
}
}
let path = path?;
let path = path.trim_end_matches('/');
let path = path.strip_suffix(".git").unwrap_or(path);
let mut segs = path.split('/');
let owner = segs.next().filter(|s| !s.is_empty())?;
let repo = segs.next().filter(|s| !s.is_empty())?;
if segs.next().is_some() {
return None;
}
Some(format!(
"https://github.com/{owner}/{repo}/archive/{}.tar.gz",
commit_hash.to_ascii_lowercase()
))
}
fn pick_ref_object(
refs: &[gix::protocol::handshake::Ref],
target: &str,
) -> Option<gix::hash::ObjectId> {
refs.iter().find_map(|r| {
let (name, object) = match r {
gix::protocol::handshake::Ref::Peeled {
full_ref_name,
object,
..
}
| gix::protocol::handshake::Ref::Direct {
full_ref_name,
object,
}
| gix::protocol::handshake::Ref::Symbolic {
full_ref_name,
object,
..
} => (full_ref_name, object),
gix::protocol::handshake::Ref::Unborn { .. } => return None,
};
(*name == target).then_some(*object)
})
}
pub(crate) fn resolve_ref_commit(
url: &str,
git_ref: &str,
ref_kind: crate::kernel_path::GitRefKind,
) -> Option<String> {
use crate::kernel_path::GitRefKind;
let target = match ref_kind {
GitRefKind::Sha => return Some(git_ref.to_ascii_lowercase()),
GitRefKind::Tag => format!("refs/tags/{git_ref}"),
GitRefKind::Branch => format!("refs/heads/{git_ref}"),
GitRefKind::Unknown => return None,
};
pick_ref_object(&ls_remote_refs(url)?, &target).map(|object| format!("{object}"))
}
fn is_full_sha(git_ref: &str) -> bool {
git_ref.len() == 40 && git_ref.bytes().all(|b| b.is_ascii_hexdigit())
}
fn ls_remote_refs(url: &str) -> Option<Vec<gix::protocol::handshake::Ref>> {
let tmp = tempfile::TempDir::new().ok()?;
let repo = gix::ThreadSafeRepository::init_opts(
tmp.path(),
gix::create::Kind::WithWorktree,
gix::create::Options::default(),
anon_open_opts(),
)
.ok()?
.to_thread_local();
let remote = repo.remote_at(url).ok()?;
let conn = remote.connect(gix::remote::Direction::Fetch).ok()?;
let (refmap, _handshake) = conn
.ref_map(
gix::progress::Discard,
gix::remote::ref_map::Options {
prefix_from_spec_as_filter_on_remote: false,
..Default::default()
},
)
.ok()?;
Some(refmap.remote_refs)
}
fn anon_open_opts() -> gix::open::Options {
use gix::sec::trust::DefaultForLevel;
let mut opts = gix::open::Options::default_for_level(gix::sec::Trust::Full);
opts.permissions.config.system = false;
opts.permissions.config.git = false;
opts.permissions.config.user = false;
opts.permissions.config.env = false;
opts.permissions.config.git_binary = false;
opts
}
pub fn git_clone(
url: &str,
git_ref: &str,
dest_dir: &Path,
cli_label: &str,
mp: Option<&crate::cli::FetchProgress>,
) -> Result<AcquiredSource> {
git_clone_inner(url, git_ref, dest_dir, cli_label, mp, None)
}
pub(crate) fn git_clone_tag(
url: &str,
tag: &str,
dest_dir: &Path,
cli_label: &str,
mp: Option<&crate::cli::FetchProgress>,
) -> Result<AcquiredSource> {
let extra_refspec = format!("+refs/tags/{tag}:refs/heads/{tag}");
git_clone_inner(url, tag, dest_dir, cli_label, mp, Some(extra_refspec))
}
pub(crate) fn git_clone_kinded(
url: &str,
git_ref: &str,
ref_kind: crate::kernel_path::GitRefKind,
dest_dir: &Path,
cli_label: &str,
mp: Option<&crate::cli::FetchProgress>,
) -> Result<AcquiredSource> {
use crate::kernel_path::GitRefKind;
match ref_kind {
GitRefKind::Tag => git_clone_tag(url, git_ref, dest_dir, cli_label, mp),
GitRefKind::Branch => git_clone(url, git_ref, dest_dir, cli_label, mp),
GitRefKind::Sha => anyhow::bail!(
"git+{url}#sha={git_ref}: fetching this source by commit sha is \
not supported — gix cannot fetch a bare commit and the remote \
lacks allow-sha-in-want. Use a github.com/OWNER/REPO URL \
(codeload serves any commit) or pin a #tag= / #branch= instead."
),
GitRefKind::Unknown => anyhow::bail!(
"git+{url}: ref kind could not be determined; use #tag=NAME, \
#branch=NAME, or #sha=<40-hex>"
),
}
}
fn git_clone_inner(
url: &str,
git_ref: &str,
dest_dir: &Path,
cli_label: &str,
mp: Option<&crate::cli::FetchProgress>,
extra_refspec: Option<String>,
) -> Result<AcquiredSource> {
if is_full_sha(git_ref) {
anyhow::bail!(
"git+{url}#{git_ref}: cannot fetch a kernel by a raw commit SHA — \
gix's shallow clone treats any 40-hex ref as a commit id (even a \
branch/tag named 40 hex chars). Use a branch or tag name that is \
not 40 hex chars, or on github.com `#sha=<40-hex>` (codeload \
fetches the commit)."
);
}
let cloning = format!("{cli_label}: cloning {url} (ref: {git_ref}, depth: 1)");
match mp {
Some(fp) => fp.println(&cloning),
None => eprintln!("{cloning}"),
}
let clone_dir = dest_dir.join("linux");
let mut prep = gix::clone::PrepareFetch::new(
url,
&clone_dir,
gix::create::Kind::WithWorktree,
gix::create::Options::default(),
anon_open_opts(),
)
.with_context(|| "prepare clone")?
.with_shallow(gix::remote::fetch::Shallow::DepthAtRemote(
NonZeroU32::new(1).expect("1 is nonzero"),
))
.with_ref_name(Some(git_ref))
.with_context(|| "set ref name")?;
if let Some(spec) = extra_refspec {
prep = prep.configure_remote(move |remote| {
Ok(remote.with_refspecs(Some(spec.as_str()), gix::remote::Direction::Fetch)?)
});
}
let clone_progress = mp.map(|fp| fp.clone_progress(git_ref));
let interrupt = std::sync::atomic::AtomicBool::new(false);
let (mut checkout, _outcome) = match &clone_progress {
Some(cp) => prep
.fetch_then_checkout(cp.item(), &interrupt)
.with_context(|| "clone fetch")?,
None => prep
.fetch_then_checkout(gix::progress::Discard, &interrupt)
.with_context(|| "clone fetch")?,
};
let (_repo, _outcome) = match &clone_progress {
Some(cp) => checkout
.main_worktree(cp.item(), &interrupt)
.with_context(|| "checkout")?,
None => checkout
.main_worktree(gix::progress::Discard, &interrupt)
.with_context(|| "checkout")?,
};
if let Some(cp) = clone_progress {
cp.finish();
}
let repo = gix::open(&clone_dir).with_context(|| "open cloned repo")?;
let head = repo.head_id().with_context(|| "read HEAD")?;
let commit_hash = format!("{head}");
let short_hash = commit_hash.chars().take(7).collect::<String>();
let cache_key = git_cache_key(git_ref, &commit_hash);
let version = read_makefile_version(&clone_dir);
Ok(AcquiredSource {
source_dir: clone_dir,
cache_key,
version,
kernel_source: crate::cache::KernelSource::git(short_hash, git_ref),
is_temp: true,
is_dirty: false,
is_git: true,
})
}
pub fn local_source(source_path: &Path) -> Result<AcquiredSource> {
let (arch, _) = arch_info();
if !source_path.is_dir() {
anyhow::bail!("{}: not a directory", source_path.display());
}
let canonical = source_path
.canonicalize()
.with_context(|| format!("canonicalize {}", source_path.display()))?;
let LocalSourceState {
short_hash,
is_dirty,
is_git,
} = inspect_local_source_state(&canonical)?;
let user_config_hash = config_hash_for_key(&canonical);
let cache_key =
compose_local_cache_key(arch, &short_hash, &canonical, user_config_hash.as_deref());
let version = read_makefile_version(&canonical);
Ok(AcquiredSource {
source_dir: canonical.clone(),
cache_key,
version,
kernel_source: crate::cache::KernelSource::Local {
source_tree_path: Some(canonical),
git_hash: short_hash,
},
is_temp: false,
is_dirty,
is_git,
})
}
fn read_makefile_version(source_dir: &Path) -> Option<String> {
let text = std::fs::read_to_string(source_dir.join("Makefile")).ok()?;
let field = |name: &str| -> Option<u16> {
text.lines().find_map(|line| {
line.trim()
.strip_prefix(name)?
.trim_start()
.strip_prefix('=')?
.trim()
.parse::<u16>()
.ok()
})
};
Some(format!(
"{}.{}.{}",
field("VERSION")?,
field("PATCHLEVEL")?,
field("SUBLEVEL")?
))
}
#[derive(Debug, Clone)]
pub struct LocalSourceState {
pub short_hash: Option<String>,
pub is_dirty: bool,
pub is_git: bool,
}
pub fn inspect_local_source_state(canonical: &Path) -> Result<LocalSourceState> {
let (short_hash, is_dirty, is_git) = match gix::discover(canonical) {
Ok(repo) => {
let head = repo.head_id().with_context(|| "read HEAD")?;
let short_hash = format!("{}", head).chars().take(7).collect::<String>();
let head_tree = repo.head_tree().with_context(|| "read HEAD tree")?;
let head_tree_id = head_tree.id;
let mut index_dirty = false;
let index = repo.index_or_empty().with_context(|| "open index")?;
let _ = repo.tree_index_status(
&head_tree_id,
&index,
None,
gix::status::tree_index::TrackRenames::Disabled,
|_, _, _| {
index_dirty = true;
Ok::<_, std::convert::Infallible>(std::ops::ControlFlow::Break(()))
},
);
let worktree_dirty = if !index_dirty {
repo.status(gix::progress::Discard)
.with_context(|| "status")?
.index_worktree_rewrites(None)
.index_worktree_submodules(gix::status::Submodule::Given {
ignore: gix::submodule::config::Ignore::All,
check_dirty: false,
})
.index_worktree_options_mut(|opts| {
opts.dirwalk_options = None;
})
.into_index_worktree_iter(Vec::new())
.map(|mut iter| iter.next().is_some())
.unwrap_or(false)
} else {
false
};
let is_dirty = index_dirty || worktree_dirty;
let hash = if is_dirty { None } else { Some(short_hash) };
(hash, is_dirty, true)
}
Err(_) => {
(None, true, false)
}
};
Ok(LocalSourceState {
short_hash,
is_dirty,
is_git,
})
}
pub fn compose_local_cache_key(
arch: &str,
short_hash: &Option<String>,
canonical: &Path,
user_config_hash: Option<&str>,
) -> String {
let suffix = crate::cache_key_suffix();
match short_hash {
Some(hash) => match user_config_hash {
Some(cfg) => format!("local-{hash}-{arch}-cfg{cfg}-kc{suffix}"),
None => format!("local-{hash}-{arch}-kc{suffix}"),
},
None => {
let path_hash = canonical_path_hash(canonical);
format!("local-unknown-{path_hash}-{arch}-kc{suffix}")
}
}
}
pub(crate) fn canonical_path_hash(canonical: &Path) -> String {
let bytes = canonical.as_os_str().as_encoded_bytes();
format!("{:08x}", crc32fast::hash(bytes))
}
fn config_hash_for_key(canonical: &Path) -> Option<String> {
let config_path = canonical.join(".config");
let data = std::fs::read(&config_path).ok()?;
Some(format!("{:08x}", crc32fast::hash(&data)))
}
#[cfg(test)]
#[path = "fetch_tests.rs"]
mod tests;