mirror of
https://codeberg.org/Toasterson/ips.git
synced 2026-04-10 13:20:42 +00:00
- Adjusted indentation and line breaks for structs, functions, and method calls to comply with Rust formatting standards. - Improved error message formatting and consistency across `PkgTreeError` instances. - Restructured long function arguments and chained calls for clarity and maintainability. - Simplified conditional statements and loops for better readability. - No functional changes introduced.
442 lines
12 KiB
Rust
442 lines
12 KiB
Rust
use std::fs::{self, File as FsFile};
|
|
use std::io::{self, Write};
|
|
use std::os::unix::fs as unix_fs;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::{Component, Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use miette::Diagnostic;
|
|
use thiserror::Error;
|
|
use tracing::info;
|
|
|
|
use crate::actions::{Dir as DirAction, File as FileAction};
|
|
use crate::actions::{Link as LinkAction, Manifest};
|
|
|
|
#[derive(Error, Debug, Diagnostic)]
|
|
pub enum InstallerError {
|
|
#[error("I/O error while operating on {path}")]
|
|
#[diagnostic(code(ips::installer_error::io))]
|
|
Io {
|
|
#[source]
|
|
source: io::Error,
|
|
path: PathBuf,
|
|
},
|
|
|
|
#[error("Absolute paths are forbidden in actions: {path}")]
|
|
#[diagnostic(
|
|
code(ips::installer_error::absolute_path_forbidden),
|
|
help("Provide paths relative to the image root")
|
|
)]
|
|
AbsolutePathForbidden { path: String },
|
|
|
|
#[error("Path escapes image root via traversal: {rel}")]
|
|
#[diagnostic(
|
|
code(ips::installer_error::path_outside_image),
|
|
help("Remove '..' components that escape the image root")
|
|
)]
|
|
PathTraversalOutsideImage { rel: String },
|
|
|
|
#[error("Unsupported or not yet implemented action: {action} ({reason})")]
|
|
#[diagnostic(code(ips::installer_error::unsupported_action))]
|
|
UnsupportedAction {
|
|
action: &'static str,
|
|
reason: String,
|
|
},
|
|
}
|
|
|
|
fn parse_mode(mode: &str, default: u32) -> u32 {
|
|
if mode.is_empty() || mode.eq("0") {
|
|
return default;
|
|
}
|
|
// Accept strings like "0755" or "755"
|
|
let trimmed = mode.trim_start_matches('0');
|
|
u32::from_str_radix(if trimmed.is_empty() { "0" } else { trimmed }, 8).unwrap_or(default)
|
|
}
|
|
|
|
/// Join a manifest-provided path (must be relative) under image_root.
|
|
/// - Rejects absolute paths
|
|
/// - Rejects traversal that would escape the image root
|
|
pub fn safe_join(image_root: &Path, rel: &str) -> Result<PathBuf, InstallerError> {
|
|
if rel.is_empty() {
|
|
return Ok(image_root.to_path_buf());
|
|
}
|
|
let rel_path = Path::new(rel);
|
|
if rel_path.is_absolute() {
|
|
return Err(InstallerError::AbsolutePathForbidden {
|
|
path: rel.to_string(),
|
|
});
|
|
}
|
|
|
|
let mut stack: Vec<PathBuf> = Vec::new();
|
|
for c in rel_path.components() {
|
|
match c {
|
|
Component::CurDir => {}
|
|
Component::Normal(seg) => stack.push(PathBuf::from(seg)),
|
|
Component::ParentDir => {
|
|
if stack.pop().is_none() {
|
|
return Err(InstallerError::PathTraversalOutsideImage {
|
|
rel: rel.to_string(),
|
|
});
|
|
}
|
|
}
|
|
// Prefixes shouldn't appear on Unix; treat conservatively
|
|
Component::Prefix(_) | Component::RootDir => {
|
|
return Err(InstallerError::AbsolutePathForbidden {
|
|
path: rel.to_string(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut out = PathBuf::from(image_root);
|
|
for seg in stack {
|
|
out.push(seg);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug)]
|
|
pub enum ActionOrder {
|
|
Dir = 0,
|
|
File = 1,
|
|
Link = 2,
|
|
Other = 3,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct ApplyOptions {
|
|
pub dry_run: bool,
|
|
/// Optional progress callback. If set, library will emit coarse-grained progress events.
|
|
pub progress: Option<ProgressCallback>,
|
|
/// Emit numeric progress every N items per phase. 0 disables periodic progress.
|
|
pub progress_interval: usize,
|
|
}
|
|
|
|
impl std::fmt::Debug for ApplyOptions {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("ApplyOptions")
|
|
.field("dry_run", &self.dry_run)
|
|
.field(
|
|
"progress",
|
|
&self.progress.as_ref().map(|_| "Some(callback)"),
|
|
)
|
|
.field("progress_interval", &self.progress_interval)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl Default for ApplyOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
dry_run: false,
|
|
progress: None,
|
|
progress_interval: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Progress event emitted by apply_manifest when a callback is provided.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum ProgressEvent {
|
|
StartingPhase {
|
|
phase: &'static str,
|
|
total: usize,
|
|
},
|
|
Progress {
|
|
phase: &'static str,
|
|
current: usize,
|
|
total: usize,
|
|
},
|
|
FinishedPhase {
|
|
phase: &'static str,
|
|
total: usize,
|
|
},
|
|
}
|
|
|
|
pub type ProgressCallback = Arc<dyn Fn(ProgressEvent) + Send + Sync + 'static>;
|
|
|
|
/// Apply a manifest to the filesystem rooted at image_root.
|
|
/// This function enforces ordering: directories, then files, then links, then others (no-ops for now).
|
|
pub fn apply_manifest(
|
|
image_root: &Path,
|
|
manifest: &Manifest,
|
|
opts: &ApplyOptions,
|
|
) -> Result<(), InstallerError> {
|
|
let emit = |evt: ProgressEvent, cb: &Option<ProgressCallback>| {
|
|
if let Some(cb) = cb.as_ref() {
|
|
(cb)(evt);
|
|
}
|
|
};
|
|
|
|
// Directories first
|
|
let total_dirs = manifest.directories.len();
|
|
if total_dirs > 0 {
|
|
emit(
|
|
ProgressEvent::StartingPhase {
|
|
phase: "directories",
|
|
total: total_dirs,
|
|
},
|
|
&opts.progress,
|
|
);
|
|
}
|
|
let mut i = 0usize;
|
|
for d in &manifest.directories {
|
|
apply_dir(image_root, d, opts)?;
|
|
i += 1;
|
|
if opts.progress_interval > 0 && (i % opts.progress_interval == 0 || i == total_dirs) {
|
|
emit(
|
|
ProgressEvent::Progress {
|
|
phase: "directories",
|
|
current: i,
|
|
total: total_dirs,
|
|
},
|
|
&opts.progress,
|
|
);
|
|
}
|
|
}
|
|
if total_dirs > 0 {
|
|
emit(
|
|
ProgressEvent::FinishedPhase {
|
|
phase: "directories",
|
|
total: total_dirs,
|
|
},
|
|
&opts.progress,
|
|
);
|
|
}
|
|
|
|
// Files next
|
|
let total_files = manifest.files.len();
|
|
if total_files > 0 {
|
|
emit(
|
|
ProgressEvent::StartingPhase {
|
|
phase: "files",
|
|
total: total_files,
|
|
},
|
|
&opts.progress,
|
|
);
|
|
}
|
|
i = 0;
|
|
for f_action in &manifest.files {
|
|
apply_file(image_root, f_action, opts)?;
|
|
i += 1;
|
|
if opts.progress_interval > 0 && (i % opts.progress_interval == 0 || i == total_files) {
|
|
emit(
|
|
ProgressEvent::Progress {
|
|
phase: "files",
|
|
current: i,
|
|
total: total_files,
|
|
},
|
|
&opts.progress,
|
|
);
|
|
}
|
|
}
|
|
if total_files > 0 {
|
|
emit(
|
|
ProgressEvent::FinishedPhase {
|
|
phase: "files",
|
|
total: total_files,
|
|
},
|
|
&opts.progress,
|
|
);
|
|
}
|
|
|
|
// Links
|
|
let total_links = manifest.links.len();
|
|
if total_links > 0 {
|
|
emit(
|
|
ProgressEvent::StartingPhase {
|
|
phase: "links",
|
|
total: total_links,
|
|
},
|
|
&opts.progress,
|
|
);
|
|
}
|
|
i = 0;
|
|
for l in &manifest.links {
|
|
apply_link(image_root, l, opts)?;
|
|
i += 1;
|
|
if opts.progress_interval > 0 && (i % opts.progress_interval == 0 || i == total_links) {
|
|
emit(
|
|
ProgressEvent::Progress {
|
|
phase: "links",
|
|
current: i,
|
|
total: total_links,
|
|
},
|
|
&opts.progress,
|
|
);
|
|
}
|
|
}
|
|
if total_links > 0 {
|
|
emit(
|
|
ProgressEvent::FinishedPhase {
|
|
phase: "links",
|
|
total: total_links,
|
|
},
|
|
&opts.progress,
|
|
);
|
|
}
|
|
|
|
// Other action kinds are ignored for now and left for future extension.
|
|
Ok(())
|
|
}
|
|
|
|
fn apply_dir(image_root: &Path, d: &DirAction, opts: &ApplyOptions) -> Result<(), InstallerError> {
|
|
let full = safe_join(image_root, &d.path)?;
|
|
info!(?full, "creating directory");
|
|
if opts.dry_run {
|
|
return Ok(());
|
|
}
|
|
|
|
fs::create_dir_all(&full).map_err(|e| InstallerError::Io {
|
|
source: e,
|
|
path: full.clone(),
|
|
})?;
|
|
|
|
// Set permissions if provided
|
|
let mode = parse_mode(&d.mode, 0o755);
|
|
let perm = fs::Permissions::from_mode(mode);
|
|
fs::set_permissions(&full, perm).map_err(|e| InstallerError::Io {
|
|
source: e,
|
|
path: full.clone(),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_parent(image_root: &Path, p: &str, opts: &ApplyOptions) -> Result<(), InstallerError> {
|
|
let full = safe_join(image_root, p)?;
|
|
if let Some(parent) = full.parent() {
|
|
if opts.dry_run {
|
|
return Ok(());
|
|
}
|
|
fs::create_dir_all(parent).map_err(|e| InstallerError::Io {
|
|
source: e,
|
|
path: parent.to_path_buf(),
|
|
})?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn apply_file(
|
|
image_root: &Path,
|
|
f: &FileAction,
|
|
opts: &ApplyOptions,
|
|
) -> Result<(), InstallerError> {
|
|
let full = safe_join(image_root, &f.path)?;
|
|
|
|
// Ensure parent exists (directories should already be applied, but be robust)
|
|
ensure_parent(image_root, &f.path, opts)?;
|
|
|
|
info!(?full, "creating file (payload handling TBD)");
|
|
if opts.dry_run {
|
|
return Ok(());
|
|
}
|
|
|
|
// For now, write empty content as a scaffold. Payload fetching/integration will follow later.
|
|
let mut file = FsFile::create(&full).map_err(|e| InstallerError::Io {
|
|
source: e,
|
|
path: full.clone(),
|
|
})?;
|
|
file.write_all(&[]).map_err(|e| InstallerError::Io {
|
|
source: e,
|
|
path: full.clone(),
|
|
})?;
|
|
|
|
// Set permissions if provided
|
|
let mode = parse_mode(&f.mode, 0o644);
|
|
let perm = fs::Permissions::from_mode(mode);
|
|
fs::set_permissions(&full, perm).map_err(|e| InstallerError::Io {
|
|
source: e,
|
|
path: full.clone(),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn apply_link(
|
|
image_root: &Path,
|
|
l: &LinkAction,
|
|
opts: &ApplyOptions,
|
|
) -> Result<(), InstallerError> {
|
|
let link_path = safe_join(image_root, &l.path)?;
|
|
|
|
// Determine link type (default to symlink). If properties contain type=hard, create hard link.
|
|
let mut is_hard = false;
|
|
if let Some(prop) = l.properties.get("type") {
|
|
let v = prop.value.to_ascii_lowercase();
|
|
if v == "hard" || v == "hardlink" {
|
|
is_hard = true;
|
|
}
|
|
}
|
|
|
|
// Target may be relative; keep it as-is for symlink. For hard links, target must resolve under image_root.
|
|
if opts.dry_run {
|
|
return Ok(());
|
|
}
|
|
|
|
if is_hard {
|
|
// Hard link needs a resolved, safe target within the image.
|
|
let target_full = safe_join(image_root, &l.target)?;
|
|
fs::hard_link(&target_full, &link_path).map_err(|e| InstallerError::Io {
|
|
source: e,
|
|
path: link_path.clone(),
|
|
})?;
|
|
} else {
|
|
// Symlink: require non-absolute target to avoid embedding full host paths
|
|
if Path::new(&l.target).is_absolute() {
|
|
return Err(InstallerError::AbsolutePathForbidden {
|
|
path: l.target.clone(),
|
|
});
|
|
}
|
|
// Create relative symlink as provided (do not convert to absolute to avoid embedding full paths)
|
|
#[cfg(target_family = "unix")]
|
|
{
|
|
unix_fs::symlink(&l.target, &link_path).map_err(|e| InstallerError::Io {
|
|
source: e,
|
|
path: link_path.clone(),
|
|
})?;
|
|
}
|
|
#[cfg(not(target_family = "unix"))]
|
|
{
|
|
return Err(InstallerError::UnsupportedAction {
|
|
action: "link",
|
|
reason: "symlink not supported on this platform".to_string(),
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn safe_join_rejects_absolute() {
|
|
let root = Path::new("/tmp/image");
|
|
let err = safe_join(root, "/etc/passwd").unwrap_err();
|
|
match err {
|
|
InstallerError::AbsolutePathForbidden { .. } => {}
|
|
_ => panic!("expected AbsolutePathForbidden"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn safe_join_rejects_escape() {
|
|
let root = Path::new("/tmp/image");
|
|
let err = safe_join(root, "../../etc").unwrap_err();
|
|
match err {
|
|
InstallerError::PathTraversalOutsideImage { .. } => {}
|
|
_ => panic!("expected PathTraversalOutsideImage"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn safe_join_ok() {
|
|
let root = Path::new("/tmp/image");
|
|
let p = safe_join(root, "etc/pkg").unwrap();
|
|
assert!(p.starts_with(root));
|
|
assert!(p.ends_with("pkg"));
|
|
}
|
|
}
|