test: add deterministic runtime config chain race regression
This commit is contained in:
@@ -560,3 +560,52 @@ test.each(["leaf", "ancestor"] as const)("actual harness rejects canonical %s sw
|
|||||||
})).toThrow();
|
})).toThrow();
|
||||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("actual harness rejects a workspace chain swap between config and manifest traversal", () => {
|
||||||
|
const f = fixture();
|
||||||
|
try {
|
||||||
|
const lease = f.factory.acquireSession(f.snapshotPath);
|
||||||
|
const python = join(process.cwd(), "..", "harness", ".venv", "bin", "python");
|
||||||
|
const helper = join(process.cwd(), "..", "harness", "tht");
|
||||||
|
const workspaceRoot = join(f.factoryInput.dataRoot, "sessions", workspace);
|
||||||
|
const code = `import os,sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, sys.argv[3])
|
||||||
|
from tht.config import ConfigError, _read_runtime_config_source
|
||||||
|
config_path = Path(sys.argv[1])
|
||||||
|
workspace = Path(sys.argv[2])
|
||||||
|
revision = config_path.stem
|
||||||
|
|
||||||
|
def swap_workspace_chain():
|
||||||
|
moved = Path(str(workspace) + ".moved")
|
||||||
|
os.rename(workspace, moved)
|
||||||
|
(workspace / "preprocessing").mkdir(parents=True, mode=0o700)
|
||||||
|
for name, leaf in (("runtime-config", revision + ".yaml"),
|
||||||
|
("runtime-config-manifests", revision + ".json")):
|
||||||
|
source = moved / "preprocessing" / name
|
||||||
|
destination = workspace / "preprocessing" / name
|
||||||
|
original_inode = os.stat(source).st_ino
|
||||||
|
original_leaf_inode = os.stat(source / leaf).st_ino
|
||||||
|
os.rename(source, destination)
|
||||||
|
assert os.stat(destination).st_ino == original_inode
|
||||||
|
assert os.stat(destination / leaf).st_ino == original_leaf_inode
|
||||||
|
|
||||||
|
try:
|
||||||
|
_read_runtime_config_source(
|
||||||
|
config_path,
|
||||||
|
between_config_and_manifest_traversal=swap_workspace_chain,
|
||||||
|
)
|
||||||
|
except ConfigError:
|
||||||
|
print("rejected")
|
||||||
|
else:
|
||||||
|
raise SystemExit("secure reader accepted a replaced workspace chain")
|
||||||
|
`;
|
||||||
|
const output = execFileSync(python, ["-c", code, lease.path, workspaceRoot, helper], {
|
||||||
|
cwd: join(process.cwd(), "..", "harness"),
|
||||||
|
env: { ...process.env, THT_RUNTIME_CONFIG_MANIFEST_SHA256: lease.manifestSha256 },
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
expect(output.trim()).toBe("rejected");
|
||||||
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|||||||
+54
-10
@@ -5,6 +5,7 @@ import re
|
|||||||
import stat
|
import stat
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
|
from collections.abc import Callable
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
@@ -694,8 +695,25 @@ def _runtime_directory_identities(paths: list[Path]) -> list[dict[str, str]]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _verify_runtime_directory_identities(config_path: Path, manifest_path: Path, expected: object) -> None:
|
def _verify_runtime_directory_identities(
|
||||||
current = _runtime_directory_identities([config_path.parent, manifest_path.parent])
|
config_path: Path,
|
||||||
|
manifest_path: Path,
|
||||||
|
expected: object,
|
||||||
|
*,
|
||||||
|
between_config_and_manifest_traversal: Callable[[], None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
# Traverse the two destination branches separately. The callback is a narrow,
|
||||||
|
# package-private seam for deterministic direct tests of a replacement between
|
||||||
|
# those traversals; production always passes None.
|
||||||
|
current = _runtime_directory_identities([config_path.parent])
|
||||||
|
if between_config_and_manifest_traversal is not None:
|
||||||
|
between_config_and_manifest_traversal()
|
||||||
|
for identity in _runtime_directory_identities([manifest_path.parent]):
|
||||||
|
previous = next((item for item in current if item["path"] == identity["path"]), None)
|
||||||
|
if previous is None:
|
||||||
|
current.append(identity)
|
||||||
|
elif previous != identity:
|
||||||
|
raise ConfigError("Destinazione config runtime modificata")
|
||||||
if current != expected:
|
if current != expected:
|
||||||
raise ConfigError("Destinazione config runtime modificata")
|
raise ConfigError("Destinazione config runtime modificata")
|
||||||
|
|
||||||
@@ -746,11 +764,20 @@ def _runtime_manifest_path(config_path: Path) -> Path:
|
|||||||
raise OSError("runtime config path is not canonical")
|
raise OSError("runtime config path is not canonical")
|
||||||
return config_path.parent.parent / "runtime-config-manifests" / f"{config_path.stem}.json"
|
return config_path.parent.parent / "runtime-config-manifests" / f"{config_path.stem}.json"
|
||||||
|
|
||||||
def load_config(path: Path) -> Config:
|
def _read_runtime_config_source(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
between_config_and_manifest_traversal: Callable[[], None] | None = None,
|
||||||
|
) -> tuple[str | None, dict[str, object] | None]:
|
||||||
|
"""Read a trusted runtime config, with a scoped direct-test race seam.
|
||||||
|
|
||||||
|
The callback is deliberately available only on this package-private helper.
|
||||||
|
Production callers go through :func:`load_config`, which always passes ``None``;
|
||||||
|
no environment variable or process-global hook can alter this traversal.
|
||||||
|
"""
|
||||||
# Registry leases authenticate the canonical pathname through a durable manifest
|
# Registry leases authenticate the canonical pathname through a durable manifest
|
||||||
# digest. The config and manifest are opened component-by-component; FD 3/4 are
|
# digest. The config and manifest are opened component-by-component; FD 3/4 are
|
||||||
# reserved for the maintenance writer/root ABI.
|
# reserved for the maintenance writer/root ABI.
|
||||||
runtime_manifest: dict[str, object] | None = None
|
|
||||||
expected_manifest = os.environ.get("THT_RUNTIME_CONFIG_MANIFEST_SHA256")
|
expected_manifest = os.environ.get("THT_RUNTIME_CONFIG_MANIFEST_SHA256")
|
||||||
runtime_fd = os.environ.get("THT_CONFIG_FD")
|
runtime_fd = os.environ.get("THT_CONFIG_FD")
|
||||||
manifest_fd = os.environ.get("THT_CONFIG_MANIFEST_FD")
|
manifest_fd = os.environ.get("THT_CONFIG_MANIFEST_FD")
|
||||||
@@ -767,7 +794,12 @@ def load_config(path: Path) -> Config:
|
|||||||
if hashlib.sha256(manifest_bytes).hexdigest() != expected_manifest:
|
if hashlib.sha256(manifest_bytes).hexdigest() != expected_manifest:
|
||||||
raise ConfigError("Manifest runtime modificato")
|
raise ConfigError("Manifest runtime modificato")
|
||||||
runtime_manifest = _strict_runtime_manifest(json.loads(manifest_bytes.decode("utf-8")))
|
runtime_manifest = _strict_runtime_manifest(json.loads(manifest_bytes.decode("utf-8")))
|
||||||
_verify_runtime_directory_identities(path, _runtime_manifest_path(path), runtime_manifest["directory_identities"])
|
_verify_runtime_directory_identities(
|
||||||
|
path,
|
||||||
|
_runtime_manifest_path(path),
|
||||||
|
runtime_manifest["directory_identities"],
|
||||||
|
between_config_and_manifest_traversal=between_config_and_manifest_traversal,
|
||||||
|
)
|
||||||
if (runtime_manifest["config_sha256"] != hashlib.sha256(config_bytes).hexdigest()
|
if (runtime_manifest["config_sha256"] != hashlib.sha256(config_bytes).hexdigest()
|
||||||
or int(runtime_manifest["config_dev"]) != config_info.st_dev
|
or int(runtime_manifest["config_dev"]) != config_info.st_dev
|
||||||
or int(runtime_manifest["config_ino"]) != config_info.st_ino
|
or int(runtime_manifest["config_ino"]) != config_info.st_ino
|
||||||
@@ -776,7 +808,7 @@ def load_config(path: Path) -> Config:
|
|||||||
or int(runtime_manifest["config_nlink"]) != config_info.st_nlink
|
or int(runtime_manifest["config_nlink"]) != config_info.st_nlink
|
||||||
or config_info.st_dev == manifest_info.st_dev and config_info.st_ino == manifest_info.st_ino):
|
or config_info.st_dev == manifest_info.st_dev and config_info.st_ino == manifest_info.st_ino):
|
||||||
raise ConfigError("Identità config runtime non valida")
|
raise ConfigError("Identità config runtime non valida")
|
||||||
source_text = config_bytes.decode("utf-8")
|
return config_bytes.decode("utf-8"), runtime_manifest
|
||||||
except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
|
except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
|
||||||
if isinstance(exc, ConfigError):
|
if isinstance(exc, ConfigError):
|
||||||
raise
|
raise
|
||||||
@@ -786,7 +818,7 @@ def load_config(path: Path) -> Config:
|
|||||||
os.close(config_fd)
|
os.close(config_fd)
|
||||||
if manifest_fd_local is not None:
|
if manifest_fd_local is not None:
|
||||||
os.close(manifest_fd_local)
|
os.close(manifest_fd_local)
|
||||||
elif runtime_fd is not None or manifest_fd is not None or legacy_expected is not None:
|
if runtime_fd is not None or manifest_fd is not None or legacy_expected is not None:
|
||||||
# Compatibility for direct /dev/fd callers. New backend leases never use it.
|
# Compatibility for direct /dev/fd callers. New backend leases never use it.
|
||||||
if runtime_fd is None or manifest_fd is None or legacy_expected is None or not re.fullmatch(r"[0-9a-f]{64}", legacy_expected):
|
if runtime_fd is None or manifest_fd is None or legacy_expected is None or not re.fullmatch(r"[0-9a-f]{64}", legacy_expected):
|
||||||
raise ConfigError("Handoff runtime incompleto")
|
raise ConfigError("Handoff runtime incompleto")
|
||||||
@@ -796,7 +828,12 @@ def load_config(path: Path) -> Config:
|
|||||||
if hashlib.sha256(manifest_bytes).hexdigest() != legacy_expected:
|
if hashlib.sha256(manifest_bytes).hexdigest() != legacy_expected:
|
||||||
raise ConfigError("Manifest runtime modificato")
|
raise ConfigError("Manifest runtime modificato")
|
||||||
runtime_manifest = _strict_runtime_manifest(json.loads(manifest_bytes.decode("utf-8")))
|
runtime_manifest = _strict_runtime_manifest(json.loads(manifest_bytes.decode("utf-8")))
|
||||||
_verify_runtime_directory_identities(path, _runtime_manifest_path(path), runtime_manifest["directory_identities"])
|
_verify_runtime_directory_identities(
|
||||||
|
path,
|
||||||
|
_runtime_manifest_path(path),
|
||||||
|
runtime_manifest["directory_identities"],
|
||||||
|
between_config_and_manifest_traversal=between_config_and_manifest_traversal,
|
||||||
|
)
|
||||||
if (runtime_manifest["config_sha256"] != hashlib.sha256(config_bytes).hexdigest()
|
if (runtime_manifest["config_sha256"] != hashlib.sha256(config_bytes).hexdigest()
|
||||||
or int(runtime_manifest["config_dev"]) != config_info.st_dev
|
or int(runtime_manifest["config_dev"]) != config_info.st_dev
|
||||||
or int(runtime_manifest["config_ino"]) != config_info.st_ino
|
or int(runtime_manifest["config_ino"]) != config_info.st_ino
|
||||||
@@ -805,12 +842,19 @@ def load_config(path: Path) -> Config:
|
|||||||
or int(runtime_manifest["config_nlink"]) != config_info.st_nlink
|
or int(runtime_manifest["config_nlink"]) != config_info.st_nlink
|
||||||
or config_info.st_dev == manifest_info.st_dev and config_info.st_ino == manifest_info.st_ino):
|
or config_info.st_dev == manifest_info.st_dev and config_info.st_ino == manifest_info.st_ino):
|
||||||
raise ConfigError("Identità config runtime non valida")
|
raise ConfigError("Identità config runtime non valida")
|
||||||
source_text = config_bytes.decode("utf-8")
|
return config_bytes.decode("utf-8"), runtime_manifest
|
||||||
except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
|
except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
|
||||||
if isinstance(exc, ConfigError):
|
if isinstance(exc, ConfigError):
|
||||||
raise
|
raise
|
||||||
raise ConfigError("File di configurazione runtime non attendibile") from exc
|
raise ConfigError("File di configurazione runtime non attendibile") from exc
|
||||||
else:
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path) -> Config:
|
||||||
|
source_text, runtime_manifest = _read_runtime_config_source(
|
||||||
|
path, between_config_and_manifest_traversal=None
|
||||||
|
)
|
||||||
|
if source_text is None:
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise ConfigError(f"File di configurazione non trovato: {path}")
|
raise ConfigError(f"File di configurazione non trovato: {path}")
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user