Skip to content

Commit e4fc9eb

Browse files
Ilanlidoclaude
andcommitted
CM-71568 - Write config files atomically and quarantine corrupt ones
update_yaml_file did a read-modify-write with open(filename, 'w'), which truncates in place. Two concurrent cycode processes both truncated to zero and each kept its own file offset, so whichever wrote fewer bytes left the longer writer's tail behind past its end. The boundary is a raw byte offset, so it landed mid-token and produced an unparseable credentials.yaml. The size gap is built in: refresh_access_token persists a ~900 char JWT while invalidate_access_token persists nulls. Parallel AI guardrails hook invocations put both on the same file at the same time. Recovery was impossible because update_yaml_file reads before it writes, so cycode auth raised the ScannerError before it could write the repair. The hook then respawned cycode auth, forever. Write to a sibling temp file and os.replace it into place, so a reader never observes a partial file and a short write cannot leave a long tail. On a YAMLError, move the file aside as <name>.corrupt-<n> and carry on with an empty config; this sits at the single choke point every command goes through, so cycode auth can now repair the machine on its own. Concurrent updates can still lose each other's keys, which is accepted: the losing writer's credentials are still valid ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f112931 commit e4fc9eb

2 files changed

Lines changed: 152 additions & 5 deletions

File tree

‎cycode/cli/utils/yaml_utils.py‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import tempfile
23
from collections.abc import Hashable
34
from typing import Any, TextIO
45

@@ -34,22 +35,52 @@ def _yaml_object_safe_load(file: TextIO) -> dict[Hashable, Any]:
3435
return loaded_file
3536

3637

38+
def _quarantine_corrupt_file(filename: str) -> None:
39+
# Renamed rather than deleted: the file may hold the only copy of the user's credentials,
40+
# and keeping it around leaves something to look at in the next bug report.
41+
index = 0
42+
while os.path.exists(f'{filename}.corrupt-{index}'):
43+
index += 1
44+
45+
try:
46+
os.replace(filename, f'{filename}.corrupt-{index}')
47+
except OSError as e:
48+
logger.warning('Failed to quarantine corrupt file, %s', {'filename': filename}, exc_info=e)
49+
50+
3751
def read_yaml_file(filename: str) -> dict[Hashable, Any]:
3852
if not os.access(filename, os.R_OK) or not os.path.exists(filename):
3953
logger.debug('Config file is not accessible or does not exist: %s', {'filename': filename})
4054
return {}
4155

42-
with open(filename, encoding='UTF-8') as file:
43-
return _yaml_object_safe_load(file)
56+
try:
57+
with open(filename, encoding='UTF-8') as file:
58+
return _yaml_object_safe_load(file)
59+
except yaml.YAMLError as e:
60+
logger.warning('Config file is corrupt and will be moved aside, %s', {'filename': filename}, exc_info=e)
61+
_quarantine_corrupt_file(filename)
62+
return {}
4463

4564

4665
def write_yaml_file(filename: str, content: dict[Hashable, Any]) -> None:
47-
if not os.access(filename, os.W_OK) and os.path.exists(filename):
66+
directory = os.path.dirname(filename)
67+
if not os.access(directory, os.W_OK) or (os.path.exists(filename) and not os.access(filename, os.W_OK)):
4868
logger.warning('No write permission for file. Cannot save config, %s', {'filename': filename})
4969
return
5070

51-
with open(filename, 'w', encoding='UTF-8') as file:
52-
yaml.safe_dump(content, file)
71+
# Atomic write to avoid race conditions between concurrent CLI processes
72+
file_descriptor, temp_filename = tempfile.mkstemp(dir=directory, prefix=f'.{os.path.basename(filename)}.')
73+
try:
74+
with os.fdopen(file_descriptor, 'w', encoding='UTF-8') as file:
75+
yaml.safe_dump(content, file)
76+
file.flush()
77+
os.fsync(file.fileno())
78+
79+
os.replace(temp_filename, filename)
80+
except Exception:
81+
if os.path.exists(temp_filename):
82+
os.remove(temp_filename)
83+
raise
5384

5485

5586
def update_yaml_file(filename: str, content: dict[Hashable, Any]) -> None:

‎tests/utils/test_yaml_utils.py‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import multiprocessing
2+
import os
3+
from pathlib import Path
4+
5+
import pytest
6+
import yaml
7+
from pyfakefs.fake_filesystem import FakeFilesystem
8+
9+
from cycode.cli.utils.yaml_utils import read_yaml_file, update_yaml_file, write_yaml_file
10+
11+
# refresh_access_token() persists a ~900 char JWT, invalidate_access_token() persists nulls.
12+
# That size gap is what let a short write leave a longer write's tail behind it.
13+
_LONG_TOKEN = 'eyJhbGciOiJIUzI1NiJ9.' + ('A' * 800) + '.signature'
14+
_ITERATIONS = 200
15+
16+
_CLIENT_ID = 'b3a1f2c4-1111-2222-3333-444455556666'
17+
_CORRUPT_CONTENT = 'cycode_client_id: valid\nycode_client_id\nleftover tail\n'
18+
19+
_DIRECTORY = '/home/user/.cycode'
20+
_FILENAME = f'{_DIRECTORY}/credentials.yaml'
21+
22+
23+
def _write_long_token(filename: str) -> None:
24+
for _ in range(_ITERATIONS):
25+
update_yaml_file(
26+
filename,
27+
{
28+
'cycode_access_token': _LONG_TOKEN,
29+
'cycode_access_token_creator': 'h' * 64,
30+
'cycode_access_token_expires_in': 1755000000.123456,
31+
},
32+
)
33+
34+
35+
def _write_null_token(filename: str) -> None:
36+
for _ in range(_ITERATIONS):
37+
update_yaml_file(
38+
filename,
39+
{
40+
'cycode_access_token': None,
41+
'cycode_access_token_creator': None,
42+
'cycode_access_token_expires_in': None,
43+
},
44+
)
45+
46+
47+
# Real processes cannot see a pyfakefs filesystem, so this one case has to touch the disk.
48+
def test_concurrent_updates_never_corrupt_the_file(tmp_path: Path) -> None:
49+
filename = str(tmp_path / 'credentials.yaml')
50+
write_yaml_file(filename, {'cycode_client_id': _CLIENT_ID, 'cycode_client_secret': 's' * 40})
51+
52+
context = multiprocessing.get_context('spawn')
53+
processes = [context.Process(target=target, args=(filename,)) for target in (_write_long_token, _write_null_token)]
54+
for process in processes:
55+
process.start()
56+
for process in processes:
57+
process.join(timeout=120)
58+
assert process.exitcode == 0, f'writer process failed with exit code {process.exitcode}'
59+
60+
with open(filename, encoding='UTF-8') as file:
61+
content = yaml.safe_load(file)
62+
63+
assert isinstance(content, dict)
64+
assert content['cycode_client_id'] == _CLIENT_ID
65+
66+
67+
def test_reading_a_corrupt_file_quarantines_it_and_returns_empty(fs: FakeFilesystem) -> None:
68+
fs.create_file(_FILENAME, contents=_CORRUPT_CONTENT)
69+
70+
assert read_yaml_file(_FILENAME) == {}
71+
assert not os.path.exists(_FILENAME)
72+
73+
quarantined = list(Path(_DIRECTORY).glob('credentials.yaml.corrupt-*'))
74+
assert len(quarantined) == 1
75+
assert quarantined[0].read_text(encoding='UTF-8') == _CORRUPT_CONTENT
76+
77+
78+
def test_updating_a_corrupt_file_recovers_instead_of_raising(fs: FakeFilesystem) -> None:
79+
fs.create_file(_FILENAME, contents=_CORRUPT_CONTENT)
80+
81+
update_yaml_file(_FILENAME, {'cycode_client_id': 'recovered'})
82+
83+
assert read_yaml_file(_FILENAME) == {'cycode_client_id': 'recovered'}
84+
85+
86+
def test_quarantine_does_not_overwrite_an_earlier_quarantined_file(fs: FakeFilesystem) -> None:
87+
fs.create_dir(_DIRECTORY)
88+
for marker in ('first', 'second'):
89+
with open(_FILENAME, 'w', encoding='UTF-8') as file:
90+
file.write(f'{marker}\nnot: [valid\n')
91+
read_yaml_file(_FILENAME)
92+
93+
quarantined = [path.read_text(encoding='UTF-8') for path in Path(_DIRECTORY).glob('credentials.yaml.corrupt-*')]
94+
95+
assert len(quarantined) == 2
96+
assert any('first' in content for content in quarantined)
97+
assert any('second' in content for content in quarantined)
98+
99+
100+
@pytest.mark.parametrize('read_only_path', [_DIRECTORY, _FILENAME])
101+
def test_write_is_skipped_on_a_read_only_filesystem(fs: FakeFilesystem, read_only_path: str) -> None:
102+
fs.create_dir(_DIRECTORY)
103+
write_yaml_file(_FILENAME, {'cycode_client_id': 'original'})
104+
fs.chmod(read_only_path, 0o500)
105+
106+
write_yaml_file(_FILENAME, {'cycode_client_id': 'updated'})
107+
108+
fs.chmod(read_only_path, 0o700)
109+
assert read_yaml_file(_FILENAME) == {'cycode_client_id': 'original'}
110+
111+
112+
def test_write_leaves_no_temporary_files_behind(fs: FakeFilesystem) -> None:
113+
fs.create_dir(_DIRECTORY)
114+
write_yaml_file(_FILENAME, {'cycode_client_id': _CLIENT_ID})
115+
116+
assert [path.name for path in Path(_DIRECTORY).iterdir()] == ['credentials.yaml']

0 commit comments

Comments
 (0)