mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 03:46:09 +00:00
452 lines
22 KiB
Rust
452 lines
22 KiB
Rust
//! The folders automation may read and write.
|
|
//!
|
|
//! `pdfcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the
|
|
//! files an agent's tools reach, with the names and wording VectorCraft uses. [`AutomationRoots`]
|
|
//! holds the two folders. Every path a tool reads is checked with [`Access::Read`] and every path
|
|
//! it writes with [`Access::Write`], in `Automation::resolve`, which all of them go through:
|
|
//!
|
|
//! - The path is made absolute against the working directory, as the file system reads it. Its
|
|
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
|
|
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
|
|
//! is a link that leads nowhere).
|
|
//! - A path on another network share or in a device namespace (`\\host\share`, `\\.\pipe\x`)
|
|
//! than the root's is refused by name, before anything is resolved: resolving it would contact
|
|
//! the host.
|
|
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
|
|
//! `/work`), without regard to case on Windows.
|
|
//! - Read and write access are separate: a root given alone grants only its own kind of access.
|
|
//!
|
|
//! Without roots nothing is checked. A path is checked, then opened: a process that can change the
|
|
//! folders in between (swapping in a link) can still win that race. The roots keep an agent to the
|
|
//! files it was given; they don't guard against other programs on the computer.
|
|
|
|
use std::ffi::OsString;
|
|
use std::path::{Component, Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
/// Read access or write access.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Access {
|
|
Read,
|
|
Write,
|
|
}
|
|
|
|
/// The folders automation may read and write; a root left out grants no access of its kind.
|
|
#[derive(Debug)]
|
|
pub struct AutomationRoots {
|
|
read: Option<Root>,
|
|
write: Option<Root>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Root {
|
|
/// The folder made absolute, links followed: what paths are compared with.
|
|
resolved: PathBuf,
|
|
/// The same without Windows' `\\?\` prefix, for people.
|
|
shown: PathBuf,
|
|
}
|
|
|
|
impl Root {
|
|
/// The folder at `path`, which must exist.
|
|
fn open(path: &Path, access: Access) -> Result<Self, String> {
|
|
let kind = match access {
|
|
Access::Read => "read",
|
|
Access::Write => "write",
|
|
};
|
|
let resolved = std::fs::canonicalize(path)
|
|
.and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) })
|
|
.map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?;
|
|
Ok(Self { shown: plain(resolved.clone()), resolved })
|
|
}
|
|
}
|
|
|
|
impl AutomationRoots {
|
|
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
|
|
/// (nothing is confined then).
|
|
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
|
|
if read.is_none() && write.is_none() {
|
|
return Ok(None);
|
|
}
|
|
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
|
|
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
|
|
Ok(Some(Arc::new(Self { read, write })))
|
|
}
|
|
|
|
/// The read root, absolute with its links followed.
|
|
pub fn read_root(&self) -> Option<&Path> {
|
|
self.read.as_ref().map(|r| r.shown.as_path())
|
|
}
|
|
|
|
/// The write root, absolute with its links followed.
|
|
pub fn write_root(&self) -> Option<&Path> {
|
|
self.write.as_ref().map(|r| r.shown.as_path())
|
|
}
|
|
|
|
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
|
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
|
self.check_path(path, Path::new(path), access)
|
|
}
|
|
|
|
/// [`check`](Self::check) for a path that was already put together from `shown` (the way the
|
|
/// caller wrote it, which is what refusals quote).
|
|
pub fn check_path(&self, shown: &str, target: &Path, access: Access) -> Result<(), String> {
|
|
let root = match access {
|
|
Access::Read => self.read.as_ref(),
|
|
Access::Write => self.write.as_ref(),
|
|
};
|
|
let Some(Root { resolved: folder, shown: root_shown }) = root else {
|
|
return match access {
|
|
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {shown}")),
|
|
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown}")),
|
|
};
|
|
};
|
|
let absolute = absolute(shown, target)?;
|
|
// Another network share or a device is refused by name, as `--root` refuses it: even
|
|
// resolving the path would contact the host (#4003).
|
|
if !foreign_share(&absolute, folder) && inside(&resolve(shown, &absolute)?, folder) {
|
|
return Ok(());
|
|
}
|
|
let root = root_shown.display();
|
|
match access {
|
|
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {shown}")),
|
|
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {shown}")),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read.
|
|
fn plain(path: PathBuf) -> PathBuf {
|
|
if !cfg!(windows) {
|
|
return path;
|
|
}
|
|
let text = path.to_string_lossy();
|
|
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
|
|
return PathBuf::from(format!(r"\\{rest}"));
|
|
}
|
|
match text.strip_prefix(r"\\?\") {
|
|
Some(rest) => PathBuf::from(rest),
|
|
None => path,
|
|
}
|
|
}
|
|
|
|
/// `path` made absolute against the working directory, by name: nothing on disk is read.
|
|
fn absolute(shown: &str, path: &Path) -> Result<PathBuf, String> {
|
|
if path.as_os_str().is_empty() {
|
|
return Err("automation path rejected: path is empty".into());
|
|
}
|
|
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
|
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
|
return Err(format!("automation path rejected: reserved device names are not allowed: {shown}"));
|
|
}
|
|
std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))
|
|
}
|
|
|
|
/// `absolute` with its links followed; for a path that doesn't exist (yet), its nearest existing
|
|
/// folder's links followed and the rest appended.
|
|
fn resolve(shown: &str, absolute: &Path) -> Result<PathBuf, String> {
|
|
// The names below the nearest existing folder, last first.
|
|
let mut missing: Vec<OsString> = vec![];
|
|
let mut at = absolute;
|
|
loop {
|
|
match std::fs::canonicalize(at) {
|
|
Ok(mut found) => {
|
|
found.extend(missing.iter().rev());
|
|
return Ok(found);
|
|
}
|
|
Err(e) => match std::fs::symlink_metadata(at) {
|
|
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
|
|
// There, yet its real path can't be had (no permission): nothing to compare.
|
|
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
|
},
|
|
}
|
|
let parent = at.parent();
|
|
match (at.components().next_back(), parent) {
|
|
(Some(Component::Normal(name)), Some(parent)) => {
|
|
missing.push(name.to_os_string());
|
|
at = parent;
|
|
}
|
|
(Some(Component::ParentDir), _) => {
|
|
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
|
|
}
|
|
// Down to the root (or drive) and it doesn't exist either.
|
|
_ => {
|
|
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
|
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
|
|
/// Windows, whose file systems ignore it)?
|
|
fn inside(path: &Path, root: &Path) -> bool {
|
|
let mut path = path.components();
|
|
root.components().all(|r| path.next().is_some_and(|p| same_component(p, r)))
|
|
}
|
|
|
|
fn same_component(a: Component, b: Component) -> bool {
|
|
if a == b {
|
|
return true;
|
|
}
|
|
if !cfg!(windows) {
|
|
return false;
|
|
}
|
|
match (a.as_os_str().to_str(), b.as_os_str().to_str()) {
|
|
(Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Whether `path` names another network share or device namespace than `root` (Windows
|
|
/// `\\host\share`, `\\?\UNC\…`, `\\.\…`, `\\?\…`). Those are refused by name: even checking
|
|
/// that one exists would contact the host. Drive letters are left to the normal check.
|
|
pub(crate) fn foreign_share(path: &Path, root: &Path) -> bool {
|
|
use std::path::Prefix;
|
|
fn share(p: &Path) -> Option<String> {
|
|
let Some(Component::Prefix(prefix)) = p.components().next() else { return None };
|
|
let (kind, a, b) = match prefix.kind() {
|
|
Prefix::Disk(_) | Prefix::VerbatimDisk(_) => return None,
|
|
Prefix::UNC(host, share) | Prefix::VerbatimUNC(host, share) => ("unc", host, share),
|
|
Prefix::DeviceNS(name) => ("device", name, std::ffi::OsStr::new("")),
|
|
Prefix::Verbatim(name) => ("verbatim", name, std::ffi::OsStr::new("")),
|
|
};
|
|
Some(format!("{kind}\\{}\\{}", a.to_string_lossy(), b.to_string_lossy()).to_lowercase())
|
|
}
|
|
share(path).is_some_and(|s| share(root).as_ref() != Some(&s))
|
|
}
|
|
|
|
/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.
|
|
fn is_device_name(component: &str) -> bool {
|
|
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
|
|
let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
|
|
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A fresh folder holding `in` (read root), `out` (write root) and `outside`.
|
|
fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
|
|
let base = std::env::temp_dir().join(format!("pc-file-access-{name}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&base);
|
|
for d in ["in/sub", "out", "outside", "in2"] {
|
|
std::fs::create_dir_all(base.join(d)).unwrap();
|
|
}
|
|
std::fs::write(base.join("in/a.pdf"), "x").unwrap();
|
|
std::fs::write(base.join("outside/secret.pdf"), "x").unwrap();
|
|
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
|
|
(base, roots)
|
|
}
|
|
|
|
fn s(p: PathBuf) -> String {
|
|
p.to_string_lossy().into_owned()
|
|
}
|
|
|
|
#[test]
|
|
fn paths_inside_the_roots_pass_and_others_dont() {
|
|
let (base, roots) = folders("inside");
|
|
assert_eq!(roots.check(&s(base.join("in/a.pdf")), Access::Read), Ok(()));
|
|
assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself");
|
|
assert_eq!(roots.check(&s(base.join("in/sub/new.pdf")), Access::Read), Ok(()), "missing files are checked by their folder");
|
|
assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder");
|
|
let e = roots.check(&s(base.join("outside/secret.pdf")), Access::Read).unwrap_err();
|
|
assert!(e.starts_with("automation path rejected: outside the read root"), "{e}");
|
|
// Read and write access are separate.
|
|
let e = roots.check(&s(base.join("in/a.pdf")), Access::Write).unwrap_err();
|
|
assert!(e.contains("outside the write root"), "{e}");
|
|
assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root"));
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
/// #4003: another share or a device namespace is refused by name, with the same words as any
|
|
/// other path outside the root, before anything is resolved (which would contact the host).
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn other_shares_and_devices_are_refused_before_they_are_resolved() {
|
|
let (base, roots) = folders("shares");
|
|
for p in [
|
|
r"\\.\pipe\pdfcraft-4003.pdf",
|
|
r"\\?\GLOBALROOT\Device\Null",
|
|
r"\\pdfcraft-no-such-host.invalid\share\a.pdf",
|
|
"//pdfcraft-no-such-host.invalid/share/a.pdf",
|
|
r"\\?\UNC\pdfcraft-no-such-host.invalid\share\a.pdf",
|
|
] {
|
|
let e = roots.check(p, Access::Read).unwrap_err();
|
|
assert!(e.starts_with("automation path rejected: outside the read root"), "{p}: {e}");
|
|
let e = roots.check(p, Access::Write).unwrap_err();
|
|
assert!(e.starts_with("automation path rejected: outside the write root"), "{p}: {e}");
|
|
}
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn a_sibling_with_the_same_prefix_is_outside() {
|
|
let (base, roots) = folders("prefix");
|
|
std::fs::write(base.join("in2/b.pdf"), "x").unwrap();
|
|
assert!(roots.check(&s(base.join("in2/b.pdf")), Access::Read).is_err(), "in2 is not inside in");
|
|
assert!(roots.check(&format!("{}2/c.pdf", s(base.join("in"))), Access::Read).is_err());
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn parent_traversal_is_resolved_or_refused() {
|
|
let (base, roots) = folders("dotdot");
|
|
let sep = std::path::MAIN_SEPARATOR;
|
|
let up = format!("{}{sep}..{sep}outside{sep}secret.pdf", s(base.join("in")));
|
|
assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root");
|
|
let back = format!("{}{sep}..{sep}a.pdf", s(base.join("in/sub")));
|
|
assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside");
|
|
let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.pdf", s(base.join("out")));
|
|
let e = roots.check(&missing, Access::Write).unwrap_err();
|
|
// Windows resolves `..` by name first (so does opening the file there): then it's outside.
|
|
assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn relative_paths_resolve_against_the_working_directory() {
|
|
let (base, roots) = folders("relative");
|
|
// The tests' working directory is the crate's folder: not inside the roots.
|
|
assert!(roots.check("a.pdf", Access::Read).unwrap_err().contains("outside the read root"));
|
|
assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty"));
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
|
|
let (base, _) = folders("missing");
|
|
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
|
|
assert!(e.starts_with("cannot open the automation read root"), "{e}");
|
|
let e = AutomationRoots::new(None, Some(&base.join("in/a.pdf"))).unwrap_err();
|
|
assert!(
|
|
e.starts_with("cannot open the automation write root")
|
|
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
|
|
"{e}"
|
|
);
|
|
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
|
|
assert!(AutomationRoots::new(None, None).unwrap().is_none());
|
|
// One root alone grants only its own access.
|
|
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
|
|
let e = read_only.check(&s(base.join("in/x.pdf")), Access::Write).unwrap_err();
|
|
assert!(e.contains("write authority is absent"), "{e}");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[test]
|
|
fn links_are_followed_before_comparing() {
|
|
let (base, roots) = folders("links");
|
|
#[cfg(unix)]
|
|
let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok();
|
|
#[cfg(windows)]
|
|
let made = |target: &Path, link: &Path| {
|
|
let made =
|
|
if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) };
|
|
made.is_ok()
|
|
};
|
|
#[cfg(not(any(unix, windows)))]
|
|
let made = |_: &Path, _: &Path| false;
|
|
// Windows needs Developer Mode or elevation to make links: nothing to test without them.
|
|
if !made(&base.join("outside/secret.pdf"), &base.join("in/leak.pdf")) {
|
|
eprintln!("skipped: symbolic links can't be made here");
|
|
let _ = std::fs::remove_dir_all(base);
|
|
return;
|
|
}
|
|
assert!(roots.check(&s(base.join("in/leak.pdf")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out");
|
|
assert!(made(&base.join("outside"), &base.join("out/away")));
|
|
let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err();
|
|
assert!(e.contains("outside the write root"), "a folder link out: {e}");
|
|
assert!(made(&base.join("outside/gone.pdf"), &base.join("out/dangling.png")));
|
|
let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err();
|
|
assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}");
|
|
// A link to a folder inside the root stays inside.
|
|
assert!(made(&base.join("in/sub"), &base.join("in/alias")));
|
|
assert_eq!(roots.check(&s(base.join("in/alias/x.pdf")), Access::Read), Ok(()));
|
|
// A root given through a link is the folder it leads to.
|
|
assert!(made(&base.join("in"), &base.join("in-link")));
|
|
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
|
|
assert_eq!(linked.check(&s(base.join("in/a.pdf")), Access::Read), Ok(()));
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn windows_ignores_case_and_refuses_devices() {
|
|
let (base, roots) = folders("case");
|
|
let upper = s(base.join("IN/A.PDF"));
|
|
assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}");
|
|
assert_eq!(roots.check(&s(base.join("In/Sub/New.pdf")), Access::Read), Ok(()));
|
|
let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err();
|
|
assert!(e.contains("reserved device names"), "{e}");
|
|
assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err());
|
|
assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(()));
|
|
let _ = std::fs::remove_dir_all(base);
|
|
}
|
|
|
|
/// Every file a tool reads or writes goes through `Automation::resolve`, which holds it to the
|
|
/// roots. A function that reaches for the file system without resolving a path first would
|
|
/// bypass them, so each use of `std::fs` or `File::open` must come after a `resolve` call in
|
|
/// the same function (or be one of the helpers that only ever get a resolved path).
|
|
#[test]
|
|
fn tools_do_not_touch_the_file_system_without_resolving_the_path() {
|
|
// Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its
|
|
// inputs and out_dir, walks only inside them (skipping links) and resolves every
|
|
// discovered file again before `process` reads it or `publish` writes it.
|
|
const RESOLVED_BY_CALLER: &[&str] =
|
|
&["write_atomic", "write_atomic_with", "read_input", "read_input_bytes", "read_review_sidecar", "process", "linked", "walk", "publish"];
|
|
let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
|
|
let mut files = vec![];
|
|
while let Some(dir) = dirs.pop() {
|
|
for entry in std::fs::read_dir(&dir).unwrap() {
|
|
let path = entry.unwrap().path();
|
|
if path.is_dir() {
|
|
if path.file_name().is_some_and(|n| !n.to_string_lossy().starts_with("tests")) {
|
|
dirs.push(path);
|
|
}
|
|
} else {
|
|
files.push(path);
|
|
}
|
|
}
|
|
}
|
|
let mut offenders = vec![];
|
|
for path in files {
|
|
if path.extension().is_none_or(|e| e != "rs")
|
|
|| path.file_name().is_some_and(|n| n == "file_access.rs" || n.to_string_lossy().starts_with("tests"))
|
|
{
|
|
continue;
|
|
}
|
|
let text = std::fs::read_to_string(&path).unwrap();
|
|
// Inline test modules (which build their own fixtures) come last in each file. A
|
|
// `#[cfg(test)]` on a `use` line or a `mod tests;` declaration doesn't start one.
|
|
let code = text
|
|
.match_indices("\n#[cfg(test)]\nmod ")
|
|
.find(|(at, m)| text[at + m.len()..].lines().next().is_some_and(|l| l.trim_end().ends_with('{')))
|
|
.map_or(text.as_str(), |(at, _)| &text[..at]);
|
|
let lines: Vec<&str> = code.lines().collect();
|
|
for (n, line) in lines.iter().enumerate() {
|
|
let trimmed = line.trim_start();
|
|
// A `use` line only names a helper; the call is checked where it is made.
|
|
if trimmed.starts_with("//")
|
|
|| trimmed.starts_with("use ")
|
|
|| (trimmed.starts_with("pub") && trimmed.contains(" use "))
|
|
|| !(trimmed.contains("std::fs")
|
|
|| trimmed.contains("File::open")
|
|
|| trimmed.contains("File::create")
|
|
|| trimmed.contains("OpenOptions")
|
|
|| trimmed.contains("read_input"))
|
|
{
|
|
continue;
|
|
}
|
|
let start = (0..=n).rev().find(|&i| lines[i].trim_start().contains("fn ") && lines[i].trim_start().ends_with('{')).unwrap_or(0);
|
|
let name = lines[start].split("fn ").nth(1).and_then(|r| r.split(['(', '<']).next()).unwrap_or("");
|
|
if !RESOLVED_BY_CALLER.contains(&name) && !lines[start..=n].iter().any(|l| l.contains("resolve")) {
|
|
offenders.push(format!("{}:{}: in `{name}`: {}", path.display(), n + 1, trimmed));
|
|
}
|
|
}
|
|
}
|
|
assert!(offenders.is_empty(), "resolve the path with `self.resolve` first: {offenders:#?}");
|
|
}
|
|
}
|