From 71b2dbab6dfab7948ac416edd2590197cdf96a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Sun, 5 Apr 2026 18:38:05 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix=EF=BC=9A=20portable=20runtime=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci/package_windows_portable.py | 11 +++++----- scripts/ci/test_package_windows_portable.py | 24 +++++++++------------ 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/scripts/ci/package_windows_portable.py b/scripts/ci/package_windows_portable.py index 5f4d0c06..d4a2c73d 100644 --- a/scripts/ci/package_windows_portable.py +++ b/scripts/ci/package_windows_portable.py @@ -38,6 +38,8 @@ CARGO_TOML_RELATIVE_PATH = pathlib.Path("src-tauri") / "Cargo.toml" BACKEND_RESOURCE_RELATIVE_PATH = pathlib.Path("resources") / "backend" WEBUI_RESOURCE_RELATIVE_PATH = pathlib.Path("resources") / "webui" +PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH = pathlib.Path("backend") +PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH = pathlib.Path("webui") WINDOWS_CLEANUP_SCRIPT_RELATIVE_PATH = ( pathlib.Path("src-tauri") / "windows" / "kill-backend-processes.ps1" ) @@ -263,16 +265,15 @@ def populate_portable_root( if cleanup_script.is_file(): shutil.copy2(cleanup_script, destination_root / "kill-backend-processes.ps1") - resources_root = destination_root / "resources" backend_src = project_config.root / BACKEND_RESOURCE_RELATIVE_PATH if not backend_src.is_dir(): raise FileNotFoundError(f"Required directory not found: {backend_src}") - shutil.copytree(backend_src, resources_root / "backend") + shutil.copytree(backend_src, destination_root / PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH) webui_src = project_config.root / WEBUI_RESOURCE_RELATIVE_PATH if not webui_src.is_dir(): raise FileNotFoundError(f"Required directory not found: {webui_src}") - shutil.copytree(webui_src, resources_root / "webui") + shutil.copytree(webui_src, destination_root / PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH) add_portable_runtime_files(destination_root, project_config) validate_portable_root(destination_root) @@ -292,8 +293,8 @@ def add_portable_runtime_files( def validate_portable_root(destination_root: pathlib.Path) -> None: expected_paths = [ - destination_root / "resources" / "backend" / "runtime-manifest.json", - destination_root / "resources" / "webui" / "index.html", + destination_root / PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH / "runtime-manifest.json", + destination_root / PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH / "index.html", ] missing = [ str(path.relative_to(destination_root)) diff --git a/scripts/ci/test_package_windows_portable.py b/scripts/ci/test_package_windows_portable.py index 74ec1873..b9370936 100644 --- a/scripts/ci/test_package_windows_portable.py +++ b/scripts/ci/test_package_windows_portable.py @@ -423,13 +423,9 @@ def test_populate_portable_root_copies_release_bundle_contents(self): self.assertFalse((destination_root / "astrbot-desktop-tauri.exe").exists()) self.assertTrue((destination_root / "WebView2Loader.dll").is_file()) self.assertTrue( - ( - destination_root / "resources" / "backend" / "runtime-manifest.json" - ).is_file() - ) - self.assertTrue( - (destination_root / "resources" / "webui" / "index.html").is_file() + (destination_root / "backend" / "runtime-manifest.json").is_file() ) + self.assertTrue((destination_root / "webui" / "index.html").is_file()) self.assertTrue((destination_root / "kill-backend-processes.ps1").is_file()) self.assertTrue((destination_root / "portable.flag").is_file()) self.assertTrue((destination_root / MODULE.PORTABLE_README_NAME).is_file()) @@ -480,10 +476,10 @@ def test_validate_portable_root_accepts_expected_layout(self): with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) (root / "AstrBot.exe").write_text("binary") - (root / "resources" / "backend").mkdir(parents=True) - (root / "resources" / "webui").mkdir(parents=True) - (root / "resources" / "backend" / "runtime-manifest.json").write_text("{}") - (root / "resources" / "webui" / "index.html").write_text("") + (root / "backend").mkdir(parents=True) + (root / "webui").mkdir(parents=True) + (root / "backend" / "runtime-manifest.json").write_text("{}") + (root / "webui" / "index.html").write_text("") MODULE.validate_portable_root(root) @@ -498,10 +494,10 @@ def test_validate_portable_root_requires_expected_files(self): def test_validate_portable_root_requires_top_level_exe(self): with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) - (root / "resources" / "backend").mkdir(parents=True) - (root / "resources" / "webui").mkdir(parents=True) - (root / "resources" / "backend" / "runtime-manifest.json").write_text("{}") - (root / "resources" / "webui" / "index.html").write_text("") + (root / "backend").mkdir(parents=True) + (root / "webui").mkdir(parents=True) + (root / "backend" / "runtime-manifest.json").write_text("{}") + (root / "webui" / "index.html").write_text("") with self.assertRaisesRegex(ValueError, r"top-level \*\.exe"): MODULE.validate_portable_root(root) From b1181d7ab23dd4b9ecb40c53f7c5030983a47601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Sun, 5 Apr 2026 18:46:12 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix=EF=BC=9A=20tighten=20portable=20layout?= =?UTF-8?q?=20review=20follow-ups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci/package_windows_portable.py | 2 ++ scripts/ci/test_package_windows_portable.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/scripts/ci/package_windows_portable.py b/scripts/ci/package_windows_portable.py index d4a2c73d..2e36d111 100644 --- a/scripts/ci/package_windows_portable.py +++ b/scripts/ci/package_windows_portable.py @@ -36,8 +36,10 @@ """ TAURI_CONFIG_RELATIVE_PATH = pathlib.Path("src-tauri") / "tauri.conf.json" CARGO_TOML_RELATIVE_PATH = pathlib.Path("src-tauri") / "Cargo.toml" +# These point to the source resource directories inside the repository checkout. BACKEND_RESOURCE_RELATIVE_PATH = pathlib.Path("resources") / "backend" WEBUI_RESOURCE_RELATIVE_PATH = pathlib.Path("resources") / "webui" +# These are the runtime-visible locations emitted into the portable package root. PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH = pathlib.Path("backend") PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH = pathlib.Path("webui") WINDOWS_CLEANUP_SCRIPT_RELATIVE_PATH = ( diff --git a/scripts/ci/test_package_windows_portable.py b/scripts/ci/test_package_windows_portable.py index b9370936..8573619a 100644 --- a/scripts/ci/test_package_windows_portable.py +++ b/scripts/ci/test_package_windows_portable.py @@ -426,6 +426,8 @@ def test_populate_portable_root_copies_release_bundle_contents(self): (destination_root / "backend" / "runtime-manifest.json").is_file() ) self.assertTrue((destination_root / "webui" / "index.html").is_file()) + self.assertFalse((destination_root / "resources" / "backend").exists()) + self.assertFalse((destination_root / "resources" / "webui").exists()) self.assertTrue((destination_root / "kill-backend-processes.ps1").is_file()) self.assertTrue((destination_root / "portable.flag").is_file()) self.assertTrue((destination_root / MODULE.PORTABLE_README_NAME).is_file()) From 558702b5d223ab61cbe178142c323890f16294da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Sun, 5 Apr 2026 18:57:45 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix=EF=BC=9A=20align=20portable=20layout=20?= =?UTF-8?q?paths=20in=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci/test_package_windows_portable.py | 37 +++++++++++++++------ 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/scripts/ci/test_package_windows_portable.py b/scripts/ci/test_package_windows_portable.py index 8573619a..c19ac8ad 100644 --- a/scripts/ci/test_package_windows_portable.py +++ b/scripts/ci/test_package_windows_portable.py @@ -6,6 +6,9 @@ from scripts.ci import package_windows_portable as MODULE +PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH = MODULE.PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH +PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH = MODULE.PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH + class PackageWindowsPortableTests(unittest.TestCase): def make_project_layout( @@ -423,9 +426,15 @@ def test_populate_portable_root_copies_release_bundle_contents(self): self.assertFalse((destination_root / "astrbot-desktop-tauri.exe").exists()) self.assertTrue((destination_root / "WebView2Loader.dll").is_file()) self.assertTrue( - (destination_root / "backend" / "runtime-manifest.json").is_file() + ( + destination_root + / PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH + / "runtime-manifest.json" + ).is_file() + ) + self.assertTrue( + (destination_root / PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH / "index.html").is_file() ) - self.assertTrue((destination_root / "webui" / "index.html").is_file()) self.assertFalse((destination_root / "resources" / "backend").exists()) self.assertFalse((destination_root / "resources" / "webui").exists()) self.assertTrue((destination_root / "kill-backend-processes.ps1").is_file()) @@ -478,10 +487,14 @@ def test_validate_portable_root_accepts_expected_layout(self): with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) (root / "AstrBot.exe").write_text("binary") - (root / "backend").mkdir(parents=True) - (root / "webui").mkdir(parents=True) - (root / "backend" / "runtime-manifest.json").write_text("{}") - (root / "webui" / "index.html").write_text("") + (root / PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH).mkdir(parents=True) + (root / PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH).mkdir(parents=True) + ( + root / PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH / "runtime-manifest.json" + ).write_text("{}") + (root / PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH / "index.html").write_text( + "" + ) MODULE.validate_portable_root(root) @@ -496,10 +509,14 @@ def test_validate_portable_root_requires_expected_files(self): def test_validate_portable_root_requires_top_level_exe(self): with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) - (root / "backend").mkdir(parents=True) - (root / "webui").mkdir(parents=True) - (root / "backend" / "runtime-manifest.json").write_text("{}") - (root / "webui" / "index.html").write_text("") + (root / PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH).mkdir(parents=True) + (root / PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH).mkdir(parents=True) + ( + root / PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH / "runtime-manifest.json" + ).write_text("{}") + (root / PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH / "index.html").write_text( + "" + ) with self.assertRaisesRegex(ValueError, r"top-level \*\.exe"): MODULE.validate_portable_root(root) From ff74aba486827baeb2c2f9cc451e7c46d0b43047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Sun, 5 Apr 2026 19:07:23 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix=EF=BC=9A=20derive=20portable=20resource?= =?UTF-8?q?=20aliases=20from=20tauri=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci/package_windows_portable.py | 88 ++++++++++++++++++--- scripts/ci/test_package_windows_portable.py | 82 +++++++++++++++---- src-tauri/Cargo.toml | 1 + src-tauri/build.rs | 67 +++++++++++++++- src-tauri/src/launch_plan.rs | 35 +++++++- 5 files changed, 241 insertions(+), 32 deletions(-) diff --git a/scripts/ci/package_windows_portable.py b/scripts/ci/package_windows_portable.py index 2e36d111..bfc80905 100644 --- a/scripts/ci/package_windows_portable.py +++ b/scripts/ci/package_windows_portable.py @@ -39,9 +39,6 @@ # These point to the source resource directories inside the repository checkout. BACKEND_RESOURCE_RELATIVE_PATH = pathlib.Path("resources") / "backend" WEBUI_RESOURCE_RELATIVE_PATH = pathlib.Path("resources") / "webui" -# These are the runtime-visible locations emitted into the portable package root. -PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH = pathlib.Path("backend") -PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH = pathlib.Path("webui") WINDOWS_CLEANUP_SCRIPT_RELATIVE_PATH = ( pathlib.Path("src-tauri") / "windows" / "kill-backend-processes.ps1" ) @@ -56,6 +53,8 @@ class ProjectConfig: product_name: str binary_name: str portable_marker_name: str + backend_layout_relative_path: pathlib.Path + webui_layout_relative_path: pathlib.Path def normalize_arch(arch: str) -> str: @@ -103,16 +102,70 @@ def load_portable_runtime_marker(project_root: pathlib.Path) -> str: return marker_name +def resolve_bundle_resource_alias_from_tauri_config( + project_root: pathlib.Path, + tauri_config: dict, + source_relative_path: pathlib.Path, +) -> pathlib.Path: + bundle_table = tauri_config.get("bundle") + if not isinstance(bundle_table, dict): + raise ValueError(f"Missing bundle table in {TAURI_CONFIG_RELATIVE_PATH}") + + resources_table = bundle_table.get("resources") + if not isinstance(resources_table, dict): + raise ValueError( + f"Missing bundle.resources table in {TAURI_CONFIG_RELATIVE_PATH}" + ) + + tauri_config_dir = (project_root / TAURI_CONFIG_RELATIVE_PATH).parent.resolve() + expected_source_path = (project_root / source_relative_path).resolve() + for source_path_text, alias_text in resources_table.items(): + candidate_source_path = (tauri_config_dir / str(source_path_text)).resolve() + if candidate_source_path != expected_source_path: + continue + + alias_path = pathlib.Path(str(alias_text).strip()) + if not alias_path.parts or alias_path.is_absolute(): + raise ValueError( + "Invalid bundle.resources alias for " + f"{source_relative_path} in {TAURI_CONFIG_RELATIVE_PATH}: {alias_text}" + ) + if any(part in (".", "..") for part in alias_path.parts): + raise ValueError( + "bundle.resources alias must not contain path traversal for " + f"{source_relative_path} in {TAURI_CONFIG_RELATIVE_PATH}: {alias_text}" + ) + return alias_path + + raise ValueError( + "Missing bundle.resources alias for " + f"{source_relative_path} in {TAURI_CONFIG_RELATIVE_PATH}" + ) + + def load_project_config_from(start_path: pathlib.Path) -> ProjectConfig: project_root = resolve_project_root_from(start_path) - product_name = resolve_product_name(project_root) + tauri_config = load_tauri_config(project_root) + product_name = resolve_product_name_from_tauri_config(tauri_config) binary_name = load_binary_name_from_cargo(project_root) portable_marker_name = load_portable_runtime_marker(project_root) + backend_layout_relative_path = resolve_bundle_resource_alias_from_tauri_config( + project_root, + tauri_config, + BACKEND_RESOURCE_RELATIVE_PATH, + ) + webui_layout_relative_path = resolve_bundle_resource_alias_from_tauri_config( + project_root, + tauri_config, + WEBUI_RESOURCE_RELATIVE_PATH, + ) return ProjectConfig( root=project_root, product_name=product_name, binary_name=binary_name, portable_marker_name=portable_marker_name, + backend_layout_relative_path=backend_layout_relative_path, + webui_layout_relative_path=webui_layout_relative_path, ) @@ -216,8 +269,7 @@ def load_binary_name_from_cargo(project_root: pathlib.Path) -> str: return binary_name -def resolve_product_name(project_root: pathlib.Path) -> str: - config = load_tauri_config(project_root) +def resolve_product_name_from_tauri_config(config: dict) -> str: product_name = str(config.get("productName", "")).strip() if not product_name: raise ValueError(f"Missing productName in {TAURI_CONFIG_RELATIVE_PATH}") @@ -231,6 +283,10 @@ def resolve_product_name(project_root: pathlib.Path) -> str: return product_name +def resolve_product_name(project_root: pathlib.Path) -> str: + return resolve_product_name_from_tauri_config(load_tauri_config(project_root)) + + def resolve_release_dir(bundle_dir: pathlib.Path) -> pathlib.Path: return bundle_dir.parent.parent @@ -270,15 +326,19 @@ def populate_portable_root( backend_src = project_config.root / BACKEND_RESOURCE_RELATIVE_PATH if not backend_src.is_dir(): raise FileNotFoundError(f"Required directory not found: {backend_src}") - shutil.copytree(backend_src, destination_root / PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH) + shutil.copytree( + backend_src, destination_root / project_config.backend_layout_relative_path + ) webui_src = project_config.root / WEBUI_RESOURCE_RELATIVE_PATH if not webui_src.is_dir(): raise FileNotFoundError(f"Required directory not found: {webui_src}") - shutil.copytree(webui_src, destination_root / PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH) + shutil.copytree( + webui_src, destination_root / project_config.webui_layout_relative_path + ) add_portable_runtime_files(destination_root, project_config) - validate_portable_root(destination_root) + validate_portable_root(destination_root, project_config) def add_portable_runtime_files( @@ -293,10 +353,14 @@ def add_portable_runtime_files( ) -def validate_portable_root(destination_root: pathlib.Path) -> None: +def validate_portable_root( + destination_root: pathlib.Path, project_config: ProjectConfig | None = None +) -> None: + if project_config is None: + project_config = load_project_config() expected_paths = [ - destination_root / PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH / "runtime-manifest.json", - destination_root / PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH / "index.html", + destination_root / project_config.backend_layout_relative_path / "runtime-manifest.json", + destination_root / project_config.webui_layout_relative_path / "index.html", ] missing = [ str(path.relative_to(destination_root)) diff --git a/scripts/ci/test_package_windows_portable.py b/scripts/ci/test_package_windows_portable.py index c19ac8ad..c35c44b6 100644 --- a/scripts/ci/test_package_windows_portable.py +++ b/scripts/ci/test_package_windows_portable.py @@ -6,9 +6,6 @@ from scripts.ci import package_windows_portable as MODULE -PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH = MODULE.PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH -PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH = MODULE.PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH - class PackageWindowsPortableTests(unittest.TestCase): def make_project_layout( @@ -17,17 +14,30 @@ def make_project_layout( product_name: str = "AstrBot", cargo_toml: str = '[package]\nname = "astrbot-desktop-tauri"\n', marker_name: str = "portable.flag\n", + tauri_resources: dict[str, str] | None = None, ) -> dict[str, Path]: project_root = Path(self.enterContext(tempfile.TemporaryDirectory())) script_path = project_root / "scripts" / "ci" / "package_windows_portable.py" tauri_config_path = project_root / "src-tauri" / "tauri.conf.json" cargo_toml_path = project_root / "src-tauri" / "Cargo.toml" marker_path = project_root / MODULE.PORTABLE_RUNTIME_MARKER_RELATIVE_PATH + if tauri_resources is None: + tauri_resources = { + "../resources/backend": "backend", + "../resources/webui": "webui", + } script_path.parent.mkdir(parents=True) script_path.write_text("# placeholder") tauri_config_path.parent.mkdir(parents=True) - tauri_config_path.write_text(json.dumps({"productName": product_name})) + tauri_config_path.write_text( + json.dumps( + { + "productName": product_name, + "bundle": {"resources": tauri_resources}, + } + ) + ) cargo_toml_path.write_text(cargo_toml) marker_path.parent.mkdir(parents=True, exist_ok=True) marker_path.write_text(marker_name) @@ -201,6 +211,27 @@ def test_load_project_config_from_returns_root_product_and_marker(self): self.assertEqual(project_config.product_name, "AstrBot") self.assertEqual(project_config.binary_name, "astrbot-desktop-tauri") self.assertEqual(project_config.portable_marker_name, "portable.flag") + self.assertEqual(project_config.backend_layout_relative_path, Path("backend")) + self.assertEqual(project_config.webui_layout_relative_path, Path("webui")) + + def test_load_project_config_from_reads_portable_layout_aliases_from_tauri_resources( + self, + ): + layout = self.make_project_layout( + tauri_resources={ + "../resources/backend": "runtime/backend", + "../resources/webui": "runtime/webui", + } + ) + + project_config = MODULE.load_project_config_from(layout["script_path"]) + + self.assertEqual( + project_config.backend_layout_relative_path, Path("runtime/backend") + ) + self.assertEqual( + project_config.webui_layout_relative_path, Path("runtime/webui") + ) def test_normalize_legacy_nightly_version_returns_base_version_and_suffix(self): self.assertEqual( @@ -363,6 +394,8 @@ def test_resolve_main_executable_path_uses_binary_name_not_product_name(self): product_name="AstrBot", binary_name="astrbot-desktop-tauri", portable_marker_name="portable.flag", + backend_layout_relative_path=Path("backend"), + webui_layout_relative_path=Path("webui"), ) self.assertEqual( @@ -428,12 +461,16 @@ def test_populate_portable_root_copies_release_bundle_contents(self): self.assertTrue( ( destination_root - / PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH + / project_config.backend_layout_relative_path / "runtime-manifest.json" ).is_file() ) self.assertTrue( - (destination_root / PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH / "index.html").is_file() + ( + destination_root + / project_config.webui_layout_relative_path + / "index.html" + ).is_file() ) self.assertFalse((destination_root / "resources" / "backend").exists()) self.assertFalse((destination_root / "resources" / "webui").exists()) @@ -473,6 +510,8 @@ def test_add_portable_runtime_files_writes_marker_and_readme(self): product_name="AstrBot", binary_name="astrbot-desktop-tauri", portable_marker_name="portable.flag", + backend_layout_relative_path=Path("backend"), + webui_layout_relative_path=Path("webui"), ) MODULE.add_portable_runtime_files(root, project_config) @@ -484,42 +523,51 @@ def test_add_portable_runtime_files_writes_marker_and_readme(self): ) def test_validate_portable_root_accepts_expected_layout(self): + layout = self.make_project_layout() + project_config = MODULE.load_project_config_from(layout["script_path"]) + with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) (root / "AstrBot.exe").write_text("binary") - (root / PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH).mkdir(parents=True) - (root / PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH).mkdir(parents=True) + (root / project_config.backend_layout_relative_path).mkdir(parents=True) + (root / project_config.webui_layout_relative_path).mkdir(parents=True) ( - root / PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH / "runtime-manifest.json" + root / project_config.backend_layout_relative_path / "runtime-manifest.json" ).write_text("{}") - (root / PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH / "index.html").write_text( + (root / project_config.webui_layout_relative_path / "index.html").write_text( "" ) - MODULE.validate_portable_root(root) + MODULE.validate_portable_root(root, project_config) def test_validate_portable_root_requires_expected_files(self): + layout = self.make_project_layout() + project_config = MODULE.load_project_config_from(layout["script_path"]) + with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) (root / "AstrBot.exe").write_text("binary") with self.assertRaisesRegex(ValueError, "runtime-manifest.json"): - MODULE.validate_portable_root(root) + MODULE.validate_portable_root(root, project_config) def test_validate_portable_root_requires_top_level_exe(self): + layout = self.make_project_layout() + project_config = MODULE.load_project_config_from(layout["script_path"]) + with tempfile.TemporaryDirectory() as tmpdir: root = Path(tmpdir) - (root / PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH).mkdir(parents=True) - (root / PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH).mkdir(parents=True) + (root / project_config.backend_layout_relative_path).mkdir(parents=True) + (root / project_config.webui_layout_relative_path).mkdir(parents=True) ( - root / PORTABLE_BACKEND_LAYOUT_RELATIVE_PATH / "runtime-manifest.json" + root / project_config.backend_layout_relative_path / "runtime-manifest.json" ).write_text("{}") - (root / PORTABLE_WEBUI_LAYOUT_RELATIVE_PATH / "index.html").write_text( + (root / project_config.webui_layout_relative_path / "index.html").write_text( "" ) with self.assertRaisesRegex(ValueError, r"top-level \*\.exe"): - MODULE.validate_portable_root(root) + MODULE.validate_portable_root(root, project_config) if __name__ == "__main__": diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3066bb2c..2019a708 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -10,6 +10,7 @@ rust-version = "1.86" build = "build.rs" [build-dependencies] +serde_json = "1.0" tauri-build = { version = "2.0", features = [] } [dependencies] diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 34927bf7..18cb73d9 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,8 +1,63 @@ -use std::{fs, path::Path}; +use serde_json::Value; +use std::{ + fs, + path::{Component, Path}, +}; + +const TAURI_CONFIG_PATH: &str = "tauri.conf.json"; +const BACKEND_RESOURCE_SOURCE: &str = "../resources/backend"; +const WEBUI_RESOURCE_SOURCE: &str = "../resources/webui"; + +fn load_bundle_resource_alias(tauri_config: &Value, source_relative_path: &str) -> String { + let resources = tauri_config + .get("bundle") + .and_then(|bundle| bundle.get("resources")) + .and_then(Value::as_object) + .unwrap_or_else(|| panic!("missing bundle.resources table in {TAURI_CONFIG_PATH}")); + + let alias = resources + .get(source_relative_path) + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or_else(|| { + panic!( + "missing bundle.resources alias for {} in {}", + source_relative_path, TAURI_CONFIG_PATH + ) + }); + assert!( + !alias.is_empty(), + "bundle.resources alias for {} is empty in {}", + source_relative_path, + TAURI_CONFIG_PATH + ); + + let alias_path = Path::new(alias); + assert!( + !alias_path.is_absolute() + && alias_path.components().all(|component| { + !matches!( + component, + Component::CurDir + | Component::ParentDir + | Component::Prefix(_) + | Component::RootDir + ) + }), + "bundle.resources alias for {} must be a relative path without traversal in {}: {}", + source_relative_path, + TAURI_CONFIG_PATH, + alias + ); + + alias.to_string() +} fn main() { let marker_path = Path::new("windows").join("portable-runtime-marker.txt"); + let tauri_config_path = Path::new(TAURI_CONFIG_PATH); println!("cargo:rerun-if-changed={}", marker_path.display()); + println!("cargo:rerun-if-changed={}", tauri_config_path.display()); let marker = fs::read_to_string(&marker_path) .unwrap_or_else(|error| panic!("failed to read {}: {error}", marker_path.display())); @@ -14,5 +69,15 @@ fn main() { ); println!("cargo:rustc-env=ASTRBOT_PORTABLE_RUNTIME_MARKER={marker}"); + let tauri_config_text = fs::read_to_string(tauri_config_path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", tauri_config_path.display())); + let tauri_config: Value = serde_json::from_str(&tauri_config_text) + .unwrap_or_else(|error| panic!("failed to parse {}: {error}", tauri_config_path.display())); + + let backend_resource_alias = load_bundle_resource_alias(&tauri_config, BACKEND_RESOURCE_SOURCE); + let webui_resource_alias = load_bundle_resource_alias(&tauri_config, WEBUI_RESOURCE_SOURCE); + println!("cargo:rustc-env=ASTRBOT_BACKEND_RESOURCE_ALIAS={backend_resource_alias}"); + println!("cargo:rustc-env=ASTRBOT_WEBUI_RESOURCE_ALIAS={webui_resource_alias}"); + tauri_build::build() } diff --git a/src-tauri/src/launch_plan.rs b/src-tauri/src/launch_plan.rs index 215e9407..cb421bbe 100644 --- a/src-tauri/src/launch_plan.rs +++ b/src-tauri/src/launch_plan.rs @@ -7,6 +7,13 @@ use tauri::AppHandle; use crate::{packaged_webui, runtime_paths, LaunchPlan, RuntimeManifest}; +const BACKEND_RESOURCE_ALIAS: &str = env!("ASTRBOT_BACKEND_RESOURCE_ALIAS"); +const WEBUI_RESOURCE_ALIAS: &str = env!("ASTRBOT_WEBUI_RESOURCE_ALIAS"); + +fn build_packaged_resource_relative_path(resource_alias: &str, leaf_name: &str) -> PathBuf { + PathBuf::from(resource_alias).join(leaf_name) +} + pub fn resolve_custom_launch(custom_cmd: String) -> Result { let mut pieces = shlex::split(&custom_cmd) .ok_or_else(|| format!("Invalid ASTRBOT_BACKEND_CMD: {custom_cmd}"))?; @@ -41,8 +48,11 @@ pub fn resolve_packaged_launch( where F: Fn(&str) + Copy, { + let manifest_relative_path = + build_packaged_resource_relative_path(BACKEND_RESOURCE_ALIAS, "runtime-manifest.json"); + let manifest_relative_path_string = manifest_relative_path.to_string_lossy().to_string(); let manifest_path = - match runtime_paths::resolve_resource_path(app, "backend/runtime-manifest.json", log) { + match runtime_paths::resolve_resource_path(app, &manifest_relative_path_string, log) { Some(path) if path.is_file() => path, _ => return Ok(None), }; @@ -112,7 +122,11 @@ where .ok() .map(PathBuf::from) .or_else(|| { - runtime_paths::resolve_resource_path(app, "webui/index.html", log) + let webui_index_relative_path = + build_packaged_resource_relative_path(WEBUI_RESOURCE_ALIAS, "index.html"); + let webui_index_relative_path_string = + webui_index_relative_path.to_string_lossy().to_string(); + runtime_paths::resolve_resource_path(app, &webui_index_relative_path_string, log) .and_then(|index_path| index_path.parent().map(Path::to_path_buf)) }); let webui_dir = packaged_webui::resolve_packaged_webui_dir( @@ -172,3 +186,20 @@ pub fn resolve_dev_launch() -> Result { packaged_mode: false, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_packaged_resource_relative_path_joins_alias_and_leaf_name() { + assert_eq!( + build_packaged_resource_relative_path("runtime/backend", "runtime-manifest.json"), + PathBuf::from("runtime/backend").join("runtime-manifest.json") + ); + assert_eq!( + build_packaged_resource_relative_path("runtime/webui", "index.html"), + PathBuf::from("runtime/webui").join("index.html") + ); + } +} From 5b766ca43bda6cd6593f6e9c8c6564067f78ee4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Sun, 5 Apr 2026 19:12:36 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix=EF=BC=9A=20align=20bundle=20resource=20?= =?UTF-8?q?alias=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci/package_windows_portable.py | 56 ++++++++++++--------- scripts/ci/test_package_windows_portable.py | 41 +++++++++++++++ src-tauri/build.rs | 33 +++++++----- 3 files changed, 94 insertions(+), 36 deletions(-) diff --git a/scripts/ci/package_windows_portable.py b/scripts/ci/package_windows_portable.py index bfc80905..a576095c 100644 --- a/scripts/ci/package_windows_portable.py +++ b/scripts/ci/package_windows_portable.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from datetime import datetime import json +import os import pathlib import re import shutil @@ -107,40 +108,49 @@ def resolve_bundle_resource_alias_from_tauri_config( tauri_config: dict, source_relative_path: pathlib.Path, ) -> pathlib.Path: + # Keep validation rules aligned with src-tauri/build.rs::load_bundle_resource_alias. bundle_table = tauri_config.get("bundle") if not isinstance(bundle_table, dict): - raise ValueError(f"Missing bundle table in {TAURI_CONFIG_RELATIVE_PATH}") + raise ValueError(f"Missing bundle object in {TAURI_CONFIG_RELATIVE_PATH}") resources_table = bundle_table.get("resources") if not isinstance(resources_table, dict): raise ValueError( - f"Missing bundle.resources table in {TAURI_CONFIG_RELATIVE_PATH}" + f"Missing bundle.resources object in {TAURI_CONFIG_RELATIVE_PATH}" ) tauri_config_dir = (project_root / TAURI_CONFIG_RELATIVE_PATH).parent.resolve() expected_source_path = (project_root / source_relative_path).resolve() - for source_path_text, alias_text in resources_table.items(): - candidate_source_path = (tauri_config_dir / str(source_path_text)).resolve() - if candidate_source_path != expected_source_path: - continue - - alias_path = pathlib.Path(str(alias_text).strip()) - if not alias_path.parts or alias_path.is_absolute(): - raise ValueError( - "Invalid bundle.resources alias for " - f"{source_relative_path} in {TAURI_CONFIG_RELATIVE_PATH}: {alias_text}" - ) - if any(part in (".", "..") for part in alias_path.parts): - raise ValueError( - "bundle.resources alias must not contain path traversal for " - f"{source_relative_path} in {TAURI_CONFIG_RELATIVE_PATH}: {alias_text}" - ) - return alias_path + expected_source_key = pathlib.PurePosixPath( + os.path.relpath(expected_source_path, tauri_config_dir) + ).as_posix() + alias_text = resources_table.get(expected_source_key) + if alias_text is None: + raise ValueError( + "Missing bundle.resources alias for " + f"{expected_source_key} in {TAURI_CONFIG_RELATIVE_PATH}" + ) - raise ValueError( - "Missing bundle.resources alias for " - f"{source_relative_path} in {TAURI_CONFIG_RELATIVE_PATH}" - ) + if not isinstance(alias_text, str): + raise ValueError( + "bundle.resources alias for " + f"{expected_source_key} must be a string in {TAURI_CONFIG_RELATIVE_PATH}" + ) + + alias_path = pathlib.Path(alias_text.strip()) + if not alias_path.parts or alias_path.is_absolute(): + raise ValueError( + "bundle.resources alias for " + f"{expected_source_key} must be a relative path in " + f"{TAURI_CONFIG_RELATIVE_PATH}: {alias_text}" + ) + if any(part in (".", "..") for part in alias_path.parts): + raise ValueError( + "bundle.resources alias for " + f"{expected_source_key} must be a relative path without traversal in " + f"{TAURI_CONFIG_RELATIVE_PATH}: {alias_text}" + ) + return alias_path def load_project_config_from(start_path: pathlib.Path) -> ProjectConfig: diff --git a/scripts/ci/test_package_windows_portable.py b/scripts/ci/test_package_windows_portable.py index c35c44b6..6bc15e93 100644 --- a/scripts/ci/test_package_windows_portable.py +++ b/scripts/ci/test_package_windows_portable.py @@ -233,6 +233,20 @@ def test_load_project_config_from_reads_portable_layout_aliases_from_tauri_resou project_config.webui_layout_relative_path, Path("runtime/webui") ) + def test_load_project_config_from_requires_exact_tauri_resource_source_keys(self): + layout = self.make_project_layout( + tauri_resources={ + "./../resources/backend": "runtime/backend", + "../resources/webui": "runtime/webui", + } + ) + + with self.assertRaisesRegex( + ValueError, + re.escape("Missing bundle.resources alias for ../resources/backend"), + ): + MODULE.load_project_config_from(layout["script_path"]) + def test_normalize_legacy_nightly_version_returns_base_version_and_suffix(self): self.assertEqual( MODULE.normalize_legacy_nightly_version("4.29.0-nightly.20260401.deadbeef"), @@ -540,6 +554,33 @@ def test_validate_portable_root_accepts_expected_layout(self): MODULE.validate_portable_root(root, project_config) + def test_validate_portable_root_accepts_nested_alias_layout(self): + layout = self.make_project_layout( + tauri_resources={ + "../resources/backend": "runtime/backend", + "../resources/webui": "runtime/webui", + } + ) + project_config = MODULE.load_project_config_from(layout["script_path"]) + + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + (root / "AstrBot.exe").write_text("binary") + (root / project_config.portable_marker_name).write_text("marker") + (root / project_config.backend_layout_relative_path).mkdir(parents=True) + (root / project_config.webui_layout_relative_path).mkdir(parents=True) + ( + root / project_config.backend_layout_relative_path / "runtime-manifest.json" + ).write_text("{}") + (root / project_config.webui_layout_relative_path / "index.html").write_text( + "" + ) + + MODULE.validate_portable_root(root, project_config) + + self.assertFalse((root / "backend").exists()) + self.assertFalse((root / "webui").exists()) + def test_validate_portable_root_requires_expected_files(self): layout = self.make_project_layout() project_config = MODULE.load_project_config_from(layout["script_path"]) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 18cb73d9..ecc07d82 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -9,22 +9,29 @@ const BACKEND_RESOURCE_SOURCE: &str = "../resources/backend"; const WEBUI_RESOURCE_SOURCE: &str = "../resources/webui"; fn load_bundle_resource_alias(tauri_config: &Value, source_relative_path: &str) -> String { - let resources = tauri_config + // Keep validation rules aligned with + // scripts/ci/package_windows_portable.py::resolve_bundle_resource_alias_from_tauri_config. + let bundle = tauri_config .get("bundle") - .and_then(|bundle| bundle.get("resources")) .and_then(Value::as_object) - .unwrap_or_else(|| panic!("missing bundle.resources table in {TAURI_CONFIG_PATH}")); + .unwrap_or_else(|| panic!("missing bundle object in {TAURI_CONFIG_PATH}")); + let resources = bundle + .get("resources") + .and_then(Value::as_object) + .unwrap_or_else(|| panic!("missing bundle.resources object in {TAURI_CONFIG_PATH}")); - let alias = resources - .get(source_relative_path) - .and_then(Value::as_str) - .map(str::trim) - .unwrap_or_else(|| { - panic!( - "missing bundle.resources alias for {} in {}", - source_relative_path, TAURI_CONFIG_PATH - ) - }); + let alias_value = resources.get(source_relative_path).unwrap_or_else(|| { + panic!( + "missing bundle.resources alias for {} in {}", + source_relative_path, TAURI_CONFIG_PATH + ) + }); + let alias = alias_value.as_str().map(str::trim).unwrap_or_else(|| { + panic!( + "bundle.resources alias for {} must be a string in {}", + source_relative_path, TAURI_CONFIG_PATH + ) + }); assert!( !alias.is_empty(), "bundle.resources alias for {} is empty in {}", From 28ce722d8ceb1692e5e5a5119b742ecf90fb9ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Sun, 5 Apr 2026 19:16:13 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix=EF=BC=9A=20normalize=20windows=20resour?= =?UTF-8?q?ce=20alias=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci/package_windows_portable.py | 2 +- scripts/ci/test_package_windows_portable.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/scripts/ci/package_windows_portable.py b/scripts/ci/package_windows_portable.py index a576095c..801d6c48 100644 --- a/scripts/ci/package_windows_portable.py +++ b/scripts/ci/package_windows_portable.py @@ -121,7 +121,7 @@ def resolve_bundle_resource_alias_from_tauri_config( tauri_config_dir = (project_root / TAURI_CONFIG_RELATIVE_PATH).parent.resolve() expected_source_path = (project_root / source_relative_path).resolve() - expected_source_key = pathlib.PurePosixPath( + expected_source_key = pathlib.PureWindowsPath( os.path.relpath(expected_source_path, tauri_config_dir) ).as_posix() alias_text = resources_table.get(expected_source_key) diff --git a/scripts/ci/test_package_windows_portable.py b/scripts/ci/test_package_windows_portable.py index 6bc15e93..1ed11fc6 100644 --- a/scripts/ci/test_package_windows_portable.py +++ b/scripts/ci/test_package_windows_portable.py @@ -3,6 +3,7 @@ import tempfile import unittest from pathlib import Path +from unittest import mock from scripts.ci import package_windows_portable as MODULE @@ -247,6 +248,19 @@ def test_load_project_config_from_requires_exact_tauri_resource_source_keys(self ): MODULE.load_project_config_from(layout["script_path"]) + def test_load_project_config_from_normalizes_windows_relpath_separators(self): + layout = self.make_project_layout() + + with mock.patch.object( + MODULE.os.path, + "relpath", + side_effect=[r"..\resources\backend", r"..\resources\webui"], + ): + project_config = MODULE.load_project_config_from(layout["script_path"]) + + self.assertEqual(project_config.backend_layout_relative_path, Path("backend")) + self.assertEqual(project_config.webui_layout_relative_path, Path("webui")) + def test_normalize_legacy_nightly_version_returns_base_version_and_suffix(self): self.assertEqual( MODULE.normalize_legacy_nightly_version("4.29.0-nightly.20260401.deadbeef"),