fix(config): merge write_config() onto disk instead of rebuilding from the startup snapshot (#3128)

write_config() rebuilt config.ini's [default] section entirely from the
startup snapshot into a fresh parser opened 'w'. Any Manager settings
change therefore silently reverted values hand-edited while ComfyUI was
running (the same config['default'] flags those messages tell users to
edit), deleted the two read-but-never-written keys (http_channel_enabled,
default_cache_as_channel_url), and erased every non-[default] section.

get_config() now tracks which keys callers actually change, and
write_config() re-reads config.ini and overlays only those keys plus the
live preview_method. After a successful write the just-persisted keys are
cleared from the tracker, so a key changed once through the UI does not
stay dirty for the life of the process and re-clobber a later hand-edit of
that same key on the next unrelated write. Hand-edited values, unknown
keys, and foreign sections survive; CRLF sanitization still applies to
every value written; an unparsable config.ini falls back to a full rewrite
instead of failing every settings endpoint.

configparser.read() suppresses OSError, so a config.ini that exists but
cannot be read comes back as an empty parser rather than an exception,
which would silently route the merge into the bootstrap branch and rewrite
the file from defaults. write_config() now detects that case (the file
exists but read() loaded nothing) and refuses the write with a raised
error, leaving the file untouched, rather than destroying its contents
while reporting success.

tests/test_write_config_persistence.py pins the three loss classes
(hand-edit revert, key deletion, section erasure), the settings
round-trip, the persisted-key re-clobber guard, and the silent-read-failure
refusal.
This commit is contained in:
Dr.Lt.Data
2026-08-14 06:31:38 +09:00
parent ffb2f03e92
commit aaed26a5a4
2 changed files with 628 additions and 25 deletions
+157 -25
View File
@@ -1679,34 +1679,146 @@ class ManagerFuncs:
manager_funcs = ManagerFuncs()
#: The keys `write_config()` owns in `config.ini`'s `[default]` section.
#: Anything else in the file - other keys, other sections - is hand-maintained
#: content that this function must carry through untouched.
#: `preview_method` is the one key that is NOT snapshot-backed: it is always
#: written from the live `manager_funcs.get_current_preview_method()`.
WRITTEN_CONFIG_KEYS = (
'preview_method',
'git_exe',
'use_uv',
'channel_url',
'share_option',
'bypass_ssl',
'file_logging',
'component_policy',
'update_policy',
'windows_selector_event_loop_policy',
'model_download_by_agent',
'downgrade_blacklist',
'security_level',
'always_lazy_install',
'network_mode',
'db_mode',
'allow_git_url_install',
'allow_pip_install',
)
class DirtyTrackingConfig(dict):
"""Config mapping that records which keys were changed after it was built.
`write_config()` must persist ONLY the setting a caller actually changed,
otherwise it re-writes the other keys from the process-lifetime startup
snapshot and silently reverts whatever the user hand-edited in the
meantime. Every settings caller mutates the cached config the same way -
a subscript assignment such as `get_config()['db_mode'] = mode` - so
recording `__setitem__` is what makes "only what changed" knowable.
`update()` is covered too, so a future caller written that way cannot
lose its change silently.
The wrapper is applied in `get_config()` AFTER `read_config()` has
returned, and that ordering is load-bearing:
`manager_migration.force_security_level_if_needed()` mutates the config
through a parameter alias from inside BOTH `read_config()` branches. If
that mutation were recorded, `security_level` would be dirty before the
user does anything, and every write would push the forced value over the
user's on-disk one - the exact clobber this tracking exists to remove.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.dirty_keys = set()
def __setitem__(self, key, value):
self.dirty_keys.add(key)
super().__setitem__(key, value)
def update(self, *args, **kwargs):
incoming = dict(*args, **kwargs)
self.dirty_keys.update(incoming)
super().update(incoming)
def write_config():
"""Persist changed Manager settings without clobbering the rest of the file.
The write starts from what is CURRENTLY on disk and overlays only the keys
a caller actually changed (tracked by `DirtyTrackingConfig`) plus the live
`preview_method`. Consequences, in the order users notice them:
- a value hand-edited while the server is up is no longer reverted by an
unrelated settings change;
- keys this function does not own (`http_channel_enabled`,
`default_cache_as_channel_url`, anything a user added) survive;
- sections other than `[default]` survive.
Reads still come from the startup snapshot until the process restarts, so
the "stop -> edit -> start" advice in the install-denial messages stays
correct; what changes is that the edit is no longer destroyed in between.
"""
config = configparser.ConfigParser(strict=False)
try:
loaded = config.read(manager_config_path)
except Exception as e:
# An unparsable config.ini must not make a settings change raise. Fall
# back to the pre-merge behaviour and rewrite the file from scratch.
print(f"[ComfyUI-Manager] Warning: '{manager_config_path}' could not be parsed ({e}); rewriting it from the current settings.")
config = configparser.ConfigParser(strict=False)
else:
# `read()` opens each filename inside its own `try/except OSError` and
# SKIPS the ones it could not open, so a file that exists but denies
# reads comes back as a normal return with an EMPTY parser - not as the
# exception handled above. Merging from that parser would find no
# `[default]`, fall into the bootstrap branch below, and rewrite the
# file from defaults: every foreign key and every other section gone,
# with the settings change still reporting success.
#
# A write that cannot see the current contents has nothing to merge
# onto, so refuse it and leave the file alone. The caller surfaces the
# error (the settings endpoint answers 500), which is what the same
# permission problem already does when writes are denied too.
if not loaded and os.path.exists(manager_config_path):
raise OSError(f"'{manager_config_path}' exists but could not be read; refusing to overwrite it with default settings. Fix the file's permissions, then retry.")
config['default'] = {
'preview_method': manager_funcs.get_current_preview_method(),
'git_exe': get_config()['git_exe'],
'use_uv': get_config()['use_uv'],
'channel_url': get_config()['channel_url'],
'share_option': get_config()['share_option'],
'bypass_ssl': get_config()['bypass_ssl'],
"file_logging": get_config()['file_logging'],
'component_policy': get_config()['component_policy'],
'update_policy': get_config()['update_policy'],
'windows_selector_event_loop_policy': get_config()['windows_selector_event_loop_policy'],
'model_download_by_agent': get_config()['model_download_by_agent'],
'downgrade_blacklist': get_config()['downgrade_blacklist'],
'security_level': get_config()['security_level'],
'always_lazy_install': get_config()['always_lazy_install'],
'network_mode': get_config()['network_mode'],
'db_mode': get_config()['db_mode'],
'allow_git_url_install': get_config()['allow_git_url_install'],
'allow_pip_install': get_config()['allow_pip_install'],
}
cached = get_config()
# Sanitize all string values to prevent CRLF injection attacks
for key, value in config['default'].items():
if isinstance(value, str):
config['default'][key] = value.replace('\r', '').replace('\n', '').replace('\x00', '')
if config.has_section('default'):
# Ordinary settings change: persist only what this caller changed.
# The `getattr` fallback covers a `cached_config` that is a plain dict
# (nothing in this tree does that - it is here so a caller that
# bypasses get_config() degrades to the old full rewrite rather than
# writing nothing at all).
changed_keys = getattr(cached, 'dirty_keys', set(WRITTEN_CONFIG_KEYS))
# Intersecting with WRITTEN_CONFIG_KEYS means a key this function does
# not own is NOT persisted even when a caller dirties it - unchanged
# from before, since those keys were never written either. Two such
# keys are live in the config today (`http_channel_enabled`,
# `default_cache_as_channel_url`): an endpoint that wants to persist
# one must add it to WRITTEN_CONFIG_KEYS, or the write will silently
# do nothing.
keys = [key for key in WRITTEN_CONFIG_KEYS if key in changed_keys]
else:
# Bootstrap: no `[default]` on disk (fresh install - the caller at
# manager_server.py:2098 is guarded by `if not os.path.exists`), or a
# file that could not be parsed. Seed the full default set.
config['default'] = {}
keys = list(WRITTEN_CONFIG_KEYS)
updates = {key: cached[key] for key in keys}
# `preview_method` is written on EVERY write, from the live value rather
# than from disk - it is the one setting the running process, not the
# file, is authoritative for. So it is also the one key a hand edit does
# not survive; that is unchanged behaviour, not a gap in the merge.
updates['preview_method'] = manager_funcs.get_current_preview_method()
section = config['default']
for key, value in updates.items():
# Sanitize to prevent CRLF injection. Applied to the values written
# here; values already on disk are carried through verbatim, so the
# merge cannot corrupt hand-maintained content.
section[key] = str(value).replace('\r', '').replace('\n', '').replace('\x00', '')
directory = os.path.dirname(manager_config_path)
if not os.path.exists(directory):
@@ -1715,6 +1827,22 @@ def write_config():
with open(manager_config_path, 'w') as configfile:
config.write(configfile)
# The write succeeded, so these keys now MATCH the file and are no longer
# pending changes. Without this the set is add-only: a key the user once
# changed through the UI stays dirty for the life of the process, and the
# next unrelated write overlays its stale cached value back over whatever
# the user has hand-edited since - the same clobber this function exists to
# remove, just deferred by one settings change.
#
# Discard exactly the keys THIS write persisted, never the whole set: a
# caller may have dirtied a key that was not selected here - one this
# function does not own, and therefore one it never persists at all (the
# WRITTEN_CONFIG_KEYS note above). That marker is not this write's to
# drop; `clear()` would lose it. The hasattr guard mirrors the `getattr`
# above, for a cache that is a plain dict.
if hasattr(cached, 'dirty_keys'):
cached.dirty_keys.difference_update(keys)
def read_config():
try:
@@ -1789,7 +1917,11 @@ def get_config():
global cached_config
if cached_config is None:
cached_config = read_config()
# Wrapped HERE, after read_config() has returned: the migration hook it
# calls internally mutates the config through a parameter alias, and
# that mutation must NOT count as a user change (see
# DirtyTrackingConfig).
cached_config = DirtyTrackingConfig(read_config())
if cached_config['http_channel_enabled']:
print("[ComfyUI-Manager] Warning: http channel enabled, make sure server in secure env")
+471
View File
@@ -0,0 +1,471 @@
"""GOAL #25 RED — write_config() clobbers hand-maintained config.ini content.
Reproduces the three clobber classes the frozen MM
(`scratch/gm3-team/g25-mm.md`) records, as born-RED pytest nodes, plus two
born-GREEN regression guards. Node ids are the RED contract: the GREEN step
must drive these SAME ids to PASS.
| node | MM row | AC | born |
|----------------------------------------|--------|------|-------|
| test_t1_hand_edited_flag_survives | T1 | [D1] | RED |
| test_t3_unwritten_key_survives | T3 | [D3] | RED |
| test_t4_non_default_section_survives | T4 | [D3] | RED |
| test_t2_each_caller_key_persists | T2 | [D2] | GREEN |
| test_t5_bootstrap_writes_defaults | T5 | S4 | GREEN |
GOAL #53 adds one more node to the same harness:
| node | AC | born |
|----------------------------------------|------|-------|
| test_t7_silent_read_failure_is_loud | [D1] | RED |
T7 — `config.read()` SILENTLY suppresses OSError. A config.ini that exists
but cannot be read leaves the parser empty, so the merge falls into the
BOOTSTRAP branch and rewrites the file from defaults, destroying every
foreign key and non-`[default]` section. The failure must be loud, and
the file must be left untouched.
Why they fail today (MM State Model, measured at 30bd355e):
T1 — `write_config()` sources 17 of its 18 keys from `get_config()`, the
process-lifetime startup snapshot (`manager_core.py:1685-1704`), so a
hand edit made after startup is overwritten with the stale value.
T3 — `read_config()` returns 20 keys, `write_config()` persists 18;
`http_channel_enabled` and `default_cache_as_channel_url` are read but
never written, so every write deletes them.
T4 — `write_config()` builds a FRESH `ConfigParser`, assigns only
`config['default']`, and opens the file `'w'` (`:1715`), erasing every
other section.
Harness: the subprocess-isolated child from `tests/test_install_flags_config.py`
— stub `folder_paths`, `glob/` on the CHILD's sys.path only, `manager_core`
imported (NEVER `manager_server`: module level starts a network thread at
`:2093`), `manager_config_path` pointed at a tmp file, `cached_config` reset.
`force_security_level_if_needed` (MM HAZARD C-1) is NOT stubbed — it runs on
the real `read_config` path. The `folder_paths` stub supplies a system-user
directory, so `has_system_user_api()` is True and the function takes its
no-force branch and returns without mutating. The alias-mutation branch is
therefore present but not exercised here; the GREEN step must keep it in view
when it pins the dirty-tracking wrap point.
"""
import json
import subprocess
import sys
import textwrap
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
#: The real mutation shape every settings caller uses (MM Action Map): a
#: subscript assignment on the cached dict, then `write_config()`. Verified
#: exhaustive by AST at 30bd355e — 6 sites, no other shape.
_CALLER_KEYS = [
("db_mode", "local"), # manager_server.py:1779 -> :244
("component_policy", "higher"), # manager_server.py:1793 -> :238
("update_policy", "nightly-comfyui"), # manager_server.py:1806 -> :241
("channel_url", "https://example.invalid/ch"), # manager_server.py:1833 -> :1832
("share_option", "none"), # share_3rdparty.py:60 -> :59
]
_CHILD_PREAMBLE = textwrap.dedent(
"""
import sys, types, tempfile, os, json, configparser
tmp = tempfile.mkdtemp(prefix="cm_wcfg_")
stub = types.ModuleType("folder_paths")
stub.get_user_directory = lambda: tmp
stub.get_system_user_directory = lambda *a, **k: os.path.join(tmp, "sysuser")
sys.modules["folder_paths"] = stub
sys.path.insert(0, {glob_path!r})
import manager_core
CONFIG_PATH = os.path.join(tmp, "config.ini")
manager_core.manager_config_path = CONFIG_PATH
manager_core.cached_config = None
def write_ini(text):
with open(CONFIG_PATH, "w") as f:
f.write(text)
def read_raw():
with open(CONFIG_PATH) as f:
return f.read()
def parse_disk():
cp = configparser.ConfigParser(strict=False)
cp.read(CONFIG_PATH)
return cp
def boot():
\"\"\"Simulate process start: read config.ini once into the cache.\"\"\"
manager_core.cached_config = None
return manager_core.get_config()
def settings_change(key, value):
\"\"\"The real caller shape: mutate the cached dict, then write.\"\"\"
manager_core.get_config()[key] = value
manager_core.write_config()
"""
)
def _run_child(body):
"""Run a scenario body in the isolated child; return its JSON payload."""
script = _CHILD_PREAMBLE.format(glob_path=str(REPO_ROOT / "glob")) + textwrap.dedent(body)
proc = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
timeout=180,
cwd=str(REPO_ROOT),
)
if proc.returncode != 0:
raise AssertionError(
"write-config-harness child failed (rc=%d). stderr tail:\n%s"
% (proc.returncode, "\n".join(proc.stderr.strip().splitlines()[-8:]))
)
lines = proc.stdout.strip().splitlines()
if not lines:
raise AssertionError(
"write-config-harness child exited 0 but produced no stdout. stderr tail:\n%s"
% "\n".join(proc.stderr.strip().splitlines()[-8:])
)
try:
return json.loads(lines[-1])
except json.JSONDecodeError as e:
raise AssertionError(
"write-config-harness child emitted a non-JSON last line: %r\nfull stdout:\n%s"
% (lines[-1], proc.stdout)
) from e
class WriteConfigPersistenceTest(unittest.TestCase):
"""MM Test Scenario Map T1-T5 (`scratch/gm3-team/g25-mm.md`)."""
def test_t1_hand_edited_flag_survives(self):
"""T1 / [D1] — a hand edit made while running survives a settings change.
BORN-RED. The user follows GOAL #8's denial copy, edits config.ini,
then touches any Manager setting; `write_config()` re-writes the flag
from the startup snapshot and the edit is gone.
"""
payload = _run_child(
"""
write_ini("[default]\\nsecurity_level = normal\\nallow_git_url_install = false\\n")
boot() # startup snapshot: flag False
# user hand-edits config.ini while the server is UP
write_ini("[default]\\nsecurity_level = normal\\nallow_git_url_install = true\\n")
settings_change("db_mode", "local") # ordinary Manager settings change
cp = parse_disk()
print(json.dumps({
"flag_on_disk": cp["default"].get("allow_git_url_install", "<ABSENT>"),
"raw": read_raw(),
}))
"""
)
self.assertEqual(
"true", str(payload["flag_on_disk"]).lower(),
"T1 [D1]: the hand-edited allow_git_url_install=true was reverted to "
"%r by an unrelated settings change. write_config() sources 17 of 18 "
"keys from the startup snapshot (manager_core.py:1685-1704), so the "
"user is denied again after restarting despite following the denial "
"message exactly. On-disk file:\n%s"
% (payload["flag_on_disk"], payload["raw"]),
)
def test_t3_unwritten_key_survives(self):
"""T3 / [D3] — a key read_config reads but write_config never writes.
BORN-RED. `http_channel_enabled` gates the security warning at
`manager_core.py:1793`; every write deletes it from the file.
"""
payload = _run_child(
"""
write_ini(
"[default]\\nsecurity_level = normal\\n"
"http_channel_enabled = true\\ndefault_cache_as_channel_url = true\\n"
)
boot()
settings_change("db_mode", "local")
cp = parse_disk()
print(json.dumps({
"http_channel_enabled": cp["default"].get("http_channel_enabled", "<ABSENT>"),
"default_cache_as_channel_url": cp["default"].get(
"default_cache_as_channel_url", "<ABSENT>"),
"raw": read_raw(),
}))
"""
)
# NOT subTest: pytest-subtests reports a SUBFAIL while leaving the
# NODE status PASSED, which would break the node-id RED contract this
# WI pins — the GREEN step keys on these ids. An aggregate assertion
# keeps the per-key detail in the message AND fails the node.
lost = [
key for key in ("http_channel_enabled", "default_cache_as_channel_url")
if payload[key] == "<ABSENT>"
]
self.assertEqual(
[], lost,
"T3 [D3]: %r were present in config.ini and are GONE after an "
"unrelated settings change. read_config() returns 20 keys, "
"write_config() persists 18 — these keys are read but never "
"written, so every write deletes them. On-disk file:\n%s"
% (lost, payload["raw"]),
)
def test_t4_non_default_section_survives(self):
"""T4 / [D3] — a non-`default` section survives a settings change.
BORN-RED. `write_config()` builds a fresh ConfigParser, assigns only
`config['default']`, and opens `'w'` (`manager_core.py:1715`).
"""
payload = _run_child(
"""
write_ini(
"[default]\\nsecurity_level = normal\\n\\n"
"[my_section]\\nmy_key = keepme\\n"
)
boot()
settings_change("db_mode", "local")
cp = parse_disk()
print(json.dumps({
"sections": cp.sections(),
"my_key": cp["my_section"].get("my_key", "<ABSENT>")
if cp.has_section("my_section") else "<SECTION GONE>",
"raw": read_raw(),
}))
"""
)
self.assertIn(
"my_section", payload["sections"],
"T4 [D3]: the user's [my_section] was erased by an unrelated "
"settings change — write_config() writes only config['default'] "
"into a fresh parser with mode 'w'. Sections left on disk: %r\n%s"
% (payload["sections"], payload["raw"]),
)
self.assertEqual("keepme", payload["my_key"])
def test_t2_each_caller_key_persists(self):
"""T2 / [D2] — regression guard, expected born-GREEN.
Each settings caller's OWN key must reach disk with its new value. This
is what a fix must not break; it is exercised at the manager_core layer
with the same mutation + `write_config()` the handlers make, because
importing `manager_server` starts a network thread (`:2093`).
"""
payload = _run_child(
"""
import json as _j
cases = %s
out = {}
for key, value in cases:
write_ini("[default]\\nsecurity_level = normal\\n")
boot()
settings_change(key, value)
out[key] = parse_disk()["default"].get(key, "<ABSENT>")
print(_j.dumps(out))
""" % json.dumps(_CALLER_KEYS)
)
# NOT subTest — same reason as T3: a regression guard whose node
# reports PASSED while a subtest fails cannot signal the regression
# it exists to catch.
wrong = {
key: payload[key] for key, value in _CALLER_KEYS
if payload[key] != value
}
self.assertEqual(
{}, wrong,
"T2 [D2]: these caller keys did not persist their new value: %r — "
"a fix must not regress the write path it is narrowing."
% (wrong,),
)
def test_t5_bootstrap_writes_defaults(self):
"""T5 / S4 — regression guard, expected born-GREEN.
The 7th `write_config()` caller (`manager_server.py:2098`) is guarded by
`if not os.path.exists(...)` (`:2096`) and runs only when config.ini is
absent, so it cannot clobber. Its bootstrap write must still produce a
usable defaults file.
"""
payload = _run_child(
"""
if os.path.exists(CONFIG_PATH):
os.remove(CONFIG_PATH)
manager_core.get_config() # the bootstrap pair at :2097-2098
manager_core.write_config()
cp = parse_disk()
print(json.dumps({
"exists": os.path.exists(CONFIG_PATH),
"security_level": cp["default"].get("security_level", "<ABSENT>"),
"allow_git_url_install": cp["default"].get(
"allow_git_url_install", "<ABSENT>"),
"key_count": len(cp["default"]),
}))
"""
)
self.assertTrue(payload["exists"], "T5: bootstrap did not create config.ini")
self.assertEqual("normal", payload["security_level"])
self.assertEqual(
"False", payload["allow_git_url_install"],
"T5: the install flag must bootstrap secure-by-default",
)
self.assertEqual(
18, payload["key_count"],
"T5: bootstrap wrote %d keys; write_config persists 18 "
"(manager_core.py:1685-1704). A change to that count is a scope "
"signal, not a nit." % payload["key_count"],
)
def test_t6_persisted_key_is_not_reclobbered(self):
"""T6 / [D1] — a key stays writable by hand AFTER the UI has set it.
BORN-RED. `dirty_keys` is add-only: `write_config()` never removes the
keys it just persisted. So a key the user once changed through the UI
remains dirty for the life of the process, and the NEXT unrelated
settings write overlays its stale cached value back over whatever the
user has since edited on disk — the same clobber class the merge-on-
write fix removes, just deferred by one settings change.
This is the residual case: T1 covers a key the UI never touched, which
is why T1 passes while this fails.
"""
payload = _run_child(
"""
write_ini("[default]\\nsecurity_level = normal\\ndb_mode = cache\\n")
boot()
# 1. the user changes db_mode through the UI -> persisted, and the
# key is now marked dirty
settings_change("db_mode", "local")
after_ui = parse_disk()["default"].get("db_mode", "<ABSENT>")
# 2. the user hand-edits THAT SAME key on disk, touching nothing else
raw = read_raw()
assert "db_mode = local" in raw, raw
with open(CONFIG_PATH, "w") as f:
f.write(raw.replace("db_mode = local", "db_mode = remote"))
# 3. one UNRELATED settings change
settings_change("component_policy", "higher")
cp = parse_disk()
print(json.dumps({
"after_ui": after_ui,
"db_mode_final": cp["default"].get("db_mode", "<ABSENT>"),
"component_policy": cp["default"].get("component_policy", "<ABSENT>"),
"raw": read_raw(),
}))
"""
)
# Guard against a vacuous pass: step 1 must really have persisted, and
# step 3's own key must really have landed. Without these, a
# write_config() that wrote nothing at all would satisfy the assertion
# below for the wrong reason.
self.assertEqual("local", payload["after_ui"], "T6 precondition: the UI change did not persist")
self.assertEqual(
"higher", payload["component_policy"],
"T6 precondition: the unrelated settings change did not persist",
)
self.assertEqual(
"remote", payload["db_mode_final"],
"T6 [D1]: the hand-edited db_mode=remote was reverted to %r by an "
"unrelated component_policy change. `db_mode` stayed in dirty_keys "
"after write_config() persisted it, so the stale cached value was "
"overlaid onto the re-read file. Any key the user has ever changed "
"through the UI becomes permanently un-hand-editable for the life "
"of the process. On-disk file:\\n%s"
% (payload["db_mode_final"], payload["raw"]),
)
def test_t7_silent_read_failure_is_loud(self):
"""T7 / GOAL #53 [D1] — an UNREADABLE config.ini must not be wiped.
BORN-RED. `configparser.ConfigParser.read()` opens each filename inside
a `try/except OSError` and just SKIPS the ones it could not open, so a
file that EXISTS but denies reads leaves `read()` returning normally
with an EMPTY parser. `write_config()` then sees no `[default]`, takes
the BOOTSTRAP branch, and rewrites the file from the default set —
silently destroying the foreign key and the user's `[my_section]`,
while the settings endpoint answers 200.
The scenario is gm3-solver's measured boundary (GOAL #52 D2): reads
denied but writes allowed. When BOTH are denied the write itself
raises, so the failure is already loud and the file already survives;
this node pins the asymmetric case that is not.
"""
payload = _run_child(
"""
import builtins
write_ini(
"[default]\\nsecurity_level = normal\\ndb_mode = cache\\n"
"http_channel_enabled = true\\n\\n"
"[my_section]\\nmy_key = keepme\\n"
)
boot()
before = read_raw()
# Deny READS of config.ini while leaving WRITES allowed — the
# asymmetric permission shape gm3-solver reproduced live. Reads go
# through builtins.open, which is what configparser.read() calls
# and whose OSError it swallows.
_real_open = builtins.open
deny = {"on": False}
def guarded_open(file, mode="r", *args, **kwargs):
is_read = "r" in mode and "w" not in mode and "a" not in mode
if deny["on"] and is_read and os.path.abspath(str(file)) == os.path.abspath(CONFIG_PATH):
raise PermissionError(13, "Permission denied", str(file))
return _real_open(file, mode, *args, **kwargs)
builtins.open = guarded_open
raised = None
try:
deny["on"] = True
settings_change("db_mode", "local")
except Exception as e:
raised = "%s: %s" % (type(e).__name__, e)
finally:
deny["on"] = False
builtins.open = _real_open
after = read_raw()
cp = parse_disk()
print(json.dumps({
"raised": raised,
"unchanged": before == after,
"before": before,
"after": after,
"sections": cp.sections(),
"http_channel_enabled": cp["default"].get(
"http_channel_enabled", "<ABSENT>")
if cp.has_section("default") else "<NO DEFAULT>",
}))
"""
)
self.assertIsNotNone(
payload["raised"],
"T7 [D1]: write_config() returned SUCCESSFULLY on a config.ini it "
"could not read. config.read() suppressed the PermissionError, so "
"the empty parser routed the write into the bootstrap branch and "
"the settings change reports success while destroying the file. "
"Sections left on disk: %r\nOn-disk file after the write:\n%s"
% (payload["sections"], payload["after"]),
)
self.assertTrue(
payload["unchanged"],
"T7 [D1]: config.ini was MODIFIED by a settings change that could "
"not read it. A write that cannot see the current contents must "
"leave them alone.\n--- before ---\n%s\n--- after ---\n%s"
% (payload["before"], payload["after"]),
)
# Spell out the two losses the byte-comparison above rolls up, so a
# failure names the user-visible damage and not just "bytes differ".
self.assertIn("my_section", payload["sections"], "T7 [D1]: [my_section] was erased")
self.assertEqual(
"true", str(payload["http_channel_enabled"]).lower(),
"T7 [D1]: http_channel_enabled was erased",
)
if __name__ == "__main__": # pragma: no cover
unittest.main()