From bd67e0601258327a05e88a63eef3765a37196212 Mon Sep 17 00:00:00 2001 From: Till Wegmueller Date: Tue, 9 Dec 2025 12:49:25 +0100 Subject: [PATCH] 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. --- libips/src/repository/catalog.rs | 4 +- libips/src/repository/catalog_writer.rs | 95 ++++++++++++++++ libips/src/repository/file_backend.rs | 144 +++++++++++++++++++++--- libips/src/repository/mod.rs | 1 + 4 files changed, 225 insertions(+), 19 deletions(-) create mode 100644 libips/src/repository/catalog_writer.rs diff --git a/libips/src/repository/catalog.rs b/libips/src/repository/catalog.rs index 3acd211..e49ec1f 100644 --- a/libips/src/repository/catalog.rs +++ b/libips/src/repository/catalog.rs @@ -175,7 +175,7 @@ impl CatalogAttrs { /// Save catalog attributes to a file pub fn save>(&self, path: P) -> Result<()> { - let json = serde_json::to_string_pretty(self)?; + let json = serde_json::to_string(self)?; fs::write(path, json)?; Ok(()) } @@ -370,7 +370,7 @@ impl UpdateLog { /// Save update log to a file pub fn save>(&self, path: P) -> Result<()> { - let json = serde_json::to_string_pretty(self)?; + let json = serde_json::to_string(self)?; fs::write(path, json)?; Ok(()) } diff --git a/libips/src/repository/catalog_writer.rs b/libips/src/repository/catalog_writer.rs new file mode 100644 index 0000000..09338cd --- /dev/null +++ b/libips/src/repository/catalog_writer.rs @@ -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 { + // 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 { + // 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 { + // 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()) +} diff --git a/libips/src/repository/file_backend.rs b/libips/src/repository/file_backend.rs index 3387cd7..da130cc 100644 --- a/libips/src/repository/file_backend.rs +++ b/libips/src/repository/file_backend.rs @@ -29,6 +29,7 @@ use super::{ PackageContents, PackageInfo, PublisherInfo, ReadableRepository, RepositoryConfig, RepositoryInfo, RepositoryVersion, WritableRepository, REPOSITORY_CONFIG_FILENAME, }; +use super::catalog_writer; use ini::Ini; // Define a struct to hold the content vectors for each package @@ -1707,6 +1708,110 @@ impl WritableRepository for 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 { + 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 { + 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>>, + signature_sha1: Option, + ) -> 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 = 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, + ) -> Result { + 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 { // Require a concrete 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 // Base part @@ -2270,8 +2368,7 @@ impl FileBackend { for (fmri, actions, signature) in base_entries { base_part.add_package(publisher, &fmri, actions, Some(signature)); } - let base_part_json = serde_json::to_string_pretty(&base_part)?; - fs::write(&base_part_path, base_part_json)?; + let base_sig = catalog_writer::write_catalog_part(&base_part_path, &mut base_part)?; debug!("Wrote base part file"); // Dependency part @@ -2284,8 +2381,7 @@ impl FileBackend { for (fmri, actions, signature) in dependency_entries { dependency_part.add_package(publisher, &fmri, actions, Some(signature)); } - let dependency_part_json = serde_json::to_string_pretty(&dependency_part)?; - fs::write(&dependency_part_path, dependency_part_json)?; + let dependency_sig = catalog_writer::write_catalog_part(&dependency_part_path, &mut dependency_part)?; debug!("Wrote dependency part file"); // Summary part @@ -2295,10 +2391,26 @@ impl FileBackend { for (fmri, actions, signature) in summary_entries { summary_part.add_package(publisher, &fmri, actions, Some(signature)); } - let summary_part_json = serde_json::to_string_pretty(&summary_part)?; - fs::write(&summary_part_path, summary_part_json)?; + let summary_sig = catalog_writer::write_catalog_part(&summary_part_path, &mut summary_part)?; 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 if create_update_log { debug!("Creating update log"); @@ -2318,8 +2430,7 @@ impl FileBackend { ); } - let update_log_json = serde_json::to_string_pretty(&update_log)?; - fs::write(&update_log_path, update_log_json)?; + let _ = catalog_writer::write_update_log(&update_log_path, &mut update_log)?; debug!("Wrote update log file"); // Add an update log to catalog.attrs @@ -2334,8 +2445,7 @@ impl FileBackend { // Update the catalog.attrs file with the new update log debug!("Updating catalog.attrs file with new update log"); - let attrs_json = serde_json::to_string_pretty(&attrs)?; - fs::write(catalog_dir.join("catalog.attrs"), attrs_json)?; + let _ = catalog_writer::write_catalog_attrs(&attrs_path, &mut attrs)?; debug!("Updated catalog.attrs file"); } diff --git a/libips/src/repository/mod.rs b/libips/src/repository/mod.rs index d0c4d72..9f1d294 100644 --- a/libips/src/repository/mod.rs +++ b/libips/src/repository/mod.rs @@ -217,6 +217,7 @@ impl From for RepositoryError { } pub mod catalog; pub(crate) mod file_backend; +mod catalog_writer; mod obsoleted; pub mod progress; mod rest_backend;