Merge pull request #269 from Kuddev/codex/fix-macos-auto-update

fix(updater): recover rate-limited checks and install macOS updates
This commit is contained in:
Kuddev authored and GitHub committed 2026-09-24 13:11:39 +08:00
commit a5fc1c9fc4
19 files changed
+1583 -76

No files matched your search

@@ -0,0 +1,73 @@
# Public release discovery when GitHub REST access is limited
## Status
Proposed for review; implemented and verified on macOS arm64.
## Context
A macOS user received HTTP 403 when checking for updates. Startup and manual
checks use the same unauthenticated GitHub REST endpoint, so shared egress IP
quota exhaustion can disable both even when the release website is reachable.
## Evidence
On 2026-09-23, the public REST endpoint returned HTTP 403 with `API rate limit
exceeded`. The official `/releases/latest` website returned HTTP 200 after
redirecting to `/Kuddev/pebrel/releases/tag/v1.9.0`. No account credentials were
needed for the website request. The official macOS 1.9.0 DMG contains a signed
`Pebrel.app` with bundle identifier `io.github.kuddev.pebrel` and version 1.9.0.
## Decision
Only REST HTTP 403/429 invokes a second, bounded request to the official latest
release page. Resolve its proxy independently for github.com. The HTTP client's
final URI must name this repository, use HTTPS, and contain an exact numeric
major.minor.patch tag (optional v/V prefix). Do not parse website markup.
After version discovery, fetch the official `SHA256SUMS` release asset with a
separate ten-second deadline and a 64 KiB limit. Only one valid checksum for an
exact native package name grants download metadata; absence or failure retains
version discovery with manual download. Package names are shared with download
validation. Normal API responses retain digest/release-body checksum handling.
Both automatic and manual checks use this path. Local update rehearsals never
fall back to the public network.
## Rejected alternatives
- Embedding a GitHub token: introduces credentials and distribution risk for a
public read-only operation.
- Treating an API failure as up to date: hides real failures and misses upgrades.
- Guessing package URLs or checksums from a tag: discovery does not authorize an
installer. Scraping HTML would add a fragile metadata parser.
## Consequences
A limited API may add two ten-second requests; the website lookup permits at
most five redirects. If version discovery fails, manual checking reports an
error. Without a valid manifest entry, a newer fallback version offers manual
download only. No unverified package can be auto-executed. Unsupported future
tag formats fail explicitly rather than producing a guessed version.
## Validation
Update-check and proxy regressions passed on macOS. Coverage
includes 403/429, ordinary API success, malformed responses, fallback failure,
redirect transport and disallowed hosts/tags. An explicit live test successfully
queried the production checker and the public fallback, both returning 1.9.0.
Exact architecture/name preference and checksum manifest rejection tests passed.
Independent i18n contracts passed, including zero-allocation lookup. A native
macOS test window completed the Settings check from checking to up to date
(GitHub v1.9.0), without the previous HTTP 403. The separate
[macOS installation decision](../update_download/2026-09-23-macos-bundle-handoff.md)
records installation evidence. Windows/Linux runtime acceptance is not claimed.
## Supersedes
None.
## Revisit when
GitHub changes the public redirect contract, release tags gain another stable
format, or a first-party metadata endpoint supplies authenticated package hashes
without requiring client credentials.
@@ -0,0 +1,115 @@
# Native macOS application replacement
## Status
Proposed for review; implemented with native macOS arm64 acceptance.
## Context
macOS could discover releases but had no in-app installer adapter. The requested
update flow must preserve the running application until the downloaded replacement
is verified and all windows have authorized shutdown. API rate-limit recovery and
installation are delivered together because discovery alone left manual upgrades.
## Evidence
The official v1.9.0 ARM64 DMG was downloaded and mounted read-only on 2026-09-23.
Its legacy `-preview.dmg` asset name contains the formal `Pebrel.app`, identifier
`io.github.kuddev.pebrel`, bundle version 1.9.0 and executable version 1.9.0;
`codesign --verify --deep --strict` passed. Current packaging also emits the
preferred `macos-arm64.dmg` / `macos-x64.dmg` names without the legacy suffix.
## Decision
`update_check::assets` owns exact native package selection. `update_download`
continues to own streaming, size limits, SHA-256 validation and cache publication;
a DMG additionally needs a UDIF trailer. This uses existing dependencies.
Reuse the existing schema-1 prepare/commit/result/workspace transaction. The
macOS coordinator remains in `update_download::handoff::macos`; the platform
adapter owns plist, signature, process identity, mounting and atomic filesystem
operations. No UI module owns installation persistence or invokes shell commands.
Copy the current executable into its private transaction directory and enter a
non-GUI helper mode before normal startup. Validate every plan's configuration,
installation, package, version and participant identity. A lifetime lock beside
the app survives replacement and is shared across configuration overrides. Hash
the bundle filename for an extension-free lock base; the existing lock API remains
the authority for its final filename. PID plus native creation time avoids PID
reuse. Refuse updates while another process executes the same installed binary.
Mount the verified DMG read-only in a private transaction directory. Copy its
single top-level app beside the installed app, then validate bundle ID, plist and
executable versions, native architecture and strict code signature. A Developer
ID signed installation also requires the same signing team. Existing ad-hoc
packages remain supported. Unmount before reporting readiness.
Only commit authorizes installation. The application saves its workspace and
exits through the existing shutdown flow. The helper waits for that exact process
to stop, rechecks that the old executable has not changed and that no other copy
started, then atomically swaps same-volume bundle directories with `RENAME_SWAP`.
The complete old app remains at the unique hidden staging path as a backup.
Persist the result before launching the new app with the existing restore ticket.
If launch fails or exits unsuccessfully during the immediate two-second check,
reacquire the installation lock, ensure no copy is running, exchange back and
restart the original with a recovery ticket. Later crashes are not classified by
this bounded launch check; the retained backup and durable workspace remain.
Cancelled or rejected transactions keep the original application running. Helpers
have bounded preparation, commit and exit waits and never kill the user's process.
The platform adapter also owns helper materialization and launch selection. The
shared transaction requests a helper through one platform-independent entry,
without adding operating-system branches to prepare/commit orchestration.
Native command output is spooled to temporary files while enforcing the existing
process deadline, then read with a 2 MiB limit per stream. Polling a child before
reading piped output can otherwise block both processes once a pipe fills. A
native regression produces 256 KiB on each stream and checks command failure.
## Rejected alternatives
- Replacing a running bundle: risks mixed resources and incomplete session saves.
- Removing the original before copying: leaves no working app after interruption.
- Shell interpolation / privileged installer: unnecessary for a writable app
directory; paths are separate arguments and no elevation is requested.
- Installing an unverified package when metadata is unavailable: retain the
existing manual download path instead.
- A second session format: reuse the shared update restore/acknowledgement flow.
## Consequences
Only packaged official-identity apps in writable locations support installation.
Developer binaries, read-only disk-image launches and App Translocation return an
actionable preparation error while keeping the app open. Preparation is background
work; scheduled installation uses the existing pre-window startup path. The old
bundle is retained until manually removed; no backup-retention policy is added.
Windows retains its existing installer helper. Linux retains manual installation.
No release assets, version tags, signing settings or distribution channels change.
## Validation
Native tests create disposable ad-hoc-signed Mach-O apps and real HFS+ DMGs. They
exercise the actual copied helper in successful replacement/relaunch, immediate
launch failure and rollback, cancellation, SHA-256 rejection and a second running
instance. Tests also cover atomic swap rollback, process identity, developer-path
rejection, native asset selection, DMG trailer validation and shared restore ticket
success/recovery/acknowledgement/crash-loop limits. All passed on macOS arm64. A separate packaged debug build with the existing
loopback-only update-test-source feature also completed the native UI flow:
verified DMG download, Restart and install, replacement, reopened terminal window,
and persisted `success: true` plus `restored.json`. This rehearsal used isolated
settings and same-version reinstall, which normal builds do not permit.
The isolated i18n contract passed, including zero-allocation lookup. Architecture
validation against the actual PR base passed without budget changes. Intel macOS,
Windows and Linux execution, Developer ID team migration and notarization remain
outside local runtime acceptance and need their platform CI/release validation.
## Supersedes
None. Extends the existing shared update handoff to a native macOS participant.
## Revisit when
Distribution changes to a notarized update feed, privileged installation becomes
a supported requirement, or backup retention / longer startup health acknowledgement
is given an explicit product policy.
+6 -1
View File
@@ -783,6 +783,10 @@
"too_large": "The background image exceeds the memory limit. Choose a smaller image."
},
"update": {
"downloading_hint": "Downloading and verifying the update package for this device.",
"no_verified_package": "This release has no verified update package for this device. Open the Releases page to download it manually.",
"check_fallback_failed": "GitHub API access is restricted (HTTP {status}), and the official Releases page could not be reached: {error}. Retry later or open the Releases page.",
"check_unrecognized_release": "The official Releases page did not identify a supported stable version. Retry later or open the Releases page.",
"installation_failed": "Update installation failed",
"installation_failed_hint": "The update did not complete. Your original session snapshot is retained. Review the error, recheck the package, then confirm installation again.",
"recheck_package": "Recheck package",
@@ -799,8 +803,9 @@
},
"auto_download": "Download updates in the background",
"stop_download": "Stop download",
"skip_version": "Skip version",
"restart_install": "Restart and install",
"install_next_launch": "Install on next launch",
"install_next_launch": "Install on launch",
"ready_hint": "The update is ready. Pebrel will save your workspace and exit before installation, then reopen it.",
"later": "Later",
"prepare_failed": "Could not prepare the update: {error}. Pebrel will stay open.",
+7
View File
@@ -178,6 +178,13 @@
"paste_lines": "{lines}줄을 붙여넣을까요?",
"sent_to": "{target}(으)로 전송했습니다"
},
"update": {
"skip_version": "이 버전 건너뛰기",
"downloading_hint": "이 기기에 맞는 업데이트 패키지를 다운로드하고 검증하는 중입니다.",
"no_verified_package": "이 릴리스에는 이 기기에 맞는 검증된 업데이트 패키지가 없습니다. 릴리스 페이지에서 직접 다운로드하세요.",
"check_fallback_failed": "GitHub API 접근이 제한되었으며(HTTP {status}) 공식 릴리스 페이지에도 연결할 수 없습니다: {error}. 나중에 다시 시도하거나 릴리스 페이지를 여세요.",
"check_unrecognized_release": "공식 릴리스 페이지에서 지원되는 안정 버전을 확인할 수 없습니다. 나중에 다시 시도하거나 릴리스 페이지를 여세요."
},
"tab_menu": {
"fork_ai_session": "AI 세션 분기",
"move_to_new_window": "새 창으로 이동",
+6 -1
View File
@@ -783,6 +783,10 @@
"too_large": "背景图超出内存限制,请选择尺寸更小的图片。"
},
"update": {
"downloading_hint": "正在后台下载并校验适用于此设备的更新安装包。",
"no_verified_package": "此版本没有适用于此设备的可验证安装包,请打开发布页手动下载。",
"check_fallback_failed": "GitHub API 访问受限(HTTP {status}),备用发布页查询也失败了:{error}。请稍后重试或打开发布页。",
"check_unrecognized_release": "官方发布页未返回可识别的稳定版本,请稍后重试或打开发布页。",
"installation_failed": "更新安装失败",
"installation_failed_hint": "更新未完成,原会话快照仍保留。检查错误后可重新校验安装包,再确认安装。",
"recheck_package": "重新校验安装包",
@@ -799,9 +803,10 @@
},
"auto_download": "在后台预下载更新",
"stop_download": "停止下载",
"skip_version": "跳过此版本",
"restart_install": "重启并安装",
"install_next_launch": "下次启动时安装",
"ready_hint": "更新已就绪。Pebrel 将先保存工作区并退出,再安装更新并重新打开。",
"ready_hint": "更新已就绪,将保存工作区、安装更新并重启。",
"later": "稍后",
"prepare_failed": "更新准备失败:{error}。Pebrel 将保持打开。",
"draft_changed": "准备退出期间文档发生了变化。请保存或放弃最新修改后重试。",
@@ -203,7 +203,7 @@ pub(crate) fn open_update_dialog(
let latest_version: SharedString = format!("v{}", dialog_result.latest).into();
let later_text: SharedString =
language.text(Message::UpdateLater).into();
let skip_text: SharedString = language.pick("跳过此版本", "Skip this version").into();
let skip_text: SharedString = language.text(Message::UpdateSkipVersion).into();
let muted = cx.theme().muted_foreground;
let latest_color = cx.theme().warning;
let version_background = cx.theme().muted;
@@ -226,12 +226,7 @@ pub(crate) fn open_update_dialog(
};
let hint: SharedString = match (&status, asset.as_ref()) {
(DownloadStatus::Downloading { .. }, _) => language
.pick(
"正在后台下载并校验 Windows x64 安装包。",
"Downloading and verifying the Windows x64 installer in the background.",
)
.into(),
(DownloadStatus::Downloading { .. }, _) => language.text(Message::UpdateDownloadingHint).into(),
(DownloadStatus::Ready { .. }, _) => language.text(Message::UpdateReadyHint).into(),
(DownloadStatus::InstallFailed(_), _) => language.text(Message::UpdateInstallationFailedHint).into(),
(DownloadStatus::Failed(_), _) => language
@@ -246,20 +241,14 @@ pub(crate) fn open_update_dialog(
"Pebrel will download and verify the installer, then wait for your confirmation before installing.",
)
.into(),
// 非 Windows 目前没有自动安装路径(能力表 `self_update_install`):
// 不说「缺 Windows 安装包」,那对 Mac/Linux 用户是句错话。
// Platforms without an installation adapter retain manual downloads.
_ if !crate::platform::CAPABILITIES.self_update_install => language
.pick(
"此平台暂不支持应用内自动更新;请到发布页下载对应的安装包。",
"In-app automatic updates are not available on this platform yet. Download the matching package from the Releases page.",
)
.into(),
_ => language
.pick(
"此 release 没有可验证的 Windows x64 安装包,已禁用自动执行;可打开发布页手动处理。",
"This release has no verifiable Windows x64 installer, so automatic execution is disabled. Use the Releases page instead.",
)
.into(),
_ => language.text(Message::UpdateNoVerifiedPackage).into(),
};
let primary_text: SharedString = match status {
@@ -371,6 +360,7 @@ pub(crate) fn open_update_dialog(
let cancel_save_failed_prefix = save_failed_prefix.to_owned();
let cancel_error_separator = error_separator.to_owned();
let mut footer = DialogFooter::new()
.gap(px(6.0))
.child(
Button::new("skip-nebula-update")
.label(skip_text)
+4
View File
@@ -166,6 +166,10 @@ use crate::macos::locale;
use crate::polling::{IoListener, ipc};
fn main() -> Result<(), Box<dyn Error>> {
#[cfg(all(target_os = "macos", feature = "gpui-shell"))]
if let Some(code) = update_download::handoff::macos::run_helper_if_requested() {
std::process::exit(code);
}
if let Some(code) = platform::ai_session_identity::run_helper_if_requested() {
std::process::exit(code);
}
+3 -3
View File
@@ -21,7 +21,7 @@ pub struct Capabilities {
pub system_notifications: bool,
/// 系统提示音(`platform::beep`)。
pub system_bell: bool,
/// 下载并静默运行安装包完成更新;其余平台只能打开 Releases 页。
/// Verified Windows installer or macOS bundle replacement is implemented.
pub self_update_install: bool,
/// 自动往 Claude / Codex 配置里写入 hook 与 skill(`crate::ai_hook`)。
pub ai_hook_server: bool,
@@ -60,7 +60,7 @@ pub const CAPABILITIES: Capabilities = {
launch_at_login: false,
system_notifications: true,
system_bell: false,
self_update_install: false,
self_update_install: cfg!(target_os = "macos"),
ai_hook_server: false,
quick_terminal_hotkey: false,
system_font_enumeration: true,
@@ -98,7 +98,7 @@ mod tests {
assert!(!CAPABILITIES.hide_window_on_close);
assert!(!CAPABILITIES.system_tray);
assert!(!CAPABILITIES.system_bell);
assert!(!CAPABILITIES.self_update_install);
assert_eq!(CAPABILITIES.self_update_install, cfg!(target_os = "macos"));
assert!(!CAPABILITIES.ai_hook_server);
assert!(!CAPABILITIES.quick_terminal_hotkey);
assert!(!CAPABILITIES.shell_context_menu);
+51 -3
View File
@@ -1,9 +1,12 @@
//! Native paths and process identity for the Windows installer handoff.
//! Native paths and process identity for the platform installer handoff.
//! Transaction persistence and commit authority remain in `update_download`.
use std::io;
use std::path::{Path, PathBuf};
use std::process::Child;
#[cfg(target_os = "macos")]
pub(crate) mod macos;
pub(crate) fn canonical(path: &Path) -> io::Result<PathBuf> {
let path = std::fs::canonicalize(path)?;
#[cfg(windows)]
@@ -19,7 +22,7 @@ pub(crate) fn canonical(path: &Path) -> io::Result<PathBuf> {
Ok(path)
}
/// FILETIME identity is compared with the exact process handle by the helper.
/// Native creation identity prevents a recycled PID from authorizing a handoff.
pub(crate) fn current_process_created() -> io::Result<u64> {
#[cfg(windows)]
{
@@ -38,7 +41,10 @@ pub(crate) fn current_process_created() -> io::Result<u64> {
}
Ok((u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime))
}
#[cfg(not(windows))]
#[cfg(target_os = "macos")]
return macos::process_created(std::process::id())?
.ok_or_else(|| io::Error::other("Process identity unavailable"));
#[cfg(not(any(windows, target_os = "macos")))]
Err(io::Error::new(io::ErrorKind::Unsupported, "Windows process identity is unavailable"))
}
@@ -70,3 +76,45 @@ pub(crate) fn spawn_helper(helper: &Path, plan: &Path) -> io::Result<Child> {
Err(io::Error::new(io::ErrorKind::Unsupported, "Windows installation is unavailable"))
}
}
pub(crate) fn installation_directory(executable: &Path) -> Result<PathBuf, String> {
#[cfg(target_os = "macos")]
return macos::bundle(executable);
#[cfg(not(target_os = "macos"))]
{
let directory = executable.parent().ok_or("Missing application directory")?;
if !directory.join("unins000.exe").is_file() {
return Err(
"This copy is portable. Use the download page to replace its package.".into()
);
}
Ok(directory.to_owned())
}
}
pub(crate) fn guard_base(executable: &Path) -> PathBuf {
#[cfg(target_os = "macos")]
if let Ok(bundle) = macos::bundle(executable) {
if let (Some(parent), Some(name)) = (bundle.parent(), bundle.file_name()) {
use sha2::{Digest as _, Sha256};
use std::os::unix::ffi::OsStrExt as _;
let identity: String =
Sha256::digest(name.as_bytes()).iter().map(|byte| format!("{byte:02x}")).collect();
return parent.join(format!(".pebrel-update-{identity}"));
}
}
executable.parent().expect("canonical executable parent").join(".pebrel-update")
}
/// Select and materialize the native helper; transaction authority stays with the caller.
pub(crate) fn spawn_prepared_helper(directory: &Path, plan: &Path) -> Result<Child, String> {
#[cfg(target_os = "macos")]
return macos::spawn(directory, plan);
#[cfg(not(target_os = "macos"))]
{
let helper = directory.join("handoff.ps1");
crate::atomic_file::write(&helper, include_bytes!("../update_download/handoff.ps1"))
.map_err(|error| error.to_string())?;
spawn_helper(&helper, plan).map_err(|error| error.to_string())
}
}
@@ -0,0 +1,293 @@
//! macOS bundle I/O. Transaction authorization stays in update_download::handoff.
use std::ffi::{CStr, CString};
use std::io;
use std::os::unix::ffi::OsStrExt as _;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};
pub(crate) fn spawn(directory: &Path, plan: &Path) -> Result<Child, String> {
let helper = directory.join("handoff");
std::fs::copy(std::env::current_exe().map_err(|e| e.to_string())?, &helper)
.map_err(|e| e.to_string())?;
Command::new(helper)
.arg("--internal-macos-update")
.arg(plan)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| e.to_string())
}
pub(crate) fn run(command: &mut Command) -> Result<Output, String> {
// Regular files cannot fill a pipe while we poll for process completion.
let stdout = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?;
let stderr = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?;
let mut child = command
.stdin(Stdio::null())
.stdout(stdout.reopen().map_err(|e| e.to_string())?)
.stderr(stderr.reopen().map_err(|e| e.to_string())?)
.spawn()
.map_err(|e| e.to_string())?;
let deadline = Instant::now() + Duration::from_secs(120);
while child.try_wait().map_err(|e| e.to_string())?.is_none() {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err("macOS update command timed out".into());
}
std::thread::sleep(Duration::from_millis(25));
}
let read_output = |file: &tempfile::NamedTempFile| -> Result<Vec<u8>, String> {
if file.as_file().metadata().map_err(|e| e.to_string())?.len() > 2 * 1024 * 1024 {
return Err("macOS update command output exceeded its limit".into());
}
std::fs::read(file.path()).map_err(|e| e.to_string())
};
let output = Output {
status: child.wait().map_err(|e| e.to_string())?,
stdout: read_output(&stdout)?,
stderr: read_output(&stderr)?,
};
if !output.status.success() {
return Err(format!(
"macOS update command failed ({}): {}",
output.status,
String::from_utf8_lossy(&output.stderr).chars().take(2048).collect::<String>()
));
}
Ok(output)
}
pub(crate) fn property(bundle: &Path, key: &str) -> Result<String, String> {
let result = run(Command::new("/usr/bin/plutil")
.args(["-extract", key, "raw", "-o", "-"])
.arg(bundle.join("Contents/Info.plist")))?;
String::from_utf8(result.stdout).map(|s| s.trim().to_owned()).map_err(|e| e.to_string())
}
pub(crate) fn bundle(executable: &Path) -> Result<PathBuf, String> {
let macos = executable.parent().ok_or("Missing executable directory")?;
let contents = macos.parent().ok_or("Missing bundle contents")?;
let bundle = contents.parent().ok_or("Missing application bundle")?;
if executable.file_name().is_none_or(|n| n != "pebrel")
|| macos.file_name().is_none_or(|n| n != "MacOS")
|| contents.file_name().is_none_or(|n| n != "Contents")
|| bundle.extension().is_none_or(|e| e != "app")
{
return Err("Automatic installation requires a packaged Pebrel .app. Install it from the Releases page first.".into());
}
if !matches!(
property(bundle, "CFBundleIdentifier")?.as_str(),
"io.github.kuddev.pebrel" | "io.github.kuddev.pebrel.preview"
) || property(bundle, "CFBundleExecutable")? != "pebrel"
{
return Err("This is not an official Pebrel application bundle".into());
}
if bundle.starts_with("/Volumes")
|| bundle.components().any(|p| p.as_os_str() == "AppTranslocation")
{
return Err(
"Move Pebrel to a writable Applications folder before installing updates".into()
);
}
Ok(bundle.to_owned())
}
pub(crate) fn process_created(pid: u32) -> io::Result<Option<u64>> {
// SAFETY: buffer is an initialized proc_bsdinfo of exactly the requested size.
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
let size = std::mem::size_of_val(&info) as i32;
let read = unsafe {
libc::proc_pidinfo(
pid as i32,
libc::PROC_PIDTBSDINFO,
0,
(&mut info as *mut libc::proc_bsdinfo).cast(),
size,
)
};
if read == size {
// Zombies have already stopped using their application bundle.
if info.pbi_status == 5 {
return Ok(None);
}
return Ok(Some(info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec));
}
let error = io::Error::last_os_error();
if error.raw_os_error() == Some(libc::ESRCH) { Ok(None) } else { Err(error) }
}
pub(crate) fn running_copies(executable: &Path) -> Result<Vec<u32>, String> {
// proc_listallpids returns a count, unlike proc_listpids' byte count.
let count = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
if count <= 0 {
return Err("Could not inspect running applications".into());
}
let mut pids = vec![0i32; count as usize + 1024];
let read = unsafe {
libc::proc_listallpids(
pids.as_mut_ptr().cast(),
(pids.len() * std::mem::size_of::<i32>()) as i32,
)
};
if read <= 0 || read as usize >= pids.len() {
return Err("Process snapshot was incomplete".into());
}
let mut result = Vec::new();
for pid in pids.into_iter().take(read as usize).filter(|pid| *pid > 0) {
let mut buffer = vec![0u8; 4096];
let read =
unsafe { libc::proc_pidpath(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) };
if read > 0 {
let path = CStr::from_bytes_until_nul(&buffer).map_err(|e| e.to_string())?;
if Path::new(std::ffi::OsStr::from_bytes(path.to_bytes())) == executable {
result.push(pid as u32);
}
}
}
Ok(result)
}
pub(crate) fn exchange(first: &Path, second: &Path) -> Result<(), String> {
let first = CString::new(first.as_os_str().as_bytes()).map_err(|e| e.to_string())?;
let second = CString::new(second.as_os_str().as_bytes()).map_err(|e| e.to_string())?;
// Same-volume atomic swap: the original app never disappears, even on crash.
let result = unsafe {
libc::renameatx_np(
libc::AT_FDCWD,
first.as_ptr(),
libc::AT_FDCWD,
second.as_ptr(),
libc::RENAME_SWAP,
)
};
if result != 0 {
return Err(io::Error::last_os_error().to_string());
}
Ok(())
}
pub(crate) fn verify_bundle(bundle: &Path, version: &str) -> Result<(), String> {
let id = property(bundle, "CFBundleIdentifier")?;
if !matches!(id.as_str(), "io.github.kuddev.pebrel" | "io.github.kuddev.pebrel.preview")
|| property(bundle, "CFBundleExecutable")? != "pebrel"
|| property(bundle, "CFBundleShortVersionString")? != version
{
return Err("The downloaded bundle identity or version does not match the release".into());
}
let executable = bundle.join("Contents/MacOS/pebrel");
if std::fs::canonicalize(&executable).map_err(|e| e.to_string())? != executable {
return Err("The bundle executable must not redirect outside the application".into());
}
run(Command::new("/usr/bin/codesign").args(["--verify", "--deep", "--strict"]).arg(bundle))?;
let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x86_64" };
let architectures = run(Command::new("/usr/bin/lipo").arg("-archs").arg(&executable))?;
if !String::from_utf8_lossy(&architectures.stdout).split_whitespace().any(|v| v == arch) {
return Err("The update does not contain this Mac's architecture".into());
}
let output = run(Command::new(&executable).arg("--version"))?;
let output = String::from_utf8_lossy(&output.stdout);
if output.split_whitespace().nth(1) != Some(version) {
return Err("The update executable reports a different version".into());
}
Ok(())
}
fn team(bundle: &Path) -> Result<Option<String>, String> {
let output = run(Command::new("/usr/bin/codesign").args(["-d", "--verbose=4"]).arg(bundle))?;
Ok(String::from_utf8_lossy(&output.stderr)
.lines()
.find_map(|line| line.strip_prefix("TeamIdentifier="))
.filter(|team| *team != "not set")
.map(str::to_owned))
}
pub(crate) fn stage(
installer: &Path,
original: &Path,
staged: &Path,
mount: &Path,
version: &str,
) -> Result<(), String> {
std::fs::create_dir(mount).map_err(|e| e.to_string())?;
let outcome: Result<(), String> = (|| {
run(Command::new("/usr/bin/hdiutil")
.args(["attach", "-readonly", "-nobrowse", "-noautoopen", "-verify", "-mountpoint"])
.arg(mount)
.arg(installer))?;
let bundles: Vec<_> = std::fs::read_dir(mount)
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?
.into_iter()
.map(|entry| entry.path())
.filter(|path| {
path.extension().is_some_and(|e| e == "app") && path.is_dir() && !path.is_symlink()
})
.collect();
if bundles.len() != 1 {
return Err("The disk image must contain exactly one application".into());
}
// Copy before checking/executing; all subsequent verification uses the local staged tree.
run(Command::new("/usr/bin/ditto").arg(&bundles[0]).arg(staged))?;
if let Some(expected) = team(original)?
&& team(staged)?.as_deref() != Some(expected.as_str())
{
return Err("The update's signing team differs from the installed application".into());
}
verify_bundle(staged, version)?;
Ok(())
})();
let detached = run(Command::new("/usr/bin/hdiutil").args(["detach"]).arg(mount));
let _ = std::fs::remove_dir(mount);
outcome?;
detached?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_output_larger_than_a_pipe_is_drained() {
let output = run(Command::new("/bin/sh")
.args(["-c", "head -c 262144 /dev/zero; head -c 262144 /dev/zero >&2"]))
.unwrap();
assert_eq!(output.stdout.len(), 262144);
assert_eq!(output.stderr.len(), 262144);
assert!(run(Command::new("/bin/sh").args(["-c", "exit 7"])).is_err());
}
#[test]
fn atomic_exchange_keeps_a_complete_original_and_can_roll_back() {
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join("Pebrel.app");
let new = dir.path().join("staged.app");
std::fs::create_dir(&old).unwrap();
std::fs::create_dir(&new).unwrap();
std::fs::write(old.join("marker"), "old").unwrap();
std::fs::write(new.join("marker"), "new").unwrap();
exchange(&old, &new).unwrap();
assert_eq!(std::fs::read_to_string(old.join("marker")).unwrap(), "new");
assert_eq!(std::fs::read_to_string(new.join("marker")).unwrap(), "old");
exchange(&old, &new).unwrap();
assert_eq!(std::fs::read_to_string(old.join("marker")).unwrap(), "old");
assert!(exchange(&old, &dir.path().join("missing")).is_err());
assert_eq!(std::fs::read_to_string(old.join("marker")).unwrap(), "old");
}
#[test]
fn developer_executables_are_not_treated_as_installable_bundles() {
assert!(bundle(Path::new("/tmp/target/debug/pebrel")).is_err());
assert!(bundle(Path::new("/tmp/Pebrel.app/Contents/MacOS/not-pebrel")).is_err());
}
#[test]
fn process_identity_and_executable_inspection_find_this_process() {
let created = process_created(std::process::id()).unwrap().unwrap();
assert!(created > 0);
assert_eq!(process_created(std::process::id()).unwrap(), Some(created));
let executable = std::fs::canonicalize(std::env::current_exe().unwrap()).unwrap();
assert!(running_copies(&executable).unwrap().contains(&std::process::id()));
}
}
+32 -31
View File
@@ -22,6 +22,9 @@ pub const RELEASES_PAGE: &str = "https://github.com/Kuddev/pebrel/releases";
const UPDATE_STATE_FILE: &str = "update_state.json";
const REMIND_LATER_SECS: u64 = 3 * 24 * 60 * 60;
pub(crate) mod assets;
mod fallback;
#[cfg(feature = "update-test-source")]
pub(crate) mod test_source;
@@ -291,18 +294,34 @@ fn fetch_latest_release() -> Result<LatestRelease, String> {
return fetch_release_with_agent(&agent, &url);
}
let agent = crate::update_proxy::agent(RELEASES_API, Duration::from_secs(10));
fetch_release_with_agent(&agent, RELEASES_API)
fetch_release_with_fallback(&agent, RELEASES_API, fallback::fetch_latest)
}
#[cfg(any(test, feature = "update-test-source"))]
fn fetch_release_with_agent(agent: &ureq::Agent, url: &str) -> Result<LatestRelease, String> {
let bytes = agent
// Explicit local rehearsals must never fall back to the public network.
fetch_release_with_fallback(agent, url, |status| Err(format!("GitHub HTTP {status}")))
}
fn fetch_release_with_fallback(
agent: &ureq::Agent,
url: &str,
on_rate_limit: impl FnOnce(u16) -> Result<LatestRelease, String>,
) -> Result<LatestRelease, String> {
let response = agent
.get(url)
.header("User-Agent", "pebrel")
.header("Accept", "application/vnd.github+json")
.call()
.and_then(|mut response| {
response.body_mut().with_config().limit(2 * 1024 * 1024).read_to_vec()
})
.call();
let mut response = match response {
Err(ureq::Error::StatusCode(status @ (403 | 429))) => return on_rate_limit(status),
other => other.map_err(|error| format!("GitHub 请求失败:{error}"))?,
};
let bytes = response
.body_mut()
.with_config()
.limit(2 * 1024 * 1024)
.read_to_vec()
.map_err(|error| format!("GitHub 请求失败:{error}"))?;
parse_latest_release(&bytes)
}
@@ -315,15 +334,12 @@ fn parse_latest_release(bytes: &[u8]) -> Result<LatestRelease, String> {
return Err("GitHub release 的版本号为空".to_owned());
}
let version = version.to_owned();
let asset = if cfg!(all(windows, target_arch = "x86_64")) {
select_windows_x64_installer(
&version,
release.body.as_deref().unwrap_or_default(),
release.assets,
)
} else {
None
};
let asset = assets::select(
&version,
release.body.as_deref().unwrap_or_default(),
&release.assets,
&assets::native_names(&version),
);
Ok(LatestRelease { version, asset })
}
@@ -340,22 +356,7 @@ fn select_windows_x64_installer(
release_body: &str,
assets: Vec<GitHubReleaseAsset>,
) -> Option<UpdateAsset> {
let selected = windows_x64_installer_names(version)
.iter()
.find_map(|name| assets.iter().position(|asset| asset.name == *name))?;
let asset = assets.into_iter().nth(selected)?;
let sha256 = asset
.digest
.as_deref()
.and_then(normalize_sha256)
.or_else(|| checksum_from_release_body(release_body, &asset.name));
Some(UpdateAsset {
version: version.to_owned(),
name: asset.name,
download_url: asset.browser_download_url,
size: (asset.size > 0).then_some(asset.size),
sha256,
})
assets::select(version, release_body, &assets, &windows_x64_installer_names(version))
}
fn normalize_sha256(value: &str) -> Option<String> {
+106
View File
@@ -0,0 +1,106 @@
//! Exact package names shared by release discovery and download validation.
use super::{GitHubReleaseAsset, UpdateAsset, checksum_from_release_body, normalize_sha256};
use crate::platform::Platform;
pub(crate) fn macos_names(version: &str, architecture: &str) -> Vec<String> {
let architecture = match architecture {
"aarch64" => "arm64",
"x86_64" => "x64",
_ => return Vec::new(),
};
vec![
format!("Pebrel-v{version}-macos-{architecture}.dmg"),
format!("Pebrel-v{version}-macos-{architecture}-preview.dmg"),
]
}
pub(crate) fn native_names(version: &str) -> Vec<String> {
match Platform::current() {
Platform::MacOS => macos_names(version, std::env::consts::ARCH),
Platform::Windows if std::env::consts::ARCH == "x86_64" => {
super::windows_x64_installer_names(version).to_vec()
},
_ => Vec::new(),
}
}
pub(super) fn select(
version: &str,
body: &str,
assets: &[GitHubReleaseAsset],
names: &[String],
) -> Option<UpdateAsset> {
let asset = names.iter().find_map(|name| assets.iter().find(|asset| asset.name == *name))?;
Some(UpdateAsset {
version: version.into(),
name: asset.name.clone(),
download_url: asset.browser_download_url.clone(),
size: (asset.size > 0).then_some(asset.size),
sha256: asset
.digest
.as_deref()
.and_then(normalize_sha256)
.or_else(|| checksum_from_release_body(body, &asset.name)),
})
}
pub(super) fn from_checksums(version: &str, text: &str) -> Option<UpdateAsset> {
native_names(version).iter().find_map(|name| {
let hashes: Vec<_> = text
.lines()
.filter_map(|line| {
let (hash, file) = line.split_once(char::is_whitespace)?;
(file.trim().trim_start_matches('*') == name)
.then(|| normalize_sha256(&format!("sha256:{hash}")))
.flatten()
})
.collect();
if hashes.len() != 1 {
return None;
}
Some(UpdateAsset {
version: version.into(),
name: name.clone(),
download_url: format!(
"https://github.com/Kuddev/pebrel/releases/download/v{version}/{name}"
),
size: None,
sha256: hashes.into_iter().next(),
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn macos_architectures_and_preference_are_exact() {
assert!(macos_names("1.9.0", "unknown").is_empty());
for (arch, expected) in [("aarch64", "arm64"), ("x86_64", "x64")] {
let names = macos_names("1.9.0", arch);
assert_eq!(names[0], format!("Pebrel-v1.9.0-macos-{expected}.dmg"));
assert_eq!(names[1], format!("Pebrel-v1.9.0-macos-{expected}-preview.dmg"));
let assets = names
.iter()
.rev()
.map(|name| GitHubReleaseAsset {
name: name.clone(),
browser_download_url: "https://example.invalid".into(),
size: 42,
digest: Some(format!("sha256:{}", "a".repeat(64))),
})
.collect::<Vec<_>>();
assert_eq!(select("1.9.0", "", &assets, &names).unwrap().name, names[0]);
}
}
#[test]
fn checksum_manifest_requires_one_exact_valid_entry() {
let Some(name) = native_names("1.9.0").into_iter().next() else { return };
let entry = format!("{} {name}\n", "a".repeat(64));
assert!(from_checksums("1.9.0", &entry).is_some());
assert!(from_checksums("1.9.0", &(entry.clone() + &entry)).is_none());
assert!(from_checksums("1.9.0", &format!("bad {name}")).is_none());
assert!(from_checksums("1.9.0", &entry.replace(&name, &(name.clone() + ".bak"))).is_none());
assert!(from_checksums("1.9.1", &entry).is_none());
}
}
+186
View File
@@ -0,0 +1,186 @@
//! GitHub's public latest-release redirect is independent of the REST API quota.
//! The redirect proves a version; the official SHA256SUMS asset can then supply
//! verified package metadata. If absent, retain manual download only.
use std::time::Duration;
use ureq::ResponseExt as _;
use super::LatestRelease;
use crate::i18n::{LanguagePreference, Message};
const LATEST_PAGE: &str = "https://github.com/Kuddev/pebrel/releases/latest";
const TAG_PREFIX: &str = "https://github.com/Kuddev/pebrel/releases/tag/";
pub(super) fn fetch_latest(status: u16) -> Result<LatestRelease, String> {
log::info!("update-check: API returned HTTP {status}; checking official latest-release page");
// Resolve proxy settings for github.com independently of api.github.com.
let agent = crate::update_proxy::agent(LATEST_PAGE, Duration::from_secs(10));
let language =
LanguagePreference::from(nebula_settings::RuntimeSettings::load().language).resolved();
let uri = redirected_uri(&agent, LATEST_PAGE).map_err(|error| {
language.format(
Message::UpdateCheckFallbackFailed,
&[("status", &status.to_string()), ("error", &error.to_string())],
)
})?;
let mut release = release_from_uri(&uri)
.ok_or_else(|| language.text(Message::UpdateCheckUnrecognizedRelease).to_owned())?;
// The official manifest restores verified download metadata during API limits.
// If it is unavailable, version discovery still works with manual download.
let url = format!(
"https://github.com/Kuddev/pebrel/releases/download/v{}/SHA256SUMS",
release.version
);
let agent = crate::update_proxy::agent(&url, Duration::from_secs(10));
if let Ok(mut response) = agent.get(&url).header("User-Agent", "pebrel").call()
&& let Ok(text) = response.body_mut().with_config().limit(64 * 1024).read_to_string()
{
release.asset = super::assets::from_checksums(&release.version, &text);
}
Ok(release)
}
fn redirected_uri(agent: &ureq::Agent, url: &str) -> Result<String, ureq::Error> {
let response = agent
.get(url)
.header("User-Agent", "pebrel")
.header("Accept", "text/html")
.config()
.max_redirects(5)
.build()
.call()?;
// The final response URI is supplied by the HTTP client, not by untrusted
// markup. No HTML body needs to be downloaded or parsed.
Ok(response.get_uri().to_string())
}
fn release_from_uri(uri: &str) -> Option<LatestRelease> {
let tag = uri.strip_prefix(TAG_PREFIX)?;
let version = tag.strip_prefix(['v', 'V']).unwrap_or(tag);
// Official stable releases use major.minor.patch. Reject login pages,
// arbitrary tags, extra path/query/fragment components and preview tags.
let components: Vec<_> = version.split('.').collect();
if components.len() != 3
|| components.iter().any(|part| {
part.is_empty()
|| !part.bytes().all(|byte| byte.is_ascii_digit())
|| part.parse::<u64>().is_err()
|| (part.len() > 1 && part.starts_with('0'))
})
{
return None;
}
Some(LatestRelease { version: version.to_owned(), asset: None })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::update_proxy::test_support::{Server, response};
#[test]
fn rate_limited_api_falls_back_and_discovers_an_upgrade_without_install_authority() {
for status in ["403 Forbidden", "429 Too Many Requests"] {
let server = Server::start(vec![response(status, "", "API rate limit exceeded")]);
let result = super::super::fetch_release_with_fallback(
&server.agent(&[]),
"http://api.update.invalid/latest",
|code| {
assert!(matches!(code, 403 | 429));
Ok(release_from_uri(&format!("{TAG_PREFIX}v1.9.0")).unwrap())
},
)
.unwrap();
assert!(super::super::is_newer(&result.version, "1.8.2"));
assert!(!super::super::is_newer(&result.version, "1.9.0"));
assert!(result.asset.is_none(), "a version redirect grants no install authority");
assert_eq!(server.finish().len(), 1);
}
}
#[test]
fn successful_api_and_other_errors_never_call_the_fallback() {
for (status, body, succeeds) in [
("200 OK", r#"{"tag_name":"v1.9.0","assets":[]}"#, true),
("200 OK", "not JSON", false),
("404 Not Found", "missing release", false),
("500 Internal Server Error", "server error", false),
] {
let server = Server::start(vec![response(status, "", body)]);
let result = super::super::fetch_release_with_fallback(
&server.agent(&[]),
"http://api.update.invalid/latest",
|_| panic!("fallback is only for access/rate limiting"),
);
assert_eq!(result.is_ok(), succeeds);
server.finish();
}
}
#[test]
fn failed_fallback_stays_an_error_instead_of_claiming_up_to_date() {
let server = Server::start(vec![response("403 Forbidden", "", "rate limited")]);
let result = super::super::fetch_release_with_fallback(
&server.agent(&[]),
"http://api.update.invalid/latest",
|_| Err("release page unavailable".into()),
);
assert_eq!(result.unwrap_err(), "release page unavailable");
server.finish();
}
#[test]
fn redirect_transport_uses_the_final_location_without_parsing_html() {
// The local CONNECT fixture uses HTTP; production agent requires HTTPS.
let destination = "http://github.com/Kuddev/pebrel/releases/tag/v1.9.0";
let server = Server::start(vec![
response("302 Found", &format!("Location: {destination}\r\n"), ""),
response("200 OK", "Content-Type: text/html\r\n", "<html>no metadata</html>"),
]);
let uri =
redirected_uri(&server.agent(&[]), "http://github.com/Kuddev/pebrel/releases/latest")
.unwrap();
assert_eq!(uri, destination);
let requests = server.finish();
assert_eq!(requests.len(), 2);
assert!(requests[0].1.contains("/releases/latest"));
assert!(requests[1].1.contains("/releases/tag/v1.9.0"));
}
#[test]
fn only_exact_official_stable_tag_locations_are_accepted() {
for tag in ["v1.9.0", "V1.9.0", "1.9.0"] {
assert_eq!(release_from_uri(&format!("{TAG_PREFIX}{tag}")).unwrap().version, "1.9.0");
}
for uri in [
LATEST_PAGE,
"https://github.com/login",
"https://github.com/Other/pebrel/releases/tag/v99.0.0",
"http://github.com/Kuddev/pebrel/releases/tag/v1.9.0",
"https://github.com.evil.invalid/Kuddev/pebrel/releases/tag/v1.9.0",
"https://github.com/Kuddev/pebrel/releases/tag/v1.9.0?foo=bar",
"https://github.com/Kuddev/pebrel/releases/tag/v1.9.0#fragment",
"https://github.com/Kuddev/pebrel/releases/tag/v1.9.0/extra",
"https://github.com/Kuddev/pebrel/releases/tag/v1.9.0-rc1",
"https://github.com/Kuddev/pebrel/releases/tag/relay-abcd",
"https://github.com/Kuddev/pebrel/releases/tag/v1..0",
"https://github.com/Kuddev/pebrel/releases/tag/v01.9.0",
"https://github.com/Kuddev/pebrel/releases/tag/v18446744073709551616.0.0",
] {
assert!(release_from_uri(uri).is_none(), "{uri}");
}
}
#[test]
#[ignore = "contacts public GitHub; run explicitly for native network acceptance"]
fn live_update_check_survives_api_rate_limiting() {
let release = super::super::check_now().expect("live public update check");
println!(
"current={} latest={} update_available={}",
release.current, release.latest, release.update_available
);
let fallback = fetch_latest(403).expect("live official release fallback");
assert_eq!(release.latest, fallback.version);
}
}
+66 -8
View File
@@ -1,12 +1,12 @@
//! GPUI 更新安装包的下载、校验与启动。
//!
//! 更新检查只负责提供 release 元数据;本模块再次收紧资产合同,并把大文件
//! 流式写入同目录 `.part` 文件。只有长度、PE 文件头与 SHA-256 全部通过后,
//! 流式写入同目录 `.part` 文件。只有长度、PE 文件头或 DMG 尾标记与 SHA-256 全部通过后,
//! 才原子替换为可启动的安装包,避免中断下载或错误响应变成可执行文件。
use std::fmt::Write as _;
use std::fs::{File, OpenOptions};
use std::io::{Read as _, Write as _};
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, MutexGuard};
@@ -299,6 +299,7 @@ fn download_with_job(
output.sync_all().map_err(|error| format!("同步更新临时文件失败:{error}"))?;
verify_download(downloaded, &pe_header, hasher.finalize(), asset)?;
verify_package_trailer(&mut File::open(partial_path).map_err(|e| e.to_string())?, asset)?;
Ok(downloaded)
}
@@ -365,6 +366,7 @@ fn verify_file(path: &Path, asset: &UpdateAsset) -> Result<u64, String> {
hasher.update(&buffer[..read]);
}
verify_download(bytes, &pe_header, hasher.finalize(), asset)?;
verify_package_trailer(&mut file, asset)?;
Ok(bytes)
}
@@ -377,7 +379,7 @@ fn verify_download(
if bytes == 0 || asset.size.is_some_and(|expected| expected != bytes) {
return Err(format!("安装包长度校验失败(实际 {bytes} 字节)"));
}
if pe_header != b"MZ" {
if !asset.name.ends_with(".dmg") && pe_header != b"MZ" {
return Err("下载内容不是 Windows PE 安装包".to_owned());
}
let expected = asset.sha256.as_deref().ok_or_else(|| "release 未提供 SHA-256".to_owned())?;
@@ -410,14 +412,32 @@ fn download_paths(asset: &UpdateAsset) -> Result<(PathBuf, PathBuf), String> {
Ok((partial_path, final_path))
}
fn validate_asset(asset: &UpdateAsset) -> Result<(), String> {
if !cfg!(all(windows, target_arch = "x86_64")) {
return Err("当前平台没有可用的自动更新安装包".to_owned());
fn verify_package_trailer(file: &mut File, asset: &UpdateAsset) -> Result<(), String> {
if asset.name.ends_with(".dmg") {
let mut signature = [0; 4];
file.seek(SeekFrom::End(-512))
.and_then(|_| file.read_exact(&mut signature))
.map_err(|_| "Invalid macOS disk image trailer".to_owned())?;
if &signature != b"koly" {
return Err("Invalid macOS disk image trailer".into());
}
}
validate_windows_asset_contract(asset)
Ok(())
}
fn validate_asset(asset: &UpdateAsset) -> Result<(), String> {
validate_asset_contract(asset, &crate::update_check::assets::native_names(&asset.version))
}
#[cfg(test)]
fn validate_windows_asset_contract(asset: &UpdateAsset) -> Result<(), String> {
validate_asset_contract(
asset,
&crate::update_check::windows_x64_installer_names(&asset.version),
)
}
fn validate_asset_contract(asset: &UpdateAsset, names: &[String]) -> Result<(), String> {
if asset.version.is_empty()
|| !asset
.version
@@ -426,7 +446,7 @@ fn validate_windows_asset_contract(asset: &UpdateAsset) -> Result<(), String> {
{
return Err("release 版本号不符合安装包命名规则".to_owned());
}
if !crate::update_check::windows_x64_installer_names(&asset.version).contains(&asset.name) {
if !names.contains(&asset.name) {
return Err("release 资产不是当前平台的精确安装包".to_owned());
}
let trusted_url = [RELEASE_DOWNLOAD_PREFIX, LEGACY_RELEASE_DOWNLOAD_PREFIX]
@@ -737,3 +757,41 @@ mod tests {
}
}
}
#[cfg(test)]
mod macos_package_tests {
use super::*;
#[test]
fn dmg_contract_checks_architecture_hash_and_udif_trailer() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("image.part");
let mut bytes = vec![0; 1024];
bytes[512..516].copy_from_slice(b"koly");
std::fs::write(&path, &bytes).unwrap();
let names = crate::update_check::assets::macos_names("1.9.1", "aarch64");
let mut asset = UpdateAsset {
version: "1.9.1".into(),
name: names[0].clone(),
download_url: format!("{RELEASE_DOWNLOAD_PREFIX}v1.9.1/{}", names[0]),
size: Some(bytes.len() as u64),
sha256: Some(Sha256::digest(&bytes).iter().map(|byte| format!("{byte:02x}")).collect()),
};
validate_asset_contract(&asset, &names).unwrap();
verify_file(&path, &asset).unwrap();
assert!(
validate_asset_contract(
&asset,
&crate::update_check::assets::macos_names("1.9.1", "x86_64")
)
.is_err()
);
bytes[512] = b'x';
std::fs::write(&path, &bytes).unwrap();
assert!(verify_file(&path, &asset).is_err());
asset.sha256 =
Some(Sha256::digest(&bytes).iter().map(|byte| format!("{byte:02x}")).collect());
assert!(verify_file(&path, &asset).unwrap_err().contains("trailer"));
asset.download_url = "https://example.invalid/image.dmg".into();
assert!(validate_asset_contract(&asset, &names).is_err());
}
}
+28 -13
View File
@@ -1,4 +1,4 @@
//! Two-phase Windows installation handoff and the resulting restore ticket.
//! Two-phase platform installation handoff and the resulting restore ticket.
//! Files under one transaction are durable evidence; only commit authorizes setup.
use std::io::{self, Read as _};
use std::path::{Path, PathBuf};
@@ -7,10 +7,13 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::platform::update_installation::{canonical, current_process_created, spawn_helper};
use crate::platform::update_installation::{canonical, current_process_created};
use crate::session::Session;
use crate::update_check::UpdateAsset;
#[cfg(target_os = "macos")]
pub(crate) mod macos;
struct ActiveRestore {
directory: PathBuf,
expected_windows: usize,
@@ -70,6 +73,9 @@ impl Drop for PreparedUpdate {
if !self.committed {
let _ = crate::atomic_file::write(&self.directory.join("cancel.json"), b"{}");
// This is our own uncommitted helper; it has no authority to install.
if crate::platform::Platform::current() == crate::platform::Platform::MacOS {
return;
}
let _ = self.child.kill();
let _ = self.child.wait();
}
@@ -80,7 +86,7 @@ fn guard_base(executable: &Path) -> PathBuf {
// All configurations of the same installed binary share this lock. A
// settings-directory override must not bypass an installation in progress.
// The installer never replaces or deletes this reserved sidecar.
executable.parent().expect("canonical executable has a parent").join(".pebrel-update")
crate::platform::update_installation::guard_base(executable)
}
/// Called before starting any resident resources. The lock belongs to the exact
@@ -104,10 +110,7 @@ pub(crate) fn prepare(asset: &UpdateAsset) -> Result<PreparedUpdate, String> {
let installer = super::ready_path(asset)?;
let executable = canonical(&std::env::current_exe().map_err(|error| error.to_string())?)
.map_err(|error| error.to_string())?;
let installation = executable.parent().ok_or("Application directory is missing")?.to_owned();
if !installation.join("unins000.exe").is_file() {
return Err("This copy is portable. Use the download page to replace its package.".into());
}
let installation = crate::platform::update_installation::installation_directory(&executable)?;
let transaction = format!(
"{}-{}",
std::process::id(),
@@ -116,7 +119,13 @@ pub(crate) fn prepare(asset: &UpdateAsset) -> Result<PreparedUpdate, String> {
let directory = nebula_settings::settings_dir().join("updates/handoffs").join(&transaction);
std::fs::create_dir_all(&directory).map_err(|error| error.to_string())?;
// Detect an unwritable target while all windows are still available.
let probe = installation.join(format!(".pebrel-update-{transaction}"));
let probe_directory =
if crate::platform::Platform::current() == crate::platform::Platform::MacOS {
installation.parent().ok_or("Missing application parent")?
} else {
&installation
};
let probe = probe_directory.join(format!(".pebrel-update-{transaction}"));
let probe_file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
@@ -153,12 +162,15 @@ pub(crate) fn prepare(asset: &UpdateAsset) -> Result<PreparedUpdate, String> {
&serde_json::to_vec(&plan_path).map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())?;
let helper = directory.join("handoff.ps1");
crate::atomic_file::write(&helper, include_bytes!("handoff.ps1"))
.map_err(|error| error.to_string())?;
let child = spawn_helper(&helper, &plan_path).map_err(|error| error.to_string())?;
let child =
crate::platform::update_installation::spawn_prepared_helper(&directory, &plan_path)?;
let mut prepared = PreparedUpdate { directory, transaction, child, committed: false };
let deadline = Instant::now() + Duration::from_secs(30);
let timeout = if crate::platform::Platform::current() == crate::platform::Platform::MacOS {
600
} else {
30
};
let deadline = Instant::now() + Duration::from_secs(timeout);
loop {
if prepared.child.try_wait().map_err(|error| error.to_string())?.is_some() {
let result = read_json::<serde_json::Value>(&prepared.directory.join("result.json"))
@@ -335,3 +347,6 @@ pub(crate) fn apply_scheduled() -> bool {
},
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,247 @@
//! The macOS participant in the shared prepare/commit/restore transaction.
use super::*;
use crate::platform::update_installation::macos as native;
use std::process::{Command, Stdio};
pub(crate) fn run_helper_if_requested() -> Option<i32> {
let args: Vec<_> = std::env::args_os().collect();
if args.get(1).is_none_or(|arg| arg != "--internal-macos-update") {
return None;
}
if args.len() != 3 {
return Some(2);
}
Some(match run(&PathBuf::from(&args[2])) {
Ok(()) => 0,
Err(error) => {
eprintln!("macOS update: {error}");
1
},
})
}
fn run(path: &Path) -> Result<(), String> {
let path = canonical(path).map_err(|e| e.to_string())?;
let directory = path.parent().ok_or("Missing update directory")?;
let plan: Plan = read_json(&path).ok_or("Invalid update plan")?;
let root = canonical(&nebula_settings::settings_dir().join("updates/handoffs"))
.map_err(|e| e.to_string())?;
if path.file_name().is_none_or(|n| n != "plan.json")
|| directory.parent() != Some(root.as_path())
|| directory.file_name().is_none_or(|n| n != plan.transaction.as_str())
|| canonical(&std::env::current_exe().map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?
!= directory.join("handoff")
|| plan.schema != 1
|| plan.participants.len() != 1
|| plan.version != plan.asset.version
|| plan.sha256 != plan.asset.sha256.clone().unwrap_or_default()
|| canonical(&nebula_settings::settings_dir()).map_err(|e| e.to_string())?
!= plan.config_directory
|| plan.executable != plan.installation.join("Contents/MacOS/pebrel")
|| native::bundle(&plan.executable)? != plan.installation
|| plan.guard_path != guard_base(&plan.executable).with_extension("nebula-lock")
|| plan.installer != plan.config_directory.join("updates").join(&plan.asset.name)
|| !crate::update_check::can_install_version(&plan.version)?
{
return Err("The macOS update plan does not match this installation".into());
}
let _guard = crate::atomic_file::try_lifetime_lock(&guard_base(&plan.executable))
.map_err(|e| e.to_string())?
.ok_or("Another update owns this application")?;
let staged = plan
.installation
.parent()
.ok_or("Missing bundle parent")?
.join(format!(".pebrel-update-{}.app", plan.transaction));
if std::fs::symlink_metadata(&staged).is_ok() {
return Err("The staging bundle already exists".into());
}
let mut swapped = false;
let mut committed = false;
let result: Result<(), String> = (|| {
super::super::validate_asset(&plan.asset)?;
let bytes = super::super::verify_file(&plan.installer, &plan.asset)?;
if bytes != plan.bytes {
return Err("The installer size changed".into());
}
let participant = &plan.participants[0];
let created: u64 = participant.created.parse().map_err(|_| "Invalid process identity")?;
if native::process_created(participant.pid).map_err(|e| e.to_string())? != Some(created)
|| native::running_copies(&plan.executable)? != vec![participant.pid]
{
return Err("Close other copies of this Pebrel application before updating".into());
}
native::verify_bundle(&plan.installation, &plan.original_version)?;
let original_digest = file_digest(&plan.executable)?;
native::stage(
&plan.installer,
&plan.installation,
&staged,
&directory.join("mount"),
&plan.version,
)?;
if directory.join("cancel.json").exists() {
return Err("Update cancelled before commit".into());
}
write_value(
&directory.join("ready.json"),
&serde_json::json!({"transaction": plan.transaction}),
)?;
let deadline = Instant::now() + Duration::from_secs(180);
loop {
if directory.join("cancel.json").exists() {
return Err("Update cancelled before commit".into());
}
if let Some(commit) = read_json::<serde_json::Value>(&directory.join("commit.json")) {
if commit["transaction"] != plan.transaction {
return Err("Update commit does not match".into());
}
let _: Vec<Session> = read_json(&directory.join("workspace.json"))
.ok_or("Missing update workspace")?;
committed = true;
break;
}
if Instant::now() >= deadline
|| native::process_created(participant.pid).map_err(|e| e.to_string())?
!= Some(created)
{
return Err(
"The application exited or timed out without authorizing installation".into()
);
}
std::thread::sleep(Duration::from_millis(50));
}
let deadline = Instant::now() + Duration::from_secs(90);
while native::process_created(participant.pid).map_err(|e| e.to_string())? == Some(created)
{
if Instant::now() >= deadline {
return Err("Pebrel did not exit; its application was not replaced".into());
}
std::thread::sleep(Duration::from_millis(50));
}
if !native::running_copies(&plan.executable)?.is_empty()
|| file_digest(&plan.executable)? != original_digest
{
return Err(
"The installed application changed or another copy started during preparation"
.into(),
);
}
native::verify_bundle(&staged, &plan.version)?;
native::exchange(&plan.installation, &staged)?;
swapped = true;
// Persist success before launch so the new application can consume the restore ticket.
write_result(directory, &plan, true, false, None)?;
Ok(())
})();
// Startup checks this same installation lock. Release it only after the swap/result are durable.
drop(_guard);
let result = result.and_then(|_| relaunch(&plan, &path));
if let Err(mut error) = result {
let rollback_guard =
match crate::atomic_file::try_lifetime_lock(&guard_base(&plan.executable)) {
Ok(guard) => guard,
Err(lock_error) => {
error.push_str(&format!("; could not lock for rollback: {lock_error}"));
None
},
};
let recovery = (|| -> Result<bool, String> {
if rollback_guard.is_none() || !native::running_copies(&plan.executable)?.is_empty() {
return Ok(false);
}
if swapped {
native::exchange(&plan.installation, &staged)?;
Ok(true)
} else {
Ok(committed
&& native::verify_bundle(&plan.installation, &plan.original_version).is_ok())
}
})();
let recovered = match recovery {
Ok(recovered) => recovered,
Err(rollback) => {
error.push_str(&format!(
"; rollback failed: {rollback}; retained bundle at {}",
staged.display()
));
false
},
};
write_result(directory, &plan, false, recovered, Some(&error))?;
drop(rollback_guard);
if recovered {
let _ = relaunch(&plan, &path);
}
if !swapped {
let _ = std::fs::remove_dir_all(&staged);
}
return Err(error);
}
// Retain the original signed bundle at the unique sibling path for recovery.
Ok(())
}
fn relaunch(plan: &Plan, path: &Path) -> Result<(), String> {
let mut child = Command::new(&plan.executable)
.arg("--gpui")
.env("PEBREL_CONFIG_DIR", &plan.config_directory)
.env("NEBULA_CONFIG_DIR", &plan.config_directory)
.env("PEBREL_UPDATE_RESTORE", path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Could not relaunch Pebrel: {e}"))?;
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline {
if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {
return if status.success() {
Ok(())
} else {
Err(format!("Updated Pebrel exited with {status}"))
};
}
std::thread::sleep(Duration::from_millis(50));
}
Ok(())
}
fn write_value(path: &Path, value: &serde_json::Value) -> Result<(), String> {
crate::atomic_file::write(path, &serde_json::to_vec(value).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())
}
fn write_result(
directory: &Path,
plan: &Plan,
success: bool,
recovered: bool,
error: Option<&str>,
) -> Result<(), String> {
write_value(
&directory.join("result.json"),
&serde_json::json!({
"transaction": plan.transaction, "success": success, "recovered_original": recovered, "error": error,
}),
)
}
fn file_digest(path: &Path) -> Result<Vec<u8>, String> {
use sha2::{Digest as _, Sha256};
let mut file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let read = file.read(&mut buffer).map_err(|e| e.to_string())?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(hasher.finalize().to_vec())
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,261 @@
//! Native acceptance uses disposable signed apps, never the user's installation.
use super::*;
use std::fs;
struct Fixture {
_root: tempfile::TempDir,
plan: Plan,
directory: PathBuf,
old: Child,
helper: Option<Child>,
}
impl Drop for Fixture {
fn drop(&mut self) {
if let Some(child) = &mut self.helper {
let _ = child.kill();
let _ = child.wait();
}
let _ = self.old.kill();
let _ = self.old.wait();
// Only processes executing this disposable fixture path are ours.
for pid in native::running_copies(&self.plan.executable).unwrap_or_default() {
unsafe { libc::kill(pid as i32, libc::SIGTERM) };
}
}
}
fn app(path: &Path, version: &str, fails: bool) {
fs::create_dir_all(path.join("Contents/MacOS")).unwrap();
let source = path.parent().unwrap().join("fixture.c");
fs::write(
&source,
format!(
r#"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char **argv) {{
if (argc > 1 && strcmp(argv[1], "--version") == 0) {{
puts("pebrel {version}"); return 0;
}}
char path[4096];
snprintf(path, sizeof(path), "%s/launched.txt", getenv("PEBREL_CONFIG_DIR"));
FILE *file = fopen(path, "a");
if (file) {{ fprintf(file, "{version}\n"); fclose(file); }}
if ({fails}) return 42;
sleep(600); return 0;
}}
"#,
fails = u8::from(fails)
),
)
.unwrap();
native::run(
Command::new("/usr/bin/cc").arg(&source).arg("-o").arg(path.join("Contents/MacOS/pebrel")),
)
.unwrap();
fs::remove_file(source).unwrap();
fs::write(path.join("Contents/Info.plist"), format!(r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleIdentifier</key><string>io.github.kuddev.pebrel</string>
<key>CFBundleExecutable</key><string>pebrel</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>{version}</string>
</dict></plist>"#)).unwrap();
native::run(Command::new("/usr/bin/codesign").args(["--force", "--sign", "-"]).arg(path))
.unwrap();
}
impl Fixture {
fn new(binary: &Path, fails: bool) -> Self {
let root = tempfile::tempdir().unwrap();
let path = fs::canonicalize(root.path()).unwrap();
let installation = path.join("Pebrel.app");
let config = path.join("config");
let transaction = "native-acceptance".to_owned();
let directory = config.join("updates/handoffs").join(&transaction);
fs::create_dir_all(&directory).unwrap();
let version = "99.0.0";
app(&installation, env!("CARGO_PKG_VERSION"), false);
let source = path.join("image");
fs::create_dir(&source).unwrap();
app(&source.join("Pebrel.app"), version, fails);
let name = crate::update_check::assets::native_names(version).remove(0);
let installer = config.join("updates").join(&name);
native::run(
Command::new("/usr/bin/hdiutil")
.args([
"create",
"-quiet",
"-fs",
"HFS+",
"-volname",
"PebrelUpdateTest",
"-srcfolder",
])
.arg(&source)
.args(["-format", "UDZO"])
.arg(&installer),
)
.unwrap();
let hash: String =
file_digest(&installer).unwrap().iter().map(|b| format!("{b:02x}")).collect();
let executable = installation.join("Contents/MacOS/pebrel");
let old = Command::new(&executable).env("PEBREL_CONFIG_DIR", &config).spawn().unwrap();
let created = native::process_created(old.id()).unwrap().unwrap();
let plan = Plan {
schema: 1,
asset: UpdateAsset {
version: version.into(),
name: name.clone(),
download_url: format!(
"https://github.com/Kuddev/pebrel/releases/download/v{version}/{name}"
),
size: Some(fs::metadata(&installer).unwrap().len()),
sha256: Some(hash.clone()),
},
transaction,
guard_path: guard_base(&executable).with_extension("nebula-lock"),
executable,
installation,
config_directory: config,
bytes: fs::metadata(&installer).unwrap().len(),
installer,
sha256: hash,
version: version.into(),
original_version: env!("CARGO_PKG_VERSION").into(),
participants: vec![Participant { pid: old.id(), created: created.to_string() }],
};
fs::copy(binary, directory.join("handoff")).unwrap();
Self { _root: root, plan, directory, old, helper: None }
}
fn start(&mut self) {
let path = self.directory.join("plan.json");
fs::write(&path, serde_json::to_vec(&self.plan).unwrap()).unwrap();
let log = fs::File::create(self.directory.join("helper.log")).unwrap();
self.helper = Some(
Command::new(self.directory.join("handoff"))
.arg("--internal-macos-update")
.arg(path)
.env("PEBREL_CONFIG_DIR", &self.plan.config_directory)
.env("NEBULA_CONFIG_DIR", &self.plan.config_directory)
.stdout(Stdio::null())
.stderr(log)
.spawn()
.unwrap(),
);
}
fn ready(&mut self) {
let deadline = Instant::now() + Duration::from_secs(60);
while !self.directory.join("ready.json").exists() {
assert!(self.helper.as_mut().unwrap().try_wait().unwrap().is_none(), "{}", self.log());
assert!(Instant::now() < deadline, "helper did not prepare: {}", self.log());
std::thread::sleep(Duration::from_millis(50));
}
assert_eq!(
native::property(&self.plan.installation, "CFBundleShortVersionString").unwrap(),
self.plan.original_version
);
assert!(
crate::atomic_file::try_lifetime_lock(&guard_base(&self.plan.executable))
.unwrap()
.is_none()
);
}
fn commit(&mut self) {
fs::write(self.directory.join("workspace.json"), b"[]").unwrap();
write_value(
&self.directory.join("commit.json"),
&serde_json::json!({"transaction": self.plan.transaction}),
)
.unwrap();
self.old.kill().unwrap();
self.old.wait().unwrap();
}
fn finish(&mut self, success: bool) -> serde_json::Value {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
if let Some(status) = self.helper.as_mut().unwrap().try_wait().unwrap() {
assert_eq!(status.success(), success, "{}", self.log());
break;
}
assert!(Instant::now() < deadline, "helper timed out: {}", self.log());
std::thread::sleep(Duration::from_millis(50));
}
assert!(!self.directory.join("mount").exists(), "image must be detached");
read_json(&self.directory.join("result.json")).unwrap()
}
fn log(&self) -> String {
fs::read_to_string(self.directory.join("helper.log")).unwrap_or_default()
}
}
#[test]
#[ignore = "needs macOS signing/mount permissions and PEBREL_MACOS_TEST_EXECUTABLE from a fresh product build"]
fn native_macos_update_installs_cancels_rejects_and_rolls_back() {
let binary = PathBuf::from(
std::env::var_os("PEBREL_MACOS_TEST_EXECUTABLE").expect("fresh product executable"),
);
for case in ["success", "rollback", "cancel", "digest", "other-instance"] {
let mut fixture = Fixture::new(&binary, case == "rollback");
if case == "digest" {
fixture.plan.sha256 = "0".repeat(64);
fixture.plan.asset.sha256 = Some(fixture.plan.sha256.clone());
}
let mut other = (case == "other-instance").then(|| {
Command::new(&fixture.plan.executable)
.env("PEBREL_CONFIG_DIR", &fixture.plan.config_directory)
.spawn()
.unwrap()
});
fixture.start();
if case == "digest" || case == "other-instance" {
let result = fixture.finish(false);
assert_eq!(result["success"], false);
assert!(!fixture.directory.join("ready.json").exists());
} else {
fixture.ready();
if case == "cancel" {
fs::write(fixture.directory.join("cancel.json"), b"{}").unwrap();
} else {
fixture.commit();
}
let result = fixture.finish(case == "success");
assert_eq!(result["success"], case == "success");
assert_eq!(result["recovered_original"], case == "rollback");
}
if let Some(child) = &mut other {
let _ = child.kill();
let _ = child.wait();
}
let expected = if case == "success" { "99.0.0" } else { env!("CARGO_PKG_VERSION") };
assert_eq!(
native::property(&fixture.plan.installation, "CFBundleShortVersionString").unwrap(),
expected,
"{case}"
);
if case == "success" {
let backup = fixture
.plan
.installation
.parent()
.unwrap()
.join(".pebrel-update-native-acceptance.app");
native::verify_bundle(&backup, env!("CARGO_PKG_VERSION")).unwrap();
assert!(
fs::read_to_string(fixture.plan.config_directory.join("launched.txt"))
.unwrap()
.contains("99.0.0")
);
}
println!("native macOS handoff: {case} passed");
}
}
@@ -0,0 +1,85 @@
use super::*;
#[test]
fn update_restore_tickets_cover_success_rollback_and_acknowledgement() {
// Run in a separate process: changing the settings environment in the shared
// test runner would race unrelated tests and could touch real user settings.
if std::env::var_os("PEBREL_RESTORE_TEST_CHILD").is_none() {
let root = tempfile::tempdir().unwrap();
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "update_download::handoff::tests::update_restore_tickets_cover_success_rollback_and_acknowledgement", "--nocapture"])
.env("PEBREL_RESTORE_TEST_CHILD", "1")
.env("PEBREL_CONFIG_DIR", root.path())
.env("NEBULA_CONFIG_DIR", root.path())
.env_remove("PEBREL_UPDATE_RESTORE")
.output().unwrap();
assert!(
result.status.success(),
"{}\n{}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
return;
}
let config = canonical(&nebula_settings::settings_dir()).unwrap();
let directory = config.join("updates/handoffs/restore-test");
std::fs::create_dir_all(&directory).unwrap();
let executable = canonical(&std::env::current_exe().unwrap()).unwrap();
let version = env!("CARGO_PKG_VERSION").to_owned();
let mut plan = Plan {
schema: 1,
asset: UpdateAsset {
version: version.clone(),
name: "fixture".into(),
download_url: String::new(),
sha256: None,
size: None,
},
transaction: "restore-test".into(),
installation: executable.parent().unwrap().to_owned(),
executable,
config_directory: config.clone(),
installer: config.join("fixture"),
sha256: String::new(),
bytes: 1,
version: version.clone(),
original_version: version,
guard_path: config.join("guard"),
participants: Vec::new(),
};
let save = |name: &str, value: serde_json::Value| {
std::fs::write(directory.join(name), serde_json::to_vec(&value).unwrap()).unwrap();
};
std::fs::write(
config.join("updates/last-handoff.json"),
serde_json::to_vec(&directory.join("plan.json")).unwrap(),
)
.unwrap();
save("workspace.json", serde_json::to_value(vec![Session::new(0, Vec::new())]).unwrap());
for recovered in [false, true] {
plan.version = if recovered { "99.0.0".into() } else { env!("CARGO_PKG_VERSION").into() };
save("plan.json", serde_json::to_value(&plan).unwrap());
save(
"result.json",
serde_json::json!({"transaction": "wrong", "success": !recovered, "recovered_original": recovered}),
);
assert!(restore_ticket().is_none());
save(
"result.json",
serde_json::json!({"transaction": plan.transaction, "success": !recovered, "recovered_original": recovered}),
);
assert_eq!(restore_ticket().unwrap().len(), 1);
acknowledge_restore(2);
assert!(!directory.join("restored.json").exists());
acknowledge_restore(1);
assert!(directory.join("restored.json").exists());
assert!(restore_ticket().is_none(), "a completed restore must not replay");
std::fs::remove_file(directory.join("restored.json")).unwrap();
save("restore-attempts.json", serde_json::json!(3));
assert!(restore_ticket().is_none(), "crash-loop recovery is bounded");
save("restore-attempts.json", serde_json::json!(0));
}
plan.original_version = "0.0.0".into();
save("plan.json", serde_json::to_value(&plan).unwrap());
assert!(restore_ticket().is_none(), "rollback must match the running original version");
}
+9 -1
View File
@@ -85,7 +85,15 @@ class PebrelBrandingTests(unittest.TestCase):
self.assertIn('format!("Pebrel-v{version}-windows-x64-setup.exe")', check)
self.assertIn('format!("NebulaTerminal-{version}-windows-x64-setup.exe")', check)
self.assertIn("windows_x64_installer_names(version)", check)
self.assertIn("windows_x64_installer_names(&asset.version).contains(&asset.name)", download)
# Discovery and validation now route through the shared native selector.
# Retain the Windows brand contract while also checking the macOS path;
# executable Rust regressions cover accepted brands and rejected URLs.
assets = self.source("nebula_app/src/update_check/assets.rs")
self.assertIn("assets::native_names(&version)", check)
self.assertIn("assets::native_names(&asset.version)", download)
self.assertIn("super::windows_x64_installer_names(version).to_vec()", assets)
self.assertIn("Platform::MacOS => macos_names(version, std::env::consts::ARCH)", assets)
self.assertIn("if !names.contains(&asset.name)", download)
def test_notification_identity_uses_pebrel(self):
source = self.source("nebula_app/src/platform/notifications.rs")