Skip to content

Commit 9e0bc9d

Browse files
Copilotandymeneely
andauthored
fix: validate ini settings after CLI parsing
Agent-Logs-Url: https://github.com/VulnerabilityHistoryProject/recidivism/sessions/a059b408-391c-467d-b495-6d9d3789015b Co-authored-by: andymeneely <341847+andymeneely@users.noreply.github.com>
1 parent 18a970e commit 9e0bc9d

4 files changed

Lines changed: 89 additions & 31 deletions

File tree

scripts/clone_osv_repositories.py

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from urllib.parse import urlparse
66

77
from osv_common import extract_repo_urls, iter_vulnerability_files, load_vulnerability
8-
from recidivism_config import load_config, resolve_config_path
8+
from recidivism_config import load_config_with_source, required_value, resolve_config_path
99

1010

1111
def clone_or_update(repo_url: str, target_dir: Path, update_existing: bool) -> None:
@@ -43,24 +43,16 @@ def clone_or_update(repo_url: str, target_dir: Path, update_existing: bool) -> N
4343

4444

4545
def main() -> None:
46-
config = load_config("clone")
46+
config, config_source = load_config_with_source("clone")
4747

4848
parser = argparse.ArgumentParser(description="Clone all repositories referenced by OSV vulnerabilities.")
49-
parser.add_argument(
50-
"--osv-dir",
51-
default=config.get("osv_dir"),
52-
help="Directory containing extracted OSV JSON files",
53-
)
54-
parser.add_argument(
55-
"--target-dir",
56-
default=config.get("target_dir"),
57-
help="Directory to place local repository clones",
58-
)
59-
max_repos = config.get("max_repos", fallback="").strip()
49+
parser.add_argument("--osv-dir", help="Directory containing extracted OSV JSON files")
50+
parser.add_argument("--target-dir", help="Directory to place local repository clones")
51+
max_repos_str = config.get("max_repos", fallback="").strip()
6052
parser.add_argument(
6153
"--max-repos",
6254
type=int,
63-
default=int(max_repos) if max_repos else None,
55+
default=int(max_repos_str) if max_repos_str else None,
6456
help="Optional limit for number of repositories",
6557
)
6658
parser.add_argument(
@@ -71,8 +63,11 @@ def main() -> None:
7163
)
7264
args = parser.parse_args()
7365

74-
osv_dir = resolve_config_path(args.osv_dir)
75-
target_dir = resolve_config_path(args.target_dir)
66+
try:
67+
osv_dir = resolve_config_path(args.osv_dir or required_value(config, "osv_dir"))
68+
target_dir = resolve_config_path(args.target_dir or required_value(config, "target_dir"))
69+
except ValueError as error:
70+
parser.error(f"{error} (config: {config_source})")
7671
target_dir.mkdir(parents=True, exist_ok=True)
7772

7873
repo_urls = set()

scripts/enrich_osv_recidivism.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from urllib.request import urlretrieve
99

1010
from osv_common import collect_history, iter_vulnerability_files, load_vulnerability, recidivism_for_vulnerability
11-
from recidivism_config import load_config, resolve_config_path
11+
from recidivism_config import load_config_with_source, required_value, resolve_config_path
1212

1313

1414
def download_dump(url: str, destination: Path, force: bool) -> None:
@@ -34,13 +34,13 @@ def extract_dump(archive: Path, extract_dir: Path, force: bool) -> None:
3434

3535

3636
def main() -> None:
37-
config = load_config("enrich")
37+
config, config_source = load_config_with_source("enrich")
3838

3939
parser = argparse.ArgumentParser(description="Download OSV dump and enrich with recidivism metrics.")
40-
parser.add_argument("--dump-url", default=config.get("dump_url"))
41-
parser.add_argument("--archive-path", default=config.get("archive_path"))
42-
parser.add_argument("--extract-dir", default=config.get("extract_dir"))
43-
parser.add_argument("--output", default=config.get("output"))
40+
parser.add_argument("--dump-url")
41+
parser.add_argument("--archive-path")
42+
parser.add_argument("--extract-dir")
43+
parser.add_argument("--output")
4444
parser.add_argument(
4545
"--force-download",
4646
action=argparse.BooleanOptionalAction,
@@ -53,11 +53,15 @@ def main() -> None:
5353
)
5454
args = parser.parse_args()
5555

56-
archive_path = resolve_config_path(args.archive_path)
57-
extract_dir = resolve_config_path(args.extract_dir)
58-
output_path = resolve_config_path(args.output)
56+
try:
57+
dump_url = args.dump_url or required_value(config, "dump_url")
58+
archive_path = resolve_config_path(args.archive_path or required_value(config, "archive_path"))
59+
extract_dir = resolve_config_path(args.extract_dir or required_value(config, "extract_dir"))
60+
output_path = resolve_config_path(args.output or required_value(config, "output"))
61+
except ValueError as error:
62+
parser.error(f"{error} (config: {config_source})")
5963

60-
download_dump(args.dump_url, archive_path, args.force_download)
64+
download_dump(dump_url, archive_path, args.force_download)
6165
extract_dump(archive_path, extract_dir, args.force_extract)
6266

6367
vulnerability_files = list(iter_vulnerability_files(extract_dir))

scripts/recidivism_config.py

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,22 @@
77
DEFAULT_CONFIG_FILE = REPO_ROOT / "recidivism.default.ini"
88

99

10-
def load_config(section: str) -> configparser.SectionProxy:
10+
def load_config_with_source(section: str) -> tuple[configparser.SectionProxy, Path]:
11+
"""Load configuration for a script section.
12+
13+
Reads `recidivism.ini` from the repository root when present. If it is
14+
missing, prints local setup guidance and falls back to
15+
`recidivism.default.ini`. Raises KeyError when the requested section is not
16+
defined.
17+
18+
Returns:
19+
Tuple of the requested section proxy and the config file path used.
20+
"""
1121
config = configparser.ConfigParser()
1222

1323
if LOCAL_CONFIG_FILE.exists():
14-
config.read(LOCAL_CONFIG_FILE, encoding="utf-8")
24+
source = LOCAL_CONFIG_FILE
25+
config.read(source, encoding="utf-8")
1526
else:
1627
print(
1728
"Missing recidivism.ini. Copy recidivism.default.ini to recidivism.ini "
@@ -21,16 +32,53 @@ def load_config(section: str) -> configparser.SectionProxy:
2132
raise FileNotFoundError(
2233
f"Could not find {LOCAL_CONFIG_FILE} or fallback {DEFAULT_CONFIG_FILE}."
2334
)
24-
config.read(DEFAULT_CONFIG_FILE, encoding="utf-8")
35+
source = DEFAULT_CONFIG_FILE
36+
config.read(source, encoding="utf-8")
2537

2638
if section not in config:
2739
raise KeyError(f"Missing [{section}] section in configuration file.")
2840

29-
return config[section]
41+
return config[section], source
42+
43+
44+
def load_config(section: str) -> configparser.SectionProxy:
45+
"""Backwards-compatible section-only loader."""
46+
config_section, _ = load_config_with_source(section)
47+
return config_section
3048

3149

3250
def resolve_config_path(path_value: str) -> Path:
51+
"""Resolve a configured path value.
52+
53+
Absolute paths are normalized directly. Relative paths are interpreted
54+
relative to the repository root.
55+
56+
Args:
57+
path_value: Path string from configuration or CLI.
58+
59+
Returns:
60+
Fully resolved filesystem path.
61+
"""
3362
path = Path(path_value)
3463
if path.is_absolute():
3564
return path.resolve()
3665
return (REPO_ROOT / path).resolve()
66+
67+
68+
def required_value(config: configparser.SectionProxy, key: str) -> str:
69+
"""Return a required non-empty configuration value.
70+
71+
Args:
72+
config: Configuration section containing script settings.
73+
key: Config key to read.
74+
75+
Returns:
76+
Non-empty configuration value.
77+
78+
Raises:
79+
ValueError: If the key is missing or empty.
80+
"""
81+
value = config.get(key)
82+
if value is None or not value.strip():
83+
raise ValueError(f"Missing required config key '{key}' in section [{config.name}].")
84+
return value

tests/test_recidivism_config.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
sys.path.insert(0, str((Path(__file__).resolve().parents[1] / "scripts")))
1010

11-
from recidivism_config import load_config, resolve_config_path # noqa: E402
11+
from recidivism_config import required_value, load_config, resolve_config_path # noqa: E402
1212

1313

1414
class RecidivismConfigTests(unittest.TestCase):
@@ -24,6 +24,7 @@ def test_loads_default_and_prints_message_when_local_missing(self) -> None:
2424
), contextlib.redirect_stdout(output):
2525
section = load_config("enrich")
2626

27+
self.assertFalse((tmp_path / "recidivism.ini").exists())
2728
self.assertEqual(section.get("output"), "data/out.jsonl")
2829
self.assertIn("Missing recidivism.ini", output.getvalue())
2930

@@ -34,6 +35,16 @@ def test_resolve_config_path_uses_repo_root_for_relative(self) -> None:
3435
resolved = resolve_config_path("data/example.json")
3536
self.assertEqual(resolved, (tmp_path / "data/example.json").resolve())
3637

38+
def test_required_value_raises_for_empty(self) -> None:
39+
with tempfile.TemporaryDirectory() as tmp:
40+
tmp_path = Path(tmp)
41+
config_path = tmp_path / "recidivism.ini"
42+
config_path.write_text("[clone]\nosv_dir =\n", encoding="utf-8")
43+
with patch("recidivism_config.LOCAL_CONFIG_FILE", config_path):
44+
section = load_config("clone")
45+
with self.assertRaises(ValueError):
46+
required_value(section, "osv_dir")
47+
3748

3849
if __name__ == "__main__":
3950
unittest.main()

0 commit comments

Comments
 (0)