boundgraph-grant 0.1.0

The BoundGraph permission lattice: containment, meet and join, normalisation, and the disjointness solver behind Law I.
Documentation
//! Read or write: which side of the disjointness condition an action falls on.
//!
//! `par` may run two branches concurrently only if they cannot interfere. Two
//! branches reading the same file interfere with nothing; one writing while the
//! other reads is a race. Deciding that needs each action classified.
//!
//! # Conservative by default
//!
//! An unrecognised action is classified as a **write**. That is Goal G5 applied
//! here: misclassifying a write as a read admits a real data race, while
//! misclassifying a read as a write costs only some lost parallelism. The
//! failure modes are not symmetric, so the default is not neutral.

/// Whether an action observes a resource or changes it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Access {
    /// Observes without changing. Two reads never conflict.
    Read,
    /// May change the resource, or have an effect beyond it.
    Write,
}

impl Access {
    /// Whether this is a write.
    pub fn is_write(self) -> bool {
        self == Self::Write
    }
}

/// Actions known to observe without changing anything.
const READ_ACTIONS: &[&str] = &["read", "list", "stat", "now", "bytes", "use"];

/// Actions known to change something, or to have an effect beyond the process.
const WRITE_ACTIONS: &[&str] = &[
    "write", "append", "create", "delete", "truncate", "exec", "egress",
];

/// Classify an action.
///
/// Note two calls that may look surprising, both deliberate:
///
/// - `exec` is a **write**. Running a process can do anything the process can.
/// - `egress` is a **write**. Sending a request may change remote state, so two
///   branches talking to the same host are not provably independent.
///
/// And `use`, as in `secret:use:stripe_key`, is a **read**: two branches using
/// the same credential do not interfere with each other.
pub fn classify(action: &str) -> Access {
    if READ_ACTIONS.contains(&action) {
        Access::Read
    } else if WRITE_ACTIONS.contains(&action) {
        Access::Write
    } else {
        // Unrecognised: assume the more dangerous reading.
        Access::Write
    }
}

/// Whether the action is one this crate recognises.
///
/// An unrecognised action still classifies (as a write); this only says whether
/// the classification came from knowledge or from the conservative default,
/// which is worth surfacing in a diagnostic.
pub fn is_known_action(action: &str) -> bool {
    READ_ACTIONS.contains(&action) || WRITE_ACTIONS.contains(&action)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn observing_actions_are_reads() {
        for a in ["read", "list", "stat", "now", "bytes", "use"] {
            assert_eq!(classify(a), Access::Read, "{a} should be a read");
        }
    }

    #[test]
    fn mutating_actions_are_writes() {
        for a in ["write", "append", "create", "delete", "exec", "egress"] {
            assert_eq!(classify(a), Access::Write, "{a} should be a write");
        }
    }

    #[test]
    fn an_unknown_action_is_assumed_to_write() {
        assert_eq!(classify("frobnicate"), Access::Write);
        assert!(!is_known_action("frobnicate"));
        assert!(is_known_action("read"));
    }

    #[test]
    fn executing_a_process_counts_as_writing() {
        // A subprocess can do anything its capabilities allow, so two branches
        // that both exec are not provably independent.
        assert_eq!(classify("exec"), Access::Write);
    }

    #[test]
    fn network_egress_counts_as_writing() {
        assert_eq!(classify("egress"), Access::Write);
    }

    #[test]
    fn using_a_secret_counts_as_reading() {
        assert_eq!(classify("use"), Access::Read);
    }
}