ipmg 2.1.1__tar.gz → 2.2.0__tar.gz
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.
- {ipmg-2.1.1/src/ipmg.egg-info → ipmg-2.2.0}/PKG-INFO +17 -1
- {ipmg-2.1.1 → ipmg-2.2.0}/README.md +16 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/pyproject.toml +1 -1
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/__init__.py +1 -1
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/cli/parser.py +12 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/infrastructure/incremental.py +213 -5
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/reporting/ui.py +2 -1
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/services/scan_service.py +61 -9
- {ipmg-2.1.1 → ipmg-2.2.0/src/ipmg.egg-info}/PKG-INFO +17 -1
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_incremental.py +163 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_parser.py +10 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/LICENSE +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/setup.cfg +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/__main__.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/cli/__init__.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/cli/commands.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/core/__init__.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/core/diff.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/core/discovery.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/core/engine.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/core/ping.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/core/portscan.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/core/security.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/exceptions.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/infrastructure/__init__.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/infrastructure/database.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/infrastructure/file_io.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/reporting/__init__.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/reporting/diff_report.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/reporting/frames.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/reporting/live.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/reporting/summary.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/services/__init__.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/services/history_service.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/utils/__init__.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/utils/helpers.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/__init__.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/app.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/db.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/manager.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/server.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/static/css/app.css +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/static/index.html +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/static/js/api.js +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/static/js/app.js +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/static/js/charts.js +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/static/js/demo.js +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg/web/static/js/views.js +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg.egg-info/SOURCES.txt +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg.egg-info/dependency_links.txt +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg.egg-info/entry_points.txt +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg.egg-info/requires.txt +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/src/ipmg.egg-info/top_level.txt +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_commands.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_database_history.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_diff.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_diff_report.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_discover.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_engine.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_file_io.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_history_service.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_live.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_ping.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_ping_command.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_portscan.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_scan_service.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_ui.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_utils.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_web_api.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_web_db.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_web_manager.py +0 -0
- {ipmg-2.1.1 → ipmg-2.2.0}/tests/test_web_server.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ipmg
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: IP Management & Ping Monitoring CLI Tool
|
|
5
5
|
Author: Sameer Alam
|
|
6
6
|
Maintainer-email: Sameer Alam <sameeralam3127@gmail.com>
|
|
@@ -542,6 +542,21 @@ default). A finished scan overwrites those files with the complete report, so
|
|
|
542
542
|
the file names and contents are the same as they always were. Use
|
|
543
543
|
`--no-incremental` to go back to writing only at the end.
|
|
544
544
|
|
|
545
|
+
**Pick up where an interrupted scan stopped.** `--resume` reads the hosts the
|
|
546
|
+
partial report already holds, scans only the rest, and finishes that same
|
|
547
|
+
report — same file names, same batch timestamp:
|
|
548
|
+
|
|
549
|
+
```bash
|
|
550
|
+
ipmg --input 10.0.0.0/16 --formats jsonl xlsx # Ctrl+C partway through
|
|
551
|
+
ipmg --input 10.0.0.0/16 --formats jsonl xlsx --resume
|
|
552
|
+
ipmg --input 10.0.0.0/16 --resume results_20260917_120000.csv
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
Without a path, `--resume` takes the newest report named after `--output`,
|
|
556
|
+
preferring `jsonl` or `csv` (current to the last host) over `json` or `xlsx`
|
|
557
|
+
(current to the last autosave). Hosts dropped from the target list since are
|
|
558
|
+
left out of the finished report.
|
|
559
|
+
|
|
545
560
|
**Open ports.** `Open Ports` is only populated when `--scan-ports` is set: for
|
|
546
561
|
each host that answers, IPMG probes a list of common TCP ports (SSH, HTTP,
|
|
547
562
|
HTTPS, RDP, SMB, FTP, SMTP, DNS, MSSQL, MySQL, PostgreSQL by default)
|
|
@@ -572,6 +587,7 @@ them — so piping IPMG into a file or a log gives you clean text.
|
|
|
572
587
|
| `--formats` | `xlsx` | One or more of `xlsx`, `csv`, `json`, `jsonl`, `md` |
|
|
573
588
|
| `--no-incremental` | off | Only write the report once the scan has finished |
|
|
574
589
|
| `--autosave` | `30` | How often a running scan re-saves `xlsx`, `json`, and `md` |
|
|
590
|
+
| `--resume` | off | Finish an interrupted scan from its partial report (newest one, or the path given) |
|
|
575
591
|
|
|
576
592
|
**Speed and accuracy**
|
|
577
593
|
|
|
@@ -495,6 +495,21 @@ default). A finished scan overwrites those files with the complete report, so
|
|
|
495
495
|
the file names and contents are the same as they always were. Use
|
|
496
496
|
`--no-incremental` to go back to writing only at the end.
|
|
497
497
|
|
|
498
|
+
**Pick up where an interrupted scan stopped.** `--resume` reads the hosts the
|
|
499
|
+
partial report already holds, scans only the rest, and finishes that same
|
|
500
|
+
report — same file names, same batch timestamp:
|
|
501
|
+
|
|
502
|
+
```bash
|
|
503
|
+
ipmg --input 10.0.0.0/16 --formats jsonl xlsx # Ctrl+C partway through
|
|
504
|
+
ipmg --input 10.0.0.0/16 --formats jsonl xlsx --resume
|
|
505
|
+
ipmg --input 10.0.0.0/16 --resume results_20260917_120000.csv
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
Without a path, `--resume` takes the newest report named after `--output`,
|
|
509
|
+
preferring `jsonl` or `csv` (current to the last host) over `json` or `xlsx`
|
|
510
|
+
(current to the last autosave). Hosts dropped from the target list since are
|
|
511
|
+
left out of the finished report.
|
|
512
|
+
|
|
498
513
|
**Open ports.** `Open Ports` is only populated when `--scan-ports` is set: for
|
|
499
514
|
each host that answers, IPMG probes a list of common TCP ports (SSH, HTTP,
|
|
500
515
|
HTTPS, RDP, SMB, FTP, SMTP, DNS, MSSQL, MySQL, PostgreSQL by default)
|
|
@@ -525,6 +540,7 @@ them — so piping IPMG into a file or a log gives you clean text.
|
|
|
525
540
|
| `--formats` | `xlsx` | One or more of `xlsx`, `csv`, `json`, `jsonl`, `md` |
|
|
526
541
|
| `--no-incremental` | off | Only write the report once the scan has finished |
|
|
527
542
|
| `--autosave` | `30` | How often a running scan re-saves `xlsx`, `json`, and `md` |
|
|
543
|
+
| `--resume` | off | Finish an interrupted scan from its partial report (newest one, or the path given) |
|
|
528
544
|
|
|
529
545
|
**Speed and accuracy**
|
|
530
546
|
|
|
@@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta"
|
|
|
13
13
|
|
|
14
14
|
[project]
|
|
15
15
|
name = "ipmg"
|
|
16
|
-
version = "2.
|
|
16
|
+
version = "2.2.0" # Managed automatically by semantic-release
|
|
17
17
|
description = "IP Management & Ping Monitoring CLI Tool"
|
|
18
18
|
readme = "README.md"
|
|
19
19
|
requires-python = ">=3.9"
|
|
@@ -219,6 +219,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
219
219
|
"csv and jsonl are written per host regardless."
|
|
220
220
|
),
|
|
221
221
|
)
|
|
222
|
+
reports.add_argument(
|
|
223
|
+
"--resume",
|
|
224
|
+
nargs="?",
|
|
225
|
+
const="",
|
|
226
|
+
default=None,
|
|
227
|
+
metavar="REPORT",
|
|
228
|
+
help=(
|
|
229
|
+
"Finish an interrupted scan: skip the hosts its report already holds and "
|
|
230
|
+
"complete that report. REPORT is its jsonl, csv, json, or xlsx file "
|
|
231
|
+
"(default: the newest report named after --output)."
|
|
232
|
+
),
|
|
233
|
+
)
|
|
222
234
|
|
|
223
235
|
live = parser.add_argument_group("live output")
|
|
224
236
|
live.add_argument(
|
|
@@ -17,23 +17,32 @@ writes at the end of the pass, which is why the scan shares its timestamp with
|
|
|
17
17
|
the writer. A completed scan overwrites every snapshot with the canonical
|
|
18
18
|
report, so incremental writing changes what survives an interruption without
|
|
19
19
|
changing what a finished scan produces.
|
|
20
|
+
|
|
21
|
+
That partial report is also where an interrupted scan picks up again:
|
|
22
|
+
:func:`load_partial_report` reads the hosts it already holds, and ``--resume``
|
|
23
|
+
scans only the rest, writing to the same files under the same timestamp.
|
|
20
24
|
"""
|
|
21
25
|
|
|
22
26
|
from __future__ import annotations
|
|
23
27
|
|
|
28
|
+
import io
|
|
24
29
|
import json
|
|
25
30
|
import logging
|
|
31
|
+
import math
|
|
26
32
|
import os
|
|
33
|
+
import re
|
|
27
34
|
import time
|
|
28
35
|
from dataclasses import dataclass
|
|
36
|
+
from datetime import datetime
|
|
29
37
|
from pathlib import Path
|
|
30
|
-
from typing import IO, Dict, List, Optional, Sequence
|
|
38
|
+
from typing import IO, Dict, List, Optional, Sequence, Set, Tuple
|
|
31
39
|
|
|
32
40
|
import pandas as pd
|
|
33
41
|
|
|
34
42
|
from ipmg.core.engine import HostResult
|
|
43
|
+
from ipmg.exceptions import FileIOError
|
|
35
44
|
from ipmg.reporting.frames import RESULT_COLUMNS, format_open_ports
|
|
36
|
-
from ipmg.utils.helpers import spreadsheet_escape
|
|
45
|
+
from ipmg.utils.helpers import FORMULA_PREFIXES, spreadsheet_escape
|
|
37
46
|
|
|
38
47
|
log = logging.getLogger(__name__)
|
|
39
48
|
|
|
@@ -48,6 +57,12 @@ REPORT_FORMATS = ("xlsx", "csv", "json", "jsonl", "md")
|
|
|
48
57
|
STREAMING_FORMATS = ("csv", "jsonl")
|
|
49
58
|
#: Formats that only exist as a whole file, so they are re-snapshotted.
|
|
50
59
|
SNAPSHOT_FORMATS = ("xlsx", "json", "md")
|
|
60
|
+
#: Formats a scan can be resumed from, most faithful first. ``md`` only
|
|
61
|
+
#: previews the first 25 hosts, so it cannot stand in for the whole report.
|
|
62
|
+
RESUMABLE_FORMATS = ("jsonl", "csv", "json", "xlsx")
|
|
63
|
+
|
|
64
|
+
#: ``<base>_<YYYYMMDD_HHMMSS>.<format>``, the name every report is saved under.
|
|
65
|
+
_REPORT_NAME = re.compile(r"^(?P<prefix>.+)_(?P<timestamp>\d{8}_\d{6})\.(?P<fmt>[a-z]+)$")
|
|
51
66
|
|
|
52
67
|
|
|
53
68
|
@dataclass(frozen=True)
|
|
@@ -120,6 +135,7 @@ class IncrementalReport:
|
|
|
120
135
|
batch_timestamp: object,
|
|
121
136
|
options: IncrementalOptions = IncrementalOptions(),
|
|
122
137
|
previous: Optional[Sequence[HostResult]] = None,
|
|
138
|
+
previous_elapsed_s: float = 0.0,
|
|
123
139
|
) -> None:
|
|
124
140
|
self.options = options.clamped()
|
|
125
141
|
self._base = base
|
|
@@ -127,13 +143,17 @@ class IncrementalReport:
|
|
|
127
143
|
self._batch_timestamp = batch_timestamp
|
|
128
144
|
self._formats = [fmt for fmt in dict.fromkeys(formats)]
|
|
129
145
|
self._results: List[HostResult] = list(previous or ())
|
|
146
|
+
# Resumed rows keep the time the earlier run had reached, and new rows
|
|
147
|
+
# count on from it, so a report interrupted twice still knows how long
|
|
148
|
+
# the whole scan has taken.
|
|
130
149
|
self._rows: List[Dict[str, object]] = [
|
|
131
|
-
result_row(result, batch_timestamp,
|
|
150
|
+
result_row(result, batch_timestamp, previous_elapsed_s if previous else None)
|
|
151
|
+
for result in self._results
|
|
132
152
|
]
|
|
133
153
|
self._handles: Dict[str, IO[str]] = {}
|
|
134
154
|
self._written: Dict[str, None] = {}
|
|
135
|
-
self._started_at = time.monotonic()
|
|
136
|
-
self._last_snapshot =
|
|
155
|
+
self._started_at = time.monotonic() - previous_elapsed_s
|
|
156
|
+
self._last_snapshot = time.monotonic()
|
|
137
157
|
self._closed = False
|
|
138
158
|
|
|
139
159
|
# -- lifecycle ---------------------------------------------------------
|
|
@@ -259,6 +279,194 @@ class IncrementalReport:
|
|
|
259
279
|
self._handles.clear()
|
|
260
280
|
|
|
261
281
|
|
|
282
|
+
@dataclass(frozen=True)
|
|
283
|
+
class PartialReport:
|
|
284
|
+
"""The hosts an interrupted scan left in its report, ready to resume."""
|
|
285
|
+
|
|
286
|
+
path: str
|
|
287
|
+
#: The ``--output`` prefix and timestamp the report was saved under, so a
|
|
288
|
+
#: resumed scan writes back to the same files.
|
|
289
|
+
base: str
|
|
290
|
+
timestamp: str
|
|
291
|
+
results: Tuple[HostResult, ...]
|
|
292
|
+
batch_timestamp: Optional[datetime]
|
|
293
|
+
#: How long the earlier run had been scanning when it stopped.
|
|
294
|
+
elapsed_s: float
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def scanned(self) -> Set[str]:
|
|
298
|
+
return {result.ip for result in self.results}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def find_partial_report(base: str) -> Optional[str]:
|
|
302
|
+
"""The newest report saved under ``base`` that a scan can resume from.
|
|
303
|
+
|
|
304
|
+
When one scan left several formats behind, the most faithful one wins:
|
|
305
|
+
``jsonl`` and ``csv`` hold every host up to the moment of interruption,
|
|
306
|
+
while ``json`` and ``xlsx`` are only as recent as their last autosave.
|
|
307
|
+
"""
|
|
308
|
+
directory = Path(base).parent
|
|
309
|
+
prefix = Path(base).name
|
|
310
|
+
candidates = []
|
|
311
|
+
try:
|
|
312
|
+
entries = list(directory.iterdir())
|
|
313
|
+
except OSError:
|
|
314
|
+
return None
|
|
315
|
+
for entry in entries:
|
|
316
|
+
match = _REPORT_NAME.match(entry.name)
|
|
317
|
+
if not match or match["prefix"] != prefix or match["fmt"] not in RESUMABLE_FORMATS:
|
|
318
|
+
continue
|
|
319
|
+
rank = RESUMABLE_FORMATS.index(match["fmt"])
|
|
320
|
+
candidates.append((match["timestamp"], -rank, str(entry)))
|
|
321
|
+
return max(candidates)[2] if candidates else None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def load_partial_report(path: str) -> PartialReport:
|
|
325
|
+
"""Read the hosts a report already holds, including one cut off mid-write."""
|
|
326
|
+
name = _REPORT_NAME.match(Path(path).name)
|
|
327
|
+
if name is None:
|
|
328
|
+
raise FileIOError(
|
|
329
|
+
f"Cannot resume from {path}: expected a report named like "
|
|
330
|
+
"results_20260917_120000.jsonl, as IPMG saves them."
|
|
331
|
+
)
|
|
332
|
+
fmt = name["fmt"]
|
|
333
|
+
if fmt not in RESUMABLE_FORMATS:
|
|
334
|
+
raise FileIOError(
|
|
335
|
+
f"Cannot resume from a .{fmt} report; use the "
|
|
336
|
+
f"{', '.join(RESUMABLE_FORMATS)} report from the same scan instead."
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
frame = _read_report(path, fmt)
|
|
341
|
+
except FileNotFoundError:
|
|
342
|
+
raise FileIOError(f"Report to resume not found: {path}") from None
|
|
343
|
+
except (OSError, ValueError) as exc:
|
|
344
|
+
raise FileIOError(f"Cannot read report {path}: {exc}") from exc
|
|
345
|
+
|
|
346
|
+
missing = [column for column in ("IP Address", "Status") if column not in frame.columns]
|
|
347
|
+
if missing:
|
|
348
|
+
raise FileIOError(f"{path} is not an IPMG report: missing {', '.join(missing)}.")
|
|
349
|
+
|
|
350
|
+
# A spreadsheet format stores cells escaped against formula injection;
|
|
351
|
+
# the scan itself needs the text the host actually reported.
|
|
352
|
+
unescape = fmt in ("csv", "xlsx")
|
|
353
|
+
by_ip: Dict[str, HostResult] = {}
|
|
354
|
+
for row in frame.to_dict(orient="records"):
|
|
355
|
+
result = _row_result(row, unescape)
|
|
356
|
+
if result is not None:
|
|
357
|
+
by_ip[result.ip] = result
|
|
358
|
+
|
|
359
|
+
base = str(Path(path).parent / name["prefix"])
|
|
360
|
+
return PartialReport(
|
|
361
|
+
path=path,
|
|
362
|
+
base=base,
|
|
363
|
+
timestamp=name["timestamp"],
|
|
364
|
+
results=tuple(by_ip.values()),
|
|
365
|
+
batch_timestamp=_batch_timestamp(frame),
|
|
366
|
+
elapsed_s=_elapsed(frame),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _read_report(path: str, fmt: str) -> pd.DataFrame:
|
|
371
|
+
if fmt == "xlsx":
|
|
372
|
+
return pd.read_excel(path, dtype=object)
|
|
373
|
+
if fmt == "json":
|
|
374
|
+
with open(path, encoding="utf-8") as handle:
|
|
375
|
+
return pd.DataFrame(json.load(handle))
|
|
376
|
+
|
|
377
|
+
with open(path, encoding="utf-8", newline="") as handle:
|
|
378
|
+
text = handle.read()
|
|
379
|
+
# Rows are flushed whole, so a last line without its newline is one the
|
|
380
|
+
# previous run was killed while writing. It is dropped, and that host is
|
|
381
|
+
# simply scanned again.
|
|
382
|
+
if text and not text.endswith("\n"):
|
|
383
|
+
text = text[: text.rfind("\n") + 1]
|
|
384
|
+
|
|
385
|
+
if fmt == "csv":
|
|
386
|
+
if not text.strip():
|
|
387
|
+
return pd.DataFrame(columns=RESULT_COLUMNS)
|
|
388
|
+
return pd.read_csv(io.StringIO(text), dtype=str, keep_default_na=False)
|
|
389
|
+
|
|
390
|
+
records = []
|
|
391
|
+
for number, line in enumerate(text.splitlines(), start=1):
|
|
392
|
+
if not line.strip():
|
|
393
|
+
continue
|
|
394
|
+
try:
|
|
395
|
+
records.append(json.loads(line))
|
|
396
|
+
except json.JSONDecodeError as exc:
|
|
397
|
+
raise ValueError(f"line {number} is not valid JSON ({exc.msg})") from None
|
|
398
|
+
return pd.DataFrame(records)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _is_blank(value: object) -> bool:
|
|
402
|
+
return value is None or (isinstance(value, float) and math.isnan(value)) or value == ""
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _text(value: object, unescape: bool = False) -> str:
|
|
406
|
+
if _is_blank(value):
|
|
407
|
+
return ""
|
|
408
|
+
text = str(value).strip()
|
|
409
|
+
if unescape and text.startswith("'") and text[1:].startswith(FORMULA_PREFIXES):
|
|
410
|
+
return text[1:]
|
|
411
|
+
return text
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _latency(value: object) -> Optional[float]:
|
|
415
|
+
if _is_blank(value):
|
|
416
|
+
return None
|
|
417
|
+
try:
|
|
418
|
+
return float(value) # type: ignore[arg-type]
|
|
419
|
+
except (TypeError, ValueError):
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _open_ports(value: object) -> Tuple[int, ...]:
|
|
424
|
+
ports = []
|
|
425
|
+
for piece in _text(value).split(","):
|
|
426
|
+
try:
|
|
427
|
+
ports.append(int(float(piece)))
|
|
428
|
+
except ValueError:
|
|
429
|
+
continue
|
|
430
|
+
return tuple(ports)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _row_result(row: Dict[str, object], unescape: bool) -> Optional[HostResult]:
|
|
434
|
+
ip = _text(row.get("IP Address"))
|
|
435
|
+
status = _text(row.get("Status"))
|
|
436
|
+
if not ip or not status:
|
|
437
|
+
return None
|
|
438
|
+
return HostResult(
|
|
439
|
+
ip=ip,
|
|
440
|
+
status=status,
|
|
441
|
+
latency=_latency(row.get("Latency")),
|
|
442
|
+
hostname=_text(row.get("Hostname"), unescape),
|
|
443
|
+
open_ports=_open_ports(row.get("Open Ports")),
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _batch_timestamp(frame: pd.DataFrame) -> Optional[datetime]:
|
|
448
|
+
"""The resumed scan's start time, so both runs report as one batch."""
|
|
449
|
+
if "Batch Timestamp" not in frame.columns:
|
|
450
|
+
return None
|
|
451
|
+
for value in frame["Batch Timestamp"]:
|
|
452
|
+
if _is_blank(value):
|
|
453
|
+
continue
|
|
454
|
+
try:
|
|
455
|
+
# A finished json report stores it as epoch milliseconds.
|
|
456
|
+
unit = "ms" if isinstance(value, (int, float)) else None
|
|
457
|
+
return pd.to_datetime(value, unit=unit).to_pydatetime()
|
|
458
|
+
except (TypeError, ValueError, OverflowError):
|
|
459
|
+
return None
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _elapsed(frame: pd.DataFrame) -> float:
|
|
464
|
+
if "Scan Duration (s)" not in frame.columns:
|
|
465
|
+
return 0.0
|
|
466
|
+
durations = pd.to_numeric(frame["Scan Duration (s)"], errors="coerce").dropna()
|
|
467
|
+
return float(durations.max()) if not durations.empty else 0.0
|
|
468
|
+
|
|
469
|
+
|
|
262
470
|
def atomic_write_bytes(path: str, data: bytes) -> None:
|
|
263
471
|
"""Replace ``path`` with ``data`` in one step, or leave it untouched.
|
|
264
472
|
|
|
@@ -111,7 +111,8 @@ def heading(title: str) -> None:
|
|
|
111
111
|
def field(label: str, value: object, value_style: str = "ipmg.value") -> None:
|
|
112
112
|
"""One aligned ``label value`` line."""
|
|
113
113
|
text = Text(INDENT)
|
|
114
|
-
|
|
114
|
+
# A label as long as the column still gets one space before its value.
|
|
115
|
+
text.append(f"{label:<{LABEL_WIDTH - 1}} ", style="ipmg.label")
|
|
115
116
|
text.append(str(value), style=value_style)
|
|
116
117
|
console.print(text)
|
|
117
118
|
|
|
@@ -15,7 +15,7 @@ from ipmg.core.diff import DiffOptions
|
|
|
15
15
|
from ipmg.core.discovery import discover_local_subnet
|
|
16
16
|
from ipmg.core.engine import HostResult, ScanConfig, execute_scan
|
|
17
17
|
from ipmg.core.portscan import DEFAULT_PORTS
|
|
18
|
-
from ipmg.exceptions import HistoryError
|
|
18
|
+
from ipmg.exceptions import FileIOError, HistoryError
|
|
19
19
|
from ipmg.infrastructure.file_io import (
|
|
20
20
|
DEFAULT_INPUT_FILE,
|
|
21
21
|
create_sample_file,
|
|
@@ -26,6 +26,9 @@ from ipmg.infrastructure.incremental import (
|
|
|
26
26
|
DEFAULT_AUTOSAVE_S,
|
|
27
27
|
IncrementalOptions,
|
|
28
28
|
IncrementalReport,
|
|
29
|
+
PartialReport,
|
|
30
|
+
find_partial_report,
|
|
31
|
+
load_partial_report,
|
|
29
32
|
)
|
|
30
33
|
from ipmg.reporting import ui
|
|
31
34
|
from ipmg.reporting.diff_report import export_diff, print_diff
|
|
@@ -49,6 +52,8 @@ class ScanOutcome:
|
|
|
49
52
|
source: str
|
|
50
53
|
#: File-name stamp shared by the incremental writes and the final report.
|
|
51
54
|
timestamp: str
|
|
55
|
+
#: Report file name prefix; a resumed scan keeps the one it started with.
|
|
56
|
+
output: str
|
|
52
57
|
|
|
53
58
|
|
|
54
59
|
@dataclass(frozen=True)
|
|
@@ -88,6 +93,20 @@ def _incremental_options(args) -> IncrementalOptions:
|
|
|
88
93
|
).clamped()
|
|
89
94
|
|
|
90
95
|
|
|
96
|
+
def _load_resume(args) -> Optional[PartialReport]:
|
|
97
|
+
"""The partial report ``--resume`` points at, or the newest one for ``--output``."""
|
|
98
|
+
target = getattr(args, "resume", None)
|
|
99
|
+
if target is None:
|
|
100
|
+
return None
|
|
101
|
+
path = target or find_partial_report(args.output)
|
|
102
|
+
if path is None:
|
|
103
|
+
raise FileIOError(
|
|
104
|
+
f"No report to resume: nothing named {args.output}_<timestamp>.<format> "
|
|
105
|
+
"was found. Pass the report's path, as in --resume results_20260917_120000.jsonl."
|
|
106
|
+
)
|
|
107
|
+
return load_partial_report(path)
|
|
108
|
+
|
|
109
|
+
|
|
91
110
|
def _stream_options(args) -> StreamOptions:
|
|
92
111
|
"""Read live-output settings off the parsed arguments."""
|
|
93
112
|
all_hosts = bool(getattr(args, "stream_all", False))
|
|
@@ -175,18 +194,32 @@ def _scan_with_progress(
|
|
|
175
194
|
def _open_report(
|
|
176
195
|
args,
|
|
177
196
|
incremental: IncrementalOptions,
|
|
197
|
+
output: str,
|
|
178
198
|
timestamp: str,
|
|
179
199
|
batch_timestamp: datetime,
|
|
200
|
+
previous: List[HostResult],
|
|
201
|
+
previous_elapsed_s: float,
|
|
180
202
|
) -> Optional[IncrementalReport]:
|
|
181
203
|
"""Start writing this pass's report, unless incremental writing is off."""
|
|
182
204
|
if not incremental.enabled or not args.formats:
|
|
183
205
|
return None
|
|
184
206
|
return IncrementalReport(
|
|
185
|
-
base=
|
|
207
|
+
base=output,
|
|
186
208
|
formats=args.formats,
|
|
187
209
|
timestamp=timestamp,
|
|
188
210
|
batch_timestamp=batch_timestamp,
|
|
189
211
|
options=incremental,
|
|
212
|
+
previous=previous,
|
|
213
|
+
previous_elapsed_s=previous_elapsed_s,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _announce_resume(resume: PartialReport, done: int, total: int) -> None:
|
|
218
|
+
ui.fields(
|
|
219
|
+
[
|
|
220
|
+
("Resuming", resume.path),
|
|
221
|
+
("Left", f"{ui.plural(total - done, 'host')} of {total} still to scan"),
|
|
222
|
+
]
|
|
190
223
|
)
|
|
191
224
|
|
|
192
225
|
|
|
@@ -224,18 +257,32 @@ def _run_single_pass(
|
|
|
224
257
|
config: ScanConfig,
|
|
225
258
|
stream: StreamOptions,
|
|
226
259
|
incremental: IncrementalOptions,
|
|
260
|
+
resume: Optional[PartialReport] = None,
|
|
227
261
|
) -> ScanOutcome:
|
|
228
|
-
batch_timestamp = current_timestamp()
|
|
229
|
-
timestamp = timestamp_str()
|
|
262
|
+
batch_timestamp = (resume and resume.batch_timestamp) or current_timestamp()
|
|
263
|
+
timestamp = resume.timestamp if resume else timestamp_str()
|
|
264
|
+
output = resume.base if resume else args.output
|
|
230
265
|
started_at = time.perf_counter()
|
|
231
266
|
|
|
232
267
|
ip_list = discover_local_subnet() if args.discover else load_targets(args.input)
|
|
233
268
|
source = "auto-discovery" if args.discover else args.input
|
|
234
269
|
|
|
270
|
+
# Hosts the earlier run finished are kept only if they are still targets,
|
|
271
|
+
# so resuming against an edited list never reports hosts it no longer has.
|
|
272
|
+
targets = set(ip_list)
|
|
273
|
+
previous = [result for result in resume.results if result.ip in targets] if resume else []
|
|
274
|
+
previous_elapsed_s = resume.elapsed_s if resume else 0.0
|
|
275
|
+
scanned = {result.ip for result in previous}
|
|
276
|
+
remaining = [ip for ip in ip_list if ip not in scanned]
|
|
277
|
+
|
|
235
278
|
_print_configuration(source, len(ip_list), config)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
279
|
+
if resume:
|
|
280
|
+
_announce_resume(resume, len(previous), len(ip_list))
|
|
281
|
+
report = _open_report(
|
|
282
|
+
args, incremental, output, timestamp, batch_timestamp, previous, previous_elapsed_s
|
|
283
|
+
)
|
|
284
|
+
results = previous + _run_pass(remaining, config, stream, report)
|
|
285
|
+
duration = previous_elapsed_s + time.perf_counter() - started_at
|
|
239
286
|
|
|
240
287
|
return ScanOutcome(
|
|
241
288
|
results=results,
|
|
@@ -244,6 +291,7 @@ def _run_single_pass(
|
|
|
244
291
|
duration_s=duration,
|
|
245
292
|
source=source,
|
|
246
293
|
timestamp=timestamp,
|
|
294
|
+
output=output,
|
|
247
295
|
)
|
|
248
296
|
|
|
249
297
|
|
|
@@ -306,14 +354,18 @@ def run_scan(args) -> None:
|
|
|
306
354
|
stream = _stream_options(args)
|
|
307
355
|
incremental = _incremental_options(args)
|
|
308
356
|
_ensure_input_file(args)
|
|
357
|
+
resume = _load_resume(args)
|
|
309
358
|
|
|
310
359
|
while True:
|
|
311
|
-
outcome = _run_single_pass(args, config, stream, incremental)
|
|
360
|
+
outcome = _run_single_pass(args, config, stream, incremental, resume)
|
|
361
|
+
# Only the first pass picks up where an earlier run stopped; every
|
|
362
|
+
# --interval pass after it is a fresh scan.
|
|
363
|
+
resume = None
|
|
312
364
|
|
|
313
365
|
print_summary(outcome.frame, outcome.batch_timestamp, outcome.duration_s)
|
|
314
366
|
# Overwrites whatever the incremental writer left on those same paths,
|
|
315
367
|
# so a finished scan produces exactly the report it always did.
|
|
316
|
-
save_results(outcome.frame,
|
|
368
|
+
save_results(outcome.frame, outcome.output, args.formats, timestamp=outcome.timestamp)
|
|
317
369
|
_store_and_compare(history_options, config, outcome)
|
|
318
370
|
|
|
319
371
|
if not args.interval:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ipmg
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: IP Management & Ping Monitoring CLI Tool
|
|
5
5
|
Author: Sameer Alam
|
|
6
6
|
Maintainer-email: Sameer Alam <sameeralam3127@gmail.com>
|
|
@@ -542,6 +542,21 @@ default). A finished scan overwrites those files with the complete report, so
|
|
|
542
542
|
the file names and contents are the same as they always were. Use
|
|
543
543
|
`--no-incremental` to go back to writing only at the end.
|
|
544
544
|
|
|
545
|
+
**Pick up where an interrupted scan stopped.** `--resume` reads the hosts the
|
|
546
|
+
partial report already holds, scans only the rest, and finishes that same
|
|
547
|
+
report — same file names, same batch timestamp:
|
|
548
|
+
|
|
549
|
+
```bash
|
|
550
|
+
ipmg --input 10.0.0.0/16 --formats jsonl xlsx # Ctrl+C partway through
|
|
551
|
+
ipmg --input 10.0.0.0/16 --formats jsonl xlsx --resume
|
|
552
|
+
ipmg --input 10.0.0.0/16 --resume results_20260917_120000.csv
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
Without a path, `--resume` takes the newest report named after `--output`,
|
|
556
|
+
preferring `jsonl` or `csv` (current to the last host) over `json` or `xlsx`
|
|
557
|
+
(current to the last autosave). Hosts dropped from the target list since are
|
|
558
|
+
left out of the finished report.
|
|
559
|
+
|
|
545
560
|
**Open ports.** `Open Ports` is only populated when `--scan-ports` is set: for
|
|
546
561
|
each host that answers, IPMG probes a list of common TCP ports (SSH, HTTP,
|
|
547
562
|
HTTPS, RDP, SMB, FTP, SMTP, DNS, MSSQL, MySQL, PostgreSQL by default)
|
|
@@ -572,6 +587,7 @@ them — so piping IPMG into a file or a log gives you clean text.
|
|
|
572
587
|
| `--formats` | `xlsx` | One or more of `xlsx`, `csv`, `json`, `jsonl`, `md` |
|
|
573
588
|
| `--no-incremental` | off | Only write the report once the scan has finished |
|
|
574
589
|
| `--autosave` | `30` | How often a running scan re-saves `xlsx`, `json`, and `md` |
|
|
590
|
+
| `--resume` | off | Finish an interrupted scan from its partial report (newest one, or the path given) |
|
|
575
591
|
|
|
576
592
|
**Speed and accuracy**
|
|
577
593
|
|
|
@@ -7,11 +7,14 @@ import pandas as pd
|
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
9
|
from ipmg.core.engine import HostResult
|
|
10
|
+
from ipmg.exceptions import FileIOError
|
|
10
11
|
from ipmg.infrastructure.file_io import save_results, write_report
|
|
11
12
|
from ipmg.infrastructure.incremental import (
|
|
12
13
|
IncrementalOptions,
|
|
13
14
|
IncrementalReport,
|
|
14
15
|
atomic_write_bytes,
|
|
16
|
+
find_partial_report,
|
|
17
|
+
load_partial_report,
|
|
15
18
|
)
|
|
16
19
|
from ipmg.services.scan_service import run_scan
|
|
17
20
|
from ipmg.utils.helpers import console
|
|
@@ -225,3 +228,163 @@ def test_finished_scan_writes_one_set_of_reports(tmp_path, monkeypatch):
|
|
|
225
228
|
assert sorted(row["IP Address"] for row in rows) == ["1.1.1.1", "8.8.8.8"]
|
|
226
229
|
# Every row carries the duration of the whole pass, as it did before.
|
|
227
230
|
assert len({row["Scan Duration (s)"] for row in rows}) == 1
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# -- resuming an interrupted scan ---------------------------------------------
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _pinged(calls):
|
|
237
|
+
def ping_ip(ip, _timeout, _count):
|
|
238
|
+
calls.append(ip)
|
|
239
|
+
return "Active", 2.0
|
|
240
|
+
|
|
241
|
+
return ping_ip
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _interrupt_first_run(tmp_path, monkeypatch, targets, stop_at, **overrides):
|
|
245
|
+
monkeypatch.setattr("ipmg.services.scan_service.load_targets", lambda _source: targets)
|
|
246
|
+
monkeypatch.setattr("ipmg.core.engine.ping_ip", _interrupted_ping(stop_at))
|
|
247
|
+
with pytest.raises(KeyboardInterrupt):
|
|
248
|
+
run_scan(_scan_args(tmp_path, **overrides))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_resume_scans_only_the_hosts_left(tmp_path, monkeypatch):
|
|
252
|
+
targets = ["8.8.8.8", "1.1.1.1", "9.9.9.9"]
|
|
253
|
+
_interrupt_first_run(tmp_path, monkeypatch, targets, "1.1.1.1")
|
|
254
|
+
(partial,) = tmp_path.glob("scan_*.csv")
|
|
255
|
+
|
|
256
|
+
calls = []
|
|
257
|
+
monkeypatch.setattr("ipmg.core.engine.ping_ip", _pinged(calls))
|
|
258
|
+
run_scan(_scan_args(tmp_path, resume=str(partial)))
|
|
259
|
+
|
|
260
|
+
assert sorted(calls) == ["1.1.1.1", "9.9.9.9"]
|
|
261
|
+
# The finished report lands on the file the interrupted run started.
|
|
262
|
+
assert list(tmp_path.glob("scan_*.csv")) == [partial]
|
|
263
|
+
rows = list(csv.DictReader(open(partial, encoding="utf-8")))
|
|
264
|
+
assert sorted(row["IP Address"] for row in rows) == sorted(targets)
|
|
265
|
+
assert len({row["Batch Timestamp"] for row in rows}) == 1
|
|
266
|
+
assert len({row["Scan Duration (s)"] for row in rows}) == 1
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_resume_without_a_path_picks_the_newest_report(tmp_path, monkeypatch):
|
|
270
|
+
(tmp_path / "scan_20200101_000000.jsonl").write_text(
|
|
271
|
+
json.dumps({"IP Address": "1.1.1.1", "Status": "Active"}) + "\n", encoding="utf-8"
|
|
272
|
+
)
|
|
273
|
+
_interrupt_first_run(
|
|
274
|
+
tmp_path, monkeypatch, ["8.8.8.8", "1.1.1.1"], "1.1.1.1", formats=["jsonl", "xlsx"]
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
calls = []
|
|
278
|
+
monkeypatch.setattr("ipmg.core.engine.ping_ip", _pinged(calls))
|
|
279
|
+
run_scan(_scan_args(tmp_path, formats=["jsonl", "xlsx"], resume=""))
|
|
280
|
+
|
|
281
|
+
# The older report lists 1.1.1.1 as done; the newer one, which wins, does not.
|
|
282
|
+
assert calls == ["1.1.1.1"]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_resume_with_no_report_to_find_is_an_error(tmp_path):
|
|
286
|
+
with pytest.raises(FileIOError, match="No report to resume"):
|
|
287
|
+
run_scan(_scan_args(tmp_path, resume=""))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_interrupting_a_resumed_scan_keeps_both_runs(tmp_path, monkeypatch):
|
|
291
|
+
targets = ["8.8.8.8", "1.1.1.1", "9.9.9.9"]
|
|
292
|
+
_interrupt_first_run(tmp_path, monkeypatch, targets, "1.1.1.1", formats=["jsonl"])
|
|
293
|
+
(partial,) = tmp_path.glob("scan_*.jsonl")
|
|
294
|
+
|
|
295
|
+
monkeypatch.setattr(
|
|
296
|
+
"ipmg.services.scan_service.load_targets", lambda _source: ["8.8.8.8", "9.9.9.9", "1.1.1.1"]
|
|
297
|
+
)
|
|
298
|
+
with pytest.raises(KeyboardInterrupt):
|
|
299
|
+
run_scan(_scan_args(tmp_path, formats=["jsonl"], resume=str(partial)))
|
|
300
|
+
|
|
301
|
+
resumed = load_partial_report(str(partial))
|
|
302
|
+
assert sorted(resumed.scanned) == ["8.8.8.8", "9.9.9.9"]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_resume_drops_hosts_no_longer_targeted(tmp_path, monkeypatch):
|
|
306
|
+
partial = tmp_path / "scan_20260917_120000.jsonl"
|
|
307
|
+
partial.write_text(
|
|
308
|
+
"".join(
|
|
309
|
+
json.dumps({"IP Address": ip, "Status": "Active", "Batch Timestamp": BATCH}) + "\n"
|
|
310
|
+
for ip in ("8.8.8.8", "10.0.0.1")
|
|
311
|
+
),
|
|
312
|
+
encoding="utf-8",
|
|
313
|
+
)
|
|
314
|
+
monkeypatch.setattr(
|
|
315
|
+
"ipmg.services.scan_service.load_targets", lambda _source: ["8.8.8.8", "1.1.1.1"]
|
|
316
|
+
)
|
|
317
|
+
monkeypatch.setattr("ipmg.core.engine.ping_ip", _pinged([]))
|
|
318
|
+
|
|
319
|
+
run_scan(_scan_args(tmp_path, formats=["jsonl"], resume=str(partial)))
|
|
320
|
+
|
|
321
|
+
rows = [json.loads(line) for line in partial.read_text(encoding="utf-8").splitlines()]
|
|
322
|
+
assert sorted(row["IP Address"] for row in rows) == ["1.1.1.1", "8.8.8.8"]
|
|
323
|
+
assert {row["Batch Timestamp"] for row in rows} == {BATCH}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_load_drops_a_line_cut_off_mid_write(tmp_path):
|
|
327
|
+
partial = tmp_path / "scan_20260917_120000.jsonl"
|
|
328
|
+
partial.write_text(
|
|
329
|
+
json.dumps({"IP Address": "8.8.8.8", "Status": "Active", "Scan Duration (s)": 4.5})
|
|
330
|
+
+ '\n{"IP Address": "1.1.1.1", "Sta',
|
|
331
|
+
encoding="utf-8",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
loaded = load_partial_report(str(partial))
|
|
335
|
+
|
|
336
|
+
assert [result.ip for result in loaded.results] == ["8.8.8.8"]
|
|
337
|
+
assert loaded.elapsed_s == 4.5
|
|
338
|
+
assert loaded.base == str(tmp_path / "scan")
|
|
339
|
+
assert loaded.timestamp == "20260917_120000"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@pytest.mark.parametrize("fmt", ["csv", "xlsx", "json"])
|
|
343
|
+
def test_load_round_trips_every_field(tmp_path, fmt):
|
|
344
|
+
original = HostResult(
|
|
345
|
+
ip="8.8.8.8",
|
|
346
|
+
status="Active",
|
|
347
|
+
latency=12.5,
|
|
348
|
+
hostname="=evil.example",
|
|
349
|
+
open_ports=(22, 443),
|
|
350
|
+
)
|
|
351
|
+
with _report(tmp_path, [fmt]) as report:
|
|
352
|
+
report.record(original)
|
|
353
|
+
report.record(_result("1.1.1.1", "Timeout", None))
|
|
354
|
+
report.snapshot()
|
|
355
|
+
|
|
356
|
+
loaded = load_partial_report(report.path_for(fmt))
|
|
357
|
+
|
|
358
|
+
# Spreadsheet formats store the hostname escaped; the scan gets it back as-is.
|
|
359
|
+
assert loaded.results[0] == original
|
|
360
|
+
assert loaded.results[1].latency is None
|
|
361
|
+
assert str(loaded.batch_timestamp) == BATCH
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@pytest.mark.parametrize(
|
|
365
|
+
"name, message",
|
|
366
|
+
[
|
|
367
|
+
("scan_20260917_120000.md", "Cannot resume from a .md report"),
|
|
368
|
+
("notes.jsonl", "expected a report named like"),
|
|
369
|
+
("scan_20260917_999999.csv", "not found"),
|
|
370
|
+
],
|
|
371
|
+
)
|
|
372
|
+
def test_load_rejects_what_it_cannot_resume(tmp_path, name, message):
|
|
373
|
+
if not name.endswith(".csv"):
|
|
374
|
+
(tmp_path / name).write_text("", encoding="utf-8")
|
|
375
|
+
|
|
376
|
+
with pytest.raises(FileIOError, match=message):
|
|
377
|
+
load_partial_report(str(tmp_path / name))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_find_prefers_the_most_complete_format(tmp_path):
|
|
381
|
+
for name in (
|
|
382
|
+
"scan_20260917_120000.xlsx",
|
|
383
|
+
"scan_20260917_120000.jsonl",
|
|
384
|
+
"other_20990101_000000.csv",
|
|
385
|
+
):
|
|
386
|
+
(tmp_path / name).write_text("", encoding="utf-8")
|
|
387
|
+
|
|
388
|
+
assert find_partial_report(str(tmp_path / "scan")) == str(
|
|
389
|
+
tmp_path / "scan_20260917_120000.jsonl"
|
|
390
|
+
)
|
|
@@ -55,3 +55,13 @@ def test_parser_accepts_streaming_flags():
|
|
|
55
55
|
|
|
56
56
|
assert args.stream_all is True
|
|
57
57
|
assert args.stream_refresh == 1.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_parser_resume_takes_an_optional_report():
|
|
61
|
+
parser = build_parser()
|
|
62
|
+
|
|
63
|
+
assert parser.parse_args([]).resume is None
|
|
64
|
+
assert parser.parse_args(["--resume"]).resume == ""
|
|
65
|
+
assert parser.parse_args(["--resume", "scan_20260917_120000.csv"]).resume == (
|
|
66
|
+
"scan_20260917_120000.csv"
|
|
67
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|