Expand description
§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
SafePath
and 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_LOCKING
and includelock_wait_ms
- Deterministic
plan_id
andaction_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_rescue
verification (BusyBox
or ≥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/
: policyPolicy
config,types
,gating
(apply parity), andrescue
helperssrc/adapters/
: integration traits (LockManager
,OwnershipOracle
,PathResolver
,Attestor
,SmokeTestRunner
) and defaultssrc/api/
: facade (Switchyard
) delegating toplan
,preflight
,apply
,rollback
src/logging/
:StageLogger
audit 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:
cargo run --example 01_dry_run
cargo run --example 02_commit_with_lock
cargo run --example 03_rollback
cargo run --example 04_audit_and_redaction
cargo run --example 05_exdev_degraded
§Quick Start
Build and run tests for this crate only:
cargo test
Add as a dependency (when used standalone):
[dependencies]
switchyard = { package = "switchyard-fs", version = "0.1" }
Add as a dependency (when used as a workspace submodule):
[dependencies]
# Adjust the path to where the submodule lives in your workspace
switchyard = { path = "../switchyard" }
Recommended: add this repository as a Git submodule at the desired path, e.g.:
git submodule add https://github.com/veighnsche/switchyard ../switchyard
git submodule update --init --recursive
§Minimal Example
use switchyard::api::{ApiBuilder, Switchyard};
use switchyard::logging::JsonlSink;
use switchyard::policy::Policy;
use switchyard::types::{PlanInput, LinkRequest, SafePath, ApplyMode};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Provide your emitters
let facts = JsonlSink::default();
let audit = JsonlSink::default();
let policy = Policy::default();
// Construct using the builder (or use Switchyard::builder(...))
let api: Switchyard<_, _> = ApiBuilder::new(facts.clone(), audit, policy)
.with_lock_timeout_ms(500)
.build();
// All mutating paths must be SafePath rooted under a directory you control
let td = tempfile::tempdir()?;
let root = td.path();
std::fs::create_dir_all(root.join("usr/bin"))?;
std::fs::write(root.join("usr/bin/ls"), b"old")?;
std::fs::create_dir_all(root.join("bin"))?;
std::fs::write(root.join("bin/new"), b"new")?;
let source = SafePath::from_rooted(root, &root.join("bin/new"))?;
let target = SafePath::from_rooted(root, &root.join("usr/bin/ls"))?;
let plan = api.plan(PlanInput { link: vec![LinkRequest { source, target }], restore: vec![] });
let preflight = api.preflight(&plan)?;
if !preflight.ok {
eprintln!("Preflight failed: {:?}", preflight.stops);
std::process::exit(10);
}
let report = api.apply(&plan, ApplyMode::Commit)?;
println!(
"Apply decision: {}",
if report.errors.is_empty() { "success" } else { "failure" }
);
Ok(())
}
Alternate entrypoint:
use switchyard::api::Switchyard;
use switchyard::logging::JsonlSink;
use switchyard::policy::Policy;
let facts = JsonlSink::default();
let audit = JsonlSink::default();
let policy = Policy::default();
let api = Switchyard::builder(facts, audit, policy)
.with_lock_timeout_ms(500)
.build();
§Core Concepts
§SafePath
and TOCTOU Safety
- All mutating APIs accept
SafePath
to 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_ok
preservation { owner, mode, timestamps, xattrs, acls, caps }
, andpreservation_supported
- Enforced STOPs:
require_preservation=true
but unsupported → STOPrequire_rescue=true
and rescue verification fails → STOP
- Additional gates cover mount
rw+exec
, immutability, roots/forbid paths, and ownership viaOwnershipOracle
whenstrict_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 +symlinkat
as a best-effort degraded fallback and recordsdegraded=true
in facts. - When disallowed, the operation fails with
E_EXDEV
and no visible change.
§Locking and Exit Codes (Silver Tier)
- Provide a
LockManager
to serialize apply operations. On timeout, Switchyard emitsapply.attempt
failure with:error_id=E_LOCKING
andexit_code=30
lock_wait_ms
included (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
EnsureSymlink
targets resolve to their sources.
§Determinism and Redaction
plan_id
andaction_id
areUUIDv5
for stable ordering.DryRun
timestamps 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 forDryRun
vsCommit
parity.
§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 anOwnershipOracle
is available. - Attestation bundle scaffolding exists; sensitive fields are masked for canon comparison.
§Testing and Goldens
Run all tests for this crate:
cargo test -p switchyard-fs
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.py
and 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
ApiBuilder
orSwitchyard::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
FactsEmitter
andAuditSink
implementations to integrate with your logging/telemetry stack.JsonlSink
is bundled for development/testing. -
Adapters: Implement or wire in
OwnershipOracle
,LockManager
,PathResolver
,Attestor
, andSmokeTestRunner
as 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.preservation
policy.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 switchyard::api::ApiBuilder;
use switchyard::policy::{Policy, types::ExdevPolicy};
use switchyard::adapters::{FileLockManager, DefaultSmokeRunner};
use switchyard::logging::JsonlSink;
use std::path::PathBuf;
let facts = JsonlSink::default();
let audit = JsonlSink::default();
let mut policy = Policy::production_preset();
policy.apply.exdev = ExdevPolicy::DegradedFallback;
let api = ApiBuilder::new(facts.clone(), audit, policy)
.with_lock_manager(Box::new(FileLockManager::new(PathBuf::from("/var/lock/switchyard.lock"))))
.with_smoke_runner(Box::new(DefaultSmokeRunner::default()))
.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_SMOKE
and 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 switchyard::types::SafePath;
let target = SafePath::from_rooted(root, &root.join("usr/bin/ls"))?;
let res = api.prune_backups(&target)?;
println!("pruned={}, retained={}", res.pruned_count, res.retained_count);
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
|none
prior_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>
):
ls -1a .<name>.*.bak* | sort -t '.' -k 4,4n | tail -n 2
- Read
prior_kind
(andprior_dest
/mode
) from the sidecar:
jq -r '.prior_kind,.prior_dest,.mode' .<name>.<tag>.<millis>.bak.meta.json
- Restore according to
prior_kind
:
- file
mv .<name>.<tag>.<millis>.bak <name>
[ -n "$(jq -r '.mode' .<name>.<tag>.<millis>.bak.meta.json)" ] && \
chmod "$(jq -r '.mode' .<name>.<tag>.<millis>.bak.meta.json)" <name>
sync
- symlink
rm -f <name>
ln -s "$(jq -r '.prior_dest' .<name>.<tag>.<millis>.bak.meta.json)" <name>
sync
- none
rm -f <name>
sync
Notes:
- Relative
prior_dest
values 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; ifjq
is 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_####.md
entry - 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-MIT
at your option.
Minimum Supported Rust Version (MSRV): 1.89 Switchyard: safe, atomic, reversible filesystem swaps.
Safety model highlights:
- All mutations follow a TOCTOU-safe sequence using directory handles (open parent
O_DIRECTORY|O_NOFOLLOW
→ *at on final component → renameat → fsync(parent)). - Public mutating APIs operate on
SafePath
only; internal FS code uses capability-style directory handles. - This crate forbids
unsafe
and usesrustix
for syscalls.
Quickstart (builder is the default way to construct the API):
use switchyard::api::Switchyard;
use switchyard::logging::JsonlSink;
use switchyard::policy::Policy;
let facts = JsonlSink::default();
let audit = JsonlSink::default();
let _api = Switchyard::builder(facts, audit, Policy::default()).build();
Re-exports§
pub use api::*;