From 56ed1449572dbc8a33fc772c010a6721f56495c7 Mon Sep 17 00:00:00 2001 From: Dave Thompson Date: Mon, 24 Aug 2026 16:39:09 -0400 Subject: [PATCH] docs(schema): document registry behavior Document schema configuration, loading rules, peer integration, and TypeScript limitations. Verification: make prepush Generated by GPT-5.6 Terra via OpenCode under supervision of @3leapsdave Co-Authored-By: GPT-5.6 Terra Role: devlead Committer-of-Record: Dave Thompson [@3leapsdave] --- README.md | 8 +- bindings/typescript/README.md | 42 +++++ crates/ipcprims-schema/src/config.rs | 24 ++- crates/ipcprims-schema/src/error.rs | 12 +- crates/ipcprims-schema/src/lib.rs | 8 +- crates/ipcprims-schema/src/registry.rs | 50 ++++-- docs/guides/schema-registry.md | 220 +++++++++++++++++++++++++ 7 files changed, 334 insertions(+), 30 deletions(-) create mode 100644 docs/guides/schema-registry.md diff --git a/README.md b/README.md index 47cfa45..08f7176 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bindings/typescript/README.md b/bindings/typescript/README.md index e653f7b..982d972 100644 --- a/bindings/typescript/README.md +++ b/bindings/typescript/README.md @@ -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. diff --git a/crates/ipcprims-schema/src/config.rs b/crates/ipcprims-schema/src/config.rs index 731ef88..ad53c11 100644 --- a/crates/ipcprims-schema/src/config.rs +++ b/crates/ipcprims-schema/src/config.rs @@ -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, } diff --git a/crates/ipcprims-schema/src/error.rs b/crates/ipcprims-schema/src/error.rs index 5aef99a..8595317 100644 --- a/crates/ipcprims-schema/src/error.rs +++ b/crates/ipcprims-schema/src/error.rs @@ -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), } diff --git a/crates/ipcprims-schema/src/lib.rs b/crates/ipcprims-schema/src/lib.rs index d5377e2..ddddf9d 100644 --- a/crates/ipcprims-schema/src/lib.rs +++ b/crates/ipcprims-schema/src/lib.rs @@ -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; diff --git a/crates/ipcprims-schema/src/registry.rs b/crates/ipcprims-schema/src/registry.rs index 02ece8e..0fdb8f9 100644 --- a/crates/ipcprims-schema/src/registry.rs +++ b/crates/ipcprims-schema/src/registry.rs @@ -11,18 +11,23 @@ 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, 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(), @@ -30,13 +35,19 @@ impl SchemaRegistry { } } - /// 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 { @@ -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::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_.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 { let mut registry = Self::with_config(config); let mut loaded_schema_count = 0usize; @@ -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 { let mut registry = Self::new(); for (channel, schema) in schemas { @@ -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), @@ -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 { let mut channels: Vec = 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 } diff --git a/docs/guides/schema-registry.md b/docs/guides/schema-registry.md new file mode 100644 index 0000000..508a8da --- /dev/null +++ b/docs/guides/schema-registry.md @@ -0,0 +1,220 @@ +# Schema Registry Guide + +`ipcprims-schema` validates JSON payloads by channel at the IPC boundary. It +uses JSON Schema 2020-12 and is optional: applications that do not attach a +registry perform no schema validation. + +## Start With The Default + +`RegistryConfig::default()` is deliberately permissive: + +| Setting | Default | Effect | +| ---------------------------- | --------- | ----------------------------------------------------------------------- | +| `strict_mode` | `false` | Schemas are compiled without the strict transform. | +| `fail_on_missing_schema` | `false` | An unregistered channel succeeds without validation. | +| `max_schemas_from_directory` | `256` | Directory loading fails when this many recognized schemas are exceeded. | +| `max_schema_file_size` | `256 KiB` | Each recognized schema has this maximum size. | + +With the default configuration, a channel without a registered schema receives +**no validation at all**. `SchemaRegistry::validate` returns success for that +channel without parsing the payload as JSON. This fail-open default is a +compatibility contract. Use both `strict_mode` and `fail_on_missing_schema` +when an application requires the stricter path. + +## Setup And Registration + +Add the schema crate directly when validation is needed: + +```toml +[dependencies] +ipcprims-schema = "0.2.4" +``` + +Create a registry, then register JSON Schema text or a parsed +`serde_json::Value`. A successful registration for an existing channel replaces +that channel's validator. + +```rust +use ipcprims_schema::{RegistryConfig, SchemaError, SchemaRegistry}; + +let mut registry = SchemaRegistry::with_config(RegistryConfig { + strict_mode: true, + fail_on_missing_schema: true, + ..RegistryConfig::default() +}); + +registry.register( + 1, + r#"{ + "type": "object", + "properties": { "id": { "type": "integer" } }, + "required": ["id"] + }"#, +)?; + +registry.validate(1, br#"{"id": 7}"#)?; +assert!(registry.validate(1, br#"{"id": 7, "extra": true}"#).is_err()); + +// Missing schemas fail before this non-JSON payload is parsed. +assert!(matches!( + registry.validate(2, b"not json"), + Err(SchemaError::NoSchema(2)) +)); +# Ok::<(), SchemaError>(()) +``` + +Use `SchemaRegistry::from_embedded` for a fixed list of schema strings. It +always uses the default configuration. Use `with_config` followed by `register`, +or `from_directory_with_config`, when embedded or loaded schemas need the +stricter path. + +`validate_frame` validates an `ipcprims_frame::Frame`; `has_schema`, +`channels`, and `config` expose the current registry state. + +## Strict Mode And Missing Schemas + +`strict_mode` is a registration-time schema transform, not a universal +deny-unknown guarantee. For recognized object-like schema locations, it inserts +`"additionalProperties": false` only when that keyword is absent. An explicit +`additionalProperties` value, including `true` or a schema, is preserved. + +An object-like schema is recognized by `type: "object"`, a type array containing +`"object"`, or, when `type` is absent, an object keyword such as `properties`, +`patternProperties`, `additionalProperties`, `unevaluatedProperties`, +`required`, `dependentRequired`, `dependentSchemas`, or `propertyNames`. An +explicit non-object `type` takes precedence over the object-keyword fallback. +The transform recurses through the supported structural keywords: `properties`, +`patternProperties`, `dependentSchemas`, `$defs`, `definitions`, +`propertyNames`, `additionalProperties`, `unevaluatedProperties`, `items`, +`contains`, `additionalItems`, `unevaluatedItems`, `not`, `if`, `then`, `else`, +`prefixItems`, `allOf`, `anyOf`, and `oneOf`. + +This is best-effort hardening of those recognized locations. It does not make +every JSON Schema construct deny unknown properties. Pair it with +`fail_on_missing_schema: true` when unregistered channels must be rejected. + +For a registered channel, invalid JSON returns `SchemaError::InvalidJson`; a +payload that fails its schema returns `SchemaError::ValidationFailed`. Invalid +schema JSON returns `InvalidJson`, while schema compilation failures return +`CompileFailed`. + +## Loading A Directory + +`SchemaRegistry::from_directory` uses defaults. +`SchemaRegistry::from_directory_with_config` applies an explicit configuration. +Use canonical lowercase filenames: + +| Filename | Channel | +| ------------------------- | ----------------------------------------: | +| `control.schema.json` | 0 | +| `command.schema.json` | 1 | +| `data.schema.json` | 2 | +| `telemetry.schema.json` | 3 | +| `error.schema.json` | 4 | +| `channel_.schema.json` | Numeric channel `N` (`0` through `65535`) | + +The loader ignores ordinary non-schema files and non-file entries. A canonical +lowercase schema-file symlink is rejected. A filename ending in `.schema.json` +that does not map to a supported channel is a load error rather than an ignored +file. + +Use the canonical lowercase names exactly. Channel resolution normalizes ASCII +case, but the current symlink rejection checks only the literal lowercase +`.schema.json` suffix. A symlink with a differently cased suffix, such as +`command.SCHEMA.JSON`, is skipped rather than rejected; with the default +fail-open configuration, the missing channel then receives no validation. +Treat non-canonical filename case as unsupported for security-sensitive schema +directories. + +Recognized schema files are subject to the configured count limit and size +limit. The loader checks the opened file's reported length and also reads at +most one byte beyond the configured size, so a file that grows while being read +cannot bypass the bound. + +Avoid duplicate aliases for a channel. For example, `command.schema.json` and +`channel_1.schema.json` map to the same channel. The loader replaces an earlier +validator with a later successful registration, and directory enumeration order +is unspecified. A permissive schema can therefore nondeterministically replace +a stricter schema. Keep exactly one schema filename per channel. + +### File Identity By Platform + +| Platform | Protection during schema loading | Residual limitation | +| -------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Unix | Canonical lowercase schema symlinks are rejected and path metadata is compared with the opened file using `(dev, ino)`. | A path-to-open replacement is detected before the file is read. Symlinks with a differently cased suffix are skipped rather than rejected. | +| Windows | Canonical lowercase schema symlinks are rejected and the count/size-bounded read protections apply. | Opened-file identity is not yet compared. A local file swap between path metadata and open is not detected; symlinks with a differently cased suffix are skipped rather than rejected. | + +The accepted Windows file-identity design is not yet implemented. Do not rely +on directory loading on Windows to provide the Unix opened-file identity check. + +## Attach To Rust Peers + +Enable the peer crate's `schema` feature to attach a shared +`Arc` to synchronous or asynchronous listeners. Peers accepted +from that listener validate public sends and delivered receives with the shared +registry. Outbound connectors accept the same registry through their explicit +configuration functions. + +```toml +[dependencies] +ipcprims-peer = { version = "0.2.4", features = ["schema"] } +ipcprims-schema = "0.2.4" +``` + +Add the `async` feature when using `AsyncPeerListener` or +`async_connect_with_config`: + +```toml +ipcprims-peer = { version = "0.2.4", features = ["schema", "async"] } +``` + +Create the registry with `Arc::new`, then pass a clone to +`PeerListener::with_schema_registry` or +`AsyncPeerListener::with_schema_registry`. For outbound connections, pass +`Some(registry)` to `connect_with_config` or `async_connect_with_config` after +the handshake configuration argument. The registry is an optional peer feature; +without one, peer traffic has no schema-validation overhead. + +## CLI Validation + +`ipcprims echo --validate ` loads the directory with +`strict_mode: true` and `fail_on_missing_schema: false`. It applies the strict +transform to loaded schemas, but it is **not** the full deny-on-missing path: +unregistered channels still pass without validation. Schema failures received by +the echo server trigger a best-effort `ERROR`-channel response while the server +continues to run; it logs a warning if that response cannot be sent. + +## TypeScript Binding + +The TypeScript binding supports standalone directory loading and validation: + +```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. After it closes the registry, +`validate()` fails because the registry is no longer available. + +`Listener.bind` and `AsyncListener.bind` also accept +`ListenerOptions.schemaDir`, which loads and attaches a separate registry for +that listener. The standalone registry cannot be attached to a TypeScript peer +or listener. All three TypeScript surfaces use `RegistryConfig::default()`: +TypeScript currently cannot enable `strict_mode` or `fail_on_missing_schema`, +and it cannot register schemas programmatically. Do not assume TypeScript +directory validation rejects unregistered channels. + +See the [TypeScript binding README](https://github.com/3leaps/ipcprims/blob/main/bindings/typescript/README.md) +for listener examples. + +## Decisions + +[SDR-0001](https://github.com/3leaps/ipcprims/blob/main/docs/decisions/SDR-0001-schema-validation-scope.md) +describes the validation boundary, and +[SDR-0003](https://github.com/3leaps/ipcprims/blob/main/docs/decisions/SDR-0003-schema-registry-hardening-boundaries.md) +describes the hardening direction. The implementation and this guide are the +current contract where historical decision text differs from the shipped API or +platform support.