From c05e8b0c06f716bb6e1fcfd009ecdaedd0655906 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Thu, 12 Nov 2026 09:03:23 -0500 Subject: [PATCH 1/2] Reject line breaks in RFD attribute values with SingleLineString --- Cargo.lock | 2 + Cargo.toml | 1 + rfd-api-spec.json | 3 +- rfd-api/src/endpoints/rfd.rs | 18 ++-- rfd-cli/src/generated/cli.rs | 4 +- rfd-data/Cargo.toml | 3 + rfd-data/src/content/asciidoc.rs | 21 +++-- rfd-data/src/content/markdown.rs | 15 ++-- rfd-data/src/content/mod.rs | 14 ++-- rfd-data/src/lib.rs | 87 +++++++++++++++++++- rfd-processor/src/content/mod.rs | 8 +- rfd-processor/src/rfd.rs | 27 +++--- rfd-processor/src/updater/ensure_pr_state.rs | 3 +- rfd-sdk/Cargo.toml | 1 + rfd-sdk/src/generated/sdk.rs | 74 ++++++++++++++++- rfd-ts/src/validate.ts | 2 +- 16 files changed, 234 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32fcf3fc..ba739ceb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3274,6 +3274,7 @@ dependencies = [ "rfd-model", "schemars 0.8.22", "serde", + "serde_json", "thiserror 2.0.21", "tracing", ] @@ -3363,6 +3364,7 @@ version = "0.15.9" dependencies = [ "chrono", "progenitor-client", + "regress", "reqwest 0.13.5", "schemars 0.8.22", "serde", diff --git a/Cargo.toml b/Cargo.toml index dbdd9fa0..58c22465 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ partial-struct = { git = "https://github.com/oxidecomputer/partial-struct" } progenitor = { version = "0.15" } progenitor-client = { version = "0.15" } regex = "1.13.1" +regress = "0.12" reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls"] } reqwest-middleware = "0.5" reqwest-retry = "0.9" diff --git a/rfd-api-spec.json b/rfd-api-spec.json index 41640b2d..1932cf72 100644 --- a/rfd-api-spec.json +++ b/rfd-api-spec.json @@ -4441,7 +4441,8 @@ }, "value": { "description": "Full value to set this attribute to in the existing RFD contents", - "type": "string" + "type": "string", + "pattern": "^[^\\r\\n]*$" } }, "required": [ diff --git a/rfd-api/src/endpoints/rfd.rs b/rfd-api/src/endpoints/rfd.rs index ec8c8bae..90fcf5d9 100644 --- a/rfd-api/src/endpoints/rfd.rs +++ b/rfd-api/src/endpoints/rfd.rs @@ -9,7 +9,7 @@ use dropshot::{ use newtype_uuid::TypedUuid; use rfd_data::{ content::{RfdAsciidoc, RfdContent, RfdDocument, RfdMarkdown}, - RfdState, + RfdState, SingleLineString, }; use rfd_model::{ schema_ext::{ContentFormat, Visibility}, @@ -717,7 +717,7 @@ async fn set_rfd_content_op( #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct RfdAttrValue { /// Full value to set this attribute to in the existing RFD contents - value: String, + value: SingleLineString, /// Optional Git commit message to send with this update (recommended) message: Option, } @@ -767,7 +767,7 @@ async fn set_rfd_attr_op( tracing::info!(?err, "Invalid state was supplied"); HttpError::for_bad_request(None, "Invalid RFD state".to_string()) })?; - content.update_state(&state.to_string()) + content.update_state(&state.into()) } }; @@ -814,7 +814,7 @@ pub async fn discuss_rfd( path.number, RfdAttrName::State, &RfdAttrValue { - value: RfdState::Discussion.to_string(), + value: RfdState::Discussion.into(), message: Some("Move to discussion".to_string()), }, ) @@ -841,7 +841,7 @@ pub async fn publish_rfd( path.number, RfdAttrName::State, &RfdAttrValue { - value: RfdState::Published.to_string(), + value: RfdState::Published.into(), message: Some("Publish".to_string()), }, ) @@ -1009,7 +1009,13 @@ mod tests { permissions::RfdPermission, }; - use super::list_rfds_op; + use super::{list_rfds_op, RfdAttrValue}; + + #[test] + fn test_attr_value_rejects_line_breaks() { + let body = serde_json::json!({ "value": "a\n:authors: b" }); + assert!(serde_json::from_value::(body).is_err()); + } async fn ctx() -> RfdContext { let private_rfd_id_1 = Uuid::new_v4(); diff --git a/rfd-cli/src/generated/cli.rs b/rfd-cli/src/generated/cli.rs index 3d417ae0..65840907 100644 --- a/rfd-cli/src/generated/cli.rs +++ b/rfd-cli/src/generated/cli.rs @@ -1345,7 +1345,7 @@ impl Cli { .arg( ::clap::Arg::new("value") .long("value") - .value_parser(::clap::value_parser!(::std::string::String)) + .value_parser(::clap::value_parser!(types::RfdAttrValueValue)) .required_unless_present("json-body") .help("Full value to set this attribute to in the existing RFD contents"), ) @@ -3437,7 +3437,7 @@ impl Cli { request = request.number(value.clone()); } - if let Some(value) = matches.get_one::<::std::string::String>("value") { + if let Some(value) = matches.get_one::("value") { request = request.body_map(|body| body.value(value.clone())) } diff --git a/rfd-data/Cargo.toml b/rfd-data/Cargo.toml index 19a64add..04c3684a 100644 --- a/rfd-data/Cargo.toml +++ b/rfd-data/Cargo.toml @@ -12,3 +12,6 @@ schemars = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/rfd-data/src/content/asciidoc.rs b/rfd-data/src/content/asciidoc.rs index 1f5dcbe1..65cb6310 100644 --- a/rfd-data/src/content/asciidoc.rs +++ b/rfd-data/src/content/asciidoc.rs @@ -6,6 +6,7 @@ use regex::{regex, Regex}; use std::borrow::Cow; use super::RfdDocument; +use crate::SingleLineString; /// The text data of an Asciidoc RFD #[derive(Debug, Clone)] @@ -189,7 +190,7 @@ impl<'a> RfdDocument for RfdAsciidoc<'a> { Self::attr("state", &self.resolved) } - fn update_state(&mut self, value: &str) -> &mut Self { + fn update_state(&mut self, value: &SingleLineString) -> &mut Self { self.set_raw(&Self::set_attr(&self.content, "state", value.trim())) } @@ -202,7 +203,7 @@ impl<'a> RfdDocument for RfdAsciidoc<'a> { } } - fn update_discussion(&mut self, value: &str) -> &mut Self { + fn update_discussion(&mut self, value: &SingleLineString) -> &mut Self { self.set_raw(&Self::set_attr(&self.content, "discussion", value.trim())) } @@ -214,7 +215,7 @@ impl<'a> RfdDocument for RfdAsciidoc<'a> { Self::attr("labels", &self.resolved) } - fn update_labels(&mut self, value: &str) -> &mut Self { + fn update_labels(&mut self, value: &SingleLineString) -> &mut Self { self.set_raw(&Self::set_attr(&self.content, "labels", value.trim())) } @@ -429,6 +430,10 @@ impl RfdAuthor { mod tests { use super::*; + fn line(value: &str) -> crate::SingleLineString { + value.to_string().try_into().unwrap() + } + // RFD Author tests #[test] @@ -709,7 +714,7 @@ sdf :discussion: nope"#; let mut rfd = RfdAsciidoc::new(content); - rfd.update_discussion(link); + rfd.update_discussion(&line(link)); let expected = r#"sdfsdf = sdfgsd discussion: fgsdfg @@ -733,7 +738,7 @@ sdf :discussion: nope"#; let mut rfd = RfdAsciidoc::new(content); - rfd.update_discussion(link); + rfd.update_discussion(&line(link)); let expected = r#"sdfsdf = sdfgsd discussion: fgsdfg @@ -757,7 +762,7 @@ dsfsdf sdf :state: nope"#; let mut rfd = RfdAsciidoc::new(content); - rfd.update_state(state); + rfd.update_state(&line(state)); let expected = r#"sdfsdf = sdfgsd state: fgsdfg @@ -779,7 +784,7 @@ dsfsdf sdf :state: nope"#; let mut rfd = RfdAsciidoc::new(content); - rfd.update_state(state); + rfd.update_state(&line(state)); let expected = r#"sdfsdf = sdfgsd state: fgsdfg @@ -921,7 +926,7 @@ in velit. #[test] fn test_update_asciidoc_labels() { let mut rfd = RfdAsciidoc::new(Cow::Borrowed(test_rfd_content())); - rfd.update_labels("newlabel1, newlabel2"); + rfd.update_labels(&line("newlabel1, newlabel2")); let labels = rfd.get_labels().unwrap(); let expected = "newlabel1, newlabel2".to_string(); assert_eq!(expected, labels); diff --git a/rfd-data/src/content/markdown.rs b/rfd-data/src/content/markdown.rs index 95217141..29e018c1 100644 --- a/rfd-data/src/content/markdown.rs +++ b/rfd-data/src/content/markdown.rs @@ -6,6 +6,7 @@ use regex::{regex, Regex}; use std::borrow::Cow; use super::RfdDocument; +use crate::SingleLineString; #[derive(Debug, Clone)] pub struct RfdMarkdown<'a> { @@ -95,7 +96,7 @@ impl<'a> RfdDocument for RfdMarkdown<'a> { self.attr("state") } - fn update_state(&mut self, value: &str) -> &mut Self { + fn update_state(&mut self, value: &SingleLineString) -> &mut Self { self.set_attr("state", value); self } @@ -104,7 +105,7 @@ impl<'a> RfdDocument for RfdMarkdown<'a> { self.attr("discussion") } - fn update_discussion(&mut self, value: &str) -> &mut Self { + fn update_discussion(&mut self, value: &SingleLineString) -> &mut Self { self.set_attr("discussion", value); self } @@ -117,7 +118,7 @@ impl<'a> RfdDocument for RfdMarkdown<'a> { self.attr("labels") } - fn update_labels(&mut self, value: &str) -> &mut Self { + fn update_labels(&mut self, value: &SingleLineString) -> &mut Self { self.set_attr("labels", value); self } @@ -160,6 +161,10 @@ impl<'a> RfdDocument for RfdMarkdown<'a> { mod tests { use crate::content::{markdown::RfdMarkdown, RfdDocument}; + fn line(value: &str) -> crate::SingleLineString { + value.to_string().try_into().unwrap() + } + // Read authors tests #[test] @@ -247,7 +252,7 @@ dsfsdf sdf authors: nope"#; let mut rfd = RfdMarkdown::new(content); - rfd.update_discussion(link); + rfd.update_discussion(&line(link)); let expected = r#"sdfsdf sdfsdf @@ -271,7 +276,7 @@ dsfsdf sdf authors: nope"#; let mut rfd = RfdMarkdown::new(content); - rfd.update_state(state); + rfd.update_state(&line(state)); let expected = r#"sdfsdf sdfsdf diff --git a/rfd-data/src/content/mod.rs b/rfd-data/src/content/mod.rs index 4d1e347a..09d36344 100644 --- a/rfd-data/src/content/mod.rs +++ b/rfd-data/src/content/mod.rs @@ -12,6 +12,8 @@ pub use template::{RenderableRfdTemplate, RfdTemplate, TemplateError}; use rfd_model::{schema_ext::ContentFormat, RfdRevision}; +use crate::SingleLineString; + pub trait RfdDocument { /// Extract the title from the internal content fn get_title(&self) -> Option<&str>; @@ -20,13 +22,13 @@ pub trait RfdDocument { fn get_state(&self) -> Option<&str>; // Update the state value stored within the document or add it if it does not exist - fn update_state(&mut self, value: &str) -> &mut Self; + fn update_state(&mut self, value: &SingleLineString) -> &mut Self; /// Get the discussion link stored within the document fn get_discussion(&self) -> Option<&str>; // Update the discussion link stored within the document or add it if it does not exist - fn update_discussion(&mut self, value: &str) -> &mut Self; + fn update_discussion(&mut self, value: &SingleLineString) -> &mut Self; /// Get the authors line stored within the document. The returned string may contain multiple /// names @@ -36,7 +38,7 @@ pub trait RfdDocument { fn get_labels(&self) -> Option<&str>; // Update the labels stored within the document or add them if they do not exist - fn update_labels(&mut self, value: &str) -> &mut Self; + fn update_labels(&mut self, value: &SingleLineString) -> &mut Self; // Get a reference to the contents of the RFD header fn header(&self) -> Option<&str>; @@ -84,7 +86,7 @@ impl<'a> RfdDocument for RfdContent<'a> { } } - fn update_state(&mut self, value: &str) -> &mut Self { + fn update_state(&mut self, value: &SingleLineString) -> &mut Self { match self { Self::Asciidoc(inner) => { inner.update_state(value); @@ -103,7 +105,7 @@ impl<'a> RfdDocument for RfdContent<'a> { } } - fn update_discussion(&mut self, value: &str) -> &mut Self { + fn update_discussion(&mut self, value: &SingleLineString) -> &mut Self { match self { Self::Asciidoc(inner) => { inner.update_discussion(value); @@ -129,7 +131,7 @@ impl<'a> RfdDocument for RfdContent<'a> { } } - fn update_labels(&mut self, value: &str) -> &mut Self { + fn update_labels(&mut self, value: &SingleLineString) -> &mut Self { match self { Self::Asciidoc(inner) => { inner.update_labels(value); diff --git a/rfd-data/src/lib.rs b/rfd-data/src/lib.rs index 8c813ae7..558c487d 100644 --- a/rfd-data/src/lib.rs +++ b/rfd-data/src/lib.rs @@ -4,7 +4,10 @@ use std::fmt::Display; -use schemars::JsonSchema; +use schemars::{ + schema::{InstanceType, Schema, SchemaObject, StringValidation}, + JsonSchema, +}; use serde::{Deserialize, Serialize}; pub mod content; @@ -100,3 +103,85 @@ impl<'a> TryFrom<&'a str> for RfdState { } } } + +/// A string that contains no line breaks (`\r` or `\n`). Fails to deserialize otherwise. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(try_from = "String", into = "String")] +pub struct SingleLineString(String); + +/// Inlined as a string with a pattern so that generated clients validate it too +impl JsonSchema for SingleLineString { + fn schema_name() -> String { + "SingleLineString".to_string() + } + + fn is_referenceable() -> bool { + false + } + + fn json_schema(_: &mut schemars::r#gen::SchemaGenerator) -> Schema { + SchemaObject { + instance_type: Some(InstanceType::String.into()), + string: Some(Box::new(StringValidation { + pattern: Some(r"^[^\r\n]*$".to_string()), + ..Default::default() + })), + ..Default::default() + } + .into() + } +} + +impl SingleLineString { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for SingleLineString { + type Error = &'static str; + + fn try_from(value: String) -> Result { + if value.contains(['\r', '\n']) { + Err("value must not contain line breaks") + } else { + Ok(Self(value)) + } + } +} + +impl From for String { + fn from(value: SingleLineString) -> Self { + value.0 + } +} + +impl From for SingleLineString { + fn from(state: RfdState) -> Self { + Self(state.to_string()) + } +} + +impl std::ops::Deref for SingleLineString { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + +#[cfg(test)] +mod tests { + use super::SingleLineString; + + #[test] + fn test_single_line_string_rejects_line_breaks() { + for value in ["a\nb", "a\r\nb", "a\rb", "\n"] { + assert!(SingleLineString::try_from(value.to_string()).is_err()); + assert!(serde_json::from_value::(serde_json::json!(value)).is_err()); + } + + let parsed: SingleLineString = serde_json::from_value(serde_json::json!("ok")).unwrap(); + assert_eq!(parsed.as_str(), "ok"); + } +} diff --git a/rfd-processor/src/content/mod.rs b/rfd-processor/src/content/mod.rs index c937218f..6c1dc4b9 100644 --- a/rfd-processor/src/content/mod.rs +++ b/rfd-processor/src/content/mod.rs @@ -15,7 +15,7 @@ use base64::DecodeError; use octorust::Client; use rfd_data::{ content::{RfdAsciidoc, RfdContent, RfdDocument, RfdMarkdown}, - RfdNumber, + RfdNumber, SingleLineString, }; use rfd_github::{GitHubError, GitHubRfdLocation}; use rfd_model::schema_ext::ContentFormat; @@ -223,7 +223,7 @@ impl<'a> RfdDocument for RenderableRfd<'a> { RfdDocument::get_state(&self.content) } - fn update_state(&mut self, value: &str) -> &mut Self { + fn update_state(&mut self, value: &SingleLineString) -> &mut Self { RfdDocument::update_state(&mut self.content, value); self } @@ -232,7 +232,7 @@ impl<'a> RfdDocument for RenderableRfd<'a> { RfdDocument::get_discussion(&self.content) } - fn update_discussion(&mut self, value: &str) -> &mut Self { + fn update_discussion(&mut self, value: &SingleLineString) -> &mut Self { RfdDocument::update_discussion(&mut self.content, value); self } @@ -245,7 +245,7 @@ impl<'a> RfdDocument for RenderableRfd<'a> { RfdDocument::get_labels(&self.content) } - fn update_labels(&mut self, value: &str) -> &mut Self { + fn update_labels(&mut self, value: &SingleLineString) -> &mut Self { RfdDocument::update_labels(&mut self.content, value); self } diff --git a/rfd-processor/src/rfd.rs b/rfd-processor/src/rfd.rs index 347cf721..7c9dbe2d 100644 --- a/rfd-processor/src/rfd.rs +++ b/rfd-processor/src/rfd.rs @@ -7,7 +7,7 @@ use std::sync::Mutex; use chrono::{DateTime, Utc}; use newtype_uuid::TypedUuid; use octorust::{Client, ClientError}; -use rfd_data::{content::RfdDocument, RfdNumber}; +use rfd_data::{content::RfdDocument, RfdNumber, RfdState, SingleLineString}; use rfd_github::{GitHubError, GitHubRfdReadme, GitHubRfdUpdate}; use rfd_model::{ schema_ext::{ContentFormat, Visibility}, @@ -132,15 +132,20 @@ impl PersistedRfd { } pub fn update_discussion(&mut self, new_discussion_url: impl ToString) { - let new_discussion_url = new_discussion_url.to_string(); + match SingleLineString::try_from(new_discussion_url.to_string()) { + Ok(new_discussion_url) => { + let mut content = self.content(); + content.update_discussion(&new_discussion_url); - let mut content = self.content(); - content.update_discussion(&new_discussion_url); - - self.revision.content = content.into_inner_content(); - self.revision.discussion = Some(new_discussion_url); + self.revision.content = content.into_inner_content(); + self.revision.discussion = Some(new_discussion_url.into()); - *self.needs_update.lock().unwrap() = true; + *self.needs_update.lock().unwrap() = true; + } + Err(err) => { + tracing::warn!(?err, "Refusing to set an invalid discussion url"); + } + } } pub fn is_state(&self, state: &str) -> bool { @@ -151,14 +156,14 @@ impl PersistedRfd { .unwrap_or(false) } - pub fn update_state(&mut self, new_state: impl ToString) { - let new_state = new_state.to_string(); + pub fn update_state(&mut self, new_state: RfdState) { + let new_state = SingleLineString::from(new_state); let mut content = self.content(); content.update_state(&new_state); self.revision.content = content.into_inner_content(); - self.revision.state = Some(new_state); + self.revision.state = Some(new_state.into()); *self.needs_update.lock().unwrap() = true; } diff --git a/rfd-processor/src/updater/ensure_pr_state.rs b/rfd-processor/src/updater/ensure_pr_state.rs index edac0d7f..0df7525a 100644 --- a/rfd-processor/src/updater/ensure_pr_state.rs +++ b/rfd-processor/src/updater/ensure_pr_state.rs @@ -3,6 +3,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. use async_trait::async_trait; +use rfd_data::RfdState; use std::cmp::Ordering; use tracing::instrument; @@ -58,7 +59,7 @@ impl RfdUpdateAction for EnsureRfdWithPullRequestIsInValidState { && !new.is_state("ideation") && !new.is_state("abandoned") { - new.update_state("discussion"); + new.update_state(RfdState::Discussion); requires_source_commit = true; } else { tracing::debug!("RFD is in a valid state and does not need to be updated"); diff --git a/rfd-sdk/Cargo.toml b/rfd-sdk/Cargo.toml index 8dce41b8..94ac6799 100644 --- a/rfd-sdk/Cargo.toml +++ b/rfd-sdk/Cargo.toml @@ -8,6 +8,7 @@ edition = { workspace = true } [dependencies] chrono = { workspace = true, features = ["serde"] } progenitor-client = { workspace = true } +regress = { workspace = true } schemars = { workspace = true, features = ["chrono", "uuid1"] } serde = { workspace = true } serde_json = { workspace = true } diff --git a/rfd-sdk/src/generated/sdk.rs b/rfd-sdk/src/generated/sdk.rs index b8eb2906..6b93e199 100644 --- a/rfd-sdk/src/generated/sdk.rs +++ b/rfd-sdk/src/generated/sdk.rs @@ -1704,7 +1704,7 @@ pub mod types { #[serde(skip_serializing_if = "::std::option::Option::is_none")] pub message: ::std::option::Option<::std::string::String>, /// Full value to set this attribute to in the existing RFD contents - pub value: ::std::string::String, + pub value: RfdAttrValueValue, } impl RfdAttrValue { @@ -1713,6 +1713,74 @@ pub mod types { } } + /// Full value to set this attribute to in the existing RFD contents + #[derive( + :: serde :: Serialize, + Clone, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, + schemars :: JsonSchema, + )] + #[serde(transparent)] + pub struct RfdAttrValueValue(::std::string::String); + impl ::std::ops::Deref for RfdAttrValueValue { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } + } + + impl ::std::convert::From for ::std::string::String { + fn from(value: RfdAttrValueValue) -> Self { + value.0 + } + } + + impl ::std::str::FromStr for RfdAttrValueValue { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> ::std::result::Result { + static PATTERN: ::std::sync::LazyLock<::regress::Regex> = + ::std::sync::LazyLock::new(|| ::regress::Regex::new("^[^\\r\\n]*$").unwrap()); + if PATTERN.find(value).is_none() { + return Err("doesn't match pattern \"^[^\\r\\n]*$\"".into()); + } + Ok(Self(value.to_string())) + } + } + + impl ::std::convert::TryFrom<&str> for RfdAttrValueValue { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } + } + + impl ::std::convert::TryFrom<::std::string::String> for RfdAttrValueValue { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } + } + + impl<'de> ::serde::Deserialize<'de> for RfdAttrValueValue { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::string::String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } + } + /// `RfdId` #[derive( :: serde :: Deserialize, @@ -8196,7 +8264,7 @@ pub mod types { ::std::option::Option<::std::string::String>, ::std::string::String, >, - value: ::std::result::Result<::std::string::String, ::std::string::String>, + value: ::std::result::Result, } impl ::std::default::Default for RfdAttrValue { @@ -8221,7 +8289,7 @@ pub mod types { } pub fn value(mut self, value: T) -> Self where - T: ::std::convert::TryInto<::std::string::String>, + T: ::std::convert::TryInto, T::Error: ::std::fmt::Display, { self.value = value diff --git a/rfd-ts/src/validate.ts b/rfd-ts/src/validate.ts index e1a0ec60..55b2d601 100644 --- a/rfd-ts/src/validate.ts +++ b/rfd-ts/src/validate.ts @@ -666,7 +666,7 @@ export const RfdAttr = z.preprocess( export const RfdAttrValue = z.preprocess( processResponseBody, - z.object({ 'message': z.string().nullable().optional(), 'value': z.string() }), + z.object({ 'message': z.string().nullable().optional(), 'value': z.string().regex(/^[^\r\n]*$/) }), ) export const RfdPdfId = z.preprocess(processResponseBody, z.record(z.string(), z.unknown())) From b7fb196193eda8e0c94dad51b5655d6d94f2fe88 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Thu, 12 Nov 2026 09:50:16 -0500 Subject: [PATCH 2/2] One less test --- rfd-api/src/endpoints/rfd.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/rfd-api/src/endpoints/rfd.rs b/rfd-api/src/endpoints/rfd.rs index 90fcf5d9..7072c611 100644 --- a/rfd-api/src/endpoints/rfd.rs +++ b/rfd-api/src/endpoints/rfd.rs @@ -1009,13 +1009,7 @@ mod tests { permissions::RfdPermission, }; - use super::{list_rfds_op, RfdAttrValue}; - - #[test] - fn test_attr_value_rejects_line_breaks() { - let body = serde_json::json!({ "value": "a\n:authors: b" }); - assert!(serde_json::from_value::(body).is_err()); - } + use super::list_rfds_op; async fn ctx() -> RfdContext { let private_rfd_id_1 = Uuid::new_v4();