diff options
Diffstat (limited to 'mingling_cli/src')
| -rw-r--r-- | mingling_cli/src/bin/wrapper.rs | 115 | ||||
| -rw-r--r-- | mingling_cli/src/lib.rs | 28 | ||||
| -rw-r--r-- | mingling_cli/src/linter/registry.rs | 5 | ||||
| -rw-r--r-- | mingling_cli/src/pkg_mgr/cmd_install.rs | 86 | ||||
| -rw-r--r-- | mingling_cli/src/updater.rs | 1 | ||||
| -rw-r--r-- | mingling_cli/src/updater/cmd_update.rs | 266 |
6 files changed, 492 insertions, 9 deletions
diff --git a/mingling_cli/src/bin/wrapper.rs b/mingling_cli/src/bin/wrapper.rs index b1aecf6..a47b926 100644 --- a/mingling_cli/src/bin/wrapper.rs +++ b/mingling_cli/src/bin/wrapper.rs @@ -1,9 +1,16 @@ use std::env; use std::ffi::OsString; -use std::path::PathBuf; +use std::fs; +use std::io; +use std::path::{Component, Path, PathBuf}; use std::process::{self, Command}; +use flate2::read::GzDecoder; +use sha2::{Digest, Sha256}; +use tar::Archive; + fn main() { + apply_update_if_present(); exec(); } @@ -45,3 +52,109 @@ fn exec() { } } } + +/// The update package staged by `mling update` at `{data_dir}/mingling/update.tar.gz`. +fn update_package_path() -> Option<PathBuf> { + dirs::data_dir().map(|data_dir| data_dir.join("mingling").join("update.tar.gz")) +} + +/// Apply a staged update, if any: unpack it over the installation directory +/// (never replacing the running wrapper itself), then remove the staged file. +/// This runs before forwarding to `mingling-cli`, so the new version is loaded +/// by this very invocation. +fn apply_update_if_present() { + let Some(update_path) = update_package_path() else { + return; + }; + if !update_path.is_file() { + return; + } + let Ok(current_exe) = env::current_exe() else { + return; + }; + + // The package mirrors the install layout: the wrapper lives at + // `<root>/bin/mling` and the archive root maps onto `<root>`, so entries + // like `bin/mingling-cli` replace the files next to this wrapper. + let Some(exe_dir) = current_exe.parent() else { + return; + }; + let Some(install_root) = exe_dir.parent() else { + return; + }; + + match unpack_update(&update_path, ¤t_exe, install_root) { + Ok(()) => { + // Record the applied package's checksum so `mling update` can tell + // that this installation is already up to date. + if let Some(checksum_path) = last_update_checksum_path() + && let Ok(checksum) = sha256_file(&update_path) + { + let _ = fs::write(checksum_path, checksum); + } + let _ = fs::remove_file(update_path); + } + Err(e) => eprintln!("mling: failed to apply update: {e}"), + } +} + +/// Extract `update.tar.gz` into `install_root`, skipping the running wrapper. +fn unpack_update( + update_path: &Path, + current_exe: &Path, + install_root: &Path, +) -> std::io::Result<()> { + let file = fs::File::open(update_path)?; + let mut archive = Archive::new(GzDecoder::new(file)); + + for entry in archive.entries()? { + let mut entry = entry?; + let entry_type = entry.header().entry_type(); + let rel_path = sanitize_relative_path(&entry.path()?); + if rel_path.as_os_str().is_empty() { + continue; + } + let dest = install_root.join(rel_path); + // The running executable cannot (and must not) replace itself. + if dest == current_exe { + continue; + } + if entry_type.is_dir() { + fs::create_dir_all(dest)?; + continue; + } + if !entry_type.is_file() { + continue; + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + entry.unpack(dest)?; + } + Ok(()) +} + +/// Keep only normal path components so entries cannot escape `install_root`. +fn sanitize_relative_path(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + if let Component::Normal(part) = component { + out.push(part); + } + } + out +} + +/// `{data_dir}/mingling/last-update.sha256`, where the wrapper records the +/// checksum of the update it applied. +fn last_update_checksum_path() -> Option<PathBuf> { + dirs::data_dir().map(|data_dir| data_dir.join("mingling").join("last-update.sha256")) +} + +/// The sha256 hex digest of a file. +fn sha256_file(path: &Path) -> io::Result<String> { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + io::copy(&mut file, &mut hasher)?; + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/mingling_cli/src/lib.rs b/mingling_cli/src/lib.rs index fdd7636..902ab32 100644 --- a/mingling_cli/src/lib.rs +++ b/mingling_cli/src/lib.rs @@ -1,7 +1,7 @@ use mingling::{ - ShellContext, Suggest, + RenderResult, ShellContext, Suggest, consts::HELP_FLAG, - macros::{completion, gen_program, help, suggest}, + macros::{completion, gen_program, help, renderer, suggest}, }; use crate::{ @@ -21,11 +21,12 @@ pub mod message; pub mod metadata; pub mod pkg_mgr; pub mod proj_mgr; +pub mod updater; pub mod utils; #[help] pub fn help_global(_: EntryFallback) -> String { - include_str!("../help/help.txt").parse_color_code() + format!("{}\n", include_str!("../help/help.txt").parse_color_code()) } #[completion(EntryFallback)] @@ -41,4 +42,25 @@ pub fn complete_global(_ctx: &ShellContext) -> Suggest { } } +#[renderer] +pub fn handle_fallback(args: EntryFallback) -> RenderResult { + let mut r = RenderResult::new(); + let args = args.inner; + if !args.is_empty() { + eprintln_cargo!( + r, + "{}", + format!("Unknown command `{}`", args.join(" ")).parse_color_code() + ) + } else { + hprintln_cargo!( + r, + "{}", + "Welcome to MinglingCLI, please use `mling -h` to see available commands" + .parse_color_code(), + ); + } + r +} + gen_program!(); diff --git a/mingling_cli/src/linter/registry.rs b/mingling_cli/src/linter/registry.rs index 79f3225..b9f82fa 100644 --- a/mingling_cli/src/linter/registry.rs +++ b/mingling_cli/src/linter/registry.rs @@ -26,8 +26,9 @@ pub struct LintMetadata { #[program_setup] pub fn lint_registry_setup(p: &mut Program<ThisProgram>) { p.with_resource(ResLintRegistry::lazy_init(|| { - let registry: ResLintRegistry = serde_json::from_str(include_str!("../../registry.json")) - .expect("failed to parse embedded registry.json"); + let registry: ResLintRegistry = + serde_json::from_str(include_str!(concat!(env!("OUT_DIR"), "/registry.json"))) + .expect("failed to parse embedded registry.json"); registry })); } diff --git a/mingling_cli/src/pkg_mgr/cmd_install.rs b/mingling_cli/src/pkg_mgr/cmd_install.rs index ddcef7d..731ba98 100644 --- a/mingling_cli/src/pkg_mgr/cmd_install.rs +++ b/mingling_cli/src/pkg_mgr/cmd_install.rs @@ -2,9 +2,10 @@ use std::{env, fs, io, path::PathBuf, process::Command}; use cargo_metadata::TargetKind; use mingling::{ - Grouped, LazyRes, RenderResult, Routable, - macros::{chain, command, metadata, pack_err, renderer, routeify}, + Grouped, LazyRes, RenderResult, Routable, ShellContext, Suggest, + macros::{arg, chain, command, completion, metadata, pack_err, renderer, routeify, suggest}, metadata::Description, + picker::{EntryPicker, PickerArg, value::Flag}, }; use crate::{ @@ -16,6 +17,11 @@ use crate::{ pack_err!(ErrorBuildFailed = String); pack_err!(ErrorBinaryNotFound = String); +pack_err!(ErrorPkgEnableFailed = String); + +/// Flag: `--enable` — run `mling pkg-enable` after a successful install +/// to enable the package being installed. +pub static ARG_ENABLE: PickerArg<Flag> = arg![enable: Flag]; /// Resolved install paths, used by the build step. #[derive(Debug, Default, Grouped)] @@ -24,6 +30,7 @@ pub struct StateInstallBuild { pub install_dir: PathBuf, pub release_dir: PathBuf, pub exe_suffix: &'static str, + pub enable: bool, } /// State after `cargo build --release`, used by the copy step. @@ -33,6 +40,16 @@ pub struct StateInstallCopy { pub release_dir: PathBuf, pub exe_suffix: &'static str, pub installed: Vec<PathBuf>, + pub enable: bool, +} + +/// State after the copy step when `--enable` was given: run `mling pkg-enable`. +#[derive(Debug, Default, Grouped)] +pub struct StateInstallEnable { + pub install_dir: PathBuf, + pub installed: Vec<PathBuf>, + pub name: String, + pub version: String, } #[derive(Debug, Default, Grouped)] @@ -49,7 +66,12 @@ pub fn desc_install() -> Description { } #[command(routeify)] -pub fn install(packages_dir: &ResPackagesDir, metadata: &mut LazyRes<ResMetadata>) -> Next { +pub fn install( + args: EntryInstall, + packages_dir: &ResPackagesDir, + metadata: &mut LazyRes<ResMetadata>, +) -> Next { + let enable = args.pick(&ARG_ENABLE).to_result()?; let metadata = metadata.get_ref().data(); let packages_dir = &packages_dir.path; if packages_dir.as_os_str().is_empty() { @@ -69,6 +91,7 @@ pub fn install(packages_dir: &ResPackagesDir, metadata: &mut LazyRes<ResMetadata .join("release") .into_std_path_buf(), exe_suffix: env::consts::EXE_SUFFIX, + enable: enable.bool(), } .to_chain() } @@ -93,6 +116,7 @@ pub fn handle_state_install_build(state: StateInstallBuild) -> Next { release_dir: state.release_dir, exe_suffix: state.exe_suffix, installed: vec![], + enable: state.enable, } .to_chain() } @@ -153,6 +177,45 @@ pub fn handle_state_install_copy( } } + if state.enable { + let root_package = metadata + .root_package() + .or_else(|| metadata.workspace_packages().first().copied()) + .ok_or(ErrorRootPackageNotFound::default())?; + return StateInstallEnable { + install_dir: state.install_dir, + installed: state.installed, + name: root_package.name.to_string(), + version: root_package.version.to_string(), + } + .to_chain(); + } + + ResultInstall { + install_dir: state.install_dir, + installed: state.installed, + } + .to_chain() +} + +/// Step 3 (optional): enable the installed package via `mling pkg-enable` +/// when `--enable` was given. +#[chain(routeify)] +pub fn handle_state_install_enable(state: StateInstallEnable) -> Next { + let spec = format!("{}@{}", state.name, state.version); + let status = Command::new("mling") + .args(["pkg-enable", &spec]) + .status() + .map_err(|e| { + ErrorPkgEnableFailed::new(format!("failed to run `mling pkg-enable {spec}`: {e}")) + })?; + if !status.success() { + return ErrorPkgEnableFailed::new(format!( + "`mling pkg-enable {spec}` failed with {status}" + )) + .to_chain(); + } + ResultInstall { install_dir: state.install_dir, installed: state.installed, @@ -183,3 +246,20 @@ pub fn render_error_binary_not_found(err: ErrorBinaryNotFound) -> RenderResult { eprintln_cargo!(r, "binary not found: {}", err.info); r } + +#[renderer] +pub fn render_error_pkg_enable_failed(err: ErrorPkgEnableFailed) -> RenderResult { + let mut r = RenderResult::new(); + eprintln_cargo!(r, "{}", err.info); + r +} + +#[completion(EntryInstall)] +pub fn complete_install(ctx: &ShellContext) -> Suggest { + if ctx.previous_word != "install" { + return Suggest::FileCompletion; + } + suggest! { + ARG_ENABLE: "Enable the package after installing (runs `mling pkg-enable`)" + } +} diff --git a/mingling_cli/src/updater.rs b/mingling_cli/src/updater.rs new file mode 100644 index 0000000..1c81fb3 --- /dev/null +++ b/mingling_cli/src/updater.rs @@ -0,0 +1 @@ +pub mod cmd_update; diff --git a/mingling_cli/src/updater/cmd_update.rs b/mingling_cli/src/updater/cmd_update.rs new file mode 100644 index 0000000..545361f --- /dev/null +++ b/mingling_cli/src/updater/cmd_update.rs @@ -0,0 +1,266 @@ +use std::{fs, path::Path, path::PathBuf}; + +use mingling::{ + Grouped, LazyRes, RenderResult, Routable, + macros::{chain, command, metadata, renderer, routeify}, + metadata::Description, +}; +use sha2::{Digest, Sha256}; + +use crate::{Next, config::ResMlingConfig, eprintln_cargo, println_cargo}; + +/// Config key holding the base URL that hosts the mling release packages. +const CONFIG_KEY_UPDATE_URL: &str = "update-url"; + +/// Default update source, used when the config key is unset. +const DEFAULT_UPDATE_URL: &str = "https://mingling-rs.github.io/mingling/dist"; + +/// Name of the staged update package inside `{data_dir}/mingling`. +const UPDATE_FILE_NAME: &str = "update.tar.gz"; + +/// Records the sha256 of the last applied update, written by the wrapper. +const LAST_UPDATE_FILE_NAME: &str = "last-update.sha256"; + +/// The resolved download task: check the remote checksum and stage the package. +#[derive(Debug, Default, Grouped)] +pub struct StateUpdateDownload { + pub base_url: String, + pub update_path: PathBuf, +} + +/// The latest package is already installed. +#[derive(Debug, Default, Grouped)] +pub struct ResultUpdateUpToDate; + +/// The latest package was downloaded, verified, and staged for the wrapper. +#[derive(Debug, Default, Grouped)] +pub struct ResultUpdateStaged { + pub update_path: PathBuf, +} + +/// Errors produced by the download pipeline. +#[derive(Debug, Grouped)] +pub enum UpdateError { + /// The data directory could not be determined. + NoDataDirectory, + /// The configured update URL is not a valid `http(s)://` URL. + InvalidUrl(String), + /// A network request failed, or the remote responded with an error. + Network(String), + /// The downloaded package failed its sha256 verification. + ChecksumMismatch(String), + /// Writing the staged update package failed. + Io(String), +} + +#[metadata(EntryUpdate)] +pub fn desc_update() -> Description { + "Update mling to the latest version".into() +} + +#[command(routeify)] +pub fn update(config: &mut LazyRes<ResMlingConfig>) -> Next { + let config = config.get_ref(); + let source = config.get_or(CONFIG_KEY_UPDATE_URL, DEFAULT_UPDATE_URL); + let Some(update_path) = update_package_path() else { + return UpdateError::NoDataDirectory.to_chain(); + }; + if !is_http_url(source) { + return UpdateError::InvalidUrl(source.to_string()).to_chain(); + } + StateUpdateDownload { + base_url: source.to_string(), + update_path, + } + .to_chain() +} + +/// Check the remote checksum against the installed version; if they differ, +/// download the package, verify its checksum, and stage it at +/// `{data_dir}/mingling/update.tar.gz`. +#[chain(routeify)] +pub async fn handle_state_update_download(state: StateUpdateDownload) -> Next { + match check_and_fetch(&state.base_url, &state.update_path).await { + Ok(FetchOutcome::UpToDate) => ResultUpdateUpToDate.to_chain(), + Ok(FetchOutcome::Staged) => ResultUpdateStaged { + update_path: state.update_path, + } + .to_chain(), + Err(e) => e.to_chain(), + } +} + +#[renderer] +pub fn render_result_update_up_to_date(_: ResultUpdateUpToDate) -> RenderResult { + let mut result = RenderResult::new(); + println_cargo!(result, "mling is already up to date"); + result +} + +#[renderer] +pub fn render_result_update_staged(r: ResultUpdateStaged) -> RenderResult { + let mut result = RenderResult::new(); + println_cargo!(result, "Downloaded: {}", r.update_path.display()); + println_cargo!(result, "Run `mling` again to apply the update"); + result +} + +#[renderer] +pub fn render_error_update(err: UpdateError) -> RenderResult { + let mut result = RenderResult::new(); + match err { + UpdateError::NoDataDirectory => { + eprintln_cargo!(result, "failed to determine the data directory"); + } + UpdateError::InvalidUrl(source) => { + eprintln_cargo!( + result, + "invalid update URL `{}`, expected an `http(s)://` URL such as `https://mingling-rs.github.io/mingling/dist`", + source + ); + } + UpdateError::Network(msg) | UpdateError::ChecksumMismatch(msg) | UpdateError::Io(msg) => { + eprintln_cargo!(result, "{}", msg); + } + } + result +} + +/// `{data_dir}/mingling/update.tar.gz`, where the wrapper looks for staged updates. +pub fn update_package_path() -> Option<PathBuf> { + dirs::data_dir().map(|dir| dir.join("mingling").join(UPDATE_FILE_NAME)) +} + +/// `{data_dir}/mingling/last-update.sha256`, the checksum of the last applied update. +fn last_update_checksum_path() -> Option<PathBuf> { + dirs::data_dir().map(|dir| dir.join("mingling").join(LAST_UPDATE_FILE_NAME)) +} + +fn is_http_url(source: &str) -> bool { + let source = source.trim(); + source.starts_with("http://") || source.starts_with("https://") +} + +/// The platform suffix used by the package names (`mling-{os}.tar.gz`). +fn update_os_name() -> &'static str { + if cfg!(windows) { + "win" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "macos") { + "mac" + } else { + "unknown" + } +} + +enum FetchOutcome { + UpToDate, + Staged, +} + +/// Fetch `mling-{os}.tar.gz.sha256`, skip the download when the installed +/// version already matches, then download and verify the package before +/// staging it. +async fn check_and_fetch(base_url: &str, update_path: &Path) -> Result<FetchOutcome, UpdateError> { + let os = update_os_name(); + let base = base_url.trim_end_matches('/'); + let checksum_url = format!("{base}/mling-{os}.tar.gz.sha256"); + let package_url = format!("{base}/mling-{os}.tar.gz"); + + let client = reqwest::Client::builder() + .user_agent(format!("mling-updater/{}", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|e| UpdateError::Network(format!("failed to build HTTP client: {e}")))?; + + // 1. Fetch the remote checksum first. + let response = client.get(&checksum_url).send().await.map_err(|e| { + UpdateError::Network(format!( + "failed to fetch checksum from `{checksum_url}`: {e}" + )) + })?; + if !response.status().is_success() { + return Err(UpdateError::Network(format!( + "failed to fetch checksum from `{checksum_url}`: HTTP {}", + response.status() + ))); + } + let checksum_text = response + .text() + .await + .map_err(|e| UpdateError::Network(format!("failed to read checksum: {e}")))?; + let remote_sha = parse_sha256(&checksum_text).ok_or_else(|| { + UpdateError::Network(format!("invalid checksum file at `{checksum_url}`")) + })?; + + // 2. Skip the download when the installed version already matches. + if let Some(local_sha) = read_last_update_checksum() + && local_sha == remote_sha + { + return Ok(FetchOutcome::UpToDate); + } + + // 3. Download the package. + let response = + client.get(&package_url).send().await.map_err(|e| { + UpdateError::Network(format!("failed to download `{package_url}`: {e}")) + })?; + if !response.status().is_success() { + return Err(UpdateError::Network(format!( + "failed to download `{package_url}`: HTTP {}", + response.status() + ))); + } + let bytes = response + .bytes() + .await + .map_err(|e| UpdateError::Network(format!("failed to read package body: {e}")))?; + + // 4. Verify the package before staging it. + let actual_sha = sha256_hex(&bytes); + if actual_sha != remote_sha { + return Err(UpdateError::ChecksumMismatch(format!( + "checksum mismatch for `{package_url}`: expected {remote_sha}, got {actual_sha}" + ))); + } + + // 5. Stage it for the wrapper. + write_update_package(&bytes, update_path)?; + Ok(FetchOutcome::Staged) +} + +/// Parse the sha256 hex digest from a `sha256sum`-style line (`<hash> <file>`). +fn parse_sha256(line: &str) -> Option<String> { + let hash = line.split_whitespace().next()?; + (hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| hash.to_string()) +} + +/// The checksum of the last update applied by the wrapper, if recorded. +fn read_last_update_checksum() -> Option<String> { + let path = last_update_checksum_path()?; + let content = fs::read_to_string(path).ok()?; + let sha = content.trim(); + (!sha.is_empty()).then(|| sha.to_string()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +/// Stage the update package at the wrapper's well-known location. The bytes are +/// written to a temporary file first so a failed download never corrupts a +/// previously staged update. +fn write_update_package(package: &[u8], update_path: &Path) -> Result<(), UpdateError> { + let parent = update_path.parent().ok_or_else(|| { + UpdateError::Io(format!("no parent directory for {}", update_path.display())) + })?; + fs::create_dir_all(parent).map_err(|e| UpdateError::Io(e.to_string()))?; + let tmp_path = parent.join("update.tar.gz.tmp"); + fs::write(&tmp_path, package).map_err(|e| UpdateError::Io(e.to_string()))?; + if update_path.exists() { + fs::remove_file(update_path).map_err(|e| UpdateError::Io(e.to_string()))?; + } + fs::rename(&tmp_path, update_path).map_err(|e| UpdateError::Io(e.to_string())) +} |
