From bb2a981120cfc2036de6b79888d3a5005808dc99 Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 16 Aug 2026 12:37:12 +0800 Subject: [PATCH 1/5] fix: improve FRB cross-platform compatibility --- .github/workflows/analysis.yml | 20 ++++++ crates/sbm_parser/src/linux.rs | 15 +++-- crates/sbm_parser/src/smart.rs | 4 +- lib/core/utils/android_rootfs.dart | 11 ++-- lib/core/utils/local_file_backend.dart | 2 +- lib/data/model/file/file_ref.dart | 2 +- lib/view/page/home.dart | 3 +- monitor/src/api/fs.rs | 27 +++++++- monitor/src/ssh/local_pty.rs | 88 +++++++++++++++++++++++--- monitor/tests/exec_api.rs | 65 +++++++++++++++---- monitor/tests/fs_api.rs | 40 +++++++++--- monitor/tests/fs_roots.rs | 59 +++++++++++++---- monitor/tests/terminal_ws.rs | 24 ++++++- test/android_rootfs_path_test.dart | 46 ++++++++++---- test/copy_tree_test.dart | 3 +- test/frb_parser_test.dart | 11 +--- test/local_file_backend_test.dart | 2 +- 17 files changed, 335 insertions(+), 87 deletions(-) diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index 59d617532e..acd26aa44f 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -57,6 +57,26 @@ jobs: - name: Check JNI build-id patch run: scripts/release/patch-jni-build-id.sh + rustTests: + name: Rust tests (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + submodules: recursive + persist-credentials: false + + - uses: Swatinem/rust-cache@v2 + + - name: Test Rust workspace + run: cargo test --workspace --no-fail-fast + check: runs-on: ubuntu-latest diff --git a/crates/sbm_parser/src/linux.rs b/crates/sbm_parser/src/linux.rs index 1a1c07f2b2..0ce3fe794e 100644 --- a/crates/sbm_parser/src/linux.rs +++ b/crates/sbm_parser/src/linux.rs @@ -6,7 +6,7 @@ use crate::types::*; /// first line must be `cpu`/`cpuN`, stop at the first non-cpu line; skip lines with fewer than 8 fields pub fn parse_cpu(raw: &str) -> Vec { let mut cores = Vec::new(); - for line in raw.split('\n') { + for line in raw.lines() { let line = line.trim(); if line.is_empty() { continue; @@ -319,7 +319,7 @@ pub fn parse_temps(types_raw: &str, values_raw: &str, divisor: f64) -> Temperatu /// Tcp lines of /proc/net/snmp (Dart `Conn.parse`): /// take the last `Tcp:` line; MaxConn is column 4, AttemptFails column 7 pub fn parse_conn(raw: &str) -> Option { - let line = raw.split('\n').filter(|l| l.starts_with("Tcp:")).next_back()?; + let line = raw.split('\n').rfind(|l| l.starts_with("Tcp:"))?; let fields: Vec<&str> = line.split_whitespace().collect(); if fields.len() <= 7 { return None; @@ -357,7 +357,7 @@ pub fn parse_diskio(raw: &str) -> Vec { pub fn parse_batteries(raw: &str, only_li_poly: bool) -> Vec { let mut batteries = Vec::new(); let mut block: Vec<&str> = Vec::new(); - for line in raw.split('\n') { + for line in raw.lines() { if !line.is_empty() { block.push(line); continue; @@ -369,6 +369,11 @@ pub fn parse_batteries(raw: &str, only_li_poly: bool) -> Vec { } block.clear(); } + if let Some(battery) = parse_battery_block(&block) + && (!only_li_poly || battery.is_li_poly()) + { + batteries.push(battery); + } batteries } @@ -399,7 +404,7 @@ fn parse_battery_block(lines: &[&str]) -> Option { /// each block at least 3 lines [device, adapter, detail...] pub fn parse_sensors(raw: &str) -> Vec { let mut groups: Vec> = vec![Vec::new()]; - for line in raw.split('\n') { + for line in raw.lines() { if line.is_empty() { groups.push(Vec::new()); } else { @@ -435,7 +440,7 @@ pub fn parse_sensors(raw: &str) -> Vec { /// count, keeping first-seen order pub fn parse_cpu_brand(raw: &str) -> Vec<(String, u32)> { let mut brands: Vec<(String, u32)> = Vec::new(); - for line in raw.split('\n') { + for line in raw.lines() { if !line.contains("model name") { continue; } diff --git a/crates/sbm_parser/src/smart.rs b/crates/sbm_parser/src/smart.rs index 99457f0abd..9bd7320f10 100644 --- a/crates/sbm_parser/src/smart.rs +++ b/crates/sbm_parser/src/smart.rs @@ -49,7 +49,9 @@ fn is_physical_disk(device: &str) -> bool { } pub fn parse(raw: &str) -> Vec { - raw.split("\n\n") + let normalized = raw.replace("\r\n", "\n"); + normalized + .split("\n\n") .filter(|s| !s.trim().is_empty()) .filter_map(|block| parse_block(block.trim())) .collect() diff --git a/lib/core/utils/android_rootfs.dart b/lib/core/utils/android_rootfs.dart index 6d40e691cb..5f6d47f670 100644 --- a/lib/core/utils/android_rootfs.dart +++ b/lib/core/utils/android_rootfs.dart @@ -288,11 +288,14 @@ abstract final class AndroidRootfs { } if (parts.isEmpty) return base; - final host = [base, ...parts].join('/'); + final separator = Platform.pathSeparator; + final host = [base, ...parts].join(separator); // And resolved again at the end, because a symlink *inside* the rootfs can // point out of it — `ln -s / /tmp/out` is one reviewed command away, and // `File.readAsBytes` would follow it without asking anybody. - final toResolve = forWrite ? host.substring(0, host.lastIndexOf('/')) : host; + final toResolve = forWrite + ? host.substring(0, host.lastIndexOf(separator)) + : host; String? real; try { real = await Directory(toResolve).resolveSymbolicLinks(); @@ -305,8 +308,8 @@ abstract final class AndroidRootfs { return null; } } - if (real != base && !real.startsWith('$base/')) return null; - return forWrite ? '$real/${parts.last}' : real; + if (real != base && !real.startsWith('$base$separator')) return null; + return forWrite ? '$real$separator${parts.last}' : real; } /// What the guest needs in its environment. diff --git a/lib/core/utils/local_file_backend.dart b/lib/core/utils/local_file_backend.dart index f1acb5cf96..e4cff95457 100644 --- a/lib/core/utils/local_file_backend.dart +++ b/lib/core/utils/local_file_backend.dart @@ -168,7 +168,7 @@ class LocalFileBackend implements FileBackend { static Future _targetOf(Link link) async { try { - return await link.target(); + return (await link.target()).replaceAll(r'\', '/'); } on FileSystemException { // A link to nowhere is still a link, and still worth listing. return null; diff --git a/lib/data/model/file/file_ref.dart b/lib/data/model/file/file_ref.dart index fbde024ccd..da5416e5ae 100644 --- a/lib/data/model/file/file_ref.dart +++ b/lib/data/model/file/file_ref.dart @@ -46,7 +46,7 @@ final class LocalFileRef extends FileRef { @override LocalFileRef child(String name) => - LocalFileRef(path.joinPath(name, separator: Pfs.seperator)); + LocalFileRef(path.joinPath(name, separator: '/')); @override bool operator ==(Object other) => diff --git a/lib/view/page/home.dart b/lib/view/page/home.dart index bac3d69d53..66996bc6f0 100644 --- a/lib/view/page/home.dart +++ b/lib/view/page/home.dart @@ -4,12 +4,11 @@ import 'dart:io'; import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/foundation.dart' show kReleaseMode; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:responsive_framework/responsive_framework.dart'; import 'package:server_box/core/chan.dart'; -import 'package:server_box/core/utils/desktop_shortcuts.dart'; import 'package:server_box/core/sync.dart'; +import 'package:server_box/core/utils/desktop_shortcuts.dart'; import 'package:server_box/data/model/app/tab.dart'; import 'package:server_box/data/provider/app/session_requests.dart'; import 'package:server_box/data/provider/server/all.dart'; diff --git a/monitor/src/api/fs.rs b/monitor/src/api/fs.rs index 9a3ad1e830..be3a43ad6d 100644 --- a/monitor/src/api/fs.rs +++ b/monitor/src/api/fs.rs @@ -166,7 +166,7 @@ pub async fn roots( .roots .as_slice() .iter() - .map(|p| p.to_string_lossy().into_owned()) + .map(|p| exposed_path(p)) .collect(); Ok(HttpResponse::Ok().json(&serde_json::json!({ "roots": roots }))) } @@ -518,7 +518,7 @@ async fn view_of(path: &Path) -> EntryView { tokio::fs::read_link(path) .await .ok() - .map(|p| p.to_string_lossy().into_owned()) + .map(|p| exposed_path(&p)) } else { None }; @@ -537,6 +537,29 @@ async fn view_of(path: &Path) -> EntryView { } } +/// Paths crossing the HTTP boundary use `/` on every platform, matching the +/// app's `FileBackend` contract. Windows canonical paths also carry a `\\?\` +/// prefix that is useful to the OS but not a path a user should have to see or +/// send back. +fn exposed_path(path: &Path) -> String { + let raw = path.to_string_lossy(); + #[cfg(windows)] + { + let without_verbatim = if let Some(rest) = raw.strip_prefix(r"\\?\UNC\") { + format!(r"\\{rest}") + } else if let Some(rest) = raw.strip_prefix(r"\\?\") { + rest.to_string() + } else { + raw.into_owned() + }; + without_verbatim.replace('\\', "/") + } + #[cfg(not(windows))] + { + raw.into_owned() + } +} + #[cfg(unix)] fn mode_of(meta: &std::fs::Metadata) -> Option { use std::os::unix::fs::PermissionsExt; diff --git a/monitor/src/ssh/local_pty.rs b/monitor/src/ssh/local_pty.rs index cffe866191..74ae6fd9b3 100644 --- a/monitor/src/ssh/local_pty.rs +++ b/monitor/src/ssh/local_pty.rs @@ -12,7 +12,7 @@ //! through the same session, scrollback and reconnect machinery. use std::io::{Read, Write}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Condvar, Mutex}; use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system}; use tokio::sync::mpsc; @@ -143,29 +143,64 @@ impl LocalShell { let (tx, rx) = mpsc::channel(64); let child = Arc::new(Mutex::new(child)); + let reader_done = Arc::new((Mutex::new(false), Condvar::new())); // A PTY read is blocking, so it gets a thread rather than a task. One // thread per open terminal, bounded by `terminal_max_sessions`. - let reaper = child.clone(); + let reader_tx = tx.clone(); + let reader_finished = reader_done.clone(); std::thread::spawn(move || { let mut buf = [0u8; 8 * 1024]; loop { match reader.read(&mut buf) { Ok(0) | Err(_) => break, Ok(n) => { - if tx.blocking_send(ShellEvent::Data(buf[..n].to_vec())).is_err() { + if reader_tx.blocking_send(ShellEvent::Data(buf[..n].to_vec())).is_err() { break; } } } } + let (done, ready) = &*reader_finished; + { + let mut done = done.lock().unwrap_or_else(|e| e.into_inner()); + *done = true; + } + ready.notify_one(); + }); + + // ConPTY can keep the read side open after the child exits while the + // master handle is still alive. Reap independently instead of making + // exit delivery depend on the reader observing EOF. `try_wait` keeps + // the mutex available between polls, so `kill` cannot deadlock behind + // a blocking wait. + let reaper = child.clone(); + std::thread::spawn(move || loop { let status = reaper .lock() .unwrap_or_else(|e| e.into_inner()) - .wait() - .ok() - .map(|s| s.exit_code()); - let _ = tx.blocking_send(ShellEvent::Exit(status)); + .try_wait(); + let exit = match status { + Ok(Some(status)) => Some(Some(status.exit_code())), + Ok(None) => None, + Err(_) => Some(None), + }; + if let Some(status) = exit { + // Preserve the usual PTY contract that the last output comes + // before the exit notification. Unix readers normally reach + // EOF immediately; ConPTY gets a short drain window and then + // exit is delivered even if its read handle stays open. + let (done, ready) = &*reader_done; + let done = done.lock().unwrap_or_else(|e| e.into_inner()); + drop(ready.wait_timeout_while( + done, + std::time::Duration::from_millis(100), + |done| !*done, + )); + let _ = tx.blocking_send(ShellEvent::Exit(status)); + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); }); Ok(( @@ -215,13 +250,45 @@ fn dirs_home() -> Option { mod tests { use super::*; + #[cfg(windows)] + async fn answer_cursor_position_query( + shell: &LocalShell, + rx: &mut mpsc::Receiver, + ) -> String { + let mut seen = String::new(); + let queried = tokio::time::timeout(std::time::Duration::from_secs(15), async { + while let Some(event) = rx.recv().await { + if let ShellEvent::Data(data) = event { + seen.push_str(&String::from_utf8_lossy(&data)); + if seen.contains("\u{1b}[6n") { + return true; + } + } + } + false + }) + .await + .unwrap_or(false); + assert!(queried, "ConPTY should ask for the cursor position; saw {seen:?}"); + shell.write(b"\x1b[1;1R").unwrap(); + seen + } + + #[cfg(not(windows))] + async fn answer_cursor_position_query( + _shell: &LocalShell, + _rx: &mut mpsc::Receiver, + ) -> String { + String::new() + } + #[tokio::test] async fn a_shell_starts_echoes_and_exits() { let (shell, mut rx) = LocalShell::spawn("xterm-256color", 80, 24).unwrap(); - shell.write(b"echo local-pty-marker\n").unwrap(); + let mut seen = answer_cursor_position_query(&shell, &mut rx).await; + shell.write(b"echo local-pty-marker\r").unwrap(); - let mut seen = String::new(); let found = tokio::time::timeout(std::time::Duration::from_secs(15), async { while let Some(event) = rx.recv().await { if let ShellEvent::Data(data) = event { @@ -245,7 +312,8 @@ mod tests { #[tokio::test] async fn exiting_the_shell_reports_an_exit() { let (shell, mut rx) = LocalShell::spawn("xterm-256color", 80, 24).unwrap(); - shell.write(b"exit 7\n").unwrap(); + answer_cursor_position_query(&shell, &mut rx).await; + shell.write(b"exit 7\r").unwrap(); let exit = tokio::time::timeout(std::time::Duration::from_secs(15), async { while let Some(event) = rx.recv().await { diff --git a/monitor/tests/exec_api.rs b/monitor/tests/exec_api.rs index 50600d3306..be65674f65 100644 --- a/monitor/tests/exec_api.rs +++ b/monitor/tests/exec_api.rs @@ -10,6 +10,7 @@ use std::sync::{Arc, Once}; use ntex::web::test::{self as web_test, TestServer}; use ntex::web::{self, App}; use rustls::crypto::ring; +use sbm_parser::script; use serde_json::json; use server_box_monitor::api::auth::generate_token; use server_box_monitor::api::server::AppState; @@ -80,13 +81,29 @@ fn token() -> String { generate_token("admin", SECRET).unwrap() } +fn platform_command<'a>(unix: &'a str, windows: &'a str) -> &'a str { + if cfg!(windows) { windows } else { unix } +} + +fn platform_line(text: &str) -> String { + format!("{text}{}", if cfg!(windows) { "\r\n" } else { "\n" }) +} + +fn platform_powershell_command(unix: &str, windows: &str) -> String { + if cfg!(windows) { + script::encoded_powershell_command(windows) + } else { + unix.to_string() + } +} + #[ntex::test] async fn a_command_runs_and_its_output_comes_back() { let srv = test_server(app_state(true).await).await; let body = post(&srv, json!({"cmd": "echo exec-ok"})).await.unwrap(); assert_eq!(body["exit_code"], 0); - assert_eq!(body["stdout"], "exec-ok\n"); + assert_eq!(body["stdout"], platform_line("exec-ok")); assert_eq!(body["stderr"], ""); assert_eq!(body["truncated"], false); assert_eq!(body["timed_out"], false); @@ -97,12 +114,13 @@ async fn a_command_runs_and_its_output_comes_back() { #[ntex::test] async fn stdout_and_stderr_are_reported_separately() { let srv = test_server(app_state(true).await).await; - let body = post(&srv, json!({"cmd": "echo out; echo err 1>&2"})) + let cmd = platform_command("echo out; echo err 1>&2", "echo out&echo err>&2"); + let body = post(&srv, json!({"cmd": cmd})) .await .unwrap(); - assert_eq!(body["stdout"], "out\n"); - assert_eq!(body["stderr"], "err\n"); + assert_eq!(body["stdout"], platform_line("out")); + assert_eq!(body["stderr"], platform_line("err")); } #[ntex::test] @@ -126,7 +144,10 @@ async fn stdin_is_not_audited() { let srv = test_server(state).await; post( &srv, - json!({"cmd": "cat > /dev/null", "stdin": "hunter2-not-in-the-log"}), + json!({ + "cmd": platform_command("cat > /dev/null", "more > NUL"), + "stdin": "hunter2-not-in-the-log", + }), ) .await .unwrap(); @@ -158,7 +179,7 @@ async fn a_command_that_ignores_a_large_stdin_still_returns() { .await .unwrap(); - assert_eq!(body["stdout"], "ignored-it\n"); + assert_eq!(body["stdout"], platform_line("ignored-it")); assert_eq!(body["timed_out"], false); } @@ -166,7 +187,11 @@ async fn a_command_that_ignores_a_large_stdin_still_returns() { #[ntex::test] async fn stdin_reaches_the_command() { let srv = test_server(app_state(true).await).await; - let body = post(&srv, json!({"cmd": "cat", "stdin": "fed-on-stdin"})) + let cmd = platform_powershell_command( + "cat", + "$text = [Console]::In.ReadToEnd(); [Console]::Out.Write($text)", + ); + let body = post(&srv, json!({"cmd": cmd, "stdin": "fed-on-stdin"})) .await .unwrap(); @@ -181,23 +206,35 @@ async fn a_multi_line_script_fed_to_a_shell_runs_whole() { let body = post( &srv, json!({ - "cmd": "cat | sh", + "cmd": platform_command( + "cat | sh", + "powershell -NoLogo -NoProfile -NonInteractive -Command -", + ), "stdin": "echo first\necho 'second with \"quotes\"'\n", }), ) .await .unwrap(); - assert_eq!(body["stdout"], "first\nsecond with \"quotes\"\n"); + let expected = format!( + "{}{}", + platform_line("first"), + platform_line("second with \"quotes\"") + ); + assert_eq!(body["stdout"], expected); } #[ntex::test] async fn env_reaches_the_command_without_being_quoted_into_it() { let srv = test_server(app_state(true).await).await; + let cmd = platform_powershell_command( + "printf '%s' \"$SBM_TEST\"", + "[Console]::Out.Write($env:SBM_TEST)", + ); let body = post( &srv, json!({ - "cmd": "printf '%s' \"$SBM_TEST\"", + "cmd": cmd, // Would need escaping if it were prepended to the command as an // `export` line, which is the reason this is a field. "env": {"SBM_TEST": "a 'quoted' \"value\""}, @@ -214,14 +251,18 @@ async fn env_reaches_the_command_without_being_quoted_into_it() { #[ntex::test] async fn env_adds_to_the_environment_rather_than_replacing_it() { let srv = test_server(app_state(true).await).await; + let cmd = platform_command( + "test -n \"$PATH\" && echo has-path", + "if defined PATH echo has-path", + ); let body = post( &srv, - json!({"cmd": "test -n \"$PATH\" && echo has-path", "env": {"SBM_TEST": "x"}}), + json!({"cmd": cmd, "env": {"SBM_TEST": "x"}}), ) .await .unwrap(); - assert_eq!(body["stdout"], "has-path\n"); + assert_eq!(body["stdout"], platform_line("has-path")); } #[ntex::test] diff --git a/monitor/tests/fs_api.rs b/monitor/tests/fs_api.rs index dd00e1e5d1..2d96f49ca6 100644 --- a/monitor/tests/fs_api.rs +++ b/monitor/tests/fs_api.rs @@ -6,6 +6,7 @@ //! job and is tested directly in `fs_roots.rs`. use std::sync::{Arc, Once}; +use std::{fs, path::Path}; use ntex::web::test::{self as web_test, TestServer}; use ntex::web::{self, App}; @@ -71,28 +72,46 @@ fn token() -> String { generate_token("admin", SECRET).unwrap() } +fn existing_root() -> String { + fs::canonicalize(std::env::current_dir().unwrap()) + .unwrap() + .to_string_lossy() + .into_owned() +} + +fn canonical(path: &str) -> std::path::PathBuf { + fs::canonicalize(Path::new(path)).unwrap() +} + #[ntex::test] async fn the_configured_roots_come_back() { - // Canonicalised at startup, so the paths have to exist to survive - // `FsRoots::resolve` — these two do on every platform this runs on. - let srv = test_server(app_state(true, &["/tmp", "/etc"]).await).await; + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join("first"); + let second = dir.path().join("second"); + fs::create_dir(&first).unwrap(); + fs::create_dir(&second).unwrap(); + let first = first.to_string_lossy().into_owned(); + let second = second.to_string_lossy().into_owned(); + let srv = test_server(app_state(true, &[&first, &second]).await).await; let body = get_roots(&srv, Some(&token())).await.unwrap(); let roots = body["roots"].as_array().expect("roots is an array"); - let mut got: Vec<&str> = roots.iter().map(|r| r.as_str().unwrap()).collect(); + let got: Vec<&str> = roots.iter().map(|r| r.as_str().unwrap()).collect(); + assert!(got.iter().all(|root| !root.contains('\\')), "got {got:?}"); + let mut got: Vec<_> = got.iter().map(|root| canonical(root)).collect(); got.sort_unstable(); - // /tmp is a symlink to /private/tmp on macOS, and the roots are stored - // resolved — assert on what it ends with rather than on the literal. + let mut expected = vec![canonical(&first), canonical(&second)]; + expected.sort_unstable(); assert_eq!(got.len(), 2, "got {got:?}"); - assert!(got.iter().any(|r| r.ends_with("/etc")), "got {got:?}"); - assert!(got.iter().any(|r| r.ends_with("/tmp")), "got {got:?}"); + assert_eq!(got, expected); } /// The same 401 every other fs handler gives: this one says where the agent /// will let a caller go, which is not something an unauthenticated one may ask. #[ntex::test] async fn an_unauthenticated_caller_is_refused() { - let srv = test_server(app_state(true, &["/tmp"]).await).await; + let root = existing_root(); + let srv = test_server(app_state(true, &[&root]).await).await; assert_eq!(get_roots(&srv, None).await.unwrap_err(), 401); } @@ -100,7 +119,8 @@ async fn an_unauthenticated_caller_is_refused() { /// a client would read as "no limit" rather than as "not serving files". #[ntex::test] async fn a_disabled_file_api_refuses_rather_than_answering_empty() { - let srv = test_server(app_state(false, &["/tmp"]).await).await; + let root = existing_root(); + let srv = test_server(app_state(false, &[&root]).await).await; assert_eq!(get_roots(&srv, Some(&token())).await.unwrap_err(), 403); } diff --git a/monitor/tests/fs_roots.rs b/monitor/tests/fs_roots.rs index 28cd077638..f2459163a2 100644 --- a/monitor/tests/fs_roots.rs +++ b/monitor/tests/fs_roots.rs @@ -6,7 +6,7 @@ //! the handler would catch. use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use server_box_monitor::core::fs_roots::{FsDenied, FsRoots}; @@ -37,10 +37,33 @@ fn sandbox() -> Sandbox { Sandbox { _dir: dir, root, outside, roots } } -fn s(path: &PathBuf) -> String { +fn s(path: &Path) -> String { path.to_string_lossy().into_owned() } +fn request_path(path: &Path) -> String { + let raw = s(path); + #[cfg(windows)] + { + if let Some(rest) = raw.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{rest}"); + } + if let Some(rest) = raw.strip_prefix(r"\\?\") { + return rest.to_string(); + } + } + raw +} + +fn filesystem_root() -> PathBuf { + std::env::current_dir() + .expect("current directory") + .ancestors() + .last() + .expect("filesystem root") + .to_path_buf() +} + #[test] fn a_path_inside_a_root_resolves() { let sb = sandbox(); @@ -71,10 +94,16 @@ fn dot_dot_is_refused_even_when_it_would_land_inside() { // client that knows where it wants to go can say so, and allowing it would // make the two resolvers have to agree about a partly-resolvable `..`. let sb = sandbox(); + let root = request_path(&sb.root); + let requested = if cfg!(windows) { + format!(r"{root}\sub\..\inside.txt") + } else { + format!("{root}/sub/../inside.txt") + }; let err = sb .roots - .resolve_existing(&format!("{}/sub/../inside.txt", s(&sb.root))) + .resolve_existing(&requested) .unwrap_err(); assert_eq!(err, FsDenied::Traversal); @@ -90,7 +119,7 @@ fn a_symlink_pointing_out_of_the_root_is_refused() { let err = sb .roots - .resolve_existing(&format!("{}/escape/secret.txt", s(&sb.root))) + .resolve_existing(&s(&sb.root.join("escape").join("secret.txt"))) .unwrap_err(); assert_eq!(err, FsDenied::OutsideRoots); @@ -105,7 +134,7 @@ fn a_symlink_staying_inside_the_root_is_allowed() { let resolved = sb .roots - .resolve_existing(&format!("{}/link", s(&sb.root))) + .resolve_existing(&s(&sb.root.join("link"))) .expect("still inside"); assert_eq!(resolved, sb.root.join("inside.txt")); @@ -121,7 +150,7 @@ fn a_write_through_a_symlinked_parent_is_refused() { let err = sb .roots - .resolve_new(&format!("{}/escape/planted.sh", s(&sb.root))) + .resolve_new(&s(&sb.root.join("escape").join("planted.sh"))) .unwrap_err(); assert_eq!(err, FsDenied::OutsideRoots); @@ -133,10 +162,10 @@ fn a_file_that_does_not_exist_yet_resolves_under_its_parent() { let resolved = sb .roots - .resolve_new(&format!("{}/sub/new.txt", s(&sb.root))) + .resolve_new(&s(&sb.root.join("sub").join("new.txt"))) .expect("a new file in an existing directory"); - assert_eq!(resolved, sb.root.join("sub/new.txt")); + assert_eq!(resolved, sb.root.join("sub").join("new.txt")); } #[test] @@ -145,10 +174,10 @@ fn a_directory_tree_that_does_not_exist_yet_resolves() { let resolved = sb .roots - .resolve_new(&format!("{}/a/b/c", s(&sb.root))) + .resolve_new(&s(&sb.root.join("a").join("b").join("c"))) .expect("several missing levels"); - assert_eq!(resolved, sb.root.join("a/b/c")); + assert_eq!(resolved, sb.root.join("a").join("b").join("c")); } #[test] @@ -157,7 +186,7 @@ fn a_new_path_outside_the_roots_is_refused() { let err = sb .roots - .resolve_new(&format!("{}/planted.sh", s(&sb.outside))) + .resolve_new(&s(&sb.outside.join("planted.sh"))) .unwrap_err(); assert_eq!(err, FsDenied::OutsideRoots); @@ -201,8 +230,9 @@ fn no_roots_means_nothing_resolves() { let roots = FsRoots::from_canonical(vec![]); assert!(roots.is_empty()); + let existing = std::env::current_dir().expect("current directory"); assert_eq!( - roots.resolve_existing("/etc/passwd").unwrap_err(), + roots.resolve_existing(&s(&existing)).unwrap_err(), FsDenied::OutsideRoots ); } @@ -210,8 +240,9 @@ fn no_roots_means_nothing_resolves() { #[test] fn the_filesystem_root_is_recognised_as_unrestricted() { // What the startup warning keys on. - assert!(FsRoots::from_canonical(vec![PathBuf::from("/")]).is_unrestricted()); - assert!(!FsRoots::from_canonical(vec![PathBuf::from("/srv/data")]).is_unrestricted()); + assert!(FsRoots::from_canonical(vec![filesystem_root()]).is_unrestricted()); + let nested = std::env::current_dir().expect("current directory"); + assert!(!FsRoots::from_canonical(vec![nested]).is_unrestricted()); } #[test] diff --git a/monitor/tests/terminal_ws.rs b/monitor/tests/terminal_ws.rs index 90a60026a6..7d310ca38b 100644 --- a/monitor/tests/terminal_ws.rs +++ b/monitor/tests/terminal_ws.rs @@ -303,6 +303,25 @@ async fn read_until(io: &Io, codec: &ws::Codec, needle: &[u8]) -> Vec, codec: &ws::Codec) { + let query = b"\x1b[6n"; + let seen = read_until(io, codec, query).await; + assert!( + seen.windows(query.len()).any(|window| window == query), + "ConPTY should ask for the cursor position; saw {seen:?}" + ); + io.send( + ws::Message::Binary(ntex::util::Bytes::from_static(b"\x1b[1;1R")), + codec, + ) + .await + .unwrap(); +} + +#[cfg(not(windows))] +async fn answer_cursor_position_query(_io: &Io, _codec: &ws::Codec) {} + #[ntex::test] async fn a_password_login_produces_a_working_shell() { let sshd = fake_sshd::start(false).await; @@ -638,9 +657,10 @@ async fn a_full_access_open_starts_a_shell_without_any_credential() { let ready = next_control(&io, &codec).await; assert_eq!(ready["type"], "ready", "expected a shell, got {ready}"); + answer_cursor_position_query(&io, &codec).await; + io.send( - ws::Message::Binary(ntex::util::Bytes::from_static(b"echo full-access-ok -")), + ws::Message::Binary(ntex::util::Bytes::from_static(b"echo full-access-ok\r")), &codec, ) .await diff --git a/test/android_rootfs_path_test.dart b/test/android_rootfs_path_test.dart index c7b777d277..44f75409c3 100644 --- a/test/android_rootfs_path_test.dart +++ b/test/android_rootfs_path_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; import 'package:server_box/core/utils/android_rootfs.dart'; /// What the Agent's file tools may reach when the local target is the rootfs. @@ -19,9 +20,11 @@ void main() { // the same shape as Android's `/data/user/0` and would otherwise make // every comparison below fail for the wrong reason. realRoot = await root.resolveSymbolicLinks(); - await Directory('${root.path}/etc').create(recursive: true); - await File('${root.path}/etc/alpine-release').writeAsString('3.22.5\n'); - await Directory('${root.path}/tmp').create(recursive: true); + await Directory(p.join(root.path, 'etc')).create(recursive: true); + await File( + p.join(root.path, 'etc', 'alpine-release'), + ).writeAsString('3.22.5\n'); + await Directory(p.join(root.path, 'tmp')).create(recursive: true); }); tearDown(() => root.delete(recursive: true)); @@ -29,9 +32,12 @@ void main() { Future resolve(String guest, {bool forWrite = false}) => AndroidRootfs.resolveWithin(root.path, guest, forWrite: forWrite); + String inside(String relative) => + p.joinAll([realRoot, ...p.posix.split(relative)]); + group('a path inside the container', () { test('is the same path under the rootfs', () async { - expect(await resolve('/etc/alpine-release'), '$realRoot/etc/alpine-release'); + expect(await resolve('/etc/alpine-release'), inside('etc/alpine-release')); }); test('is the root itself when that is what was asked for', () async { @@ -39,11 +45,17 @@ void main() { }); test('resolves . and redundant separators', () async { - expect(await resolve('/./etc//alpine-release'), '$realRoot/etc/alpine-release'); + expect( + await resolve('/./etc//alpine-release'), + inside('etc/alpine-release'), + ); }); test('a file that does not exist yet can still be written', () async { - expect(await resolve('/tmp/new.txt', forWrite: true), '$realRoot/tmp/new.txt'); + expect( + await resolve('/tmp/new.txt', forWrite: true), + inside('tmp/new.txt'), + ); }); }); @@ -51,8 +63,14 @@ void main() { test('.. is resolved against the container root, not the host', () async { // Not an escape: inside a container `/../etc` is `/etc`, and clamping it // silently would hand back a different file than the one named. - expect(await resolve('/../etc/alpine-release'), '$realRoot/etc/alpine-release'); - expect(await resolve('/etc/../../etc/alpine-release'), '$realRoot/etc/alpine-release'); + expect( + await resolve('/../etc/alpine-release'), + inside('etc/alpine-release'), + ); + expect( + await resolve('/etc/../../etc/alpine-release'), + inside('etc/alpine-release'), + ); }); test('a symlink out of the container is refused', () async { @@ -60,17 +78,19 @@ void main() { // without asking anybody. final outside = await Directory.systemTemp.createTemp('outside'); addTearDown(() => outside.delete(recursive: true)); - await File('${outside.path}/secret').writeAsString('not yours'); - await Link('${root.path}/tmp/out').create(outside.path); + await File(p.join(outside.path, 'secret')).writeAsString('not yours'); + await Link(p.join(root.path, 'tmp', 'out')).create(outside.path); expect(await resolve('/tmp/out/secret'), isNull); expect(await resolve('/tmp/out/secret', forWrite: true), isNull); }); test('a symlink within the container is followed', () async { - await Link('${root.path}/tmp/release').create('$realRoot/etc/alpine-release'); + await Link( + p.join(root.path, 'tmp', 'release'), + ).create(inside('etc/alpine-release')); - expect(await resolve('/tmp/release'), '$realRoot/etc/alpine-release'); + expect(await resolve('/tmp/release'), inside('etc/alpine-release')); }); test('a relative path means nothing here', () async { @@ -90,7 +110,7 @@ void main() { test('no rootfs on disk means nothing is inside one', () async { expect( - await AndroidRootfs.resolveWithin('${root.path}/gone', '/etc/hosts'), + await AndroidRootfs.resolveWithin(p.join(root.path, 'gone'), '/etc/hosts'), isNull, ); }); diff --git a/test/copy_tree_test.dart b/test/copy_tree_test.dart index dfad62efa6..3d809f4a1c 100644 --- a/test/copy_tree_test.dart +++ b/test/copy_tree_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; import 'package:server_box/core/utils/local_file_backend.dart'; import 'package:server_box/data/model/file/copy_tree.dart'; @@ -97,7 +98,7 @@ void main() { expect(File(at('dest.txt')).readAsStringSync(), 'the good one'); expect( - Directory(tempDir.path).listSync().map((e) => e.path.split('/').last), + Directory(tempDir.path).listSync().map((e) => p.basename(e.path)), ['dest.txt'], ); }); diff --git a/test/frb_parser_test.dart b/test/frb_parser_test.dart index 29ff210a24..53e2a2337c 100644 --- a/test/frb_parser_test.dart +++ b/test/frb_parser_test.dart @@ -6,13 +6,13 @@ import 'dart:convert'; import 'dart:io'; -import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:server_box/data/model/app/scripts/cmd_types.dart'; import 'package:server_box/data/model/app/scripts/script_consts.dart'; import 'package:server_box/src/rust/api/parser.dart'; import 'package:server_box/src/rust/api/script.dart' as script; -import 'package:server_box/src/rust/frb_generated.dart'; + +import 'rust_lib_helper.dart'; const _cpuRaw = '''cpu 18232538 52837 5772391 334460731 247294 0 134107 0 0 0 cpu0 1823253 5283 577239 33446073 24729 0 13410 0 0 0'''; @@ -40,12 +40,7 @@ const _connRaw = 'Tcp: 1 200 120000 -1 11 22 33 44 55 66 77 88 99 111 222'; void main() { - setUpAll(() async { - final lib = File('target/debug/libsbm_ffi.dylib').existsSync() - ? 'target/debug/libsbm_ffi.dylib' - : 'target/debug/libsbm_ffi.so'; - await RustLib.init(externalLibrary: ExternalLibrary.open(lib)); - }); + setUpAll(initRustLibForTest); Future> parseViaFfi(Map raw) async { final json = await parseStatusJson( diff --git a/test/local_file_backend_test.dart b/test/local_file_backend_test.dart index f6784556b0..111a788de4 100644 --- a/test/local_file_backend_test.dart +++ b/test/local_file_backend_test.dart @@ -37,7 +37,7 @@ void main() { // not a number anybody wants shown beside a folder. expect(byName['sub']!.size, isNull); expect(byName['l']!.kind, FileKind.link); - expect(byName['l']!.linkTarget, at('a.txt')); + expect(byName['l']!.linkTarget, at('a.txt').replaceAll(r'\', '/')); }); test('a link to nowhere is still listed', () async { From f103d8d903471b5c2bca89f6c33b4f7bb64f79a6 Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 16 Aug 2026 13:16:18 +0800 Subject: [PATCH 2/5] ci: add safe cross-platform verification --- .github/workflows/analysis.yml | 4 +- .github/workflows/build.yml | 107 ++++++++++++++++++++++++++++++++- .github/workflows/macos.yml | 2 + 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index acd26aa44f..c8bf469173 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -16,6 +16,8 @@ permissions: env: FLUTTER_VERSION: '3.47.0' + FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn + PUB_HOSTED_URL: https://pub.flutter-io.cn jobs: reproducibilityCheck: @@ -48,8 +50,6 @@ jobs: fi - name: Check locked dependencies - env: - PUB_HOSTED_URL: https://pub.dev run: | flutter pub get --enforce-lockfile git diff --exit-code -- pubspec.yaml pubspec.lock diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a23cba5240..39e67df62e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,6 +19,8 @@ env: APP_NAME: ServerBox BUILD_TAG: test-build-${{ github.run_number }}-${{ github.sha }} FLUTTER_VERSION: "3.47.0" + FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn + PUB_HOSTED_URL: https://pub.flutter-io.cn jobs: reproducibilityCheck: @@ -65,8 +67,6 @@ jobs: fi - name: Check locked dependencies shell: bash - env: - PUB_HOSTED_URL: https://pub.dev run: | flutter pub get --enforce-lockfile git diff --exit-code -- pubspec.yaml pubspec.lock @@ -74,6 +74,109 @@ jobs: shell: bash run: scripts/release/patch-jni-build-id.sh + verifyMacOS: + name: Verify macOS integration + if: github.event_name == 'workflow_dispatch' && inputs.release != true + needs: reproducibilityCheck + runs-on: macos-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: recursive + persist-credentials: false + fetch-depth: 1 + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: ${{ env.FLUTTER_VERSION }} + - uses: Swatinem/rust-cache@v2 + - name: Install dependencies + run: flutter pub get + - name: Sign locally + shell: bash + run: | + set -euo pipefail + project=macos/Runner.xcodeproj/project.pbxproj + sed -i '' \ + -e 's/CODE_SIGN_IDENTITY = "Apple Development";/CODE_SIGN_IDENTITY = "-";/' \ + -e 's/CODE_SIGN_STYLE = Automatic;/CODE_SIGN_STYLE = Manual;/' \ + -e 's/DEVELOPMENT_TEAM = BA88US33G6;/DEVELOPMENT_TEAM = "";/' \ + "$project" + cat > macos/Runner/DebugProfile.entitlements <<'EOF' + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.files.user-selected.read-write + + + + EOF + - name: Run integration tests + run: flutter test integration_test -d macos + + verifyMonitor: + name: Verify monitor ${{ matrix.platform }}-${{ matrix.arch }} + if: github.event_name == 'workflow_dispatch' && inputs.release != true + needs: reproducibilityCheck + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + platform: linux + arch: amd64 + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + platform: linux + arch: arm64 + - os: macos-latest + target: aarch64-apple-darwin + platform: macos + arch: arm64 + - os: macos-latest + target: x86_64-apple-darwin + platform: macos + arch: amd64 + - os: windows-latest + target: x86_64-pc-windows-msvc + platform: windows + arch: amd64 + runs-on: ${{ matrix.os }} + env: + SQLX_OFFLINE: "true" + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: recursive + persist-credentials: false + - name: Install musl toolchain + if: matrix.platform == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y musl-tools + - name: Add Rust target + run: rustup target add ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + - name: Build + shell: bash + run: cargo build -p server_box_monitor --release --target ${{ matrix.target }} + buildAndroid: name: Build android needs: reproducibilityCheck diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index f9df793247..2c33bb1fda 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -35,6 +35,8 @@ permissions: env: FLUTTER_VERSION: '3.47.0' + FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn + PUB_HOSTED_URL: https://pub.flutter-io.cn jobs: integration: From c74dd952d7cf71b60c13980d57661a405c1779eb Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 16 Aug 2026 13:22:19 +0800 Subject: [PATCH 3/5] ci: preserve lockfiles when using pub mirror --- .github/workflows/analysis.yml | 9 +++++++++ .github/workflows/build.yml | 20 ++++++++++++++++++++ .github/workflows/macos.yml | 4 ++++ scripts/ci/use-pub-mirror.sh | 26 ++++++++++++++++++++++++++ 4 files changed, 59 insertions(+) create mode 100644 scripts/ci/use-pub-mirror.sh diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index c8bf469173..985a0f5d06 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -51,7 +51,12 @@ jobs: - name: Check locked dependencies run: | + cp pubspec.lock "$RUNNER_TEMP/pubspec.lock" + trap 'cp "$RUNNER_TEMP/pubspec.lock" pubspec.lock' EXIT + bash scripts/ci/use-pub-mirror.sh pubspec.lock flutter pub get --enforce-lockfile + cp "$RUNNER_TEMP/pubspec.lock" pubspec.lock + trap - EXIT git diff --exit-code -- pubspec.yaml pubspec.lock - name: Check JNI build-id patch @@ -92,6 +97,10 @@ jobs: channel: 'stable' flutter-version: ${{ env.FLUTTER_VERSION }} + - name: Use pub mirror with locked versions + shell: bash + run: bash scripts/ci/use-pub-mirror.sh + - name: Install dependencies run: flutter pub get diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 39e67df62e..43c7998e1e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,7 +68,12 @@ jobs: - name: Check locked dependencies shell: bash run: | + cp pubspec.lock "$RUNNER_TEMP/pubspec.lock" + trap 'cp "$RUNNER_TEMP/pubspec.lock" pubspec.lock' EXIT + bash scripts/ci/use-pub-mirror.sh pubspec.lock flutter pub get --enforce-lockfile + cp "$RUNNER_TEMP/pubspec.lock" pubspec.lock + trap - EXIT git diff --exit-code -- pubspec.yaml pubspec.lock - name: Check JNI build-id patch shell: bash @@ -93,6 +98,9 @@ jobs: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} - uses: Swatinem/rust-cache@v2 + - name: Use pub mirror with locked versions + shell: bash + run: bash scripts/ci/use-pub-mirror.sh - name: Install dependencies run: flutter pub get - name: Sign locally @@ -197,6 +205,9 @@ jobs: with: distribution: "zulu" java-version: "21" + - name: Use pub mirror with locked versions + shell: bash + run: bash scripts/ci/use-pub-mirror.sh - name: Fetch secrets run: | curl --fail --show-error --location -u ${{ secrets.BASIC_AUTH }} -o android/app/app.key ${{ secrets.URL_PREFIX }}app.key @@ -283,6 +294,9 @@ jobs: with: channel: "stable" flutter-version: ${{ env.FLUTTER_VERSION }} + - name: Use pub mirror with locked versions + shell: bash + run: bash scripts/ci/use-pub-mirror.sh - name: Resolve build number shell: bash run: | @@ -353,6 +367,9 @@ jobs: sudo apt update sudo apt install -y clang cmake ninja-build pkg-config libgtk-3-dev mesa-utils libvulkan-dev desktop-file-utils wget sudo apt install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libunwind-dev libsecret-1-dev + - name: Use pub mirror with locked versions + shell: bash + run: bash scripts/ci/use-pub-mirror.sh - name: Build (release) if: inputs.release == true || github.ref_type == 'tag' shell: bash @@ -406,6 +423,9 @@ jobs: with: channel: 'stable' flutter-version: ${{ env.FLUTTER_VERSION }} + - name: Use pub mirror with locked versions + shell: bash + run: bash scripts/ci/use-pub-mirror.sh - name: Build (release) if: inputs.release == true || github.ref_type == 'tag' shell: bash diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 2c33bb1fda..27230a96e9 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -57,6 +57,10 @@ jobs: - uses: Swatinem/rust-cache@v2 + - name: Use pub mirror with locked versions + shell: bash + run: bash scripts/ci/use-pub-mirror.sh + - name: Install dependencies run: flutter pub get diff --git a/scripts/ci/use-pub-mirror.sh b/scripts/ci/use-pub-mirror.sh new file mode 100644 index 0000000000..80d9ffdcf2 --- /dev/null +++ b/scripts/ci/use-pub-mirror.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${PUB_HOSTED_URL:?PUB_HOSTED_URL must be set}" + +replace_host() { + local lockfile="$1" + local mirror="${PUB_HOSTED_URL%/}" + + if [[ "${RUNNER_OS:-}" == "macOS" ]]; then + sed -i '' "s#https://pub.dev#${mirror}#g" "$lockfile" + else + sed -i "s#https://pub.dev#${mirror}#g" "$lockfile" + fi +} + +if (( $# > 0 )); then + for lockfile in "$@"; do + replace_host "$lockfile" + done +else + while IFS= read -r -d '' lockfile; do + replace_host "$lockfile" + done < <(git ls-files -z '*pubspec.lock') +fi From f966a2a04b5425dd981b34fbc116e9b957efc234 Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 16 Aug 2026 13:40:50 +0800 Subject: [PATCH 4/5] ci: verify macOS release launch --- .github/workflows/build.yml | 42 +++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 43c7998e1e..a9bbd72e42 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,7 +80,7 @@ jobs: run: scripts/release/patch-jni-build-id.sh verifyMacOS: - name: Verify macOS integration + name: Verify macOS release if: github.event_name == 'workflow_dispatch' && inputs.release != true needs: reproducibilityCheck runs-on: macos-latest @@ -110,10 +110,12 @@ jobs: project=macos/Runner.xcodeproj/project.pbxproj sed -i '' \ -e 's/CODE_SIGN_IDENTITY = "Apple Development";/CODE_SIGN_IDENTITY = "-";/' \ + -e 's/"CODE_SIGN_IDENTITY\[sdk=macosx\*\]" = "3rd Party Mac Developer Application";/"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";/' \ -e 's/CODE_SIGN_STYLE = Automatic;/CODE_SIGN_STYLE = Manual;/' \ -e 's/DEVELOPMENT_TEAM = BA88US33G6;/DEVELOPMENT_TEAM = "";/' \ + -e 's/"DEVELOPMENT_TEAM\[sdk=macosx\*\]" = BA88US33G6;/"DEVELOPMENT_TEAM[sdk=macosx*]" = "";/' \ "$project" - cat > macos/Runner/DebugProfile.entitlements <<'EOF' + cat > macos/Runner/Release.entitlements <<'EOF' @@ -131,8 +133,40 @@ jobs: EOF - - name: Run integration tests - run: flutter test integration_test -d macos + - name: Build release + run: flutter build macos --release + - name: Verify release launch + shell: bash + run: | + set -euo pipefail + app="build/macos/Build/Products/Release/Server Box.app" + executable="$app/Contents/MacOS/Server Box" + log_file="$RUNNER_TEMP/server-box.log" + + codesign --verify --deep --strict "$app" + "$executable" >"$log_file" 2>&1 & + pid=$! + + cleanup() { + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" + wait "$pid" || true + fi + } + trap cleanup EXIT + + for _ in {1..10}; do + if ! kill -0 "$pid" 2>/dev/null; then + wait "$pid" || true + cat "$log_file" + log show --last 2m --style compact --predicate 'process == "Server Box"' || true + echo "Server Box exited before the macOS launch smoke test completed." + exit 1 + fi + sleep 1 + done + + cat "$log_file" verifyMonitor: name: Verify monitor ${{ matrix.platform }}-${{ matrix.arch }} From 43a6951b468f701b638a4eb9d07fdf42e29f32e2 Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 16 Aug 2026 17:32:20 +0800 Subject: [PATCH 5/5] fix: preserve local PTY event ordering --- monitor/src/ssh/local_pty.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/monitor/src/ssh/local_pty.rs b/monitor/src/ssh/local_pty.rs index 74ae6fd9b3..5ff69951d1 100644 --- a/monitor/src/ssh/local_pty.rs +++ b/monitor/src/ssh/local_pty.rs @@ -144,17 +144,28 @@ impl LocalShell { let (tx, rx) = mpsc::channel(64); let child = Arc::new(Mutex::new(child)); let reader_done = Arc::new((Mutex::new(false), Condvar::new())); + // Data and exit share this gate so an exit closes data delivery before + // it is enqueued. Holding it across blocking_send also lets any data + // already under backpressure finish before the exit notification. + let delivery_closed = Arc::new(Mutex::new(false)); // A PTY read is blocking, so it gets a thread rather than a task. One // thread per open terminal, bounded by `terminal_max_sessions`. let reader_tx = tx.clone(); let reader_finished = reader_done.clone(); + let reader_delivery_closed = delivery_closed.clone(); std::thread::spawn(move || { let mut buf = [0u8; 8 * 1024]; loop { match reader.read(&mut buf) { Ok(0) | Err(_) => break, Ok(n) => { + let delivery_closed = reader_delivery_closed + .lock() + .unwrap_or_else(|e| e.into_inner()); + if *delivery_closed { + break; + } if reader_tx.blocking_send(ShellEvent::Data(buf[..n].to_vec())).is_err() { break; } @@ -175,6 +186,7 @@ impl LocalShell { // the mutex available between polls, so `kill` cannot deadlock behind // a blocking wait. let reaper = child.clone(); + let exit_delivery_closed = delivery_closed.clone(); std::thread::spawn(move || loop { let status = reaper .lock() @@ -197,6 +209,10 @@ impl LocalShell { std::time::Duration::from_millis(100), |done| !*done, )); + let mut delivery_closed = exit_delivery_closed + .lock() + .unwrap_or_else(|e| e.into_inner()); + *delivery_closed = true; let _ = tx.blocking_send(ShellEvent::Exit(status)); break; } @@ -326,9 +342,10 @@ mod tests { .await .unwrap_or(None); - assert!( - exit.is_some(), - "the terminal must learn that the shell is gone, not hang" + assert_eq!( + exit, + Some(Some(7)), + "the terminal must retain the shell's requested exit status" ); }