test_config.py 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390
  1. # test_config.py -- Tests for reading and writing configuration files
  2. # Copyright (C) 2011 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 public 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 reading and writing configuration files."""
  22. import os
  23. import sys
  24. import tempfile
  25. from io import BytesIO
  26. from unittest import skipIf
  27. from unittest.mock import patch
  28. from dulwich.config import (
  29. CaseInsensitiveOrderedMultiDict,
  30. ConfigDict,
  31. ConfigFile,
  32. StackedConfig,
  33. _check_section_name,
  34. _check_variable_name,
  35. _escape_value,
  36. _format_string,
  37. _parse_string,
  38. apply_instead_of,
  39. parse_submodules,
  40. )
  41. from . import TestCase
  42. class ConfigFileTests(TestCase):
  43. def from_file(self, text):
  44. return ConfigFile.from_file(BytesIO(text))
  45. def test_empty(self) -> None:
  46. ConfigFile()
  47. def test_eq(self) -> None:
  48. self.assertEqual(ConfigFile(), ConfigFile())
  49. def test_default_config(self) -> None:
  50. cf = self.from_file(
  51. b"""[core]
  52. \trepositoryformatversion = 0
  53. \tfilemode = true
  54. \tbare = false
  55. \tlogallrefupdates = true
  56. """
  57. )
  58. self.assertEqual(
  59. ConfigFile(
  60. {
  61. (b"core",): {
  62. b"repositoryformatversion": b"0",
  63. b"filemode": b"true",
  64. b"bare": b"false",
  65. b"logallrefupdates": b"true",
  66. }
  67. }
  68. ),
  69. cf,
  70. )
  71. def test_from_file_empty(self) -> None:
  72. cf = self.from_file(b"")
  73. self.assertEqual(ConfigFile(), cf)
  74. def test_empty_line_before_section(self) -> None:
  75. cf = self.from_file(b"\n[section]\n")
  76. self.assertEqual(ConfigFile({(b"section",): {}}), cf)
  77. def test_comment_before_section(self) -> None:
  78. cf = self.from_file(b"# foo\n[section]\n")
  79. self.assertEqual(ConfigFile({(b"section",): {}}), cf)
  80. def test_comment_after_section(self) -> None:
  81. cf = self.from_file(b"[section] # foo\n")
  82. self.assertEqual(ConfigFile({(b"section",): {}}), cf)
  83. def test_comment_after_variable(self) -> None:
  84. cf = self.from_file(b"[section]\nbar= foo # a comment\n")
  85. self.assertEqual(ConfigFile({(b"section",): {b"bar": b"foo"}}), cf)
  86. def test_comment_character_within_value_string(self) -> None:
  87. cf = self.from_file(b'[section]\nbar= "foo#bar"\n')
  88. self.assertEqual(ConfigFile({(b"section",): {b"bar": b"foo#bar"}}), cf)
  89. def test_comment_character_within_section_string(self) -> None:
  90. cf = self.from_file(b'[branch "foo#bar"] # a comment\nbar= foo\n')
  91. self.assertEqual(ConfigFile({(b"branch", b"foo#bar"): {b"bar": b"foo"}}), cf)
  92. def test_closing_bracket_within_section_string(self) -> None:
  93. cf = self.from_file(b'[branch "foo]bar"] # a comment\nbar= foo\n')
  94. self.assertEqual(ConfigFile({(b"branch", b"foo]bar"): {b"bar": b"foo"}}), cf)
  95. def test_from_file_section(self) -> None:
  96. cf = self.from_file(b"[core]\nfoo = bar\n")
  97. self.assertEqual(b"bar", cf.get((b"core",), b"foo"))
  98. self.assertEqual(b"bar", cf.get((b"core", b"foo"), b"foo"))
  99. def test_from_file_multiple(self) -> None:
  100. cf = self.from_file(b"[core]\nfoo = bar\nfoo = blah\n")
  101. self.assertEqual([b"bar", b"blah"], list(cf.get_multivar((b"core",), b"foo")))
  102. self.assertEqual([], list(cf.get_multivar((b"core",), b"blah")))
  103. def test_from_file_utf8_bom(self) -> None:
  104. text = "[core]\nfoo = b\u00e4r\n".encode("utf-8-sig")
  105. cf = self.from_file(text)
  106. self.assertEqual(b"b\xc3\xa4r", cf.get((b"core",), b"foo"))
  107. def test_from_file_section_case_insensitive_lower(self) -> None:
  108. cf = self.from_file(b"[cOre]\nfOo = bar\n")
  109. self.assertEqual(b"bar", cf.get((b"core",), b"foo"))
  110. self.assertEqual(b"bar", cf.get((b"core", b"foo"), b"foo"))
  111. def test_from_file_section_case_insensitive_mixed(self) -> None:
  112. cf = self.from_file(b"[cOre]\nfOo = bar\n")
  113. self.assertEqual(b"bar", cf.get((b"core",), b"fOo"))
  114. self.assertEqual(b"bar", cf.get((b"cOre", b"fOo"), b"fOo"))
  115. def test_from_file_with_mixed_quoted(self) -> None:
  116. cf = self.from_file(b'[core]\nfoo = "bar"la\n')
  117. self.assertEqual(b"barla", cf.get((b"core",), b"foo"))
  118. def test_from_file_section_with_open_brackets(self) -> None:
  119. self.assertRaises(ValueError, self.from_file, b"[core\nfoo = bar\n")
  120. def test_from_file_value_with_open_quoted(self) -> None:
  121. self.assertRaises(ValueError, self.from_file, b'[core]\nfoo = "bar\n')
  122. def test_from_file_with_quotes(self) -> None:
  123. cf = self.from_file(b'[core]\nfoo = " bar"\n')
  124. self.assertEqual(b" bar", cf.get((b"core",), b"foo"))
  125. def test_from_file_with_interrupted_line(self) -> None:
  126. cf = self.from_file(b"[core]\nfoo = bar\\\n la\n")
  127. self.assertEqual(b"barla", cf.get((b"core",), b"foo"))
  128. def test_from_file_with_boolean_setting(self) -> None:
  129. cf = self.from_file(b"[core]\nfoo\n")
  130. self.assertEqual(b"true", cf.get((b"core",), b"foo"))
  131. def test_from_file_subsection(self) -> None:
  132. cf = self.from_file(b'[branch "foo"]\nfoo = bar\n')
  133. self.assertEqual(b"bar", cf.get((b"branch", b"foo"), b"foo"))
  134. def test_from_file_subsection_invalid(self) -> None:
  135. self.assertRaises(ValueError, self.from_file, b'[branch "foo]\nfoo = bar\n')
  136. def test_from_file_subsection_not_quoted(self) -> None:
  137. cf = self.from_file(b"[branch.foo]\nfoo = bar\n")
  138. self.assertEqual(b"bar", cf.get((b"branch", b"foo"), b"foo"))
  139. def test_from_file_includeif_hasconfig(self) -> None:
  140. """Test parsing includeIf sections with hasconfig conditions."""
  141. # Test case from issue #1216
  142. cf = self.from_file(
  143. b'[includeIf "hasconfig:remote.*.url:ssh://org-*@github.com/**"]\n'
  144. b" path = ~/.config/git/.work\n"
  145. )
  146. self.assertEqual(
  147. b"~/.config/git/.work",
  148. cf.get(
  149. (b"includeIf", b"hasconfig:remote.*.url:ssh://org-*@github.com/**"),
  150. b"path",
  151. ),
  152. )
  153. def test_write_preserve_multivar(self) -> None:
  154. cf = self.from_file(b"[core]\nfoo = bar\nfoo = blah\n")
  155. f = BytesIO()
  156. cf.write_to_file(f)
  157. self.assertEqual(b"[core]\n\tfoo = bar\n\tfoo = blah\n", f.getvalue())
  158. def test_write_to_file_empty(self) -> None:
  159. c = ConfigFile()
  160. f = BytesIO()
  161. c.write_to_file(f)
  162. self.assertEqual(b"", f.getvalue())
  163. def test_write_to_file_section(self) -> None:
  164. c = ConfigFile()
  165. c.set((b"core",), b"foo", b"bar")
  166. f = BytesIO()
  167. c.write_to_file(f)
  168. self.assertEqual(b"[core]\n\tfoo = bar\n", f.getvalue())
  169. def test_write_to_file_section_multiple(self) -> None:
  170. c = ConfigFile()
  171. c.set((b"core",), b"foo", b"old")
  172. c.set((b"core",), b"foo", b"new")
  173. f = BytesIO()
  174. c.write_to_file(f)
  175. self.assertEqual(b"[core]\n\tfoo = new\n", f.getvalue())
  176. def test_write_to_file_subsection(self) -> None:
  177. c = ConfigFile()
  178. c.set((b"branch", b"blie"), b"foo", b"bar")
  179. f = BytesIO()
  180. c.write_to_file(f)
  181. self.assertEqual(b'[branch "blie"]\n\tfoo = bar\n', f.getvalue())
  182. def test_same_line(self) -> None:
  183. cf = self.from_file(b"[branch.foo] foo = bar\n")
  184. self.assertEqual(b"bar", cf.get((b"branch", b"foo"), b"foo"))
  185. def test_quoted_newlines_windows(self) -> None:
  186. cf = self.from_file(
  187. b"[alias]\r\n"
  188. b"c = '!f() { \\\r\n"
  189. b' printf \'[git commit -m \\"%s\\"]\\n\' \\"$*\\" && \\\r\n'
  190. b' git commit -m \\"$*\\"; \\\r\n'
  191. b" }; f'\r\n"
  192. )
  193. self.assertEqual(list(cf.sections()), [(b"alias",)])
  194. self.assertEqual(
  195. b'\'!f() { printf \'[git commit -m "%s"]\n\' "$*" && git commit -m "$*"',
  196. cf.get((b"alias",), b"c"),
  197. )
  198. def test_quoted(self) -> None:
  199. cf = self.from_file(
  200. b"""[gui]
  201. \tfontdiff = -family \\\"Ubuntu Mono\\\" -size 11 -overstrike 0
  202. """
  203. )
  204. self.assertEqual(
  205. ConfigFile(
  206. {
  207. (b"gui",): {
  208. b"fontdiff": b'-family "Ubuntu Mono" -size 11 -overstrike 0',
  209. }
  210. }
  211. ),
  212. cf,
  213. )
  214. def test_quoted_multiline(self) -> None:
  215. cf = self.from_file(
  216. b"""[alias]
  217. who = \"!who() {\\
  218. git log --no-merges --pretty=format:'%an - %ae' $@ | uniq -c | sort -rn;\\
  219. };\\
  220. who\"
  221. """
  222. )
  223. self.assertEqual(
  224. ConfigFile(
  225. {
  226. (b"alias",): {
  227. b"who": (
  228. b"!who() {git log --no-merges --pretty=format:'%an - "
  229. b"%ae' $@ | uniq -c | sort -rn;};who"
  230. )
  231. }
  232. }
  233. ),
  234. cf,
  235. )
  236. def test_set_hash_gets_quoted(self) -> None:
  237. c = ConfigFile()
  238. c.set(b"xandikos", b"color", b"#665544")
  239. f = BytesIO()
  240. c.write_to_file(f)
  241. self.assertEqual(b'[xandikos]\n\tcolor = "#665544"\n', f.getvalue())
  242. def test_windows_path_with_trailing_backslash_unquoted(self) -> None:
  243. """Test that Windows paths ending with escaped backslash are handled correctly."""
  244. # This reproduces the issue from https://github.com/jelmer/dulwich/issues/1088
  245. # A single backslash at the end should actually be a line continuation in strict Git config
  246. # But we want to be more tolerant like Git itself
  247. cf = self.from_file(
  248. b'[core]\n\trepositoryformatversion = 0\n[remote "origin"]\n\turl = C:/Users/test\\\\\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n'
  249. )
  250. self.assertEqual(b"C:/Users/test\\", cf.get((b"remote", b"origin"), b"url"))
  251. self.assertEqual(
  252. b"+refs/heads/*:refs/remotes/origin/*",
  253. cf.get((b"remote", b"origin"), b"fetch"),
  254. )
  255. def test_windows_path_with_trailing_backslash_quoted(self) -> None:
  256. """Test that quoted Windows paths with escaped backslashes work correctly."""
  257. cf = self.from_file(
  258. b'[core]\n\trepositoryformatversion = 0\n[remote "origin"]\n\turl = "C:\\\\Users\\\\test\\\\"\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n'
  259. )
  260. self.assertEqual(b"C:\\Users\\test\\", cf.get((b"remote", b"origin"), b"url"))
  261. self.assertEqual(
  262. b"+refs/heads/*:refs/remotes/origin/*",
  263. cf.get((b"remote", b"origin"), b"fetch"),
  264. )
  265. def test_single_backslash_at_line_end_shows_proper_escaping_needed(self) -> None:
  266. """Test that demonstrates proper escaping is needed for single backslashes."""
  267. # This test documents the current behavior: a single backslash at the end of a line
  268. # is treated as a line continuation per Git config spec. Users should escape backslashes.
  269. # This reproduces the original issue - single backslash causes line continuation
  270. cf = self.from_file(
  271. b'[remote "origin"]\n\turl = C:/Users/test\\\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n'
  272. )
  273. # The result shows that line continuation occurred
  274. self.assertEqual(
  275. b"C:/Users/testfetch = +refs/heads/*:refs/remotes/origin/*",
  276. cf.get((b"remote", b"origin"), b"url"),
  277. )
  278. # The proper way to include a literal backslash is to escape it
  279. cf2 = self.from_file(
  280. b'[remote "origin"]\n\turl = C:/Users/test\\\\\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n'
  281. )
  282. self.assertEqual(b"C:/Users/test\\", cf2.get((b"remote", b"origin"), b"url"))
  283. self.assertEqual(
  284. b"+refs/heads/*:refs/remotes/origin/*",
  285. cf2.get((b"remote", b"origin"), b"fetch"),
  286. )
  287. def test_from_path_pathlib(self) -> None:
  288. import tempfile
  289. from pathlib import Path
  290. # Create a temporary config file
  291. with tempfile.NamedTemporaryFile(mode="w", suffix=".config", delete=False) as f:
  292. f.write("[core]\n filemode = true\n")
  293. temp_path = f.name
  294. try:
  295. # Test with pathlib.Path
  296. path_obj = Path(temp_path)
  297. cf = ConfigFile.from_path(path_obj)
  298. self.assertEqual(cf.get((b"core",), b"filemode"), b"true")
  299. finally:
  300. # Clean up
  301. os.unlink(temp_path)
  302. def test_write_to_path_pathlib(self) -> None:
  303. import tempfile
  304. from pathlib import Path
  305. # Create a config
  306. cf = ConfigFile()
  307. cf.set((b"user",), b"name", b"Test User")
  308. # Write to pathlib.Path
  309. with tempfile.NamedTemporaryFile(suffix=".config", delete=False) as f:
  310. temp_path = f.name
  311. try:
  312. path_obj = Path(temp_path)
  313. cf.write_to_path(path_obj)
  314. # Read it back
  315. cf2 = ConfigFile.from_path(path_obj)
  316. self.assertEqual(cf2.get((b"user",), b"name"), b"Test User")
  317. finally:
  318. # Clean up
  319. os.unlink(temp_path)
  320. def test_include_basic(self) -> None:
  321. """Test basic include functionality."""
  322. with tempfile.TemporaryDirectory() as tmpdir:
  323. # Create included config file
  324. included_path = os.path.join(tmpdir, "included.config")
  325. with open(included_path, "wb") as f:
  326. f.write(
  327. b"[user]\n name = Included User\n email = included@example.com\n"
  328. )
  329. # Create main config with include
  330. main_config = self.from_file(
  331. b"[user]\n name = Main User\n[include]\n path = included.config\n"
  332. )
  333. # Should not include anything without proper directory context
  334. self.assertEqual(b"Main User", main_config.get((b"user",), b"name"))
  335. with self.assertRaises(KeyError):
  336. main_config.get((b"user",), b"email")
  337. # Now test with proper file loading
  338. main_path = os.path.join(tmpdir, "main.config")
  339. with open(main_path, "wb") as f:
  340. f.write(
  341. b"[user]\n name = Main User\n[include]\n path = included.config\n"
  342. )
  343. # Load from path to get include functionality
  344. cf = ConfigFile.from_path(main_path)
  345. self.assertEqual(b"Included User", cf.get((b"user",), b"name"))
  346. self.assertEqual(b"included@example.com", cf.get((b"user",), b"email"))
  347. def test_include_absolute_path(self) -> None:
  348. """Test include with absolute path."""
  349. with tempfile.TemporaryDirectory() as tmpdir:
  350. # Use realpath to resolve any symlinks (important on macOS and Windows)
  351. tmpdir = os.path.realpath(tmpdir)
  352. # Create included config file
  353. included_path = os.path.join(tmpdir, "included.config")
  354. with open(included_path, "wb") as f:
  355. f.write(b"[core]\n bare = true\n")
  356. # Create main config with absolute include path
  357. main_path = os.path.join(tmpdir, "main.config")
  358. with open(main_path, "wb") as f:
  359. # Properly escape backslashes in Windows paths
  360. escaped_path = included_path.replace("\\", "\\\\")
  361. f.write(f"[include]\n path = {escaped_path}\n".encode())
  362. cf = ConfigFile.from_path(main_path)
  363. self.assertEqual(b"true", cf.get((b"core",), b"bare"))
  364. def test_includeif_gitdir_match(self) -> None:
  365. """Test includeIf with gitdir condition that matches."""
  366. with tempfile.TemporaryDirectory() as tmpdir:
  367. repo_dir = os.path.join(tmpdir, "myrepo")
  368. os.makedirs(repo_dir)
  369. # Use realpath to resolve any symlinks (important on macOS)
  370. repo_dir = os.path.realpath(repo_dir)
  371. # Create included config file
  372. included_path = os.path.join(tmpdir, "work.config")
  373. with open(included_path, "wb") as f:
  374. f.write(b"[user]\n email = work@example.com\n")
  375. # Create main config with includeIf
  376. main_path = os.path.join(tmpdir, "main.config")
  377. with open(main_path, "wb") as f:
  378. f.write(
  379. f'[includeIf "gitdir:{repo_dir}/"]\n path = work.config\n'.encode()
  380. )
  381. # Load with matching repo_dir
  382. cf = ConfigFile.from_path(main_path, repo_dir=repo_dir)
  383. self.assertEqual(b"work@example.com", cf.get((b"user",), b"email"))
  384. def test_includeif_gitdir_no_match(self) -> None:
  385. """Test includeIf with gitdir condition that doesn't match."""
  386. with tempfile.TemporaryDirectory() as tmpdir:
  387. repo_dir = os.path.join(tmpdir, "myrepo")
  388. other_dir = os.path.join(tmpdir, "other")
  389. os.makedirs(repo_dir)
  390. os.makedirs(other_dir)
  391. # Use realpath to resolve any symlinks (important on macOS)
  392. repo_dir = os.path.realpath(repo_dir)
  393. other_dir = os.path.realpath(other_dir)
  394. # Create included config file
  395. included_path = os.path.join(tmpdir, "work.config")
  396. with open(included_path, "wb") as f:
  397. f.write(b"[user]\n email = work@example.com\n")
  398. # Create main config with includeIf
  399. main_path = os.path.join(tmpdir, "main.config")
  400. with open(main_path, "wb") as f:
  401. f.write(
  402. f'[includeIf "gitdir:{repo_dir}/"]\n path = work.config\n'.encode()
  403. )
  404. # Load with non-matching repo_dir
  405. cf = ConfigFile.from_path(main_path, repo_dir=other_dir)
  406. with self.assertRaises(KeyError):
  407. cf.get((b"user",), b"email")
  408. def test_includeif_gitdir_pattern(self) -> None:
  409. """Test includeIf with gitdir pattern matching."""
  410. with tempfile.TemporaryDirectory() as tmpdir:
  411. # Use realpath to resolve any symlinks
  412. tmpdir = os.path.realpath(tmpdir)
  413. work_dir = os.path.join(tmpdir, "work", "project1")
  414. os.makedirs(work_dir)
  415. # Create included config file
  416. included_path = os.path.join(tmpdir, "work.config")
  417. with open(included_path, "wb") as f:
  418. f.write(b"[user]\n email = work@company.com\n")
  419. # Create main config with pattern
  420. main_path = os.path.join(tmpdir, "main.config")
  421. with open(main_path, "wb") as f:
  422. # Pattern that should match any repo under work/
  423. f.write(b'[includeIf "gitdir:work/**"]\n path = work.config\n')
  424. # Load with matching pattern
  425. cf = ConfigFile.from_path(main_path, repo_dir=work_dir)
  426. self.assertEqual(b"work@company.com", cf.get((b"user",), b"email"))
  427. def test_includeif_hasconfig(self) -> None:
  428. """Test includeIf with hasconfig conditions."""
  429. with tempfile.TemporaryDirectory() as tmpdir:
  430. # Create included config file
  431. work_included_path = os.path.join(tmpdir, "work.config")
  432. with open(work_included_path, "wb") as f:
  433. f.write(b"[user]\n email = work@company.com\n")
  434. personal_included_path = os.path.join(tmpdir, "personal.config")
  435. with open(personal_included_path, "wb") as f:
  436. f.write(b"[user]\n email = personal@example.com\n")
  437. # Create main config with hasconfig conditions
  438. main_path = os.path.join(tmpdir, "main.config")
  439. with open(main_path, "wb") as f:
  440. f.write(
  441. b'[remote "origin"]\n'
  442. b" url = ssh://org-work@github.com/company/project\n"
  443. b'[includeIf "hasconfig:remote.*.url:ssh://org-*@github.com/**"]\n'
  444. b" path = work.config\n"
  445. b'[includeIf "hasconfig:remote.*.url:https://github.com/opensource/**"]\n'
  446. b" path = personal.config\n"
  447. )
  448. # Load config - should match the work config due to org-work remote
  449. # The second condition won't match since url doesn't have /opensource/ path
  450. cf = ConfigFile.from_path(main_path)
  451. self.assertEqual(b"work@company.com", cf.get((b"user",), b"email"))
  452. def test_includeif_hasconfig_wildcard(self) -> None:
  453. """Test includeIf hasconfig with wildcard patterns."""
  454. with tempfile.TemporaryDirectory() as tmpdir:
  455. # Create included config
  456. included_path = os.path.join(tmpdir, "included.config")
  457. with open(included_path, "wb") as f:
  458. f.write(b"[user]\n name = IncludedUser\n")
  459. # Create main config with hasconfig condition using wildcards
  460. main_path = os.path.join(tmpdir, "main.config")
  461. with open(main_path, "wb") as f:
  462. f.write(
  463. b"[core]\n"
  464. b" autocrlf = true\n"
  465. b'[includeIf "hasconfig:core.autocrlf:true"]\n'
  466. b" path = included.config\n"
  467. )
  468. # Load config - should include based on core.autocrlf value
  469. cf = ConfigFile.from_path(main_path)
  470. self.assertEqual(b"IncludedUser", cf.get((b"user",), b"name"))
  471. def test_includeif_hasconfig_no_match(self) -> None:
  472. """Test includeIf hasconfig when condition doesn't match."""
  473. with tempfile.TemporaryDirectory() as tmpdir:
  474. # Create included config
  475. included_path = os.path.join(tmpdir, "included.config")
  476. with open(included_path, "wb") as f:
  477. f.write(b"[user]\n name = IncludedUser\n")
  478. # Create main config with non-matching hasconfig condition
  479. main_path = os.path.join(tmpdir, "main.config")
  480. with open(main_path, "wb") as f:
  481. f.write(
  482. b"[core]\n"
  483. b" autocrlf = false\n"
  484. b'[includeIf "hasconfig:core.autocrlf:true"]\n'
  485. b" path = included.config\n"
  486. )
  487. # Load config - should NOT include since condition doesn't match
  488. cf = ConfigFile.from_path(main_path)
  489. with self.assertRaises(KeyError):
  490. cf.get((b"user",), b"name")
  491. def test_includeif_gitdir_relative(self) -> None:
  492. """Test includeIf with relative gitdir patterns."""
  493. with tempfile.TemporaryDirectory() as tmpdir:
  494. # Create a directory structure
  495. config_dir = os.path.join(tmpdir, "config")
  496. repo_dir = os.path.join(tmpdir, "repo")
  497. os.makedirs(config_dir)
  498. os.makedirs(repo_dir)
  499. # Create included config
  500. included_path = os.path.join(config_dir, "work.config")
  501. with open(included_path, "wb") as f:
  502. f.write(b"[user]\n email = relative@example.com\n")
  503. # Create main config with relative gitdir pattern
  504. main_path = os.path.join(config_dir, "main.config")
  505. with open(main_path, "wb") as f:
  506. # Pattern ./../repo/** should match when config is in config/ and repo is in repo/
  507. f.write(b'[includeIf "gitdir:./../repo/**"]\n path = work.config\n')
  508. # Load config with repo_dir that matches the relative pattern
  509. cf = ConfigFile.from_path(main_path, repo_dir=repo_dir)
  510. self.assertEqual(b"relative@example.com", cf.get((b"user",), b"email"))
  511. def test_includeif_onbranch(self) -> None:
  512. """Test includeIf with onbranch conditions."""
  513. with tempfile.TemporaryDirectory() as tmpdir:
  514. # Create a mock git repository
  515. repo_dir = os.path.join(tmpdir, "repo")
  516. git_dir = os.path.join(repo_dir, ".git")
  517. os.makedirs(git_dir)
  518. # Create HEAD file pointing to main branch
  519. head_path = os.path.join(git_dir, "HEAD")
  520. with open(head_path, "wb") as f:
  521. f.write(b"ref: refs/heads/main\n")
  522. # Create included configs for different branches
  523. main_config_path = os.path.join(tmpdir, "main.config")
  524. with open(main_config_path, "wb") as f:
  525. f.write(b"[user]\n email = main@example.com\n")
  526. feature_config_path = os.path.join(tmpdir, "feature.config")
  527. with open(feature_config_path, "wb") as f:
  528. f.write(b"[user]\n email = feature@example.com\n")
  529. # Create main config with onbranch conditions
  530. config_path = os.path.join(tmpdir, "config")
  531. with open(config_path, "wb") as f:
  532. f.write(
  533. b'[includeIf "onbranch:main"]\n'
  534. b" path = main.config\n"
  535. b'[includeIf "onbranch:feature/*"]\n'
  536. b" path = feature.config\n"
  537. )
  538. # Load config - should match main branch
  539. cf = ConfigFile.from_path(config_path, repo_dir=repo_dir)
  540. self.assertEqual(b"main@example.com", cf.get((b"user",), b"email"))
  541. # Change branch to feature/test
  542. with open(head_path, "wb") as f:
  543. f.write(b"ref: refs/heads/feature/test\n")
  544. # Reload config - should match feature branch pattern
  545. cf = ConfigFile.from_path(config_path, repo_dir=repo_dir)
  546. self.assertEqual(b"feature@example.com", cf.get((b"user",), b"email"))
  547. def test_includeif_onbranch_gitdir(self) -> None:
  548. """Test includeIf onbranch when repo_dir points to .git directory."""
  549. with tempfile.TemporaryDirectory() as tmpdir:
  550. # Create a mock git repository
  551. git_dir = os.path.join(tmpdir, ".git")
  552. os.makedirs(git_dir)
  553. # Create HEAD file
  554. head_path = os.path.join(git_dir, "HEAD")
  555. with open(head_path, "wb") as f:
  556. f.write(b"ref: refs/heads/develop\n")
  557. # Create included config
  558. included_path = os.path.join(tmpdir, "develop.config")
  559. with open(included_path, "wb") as f:
  560. f.write(b"[core]\n autocrlf = false\n")
  561. # Create main config
  562. config_path = os.path.join(tmpdir, "config")
  563. with open(config_path, "wb") as f:
  564. f.write(b'[includeIf "onbranch:develop"]\n path = develop.config\n')
  565. # Load config with repo_dir pointing to .git
  566. cf = ConfigFile.from_path(config_path, repo_dir=git_dir)
  567. self.assertEqual(b"false", cf.get((b"core",), b"autocrlf"))
  568. def test_include_circular(self) -> None:
  569. """Test that circular includes are handled properly."""
  570. with tempfile.TemporaryDirectory() as tmpdir:
  571. # Create two configs that include each other
  572. config1_path = os.path.join(tmpdir, "config1")
  573. config2_path = os.path.join(tmpdir, "config2")
  574. with open(config1_path, "wb") as f:
  575. f.write(b"[user]\n name = User1\n[include]\n path = config2\n")
  576. with open(config2_path, "wb") as f:
  577. f.write(
  578. b"[user]\n email = user2@example.com\n[include]\n path = config1\n"
  579. )
  580. # Should handle circular includes gracefully
  581. cf = ConfigFile.from_path(config1_path)
  582. self.assertEqual(b"User1", cf.get((b"user",), b"name"))
  583. self.assertEqual(b"user2@example.com", cf.get((b"user",), b"email"))
  584. def test_include_missing_file(self) -> None:
  585. """Test that missing include files are ignored."""
  586. with tempfile.TemporaryDirectory() as tmpdir:
  587. # Create config with include of non-existent file
  588. config_path = os.path.join(tmpdir, "config")
  589. with open(config_path, "wb") as f:
  590. f.write(
  591. b"[user]\n name = TestUser\n[include]\n path = missing.config\n"
  592. )
  593. # Should not fail, just ignore missing include
  594. cf = ConfigFile.from_path(config_path)
  595. self.assertEqual(b"TestUser", cf.get((b"user",), b"name"))
  596. def test_include_depth_limit(self) -> None:
  597. """Test that excessive include depth is prevented."""
  598. with tempfile.TemporaryDirectory() as tmpdir:
  599. # Create a chain of includes that exceeds depth limit
  600. for i in range(15):
  601. config_path = os.path.join(tmpdir, f"config{i}")
  602. with open(config_path, "wb") as f:
  603. if i == 0:
  604. f.write(b"[user]\n name = User0\n")
  605. f.write(f"[include]\n path = config{i + 1}\n".encode())
  606. # Should raise error due to depth limit
  607. with self.assertRaises(ValueError) as cm:
  608. ConfigFile.from_path(os.path.join(tmpdir, "config0"))
  609. self.assertIn("include depth", str(cm.exception))
  610. def test_include_with_custom_file_opener(self) -> None:
  611. """Test include functionality with a custom file opener for security."""
  612. with tempfile.TemporaryDirectory() as tmpdir:
  613. # Create config files
  614. included_path = os.path.join(tmpdir, "included.config")
  615. with open(included_path, "wb") as f:
  616. f.write(b"[user]\n email = custom@example.com\n")
  617. restricted_path = os.path.join(tmpdir, "restricted.config")
  618. with open(restricted_path, "wb") as f:
  619. f.write(b"[user]\n email = restricted@example.com\n")
  620. main_path = os.path.join(tmpdir, "main.config")
  621. with open(main_path, "wb") as f:
  622. f.write(b"[user]\n name = Test User\n")
  623. f.write(b"[include]\n path = included.config\n")
  624. f.write(b"[include]\n path = restricted.config\n")
  625. # Define a custom file opener that restricts access
  626. allowed_files = {included_path, main_path}
  627. def secure_file_opener(path):
  628. path_str = os.fspath(path)
  629. if path_str not in allowed_files:
  630. raise PermissionError(f"Access denied to {path}")
  631. return open(path_str, "rb")
  632. # Load config with restricted file access
  633. cf = ConfigFile.from_path(main_path, file_opener=secure_file_opener)
  634. # Should have the main config and included config, but not restricted
  635. self.assertEqual(b"Test User", cf.get((b"user",), b"name"))
  636. self.assertEqual(b"custom@example.com", cf.get((b"user",), b"email"))
  637. # Email from restricted.config should not be loaded
  638. def test_unknown_includeif_condition(self) -> None:
  639. """Test that unknown includeIf conditions are silently ignored (like Git)."""
  640. with tempfile.TemporaryDirectory() as tmpdir:
  641. # Create included config file
  642. included_path = os.path.join(tmpdir, "included.config")
  643. with open(included_path, "wb") as f:
  644. f.write(b"[user]\n email = included@example.com\n")
  645. # Create main config with unknown includeIf condition
  646. main_path = os.path.join(tmpdir, "main.config")
  647. with open(main_path, "wb") as f:
  648. f.write(b"[user]\n name = Main User\n")
  649. f.write(
  650. b'[includeIf "unknowncondition:foo"]\n path = included.config\n'
  651. )
  652. # Should not fail, just ignore the unknown condition
  653. cf = ConfigFile.from_path(main_path)
  654. self.assertEqual(b"Main User", cf.get((b"user",), b"name"))
  655. # Email should not be included because condition is unknown
  656. with self.assertRaises(KeyError):
  657. cf.get((b"user",), b"email")
  658. def test_missing_include_file_logging(self) -> None:
  659. """Test that missing include files are logged but don't cause failure."""
  660. import logging
  661. from io import StringIO
  662. # Set up logging capture
  663. log_capture = StringIO()
  664. handler = logging.StreamHandler(log_capture)
  665. handler.setLevel(logging.DEBUG)
  666. logger = logging.getLogger("dulwich.config")
  667. logger.addHandler(handler)
  668. logger.setLevel(logging.DEBUG)
  669. try:
  670. with tempfile.TemporaryDirectory() as tmpdir:
  671. config_path = os.path.join(tmpdir, "test.config")
  672. with open(config_path, "wb") as f:
  673. f.write(b"[user]\n name = Test User\n")
  674. f.write(b"[include]\n path = nonexistent.config\n")
  675. # Should not fail, just log
  676. cf = ConfigFile.from_path(config_path)
  677. self.assertEqual(b"Test User", cf.get((b"user",), b"name"))
  678. # Check that it was logged
  679. log_output = log_capture.getvalue()
  680. self.assertIn("Invalid include path", log_output)
  681. self.assertIn("nonexistent.config", log_output)
  682. finally:
  683. logger.removeHandler(handler)
  684. def test_invalid_include_path_logging(self) -> None:
  685. """Test that invalid include paths are logged but don't cause failure."""
  686. import logging
  687. from io import StringIO
  688. # Set up logging capture
  689. log_capture = StringIO()
  690. handler = logging.StreamHandler(log_capture)
  691. handler.setLevel(logging.DEBUG)
  692. logger = logging.getLogger("dulwich.config")
  693. logger.addHandler(handler)
  694. logger.setLevel(logging.DEBUG)
  695. try:
  696. with tempfile.TemporaryDirectory() as tmpdir:
  697. config_path = os.path.join(tmpdir, "test.config")
  698. with open(config_path, "wb") as f:
  699. f.write(b"[user]\n name = Test User\n")
  700. # Use null bytes which are invalid in paths
  701. f.write(b"[include]\n path = /invalid\x00path/file.config\n")
  702. # Should not fail, just log
  703. cf = ConfigFile.from_path(config_path)
  704. self.assertEqual(b"Test User", cf.get((b"user",), b"name"))
  705. # Check that it was logged
  706. log_output = log_capture.getvalue()
  707. self.assertIn("Invalid include path", log_output)
  708. finally:
  709. logger.removeHandler(handler)
  710. def test_unknown_includeif_condition_logging(self) -> None:
  711. """Test that unknown includeIf conditions are logged."""
  712. import logging
  713. from io import StringIO
  714. # Set up logging capture
  715. log_capture = StringIO()
  716. handler = logging.StreamHandler(log_capture)
  717. handler.setLevel(logging.DEBUG)
  718. logger = logging.getLogger("dulwich.config")
  719. logger.addHandler(handler)
  720. logger.setLevel(logging.DEBUG)
  721. try:
  722. with tempfile.TemporaryDirectory() as tmpdir:
  723. config_path = os.path.join(tmpdir, "test.config")
  724. with open(config_path, "wb") as f:
  725. f.write(b"[user]\n name = Test User\n")
  726. f.write(
  727. b'[includeIf "futurefeature:value"]\n path = other.config\n'
  728. )
  729. # Should not fail, just log
  730. cf = ConfigFile.from_path(config_path)
  731. self.assertEqual(b"Test User", cf.get((b"user",), b"name"))
  732. # Check that it was logged
  733. log_output = log_capture.getvalue()
  734. self.assertIn("Unknown includeIf condition", log_output)
  735. self.assertIn("futurefeature:value", log_output)
  736. finally:
  737. logger.removeHandler(handler)
  738. def test_includeif_with_custom_file_opener(self) -> None:
  739. """Test includeIf functionality with custom file opener."""
  740. with tempfile.TemporaryDirectory() as tmpdir:
  741. # Use realpath to resolve any symlinks
  742. tmpdir = os.path.realpath(tmpdir)
  743. repo_dir = os.path.join(tmpdir, "work", "project", ".git")
  744. os.makedirs(repo_dir, exist_ok=True)
  745. # Create config files
  746. work_config_path = os.path.join(tmpdir, "work.config")
  747. with open(work_config_path, "wb") as f:
  748. f.write(b"[user]\n email = work@company.com\n")
  749. personal_config_path = os.path.join(tmpdir, "personal.config")
  750. with open(personal_config_path, "wb") as f:
  751. f.write(b"[user]\n email = personal@home.com\n")
  752. main_path = os.path.join(tmpdir, "main.config")
  753. with open(main_path, "wb") as f:
  754. f.write(b"[user]\n name = Test User\n")
  755. f.write(b'[includeIf "gitdir:**/work/**"]\n')
  756. escaped_work_path = work_config_path.replace("\\", "\\\\")
  757. f.write(f" path = {escaped_work_path}\n".encode())
  758. f.write(b'[includeIf "gitdir:**/personal/**"]\n')
  759. escaped_personal_path = personal_config_path.replace("\\", "\\\\")
  760. f.write(f" path = {escaped_personal_path}\n".encode())
  761. # Track which files were opened
  762. opened_files = []
  763. def tracking_file_opener(path):
  764. path_str = os.fspath(path)
  765. opened_files.append(path_str)
  766. return open(path_str, "rb")
  767. # Load config with tracking file opener
  768. cf = ConfigFile.from_path(
  769. main_path, repo_dir=repo_dir, file_opener=tracking_file_opener
  770. )
  771. # Check results
  772. self.assertEqual(b"Test User", cf.get((b"user",), b"name"))
  773. self.assertEqual(b"work@company.com", cf.get((b"user",), b"email"))
  774. # Verify that only the matching includeIf file was opened
  775. self.assertIn(main_path, opened_files)
  776. self.assertIn(work_config_path, opened_files)
  777. self.assertNotIn(personal_config_path, opened_files)
  778. def test_custom_file_opener_with_include_depth(self) -> None:
  779. """Test that custom file opener is passed through include chain."""
  780. with tempfile.TemporaryDirectory() as tmpdir:
  781. # Use realpath to resolve any symlinks
  782. tmpdir = os.path.realpath(tmpdir)
  783. # Create a chain of includes
  784. final_config = os.path.join(tmpdir, "final.config")
  785. with open(final_config, "wb") as f:
  786. f.write(b"[feature]\n enabled = true\n")
  787. middle_config = os.path.join(tmpdir, "middle.config")
  788. with open(middle_config, "wb") as f:
  789. f.write(b"[user]\n email = test@example.com\n")
  790. escaped_final_config = final_config.replace("\\", "\\\\")
  791. f.write(f"[include]\n path = {escaped_final_config}\n".encode())
  792. main_config = os.path.join(tmpdir, "main.config")
  793. with open(main_config, "wb") as f:
  794. f.write(b"[user]\n name = Test User\n")
  795. escaped_middle_config = middle_config.replace("\\", "\\\\")
  796. f.write(f"[include]\n path = {escaped_middle_config}\n".encode())
  797. # Track file access order
  798. access_order = []
  799. def ordering_file_opener(path):
  800. path_str = os.fspath(path)
  801. access_order.append(os.path.basename(path_str))
  802. return open(path_str, "rb")
  803. # Load config
  804. cf = ConfigFile.from_path(main_config, file_opener=ordering_file_opener)
  805. # Verify all values were loaded
  806. self.assertEqual(b"Test User", cf.get((b"user",), b"name"))
  807. self.assertEqual(b"test@example.com", cf.get((b"user",), b"email"))
  808. self.assertEqual(b"true", cf.get((b"feature",), b"enabled"))
  809. # Verify access order
  810. self.assertEqual(
  811. ["main.config", "middle.config", "final.config"], access_order
  812. )
  813. class ConfigDictTests(TestCase):
  814. def test_get_set(self) -> None:
  815. cd = ConfigDict()
  816. self.assertRaises(KeyError, cd.get, b"foo", b"core")
  817. cd.set((b"core",), b"foo", b"bla")
  818. self.assertEqual(b"bla", cd.get((b"core",), b"foo"))
  819. cd.set((b"core",), b"foo", b"bloe")
  820. self.assertEqual(b"bloe", cd.get((b"core",), b"foo"))
  821. def test_get_boolean(self) -> None:
  822. cd = ConfigDict()
  823. cd.set((b"core",), b"foo", b"true")
  824. self.assertTrue(cd.get_boolean((b"core",), b"foo"))
  825. cd.set((b"core",), b"foo", b"false")
  826. self.assertFalse(cd.get_boolean((b"core",), b"foo"))
  827. cd.set((b"core",), b"foo", b"invalid")
  828. self.assertRaises(ValueError, cd.get_boolean, (b"core",), b"foo")
  829. def test_dict(self) -> None:
  830. cd = ConfigDict()
  831. cd.set((b"core",), b"foo", b"bla")
  832. cd.set((b"core2",), b"foo", b"bloe")
  833. self.assertEqual([(b"core",), (b"core2",)], list(cd.keys()))
  834. self.assertEqual(cd[(b"core",)], {b"foo": b"bla"})
  835. cd[b"a"] = b"b"
  836. self.assertEqual(cd[b"a"], b"b")
  837. def test_items(self) -> None:
  838. cd = ConfigDict()
  839. cd.set((b"core",), b"foo", b"bla")
  840. cd.set((b"core2",), b"foo", b"bloe")
  841. self.assertEqual([(b"foo", b"bla")], list(cd.items((b"core",))))
  842. def test_items_nonexistant(self) -> None:
  843. cd = ConfigDict()
  844. cd.set((b"core2",), b"foo", b"bloe")
  845. self.assertEqual([], list(cd.items((b"core",))))
  846. def test_sections(self) -> None:
  847. cd = ConfigDict()
  848. cd.set((b"core2",), b"foo", b"bloe")
  849. self.assertEqual([(b"core2",)], list(cd.sections()))
  850. def test_set_vs_add(self) -> None:
  851. cd = ConfigDict()
  852. # Test add() creates multivars
  853. cd.add((b"core",), b"foo", b"value1")
  854. cd.add((b"core",), b"foo", b"value2")
  855. self.assertEqual(
  856. [b"value1", b"value2"], list(cd.get_multivar((b"core",), b"foo"))
  857. )
  858. # Test set() replaces values
  859. cd.set((b"core",), b"foo", b"value3")
  860. self.assertEqual([b"value3"], list(cd.get_multivar((b"core",), b"foo")))
  861. self.assertEqual(b"value3", cd.get((b"core",), b"foo"))
  862. class StackedConfigTests(TestCase):
  863. def test_default_backends(self) -> None:
  864. StackedConfig.default_backends()
  865. @skipIf(sys.platform != "win32", "Windows specific config location.")
  866. def test_windows_config_from_path(self) -> None:
  867. from dulwich.config import get_win_system_paths
  868. install_dir = os.path.join("C:", "foo", "Git")
  869. self.overrideEnv("PATH", os.path.join(install_dir, "cmd"))
  870. with patch("os.path.exists", return_value=True):
  871. paths = set(get_win_system_paths())
  872. self.assertEqual(
  873. {
  874. os.path.join(os.environ.get("PROGRAMDATA"), "Git", "config"),
  875. os.path.join(install_dir, "etc", "gitconfig"),
  876. },
  877. paths,
  878. )
  879. @skipIf(sys.platform != "win32", "Windows specific config location.")
  880. def test_windows_config_from_reg(self) -> None:
  881. import winreg
  882. from dulwich.config import get_win_system_paths
  883. self.overrideEnv("PATH", None)
  884. install_dir = os.path.join("C:", "foo", "Git")
  885. with patch("winreg.OpenKey"):
  886. with patch(
  887. "winreg.QueryValueEx",
  888. return_value=(install_dir, winreg.REG_SZ),
  889. ):
  890. paths = set(get_win_system_paths())
  891. self.assertEqual(
  892. {
  893. os.path.join(os.environ.get("PROGRAMDATA"), "Git", "config"),
  894. os.path.join(install_dir, "etc", "gitconfig"),
  895. },
  896. paths,
  897. )
  898. class EscapeValueTests(TestCase):
  899. def test_nothing(self) -> None:
  900. self.assertEqual(b"foo", _escape_value(b"foo"))
  901. def test_backslash(self) -> None:
  902. self.assertEqual(b"foo\\\\", _escape_value(b"foo\\"))
  903. def test_newline(self) -> None:
  904. self.assertEqual(b"foo\\n", _escape_value(b"foo\n"))
  905. class FormatStringTests(TestCase):
  906. def test_quoted(self) -> None:
  907. self.assertEqual(b'" foo"', _format_string(b" foo"))
  908. self.assertEqual(b'"\\tfoo"', _format_string(b"\tfoo"))
  909. def test_not_quoted(self) -> None:
  910. self.assertEqual(b"foo", _format_string(b"foo"))
  911. self.assertEqual(b"foo bar", _format_string(b"foo bar"))
  912. class ParseStringTests(TestCase):
  913. def test_quoted(self) -> None:
  914. self.assertEqual(b" foo", _parse_string(b'" foo"'))
  915. self.assertEqual(b"\tfoo", _parse_string(b'"\\tfoo"'))
  916. def test_not_quoted(self) -> None:
  917. self.assertEqual(b"foo", _parse_string(b"foo"))
  918. self.assertEqual(b"foo bar", _parse_string(b"foo bar"))
  919. def test_nothing(self) -> None:
  920. self.assertEqual(b"", _parse_string(b""))
  921. def test_tab(self) -> None:
  922. self.assertEqual(b"\tbar\t", _parse_string(b"\\tbar\\t"))
  923. def test_newline(self) -> None:
  924. self.assertEqual(b"\nbar\t", _parse_string(b"\\nbar\\t\t"))
  925. def test_quote(self) -> None:
  926. self.assertEqual(b'"foo"', _parse_string(b'\\"foo\\"'))
  927. class CheckVariableNameTests(TestCase):
  928. def test_invalid(self) -> None:
  929. self.assertFalse(_check_variable_name(b"foo "))
  930. self.assertFalse(_check_variable_name(b"bar,bar"))
  931. self.assertFalse(_check_variable_name(b"bar.bar"))
  932. def test_valid(self) -> None:
  933. self.assertTrue(_check_variable_name(b"FOO"))
  934. self.assertTrue(_check_variable_name(b"foo"))
  935. self.assertTrue(_check_variable_name(b"foo-bar"))
  936. class CheckSectionNameTests(TestCase):
  937. def test_invalid(self) -> None:
  938. self.assertFalse(_check_section_name(b"foo "))
  939. self.assertFalse(_check_section_name(b"bar,bar"))
  940. def test_valid(self) -> None:
  941. self.assertTrue(_check_section_name(b"FOO"))
  942. self.assertTrue(_check_section_name(b"foo"))
  943. self.assertTrue(_check_section_name(b"foo-bar"))
  944. self.assertTrue(_check_section_name(b"bar.bar"))
  945. class SubmodulesTests(TestCase):
  946. def testSubmodules(self) -> None:
  947. cf = ConfigFile.from_file(
  948. BytesIO(
  949. b"""\
  950. [submodule "core/lib"]
  951. \tpath = core/lib
  952. \turl = https://github.com/phhusson/QuasselC.git
  953. """
  954. )
  955. )
  956. got = list(parse_submodules(cf))
  957. self.assertEqual(
  958. [
  959. (
  960. b"core/lib",
  961. b"https://github.com/phhusson/QuasselC.git",
  962. b"core/lib",
  963. )
  964. ],
  965. got,
  966. )
  967. def testMalformedSubmodules(self) -> None:
  968. cf = ConfigFile.from_file(
  969. BytesIO(
  970. b"""\
  971. [submodule "core/lib"]
  972. \tpath = core/lib
  973. \turl = https://github.com/phhusson/QuasselC.git
  974. [submodule "dulwich"]
  975. \turl = https://github.com/jelmer/dulwich
  976. """
  977. )
  978. )
  979. got = list(parse_submodules(cf))
  980. self.assertEqual(
  981. [
  982. (
  983. b"core/lib",
  984. b"https://github.com/phhusson/QuasselC.git",
  985. b"core/lib",
  986. )
  987. ],
  988. got,
  989. )
  990. class ApplyInsteadOfTests(TestCase):
  991. def test_none(self) -> None:
  992. config = ConfigDict()
  993. self.assertEqual(
  994. "https://example.com/", apply_instead_of(config, "https://example.com/")
  995. )
  996. def test_apply(self) -> None:
  997. config = ConfigDict()
  998. config.set(("url", "https://samba.org/"), "insteadOf", "https://example.com/")
  999. self.assertEqual(
  1000. "https://samba.org/", apply_instead_of(config, "https://example.com/")
  1001. )
  1002. def test_apply_multiple(self) -> None:
  1003. config = ConfigDict()
  1004. config.add(("url", "https://samba.org/"), "insteadOf", "https://blah.com/")
  1005. config.add(("url", "https://samba.org/"), "insteadOf", "https://example.com/")
  1006. self.assertEqual(
  1007. [b"https://blah.com/", b"https://example.com/"],
  1008. list(config.get_multivar(("url", "https://samba.org/"), "insteadOf")),
  1009. )
  1010. self.assertEqual(
  1011. "https://samba.org/", apply_instead_of(config, "https://example.com/")
  1012. )
  1013. def test_apply_preserves_case_in_subsection(self) -> None:
  1014. """Test that mixed-case URLs (like those with access tokens) are preserved."""
  1015. config = ConfigDict()
  1016. # GitHub access tokens have mixed case that must be preserved
  1017. url_with_token = "https://ghp_AbCdEfGhIjKlMnOpQrStUvWxYz1234567890@github.com/"
  1018. config.set(("url", url_with_token), "insteadOf", "https://github.com/")
  1019. # Apply the substitution
  1020. result = apply_instead_of(config, "https://github.com/jelmer/dulwich.git")
  1021. expected = "https://ghp_AbCdEfGhIjKlMnOpQrStUvWxYz1234567890@github.com/jelmer/dulwich.git"
  1022. self.assertEqual(expected, result)
  1023. # Verify the token case is preserved
  1024. self.assertIn("ghp_AbCdEfGhIjKlMnOpQrStUvWxYz1234567890", result)
  1025. class CaseInsensitiveConfigTests(TestCase):
  1026. def test_case_insensitive(self) -> None:
  1027. config = CaseInsensitiveOrderedMultiDict()
  1028. config[("core",)] = "value"
  1029. self.assertEqual("value", config[("CORE",)])
  1030. self.assertEqual("value", config[("CoRe",)])
  1031. self.assertEqual([("core",)], list(config.keys()))
  1032. def test_multiple_set(self) -> None:
  1033. config = CaseInsensitiveOrderedMultiDict()
  1034. config[("core",)] = "value1"
  1035. config[("core",)] = "value2"
  1036. # The second set overwrites the first one
  1037. self.assertEqual("value2", config[("core",)])
  1038. self.assertEqual("value2", config[("CORE",)])
  1039. def test_get_all(self) -> None:
  1040. config = CaseInsensitiveOrderedMultiDict()
  1041. config[("core",)] = "value1"
  1042. config[("CORE",)] = "value2"
  1043. config[("CoRe",)] = "value3"
  1044. self.assertEqual(
  1045. ["value1", "value2", "value3"], list(config.get_all(("core",)))
  1046. )
  1047. self.assertEqual(
  1048. ["value1", "value2", "value3"], list(config.get_all(("CORE",)))
  1049. )
  1050. def test_delitem(self) -> None:
  1051. config = CaseInsensitiveOrderedMultiDict()
  1052. config[("core",)] = "value1"
  1053. config[("CORE",)] = "value2"
  1054. config[("other",)] = "value3"
  1055. del config[("core",)]
  1056. self.assertNotIn(("core",), config)
  1057. self.assertNotIn(("CORE",), config)
  1058. self.assertEqual("value3", config[("other",)])
  1059. self.assertEqual(1, len(config))
  1060. def test_len(self) -> None:
  1061. config = CaseInsensitiveOrderedMultiDict()
  1062. self.assertEqual(0, len(config))
  1063. config[("core",)] = "value1"
  1064. self.assertEqual(1, len(config))
  1065. config[("CORE",)] = "value2"
  1066. self.assertEqual(1, len(config)) # Same key, case insensitive
  1067. def test_subsection_case_preserved(self) -> None:
  1068. """Test that subsection names preserve their case."""
  1069. config = CaseInsensitiveOrderedMultiDict()
  1070. # Section names should be case-insensitive, but subsection names should preserve case
  1071. config[("url", "https://Example.COM/Path")] = "value1"
  1072. # Can retrieve with different case section name
  1073. self.assertEqual("value1", config[("URL", "https://Example.COM/Path")])
  1074. self.assertEqual("value1", config[("url", "https://Example.COM/Path")])
  1075. # But not with different case subsection name
  1076. with self.assertRaises(KeyError):
  1077. config[("url", "https://example.com/path")]
  1078. # Verify the stored key preserves subsection case
  1079. stored_keys = list(config.keys())
  1080. self.assertEqual(1, len(stored_keys))
  1081. self.assertEqual(("url", "https://Example.COM/Path"), stored_keys[0])
  1082. config[("other",)] = "value3"
  1083. self.assertEqual(2, len(config))
  1084. def test_make_from_dict(self) -> None:
  1085. original = {("core",): "value1", ("other",): "value2"}
  1086. config = CaseInsensitiveOrderedMultiDict.make(original)
  1087. self.assertEqual("value1", config[("core",)])
  1088. self.assertEqual("value1", config[("CORE",)])
  1089. self.assertEqual("value2", config[("other",)])
  1090. def test_make_from_self(self) -> None:
  1091. config1 = CaseInsensitiveOrderedMultiDict()
  1092. config1[("core",)] = "value"
  1093. config2 = CaseInsensitiveOrderedMultiDict.make(config1)
  1094. self.assertIs(config1, config2)
  1095. def test_make_invalid_type(self) -> None:
  1096. self.assertRaises(TypeError, CaseInsensitiveOrderedMultiDict.make, "invalid")
  1097. def test_get_with_default(self) -> None:
  1098. config = CaseInsensitiveOrderedMultiDict()
  1099. config[("core",)] = "value"
  1100. self.assertEqual("value", config.get(("core",)))
  1101. self.assertEqual("value", config.get(("CORE",)))
  1102. self.assertEqual("default", config.get(("missing",), "default"))
  1103. # Test SENTINEL behavior
  1104. result = config.get(("missing",))
  1105. self.assertIsInstance(result, CaseInsensitiveOrderedMultiDict)
  1106. self.assertEqual(0, len(result))
  1107. def test_setdefault(self) -> None:
  1108. config = CaseInsensitiveOrderedMultiDict()
  1109. # Set new value
  1110. result1 = config.setdefault(("core",), "value1")
  1111. self.assertEqual("value1", result1)
  1112. self.assertEqual("value1", config[("core",)])
  1113. # Try to set again with different case - should return existing
  1114. result2 = config.setdefault(("CORE",), "value2")
  1115. self.assertEqual("value1", result2)
  1116. self.assertEqual("value1", config[("core",)])
  1117. def test_values(self) -> None:
  1118. config = CaseInsensitiveOrderedMultiDict()
  1119. config[("core",)] = "value1"
  1120. config[("other",)] = "value2"
  1121. config[("CORE",)] = "value3" # Overwrites previous core value
  1122. self.assertEqual({"value3", "value2"}, set(config.values()))
  1123. def test_items_iteration(self) -> None:
  1124. config = CaseInsensitiveOrderedMultiDict()
  1125. config[("core",)] = "value1"
  1126. config[("other",)] = "value2"
  1127. config[("CORE",)] = "value3"
  1128. items = list(config.items())
  1129. self.assertEqual(3, len(items))
  1130. self.assertEqual((("core",), "value1"), items[0])
  1131. self.assertEqual((("other",), "value2"), items[1])
  1132. self.assertEqual((("CORE",), "value3"), items[2])
  1133. def test_str_keys(self) -> None:
  1134. config = CaseInsensitiveOrderedMultiDict()
  1135. config["core"] = "value"
  1136. self.assertEqual("value", config["CORE"])
  1137. self.assertEqual("value", config["CoRe"])
  1138. def test_nested_tuple_keys(self) -> None:
  1139. config = CaseInsensitiveOrderedMultiDict()
  1140. config[("branch", "master")] = "value"
  1141. # Section names are case-insensitive
  1142. self.assertEqual("value", config[("BRANCH", "master")])
  1143. self.assertEqual("value", config[("Branch", "master")])
  1144. # But subsection names are case-sensitive
  1145. with self.assertRaises(KeyError):
  1146. config[("branch", "MASTER")]
  1147. class ConfigFileSetTests(TestCase):
  1148. def test_set_replaces_value(self) -> None:
  1149. # Test that set() replaces the value instead of appending
  1150. cf = ConfigFile()
  1151. cf.set((b"core",), b"sshCommand", b"ssh -i ~/.ssh/id_rsa1")
  1152. cf.set((b"core",), b"sshCommand", b"ssh -i ~/.ssh/id_rsa2")
  1153. # Should only have one value
  1154. self.assertEqual(b"ssh -i ~/.ssh/id_rsa2", cf.get((b"core",), b"sshCommand"))
  1155. # When written to file, should only have one entry
  1156. f = BytesIO()
  1157. cf.write_to_file(f)
  1158. content = f.getvalue()
  1159. self.assertEqual(1, content.count(b"sshCommand"))
  1160. self.assertIn(b"sshCommand = ssh -i ~/.ssh/id_rsa2", content)
  1161. self.assertNotIn(b"id_rsa1", content)