| 148 |
148 |
|
Ok(())
|
| 149 |
149 |
|
}
|
| 150 |
150 |
|
|
|
151 |
+ |
/// Namespaces MNW writes itself and nobody else may.
|
|
152 |
+ |
///
|
|
153 |
+ |
/// `refs/notes/mnw/*` is where build results, issue links and scan verdicts go
|
|
154 |
+ |
/// once P6 mirrors them out of Postgres. Reserving it before anyone can write
|
|
155 |
+ |
/// there is the cheap order: taking a prefix back after repositories carry
|
|
156 |
+ |
/// user notes under it means deciding what happens to those notes.
|
|
157 |
+ |
const RESERVED_NOTE_NAMESPACE: &str = "mnw";
|
|
158 |
+ |
|
|
159 |
+ |
/// Validate a git notes namespace: the part after `refs/notes/`.
|
|
160 |
+ |
///
|
|
161 |
+ |
/// It becomes a ref path, so it has to survive git's own rules — and it is
|
|
162 |
+ |
/// user input that gets concatenated into a ref name, so anything clever with
|
|
163 |
+ |
/// dots, slashes or control characters is rejected rather than normalized.
|
|
164 |
+ |
pub fn validate_note_namespace(namespace: &str) -> Result<(), AppError> {
|
|
165 |
+ |
if namespace.is_empty() || namespace.len() > limits::NOTE_NAMESPACE_MAX {
|
|
166 |
+ |
return Err(AppError::validation(format!(
|
|
167 |
+ |
"Notes namespace must be 1-{} characters",
|
|
168 |
+ |
limits::NOTE_NAMESPACE_MAX
|
|
169 |
+ |
)));
|
|
170 |
+ |
}
|
|
171 |
+ |
if !namespace
|
|
172 |
+ |
.chars()
|
|
173 |
+ |
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/')
|
|
174 |
+ |
{
|
|
175 |
+ |
return Err(AppError::validation(
|
|
176 |
+ |
"Notes namespace can only contain letters, numbers, hyphens, underscores, dots and slashes".to_string(),
|
|
177 |
+ |
));
|
|
178 |
+ |
}
|
|
179 |
+ |
|
|
180 |
+ |
// Git's ref rules, the subset the character set above leaves reachable.
|
|
181 |
+ |
for component in namespace.split('/') {
|
|
182 |
+ |
if component.is_empty() {
|
|
183 |
+ |
return Err(AppError::validation(
|
|
184 |
+ |
"Notes namespace cannot have an empty path component".to_string(),
|
|
185 |
+ |
));
|
|
186 |
+ |
}
|
|
187 |
+ |
// Compared as bytes: git's `.lock` rule is a literal, case-sensitive
|
|
188 |
+ |
// suffix on the ref name rather than a file extension, and the string
|
|
189 |
+ |
// form reads to clippy as the latter.
|
|
190 |
+ |
let lock_suffix = component.as_bytes().ends_with(b".lock");
|
|
191 |
+ |
if component.starts_with('.') || lock_suffix || component.contains("..") {
|
|
192 |
+ |
return Err(AppError::validation(
|
|
193 |
+ |
"Notes namespace components cannot start with a dot, contain '..', or end in '.lock'"
|
|
194 |
+ |
.to_string(),
|
|
195 |
+ |
));
|
|
196 |
+ |
}
|
|
197 |
+ |
}
|
|
198 |
+ |
|
|
199 |
+ |
let reserved = namespace == RESERVED_NOTE_NAMESPACE
|
|
200 |
+ |
|| namespace.starts_with(&format!("{RESERVED_NOTE_NAMESPACE}/"));
|
|
201 |
+ |
if reserved {
|
|
202 |
+ |
return Err(AppError::validation(format!(
|
|
203 |
+ |
"The {RESERVED_NOTE_NAMESPACE} namespace is written by makenot.work and cannot be edited here"
|
|
204 |
+ |
)));
|
|
205 |
+ |
}
|
|
206 |
+ |
Ok(())
|
|
207 |
+ |
}
|
|
208 |
+ |
|
|
209 |
+ |
/// Validate the body of a git note.
|
|
210 |
+ |
pub fn validate_note_content(content: &str) -> Result<(), AppError> {
|
|
211 |
+ |
if content.trim().is_empty() {
|
|
212 |
+ |
return Err(AppError::validation(
|
|
213 |
+ |
"A note cannot be empty. Remove it instead.".to_string(),
|
|
214 |
+ |
));
|
|
215 |
+ |
}
|
|
216 |
+ |
if content.chars().count() > limits::NOTE_CONTENT_MAX {
|
|
217 |
+ |
return Err(AppError::validation(format!(
|
|
218 |
+ |
"A note must be {} characters or less",
|
|
219 |
+ |
limits::NOTE_CONTENT_MAX
|
|
220 |
+ |
)));
|
|
221 |
+ |
}
|
|
222 |
+ |
super::reject_control_chars_multiline("Note", content)?;
|
|
223 |
+ |
Ok(())
|
|
224 |
+ |
}
|
|
225 |
+ |
|
| 151 |
226 |
|
#[cfg(test)]
|
| 152 |
227 |
|
mod tests {
|
| 153 |
228 |
|
use super::*;
|
| 154 |
229 |
|
|
|
230 |
+ |
#[test]
|
|
231 |
+ |
fn a_notes_namespace_has_to_survive_being_a_ref_path() {
|
|
232 |
+ |
assert!(validate_note_namespace("commits").is_ok());
|
|
233 |
+ |
assert!(validate_note_namespace("review/security").is_ok());
|
|
234 |
+ |
|
|
235 |
+ |
assert!(validate_note_namespace("").is_err());
|
|
236 |
+ |
assert!(validate_note_namespace("has space").is_err());
|
|
237 |
+ |
assert!(validate_note_namespace("trailing/").is_err());
|
|
238 |
+ |
assert!(validate_note_namespace("double//slash").is_err());
|
|
239 |
+ |
assert!(validate_note_namespace(".hidden").is_err());
|
|
240 |
+ |
assert!(validate_note_namespace("up/../out").is_err());
|
|
241 |
+ |
assert!(validate_note_namespace("branch.lock").is_err());
|
|
242 |
+ |
assert!(validate_note_namespace(&"a".repeat(65)).is_err());
|
|
243 |
+ |
}
|
|
244 |
+ |
|
|
245 |
+ |
#[test]
|
|
246 |
+ |
fn the_server_owned_namespace_is_not_writable_from_the_browser() {
|
|
247 |
+ |
// Reserved before anything can be written there, because taking the
|
|
248 |
+ |
// prefix back afterwards would mean deciding what happens to the notes
|
|
249 |
+ |
// already under it.
|
|
250 |
+ |
assert!(validate_note_namespace("mnw").is_err());
|
|
251 |
+ |
assert!(validate_note_namespace("mnw/builds").is_err());
|
|
252 |
+ |
// Not a blanket prefix match: only the namespace and what is under it.
|
|
253 |
+ |
assert!(validate_note_namespace("mnwish").is_ok());
|
|
254 |
+ |
}
|
|
255 |
+ |
|
|
256 |
+ |
#[test]
|
|
257 |
+ |
fn note_content_is_bounded_and_never_empty() {
|
|
258 |
+ |
assert!(validate_note_content("a real note").is_ok());
|
|
259 |
+ |
assert!(validate_note_content("").is_err());
|
|
260 |
+ |
assert!(validate_note_content(" \n ").is_err());
|
|
261 |
+ |
assert!(validate_note_content(&"a".repeat(50_001)).is_err());
|
|
262 |
+ |
assert!(validate_note_content("null\0byte").is_err());
|
|
263 |
+ |
// Newlines are the point of a note body.
|
|
264 |
+ |
assert!(validate_note_content("two\nlines").is_ok());
|
|
265 |
+ |
}
|
|
266 |
+ |
|
| 155 |
267 |
|
#[test]
|
| 156 |
268 |
|
fn test_validate_project_slug_valid() {
|
| 157 |
269 |
|
assert!(validate_project_slug("my-project").is_ok());
|