Add independent bounded guest-write evidence for backup thaw

An OK PVE task and HTTP admission controls cannot establish thaw. Supply a credential-free guest-local fsync/readback witness and bounded offline record checks for the real #2439 job, without QGA probes, recovery actions or a replacement native platform. Require actual overlap, every covered filesystem and truthful monitoring resumption; retain native provenance and execution with Delivery.

Contract-Neutral: Measurement support for the existing guest-agent backup safety obligation only; production APIs, polling, access boundaries and release gates are unchanged.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-03 19:44:31 +01:00
parent 7a5635b2c0
commit ee069f2144
6 changed files with 1089 additions and 0 deletions
@@ -58,6 +58,15 @@ on a containing build remains required. Until then, stopping Pulse before a
freeze-enabled backup and restarting only after verified thaw remains the safe
workaround, with monitoring and alerts unavailable during that window.
The [bounded backup observation tools](../../../../../tests/qualification/guest-agent-backup/README.md)
provide an independent guest filesystem writer (file/directory fsync and
synthetic readback over non-QGA stdout) and a fail-closed checker for native
overlap, truthful deferred readings/History and post-task writes/resumption.
An OK task without fresh writes on every covered filesystem cannot pass those
record checks. Local tests are not native freeze/thaw evidence; supplied
platform/artifact provenance and mount completeness still need independent
review. The tools add no runtime interface, recovery action or release gate.
### Exact TrueNAS subscription termination — issue #2396
JSON-RPC stream readers and the subscription-acknowledgement wait recognise
@@ -0,0 +1,160 @@
# Guest-agent backup observation — #2439
This is a **measurement tool**, not a new release gate or a backup hook. It
does not start Pulse, a backup, PVE or QGA. It never sends a guest-agent command,
freezes/thaws a filesystem, changes ACLs, disables freezing or restarts/resets
a guest. Local tests and an OK backup task do not establish native acceptance.
The reported job is PVE **9.2.21**, **HAOS with QGA**, and Pulse **6.4.5** using
the same effective **Audit + FileRead** access: a backup task reported OK while
the guest remained frozen. Pulse's containing-source mitigation starts at
`3c57c2d20728f9e711ae2930e282b853eb218730` (with its ancestors). Source controls
cover admission, lock checks, in-flight payload withdrawal, no replay and cache
truth. They cannot atomically reserve the native PVE/QGA channel or prove thaw.
## Smallest useful native route
Delivery owns provisioning and native execution. Core supplies the independent
guest writer and record checks here. The existing private CI VM can build these
tools and judge returned records; guest sudo does not supply a PVE hypervisor,
HAOS, QGA's real channel or permission to reach an existing host. An ordinary
QGA subprocess, simulated PVE API or observation guest with PVE/QGA disabled
cannot meet this job. Do not build a replacement platform just to call it native.
The missing resource is **one owned disposable PVE target with a real HAOS/QGA
guest and backup storage**, isolated from production, plus verified official
image/package inputs and a supported way for its owner to observe it. Use an
already provisioned suitable target if available; otherwise Delivery returns
the exact provisioning/input gap through its normal reviewed infrastructure
route. This file is not permission to use a host, account or credential.
Before native execution the owner retains, privately:
- The exact inspected Pulse binary digest, source/tree, required guard ancestry,
build/artifact provenance and process identity. Use a containing reviewed
build, not a version string or an unverified rebuild. Preserve 6.4.5 as the
reported source, not proof of the candidate. No new-source acceptance follows
from old app/source receipts.
- Actual PVE manager/kernel/package identities (reconcile what the reported
`9.2.21` identifies), HAOS image and QGA executable/version/configuration.
Record filesystem-freeze enablement and every writable filesystem covered by
the backup. Map opaque filesystem IDs to these mounts; exclude tmpfs and
container overlays that are not actually frozen. Stage the writer through
the owner's supported guest-console/image route **before** the backup.
- Effective privileges of the *same* Pulse token: Audit and FileRead on the
target VM, QGA enabled, ordinary TLS verification, one Pulse process, no other
manual QGA probes. A refusal stops the attempt; do not substitute root or
broaden access. Never record the credential, full config or service environment.
- Preselected observation window (at most ten minutes), backup task identity,
Pulse polling/cache intervals, native command-trace coverage and guest/host
clock calibration with at most one second relative error. Hash/link original
provenance, clock/mount crosswalk and raw task/command traces separately.
Run a normal freeze-enabled backup with Pulse stopped first as the fixture
control, observing the guest independently. Then, on a fresh equivalent
disposable fixture, observe normal containing-build polling across a backup.
For in-flight acceptance, require a **genuinely observed** Pulse read spanning
lock acquisition, not a sleep or a claimed overlap. Never force overlap on a
customer guest or queue manual QGA probes. If no overlap occurs within the
chosen window, retain that inconclusive attempt; it is not an acceptance pass.
No repeated run is prescribed merely to obtain a favourable result.
## Independent guest filesystem writer
Build with the declared Go compiler; stage the resulting Linux binary for the
actual guest architecture. Python is **not** required in HAOS:
```sh
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tmp/guest-writer \
./tests/qualification/guest-agent-backup/guest-writer
```
The native owner runs one writer on each mapped, freeze-covered filesystem:
```sh
guest-writer --directory /owner-provisioned/scratch-on-covered-mount \
--run-id run-01 --filesystem-id data --duration-seconds 600 --interval-ms 1000
```
The directory must exist, be absolute and have no symlink components. Each run
creates a new private child; repeated IDs refuse reuse. Each write uses an
exclusive new 0600 file, writes 512 synthetic bytes, fsyncs both file and
directory, reads back and checks the bytes, then emits their SHA-256. At most
600 such files are left per filesystem; it deletes nothing. Only opaque IDs,
fixed outcomes and timestamps reach stdout. Collect stdout/exit through an
**independent console channel**, not QGA or the filesystem being frozen.
One write can be in flight. Heartbeats continue while it is blocked; no further
write is queued. Interruption, output failure, readback/write error or a write
pending at the deadline cannot pass. The program does not unblock a frozen
filesystem. Preserve failed evidence and leave disposal/recovery to the fixture
owner; do not turn the failure into a guest restart/reset workaround.
A successful write witnesses only that mapped filesystem at that instant, not
all filesystems, application integrity or a successful backup restore. Require
at least two completed baseline writes, liveness during the frozen interval and
two **newly started** fsync/readback writes after task completion on every mapped
filesystem. A pre-freeze write that finally returns is insufficient on its own.
## Bounded record checks
`check.py` reads a redacted JSON record and adjacent hash-bound writer JSONL
files. It has no network, subprocess or guest-control capability. The shape is
exercised by `record()` and `witness_events()` in `test_check.py`; those are
**synthetic checker fixtures, never native evidence**. Fields are exact and
extra fields (including private names/raw logs) are rejected.
The native owner normalises its actual observations into that shape, retaining
underlying receipts separately. `identity` binds Pulse/PVE/QGA/HAOS digests;
`preflight` and its mount-crosswalk hash cover all declared filesystem IDs.
`backup` contains native start, lock, freeze, thaw-*request*, unlock and completed
times. A thaw request is not a thaw result. `coverage` binds the complete
bounded native command trace with zero dropped events. `commands` are **actual
dispatched** reads, not planned or deferred calls; preserve adverse outcomes.
The checker requires real in-flight overlap, no concurrent/duplicate dispatch
and no dispatch inside the observed lock/clock-uncertainty interval.
`snapshots` are baseline, at least two locked polls and resumed readbacks of the
same VM. Retain metadata/memory/disk payload hashes, guest-agent state, memory
source, disk reason, latest memory/disk History times and CPU observation time.
Obtain them from the existing read-state, memory diagnostics and History APIs;
normalise only the target guest's fixed fields, without raw snapshots in public
evidence. Locked values/History cannot be renewed as fresh; CPU must progress.
Resumption needs available/agent memory, no disk deferral and fresh post-task
memory/disk History. **Internal cache timestamp preservation is source-tested;
these public readbacks do not directly inspect those internal timestamps.**
```sh
python3 tests/qualification/guest-agent-backup/check.py \
--record /private/new-native-record/record.json --expected-source EXACT_40_HEX_SHA
```
Files are bounded, hash-bound, single-name regular files; symlinks, traversal,
duplicate JSON keys, wrong identities, truncation, impossible order, replay,
incomplete coverage and adverse witness exits fail closed. Evidence-class and
provenance fields are owner observations, **not access grants or independent
artifact verification by the checker**. Exit 0 means the supplied record's
checks passed. Output deliberately retains `native_acceptance_complete: false`:
review original native provenance, mount completeness and command/task traces
before judging the job. It does not establish the reported command-ID cause,
fleet-wide safety, shipment, restore integrity or release qualification.
## Source checks and next result
```sh
python3 -m unittest discover -s tests/qualification/guest-agent-backup -v
go test -race ./tests/qualification/guest-agent-backup/guest-writer -count=1
```
Use the assigned source-proof VM for worker execution. The real local writer
smoke is ordinary synthetic fsync/readback, **not** a filesystem-freeze test.
Do not replay unchanged guard/app suites to disguise the missing native target.
Delivery's next material result is verified native inputs and a completed (or
adverse/inconclusive) bounded run: independent writes/liveness, actual command
overlap, truthful lock-time readings/History and normal cache resumption on the
exact containing build. Coordinator/reviewer/publisher own protected landing;
Delivery/steward own its containing all-main release. Community owns useful
reporter contact after material evidence. Until native acceptance, preserve
stop-Pulse-before-backup and restart-only-after-verified-thaw advice, explicitly
including the monitoring/alert outage. An OK task or cleared lock is not enough.
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""Check a bounded, redacted native backup record. No network or guest commands.
Supplied provenance is evidence for independent review, not authority or an
independently verified artifact. A successful check is not release qualification.
"""
import argparse
import hashlib
import json
from pathlib import Path
import re
import sys
MAX_BYTES = 2 * 1024 * 1024
ID = re.compile(r"[a-z0-9][a-z0-9-]{0,31}\Z")
HEX40 = re.compile(r"[0-9a-f]{40}\Z")
HEX64 = re.compile(r"[0-9a-f]{64}\Z")
METHODS = {"get-fsinfo", "file-read", "get-osinfo", "info", "network-get-interfaces"}
class InvalidRecord(Exception):
pass
def require(ok, message):
if not ok:
raise InvalidRecord(message)
def shape(value, keys, label):
require(type(value) is dict and set(value) == set(keys.split()), label + " shape")
def integer(value):
return type(value) is int and 0 <= value < 2**53
def digest(value, pattern=HEX64):
return type(value) is str and pattern.fullmatch(value) is not None
def unique_object(pairs):
result = {}
for key, value in pairs:
require(key not in result, "duplicate JSON field")
result[key] = value
return result
def decode(raw):
try:
return json.loads(raw, object_pairs_hook=unique_object)
except (ValueError, UnicodeError, RecursionError):
raise InvalidRecord("invalid JSON") from None
def read_bounded(path):
require(not path.is_symlink() and path.is_file(), "regular evidence file required")
with path.open("rb") as stream:
raw = stream.read(MAX_BYTES + 1)
require(0 < len(raw) <= MAX_BYTES, "evidence size bound")
return raw
def witness_events(root, entry, run_id, skew):
shape(entry, "filesystem_id file sha256 exit_code", "witness")
require(type(entry["filesystem_id"]) is str and ID.fullmatch(entry["filesystem_id"]), "filesystem ID")
name = entry["file"]
require(type(name) is str and re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}\.jsonl", name), "witness filename")
require(digest(entry["sha256"]), "witness digest")
require(type(entry["exit_code"]) is int and entry["exit_code"] == 0, "witness did not exit successfully")
raw = read_bounded(root / name)
require(hashlib.sha256(raw).hexdigest() == entry["sha256"], "witness content mismatch")
require(raw.endswith(b"\n"), "incomplete witness line")
lines = raw.splitlines()
require(4 <= len(lines) <= 2000 and all(0 < len(line) <= 2048 for line in lines), "witness event bound")
events = [decode(line) for line in lines]
previous_ms, previous_elapsed, sequence, previous_write_ms = -1, -1, 0, -1
for i, event in enumerate(events):
kind = event.get("kind") if type(event) is dict else None
require(type(kind) is str, "witness event kind")
extra = {"start": "", "heartbeat": "", "write": " sequence started_unix_ms sha256", "stop": " result"}.get(kind)
require(extra is not None, "witness event kind")
shape(event, "schema_version kind run_id filesystem_id unix_ms elapsed_ms" + extra, "witness event")
require(type(event["schema_version"]) is int and event["schema_version"] == 1, "witness schema")
require(event["run_id"] == run_id and event["filesystem_id"] == entry["filesystem_id"], "witness identity mismatch")
at, elapsed = event["unix_ms"], event["elapsed_ms"]
require(integer(at) and integer(elapsed) and at >= previous_ms and elapsed >= previous_elapsed, "witness time order")
require(elapsed <= 601000, "witness duration bound")
if i:
drift = (at - events[0]["unix_ms"]) - (elapsed - events[0]["elapsed_ms"])
require(abs(drift) <= skew, "witness clock discontinuity")
require((kind == "start") == (i == 0) and (kind == "stop") == (i == len(events) - 1), "witness lifecycle")
if kind == "write":
sequence += 1
require(type(event["sequence"]) is int and event["sequence"] == sequence, "witness sequence")
require(integer(event["started_unix_ms"]) and
max(previous_write_ms, events[0]["unix_ms"]) <= event["started_unix_ms"] <= at, "witness write time")
payload = f"Pulse disposable backup witness\n{run_id}\n{entry['filesystem_id']}\n{sequence}\n".encode()
payload += b"x" * (512 - len(payload))
require(digest(event["sha256"]) and event["sha256"] == hashlib.sha256(payload).hexdigest(), "witness readback digest")
previous_write_ms = at
if kind == "stop":
require(event["result"] == "complete", "witness incomplete or failed")
previous_ms, previous_elapsed = at, elapsed
return events
def check_record(record, root, expected_source):
require(digest(expected_source, HEX40), "expected source SHA")
shape(record, "schema_version run_id evidence_class identity preflight window backup coverage commands snapshots witnesses", "record")
require(type(record["schema_version"]) is int and record["schema_version"] == 1, "record schema")
require(type(record["run_id"]) is str and ID.fullmatch(record["run_id"]), "run ID")
require(record["evidence_class"] == "native-disposable", "not a native disposable record")
identity = record["identity"]
shape(identity, "pulse_source pulse_binary_sha256 pve_packages_sha256 qga_binary_sha256 haos_image_sha256 provenance_receipt_sha256", "identity")
require(identity["pulse_source"] == expected_source, "installed source mismatch")
require(all(digest(identity[k]) for k in identity if k != "pulse_source"), "input identity digest")
preflight = record["preflight"]
shape(preflight, "pve_version guest_type audit file_read tls_verified qga_enabled freeze_enabled single_pulse_process filesystem_map_sha256 filesystem_ids", "preflight")
require(preflight["pve_version"] == "9.2.21" and preflight["guest_type"] == "HAOS", "reported platform mismatch")
require(all(preflight[k] is True for k in ("audit", "file_read", "tls_verified", "qga_enabled", "freeze_enabled", "single_pulse_process")), "preflight incomplete")
require(digest(preflight["filesystem_map_sha256"]), "filesystem crosswalk digest")
expected_filesystems = preflight["filesystem_ids"]
require(type(expected_filesystems) is list and 1 <= len(expected_filesystems) <= 16 and
all(type(v) is str and ID.fullmatch(v) for v in expected_filesystems) and
len(set(expected_filesystems)) == len(expected_filesystems), "covered filesystem IDs")
window = record["window"]
shape(window, "start_ms end_ms clock_error_ms", "window")
require(all(integer(v) for v in window.values()), "observation bounds")
start, end, skew = window["start_ms"], window["end_ms"], window["clock_error_ms"]
require(3000 <= end - start <= 600000 and skew <= 1000, "observation duration or clock bound")
backup = record["backup"]
shape(backup, "start_ms lock_start_ms freeze_ms thaw_request_ms lock_end_ms completed_ms status trace_sha256", "backup")
times = [backup[k] for k in ("start_ms", "lock_start_ms", "freeze_ms", "thaw_request_ms", "lock_end_ms", "completed_ms")]
require(all(integer(v) for v in times) and times == sorted(times) and start < times[0] < times[-1] < end, "backup time order")
require(backup["lock_start_ms"] < backup["lock_end_ms"], "backup lock not observed")
require(backup["status"] == "OK" and digest(backup["trace_sha256"]), "backup did not complete normally")
coverage = record["coverage"]
shape(coverage, "start_ms end_ms dropped_events trace_sha256", "coverage")
require(all(integer(coverage[k]) for k in ("start_ms", "end_ms", "dropped_events")) and
coverage["start_ms"] <= start and coverage["end_ms"] >= end and coverage["dropped_events"] == 0 and
digest(coverage["trace_sha256"]), "native command coverage incomplete")
commands = record["commands"]
require(type(commands) is list and 1 <= len(commands) <= 512, "native command bound")
ids, intervals = set(), []
overlap = False
for command in commands:
shape(command, "id method start_ms end_ms result", "command")
require(type(command["id"]) is str and ID.fullmatch(command["id"]) and command["id"] not in ids, "duplicate or invalid command ID")
ids.add(command["id"])
require(type(command["method"]) is str and command["method"] in METHODS and command["result"] == "ok", "native command adverse or unsupported")
began, finished = command["start_ms"], command["end_ms"]
require(integer(began) and integer(finished) and start <= began < finished <= end, "command time order")
require(not backup["lock_start_ms"] - skew <= began <= backup["lock_end_ms"] + skew, "command dispatched during lock or uncertainty")
overlap |= began < backup["lock_start_ms"] - skew and finished > backup["lock_start_ms"] + skew
intervals.append((began, finished))
require(overlap, "no observed in-flight overlap")
intervals.sort()
require(all(a[1] <= b[0] for a, b in zip(intervals, intervals[1:])), "concurrent guest commands")
snapshots = record["snapshots"]
require(type(snapshots) is list and 4 <= len(snapshots) <= 64, "monitoring snapshot bound")
phases = [s.get("phase") if type(s) is dict else None for s in snapshots]
require(phases == ["before"] + ["locked"] * (len(snapshots) - 2) + ["resumed"], "monitoring phases")
for snapshot in snapshots:
shape(snapshot, "phase at_ms guest_identity_sha256 guest_status metadata_sha256 memory_sha256 disk_sha256 memory_source disk_reason disk_history_latest_ms memory_history_latest_ms cpu_latest_ms", "snapshot")
require(integer(snapshot["at_ms"]), "snapshot time")
require(all(digest(snapshot[k]) for k in ("guest_identity_sha256", "metadata_sha256", "memory_sha256", "disk_sha256")), "snapshot digest")
require(all(integer(snapshot[k]) and start <= snapshot[k] <= snapshot["at_ms"] <= end for k in
("at_ms", "disk_history_latest_ms", "memory_history_latest_ms", "cpu_latest_ms")), "snapshot times")
require(all(a["at_ms"] < b["at_ms"] for a, b in zip(snapshots, snapshots[1:])), "snapshot time order")
before, resumed = snapshots[0], snapshots[-1]
require(before["at_ms"] < backup["start_ms"] - skew and before["guest_status"] == "available" and
before["memory_source"] == "guest-agent-meminfo" and before["disk_reason"] == "", "baseline guest truth missing")
stable = ("guest_identity_sha256", "metadata_sha256", "memory_sha256", "disk_sha256", "disk_history_latest_ms", "memory_history_latest_ms")
for locked in snapshots[1:-1]:
require(backup["lock_start_ms"] + skew < locked["at_ms"] < backup["lock_end_ms"] - skew,
"locked snapshot outside lock")
require(locked["guest_status"] == "deferred" and locked["memory_source"] == "previous-snapshot" and
locked["disk_reason"] == "prev-vm-locked", "deferral not visible")
require(all(locked[k] == before[k] for k in stable), "retained evidence renewed or changed during lock")
require(locked["cpu_latest_ms"] > before["cpu_latest_ms"], "non-QGA monitoring did not progress")
require(resumed["guest_identity_sha256"] == before["guest_identity_sha256"] and resumed["guest_status"] == "available" and
resumed["memory_source"] == "guest-agent-meminfo" and resumed["disk_reason"] == "", "guest did not resume")
require(all(resumed[k] > backup["completed_ms"] + skew for k in ("disk_history_latest_ms", "memory_history_latest_ms", "cpu_latest_ms")),
"fresh post-backup History absent")
witnesses = record["witnesses"]
require(type(witnesses) is list and 1 <= len(witnesses) <= 16, "filesystem witness bound")
filesystems = set()
for witness in witnesses:
events = witness_events(root, witness, record["run_id"], skew)
require(witness["filesystem_id"] not in filesystems, "duplicate filesystem witness")
filesystems.add(witness["filesystem_id"])
require(events[0]["unix_ms"] < backup["start_ms"] - skew and end - skew <= events[-1]["unix_ms"] <= end + skew,
"witness did not cover the observation window")
writes = [e for e in events if e["kind"] == "write"]
require(sum(e["unix_ms"] < backup["start_ms"] - skew for e in writes) >= 2, "baseline filesystem writes missing")
require(sum(backup["completed_ms"] + skew < e["started_unix_ms"] <= e["unix_ms"] <= end + skew for e in writes) >= 2,
"independent post-task fsync/readback missing")
require(any(e["kind"] == "heartbeat" and backup["freeze_ms"] + skew < e["unix_ms"] < backup["thaw_request_ms"] - skew for e in events),
"guest liveness during freeze missing")
require(filesystems == set(expected_filesystems), "covered filesystem witness missing")
return {"record_checks_passed": True, "native_acceptance_complete": False,
"source": expected_source, "filesystem_witnesses": len(filesystems),
"limits": "Review native provenance, covered-filesystem completeness and raw traces independently. This record does not reproduce the reported cause, certify all guests, establish delivery or qualify a release."}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--record", type=Path, required=True)
parser.add_argument("--expected-source", required=True)
args = parser.parse_args()
try:
result = check_record(decode(read_bounded(args.record)), args.record.parent, args.expected_source)
except (InvalidRecord, OSError, TypeError, ValueError, RecursionError) as error:
# The record may contain private or malformed input. Do not echo it or
# exception paths. Detailed receipts remain with the native owner.
failure = str(error) if isinstance(error, InvalidRecord) else "evidence read or schema failure"
print(json.dumps({"record_checks_passed": False, "native_acceptance_complete": False, "failure": failure}), file=sys.stderr)
return 1
print(json.dumps(result, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,206 @@
// guest-writer observes a disposable guest filesystem without using QGA.
// It never starts a backup, freezes/thaws a filesystem or restarts a service.
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"regexp"
"syscall"
"time"
)
var opaqueID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
type options struct {
directory, runID, filesystemID string
duration, interval time.Duration
}
func (o options) validate() error {
if !opaqueID.MatchString(o.runID) || !opaqueID.MatchString(o.filesystemID) {
return errors.New("opaque run and filesystem IDs required")
}
if o.duration < 3*time.Second || o.duration > 10*time.Minute || o.interval < time.Second || o.interval > 5*time.Second {
return errors.New("duration must be 3..600 seconds and interval 1000..5000 milliseconds")
}
if !filepath.IsAbs(o.directory) || filepath.Clean(o.directory) != o.directory {
return errors.New("absolute clean scratch directory required")
}
return nil
}
// The owner must map this directory to a filesystem actually covered by the
// native backup. A tmpfs or container overlay is not an equivalent witness.
func prepareWriter(o options) (func(int) (string, error), error) {
if err := o.validate(); err != nil {
return nil, err
}
resolved, err := filepath.EvalSymlinks(o.directory)
if err != nil || resolved != o.directory {
return nil, errors.New("scratch directory unavailable or symlinked")
}
info, err := os.Stat(o.directory)
if err != nil || !info.IsDir() {
return nil, errors.New("scratch directory unavailable")
}
dir := filepath.Join(o.directory, "pulse-backup-witness-"+o.runID+"-"+o.filesystemID)
// Never reuse a run or overwrite any pre-existing file. Retain synthetic
// files on both success and failure; fixture teardown belongs to its owner.
if err := os.Mkdir(dir, 0700); err != nil {
return nil, errors.New("new witness directory could not be created")
}
return func(seq int) (string, error) {
payload := []byte(fmt.Sprintf("Pulse disposable backup witness\n%s\n%s\n%d\n", o.runID, o.filesystemID, seq))
payload = append(payload, bytes.Repeat([]byte{'x'}, 512-len(payload))...)
path := filepath.Join(dir, fmt.Sprintf("witness-%06d", seq))
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
return "", errors.New("witness write failed")
}
_, writeErr := f.Write(payload)
syncErr := f.Sync()
closeErr := f.Close()
if errors.Join(writeErr, syncErr, closeErr) != nil {
return "", errors.New("witness write failed")
}
d, err := os.Open(dir)
if err != nil {
return "", errors.New("witness directory sync failed")
}
syncErr, closeErr = d.Sync(), d.Close()
if errors.Join(syncErr, closeErr) != nil {
return "", errors.New("witness directory sync failed")
}
readback, err := os.ReadFile(path)
if err != nil || !bytes.Equal(readback, payload) {
return "", errors.New("witness readback failed")
}
digest := sha256.Sum256(readback)
return hex.EncodeToString(digest[:]), nil
}, nil
}
type event struct {
SchemaVersion int `json:"schema_version"`
Kind string `json:"kind"`
RunID string `json:"run_id"`
FilesystemID string `json:"filesystem_id"`
UnixMS int64 `json:"unix_ms"`
ElapsedMS int64 `json:"elapsed_ms"`
Sequence int `json:"sequence,omitempty"`
StartedUnixMS int64 `json:"started_unix_ms,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Result string `json:"result,omitempty"`
}
type writeResult struct {
sequence int
started time.Time
digest string
err error
}
func observe(ctx context.Context, o options, output io.Writer, write func(int) (string, error)) error {
start := time.Now()
deadline := start.Add(o.duration)
encoder := json.NewEncoder(output)
emit := func(e event) error {
now := time.Now()
e.SchemaVersion, e.RunID, e.FilesystemID = 1, o.runID, o.filesystemID
e.UnixMS, e.ElapsedMS = now.UnixMilli(), now.Sub(start).Milliseconds()
return encoder.Encode(e)
}
if err := emit(event{Kind: "start"}); err != nil {
return err
}
timer := time.NewTimer(o.duration)
defer timer.Stop()
ticker := time.NewTicker(o.interval)
defer ticker.Stop()
results := make(chan writeResult, 1)
sequence, pending := 0, false
begin := func() {
sequence++
pending = true
seq, began := sequence, time.Now()
go func() {
digest, err := write(seq)
results <- writeResult{seq, began, digest, err}
}()
}
stop := func(result string) error { return emit(event{Kind: "stop", Result: result}) }
if ctx.Err() == nil {
begin()
}
for {
select {
case <-ctx.Done():
_ = stop("interrupted")
return errors.New("witness interrupted")
case <-timer.C:
if pending {
_ = stop("write-pending")
return errors.New("witness deadline with write pending")
}
return stop("complete")
case <-ticker.C:
if err := emit(event{Kind: "heartbeat"}); err != nil {
return err
}
// A blocked filesystem write must not queue more work or prevent
// independent guest liveness observations on stdout.
if !pending && time.Now().Before(deadline) {
begin()
}
case result := <-results:
pending = false
if result.err != nil {
_ = stop("write-failed")
return errors.New("witness write failed")
}
if err := emit(event{Kind: "write", Sequence: result.sequence, StartedUnixMS: result.started.UnixMilli(), SHA256: result.digest}); err != nil {
return err
}
}
}
}
func main() {
var o options
var durationSeconds, intervalMS int
flag.StringVar(&o.directory, "directory", "", "owner-provisioned scratch directory on the covered filesystem")
flag.StringVar(&o.runID, "run-id", "", "opaque run ID; no names or paths")
flag.StringVar(&o.filesystemID, "filesystem-id", "", "opaque filesystem ID from the private mount crosswalk")
flag.IntVar(&durationSeconds, "duration-seconds", 600, "preselected observation duration, 3..600")
flag.IntVar(&intervalMS, "interval-ms", 1000, "synthetic write/heartbeat interval, 1000..5000")
flag.Parse()
if flag.NArg() != 0 || durationSeconds < 3 || durationSeconds > 600 || intervalMS < 1000 || intervalMS > 5000 {
fmt.Fprintln(os.Stderr, "invalid witness arguments")
os.Exit(2)
}
o.duration, o.interval = time.Duration(durationSeconds)*time.Second, time.Duration(intervalMS)*time.Millisecond
write, err := prepareWriter(o)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
if err := observe(ctx, o, os.Stdout, write); err != nil {
// Fixed errors only: never emit the private filesystem path or a
// provider/service environment. An output error is still a failure.
fmt.Fprintln(os.Stderr, "witness observation failed")
os.Exit(1)
}
}
@@ -0,0 +1,220 @@
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
func testOptions(t *testing.T) options {
t.Helper()
return options{directory: t.TempDir(), runID: "run-01", filesystemID: "data", duration: 3 * time.Second, interval: time.Second}
}
func events(t *testing.T, output *bytes.Buffer) []event {
t.Helper()
var result []event
decoder := json.NewDecoder(output)
for {
var e event
if err := decoder.Decode(&e); err == io.EOF {
break
} else if err != nil {
t.Fatal(err)
}
result = append(result, e)
}
return result
}
func TestWitnessWritesSyncsAndReadsOnlyNewSyntheticFiles(t *testing.T) {
o := testOptions(t)
untouched := filepath.Join(o.directory, "existing")
if err := os.WriteFile(untouched, []byte("do not overwrite"), 0600); err != nil {
t.Fatal(err)
}
write, err := prepareWriter(o)
if err != nil {
t.Fatal(err)
}
dir := filepath.Join(o.directory, "pulse-backup-witness-run-01-data")
for seq := 1; seq <= 2; seq++ {
digest, err := write(seq)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, fmt.Sprintf("witness-%06d", seq))
payload, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
hash := sha256.Sum256(payload)
if len(payload) != 512 || digest != hex.EncodeToString(hash[:]) || !bytes.Contains(payload, []byte("\nrun-01\ndata\n")) {
t.Fatal("witness did not read back the new synthetic payload")
}
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0600 {
t.Fatal("witness file permissions")
}
}
if _, err := write(1); err == nil {
t.Fatal("repeated sequence overwrote existing evidence")
}
if _, err := prepareWriter(o); err == nil {
t.Fatal("repeated run reused existing evidence")
}
data, err := os.ReadFile(untouched)
if err != nil || string(data) != "do not overwrite" {
t.Fatal("unrelated file changed")
}
info, err := os.Stat(dir)
if err != nil || info.Mode().Perm() != 0700 {
t.Fatal("witness directory permissions")
}
}
func TestWitnessRejectsInvalidInputsBeforeCreatingFiles(t *testing.T) {
changes := map[string]func(*options){
"relative": func(o *options) { o.directory = "relative" },
"run traversal": func(o *options) { o.runID = "../elsewhere" },
"filesystem traversal": func(o *options) { o.filesystemID = "../elsewhere" },
"oversize ID": func(o *options) { o.runID = strings.Repeat("a", 33) },
"duration small": func(o *options) { o.duration = time.Second },
"duration large": func(o *options) { o.duration = 11 * time.Minute },
"interval small": func(o *options) { o.interval = time.Millisecond },
"interval large": func(o *options) { o.interval = 6 * time.Second },
}
for name, change := range changes {
t.Run(name, func(t *testing.T) {
o := testOptions(t)
original := o.directory
change(&o)
if _, err := prepareWriter(o); err == nil {
t.Fatal("unsafe or unbounded input accepted")
}
entries, err := os.ReadDir(original)
if err != nil || len(entries) != 0 {
t.Fatal("invalid input created files")
}
})
}
for _, parent := range []bool{false, true} {
t.Run("symlink", func(t *testing.T) {
o := testOptions(t)
real := t.TempDir()
link := filepath.Join(o.directory, "link")
if err := os.Symlink(real, link); err != nil {
t.Fatal(err)
}
o.directory = link
if parent {
if err := os.Mkdir(filepath.Join(real, "child"), 0700); err != nil {
t.Fatal(err)
}
o.directory = filepath.Join(link, "child")
}
if _, err := prepareWriter(o); err == nil {
t.Fatal("symlinked scratch path accepted")
}
})
}
}
func TestObserverCompletesWithOrderedWriteAndLivenessEvidence(t *testing.T) {
o := testOptions(t)
// Short timers exercise the event loop, not the CLI's native bounds.
o.duration, o.interval = 250*time.Millisecond, 30*time.Millisecond
var output bytes.Buffer
if err := observe(context.Background(), o, &output, func(int) (string, error) { return strings.Repeat("a", 64), nil }); err != nil {
t.Fatal(err)
}
seen := events(t, &output)
if len(seen) < 4 || seen[0].Kind != "start" || seen[len(seen)-1].Result != "complete" {
t.Fatalf("incomplete lifecycle: %#v", seen)
}
sequence := 0
for i, event := range seen {
if event.SchemaVersion != 1 || event.RunID != o.runID || event.FilesystemID != o.filesystemID || (i > 0 && event.ElapsedMS < seen[i-1].ElapsedMS) {
t.Fatal("event identity or order")
}
if event.Kind == "write" {
sequence++
if event.Sequence != sequence || event.StartedUnixMS > event.UnixMS || event.SHA256 == "" {
t.Fatal("write evidence incomplete")
}
}
}
if sequence == 0 {
t.Fatal("no writes observed")
}
}
func TestObserverBlockedWriteDoesNotQueueMoreAndCannotPass(t *testing.T) {
o := testOptions(t)
o.duration, o.interval = 250*time.Millisecond, 30*time.Millisecond
var output bytes.Buffer
var calls atomic.Int32
release := make(chan struct{})
defer close(release)
err := observe(context.Background(), o, &output, func(int) (string, error) {
calls.Add(1)
<-release
return "", nil
})
if err == nil || calls.Load() != 1 {
t.Fatal("blocked write passed or queued more work")
}
seen := events(t, &output)
if len(seen) < 3 || seen[len(seen)-1].Result != "write-pending" {
t.Fatal("blocked write erased liveness or deadline failure")
}
for _, event := range seen {
if event.Kind == "write" {
t.Fatal("uncompleted write reported successful")
}
}
}
func TestObserverFailureAndInterruptionRemainAdverse(t *testing.T) {
o := testOptions(t)
var output bytes.Buffer
if err := observe(context.Background(), o, &output, func(int) (string, error) { return "", errors.New("private path") }); err == nil {
t.Fatal("failed write passed")
}
if strings.Contains(output.String(), "private path") || events(t, &output)[1].Result != "write-failed" {
t.Fatal("failure missing or private error disclosed")
}
output.Reset()
ctx, cancel := context.WithCancel(context.Background())
cancel()
called := false
if err := observe(ctx, o, &output, func(int) (string, error) { called = true; return "", nil }); err == nil || called {
t.Fatal("cancelled run started writes or passed")
}
if events(t, &output)[1].Result != "interrupted" {
t.Fatal("interruption not retained")
}
}
type failedOutput struct{}
func (failedOutput) Write([]byte) (int, error) { return 0, errors.New("private output path") }
func TestObserverUncollectedOutputFailsBeforeStartingWrites(t *testing.T) {
called := false
err := observe(context.Background(), testOptions(t), failedOutput{}, func(int) (string, error) { called = true; return "", nil })
if err == nil || called {
t.Fatal("missing output passed or began writes")
}
}
@@ -0,0 +1,264 @@
"""Synthetic checker controls; none is native PVE/QGA acceptance."""
import copy
import hashlib
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
SPEC = importlib.util.spec_from_file_location("backup_check", Path(__file__).with_name("check.py"))
CHECK = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(CHECK)
SOURCE = "a" * 40
HASH = "b" * 64
def witness_events(filesystem="data"):
events = []
for kind, at, extra in [
("start", 100000, {}),
("write", 100020, {"sequence": 1, "started_unix_ms": 100010}),
("heartbeat", 101000, {}),
("write", 101020, {"sequence": 2, "started_unix_ms": 101010}),
("heartbeat", 105000, {}),
("write", 108020, {"sequence": 3, "started_unix_ms": 108010}),
("write", 109020, {"sequence": 4, "started_unix_ms": 109010}),
("stop", 120000, {"result": "complete"}),
]:
event = {"schema_version": 1, "kind": kind, "run_id": "run-01", "filesystem_id": filesystem,
"unix_ms": at, "elapsed_ms": at - 100000, **extra}
if kind == "write":
payload = f"Pulse disposable backup witness\nrun-01\n{filesystem}\n{event['sequence']}\n".encode()
payload += b"x" * (512 - len(payload))
event["sha256"] = hashlib.sha256(payload).hexdigest()
events.append(event)
return events
def record():
baseline = {"phase": "before", "at_ms": 102000, "guest_identity_sha256": HASH, "guest_status": "available",
"metadata_sha256": HASH, "memory_sha256": HASH, "disk_sha256": HASH,
"memory_source": "guest-agent-meminfo", "disk_reason": "", "disk_history_latest_ms": 100020,
"memory_history_latest_ms": 100040, "cpu_latest_ms": 100010}
locked = {**baseline, "phase": "locked", "at_ms": 104500, "cpu_latest_ms": 104000,
"guest_status": "deferred", "memory_source": "previous-snapshot", "disk_reason": "prev-vm-locked"}
resumed = {**baseline, "phase": "resumed", "at_ms": 110000, "disk_history_latest_ms": 109500,
"memory_history_latest_ms": 109500, "cpu_latest_ms": 109500}
return {"schema_version": 1, "run_id": "run-01", "evidence_class": "native-disposable",
"identity": {"pulse_source": SOURCE, "pulse_binary_sha256": HASH, "pve_packages_sha256": HASH,
"qga_binary_sha256": HASH, "haos_image_sha256": HASH, "provenance_receipt_sha256": HASH},
"preflight": {"pve_version": "9.2.21", "guest_type": "HAOS", "audit": True, "file_read": True,
"tls_verified": True, "qga_enabled": True, "freeze_enabled": True,
"single_pulse_process": True, "filesystem_map_sha256": HASH, "filesystem_ids": ["data"]},
"window": {"start_ms": 100000, "end_ms": 120000, "clock_error_ms": 10},
"backup": {"start_ms": 103000, "lock_start_ms": 103200, "freeze_ms": 104000, "thaw_request_ms": 106000,
"lock_end_ms": 106200, "completed_ms": 107000, "status": "OK", "trace_sha256": HASH},
"coverage": {"start_ms": 100000, "end_ms": 120000, "dropped_events": 0, "trace_sha256": HASH},
"commands": [{"id": "request-01", "method": "get-fsinfo", "start_ms": 103100,
"end_ms": 103500, "result": "ok"}],
"snapshots": [baseline, locked, {**locked, "at_ms": 105500, "cpu_latest_ms": 105000}, resumed],
"witnesses": []}
class BackupRecordTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.record = record()
self.add_witness()
def add_witness(self, filesystem="data", events=None):
events = witness_events(filesystem) if events is None else events
raw = b"".join(json.dumps(e).encode() + b"\n" for e in events)
filename = filesystem + ".jsonl"
(self.root / filename).write_bytes(raw)
entry = {"filesystem_id": filesystem, "file": filename, "sha256": hashlib.sha256(raw).hexdigest(), "exit_code": 0}
self.record["witnesses"] = [w for w in self.record["witnesses"] if w["filesystem_id"] != filesystem] + [entry]
def check(self):
return CHECK.check_record(self.record, self.root, SOURCE)
def test_complete_record_checks_do_not_claim_native_acceptance(self):
result = self.check()
self.assertTrue(result["record_checks_passed"])
self.assertFalse(result["native_acceptance_complete"])
def test_every_covered_filesystem_needs_an_independent_witness(self):
self.record["preflight"]["filesystem_ids"].append("root")
with self.assertRaisesRegex(CHECK.InvalidRecord, "witness missing"):
self.check()
self.add_witness("root")
self.assertEqual(self.check()["filesystem_witnesses"], 2)
def test_ok_task_without_post_task_writes_is_not_thaw(self):
self.add_witness(events=[e for e in witness_events() if e.get("sequence", 0) < 3])
with self.assertRaisesRegex(CHECK.InvalidRecord, "post-task fsync"):
self.check()
def test_outstanding_prefreeze_write_is_not_two_fresh_post_task_writes(self):
events = witness_events()
for event in events:
if event.get("sequence") == 3:
event["started_unix_ms"] = 102000
self.add_witness(events=events)
with self.assertRaisesRegex(CHECK.InvalidRecord, "post-task fsync"):
self.check()
def test_wrong_id_timeout_and_incomplete_native_commands_stay_adverse(self):
for result in ("wrong-command-id", "timeout", "response-incomplete", "permission-denied"):
with self.subTest(result=result):
self.record["commands"][0]["result"] = result
with self.assertRaisesRegex(CHECK.InvalidRecord, "adverse"):
self.check()
def test_lock_dispatch_concurrency_replay_and_absent_overlap_fail(self):
original = copy.deepcopy(self.record)
changes = [
lambda r: r["commands"][0].update(start_ms=104000, end_ms=104500),
lambda r: r["commands"][0].update(start_ms=103200),
lambda r: r["commands"][0].update(end_ms=103150),
lambda r: r["commands"].append({**r["commands"][0], "id": "another"}),
lambda r: r["commands"].append(copy.deepcopy(r["commands"][0])),
]
for change in changes:
self.record = copy.deepcopy(original)
change(self.record)
with self.subTest(commands=self.record["commands"]):
with self.assertRaises(CHECK.InvalidRecord):
self.check()
def test_cache_history_cpu_and_resume_controls(self):
original = copy.deepcopy(self.record)
changes = [
lambda r: r["snapshots"][1].update(disk_history_latest_ms=104000),
lambda r: r["snapshots"][1].update(memory_history_latest_ms=104000),
lambda r: r["snapshots"][1].update(metadata_sha256="c" * 64),
lambda r: r["snapshots"][1].update(memory_sha256="c" * 64),
lambda r: r["snapshots"][1].update(disk_sha256="c" * 64),
lambda r: r["snapshots"][1].update(cpu_latest_ms=100010),
lambda r: r["snapshots"][1].update(guest_status="available"),
lambda r: r["snapshots"][1].update(disk_reason=""),
lambda r: r["snapshots"][1].update(memory_source="guest-agent-meminfo"),
lambda r: r["snapshots"][-1].update(guest_identity_sha256="c" * 64),
lambda r: r["snapshots"][-1].update(disk_history_latest_ms=100020),
lambda r: r["snapshots"][-1].update(memory_history_latest_ms=100040),
lambda r: r["snapshots"][-1].update(guest_status="deferred"),
]
for change in changes:
self.record = copy.deepcopy(original)
change(self.record)
with self.subTest(snapshots=self.record["snapshots"]):
with self.assertRaises(CHECK.InvalidRecord):
self.check()
def test_missing_permissions_identity_trace_and_clock_are_not_acceptance(self):
original = copy.deepcopy(self.record)
changes = [
lambda r: r["identity"].update(pulse_source="c" * 40),
lambda r: r["identity"].update(haos_image_sha256="missing"),
lambda r: r.update(evidence_class="source-control"),
lambda r: r["preflight"].update(pve_version="9.0"),
lambda r: r["preflight"].update(guest_type="ordinary-qga-subprocess"),
lambda r: r["preflight"].update(audit=False),
lambda r: r["preflight"].update(file_read=False),
lambda r: r["preflight"].update(tls_verified=False),
lambda r: r["preflight"].update(freeze_enabled=False),
lambda r: r["preflight"].update(single_pulse_process=False),
lambda r: r["coverage"].update(dropped_events=1),
lambda r: r["coverage"].update(start_ms=103500),
lambda r: r["coverage"].update(end_ms=107000),
lambda r: r["window"].update(end_ms=800000),
lambda r: r["window"].update(clock_error_ms=2000),
lambda r: r["backup"].update(status="failed"),
]
for change in changes:
self.record = copy.deepcopy(original)
change(self.record)
with self.subTest(record=self.record):
with self.assertRaises(CHECK.InvalidRecord):
self.check()
def test_witness_corruption_replay_missing_liveness_and_early_exit_fail(self):
original = witness_events()
changes = [
lambda events: events[-1].update(result="write-pending"),
lambda events: events[-1].update(result="interrupted"),
lambda events: events[-1].update(unix_ms=110000, elapsed_ms=10000),
lambda events: events[1].update(sha256=HASH),
lambda events: events[5].update(sequence=1),
lambda events: events[1].update(run_id="other-run"),
lambda events: events[1].update(filesystem_id="other-fs"),
lambda events: events[4].update(unix_ms=104000, elapsed_ms=4000),
lambda events: events[4].update(elapsed_ms=4000),
lambda events: events[1].update(kind=[]),
lambda events: events[1].update(private_value="must not be accepted"),
]
for change in changes:
events = copy.deepcopy(original)
change(events)
self.add_witness(events=events)
with self.subTest(events=events):
with self.assertRaises(CHECK.InvalidRecord):
self.check()
def test_missing_symlinked_oversized_truncated_and_changed_witness_fail(self):
path = self.root / "data.jsonl"
original = path.read_bytes()
for raw in (original[:-1], original + b"corrupted\n", b"x" * (CHECK.MAX_BYTES + 1)):
path.write_bytes(raw)
self.record["witnesses"][0]["sha256"] = hashlib.sha256(raw).hexdigest()
with self.assertRaises(CHECK.InvalidRecord):
self.check()
self.add_witness()
self.record["witnesses"][0]["sha256"] = HASH
with self.assertRaisesRegex(CHECK.InvalidRecord, "mismatch"):
self.check()
self.add_witness()
target = self.root / "retained.jsonl"
path.rename(target)
path.symlink_to(target.name)
with self.assertRaisesRegex(CHECK.InvalidRecord, "regular"):
self.check()
path.unlink()
with self.assertRaisesRegex(CHECK.InvalidRecord, "regular"):
self.check()
def test_traversal_duplicate_filesystem_boolean_times_and_extra_fields_fail(self):
original = copy.deepcopy(self.record)
changes = [
lambda r: r["witnesses"][0].update(file="../data.jsonl"),
lambda r: r["witnesses"][0].update(exit_code=True),
lambda r: r["witnesses"].append(copy.deepcopy(r["witnesses"][0])),
lambda r: r["window"].update(start_ms=True),
lambda r: r["snapshots"][0].update(at_ms=False),
lambda r: r["commands"][0].update(method=[]),
lambda r: r.update(private_names="do not accept private fields"),
]
for change in changes:
self.record = copy.deepcopy(original)
change(self.record)
with self.assertRaises(CHECK.InvalidRecord):
self.check()
def test_duplicate_json_keys_fail(self):
with self.assertRaisesRegex(CHECK.InvalidRecord, "duplicate"):
CHECK.decode(b'{"status":"OK","status":"failed"}')
def test_cli_returns_sanitised_failures_without_echoing_private_input(self):
path = self.root / "record.json"
self.record["snapshots"][0]["at_ms"] = "private-value-must-not-escape"
path.write_text(json.dumps(self.record))
result = subprocess.run([sys.executable, str(Path(__file__).with_name("check.py")),
"--record", str(path), "--expected-source", SOURCE], capture_output=True, text=True)
self.assertEqual(result.returncode, 1)
self.assertNotIn("private-value-must-not-escape", result.stdout + result.stderr)
self.assertEqual(json.loads(result.stderr)["failure"], "snapshot time")
if __name__ == "__main__":
unittest.main()