Skip to content

Commit 48beff8

Browse files
authored
fix(p0-5): atomic write_secret with flock + fsync + atomic replace (#431)
* test(secrets-store): add failing scaffold for concurrent write atomicity (F007 S1) * feat(secrets-store): add write_secret with flock + atomic replace + fsync (F007 S2) * fix(cli): configure --replace routes through atomic write_secret (F007 S3) * fix(cli): _write_cookie_to_secrets routes through atomic write_secret (F007 S4) * test(secrets-store): cover comment + unknown line preservation (F007 S5) * fix(secrets-store): make fcntl import optional for Windows compatibility * fix(secrets-store): replace empty except with explanatory comment + debug log * fix(secrets-store): validate key/value to reject newlines and bad keys * refactor(cli): drop PLC0415 suppression by moving import to module scope
1 parent 44fefca commit 48beff8

4 files changed

Lines changed: 349 additions & 67 deletions

File tree

autosearch/cli/main.py

Lines changed: 11 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from autosearch import __version__
1111
from autosearch.cli.mcp_config_writers import MCPConfigWriteError
12+
from autosearch.core import secrets_store
1213
from autosearch.core.environment_probe import probe_environment
1314
from autosearch.core.models import SearchMode
1415
from autosearch.core.scope_clarifier import ScopeClarifier
@@ -82,9 +83,7 @@ def main(
8283
# Push ~/.config/ai-secrets.env keys into process env so subcommands and
8384
# any provider/channel that does `os.getenv("FOO_API_KEY")` actually sees
8485
# what the user configured via `autosearch configure`.
85-
from autosearch.core.secrets_store import inject_into_env
86-
87-
inject_into_env()
86+
secrets_store.inject_into_env()
8887

8988
if ctx.invoked_subcommand is None and not version:
9089
raise typer.Exit(code=0)
@@ -483,9 +482,6 @@ def configure(
483482
Default flow: prompts for the value with hidden input so the secret never
484483
appears on the command line, in shell history, or in `ps`.
485484
"""
486-
import shlex
487-
import sys
488-
489485
if from_stdin:
490486
value = sys.stdin.read().rstrip("\n")
491487
elif value is None:
@@ -502,19 +498,8 @@ def configure(
502498
typer.echo("error: value must not be empty.", err=True)
503499
raise typer.Exit(code=2)
504500

505-
# Bug 3 (fix-plan): write target must follow AUTOSEARCH_SECRETS_FILE so
506-
# containers / CI / multi-user installs don't end up writing to A while
507-
# the runtime reads B.
508-
from autosearch.core.secrets_store import secrets_path as _secrets_path # noqa: PLC0415
509-
510-
secrets_path = _secrets_path()
511-
existing: dict[str, str] = {}
512-
if secrets_path.exists():
513-
for line in secrets_path.read_text(encoding="utf-8").splitlines():
514-
stripped = line.strip()
515-
if stripped and not stripped.startswith("#") and "=" in stripped:
516-
k, _, v = stripped.partition("=")
517-
existing[k.strip()] = v
501+
secrets_path = secrets_store.secrets_path()
502+
existing = secrets_store.load_secrets(secrets_path)
518503

519504
if key in existing and not replace:
520505
typer.echo(
@@ -523,24 +508,7 @@ def configure(
523508
)
524509
raise typer.Exit(code=0)
525510

526-
secrets_path.parent.mkdir(parents=True, exist_ok=True)
527-
if key in existing and replace:
528-
# Rewrite the file in place with the new value, preserve all others.
529-
existing[key] = shlex.quote(value)
530-
secrets_path.write_text(
531-
"\n".join(f"{k}={v}" for k, v in existing.items()) + "\n",
532-
encoding="utf-8",
533-
)
534-
else:
535-
with secrets_path.open("a", encoding="utf-8") as fh:
536-
fh.write(f"\n{key}={shlex.quote(value)}\n")
537-
538-
# Restrict file permissions so other users on the box can't read the secrets.
539-
try:
540-
secrets_path.chmod(0o600)
541-
except OSError:
542-
pass
543-
511+
secrets_store.write_secret(key, value, path=secrets_path)
544512
typer.echo(f"Written: {key} -> {secrets_path}")
545513

546514

@@ -770,41 +738,17 @@ def _write_cookie_to_secrets(
770738
the same file we just wrote. Bug 4: chmods the file 0o600 after write
771739
so cookies aren't world-readable on shared boxes.
772740
"""
773-
import shlex
774-
775-
from autosearch.core.secrets_store import secrets_path as _secrets_path
776-
777-
secrets_path = _secrets_path()
778-
secrets_path.parent.mkdir(parents=True, exist_ok=True)
779-
780-
existing_keys: set[str] = set()
781-
if secrets_path.exists():
782-
for line in secrets_path.read_text(encoding="utf-8").splitlines():
783-
stripped = line.strip()
784-
if stripped and not stripped.startswith("#") and "=" in stripped:
785-
existing_keys.add(stripped.split("=", 1)[0].strip())
786-
741+
secrets_path = secrets_store.secrets_path()
742+
existing = secrets_store.load_secrets(secrets_path)
787743
label = f"{n_cookies} cookies" if n_cookies else "cookies"
788-
if env_key in existing_keys:
789-
lines = secrets_path.read_text(encoding="utf-8").splitlines()
790-
new_lines = [
791-
f"{env_key}={shlex.quote(cookie_str)}"
792-
if line.strip().startswith(f"{env_key}=")
793-
else line
794-
for line in lines
795-
]
796-
secrets_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
744+
existed = env_key in existing
745+
746+
secrets_store.write_secret(env_key, cookie_str, path=secrets_path)
747+
if existed:
797748
typer.echo(f"Updated {env_key} ({label}) → {secrets_path}")
798749
else:
799-
with secrets_path.open("a", encoding="utf-8") as fh:
800-
fh.write(f"\n{env_key}={shlex.quote(cookie_str)}\n")
801750
typer.echo(f"Written {env_key} ({label}) → {secrets_path}")
802751

803-
try:
804-
secrets_path.chmod(0o600)
805-
except OSError:
806-
pass
807-
808752

809753
def _stderr_event_writer(event: dict[str, object]) -> None:
810754
sys.stderr.write(json.dumps(event, ensure_ascii=False) + "\n")

autosearch/core/secrets_store.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,21 @@
1717

1818
from __future__ import annotations
1919

20+
import logging
2021
import os
22+
import re
2123
import shlex
24+
import tempfile
2225
from pathlib import Path
2326

27+
try:
28+
import fcntl as _fcntl
29+
except ImportError: # pragma: no cover - exercised by import-time compatibility test
30+
_fcntl = None
31+
2432
_FILE_INJECTED_VALUES: dict[str, str] = {}
33+
_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
34+
_log = logging.getLogger(__name__)
2535

2636

2737
def secrets_path() -> Path:
@@ -63,6 +73,100 @@ def load_secrets(path: Path | None = None) -> dict[str, str]:
6373
return result
6474

6575

76+
def write_secret(key: str, value: str, *, path: Path | None = None) -> None:
77+
"""Atomically write or replace one KEY=value entry in the secrets file."""
78+
_validate_secret_entry(key, value)
79+
target = path or secrets_path()
80+
target.parent.mkdir(parents=True, exist_ok=True)
81+
lock_path = target.with_name(f"{target.name}.lock")
82+
temp_path: str | None = None
83+
84+
with lock_path.open("a+b") as lock_fh:
85+
if _fcntl is not None:
86+
_fcntl.flock(lock_fh.fileno(), _fcntl.LOCK_EX)
87+
try:
88+
try:
89+
existing_text = target.read_text(encoding="utf-8")
90+
except FileNotFoundError:
91+
existing_text = ""
92+
93+
next_text = _replace_or_append_secret(existing_text, key, value)
94+
with tempfile.NamedTemporaryFile(
95+
"w",
96+
encoding="utf-8",
97+
dir=target.parent,
98+
prefix=f"{target.name}.tmp.{os.getpid()}.",
99+
delete=False,
100+
) as temp_fh:
101+
temp_path = temp_fh.name
102+
temp_fh.write(next_text)
103+
temp_fh.flush()
104+
os.fsync(temp_fh.fileno())
105+
106+
os.replace(temp_path, target)
107+
temp_path = None
108+
try:
109+
target.chmod(0o600)
110+
except OSError:
111+
# Some filesystems reject chmod; the secret write itself still succeeded.
112+
_log.debug("Unable to chmod secrets file to 0600: %s", target, exc_info=True)
113+
finally:
114+
try:
115+
if temp_path is not None:
116+
os.unlink(temp_path)
117+
except FileNotFoundError:
118+
# Preserve the original write error if another cleanup path removed the temp file.
119+
_log.debug("Temporary secrets file already removed: %s", temp_path, exc_info=True)
120+
finally:
121+
if _fcntl is not None:
122+
_fcntl.flock(lock_fh.fileno(), _fcntl.LOCK_UN)
123+
124+
125+
def _replace_or_append_secret(text: str, key: str, value: str) -> str:
126+
replacement = _format_secret_line(key, value)
127+
lines = text.splitlines()
128+
replaced = False
129+
next_lines: list[str] = []
130+
131+
for line in lines:
132+
if _secret_line_key(line) == key:
133+
next_lines.append(replacement)
134+
replaced = True
135+
else:
136+
next_lines.append(line)
137+
138+
if not replaced:
139+
next_lines.append(replacement)
140+
return "\n".join(next_lines) + "\n"
141+
142+
143+
def _format_secret_line(key: str, value: str) -> str:
144+
_validate_secret_entry(key, value)
145+
return f"{key}={shlex.quote(value)}"
146+
147+
148+
def _validate_secret_entry(key: str, value: str) -> None:
149+
if not _ENV_KEY_RE.fullmatch(key):
150+
raise ValueError("secret key must match [A-Za-z_][A-Za-z0-9_]*")
151+
if any(char in value for char in ("\n", "\r", "\0")):
152+
raise ValueError("secret value must not contain newline, carriage return, or NUL")
153+
154+
155+
def _secret_line_key(raw: str) -> str | None:
156+
line = raw.strip()
157+
if not line or line.startswith("#") or "=" not in line:
158+
return None
159+
key, _, value = line.partition("=")
160+
key = key.strip()
161+
if not key or not key.replace("_", "").isalnum():
162+
return None
163+
try:
164+
shlex.split(value)
165+
except ValueError:
166+
return None
167+
return key
168+
169+
66170
def available_env_keys(path: Path | None = None) -> set[str]:
67171
"""Return the set of keys with non-empty values in env OR the secrets file."""
68172
keys = {key for key, val in os.environ.items() if val}

0 commit comments

Comments
 (0)