mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
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.
167 lines
7.0 KiB
Python
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"
|