Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/configdrift/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ def scan(
raise typer.Exit(code=1)

env_configs: dict[str, dict[str, Any]] = {}
files_loaded = 0
for env_name, dir_path in dir_mapping.items():
env_configs[env_name] = {}
p = Path(dir_path)
Expand All @@ -294,9 +295,26 @@ def scan(
try:
data = load_file(str(f))
env_configs[env_name].update(data)
files_loaded += 1
except Exception as e:
console.print(f"[yellow]Warning: could not load {f}: {e}[/yellow]")

# Silent-failure guard: if nothing was actually loaded, any "no drift"
# result would be a false green. Fail loudly instead.
if files_loaded == 0:
console.print(
"[red]ERROR: No config files could be loaded from any environment "
"directory. Refusing to report 'no drift' from an empty scan.[/red]"
)
raise typer.Exit(code=1)
if not env_configs.get(baseline):
console.print(
f"[red]ERROR: Baseline environment '{baseline}' loaded no config "
"keys; comparison against an empty baseline would flag every key "
"as drift.[/red]"
)
raise typer.Exit(code=1)

results = diff_environments(env_configs, baseline_env=baseline)

if output == OutputFormat.JSON:
Expand Down
15 changes: 14 additions & 1 deletion src/configdrift/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,19 @@ def by_severity(self, severity: Severity) -> list[Change]:
return [c for c in self.changes if c.severity == severity]


def _values_differ(old: Any, new: Any) -> bool:
"""Type-sensitive value comparison.

Python's ``==`` treats ``True == 1`` and ``False == 0``, so a config change
like ``debug: true -> debug: 1`` would silently compare equal. A drift
detector must flag cross-type changes (bool vs number) even when values
compare equal.
"""
if isinstance(old, bool) != isinstance(new, bool):
return True
return old != new


def diff_configs(
base: dict[str, Any],
target: dict[str, Any],
Expand Down Expand Up @@ -162,7 +175,7 @@ def diff_configs(
env=base_env,
)
)
elif old_val != new_val:
elif _values_differ(old_val, new_val):
result.changes.append(
Change(
key=key,
Expand Down
58 changes: 58 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,3 +473,61 @@ def test_version_output(self):
assert result.exit_code == 0
assert "configdrift" in result.stdout
assert "0.1.0" in result.stdout


class TestScanEmptyGuards:
"""Regression: scan must not report a false-green 'no drift' when nothing loaded."""

def test_scan_all_dirs_missing_exits_1(self):
with tempfile.TemporaryDirectory() as tmpdir:
result = runner.invoke(
app, ["scan", str(Path(tmpdir) / "nope1"), str(Path(tmpdir) / "nope2")]
)
assert result.exit_code == 1
# Either the baseline-not-found guard or the empty-scan guard fires.
assert (
"not found" in result.stdout
or "No config files could be loaded" in result.stdout
)

def test_scan_empty_dirs_exits_1(self):
with tempfile.TemporaryDirectory() as tmpdir:
dev = Path(tmpdir) / "dev"
prod = Path(tmpdir) / "prod"
dev.mkdir()
prod.mkdir()
result = runner.invoke(app, ["scan", str(dev), str(prod)])
assert result.exit_code == 1
assert "No config files could be loaded" in result.stdout

def test_scan_baseline_dir_missing_exits_1(self):
with tempfile.TemporaryDirectory() as tmpdir:
prod = Path(tmpdir) / "prod"
prod.mkdir()
(prod / "config.yaml").write_text(yaml.dump({"host": "prod.example.com"}))
result = runner.invoke(app, ["scan", str(prod), "--baseline", "dev"])
assert result.exit_code == 1

def test_scan_baseline_loaded_nothing_exits_1(self):
"""Baseline dir exists but only contains unparseable files -> refuse empty-baseline diff."""
with tempfile.TemporaryDirectory() as tmpdir:
dev = Path(tmpdir) / "dev"
prod = Path(tmpdir) / "prod"
dev.mkdir()
prod.mkdir()
(dev / "broken.yaml").write_text(":::: not yaml :::\n")
(prod / "config.yaml").write_text(yaml.dump({"host": "prod.example.com"}))
result = runner.invoke(app, ["scan", str(dev), str(prod)])
assert result.exit_code == 1
assert "loaded no config" in result.stdout

def test_scan_healthy_still_works(self):
with tempfile.TemporaryDirectory() as tmpdir:
dev = Path(tmpdir) / "dev"
prod = Path(tmpdir) / "prod"
dev.mkdir()
prod.mkdir()
(dev / "config.yaml").write_text(yaml.dump({"host": "localhost"}))
(prod / "config.yaml").write_text(yaml.dump({"host": "prod.example.com"}))
result = runner.invoke(app, ["scan", str(dev), str(prod)])
assert result.exit_code == 0
34 changes: 34 additions & 0 deletions tests/test_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,3 +374,37 @@ def test_authentication_not_auth(self):

def test_authz_not_auth(self):
assert _infer_severity_added("authz", "x") == Severity.WARNING


class TestTypeSensitiveComparison:
"""bool-vs-number equivalence hole: True == 1 in Python must still be drift."""

def test_bool_true_vs_int_one_is_change(self):
result = diff_configs({"debug": True}, {"debug": 1})
assert result.count == 1
change = result.changes[0]
assert change.change_type == ChangeType.CHANGED
assert change.old_value is True
assert change.new_value == 1

def test_bool_false_vs_int_zero_is_change(self):
result = diff_configs({"debug": False}, {"debug": 0})
assert result.count == 1
assert result.changes[0].change_type == ChangeType.CHANGED

def test_same_bool_values_not_drift(self):
assert diff_configs({"debug": True}, {"debug": True}).count == 0

def test_same_int_values_not_drift(self):
assert diff_configs({"port": 5432}, {"port": 5432}).count == 0

def test_str_vs_int_still_detected(self):
assert diff_configs({"port": "5432"}, {"port": 5432}).count == 1

def test_bool_vs_string_detected(self):
assert diff_configs({"debug": True}, {"debug": "true"}).count == 1

def test_nested_flattened_key_bool_vs_number(self):
base = {"app.debug": True}
target = {"app.debug": 1}
assert diff_configs(base, target).count == 1
Loading