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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,13 @@ The core value-add. Length-prefixed message framing with channel multiplexing.

### ipcprims-schema

Optional JSON Schema 2020-12 validation at the transport boundary. Behind the `schema` feature flag.
Optional JSON Schema 2020-12 validation at the transport boundary. Attach a
channel-keyed `SchemaRegistry` to validate registered payloads, or load
schemas from a directory with explicit strictness and missing-schema policy.
The default configuration remains permissive for unregistered channels; see the
[Schema Registry Guide](docs/guides/schema-registry.md) for configuration,
directory-loading rules, peer integration, and platform limitations. The peer
integration is behind the `schema` feature flag.

### ipcprims-peer

Expand Down
42 changes: 42 additions & 0 deletions bindings/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,45 @@ if (!ok) {
```

`crypto.timingSafeEqual()` throws on unequal lengths, so check the length first and reject cleanly. Do not use `==`, string comparison, or `Buffer.equals()` for token authorization.

## Schema Registry

Load a schema directory for standalone validation with `SchemaRegistry`. It
uses the Rust registry's default configuration, so a channel without a schema
passes without validation. TypeScript does not currently expose strict mode,
missing-schema rejection, or programmatic schema registration.

```ts
import { COMMAND, SchemaRegistry } from "@3leaps/ipcprims";

const registry = SchemaRegistry.fromDirectory("/opt/example/schemas");
registry.validate(COMMAND, Buffer.from('{"id":7}'));
registry.close();
```

`close()` is safe to call more than once. Calling `validate()` after closing the
registry throws because the native registry is no longer available.

Both `Listener.bind()` and `AsyncListener.bind()` accept `schemaDir` in their
options. Each loads and attaches its own registry with the same default-only
configuration:

```ts
import { AsyncListener, COMMAND, Listener } from "@3leaps/ipcprims";

const listener = Listener.bind("/tmp/ipcprims.sock", {
channels: [COMMAND],
schemaDir: "/opt/example/schemas",
});

const asyncListener = AsyncListener.bind("/tmp/ipcprims-async.sock", {
channels: [COMMAND],
schemaDir: "/opt/example/schemas",
});
```

The standalone `SchemaRegistry` cannot be attached to a TypeScript peer or
listener. Use `schemaDir` when a TypeScript listener needs validation, and do
not assume it rejects unregistered channels. See the repository's
[Schema Registry Guide](https://github.com/3leaps/ipcprims/blob/main/docs/guides/schema-registry.md)
for Rust configuration, directory rules, and platform-specific protections.
24 changes: 19 additions & 5 deletions crates/ipcprims-schema/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
/// Controls schema validation behavior.
/// Controls schema registration, validation, and directory-loading behavior.
///
/// The default is deliberately permissive: unregistered channels validate
/// successfully without parsing their payload. Use both [`Self::strict_mode`]
/// and [`Self::fail_on_missing_schema`] when an application requires the
/// stricter path.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RegistryConfig {
/// When true, schemas reject additional properties not in the schema.
/// Apply the strict transform while schemas are registered or loaded.
///
/// The transform adds `additionalProperties: false` only to recognized
/// object-like schema locations that do not already specify that keyword.
/// An explicit non-object `type` takes precedence over object-keyword
/// detection. Explicit schema policy is preserved, and unrecognized JSON
/// Schema constructs are not rewritten.
pub strict_mode: bool,
/// When true, channels without a schema return `SchemaError::NoSchema`.
/// Reject channels without a registered schema.
///
/// When false, validation for an unregistered channel succeeds without
/// parsing the payload as JSON.
pub fail_on_missing_schema: bool,
/// Maximum number of schemas loaded from a directory.
/// Maximum number of recognized schemas loaded from a directory.
pub max_schemas_from_directory: usize,
/// Maximum bytes allowed per schema file loaded from a directory.
/// Maximum bytes allowed per recognized schema file loaded from a directory.
pub max_schema_file_size: usize,
}

Expand Down
12 changes: 6 additions & 6 deletions crates/ipcprims-schema/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
/// Errors that can occur during schema validation.
/// Errors that can occur while loading, compiling, or validating schemas.
#[derive(Debug, thiserror::Error)]
pub enum SchemaError {
/// The schema file could not be loaded.
/// A schema directory or file violated loading policy or could not be read.
#[error("failed to load schema: {0}")]
LoadFailed(String),

/// The schema could not be compiled.
/// A parsed JSON schema could not be compiled.
#[error("failed to compile schema: {0}")]
CompileFailed(String),

/// The payload failed schema validation.
/// A payload parsed successfully but failed its channel schema.
#[error("validation failed on channel {channel}: {message}")]
ValidationFailed { channel: u16, message: String },

/// The payload is not valid JSON.
/// Schema or payload JSON could not be parsed.
#[error("payload is not valid JSON: {0}")]
InvalidJson(#[from] serde_json::Error),

/// No schema registered for the given channel.
/// No schema is registered for the given channel while missing schemas fail.
#[error("no schema registered for channel {0}")]
NoSchema(u16),
}
Expand Down
8 changes: 1 addition & 7 deletions crates/ipcprims-schema/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
//! Optional JSON Schema validation at the IPC transport boundary.
//!
//! Validate messages against JSON Schema 2020-12 at the frame level.
//! Catch contract violations before they become bugs.
//!
//! This crate is optional — use it when you want schema-enforced
//! message contracts between peers.
#![doc = include_str!("../../../docs/guides/schema-registry.md")]

pub mod config;
pub mod error;
Expand Down
50 changes: 39 additions & 11 deletions crates/ipcprims-schema/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,32 +11,43 @@ use crate::error::{Result, SchemaError};
use crate::validator::validate_payload;

/// Channel-keyed registry of compiled JSON Schema validators.
///
/// Registrations replace an existing validator for the same channel. With the
/// default configuration, validation for a channel without a registered schema
/// succeeds without parsing the payload. See [`RegistryConfig`] for the strict
/// and missing-schema controls.
pub struct SchemaRegistry {
validators: HashMap<u16, Validator>,
config: RegistryConfig,
}

impl SchemaRegistry {
/// Create an empty registry with default config.
/// Create an empty registry with the permissive default configuration.
pub fn new() -> Self {
Self::with_config(RegistryConfig::default())
}

/// Create an empty registry with explicit config.
/// Create an empty registry with explicit configuration.
pub fn with_config(config: RegistryConfig) -> Self {
Self {
validators: HashMap::new(),
config,
}
}

/// Register a schema for a channel from a JSON string.
/// Register a JSON schema for a channel.
///
/// A successful registration replaces any existing schema for `channel`.
/// When strict mode is enabled, the strict transform is applied before the
/// schema is compiled.
pub fn register(&mut self, channel: u16, schema_json: &str) -> Result<()> {
let schema: Value = serde_json::from_str(schema_json)?;
self.register_value(channel, &schema)
}

/// Register a schema for a channel from JSON value.
/// Register a parsed JSON schema for a channel.
///
/// A successful registration replaces any existing schema for `channel`.
pub fn register_value(&mut self, channel: u16, schema: &Value) -> Result<()> {
let mut schema_to_compile = schema.clone();
if self.config.strict_mode {
Expand All @@ -50,12 +61,25 @@ impl SchemaRegistry {
Ok(())
}

/// Load schemas from a directory.
/// Load recognized schemas from a directory with the default configuration.
pub fn from_directory(path: &Path) -> Result<Self> {
Self::from_directory_with_config(path, RegistryConfig::default())
}

/// Load schemas from a directory with explicit config.
/// Load recognized schemas from a directory with explicit configuration.
///
/// Canonical filenames are `channel_<N>.schema.json` and the built-in
/// `control`, `command`, `data`, `telemetry`, and `error` names. Non-schema
/// files are ignored; an unrecognized `*.schema.json` file is an error.
/// Canonical lowercase schema symlinks are rejected. The current symlink
/// check is literal-case-sensitive, so non-canonical schema symlinks are
/// skipped rather than rejected. Recognized files are count- and
/// size-bounded. On Unix the loader also compares `(dev, ino)` from path
/// metadata with the opened file; Windows does not yet have that
/// opened-file identity check.
///
/// Do not provide more than one recognized filename for a channel. Later
/// registrations replace earlier ones in unspecified directory order.
pub fn from_directory_with_config(path: &Path, config: RegistryConfig) -> Result<Self> {
let mut registry = Self::with_config(config);
let mut loaded_schema_count = 0usize;
Expand Down Expand Up @@ -154,7 +178,7 @@ impl SchemaRegistry {
Ok(registry)
}

/// Load from embedded schema strings.
/// Load from embedded schema strings with the default configuration.
pub fn from_embedded(schemas: &[(u16, &str)]) -> Result<Self> {
let mut registry = Self::new();
for (channel, schema) in schemas {
Expand All @@ -163,7 +187,11 @@ impl SchemaRegistry {
Ok(registry)
}

/// Validate channel payload against its schema.
/// Validate a channel payload against its registered schema.
///
/// With the default configuration, an unregistered channel succeeds
/// without parsing `payload`. When `fail_on_missing_schema` is enabled,
/// the same channel returns [`SchemaError::NoSchema`] before parsing.
pub fn validate(&self, channel: u16, payload: &[u8]) -> Result<()> {
match self.validators.get(&channel) {
Some(validator) => validate_payload(channel, payload, validator),
Expand All @@ -177,19 +205,19 @@ impl SchemaRegistry {
self.validate(frame.channel, frame.payload.as_ref())
}

/// Check if a channel has a registered schema.
/// Return whether a channel has a registered schema.
pub fn has_schema(&self, channel: u16) -> bool {
self.validators.contains_key(&channel)
}

/// Get channels that have registered schemas.
/// Return registered channels in ascending order.
pub fn channels(&self) -> Vec<u16> {
let mut channels: Vec<u16> = self.validators.keys().copied().collect();
channels.sort_unstable();
channels
}

/// Get registry configuration.
/// Return the configuration used by this registry.
pub fn config(&self) -> &RegistryConfig {
&self.config
}
Expand Down
Loading
Loading