Switchyard (Library Crate)
Operator & Integrator Guide (mdBook): https://veighnsche.github.io/switchyard/
API docs on docs.rs: https://docs.rs/switchyard-fs
Switchyard is a Rust library that provides a safe, deterministic, and auditable engine for applying system changes with:
- Atomic symlink replacement with backup/restore
- Preflight policy gating and capability probes
- Deterministic IDs and redaction for DryRun==Commit parity
- Rescue profile verification and fail-closed behavior
- Optional post-apply smoke checks with auto-rollback
- Structured facts and audit emission with provenance and exit codes
Switchyard can be used standalone or embedded by higher‑level CLIs. In some workspaces it may be included as a Git submodule.
Status: Core flows implemented with structured audit and locking; some features are intentionally minimal while SPEC v1.1 evolves. See the mdBook for coverage and roadmap.
Features
SafePathand TOCTOU-safe FS ops via capability-style handles (rustix)- Transactional symlink replacement with backup/restore (reverse-ordered rollback)
- Cross-filesystem degraded fallback for symlink replacement (EXDEV → unlink+symlink when policy allows)
- Locking with bounded wait; timeouts emit
E_LOCKINGand includelock_wait_ms - Deterministic
plan_idandaction_id(UUIDv5) - Facts emission (JSON) with minimal provenance and optional attestation bundle
- Redaction layer: removes timing/severity and masks secrets for canon comparison
- Rescue policy:
require_rescueverification (BusyBoxor ≥6/10 GNU tools on PATH), fail-closed gates - Optional smoke runner; default deterministic subset validates symlink targets resolve to sources
Module Overview
src/types/: core types (Plan,Action,ApplyMode,SafePath, IDs, reports)src/fs/: filesystem ops (atomic swap, backup/sidecar, restore engine, mount checks)src/policy/: policyPolicyconfig,types,gating(apply parity), andrescuehelperssrc/adapters/: integration traits (LockManager,OwnershipOracle,PathResolver,Attestor,SmokeTestRunner) and defaultssrc/api/: facade (Switchyard) delegating toplan,preflight,apply,rollbacksrc/logging/:StageLoggeraudit facade,FactsEmitter/AuditSink, andredact
Documentation
- Operator & Integrator Guide (mdBook): https://veighnsche.github.io/switchyard/
- Quickstart, Concepts, How‑Tos, and Reference live in the book.
- API docs on docs.rs: https://docs.rs/switchyard-fs
Supported Toolchains
- Stable (latest) — continuously tested in CI
- Beta — continuously tested in CI
- Nightly — continuously tested in CI
- MSRV: 1.89 (declared in
Cargo.toml; MSRV job builds the workspace on 1.89.0)
Examples
Run examples locally:
Quick Start
Build and run tests for this crate only:
Add as a dependency (when used standalone):
[]
= { = "switchyard-fs", = "0.1" }
Add as a dependency (when used as a workspace submodule):
[]
# Adjust the path to where the submodule lives in your workspace
= { = "../switchyard" }
Recommended: add this repository as a Git submodule at the desired path, e.g.:
Minimal Example
use ;
use JsonlSink;
use Policy;
use ;
Alternate entrypoint:
use Switchyard;
use JsonlSink;
use Policy;
let facts = default;
let audit = default;
let policy = default;
let api = builder
.with_lock_timeout_ms
.build;
Core Concepts
SafePath and TOCTOU Safety
- All mutating APIs accept
SafePathto avoid path traversal (..) and ensure operations anchor under a known root. - Filesystem operations use TOCTOU-safe sequences (open parent
O_DIRECTORY|O_NOFOLLOW→openat→renameat→fsync(parent)).
Preflight Policy and Preservation
- One preflight row per action with deterministic ordering and fields:
action_id,path,current_kind,planned_kind,policy_okpreservation { owner, mode, timestamps, xattrs, acls, caps }, andpreservation_supported
- Enforced STOPs:
require_preservation=truebut unsupported → STOPrequire_rescue=trueand rescue verification fails → STOP
- Additional gates cover mount
rw+exec, immutability, roots/forbid paths, and ownership viaOwnershipOraclewhenstrict_ownership=true.
Degraded Symlink Semantics (Cross-FS)
- For symlink replacement across filesystems (EXDEV), atomic rename does not apply to the symlink itself.
- When
allow_degraded_fs=true, Switchyard uses unlink +symlinkatas a best-effort degraded fallback and recordsdegraded=truein facts. - When disallowed, the operation fails with
E_EXDEVand no visible change.
Locking and Exit Codes (Silver Tier)
- Provide a
LockManagerto serialize apply operations. On timeout, Switchyard emitsapply.attemptfailure with:error_id=E_LOCKINGandexit_code=30lock_wait_msincluded (redacted in canon)
- Other covered IDs include
E_POLICY,E_ATOMIC_SWAP,E_EXDEV,E_BACKUP_MISSING,E_RESTORE_FAILED,E_SMOKE.
Smoke Tests and Auto-Rollback
- Switchyard accepts an optional
SmokeTestRunner. When provided, smoke tests run after apply (Commit mode) and on failure:- Emit
E_SMOKE - Auto-rollback unless
policy.disable_auto_rollback=true
- Emit
- The default runner provides a deterministic subset: validate that
EnsureSymlinktargets resolve to their sources.
Determinism and Redaction
plan_idandaction_idareUUIDv5for stable ordering.DryRuntimestamps are zeroed (1970-01-01T00:00:00Z); volatile fields like durations and severity are removed; secrets are masked.- Use the
redact_event()helper to compare facts forDryRunvsCommitparity.
Provenance and Attestation
- Minimal provenance is included on all facts (
ensure_provenance()ensures presence); apply per-action can be enriched with{uid,gid,pkg}when anOwnershipOracleis available. - Attestation bundle scaffolding exists; sensitive fields are masked for canon comparison.
Testing and Goldens
Run all tests for this crate:
Useful environment variables:
GOLDEN_OUT_DIR: if set, certain tests will write canon files (e.g.,locking-timeout/canon_apply_attempt.json).SWITCHYARD_FORCE_RESCUE_OK: testing override for rescue verification (0/1). Do not use outside tests.
Conformance and acceptance:
- Some golden fixtures live under
tests/golden/*. - A non-blocking CI job runs
SPEC/tools/traceability.pyand publishes coverage artifacts.
See the mdBook for testing guidance, troubleshooting, and conventions.
Cargo Features
file-logging: enables a file‑backed JSONL sink (logging::facts::FileJsonlSink) for facts/audit emission.
Integration Notes
Construction options
-
Construct via
ApiBuilderorSwitchyard::builder:with_lock_manager(Box<dyn LockManager>)with_ownership_oracle(Box<dyn OwnershipOracle>)with_attestor(Box<dyn Attestor>)with_smoke_runner(Box<dyn SmokeTestRunner>)with_lock_timeout_ms(u64)
-
Switchyard::new(facts, audit, policy)remains available for compatibility and delegates to the builder internally.
Naming conventions
-
Facts/Audit sinks:
facts,audit -
Policy:
policy -
API instance:
api -
Builder variable (if kept):
builder -
Emitters: Provide your own
FactsEmitterandAuditSinkimplementations to integrate with your logging/telemetry stack.JsonlSinkis bundled for development/testing. -
Adapters: Implement or wire in
OwnershipOracle,LockManager,PathResolver,Attestor, andSmokeTestRunneras needed. -
Policy: Start from
Policy::default()or a preset (Policy::production_preset(),Policy::coreutils_switch_preset()). Key knobs are grouped:policy.rescue.{require, exec_check, min_count}policy.apply.{exdev, override_preflight, best_effort_restore, extra_mount_checks, capture_restore_snapshot}policy.risks.{ownership_strict, source_trust, suid_sgid, hardlinks}policy.durability.preservationpolicy.governance.{locking, smoke, allow_unlocked_commit}policy.scope.{allow_roots, forbid_paths}policy.backup.tag,policy.retention_count_limit,policy.retention_age_limit
Production Policy Preset (with builder)
Use the hardened preset and wire required adapters. Adjust EXDEV behavior per environment.
use ApiBuilder;
use ;
use ;
use JsonlSink;
use PathBuf;
let facts = default;
let audit = default;
let mut policy = production_preset;
policy.apply.exdev = DegradedFallback;
let api = new
.with_lock_manager
.with_smoke_runner
.build;
This preset ensures:
- Lock manager is required in Commit; absence fails with
E_LOCKING(exit code 30). - Smoke verification is required in Commit; missing or failing runner yields
E_SMOKEand triggers auto‑rollback (unless explicitly disabled by policy). - Rescue profile is verified (presence and X bits) before mutation.
Prune Backups
Prune backup artifacts for a target under the current retention policy. Emits a prune.result fact.
use SafePath;
let target = from_rooted?;
let res = api.prune_backups?;
println!;
Knobs: policy.retention_count_limit: Option<usize>, policy.retention_age_limit: Option<Duration>.
Rescue How‑To (Manual Rollback)
When the library cannot run (e.g., toolchain broken) you can manually restore using BusyBox/GNU coreutils.
Backup artifacts next to the target use:
- Backup payload:
.<name>.<tag>.<millis>.bak - Sidecar (JSON):
.<name>.<tag>.<millis>.bak.meta.json
The sidecar schema (backup_meta.v1) includes:
prior_kind:file|symlink|noneprior_dest: original symlink destination (forsymlink)mode: octal string for file mode (forfile)
Steps (run in the parent directory of the target):
- Locate the latest pair (highest
<millis>):
| |
- Read
prior_kind(andprior_dest/mode) from the sidecar:
- Restore according to
prior_kind:
- file
[ && \
- symlink
- none
Notes:
- Relative
prior_destvalues are relative to the parent directory of<name>. - The sidecar is retained to allow idempotent retries; do not delete it.
- Prefer
busybox jq(or ship a minimaljq) for convenience; ifjqis unavailable, you can inspect the JSON manually.
Documentation and Change Control
- Baseline SPEC:
SPEC/SPEC.md - Immutable updates:
SPEC/SPEC_UPDATE_*.md - Checklist and compliance:
SPEC_CHECKLIST.md - Deep dives and topics:
DOCS/andINVENTORY/ - Refactors and ongoing work:
zrefactor/ - Gaps and tasks:
TODO.md,a-test-gaps.md
When introducing normative behavior changes:
- Add a
SPEC_UPDATE_####.mdentry - Update relevant docs (
DOCS/,INVENTORY/) and checklist - Update tests/goldens accordingly
License
This crate is dual-licensed under either:
- Apache License, Version 2.0 — see repository root
LICENSE - MIT License — see repository root
LICENSE-MITat your option.
Minimum Supported Rust Version (MSRV): 1.89