Skip to content

Latest commit

 

History

54 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

rubric

Rubric is cohesion tooling for software development in Rust.

Status: Rubric is currently in pre-release. Expect significant breaking changes between minor versions. More feedback from real projects using Rubric is needed for progress to a v1.0.0 stable release.

Installation

Install is handled through Cargo and crates.io

cargo install cargo-rubric

Usage

Rubric allows you to define requirements for your project, track which source code meets a requirement, and register verification evidence against the two:

# examples/gizmo/rubric.toml

[req.gizmo.spins]                       # pathed requirement label
kind = "functional"                     # a function implements this
statement = "The gizmo spins for 100ms" # what this is supposed to do / not do
reconcile = true                        # require vouching for changes
// examples/gizmo/src/main.rs   
use rubric_trace_macros::satisfies;             // Optional annotation macros

// Write some code that satisfies the requirement. Only the label has to match:
pub struct Gizmo {}
impl Gizmo {
    #[satisfies(req.gizmo.spins)]               // Annotate code to match it to a requirement
    pub fn spin(&self, times: usize) -> usize { // Can also use bare comment or cfg_attr forms:
        for _ in 0..times {                     // `// satisfies: req.gizmo.spins` or
            println!("spinnin...")              // `#[cfg_attr(any, satisfies(req.gizmo.spins))]`
        }
        times
    }                                   
}

// Verify the satisfier. It can be an ordinary test, external evidence, or something 
// else. Verifiers don't have to be in the same file, so you can bring in your 
// favorite static analysis tools (like clippy).
pub mod tests {
    use crate::Gizmo;
    use rubric_trace_macros::verifies;
    
    #[test]
    #[verifies(req.gizmo.spins)]                // Match the verifier edge to the req/sat
    fn does_it_spin() {                         // Rubric will now seal the three
        let gizmo = Gizmo{};
        let times = gizmo.spin(10);
        assert!(times == 10,
            "expected to return 10"
        );
    }
}

Rubric collects the three into a triplet and uses content-hashing to record the state of a triplet in its lockfile (rubric.lock). Rubric calls these hashes seals because they enclose content against an address, kind of like an actual wax seal. If the content included in the satisfier or verifier ever changes, the hash will change. The triplet of requirement, satisfier, verifier, can be referred to as a requirement triplet, where its content-hash is called a seal.

Rubric ships a Cargo tool called cargo-rubric. cargo rubric init bootstraps an empty rubric rubric.toml in the current working directory. You can then write whatever requirement, satisfiers, and verifiers you want. Rubric takes care of the tracking.

Once you have at least one requirement - and a matching satisfier and verifier -cargo-rubric should report as much:

user@host:~/gizmo> cargo rubric check
gizmo.spins  pending  statement not yet accepted
gizmo.spins  pending  crate::Gizmo::spin not yet accepted
gizmo.spins  pending  crate::tests::does_it_spin not yet accepted
gizmo.spins  attest   not attested; vouch for changes with `cargo rubric attest`

You can then accept your changes, and optionally vouch for them by running cargo rubric attest. Attestation performs additional tracking apart from cargo rubric accept, presenting an additional audit surface for items.

Accepting will generate a lockfile, rubric.lock:

user@host~/gizmo> cargo rubric accept
+ gizmo.spins (statement)
+ gizmo.spins crate::Gizmo::spin
+ gizmo.spins crate::tests::does_it_spin

rubric: 3 added, 0 re-sealed, 0 removed

This is the simplest case, but Rubric also supports:

  • Tracking the history of the lock over time
  • Generating requirements documentation
  • Adding requirement triplets to existing docs.
  • Alternative sealing behavior (signature, signature + body, full file, etc.)

Sealing Modes and Attestation

When the requirement triplet is sealed, Rubric's default behavior will seal the body of whatever satisfier or verifier entities are annotated. However, Rubric supports other sealing behaviors:

  • stmt: The requirement statement alone
  • sig: An item's signature up to its body block
  • full: An item's signature and its body
  • file: Raw bytes of some file (e.g., external evidence)
  • attest: A requirement's attestation root, hashed over its current leg/edge seals

The variety here makes Rubric deceptively flexible. In particular, file based sealing enables Rubric to support citing outputs from external tooling without requiring an explicit parsing step. Rubric's concern is recording the data and providing visibility for changes scoped to the requirement. External evidence support means Rubric can stay light and focused, letting other tools perform analysis where Rubric tracks that analysis against the code and stated requirements.

Returning to the gizmo example, you can examine the lockfile after accept and observe the seal content:

user@host:~/gizmo> cat rubric.lock 
# rubric.lock — managed by `cargo rubric accept`. Do not edit by hand.
# Format: <requirement_label>\t<item_path>\t<origin>\t<seal>
# <origin> is `annotation` or `declared`.
# <seal> is `<scheme>:<hex>` (e.g. `body:a3f2b1c8`, `stmt:...`) or `off`.

gizmo.spins     <statement>     declared        stmt:8a90d01158c425db
gizmo.spins     crate::Gizmo::spin      annotation      body:697348f1bad4658e
gizmo.spins     crate::tests::does_it_spin      annotation      body:c3a1471434b59809

Here we can see the locked form of each item in the requirement triplet. Earlier on, the attest subcommand and sealing mode were mentioned

If you remember from the Gizmo example's rubric.toml we set reconcile = true. Running cargo rubric check, you might notice that the attestation root for the gizmo.spins requirement isn't present in the lockfile:

user@host:~/gizmo> cargo rubric check
gizmo.spins  attest  not attested; vouch for changes with `cargo rubric attest`

1 delta

This displays because accept will generate any needed seals for an item but not an attestation root.

The attestation root itself is a hash of the requirement triplet's constituent leg seals. This hash-of-hashes is important because it is sensitive to change in the membership set of any legs of the requirement triplet - or more plainly, it enables Rubric to track sets of satisfiers or verifiers tied to one requirement. Since Rubric automatically supports multiple satisfier annotations per requirement, changes to the set of satisfiers will change its respective content-hash in rubric.lock.

If you then run cargo rubric attest on Gizmo, a new line will appear over the existing triplet:

gizmo.spins     <attest>        declared        attest:1dd4df25f7aa79a4

One way to use attestation is as a continuous integration step. To support this, cargo-rubric includes the log and audit subcommands. log will utilize a repository's existing git history to specifically highlight changes to the lockfile and requirements. audit specifically checks commits that carry an attestation root which doesn't match its requirements triplet components. An example where this may become useful would be a crate with a sensitive API surface. Once requirements, satisfiers, and verifiers are written as desired, log and audit become tools for monitoring changes to any of the components. Someone running cargo rubric attest will update an attestation root in the lockfile. This would be a CI signal to key certain review steps on and cargo rubric audit locates this plainly.

Integrating with Other Tools

Rubric supports sealing of external evidence to enable integration with other analysis tools, like (but not limited to) rustdoc or clippy. Suppose you are working on a project that requires tight control over its public API surface. You can point Rubric at that external evidence:

# widget/rubric.toml

[req.api.surface]
kind = "invariant"
statement = "The crate's public API surface is exactly the reviewed set"
verified_by = ["external:docs/public-api.txt"]
reconcile = true

In this example, you might use rustdoc to audit the public API surface and generate a report:

cargo +nightly rustdoc --lib -- -Z unstable-options --output-format json
jq -r '.paths | to_entries[]
        | select(.value.crate_id == 0)
        | "\(.value.kind) \(.value.path | join("::"))"' \
   target/doc/widget.json | sort > docs/public-api.txt
user@host:~/widget> ./gen-surface.sh
user@host:~/widget> cat docs/public-api.txt
function widget::connect
function widget::disconnect
module widget

You might be concerned about the report becoming stale, however. But don't worry, because you have options. For example, you could add a second verifier to the existing requirement:

verified_by = ["external:docs/public-api.txt", "crate::tests::public_api_is_current"]

Then, the other verifier could generate the evidence each time the test is run.

#[cfg(test)]
mod tests {
    use std::process::Command;
    #[test]
    fn public_api_is_current() {
        let status = Command::new("bash").arg("gen-surface.sh").status().expect("gen-surface");
        assert!(status.success(), "surface generation failed");
        let current = std::fs::read_to_string("docs/public-api.txt").unwrap();
        let reviewed = include_str!("../docs/public-api.txt.reviewed");
        assert_eq!(current, reviewed, "public API surface drifted; review and re-accept");
    }
}

If you want a stronger guarantee that the analysis report stays up-to-date, you could also put it in build.rs:

verified_by = ["external:docs/public-api.txt", "external:build.rs"]

Each approach has pros and cons - it's up to you to decide what's best for your project.

Extra Fields

Rubric supports requirement metadata in rubric.toml through the meta blocks. With meta blocks, you can define your own optional keys and values to support items like expanded prose descriptions, rationale notes, change orders, issue IDs, or other data required by your own project's requirements tracing policy. For the gizmo example:

# examples/gizmo/rubric.toml

<SNIP> # existing requirements

[req.gizmo.spins.meta]
rationale = "The downstream sensor must have 100ms of spin to get a stable indication."
change_order = "CO-1234"
description = '''
spin(times) turns the rotor for the given number of cycles and returns
that count. Thus a caller can make sure that all the given cycles occurred.'''

If you accept, attest, and review the lock, you can then see what was tracked:

gizmo.spins	<attest>	declared	attest:644ecd6570b765f5
gizmo.spins	<meta:change_order>	declared	meta:de0c95056e2fe4fa
gizmo.spins	<meta:description>	declared	meta:6645a123421b5ba9
gizmo.spins	<meta:rationale>	declared	meta:787df6885c40d8f4
gizmo.spins	<statement>	declared	stmt:8a90d01158c425db
gizmo.spins	crate::Gizmo::spin	annotation	body:697348f1bad4658e
gizmo.spins	crate::tests::does_it_spin	annotation	body:c3a1471434b59809

Contributing

The project is early-stage and all contributions are welcome.

License

Dual-licensed under either of:

at your option.

About

Requirements and documentation traceability for Rust

Topics

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages