fix(paths): reject space-padded Windows device names (#6458)

This commit is contained in:
siye566 2026-10-08 10:31:06 +08:00 committed by GitHub
parent c15ecb688d
commit 7d54feea07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 33 additions and 1 deletions

View File

@ -2124,6 +2124,9 @@ failing the upload; hidden staging files are left for the startup sweep.
Uploads, new skill support files, and new local sandbox paths reject Windows
reserved device names on every platform, including `COM¹`, `LPT²`, the console
aliases `CONIN$` and `CONOUT$`, and names with extensions such as `com³.txt`.
ASCII spaces before the extension do not make a device name portable:
`NUL .txt` and `COM1 .log` are rejected too. Ordinary names such as
`report .txt` are preserved unchanged.
Rename these files before creating or uploading them so the same file tree
remains usable on Windows.

View File

@ -94,6 +94,10 @@ every platform, including the `COM`/`LPT` aliases with superscript ¹, ² and ³
The console aliases `CONIN$` and `CONOUT$` are reserved too; match them
case-insensitively before the first dot, without rejecting longer ordinary
names such as `CONIN$notes.txt`.
Device-name comparison ignores ASCII spaces before the first dot, so
`NUL .txt` and `COM1 .log` remain reserved. Trim the comparison stem only,
not the supplied filename; ordinary names such as `report .txt` and
non-ASCII whitespace remain unchanged.
Do not normalize arbitrary Unicode digits into device numbers: names such as
`COM⁴.txt` and `COM¹notes.txt` are ordinary portable names. Read/removal callers
retain their existing portability exemptions.

View File

@ -52,7 +52,9 @@ def windows_incompatible_segment(segment: str) -> str | None:
return None
if segment.endswith((" ", ".")):
return "trailing dot or space"
stem = segment.split(".", 1)[0]
# Windows ignores ASCII spaces before a device name's extension (NUL .txt).
# Trim only for comparison; ordinary filenames must retain their spaces.
stem = segment.split(".", 1)[0].rstrip(" ")
if stem.upper() in _WINDOWS_RESERVED_NAMES:
return "reserved Windows device name"
return None

View File

@ -519,6 +519,14 @@ def test_validate_local_tool_path_rejects_new_console_device_names(name: str) ->
validate_local_tool_path(path, _THREAD_DATA, read_only=True)
@pytest.mark.parametrize("name", ["NUL .txt", "con .log", "COM1 .txt", "lpt² .md", "CONIN$ .txt", "conout$ .log"])
def test_validate_local_tool_path_rejects_new_padded_device_names(name: str) -> None:
path = f"{VIRTUAL_PATH_PREFIX}/workspace/{name}"
with pytest.raises(PermissionError, match="Access denied"):
validate_local_tool_path(path, _THREAD_DATA, read_only=False)
validate_local_tool_path(path, _THREAD_DATA, read_only=True)
def test_validate_local_tool_path_rejects_traversal_in_user_data() -> None:
"""Path traversal via .. in user-data paths must be rejected."""
with pytest.raises(PermissionError, match="path traversal"):

View File

@ -86,6 +86,12 @@ def test_ensure_safe_support_path_rejects_console_device_names(storage, skill_di
storage.ensure_safe_support_path("demo-skill", path)
@pytest.mark.parametrize("path", ["assets/NUL .txt", "scripts/con .log", "assets/COM1 .txt", "assets/lpt² .md", "assets/CONIN$ .txt", "assets/conout$ .log", "assets/COM1 .txt/icon.png"])
def test_ensure_safe_support_path_rejects_padded_device_names(storage, skill_dir, path):
with pytest.raises(ValueError, match="reserved Windows device name"):
storage.ensure_safe_support_path("demo-skill", path)
def test_read_text_or_none_decodes_text_and_returns_none_for_binary(tmp_path: Path):
text = tmp_path / "t.md"
text.write_text("héllo", encoding="utf-8")

View File

@ -108,6 +108,15 @@ class TestNormalizeFilename:
with pytest.raises(ValueError, match="reserved Windows device name"):
normalize_filename(filename)
@pytest.mark.parametrize("filename", ["NUL .txt", "con .log", "COM1 .txt", "lpt² .md", "CONIN$ .txt", "conout$ .log"])
def test_rejects_windows_device_names_with_spaces_before_extension(self, filename):
with pytest.raises(ValueError, match="reserved Windows device name"):
normalize_filename(filename)
@pytest.mark.parametrize("filename", ["report .txt", "NULnotes .txt", "COM¹notes .txt", "NUL\u00a0.txt"])
def test_preserves_spaces_in_ordinary_filename_stems(self, filename):
assert normalize_filename(filename) == filename
@pytest.mark.parametrize("filename", ["CONIN", "CONOUT", "CONIN$notes.txt", "CONOUT$notes.log"])
def test_allows_names_resembling_console_devices(self, filename):
assert normalize_filename(filename) == filename