use crate::models::{Error, Result};
use std::collections::HashMap;
use std::path::Path;
pub const LOCKFILE_VERSION: &str = "1.0.0";
#[derive(Debug, Clone)]
pub struct Lockfile {
pub generated_at: u64,
pub generator: String,
pub content_hash: String,
pub artifacts: Vec<LockedArtifact>,
pub environment: LockfileEnvironment,
}
impl Lockfile {
pub fn new() -> Self {
Self {
generated_at: current_timestamp(),
generator: format!("bashrs-installer/{}", env!("CARGO_PKG_VERSION")),
content_hash: String::new(),
artifacts: Vec::new(),
environment: LockfileEnvironment::capture(),
}
}
pub fn add_artifact(&mut self, artifact: LockedArtifact) {
self.artifacts.push(artifact);
}
pub fn finalize(&mut self) {
self.content_hash = self.compute_hash();
}
fn compute_hash(&self) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
for artifact in &self.artifacts {
artifact.id.hash(&mut hasher);
artifact.version.hash(&mut hasher);
artifact.sha256.hash(&mut hasher);
artifact.url.hash(&mut hasher);
}
self.environment.source_date_epoch.hash(&mut hasher);
format!("sha256:{:016x}", hasher.finish())
}
pub fn verify(&self) -> Result<()> {
let computed = self.compute_hash();
if computed != self.content_hash {
return Err(Error::Validation(format!(
"Lockfile integrity check failed: expected {}, got {}",
self.content_hash, computed
)));
}
Ok(())
}
pub fn save(&self, path: &Path) -> Result<()> {
let toml = self.to_toml();
std::fs::write(path, toml).map_err(|e| {
Error::Io(std::io::Error::new(
e.kind(),
format!("Failed to write lockfile: {}", e),
))
})
}
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path).map_err(|e| {
Error::Io(std::io::Error::new(
e.kind(),
format!("Failed to read lockfile: {}", e),
))
})?;
Self::from_toml(&content)
}
pub fn to_toml(&self) -> String {
let mut toml = String::new();
toml.push_str("# Lockfile for hermetic builds\n");
toml.push_str("# Generated by bashrs installer\n\n");
toml.push_str("[lockfile]\n");
toml.push_str(&format!("version = \"{}\"\n", LOCKFILE_VERSION));
toml.push_str(&format!("generated_at = {}\n", self.generated_at));
toml.push_str(&format!("generator = \"{}\"\n", self.generator));
toml.push_str(&format!("content_hash = \"{}\"\n", self.content_hash));
toml.push('\n');
toml.push_str("[environment]\n");
toml.push_str(&format!(
"source_date_epoch = {}\n",
self.environment.source_date_epoch
));
toml.push_str(&format!("lc_all = \"{}\"\n", self.environment.lc_all));
toml.push_str(&format!("tz = \"{}\"\n", self.environment.tz));
toml.push('\n');
for artifact in &self.artifacts {
toml.push_str("[[locked.artifact]]\n");
toml.push_str(&format!("id = \"{}\"\n", artifact.id));
toml.push_str(&format!("version = \"{}\"\n", artifact.version));
toml.push_str(&format!("url = \"{}\"\n", artifact.url));
toml.push_str(&format!("sha256 = \"{}\"\n", artifact.sha256));
toml.push_str(&format!("size = {}\n", artifact.size));
toml.push_str(&format!("fetched_at = {}\n", artifact.fetched_at));
toml.push('\n');
}
toml
}
fn apply_lockfile_field(&mut self, key: &str, value: &str) {
match key {
"generated_at" => self.generated_at = value.parse().unwrap_or(0),
"generator" => self.generator = value.to_string(),
"content_hash" => self.content_hash = value.to_string(),
_ => {}
}
}
fn apply_environment_field(&mut self, key: &str, value: &str) {
match key {
"source_date_epoch" => self.environment.source_date_epoch = value.parse().unwrap_or(0),
"lc_all" => self.environment.lc_all = value.to_string(),
"tz" => self.environment.tz = value.to_string(),
_ => {}
}
}
pub fn from_toml(content: &str) -> Result<Self> {
let mut lockfile = Lockfile::new();
let mut in_lockfile = false;
let mut in_environment = false;
let mut in_artifact = false;
let mut current_artifact: Option<LockedArtifact> = None;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line == "[lockfile]" {
in_lockfile = true;
in_environment = false;
in_artifact = false;
continue;
}
if line == "[environment]" {
in_lockfile = false;
in_environment = true;
in_artifact = false;
continue;
}
if line == "[[locked.artifact]]" {
if let Some(artifact) = current_artifact.take() {
lockfile.artifacts.push(artifact);
}
current_artifact = Some(LockedArtifact::default());
in_lockfile = false;
in_environment = false;
in_artifact = true;
continue;
}
if let Some((key, value)) = parse_toml_line(line) {
if in_lockfile {
lockfile.apply_lockfile_field(key, value);
} else if in_environment {
lockfile.apply_environment_field(key, value);
} else if in_artifact {
if let Some(ref mut artifact) = current_artifact {
artifact.apply_field(key, value);
}
}
}
}
if let Some(artifact) = current_artifact {
lockfile.artifacts.push(artifact);
}
Ok(lockfile)
}
pub fn get_artifact(&self, id: &str) -> Option<&LockedArtifact> {
self.artifacts.iter().find(|a| a.id == id)
}
}
impl Default for Lockfile {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Default)]
pub struct LockedArtifact {
pub id: String,
pub version: String,
pub url: String,
pub sha256: String,
pub size: u64,
pub fetched_at: u64,
}
impl LockedArtifact {
fn apply_field(&mut self, key: &str, value: &str) {
match key {
"id" => self.id = value.to_string(),
"version" => self.version = value.to_string(),
"url" => self.url = value.to_string(),
"sha256" => self.sha256 = value.to_string(),
"size" => self.size = value.parse().unwrap_or(0),
"fetched_at" => self.fetched_at = value.parse().unwrap_or(0),
_ => {}
}
}
pub fn new(id: &str, version: &str, url: &str, sha256: &str, size: u64) -> Self {
Self {
id: id.to_string(),
version: version.to_string(),
url: url.to_string(),
sha256: sha256.to_string(),
size,
fetched_at: current_timestamp(),
}
}
}
#[derive(Debug, Clone)]
pub struct LockfileEnvironment {
pub source_date_epoch: u64,
pub lc_all: String,
pub tz: String,
}
impl LockfileEnvironment {
pub fn capture() -> Self {
Self {
source_date_epoch: std::env::var("SOURCE_DATE_EPOCH")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or_else(current_timestamp),
lc_all: std::env::var("LC_ALL").unwrap_or_else(|_| "C.UTF-8".to_string()),
tz: std::env::var("TZ").unwrap_or_else(|_| "UTC".to_string()),
}
}
pub fn deterministic(source_date_epoch: u64) -> Self {
Self {
source_date_epoch,
lc_all: "C.UTF-8".to_string(),
tz: "UTC".to_string(),
}
}
}
impl Default for LockfileEnvironment {
fn default() -> Self {
Self::capture()
}
}
#[derive(Debug, Clone)]
pub struct HermeticContext {
pub lockfile: Lockfile,
pub strict: bool,
artifact_cache: HashMap<String, String>,
}
impl HermeticContext {
pub fn from_lockfile(lockfile: Lockfile) -> Result<Self> {
lockfile.verify()?;
let artifact_cache = lockfile
.artifacts
.iter()
.map(|a| (a.id.clone(), a.sha256.clone()))
.collect();
Ok(Self {
lockfile,
strict: true,
artifact_cache,
})
}
pub fn load(lockfile_path: &Path) -> Result<Self> {
let lockfile = Lockfile::load(lockfile_path)?;
Self::from_lockfile(lockfile)
}
pub fn source_date_epoch(&self) -> u64 {
self.lockfile.environment.source_date_epoch
}
pub fn verify_artifact(&self, id: &str, sha256: &str) -> Result<()> {
let expected = self
.artifact_cache
.get(id)
.ok_or_else(|| Error::Validation(format!("Artifact '{}' not found in lockfile", id)))?;
if expected != sha256 {
return Err(Error::Validation(format!(
"Artifact '{}' hash mismatch: lockfile={}, actual={}",
id, expected, sha256
)));
}
Ok(())
}
pub fn has_artifact(&self, id: &str) -> bool {
self.artifact_cache.contains_key(id)
}
}
fn parse_toml_line(line: &str) -> Option<(&str, &str)> {
let mut parts = line.splitn(2, '=');
let key = parts.next()?.trim();
let value = parts.next()?.trim();
let value = value.trim_matches('"');
Some((key, value))
}
fn current_timestamp() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
#[path = "hermetic_tests_hermetic_109.rs"]
mod tests_extracted;