Skip to content

Commit ec1d691

Browse files
authored
Merge pull request #1857 from transformerlab/fix/cli-config-atomic-write
fix(cli): prevent ~/.lab/config.json from being silently wiped
2 parents ed13129 + 38255c6 commit ec1d691

6 files changed

Lines changed: 153 additions & 6 deletions

File tree

cli/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "transformerlab-cli"
7-
version = "0.0.21"
7+
version = "0.0.22"
88
description = "Transformer Lab CLI"
99
requires-python = ">=3.10"
1010
authors = [{ name = "Transformer Lab", email = "hello@transformerlab.ai" }]

cli/src/transformerlab_cli/util/config.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import json
3+
import time
34
from typing import Any
45
from urllib.parse import urlparse
56

@@ -21,7 +22,12 @@
2122

2223

2324
def load_config() -> dict[str, Any]:
24-
"""Load config from file, return empty dict if not found."""
25+
"""Load config from file, return empty dict if not found.
26+
27+
If the file exists but cannot be parsed (corrupt/truncated), move it
28+
aside to config.json.corrupt-<ts> and return {} so the next write
29+
does not silently overwrite a recoverable file.
30+
"""
2531
global cached_config
2632

2733
if cached_config is not None:
@@ -33,21 +39,47 @@ def load_config() -> dict[str, Any]:
3339
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
3440
cached_config = json.loads(f.read())
3541
return cached_config
36-
except (json.JSONDecodeError, OSError):
42+
except (json.JSONDecodeError, OSError) as e:
43+
backup_path = f"{CONFIG_FILE}.corrupt-{int(time.time())}"
44+
try:
45+
os.rename(CONFIG_FILE, backup_path)
46+
console.print(
47+
f"[error]Error:[/error] Config file is unreadable ({e}). "
48+
f"Moved to [label]{backup_path}[/label] to avoid overwriting it."
49+
)
50+
except OSError as rename_err:
51+
console.print(
52+
f"[error]Error:[/error] Config file is unreadable ({e}) and could not be "
53+
f"backed up ({rename_err}). Refusing to continue."
54+
)
55+
raise typer.Exit(1)
3756
return {}
3857

3958

4059
def _save_config(config: dict[str, Any]) -> bool:
41-
"""Save config to file. Returns True on success."""
60+
"""Save config to file atomically. Returns True on success.
61+
62+
Writes to a sibling tmp file and renames over the target so readers
63+
never observe a truncated or partially-written config.
64+
"""
4265
global cached_config
66+
tmp_path = f"{CONFIG_FILE}.tmp"
4367
try:
4468
os.makedirs(CONFIG_DIR, exist_ok=True)
45-
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
69+
with open(tmp_path, "w", encoding="utf-8") as f:
4670
f.write(json.dumps(config, indent=2))
71+
f.flush()
72+
os.fsync(f.fileno())
73+
os.replace(tmp_path, CONFIG_FILE)
4774
cached_config = config
4875
return True
4976
except OSError as e:
5077
console.print(f"[error]Error:[/error] Failed to save config: {e}")
78+
try:
79+
if os.path.exists(tmp_path):
80+
os.unlink(tmp_path)
81+
except OSError:
82+
pass
5183
return False
5284

5385

cli/tests/commands/test_config.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Tests for config command and utility functions."""
22

33
import json
4+
import os
45
from typer.testing import CliRunner
56
from transformerlab_cli.main import app
67
from transformerlab_cli.util.config import (
@@ -137,6 +138,54 @@ def test_load_config_with_data(tmp_config_dir):
137138
assert config["server"] == "http://test:8338"
138139

139140

141+
def test_load_config_corrupt_moves_aside_and_returns_empty(tmp_config_dir):
142+
"""A corrupt config must not silently resolve to {}; it must be renamed aside."""
143+
import glob
144+
145+
config_dir, config_file = tmp_config_dir
146+
with open(config_file, "w", encoding="utf-8") as f:
147+
f.write("{not: valid json,,")
148+
with _patch_config_paths(config_dir, config_file):
149+
config = load_config()
150+
assert config == {}
151+
assert not os.path.exists(config_file), "corrupt config should have been renamed aside"
152+
backups = glob.glob(f"{config_file}.corrupt-*")
153+
assert len(backups) == 1, f"expected one backup, got {backups}"
154+
155+
156+
def test_set_config_does_not_wipe_existing_keys_when_file_is_fine(tmp_config_dir):
157+
"""Regression: a set_config call must merge, never wipe unrelated keys."""
158+
config_dir, config_file = tmp_config_dir
159+
with open(config_file, "w", encoding="utf-8") as f:
160+
f.write(
161+
json.dumps(
162+
{
163+
"server": "http://localhost:8338",
164+
"team_id": "abc-123",
165+
"user_email": "u@example.com",
166+
"current_experiment": "alpha",
167+
}
168+
)
169+
)
170+
with _patch_config_paths(config_dir, config_file):
171+
assert set_config("current_experiment", "beta") is True
172+
with open(config_file, "r", encoding="utf-8") as f:
173+
data = json.loads(f.read())
174+
assert data["server"] == "http://localhost:8338"
175+
assert data["team_id"] == "abc-123"
176+
assert data["user_email"] == "u@example.com"
177+
assert data["current_experiment"] == "beta"
178+
179+
180+
def test_save_config_is_atomic_no_tmp_left_behind(tmp_config_dir):
181+
"""After a successful save, the sibling .tmp file must not exist."""
182+
config_dir, config_file = tmp_config_dir
183+
with _patch_config_paths(config_dir, config_file):
184+
assert set_config("server", "http://localhost:8338") is True
185+
assert os.path.exists(config_file)
186+
assert not os.path.exists(f"{config_file}.tmp")
187+
188+
140189
# --- JSON format output ---
141190

142191

cli/tests/commands/test_login.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,35 @@ def test_logout_success(_mock_delete):
5050
"""Test successful logout."""
5151
result = runner.invoke(app, ["logout"])
5252
assert result.exit_code == 0
53+
54+
55+
@patch("transformerlab_cli.commands.logout.delete_api_key", return_value=True)
56+
def test_logout_clears_user_and_team_keys_but_not_server(_mock_delete):
57+
"""Regression: logout must only clear session-scoped keys, keep server.
58+
59+
And, via the autouse isolation fixture, must never touch the real
60+
~/.lab/config.json on the developer's machine.
61+
"""
62+
import json
63+
64+
from transformerlab_cli.util.shared import CONFIG_FILE
65+
66+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
67+
f.write(
68+
json.dumps(
69+
{
70+
"server": "http://localhost:8338",
71+
"team_id": "abc-123",
72+
"team_name": "Team Name",
73+
"user_email": "user@example.com",
74+
"current_experiment": "alpha",
75+
}
76+
)
77+
)
78+
79+
result = runner.invoke(app, ["logout"])
80+
assert result.exit_code == 0
81+
82+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
83+
data = json.loads(f.read())
84+
assert data == {"server": "http://localhost:8338"}, data

cli/tests/conftest.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,40 @@
77
import pytest
88

99

10+
@pytest.fixture(autouse=True)
11+
def _isolate_lab_config_from_user_home(tmp_path, monkeypatch):
12+
"""Point CONFIG_DIR/CONFIG_FILE and CREDENTIALS_DIR/CREDENTIALS_FILE at a
13+
per-test tmp directory so tests cannot read from — or wipe — the real
14+
``~/.lab/config.json`` / ``~/.lab/credentials`` on the developer's machine.
15+
16+
Applied to every test via ``autouse=True``; any test that needs the raw
17+
paths can still accept the ``tmp_config_dir`` fixture explicitly.
18+
"""
19+
fake_lab_dir = tmp_path / "_isolated_lab"
20+
fake_lab_dir.mkdir(exist_ok=True)
21+
fake_config_file = str(fake_lab_dir / "config.json")
22+
fake_credentials_file = str(fake_lab_dir / "credentials")
23+
24+
import transformerlab_cli.util.config as config_mod
25+
import transformerlab_cli.util.shared as shared_mod
26+
27+
monkeypatch.setattr(shared_mod, "CONFIG_DIR", str(fake_lab_dir))
28+
monkeypatch.setattr(shared_mod, "CONFIG_FILE", fake_config_file)
29+
monkeypatch.setattr(shared_mod, "CREDENTIALS_DIR", str(fake_lab_dir))
30+
monkeypatch.setattr(shared_mod, "CREDENTIALS_FILE", fake_credentials_file)
31+
monkeypatch.setattr(config_mod, "CONFIG_DIR", str(fake_lab_dir))
32+
monkeypatch.setattr(config_mod, "CONFIG_FILE", fake_config_file)
33+
monkeypatch.setattr(config_mod, "cached_config", None)
34+
35+
try:
36+
import transformerlab_cli.util.auth as auth_mod
37+
38+
monkeypatch.setattr(auth_mod, "CREDENTIALS_DIR", str(fake_lab_dir), raising=False)
39+
monkeypatch.setattr(auth_mod, "CREDENTIALS_FILE", fake_credentials_file, raising=False)
40+
except ImportError:
41+
pass
42+
43+
1044
@pytest.fixture()
1145
def tmp_config_dir(tmp_path):
1246
"""Provide a temporary config directory and patch CONFIG_DIR/CONFIG_FILE."""

cli/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)