Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions core/CODING_STANDARDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ Drop a `std::sync` lock before `.await`. Use `tokio::sync::Mutex` when the guard

Generics (`T: Trait` / `impl Trait`) until a mixed collection or object-safe trait needs `dyn`. Prefer `&dyn Trait` when the callee does not need ownership. Own the `Box`/`Arc` at that boundary, not inside the module.

## Decisions

A decision a model checks lives in `ployz-decide` as a pure function; the model calls it, never re-states it. Statement order across processes (the upgrade receipt and marker, the credential temp file and rename, pool ownership, removal marks, volume marks, Cloud's TypeScript walks) stays a model and links to the code by symbol path.

## Macros

Reuse the existing newtype and RPC macros for repeated mechanical shapes. A one-off stays a function or generic.
Expand Down
9 changes: 9 additions & 0 deletions core/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
members = ["crates/ployz-core", "crates/ployz-build", "crates/ployz", "crates/ployz-sdk", "crates/ployz-config-wasm", "crates/ployz-store", "crates/ployzd", "crates/ployz-testkit", "crates/ployz-verify"]
default-members = ["crates/ployz-core", "crates/ployz-build", "crates/ployz", "crates/ployz-sdk", "crates/ployz-store", "crates/ployzd"]
members = ["crates/ployz-core", "crates/ployz-decide", "crates/ployz-build", "crates/ployz", "crates/ployz-sdk", "crates/ployz-config-wasm", "crates/ployz-store", "crates/ployzd", "crates/ployz-testkit", "crates/ployz-verify"]
default-members = ["crates/ployz-core", "crates/ployz-decide", "crates/ployz-build", "crates/ployz", "crates/ployz-sdk", "crates/ployz-store", "crates/ployzd"]
resolver = "3"

[workspace.package]
Expand All @@ -14,6 +14,7 @@ license = "Apache-2.0"
inotify = "0.11.5"
ployz-build = { path = "crates/ployz-build" }
ployz-core = { path = "crates/ployz-core" }
ployz-decide = { path = "crates/ployz-decide" }
ployz-store = { path = "crates/ployz-store" }
ployz-testkit = { path = "crates/ployz-testkit" }
ts-rs = { version = "12.0.1", features = ["serde-compat", "serde-json-impl", "no-serde-warnings"] }
Expand All @@ -36,6 +37,9 @@ debug = "line-tables-only"
[profile.dev.package.ployz-verify]
opt-level = 3

[profile.dev.package.ployz-decide]
opt-level = 3

[profile.dev.package.stateright]
opt-level = 3

Expand Down
1 change: 1 addition & 0 deletions core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ there is no automatic reclamation, cross-store synchronization, or subnet repair
Run Cargo and engine script commands from `core/`.

- `crates/ployz-core`: domain and wire contracts shared by both binaries
- `crates/ployz-decide`: the decisions a model check calls, pure over plain facts
- `crates/ployz`: CLI for Linux, macOS, and Windows through WSL
- `crates/ployz-store`: the Config Store, all authored configuration and its history behind one read/write interface, served to Cloud through `@ployz/sdk`
- `crates/ployz-build`: BuildKit execution for one captured Build
Expand Down
67 changes: 67 additions & 0 deletions core/crates/ployz-core/src/authority/canonical.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,77 @@ pub(super) fn write_hex(bytes: &[u8], formatter: &mut fmt::Formatter<'_>) -> fmt
.try_for_each(|byte| write!(formatter, "{byte:02x}"))
}

/// `deserialize_with` for a list a signed body keeps strictly ascending, so a set of
/// elements has one encoding and one signature input.
///
/// # Errors
/// Returns a deserialize error when the list is not sorted and distinct.
pub(super) fn sorted_distinct<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de> + Ord,
{
sorted_by(deserializer, |item: &T| item)
}

/// [`sorted_distinct`] for elements ordered by one field of theirs, such as a Service's name.
///
/// # Errors
/// Returns a deserialize error when the keys are not strictly ascending.
pub(super) fn sorted_by<'de, D, T, K>(
deserializer: D,
key: fn(&T) -> &K,
) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
K: Ord + ?Sized,
{
let items = Vec::<T>::deserialize(deserializer)?;
if !items.is_sorted_by(|left, right| key(left) < key(right)) {
return Err(serde::de::Error::custom("must be sorted and distinct"));
}
Ok(items)
}

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

#[derive(Debug, Deserialize)]
struct Lists {
#[serde(deserialize_with = "sorted_distinct")]
plain: Vec<u8>,
#[serde(deserialize_with = "by_first")]
pairs: Vec<(u8, u8)>,
}

fn by_first<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<(u8, u8)>, D::Error> {
sorted_by(deserializer, |pair: &(u8, u8)| &pair.0)
}

#[test]
fn a_list_must_be_sorted_and_distinct() {
let parse = |text: &str| serde_json::from_str::<Lists>(text);
let lists = parse(r#"{"plain":[1,2],"pairs":[[1,9],[2,0]]}"#).unwrap();
assert_eq!(
(lists.plain, lists.pairs),
(vec![1, 2], vec![(1, 9), (2, 0)])
);
assert!(parse(r#"{"plain":[],"pairs":[]}"#).is_ok());
for text in [
r#"{"plain":[2,1],"pairs":[]}"#,
r#"{"plain":[1,1],"pairs":[]}"#,
r#"{"plain":[],"pairs":[[2,0],[1,0]]}"#,
r#"{"plain":[],"pairs":[[1,0],[1,9]]}"#,
] {
let error = parse(text).unwrap_err().to_string();
assert!(error.starts_with("must be sorted and distinct"), "{error}");
}
}

#[test]
fn sorts_keys_and_drops_whitespace() {
let value = serde_json::json!({"b": 1, "a": [true, "x"], "A": {"z": 0}});
Expand Down
37 changes: 8 additions & 29 deletions core/crates/ployz-core/src/authority/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use super::{
canonical::{self, CanonicalError, Timestamp, hex_bytes, write_hex},
keys::{Approver, Purpose, Signed, Statement},
proposal::{self, ProposalRef, StepBody, StepKind},
refusal::AuthorityRefusal,
refusal::{AuthorityRefusal, field, first_mismatch},
restart::SignerKey,
session::{ActionFamily, GrantScope},
trusted_key::TrustedKeys,
Expand Down Expand Up @@ -285,7 +285,7 @@ pub struct DeployApproval {
pub operation: OperationId,
pub namespace: Namespace,
/// Sorted and distinct.
#[serde(deserialize_with = "sorted_distinct")]
#[serde(deserialize_with = "canonical::sorted_distinct")]
pub machines: Vec<MachineId>,
/// Sorted by name and distinct.
#[serde(deserialize_with = "services_by_name")]
Expand Down Expand Up @@ -466,28 +466,10 @@ impl DeployApproval {
}
}

fn sorted_distinct<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<MachineId>, D::Error> {
let machines = Vec::<MachineId>::deserialize(deserializer)?;
if !machines.is_sorted_by(|left, right| left < right) {
return Err(serde::de::Error::custom(
"Machines must be sorted and distinct",
));
}
Ok(machines)
}

fn services_by_name<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<ApprovedService>, D::Error> {
let services = Vec::<ApprovedService>::deserialize(deserializer)?;
if !services.is_sorted_by(|left, right| left.name < right.name) {
return Err(serde::de::Error::custom(
"Services must be sorted by name and distinct",
));
}
Ok(services)
canonical::sorted_by(deserializer, |service: &ApprovedService| &service.name)
}

/// A Container this Machine is asked to create.
Expand Down Expand Up @@ -543,17 +525,14 @@ pub fn verify(
}
None => open_signed(approval, facts)?,
};
let checks = [
("a Cluster", body.cluster == cluster),
first_mismatch([
(field::CLUSTER, body.cluster == cluster),
(
"Machines",
field::MACHINES,
body.machines.binary_search(facts.machine).is_ok(),
),
("an Environment", &body.namespace == facts.namespace),
];
if let Some((field, _)) = checks.iter().find(|(_, holds)| !holds) {
return Err(AuthorityRefusal::Mismatch { field });
}
(field::ENVIRONMENT, &body.namespace == facts.namespace),
])?;
if let Approver::Browser { grant, .. } = &by {
grant.body.covers(&GrantScope {
cluster,
Expand Down
15 changes: 7 additions & 8 deletions core/crates/ployz-core/src/authority/join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use ts_rs::TS;

use super::{AuthorityRefusal, Purpose, Signed, SigningPublicKey, Statement, Timestamp};
use super::{
AuthorityRefusal, Purpose, Signed, SigningPublicKey, Statement, Timestamp,
refusal::{field, first_mismatch},
};
use crate::{DeviceKey, Machine, MachineId, SealingPublicKey, WireGuardPublicKey};

/// The immutable enrollment allocation. Runtime observations confer no authority.
Expand Down Expand Up @@ -158,8 +161,8 @@ pub fn verify(
now: Timestamp,
) -> Result<JoinApproval, AuthorityRefusal> {
let body = verify_signature(signed, devices, now)?;
for (field, matches) in [
("a Cluster", body.cluster == cluster),
first_mismatch([
(field::CLUSTER, body.cluster == cluster),
(
"approved Devices",
body.devices.contains(&body.signer)
Expand All @@ -177,11 +180,7 @@ pub fn verify(
"a sealing recipient",
body.sealing_recipient == machine.public_key,
),
] {
if !matches {
return Err(AuthorityRefusal::Mismatch { field });
}
}
])?;
Ok(body)
}

Expand Down
15 changes: 6 additions & 9 deletions core/crates/ployz-core/src/authority/proposal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use ts_rs::TS;
use super::{
canonical::{Timestamp, hex_bytes, write_hex},
keys::{Approver, Purpose, Signed, Statement},
refusal::AuthorityRefusal,
refusal::{AuthorityRefusal, field, first_mismatch},
restart::{Approved, RestartApproval, RestartFacts, SignerKey, WorkflowKind},
session::GrantScope,
trusted_key::TrustedKeys,
Expand Down Expand Up @@ -219,7 +219,7 @@ pub(super) fn open_step<T: StepBody>(
.place()
.filter(|place| place.operation == plan.operation);
let listed = place.and_then(|place| plan.steps.get(place.index as usize));
let checks = [
first_mismatch([
("the signer", by.is_some() && body.signer() == plan.signer),
("a proposal", place.is_some()),
(
Expand All @@ -228,16 +228,13 @@ pub(super) fn open_step<T: StepBody>(
listed.kind == body.kind() && listed.digest == StepDigest::of(&step.body)
}),
),
("a Cluster", plan.cluster == body.cluster()),
("an Environment", &plan.environment == body.namespace()),
(field::CLUSTER, plan.cluster == body.cluster()),
(field::ENVIRONMENT, &plan.environment == body.namespace()),
(
"a window",
field::WINDOW,
(plan.issued_at, plan.expires_at) == body.window(),
),
];
if let Some((field, _)) = checks.iter().find(|(_, holds)| !holds) {
return Err(AuthorityRefusal::Mismatch { field });
}
])?;
let by = by.expect("the signer check passed");
Ok((body, by, cluster))
}
Expand Down
21 changes: 21 additions & 0 deletions core/crates/ployz-core/src/authority/refusal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,24 @@ pub enum AuthorityRefusal {
#[error("this GitHub token already minted a Build Grant; a build asks for a new one")]
ProofReplayed,
}

/// The first of `checks` that does not hold, as a [`AuthorityRefusal::Mismatch`] naming its field.
///
/// # Errors
/// Returns [`AuthorityRefusal::Mismatch`] for the first `false` check, in order.
pub(super) fn first_mismatch<const N: usize>(
checks: [(&'static str, bool); N],
) -> Result<(), AuthorityRefusal> {
match checks.into_iter().find(|(_, holds)| !holds) {
Some((field, _)) => Err(AuthorityRefusal::Mismatch { field }),
None => Ok(()),
}
}

/// The names `Mismatch` gives the parts of a request that more than one statement checks.
pub(super) mod field {
pub const CLUSTER: &str = "a Cluster";
pub const ENVIRONMENT: &str = "an Environment";
pub const MACHINES: &str = "Machines";
pub const WINDOW: &str = "a window";
}
36 changes: 8 additions & 28 deletions core/crates/ployz-core/src/authority/restart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ use serde::{Deserialize, Serialize};
use ts_rs::TS;

use super::{
canonical::Timestamp,
canonical::{self, Timestamp},
keys::{Approver, Purpose, Signed, SigningPublicKey, Statement},
proposal::ProposalRef,
refusal::AuthorityRefusal,
refusal::{AuthorityRefusal, field, first_mismatch},
session::{ActionFamily, GrantScope},
trusted_key::TrustedKeys,
};
Expand Down Expand Up @@ -100,7 +100,7 @@ pub struct RestartApproval {
pub namespace: Namespace,
pub service: ServiceName,
/// Sorted and distinct.
#[serde(deserialize_with = "ordered_machines")]
#[serde(deserialize_with = "canonical::sorted_distinct")]
pub machines: Vec<MachineId>,
pub container: ContainerId,
pub issued_at: Timestamp,
Expand All @@ -124,42 +124,22 @@ impl RestartApproval {
cluster: SealingPublicKey,
facts: &RestartFacts<'_>,
) -> Result<(), AuthorityRefusal> {
let checks = [
("a Cluster", self.cluster == cluster),
first_mismatch([
(field::CLUSTER, self.cluster == cluster),
(
"a workflow",
self.kind.permits(facts.step) && self.kind.reaches(facts.kind),
),
("an Environment", &self.namespace == facts.namespace),
(field::ENVIRONMENT, &self.namespace == facts.namespace),
("a Service", &self.service == facts.service),
("Machines", self.machines.contains(facts.machine)),
(field::MACHINES, self.machines.contains(facts.machine)),
("a Container", &self.container == facts.container),
];
match checks.iter().find(|(_, holds)| !holds) {
Some((field, _)) => Err(AuthorityRefusal::Mismatch { field }),
None => Ok(()),
}
])
}
}

pub use super::validity::{CLOCK_SKEW_MILLIS, MAX_LIFETIME_MILLIS};

pub(super) fn ordered_machines<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<MachineId>, D::Error> {
let machines = Vec::<MachineId>::deserialize(deserializer)?;
if machines
.iter()
.zip(machines.iter().skip(1))
.any(|(left, right)| left >= right)
{
return Err(serde::de::Error::custom(
"Machines must be sorted and distinct",
));
}
Ok(machines)
}

impl Statement for RestartApproval {
const PURPOSE: Purpose = Purpose::Workflow;
}
Expand Down
Loading
Loading