mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Keep demo runtime profile changes out of signed billing state
The helper could repersist legacy entitlement secrets and clear billing integrity while granting demo fixtures. Require the existing demo capability before mutation, leave all billing/key material untouched, and keep verification and encrypted legacy migration with the canonical application. Preserve full-state recovery, service ownership and the existing native route; seed only non-secret admitted fresh-runner fixtures. Add real canonical encrypted/legacy/HMAC connected controls and malformed or unprovisioned refusal-before-mutation coverage. Change-source: pulse-maintainer
This commit is contained in:
@@ -349,17 +349,61 @@ class Host:
|
||||
values["PULSE_MOCK_" + key.upper()] = value
|
||||
return values
|
||||
|
||||
def profile_matches(self, request):
|
||||
text = (PATHS["data"] / ".env").read_text().splitlines()
|
||||
values = self.profile_values(request)
|
||||
return (all(text.count(key + "=" + value) == 1 for key, value in values.items())
|
||||
and "demo_fixtures" in json.loads((PATHS["data"] / "billing.json").read_text()).get("capabilities", []))
|
||||
def profile_admission(self):
|
||||
"""Read-only prerequisite; this transaction never grants entitlements.
|
||||
|
||||
def profile(self, request, unhealthy):
|
||||
The running application owns billing HMAC verification, encrypted
|
||||
secrets and legacy migration. Do not decrypt, clear integrity, re-sign
|
||||
or rewrite billing/key material to enable a demo profile.
|
||||
"""
|
||||
env_file = PATHS["data"] / ".env"
|
||||
billing_file = PATHS["data"] / "billing.json"
|
||||
if env_file.is_symlink() or billing_file.is_symlink():
|
||||
raise Failure("configuration-symlink")
|
||||
if not env_file.is_file():
|
||||
raise Failure("configuration-shape")
|
||||
|
||||
def unique_fields(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise Failure("billing-shape")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
def invalid_constant(_value):
|
||||
raise Failure("billing-shape")
|
||||
|
||||
try:
|
||||
fd = os.open(billing_file, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(fd, "rb") as stream:
|
||||
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
||||
raise Failure("billing-shape")
|
||||
raw = stream.read(1024 * 1024 + 1)
|
||||
if len(raw) > 1024 * 1024:
|
||||
raise Failure("billing-shape")
|
||||
billing = json.loads(raw, object_pairs_hook=unique_fields,
|
||||
parse_constant=invalid_constant)
|
||||
except (OSError, ValueError, UnicodeError, RecursionError) as exc:
|
||||
raise Failure("billing-observation") from exc
|
||||
if (not isinstance(billing, dict)
|
||||
or not isinstance(billing.get("capabilities"), list)
|
||||
or not all(isinstance(capability, str) for capability in billing["capabilities"])):
|
||||
raise Failure("billing-shape")
|
||||
if "demo_fixtures" not in billing["capabilities"]:
|
||||
raise Failure("billing-demo-capability-required")
|
||||
|
||||
def profile_matches(self, request):
|
||||
self.profile_admission()
|
||||
text = (PATHS["data"] / ".env").read_text().splitlines()
|
||||
values = self.profile_values(request)
|
||||
return all(text.count(key + "=" + value) == 1 for key, value in values.items())
|
||||
|
||||
def profile(self, request, unhealthy):
|
||||
env_file = PATHS["data"] / ".env"
|
||||
# Recheck after quiescent capture too; a failed prerequisite must not
|
||||
# partially change the profile or bypass the application's licence gate.
|
||||
self.profile_admission()
|
||||
values = self.profile_values(request)
|
||||
text = env_file.read_text()
|
||||
for key, value in values.items():
|
||||
@@ -369,14 +413,6 @@ class Host:
|
||||
data_owner = PATHS["data"].stat()
|
||||
os.chown(env_file, data_owner.st_uid, data_owner.st_gid)
|
||||
os.chmod(env_file, 0o600)
|
||||
billing = json.loads(billing_file.read_text())
|
||||
if not isinstance(billing, dict) or not isinstance(billing.get("capabilities", []), list):
|
||||
raise Failure("billing-shape")
|
||||
billing["capabilities"] = sorted(set(billing.get("capabilities", []) + ["demo_fixtures"]))
|
||||
billing.pop("integrity", None)
|
||||
billing_file.write_text(json.dumps(billing))
|
||||
os.chown(billing_file, data_owner.st_uid, data_owner.st_gid)
|
||||
os.chmod(billing_file, 0o600)
|
||||
if unhealthy:
|
||||
for relative in DEMO_HISTORY:
|
||||
path = PATHS["data"] / relative
|
||||
@@ -546,6 +582,9 @@ class Transaction:
|
||||
if (prior.get("status") not in TERMINAL
|
||||
or (prior.get("child_result_required") and not self.host.prior_closed(entry, prior["status"]))):
|
||||
raise Failure("prior-recovery-required")
|
||||
# Refuse absent/malformed or unprovisioned billing before stopping
|
||||
# a service, installing a binary or changing any estate file.
|
||||
self.host.profile_admission()
|
||||
states = self.host.state()
|
||||
if not all(active(states[name]) for name in ("pulse-relay", "caddy")) or self.host.status(RELAY_HEALTH) != "200":
|
||||
raise Failure("cohost-unhealthy")
|
||||
|
||||
@@ -92,7 +92,7 @@ def main():
|
||||
Path("/etc/pulse").mkdir(mode=0o755)
|
||||
os.chown("/etc/pulse", owner.pw_uid, owner.pw_gid)
|
||||
Path("/etc/pulse/.env").write_text("DEMO_MODE=true\n")
|
||||
Path("/etc/pulse/billing.json").write_text('{"capabilities":[]}')
|
||||
Path("/etc/pulse/billing.json").write_text('{"capabilities":["demo_fixtures"],"limits":{},"meters_enabled":[],"plan_version":"community","subscription_state":"active"}')
|
||||
Path("/etc/pulse/persistent-marker").write_text("original data")
|
||||
for path in Path("/etc/pulse").iterdir():
|
||||
os.chown(path, owner.pw_uid, owner.pw_gid)
|
||||
|
||||
@@ -156,7 +156,7 @@ class TransactionTest(unittest.TestCase):
|
||||
path.chmod(0o755 if name == "binary" else 0o644)
|
||||
(self.paths["dropins"] / "limits.conf").write_bytes(b"old limits")
|
||||
(self.paths["data"] / ".env").write_text("PRIVATE_TOKEN=not-logged\n")
|
||||
(self.paths["data"] / "billing.json").write_text('{"capabilities":[],"integrity":"private"}')
|
||||
(self.paths["data"] / "billing.json").write_text('{"capabilities":["demo_fixtures"],"integrity":"private"}')
|
||||
(self.paths["data"] / "persisted-history").write_bytes(b"original history")
|
||||
(self.paths["data"] / "alerts").mkdir()
|
||||
(self.paths["data"] / "alerts/events.db").write_bytes(b"synthetic opaque persistent file")
|
||||
@@ -188,6 +188,105 @@ class TransactionTest(unittest.TestCase):
|
||||
self.assertEqual(tx.receipt, json.loads((self.attempt / "receipt.json").read_text()))
|
||||
return result, tx.receipt
|
||||
|
||||
def test_profile_preserves_encrypted_and_legacy_billing_bytes_key_and_metadata(self):
|
||||
billing_path = self.paths["data"] / "billing.json"
|
||||
key_path = self.paths["data"] / ".encryption.key"
|
||||
key_path.write_bytes(b"synthetic-original-key")
|
||||
key_path.chmod(0o600)
|
||||
for representation in ("synthetic-legacy-plaintext", "synthetic-encrypted-ciphertext"):
|
||||
with self.subTest(representation=representation):
|
||||
billing = {"capabilities": ["other-feature", "demo_fixtures"],
|
||||
"integrity": "original-synthetic-signature",
|
||||
"entitlement_jwt": representation + "-jwt",
|
||||
"entitlement_refresh_token": representation + "-refresh",
|
||||
"limits": {"resources": 100}, "subscription_state": "active",
|
||||
"other_field": {"preserve": [1, 2]}}
|
||||
billing_path.write_text(json.dumps(billing, indent=2) + "\n")
|
||||
billing_path.chmod(0o640)
|
||||
before = engine.estate_hash({"billing": billing_path, "key": key_path})
|
||||
self.host.profile(request(), False)
|
||||
self.host.profile(request(), False)
|
||||
self.assertEqual(engine.estate_hash({"billing": billing_path, "key": key_path}), before)
|
||||
self.assertTrue(self.host.profile_matches(request()))
|
||||
|
||||
def test_missing_demo_capability_refuses_before_any_service_or_estate_mutation(self):
|
||||
(self.paths["data"] / "billing.json").write_text('{"capabilities":[],"integrity":"unchanged"}')
|
||||
before = engine.estate_hash(self.paths)
|
||||
code, receipt = self.run_transaction("update", "v6.4.6")
|
||||
self.assertEqual((code, receipt["status"], receipt["failure"]),
|
||||
(1, "refused", "billing-demo-capability-required"))
|
||||
self.assertFalse(receipt["mutated"])
|
||||
self.assertEqual((self.host.stops, self.host.starts, self.host.install_count), (0, 0, 0))
|
||||
self.assertEqual(engine.estate_hash(self.paths), before)
|
||||
self.assertFalse((self.attempt / "snapshot").exists())
|
||||
|
||||
def test_malformed_billing_refuses_before_mutation_with_fixed_diagnostics(self):
|
||||
path = self.paths["data"] / "billing.json"
|
||||
cases = (b"private-malformed-input", b"[]", b"null", b"{}",
|
||||
b'{"capabilities":"demo_fixtures"}',
|
||||
b'{"capabilities":["demo_fixtures",{}]}',
|
||||
b'{"capabilities":[],"capabilities":["demo_fixtures"]}',
|
||||
b'{"capabilities":["demo_fixtures"],"other":NaN}',
|
||||
b'{"capabilities":["demo_fixtures"],"other":"\xff"}',
|
||||
b" " * (1024 * 1024 + 1))
|
||||
for value in cases:
|
||||
with self.subTest(length=len(value)):
|
||||
path.write_bytes(value)
|
||||
before = engine.estate_hash(self.paths)
|
||||
code, receipt = self.run_transaction()
|
||||
self.assertEqual((code, receipt["status"]), (1, "refused"))
|
||||
self.assertIn(receipt["failure"], {"billing-shape", "billing-observation"})
|
||||
self.assertEqual(self.host.stops, 0)
|
||||
self.assertFalse(receipt["mutated"])
|
||||
self.assertEqual(engine.estate_hash(self.paths), before)
|
||||
self.assertNotIn("private-malformed-input", json.dumps(receipt))
|
||||
|
||||
def test_missing_symlink_and_nonregular_billing_refuse_without_touching_target(self):
|
||||
path = self.paths["data"] / "billing.json"
|
||||
target = self.paths["data"] / "other-billing"
|
||||
target.write_text('{"capabilities":["demo_fixtures"]}')
|
||||
path.unlink()
|
||||
for kind in ("missing", "symlink", "directory"):
|
||||
with self.subTest(kind=kind):
|
||||
if kind == "symlink":
|
||||
path.symlink_to(target)
|
||||
elif kind == "directory":
|
||||
path.mkdir()
|
||||
before = engine.estate_hash(self.paths)
|
||||
code, receipt = self.run_transaction()
|
||||
self.assertEqual((code, receipt["status"]), (1, "refused"))
|
||||
self.assertEqual(self.host.stops, 0)
|
||||
self.assertEqual(engine.estate_hash(self.paths), before)
|
||||
if kind == "symlink":
|
||||
path.unlink()
|
||||
elif kind == "directory":
|
||||
path.rmdir()
|
||||
|
||||
def test_profile_rechecks_capability_before_changing_environment(self):
|
||||
path = self.paths["data"] / "billing.json"
|
||||
self.host.profile_admission()
|
||||
path.write_text('{"capabilities":[]}')
|
||||
before = engine.estate_hash(self.paths)
|
||||
with self.assertRaisesRegex(engine.Failure, "^billing-demo-capability-required$"):
|
||||
self.host.profile(request(), True)
|
||||
self.assertEqual(engine.estate_hash(self.paths), before)
|
||||
|
||||
def test_late_update_failure_preserves_full_billing_and_key_through_recovery(self):
|
||||
path = self.paths["data"] / "billing.json"
|
||||
path.write_text(json.dumps({"capabilities": ["demo_fixtures"],
|
||||
"integrity": "synthetic-original-signature",
|
||||
"entitlement_jwt": "synthetic-original-secret",
|
||||
"other": {"keep": True}}))
|
||||
(self.paths["data"] / ".encryption.key").write_text("synthetic-original-key")
|
||||
before = engine.estate_hash(self.paths)
|
||||
self.host.fail_at = 55
|
||||
code, receipt = self.run_transaction("update", "v6.4.6")
|
||||
self.assertEqual((code, receipt["status"]), (1, "rolled_back"))
|
||||
self.assertEqual(engine.estate_hash(self.paths), before)
|
||||
self.assertEqual(receipt["recovery"]["elapsed_seconds"], 300)
|
||||
for secret in ("synthetic-original-signature", "synthetic-original-secret", "synthetic-original-key"):
|
||||
self.assertNotIn(secret, json.dumps(receipt))
|
||||
|
||||
def test_healthy_profile_change_keeps_data_and_watches_complete_window(self):
|
||||
code, receipt = self.run_transaction()
|
||||
self.assertEqual(code, 0)
|
||||
|
||||
Reference in New Issue
Block a user