Skip to content

Commit 9cec714

Browse files
fix: preserve owner during atomic replace, normalize dotenv booleans, reject dotted keys
- _atomic.py atomic_write_text/bytes: call os.chown(tmp, st_uid, st_gid) after os.chmod so application-owned configs remain readable after a privileged deployment user runs fix. - cli.py fix: normalize boolean baseline values to lowercase strings before comparison when target is dotenv, so True/true converges instead of perpetually drifting. - cli.py fix dotenv branch: reject keys containing dots (dotted keys from flattened JSON/YAML baselines) since _load_dotenv only accepts [A-Za-z_][A-Za-z0-9_]* identifiers and silently drops others. Addresses Codex review: _atomic.py:39 (P1), cli.py:502 (P2), cli.py:509 (P2)
1 parent d9ee511 commit 9cec714

2 files changed

Lines changed: 39 additions & 10 deletions

File tree

src/configdrift/_atomic.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,14 @@ def atomic_write_text(path: Path, text: str, encoding: str = "utf-8") -> None:
3232
fh.write(text)
3333
fh.flush()
3434
os.fsync(fh.fileno())
35-
# Preserve target permissions during atomic replacement
35+
# Preserve target permissions and ownership during atomic replacement
3636
if resolved.exists():
3737
try:
3838
st = resolved.stat()
3939
os.chmod(tmp, st.st_mode)
40+
# Preserve owner so application accounts can still read the
41+
# config after a privileged deployment user runs fix.
42+
os.chown(tmp, st.st_uid, st.st_gid)
4043
except OSError:
4144
pass
4245
os.replace(tmp, resolved)
@@ -63,11 +66,12 @@ def atomic_write_bytes(path: Path, data: bytes) -> None:
6366
fh.write(data)
6467
fh.flush()
6568
os.fsync(fh.fileno())
66-
# Preserve target permissions during atomic replacement
69+
# Preserve target permissions and ownership during atomic replacement
6770
if resolved.exists():
6871
try:
6972
st = resolved.stat()
7073
os.chmod(tmp, st.st_mode)
74+
os.chown(tmp, st.st_uid, st.st_gid)
7175
except OSError:
7276
pass
7377
os.replace(tmp, resolved)

src/configdrift/cli.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -359,17 +359,31 @@ def fix(
359359
continue
360360

361361
changes = 0
362+
# Detect dotenv target early so we can normalize boolean comparisons.
363+
_target_ext = target_path.suffix.lower()
364+
_target_is_dotenv = (
365+
_target_ext == ".env"
366+
or target_path.name == ".env"
367+
or target_path.name.startswith(".env.")
368+
)
362369
for key, value in baseline_data.items():
363370
# Distinguish missing keys from null values: a baseline null
364371
# must restore a missing target key, not be silently skipped.
365372
if key not in target_data:
366373
changes += 1
367374
if not dry_run:
368375
target_data[key] = value
369-
elif target_data[key] != value:
370-
changes += 1
371-
if not dry_run:
372-
target_data[key] = value
376+
else:
377+
# When fixing a dotenv target, normalize boolean baseline
378+
# values to their lowercase string form so the comparison
379+
# converges (True vs "true" would otherwise keep drifting).
380+
cmp_value = value
381+
if _target_is_dotenv and isinstance(value, bool):
382+
cmp_value = "true" if value else "false"
383+
if target_data[key] != cmp_value:
384+
changes += 1
385+
if not dry_run:
386+
target_data[key] = cmp_value
373387

374388
# Skip write-back when no changes detected
375389
if changes == 0:
@@ -491,12 +505,23 @@ def fix(
491505
failed_targets.append(str(target_path))
492506
continue
493507
elif is_dotenv:
494-
# Handle .env targets: write flat KEY=VALUE format
508+
# Handle .env targets: write flat KEY=VALUE format.
509+
# Reject keys containing dots — _load_dotenv only accepts
510+
# [A-Za-z_][A-Za-z0-9_]* identifiers, so dotted keys from
511+
# flattened JSON/YAML baselines would be silently dropped
512+
# on reload, causing perpetual drift.
513+
import re as _dotenv_re
514+
_DOTENV_KEY_RE = _dotenv_re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
515+
bad_keys = [k for k in target_data if not _DOTENV_KEY_RE.match(k)]
516+
if bad_keys:
517+
console.print(
518+
f"[red]Error: dotenv keys must match [A-Za-z_][A-Za-z0-9_]*. "
519+
f"Invalid keys: {', '.join(bad_keys[:5])}[/red]"
520+
)
521+
failed_targets.append(str(target_path))
522+
continue
495523
lines = []
496524
for k, v in target_data.items():
497-
# Quote values containing spaces, comments, or special chars.
498-
# Escape embedded double quotes so the value round-trips
499-
# through any POSIX-compatible shell or dotenv parser.
500525
# Convert Python booleans to lowercase for dotenv compatibility
501526
if isinstance(v, bool):
502527
str_v = "true" if v else "false"

0 commit comments

Comments
 (0)