| Crates.io | switchyard-fs |
| lib.rs | switchyard-fs |
| version | 1.0.0 |
| created_at | 2025-09-16 17:03:55.003097+00 |
| updated_at | 2025-09-18 18:24:21.844046+00 |
| description | Switchyard: safe, atomic, reversible filesystem swaps with policy and audit |
| homepage | https://veighnsche.github.io/switchyard/ |
| repository | https://github.com/veighnsche/switchyard |
| max_upload_size | |
| id | 1842066 |
| size | 391,473 |
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:
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.
SafePath and TOCTOU-safe FS ops via capability-style handles (rustix)E_LOCKING and include lock_wait_msplan_id and action_id (UUIDv5)require_rescue verification (BusyBox or ≥6/10 GNU tools on PATH), fail-closed gatessrc/types/: core types (Plan, Action, ApplyMode, SafePath, IDs, reports)src/fs/: filesystem ops (atomic swap, backup/sidecar, restore engine, mount checks)src/policy/: policy Policy config, types, gating (apply parity), and rescue helperssrc/adapters/: integration traits (LockManager, OwnershipOracle, PathResolver, Attestor, SmokeTestRunner) and defaultssrc/api/: facade (Switchyard) delegating to plan, preflight, apply, rollbacksrc/logging/: StageLogger audit facade, FactsEmitter/AuditSink, and redactCargo.toml; MSRV job builds the workspace on 1.89.0)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
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
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();
SafePath and TOCTOU SafetySafePath to avoid path traversal (..) and ensure operations anchor under a known root.O_DIRECTORY|O_NOFOLLOW → openat → renameat → fsync(parent)).action_id, path, current_kind, planned_kind, policy_okpreservation { owner, mode, timestamps, xattrs, acls, caps }, and preservation_supportedrequire_preservation=true but unsupported → STOPrequire_rescue=true and rescue verification fails → STOPrw+exec, immutability, roots/forbid paths, and ownership via OwnershipOracle when strict_ownership=true.allow_degraded_fs=true, Switchyard uses unlink + symlinkat as a best-effort degraded fallback and records degraded=true in facts.E_EXDEV and no visible change.LockManager to serialize apply operations. On timeout, Switchyard emits apply.attempt failure with:
error_id=E_LOCKING and exit_code=30lock_wait_ms included (redacted in canon)E_POLICY, E_ATOMIC_SWAP, E_EXDEV, E_BACKUP_MISSING, E_RESTORE_FAILED, E_SMOKE.SmokeTestRunner. When provided, smoke tests run after apply (Commit mode) and on failure:
E_SMOKEpolicy.disable_auto_rollback=trueEnsureSymlink targets resolve to their sources.plan_id and action_id are UUIDv5 for stable ordering.DryRun timestamps are zeroed (1970-01-01T00:00:00Z); volatile fields like durations and severity are removed; secrets are masked.redact_event() helper to compare facts for DryRun vs Commit parity.ensure_provenance() ensures presence); apply per-action can be enriched with {uid,gid,pkg} when an OwnershipOracle is available.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:
tests/golden/*.SPEC/tools/traceability.py and publishes coverage artifacts.See the mdBook for testing guidance, troubleshooting, and conventions.
file-logging: enables a file‑backed JSONL sink (logging::facts::FileJsonlSink) for facts/audit emission.Construct via ApiBuilder or Switchyard::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.
Facts/Audit sinks: facts, audit
Policy: policy
API instance: api
Builder variable (if kept): builder
Emitters: Provide your own FactsEmitter and AuditSink implementations to integrate with your logging/telemetry stack. JsonlSink is bundled for development/testing.
Adapters: Implement or wire in OwnershipOracle, LockManager, PathResolver, Attestor, and SmokeTestRunner 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.preservationpolicy.governance.{locking, smoke, allow_unlocked_commit}policy.scope.{allow_roots, forbid_paths}policy.backup.tag, policy.retention_count_limit, policy.retention_age_limitUse 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:
E_LOCKING (exit code 30).E_SMOKE and triggers auto‑rollback (unless explicitly disabled by policy).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>.
When the library cannot run (e.g., toolchain broken) you can manually restore using BusyBox/GNU coreutils.
Backup artifacts next to the target use:
.<name>.<tag>.<millis>.bak.<name>.<tag>.<millis>.bak.meta.jsonThe sidecar schema (backup_meta.v1) includes:
prior_kind: file | symlink | noneprior_dest: original symlink destination (for symlink)mode: octal string for file mode (for file)Steps (run in the parent directory of the target):
<millis>):ls -1a .<name>.*.bak* | sort -t '.' -k 4,4n | tail -n 2
prior_kind (and prior_dest/mode) from the sidecar:jq -r '.prior_kind,.prior_dest,.mode' .<name>.<tag>.<millis>.bak.meta.json
prior_kind: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
rm -f <name>
ln -s "$(jq -r '.prior_dest' .<name>.<tag>.<millis>.bak.meta.json)" <name>
sync
rm -f <name>
sync
Notes:
prior_dest values are relative to the parent directory of <name>.busybox jq (or ship a minimal jq) for convenience; if jq is unavailable, you can inspect the JSON manually.SPEC/SPEC.mdSPEC/SPEC_UPDATE_*.mdSPEC_CHECKLIST.mdDOCS/ and INVENTORY/zrefactor/TODO.md, a-test-gaps.mdWhen introducing normative behavior changes:
SPEC_UPDATE_####.md entryDOCS/, INVENTORY/) and checklistThis crate is dual-licensed under either:
LICENSELICENSE-MIT
at your option.Minimum Supported Rust Version (MSRV): 1.89