diff --git a/dissect/target/plugins/os/unix/locale.py b/dissect/target/plugins/os/unix/locale.py index 1f4b6df85..e51e1f192 100644 --- a/dissect/target/plugins/os/unix/locale.py +++ b/dissect/target/plugins/os/unix/locale.py @@ -1,3 +1,5 @@ +from pathlib import Path + from dissect.target.helpers.localeutil import normalize_language from dissect.target.helpers.record import TargetRecordDescriptor from dissect.target.plugin import Plugin, export @@ -14,6 +16,15 @@ ) +def timezone_from_path(path: Path) -> str: + """Return timezone name for given zoneinfo path. + + /usr/share/zoneinfo/Europe/Amsterdam -> Europe/Amsterdam + """ + zoneinfo_path = str(path).split("/") + return "/".join(zoneinfo_path[-2:]) + + class LocalePlugin(Plugin): def check_compatible(self): pass @@ -28,13 +39,27 @@ def timezone(self): for line in path.open("rt"): return line.strip() - # /etc/localtime should be a symlink to + # /etc/localtime can be a symlink, hardlink or a copy of: # eg. /usr/share/zoneinfo/America/New_York - # on centos and some other distros - if (zoneinfo := self.target.fs.path("/etc/localtime")).exists(): - zoneinfo_path = str(zoneinfo.readlink()).split("/") - timezone = "/".join(zoneinfo_path[-2:]) - return timezone + p_localtime = self.target.fs.path("/etc/localtime") + + # If it's a symlink, read the path of the symlink + if p_localtime.is_symlink(): + return timezone_from_path(p_localtime.readlink()) + + # If it's a hardlink, try finding the hardlinked zoneinfo file + if p_localtime.exists() and p_localtime.stat().st_nlink > 1: + for path in self.target.fs.path("/usr/share/zoneinfo").rglob("*"): + if p_localtime.samefile(path): + return timezone_from_path(path) + + # If it's a regular file (probably copied), we try finding it by matching size and sha1 hash. + if p_localtime.is_file(): + size = p_localtime.stat().st_size + sha1 = p_localtime.get().sha1() + for path in self.target.fs.path("/usr/share/zoneinfo").rglob("*"): + if path.is_file() and path.stat().st_size == size and path.get().sha1() == sha1: + return timezone_from_path(path) @export(property=True) def language(self): diff --git a/tests/test_plugins_os_unix_locale.py b/tests/test_plugins_os_unix_locale.py index f64d68e3b..fdda52854 100644 --- a/tests/test_plugins_os_unix_locale.py +++ b/tests/test_plugins_os_unix_locale.py @@ -1,11 +1,19 @@ from io import BytesIO +from unittest.mock import patch +from dissect.target.filesystem import ( + VirtualDirectory, + VirtualFile, + VirtualFilesystem, + VirtualSymlink, +) from dissect.target.plugins.os.unix.locale import LocalePlugin as UnixLocalePlugin +from dissect.target.target import Target from ._utils import absolute_path -def test_locale_plugin_unix(target_unix_users, fs_unix): +def test_locale_plugin_unix(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: # Locale locations originate from Ubuntu 20. fs_unix.map_file_fh("/etc/timezone", BytesIO(b"Europe/Amsterdam")) fs_unix.map_file_fh("/etc/default/locale", BytesIO(b"LANG=en_US.UTF-8")) @@ -23,9 +31,49 @@ def test_locale_plugin_unix(target_unix_users, fs_unix): assert keyboard[0].backspace == "guess" -def test_locale_plugin_unix_quotes(target_unix_users, fs_unix): +def test_locale_plugin_unix_quotes(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: # Older Fedora system fs_unix.map_file_fh("/etc/default/locale", BytesIO(b'LANG="en_US.UTF-8"')) target_unix_users.add_plugin(UnixLocalePlugin) assert target_unix_users.language == ["en_US"] + + +def test_locale_etc_localtime_symlink(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: + fs_unix.symlink("/usr/share/zoneinfo/Europe/Amsterdam", "/etc/localtime") + target_unix_users.add_plugin(UnixLocalePlugin) + + assert isinstance(fs_unix.get("/etc/localtime"), VirtualSymlink) + assert target_unix_users.timezone == "Europe/Amsterdam" + + +def test_locale_etc_localtime_hardlink(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: + fs_unix.map_file_fh("/usr/share/zoneinfo/Europe/Amsterdam", BytesIO(b"contents of Europe/Amsterdam")) + fs_unix.link("/usr/share/zoneinfo/Europe/Amsterdam", "/etc/localtime") + target_unix_users.add_plugin(UnixLocalePlugin) + + assert isinstance(fs_unix.get("/etc/localtime"), VirtualFile) + assert fs_unix.path("/etc/localtime").samefile(fs_unix.path("/usr/share/zoneinfo/Europe/Amsterdam")) + + entry = fs_unix.get("/etc/localtime") + stat = entry.stat() + stat.st_nlink = 2 + + with patch.object(entry, "stat", return_value=stat): + assert entry.stat().st_nlink == 2 + assert target_unix_users.timezone == "Europe/Amsterdam" + + +def test_locale_etc_localtime_regular_file(target_unix_users: Target, fs_unix: VirtualFilesystem) -> None: + fs_unix.map_file_fh("/etc/localtime", BytesIO(b"contents of Europe/Amsterdam")) + fs_unix.map_file_fh("/usr/share/zoneinfo/UTC", BytesIO(b"contents of UTC")) + fs_unix.map_file_fh("/usr/share/zoneinfo/Europe/Amsterdam", BytesIO(b"contents of Europe/Amsterdam")) + fs_unix.map_file_fh("/usr/share/zoneinfo/America/New_York", BytesIO(b"contents of America/New_York")) + target_unix_users.add_plugin(UnixLocalePlugin) + + assert isinstance(fs_unix.get("/etc/localtime"), VirtualFile) + assert isinstance(fs_unix.get("/usr/share/zoneinfo/UTC"), VirtualFile) + assert isinstance(fs_unix.get("/usr/share/zoneinfo/Europe"), VirtualDirectory) + assert isinstance(fs_unix.get("/usr/share/zoneinfo/Europe/Amsterdam"), VirtualFile) + assert isinstance(fs_unix.get("/usr/share/zoneinfo/America/New_York"), VirtualFile) + assert target_unix_users.timezone == "Europe/Amsterdam"