#!/usr/bin/env python3 # Copyright (c) Facebook, Inc. and its affiliates. # # This software may be used and distributed according to the terms of the # GNU General Public License version 2. import collections import pathlib import sys import typing import unittest from eden.fs.cli.systemd import ( SystemdEnvironmentFile, escape_dbus_address, systemd_escape_path, ) @unittest.skipIf(sys.platform != "linux", "systemd requires Linux") class SystemdEscapeTest(unittest.TestCase): def test_escape_benign_absolute_path(self) -> None: self.assertEqual( systemd_escape_path(pathlib.PurePosixPath("/path/to/file.txt")), "path-to-file.txt", ) def test_escape_path_containing_funky_characters(self) -> None: self.assertEqual( systemd_escape_path(pathlib.PurePosixPath("/file with spaces")), r"file\x20with\x20spaces", ) self.assertEqual( systemd_escape_path(pathlib.PurePosixPath(r"/file\with\backslashes")), r"file\x5cwith\x5cbackslashes", ) self.assertEqual( systemd_escape_path(pathlib.PurePosixPath(r"/Hallöchen, Meister")), r"Hall\xc3\xb6chen\x2c\x20Meister", ) def test_escape_path_containing_newlines(self) -> None: self.assertEqual( systemd_escape_path(pathlib.PurePosixPath("/file\nwith\nnewlines")), r"file\x0awith\x0anewlines", ) self.assertEqual( systemd_escape_path(pathlib.PurePosixPath("/trailing\n")), r"trailing\x0a" ) self.assertEqual(systemd_escape_path(pathlib.PurePosixPath("/\n")), r"\x0a") def test_escaping_path_ignores_trailing_slashes(self) -> None: self.assertEqual( systemd_escape_path(pathlib.PurePosixPath("/path/to/directory///")), "path-to-directory", ) def test_escape_relative_path_raises(self) -> None: path = pathlib.PurePosixPath("path/to/file.txt") with self.assertRaises(ValueError): systemd_escape_path(path) def test_escape_path_with_dotdot_components_raises(self) -> None: path = pathlib.PurePosixPath("/path/to/../file.txt") with self.assertRaises(ValueError): systemd_escape_path(path) class SystemdEnvironmentFileDumpTest(unittest.TestCase): # TODO(strager): Reject variables whose values are not valid UTF-8. def test_file_with_no_variables_is_empty(self) -> None: self.assertEqual(self.dumps({}), b"") def test_load_dumped_single_simple_variable(self) -> None: self.assertEqual( self.dump_and_load({b"my_variable": b"my_value"}).entries, [(b"my_variable", b"my_value")], ) self.assertEqual( self.dump_and_load({b"MESSAGE": b"hello"}).entries, [(b"MESSAGE", b"hello")] ) def test_load_dumped_multiple_simple_variables(self) -> None: variables: typing.MutableMapping[bytes, bytes] = collections.OrderedDict() variables[b"var1"] = b"val1" variables[b"var2"] = b"val2" variables[b"var3"] = b"val3" self.assertEqual( self.dump_and_load(variables).entries, [(b"var1", b"val1"), (b"var2", b"val2"), (b"var3", b"val3")], ) def test_empty_names_are_disallowed(self) -> None: variables = {b"": b"value"} with self.assertRaisesRegex(ValueError, "Variables must have a non-empty name"): self.dumps(variables) def test_leading_digit_is_disallowed_in_names(self) -> None: variables = {b"1up": b"value"} with self.assertRaisesRegex( ValueError, "Variable names must not begin with a digit" ): self.dumps(variables) def test_whitespace_is_disallowed_in_names(self) -> None: for name in [ b" leading_space", b"trailing_space ", b"interior space", b"\tleading_tab", b"trailing_tab\t", ]: with self.subTest(name=name): variables = {name: b"value"} with self.assertRaisesRegex( ValueError, "Variable names must not contain whitespace" ): self.dumps(variables) def test_equal_sign_is_disallowed_in_names(self) -> None: for name in [b"hello=world", b"var=", b"=var"]: with self.subTest(name=name): variables = {name: b"value"} with self.assertRaisesRegex( ValueError, "Variable names must not contain '='" ): self.dumps(variables) def test_backslashes_are_disallowed_in_names(self) -> None: for name in [b"\\name", b"name\\", b"abc\\ def", b"abc\\\\def"]: with self.subTest(name=name): variables = {name: b"value"} with self.assertRaisesRegex( ValueError, "Variable names must not contain '\\\\'" ): self.dumps(variables) def test_newlines_are_disallowed_in_names(self) -> None: variables = {b"hello\nworld": b"value"} with self.assertRaisesRegex( ValueError, "Variable names must not contain any newline characters" ): self.dumps(variables) def test_symbols_are_disallowed_in_names(self) -> None: for symbol_byte in b"`~!@$%^&*()[]+-.,/?'\"\\|": symbol = bytes([symbol_byte]) with self.subTest(symbol=symbol): variables = {b"hello" + symbol + b"world": b"value"} with self.assertRaisesRegex( ValueError, "Variable names must not contain '.'" ): self.dumps(variables) def test_comment_characters_are_disallowed_in_names(self) -> None: for name in [b"#name", b";name", b"name#", b"hello#world"]: with self.subTest(name=name): variables = {name: b"value"} with self.assertRaisesRegex( ValueError, "Variable names must not contain '[#;]'" ): self.dumps(variables) def test_control_characters_are_disallowed_in_names(self) -> None: for control_character_byte in b"\x00\x01\a\x1b": control_character = bytes([control_character_byte]) with self.subTest(control_character=control_character): variables = {b"a" + control_character + b"b": b"value"} with self.assertRaisesRegex( ValueError, "Variable names must not contain any control characters" ): self.dumps(variables) def test_empty_values_are_allowed(self) -> None: self.assert_variable_dumps_and_loads(b"var", b"") def test_whitespace_is_allowed_in_values(self) -> None: self.assert_variable_dumps_and_loads(b"var", b" value with spaces ") self.assert_variable_dumps_and_loads(b"var", b"\tvalue\twith\ttabs\t") def test_backslashes_are_allowed_in_values(self) -> None: self.assert_variable_dumps_and_loads(b"var", b"\\value_with_backslashes\\") self.assert_variable_dumps_and_loads(b"var", b"\\n") self.assert_variable_dumps_and_loads(b"var", b"abc\\def") self.assert_variable_dumps_and_loads(b"var", b"abc\\\\def") def test_quotes_are_allowed_in_values(self) -> None: self.assert_variable_dumps_and_loads(b"var", b"'value_with_single_quotes'") self.assert_variable_dumps_and_loads(b"var", b'"value_with_double_quotes"') self.assert_variable_dumps_and_loads(b"var", b"that's all folks") self.assert_variable_dumps_and_loads(b"var", b'unterminated " double quote') self.assert_variable_dumps_and_loads(b"var", b"\"'\"'") def test_newlines_are_allowed_in_values(self) -> None: self.assert_variable_dumps_and_loads(b"var", b"abc\ndef") def test_carriage_returns_are_disallowed_in_values(self) -> None: variables = {b"name": b"abc\rdef"} with self.assertRaisesRegex( ValueError, "Variable values must not contain carriage returns" ): self.dumps(variables) def test_control_characters_are_disallowed_in_values(self) -> None: for control_character_byte in b"\x00\x01\a\x1b": control_character = bytes([control_character_byte]) with self.subTest(control_character=control_character): variables = {b"name": b"abc" + control_character + b"def"} with self.assertRaisesRegex( ValueError, "Variable values must not contain any control characters", ): self.dumps(variables) def assert_variable_dumps_and_loads(self, name: bytes, value: bytes) -> None: self.assertEqual(self.dump_and_load({name: value}).entries, [(name, value)]) def dump_and_load( self, variables: typing.Mapping[bytes, bytes] ) -> SystemdEnvironmentFile: return self.loads(self.dumps(variables)) def dumps(self, variables: typing.Mapping[bytes, bytes]) -> bytes: return SystemdEnvironmentFile.dumps(variables) def loads(self, content: bytes) -> SystemdEnvironmentFile: return SystemdEnvironmentFile.loads(content) class SystemdEnvironmentFileLoadTest(unittest.TestCase): # TODO(strager): Discard variables whose values are not valid UTF-8. def test_empty_file_has_no_variables(self) -> None: self.assertEqual(self.loads(b"").entries, []) def test_one_simple_variable(self) -> None: self.assertEqual( self.loads(b"my_variable=my_value\n").entries, [(b"my_variable", b"my_value")], ) self.assertEqual( self.loads(b"my_variable=my_value").entries, [(b"my_variable", b"my_value")] ) self.assertEqual(self.loads(b"MESSAGE=hello").entries, [(b"MESSAGE", b"hello")]) def test_value_can_be_empty(self) -> None: self.assertEqual(self.loads(b"my_variable=\n").entries, [(b"my_variable", b"")]) self.assertEqual(self.loads(b"my_variable=").entries, [(b"my_variable", b"")]) self.assertEqual( self.loads(b"my_variable= \t \n").entries, [(b"my_variable", b"")] ) self.assertEqual(self.loads(b"my_variable=''").entries, [(b"my_variable", b"")]) self.assertEqual(self.loads(b'my_variable=""').entries, [(b"my_variable", b"")]) def test_multiple_variables_are_separated_by_newlines_or_carriage_returns( self, ) -> None: self.assertEqual( self.loads(b"var1=value1\nvar2=value2\nvar3=value3\n").entries, [(b"var1", b"value1"), (b"var2", b"value2"), (b"var3", b"value3")], ) self.assertEqual( self.loads(b"var1=value1\rvar2=value2\rvar3=value3\r").entries, [(b"var1", b"value1"), (b"var2", b"value2"), (b"var3", b"value3")], ) def test_empty_lines_are_ignored(self) -> None: self.assertEqual( self.loads(b"\n\nvar=value\n\nvar2=value2\n\n").entries, [(b"var", b"value"), (b"var2", b"value2")], ) def test_blank_lines_are_ignored(self) -> None: self.assertEqual(self.loads(b" \n \n\t\n").entries, []) self.assertEqual(self.loads(b" ").entries, []) def test_backslash_does_not_escape_anything_in_name(self) -> None: self.assertEqual(self.loads(b"a\\\nb=c").entries, [(b"b", b"c")]) self.assertEqual(self.loads(b"\\\nname=value").entries, [(b"name", b"value")]) def test_backslash_escapes_benign_characters_in_value(self) -> None: self.assertEqual(self.loads(br"var=\v\al\u\e").entries, [(b"var", b"value")]) def test_backslash_escapes_quotes_in_value(self) -> None: self.assertEqual(self.loads(br"var=\'value\'").entries, [(b"var", b"'value'")]) self.assertEqual(self.loads(br"var=\"value\"").entries, [(b"var", b'"value"')]) def test_backslash_escapes_newlines_in_value(self) -> None: self.assertEqual(self.loads(b"var=a\\\nb\n").entries, [(b"var", b"ab")]) def test_surrounding_whitespace_in_value_is_preserved_if_escaped(self) -> None: self.assertEqual(self.loads(br"var=\ value\ ").entries, [(b"var", b" value ")]) self.assertEqual( self.loads(br"var=\ value \ ").entries, [(b"var", b" value ")] ) def test_backslash_at_end_of_file_in_value_is_ignored(self) -> None: self.assertEqual(self.loads(b"var=value\\").entries, [(b"var", b"value")]) def test_backslash_at_end_of_file_in_quoted_value_is_ignored(self) -> None: self.assertEqual(self.loads(b'var="value\\').entries, [(b"var", b"value")]) def test_surrounding_whitespace_in_value_is_ignored(self) -> None: self.assertEqual(self.loads(b"var= value\n").entries, [(b"var", b"value")]) self.assertEqual(self.loads(b"var=value \n").entries, [(b"var", b"value")]) self.assertEqual(self.loads(b"var=\tvalue\n").entries, [(b"var", b"value")]) self.assertEqual(self.loads(b"var=value\t\n").entries, [(b"var", b"value")]) def test_surrounding_whitespace_in_quoted_value_is_ignored(self) -> None: self.assertEqual(self.loads(b"var= 'value' \n").entries, [(b"var", b"value")]) self.assertEqual(self.loads(b'var= "value" \n').entries, [(b"var", b"value")]) def test_surrounding_whitespace_in_name_is_ignored(self) -> None: self.assertEqual(self.loads(b" var=value\n").entries, [(b"var", b"value")]) self.assertEqual(self.loads(b"var =value\n").entries, [(b"var", b"value")]) self.assertEqual(self.loads(b"\tvar=value\n").entries, [(b"var", b"value")]) self.assertEqual(self.loads(b"var\t=value\n").entries, [(b"var", b"value")]) def test_values_can_have_interior_whitespace(self) -> None: self.assertEqual( self.loads(b"variable=multi word value").entries, [(b"variable", b"multi word value")], ) self.assertEqual(self.loads(b"var=a\tb").entries, [(b"var", b"a\tb")]) def test_values_can_contain_equal_sign(self) -> None: self.assertEqual( self.loads(b"variable=value=with=equal=signs").entries, [(b"variable", b"value=with=equal=signs")], ) def test_redundant_quotes_in_values_are_dropped(self) -> None: self.assertEqual(self.loads(b"name='value'").entries, [(b"name", b"value")]) self.assertEqual(self.loads(b'name="value"').entries, [(b"name", b"value")]) self.assertEqual(self.loads(b"name='a''b'").entries, [(b"name", b"ab")]) self.assertEqual(self.loads(b'name="a""b"').entries, [(b"name", b"ab")]) self.assertEqual( self.loads(b"name='hello'world").entries, [(b"name", b"helloworld")] ) self.assertEqual( self.loads(b'name="hello"world').entries, [(b"name", b"helloworld")] ) self.assertEqual(self.loads(b"""name="a"'b'""").entries, [(b"name", b"ab")]) self.assertEqual(self.loads(b'''name='a'"b"''').entries, [(b"name", b"ab")]) def test_quotes_in_values_are_included_verbatim_after_unquoted_nonwhitespace_characters( self, ) -> None: self.assertEqual( self.loads(b"name=hello'world'").entries, [(b"name", b"hello'world'")] ) self.assertEqual( self.loads(b"name=hello'world").entries, [(b"name", b"hello'world")] ) self.assertEqual( self.loads(b"name=hello 'world'").entries, [(b"name", b"hello 'world'")] ) self.assertEqual( self.loads(b"name='a b' c d 'e f' g h").entries, [(b"name", b"a bc d 'e f' g h")], ) self.assertEqual( self.loads(b'name="a b" c d "e f" g h').entries, [(b"name", b'a bc d "e f" g h')], ) def test_values_can_have_surrounding_whitespace_within_quotes(self) -> None: self.assertEqual(self.loads(b"name=' value '").entries, [(b"name", b" value ")]) self.assertEqual(self.loads(b'name=" value "').entries, [(b"name", b" value ")]) def test_whitespace_after_quoted_string_in_value_is_ignored(self) -> None: self.assertEqual(self.loads(b"name='' value").entries, [(b"name", b"value")]) self.assertEqual(self.loads(b'name="" value').entries, [(b"name", b"value")]) self.assertEqual(self.loads(b"name='a' value").entries, [(b"name", b"avalue")]) self.assertEqual(self.loads(b'name="a" value').entries, [(b"name", b"avalue")]) self.assertEqual( self.loads(b"name='hello' 'world'").entries, [(b"name", b"helloworld")] ) self.assertEqual( self.loads(b"name='hello' \"world\"").entries, [(b"name", b"helloworld")] ) def test_values_can_have_newlines_within_quotes(self) -> None: self.assertEqual(self.loads(b"name='a\nb'").entries, [(b"name", b"a\nb")]) self.assertEqual(self.loads(b'name="a\nb"').entries, [(b"name", b"a\nb")]) def test_backslash_escapes_benign_characters_in_quoted_value(self) -> None: self.assertEqual(self.loads(b"name='a \\n b'").entries, [(b"name", b"a n b")]) self.assertEqual(self.loads(b'name="a \\n b"').entries, [(b"name", b"a n b")]) def test_backslash_escapes_newline_in_quoted_value(self) -> None: self.assertEqual(self.loads(b"name='a\\\nb'").entries, [(b"name", b"ab")]) self.assertEqual(self.loads(b'name="a\\\nb"').entries, [(b"name", b"ab")]) def test_backslash_escapes_backslash_in_quoted_value(self) -> None: self.assertEqual(self.loads(b"name='a\\\\b'").entries, [(b"name", b"a\\b")]) def test_backslash_escapes_quotes_in_quoted_value(self) -> None: self.assertEqual( self.loads(br"""name=' \' \" '""").entries, [(b"name", b" ' \" ")] ) self.assertEqual( self.loads(br'''name=" \' \" "''').entries, [(b"name", b" ' \" ")] ) def test_double_quotes_are_benign_within_single_quotes(self) -> None: self.assertEqual( self.loads(br"""name='hello "world"'""").entries, [(b"name", b'hello "world"')], ) self.assertEqual(self.loads(br"""name='"'""").entries, [(b"name", b'"')]) def test_single_quotes_are_benign_within_double_quotes(self) -> None: self.assertEqual( self.loads(br'''name="hello 'world'"''').entries, [(b"name", b"hello 'world'")], ) self.assertEqual(self.loads(br'''name="'"''').entries, [(b"name", b"'")]) self.assertEqual( self.loads(br"""name="I can't "'even'...""").entries, [(b"name", b"I can't even...")], ) def test_value_with_unescaped_quote_extends_to_end_of_file(self) -> None: self.assertEqual( self.loads(b"name='value\nname2=value2\n").entries, [(b"name", b"value\nname2=value2\n")], ) def test_values_can_contain_comment_markers(self) -> None: self.assertEqual( self.loads(b"variable=value;with#comment").entries, [(b"variable", b"value;with#comment")], ) def test_lines_without_equal_sign_are_ignored(self) -> None: self.assertEqual(self.loads(b"notavariable\n").entries, []) def test_comment_lines_are_ignored(self) -> None: self.assertEqual(self.loads(b"#").entries, []) self.assertEqual(self.loads(b"#\\").entries, []) self.assertEqual(self.loads(b"#var=value\n").entries, []) self.assertEqual(self.loads(b";var=value\n").entries, []) self.assertEqual(self.loads(b" # var=value\n").entries, []) self.assertEqual(self.loads(b"\t# var=value\n").entries, []) def test_backslash_escapes_newline_in_comment_line(self) -> None: self.assertEqual( self.loads( b"#\\\n" b"var1=value1\\\n" b"var2=value2\n" b"var3=value3\n" ).entries, [(b"var3", b"value3")], ) self.assertEqual( self.loads(b"#\\\n\nvar=value\n").entries, [(b"var", b"value")] ) self.assertEqual(self.loads(b"#\\\n\\\nvar=value\n").entries, []) def test_backslash_escapes_backslash_in_comment_line(self) -> None: self.assertEqual(self.loads(b"#\\\\\nvar=value").entries, [(b"var", b"value")]) def test_name_can_contain_underscores(self) -> None: self.assertEqual(self.loads(b"name_=value").entries, [(b"name_", b"value")]) self.assertEqual(self.loads(b"_name=value").entries, [(b"_name", b"value")]) self.assertEqual( self.loads(b"__name__=value").entries, [(b"__name__", b"value")] ) def test_name_starting_with_one_equal_sign_discards_entire_variable(self) -> None: self.assertEqual(self.loads(b"=name=value").entries, []) self.assertEqual(self.loads(b"==a=b").entries, []) self.assertEqual( self.loads(b"=name='value\nsame=variable'").entries, [], "same=variable should be parsed as part of the value for the " "discarded variable", ) def test_name_with_symbol_discards_entire_variable(self) -> None: for symbol_byte in b"`~!@$%^&*()[]+-.,/?'\"\\|": symbol = bytes([symbol_byte]) with self.subTest(symbol=symbol): self.assertEqual( self.loads(b"var" + symbol + b"name=value").entries, [] ) self.assertEqual( self.loads( b"var" + symbol + b"name='hello\nsame=variable'" ).entries, [], "same=variable should be parsed as part of the value for " "the discarded variable", ) def test_interior_whitespace_in_name_discards_entire_variable(self) -> None: self.assertEqual(self.loads(b"multi word variable=value").entries, []) self.assertEqual( self.loads(b"multi word variable='hello\nsame=variable'").entries, [], "same=variable should be parsed as part of the value for the " "discarded variable", ) def test_comment_characters_in_name_discards_entire_variable(self) -> None: self.assertEqual(self.loads(b"variable;with#comment=value").entries, []) self.assertEqual( self.loads(b"variable;with#comment='hello\nsame=variable'").entries, [], "same=variable should be parsed as part of the value for the " "discarded variable", ) def test_backslash_in_name_discards_entire_variable(self) -> None: for name in [br"\ name", br"\\name", br"a\\b", br"\#name", b"\\"]: with self.subTest(name=name): self.assertEqual(self.loads(name + b"=value").entries, []) self.assertEqual( self.loads(name + b"='hello\nsame=variable'").entries, [], "same=variable should be parsed as part of the value for " "the discarded variable", ) def test_non_ascii_in_name_discards_entire_variable(self) -> None: name_with_valid_utf8_letter = br"hell\xc3\xb6" name_with_invalid_utf8 = br"hello\x80world" for name in [name_with_valid_utf8_letter, name_with_invalid_utf8]: with self.subTest(name=name): self.assertEqual(self.loads(name + b"=value").entries, []) self.assertEqual( self.loads(name + b"='hello\nsame=variable'").entries, [], "same=variable should be parsed as part of the value for " "the discarded variable", ) def test_leading_digit_in_name_discards_entire_variable(self) -> None: self.assertEqual(self.loads(b"99BOTTLES=beer").entries, []) self.assertEqual( self.loads(b"1up='hello\nsame=variable'").entries, [], "same=variable should be parsed as part of the value for the " "discarded variable", ) def test_carriage_return_in_value_discards_entire_variable(self) -> None: self.assertEqual(self.loads(b"var='hello\rworld'").entries, []) self.assertEqual( self.loads(b"var='hello\rsame=variable'").entries, [], "same=variable should be parsed as part of the value for the " "discarded variable", ) def test_control_characters_in_value_discards_entire_variable(self) -> None: for control_character_byte in b"\x01\a\x1b": control_character = bytes([control_character_byte]) with self.subTest(control_character=control_character): self.assertEqual( self.loads( b"var=control" + control_character + b"character\n" ).entries, [], ) def test_null_byte_is_treated_as_end_of_file(self) -> None: self.assertEqual(self.loads(b"name=value\x00").entries, [(b"name", b"value")]) self.assertEqual( self.loads(b"name=hello\x00world").entries, [(b"name", b"hello")] ) def loads(self, content: bytes) -> SystemdEnvironmentFile: return SystemdEnvironmentFile.loads(content) class EscapeDBusAddressTest(unittest.TestCase): def test_escaped_empty_address_is_empty(self) -> None: self.assertEqual(escape_dbus_address(b""), b"") def test_alphabet_is_not_escaped(self) -> None: self.assertEqual(escape_dbus_address(b"abc"), b"abc") self.assertEqual(escape_dbus_address(b"ABC"), b"ABC") def test_digits_are_not_escaped(self) -> None: self.assertEqual(escape_dbus_address(b"0123456789"), b"0123456789") def test_slashes_are_not_escaped(self) -> None: self.assertEqual(escape_dbus_address(b"/"), b"/") self.assertEqual(escape_dbus_address(b"/path/to/bus"), b"/path/to/bus") self.assertEqual(escape_dbus_address(b"\\"), b"\\") def test_dots_and_dashes_are_not_escaped(self) -> None: self.assertEqual(escape_dbus_address(b".-"), b".-") self.assertEqual(escape_dbus_address(b"file.txt"), b"file.txt") self.assertEqual(escape_dbus_address(b"hello-world"), b"hello-world") def test_special_address_characters_are_escaped(self) -> None: self.assertEqual(escape_dbus_address(b":"), b"%3a") self.assertEqual(escape_dbus_address(b";"), b"%3b") self.assertEqual(escape_dbus_address(b"c:\\windows\\"), b"c%3a\\windows\\") def test_escape_characters_are_escaped(self) -> None: self.assertEqual(escape_dbus_address(b"%"), b"%25") self.assertEqual(escape_dbus_address(b"%25"), b"%2525") def test_whitespace_is_escaped(self) -> None: self.assertEqual(escape_dbus_address(b" "), b"%20") self.assertEqual(escape_dbus_address(b"\n"), b"%0a")