diff --git a/Cargo.lock b/Cargo.lock index 9376e6f..e100ce4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,6 +295,7 @@ version = "0.2.2" dependencies = [ "annotate-snippets", "clap", + "libc", "percent-encoding", "pulldown-cmark", "serde_yaml_ng", diff --git a/Cargo.toml b/Cargo.toml index a05e898..a64563d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,6 @@ tempfile = "3" [profile.dist] inherits = "release" lto = "thin" + +[target."cfg(unix)".dependencies] +libc = "0.2" diff --git a/README.md b/README.md index 865a0fb..677ca4c 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,9 @@ Markdown reference labels are not filesystem targets and are not checked. Paths are checked literally: no automatic `.md` extension, website routing, or build-template expansion. Generated targets must already exist when checking; -intentional broken links are reported too. There are no suppressions yet. +intentional broken links are reported too. Invalid filenames and paths that are +too long produce findings without stopping the scan. Other inspection failures, +such as permission errors, remain execution errors. There are no suppressions yet. ## Development diff --git a/src/rules/markdown.rs b/src/rules/markdown.rs index be8b9d6..e167f1d 100644 --- a/src/rules/markdown.rs +++ b/src/rules/markdown.rs @@ -61,6 +61,9 @@ pub(crate) fn check(path: &Path, source: &str) -> Result, String { Some(format!("Target {raw_path:?} does not exist")) } + Err(error) if invalid_path_error(&error) => { + Some(format!("Target {raw_path:?} is an invalid path: {error}")) + } Err(error) => { return Err(format!( "cannot inspect link target {} from {}: {error}", @@ -88,6 +91,22 @@ pub(crate) fn check(path: &Path, source: &str) -> Result, String Ok(diagnostics) } +fn invalid_path_error(error: &io::Error) -> bool { + if error.kind() == io::ErrorKind::InvalidInput { + return true; + } + #[cfg(unix)] + if error.raw_os_error() == Some(libc::ENAMETOOLONG) { + return true; + } + // ERROR_INVALID_NAME, ERROR_BAD_PATHNAME, ERROR_FILENAME_EXCED_RANGE. + #[cfg(windows)] + if matches!(error.raw_os_error(), Some(123 | 161 | 206)) { + return true; + } + false +} + fn has_scheme(destination: &str) -> bool { let Some((scheme, _)) = destination.split_once(':') else { return false; @@ -103,6 +122,13 @@ mod tests { use super::*; use tempfile::TempDir; + #[test] + fn inaccessible_targets_are_not_invalid_paths() { + for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Other] { + assert!(!invalid_path_error(&io::Error::from(kind))); + } + } + fn project() -> TempDir { let root = tempfile::tempdir().unwrap(); fs::create_dir(root.path().join("docs")).unwrap(); diff --git a/tests/invalid_link_paths.rs b/tests/invalid_link_paths.rs new file mode 100644 index 0000000..52c23f7 --- /dev/null +++ b/tests/invalid_link_paths.rs @@ -0,0 +1,55 @@ +use std::{ + fs, + path::Path, + process::{Command, Output}, +}; + +fn run(path: &Path) -> Output { + Command::new(env!("CARGO_BIN_EXE_ryni")) + .arg("check") + .arg(path) + .output() + .unwrap() +} + +fn write_file(root: &Path, name: &str, content: &str) { + let path = root.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); +} + +#[test] +fn invalid_link_paths_do_not_abort_remaining_links_or_files() { + let project = tempfile::tempdir().unwrap(); + let root = project.path(); + write_file( + root, + "a.md", + &format!("[Too long]({})\n[Missing](missing.txt)\n", "a".repeat(300)), + ); + write_file(root, "z.md", "[Later](missing.txt)\n"); + let output = run(root); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert!(output.stderr.is_empty()); + assert!(stdout.contains("z.md:1:1"), "{stdout}"); + assert!(stdout.ends_with("Found 3 errors.\n"), "{stdout}"); +} + +#[test] +fn nbsp_reference_destination_does_not_abort_scan() { + let project = tempfile::tempdir().unwrap(); + let root = project.path(); + write_file( + root, + "a.md", + "See [#1].\n\n[#1]:\u{a0}https://example.com/pull/1\n", + ); + write_file(root, "z.md", "[Later](missing.txt)\n"); + let output = run(root); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert!(output.stderr.is_empty()); + assert!(stdout.contains("z.md:1:1"), "{stdout}"); + assert!(stdout.ends_with("Found 2 errors.\n"), "{stdout}"); +} diff --git a/website/rules.md b/website/rules.md index 72255a9..127dc20 100644 --- a/website/rules.md +++ b/website/rules.md @@ -45,5 +45,7 @@ Markdown reference labels are not filesystem targets and are not checked. Paths are checked literally: no automatic `.md` extension, website routing, or build-template expansion. Generated targets must already exist when checking; -intentional broken links are reported too. There are no suppressions yet. +intentional broken links are reported too. Invalid filenames and paths that are +too long produce findings without stopping the scan. Other inspection failures, +such as permission errors, remain execution errors. There are no suppressions yet.