KeiSeiKit-1.0/_primitives/_rust/kei-forge/src/form.rs
Parfii-bot 0be354a920 KeiSeiKit-public — clean state
Single-commit clean baseline after security scrub of niche-tells,
project codenames, internal jargon, and contributor-email leaks.

Contents:
- 100 Rust crates (_primitives/_rust/)
- 37 agent manifests (_manifests/) + generated specs (_generated/)
- 67 user-invocable skills (skills/)
- 33 hooks (hooks/)
- Composition blocks (_blocks/)
- Documentation (docs/, README.md)
- TS adapter packages (_ts_packages/)
- Assembler (_assembler/)
- Roles (_roles/)
- Templates (_templates/)
- Forgejo CI (.forgejo/)

Author: Denis Parfionovich <info@greendragon.info>

License: see LICENSE.
2026-05-01 12:09:03 +08:00

231 lines
6.4 KiB
Rust

//! Form request deserialization + validation.
//!
//! Accepts either `application/x-www-form-urlencoded` (HTML `<form>`) or
//! `application/json` (future API clients). Validation enforces the
//! locked substrate schema — verb naming (kebab-case) and atom kind
//! enumeration (command | query | stream | transform).
use serde::{Deserialize, Serialize};
/// Incoming POST /forge body.
///
/// `crate` is renamed because it's a Rust reserved word.
#[derive(Debug, Deserialize, Serialize)]
pub struct ForgeRequest {
#[serde(rename = "crate")]
pub crate_name: String,
pub verb: String,
pub kind: String,
pub description: String,
}
/// Validation outcome. `Ok(())` if the request matches schema constraints.
pub fn validate(req: &ForgeRequest) -> Result<(), String> {
validate_crate_name(&req.crate_name)?;
validate_verb(&req.verb)?;
validate_kind(&req.kind)?;
validate_description(&req.description)?;
Ok(())
}
/// Description whitelist — ASCII printable only.
///
/// Hardening against shell-substitution in `scripts/new-atom.sh`: an
/// attacker-controlled newline, backtick, or `$` could smuggle a
/// secondary `sed` expression into the template-substitution step and
/// poison generated Rust source. Blocking these at the Rust layer
/// prevents the shell from ever seeing a hostile byte.
fn validate_description(d: &str) -> Result<(), String> {
if d.len() > MAX_DESCRIPTION_LEN {
return Err(format!(
"description must be ≤{MAX_DESCRIPTION_LEN} chars (got {})",
d.len()
));
}
for (i, b) in d.bytes().enumerate() {
if !(0x20..=0x7E).contains(&b) {
return Err(format!(
"description contains non-printable byte 0x{b:02X} at offset {i}"
));
}
if matches!(b, b'`' | b'$') {
return Err(format!(
"description contains forbidden character '{}' at offset {i}",
b as char
));
}
}
Ok(())
}
const MAX_DESCRIPTION_LEN: usize = 200;
fn validate_crate_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("crate must not be empty".to_string());
}
if !is_kebab_lower(name) {
return Err(format!(
"crate must be lowercase kebab-case (got '{name}')"
));
}
Ok(())
}
fn validate_verb(verb: &str) -> Result<(), String> {
if verb.is_empty() {
return Err("verb must not be empty".to_string());
}
if !is_kebab_lower(verb) {
return Err(format!(
"verb must be lowercase kebab-case (got '{verb}')"
));
}
Ok(())
}
fn validate_kind(kind: &str) -> Result<(), String> {
match kind {
"command" | "query" | "stream" | "transform" => Ok(()),
other => Err(format!(
"kind must be command|query|stream|transform (got '{other}')"
)),
}
}
/// Matches regex `^[a-z][a-z0-9]*(-[a-z0-9]+)*$` without pulling in `regex`.
/// Hand-rolled because it's ~10 LOC and saves a workspace-wide dep.
fn is_kebab_lower(s: &str) -> bool {
let mut chars = s.chars();
let Some(first) = chars.next() else {
return false;
};
if !first.is_ascii_lowercase() {
return false;
}
let mut prev_dash = false;
for c in chars {
match c {
'a'..='z' | '0'..='9' => prev_dash = false,
'-' if !prev_dash => prev_dash = true,
_ => return false,
}
}
!prev_dash
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_empty() {
assert!(validate_verb("").is_err());
assert!(validate_crate_name("").is_err());
}
#[test]
fn rejects_upper() {
assert!(validate_verb("addDependency").is_err());
}
#[test]
fn rejects_trailing_dash() {
assert!(validate_verb("add-").is_err());
}
#[test]
fn rejects_double_dash() {
assert!(validate_verb("add--dep").is_err());
}
#[test]
fn accepts_kebab() {
assert!(validate_verb("add-dependency").is_ok());
assert!(validate_verb("search").is_ok());
assert!(validate_verb("v2-rename").is_ok());
}
#[test]
fn accepts_known_kinds() {
for k in ["command", "query", "stream", "transform"] {
let req = ForgeRequest {
crate_name: "kei-task".into(),
verb: "noop".into(),
kind: k.into(),
description: "test".into(),
};
assert!(validate(&req).is_ok(), "kind {k} should validate");
}
}
#[test]
fn rejects_unknown_kind() {
let req = ForgeRequest {
crate_name: "kei-task".into(),
verb: "noop".into(),
kind: "saga".into(),
description: "test".into(),
};
assert!(validate(&req).is_err());
}
fn req_with_desc(d: &str) -> ForgeRequest {
ForgeRequest {
crate_name: "kei-task".into(),
verb: "noop".into(),
kind: "command".into(),
description: d.into(),
}
}
#[test]
fn description_rejects_newline() {
let err = validate(&req_with_desc("foo\nbar")).unwrap_err();
assert!(err.contains("0x0A"), "expected newline byte report: {err}");
}
#[test]
fn description_rejects_carriage_return() {
assert!(validate(&req_with_desc("foo\rbar")).is_err());
}
#[test]
fn description_rejects_tab() {
assert!(validate(&req_with_desc("foo\tbar")).is_err());
}
#[test]
fn description_rejects_nul() {
assert!(validate(&req_with_desc("foo\0bar")).is_err());
}
#[test]
fn description_rejects_backtick() {
let err = validate(&req_with_desc("foo`id`bar")).unwrap_err();
assert!(err.contains('`'), "expected backtick in error: {err}");
}
#[test]
fn description_rejects_dollar_sign() {
let err = validate(&req_with_desc("foo$(id)bar")).unwrap_err();
assert!(err.contains('$'), "expected dollar in error: {err}");
}
#[test]
fn description_rejects_over_length() {
let long = "a".repeat(201);
assert!(validate(&req_with_desc(&long)).is_err());
}
#[test]
fn description_accepts_minimal() {
assert!(validate(&req_with_desc("ok")).is_ok());
}
#[test]
fn description_accepts_at_length_cap() {
let exact = "a".repeat(200);
assert!(validate(&req_with_desc(&exact)).is_ok());
}
}