Просмотр исходного кода

fix(library): reject FAT32-illegal filename chars at rename/upload/queue time (#1540)

  Bambu printer SD cards are FAT32/exFAT, which forbids < > : " / \ | ? *
  plus control chars and trailing dots/spaces. Library rename only blocked
  path separators, so a name like L|R.3mf was accepted and only failed
  later at FTP upload with 553 Could not create file - far from the rename
  action that caused it. Bambu Studio refuses these names in its save
  dialog; Bambuddy now does the same.

  New backend/app/utils/filename.py centralises validation. Wired into
  update_file, upload_file, print_library_file, and queue add. Existing
  rows with bad names are left alone (no silent rewrite of user data);
  users get an actionable 400 pointing at rename.

  Frontend rename modal mirrors the same set client-side with inline error.
  New fileManager.invalidFilenameChar i18n key translated across all 9
  locales. 26 new tests in test_filename_validation.py.
maziggy 23 часов назад
Родитель
Сommit
2241924312

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 22 - 3
backend/app/api/routes/library.py

@@ -64,6 +64,7 @@ from backend.app.schemas.library import (
 from backend.app.schemas.slicer import SliceRequest, SliceResponse
 from backend.app.services.archive import ThreeMFParser
 from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
 from backend.app.utils.threemf_tools import (
     extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
@@ -1577,6 +1578,11 @@ async def upload_file(
             raise HTTPException(status_code=400, detail="Filename is required")
 
         filename = file.filename
+        # Reject FAT32/exFAT-incompatible filenames up front (#1540).
+        try:
+            validate_print_filename(filename)
+        except InvalidFilenameError as e:
+            raise HTTPException(status_code=400, detail=str(e)) from e
         ext = os.path.splitext(filename)[1].lower()
         # Handle files without extension
         file_type = ext[1:] if ext else "unknown"
@@ -3830,6 +3836,15 @@ async def print_library_file(
             detail="Not a sliced file. Only .gcode or .gcode.3mf files can be printed.",
         )
 
+    # Filenames containing FAT32/exFAT-illegal characters would 553 at
+    # FTP upload time (#1540). Older rows may pre-date the rename-time
+    # validation, so reject the print attempt with an actionable message
+    # rather than silently renaming user data.
+    try:
+        validate_print_filename(lib_file.filename)
+    except InvalidFilenameError as e:
+        raise HTTPException(status_code=400, detail=str(e)) from e
+
     # Get the full file path
     file_path = Path(app_settings.base_dir) / lib_file.file_path
 
@@ -4007,9 +4022,13 @@ async def update_file(
             raise HTTPException(status_code=403, detail="You can only update your own files")
 
     if data.filename is not None:
-        # Validate filename doesn't contain path separators
-        if "/" in data.filename or "\\" in data.filename:
-            raise HTTPException(status_code=400, detail="Filename cannot contain path separators")
+        # Bambu printer SD cards are FAT32/exFAT; reject the same set Bambu
+        # Studio refuses on save so we fail here with a clear message
+        # instead of an obscure FTP 553 at print time (#1540).
+        try:
+            validate_print_filename(data.filename)
+        except InvalidFilenameError as e:
+            raise HTTPException(status_code=400, detail=str(e)) from e
         file.filename = data.filename
         # No print_name to keep in sync — library files display by filename,
         # and _without_print_name strips the embedded 3MF Title on import (#1489).

+ 9 - 0
backend/app/api/routes/print_queue.py

@@ -401,6 +401,15 @@ async def add_to_queue(
         library_file = result.scalar_one_or_none()
         if not library_file:
             raise HTTPException(400, "Library file not found")
+        # Bambu SD card is FAT32/exFAT — illegal filename chars would 553 at
+        # FTP upload time (#1540). Reject at queue time so the user gets the
+        # actionable error before waiting in queue.
+        from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
+
+        try:
+            validate_print_filename(library_file.filename)
+        except InvalidFilenameError as e:
+            raise HTTPException(400, str(e)) from e
 
     # Extract filament types for model-based assignment (used by scheduler for validation)
     required_filament_types = None

+ 57 - 0
backend/app/utils/filename.py

@@ -0,0 +1,57 @@
+"""Print-file filename validation matching Bambu Studio's save-dialog rules.
+
+The Bambu printer SD card is FAT32/exFAT. Names containing the Windows /
+DOS-reserved set (``< > : " / \\ | ? *``), ASCII control characters
+(0x00-0x1F), or trailing dots / spaces cannot be created on it — FTP fails
+with ``553 Could not create file`` (#1540). Bambu Studio refuses to save
+such names client-side; Bambuddy now does the same at the rename, upload,
+and dispatch boundaries so the failure surfaces with a clear message
+instead of an obscure FTP error after the user has already hit Print.
+"""
+
+INVALID_FILENAME_CHARS = '<>:"/\\|?*'
+
+# FAT/exFAT cap on a single path component; UTF-8 byte length, not codepoints,
+# because that is what the on-disk encoding limit actually is.
+MAX_FILENAME_BYTES = 255
+
+
+class InvalidFilenameError(ValueError):
+    """Filename contains characters or shape the printer SD card rejects.
+
+    ``char`` is the first offending character when the failure is a
+    character-set violation, or ``None`` for structural failures (empty,
+    bare ``.``, trailing space, too long, etc.). The frontend echoes it
+    back to the user in the Bambu Studio-style error message.
+    """
+
+    def __init__(self, message: str, char: str | None = None):
+        super().__init__(message)
+        self.char = char
+
+
+def validate_print_filename(name: str) -> None:
+    """Raise ``InvalidFilenameError`` if ``name`` would fail on the SD card.
+
+    Matches Bambu Studio's save-dialog rejection set. Callers are expected
+    to translate the exception into an HTTP 400 (or a clean dispatch
+    rejection); the message is intentionally short and ASCII so it fits
+    a translation template.
+    """
+    if not name or not name.strip():
+        raise InvalidFilenameError("Filename cannot be empty")
+
+    if name in (".", ".."):
+        raise InvalidFilenameError("Filename cannot be '.' or '..'")
+
+    for ch in name:
+        if ch in INVALID_FILENAME_CHARS:
+            raise InvalidFilenameError(f"Filename contains invalid character: {ch}", char=ch)
+        if ord(ch) < 0x20:
+            raise InvalidFilenameError("Filename contains a control character", char=ch)
+
+    if name.endswith(" ") or name.endswith("."):
+        raise InvalidFilenameError("Filename cannot end with a space or dot")
+
+    if len(name.encode("utf-8")) > MAX_FILENAME_BYTES:
+        raise InvalidFilenameError(f"Filename exceeds {MAX_FILENAME_BYTES} bytes")

+ 6 - 4
backend/tests/integration/test_library_api.py

@@ -285,22 +285,24 @@ class TestLibraryFilesAPI:
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_rename_file_invalid_path_separator(self, async_client: AsyncClient, file_factory, db_session):
-        """Verify file rename fails with path separators."""
+        """Verify file rename fails with a forward slash (FAT32-illegal, #1540)."""
         lib_file = await file_factory(filename="test.3mf")
         data = {"filename": "path/to/file.3mf"}
         response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
         assert response.status_code == 400
-        assert "path separator" in response.json()["detail"].lower()
+        assert "invalid character" in response.json()["detail"].lower()
+        assert "/" in response.json()["detail"]
 
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_rename_file_invalid_backslash(self, async_client: AsyncClient, file_factory, db_session):
-        """Verify file rename fails with backslash."""
+        """Verify file rename fails with a backslash (FAT32-illegal, #1540)."""
         lib_file = await file_factory(filename="test.3mf")
         data = {"filename": "path\\to\\file.3mf"}
         response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
         assert response.status_code == 400
-        assert "path separator" in response.json()["detail"].lower()
+        assert "invalid character" in response.json()["detail"].lower()
+        assert "\\" in response.json()["detail"]
 
     @pytest.mark.asyncio
     @pytest.mark.integration

+ 66 - 0
backend/tests/unit/test_filename_validation.py

@@ -0,0 +1,66 @@
+"""Validator tests for FAT32/exFAT-safe print filenames (#1540)."""
+
+import pytest
+
+from backend.app.utils.filename import (
+    INVALID_FILENAME_CHARS,
+    InvalidFilenameError,
+    validate_print_filename,
+)
+
+
+class TestValidatePrintFilename:
+    @pytest.mark.parametrize(
+        "name",
+        [
+            "model.3mf",
+            "Bersaglio.gcode.3mf",
+            "Plate 1.3mf",
+            "プリント.3mf",
+            "model_v2-final.3mf",
+            "a.3mf",
+        ],
+    )
+    def test_valid_names_accepted(self, name: str) -> None:
+        validate_print_filename(name)
+
+    @pytest.mark.parametrize("char", list(INVALID_FILENAME_CHARS))
+    def test_each_invalid_char_rejected(self, char: str) -> None:
+        with pytest.raises(InvalidFilenameError) as exc_info:
+            validate_print_filename(f"L{char}R.3mf")
+        assert exc_info.value.char == char
+
+    def test_pipe_from_issue_1540(self) -> None:
+        """The exact reproducer from the bug report."""
+        with pytest.raises(InvalidFilenameError) as exc_info:
+            validate_print_filename("L|R.3mf")
+        assert exc_info.value.char == "|"
+
+    @pytest.mark.parametrize("name", ["", " ", "   "])
+    def test_empty_rejected(self, name: str) -> None:
+        with pytest.raises(InvalidFilenameError, match="empty"):
+            validate_print_filename(name)
+
+    @pytest.mark.parametrize("name", [".", ".."])
+    def test_dot_names_rejected(self, name: str) -> None:
+        with pytest.raises(InvalidFilenameError):
+            validate_print_filename(name)
+
+    def test_control_char_rejected(self) -> None:
+        with pytest.raises(InvalidFilenameError, match="control"):
+            validate_print_filename("file\x01.3mf")
+
+    @pytest.mark.parametrize("name", ["file.3mf.", "file.3mf "])
+    def test_trailing_space_or_dot_rejected(self, name: str) -> None:
+        with pytest.raises(InvalidFilenameError, match="space or dot"):
+            validate_print_filename(name)
+
+    def test_too_long_rejected(self) -> None:
+        with pytest.raises(InvalidFilenameError, match="bytes"):
+            validate_print_filename("a" * 256)
+
+    def test_unicode_byte_length_not_codepoint(self) -> None:
+        """255 multi-byte codepoints exceeds 255 bytes — must reject."""
+        # 'ä' is 2 bytes in UTF-8
+        with pytest.raises(InvalidFilenameError, match="bytes"):
+            validate_print_filename("ä" * 200)

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -3073,6 +3073,7 @@ export default {
     folderNamePlaceholder: 'z.B. Funktionsteile',
     renameFile: 'Datei umbenennen',
     renameFolder: 'Ordner umbenennen',
+    invalidFilenameChar: 'Das Zeichen "{{char}}" ist in Druck-Dateinamen nicht erlaubt. Die SD-Karte des Druckers lehnt folgende Zeichen ab: < > : " / \\ | ? *',
     moveFiles: '{{count}} Datei(en) verschieben',
     rootNoFolder: 'Stammverzeichnis (Kein Ordner)',
     current: 'aktuell',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -3076,6 +3076,7 @@ export default {
     folderNamePlaceholder: 'e.g., Functional Parts',
     renameFile: 'Rename File',
     renameFolder: 'Rename Folder',
+    invalidFilenameChar: 'The character "{{char}}" is not allowed in print filenames. The printer SD card rejects: < > : " / \\ | ? *',
     moveFiles: 'Move {{count}} File(s)',
     rootNoFolder: 'Root (No Folder)',
     current: 'current',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -3076,6 +3076,7 @@ export default {
     folderNamePlaceholder: 'p. ej., Piezas funcionales',
     renameFile: 'Renombrar archivo',
     renameFolder: 'Renombrar carpeta',
+    invalidFilenameChar: 'El carácter "{{char}}" no está permitido en nombres de archivos de impresión. La tarjeta SD de la impresora rechaza: < > : " / \\ | ? *',
     moveFiles: 'Mover {{count}} archivo(s)',
     rootNoFolder: 'Raíz (sin carpeta)',
     current: 'actual',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -3062,6 +3062,7 @@ export default {
     folderNamePlaceholder: 'ex: Pièces Utiles',
     renameFile: 'Renommer fichier',
     renameFolder: 'Renommer dossier',
+    invalidFilenameChar: 'Le caractère "{{char}}" n\'est pas autorisé dans les noms de fichier d\'impression. La carte SD de l\'imprimante refuse : < > : " / \\ | ? *',
     moveFiles: 'Déplacer {{count}} fichier(s)',
     rootNoFolder: 'Racine (aucun dossier)',
     current: 'actuel',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -3061,6 +3061,7 @@ export default {
     folderNamePlaceholder: 'es., Parti funzionali',
     renameFile: 'Rinomina file',
     renameFolder: 'Rinomina cartella',
+    invalidFilenameChar: 'Il carattere "{{char}}" non è consentito nei nomi dei file di stampa. La scheda SD della stampante rifiuta: < > : " / \\ | ? *',
     moveFiles: 'Sposta {{count}} file',
     rootNoFolder: 'Root (nessuna cartella)',
     current: 'corrente',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -3073,6 +3073,7 @@ export default {
     folderNamePlaceholder: '例: 機能パーツ',
     renameFile: 'ファイル名を変更',
     renameFolder: 'フォルダ名を変更',
+    invalidFilenameChar: '印刷ファイル名に "{{char}}" は使用できません。プリンターのSDカードは次の文字を拒否します: < > : " / \\ | ? *',
     moveFiles: '{{count}}件のファイルを移動',
     rootNoFolder: 'ルート(フォルダなし)',
     current: '(現在)',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3061,6 +3061,7 @@ export default {
     folderNamePlaceholder: 'ex.: Peças Funcionais',
     renameFile: 'Renomear Arquivo',
     renameFolder: 'Renomear Pasta',
+    invalidFilenameChar: 'O caractere "{{char}}" não é permitido em nomes de arquivos de impressão. O cartão SD da impressora rejeita: < > : " / \\ | ? *',
     moveFiles: 'Mover {{count}} Arquivo(s)',
     rootNoFolder: 'Raiz (Sem Pasta)',
     current: 'Atual',

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3061,6 +3061,7 @@ export default {
     folderNamePlaceholder: '例如:功能零件',
     renameFile: '重命名文件',
     renameFolder: '重命名文件夹',
+    invalidFilenameChar: '打印文件名中不允许使用字符 "{{char}}"。打印机 SD 卡拒绝以下字符: < > : " / \\ | ? *',
     moveFiles: '移动 {{count}} 个文件',
     rootNoFolder: '根目录(无文件夹)',
     current: '当前',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3061,6 +3061,7 @@ export default {
     folderNamePlaceholder: '例如:功能零件',
     renameFile: '重新命名檔案',
     renameFolder: '重新命名資料夾',
+    invalidFilenameChar: '列印檔案名稱中不允許使用字元 "{{char}}"。印表機 SD 卡拒絕以下字元: < > : " / \\ | ? *',
     moveFiles: '移動 {{count}} 個檔案',
     rootNoFolder: '根目錄(無資料夾)',
     current: '目前',

+ 23 - 2
frontend/src/pages/FileManagerPage.tsx

@@ -220,6 +220,18 @@ function ExternalFolderModal({ onClose, onSave, isLoading, t }: ExternalFolderMo
   );
 }
 
+// FAT32/exFAT-illegal chars rejected by Bambu Studio (#1540). Mirrors the
+// backend validator in backend/app/utils/filename.py — keep in sync.
+const INVALID_FILENAME_CHARS = '<>:"/\\|?*';
+
+function findInvalidFilenameChar(name: string): string | null {
+  for (const ch of name) {
+    if (INVALID_FILENAME_CHARS.includes(ch)) return ch;
+    if (ch.charCodeAt(0) < 0x20) return ch;
+  }
+  return null;
+}
+
 // Rename Modal
 interface RenameModalProps {
   type: 'file' | 'folder';
@@ -237,8 +249,14 @@ function RenameModal({ type, currentName, onClose, onSave, isLoading, t }: Renam
   const baseName = type === 'file' && fileExtension ? currentName.slice(0, -fileExtension.length) : currentName;
   const [name, setName] = useState(baseName);
 
+  const invalidChar = type === 'file' ? findInvalidFilenameChar(name) : null;
+  const filenameError = invalidChar
+    ? t('fileManager.invalidFilenameChar', { char: invalidChar })
+    : null;
+
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
+    if (filenameError) return;
     const fullName = type === 'file' ? name.trim() + fileExtension : name.trim();
     if (name.trim() && fullName !== currentName) {
       onSave(fullName);
@@ -256,7 +274,7 @@ function RenameModal({ type, currentName, onClose, onSave, isLoading, t }: Renam
             <label className="block text-sm font-medium text-white mb-1">
               {t('common.name')}
             </label>
-            <div className="flex items-center bg-bambu-dark border border-bambu-dark-tertiary rounded focus-within:border-bambu-green">
+            <div className={`flex items-center bg-bambu-dark border rounded focus-within:border-bambu-green ${filenameError ? 'border-red-500' : 'border-bambu-dark-tertiary'}`}>
               <input
                 type="text"
                 value={name}
@@ -269,12 +287,15 @@ function RenameModal({ type, currentName, onClose, onSave, isLoading, t }: Renam
                 <span className="pr-3 text-bambu-gray text-sm select-none whitespace-nowrap">{fileExtension}</span>
               )}
             </div>
+            {filenameError && (
+              <p className="mt-1 text-xs text-red-400">{filenameError}</p>
+            )}
           </div>
           <div className="flex justify-end gap-2 pt-2">
             <Button type="button" variant="secondary" onClick={onClose}>
               {t('common.cancel')}
             </Button>
-            <Button type="submit" disabled={!name.trim() || name.trim() === baseName || isLoading}>
+            <Button type="submit" disabled={!name.trim() || name.trim() === baseName || !!filenameError || isLoading}>
               {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : t('common.rename')}
             </Button>
           </div>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Yqo2QO0m.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-anb_3VUN.js"></script>
+    <script type="module" crossorigin src="/assets/index-Yqo2QO0m.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-y4woBlMv.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов