diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dbf5ed..8b81ef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to Raven are documented in this file. +## [2.20.0] - 2026-07-03 + +### Added + +- `rvpm dist` packages the built application into distributable artifacts: `tar`, `zip`, `deb`, `rpm`, `msi` (WiX 3), and `inno` (Inno Setup), configured by an optional `[dist]` manifest section (metadata, extra assets, Linux dependencies, installer icon and upgrade GUID) with `--target` and `--out-dir` overrides. Control characters are rejected in `[dist]` metadata and the package version so generated packaging files cannot be injected. See `docs/v2/specs/rvpm-dist.md`. (#857) + ## [2.19.17] - 2026-07-03 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index b627898..e94a891 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -558,7 +558,7 @@ dependencies = [ [[package]] name = "raven" -version = "2.19.17" +version = "2.20.0" dependencies = [ "cc", "cranelift-codegen", @@ -575,7 +575,7 @@ dependencies = [ [[package]] name = "raven-runtime" -version = "2.19.17" +version = "2.20.0" dependencies = [ "chrono", "corosensei", diff --git a/Cargo.toml b/Cargo.toml index 405fb2a..4e61229 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ members = [".", "raven-runtime"] [workspace.package] -version = "2.19.17" +version = "2.20.0" edition = "2021" authors = ["martian56"] license = "MIT" diff --git a/docs/v2/specs/rvpm-dist.md b/docs/v2/specs/rvpm-dist.md new file mode 100644 index 0000000..8e3a1d5 --- /dev/null +++ b/docs/v2/specs/rvpm-dist.md @@ -0,0 +1,87 @@ +# rvpm dist + +`rvpm dist` builds the application in the current directory and packages it +into distributable artifacts. A Raven binary is a single static file, so +packaging is one staged file tree plus each format's own metadata, which +rvpm generates from the manifest. + +## Usage + +``` +rvpm dist [--target ] [--out-dir ] +``` + +Targets: `tar`, `zip`, `deb`, `rpm`, `msi`, `inno`. `--target` overrides the +manifest's `[dist].targets`; without either, the host default applies +(`zip` on Windows, `tar` elsewhere). Libraries have no binary and are +rejected. + +Artifacts land in `[dist].out_dir` (default `target/dist`), with a `work/` +scratch directory beside them holding the staging trees and generated +packaging files, which is useful when debugging a format. + +## The [dist] manifest section + +Every field is optional; an absent section behaves like an empty one. + +```toml +[dist] +targets = ["deb", "zip"] # what plain `rvpm dist` produces +out_dir = "target/dist" +display_name = "Rook" # installer titles; default: package name +description = "A coding agent" # default: " " +license = "MIT" # rpm License, installers +homepage = "https://example.com" # deb Homepage, rpm URL, installers +maintainer = "Ada " # deb Maintainer; default: first author +vendor = "Acme" # rpm Vendor, msi Manufacturer; default: maintainer + +[[dist.assets]] # extra files installed with the binary +source = "README.md" # read relative to the package root +dest = "share/doc/rook/README.md" # forward-slash path under the install prefix + +[dist.linux] +depends = ["libc6 (>= 2.31)"] # deb Depends and rpm Requires, verbatim +section = "utils" # deb +priority = "optional" # deb + +[dist.windows] +icon = "assets/rook.ico" # Inno Setup icon +upgrade_code = "9f0c86a1-2b3c-4d5e-8f90-112233445566" # msi upgrade GUID +``` + +Asset `source` and `dest` must be relative forward-slash paths with no `..` +components; the same containment rule `[ffi].sources` follows. The install +prefix per format: `/usr/` for deb and rpm (the binary goes to +`/usr/bin/`), the archive root for tar and zip, and the application +folder for msi and inno. + +## Formats and their tools + +rvpm generates each format's packaging text and shells out to the format's +own tool, the same approach dependency fetching takes with curl and tar. A +missing tool fails with an install hint. + +| Target | Tool | Artifact | +|---|---|---| +| `tar` | tar | `--.tar.gz`, top-level `--/` directory | +| `zip` | bsdtar or zip | `--.zip`, flat | +| `deb` | dpkg-deb | `__.deb` | +| `rpm` | rpmbuild | rpmbuild's own `--1..rpm` naming | +| `msi` | WiX 3 (candle, light) | `--.msi` | +| `inno` | Inno Setup ISCC | `--setup.exe` | + +Version strings are normalized per format: a leading `v` is dropped +everywhere, rpm turns a `-` pre-release separator into `~`, and the msi +ProductVersion keeps only the numeric `X.Y.Z` prefix. + +The msi upgrade GUID is what lets a newer installer replace an older +install. When `[dist.windows].upgrade_code` is not set, rvpm derives a +stable GUID from the package name and prints it with a note; pin it in the +manifest so it never changes by accident. + +## What is out of scope + +Packages are not signed (deb signing, rpm signing, Authenticode are all +post-processing on the produced artifacts). Cross-format scripting hooks +(postinst and friends) and desktop shortcuts are not modeled yet. The +installers do not modify PATH. diff --git a/src/bin/rvpm.rs b/src/bin/rvpm.rs index c6bc14d..d5be401 100644 --- a/src/bin/rvpm.rs +++ b/src/bin/rvpm.rs @@ -98,6 +98,7 @@ fn dispatch() -> ExitCode { Some("install") => run(cmd_install(&args[1..])), Some("update") => run(cmd_update(&args[1..])), Some("build") => run(cmd_build(&args[1..])), + Some("dist") => run(cmd_dist(&args[1..])), Some("run") => cmd_run(&args[1..]), Some("test") => cmd_test(&args[1..]), Some("doc") => run(cmd_doc(&args[1..])), @@ -572,6 +573,58 @@ fn cmd_build(args: &[String]) -> Result, String> { Ok(report.outcome_lines) } +/// Build the application, then package it into distributable artifacts +/// (archives, deb, rpm, msi, Inno Setup) per the manifest's `[dist]` +/// section and any command-line overrides. +fn cmd_dist(args: &[String]) -> Result, String> { + if args.iter().any(|a| a == "--help" || a == "-h") { + return Ok(vec![dist_usage()]); + } + let mut opts = ops::dist::DistOptions::default(); + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--target" => { + i += 1; + let value = args.get(i).ok_or_else(|| { + "--target needs a value, for example --target deb,zip".to_string() + })?; + for t in value.split(',') { + let t = t.trim(); + if t.is_empty() { + continue; + } + if !raven::manifest::DIST_TARGETS.contains(&t) { + return Err(format!( + "'{}' is not a dist target; use any of {}", + t, + raven::manifest::DIST_TARGETS.join(", ") + )); + } + opts.targets.push(t.to_string()); + } + } + "--out-dir" => { + i += 1; + let value = args + .get(i) + .ok_or_else(|| "--out-dir needs a directory".to_string())?; + opts.out_dir = Some(value.clone()); + } + other => { + return Err(format!( + "unknown argument '{}' for `rvpm dist`; run `rvpm dist --help`", + other + )); + } + } + i += 1; + } + let cwd = std::env::current_dir().map_err(|e| e.to_string())?; + let report = with_fetch_progress(|| ops::dist::dist(&cwd, opts)).map_err(|e| e.to_string())?; + Ok(report.outcome_lines) +} + /// Build the package then run the produced binary, forwarding any args /// after `run` to the program and exiting with its code. fn cmd_run(args: &[String]) -> ExitCode { @@ -856,6 +909,21 @@ fn build_usage() -> String { "Usage: rvpm build".to_string() } +fn dist_usage() -> String { + [ + "Usage: rvpm dist [--target ] [--out-dir ]", + "", + "Build the application, then package it into distributable artifacts", + "under target/dist. Targets: tar, zip, deb, rpm, msi, inno.", + "", + "Without --target, the manifest's [dist].targets applies; without a", + "[dist] section, the host default does (zip on Windows, tar elsewhere).", + "Metadata, extra files, and installer settings live in the optional", + "[dist] section of rv.toml; see docs/v2/specs/rvpm-dist.md.", + ] + .join("\n") +} + fn run_usage() -> String { "Usage: rvpm run [program arguments]".to_string() } @@ -893,6 +961,7 @@ fn print_usage() { println!(" install Resolve rv.toml against rv.lock and fill the cache"); println!(" update [pkg] Re-resolve rv.toml and rewrite rv.lock for one package or all"); println!(" build Compile src/main.rv to a binary, or type-check a lib.rv library"); + println!(" dist Package the built application (tar, zip, deb, rpm, msi, inno)"); println!(" run [args] Build the application then run it, forwarding args"); println!(" test Run fun test_*() tests in *_test.rv files"); println!(" doc Generate Markdown API docs into target/doc"); diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs index b26fdc8..21bda73 100644 --- a/src/manifest/mod.rs +++ b/src/manifest/mod.rs @@ -72,6 +72,10 @@ pub struct Manifest { pub dependencies: Vec, pub ffi: Ffi, pub fmt: Fmt, + /// `Some` when the manifest has a `[dist]` section, with absent fields + /// already filled from `[package]`. `None` means the section is absent; + /// `rvpm dist` then acts as if it were `Dist::with_defaults`. + pub dist: Option, } /// The `[package]` section. @@ -129,6 +133,289 @@ impl Default for Fmt { } } +/// The artifact formats `rvpm dist` can produce. +pub const DIST_TARGETS: &[&str] = &["tar", "zip", "deb", "rpm", "msi", "inno"]; + +/// The default `[dist].out_dir`, relative to the package root. +pub const DEFAULT_DIST_OUT_DIR: &str = "target/dist"; + +/// The optional `[dist]` section: how `rvpm dist` packages the built +/// application. Every field has a default derived from `[package]`, so the +/// section can be omitted entirely and `rvpm dist` still produces the host's +/// native archive. See `docs/v2/specs/rvpm-dist.md`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Dist { + /// Artifact formats to produce when `--target` is not given. Empty means + /// the host default: `tar` on Unix, `zip` on Windows. + pub targets: Vec, + /// Where artifacts land, relative to the package root. + pub out_dir: String, + /// Human-facing application name, used in installer titles and shortcuts. + pub display_name: String, + /// One-line description, used by deb, rpm, and the installers. + pub description: String, + /// SPDX-style license name, used by rpm and the installers. + pub license: String, + /// Project URL, used by deb, rpm, and the installers. + pub homepage: String, + /// `Name ` contact, required by deb; defaults to the first + /// `[package].authors` entry. + pub maintainer: String, + /// Organization name for rpm and msi; defaults to the maintainer. + pub vendor: String, + /// Extra files installed alongside the binary. + pub assets: Vec, + pub linux: DistLinux, + pub windows: DistWindows, +} + +/// One `[[dist.assets]]` entry. `source` is read relative to the package +/// root. `dest` is a forward-slash install path relative to the install +/// prefix: `/usr/` for deb and rpm, the archive root for tar and zip, and +/// the application folder for msi and inno. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DistAsset { + pub source: String, + pub dest: String, +} + +/// The `[dist.linux]` subsection, shared by the deb and rpm backends. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DistLinux { + /// Package dependencies, in each format's own syntax (they are passed + /// through verbatim to `Depends:` and `Requires:`). + pub depends: Vec, + /// The deb archive section. + pub section: String, + /// The deb priority. + pub priority: String, +} + +impl Default for DistLinux { + fn default() -> Self { + DistLinux { + depends: Vec::new(), + section: "utils".to_string(), + priority: "optional".to_string(), + } + } +} + +/// The `[dist.windows]` subsection, shared by the msi and inno backends. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DistWindows { + /// An .ico file relative to the package root, used by the installers. + pub icon: String, + /// The stable GUID that lets an msi upgrade an installed older version. + /// Required by the msi backend; generate one once and keep it. + pub upgrade_code: String, +} + +impl Dist { + /// The `[dist]` configuration an absent section stands for: host-default + /// target, everything else derived from `[package]`. + pub fn with_defaults(package: &Package) -> Dist { + Dist { + targets: Vec::new(), + out_dir: DEFAULT_DIST_OUT_DIR.to_string(), + display_name: package.name.clone(), + description: format!("{} {}", package.name, package.version), + license: String::new(), + homepage: String::new(), + maintainer: package + .authors + .first() + .cloned() + .unwrap_or_else(|| format!("{} maintainers", package.name)), + vendor: String::new(), + assets: Vec::new(), + linux: DistLinux::default(), + windows: DistWindows::default(), + } + } +} + +/// Whether `p` is safe to join under a staging or install root: relative, +/// forward slashes only, and free of `.` and `..` components. The same +/// containment idea as `checked_ffi_source`, applied at parse time. +pub fn is_safe_dist_path(p: &str) -> bool { + !p.is_empty() + && !p.contains('\\') + && !p.starts_with('/') + && !p.contains(':') + && p.split('/') + .all(|part| !part.is_empty() && part != "." && part != "..") +} + +fn is_guid(s: &str) -> bool { + let bytes = s.as_bytes(); + if bytes.len() != 36 { + return false; + } + for (i, b) in bytes.iter().enumerate() { + let is_sep = matches!(i, 8 | 13 | 18 | 23); + if is_sep != (*b == b'-') { + return false; + } + if !is_sep && !b.is_ascii_hexdigit() { + return false; + } + } + true +} + +/// Reject a control character in a `[dist]` text field. These fields are +/// written into line-oriented and scripted packaging files, where a newline +/// or other control byte could inject an extra field or section. A real +/// value (a name, a description, a dependency) never contains one. +fn sanitize_dist_text(field: &str, value: String) -> Result { + if let Some(bad) = value.chars().find(|c| c.is_control()) { + return Err(ManifestError::InvalidValue { + section: "dist".to_string(), + field: field.to_string(), + message: format!( + "must not contain control characters (found U+{:04X}); dist metadata is written into generated packaging files", + bad as u32 + ), + }); + } + Ok(value) +} + +/// Apply [`sanitize_dist_text`] to each entry of a `[dist]` string list. +fn sanitize_dist_list(field: &str, values: Vec) -> Result, ManifestError> { + values + .into_iter() + .map(|v| sanitize_dist_text(field, v)) + .collect() +} + +/// Validate a raw `[dist]` section against the schema, filling absent +/// fields from `package`. +fn validate_dist(raw: RawDist, package: &Package) -> Result { + let invalid = |field: &str, message: String| ManifestError::InvalidValue { + section: "dist".to_string(), + field: field.to_string(), + message, + }; + + for t in &raw.targets { + if !DIST_TARGETS.contains(&t.as_str()) { + return Err(invalid( + "targets", + format!( + "'{}' is not a known target; use any of {}", + t, + DIST_TARGETS.join(", ") + ), + )); + } + } + + let out_dir = raw + .out_dir + .unwrap_or_else(|| DEFAULT_DIST_OUT_DIR.to_string()); + if !is_safe_dist_path(&out_dir) { + return Err(invalid( + "out_dir", + "must be a relative forward-slash path inside the package".to_string(), + )); + } + + let mut assets = Vec::with_capacity(raw.assets.len()); + for a in raw.assets { + if !is_safe_dist_path(&a.source) { + return Err(invalid( + "assets.source", + format!( + "'{}' must be a relative forward-slash path inside the package", + a.source + ), + )); + } + if !is_safe_dist_path(&a.dest) { + return Err(invalid( + "assets.dest", + format!( + "'{}' must be a relative forward-slash path under the install prefix", + a.dest + ), + )); + } + assets.push(DistAsset { + source: a.source, + dest: a.dest, + }); + } + + let linux = raw.linux.unwrap_or_default(); + let windows = raw.windows.unwrap_or_default(); + if let Some(icon) = &windows.icon { + if !is_safe_dist_path(icon) { + return Err(invalid( + "windows.icon", + format!( + "'{}' must be a relative forward-slash path inside the package", + icon + ), + )); + } + } + if let Some(code) = &windows.upgrade_code { + if !is_guid(code) { + return Err(invalid( + "windows.upgrade_code", + format!( + "'{}' is not a GUID (expected xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)", + code + ), + )); + } + } + + // Every text field below is written verbatim into a line-oriented or + // scripted packaging file (the deb control file, the rpm spec, the Inno + // Setup script). A control character, above all a newline, could start a + // new field or section there and, for rpm and inno, run commands during + // packaging. Reject them at the boundary so no backend has to trust the + // metadata. Path and GUID fields are already constrained above. + let defaults = Dist::with_defaults(package); + let maintainer = + sanitize_dist_text("maintainer", raw.maintainer.unwrap_or(defaults.maintainer))?; + Ok(Dist { + targets: raw.targets, + out_dir, + display_name: sanitize_dist_text( + "display_name", + raw.display_name.unwrap_or(defaults.display_name), + )?, + description: sanitize_dist_text( + "description", + raw.description.unwrap_or(defaults.description), + )?, + license: sanitize_dist_text("license", raw.license.unwrap_or_default())?, + homepage: sanitize_dist_text("homepage", raw.homepage.unwrap_or_default())?, + vendor: sanitize_dist_text("vendor", raw.vendor.unwrap_or_else(|| maintainer.clone()))?, + maintainer, + assets, + linux: DistLinux { + depends: sanitize_dist_list("linux.depends", linux.depends)?, + section: sanitize_dist_text( + "linux.section", + linux.section.unwrap_or_else(|| "utils".to_string()), + )?, + priority: sanitize_dist_text( + "linux.priority", + linux.priority.unwrap_or_else(|| "optional".to_string()), + )?, + }, + windows: DistWindows { + icon: windows.icon.unwrap_or_default(), + upgrade_code: windows.upgrade_code.unwrap_or_default(), + }, + }) +} + /// An error produced while reading or parsing a manifest. #[derive(Debug)] pub enum ManifestError { @@ -201,6 +488,7 @@ struct RawManifest { dependencies: std::collections::BTreeMap, ffi: Option, fmt: Option, + dist: Option, } #[derive(Debug, Default, Deserialize)] @@ -231,6 +519,47 @@ struct RawFmt { wrap_width: Option, } +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawDist { + #[serde(default)] + targets: Vec, + out_dir: Option, + display_name: Option, + description: Option, + license: Option, + homepage: Option, + maintainer: Option, + vendor: Option, + #[serde(default)] + assets: Vec, + linux: Option, + windows: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawDistAsset { + source: String, + dest: String, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawDistLinux { + #[serde(default)] + depends: Vec, + section: Option, + priority: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawDistWindows { + icon: Option, + upgrade_code: Option, +} + impl Manifest { /// Parse and validate a manifest from a TOML string. pub fn from_toml_str(s: &str) -> Result { @@ -250,6 +579,20 @@ impl Manifest { section: "package".to_string(), field: "version".to_string(), })?; + // The version is copied verbatim into generated packaging text (the rpm + // spec `Version:`, the deb control `Version:`, the Inno Setup + // `AppVersion=`), where a control character such as a newline could + // inject an additional directive. A real version never contains one. + if let Some(bad) = version.chars().find(|c| c.is_control()) { + return Err(ManifestError::InvalidValue { + section: "package".to_string(), + field: "version".to_string(), + message: format!( + "must not contain control characters (found U+{:04X})", + bad as u32 + ), + }); + } if name.trim().is_empty() { return Err(ManifestError::InvalidValue { section: "package".to_string(), @@ -342,16 +685,24 @@ impl Manifest { None => Fmt::default(), }; + let package = Package { + name, + version, + authors: raw_pkg.authors, + edition, + }; + + let dist = match raw.dist { + Some(d) => Some(validate_dist(d, &package)?), + None => None, + }; + Ok(Manifest { - package: Package { - name, - version, - authors: raw_pkg.authors, - edition, - }, + package, dependencies, ffi, fmt, + dist, }) } @@ -623,4 +974,180 @@ license = "MIT" let err = Manifest::from_toml_str(src).unwrap_err(); assert!(matches!(err, ManifestError::Toml(_))); } + + #[test] + fn manifest_without_dist_has_none() { + let src = "[package]\nname = \"x\"\nversion = \"0.1.0\"\n"; + let m = Manifest::from_toml_str(src).expect("parses"); + assert!(m.dist.is_none()); + } + + #[test] + fn empty_dist_section_fills_defaults_from_package() { + let src = r#" +[package] +name = "rook" +version = "0.2.0" +authors = ["Ada "] + +[dist] +"#; + let m = Manifest::from_toml_str(src).expect("parses"); + let d = m.dist.expect("dist present"); + assert!(d.targets.is_empty()); + assert_eq!(d.out_dir, DEFAULT_DIST_OUT_DIR); + assert_eq!(d.display_name, "rook"); + assert_eq!(d.description, "rook 0.2.0"); + assert_eq!(d.maintainer, "Ada "); + assert_eq!(d.vendor, "Ada "); + assert_eq!(d.linux.section, "utils"); + assert_eq!(d.linux.priority, "optional"); + } + + #[test] + fn full_dist_section_parses() { + let src = r#" +[package] +name = "rook" +version = "0.2.0" + +[dist] +targets = ["deb", "zip"] +out_dir = "artifacts" +display_name = "Rook" +description = "A coding agent for Raven" +license = "MIT" +homepage = "https://example.com/rook" +maintainer = "Ada " +vendor = "Acme" + +[[dist.assets]] +source = "README.md" +dest = "share/doc/rook/README.md" + +[dist.linux] +depends = ["libc6 (>= 2.31)"] +section = "devel" + +[dist.windows] +icon = "assets/rook.ico" +upgrade_code = "9f0c86a1-2b3c-4d5e-8f90-112233445566" +"#; + let m = Manifest::from_toml_str(src).expect("parses"); + let d = m.dist.expect("dist present"); + assert_eq!(d.targets, vec!["deb", "zip"]); + assert_eq!(d.out_dir, "artifacts"); + assert_eq!(d.display_name, "Rook"); + assert_eq!(d.vendor, "Acme"); + assert_eq!(d.assets.len(), 1); + assert_eq!(d.assets[0].dest, "share/doc/rook/README.md"); + assert_eq!(d.linux.depends, vec!["libc6 (>= 2.31)"]); + assert_eq!(d.linux.section, "devel"); + assert_eq!(d.linux.priority, "optional"); + assert_eq!(d.windows.icon, "assets/rook.ico"); + assert_eq!( + d.windows.upgrade_code, + "9f0c86a1-2b3c-4d5e-8f90-112233445566" + ); + } + + #[test] + fn unknown_dist_target_is_rejected() { + let src = "[package]\nname = \"x\"\nversion = \"0.1.0\"\n[dist]\ntargets = [\"pkg\"]\n"; + let err = Manifest::from_toml_str(src).unwrap_err(); + match err { + ManifestError::InvalidValue { section, field, .. } => { + assert_eq!(section, "dist"); + assert_eq!(field, "targets"); + } + other => panic!("expected InvalidValue, got {:?}", other), + } + } + + #[test] + fn dist_traversal_paths_are_rejected() { + let bad_dest = "[package]\nname = \"x\"\nversion = \"0.1.0\"\n[[dist.assets]]\nsource = \"a\"\ndest = \"../../etc/passwd\"\n"; + assert!(Manifest::from_toml_str(bad_dest).is_err()); + let abs_source = "[package]\nname = \"x\"\nversion = \"0.1.0\"\n[[dist.assets]]\nsource = \"/etc/passwd\"\ndest = \"a\"\n"; + assert!(Manifest::from_toml_str(abs_source).is_err()); + let bad_out = + "[package]\nname = \"x\"\nversion = \"0.1.0\"\n[dist]\nout_dir = \"C:/tmp\"\n"; + assert!(Manifest::from_toml_str(bad_out).is_err()); + } + + #[test] + fn dist_bad_upgrade_code_is_rejected() { + let src = "[package]\nname = \"x\"\nversion = \"0.1.0\"\n[dist.windows]\nupgrade_code = \"not-a-guid\"\n"; + let err = Manifest::from_toml_str(src).unwrap_err(); + match err { + ManifestError::InvalidValue { field, .. } => { + assert_eq!(field, "windows.upgrade_code"); + } + other => panic!("expected InvalidValue, got {:?}", other), + } + } + + #[test] + fn dist_metadata_with_a_newline_is_rejected() { + // A newline in a text field could inject an rpm %prep section or an + // Inno [Run] section into the generated packaging file. Reject it. + let injected = "[package]\nname = \"x\"\nversion = \"0.1.0\"\n[dist]\ndescription = \"ok\\n%prep\\necho pwned\"\n"; + let err = Manifest::from_toml_str(injected).unwrap_err(); + match err { + ManifestError::InvalidValue { section, field, .. } => { + assert_eq!(section, "dist"); + assert_eq!(field, "description"); + } + other => panic!("expected InvalidValue, got {:?}", other), + } + } + + #[test] + fn dist_dependency_with_a_newline_is_rejected() { + let injected = "[package]\nname = \"x\"\nversion = \"0.1.0\"\n[dist.linux]\ndepends = [\"libc6\", \"z\\nRequires: evil\"]\n"; + let err = Manifest::from_toml_str(injected).unwrap_err(); + match err { + ManifestError::InvalidValue { section, field, .. } => { + assert_eq!(section, "dist"); + assert_eq!(field, "linux.depends"); + } + other => panic!("expected InvalidValue, got {:?}", other), + } + } + + #[test] + fn package_version_with_a_control_char_is_rejected() { + let injected = "[package]\nname = \"x\"\nversion = \"1.0\\n%prep\"\n"; + let err = Manifest::from_toml_str(injected).unwrap_err(); + match err { + ManifestError::InvalidValue { section, field, .. } => { + assert_eq!(section, "package"); + assert_eq!(field, "version"); + } + other => panic!("expected InvalidValue, got {:?}", other), + } + } + + #[test] + fn clean_dist_metadata_still_parses() { + // Ordinary punctuation and spaces are fine; only control characters + // are rejected. + let src = "[package]\nname = \"x\"\nversion = \"v1.2.3\"\n[dist]\ndescription = \"A tool: fast, small (and tidy)\"\nmaintainer = \"Ada \"\n"; + let m = Manifest::from_toml_str(src).expect("clean metadata parses"); + let d = m.dist.expect("dist present"); + assert_eq!(d.description, "A tool: fast, small (and tidy)"); + } + + #[test] + fn safe_dist_path_rules() { + assert!(is_safe_dist_path("share/doc/x/README.md")); + assert!(is_safe_dist_path("README.md")); + assert!(!is_safe_dist_path("")); + assert!(!is_safe_dist_path("/abs")); + assert!(!is_safe_dist_path("a/../b")); + assert!(!is_safe_dist_path("a/./b")); + assert!(!is_safe_dist_path("a//b")); + assert!(!is_safe_dist_path("a\\b")); + assert!(!is_safe_dist_path("C:/x")); + } } diff --git a/src/ops/dist.rs b/src/ops/dist.rs new file mode 100644 index 0000000..bf76b12 --- /dev/null +++ b/src/ops/dist.rs @@ -0,0 +1,1067 @@ +//! `rvpm dist`: package a built application into distributable artifacts. +//! +//! The command builds the package, stages its file tree once per target, +//! and produces each requested artifact by generating the format's own +//! packaging text (a deb control file, an rpm spec, a WiX source, an Inno +//! Setup script) and shelling out to the format's tool, following the same +//! external-tool approach `pkg` takes with curl and tar. Archive targets +//! (`tar`, `zip`) need nothing beyond tar or zip. A missing tool fails with +//! an install hint rather than a raw spawn error. +//! +//! See `docs/v2/specs/rvpm-dist.md` for the manifest surface. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use sha2::{Digest, Sha256}; + +use crate::manifest::{Dist, Manifest}; +use crate::pkg; + +use super::{build_in, OpError, MANIFEST_FILE_NAME}; + +/// What one `rvpm dist` invocation produced. +pub struct DistReport { + pub outcome_lines: Vec, + pub artifacts: Vec, +} + +/// Command-line overrides for a dist run. +#[derive(Default)] +pub struct DistOptions { + /// Targets to produce instead of the manifest's `[dist].targets`. + pub targets: Vec, + /// Output directory instead of the manifest's `[dist].out_dir`. + pub out_dir: Option, +} + +/// Everything a backend needs to package one application. +struct DistContext { + name: String, + version: String, + dist: Dist, + /// The built binary on disk. + binary: PathBuf, + /// The package root, for resolving asset sources. + project_dir: PathBuf, + /// Where artifacts land (absolute). + out_dir: PathBuf, + /// Scratch space under `out_dir` for staging trees and generated files. + work_dir: PathBuf, +} + +/// Package the application under `project_dir` using the default cache +/// root. See [`dist_in`]. +pub fn dist(project_dir: &Path, opts: DistOptions) -> Result { + dist_in(project_dir, &pkg::cache_root(), opts) +} + +/// Build the package under `project_dir` against `cache_root`, then produce +/// every requested artifact. Targets come from `opts.targets`, else the +/// manifest's `[dist].targets`, else the host default (`tar` on Unix, `zip` +/// on Windows). +pub fn dist_in( + project_dir: &Path, + cache_root: &Path, + opts: DistOptions, +) -> Result { + let manifest = Manifest::load(project_dir.join(MANIFEST_FILE_NAME))?; + let dist_cfg = manifest + .dist + .clone() + .unwrap_or_else(|| Dist::with_defaults(&manifest.package)); + + let build = build_in(project_dir, cache_root)?; + let binary = build.binary.ok_or(OpError::NotPackageable)?; + + let targets = resolve_targets(&opts.targets, &dist_cfg.targets); + let out_rel = opts.out_dir.as_deref().unwrap_or(&dist_cfg.out_dir); + let out_dir = project_dir.join(out_rel); + let work_dir = out_dir.join("work"); + + let ctx = DistContext { + name: manifest.package.name.clone(), + version: manifest.package.version.clone(), + dist: dist_cfg, + binary, + project_dir: project_dir.to_path_buf(), + out_dir, + work_dir, + }; + package_targets(&ctx, &targets) +} + +/// Produce each target from an already-built binary. Split from [`dist_in`] +/// so tests can package a plain file without compiling a program. +fn package_targets(ctx: &DistContext, targets: &[String]) -> Result { + create_dir_all(&ctx.out_dir)?; + // A fresh work tree per run keeps stale staging out of the artifacts. + if ctx.work_dir.exists() { + std::fs::remove_dir_all(&ctx.work_dir).map_err(|e| io_err("clear", &ctx.work_dir, e))?; + } + create_dir_all(&ctx.work_dir)?; + + let mut report = DistReport { + outcome_lines: Vec::new(), + artifacts: Vec::new(), + }; + for target in targets { + let (artifact, note) = match target.as_str() { + "tar" => (dist_tar(ctx)?, None), + "zip" => (dist_zip(ctx)?, None), + "deb" => (dist_deb(ctx)?, None), + "rpm" => (dist_rpm(ctx)?, None), + "msi" => dist_msi(ctx)?, + "inno" => (dist_inno(ctx)?, None), + other => { + return Err(OpError::DistFailed { + tool: "rvpm".to_string(), + detail: format!("unknown dist target '{}'", other), + }) + } + }; + report + .outcome_lines + .push(format!("Packaged {} ({})", artifact.display(), target)); + if let Some(note) = note { + report.outcome_lines.push(note); + } + report.artifacts.push(artifact); + } + Ok(report) +} + +/// The targets to produce: explicit overrides win, then the manifest list, +/// then the host's native archive. +fn resolve_targets(overrides: &[String], manifest_targets: &[String]) -> Vec { + if !overrides.is_empty() { + return overrides.to_vec(); + } + if !manifest_targets.is_empty() { + return manifest_targets.to_vec(); + } + if cfg!(windows) { + vec!["zip".to_string()] + } else { + vec!["tar".to_string()] + } +} + +/// The architecture label most formats use. +fn host_arch() -> &'static str { + std::env::consts::ARCH +} + +/// Debian's name for the host architecture. +fn deb_arch() -> &'static str { + match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + other => other, + } +} + +/// A version acceptable to deb and rpm: the common `v` tag prefix is +/// dropped, and for rpm a `-` pre-release separator becomes `~`. +fn plain_version(version: &str) -> String { + version.strip_prefix('v').unwrap_or(version).to_string() +} + +fn rpm_version(version: &str) -> String { + plain_version(version).replace('-', "~") +} + +/// An MSI ProductVersion must be dotted numerics; take the leading +/// `X.Y.Z` of the version and fall back to 0.0.0. +fn msi_version(version: &str) -> String { + let plain = plain_version(version); + let numeric = plain.split('-').next().unwrap_or(""); + let ok = !numeric.is_empty() + && numeric + .split('.') + .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())); + if ok { + numeric.to_string() + } else { + "0.0.0".to_string() + } +} + +/// The binary's installed file name. +fn binary_file_name(name: &str) -> String { + if cfg!(windows) { + format!("{}.exe", name) + } else { + name.to_string() + } +} + +// --------------------------------------------------------------------------- +// Staging +// --------------------------------------------------------------------------- + +/// Copy the binary and every asset under `root`, with the binary at +/// `bin_prefix` (for example `usr/bin` or the tree root) and assets at +/// `asset_prefix`/dest. +fn stage_tree( + ctx: &DistContext, + root: &Path, + bin_prefix: &str, + asset_prefix: &str, +) -> Result<(), OpError> { + let bin_dir = if bin_prefix.is_empty() { + root.to_path_buf() + } else { + root.join(bin_prefix) + }; + create_dir_all(&bin_dir)?; + let staged_bin = bin_dir.join(binary_file_name(&ctx.name)); + copy_file(&ctx.binary, &staged_bin)?; + mark_executable(&staged_bin)?; + + for asset in &ctx.dist.assets { + let source = ctx.project_dir.join(&asset.source); + let dest = if asset_prefix.is_empty() { + root.join(&asset.dest) + } else { + root.join(asset_prefix).join(&asset.dest) + }; + if let Some(parent) = dest.parent() { + create_dir_all(parent)?; + } + copy_file(&source, &dest)?; + } + Ok(()) +} + +#[cfg(unix)] +fn mark_executable(path: &Path) -> Result<(), OpError> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + .map_err(|e| io_err("set permissions on", path, e)) +} + +#[cfg(not(unix))] +fn mark_executable(_path: &Path) -> Result<(), OpError> { + Ok(()) +} + +// --------------------------------------------------------------------------- +// Archive backends +// --------------------------------------------------------------------------- + +/// `tar`: a `.tar.gz` holding a `--/` top directory, +/// the same layout the Raven toolchain archives use. +fn dist_tar(ctx: &DistContext) -> Result { + let top = format!( + "{}-{}-{}", + ctx.name, + plain_version(&ctx.version), + host_arch() + ); + let stage = ctx.work_dir.join("tar"); + stage_tree(ctx, &stage.join(&top), "", "")?; + let artifact = ctx.out_dir.join(format!("{}.tar.gz", top)); + remove_if_present(&artifact)?; + let mut cmd = Command::new("tar"); + cmd.arg("-czf") + .arg(&artifact) + .arg("-C") + .arg(&stage) + .arg(&top); + run_tool(cmd, "tar", TAR_HINT)?; + Ok(artifact) +} + +/// `zip`: a flat archive, binary and assets at the root. +fn dist_zip(ctx: &DistContext) -> Result { + let stage = ctx.work_dir.join("zip"); + stage_tree(ctx, &stage, "", "")?; + let artifact = ctx.out_dir.join(format!( + "{}-{}-{}.zip", + ctx.name, + plain_version(&ctx.version), + host_arch() + )); + remove_if_present(&artifact)?; + let entries = top_level_entries(&stage)?; + if tar_is_bsdtar() { + let mut cmd = Command::new("tar"); + cmd.arg("-a") + .arg("-cf") + .arg(&artifact) + .arg("-C") + .arg(&stage); + cmd.args(&entries); + run_tool(cmd, "tar", ZIP_HINT)?; + return Ok(artifact); + } + let mut cmd = Command::new("zip"); + cmd.arg("-qr").arg(&artifact); + cmd.args(&entries); + cmd.current_dir(&stage); + run_tool(cmd, "zip", ZIP_HINT)?; + Ok(artifact) +} + +/// Whether the `tar` on PATH is bsdtar, which can write zip archives. +fn tar_is_bsdtar() -> bool { + Command::new("tar") + .arg("--version") + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).contains("bsdtar")) + .unwrap_or(false) +} + +// --------------------------------------------------------------------------- +// deb +// --------------------------------------------------------------------------- + +/// `deb`: stage a Debian file tree, generate `DEBIAN/control`, and run +/// `dpkg-deb --build`. +fn dist_deb(ctx: &DistContext) -> Result { + let stage = ctx.work_dir.join("deb"); + stage_tree(ctx, &stage, "usr/bin", "usr")?; + let control_dir = stage.join("DEBIAN"); + create_dir_all(&control_dir)?; + write_file(&control_dir.join("control"), &deb_control(ctx))?; + let artifact = ctx.out_dir.join(format!( + "{}_{}_{}.deb", + ctx.name, + plain_version(&ctx.version), + deb_arch() + )); + remove_if_present(&artifact)?; + let mut cmd = Command::new("dpkg-deb"); + cmd.arg("--build") + .arg("--root-owner-group") + .arg(&stage) + .arg(&artifact); + run_tool(cmd, "dpkg-deb", DEB_HINT)?; + Ok(artifact) +} + +/// The `DEBIAN/control` text for this package. +fn deb_control(ctx: &DistContext) -> String { + let d = &ctx.dist; + let mut out = String::new(); + out.push_str(&format!("Package: {}\n", ctx.name)); + out.push_str(&format!("Version: {}\n", plain_version(&ctx.version))); + out.push_str(&format!("Architecture: {}\n", deb_arch())); + out.push_str(&format!("Maintainer: {}\n", d.maintainer)); + out.push_str(&format!("Section: {}\n", d.linux.section)); + out.push_str(&format!("Priority: {}\n", d.linux.priority)); + if !d.linux.depends.is_empty() { + out.push_str(&format!("Depends: {}\n", d.linux.depends.join(", "))); + } + if !d.homepage.is_empty() { + out.push_str(&format!("Homepage: {}\n", d.homepage)); + } + out.push_str(&format!("Description: {}\n", d.description)); + out +} + +// --------------------------------------------------------------------------- +// rpm +// --------------------------------------------------------------------------- + +/// `rpm`: stage the install tree, generate a spec whose `%install` copies +/// it into the buildroot, and run `rpmbuild -bb`. +fn dist_rpm(ctx: &DistContext) -> Result { + let stage = ctx.work_dir.join("rpm-root"); + stage_tree(ctx, &stage, "usr/bin", "usr")?; + let topdir = ctx.work_dir.join("rpm"); + create_dir_all(&topdir)?; + let spec_path = ctx.work_dir.join(format!("{}.spec", ctx.name)); + write_file(&spec_path, &rpm_spec(ctx, &stage))?; + + let mut cmd = Command::new("rpmbuild"); + cmd.arg("-bb") + .arg("--define") + .arg(format!("_topdir {}", topdir.display())) + .arg(&spec_path); + run_tool(cmd, "rpmbuild", RPM_HINT)?; + + // rpmbuild names the artifact itself; find it under RPMS and move it + // next to the other artifacts. + let built = find_first_rpm(&topdir.join("RPMS"))?.ok_or_else(|| OpError::DistFailed { + tool: "rpmbuild".to_string(), + detail: "rpmbuild succeeded but produced no .rpm under RPMS".to_string(), + })?; + let artifact = ctx.out_dir.join(built.file_name().expect("rpm file name")); + remove_if_present(&artifact)?; + std::fs::rename(&built, &artifact).map_err(|e| io_err("move", &built, e))?; + Ok(artifact) +} + +/// The rpm spec for this package. `%install` copies the staged tree, so +/// the spec needs no build steps. +fn rpm_spec(ctx: &DistContext, staged_root: &Path) -> String { + let d = &ctx.dist; + let mut out = String::new(); + out.push_str(&format!("Name: {}\n", ctx.name)); + out.push_str(&format!("Version: {}\n", rpm_version(&ctx.version))); + out.push_str("Release: 1\n"); + out.push_str(&format!("Summary: {}\n", d.description)); + let license = if d.license.is_empty() { + "Unspecified" + } else { + &d.license + }; + out.push_str(&format!("License: {}\n", license)); + if !d.homepage.is_empty() { + out.push_str(&format!("URL: {}\n", d.homepage)); + } + if !d.vendor.is_empty() { + out.push_str(&format!("Vendor: {}\n", d.vendor)); + } + if !d.linux.depends.is_empty() { + for dep in &d.linux.depends { + out.push_str(&format!("Requires: {}\n", dep)); + } + } + out.push_str(&format!("BuildArch: {}\n", host_arch())); + out.push_str("\n%description\n"); + out.push_str(&format!("{}\n", d.description)); + out.push_str("\n%install\n"); + out.push_str("mkdir -p %{buildroot}\n"); + out.push_str(&format!( + "cp -a {}/. %{{buildroot}}/\n", + staged_root.display() + )); + out.push_str("\n%files\n"); + out.push_str(&format!("/usr/bin/{}\n", ctx.name)); + for asset in &d.assets { + out.push_str(&format!("/usr/{}\n", asset.dest)); + } + out +} + +fn find_first_rpm(dir: &Path) -> Result, OpError> { + if !dir.exists() { + return Ok(None); + } + let entries = std::fs::read_dir(dir).map_err(|e| io_err("read", dir, e))?; + for entry in entries { + let entry = entry.map_err(|e| io_err("read", dir, e))?; + let path = entry.path(); + if path.is_dir() { + if let Some(found) = find_first_rpm(&path)? { + return Ok(Some(found)); + } + } else if path.extension().is_some_and(|e| e == "rpm") { + return Ok(Some(path)); + } + } + Ok(None) +} + +// --------------------------------------------------------------------------- +// msi (WiX) +// --------------------------------------------------------------------------- + +/// `msi`: generate a WiX 3 source and compile it with candle and light. +/// Returns the artifact and an advisory note when the upgrade GUID was +/// derived rather than pinned in the manifest. +fn dist_msi(ctx: &DistContext) -> Result<(PathBuf, Option), OpError> { + let stage = ctx.work_dir.join("msi"); + stage_tree(ctx, &stage, "", "")?; + + let (upgrade_code, note) = if ctx.dist.windows.upgrade_code.is_empty() { + let derived = derived_upgrade_code(&ctx.name); + let note = format!( + "note: derived msi upgrade code {} from the package name; pin it as [dist.windows].upgrade_code so future installers keep upgrading old ones", + derived + ); + (derived, Some(note)) + } else { + (ctx.dist.windows.upgrade_code.clone(), None) + }; + + let wxs_path = ctx.work_dir.join(format!("{}.wxs", ctx.name)); + write_file(&wxs_path, &wix_source(ctx, &stage, &upgrade_code))?; + let artifact = ctx.out_dir.join(format!( + "{}-{}-{}.msi", + ctx.name, + plain_version(&ctx.version), + host_arch() + )); + remove_if_present(&artifact)?; + + // The generated source uses the WiX 3 schema (the 2006 namespace and the + // element), so it is compiled with WiX 3's candle and light. + // WiX 4 removed that schema and its `wix build` would reject the source, + // so it is deliberately not attempted; WiX 3 is also what the Raven + // toolchain's own release build uses. WiX 4 support would mean emitting + // the v4 schema and is tracked separately. + let candle = find_wix3_tool("candle.exe").ok_or_else(|| OpError::DistTool { + tool: "candle.exe".to_string(), + hint: WIX_HINT.to_string(), + })?; + let light = find_wix3_tool("light.exe").ok_or_else(|| OpError::DistTool { + tool: "light.exe".to_string(), + hint: WIX_HINT.to_string(), + })?; + let wixobj = ctx.work_dir.join(format!("{}.wixobj", ctx.name)); + let mut compile = Command::new(&candle); + compile + .arg("-nologo") + .arg("-arch") + .arg("x64") + .arg("-out") + .arg(&wixobj) + .arg(&wxs_path); + run_tool(compile, "candle.exe", WIX_HINT)?; + let mut link = Command::new(&light); + link.arg("-nologo").arg("-out").arg(&artifact).arg(&wixobj); + run_tool(link, "light.exe", WIX_HINT)?; + Ok((artifact, note)) +} + +/// WiX 3 installs are found through PATH or the WIX environment variable +/// the toolset installer sets. +fn find_wix3_tool(exe: &str) -> Option { + if command_exists(exe.trim_end_matches(".exe")) { + return Some(PathBuf::from(exe)); + } + let wix_home = std::env::var_os("WIX")?; + let candidate = PathBuf::from(wix_home).join("bin").join(exe); + candidate.exists().then_some(candidate) +} + +/// A stable GUID derived from the package name, used when the manifest +/// does not pin `[dist.windows].upgrade_code`. Stability is what matters: +/// the same name always derives the same code, so upgrades keep working. +fn derived_upgrade_code(name: &str) -> String { + let digest = Sha256::digest(format!("rvpm-dist-upgrade-code:{}", name)); + format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + digest[0], digest[1], digest[2], digest[3], + digest[4], digest[5], + digest[6], digest[7], + digest[8], digest[9], + digest[10], digest[11], digest[12], digest[13], digest[14], digest[15], + ) +} + +/// The WiX source: one per-file component per staged file under an +/// application folder in Program Files, with a major-upgrade rule. +fn wix_source(ctx: &DistContext, stage: &Path, upgrade_code: &str) -> String { + let d = &ctx.dist; + let mut components = String::new(); + let mut refs = String::new(); + let files = files_under(stage); + for (i, file) in files.iter().enumerate() { + let id = format!("File{}", i); + components.push_str(&format!( + " \n \n \n", + xml_escape(&file.display().to_string()), + id = id, + )); + refs.push_str(&format!(" \n", id)); + } + format!( + r#" + + + + + + + + +{components} + + + +{refs} + + +"#, + name = xml_escape(&d.display_name), + version = msi_version(&ctx.version), + vendor = xml_escape(if d.vendor.is_empty() { + &d.maintainer + } else { + &d.vendor + }), + upgrade = upgrade_code, + components = components, + refs = refs, + ) +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// Every file under `root`, depth-first, in a stable order. +fn files_under(root: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + let mut paths: Vec = entries.flatten().map(|e| e.path()).collect(); + paths.sort(); + for path in paths { + if path.is_dir() { + stack.push(path); + } else { + out.push(path); + } + } + } + out.sort(); + out +} + +// --------------------------------------------------------------------------- +// inno +// --------------------------------------------------------------------------- + +/// `inno`: generate an Inno Setup script and compile it with ISCC. +fn dist_inno(ctx: &DistContext) -> Result { + let stage = ctx.work_dir.join("inno"); + stage_tree(ctx, &stage, "", "")?; + let iss_path = ctx.work_dir.join(format!("{}.iss", ctx.name)); + write_file(&iss_path, &inno_script(ctx, &stage))?; + let base = format!("{}-{}-setup", ctx.name, plain_version(&ctx.version)); + let artifact = ctx.out_dir.join(format!("{}.exe", base)); + remove_if_present(&artifact)?; + + let iscc = find_iscc().ok_or_else(|| OpError::DistTool { + tool: "ISCC.exe".to_string(), + hint: INNO_HINT.to_string(), + })?; + let mut cmd = Command::new(iscc); + cmd.arg("/Q").arg(&iss_path); + run_tool(cmd, "ISCC.exe", INNO_HINT)?; + Ok(artifact) +} + +fn find_iscc() -> Option { + if command_exists("iscc") { + return Some(PathBuf::from("iscc")); + } + for base in [ + "C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe", + "C:\\Program Files\\Inno Setup 6\\ISCC.exe", + ] { + let p = PathBuf::from(base); + if p.exists() { + return Some(p); + } + } + None +} + +/// The Inno Setup script for this package. +fn inno_script(ctx: &DistContext, stage: &Path) -> String { + let d = &ctx.dist; + let mut out = String::new(); + out.push_str("[Setup]\n"); + out.push_str(&format!("AppName={}\n", d.display_name)); + out.push_str(&format!("AppVersion={}\n", plain_version(&ctx.version))); + if !d.vendor.is_empty() { + out.push_str(&format!("AppPublisher={}\n", d.vendor)); + } + if !d.homepage.is_empty() { + out.push_str(&format!("AppPublisherURL={}\n", d.homepage)); + } + out.push_str(&format!("DefaultDirName={{autopf}}\\{}\n", d.display_name)); + out.push_str("DisableProgramGroupPage=yes\n"); + out.push_str("ArchitecturesInstallIn64BitMode=x64compatible\n"); + if !d.windows.icon.is_empty() { + let icon = ctx.project_dir.join(&d.windows.icon); + out.push_str(&format!("SetupIconFile={}\n", icon.display())); + } + out.push_str(&format!( + "OutputBaseFilename={}-{}-setup\n", + ctx.name, + plain_version(&ctx.version) + )); + out.push_str(&format!("OutputDir={}\n", ctx.out_dir.display())); + out.push_str("\n[Files]\n"); + out.push_str(&format!( + "Source: \"{}\\*\"; DestDir: \"{{app}}\"; Flags: recursesubdirs\n", + stage.display() + )); + out +} + +// --------------------------------------------------------------------------- +// Tool execution and small file helpers +// --------------------------------------------------------------------------- + +const TAR_HINT: &str = + "install tar; it ships with Windows 10 and later and every major Linux distribution"; +const ZIP_HINT: &str = + "no zip-capable tool found; install zip, or rely on bsdtar (the Windows tar), or use the tar target"; +const DEB_HINT: &str = "install dpkg (for example 'apt install dpkg'); deb packages are normally built on a Debian-family system"; +const RPM_HINT: &str = "install rpmbuild ('apt install rpm' on Debian-family, 'dnf install rpm-build' on Fedora-family)"; +const WIX_HINT: &str = "install WiX Toolset 3 ('choco install wixtoolset --version 3.14.0'); the generated source uses the WiX 3 schema, so candle.exe and light.exe must be on PATH or found through the WIX environment variable"; +const INNO_HINT: &str = "install Inno Setup 6 and put ISCC.exe on PATH ('choco install innosetup')"; + +/// Run a packaging tool, mapping a missing executable to an install hint +/// and a non-zero exit to the tool's own diagnostics. +fn run_tool(mut cmd: Command, tool: &str, hint: &str) -> Result<(), OpError> { + let output = match cmd.output() { + Ok(o) => o, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(OpError::DistTool { + tool: tool.to_string(), + hint: hint.to_string(), + }) + } + Err(e) => { + return Err(OpError::DistFailed { + tool: tool.to_string(), + detail: e.to_string(), + }) + } + }; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim().to_string() + } else { + stderr.trim().to_string() + }; + return Err(OpError::DistFailed { + tool: tool.to_string(), + detail, + }); + } + Ok(()) +} + +/// Whether `name` resolves to a runnable command. +fn command_exists(name: &str) -> bool { + Command::new(name) + .arg("--version") + .output() + .map(|_| true) + .unwrap_or(false) +} + +fn top_level_entries(dir: &Path) -> Result, OpError> { + let mut names = Vec::new(); + let entries = std::fs::read_dir(dir).map_err(|e| io_err("read", dir, e))?; + for entry in entries { + let entry = entry.map_err(|e| io_err("read", dir, e))?; + names.push(entry.file_name().to_string_lossy().to_string()); + } + names.sort(); + Ok(names) +} + +fn create_dir_all(path: &Path) -> Result<(), OpError> { + std::fs::create_dir_all(path).map_err(|e| io_err("create", path, e)) +} + +fn copy_file(from: &Path, to: &Path) -> Result<(), OpError> { + std::fs::copy(from, to) + .map(|_| ()) + .map_err(|e| io_err("copy", from, e)) +} + +fn write_file(path: &Path, text: &str) -> Result<(), OpError> { + std::fs::write(path, text).map_err(|e| io_err("write", path, e)) +} + +fn remove_if_present(path: &Path) -> Result<(), OpError> { + if path.exists() { + std::fs::remove_file(path).map_err(|e| io_err("remove", path, e))?; + } + Ok(()) +} + +fn io_err(action: &str, path: &Path, source: std::io::Error) -> OpError { + OpError::Io { + action: action.to_string(), + path: path.to_path_buf(), + source, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::{Dist, DistAsset, Manifest}; + + fn counter() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + COUNTER.fetch_add(1, Ordering::Relaxed) + } + + /// A scratch package root holding a fake built binary and one asset, + /// so backends can be exercised without compiling a program. + struct FakeApp { + root: PathBuf, + } + + impl FakeApp { + fn new(tag: &str) -> FakeApp { + let mut root = std::env::temp_dir(); + root.push(format!( + "rvpm-dist-{}-{}-{}", + tag, + std::process::id(), + counter() + )); + std::fs::create_dir_all(&root).expect("create temp root"); + FakeApp { root } + } + + fn context(&self, manifest_toml: &str) -> DistContext { + let manifest = Manifest::from_toml_str(manifest_toml).expect("manifest parses"); + let dist = manifest + .dist + .clone() + .unwrap_or_else(|| Dist::with_defaults(&manifest.package)); + let binary = self.root.join(binary_file_name(&manifest.package.name)); + std::fs::write(&binary, b"#!/bin/sh\necho fake\n").expect("write fake binary"); + std::fs::write(self.root.join("README.md"), "docs\n").expect("write asset"); + let out_dir = self.root.join("target/dist"); + DistContext { + name: manifest.package.name.clone(), + version: manifest.package.version.clone(), + dist, + binary, + project_dir: self.root.clone(), + out_dir: out_dir.clone(), + work_dir: out_dir.join("work"), + } + } + } + + impl Drop for FakeApp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + + const BASIC: &str = + "[package]\nname = \"demo\"\nversion = \"v1.2.0\"\nauthors = [\"Ada \"]\n"; + + const WITH_ASSET: &str = r#" +[package] +name = "demo" +version = "1.2.0" +authors = ["Ada "] + +[dist] +description = "A demo tool" +license = "MIT" +homepage = "https://example.com/demo" +vendor = "Acme" + +[[dist.assets]] +source = "README.md" +dest = "share/doc/demo/README.md" + +[dist.linux] +depends = ["libc6 (>= 2.31)", "zlib1g"] +"#; + + #[test] + fn resolve_targets_precedence() { + let over = vec!["deb".to_string()]; + let manifest = vec!["rpm".to_string(), "zip".to_string()]; + assert_eq!(resolve_targets(&over, &manifest), vec!["deb"]); + assert_eq!(resolve_targets(&[], &manifest), vec!["rpm", "zip"]); + let host = resolve_targets(&[], &[]); + assert_eq!(host.len(), 1); + assert!(host[0] == "zip" || host[0] == "tar"); + } + + #[test] + fn version_normalization() { + assert_eq!(plain_version("v1.2.3"), "1.2.3"); + assert_eq!(plain_version("1.2.3"), "1.2.3"); + assert_eq!(rpm_version("v1.2.3-beta.1"), "1.2.3~beta.1"); + assert_eq!(msi_version("v1.2.3-beta.1"), "1.2.3"); + assert_eq!(msi_version("nonsense"), "0.0.0"); + } + + #[test] + fn deb_control_renders_the_manifest() { + let app = FakeApp::new("control"); + let ctx = app.context(WITH_ASSET); + let control = deb_control(&ctx); + assert!(control.contains("Package: demo\n")); + assert!(control.contains("Version: 1.2.0\n")); + assert!(control.contains("Maintainer: Ada \n")); + assert!(control.contains("Depends: libc6 (>= 2.31), zlib1g\n")); + assert!(control.contains("Homepage: https://example.com/demo\n")); + assert!(control.contains("Description: A demo tool\n")); + assert!(control.contains("Section: utils\n")); + } + + #[test] + fn deb_control_minimal_has_no_optional_fields() { + let app = FakeApp::new("control-min"); + let ctx = app.context(BASIC); + let control = deb_control(&ctx); + assert!(control.contains("Version: 1.2.0\n"), "v prefix is stripped"); + assert!(!control.contains("Depends:")); + assert!(!control.contains("Homepage:")); + } + + #[test] + fn rpm_spec_lists_every_installed_file() { + let app = FakeApp::new("spec"); + let ctx = app.context(WITH_ASSET); + let spec = rpm_spec(&ctx, Path::new("/tmp/staged")); + assert!(spec.contains("Name: demo\n")); + assert!(spec.contains("License: MIT\n")); + assert!(spec.contains("Requires: libc6 (>= 2.31)\n")); + assert!(spec.contains("/usr/bin/demo\n")); + assert!(spec.contains("/usr/share/doc/demo/README.md\n")); + assert!(spec.contains("cp -a /tmp/staged/. %{buildroot}/\n")); + } + + #[test] + fn wix_source_holds_upgrade_code_and_files() { + let app = FakeApp::new("wix"); + let ctx = app.context(WITH_ASSET); + let stage = ctx.work_dir.join("msi"); + stage_tree(&ctx, &stage, "", "").expect("stage"); + let code = "9f0c86a1-2b3c-4d5e-8f90-112233445566"; + let wxs = wix_source(&ctx, &stage, code); + assert!(wxs.contains(code)); + assert!(wxs.contains("Name=\"demo\"")); + assert!(wxs.contains("Manufacturer=\"Acme\"")); + assert!(wxs.contains(" { + assert!(report.artifacts[0].is_file()); + } + // A host with GNU tar and no zip has no zip-capable tool; the + // diagnostic must say so rather than fail mysteriously. + Err(OpError::DistTool { tool, .. }) => assert_eq!(tool, "zip"), + Err(other) => panic!("unexpected error: {}", other), + } + } + + #[test] + fn deb_backend_builds_where_dpkg_exists() { + let app = FakeApp::new("deb"); + let ctx = app.context(WITH_ASSET); + match package_targets(&ctx, &["deb".to_string()]) { + Ok(report) => { + let artifact = &report.artifacts[0]; + assert!(artifact.is_file()); + assert!(artifact + .file_name() + .unwrap() + .to_string_lossy() + .ends_with(".deb")); + } + Err(OpError::DistTool { tool, .. }) => assert_eq!(tool, "dpkg-deb"), + Err(other) => panic!("unexpected error: {}", other), + } + } + + #[test] + fn artifact_names_follow_each_format_convention() { + let app = FakeApp::new("names"); + let ctx = app.context(BASIC); + let report = package_targets(&ctx, &["tar".to_string()]).expect("tar dist"); + let name = report.artifacts[0] + .file_name() + .unwrap() + .to_string_lossy() + .to_string(); + assert_eq!(name, format!("demo-1.2.0-{}.tar.gz", host_arch())); + } +} diff --git a/src/ops/mod.rs b/src/ops/mod.rs index 3f6a98c..9f7dd34 100644 --- a/src/ops/mod.rs +++ b/src/ops/mod.rs @@ -17,6 +17,8 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::path::{Component, Path, PathBuf}; +pub mod dist; + use crate::codegen::linker; use crate::driver::{self, DriverError}; use crate::lock::{self, LockError, LockFile, LOCK_FILE_NAME}; @@ -59,6 +61,12 @@ pub enum OpError { MissingEntry(PathBuf), /// `run` was asked to execute a library, which produces no binary. NotRunnable, + /// `dist` was asked to package a library, which produces no binary. + NotPackageable, + /// A packaging tool `dist` needs is not installed. + DistTool { tool: String, hint: String }, + /// A packaging tool ran and reported failure. + DistFailed { tool: String, detail: String }, /// A `*_test.rv` file could not be lexed or parsed during test discovery. TestParse { file: PathBuf, message: String }, /// Compiling or linking the package failed. @@ -111,6 +119,14 @@ impl fmt::Display for OpError { f, "this package is a library (lib.rv) with no executable to run; use 'rvpm build' to type-check it" ), + OpError::NotPackageable => write!( + f, + "this package is a library (lib.rv) with no executable to package; 'rvpm dist' packages applications (src/main.rv)" + ), + OpError::DistTool { tool, hint } => { + write!(f, "'{}' is not installed; {}", tool, hint) + } + OpError::DistFailed { tool, detail } => write!(f, "{} failed: {}", tool, detail), OpError::TestParse { file, message } => { write!(f, "cannot read test file '{}': {}", file.display(), message) }