fix: strip frontmatter with the same leniency it is parsed with
extract_frontmatter tolerates CRLF line endings, a leading UTF-8 BOM, and blank lines before the opening ---, but strip_frontmatter required the opener to be exactly "---\n" at byte 0. So a command or agent-type file saved with CRLF (common on Windows, or any core.autocrlf checkout) parsed its name and description fine and got discovered, yet strip_frontmatter returned the whole file. The raw --- block then leaked into the rendered slash-command prompt or the subagent system prompt as if it were content. strip_frontmatter now scans line by line and compares trimmed lines, the same way extract_frontmatter does, so whenever one finds a block the other removes exactly that block.
paydii committed
Jul 11, 2026 at 15:10 UTC
07fd7e5a63dc003db17a946b3fe281d1b1896b57
1 file changed
+48
-17
src/frontmatter.rs
+48
-17
@@ -94,28 +94,35 @@ pub fn unquote(value: &str) -> String {
94
/// Return the content after the frontmatter block, or the whole input if
95
/// there is no frontmatter.
96
pub fn strip_frontmatter(contents: &str) -> &str {
97
+ // Mirror `extract_frontmatter`'s leniency exactly: tolerate a UTF-8 BOM,
98
+ // leading blank lines before the opener, and CRLF line endings (compare
99
+ // trimmed lines). If the two disagree, a file whose frontmatter parses
100
+ // still leaks that raw block into the body — see the round-trip test.
101
let trimmed = contents.trim_start_matches('\u{feff}');
98
- let after_opener = match trimmed.strip_prefix("---") {
99
- Some(rest) => match rest.strip_prefix('\n') {
100
- Some(rest) => rest,
101
- None => return contents,
102
- },
103
- None => return contents,
104
- };
105
- // Find the closing `---` line.
106
- let mut search = after_opener;
107
- let mut offset = 0;
102
+ let mut rest = trimmed;
103
loop {
109
- let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());
110
- let (line, after) = search.split_at(end);
111
- if line.trim() == "---" {
112
- return &after_opener[offset + end..];
104
+ let line_end = rest.find('\n').map(|i| i + 1).unwrap_or(rest.len());
105
+ let (line, after) = rest.split_at(line_end);
106
+ if line.trim().is_empty() {
107
+ rest = after;
108
+ continue;
109
}
114
- if after.is_empty() {
110
+ if line.trim() != "---" {
111
return contents;
112
}
117
- offset += end;
118
- search = after;
113
+ // `after` begins just past the opening `---` line; find the closing one.
114
+ let mut search = after;
115
+ loop {
116
+ let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());
117
+ let (l, a) = search.split_at(end);
118
+ if l.trim() == "---" {
119
+ return a;
120
+ }
121
+ if a.is_empty() {
122
+ return contents;
123
+ }
124
+ search = a;
125
+ }
126
}
127
}
128
@@ -166,4 +173,28 @@ mod tests {
173
fn strip_frontmatter_passes_through_without_block() {
174
assert_eq!(strip_frontmatter("just body"), "just body");
175
}
176
+
177
+ #[test]
178
+ fn extract_and_strip_agree_on_crlf_and_leading_blanks() {
179
+ // Whenever extract_frontmatter finds a block, strip_frontmatter must
180
+ // remove exactly that block and never leak it into the body. These
181
+ // inputs (CRLF endings, a leading blank line, a BOM) parse fine in
182
+ // extract but used to defeat strip's byte-exact opener check.
183
+ for md in [
184
+ "---\r\nname: foo\r\ndescription: bar\r\n---\r\n\r\nBody.\r\n",
185
+ "\n---\nname: foo\ndescription: bar\n---\n\nBody.\n",
186
+ "\u{feff}---\nname: foo\ndescription: bar\n---\n\nBody.\n",
187
+ ] {
188
+ assert!(
189
+ extract_frontmatter(md).is_some(),
190
+ "extract should find frontmatter in {md:?}"
191
+ );
192
+ let body = strip_frontmatter(md);
193
+ assert!(
194
+ !body.contains("name: foo"),
195
+ "frontmatter leaked into body for {md:?}: {body:?}"
196
+ );
197
+ assert_eq!(body.trim(), "Body.");
198
+ }
199
+ }
200
}