2894 lines
98 KiB
Rust
2894 lines
98 KiB
Rust
//! Repository management and SQLite database generation
|
|
|
|
use crate::metadata_time;
|
|
use anyhow::{Context, Result};
|
|
use rusqlite::{Connection, params};
|
|
use sha2::{Digest, Sha256, Sha512};
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::io::{Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{
|
|
Mutex, OnceLock,
|
|
atomic::{AtomicUsize, Ordering},
|
|
mpsc,
|
|
};
|
|
use zstd::stream::write::Encoder;
|
|
|
|
fn parse_license_text(metadata: &toml::Value) -> Option<String> {
|
|
if let Some(s) = metadata.get("license").and_then(|v| v.as_str()) {
|
|
return Some(s.to_string());
|
|
}
|
|
if let Some(arr) = metadata.get("license").and_then(|v| v.as_array()) {
|
|
let licenses: Vec<String> = arr
|
|
.iter()
|
|
.filter_map(|v| v.as_str())
|
|
.map(String::from)
|
|
.collect();
|
|
if !licenses.is_empty() {
|
|
return Some(licenses.join(", "));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub struct RepoManager {
|
|
pub repo_dir: PathBuf,
|
|
}
|
|
|
|
struct HashingReader<R> {
|
|
inner: R,
|
|
sha256: Sha256,
|
|
sha512: Sha512,
|
|
}
|
|
|
|
impl<R> HashingReader<R> {
|
|
fn new(inner: R) -> Self {
|
|
Self {
|
|
inner,
|
|
sha256: Sha256::new(),
|
|
sha512: Sha512::new(),
|
|
}
|
|
}
|
|
|
|
fn finalize_hex(self) -> (String, String) {
|
|
(
|
|
format!("{:x}", self.sha256.finalize()),
|
|
format!("{:x}", self.sha512.finalize()),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl<R: Read> Read for HashingReader<R> {
|
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
|
let n = self.inner.read(buf)?;
|
|
if n > 0 {
|
|
self.sha256.update(&buf[..n]);
|
|
self.sha512.update(&buf[..n]);
|
|
}
|
|
Ok(n)
|
|
}
|
|
}
|
|
|
|
struct IndexedPackage {
|
|
name: String,
|
|
version: String,
|
|
revision: u32,
|
|
completed_at: Option<i64>,
|
|
description: Option<String>,
|
|
homepage: Option<String>,
|
|
license: Option<String>,
|
|
filename: String,
|
|
size: u64,
|
|
sha256: String,
|
|
sha512: String,
|
|
provides: Vec<String>,
|
|
runtime_dependencies: Vec<String>,
|
|
optional_dependencies: Vec<String>,
|
|
archive_files: Vec<String>,
|
|
}
|
|
|
|
/// Search hit returned from a cached binary repository database.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BinaryRepoSearchHit {
|
|
pub repo_name: String,
|
|
pub name: String,
|
|
pub version: String,
|
|
pub revision: u32,
|
|
pub description: Option<String>,
|
|
pub filename: String,
|
|
pub size: u64,
|
|
pub provides: Vec<String>,
|
|
}
|
|
|
|
/// Exact package record from a binary repository database, including checksums
|
|
/// used to verify the downloaded package archive.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BinaryRepoPackageRecord {
|
|
pub repo_name: String,
|
|
pub name: String,
|
|
pub version: String,
|
|
pub revision: u32,
|
|
pub completed_at: Option<i64>,
|
|
pub filename: String,
|
|
pub size: u64,
|
|
pub sha256: String,
|
|
pub sha512: String,
|
|
pub description: Option<String>,
|
|
pub homepage: Option<String>,
|
|
pub license: Option<String>,
|
|
pub provides: Vec<String>,
|
|
pub runtime_dependencies: Vec<String>,
|
|
pub optional_dependencies: Vec<String>,
|
|
}
|
|
|
|
/// Local cache paths for a binary package archive and its detached signature.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BinaryRepoCachedArchive {
|
|
pub package_path: PathBuf,
|
|
pub signature_path: PathBuf,
|
|
}
|
|
|
|
/// File search hit returned from a cached binary repo database.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BinaryRepoFileSearchHit {
|
|
pub repo_name: String,
|
|
pub package_name: String,
|
|
pub version: String,
|
|
pub revision: u32,
|
|
pub path: String,
|
|
pub size: u64,
|
|
}
|
|
|
|
impl RepoManager {
|
|
pub fn new(repo_dir: PathBuf) -> Self {
|
|
Self { repo_dir }
|
|
}
|
|
|
|
/// Create a compressed SQLite repository database from a directory of packages
|
|
pub fn create_repo_db(&self) -> Result<PathBuf> {
|
|
let db_path = self.repo_dir.join("repo.db");
|
|
let compressed_db_path = self.repo_dir.join("repo.db.zst");
|
|
|
|
// Remove existing DB if it exists
|
|
if db_path.exists() {
|
|
fs::remove_file(&db_path)?;
|
|
}
|
|
|
|
let mut conn = Connection::open(&db_path)
|
|
.with_context(|| format!("Failed to create repo database at {}", db_path.display()))?;
|
|
|
|
self.configure_repo_build_pragmas(&mut conn)?;
|
|
self.init_repo_schema(&mut conn)?;
|
|
|
|
let package_paths = self.collect_repo_package_paths()?;
|
|
let indexed_packages = self.collect_indexed_packages_parallel(&package_paths)?;
|
|
|
|
conn.execute_batch("BEGIN IMMEDIATE TRANSACTION;")
|
|
.context("Failed to begin repo DB write transaction")?;
|
|
let insert_result: Result<()> = (|| {
|
|
for indexed in indexed_packages {
|
|
self.insert_indexed_package(&mut conn, indexed)?;
|
|
}
|
|
Ok(())
|
|
})();
|
|
match insert_result {
|
|
Ok(()) => {
|
|
conn.execute_batch("COMMIT;")
|
|
.context("Failed to commit repo DB write transaction")?;
|
|
}
|
|
Err(err) => {
|
|
let _ = conn.execute_batch("ROLLBACK;");
|
|
return Err(err);
|
|
}
|
|
}
|
|
|
|
self.create_repo_indexes(&mut conn)?;
|
|
|
|
conn.close().map_err(|(_, e)| e)?;
|
|
|
|
// Compress the database
|
|
self.compress_db(&db_path, &compressed_db_path)?;
|
|
|
|
// Remove the uncompressed DB
|
|
fs::remove_file(&db_path)?;
|
|
|
|
Ok(compressed_db_path)
|
|
}
|
|
|
|
fn configure_repo_build_pragmas(&self, conn: &mut Connection) -> Result<()> {
|
|
// Speed-focused settings are scoped to this temporary repo DB build process.
|
|
conn.execute_batch(
|
|
"PRAGMA synchronous = OFF;
|
|
PRAGMA journal_mode = MEMORY;
|
|
PRAGMA temp_store = MEMORY;
|
|
PRAGMA locking_mode = EXCLUSIVE;
|
|
PRAGMA cache_size = -200000;",
|
|
)
|
|
.context("Failed to apply SQLite build PRAGMAs for repo DB creation")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn collect_repo_package_paths(&self) -> Result<Vec<PathBuf>> {
|
|
let mut package_paths = Vec::new();
|
|
for entry in fs::read_dir(&self.repo_dir)
|
|
.with_context(|| format!("Failed to read {}", self.repo_dir.display()))?
|
|
{
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if path.is_file() && path.to_string_lossy().ends_with(".depot.pkg.tar.zst") {
|
|
package_paths.push(path);
|
|
}
|
|
}
|
|
package_paths.sort();
|
|
Ok(package_paths)
|
|
}
|
|
|
|
fn collect_indexed_packages_parallel(
|
|
&self,
|
|
package_paths: &[PathBuf],
|
|
) -> Result<Vec<IndexedPackage>> {
|
|
if package_paths.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let worker_count = num_cpus().min(package_paths.len());
|
|
crate::log_info!(
|
|
"Using {} thread(s) to index {} package(s)...",
|
|
worker_count,
|
|
package_paths.len()
|
|
);
|
|
|
|
let next_index = AtomicUsize::new(0);
|
|
let mut indexed = std::thread::scope(|scope| -> Result<Vec<(usize, IndexedPackage)>> {
|
|
let (tx, rx) = mpsc::channel::<(usize, Result<IndexedPackage>)>();
|
|
|
|
for _ in 0..worker_count {
|
|
let tx = tx.clone();
|
|
let next_index = &next_index;
|
|
scope.spawn(move || {
|
|
loop {
|
|
let idx = next_index.fetch_add(1, Ordering::Relaxed);
|
|
if idx >= package_paths.len() {
|
|
break;
|
|
}
|
|
let result = self.read_indexed_package(&package_paths[idx]);
|
|
if tx.send((idx, result)).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
drop(tx);
|
|
|
|
let mut indexed = Vec::with_capacity(package_paths.len());
|
|
for _ in 0..package_paths.len() {
|
|
let (idx, result) = rx
|
|
.recv()
|
|
.context("Failed to receive package indexing result from worker")?;
|
|
indexed.push((idx, result?));
|
|
}
|
|
Ok(indexed)
|
|
})?;
|
|
|
|
indexed.sort_by_key(|(idx, _)| *idx);
|
|
Ok(indexed.into_iter().map(|(_, pkg)| pkg).collect())
|
|
}
|
|
|
|
fn init_repo_schema(&self, conn: &mut Connection) -> Result<()> {
|
|
conn.execute_batch(
|
|
"CREATE TABLE packages (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
version TEXT NOT NULL,
|
|
revision INTEGER NOT NULL,
|
|
completed_at INTEGER,
|
|
description TEXT,
|
|
homepage TEXT,
|
|
license TEXT,
|
|
filename TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
sha256 TEXT NOT NULL,
|
|
sha512 TEXT NOT NULL
|
|
);
|
|
CREATE TABLE provides (
|
|
package_id INTEGER,
|
|
name TEXT NOT NULL,
|
|
FOREIGN KEY(package_id) REFERENCES packages(id)
|
|
);
|
|
CREATE TABLE dependencies (
|
|
package_id INTEGER,
|
|
kind TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
FOREIGN KEY(package_id) REFERENCES packages(id)
|
|
);
|
|
CREATE TABLE files (
|
|
package_id INTEGER,
|
|
path TEXT NOT NULL,
|
|
FOREIGN KEY(package_id) REFERENCES packages(id)
|
|
);",
|
|
)
|
|
.context("Failed to initialize repo schema")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn create_repo_indexes(&self, conn: &mut Connection) -> Result<()> {
|
|
conn.execute_batch(
|
|
"CREATE INDEX idx_packages_name ON packages(name);
|
|
CREATE INDEX idx_provides_name ON provides(name);
|
|
CREATE INDEX idx_dependencies_name ON dependencies(name);
|
|
CREATE INDEX idx_dependencies_kind ON dependencies(kind);
|
|
CREATE INDEX idx_repo_files_path ON files(path);",
|
|
)
|
|
.context("Failed to create repo DB indexes")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn read_indexed_package(&self, pkg_path: &Path) -> Result<IndexedPackage> {
|
|
crate::log_info!("Indexing package {}...", pkg_path.display());
|
|
|
|
let filename = pkg_path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.with_context(|| format!("Invalid package filename: {}", pkg_path.display()))?
|
|
.to_string();
|
|
let file = fs::File::open(pkg_path)?;
|
|
let size = file.metadata()?.len();
|
|
let mut hashing_reader = HashingReader::new(file);
|
|
|
|
let mut name = String::new();
|
|
let mut version = String::new();
|
|
let mut revision = 1;
|
|
let mut completed_at = path_modified_unix_timestamp(pkg_path)?;
|
|
let mut description = None;
|
|
let mut homepage = None;
|
|
let mut license = None;
|
|
let mut provides = Vec::new();
|
|
let mut runtime_dependencies = Vec::new();
|
|
let mut optional_dependencies = Vec::new();
|
|
let mut archive_files = Vec::new();
|
|
|
|
{
|
|
let zstd_decoder = zstd::stream::read::Decoder::new(&mut hashing_reader)?;
|
|
let mut archive = tar::Archive::new(zstd_decoder);
|
|
for entry in archive.entries()? {
|
|
let mut entry = entry?;
|
|
let path = entry.path()?;
|
|
let path_str = path.to_string_lossy().to_string();
|
|
if path_str == ".metadata.toml" {
|
|
let mut content = String::new();
|
|
use std::io::Read;
|
|
entry.read_to_string(&mut content)?;
|
|
let metadata: toml::Value = toml::from_str(&content).with_context(|| {
|
|
format!("Failed to parse .metadata.toml in {}", pkg_path.display())
|
|
})?;
|
|
|
|
name = metadata
|
|
.get("name")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
version = metadata
|
|
.get("version")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
revision = metadata
|
|
.get("revision")
|
|
.and_then(|v| v.as_integer())
|
|
.unwrap_or(1) as u32;
|
|
completed_at =
|
|
metadata_time::parse_completed_at_value(&metadata).or(completed_at);
|
|
description = metadata
|
|
.get("description")
|
|
.and_then(|v| v.as_str())
|
|
.map(String::from);
|
|
homepage = metadata
|
|
.get("homepage")
|
|
.and_then(|v| v.as_str())
|
|
.map(String::from);
|
|
license = parse_license_text(&metadata);
|
|
|
|
if let Some(provides_arr) = metadata.get("provides").and_then(|v| v.as_array())
|
|
{
|
|
provides = provides_arr
|
|
.iter()
|
|
.filter_map(|v| v.as_str())
|
|
.map(String::from)
|
|
.collect();
|
|
}
|
|
if let Some(runtime_arr) = metadata
|
|
.get("dependencies")
|
|
.and_then(|v| v.get("runtime"))
|
|
.and_then(|v| v.as_array())
|
|
{
|
|
runtime_dependencies = runtime_arr
|
|
.iter()
|
|
.filter_map(|v| v.as_str())
|
|
.map(String::from)
|
|
.collect();
|
|
}
|
|
if let Some(optional_arr) = metadata
|
|
.get("dependencies")
|
|
.and_then(|v| v.get("optional"))
|
|
.and_then(|v| v.as_array())
|
|
{
|
|
optional_dependencies = optional_arr
|
|
.iter()
|
|
.filter_map(|v| v.as_str())
|
|
.map(String::from)
|
|
.collect();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if entry.header().entry_type().is_file() {
|
|
let normalized = path_str.trim_start_matches("./").to_string();
|
|
if normalized == ".metadata.toml" {
|
|
continue;
|
|
}
|
|
archive_files.push(normalized);
|
|
}
|
|
}
|
|
}
|
|
let (sha256, sha512) = hashing_reader.finalize_hex();
|
|
|
|
if name.is_empty() {
|
|
// Fallback for packages WITHOUT metadata (e.g. legacy or during transition)
|
|
let name_parts: Vec<&str> = filename.split('-').collect();
|
|
if name_parts.len() < 4 {
|
|
anyhow::bail!(
|
|
"Invalid package filename and no .metadata.toml: {}",
|
|
filename
|
|
);
|
|
}
|
|
name = name_parts[0].to_string();
|
|
version = name_parts[1].to_string();
|
|
revision = name_parts[2].parse().unwrap_or(1);
|
|
}
|
|
|
|
Ok(IndexedPackage {
|
|
name,
|
|
version,
|
|
revision,
|
|
completed_at,
|
|
description,
|
|
homepage,
|
|
license,
|
|
filename,
|
|
size,
|
|
sha256,
|
|
sha512,
|
|
provides,
|
|
runtime_dependencies,
|
|
optional_dependencies,
|
|
archive_files,
|
|
})
|
|
}
|
|
|
|
fn insert_indexed_package(&self, conn: &mut Connection, indexed: IndexedPackage) -> Result<()> {
|
|
let IndexedPackage {
|
|
name,
|
|
version,
|
|
revision,
|
|
completed_at,
|
|
description,
|
|
homepage,
|
|
license,
|
|
filename,
|
|
size,
|
|
sha256,
|
|
sha512,
|
|
provides,
|
|
runtime_dependencies,
|
|
optional_dependencies,
|
|
archive_files,
|
|
} = indexed;
|
|
|
|
// Insert into database
|
|
conn.execute(
|
|
"INSERT INTO packages (name, version, revision, completed_at, description, homepage, license, filename, size, sha256, sha512)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
|
params![
|
|
name,
|
|
version,
|
|
revision as i64,
|
|
completed_at,
|
|
description,
|
|
homepage,
|
|
license,
|
|
filename,
|
|
size as i64,
|
|
sha256,
|
|
sha512
|
|
],
|
|
)?;
|
|
|
|
let package_id = conn.last_insert_rowid();
|
|
|
|
// Insert into provides
|
|
for provide in provides {
|
|
conn.execute(
|
|
"INSERT INTO provides (package_id, name) VALUES (?1, ?2)",
|
|
params![package_id, provide],
|
|
)?;
|
|
}
|
|
|
|
for dep in runtime_dependencies {
|
|
conn.execute(
|
|
"INSERT INTO dependencies (package_id, kind, name) VALUES (?1, 'runtime', ?2)",
|
|
params![package_id, dep],
|
|
)?;
|
|
}
|
|
for dep in optional_dependencies {
|
|
conn.execute(
|
|
"INSERT INTO dependencies (package_id, kind, name) VALUES (?1, 'optional', ?2)",
|
|
params![package_id, dep],
|
|
)?;
|
|
}
|
|
|
|
for file_path in archive_files {
|
|
conn.execute(
|
|
"INSERT INTO files (package_id, path) VALUES (?1, ?2)",
|
|
params![package_id, file_path],
|
|
)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn compress_db(&self, source: &Path, dest: &Path) -> Result<()> {
|
|
let mut input = fs::File::open(source)?;
|
|
let output = fs::File::create(dest)?;
|
|
let mut encoder = Encoder::new(output, 19)?; // High compression for repo DB
|
|
encoder.multithread(num_cpus() as u32)?;
|
|
std::io::copy(&mut input, &mut encoder)?;
|
|
encoder.finish()?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn binary_repo_cache_dir(package_cache_dir: &Path, repo_name: &str) -> PathBuf {
|
|
package_cache_dir.join("repos").join(repo_name)
|
|
}
|
|
|
|
fn binary_repo_packages_cache_dir(package_cache_dir: &Path, repo_name: &str) -> PathBuf {
|
|
binary_repo_cache_dir(package_cache_dir, repo_name).join("packages")
|
|
}
|
|
|
|
fn join_repo_url(base: &str, rel: &str) -> Result<String> {
|
|
let base = if base.ends_with('/') {
|
|
base.to_string()
|
|
} else {
|
|
format!("{base}/")
|
|
};
|
|
let url = url::Url::parse(&base).with_context(|| format!("Invalid repo URL: {base}"))?;
|
|
Ok(url
|
|
.join(rel)
|
|
.with_context(|| format!("Invalid repo db path '{}'", rel))?
|
|
.to_string())
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum FileUrlCopyOutcome {
|
|
NotFileUrl,
|
|
Copied,
|
|
Missing,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Eq, Hash, PartialEq)]
|
|
struct RepoDbFetchCacheKey {
|
|
repo_name: String,
|
|
base_url: String,
|
|
repo_db_rel: String,
|
|
rootfs: PathBuf,
|
|
package_cache_dir: PathBuf,
|
|
}
|
|
|
|
fn repo_db_fetch_cache() -> &'static Mutex<HashMap<RepoDbFetchCacheKey, PathBuf>> {
|
|
static CACHE: OnceLock<Mutex<HashMap<RepoDbFetchCacheKey, PathBuf>>> = OnceLock::new();
|
|
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
|
}
|
|
|
|
fn get_cached_repo_db_path(cache_key: &RepoDbFetchCacheKey) -> Option<PathBuf> {
|
|
let mut cache = repo_db_fetch_cache()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
let cached = cache.get(cache_key).cloned()?;
|
|
if cached.exists() {
|
|
return Some(cached);
|
|
}
|
|
cache.remove(cache_key);
|
|
None
|
|
}
|
|
|
|
fn remember_repo_db_path(cache_key: RepoDbFetchCacheKey, db_path: PathBuf) {
|
|
let mut cache = repo_db_fetch_cache()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
cache.insert(cache_key, db_path);
|
|
}
|
|
|
|
fn copy_file_url_to_path(url: &str, dest: &Path) -> Result<FileUrlCopyOutcome> {
|
|
let parsed = match url::Url::parse(url) {
|
|
Ok(parsed) => parsed,
|
|
Err(_) => return Ok(FileUrlCopyOutcome::NotFileUrl),
|
|
};
|
|
if parsed.scheme() != "file" {
|
|
return Ok(FileUrlCopyOutcome::NotFileUrl);
|
|
}
|
|
|
|
let src = parsed
|
|
.to_file_path()
|
|
.map_err(|_| anyhow::anyhow!("Invalid file:// URL: {}", url))?;
|
|
if !src.exists() {
|
|
return Ok(FileUrlCopyOutcome::Missing);
|
|
}
|
|
if !src.is_file() {
|
|
anyhow::bail!("file:// URL is not a file: {}", src.display());
|
|
}
|
|
|
|
fs::copy(&src, dest)
|
|
.with_context(|| format!("Failed to copy {} to {}", src.display(), dest.display()))?;
|
|
Ok(FileUrlCopyOutcome::Copied)
|
|
}
|
|
|
|
fn fetch_url_to_path(client: &reqwest::blocking::Client, url: &str, dest: &Path) -> Result<bool> {
|
|
match copy_file_url_to_path(url, dest)? {
|
|
FileUrlCopyOutcome::Copied => return Ok(true),
|
|
FileUrlCopyOutcome::Missing => return Ok(false),
|
|
FileUrlCopyOutcome::NotFileUrl => {}
|
|
}
|
|
|
|
let mut resp = client
|
|
.get(url)
|
|
.send()
|
|
.with_context(|| format!("Failed to fetch {}", url))?;
|
|
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
|
return Ok(false);
|
|
}
|
|
if !resp.status().is_success() {
|
|
anyhow::bail!("Failed to fetch {}: HTTP {}", url, resp.status());
|
|
}
|
|
|
|
let mut out =
|
|
fs::File::create(dest).with_context(|| format!("Failed to create {}", dest.display()))?;
|
|
std::io::copy(&mut resp, &mut out)
|
|
.with_context(|| format!("Failed to save {}", dest.display()))?;
|
|
out.flush()
|
|
.with_context(|| format!("Failed to flush {}", dest.display()))?;
|
|
Ok(true)
|
|
}
|
|
|
|
fn extract_html_href_targets(html: &str) -> Vec<String> {
|
|
let lower = html.to_ascii_lowercase();
|
|
let lower_bytes = lower.as_bytes();
|
|
let html_bytes = html.as_bytes();
|
|
let mut out = Vec::new();
|
|
let mut i = 0usize;
|
|
|
|
while i < lower_bytes.len() {
|
|
let Some(rel) = lower[i..].find("href") else {
|
|
break;
|
|
};
|
|
let mut j = i + rel + 4;
|
|
while j < lower_bytes.len() && lower_bytes[j].is_ascii_whitespace() {
|
|
j += 1;
|
|
}
|
|
if j >= lower_bytes.len() || lower_bytes[j] != b'=' {
|
|
i = j;
|
|
continue;
|
|
}
|
|
j += 1;
|
|
while j < lower_bytes.len() && lower_bytes[j].is_ascii_whitespace() {
|
|
j += 1;
|
|
}
|
|
if j >= lower_bytes.len() {
|
|
break;
|
|
}
|
|
|
|
let (start, end) = if lower_bytes[j] == b'"' || lower_bytes[j] == b'\'' {
|
|
let quote = lower_bytes[j];
|
|
let start = j + 1;
|
|
let mut k = start;
|
|
while k < lower_bytes.len() && lower_bytes[k] != quote {
|
|
k += 1;
|
|
}
|
|
(start, k)
|
|
} else {
|
|
let start = j;
|
|
let mut k = start;
|
|
while k < lower_bytes.len()
|
|
&& !lower_bytes[k].is_ascii_whitespace()
|
|
&& lower_bytes[k] != b'>'
|
|
{
|
|
k += 1;
|
|
}
|
|
(start, k)
|
|
};
|
|
|
|
if start < end && end <= html_bytes.len() {
|
|
out.push(String::from_utf8_lossy(&html_bytes[start..end]).to_string());
|
|
}
|
|
i = end.saturating_add(1);
|
|
}
|
|
|
|
out
|
|
}
|
|
|
|
fn default_repo_public_key_candidate_names(base_url: &str) -> Result<Vec<String>> {
|
|
let mut names = vec![
|
|
"vertex.pub".to_string(),
|
|
"depot.pub".to_string(),
|
|
"depot.minisign.pub".to_string(),
|
|
"minisign.pub".to_string(),
|
|
"repo.pub".to_string(),
|
|
];
|
|
|
|
if let Ok(parsed) = url::Url::parse(base_url)
|
|
&& let Some(last_segment) = parsed
|
|
.path_segments()
|
|
.and_then(|mut segments| segments.rfind(|s| !s.is_empty()))
|
|
{
|
|
names.push(format!("{}.pub", last_segment));
|
|
}
|
|
|
|
names.sort();
|
|
names.dedup();
|
|
Ok(names)
|
|
}
|
|
|
|
fn probe_repo_public_key_urls(
|
|
base_url: &str,
|
|
client: &reqwest::blocking::Client,
|
|
) -> Result<Vec<(String, String)>> {
|
|
let mut out = Vec::new();
|
|
for key_name in default_repo_public_key_candidate_names(base_url)? {
|
|
let key_url = join_repo_url(base_url, &format!("keys/{}", key_name))?;
|
|
let resp = client
|
|
.get(&key_url)
|
|
.send()
|
|
.with_context(|| format!("Failed to fetch {}", key_url))?;
|
|
if resp.status().is_success() {
|
|
out.push((key_name, key_url));
|
|
}
|
|
}
|
|
out.sort();
|
|
out.dedup();
|
|
Ok(out)
|
|
}
|
|
|
|
fn list_repo_public_key_urls(
|
|
base_url: &str,
|
|
client: &reqwest::blocking::Client,
|
|
) -> Result<Vec<(String, String)>> {
|
|
let parsed =
|
|
url::Url::parse(base_url).with_context(|| format!("Invalid repo URL: {base_url}"))?;
|
|
if parsed.scheme() == "file" {
|
|
let repo_dir = parsed
|
|
.to_file_path()
|
|
.map_err(|_| anyhow::anyhow!("Invalid file:// URL: {}", base_url))?;
|
|
let keys_dir = repo_dir.join("keys");
|
|
if !keys_dir.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
if !keys_dir.is_dir() {
|
|
anyhow::bail!(
|
|
"Binary repo keys path is not a directory: {}",
|
|
keys_dir.display()
|
|
);
|
|
}
|
|
|
|
let mut out = Vec::new();
|
|
for entry in fs::read_dir(&keys_dir)
|
|
.with_context(|| format!("Failed to read {}", keys_dir.display()))?
|
|
{
|
|
let path = entry?.path();
|
|
if !path.is_file() {
|
|
continue;
|
|
}
|
|
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
|
continue;
|
|
};
|
|
if !name.to_ascii_lowercase().ends_with(".pub") {
|
|
continue;
|
|
}
|
|
let key_url = url::Url::from_file_path(&path)
|
|
.map_err(|_| anyhow::anyhow!("Failed to build file:// URL for {}", path.display()))?
|
|
.to_string();
|
|
out.push((name.to_string(), key_url));
|
|
}
|
|
out.sort();
|
|
out.dedup();
|
|
return Ok(out);
|
|
}
|
|
|
|
let mut out = Vec::new();
|
|
let keys_url = join_repo_url(base_url, "keys/")?;
|
|
let resp = client
|
|
.get(&keys_url)
|
|
.send()
|
|
.with_context(|| format!("Failed to fetch {}", keys_url))?;
|
|
if resp.status().is_success() {
|
|
let body = resp
|
|
.text()
|
|
.with_context(|| format!("Failed to read {}", keys_url))?;
|
|
let keys_base = url::Url::parse(&keys_url)
|
|
.with_context(|| format!("Invalid repo keys URL: {}", keys_url))?;
|
|
|
|
for href in extract_html_href_targets(&body) {
|
|
if href.is_empty() || href.starts_with('#') || href.starts_with('?') {
|
|
continue;
|
|
}
|
|
let Ok(url) = keys_base.join(&href) else {
|
|
continue;
|
|
};
|
|
let Some(name) = url.path_segments().and_then(|mut s| s.next_back()) else {
|
|
continue;
|
|
};
|
|
if name.is_empty() || !name.to_ascii_lowercase().ends_with(".pub") {
|
|
continue;
|
|
}
|
|
out.push((name.to_string(), url.to_string()));
|
|
}
|
|
}
|
|
|
|
if out.is_empty() {
|
|
out = probe_repo_public_key_urls(base_url, client)?;
|
|
}
|
|
|
|
out.sort();
|
|
out.dedup();
|
|
Ok(out)
|
|
}
|
|
|
|
fn verify_with_any_trusted_public_key(
|
|
rootfs: &Path,
|
|
input: &Path,
|
|
sig_path: &Path,
|
|
) -> Result<PathBuf> {
|
|
let keys = crate::signing::list_trusted_public_keys(rootfs)?;
|
|
if keys.is_empty() {
|
|
anyhow::bail!("No trusted minisign public keys found in rootfs or host");
|
|
}
|
|
|
|
let mut last_failure: Option<(PathBuf, anyhow::Error)> = None;
|
|
for key_path in keys {
|
|
match crate::signing::verify_zst_file_detached_with_public_key(input, sig_path, &key_path) {
|
|
Ok(()) => return Ok(key_path),
|
|
Err(err) => last_failure = Some((key_path, err)),
|
|
}
|
|
}
|
|
|
|
let (key_path, err) = last_failure.expect("non-empty key list must produce a failure");
|
|
Err(err).with_context(|| {
|
|
format!(
|
|
"Detached signature verification failed with all trusted public keys (last tried {})",
|
|
key_path.display()
|
|
)
|
|
})
|
|
}
|
|
|
|
fn sanitize_filename_component(input: &str) -> String {
|
|
input
|
|
.chars()
|
|
.map(|ch| match ch {
|
|
'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => ch,
|
|
_ => '_',
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn install_trusted_repo_public_key(
|
|
rootfs: &Path,
|
|
repo_name: &str,
|
|
source_key_path: &Path,
|
|
source_name: &str,
|
|
) -> Result<PathBuf> {
|
|
let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs);
|
|
fs::create_dir_all(&trusted_dir)
|
|
.with_context(|| format!("Failed to create {}", trusted_dir.display()))?;
|
|
|
|
let base_name = source_name
|
|
.split('/')
|
|
.next_back()
|
|
.filter(|name| !name.is_empty())
|
|
.unwrap_or("repo.pub");
|
|
let base_name = sanitize_filename_component(base_name);
|
|
let repo_prefix = sanitize_filename_component(repo_name);
|
|
|
|
let source_bytes = fs::read(source_key_path)
|
|
.with_context(|| format!("Failed to read {}", source_key_path.display()))?;
|
|
let mut candidates = Vec::new();
|
|
candidates.push(trusted_dir.join(&base_name));
|
|
if !repo_prefix.is_empty() {
|
|
candidates.push(trusted_dir.join(format!("{}-{}", repo_prefix, base_name)));
|
|
}
|
|
|
|
for candidate in &candidates {
|
|
if candidate.exists() {
|
|
let existing = fs::read(candidate)
|
|
.with_context(|| format!("Failed to read {}", candidate.display()))?;
|
|
if existing == source_bytes {
|
|
return Ok(candidate.clone());
|
|
}
|
|
} else {
|
|
fs::write(candidate, &source_bytes)
|
|
.with_context(|| format!("Failed to write {}", candidate.display()))?;
|
|
return Ok(candidate.clone());
|
|
}
|
|
}
|
|
|
|
for idx in 1usize.. {
|
|
let candidate = trusted_dir.join(format!("{}-{}.{}", repo_prefix, base_name, idx));
|
|
if candidate.exists() {
|
|
let existing = fs::read(&candidate)
|
|
.with_context(|| format!("Failed to read {}", candidate.display()))?;
|
|
if existing == source_bytes {
|
|
return Ok(candidate);
|
|
}
|
|
continue;
|
|
}
|
|
fs::write(&candidate, &source_bytes)
|
|
.with_context(|| format!("Failed to write {}", candidate.display()))?;
|
|
return Ok(candidate);
|
|
}
|
|
|
|
unreachable!("infinite loop returns on first available candidate")
|
|
}
|
|
|
|
fn try_trust_repo_public_key_for_repo_db(
|
|
repo_name: &str,
|
|
base_url: &str,
|
|
rootfs: &Path,
|
|
cache_dir: &Path,
|
|
client: &reqwest::blocking::Client,
|
|
repo_db_zst_path: &Path,
|
|
repo_db_sig_path: &Path,
|
|
) -> Result<Option<PathBuf>> {
|
|
let repo_keys = list_repo_public_key_urls(base_url, client)?;
|
|
if repo_keys.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let repo_keys_cache_dir = cache_dir.join("repo_keys");
|
|
fs::create_dir_all(&repo_keys_cache_dir)
|
|
.with_context(|| format!("Failed to create {}", repo_keys_cache_dir.display()))?;
|
|
|
|
for (key_name, key_url) in repo_keys {
|
|
let cache_name = sanitize_filename_component(&key_name);
|
|
let key_tmp_path = repo_keys_cache_dir.join(&cache_name);
|
|
if !fetch_url_to_path(client, &key_url, &key_tmp_path)? {
|
|
continue;
|
|
}
|
|
|
|
if crate::signing::verify_zst_file_detached_with_public_key(
|
|
repo_db_zst_path,
|
|
repo_db_sig_path,
|
|
&key_tmp_path,
|
|
)
|
|
.is_err()
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs);
|
|
let prompt = format!(
|
|
"Trust repo key '{}' from binary repo '{}' and copy it to {}?",
|
|
key_name,
|
|
repo_name,
|
|
trusted_dir.display()
|
|
);
|
|
if !crate::ui::prompt_yes_no(&prompt, false)? {
|
|
crate::log_warn!(
|
|
"Skipped trusting repo key '{}' for binary repo '{}'",
|
|
key_name,
|
|
repo_name
|
|
);
|
|
continue;
|
|
}
|
|
|
|
let installed =
|
|
install_trusted_repo_public_key(rootfs, repo_name, &key_tmp_path, &key_name)?;
|
|
return Ok(Some(installed));
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
fn normalize_git_mirror_url(url: &str) -> Result<String> {
|
|
let parsed = match url::Url::parse(url) {
|
|
Ok(parsed) => parsed,
|
|
Err(_) => return Ok(url.to_string()),
|
|
};
|
|
if parsed.scheme() != "file" {
|
|
return Ok(url.to_string());
|
|
}
|
|
let path = parsed
|
|
.to_file_path()
|
|
.map_err(|_| anyhow::anyhow!("Invalid file:// mirror URL: {}", url))?;
|
|
Ok(path.to_string_lossy().into_owned())
|
|
}
|
|
|
|
fn decompress_zstd_file(src: &Path, dst: &Path) -> Result<()> {
|
|
let mut input =
|
|
fs::File::open(src).with_context(|| format!("Failed to open {}", src.display()))?;
|
|
let mut decoder = zstd::stream::read::Decoder::new(&mut input)
|
|
.with_context(|| format!("Failed to open zstd decoder for {}", src.display()))?;
|
|
let tmp = dst.with_extension("tmp");
|
|
let mut output =
|
|
fs::File::create(&tmp).with_context(|| format!("Failed to create {}", tmp.display()))?;
|
|
std::io::copy(&mut decoder, &mut output)
|
|
.with_context(|| format!("Failed to decompress {}", src.display()))?;
|
|
output
|
|
.flush()
|
|
.with_context(|| format!("Failed to flush {}", tmp.display()))?;
|
|
fs::rename(&tmp, dst)
|
|
.with_context(|| format!("Failed to move {} to {}", tmp.display(), dst.display()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Fetch (or refresh) a binary repo `repo.db.zst` into the configured package cache.
|
|
///
|
|
/// Returns the path to the decompressed SQLite database file.
|
|
pub fn fetch_binary_repo_db(
|
|
repo_name: &str,
|
|
repo: &crate::config::BinaryRepo,
|
|
rootfs: &Path,
|
|
package_cache_dir: &Path,
|
|
) -> Result<PathBuf> {
|
|
let machine_arch = std::env::consts::ARCH;
|
|
let base_url = repo.effective_url_for_arch(machine_arch).with_context(|| {
|
|
format!(
|
|
"Binary repo '{}' is not configured for machine arch '{}'",
|
|
repo_name, machine_arch
|
|
)
|
|
})?;
|
|
let repo_db_rel = repo
|
|
.effective_repo_db_for_arch(machine_arch)
|
|
.with_context(|| {
|
|
format!(
|
|
"Binary repo '{}' is not configured for machine arch '{}'",
|
|
repo_name, machine_arch
|
|
)
|
|
})?;
|
|
let cache_key = RepoDbFetchCacheKey {
|
|
repo_name: repo_name.to_string(),
|
|
base_url: base_url.to_string(),
|
|
repo_db_rel: repo_db_rel.to_string(),
|
|
rootfs: rootfs.to_path_buf(),
|
|
package_cache_dir: package_cache_dir.to_path_buf(),
|
|
};
|
|
if let Some(cached_db_path) = get_cached_repo_db_path(&cache_key) {
|
|
return Ok(cached_db_path);
|
|
}
|
|
|
|
let cache_dir = binary_repo_cache_dir(package_cache_dir, repo_name);
|
|
fs::create_dir_all(&cache_dir)
|
|
.with_context(|| format!("Failed to create {}", cache_dir.display()))?;
|
|
|
|
let repo_db_zst = cache_dir.join("repo.db.zst");
|
|
let repo_db_sig = cache_dir.join("repo.db.zst.sig");
|
|
let repo_db_sqlite = cache_dir.join("repo.db");
|
|
let tmp_zst = cache_dir.join("repo.db.zst.tmp");
|
|
let tmp_sig = cache_dir.join("repo.db.zst.sig.tmp");
|
|
|
|
let repo_db_url = join_repo_url(base_url, repo_db_rel)?;
|
|
let repo_sig_url = join_repo_url(base_url, &format!("{}.sig", repo_db_rel))?;
|
|
crate::log_info!(
|
|
"Fetching binary repo DB for '{}' from {}",
|
|
repo_name,
|
|
repo_db_url
|
|
);
|
|
|
|
let client = reqwest::blocking::Client::builder()
|
|
.build()
|
|
.context("Failed to build HTTP client for binary repo fetch")?;
|
|
match copy_file_url_to_path(&repo_db_url, &tmp_zst)? {
|
|
FileUrlCopyOutcome::Copied => {}
|
|
FileUrlCopyOutcome::Missing => {
|
|
if repo_db_sqlite.exists() {
|
|
crate::log_warn!(
|
|
"Failed to refresh binary repo '{}' (missing local file), using cached DB: {}",
|
|
repo_name,
|
|
repo_db_url
|
|
);
|
|
remember_repo_db_path(cache_key.clone(), repo_db_sqlite.clone());
|
|
return Ok(repo_db_sqlite);
|
|
}
|
|
anyhow::bail!("Failed to fetch {}: local file not found", repo_db_url);
|
|
}
|
|
FileUrlCopyOutcome::NotFileUrl => {
|
|
let resp = client
|
|
.get(&repo_db_url)
|
|
.send()
|
|
.with_context(|| format!("Failed to fetch {}", repo_db_url))?;
|
|
|
|
if !resp.status().is_success() {
|
|
if repo_db_sqlite.exists() {
|
|
crate::log_warn!(
|
|
"Failed to refresh binary repo '{}' (HTTP {}), using cached DB",
|
|
repo_name,
|
|
resp.status()
|
|
);
|
|
remember_repo_db_path(cache_key.clone(), repo_db_sqlite.clone());
|
|
return Ok(repo_db_sqlite);
|
|
}
|
|
anyhow::bail!("Failed to fetch {}: HTTP {}", repo_db_url, resp.status());
|
|
}
|
|
|
|
let mut resp = resp;
|
|
let mut out = fs::File::create(&tmp_zst)
|
|
.with_context(|| format!("Failed to create {}", tmp_zst.display()))?;
|
|
std::io::copy(&mut resp, &mut out)
|
|
.with_context(|| format!("Failed to save {}", tmp_zst.display()))?;
|
|
out.flush()
|
|
.with_context(|| format!("Failed to flush {}", tmp_zst.display()))?;
|
|
}
|
|
}
|
|
|
|
let sig_downloaded = match copy_file_url_to_path(&repo_sig_url, &tmp_sig)? {
|
|
FileUrlCopyOutcome::Copied => true,
|
|
FileUrlCopyOutcome::Missing => {
|
|
if !repo.allow_unsigned {
|
|
anyhow::bail!(
|
|
"Failed to fetch detached signature for binary repo '{}' (local file not found): {}",
|
|
repo_name,
|
|
repo_sig_url
|
|
);
|
|
}
|
|
crate::log_warn!(
|
|
"Binary repo '{}' has no detached signature (missing local file) for {}; allow_unsigned=true so continuing",
|
|
repo_name,
|
|
repo_db_url
|
|
);
|
|
false
|
|
}
|
|
FileUrlCopyOutcome::NotFileUrl => {
|
|
let sig_resp = client
|
|
.get(&repo_sig_url)
|
|
.send()
|
|
.with_context(|| format!("Failed to fetch {}", repo_sig_url))?;
|
|
if sig_resp.status().is_success() {
|
|
let mut sig_resp = sig_resp;
|
|
let mut sig_out = fs::File::create(&tmp_sig)
|
|
.with_context(|| format!("Failed to create {}", tmp_sig.display()))?;
|
|
std::io::copy(&mut sig_resp, &mut sig_out)
|
|
.with_context(|| format!("Failed to save {}", tmp_sig.display()))?;
|
|
sig_out
|
|
.flush()
|
|
.with_context(|| format!("Failed to flush {}", tmp_sig.display()))?;
|
|
true
|
|
} else {
|
|
if !repo.allow_unsigned {
|
|
anyhow::bail!(
|
|
"Failed to fetch detached signature for binary repo '{}' (HTTP {}): {}",
|
|
repo_name,
|
|
sig_resp.status(),
|
|
repo_sig_url
|
|
);
|
|
}
|
|
crate::log_warn!(
|
|
"Binary repo '{}' has no detached signature (HTTP {}) for {}; allow_unsigned=true so continuing",
|
|
repo_name,
|
|
sig_resp.status(),
|
|
repo_db_url
|
|
);
|
|
false
|
|
}
|
|
}
|
|
};
|
|
|
|
if sig_downloaded {
|
|
let mut trusted_keys = crate::signing::list_trusted_public_keys(rootfs)?;
|
|
if trusted_keys.is_empty() {
|
|
if let Some(installed_key) = try_trust_repo_public_key_for_repo_db(
|
|
repo_name, base_url, rootfs, &cache_dir, &client, &tmp_zst, &tmp_sig,
|
|
)? {
|
|
crate::log_info!(
|
|
"Trusted repo key for '{}' installed at {}",
|
|
repo_name,
|
|
installed_key.display()
|
|
);
|
|
} else if !repo.allow_unsigned {
|
|
anyhow::bail!(
|
|
"No trusted minisign public key found for binary repo '{}' and no trusted key was accepted from {}/keys/",
|
|
repo_name,
|
|
base_url.trim_end_matches('/')
|
|
);
|
|
} else {
|
|
crate::log_warn!(
|
|
"No trusted minisign public key found; skipping verification for binary repo '{}' because allow_unsigned=true",
|
|
repo_name
|
|
);
|
|
}
|
|
trusted_keys = crate::signing::list_trusted_public_keys(rootfs)?;
|
|
}
|
|
|
|
if trusted_keys.is_empty() {
|
|
// No key was trusted/installed, and allow_unsigned=true already handled above.
|
|
} else {
|
|
let verified_key = match verify_with_any_trusted_public_key(rootfs, &tmp_zst, &tmp_sig)
|
|
{
|
|
Ok(key) => key,
|
|
Err(initial_err) => {
|
|
if let Some(installed_key) = try_trust_repo_public_key_for_repo_db(
|
|
repo_name, base_url, rootfs, &cache_dir, &client, &tmp_zst, &tmp_sig,
|
|
)? {
|
|
crate::log_info!(
|
|
"Trusted repo key for '{}' installed at {}",
|
|
repo_name,
|
|
installed_key.display()
|
|
);
|
|
verify_with_any_trusted_public_key(rootfs, &tmp_zst, &tmp_sig)
|
|
.with_context(|| {
|
|
format!(
|
|
"Failed to verify detached signature for binary repo '{}'",
|
|
repo_name
|
|
)
|
|
})?
|
|
} else {
|
|
return Err(initial_err).with_context(|| {
|
|
format!(
|
|
"Failed to verify detached signature for binary repo '{}'",
|
|
repo_name
|
|
)
|
|
});
|
|
}
|
|
}
|
|
};
|
|
crate::log_info!(
|
|
"Verified detached signature for binary repo '{}' using {}",
|
|
repo_name,
|
|
verified_key.display()
|
|
);
|
|
}
|
|
}
|
|
|
|
fs::rename(&tmp_zst, &repo_db_zst).with_context(|| {
|
|
format!(
|
|
"Failed to move {} to {}",
|
|
tmp_zst.display(),
|
|
repo_db_zst.display()
|
|
)
|
|
})?;
|
|
if sig_downloaded {
|
|
fs::rename(&tmp_sig, &repo_db_sig).with_context(|| {
|
|
format!(
|
|
"Failed to move {} to {}",
|
|
tmp_sig.display(),
|
|
repo_db_sig.display()
|
|
)
|
|
})?;
|
|
} else if repo_db_sig.exists() {
|
|
let _ = fs::remove_file(&repo_db_sig);
|
|
}
|
|
|
|
decompress_zstd_file(&repo_db_zst, &repo_db_sqlite)?;
|
|
remember_repo_db_path(cache_key, repo_db_sqlite.clone());
|
|
Ok(repo_db_sqlite)
|
|
}
|
|
|
|
/// Search a cached binary repository SQLite DB by package name or provided feature.
|
|
pub fn search_cached_binary_repo_db(
|
|
repo_name: &str,
|
|
db_path: &Path,
|
|
query: &str,
|
|
) -> Result<Vec<BinaryRepoSearchHit>> {
|
|
let conn = Connection::open(db_path)
|
|
.with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?;
|
|
|
|
let like = format!("%{}%", query.to_ascii_lowercase());
|
|
let mut stmt = conn.prepare(
|
|
"SELECT
|
|
p.name,
|
|
p.version,
|
|
p.revision,
|
|
p.description,
|
|
p.filename,
|
|
p.size,
|
|
GROUP_CONCAT(DISTINCT pr_all.name)
|
|
FROM packages p
|
|
LEFT JOIN provides pr_all ON pr_all.package_id = p.id
|
|
WHERE lower(p.name) LIKE ?1
|
|
OR EXISTS (
|
|
SELECT 1 FROM provides pr
|
|
WHERE pr.package_id = p.id
|
|
AND lower(pr.name) LIKE ?1
|
|
)
|
|
GROUP BY p.id
|
|
ORDER BY
|
|
CASE
|
|
WHEN lower(p.name) = lower(?2) THEN 0
|
|
WHEN lower(p.name) LIKE lower(?3) THEN 1
|
|
ELSE 2
|
|
END,
|
|
p.name ASC",
|
|
)?;
|
|
|
|
let starts = format!("{}%", query.to_ascii_lowercase());
|
|
let rows = stmt.query_map(params![like, query, starts], |row| {
|
|
let provides_csv: Option<String> = row.get(6)?;
|
|
Ok(BinaryRepoSearchHit {
|
|
repo_name: repo_name.to_string(),
|
|
name: row.get(0)?,
|
|
version: row.get(1)?,
|
|
revision: row.get::<_, i64>(2)? as u32,
|
|
description: row.get(3)?,
|
|
filename: row.get(4)?,
|
|
size: row.get::<_, i64>(5)? as u64,
|
|
provides: provides_csv
|
|
.map(|s| {
|
|
s.split(',')
|
|
.filter(|v| !v.is_empty())
|
|
.map(|v| v.to_string())
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default(),
|
|
})
|
|
})?;
|
|
|
|
Ok(rows.filter_map(|r| r.ok()).collect())
|
|
}
|
|
|
|
/// Fetch and search a binary repo by name or provide.
|
|
pub fn search_binary_repo(
|
|
repo_name: &str,
|
|
repo: &crate::config::BinaryRepo,
|
|
rootfs: &Path,
|
|
package_cache_dir: &Path,
|
|
query: &str,
|
|
) -> Result<Vec<BinaryRepoSearchHit>> {
|
|
let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?;
|
|
search_cached_binary_repo_db(repo_name, &db_path, query)
|
|
}
|
|
|
|
/// Search a cached binary repo DB by file path substring.
|
|
pub fn search_cached_binary_repo_files(
|
|
repo_name: &str,
|
|
db_path: &Path,
|
|
query: &str,
|
|
) -> Result<Vec<BinaryRepoFileSearchHit>> {
|
|
let conn = Connection::open(db_path)
|
|
.with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?;
|
|
|
|
let has_files_table: bool = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='files'",
|
|
[],
|
|
|r| {
|
|
let n: i64 = r.get(0)?;
|
|
Ok(n > 0)
|
|
},
|
|
)
|
|
.unwrap_or(false);
|
|
if !has_files_table {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let like = format!("%{}%", query.to_ascii_lowercase());
|
|
let mut stmt = conn.prepare(
|
|
"SELECT p.name, p.version, p.revision, f.path, p.size
|
|
FROM files f
|
|
JOIN packages p ON p.id = f.package_id
|
|
WHERE lower(f.path) LIKE ?1
|
|
ORDER BY p.name ASC, f.path ASC",
|
|
)?;
|
|
let rows = stmt.query_map(params![like], |row| {
|
|
Ok(BinaryRepoFileSearchHit {
|
|
repo_name: repo_name.to_string(),
|
|
package_name: row.get(0)?,
|
|
version: row.get(1)?,
|
|
revision: row.get::<_, i64>(2)? as u32,
|
|
path: row.get(3)?,
|
|
size: row.get::<_, i64>(4)? as u64,
|
|
})
|
|
})?;
|
|
Ok(rows.filter_map(|r| r.ok()).collect())
|
|
}
|
|
|
|
/// Fetch and search a binary repo by file path substring.
|
|
pub fn search_binary_repo_files(
|
|
repo_name: &str,
|
|
repo: &crate::config::BinaryRepo,
|
|
rootfs: &Path,
|
|
package_cache_dir: &Path,
|
|
query: &str,
|
|
) -> Result<Vec<BinaryRepoFileSearchHit>> {
|
|
let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?;
|
|
search_cached_binary_repo_files(repo_name, &db_path, query)
|
|
}
|
|
|
|
/// Find the package(s) that own a file path in a cached binary repo DB.
|
|
pub fn cached_binary_repo_owns_path(
|
|
repo_name: &str,
|
|
db_path: &Path,
|
|
path: &str,
|
|
) -> Result<Vec<BinaryRepoFileSearchHit>> {
|
|
let conn = Connection::open(db_path)
|
|
.with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?;
|
|
|
|
let has_files_table: bool = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='files'",
|
|
[],
|
|
|r| {
|
|
let n: i64 = r.get(0)?;
|
|
Ok(n > 0)
|
|
},
|
|
)
|
|
.unwrap_or(false);
|
|
if !has_files_table {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let normalized = path.trim_start_matches('/').trim_start_matches("./");
|
|
let mut stmt = conn.prepare(
|
|
"SELECT p.name, p.version, p.revision, f.path, p.size
|
|
FROM files f
|
|
JOIN packages p ON p.id = f.package_id
|
|
WHERE f.path = ?1
|
|
ORDER BY p.name ASC",
|
|
)?;
|
|
let rows = stmt.query_map(params![normalized], |row| {
|
|
Ok(BinaryRepoFileSearchHit {
|
|
repo_name: repo_name.to_string(),
|
|
package_name: row.get(0)?,
|
|
version: row.get(1)?,
|
|
revision: row.get::<_, i64>(2)? as u32,
|
|
path: row.get(3)?,
|
|
size: row.get::<_, i64>(4)? as u64,
|
|
})
|
|
})?;
|
|
Ok(rows.filter_map(|r| r.ok()).collect())
|
|
}
|
|
|
|
/// Fetch repo metadata and resolve file ownership in a binary repo.
|
|
pub fn binary_repo_owns_path(
|
|
repo_name: &str,
|
|
repo: &crate::config::BinaryRepo,
|
|
rootfs: &Path,
|
|
package_cache_dir: &Path,
|
|
path: &str,
|
|
) -> Result<Vec<BinaryRepoFileSearchHit>> {
|
|
let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?;
|
|
cached_binary_repo_owns_path(repo_name, &db_path, path)
|
|
}
|
|
|
|
fn query_package_provides(conn: &Connection, package_id: i64) -> Result<Vec<String>> {
|
|
let mut stmt = conn.prepare("SELECT name FROM provides WHERE package_id = ?1 ORDER BY name")?;
|
|
let rows = stmt.query_map(params![package_id], |row| row.get(0))?;
|
|
Ok(rows.filter_map(|r| r.ok()).collect())
|
|
}
|
|
|
|
fn query_package_runtime_deps(conn: &Connection, package_id: i64) -> Result<Vec<String>> {
|
|
let has_dependencies_table: bool = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='dependencies'",
|
|
[],
|
|
|r| {
|
|
let n: i64 = r.get(0)?;
|
|
Ok(n > 0)
|
|
},
|
|
)
|
|
.unwrap_or(false);
|
|
if !has_dependencies_table {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut stmt = conn.prepare(
|
|
"SELECT name FROM dependencies WHERE package_id = ?1 AND kind = 'runtime' ORDER BY name",
|
|
)?;
|
|
let rows = stmt.query_map(params![package_id], |row| row.get(0))?;
|
|
Ok(rows.filter_map(|r| r.ok()).collect())
|
|
}
|
|
|
|
fn query_package_optional_deps(conn: &Connection, package_id: i64) -> Result<Vec<String>> {
|
|
let has_dependencies_table: bool = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='dependencies'",
|
|
[],
|
|
|r| {
|
|
let n: i64 = r.get(0)?;
|
|
Ok(n > 0)
|
|
},
|
|
)
|
|
.unwrap_or(false);
|
|
if !has_dependencies_table {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut stmt = conn.prepare(
|
|
"SELECT name FROM dependencies WHERE package_id = ?1 AND kind = 'optional' ORDER BY name",
|
|
)?;
|
|
let rows = stmt.query_map(params![package_id], |row| row.get(0))?;
|
|
Ok(rows.filter_map(|r| r.ok()).collect())
|
|
}
|
|
|
|
fn find_cached_binary_repo_packages(
|
|
repo_name: &str,
|
|
db_path: &Path,
|
|
query: &str,
|
|
) -> Result<Vec<BinaryRepoPackageRecord>> {
|
|
let conn = Connection::open(db_path)
|
|
.with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?;
|
|
|
|
let completed_at_expr = if repo_packages_have_completed_at(&conn)? {
|
|
"p.completed_at"
|
|
} else {
|
|
"NULL"
|
|
};
|
|
let sql = format!(
|
|
"SELECT
|
|
p.id,
|
|
p.name,
|
|
p.version,
|
|
p.revision,
|
|
{completed_at_expr},
|
|
p.filename,
|
|
p.size,
|
|
p.sha256,
|
|
p.sha512,
|
|
p.description,
|
|
p.homepage,
|
|
p.license
|
|
FROM packages p
|
|
WHERE lower(p.name) = lower(?1)
|
|
OR EXISTS (
|
|
SELECT 1 FROM provides pr
|
|
WHERE pr.package_id = p.id
|
|
AND lower(pr.name) = lower(?1)
|
|
)
|
|
ORDER BY
|
|
CASE WHEN lower(p.name) = lower(?1) THEN 0 ELSE 1 END,
|
|
p.name ASC"
|
|
);
|
|
let mut stmt = conn.prepare(&sql)?;
|
|
|
|
let rows = stmt.query_map(params![query], |row| {
|
|
let package_id = row.get::<_, i64>(0)?;
|
|
Ok((
|
|
package_id,
|
|
BinaryRepoPackageRecord {
|
|
repo_name: repo_name.to_string(),
|
|
name: row.get(1)?,
|
|
version: row.get(2)?,
|
|
revision: row.get::<_, i64>(3)? as u32,
|
|
completed_at: row.get(4)?,
|
|
filename: row.get(5)?,
|
|
size: row.get::<_, i64>(6)? as u64,
|
|
sha256: row.get(7)?,
|
|
sha512: row.get(8)?,
|
|
description: row.get(9)?,
|
|
homepage: row.get(10)?,
|
|
license: row.get(11)?,
|
|
provides: Vec::new(),
|
|
runtime_dependencies: Vec::new(),
|
|
optional_dependencies: Vec::new(),
|
|
},
|
|
))
|
|
})?;
|
|
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
let (package_id, mut rec) = row?;
|
|
rec.provides = query_package_provides(&conn, package_id)?;
|
|
rec.runtime_dependencies = query_package_runtime_deps(&conn, package_id)?;
|
|
rec.optional_dependencies = query_package_optional_deps(&conn, package_id)?;
|
|
out.push(rec);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn list_cached_binary_repo_packages(
|
|
repo_name: &str,
|
|
db_path: &Path,
|
|
) -> Result<Vec<BinaryRepoPackageRecord>> {
|
|
let conn = Connection::open(db_path)
|
|
.with_context(|| format!("Failed to open binary repo DB {}", db_path.display()))?;
|
|
|
|
let completed_at_expr = if repo_packages_have_completed_at(&conn)? {
|
|
"p.completed_at"
|
|
} else {
|
|
"NULL"
|
|
};
|
|
let sql = format!(
|
|
"SELECT
|
|
p.id,
|
|
p.name,
|
|
p.version,
|
|
p.revision,
|
|
{completed_at_expr},
|
|
p.filename,
|
|
p.size,
|
|
p.sha256,
|
|
p.sha512,
|
|
p.description,
|
|
p.homepage,
|
|
p.license
|
|
FROM packages p
|
|
ORDER BY p.name ASC, p.version ASC, p.revision ASC"
|
|
);
|
|
let mut stmt = conn.prepare(&sql)?;
|
|
|
|
let rows = stmt.query_map([], |row| {
|
|
let package_id = row.get::<_, i64>(0)?;
|
|
Ok((
|
|
package_id,
|
|
BinaryRepoPackageRecord {
|
|
repo_name: repo_name.to_string(),
|
|
name: row.get(1)?,
|
|
version: row.get(2)?,
|
|
revision: row.get::<_, i64>(3)? as u32,
|
|
completed_at: row.get(4)?,
|
|
filename: row.get(5)?,
|
|
size: row.get::<_, i64>(6)? as u64,
|
|
sha256: row.get(7)?,
|
|
sha512: row.get(8)?,
|
|
description: row.get(9)?,
|
|
homepage: row.get(10)?,
|
|
license: row.get(11)?,
|
|
provides: Vec::new(),
|
|
runtime_dependencies: Vec::new(),
|
|
optional_dependencies: Vec::new(),
|
|
},
|
|
))
|
|
})?;
|
|
|
|
let mut out = Vec::new();
|
|
for row in rows {
|
|
let (package_id, mut rec) = row?;
|
|
rec.provides = query_package_provides(&conn, package_id)?;
|
|
rec.runtime_dependencies = query_package_runtime_deps(&conn, package_id)?;
|
|
rec.optional_dependencies = query_package_optional_deps(&conn, package_id)?;
|
|
out.push(rec);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn repo_packages_have_completed_at(conn: &Connection) -> Result<bool> {
|
|
conn.query_row(
|
|
"SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'completed_at'",
|
|
[],
|
|
|row| {
|
|
let count: i64 = row.get(0)?;
|
|
Ok(count > 0)
|
|
},
|
|
)
|
|
.context("Failed to inspect binary repo DB schema")
|
|
}
|
|
|
|
fn path_modified_unix_timestamp(path: &Path) -> Result<Option<i64>> {
|
|
let metadata = fs::metadata(path)
|
|
.with_context(|| format!("Failed to read metadata for {}", path.display()))?;
|
|
let modified = metadata
|
|
.modified()
|
|
.with_context(|| format!("Failed to read modification time for {}", path.display()))?;
|
|
Ok(Some(metadata_time::system_time_to_unix(modified)?))
|
|
}
|
|
|
|
/// Resolve an exact package name/provide match from a binary repo after verifying
|
|
/// and caching its signed `repo.db.zst`.
|
|
pub fn find_binary_repo_package(
|
|
repo_name: &str,
|
|
repo: &crate::config::BinaryRepo,
|
|
rootfs: &Path,
|
|
package_cache_dir: &Path,
|
|
query: &str,
|
|
) -> Result<Option<BinaryRepoPackageRecord>> {
|
|
let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?;
|
|
let mut matches = find_cached_binary_repo_packages(repo_name, &db_path, query)?;
|
|
if matches.len() > 1 {
|
|
crate::log_warn!(
|
|
"Multiple binary packages matched '{}' in repo '{}'; using the first match",
|
|
query,
|
|
repo_name
|
|
);
|
|
}
|
|
Ok(matches.drain(..).next())
|
|
}
|
|
|
|
/// Resolve exact package name/provide matches from a binary repo.
|
|
pub fn find_binary_repo_packages(
|
|
repo_name: &str,
|
|
repo: &crate::config::BinaryRepo,
|
|
rootfs: &Path,
|
|
package_cache_dir: &Path,
|
|
query: &str,
|
|
) -> Result<Vec<BinaryRepoPackageRecord>> {
|
|
let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?;
|
|
find_cached_binary_repo_packages(repo_name, &db_path, query)
|
|
}
|
|
|
|
/// List all binary packages from a cached, verified repository database.
|
|
pub fn list_binary_repo_packages(
|
|
repo_name: &str,
|
|
repo: &crate::config::BinaryRepo,
|
|
rootfs: &Path,
|
|
package_cache_dir: &Path,
|
|
) -> Result<Vec<BinaryRepoPackageRecord>> {
|
|
let db_path = fetch_binary_repo_db(repo_name, repo, rootfs, package_cache_dir)?;
|
|
list_cached_binary_repo_packages(repo_name, &db_path)
|
|
}
|
|
|
|
fn verify_hex_digest(path: &Path, algorithm: &str, expected_hex: &str) -> Result<bool> {
|
|
let expected = expected_hex.trim().to_ascii_lowercase();
|
|
if expected.is_empty() {
|
|
return Ok(false);
|
|
}
|
|
|
|
let mut file =
|
|
fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
|
|
let mut buf = [0u8; 64 * 1024];
|
|
|
|
let actual = match algorithm {
|
|
"sha256" => {
|
|
use sha2::{Digest, Sha256};
|
|
let mut h = Sha256::new();
|
|
loop {
|
|
let n = file.read(&mut buf)?;
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
h.update(&buf[..n]);
|
|
}
|
|
format!("{:x}", h.finalize())
|
|
}
|
|
"sha512" => {
|
|
use sha2::{Digest, Sha512};
|
|
let mut h = Sha512::new();
|
|
loop {
|
|
let n = file.read(&mut buf)?;
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
h.update(&buf[..n]);
|
|
}
|
|
format!("{:x}", h.finalize())
|
|
}
|
|
_ => anyhow::bail!("Unsupported checksum algorithm: {}", algorithm),
|
|
};
|
|
|
|
Ok(actual == expected)
|
|
}
|
|
|
|
fn verify_binary_package_record_checksums(
|
|
path: &Path,
|
|
rec: &BinaryRepoPackageRecord,
|
|
) -> Result<()> {
|
|
if !verify_hex_digest(path, "sha256", &rec.sha256)? {
|
|
anyhow::bail!(
|
|
"SHA-256 mismatch for {} from repo '{}'",
|
|
path.display(),
|
|
rec.repo_name
|
|
);
|
|
}
|
|
if !verify_hex_digest(path, "sha512", &rec.sha512)? {
|
|
anyhow::bail!(
|
|
"SHA-512 mismatch for {} from repo '{}'",
|
|
path.display(),
|
|
rec.repo_name
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn download_binary_package_archive(
|
|
client: &reqwest::blocking::Client,
|
|
pkg_url: &str,
|
|
tmp_path: &Path,
|
|
progress_cb: &mut Option<&mut dyn FnMut(u64, Option<u64>)>,
|
|
) -> Result<()> {
|
|
match copy_file_url_to_path(pkg_url, tmp_path)? {
|
|
FileUrlCopyOutcome::Copied => {
|
|
if let Some(cb) = progress_cb.as_mut() {
|
|
let total = fs::metadata(tmp_path).map(|m| m.len()).unwrap_or(0);
|
|
cb(total, Some(total));
|
|
}
|
|
}
|
|
FileUrlCopyOutcome::Missing => {
|
|
anyhow::bail!("Failed to fetch {}: local file not found", pkg_url);
|
|
}
|
|
FileUrlCopyOutcome::NotFileUrl => {
|
|
let mut resp = client
|
|
.get(pkg_url)
|
|
.send()
|
|
.with_context(|| format!("Failed to fetch {}", pkg_url))?;
|
|
if !resp.status().is_success() {
|
|
anyhow::bail!("Failed to fetch {}: HTTP {}", pkg_url, resp.status());
|
|
}
|
|
|
|
let total = resp.content_length();
|
|
if let Some(cb) = progress_cb.as_mut() {
|
|
cb(0, total);
|
|
}
|
|
|
|
let mut out = fs::File::create(tmp_path)
|
|
.with_context(|| format!("Failed to create {}", tmp_path.display()))?;
|
|
let mut downloaded = 0u64;
|
|
let mut buf = [0u8; 64 * 1024];
|
|
loop {
|
|
let n = resp
|
|
.read(&mut buf)
|
|
.with_context(|| format!("Failed to read {}", pkg_url))?;
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
out.write_all(&buf[..n])
|
|
.with_context(|| format!("Failed to save {}", tmp_path.display()))?;
|
|
downloaded = downloaded.saturating_add(n as u64);
|
|
if let Some(cb) = progress_cb.as_mut() {
|
|
cb(downloaded, total);
|
|
}
|
|
}
|
|
out.flush()
|
|
.with_context(|| format!("Failed to flush {}", tmp_path.display()))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn fetch_binary_package_signature(
|
|
repo_name: &str,
|
|
repo: &crate::config::BinaryRepo,
|
|
client: &reqwest::blocking::Client,
|
|
sig_url: &str,
|
|
sig_path: &Path,
|
|
) -> Result<bool> {
|
|
let found = fetch_url_to_path(client, sig_url, sig_path)?;
|
|
if !found {
|
|
if !repo.allow_unsigned {
|
|
anyhow::bail!(
|
|
"Failed to fetch detached signature for binary package in repo '{}' at {}",
|
|
repo_name,
|
|
sig_url
|
|
);
|
|
}
|
|
crate::log_warn!(
|
|
"Detached package signature missing for binary repo '{}' at {}; allow_unsigned=true so continuing",
|
|
repo_name,
|
|
sig_url
|
|
);
|
|
}
|
|
Ok(found)
|
|
}
|
|
|
|
fn verify_binary_package_signature(
|
|
repo_name: &str,
|
|
repo: &crate::config::BinaryRepo,
|
|
rootfs: &Path,
|
|
pkg_path: &Path,
|
|
sig_path: &Path,
|
|
) -> Result<()> {
|
|
if !sig_path.exists() {
|
|
if repo.allow_unsigned {
|
|
return Ok(());
|
|
}
|
|
anyhow::bail!(
|
|
"Detached package signature required but missing for {}",
|
|
pkg_path.display()
|
|
);
|
|
}
|
|
|
|
let trusted_keys = crate::signing::list_trusted_public_keys(rootfs)?;
|
|
if trusted_keys.is_empty() {
|
|
if repo.allow_unsigned {
|
|
crate::log_warn!(
|
|
"No trusted minisign public key found; skipping package signature verification for binary repo '{}' because allow_unsigned=true",
|
|
repo_name
|
|
);
|
|
return Ok(());
|
|
}
|
|
anyhow::bail!(
|
|
"No trusted minisign public key found for detached package signature verification in binary repo '{}'",
|
|
repo_name
|
|
);
|
|
}
|
|
|
|
let _verified_key = verify_with_any_trusted_public_key(rootfs, pkg_path, sig_path)
|
|
.with_context(|| {
|
|
format!(
|
|
"Failed to verify detached package signature for {}",
|
|
pkg_path.display()
|
|
)
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Ensure a binary package archive and detached signature are present in cache
|
|
/// without performing checksum/signature verification.
|
|
pub fn cache_binary_package_archive(
|
|
repo_name: &str,
|
|
repo: &crate::config::BinaryRepo,
|
|
rec: &BinaryRepoPackageRecord,
|
|
package_cache_dir: &Path,
|
|
) -> Result<BinaryRepoCachedArchive> {
|
|
cache_binary_package_archive_with_progress(repo_name, repo, rec, package_cache_dir, None)
|
|
}
|
|
|
|
/// Ensure a binary package archive and detached signature are present in cache
|
|
/// without performing checksum/signature verification, optionally reporting
|
|
/// download progress.
|
|
pub fn cache_binary_package_archive_with_progress(
|
|
repo_name: &str,
|
|
repo: &crate::config::BinaryRepo,
|
|
rec: &BinaryRepoPackageRecord,
|
|
package_cache_dir: &Path,
|
|
mut progress_cb: Option<&mut dyn FnMut(u64, Option<u64>)>,
|
|
) -> Result<BinaryRepoCachedArchive> {
|
|
let machine_arch = std::env::consts::ARCH;
|
|
let base_url = repo.effective_url_for_arch(machine_arch).with_context(|| {
|
|
format!(
|
|
"Binary repo '{}' is not configured for machine arch '{}'",
|
|
repo_name, machine_arch
|
|
)
|
|
})?;
|
|
|
|
let cache_dir = binary_repo_packages_cache_dir(package_cache_dir, repo_name);
|
|
fs::create_dir_all(&cache_dir)
|
|
.with_context(|| format!("Failed to create {}", cache_dir.display()))?;
|
|
let package_path = cache_dir.join(&rec.filename);
|
|
let signature_path = cache_dir.join(format!("{}.sig", rec.filename));
|
|
let tmp_path = cache_dir.join(format!("{}.tmp", rec.filename));
|
|
let tmp_sig_path = cache_dir.join(format!("{}.sig.tmp", rec.filename));
|
|
let pkg_url = join_repo_url(base_url, &rec.filename)?;
|
|
let sig_url = join_repo_url(base_url, &format!("{}.sig", rec.filename))?;
|
|
|
|
let client = reqwest::blocking::Client::builder()
|
|
.build()
|
|
.context("Failed to build HTTP client for binary package fetch")?;
|
|
|
|
let package_downloaded = if !package_path.exists() {
|
|
download_binary_package_archive(&client, &pkg_url, &tmp_path, &mut progress_cb)?;
|
|
fs::rename(&tmp_path, &package_path).with_context(|| {
|
|
format!(
|
|
"Failed to move {} to {}",
|
|
tmp_path.display(),
|
|
package_path.display()
|
|
)
|
|
})?;
|
|
true
|
|
} else {
|
|
if let Some(cb) = progress_cb.as_mut() {
|
|
let total = fs::metadata(&package_path)
|
|
.with_context(|| format!("Failed to stat {}", package_path.display()))?
|
|
.len();
|
|
cb(total, Some(total));
|
|
}
|
|
false
|
|
};
|
|
|
|
if package_downloaded || !signature_path.exists() {
|
|
let sig_downloaded =
|
|
fetch_binary_package_signature(repo_name, repo, &client, &sig_url, &tmp_sig_path)?;
|
|
if sig_downloaded {
|
|
fs::rename(&tmp_sig_path, &signature_path).with_context(|| {
|
|
format!(
|
|
"Failed to move {} to {}",
|
|
tmp_sig_path.display(),
|
|
signature_path.display()
|
|
)
|
|
})?;
|
|
} else {
|
|
let _ = fs::remove_file(&signature_path);
|
|
}
|
|
}
|
|
|
|
Ok(BinaryRepoCachedArchive {
|
|
package_path,
|
|
signature_path,
|
|
})
|
|
}
|
|
|
|
/// Verify a cached/downloaded package archive against checksums from signed
|
|
/// repository metadata.
|
|
pub fn verify_binary_package_archive_checksums(
|
|
archive_path: &Path,
|
|
rec: &BinaryRepoPackageRecord,
|
|
) -> Result<()> {
|
|
verify_binary_package_record_checksums(archive_path, rec)
|
|
}
|
|
|
|
/// Verify a cached/downloaded package archive against its detached signature.
|
|
pub fn verify_binary_package_archive_signature(
|
|
repo_name: &str,
|
|
repo: &crate::config::BinaryRepo,
|
|
rootfs: &Path,
|
|
package_path: &Path,
|
|
signature_path: &Path,
|
|
) -> Result<()> {
|
|
verify_binary_package_signature(repo_name, repo, rootfs, package_path, signature_path)
|
|
}
|
|
|
|
/// Download a binary package archive and verify it against detached signatures
|
|
/// and checksums from signed repository metadata.
|
|
pub fn fetch_binary_package_archive(
|
|
repo_name: &str,
|
|
repo: &crate::config::BinaryRepo,
|
|
rootfs: &Path,
|
|
rec: &BinaryRepoPackageRecord,
|
|
package_cache_dir: &Path,
|
|
) -> Result<PathBuf> {
|
|
let cached = cache_binary_package_archive(repo_name, repo, rec, package_cache_dir)?;
|
|
verify_binary_package_archive_checksums(&cached.package_path, rec).with_context(|| {
|
|
format!(
|
|
"Binary package failed checksum verification: {}",
|
|
cached.package_path.display()
|
|
)
|
|
})?;
|
|
verify_binary_package_archive_signature(
|
|
repo_name,
|
|
repo,
|
|
rootfs,
|
|
&cached.package_path,
|
|
&cached.signature_path,
|
|
)
|
|
.with_context(|| {
|
|
format!(
|
|
"Binary package failed signature verification: {}",
|
|
cached.package_path.display()
|
|
)
|
|
})?;
|
|
Ok(cached.package_path)
|
|
}
|
|
|
|
/// Synchronize git mirrors into /usr/src/depot/<reponame>
|
|
pub fn sync_mirrors(
|
|
repo_dir: &std::path::Path,
|
|
mirrors: &std::collections::HashMap<String, String>,
|
|
) -> Result<()> {
|
|
use git2::{Cred, FetchOptions, RemoteCallbacks, Repository, ResetType, build::RepoBuilder};
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let base = repo_dir.to_path_buf();
|
|
if !base.exists() {
|
|
std::fs::create_dir_all(&base)?;
|
|
}
|
|
|
|
for (name, url) in mirrors {
|
|
let target = base.join(name);
|
|
let git_url = normalize_git_mirror_url(url)?;
|
|
if !target.exists() {
|
|
crate::log_info!("Cloning mirror '{}' -> {}", name, target.display());
|
|
|
|
// Use git2 RepoBuilder to clone
|
|
let mut cb = RemoteCallbacks::new();
|
|
cb.credentials(|_url, username_from_url, _allowed| {
|
|
// Try default credentials (ssh-agent / keychain)
|
|
Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
|
|
});
|
|
|
|
let mut fo = FetchOptions::new();
|
|
fo.remote_callbacks(cb);
|
|
|
|
let mut builder = RepoBuilder::new();
|
|
builder.fetch_options(fo);
|
|
builder
|
|
.clone(&git_url, &target)
|
|
.with_context(|| format!("Failed to clone {}", url))?;
|
|
} else {
|
|
crate::log_info!("Updating mirror '{}' in {}", name, target.display());
|
|
// Open repository and fetch updates
|
|
let repo = Repository::open(&target)
|
|
.with_context(|| format!("Failed to open repository at {}", target.display()))?;
|
|
|
|
let mut cb = RemoteCallbacks::new();
|
|
cb.credentials(|_url, username_from_url, _allowed| {
|
|
Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
|
|
});
|
|
|
|
let mut fo = FetchOptions::new();
|
|
fo.remote_callbacks(cb);
|
|
|
|
// Fetch from origin
|
|
let mut remote = repo
|
|
.find_remote("origin")
|
|
.or_else(|_| repo.remote_anonymous(&git_url))?;
|
|
remote
|
|
.fetch(&["refs/heads/*:refs/remotes/origin/*"], Some(&mut fo), None)
|
|
.with_context(|| format!("Failed to fetch updates for {}", url))?;
|
|
|
|
// Try to fast-forward HEAD to origin/HEAD by resetting to FETCH_HEAD if present
|
|
if let Ok(fetch_head) = repo.find_reference("FETCH_HEAD")
|
|
&& let Some(oid) = fetch_head.target()
|
|
{
|
|
let obj = repo.find_object(oid, None)?;
|
|
repo.reset(&obj, ResetType::Hard, None)?;
|
|
}
|
|
}
|
|
|
|
// Make the tree readable and writable by everyone
|
|
for entry in walkdir::WalkDir::new(&target) {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o777))?;
|
|
} else if path.is_file() {
|
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o666))?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Show status for each mirror repository: path, exists, branch/HEAD, latest commit, dirty
|
|
pub fn mirrors_status(
|
|
repo_dir: &std::path::Path,
|
|
mirrors: &std::collections::HashMap<String, String>,
|
|
) -> Result<()> {
|
|
use git2::Repository;
|
|
|
|
let base = repo_dir.to_path_buf();
|
|
if !base.exists() {
|
|
crate::log_info!("Repo base directory does not exist: {}", base.display());
|
|
return Ok(());
|
|
}
|
|
|
|
for name in mirrors.keys() {
|
|
let target = base.join(name);
|
|
crate::log_info!("--- {} ---", name);
|
|
if !target.exists() {
|
|
crate::log_info!("Not cloned: {}", target.display());
|
|
continue;
|
|
}
|
|
|
|
match Repository::open(&target) {
|
|
Ok(repo) => {
|
|
// Branch / HEAD
|
|
let head = repo.head().ok();
|
|
let branch = head
|
|
.as_ref()
|
|
.and_then(|h| h.shorthand().map(|s| s.to_string()))
|
|
.unwrap_or_else(|| "(no branch)".to_string());
|
|
|
|
// Latest commit OID
|
|
let oid = repo.refname_to_id("HEAD").ok();
|
|
let short = oid
|
|
.map(|o| format!("{}", o))
|
|
.unwrap_or_else(|| "(unknown)".to_string());
|
|
|
|
// Commit time (seconds since epoch) if available
|
|
let mut commit_time = String::new();
|
|
if let Some(oid) = oid
|
|
&& let Ok(commit) = repo.find_commit(oid)
|
|
{
|
|
let t = commit.time().seconds();
|
|
commit_time = format!("{}", t);
|
|
}
|
|
|
|
// Dirty status
|
|
let statuses = match repo.statuses(None) {
|
|
Ok(s) => s,
|
|
Err(_) => {
|
|
crate::log_warn!("Failed to read status for {}", target.display());
|
|
continue;
|
|
}
|
|
};
|
|
let dirty = statuses.iter().any(|s| {
|
|
s.status().intersects(
|
|
git2::Status::WT_MODIFIED | git2::Status::WT_NEW | git2::Status::WT_DELETED,
|
|
)
|
|
});
|
|
|
|
crate::log_info!("Path: {}", target.display());
|
|
crate::log_info!("Branch/HEAD: {}", branch);
|
|
crate::log_info!("HEAD OID: {}", short);
|
|
if !commit_time.is_empty() {
|
|
crate::log_info!("Latest commit time (epoch): {}", commit_time);
|
|
}
|
|
crate::log_info!("Dirty: {}", if dirty { "yes" } else { "no" });
|
|
}
|
|
Err(e) => {
|
|
crate::log_info!("Failed to open repo at {}: {}", target.display(), e);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn num_cpus() -> usize {
|
|
std::thread::available_parallelism()
|
|
.map(|n| n.get())
|
|
.unwrap_or(1)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_init_repo_schema() {
|
|
let mut conn = Connection::open_in_memory().unwrap();
|
|
let manager = RepoManager::new(PathBuf::from("."));
|
|
manager.init_repo_schema(&mut conn).unwrap();
|
|
|
|
// Check if table exists
|
|
let exists: bool = conn
|
|
.query_row(
|
|
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='packages'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert!(exists);
|
|
|
|
let has_sha512: bool = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'sha512'",
|
|
[],
|
|
|r| {
|
|
let n: i64 = r.get(0)?;
|
|
Ok(n > 0)
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(has_sha512);
|
|
|
|
let has_completed_at: bool = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM pragma_table_info('packages') WHERE name = 'completed_at'",
|
|
[],
|
|
|r| {
|
|
let n: i64 = r.get(0)?;
|
|
Ok(n > 0)
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(has_completed_at);
|
|
}
|
|
|
|
#[test]
|
|
fn test_index_package() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_dir = tmp.path();
|
|
let pkg_path = repo_dir.join("test-1.0-1-x86_64.depot.pkg.tar.zst");
|
|
|
|
// Create a valid .tar.zst with .metadata.toml
|
|
let file = fs::File::create(&pkg_path).unwrap();
|
|
let encoder = zstd::stream::write::Encoder::new(file, 3).unwrap();
|
|
let mut tar = tar::Builder::new(encoder);
|
|
|
|
let metadata = r#"
|
|
name = "test"
|
|
version = "1.0"
|
|
revision = 1
|
|
description = "test description"
|
|
homepage = "https://example.com"
|
|
license = "MIT"
|
|
completed_at = "2026-03-10T12:34:56Z"
|
|
provides = ["test-feature"]
|
|
|
|
[dependencies]
|
|
runtime = []
|
|
optional = []
|
|
"#;
|
|
let mut header = tar::Header::new_gnu();
|
|
header.set_path(".metadata.toml").unwrap();
|
|
header.set_size(metadata.len() as u64);
|
|
header.set_mode(0o644);
|
|
header.set_cksum();
|
|
tar.append(&header, metadata.as_bytes()).unwrap();
|
|
|
|
let encoder = tar.into_inner().unwrap();
|
|
encoder.finish().unwrap();
|
|
filetime::set_file_mtime(
|
|
&pkg_path,
|
|
filetime::FileTime::from_unix_time(1_700_000_000, 0),
|
|
)
|
|
.unwrap();
|
|
|
|
let mut conn = Connection::open_in_memory().unwrap();
|
|
let manager = RepoManager::new(repo_dir.to_path_buf());
|
|
manager.init_repo_schema(&mut conn).unwrap();
|
|
let indexed = manager.read_indexed_package(&pkg_path).unwrap();
|
|
manager.insert_indexed_package(&mut conn, indexed).unwrap();
|
|
|
|
type PackageRow = (
|
|
String,
|
|
String,
|
|
i64,
|
|
Option<String>,
|
|
Option<String>,
|
|
Option<String>,
|
|
String,
|
|
String,
|
|
);
|
|
|
|
let (name, version, revision, desc, home, lic, sha256, sha512): PackageRow = conn
|
|
.query_row(
|
|
"SELECT name, version, revision, description, homepage, license, sha256, sha512 FROM packages",
|
|
[],
|
|
|r| {
|
|
Ok((
|
|
r.get(0)?,
|
|
r.get(1)?,
|
|
r.get(2)?,
|
|
r.get(3)?,
|
|
r.get(4)?,
|
|
r.get(5)?,
|
|
r.get(6)?,
|
|
r.get(7)?,
|
|
))
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(name, "test");
|
|
assert_eq!(version, "1.0");
|
|
assert_eq!(revision, 1);
|
|
assert_eq!(desc, Some("test description".to_string()));
|
|
assert_eq!(home, Some("https://example.com".to_string()));
|
|
assert_eq!(lic, Some("MIT".to_string()));
|
|
assert_eq!(sha256.len(), 64);
|
|
assert_eq!(sha512.len(), 128);
|
|
|
|
let completed_at: Option<i64> = conn
|
|
.query_row("SELECT completed_at FROM packages", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(completed_at, Some(1_773_146_096));
|
|
|
|
let provides_count: i64 = conn
|
|
.query_row("SELECT count(*) FROM provides", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(provides_count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_index_package_with_multiple_licenses() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_dir = tmp.path();
|
|
let pkg_path = repo_dir.join("test-1.0-1-x86_64.depot.pkg.tar.zst");
|
|
|
|
let file = fs::File::create(&pkg_path).unwrap();
|
|
let encoder = zstd::stream::write::Encoder::new(file, 3).unwrap();
|
|
let mut tar = tar::Builder::new(encoder);
|
|
|
|
let metadata = r#"
|
|
name = "test"
|
|
version = "1.0"
|
|
revision = 1
|
|
license = ["MIT", "Apache-2.0"]
|
|
"#;
|
|
let mut header = tar::Header::new_gnu();
|
|
header.set_path(".metadata.toml").unwrap();
|
|
header.set_size(metadata.len() as u64);
|
|
header.set_mode(0o644);
|
|
header.set_cksum();
|
|
tar.append(&header, metadata.as_bytes()).unwrap();
|
|
|
|
let encoder = tar.into_inner().unwrap();
|
|
encoder.finish().unwrap();
|
|
|
|
let mut conn = Connection::open_in_memory().unwrap();
|
|
let manager = RepoManager::new(repo_dir.to_path_buf());
|
|
manager.init_repo_schema(&mut conn).unwrap();
|
|
let indexed = manager.read_indexed_package(&pkg_path).unwrap();
|
|
manager.insert_indexed_package(&mut conn, indexed).unwrap();
|
|
|
|
let lic: Option<String> = conn
|
|
.query_row("SELECT license FROM packages", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(lic, Some("MIT, Apache-2.0".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_search_cached_binary_repo_db_matches_name_and_provides() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let db_path = tmp.path().join("repo.db");
|
|
let mut conn = Connection::open(&db_path).unwrap();
|
|
let manager = RepoManager::new(tmp.path().to_path_buf());
|
|
manager.init_repo_schema(&mut conn).unwrap();
|
|
|
|
conn.execute(
|
|
"INSERT INTO packages (id, name, version, revision, description, homepage, license, filename, size, sha256, sha512)
|
|
VALUES (1, 'foo', '1.2.3', 1, 'Foo package', 'https://example.test', 'MIT', 'foo-1.2.3-1-x86_64.depot.pkg.tar.zst', 1234, 'a', 'b')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO provides (package_id, name) VALUES (1, 'libfoo.so')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
drop(conn);
|
|
|
|
let name_hits = search_cached_binary_repo_db("testrepo", &db_path, "foo").unwrap();
|
|
assert_eq!(name_hits.len(), 1);
|
|
assert_eq!(name_hits[0].name, "foo");
|
|
assert_eq!(name_hits[0].repo_name, "testrepo");
|
|
assert!(name_hits[0].provides.iter().any(|p| p == "libfoo.so"));
|
|
|
|
let provide_hits = search_cached_binary_repo_db("testrepo", &db_path, "libfoo").unwrap();
|
|
assert_eq!(provide_hits.len(), 1);
|
|
assert_eq!(provide_hits[0].name, "foo");
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_cached_binary_repo_package_prefers_exact_name() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let db_path = tmp.path().join("repo.db");
|
|
let mut conn = Connection::open(&db_path).unwrap();
|
|
let manager = RepoManager::new(tmp.path().to_path_buf());
|
|
manager.init_repo_schema(&mut conn).unwrap();
|
|
|
|
conn.execute(
|
|
"INSERT INTO packages (id, name, version, revision, description, homepage, license, filename, size, sha256, sha512)
|
|
VALUES (1, 'foo', '1.0', 1, NULL, NULL, NULL, 'foo-1.0-1.depot.pkg.tar.zst', 10, 'aa', 'bb')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO packages (id, name, version, revision, description, homepage, license, filename, size, sha256, sha512)
|
|
VALUES (2, 'bar', '1.0', 1, NULL, NULL, NULL, 'bar-1.0-1.depot.pkg.tar.zst', 10, 'cc', 'dd')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
conn.execute(
|
|
"INSERT INTO provides (package_id, name) VALUES (2, 'foo')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
drop(conn);
|
|
|
|
let recs = find_cached_binary_repo_packages("repo", &db_path, "foo").unwrap();
|
|
let rec = recs.first().expect("expected a match");
|
|
assert_eq!(rec.name, "foo");
|
|
assert_eq!(rec.filename, "foo-1.0-1.depot.pkg.tar.zst");
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_binary_package_record_checksums_accepts_valid_hashes() {
|
|
use sha2::{Digest, Sha256, Sha512};
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let pkg = tmp.path().join("pkg.depot.pkg.tar.zst");
|
|
fs::write(&pkg, b"payload").unwrap();
|
|
|
|
let sha256 = {
|
|
let mut h = Sha256::new();
|
|
h.update(b"payload");
|
|
format!("{:x}", h.finalize())
|
|
};
|
|
let sha512 = {
|
|
let mut h = Sha512::new();
|
|
h.update(b"payload");
|
|
format!("{:x}", h.finalize())
|
|
};
|
|
|
|
let rec = BinaryRepoPackageRecord {
|
|
repo_name: "repo".into(),
|
|
name: "pkg".into(),
|
|
version: "1.0".into(),
|
|
revision: 1,
|
|
completed_at: None,
|
|
filename: "pkg.depot.pkg.tar.zst".into(),
|
|
size: 7,
|
|
sha256,
|
|
sha512,
|
|
description: None,
|
|
homepage: None,
|
|
license: None,
|
|
provides: Vec::new(),
|
|
runtime_dependencies: Vec::new(),
|
|
optional_dependencies: Vec::new(),
|
|
};
|
|
|
|
verify_binary_package_record_checksums(&pkg, &rec).unwrap();
|
|
}
|
|
|
|
fn test_record_for_payload(filename: &str, payload: &[u8]) -> BinaryRepoPackageRecord {
|
|
use sha2::{Digest, Sha256, Sha512};
|
|
|
|
let sha256 = {
|
|
let mut h = Sha256::new();
|
|
h.update(payload);
|
|
format!("{:x}", h.finalize())
|
|
};
|
|
let sha512 = {
|
|
let mut h = Sha512::new();
|
|
h.update(payload);
|
|
format!("{:x}", h.finalize())
|
|
};
|
|
|
|
BinaryRepoPackageRecord {
|
|
repo_name: "repo".into(),
|
|
name: "pkg".into(),
|
|
version: "1.0".into(),
|
|
revision: 1,
|
|
completed_at: None,
|
|
filename: filename.to_string(),
|
|
size: payload.len() as u64,
|
|
sha256,
|
|
sha512,
|
|
description: None,
|
|
homepage: None,
|
|
license: None,
|
|
provides: Vec::new(),
|
|
runtime_dependencies: Vec::new(),
|
|
optional_dependencies: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_fetch_binary_package_archive_requires_signature_when_unsigned_disallowed() {
|
|
let rootfs = tempfile::tempdir().unwrap();
|
|
let repo_dir = tempfile::tempdir().unwrap();
|
|
let cache_dir = tempfile::tempdir().unwrap();
|
|
|
|
let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst";
|
|
let payload = b"package payload";
|
|
std::fs::write(repo_dir.path().join(filename), payload).unwrap();
|
|
|
|
let rec = test_record_for_payload(filename, payload);
|
|
let repo_url = url::Url::from_directory_path(repo_dir.path())
|
|
.expect("file URL")
|
|
.to_string();
|
|
let repo_cfg = crate::config::BinaryRepo {
|
|
url: repo_url,
|
|
allow_unsigned: false,
|
|
..Default::default()
|
|
};
|
|
|
|
let err =
|
|
fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path())
|
|
.expect_err("missing detached signature should fail");
|
|
assert!(err.to_string().to_ascii_lowercase().contains("signature"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_fetch_binary_package_archive_verifies_signature_and_checksum() {
|
|
let rootfs = tempfile::tempdir().unwrap();
|
|
let repo_dir = tempfile::tempdir().unwrap();
|
|
let cache_dir = tempfile::tempdir().unwrap();
|
|
|
|
let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs.path());
|
|
std::fs::create_dir_all(&trusted_dir).unwrap();
|
|
|
|
let keypair = minisign::KeyPair::generate_unencrypted_keypair().unwrap();
|
|
std::fs::write(
|
|
trusted_dir.join("repo.pub"),
|
|
keypair.pk.to_box().unwrap().to_bytes(),
|
|
)
|
|
.unwrap();
|
|
|
|
let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst";
|
|
let payload = b"signed package payload";
|
|
let package_path = repo_dir.path().join(filename);
|
|
std::fs::write(&package_path, payload).unwrap();
|
|
|
|
let sig = minisign::sign(
|
|
Some(&keypair.pk),
|
|
&keypair.sk,
|
|
std::fs::File::open(&package_path).unwrap(),
|
|
None,
|
|
Some("test signature"),
|
|
)
|
|
.unwrap();
|
|
std::fs::write(format!("{}.sig", package_path.display()), sig.to_bytes()).unwrap();
|
|
|
|
let rec = test_record_for_payload(filename, payload);
|
|
let repo_url = url::Url::from_directory_path(repo_dir.path())
|
|
.expect("file URL")
|
|
.to_string();
|
|
let repo_cfg = crate::config::BinaryRepo {
|
|
url: repo_url,
|
|
allow_unsigned: false,
|
|
..Default::default()
|
|
};
|
|
|
|
let fetched =
|
|
fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path())
|
|
.unwrap();
|
|
assert_eq!(std::fs::read(&fetched).unwrap(), payload);
|
|
assert!(PathBuf::from(format!("{}.sig", fetched.display())).exists());
|
|
}
|
|
|
|
#[test]
|
|
fn test_cache_binary_package_archive_supports_phased_verification() {
|
|
let rootfs = tempfile::tempdir().unwrap();
|
|
let repo_dir = tempfile::tempdir().unwrap();
|
|
let cache_dir = tempfile::tempdir().unwrap();
|
|
|
|
let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs.path());
|
|
std::fs::create_dir_all(&trusted_dir).unwrap();
|
|
|
|
let keypair = minisign::KeyPair::generate_unencrypted_keypair().unwrap();
|
|
std::fs::write(
|
|
trusted_dir.join("repo.pub"),
|
|
keypair.pk.to_box().unwrap().to_bytes(),
|
|
)
|
|
.unwrap();
|
|
|
|
let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst";
|
|
let payload = b"staged verification payload";
|
|
let package_path = repo_dir.path().join(filename);
|
|
std::fs::write(&package_path, payload).unwrap();
|
|
|
|
let sig = minisign::sign(
|
|
Some(&keypair.pk),
|
|
&keypair.sk,
|
|
std::fs::File::open(&package_path).unwrap(),
|
|
None,
|
|
Some("test signature"),
|
|
)
|
|
.unwrap();
|
|
std::fs::write(format!("{}.sig", package_path.display()), sig.to_bytes()).unwrap();
|
|
|
|
let rec = test_record_for_payload(filename, payload);
|
|
let repo_url = url::Url::from_directory_path(repo_dir.path())
|
|
.expect("file URL")
|
|
.to_string();
|
|
let repo_cfg = crate::config::BinaryRepo {
|
|
url: repo_url,
|
|
allow_unsigned: false,
|
|
..Default::default()
|
|
};
|
|
|
|
let cached = cache_binary_package_archive("repo", &repo_cfg, &rec, cache_dir.path())
|
|
.expect("cache should succeed");
|
|
assert!(cached.package_path.exists());
|
|
assert!(cached.signature_path.exists());
|
|
|
|
verify_binary_package_archive_checksums(&cached.package_path, &rec)
|
|
.expect("checksum verification should succeed");
|
|
verify_binary_package_archive_signature(
|
|
"repo",
|
|
&repo_cfg,
|
|
rootfs.path(),
|
|
&cached.package_path,
|
|
&cached.signature_path,
|
|
)
|
|
.expect("signature verification should succeed");
|
|
}
|
|
|
|
#[test]
|
|
fn test_fetch_binary_package_archive_allows_missing_signature_when_configured() {
|
|
let rootfs = tempfile::tempdir().unwrap();
|
|
let repo_dir = tempfile::tempdir().unwrap();
|
|
let cache_dir = tempfile::tempdir().unwrap();
|
|
|
|
let filename = "pkg-1.0-1-x86_64.depot.pkg.tar.zst";
|
|
let payload = b"unsigned package payload";
|
|
std::fs::write(repo_dir.path().join(filename), payload).unwrap();
|
|
|
|
let rec = test_record_for_payload(filename, payload);
|
|
let repo_url = url::Url::from_directory_path(repo_dir.path())
|
|
.expect("file URL")
|
|
.to_string();
|
|
let repo_cfg = crate::config::BinaryRepo {
|
|
url: repo_url,
|
|
allow_unsigned: true,
|
|
..Default::default()
|
|
};
|
|
|
|
let fetched =
|
|
fetch_binary_package_archive("repo", &repo_cfg, rootfs.path(), &rec, cache_dir.path())
|
|
.unwrap();
|
|
assert_eq!(std::fs::read(&fetched).unwrap(), payload);
|
|
assert!(!PathBuf::from(format!("{}.sig", fetched.display())).exists());
|
|
}
|
|
|
|
#[test]
|
|
fn test_copy_file_url_to_path_supports_file_scheme() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let src = tmp.path().join("repo.db.zst");
|
|
let dst = tmp.path().join("copy.zst");
|
|
fs::write(&src, b"repo-db").unwrap();
|
|
|
|
let url = format!("file://{}", src.display());
|
|
let outcome = copy_file_url_to_path(&url, &dst).unwrap();
|
|
assert_eq!(outcome, FileUrlCopyOutcome::Copied);
|
|
assert_eq!(fs::read(&dst).unwrap(), b"repo-db");
|
|
}
|
|
|
|
#[test]
|
|
fn test_copy_file_url_to_path_reports_missing_file() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let missing = tmp.path().join("missing.db.zst");
|
|
let dst = tmp.path().join("copy.zst");
|
|
|
|
let url = format!("file://{}", missing.display());
|
|
let outcome = copy_file_url_to_path(&url, &dst).unwrap();
|
|
assert_eq!(outcome, FileUrlCopyOutcome::Missing);
|
|
assert!(!dst.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn test_repo_db_fetch_cache_roundtrip_and_prunes_stale_entry() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let db_path = tmp.path().join("repo.db");
|
|
fs::write(&db_path, b"db").unwrap();
|
|
|
|
let key = RepoDbFetchCacheKey {
|
|
repo_name: "core".to_string(),
|
|
base_url: "https://repo.example.test/core".to_string(),
|
|
repo_db_rel: "repo.db.zst".to_string(),
|
|
rootfs: PathBuf::from("/tmp/rootfs-test"),
|
|
package_cache_dir: PathBuf::from("/tmp/pkg-cache-test"),
|
|
};
|
|
|
|
remember_repo_db_path(key.clone(), db_path.clone());
|
|
assert_eq!(get_cached_repo_db_path(&key), Some(db_path.clone()));
|
|
|
|
fs::remove_file(&db_path).unwrap();
|
|
assert_eq!(get_cached_repo_db_path(&key), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_html_href_targets_parses_common_forms() {
|
|
let html = r#"
|
|
<html><body>
|
|
<a href="alpha.pub">alpha</a>
|
|
<a HREF='nested/beta.pub'>beta</a>
|
|
<a href=gamma.pub>gamma</a>
|
|
<a href="../">parent</a>
|
|
</body></html>
|
|
"#;
|
|
let hrefs = extract_html_href_targets(html);
|
|
assert!(hrefs.iter().any(|h| h == "alpha.pub"));
|
|
assert!(hrefs.iter().any(|h| h == "nested/beta.pub"));
|
|
assert!(hrefs.iter().any(|h| h == "gamma.pub"));
|
|
assert!(hrefs.iter().any(|h| h == "../"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_repo_public_key_urls_reads_file_repo_keys_dir() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_dir = tmp.path().join("repo");
|
|
let keys_dir = repo_dir.join("keys");
|
|
fs::create_dir_all(&keys_dir).unwrap();
|
|
fs::write(keys_dir.join("repo.pub"), b"pubkey").unwrap();
|
|
fs::write(keys_dir.join("ignore.txt"), b"nope").unwrap();
|
|
fs::create_dir_all(keys_dir.join("subdir")).unwrap();
|
|
|
|
let base_url = url::Url::from_directory_path(&repo_dir)
|
|
.expect("file URL")
|
|
.to_string();
|
|
let client = reqwest::blocking::Client::builder().build().unwrap();
|
|
let keys = list_repo_public_key_urls(&base_url, &client).unwrap();
|
|
assert_eq!(keys.len(), 1);
|
|
assert_eq!(keys[0].0, "repo.pub");
|
|
assert!(keys[0].1.ends_with("/repo.pub"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_repo_public_key_urls_probes_common_names_when_index_missing() {
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::net::TcpListener;
|
|
use std::thread;
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let server = thread::spawn(move || {
|
|
for _ in 0..7 {
|
|
let (mut stream, _) = listener.accept().unwrap();
|
|
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
|
let mut request_line = String::new();
|
|
reader.read_line(&mut request_line).unwrap();
|
|
loop {
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).unwrap();
|
|
if line == "\r\n" || line.is_empty() {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let path = request_line
|
|
.split_whitespace()
|
|
.nth(1)
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
let (status, body) = if path == "/core/keys/vertex.pub" {
|
|
("200 OK", "trusted-key")
|
|
} else {
|
|
("404 Not Found", "missing")
|
|
};
|
|
|
|
write!(
|
|
stream,
|
|
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
)
|
|
.unwrap();
|
|
stream.flush().unwrap();
|
|
}
|
|
});
|
|
|
|
let base_url = format!("http://{}/core", addr);
|
|
let client = reqwest::blocking::Client::builder().build().unwrap();
|
|
let keys = list_repo_public_key_urls(&base_url, &client).unwrap();
|
|
server.join().unwrap();
|
|
|
|
assert_eq!(keys.len(), 1);
|
|
assert_eq!(keys[0].0, "vertex.pub");
|
|
assert!(keys[0].1.ends_with("/core/keys/vertex.pub"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_fetch_binary_repo_db_can_recover_from_stale_trusted_key() {
|
|
use std::io::Write;
|
|
|
|
struct AssumeYesReset;
|
|
impl Drop for AssumeYesReset {
|
|
fn drop(&mut self) {
|
|
crate::ui::set_assume_yes(false);
|
|
}
|
|
}
|
|
|
|
crate::ui::set_assume_yes(true);
|
|
let _reset = AssumeYesReset;
|
|
|
|
let rootfs = tempfile::tempdir().unwrap();
|
|
let repo_dir = tempfile::tempdir().unwrap();
|
|
let cache_dir = tempfile::tempdir().unwrap();
|
|
|
|
let stale_keypair = minisign::KeyPair::generate_unencrypted_keypair().unwrap();
|
|
let repo_keypair = minisign::KeyPair::generate_unencrypted_keypair().unwrap();
|
|
|
|
let trusted_dir = crate::signing::trusted_public_keys_dir(rootfs.path());
|
|
fs::create_dir_all(&trusted_dir).unwrap();
|
|
fs::write(
|
|
trusted_dir.join("vertex.pub"),
|
|
stale_keypair.pk.to_box().unwrap().to_bytes(),
|
|
)
|
|
.unwrap();
|
|
|
|
let repo_keys_dir = repo_dir.path().join("keys");
|
|
fs::create_dir_all(&repo_keys_dir).unwrap();
|
|
fs::write(
|
|
repo_keys_dir.join("vertex.pub"),
|
|
repo_keypair.pk.to_box().unwrap().to_bytes(),
|
|
)
|
|
.unwrap();
|
|
|
|
let repo_db_path = repo_dir.path().join("repo.db.zst");
|
|
let mut encoder =
|
|
zstd::stream::write::Encoder::new(fs::File::create(&repo_db_path).unwrap(), 3).unwrap();
|
|
encoder.write_all(b"repo-db-content").unwrap();
|
|
encoder.finish().unwrap();
|
|
|
|
let sig = minisign::sign(
|
|
Some(&repo_keypair.pk),
|
|
&repo_keypair.sk,
|
|
fs::File::open(&repo_db_path).unwrap(),
|
|
None,
|
|
Some("repo db signature"),
|
|
)
|
|
.unwrap();
|
|
fs::write(repo_dir.path().join("repo.db.zst.sig"), sig.to_bytes()).unwrap();
|
|
|
|
let repo_cfg = crate::config::BinaryRepo {
|
|
url: url::Url::from_directory_path(repo_dir.path())
|
|
.expect("file URL")
|
|
.to_string(),
|
|
allow_unsigned: false,
|
|
..Default::default()
|
|
};
|
|
|
|
let sqlite_db =
|
|
fetch_binary_repo_db("core", &repo_cfg, rootfs.path(), cache_dir.path()).unwrap();
|
|
assert_eq!(fs::read(sqlite_db).unwrap(), b"repo-db-content");
|
|
|
|
let installed_key = trusted_dir.join("core-vertex.pub");
|
|
assert!(installed_key.exists());
|
|
assert_eq!(
|
|
fs::read(installed_key).unwrap(),
|
|
repo_keypair.pk.to_box().unwrap().to_bytes()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_normalize_git_mirror_url_converts_file_scheme() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_dir = tmp.path().join("repo.git");
|
|
fs::create_dir_all(&repo_dir).unwrap();
|
|
|
|
let url = format!("file://{}", repo_dir.display());
|
|
let normalized = normalize_git_mirror_url(&url).unwrap();
|
|
assert_eq!(normalized, repo_dir.to_string_lossy());
|
|
}
|
|
}
|