dissect.target 3.18.dev15__py3-none-any.whl → 3.19__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (95) hide show
  1. dissect/target/filesystem.py +44 -25
  2. dissect/target/filesystems/config.py +32 -21
  3. dissect/target/filesystems/extfs.py +4 -0
  4. dissect/target/filesystems/itunes.py +1 -1
  5. dissect/target/filesystems/tar.py +1 -1
  6. dissect/target/filesystems/zip.py +81 -46
  7. dissect/target/helpers/config.py +22 -7
  8. dissect/target/helpers/configutil.py +69 -5
  9. dissect/target/helpers/cyber.py +4 -2
  10. dissect/target/helpers/fsutil.py +32 -4
  11. dissect/target/helpers/loaderutil.py +26 -7
  12. dissect/target/helpers/network_managers.py +22 -7
  13. dissect/target/helpers/record.py +37 -0
  14. dissect/target/helpers/record_modifier.py +23 -4
  15. dissect/target/helpers/shell_application_ids.py +732 -0
  16. dissect/target/helpers/utils.py +11 -0
  17. dissect/target/loader.py +1 -0
  18. dissect/target/loaders/ab.py +285 -0
  19. dissect/target/loaders/libvirt.py +40 -0
  20. dissect/target/loaders/mqtt.py +14 -1
  21. dissect/target/loaders/tar.py +8 -4
  22. dissect/target/loaders/utm.py +3 -0
  23. dissect/target/loaders/velociraptor.py +6 -6
  24. dissect/target/plugin.py +60 -3
  25. dissect/target/plugins/apps/browser/chrome.py +1 -0
  26. dissect/target/plugins/apps/browser/chromium.py +7 -5
  27. dissect/target/plugins/apps/browser/edge.py +1 -0
  28. dissect/target/plugins/apps/browser/firefox.py +82 -36
  29. dissect/target/plugins/apps/remoteaccess/anydesk.py +70 -50
  30. dissect/target/plugins/apps/remoteaccess/remoteaccess.py +8 -8
  31. dissect/target/plugins/apps/remoteaccess/teamviewer.py +46 -31
  32. dissect/target/plugins/apps/ssh/openssh.py +1 -1
  33. dissect/target/plugins/apps/ssh/ssh.py +177 -0
  34. dissect/target/plugins/apps/texteditor/__init__.py +0 -0
  35. dissect/target/plugins/apps/texteditor/texteditor.py +13 -0
  36. dissect/target/plugins/apps/texteditor/windowsnotepad.py +340 -0
  37. dissect/target/plugins/child/qemu.py +21 -0
  38. dissect/target/plugins/filesystem/ntfs/mft.py +132 -45
  39. dissect/target/plugins/filesystem/unix/capability.py +102 -87
  40. dissect/target/plugins/filesystem/walkfs.py +32 -21
  41. dissect/target/plugins/filesystem/yara.py +144 -23
  42. dissect/target/plugins/general/network.py +82 -0
  43. dissect/target/plugins/general/users.py +14 -10
  44. dissect/target/plugins/os/unix/_os.py +19 -5
  45. dissect/target/plugins/os/unix/bsd/freebsd/_os.py +3 -5
  46. dissect/target/plugins/os/unix/esxi/_os.py +29 -23
  47. dissect/target/plugins/os/unix/etc/etc.py +5 -8
  48. dissect/target/plugins/os/unix/history.py +3 -7
  49. dissect/target/plugins/os/unix/linux/_os.py +15 -14
  50. dissect/target/plugins/os/unix/linux/android/_os.py +15 -24
  51. dissect/target/plugins/os/unix/linux/redhat/_os.py +1 -1
  52. dissect/target/plugins/os/unix/locale.py +17 -6
  53. dissect/target/plugins/os/unix/shadow.py +47 -31
  54. dissect/target/plugins/os/windows/_os.py +4 -4
  55. dissect/target/plugins/os/windows/adpolicy.py +4 -1
  56. dissect/target/plugins/os/windows/catroot.py +1 -11
  57. dissect/target/plugins/os/windows/credential/__init__.py +0 -0
  58. dissect/target/plugins/os/windows/credential/lsa.py +174 -0
  59. dissect/target/plugins/os/windows/{sam.py → credential/sam.py} +5 -2
  60. dissect/target/plugins/os/windows/defender.py +6 -3
  61. dissect/target/plugins/os/windows/dpapi/blob.py +3 -0
  62. dissect/target/plugins/os/windows/dpapi/crypto.py +61 -23
  63. dissect/target/plugins/os/windows/dpapi/dpapi.py +127 -133
  64. dissect/target/plugins/os/windows/dpapi/keyprovider/__init__.py +0 -0
  65. dissect/target/plugins/os/windows/dpapi/keyprovider/credhist.py +21 -0
  66. dissect/target/plugins/os/windows/dpapi/keyprovider/empty.py +17 -0
  67. dissect/target/plugins/os/windows/dpapi/keyprovider/keychain.py +20 -0
  68. dissect/target/plugins/os/windows/dpapi/keyprovider/keyprovider.py +8 -0
  69. dissect/target/plugins/os/windows/dpapi/keyprovider/lsa.py +38 -0
  70. dissect/target/plugins/os/windows/dpapi/master_key.py +3 -0
  71. dissect/target/plugins/os/windows/jumplist.py +292 -0
  72. dissect/target/plugins/os/windows/lnk.py +96 -93
  73. dissect/target/plugins/os/windows/regf/shellbags.py +8 -5
  74. dissect/target/plugins/os/windows/regf/shimcache.py +2 -2
  75. dissect/target/plugins/os/windows/regf/usb.py +179 -114
  76. dissect/target/plugins/os/windows/task_helpers/tasks_xml.py +1 -1
  77. dissect/target/plugins/os/windows/wua_history.py +1073 -0
  78. dissect/target/target.py +4 -3
  79. dissect/target/tools/fs.py +53 -15
  80. dissect/target/tools/fsutils.py +243 -0
  81. dissect/target/tools/info.py +11 -4
  82. dissect/target/tools/query.py +2 -2
  83. dissect/target/tools/shell.py +505 -333
  84. dissect/target/tools/utils.py +23 -2
  85. dissect/target/tools/yara.py +65 -0
  86. dissect/target/volumes/md.py +2 -2
  87. {dissect.target-3.18.dev15.dist-info → dissect.target-3.19.dist-info}/METADATA +11 -7
  88. {dissect.target-3.18.dev15.dist-info → dissect.target-3.19.dist-info}/RECORD +94 -75
  89. {dissect.target-3.18.dev15.dist-info → dissect.target-3.19.dist-info}/WHEEL +1 -1
  90. {dissect.target-3.18.dev15.dist-info → dissect.target-3.19.dist-info}/entry_points.txt +1 -0
  91. dissect/target/helpers/ssh.py +0 -177
  92. /dissect/target/plugins/os/windows/{credhist.py → credential/credhist.py} +0 -0
  93. {dissect.target-3.18.dev15.dist-info → dissect.target-3.19.dist-info}/COPYRIGHT +0 -0
  94. {dissect.target-3.18.dev15.dist-info → dissect.target-3.19.dist-info}/LICENSE +0 -0
  95. {dissect.target-3.18.dev15.dist-info → dissect.target-3.19.dist-info}/top_level.txt +0 -0
@@ -228,10 +228,9 @@ class Filesystem:
228
228
  topdown: bool = True,
229
229
  onerror: Optional[Callable] = None,
230
230
  followlinks: bool = False,
231
- ) -> Iterator[str]:
232
- """Walk a directory pointed to by ``path``, returning the string representation of both files and directories.
233
-
234
- It walks across all the files inside ``path`` recursively.
231
+ ) -> Iterator[tuple[str, list[str], list[str]]]:
232
+ """Recursively walk a directory pointed to by ``path``, returning the string representation of both files
233
+ and directories.
235
234
 
236
235
  Args:
237
236
  path: The path to walk on the filesystem.
@@ -250,10 +249,9 @@ class Filesystem:
250
249
  topdown: bool = True,
251
250
  onerror: Optional[Callable] = None,
252
251
  followlinks: bool = False,
253
- ) -> Iterator[FilesystemEntry]:
254
- """Walk a directory pointed to by ``path``, returning FilesystemEntry's of both files and directories.
255
-
256
- It walks across all the files inside ``path`` recursively.
252
+ ) -> Iterator[tuple[list[FilesystemEntry], list[FilesystemEntry], list[FilesystemEntry]]]:
253
+ """Recursively walk a directory pointed to by ``path``, returning :class:`FilesystemEntry` of files
254
+ and directories.
257
255
 
258
256
  Args:
259
257
  path: The path to walk on the filesystem.
@@ -266,6 +264,19 @@ class Filesystem:
266
264
  """
267
265
  return self.get(path).walk_ext(topdown, onerror, followlinks)
268
266
 
267
+ def recurse(self, path: str) -> Iterator[FilesystemEntry]:
268
+ """Recursively walk a directory and yield contents as :class:`FilesystemEntry`.
269
+
270
+ Does not follow symbolic links.
271
+
272
+ Args:
273
+ path: The path to recursively walk on the target filesystem.
274
+
275
+ Returns:
276
+ An iterator of :class:`FilesystemEntry`.
277
+ """
278
+ return self.get(path).recurse()
279
+
269
280
  def glob(self, pattern: str) -> Iterator[str]:
270
281
  """Iterate over the directory part of ``pattern``, returning entries matching ``pattern`` as strings.
271
282
 
@@ -578,10 +589,9 @@ class FilesystemEntry:
578
589
  topdown: bool = True,
579
590
  onerror: Optional[Callable] = None,
580
591
  followlinks: bool = False,
581
- ) -> Iterator[str]:
582
- """Walk a directory and list its contents as strings.
583
-
584
- It walks across all the files inside the entry recursively.
592
+ ) -> Iterator[tuple[str, list[str], list[str]]]:
593
+ """Recursively walk a directory and yield its contents as strings split in a tuple
594
+ of lists of files, directories and symlinks.
585
595
 
586
596
  These contents include::
587
597
  - files
@@ -603,15 +613,9 @@ class FilesystemEntry:
603
613
  topdown: bool = True,
604
614
  onerror: Optional[Callable] = None,
605
615
  followlinks: bool = False,
606
- ) -> Iterator[FilesystemEntry]:
607
- """Walk a directory and show its contents as :class:`FilesystemEntry`.
608
-
609
- It walks across all the files inside the entry recursively.
610
-
611
- These contents include::
612
- - files
613
- - directories
614
- - symboliclinks
616
+ ) -> Iterator[tuple[list[FilesystemEntry], list[FilesystemEntry], list[FilesystemEntry]]]:
617
+ """Recursively walk a directory and yield its contents as :class:`FilesystemEntry` split in a tuple of
618
+ lists of files, directories and symlinks.
615
619
 
616
620
  Args:
617
621
  topdown: ``True`` puts this entry at the top of the list, ``False`` puts this entry at the bottom.
@@ -619,10 +623,20 @@ class FilesystemEntry:
619
623
  followlinks: ``True`` if we want to follow any symbolic link
620
624
 
621
625
  Returns:
622
- An iterator of :class:`FilesystemEntry`.
626
+ An iterator of tuples :class:`FilesystemEntry`.
623
627
  """
624
628
  yield from fsutil.walk_ext(self, topdown, onerror, followlinks)
625
629
 
630
+ def recurse(self) -> Iterator[FilesystemEntry]:
631
+ """Recursively walk a directory and yield its contents as :class:`FilesystemEntry`.
632
+
633
+ Does not follow symbolic links.
634
+
635
+ Returns:
636
+ An iterator of :class:`FilesystemEntry`.
637
+ """
638
+ yield from fsutil.recurse(self)
639
+
626
640
  def glob(self, pattern: str) -> Iterator[str]:
627
641
  """Iterate over this directory part of ``patern``, returning entries matching ``pattern`` as strings.
628
642
 
@@ -739,7 +753,7 @@ class FilesystemEntry:
739
753
  """
740
754
  log.debug("%r::readlink_ext()", self)
741
755
  # Default behavior, resolve link own filesystem.
742
- return fsutil.resolve_link(fs=self.fs, entry=self)
756
+ return fsutil.resolve_link(self.fs, self.readlink(), self.path, alt_separator=self.fs.alt_separator)
743
757
 
744
758
  def stat(self, follow_symlinks: bool = True) -> fsutil.stat_result:
745
759
  """Determine the stat information of this entry.
@@ -1453,10 +1467,15 @@ class LayerFilesystem(Filesystem):
1453
1467
  """Get a :class:`FilesystemEntry` relative to a specific entry."""
1454
1468
  parts = path.split("/")
1455
1469
 
1456
- for part in parts:
1470
+ for i, part in enumerate(parts):
1457
1471
  if entry.is_symlink():
1458
1472
  # Resolve using the RootFilesystem instead of the entry's Filesystem
1459
- entry = fsutil.resolve_link(fs=self, entry=entry)
1473
+ entry = fsutil.resolve_link(
1474
+ self,
1475
+ entry.readlink(),
1476
+ "/".join(parts[:i]),
1477
+ alt_separator=entry.fs.alt_separator,
1478
+ )
1460
1479
  entry = entry.get(part)
1461
1480
 
1462
1481
  return entry
@@ -3,11 +3,11 @@ from __future__ import annotations
3
3
  import io
4
4
  import textwrap
5
5
  from logging import getLogger
6
- from typing import Any, BinaryIO, Iterator, Optional, Union
6
+ from typing import Any, BinaryIO, Iterator, Optional
7
7
 
8
8
  from dissect.target import Target
9
9
  from dissect.target.exceptions import ConfigurationParsingError, FileNotFoundError
10
- from dissect.target.filesystem import Filesystem, FilesystemEntry, VirtualFilesystem
10
+ from dissect.target.filesystem import FilesystemEntry, VirtualFilesystem
11
11
  from dissect.target.helpers import fsutil
12
12
  from dissect.target.helpers.configutil import ConfigurationParser, parse
13
13
 
@@ -46,7 +46,7 @@ class ConfigurationFilesystem(VirtualFilesystem):
46
46
  super().__init__(**kwargs)
47
47
  self.root.top = target.fs.get(path)
48
48
 
49
- def _get_till_file(self, path: str, relentry: FilesystemEntry) -> tuple[list[str], FilesystemEntry]:
49
+ def _get_till_file(self, path: str, relentry: FilesystemEntry | None) -> tuple[list[str], FilesystemEntry]:
50
50
  """Searches for the file entry that is pointed to by ``path``.
51
51
 
52
52
  The ``path`` could contain ``key`` entries too, so it searches for the entry from
@@ -56,9 +56,13 @@ class ConfigurationFilesystem(VirtualFilesystem):
56
56
  A list of ``parts`` containing keys: [keys, into, the, file].
57
57
  And the resolved entry: Entry(filename)
58
58
  """
59
+
59
60
  entry = relentry or self.root
61
+ root_path = relentry.path if relentry else self.root.top.path
60
62
 
61
- path = fsutil.normalize(path, alt_separator=self.alt_separator).strip("/")
63
+ # Calculate the relative path
64
+ relpath = fsutil.relpath(path, root_path, alt_separator=self.alt_separator)
65
+ path = fsutil.normalize(relpath, alt_separator=self.alt_separator).strip("/")
62
66
 
63
67
  if not path:
64
68
  return [], entry
@@ -85,10 +89,8 @@ class ConfigurationFilesystem(VirtualFilesystem):
85
89
 
86
90
  return parts[idx:], entry
87
91
 
88
- def get(
89
- self, path: str, relentry: Optional[FilesystemEntry] = None, *args, **kwargs
90
- ) -> Union[FilesystemEntry, ConfigurationEntry]:
91
- """Retrieve a :class:`ConfigurationEntry` or :class:`.FilesystemEntry` relative to the root or ``relentry``.
92
+ def get(self, path: str, relentry: Optional[FilesystemEntry] = None, *args, **kwargs) -> ConfigurationEntry:
93
+ """Retrieve a :class:`ConfigurationEntry` relative to the root or ``relentry``.
92
94
 
93
95
  Raises:
94
96
  FileNotFoundError: if it could not find the entry.
@@ -96,7 +98,7 @@ class ConfigurationFilesystem(VirtualFilesystem):
96
98
  parts, entry = self._get_till_file(path, relentry)
97
99
 
98
100
  if entry.is_dir():
99
- return entry
101
+ return ConfigurationEntry(self, entry.path, entry, None)
100
102
 
101
103
  entry = self._convert_entry(entry, *args, **kwargs)
102
104
 
@@ -108,23 +110,21 @@ class ConfigurationFilesystem(VirtualFilesystem):
108
110
 
109
111
  return entry
110
112
 
111
- def _convert_entry(
112
- self, file_entry: FilesystemEntry, *args, **kwargs
113
- ) -> Union[ConfigurationEntry, FilesystemEntry]:
113
+ def _convert_entry(self, file_entry: FilesystemEntry, *args, **kwargs) -> ConfigurationEntry:
114
114
  """Creates a :class:`ConfigurationEntry` from a ``file_entry``.
115
115
 
116
116
  If an error occurs during the parsing of the file contents,
117
117
  the original ``file_entry`` is returned.
118
118
  """
119
119
  entry = file_entry
120
+ config_parser = None
120
121
  try:
121
122
  config_parser = parse(entry, *args, **kwargs)
122
- entry = ConfigurationEntry(self, entry.path, entry, config_parser)
123
123
  except ConfigurationParsingError as e:
124
124
  # If a parsing error gets created, it should return the `entry`
125
125
  log.debug("Error when parsing %s with message '%s'", entry.path, e)
126
126
 
127
- return entry
127
+ return ConfigurationEntry(self, entry.path, entry, config_parser)
128
128
 
129
129
 
130
130
  class ConfigurationEntry(FilesystemEntry):
@@ -163,10 +163,10 @@ class ConfigurationEntry(FilesystemEntry):
163
163
 
164
164
  def __init__(
165
165
  self,
166
- fs: Filesystem,
166
+ fs: ConfigurationFilesystem,
167
167
  path: str,
168
168
  entry: FilesystemEntry,
169
- parser_items: Optional[Union[dict, ConfigurationParser, str, list]] = None,
169
+ parser_items: dict | ConfigurationParser | str | list | None = None,
170
170
  ) -> None:
171
171
  super().__init__(fs, path, entry)
172
172
  self.parser_items = parser_items
@@ -182,7 +182,7 @@ class ConfigurationEntry(FilesystemEntry):
182
182
 
183
183
  return f"<{self.__class__.__name__} {output}"
184
184
 
185
- def get(self, key, default: Optional[Any] = None) -> Union[ConfigurationEntry, Any, None]:
185
+ def get(self, key, default: Any | None = None) -> ConfigurationEntry | Any | None:
186
186
  """Gets the dictionary key that belongs to this entry using ``key``.
187
187
  Behaves like ``dictionary.get()``.
188
188
 
@@ -197,13 +197,19 @@ class ConfigurationEntry(FilesystemEntry):
197
197
  if not key:
198
198
  raise TypeError("key should be defined")
199
199
 
200
- if key in self.parser_items:
200
+ path = fsutil.join(self.path, key, alt_separator=self.fs.alt_separator)
201
+
202
+ if self.parser_items and key in self.parser_items:
201
203
  return ConfigurationEntry(
202
204
  self.fs,
203
- fsutil.join(self.path, key, alt_separator=self.fs.alt_separator),
205
+ path,
204
206
  self.entry,
205
207
  self.parser_items[key],
206
208
  )
209
+
210
+ if self.entry.is_dir():
211
+ return self.fs.get(path, self.entry)
212
+
207
213
  return default
208
214
 
209
215
  def _write_value_mapping(self, values: dict[str, Any], indentation_nr: int = 0) -> str:
@@ -257,6 +263,11 @@ class ConfigurationEntry(FilesystemEntry):
257
263
  if self.is_file():
258
264
  raise NotADirectoryError()
259
265
 
266
+ if self.parser_items is None and self.entry.is_dir():
267
+ for entry in self.entry.scandir():
268
+ yield ConfigurationEntry(self.fs, entry.name, entry, None)
269
+ return
270
+
260
271
  for key, values in self.parser_items.items():
261
272
  yield ConfigurationEntry(self.fs, key, self.entry, values)
262
273
 
@@ -266,7 +277,7 @@ class ConfigurationEntry(FilesystemEntry):
266
277
  def is_dir(self, follow_symlinks: bool = True) -> bool:
267
278
  """Returns whether this :class:`ConfigurationEntry` can be considered a directory."""
268
279
  # if self.parser_items has keys (thus sub-values), we can consider it a directory.
269
- return hasattr(self.parser_items, "keys")
280
+ return (self.parser_items is None and self.entry.is_dir()) or hasattr(self.parser_items, "keys")
270
281
 
271
282
  def is_symlink(self) -> bool:
272
283
  """Return whether this :class:`ConfigurationEntry` is a symlink or not.
@@ -284,7 +295,7 @@ class ConfigurationEntry(FilesystemEntry):
284
295
  Returns:
285
296
  Whether the ``entry`` and ``key`` exists
286
297
  """
287
- return self.entry.exists() and key in self.parser_items
298
+ return self.parser_items and self.entry.exists() and key in self.parser_items
288
299
 
289
300
  def stat(self, follow_symlinks: bool = True) -> fsutil.stat_result:
290
301
  """Returns the stat from the underlying :class:`.FilesystemEntry` :attr:`entry`."""
@@ -134,6 +134,10 @@ class ExtFilesystemEntry(FilesystemEntry):
134
134
  st_info.st_mtime_ns = self.entry.mtime_ns
135
135
  st_info.st_ctime_ns = self.entry.ctime_ns
136
136
 
137
+ # Set blocks
138
+ st_info.st_blocks = self.entry.inode.i_blocks_lo
139
+ st_info.st_blksize = self.entry.extfs.block_size
140
+
137
141
  return st_info
138
142
 
139
143
  def attr(self) -> Any:
@@ -94,7 +94,7 @@ class ITunesFilesystemEntry(VirtualFile):
94
94
  def readlink_ext(self) -> FilesystemEntry:
95
95
  """Read the link if this entry is a symlink. Returns a filesystem entry."""
96
96
  # Can't use the one in VirtualFile as it overrides the FilesystemEntry
97
- return fsutil.resolve_link(fs=self.fs, entry=self)
97
+ return fsutil.resolve_link(self.fs, self.readlink(), self.path, alt_separator=self.fs.alt_separator)
98
98
 
99
99
  def stat(self, follow_symlinks: bool = True) -> fsutil.stat_result:
100
100
  """Return the stat information of this entry."""
@@ -121,7 +121,7 @@ class TarFilesystemEntry(VirtualFile):
121
121
  def readlink_ext(self) -> FilesystemEntry:
122
122
  """Read the link if this entry is a symlink. Returns a filesystem entry."""
123
123
  # Can't use the one in VirtualFile as it overrides the FilesystemEntry
124
- return fsutil.resolve_link(fs=self.fs, entry=self)
124
+ return fsutil.resolve_link(self.fs, self.readlink(), self.path, alt_separator=self.fs.alt_separator)
125
125
 
126
126
  def stat(self, follow_symlinks: bool = True) -> fsutil.stat_result:
127
127
  """Return the stat information of this entry."""
@@ -4,16 +4,21 @@ import logging
4
4
  import stat
5
5
  import zipfile
6
6
  from datetime import datetime, timezone
7
- from typing import BinaryIO, Optional
7
+ from typing import BinaryIO, Iterator
8
8
 
9
9
  from dissect.util.stream import BufferedStream
10
10
 
11
- from dissect.target.exceptions import FileNotFoundError
11
+ from dissect.target.exceptions import (
12
+ FileNotFoundError,
13
+ FilesystemError,
14
+ IsADirectoryError,
15
+ NotADirectoryError,
16
+ NotASymlinkError,
17
+ )
12
18
  from dissect.target.filesystem import (
13
19
  Filesystem,
14
20
  FilesystemEntry,
15
21
  VirtualDirectory,
16
- VirtualFile,
17
22
  VirtualFilesystem,
18
23
  )
19
24
  from dissect.target.helpers import fsutil
@@ -33,7 +38,7 @@ class ZipFilesystem(Filesystem):
33
38
  def __init__(
34
39
  self,
35
40
  fh: BinaryIO,
36
- base: Optional[str] = None,
41
+ base: str | None = None,
37
42
  *args,
38
43
  **kwargs,
39
44
  ):
@@ -52,12 +57,7 @@ class ZipFilesystem(Filesystem):
52
57
  continue
53
58
 
54
59
  rel_name = fsutil.normpath(mname[len(self.base) :], alt_separator=self.alt_separator)
55
-
56
- # NOTE: Normally we would check here if the member is a symlink or not
57
-
58
- entry_cls = ZipFilesystemDirectoryEntry if member.is_dir() else ZipFilesystemEntry
59
- file_entry = entry_cls(self, rel_name, member)
60
- self._fs.map_file_entry(rel_name, file_entry)
60
+ self._fs.map_file_entry(rel_name, ZipFilesystemEntry(self, rel_name, member))
61
61
 
62
62
  @staticmethod
63
63
  def _detect(fh: BinaryIO) -> bool:
@@ -69,60 +69,95 @@ class ZipFilesystem(Filesystem):
69
69
  return self._fs.get(path, relentry=relentry)
70
70
 
71
71
 
72
- class ZipFilesystemEntry(VirtualFile):
72
+ # Note: We subclass from VirtualDirectory because VirtualFilesystem is currently only compatible with VirtualDirectory
73
+ # Subclass from VirtualDirectory so we get that compatibility for free, and override the rest to do our own thing
74
+ class ZipFilesystemEntry(VirtualDirectory):
75
+ fs: ZipFilesystem
76
+ entry: zipfile.ZipInfo
77
+
78
+ def __init__(self, fs: ZipFilesystem, path: str, entry: zipfile.ZipInfo):
79
+ super().__init__(fs, path)
80
+ self.entry = entry
81
+
73
82
  def open(self) -> BinaryIO:
74
- """Returns file handle (file-like object)."""
83
+ if self.is_dir():
84
+ raise IsADirectoryError(self.path)
85
+
86
+ if self.is_symlink():
87
+ return self._resolve().open()
88
+
75
89
  try:
76
90
  return BufferedStream(self.fs.zip.open(self.entry), size=self.entry.file_size)
77
91
  except Exception:
78
- raise FileNotFoundError()
92
+ raise FileNotFoundError(self.path)
79
93
 
80
- def readlink(self) -> str:
81
- """Read the link if this entry is a symlink. Returns a string."""
82
- raise NotImplementedError()
94
+ def iterdir(self) -> Iterator[str]:
95
+ if not self.is_dir():
96
+ raise NotADirectoryError(self.path)
83
97
 
84
- def readlink_ext(self) -> FilesystemEntry:
85
- """Read the link if this entry is a symlink. Returns a filesystem entry."""
86
- raise NotImplementedError()
98
+ entry = self._resolve()
99
+ if isinstance(entry, ZipFilesystemEntry):
100
+ yield from super(ZipFilesystemEntry, entry).iterdir()
101
+ else:
102
+ yield from entry.iterdir()
87
103
 
88
- def stat(self, follow_symlinks: bool = True) -> fsutil.stat_result:
89
- """Return the stat information of this entry."""
90
- return self.lstat()
104
+ def scandir(self) -> Iterator[FilesystemEntry]:
105
+ if not self.is_dir():
106
+ raise NotADirectoryError(self.path)
91
107
 
92
- def lstat(self) -> fsutil.stat_result:
93
- """Return the stat information of the given path, without resolving links."""
94
- # ['mode', 'addr', 'dev', 'nlink', 'uid', 'gid', 'size', 'atime', 'mtime', 'ctime']
95
- return fsutil.stat_result(
96
- [
97
- stat.S_IFREG | 0o777,
98
- self.entry.header_offset,
99
- id(self.fs),
100
- 1,
101
- 0,
102
- 0,
103
- self.entry.file_size,
104
- 0,
105
- datetime(*self.entry.date_time, tzinfo=timezone.utc).timestamp(),
106
- 0,
107
- ]
108
- )
108
+ entry = self._resolve()
109
+ if isinstance(entry, ZipFilesystemEntry):
110
+ yield from super(ZipFilesystemEntry, entry).scandir()
111
+ else:
112
+ yield from entry.scandir()
109
113
 
114
+ def is_dir(self, follow_symlinks: bool = True) -> bool:
115
+ try:
116
+ entry = self._resolve(follow_symlinks=follow_symlinks)
117
+ except FilesystemError:
118
+ return False
110
119
 
111
- class ZipFilesystemDirectoryEntry(VirtualDirectory):
112
- def __init__(self, fs: ZipFilesystem, path: str, entry: zipfile.ZipInfo):
113
- super().__init__(fs, path)
114
- self.entry = entry
120
+ if isinstance(entry, ZipFilesystemEntry):
121
+ return entry.entry.is_dir()
122
+ return isinstance(entry, VirtualDirectory)
123
+
124
+ def is_file(self, follow_symlinks: bool = True) -> bool:
125
+ try:
126
+ entry = self._resolve(follow_symlinks=follow_symlinks)
127
+ except FilesystemError:
128
+ return False
129
+
130
+ if isinstance(entry, ZipFilesystemEntry):
131
+ return not entry.entry.is_dir()
132
+ return False
133
+
134
+ def is_symlink(self) -> bool:
135
+ return stat.S_ISLNK(self.entry.external_attr >> 16)
136
+
137
+ def readlink(self) -> str:
138
+ if not self.is_symlink():
139
+ raise NotASymlinkError()
140
+ return self.fs.zip.open(self.entry).read().decode()
141
+
142
+ def readlink_ext(self) -> FilesystemEntry:
143
+ return FilesystemEntry.readlink_ext(self)
115
144
 
116
145
  def stat(self, follow_symlinks: bool = True) -> fsutil.stat_result:
117
- """Return the stat information of this entry."""
118
- return self.lstat()
146
+ return self._resolve(follow_symlinks=follow_symlinks).lstat()
119
147
 
120
148
  def lstat(self) -> fsutil.stat_result:
121
149
  """Return the stat information of the given path, without resolving links."""
122
150
  # ['mode', 'addr', 'dev', 'nlink', 'uid', 'gid', 'size', 'atime', 'mtime', 'ctime']
151
+ mode = self.entry.external_attr >> 16
152
+
153
+ if self.entry.is_dir() and not stat.S_ISDIR(mode):
154
+ mode = stat.S_IFDIR | mode
155
+ elif not self.entry.is_dir() and not stat.S_ISREG(mode):
156
+ mode = stat.S_IFREG | mode
157
+
123
158
  return fsutil.stat_result(
124
159
  [
125
- stat.S_IFDIR | 0o777,
160
+ mode,
126
161
  self.entry.header_offset,
127
162
  id(self.fs),
128
163
  1,
@@ -1,27 +1,35 @@
1
+ from __future__ import annotations
2
+
1
3
  import ast
2
4
  import importlib.machinery
3
5
  import importlib.util
4
6
  import logging
5
7
  from pathlib import Path
6
8
  from types import ModuleType
7
- from typing import Optional, Union
8
9
 
9
10
  log = logging.getLogger(__name__)
10
11
 
11
12
  CONFIG_NAME = ".targetcfg.py"
12
13
 
13
14
 
14
- def load(path: Optional[Union[Path, str]]) -> ModuleType:
15
+ def load(paths: list[Path | str] | Path | str | None) -> ModuleType:
16
+ """Attempt to load one configuration from the provided path(s)."""
17
+
18
+ if isinstance(paths, Path) or isinstance(paths, str):
19
+ paths = [paths]
20
+
15
21
  config_spec = importlib.machinery.ModuleSpec("config", None)
16
22
  config = importlib.util.module_from_spec(config_spec)
17
- config_file = _find_config_file(path)
23
+ config_file = _find_config_file(paths)
24
+
18
25
  if config_file:
19
26
  config_values = _parse_ast(config_file.read_bytes())
20
27
  config.__dict__.update(config_values)
28
+
21
29
  return config
22
30
 
23
31
 
24
- def _parse_ast(code: str) -> dict[str, Union[str, int]]:
32
+ def _parse_ast(code: str) -> dict[str, str | int]:
25
33
  # Only allow basic value assignments for backwards compatibility
26
34
  obj = {}
27
35
 
@@ -49,15 +57,19 @@ def _parse_ast(code: str) -> dict[str, Union[str, int]]:
49
57
  return obj
50
58
 
51
59
 
52
- def _find_config_file(path: Optional[Union[Path, str]]) -> Optional[Path]:
53
- """Find a config file anywhere in the given path and return it.
60
+ def _find_config_file(paths: list[Path | str] | None) -> Path | None:
61
+ """Find a config file anywhere in the given path(s) and return it.
54
62
 
55
63
  This algorithm allows parts of the path to not exist or the last part to be a filename.
56
64
  It also does not look in the root directory ('/') for config files.
57
65
  """
58
66
 
67
+ if not paths:
68
+ return
69
+
59
70
  config_file = None
60
- if path:
71
+
72
+ for path in paths:
61
73
  path = Path(path)
62
74
  cur_path = path.absolute()
63
75
 
@@ -69,4 +81,7 @@ def _find_config_file(path: Optional[Union[Path, str]]) -> Optional[Path]:
69
81
  config_file = cur_config
70
82
  cur_path = cur_path.parent
71
83
 
84
+ if config_file:
85
+ break
86
+
72
87
  return config_file