perf(delivery): use bounded native cold restore copies

This commit is contained in:
2026-10-28 12:11:53 +08:00
parent 9f4a8c9a68
commit 8d7f651ddc
24 changed files with 2911 additions and 19 deletions
+3
View File
@@ -111,6 +111,8 @@ jobs:
tests/test_delivery_io.py \
tests/test_delivery_backup.py \
tests/test_delivery_restore_io.py \
tests/test_restore_io_contracts.py \
tests/test_restore_native_transport.py \
tests/test_operator_imports.py \
tests/test_health.py \
tests/test_identity.py \
@@ -126,6 +128,7 @@ jobs:
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --lib --locked
cargo test --package kf-contracts --locked
cargo test --package kf-sandbox --bin kf-restore-io --locked
- name: Check and build Frontend
timeout-minutes: 5
+3 -1
View File
@@ -37,12 +37,14 @@ Python 业务域分为 `identity`、`problems`、`submissions`、`community`、`
Rust 分为 `kf-contracts`、`kf-sandbox`、`kf-engine`、`kf-worker`、`kf-coordinator` 五个 crate。
协调器拥有执行状态写入权,判题 Worker 不直接写评测数据库。
Sandbox 内的 `kf-restore-io` 仅在固定离线恢复单元中复制和核验固定数据域,
通过共享有界协议接收文件记录;不启动程序、服务或写业务状态。
沙箱执行入口每次核验隔离与限额,任何未验证配置继续拒绝执行。
用户程序及隔离/超限验收只在独立、整体限额的无业务凭据测试 VM 内运行。
## 安装与检查
工具链基线:Python 3.13.16 + uv、Rust 1.99.0、Node 26.10.0 + pnpm 11.28.4。
工具链基线:Python 3.13.16 + uv、Rust 1.99.0、Node 26.11.1 + pnpm 11.28.4。
Python 和 Rust 版本由工程配置固定,三套工程分别提交依赖锁文件。
开发与构建使用 Linux 原生文件系统。
+3
View File
@@ -20,6 +20,7 @@ from kf_oj.contracts.problem_maintenance import (
MaintenanceReceipt,
PublishCandidate,
)
from kf_oj.contracts.restore_io import RestoreIOReply, RestoreIORequest
from kf_oj.contracts.submission_performance import SubmissionPerformance
from kf_oj.core.settings import Settings
@@ -57,6 +58,8 @@ def main() -> None:
outputs[root / "contracts/schemas/submission-performance.v1.schema.json"] = performance
for name, model in {
"delivery-availability": AvailabilityRecord,
"restore-io-request": RestoreIORequest,
"restore-io-reply": RestoreIOReply,
"problem-publish": PublishCandidate,
"problem-availability": ChangeProblemAvailability,
"problem-maintenance": MaintenanceReceipt,
+129
View File
@@ -0,0 +1,129 @@
"""Private offline restore byte-copy protocol; conveys no execution authority."""
import re
from typing import Annotated, Literal
from pydantic import (
BaseModel,
BeforeValidator,
ConfigDict,
Field,
RootModel,
StrictInt,
model_validator,
)
from kf_oj.contracts.judging import integer_version
MAX_FILES = 98304
MAX_LOGICAL_BYTES = 12 * 1024**3
MAX_FILE_BYTES = 256 * 1024**2
MAX_BATCH_BYTES = 64 * 1024**2
DOMAINS = frozenset(
"postgres artifacts imports app-config judge-config factory operations releases "
"images dependencies proxy recovery".split()
)
Count = Annotated[StrictInt, Field(ge=0, le=MAX_FILES)]
ByteCount = Annotated[StrictInt, Field(ge=0, le=MAX_LOGICAL_BYTES)]
class Record(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
class RestoreFile(Record):
path: Annotated[str, Field(min_length=1, max_length=512)]
uid: Annotated[StrictInt, Field(ge=0, le=1205)]
gid: Annotated[StrictInt, Field(ge=0, le=1205)]
mode: StrictInt
bytes: Annotated[StrictInt, Field(ge=0, le=MAX_FILE_BYTES)]
sha256: Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")]
@model_validator(mode="after")
def check_member(self) -> "RestoreFile":
parts = self.path.split("/")
if (
len(parts) < 2
or parts[0] not in DOMAINS
or any(
p in {"", ".", "..", ".git", ".ssh", ".localplan", "docs"}
or re.fullmatch(r"[A-Za-z0-9_.+@-]{1,255}", p) is None
for p in parts
)
):
raise ValueError("restore file must be below a fixed backup domain")
if self.uid not in {0, 1200, 1201, 1202, 1203, 1204, 1205} or self.gid not in (
{0, 1203, 1204, 1205} if self.uid == 0 else {self.uid}
):
raise ValueError("unsupported restore identity")
if self.mode not in {0o400, 0o444, 0o600, 0o640, 0o644, 0o555, 0o755}:
raise ValueError("unsupported restore file mode")
if self.sha256 in {"a" * 64, "0" * 64}:
raise ValueError("placeholder digest refused")
return self
class Open(Record):
schema_version: Annotated[Literal[1], BeforeValidator(integer_version)]
kind: Literal["restore.io.open"]
backup_id: Annotated[str, Field(pattern=r"^bk-[0-9a-f]{32}$")]
recovery_id: Annotated[str, Field(pattern=r"^rr-[0-9a-f]{32}$")]
expected_files: Annotated[StrictInt, Field(ge=1, le=MAX_FILES)]
expected_logical_bytes: ByteCount
deadline_monotonic_ns: Annotated[StrictInt, Field(ge=1, le=2**63 - 1)]
class Batch(Record):
schema_version: Annotated[Literal[1], BeforeValidator(integer_version)]
kind: Literal["restore.io.batch"]
batch_id: Annotated[StrictInt, Field(ge=1, le=MAX_FILES)]
files: Annotated[list[RestoreFile], Field(min_length=1, max_length=256)]
@model_validator(mode="after")
def check_batch(self) -> "Batch":
if len(self.files) > 1 and sum(f.bytes for f in self.files) > MAX_BATCH_BYTES:
raise ValueError("restore batch byte bound exceeded")
if len({f.path for f in self.files}) != len(self.files):
raise ValueError("duplicate restore batch member")
return self
class Finish(Record):
schema_version: Annotated[Literal[1], BeforeValidator(integer_version)]
kind: Literal["restore.io.finish"]
class RestoreIORequest(RootModel[Annotated[Open | Batch | Finish, Field(discriminator="kind")]]):
pass
class RestoreIOReply(Record):
schema_version: Annotated[Literal[1], BeforeValidator(integer_version)]
kind: Literal["restore.io.reply"]
stage: Literal["opened", "verified", "finished", "refused"]
batch_id: Count
verified_files: Count
verified_logical_bytes: ByteCount
execution_verified: Annotated[
Literal[False],
BeforeValidator(lambda value: value if type(value) is bool else _bad_boolean()),
]
error: Literal["none", "protocol", "guard", "deadline", "filesystem", "bytes", "identity"]
@model_validator(mode="after")
def check_reply(self) -> "RestoreIOReply":
if (self.stage == "refused") != (self.error != "none"):
raise ValueError("restore refusal state differs")
if self.stage == "opened" and (
self.batch_id or self.verified_files or self.verified_logical_bytes
):
raise ValueError("restore open cannot claim verified data")
if self.stage in {"verified", "finished"} and not (
1 <= self.batch_id <= self.verified_files
):
raise ValueError("restore completion has no verified batch")
return self
def _bad_boolean() -> bool:
raise ValueError("execution verification must be a boolean false")
+32
View File
@@ -1437,3 +1437,35 @@ def test_proxy_previous_snapshot_retains_the_legacy_unit_and_binding_format(tmp_
unit.chmod(0o644)
assert proxy_install.known_snapshot(directory) == selected
assert "--previous-certificate" not in unit.read_text()
@pytest.mark.parametrize("support", ["legacy", "complete", "binary-only", "transport-only"])
def test_native_restore_capability_requires_a_complete_pair_but_keeps_legacy(
ordinary_bundle, support
):
bundle, _ = ordinary_bundle
data = json.loads((bundle / "manifest.json").read_bytes())
names = {
"legacy": [],
"complete": ["bin/kf-restore-io", "delivery/scripts/release/restore_native.py"],
"binary-only": ["bin/kf-restore-io"],
"transport-only": ["delivery/scripts/release/restore_native.py"],
}[support]
parents = set(data["directories"])
for name in names:
data["files"].append(
{
"path": name,
"bytes": 1,
"sha256": "b" * 64,
"mode": "0555" if name.startswith("bin/") else "0444",
}
)
data["payload_bytes"] += 1
parents.update(str(p) for p in Path(name).parents if str(p) != ".")
data["directories"] = sorted(parents)
if support in {"legacy", "complete"}:
assert manifest.validate(data) is data
else:
with pytest.raises(policy.DeliveryError, match="incomplete application bundle"):
manifest.validate(data)
@@ -0,0 +1,23 @@
"""Shared ordinary wire examples; no privileged copy or execution acceptance."""
import json
from pathlib import Path
import pytest
from pydantic import ValidationError
from kf_oj.contracts.restore_io import RestoreIOReply, RestoreIORequest
CASES = json.loads(
(Path(__file__).resolve().parents[2] / "tests/fixtures/restore-io/examples.json").read_bytes()
)
@pytest.mark.parametrize("case", CASES, ids=lambda c: c["name"])
def test_shared_restore_io_examples(case):
model = RestoreIORequest if case["direction"] == "request" else RestoreIOReply
if case["valid"]:
assert model.model_validate(case["value"]).model_dump(mode="json") == case["value"]
else:
with pytest.raises(ValidationError):
model.model_validate(case["value"])
@@ -0,0 +1,115 @@
"""Ordinary pipe fixtures only; no Root, filesystem-copy or VM acceptance."""
import json
import subprocess
import sys
import time
import pytest
from scripts.release import restore_native
from scripts.release.policy import DeliveryError
@pytest.fixture
def transport(tmp_path, monkeypatch):
helper = tmp_path / "bin/kf-restore-io"
helper.parent.mkdir()
helper.write_bytes(b"ordinary metadata fixture, never executed\n")
helper.chmod(0o555)
monkeypatch.setattr(restore_native.preflight, "protected", lambda *_a, **_kw: None)
original = subprocess.Popen
children = []
def start(program):
def popen(args, **options):
assert args == [str(helper), "--stdio-v1"]
assert options["start_new_session"] and options["close_fds"]
assert set(options["env"]) == {"PATH", "LC_ALL", "LD_LIBRARY_PATH"}
child = original([sys.executable, "-c", program], **options)
children.append(child)
return child
monkeypatch.setattr(restore_native.subprocess, "Popen", popen)
yield tmp_path, start, children
assert all(p.poll() is not None for p in children)
assert all(h.closed for p in children for h in (p.stdin, p.stdout, p.stderr))
def test_transport_counts_and_child_join_without_execution_claim(transport):
payload, start, children = transport
start("""
import json,sys
count=total=batch=0
for line in sys.stdin:
value=json.loads(line)
kind=value['kind']
stage='opened'
if kind=='restore.io.batch':
count+=len(value['files'])
total+=sum(r['bytes'] for r in value['files'])
batch=value['batch_id']
stage='verified'
if kind=='restore.io.finish':stage='finished'
reply={'schema_version':1,'kind':'restore.io.reply','stage':stage,'batch_id':batch,'verified_files':count,'verified_logical_bytes':total,'execution_verified':False,'error':'none'}
print(json.dumps(reply),flush=True)
if stage=='finished':break
""")
session = restore_native.Session(
payload, "bk-" + "1" * 32, "rr-" + "2" * 32, 2, 7, time.monotonic() + 10
)
try:
session.materialize(
[
{"path": "artifacts/a", "bytes": 3, "sha256": "1" * 64},
{"path": "artifacts/b", "bytes": 4, "sha256": "2" * 64},
]
)
session.finish()
assert session.finished and children[0].returncode == 0
finally:
session.close()
@pytest.mark.parametrize(
"response",
[
b"",
b"not-json\n",
b"{}\n{}\n",
b"x" * (64 * 1024 + 1),
json.dumps(
{
"schema_version": 1,
"kind": "restore.io.reply",
"stage": "opened",
"batch_id": 0,
"verified_files": 0,
"verified_logical_bytes": 0,
"execution_verified": True,
"error": "none",
}
).encode()
+ b"\n",
],
)
def test_transport_refuses_eof_malformed_unbounded_or_authority_reply(transport, response):
payload, start, _ = transport
start(
"import sys;sys.stdin.readline();sys.stdout.buffer.write("
+ repr(response)
+ ");sys.stdout.flush()"
)
with pytest.raises(DeliveryError):
restore_native.Session(
payload, "bk-" + "1" * 32, "rr-" + "2" * 32, 1, 0, time.monotonic() + 10
)
def test_transport_deadline_reaps_its_owned_waiting_child(transport):
payload, start, _ = transport
start("import sys,time;sys.stdin.readline();time.sleep(10)")
then = time.monotonic()
with pytest.raises(DeliveryError):
restore_native.Session(payload, "bk-" + "1" * 32, "rr-" + "2" * 32, 1, 0, then + 0.2)
assert time.monotonic() - then < 5
+11
View File
@@ -38,3 +38,14 @@ Coordinator 必须在事务锁内核对当前租约及状态规则,并将结
Python/Rust 的 `manifest_bytes` 输出同一 UTF-8 JSON:递归排序对象键,无额外空白/换行。
清单摘要是这些确切字节的 SHA-256,不能使用数据库 JSONB 的输出顺序或任意 prettify 后的字节。
共享 `canonical-manifest.v1.txt` 为合成示例的字节验收,平台内部响应复用这些序列化字节。
## 离线恢复字节复制协议
`restore-io-request/reply` v1 用于 Python 恢复操作器与 Sandbox 的 `kf-restore-io`。
共享 37 项合法/非法元数据样例在 `tests/fixtures/restore-io/examples.json`。
请求仅含固定域内规范文件名、既有 OS 身份/模式、字节数和摘要;不接受源/目标宿主路径、
命令或服务操作。每行请求最多 256 KiB、回复 64 KiB;重复/未知字段、严格整数违规及
总量/批次/状态不一致均拒绝。单批至多 256 文件、64 MiB,较大单文件独占批次且不超过
256 MiB;整个已验证清单至多 98304 文件、12 GiB。接收端仍独立核对固定 Root 单元、
真实限额、只读备份卷与停止的写入者。`execution_verified` 始终为 false;复制回复
不能证明业务恢复、正常就绪、判题或恢复时间目标。Schema 描述结构,跨字段规则需模型校验。
@@ -0,0 +1,75 @@
{
"$id": "urn:kf-oj:restore-io-reply:v1",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"batch_id": {
"maximum": 98304,
"minimum": 0,
"title": "Batch Id",
"type": "integer"
},
"error": {
"enum": [
"none",
"protocol",
"guard",
"deadline",
"filesystem",
"bytes",
"identity"
],
"title": "Error",
"type": "string"
},
"execution_verified": {
"const": false,
"title": "Execution Verified",
"type": "boolean"
},
"kind": {
"const": "restore.io.reply",
"title": "Kind",
"type": "string"
},
"schema_version": {
"const": 1,
"title": "Schema Version",
"type": "integer"
},
"stage": {
"enum": [
"opened",
"verified",
"finished",
"refused"
],
"title": "Stage",
"type": "string"
},
"verified_files": {
"maximum": 98304,
"minimum": 0,
"title": "Verified Files",
"type": "integer"
},
"verified_logical_bytes": {
"maximum": 12884901888,
"minimum": 0,
"title": "Verified Logical Bytes",
"type": "integer"
}
},
"required": [
"schema_version",
"kind",
"stage",
"batch_id",
"verified_files",
"verified_logical_bytes",
"execution_verified",
"error"
],
"title": "RestoreIOReply",
"type": "object"
}
@@ -0,0 +1,187 @@
{
"$defs": {
"Batch": {
"additionalProperties": false,
"properties": {
"batch_id": {
"maximum": 98304,
"minimum": 1,
"title": "Batch Id",
"type": "integer"
},
"files": {
"items": {
"$ref": "#/$defs/RestoreFile"
},
"maxItems": 256,
"minItems": 1,
"title": "Files",
"type": "array"
},
"kind": {
"const": "restore.io.batch",
"title": "Kind",
"type": "string"
},
"schema_version": {
"const": 1,
"title": "Schema Version",
"type": "integer"
}
},
"required": [
"schema_version",
"kind",
"batch_id",
"files"
],
"title": "Batch",
"type": "object"
},
"Finish": {
"additionalProperties": false,
"properties": {
"kind": {
"const": "restore.io.finish",
"title": "Kind",
"type": "string"
},
"schema_version": {
"const": 1,
"title": "Schema Version",
"type": "integer"
}
},
"required": [
"schema_version",
"kind"
],
"title": "Finish",
"type": "object"
},
"Open": {
"additionalProperties": false,
"properties": {
"backup_id": {
"pattern": "^bk-[0-9a-f]{32}$",
"title": "Backup Id",
"type": "string"
},
"deadline_monotonic_ns": {
"maximum": 9223372036854775807,
"minimum": 1,
"title": "Deadline Monotonic Ns",
"type": "integer"
},
"expected_files": {
"maximum": 98304,
"minimum": 1,
"title": "Expected Files",
"type": "integer"
},
"expected_logical_bytes": {
"maximum": 12884901888,
"minimum": 0,
"title": "Expected Logical Bytes",
"type": "integer"
},
"kind": {
"const": "restore.io.open",
"title": "Kind",
"type": "string"
},
"recovery_id": {
"pattern": "^rr-[0-9a-f]{32}$",
"title": "Recovery Id",
"type": "string"
},
"schema_version": {
"const": 1,
"title": "Schema Version",
"type": "integer"
}
},
"required": [
"schema_version",
"kind",
"backup_id",
"recovery_id",
"expected_files",
"expected_logical_bytes",
"deadline_monotonic_ns"
],
"title": "Open",
"type": "object"
},
"RestoreFile": {
"additionalProperties": false,
"properties": {
"bytes": {
"maximum": 268435456,
"minimum": 0,
"title": "Bytes",
"type": "integer"
},
"gid": {
"maximum": 1205,
"minimum": 0,
"title": "Gid",
"type": "integer"
},
"mode": {
"title": "Mode",
"type": "integer"
},
"path": {
"maxLength": 512,
"minLength": 1,
"title": "Path",
"type": "string"
},
"sha256": {
"pattern": "^[0-9a-f]{64}$",
"title": "Sha256",
"type": "string"
},
"uid": {
"maximum": 1205,
"minimum": 0,
"title": "Uid",
"type": "integer"
}
},
"required": [
"path",
"uid",
"gid",
"mode",
"bytes",
"sha256"
],
"title": "RestoreFile",
"type": "object"
}
},
"$id": "urn:kf-oj:restore-io-request:v1",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"discriminator": {
"mapping": {
"restore.io.batch": "#/$defs/Batch",
"restore.io.finish": "#/$defs/Finish",
"restore.io.open": "#/$defs/Open"
},
"propertyName": "kind"
},
"oneOf": [
{
"$ref": "#/$defs/Open"
},
{
"$ref": "#/$defs/Batch"
},
{
"$ref": "#/$defs/Finish"
}
],
"title": "RestoreIORequest"
}
+1
View File
@@ -6,6 +6,7 @@ use uuid::Uuid;
pub mod judging;
pub mod judging_v2;
pub mod judging_versions;
pub mod restore_io;
pub mod state;
pub const SCHEMA_VERSION: u16 = 1;
+240
View File
@@ -0,0 +1,240 @@
//! Private offline restore protocol. No OS operations or execution authority.
use crate::SchemaVersion;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
pub const MAX_FILES: usize = 98304;
pub const MAX_LOGICAL_BYTES: u64 = 12 * 1024 * 1024 * 1024;
pub const MAX_FILE_BYTES: u64 = 256 * 1024 * 1024;
pub const MAX_BATCH_BYTES: u64 = 64 * 1024 * 1024;
pub const MAX_REQUEST_BYTES: usize = 256 * 1024;
pub const MAX_REPLY_BYTES: usize = 64 * 1024;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RestoreFile {
pub path: String,
pub uid: u32,
pub gid: u32,
pub mode: u32,
pub bytes: u64,
pub sha256: String,
}
impl RestoreFile {
pub fn validate(&self) -> Result<(), &'static str> {
let mut parts = self.path.split('/');
if self.path.len() > 512
|| !matches!(
parts.next(),
Some(
"postgres"
| "artifacts"
| "imports"
| "app-config"
| "judge-config"
| "factory"
| "operations"
| "releases"
| "images"
| "dependencies"
| "proxy"
| "recovery"
)
)
{
return Err("restore file domain differs");
}
let mut count = 0;
for part in parts {
count += 1;
if part.is_empty()
|| part.len() > 255
|| matches!(part, "." | ".." | ".git" | ".ssh" | ".localplan" | "docs")
|| !part
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_.+@-".contains(&b))
{
return Err("restore path component differs");
}
}
if count == 0
|| !matches!(self.uid, 0 | 1200..=1205)
|| !(if self.uid == 0 {
matches!(self.gid, 0 | 1203..=1205)
} else {
self.gid == self.uid
})
|| !matches!(
self.mode,
0o400 | 0o444 | 0o600 | 0o640 | 0o644 | 0o555 | 0o755
)
|| self.bytes > MAX_FILE_BYTES
|| self.sha256.len() != 64
|| !self
.sha256
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|| self.sha256 == "0".repeat(64)
|| self.sha256 == "a".repeat(64)
{
return Err("restore member metadata differs");
}
Ok(())
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind", deny_unknown_fields)]
pub enum Request {
#[serde(rename = "restore.io.open")]
Open {
schema_version: SchemaVersion,
backup_id: String,
recovery_id: String,
expected_files: u32,
expected_logical_bytes: u64,
deadline_monotonic_ns: u64,
},
#[serde(rename = "restore.io.batch")]
Batch {
schema_version: SchemaVersion,
batch_id: u32,
files: Vec<RestoreFile>,
},
#[serde(rename = "restore.io.finish")]
Finish { schema_version: SchemaVersion },
}
pub fn identifier(value: &str, prefix: &str) -> bool {
value.len() == prefix.len() + 32
&& value.starts_with(prefix)
&& value[prefix.len()..]
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
impl Request {
pub fn validate(&self) -> Result<(), &'static str> {
match self {
Self::Open {
backup_id,
recovery_id,
expected_files,
expected_logical_bytes,
deadline_monotonic_ns,
..
} => {
if !identifier(backup_id, "bk-")
|| !identifier(recovery_id, "rr-")
|| !(1..=MAX_FILES as u32).contains(expected_files)
|| *expected_logical_bytes > MAX_LOGICAL_BYTES
|| !(1..=i64::MAX as u64).contains(deadline_monotonic_ns)
{
return Err("restore open bounds differ");
}
}
Self::Batch {
batch_id, files, ..
} => {
if !(1..=MAX_FILES as u32).contains(batch_id)
|| files.is_empty()
|| files.len() > 256
{
return Err("restore batch count differs");
}
let mut seen = HashSet::new();
for file in files {
file.validate()?;
if !seen.insert(&file.path) {
return Err("duplicate restore file");
}
}
if files.len() > 1 && files.iter().map(|f| f.bytes).sum::<u64>() > MAX_BATCH_BYTES {
return Err("restore batch byte bound exceeded");
}
}
Self::Finish { .. } => {}
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Stage {
Opened,
Verified,
Finished,
Refused,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Error {
None,
Protocol,
Guard,
Deadline,
Filesystem,
Bytes,
Identity,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Reply {
pub schema_version: SchemaVersion,
pub kind: ReplyKind,
pub stage: Stage,
pub batch_id: u32,
pub verified_files: u32,
pub verified_logical_bytes: u64,
pub execution_verified: bool,
pub error: Error,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum ReplyKind {
#[serde(rename = "restore.io.reply")]
RestoreIO,
}
impl Reply {
pub fn validate(&self) -> Result<(), &'static str> {
if self.execution_verified
|| self.batch_id > MAX_FILES as u32
|| self.verified_files > MAX_FILES as u32
|| self.verified_logical_bytes > MAX_LOGICAL_BYTES
|| (self.stage == Stage::Refused) != (self.error != Error::None)
|| (self.stage == Stage::Opened
&& (self.batch_id != 0
|| self.verified_files != 0
|| self.verified_logical_bytes != 0))
|| (matches!(self.stage, Stage::Verified | Stage::Finished)
&& !(1..=self.verified_files).contains(&self.batch_id))
{
return Err("restore reply state differs");
}
Ok(())
}
}
pub fn decode_request(raw: &[u8]) -> Result<Request, &'static str> {
if raw.len() > MAX_REQUEST_BYTES {
return Err("restore request too large");
}
let value: Request = serde_json::from_slice(raw).map_err(|_| "invalid restore request")?;
value.validate()?;
Ok(value)
}
pub fn decode_reply(raw: &[u8]) -> Result<Reply, &'static str> {
if raw.len() > MAX_REPLY_BYTES {
return Err("restore reply too large");
}
let value: Reply = serde_json::from_slice(raw).map_err(|_| "invalid restore reply")?;
value.validate()?;
Ok(value)
}
@@ -0,0 +1,40 @@
use kf_contracts::restore_io::{decode_reply, decode_request};
use serde::Deserialize;
#[derive(Deserialize)]
struct Case {
name: String,
direction: String,
valid: bool,
value: serde_json::Value,
}
#[test]
fn shared_restore_io_examples() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../tests/fixtures/restore-io/examples.json");
let cases: Vec<Case> = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
assert_eq!(cases.len(), 37);
for case in cases {
let raw = serde_json::to_vec(&case.value).unwrap();
let result = match case.direction.as_str() {
"request" => decode_request(&raw).map(|v| serde_json::to_value(v).unwrap()),
"reply" => decode_reply(&raw).map(|v| serde_json::to_value(v).unwrap()),
_ => panic!("unknown fixture direction"),
};
assert_eq!(result.is_ok(), case.valid, "{}", case.name);
if let Ok(actual) = result {
assert_eq!(actual, case.value, "{}", case.name);
}
}
}
#[test]
fn duplicate_fields_and_oversized_wire_are_refused() {
assert!(
decode_request(br#"{"kind":"restore.io.finish","schema_version":1,"schema_version":1}"#)
.is_err()
);
assert!(decode_request(&vec![b' '; 256 * 1024 + 1]).is_err());
assert!(decode_reply(&vec![b' '; 64 * 1024 + 1]).is_err());
}
+5 -1
View File
@@ -16,7 +16,7 @@ clap.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2 = "=0.10.9"
rustix = { version = "1.1.5", features = ["net", "process", "fs", "thread", "mount", "system", "stdio", "pipe", "event"] }
rustix = { version = "1.1.5", features = ["net", "process", "fs", "thread", "mount", "system", "stdio", "pipe", "event", "time"] }
# Fixed BPF compilation/installation through safe APIs; libc supplies syscall
# numbers and the audited fork/exec primitive. Both dependencies stay inside
@@ -30,3 +30,7 @@ workspace = true
[[example]]
name = "identity_probe"
test = false
[[bin]]
name = "kf-restore-io"
path = "src/restore_copy/main.rs"
@@ -0,0 +1,502 @@
//! Anchored regular-file copies, filesystem error cursors and complete byte readback.
use super::{Error, Result, remaining};
use kf_contracts::restore_io::RestoreFile;
use rustix::{
fs::{AtFlags, Mode, OFlags, ResolveFlags},
process::{Gid, Uid},
};
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
fs::{File, Metadata},
io::{Read, Seek, Write},
os::unix::fs::MetadataExt,
path::{Path, PathBuf},
};
const CHUNK: usize = 1024 * 1024;
const RESOLVE: ResolveFlags = ResolveFlags::BENEATH
.union(ResolveFlags::NO_SYMLINKS)
.union(ResolveFlags::NO_MAGICLINKS);
pub type Stamp = [u64; 9];
fn stamp(info: &Metadata) -> Result<Stamp> {
let ns = |s: i64, n: i64| {
u64::try_from(i128::from(s) * 1_000_000_000 + i128::from(n)).map_err(|_| Error::Identity)
};
Ok([
info.dev(),
info.ino(),
info.mode().into(),
info.nlink(),
info.uid().into(),
info.gid().into(),
info.size(),
ns(info.mtime(), info.mtime_nsec())?,
ns(info.ctime(), info.ctime_nsec())?,
])
}
struct Anchor {
path: PathBuf,
file: File,
device: u64,
inode: u64,
}
impl Anchor {
fn open(path: PathBuf) -> Result<Self> {
let file = File::from(
rustix::fs::openat2(
rustix::fs::CWD,
&path,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
Mode::empty(),
ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
)
.map_err(|_| Error::Filesystem)?,
);
let info = file.metadata().map_err(|_| Error::Filesystem)?;
Ok(Self {
path,
file,
device: info.dev(),
inode: info.ino(),
})
}
fn unchanged(&self) -> Result<()> {
let fresh = Self::open(self.path.clone())?;
if (self.device, self.inode) != (fresh.device, fresh.inode) {
return Err(Error::Identity);
}
Ok(())
}
fn file(&self, relative: &str, flags: OFlags) -> Result<File> {
rustix::fs::openat2(
&self.file,
relative,
flags | OFlags::CLOEXEC | OFlags::NOFOLLOW,
if flags.contains(OFlags::CREATE) {
Mode::from_raw_mode(0o600)
} else {
Mode::empty()
},
RESOLVE,
)
.map(File::from)
.map_err(|_| Error::Filesystem)
}
fn observed(&self, relative: &str) -> Result<Stamp> {
// Parents were created/checked by the locked Root operator on the
// stopped node. The actual byte opens additionally use openat2 with
// BENEATH/NO_SYMLINKS. Final metadata checks include the leaf's type.
let s = rustix::fs::statat(&self.file, relative, AtFlags::SYMLINK_NOFOLLOW)
.map_err(|_| Error::Filesystem)?;
let ns = |sec: i64, nano: i64| {
u64::try_from(i128::from(sec) * 1_000_000_000 + i128::from(nano))
.map_err(|_| Error::Identity)
};
Ok([
s.st_dev,
s.st_ino,
s.st_mode.into(),
s.st_nlink,
s.st_uid.into(),
s.st_gid.into(),
u64::try_from(s.st_size).map_err(|_| Error::Identity)?,
ns(s.st_mtime, s.st_mtime_nsec as i64)?,
ns(s.st_ctime, s.st_ctime_nsec as i64)?,
])
}
}
pub struct Roots {
source: Anchor,
targets: HashMap<&'static str, Anchor>,
source_owner: (u32, u32),
#[cfg(test)]
ordinary_fixture: bool,
}
impl Roots {
pub fn production(backup_id: &str, recovery_id: &str) -> Result<Self> {
let source = Path::new("/var/lib/kf-onlinejudge/backups")
.join(backup_id)
.join("objects");
let targets = [
(
"postgres",
PathBuf::from("/var/lib/kf-onlinejudge/postgres/data"),
),
(
"artifacts",
PathBuf::from("/var/lib/kf-onlinejudge/artifacts"),
),
("imports", PathBuf::from("/var/lib/kf-onlinejudge/imports")),
("factory", PathBuf::from("/var/lib/kf-judge/factory")),
("releases", PathBuf::from("/opt/kf-onlinejudge/releases")),
("images", PathBuf::from("/var/lib/kf-judge/images")),
(
"history",
Path::new("/var/lib/kf-onlinejudge/recovery")
.join(recovery_id)
.join("history"),
),
];
let snapshot = Anchor::open(source.parent().ok_or(Error::Guard)?.to_owned())?;
let roots = Self::open(source, targets)?;
for anchor in [&snapshot, &roots.source] {
let info = anchor.file.metadata().map_err(|_| Error::Guard)?;
if (info.uid(), info.gid(), info.mode() & 0o7777) != (0, 0, 0o700) {
return Err(Error::Guard);
}
}
Ok(roots)
}
fn open<const N: usize>(
source: PathBuf,
targets: [(&'static str, PathBuf); N],
) -> Result<Self> {
Ok(Self {
source: Anchor::open(source)?,
targets: targets
.into_iter()
.map(|(key, path)| Ok((key, Anchor::open(path)?)))
.collect::<Result<_>>()?,
source_owner: (0, 0),
#[cfg(test)]
ordinary_fixture: false,
})
}
fn target<'a>(&'a self, row: &'a RestoreFile) -> Result<(&'a Anchor, &'a str, u32, u32, u32)> {
let (domain, suffix) = row.path.split_once('/').ok_or(Error::Protocol)?;
match self.targets.get(domain) {
Some(anchor) => Ok((anchor, suffix, row.uid, row.gid, row.mode)),
None => Ok((
self.targets.get("history").ok_or(Error::Protocol)?,
&row.path,
0,
0,
0o600,
)),
}
}
pub fn sync(&self, deadline: u64) -> Result<()> {
let mut seen = Vec::new();
for anchor in self.targets.values() {
remaining(deadline)?;
anchor.unchanged()?;
if !seen.contains(&anchor.device) {
#[cfg(not(test))]
rustix::fs::syncfs(&anchor.file).map_err(|_| Error::Filesystem)?;
// Ordinary tests may fsync only their own tiny files/directories,
// never syncfs the host. Real error-cursor validation is VM-only.
#[cfg(test)]
{
assert!(self.ordinary_fixture);
for entry in std::fs::read_dir(&anchor.path).map_err(|_| Error::Filesystem)? {
let entry = entry.map_err(|_| Error::Filesystem)?;
File::open(entry.path())
.and_then(|f| f.sync_all())
.map_err(|_| Error::Filesystem)?;
}
anchor.file.sync_all().map_err(|_| Error::Filesystem)?;
}
seen.push(anchor.device);
}
remaining(deadline)?;
}
Ok(())
}
pub fn unchanged(&self) -> Result<()> {
self.source.unchanged()?;
for anchor in self.targets.values() {
anchor.unchanged()?;
}
Ok(())
}
pub fn copy(&self, row: RestoreFile, deadline: u64) -> Result<Pending> {
remaining(deadline)?;
let (anchor, name, uid, gid, mode) = self.target(&row)?;
match rustix::fs::openat2(
&anchor.file,
name,
OFlags::PATH | OFlags::CLOEXEC | OFlags::NOFOLLOW,
Mode::empty(),
RESOLVE,
) {
Ok(fd) => {
let info = File::from(fd).metadata().map_err(|_| Error::Filesystem)?;
member(&info, &row, uid, gid, mode)?;
return Ok(Pending {
witness: stamp(&info)?,
row,
});
}
Err(rustix::io::Errno::NOENT) => {}
Err(_) => return Err(Error::Filesystem),
}
let mut input = self
.source
.file(&row.sha256, OFlags::RDONLY | OFlags::NONBLOCK)?;
let before = input.metadata().map_err(|_| Error::Filesystem)?;
member(
&before,
&row,
self.source_owner.0,
self.source_owner.1,
0o600,
)?;
let original = stamp(&before)?;
if self.source.observed(&row.sha256)? != original {
return Err(Error::Identity);
}
let mut output = anchor.file(name, OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL)?;
let empty = output.metadata().map_err(|_| Error::Filesystem)?;
if !empty.is_file()
|| empty.nlink() != 1
|| empty.size() != 0
|| input.stream_position().map_err(|_| Error::Filesystem)? != 0
|| output.stream_position().map_err(|_| Error::Filesystem)? != 0
{
return Err(Error::Identity);
}
transfer(&mut input, &mut output, row.bytes, deadline)?;
if stamp(&input.metadata().map_err(|_| Error::Filesystem)?)? != original
|| self.source.observed(&row.sha256)? != original
{
return Err(Error::Identity);
}
let current = output.metadata().map_err(|_| Error::Filesystem)?;
let changed = (current.uid(), current.gid()) != (uid, gid);
if changed {
rustix::fs::fchown(&output, Some(Uid::from_raw(uid)), Some(Gid::from_raw(gid)))
.map_err(|_| Error::Filesystem)?;
}
let mode_changed = changed || current.mode() & 0o7777 != mode;
if mode_changed {
rustix::fs::fchmod(&output, Mode::from_raw_mode(mode))
.map_err(|_| Error::Filesystem)?;
}
let final_info = if mode_changed {
output.metadata().map_err(|_| Error::Filesystem)?
} else {
current
};
member(&final_info, &row, uid, gid, mode)?;
let witness = stamp(&final_info)?;
drop(output);
if anchor.observed(name)? != witness {
return Err(Error::Identity);
}
Ok(Pending { row, witness })
}
pub fn read_group(&self, group: Vec<Pending>, deadline: u64) -> Result<Vec<Pending>> {
let mut previous = None;
let mut reference = None;
let mut buffer = vec![0; CHUNK];
let mut counts = HashMap::new();
for pending in &group {
*counts
.entry((&pending.row.sha256, pending.row.bytes))
.or_insert(0) += 1;
}
// Keep one immutable, actually hashed <=8MiB reference per reader. Every
// other destination is still read completely and compared byte for byte.
let repeated: Vec<_> = group
.iter()
.map(|p| counts[&(&p.row.sha256, p.row.bytes)] > 1)
.collect();
drop(counts);
for (pending, repeated) in group.iter().zip(repeated) {
remaining(deadline)?;
let row = &pending.row;
let key = (&row.sha256, row.bytes);
if previous != Some(key) {
reference = None;
previous = Some(key);
}
let small = repeated && row.bytes <= 8 * 1024 * 1024;
let retain = small && reference.is_none();
let (anchor, name, uid, gid, mode) = self.target(row)?;
if anchor.observed(name)? != pending.witness {
return Err(Error::Identity);
}
let mut input = anchor.file(name, OFlags::RDONLY | OFlags::NONBLOCK)?;
let info = input.metadata().map_err(|_| Error::Filesystem)?;
member(&info, row, uid, gid, mode)?;
if stamp(&info)? != pending.witness {
return Err(Error::Identity);
}
let retained = verify(
&mut input,
row,
deadline,
if small { reference.as_deref() } else { None },
retain,
&mut buffer,
)?;
if stamp(&input.metadata().map_err(|_| Error::Filesystem)?)? != pending.witness
|| anchor.observed(name)? != pending.witness
{
return Err(Error::Identity);
}
if let Some(data) = retained {
reference = Some(data);
}
}
remaining(deadline)?;
Ok(group)
}
pub fn final_file(&self, pending: &Pending) -> Result<()> {
let (anchor, name, _, _, _) = self.target(&pending.row)?;
if anchor.observed(name)? != pending.witness {
return Err(Error::Identity);
}
Ok(())
}
}
pub struct Pending {
pub row: RestoreFile,
pub witness: Stamp,
}
fn member(info: &Metadata, row: &RestoreFile, uid: u32, gid: u32, mode: u32) -> Result<()> {
if !info.is_file()
|| info.nlink() != 1
|| info.size() != row.bytes
|| (info.uid(), info.gid(), info.mode() & 0o7777) != (uid, gid, mode)
{
return Err(Error::Identity);
}
Ok(())
}
fn transfer(input: &mut File, output: &mut File, bytes: u64, deadline: u64) -> Result<()> {
let mut total = 0;
let mut kernel = true;
let mut buffer = Vec::new();
while total < bytes {
remaining(deadline)?;
let maximum = (bytes - total).min(CHUNK as u64) as usize;
let count = if kernel {
match rustix::fs::sendfile(&*output, &*input, None, maximum) {
Ok(n) => n,
Err(rustix::io::Errno::INTR) => continue,
Err(
rustix::io::Errno::NOSYS
| rustix::io::Errno::INVAL
| rustix::io::Errno::OPNOTSUPP
| rustix::io::Errno::XDEV,
) => {
kernel = false;
continue;
}
Err(_) => return Err(Error::Filesystem),
}
} else {
if buffer.is_empty() {
buffer.resize(CHUNK, 0);
}
let count = input
.read(&mut buffer[..maximum])
.map_err(|_| Error::Filesystem)?;
output
.write_all(&buffer[..count])
.map_err(|_| Error::Filesystem)?;
count
};
if count == 0 || count > maximum {
return Err(Error::Bytes);
}
total += count as u64;
}
remaining(deadline)?;
if input.read(&mut [0; 1]).map_err(|_| Error::Filesystem)? != 0 {
return Err(Error::Bytes);
}
Ok(())
}
fn verify(
input: &mut File,
row: &RestoreFile,
deadline: u64,
reference: Option<&[u8]>,
retain: bool,
buffer: &mut [u8],
) -> Result<Option<Vec<u8>>> {
if reference.is_some_and(|r| r.len() as u64 != row.bytes) {
return Err(Error::Bytes);
}
let mut hasher = reference.is_none().then(Sha256::new);
let mut retained = retain.then(|| Vec::with_capacity(row.bytes as usize));
let mut total = 0;
loop {
remaining(deadline)?;
let count = input.read(buffer).map_err(|_| Error::Filesystem)?;
if count == 0 {
break;
}
let end = total + count;
if end as u64 > row.bytes {
return Err(Error::Bytes);
}
if reference.is_some_and(|r| r[total..end] != buffer[..count]) {
return Err(Error::Bytes);
}
if let Some(hash) = &mut hasher {
hash.update(&buffer[..count]);
}
if let Some(data) = &mut retained {
data.extend_from_slice(&buffer[..count]);
}
total = end;
}
if total as u64 != row.bytes
|| hasher.is_some_and(|h| format!("{:x}", h.finalize()) != row.sha256)
{
return Err(Error::Bytes);
}
remaining(deadline)?;
Ok(retained)
}
#[cfg(test)]
pub struct Fixture(PathBuf);
#[cfg(test)]
impl Fixture {
pub fn new() -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"kf-restore-trusted-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir(&root).unwrap();
std::fs::create_dir(root.join("source")).unwrap();
std::fs::create_dir(root.join("target")).unwrap();
Self(root)
}
pub fn source(&self) -> PathBuf {
self.0.join("source")
}
pub fn target(&self) -> PathBuf {
self.0.join("target")
}
pub fn roots(&self) -> Roots {
let mut roots = Roots::open(self.source(), [("artifacts", self.target())])
.unwrap_or_else(|_| panic!("trusted test root"));
let info = std::fs::metadata(self.source()).unwrap();
roots.source_owner = (info.uid(), info.gid());
roots.ordinary_fixture = true;
roots
}
}
#[cfg(test)]
impl Drop for Fixture {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).unwrap();
}
}
@@ -0,0 +1,206 @@
//! Fixed production identity, stopped-writer and storage guards. No subprocesses.
use super::{Error, Result};
use rustix::process::{Resource, getrlimit};
use std::{collections::HashMap, fs, io::Read, os::unix::fs::MetadataExt, path::Path};
const CGROUP: &str = "/sys/fs/cgroup/kfonlinejudge.slice";
const BACKUPS: &str = "/var/lib/kf-onlinejudge/backups";
pub fn text(path: impl AsRef<Path>, bound: usize) -> Result<String> {
let mut data = Vec::new();
fs::File::open(path)
.map_err(|_| Error::Guard)?
.take(bound as u64 + 1)
.read_to_end(&mut data)
.map_err(|_| Error::Guard)?;
if data.len() > bound {
return Err(Error::Guard);
}
String::from_utf8(data).map_err(|_| Error::Guard)
}
pub fn stopped() -> Result<()> {
for name in [
"proxy",
"api",
"outbox",
"worker",
"problem-import",
"coordinator",
"sandbox-factory",
"postgres",
"rabbitmq",
] {
let path = Path::new(CGROUP).join(format!("kf-{name}.service/cgroup.procs"));
match fs::symlink_metadata(&path) {
Ok(info) if info.is_file() && !info.file_type().is_symlink() => {
if !text(path, 8192)?.trim().is_empty() {
return Err(Error::Guard);
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
_ => return Err(Error::Guard),
}
}
let mut count = 0;
for entry in fs::read_dir("/proc").map_err(|_| Error::Guard)? {
let entry = entry.map_err(|_| Error::Guard)?;
if !entry
.file_name()
.as_encoded_bytes()
.iter()
.all(u8::is_ascii_digit)
{
continue;
}
count += 1;
if count > 1024 {
return Err(Error::Guard);
}
let path = entry.path().join("status");
let raw = match text(&path, 16384) {
Ok(v) => v,
Err(_) if !path.exists() => continue,
Err(e) => return Err(e),
};
let uid = raw
.lines()
.find_map(|s| s.strip_prefix("Uid:"))
.ok_or(Error::Guard)?;
let values: Vec<_> = uid.split_whitespace().map(str::parse::<u32>).collect();
if values.len() != 4
|| values
.iter()
.any(|v| matches!(v, Ok(1200..=1205 | 100000) | Err(_)))
{
return Err(Error::Guard);
}
}
for name in ["tasks", "identities", "captures"] {
let path = Path::new("/var/lib/kf-judge").join(name);
match fs::read_dir(&path) {
Ok(mut entries) => {
if entries.next().is_some() {
return Err(Error::Guard);
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
_ => return Err(Error::Guard),
}
}
match fs::symlink_metadata("/var/lib/kf-judge/factory/control.sock") {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
_ => Err(Error::Guard),
}
}
pub fn require() -> Result<()> {
let raw = text("/proc/self/status", 16384)?;
let fields: HashMap<_, _> = raw.lines().filter_map(|s| s.split_once(':')).collect();
for key in ["Uid", "Gid"] {
if fields
.get(key)
.ok_or(Error::Guard)?
.split_whitespace()
.collect::<Vec<_>>()
!= ["0", "0", "0", "0"]
{
return Err(Error::Guard);
}
}
if fields
.get("Groups")
.ok_or(Error::Guard)?
.split_whitespace()
.any(|v| v != "0")
|| fields.get("NoNewPrivs").ok_or(Error::Guard)?.trim() != "1"
{
return Err(Error::Guard);
}
for key in ["CapInh", "CapPrm", "CapEff", "CapBnd", "CapAmb"] {
if u64::from_str_radix(fields.get(key).ok_or(Error::Guard)?.trim(), 16) != Ok(15) {
return Err(Error::Guard);
}
}
if text("/proc/self/cgroup", 4096)?.trim() != "0::/kfonlinejudge.slice/kf-node-restore.service"
{
return Err(Error::Guard);
}
for (group, limits) in [
(
format!("{CGROUP}/kf-node-restore.service"),
["536870912", "0", "32", "200000 100000"],
),
(
CGROUP.to_owned(),
["4026531840", "0", "384", "200000 100000"],
),
] {
for (key, expected) in ["memory.max", "memory.swap.max", "pids.max", "cpu.max"]
.into_iter()
.zip(limits)
{
if text(Path::new(&group).join(key), 128)?.trim() != expected {
return Err(Error::Guard);
}
}
}
for (resource, maximum) in [
(Resource::Core, 0),
(Resource::Fsize, 256 * 1024 * 1024),
(Resource::Nofile, 256),
(Resource::Stack, 16 * 1024 * 1024),
] {
let observed = getrlimit(resource);
if observed.current != Some(maximum) || observed.maximum != Some(maximum) {
return Err(Error::Guard);
}
}
let metadata = fs::symlink_metadata(BACKUPS).map_err(|_| Error::Guard)?;
if !metadata.is_dir()
|| metadata.uid() != 0
|| metadata.gid() != 0
|| metadata.mode() & 0o7777 != 0o700
{
return Err(Error::Guard);
}
let mounts = text("/proc/self/mountinfo", 256 * 1024)?;
let rows: Vec<Vec<&str>> = mounts
.lines()
.map(|r| r.split_whitespace().collect())
.collect();
let found: Vec<_> = rows.iter().filter(|r| r.get(4) == Some(&BACKUPS)).collect();
if found.len() != 1 {
return Err(Error::Guard);
}
let row = found[0];
let sep = row.iter().position(|v| *v == "-").ok_or(Error::Guard)?;
if sep < 6
|| row.len() <= sep + 3
|| row[3] != "/"
|| row[sep + 1] != "ext4"
|| row[6..sep]
.iter()
.any(|v| v.starts_with("shared:") || v.starts_with("master:"))
|| rows.iter().filter(|r| r.get(2) == Some(&row[2])).count() != 1
{
return Err(Error::Guard);
}
let options: Vec<_> = row[5].split(',').chain(row[sep + 3].split(',')).collect();
if ["ro", "nosuid", "nodev", "noexec"]
.iter()
.any(|v| !options.contains(v))
{
return Err(Error::Guard);
}
let info = rustix::fs::statvfs(BACKUPS).map_err(|_| Error::Guard)?;
let bytes = info
.f_frsize
.checked_mul(info.f_blocks)
.ok_or(Error::Guard)?;
if !(16 * 1024_u64.pow(3) * 9 / 10..=16 * 1024_u64.pow(3)).contains(&bytes) {
return Err(Error::Guard);
}
stopped()
}
@@ -0,0 +1,401 @@
//! Bounded offline restore data plane. Never starts a program or a service.
mod files;
mod guard;
use files::{Pending, Roots};
use kf_contracts::{
SchemaVersion,
restore_io::{self, Error, Reply, ReplyKind, Request, Stage},
};
use std::{
collections::HashSet,
io::{self, BufRead, Read, Write},
sync::{Arc, mpsc},
thread::{self, JoinHandle},
};
type Result<T> = std::result::Result<T, Error>;
fn now() -> Result<u64> {
let t = rustix::time::clock_gettime(rustix::time::ClockId::Monotonic);
u64::try_from(i128::from(t.tv_sec) * 1_000_000_000 + i128::from(t.tv_nsec))
.map_err(|_| Error::Deadline)
}
fn remaining(deadline: u64) -> Result<()> {
if now()? >= deadline {
return Err(Error::Deadline);
}
Ok(())
}
enum Job {
Copy(Vec<restore_io::RestoreFile>, u64),
Read(Vec<Pending>, u64),
Stop,
}
enum Completed {
Copied(Result<Vec<Pending>>),
Read(Result<Vec<Pending>>),
}
struct Worker {
sender: mpsc::Sender<Job>,
receiver: mpsc::Receiver<Completed>,
join: Option<JoinHandle<()>>,
}
struct Pool {
workers: Vec<Worker>,
}
impl Pool {
fn new(roots: Arc<Roots>) -> Result<Self> {
let mut pool = Self {
workers: Vec::new(),
};
for _ in 0..2 {
let (sender, jobs) = mpsc::channel();
let (completed, receiver) = mpsc::channel();
let roots = Arc::clone(&roots);
let join = thread::Builder::new()
.name("restore-io".into())
.stack_size(1024 * 1024)
.spawn(move || {
while let Ok(job) = jobs.recv() {
let result = match job {
Job::Copy(rows, deadline) => Completed::Copied(
rows.into_iter().map(|r| roots.copy(r, deadline)).collect(),
),
Job::Read(rows, deadline) => {
Completed::Read(roots.read_group(rows, deadline))
}
Job::Stop => break,
};
if completed.send(result).is_err() {
break;
}
}
})
.map_err(|_| Error::Filesystem)?;
pool.workers.push(Worker {
sender,
receiver,
join: Some(join),
});
}
Ok(pool)
}
fn materialize(
&self,
rows: Vec<restore_io::RestoreFile>,
roots: &Roots,
deadline: u64,
) -> Result<Vec<Pending>> {
let size = rows.len().div_ceil(2);
let mut groups = vec![Vec::new(), Vec::new()];
for (i, row) in rows.into_iter().enumerate() {
groups[i / size].push(row);
}
for (worker, group) in self.workers.iter().zip(groups) {
worker
.sender
.send(Job::Copy(group, deadline))
.map_err(|_| Error::Filesystem)?;
}
let mut pending = Vec::with_capacity(size * 2);
let mut error = None;
// Observe both completions even when the first failed. No sync/readback
// and no success response can outrun an outstanding copy worker.
for worker in &self.workers {
match worker.receiver.recv() {
Ok(Completed::Copied(Ok(rows))) => pending.extend(rows),
Ok(Completed::Copied(Err(e))) => {
error.get_or_insert(e);
}
_ => {
error.get_or_insert(Error::Filesystem);
}
}
}
if let Some(e) = error {
return Err(e);
}
roots.sync(deadline)?;
let mut groups = vec![Vec::new(), Vec::new()];
for (i, row) in pending.into_iter().enumerate() {
groups[i / size].push(row);
}
for (worker, group) in self.workers.iter().zip(groups) {
worker
.sender
.send(Job::Read(group, deadline))
.map_err(|_| Error::Filesystem)?;
}
let mut verified = Vec::with_capacity(size * 2);
for worker in &self.workers {
match worker.receiver.recv() {
Ok(Completed::Read(Ok(rows))) => verified.extend(rows),
Ok(Completed::Read(Err(e))) => {
error.get_or_insert(e);
}
_ => {
error.get_or_insert(Error::Filesystem);
}
}
}
if let Some(e) = error {
return Err(e);
}
remaining(deadline)?;
Ok(verified)
}
}
impl Drop for Pool {
fn drop(&mut self) {
for worker in &self.workers {
let _ = worker.sender.send(Job::Stop);
}
for worker in &mut self.workers {
if let Some(join) = worker.join.take() {
let _ = join.join();
}
}
}
}
struct Session {
// Pool drops/joins before the shared descriptors or verified inventory.
pool: Pool,
roots: Arc<Roots>,
deadline: u64,
expected_files: u32,
expected_bytes: u64,
batch_id: u32,
bytes: u64,
seen: HashSet<String>,
verified: Vec<Pending>,
}
impl Session {
fn open(request: Request) -> Result<Self> {
let Request::Open {
backup_id,
recovery_id,
expected_files,
expected_logical_bytes,
deadline_monotonic_ns,
..
} = request
else {
return Err(Error::Protocol);
};
remaining(deadline_monotonic_ns)?;
if deadline_monotonic_ns
.checked_sub(now()?)
.is_none_or(|n| n > 1700 * 1_000_000_000)
{
return Err(Error::Deadline);
}
let roots = Arc::new(Roots::production(&backup_id, &recovery_id)?);
let pool = Pool::new(Arc::clone(&roots))?;
Ok(Self {
pool,
roots,
deadline: deadline_monotonic_ns,
expected_files,
expected_bytes: expected_logical_bytes,
batch_id: 0,
bytes: 0,
seen: HashSet::with_capacity(expected_files as usize),
verified: Vec::with_capacity(expected_files as usize),
})
}
fn reply(&self, stage: Stage, error: Error) -> Reply {
Reply {
schema_version: SchemaVersion,
kind: ReplyKind::RestoreIO,
stage,
batch_id: self.batch_id,
verified_files: self.verified.len() as u32,
verified_logical_bytes: self.bytes,
execution_verified: false,
error,
}
}
fn batch(&mut self, batch_id: u32, rows: Vec<restore_io::RestoreFile>) -> Result<()> {
remaining(self.deadline)?;
let bytes: u64 = rows.iter().map(|r| r.bytes).sum();
if batch_id != self.batch_id + 1
|| self.verified.len() + rows.len() > self.expected_files as usize
|| self
.bytes
.checked_add(bytes)
.is_none_or(|n| n > self.expected_bytes)
|| rows.iter().any(|r| !self.seen.insert(r.path.clone()))
{
return Err(Error::Protocol);
}
let verified = self.pool.materialize(rows, &self.roots, self.deadline)?;
self.verified.extend(verified);
self.bytes += bytes;
self.batch_id = batch_id;
Ok(())
}
fn finish(&self) -> Result<()> {
remaining(self.deadline)?;
if self.verified.len() != self.expected_files as usize || self.bytes != self.expected_bytes
{
return Err(Error::Protocol);
}
guard::require()?;
self.roots.unchanged()?;
for pending in &self.verified {
remaining(self.deadline)?;
self.roots.final_file(pending)?;
}
self.roots.unchanged()?;
guard::stopped()?;
remaining(self.deadline)?;
Ok(())
}
}
fn request(input: &mut impl BufRead) -> Result<Request> {
let mut bytes = Vec::new();
input
.take(restore_io::MAX_REQUEST_BYTES as u64 + 1)
.read_until(b'\n', &mut bytes)
.map_err(|_| Error::Protocol)?;
if bytes.last() != Some(&b'\n') || bytes.len() > restore_io::MAX_REQUEST_BYTES {
return Err(Error::Protocol);
}
restore_io::decode_request(&bytes).map_err(|_| Error::Protocol)
}
fn respond(output: &mut impl Write, value: &Reply) -> Result<()> {
value.validate().map_err(|_| Error::Protocol)?;
let mut bytes = serde_json::to_vec(value).map_err(|_| Error::Protocol)?;
bytes.push(b'\n');
if bytes.len() > restore_io::MAX_REPLY_BYTES {
return Err(Error::Protocol);
}
output
.write_all(&bytes)
.and_then(|_| output.flush())
.map_err(|_| Error::Protocol)
}
fn run(input: &mut impl BufRead, output: &mut impl Write) -> Result<()> {
guard::require()?;
let mut session = Session::open(request(input)?)?;
respond(output, &session.reply(Stage::Opened, Error::None))?;
loop {
let action = request(input);
let result = match action {
Ok(Request::Batch {
batch_id, files, ..
}) => session.batch(batch_id, files),
Ok(Request::Finish { .. }) => {
let result = session.finish();
match result {
Ok(()) => {
// Join both workers before the finished byte-copy reply.
let reply = session.reply(Stage::Finished, Error::None);
drop(session);
respond(output, &reply)?;
return Ok(());
}
Err(e) => Err(e),
}
}
_ => Err(Error::Protocol),
};
match result {
Ok(()) => respond(output, &session.reply(Stage::Verified, Error::None))?,
Err(e) => {
let reply = session.reply(Stage::Refused, e);
drop(session);
respond(output, &reply)?;
return Err(e);
}
}
}
}
fn main() -> std::process::ExitCode {
if std::env::args_os().skip(1).collect::<Vec<_>>() != ["--stdio-v1"] {
return std::process::ExitCode::FAILURE;
}
let result = run(&mut io::stdin().lock(), &mut io::stdout().lock());
if result.is_ok() {
std::process::ExitCode::SUCCESS
} else {
std::process::ExitCode::FAILURE
}
}
#[cfg(test)]
mod tests {
use super::*;
use sha2::{Digest, Sha256};
use std::{fs, os::unix::fs::PermissionsExt};
// Trusted tiny ordinary fixtures exercise bytes/joining, without bypassing
// the production CLI guards or claiming Root/unit/isolation acceptance.
#[test]
fn joined_copy_sync_full_readback_and_final_witnesses() {
let fixture = files::Fixture::new();
let roots = Arc::new(fixture.roots());
let data = b"ordinary trusted restore fixture\n".repeat(128);
let hash = format!("{:x}", Sha256::digest(&data));
fs::write(fixture.source().join(&hash), &data).unwrap();
fs::set_permissions(
fixture.source().join(&hash),
fs::Permissions::from_mode(0o600),
)
.unwrap();
fs::write(fixture.target().join("existing"), &data).unwrap();
fs::set_permissions(
fixture.target().join("existing"),
fs::Permissions::from_mode(0o644),
)
.unwrap();
let owner = fs::metadata(fixture.source()).unwrap();
use std::os::unix::fs::MetadataExt;
let rows = ["existing", "one", "two", "three"]
.into_iter()
.map(|name| restore_io::RestoreFile {
path: format!("artifacts/{name}"),
uid: owner.uid(),
gid: owner.gid(),
mode: 0o644,
bytes: data.len() as u64,
sha256: hash.clone(),
})
.collect();
let deadline = now().unwrap() + 10_000_000_000;
let pool = Pool::new(Arc::clone(&roots)).unwrap_or_else(|_| panic!("worker pool"));
let verified = pool
.materialize(rows, &roots, deadline)
.unwrap_or_else(|_| panic!("verified copy"));
assert_eq!(verified.len(), 4);
for row in &verified {
roots
.final_file(row)
.unwrap_or_else(|_| panic!("final witness"));
let name = row.row.path.strip_prefix("artifacts/").unwrap();
assert_eq!(fs::read(fixture.target().join(name)).unwrap(), data);
}
drop(pool);
roots.unchanged().unwrap_or_else(|_| panic!("root anchors"));
}
#[test]
fn request_line_requires_finite_complete_frame() {
assert!(matches!(
request(&mut &b"{\"schema_version\":1,\"kind\":\"restore.io.finish\"}\n"[..]),
Ok(Request::Finish { .. })
));
assert!(
request(&mut &b"{\"schema_version\":1,\"kind\":\"restore.io.finish\"}"[..]).is_err()
);
assert!(request(&mut &b""[..]).is_err());
assert!(request(&mut &vec![b' '; restore_io::MAX_REQUEST_BYTES + 1][..]).is_err());
}
}
+31 -1
View File
@@ -108,8 +108,38 @@ def release_violations() -> list[str]:
return violations
def restore_copy_violations() -> list[str]:
"""Keep the fixed offline data plane inside Sandbox and free of execution."""
root = ROOT / "judge/crates/sandbox"
manifest = tomllib.loads((root / "Cargo.toml").read_text(encoding="utf-8"))
entries = [row for row in manifest.get("bin", []) if row.get("name") == "kf-restore-io"]
if entries != [{"name": "kf-restore-io", "path": "src/restore_copy/main.rs"}]:
return ["kf-restore-io must remain the declared offline Sandbox entry"]
violations = []
for path in sorted((root / "src/restore_copy").glob("*.rs")):
text = path.read_text(encoding="utf-8")
# A source boundary check, complemented by actual Root/cgroup/IO VM tests.
for forbidden in (
"Command",
"std::net",
"kf_sandbox::",
"kf_engine::",
"kf_worker::",
"kf_coordinator::",
"libc::",
"execve",
"execv(",
"fork(",
):
if forbidden in text:
violations.append(f"{path.relative_to(ROOT)}: offline copy cannot use {forbidden}")
return violations
def main() -> None:
violations = python_violations() + rust_violations() + release_violations()
violations = (
python_violations() + rust_violations() + release_violations() + restore_copy_violations()
)
if violations:
print("\n".join(violations), file=sys.stderr)
raise SystemExit(1)
+19
View File
@@ -411,6 +411,25 @@ all normal services and tasks are stopped. The stopped-state checks precede data
verification and copying and run again before completion. Original identities,
modes and replay IDs are preserved. Its PostgreSQL target is writable; the fresh
RabbitMQ volume is checked through a read-only mount and receives no copied broker state.
Version 4 delegates its file data plane to the selected bundle's `kf-restore-io`
binary inside the existing 512 MiB/200% CPU/32-task restore unit. A single native
process retains two joined worker threads and the verified file witnesses; it
never forks a copy of Python's full inventory. Its only input is the versioned
private stdio protocol, with fixed source/target domains and no command or path
overrides. The binary independently checks Root capabilities, actual unit/node
limits, the read-only backup volume and stopped writers. It starts no programs
or services. Python retains its filesystem error cursors from before directory
creation; the native child holds its own cursors before file writes. Both cursor
sets are synchronized before completion. The original deadlines remain.
Each batch copies, joins both workers, flushes the affected filesystems, then
reads every target. Final directory sync and stopped/selected-identity checks
precede the native inventory's complete metadata recheck and thread joins.
Transport, copy, byte, sync or identity failure retains the partial target and
returns no successful data receipt. There is no fallback after a native failure.
Version 3 keeps the prior per-file implementation. Older complete bundles remain
eligible for compatible software rollback; new native support requires both its
binary and Python transport module in the bundle.
Each restored file is flushed and read back in full. Within a joined half-batch,
repeated digest/size groups can retain at most 8 MiB of reference bytes per reader.
The first target receives an actual SHA-256 check; every later target is fully
+1 -1
View File
@@ -340,7 +340,7 @@ def build(repo: Path, inputs: Path, output: Path, work: Path) -> dict:
name = raw.decode()
relative_name("delivery/" + name)
copy(repo / name, payload / "delivery" / name)
for name in ("kf-sandbox", "kf-worker", "kf-coordinator"):
for name in ("kf-sandbox", "kf-worker", "kf-coordinator", "kf-restore-io"):
copy(repo / "judge/target/release" / name, payload / "bin" / name)
for name in sorted(records):
if name.startswith("python/lib/") or name == "python/bin/python3.13":
+5
View File
@@ -349,6 +349,11 @@ def validate(data: dict) -> dict:
"frontend/admin/index.html",
"deploy/release/contract.v1.json",
}
# Older deliveries remain valid rollback candidates. New native restore
# support must be complete in both directions when either member is present.
restore_pair = {"bin/kf-restore-io", "delivery/scripts/release/restore_native.py"}
if seen & restore_pair:
required.update(restore_pair)
required.update(f"images/{name}/manifest.json" for name in data["images"])
if not required <= seen:
raise DeliveryError("incomplete application bundle")
+28 -15
View File
@@ -23,6 +23,7 @@ from . import (
manifest,
preflight,
restore_io,
restore_native,
restore_policy,
)
from .policy import DeliveryError, canonical, digest, sha256
@@ -295,6 +296,7 @@ def restore(recovery_id: str, backup_id: str, expected: str) -> dict:
"restore.py",
"restore_policy.py",
"restore_io.py",
"restore_native.py",
"backup.py",
"backup_manifest.py",
"backup_objects.py",
@@ -345,6 +347,7 @@ def restore(recovery_id: str, backup_id: str, expected: str) -> dict:
}
deadline = time.monotonic() + 1700
filesystems = None
native = None
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
stopped()
@@ -398,15 +401,15 @@ def restore(recovery_id: str, backup_id: str, expected: str) -> dict:
if value["schema_version"] == 4:
assert filesystems is not None
verified = restore_io.materialize(
value["files"],
lambda item: backup_manifest.member_path(root, value, item),
lambda item: _target(item["path"], item, recovery),
readback,
filesystems,
native = restore_native.Session(
payload,
backup_id,
recovery_id,
len(value["files"]),
backup_manifest.logical_bytes(value),
deadline,
group_readback=readback_group,
)
native.materialize(value["files"])
else:
witnesses = file_copy.bounded_copies(value["files"], copy, deadline=deadline)
verified = {
@@ -439,11 +442,17 @@ def restore(recovery_id: str, backup_id: str, expected: str) -> dict:
stopped()
if install.selection() != selected:
raise DeliveryError("selected recovery identity changed")
for item in value["files"]:
if time.monotonic() >= deadline:
raise DeliveryError("recovery verification deadline exceeded")
path, uid, gid, mode = _target(item["path"], item, recovery)
unchanged_file(path, verified[item["path"]], uid, gid, mode)
if native is not None:
# The native child retained every post-sync full-byte witness. It
# rechecks the complete inventory after final directory sync and
# stopped/selection proofs, then joins before this receipt exists.
native.finish()
else:
for item in value["files"]:
if time.monotonic() >= deadline:
raise DeliveryError("recovery verification deadline exceeded")
path, uid, gid, mode = _target(item["path"], item, recovery)
unchanged_file(path, verified[item["path"]], uid, gid, mode)
if filesystems is not None:
filesystems.close()
filesystems = None
@@ -461,10 +470,14 @@ def restore(recovery_id: str, backup_id: str, expected: str) -> dict:
return record
finally:
try:
if filesystems is not None:
filesystems.close()
if native is not None:
native.close()
finally:
os.close(descriptor)
try:
if filesystems is not None:
filesystems.close()
finally:
os.close(descriptor)
def launch(recovery_id: str, backup_id: str, expected: str) -> dict:
+220
View File
@@ -0,0 +1,220 @@
"""One bounded native data-copy child in the existing exclusive restore unit."""
import json
import os
import selectors
import signal
import stat
import subprocess
import time
from pathlib import Path
from kf_oj.contracts.restore_io import RestoreIOReply
from . import file_copy, preflight, restore_io
from .policy import DeliveryError, invalid_constant, unique_object
MAX_REQUEST_BYTES = 256 * 1024
MAX_REPLY_BYTES = 64 * 1024
MAX_STDERR_BYTES = 16 * 1024
class Session:
"""No fallback after partial writes; all verified witnesses stay in the child."""
def __init__(
self,
payload: Path,
backup_id: str,
recovery_id: str,
expected_files: int,
expected_bytes: int,
deadline: float,
):
self.deadline = deadline
self.batch_id = self.files = self.bytes = 0
self.expected_files, self.expected_bytes = expected_files, expected_bytes
self.finished = False
self.process = None
self.stderr_bytes = 0
helper = payload / "bin/kf-restore-io"
preflight.protected(helper)
info = helper.lstat()
if (
not stat.S_ISREG(info.st_mode)
or info.st_nlink != 1
or stat.S_IMODE(info.st_mode) != 0o555
):
raise DeliveryError("selected native recovery helper differs")
witness = file_copy._identity(info)
self.selector = selectors.DefaultSelector()
try:
restore_io.remaining(self.deadline)
self.process = subprocess.Popen(
[str(helper), "--stdio-v1"],
cwd="/",
env={
"PATH": "/usr/bin:/bin",
"LC_ALL": "C.UTF-8",
"LD_LIBRARY_PATH": f"{payload}/lib:{payload}/python/lib",
},
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
close_fds=True,
)
if file_copy._identity(helper.lstat()) != witness:
raise DeliveryError("selected native recovery helper changed")
assert self.process.stdin and self.process.stdout and self.process.stderr
for handle in (self.process.stdin, self.process.stdout, self.process.stderr):
os.set_blocking(handle.fileno(), False)
self.selector.register(self.process.stdout, selectors.EVENT_READ, "stdout")
self.selector.register(self.process.stderr, selectors.EVENT_READ, "stderr")
self._exchange(
{
"schema_version": 1,
"kind": "restore.io.open",
"backup_id": backup_id,
"recovery_id": recovery_id,
"expected_files": expected_files,
"expected_logical_bytes": expected_bytes,
"deadline_monotonic_ns": int(deadline * 1_000_000_000),
},
"opened",
)
except BaseException:
self.close()
raise
def _exchange(self, value: dict, stage: str) -> None:
restore_io.remaining(self.deadline)
if self.process is None or self.finished:
raise DeliveryError("native recovery session is closed")
raw = json.dumps(value, separators=(",", ":"), ensure_ascii=True).encode() + b"\n"
if len(raw) > MAX_REQUEST_BYTES:
raise DeliveryError("native recovery request exceeds bound")
assert self.process.stdin
self.selector.register(self.process.stdin, selectors.EVENT_WRITE, "stdin")
offset = 0
output = bytearray()
try:
while True:
restore_io.remaining(self.deadline)
ready = self.selector.select(max(0, self.deadline - time.monotonic()))
if not ready:
raise DeliveryError("native recovery copy deadline exceeded")
for key, _ in ready:
if key.data == "stdin":
try:
count = os.write(key.fd, memoryview(raw)[offset:])
except BlockingIOError:
continue
if count <= 0:
raise DeliveryError("native recovery request pipe closed")
offset += count
if offset == len(raw):
self.selector.unregister(self.process.stdin)
continue
try:
block = os.read(key.fd, 8192)
except BlockingIOError:
continue
if key.data == "stderr":
self.stderr_bytes += len(block)
if self.stderr_bytes > MAX_STDERR_BYTES:
raise DeliveryError("native recovery diagnostic exceeds bound")
if not block:
self.selector.unregister(key.fileobj)
continue
if not block:
raise DeliveryError("native recovery stopped before verification")
output.extend(block)
if len(output) > MAX_REPLY_BYTES:
raise DeliveryError("native recovery reply exceeds bound")
if b"\n" in output:
if offset != len(raw) or output[-1:] != b"\n" or output.count(b"\n") != 1:
raise DeliveryError("native recovery reply framing differs")
reply = RestoreIOReply.model_validate(
json.loads(
output,
object_pairs_hook=unique_object,
parse_constant=invalid_constant,
)
)
if (
reply.stage != stage
or reply.error != "none"
or (reply.batch_id, reply.verified_files, reply.verified_logical_bytes)
!= (self.batch_id, self.files, self.bytes)
):
raise DeliveryError("native recovery file verification refused")
restore_io.remaining(self.deadline)
return
except (OSError, ValueError, RecursionError) as error:
raise DeliveryError("native recovery transport or reply differs") from error
finally:
if self.process.stdin in self.selector.get_map():
self.selector.unregister(self.process.stdin)
def materialize(self, rows: list[dict]) -> None:
for batch in restore_io.batches(rows):
self.batch_id += 1
self.files += len(batch)
self.bytes += sum(item["bytes"] for item in batch)
if self.files > self.expected_files or self.bytes > self.expected_bytes:
raise DeliveryError("native recovery inventory exceeds original receipt")
# The complete private manifest was already strictly validated.
# The native decoder independently validates each row before use.
self._exchange(
{
"schema_version": 1,
"kind": "restore.io.batch",
"batch_id": self.batch_id,
"files": batch,
},
"verified",
)
def finish(self) -> None:
if (self.files, self.bytes) != (self.expected_files, self.expected_bytes):
raise DeliveryError("native recovery inventory incomplete")
self._exchange({"schema_version": 1, "kind": "restore.io.finish"}, "finished")
assert self.process is not None and self.process.stdin
self.process.stdin.close()
restore_io.remaining(self.deadline)
try:
code = self.process.wait(timeout=max(0, self.deadline - time.monotonic()))
except subprocess.TimeoutExpired as error:
raise DeliveryError("native recovery child did not join") from error
if code != 0:
raise DeliveryError("native recovery child failed after verification")
restore_io.remaining(self.deadline)
self.finished = True
def close(self) -> None:
process = self.process
try:
if process is not None and process.poll() is None:
# This child starts its own session and never spawns processes.
# Its only descendants are its two native threads.
try:
if os.getpgid(process.pid) != process.pid:
raise DeliveryError("native recovery child ownership differs")
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait(timeout=2)
finally:
self.selector.close()
if process is not None:
for handle in (process.stdin, process.stdout, process.stderr):
if handle is not None:
handle.close()
+631
View File
@@ -0,0 +1,631 @@
[
{
"name": "open",
"direction": "request",
"valid": true,
"value": {
"schema_version": 1,
"kind": "restore.io.open",
"backup_id": "bk-11111111111111111111111111111111",
"recovery_id": "rr-22222222222222222222222222222222",
"expected_files": 1,
"expected_logical_bytes": 4,
"deadline_monotonic_ns": 1700000000000
}
},
{
"name": "batch",
"direction": "request",
"valid": true,
"value": {
"schema_version": 1,
"kind": "restore.io.batch",
"batch_id": 1,
"files": [
{
"path": "artifacts/trusted.txt",
"uid": 1200,
"gid": 1200,
"mode": 420,
"bytes": 4,
"sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"
}
]
}
},
{
"name": "finish",
"direction": "request",
"valid": true,
"value": {
"schema_version": 1,
"kind": "restore.io.finish"
}
},
{
"name": "opened",
"direction": "reply",
"valid": true,
"value": {
"schema_version": 1,
"kind": "restore.io.reply",
"stage": "opened",
"batch_id": 0,
"verified_files": 0,
"verified_logical_bytes": 0,
"execution_verified": false,
"error": "none"
}
},
{
"name": "verified",
"direction": "reply",
"valid": true,
"value": {
"schema_version": 1,
"kind": "restore.io.reply",
"stage": "verified",
"batch_id": 1,
"verified_files": 1,
"verified_logical_bytes": 4,
"execution_verified": false,
"error": "none"
}
},
{
"name": "finished",
"direction": "reply",
"valid": true,
"value": {
"schema_version": 1,
"kind": "restore.io.reply",
"stage": "finished",
"batch_id": 1,
"verified_files": 1,
"verified_logical_bytes": 4,
"execution_verified": false,
"error": "none"
}
},
{
"name": "refused",
"direction": "reply",
"valid": true,
"value": {
"schema_version": 1,
"kind": "restore.io.reply",
"stage": "refused",
"batch_id": 0,
"verified_files": 0,
"verified_logical_bytes": 0,
"execution_verified": false,
"error": "identity"
}
},
{
"name": "bad-open-schema_version-True",
"direction": "request",
"valid": false,
"value": {
"schema_version": true,
"kind": "restore.io.open",
"backup_id": "bk-11111111111111111111111111111111",
"recovery_id": "rr-22222222222222222222222222222222",
"expected_files": 1,
"expected_logical_bytes": 4,
"deadline_monotonic_ns": 1700000000000
}
},
{
"name": "bad-open-expected_files-0",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.open",
"backup_id": "bk-11111111111111111111111111111111",
"recovery_id": "rr-22222222222222222222222222222222",
"expected_files": 0,
"expected_logical_bytes": 4,
"deadline_monotonic_ns": 1700000000000
}
},
{
"name": "bad-open-expected_files-98305",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.open",
"backup_id": "bk-11111111111111111111111111111111",
"recovery_id": "rr-22222222222222222222222222222222",
"expected_files": 98305,
"expected_logical_bytes": 4,
"deadline_monotonic_ns": 1700000000000
}
},
{
"name": "bad-open-expected_logical_bytes-12884901889",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.open",
"backup_id": "bk-11111111111111111111111111111111",
"recovery_id": "rr-22222222222222222222222222222222",
"expected_files": 1,
"expected_logical_bytes": 12884901889,
"deadline_monotonic_ns": 1700000000000
}
},
{
"name": "bad-open-deadline_monotonic_ns-1.0",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.open",
"backup_id": "bk-11111111111111111111111111111111",
"recovery_id": "rr-22222222222222222222222222222222",
"expected_files": 1,
"expected_logical_bytes": 4,
"deadline_monotonic_ns": 1.0
}
},
{
"name": "bad-open-backup_id-bk-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.open",
"backup_id": "bk-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"recovery_id": "rr-22222222222222222222222222222222",
"expected_files": 1,
"expected_logical_bytes": 4,
"deadline_monotonic_ns": 1700000000000
}
},
{
"name": "bad-open-recovery_id-rr-../etc",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.open",
"backup_id": "bk-11111111111111111111111111111111",
"recovery_id": "rr-../etc",
"expected_files": 1,
"expected_logical_bytes": 4,
"deadline_monotonic_ns": 1700000000000
}
},
{
"name": "bad-open-extra-1",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.open",
"backup_id": "bk-11111111111111111111111111111111",
"recovery_id": "rr-22222222222222222222222222222222",
"expected_files": 1,
"expected_logical_bytes": 4,
"deadline_monotonic_ns": 1700000000000,
"extra": 1
}
},
{
"name": "bad-file-path-artifacts/../x",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.batch",
"batch_id": 1,
"files": [
{
"path": "artifacts/../x",
"uid": 1200,
"gid": 1200,
"mode": 420,
"bytes": 4,
"sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"
}
]
}
},
{
"name": "bad-file-path-artifacts/x/.ssh/id",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.batch",
"batch_id": 1,
"files": [
{
"path": "artifacts/x/.ssh/id",
"uid": 1200,
"gid": 1200,
"mode": 420,
"bytes": 4,
"sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"
}
]
}
},
{
"name": "bad-file-path-artifacts",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.batch",
"batch_id": 1,
"files": [
{
"path": "artifacts",
"uid": 1200,
"gid": 1200,
"mode": 420,
"bytes": 4,
"sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"
}
]
}
},
{
"name": "bad-file-path-x/y",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.batch",
"batch_id": 1,
"files": [
{
"path": "x/y",
"uid": 1200,
"gid": 1200,
"mode": 420,
"bytes": 4,
"sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"
}
]
}
},
{
"name": "bad-file-path-artifacts/你好",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.batch",
"batch_id": 1,
"files": [
{
"path": "artifacts/你好",
"uid": 1200,
"gid": 1200,
"mode": 420,
"bytes": 4,
"sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"
}
]
}
},
{
"name": "bad-file-uid-1000",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.batch",
"batch_id": 1,
"files": [
{
"path": "artifacts/trusted.txt",
"uid": 1000,
"gid": 1200,
"mode": 420,
"bytes": 4,
"sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"
}
]
}
},
{
"name": "bad-file-gid-1201",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.batch",
"batch_id": 1,
"files": [
{
"path": "artifacts/trusted.txt",
"uid": 1200,
"gid": 1201,
"mode": 420,
"bytes": 4,
"sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"
}
]
}
},
{
"name": "bad-file-mode-2541",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.batch",
"batch_id": 1,
"files": [
{
"path": "artifacts/trusted.txt",
"uid": 1200,
"gid": 1200,
"mode": 2541,
"bytes": 4,
"sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"
}
]
}
},
{
"name": "bad-file-bytes--1",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.batch",
"batch_id": 1,
"files": [
{
"path": "artifacts/trusted.txt",
"uid": 1200,
"gid": 1200,
"mode": 420,
"bytes": -1,
"sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"
}
]
}
},
{
"name": "bad-file-bytes-268435457",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.batch",
"batch_id": 1,
"files": [
{
"path": "artifacts/trusted.txt",
"uid": 1200,
"gid": 1200,
"mode": 420,
"bytes": 268435457,
"sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"
}
]
}
},
{
"name": "bad-file-bytes-False",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.batch",
"batch_id": 1,
"files": [
{
"path": "artifacts/trusted.txt",
"uid": 1200,
"gid": 1200,
"mode": 420,
"bytes": false,
"sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"
}
]
}
},
{
"name": "bad-file-sha256-0000000000000000000000000000000000000000000000000000000000000000",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.batch",
"batch_id": 1,
"files": [
{
"path": "artifacts/trusted.txt",
"uid": 1200,
"gid": 1200,
"mode": 420,
"bytes": 4,
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
}
]
}
},
{
"name": "duplicate-file",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.batch",
"batch_id": 1,
"files": [
{
"path": "artifacts/trusted.txt",
"uid": 1200,
"gid": 1200,
"mode": 420,
"bytes": 4,
"sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"
},
{
"path": "artifacts/trusted.txt",
"uid": 1200,
"gid": 1200,
"mode": 420,
"bytes": 4,
"sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"
}
]
}
},
{
"name": "multi-large",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.batch",
"batch_id": 1,
"files": [
{
"path": "artifacts/trusted.txt",
"uid": 1200,
"gid": 1200,
"mode": 420,
"bytes": 67108864,
"sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"
},
{
"path": "artifacts/b",
"uid": 1200,
"gid": 1200,
"mode": 420,
"bytes": 4,
"sha256": "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7"
}
]
}
},
{
"name": "empty-batch",
"direction": "request",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.batch",
"batch_id": 1,
"files": []
}
},
{
"name": "bad-reply-execution_verified-True",
"direction": "reply",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.reply",
"stage": "opened",
"batch_id": 0,
"verified_files": 0,
"verified_logical_bytes": 0,
"execution_verified": true,
"error": "none"
}
},
{
"name": "bad-reply-execution_verified-0",
"direction": "reply",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.reply",
"stage": "opened",
"batch_id": 0,
"verified_files": 0,
"verified_logical_bytes": 0,
"execution_verified": 0,
"error": "none"
}
},
{
"name": "bad-reply-stage-unknown",
"direction": "reply",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.reply",
"stage": "unknown",
"batch_id": 0,
"verified_files": 0,
"verified_logical_bytes": 0,
"execution_verified": false,
"error": "none"
}
},
{
"name": "bad-reply-error-bytes",
"direction": "reply",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.reply",
"stage": "opened",
"batch_id": 0,
"verified_files": 0,
"verified_logical_bytes": 0,
"execution_verified": false,
"error": "bytes"
}
},
{
"name": "bad-reply-verified_files-1",
"direction": "reply",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.reply",
"stage": "opened",
"batch_id": 0,
"verified_files": 1,
"verified_logical_bytes": 0,
"execution_verified": false,
"error": "none"
}
},
{
"name": "bad-reply-schema_version-1.0",
"direction": "reply",
"valid": false,
"value": {
"schema_version": 1.0,
"kind": "restore.io.reply",
"stage": "opened",
"batch_id": 0,
"verified_files": 0,
"verified_logical_bytes": 0,
"execution_verified": false,
"error": "none"
}
},
{
"name": "bad-reply-extra-1",
"direction": "reply",
"valid": false,
"value": {
"schema_version": 1,
"kind": "restore.io.reply",
"stage": "opened",
"batch_id": 0,
"verified_files": 0,
"verified_logical_bytes": 0,
"execution_verified": false,
"error": "none",
"extra": 1
}
}
]