test_filters.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. # test_filters.py -- Tests for filters
  2. # Copyright (C) 2024 Jelmer Vernooij <jelmer@jelmer.uk>
  3. #
  4. # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
  5. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  6. # General Public License as published by the Free Software Foundation; version 2.0
  7. # or (at your option) any later version. You can redistribute it and/or
  8. # modify it under the terms of either of these two licenses.
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. #
  16. # You should have received a copy of the licenses; if not, see
  17. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  18. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  19. # License, Version 2.0.
  20. #
  21. """Tests for filters."""
  22. import os
  23. import tempfile
  24. import unittest
  25. from dulwich import porcelain
  26. from dulwich.filters import FilterError
  27. from dulwich.repo import Repo
  28. from . import TestCase
  29. class GitAttributesFilterIntegrationTests(TestCase):
  30. """Test gitattributes integration with filter drivers."""
  31. def setUp(self) -> None:
  32. super().setUp()
  33. self.test_dir = tempfile.mkdtemp()
  34. self.addCleanup(self._cleanup_test_dir)
  35. self.repo = Repo.init(self.test_dir)
  36. def _cleanup_test_dir(self) -> None:
  37. """Clean up test directory."""
  38. import shutil
  39. shutil.rmtree(self.test_dir)
  40. def test_gitattributes_text_filter(self) -> None:
  41. """Test that text attribute triggers line ending conversion."""
  42. # Configure autocrlf first
  43. config = self.repo.get_config()
  44. config.set((b"core",), b"autocrlf", b"true")
  45. config.write_to_path()
  46. # Create .gitattributes with text attribute
  47. gitattributes_path = os.path.join(self.test_dir, ".gitattributes")
  48. with open(gitattributes_path, "wb") as f:
  49. f.write(b"*.txt text\n")
  50. f.write(b"*.bin -text\n")
  51. # Add .gitattributes
  52. porcelain.add(self.repo, paths=[".gitattributes"])
  53. porcelain.commit(self.repo, message=b"Add gitattributes")
  54. # Create text file with CRLF
  55. text_file = os.path.join(self.test_dir, "test.txt")
  56. with open(text_file, "wb") as f:
  57. f.write(b"line1\r\nline2\r\n")
  58. # Create binary file with CRLF
  59. bin_file = os.path.join(self.test_dir, "test.bin")
  60. with open(bin_file, "wb") as f:
  61. f.write(b"binary\r\ndata\r\n")
  62. # Add files
  63. porcelain.add(self.repo, paths=["test.txt", "test.bin"])
  64. # Check that text file was normalized
  65. index = self.repo.open_index()
  66. text_entry = index[b"test.txt"]
  67. text_blob = self.repo.object_store[text_entry.sha]
  68. self.assertEqual(text_blob.data, b"line1\nline2\n")
  69. # Check that binary file was not normalized
  70. bin_entry = index[b"test.bin"]
  71. bin_blob = self.repo.object_store[bin_entry.sha]
  72. self.assertEqual(bin_blob.data, b"binary\r\ndata\r\n")
  73. @unittest.skip("Custom process filters require external commands")
  74. def test_gitattributes_custom_filter(self) -> None:
  75. """Test custom filter specified in gitattributes."""
  76. # Create .gitattributes with custom filter
  77. gitattributes_path = os.path.join(self.test_dir, ".gitattributes")
  78. with open(gitattributes_path, "wb") as f:
  79. f.write(b"*.secret filter=redact\n")
  80. # Configure custom filter (use tr command for testing)
  81. config = self.repo.get_config()
  82. # This filter replaces all digits with X
  83. config.set((b"filter", b"redact"), b"clean", b"tr '0-9' 'X'")
  84. config.write_to_path()
  85. # Add .gitattributes
  86. porcelain.add(self.repo, paths=[".gitattributes"])
  87. # Create file with sensitive content
  88. secret_file = os.path.join(self.test_dir, "password.secret")
  89. with open(secret_file, "wb") as f:
  90. f.write(b"password123\ntoken456\n")
  91. # Add file
  92. porcelain.add(self.repo, paths=["password.secret"])
  93. # Check that content was filtered
  94. index = self.repo.open_index()
  95. entry = index[b"password.secret"]
  96. blob = self.repo.object_store[entry.sha]
  97. self.assertEqual(blob.data, b"passwordXXX\ntokenXXX\n")
  98. def test_gitattributes_from_tree(self) -> None:
  99. """Test that gitattributes from tree are used when no working tree exists."""
  100. # Create .gitattributes with text attribute
  101. gitattributes_path = os.path.join(self.test_dir, ".gitattributes")
  102. with open(gitattributes_path, "wb") as f:
  103. f.write(b"*.txt text\n")
  104. # Add and commit .gitattributes
  105. porcelain.add(self.repo, paths=[".gitattributes"])
  106. porcelain.commit(self.repo, message=b"Add gitattributes")
  107. # Remove .gitattributes from working tree
  108. os.remove(gitattributes_path)
  109. # Get gitattributes - should still work from tree
  110. gitattributes = self.repo.get_gitattributes()
  111. attrs = gitattributes.match_path(b"test.txt")
  112. self.assertEqual(attrs.get(b"text"), True)
  113. def test_gitattributes_info_attributes(self) -> None:
  114. """Test that .git/info/attributes is read."""
  115. # Create info/attributes
  116. info_dir = os.path.join(self.repo.controldir(), "info")
  117. if not os.path.exists(info_dir):
  118. os.makedirs(info_dir)
  119. info_attrs_path = os.path.join(info_dir, "attributes")
  120. with open(info_attrs_path, "wb") as f:
  121. f.write(b"*.log text\n")
  122. # Get gitattributes
  123. gitattributes = self.repo.get_gitattributes()
  124. attrs = gitattributes.match_path(b"debug.log")
  125. self.assertEqual(attrs.get(b"text"), True)
  126. @unittest.skip("Custom process filters require external commands")
  127. def test_filter_precedence(self) -> None:
  128. """Test that filter attribute takes precedence over text attribute."""
  129. # Create .gitattributes with both text and filter
  130. gitattributes_path = os.path.join(self.test_dir, ".gitattributes")
  131. with open(gitattributes_path, "wb") as f:
  132. f.write(b"*.txt text filter=custom\n")
  133. # Configure autocrlf and custom filter
  134. config = self.repo.get_config()
  135. config.set((b"core",), b"autocrlf", b"true")
  136. # This filter converts to uppercase
  137. config.set((b"filter", b"custom"), b"clean", b"tr '[:lower:]' '[:upper:]'")
  138. config.write_to_path()
  139. # Add .gitattributes
  140. porcelain.add(self.repo, paths=[".gitattributes"])
  141. # Create text file with lowercase and CRLF
  142. text_file = os.path.join(self.test_dir, "test.txt")
  143. with open(text_file, "wb") as f:
  144. f.write(b"hello\r\nworld\r\n")
  145. # Add file
  146. porcelain.add(self.repo, paths=["test.txt"])
  147. # Check that custom filter was applied (not just line ending conversion)
  148. index = self.repo.open_index()
  149. entry = index[b"test.txt"]
  150. blob = self.repo.object_store[entry.sha]
  151. # Should be uppercase with LF endings
  152. self.assertEqual(blob.data, b"HELLO\nWORLD\n")
  153. def test_blob_normalizer_integration(self) -> None:
  154. """Test that get_blob_normalizer returns a FilterBlobNormalizer."""
  155. normalizer = self.repo.get_blob_normalizer()
  156. # Check it's the right type
  157. from dulwich.filters import FilterBlobNormalizer
  158. self.assertIsInstance(normalizer, FilterBlobNormalizer)
  159. # Check it has access to gitattributes
  160. self.assertIsNotNone(normalizer.gitattributes)
  161. self.assertIsNotNone(normalizer.filter_registry)
  162. def test_required_filter_missing(self) -> None:
  163. """Test that missing required filter raises an error."""
  164. # Create .gitattributes with required filter
  165. gitattributes_path = os.path.join(self.test_dir, ".gitattributes")
  166. with open(gitattributes_path, "wb") as f:
  167. f.write(b"*.secret filter=required_filter\n")
  168. # Configure filter as required but without commands
  169. config = self.repo.get_config()
  170. config.set((b"filter", b"required_filter"), b"required", b"true")
  171. config.write_to_path()
  172. # Add .gitattributes
  173. porcelain.add(self.repo, paths=[".gitattributes"])
  174. # Create file that would use the filter
  175. secret_file = os.path.join(self.test_dir, "test.secret")
  176. with open(secret_file, "wb") as f:
  177. f.write(b"test content\n")
  178. # Adding file should raise error due to missing required filter
  179. with self.assertRaises(FilterError) as cm:
  180. porcelain.add(self.repo, paths=["test.secret"])
  181. self.assertIn(
  182. "Required filter 'required_filter' is not available", str(cm.exception)
  183. )
  184. def test_required_filter_clean_command_fails(self) -> None:
  185. """Test that required filter failure during clean raises an error."""
  186. # Create .gitattributes with required filter
  187. gitattributes_path = os.path.join(self.test_dir, ".gitattributes")
  188. with open(gitattributes_path, "wb") as f:
  189. f.write(b"*.secret filter=failing_filter\n")
  190. # Configure filter as required with failing command
  191. config = self.repo.get_config()
  192. config.set(
  193. (b"filter", b"failing_filter"), b"clean", b"false"
  194. ) # false command always fails
  195. config.set((b"filter", b"failing_filter"), b"required", b"true")
  196. config.write_to_path()
  197. # Add .gitattributes
  198. porcelain.add(self.repo, paths=[".gitattributes"])
  199. # Create file that would use the filter
  200. secret_file = os.path.join(self.test_dir, "test.secret")
  201. with open(secret_file, "wb") as f:
  202. f.write(b"test content\n")
  203. # Adding file should raise error due to failing required filter
  204. with self.assertRaises(FilterError) as cm:
  205. porcelain.add(self.repo, paths=["test.secret"])
  206. self.assertIn("Required clean filter failed", str(cm.exception))
  207. def test_required_filter_success(self) -> None:
  208. """Test that required filter works when properly configured."""
  209. # Create .gitattributes with required filter
  210. gitattributes_path = os.path.join(self.test_dir, ".gitattributes")
  211. with open(gitattributes_path, "wb") as f:
  212. f.write(b"*.secret filter=working_filter\n")
  213. # Configure filter as required with working command
  214. config = self.repo.get_config()
  215. config.set(
  216. (b"filter", b"working_filter"), b"clean", b"tr 'a-z' 'A-Z'"
  217. ) # uppercase
  218. config.set((b"filter", b"working_filter"), b"required", b"true")
  219. config.write_to_path()
  220. # Add .gitattributes
  221. porcelain.add(self.repo, paths=[".gitattributes"])
  222. # Create file that would use the filter
  223. secret_file = os.path.join(self.test_dir, "test.secret")
  224. with open(secret_file, "wb") as f:
  225. f.write(b"hello world\n")
  226. # Adding file should work and apply filter
  227. porcelain.add(self.repo, paths=["test.secret"])
  228. # Check that content was filtered
  229. index = self.repo.open_index()
  230. entry = index[b"test.secret"]
  231. blob = self.repo.object_store[entry.sha]
  232. self.assertEqual(blob.data, b"HELLO WORLD\n")
  233. def test_optional_filter_failure_fallback(self) -> None:
  234. """Test that optional filter failure falls back to original data."""
  235. # Create .gitattributes with optional filter
  236. gitattributes_path = os.path.join(self.test_dir, ".gitattributes")
  237. with open(gitattributes_path, "wb") as f:
  238. f.write(b"*.txt filter=optional_filter\n")
  239. # Configure filter as optional (required=false) with failing command
  240. config = self.repo.get_config()
  241. config.set(
  242. (b"filter", b"optional_filter"), b"clean", b"false"
  243. ) # false command always fails
  244. config.set((b"filter", b"optional_filter"), b"required", b"false")
  245. config.write_to_path()
  246. # Add .gitattributes
  247. porcelain.add(self.repo, paths=[".gitattributes"])
  248. # Create file that would use the filter
  249. test_file = os.path.join(self.test_dir, "test.txt")
  250. with open(test_file, "wb") as f:
  251. f.write(b"test content\n")
  252. # Adding file should work and fallback to original content
  253. porcelain.add(self.repo, paths=["test.txt"])
  254. # Check that original content was preserved
  255. index = self.repo.open_index()
  256. entry = index[b"test.txt"]
  257. blob = self.repo.object_store[entry.sha]
  258. self.assertEqual(blob.data, b"test content\n")