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();
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) {
if let Some(len) = response.content_length() {
let mib = len as f64 / (1024.0 * 1024.0);
eprintln!("{cli_label}: downloading {url} ({mib:.1} MiB)");
} else {
eprintln!("{cli_label}: downloading {url}");
}
}
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,
}
impl<R: Read> DownloadStream<R> {
fn new(inner: R) -> Self {
Self {
inner,
hasher: Sha256::new(),
bytes_total: 0,
last_progress: Instant::now(),
no_progress_timeout: DOWNLOAD_NO_PROGRESS_TIMEOUT,
}
}
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();
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 fetch_stable_sha256sums(client: &Client, major: u32) -> Result<String> {
let url = format!("https://cdn.kernel.org/pub/linux/kernel/v{major}.x/sha256sums.asc");
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> {
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_stable_sha256sums(client, major) {
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
}
}
}
fn download_stable_tarball(
client: &Client,
version: &str,
dest_dir: &Path,
cli_label: &str,
skip_sha256: bool,
) -> Result<PathBuf> {
let major = major_version(version)?;
let tarball_name = format!("linux-{version}.tar.xz");
let url = format!("https://cdn.kernel.org/pub/linux/kernel/v{major}.x/{tarball_name}");
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 {
anyhow::bail!("{}", version_not_found_msg(client, version));
}
anyhow::bail!("download {url}: HTTP {}", response.status());
}
reject_html_response(&response, &url)?;
print_download_size(&response, &url, cli_label);
eprintln!("{cli_label}: extracting tarball (xz)");
let staging =
tempfile::TempDir::new_in(dest_dir).with_context(|| "create extraction staging dir")?;
let stream = DownloadStream::new(response);
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(expected) = expected_sha256.as_deref() {
verify_sha256(&actual_hex, expected, &url)?;
eprintln!("{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 download_rc_tarball(
client: &Client,
version: &str,
dest_dir: &Path,
cli_label: &str,
) -> 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);
eprintln!("{cli_label}: extracting tarball (gzip)");
let staging =
tempfile::TempDir::new_in(dest_dir).with_context(|| "create extraction staging dir")?;
let stream = DownloadStream::new(response);
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();
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 fn download_tarball(
client: &Client,
version: &str,
dest_dir: &Path,
cli_label: &str,
skip_sha256: bool,
) -> Result<AcquiredSource> {
let (arch, _) = arch_info();
let source_dir = if is_rc(version) {
download_rc_tarball(client, version, dest_dir, cli_label)?
} else {
download_stable_tarball(client, version, dest_dir, cli_label, skip_sha256)?
};
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 series), probing cdn.kernel.org");
probe_latest_patch(client, prefix, cli_label)
}
fn probe_latest_patch(client: &Client, prefix: &str, cli_label: &str) -> Result<String> {
let major = major_version(prefix)?;
let url = format!("https://cdn.kernel.org/pub/linux/kernel/v{major}.x/");
eprintln!("{cli_label}: fetching directory listing from {url}");
let body = client
.get(&url)
.send()
.with_context(|| format!("GET {url}"))?
.error_for_status()
.with_context(|| format!("GET {url}"))?
.text()
.with_context(|| format!("reading body from {url}"))?;
let needle = format!("linux-{prefix}.");
let mut best_patch: Option<u32> = None;
for line in body.lines() {
let Some(pos) = line.find(&needle) else {
continue;
};
let after = &line[pos + needle.len()..];
let Some(dot) = after.find(".tar.xz") else {
continue;
};
let patch_str = &after[..dot];
if let Ok(patch) = patch_str.parse::<u32>()
&& best_patch.is_none_or(|b| patch > b)
{
best_patch = Some(patch);
}
}
match best_patch {
Some(patch) => {
let version = format!("{prefix}.{patch}");
eprintln!("{cli_label}: latest {prefix}.x kernel (from cdn listing): {version}");
Ok(version)
}
None => {
anyhow::bail!(
"no tarball matching {prefix}.x found in cdn.kernel.org \
directory listing at {url}"
);
}
}
}
pub fn git_clone(
url: &str,
git_ref: &str,
dest_dir: &Path,
cli_label: &str,
) -> Result<AcquiredSource> {
let (arch, _) = arch_info();
eprintln!("{cli_label}: cloning {url} (ref: {git_ref}, depth: 1)");
let clone_dir = dest_dir.join("linux");
let mut prep = gix::prepare_clone(url, &clone_dir)
.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")?;
let (mut checkout, _outcome) = prep
.fetch_then_checkout(
gix::progress::Discard,
&std::sync::atomic::AtomicBool::new(false),
)
.with_context(|| "clone fetch")?;
let (_repo, _outcome) = checkout
.main_worktree(
gix::progress::Discard,
&std::sync::atomic::AtomicBool::new(false),
)
.with_context(|| "checkout")?;
let repo = gix::open(&clone_dir).with_context(|| "open cloned repo")?;
let head = repo.head_id().with_context(|| "read HEAD")?;
let short_hash = format!("{}", head).chars().take(7).collect::<String>();
let cache_key = format!(
"{git_ref}-git-{short_hash}-{arch}-kc{}",
crate::cache_key_suffix()
);
Ok(AcquiredSource {
source_dir: clone_dir,
cache_key,
version: None,
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());
Ok(AcquiredSource {
source_dir: canonical.clone(),
cache_key,
version: None,
kernel_source: crate::cache::KernelSource::Local {
source_tree_path: Some(canonical),
git_hash: short_hash,
},
is_temp: false,
is_dirty,
is_git,
})
}
#[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;