Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/timecapsulesmb/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment on lines +658 to +662

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The int(raw, 8) call will raise a ValueError if the user specifies the octal mode with a standard Python octal prefix like 0o or 0O (e.g., 0o640). This causes it to silently fall back to the default 0o600 mode, which defeats the user's override. Stripping the 0o or 0O prefix if present makes the override more robust and user-friendly.

Suggested change
raw = environ.get(ENV_FILE_MODE_ENV, "").strip()
if not raw:
return DEFAULT_ENV_FILE_MODE
try:
mode = int(raw, 8)
raw = environ.get(ENV_FILE_MODE_ENV, "").strip()
if not raw:
return DEFAULT_ENV_FILE_MODE
if raw.lower().startswith("0o"):
raw = raw[2:]
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(
Expand All @@ -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:
Expand Down
34 changes: 34 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import os
import shlex
import tempfile
import unittest
Expand All @@ -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,
Expand Down Expand Up @@ -205,6 +208,37 @@ 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)
Comment on lines +214 to +215

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a test case to verify that env_file_target_mode correctly handles the standard Python octal prefix 0o (e.g., 0o640).

Suggested change
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": "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)

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)

@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"
Expand Down