diff --git a/src/build_options.rs b/src/build_options.rs index f02d193..d0415b0 100644 --- a/src/build_options.rs +++ b/src/build_options.rs @@ -54,7 +54,7 @@ pub(crate) fn build_tool_package_option(build_type: BuildType) -> Option<&'stati BuildType::Python => Some("DEPOT_PYTHON_PACKAGE"), BuildType::Rust => Some("DEPOT_RUST_PACKAGE"), BuildType::Makefile => Some("DEPOT_MAKEFILE_PACKAGE"), - BuildType::Bin | BuildType::Meta => None, + BuildType::Dkms | BuildType::Bin | BuildType::Meta => None, } } @@ -68,7 +68,7 @@ pub(crate) fn requested_build_tool_package(build_type: BuildType) -> Option normalize_string_option(option_env!("DEPOT_PYTHON_PACKAGE")), BuildType::Rust => normalize_string_option(option_env!("DEPOT_RUST_PACKAGE")), BuildType::Makefile => normalize_string_option(option_env!("DEPOT_MAKEFILE_PACKAGE")), - BuildType::Bin | BuildType::Meta => None, + BuildType::Dkms | BuildType::Bin | BuildType::Meta => None, } } diff --git a/src/builder/dkms.rs b/src/builder/dkms.rs new file mode 100644 index 0000000..6ff9dd3 --- /dev/null +++ b/src/builder/dkms.rs @@ -0,0 +1,378 @@ +//! Depot-managed kernel module source packaging. + +use crate::cross::CrossConfig; +use crate::install::scripts; +use crate::package::{DkmsModule, PackageSpec}; +use anyhow::{Context, Result}; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +const DEPOT_KMOD_MANIFEST: &str = ".depot-kmod.toml"; + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub(crate) struct DepotKmodManifest { + pub name: String, + pub version: String, + pub install_dir: String, + pub make_args: Vec, + pub pre_build: Vec, + pub modules: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub(crate) struct DepotKmodModule { + pub name: String, + pub dest_name: String, + pub build_dir: String, + pub built_location: String, + pub install_dir: String, +} + +pub fn build( + spec: &PackageSpec, + src_dir: &Path, + destdir: &Path, + _cross: Option<&CrossConfig>, + _export_compiler_flags: bool, + _host_build_dir: Option<&Path>, +) -> Result<()> { + if spec.build.flags.lib32_variant { + anyhow::bail!("build.type = \"dkms\" does not support lib32 variants"); + } + + fs::create_dir_all(destdir) + .with_context(|| format!("Failed to create DESTDIR: {}", destdir.display()))?; + + let source_root = resolve_dkms_source_root(spec, src_dir)?; + let source_dest = staged_source_dir(spec, destdir); + if source_dest.exists() { + fs::remove_dir_all(&source_dest).with_context(|| { + format!( + "Failed to remove existing DKMS source staging dir: {}", + source_dest.display() + ) + })?; + } + crate::fs_copy::copy_tree_preserving_links(&source_root, &source_dest).with_context(|| { + format!( + "Failed to stage DKMS source tree from {} to {}", + source_root.display(), + source_dest.display() + ) + })?; + + let manifest = manifest_from_spec(spec)?; + let manifest_toml = + toml::to_string_pretty(&manifest).context("Failed to serialize DKMS metadata")?; + fs::write(source_dest.join(DEPOT_KMOD_MANIFEST), manifest_toml).with_context(|| { + format!( + "Failed to write DKMS metadata: {}", + source_dest.join(DEPOT_KMOD_MANIFEST).display() + ) + })?; + + Ok(()) +} + +pub(crate) fn stage_lifecycle_scripts(spec: &PackageSpec, destdir: &Path) -> Result<()> { + if !spec.build.flags.dkms_autoinstall { + return Ok(()); + } + + let scripts_dir = scripts::staged_scripts_dir(destdir); + fs::create_dir_all(&scripts_dir) + .with_context(|| format!("Failed to create scripts dir: {}", scripts_dir.display()))?; + + let source_rel = format!("/usr/src/{}", source_dir_name(spec)); + let package_name = spec.effective_dkms_name(); + stage_script_command( + &scripts_dir.join("post_install"), + &autoinstall_command(&source_rel), + )?; + stage_script_command( + &scripts_dir.join("post_update"), + &autoinstall_command(&source_rel), + )?; + stage_script_command( + &scripts_dir.join("pre_remove"), + &remove_command(&package_name), + )?; + stage_script_command( + &scripts_dir.join("pre_update"), + &remove_command(&package_name), + )?; + Ok(()) +} + +pub(crate) fn manifest_from_spec(spec: &PackageSpec) -> Result { + let default_install_dir = spec.effective_dkms_install_dir(); + let modules = spec + .build + .flags + .dkms_modules + .iter() + .map(|module| manifest_module_from_spec(spec, module, &default_install_dir)) + .collect::>>()?; + + let mut build_dirs = BTreeSet::new(); + for module in &modules { + build_dirs.insert(module.build_dir.clone()); + } + if build_dirs.is_empty() { + anyhow::bail!("DKMS manifest must contain at least one module"); + } + + Ok(DepotKmodManifest { + name: spec.effective_dkms_name(), + version: spec.effective_dkms_version(), + install_dir: default_install_dir, + make_args: spec + .build + .flags + .dkms_make_args + .iter() + .map(|arg| spec.expand_vars(arg)) + .collect(), + pre_build: spec + .build + .flags + .dkms_pre_build + .iter() + .map(|arg| spec.expand_vars(arg)) + .collect(), + modules, + }) +} + +fn manifest_module_from_spec( + spec: &PackageSpec, + module: &DkmsModule, + default_install_dir: &str, +) -> Result { + let name = spec.expand_vars(module.name.trim()); + let dest_name = spec.effective_dkms_module_dest_name(module); + let build_dir = normalized_rel_string(&spec.expand_vars(module.build_dir.trim()), true)?; + let built_location = if module.built_location.trim().is_empty() { + build_dir.clone() + } else { + normalized_rel_string(&spec.expand_vars(module.built_location.trim()), true)? + }; + let install_dir = if module.install_dir.trim().is_empty() { + default_install_dir.to_string() + } else { + spec.effective_dkms_module_install_dir(module) + }; + + Ok(DepotKmodModule { + name, + dest_name, + build_dir, + built_location, + install_dir, + }) +} + +fn resolve_dkms_source_root(spec: &PackageSpec, src_dir: &Path) -> Result { + let base = if spec.build.flags.source_subdir.trim().is_empty() { + src_dir.to_path_buf() + } else { + let subdir = + normalized_rel_string(&spec.expand_vars(&spec.build.flags.source_subdir), false)?; + src_dir.join(subdir) + }; + let source_dir = spec.expand_vars(spec.build.flags.dkms_source_dir.trim()); + let source_root = if source_dir.trim().is_empty() { + base + } else { + base.join(normalized_rel_string(&source_dir, false)?) + }; + if !source_root.is_dir() { + anyhow::bail!("DKMS source directory not found: {}", source_root.display()); + } + Ok(source_root) +} + +fn staged_source_dir(spec: &PackageSpec, destdir: &Path) -> PathBuf { + destdir.join("usr/src").join(source_dir_name(spec)) +} + +fn source_dir_name(spec: &PackageSpec) -> String { + format!( + "{}-{}", + spec.effective_dkms_name(), + spec.effective_dkms_version() + ) +} + +fn autoinstall_command(source_rel: &str) -> String { + format!( + "depot internal dkms-autoinstall --rootfs \"$DEPOT_ROOTFS\" --source {}", + sh_quote(source_rel) + ) +} + +fn remove_command(name: &str) -> String { + format!( + "depot internal dkms-remove --rootfs \"$DEPOT_ROOTFS\" --name {}", + sh_quote(name) + ) +} + +fn stage_script_command(path: &Path, command: &str) -> Result<()> { + if path.exists() { + use std::io::Write; + let mut file = fs::OpenOptions::new() + .append(true) + .open(path) + .with_context(|| format!("Failed to open lifecycle script: {}", path.display()))?; + writeln!(file).with_context(|| format!("Failed to update {}", path.display()))?; + writeln!(file, "# Depot DKMS autoinstall").with_context(|| { + format!( + "Failed to append lifecycle script marker: {}", + path.display() + ) + })?; + writeln!(file, "{command}") + .with_context(|| format!("Failed to append lifecycle script: {}", path.display()))?; + } else { + fs::write(path, format!("#!/bin/sh\nset -eu\n{command}\n")) + .with_context(|| format!("Failed to write lifecycle script: {}", path.display()))?; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms) + .with_context(|| format!("Failed to make script executable: {}", path.display()))?; + } + Ok(()) +} + +fn normalized_rel_string(raw: &str, allow_empty: bool) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + if allow_empty { + return Ok(".".to_string()); + } + anyhow::bail!("DKMS path cannot be empty"); + } + let path = Path::new(trimmed); + if path.is_absolute() { + anyhow::bail!("DKMS path must be relative: {}", trimmed); + } + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(part) => out.push(part), + Component::CurDir => {} + _ => anyhow::bail!("DKMS path contains unsafe component: {}", trimmed), + } + } + if out.as_os_str().is_empty() { + Ok(".".to_string()) + } else { + Ok(out.to_string_lossy().into_owned()) + } +} + +fn sh_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::package::{ + Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, Source, + }; + use tempfile::tempdir; + + fn mk_spec() -> PackageSpec { + let flags = BuildFlags { + dkms_modules: vec![ + DkmsModule { + name: "zfs".into(), + build_dir: "module".into(), + built_location: "module/zfs".into(), + ..DkmsModule::default() + }, + DkmsModule { + name: "spl".into(), + dest_name: "spl_compat".into(), + build_dir: "module".into(), + built_location: "module/spl".into(), + install_dir: "updates/storage".into(), + }, + ], + dkms_make_args: vec!["V=1".into()], + dkms_pre_build: vec!["./configure --with-linux=$kernel_build_dir".into()], + ..BuildFlags::default() + }; + PackageSpec { + package: PackageInfo { + name: "zfs-dkms".into(), + real_name: None, + version: "2.4.3".into(), + revision: 1, + description: "d".into(), + homepage: "h".into(), + abi_breaking: false, + built_against: Vec::new(), + license: vec!["MIT".into()], + }, + packages: Vec::new(), + alternatives: Alternatives::default(), + manual_sources: Vec::new(), + source: vec![Source { + url: "https://example.test/zfs.tar.gz".into(), + sha256: "skip".into(), + extract_dir: "zfs".into(), + patches: Vec::new(), + post_extract: Vec::new(), + cherry_pick: Vec::new(), + }], + build: Build { + build_type: BuildType::Dkms, + flags, + }, + dependencies: Dependencies::default(), + package_alternatives: Default::default(), + package_dependencies: Default::default(), + spec_dir: PathBuf::from("."), + } + } + + #[test] + fn dkms_build_stages_source_metadata_and_scripts() -> Result<()> { + let tmp = tempdir()?; + let src = tmp.path().join("src"); + let dest = tmp.path().join("dest"); + fs::create_dir_all(src.join("module/zfs"))?; + fs::write(src.join("module/zfs/zfs.c"), "source")?; + + let spec = mk_spec(); + build(&spec, &src, &dest, None, true, None)?; + fs::create_dir_all(dest.join("scripts"))?; + fs::write( + dest.join("scripts/post_install"), + "#!/bin/sh\necho package\n", + )?; + stage_lifecycle_scripts(&spec, &dest)?; + + let staged = dest.join("usr/src/zfs-dkms-2.4.3"); + assert!(staged.join("module/zfs/zfs.c").exists()); + let manifest: DepotKmodManifest = + toml::from_str(&fs::read_to_string(staged.join(DEPOT_KMOD_MANIFEST))?)?; + assert_eq!(manifest.name, "zfs-dkms"); + assert_eq!(manifest.pre_build.len(), 1); + assert_eq!(manifest.modules.len(), 2); + assert_eq!(manifest.modules[1].dest_name, "spl_compat"); + let post_install = fs::read_to_string(dest.join("scripts/post_install"))?; + assert!(post_install.contains("echo package")); + assert!(post_install.contains("dkms-autoinstall")); + assert!(dest.join("scripts/pre_update").exists()); + Ok(()) + } +} diff --git a/src/builder/mod.rs b/src/builder/mod.rs index cad7af8..a7f4099 100755 --- a/src/builder/mod.rs +++ b/src/builder/mod.rs @@ -4,6 +4,7 @@ mod autotools; mod bin; mod cmake; mod custom; +mod dkms; mod makefile; mod meson; mod perl; @@ -489,6 +490,13 @@ pub(crate) fn requested_development_package() -> Option { crate::build_options::requested_development_package() } +pub(crate) fn stage_generated_lifecycle_scripts(spec: &PackageSpec, destdir: &Path) -> Result<()> { + match spec.build.build_type { + BuildType::Dkms => dkms::stage_lifecycle_scripts(spec, destdir), + _ => Ok(()), + } +} + fn configured_install_dir(value: &str, default: &str) -> String { let trimmed = value.trim(); if trimmed.is_empty() { @@ -1100,6 +1108,14 @@ pub fn build( export_compiler_flags, host_build_dir, ), + BuildType::Dkms => dkms::build( + spec, + src_dir, + destdir, + cross, + export_compiler_flags, + host_build_dir, + ), BuildType::Bin => bin::build( spec, src_dir, diff --git a/src/cli.rs b/src/cli.rs index 6d371da..04d5eb9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -384,6 +384,22 @@ pub enum InternalCommands { #[arg(value_name = "ARG", num_args = 0.., allow_hyphen_values = true)] args: Vec, }, + #[command(hide = true)] + DkmsAutoinstall { + #[arg(long, default_value = "/")] + rootfs: PathBuf, + #[arg(long)] + source: PathBuf, + }, + #[command(hide = true)] + DkmsRemove { + #[arg(long, default_value = "/")] + rootfs: PathBuf, + #[arg(long)] + source: Option, + #[arg(long)] + name: Option, + }, } #[derive(Debug, Clone, Args)] diff --git a/src/commands.rs b/src/commands.rs index 929960a..29b2f31 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -2501,6 +2501,7 @@ fn run_direct_install_request( // 3.1 Copy license files into staged tree staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?; install::scripts::stage_scripts_from_spec_dir(&pkg_spec, &destdir)?; + builder::stage_generated_lifecycle_scripts(&pkg_spec, &destdir)?; } destdir diff --git a/src/commands/build_cmd.rs b/src/commands/build_cmd.rs index 16a342f..83b5c9f 100644 --- a/src/commands/build_cmd.rs +++ b/src/commands/build_cmd.rs @@ -365,6 +365,7 @@ pub(super) fn run_build(args: BuildArgs, cli_test_deps: bool) -> Result<()> { if !lib32_only { staging::add_licenses(&src_dir, &destdir, &pkg_spec.package.name)?; install::scripts::stage_scripts_from_spec_dir(&pkg_spec, &destdir)?; + builder::stage_generated_lifecycle_scripts(&pkg_spec, &destdir)?; staging::process(&destdir, &pkg_spec)?; staging::stage_split_package_licenses(&src_dir, &destdir, &pkg_spec)?; if let Some(watcher) = interrupt_watcher.as_ref() { diff --git a/src/commands/build_cmd/support.rs b/src/commands/build_cmd/support.rs index 5480724..d101976 100644 --- a/src/commands/build_cmd/support.rs +++ b/src/commands/build_cmd/support.rs @@ -354,6 +354,7 @@ pub(crate) fn build_lib32_companion_package( ); } install::scripts::stage_scripts_from_spec_dir(&lib32_pkg_spec, &lib32_destdir)?; + builder::stage_generated_lifecycle_scripts(&lib32_pkg_spec, &lib32_destdir)?; staging::process(&lib32_destdir, &lib32_pkg_spec)?; staging::symlink_package_license( &lib32_destdir, diff --git a/src/commands/misc/internal.rs b/src/commands/misc/internal.rs index f879459..a4bf5f0 100644 --- a/src/commands/misc/internal.rs +++ b/src/commands/misc/internal.rs @@ -233,6 +233,19 @@ pub(crate) fn run_internal_command(command: InternalCommands) -> Result<()> { &args, ) } + InternalCommands::DkmsAutoinstall { rootfs, source } => { + crate::install::dkms::autoinstall(&rootfs, &source) + } + InternalCommands::DkmsRemove { + rootfs, + source, + name, + } => { + if source.is_none() && name.as_deref().is_none_or(|value| value.trim().is_empty()) { + anyhow::bail!("internal dkms-remove requires --source or --name"); + } + crate::install::dkms::remove(&rootfs, source.as_deref(), name.as_deref()) + } } } diff --git a/src/install/dkms.rs b/src/install/dkms.rs new file mode 100644 index 0000000..7869ff1 --- /dev/null +++ b/src/install/dkms.rs @@ -0,0 +1,547 @@ +//! Runtime support for Depot-managed kernel module source packages. + +use anyhow::{Context, Result}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; + +const DEPOT_KMOD_MANIFEST: &str = ".depot-kmod.toml"; +const TRACKING_DIR_REL: &str = "var/lib/depot/kmods"; + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +struct DepotKmodManifest { + name: String, + version: String, + #[serde(default)] + install_dir: String, + #[serde(default)] + make_args: Vec, + #[serde(default)] + pre_build: Vec, + modules: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +struct DepotKmodModule { + name: String, + dest_name: String, + build_dir: String, + built_location: String, + install_dir: String, +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +struct InstalledKmodManifest { + name: String, + version: String, + entries: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +struct InstalledKmodEntry { + kernel: String, + module: String, + path: String, +} + +#[derive(Debug, Clone)] +struct KernelTarget { + version: String, + build_dir: PathBuf, +} + +/// Build and install a Depot-managed kernel module source tree for all kernels. +pub(crate) fn autoinstall(rootfs: &Path, source: &Path) -> Result<()> { + let source_dir = resolve_rootfs_path(rootfs, source); + let manifest = read_source_manifest(&source_dir)?; + if manifest.modules.is_empty() { + anyhow::bail!( + "DKMS source manifest contains no modules: {}", + source_dir.display() + ); + } + + let kernels = discover_kernels(rootfs)?; + if kernels.is_empty() { + crate::log_warn!( + "Skipping Depot DKMS autoinstall for {}/{}: no kernels under {}", + manifest.name, + manifest.version, + rootfs.join("lib/modules").display() + ); + return Ok(()); + } + + let buildable: Vec<_> = kernels + .into_iter() + .filter_map(|kernel| { + if kernel.build_dir.exists() { + Some(kernel) + } else { + crate::log_warn!( + "Skipping kernel {} for {}/{}: missing build directory {}", + kernel.version, + manifest.name, + manifest.version, + kernel.build_dir.display() + ); + None + } + }) + .collect(); + if buildable.is_empty() { + anyhow::bail!( + "No buildable kernels found for {}/{} under {}", + manifest.name, + manifest.version, + rootfs.join("lib/modules").display() + ); + } + + let mut installed = Vec::new(); + for kernel in &buildable { + build_for_kernel(rootfs, &source_dir, &manifest, kernel)?; + installed.extend(install_for_kernel(rootfs, &source_dir, &manifest, kernel)?); + run_depmod(rootfs, &kernel.version)?; + } + + write_tracking_manifest(rootfs, &manifest, installed)?; + Ok(()) +} + +/// Remove Depot-installed kernel modules by source tree or package name. +pub(crate) fn remove(rootfs: &Path, source: Option<&Path>, name: Option<&str>) -> Result<()> { + let selected = selected_tracking_manifests(rootfs, source, name)?; + if selected.is_empty() { + if let Some(name) = name { + crate::log_warn!("No installed Depot DKMS modules found for {}", name); + } + return Ok(()); + } + + let mut depmod_kernels = BTreeSet::new(); + for (manifest_path, manifest) in selected { + for entry in &manifest.entries { + let module_path = rootfs.join(&entry.path); + if module_path.exists() { + fs::remove_file(&module_path).with_context(|| { + format!( + "Failed to remove installed module {}", + module_path.display() + ) + })?; + } + depmod_kernels.insert(entry.kernel.clone()); + } + fs::remove_file(&manifest_path).with_context(|| { + format!( + "Failed to remove Depot DKMS tracking manifest {}", + manifest_path.display() + ) + })?; + } + + for kernel in depmod_kernels { + run_depmod(rootfs, &kernel)?; + } + Ok(()) +} + +fn read_source_manifest(source_dir: &Path) -> Result { + let manifest_path = source_dir.join(DEPOT_KMOD_MANIFEST); + let raw = fs::read_to_string(&manifest_path) + .with_context(|| format!("Failed to read {}", manifest_path.display()))?; + toml::from_str(&raw).with_context(|| format!("Failed to parse {}", manifest_path.display())) +} + +fn discover_kernels(rootfs: &Path) -> Result> { + let modules_root = rootfs.join("lib/modules"); + if !modules_root.exists() { + return Ok(Vec::new()); + } + if !modules_root.is_dir() { + anyhow::bail!( + "Kernel modules path is not a directory: {}", + modules_root.display() + ); + } + + let mut kernels = Vec::new(); + for entry in fs::read_dir(&modules_root) + .with_context(|| format!("Failed to read {}", modules_root.display()))? + { + let entry = entry.with_context(|| format!("Failed to list {}", modules_root.display()))?; + let file_type = entry + .file_type() + .with_context(|| format!("Failed to inspect {}", entry.path().display()))?; + if !file_type.is_dir() { + continue; + } + let version = entry.file_name().to_string_lossy().into_owned(); + if version.trim().is_empty() { + continue; + } + kernels.push(KernelTarget { + version, + build_dir: entry.path().join("build"), + }); + } + kernels.sort_by(|a, b| a.version.cmp(&b.version)); + Ok(kernels) +} + +fn build_for_kernel( + rootfs: &Path, + source_dir: &Path, + manifest: &DepotKmodManifest, + kernel: &KernelTarget, +) -> Result<()> { + run_pre_build_commands(rootfs, source_dir, manifest, kernel)?; + let build_dirs = module_build_dirs(source_dir, manifest)?; + for build_dir in build_dirs { + crate::log_info!( + "Building Depot DKMS module {}/{} for kernel {}", + manifest.name, + manifest.version, + kernel.version + ); + let mut cmd = Command::new("make"); + cmd.arg("-C") + .arg(&kernel.build_dir) + .arg(format!("M={}", build_dir.display())) + .arg("modules") + .args(&manifest.make_args); + crate::builder::prepare_tool_command(&mut cmd, &Vec::new()); + let status = crate::interrupts::command_status(&mut cmd).with_context(|| { + format!( + "Failed to run kernel module build for {} on {}", + manifest.name, kernel.version + ) + })?; + if !status.success() { + anyhow::bail!( + "Kernel module build failed for {}/{} on {}", + manifest.name, + manifest.version, + kernel.version + ); + } + } + Ok(()) +} + +fn run_pre_build_commands( + rootfs: &Path, + source_dir: &Path, + manifest: &DepotKmodManifest, + kernel: &KernelTarget, +) -> Result<()> { + for raw in &manifest.pre_build { + let command = expand_kernel_command(raw, rootfs, source_dir, kernel); + if command.trim().is_empty() { + continue; + } + crate::log_info!( + "Preparing Depot DKMS module {}/{} for kernel {}: {}", + manifest.name, + manifest.version, + kernel.version, + command + ); + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(&command).current_dir(source_dir); + crate::builder::prepare_tool_command(&mut cmd, &Vec::new()); + let status = crate::interrupts::command_status(&mut cmd).with_context(|| { + format!( + "Failed to run DKMS pre-build command for {} on {}", + manifest.name, kernel.version + ) + })?; + if !status.success() { + anyhow::bail!( + "DKMS pre-build command failed for {}/{} on {}: {}", + manifest.name, + manifest.version, + kernel.version, + command + ); + } + } + Ok(()) +} + +fn expand_kernel_command( + raw: &str, + rootfs: &Path, + source_dir: &Path, + kernel: &KernelTarget, +) -> String { + raw.replace("$kernel_build_dir", &shell_path(&kernel.build_dir)) + .replace("${kernel_build_dir}", &shell_path(&kernel.build_dir)) + .replace("$source_dir", &shell_path(source_dir)) + .replace("${source_dir}", &shell_path(source_dir)) + .replace("$rootfs", &shell_path(rootfs)) + .replace("${rootfs}", &shell_path(rootfs)) + .replace("$kernel", &kernel.version) + .replace("${kernel}", &kernel.version) +} + +fn shell_path(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +fn module_build_dirs(source_dir: &Path, manifest: &DepotKmodManifest) -> Result> { + let mut seen = BTreeSet::new(); + let mut dirs = Vec::new(); + for module in &manifest.modules { + let rel = safe_rel_path(&module.build_dir)?; + let dir = source_dir.join(rel); + if seen.insert(dir.clone()) { + dirs.push(dir); + } + } + Ok(dirs) +} + +fn install_for_kernel( + rootfs: &Path, + source_dir: &Path, + manifest: &DepotKmodManifest, + kernel: &KernelTarget, +) -> Result> { + let mut entries = Vec::new(); + for module in &manifest.modules { + let built_location = source_dir.join(safe_rel_path(&module.built_location)?); + let built_module = built_location.join(format!("{}.ko", module.name)); + if !built_module.is_file() { + anyhow::bail!( + "Built module not found for {}/{} on {}: {}", + manifest.name, + module.name, + kernel.version, + built_module.display() + ); + } + + let install_dir = safe_rel_path(&module.install_dir)?; + let dest_rel = Path::new("lib/modules") + .join(&kernel.version) + .join(install_dir) + .join(format!("{}.ko", module.dest_name)); + let dest = rootfs.join(&dest_rel); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + fs::copy(&built_module, &dest).with_context(|| { + format!( + "Failed to install module {} to {}", + built_module.display(), + dest.display() + ) + })?; + entries.push(InstalledKmodEntry { + kernel: kernel.version.clone(), + module: module.dest_name.clone(), + path: dest_rel.to_string_lossy().into_owned(), + }); + } + Ok(entries) +} + +fn run_depmod(rootfs: &Path, kernel: &str) -> Result<()> { + let mut cmd = Command::new("depmod"); + if rootfs == Path::new("/") { + cmd.arg("-a").arg(kernel); + } else { + cmd.arg("-b").arg(rootfs).arg(kernel); + } + let status = crate::interrupts::command_status(&mut cmd) + .with_context(|| format!("Failed to run depmod for kernel {}", kernel))?; + if !status.success() { + anyhow::bail!("depmod failed for kernel {}", kernel); + } + Ok(()) +} + +fn write_tracking_manifest( + rootfs: &Path, + manifest: &DepotKmodManifest, + entries: Vec, +) -> Result<()> { + let tracking_dir = rootfs.join(TRACKING_DIR_REL); + fs::create_dir_all(&tracking_dir) + .with_context(|| format!("Failed to create {}", tracking_dir.display()))?; + let installed = InstalledKmodManifest { + name: manifest.name.clone(), + version: manifest.version.clone(), + entries, + }; + let path = tracking_manifest_path(rootfs, &manifest.name, &manifest.version); + let raw = toml::to_string_pretty(&installed) + .context("Failed to serialize Depot DKMS tracking manifest")?; + fs::write(&path, raw).with_context(|| format!("Failed to write {}", path.display()))?; + Ok(()) +} + +fn selected_tracking_manifests( + rootfs: &Path, + source: Option<&Path>, + name: Option<&str>, +) -> Result> { + let mut selected = BTreeMap::new(); + if let Some(source) = source { + let source_dir = resolve_rootfs_path(rootfs, source); + if source_dir.join(DEPOT_KMOD_MANIFEST).exists() { + let manifest = read_source_manifest(&source_dir)?; + let tracking = tracking_manifest_path(rootfs, &manifest.name, &manifest.version); + if tracking.exists() { + selected.insert(tracking.clone(), read_tracking_manifest(&tracking)?); + } + } + } + + if let Some(name) = name { + for (path, manifest) in tracking_manifests_by_name(rootfs, name)? { + selected.insert(path, manifest); + } + } + + Ok(selected.into_iter().collect()) +} + +fn tracking_manifests_by_name( + rootfs: &Path, + name: &str, +) -> Result> { + let tracking_dir = rootfs.join(TRACKING_DIR_REL); + if !tracking_dir.exists() { + return Ok(Vec::new()); + } + let mut manifests = Vec::new(); + for entry in fs::read_dir(&tracking_dir) + .with_context(|| format!("Failed to read {}", tracking_dir.display()))? + { + let entry = entry.with_context(|| format!("Failed to list {}", tracking_dir.display()))?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("toml") { + continue; + } + let manifest = read_tracking_manifest(&path)?; + if manifest.name == name { + manifests.push((path, manifest)); + } + } + manifests.sort_by(|left, right| left.0.cmp(&right.0)); + Ok(manifests) +} + +fn read_tracking_manifest(path: &Path) -> Result { + let raw = + fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?; + toml::from_str(&raw).with_context(|| format!("Failed to parse {}", path.display())) +} + +fn tracking_manifest_path(rootfs: &Path, name: &str, version: &str) -> PathBuf { + rootfs.join(TRACKING_DIR_REL).join(format!( + "{}-{}.toml", + safe_tracking_component(name), + safe_tracking_component(version) + )) +} + +fn safe_tracking_component(raw: &str) -> String { + raw.chars() + .map(|ch| match ch { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '_' | '+' | '-' => ch, + _ => '_', + }) + .collect() +} + +fn resolve_rootfs_path(rootfs: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + rootfs.join(path.strip_prefix(Path::new("/")).unwrap_or(path)) + } else { + rootfs.join(path) + } +} + +fn safe_rel_path(raw: &str) -> Result { + let trimmed = raw.trim().trim_start_matches('/'); + if trimmed.is_empty() || trimmed == "." { + return Ok(PathBuf::new()); + } + let path = Path::new(trimmed); + if path.is_absolute() { + anyhow::bail!("Expected relative path: {}", raw); + } + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(part) => out.push(part), + Component::CurDir => {} + _ => anyhow::bail!("Unsafe path component in DKMS manifest path: {}", raw), + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::TestEnv; + use tempfile::tempdir; + + fn write_tracking(rootfs: &Path) -> Result { + let path = tracking_manifest_path(rootfs, "zfs", "2.4.3"); + fs::create_dir_all(path.parent().unwrap())?; + let manifest = InstalledKmodManifest { + name: "zfs".into(), + version: "2.4.3".into(), + entries: vec![InstalledKmodEntry { + kernel: "6.1.0".into(), + module: "zfs".into(), + path: "lib/modules/6.1.0/updates/depot/zfs.ko".into(), + }], + }; + fs::write(&path, toml::to_string(&manifest)?)?; + Ok(path) + } + + #[test] + fn remove_uses_tracking_manifest_and_only_removes_tracked_modules() -> Result<()> { + let tmp = tempdir()?; + let rootfs = tmp.path(); + let module_path = rootfs.join("lib/modules/6.1.0/updates/depot/zfs.ko"); + fs::create_dir_all(module_path.parent().unwrap())?; + fs::write(&module_path, "module")?; + let tracking = write_tracking(rootfs)?; + + let depmod = rootfs.join("depmod-bin"); + fs::write(&depmod, "#!/bin/sh\nexit 0\n")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&depmod)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&depmod, perms)?; + } + + let mut env = TestEnv::new(); + env.set_var("PATH", rootfs); + fs::rename(&depmod, rootfs.join("depmod"))?; + remove(rootfs, None, Some("zfs"))?; + + assert!(!module_path.exists()); + assert!(!tracking.exists()); + Ok(()) + } + + #[test] + fn safe_rel_path_rejects_traversal() { + assert!(safe_rel_path("../x").is_err()); + assert!(safe_rel_path("updates/depot").is_ok()); + } +} diff --git a/src/install/mod.rs b/src/install/mod.rs index c4bcebb..14f0e7e 100644 --- a/src/install/mod.rs +++ b/src/install/mod.rs @@ -1,4 +1,5 @@ //! Installation helpers. +pub(crate) mod dkms; pub mod hooks; pub mod scripts; diff --git a/src/package/interactive.rs b/src/package/interactive.rs index 048e90b..5a0e42c 100644 --- a/src/package/interactive.rs +++ b/src/package/interactive.rs @@ -22,6 +22,7 @@ impl fmt::Display for BuildType { BuildType::Python => write!(f, "Python"), BuildType::Rust => write!(f, "Rust"), BuildType::Makefile => write!(f, "Makefile"), + BuildType::Dkms => write!(f, "DKMS"), BuildType::Bin => write!(f, "Binary installer"), BuildType::Meta => write!(f, "Metapackage"), } @@ -960,6 +961,7 @@ pub fn spec_to_minimal_toml(spec: &PackageSpec) -> anyhow::Result { BuildType::Python => "python", BuildType::Rust => "rust", BuildType::Makefile => "makefile", + BuildType::Dkms => "dkms", BuildType::Bin => "bin", BuildType::Meta => "meta", }; diff --git a/src/package/spec/loading.rs b/src/package/spec/loading.rs index e71fcc3..5543c0b 100644 --- a/src/package/spec/loading.rs +++ b/src/package/spec/loading.rs @@ -87,6 +87,7 @@ impl PackageSpec { ); } spec.validate_manual_sources()?; + spec.validate_dkms()?; Ok(spec) } @@ -166,6 +167,52 @@ impl PackageSpec { Ok(()) } + fn validate_dkms(&self) -> Result<()> { + if !matches!(self.build.build_type, BuildType::Dkms) { + return Ok(()); + } + + if self.build.flags.dkms_modules.is_empty() { + anyhow::bail!( + "build.type = \"dkms\" requires at least one build.flags.dkms_modules entry" + ); + } + + validate_dkms_component("build.flags.dkms_name", &self.effective_dkms_name())?; + validate_dkms_component("build.flags.dkms_version", &self.effective_dkms_version())?; + validate_dkms_rel_path( + "build.flags.dkms_source_dir", + self.build.flags.dkms_source_dir.trim(), + true, + )?; + validate_dkms_install_dir( + "build.flags.dkms_install_dir", + self.effective_dkms_install_dir().as_str(), + )?; + + let mut installed_names = HashSet::new(); + for (idx, module) in self.build.flags.dkms_modules.iter().enumerate() { + let label = format!("build.flags.dkms_modules[{idx}]"); + validate_dkms_component(&format!("{label}.name"), module.name.trim())?; + let dest_name = self.effective_dkms_module_dest_name(module); + validate_dkms_component(&format!("{label}.dest_name"), &dest_name)?; + validate_dkms_rel_path(&format!("{label}.build_dir"), module.build_dir.trim(), true)?; + validate_dkms_rel_path( + &format!("{label}.built_location"), + module.built_location.trim(), + true, + )?; + let install_dir = self.effective_dkms_module_install_dir(module); + validate_dkms_install_dir(&format!("{label}.install_dir"), &install_dir)?; + + if !installed_names.insert(dest_name.clone()) { + anyhow::bail!("Duplicate DKMS destination module name: {}", dest_name); + } + } + + Ok(()) + } + /// Expand variables like `$name` and `$version` in a string. pub fn expand_vars(&self, input: &str) -> String { let specdir = self.spec_dir.to_string_lossy(); @@ -180,6 +227,56 @@ impl PackageSpec { &self.source } + /// Return the effective Depot DKMS source package name. + pub fn effective_dkms_name(&self) -> String { + let configured = self.build.flags.dkms_name.trim(); + if configured.is_empty() { + self.package.name.clone() + } else { + self.expand_vars(configured) + } + } + + /// Return the effective Depot DKMS source package version. + pub fn effective_dkms_version(&self) -> String { + let configured = self.build.flags.dkms_version.trim(); + if configured.is_empty() { + self.package.version.clone() + } else { + self.expand_vars(configured) + } + } + + /// Return the default module install directory for Depot DKMS packages. + pub fn effective_dkms_install_dir(&self) -> String { + let configured = self.build.flags.dkms_install_dir.trim(); + if configured.is_empty() { + "updates/depot".to_string() + } else { + normalize_dkms_install_dir(&self.expand_vars(configured)) + } + } + + /// Return the effective installed module name for a DKMS module entry. + pub fn effective_dkms_module_dest_name(&self, module: &DkmsModule) -> String { + let configured = module.dest_name.trim(); + if configured.is_empty() { + module.name.clone() + } else { + self.expand_vars(configured) + } + } + + /// Return the effective install directory for a DKMS module entry. + pub fn effective_dkms_module_install_dir(&self, module: &DkmsModule) -> String { + let configured = module.install_dir.trim(); + if configured.is_empty() { + self.effective_dkms_install_dir() + } else { + normalize_dkms_install_dir(&self.expand_vars(configured)) + } + } + /// Returns true when this spec is a metadata-only package that exists to pull dependencies. pub fn is_metapackage(&self) -> bool { matches!(self.build.build_type, BuildType::Meta) @@ -339,6 +436,49 @@ impl PackageSpec { } } +fn normalize_dkms_install_dir(raw: &str) -> String { + raw.trim().trim_start_matches('/').to_string() +} + +fn validate_dkms_component(label: &str, value: &str) -> Result<()> { + let value = value.trim(); + if value.is_empty() { + anyhow::bail!("{label} cannot be empty"); + } + if value.contains('/') || value.contains('\\') { + anyhow::bail!("{label} cannot contain path separators: {value}"); + } + if value == "." || value == ".." { + anyhow::bail!("{label} cannot be '.' or '..'"); + } + Ok(()) +} + +fn validate_dkms_install_dir(label: &str, value: &str) -> Result<()> { + validate_dkms_rel_path(label, value.trim_start_matches('/'), false) +} + +fn validate_dkms_rel_path(label: &str, value: &str, allow_empty: bool) -> Result<()> { + let value = value.trim(); + if value.is_empty() { + if allow_empty { + return Ok(()); + } + anyhow::bail!("{label} cannot be empty"); + } + let path = Path::new(value); + if path.is_absolute() { + anyhow::bail!("{label} must be relative: {value}"); + } + for component in path.components() { + match component { + std::path::Component::Normal(_) | std::path::Component::CurDir => {} + _ => anyhow::bail!("{label} contains an unsafe path component: {value}"), + } + } + Ok(()) +} + impl fmt::Display for PackageSpec { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!( diff --git a/src/package/spec/model.rs b/src/package/spec/model.rs index f091709..4e0d1c1 100644 --- a/src/package/spec/model.rs +++ b/src/package/spec/model.rs @@ -217,10 +217,36 @@ pub enum BuildType { Python, Rust, Makefile, + Dkms, Bin, Meta, } +/// One kernel module output managed by Depot's DKMS-compatible backend. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct DkmsModule { + /// Built module name without a `.ko` suffix. + pub name: String, + /// Installed module name without a `.ko` suffix. Defaults to `name`. + #[serde(default, alias = "dest-name", alias = "dest_name")] + pub dest_name: String, + /// Source subdirectory passed as `M=` to the kernel build system. + #[serde(default, alias = "path", alias = "build-dir", alias = "build_dir")] + pub build_dir: String, + /// Directory containing the resulting `.ko`. Defaults to `build_dir`. + #[serde( + default, + alias = "built-location", + alias = "built_location", + alias = "output-dir", + alias = "output_dir" + )] + pub built_location: String, + /// Install directory under `/lib/modules/`. Defaults to `dkms_install_dir`. + #[serde(default, alias = "install-dir", alias = "install_dir")] + pub install_dir: String, +} + /// Build flags and toolchain configuration #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] pub struct BuildFlags { @@ -715,6 +741,47 @@ pub struct BuildFlags { /// Binary package type when using BuildType::Bin (e.g. "deb") #[serde(default)] pub binary_type: String, + /// DKMS package/module name for Depot-managed kernel modules. + #[serde(default, alias = "dkms-name", alias = "dkms_name")] + pub dkms_name: String, + /// DKMS package/module version. Defaults to the package version. + #[serde(default, alias = "dkms-version", alias = "dkms_version")] + pub dkms_version: String, + /// Source subdirectory to stage into `/usr/src/-`. + #[serde(default, alias = "dkms-source-dir", alias = "dkms_source_dir")] + pub dkms_source_dir: String, + /// Default install directory under `/lib/modules/` for built modules. + #[serde(default, alias = "dkms-install-dir", alias = "dkms_install_dir")] + pub dkms_install_dir: String, + /// Additional arguments appended to kernel module `make` invocations. + #[serde( + default, + alias = "dkms-make-args", + alias = "dkms_make_args", + deserialize_with = "deserialize_string_or_array" + )] + pub dkms_make_args: Vec, + /// Commands run from the staged source tree before each per-kernel module build. + /// + /// Supports `$kernel`, `$kernel_build_dir`, `$source_dir`, and `$rootfs`. + #[serde( + default, + alias = "dkms-pre-build", + alias = "dkms_pre_build", + deserialize_with = "deserialize_string_or_array_no_split" + )] + pub dkms_pre_build: Vec, + /// Run Depot's kernel-module build/install hook after package install/update. + #[serde( + default = "default_dkms_autoinstall", + alias = "dkms-autoinstall", + alias = "dkms_autoinstall", + deserialize_with = "deserialize_boolish" + )] + pub dkms_autoinstall: bool, + /// Kernel modules produced by this DKMS source package. + #[serde(default, alias = "dkms-modules", alias = "dkms_modules")] + pub dkms_modules: Vec, /// Internal runtime marker used to adjust builder behavior for the lib32 variant. #[serde(skip)] pub lib32_variant: bool, @@ -819,6 +886,14 @@ impl Default for BuildFlags { source_subdir: String::new(), build_dir: None, binary_type: String::new(), + dkms_name: String::new(), + dkms_version: String::new(), + dkms_source_dir: String::new(), + dkms_install_dir: String::new(), + dkms_make_args: Vec::new(), + dkms_pre_build: Vec::new(), + dkms_autoinstall: default_dkms_autoinstall(), + dkms_modules: Vec::new(), lib32_variant: false, host_build_dir: None, } @@ -958,6 +1033,10 @@ fn default_use_lto() -> bool { true } +fn default_dkms_autoinstall() -> bool { + true +} + fn default_ar() -> String { "ar".to_string() } diff --git a/src/package/spec/tests.rs b/src/package/spec/tests.rs index d8b795c..5105efa 100644 --- a/src/package/spec/tests.rs +++ b/src/package/spec/tests.rs @@ -2379,6 +2379,102 @@ fn docs_package_for_output_derives_name_and_description() { assert_eq!(docs.version, "1.0"); } +#[test] +fn parse_dkms_build_type_and_modules() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("zfs.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "zfs-dkms" +version = "2.4.3" +description = "d" +homepage = "h" +license = "CDDL" + +[source] +url = "https://github.com/openzfs/zfs/releases/download/zfs-$version/zfs-$version.tar.gz" +sha256 = "skip" +extract_dir = "zfs-$version" + +[build] +type = "dkms" + +[build.flags] +dkms_name = "zfs" +dkms_version = "$version" +dkms_source_dir = "." +dkms_install_dir = "updates/depot" +dkms_make_args = ["V=1"] +dkms_pre_build = [ + "./configure --with-config=kernel --with-linux=$kernel_build_dir --with-linux-obj=$kernel_build_dir" +] + +[[build.flags.dkms_modules]] +name = "zfs" +path = "module" +built_location = "module/zfs" + +[[build.flags.dkms_modules]] +name = "spl" +dest_name = "spl_compat" +build_dir = "module" +built_location = "module/spl" +install_dir = "/updates/storage" +"#, + ) + .unwrap(); + + let spec = PackageSpec::from_file(&path).unwrap(); + assert!(matches!(spec.build.build_type, BuildType::Dkms)); + assert_eq!(spec.effective_dkms_name(), "zfs"); + assert_eq!(spec.effective_dkms_version(), "2.4.3"); + assert_eq!(spec.effective_dkms_install_dir(), "updates/depot"); + assert_eq!(spec.build.flags.dkms_pre_build.len(), 1); + assert_eq!(spec.build.flags.dkms_modules.len(), 2); + assert_eq!(spec.build.flags.dkms_modules[0].build_dir, "module"); + assert_eq!( + spec.effective_dkms_module_install_dir(&spec.build.flags.dkms_modules[1]), + "updates/storage" + ); +} + +#[test] +fn parse_dkms_rejects_unsafe_module_paths() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("bad.toml"); + + std::fs::write( + &path, + r#" +[package] +name = "bad-dkms" +version = "1.0" +description = "d" +homepage = "h" +license = "MIT" + +[source] +url = "https://example.com/bad.tar.gz" +sha256 = "skip" +extract_dir = "bad" + +[build] +type = "dkms" + +[[build.flags.dkms_modules]] +name = "bad" +path = "../outside" +"#, + ) + .unwrap(); + + let err = PackageSpec::from_file(&path).unwrap_err().to_string(); + assert!(err.contains("unsafe path component"), "{err}"); +} + fn mk_spec(name: &str, version: &str) -> PackageSpec { PackageSpec { package: PackageInfo {