Files
bambuddy/backend/tests/unit/test_slice_output_name_2832.py
maziggy aff737999f 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.
2026-08-14 16:02:53 +02:00

167 lines
7.0 KiB
Python

"""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"