From 8d7326c08e2a959821ca4333235cdaf3f3360700 Mon Sep 17 00:00:00 2001 From: Jackson Smith <42005615+jackson-asmith@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:39:40 -0400 Subject: [PATCH 1/2] Default .env to 0600 but allow overriding the mode Reworks the closed #252 per maintainer feedback: instead of unconditionally forcing 0600 (which breaks Docker/multi-user setups that need group access to .env), default to 0600 and let power users pick a different mode via the TCAPSULE_ENV_FILE_MODE env var (octal, e.g. 640). Invalid or out-of-range values fall back to 0600. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- src/timecapsulesmb/core/config.py | 21 ++++++++++++++++++++ tests/test_config.py | 33 +++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ceb5fdc5..a16857bb 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ At the start of `configure`, the tool first tries to discover your Time Capsule `configure` also checks whether SSH is reachable. If SSH is closed, it enables SSH using the built-in Python 3 ACP client, reboots the device, waits for SSH to come up, and then continues the normal probing flow. If the password is wrong, it asks again instead of writing a broken `.env` file. -The password you enter here is stored locally as `TC_PASSWORD` so the tool can keep using SSH and ACP. The managed Samba runtime reads the current device password on the Time Capsule at boot. In other words, after setup, you normally connect with: +The password you enter here is stored locally as `TC_PASSWORD` so the tool can keep using SSH and ACP. The `.env` file is written owner-only (`0600`) by default; if you need a different mode (for example a shared Docker or multi-user setup), set `TCAPSULE_ENV_FILE_MODE` to an octal mode like `640`. The managed Samba runtime reads the current device password on the Time Capsule at boot. In other words, after setup, you normally connect with: - username: `admin` (or any other password) - password: the same Time Capsule password you entered during configuration diff --git a/src/timecapsulesmb/core/config.py b/src/timecapsulesmb/core/config.py index 4f56bdae..ec56bf19 100644 --- a/src/timecapsulesmb/core/config.py +++ b/src/timecapsulesmb/core/config.py @@ -3,6 +3,7 @@ import shlex from dataclasses import dataclass from pathlib import Path +from collections.abc import Mapping from typing import Callable, Optional import os import re @@ -648,8 +649,27 @@ def preserved_env_file_values(values: dict[str, str]) -> dict[str, str]: return {key: value for key, value in values.items() if key not in ENV_FILE_OMIT_KEYS} +ENV_FILE_MODE_ENV = "TCAPSULE_ENV_FILE_MODE" +DEFAULT_ENV_FILE_MODE = 0o600 + + +def env_file_target_mode(env: Mapping[str, str] | None = None) -> int: + environ = os.environ if env is None else env + raw = environ.get(ENV_FILE_MODE_ENV, "").strip() + if not raw: + return DEFAULT_ENV_FILE_MODE + try: + mode = int(raw, 8) + except ValueError: + return DEFAULT_ENV_FILE_MODE + if 0 <= mode <= 0o777: + return mode + return DEFAULT_ENV_FILE_MODE + + def write_env_file(path: Path, values: dict[str, str]) -> None: text = render_env_text(values) + mode = env_file_target_mode() tmp_name: str | None = None try: with tempfile.NamedTemporaryFile( @@ -664,6 +684,7 @@ def write_env_file(path: Path, values: dict[str, str]) -> None: tmp.write(text) tmp.flush() os.fsync(tmp.fileno()) + os.chmod(tmp_name, mode) os.replace(tmp_name, path) finally: if tmp_name is not None: diff --git a/tests/test_config.py b/tests/test_config.py index d1b23277..3ab619c1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import shlex import tempfile import unittest @@ -19,7 +20,9 @@ ConfigError, ConfigValidationError, build_mdns_device_model_txt, + DEFAULT_ENV_FILE_MODE, DEFAULTS, + env_file_target_mode, load_app_config, parse_bool, parse_env_file, @@ -205,6 +208,36 @@ def test_validate_bool_accepts_true_false_and_missing_default(self) -> None: self.assertIsNone(validate_bool("", "Flag")) self.assertEqual(validate_bool("yes", "Flag"), "Flag must be true or false.") + def test_env_file_target_mode_defaults_and_overrides(self) -> None: + self.assertEqual(env_file_target_mode({}), DEFAULT_ENV_FILE_MODE) + self.assertEqual(env_file_target_mode({}), 0o600) + self.assertEqual(env_file_target_mode({"TCAPSULE_ENV_FILE_MODE": "640"}), 0o640) + self.assertEqual(env_file_target_mode({"TCAPSULE_ENV_FILE_MODE": "0640"}), 0o640) + # Invalid or out-of-range values fall back to the safe default. + self.assertEqual(env_file_target_mode({"TCAPSULE_ENV_FILE_MODE": "not-octal"}), 0o600) + self.assertEqual(env_file_target_mode({"TCAPSULE_ENV_FILE_MODE": "99999"}), 0o600) + + @unittest.skipUnless(os.name == "posix", "POSIX file modes only") + def test_write_env_file_defaults_to_0600(self) -> None: + values = dict(DEFAULTS) + values["TC_PASSWORD"] = "secret" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ".env" + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("TCAPSULE_ENV_FILE_MODE", None) + write_env_file(path, values) + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + + @unittest.skipUnless(os.name == "posix", "POSIX file modes only") + def test_write_env_file_honors_mode_override(self) -> None: + values = dict(DEFAULTS) + values["TC_PASSWORD"] = "secret" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ".env" + with mock.patch.dict(os.environ, {"TCAPSULE_ENV_FILE_MODE": "640"}): + write_env_file(path, values) + self.assertEqual(path.stat().st_mode & 0o777, 0o640) + def test_write_env_file_omits_mdns_device_model(self) -> None: values = dict(DEFAULTS) values["TC_PASSWORD"] = "secret" From 82cc7feb3c68b44f57053ee7c399e39353437cba Mon Sep 17 00:00:00 2001 From: Jackson Smith <42005615+jackson-asmith@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:29:51 -0400 Subject: [PATCH 2/2] Cover 0o-prefixed octal mode override in tests Co-Authored-By: Claude Fable 5 --- tests/test_config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_config.py b/tests/test_config.py index 3ab619c1..ba8d8d53 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -213,6 +213,7 @@ def test_env_file_target_mode_defaults_and_overrides(self) -> None: self.assertEqual(env_file_target_mode({}), 0o600) self.assertEqual(env_file_target_mode({"TCAPSULE_ENV_FILE_MODE": "640"}), 0o640) self.assertEqual(env_file_target_mode({"TCAPSULE_ENV_FILE_MODE": "0640"}), 0o640) + self.assertEqual(env_file_target_mode({"TCAPSULE_ENV_FILE_MODE": "0o640"}), 0o640) # Invalid or out-of-range values fall back to the safe default. self.assertEqual(env_file_target_mode({"TCAPSULE_ENV_FILE_MODE": "not-octal"}), 0o600) self.assertEqual(env_file_target_mode({"TCAPSULE_ENV_FILE_MODE": "99999"}), 0o600)