test_slice_output_name_2832.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. """A display name is not a filename (#2832).
  2. A print's display name comes from the ``print_name`` embedded in the 3MF, which
  3. is whatever the model's author typed. The slice-to-archive sink built both the
  4. output directory and the output file straight out of it, so the MakerWorld title
  5. "Planter Pot with Drip Tray, 12 cm / 5 inches" put a path separator in the
  6. middle of a filename:
  7. .../20260814_090539_Planter Pot with Drip Tray, 12 cm / 5 inches_sliced/
  8. Planter Pot with Drip Tray, 12 cm / 5 inches.gcode.3mf
  9. ``mkdir(parents=True)`` created the two directories that first join implies --
  10. which is why the reporter could ``cd`` into it -- and the write then failed on a
  11. third level nobody had made. The same arithmetic with ``..`` in the name steers
  12. the write out of the archive directory altogether.
  13. """
  14. import pytest
  15. from backend.app.utils.filename import MAX_FILENAME_BYTES, clean_display_name, safe_path_component
  16. pytestmark = pytest.mark.unit
  17. REPORTED = "Planter Pot with Drip Tray, 12 cm / 5 inches"
  18. class TestTheReportedName:
  19. def test_the_slash_stops_being_a_separator(self):
  20. assert "/" not in safe_path_component(REPORTED, fallback="x")
  21. def test_and_the_name_is_still_recognisable(self):
  22. """Replaced rather than dropped: this string names the folder the user
  23. browses to, so it should still read like the model's title."""
  24. assert safe_path_component(REPORTED, fallback="x") == "Planter Pot with Drip Tray, 12 cm - 5 inches"
  25. def test_the_comma_is_left_alone(self):
  26. """Only what the filesystem cannot take is touched. A comma is fine,
  27. and the reporter's title has one."""
  28. assert "," in safe_path_component(REPORTED, fallback="x")
  29. class TestItCannotEscape:
  30. @pytest.mark.parametrize(
  31. "name",
  32. [
  33. "../../../../etc/cron.d/x",
  34. "..",
  35. "../..",
  36. "/etc/passwd",
  37. "..\\..\\windows\\system32",
  38. "a/../../b",
  39. ],
  40. )
  41. def test_no_separator_survives(self, name):
  42. """One component in, one component out. Nothing that follows can
  43. rejoin a directory it was not given."""
  44. result = safe_path_component(name, fallback="fallback")
  45. assert "/" not in result
  46. assert "\\" not in result
  47. assert result not in (".", "..")
  48. def test_a_name_that_reduces_to_nothing_falls_back(self):
  49. """An empty component would make the join collapse onto the parent."""
  50. assert safe_path_component("", fallback="archive_42") == "archive_42"
  51. assert safe_path_component(" ", fallback="archive_42") == "archive_42"
  52. assert safe_path_component("...", fallback="archive_42") == "archive_42"
  53. def test_a_name_of_pure_separators_reduces_to_a_usable_component(self):
  54. """Not the fallback -- the separators become ordinary characters, which
  55. is already a single valid component. What matters is that it is neither
  56. empty nor a relative path."""
  57. result = safe_path_component("/..", fallback="archive_42")
  58. assert result and "/" not in result and result not in (".", "..")
  59. class TestWindowsReservedCharacters:
  60. """A Windows install fails on the same shape for a different set. Bambuddy
  61. ships a Windows installer, and "Model: v2" is an ordinary title."""
  62. @pytest.mark.parametrize("char", list('<>:"|?*'))
  63. def test_reserved_punctuation_is_replaced(self, char):
  64. assert char not in safe_path_component(f"Model{char}v2", fallback="x")
  65. def test_control_characters_go_too(self):
  66. assert safe_path_component("Model\x00\x1bv2", fallback="x") == "Model--v2"
  67. def test_trailing_dots_and_spaces_go(self):
  68. """Windows cannot create either, and a trailing dot is how ".." would
  69. sneak back in."""
  70. assert safe_path_component("Model v2. ", fallback="x") == "Model v2"
  71. class TestLengthBudget:
  72. def test_a_long_name_is_capped(self):
  73. assert len(safe_path_component("A" * 400, fallback="x").encode()) == MAX_FILENAME_BYTES
  74. def test_the_caller_can_reserve_room_for_its_affixes(self):
  75. """The archive sink wraps the result in a timestamp and "_sliced", so
  76. the composed component would otherwise overrun the cap it just met."""
  77. result = safe_path_component("A" * 400, fallback="x", max_bytes=MAX_FILENAME_BYTES - 23)
  78. assert len(f"20260814_090539_{result}_sliced".encode()) <= MAX_FILENAME_BYTES
  79. def test_a_multibyte_name_is_not_cut_mid_character(self):
  80. """Truncating UTF-8 on a byte boundary can leave half a character,
  81. which does not decode."""
  82. result = safe_path_component("ü" * 200, fallback="x")
  83. assert len(result.encode()) <= MAX_FILENAME_BYTES
  84. result.encode().decode("utf-8") # must not raise
  85. class TestDisplayNamesKeepTheirPunctuation:
  86. """The name in the database is a title, not a path. Refusing the slash
  87. would reject the very name this issue is about."""
  88. def test_the_reported_title_survives_intact(self):
  89. assert clean_display_name(REPORTED) == REPORTED
  90. def test_control_characters_are_removed(self):
  91. assert clean_display_name("Piggy\x00 bank\x07") == "Piggy bank"
  92. def test_surrounding_whitespace_goes(self):
  93. assert clean_display_name(" Benchy ") == "Benchy"
  94. def test_an_empty_name_becomes_none(self):
  95. """Rather than an empty string, which would read as a name of nothing
  96. and defeat every ``print_name or fallback`` in the codebase."""
  97. assert clean_display_name(" ") is None
  98. assert clean_display_name("\x00") is None
  99. def test_none_stays_none(self):
  100. assert clean_display_name(None) is None
  101. @pytest.mark.parametrize("value", [123, ["a"], {"x": 1}, True])
  102. def test_a_non_string_is_handed_back_for_the_schema_to_reject(self, value):
  103. """It runs in front of the field's own type check. Iterating the value
  104. here would turn ["a"] into the name "a" and answer a bare int with a
  105. 500, where both should be a 422."""
  106. assert clean_display_name(value) is value
  107. @pytest.mark.parametrize("value", [123, ["a"], {"x": 1}, True])
  108. def test_and_the_schema_does_reject_it(self, value):
  109. from pydantic import ValidationError
  110. from backend.app.schemas.archive import ArchiveUpdate
  111. with pytest.raises(ValidationError) as excinfo:
  112. ArchiveUpdate(print_name=value)
  113. assert excinfo.value.errors()[0]["type"] == "string_type"
  114. def test_a_title_with_punctuation_reaches_the_database_intact(self):
  115. from backend.app.schemas.archive import ArchiveUpdate
  116. assert ArchiveUpdate(print_name=REPORTED).print_name == REPORTED
  117. def test_an_embedded_name_of_only_whitespace_falls_back_to_the_filename(self):
  118. """Cleaning has to happen before the fallback, not after it: " " is
  119. truthy, so cleaning afterwards would leave the archive with no name at
  120. all instead of the filename it used to get."""
  121. embedded, stem = " ", "Benchy"
  122. assert (clean_display_name(embedded) or clean_display_name(stem)) == "Benchy"