Keep future Coulomb release evidence directories private
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
This commit is contained in:
parent
d795120f98
commit
904dcb3ce3
4 changed files with 99 additions and 2 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue