Keep future Coulomb release evidence directories private
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
This commit is contained in:
tegwick 2026-10-31 12:44:59 +01:00
parent d795120f98
commit 904dcb3ce3
4 changed files with 99 additions and 2 deletions

View file

@ -42,6 +42,7 @@
| task | RAPPS-WP-0015-T03 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T03 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md |
| task | RAPPS-WP-0015-T04 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T04 | wait | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md |
| task | RAPPS-WP-0015-T05 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md | | task | RAPPS-WP-0015-T05 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md |
| task | RAPPS-WP-0015-T06 | done | — | workplans/RAPPS-WP-0015-coulomb-personal-content-release.md |
| task | RAPPS-WP-0016-T01 | done | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T01 | done | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md |
| task | RAPPS-WP-0016-T02 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T02 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md |
| task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md | | task | RAPPS-WP-0016-T03 | wait | — | workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md |

View file

@ -2,6 +2,8 @@
import copy import copy
import importlib.util import importlib.util
import json import json
import os
import stat
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import Mock from unittest.mock import Mock
@ -66,6 +68,48 @@ def test_mutable_staging_is_frozen_before_live_work(tmp_path, monkeypatch):
assert (instance.journal / "receipt.json").is_file() assert (instance.journal / "receipt.json").is_file()
def test_nested_capsule_and_release_root_stay_private_under_normal_umask(tmp_path, monkeypatch):
root, _ = capsule(tmp_path)
nested = root / "charts/coulomb/templates/deployment.yaml"
nested.parent.mkdir(parents=True)
nested.write_bytes(b"synthetic chart")
plan = {"files": {str(p.relative_to(root)): release.sha(p.read_bytes())
for p in root.rglob("*") if p.is_file() and p.name != "manifest.json"}}
(root / "manifest.json").write_text(json.dumps(plan))
monkeypatch.setattr(release.os, "geteuid", lambda: 0)
monkeypatch.setattr(release, "JOURNAL_ROOT", tmp_path / "journal")
previous = os.umask(0o022)
try:
instance = release.Release(root, "sha256:" + release.sha(release.canonical(plan)))
finally:
os.umask(previous)
for path in [tmp_path / "journal", *(tmp_path / "journal").rglob("*")]:
assert stat.S_IMODE(path.stat().st_mode) == (0o700 if path.is_dir() else 0o600)
assert (instance.root / nested.relative_to(root)).read_bytes() == b"synthetic chart"
@pytest.mark.parametrize("problem", ["broad", "link", "ancestor_link", "file"])
def test_unsafe_release_parent_is_refused_without_changing_it(tmp_path, monkeypatch, problem):
root, digest = capsule(tmp_path)
journal = tmp_path / "journal"
if problem == "broad":
journal.mkdir(mode=0o755)
elif problem == "file":
journal.write_text("untouched")
elif problem == "link":
journal.symlink_to(root, target_is_directory=True)
else:
alias = tmp_path / "alias"
alias.symlink_to(root, target_is_directory=True)
journal = alias / "journal"
monkeypatch.setattr(release.os, "geteuid", lambda: 0)
monkeypatch.setattr(release, "JOURNAL_ROOT", journal)
before = {str(p): p.lstat() for p in tmp_path.rglob("*")}
with pytest.raises((release.Refused, OSError)):
release.Release(root, digest)
assert before == {str(p): p.lstat() for p in tmp_path.rglob("*")}
@pytest.mark.parametrize("field", ["business_rows_sha256", "content_files_sha256", "migration_rows_sha256"]) @pytest.mark.parametrize("field", ["business_rows_sha256", "content_files_sha256", "migration_rows_sha256"])
def test_dataset_drift_is_not_accepted(field): def test_dataset_drift_is_not_accepted(field):
instance = object.__new__(release.Release) instance = object.__new__(release.Release)

View file

@ -16,6 +16,7 @@ import subprocess
import tempfile import tempfile
import time import time
import urllib.request import urllib.request
from contextlib import ExitStack
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
@ -35,6 +36,33 @@ class Refused(RuntimeError):
pass pass
def private_directories(root, relative):
"""Create every component privately; never repair existing unsafe state."""
if not root.is_absolute() or relative.is_absolute() or ".." in relative.parts:
raise Refused("private_directory_path_required")
with ExitStack() as stack:
def opened(name, parent=None):
fd = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
stack.callback(os.close, fd)
return fd
fd = opened("/")
for component in root.parts[1:]:
fd = opened(component, fd)
for component in (*relative.parts, None):
info = os.fstat(fd)
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700:
raise Refused("private_owned_directory_required")
if component is None:
break
try:
os.mkdir(component, mode=0o700, dir_fd=fd)
os.fsync(fd)
except FileExistsError:
pass
fd = opened(component, fd)
class Release: class Release:
def __init__(self, capsule, expected, read_only=False): def __init__(self, capsule, expected, read_only=False):
self.root = capsule self.root = capsule
@ -73,12 +101,13 @@ class Release:
return return
if self.journal.exists(): if self.journal.exists():
raise Refused("prior_execution_requires_review") raise Refused("prior_execution_requires_review")
self.journal.mkdir(parents=True, mode=0o700) private_directories(JOURNAL_ROOT.parent, Path(JOURNAL_ROOT.name))
self.journal.mkdir(mode=0o700)
self.root = self.journal / "capsule" self.root = self.journal / "capsule"
self.root.mkdir(mode=0o700) self.root.mkdir(mode=0o700)
for name, data in frozen.items(): for name, data in frozen.items():
target = self.root / name target = self.root / name
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) private_directories(self.root, Path(name).parent)
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "wb") as stream: with os.fdopen(fd, "wb") as stream:
stream.write(data) stream.write(data)

View file

@ -399,3 +399,26 @@ packaged client export verifies85files without source mutation. Full make check
441passes, zero skips. Release decision75b94bb6 is resolved only for that exact 441passes, zero skips. Release decision75b94bb6 is resolved only for that exact
execution; RAPPS-WP-0015-T05 is complete. Productive flow and service/offboarding execution; RAPPS-WP-0015-T05 is complete. Productive flow and service/offboarding
acceptance remain open. Return: docs/release/identity-export-release-return-2026-10-31.md. acceptance remain open. Return: docs/release/identity-export-release-return-2026-10-31.md.
## T06 — Preserve private custody when freezing future release evidence
```task
id: RAPPS-WP-0015-T06
status: done
priority: high
needs_human: false
```
Source correction for CSOC-WP-0012-T13 after backup installation preflight exposed
0755 intermediate capsule directories from completed releases. The current image
controller creates each component with mode0700 through no-follow directory
file descriptors, checks private ownership/mode and refuses unsafe existing state.
It does not chmod historical directories or replay the completed release. Files
remain0600. The old storage capsule remains frozen; it is not a current release path.
Validation: 24 focused release safety cases pass, including a nested chart under
umask022 and refusal of broad, symlinked, ancestor-symlinked and non-directory
parents without mutation. Ruff and git diff --check pass. Product receipt:
coulomb-social/docs/release/backup-producer-custody-validation-2026-10-31.json.
The historical eight-entry repair and recurring service acceptance remain product
T04/T09. No cluster operation occurred; future release proposals bind the new source.