huskies: merge 1026

This commit is contained in:
dave
2026-05-14 12:53:14 +00:00
parent a80d0a497a
commit 72d79deec9
13 changed files with 1443 additions and 127 deletions
+127
View File
@@ -0,0 +1,127 @@
//! Typed validation error enum returned from all MCP write-tool input validation.
use serde::{Deserialize, Serialize};
use std::fmt;
/// Structured error from input validation.
///
/// Each variant carries exactly the data a caller needs to act on the error.
/// Serialises to serde's default externally-tagged form, e.g.
/// `{"FieldTooLong":{"field":"description","max":200,"actual":287}}`.
/// Callers can pattern-match on the JSON tag without parsing prose.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ValidationError {
/// A required field was absent from the input.
FieldMissing { field: String },
/// A field value is empty (or whitespace-only) after trimming.
EmptyAfterTrim { field: String },
/// A field value exceeds the maximum allowed length.
FieldTooLong {
field: String,
max: usize,
actual: usize,
},
/// A field value contains a character outside the allowed set.
InvalidCharacter {
field: String,
ch: char,
position: usize,
},
/// A field value contains a tool-call grammar fragment that must be rejected.
AntiGrammarToken { field: String, token: String },
/// A numeric field value is outside its allowed range.
OutOfRange {
field: String,
min: i64,
max: i64,
actual: i64,
},
/// A list field has fewer items than the minimum.
TooFewItems {
field: String,
min: usize,
actual: usize,
},
/// A list field has more items than the maximum.
TooManyItems {
field: String,
max: usize,
actual: usize,
},
/// A field value is not valid UTF-8.
InvalidUtf8 { field: String },
/// A `depends_on` entry references the same item being created or updated.
SelfReference { field: String },
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::FieldMissing { field } => {
write!(f, "field '{field}' is required but was not provided")
}
Self::EmptyAfterTrim { field } => {
write!(f, "field '{field}' must not be empty or whitespace-only")
}
Self::FieldTooLong { field, max, actual } => {
write!(f, "field '{field}' is too long ({actual} chars, max {max})")
}
Self::InvalidCharacter {
field,
ch,
position,
} => {
write!(
f,
"field '{field}' contains invalid character {ch:?} at position {position}"
)
}
Self::AntiGrammarToken { field, token } => {
write!(
f,
"field '{field}' contains a tool-call grammar fragment: {token:?}"
)
}
Self::OutOfRange {
field,
min,
max,
actual,
} => {
write!(
f,
"field '{field}' value {actual} is out of allowed range [{min}, {max}]"
)
}
Self::TooFewItems { field, min, actual } => {
write!(
f,
"field '{field}' has too few items ({actual}; minimum {min})"
)
}
Self::TooManyItems { field, max, actual } => {
write!(
f,
"field '{field}' has too many items ({actual}; maximum {max})"
)
}
Self::InvalidUtf8 { field } => {
write!(f, "field '{field}' contains invalid UTF-8")
}
Self::SelfReference { field } => {
write!(
f,
"field '{field}' contains a self-reference (depends on itself)"
)
}
}
}
}
/// Serialise a slice of validation errors as a pretty-printed JSON string.
///
/// Used to turn `Vec<ValidationError>` into the `Err(String)` value returned by
/// MCP tool handlers.
pub fn format_errors_as_json(errors: &[ValidationError]) -> String {
serde_json::to_string_pretty(errors).unwrap_or_else(|_| format!("{errors:?}"))
}
+22
View File
@@ -0,0 +1,22 @@
//! Transport-agnostic validated input layer for MCP write tools.
//!
//! This module houses all input validation primitives shared across MCP, HTTP,
//! and future WebSocket callers. It is intentionally decoupled from any
//! specific transport — callers parse their raw input into the request types
//! here and receive either a validated struct or a `Vec<ValidationError>`.
//!
//! # Structure
//!
//! - [`error`] — [`ValidationError`] typed enum + JSON serialisation helpers.
//! - [`sanitize`] — HTML sanitisation via `ammonia`.
//! - [`newtypes`] — field-level newtypes (`StoryName`, `AcceptanceCriterion`, …).
//! - [`requests`] — top-level request structs with cross-field `garde` rules.
mod error;
mod newtypes;
mod requests;
mod sanitize;
pub use error::{ValidationError, format_errors_as_json};
pub use newtypes::{AcceptanceCriterion, DependsOnId, Description, StoryName};
pub use requests::{CreateEpicRequest, CreateStoryRequest};
+397
View File
@@ -0,0 +1,397 @@
//! Validated input newtypes for MCP write tools.
//!
//! Each newtype's inner value is guaranteed valid once constructed — the only
//! public constructors run the full validation pipeline. Use the associated
//! `parse` or `parse_with_field` methods (which return rich `Vec<ValidationError>`)
//! in preference to nutype's lower-level `new()`.
use nutype::nutype;
use serde::{Deserialize, Serialize};
use super::error::ValidationError;
use super::sanitize;
/// Tool-call grammar fragments that must be rejected in any text field.
///
/// These are hallucination artifacts from the LLM (bug 1001): if they appear
/// in a field value the whole call is malformed bot output, not user content.
const ANTI_GRAMMAR_TOKENS: &[&str] = &[
"</description>",
"<parameter name=",
"<thinking>",
"</thinking>",
"<assistant>",
"</assistant>",
"<tool_use>",
"</tool_use>",
"<tool_result>",
"</tool_result>",
"<function_calls>",
"</function_calls>",
"<invoke>",
"</invoke>",
];
/// Maximum length (chars) for a story/epic name.
pub(super) const NAME_MAX_LEN: usize = 200;
/// Maximum length (chars) for a description / goal / motivation field.
pub(super) const DESCRIPTION_MAX_LEN: usize = 4000;
/// Maximum length (chars) for a single acceptance criterion.
pub(super) const AC_MAX_LEN: usize = 1000;
/// Scan `value` for any anti-grammar-token and return errors if found.
fn check_grammar_tokens(field: &str, value: &str) -> Vec<ValidationError> {
ANTI_GRAMMAR_TOKENS
.iter()
.filter(|&&token| value.contains(token))
.map(|&token| ValidationError::AntiGrammarToken {
field: field.to_string(),
token: token.to_string(),
})
.collect()
}
// ---------------------------------------------------------------------------
// StoryName newtype
// ---------------------------------------------------------------------------
fn is_story_name_nonempty(val: &str) -> bool {
!val.is_empty()
}
#[nutype(
sanitize(trim),
validate(predicate = is_story_name_nonempty),
derive(Debug, Clone, PartialEq, Serialize, Deserialize, AsRef)
)]
/// A validated, trimmed story or epic name.
pub struct StoryName(String);
impl StoryName {
/// Parse a raw string as a story name, returning all validation errors found.
///
/// Checks anti-grammar tokens first (on the raw trimmed value), then HTML-sanitises,
/// then validates length and non-emptiness.
pub fn parse(raw: &str) -> Result<Self, Vec<ValidationError>> {
let trimmed = raw.trim();
// Anti-grammar check on original trimmed value — must precede HTML sanitise
// because ammonia would strip the tokens before we could detect them.
let grammar_errors = check_grammar_tokens("name", trimmed);
if !grammar_errors.is_empty() {
return Err(grammar_errors);
}
let (sanitized, _) = sanitize::sanitize_html("name", trimmed);
let mut errors = Vec::new();
if sanitized.is_empty() {
errors.push(ValidationError::EmptyAfterTrim {
field: "name".into(),
});
} else if sanitized.len() > NAME_MAX_LEN {
errors.push(ValidationError::FieldTooLong {
field: "name".into(),
max: NAME_MAX_LEN,
actual: sanitized.len(),
});
}
if !errors.is_empty() {
return Err(errors);
}
// nutype's sanitize(trim) is idempotent; predicate passes since we already checked.
StoryName::try_new(sanitized).map_err(|_e| {
vec![ValidationError::EmptyAfterTrim {
field: "name".into(),
}]
})
}
}
// ---------------------------------------------------------------------------
// AcceptanceCriterion newtype
// ---------------------------------------------------------------------------
fn is_ac_nonempty(val: &str) -> bool {
!val.is_empty()
}
#[nutype(
sanitize(trim),
validate(predicate = is_ac_nonempty),
derive(Debug, Clone, PartialEq, Serialize, Deserialize, AsRef)
)]
/// A single validated acceptance criterion.
pub struct AcceptanceCriterion(String);
impl AcceptanceCriterion {
/// Parse a single raw acceptance criterion string, using `field` in error messages.
pub fn parse(field: &str, raw: &str) -> Result<Self, Vec<ValidationError>> {
let trimmed = raw.trim();
let grammar_errors = check_grammar_tokens(field, trimmed);
if !grammar_errors.is_empty() {
return Err(grammar_errors);
}
let (sanitized, _) = sanitize::sanitize_html(field, trimmed);
let mut errors = Vec::new();
if sanitized.is_empty() {
errors.push(ValidationError::EmptyAfterTrim {
field: field.to_string(),
});
} else if sanitized.len() > AC_MAX_LEN {
errors.push(ValidationError::FieldTooLong {
field: field.to_string(),
max: AC_MAX_LEN,
actual: sanitized.len(),
});
}
if !errors.is_empty() {
return Err(errors);
}
AcceptanceCriterion::try_new(sanitized).map_err(|_e| {
vec![ValidationError::EmptyAfterTrim {
field: field.to_string(),
}]
})
}
}
// ---------------------------------------------------------------------------
// Description newtype (used for description, user_story, goal, motivation)
// ---------------------------------------------------------------------------
/// A validated trimmed description / goal / motivation text.
///
/// The field name is not embedded in the type; pass it to `parse`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Description(String);
impl Description {
/// Parse and validate a description field value.
///
/// `field` is used in error messages (e.g. `"description"`, `"goal"`).
pub fn parse(field: &str, raw: &str) -> Result<Self, Vec<ValidationError>> {
let trimmed = raw.trim();
let grammar_errors = check_grammar_tokens(field, trimmed);
if !grammar_errors.is_empty() {
return Err(grammar_errors);
}
let (sanitized, _) = sanitize::sanitize_html(field, trimmed);
let mut errors = Vec::new();
if sanitized.is_empty() {
errors.push(ValidationError::EmptyAfterTrim {
field: field.to_string(),
});
} else if sanitized.len() > DESCRIPTION_MAX_LEN {
errors.push(ValidationError::FieldTooLong {
field: field.to_string(),
max: DESCRIPTION_MAX_LEN,
actual: sanitized.len(),
});
}
if !errors.is_empty() {
Err(errors)
} else {
Ok(Description(sanitized))
}
}
/// Return the inner string value.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for Description {
fn as_ref(&self) -> &str {
&self.0
}
}
// ---------------------------------------------------------------------------
// DependsOnId newtype
// ---------------------------------------------------------------------------
fn is_nonzero_dep_id(val: &u32) -> bool {
*val != 0
}
#[nutype(
validate(predicate = is_nonzero_dep_id),
derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)
)]
/// A validated non-zero story/item dependency ID.
pub struct DependsOnId(u32);
impl DependsOnId {
/// Parse a raw `u32` as a dependency ID, using `field` in error messages.
pub fn parse(field: &str, id: u32) -> Result<Self, Vec<ValidationError>> {
if id == 0 {
return Err(vec![ValidationError::OutOfRange {
field: field.to_string(),
min: 1,
max: i64::from(u32::MAX),
actual: 0,
}]);
}
DependsOnId::try_new(id).map_err(|_| {
vec![ValidationError::OutOfRange {
field: field.to_string(),
min: 1,
max: i64::from(u32::MAX),
actual: i64::from(id),
}]
})
}
/// Return the raw inner value.
pub fn get(self) -> u32 {
self.into_inner()
}
}
#[cfg(test)]
mod tests {
use super::*;
// --- StoryName ---
#[test]
fn story_name_rejects_empty() {
let err = StoryName::parse("").unwrap_err();
assert!(matches!(err[0], ValidationError::EmptyAfterTrim { .. }));
}
#[test]
fn story_name_rejects_whitespace_only() {
let err = StoryName::parse(" ").unwrap_err();
assert!(matches!(err[0], ValidationError::EmptyAfterTrim { .. }));
}
#[test]
fn story_name_accepts_valid() {
let n = StoryName::parse("My Story").unwrap();
assert_eq!(n.as_ref(), "My Story");
}
#[test]
fn story_name_trims_whitespace() {
let n = StoryName::parse(" Trimmed ").unwrap();
assert_eq!(n.as_ref(), "Trimmed");
}
#[test]
fn story_name_rejects_too_long() {
let long = "x".repeat(NAME_MAX_LEN + 1);
let err = StoryName::parse(&long).unwrap_err();
assert!(matches!(err[0], ValidationError::FieldTooLong { .. }));
}
#[test]
fn story_name_rejects_grammar_token() {
let err = StoryName::parse("my story </description> end").unwrap_err();
assert!(matches!(err[0], ValidationError::AntiGrammarToken { .. }));
}
#[test]
fn story_name_rejects_thinking_token() {
let err = StoryName::parse("<thinking>hello</thinking>").unwrap_err();
assert!(matches!(err[0], ValidationError::AntiGrammarToken { .. }));
}
#[test]
fn story_name_strips_html() {
let n = StoryName::parse("Hello <b>World</b>").unwrap();
assert!(!n.as_ref().contains('<'));
assert!(n.as_ref().contains("World"));
}
// --- AcceptanceCriterion ---
#[test]
fn ac_rejects_empty() {
let err = AcceptanceCriterion::parse("acceptance_criteria[0]", "").unwrap_err();
assert!(matches!(err[0], ValidationError::EmptyAfterTrim { .. }));
}
#[test]
fn ac_accepts_valid() {
let ac = AcceptanceCriterion::parse("acceptance_criteria[0]", "It works").unwrap();
assert_eq!(ac.as_ref(), "It works");
}
#[test]
fn ac_rejects_grammar_token() {
let err = AcceptanceCriterion::parse("acceptance_criteria[0]", "<tool_use>bad</tool_use>")
.unwrap_err();
assert!(matches!(err[0], ValidationError::AntiGrammarToken { .. }));
}
#[test]
fn ac_rejects_too_long() {
let long = "x".repeat(AC_MAX_LEN + 1);
let err = AcceptanceCriterion::parse("acceptance_criteria[0]", &long).unwrap_err();
assert!(matches!(err[0], ValidationError::FieldTooLong { .. }));
}
// --- Description ---
#[test]
fn description_rejects_empty() {
let err = Description::parse("description", "").unwrap_err();
assert!(matches!(err[0], ValidationError::EmptyAfterTrim { .. }));
}
#[test]
fn description_accepts_valid() {
let d = Description::parse("goal", "Achieve world peace").unwrap();
assert_eq!(d.as_str(), "Achieve world peace");
}
#[test]
fn description_rejects_grammar_token() {
let err = Description::parse("description", "text <parameter name=x> more").unwrap_err();
assert!(matches!(err[0], ValidationError::AntiGrammarToken { .. }));
}
// --- DependsOnId ---
#[test]
fn depends_on_id_rejects_zero() {
let err = DependsOnId::parse("depends_on[0]", 0).unwrap_err();
assert!(matches!(err[0], ValidationError::OutOfRange { .. }));
}
#[test]
fn depends_on_id_accepts_nonzero() {
let id = DependsOnId::parse("depends_on[0]", 42).unwrap();
assert_eq!(id.get(), 42);
}
// --- Round-trip serde ---
#[test]
fn validation_error_round_trips_json() {
let err = ValidationError::FieldTooLong {
field: "description".into(),
max: 200,
actual: 287,
};
let json = serde_json::to_string(&err).unwrap();
assert!(json.contains("FieldTooLong"));
assert!(json.contains("description"));
let back: ValidationError = serde_json::from_str(&json).unwrap();
assert_eq!(err, back);
}
}
+476
View File
@@ -0,0 +1,476 @@
//! Validated request structs for MCP write tools.
//!
//! Each struct is populated by `from_json`, which runs field-level validation via
//! the newtypes, then cross-field rules via `garde`. Callers receive either a
//! fully validated struct or a `Vec<ValidationError>` with every problem found.
use garde::Validate;
use serde_json::Value;
use super::error::{ValidationError, format_errors_as_json};
use super::newtypes::{AcceptanceCriterion, DependsOnId, Description, StoryName};
// ---------------------------------------------------------------------------
// Cross-field validators (used by garde derive)
// ---------------------------------------------------------------------------
/// Junk-only acceptance-criteria indicators — placeholders agents fill in but
/// that contain no actionable requirement.
const JUNK_AC_MARKERS: &[&str] = &["todo", "tbd", "fixme", "xxx", "???"];
fn validate_acceptance_criteria_nonempty(acs: &[AcceptanceCriterion], _ctx: &()) -> garde::Result {
if acs.is_empty() {
return Err(garde::Error::new(
"acceptance_criteria must contain at least one entry",
));
}
let all_junk = acs.iter().all(|ac| {
let lower = ac.as_ref().to_lowercase();
JUNK_AC_MARKERS.contains(&lower.trim())
});
if all_junk {
return Err(garde::Error::new(
"acceptance_criteria must contain at least one real entry (not just TODO/TBD/FIXME)",
));
}
Ok(())
}
// ---------------------------------------------------------------------------
// CreateStoryRequest
// ---------------------------------------------------------------------------
/// Fully validated inputs for the `create_story` MCP tool.
#[derive(Debug, Validate)]
pub struct CreateStoryRequest {
/// Validated story name.
#[garde(skip)]
pub name: StoryName,
/// Optional user story text.
#[garde(skip)]
pub user_story: Option<Description>,
/// Optional background description.
#[garde(skip)]
pub description: Option<Description>,
/// At least one non-junk acceptance criterion required (garde-enforced).
#[garde(custom(validate_acceptance_criteria_nonempty))]
pub acceptance_criteria: Vec<AcceptanceCriterion>,
/// Optional list of story IDs this story depends on.
#[garde(skip)]
pub depends_on: Option<Vec<DependsOnId>>,
}
impl CreateStoryRequest {
/// Parse and validate a `create_story` JSON argument map.
///
/// Runs all field-level validation and cross-field garde rules in a single
/// pass. Returns every error found, not just the first.
pub fn from_json(args: &Value) -> Result<Self, String> {
let mut errors: Vec<ValidationError> = Vec::new();
// name (required)
let name = match args.get("name").and_then(|v| v.as_str()) {
None => {
errors.push(ValidationError::FieldMissing {
field: "name".into(),
});
None
}
Some(raw) => match StoryName::parse(raw) {
Ok(n) => Some(n),
Err(mut errs) => {
errors.append(&mut errs);
None
}
},
};
// user_story (optional)
let user_story = match args.get("user_story").and_then(|v| v.as_str()) {
None => None,
Some(raw) => match Description::parse("user_story", raw) {
Ok(d) => Some(d),
Err(mut errs) => {
errors.append(&mut errs);
None
}
},
};
// description (optional)
let description = match args.get("description").and_then(|v| v.as_str()) {
None => None,
Some(raw) => match Description::parse("description", raw) {
Ok(d) => Some(d),
Err(mut errs) => {
errors.append(&mut errs);
None
}
},
};
// acceptance_criteria (required)
let acceptance_criteria = match args
.get("acceptance_criteria")
.and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok())
{
None => {
errors.push(ValidationError::FieldMissing {
field: "acceptance_criteria".into(),
});
None
}
Some(raw_acs) => {
let mut parsed = Vec::new();
for (i, raw) in raw_acs.iter().enumerate() {
let field = format!("acceptance_criteria[{i}]");
match AcceptanceCriterion::parse(&field, raw) {
Ok(ac) => parsed.push(ac),
Err(mut errs) => errors.append(&mut errs),
}
}
Some(parsed)
}
};
// depends_on (optional)
let depends_on: Option<Vec<DependsOnId>> =
match args.get("depends_on").and_then(|v| v.as_array()) {
None => None,
Some(arr) => {
let mut ids = Vec::new();
for (i, val) in arr.iter().enumerate() {
let field = format!("depends_on[{i}]");
match val.as_u64().map(|n| n as u32) {
None => errors.push(ValidationError::InvalidUtf8 {
field: field.clone(),
}),
Some(id) => match DependsOnId::parse(&field, id) {
Ok(d) => ids.push(d),
Err(mut errs) => errors.append(&mut errs),
},
}
}
Some(ids)
}
};
if !errors.is_empty() {
return Err(format_errors_as_json(&errors));
}
let req = CreateStoryRequest {
name: name.unwrap(),
user_story,
description,
acceptance_criteria: acceptance_criteria.unwrap(),
depends_on,
};
// Cross-field garde validation
if let Err(report) = req.validate_with(&()) {
for (_, _field_error) in report.iter() {
// Map garde errors back to typed ValidationError.
// The only garde rule here is the AC nonempty/junk check.
let actual = req.acceptance_criteria.len();
let all_junk = req.acceptance_criteria.iter().all(|ac| {
let lower = ac.as_ref().to_lowercase();
JUNK_AC_MARKERS.contains(&lower.trim())
});
if all_junk && actual > 0 {
errors.push(ValidationError::TooFewItems {
field: "acceptance_criteria".into(),
min: 1,
// Semantic "0 real entries"
actual: 0,
});
} else {
errors.push(ValidationError::TooFewItems {
field: "acceptance_criteria".into(),
min: 1,
actual,
});
}
}
return Err(format_errors_as_json(&errors));
}
Ok(req)
}
/// Extract validated `depends_on` as a plain `Vec<u32>` for downstream use.
pub fn depends_on_ids(&self) -> Option<Vec<u32>> {
self.depends_on
.as_ref()
.map(|ids| ids.iter().map(|d| d.get()).collect())
}
}
// ---------------------------------------------------------------------------
// CreateEpicRequest
// ---------------------------------------------------------------------------
/// Fully validated inputs for the `create_epic` MCP tool.
#[derive(Debug, Validate)]
pub struct CreateEpicRequest {
/// Validated epic name.
#[garde(skip)]
pub name: StoryName,
/// Validated goal statement.
#[garde(skip)]
pub goal: Description,
/// Optional motivation text.
#[garde(skip)]
pub motivation: Option<Description>,
/// Optional key files text (plain string, minimal validation).
#[garde(skip)]
pub key_files: Option<String>,
/// Optional success criteria list.
#[garde(skip)]
pub success_criteria: Option<Vec<AcceptanceCriterion>>,
}
impl CreateEpicRequest {
/// Parse and validate a `create_epic` JSON argument map.
pub fn from_json(args: &Value) -> Result<Self, String> {
let mut errors: Vec<ValidationError> = Vec::new();
// name (required)
let name = match args.get("name").and_then(|v| v.as_str()) {
None => {
errors.push(ValidationError::FieldMissing {
field: "name".into(),
});
None
}
Some(raw) => match StoryName::parse(raw) {
Ok(n) => Some(n),
Err(mut errs) => {
errors.append(&mut errs);
None
}
},
};
// goal (required)
let goal = match args.get("goal").and_then(|v| v.as_str()) {
None => {
errors.push(ValidationError::FieldMissing {
field: "goal".into(),
});
None
}
Some(raw) => match Description::parse("goal", raw) {
Ok(d) => Some(d),
Err(mut errs) => {
errors.append(&mut errs);
None
}
},
};
// motivation (optional)
let motivation = match args.get("motivation").and_then(|v| v.as_str()) {
None => None,
Some(raw) => match Description::parse("motivation", raw) {
Ok(d) => Some(d),
Err(mut errs) => {
errors.append(&mut errs);
None
}
},
};
// key_files (optional, plain string — structural markup, not user prose)
let key_files = args
.get("key_files")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
// success_criteria (optional list)
let success_criteria = match args
.get("success_criteria")
.and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok())
{
None => None,
Some(raw_sc) => {
let mut parsed = Vec::new();
for (i, raw) in raw_sc.iter().enumerate() {
let field = format!("success_criteria[{i}]");
match AcceptanceCriterion::parse(&field, raw) {
Ok(ac) => parsed.push(ac),
Err(mut errs) => errors.append(&mut errs),
}
}
Some(parsed)
}
};
if !errors.is_empty() {
return Err(format_errors_as_json(&errors));
}
Ok(CreateEpicRequest {
name: name.unwrap(),
goal: goal.unwrap(),
motivation,
key_files,
success_criteria,
})
}
/// Extract success criteria as plain strings for downstream use.
pub fn success_criteria_strings(&self) -> Vec<String> {
self.success_criteria
.as_ref()
.map(|sc| sc.iter().map(|c| c.as_ref().to_string()).collect())
.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// --- CreateStoryRequest ---
#[test]
fn create_story_request_valid_minimal() {
let args = json!({
"name": "My Story",
"acceptance_criteria": ["It works"]
});
let req = CreateStoryRequest::from_json(&args).unwrap();
assert_eq!(req.name.as_ref(), "My Story");
assert_eq!(req.acceptance_criteria.len(), 1);
}
#[test]
fn create_story_request_missing_name() {
let args = json!({"acceptance_criteria": ["AC1"]});
let err = CreateStoryRequest::from_json(&args).unwrap_err();
assert!(err.contains("FieldMissing"));
assert!(err.contains("name"));
}
#[test]
fn create_story_request_missing_acs() {
let args = json!({"name": "My Story"});
let err = CreateStoryRequest::from_json(&args).unwrap_err();
assert!(err.contains("FieldMissing"));
assert!(err.contains("acceptance_criteria"));
}
#[test]
fn create_story_request_empty_acs() {
let args = json!({"name": "My Story", "acceptance_criteria": []});
let err = CreateStoryRequest::from_json(&args).unwrap_err();
assert!(err.contains("TooFewItems"));
}
#[test]
fn create_story_request_all_junk_acs() {
let args = json!({"name": "My Story", "acceptance_criteria": ["TODO", "TBD"]});
let err = CreateStoryRequest::from_json(&args).unwrap_err();
assert!(err.contains("TooFewItems"));
}
#[test]
fn create_story_request_mixed_junk_and_real() {
let args = json!({
"name": "My Story",
"acceptance_criteria": ["TODO", "Real criterion"]
});
let req = CreateStoryRequest::from_json(&args).unwrap();
assert_eq!(req.acceptance_criteria.len(), 2);
}
#[test]
fn create_story_request_grammar_token_in_name() {
let args = json!({
"name": "Story </description> bad",
"acceptance_criteria": ["AC1"]
});
let err = CreateStoryRequest::from_json(&args).unwrap_err();
assert!(err.contains("AntiGrammarToken"));
}
#[test]
fn create_story_request_grammar_token_in_ac() {
let args = json!({
"name": "Valid Name",
"acceptance_criteria": ["<thinking>bad</thinking>"]
});
let err = CreateStoryRequest::from_json(&args).unwrap_err();
assert!(err.contains("AntiGrammarToken"));
}
#[test]
fn create_story_request_with_all_optional_fields() {
let args = json!({
"name": "Full Story",
"user_story": "As a user I want this",
"description": "Background context",
"acceptance_criteria": ["AC1", "AC2"],
"depends_on": [1, 2, 3]
});
let req = CreateStoryRequest::from_json(&args).unwrap();
assert_eq!(req.depends_on_ids(), Some(vec![1, 2, 3]));
}
#[test]
fn create_story_request_errors_contain_json() {
let args = json!({
"name": "<thinking>bad</thinking>",
"acceptance_criteria": []
});
let err = CreateStoryRequest::from_json(&args).unwrap_err();
// Errors are JSON, parseable
let parsed: serde_json::Value = serde_json::from_str(&err).unwrap();
assert!(parsed.is_array());
}
// --- CreateEpicRequest ---
#[test]
fn create_epic_request_valid_minimal() {
let args = json!({
"name": "My Epic",
"goal": "Achieve something great"
});
let req = CreateEpicRequest::from_json(&args).unwrap();
assert_eq!(req.name.as_ref(), "My Epic");
assert_eq!(req.goal.as_str(), "Achieve something great");
}
#[test]
fn create_epic_request_missing_name() {
let args = json!({"goal": "some goal"});
let err = CreateEpicRequest::from_json(&args).unwrap_err();
assert!(err.contains("FieldMissing"));
assert!(err.contains("name"));
}
#[test]
fn create_epic_request_missing_goal() {
let args = json!({"name": "Epic"});
let err = CreateEpicRequest::from_json(&args).unwrap_err();
assert!(err.contains("FieldMissing"));
assert!(err.contains("goal"));
}
#[test]
fn create_epic_request_with_success_criteria() {
let args = json!({
"name": "My Epic",
"goal": "Achieve world peace",
"success_criteria": ["All wars end", "People prosper"]
});
let req = CreateEpicRequest::from_json(&args).unwrap();
let sc = req.success_criteria_strings();
assert_eq!(sc.len(), 2);
}
}
+67
View File
@@ -0,0 +1,67 @@
//! HTML sanitisation for user-supplied text fields.
//!
//! Uses ammonia to strip dangerous HTML tags and attributes while preserving
//! the visible text content. Sanitisation that actually fires is logged at
//! WARN so operators can spot abuse patterns.
use sha2::Digest;
use std::collections::HashSet;
/// Sanitise `value` for the named `field`.
///
/// Strips all HTML tags (keeping their text content) and removes dangerous
/// attributes. Returns `(sanitised_value, was_modified)`. When `was_modified`
/// is `true` the caller should log at WARN.
pub(super) fn sanitize_html(field: &str, value: &str) -> (String, bool) {
// Build an ammonia cleaner that allows NO tags but keeps text content.
// clear_content_tags is also set to empty so that <script>...</script>
// content is preserved as literal text rather than silently discarded.
let clean = ammonia::Builder::new()
.tags(HashSet::new())
.clean_content_tags(HashSet::new())
.clean(value)
.to_string();
let modified = clean != value;
if modified {
crate::slog_warn!(
"[validation] HTML sanitised in field '{}': fingerprint={}",
field,
fingerprint(value)
);
}
(clean, modified)
}
/// Return an 8-hex-char SHA-256 fingerprint of the input string.
fn fingerprint(input: &str) -> String {
let hash = sha2::Sha256::digest(input.as_bytes());
hash[..4].iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn script_tags_stripped_content_preserved() {
let (out, modified) = sanitize_html("name", "<script>alert('xss')</script>");
assert!(modified);
assert!(!out.contains("<script>"));
assert!(out.contains("alert("));
}
#[test]
fn plain_text_unchanged() {
let (out, modified) = sanitize_html("name", "Hello World");
assert!(!modified);
assert_eq!(out, "Hello World");
}
#[test]
fn on_event_stripped() {
let (out, modified) = sanitize_html("name", r#"<img onload="evil()">"#);
assert!(modified);
assert!(!out.contains("onload"));
}
}