filename.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. """Print-file filename validation matching Bambu Studio's save-dialog rules.
  2. The Bambu printer SD card is FAT32/exFAT. Names containing the Windows /
  3. DOS-reserved set (``< > : " / \\ | ? *``), ASCII control characters
  4. (0x00-0x1F), or trailing dots / spaces cannot be created on it — FTP fails
  5. with ``553 Could not create file`` (#1540). Bambu Studio refuses to save
  6. such names client-side; Bambuddy now does the same at the rename, upload,
  7. and dispatch boundaries so the failure surfaces with a clear message
  8. instead of an obscure FTP error after the user has already hit Print.
  9. """
  10. INVALID_FILENAME_CHARS = '<>:"/\\|?*'
  11. # FAT/exFAT cap on a single path component; UTF-8 byte length, not codepoints,
  12. # because that is what the on-disk encoding limit actually is.
  13. MAX_FILENAME_BYTES = 255
  14. class InvalidFilenameError(ValueError):
  15. """Filename contains characters or shape the printer SD card rejects.
  16. ``char`` is the first offending character when the failure is a
  17. character-set violation, or ``None`` for structural failures (empty,
  18. bare ``.``, trailing space, too long, etc.). The frontend echoes it
  19. back to the user in the Bambu Studio-style error message.
  20. """
  21. def __init__(self, message: str, char: str | None = None):
  22. super().__init__(message)
  23. self.char = char
  24. def validate_print_filename(name: str) -> None:
  25. """Raise ``InvalidFilenameError`` if ``name`` would fail on the SD card.
  26. Matches Bambu Studio's save-dialog rejection set. Callers are expected
  27. to translate the exception into an HTTP 400 (or a clean dispatch
  28. rejection); the message is intentionally short and ASCII so it fits
  29. a translation template.
  30. """
  31. if not name or not name.strip():
  32. raise InvalidFilenameError("Filename cannot be empty")
  33. if name in (".", ".."):
  34. raise InvalidFilenameError("Filename cannot be '.' or '..'")
  35. for ch in name:
  36. if ch in INVALID_FILENAME_CHARS:
  37. raise InvalidFilenameError(f"Filename contains invalid character: {ch}", char=ch)
  38. if ord(ch) < 0x20:
  39. raise InvalidFilenameError("Filename contains a control character", char=ch)
  40. if name.endswith(" ") or name.endswith("."):
  41. raise InvalidFilenameError("Filename cannot end with a space or dot")
  42. if len(name.encode("utf-8")) > MAX_FILENAME_BYTES:
  43. raise InvalidFilenameError(f"Filename exceeds {MAX_FILENAME_BYTES} bytes")
  44. def clean_display_name(name: str | None) -> str | None:
  45. """Tidy a free-text display name on the way into the database (#2832).
  46. A display name is allowed its punctuation: "Planter Pot with Drip Tray,
  47. 12 cm / 5 inches" is a perfectly good title and refusing the slash would
  48. reject the very name this issue was reported about. What has no business
  49. in one is a control character or a NUL -- neither renders, both can
  50. truncate a string somewhere further down.
  51. Path safety is *not* enforced here, deliberately. It belongs at each point
  52. where a name becomes a path, because that is where the budget and the
  53. fallback differ; see ``safe_path_component``. This is tidying, not a
  54. boundary.
  55. Returns None unchanged, and None for a name that was only whitespace.
  56. Anything that is not a string is handed back untouched, so the schema this
  57. runs in front of still applies its own type check. Iterating it here instead
  58. would turn ``["a"]`` into the name ``"a"`` and a non-iterable into a 500,
  59. where the field is meant to answer with a 422.
  60. """
  61. if not isinstance(name, str):
  62. return name
  63. cleaned = "".join(ch for ch in name if ord(ch) >= 0x20 and ch != "\x7f").strip()
  64. return cleaned or None
  65. def safe_path_component(name: str, *, fallback: str, max_bytes: int = MAX_FILENAME_BYTES) -> str:
  66. """Reduce a display name to something usable as one path component (#2832).
  67. A print's display name is not a filename. It comes from the ``print_name``
  68. embedded in the 3MF -- MakerWorld titles like "Planter Pot with Drip Tray,
  69. 12 cm / 5 inches" arrive verbatim -- and several places build a directory or
  70. a file out of it. A ``/`` in such a name is a path separator: the join
  71. silently gains a level, ``mkdir(parents=True)`` creates it, and the write
  72. that follows fails on a parent that was never made. Worse, the name is
  73. user-controlled, so ``..`` segments in one steer the write out of the
  74. directory it was meant for.
  75. Every character the SD-card rules already reject is replaced rather than
  76. dropped, so the result still reads like the original: that set is exactly
  77. the separators plus the Windows-reserved punctuation, which a Windows
  78. install needs for the same reason Linux needs the separators. Leading and
  79. trailing dots and spaces go too -- ``..`` reduces to nothing rather than to
  80. a relative path -- and the result is capped to what one component may hold.
  81. Returns *fallback* when nothing usable survives, so a name made entirely of
  82. separators cannot produce an empty path component.
  83. *max_bytes* is the budget for this component alone. Callers that wrap the
  84. result in a prefix or an extension must subtract those, or the composed
  85. name can still exceed what the filesystem accepts.
  86. """
  87. cleaned = "".join("-" if (ch in INVALID_FILENAME_CHARS or ord(ch) < 0x20 or ch == "\x7f") else ch for ch in name)
  88. cleaned = cleaned.strip(" .")
  89. if len(cleaned.encode("utf-8")) > max_bytes:
  90. # Cut on the byte limit, then drop any partial character the cut left.
  91. cleaned = cleaned.encode("utf-8")[:max_bytes].decode("utf-8", errors="ignore").strip(" .")
  92. return cleaned or fallback
  93. def derive_remote_filename(filename: str) -> str:
  94. """Compute the SD-card filename used when uploading a sliced print file.
  95. Strips repeated trailing ``.gcode.3mf`` / ``.3mf`` suffixes until the
  96. bare stem remains, then appends a single ``.3mf``; spaces are
  97. replaced with underscores because the firmware parses
  98. ``ftp://{filename}`` as a URL.
  99. Canonical for both the dispatch uploader and the post-print SD
  100. cleanup — when the two drift apart the cleanup misses, and a
  101. library row whose stored filename ended up with a doubled
  102. ``.gcode.3mf`` (#1542) leaves the real file on the SD card. On A1
  103. firmware that lingering file becomes a ghost print on the next
  104. power-on (same family as the P1S behaviour in #374).
  105. Raises ``TypeError`` on non-string input rather than entering the
  106. strip loop, because a duck-typed object that returns truthy
  107. sentinels from ``endswith`` would never escape and the resulting
  108. unbounded allocation has cgroup-OOM'd the test runner under mocks.
  109. """
  110. if not isinstance(filename, str):
  111. raise TypeError(f"derive_remote_filename requires str, got {type(filename).__name__}")
  112. stem = filename
  113. while True:
  114. if stem.endswith(".gcode.3mf"):
  115. stem = stem[:-10]
  116. elif stem.endswith(".3mf"):
  117. stem = stem[:-4]
  118. else:
  119. break
  120. return f"{stem}.3mf".replace(" ", "_")