|
55 | 55 | _CONFIG_GENERATED_KEY, |
56 | 56 | _host_discovery, |
57 | 57 | _atomic_write, |
| 58 | + _cli_target_from_argv, |
| 59 | + _parse_range_line, |
| 60 | + _parse_target_arg, |
| 61 | + _parse_target_ranges, |
| 62 | + _resolve_cli_target, |
| 63 | + _write_cli_target_file, |
58 | 64 | _combine_live_hosts, |
59 | 65 | _load_config, |
60 | 66 | _read_config_file, |
@@ -10662,3 +10668,165 @@ def test_keyboard_interrupt_during_masscan_reraised_after_nmap_join(self, tmp_pa |
10662 | 10668 | with pytest.raises(KeyboardInterrupt): |
10663 | 10669 | _internal_host_discovery( |
10664 | 10670 | str(targets), str(disc), '1000', None, HOST_DISCOVERY_NMAP_THRESHOLD) |
| 10671 | + |
| 10672 | + |
| 10673 | +# ── --target CLI flag ───────────────────────────────────────────────────────── |
| 10674 | + |
| 10675 | +class TestParseRangeLine: |
| 10676 | + """The shared per-line parser behind both target files and --target.""" |
| 10677 | + |
| 10678 | + @pytest.mark.parametrize('line,expected', [ |
| 10679 | + ('10.0.0.5', (167772165, 167772165)), |
| 10680 | + ('10.0.0.0/30', (167772160, 167772163)), |
| 10681 | + ('10.0.0.1-10.0.0.3', (167772161, 167772163)), |
| 10682 | + ('10.0.0.0 255.255.255.252', (167772160, 167772163)), |
| 10683 | + ]) |
| 10684 | + def test_accepted_forms(self, line, expected): |
| 10685 | + bounds, error = _parse_range_line(line) |
| 10686 | + assert error is None |
| 10687 | + assert bounds == expected |
| 10688 | + |
| 10689 | + def test_ipv6_reported_separately(self): |
| 10690 | + """IPv6 parses but is not IPv4, so it gets its own error code.""" |
| 10691 | + bounds, error = _parse_range_line('2001:db8::/32') |
| 10692 | + assert bounds is None |
| 10693 | + assert error == 'ipv6' |
| 10694 | + |
| 10695 | + @pytest.mark.parametrize('line', [ |
| 10696 | + 'nonsense', |
| 10697 | + '10.0.0.9-10.0.0.1', # reversed range |
| 10698 | + '10.0.0.0 999.999.999.999', |
| 10699 | + '10.0.0.300', |
| 10700 | + ]) |
| 10701 | + def test_rejected_forms(self, line): |
| 10702 | + bounds, error = _parse_range_line(line) |
| 10703 | + assert bounds is None |
| 10704 | + assert error == 'invalid' |
| 10705 | + |
| 10706 | + def test_file_parser_still_skips_bad_lines_silently(self, tmp_path): |
| 10707 | + """Extracting the helper must not change _parse_target_ranges behaviour: |
| 10708 | + a junk line is skipped without aborting the surrounding parse.""" |
| 10709 | + f = tmp_path / 'ranges.txt' |
| 10710 | + f.write_text('10.0.0.0/30\nnonsense\n10.0.1.1\n') |
| 10711 | + assert _parse_target_ranges(str(f)) == [ |
| 10712 | + (167772160, 167772163), (167772417, 167772417)] |
| 10713 | + |
| 10714 | + def test_file_parser_warns_on_ipv6_with_line_number(self, tmp_path, capsys): |
| 10715 | + f = tmp_path / 'ranges.txt' |
| 10716 | + f.write_text('10.0.0.1\n2001:db8::1\n') |
| 10717 | + result = _parse_target_ranges(str(f)) |
| 10718 | + out = capsys.readouterr().out |
| 10719 | + assert result == [(167772161, 167772161)] |
| 10720 | + assert 'line 2' in out |
| 10721 | + assert 'IPv4 only' in out |
| 10722 | + |
| 10723 | + |
| 10724 | +class TestParseTargetArg: |
| 10725 | + """--target value validation.""" |
| 10726 | + |
| 10727 | + def test_single_ip(self): |
| 10728 | + assert _parse_target_arg('10.0.0.5') == ['10.0.0.5'] |
| 10729 | + |
| 10730 | + def test_comma_separated_with_whitespace(self): |
| 10731 | + assert _parse_target_arg(' 10.0.0.0/24 , 10.0.1.5 ') == [ |
| 10732 | + '10.0.0.0/24', '10.0.1.5'] |
| 10733 | + |
| 10734 | + def test_range_and_netmask_forms(self): |
| 10735 | + assert _parse_target_arg('10.0.0.1-10.0.0.9') == ['10.0.0.1-10.0.0.9'] |
| 10736 | + assert _parse_target_arg('10.0.0.0 255.255.0.0') == ['10.0.0.0 255.255.0.0'] |
| 10737 | + |
| 10738 | + def test_empty_value_rejected(self): |
| 10739 | + with pytest.raises(ValueError, match='no address'): |
| 10740 | + _parse_target_arg('') |
| 10741 | + |
| 10742 | + def test_only_commas_rejected(self): |
| 10743 | + with pytest.raises(ValueError, match='no address'): |
| 10744 | + _parse_target_arg(' , , ') |
| 10745 | + |
| 10746 | + def test_ipv6_rejected_by_name(self): |
| 10747 | + with pytest.raises(ValueError, match='not IPv4'): |
| 10748 | + _parse_target_arg('2001:db8::1') |
| 10749 | + |
| 10750 | + def test_garbage_rejected_naming_the_token(self): |
| 10751 | + """The message must name the offending token, not just fail.""" |
| 10752 | + with pytest.raises(ValueError, match='nope'): |
| 10753 | + _parse_target_arg('10.0.0.1,nope') |
| 10754 | + |
| 10755 | + |
| 10756 | +class TestCliTargetFromArgv: |
| 10757 | + """--target extraction from argv.""" |
| 10758 | + |
| 10759 | + def test_absent_returns_none(self): |
| 10760 | + assert _cli_target_from_argv(['spoonmap.py']) is None |
| 10761 | + |
| 10762 | + def test_value_returned(self): |
| 10763 | + assert _cli_target_from_argv( |
| 10764 | + ['spoonmap.py', '--target', '10.0.0.5']) == '10.0.0.5' |
| 10765 | + |
| 10766 | + def test_value_missing_at_end_rejected(self): |
| 10767 | + with pytest.raises(ValueError, match='requires an address'): |
| 10768 | + _cli_target_from_argv(['spoonmap.py', '--target']) |
| 10769 | + |
| 10770 | + def test_next_flag_not_consumed_as_value(self): |
| 10771 | + """--target --resume must not treat --resume as an address.""" |
| 10772 | + with pytest.raises(ValueError, match='requires an address'): |
| 10773 | + _cli_target_from_argv(['spoonmap.py', '--target', '--resume']) |
| 10774 | + |
| 10775 | + def test_coexists_with_other_flags(self): |
| 10776 | + assert _cli_target_from_argv( |
| 10777 | + ['spoonmap.py', '--resume', '--target', '10.0.0.5']) == '10.0.0.5' |
| 10778 | + |
| 10779 | + |
| 10780 | +class TestResolveCliTarget: |
| 10781 | + """main()'s entry point: returns tokens or exits non-zero.""" |
| 10782 | + |
| 10783 | + def test_absent_returns_none(self): |
| 10784 | + assert _resolve_cli_target(['spoonmap.py']) is None |
| 10785 | + |
| 10786 | + def test_valid_returns_tokens(self): |
| 10787 | + assert _resolve_cli_target( |
| 10788 | + ['spoonmap.py', '--target', '10.0.0.0/24']) == ['10.0.0.0/24'] |
| 10789 | + |
| 10790 | + def test_invalid_exits_nonzero(self, capsys): |
| 10791 | + """A bad address must abort the run, not scan an empty target set.""" |
| 10792 | + with pytest.raises(SystemExit) as exc: |
| 10793 | + _resolve_cli_target(['spoonmap.py', '--target', 'nope']) |
| 10794 | + assert exc.value.code == 1 |
| 10795 | + assert 'ERROR' in capsys.readouterr().out |
| 10796 | + |
| 10797 | + def test_missing_value_exits_nonzero(self, capsys): |
| 10798 | + with pytest.raises(SystemExit) as exc: |
| 10799 | + _resolve_cli_target(['spoonmap.py', '--target']) |
| 10800 | + assert exc.value.code == 1 |
| 10801 | + assert 'requires an address' in capsys.readouterr().out |
| 10802 | + |
| 10803 | + |
| 10804 | +class TestWriteCliTargetFile: |
| 10805 | + """--target writes its own scope file and never touches ranges.txt.""" |
| 10806 | + |
| 10807 | + def test_writes_one_token_per_line(self, tmp_path): |
| 10808 | + path = _write_cli_target_file(['10.0.0.5', '10.0.1.0/24'], str(tmp_path)) |
| 10809 | + assert Path(path).read_text() == '10.0.0.5\n10.0.1.0/24\n' |
| 10810 | + |
| 10811 | + def test_lands_in_output_path(self, tmp_path): |
| 10812 | + path = _write_cli_target_file(['10.0.0.5'], str(tmp_path)) |
| 10813 | + assert path == str(tmp_path / 'cli_targets.txt') |
| 10814 | + |
| 10815 | + def test_does_not_touch_ranges_txt(self, tmp_path): |
| 10816 | + """ranges.txt is the operator's scope input; a convenience flag must |
| 10817 | + never overwrite it (the 2021 PR #12 approach did).""" |
| 10818 | + ranges = tmp_path / 'ranges.txt' |
| 10819 | + ranges.write_text('192.168.50.0/24\n') |
| 10820 | + _write_cli_target_file(['10.0.0.5'], str(tmp_path)) |
| 10821 | + assert ranges.read_text() == '192.168.50.0/24\n' |
| 10822 | + |
| 10823 | + def test_output_is_parseable_by_the_file_parser(self, tmp_path): |
| 10824 | + """Round-trip: what --target writes must be what the scan can read.""" |
| 10825 | + path = _write_cli_target_file( |
| 10826 | + ['10.0.0.0/30', '10.0.1.1-10.0.1.3'], str(tmp_path)) |
| 10827 | + assert _parse_target_ranges(path) == [ |
| 10828 | + (167772160, 167772163), (167772417, 167772419)] |
| 10829 | + |
| 10830 | + def test_atomic_leaves_no_temp_file(self, tmp_path): |
| 10831 | + _write_cli_target_file(['10.0.0.5'], str(tmp_path)) |
| 10832 | + assert [p.name for p in tmp_path.iterdir()] == ['cli_targets.txt'] |
0 commit comments