diff --git a/apps/bootstrap-installer/src-tauri/src/install_script.rs b/apps/bootstrap-installer/src-tauri/src/install_script.rs index 67a114408f89..c37a7dfdc647 100644 --- a/apps/bootstrap-installer/src-tauri/src/install_script.rs +++ b/apps/bootstrap-installer/src-tauri/src/install_script.rs @@ -70,6 +70,29 @@ fn is_valid_commit(s: &str) -> bool { (7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit()) } +/// Resolver cache plan for a pin that already has a local path computed. +/// +/// Immutable commit pins reuse cache forever. Mutable branch/tag pins always +/// refresh, and only fall back to a stale cache when the refresh fails. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CachePlan { + /// On-disk hit for an immutable pin — skip the network. + Reuse, + /// Download (or re-download). `stale_ok` means a failed refresh may return + /// the existing cache file (mutable pins with a prior download). + Fetch { stale_ok: bool }, +} + +pub(crate) fn cache_plan(immutable: bool, cached_exists: bool) -> CachePlan { + if immutable && cached_exists { + CachePlan::Reuse + } else { + CachePlan::Fetch { + stale_ok: !immutable && cached_exists, + } + } +} + /// Resolves the install script to use for this run. /// /// `pin` is the commit-or-branch from either Hermes-Setup's build-time @@ -100,9 +123,13 @@ pub async fn resolve( // 2. (Not implemented) bundled fallback. // 3. Network. Pin must be a real commit or a branch ref. - let commit_or_ref = match (&pin.commit, &pin.branch) { - (Some(c), _) if is_valid_commit(c) => c.clone(), - (_, Some(b)) if !b.trim().is_empty() => b.clone(), + // + // Commit SHAs are immutable — permanent cache reuse is safe. + // Branch/tag pins are moving refs: always try to refresh so "Retry install" + // cannot keep reusing a poisoned install-main.ps1 forever (#67193). + let (commit_or_ref, immutable) = match (&pin.commit, &pin.branch) { + (Some(c), _) if is_valid_commit(c) => (c.clone(), true), + (_, Some(b)) if !b.trim().is_empty() => (b.clone(), false), (Some(other), _) => { return Err(anyhow!( "install script pin commit `{other}` is not a valid git SHA" @@ -116,36 +143,60 @@ pub async fn resolve( }; let cached = cached_path(kind, &commit_or_ref); - if cached.exists() { - emit_log(&format!( - "[bootstrap] using cached {} for {}", - kind.filename(), - truncate_ref(&commit_or_ref) - )); - return Ok(ResolvedScript { - path: cached, - source: ScriptSource::Cached, - commit: pin.commit.clone(), - branch: pin.branch.clone(), - }); + match cache_plan(immutable, cached.exists()) { + CachePlan::Reuse => { + emit_log(&format!( + "[bootstrap] using cached {} for {}", + kind.filename(), + truncate_ref(&commit_or_ref) + )); + return Ok(ResolvedScript { + path: cached, + source: ScriptSource::Cached, + commit: pin.commit.clone(), + branch: pin.branch.clone(), + }); + } + CachePlan::Fetch { stale_ok } => { + emit_log(&format!( + "[bootstrap] downloading {} for {} {} from GitHub", + kind.filename(), + if immutable { + "commit" + } else { + "mutable ref" + }, + truncate_ref(&commit_or_ref) + )); + + match download(kind, &commit_or_ref, &cached).await { + Ok(()) => { + emit_log(&format!("[bootstrap] cached to {}", cached.display())); + Ok(ResolvedScript { + path: cached, + source: ScriptSource::Downloaded, + commit: pin.commit.clone(), + branch: pin.branch.clone(), + }) + } + Err(err) if stale_ok => { + emit_log(&format!( + "[bootstrap] WARNING: refresh failed for mutable ref {}; using stale cached {} at {}: {err:#}", + truncate_ref(&commit_or_ref), + kind.filename(), + cached.display() + )); + Ok(ResolvedScript { + path: cached, + source: ScriptSource::Cached, + commit: pin.commit.clone(), + branch: pin.branch.clone(), + }) + } + Err(err) => Err(err), + } + } } - - emit_log(&format!( - "[bootstrap] downloading {} for {} from GitHub", - kind.filename(), - truncate_ref(&commit_or_ref) - )); - - download(kind, &commit_or_ref, &cached).await?; - - emit_log(&format!("[bootstrap] cached to {}", cached.display())); - - Ok(ResolvedScript { - path: cached, - source: ScriptSource::Downloaded, - commit: pin.commit.clone(), - branch: pin.branch.clone(), - }) } #[derive(Debug, Clone, Default)] @@ -185,6 +236,33 @@ fn truncate_ref(s: &str) -> &str { } } +/// UTF-8 BOM. Windows PowerShell 5.1 reads a BOM-less `.ps1` using the system +/// ANSI code page; a leading BOM is what tells it the file is UTF-8. The +/// `irm | iex` / `[scriptblock]::Create` path strips BOMs on purpose, but the +/// GUI bootstrap runs the *cached file* via `-File`, so we write the opposite +/// (#67193). +const UTF8_BOM: &[u8] = &[0xEF, 0xBB, 0xBF]; + +/// Prepare bytes for the on-disk bootstrap cache. +/// +/// `.ps1` files get a UTF-8 BOM (unless one is already present). `.sh` files +/// are left unchanged — a BOM would break `#!/bin/bash`. +pub(crate) fn prepare_cached_script_bytes(kind: ScriptKind, bytes: &[u8]) -> Vec { + match kind { + ScriptKind::Ps1 => { + if bytes.starts_with(UTF8_BOM) { + bytes.to_vec() + } else { + let mut out = Vec::with_capacity(UTF8_BOM.len() + bytes.len()); + out.extend_from_slice(UTF8_BOM); + out.extend_from_slice(bytes); + out + } + } + ScriptKind::Sh => bytes.to_vec(), + } +} + /// Downloads to `dest_path` via reqwest with rustls. Atomically renames /// `dest_path.tmp` → `dest_path` so partial writes don't poison the cache. async fn download(kind: ScriptKind, commit_or_ref: &str, dest_path: &Path) -> Result<()> { @@ -228,6 +306,7 @@ async fn download(kind: ScriptKind, commit_or_ref: &str, dest_path: &Path) -> Re .bytes() .await .with_context(|| format!("reading body of {url}"))?; + let bytes = prepare_cached_script_bytes(kind, &bytes); let mut file = tokio::fs::File::create(&tmp_path) .await diff --git a/apps/bootstrap-installer/src-tauri/src/powershell.rs b/apps/bootstrap-installer/src-tauri/src/powershell.rs index 04694c113b52..62e9512ecca9 100644 --- a/apps/bootstrap-installer/src-tauri/src/powershell.rs +++ b/apps/bootstrap-installer/src-tauri/src/powershell.rs @@ -13,6 +13,94 @@ use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::{Child, Command}; use tokio::sync::mpsc; +/// CP1252 mapping for bytes `0x80..=0x9F` (the range that differs from Latin-1). +/// Undefined slots keep the C1 control code points, matching Windows-1252 +/// best-fit behavior used by `encoding_rs::WINDOWS_1252`. +const CP1252_80_9F: [char; 32] = [ + '\u{20AC}', // 0x80 € + '\u{0081}', // 0x81 + '\u{201A}', // 0x82 ‚ + '\u{0192}', // 0x83 ƒ + '\u{201E}', // 0x84 „ + '\u{2026}', // 0x85 … + '\u{2020}', // 0x86 † + '\u{2021}', // 0x87 ‡ + '\u{02C6}', // 0x88 ˆ + '\u{2030}', // 0x89 ‰ + '\u{0160}', // 0x8A Š + '\u{2039}', // 0x8B ‹ + '\u{0152}', // 0x8C Œ + '\u{008D}', // 0x8D + '\u{017D}', // 0x8E Ž + '\u{008F}', // 0x8F + '\u{0090}', // 0x90 + '\u{2018}', // 0x91 ‘ + '\u{2019}', // 0x92 ’ + '\u{201C}', // 0x93 “ + '\u{201D}', // 0x94 ” + '\u{2022}', // 0x95 • + '\u{2013}', // 0x96 – + '\u{2014}', // 0x97 — + '\u{02DC}', // 0x98 ˜ + '\u{2122}', // 0x99 ™ + '\u{0161}', // 0x9A š + '\u{203A}', // 0x9B › + '\u{0153}', // 0x9C œ + '\u{009D}', // 0x9D + '\u{017E}', // 0x9E ž + '\u{0178}', // 0x9F Ÿ +]; + +fn decode_cp1252_byte(b: u8) -> char { + match b { + 0x00..=0x7F => b as char, + 0x80..=0x9F => CP1252_80_9F[(b - 0x80) as usize], + // 0xA0..=0xFF match Unicode Latin-1 / Windows-1252. + _ => b as char, + } +} + +/// Decode one stdout/stderr line from a child process. +/// +/// Tokio's `BufReader::lines()` requires valid UTF-8 and aborts the line (with +/// `stream did not contain valid UTF-8`) at the first accented byte. Windows +/// PowerShell 5.1 emits localized ParserError text in the console ANSI code +/// page (often CP1252), so Portuguese/Spanish/etc. users only saw a truncated +/// `No` instead of `Não foi fornecido o terminador...` (#67193). +/// +/// Prefer UTF-8 when the bytes are valid; otherwise decode as Windows-1252 so +/// both Western-European letters and CP1252-only punctuation (e.g. `0x91` → +/// U+2018) survive rather than disappearing into a read-error warning. +pub(crate) fn decode_console_bytes(bytes: &[u8]) -> String { + match std::str::from_utf8(bytes) { + Ok(s) => s.to_string(), + Err(_) => bytes.iter().copied().map(decode_cp1252_byte).collect(), + } +} + +/// Read one line (LF or CRLF) and decode it with [`decode_console_bytes`]. +/// Returns `Ok(None)` on EOF with no bytes pending. +pub(crate) async fn read_decoded_line( + reader: &mut R, + buf: &mut Vec, +) -> std::io::Result> +where + R: AsyncBufReadExt + Unpin, +{ + buf.clear(); + let n = reader.read_until(b'\n', buf).await?; + if n == 0 { + return Ok(None); + } + if buf.last() == Some(&b'\n') { + buf.pop(); + } + if buf.last() == Some(&b'\r') { + buf.pop(); + } + Ok(Some(decode_console_bytes(buf))) +} + /// Hooks the caller installs to receive output. pub struct StreamSink { pub on_stdout_line: Box, @@ -77,8 +165,13 @@ pub async fn run_script( let stdout = child.stdout.take().expect("stdout was piped"); let stderr = child.stderr.take().expect("stderr was piped"); - let mut stdout_reader = BufReader::new(stdout).lines(); - let mut stderr_reader = BufReader::new(stderr).lines(); + // Byte-oriented readers + [`decode_console_bytes`]: do NOT use + // `BufReader::lines()`, which requires valid UTF-8 and hides localized + // PowerShell errors on non-English Windows (#67193). + let mut stdout_reader = BufReader::new(stdout); + let mut stderr_reader = BufReader::new(stderr); + let mut stdout_buf = Vec::new(); + let mut stderr_buf = Vec::new(); let mut combined_stdout = String::new(); let mut combined_stderr = String::new(); @@ -87,7 +180,7 @@ pub async fn run_script( // Loop: poll stdout, stderr, cancel, and child exit concurrently. loop { tokio::select! { - line = stdout_reader.next_line() => { + line = read_decoded_line(&mut stdout_reader, &mut stdout_buf) => { match line { Ok(Some(l)) => { (sink.on_stdout_line)(&l); @@ -104,7 +197,7 @@ pub async fn run_script( } } } - line = stderr_reader.next_line() => { + line = read_decoded_line(&mut stderr_reader, &mut stderr_buf) => { match line { Ok(Some(l)) => { (sink.on_stderr_line)(&l); @@ -130,12 +223,12 @@ pub async fn run_script( } // Drain remaining lines after the loop exited. - while let Ok(Some(l)) = stdout_reader.next_line().await { + while let Ok(Some(l)) = read_decoded_line(&mut stdout_reader, &mut stdout_buf).await { (sink.on_stdout_line)(&l); combined_stdout.push_str(&l); combined_stdout.push('\n'); } - while let Ok(Some(l)) = stderr_reader.next_line().await { + while let Ok(Some(l)) = read_decoded_line(&mut stderr_reader, &mut stderr_buf).await { (sink.on_stderr_line)(&l); combined_stderr.push_str(&l); combined_stderr.push('\n'); diff --git a/apps/bootstrap-installer/src-tauri/src/update.rs b/apps/bootstrap-installer/src-tauri/src/update.rs index 6746600bb275..be4884ab2634 100644 --- a/apps/bootstrap-installer/src-tauri/src/update.rs +++ b/apps/bootstrap-installer/src-tauri/src/update.rs @@ -31,10 +31,11 @@ use std::time::{Duration, Instant}; use anyhow::{anyhow, Result}; use tauri::{AppHandle, Emitter}; -use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::io::BufReader; use tokio::process::Command; use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState}; +use crate::powershell::read_decoded_line; /// `hermes update` exit code meaning "another hermes process is holding the /// venv shim open / dirty precondition" — see _cmd_update_impl in @@ -662,28 +663,31 @@ async fn run_streamed( let stdout = child.stdout.take().expect("stdout piped"); let stderr = child.stderr.take().expect("stderr piped"); - let mut out = BufReader::new(stdout).lines(); - let mut err = BufReader::new(stderr).lines(); + // Same non-UTF-8-safe decode path as powershell::run_script (#67193). + let mut out = BufReader::new(stdout); + let mut err = BufReader::new(stderr); + let mut out_buf = Vec::new(); + let mut err_buf = Vec::new(); let stage_owned = stage.map(|s| s.to_string()); loop { tokio::select! { - line = out.next_line() => match line { + line = read_decoded_line(&mut out, &mut out_buf) => match line { Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l), Ok(None) => break, Err(e) => { tracing::warn!("stdout read error: {e}"); break; } }, - line = err.next_line() => match line { + line = read_decoded_line(&mut err, &mut err_buf) => match line { Ok(Some(l)) => emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l), Ok(None) => {} Err(e) => { tracing::warn!("stderr read error: {e}"); } }, } } - while let Ok(Some(l)) = out.next_line().await { + while let Ok(Some(l)) = read_decoded_line(&mut out, &mut out_buf).await { emit_log(app, stage_owned.as_deref(), LogStream::Stdout, &l); } - while let Ok(Some(l)) = err.next_line().await { + while let Ok(Some(l)) = read_decoded_line(&mut err, &mut err_buf).await { emit_log(app, stage_owned.as_deref(), LogStream::Stderr, &l); }