소스 검색

Build the slice output's path from a name a folder can have (#2832)

A print's display name comes from inside the 3MF, not from the filename,
so a MakerWorld title arrives with its punctuation: "Planter Pot with
Drip Tray, 12 cm / 5 inches". The slice-to-archive sink used it verbatim
for the output folder and the output file, and a slash in a folder name
is not a character -- it is another folder. mkdir(parents=True) created
the level it implied and the file's own join added a third that nobody
had made, so the slice failed with ENOENT on a path that half existed.
Renaming the print first was the only way through.

Reduce a display name to a single path component before it becomes one.
Characters a name cannot hold are replaced rather than dropped, so the
folder still reads like the model's title, and the set is the one the SD
card already rejects -- which covers a Windows install too, where the
colon in "Model: v2" fails the same way. The name shown in Bambuddy is
untouched: a title is allowed its punctuation, and refusing the slash
would reject the name this was reported about.

The joins are asserted to stay under the archive directory. That was
already claimed by a SEC-PATH-OK marker on both lines, citing a
sanitiser that is defined in another module and was never called here;
without the marker the path-join backstop flags them both. The claim is
now true, and a future edit that reaches around the reduction is caught
rather than trusted.

The library sink takes the same embedded name, so it gets the same
reduction: managed storage names the file after a UUID and never saw
this, but an external folder writes the name as given.

Display names are also stripped of control characters on the way into
the database, in the schema and in the archive service. The validator
hands back anything that is not a string rather than iterating it, so
the field still answers a list or a bare int with a 422 instead of
accepting the one and failing on the other.

Display names are also stripped of control characters on the way into
the database, in the schema and in the archive service, cleaned before
the filename fallback rather than after it so a whitespace-only embedded
name still falls through to the filename. The validator hands back
anything that is not a string rather than iterating it, so the field
still answers a list or a bare int with a 422 instead of accepting the
one and failing on the other.
maziggy 3 주 전
부모
커밋
aff737999f

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 1 - 0
CHANGELOG.md


+ 36 - 11
backend/app/api/routes/library.py

@@ -73,8 +73,13 @@ from backend.app.services.filament_requirements import annotate_rack_groups
 from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
 from backend.app.services.process_overrides import apply_process_overrides
 from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
-from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
-from backend.app.utils.safe_path import PathTraversalError, safe_join_under
+from backend.app.utils.filename import (
+    MAX_FILENAME_BYTES,
+    InvalidFilenameError,
+    safe_path_component,
+    validate_print_filename,
+)
+from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
 from backend.app.utils.threemf_tools import (
     default_plate_gcode_name,
     expand_to_project_slots,
@@ -4303,8 +4308,14 @@ async def slice_and_persist(
         job_id=job_id,
     )
 
+    # Same reduction as the archive sink: ``model_filename`` may be built from
+    # the source's embedded ``print_name``, which is free text (#2832). Managed
+    # storage names the file after a UUID and never sees this, but an external
+    # folder writes it verbatim, where a "/" would mean a directory nobody
+    # created -- and the library row shows it either way.
     base_name = model_filename.rsplit(".", 1)[0]
-    out_filename = f"{base_name}.gcode.3mf"
+    safe_base = safe_path_component(base_name, fallback="sliced", max_bytes=MAX_FILENAME_BYTES - len(b".gcode.3mf"))
+    out_filename = f"{safe_base}.gcode.3mf"
     # Write next to the source when the source lives on an external mount
     # (#2810). The folder is loaded here rather than passed in because every
     # caller already has only the id.
@@ -4445,19 +4456,33 @@ async def slice_and_persist_as_archive(
         current_user_id=current_user_id,
     )
 
-    base_name = model_filename.rsplit(".", 1)[0]
-    out_filename = f"{base_name}.gcode.3mf"
-
     timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
     printer_folder = str(source_archive.printer_id) if source_archive.printer_id is not None else "unassigned"
-    archive_subdir = f"{timestamp}_{base_name}_sliced"
+
+    # ``model_filename`` is built from the archive's display name, which comes
+    # from the 3MF's own metadata and is whatever the model's author typed. A
+    # "/" in it is a path separator, not a character: the joins below silently
+    # gain a level and the write lands on a parent that was never created
+    # (#2832). Reduce it to a single component first, leaving room for the
+    # prefix and the extension wrapped around it.
+    base_name = model_filename.rsplit(".", 1)[0]
+    reserve = max(len(f"{timestamp}__sliced".encode()), len(b".gcode.3mf"))
+    safe_base = safe_path_component(
+        base_name, fallback=f"archive_{source_archive.id}", max_bytes=MAX_FILENAME_BYTES - reserve
+    )
+    out_filename = f"{safe_base}.gcode.3mf"
+    archive_subdir = f"{timestamp}_{safe_base}_sliced"
+
     archive_dir = (
         app_settings.archive_dir / printer_folder / archive_subdir
-    )  # SEC-PATH-OK: printer_folder = str(int|None), archive_subdir = f"{timestamp}_{base_name}_sliced" where base_name went through _safe_filename
+    )  # SEC-PATH-OK: printer_folder = str(int|None); archive_subdir wraps safe_path_component output, asserted below
+    out_path = archive_dir / out_filename  # SEC-PATH-OK: out_filename wraps safe_path_component output, asserted below
+    # The sanitiser is what makes the two joins single-component; this is the
+    # backstop that says so out loud, and would catch a future edit that reaches
+    # around it. Checked before mkdir so a rejected path creates nothing.
+    assert_under(app_settings.archive_dir, archive_dir, http=False)
+    assert_under(app_settings.archive_dir, out_path, http=False)
     archive_dir.mkdir(parents=True, exist_ok=True)
-    out_path = (
-        archive_dir / out_filename
-    )  # SEC-PATH-OK: out_filename = f"{base_name}.gcode.3mf" where base_name went through _safe_filename
     # See library-slice path: BS/Orca sidecar CLIs don't embed plate_N.png
     # in headless --export-3mf, so the produced 3MF often has no thumbnail
     # at all. Server-side render fills the gap; no-op when the slicer did

+ 10 - 2
backend/app/schemas/archive.py

@@ -1,10 +1,18 @@
 from datetime import datetime
+from typing import Annotated
 
-from pydantic import BaseModel, model_validator
+from pydantic import BaseModel, BeforeValidator, model_validator
+
+from backend.app.utils.filename import clean_display_name
+
+# Free text, punctuation and all -- only control characters are taken out, and
+# only on the way in (#2832). Anything that turns a name into a path sanitises
+# it there instead, where the budget and the fallback are known.
+DisplayName = Annotated[str | None, BeforeValidator(clean_display_name)]
 
 
 class ArchiveBase(BaseModel):
-    print_name: str | None = None
+    print_name: DisplayName = None
     is_favorite: bool | None = None
     tags: str | None = None
     notes: str | None = None

+ 12 - 1
backend/app/services/archive.py

@@ -18,6 +18,7 @@ from backend.app.core.tasks import spawn_background_task
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.printer import Printer
+from backend.app.utils.filename import clean_display_name
 from backend.app.utils.safe_path import PathTraversalError, safe_join_under
 
 logger = logging.getLogger(__name__)
@@ -1332,7 +1333,17 @@ class ArchiveService:
             file_size=dest_file.stat().st_size,
             content_hash=content_hash,
             thumbnail_path=thumbnail_path,
-            print_name=display_stem if prefer_filename_for_name else (metadata.get("print_name") or display_stem),
+            # clean_display_name because the 3MF's own metadata reaches this
+            # verbatim, and a control character in it renders nowhere and
+            # truncates somewhere (#2832). The schema does the same for names
+            # arriving over the API. Cleaned before the fallback rather than
+            # after it, so an embedded name that is only whitespace still falls
+            # through to the filename instead of leaving the archive nameless.
+            print_name=(
+                clean_display_name(display_stem)
+                if prefer_filename_for_name
+                else (clean_display_name(metadata.get("print_name")) or clean_display_name(display_stem))
+            ),
             print_time_seconds=metadata.get("print_time_seconds"),
             filament_used_grams=metadata.get("filament_used_grams"),
             filament_type=metadata.get("filament_type"),

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

@@ -57,6 +57,69 @@ def validate_print_filename(name: str) -> None:
         raise InvalidFilenameError(f"Filename exceeds {MAX_FILENAME_BYTES} bytes")
 
 
+def clean_display_name(name: str | None) -> str | None:
+    """Tidy a free-text display name on the way into the database (#2832).
+
+    A display name is allowed its punctuation: "Planter Pot with Drip Tray,
+    12 cm / 5 inches" is a perfectly good title and refusing the slash would
+    reject the very name this issue was reported about. What has no business
+    in one is a control character or a NUL -- neither renders, both can
+    truncate a string somewhere further down.
+
+    Path safety is *not* enforced here, deliberately. It belongs at each point
+    where a name becomes a path, because that is where the budget and the
+    fallback differ; see ``safe_path_component``. This is tidying, not a
+    boundary.
+
+    Returns None unchanged, and None for a name that was only whitespace.
+
+    Anything that is not a string is handed back untouched, so the schema this
+    runs in front of still applies its own type check. Iterating it here instead
+    would turn ``["a"]`` into the name ``"a"`` and a non-iterable into a 500,
+    where the field is meant to answer with a 422.
+    """
+    if not isinstance(name, str):
+        return name
+    cleaned = "".join(ch for ch in name if ord(ch) >= 0x20 and ch != "\x7f").strip()
+    return cleaned or None
+
+
+def safe_path_component(name: str, *, fallback: str, max_bytes: int = MAX_FILENAME_BYTES) -> str:
+    """Reduce a display name to something usable as one path component (#2832).
+
+    A print's display name is not a filename. It comes from the ``print_name``
+    embedded in the 3MF -- MakerWorld titles like "Planter Pot with Drip Tray,
+    12 cm / 5 inches" arrive verbatim -- and several places build a directory or
+    a file out of it. A ``/`` in such a name is a path separator: the join
+    silently gains a level, ``mkdir(parents=True)`` creates it, and the write
+    that follows fails on a parent that was never made. Worse, the name is
+    user-controlled, so ``..`` segments in one steer the write out of the
+    directory it was meant for.
+
+    Every character the SD-card rules already reject is replaced rather than
+    dropped, so the result still reads like the original: that set is exactly
+    the separators plus the Windows-reserved punctuation, which a Windows
+    install needs for the same reason Linux needs the separators. Leading and
+    trailing dots and spaces go too -- ``..`` reduces to nothing rather than to
+    a relative path -- and the result is capped to what one component may hold.
+
+    Returns *fallback* when nothing usable survives, so a name made entirely of
+    separators cannot produce an empty path component.
+
+    *max_bytes* is the budget for this component alone. Callers that wrap the
+    result in a prefix or an extension must subtract those, or the composed
+    name can still exceed what the filesystem accepts.
+    """
+    cleaned = "".join("-" if (ch in INVALID_FILENAME_CHARS or ord(ch) < 0x20 or ch == "\x7f") else ch for ch in name)
+    cleaned = cleaned.strip(" .")
+
+    if len(cleaned.encode("utf-8")) > max_bytes:
+        # Cut on the byte limit, then drop any partial character the cut left.
+        cleaned = cleaned.encode("utf-8")[:max_bytes].decode("utf-8", errors="ignore").strip(" .")
+
+    return cleaned or fallback
+
+
 def derive_remote_filename(filename: str) -> str:
     """Compute the SD-card filename used when uploading a sliced print file.
 

+ 139 - 0
backend/tests/integration/test_slice_archive_output_path_2832.py

@@ -0,0 +1,139 @@
+"""Where a slice-to-archive actually writes (#2832).
+
+``safe_path_component`` is unit-tested. It is only worth anything if the sink
+uses it: reverting the sanitiser leaves those tests green, because they never
+touch the code that builds the path. These drive ``slice_and_persist_as_archive``
+with the slicer stubbed, on the name from the report and on one that tries to
+leave the archive directory.
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.api.routes.library import slice_and_persist_as_archive
+from backend.app.schemas.slicer import SliceRequest
+from backend.app.services.slicer_api import SliceResult
+
+pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
+
+REPORTED = "Planter Pot with Drip Tray, 12 cm / 5 inches"
+
+
+@pytest.fixture
+def archive_root(monkeypatch, tmp_path):
+    """Point both roots at a tmp dir, keeping their real relationship."""
+    from backend.app.core.config import settings
+
+    monkeypatch.setattr(settings, "base_dir", tmp_path)
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+    return tmp_path / "archive"
+
+
+def _stub_slicer(content: bytes = b"PK\x03\x04 not-a-real-3mf"):
+    return patch(
+        "backend.app.api.routes.library._run_slicer_with_fallback",
+        AsyncMock(return_value=(SliceResult(content, 3600, 12.5, 4200.0), False)),
+    )
+
+
+async def _slice(db_session, source_archive, model_filename):
+    with _stub_slicer():
+        return await slice_and_persist_as_archive(
+            db_session,
+            model_bytes=b"source model",
+            model_filename=model_filename,
+            request=SliceRequest(printer_preset_id=1, process_preset_id=2, filament_preset_id=3),
+            source_archive=source_archive,
+            current_user_id=None,
+        )
+
+
+async def _written_files(archive_root):
+    return [p for p in archive_root.rglob("*") if p.is_file()]
+
+
+class TestTheReportedFailure:
+    async def test_the_slice_lands_on_disk(self, db_session, archive_factory, printer_factory, archive_root):
+        """The write used to fail with ENOENT: mkdir made the two directories
+        the folder name implied, and the file's own join added a third that
+        nobody had created."""
+        printer = await printer_factory()
+        source = await archive_factory(printer.id, print_name=REPORTED, filename=f"{REPORTED}.3mf")
+
+        response = await _slice(db_session, source, f"{REPORTED}.3mf")
+
+        written = await _written_files(archive_root)
+        assert [p.name for p in written if p.suffix == ".3mf"] == [
+            "Planter Pot with Drip Tray, 12 cm - 5 inches.gcode.3mf"
+        ]
+        # The display name keeps its punctuation -- only the path is reduced.
+        assert "/" in response.name
+
+    async def test_the_folder_is_one_level_deep(self, db_session, archive_factory, printer_factory, archive_root):
+        """<archive>/<printer>/<timestamp>_<name>_sliced/<file> and no more.
+        The slash used to add a level in the middle of the folder name."""
+        printer = await printer_factory()
+        source = await archive_factory(printer.id, print_name=REPORTED, filename=f"{REPORTED}.3mf")
+
+        await _slice(db_session, source, f"{REPORTED}.3mf")
+
+        written = [p for p in await _written_files(archive_root) if p.suffix == ".3mf"][0]
+        assert written.relative_to(archive_root).parts[:1] == (str(printer.id),)
+        assert len(written.relative_to(archive_root).parts) == 3
+
+    async def test_the_archive_row_points_at_the_file(self, db_session, archive_factory, printer_factory, archive_root):
+        """A row whose file_path does not exist is the same class of bug one
+        step later -- every reprint and rescan reads it back."""
+        from backend.app.core.config import settings
+        from backend.app.models.archive import PrintArchive
+
+        printer = await printer_factory()
+        source = await archive_factory(printer.id, print_name=REPORTED, filename=f"{REPORTED}.3mf")
+
+        response = await _slice(db_session, source, f"{REPORTED}.3mf")
+
+        new_archive = await db_session.get(PrintArchive, response.archive_id)
+        assert (settings.base_dir / new_archive.file_path).is_file()
+
+
+class TestItStaysInTheArchiveDirectory:
+    @pytest.mark.parametrize(
+        "name",
+        [
+            "../../../../etc/cron.d/x",
+            "../escaped",
+            "..",
+        ],
+    )
+    async def test_a_traversing_name_writes_nowhere_else(
+        self, db_session, archive_factory, printer_factory, archive_root, tmp_path, name
+    ):
+        """The display name is free text from the 3MF, so it is whatever its
+        author put there."""
+        printer = await printer_factory()
+        source = await archive_factory(printer.id, print_name=name, filename="source.3mf")
+
+        await _slice(db_session, source, f"{name}.3mf")
+
+        written = await _written_files(archive_root)
+        assert written, "nothing was written at all"
+        for path in written:
+            # Not merely inside the archive root: inside this slice's own
+            # folder. A name that only climbs one level lands in the printer's
+            # directory, which is still contained and still wrong.
+            assert path.parent.name.endswith("_sliced"), path
+            assert path.resolve().is_relative_to(archive_root.resolve())
+        # And nothing appeared beside the archive root either.
+        assert not [p for p in tmp_path.iterdir() if p.name != "archive"]
+
+
+class TestOrdinaryNamesAreUnchanged:
+    async def test_a_plain_name_keeps_its_spelling(self, db_session, archive_factory, printer_factory, archive_root):
+        printer = await printer_factory()
+        source = await archive_factory(printer.id, print_name="Benchy", filename="Benchy.3mf")
+
+        response = await _slice(db_session, source, "Benchy.3mf")
+
+        assert response.name == "Benchy (re-sliced)"
+        assert [p.name for p in await _written_files(archive_root) if p.suffix == ".3mf"] == ["Benchy.gcode.3mf"]

+ 166 - 0
backend/tests/unit/test_slice_output_name_2832.py

@@ -0,0 +1,166 @@
+"""A display name is not a filename (#2832).
+
+A print's display name comes from the ``print_name`` embedded in the 3MF, which
+is whatever the model's author typed. The slice-to-archive sink built both the
+output directory and the output file straight out of it, so the MakerWorld title
+"Planter Pot with Drip Tray, 12 cm / 5 inches" put a path separator in the
+middle of a filename:
+
+    .../20260814_090539_Planter Pot with Drip Tray, 12 cm / 5 inches_sliced/
+        Planter Pot with Drip Tray, 12 cm / 5 inches.gcode.3mf
+
+``mkdir(parents=True)`` created the two directories that first join implies --
+which is why the reporter could ``cd`` into it -- and the write then failed on a
+third level nobody had made. The same arithmetic with ``..`` in the name steers
+the write out of the archive directory altogether.
+"""
+
+import pytest
+
+from backend.app.utils.filename import MAX_FILENAME_BYTES, clean_display_name, safe_path_component
+
+pytestmark = pytest.mark.unit
+
+REPORTED = "Planter Pot with Drip Tray, 12 cm / 5 inches"
+
+
+class TestTheReportedName:
+    def test_the_slash_stops_being_a_separator(self):
+        assert "/" not in safe_path_component(REPORTED, fallback="x")
+
+    def test_and_the_name_is_still_recognisable(self):
+        """Replaced rather than dropped: this string names the folder the user
+        browses to, so it should still read like the model's title."""
+        assert safe_path_component(REPORTED, fallback="x") == "Planter Pot with Drip Tray, 12 cm - 5 inches"
+
+    def test_the_comma_is_left_alone(self):
+        """Only what the filesystem cannot take is touched. A comma is fine,
+        and the reporter's title has one."""
+        assert "," in safe_path_component(REPORTED, fallback="x")
+
+
+class TestItCannotEscape:
+    @pytest.mark.parametrize(
+        "name",
+        [
+            "../../../../etc/cron.d/x",
+            "..",
+            "../..",
+            "/etc/passwd",
+            "..\\..\\windows\\system32",
+            "a/../../b",
+        ],
+    )
+    def test_no_separator_survives(self, name):
+        """One component in, one component out. Nothing that follows can
+        rejoin a directory it was not given."""
+        result = safe_path_component(name, fallback="fallback")
+
+        assert "/" not in result
+        assert "\\" not in result
+        assert result not in (".", "..")
+
+    def test_a_name_that_reduces_to_nothing_falls_back(self):
+        """An empty component would make the join collapse onto the parent."""
+        assert safe_path_component("", fallback="archive_42") == "archive_42"
+        assert safe_path_component("   ", fallback="archive_42") == "archive_42"
+        assert safe_path_component("...", fallback="archive_42") == "archive_42"
+
+    def test_a_name_of_pure_separators_reduces_to_a_usable_component(self):
+        """Not the fallback -- the separators become ordinary characters, which
+        is already a single valid component. What matters is that it is neither
+        empty nor a relative path."""
+        result = safe_path_component("/..", fallback="archive_42")
+
+        assert result and "/" not in result and result not in (".", "..")
+
+
+class TestWindowsReservedCharacters:
+    """A Windows install fails on the same shape for a different set. Bambuddy
+    ships a Windows installer, and "Model: v2" is an ordinary title."""
+
+    @pytest.mark.parametrize("char", list('<>:"|?*'))
+    def test_reserved_punctuation_is_replaced(self, char):
+        assert char not in safe_path_component(f"Model{char}v2", fallback="x")
+
+    def test_control_characters_go_too(self):
+        assert safe_path_component("Model\x00\x1bv2", fallback="x") == "Model--v2"
+
+    def test_trailing_dots_and_spaces_go(self):
+        """Windows cannot create either, and a trailing dot is how ".." would
+        sneak back in."""
+        assert safe_path_component("Model v2. ", fallback="x") == "Model v2"
+
+
+class TestLengthBudget:
+    def test_a_long_name_is_capped(self):
+        assert len(safe_path_component("A" * 400, fallback="x").encode()) == MAX_FILENAME_BYTES
+
+    def test_the_caller_can_reserve_room_for_its_affixes(self):
+        """The archive sink wraps the result in a timestamp and "_sliced", so
+        the composed component would otherwise overrun the cap it just met."""
+        result = safe_path_component("A" * 400, fallback="x", max_bytes=MAX_FILENAME_BYTES - 23)
+
+        assert len(f"20260814_090539_{result}_sliced".encode()) <= MAX_FILENAME_BYTES
+
+    def test_a_multibyte_name_is_not_cut_mid_character(self):
+        """Truncating UTF-8 on a byte boundary can leave half a character,
+        which does not decode."""
+        result = safe_path_component("ü" * 200, fallback="x")
+
+        assert len(result.encode()) <= MAX_FILENAME_BYTES
+        result.encode().decode("utf-8")  # must not raise
+
+
+class TestDisplayNamesKeepTheirPunctuation:
+    """The name in the database is a title, not a path. Refusing the slash
+    would reject the very name this issue is about."""
+
+    def test_the_reported_title_survives_intact(self):
+        assert clean_display_name(REPORTED) == REPORTED
+
+    def test_control_characters_are_removed(self):
+        assert clean_display_name("Piggy\x00 bank\x07") == "Piggy bank"
+
+    def test_surrounding_whitespace_goes(self):
+        assert clean_display_name("  Benchy  ") == "Benchy"
+
+    def test_an_empty_name_becomes_none(self):
+        """Rather than an empty string, which would read as a name of nothing
+        and defeat every ``print_name or fallback`` in the codebase."""
+        assert clean_display_name("   ") is None
+        assert clean_display_name("\x00") is None
+
+    def test_none_stays_none(self):
+        assert clean_display_name(None) is None
+
+    @pytest.mark.parametrize("value", [123, ["a"], {"x": 1}, True])
+    def test_a_non_string_is_handed_back_for_the_schema_to_reject(self, value):
+        """It runs in front of the field's own type check. Iterating the value
+        here would turn ["a"] into the name "a" and answer a bare int with a
+        500, where both should be a 422."""
+        assert clean_display_name(value) is value
+
+    @pytest.mark.parametrize("value", [123, ["a"], {"x": 1}, True])
+    def test_and_the_schema_does_reject_it(self, value):
+        from pydantic import ValidationError
+
+        from backend.app.schemas.archive import ArchiveUpdate
+
+        with pytest.raises(ValidationError) as excinfo:
+            ArchiveUpdate(print_name=value)
+
+        assert excinfo.value.errors()[0]["type"] == "string_type"
+
+    def test_a_title_with_punctuation_reaches_the_database_intact(self):
+        from backend.app.schemas.archive import ArchiveUpdate
+
+        assert ArchiveUpdate(print_name=REPORTED).print_name == REPORTED
+
+    def test_an_embedded_name_of_only_whitespace_falls_back_to_the_filename(self):
+        """Cleaning has to happen before the fallback, not after it: "   " is
+        truthy, so cleaning afterwards would leave the archive with no name at
+        all instead of the filename it used to get."""
+        embedded, stem = "   ", "Benchy"
+
+        assert (clean_display_name(embedded) or clean_display_name(stem)) == "Benchy"

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.