nardl: can figure out which nars to download now

This commit is contained in:
Artemis Tosini 2024-07-08 00:24:55 +00:00
parent 9cbb752b0d
commit 21d5613c4d
Signed by: artemist
GPG key ID: EE5227935FE3FF18
4 changed files with 2122 additions and 0 deletions

1
rust/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
target/

1915
rust/nardl/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

15
rust/nardl/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "nardl"
version = "0.1.0"
edition = "2021"
[dependencies]
base64 = "0.22.1"
color-eyre = "0.6"
ed25519-dalek = "2.1.1"
env_logger = "0.11"
log = "0.4"
narinfo = "1.0.1"
nix-nar = "0.3.0"
reqwest = { version = "0.12.5", features = ["http2", "rustls-tls", "zstd"], default-features = false }
tokio = { version = "1.38.0", features = ["full"] }

191
rust/nardl/src/main.rs Normal file
View file

@ -0,0 +1,191 @@
#![allow(dead_code)]
use base64::{
engine::general_purpose::{
STANDARD as BASE64_STANDARD, STANDARD_NO_PAD as BASE64_STANDARD_NO_PAD,
},
Engine,
};
use color_eyre::{
eyre::{self, Context, OptionExt},
owo_colors::styles::ReversedDisplay,
};
use ed25519_dalek::{Signature, VerifyingKey};
use reqwest::ResponseBuilderExt;
use std::{
collections::{HashMap, HashSet},
path::Path,
};
// oh no i'm too lazy to add arguments for now
const SUBSTITUERS: [&str; 2] = ["https://cache.nixos.org", "https://cache.lix.systems"];
const KEYS: [&str; 2] = [
"cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=",
"cache.lix.systems:aBnZUw8zA7H35Cz2RyKFVs3H4PlGTLawyY5KRbvJR8o",
];
const OUTPUT: &str = "/nix/store/n50jk09x9hshwx1lh6k3qaiygc7yxbv9-lix-2.90.0-rc1";
#[tokio::main]
async fn main() -> eyre::Result<()> {
env_logger::init();
color_eyre::install()?;
let cache_base_urls = SUBSTITUERS
.iter()
.map(|url| reqwest::Url::parse(*url))
.collect::<Result<Vec<_>, _>>()?;
let output_path = Path::new(OUTPUT);
let store_dir = output_path
.parent()
.ok_or_eyre("No parent of output file")?;
log::debug!("Want store dir {:?}", store_dir);
let output_name = output_path
.file_name()
.ok_or_eyre("output is not a filename")?
.to_str()
.ok_or_eyre("output not valid utf-8")?
.to_owned();
log::debug!("Want output {}", output_name);
let trusted_keys = KEYS
.iter()
.map(|key| -> eyre::Result<_> {
let (name, public_str) = key.split_once(":").ok_or_eyre("Key has no name")?;
let public_bytes = BASE64_STANDARD_NO_PAD
.decode(public_str.trim_end_matches("="))
.wrap_err("Invalid base64 in key")?;
Ok((
name,
ed25519_dalek::VerifyingKey::from_bytes(public_bytes.as_slice().try_into()?)?,
))
})
.collect::<eyre::Result<HashMap<&str, VerifyingKey>>>()?;
let client = reqwest::Client::new();
// TODO: Handle priority here
for cache in cache_base_urls.iter() {
let info_str = client
.get(cache.join("nix-cache-info")?)
.send()
.await?
.text()
.await?;
let parsed = narinfo::NixCacheInfo::parse(&info_str).unwrap();
log::trace!("narinfo {}: {:?}", cache, parsed);
if &parsed.store_dir != store_dir.to_str().unwrap() {
eyre::bail!("Cache {} has a different store directory", cache);
}
}
let mut outputs_remaining = vec![output_name];
let mut outputs_done = HashSet::new();
loop {
let Some(output) = outputs_remaining.pop() else {
break;
};
outputs_done.insert(output.clone());
log::debug!("Requesting output {}", output);
let (fingerprint, _) = &output
.split_once("-")
.ok_or_else(|| eyre::eyre!("Invalid output name {}", output))?;
let narinfo_text = get_narinfo(client.clone(), cache_base_urls.as_slice(), &fingerprint)
.await
.wrap_err_with(|| format!("While processing {}", output))?;
let narinfo_parsed = narinfo::NarInfo::parse(&narinfo_text).unwrap();
verify_signature(&narinfo_parsed, &trusted_keys, store_dir, &output)
.wrap_err_with(|| format!("While processing {}", output))?;
for reference in narinfo_parsed.references.iter() {
if reference.is_empty() || outputs_done.contains(reference.as_ref()) {
continue;
}
outputs_remaining.push(reference.to_string());
}
}
Ok(())
}
#[must_use]
async fn get_narinfo(
client: reqwest::Client,
cache_base_urls: &[reqwest::Url],
fingerprint: &str,
) -> eyre::Result<String> {
for cache in cache_base_urls.iter() {
let response = client
.get(cache.join(&format!("{}.narinfo", fingerprint))?)
.send()
.await?;
if !response.status().is_success() {
continue;
}
return response.text().await.wrap_err("Could not download narinfo");
}
eyre::bail!("No cache has fingerprint {}", fingerprint);
}
fn verify_signature(
info: &narinfo::NarInfo,
trusted_keys: &HashMap<&str, VerifyingKey>,
store_dir: &Path,
output_name: &str,
) -> eyre::Result<()> {
for sig_info in &info.sigs {
let Some(key) = trusted_keys.get(sig_info.key_name.as_ref()) else {
continue;
};
let signature_bytes = BASE64_STANDARD.decode(sig_info.sig.as_ref())?;
let signature = Signature::from_bytes(
signature_bytes
.as_slice()
.try_into()
.wrap_err("Invalid signature length")?,
);
// no one documents it, but this is all that's actually signed:
// https://git.lix.systems/lix-project/lix/src/commit/d461cc1d7b2f489c3886f147166ba5b5e0e37541/src/libstore/path-info.cc#L25
let fingerprint = format!(
"1;{};{};{};{}",
store_dir
.join(output_name)
.to_str()
.ok_or_eyre("Path not valid UTF-8")?,
info.nar_hash,
info.nar_size,
info.references
.iter()
.filter(|reference| !reference.is_empty())
.map(|reference| store_dir
.join(reference.as_ref())
.to_str()
.unwrap()
.to_string())
.collect::<Vec<String>>()
.join(",")
);
log::trace!("narinfo fingerprint: `{}`", fingerprint);
key.verify_strict(&fingerprint.as_bytes(), &signature)
.wrap_err("Invalid signature")?;
return Ok(());
}
eyre::bail!("No signatures by a trusted key");
}