Skip to content

Commit c6a76e7

Browse files
Merge pull request #774 from /issues/772
fix: windows filename chars
2 parents 9d69115 + b96d2c0 commit c6a76e7

5 files changed

Lines changed: 1167 additions & 1066 deletions

File tree

core/attachment_storage.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import base64
99
import logging
1010
import os
11+
import re
1112
import uuid
1213
from pathlib import Path
1314
from typing import NamedTuple, Optional, Dict
@@ -25,12 +26,38 @@
2526
Path(os.getenv("WORKSPACE_ATTACHMENT_DIR", _default_dir)).expanduser().resolve()
2627
)
2728

29+
_WINDOWS_RESERVED_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
30+
_WINDOWS_RESERVED_NAMES = {
31+
"CON",
32+
"PRN",
33+
"AUX",
34+
"NUL",
35+
*(f"COM{i}" for i in range(1, 10)),
36+
*(f"LPT{i}" for i in range(1, 10)),
37+
}
38+
2839

2940
def _ensure_storage_dir() -> None:
3041
"""Create the storage directory on first use, not at import time."""
3142
STORAGE_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
3243

3344

45+
def sanitize_attachment_filename(filename: Optional[str]) -> str:
46+
"""Return a filesystem-safe attachment filename."""
47+
if not filename:
48+
return "attachment"
49+
50+
sanitized = _WINDOWS_RESERVED_FILENAME_CHARS.sub("_", filename).rstrip(" .")
51+
if not sanitized:
52+
return "attachment"
53+
54+
stem = sanitized.split(".", 1)[0]
55+
if stem.upper() in _WINDOWS_RESERVED_NAMES:
56+
sanitized = f"_{sanitized}"
57+
58+
return sanitized
59+
60+
3461
class SavedAttachment(NamedTuple):
3562
"""Result of saving an attachment: provides both the UUID and the absolute file path."""
3663

@@ -76,8 +103,10 @@ def save_attachment(
76103

77104
# Determine file extension from filename or mime type
78105
extension = ""
106+
safe_filename = sanitize_attachment_filename(filename)
107+
79108
if filename:
80-
extension = Path(filename).suffix
109+
extension = Path(safe_filename).suffix
81110
elif mime_type:
82111
# Basic mime type to extension mapping
83112
mime_to_ext = {
@@ -93,8 +122,8 @@ def save_attachment(
93122

94123
# Use original filename if available, with UUID suffix for uniqueness
95124
if filename:
96-
stem = Path(filename).stem
97-
ext = Path(filename).suffix
125+
stem = Path(safe_filename).stem
126+
ext = Path(safe_filename).suffix
98127
save_name = f"{stem}_{file_id[:8]}{ext}"
99128
else:
100129
save_name = f"{file_id}{extension}"
@@ -134,7 +163,8 @@ def save_attachment(
134163
expires_at = datetime.now() + timedelta(seconds=self.expiration_seconds)
135164
self._metadata[file_id] = {
136165
"file_path": str(file_path),
137-
"filename": filename or f"attachment{extension}",
166+
"filename": save_name,
167+
"original_filename": filename,
138168
"mime_type": mime_type or "application/octet-stream",
139169
"size": len(file_bytes),
140170
"created_at": datetime.now(),

gmail/gmail_tools.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1794,11 +1794,13 @@ async def get_gmail_attachment_content(
17941794
result = storage.save_attachment(
17951795
base64_data=base64_data, filename=filename, mime_type=mime_type
17961796
)
1797+
saved_filename = Path(result.path).name
17971798

17981799
result_lines = [
17991800
"Attachment downloaded successfully!",
18001801
f"Message ID: {message_id}",
18011802
f"Filename: {filename or 'unknown'}",
1803+
f"Saved filename: {saved_filename}",
18021804
f"Size: {size_kb:.1f} KB ({size_bytes} bytes)",
18031805
]
18041806

tests/gmail/test_attachment_fix.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,55 @@ def test_save_attachment_uses_binary_mode(isolated_storage):
8282
)
8383

8484

85+
@pytest.mark.parametrize(
86+
("filename", "expected_prefix"),
87+
[
88+
("RE: Foo/Bar?.eml", "RE_ Foo_Bar_"),
89+
("FW: Client\\Matter*.eml", "FW_ Client_Matter_"),
90+
("CON.txt", "_CON_"),
91+
("report. ", "report_"),
92+
("... ", "attachment_"),
93+
],
94+
)
95+
def test_save_attachment_sanitizes_windows_reserved_filenames(
96+
isolated_storage, filename, expected_prefix
97+
):
98+
"""Attachment filenames should be safe on Windows and POSIX filesystems."""
99+
payload = b"safe filename check"
100+
b64_data = base64.urlsafe_b64encode(payload).decode()
101+
102+
result = isolated_storage.save_attachment(b64_data, filename=filename)
103+
saved_name = os.path.basename(result.path)
104+
105+
assert saved_name.startswith(expected_prefix)
106+
assert not any(char in saved_name for char in '<>:"/\\|?*')
107+
assert saved_name == saved_name.rstrip(". ")
108+
109+
with open(result.path, "rb") as f:
110+
saved_bytes = f.read()
111+
112+
assert saved_bytes == payload
113+
114+
115+
def test_save_attachment_metadata_filename_matches_saved_file(isolated_storage):
116+
"""Attachment metadata should report the on-disk filename."""
117+
payload = b"metadata filename check"
118+
b64_data = base64.urlsafe_b64encode(payload).decode()
119+
120+
result = isolated_storage.save_attachment(
121+
b64_data, filename="RE: Foo?.eml", mime_type="message/rfc822"
122+
)
123+
saved_name = os.path.basename(result.path)
124+
metadata = isolated_storage.get_attachment_metadata(result.file_id)
125+
126+
assert metadata["filename"] == saved_name
127+
assert metadata["original_filename"] == "RE: Foo?.eml"
128+
assert metadata["filename"].startswith("RE_ Foo_")
129+
assert metadata["filename"].endswith(".eml")
130+
assert ":" not in metadata["filename"]
131+
assert "?" not in metadata["filename"]
132+
133+
85134
@pytest.mark.parametrize(
86135
"payload",
87136
[

tests/gmail/test_get_gmail_attachment_content.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,34 @@ async def test_default_call_omits_base64_content(isolated_attachment_env):
134134
assert "standard base64" not in result
135135

136136

137+
@pytest.mark.asyncio
138+
async def test_download_response_reports_sanitized_saved_filename(
139+
isolated_attachment_env,
140+
):
141+
"""Windows-reserved filename characters should be sanitized before saving."""
142+
payload = b"attached email bytes"
143+
mock_service = _build_mock_service(
144+
payload, filename="RE: Foo?.eml", mime_type="message/rfc822"
145+
)
146+
147+
result = await _unwrap(get_gmail_attachment_content)(
148+
service=mock_service,
149+
message_id="msg-1",
150+
attachment_id="att-123",
151+
user_google_email="user@example.com",
152+
)
153+
154+
assert "Filename: RE: Foo?.eml" in result
155+
assert "Saved filename: RE_ Foo_" in result
156+
157+
saved_files = list(isolated_attachment_env.iterdir())
158+
assert len(saved_files) == 1
159+
assert saved_files[0].name.startswith("RE_ Foo_")
160+
assert ":" not in saved_files[0].name
161+
assert "?" not in saved_files[0].name
162+
assert saved_files[0].read_bytes() == payload
163+
164+
137165
@pytest.mark.asyncio
138166
@pytest.mark.parametrize(
139167
("payload", "filename", "mime_type"),

0 commit comments

Comments
 (0)