Files
DocsGPT/docsgpt/deploy/envfile.py
T
Alex 303f239fcd fix: tighten an existing .env to 0600 before rewriting it; CI checks secrets have values
envfile.update only applied 0600 when it created the file, so an existing
.env with a wider mode kept it while secrets were written into it. The mode
is now set on the open descriptor before the file is truncated and written.

The CI step now requires POSTGRES_PASSWORD and JWT_SECRET_KEY to have values:
an empty one falls back to a default without any check noticing.
2026-09-16 00:11:38 +01:00

81 lines
3.2 KiB
Python

"""Read and update a ``.env`` file in place, keeping the lines the user wrote."""
from __future__ import annotations
import os
import re
from collections.abc import Mapping
from pathlib import Path
from typing import Optional
_ASSIGNMENT = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$")
# Values written without quotes: nothing Compose would interpolate, strip or treat as a comment.
_PLAIN = re.compile(r"^[A-Za-z0-9_./:@+,=-]*$")
def _parse_value(raw: str) -> str:
"""The value of one assignment, with Compose's quoting rules."""
value = raw.strip()
if len(value) >= 2 and value[0] == value[-1] == "'":
return value[1:-1]
if len(value) >= 2 and value[0] == value[-1] == '"':
return re.sub(r'\\(["\\])', r"\1", value[1:-1]).replace("$$", "$")
return value.split(" #", 1)[0].rstrip()
def _format_value(value: str) -> str:
"""``value`` quoted so it reads back unchanged, and so Compose passes it on unchanged."""
if "\n" in value or "\r" in value:
raise ValueError("a .env value cannot contain a newline")
if _PLAIN.match(value):
return value
if "'" not in value:
# Compose does not interpolate single-quoted values.
return f"'{value}'"
# Double quotes are interpolated: $$ is Compose's literal dollar.
return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "$$") + '"'
def read(path: Path) -> dict[str, str]:
"""The assignments in ``path`` (empty when it does not exist); a repeated key keeps its last value."""
path = Path(path)
if not path.is_file():
return {}
values: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
match = _ASSIGNMENT.match(line)
if match:
values[match.group(1)] = _parse_value(match.group(2))
return values
def update(path: Path, values: Mapping[str, Optional[str]]) -> None:
"""Set each key in place (``None`` removes it), append new keys, and leave every other line alone.
The file is left readable by its owner only, including one that existed with a wider mode: it holds secrets.
"""
formatted = {key: None if value is None else _format_value(value) for key, value in values.items()}
path = Path(path)
lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
written: set[str] = set()
out: list[str] = []
for line in lines:
match = _ASSIGNMENT.match(line)
key = match.group(1) if match else None
if key not in formatted:
out.append(line)
continue
if key not in written and formatted[key] is not None:
out.append(f"{key}={formatted[key]}")
written.add(key)
out.extend(f"{key}={value}" for key, value in formatted.items() if key not in written and value is not None)
path.parent.mkdir(parents=True, exist_ok=True)
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT, 0o600)
if hasattr(os, "fchmod"):
# The creation mode only applies to a new file; tighten an existing one before writing.
os.fchmod(descriptor, 0o600)
os.ftruncate(descriptor, 0)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write("\n".join(out) + "\n" if out else "")