Skip to content

Commit d613176

Browse files
authored
fix: normalize windows portable naming (#109)
* fix(ci): normalize windows portable naming * test(ci): harden windows portable naming rules * fix(ci): validate windows portable file names * refactor(ci): extract windows portable naming helpers * refactor(testing): simplify windows portable helpers * fix(ci): reject extra nightly components
1 parent 208b5e7 commit d613176

3 files changed

Lines changed: 299 additions & 107 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
import re
4+
5+
WINDOWS_FILENAME_INVALID_CHARS_RE = re.compile(r'[<>:"/\\|?*]')
6+
WINDOWS_FILENAME_INVALID_TRAILING_RE = re.compile(r"[ .]+$")
7+
WINDOWS_RESERVED_DEVICE_NAMES = {
8+
"CON",
9+
"PRN",
10+
"AUX",
11+
"NUL",
12+
*{f"COM{i}" for i in range(1, 10)},
13+
*{f"LPT{i}" for i in range(1, 10)},
14+
}
15+
16+
17+
def validate_windows_filename(name: str) -> None:
18+
if not name or name in {".", ".."}:
19+
raise ValueError(f"invalid Windows filename: {name!r}")
20+
21+
if WINDOWS_FILENAME_INVALID_CHARS_RE.search(name):
22+
raise ValueError(
23+
f"invalid Windows filename {name!r}: contains characters invalid in Windows filenames"
24+
)
25+
26+
if WINDOWS_FILENAME_INVALID_TRAILING_RE.search(name):
27+
raise ValueError(
28+
f"invalid Windows filename {name!r}: trailing spaces or dots are not allowed"
29+
)
30+
31+
stem = name.split(".", 1)[0].upper()
32+
if stem in WINDOWS_RESERVED_DEVICE_NAMES:
33+
raise ValueError(
34+
f"invalid Windows filename {name!r}: {stem!r} is a reserved device name"
35+
)

scripts/ci/package_windows_portable.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import argparse
66
from dataclasses import dataclass
7+
from datetime import datetime
78
import json
89
import pathlib
910
import re
@@ -14,6 +15,7 @@
1415

1516
from scripts.ci.lib.artifact_arch import normalize_arch_alias
1617
from scripts.ci.lib.release_artifacts import SHORT_SHA_PATTERN
18+
from scripts.ci.lib.windows_filenames import validate_windows_filename
1719

1820

1921
WINDOWS_CANONICAL_INSTALLER_RE = re.compile(
@@ -23,6 +25,7 @@
2325
WINDOWS_LEGACY_INSTALLER_RE = re.compile(
2426
r"(?P<name>.+?)_(?P<version>.+?)_(?P<arch>x64|amd64|arm64|aarch64)-setup\.exe$"
2527
)
28+
LEGACY_NIGHTLY_BASE_VERSION_RE = re.compile(r"^[0-9A-Za-z.+]+(?:-[0-9A-Za-z.+]+)*$")
2629

2730
PORTABLE_README_NAME = "README-portable.txt"
2831
PORTABLE_README_TEXT = """AstrBot Windows portable package
@@ -55,6 +58,14 @@ def normalize_arch(arch: str) -> str:
5558
return normalize_arch_alias(arch) or arch
5659

5760

61+
def is_valid_nightly_date(date_value: str) -> bool:
62+
try:
63+
datetime.strptime(date_value, "%Y%m%d")
64+
except ValueError:
65+
return False
66+
return True
67+
68+
5869
def resolve_project_root_from(start_path: pathlib.Path) -> pathlib.Path:
5970
candidate = start_path.resolve()
6071
if candidate.is_file():
@@ -101,6 +112,31 @@ def load_project_config_from(start_path: pathlib.Path) -> ProjectConfig:
101112
)
102113

103114

115+
def normalize_legacy_nightly_version(version: str) -> tuple[str, str]:
116+
if "-nightly" not in version:
117+
return version, ""
118+
119+
base_version, separator, nightly_part = version.partition("-nightly")
120+
if not separator or not LEGACY_NIGHTLY_BASE_VERSION_RE.fullmatch(base_version):
121+
return version, ""
122+
123+
nightly_part = nightly_part.lstrip("._-")
124+
if not nightly_part:
125+
return base_version, ""
126+
127+
parts = re.split(r"[._-]", nightly_part, maxsplit=2)
128+
if len(parts) != 2:
129+
return base_version, ""
130+
131+
date_value, sha = parts[0], parts[1]
132+
if not is_valid_nightly_date(date_value):
133+
return base_version, ""
134+
if not re.fullmatch(SHORT_SHA_PATTERN, sha):
135+
return base_version, ""
136+
137+
return base_version, f"_nightly_{sha}"
138+
139+
104140
def load_project_config() -> ProjectConfig:
105141
return load_project_config_from(pathlib.Path(__file__))
106142

@@ -117,9 +153,11 @@ def installer_to_portable_name(installer_name: str) -> str:
117153
legacy_match = WINDOWS_LEGACY_INSTALLER_RE.fullmatch(installer_name)
118154
if legacy_match:
119155
name = legacy_match.group("name")
120-
version = legacy_match.group("version")
156+
version, nightly_suffix = normalize_legacy_nightly_version(
157+
legacy_match.group("version")
158+
)
121159
arch = normalize_arch(legacy_match.group("arch"))
122-
return f"{name}_{version}_windows_{arch}_portable.zip"
160+
return f"{name}_{version}_windows_{arch}_portable{nightly_suffix}.zip"
123161

124162
raise ValueError(
125163
"Unexpected Windows installer name: "
@@ -179,6 +217,13 @@ def resolve_product_name(project_root: pathlib.Path) -> str:
179217
product_name = str(config.get("productName", "")).strip()
180218
if not product_name:
181219
raise ValueError(f"Missing productName in {TAURI_CONFIG_RELATIVE_PATH}")
220+
if product_name.lower().endswith(".exe"):
221+
product_name = product_name[:-4].rstrip()
222+
if not product_name:
223+
raise ValueError(
224+
f"productName resolves to an empty executable name in {TAURI_CONFIG_RELATIVE_PATH}"
225+
)
226+
validate_windows_filename(product_name)
182227
return product_name
183228

184229

@@ -205,7 +250,10 @@ def populate_portable_root(
205250
main_executable_path = resolve_main_executable_path(bundle_dir, project_config)
206251

207252
destination_root.mkdir(parents=True, exist_ok=True)
208-
shutil.copy2(main_executable_path, destination_root / main_executable_path.name)
253+
shutil.copy2(
254+
main_executable_path,
255+
destination_root / f"{project_config.product_name}.exe",
256+
)
209257

210258
webview_loader = release_dir / "WebView2Loader.dll"
211259
if webview_loader.is_file():

0 commit comments

Comments
 (0)