dissect.target 3.21.dev11__py3-none-any.whl → 3.21.dev12__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dissect/target/tools/diff.py +990 -0
- {dissect.target-3.21.dev11.dist-info → dissect.target-3.21.dev12.dist-info}/METADATA +1 -1
- {dissect.target-3.21.dev11.dist-info → dissect.target-3.21.dev12.dist-info}/RECORD +8 -7
- {dissect.target-3.21.dev11.dist-info → dissect.target-3.21.dev12.dist-info}/entry_points.txt +1 -0
- {dissect.target-3.21.dev11.dist-info → dissect.target-3.21.dev12.dist-info}/COPYRIGHT +0 -0
- {dissect.target-3.21.dev11.dist-info → dissect.target-3.21.dev12.dist-info}/LICENSE +0 -0
- {dissect.target-3.21.dev11.dist-info → dissect.target-3.21.dev12.dist-info}/WHEEL +0 -0
- {dissect.target-3.21.dev11.dist-info → dissect.target-3.21.dev12.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,990 @@
|
|
1
|
+
#!/usr/bin/env python
|
2
|
+
# -*- coding: utf-8 -*-
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import argparse
|
6
|
+
import dataclasses
|
7
|
+
import logging
|
8
|
+
import os
|
9
|
+
import re
|
10
|
+
import shutil
|
11
|
+
import sys
|
12
|
+
from difflib import diff_bytes, unified_diff
|
13
|
+
from fnmatch import fnmatch, translate
|
14
|
+
from io import BytesIO
|
15
|
+
from typing import Iterable, Iterator, TextIO
|
16
|
+
|
17
|
+
from dissect.cstruct import hexdump
|
18
|
+
from flow.record import Record, RecordOutput, ignore_fields_for_comparison
|
19
|
+
|
20
|
+
from dissect.target import Target
|
21
|
+
from dissect.target.exceptions import FileNotFoundError
|
22
|
+
from dissect.target.filesystem import FilesystemEntry
|
23
|
+
from dissect.target.helpers import fsutil
|
24
|
+
from dissect.target.helpers.record import TargetRecordDescriptor
|
25
|
+
from dissect.target.plugin import alias, arg
|
26
|
+
from dissect.target.tools.fsutils import print_extensive_file_stat_listing
|
27
|
+
from dissect.target.tools.query import record_output
|
28
|
+
from dissect.target.tools.shell import (
|
29
|
+
ExtendedCmd,
|
30
|
+
TargetCli,
|
31
|
+
arg_str_to_arg_list,
|
32
|
+
build_pipe_stdout,
|
33
|
+
fmt_ls_colors,
|
34
|
+
python_shell,
|
35
|
+
run_cli,
|
36
|
+
)
|
37
|
+
from dissect.target.tools.utils import (
|
38
|
+
catch_sigpipe,
|
39
|
+
configure_generic_arguments,
|
40
|
+
generate_argparse_for_bound_method,
|
41
|
+
process_generic_arguments,
|
42
|
+
)
|
43
|
+
|
44
|
+
log = logging.getLogger(__name__)
|
45
|
+
logging.lastResort = None
|
46
|
+
logging.raiseExceptions = False
|
47
|
+
|
48
|
+
BLOCK_SIZE = 2048
|
49
|
+
FILE_LIMIT = BLOCK_SIZE * 16
|
50
|
+
|
51
|
+
FILE_DIFF_RECORD_FIELDS = [
|
52
|
+
("string", "src_target"),
|
53
|
+
("string", "dst_target"),
|
54
|
+
("string", "path"),
|
55
|
+
]
|
56
|
+
RECORD_DIFF_RECORD_FIELDS = [
|
57
|
+
("string", "src_target"),
|
58
|
+
("string", "dst_target"),
|
59
|
+
("record", "record"),
|
60
|
+
]
|
61
|
+
|
62
|
+
FileDeletedRecord = TargetRecordDescriptor("differential/file/deleted", FILE_DIFF_RECORD_FIELDS)
|
63
|
+
FileCreatedRecord = TargetRecordDescriptor("differential/file/created", FILE_DIFF_RECORD_FIELDS)
|
64
|
+
FileModifiedRecord = TargetRecordDescriptor(
|
65
|
+
"differential/file/modified",
|
66
|
+
FILE_DIFF_RECORD_FIELDS
|
67
|
+
+ [
|
68
|
+
("bytes[]", "diff"),
|
69
|
+
],
|
70
|
+
)
|
71
|
+
|
72
|
+
RecordCreatedRecord = TargetRecordDescriptor("differential/record/created", RECORD_DIFF_RECORD_FIELDS)
|
73
|
+
RecordDeletedRecord = TargetRecordDescriptor("differential/record/deleted", RECORD_DIFF_RECORD_FIELDS)
|
74
|
+
RecordUnchangedRecord = TargetRecordDescriptor("differential/record/unchanged", RECORD_DIFF_RECORD_FIELDS)
|
75
|
+
|
76
|
+
|
77
|
+
@dataclasses.dataclass
|
78
|
+
class DifferentialEntry:
|
79
|
+
"""Signifies a change for a FilesystemEntry between two versions of a target."""
|
80
|
+
|
81
|
+
path: str
|
82
|
+
name: str
|
83
|
+
src_target_entry: FilesystemEntry
|
84
|
+
dst_target_entry: FilesystemEntry
|
85
|
+
diff: list[bytes]
|
86
|
+
|
87
|
+
|
88
|
+
@dataclasses.dataclass
|
89
|
+
class DirectoryDifferential:
|
90
|
+
"""For a given directory, contains the unchanged, created, modified and deleted entries, as well as a list of
|
91
|
+
subdirectories."""
|
92
|
+
|
93
|
+
directory: str
|
94
|
+
unchanged: list[FilesystemEntry] = dataclasses.field(default_factory=list)
|
95
|
+
created: list[FilesystemEntry] = dataclasses.field(default_factory=list)
|
96
|
+
modified: list[DifferentialEntry] = dataclasses.field(default_factory=list)
|
97
|
+
deleted: list[FilesystemEntry] = dataclasses.field(default_factory=list)
|
98
|
+
|
99
|
+
|
100
|
+
def likely_unchanged(src: fsutil.stat_result, dst: fsutil.stat_result) -> bool:
|
101
|
+
"""Determine whether or not, based on the file stats, we can assume a file hasn't been changed."""
|
102
|
+
if src.st_size != dst.st_size or src.st_mtime != dst.st_mtime or src.st_ctime != dst.st_ctime:
|
103
|
+
return False
|
104
|
+
return True
|
105
|
+
|
106
|
+
|
107
|
+
def get_plugin_output_records(plugin_name: str, plugin_arg_parts: list[str], target: Target) -> Iterable[Record]:
|
108
|
+
"""Command exection helper for target plugins. Highly similar to target-shell's _exec_target, however this function
|
109
|
+
only accepts plugins that outputs records, and returns an iterable of records rather than a function that outputs
|
110
|
+
to stdout."""
|
111
|
+
attr = target
|
112
|
+
for part in plugin_name.split("."):
|
113
|
+
attr = getattr(attr, part)
|
114
|
+
|
115
|
+
if getattr(attr, "__output__", "default") != "record":
|
116
|
+
raise ValueError("Comparing plugin output is only supported for plugins outputting records.")
|
117
|
+
|
118
|
+
if callable(attr):
|
119
|
+
argparser = generate_argparse_for_bound_method(attr)
|
120
|
+
try:
|
121
|
+
args = argparser.parse_args(plugin_arg_parts)
|
122
|
+
except SystemExit:
|
123
|
+
return False
|
124
|
+
|
125
|
+
return attr(**vars(args))
|
126
|
+
else:
|
127
|
+
return attr
|
128
|
+
|
129
|
+
|
130
|
+
class TargetComparison:
|
131
|
+
"""This class wraps functionality that for two given targets can identify similarities and differences between them.
|
132
|
+
Currently supports differentiating between the target filesystems, and between plugin outputs."""
|
133
|
+
|
134
|
+
def __init__(
|
135
|
+
self,
|
136
|
+
src_target: Target,
|
137
|
+
dst_target: Target,
|
138
|
+
deep: bool = False,
|
139
|
+
file_limit: int = FILE_LIMIT,
|
140
|
+
):
|
141
|
+
self.src_target = src_target
|
142
|
+
self.dst_target = dst_target
|
143
|
+
self.deep = deep
|
144
|
+
self.file_limit = file_limit
|
145
|
+
|
146
|
+
def scandir(self, path: str) -> DirectoryDifferential:
|
147
|
+
"""Scan a given directory for files that have been unchanged, modified, created or deleted from one target to
|
148
|
+
the next. Add these results (as well as subdirectories) to a DirectoryDifferential object."""
|
149
|
+
unchanged = []
|
150
|
+
modified = []
|
151
|
+
exists_as_directory_src = self.src_target.fs.exists(path) and self.src_target.fs.get(path).is_dir()
|
152
|
+
exists_as_directory_dst = self.dst_target.fs.exists(path) and self.dst_target.fs.get(path).is_dir()
|
153
|
+
|
154
|
+
if not (exists_as_directory_src and exists_as_directory_dst):
|
155
|
+
if exists_as_directory_src:
|
156
|
+
# Path only exists on src target, hence all entries can be considered 'deleted'
|
157
|
+
entries = list(self.src_target.fs.scandir(path))
|
158
|
+
return DirectoryDifferential(path, deleted=entries)
|
159
|
+
elif exists_as_directory_dst:
|
160
|
+
# Path only exists on dst target, hence all entries can be considered 'created'
|
161
|
+
entries = list(self.dst_target.fs.scandir(path))
|
162
|
+
return DirectoryDifferential(path, created=entries)
|
163
|
+
raise ValueError(f"{path} is not a directory on either the source or destination target!")
|
164
|
+
|
165
|
+
src_target_entries = list(self.src_target.fs.scandir(path))
|
166
|
+
src_target_children_paths = set(entry.path for entry in src_target_entries)
|
167
|
+
|
168
|
+
dst_target_entries = list(self.dst_target.fs.scandir(path))
|
169
|
+
dst_target_children_paths = set(entry.path for entry in dst_target_entries)
|
170
|
+
|
171
|
+
paths_only_on_src_target = src_target_children_paths - dst_target_children_paths
|
172
|
+
paths_only_on_dst_target = dst_target_children_paths - src_target_children_paths
|
173
|
+
|
174
|
+
deleted = [entry for entry in src_target_entries if entry.path in paths_only_on_src_target]
|
175
|
+
created = [entry for entry in dst_target_entries if entry.path in paths_only_on_dst_target]
|
176
|
+
|
177
|
+
paths_on_both = src_target_children_paths.intersection(dst_target_children_paths)
|
178
|
+
entry_pairs = []
|
179
|
+
|
180
|
+
for dst_entry in dst_target_entries:
|
181
|
+
if dst_entry.path not in paths_on_both:
|
182
|
+
continue
|
183
|
+
src_entry = next((entry for entry in src_target_entries if entry.path == dst_entry.path), None)
|
184
|
+
entry_pairs.append((src_entry, dst_entry))
|
185
|
+
|
186
|
+
for entry_pair in entry_pairs:
|
187
|
+
src_entry, dst_entry = entry_pair
|
188
|
+
entry_path = src_entry.path
|
189
|
+
|
190
|
+
# It's possible that there is an entry, but upon trying to retrieve its stats / content, we get a
|
191
|
+
# FileNotFoundError. We account for this by wrapping both stat retrievals in a try except
|
192
|
+
src_target_notfound = False
|
193
|
+
dst_target_notfound = False
|
194
|
+
src_target_isdir = None
|
195
|
+
dst_target_isdir = None
|
196
|
+
|
197
|
+
try:
|
198
|
+
src_target_stat = src_entry.stat()
|
199
|
+
src_target_isdir = src_entry.is_dir()
|
200
|
+
except FileNotFoundError:
|
201
|
+
src_target_notfound = True
|
202
|
+
|
203
|
+
try:
|
204
|
+
dst_target_stat = dst_entry.stat()
|
205
|
+
dst_target_isdir = dst_entry.is_dir()
|
206
|
+
except FileNotFoundError:
|
207
|
+
dst_target_notfound = True
|
208
|
+
|
209
|
+
if src_target_notfound or dst_target_notfound:
|
210
|
+
if src_target_notfound and not dst_target_notfound:
|
211
|
+
created.append(dst_entry)
|
212
|
+
elif dst_target_notfound and not src_target_notfound:
|
213
|
+
deleted.append(src_entry)
|
214
|
+
else:
|
215
|
+
# Not found on both
|
216
|
+
unchanged.append(src_entry)
|
217
|
+
# We can't continue as we cannot access the stats (or buffer)
|
218
|
+
continue
|
219
|
+
|
220
|
+
if src_target_isdir or dst_target_isdir:
|
221
|
+
if src_target_isdir == dst_target_isdir:
|
222
|
+
unchanged.append(src_entry)
|
223
|
+
else:
|
224
|
+
# Went from a file to a dir, or from a dir to a file. Either way, we consider the source entry
|
225
|
+
# 'deleted' and the dst entry 'Created'
|
226
|
+
deleted.append(src_entry)
|
227
|
+
created.append(dst_entry)
|
228
|
+
continue
|
229
|
+
|
230
|
+
if self.deep is False and likely_unchanged(src_target_stat, dst_target_stat):
|
231
|
+
unchanged.append(src_entry)
|
232
|
+
continue
|
233
|
+
|
234
|
+
# If we get here, we have two files that we need to compare contents of
|
235
|
+
src_fh = src_entry.open()
|
236
|
+
dst_fh = dst_entry.open()
|
237
|
+
|
238
|
+
while True:
|
239
|
+
chunk_a = src_fh.read(BLOCK_SIZE)
|
240
|
+
chunk_b = dst_fh.read(BLOCK_SIZE)
|
241
|
+
if chunk_a != chunk_b:
|
242
|
+
# We immediately break after discovering a difference in file contents
|
243
|
+
# This means that we won't return a full diff of the file, merely the first block where a difference
|
244
|
+
# is observed
|
245
|
+
content_difference = list(diff_bytes(unified_diff, [chunk_a], [chunk_b]))
|
246
|
+
differential_entry = DifferentialEntry(
|
247
|
+
entry_path,
|
248
|
+
src_entry.name,
|
249
|
+
src_entry,
|
250
|
+
dst_entry,
|
251
|
+
content_difference,
|
252
|
+
)
|
253
|
+
modified.append(differential_entry)
|
254
|
+
break
|
255
|
+
|
256
|
+
if src_fh.tell() > self.file_limit:
|
257
|
+
unchanged.append(src_entry)
|
258
|
+
break
|
259
|
+
|
260
|
+
if len(chunk_a) == 0:
|
261
|
+
# End of file
|
262
|
+
unchanged.append(src_entry)
|
263
|
+
break
|
264
|
+
|
265
|
+
return DirectoryDifferential(path, unchanged, created, modified, deleted)
|
266
|
+
|
267
|
+
def walkdir(
|
268
|
+
self,
|
269
|
+
path: str,
|
270
|
+
exclude: list[str] | str | None = None,
|
271
|
+
already_iterated: list[str] = None,
|
272
|
+
) -> Iterator[DirectoryDifferential]:
|
273
|
+
"""Recursively iterate directories and yield DirectoryDifferentials."""
|
274
|
+
if already_iterated is None:
|
275
|
+
already_iterated = []
|
276
|
+
|
277
|
+
if path in already_iterated:
|
278
|
+
return
|
279
|
+
|
280
|
+
if exclude is not None and not isinstance(exclude, list):
|
281
|
+
exclude = [exclude]
|
282
|
+
|
283
|
+
already_iterated.append(path)
|
284
|
+
|
285
|
+
diff = self.scandir(path)
|
286
|
+
yield diff
|
287
|
+
|
288
|
+
subentries = diff.created + diff.unchanged + diff.deleted
|
289
|
+
subdirectories = [entry for entry in subentries if entry.is_dir()]
|
290
|
+
# Check if the scandir lead to the discovery of new directories that we have to scan for differentials
|
291
|
+
# Directories are always in 'unchanged'
|
292
|
+
for subdirectory in subdirectories:
|
293
|
+
if subdirectory in already_iterated:
|
294
|
+
continue
|
295
|
+
|
296
|
+
# Right-pad with a '/'
|
297
|
+
subdirectory_path = subdirectory.path if subdirectory.path.endswith("/") else subdirectory.path + "/"
|
298
|
+
if exclude:
|
299
|
+
match = next((pattern for pattern in exclude if fnmatch(subdirectory_path, pattern)), None)
|
300
|
+
if match:
|
301
|
+
continue
|
302
|
+
yield from self.walkdir(subdirectory.path, exclude, already_iterated)
|
303
|
+
|
304
|
+
def differentiate_plugin_outputs(
|
305
|
+
self, plugin_name: str, plugin_arg_parts: list[str], only_changed: bool = False
|
306
|
+
) -> Iterator[Record]:
|
307
|
+
"""Run a plugin on the source and destination targets and yield RecordUnchanged, RecordCreated and RecordDeleted
|
308
|
+
records. There is no equivalent for the FileModifiedRecord. For files and directories, we can use the path to
|
309
|
+
reliably track changes from one target to the next. There is no equivalent for plugin outputs, so we just assume
|
310
|
+
that all records are either deleted (only on src), created (only on dst) or unchanged (on both)."""
|
311
|
+
with ignore_fields_for_comparison(["_generated", "_source", "hostname", "domain"]):
|
312
|
+
src_records = set(get_plugin_output_records(plugin_name, plugin_arg_parts, self.src_target))
|
313
|
+
src_records_seen = set()
|
314
|
+
|
315
|
+
for dst_record in get_plugin_output_records(plugin_name, plugin_arg_parts, self.dst_target):
|
316
|
+
if dst_record in src_records:
|
317
|
+
src_records_seen.add(dst_record)
|
318
|
+
if not only_changed:
|
319
|
+
yield RecordUnchangedRecord(
|
320
|
+
src_target=self.src_target.path, dst_target=self.dst_target.path, record=dst_record
|
321
|
+
)
|
322
|
+
else:
|
323
|
+
yield RecordCreatedRecord(
|
324
|
+
src_target=self.src_target.path, dst_target=self.dst_target.path, record=dst_record
|
325
|
+
)
|
326
|
+
for record in src_records - src_records_seen:
|
327
|
+
yield RecordDeletedRecord(
|
328
|
+
src_target=self.src_target.path, dst_target=self.dst_target.path, record=record
|
329
|
+
)
|
330
|
+
|
331
|
+
|
332
|
+
class DifferentialCli(ExtendedCmd):
|
333
|
+
"""CLI for browsing the differential between two or more targets."""
|
334
|
+
|
335
|
+
doc_header_prefix = "target-diff\n" "==========\n"
|
336
|
+
doc_header_suffix = "\n\nDocumented commands (type help <topic>):"
|
337
|
+
doc_header_multiple_targets = "Use 'list', 'prev' and 'next' to list and select targets to differentiate between."
|
338
|
+
|
339
|
+
def __init__(self, *targets: tuple[Target], deep: bool = False, limit: int = FILE_LIMIT):
|
340
|
+
self.targets = targets
|
341
|
+
self.deep = deep
|
342
|
+
self.limit = limit
|
343
|
+
|
344
|
+
self.src_index = 0
|
345
|
+
self.dst_index = 0
|
346
|
+
self.comparison: TargetComparison = None
|
347
|
+
|
348
|
+
self.cwd = "/"
|
349
|
+
self.alt_separator = "/"
|
350
|
+
|
351
|
+
doc_header_middle = self.doc_header_multiple_targets if len(targets) > 2 else ""
|
352
|
+
self.doc_header = self.doc_header_prefix + doc_header_middle + self.doc_header_suffix
|
353
|
+
|
354
|
+
self._select_source_and_dest(0, 1)
|
355
|
+
|
356
|
+
start_in_cyber = any(target.props.get("cyber") for target in self.targets)
|
357
|
+
super().__init__(start_in_cyber)
|
358
|
+
|
359
|
+
if len(self.targets) > 2:
|
360
|
+
# Some help may be nice if you are diffing more than 2 targets at once
|
361
|
+
self.do_help(arg=None)
|
362
|
+
|
363
|
+
@property
|
364
|
+
def src_target(self) -> Target:
|
365
|
+
return self.targets[self.src_index]
|
366
|
+
|
367
|
+
@property
|
368
|
+
def dst_target(self) -> Target:
|
369
|
+
return self.targets[self.dst_index]
|
370
|
+
|
371
|
+
@property
|
372
|
+
def prompt(self) -> str:
|
373
|
+
if self.comparison.src_target.name != self.comparison.dst_target.name:
|
374
|
+
prompt_base = f"{self.comparison.src_target.name}/{self.comparison.dst_target.name}"
|
375
|
+
else:
|
376
|
+
prompt_base = self.comparison.src_target.name
|
377
|
+
|
378
|
+
if os.getenv("NO_COLOR"):
|
379
|
+
suffix = f"{prompt_base}:{self.cwd}$ "
|
380
|
+
else:
|
381
|
+
suffix = f"\x1b[1;32m{prompt_base}\x1b[0m:\x1b[1;34m{self.cwd}\x1b[0m$ "
|
382
|
+
|
383
|
+
if len(self.targets) <= 2:
|
384
|
+
return f"(diff) {suffix}"
|
385
|
+
|
386
|
+
chain_prefix = "[ "
|
387
|
+
for i in range(len(self.targets)):
|
388
|
+
char = "O " if i == self.src_index or i == self.dst_index else ". "
|
389
|
+
chain_prefix += char
|
390
|
+
chain_prefix += "] "
|
391
|
+
|
392
|
+
return f"(diff) {chain_prefix}{suffix}"
|
393
|
+
|
394
|
+
def _select_source_and_dest(self, src_index: int, dst_index: int) -> None:
|
395
|
+
"""Set local variables according to newly selected source and destination index, and re-instatiate
|
396
|
+
TargetComparison."""
|
397
|
+
self.src_index = src_index
|
398
|
+
self.dst_index = dst_index
|
399
|
+
if not self.src_target.fs.exists(self.cwd) and not self.dst_target.fs.exists(self.cwd):
|
400
|
+
logging.warning("The current directory exists on neither of the selected targets.")
|
401
|
+
if self.src_target.fs.alt_separator != self.dst_target.fs.alt_separator:
|
402
|
+
raise NotImplementedError("No support for handling targets with different path separators")
|
403
|
+
|
404
|
+
self.alt_separator = self.src_target.fs.alt_separator
|
405
|
+
self.comparison = TargetComparison(self.src_target, self.dst_target, self.deep, self.limit)
|
406
|
+
|
407
|
+
def _annotate_differential(
|
408
|
+
self,
|
409
|
+
diff: DirectoryDifferential,
|
410
|
+
unchanged: bool = True,
|
411
|
+
created: bool = True,
|
412
|
+
modified: bool = True,
|
413
|
+
deleted: bool = True,
|
414
|
+
absolute: bool = False,
|
415
|
+
) -> list[tuple[fsutil.TargetPath | DifferentialEntry], str]:
|
416
|
+
"""Given a DirectoryDifferential instance, construct a list of tuples where the first element is a Filesystem /
|
417
|
+
DifferentialEntry and the second a color-formatted string."""
|
418
|
+
r = []
|
419
|
+
|
420
|
+
attr = "path" if absolute else "name"
|
421
|
+
if unchanged:
|
422
|
+
for entry in diff.unchanged:
|
423
|
+
color = "di" if entry.is_dir() else "fi"
|
424
|
+
r.append((entry, fmt_ls_colors(color, getattr(entry, attr))))
|
425
|
+
|
426
|
+
if created:
|
427
|
+
for entry in diff.created:
|
428
|
+
color = "tw" if entry.is_dir() else "ex"
|
429
|
+
r.append((entry, fmt_ls_colors(color, f"{getattr(entry, attr)} (created)")))
|
430
|
+
|
431
|
+
if modified:
|
432
|
+
for entry in diff.modified:
|
433
|
+
# Modified entries are always files
|
434
|
+
r.append((entry, fmt_ls_colors("ln", f"{getattr(entry, attr)} (modified)")))
|
435
|
+
if deleted:
|
436
|
+
for entry in diff.deleted:
|
437
|
+
color = "su" if entry.is_dir() else "or"
|
438
|
+
r.append((entry, fmt_ls_colors(color, f"{getattr(entry, attr)} (deleted)")))
|
439
|
+
|
440
|
+
r.sort(key=lambda e: e[0].name)
|
441
|
+
return r
|
442
|
+
|
443
|
+
def _targets_with_directory(self, path: str, warn_when_incomplete: bool = False) -> int:
|
444
|
+
"""Return whether a given path is an existing directory for neither, one of, or both of the targets being
|
445
|
+
compared. Optionally log a warning if the directory only exists on one of the two targets."""
|
446
|
+
src_has_dir = False
|
447
|
+
dst_has_dir = False
|
448
|
+
try:
|
449
|
+
entry = self.comparison.src_target.fs.get(path)
|
450
|
+
src_has_dir = entry.is_dir()
|
451
|
+
except FileNotFoundError:
|
452
|
+
pass
|
453
|
+
try:
|
454
|
+
entry = self.comparison.dst_target.fs.get(path)
|
455
|
+
dst_has_dir = entry.is_dir()
|
456
|
+
except FileNotFoundError:
|
457
|
+
pass
|
458
|
+
|
459
|
+
if (src_has_dir is False or dst_has_dir is False) and warn_when_incomplete:
|
460
|
+
if src_has_dir != dst_has_dir:
|
461
|
+
target_with_dir = self.comparison.src_target if src_has_dir else self.comparison.dst_target
|
462
|
+
log.warning("'%s' is only a valid path on '%s'.", path, target_with_dir)
|
463
|
+
else:
|
464
|
+
log.warning("'%s' is not a valid path on either target.", path)
|
465
|
+
return int(src_has_dir) + int(dst_has_dir)
|
466
|
+
|
467
|
+
def _write_entry_contents_to_stdout(self, entry: FilesystemEntry, stdout: TextIO) -> bool:
|
468
|
+
"""Copy the contents of a Filesystementry to stdout."""
|
469
|
+
stdout = stdout.buffer
|
470
|
+
fh = entry.open()
|
471
|
+
shutil.copyfileobj(fh, stdout)
|
472
|
+
stdout.flush()
|
473
|
+
print("")
|
474
|
+
return False
|
475
|
+
|
476
|
+
def completedefault(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
|
477
|
+
"""Autocomplete based on files / directories found in the current path."""
|
478
|
+
path = line[:begidx].rsplit(" ")[-1]
|
479
|
+
textlower = text.lower()
|
480
|
+
|
481
|
+
path = fsutil.abspath(path, cwd=str(self.cwd), alt_separator=self.alt_separator)
|
482
|
+
|
483
|
+
diff = self.comparison.scandir(path)
|
484
|
+
items = [
|
485
|
+
(item.entry.is_dir(), item.name) for group in [diff.created, diff.unchanged, diff.deleted] for item in group
|
486
|
+
]
|
487
|
+
items += [
|
488
|
+
(item.src_target_entry.is_dir() and item.dst_target_entry.is_dir(), item.name) for item in diff.modified
|
489
|
+
]
|
490
|
+
suggestions = []
|
491
|
+
for is_dir, fname in items:
|
492
|
+
if not fname.lower().startswith(textlower):
|
493
|
+
continue
|
494
|
+
|
495
|
+
# Add a trailing slash to directories, to allow for easier traversal of the filesystem
|
496
|
+
suggestion = f"{fname}/" if is_dir else fname
|
497
|
+
suggestions.append(suggestion)
|
498
|
+
return suggestions
|
499
|
+
|
500
|
+
def do_list(self, line: str) -> bool:
|
501
|
+
"""Prints a list of targets to differentiate between. Useful when differentiating between three or more
|
502
|
+
targets. Looks quite bad on small terminal screens."""
|
503
|
+
columns = ["#", "Name", "Path", "From", "To"]
|
504
|
+
|
505
|
+
rows = []
|
506
|
+
|
507
|
+
for i, target in enumerate(self.targets):
|
508
|
+
rows.append(
|
509
|
+
[
|
510
|
+
f"{i:2d}",
|
511
|
+
target.name,
|
512
|
+
str(target.path),
|
513
|
+
"**" if i == self.src_index else "",
|
514
|
+
"**" if i == self.dst_index else "",
|
515
|
+
]
|
516
|
+
)
|
517
|
+
|
518
|
+
longest_name = max(len(row[1]) + 4 for row in rows)
|
519
|
+
longest_path = max(len(row[2]) + 4 for row in rows)
|
520
|
+
name_len = max(10, longest_name)
|
521
|
+
path_len = max(15, longest_path)
|
522
|
+
|
523
|
+
fmt = "{:^5} | {:<" + str(name_len) + "} | {:<" + str(path_len) + "} | {:^6} | {:^6} |"
|
524
|
+
print(fmt.format(*columns))
|
525
|
+
print("")
|
526
|
+
for row in rows:
|
527
|
+
print(fmt.format(*row))
|
528
|
+
print("")
|
529
|
+
return False
|
530
|
+
|
531
|
+
@alias("prev")
|
532
|
+
@arg("-a", "--absolute", action="store_true", help="Only move the destination target one position back.")
|
533
|
+
def cmd_previous(self, args: argparse.Namespace, line: str) -> bool:
|
534
|
+
"""When three or more targets are available, move the 'comparison window' one position back."""
|
535
|
+
src_index = self.src_index - 1 if not args.absolute else 0
|
536
|
+
if src_index < 0:
|
537
|
+
src_index = len(self.targets) - 1
|
538
|
+
dst_index = self.dst_index - 1
|
539
|
+
if dst_index < 0:
|
540
|
+
dst_index = len(self.targets) - 1
|
541
|
+
if dst_index <= src_index:
|
542
|
+
src_index, dst_index = dst_index, src_index
|
543
|
+
self._select_source_and_dest(src_index, dst_index)
|
544
|
+
return False
|
545
|
+
|
546
|
+
@arg("-a", "--absolute", action="store_true", help="Only move the destination target one position forward.")
|
547
|
+
def cmd_next(self, args: argparse.Namespace, line: str) -> bool:
|
548
|
+
"""When three or more targets are available, move the 'comparison window' one position forward."""
|
549
|
+
dst_index = (self.dst_index + 1) % len(self.targets)
|
550
|
+
src_index = self.src_index + 1 % len(self.targets) if not args.absolute else 0
|
551
|
+
|
552
|
+
if dst_index <= src_index:
|
553
|
+
src_index, dst_index = dst_index, src_index
|
554
|
+
self._select_source_and_dest(src_index, dst_index)
|
555
|
+
return False
|
556
|
+
|
557
|
+
def do_cd(self, path: str) -> bool:
|
558
|
+
"""Change directory to the given path."""
|
559
|
+
path = fsutil.abspath(path, cwd=str(self.cwd), alt_separator=self.alt_separator)
|
560
|
+
if self._targets_with_directory(path, warn_when_incomplete=True) != 0:
|
561
|
+
self.cwd = path
|
562
|
+
return False
|
563
|
+
|
564
|
+
@arg("path", nargs="?")
|
565
|
+
@arg("-l", action="store_true")
|
566
|
+
@arg("-a", "--all", action="store_true") # ignored but included for proper argument parsing
|
567
|
+
@arg("-h", "--human-readable", action="store_true")
|
568
|
+
def cmd_ls(self, args: argparse.Namespace, stdout: TextIO) -> bool:
|
569
|
+
"""List contents of a directory for two targets."""
|
570
|
+
path = args.path if args.path is not None else self.cwd
|
571
|
+
diff = self.comparison.scandir(path)
|
572
|
+
results = self._annotate_differential(diff)
|
573
|
+
if not args.l:
|
574
|
+
print("\n".join([name for _, name in results]), file=stdout)
|
575
|
+
else:
|
576
|
+
for entry, name in results:
|
577
|
+
if not isinstance(entry, DifferentialEntry):
|
578
|
+
print_extensive_file_stat_listing(stdout, name, entry, human_readable=args.human_readable)
|
579
|
+
else:
|
580
|
+
# We have to choose for which version of this file we are going to print detailed info. The
|
581
|
+
# destination target seems to make the most sense: it is likely newer
|
582
|
+
print_extensive_file_stat_listing(
|
583
|
+
stdout, name, entry.dst_target_entry, human_readable=args.human_readable
|
584
|
+
)
|
585
|
+
return False
|
586
|
+
|
587
|
+
@arg("path", nargs="?")
|
588
|
+
def cmd_cat(self, args: argparse.Namespace, stdout: TextIO) -> bool:
|
589
|
+
"""Output the contents of a file."""
|
590
|
+
base_dir, _, name = args.path.rpartition("/")
|
591
|
+
if not base_dir:
|
592
|
+
base_dir = self.cwd
|
593
|
+
|
594
|
+
directory_differential = self.comparison.scandir(base_dir)
|
595
|
+
entry = None
|
596
|
+
for entry in directory_differential.unchanged:
|
597
|
+
if entry.name == name:
|
598
|
+
return self._write_entry_contents_to_stdout(entry, stdout)
|
599
|
+
for entry in directory_differential.created:
|
600
|
+
if entry.name == name:
|
601
|
+
log.warning("'%s' is only present on '%s'.", entry.name, self.comparison.dst_target.path)
|
602
|
+
return self._write_entry_contents_to_stdout(entry, stdout)
|
603
|
+
for entry in directory_differential.deleted:
|
604
|
+
if entry.name == name:
|
605
|
+
log.warning("'%s' is only present on '%s'.", entry.name, self.comparison.src_target.path)
|
606
|
+
return self._write_entry_contents_to_stdout(entry, stdout)
|
607
|
+
for entry in directory_differential.modified:
|
608
|
+
if entry.name == name:
|
609
|
+
log.warning(
|
610
|
+
"Concatinating latest version of '%s'. Use 'diff' to differentiate between target versions.",
|
611
|
+
entry.name,
|
612
|
+
)
|
613
|
+
return self._write_entry_contents_to_stdout(entry.dst_target_entry, stdout)
|
614
|
+
print(f"File {name} not found.")
|
615
|
+
return False
|
616
|
+
|
617
|
+
@arg("path", nargs="?")
|
618
|
+
@arg("--hex", action="store_true", default=False)
|
619
|
+
def cmd_diff(self, args: argparse.Namespace, stdout: TextIO) -> bool:
|
620
|
+
"""Output the difference in file contents between two targets."""
|
621
|
+
stdout = stdout.buffer
|
622
|
+
base_dir, _, name = args.path.rpartition("/")
|
623
|
+
if not base_dir:
|
624
|
+
base_dir = self.cwd
|
625
|
+
directory_differential = self.comparison.scandir(base_dir)
|
626
|
+
for entry in directory_differential.modified:
|
627
|
+
if entry.name == name:
|
628
|
+
if args.hex:
|
629
|
+
primary_fh_lines = [
|
630
|
+
line.encode()
|
631
|
+
for line in hexdump(entry.src_target_entry.open().read(), output="string").split("\n")
|
632
|
+
]
|
633
|
+
secondary_fh_lines = [
|
634
|
+
line.encode()
|
635
|
+
for line in hexdump(entry.dst_target_entry.open().read(), output="string").split("\n")
|
636
|
+
]
|
637
|
+
else:
|
638
|
+
primary_fh_lines = entry.src_target_entry.open().readlines()
|
639
|
+
secondary_fh_lines = entry.dst_target_entry.open().readlines()
|
640
|
+
|
641
|
+
for chunk in diff_bytes(unified_diff, primary_fh_lines, secondary_fh_lines):
|
642
|
+
if chunk.startswith(b"@@"):
|
643
|
+
chunk = fmt_ls_colors("ln", chunk.decode()).encode()
|
644
|
+
elif chunk.startswith(b"+"):
|
645
|
+
chunk = fmt_ls_colors("ex", chunk.decode()).encode()
|
646
|
+
elif chunk.startswith(b"-"):
|
647
|
+
chunk = fmt_ls_colors("or", chunk.decode()).encode()
|
648
|
+
|
649
|
+
shutil.copyfileobj(BytesIO(chunk), stdout)
|
650
|
+
|
651
|
+
if args.hex:
|
652
|
+
stdout.write(b"\n")
|
653
|
+
|
654
|
+
stdout.flush()
|
655
|
+
|
656
|
+
print("")
|
657
|
+
return False
|
658
|
+
|
659
|
+
# Check if this file is even present on one of the targets
|
660
|
+
files = directory_differential.unchanged + directory_differential.created + directory_differential.deleted
|
661
|
+
match = next((entry for entry in files if entry.name == name), None)
|
662
|
+
if match is None:
|
663
|
+
print(f"File {name} not found.")
|
664
|
+
else:
|
665
|
+
print(f"No two versions available for {name} to differentiate between.")
|
666
|
+
return False
|
667
|
+
|
668
|
+
@arg("path", nargs="?")
|
669
|
+
@alias("xxd")
|
670
|
+
def cmd_hexdump(self, args: argparse.Namespace, stdout: TextIO) -> bool:
|
671
|
+
"""Output difference of the given file between targets in hexdump."""
|
672
|
+
setattr(args, "hex", True)
|
673
|
+
return self.cmd_diff(args, stdout)
|
674
|
+
|
675
|
+
@arg("index", type=str)
|
676
|
+
@arg("type", choices=["src", "dst"])
|
677
|
+
def cmd_set(self, args: argparse.Namespace, stdout: TextIO) -> bool:
|
678
|
+
"""Change either the source or destination target for differentiation. Index can be given relative (when
|
679
|
+
prefixed with '+' or '-', e.g. "set dst +1") or absolute (e.g. set src 0)."""
|
680
|
+
index = args.index.strip()
|
681
|
+
pos = self.src_index if args.type == "src" else self.dst_index
|
682
|
+
|
683
|
+
if index.startswith(("+", "-")):
|
684
|
+
multiplier = 1 if index[0] == "+" else -1
|
685
|
+
index = index[1:].strip()
|
686
|
+
if not index.isdigit():
|
687
|
+
return False
|
688
|
+
pos += int(index) * multiplier
|
689
|
+
elif index.isdigit():
|
690
|
+
pos = int(index)
|
691
|
+
else:
|
692
|
+
raise ValueError(f"Could not set {args.type} to {index}.")
|
693
|
+
if args.type == "src":
|
694
|
+
self._select_source_and_dest(pos, self.dst_index)
|
695
|
+
else:
|
696
|
+
self._select_source_and_dest(self.src_index, pos)
|
697
|
+
return False
|
698
|
+
|
699
|
+
@arg("target", choices=["src", "dst"])
|
700
|
+
def cmd_enter(self, args: argparse.Namespace, stdout: TextIO) -> bool:
|
701
|
+
"""Open a subshell for the source or destination target."""
|
702
|
+
target = self.src_target if args.target == "src" else self.dst_target
|
703
|
+
cli = TargetCli(target)
|
704
|
+
if target.fs.exists(self.cwd):
|
705
|
+
cli.chdir(self.cwd)
|
706
|
+
|
707
|
+
# Cyber doesn't work well with subshells
|
708
|
+
cli.cyber = False
|
709
|
+
run_cli(cli)
|
710
|
+
return False
|
711
|
+
|
712
|
+
@arg("path", nargs="?")
|
713
|
+
@arg("-name", default="*")
|
714
|
+
@arg("-iname")
|
715
|
+
@arg("-c", "--created", action="store_true")
|
716
|
+
@arg("-m", "--modified", action="store_true")
|
717
|
+
@arg("-d", "--deleted", action="store_true")
|
718
|
+
@arg("-u", "--unchanged", action="store_true")
|
719
|
+
def cmd_find(self, args: argparse.Namespace, stdout: TextIO) -> bool:
|
720
|
+
"""Search for files in a directory hierarchy."""
|
721
|
+
path = fsutil.abspath(args.path, cwd=str(self.cwd), alt_separator=self.comparison.src_target.fs.alt_separator)
|
722
|
+
if not path:
|
723
|
+
return False
|
724
|
+
|
725
|
+
if self._targets_with_directory(path, warn_when_incomplete=True) == 0:
|
726
|
+
return False
|
727
|
+
|
728
|
+
if args.iname:
|
729
|
+
pattern = re.compile(translate(args.iname), re.IGNORECASE)
|
730
|
+
else:
|
731
|
+
pattern = re.compile(translate(args.name))
|
732
|
+
|
733
|
+
include_all_changes = not (args.created or args.modified or args.deleted or args.unchanged)
|
734
|
+
|
735
|
+
include_unchanged = args.unchanged
|
736
|
+
include_modified = include_all_changes or args.modified
|
737
|
+
include_created = include_all_changes or args.created
|
738
|
+
include_deleted = include_all_changes or args.deleted
|
739
|
+
|
740
|
+
for differential in self.comparison.walkdir(path):
|
741
|
+
for entry, line in self._annotate_differential(
|
742
|
+
differential, include_unchanged, include_created, include_modified, include_deleted, absolute=True
|
743
|
+
):
|
744
|
+
if not pattern.match(entry.name):
|
745
|
+
continue
|
746
|
+
|
747
|
+
print(line, file=stdout)
|
748
|
+
|
749
|
+
return False
|
750
|
+
|
751
|
+
def do_plugin(self, line: str) -> bool:
|
752
|
+
"""Yield RecordCreated, RecordUnchanged and RecordDeleted Records by comparing plugin outputs for two
|
753
|
+
targets."""
|
754
|
+
argparts = arg_str_to_arg_list(line)
|
755
|
+
pipeparts = []
|
756
|
+
if "|" in argparts:
|
757
|
+
pipeidx = argparts.index("|")
|
758
|
+
argparts, pipeparts = argparts[:pipeidx], argparts[pipeidx + 1 :]
|
759
|
+
|
760
|
+
if len(argparts) < 1:
|
761
|
+
raise ValueError("Provide a plugin name, and optionally parameters to pass to the plugin.")
|
762
|
+
|
763
|
+
plugin = argparts.pop(0)
|
764
|
+
|
765
|
+
iterator = self.comparison.differentiate_plugin_outputs(plugin, argparts)
|
766
|
+
if pipeparts:
|
767
|
+
try:
|
768
|
+
with build_pipe_stdout(pipeparts) as pipe_stdin:
|
769
|
+
rs = RecordOutput(pipe_stdin.buffer)
|
770
|
+
for record in iterator:
|
771
|
+
rs.write(record)
|
772
|
+
except OSError as e:
|
773
|
+
# in case of a failure in a subprocess
|
774
|
+
print(e)
|
775
|
+
else:
|
776
|
+
for record in iterator:
|
777
|
+
print(record, file=sys.stdout)
|
778
|
+
|
779
|
+
return False
|
780
|
+
|
781
|
+
def do_python(self, line: str) -> bool:
|
782
|
+
"""drop into a Python shell."""
|
783
|
+
python_shell(list(self.targets))
|
784
|
+
return False
|
785
|
+
|
786
|
+
|
787
|
+
def make_target_pairs(targets: tuple[Target], absolute: bool = False) -> list[tuple[Target, Target]]:
|
788
|
+
"""Make 'pairs' of targets that we are going to compare against one another. A list of targets can be treated in two
|
789
|
+
ways: compare every target with the one that came before it, or compare all targets against a 'base' target (which
|
790
|
+
has to be supplied as initial target in the list)."""
|
791
|
+
target_pairs = []
|
792
|
+
|
793
|
+
previous_target = targets[0]
|
794
|
+
for target in targets[1:]:
|
795
|
+
target_pairs.append((previous_target, target))
|
796
|
+
if not absolute:
|
797
|
+
# The next target should be compared against the one we just opened
|
798
|
+
previous_target = target
|
799
|
+
return target_pairs
|
800
|
+
|
801
|
+
|
802
|
+
def differentiate_target_filesystems(
|
803
|
+
*targets: tuple[Target],
|
804
|
+
deep: bool = False,
|
805
|
+
limit: int = FILE_LIMIT,
|
806
|
+
absolute: bool = False,
|
807
|
+
include: list[str] = None,
|
808
|
+
exclude: list[str] = None,
|
809
|
+
) -> Iterator[Record]:
|
810
|
+
"""Given a list of targets, compare targets against one another and yield File[Created|Modified|Deleted]Records
|
811
|
+
indicating the differences between them."""
|
812
|
+
|
813
|
+
for target_pair in make_target_pairs(targets, absolute):
|
814
|
+
# Unpack the tuple and initialize the comparison class
|
815
|
+
src_target, dst_target = target_pair
|
816
|
+
comparison = TargetComparison(src_target, dst_target, deep, limit)
|
817
|
+
|
818
|
+
paths = ["/"] if include is None else include
|
819
|
+
|
820
|
+
for path in paths:
|
821
|
+
for directory_diff in comparison.walkdir(path, exclude=exclude):
|
822
|
+
for creation_entry in directory_diff.created:
|
823
|
+
yield FileCreatedRecord(
|
824
|
+
path=creation_entry.path,
|
825
|
+
src_target=src_target.path,
|
826
|
+
dst_target=dst_target.path,
|
827
|
+
)
|
828
|
+
|
829
|
+
for deletion_entry in directory_diff.deleted:
|
830
|
+
yield FileDeletedRecord(
|
831
|
+
path=deletion_entry.path,
|
832
|
+
src_target=src_target.path,
|
833
|
+
dst_target=dst_target.path,
|
834
|
+
)
|
835
|
+
|
836
|
+
for entry_difference in directory_diff.modified:
|
837
|
+
yield FileModifiedRecord(
|
838
|
+
path=entry_difference.path,
|
839
|
+
diff=entry_difference.diff,
|
840
|
+
src_target=src_target.path,
|
841
|
+
dst_target=dst_target.path,
|
842
|
+
)
|
843
|
+
|
844
|
+
|
845
|
+
def differentiate_target_plugin_outputs(
|
846
|
+
*targets: tuple[Target], absolute: bool = False, only_changed: bool = False, plugin: str, plugin_args: str = ""
|
847
|
+
) -> Iterator[Record]:
|
848
|
+
"""Given a list of targets, yielding records indicating which records from this plugin are new, unmodified or
|
849
|
+
deleted."""
|
850
|
+
for target_pair in make_target_pairs(targets, absolute):
|
851
|
+
src_target, dst_target = target_pair
|
852
|
+
comparison = TargetComparison(src_target, dst_target)
|
853
|
+
yield from comparison.differentiate_plugin_outputs(plugin, plugin_args, only_changed)
|
854
|
+
|
855
|
+
|
856
|
+
@catch_sigpipe
|
857
|
+
def main() -> None:
|
858
|
+
help_formatter = argparse.ArgumentDefaultsHelpFormatter
|
859
|
+
parser = argparse.ArgumentParser(
|
860
|
+
description="target-diff",
|
861
|
+
fromfile_prefix_chars="@",
|
862
|
+
formatter_class=help_formatter,
|
863
|
+
)
|
864
|
+
|
865
|
+
parser.add_argument(
|
866
|
+
"-d",
|
867
|
+
"--deep",
|
868
|
+
action="store_true",
|
869
|
+
help="Compare file contents even if metadata suggests they have been left unchanged",
|
870
|
+
)
|
871
|
+
parser.add_argument(
|
872
|
+
"-l",
|
873
|
+
"--limit",
|
874
|
+
default=FILE_LIMIT,
|
875
|
+
type=int,
|
876
|
+
help="How many bytes to compare before assuming a file is left unchanged (0 for no limit)",
|
877
|
+
)
|
878
|
+
subparsers = parser.add_subparsers(help="Mode for differentiating targets", dest="mode", required=True)
|
879
|
+
|
880
|
+
shell_mode = subparsers.add_parser("shell", help="Open an interactive shell to compare two or more targets.")
|
881
|
+
shell_mode.add_argument("targets", metavar="TARGETS", nargs="+", help="Targets to differentiate between")
|
882
|
+
|
883
|
+
fs_mode = subparsers.add_parser("fs", help="Yield records about differences between target filesystems.")
|
884
|
+
fs_mode.add_argument("targets", metavar="TARGETS", nargs="+", help="Targets to differentiate between")
|
885
|
+
fs_mode.add_argument("-s", "--strings", action="store_true", help="print records as strings")
|
886
|
+
fs_mode.add_argument("-e", "--exclude", action="append", help="Path(s) on targets not to check for differences")
|
887
|
+
fs_mode.add_argument(
|
888
|
+
"-i",
|
889
|
+
"--include",
|
890
|
+
action="append",
|
891
|
+
help="Path(s) on targets to check for differences (all will be checked if left omitted)",
|
892
|
+
)
|
893
|
+
fs_mode.add_argument(
|
894
|
+
"-a",
|
895
|
+
"--absolute",
|
896
|
+
action="store_true",
|
897
|
+
help=(
|
898
|
+
"Treat every target as an absolute. The first given target is treated as the 'base' target to compare "
|
899
|
+
"subsequent targets against. If omitted, every target is treated as a 'delta' and compared against the "
|
900
|
+
"target that came before it."
|
901
|
+
),
|
902
|
+
)
|
903
|
+
|
904
|
+
query_mode = subparsers.add_parser("query", help="Differentiate plugin outputs between two or more targets.")
|
905
|
+
query_mode.add_argument("targets", metavar="TARGETS", nargs="+", help="Targets to differentiate between")
|
906
|
+
query_mode.add_argument("-s", "--strings", action="store_true", help="print records as strings")
|
907
|
+
query_mode.add_argument(
|
908
|
+
"-p",
|
909
|
+
"--parameters",
|
910
|
+
type=str,
|
911
|
+
required=False,
|
912
|
+
default="",
|
913
|
+
help="Parameters for the plugin",
|
914
|
+
)
|
915
|
+
query_mode.add_argument(
|
916
|
+
"-f",
|
917
|
+
"--plugin",
|
918
|
+
type=str,
|
919
|
+
required=True,
|
920
|
+
help="Function to execute",
|
921
|
+
)
|
922
|
+
query_mode.add_argument(
|
923
|
+
"-a",
|
924
|
+
"--absolute",
|
925
|
+
action="store_true",
|
926
|
+
help=(
|
927
|
+
"Treat every target as an absolute. The first given target is treated as the 'base' target to compare "
|
928
|
+
"subsequent targets against. If omitted, every target is treated as a 'delta' and compared against the "
|
929
|
+
"target that came before it."
|
930
|
+
),
|
931
|
+
)
|
932
|
+
query_mode.add_argument(
|
933
|
+
"--only-changed",
|
934
|
+
action="store_true",
|
935
|
+
help="Do not output unchanged records",
|
936
|
+
default=False,
|
937
|
+
)
|
938
|
+
|
939
|
+
configure_generic_arguments(parser)
|
940
|
+
|
941
|
+
args = parser.parse_args()
|
942
|
+
process_generic_arguments(args)
|
943
|
+
|
944
|
+
if len(args.targets) < 2:
|
945
|
+
print("At least two targets are required for target-diff.")
|
946
|
+
parser.exit(1)
|
947
|
+
|
948
|
+
target_list = [Target.open(path) for path in args.targets]
|
949
|
+
if args.mode == "shell":
|
950
|
+
cli = DifferentialCli(*target_list, deep=args.deep, limit=args.limit)
|
951
|
+
run_cli(cli)
|
952
|
+
else:
|
953
|
+
writer = record_output(args.strings)
|
954
|
+
if args.mode == "fs":
|
955
|
+
iterator = differentiate_target_filesystems(
|
956
|
+
*target_list,
|
957
|
+
deep=args.deep,
|
958
|
+
limit=args.limit,
|
959
|
+
absolute=args.absolute,
|
960
|
+
include=args.include,
|
961
|
+
exclude=args.exclude,
|
962
|
+
)
|
963
|
+
elif args.mode == "query":
|
964
|
+
if args.deep:
|
965
|
+
log.error("argument --deep is not available in target-diff query mode")
|
966
|
+
parser.exit(1)
|
967
|
+
|
968
|
+
if args.limit != FILE_LIMIT:
|
969
|
+
log.error("argument --limit is not available in target-diff query mode")
|
970
|
+
parser.exit(1)
|
971
|
+
|
972
|
+
iterator = differentiate_target_plugin_outputs(
|
973
|
+
*target_list,
|
974
|
+
absolute=args.absolute,
|
975
|
+
only_changed=args.only_changed,
|
976
|
+
plugin=args.plugin,
|
977
|
+
plugin_args=arg_str_to_arg_list(args.parameters),
|
978
|
+
)
|
979
|
+
|
980
|
+
try:
|
981
|
+
for record in iterator:
|
982
|
+
writer.write(record)
|
983
|
+
|
984
|
+
except Exception as e:
|
985
|
+
log.error(e)
|
986
|
+
parser.exit(1)
|
987
|
+
|
988
|
+
|
989
|
+
if __name__ == "__main__":
|
990
|
+
main()
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: dissect.target
|
3
|
-
Version: 3.21.
|
3
|
+
Version: 3.21.dev12
|
4
4
|
Summary: This module ties all other Dissect modules together, it provides a programming API and command line tools which allow easy access to various data sources inside disk images or file collections (a.k.a. targets)
|
5
5
|
Author-email: Dissect Team <dissect@fox-it.com>
|
6
6
|
License: Affero General Public License v3
|
@@ -360,6 +360,7 @@ dissect/target/plugins/os/windows/task_helpers/tasks_xml.py,sha256=fKwh9jtOP_gzW
|
|
360
360
|
dissect/target/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
361
361
|
dissect/target/tools/build_pluginlist.py,sha256=5fomcuMwsVzcnYx5Htf5f9lSwsLeUUvomLUXNA4t7m4,849
|
362
362
|
dissect/target/tools/dd.py,sha256=rTM-lgXxrYBpVAtJqFqAatDz45bLoD8-mFt_59Q3Lio,1928
|
363
|
+
dissect/target/tools/diff.py,sha256=0y6TgIYjZfQNhKXYB-LTen9EeMDh3AxvlzVLtKO5uIc,40976
|
363
364
|
dissect/target/tools/fs.py,sha256=3Ny8zoooVeeF7OUkQ0nxZVdEaQeU7vPRjDOYhz6XfRA,5385
|
364
365
|
dissect/target/tools/fsutils.py,sha256=q0t9gFwKHaPr2Ya-MN2o4LsYledde7kp2DZZTd8roIc,8314
|
365
366
|
dissect/target/tools/info.py,sha256=t2bWENeyaEh87ayE_brdKvz9kHAWOLqkKJcGixl6hGo,5725
|
@@ -382,10 +383,10 @@ dissect/target/volumes/luks.py,sha256=OmCMsw6rCUXG1_plnLVLTpsvE1n_6WtoRUGQbpmu1z
|
|
382
383
|
dissect/target/volumes/lvm.py,sha256=wwQVR9I3G9YzmY6UxFsH2Y4MXGBcKL9aayWGCDTiWMU,2269
|
383
384
|
dissect/target/volumes/md.py,sha256=7ShPtusuLGaIv27SvEETtgsuoQyAa4iAAeOR1NEaajI,1689
|
384
385
|
dissect/target/volumes/vmfs.py,sha256=-LoUbn9WNwTtLi_4K34uV_-wDw2W5hgaqxZNj4UmqAQ,1730
|
385
|
-
dissect.target-3.21.
|
386
|
-
dissect.target-3.21.
|
387
|
-
dissect.target-3.21.
|
388
|
-
dissect.target-3.21.
|
389
|
-
dissect.target-3.21.
|
390
|
-
dissect.target-3.21.
|
391
|
-
dissect.target-3.21.
|
386
|
+
dissect.target-3.21.dev12.dist-info/COPYRIGHT,sha256=m-9ih2RVhMiXHI2bf_oNSSgHgkeIvaYRVfKTwFbnJPA,301
|
387
|
+
dissect.target-3.21.dev12.dist-info/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
|
388
|
+
dissect.target-3.21.dev12.dist-info/METADATA,sha256=WsHf4dOaCQDZy208YPuFfTDBPnDozqhis8woCOZFRzw,13187
|
389
|
+
dissect.target-3.21.dev12.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
390
|
+
dissect.target-3.21.dev12.dist-info/entry_points.txt,sha256=yQwLCWUuzHgS6-sfCcRk66gAfoCfqXdCjqKjvhnQW8o,537
|
391
|
+
dissect.target-3.21.dev12.dist-info/top_level.txt,sha256=Mn-CQzEYsAbkxrUI0TnplHuXnGVKzxpDw_po_sXpvv4,8
|
392
|
+
dissect.target-3.21.dev12.dist-info/RECORD,,
|
{dissect.target-3.21.dev11.dist-info → dissect.target-3.21.dev12.dist-info}/entry_points.txt
RENAMED
@@ -1,6 +1,7 @@
|
|
1
1
|
[console_scripts]
|
2
2
|
target-build-pluginlist = dissect.target.tools.build_pluginlist:main
|
3
3
|
target-dd = dissect.target.tools.dd:main
|
4
|
+
target-diff = dissect.target.tools.diff:main
|
4
5
|
target-dump = dissect.target.tools.dump.run:main
|
5
6
|
target-fs = dissect.target.tools.fs:main
|
6
7
|
target-info = dissect.target.tools.info:main
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|