Add catalog_writer module for atomic catalog updates with SHA-1 signatures

- Introduced `write_catalog_attrs`, `write_catalog_part`, and `write_update_log` functions for streamlined and secure file writing.
- Refactored `file_backend` to use `catalog_writer` for managing catalog updates, improving readability and maintainability.
- Updated `save_catalog_attrs`, `save_catalog_part`, and `append_update` to leverage atomic writes and ensure signature computation.
- Replaced manual serialization logic with centralized writing utilities for consistency and error resilience.
- Updated dependencies for JSON handling and signature computation.
This commit is contained in:
Till Wegmueller 2025-12-09 12:49:25 +01:00
parent a948f87e6f
commit bd67e06012
No known key found for this signature in database
4 changed files with 225 additions and 19 deletions

View file

@ -175,7 +175,7 @@ impl CatalogAttrs {
/// Save catalog attributes to a file /// Save catalog attributes to a file
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> { pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let json = serde_json::to_string_pretty(self)?; let json = serde_json::to_string(self)?;
fs::write(path, json)?; fs::write(path, json)?;
Ok(()) Ok(())
} }
@ -370,7 +370,7 @@ impl UpdateLog {
/// Save update log to a file /// Save update log to a file
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> { pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let json = serde_json::to_string_pretty(self)?; let json = serde_json::to_string(self)?;
fs::write(path, json)?; fs::write(path, json)?;
Ok(()) Ok(())
} }

View file

@ -0,0 +1,95 @@
// This Source Code Form is subject to the terms of
// the Mozilla Public License, v. 2.0. If a copy of the
// MPL was not distributed with this file, You can
// obtain one at https://mozilla.org/MPL/2.0/.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tracing::{debug, instrument};
use super::catalog::{CatalogAttrs, CatalogPart, UpdateLog};
use super::{RepositoryError, Result};
fn sha1_hex(bytes: &[u8]) -> String {
use sha1::Digest as _;
let mut hasher = sha1::Sha1::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<()> {
let parent = path.parent().unwrap_or(Path::new("."));
fs::create_dir_all(parent)
.map_err(|e| RepositoryError::DirectoryCreateError { path: parent.to_path_buf(), source: e })?;
let tmp: PathBuf = path.with_extension("tmp");
{
let mut f = std::fs::File::create(&tmp)
.map_err(|e| RepositoryError::FileWriteError { path: tmp.clone(), source: e })?;
f.write_all(bytes)
.map_err(|e| RepositoryError::FileWriteError { path: tmp.clone(), source: e })?;
f.flush()
.map_err(|e| RepositoryError::FileWriteError { path: tmp.clone(), source: e })?;
}
fs::rename(&tmp, path)
.map_err(|e| RepositoryError::FileWriteError { path: path.to_path_buf(), source: e })?
;
Ok(())
}
#[instrument(level = "debug", skip(attrs))]
pub(crate) fn write_catalog_attrs(path: &Path, attrs: &mut CatalogAttrs) -> Result<String> {
// Compute signature over content without _SIGNATURE
attrs.signature = None;
let bytes_without_sig = serde_json::to_vec(&attrs)
.map_err(|e| RepositoryError::JsonSerializeError(format!("Catalog attrs serialize error: {}", e)))?;
let sig = sha1_hex(&bytes_without_sig);
let mut sig_map = std::collections::HashMap::new();
sig_map.insert("sha-1".to_string(), sig);
attrs.signature = Some(sig_map);
let final_bytes = serde_json::to_vec(&attrs)
.map_err(|e| RepositoryError::JsonSerializeError(format!("Catalog attrs serialize error: {}", e)))?;
debug!(path = %path.display(), bytes = final_bytes.len(), "writing catalog.attrs");
atomic_write_bytes(path, &final_bytes)?;
// safe to unwrap as signature was just inserted
Ok(attrs.signature.as_ref().and_then(|m| m.get("sha-1").cloned()).unwrap_or_default())
}
#[instrument(level = "debug", skip(part))]
pub(crate) fn write_catalog_part(path: &Path, part: &mut CatalogPart) -> Result<String> {
// Compute signature over content without _SIGNATURE
part.signature = None;
let bytes_without_sig = serde_json::to_vec(&part)
.map_err(|e| RepositoryError::JsonSerializeError(format!("Catalog part serialize error: {}", e)))?;
let sig = sha1_hex(&bytes_without_sig);
let mut sig_map = std::collections::HashMap::new();
sig_map.insert("sha-1".to_string(), sig);
part.signature = Some(sig_map);
let final_bytes = serde_json::to_vec(&part)
.map_err(|e| RepositoryError::JsonSerializeError(format!("Catalog part serialize error: {}", e)))?;
debug!(path = %path.display(), bytes = final_bytes.len(), "writing catalog part");
atomic_write_bytes(path, &final_bytes)?;
Ok(part.signature.as_ref().and_then(|m| m.get("sha-1").cloned()).unwrap_or_default())
}
#[instrument(level = "debug", skip(log))]
pub(crate) fn write_update_log(path: &Path, log: &mut UpdateLog) -> Result<String> {
// Compute signature over content without _SIGNATURE
log.signature = None;
let bytes_without_sig = serde_json::to_vec(&log)
.map_err(|e| RepositoryError::JsonSerializeError(format!("Update log serialize error: {}", e)))?;
let sig = sha1_hex(&bytes_without_sig);
let mut sig_map = std::collections::HashMap::new();
sig_map.insert("sha-1".to_string(), sig);
log.signature = Some(sig_map);
let final_bytes = serde_json::to_vec(&log)
.map_err(|e| RepositoryError::JsonSerializeError(format!("Update log serialize error: {}", e)))?;
debug!(path = %path.display(), bytes = final_bytes.len(), "writing update log");
atomic_write_bytes(path, &final_bytes)?;
Ok(log.signature.as_ref().and_then(|m| m.get("sha-1").cloned()).unwrap_or_default())
}

View file

@ -29,6 +29,7 @@ use super::{
PackageContents, PackageInfo, PublisherInfo, ReadableRepository, RepositoryConfig, PackageContents, PackageInfo, PublisherInfo, ReadableRepository, RepositoryConfig,
RepositoryInfo, RepositoryVersion, WritableRepository, REPOSITORY_CONFIG_FILENAME, RepositoryInfo, RepositoryVersion, WritableRepository, REPOSITORY_CONFIG_FILENAME,
}; };
use super::catalog_writer;
use ini::Ini; use ini::Ini;
// Define a struct to hold the content vectors for each package // Define a struct to hold the content vectors for each package
@ -1707,6 +1708,110 @@ impl WritableRepository for FileBackend {
} }
impl FileBackend { impl FileBackend {
/// Save catalog.attrs for a publisher using atomic write and SHA-1 signature
pub fn save_catalog_attrs(
&self,
publisher: &str,
attrs: &mut crate::repository::catalog::CatalogAttrs,
) -> Result<String> {
let catalog_dir = Self::construct_catalog_path(&self.path, publisher);
std::fs::create_dir_all(&catalog_dir)?;
let attrs_path = catalog_dir.join("catalog.attrs");
super::catalog_writer::write_catalog_attrs(&attrs_path, attrs)
}
/// Save a catalog part for a publisher using atomic write and SHA-1 signature
pub fn save_catalog_part(
&self,
publisher: &str,
part_name: &str,
part: &mut crate::repository::catalog::CatalogPart,
) -> Result<String> {
if part_name.contains('/') || part_name.contains('\\') {
return Err(RepositoryError::PathPrefixError(part_name.to_string()));
}
let catalog_dir = Self::construct_catalog_path(&self.path, publisher);
std::fs::create_dir_all(&catalog_dir)?;
let part_path = catalog_dir.join(part_name);
super::catalog_writer::write_catalog_part(&part_path, part)
}
/// Append a single update entry to the current update log file for a publisher and locale.
/// If no current log exists, creates one using current timestamp.
pub fn append_update(
&self,
publisher: &str,
locale: &str,
fmri: &crate::fmri::Fmri,
op_type: crate::repository::catalog::CatalogOperationType,
catalog_parts: std::collections::HashMap<String, std::collections::HashMap<String, Vec<String>>>,
signature_sha1: Option<String>,
) -> Result<()> {
let catalog_dir = Self::construct_catalog_path(&self.path, publisher);
std::fs::create_dir_all(&catalog_dir)?;
// Locate latest update file for locale
let mut latest: Option<PathBuf> = None;
if let Ok(read_dir) = std::fs::read_dir(&catalog_dir) {
for e in read_dir.flatten() {
let p = e.path();
if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
if name.starts_with("update.") && name.ends_with(&format!(".{}", locale)) {
if latest.as_ref().map(|lp| p > *lp).unwrap_or(true) {
latest = Some(p);
}
}
}
}
}
// If none, create a new filename using current timestamp in basic format
let update_path = match latest {
Some(p) => p,
None => {
let now = std::time::SystemTime::now();
let ts = format_iso8601_timestamp(&now); // e.g., 20090508T161025.686485Z
let stem = ts.split('.').next().unwrap_or(&ts); // take up to seconds
catalog_dir.join(format!("update.{}.{}", stem, locale))
}
};
// Load or create log
let mut log = if update_path.exists() {
crate::repository::catalog::UpdateLog::load(&update_path)?
} else {
crate::repository::catalog::UpdateLog::new()
};
// Append entry
log.add_update(publisher, fmri, op_type, catalog_parts, signature_sha1);
let _ = super::catalog_writer::write_update_log(&update_path, &mut log)?;
Ok(())
}
/// Rotate the update log file by creating a new empty file with the provided timestamp (basic format).
/// If `timestamp_basic` is None, the current time is used. Timestamp should match catalog v1 naming: YYYYMMDDThhmmssZ
pub fn rotate_update_file(
&self,
publisher: &str,
locale: &str,
timestamp_basic: Option<String>,
) -> Result<PathBuf> {
let catalog_dir = Self::construct_catalog_path(&self.path, publisher);
std::fs::create_dir_all(&catalog_dir)?;
let ts_basic = match timestamp_basic {
Some(s) => s,
None => {
let now = std::time::SystemTime::now();
let ts = format_iso8601_timestamp(&now);
ts.split('.').next().unwrap_or(&ts).to_string()
}
};
let path = catalog_dir.join(format!("update.{}.{}", ts_basic, locale));
let mut log = crate::repository::catalog::UpdateLog::new();
let _ = super::catalog_writer::write_update_log(&path, &mut log)?;
Ok(path)
}
pub fn fetch_manifest_text(&self, publisher: &str, fmri: &Fmri) -> Result<String> { pub fn fetch_manifest_text(&self, publisher: &str, fmri: &Fmri) -> Result<String> {
// Require a concrete version // Require a concrete version
let version = fmri.version(); let version = fmri.version();
@ -2254,13 +2359,6 @@ impl FileBackend {
}, },
); );
// Save the catalog.attrs file
let attrs_path = catalog_dir.join("catalog.attrs");
debug!("Writing catalog.attrs to: {}", attrs_path.display());
let attrs_json = serde_json::to_string_pretty(&attrs)?;
fs::write(&attrs_path, attrs_json)?;
debug!("Wrote catalog.attrs file");
// Create and save catalog parts // Create and save catalog parts
// Base part // Base part
@ -2270,8 +2368,7 @@ impl FileBackend {
for (fmri, actions, signature) in base_entries { for (fmri, actions, signature) in base_entries {
base_part.add_package(publisher, &fmri, actions, Some(signature)); base_part.add_package(publisher, &fmri, actions, Some(signature));
} }
let base_part_json = serde_json::to_string_pretty(&base_part)?; let base_sig = catalog_writer::write_catalog_part(&base_part_path, &mut base_part)?;
fs::write(&base_part_path, base_part_json)?;
debug!("Wrote base part file"); debug!("Wrote base part file");
// Dependency part // Dependency part
@ -2284,8 +2381,7 @@ impl FileBackend {
for (fmri, actions, signature) in dependency_entries { for (fmri, actions, signature) in dependency_entries {
dependency_part.add_package(publisher, &fmri, actions, Some(signature)); dependency_part.add_package(publisher, &fmri, actions, Some(signature));
} }
let dependency_part_json = serde_json::to_string_pretty(&dependency_part)?; let dependency_sig = catalog_writer::write_catalog_part(&dependency_part_path, &mut dependency_part)?;
fs::write(&dependency_part_path, dependency_part_json)?;
debug!("Wrote dependency part file"); debug!("Wrote dependency part file");
// Summary part // Summary part
@ -2295,10 +2391,26 @@ impl FileBackend {
for (fmri, actions, signature) in summary_entries { for (fmri, actions, signature) in summary_entries {
summary_part.add_package(publisher, &fmri, actions, Some(signature)); summary_part.add_package(publisher, &fmri, actions, Some(signature));
} }
let summary_part_json = serde_json::to_string_pretty(&summary_part)?; let summary_sig = catalog_writer::write_catalog_part(&summary_part_path, &mut summary_part)?;
fs::write(&summary_part_path, summary_part_json)?;
debug!("Wrote summary part file"); debug!("Wrote summary part file");
// Update part signatures in attrs (written after parts)
if let Some(info) = attrs.parts.get_mut(base_part_name) {
info.signature_sha1 = Some(base_sig);
}
if let Some(info) = attrs.parts.get_mut(dependency_part_name) {
info.signature_sha1 = Some(dependency_sig);
}
if let Some(info) = attrs.parts.get_mut(summary_part_name) {
info.signature_sha1 = Some(summary_sig);
}
// Save the catalog.attrs file (after parts so signatures are present)
let attrs_path = catalog_dir.join("catalog.attrs");
debug!("Writing catalog.attrs to: {}", attrs_path.display());
let _attrs_sig = catalog_writer::write_catalog_attrs(&attrs_path, &mut attrs)?;
debug!("Wrote catalog.attrs file");
// Create and save the update log if needed // Create and save the update log if needed
if create_update_log { if create_update_log {
debug!("Creating update log"); debug!("Creating update log");
@ -2318,8 +2430,7 @@ impl FileBackend {
); );
} }
let update_log_json = serde_json::to_string_pretty(&update_log)?; let _ = catalog_writer::write_update_log(&update_log_path, &mut update_log)?;
fs::write(&update_log_path, update_log_json)?;
debug!("Wrote update log file"); debug!("Wrote update log file");
// Add an update log to catalog.attrs // Add an update log to catalog.attrs
@ -2334,8 +2445,7 @@ impl FileBackend {
// Update the catalog.attrs file with the new update log // Update the catalog.attrs file with the new update log
debug!("Updating catalog.attrs file with new update log"); debug!("Updating catalog.attrs file with new update log");
let attrs_json = serde_json::to_string_pretty(&attrs)?; let _ = catalog_writer::write_catalog_attrs(&attrs_path, &mut attrs)?;
fs::write(catalog_dir.join("catalog.attrs"), attrs_json)?;
debug!("Updated catalog.attrs file"); debug!("Updated catalog.attrs file");
} }

View file

@ -217,6 +217,7 @@ impl From<bincode::error::EncodeError> for RepositoryError {
} }
pub mod catalog; pub mod catalog;
pub(crate) mod file_backend; pub(crate) mod file_backend;
mod catalog_writer;
mod obsoleted; mod obsoleted;
pub mod progress; pub mod progress;
mod rest_backend; mod rest_backend;